Skip to main content

type_bridge_contract/
fingerprint.rs

1//! Domain-separated canonical fingerprint values.
2
3use std::fmt;
4
5use serde::de::Error as _;
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7use sha2::{Digest as _, Sha256};
8
9use crate::diagnostic::{Diagnostic, DiagnosticCategory};
10
11/// The initial supported fingerprint algorithm.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum FingerprintAlgorithm {
15    /// SHA-256 with a 32-byte digest.
16    Sha256,
17}
18
19/// A validated canonicalization identifier such as `typebridge.canonical-json/v1`.
20#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
21pub struct CanonicalizationVersion(String);
22/// A validated fingerprint domain identifier.
23#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
24pub struct FingerprintDomain(String);
25/// A validated semantic-profile identifier.
26#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
27pub struct SemanticProfileId(String);
28
29fn validate_name(
30    value: String,
31    kind: &'static str,
32    require_version: bool,
33) -> Result<String, Diagnostic> {
34    let valid = !value.is_empty()
35        && value.len() <= 255
36        && value.bytes().all(|b| {
37            b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'/' | b'_' | b'-')
38        })
39        && !value.starts_with(['.', '/', '_', '-'])
40        && !value.ends_with(['.', '/', '_', '-'])
41        && (!require_version
42            || value.rsplit_once("/v").is_some_and(|(_, version)| {
43                !version.is_empty() && version.bytes().all(|b| b.is_ascii_digit())
44            }));
45    if valid {
46        Ok(value)
47    } else {
48        Err(Diagnostic::stable(
49            DiagnosticCategory::Integrity,
50            "invalid_fingerprint_identifier",
51            "fingerprint metadata identifier is malformed",
52        )
53        .with_detail("identifier_kind", kind))
54    }
55}
56
57macro_rules! fingerprint_name {
58    ($name:ident, $versioned:expr) => {
59        impl $name {
60            /// Validate and construct this identifier.
61            pub fn new(value: impl Into<String>) -> Result<Self, Diagnostic> {
62                Ok(Self(validate_name(
63                    value.into(),
64                    stringify!($name),
65                    $versioned,
66                )?))
67            }
68            /// Return its canonical spelling.
69            pub fn as_str(&self) -> &str {
70                &self.0
71            }
72        }
73        impl fmt::Display for $name {
74            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75                f.write_str(self.as_str())
76            }
77        }
78        impl Serialize for $name {
79            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
80            where
81                S: Serializer,
82            {
83                serializer.serialize_str(self.as_str())
84            }
85        }
86        impl<'de> Deserialize<'de> for $name {
87            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
88            where
89                D: Deserializer<'de>,
90            {
91                Self::new(String::deserialize(deserializer)?).map_err(D::Error::custom)
92            }
93        }
94    };
95}
96fingerprint_name!(CanonicalizationVersion, true);
97fingerprint_name!(FingerprintDomain, false);
98fingerprint_name!(SemanticProfileId, true);
99
100/// Require an exact canonicalization version before consuming canonical bytes.
101pub fn ensure_canonicalization_version(
102    actual: &CanonicalizationVersion,
103    supported: &CanonicalizationVersion,
104) -> Result<(), Diagnostic> {
105    if actual == supported {
106        Ok(())
107    } else {
108        Err(Diagnostic::stable(
109            DiagnosticCategory::InvalidContract,
110            "unsupported_canonicalization_version",
111            "canonicalization version is not supported",
112        )
113        .with_detail("actual", actual.as_str().to_owned())
114        .with_detail("supported", supported.as_str().to_owned()))
115    }
116}
117
118/// A validated 32-byte SHA-256 digest.
119#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
120pub struct FingerprintDigest([u8; 32]);
121
122impl FingerprintDigest {
123    /// Parse exactly 64 lowercase hexadecimal characters.
124    pub fn from_hex(value: &str) -> Result<Self, Diagnostic> {
125        if value.len() != 64
126            || value
127                .bytes()
128                .any(|b| !b.is_ascii_digit() && !(b'a'..=b'f').contains(&b))
129        {
130            return Err(Diagnostic::stable(
131                DiagnosticCategory::Integrity,
132                "invalid_fingerprint_digest",
133                "fingerprint digest must be 64 lowercase hexadecimal characters",
134            ));
135        }
136        let mut bytes = [0_u8; 32];
137        for (index, output) in bytes.iter_mut().enumerate() {
138            *output = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16).map_err(|_| {
139                Diagnostic::stable(
140                    DiagnosticCategory::Integrity,
141                    "invalid_fingerprint_digest",
142                    "fingerprint digest contains invalid hexadecimal",
143                )
144            })?;
145        }
146        Ok(Self(bytes))
147    }
148    /// Return lowercase hexadecimal text.
149    pub fn to_hex(self) -> String {
150        self.0.iter().map(|byte| format!("{byte:02x}")).collect()
151    }
152    /// Return the raw digest bytes.
153    pub const fn bytes(self) -> [u8; 32] {
154        self.0
155    }
156}
157impl Serialize for FingerprintDigest {
158    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
159    where
160        S: Serializer,
161    {
162        serializer.serialize_str(&self.to_hex())
163    }
164}
165impl<'de> Deserialize<'de> for FingerprintDigest {
166    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
167    where
168        D: Deserializer<'de>,
169    {
170        Self::from_hex(&String::deserialize(deserializer)?).map_err(D::Error::custom)
171    }
172}
173
174/// A complete self-describing domain-separated fingerprint.
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
176pub struct Fingerprint {
177    domain: FingerprintDomain,
178    algorithm: FingerprintAlgorithm,
179    canonicalization: CanonicalizationVersion,
180    #[serde(skip_serializing_if = "Option::is_none")]
181    semantic_profile: Option<SemanticProfileId>,
182    digest: FingerprintDigest,
183}
184
185impl Fingerprint {
186    /// Compute SHA-256 over the domain, canonicalization, optional semantic profile, and canonical bytes.
187    pub fn compute(
188        domain: FingerprintDomain,
189        canonicalization: CanonicalizationVersion,
190        semantic_profile: Option<SemanticProfileId>,
191        canonical_bytes: &[u8],
192    ) -> Self {
193        let mut hasher = Sha256::new();
194        hasher.update(b"typebridge.fingerprint/v1\0");
195        hash_field(&mut hasher, domain.as_str().as_bytes());
196        hash_field(&mut hasher, canonicalization.as_str().as_bytes());
197        match &semantic_profile {
198            Some(profile) => {
199                hasher.update([1]);
200                hash_field(&mut hasher, profile.as_str().as_bytes());
201            }
202            None => hasher.update([0]),
203        }
204        hash_field(&mut hasher, canonical_bytes);
205        let mut digest = [0_u8; 32];
206        digest.copy_from_slice(&hasher.finalize());
207        Self {
208            domain,
209            algorithm: FingerprintAlgorithm::Sha256,
210            canonicalization,
211            semantic_profile,
212            digest: FingerprintDigest(digest),
213        }
214    }
215    /// Return the fingerprint domain.
216    pub fn domain(&self) -> &FingerprintDomain {
217        &self.domain
218    }
219    /// Return the algorithm.
220    pub const fn algorithm(&self) -> FingerprintAlgorithm {
221        self.algorithm
222    }
223    /// Return the canonicalization identifier.
224    pub fn canonicalization(&self) -> &CanonicalizationVersion {
225        &self.canonicalization
226    }
227    /// Return the optional semantic profile.
228    pub fn semantic_profile(&self) -> Option<&SemanticProfileId> {
229        self.semantic_profile.as_ref()
230    }
231    /// Return the digest.
232    pub const fn digest(&self) -> FingerprintDigest {
233        self.digest
234    }
235}
236
237fn hash_field(hasher: &mut Sha256, value: &[u8]) {
238    hasher.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes());
239    hasher.update(value);
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn fingerprint_is_golden_and_domain_separated() {
248        let canonicalization =
249            CanonicalizationVersion::new("typebridge.canonical-json/v1").unwrap();
250        let payload = br#"{"kind":"long","value":"9007199254740993"}"#;
251        let first = Fingerprint::compute(
252            FingerprintDomain::new("test.value").unwrap(),
253            canonicalization.clone(),
254            None,
255            payload,
256        );
257        assert_eq!(
258            first.digest().to_hex(),
259            "cbe437dc731095f176ab19a4494c0ee53e491bded9a50627208a9bf022576ce9"
260        );
261        let other = Fingerprint::compute(
262            FingerprintDomain::new("test.other").unwrap(),
263            canonicalization,
264            None,
265            payload,
266        );
267        assert_ne!(first.digest(), other.digest());
268    }
269
270    #[test]
271    fn canonicalization_versions_validate_and_fail_closed() {
272        for value in [
273            "",
274            "typebridge.canonical-json",
275            "Typebridge.canonical-json/v1",
276            "typebridge.canonical-json/vx",
277        ] {
278            assert_eq!(
279                CanonicalizationVersion::new(value)
280                    .unwrap_err()
281                    .code()
282                    .as_str(),
283                "invalid_fingerprint_identifier",
284            );
285        }
286
287        let supported = CanonicalizationVersion::new("typebridge.canonical-json/v1").unwrap();
288        let unknown = CanonicalizationVersion::new("typebridge.canonical-json/v2").unwrap();
289        assert!(ensure_canonicalization_version(&supported, &supported).is_ok());
290        assert_eq!(
291            ensure_canonicalization_version(&unknown, &supported)
292                .unwrap_err()
293                .code()
294                .as_str(),
295            "unsupported_canonicalization_version",
296        );
297    }
298}