diff --git a/README.md b/README.md index 8328845..589d88f 100644 --- a/README.md +++ b/README.md @@ -536,6 +536,9 @@ 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. +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.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 28b8e86..505ca5d 100644 --- a/crates/mergemaster-evidence/src/evidence/legacy_verify/lane_git.rs +++ b/crates/mergemaster-evidence/src/evidence/legacy_verify/lane_git.rs @@ -134,9 +134,13 @@ 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> { crate::evidence::oid(commit).map_err(error)?; + let preferred = legacy.replacen(".st/", ".fractal/", 1); let listing = git( repo, &[ @@ -146,25 +150,32 @@ fn config_path(repo: &Path, commit: &str) -> Result, Policy "--end-of-options", 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 path = config_path(repo, commit, LANES)?; let shown = git( repo, - &["show", "--end-of-options", &format!("{commit}:{path}")], + &[ + "show", + "--end-of-options", + &format!("{commit}:{}", path.as_deref().unwrap_or(LANES)), + ], ) .map_err(error)?; String::from_utf8(shown).map_err(error) @@ -184,13 +195,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", "--end-of-options", - &format!("{base}:{}", path.unwrap_or(LANES)), + &format!("{base}:{}", path.as_deref().unwrap_or(LANES)), ], ); let here = match shown { 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..023aa8e --- /dev/null +++ b/crates/mergemaster-evidence/src/evidence/legacy_verify/selection.rs @@ -0,0 +1,65 @@ +//! 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, git_bounded}, +}; +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> { + // 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]) + .map_err(error)? + .is_empty() + { + return Ok(None); + } + 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)?; + 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..a0d27f3 --- /dev/null +++ b/crates/mergemaster-evidence/src/evidence/legacy_verify/selection_tests.rs @@ -0,0 +1,129 @@ +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"]); + 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"]); + 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/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 d8aeebe..295283f 100644 --- a/scripts/PROVENANCE +++ b/scripts/PROVENANCE @@ -24,13 +24,16 @@ # (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. # Hardening divergence: discovery-input-hardening rejects invalid ref prefixes, # non-OID commits, LF/CR/NUL note paths and malformed batch frames. Python callers # 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