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;