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 @@ -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.
Expand Down
124 changes: 123 additions & 1 deletion crates/mergemaster-evidence/src/evidence/legacy_json.rs
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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::<serde::de::IgnoredAny>(&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 {
Expand Down
100 changes: 2 additions & 98 deletions crates/mergemaster-evidence/src/evidence/legacy_verify/lane_entry.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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::<serde::de::IgnoredAny>(&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<Option<Value>, PolicyError> {
let (depth, long_integer) = limits(text);
Expand Down Expand Up @@ -110,42 +44,12 @@ pub fn selected(text: &str, lane: &str) -> Result<Option<Value>, 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<Option<String>, PolicyError> {
selected(text, lane).map(|entry| {
entry.map(|entry| {
let mut text = String::new();
encode(&entry, &mut text);
encode_integer(&entry, &mut text);
text
})
})
Expand Down
22 changes: 1 addition & 21 deletions crates/mergemaster-evidence/src/evidence/legacy_verify/lanes.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -105,27 +106,6 @@ pub(super) fn codepoints(bytes: &[u8]) -> impl Iterator<Item = u32> + '_ {
.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() {
Expand Down
2 changes: 2 additions & 0 deletions scripts/PROVENANCE
Original file line number Diff line number Diff line change
Expand Up @@ -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.
# 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