From 334e6e61bc2517045ae5a3aa8089db085dc2f5b5 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:51:46 +0100 Subject: [PATCH 1/2] Port legacy selected-content layout resolution 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 | 43 +++++-- .../src/evidence/legacy_verify/selection.rs | 52 ++++++++ .../evidence/legacy_verify/selection_tests.rs | 115 ++++++++++++++++++ scripts/PROVENANCE | 2 + 6 files changed, 202 insertions(+), 13 deletions(-) create mode 100644 crates/mergemaster-evidence/src/evidence/legacy_verify/selection.rs create mode 100644 crates/mergemaster-evidence/src/evidence/legacy_verify/selection_tests.rs diff --git a/README.md b/README.md index 8328845..cf7d77a 100644 --- a/README.md +++ b/README.md @@ -536,6 +536,8 @@ job dependencies; it follows the legacy layout contract, not general YAML. `selection_json::select_json` preserves legacy integer JSON selections. Selected 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. ### 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 77acd33..ffab221 100644 --- a/crates/mergemaster-evidence/src/evidence/legacy_verify.rs +++ b/crates/mergemaster-evidence/src/evidence/legacy_verify.rs @@ -7,6 +7,7 @@ pub mod lane_entry; pub mod lane_git; pub mod lanes; pub mod routing; +pub mod selection; pub mod selection_json; pub mod workflow; use super::legacy_policy::kind_rules::KindRule; 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 d093327..c49f9bc 100644 --- a/crates/mergemaster-evidence/src/evidence/legacy_verify/lane_git.rs +++ b/crates/mergemaster-evidence/src/evidence/legacy_verify/lane_git.rs @@ -134,8 +134,12 @@ pub fn lane_definition( } const LANES: &str = ".st/required-lanes.json"; -const PREFERRED: &str = ".fractal/required-lanes.json"; -fn config_path(repo: &Path, commit: &str) -> Result, PolicyError> { +pub(super) fn config_path( + repo: &Path, + commit: &str, + legacy: &str, +) -> Result, PolicyError> { + let preferred = legacy.replacen(".st/", ".fractal/", 1); let listing = git( repo, &[ @@ -144,23 +148,33 @@ fn config_path(repo: &Path, commit: &str) -> Result, Policy "--full-tree", commit, "--", - PREFERRED, - LANES, + &preferred, + legacy, ], ) .map_err(error)?; let mut entries = BTreeSet::new(); parse_entries(&listing, &mut entries)?; - Ok([PREFERRED, LANES].into_iter().find(|path| { - entries - .iter() - .any(|entry| entry.path.as_slice() == path.as_bytes()) - })) + Ok([preferred.as_str(), legacy] + .into_iter() + .find(|path| { + entries + .iter() + .any(|entry| entry.path.as_slice() == path.as_bytes()) + }) + .map(str::to_owned)) } /// Read the accepted revision, not the checkout; a present preferred file never falls back. pub fn read_lanes(repo: &Path, commit: &str) -> Result { - let path = config_path(repo, commit)?.unwrap_or(LANES); - let shown = git(repo, &["show", &format!("{commit}:{path}")]).map_err(error)?; + let path = config_path(repo, commit, LANES)?; + let shown = git( + repo, + &[ + "show", + &format!("{commit}:{}", path.as_deref().unwrap_or(LANES)), + ], + ) + .map_err(error)?; String::from_utf8(shown).map_err(error) } pub fn lane_for( @@ -177,10 +191,13 @@ pub fn lane_for( if let Some(base) = lane_base { crate::evidence::oid(base).map_err(error)?; git(repo, &["merge-base", "--is-ancestor", base, accepted]).map_err(error)?; - let path = config_path(repo, base)?; + let path = config_path(repo, base, LANES)?; let shown = git( repo, - &["show", &format!("{base}:{}", path.unwrap_or(LANES))], + &[ + "show", + &format!("{base}:{}", path.as_deref().unwrap_or(LANES)), + ], ); let here = match shown { Ok(bytes) => super::lane_entry::canonical(&String::from_utf8_lossy(&bytes), check)?, diff --git a/crates/mergemaster-evidence/src/evidence/legacy_verify/selection.rs b/crates/mergemaster-evidence/src/evidence/legacy_verify/selection.rs new file mode 100644 index 0000000..61acd9b --- /dev/null +++ b/crates/mergemaster-evidence/src/evidence/legacy_verify/selection.rs @@ -0,0 +1,52 @@ +//! Resolve configuration layout and select content from the supplied Git tree only. +use super::{lane_git::config_path, selection_json::select_json, workflow::workflow_job}; +use crate::{evidence::legacy_policy::PolicyError, gate::git::git}; +use std::path::Path; +fn error(detail: impl ToString) -> PolicyError { + PolicyError { + reason: "error", + detail: detail.to_string(), + } +} +pub fn selector_item(repo: &Path, tree: &str, item: &str) -> Result, PolicyError> { + if let Some((path, selector)) = item.split_once("::") + && (path.starts_with(".st/") || path.starts_with(".fractal/")) + { + let legacy = path.replacen(".fractal/", ".st/", 1); + return Ok(config_path(repo, tree, &legacy)?.map(|path| format!("{path}::{selector}"))); + } + Ok(Some(item.to_owned())) +} +pub fn selected(repo: &Path, tree: &str, item: &str) -> Result>, PolicyError> { + match selector_item(repo, tree, item)? { + Some(item) => selected_content(repo, tree, &item), + None => Ok(None), + } +} +pub fn selected_content( + repo: &Path, + tree: &str, + item: &str, +) -> Result>, PolicyError> { + let (path, selector) = item.split_once("::").unwrap_or((item, "")); + if git(repo, &["ls-tree", "--full-tree", tree, "--", path]) + .map_err(error)? + .is_empty() + { + return Ok(None); + } + let shown = git( + repo, + &["show", "--end-of-options", &format!("{tree}:{path}")], + ) + .map_err(error)?; + let text = std::str::from_utf8(&shown).map_err(error)?; + if let Some(job) = selector.strip_prefix("jobs.") { + workflow_job(text, job) + } else { + select_json(text, selector) + } +} +#[cfg(test)] +#[path = "selection_tests.rs"] +mod tests; diff --git a/crates/mergemaster-evidence/src/evidence/legacy_verify/selection_tests.rs b/crates/mergemaster-evidence/src/evidence/legacy_verify/selection_tests.rs new file mode 100644 index 0000000..2486f2e --- /dev/null +++ b/crates/mergemaster-evidence/src/evidence/legacy_verify/selection_tests.rs @@ -0,0 +1,115 @@ +use super::*; +use serde_json::{Value, json}; +#[test] +fn content_and_layout_match_oracle_at_each_tree() { + 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"]); + let write = |path: &str, bytes: &[u8]| { + let path = repo.join(path); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); + }; + write(".st/inputs.json", br#"{"selected":{"legacy":1},"":2}"#); + write("plain.json", br#"{"selected":{"z":1,"a":2}}"#); + write("bad.json", b"not JSON"); + write("invalid.json", b"\xff"); + write("workflow.yml",b"name: CI\njobs:\n build:\n runs-on: linux\n test:\n needs: build\n runs-on: linux\n"); + run(&["add", "."]); + let legacy = run(&["write-tree"]); + write(".fractal/inputs.json", br#"{"selected":{"preferred":1}}"#); + run(&["add", "."]); + let both = run(&["write-tree"]); + write(".fractal/inputs.json", b"bad preferred JSON"); + run(&["add", "."]); + let malformed = run(&["write-tree"]); + write(".fractal/inputs.json", b"\xff"); + run(&["add", "."]); + let nontext = run(&["write-tree"]); + write(".fractal/inputs.json", br#"{"selected":{"preferred":1}}"#); + run(&["add", "."]); + std::fs::remove_file(repo.join(".st/inputs.json")).unwrap(); + run(&["add", "-u"]); + let preferred = run(&["write-tree"]); + // The checkout is now invalid: every selection must still read the exact tree. + write(".fractal/inputs.json", b"invalid checkout"); + let items = [ + ".st/inputs.json::selected", + ".fractal/inputs.json::selected", + ".st/missing.json::selected", + ".fractal/missing.json::selected", + "plain.json::selected", + "plain.json::selected.missing", + "plain.json::", + "missing.json::selected", + "bad.json::selected", + "invalid.json::selected", + "workflow.yml::jobs.test", + "workflow.yml::jobs.absent", + "plain.json::selected::extra", + ".st/inputs.json", + ]; + let mut cases = Vec::new(); + let mut actual = Vec::new(); + for tree in [&legacy, &both, &preferred, &malformed, &nontext] { + for item in items { + cases.push(json!([tree, item])); + let path = match selector_item(repo, tree, item) { + Ok(path) => json!({"path":path}), + Err(e) => json!({"reason":e.reason}), + }; + let encode = |result: Result>, PolicyError>| match result { + Ok(bytes) => json!({"hex":bytes.map(hex::encode)}), + Err(e) => json!({"reason":e.reason}), + }; + actual.push(json!([ + path, + encode(selected(repo, tree, item)), + encode(selected_content(repo, tree, item)) + ])); + } + } + 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,item in json.loads(sys.argv[3]): + row=[] + for name in ['selector_item','selected','selected_content']: + try: + value=o[name](sys.argv[2],tree,item) + row.append(dict(path=value) if name=='selector_item' else dict(hex=value.hex() if value is not None else None)) + except o['Failure'] as e: + row.append(dict(reason=e.reason)) + results.append(row) +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 211ee15..b2584a6 100644 --- a/scripts/PROVENANCE +++ b/scripts/PROVENANCE @@ -24,6 +24,8 @@ # (more than 127 open containers). Oracle accepts the 1200-container fixtures; # Rust refuses; pinned classify() reports rejected. Approved after a zero-hit # 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. # 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 2ab04d914eafbe862c76d438d5a3312d3905d51b Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:21:17 +0100 Subject: [PATCH 2/2] Bound selected input blob reads before selectors 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 | 1 + .../src/evidence/legacy_verify/selection.rs | 17 +++++++-- .../evidence/legacy_verify/selection_tests.rs | 14 ++++++++ crates/mergemaster-evidence/src/gate/git.rs | 35 +++++++++++++++++++ scripts/PROVENANCE | 5 +-- 5 files changed, 68 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index cf7d77a..589d88f 100644 --- a/README.md +++ b/README.md @@ -538,6 +538,7 @@ 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. +Selected blobs above 16 MiB refuse with `error` before decoding or selection. ### Main-push tree-equality evidence diff --git a/crates/mergemaster-evidence/src/evidence/legacy_verify/selection.rs b/crates/mergemaster-evidence/src/evidence/legacy_verify/selection.rs index 61acd9b..023aa8e 100644 --- a/crates/mergemaster-evidence/src/evidence/legacy_verify/selection.rs +++ b/crates/mergemaster-evidence/src/evidence/legacy_verify/selection.rs @@ -1,6 +1,9 @@ //! Resolve configuration layout and select content from the supplied Git tree only. use super::{lane_git::config_path, selection_json::select_json, workflow::workflow_job}; -use crate::{evidence::legacy_policy::PolicyError, gate::git::git}; +use crate::{ + evidence::legacy_policy::PolicyError, + gate::git::{git, git_bounded}, +}; use std::path::Path; fn error(detail: impl ToString) -> PolicyError { PolicyError { @@ -27,6 +30,15 @@ pub fn selected_content( repo: &Path, tree: &str, item: &str, +) -> Result>, PolicyError> { + // Generous configuration/workflow cap; enforce before decoding or either selector. + selected_content_limit(repo, tree, item, 16 * 1024 * 1024) +} +fn selected_content_limit( + repo: &Path, + tree: &str, + item: &str, + max_bytes: usize, ) -> Result>, PolicyError> { let (path, selector) = item.split_once("::").unwrap_or((item, "")); if git(repo, &["ls-tree", "--full-tree", tree, "--", path]) @@ -35,9 +47,10 @@ pub fn selected_content( { return Ok(None); } - let shown = git( + let shown = git_bounded( repo, &["show", "--end-of-options", &format!("{tree}:{path}")], + max_bytes, ) .map_err(error)?; let text = std::str::from_utf8(&shown).map_err(error)?; diff --git a/crates/mergemaster-evidence/src/evidence/legacy_verify/selection_tests.rs b/crates/mergemaster-evidence/src/evidence/legacy_verify/selection_tests.rs index 2486f2e..a0d27f3 100644 --- a/crates/mergemaster-evidence/src/evidence/legacy_verify/selection_tests.rs +++ b/crates/mergemaster-evidence/src/evidence/legacy_verify/selection_tests.rs @@ -23,6 +23,20 @@ fn content_and_layout_match_oracle_at_each_tree() { write("workflow.yml",b"name: CI\njobs:\n build:\n runs-on: linux\n test:\n needs: build\n runs-on: linux\n"); run(&["add", "."]); let legacy = run(&["write-tree"]); + for item in ["plain.json::selected", "workflow.yml::jobs.test"] { + let path = item.split_once("::").unwrap().0; + let size = std::fs::read(repo.join(path)).unwrap().len(); + assert_eq!( + selected_content_limit(repo, &legacy, item, size - 1) + .unwrap_err() + .reason, + "error" + ); + assert_eq!( + selected_content_limit(repo, &legacy, item, size).unwrap(), + selected_content(repo, &legacy, item).unwrap() + ); + } write(".fractal/inputs.json", br#"{"selected":{"preferred":1}}"#); run(&["add", "."]); let both = run(&["write-tree"]); diff --git a/crates/mergemaster-evidence/src/gate/git.rs b/crates/mergemaster-evidence/src/gate/git.rs index ae632a4..c2e084b 100644 --- a/crates/mergemaster-evidence/src/gate/git.rs +++ b/crates/mergemaster-evidence/src/gate/git.rs @@ -73,6 +73,41 @@ pub fn git(repo: &Path, args: &[&str]) -> Result> { Ok(output.stdout) } +/// Refuse oversized output without exposing a truncated result or Git diagnostics. +pub fn git_bounded(repo: &Path, args: &[&str], max_bytes: usize) -> Result> { + let limit = u64::try_from(max_bytes)? + .checked_add(1) + .context("Git byte limit overflow")?; + let mut child = command(repo) + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn()?; + let result: Result> = (|| { + let mut bytes = Vec::new(); + child + .stdout + .take() + .context("Git stdout")? + .take(limit) + .read_to_end(&mut bytes)?; + ensure!( + bytes.len() <= max_bytes, + "Git output exceeds {max_bytes} bytes" + ); + Ok(bytes) + })(); + if result.is_err() { + let _ = child.kill(); + } + let status = child.wait()?; + ensure!( + status.success() || result.is_err(), + "Git plumbing failed ({status})" + ); + result +} + pub fn text(repo: &Path, args: &[&str]) -> Result { let mut value = String::from_utf8(git(repo, args)?).context("Git output is not UTF-8")?; value.truncate(value.trim_end().len()); diff --git a/scripts/PROVENANCE b/scripts/PROVENANCE index 5a25b76..295283f 100644 --- a/scripts/PROVENANCE +++ b/scripts/PROVENANCE @@ -31,8 +31,9 @@ # never supply these; its suffix-only test counts ambiguous replies as present (impossible with full OIDs). # Lane configuration revisions require full lowercase OIDs; Git option parsing ends explicitly. # 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. +# Open gap: lane-git-output-bounds: 3e-4 closes selector-input P3 with a 16 MiB +# selected-blob read cap (generous config/workflow allowance), before UTF-8/selection. +# Oversize is error, never truncation; other Git reads and entry counts remain unbounded. # Rust parity P2-a: degraded-veto-and-fallback-exclusion. Unsigned degraded # records do not shadow trusted candidates during non-push selection. # Conservative divergence P3: wrong-definition-plus-bound-input. Python passes