1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum FingerprintAlgorithm {
15 Sha256,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
21pub struct CanonicalizationVersion(String);
22#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
24pub struct FingerprintDomain(String);
25#[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 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 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
100pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
120pub struct FingerprintDigest([u8; 32]);
121
122impl FingerprintDigest {
123 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 pub fn to_hex(self) -> String {
150 self.0.iter().map(|byte| format!("{byte:02x}")).collect()
151 }
152 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#[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 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 pub fn domain(&self) -> &FingerprintDomain {
217 &self.domain
218 }
219 pub const fn algorithm(&self) -> FingerprintAlgorithm {
221 self.algorithm
222 }
223 pub fn canonicalization(&self) -> &CanonicalizationVersion {
225 &self.canonicalization
226 }
227 pub fn semantic_profile(&self) -> Option<&SemanticProfileId> {
229 self.semantic_profile.as_ref()
230 }
231 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}