From d62136a9bc07597525bd21a85f46c98f04856b02 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:40:40 +0100 Subject: [PATCH] Share legacy integer JSON guards and exact escaping agent-identity: agent/compoundingtech/mergemaster agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- README.md | 2 + .../src/evidence/legacy_json.rs | 124 +++++++++++++++++- .../src/evidence/legacy_verify/lane_entry.rs | 100 +------------- .../src/evidence/legacy_verify/lanes.rs | 22 +--- scripts/PROVENANCE | 2 + 5 files changed, 130 insertions(+), 120 deletions(-) diff --git a/README.md b/README.md index 0d4de2d..1b423b2 100644 --- a/README.md +++ b/README.md @@ -518,6 +518,8 @@ including surrogateescaped filenames; native lane hashing remains unchanged. Legacy lane-entry parsing preserves integer lexemes and reports unsupported JSON explicitly. Canonical lane-entry comparisons use Python-compatible sorted compact JSON text, not parsed-value equality; callers compare before validating the lane schema. +Legacy JSON helpers share integer-only encoding and ASCII string escaping; +callers guard unsupported number lexemes before encoding. `legacy_verify::judgment::judge` routes before lazy lane lookup, then checks the runner, clock/inputs, lane identity and ordered Git bindings. Earlier guards never fetch later provider resources; a supplied lane avoids the lazy lookup. diff --git a/crates/mergemaster-evidence/src/evidence/legacy_json.rs b/crates/mergemaster-evidence/src/evidence/legacy_json.rs index f153c83..d05b59e 100644 --- a/crates/mergemaster-evidence/src/evidence/legacy_json.rs +++ b/crates/mergemaster-evidence/src/evidence/legacy_json.rs @@ -1,6 +1,10 @@ //! Local JSON values retain number lexemes without enabling serde_json extension features. use anyhow::{Context, Result, bail, ensure}; -use std::{collections::BTreeMap, ops::Index}; +use std::{ + collections::BTreeMap, + fmt::{self, Write}, + ops::Index, +}; #[derive(Debug)] pub enum Value { @@ -168,6 +172,124 @@ impl Scanner<'_> { ) } } +pub(super) fn extension_syntax(text: &str) -> bool { + let bytes = text.as_bytes(); + let (mut i, mut quoted, mut escaped) = (0, false, false); + let mut probe = Vec::with_capacity(bytes.len()); + while i < bytes.len() { + let token = ["-Infinity", "Infinity", "NaN"] + .into_iter() + .find(|t| !quoted && bytes[i..].starts_with(t.as_bytes())); + if let Some(token) = token { + probe.extend_from_slice(b"null"); + i += token.len(); + continue; + } + let b = bytes[i]; + probe.push(b); + if escaped { + escaped = false; + } else if quoted && b == b'\\' { + escaped = true; + } else if b == b'"' { + quoted = !quoted; + } + i += 1; + } + serde_json::from_slice::(&probe).is_ok() +} +pub(super) fn limits(text: &str) -> (usize, bool) { + let bytes = text.as_bytes(); + let (mut i, mut quoted, mut depth, mut maximum, mut long_integer) = + (0, false, 0usize, 0, false); + while i < bytes.len() { + match bytes[i] { + b'\\' if quoted => { + i += 2; + continue; + } + b'"' => quoted = !quoted, + b'[' | b'{' if !quoted => { + depth += 1; + maximum = maximum.max(depth); + } + b']' | b'}' if !quoted => depth = depth.saturating_sub(1), + b'-' | b'0'..=b'9' if !quoted => { + let start = i; + while i < bytes.len() && b"0123456789.eE+-".contains(&bytes[i]) { + i += 1; + } + let number = &text[start..i]; + long_integer |= !number.contains(['.', 'e', 'E']) + && number.trim_start_matches('-').len() > 4300; + continue; + } + _ => {} + } + i += 1; + } + (maximum, long_integer) +} +pub(super) fn has_float(value: &Value) -> bool { + match value { + Value::Number(n) => n.contains(['.', 'e', 'E']), + Value::Array(a) => a.iter().any(has_float), + Value::Object(m) => m.values().any(has_float), + _ => false, + } +} +/// Encode guarded values whose number lexemes are integers, with Python ASCII JSON. +pub(super) fn encode_integer(value: &Value, out: &mut String) { + match value { + Value::Null => out.push_str("null"), + Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }), + Value::Number(n) => out.push_str(if n == "-0" { "0" } else { n }), + Value::String(s) => string(s, out).expect("infallible string sink"), + Value::Array(a) => { + out.push('['); + for (i, value) in a.iter().enumerate() { + if i != 0 { + out.push(','); + } + encode_integer(value, out); + } + out.push(']'); + } + Value::Object(m) => { + out.push('{'); + for (i, (key, value)) in m.iter().enumerate() { + if i != 0 { + out.push(','); + } + string(key, out).expect("infallible string sink"); + out.push(':'); + encode_integer(value, out); + } + out.push('}'); + } + } +} +pub(super) fn string(value: &str, out: &mut impl Write) -> fmt::Result { + out.write_char('"')?; + for ch in value.chars() { + match ch { + '"' => out.write_str("\\\"")?, + '\\' => out.write_str("\\\\")?, + '\x08' => out.write_str("\\b")?, + '\x0c' => out.write_str("\\f")?, + '\n' => out.write_str("\\n")?, + '\r' => out.write_str("\\r")?, + '\t' => out.write_str("\\t")?, + ' '..='~' => out.write_char(ch)?, + _ => { + for unit in ch.encode_utf16(&mut [0; 2]) { + write!(out, "\\u{unit:04x}")?; + } + } + } + } + out.write_char('"') +} #[cfg(test)] mod tests { diff --git a/crates/mergemaster-evidence/src/evidence/legacy_verify/lane_entry.rs b/crates/mergemaster-evidence/src/evidence/legacy_verify/lane_entry.rs index 80daf46..c3b0237 100644 --- a/crates/mergemaster-evidence/src/evidence/legacy_verify/lane_entry.rs +++ b/crates/mergemaster-evidence/src/evidence/legacy_verify/lane_entry.rs @@ -1,6 +1,6 @@ //! Guarded, lexeme-preserving lane entries; conservative failures are not missing lanes. -use super::lanes::string; pub use crate::evidence::legacy_json::Value; +use crate::evidence::legacy_json::{encode_integer, extension_syntax, has_float, limits}; use crate::evidence::{legacy_json, legacy_policy::PolicyError}; /// Maximum simultaneously open JSON containers, including the root. pub const MAX_CONTAINERS: usize = 127; @@ -10,72 +10,6 @@ fn refusal(reason: &'static str) -> PolicyError { detail: format!("legacy lane canonicalization refused: {reason}"), } } -fn extension_syntax(text: &str) -> bool { - let bytes = text.as_bytes(); - let (mut i, mut quoted, mut escaped) = (0, false, false); - let mut probe = Vec::with_capacity(bytes.len()); - while i < bytes.len() { - let token = ["-Infinity", "Infinity", "NaN"] - .into_iter() - .find(|t| !quoted && bytes[i..].starts_with(t.as_bytes())); - if let Some(token) = token { - probe.extend_from_slice(b"null"); - i += token.len(); - continue; - } - let b = bytes[i]; - probe.push(b); - if escaped { - escaped = false; - } else if quoted && b == b'\\' { - escaped = true; - } else if b == b'"' { - quoted = !quoted; - } - i += 1; - } - serde_json::from_slice::(&probe).is_ok() -} -fn limits(text: &str) -> (usize, bool) { - let bytes = text.as_bytes(); - let (mut i, mut quoted, mut depth, mut maximum, mut long_integer) = - (0, false, 0usize, 0, false); - while i < bytes.len() { - match bytes[i] { - b'\\' if quoted => { - i += 2; - continue; - } - b'"' => quoted = !quoted, - b'[' | b'{' if !quoted => { - depth += 1; - maximum = maximum.max(depth); - } - b']' | b'}' if !quoted => depth = depth.saturating_sub(1), - b'-' | b'0'..=b'9' if !quoted => { - let start = i; - while i < bytes.len() && b"0123456789.eE+-".contains(&bytes[i]) { - i += 1; - } - let number = &text[start..i]; - long_integer |= !number.contains(['.', 'e', 'E']) - && number.trim_start_matches('-').len() > 4300; - continue; - } - _ => {} - } - i += 1; - } - (maximum, long_integer) -} -fn has_float(value: &Value) -> bool { - match value { - Value::Number(n) => n.contains(['.', 'e', 'E']), - Value::Array(a) => a.iter().any(has_float), - Value::Object(m) => m.values().any(has_float), - _ => false, - } -} /// Select raw values before canonicalization and lane schema validation. pub fn selected(text: &str, lane: &str) -> Result, PolicyError> { let (depth, long_integer) = limits(text); @@ -110,42 +44,12 @@ pub fn selected(text: &str, lane: &str) -> Result, PolicyError> { } Ok(Some(entry)) } -fn encode(value: &Value, out: &mut String) { - match value { - Value::Null => out.push_str("null"), - Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }), - Value::Number(n) => out.push_str(if n == "-0" { "0" } else { n }), - Value::String(s) => string(s, out).expect("infallible string sink"), - Value::Array(a) => { - out.push('['); - for (i, value) in a.iter().enumerate() { - if i != 0 { - out.push(','); - } - encode(value, out); - } - out.push(']'); - } - Value::Object(m) => { - out.push('{'); - for (i, (key, value)) in m.iter().enumerate() { - if i != 0 { - out.push(','); - } - string(key, out).expect("infallible string sink"); - out.push(':'); - encode(value, out); - } - out.push('}'); - } - } -} /// Compare this serialized text before validating the lane schema. pub fn canonical(text: &str, lane: &str) -> Result, PolicyError> { selected(text, lane).map(|entry| { entry.map(|entry| { let mut text = String::new(); - encode(&entry, &mut text); + encode_integer(&entry, &mut text); text }) }) diff --git a/crates/mergemaster-evidence/src/evidence/legacy_verify/lanes.rs b/crates/mergemaster-evidence/src/evidence/legacy_verify/lanes.rs index 30b3bdb..a4898d9 100644 --- a/crates/mergemaster-evidence/src/evidence/legacy_verify/lanes.rs +++ b/crates/mergemaster-evidence/src/evidence/legacy_verify/lanes.rs @@ -1,4 +1,5 @@ //! Legacy lane declarations and definition bytes; no Git reads or native hashing. +use crate::evidence::legacy_json::string; use crate::evidence::legacy_policy::{PolicyError, declared_paths, selected_entry}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -105,27 +106,6 @@ pub(super) fn codepoints(bytes: &[u8]) -> impl Iterator + '_ { .chain(part.invalid().iter().map(|b| 0xdc00 + u32::from(*b))) }) } -pub(super) fn string(value: &str, out: &mut impl Write) -> fmt::Result { - out.write_char('"')?; - for ch in value.chars() { - match ch { - '"' => out.write_str("\\\"")?, - '\\' => out.write_str("\\\\")?, - '\x08' => out.write_str("\\b")?, - '\x0c' => out.write_str("\\f")?, - '\n' => out.write_str("\\n")?, - '\r' => out.write_str("\\r")?, - '\t' => out.write_str("\\t")?, - ' '..='~' => out.write_char(ch)?, - _ => { - for unit in ch.encode_utf16(&mut [0; 2]) { - write!(out, "\\u{unit:04x}")?; - } - } - } - } - out.write_char('"') -} fn strings(values: &[String], out: &mut impl Write) -> fmt::Result { out.write_char('[')?; for (i, value) in values.iter().enumerate() { diff --git a/scripts/PROVENANCE b/scripts/PROVENANCE index 6c98476..be666aa 100644 --- a/scripts/PROVENANCE +++ b/scripts/PROVENANCE @@ -14,6 +14,8 @@ # identity, deduplication and batch presence match; discovery does not grant trust. # Rust parity: workflow-job-selection (462-520). Raw top-level/job bytes and # 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. # 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.