Canonical JSON: numbers as ECMAScript writes them (jouleclaw-jcs), and the integer range (sandbox-attest) #313
7 changed files with 3574 additions and 40 deletions
1
jouleclaw/jouleclaw-rs/Cargo.lock
generated
1
jouleclaw/jouleclaw-rs/Cargo.lock
generated
|
|
@ -4022,7 +4022,6 @@ name = "jouleclaw-jcs"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"ryu",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.20",
|
||||
|
|
|
|||
|
|
@ -13,4 +13,3 @@ serde = { workspace = true }
|
|||
serde_json = { workspace = true, features = ["preserve_order"] }
|
||||
thiserror = { workspace = true }
|
||||
blake3 = { workspace = true }
|
||||
ryu = "1.0"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
3385
jouleclaw/jouleclaw-rs/crates/jouleclaw-jcs/testdata/es-number-tostring.txt
vendored
Normal file
3385
jouleclaw/jouleclaw-rs/crates/jouleclaw-jcs/testdata/es-number-tostring.txt
vendored
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Reference in a new issue