Skip to main content

graphforge_core/
canonical.rs

1//! Shared `graphforge-canonical/1` envelope and byte primitives.
2//!
3//! Domain owners define their registered schemas and logical-value encoders.
4//! This module owns the common bounded byte writer, closed fingerprint domains,
5//! SHA-256 envelope, and UUIDv8 projection.
6
7use sha2::{Digest, Sha256};
8
9/// Frozen canonical contract version.
10pub const CANONICAL_CONTRACT_VERSION: u32 = 1;
11/// Maximum canonical payload size in version 1.
12pub const MAX_CANONICAL_PAYLOAD_BYTES: u64 = 2_147_483_648;
13/// Maximum UTF-8 value size in version 1.
14pub const MAX_CANONICAL_TEXT_BYTES: u64 = 16_777_216;
15/// Maximum binary value size in version 1.
16pub const MAX_CANONICAL_BINARY_BYTES: u64 = 67_108_864;
17
18/// Closed fingerprint domains in `graphforge-canonical/1`.
19#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
20pub enum CanonicalDomain {
21    /// Authoritative schema bytes.
22    Schema,
23    /// Assertion record/table.
24    Assertion,
25    /// Evidence-link record/table.
26    EvidenceLink,
27    /// Confidence-assessment record/table.
28    ConfidenceAssessment,
29    /// Immutable reasoning record/table.
30    Reasoning,
31    /// Append-only assertion-status event/table.
32    AssertionStatus,
33    /// Append-only assertion-validity event/table.
34    AssertionValidity,
35    /// Immutable assertion-supersession relation/table.
36    AssertionSupersession,
37    /// Immutable hypothesis-group record/table.
38    HypothesisGroup,
39    /// Append-only hypothesis-membership event/table.
40    HypothesisMembership,
41    /// Append-only hypothesis-selection event/table.
42    HypothesisSelection,
43    /// Ordered composite graph-mutation content.
44    CompositeGraphMutationContent,
45    /// Composite graph and knowledge request content.
46    CompositeRequest,
47    /// Provenance event/table.
48    ProvenanceEvent,
49    /// Lineage record/table.
50    Lineage,
51    /// Neutral M18 invocation descriptor.
52    InvocationDescriptor,
53    /// Resolved graph projection.
54    GraphProjection,
55    /// Resolved-belief policy bytes.
56    BeliefProjectionPolicy,
57    /// Append-only interpretation attachment.
58    BeliefProjectionAttachment,
59    /// Public Arrow result table.
60    ArrowResult,
61}
62
63impl CanonicalDomain {
64    /// Exact lowercase domain tag.
65    #[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/// Structured canonicalization failures.
93#[derive(thiserror::Error, Clone, Debug, PartialEq, Eq)]
94pub enum CanonicalError {
95    /// A bounded byte or item limit was exceeded.
96    #[error("canonical limit exceeded for {item}: observed {observed}, limit {limit}")]
97    Limit {
98        /// Safe item category.
99        item: &'static str,
100        /// Observed byte/item count.
101        observed: u64,
102        /// Frozen maximum.
103        limit: u64,
104    },
105    /// A caller requested an unsupported canonical contract version.
106    #[error("unsupported canonical contract version {version}")]
107    UnsupportedVersion {
108        /// Unsupported version.
109        version: u32,
110    },
111    /// Canonical bytes are truncated or structurally invalid.
112    #[error("invalid canonical payload: {0}")]
113    Malformed(&'static str),
114}
115
116impl CanonicalError {
117    /// Stable public error code.
118    #[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/// Bounded writer for the fixed-width canonical grammar.
129#[derive(Clone, Debug, Default, PartialEq, Eq)]
130pub struct CanonicalWriter {
131    bytes: Vec<u8>,
132}
133
134/// Strict reader for the fixed-width canonical grammar.
135#[derive(Clone, Debug)]
136pub struct CanonicalReader<'a> {
137    bytes: &'a [u8],
138    offset: usize,
139}
140
141impl<'a> CanonicalReader<'a> {
142    /// Construct a reader after enforcing the top-level payload bound.
143    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    /// Read an exact number of bytes.
156    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    /// Read one unsigned byte.
170    pub fn u8(&mut self) -> Result<u8, CanonicalError> {
171        Ok(self.raw(1)?[0])
172    }
173
174    /// Read one big-endian unsigned 32-bit integer.
175    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    /// Read one big-endian unsigned 64-bit integer.
182    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    /// Read one bounded length-prefixed UTF-8 value.
191    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    /// Reject unconsumed trailing bytes.
207    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    /// Construct an empty writer.
218    #[must_use]
219    pub const fn new() -> Self {
220        Self { bytes: Vec::new() }
221    }
222
223    /// Append exact bytes while enforcing the payload bound.
224    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    /// Append one unsigned byte.
247    pub fn u8(&mut self, value: u8) -> Result<(), CanonicalError> {
248        self.raw(&[value])
249    }
250
251    /// Append an unsigned big-endian 16-bit integer.
252    pub fn u16(&mut self, value: u16) -> Result<(), CanonicalError> {
253        self.raw(&value.to_be_bytes())
254    }
255
256    /// Append an unsigned big-endian 32-bit integer.
257    pub fn u32(&mut self, value: u32) -> Result<(), CanonicalError> {
258        self.raw(&value.to_be_bytes())
259    }
260
261    /// Append an unsigned big-endian 64-bit integer.
262    pub fn u64(&mut self, value: u64) -> Result<(), CanonicalError> {
263        self.raw(&value.to_be_bytes())
264    }
265
266    /// Append a signed big-endian 64-bit integer.
267    pub fn i64(&mut self, value: i64) -> Result<(), CanonicalError> {
268        self.raw(&value.to_be_bytes())
269    }
270
271    /// Append a bounded binary item (`u64` length plus bytes).
272    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    /// Append a bounded UTF-8 item (`u64` length plus exact UTF-8 bytes).
290    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    /// Consume the writer.
308    #[must_use]
309    pub fn finish(self) -> Vec<u8> {
310        self.bytes
311    }
312}
313
314/// Build the frozen fingerprint preimage for a canonical payload.
315pub 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
350/// Compute the domain-separated SHA-256 fingerprint.
351pub 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/// Project a full canonical fingerprint into an RFC 9562 UUIDv8.
360#[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}