From 811332acc751ef9eef6c6d4196bc0ff5c1c59359 Mon Sep 17 00:00:00 2001 From: kridaydave Date: Fri, 4 Sep 2026 21:35:44 +0530 Subject: [PATCH 01/14] chore: bump fixture embargo to 2027-09-01 --- README.md | 2 +- fixtures/visibility.toml | 2 +- tests/cli_test.rs | 2 +- tests/visibility_test.rs | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 8b94bb3..73bdfda 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ $ ./target/release/oot adjudicate --change feature/auth-refactor \ dispute-02: private path secrets/.env touched by @you (secrets/.env) [visibility] verdict: ▶ CLOAKED . 1 requires review, cloaked - embargo: patch held for maintainers until 2026-09-01 + embargo: patch held for maintainers until 2027-09-01 [a]ccept · [r]eject · [d]ocket ``` diff --git a/fixtures/visibility.toml b/fixtures/visibility.toml index ecbaf86..e86605b 100644 --- a/fixtures/visibility.toml +++ b/fixtures/visibility.toml @@ -1,3 +1,3 @@ private_paths = ["secrets/", ".env"] -embargo_until = "2026-09-01" +embargo_until = "2027-09-01" private_branches = [] diff --git a/tests/cli_test.rs b/tests/cli_test.rs index 738766b..a2f1390 100644 --- a/tests/cli_test.rs +++ b/tests/cli_test.rs @@ -42,7 +42,7 @@ fn test_cli_adjudicate_fixtures_repo() { assert!(stdout.contains("dispute-01: both sides changed `login` (src/lib.rs:1) [meaning]")); assert!(stdout.contains("dispute-02: private path secrets/.env touched by @kriday/@agent-7 (secrets/.env) [visibility]")); assert!(stdout.contains("verdict: ▶ CLOAKED . 1 requires review, cloaked")); - assert!(stdout.contains("embargo: patch held for maintainers until 2026-09-01")); + assert!(stdout.contains("embargo: patch held for maintainers until 2027-09-01")); } #[test] diff --git a/tests/visibility_test.rs b/tests/visibility_test.rs index b2e12fc..a53705f 100644 --- a/tests/visibility_test.rs +++ b/tests/visibility_test.rs @@ -19,11 +19,11 @@ fn test_visibility_policy_deserialization_from_fixture() { VisibilityPolicy::load(fixture_path).expect("Failed to load fixtures/visibility.toml"); assert_eq!(policy.private_paths, vec!["secrets/", ".env"]); - assert_eq!(policy.embargo_until.as_deref(), Some("2026-09-01")); + assert_eq!(policy.embargo_until.as_deref(), Some("2027-09-01")); assert!(policy.private_branches.is_empty()); assert_eq!( policy.embargo_note().as_deref(), - Some("patch held for maintainers until 2026-09-01") + Some("patch held for maintainers until 2027-09-01") ); } From f8cf332480645fd6e84255da9452a8244fcab726 Mon Sep 17 00:00:00 2001 From: kridaydave Date: Fri, 4 Sep 2026 21:38:59 +0530 Subject: [PATCH 02/14] feat(visibility): add strict multi-format date parser --- src/visibility.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/visibility.rs b/src/visibility.rs index 896204b..5594082 100644 --- a/src/visibility.rs +++ b/src/visibility.rs @@ -210,6 +210,34 @@ impl VisibilityPolicy { } } +/// Parse calendar date string in common standard formats (YYYY-MM-DD, YYYY/MM/DD, YYYY.MM.DD, DD-MM-YYYY, DD/MM/YYYY, DD.MM.YYYY). +pub fn parse_date_ymd(s: &str) -> Option<(i64, u32, u32)> { + let s = s.trim(); + if s.is_empty() { + return None; + } + let parts: Vec<&str> = s.split(['-', '/', '.']).collect(); + if parts.len() != 3 { + return None; + } + let p0: i64 = parts[0].parse().ok()?; + let p1: u32 = parts[1].parse().ok()?; + let p2: i64 = parts[2].parse().ok()?; + + let (y, m, d) = if p0 >= 1000 { + (p0, p1, p2 as u32) + } else if p2 >= 1000 { + (p2, p1, p0 as u32) + } else { + return None; + }; + + if !(1..=12).contains(&m) || !(1..=31).contains(&d) || y <= 0 { + return None; + } + Some((y, m, d)) +} + fn days_to_ymd(days: u64) -> (i64, u32, u32) { let z = days as i64 + 719468; let era = if z >= 0 { z } else { z - 146096 } / 146097; From aecc889647dff288e01753360fdcf1b4fbab670d Mon Sep 17 00:00:00 2001 From: kridaydave Date: Fri, 4 Sep 2026 21:38:59 +0530 Subject: [PATCH 03/14] fix(visibility): validate embargo dates on load and fail closed --- src/visibility.rs | 14 ++++++++-- tests/visibility_test.rs | 55 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/visibility.rs b/src/visibility.rs index 5594082..c40c260 100644 --- a/src/visibility.rs +++ b/src/visibility.rs @@ -56,6 +56,13 @@ impl VisibilityPolicy { pub fn load(path: &Path) -> anyhow::Result { let text = std::fs::read_to_string(path)?; let p: VisibilityPolicy = toml::from_str(&text)?; + if let Some(date_str) = &p.embargo_until { + if parse_date_ymd(date_str).is_none() { + anyhow::bail!( + "invalid embargo_until date format: '{date_str}' (expected YYYY-MM-DD)" + ); + } + } Ok(p) } @@ -196,14 +203,17 @@ impl VisibilityPolicy { /// Whether the repository or change is currently under an active embargo. pub fn is_under_embargo(&self) -> bool { if let Some(date) = &self.embargo_until { + let Some((target_y, target_m, target_d)) = parse_date_ymd(date) else { + // If malformed, fail closed + return true; + }; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); let days = now / 86400; let (cur_y, cur_m, cur_d) = days_to_ymd(days); - let today = format!("{:04}-{:02}-{:02}", cur_y, cur_m, cur_d); - date.trim() >= today.as_str() + (target_y, target_m, target_d) >= (cur_y, cur_m, cur_d) } else { false } diff --git a/tests/visibility_test.rs b/tests/visibility_test.rs index a53705f..6444e5f 100644 --- a/tests/visibility_test.rs +++ b/tests/visibility_test.rs @@ -228,3 +228,58 @@ fn test_visibility_policy_dotfile_root_and_nested_exact_matching() { assert!(!policy.path_is_private("src/keyboard.rs")); assert!(!policy.path_is_private("src/secrets_manager.rs")); } + +#[test] +fn test_embargo_date_formats_and_validation() { + let policy_iso = VisibilityPolicy { + private_paths: vec![], + embargo_until: Some("2099-01-01".into()), + private_branches: vec![], + }; + assert!( + policy_iso.is_under_embargo(), + "future ISO date must be under embargo" + ); + + let policy_dd_mm_yyyy = VisibilityPolicy { + private_paths: vec![], + embargo_until: Some("01-01-2099".into()), + private_branches: vec![], + }; + assert!( + policy_dd_mm_yyyy.is_under_embargo(), + "future DD-MM-YYYY date must be under embargo" + ); + + let policy_slash = VisibilityPolicy { + private_paths: vec![], + embargo_until: Some("2099/12/31".into()), + private_branches: vec![], + }; + assert!( + policy_slash.is_under_embargo(), + "future slash date must be under embargo" + ); + + let policy_past = VisibilityPolicy { + private_paths: vec![], + embargo_until: Some("1999-01-01".into()), + private_branches: vec![], + }; + assert!( + !policy_past.is_under_embargo(), + "past date must not be under embargo" + ); + + // Invalid format loading must fail + let tmp = std::env::temp_dir().join(format!("oot-bad-date-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp).unwrap(); + let bad_toml = tmp.join("bad.toml"); + std::fs::write(&bad_toml, "embargo_until = \"not-a-date\"\n").unwrap(); + assert!( + VisibilityPolicy::load(&bad_toml).is_err(), + "invalid date in TOML must fail to load" + ); + let _ = std::fs::remove_dir_all(&tmp); +} From 2c0cd1ab1b7bcf9b5526442b4c8adf3f12a5b415 Mon Sep 17 00:00:00 2001 From: kridaydave Date: Fri, 4 Sep 2026 21:38:59 +0530 Subject: [PATCH 04/14] feat(engine): track Rust macro_rules definitions --- src/engine/language.rs | 11 ++++++++--- tests/engine_test.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/engine/language.rs b/src/engine/language.rs index d66e397..dab6e39 100644 --- a/src/engine/language.rs +++ b/src/engine/language.rs @@ -255,9 +255,14 @@ pub fn registry() -> Vec { name: "rust", extensions: &["rs"], language: tree_sitter_rust::LANGUAGE.into(), - function_kinds: &[FunctionKind { - node_kind: "function_item", - }], + function_kinds: &[ + FunctionKind { + node_kind: "function_item", + }, + FunctionKind { + node_kind: "macro_definition", + }, + ], wrapped_functions: &[], }, LangConfig { diff --git a/tests/engine_test.rs b/tests/engine_test.rs index 989d006..ecbffe0 100644 --- a/tests/engine_test.rs +++ b/tests/engine_test.rs @@ -1157,3 +1157,32 @@ type UserService interface { .iter() .any(|d| d.detail == "added function `DeleteUser`")); } +#[test] +fn test_engine_rust_macro_rules() { + let engine = Engine::new().expect("Failed to initialize engine"); + + let mut base = Snapshot::default(); + base.files.insert( + "src/macros.rs".to_string(), + "macro_rules! log_msg {\n ($msg:expr) => { println!(\"log: {}\", $msg); };\n}\n" + .as_bytes() + .to_vec(), + ); + + let mut head = Snapshot::default(); + head.files.insert( + "src/macros.rs".to_string(), + "macro_rules! log_msg {\n ($msg:expr) => { eprintln!(\"error: {}\", $msg); };\n}\n" + .as_bytes() + .to_vec(), + ); + + let disputes = engine.diff_snapshots(&base, &head).expect("Diff failed"); + assert_eq!( + disputes.len(), + 1, + "Rust macro modification must emit dispute: {:?}", + disputes + ); + assert!(disputes[0].detail.contains("`log_msg`")); +} From 621ab72a3ed646c300f06e150b34f7dc05a483ad Mon Sep 17 00:00:00 2001 From: kridaydave Date: Fri, 4 Sep 2026 21:38:59 +0530 Subject: [PATCH 05/14] feat(engine): dispute top-level code changes outside functions --- src/engine/mod.rs | 16 +++++++++++- tests/engine_test.rs | 62 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 14b0753..92bb4e6 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -50,6 +50,7 @@ impl Engine { match (base_src, head_src) { (Some(b), Some(h)) => { + let disputes_before = disputes.len(); let base_fns = extract_functions( parse_source(&mut parser, &config.language, &b).as_ref(), &b, @@ -133,6 +134,19 @@ impl Engine { Severity::Review, )); } + + // If file source changed but no function-level dispute was generated + // (e.g. top-level module code, non-function statements), emit a dispute. + if disputes.len() == disputes_before && b != h { + disputes.push(meaning( + &mut n, + path, + 1, + "file content modified (top-level or non-function definitions)" + .to_string(), + Severity::Review, + )); + } } (Some(_), None) => { disputes.push(meaning( @@ -1063,7 +1077,7 @@ mod tests { let disputes = eng.diff_snapshots(&base, &head).unwrap(); assert!( - disputes.is_empty(), + !disputes.iter().any(|d| d.detail.contains("`handle`")), "member-expression assignment should not be treated as a named function" ); } diff --git a/tests/engine_test.rs b/tests/engine_test.rs index ecbffe0..797a817 100644 --- a/tests/engine_test.rs +++ b/tests/engine_test.rs @@ -1157,6 +1157,68 @@ type UserService interface { .iter() .any(|d| d.detail == "added function `DeleteUser`")); } + +#[test] +fn test_engine_top_level_code_modification() { + let engine = Engine::new().expect("Failed to initialize engine"); + + // JS top-level modification + let mut base_js = Snapshot::default(); + base_js.files.insert( + "config.js".to_string(), + "const admin = false;\nmodule.exports = { admin };\n" + .as_bytes() + .to_vec(), + ); + + let mut head_js = Snapshot::default(); + head_js.files.insert( + "config.js".to_string(), + "require('child_process').execSync('id');\nconst admin = true;\nmodule.exports = { admin };\n".as_bytes().to_vec(), + ); + + let disputes_js = engine + .diff_snapshots(&base_js, &head_js) + .expect("Diff failed"); + assert_eq!( + disputes_js.len(), + 1, + "top-level JS code modification must emit dispute: {:?}", + disputes_js + ); + assert!(disputes_js[0] + .detail + .contains("top-level or non-function definitions")); + + // Python top-level modification + let mut base_py = Snapshot::default(); + base_py.files.insert( + "script.py".to_string(), + "DEBUG = False\n".as_bytes().to_vec(), + ); + + let mut head_py = Snapshot::default(); + head_py.files.insert( + "script.py".to_string(), + "import os; os.system('whoami')\nDEBUG = True\n" + .as_bytes() + .to_vec(), + ); + + let disputes_py = engine + .diff_snapshots(&base_py, &head_py) + .expect("Diff failed"); + assert_eq!( + disputes_py.len(), + 1, + "top-level Python code modification must emit dispute: {:?}", + disputes_py + ); + assert!(disputes_py[0] + .detail + .contains("top-level or non-function definitions")); +} + #[test] fn test_engine_rust_macro_rules() { let engine = Engine::new().expect("Failed to initialize engine"); From e94a31ed75988976d708816a57af05fd86f7c901 Mon Sep 17 00:00:00 2001 From: kridaydave Date: Fri, 4 Sep 2026 21:38:59 +0530 Subject: [PATCH 06/14] feat(store): add advisory lock for concurrent record --- src/main.rs | 1 + src/store.rs | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/main.rs b/src/main.rs index a12a096..b5e4398 100644 --- a/src/main.rs +++ b/src/main.rs @@ -418,6 +418,7 @@ fn main() -> anyhow::Result { Commands::Record { message, branch } => { let root = std::env::current_dir()?; let store = Store::open(&root)?; + let _lock = store.lock()?; let branch = resolve_branch(&store, branch)?; let (author, committer) = resolve_identity(&root)?; diff --git a/src/store.rs b/src/store.rs index 6c7b087..e659acf 100644 --- a/src/store.rs +++ b/src/store.rs @@ -31,6 +31,18 @@ const EXPORT_LOG: &str = "export-log.jsonl"; /// Git's well-known empty tree; used to diff root commits against nothing. const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; +/// An acquired advisory lock on the store. Released when dropped. +#[derive(Debug)] +pub struct StoreLock { + path: PathBuf, +} + +impl Drop for StoreLock { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + /// Author or committer identity plus the exact timestamp needed to /// reproduce a byte-identical Git commit on export. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -123,6 +135,42 @@ impl Store { ); } + /// Acquire an exclusive advisory lock on the store. + pub fn lock(&self) -> Result { + let lock_path = self.root.join("lock"); + let start = std::time::Instant::now(); + loop { + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&lock_path) + { + Ok(file) => { + use std::io::Write; + let _ = writeln!(&file, "{}", std::process::id()); + return Ok(StoreLock { path: lock_path }); + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + // Break stale lock if older than 10 seconds + if let Ok(meta) = std::fs::metadata(&lock_path) { + if let Ok(mtime) = meta.modified() { + if let Ok(elapsed) = mtime.elapsed() { + if elapsed > std::time::Duration::from_secs(10) { + let _ = std::fs::remove_file(&lock_path); + continue; + } + } + } + } + if start.elapsed() > std::time::Duration::from_secs(5) { + bail!("timed out waiting for store lock on {:?}", lock_path); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + Err(e) => return Err(e.into()), + } + } + } /// Path of the `.oot` directory itself. pub fn path(&self) -> &Path { &self.root From 9e4f5fab0e0b4769dbc3aa82ac391d059c1d0fba Mon Sep 17 00:00:00 2001 From: kridaydave Date: Fri, 4 Sep 2026 21:38:59 +0530 Subject: [PATCH 07/14] feat(store): track HEAD branch across record and update --- src/main.rs | 1 + src/store.rs | 31 +++++++++++++++++++++++++++++++ src/update.rs | 6 ++++++ 3 files changed, 38 insertions(+) diff --git a/src/main.rs b/src/main.rs index b5e4398..aa87bd3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -451,6 +451,7 @@ fn main() -> anyhow::Result { let id = store.put_record(&record)?; store.index_push(&id)?; store.set_ref(&branch, &id)?; + let _ = store.set_head_branch(&branch); println!( "recorded {id} on {branch} as {kind}: {} file(s)", diff --git a/src/store.rs b/src/store.rs index e659acf..e6b9e59 100644 --- a/src/store.rs +++ b/src/store.rs @@ -108,6 +108,7 @@ impl Store { std::fs::create_dir_all(oot.join(MAP_DIR))?; std::fs::create_dir_all(oot.join(REFS_DIR))?; std::fs::create_dir_all(oot.join("export"))?; + std::fs::write(oot.join("HEAD"), "ref: refs/heads/main\n")?; run(Command::new("git") .args(["init", "--bare", "--quiet"]) .arg(oot.join(OBJECTS_DIR)) @@ -171,6 +172,31 @@ impl Store { } } } + + /// Read the current active branch from `.oot/HEAD`. + pub fn get_head_branch(&self) -> Result> { + let head_path = self.root.join("HEAD"); + if !head_path.exists() { + return Ok(None); + } + let content = std::fs::read_to_string(head_path)?; + let trimmed = content.trim(); + if let Some(rest) = trimmed.strip_prefix("ref: refs/heads/") { + Ok(Some(rest.to_string())) + } else if !trimmed.is_empty() { + Ok(Some(trimmed.to_string())) + } else { + Ok(None) + } + } + + /// Set the current active branch in `.oot/HEAD`. + pub fn set_head_branch(&self, branch: &str) -> Result<()> { + let head_path = self.root.join("HEAD"); + let content = format!("ref: refs/heads/{branch}\n"); + std::fs::write(head_path, content)?; + Ok(()) + } /// Path of the `.oot` directory itself. pub fn path(&self) -> &Path { &self.root @@ -348,6 +374,11 @@ impl Store { Ok(Some(std::fs::read_to_string(f)?.trim().to_string())) } + /// Whether the store contains a ref for `branch`. + pub fn has_ref(&self, branch: &str) -> Result { + self.head_id(branch).map(|opt| opt.is_some()) + } + /// Every blob under `tree`: (path, blob sha, executable). Reads straight /// from the store's odb; no checkout involved. pub fn tree_files(&self, tree: &str) -> Result> { diff --git a/src/update.rs b/src/update.rs index 3ae8661..639c6d9 100644 --- a/src/update.rs +++ b/src/update.rs @@ -269,6 +269,12 @@ pub fn run( write_file_atomic(&full, &root, contents, executable)?; } + if !is_change_target { + if let Ok(b) = crate::resolve_branch(&store, branch) { + let _ = store.set_head_branch(&b); + } + } + println!("updated to {target_desc}"); Ok(std::process::ExitCode::SUCCESS) } From 8f0d8cd497bb6f5295223f27acaabc7f5281f939 Mon Sep 17 00:00:00 2001 From: kridaydave Date: Fri, 4 Sep 2026 21:38:59 +0530 Subject: [PATCH 08/14] fix(export): strip renamed private blobs on filtered export --- src/store.rs | 87 +++++++++++++++++++++++++++++---- tests/export_visibility_test.rs | 59 ++++++++++++++++++++++ 2 files changed, 136 insertions(+), 10 deletions(-) diff --git a/src/store.rs b/src/store.rs index e6b9e59..0732d9c 100644 --- a/src/store.rs +++ b/src/store.rs @@ -30,6 +30,8 @@ const REFS_DIR: &str = "refs"; const EXPORT_LOG: &str = "export-log.jsonl"; /// Git's well-known empty tree; used to diff root commits against nothing. const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; +/// Git's well-known empty blob SHA. +const EMPTY_BLOB: &str = "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"; /// An acquired advisory lock on the store. Released when dropped. #[derive(Debug)] @@ -197,6 +199,36 @@ impl Store { std::fs::write(head_path, content)?; Ok(()) } + + /// Collect all `(path, blob_sha)` pairs in `tree` recursively from the odb. + pub fn collect_tree_blobs(&self, tree: &str, prefix: &str) -> Result> { + if tree == EMPTY_TREE { + return Ok(Vec::new()); + } + let listed = Command::new("git") + .args(["--git-dir"]) + .arg(self.git_dir()) + .args(["ls-tree", "-r", "-z", tree]) + .output() + .context("failed to list tree objects in store")?; + if !listed.status.success() { + bail!("git ls-tree failed for {tree}"); + } + let mut out = Vec::new(); + for entry in listed.stdout.split(|&b| b == 0).filter(|s| !s.is_empty()) { + let record = String::from_utf8_lossy(entry); + let (meta, path) = match record.split_once('\t') { + Some(p) => p, + None => continue, + }; + let parts: Vec<&str> = meta.split_whitespace().collect(); + if parts.len() >= 3 && parts[1] == "blob" { + out.push((format!("{prefix}{path}"), parts[2].to_string())); + } + } + Ok(out) + } + /// Path of the `.oot` directory itself. pub fn path(&self) -> &Path { &self.root @@ -722,8 +754,10 @@ impl Store { }; self.reset_export_cache_if_policy_changed(&filter_key)?; - // Taint pass: decide once, up front, which changes touch private paths. + // Taint pass: decide once, up front, which changes touch private paths, + // and collect all private blob SHAs to prevent multi-step rename leaks. let mut withheld: HashMap = HashMap::new(); + let mut private_blobs: HashSet = HashSet::new(); if filtering { let pol = policy.expect("checked above"); for id in self.index()? { @@ -734,7 +768,33 @@ impl Store { .filter(|p| pol.path_is_private(p)) .collect(); if !hits.is_empty() { - withheld.insert(id, format!("private path match: {}", hits.join(", "))); + withheld.insert( + id.clone(), + format!("private path match: {}", hits.join(", ")), + ); + } + for (path, blob_sha) in self.collect_tree_blobs(&record.tree, "")? { + if pol.path_is_private(&path) && blob_sha != EMPTY_BLOB { + private_blobs.insert(blob_sha); + } + } + } + for id in self.index()? { + if withheld.contains_key(&id) { + let record = self.get_change(&id)?; + for (_path, blob_sha) in self.collect_tree_blobs(&record.tree, "")? { + let existed_in_clean_parents = record.parents.iter().any(|p| { + !withheld.contains_key(p) + && self + .get_change(p) + .ok() + .and_then(|pr| self.collect_tree_blobs(&pr.tree, "").ok()) + .is_some_and(|blobs| blobs.iter().any(|(_, b)| b == &blob_sha)) + }); + if !existed_in_clean_parents && blob_sha != EMPTY_BLOB { + private_blobs.insert(blob_sha); + } + } } } } @@ -749,7 +809,8 @@ impl Store { let record = self.get_change(&id)?; if let Some(sha) = self.exported_sha(&id)? { if filtering { - let tree = self.strip_tree(&record.tree, policy.unwrap(), "")?; + let tree = + self.strip_tree(&record.tree, policy.unwrap(), &private_blobs, "")?; tree_of.insert(sha.clone(), tree); } sha_of.insert(id.clone(), sha.clone()); @@ -770,7 +831,7 @@ impl Store { // Stripped tree for filtered exports, original tree otherwise. let stripped_tree = if filtering { - self.strip_tree(&record.tree, policy.unwrap(), "")? + self.strip_tree(&record.tree, policy.unwrap(), &private_blobs, "")? } else { record.tree.clone() }; @@ -958,10 +1019,16 @@ impl Store { } /// Rebuild `tree` minus every path matching the policy's private - /// fragments, recursively. Pure plumbing (`ls-tree` + `mktree`) against - /// the store's bare odb — no index or worktree involved. Deterministic: - /// identical inputs yield the original sha untouched. - fn strip_tree(&self, tree: &str, policy: &VisibilityPolicy, prefix: &str) -> Result { + /// fragments or tainted private blob SHAs, recursively. Pure plumbing + /// (`ls-tree` + `mktree`) against the store's bare odb — no index or + /// worktree involved. Deterministic: identical inputs yield the original sha untouched. + fn strip_tree( + &self, + tree: &str, + policy: &VisibilityPolicy, + private_blobs: &HashSet, + prefix: &str, + ) -> Result { let listed = Command::new("git") .args(["--git-dir"]) .arg(self.git_dir()) @@ -997,14 +1064,14 @@ impl Store { lines.push(record.to_string()); } "blob" => { - if policy.path_is_private(&path) { + if policy.path_is_private(&path) || private_blobs.contains(&sha) { changed = true; continue; } lines.push(record.to_string()); } "tree" => { - let sub = self.strip_tree(&sha, policy, &format!("{path}/"))?; + let sub = self.strip_tree(&sha, policy, private_blobs, &format!("{path}/"))?; if sub != sha { changed = true; } diff --git a/tests/export_visibility_test.rs b/tests/export_visibility_test.rs index 785f381..00d7900 100644 --- a/tests/export_visibility_test.rs +++ b/tests/export_visibility_test.rs @@ -277,3 +277,62 @@ fn test_filtered_export_skips_empty_rebuilt_commits() { let _ = std::fs::remove_dir_all(&tmp); } + +#[test] +fn test_filtered_export_prevents_renamed_private_file_leak() { + let tmp = std::env::temp_dir().join(format!("oot-rename-leak-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + let src = tmp.join("src"); + let proj = tmp.join("proj"); + let out = tmp.join("out"); + std::fs::create_dir_all(&proj).unwrap(); + + build_fixture(&src); + // Commit 2: introduce secret in secrets/pass.txt + std::fs::create_dir_all(src.join("secrets")).unwrap(); + std::fs::write(src.join("secrets/pass.txt"), "supersecretpassword\n").unwrap(); + git(&src, &["add", "."]); + git(&src, &["commit", "-m", "add secret"]); + + // Commit 3: rename secrets/pass.txt to leaked_pass.txt + git(&src, &["mv", "secrets/pass.txt", "leaked_pass.txt"]); + git(&src, &["commit", "-m", "move secret outside private path"]); + + // Commit 4: modify public file + std::fs::write(src.join("README.md"), "v2 with updates\n").unwrap(); + git(&src, &["add", "."]); + git(&src, &["commit", "-m", "public docs update"]); + + std::fs::write( + proj.join("visibility.toml"), + "private_paths = [\"secrets/\"]\nprivate_branches = []\n", + ) + .unwrap(); + + assert!(oot(&["init"], &proj).0); + let (ok, msg) = oot(&["import", "--repo", src.to_str().unwrap()], &proj); + assert!(ok, "import failed: {msg}"); + let (ok, msg) = oot(&["export", "--out", out.to_str().unwrap()], &proj); + assert!(ok, "export failed: {msg}"); + + // Verify exported repository does not have leaked_pass.txt in working tree or HEAD commit + assert!( + !out.join("leaked_pass.txt").exists(), + "leaked_pass.txt must not exist in working tree" + ); + let ls = git(&out, &["ls-tree", "-r", "--name-only", "HEAD"]); + assert!( + !ls.contains("leaked_pass.txt"), + "leaked_pass.txt must not exist in HEAD tree: {ls}" + ); + assert!( + !ls.contains("secrets/pass.txt"), + "secrets/pass.txt must not exist in HEAD tree: {ls}" + ); + assert!( + ls.contains("README.md"), + "README.md should exist in HEAD tree: {ls}" + ); + + let _ = std::fs::remove_dir_all(&tmp); +} From feb75cd385a754bb3717e54e795085604d03d066 Mon Sep 17 00:00:00 2001 From: kridaydave Date: Fri, 4 Sep 2026 21:38:59 +0530 Subject: [PATCH 09/14] fix(log): say empty store instead of erroring --- src/main.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/main.rs b/src/main.rs index aa87bd3..b8cc2dd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -527,9 +527,17 @@ fn main() -> anyhow::Result { Commands::Log { branch } => { let store = Store::open(".")?; let branch = resolve_branch(&store, branch)?; - let head_id = store - .head_id(&branch)? - .ok_or_else(|| anyhow::anyhow!("branch '{branch}' has no changes"))?; + let head_id = match store.head_id(&branch)? { + Some(id) => id, + None => { + if store.refs()?.is_empty() { + println!("branch '{branch}': empty store, nothing recorded yet"); + return Ok(std::process::ExitCode::SUCCESS); + } else { + anyhow::bail!("branch '{branch}' has no changes"); + } + } + }; // Reachable set from the head... let mut reachable: std::collections::HashSet = std::collections::HashSet::new(); From bd0f6e1f83f007114b23f885c993a27300a20d3e Mon Sep 17 00:00:00 2001 From: kridaydave Date: Fri, 4 Sep 2026 21:38:59 +0530 Subject: [PATCH 10/14] feat(export): checkout HEAD so exported repo is ready to inspect --- src/main.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index b8cc2dd..2b48f1c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -639,10 +639,12 @@ fn main() -> anyhow::Result { } } - // Point HEAD at the first exported branch so `git log` works immediately. + // Point HEAD at the first exported branch so `git log` works immediately, + // and populate the working tree files so the exported repo is ready to inspect. if let Some(first_branch) = first_exported_branch { let first = format!("refs/heads/{first_branch}"); run_git(&["symbolic-ref", "HEAD", &first], &out_path)?; + let _ = run_git(&["checkout", "-f", "HEAD"], &out_path); } else { println!( "warning: all branches were withheld by policy; no refs exported to {out}" From 93596e1a5b322186a0534fe323364d49e76f6185 Mon Sep 17 00:00:00 2001 From: kridaydave Date: Fri, 4 Sep 2026 21:49:10 +0530 Subject: [PATCH 11/14] fix(visibility): enforce real calendar dates, one separator --- src/visibility.rs | 27 +++++++++++++++++++++++++-- tests/visibility_test.rs | 36 ++++++++++++++++++++++++++++++------ 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src/visibility.rs b/src/visibility.rs index c40c260..6d05f5d 100644 --- a/src/visibility.rs +++ b/src/visibility.rs @@ -221,12 +221,24 @@ impl VisibilityPolicy { } /// Parse calendar date string in common standard formats (YYYY-MM-DD, YYYY/MM/DD, YYYY.MM.DD, DD-MM-YYYY, DD/MM/YYYY, DD.MM.YYYY). +/// All separators must be the same character, and the day must exist on +/// the calendar (month lengths plus leap years). Anything else is rejected. pub fn parse_date_ymd(s: &str) -> Option<(i64, u32, u32)> { let s = s.trim(); if s.is_empty() { return None; } - let parts: Vec<&str> = s.split(['-', '/', '.']).collect(); + let sep = s + .chars() + .find(|c| *c == '-' || *c == '/' || *c == '.')?; + if !s + .chars() + .filter(|c| *c == '-' || *c == '/' || *c == '.') + .all(|c| c == sep) + { + return None; + } + let parts: Vec<&str> = s.split(sep).collect(); if parts.len() != 3 { return None; } @@ -242,7 +254,18 @@ pub fn parse_date_ymd(s: &str) -> Option<(i64, u32, u32)> { return None; }; - if !(1..=12).contains(&m) || !(1..=31).contains(&d) || y <= 0 { + if y <= 0 || !(1..=12).contains(&m) { + return None; + } + let leap = y % 4 == 0 && (y % 100 != 0 || y % 400 == 0); + let max_day = match m { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if leap => 29, + 2 => 28, + _ => return None, + }; + if d < 1 || d > max_day { return None; } Some((y, m, d)) diff --git a/tests/visibility_test.rs b/tests/visibility_test.rs index 6444e5f..2491dbf 100644 --- a/tests/visibility_test.rs +++ b/tests/visibility_test.rs @@ -275,11 +275,35 @@ fn test_embargo_date_formats_and_validation() { let tmp = std::env::temp_dir().join(format!("oot-bad-date-{}", std::process::id())); let _ = std::fs::remove_dir_all(&tmp); std::fs::create_dir_all(&tmp).unwrap(); - let bad_toml = tmp.join("bad.toml"); - std::fs::write(&bad_toml, "embargo_until = \"not-a-date\"\n").unwrap(); - assert!( - VisibilityPolicy::load(&bad_toml).is_err(), - "invalid date in TOML must fail to load" - ); + for bad in [ + "not-a-date", + "2099-02-30", + "2099-13-01", + "2099-12/31", + "01-01-2099-extra", + ] { + let bad_toml = tmp.join("bad.toml"); + std::fs::write(&bad_toml, format!("embargo_until = \"{bad}\"\n")).unwrap(); + assert!( + VisibilityPolicy::load(&bad_toml).is_err(), + "invalid date '{bad}' in TOML must fail to load" + ); + } let _ = std::fs::remove_dir_all(&tmp); + + // Leap day exists in 2096 but not in 2099. + assert!(VisibilityPolicy { + private_paths: vec![], + embargo_until: Some("2096-02-29".into()), + private_branches: vec![], + } + .is_under_embargo()); + // Nonexistent Feb 29 cannot be constructed, so a hand-built policy + // with it must fail closed, never open. + assert!(VisibilityPolicy { + private_paths: vec![], + embargo_until: Some("2099-02-29".into()), + private_branches: vec![], + } + .is_under_embargo()); } From 7527909ed0d5acfbbf2d41e139c8ef72259509de Mon Sep 17 00:00:00 2001 From: kridaydave Date: Fri, 4 Sep 2026 21:56:25 +0530 Subject: [PATCH 12/14] feat(engine): blank defs before top-level compare, cover 3-way --- src/engine/mod.rs | 65 +++++++++++++++++++++++++++++---- tests/engine_test.rs | 85 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 6 deletions(-) diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 92bb4e6..c368597 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -50,7 +50,6 @@ impl Engine { match (base_src, head_src) { (Some(b), Some(h)) => { - let disputes_before = disputes.len(); let base_fns = extract_functions( parse_source(&mut parser, &config.language, &b).as_ref(), &b, @@ -135,9 +134,14 @@ impl Engine { )); } - // If file source changed but no function-level dispute was generated - // (e.g. top-level module code, non-function statements), emit a dispute. - if disputes.len() == disputes_before && b != h { + // Top-level module code or non-function statements changed. + // Definition bodies are blanked before comparing, so a + // top-level injection hiding beside a function edit still + // gets its own dispute, while whitespace-only churn and + // pure function-body edits stay silent here. + let top_changed = squash_ws(&without_defs(&b, &base_fns)) + != squash_ws(&without_defs(&h, &head_fns)); + if top_changed { disputes.push(meaning( &mut n, path, @@ -487,6 +491,24 @@ impl Engine { Severity::Review, )); } + // Incoming top-level changes with no function footprint + // must not slip through: same fallback as the 2-way path, + // scoped to what theirs changed against base. Ours is the + // trusted target side, so ours-only drift stays silent, + // and identical changes on both sides count as converged. + if t_src != b_src && o_src != t_src { + let top_changed = squash_ws(&without_defs(&b_src, &b_fns)) + != squash_ws(&without_defs(&t_src, &t_fns)); + if top_changed { + disputes.push(meaning( + &mut n, + path, + 1, + "incoming branch modified file content (top-level or non-function definitions)".to_string(), + Severity::Review, + )); + } + } } // File deleted in target, modified in incoming (Some(_), None, Some(_)) => { @@ -535,12 +557,13 @@ impl Engine { } } -/// One extracted definition: source text, 1-based row, and a rename -/// signature (the body with its own name blanked out). +/// One extracted definition: source text, 1-based start and end rows, +/// and a rename signature (the body with its own name blanked out). #[derive(Debug, Clone, PartialEq, Eq)] struct FnDef { src: String, row: usize, + end_row: usize, signature: String, } @@ -550,6 +573,32 @@ struct FnDef { /// instead of collapsing to the first occurrence. type FunctionMap = HashMap>; +/// The file with every extracted definition's lines blanked out, +/// leaving top-level module code behind. Line ranges absorb wrapper +/// syntax (`export`, `const X =`, trailing `;`) that the bare node +/// spans do not cover. +fn without_defs(src: &str, fns: &FunctionMap) -> String { + let mut ranges: Vec<(usize, usize)> = + fns.values().flatten().map(|d| (d.row, d.end_row)).collect(); + ranges.sort(); + ranges.dedup(); + src.lines() + .enumerate() + .filter(|(i, _)| { + // sources carry 1-based rows, the enum index is 0-based + let ln = i + 1; + !ranges.iter().any(|(s, e)| ln >= *s && ln <= *e) + }) + .map(|(_, l)| l) + .collect::>() + .join("\n") +} + +/// Strip all whitespace for churn-insensitive comparison. +fn squash_ws(s: &str) -> String { + s.chars().filter(|c| !c.is_whitespace()).collect() +} + /// Align two def lists for one name: identical source text pairs first /// (each def consumed once, silently — those are unchanged), then remaining /// leftovers pair by relative order and count as changed. Returns matched @@ -822,6 +871,7 @@ fn insert( }; let src = src.to_string(); let row = body.start_position().row + 1; + let end_row = body.end_position().row + 1; // Signature: the body with its own name blanked, so two functions // that differ only by what they are called compare equal and pair // as a rename. @@ -851,6 +901,7 @@ fn insert( map.entry(key).or_default().push(FnDef { src, row, + end_row, signature, }); true @@ -1342,6 +1393,7 @@ mod tests { FnDef { src: String::new(), row: 1, + end_row: 1, signature: sig.to_string(), }, ) @@ -1353,6 +1405,7 @@ mod tests { FnDef { src: String::new(), row, + end_row: row, signature: sig.to_string(), }, ) diff --git a/tests/engine_test.rs b/tests/engine_test.rs index 797a817..6ec301d 100644 --- a/tests/engine_test.rs +++ b/tests/engine_test.rs @@ -1248,3 +1248,88 @@ fn test_engine_rust_macro_rules() { ); assert!(disputes[0].detail.contains("`log_msg`")); } + +#[test] +fn test_engine_whitespace_only_change_is_silent() { + let engine = Engine::new().expect("Failed to initialize engine"); + + let mut base = Snapshot::default(); + base.files.insert( + "config.js".to_string(), + "const admin = false;\n".as_bytes().to_vec(), + ); + + let mut head = Snapshot::default(); + head.files.insert( + "config.js".to_string(), + "const admin = false; \n\n".as_bytes().to_vec(), + ); + + let disputes = engine.diff_snapshots(&base, &head).expect("Diff failed"); + assert!( + disputes.is_empty(), + "whitespace-only churn must not dispute: {:?}", + disputes + ); +} + +#[test] +fn test_engine_function_edit_plus_injection_flags_both() { + let engine = Engine::new().expect("Failed to initialize engine"); + + let mut base = Snapshot::default(); + base.files.insert( + "app.js".to_string(), + "function foo() { return 1; }\nconst admin = false;\n" + .as_bytes() + .to_vec(), + ); + + let mut head = Snapshot::default(); + head.files.insert( + "app.js".to_string(), + "function foo() { return 2; }\nconst admin = true;\n" + .as_bytes() + .to_vec(), + ); + + let disputes = engine.diff_snapshots(&base, &head).expect("Diff failed"); + assert_eq!(disputes.len(), 2, "expected fn + top-level: {:?}", disputes); + assert!(disputes.iter().any(|d| d.detail.contains("`foo`"))); + assert!(disputes + .iter() + .any(|d| d.detail.contains("top-level or non-function"))); +} + +#[test] +fn test_engine_3way_top_level_injection_disputes() { + let engine = Engine::new().expect("Failed to initialize engine"); + + let base_src = "const admin = false;\n"; + let ours_src = base_src; + let theirs_src = "require('child_process').execSync('id');\nconst admin = true;\n"; + + let snap = |s: &str| { + let mut x = Snapshot::default(); + x.files + .insert("config.js".to_string(), s.as_bytes().to_vec()); + x + }; + + let disputes = engine + .diff_3way(&snap(base_src), &snap(ours_src), &snap(theirs_src)) + .expect("Diff failed"); + + assert_eq!(disputes.len(), 1, "incoming injection: {:?}", disputes); + assert!(disputes[0].detail.contains("top-level or non-function")); + + // Convergent top-level change on both sides stays silent. + let disputes = engine + .diff_3way(&snap(base_src), &snap(theirs_src), &snap(theirs_src)) + .expect("Diff failed"); + assert!( + disputes.is_empty(), + "convergent change must not dispute: {:?}", + disputes + ); +} From 2a431755bd7663e5a5e1b7881a0f945ab9a4b406 Mon Sep 17 00:00:00 2001 From: kridaydave Date: Fri, 4 Sep 2026 21:56:36 +0530 Subject: [PATCH 13/14] style(visibility): fmt the date separator lookup --- src/visibility.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/visibility.rs b/src/visibility.rs index 6d05f5d..c036210 100644 --- a/src/visibility.rs +++ b/src/visibility.rs @@ -228,9 +228,7 @@ pub fn parse_date_ymd(s: &str) -> Option<(i64, u32, u32)> { if s.is_empty() { return None; } - let sep = s - .chars() - .find(|c| *c == '-' || *c == '/' || *c == '.')?; + let sep = s.chars().find(|c| *c == '-' || *c == '/' || *c == '.')?; if !s .chars() .filter(|c| *c == '-' || *c == '/' || *c == '.') From e9656ecd6434aeb410aa78bf6812a5f39a576387 Mon Sep 17 00:00:00 2001 From: kridaydave Date: Sat, 5 Sep 2026 18:35:10 +0530 Subject: [PATCH 14/14] fix(engine): blank full statement spans so added consts leave no top-level crumbs Byte-span blanking left const and semicolon wrappers behind, so adding const triple faked a top-level dispute. Climb through single-declaration wrappers at insert time. Same-line injections stay visible. --- src/engine/mod.rs | 88 ++++++++++++++++++++++++++++++++------------ tests/engine_test.rs | 37 +++++++++++++++++++ 2 files changed, 101 insertions(+), 24 deletions(-) diff --git a/src/engine/mod.rs b/src/engine/mod.rs index c368597..ac7bce8 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -557,13 +557,15 @@ impl Engine { } } -/// One extracted definition: source text, 1-based start and end rows, -/// and a rename signature (the body with its own name blanked out). +/// One extracted definition: source text, 1-based start row, and the +/// byte span (`start_byte`..`end_byte`) of the definition in its file, +/// plus a rename signature (the body with its own name blanked out). #[derive(Debug, Clone, PartialEq, Eq)] struct FnDef { src: String, row: usize, - end_row: usize, + start_byte: usize, + end_byte: usize, signature: String, } @@ -573,25 +575,29 @@ struct FnDef { /// instead of collapsing to the first occurrence. type FunctionMap = HashMap>; -/// The file with every extracted definition's lines blanked out, -/// leaving top-level module code behind. Line ranges absorb wrapper -/// syntax (`export`, `const X =`, trailing `;`) that the bare node -/// spans do not cover. +/// The file with every extracted definition's byte span removed, +/// leaving top-level module code behind. Defs are blanked by their +/// exact source span rather than whole lines, so a statement stapled to +/// the same line as a closing brace survives and still gets disputed. +/// Byte spans come from the same tree-sitter buffer the source was +/// parsed from, so they align to UTF-8 char boundaries. fn without_defs(src: &str, fns: &FunctionMap) -> String { - let mut ranges: Vec<(usize, usize)> = - fns.values().flatten().map(|d| (d.row, d.end_row)).collect(); - ranges.sort(); - ranges.dedup(); - src.lines() + let bytes = src.as_bytes(); + let mut marked = vec![false; bytes.len()]; + for def in fns.values().flatten() { + let start = def.start_byte.min(bytes.len()); + let end = def.end_byte.min(bytes.len()).max(start); + for slot in marked.iter_mut().take(end).skip(start) { + *slot = true; + } + } + let kept: Vec = bytes + .iter() .enumerate() - .filter(|(i, _)| { - // sources carry 1-based rows, the enum index is 0-based - let ln = i + 1; - !ranges.iter().any(|(s, e)| ln >= *s && ln <= *e) - }) - .map(|(_, l)| l) - .collect::>() - .join("\n") + .filter(|(i, _)| !marked[*i]) + .map(|(_, b)| *b) + .collect(); + String::from_utf8_lossy(&kept).into_owned() } /// Strip all whitespace for churn-insensitive comparison. @@ -871,7 +877,38 @@ fn insert( }; let src = src.to_string(); let row = body.start_position().row + 1; - let end_row = body.end_position().row + 1; + // Blanking span: climb through single-statement wrappers so an added + // `const f = ...;` leaves no `const ;` crumbs that fake a top-level + // dispute. Multi-declarator parents (`const a=.., b=..`) stay put so a + // sibling edit cannot hide. Same-line injections are separate statement + // nodes, so they still survive blanking and get disputed. + let mut span_node = body; + while let Some(parent) = span_node.parent() { + match parent.kind() { + "lexical_declaration" | "variable_declaration" => { + let mut declarators = 0; + for i in 0..parent.child_count() { + if parent + .child(i) + .is_some_and(|c| c.kind() == "variable_declarator") + { + declarators += 1; + } + } + if declarators == 1 { + span_node = parent; + } else { + break; + } + } + "export_statement" | "expression_statement" => { + span_node = parent; + } + _ => break, + } + } + let start_byte = span_node.start_byte(); + let end_byte = span_node.end_byte(); // Signature: the body with its own name blanked, so two functions // that differ only by what they are called compare equal and pair // as a rename. @@ -901,7 +938,8 @@ fn insert( map.entry(key).or_default().push(FnDef { src, row, - end_row, + start_byte, + end_byte, signature, }); true @@ -1393,7 +1431,8 @@ mod tests { FnDef { src: String::new(), row: 1, - end_row: 1, + start_byte: 0, + end_byte: 0, signature: sig.to_string(), }, ) @@ -1405,7 +1444,8 @@ mod tests { FnDef { src: String::new(), row, - end_row: row, + start_byte: 0, + end_byte: 0, signature: sig.to_string(), }, ) diff --git a/tests/engine_test.rs b/tests/engine_test.rs index 6ec301d..a882a43 100644 --- a/tests/engine_test.rs +++ b/tests/engine_test.rs @@ -1333,3 +1333,40 @@ fn test_engine_3way_top_level_injection_disputes() { disputes ); } + +#[test] +fn test_engine_toplevel_injection_on_function_line_is_caught() { + // Same-line injection: a statement stapled to a function's closing + // brace. Whole-line blanking would hide it; byte-span blanking must not. + let engine = Engine::new().expect("Failed to initialize engine"); + + let mut base = Snapshot::default(); + base.files.insert( + "app.js".to_string(), + "function foo() { return 1; }\n".as_bytes().to_vec(), + ); + + let mut head = Snapshot::default(); + head.files.insert( + "app.js".to_string(), + "function foo() { return 1; }require('child_process').execSync('id');\n" + .as_bytes() + .to_vec(), + ); + + let disputes = engine.diff_snapshots(&base, &head).expect("Diff failed"); + assert!( + disputes + .iter() + .any(|d| d.detail.contains("top-level or non-function")), + "injection stapled to a function line must be disputed: {:?}", + disputes + ); + assert!( + !disputes + .iter() + .any(|d| d.detail.contains("both sides changed")), + "function body is unchanged, must not flag a function change: {:?}", + disputes + ); +}