Skip to main content

codec_jcs/
canonicalize.rs

1// SPDX-FileCopyrightText: Copyright © 2026 ReallyMe LLC. All rights reserved
2//
3// SPDX-License-Identifier: Apache-2.0
4
5use std::cmp::Ordering;
6
7use serde_json::Value;
8
9use crate::error::JcsError;
10
11/// Canonicalize a JSON value according to RFC 8785 (JSON Canonicalization
12/// Scheme).
13///
14/// The output follows RFC 8785 for object member ordering and finite
15/// floating-point number formatting. Integer values that `serde_json` stores
16/// exactly as `i64`/`u64` are emitted verbatim, including values outside the
17/// ES6 safe-integer range. Callers that require strict I-JSON interoperability
18/// should reject integers outside `[-(2^53)+1, (2^53)-1]` before calling this
19/// function.
20pub fn canonicalize_json(value: &Value) -> Result<String, JcsError> {
21    canonicalize(value, 0)
22}
23
24/// `depth` counts the array/object containers currently open, bounded by
25/// [`MAX_NESTING_DEPTH`](crate::MAX_NESTING_DEPTH) as defense in depth
26/// against a `Value` that was built without a parser depth limit.
27fn canonicalize(value: &Value, depth: usize) -> Result<String, JcsError> {
28    match value {
29        Value::Null => Ok("null".to_owned()),
30        Value::Bool(value) => Ok(value.to_string()),
31        Value::Number(value) => canonicalize_number(value),
32        Value::String(value) => {
33            serde_json::to_string(value).map_err(|_| JcsError::SerializationError)
34        }
35        Value::Array(values) => canonicalize_array(values, depth),
36        Value::Object(values) => {
37            let child_depth = descend(depth)?;
38            let mut keys: Vec<&String> = values.keys().collect();
39            // RFC 8785 §3.2.3: sort by UTF-16 code unit, NOT by Unicode
40            // scalar value. The two orders agree across the BMP but diverge
41            // for supplementary-plane names, whose UTF-16 surrogate code
42            // units (0xD800–0xDFFF) sort below BMP code points such as
43            // 0xE000–0xFFFF. A plain `str` sort (UTF-8 / code-point order)
44            // would place them differently and yield non-canonical output.
45            keys.sort_by(|left, right| utf16_cmp(left, right));
46
47            let mut output = String::from("{");
48            for (index, key) in keys.iter().enumerate() {
49                if index > 0 {
50                    output.push(',');
51                }
52                output.push_str(
53                    &serde_json::to_string(key).map_err(|_| JcsError::SerializationError)?,
54                );
55                output.push(':');
56                output.push_str(&canonicalize(&values[*key], child_depth)?);
57            }
58            output.push('}');
59            Ok(output)
60        }
61    }
62}
63
64/// Enters one nesting level, rejecting input deeper than
65/// [`MAX_NESTING_DEPTH`](crate::MAX_NESTING_DEPTH).
66fn descend(depth: usize) -> Result<usize, JcsError> {
67    let next = depth.checked_add(1).ok_or(JcsError::DepthExceeded)?;
68    if next > crate::MAX_NESTING_DEPTH {
69        return Err(JcsError::DepthExceeded);
70    }
71    Ok(next)
72}
73
74/// Compares two strings by their UTF-16 code units, as RFC 8785 requires
75/// for object member ordering.
76fn utf16_cmp(left: &str, right: &str) -> Ordering {
77    left.encode_utf16().cmp(right.encode_utf16())
78}
79
80fn canonicalize_number(value: &serde_json::Number) -> Result<String, JcsError> {
81    // This crate deliberately preserves exactly represented serde_json
82    // integers instead of coercing them through an ES6 double. That keeps Rust
83    // inputs lossless, but callers that need strict RFC 8785/I-JSON behavior
84    // must reject integers outside the ES6 safe-integer range at the boundary.
85    if let Some(unsigned) = value.as_u64() {
86        return Ok(unsigned.to_string());
87    }
88    if let Some(signed) = value.as_i64() {
89        return Ok(signed.to_string());
90    }
91
92    let float = value.as_f64().ok_or(JcsError::SerializationError)?;
93    if !float.is_finite() {
94        return Err(JcsError::NonFiniteNumber);
95    }
96
97    // RFC 8785 §3.2.2.3 mandates the ECMAScript `Number.prototype.toString`
98    // algorithm (exponent thresholds, `+`/`-` exponent sign, shortest
99    // round-trip digits). `serde_json`/`ryu` do not follow the ES6
100    // exponent rules — e.g. 1e21 must serialize as `1e+21`, not `1e21` —
101    // so we use `ryu-js`, whose output is defined to match ES6 exactly.
102    let mut buffer = ryu_js::Buffer::new();
103    Ok(buffer.format_finite(float).to_owned())
104}
105
106fn canonicalize_array(values: &[Value], depth: usize) -> Result<String, JcsError> {
107    let child_depth = descend(depth)?;
108    let mut output = String::from("[");
109    for (index, item) in values.iter().enumerate() {
110        if index > 0 {
111            output.push(',');
112        }
113        output.push_str(&canonicalize(item, child_depth)?);
114    }
115    output.push(']');
116    Ok(output)
117}