acdp_jcs/lib.rs
1//! JSON Canonicalization Scheme (JCS) — RFC 8785.
2//!
3//! Implemented inline to avoid an external dependency and to guarantee
4//! correct handling of all edge cases, especially:
5//! - Object key sorting (RFC 8785 §3.2.1 UTF-16 code-unit order; all
6//! ACDP keys are ASCII, where this coincides with byte/`str` order)
7//! - No whitespace
8//! - Negative zero (`-0.0`) MUST become `0` (the most common bug)
9//! - Non-ASCII characters emitted as-is, not `\uXXXX`-escaped
10
11use std::io::Write;
12
13use acdp_primitives::AcdpError;
14use serde::Serialize;
15
16/// Hard recursion ceiling for the JCS walker. Far above any real ACDP
17/// body (metadata depth is capped at 8) and above serde_json's default
18/// 128-level parse limit, so a value that parsed off the wire can never
19/// hit it — the wire/golden-vector form is unchanged. The cap only
20/// guards against stack overflow from a pathologically deep
21/// programmatically-built `Value` (defense-in-depth, RFC-ACDP P1-3).
22const MAX_JCS_DEPTH: usize = 256;
23
24/// Canonicalize any serializable value to JCS bytes.
25///
26/// The returned bytes are the canonical UTF-8 JSON representation.
27pub fn canonicalize<T: Serialize>(value: &T) -> Result<Vec<u8>, AcdpError> {
28 let v = serde_json::to_value(value).map_err(|e| AcdpError::Canonicalization(e.to_string()))?;
29 try_canonicalize_value(&v)
30}
31
32/// Canonicalize a pre-parsed `serde_json::Value`, returning an error if
33/// nesting exceeds the internal recursion ceiling (`MAX_JCS_DEPTH`).
34/// Prefer this on any path that may canonicalize untrusted /
35/// programmatically-built input.
36pub fn try_canonicalize_value(value: &serde_json::Value) -> Result<Vec<u8>, AcdpError> {
37 let mut out = Vec::with_capacity(256);
38 write_value(value, &mut out, 0)?;
39 Ok(out)
40}
41
42/// Canonicalize a pre-parsed `serde_json::Value`.
43///
44/// Infallible back-compat wrapper. Panics only on input nested past the
45/// internal recursion ceiling (`MAX_JCS_DEPTH`, unreachable from parsed
46/// wire data); callers handling untrusted input should use
47/// [`try_canonicalize_value`].
48pub fn canonicalize_value(value: &serde_json::Value) -> Vec<u8> {
49 try_canonicalize_value(value)
50 .expect("JCS canonicalization exceeded depth limit; use try_canonicalize_value")
51}
52
53fn write_value(v: &serde_json::Value, out: &mut Vec<u8>, depth: usize) -> Result<(), AcdpError> {
54 if depth > MAX_JCS_DEPTH {
55 return Err(AcdpError::Canonicalization(format!(
56 "JSON nesting depth exceeds {MAX_JCS_DEPTH}"
57 )));
58 }
59 match v {
60 serde_json::Value::Null => out.extend_from_slice(b"null"),
61 serde_json::Value::Bool(true) => out.extend_from_slice(b"true"),
62 serde_json::Value::Bool(false) => out.extend_from_slice(b"false"),
63 serde_json::Value::Number(n) => write_number(n, out),
64 serde_json::Value::String(s) => write_string(s, out),
65 serde_json::Value::Array(arr) => {
66 out.push(b'[');
67 for (i, elem) in arr.iter().enumerate() {
68 if i > 0 {
69 out.push(b',');
70 }
71 write_value(elem, out, depth + 1)?;
72 }
73 out.push(b']');
74 }
75 serde_json::Value::Object(map) => {
76 // Sort keys in RFC 8785 §3.2.1 UTF-16 code-unit order. ACDP
77 // keys are ASCII, where Rust's `str` (byte/scalar) ordering
78 // coincides with UTF-16 code-unit ordering.
79 let mut keys: Vec<&String> = map.keys().collect();
80 keys.sort();
81 out.push(b'{');
82 for (i, key) in keys.iter().enumerate() {
83 if i > 0 {
84 out.push(b',');
85 }
86 write_string(key, out);
87 out.push(b':');
88 write_value(&map[key.as_str()], out, depth + 1)?;
89 }
90 out.push(b'}');
91 }
92 }
93 Ok(())
94}
95
96fn write_number(n: &serde_json::Number, out: &mut Vec<u8>) {
97 // Integer `Number`s (i64 / u64) are already canonical — serde_json prints
98 // the exact digits with no decimal point and no exponent, exactly what
99 // RFC 8785 requires. Only floats need the ECMAScript reformatting below.
100 if n.is_i64() || n.is_u64() {
101 out.extend_from_slice(n.to_string().as_bytes());
102 return;
103 }
104
105 // Float path. `as_f64` is `Some` for any non-integer `Number`; the `None`
106 // arm is unreachable but kept total rather than panicking.
107 let Some(f) = n.as_f64() else {
108 out.extend_from_slice(n.to_string().as_bytes());
109 return;
110 };
111
112 // RFC 8785 §3.2.2.3: both negative and positive zero serialize as "0".
113 if f == 0.0 {
114 out.push(b'0');
115 return;
116 }
117
118 // JSON cannot represent NaN or Infinity. `serde_json::Number::from_f64`
119 // rejects these and this crate does not enable `arbitrary_precision`, so a
120 // non-finite `Number` cannot be built through the safe API — unreachable on
121 // parsed input. Refuse it loudly in debug/test builds; the `null` fallback
122 // is a release-only last resort so canonicalization stays total (emitting
123 // `null` would corrupt the hash preimage). Producers with custom numeric
124 // paths MUST reject non-finite floats *before* canonicalization.
125 debug_assert!(
126 f.is_finite(),
127 "non-finite f64 reached JCS canonicalization ({f}); reject \
128 non-finite numbers before hashing (RFC 8785 §3.2.2.3)"
129 );
130 if !f.is_finite() {
131 out.extend_from_slice(b"null");
132 return;
133 }
134
135 out.extend_from_slice(ecma_number_string(f).as_bytes());
136}
137
138/// Serialize a finite, non-zero `f64` per the ECMAScript `Number::toString`
139/// algorithm that RFC 8785 §3.2.2.3 references: the shortest decimal that
140/// round-trips, rendered with the ES6 band rules — plain decimal for
141/// magnitudes in `[1e-6, 1e21)`, otherwise exponential with a signed,
142/// zero-padding-free exponent; the mantissa never carries a trailing `.0`.
143///
144/// Rust's `{:e}` formatter already produces the shortest round-tripping
145/// mantissa (via the stdlib's Grisu/Ryū path) as `d.ddde±EE`; we extract its
146/// digits and decimal exponent and reformat into the band ECMAScript chooses.
147fn ecma_number_string(f: f64) -> String {
148 let neg = f.is_sign_negative();
149 // e.g. "1.23e25", "5e-324", "1e21", "1.0000005e6".
150 let sci = format!("{:e}", f.abs());
151 let (mantissa, exp) = sci.split_once('e').expect("{:e} always emits 'e'");
152 let e10: i32 = exp.parse().expect("{:e} exponent is an integer");
153 let digits: String = mantissa.chars().filter(|c| *c != '.').collect();
154 let digits = digits.trim_end_matches('0');
155 let digits = if digits.is_empty() { "0" } else { digits };
156 // ECMA-262 step 5 tie-break: when the value sits EXACTLY halfway
157 // between two shortest decimal candidates, ECMAScript requires the
158 // even one; Rust's `{:e}` can pick the odd one. Correct the digit
159 // string before band formatting so RFC 8785 output matches every
160 // ECMAScript engine byte-for-byte.
161 let corrected = round_half_even_correction(f.abs(), digits, e10);
162 let digits: &str = corrected.as_deref().unwrap_or(digits);
163 let k = digits.len() as i32; // count of significant digits
164 let n = e10 + 1; // value = digits × 10^(n − k)
165
166 let body = if (k..=21).contains(&n) {
167 // Integer-valued: all digits then (n − k) trailing zeros.
168 format!("{digits}{}", "0".repeat((n - k) as usize))
169 } else if (1..=21).contains(&n) {
170 // Decimal point falls inside the digit run (here n < k).
171 format!("{}.{}", &digits[..n as usize], &digits[n as usize..])
172 } else if (-5..=0).contains(&n) {
173 // Leading "0." then (−n) zeros then the digits.
174 format!("0.{}{digits}", "0".repeat((-n) as usize))
175 } else if k == 1 {
176 // Single-digit mantissa, exponential form.
177 format!("{digits}e{}{}", exp_sign(n - 1), (n - 1).abs())
178 } else {
179 // Multi-digit mantissa, exponential form.
180 format!(
181 "{}.{}e{}{}",
182 &digits[..1],
183 &digits[1..],
184 exp_sign(n - 1),
185 (n - 1).abs()
186 )
187 };
188
189 if neg {
190 format!("-{body}")
191 } else {
192 body
193 }
194}
195
196/// ECMA-262 §6.1.6.1.20 (Number::toString) step 5 tie-break: among the
197/// shortest round-tripping digit strings, "if there are two such possible
198/// values of s, choose the one that is even".
199///
200/// Rust's `{:e}` emits shortest round-tripping digits but is free to break
201/// an exact tie either way, and it can pick the odd candidate. Concretely
202/// `f64::from_bits(0x43143ff3c1cb0959)` (= 1424953923781206.25 exactly)
203/// formats as `1424953923781206.3` in Rust, while every ECMAScript engine
204/// emits `1424953923781206.2` — and RFC 8785 §3.2.2.3 requires the
205/// ECMAScript output. Without this correction, two conformant JCS
206/// implementations produce different canonical bytes (hence different
207/// `content_hash` values) for the same body.
208///
209/// Returns `Some(corrected_digits)` only when `abs` is an exact decimal
210/// midpoint and Rust chose the odd candidate; `None` otherwise (the
211/// overwhelmingly common case — detection is a handful of integer ops).
212///
213/// A genuine tie means `abs == (2s ∓ 1) × 10^e / 2` exactly, which forces
214/// `5^|e|` to divide a ≤53-bit mantissa product — so `|e| ≤ 25` in every
215/// real tie and the exact test fits in checked `u128` arithmetic
216/// (overflow soundly means "not a tie").
217fn round_half_even_correction(abs: f64, digits: &str, e10: i32) -> Option<String> {
218 // Shortest f64 digit strings are at most 17 significant digits.
219 if digits.len() > 17 {
220 return None;
221 }
222 let s: u128 = digits.parse().ok()?;
223 if s % 2 == 0 {
224 // Already even: in any tie, ECMAScript would pick this candidate.
225 return None;
226 }
227 // Exponent of the LAST digit: value = s × 10^e_last.
228 let e_last = e10 - (digits.len() as i32 - 1);
229
230 // Tie with the candidate below (s−1, even) or above (s+1, even).
231 let corrected = if is_exact_decimal_midpoint(abs, 2 * s - 1, e_last) {
232 s - 1
233 } else if is_exact_decimal_midpoint(abs, 2 * s + 1, e_last) {
234 s + 1
235 } else {
236 return None;
237 };
238
239 let out = corrected.to_string();
240 // In a genuine tie under minimal digit count, the even candidate can
241 // neither change length nor gain a trailing zero — either would mean
242 // a shorter round-tripping representation existed, contradicting the
243 // formatter having emitted `digits.len()` significant digits.
244 debug_assert_eq!(out.len(), digits.len(), "tie candidate changed digit count");
245 debug_assert!(!out.ends_with('0'), "tie candidate has a shorter form");
246 Some(out)
247}
248
249/// True iff `abs == t × 10^e / 2` EXACTLY (with `t` odd) — i.e. the binary
250/// value sits precisely on the midpoint between two consecutive decimal
251/// candidates. Pure integer arithmetic on the f64 bit pattern:
252///
253/// ```text
254/// m × 2^q == t × 10^e / 2 ⇔ m × 2^(q+1−e) == t × 5^e
255/// ```
256///
257/// with every power moved to whichever side keeps it non-negative. Any
258/// `u128` overflow returns `false`, which is sound: both sides of a
259/// genuine tie are bounded well under 2^128 (see caller).
260fn is_exact_decimal_midpoint(abs: f64, t: u128, e: i32) -> bool {
261 let bits = abs.to_bits();
262 let frac = bits & ((1u64 << 52) - 1);
263 let biased = ((bits >> 52) & 0x7ff) as i32;
264 // abs = m × 2^q exactly (subnormals have no implicit bit).
265 let (m, q) = if biased == 0 {
266 (frac as u128, -1074i32)
267 } else {
268 ((frac | (1u64 << 52)) as u128, biased - 1075)
269 };
270 if m == 0 {
271 return false;
272 }
273 let two = q + 1 - e;
274 // lhs = m × 2^max(two,0) × 5^max(−e,0)
275 // rhs = t × 2^max(−two,0) × 5^max(e,0)
276 let lhs = checked_scale(m, two.max(0) as u32, (-e).max(0) as u32);
277 let rhs = checked_scale(t, (-two).max(0) as u32, e.max(0) as u32);
278 matches!((lhs, rhs), (Some(a), Some(b)) if a == b)
279}
280
281/// `v × 2^p2 × 5^p5` in `u128`, `None` on overflow. `<<` alone discards
282/// high bits silently, so the shift is guarded by `leading_zeros`.
283fn checked_scale(v: u128, p2: u32, p5: u32) -> Option<u128> {
284 if p2 >= 128 || v.leading_zeros() < p2 {
285 return None;
286 }
287 let mut acc = v << p2;
288 for _ in 0..p5 {
289 acc = acc.checked_mul(5)?;
290 }
291 Some(acc)
292}
293
294/// `'+'` for a non-negative ECMAScript exponent, `'-'` otherwise. RFC 8785
295/// requires the exponent sign to always be present (`1e+21`, `1e-7`).
296fn exp_sign(e: i32) -> char {
297 if e >= 0 {
298 '+'
299 } else {
300 '-'
301 }
302}
303
304fn write_string(s: &str, out: &mut Vec<u8>) {
305 out.push(b'"');
306 for ch in s.chars() {
307 match ch {
308 '"' => out.extend_from_slice(b"\\\""),
309 '\\' => out.extend_from_slice(b"\\\\"),
310 '\n' => out.extend_from_slice(b"\\n"),
311 '\r' => out.extend_from_slice(b"\\r"),
312 '\t' => out.extend_from_slice(b"\\t"),
313 c if (c as u32) < 0x20 => {
314 // Control characters below U+0020 must be escaped
315 write!(out, "\\u{:04x}", c as u32).unwrap();
316 }
317 c => {
318 // Non-ASCII characters emitted as-is (UTF-8 bytes, not \uXXXX)
319 let mut buf = [0u8; 4];
320 let encoded = c.encode_utf8(&mut buf);
321 out.extend_from_slice(encoded.as_bytes());
322 }
323 }
324 }
325 out.push(b'"');
326}
327
328// ── Tests ─────────────────────────────────────────────────────────────────────
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use serde_json::json;
334
335 #[test]
336 fn sorts_keys() {
337 let v = json!({"z": 1, "a": 2, "m": 3});
338 let out = canonicalize_value(&v);
339 assert_eq!(out, b"{\"a\":2,\"m\":3,\"z\":1}");
340 }
341
342 #[test]
343 fn negative_zero_becomes_zero() {
344 // The critical RFC 8785 edge case
345 let v = json!({"values": [42, -7, 0, 1.1, 1.5, -0.0_f64]});
346 let out = canonicalize_value(&v);
347 let s = std::str::from_utf8(&out).unwrap();
348 // -0.0 must become 0
349 assert!(!s.contains("-0"), "found '-0' in: {s}");
350 }
351
352 #[test]
353 fn unicode_as_is() {
354 let v = json!({"title": "café"});
355 let out = canonicalize_value(&v);
356 assert_eq!(out, "{\"title\":\"café\"}".as_bytes());
357 }
358
359 #[test]
360 fn empty_vs_absent() {
361 let with_tags = json!({"tags": [], "v": 1});
362 let without = json!({"v": 1});
363 let h1 = {
364 use sha2::{Digest, Sha256};
365 hex::encode(Sha256::digest(canonicalize_value(&with_tags)))
366 };
367 let h2 = {
368 use sha2::{Digest, Sha256};
369 hex::encode(Sha256::digest(canonicalize_value(&without)))
370 };
371 assert_ne!(h1, h2, "empty array and absent field must hash differently");
372 }
373
374 #[test]
375 fn minimal_body_golden_hash() {
376 // Reproduces can-001 vector from schemas/conformance/can-001-jcs-vector.json
377 let body = json!({
378 "agent_id": "did:agent:test",
379 "contributors": [],
380 "data_refs": [],
381 "supersedes": null,
382 "title": "Minimal",
383 "type": "data_snapshot",
384 "version": 1
385 });
386 use sha2::{Digest, Sha256};
387 let h = hex::encode(Sha256::digest(canonicalize_value(&body)));
388 assert_eq!(
389 h,
390 "5f8d88d6758cfd43be875d49edc9eaa494de8ec645bf7de6c592b15bbb1e2e3c"
391 );
392 }
393
394 // ── RFC 8785 numeric serialization vectors (Appendix B subset) ──────
395 //
396 // RFC 8785 §3.2.2.3 / Appendix B pin the serialization of JSON
397 // numbers. ACDP wire bodies only ever carry *integers* (version
398 // numbers, counts) and the occasional plain decimal — never the
399 // exponential / integer-valued-float forms (e.g. `1e21`, `1.0`) whose
400 // ECMAScript `Number::toString` output diverges from serde_json's
401 // shortest-float Display. We therefore pin the cases that actually
402 // occur on the wire and that this canonicalizer guarantees, plus the
403 // negative-zero rule that is the most common JCS bug. Full ECMAScript
404 // `Number::toString` formatting (exponential bands, shortest
405 // round-trip) is implemented in `write_number` and is covered by
406 // `rfc8785_ecmascript_float_bands` below.
407
408 /// Helper: canonicalize a single JSON number token (parsed from
409 /// text, so integers stay integers) and return the emitted string.
410 fn canon_number(json_token: &str) -> String {
411 let v: serde_json::Value = serde_json::from_str(json_token).unwrap();
412 String::from_utf8(canonicalize_value(&v)).unwrap()
413 }
414
415 #[test]
416 fn rfc8785_integer_vectors() {
417 // Integers serialize with no decimal point, no leading zeros,
418 // no plus sign — exactly their canonical decimal form.
419 for (input, expected) in [
420 ("0", "0"),
421 ("-0", "0"), // negative-zero *integer* normalizes to "0"
422 ("1", "1"),
423 ("-1", "-1"),
424 ("100", "100"),
425 ("9007199254740992", "9007199254740992"), // 2^53
426 ("9007199254740993", "9007199254740993"), // 2^53 + 1 (exact as i64)
427 ("18446744073709551615", "18446744073709551615"), // u64::MAX
428 ("-9223372036854775808", "-9223372036854775808"), // i64::MIN
429 ] {
430 assert_eq!(canon_number(input), expected, "input={input}");
431 }
432 }
433
434 #[test]
435 fn rfc8785_negative_zero_float_becomes_zero() {
436 // RFC 8785 §3.2.2.3: -0.0 MUST serialize as "0".
437 assert_eq!(canon_number("-0.0"), "0");
438 // And nested inside a structure (the realistic case). The other
439 // entries are integers to avoid the integer-valued-float case
440 // (`0.0` → "0.0") that is out of scope per the note above.
441 let v = json!({"a": [-0.0_f64, 1], "b": -0.0_f64});
442 let s = String::from_utf8(canonicalize_value(&v)).unwrap();
443 assert_eq!(s, r#"{"a":[0,1],"b":0}"#);
444 }
445
446 #[test]
447 fn rfc8785_plain_decimal_vectors() {
448 // Plain decimals whose shortest representation is unambiguous and
449 // identical under ES6 and serde_json's Display.
450 for (input, expected) in [
451 ("0.1", "0.1"),
452 ("1.5", "1.5"),
453 ("-2.5", "-2.5"),
454 ("123.456", "123.456"),
455 ] {
456 assert_eq!(canon_number(input), expected, "input={input}");
457 }
458 }
459
460 #[test]
461 fn rfc8785_numeric_serialization_is_idempotent() {
462 // Re-canonicalizing the emitted form reproduces it byte-for-byte
463 // (no drift across a parse → serialize round trip).
464 for token in ["0", "-0", "42", "9007199254740993", "0.1", "-2.5", "-0.0"] {
465 let once = canon_number(token);
466 let twice = canon_number(&once);
467 assert_eq!(once, twice, "token={token}");
468 }
469 }
470
471 /// RFC 8785 §3.2.2.3 float serialization — the `can-011` numeric
472 /// bands, now that ECMAScript `Number::toString` is implemented in
473 /// `write_number`. These canonical tokens are fixed by the algorithm,
474 /// so they hold regardless of the spec fixture's own SHA-256 values.
475 #[test]
476 fn rfc8785_ecmascript_float_bands() {
477 for (token, expected) in [
478 // Large-magnitude exponential (≥ 1e21).
479 ("1e21", "1e+21"),
480 ("1e22", "1e+22"),
481 ("1.23e25", "1.23e+25"),
482 ("1e100", "1e+100"),
483 // Small-magnitude exponential (< 1e-6).
484 ("1e-7", "1e-7"),
485 ("1e-10", "1e-10"),
486 ("5e-9", "5e-9"),
487 ("1e-20", "1e-20"),
488 // Decimal band [1e-6, 1e21).
489 ("1e-6", "0.000001"),
490 ("0.1", "0.1"),
491 ("1000000.5", "1000000.5"),
492 ("12345.6789", "12345.6789"),
493 // Integer-valued floats normalize like integers (no trailing .0).
494 ("1.0", "1"),
495 ("100.0", "100"),
496 // IEEE 754 magnitude extremes.
497 ("1.7976931348623157e308", "1.7976931348623157e+308"),
498 ("5e-324", "5e-324"),
499 ] {
500 assert_eq!(canon_number(token), expected, "token={token}");
501 }
502 }
503
504 /// Positive and negative zero — including the float and exponential
505 /// spellings — all canonicalize to "0" (RFC 8785 §3.2.2.3).
506 #[test]
507 fn rfc8785_all_zeros_normalize() {
508 for token in ["0", "-0", "0.0", "-0.0", "0e0", "-0.0e10"] {
509 assert_eq!(canon_number(token), "0", "token={token}");
510 }
511 }
512}