feat: implement sample and analysis store
This commit is contained in:
parent
170ca32f58
commit
ce641749d2
13 changed files with 3086 additions and 3 deletions
553
src/store/analysis.rs
Normal file
553
src/store/analysis.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue