553 lines
19 KiB
Rust
553 lines
19 KiB
Rust
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<String, Value>,
|
|
/// 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<InventoryFile>,
|
|
}
|
|
|
|
/// 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<Digest, StoreError> {
|
|
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<Vec<InventoryFile>, 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<AnalysisManifest, StoreError> {
|
|
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<PathBuf, StoreError> {
|
|
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<QuarantineOutcome, StoreError> {
|
|
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<Vec<InventoryFile>, 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<Digest, StoreError> {
|
|
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, StoreError> {
|
|
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<Vec<fs::DirEntry>, StoreError> {
|
|
let mut entries = fs::read_dir(path)
|
|
.map_err(|error| StoreError::io(path, error))?
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.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);
|
|
}
|
|
}
|