From 7cb0e4504fbb7499d8221c6a556aa72d901db796 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:55:49 +0100 Subject: [PATCH 1/2] Port legacy input Git tree projection agent-identity: agent/compoundingtech/mergemaster agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- README.md | 2 + .../src/evidence/legacy_verify.rs | 1 + .../src/evidence/legacy_verify/lane_git.rs | 2 +- .../src/evidence/legacy_verify/projection.rs | 118 +++++++++++++++ .../legacy_verify/projection_tests.rs | 139 ++++++++++++++++++ scripts/PROVENANCE | 2 + 6 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 crates/mergemaster-evidence/src/evidence/legacy_verify/projection.rs create mode 100644 crates/mergemaster-evidence/src/evidence/legacy_verify/projection_tests.rs diff --git a/README.md b/README.md index cf7d77a..554eed2 100644 --- a/README.md +++ b/README.md @@ -538,6 +538,8 @@ floats, JSON extensions and nesting above 127 containers refuse with named `input-selection-*` reasons; extension diagnostics identify the token and byte offset. `selection::selected` resolves the preferred `.fractal`/legacy `.st` layout at the supplied Git tree and reads strict UTF-8 there, never from the checkout. +`projection::project` computes legacy reduced Git-tree keys, retaining raw names, +symlink/gitlink modes and canonical selector entries; an empty projection has no key. ### Main-push tree-equality evidence diff --git a/crates/mergemaster-evidence/src/evidence/legacy_verify.rs b/crates/mergemaster-evidence/src/evidence/legacy_verify.rs index ffab221..5527992 100644 --- a/crates/mergemaster-evidence/src/evidence/legacy_verify.rs +++ b/crates/mergemaster-evidence/src/evidence/legacy_verify.rs @@ -6,6 +6,7 @@ pub mod judgment; pub mod lane_entry; pub mod lane_git; pub mod lanes; +pub mod projection; pub mod routing; pub mod selection; pub mod selection_json; diff --git a/crates/mergemaster-evidence/src/evidence/legacy_verify/lane_git.rs b/crates/mergemaster-evidence/src/evidence/legacy_verify/lane_git.rs index c49f9bc..d6ee893 100644 --- a/crates/mergemaster-evidence/src/evidence/legacy_verify/lane_git.rs +++ b/crates/mergemaster-evidence/src/evidence/legacy_verify/lane_git.rs @@ -41,7 +41,7 @@ pub fn definition_entries( } Ok(entries) } -fn parse_entries( +pub(super) fn parse_entries( listing: &[u8], entries: &mut BTreeSet, ) -> Result<(), PolicyError> { diff --git a/crates/mergemaster-evidence/src/evidence/legacy_verify/projection.rs b/crates/mergemaster-evidence/src/evidence/legacy_verify/projection.rs new file mode 100644 index 0000000..e541234 --- /dev/null +++ b/crates/mergemaster-evidence/src/evidence/legacy_verify/projection.rs @@ -0,0 +1,118 @@ +//! Legacy input tree projection, preserving raw Git names and modes. +use super::{ + lane_git::parse_entries, + selection::{selected_content, selector_item}, +}; +use crate::{ + evidence::legacy_policy::PolicyError, + gate::git::{command, git}, +}; +use std::{collections::BTreeSet, io::Write, path::Path, process::Stdio}; +fn error(detail: impl ToString) -> PolicyError { + PolicyError { + reason: "error", + detail: detail.to_string(), + } +} +// All callers produce bounded stdout; stderr is suppressed to avoid pipe deadlock +// and forwarding candidate-controlled diagnostics. Reap even if stdin fails. +fn input_git( + repo: &Path, + args: &[&str], + bytes: &[u8], + index: Option<&Path>, +) -> Result, PolicyError> { + let mut cmd = command(repo); + cmd.args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + if let Some(index) = index { + cmd.env("GIT_INDEX_FILE", index); + } + let mut child = cmd.spawn().map_err(error)?; + let written = child.stdin.take().unwrap().write_all(bytes); + if let Err(e) = written { + let _ = child.kill(); + let _ = child.wait(); + return Err(error(e)); + } + let output = child.wait_with_output().map_err(error)?; + if !output.status.success() { + return Err(error("Git projection plumbing failed")); + } + Ok(output.stdout) +} +pub fn project(repo: &Path, tree: &str, paths: &[String]) -> Result, PolicyError> { + let plain: Vec<_> = paths + .iter() + .filter(|p| !p.starts_with('!') && !p.contains("::")) + .map(String::as_str) + .collect(); + let excluded: Vec<_> = paths + .iter() + .filter_map(|p| p.strip_prefix('!')) + .map(str::as_bytes) + .collect(); + let mut chosen = BTreeSet::new(); + for path in paths.iter().filter(|p| p.contains("::")) { + if let Some(item) = selector_item(repo, tree, path)? { + chosen.insert(item); + } + } + let whole = plain.contains(&"."); + if whole && excluded.is_empty() && chosen.is_empty() { + return Ok(Some(tree.into())); + } + let mut entries = BTreeSet::new(); + if !plain.is_empty() { + let mut args = vec!["ls-tree", "-r", "-z", "--full-tree", tree]; + if !whole { + args.push("--"); + args.extend(plain); + } + parse_entries(&git(repo, &args).map_err(error)?, &mut entries)?; + } + let mut index_bytes = Vec::new(); + for entry in entries { + if excluded.iter().any(|out| { + let end = out.iter().rposition(|b| *b != b'/').map_or(0, |i| i + 1); + entry.path == *out + || entry + .path + .strip_prefix(&out[..end]) + .is_some_and(|tail| tail.starts_with(b"/")) + }) { + continue; + } + write!(index_bytes, "{} {} 0\t", entry.mode, entry.oid).map_err(error)?; + index_bytes.extend_from_slice(&entry.path); + index_bytes.push(0); + } + for item in chosen { + if let Some(bytes) = selected_content(repo, tree, &item)? { + let blob = + String::from_utf8(input_git(repo, &["hash-object", "--stdin"], &bytes, None)?) + .map_err(error)?; + write!(index_bytes, "100644 {} 0\t{item}\0", blob.trim()).map_err(error)?; + } + } + if index_bytes.is_empty() { + return Ok(None); + } + let scratch = tempfile::tempdir().map_err(error)?; + let index = scratch.path().join("index"); + input_git( + repo, + &["update-index", "-z", "--index-info"], + &index_bytes, + Some(&index), + )?; + let output = input_git(repo, &["write-tree", "--missing-ok"], &[], Some(&index))?; + Ok(Some( + String::from_utf8(output).map_err(error)?.trim().to_owned(), + )) +} +#[cfg(test)] +#[path = "projection_tests.rs"] +mod tests; diff --git a/crates/mergemaster-evidence/src/evidence/legacy_verify/projection_tests.rs b/crates/mergemaster-evidence/src/evidence/legacy_verify/projection_tests.rs new file mode 100644 index 0000000..f322a98 --- /dev/null +++ b/crates/mergemaster-evidence/src/evidence/legacy_verify/projection_tests.rs @@ -0,0 +1,139 @@ +use super::*; +use serde_json::{Value, json}; +use std::os::unix::{ + ffi::OsStringExt, + fs::{PermissionsExt, symlink}, +}; +#[test] +fn projection_matches_oracle_for_raw_modes_layout_and_selectors() { + let dir = tempfile::tempdir().unwrap(); + let repo = dir.path(); + let run = |args: &[&str]| { + String::from_utf8(git(repo, args).unwrap()) + .unwrap() + .trim() + .to_owned() + }; + run(&["init"]); + for (name, bytes) in [ + ("src/a", b"a\n".as_slice()), + ("src/nested/b", b"b\n"), + ("src-extra", b"extra\n"), + ("run", b"#!/bin/sh\n"), + (".st/inputs.json", br#"{"selected":{"ok":1},"other":0}"#), + ] { + let path = repo.join(name); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); + } + std::fs::set_permissions(repo.join("run"), std::fs::Permissions::from_mode(0o755)).unwrap(); + symlink("src/a", repo.join("link")).unwrap(); + std::fs::write( + repo.join(std::ffi::OsString::from_vec(b"src/raw\xff\t\n".to_vec())), + b"raw", + ) + .unwrap(); + run(&["add", "."]); + let first = run(&["write-tree"]); + let commit = run(&[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "commit-tree", + &first, + "-m", + "root", + ]); + run(&[ + "update-index", + "--add", + "--cacheinfo", + &format!("160000,{commit},module"), + ]); + let tree = run(&["write-tree"]); + std::fs::write( + repo.join(".st/inputs.json"), + br#"{"selected":{"ok":1},"other":2}"#, + ) + .unwrap(); + run(&["add", ".st/inputs.json"]); + let unrelated = run(&["write-tree"]); + std::fs::create_dir_all(repo.join(".fractal")).unwrap(); + std::fs::write( + repo.join(".fractal/inputs.json"), + br#"{"selected":{"preferred":1}}"#, + ) + .unwrap(); + run(&["add", ".fractal/inputs.json"]); + let both = run(&["write-tree"]); + let paths = [ + vec![], + vec!["."], + vec!["missing"], + vec!["src"], + vec!["src", "src/a"], + vec!["run", "link", "module"], + vec![".", "!src"], + vec![".", "!src/"], + vec![".", "!src///"], + vec![".", "!src/nested"], + vec![".", "!src-extra"], + vec!["src", "!src"], + vec!["!src"], + vec!["src", "!src/a"], + vec![".", ".st/missing.json::x"], + vec![".st/inputs.json::selected"], + vec![".fractal/inputs.json::selected"], + vec![ + ".st/inputs.json::selected", + ".fractal/inputs.json::selected", + ], + vec![".st/inputs.json::missing"], + vec![".", ".st/inputs.json::selected"], + vec!["src", ".st/inputs.json::selected"], + ]; + let mut cases = Vec::new(); + let mut actual = Vec::new(); + for tree in [&tree, &unrelated, &both] { + for paths in &paths { + let paths: Vec = paths.iter().map(|p| (*p).into()).collect(); + cases.push(json!([tree, paths])); + actual.push(match project(repo, tree, &paths) { + Ok(key) => json!({"key":key}), + Err(e) => json!({"reason":e.reason}), + }); + } + } + let root = std::env::var("MERGEMASTER_PYTHON_ORACLE") + .expect("MERGEMASTER_PYTHON_ORACLE must point to the pinned Python oracle"); + let script = r#" +import json, runpy, sys +o=runpy.run_path(sys.argv[1]+'/scripts/evidence-verify') +results=[] +for tree,paths in json.loads(sys.argv[3]): + try: results.append(dict(key=o['project'](sys.argv[2],tree,paths))) + except o['Failure'] as e: results.append(dict(reason=e.reason)) +print(json.dumps(results)) +"#; + let output = std::process::Command::new("python3") + .args([ + "-c", + script, + &root, + repo.to_str().unwrap(), + &json!(cases).to_string(), + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let expected: Vec = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(actual.len(), expected.len()); + for (i, (actual, expected)) in actual.iter().zip(expected).enumerate() { + assert_eq!(*actual, expected, "{}", cases[i]); + } +} diff --git a/scripts/PROVENANCE b/scripts/PROVENANCE index b2584a6..077142f 100644 --- a/scripts/PROVENANCE +++ b/scripts/PROVENANCE @@ -26,6 +26,8 @@ # selected-input history scan (243 blob-selector pairs, maximum depth 8). # Rust parity: selected-content-layout (523-548). Layout resolution uses the # supplied tree; a present invalid preferred file never falls back to legacy. +# Rust parity: input-projection (566-605). Raw names/modes, component exclusions, +# resolved selector identity, missing selections and empty/whole trees match. # Open gap: git-subprocess-timeout: gate::git and lane_git have no timeout. # Open gap: lane-git-output-bounds: oracle and Rust have no byte/entry bound; # a limit requires a later reviewed behavior decision. From 5e68f970486ba66df19e4a03c9e5819b1b248e7a Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:22:57 +0100 Subject: [PATCH 2/2] Harden selected-content tree revisions before Git reads agent-identity: agent/compoundingtech/mergemaster agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- README.md | 2 +- .../src/evidence/legacy_verify/projection.rs | 3 +-- .../src/evidence/legacy_verify/selection.rs | 17 ++++++++++++++--- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 28e349e..3ee9984 100644 --- a/README.md +++ b/README.md @@ -540,7 +540,7 @@ floats, JSON extensions and nesting above 127 containers refuse with named the supplied Git tree and reads strict UTF-8 there, never from the checkout. Selected blobs above 16 MiB refuse with `error` before decoding or selection. `projection::project` computes legacy reduced Git-tree keys, retaining raw names, -symlink/gitlink modes and canonical selector entries; an empty projection has no key. +symlink/gitlink modes and canonical selector entries; empty projections have no key. Selected reads require full tree OIDs. ### Main-push tree-equality evidence diff --git a/crates/mergemaster-evidence/src/evidence/legacy_verify/projection.rs b/crates/mergemaster-evidence/src/evidence/legacy_verify/projection.rs index e541234..2720076 100644 --- a/crates/mergemaster-evidence/src/evidence/legacy_verify/projection.rs +++ b/crates/mergemaster-evidence/src/evidence/legacy_verify/projection.rs @@ -14,8 +14,7 @@ fn error(detail: impl ToString) -> PolicyError { detail: detail.to_string(), } } -// All callers produce bounded stdout; stderr is suppressed to avoid pipe deadlock -// and forwarding candidate-controlled diagnostics. Reap even if stdin fails. +// Bounded stdout and suppressed stderr avoid pipe deadlock; reap on stdin failure. fn input_git( repo: &Path, args: &[&str], diff --git a/crates/mergemaster-evidence/src/evidence/legacy_verify/selection.rs b/crates/mergemaster-evidence/src/evidence/legacy_verify/selection.rs index 023aa8e..ebdb72f 100644 --- a/crates/mergemaster-evidence/src/evidence/legacy_verify/selection.rs +++ b/crates/mergemaster-evidence/src/evidence/legacy_verify/selection.rs @@ -40,10 +40,21 @@ fn selected_content_limit( item: &str, max_bytes: usize, ) -> Result>, PolicyError> { + crate::evidence::oid(tree).map_err(error)?; let (path, selector) = item.split_once("::").unwrap_or((item, "")); - if git(repo, &["ls-tree", "--full-tree", tree, "--", path]) - .map_err(error)? - .is_empty() + if git( + repo, + &[ + "ls-tree", + "--full-tree", + "--end-of-options", + tree, + "--", + path, + ], + ) + .map_err(error)? + .is_empty() { return Ok(None); }