1use sha2::{Digest, Sha256};
8
9pub const CANONICAL_CONTRACT_VERSION: u32 = 1;
11pub const MAX_CANONICAL_PAYLOAD_BYTES: u64 = 2_147_483_648;
13pub const MAX_CANONICAL_TEXT_BYTES: u64 = 16_777_216;
15pub const MAX_CANONICAL_BINARY_BYTES: u64 = 67_108_864;
17
18#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
20pub enum CanonicalDomain {
21 Schema,
23 Assertion,
25 EvidenceLink,
27 ConfidenceAssessment,
29 Reasoning,
31 AssertionStatus,
33 AssertionValidity,
35 AssertionSupersession,
37 HypothesisGroup,
39 HypothesisMembership,
41 HypothesisSelection,
43 CompositeGraphMutationContent,
45 CompositeRequest,
47 ProvenanceEvent,
49 Lineage,
51 InvocationDescriptor,
53 GraphProjection,
55 BeliefProjectionPolicy,
57 BeliefProjectionAttachment,
59 ArrowResult,
61}
62
63impl CanonicalDomain {
64 #[must_use]
66 pub const fn as_str(self) -> &'static str {
67 match self {
68 Self::Schema => "graphforge/schema",
69 Self::Assertion => "graphforge/assertion",
70 Self::EvidenceLink => "graphforge/evidence-link",
71 Self::ConfidenceAssessment => "graphforge/confidence-assessment",
72 Self::Reasoning => "graphforge/reasoning",
73 Self::AssertionStatus => "graphforge/assertion-status",
74 Self::AssertionValidity => "graphforge/assertion-validity",
75 Self::AssertionSupersession => "graphforge/assertion-supersession",
76 Self::HypothesisGroup => "graphforge/hypothesis-group",
77 Self::HypothesisMembership => "graphforge/hypothesis-membership",
78 Self::HypothesisSelection => "graphforge/hypothesis-selection",
79 Self::CompositeGraphMutationContent => "graphforge/composite-graph-mutation-content",
80 Self::CompositeRequest => "graphforge/composite-request",
81 Self::ProvenanceEvent => "graphforge/provenance-event",
82 Self::Lineage => "graphforge/lineage",
83 Self::InvocationDescriptor => "graphforge/invocation-descriptor",
84 Self::GraphProjection => "graphforge/graph-projection",
85 Self::BeliefProjectionPolicy => "graphforge/belief-projection-policy",
86 Self::BeliefProjectionAttachment => "graphforge/belief-projection-attachment",
87 Self::ArrowResult => "graphforge/arrow-result",
88 }
89 }
90}
91
92#[derive(thiserror::Error, Clone, Debug, PartialEq, Eq)]
94pub enum CanonicalError {
95 #[error("canonical limit exceeded for {item}: observed {observed}, limit {limit}")]
97 Limit {
98 item: &'static str,
100 observed: u64,
102 limit: u64,
104 },
105 #[error("unsupported canonical contract version {version}")]
107 UnsupportedVersion {
108 version: u32,
110 },
111 #[error("invalid canonical payload: {0}")]
113 Malformed(&'static str),
114}
115
116impl CanonicalError {
117 #[must_use]
119 pub const fn code(&self) -> &'static str {
120 match self {
121 Self::Limit { .. } => "GF_CANONICAL_LIMIT",
122 Self::UnsupportedVersion { .. } => "GF_UNSUPPORTED_CONTRACT_VERSION",
123 Self::Malformed(_) => "GF_CANONICAL_INVALID",
124 }
125 }
126}
127
128#[derive(Clone, Debug, Default, PartialEq, Eq)]
130pub struct CanonicalWriter {
131 bytes: Vec<u8>,
132}
133
134#[derive(Clone, Debug)]
136pub struct CanonicalReader<'a> {
137 bytes: &'a [u8],
138 offset: usize,
139}
140
141impl<'a> CanonicalReader<'a> {
142 pub fn new(bytes: &'a [u8]) -> Result<Self, CanonicalError> {
144 let observed = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
145 if observed > MAX_CANONICAL_PAYLOAD_BYTES {
146 return Err(CanonicalError::Limit {
147 item: "payload",
148 observed,
149 limit: MAX_CANONICAL_PAYLOAD_BYTES,
150 });
151 }
152 Ok(Self { bytes, offset: 0 })
153 }
154
155 pub fn raw(&mut self, length: usize) -> Result<&'a [u8], CanonicalError> {
157 let end = self
158 .offset
159 .checked_add(length)
160 .ok_or(CanonicalError::Malformed("byte range overflow"))?;
161 let value = self
162 .bytes
163 .get(self.offset..end)
164 .ok_or(CanonicalError::Malformed("truncated payload"))?;
165 self.offset = end;
166 Ok(value)
167 }
168
169 pub fn u8(&mut self) -> Result<u8, CanonicalError> {
171 Ok(self.raw(1)?[0])
172 }
173
174 pub fn u32(&mut self) -> Result<u32, CanonicalError> {
176 Ok(u32::from_be_bytes(
177 self.raw(4)?.try_into().expect("reader returned four bytes"),
178 ))
179 }
180
181 pub fn u64(&mut self) -> Result<u64, CanonicalError> {
183 Ok(u64::from_be_bytes(
184 self.raw(8)?
185 .try_into()
186 .expect("reader returned eight bytes"),
187 ))
188 }
189
190 pub fn text(&mut self) -> Result<&'a str, CanonicalError> {
192 let length = self.u64()?;
193 if length > MAX_CANONICAL_TEXT_BYTES {
194 return Err(CanonicalError::Limit {
195 item: "text",
196 observed: length,
197 limit: MAX_CANONICAL_TEXT_BYTES,
198 });
199 }
200 let length = usize::try_from(length)
201 .map_err(|_| CanonicalError::Malformed("text length exceeds usize"))?;
202 std::str::from_utf8(self.raw(length)?)
203 .map_err(|_| CanonicalError::Malformed("text is not valid UTF-8"))
204 }
205
206 pub fn finish(self) -> Result<(), CanonicalError> {
208 if self.offset == self.bytes.len() {
209 Ok(())
210 } else {
211 Err(CanonicalError::Malformed("trailing bytes"))
212 }
213 }
214}
215
216impl CanonicalWriter {
217 #[must_use]
219 pub const fn new() -> Self {
220 Self { bytes: Vec::new() }
221 }
222
223 pub fn raw(&mut self, bytes: &[u8]) -> Result<(), CanonicalError> {
225 let next = self
226 .bytes
227 .len()
228 .checked_add(bytes.len())
229 .and_then(|value| u64::try_from(value).ok())
230 .ok_or(CanonicalError::Limit {
231 item: "payload",
232 observed: u64::MAX,
233 limit: MAX_CANONICAL_PAYLOAD_BYTES,
234 })?;
235 if next > MAX_CANONICAL_PAYLOAD_BYTES {
236 return Err(CanonicalError::Limit {
237 item: "payload",
238 observed: next,
239 limit: MAX_CANONICAL_PAYLOAD_BYTES,
240 });
241 }
242 self.bytes.extend_from_slice(bytes);
243 Ok(())
244 }
245
246 pub fn u8(&mut self, value: u8) -> Result<(), CanonicalError> {
248 self.raw(&[value])
249 }
250
251 pub fn u16(&mut self, value: u16) -> Result<(), CanonicalError> {
253 self.raw(&value.to_be_bytes())
254 }
255
256 pub fn u32(&mut self, value: u32) -> Result<(), CanonicalError> {
258 self.raw(&value.to_be_bytes())
259 }
260
261 pub fn u64(&mut self, value: u64) -> Result<(), CanonicalError> {
263 self.raw(&value.to_be_bytes())
264 }
265
266 pub fn i64(&mut self, value: i64) -> Result<(), CanonicalError> {
268 self.raw(&value.to_be_bytes())
269 }
270
271 pub fn binary(&mut self, value: &[u8]) -> Result<(), CanonicalError> {
273 let length = u64::try_from(value.len()).map_err(|_| CanonicalError::Limit {
274 item: "binary",
275 observed: u64::MAX,
276 limit: MAX_CANONICAL_BINARY_BYTES,
277 })?;
278 if length > MAX_CANONICAL_BINARY_BYTES {
279 return Err(CanonicalError::Limit {
280 item: "binary",
281 observed: length,
282 limit: MAX_CANONICAL_BINARY_BYTES,
283 });
284 }
285 self.u64(length)?;
286 self.raw(value)
287 }
288
289 pub fn text(&mut self, value: &str) -> Result<(), CanonicalError> {
291 let length = u64::try_from(value.len()).map_err(|_| CanonicalError::Limit {
292 item: "text",
293 observed: u64::MAX,
294 limit: MAX_CANONICAL_TEXT_BYTES,
295 })?;
296 if length > MAX_CANONICAL_TEXT_BYTES {
297 return Err(CanonicalError::Limit {
298 item: "text",
299 observed: length,
300 limit: MAX_CANONICAL_TEXT_BYTES,
301 });
302 }
303 self.u64(length)?;
304 self.raw(value.as_bytes())
305 }
306
307 #[must_use]
309 pub fn finish(self) -> Vec<u8> {
310 self.bytes
311 }
312}
313
314pub fn fingerprint_preimage(
316 domain: CanonicalDomain,
317 contract_version: u32,
318 payload: &[u8],
319) -> Result<Vec<u8>, CanonicalError> {
320 if contract_version != CANONICAL_CONTRACT_VERSION {
321 return Err(CanonicalError::UnsupportedVersion {
322 version: contract_version,
323 });
324 }
325 let payload_length = u64::try_from(payload.len()).map_err(|_| CanonicalError::Limit {
326 item: "payload",
327 observed: u64::MAX,
328 limit: MAX_CANONICAL_PAYLOAD_BYTES,
329 })?;
330 if payload_length > MAX_CANONICAL_PAYLOAD_BYTES {
331 return Err(CanonicalError::Limit {
332 item: "payload",
333 observed: payload_length,
334 limit: MAX_CANONICAL_PAYLOAD_BYTES,
335 });
336 }
337 let domain_bytes = domain.as_str().as_bytes();
338 let domain_length = u16::try_from(domain_bytes.len()).expect("closed domains fit UInt16");
339 let mut writer = CanonicalWriter::new();
340 writer.raw(b"GFFP")?;
341 writer.u8(1)?;
342 writer.u16(domain_length)?;
343 writer.raw(domain_bytes)?;
344 writer.u32(contract_version)?;
345 writer.u64(payload_length)?;
346 writer.raw(payload)?;
347 Ok(writer.finish())
348}
349
350pub fn fingerprint(
352 domain: CanonicalDomain,
353 contract_version: u32,
354 payload: &[u8],
355) -> Result<[u8; 32], CanonicalError> {
356 Ok(Sha256::digest(fingerprint_preimage(domain, contract_version, payload)?).into())
357}
358
359#[must_use]
361pub fn uuid_v8(fingerprint: [u8; 32]) -> uuid::Uuid {
362 let mut bytes = [0_u8; 16];
363 bytes.copy_from_slice(&fingerprint[..16]);
364 bytes[6] = (bytes[6] & 0x0f) | 0x80;
365 bytes[8] = (bytes[8] & 0x3f) | 0x80;
366 uuid::Uuid::from_bytes(bytes)
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372
373 fn decode_hex(value: &str) -> Vec<u8> {
374 value
375 .as_bytes()
376 .chunks_exact(2)
377 .map(|pair| {
378 let text = std::str::from_utf8(pair).unwrap();
379 u8::from_str_radix(text, 16).unwrap()
380 })
381 .collect()
382 }
383
384 #[test]
385 fn envelope_hash_and_uuid_match_the_frozen_vector() {
386 let payload = decode_hex(
387 "474654310000000000000027474653310000000100000000000000096e6f64655f757569640032000000100000000000000000000000000000000101018f47d2a1b27c3d8e4f001122334455",
388 );
389 let preimage = fingerprint_preimage(
390 CanonicalDomain::ArrowResult,
391 CANONICAL_CONTRACT_VERSION,
392 &payload,
393 )
394 .unwrap();
395 assert_eq!(
396 preimage,
397 decode_hex(
398 "474646500100176772617068666f7267652f6172726f772d726573756c7400000001000000000000004c474654310000000000000027474653310000000100000000000000096e6f64655f757569640032000000100000000000000000000000000000000101018f47d2a1b27c3d8e4f001122334455",
399 )
400 );
401 let digest = fingerprint(
402 CanonicalDomain::ArrowResult,
403 CANONICAL_CONTRACT_VERSION,
404 &payload,
405 )
406 .unwrap();
407 assert_eq!(
408 digest,
409 decode_hex("3cc432a310818554c11f6eb272fead2c549bcd108ab059453a641ab379c7bbb9")
410 .as_slice()
411 );
412 assert_eq!(
413 uuid_v8(digest).to_string(),
414 "3cc432a3-1081-8554-811f-6eb272fead2c"
415 );
416 }
417
418 #[test]
419 fn writer_is_big_endian_exact_and_bounded() {
420 let mut writer = CanonicalWriter::new();
421 writer.u16(0x0102).unwrap();
422 writer.u32(0x0304_0506).unwrap();
423 writer.u64(7).unwrap();
424 writer.text("é").unwrap();
425 assert_eq!(
426 writer.finish(),
427 [
428 1, 2, 3, 4, 5, 6, 0, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0, 0, 0, 0, 2, 0xc3, 0xa9,
429 ]
430 );
431 }
432
433 #[test]
434 fn unknown_versions_fail_before_hashing() {
435 let error = fingerprint(CanonicalDomain::Schema, 2, b"").unwrap_err();
436 assert_eq!(error.code(), "GF_UNSUPPORTED_CONTRACT_VERSION");
437 }
438
439 #[test]
440 fn reader_round_trips_and_rejects_truncation_trailing_and_utf8() {
441 let mut writer = CanonicalWriter::new();
442 writer.u8(7).unwrap();
443 writer.u32(9).unwrap();
444 writer.u64(11).unwrap();
445 writer.text("é").unwrap();
446 let bytes = writer.finish();
447 let mut reader = CanonicalReader::new(&bytes).unwrap();
448 assert_eq!(reader.u8().unwrap(), 7);
449 assert_eq!(reader.u32().unwrap(), 9);
450 assert_eq!(reader.u64().unwrap(), 11);
451 assert_eq!(reader.text().unwrap(), "é");
452 reader.finish().unwrap();
453
454 assert!(CanonicalReader::new(&[0, 0]).unwrap().u32().is_err());
455 let mut trailing = CanonicalReader::new(&[1, 2]).unwrap();
456 assert_eq!(trailing.u8().unwrap(), 1);
457 assert!(trailing.finish().is_err());
458 let mut invalid = CanonicalReader::new(&[0, 0, 0, 0, 0, 0, 0, 1, 0xff]).unwrap();
459 assert!(invalid.text().is_err());
460 }
461}