Skip to main content

json_digest/
hasher.rs

1//! Content hasher abstraction — allows plugging different digest algorithms
2//! without duplicating the JSON walking logic.
3
4use alloc::format;
5use alloc::string::{String, ToString};
6use core::fmt::Write;
7use tiny_keccak::Hasher;
8
9/// Abstraction over the hashing algorithm used for content digests.
10///
11/// Each implementor defines a two-character prefix and both a byte-level and
12/// string-level hash function. The trait is crate-private — the public API
13/// exposes only the concrete `digest_data` / `digest_data_blake3` functions.
14pub(crate) trait ContentHasher {
15    /// Two-character prefix, e.g. `"cj"` (SHA3 json) or `"cm"` (BLAKE3 json).
16    const PREFIX: &'static str;
17
18    /// Raw output type (currently `[u8; 32]` for both SHA3-256 and BLAKE3-256).
19    type HashOutput: AsRef<[u8]>;
20
21    /// Hash raw content and return the raw digest bytes.
22    fn hash_bytes(content: &[u8]) -> Self::HashOutput;
23
24    /// Hash raw content and return the multibase-encoded string (without prefix).
25    fn hash(content: &[u8]) -> String {
26        multibase::encode(multibase::Base::Base64Url, Self::hash_bytes(content))
27    }
28}
29
30/// SHA3-256 via `tiny_keccak`.  Legacy behaviour — kept for backward compatibility.
31pub(crate) struct Sha3Cj;
32
33impl ContentHasher for Sha3Cj {
34    const PREFIX: &'static str = "cj";
35    type HashOutput = [u8; 32];
36
37    fn hash_bytes(content: &[u8]) -> [u8; 32] {
38        let mut hasher = tiny_keccak::Sha3::v256();
39        let mut hash_output = [0u8; 32];
40        hasher.update(content);
41        hasher.finalize(&mut hash_output);
42        hash_output
43    }
44}
45
46/// BLAKE3-256.  Faster, modern alternative.
47pub(crate) struct Blake3Cm;
48
49impl ContentHasher for Blake3Cm {
50    const PREFIX: &'static str = "cm";
51    type HashOutput = [u8; 32];
52
53    fn hash_bytes(content: &[u8]) -> [u8; 32] {
54        let hash = blake3::hash(content);
55        *hash.as_bytes()
56    }
57}
58
59/// A typed, parsed representation of a JSON digest string like `"cjubdcpA0Ff..."` or `"cmursDCff..."`.
60///
61/// Each variant carries its own hash size — currently both 32 bytes, but future
62/// hashers with different output sizes can add variants without changing the public API.
63///
64/// Round-trips through [`Display`] / [`FromStr`] and supports serde and SCALE codec.
65#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
66#[cfg_attr(
67    feature = "substrate",
68    derive(
69        parity_scale_codec::Encode,
70        parity_scale_codec::Decode,
71        parity_scale_codec::MaxEncodedLen,
72        parity_scale_codec::DecodeWithMemTracking,
73        scale_info::TypeInfo
74    )
75)]
76pub enum JsonDigest {
77    /// SHA3-256 Merkle-tree hash (prefix `cj`).
78    Sha3([u8; 32]),
79    /// BLAKE3-256 Merkle-tree hash (prefix `cm`).
80    Blake3([u8; 32]),
81}
82
83impl JsonDigest {
84    /// The single-letter code used in the digest string after the leading `c`.
85    pub fn kind_char(self) -> char {
86        match self {
87            Self::Sha3(_) => 'j',
88            Self::Blake3(_) => 'm',
89        }
90    }
91
92    /// Returns `true` if this is a SHA3-256 json-digest.
93    pub fn is_sha3(self) -> bool {
94        matches!(self, Self::Sha3(_))
95    }
96
97    /// Returns `true` if this is a BLAKE3-256 json-digest.
98    pub fn is_blake3(self) -> bool {
99        matches!(self, Self::Blake3(_))
100    }
101
102    /// Return the raw hash bytes as a slice, regardless of variant.
103    pub fn as_bytes(&self) -> &[u8] {
104        match self {
105            Self::Sha3(b) => b,
106            Self::Blake3(b) => b,
107        }
108    }
109}
110
111impl core::fmt::Display for JsonDigest {
112    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
113        let (kind, bytes): (char, &[u8]) = match self {
114            Self::Sha3(b) => ('j', b),
115            Self::Blake3(b) => ('m', b),
116        };
117        f.write_char('c')?;
118        f.write_char(kind)?;
119        f.write_str(&multibase::encode(multibase::Base::Base64Url, bytes))
120    }
121}
122
123impl core::str::FromStr for JsonDigest {
124    type Err = crate::error::JsonDigestError;
125
126    fn from_str(s: &str) -> core::result::Result<Self, Self::Err> {
127        let mut chars = s.chars();
128        if chars.next() != Some('c') {
129            return Err(crate::error::JsonDigestError::Internal(
130                "JsonDigest must start with 'c'".into(),
131            ));
132        }
133        let kind = chars.next().ok_or_else(|| {
134            crate::error::JsonDigestError::Internal("Missing JsonDigest kind character".into())
135        })?;
136        let multibase_str = chars.as_str();
137        let (_base, bytes) = multibase::decode(multibase_str).map_err(|e| {
138            crate::error::JsonDigestError::Internal(format!("Multibase decode error: {e}"))
139        })?;
140        match kind {
141            'j' => {
142                let arr: [u8; 32] = bytes.try_into().map_err(|_| {
143                    crate::error::JsonDigestError::Internal(
144                        "SHA3 JsonDigest must be 32 bytes".into(),
145                    )
146                })?;
147                Ok(Self::Sha3(arr))
148            }
149            'm' => {
150                let arr: [u8; 32] = bytes.try_into().map_err(|_| {
151                    crate::error::JsonDigestError::Internal(
152                        "BLAKE3 JsonDigest must be 32 bytes".into(),
153                    )
154                })?;
155                Ok(Self::Blake3(arr))
156            }
157            _ => Err(crate::error::JsonDigestError::Internal(format!(
158                "Unknown JsonDigest kind '{kind}'"
159            ))),
160        }
161    }
162}
163
164impl serde::Serialize for JsonDigest {
165    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
166        serializer.serialize_str(&self.to_string())
167    }
168}
169
170impl<'de> serde::Deserialize<'de> for JsonDigest {
171    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
172        let s = String::deserialize(deserializer)?;
173        s.parse().map_err(serde::de::Error::custom)
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn display_roundtrip_sha3() {
183        let digest = JsonDigest::Sha3([0xABu8; 32]);
184        let s = digest.to_string();
185        assert!(s.starts_with("cj"), "expected cj prefix, got: {s}");
186        let parsed: JsonDigest = s.parse().unwrap();
187        assert_eq!(parsed, digest);
188    }
189
190    #[test]
191    fn display_roundtrip_blake3() {
192        let digest = JsonDigest::Blake3([0xABu8; 32]);
193        let s = digest.to_string();
194        assert!(s.starts_with("cm"), "expected cm prefix, got: {s}");
195        let parsed: JsonDigest = s.parse().unwrap();
196        assert_eq!(parsed, digest);
197    }
198
199    #[test]
200    fn parse_rejects_missing_c() {
201        assert!("jF...".parse::<JsonDigest>().is_err());
202    }
203
204    #[test]
205    fn parse_rejects_unknown_kind() {
206        assert!("cxF...".parse::<JsonDigest>().is_err());
207    }
208
209    #[test]
210    fn parse_rejects_wrong_length() {
211        let short = format!("cj{}", multibase::encode(multibase::Base::Base64Url, [0u8; 16]));
212        assert!(short.parse::<JsonDigest>().is_err());
213    }
214}