diff --git a/README.md b/README.md index 1b423b2..8328845 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/mergemaster-evidence/src/evidence/legacy_verify.rs b/crates/mergemaster-evidence/src/evidence/legacy_verify.rs index a8c0a92..77acd33 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_json; pub mod workflow; use super::legacy_policy::kind_rules::KindRule; use serde::Serialize; diff --git a/crates/mergemaster-evidence/src/evidence/legacy_verify/selection_json.rs b/crates/mergemaster-evidence/src/evidence/legacy_verify/selection_json.rs new file mode 100644 index 0000000..244dd72 --- /dev/null +++ b/crates/mergemaster-evidence/src/evidence/legacy_verify/selection_json.rs @@ -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) -> PolicyError { + PolicyError { + reason, + detail: detail.into(), + } +} +fn unit(bytes: Option<&[u8]>) -> Option { + 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>, 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; diff --git a/crates/mergemaster-evidence/src/evidence/legacy_verify/selection_json_tests.rs b/crates/mergemaster-evidence/src/evidence/legacy_verify/selection_json_tests.rs new file mode 100644 index 0000000..d461414 --- /dev/null +++ b/crates/mergemaster-evidence/src/evidence/legacy_verify/selection_json_tests.rs @@ -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 = [ + 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 = 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}") + ); + } + } +} diff --git a/scripts/PROVENANCE b/scripts/PROVENANCE index 9cfe7ee..d8aeebe 100644 --- a/scripts/PROVENANCE +++ b/scripts/PROVENANCE @@ -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).