use std::{ collections::BTreeMap, fs::{self, File}, io::Read as _, path::{Component, Path, PathBuf}, str::FromStr as _, }; use serde::{Deserialize, Serialize}; use serde_json::Value; use sha2::{Digest as _, Sha256}; use crate::{domain::Digest, operation::AnalysisProfile}; use super::{ AnalysisLock, AnalysisStore, StoreError, StoreErrorKind, filesystem::{create_private_directory, random_id, sync_directory}, }; /// Complete immutable Analysis manifest. #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct AnalysisManifest { /// Manifest format version, exactly 1. pub manifest_version: u32, /// Completion marker, exactly `complete`. pub state: String, /// Staged Sample identity and byte length. pub sample: ManifestSample, /// Analysis Profile identity and compatibility facts. pub analysis_profile: AnalysisProfileManifest, /// Tool and invocation that completed the Analysis. pub created_by: CreatedBy, /// Fixed project name, path, and complete file inventory. pub project: AnalysisProject, } /// Sample facts recorded in an Analysis manifest. #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct ManifestSample { /// SHA-256 Sample identity. pub sha256: Digest, /// Exact Sample byte size. pub size_bytes: u64, } /// Compatibility facts recorded alongside the Profile digest. #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct AnalysisProfileManifest { /// Canonical Analysis Profile digest. pub sha256: Digest, /// Exact Ghidra version. pub ghidra_version: String, /// Rust/Java adapter protocol version. pub adapter_protocol_version: u32, /// Fully resolved Target Specification facts. pub target: BTreeMap, /// Digest of the fully resolved analyzer-option document. pub analyzer_options_sha256: Digest, } /// Creator provenance for one immutable Analysis. #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct CreatedBy { /// CLI version. pub ghidr_version: String, /// Canonical random invocation ID. pub invocation_id: String, /// RFC3339 UTC completion timestamp supplied by orchestration. pub completed_at: String, } /// Fixed project metadata and complete inventory. #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct AnalysisProject { /// Fixed project basename, exactly `analysis`. pub name: String, /// Fixed relative project directory, exactly `project`. pub path: String, /// Every regular file in bytewise relative-path order. pub files: Vec, } /// One exact project file inventory entry. #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct InventoryFile { /// UTF-8 relative path beneath `project`. pub path: String, /// Exact file length. pub size_bytes: u64, /// SHA-256 of exact file bytes. pub sha256: Digest, } /// Outcome of moving corrupt active Analysis data out of visibility. #[derive(Clone, Debug, Eq, PartialEq)] pub struct QuarantineOutcome { /// Unique retained quarantine directory. pub path: PathBuf, /// Required next decision after successful quarantine. pub decision: RebuildDecision, } /// Single-rebuild state machine primitive. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum RebuildDecision { /// One fresh build is permitted. RebuildOnce, /// Recovery was already attempted; surface corruption. FailCorrupt, } /// Returns the only permitted recovery action for the invocation state. #[must_use] pub const fn rebuild_decision(rebuild_already_attempted: bool) -> RebuildDecision { if rebuild_already_attempted { RebuildDecision::FailCorrupt } else { RebuildDecision::RebuildOnce } } /// Produces the canonical Profile digest after enforcing sorted unique options. pub fn analysis_profile_digest(profile: &AnalysisProfile) -> Result { if profile.profile_version != 1 { return Err(StoreError::new( StoreErrorKind::CorruptStore, "Analysis Profile version must be exactly 1", )); } validate_options(&profile.loader_options, "loader")?; validate_options(&profile.analyzer_options, "analyzer")?; let canonical = serde_json::to_value(profile) .and_then(|value| serde_json::to_vec(&value)) .map_err(|error| { StoreError::new( StoreErrorKind::CorruptStore, format!("Profile serialization failed: {error}"), ) })?; let digest = Sha256::digest(&canonical); digest_bytes(&digest) } /// Inventories a complete project tree in manifest bytewise path order. pub fn inventory_project(project: &Path) -> Result, StoreError> { reject_tree_root(project)?; inventory_tree(project, project) } /// Atomically writes the completed manifest into a private staged Analysis directory. pub fn write_analysis_manifest( staged_analysis: &Path, manifest: &AnalysisManifest, ) -> Result<(), StoreError> { super::filesystem::atomic_json_write( staged_analysis, &staged_analysis.join("manifest.json"), manifest, ) } fn validate_options( options: &[crate::operation::ProfileOption], kind: &str, ) -> Result<(), StoreError> { if options .windows(2) .any(|pair| pair[0].name.as_bytes() >= pair[1].name.as_bytes()) { return Err(StoreError::new( StoreErrorKind::CorruptStore, format!("{kind} options must be bytewise sorted with unique names"), )); } Ok(()) } /// Validates manifest identity and every regular project file without following symlinks. pub fn validate_analysis( analysis_directory: &Path, expected_sample: &Digest, expected_profile: &Digest, ) -> Result { reject_tree_root(analysis_directory)?; let manifest_path = analysis_directory.join("manifest.json"); require_regular(&manifest_path)?; let bytes = fs::read(&manifest_path).map_err(|error| StoreError::io(&manifest_path, error))?; let manifest: AnalysisManifest = serde_json::from_slice(&bytes).map_err(|error| { StoreError::at( StoreErrorKind::CorruptStore, &manifest_path, format!("invalid Analysis manifest: {error}"), ) })?; if manifest.manifest_version != 1 || manifest.state != "complete" || &manifest.sample.sha256 != expected_sample || &manifest.analysis_profile.sha256 != expected_profile || manifest.project.name != "analysis" || manifest.project.path != "project" { return Err(StoreError::at( StoreErrorKind::CorruptStore, &manifest_path, "Analysis manifest identity or fixed fields do not match its location", )); } validate_inventory(&analysis_directory.join("project"), &manifest.project.files)?; let mut root_entries = read_sorted(analysis_directory)?; let root_names: Vec<_> = root_entries .drain(..) .map(|entry| entry.file_name()) .collect(); if root_names != ["manifest.json", "project"].map(std::ffi::OsString::from) { return Err(StoreError::at( StoreErrorKind::CorruptStore, analysis_directory, "Analysis directory contains missing or unexpected entries", )); } Ok(manifest) } impl AnalysisStore { /// Validates and atomically promotes a staged complete Analysis. pub fn promote_analysis( &self, staged_analysis: &Path, sample: &Digest, profile: &Digest, lock: &AnalysisLock, ) -> Result { if !lock.guards_exclusive(sample, profile) { return Err(StoreError::new( StoreErrorKind::AnalysisBusy, "lock token does not guard the promoted Analysis identity", )); } validate_analysis(staged_analysis, sample, profile)?; let sample_directory = self.path("analyses").join(sample.as_str()); create_private_directory(&sample_directory)?; let destination = sample_directory.join(profile.as_str()); if fs::symlink_metadata(&destination).is_ok() { return Err(StoreError::at( StoreErrorKind::ImmutableConflict, &destination, "completed Analysis destination already exists", )); } fs::rename(staged_analysis, &destination) .map_err(|error| StoreError::io(&destination, error))?; sync_directory(&sample_directory)?; Ok(destination) } /// Atomically removes corrupt Analysis data from active visibility. pub fn quarantine_analysis( &self, sample: &Digest, profile: &Digest, lock: &AnalysisLock, rebuild_already_attempted: bool, ) -> Result { if !lock.guards_exclusive(sample, profile) { return Err(StoreError::new( StoreErrorKind::AnalysisBusy, "lock token does not guard the quarantined Analysis identity", )); } let active = self .path("analyses") .join(sample.as_str()) .join(profile.as_str()); let parent = self .path("quarantine") .join(sample.as_str()) .join(profile.as_str()); create_private_directory(&parent)?; let destination = parent.join(random_id()?); fs::rename(&active, &destination).map_err(|error| StoreError::io(&active, error))?; sync_directory(&parent)?; Ok(QuarantineOutcome { path: destination, decision: rebuild_decision(rebuild_already_attempted), }) } } fn validate_inventory(project: &Path, recorded: &[InventoryFile]) -> Result<(), StoreError> { reject_tree_root(project)?; if recorded .windows(2) .any(|pair| pair[0].path.as_bytes() >= pair[1].path.as_bytes()) { return Err(StoreError::at( StoreErrorKind::CorruptStore, project, "project inventory is not bytewise sorted and unique", )); } let actual = inventory_tree(project, project)?; if actual != recorded { return Err(StoreError::at( StoreErrorKind::CorruptStore, project, "project inventory size, digest, or membership mismatch", )); } Ok(()) } fn inventory_tree(root: &Path, directory: &Path) -> Result, StoreError> { let mut inventory = Vec::new(); for entry in read_sorted(directory)? { let path = entry.path(); let metadata = fs::symlink_metadata(&path).map_err(|error| StoreError::io(&path, error))?; if metadata.file_type().is_symlink() { return Err(StoreError::at( StoreErrorKind::CorruptStore, &path, "symlink in Analysis project", )); } if metadata.is_dir() { inventory.extend(inventory_tree(root, &path)?); } else if metadata.is_file() { let relative = path.strip_prefix(root).map_err(|_| { StoreError::at( StoreErrorKind::CorruptStore, &path, "project path escaped inventory root", ) })?; if relative .components() .any(|component| !matches!(component, Component::Normal(_))) { return Err(StoreError::at( StoreErrorKind::CorruptStore, &path, "invalid inventory path", )); } let Some(relative) = relative.to_str() else { return Err(StoreError::at( StoreErrorKind::CorruptStore, &path, "non-UTF-8 project filename", )); }; inventory.push(InventoryFile { path: relative.to_owned(), size_bytes: metadata.len(), sha256: hash_file(&path)?, }); } else { return Err(StoreError::at( StoreErrorKind::CorruptStore, &path, "special file in Analysis project", )); } } inventory.sort_by(|left, right| left.path.as_bytes().cmp(right.path.as_bytes())); Ok(inventory) } pub(crate) fn hash_file(path: &Path) -> Result { let mut file = File::open(path).map_err(|error| StoreError::io(path, error))?; let mut hasher = Sha256::new(); let mut buffer = vec![0_u8; 1024 * 1024]; loop { let count = file .read(&mut buffer) .map_err(|error| StoreError::io(path, error))?; if count == 0 { break; } hasher.update(&buffer[..count]); } digest_bytes(&hasher.finalize()) } pub(crate) fn digest_bytes(bytes: &[u8]) -> Result { Digest::from_str(&hex::encode(bytes)).map_err(|error| { StoreError::new( StoreErrorKind::Io, format!("computed digest was invalid: {error}"), ) }) } fn reject_tree_root(path: &Path) -> Result<(), StoreError> { let metadata = fs::symlink_metadata(path).map_err(|error| StoreError::io(path, error))?; if !metadata.is_dir() || metadata.file_type().is_symlink() { return Err(StoreError::at( StoreErrorKind::CorruptStore, path, "expected ordinary directory", )); } Ok(()) } fn require_regular(path: &Path) -> Result<(), StoreError> { let metadata = fs::symlink_metadata(path).map_err(|error| StoreError::io(path, error))?; if !metadata.is_file() || metadata.file_type().is_symlink() { return Err(StoreError::at( StoreErrorKind::CorruptStore, path, "expected ordinary file", )); } Ok(()) } fn read_sorted(path: &Path) -> Result, StoreError> { let mut entries = fs::read_dir(path) .map_err(|error| StoreError::io(path, error))? .collect::, _>>() .map_err(|error| StoreError::io(path, error))?; entries.sort_by_key(fs::DirEntry::file_name); Ok(entries) } #[cfg(test)] #[allow(clippy::expect_used)] mod tests { use std::{collections::BTreeMap, fs, str::FromStr as _}; use tempfile::TempDir; use super::{ AnalysisManifest, AnalysisProfileManifest, AnalysisProject, CreatedBy, InventoryFile, ManifestSample, RebuildDecision, hash_file, rebuild_decision, validate_analysis, }; use crate::{ domain::Digest, store::{AnalysisLock, AnalysisStore, StoreEnvironment, resolve_store}, }; fn digest(byte: char) -> Digest { Digest::from_str(&byte.to_string().repeat(64)).expect("digest") } fn write_analysis(root: &Path, sample: &Digest, profile: &Digest) { let project = root.join("project"); fs::create_dir_all(&project).expect("project"); let project_file = project.join("analysis.gpr"); fs::write(&project_file, b"project").expect("project file"); let manifest = AnalysisManifest { manifest_version: 1, state: "complete".to_owned(), sample: ManifestSample { sha256: sample.clone(), size_bytes: 7, }, analysis_profile: AnalysisProfileManifest { sha256: profile.clone(), ghidra_version: "12.1.2".to_owned(), adapter_protocol_version: 1, target: BTreeMap::new(), analyzer_options_sha256: digest('c'), }, created_by: CreatedBy { ghidr_version: "0.1.0".to_owned(), invocation_id: "0".repeat(32), completed_at: "2026-01-01T00:00:00Z".to_owned(), }, project: AnalysisProject { name: "analysis".to_owned(), path: "project".to_owned(), files: vec![InventoryFile { path: "analysis.gpr".to_owned(), size_bytes: 7, sha256: hash_file(&project_file).expect("hash"), }], }, }; fs::write( root.join("manifest.json"), serde_json::to_vec(&manifest).expect("json"), ) .expect("manifest"); } use std::path::Path; #[test] fn complete_inventory_validates_and_mutation_is_corruption() { let temp = TempDir::new().expect("temp"); let sample = digest('a'); let profile = digest('b'); write_analysis(temp.path(), &sample, &profile); validate_analysis(temp.path(), &sample, &profile).expect("valid"); fs::write(temp.path().join("project/analysis.gpr"), b"changed").expect("change"); assert!(validate_analysis(temp.path(), &sample, &profile).is_err()); } #[cfg(unix)] #[test] fn inventory_rejects_symlink_attack() { use std::os::unix::fs::symlink; let temp = TempDir::new().expect("temp"); let sample = digest('a'); let profile = digest('b'); write_analysis(temp.path(), &sample, &profile); symlink("analysis.gpr", temp.path().join("project/escape")).expect("symlink"); assert!(validate_analysis(temp.path(), &sample, &profile).is_err()); } #[test] fn recovery_allows_exactly_one_rebuild() { assert_eq!(rebuild_decision(false), RebuildDecision::RebuildOnce); assert_eq!(rebuild_decision(true), RebuildDecision::FailCorrupt); } #[test] fn promotion_is_atomic_and_corruption_moves_to_quarantine() { let temp = TempDir::new().expect("temp"); let environment = StoreEnvironment { ghidr_store: Some(temp.path().join("store").into_os_string()), ..StoreEnvironment::default() }; let store = AnalysisStore::initialize(resolve_store(None, &environment).expect("resolve")) .expect("store"); let sample = digest('a'); let profile = digest('b'); let staged = store.root().join("staging/build/analysis"); fs::create_dir_all(&staged).expect("staging"); write_analysis(&staged, &sample, &profile); let lock = AnalysisLock::acquire(&store, &sample, &profile).expect("lock"); let active = store .promote_analysis(&staged, &sample, &profile, &lock) .expect("promote"); assert!(!staged.exists()); validate_analysis(&active, &sample, &profile).expect("active"); fs::write(active.join("project/analysis.gpr"), b"corrupt").expect("corrupt"); assert!(validate_analysis(&active, &sample, &profile).is_err()); let outcome = store .quarantine_analysis(&sample, &profile, &lock, false) .expect("quarantine"); assert!(!active.exists()); assert!(outcome.path.exists()); assert_eq!(outcome.decision, RebuildDecision::RebuildOnce); } }