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