1use crate::hash_observation::ContractSha256 as Sha256;
14use sha2::Digest;
15
16pub const CANONICAL_CONTRACT_VERSION: u32 = 1;
18pub const MAX_CANONICAL_PAYLOAD_BYTES: u64 = 2_147_483_648;
20pub const MAX_CANONICAL_TEXT_BYTES: u64 = 16_777_216;
22pub const MAX_CANONICAL_BINARY_BYTES: u64 = 67_108_864;
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
27pub enum CanonicalDomain {
28 Schema,
30 Assertion,
32 EvidenceLink,
34 ConfidenceAssessment,
36 Reasoning,
38 AssertionStatus,
40 AssertionValidity,
42 AssertionSupersession,
44 HypothesisGroup,
46 HypothesisMembership,
48 HypothesisSelection,
50 CompositeGraphMutationContent,
52 CompositeRequest,
54 ProvenanceEvent,
56 Lineage,
58 InvocationDescriptor,
60 GraphProjection,
62 BeliefProjectionPolicy,
64 BeliefProjectionAttachment,
66 ArrowResult,
68 ResearchSource,
70 ResearchArtifact,
72 ArtifactDerivation,
74 ArtifactPreference,
76 RetentionDependency,
78 ResearchClaim,
80 ResearchClaimRelation,
82 ResearchDecision,
84 ResearchClaimSuppression,
86}
87
88impl CanonicalDomain {
89 #[must_use]
91 pub const fn as_str(self) -> &'static str {
92 match self {
93 Self::Schema => "graphforge/schema",
94 Self::Assertion => "graphforge/assertion",
95 Self::EvidenceLink => "graphforge/evidence-link",
96 Self::ConfidenceAssessment => "graphforge/confidence-assessment",
97 Self::Reasoning => "graphforge/reasoning",
98 Self::AssertionStatus => "graphforge/assertion-status",
99 Self::AssertionValidity => "graphforge/assertion-validity",
100 Self::AssertionSupersession => "graphforge/assertion-supersession",
101 Self::HypothesisGroup => "graphforge/hypothesis-group",
102 Self::HypothesisMembership => "graphforge/hypothesis-membership",
103 Self::HypothesisSelection => "graphforge/hypothesis-selection",
104 Self::CompositeGraphMutationContent => "graphforge/composite-graph-mutation-content",
105 Self::CompositeRequest => "graphforge/composite-request",
106 Self::ProvenanceEvent => "graphforge/provenance-event",
107 Self::Lineage => "graphforge/lineage",
108 Self::InvocationDescriptor => "graphforge/invocation-descriptor",
109 Self::GraphProjection => "graphforge/graph-projection",
110 Self::BeliefProjectionPolicy => "graphforge/belief-projection-policy",
111 Self::BeliefProjectionAttachment => "graphforge/belief-projection-attachment",
112 Self::ArrowResult => "graphforge/arrow-result",
113 Self::ResearchSource => "graphforge/research-source",
114 Self::ResearchArtifact => "graphforge/research-artifact",
115 Self::ArtifactDerivation => "graphforge/artifact-derivation",
116 Self::ArtifactPreference => "graphforge/artifact-preference",
117 Self::RetentionDependency => "graphforge/retention-dependency",
118 Self::ResearchClaim => "graphforge/research-claim",
119 Self::ResearchClaimRelation => "graphforge/research-claim-relation",
120 Self::ResearchDecision => "graphforge/research-decision",
121 Self::ResearchClaimSuppression => "graphforge/research-claim-suppression",
122 }
123 }
124}
125
126#[derive(thiserror::Error, Clone, Debug, PartialEq, Eq)]
128pub enum CanonicalError {
129 #[error("canonical limit exceeded for {item}: observed {observed}, limit {limit}")]
131 Limit {
132 item: &'static str,
134 observed: u64,
136 limit: u64,
138 },
139 #[error("unsupported canonical contract version {version}")]
141 UnsupportedVersion {
142 version: u32,
144 },
145 #[error("invalid canonical payload: {0}")]
147 Malformed(&'static str),
148}
149
150impl CanonicalError {
151 #[must_use]
153 pub const fn code(&self) -> &'static str {
154 match self {
155 Self::Limit { .. } => "GF_CANONICAL_LIMIT",
156 Self::UnsupportedVersion { .. } => "GF_UNSUPPORTED_CONTRACT_VERSION",
157 Self::Malformed(_) => "GF_CANONICAL_INVALID",
158 }
159 }
160}
161
162#[derive(Clone, Debug, Default, PartialEq, Eq)]
164pub struct CanonicalWriter {
165 bytes: Vec<u8>,
166}
167
168#[derive(Clone, Debug)]
170pub struct CanonicalReader<'a> {
171 bytes: &'a [u8],
172 offset: usize,
173}
174
175impl<'a> CanonicalReader<'a> {
176 pub fn new(bytes: &'a [u8]) -> Result<Self, CanonicalError> {
178 let observed = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
179 if observed > MAX_CANONICAL_PAYLOAD_BYTES {
180 return Err(CanonicalError::Limit {
181 item: "payload",
182 observed,
183 limit: MAX_CANONICAL_PAYLOAD_BYTES,
184 });
185 }
186 Ok(Self { bytes, offset: 0 })
187 }
188
189 pub fn raw(&mut self, length: usize) -> Result<&'a [u8], CanonicalError> {
191 let end = self
192 .offset
193 .checked_add(length)
194 .ok_or(CanonicalError::Malformed("byte range overflow"))?;
195 let value = self
196 .bytes
197 .get(self.offset..end)
198 .ok_or(CanonicalError::Malformed("truncated payload"))?;
199 self.offset = end;
200 Ok(value)
201 }
202
203 pub fn u8(&mut self) -> Result<u8, CanonicalError> {
205 Ok(self.raw(1)?[0])
206 }
207
208 pub fn u32(&mut self) -> Result<u32, CanonicalError> {
210 Ok(u32::from_be_bytes(
211 self.raw(4)?.try_into().expect("reader returned four bytes"),
212 ))
213 }
214
215 pub fn u64(&mut self) -> Result<u64, CanonicalError> {
217 Ok(u64::from_be_bytes(
218 self.raw(8)?
219 .try_into()
220 .expect("reader returned eight bytes"),
221 ))
222 }
223
224 pub fn text(&mut self) -> Result<&'a str, CanonicalError> {
226 let length = self.u64()?;
227 if length > MAX_CANONICAL_TEXT_BYTES {
228 return Err(CanonicalError::Limit {
229 item: "text",
230 observed: length,
231 limit: MAX_CANONICAL_TEXT_BYTES,
232 });
233 }
234 let length = usize::try_from(length)
235 .map_err(|_| CanonicalError::Malformed("text length exceeds usize"))?;
236 std::str::from_utf8(self.raw(length)?)
237 .map_err(|_| CanonicalError::Malformed("text is not valid UTF-8"))
238 }
239
240 pub fn finish(self) -> Result<(), CanonicalError> {
242 if self.offset == self.bytes.len() {
243 Ok(())
244 } else {
245 Err(CanonicalError::Malformed("trailing bytes"))
246 }
247 }
248}
249
250impl CanonicalWriter {
251 #[must_use]
253 pub const fn new() -> Self {
254 Self { bytes: Vec::new() }
255 }
256
257 pub fn raw(&mut self, bytes: &[u8]) -> Result<(), CanonicalError> {
259 let next = self
260 .bytes
261 .len()
262 .checked_add(bytes.len())
263 .and_then(|value| u64::try_from(value).ok())
264 .ok_or(CanonicalError::Limit {
265 item: "payload",
266 observed: u64::MAX,
267 limit: MAX_CANONICAL_PAYLOAD_BYTES,
268 })?;
269 if next > MAX_CANONICAL_PAYLOAD_BYTES {
270 return Err(CanonicalError::Limit {
271 item: "payload",
272 observed: next,
273 limit: MAX_CANONICAL_PAYLOAD_BYTES,
274 });
275 }
276 self.bytes.extend_from_slice(bytes);
277 Ok(())
278 }
279
280 pub fn u8(&mut self, value: u8) -> Result<(), CanonicalError> {
282 self.raw(&[value])
283 }
284
285 pub fn u16(&mut self, value: u16) -> Result<(), CanonicalError> {
287 self.raw(&value.to_be_bytes())
288 }
289
290 pub fn u32(&mut self, value: u32) -> Result<(), CanonicalError> {
292 self.raw(&value.to_be_bytes())
293 }
294
295 pub fn u64(&mut self, value: u64) -> Result<(), CanonicalError> {
297 self.raw(&value.to_be_bytes())
298 }
299
300 pub fn i64(&mut self, value: i64) -> Result<(), CanonicalError> {
302 self.raw(&value.to_be_bytes())
303 }
304
305 pub fn binary(&mut self, value: &[u8]) -> Result<(), CanonicalError> {
307 let length = u64::try_from(value.len()).map_err(|_| CanonicalError::Limit {
308 item: "binary",
309 observed: u64::MAX,
310 limit: MAX_CANONICAL_BINARY_BYTES,
311 })?;
312 if length > MAX_CANONICAL_BINARY_BYTES {
313 return Err(CanonicalError::Limit {
314 item: "binary",
315 observed: length,
316 limit: MAX_CANONICAL_BINARY_BYTES,
317 });
318 }
319 self.u64(length)?;
320 self.raw(value)
321 }
322
323 pub fn text(&mut self, value: &str) -> Result<(), CanonicalError> {
325 let length = u64::try_from(value.len()).map_err(|_| CanonicalError::Limit {
326 item: "text",
327 observed: u64::MAX,
328 limit: MAX_CANONICAL_TEXT_BYTES,
329 })?;
330 if length > MAX_CANONICAL_TEXT_BYTES {
331 return Err(CanonicalError::Limit {
332 item: "text",
333 observed: length,
334 limit: MAX_CANONICAL_TEXT_BYTES,
335 });
336 }
337 self.u64(length)?;
338 self.raw(value.as_bytes())
339 }
340
341 #[must_use]
343 pub fn finish(self) -> Vec<u8> {
344 self.bytes
345 }
346}
347
348pub fn fingerprint_preimage(
350 domain: CanonicalDomain,
351 contract_version: u32,
352 payload: &[u8],
353) -> Result<Vec<u8>, CanonicalError> {
354 if contract_version != CANONICAL_CONTRACT_VERSION {
355 return Err(CanonicalError::UnsupportedVersion {
356 version: contract_version,
357 });
358 }
359 let payload_length = u64::try_from(payload.len()).map_err(|_| CanonicalError::Limit {
360 item: "payload",
361 observed: u64::MAX,
362 limit: MAX_CANONICAL_PAYLOAD_BYTES,
363 })?;
364 if payload_length > MAX_CANONICAL_PAYLOAD_BYTES {
365 return Err(CanonicalError::Limit {
366 item: "payload",
367 observed: payload_length,
368 limit: MAX_CANONICAL_PAYLOAD_BYTES,
369 });
370 }
371 let domain_bytes = domain.as_str().as_bytes();
372 let domain_length = u16::try_from(domain_bytes.len()).expect("closed domains fit UInt16");
373 let mut writer = CanonicalWriter::new();
374 writer.raw(b"GFFP")?;
375 writer.u8(1)?;
376 writer.u16(domain_length)?;
377 writer.raw(domain_bytes)?;
378 writer.u32(contract_version)?;
379 writer.u64(payload_length)?;
380 writer.raw(payload)?;
381 Ok(writer.finish())
382}
383
384pub fn fingerprint(
386 domain: CanonicalDomain,
387 contract_version: u32,
388 payload: &[u8],
389) -> Result<[u8; 32], CanonicalError> {
390 Ok(Sha256::digest(fingerprint_preimage(domain, contract_version, payload)?).into())
391}
392
393#[must_use]
395pub fn uuid_v8(fingerprint: [u8; 32]) -> uuid::Uuid {
396 let mut bytes = [0_u8; 16];
397 bytes.copy_from_slice(&fingerprint[..16]);
398 bytes[6] = (bytes[6] & 0x0f) | 0x80;
399 bytes[8] = (bytes[8] & 0x3f) | 0x80;
400 uuid::Uuid::from_bytes(bytes)
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406
407 fn decode_hex(value: &str) -> Vec<u8> {
408 value
409 .as_bytes()
410 .chunks_exact(2)
411 .map(|pair| {
412 let text = std::str::from_utf8(pair).unwrap();
413 u8::from_str_radix(text, 16).unwrap()
414 })
415 .collect()
416 }
417
418 #[test]
419 fn envelope_hash_and_uuid_match_the_frozen_vector() {
420 let payload = decode_hex(
421 "474654310000000000000027474653310000000100000000000000096e6f64655f757569640032000000100000000000000000000000000000000101018f47d2a1b27c3d8e4f001122334455",
422 );
423 let preimage = fingerprint_preimage(
424 CanonicalDomain::ArrowResult,
425 CANONICAL_CONTRACT_VERSION,
426 &payload,
427 )
428 .unwrap();
429 assert_eq!(
430 preimage,
431 decode_hex(
432 "474646500100176772617068666f7267652f6172726f772d726573756c7400000001000000000000004c474654310000000000000027474653310000000100000000000000096e6f64655f757569640032000000100000000000000000000000000000000101018f47d2a1b27c3d8e4f001122334455",
433 )
434 );
435 let digest = fingerprint(
436 CanonicalDomain::ArrowResult,
437 CANONICAL_CONTRACT_VERSION,
438 &payload,
439 )
440 .unwrap();
441 assert_eq!(
442 digest,
443 decode_hex("3cc432a310818554c11f6eb272fead2c549bcd108ab059453a641ab379c7bbb9")
444 .as_slice()
445 );
446 assert_eq!(
447 uuid_v8(digest).to_string(),
448 "3cc432a3-1081-8554-811f-6eb272fead2c"
449 );
450 }
451
452 #[test]
453 fn writer_is_big_endian_exact_and_bounded() {
454 let mut writer = CanonicalWriter::new();
455 writer.u16(0x0102).unwrap();
456 writer.u32(0x0304_0506).unwrap();
457 writer.u64(7).unwrap();
458 writer.text("é").unwrap();
459 assert_eq!(
460 writer.finish(),
461 [
462 1, 2, 3, 4, 5, 6, 0, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0, 0, 0, 0, 2, 0xc3, 0xa9,
463 ]
464 );
465 }
466
467 #[test]
468 fn unknown_versions_fail_before_hashing() {
469 let error = fingerprint(CanonicalDomain::Schema, 2, b"").unwrap_err();
470 assert_eq!(error.code(), "GF_UNSUPPORTED_CONTRACT_VERSION");
471 }
472
473 #[test]
474 fn reader_round_trips_and_rejects_truncation_trailing_and_utf8() {
475 let mut writer = CanonicalWriter::new();
476 writer.u8(7).unwrap();
477 writer.u32(9).unwrap();
478 writer.u64(11).unwrap();
479 writer.text("é").unwrap();
480 let bytes = writer.finish();
481 let mut reader = CanonicalReader::new(&bytes).unwrap();
482 assert_eq!(reader.u8().unwrap(), 7);
483 assert_eq!(reader.u32().unwrap(), 9);
484 assert_eq!(reader.u64().unwrap(), 11);
485 assert_eq!(reader.text().unwrap(), "é");
486 reader.finish().unwrap();
487
488 assert!(CanonicalReader::new(&[0, 0]).unwrap().u32().is_err());
489 let mut trailing = CanonicalReader::new(&[1, 2]).unwrap();
490 assert_eq!(trailing.u8().unwrap(), 1);
491 assert!(trailing.finish().is_err());
492 let mut invalid = CanonicalReader::new(&[0, 0, 0, 0, 0, 0, 0, 1, 0xff]).unwrap();
493 assert!(invalid.text().is_err());
494 }
495
496 #[test]
497 fn binary_signed_integer_and_declared_text_limit_contracts_are_exact() {
498 let mut writer = CanonicalWriter::new();
499 writer.i64(-7).unwrap();
500 writer.binary(&[1, 2, 3]).unwrap();
501 assert_eq!(
502 writer.finish(),
503 [
504 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf9, 0, 0, 0, 0, 0, 0, 0, 3, 1, 2, 3,
505 ]
506 );
507
508 let mut declared = (MAX_CANONICAL_TEXT_BYTES + 1).to_be_bytes().to_vec();
509 declared.extend_from_slice(b"unused");
510 let error = CanonicalReader::new(&declared).unwrap().text().unwrap_err();
511 assert_eq!(error.code(), "GF_CANONICAL_LIMIT");
512 assert!(matches!(error, CanonicalError::Limit { item: "text", .. }));
513 }
514
515 #[test]
516 fn every_domain_and_error_code_has_a_frozen_external_spelling() {
517 let domains = [
518 (CanonicalDomain::ResearchClaim, "graphforge/research-claim"),
519 (
520 CanonicalDomain::ResearchClaimSuppression,
521 "graphforge/research-claim-suppression",
522 ),
523 (
524 CanonicalDomain::ResearchClaimRelation,
525 "graphforge/research-claim-relation",
526 ),
527 (
528 CanonicalDomain::ResearchDecision,
529 "graphforge/research-decision",
530 ),
531 (CanonicalDomain::Schema, "graphforge/schema"),
532 (CanonicalDomain::Assertion, "graphforge/assertion"),
533 (CanonicalDomain::EvidenceLink, "graphforge/evidence-link"),
534 (
535 CanonicalDomain::ConfidenceAssessment,
536 "graphforge/confidence-assessment",
537 ),
538 (CanonicalDomain::Reasoning, "graphforge/reasoning"),
539 (
540 CanonicalDomain::AssertionStatus,
541 "graphforge/assertion-status",
542 ),
543 (
544 CanonicalDomain::AssertionValidity,
545 "graphforge/assertion-validity",
546 ),
547 (
548 CanonicalDomain::AssertionSupersession,
549 "graphforge/assertion-supersession",
550 ),
551 (
552 CanonicalDomain::HypothesisGroup,
553 "graphforge/hypothesis-group",
554 ),
555 (
556 CanonicalDomain::HypothesisMembership,
557 "graphforge/hypothesis-membership",
558 ),
559 (
560 CanonicalDomain::HypothesisSelection,
561 "graphforge/hypothesis-selection",
562 ),
563 (
564 CanonicalDomain::CompositeGraphMutationContent,
565 "graphforge/composite-graph-mutation-content",
566 ),
567 (
568 CanonicalDomain::CompositeRequest,
569 "graphforge/composite-request",
570 ),
571 (
572 CanonicalDomain::ProvenanceEvent,
573 "graphforge/provenance-event",
574 ),
575 (CanonicalDomain::Lineage, "graphforge/lineage"),
576 (
577 CanonicalDomain::InvocationDescriptor,
578 "graphforge/invocation-descriptor",
579 ),
580 (
581 CanonicalDomain::GraphProjection,
582 "graphforge/graph-projection",
583 ),
584 (
585 CanonicalDomain::BeliefProjectionPolicy,
586 "graphforge/belief-projection-policy",
587 ),
588 (
589 CanonicalDomain::BeliefProjectionAttachment,
590 "graphforge/belief-projection-attachment",
591 ),
592 (CanonicalDomain::ArrowResult, "graphforge/arrow-result"),
593 (
594 CanonicalDomain::ResearchSource,
595 "graphforge/research-source",
596 ),
597 (
598 CanonicalDomain::ResearchArtifact,
599 "graphforge/research-artifact",
600 ),
601 (
602 CanonicalDomain::ArtifactDerivation,
603 "graphforge/artifact-derivation",
604 ),
605 (
606 CanonicalDomain::ArtifactPreference,
607 "graphforge/artifact-preference",
608 ),
609 (
610 CanonicalDomain::RetentionDependency,
611 "graphforge/retention-dependency",
612 ),
613 ];
614 for (domain, spelling) in domains {
615 assert_eq!(domain.as_str(), spelling);
616 }
617
618 assert_eq!(
619 CanonicalError::Limit {
620 item: "x",
621 observed: 2,
622 limit: 1
623 }
624 .code(),
625 "GF_CANONICAL_LIMIT"
626 );
627 assert_eq!(
628 CanonicalError::UnsupportedVersion { version: 0 }.code(),
629 "GF_UNSUPPORTED_CONTRACT_VERSION"
630 );
631 assert_eq!(
632 CanonicalError::Malformed("x").code(),
633 "GF_CANONICAL_INVALID"
634 );
635 }
636
637 #[test]
638 fn reader_rejects_declared_text_limit_and_offset_overflow() {
639 let bytes = (MAX_CANONICAL_TEXT_BYTES + 1).to_be_bytes();
640 let error = CanonicalReader::new(&bytes).unwrap().text().unwrap_err();
641 assert!(matches!(error, CanonicalError::Limit { item: "text", .. }));
642
643 let mut reader = CanonicalReader::new(&[0]).unwrap();
644 reader.u8().unwrap();
645 assert_eq!(
646 reader.raw(usize::MAX).unwrap_err(),
647 CanonicalError::Malformed("byte range overflow")
648 );
649 }
650
651 #[test]
652 fn writer_covers_signed_binary_and_single_byte_grammar() {
653 let mut writer = CanonicalWriter::new();
654 writer.u8(0xab).unwrap();
655 writer.i64(-2).unwrap();
656 writer.binary(&[1, 2, 3]).unwrap();
657 assert_eq!(
658 writer.finish(),
659 [
660 0xab, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, 0, 0, 0, 0, 0, 0, 0, 3, 1, 2,
661 3
662 ]
663 );
664 }
665
666 #[test]
667 fn wave11_writer_rejects_oversized_binary_and_text_before_publication() {
668 let binary = vec![0_u8; usize::try_from(MAX_CANONICAL_BINARY_BYTES + 1).unwrap()];
669 let mut writer = CanonicalWriter::new();
670 let error = writer.binary(&binary).unwrap_err();
671 assert!(matches!(
672 error,
673 CanonicalError::Limit {
674 item: "binary",
675 observed,
676 limit: MAX_CANONICAL_BINARY_BYTES,
677 } if observed == MAX_CANONICAL_BINARY_BYTES + 1
678 ));
679 assert!(writer.finish().is_empty());
680 drop(binary);
681
682 let text = "x".repeat(usize::try_from(MAX_CANONICAL_TEXT_BYTES + 1).unwrap());
683 let mut writer = CanonicalWriter::new();
684 let error = writer.text(&text).unwrap_err();
685 assert!(matches!(
686 error,
687 CanonicalError::Limit {
688 item: "text",
689 observed,
690 limit: MAX_CANONICAL_TEXT_BYTES,
691 } if observed == MAX_CANONICAL_TEXT_BYTES + 1
692 ));
693 assert!(writer.finish().is_empty());
694 }
695}