Skip to main content

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}