Canonical JSON: numbers as ECMAScript writes them (jouleclaw-jcs), and the integer range (sandbox-attest) #313

Merged
dcharlot merged 4 commits from jouleclaw/jcs-ecmascript-numbers into main 2026-10-18 15:46:46 -04:00
7 changed files with 3574 additions and 40 deletions

View file

@ -4022,7 +4022,6 @@ name = "jouleclaw-jcs"
version = "0.1.0"
dependencies = [
"blake3",
"ryu",
"serde",
"serde_json",
"thiserror 2.0.20",

View file

@ -13,4 +13,3 @@ serde = { workspace = true }
serde_json = { workspace = true, features = ["preserve_order"] }
thiserror = { workspace = true }
blake3 = { workspace = true }
ryu = "1.0"

View file

@ -13,9 +13,13 @@
//!
//! ## Algorithm (per RFC 8785)
//!
//! 1. **Numbers** — finite floats serialized per ECMA-262 ToString
//! (shortest round-trip via `ryu`); integers within safe range
//! use plain integer formatting; non-finite floats are an error.
//! 1. **Numbers** — every number is the IEEE-754 double it reads
//! as, written as ECMA-262's Number::toString writes it: the
//! fewest digits that read back as the double (the nearest of
//! those, a tie going to the even), in plain decimals from 10⁻⁶
//! up to 10²¹ and in exponent form outside. An integer past
//! ±(2⁵³ − 1), which a double cannot hold exactly, has no
//! canonical form and is an error, as is a non-finite number.
//! 2. **Strings** — escape `"`, `\`, control chars (with shortcuts
//! for `\b \t \n \f \r`), `\u00XX` for other U+0000–U+001F;
//! everything else emitted as UTF-8.
@ -45,9 +49,10 @@ pub enum JcsError {
/// these values.
#[error("non-finite number in JCS input")]
NonFiniteNumber,
/// A number with no representable form. Should never happen
/// with `serde_json::Number` but covers the unreachable arm.
#[error("unrepresentable number")]
/// An integer past ±(2⁵³ − 1): a double cannot hold it exactly,
/// so two different integers would share one canonical text
/// (I-JSON, RFC 7493 §2.2).
#[error("unrepresentable number: an integer past ±(2^53 − 1)")]
UnrepresentableNumber,
}
@ -119,34 +124,125 @@ fn write_value(v: &serde_json::Value, out: &mut Vec<u8>) -> Result<(), JcsError>
}
fn write_number(n: &serde_json::Number, out: &mut Vec<u8>) -> Result<(), JcsError> {
if let Some(i) = n.as_i64() {
// Integer in safe range — direct formatting.
let s = format!("{}", i);
out.extend_from_slice(s.as_bytes());
return Ok(());
}
if let Some(u) = n.as_u64() {
let s = format!("{}", u);
out.extend_from_slice(s.as_bytes());
return Ok(());
}
if let Some(f) = n.as_f64() {
if !f.is_finite() {
return Err(JcsError::NonFiniteNumber);
const SAFE: u64 = (1 << 53) - 1;
let x = if let Some(i) = n.as_i64() {
if i.unsigned_abs() > SAFE {
return Err(JcsError::UnrepresentableNumber);
}
// Integer-valued floats serialize as integers (RFC 8785
// / ECMA-262 ToString); other floats use ryu shortest
// round-trip.
if f.fract() == 0.0 && f.abs() < 1e16 {
let s = format!("{}", f as i64);
out.extend_from_slice(s.as_bytes());
} else {
let mut buf = ryu::Buffer::new();
out.extend_from_slice(buf.format(f).as_bytes());
i as f64
} else if let Some(u) = n.as_u64() {
if u > SAFE {
return Err(JcsError::UnrepresentableNumber);
}
return Ok(());
u as f64
} else {
n.as_f64().ok_or(JcsError::UnrepresentableNumber)?
};
if !x.is_finite() {
return Err(JcsError::NonFiniteNumber);
}
Err(JcsError::UnrepresentableNumber)
out.extend_from_slice(es_number(x).as_bytes());
Ok(())
}
/// A finite double as ECMA-262's Number::toString writes it (6.1.6.1.20).
fn es_number(x: f64) -> String {
if x == 0.0 {
return "0".into();
}
let (s, n) = shortest(x.abs());
let digits: String = s.iter().map(|d| char::from(b'0' + d)).collect();
let k = s.len() as i32;
let mut out = String::new();
if x < 0.0 {
out.push('-');
}
if k <= n && n <= 21 {
out.push_str(&digits);
out.extend(std::iter::repeat_n('0', (n - k) as usize));
} else if 0 < n && n <= 21 {
out.push_str(&digits[..n as usize]);
out.push('.');
out.push_str(&digits[n as usize..]);
} else if -6 < n && n <= 0 {
out.push_str("0.");
out.extend(std::iter::repeat_n('0', (-n) as usize));
out.push_str(&digits);
} else {
out.push_str(&digits[..1]);
if k > 1 {
out.push('.');
out.push_str(&digits[1..]);
}
let _ = write!(out, "e{}{}", if n - 1 < 0 { '-' } else { '+' }, (n - 1).abs());
}
out
}
/// The digits s and the exponent n, x = 0.s × 10ⁿ, that ECMA-262
/// chooses for `x` > 0: the fewest that read back as `x`, and of those
/// the nearest to `x`, a tie going to the even.
fn shortest(x: f64) -> (Vec<u8>, i32) {
let e = format!("{x:e}");
let (mant, exp) = e.split_once('e').unwrap_or((&e, "0"));
let mut s: Vec<u8> = mant.bytes().filter(u8::is_ascii_digit).map(|b| b - b'0').collect();
while s.len() > 1 && s.last() == Some(&0) {
s.pop();
}
let n = exp.parse::<i32>().unwrap_or(0) + 1;
// Rust's digits are the fewest, but a tie between two equally near
// ones rounds up (RFC 8785's 1424953923781206.25). Only when another
// of the same length also reads back as x (near 17 digits) is there a
// choice: then take x's exact expansion (a double's has at most 767
// significant digits) rounded to that length.
if ![false, true].into_iter().any(|up| step(&s, up).is_some_and(|t| reads_as(&t, n, x))) {
return (s, n);
}
let k = s.len();
let exact = format!("{x:.800e}");
let (em, ee) = exact.split_once('e').unwrap_or((&exact, "0"));
let all: Vec<u8> = em.bytes().filter(u8::is_ascii_digit).map(|b| b - b'0').collect();
let mut cn = ee.parse::<i32>().unwrap_or(0) + 1;
let mut c = all[..k].to_vec();
let rest = &all[k..];
let up = match rest.first().copied().unwrap_or(0) {
d if d > 5 => true,
5 => rest[1..].iter().any(|&d| d != 0) || c[k - 1] % 2 == 1,
_ => false,
};
if up {
match step(&c, true) {
Some(t) => c = t,
None => {
c = std::iter::once(1).chain(std::iter::repeat_n(0, k - 1)).collect();
cn += 1;
}
}
}
if reads_as(&c, cn, x) { (c, cn) } else { (s, n) }
}
/// The decimal one unit above or below `s` in its last digit, if it
/// still has as many digits.
fn step(s: &[u8], up: bool) -> Option<Vec<u8>> {
let mut t = s.to_vec();
for i in (0..t.len()).rev() {
match (up, t[i]) {
(true, 9) => t[i] = 0,
(false, 0) => t[i] = 9,
(_, d) => {
t[i] = if up { d + 1 } else { d - 1 };
return (t[0] != 0).then_some(t);
}
}
}
None
}
/// Whether 0.s × 10ⁿ reads as `x` (std's reader is correctly rounded).
fn reads_as(s: &[u8], n: i32, x: f64) -> bool {
let digits: String = s.iter().map(|d| char::from(b'0' + d)).collect();
format!("0.{digits}e{n}").parse::<f64>().is_ok_and(|y| y == x)
}
fn write_string(s: &str, out: &mut Vec<u8>) {
@ -214,12 +310,51 @@ mod tests {
#[test]
fn floats_use_shortest_round_trip() {
// ryu shortest = "1.5"
assert_eq!(enc(json!(1.5)), "1.5");
// ryu shortest = "0.1"
assert_eq!(enc(json!(0.1)), "0.1");
}
#[test]
fn numbers_are_laid_out_as_ecmascript_lays_them_out() {
// Plain decimals from 10^-6 up to 10^21, exponent form outside.
for (x, want) in [
(0.000001, "0.000001"),
(0.0000015, "0.0000015"),
(1e-7, "1e-7"),
(1.5e-5, "0.000015"),
(1e16, "10000000000000000"),
(1e20, "100000000000000000000"),
(1e21, "1e+21"),
(-0.0, "0"),
(5e-324, "5e-324"),
] {
assert_eq!(enc(json!(x)), want, "{x:e}");
}
}
#[test]
fn numbers_match_rfc_8785_appendix_b_and_a_javascript_engine() {
// Bit patterns and what a JavaScript engine's Number::toString
// writes for each: RFC 8785's appendix B, the edges of each
// layout, and patterns spread over every exponent.
let mut n = 0;
for line in include_str!("../testdata/es-number-tostring.txt").lines() {
let (bits, want) = line.split_once(' ').unwrap();
let x = f64::from_bits(u64::from_str_radix(bits, 16).unwrap());
assert_eq!(es_number(x), want, "{bits}");
n += 1;
}
assert!(n > 3000);
}
#[test]
fn integers_a_double_cannot_hold_have_no_canonical_form() {
assert_eq!(enc(json!(-9_007_199_254_740_991_i64)), "-9007199254740991");
for v in [json!(9_007_199_254_740_992_u64), json!(-9_007_199_254_740_992_i64), json!(u64::MAX)] {
assert!(matches!(canonicalize(&v), Err(JcsError::UnrepresentableNumber)), "{v}");
}
}
#[test]
fn non_finite_number_errors() {
// serde_json can't construct NaN/Inf directly, so we test

File diff suppressed because it is too large Load diff

View file

@ -87,7 +87,7 @@ A signature over an object binds its content address, not the reverse (§7.3).
## 4. Canonical serialization
The canonical form of receipt and attestation cores is **deterministic CBOR** (RFC 8949 §4.2 core deterministic encoding) for the JCR-1 receipt, and **JCS** (RFC 8785) for the JSON attestation. A conforming implementation **MUST** produce byte-identical canonical encodings for equal objects across platforms. **Floats MUST NOT appear in any canonical form or decision path.** All energy figures are integer microjoules; all budgets, counts, and limits are unsigned integers.
The canonical form of receipt and attestation cores is **deterministic CBOR** (RFC 8949 §4.2 core deterministic encoding) for the JCR-1 receipt, and **JCS** (RFC 8785) for the JSON attestation. A conforming implementation **MUST** produce byte-identical canonical encodings for equal objects across platforms. **Floats MUST NOT appear in any canonical form or decision path.** All energy figures are integer microjoules; all budgets, counts, and limits are unsigned integers. In the JCS form every integer **MUST** lie within ±(2⁵³ − 1): RFC 8785 holds numbers as IEEE-754 doubles (I-JSON, RFC 7493 §2.2), so a larger one has no encoding that every platform writes alike, and an implementation **MUST** refuse a core that holds one.
---

View file

@ -129,7 +129,7 @@ impl FleetRecord {
let mut idx = index;
let mut siblings = Vec::new();
while level.len() > 1 {
let sib = if idx % 2 == 0 {
let sib = if idx.is_multiple_of(2) {
// sibling on the right (or self, duplicated, at an odd end)
(*level.get(idx + 1).unwrap_or(&level[idx]), true)
} else {

View file

@ -4,7 +4,10 @@
//! the same primitive the ARL Sandbox uses, so the two interoperate at the
//! attestation layer (SPEC §7.3). Our cores contain only objects, arrays,
//! strings, and unsigned integers; **no floats** appear (SPEC §4), which keeps
//! number canonicalization trivial (integers serialize verbatim).
//! number canonicalization trivial: an integer within ±(2^53 − 1) is written as
//! its digits, which is what RFC 8785 writes for it on every platform. A larger
//! one is refused: RFC 8785 holds numbers as doubles (I-JSON, RFC 7493 §2.2), so
//! an implementation that reads it as one would write other bytes.
use serde_json::Value;
@ -13,6 +16,8 @@ use serde_json::Value;
pub enum JcsError {
#[error("floats are not permitted in a canonical attestation core (SPEC §4)")]
FloatNotPermitted,
#[error("an integer past ±(2^53 − 1) has no canonical form that every platform writes alike (SPEC §4)")]
IntegerOutOfRange,
}
/// Canonicalize a JSON value to its RFC 8785 bytes. Object keys are sorted by
@ -31,6 +36,10 @@ fn write_value(v: &Value, out: &mut String) -> Result<(), JcsError> {
if n.is_f64() {
return Err(JcsError::FloatNotPermitted);
}
const SAFE: u64 = (1 << 53) - 1;
if n.as_u64().is_some_and(|u| u > SAFE) || n.as_i64().is_some_and(|i| i.unsigned_abs() > SAFE) {
return Err(JcsError::IntegerOutOfRange);
}
out.push_str(&n.to_string());
}
Value::String(s) => write_string(s, out),
@ -97,14 +106,21 @@ mod tests {
#[test]
fn integers_serialize_verbatim() {
let v = json!({ "n": 18446744073709551615u64 });
let v = json!({ "n": 9007199254740991u64, "m": -9007199254740991i64 });
let bytes = canonicalize(&v).unwrap();
assert_eq!(
String::from_utf8(bytes).unwrap(),
r#"{"n":18446744073709551615}"#
r#"{"m":-9007199254740991,"n":9007199254740991}"#
);
}
#[test]
fn integers_a_double_cannot_hold_are_refused() {
for v in [json!({ "n": 9007199254740992u64 }), json!({ "n": u64::MAX }), json!([-9007199254740992i64])] {
assert!(matches!(canonicalize(&v), Err(JcsError::IntegerOutOfRange)), "{v}");
}
}
#[test]
fn floats_are_rejected() {
let v = json!({ "x": 1.5 });