use anyhow::{Context, Result};
use assay_evidence::diff::engine::diff_bundles;
use assay_evidence::VerifyLimits;
use clap::Args;
use std::fs::File;
#[derive(Debug, Args, Clone)]
pub struct DiffArgs {
#[arg(value_name = "BASELINE")]
pub baseline: std::path::PathBuf,
#[arg(value_name = "CANDIDATE")]
pub candidate: Option<std::path::PathBuf>,
#[arg(long, default_value = "human")]
pub format: String,
#[arg(long)]
pub baseline_dir: Option<std::path::PathBuf>,
#[arg(long)]
pub key: Option<String>,
#[arg(long)]
pub write_baseline: bool,
#[arg(long)]
pub update_baseline: bool,
}
pub fn cmd_diff(args: DiffArgs) -> Result<i32> {
if args.write_baseline || args.update_baseline {
return cmd_write_baseline(&args);
}
let (baseline_path, candidate_path) = resolve_paths(&args)?;
let baseline_file = File::open(&baseline_path)
.with_context(|| format!("failed to open baseline {}", baseline_path.display()))?;
let candidate_file = File::open(&candidate_path)
.with_context(|| format!("failed to open candidate {}", candidate_path.display()))?;
let limits = VerifyLimits::default();
let report = diff_bundles(baseline_file, candidate_file, limits)?;
match args.format.as_str() {
"json" => {
println!("{}", serde_json::to_string_pretty(&report)?);
}
_ => {
eprintln!("Assay Evidence Diff");
eprintln!("===================");
eprintln!(
"Baseline: {} ({} events)",
report.baseline.run_id, report.baseline.event_count
);
eprintln!(
"Candidate: {} ({} events)",
report.candidate.run_id, report.candidate.event_count
);
eprintln!("Event count delta: {:+}", report.summary.event_count_delta);
eprintln!();
print_diff_set("Network", &report.network);
print_diff_set("Filesystem", &report.filesystem);
print_diff_set("Processes", &report.processes);
if report.is_empty() {
eprintln!("No differences found.");
}
}
}
Ok(0)
}
fn cmd_write_baseline(args: &DiffArgs) -> Result<i32> {
let dir = args
.baseline_dir
.as_ref()
.context("--write-baseline requires --baseline-dir")?;
let key = args
.key
.as_ref()
.context("--write-baseline requires --key")?;
validate_baseline_key(key)?;
std::fs::create_dir_all(dir)
.with_context(|| format!("failed to create baseline dir {}", dir.display()))?;
let canonical_dir = dir
.canonicalize()
.with_context(|| format!("failed to canonicalize baseline dir {}", dir.display()))?;
let canonical_target = canonical_dir.join(format!("{}.tar.gz", key));
if !canonical_target.starts_with(&canonical_dir) {
anyhow::bail!("baseline key '{}' would escape baseline directory", key);
}
if canonical_target.exists() && !args.update_baseline {
anyhow::bail!(
"Baseline already exists at {}. Use --update-baseline to overwrite.",
canonical_target.display()
);
}
let candidate_path = &args.baseline;
let candidate_file = File::open(candidate_path)
.with_context(|| format!("failed to open candidate {}", candidate_path.display()))?;
let _ = assay_evidence::bundle::BundleReader::open(candidate_file)
.context("candidate bundle verification failed — refusing to write as baseline")?;
let tmp_path = canonical_dir.join(format!(".{}.tar.gz.tmp", key));
std::fs::copy(candidate_path, &tmp_path).with_context(|| {
format!(
"failed to copy {} to temp {}",
candidate_path.display(),
tmp_path.display()
)
})?;
std::fs::rename(&tmp_path, &canonical_target).with_context(|| {
format!(
"failed to rename {} to {}",
tmp_path.display(),
canonical_target.display()
)
})?;
eprintln!("Baseline written to {}", canonical_target.display());
Ok(0)
}
fn validate_baseline_key(key: &str) -> Result<()> {
if key.is_empty() {
anyhow::bail!("baseline key must not be empty");
}
if key.contains('/') || key.contains('\\') {
anyhow::bail!(
"baseline key '{}' contains path separators — this is not allowed",
key
);
}
if key.contains("..") {
anyhow::bail!(
"baseline key '{}' contains '..' — path traversal is not allowed",
key
);
}
if key.starts_with('.') {
anyhow::bail!(
"baseline key '{}' starts with '.' — hidden files are not allowed",
key
);
}
if key.chars().any(|c| c.is_control()) {
anyhow::bail!("baseline key contains control characters — this is not allowed");
}
Ok(())
}
fn resolve_paths(args: &DiffArgs) -> Result<(std::path::PathBuf, std::path::PathBuf)> {
if let (Some(dir), Some(key)) = (&args.baseline_dir, &args.key) {
validate_baseline_key(key)?;
let canonical_dir = dir
.canonicalize()
.with_context(|| format!("failed to canonicalize baseline dir {}", dir.display()))?;
let baseline_path = canonical_dir.join(format!("{}.tar.gz", key));
if !baseline_path.starts_with(&canonical_dir) {
anyhow::bail!("baseline key '{}' would escape baseline directory", key);
}
let candidate_path = args.baseline.clone();
Ok((baseline_path, candidate_path))
} else {
let candidate = args
.candidate
.as_ref()
.context("candidate bundle path is required (or use --baseline-dir + --key)")?;
Ok((args.baseline.clone(), candidate.clone()))
}
}
fn print_diff_set(category: &str, diff: &assay_evidence::diff::DiffSet) {
if diff.is_empty() {
return;
}
eprintln!("{}:", category);
for added in &diff.added {
eprintln!(" + {}", added);
}
for removed in &diff.removed {
eprintln!(" - {}", removed);
}
eprintln!();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_baseline_key_rejects_path_traversal() {
assert!(validate_baseline_key("../pwn").is_err());
assert!(validate_baseline_key("..").is_err());
assert!(validate_baseline_key("foo/../bar").is_err());
}
#[test]
fn test_validate_baseline_key_rejects_path_separators() {
assert!(validate_baseline_key("a/b").is_err());
assert!(validate_baseline_key("a\\b").is_err());
assert!(validate_baseline_key("/absolute").is_err());
assert!(validate_baseline_key("\\windows").is_err());
}
#[test]
fn test_validate_baseline_key_rejects_hidden_files() {
assert!(validate_baseline_key(".hidden").is_err());
assert!(validate_baseline_key(".").is_err());
}
#[test]
fn test_validate_baseline_key_rejects_empty() {
assert!(validate_baseline_key("").is_err());
}
#[test]
fn test_validate_baseline_key_rejects_control_chars() {
assert!(validate_baseline_key("foo\x00bar").is_err());
assert!(validate_baseline_key("foo\nbar").is_err());
}
#[test]
fn test_validate_baseline_key_accepts_valid() {
assert!(validate_baseline_key("my-baseline").is_ok());
assert!(validate_baseline_key("test_v2").is_ok());
assert!(validate_baseline_key("2024-01-15-nightly").is_ok());
}
}