Skip to main content

prikk_object/
id.rs

1//! Object identifiers and object type codes.
2
3use core::fmt;
4use core::str::FromStr;
5
6use prikk_error::{PrikkError, Result};
7use prikk_hash::{sha256, to_hex};
8
9/// Single domain used for object identity preimages.
10pub const OBJECT_ID_DOMAIN: &[u8] = b"PRIKK-OBJECT-ID-v1";
11
12/// Object type codes retired from the assignable range. `from_code` refuses every one of these
13/// with a message naming the retirement, checked *before* the live-code match below, so
14/// re-adding a code here can never silently start decoding again just because a future match arm
15/// happens to claim it too -- the retirement always wins. There are 245 codes free in the u16
16/// range `from_code`/`code` never use below `0x100`; the benefit of ever reusing a retired one is
17/// zero and the cost of a collision (two different object shapes sharing one identity-preimage
18/// type tag) is unbounded.
19const RETIRED_CODES: &[(u16, &str)] = &[(0x0A, "project-genesis")];
20
21// RFC 118 stage 6, applying the same discipline as stage 4's `verification_stages!` and DC-21's
22// `conflict_witness_kinds!`: the variant list, `ALL`, `from_code`, and `name()` are generated
23// together from one token list, in one macro expansion, rather than kept as independently
24// hand-maintained lists a hand-added variant can silently omit from. Unlike those two prior
25// macros, `ObjectType` carries an explicit `u16` discriminant per variant -- `from_code` needs
26// the reverse (code -> variant) mapping, which cannot be derived from `self as u16` alone, so the
27// discriminant token is written once per variant and copied by this expansion into both the enum
28// discriminant and the `from_code` match arm. That is the one duplication this shape cannot
29// avoid (the two things really are different directions of the same mapping); everything else --
30// adding an eleventh type -- means adding one line here and nowhere else.
31macro_rules! object_types {
32    (
33        $(
34            $(#[$doc:meta])*
35            $variant:ident = $code:literal => $name:literal,
36        )+
37    ) => {
38        /// A Prikk object type code.
39        #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
40        #[repr(u16)]
41        pub enum ObjectType {
42            $(
43                $(#[$doc])*
44                $variant = $code,
45            )+
46        }
47
48        impl ObjectType {
49            /// Every live variant, in declaration order. Generated by this enum's own defining
50            /// macro invocation, not a second, independently maintained list -- see
51            /// `id/tests.rs`'s `a_new_variant_reaches_every_generated_consumer`-style controls for
52            /// why that matters: a hand-written `ALL` cannot be forced to grow when a variant is
53            /// added, only a match can, and `ALL` is not a match.
54            pub const ALL: &'static [Self] = &[$(Self::$variant),+];
55
56            /// Return the stable u16 code used in object identity bytes.
57            #[must_use]
58            pub const fn code(self) -> u16 {
59                self as u16
60            }
61
62            /// Parse a stable u16 code.
63            pub fn from_code(code: u16) -> Result<Self> {
64                if let Some((_, name)) = RETIRED_CODES.iter().find(|&&(retired, _)| retired == code) {
65                    return Err(PrikkError::MalformedData(format!(
66                        "object type code {code} is retired (formerly {name}) and must never be reused"
67                    )));
68                }
69                match code {
70                    $($code => Ok(Self::$variant),)+
71                    other => Err(PrikkError::MalformedData(format!(
72                        "unknown object type code: {other}"
73                    ))),
74                }
75            }
76
77            /// Return a stable human-readable name.
78            #[must_use]
79            pub const fn name(self) -> &'static str {
80                match self {
81                    $(Self::$variant => $name,)+
82                }
83            }
84        }
85    };
86}
87
88object_types! {
89    /// Patch object.
90    Patch = 0x01 => "patch",
91    /// Block object.
92    Block = 0x02 => "block",
93    /// RefState object.
94    RefState = 0x03 => "ref-state",
95    /// RefUpdate event. Object-envelope type stored inline in `refs/logs/`
96    /// (journal then log), not a permanent object-store directory.
97    RefUpdate = 0x04 => "ref-update",
98    /// Tag object.
99    Tag = 0x05 => "tag",
100    /// Attestation object.
101    Attestation = 0x06 => "attestation",
102    /// Blob object.
103    Blob = 0x07 => "blob",
104    /// Rebuildable block-summary cache. Uses the canonical codec for
105    /// reproducibility but is never a root of trust or part of block identity.
106    BlockSummaryCache = 0x08 => "block-summary-cache",
107    /// Signed doctor-repair note stored inline in `refs/recovery/`. Never a
108    /// `RefUpdate` substitute (FDD-02 §10.4).
109    RecoveryNote = 0x09 => "recovery-note",
110    // `0x0A` was `ProjectGenesis` (FDD-03 §9.13) -- deleted (repository-identity settlement
111    // handoff v1): a project-level genesis object implies repositories carry an identity to
112    // anchor, which the shipped design never grants (RFC 115 §2.4-§2.7: repositories are
113    // anonymous, identity lives only in signer keys and patch ids). No payload module ever
114    // existed for it, `admitted_schemas` always returned `None`, and nothing could construct
115    // one. See `RETIRED_CODES` above -- `0x0A` must never be reassigned.
116    /// RFC 115 Stage 2 (design-v1.md D3): a signed claim that named patches were sealed into a
117    /// named block, under the signer's key. Never trust-conferring and never existence-checked
118    /// against the block/patches it names — see `RecognitionClaimPayload`'s own doc.
119    RecognitionClaim = 0x0B => "recognition-claim",
120}
121
122impl fmt::Display for ObjectType {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        f.write_str(self.name())
125    }
126}
127
128/// A 32-byte object identifier.
129#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
130pub struct ObjectId([u8; 32]);
131
132impl ObjectId {
133    /// Construct an object ID from raw bytes.
134    #[must_use]
135    pub const fn from_bytes(bytes: [u8; 32]) -> Self {
136        Self(bytes)
137    }
138
139    /// Return raw ID bytes.
140    #[must_use]
141    pub const fn as_bytes(&self) -> &[u8; 32] {
142        &self.0
143    }
144
145    /// Compute an object ID from object type, schema version, and unsigned canonical payload.
146    #[must_use]
147    pub fn from_canonical_payload(
148        object_type: ObjectType,
149        schema_version: u32,
150        canonical_payload: &[u8],
151    ) -> Self {
152        let mut preimage =
153            Vec::with_capacity(OBJECT_ID_DOMAIN.len() + 2 + 4 + 8 + canonical_payload.len());
154        preimage.extend_from_slice(OBJECT_ID_DOMAIN);
155        preimage.extend_from_slice(&object_type.code().to_be_bytes());
156        preimage.extend_from_slice(&schema_version.to_be_bytes());
157        preimage.extend_from_slice(&(canonical_payload.len() as u64).to_be_bytes());
158        preimage.extend_from_slice(canonical_payload);
159        Self(sha256(&preimage))
160    }
161
162    /// Return lowercase hex.
163    #[must_use]
164    pub fn to_hex(&self) -> String {
165        to_hex(&self.0)
166    }
167}
168
169impl fmt::Debug for ObjectId {
170    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171        write!(f, "ObjectId({})", self.to_hex())
172    }
173}
174
175impl fmt::Display for ObjectId {
176    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177        f.write_str(&self.to_hex())
178    }
179}
180
181impl FromStr for ObjectId {
182    type Err = PrikkError;
183
184    fn from_str(s: &str) -> Result<Self> {
185        if s.len() != 64 {
186            return Err(PrikkError::InvalidObjectId(format!(
187                "expected 64 lowercase hex chars, got {}",
188                s.len()
189            )));
190        }
191        let mut out = [0_u8; 32];
192        for (slot, pair) in out.iter_mut().zip(s.as_bytes().chunks_exact(2)) {
193            let mut bytes = pair.iter().copied();
194            let high = bytes.next().ok_or_else(|| {
195                PrikkError::InvalidObjectId("hex pair is unexpectedly short".to_string())
196            })?;
197            let low = bytes.next().ok_or_else(|| {
198                PrikkError::InvalidObjectId("hex pair is unexpectedly short".to_string())
199            })?;
200            *slot = (hex_value(high)? << 4) | hex_value(low)?;
201        }
202        Ok(Self(out))
203    }
204}
205
206fn hex_value(byte: u8) -> Result<u8> {
207    match byte {
208        b'0'..=b'9' => Ok(byte - b'0'),
209        b'a'..=b'f' => Ok(byte - b'a' + 10),
210        _ => Err(PrikkError::InvalidObjectId(
211            "object IDs must use lowercase hex only".to_string(),
212        )),
213    }
214}
215
216#[cfg(test)]
217mod tests;