Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,42 @@ pub fn records_under(repo: &Path, prefixes: &[String]) -> Result<Vec<String>, 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<Vec<String>, 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<Vec<String>, 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<Vec<String>, PolicyError> {
if !(1..=100).contains(&check.len())
|| !check.as_bytes()[0].is_ascii_alphanumeric()
Expand Down
119 changes: 119 additions & 0 deletions crates/mergemaster-evidence/tests/gate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4342,3 +4342,122 @@ 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);
}
}
5 changes: 5 additions & 0 deletions scripts/PROVENANCE
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down