Skip to main content

vole_document/container/
descriptor.rs

1//! High-level `.voldoc` descriptor: serialize and parse.
2//!
3//! A descriptor binds a source-format class, a universe declaration, raw byte
4//! objects, a reconstruction program, and a whole-source integrity manifest
5//! into one framed container. Parsing is structural and bounded; it does not
6//! materialize bytes. Materialization is a separate, explicit step.
7
8use crate::EXACTNESS_PROFILE_EXACT_BYTES;
9use crate::accounting::CostBreakdown;
10use crate::container::directory::{
11    DirectoryEntry, RecordSite, SEEK_DIRECTORY_ALL_SECTIONS, SeekDirectory,
12};
13use crate::container::header::{HEADER_LEN, Header, MAGIC};
14use crate::container::observation::ObservationIndex;
15use crate::container::record::{FLAG_OPTIONAL, RECORD_OVERHEAD, RecordReader, RecordTag};
16use crate::dra::Program;
17use crate::entropy::codec::EntropyChannelDescriptor;
18use crate::entropy::model::EntropyModel;
19use crate::error::{Error, Result};
20use crate::integrity::sha256;
21use crate::limits::Limits;
22use crate::store::Id;
23
24/// Payload length of an `EXTERNAL_REF` record: `[u8; 32 id][u64 LE len]`.
25pub const EXTERNAL_REF_PAYLOAD_LEN: usize = 40;
26
27/// The current reconstruction universe declaration.
28///
29/// Changing any opcode, coder, limit semantic, or adapter meaning requires a
30/// new universe string. The `universe_id` in the header is the first 16 bytes
31/// of SHA-256 over this string.
32///
33/// Phase 7 adds an optional `OBSERVATION_INDEX` record; Phase 8 adds an optional
34/// `DIRECTORY` record; Phase 9 adds the store-backed object form (`EXTERNAL_REF`
35/// records, mandatory [`crate::container::header::FEATURE_EXTERNAL_OBJECTS`]) and
36/// appends `+external-objects-v1`. The DRA graph stays at `dra-8`, the
37/// `FORMAT_MINOR` does not move, and the exactness semantics are unchanged: a
38/// decoder that ignores the optional records still fully materializes, while one
39/// without the `store` feature fails closed on a store-backed descriptor.
40pub const UNIVERSE: &str = "vole-document;universe;phase9;exact-bytes;dra-8;opaque+entropy+pdf+channels+offsets+packed+packed-channels+deflate-replay-preflate-0.7.6-experimental+observation-index-v1+seek-directory-v1+external-objects-v1";
41
42/// First 16 bytes of SHA-256 over a universe declaration string.
43pub fn universe_id_from_str(universe: &str) -> [u8; 16] {
44    let full = sha256(universe.as_bytes());
45    let mut id = [0u8; 16];
46    id.copy_from_slice(&full[0..16]);
47    id
48}
49
50/// Source of one object-table entry.
51///
52/// A descriptor's object table is a single ordered sequence; each entry is
53/// either inline bytes or a reference to a content-addressed store object, in
54/// object-table order, so the DRA's `object_id` continues to index it unchanged.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum ObjectSource {
57    /// Bytes carried in an `OBJECT` (0x10) record.
58    Inline(Vec<u8>),
59    /// Bytes held by an [`crate::store::ObjectStore`] under `id`; `len` is the
60    /// exact byte length.
61    External { id: Id, len: u64 },
62}
63
64impl ObjectSource {
65    /// Length available to the coverage certificate WITHOUT resolving.
66    pub fn len(&self) -> u64 {
67        match self {
68            ObjectSource::Inline(b) => b.len() as u64,
69            ObjectSource::External { len, .. } => *len,
70        }
71    }
72
73    /// Whether this entry contributes zero bytes.
74    pub fn is_empty(&self) -> bool {
75        self.len() == 0
76    }
77
78    /// The inline bytes, if this entry is not an external reference.
79    pub fn as_inline(&self) -> Option<&[u8]> {
80        match self {
81            ObjectSource::Inline(b) => Some(b),
82            ObjectSource::External { .. } => None,
83        }
84    }
85}
86
87/// The in-memory model of a `.voldoc` descriptor.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct Descriptor {
90    /// Universe declaration string.
91    pub universe: String,
92    /// Source-format class selector.
93    pub source_format: u8,
94    /// Human-readable basis for the format decision (provenance, not trust).
95    pub format_basis: String,
96    /// Canonical entropy models referenced by channels.
97    pub models: Vec<EntropyModel>,
98    /// Typed entropy channels referenced by the program.
99    pub channels: Vec<EntropyChannelDescriptor>,
100    /// Raw byte objects referenced by the program.
101    pub objects: Vec<ObjectSource>,
102    /// The reconstruction program.
103    pub program: Program,
104    /// Optional advisory observation index (Phase 7.3).
105    ///
106    /// `None` is today's descriptor and fully materializes. When `Some`, the
107    /// record is validated against the program at parse time; it is never
108    /// authority.
109    pub observation_index: Option<ObservationIndex>,
110    /// Whether to emit an optional seek `DIRECTORY` record (Phase 8).
111    ///
112    /// `false` is today's descriptor and produces the exact Phase-7 record
113    /// sequence (the universe string still carries the Phase-8 suffix). When
114    /// `true`, `serialize` writes a two-pass `DIRECTORY` record as the first
115    /// record; a directory requires an observation index to describe.
116    pub seek_directory: bool,
117    /// SHA-256 of the exact reconstructed source.
118    pub source_sha256: [u8; 32],
119    /// Exact reconstructed source length.
120    pub source_len: u64,
121}
122
123/// A parsed descriptor plus its physical cost breakdown.
124#[derive(Debug, Clone)]
125pub struct ParsedDescriptor {
126    /// The descriptor model.
127    pub descriptor: Descriptor,
128    /// Physical byte attribution of the serialized form.
129    pub cost: CostBreakdown,
130    /// Encode the universe identifier that was validated against the header.
131    pub universe_id: [u8; 16],
132}
133
134/// A record payload staged during the first pass of [`Descriptor::serialize`].
135struct PendingRecord {
136    tag: RecordTag,
137    flags: u8,
138    payload: Vec<u8>,
139}
140
141impl PendingRecord {
142    fn new(tag: RecordTag, flags: u8, payload: Vec<u8>) -> Self {
143        PendingRecord {
144            tag,
145            flags,
146            payload,
147        }
148    }
149}
150
151impl Descriptor {
152    /// The header that this descriptor serializes to.
153    pub fn header(&self) -> Header {
154        let mut header = Header::new(
155            universe_id_from_str(&self.universe),
156            self.source_len,
157            EXACTNESS_PROFILE_EXACT_BYTES,
158            self.source_format,
159        );
160        header.mandatory_features = self.required_features();
161        header.optional_features = self.optional_features();
162        header
163    }
164
165    /// Mandatory feature bits implied by this descriptor's contents.
166    ///
167    /// Derived rather than stored, so no constructor can forget to declare a
168    /// feature: a descriptor carrying a `DEFLATE_REPLAY` op always sets
169    /// [`crate::container::header::FEATURE_DEFLATE_REPLAY`] in its header, and a
170    /// build without that feature rejects it at header validation.
171    pub fn required_features(&self) -> u32 {
172        let mut bits = 0u32;
173        for op in &self.program.ops {
174            if matches!(op, crate::dra::Op::DeflateReplay { .. }) {
175                bits |= crate::container::header::FEATURE_DEFLATE_REPLAY;
176            }
177        }
178        if self
179            .objects
180            .iter()
181            .any(|o| matches!(o, ObjectSource::External { .. }))
182        {
183            bits |= crate::container::header::FEATURE_EXTERNAL_OBJECTS;
184        }
185        bits
186    }
187
188    /// Optional feature bits implied by this descriptor's contents.
189    ///
190    /// Optional bits are ignorable: a decoder that does not understand them
191    /// still materializes the source exactly. The observation-index bit records
192    /// only that a partial-decode lane is available, and the seek-directory bit
193    /// only that a seek-based lane is available; exactness never requires either.
194    pub fn optional_features(&self) -> u32 {
195        let mut bits = 0u32;
196        if self.observation_index.is_some() {
197            bits |= crate::container::header::FEATURE_OBSERVATION_INDEX;
198        }
199        if self.seek_directory {
200            bits |= crate::container::header::FEATURE_SEEK_DIRECTORY;
201        }
202        bits
203    }
204
205    /// Serialize to a complete `.voldoc` byte sequence plus cost attribution.
206    ///
207    /// When [`Descriptor::seek_directory`] is set this is a two-pass build: every
208    /// record payload is encoded first, the seek directory's length is computed
209    /// from counts alone (so there is no chicken-and-egg), then the records are
210    /// emitted after the directory. A descriptor without a directory emits exactly
211    /// the record sequence and bytes it emitted before this field existed, and
212    /// `cost.directory == 0`.
213    pub fn serialize(&self) -> Result<(Vec<u8>, CostBreakdown)> {
214        if self.seek_directory && self.observation_index.is_none() {
215            return Err(Error::invalid_container(
216                "a seek directory requires an observation index",
217            ));
218        }
219
220        let mut cost = CostBreakdown {
221            header: HEADER_LEN as u64,
222            ..Default::default()
223        };
224
225        // ----- Pass 1: encode every record payload; emit no bytes yet. -----
226        let mut pending: Vec<PendingRecord> = Vec::new();
227
228        // UNIVERSE
229        pending.push(PendingRecord::new(
230            RecordTag::Universe,
231            0,
232            self.universe.as_bytes().to_vec(),
233        ));
234        cost.universe = self.universe.len() as u64;
235
236        // FORMAT: [class u8][basis_len u32 LE][basis bytes]
237        let basis = self.format_basis.as_bytes();
238        let basis_len = u32::try_from(basis.len())
239            .map_err(|_| Error::resource_limit("format basis too long"))?;
240        let mut fmt = Vec::with_capacity(5 + basis.len());
241        fmt.push(self.source_format);
242        fmt.extend_from_slice(&basis_len.to_le_bytes());
243        fmt.extend_from_slice(basis);
244        cost.format = fmt.len() as u64;
245        pending.push(PendingRecord::new(RecordTag::Format, 0, fmt));
246
247        // MODELS
248        for model in &self.models {
249            let encoded = model.encode()?;
250            cost.models += encoded.len() as u64;
251            pending.push(PendingRecord::new(RecordTag::Model, 0, encoded));
252        }
253
254        // ENTROPY CHANNELS
255        for channel in &self.channels {
256            let encoded = channel.encode()?;
257            cost.entropy_payload += encoded.len() as u64;
258            pending.push(PendingRecord::new(RecordTag::EntropyChannel, 0, encoded));
259        }
260
261        // OBJECTS: each entry is either an inline OBJECT record or an
262        // EXTERNAL_REF record, in object-table order (position is the id).
263        for obj in &self.objects {
264            match obj {
265                ObjectSource::Inline(bytes) => {
266                    cost.objects += bytes.len() as u64;
267                    pending.push(PendingRecord::new(RecordTag::Object, 0, bytes.clone()));
268                }
269                ObjectSource::External { id, len } => {
270                    let mut payload = Vec::with_capacity(EXTERNAL_REF_PAYLOAD_LEN);
271                    payload.extend_from_slice(id.as_bytes());
272                    payload.extend_from_slice(&len.to_le_bytes());
273                    cost.external_refs += payload.len() as u64;
274                    pending.push(PendingRecord::new(RecordTag::ExternalRef, 0, payload));
275                }
276            }
277        }
278
279        // GRAPH
280        let graph = self.program.encode()?;
281        cost.graph = graph.len() as u64;
282        pending.push(PendingRecord::new(RecordTag::Graph, 0, graph));
283
284        // OBSERVATION_INDEX (optional, advisory). Written with the optional flag
285        // so a decoder that ignores it still fully materializes.
286        let mut index_records: u64 = 0;
287        if let Some(index) = &self.observation_index {
288            let payload = index.encode()?;
289            index_records = 1;
290            cost.index = payload.len() as u64 + RECORD_OVERHEAD as u64;
291            pending.push(PendingRecord::new(
292                RecordTag::ObservationIndex,
293                FLAG_OPTIONAL,
294                payload,
295            ));
296        }
297
298        // INTEGRITY: [sha256 32][source_len u64 LE]
299        let mut integ = Vec::with_capacity(40);
300        integ.extend_from_slice(&self.source_sha256);
301        integ.extend_from_slice(&self.source_len.to_le_bytes());
302        cost.integrity = integ.len() as u64;
303        pending.push(PendingRecord::new(RecordTag::Integrity, 0, integ));
304
305        // ----- Seek directory (optional). Build it before emitting anything, so
306        // the record offsets can account for its own (count-determined) length. -----
307        let mut directory_payload: Option<Vec<u8>> = None;
308        let mut directory_records: u64 = 0;
309        if self.seek_directory {
310            let channel_lengths: Vec<u64> =
311                self.channels.iter().map(|c| c.decoded_length).collect();
312
313            // Locators in file order: the DIRECTORY itself (offset 64), then every
314            // pending record, then the TRAILER. Offsets after the directory are
315            // filled once its record length is known.
316            let mut entries: Vec<DirectoryEntry> = Vec::with_capacity(pending.len() + 2);
317            entries.push(DirectoryEntry {
318                tag: RecordTag::Directory as u8,
319                offset: HEADER_LEN as u64,
320                payload_len: 0,
321            });
322            for rec in &pending {
323                let payload_len = u32::try_from(rec.payload.len())
324                    .map_err(|_| Error::resource_limit("record payload exceeds u32"))?;
325                entries.push(DirectoryEntry {
326                    tag: rec.tag as u8,
327                    offset: 0,
328                    payload_len,
329                });
330            }
331            entries.push(DirectoryEntry {
332                tag: RecordTag::Trailer as u8,
333                offset: 0,
334                payload_len: 20,
335            });
336            let classes = crate::container::directory::class_index(&entries);
337
338            // The provisional encode fixes the directory's own payload length:
339            // offsets are placeholder-valued but fixed-width, so only the counts
340            // determine the length. There is no circular dependency.
341            let mut dir = SeekDirectory {
342                section_flags: SEEK_DIRECTORY_ALL_SECTIONS,
343                entries,
344                classes,
345                channel_lengths,
346            };
347            let dir_payload_len = dir.encode()?.len();
348            dir.entries[0].payload_len = u32::try_from(dir_payload_len)
349                .map_err(|_| Error::resource_limit("seek directory payload exceeds u32"))?;
350
351            let mut off = (HEADER_LEN + RECORD_OVERHEAD + dir_payload_len) as u64;
352            let last = dir.entries.len() - 1;
353            for entry in &mut dir.entries[1..last] {
354                entry.offset = off;
355                off = off
356                    .checked_add(RECORD_OVERHEAD as u64)
357                    .and_then(|v| v.checked_add(u64::from(entry.payload_len)))
358                    .ok_or_else(|| Error::invalid_container("seek directory offset overflow"))?;
359            }
360            dir.entries[last].offset = off;
361
362            let payload = dir.encode()?;
363            debug_assert_eq!(payload.len(), dir_payload_len);
364            cost.directory = dir_payload_len as u64 + RECORD_OVERHEAD as u64;
365            directory_records = 1;
366            directory_payload = Some(payload);
367        }
368
369        // ----- Pass 2: emit. -----
370        let mut out = Vec::new();
371        out.extend_from_slice(&self.header().encode());
372
373        if let Some(payload) = &directory_payload {
374            crate::container::record::write_record(
375                &mut out,
376                RecordTag::Directory as u8,
377                FLAG_OPTIONAL,
378                payload,
379            )?;
380        }
381        for rec in &pending {
382            crate::container::record::write_record(
383                &mut out,
384                rec.tag as u8,
385                rec.flags,
386                &rec.payload,
387            )?;
388        }
389
390        // TRAILER: [record_count u32][payload_bytes u64][MAGIC 8]
391        // record_count includes the trailer itself and any directory record.
392        let total_records = pending.len() as u64 + directory_records + 1;
393        let total_records =
394            u32::try_from(total_records).map_err(|_| Error::resource_limit("too many records"))?;
395        let payload_bytes = (out.len() - HEADER_LEN) as u64;
396        let mut trailer = Vec::with_capacity(20);
397        trailer.extend_from_slice(&total_records.to_le_bytes());
398        trailer.extend_from_slice(&payload_bytes.to_le_bytes());
399        trailer.extend_from_slice(&MAGIC);
400        crate::container::record::write_record(&mut out, RecordTag::Trailer as u8, 0, &trailer)?;
401        cost.trailer = trailer.len() as u64;
402
403        // Framing overhead for every record after the fixed header. The optional
404        // index and directory records' framing is charged to `cost.index` and
405        // `cost.directory` instead, so subtract their counts here to keep
406        // `total()` exactly the serialized length (every category stays a real
407        // byte).
408        cost.record_framing =
409            RECORD_OVERHEAD as u64 * (u64::from(total_records) - index_records - directory_records);
410
411        debug_assert_eq!(cost.total(), out.len() as u64);
412        Ok((out, cost))
413    }
414
415    /// Parse a complete `.voldoc` byte sequence with structural validation.
416    ///
417    /// This validates framing, universe identity, version, mandatory features,
418    /// record presence, the coverage certificate, and declared length. It does
419    /// **not** materialize or hash the reconstructed source; call
420    /// [`crate::materialize::materialize`] for that.
421    pub fn parse(bytes: &[u8], limits: Limits) -> Result<ParsedDescriptor> {
422        if bytes.len() as u64 > limits.max_input_bytes {
423            return Err(Error::resource_limit(
424                "input exceeds configured input limit",
425            ));
426        }
427        let header = Header::decode(bytes)?;
428        if !header.source_format_supported() {
429            return Err(Error::unsupported_feature(format!(
430                "source format class {} has no adapter in this build",
431                header.source_format
432            )));
433        }
434
435        let mut cost = CostBreakdown {
436            header: HEADER_LEN as u64,
437            ..Default::default()
438        };
439
440        let mut reader = RecordReader::new(bytes, HEADER_LEN, limits);
441        let mut universe: Option<String> = None;
442        let mut format: Option<(u8, String)> = None;
443        let mut models: Vec<EntropyModel> = Vec::new();
444        let mut channels: Vec<EntropyChannelDescriptor> = Vec::new();
445        let mut objects: Vec<ObjectSource> = Vec::new();
446        let mut program: Option<Program> = None;
447        let mut observation_index: Option<ObservationIndex> = None;
448        let mut source_sha256: Option<[u8; 32]> = None;
449        let mut source_len: Option<u64> = None;
450        let mut saw_trailer = false;
451        let mut trailer_record_count: Option<u32> = None;
452        let mut records_seen: u32 = 0;
453        let mut index_records: u64 = 0;
454        let mut directory_records: u64 = 0;
455        let mut seek_directory: Option<SeekDirectory> = None;
456        let mut sites: Vec<RecordSite> = Vec::new();
457
458        while let Some(rec) = reader.next_record()? {
459            records_seen += 1;
460            let site = RecordSite {
461                tag: rec.tag,
462                offset: reader.position() as u64
463                    - (RECORD_OVERHEAD as u64 + rec.payload.len() as u64),
464                payload_len: u32::try_from(rec.payload.len())
465                    .map_err(|_| Error::resource_limit("record payload exceeds u32"))?,
466            };
467            sites.push(site);
468            if saw_trailer {
469                return Err(Error::invalid_container("record found after TRAILER"));
470            }
471            match RecordTag::from_u8(rec.tag) {
472                Some(RecordTag::Universe) => {
473                    if universe.is_some() {
474                        return Err(Error::invalid_container("duplicate UNIVERSE record"));
475                    }
476                    let payload_len = rec.payload.len();
477                    let s = String::from_utf8(rec.payload)
478                        .map_err(|_| Error::invalid_container("universe is not valid UTF-8"))?;
479                    if universe_id_from_str(&s) != header.universe_id {
480                        return Err(Error::invalid_container(
481                            "universe declaration does not match its header identifier",
482                        ));
483                    }
484                    universe = Some(s);
485                    cost.universe = payload_len as u64;
486                }
487                Some(RecordTag::Format) => {
488                    if format.is_some() {
489                        return Err(Error::invalid_container("duplicate FORMAT record"));
490                    }
491                    if rec.payload.len() < 5 {
492                        return Err(Error::invalid_container("truncated FORMAT payload"));
493                    }
494                    let class = rec.payload[0];
495                    let blen = u32::from_le_bytes([
496                        rec.payload[1],
497                        rec.payload[2],
498                        rec.payload[3],
499                        rec.payload[4],
500                    ]);
501                    let blen = blen as usize;
502                    if rec.payload.len() != 5 + blen {
503                        return Err(Error::invalid_container("FORMAT payload length mismatch"));
504                    }
505                    let basis = String::from_utf8(rec.payload[5..].to_vec())
506                        .map_err(|_| Error::invalid_container("format basis is not UTF-8"))?;
507                    if class != header.source_format {
508                        return Err(Error::invalid_container(
509                            "FORMAT class disagrees with header source_format",
510                        ));
511                    }
512                    format = Some((class, basis));
513                    cost.format = rec.payload.len() as u64;
514                }
515                Some(RecordTag::Object) => {
516                    if objects.len() as u32 >= limits.max_object_count {
517                        return Err(Error::resource_limit("object count limit exceeded"));
518                    }
519                    cost.objects += rec.payload.len() as u64;
520                    objects.push(ObjectSource::Inline(rec.payload));
521                }
522                Some(RecordTag::ExternalRef) => {
523                    if objects.len() as u32 >= limits.max_object_count {
524                        return Err(Error::resource_limit("object count limit exceeded"));
525                    }
526                    if rec.payload.len() != EXTERNAL_REF_PAYLOAD_LEN {
527                        return Err(Error::invalid_container(
528                            "EXTERNAL_REF payload must be 40 bytes",
529                        ));
530                    }
531                    let mut id = [0u8; 32];
532                    id.copy_from_slice(&rec.payload[0..32]);
533                    let len = u64::from_le_bytes([
534                        rec.payload[32],
535                        rec.payload[33],
536                        rec.payload[34],
537                        rec.payload[35],
538                        rec.payload[36],
539                        rec.payload[37],
540                        rec.payload[38],
541                        rec.payload[39],
542                    ]);
543                    cost.external_refs += rec.payload.len() as u64;
544                    objects.push(ObjectSource::External {
545                        id: Id::from_bytes(id),
546                        len,
547                    });
548                }
549                Some(RecordTag::Model) => {
550                    if models.len() as u32 >= limits.max_model_count {
551                        return Err(Error::resource_limit("entropy model count limit exceeded"));
552                    }
553                    if rec.payload.len() as u32 > limits.max_entropy_model_bytes {
554                        return Err(Error::resource_limit(format!(
555                            "entropy model payload {} exceeds limit {}",
556                            rec.payload.len(),
557                            limits.max_entropy_model_bytes
558                        )));
559                    }
560                    let model = EntropyModel::decode(&rec.payload)?;
561                    cost.models += rec.payload.len() as u64;
562                    models.push(model);
563                }
564                Some(RecordTag::EntropyChannel) => {
565                    if channels.len() as u32 >= limits.max_channel_count {
566                        return Err(Error::resource_limit(
567                            "entropy channel count limit exceeded",
568                        ));
569                    }
570                    let channel = EntropyChannelDescriptor::decode(&rec.payload, limits)?;
571                    cost.entropy_payload += rec.payload.len() as u64;
572                    channels.push(channel);
573                }
574                Some(RecordTag::Graph) => {
575                    if program.is_some() {
576                        return Err(Error::invalid_container("duplicate GRAPH record"));
577                    }
578                    let p = Program::decode(&rec.payload, limits)?;
579                    cost.graph = rec.payload.len() as u64;
580                    program = Some(p);
581                }
582                Some(RecordTag::ObservationIndex) => {
583                    if observation_index.is_some() {
584                        return Err(Error::invalid_container(
585                            "duplicate OBSERVATION_INDEX record",
586                        ));
587                    }
588                    let idx = ObservationIndex::decode(&rec.payload, limits)?;
589                    cost.index = rec.payload.len() as u64 + RECORD_OVERHEAD as u64;
590                    index_records = 1;
591                    observation_index = Some(idx);
592                }
593                Some(RecordTag::Directory) => {
594                    if seek_directory.is_some() {
595                        return Err(Error::invalid_container("duplicate DIRECTORY record"));
596                    }
597                    if !rec.is_optional() {
598                        return Err(Error::invalid_container(
599                            "DIRECTORY record must carry FLAG_OPTIONAL",
600                        ));
601                    }
602                    if sites.len() != 1 {
603                        return Err(Error::invalid_container(
604                            "DIRECTORY record must be the first record",
605                        ));
606                    }
607                    let dir = SeekDirectory::decode(&rec.payload, limits)?;
608                    cost.directory = rec.payload.len() as u64 + RECORD_OVERHEAD as u64;
609                    directory_records = 1;
610                    seek_directory = Some(dir);
611                }
612                Some(RecordTag::Integrity) => {
613                    if source_sha256.is_some() {
614                        return Err(Error::invalid_container("duplicate INTEGRITY record"));
615                    }
616                    if rec.payload.len() != 40 {
617                        return Err(Error::invalid_container(
618                            "INTEGRITY payload must be 40 bytes",
619                        ));
620                    }
621                    let mut sha = [0u8; 32];
622                    sha.copy_from_slice(&rec.payload[0..32]);
623                    let len = u64::from_le_bytes([
624                        rec.payload[32],
625                        rec.payload[33],
626                        rec.payload[34],
627                        rec.payload[35],
628                        rec.payload[36],
629                        rec.payload[37],
630                        rec.payload[38],
631                        rec.payload[39],
632                    ]);
633                    source_sha256 = Some(sha);
634                    source_len = Some(len);
635                    cost.integrity = rec.payload.len() as u64;
636                }
637                Some(RecordTag::Trailer) => {
638                    if rec.payload.len() != 20 {
639                        return Err(Error::invalid_container("TRAILER payload must be 20 bytes"));
640                    }
641                    if rec.payload[12..20] != MAGIC {
642                        return Err(Error::invalid_container("TRAILER magic mismatch"));
643                    }
644                    trailer_record_count = Some(u32::from_le_bytes([
645                        rec.payload[0],
646                        rec.payload[1],
647                        rec.payload[2],
648                        rec.payload[3],
649                    ]));
650                    cost.trailer = rec.payload.len() as u64;
651                    saw_trailer = true;
652                }
653                // Phase 2+ mandatory records have no meaning in this universe.
654                Some(RecordTag::Residual) | Some(RecordTag::Checkpoint) => {
655                    if rec.is_optional() {
656                        // Explicitly optional and unknown to this universe: skip.
657                    } else {
658                        return Err(Error::unsupported_feature(format!(
659                            "record class {} requires a universe this build does not implement",
660                            rec.tag
661                        )));
662                    }
663                }
664                None => {
665                    if rec.is_optional() {
666                        // Forward-compatible optional record: skip.
667                    } else {
668                        return Err(Error::unsupported_feature(format!(
669                            "unknown mandatory record tag {:#04x}",
670                            rec.tag
671                        )));
672                    }
673                }
674            }
675        }
676
677        let universe =
678            universe.ok_or_else(|| Error::invalid_container("missing UNIVERSE record"))?;
679        let (class, basis) =
680            format.ok_or_else(|| Error::invalid_container("missing FORMAT record"))?;
681        let program = program.ok_or_else(|| Error::invalid_container("missing GRAPH record"))?;
682        let source_sha256 =
683            source_sha256.ok_or_else(|| Error::invalid_container("missing INTEGRITY record"))?;
684        let source_len =
685            source_len.ok_or_else(|| Error::invalid_container("missing INTEGRITY record"))?;
686        if !saw_trailer {
687            return Err(Error::invalid_container("missing TRAILER record"));
688        }
689        if let Some(n) = trailer_record_count
690            && n != records_seen
691        {
692            return Err(Error::invalid_container(format!(
693                "TRAILER declares {n} records but {records_seen} were read"
694            )));
695        }
696        if source_len != header.declared_source_len {
697            return Err(Error::integrity_mismatch(format!(
698                "INTEGRITY length {source_len} disagrees with header {}",
699                header.declared_source_len
700            )));
701        }
702
703        // Cross-validate every channel against the model it references. A
704        // channel may not name a missing model, and its declared scale must
705        // agree with that model.
706        for (i, channel) in channels.iter().enumerate() {
707            let model = models.get(channel.model_id as usize).ok_or_else(|| {
708                Error::invalid_model(format!(
709                    "entropy channel {i} references missing model {}",
710                    channel.model_id
711                ))
712            })?;
713            if channel.scale_bits != model.scale_bits {
714                return Err(Error::invalid_model(format!(
715                    "entropy channel {i} scale_bits {} disagrees with model {} scale_bits {}",
716                    channel.scale_bits, channel.model_id, model.scale_bits
717                )));
718            }
719        }
720
721        // Coverage certificate: every source byte has exactly one authority and
722        // the program's predicted length equals the declared length.
723        let object_lens: Vec<u64> = objects.iter().map(|o| o.len()).collect();
724        let channel_lens: Vec<u64> = channels.iter().map(|c| c.decoded_length).collect();
725        let (predicted, coverage) = program.analyze(&object_lens, &channel_lens, limits)?;
726        if predicted != source_len {
727            return Err(Error::coverage_violation(format!(
728                "reconstruction program predicts {predicted} bytes but {source_len} were declared"
729            )));
730        }
731        coverage.validate(source_len)?;
732
733        // The observation index is advisory: re-derive every claim from the
734        // program and reject any contradiction. It is never authority.
735        if let Some(index) = &observation_index {
736            index.validate(&program, &object_lens, &channel_lens, limits)?;
737        }
738
739        // The seek directory is advisory too: it must be consistent with the
740        // actual record framing, but the framing and the program remain the
741        // authority. A malformed or inconsistent directory is rejected rather
742        // than trusted.
743        if let Some(dir) = &seek_directory {
744            dir.validate(&sites, bytes.len() as u64, limits)?;
745        }
746
747        // As in `serialize`, the optional index and directory records' framing is
748        // charged to `cost.index`/`cost.directory`, so exclude their counts from
749        // the framing total.
750        cost.record_framing =
751            RECORD_OVERHEAD as u64 * (records_seen as u64 - index_records - directory_records);
752
753        Ok(ParsedDescriptor {
754            descriptor: Descriptor {
755                universe,
756                source_format: class,
757                format_basis: basis,
758                models,
759                channels,
760                objects,
761                program,
762                observation_index,
763                seek_directory: seek_directory.is_some(),
764                source_sha256,
765                source_len,
766            },
767            cost,
768            universe_id: header.universe_id,
769        })
770    }
771}
772
773#[cfg(test)]
774mod tests {
775    use super::*;
776    use crate::SOURCE_FORMAT_OPAQUE;
777    use crate::dra::Op;
778    use crate::integrity::sha256;
779
780    fn sample(source: &[u8]) -> Descriptor {
781        Descriptor {
782            universe: UNIVERSE.to_string(),
783            source_format: SOURCE_FORMAT_OPAQUE,
784            format_basis: "opaque:test".to_string(),
785            models: vec![],
786            channels: vec![],
787            objects: vec![ObjectSource::Inline(source.to_vec())],
788            program: Program::new(vec![Op::EmitObject { object_id: 0 }]),
789            observation_index: None,
790            seek_directory: false,
791            source_sha256: sha256(source),
792            source_len: source.len() as u64,
793        }
794    }
795
796    fn channel(model_id: u32, scale_bits: u8, decoded_length: u64) -> EntropyChannelDescriptor {
797        EntropyChannelDescriptor {
798            coder: crate::entropy::codec::CODER_ORDER0_BYTE_RANS,
799            coder_version: crate::entropy::codec::CODER_VERSION_1,
800            scale_bits,
801            lane_count: 1,
802            model_id,
803            symbol_count: decoded_length,
804            decoded_length,
805            initial_state: 1,
806            payload: vec![0u8; 4],
807        }
808    }
809
810    #[test]
811    fn model_and_channel_roundtrip() {
812        let payload = b"channel bytes";
813        let mut d = sample(payload);
814        d.models = vec![EntropyModel::uniform(8).unwrap()];
815        d.channels = vec![channel(0, 8, payload.len() as u64)];
816        d.program = Program::new(vec![Op::DecodeChannel { channel_id: 0 }]);
817        let (bytes, cost) = d.serialize().unwrap();
818        assert_eq!(cost.total(), bytes.len() as u64);
819        assert!(cost.models > 0);
820        assert!(cost.entropy_payload > 0);
821        let parsed = Descriptor::parse(&bytes, Limits::DEFAULT).unwrap();
822        assert_eq!(parsed.descriptor, d);
823        assert_eq!(parsed.cost.total(), bytes.len() as u64);
824    }
825
826    #[test]
827    fn channel_with_missing_model_rejected() {
828        let mut d = sample(b"abc");
829        d.program = Program::new(vec![Op::DecodeChannel { channel_id: 0 }]);
830        d.channels = vec![channel(3, 8, 3)];
831        let (bytes, _) = d.serialize().unwrap();
832        let e = Descriptor::parse(&bytes, Limits::DEFAULT).unwrap_err();
833        assert_eq!(e.class(), crate::ErrorClass::InvalidModel);
834    }
835
836    #[test]
837    fn channel_scale_mismatch_rejected() {
838        let mut d = sample(b"abc");
839        d.models = vec![EntropyModel::uniform(8).unwrap()];
840        d.program = Program::new(vec![Op::DecodeChannel { channel_id: 0 }]);
841        d.channels = vec![channel(0, 12, 3)];
842        let (bytes, _) = d.serialize().unwrap();
843        let e = Descriptor::parse(&bytes, Limits::DEFAULT).unwrap_err();
844        assert_eq!(e.class(), crate::ErrorClass::InvalidModel);
845    }
846
847    #[test]
848    fn serialize_parse_roundtrip() {
849        let d = sample(b"hello, exact world");
850        let (bytes, cost) = d.serialize().unwrap();
851        assert_eq!(cost.total(), bytes.len() as u64);
852        let parsed = Descriptor::parse(&bytes, Limits::DEFAULT).unwrap();
853        assert_eq!(parsed.descriptor, d);
854        assert_eq!(parsed.cost.total(), bytes.len() as u64);
855    }
856
857    #[test]
858    fn trailing_bytes_after_trailer_rejected() {
859        let d = sample(b"abc");
860        let (mut bytes, _) = d.serialize().unwrap();
861        bytes.push(0);
862        let e = Descriptor::parse(&bytes, Limits::DEFAULT).unwrap_err();
863        assert_eq!(e.class(), crate::ErrorClass::InvalidContainer);
864    }
865
866    #[test]
867    fn declared_length_mismatch_rejected() {
868        // Build a descriptor whose declared length disagrees with the program.
869        let mut d = sample(b"abcdef");
870        d.source_len = 5;
871        let (bytes, _) = d.serialize().unwrap();
872        let e = Descriptor::parse(&bytes, Limits::DEFAULT).unwrap_err();
873        assert_eq!(e.class(), crate::ErrorClass::CoverageViolation);
874    }
875
876    fn replay_program() -> Program {
877        Program::new(vec![Op::DeflateReplay {
878            replay_codec: crate::dra::op::REPLAY_DEFLATE_PREFLATE_0_7_6,
879            source_kind: crate::dra::op::DEFLATE_SOURCE_OBJECT,
880            source_id: 0,
881            corrections_object: 0,
882            declared_output_len: 3,
883        }])
884    }
885
886    #[test]
887    fn plain_descriptor_declares_no_mandatory_features() {
888        assert_eq!(sample(b"abc").required_features(), 0);
889    }
890
891    #[cfg(feature = "deflate-replay")]
892    #[test]
893    fn replay_op_declares_mandatory_feature() {
894        let mut d = sample(b"abc");
895        d.program = replay_program();
896        assert_eq!(
897            d.required_features(),
898            crate::container::header::FEATURE_DEFLATE_REPLAY
899        );
900        // The declared bit survives a serialize/parse cycle.
901        let (bytes, _) = d.serialize().unwrap();
902        let parsed = Descriptor::parse(&bytes, Limits::DEFAULT).unwrap();
903        assert_eq!(
904            parsed.descriptor.required_features(),
905            crate::container::header::FEATURE_DEFLATE_REPLAY
906        );
907    }
908
909    #[cfg(not(feature = "deflate-replay"))]
910    #[test]
911    fn replay_descriptor_fails_closed_without_feature() {
912        let mut d = sample(b"abc");
913        d.program = replay_program();
914        let (bytes, _) = d.serialize().unwrap();
915        let e = Descriptor::parse(&bytes, Limits::DEFAULT).unwrap_err();
916        assert_eq!(e.class(), crate::ErrorClass::UnsupportedFeature);
917    }
918
919    use crate::container::observation::{
920        DEP_NONE, DEP_OBJECT, ObservationDigest, ObservationSelector, OpEntry, SECTION_DIGESTS,
921        SECTION_OP_TABLE, SECTION_PDF_SELECTORS, SELECTOR_OBJECT,
922    };
923
924    /// A two-op descriptor (`abc` literal + `de` inline) carrying a fully
925    /// consistent observation index over its five output bytes.
926    fn indexed_descriptor() -> Descriptor {
927        let mut d = sample(b"");
928        d.objects = vec![ObjectSource::Inline(b"abc".to_vec())];
929        d.program = Program::new(vec![
930            Op::EmitObject { object_id: 0 },
931            Op::Inline {
932                bytes: b"de".to_vec(),
933            },
934        ]);
935        d.source_sha256 = sha256(b"abcde");
936        d.source_len = 5;
937        d.observation_index = Some(ObservationIndex {
938            section_flags: SECTION_OP_TABLE | SECTION_PDF_SELECTORS | SECTION_DIGESTS,
939            ops: vec![
940                OpEntry {
941                    out_len: 3,
942                    dep_kind: DEP_OBJECT,
943                    dep_id: 0,
944                },
945                OpEntry {
946                    out_len: 2,
947                    dep_kind: DEP_NONE,
948                    dep_id: 0,
949                },
950            ],
951            selectors: vec![ObservationSelector {
952                kind: SELECTOR_OBJECT,
953                number: 1,
954                generation: 0,
955                out_off: 0,
956                out_len: 3,
957            }],
958            digests: vec![ObservationDigest {
959                out_off: 3,
960                out_len: 2,
961                sha256: [7u8; 32],
962            }],
963        });
964        d
965    }
966
967    #[test]
968    fn observation_index_roundtrip_and_charge() {
969        let d = indexed_descriptor();
970        assert_eq!(
971            d.optional_features(),
972            crate::container::header::FEATURE_OBSERVATION_INDEX
973        );
974        let (bytes, cost) = d.serialize().unwrap();
975        assert_eq!(
976            cost.total(),
977            bytes.len() as u64,
978            "cost must be the byte length"
979        );
980        assert!(
981            cost.index > 0,
982            "the index payload + framing must be charged"
983        );
984
985        let parsed = Descriptor::parse(&bytes, Limits::DEFAULT).unwrap();
986        assert_eq!(parsed.descriptor, d);
987        assert_eq!(parsed.cost.total(), bytes.len() as u64);
988        assert!(parsed.cost.index > 0);
989
990        // Absent: no record, no charge, and the descriptor still round-trips.
991        let plain = sample(b"nope");
992        let (pbytes, pcost) = plain.serialize().unwrap();
993        assert_eq!(pcost.total(), pbytes.len() as u64);
994        assert_eq!(pcost.index, 0);
995        assert_eq!(plain.optional_features(), 0);
996        let reparsed = Descriptor::parse(&pbytes, Limits::DEFAULT).unwrap();
997        assert!(reparsed.descriptor.observation_index.is_none());
998    }
999
1000    #[test]
1001    fn inconsistent_observation_index_is_rejected_on_parse() {
1002        // A CRC-valid but self-contradictory index must be rejected, not trusted.
1003        let mut d = indexed_descriptor();
1004        d.observation_index.as_mut().unwrap().ops[0].out_len = 9;
1005        let (bytes, _) = d.serialize().unwrap();
1006        assert_eq!(
1007            Descriptor::parse(&bytes, Limits::DEFAULT)
1008                .unwrap_err()
1009                .class(),
1010            crate::ErrorClass::CoverageViolation
1011        );
1012
1013        let mut d = indexed_descriptor();
1014        let idx = d.observation_index.as_mut().unwrap();
1015        idx.ops[0].dep_kind = DEP_OBJECT;
1016        idx.ops[0].dep_id = 99;
1017        let (bytes, _) = d.serialize().unwrap();
1018        assert_eq!(
1019            Descriptor::parse(&bytes, Limits::DEFAULT)
1020                .unwrap_err()
1021                .class(),
1022            crate::ErrorClass::CoverageViolation
1023        );
1024
1025        let mut d = indexed_descriptor();
1026        let sel = &mut d.observation_index.as_mut().unwrap().selectors[0];
1027        sel.out_off = 4;
1028        sel.out_len = 9;
1029        let (bytes, _) = d.serialize().unwrap();
1030        assert_eq!(
1031            Descriptor::parse(&bytes, Limits::DEFAULT)
1032                .unwrap_err()
1033                .class(),
1034            crate::ErrorClass::CoverageViolation
1035        );
1036    }
1037
1038    // -----------------------------------------------------------------------
1039    // Phase 8 — the optional seek `DIRECTORY` record.
1040    // -----------------------------------------------------------------------
1041
1042    /// A seekable descriptor: the two-op indexed descriptor plus the directory
1043    /// switch.
1044    fn seekable_descriptor() -> Descriptor {
1045        let mut d = indexed_descriptor();
1046        d.seek_directory = true;
1047        d
1048    }
1049
1050    /// Rebuild a descriptor's bytes after mutating its decoded directory payload,
1051    /// recomputing the record CRC. The directory payload length is unchanged by
1052    /// offset/count mutations, so the trailer stays consistent. The result is a
1053    /// *CRC-valid* but potentially lying directory.
1054    fn rebuild_with_directory(bytes: &[u8], mut mutate: impl FnMut(&mut SeekDirectory)) -> Vec<u8> {
1055        use crate::container::record::{RecordReader, write_record};
1056        let header = &bytes[0..HEADER_LEN];
1057        let mut reader = RecordReader::new(bytes, HEADER_LEN, Limits::DEFAULT);
1058        let mut records = Vec::new();
1059        while let Some(r) = reader.next_record().unwrap() {
1060            records.push(r);
1061        }
1062        let mut out = header.to_vec();
1063        for r in &records {
1064            if r.tag == RecordTag::Directory as u8 {
1065                let mut dir = SeekDirectory::decode(&r.payload, Limits::DEFAULT).unwrap();
1066                mutate(&mut dir);
1067                write_record(&mut out, r.tag, r.flags, &dir.encode().unwrap()).unwrap();
1068            } else {
1069                write_record(&mut out, r.tag, r.flags, &r.payload).unwrap();
1070            }
1071        }
1072        out
1073    }
1074
1075    #[test]
1076    fn seek_directory_roundtrips_materializes_and_charges() {
1077        let d = seekable_descriptor();
1078        assert_eq!(
1079            d.optional_features(),
1080            crate::container::header::FEATURE_OBSERVATION_INDEX
1081                | crate::container::header::FEATURE_SEEK_DIRECTORY
1082        );
1083        let (bytes, cost) = d.serialize().unwrap();
1084        assert_eq!(cost.total(), bytes.len() as u64, "cost must be the length");
1085        assert!(
1086            cost.directory > 0,
1087            "the directory payload + framing is charged"
1088        );
1089
1090        let parsed = Descriptor::parse(&bytes, Limits::DEFAULT).unwrap();
1091        assert_eq!(parsed.descriptor, d, "seekable descriptor must round-trip");
1092        assert!(parsed.descriptor.seek_directory);
1093        assert_eq!(parsed.cost.total(), bytes.len() as u64);
1094        assert_eq!(parsed.cost.directory, cost.directory);
1095
1096        // The normal materialize path is unaffected by the advisory directory.
1097        let out = crate::materialize::decode_to_bytes(&bytes, Limits::DEFAULT)
1098            .unwrap()
1099            .0;
1100        assert_eq!(out, b"abcde");
1101    }
1102
1103    #[test]
1104    fn directory_is_the_first_record_and_is_optional() {
1105        let (bytes, _) = seekable_descriptor().serialize().unwrap();
1106        let mut r = RecordReader::new(&bytes, HEADER_LEN, Limits::DEFAULT);
1107        let first = r.next_record().unwrap().unwrap();
1108        assert_eq!(first.tag, RecordTag::Directory as u8);
1109        assert!(
1110            first.is_optional(),
1111            "the directory must carry FLAG_OPTIONAL"
1112        );
1113        // The directory is locatable at the fixed offset after the header.
1114        let mut r = RecordReader::new(&bytes, HEADER_LEN, Limits::DEFAULT);
1115        r.next_record().unwrap().unwrap();
1116        assert_eq!(
1117            r.position(),
1118            HEADER_LEN + RECORD_OVERHEAD + first.payload.len(),
1119            "the next record must begin right after the directory"
1120        );
1121    }
1122
1123    #[test]
1124    fn non_seekable_descriptor_has_no_directory_cost() {
1125        let d = sample(b"no directory here");
1126        let (bytes, cost) = d.serialize().unwrap();
1127        assert_eq!(cost.directory, 0);
1128        assert_eq!(cost.total(), bytes.len() as u64);
1129        assert_eq!(d.optional_features(), 0);
1130        // The record sequence is unchanged: the first record is UNIVERSE, not a
1131        // DIRECTORY.
1132        let mut r = RecordReader::new(&bytes, HEADER_LEN, Limits::DEFAULT);
1133        assert_eq!(
1134            r.next_record().unwrap().unwrap().tag,
1135            RecordTag::Universe as u8
1136        );
1137        let parsed = Descriptor::parse(&bytes, Limits::DEFAULT).unwrap();
1138        assert!(!parsed.descriptor.seek_directory);
1139        assert_eq!(parsed.cost.directory, 0);
1140    }
1141
1142    #[test]
1143    fn seek_directory_without_index_is_rejected() {
1144        let mut d = sample(b"abc");
1145        d.seek_directory = true;
1146        assert_eq!(
1147            d.serialize().unwrap_err().class(),
1148            crate::ErrorClass::InvalidContainer
1149        );
1150    }
1151
1152    #[test]
1153    fn corrupted_directory_payload_is_rejected() {
1154        let (mut bytes, _) = seekable_descriptor().serialize().unwrap();
1155        // Flip a byte inside the directory payload (its first payload byte, the
1156        // version); the record CRC32C must catch it.
1157        bytes[HEADER_LEN + 8] ^= 0x01;
1158        assert_eq!(
1159            Descriptor::parse(&bytes, Limits::DEFAULT)
1160                .unwrap_err()
1161                .class(),
1162            crate::ErrorClass::InvalidContainer
1163        );
1164    }
1165
1166    #[test]
1167    fn lying_directory_is_rejected_on_parse() {
1168        let (bytes, _) = seekable_descriptor().serialize().unwrap();
1169        // Sanity: the honest directory parses.
1170        Descriptor::parse(&bytes, Limits::DEFAULT).unwrap();
1171
1172        // A locator offset that does not match the framing.
1173        let lying = rebuild_with_directory(&bytes, |dir| dir.entries[1].offset += 1);
1174        assert_eq!(
1175            Descriptor::parse(&lying, Limits::DEFAULT)
1176                .unwrap_err()
1177                .class(),
1178            crate::ErrorClass::InvalidContainer
1179        );
1180
1181        // A class count that disagrees with a scan of the locators.
1182        let lying = rebuild_with_directory(&bytes, |dir| dir.classes[0].count += 1);
1183        assert_eq!(
1184            Descriptor::parse(&lying, Limits::DEFAULT)
1185                .unwrap_err()
1186                .class(),
1187            crate::ErrorClass::InvalidContainer
1188        );
1189
1190        // A directory record that does not carry FLAG_OPTIONAL would strand an
1191        // older decoder, so this build rejects it too.
1192        let mut reader = RecordReader::new(&bytes, HEADER_LEN, Limits::DEFAULT);
1193        let mut records = Vec::new();
1194        while let Some(r) = reader.next_record().unwrap() {
1195            records.push(r);
1196        }
1197        let mut out = bytes[0..HEADER_LEN].to_vec();
1198        for r in &records {
1199            let flags = if r.tag == RecordTag::Directory as u8 {
1200                0
1201            } else {
1202                r.flags
1203            };
1204            crate::container::record::write_record(&mut out, r.tag, flags, &r.payload).unwrap();
1205        }
1206        assert_eq!(
1207            Descriptor::parse(&out, Limits::DEFAULT)
1208                .unwrap_err()
1209                .class(),
1210            crate::ErrorClass::InvalidContainer
1211        );
1212    }
1213}