Skip to main content

ledvar_core/
canon.rs

1//! Canonical form and hashing — the "one hasher, one truth" invariant (SPEC §6).
2//!
3//! The bytes that go into SHA-256 are streamed **directly** into the hasher with
4//! no intermediate `String` allocation: we walk the already-sorted
5//! `BTreeMap`/`BTreeSet` and feed punctuation and (JSON-escaped) strings as we
6//! go. The same routine, parameterised over a [`Sink`], also materialises the
7//! canonical string for the `canon`/inspection path. See
8//! `docs/canonical-hashing.md` for the rationale (hand-rolled, streaming, why
9//! not a general JCS crate).
10
11use sha2::{Digest, Sha256};
12use std::collections::{BTreeMap, BTreeSet};
13
14/// Where canonical bytes go. `Sha256` is the zero-allocation hashing path; a
15/// `Vec<u8>` buffer is used to materialise the canonical string for inspection.
16trait Sink {
17    fn put(&mut self, bytes: &[u8]);
18}
19
20impl Sink for Sha256 {
21    #[inline]
22    fn put(&mut self, bytes: &[u8]) {
23        self.update(bytes);
24    }
25}
26
27impl Sink for Vec<u8> {
28    #[inline]
29    fn put(&mut self, bytes: &[u8]) {
30        self.extend_from_slice(bytes);
31    }
32}
33
34const HEX: &[u8; 16] = b"0123456789abcdef";
35
36/// Stream a JSON string (RFC 8785 / RFC 8259 escaping) into the sink, quotes
37/// included. Only `"`, `\` and control chars U+0000..=U+001F are escaped;
38/// everything else — including all multi-byte UTF-8 — passes through verbatim.
39/// Iterating bytes is safe here: every escaped character is single-byte ASCII,
40/// and UTF-8 continuation bytes are all >= 0x80, so they never match.
41fn put_json_string<S: Sink>(sink: &mut S, value: &str) {
42    sink.put(b"\"");
43    let bytes = value.as_bytes();
44    let mut start = 0usize;
45    for i in 0..bytes.len() {
46        let c = bytes[i];
47        let escape: &[u8] = match c {
48            b'"' => b"\\\"",
49            b'\\' => b"\\\\",
50            0x08 => b"\\b",
51            0x09 => b"\\t",
52            0x0A => b"\\n",
53            0x0C => b"\\f",
54            0x0D => b"\\r",
55            0x00..=0x1F => {
56                if start < i {
57                    sink.put(&bytes[start..i]);
58                }
59                let buf = [
60                    b'\\',
61                    b'u',
62                    b'0',
63                    b'0',
64                    HEX[(c >> 4) as usize],
65                    HEX[(c & 0x0F) as usize],
66                ];
67                sink.put(&buf);
68                start = i + 1;
69                continue;
70            }
71            _ => continue,
72        };
73        if start < i {
74            sink.put(&bytes[start..i]);
75        }
76        sink.put(escape);
77        start = i + 1;
78    }
79    if start < bytes.len() {
80        sink.put(&bytes[start..]);
81    }
82    sink.put(b"\"");
83}
84
85/// Canonical encoding of a path: a JSON array of its segments, in order.
86fn put_path<S: Sink>(sink: &mut S, path: &[String]) {
87    sink.put(b"[");
88    for (i, seg) in path.iter().enumerate() {
89        if i > 0 {
90            sink.put(b",");
91        }
92        put_json_string(sink, seg);
93    }
94    sink.put(b"]");
95}
96
97/// Canonical encoding of content: a JSON object, keys sorted, each value a
98/// sorted/de-duplicated JSON array of strings. `BTreeMap`/`BTreeSet` provide the
99/// ordering and de-duplication, so iteration is already canonical.
100fn put_content<S: Sink>(sink: &mut S, content: &BTreeMap<String, BTreeSet<String>>) {
101    sink.put(b"{");
102    for (i, (key, values)) in content.iter().enumerate() {
103        if i > 0 {
104            sink.put(b",");
105        }
106        put_json_string(sink, key);
107        sink.put(b":[");
108        for (j, v) in values.iter().enumerate() {
109            if j > 0 {
110                sink.put(b",");
111            }
112            put_json_string(sink, v);
113        }
114        sink.put(b"]");
115    }
116    sink.put(b"}");
117}
118
119fn to_hex(bytes: &[u8]) -> String {
120    let mut out = String::with_capacity(bytes.len() * 2);
121    for &b in bytes {
122        out.push(HEX[(b >> 4) as usize] as char);
123        out.push(HEX[(b & 0x0F) as usize] as char);
124    }
125    out
126}
127
128pub(crate) fn hash_path(path: &[String]) -> String {
129    let mut h = Sha256::new();
130    put_path(&mut h, path);
131    to_hex(&h.finalize())
132}
133
134pub(crate) fn hash_content(content: &BTreeMap<String, BTreeSet<String>>) -> String {
135    let mut h = Sha256::new();
136    put_content(&mut h, content);
137    to_hex(&h.finalize())
138}
139
140/// Canonical JSON encoding of ONE string value (SPEC §6.1), quotes included: minimal
141/// RFC 8259 escaping, lowercase `\uXXXX`, no Unicode normalization — the exact same routine
142/// the protocol hashes run through. Exposed so higher layers (e.g. an ecosystem convention
143/// that canonicalises the non-hashed `labels`/`refs` fields) can produce byte-identical
144/// encodings without re-implementing the escaping rules.
145pub fn canonical_json_string(value: &str) -> String {
146    let mut buf = Vec::new();
147    put_json_string(&mut buf, value);
148    String::from_utf8(buf).expect("canonical bytes are valid UTF-8")
149}
150
151pub(crate) fn canonical_path(path: &[String]) -> String {
152    let mut buf = Vec::new();
153    put_path(&mut buf, path);
154    String::from_utf8(buf).expect("canonical bytes are valid UTF-8")
155}
156
157pub(crate) fn canonical_content(content: &BTreeMap<String, BTreeSet<String>>) -> String {
158    let mut buf = Vec::new();
159    put_content(&mut buf, content);
160    String::from_utf8(buf).expect("canonical bytes are valid UTF-8")
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    fn buf_string(value: &str) -> String {
168        let mut v = Vec::new();
169        put_json_string(&mut v, value);
170        String::from_utf8(v).unwrap()
171    }
172
173    #[test]
174    fn json_string_escaping() {
175        assert_eq!(buf_string("plain"), r#""plain""#);
176        assert_eq!(buf_string(r#"a"b"#), r#""a\"b""#);
177        assert_eq!(buf_string(r"a\b"), r#""a\\b""#);
178        assert_eq!(buf_string("tab\there"), r#""tab\there""#);
179        assert_eq!(buf_string("nl\nhere"), r#""nl\nhere""#);
180        assert_eq!(buf_string("\u{0001}"), "\"\\u0001\"");
181        assert_eq!(buf_string("acentuação ✓"), "\"acentuação ✓\""); // non-ASCII passes through
182    }
183
184    #[test]
185    fn streaming_and_materialised_paths_agree() {
186        // Hashing the streamed bytes must equal hashing the materialised string.
187        let path = vec!["a".to_string(), "b\"c".to_string()];
188        let materialised = canonical_path(&path);
189        assert_eq!(hash_path(&path), to_hex(&Sha256::digest(materialised.as_bytes())));
190    }
191
192    #[test]
193    fn empty_content_is_empty_object() {
194        assert_eq!(canonical_content(&BTreeMap::new()), "{}");
195    }
196
197    #[test]
198    fn public_canonical_json_string_matches_internal_encoding() {
199        assert_eq!(canonical_json_string(r#"a"b"#), r#""a\"b""#);
200        assert_eq!(canonical_json_string("\u{0001}"), "\"\\u0001\"");
201        assert_eq!(canonical_json_string("acentuação ✓"), "\"acentuação ✓\"");
202    }
203}