From a50de684b22e1830701b8466412379592c217612 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:00:57 +0100 Subject: [PATCH 1/2] Port legacy tree record discovery (3f-1) --- README.md | 6 + .../src/evidence/legacy_verify/discovery.rs | 35 ++++++ crates/mergemaster-evidence/tests/gate.rs | 103 ++++++++++++++++++ scripts/PROVENANCE | 5 + 4 files changed, 149 insertions(+) diff --git a/README.md b/README.md index ef29a52..0084817 100644 --- a/README.md +++ b/README.md @@ -532,6 +532,12 @@ trees, preserving raw changed-path bytes and accepted-base existence checks. checks ancestry and compares canonical entries before accepted-lane validation. Legacy `discovery::input_refs` and `with_note_for` find indexed candidates and batch note-entry presence only; neither establishes statement or signer trust. +`discovery::tree_refs` and `legacy_refs` complete tree/first-layout candidate +discovery (3f-1), including SHA-1/SHA-256 suffix identity and tag-first deduplication. +Remaining record loading: 3f-2 statement validation (`statement_of`, 633-667), +then 3f-3 Git record layout/parent/subject checks and owned cache (`read_statement` +and `load`, 720-765). Signature verification (768-773) follows before selection +(3g) and require-all (3h); none of these candidate indexes establishes trust. Lane discovery uses the gate's sanitized Git command and rejects malformed or unterminated ls-tree rows; shared subprocess timeout/output bounds remain open. `workflow::workflow_job` selects exact generated-workflow text and transitive diff --git a/crates/mergemaster-evidence/src/evidence/legacy_verify/discovery.rs b/crates/mergemaster-evidence/src/evidence/legacy_verify/discovery.rs index fe24432..d20645e 100644 --- a/crates/mergemaster-evidence/src/evidence/legacy_verify/discovery.rs +++ b/crates/mergemaster-evidence/src/evidence/legacy_verify/discovery.rs @@ -54,6 +54,41 @@ pub fn records_under(repo: &Path, prefixes: &[String]) -> Result, Po } Ok(found) } +/// First-layout notes only: the complete suffix must equal the object it names. +pub fn legacy_refs(repo: &Path, width: usize) -> Result, PolicyError> { + if !matches!(width, 40 | 64) { + return Err(error("invalid record object-id width")); + } + let listing = text( + repo, + &[ + "for-each-ref", + "--format=%(objectname) %(refname)", + "--end-of-options", + &format!("refs/notes/evidence/{}", "?".repeat(width)), + ], + ) + .map_err(error)?; + Ok(listing + .lines() + .filter_map(|line| { + let (id, name) = line.split_once(' ')?; + (oid(id).is_ok() && name.strip_prefix("refs/notes/evidence/") == Some(id)) + .then(|| id.to_owned()) + }) + .collect()) +} +/// Tags precede notes; these are candidates, not validated records. +pub fn tree_refs(repo: &Path, tree: &str) -> Result, PolicyError> { + oid(tree).map_err(error)?; + records_under( + repo, + &[ + format!("refs/tags/evidence/{tree}/"), + format!("refs/notes/evidence/{tree}/"), + ], + ) +} pub fn input_refs(repo: &Path, check: &str, key: &str) -> Result, PolicyError> { if !(1..=100).contains(&check.len()) || !check.as_bytes()[0].is_ascii_alphanumeric() diff --git a/crates/mergemaster-evidence/tests/gate.rs b/crates/mergemaster-evidence/tests/gate.rs index 4029a49..e5a87de 100644 --- a/crates/mergemaster-evidence/tests/gate.rs +++ b/crates/mergemaster-evidence/tests/gate.rs @@ -4342,3 +4342,106 @@ print(json.dumps(dict( expected ); } + +#[test] +fn legacy_tree_record_indexes_match_real_oracle() { + use mergemaster_evidence::evidence::legacy_verify::discovery::{legacy_refs, tree_refs}; + let root = std::env::var("MERGEMASTER_PYTHON_ORACLE") + .expect("MERGEMASTER_PYTHON_ORACLE must point to the pinned Python oracle"); + for format in ["sha1", "sha256"] { + let f = Fixture::new(); + // Reinitialize a separate repository so the same matrix covers both widths. + let repo = f.root.path().join(format); + fs::create_dir(&repo).unwrap(); + let git = |args: &[&str], input: &[u8]| { + let mut routed = vec!["-C", repo.to_str().unwrap()]; + routed.extend_from_slice(args); + f.git(&routed, input) + }; + git(&["init", "-q", &format!("--object-format={format}")], b""); + let tree = git(&["mktree"], b""); + let other = "a".repeat(tree.len()); + let absent = "b".repeat(tree.len()); + let first = git(&["hash-object", "-w", "--stdin"], b"first"); + let second = git(&["hash-object", "-w", "--stdin"], b"second"); + let third = git(&["hash-object", "-w", "--stdin"], b"third"); + let queries = [&tree, &other, &absent]; + let compare = || { + let trees: Vec<_> = queries + .iter() + .map(|tree| tree_refs(&repo, tree).unwrap()) + .collect(); + let old: Vec<_> = [40, 64] + .into_iter() + .map(|width| legacy_refs(&repo, width).unwrap()) + .collect(); + let output = f + .command("python3") + .args([ + "-c", + r#" +import json, runpy, sys +o = runpy.run_path(sys.argv[1] + '/scripts/evidence-verify') +repo = sys.argv[2] +print(json.dumps(dict( + trees=[o['tree_refs'](repo,t) for t in json.loads(sys.argv[3])], + old=[o['legacy_refs'](repo,w) for w in [40,64]]))) +"#, + &root, + repo.to_str().unwrap(), + &json!(queries).to_string(), + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let expected: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(json!({"trees":trees,"old":old}), expected, "{format}"); + }; + compare(); // Empty namespaces. + let tag = format!("refs/tags/evidence/{tree}/"); + let note = format!("refs/notes/evidence/{tree}/"); + for (name, target) in [ + (format!("{tag}{second}"), &second), + (format!("{tag}{first}"), &first), + (format!("{note}{second}"), &second), + (format!("{note}{third}"), &third), + (format!("refs/tags/evidence/{other}/{third}"), &third), + (format!("refs/notes/evidence/{first}"), &first), + (format!("refs/notes/evidence/{third}"), &third), + // Wrong target, malformed suffix and nested suffix cannot be records. + (format!("{tag}{third}"), &first), + (format!("{tag}bad"), &first), + (format!("{tag}{}/nested", "c".repeat(tree.len())), &first), + (format!("refs/notes/evidence/{second}"), &first), + (format!("refs/notes/evidence/{}", "A".repeat(tree.len())), &first), + (format!("refs/notes/evidence/{}", "d".repeat(39)), &first), + (format!("refs/notes/evidence/{absent}/nested"), &first), + (format!("refs/notes/evidence-input/{CHECK}/{tree}/{first}"), &first), + (format!("refs/tags/evidence/input/{CHECK}/{tree}/{first}"), &first), + (format!("refs/tags/evidence/{tree}-other/{first}"), &first), + ] { + git(&["update-ref", &name, target], b""); + } + compare(); + let candidates = tree_refs(&repo, &tree).unwrap(); + let mut tags = vec![first.clone(), second.clone()]; + tags.sort(); + tags.push(third.clone()); + assert_eq!(candidates, tags); // Namespace order, then deduplicated note. + assert_eq!(legacy_refs(&repo, tree.len()).unwrap().len(), 2); + assert!(legacy_refs(&repo, 41).is_err()); + for invalid in ["--help".to_owned(), tree.to_uppercase(), format!("{tree}\n")] { + assert!(tree_refs(&repo, &invalid).is_err()); + } + // GC of first-layout refs leaves tree-indexed candidates untouched. + for id in [&first, &third] { + git(&["update-ref", "-d", &format!("refs/notes/evidence/{id}")], b""); + } + compare(); + assert_eq!(legacy_refs(&repo, tree.len()).unwrap().len(), 0); + } +} diff --git a/scripts/PROVENANCE b/scripts/PROVENANCE index 27565ed..aa042a4 100644 --- a/scripts/PROVENANCE +++ b/scripts/PROVENANCE @@ -12,6 +12,11 @@ # native lane hashing is unchanged. Selection fixtures still supply the digest. # Rust parity: input-ref-discovery (683-693,702-717). Prefix order, exact suffix # identity, deduplication and batch presence match; discovery does not grant trust. +# Rust parity: tree-record-discovery, slice 3f-1 (670-680,696-699), reusing +# records_under. Tree tags precede notes; exact suffix identity and first-layout +# width filtering match in SHA-1 and SHA-256 repositories, including empty/GC states. +# Hardening divergence: tree-ref discovery requires full OIDs and first-layout +# widths of 40 or 64; legacy callers derive these from validated tree IDs. # Rust parity: workflow-job-selection (462-520). Raw top-level/job bytes and # dependency closure match; workflow layout is not interpreted as general YAML. # Shared legacy JSON guards/encoding preserve lane behavior; native and push From b242610da2be6406a15f8d442a6bac5e28e99106 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:05:35 +0100 Subject: [PATCH 2/2] Format tree record discovery differential fixtures --- .../src/evidence/legacy_verify/discovery.rs | 1 + crates/mergemaster-evidence/tests/gate.rs | 26 +++++++++++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/crates/mergemaster-evidence/src/evidence/legacy_verify/discovery.rs b/crates/mergemaster-evidence/src/evidence/legacy_verify/discovery.rs index d20645e..45bbe1c 100644 --- a/crates/mergemaster-evidence/src/evidence/legacy_verify/discovery.rs +++ b/crates/mergemaster-evidence/src/evidence/legacy_verify/discovery.rs @@ -55,6 +55,7 @@ pub fn records_under(repo: &Path, prefixes: &[String]) -> Result, Po Ok(found) } /// First-layout notes only: the complete suffix must equal the object it names. +/// Ref names are unique, so matching first-layout IDs need no deduplication. pub fn legacy_refs(repo: &Path, width: usize) -> Result, PolicyError> { if !matches!(width, 40 | 64) { return Err(error("invalid record object-id width")); diff --git a/crates/mergemaster-evidence/tests/gate.rs b/crates/mergemaster-evidence/tests/gate.rs index e5a87de..cb809f8 100644 --- a/crates/mergemaster-evidence/tests/gate.rs +++ b/crates/mergemaster-evidence/tests/gate.rs @@ -4417,11 +4417,20 @@ print(json.dumps(dict( (format!("{tag}bad"), &first), (format!("{tag}{}/nested", "c".repeat(tree.len())), &first), (format!("refs/notes/evidence/{second}"), &first), - (format!("refs/notes/evidence/{}", "A".repeat(tree.len())), &first), + ( + format!("refs/notes/evidence/{}", "A".repeat(tree.len())), + &first, + ), (format!("refs/notes/evidence/{}", "d".repeat(39)), &first), (format!("refs/notes/evidence/{absent}/nested"), &first), - (format!("refs/notes/evidence-input/{CHECK}/{tree}/{first}"), &first), - (format!("refs/tags/evidence/input/{CHECK}/{tree}/{first}"), &first), + ( + format!("refs/notes/evidence-input/{CHECK}/{tree}/{first}"), + &first, + ), + ( + format!("refs/tags/evidence/input/{CHECK}/{tree}/{first}"), + &first, + ), (format!("refs/tags/evidence/{tree}-other/{first}"), &first), ] { git(&["update-ref", &name, target], b""); @@ -4434,12 +4443,19 @@ print(json.dumps(dict( assert_eq!(candidates, tags); // Namespace order, then deduplicated note. assert_eq!(legacy_refs(&repo, tree.len()).unwrap().len(), 2); assert!(legacy_refs(&repo, 41).is_err()); - for invalid in ["--help".to_owned(), tree.to_uppercase(), format!("{tree}\n")] { + for invalid in [ + "--help".to_owned(), + tree.to_uppercase(), + format!("{tree}\n"), + ] { assert!(tree_refs(&repo, &invalid).is_err()); } // GC of first-layout refs leaves tree-indexed candidates untouched. for id in [&first, &third] { - git(&["update-ref", "-d", &format!("refs/notes/evidence/{id}")], b""); + git( + &["update-ref", "-d", &format!("refs/notes/evidence/{id}")], + b"", + ); } compare(); assert_eq!(legacy_refs(&repo, tree.len()).unwrap().len(), 0);