feat: implement sample and analysis store

This commit is contained in:
hermes 2026-07-28 19:51:32 +00:00
commit ce641749d2
13 changed files with 3086 additions and 3 deletions

553
src/store/analysis.rs Normal file
View file

@ -0,0 +1,553 @@
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(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(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);
}
}