Skip to main content

heddle_object_model/object/manifest/
binding.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Canonical manifest bindings — what ties a content root to an identity.
3//!
4//! A binding names `(spool, facet, owner, content_root)`. The owner descriptor
5//! is deliberately **outside** the content root: every state and attachment has
6//! a distinct owner hash, while a context-only state must reuse its parent's
7//! content root byte-for-byte. Keeping owner identity in the binding is what
8//! makes that reuse possible.
9//!
10//! Facet identity follows the ratified weft #358 decision: every spool carries
11//! all four facets (content, governance, membership, children) uniformly, with
12//! no content-bearing discriminant, so a binding always names its facet
13//! explicitly rather than defaulting. The tokens match
14//! `heddle_refs::SpoolFacet::token()` exactly — this type exists only because
15//! the object model sits below the refs crate, not to introduce a second
16//! spelling.
17//!
18//! Mutable facts stay out. Audience and visibility, the current head, and pack
19//! locations are control-plane data resolved after authorization; changing who
20//! may see a state must not rewrite a single content byte.
21//!
22//! ```text
23//! binding: "WPMB" | u8(version=1) | u16(spool_len) | spool
24//!                 | u16(facet_len) | facet | u8(owner_kind)
25//!                 | [u8;32](owner_hash) | u64(owner_decoded_size)
26//!                 | [u8;32](content_root)
27//! ```
28
29use std::fmt;
30
31use crate::object::{ContentHash, SpoolId};
32
33/// Magic prefix on every canonical binding.
34pub const MANIFEST_BINDING_MAGIC: [u8; 4] = *b"WPMB";
35/// The only binding format version this binary reads or writes.
36pub const MANIFEST_BINDING_VERSION: u8 = 1;
37
38/// The four uniform spool facets from weft #358, plus an open named tail.
39///
40/// `Named` keeps the set genuinely open — the substrate treats a facet as a
41/// token. A `Named` token that spells a well-known facet normalizes to it, so
42/// the two spellings can never diverge in a content hash.
43#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
44pub enum ManifestFacet {
45    Content,
46    Governance,
47    Membership,
48    Children,
49    Named(String),
50}
51
52impl ManifestFacet {
53    /// The facet token as it appears in canonical bytes, scope tokens, and ref
54    /// names.
55    pub fn token(&self) -> &str {
56        match self {
57            Self::Content => "content",
58            Self::Governance => "governance",
59            Self::Membership => "membership",
60            Self::Children => "children",
61            Self::Named(token) => token.as_str(),
62        }
63    }
64
65    /// Parse a token, normalizing the four well-known spellings.
66    pub fn parse(token: impl AsRef<str>) -> Result<Self, ManifestFacetParseError> {
67        let token = token.as_ref();
68        match token {
69            "content" => return Ok(Self::Content),
70            "governance" => return Ok(Self::Governance),
71            "membership" => return Ok(Self::Membership),
72            "children" => return Ok(Self::Children),
73            _ => {}
74        }
75        if !valid_facet_token(token) {
76            return Err(ManifestFacetParseError(token.to_string()));
77        }
78        Ok(Self::Named(token.to_string()))
79    }
80
81    /// The four facets every spool carries, per weft #358.
82    pub fn well_known() -> [Self; 4] {
83        [
84            Self::Content,
85            Self::Governance,
86            Self::Membership,
87            Self::Children,
88        ]
89    }
90}
91
92fn valid_facet_token(token: &str) -> bool {
93    !token.is_empty()
94        && token.len() <= 64
95        && token
96            .bytes()
97            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'-' | b'_'))
98        && token.as_bytes()[0].is_ascii_alphanumeric()
99        && token.as_bytes()[token.len() - 1].is_ascii_alphanumeric()
100}
101
102impl fmt::Display for ManifestFacet {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        f.write_str(self.token())
105    }
106}
107
108#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
109#[error("invalid facet token '{0}'")]
110pub struct ManifestFacetParseError(String);
111
112/// What a content root is bound to.
113///
114/// Distinct from [`super::node::ManifestObjectKind`] on purpose: leaves name
115/// content (blobs and trees), while owners name the publication unit.
116#[repr(u8)]
117#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
118pub enum ManifestOwnerKind {
119    State = 0,
120    StateAttachment = 1,
121}
122
123impl ManifestOwnerKind {
124    pub fn to_byte(self) -> u8 {
125        self as u8
126    }
127
128    pub fn from_byte(byte: u8) -> Option<Self> {
129        match byte {
130            0 => Some(Self::State),
131            1 => Some(Self::StateAttachment),
132            _ => None,
133        }
134    }
135}
136
137/// An immutable `(spool, facet, owner) -> content_root` binding.
138#[derive(Clone, Debug, PartialEq, Eq)]
139pub struct ManifestBinding {
140    pub spool: SpoolId,
141    pub facet: ManifestFacet,
142    pub owner_kind: ManifestOwnerKind,
143    pub owner_hash: ContentHash,
144    pub owner_decoded_size: u64,
145    pub content_root: ContentHash,
146}
147
148impl ManifestBinding {
149    /// Encode to the single canonical byte string for this binding.
150    pub fn encode(&self) -> Vec<u8> {
151        let spool = self.spool.as_str();
152        let facet = self.facet.token();
153        let mut out =
154            Vec::with_capacity(4 + 1 + 2 + spool.len() + 2 + facet.len() + 1 + 32 + 8 + 32);
155        out.extend_from_slice(&MANIFEST_BINDING_MAGIC);
156        out.push(MANIFEST_BINDING_VERSION);
157        out.extend_from_slice(&(spool.len() as u16).to_be_bytes());
158        out.extend_from_slice(spool.as_bytes());
159        out.extend_from_slice(&(facet.len() as u16).to_be_bytes());
160        out.extend_from_slice(facet.as_bytes());
161        out.push(self.owner_kind.to_byte());
162        out.extend_from_slice(self.owner_hash.as_bytes());
163        out.extend_from_slice(&self.owner_decoded_size.to_be_bytes());
164        out.extend_from_slice(self.content_root.as_bytes());
165        out
166    }
167
168    /// The binding's content address — `BLAKE3` of its canonical bytes.
169    pub fn address(&self) -> ContentHash {
170        ContentHash::compute(&self.encode())
171    }
172
173    /// Decode strictly, rejecting truncation, trailing bytes, unknown kinds,
174    /// invalid spool/facet tokens, and any non-canonical spelling.
175    pub fn decode(bytes: &[u8]) -> Result<Self, ManifestBindingDecodeError> {
176        let binding = Self::decode_inner(bytes)?;
177        if binding.encode() != bytes {
178            return Err(ManifestBindingDecodeError::NonCanonicalEncoding);
179        }
180        Ok(binding)
181    }
182
183    fn decode_inner(bytes: &[u8]) -> Result<Self, ManifestBindingDecodeError> {
184        let mut reader = BindingReader { bytes, pos: 0 };
185
186        if reader.take(4)? != MANIFEST_BINDING_MAGIC {
187            return Err(ManifestBindingDecodeError::BadMagic);
188        }
189        let version = reader.u8()?;
190        if version != MANIFEST_BINDING_VERSION {
191            return Err(ManifestBindingDecodeError::UnsupportedVersion(version));
192        }
193
194        let spool_token = reader.short_string()?;
195        let spool = SpoolId::parse(spool_token.clone())
196            .map_err(|_| ManifestBindingDecodeError::InvalidSpoolId(spool_token))?;
197        let facet_token = reader.short_string()?;
198        let facet = ManifestFacet::parse(&facet_token)
199            .map_err(|_| ManifestBindingDecodeError::InvalidFacet(facet_token))?;
200
201        let owner_byte = reader.u8()?;
202        let owner_kind = ManifestOwnerKind::from_byte(owner_byte)
203            .ok_or(ManifestBindingDecodeError::UnknownOwnerKind(owner_byte))?;
204        let owner_hash = ContentHash::from_bytes(reader.hash()?);
205        let owner_decoded_size = reader.u64()?;
206        let content_root = ContentHash::from_bytes(reader.hash()?);
207
208        if reader.pos != bytes.len() {
209            return Err(ManifestBindingDecodeError::TrailingBytes);
210        }
211
212        Ok(Self {
213            spool,
214            facet,
215            owner_kind,
216            owner_hash,
217            owner_decoded_size,
218            content_root,
219        })
220    }
221}
222
223struct BindingReader<'a> {
224    bytes: &'a [u8],
225    pos: usize,
226}
227
228impl<'a> BindingReader<'a> {
229    fn take(&mut self, len: usize) -> Result<&'a [u8], ManifestBindingDecodeError> {
230        let end = self
231            .pos
232            .checked_add(len)
233            .ok_or(ManifestBindingDecodeError::Truncated)?;
234        let slice = self
235            .bytes
236            .get(self.pos..end)
237            .ok_or(ManifestBindingDecodeError::Truncated)?;
238        self.pos = end;
239        Ok(slice)
240    }
241
242    fn u8(&mut self) -> Result<u8, ManifestBindingDecodeError> {
243        Ok(self.take(1)?[0])
244    }
245
246    fn u16(&mut self) -> Result<u16, ManifestBindingDecodeError> {
247        let bytes = self.take(2)?;
248        Ok(u16::from_be_bytes([bytes[0], bytes[1]]))
249    }
250
251    fn u64(&mut self) -> Result<u64, ManifestBindingDecodeError> {
252        let bytes = self.take(8)?;
253        let mut arr = [0u8; 8];
254        arr.copy_from_slice(bytes);
255        Ok(u64::from_be_bytes(arr))
256    }
257
258    fn hash(&mut self) -> Result<[u8; 32], ManifestBindingDecodeError> {
259        let bytes = self.take(32)?;
260        let mut arr = [0u8; 32];
261        arr.copy_from_slice(bytes);
262        Ok(arr)
263    }
264
265    fn short_string(&mut self) -> Result<String, ManifestBindingDecodeError> {
266        let len = usize::from(self.u16()?);
267        std::str::from_utf8(self.take(len)?)
268            .map(str::to_string)
269            .map_err(|_| ManifestBindingDecodeError::InvalidUtf8)
270    }
271}
272
273#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
274pub enum ManifestBindingDecodeError {
275    #[error("binding does not start with the WPMB magic")]
276    BadMagic,
277    #[error("unsupported binding version {0}")]
278    UnsupportedVersion(u8),
279    #[error("unknown manifest owner kind {0}")]
280    UnknownOwnerKind(u8),
281    #[error("binding bytes are truncated")]
282    Truncated,
283    #[error("binding has trailing bytes after its declared content")]
284    TrailingBytes,
285    #[error("spool id or facet token is not valid UTF-8")]
286    InvalidUtf8,
287    #[error("invalid spool id: {0}")]
288    InvalidSpoolId(String),
289    #[error("invalid facet token: {0}")]
290    InvalidFacet(String),
291    #[error("binding bytes are a non-canonical spelling of their own content")]
292    NonCanonicalEncoding,
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    fn binding() -> ManifestBinding {
300        ManifestBinding {
301            spool: SpoolId::parse("acme/api-v2").unwrap(),
302            facet: ManifestFacet::Content,
303            owner_kind: ManifestOwnerKind::State,
304            owner_hash: ContentHash::from_bytes([7; 32]),
305            owner_decoded_size: 4096,
306            content_root: ContentHash::from_bytes([9; 32]),
307        }
308    }
309
310    #[test]
311    fn binding_round_trips_and_is_hash_stable() {
312        let binding = binding();
313        let encoded = binding.encode();
314        let decoded = ManifestBinding::decode(&encoded).unwrap();
315        assert_eq!(decoded, binding);
316        assert_eq!(decoded.encode(), encoded);
317        assert_eq!(decoded.address(), binding.address());
318    }
319
320    #[test]
321    fn every_spool_carries_all_four_facets_and_they_bind_distinctly() {
322        // weft #358: facets are uniform and independent, so the same owner and
323        // content root under two facets must never collide.
324        let addresses: Vec<ContentHash> = ManifestFacet::well_known()
325            .into_iter()
326            .map(|facet| ManifestBinding { facet, ..binding() }.address())
327            .collect();
328        let unique: std::collections::BTreeSet<_> = addresses.iter().collect();
329        assert_eq!(unique.len(), 4, "facets must not collide in binding space");
330    }
331
332    #[test]
333    fn a_named_token_normalizes_to_its_well_known_facet() {
334        assert_eq!(
335            ManifestFacet::parse("children").unwrap(),
336            ManifestFacet::Children
337        );
338        // Normalization matters for hashing: two spellings must not produce
339        // two addresses for one logical binding.
340        let named = ManifestBinding {
341            facet: ManifestFacet::parse("content").unwrap(),
342            ..binding()
343        };
344        assert_eq!(named.address(), binding().address());
345    }
346
347    #[test]
348    fn owner_identity_is_outside_the_content_root() {
349        // A context-only state reuses its parent content root byte-for-byte;
350        // only the owner half of the binding moves.
351        let parent = binding();
352        let child = ManifestBinding {
353            owner_hash: ContentHash::from_bytes([11; 32]),
354            ..parent.clone()
355        };
356        assert_eq!(child.content_root, parent.content_root);
357        assert_ne!(child.address(), parent.address());
358    }
359
360    #[test]
361    fn decode_rejects_each_corruption_class() {
362        let encoded = binding().encode();
363
364        let mut bad_magic = encoded.clone();
365        bad_magic[0] = b'Q';
366        assert_eq!(
367            ManifestBinding::decode(&bad_magic).unwrap_err(),
368            ManifestBindingDecodeError::BadMagic
369        );
370
371        let mut bad_version = encoded.clone();
372        bad_version[4] = 4;
373        assert_eq!(
374            ManifestBinding::decode(&bad_version).unwrap_err(),
375            ManifestBindingDecodeError::UnsupportedVersion(4)
376        );
377
378        let mut trailing = encoded.clone();
379        trailing.push(0);
380        assert_eq!(
381            ManifestBinding::decode(&trailing).unwrap_err(),
382            ManifestBindingDecodeError::TrailingBytes
383        );
384
385        assert_eq!(
386            ManifestBinding::decode(&encoded[..encoded.len() - 1]).unwrap_err(),
387            ManifestBindingDecodeError::Truncated
388        );
389
390        // The owner-kind byte sits right after the two length-prefixed tokens.
391        let owner_kind_at = 4 + 1 + 2 + "acme/api-v2".len() + 2 + "content".len();
392        let mut bad_owner = encoded;
393        bad_owner[owner_kind_at] = 5;
394        assert_eq!(
395            ManifestBinding::decode(&bad_owner).unwrap_err(),
396            ManifestBindingDecodeError::UnknownOwnerKind(5)
397        );
398    }
399
400    #[test]
401    fn invalid_facet_tokens_are_rejected() {
402        for token in ["", "Content", "-bad", "bad-", "with space"] {
403            assert!(
404                ManifestFacet::parse(token).is_err(),
405                "accepted facet token {token:?}"
406            );
407        }
408    }
409}