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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions crates/mergemaster-evidence/src/evidence/legacy_verify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
37 changes: 24 additions & 13 deletions crates/mergemaster-evidence/src/evidence/legacy_verify/lane_git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<&'static str>, PolicyError> {
pub(super) fn config_path(
repo: &Path,
commit: &str,
legacy: &str,
) -> Result<Option<String>, PolicyError> {
crate::evidence::oid(commit).map_err(error)?;
let preferred = legacy.replacen(".st/", ".fractal/", 1);
let listing = git(
repo,
&[
Expand All @@ -146,25 +150,32 @@ fn config_path(repo: &Path, commit: &str) -> Result<Option<&'static str>, 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<String, PolicyError> {
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)
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Option<String>, 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<Option<Vec<u8>>, 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<Option<Vec<u8>>, 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<Option<Vec<u8>>, 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;
Original file line number Diff line number Diff line change
@@ -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<Option<Vec<u8>>, 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<Value> = 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]);
}
}
35 changes: 35 additions & 0 deletions crates/mergemaster-evidence/src/gate/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,41 @@ pub fn git(repo: &Path, args: &[&str]) -> Result<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> = (|| {
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<String> {
let mut value = String::from_utf8(git(repo, args)?).context("Git output is not UTF-8")?;
value.truncate(value.trim_end().len());
Expand Down
7 changes: 5 additions & 2 deletions scripts/PROVENANCE
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down