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 @@ -533,6 +533,9 @@ Lane discovery uses the gate's sanitized Git command and rejects malformed or
unterminated ls-tree rows; shared subprocess timeout/output bounds remain open.
`workflow::workflow_job` selects exact generated-workflow text and transitive
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.

### 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_json;
pub mod workflow;
use super::legacy_policy::kind_rules::KindRule;
use serde::Serialize;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
//! Exact supported JSON selections; approved refusals never become missing entries.
use crate::evidence::{
legacy_json::{self, Value, encode_integer, extension_syntax, has_float, limits},
legacy_policy::PolicyError,
};
fn error(reason: &'static str, detail: impl Into<String>) -> PolicyError {
PolicyError {
reason,
detail: detail.into(),
}
}
fn unit(bytes: Option<&[u8]>) -> Option<u16> {
u16::from_str_radix(std::str::from_utf8(bytes?).ok()?, 16).ok()
}
/// The first extension token and its byte offset, excluding escaped backslashes and paired surrogates.
fn extension(text: &str) -> Option<(&str, usize)> {
let b = text.as_bytes();
let (mut i, mut quoted) = (0, false);
while i < b.len() {
if b[i] == b'"' {
quoted = !quoted;
i += 1;
continue;
}
if quoted && b[i] == b'\\' {
if b.get(i + 1) == Some(&b'u') {
let value = unit(b.get(i + 2..i + 6))?;
if (0xd800..=0xdfff).contains(&value) {
if value <= 0xdbff
&& b.get(i + 6..i + 8) == Some(b"\\u")
&& unit(b.get(i + 8..i + 12))
.is_some_and(|v| (0xdc00..=0xdfff).contains(&v))
{
i += 12;
continue;
}
return Some((&text[i..i + 6], i));
}
i += 6;
} else {
i += 2;
}
continue;
}
if !quoted
&& let Some(token) = ["-Infinity", "Infinity", "NaN"]
.into_iter()
.find(|t| b[i..].starts_with(t.as_bytes()))
{
return Some((token, i));
}
i += 1;
}
None
}
pub fn select_json(text: &str, selector: &str) -> Result<Option<Vec<u8>>, PolicyError> {
let (depth, long) = limits(text);
if long {
return Err(error("error", "JSON integer exceeds 4300 digits"));
}
if depth > 127 {
return Err(error(
"input-selection-nesting-depth",
"JSON exceeds 127 open containers",
));
}
let mut value = legacy_json::parse(text.as_bytes()).map_err(|_| {
if let Some((token, at)) = extension(text).filter(|_| extension_syntax(text)) {
error(
"input-selection-json-extensions",
format!("unsupported JSON token {token} at byte offset {at}"),
)
} else {
error("error", "selected input is not supported JSON")
}
})?;
for key in selector.split('.') {
let Value::Object(mut object) = value else {
return Ok(None);
};
let Some(next) = object.remove(key) else {
return Ok(None);
};
value = next;
}
if has_float(&value) {
return Err(error(
"input-selection-float-text",
"selected value contains a floating number",
));
}
let mut output = String::new();
encode_integer(&value, &mut output);
Ok(Some(output.into_bytes()))
}
#[cfg(test)]
#[path = "selection_json_tests.rs"]
mod tests;
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
use super::select_json;
use serde_json::{Value, json};
#[test]
fn selected_json_matches_oracle_or_explicit_rejected_reason() {
type Case = (
String,
&'static str,
Option<&'static str>,
Option<(&'static str, usize)>,
);
let mut cases: Vec<Case> = [
r#"{"selected":{"z":1,"a":[null,true,"é😀"]}}"#,
r#"{"selected":18446744073709551616}"#,
r#"{"selected":-0}"#,
r#"{"selected":0,"selected":1}"#,
r#"{"selected":{"z":0,"z":1}}"#,
r#"{"selected":1.0,"selected":1}"#,
r#"{"selected":"\\ud800"}"#,
r#"{"selected":"\ud83d\ude00"}"#,
r#"{"selected":1,"other":1e400}"#,
"{}",
"[]",
"null",
"not JSON",
r#"{"selected":01}"#,
]
.map(|text| (text.into(), "selected", None, None))
.into();
cases.push((
r#"{"selected":{"inner":1}}"#.into(),
"selected.inner",
None,
None,
));
for number in [
"1.0",
"-0.0",
"1e16",
"1e-5",
"0.1",
"5e-324",
"1.7976931348623157e308",
"1e400",
"{\"n\":1.0}",
] {
cases.push((
format!("{{\"selected\":{number}}}"),
"selected",
Some("input-selection-float-text"),
None,
));
}
for (value, token) in [
("NaN", "NaN"),
("Infinity", "Infinity"),
("-Infinity", "-Infinity"),
(r#""\ud800""#, r#"\ud800"#),
(r#""\udcff""#, r#"\udcff"#),
] {
for text in [
format!("{{\"selected\":{value}}}"),
format!("{{\"é\":0,\"selected\":{{\"ok\":1}},\"other\":{value}}}"),
] {
let at = text.find(token).unwrap();
cases.push((
text,
"selected",
Some("input-selection-json-extensions"),
Some((token, at)),
));
}
}
for digits in [4300, 4301] {
cases.push((
format!("{{\"selected\":{}}}", "9".repeat(digits)),
"selected",
None,
None,
));
}
for depth in [127, 128, 200, 1200] {
for member in ["selected", "other"] {
let text = format!(
"{{\"{member}\":{}null{},\"ok\":1}}",
"[".repeat(depth - 1),
"]".repeat(depth - 1)
);
cases.push((
text,
if member == "selected" {
"selected"
} else {
"ok"
},
(depth > 127).then_some("input-selection-nesting-depth"),
None,
));
}
}
let results: Vec<_> = cases
.iter()
.map(|(text, key, _, _)| select_json(text, key))
.collect();
let actual: Vec<_> = results
.iter()
.map(|result| match result {
Ok(bytes) => json!({"hex":bytes.as_ref().map(hex::encode)}),
Err(e) => json!({"reason":e.reason}),
})
.collect();
let root = std::env::var("MERGEMASTER_PYTHON_ORACLE")
.expect("MERGEMASTER_PYTHON_ORACLE must point to the pinned Python oracle");
let fractal = std::env::var("MERGEMASTER_FRACTAL_FIXTURE")
.expect("MERGEMASTER_FRACTAL_FIXTURE must point to the pinned Fractal consumer");
let script = r#"
import json, runpy, sys
o = runpy.run_path(sys.argv[1] + '/scripts/evidence-verify')
gate = runpy.run_path(sys.argv[2] + '/scripts/ci-evidence-gate')
results = []
for (text, key, _, _), actual in zip(json.loads(sys.argv[3]),json.loads(sys.argv[4])):
try:
value = o['select_part'](text,'fixture.json',key)
result = dict(hex=value.hex() if value is not None else None)
except o['Failure'] as e:
result = dict(reason=e.reason)
category = gate['classify'](1,dict(satisfied=False,reason=actual['reason'])) if 'reason' in actual else None
results.append(dict(oracle=result,category=category))
print(json.dumps(results))
"#;
let output = std::process::Command::new("python3")
.args([
"-c",
script,
&root,
&fractal,
&json!(cases).to_string(),
&json!(actual).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, (_, _, reason, token)) in cases.iter().enumerate() {
println!("{}", json!({"rust":actual[i],"oracle":expected[i]}));
if let Some(reason) = reason {
assert_eq!(actual[i]["reason"], *reason);
assert_eq!(expected[i]["category"], "rejected");
assert!(expected[i]["oracle"]["hex"].is_string());
} else {
assert_eq!(actual[i], expected[i]["oracle"], "{}", cases[i].0);
}
if let Some((token, at)) = token {
assert_eq!(
results[i].as_ref().unwrap_err().detail,
format!("unsupported JSON token {token} at byte offset {at}")
);
}
}
}
8 changes: 8 additions & 0 deletions scripts/PROVENANCE
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@
# dependency closure match; workflow layout is not interpreted as general YAML.
# Shared legacy JSON guards/encoding preserve lane behavior; native and push
# parsing and all JSON dependency features remain unchanged.
# Rust parity: JSON input selection (543-563), including large integers, -0,
# duplicate keys and the 4300-digit cap; unrelated floats remain supported.
# Conservative divergences: input-selection-float-text (selected floats),
# input-selection-json-extensions (whole-document NaN/Infinity/lone surrogates;
# diagnostic names the token and byte offset), input-selection-nesting-depth
# (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).
# 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