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/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/src/engine/mod.rs b/src/engine/mod.rs index 14b0753..ac7bce8 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -133,6 +133,24 @@ impl Engine { Severity::Review, )); } + + // 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, + 1, + "file content modified (top-level or non-function definitions)" + .to_string(), + Severity::Review, + )); + } } (Some(_), None) => { disputes.push(meaning( @@ -473,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(_)) => { @@ -521,12 +557,15 @@ 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 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, + start_byte: usize, + end_byte: usize, signature: String, } @@ -536,6 +575,36 @@ struct FnDef { /// instead of collapsing to the first occurrence. type FunctionMap = HashMap>; +/// 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 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, _)| !marked[*i]) + .map(|(_, b)| *b) + .collect(); + String::from_utf8_lossy(&kept).into_owned() +} + +/// 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 @@ -808,6 +877,38 @@ fn insert( }; let src = src.to_string(); let row = body.start_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. @@ -837,6 +938,8 @@ fn insert( map.entry(key).or_default().push(FnDef { src, row, + start_byte, + end_byte, signature, }); true @@ -1063,7 +1166,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" ); } @@ -1328,6 +1431,8 @@ mod tests { FnDef { src: String::new(), row: 1, + start_byte: 0, + end_byte: 0, signature: sig.to_string(), }, ) @@ -1339,6 +1444,8 @@ mod tests { FnDef { src: String::new(), row, + start_byte: 0, + end_byte: 0, signature: sig.to_string(), }, ) diff --git a/src/main.rs b/src/main.rs index a12a096..2b48f1c 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)?; @@ -450,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)", @@ -525,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(); @@ -629,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}" diff --git a/src/store.rs b/src/store.rs index 6c7b087..0732d9c 100644 --- a/src/store.rs +++ b/src/store.rs @@ -30,6 +30,20 @@ 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)] +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. @@ -96,6 +110,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)) @@ -123,6 +138,97 @@ 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()), + } + } + } + + /// 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(()) + } + + /// 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 @@ -300,6 +406,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> { @@ -643,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()? { @@ -655,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); + } + } } } } @@ -670,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()); @@ -691,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() }; @@ -879,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()) @@ -918,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/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) } diff --git a/src/visibility.rs b/src/visibility.rs index 896204b..c036210 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,20 +203,72 @@ 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 } } } +/// 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 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; + } + 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 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)) +} + 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; 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/engine_test.rs b/tests/engine_test.rs index 989d006..a882a43 100644 --- a/tests/engine_test.rs +++ b/tests/engine_test.rs @@ -1157,3 +1157,216 @@ 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"); + + 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`")); +} + +#[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 + ); +} + +#[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 + ); +} 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); +} diff --git a/tests/visibility_test.rs b/tests/visibility_test.rs index b2e12fc..2491dbf 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") ); } @@ -228,3 +228,82 @@ 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(); + 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()); +}