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