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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,8 @@ floats, JSON extensions and nesting above 127 containers refuse with named
`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.
`projection::project` computes legacy reduced Git-tree keys, retaining raw names,
symlink/gitlink modes and canonical selector entries; empty projections have no key. Selected reads require full tree OIDs.

### 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 @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ pub fn definition_entries(
}
Ok(entries)
}
fn parse_entries(
pub(super) fn parse_entries(
listing: &[u8],
entries: &mut BTreeSet<DefinitionEntry>,
) -> Result<(), PolicyError> {
Expand Down
117 changes: 117 additions & 0 deletions crates/mergemaster-evidence/src/evidence/legacy_verify/projection.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
//! 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(),
}
}
// Bounded stdout and suppressed stderr avoid pipe deadlock; reap on stdin failure.
fn input_git(
repo: &Path,
args: &[&str],
bytes: &[u8],
index: Option<&Path>,
) -> Result<Vec<u8>, 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<Option<String>, 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;
Original file line number Diff line number Diff line change
@@ -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<String> = 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<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]);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,21 @@ fn selected_content_limit(
item: &str,
max_bytes: usize,
) -> Result<Option<Vec<u8>>, 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);
}
Expand Down
2 changes: 2 additions & 0 deletions scripts/PROVENANCE
Original file line number Diff line number Diff line change
Expand Up @@ -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.
# 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).
Expand Down