ledvar-core 0.1.0

Reference implementation of the Ledvar protocol: data model, canonical content-addressed hashing, and well-formedness.
Documentation
//! Canonical form and hashing — the "one hasher, one truth" invariant (SPEC §6).
//!
//! The bytes that go into SHA-256 are streamed **directly** into the hasher with
//! no intermediate `String` allocation: we walk the already-sorted
//! `BTreeMap`/`BTreeSet` and feed punctuation and (JSON-escaped) strings as we
//! go. The same routine, parameterised over a [`Sink`], also materialises the
//! canonical string for the `canon`/inspection path. See
//! `docs/canonical-hashing.md` for the rationale (hand-rolled, streaming, why
//! not a general JCS crate).

use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};

/// Where canonical bytes go. `Sha256` is the zero-allocation hashing path; a
/// `Vec<u8>` buffer is used to materialise the canonical string for inspection.
trait Sink {
    fn put(&mut self, bytes: &[u8]);
}

impl Sink for Sha256 {
    #[inline]
    fn put(&mut self, bytes: &[u8]) {
        self.update(bytes);
    }
}

impl Sink for Vec<u8> {
    #[inline]
    fn put(&mut self, bytes: &[u8]) {
        self.extend_from_slice(bytes);
    }
}

const HEX: &[u8; 16] = b"0123456789abcdef";

/// Stream a JSON string (RFC 8785 / RFC 8259 escaping) into the sink, quotes
/// included. Only `"`, `\` and control chars U+0000..=U+001F are escaped;
/// everything else — including all multi-byte UTF-8 — passes through verbatim.
/// Iterating bytes is safe here: every escaped character is single-byte ASCII,
/// and UTF-8 continuation bytes are all >= 0x80, so they never match.
fn put_json_string<S: Sink>(sink: &mut S, value: &str) {
    sink.put(b"\"");
    let bytes = value.as_bytes();
    let mut start = 0usize;
    for i in 0..bytes.len() {
        let c = bytes[i];
        let escape: &[u8] = match c {
            b'"' => b"\\\"",
            b'\\' => b"\\\\",
            0x08 => b"\\b",
            0x09 => b"\\t",
            0x0A => b"\\n",
            0x0C => b"\\f",
            0x0D => b"\\r",
            0x00..=0x1F => {
                if start < i {
                    sink.put(&bytes[start..i]);
                }
                let buf = [
                    b'\\',
                    b'u',
                    b'0',
                    b'0',
                    HEX[(c >> 4) as usize],
                    HEX[(c & 0x0F) as usize],
                ];
                sink.put(&buf);
                start = i + 1;
                continue;
            }
            _ => continue,
        };
        if start < i {
            sink.put(&bytes[start..i]);
        }
        sink.put(escape);
        start = i + 1;
    }
    if start < bytes.len() {
        sink.put(&bytes[start..]);
    }
    sink.put(b"\"");
}

/// Canonical encoding of a path: a JSON array of its segments, in order.
fn put_path<S: Sink>(sink: &mut S, path: &[String]) {
    sink.put(b"[");
    for (i, seg) in path.iter().enumerate() {
        if i > 0 {
            sink.put(b",");
        }
        put_json_string(sink, seg);
    }
    sink.put(b"]");
}

/// Canonical encoding of content: a JSON object, keys sorted, each value a
/// sorted/de-duplicated JSON array of strings. `BTreeMap`/`BTreeSet` provide the
/// ordering and de-duplication, so iteration is already canonical.
fn put_content<S: Sink>(sink: &mut S, content: &BTreeMap<String, BTreeSet<String>>) {
    sink.put(b"{");
    for (i, (key, values)) in content.iter().enumerate() {
        if i > 0 {
            sink.put(b",");
        }
        put_json_string(sink, key);
        sink.put(b":[");
        for (j, v) in values.iter().enumerate() {
            if j > 0 {
                sink.put(b",");
            }
            put_json_string(sink, v);
        }
        sink.put(b"]");
    }
    sink.put(b"}");
}

fn to_hex(bytes: &[u8]) -> String {
    let mut out = String::with_capacity(bytes.len() * 2);
    for &b in bytes {
        out.push(HEX[(b >> 4) as usize] as char);
        out.push(HEX[(b & 0x0F) as usize] as char);
    }
    out
}

pub(crate) fn hash_path(path: &[String]) -> String {
    let mut h = Sha256::new();
    put_path(&mut h, path);
    to_hex(&h.finalize())
}

pub(crate) fn hash_content(content: &BTreeMap<String, BTreeSet<String>>) -> String {
    let mut h = Sha256::new();
    put_content(&mut h, content);
    to_hex(&h.finalize())
}

/// Canonical JSON encoding of ONE string value (SPEC §6.1), quotes included: minimal
/// RFC 8259 escaping, lowercase `\uXXXX`, no Unicode normalization — the exact same routine
/// the protocol hashes run through. Exposed so higher layers (e.g. an ecosystem convention
/// that canonicalises the non-hashed `labels`/`refs` fields) can produce byte-identical
/// encodings without re-implementing the escaping rules.
pub fn canonical_json_string(value: &str) -> String {
    let mut buf = Vec::new();
    put_json_string(&mut buf, value);
    String::from_utf8(buf).expect("canonical bytes are valid UTF-8")
}

pub(crate) fn canonical_path(path: &[String]) -> String {
    let mut buf = Vec::new();
    put_path(&mut buf, path);
    String::from_utf8(buf).expect("canonical bytes are valid UTF-8")
}

pub(crate) fn canonical_content(content: &BTreeMap<String, BTreeSet<String>>) -> String {
    let mut buf = Vec::new();
    put_content(&mut buf, content);
    String::from_utf8(buf).expect("canonical bytes are valid UTF-8")
}

#[cfg(test)]
mod tests {
    use super::*;

    fn buf_string(value: &str) -> String {
        let mut v = Vec::new();
        put_json_string(&mut v, value);
        String::from_utf8(v).unwrap()
    }

    #[test]
    fn json_string_escaping() {
        assert_eq!(buf_string("plain"), r#""plain""#);
        assert_eq!(buf_string(r#"a"b"#), r#""a\"b""#);
        assert_eq!(buf_string(r"a\b"), r#""a\\b""#);
        assert_eq!(buf_string("tab\there"), r#""tab\there""#);
        assert_eq!(buf_string("nl\nhere"), r#""nl\nhere""#);
        assert_eq!(buf_string("\u{0001}"), "\"\\u0001\"");
        assert_eq!(buf_string("acentuação ✓"), "\"acentuação ✓\""); // non-ASCII passes through
    }

    #[test]
    fn streaming_and_materialised_paths_agree() {
        // Hashing the streamed bytes must equal hashing the materialised string.
        let path = vec!["a".to_string(), "b\"c".to_string()];
        let materialised = canonical_path(&path);
        assert_eq!(hash_path(&path), to_hex(&Sha256::digest(materialised.as_bytes())));
    }

    #[test]
    fn empty_content_is_empty_object() {
        assert_eq!(canonical_content(&BTreeMap::new()), "{}");
    }

    #[test]
    fn public_canonical_json_string_matches_internal_encoding() {
        assert_eq!(canonical_json_string(r#"a"b"#), r#""a\"b""#);
        assert_eq!(canonical_json_string("\u{0001}"), "\"\\u0001\"");
        assert_eq!(canonical_json_string("acentuação ✓"), "\"acentuação ✓\"");
    }
}