Skip to main content

vole_document/field/
manifest.rs

1//! The field manifest: a small, canonical, content-addressed root (Phase 11).
2//!
3//! A [`FieldRoot`] ties together the exact archival authority (the serialized
4//! `.voldoc` descriptor), the procedural seed DAG root, and the hierarchical
5//! observation index root. It is stored as one blob in the seed store and its id
6//! is the handle a caller uses to open the field.
7
8use crate::error::{Error, Result};
9use crate::integrity::to_hex;
10use crate::store::{Id, NodeId};
11
12/// Domain-separation prefix for a field root id.
13pub const FIELD_ROOT_DOMAIN: &[u8] = b"VOLE:VFIELD:v1";
14/// Canonical field-manifest format version.
15pub const FIELD_FORMAT_VERSION: u8 = 1;
16/// Magic bytes at the head of a canonical manifest.
17pub const FIELD_MAGIC: &[u8; 8] = b"VOLDFLD1";
18
19/// All-zero id used for an absent optional root (e.g. no index).
20pub const ABSENT_ROOT: [u8; 32] = [0u8; 32];
21
22/// A content-addressed handle to a field manifest.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
24pub struct FieldId([u8; 32]);
25
26impl FieldId {
27    /// Wrap 32 raw digest bytes.
28    pub const fn from_bytes(b: [u8; 32]) -> Self {
29        FieldId(b)
30    }
31    /// Raw digest bytes.
32    pub const fn as_bytes(&self) -> &[u8; 32] {
33        &self.0
34    }
35    /// Content id of canonical manifest bytes.
36    pub fn of_manifest(canonical: &[u8]) -> Self {
37        let mut h = blake3::Hasher::new();
38        h.update(FIELD_ROOT_DOMAIN);
39        h.update(canonical);
40        FieldId(*h.finalize().as_bytes())
41    }
42    /// Lower-case hex.
43    pub fn to_hex(&self) -> String {
44        to_hex(&self.0)
45    }
46    /// Parse 64 hex chars.
47    pub fn from_hex(s: &str) -> Result<Self> {
48        Id::from_hex(s).map(|id| FieldId(*id.as_bytes()))
49    }
50}
51
52impl core::fmt::Display for FieldId {
53    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
54        f.write_str(&self.to_hex())
55    }
56}
57
58/// The field manifest.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct FieldRoot {
61    /// Universe declaration this field was produced under.
62    pub universe_id: [u8; 16],
63    /// SHA-256 of the exact reconstructed source.
64    pub source_sha256: [u8; 32],
65    /// Exact reconstructed source length.
66    pub source_len: u64,
67    /// Content id (`Id`) of the serialized `.voldoc` descriptor blob.
68    pub descriptor_id: [u8; 32],
69    /// Root seed node (exact document closure).
70    pub root_node: NodeId,
71    /// Hierarchical observation index root, or all-zero when absent.
72    pub index_root: [u8; 32],
73    /// Number of seed nodes in the closure of `root_node` at ingest time.
74    pub node_count: u64,
75    /// Number of index nodes.
76    pub index_node_count: u64,
77    /// Human-readable basis/provenance (advisory).
78    pub provenance: String,
79}
80
81impl FieldRoot {
82    /// Canonical encoding (never includes the field id).
83    pub fn encode_canonical(&self) -> Vec<u8> {
84        let mut out = Vec::with_capacity(256);
85        out.extend_from_slice(FIELD_MAGIC);
86        out.push(FIELD_FORMAT_VERSION);
87        out.push(0); // reserved
88        out.extend_from_slice(&self.universe_id);
89        out.extend_from_slice(&self.source_sha256);
90        out.extend_from_slice(&self.source_len.to_le_bytes());
91        out.extend_from_slice(&self.descriptor_id);
92        out.extend_from_slice(self.root_node.as_bytes());
93        out.extend_from_slice(&self.index_root);
94        out.extend_from_slice(&self.node_count.to_le_bytes());
95        out.extend_from_slice(&self.index_node_count.to_le_bytes());
96        let prov = self.provenance.as_bytes();
97        out.extend_from_slice(&(prov.len() as u32).to_le_bytes());
98        out.extend_from_slice(prov);
99        out
100    }
101
102    /// Parse a canonical manifest.
103    pub fn decode_canonical(bytes: &[u8]) -> Result<FieldRoot> {
104        if bytes.len() < 8 || &bytes[0..8] != FIELD_MAGIC {
105            return Err(Error::unsupported_version("field manifest: bad magic"));
106        }
107        // Fixed prefix: magic(8) ver(1) res(1) universe(16) sha(32) len(8)
108        //               descriptor(32) root(32) index(32) nodes(8) inodes(8) = 178
109        const FIXED: usize = 8 + 1 + 1 + 16 + 32 + 8 + 32 + 32 + 32 + 8 + 8;
110        if bytes.len() < FIXED + 4 {
111            return Err(Error::usage("truncated field manifest"));
112        }
113        if bytes[8] != FIELD_FORMAT_VERSION {
114            return Err(Error::unsupported_version(format!(
115                "field manifest version {} is not supported",
116                bytes[8]
117            )));
118        }
119        let mut at = 10;
120        let mut universe_id = [0u8; 16];
121        universe_id.copy_from_slice(&bytes[at..at + 16]);
122        at += 16;
123        let mut source_sha256 = [0u8; 32];
124        source_sha256.copy_from_slice(&bytes[at..at + 32]);
125        at += 32;
126        let source_len = u64::from_le_bytes(bytes[at..at + 8].try_into().unwrap());
127        at += 8;
128        let mut descriptor_id = [0u8; 32];
129        descriptor_id.copy_from_slice(&bytes[at..at + 32]);
130        at += 32;
131        let mut root = [0u8; 32];
132        root.copy_from_slice(&bytes[at..at + 32]);
133        at += 32;
134        let mut index_root = [0u8; 32];
135        index_root.copy_from_slice(&bytes[at..at + 32]);
136        at += 32;
137        let node_count = u64::from_le_bytes(bytes[at..at + 8].try_into().unwrap());
138        at += 8;
139        let index_node_count = u64::from_le_bytes(bytes[at..at + 8].try_into().unwrap());
140        at += 8;
141        let prov_len = u32::from_le_bytes(bytes[at..at + 4].try_into().unwrap()) as usize;
142        at += 4;
143        let end = at
144            .checked_add(prov_len)
145            .ok_or_else(|| Error::usage("field manifest provenance overflow"))?;
146        if end != bytes.len() {
147            return Err(Error::usage(
148                "field manifest provenance length does not match the buffer",
149            ));
150        }
151        let provenance = core::str::from_utf8(&bytes[at..end])
152            .map_err(|_| Error::usage("field manifest provenance is not UTF-8"))?
153            .to_string();
154        Ok(FieldRoot {
155            universe_id,
156            source_sha256,
157            source_len,
158            descriptor_id,
159            root_node: NodeId::from_bytes(root),
160            index_root,
161            node_count,
162            index_node_count,
163            provenance,
164        })
165    }
166
167    /// Content id of this manifest.
168    pub fn content_id(&self) -> FieldId {
169        FieldId::of_manifest(&self.encode_canonical())
170    }
171
172    /// Whether the manifest declares an index.
173    pub fn has_index(&self) -> bool {
174        self.index_root != ABSENT_ROOT
175    }
176}
177
178/// Read an integer `key=<n>;` token from a field manifest's provenance string.
179///
180/// Ingest records representation facts such as `id_shared=<n>;res_shared=<n>;`
181/// (Phase 12.8) so an observation can report them without a store-wide scan.
182/// Returns `0` for a manifest that predates the token.
183pub fn provenance_counter(provenance: &str, key: &str) -> u64 {
184    provenance
185        .split(';')
186        .find_map(|token| token.strip_prefix(key).and_then(|v| v.strip_prefix('=')))
187        .and_then(|v| v.parse().ok())
188        .unwrap_or(0)
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use crate::integrity::sha256;
195
196    fn sample() -> FieldRoot {
197        FieldRoot {
198            universe_id: [3u8; 16],
199            source_sha256: sha256(b"source"),
200            source_len: 6,
201            descriptor_id: [4u8; 32],
202            root_node: NodeId::from_bytes([5u8; 32]),
203            index_root: ABSENT_ROOT,
204            node_count: 12,
205            index_node_count: 0,
206            provenance: "pdf:test".into(),
207        }
208    }
209
210    #[test]
211    fn manifest_roundtrips_and_ids() {
212        let m = sample();
213        let enc = m.encode_canonical();
214        let back = FieldRoot::decode_canonical(&enc).unwrap();
215        assert_eq!(m, back);
216        assert_eq!(m.content_id(), back.content_id());
217        assert_eq!(m.encode_canonical(), enc);
218        assert!(!m.has_index());
219    }
220
221    #[test]
222    fn bad_magic_and_version_fail_closed() {
223        let mut enc = sample().encode_canonical();
224        enc[0] = b'X';
225        assert!(FieldRoot::decode_canonical(&enc).is_err());
226        let mut enc2 = sample().encode_canonical();
227        enc2[8] = 0x7F;
228        assert!(FieldRoot::decode_canonical(&enc2).is_err());
229    }
230
231    #[test]
232    fn truncation_and_trailing_fail_closed() {
233        let enc = sample().encode_canonical();
234        assert!(FieldRoot::decode_canonical(&enc[..enc.len() - 1]).is_err());
235        let mut longer = enc.clone();
236        longer.push(0);
237        assert!(FieldRoot::decode_canonical(&longer).is_err());
238    }
239}