Skip to main content

vole_document/container/
directory.rs

1//! The optional `DIRECTORY` record: a bounded, advisory seek directory.
2//!
3//! The directory is a self-describing, little-endian, versioned payload written
4//! as the **first** record (fixed offset 64) with [`FLAG_OPTIONAL`]. It lets a
5//! seek reader jump to the records a query needs instead of reading the whole
6//! descriptor. It is **advisory, never authority**: every locator is
7//! cross-checked against the record framing, and a decoder that ignores the
8//! record still fully materializes the source exactly.
9//!
10//! ```text
11//! seek_directory_v1 :=
12//!     version:u8 = 1
13//!     section_flags:u8            # bit0 LOCATORS, bit1 CLASS_INDEX, bit2 CHANNEL_LENGTHS
14//!     entry_count:u32 LE          # if bit0
15//!     entry[entry_count]          # tag:u8 | offset:u64 | payload_len:u32
16//!     class_count:u8              # if bit1
17//!     class_entry[class_count]    # tag:u8 | first:u32 | count:u32
18//!     channel_count:u32 LE        # if bit2
19//!     channel_decoded_len[channel_count]:u64 LE
20//! ```
21//!
22//! `LOCATORS` lists every record in file order, including the `DIRECTORY` itself
23//! (entry 0, at offset [`HEADER_LEN`]), so offset contiguity is checkable against
24//! the actual framing. `CLASS_INDEX` gives the `first`/`count` of each repeating
25//! class (`OBJECT`, `ENTROPY_CHANNEL`, `MODEL`) and each singleton (`GRAPH`,
26//! `OBSERVATION_INDEX`, `INTEGRITY`, `TRAILER`) into `entry[]`. `CHANNEL_LENGTHS`
27//! carries each entropy channel's decoded length in table order.
28//!
29//! Unknown version or section flags fail closed. All arithmetic is checked.
30
31use crate::container::header::HEADER_LEN;
32use crate::container::record::{RECORD_OVERHEAD, RecordTag};
33use crate::error::{Error, Result};
34use crate::limits::Limits;
35
36/// Wire version of the seek directory implemented by this build.
37pub const SEEK_DIRECTORY_VERSION: u8 = 1;
38
39/// Section flag: the locator table (bit 0) is present.
40pub const SECTION_LOCATORS: u8 = 0x01;
41/// Section flag: the class index (bit 1) is present.
42pub const SECTION_CLASS_INDEX: u8 = 0x02;
43/// Section flag: the channel decoded-length table (bit 2) is present.
44pub const SECTION_CHANNEL_LENGTHS: u8 = 0x04;
45
46/// Every section flag this version understands. Any other bit fails closed.
47pub const SEEK_DIRECTORY_KNOWN_SECTION_FLAGS: u8 =
48    SECTION_LOCATORS | SECTION_CLASS_INDEX | SECTION_CHANNEL_LENGTHS;
49
50/// The section set emitted by this build's encoder.
51pub const SEEK_DIRECTORY_ALL_SECTIONS: u8 = SEEK_DIRECTORY_KNOWN_SECTION_FLAGS;
52
53/// Encoded size of one locator entry: `tag u8 | offset u64 | payload_len u32`.
54pub const DIRECTORY_ENTRY_LEN: usize = 1 + 8 + 4;
55/// Encoded size of one class entry: `tag u8 | first u32 | count u32`.
56pub const CLASS_ENTRY_LEN: usize = 1 + 4 + 4;
57
58/// The record classes that receive a `CLASS_INDEX` entry, in emission order.
59///
60/// Repeating classes first, then singletons. The `DIRECTORY` tag is deliberately
61/// absent: it is always entry 0 and locatable at a constant offset.
62pub const CLASS_INDEX_TAGS: [RecordTag; 7] = [
63    RecordTag::Model,
64    RecordTag::EntropyChannel,
65    RecordTag::Object,
66    RecordTag::Graph,
67    RecordTag::ObservationIndex,
68    RecordTag::Integrity,
69    RecordTag::Trailer,
70];
71
72/// One record locator: where a record's tag byte sits and its declared payload
73/// length. `offset` is absolute from the start of the descriptor.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub struct DirectoryEntry {
76    /// Raw record tag byte.
77    pub tag: u8,
78    /// Absolute file offset of the record's tag byte.
79    pub offset: u64,
80    /// Declared payload length of the record.
81    pub payload_len: u32,
82}
83
84/// One class index entry: the `first` locator index of `tag` and how many
85/// consecutive locators carry it.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub struct ClassEntry {
88    /// Raw record tag byte this entry indexes.
89    pub tag: u8,
90    /// Index into [`SeekDirectory::entries`] of the first record of this class.
91    pub first: u32,
92    /// Number of records of this class.
93    pub count: u32,
94}
95
96/// A decoded `DIRECTORY` record payload.
97///
98/// Field vectors correspond to the sections named by `section_flags`; vectors for
99/// absent sections are empty.
100#[derive(Debug, Clone, PartialEq, Eq, Default)]
101pub struct SeekDirectory {
102    /// Section flags present in the encoded record.
103    pub section_flags: u8,
104    /// Record locators in file order, when [`SECTION_LOCATORS`] is set.
105    pub entries: Vec<DirectoryEntry>,
106    /// Per-class index, when [`SECTION_CLASS_INDEX`] is set.
107    pub classes: Vec<ClassEntry>,
108    /// Per-`ENTROPY_CHANNEL` decoded length in table order, when
109    /// [`SECTION_CHANNEL_LENGTHS`] is set.
110    pub channel_lengths: Vec<u64>,
111}
112
113/// The on-disk location of one record, used to cross-check a directory against
114/// the actual framing.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub struct RecordSite {
117    /// Raw record tag byte.
118    pub tag: u8,
119    /// Absolute file offset of the record's tag byte.
120    pub offset: u64,
121    /// Declared payload length of the record.
122    pub payload_len: u32,
123}
124
125impl SeekDirectory {
126    /// Encode the payload to canonical bytes.
127    ///
128    /// A section's flag and its vector must agree: a set flag with an empty
129    /// vector is encoded as a count of zero (valid), but a *clear* flag with a
130    /// non-empty vector is an error rather than a silently dropped section.
131    pub fn encode(&self) -> Result<Vec<u8>> {
132        if self.section_flags & !SEEK_DIRECTORY_KNOWN_SECTION_FLAGS != 0 {
133            return Err(Error::invalid_container(
134                "seek directory has unknown section flags",
135            ));
136        }
137        if self.section_flags & SECTION_LOCATORS == 0 && !self.entries.is_empty() {
138            return Err(Error::invalid_container(
139                "seek directory carries locators without the locator section flag",
140            ));
141        }
142        if self.section_flags & SECTION_CLASS_INDEX == 0 && !self.classes.is_empty() {
143            return Err(Error::invalid_container(
144                "seek directory carries classes without the class-index section flag",
145            ));
146        }
147        if self.section_flags & SECTION_CHANNEL_LENGTHS == 0 && !self.channel_lengths.is_empty() {
148            return Err(Error::invalid_container(
149                "seek directory carries channel lengths without the channel-lengths section flag",
150            ));
151        }
152
153        let mut out = Vec::new();
154        out.push(SEEK_DIRECTORY_VERSION);
155        out.push(self.section_flags);
156
157        if self.section_flags & SECTION_LOCATORS != 0 {
158            let count = u32::try_from(self.entries.len())
159                .map_err(|_| Error::resource_limit("seek directory entry_count exceeds u32"))?;
160            out.extend_from_slice(&count.to_le_bytes());
161            for e in &self.entries {
162                out.push(e.tag);
163                out.extend_from_slice(&e.offset.to_le_bytes());
164                out.extend_from_slice(&e.payload_len.to_le_bytes());
165            }
166        }
167        if self.section_flags & SECTION_CLASS_INDEX != 0 {
168            let count = u8::try_from(self.classes.len())
169                .map_err(|_| Error::resource_limit("seek directory class_count exceeds u8"))?;
170            out.push(count);
171            for c in &self.classes {
172                out.push(c.tag);
173                out.extend_from_slice(&c.first.to_le_bytes());
174                out.extend_from_slice(&c.count.to_le_bytes());
175            }
176        }
177        if self.section_flags & SECTION_CHANNEL_LENGTHS != 0 {
178            let count = u32::try_from(self.channel_lengths.len())
179                .map_err(|_| Error::resource_limit("seek directory channel_count exceeds u32"))?;
180            out.extend_from_slice(&count.to_le_bytes());
181            for &len in &self.channel_lengths {
182                out.extend_from_slice(&len.to_le_bytes());
183            }
184        }
185        Ok(out)
186    }
187
188    /// Decode and structurally bound a payload.
189    ///
190    /// This checks the size bound, the version, the section flags, and that every
191    /// section fits its declared count. It does **not** check agreement with the
192    /// actual record framing; call [`SeekDirectory::validate`] for that.
193    pub fn decode(bytes: &[u8], limits: Limits) -> Result<SeekDirectory> {
194        if bytes.len() as u64 > u64::from(limits.max_directory_bytes) {
195            return Err(Error::resource_limit(format!(
196                "seek directory payload {} exceeds limit {}",
197                bytes.len(),
198                limits.max_directory_bytes
199            )));
200        }
201        if bytes.len() < 2 {
202            return Err(Error::invalid_container("truncated SEEK_DIRECTORY header"));
203        }
204        let version = bytes[0];
205        if version != SEEK_DIRECTORY_VERSION {
206            return Err(Error::unsupported_version(format!(
207                "seek directory version {version} is not supported"
208            )));
209        }
210        let section_flags = bytes[1];
211        if section_flags & !SEEK_DIRECTORY_KNOWN_SECTION_FLAGS != 0 {
212            return Err(Error::invalid_container(
213                "SEEK_DIRECTORY has unknown section flags",
214            ));
215        }
216
217        let mut p = 2usize;
218
219        let mut entries = Vec::new();
220        if section_flags & SECTION_LOCATORS != 0 {
221            let count = read_u32(bytes, &mut p)?;
222            if count > limits.max_record_count {
223                return Err(Error::resource_limit(format!(
224                    "seek directory entry_count {count} exceeds limit {}",
225                    limits.max_record_count
226                )));
227            }
228            let count = count as usize;
229            require(bytes, p, count, DIRECTORY_ENTRY_LEN)?;
230            entries.reserve(count);
231            for _ in 0..count {
232                entries.push(DirectoryEntry {
233                    tag: read_u8(bytes, &mut p)?,
234                    offset: read_u64(bytes, &mut p)?,
235                    payload_len: read_u32(bytes, &mut p)?,
236                });
237            }
238        }
239
240        let mut classes = Vec::new();
241        if section_flags & SECTION_CLASS_INDEX != 0 {
242            let count = read_u8(bytes, &mut p)? as usize;
243            require(bytes, p, count, CLASS_ENTRY_LEN)?;
244            classes.reserve(count);
245            for _ in 0..count {
246                classes.push(ClassEntry {
247                    tag: read_u8(bytes, &mut p)?,
248                    first: read_u32(bytes, &mut p)?,
249                    count: read_u32(bytes, &mut p)?,
250                });
251            }
252        }
253
254        let mut channel_lengths = Vec::new();
255        if section_flags & SECTION_CHANNEL_LENGTHS != 0 {
256            let count = read_u32(bytes, &mut p)?;
257            if count > limits.max_channel_count {
258                return Err(Error::resource_limit(format!(
259                    "seek directory channel_count {count} exceeds limit {}",
260                    limits.max_channel_count
261                )));
262            }
263            let count = count as usize;
264            require(bytes, p, count, 8)?;
265            channel_lengths.reserve(count);
266            for _ in 0..count {
267                channel_lengths.push(read_u64(bytes, &mut p)?);
268            }
269        }
270
271        if p != bytes.len() {
272            return Err(Error::invalid_container(format!(
273                "SEEK_DIRECTORY has {} trailing bytes",
274                bytes.len() - p
275            )));
276        }
277
278        Ok(SeekDirectory {
279            section_flags,
280            entries,
281            classes,
282            channel_lengths,
283        })
284    }
285
286    /// Validate the directory against the actual record framing.
287    ///
288    /// Runs the internal structural checks and then requires that [`Self::entries`]
289    /// agrees byte-for-byte with `records` (tag, offset, payload length). This is
290    /// the advisory cross-check: the directory is a claim, and the framing is the
291    /// authority.
292    pub fn validate(&self, records: &[RecordSite], file_len: u64, limits: Limits) -> Result<()> {
293        self.validate_structural(file_len, limits)?;
294
295        if self.section_flags & SECTION_LOCATORS == 0 {
296            return Err(Error::invalid_container(
297                "seek directory omits the locator section",
298            ));
299        }
300        if self.entries.len() != records.len() {
301            return Err(Error::invalid_container(format!(
302                "seek directory lists {} records but {} were framed",
303                self.entries.len(),
304                records.len()
305            )));
306        }
307        for (i, (entry, site)) in self.entries.iter().zip(records).enumerate() {
308            if entry.tag != site.tag
309                || entry.offset != site.offset
310                || entry.payload_len != site.payload_len
311            {
312                return Err(Error::invalid_container(format!(
313                    "seek directory locator {i} disagrees with the record framing"
314                )));
315            }
316        }
317        Ok(())
318    }
319
320    /// Validate the directory's internal consistency and its coverage of the file.
321    ///
322    /// This needs only the directory and the file length, so a seek reader can run
323    /// it without reading the whole record set.
324    pub fn validate_structural(&self, file_len: u64, limits: Limits) -> Result<()> {
325        if self.section_flags & !SEEK_DIRECTORY_KNOWN_SECTION_FLAGS != 0 {
326            return Err(Error::invalid_container(
327                "seek directory has unknown section flags",
328            ));
329        }
330        if self.entries.len() > limits.max_record_count as usize {
331            return Err(Error::resource_limit(
332                "seek directory entry_count exceeds the record-count limit",
333            ));
334        }
335        if self.channel_lengths.len() > limits.max_channel_count as usize {
336            return Err(Error::resource_limit(
337                "seek directory channel_count exceeds the channel-count limit",
338            ));
339        }
340
341        if self.section_flags & SECTION_LOCATORS != 0 {
342            if self.entries.is_empty() {
343                return Err(Error::invalid_container("seek directory has no locators"));
344            }
345            // Entry 0 is the directory itself, at the fixed end of the header.
346            let first = self.entries[0];
347            if first.tag != RecordTag::Directory as u8 {
348                return Err(Error::invalid_container(
349                    "seek directory locator 0 is not the DIRECTORY record",
350                ));
351            }
352            if first.offset != HEADER_LEN as u64 {
353                return Err(Error::invalid_container(
354                    "seek directory locator 0 is not at the end of the header",
355                ));
356            }
357
358            // Locators must be strictly ascending and contiguous, and the last
359            // record must end exactly at `file_len`.
360            let mut prev_end: Option<u64> = None;
361            for (i, e) in self.entries.iter().enumerate() {
362                let end = e
363                    .offset
364                    .checked_add(RECORD_OVERHEAD as u64)
365                    .and_then(|v| v.checked_add(u64::from(e.payload_len)))
366                    .ok_or_else(|| {
367                        Error::invalid_container(format!(
368                            "seek directory locator {i} end overflows"
369                        ))
370                    })?;
371                if let Some(pe) = prev_end
372                    && e.offset != pe
373                {
374                    return Err(Error::invalid_container(format!(
375                        "seek directory locator {i} is not contiguous with the previous record"
376                    )));
377                }
378                prev_end = Some(end);
379            }
380            let last = self.entries.last().expect("non-empty");
381            if last.tag != RecordTag::Trailer as u8 {
382                return Err(Error::invalid_container(
383                    "seek directory does not end at the TRAILER record",
384                ));
385            }
386            match prev_end {
387                Some(end) if end == file_len => {}
388                Some(end) => {
389                    return Err(Error::invalid_container(format!(
390                        "seek directory ends at offset {end} but the file is {file_len} bytes"
391                    )));
392                }
393                None => unreachable!("entries is non-empty"),
394            }
395
396            // Singleton classes may appear at most once.
397            for tag in [
398                RecordTag::Graph,
399                RecordTag::ObservationIndex,
400                RecordTag::Integrity,
401                RecordTag::Trailer,
402            ] {
403                let n = self.entries.iter().filter(|e| e.tag == tag as u8).count();
404                if n > 1 {
405                    return Err(Error::invalid_container(format!(
406                        "seek directory lists the singleton {} {n} times",
407                        tag.name()
408                    )));
409                }
410            }
411        }
412
413        // The class index must agree with a linear scan of the locators.
414        for (i, c) in self.classes.iter().enumerate() {
415            if c.count == 0 {
416                return Err(Error::invalid_container(format!(
417                    "seek directory class entry {i} has a zero count"
418                )));
419            }
420            if self.classes[..i].iter().any(|o| o.tag == c.tag) {
421                return Err(Error::invalid_container(format!(
422                    "seek directory has duplicate class entry for tag {:#04x}",
423                    c.tag
424                )));
425            }
426            let first = c.first as usize;
427            let count = c.count as usize;
428            let end = first.checked_add(count).ok_or_else(|| {
429                Error::invalid_container(format!("seek directory class entry {i} range overflows"))
430            })?;
431            if end > self.entries.len() {
432                return Err(Error::invalid_container(format!(
433                    "seek directory class entry {i} range {first}..{end} exceeds {} locators",
434                    self.entries.len()
435                )));
436            }
437            if self.entries[first..end].iter().any(|e| e.tag != c.tag) {
438                return Err(Error::invalid_container(format!(
439                    "seek directory class entry {i} spans a foreign tag"
440                )));
441            }
442            let scan_first = self.entries.iter().position(|e| e.tag == c.tag);
443            let scan_count = self.entries.iter().filter(|e| e.tag == c.tag).count();
444            if scan_first != Some(first) || scan_count != count {
445                return Err(Error::invalid_container(format!(
446                    "seek directory class entry {i} disagrees with a scan of the locators"
447                )));
448            }
449        }
450
451        if self.section_flags & SECTION_CHANNEL_LENGTHS != 0 {
452            let channels = self
453                .entries
454                .iter()
455                .filter(|e| e.tag == RecordTag::EntropyChannel as u8)
456                .count();
457            if channels != self.channel_lengths.len() {
458                return Err(Error::invalid_container(format!(
459                    "seek directory lists {} channel lengths for {channels} channel records",
460                    self.channel_lengths.len()
461                )));
462            }
463        }
464
465        Ok(())
466    }
467}
468
469/// Build a canonical class index from a locator table: one entry per class in
470/// [`CLASS_INDEX_TAGS`] that appears, with its first index and total count.
471pub fn class_index(entries: &[DirectoryEntry]) -> Vec<ClassEntry> {
472    let mut classes = Vec::new();
473    for tag in CLASS_INDEX_TAGS {
474        let count = entries.iter().filter(|e| e.tag == tag as u8).count();
475        if count == 0 {
476            continue;
477        }
478        let first = entries
479            .iter()
480            .position(|e| e.tag == tag as u8)
481            .expect("count > 0 implies a first occurrence");
482        classes.push(ClassEntry {
483            tag: tag as u8,
484            first: first as u32,
485            count: count as u32,
486        });
487    }
488    classes
489}
490
491/// Ensure `count` entries of `entry_len` bytes fit in `bytes` from `p`.
492fn require(bytes: &[u8], p: usize, count: usize, entry_len: usize) -> Result<()> {
493    let need = count
494        .checked_mul(entry_len)
495        .ok_or_else(|| Error::invalid_container("SEEK_DIRECTORY section length overflow"))?;
496    let available = bytes
497        .len()
498        .checked_sub(p)
499        .ok_or_else(|| Error::invalid_container("SEEK_DIRECTORY cursor past end of payload"))?;
500    if need > available {
501        return Err(Error::invalid_container(format!(
502            "SEEK_DIRECTORY section needs {need} bytes but only {available} remain"
503        )));
504    }
505    Ok(())
506}
507
508fn read_u8(bytes: &[u8], p: &mut usize) -> Result<u8> {
509    let v = *bytes
510        .get(*p)
511        .ok_or_else(|| Error::invalid_container("truncated SEEK_DIRECTORY payload"))?;
512    *p += 1;
513    Ok(v)
514}
515
516fn read_u32(bytes: &[u8], p: &mut usize) -> Result<u32> {
517    let end = p
518        .checked_add(4)
519        .ok_or_else(|| Error::invalid_container("SEEK_DIRECTORY cursor overflow"))?;
520    let slice = bytes
521        .get(*p..end)
522        .ok_or_else(|| Error::invalid_container("truncated SEEK_DIRECTORY payload"))?;
523    *p = end;
524    Ok(u32::from_le_bytes([slice[0], slice[1], slice[2], slice[3]]))
525}
526
527fn read_u64(bytes: &[u8], p: &mut usize) -> Result<u64> {
528    let end = p
529        .checked_add(8)
530        .ok_or_else(|| Error::invalid_container("SEEK_DIRECTORY cursor overflow"))?;
531    let slice = bytes
532        .get(*p..end)
533        .ok_or_else(|| Error::invalid_container("truncated SEEK_DIRECTORY payload"))?;
534    *p = end;
535    Ok(u64::from_le_bytes([
536        slice[0], slice[1], slice[2], slice[3], slice[4], slice[5], slice[6], slice[7],
537    ]))
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543
544    fn entry(tag: RecordTag, offset: u64, payload_len: u32) -> DirectoryEntry {
545        DirectoryEntry {
546            tag: tag as u8,
547            offset,
548            payload_len,
549        }
550    }
551
552    /// A directory whose locators describe a synthetic three-record file:
553    /// DIRECTORY at 64, INTEGRITY at 100, TRAILER at 152 (each 12 B framing).
554    fn sample_dir() -> SeekDirectory {
555        let entries = vec![
556            entry(RecordTag::Directory, HEADER_LEN as u64, 30),
557            entry(RecordTag::Integrity, 106, 40),
558            entry(RecordTag::Trailer, 158, 20),
559        ];
560        let classes = class_index(&entries);
561        SeekDirectory {
562            section_flags: SEEK_DIRECTORY_ALL_SECTIONS,
563            entries,
564            classes,
565            channel_lengths: Vec::new(),
566        }
567    }
568
569    #[test]
570    fn roundtrip() {
571        let dir = sample_dir();
572        let bytes = dir.encode().unwrap();
573        let decoded = SeekDirectory::decode(&bytes, Limits::DEFAULT).unwrap();
574        assert_eq!(decoded, dir);
575    }
576
577    #[test]
578    fn encoded_length_is_the_documented_sum() {
579        let dir = sample_dir();
580        let bytes = dir.encode().unwrap();
581        let expected = 2
582            + 4
583            + DIRECTORY_ENTRY_LEN * dir.entries.len()
584            + 1
585            + CLASS_ENTRY_LEN * dir.classes.len()
586            + 4
587            + 8 * dir.channel_lengths.len();
588        assert_eq!(bytes.len(), expected);
589    }
590
591    #[test]
592    fn unknown_version_and_flags_fail_closed() {
593        let dir = sample_dir();
594        let mut bytes = dir.encode().unwrap();
595        bytes[0] = 2;
596        assert_eq!(
597            SeekDirectory::decode(&bytes, Limits::DEFAULT)
598                .unwrap_err()
599                .class(),
600            crate::ErrorClass::UnsupportedVersion
601        );
602
603        let mut bytes = dir.encode().unwrap();
604        bytes[1] |= 0x80;
605        assert_eq!(
606            SeekDirectory::decode(&bytes, Limits::DEFAULT)
607                .unwrap_err()
608                .class(),
609            crate::ErrorClass::InvalidContainer
610        );
611    }
612
613    #[test]
614    fn truncated_section_is_rejected() {
615        let dir = sample_dir();
616        let bytes = dir.encode().unwrap();
617        for cut in 0..bytes.len() {
618            // The 2-byte header alone decodes but names sections that are absent.
619            if cut < 2 {
620                continue;
621            }
622            let e = SeekDirectory::decode(&bytes[..cut], Limits::DEFAULT);
623            assert!(e.is_err(), "cut {cut} was accepted");
624        }
625    }
626
627    #[test]
628    fn trailing_bytes_are_rejected() {
629        let dir = sample_dir();
630        let mut bytes = dir.encode().unwrap();
631        bytes.push(0);
632        assert_eq!(
633            SeekDirectory::decode(&bytes, Limits::DEFAULT)
634                .unwrap_err()
635                .class(),
636            crate::ErrorClass::InvalidContainer
637        );
638    }
639
640    #[test]
641    fn over_limit_size_is_declined_before_allocation() {
642        let dir = sample_dir();
643        let bytes = dir.encode().unwrap();
644        let tiny = Limits {
645            max_directory_bytes: 4,
646            ..Limits::DEFAULT
647        };
648        assert_eq!(
649            SeekDirectory::decode(&bytes, tiny).unwrap_err().class(),
650            crate::ErrorClass::ResourceLimit
651        );
652    }
653
654    #[test]
655    fn structural_validation_accepts_a_contiguous_directory() {
656        let dir = sample_dir();
657        // entry ends: dir 64+12+30=106; integrity 106+12+40=158; trailer 158+12+20=190.
658        dir.validate_structural(190, Limits::DEFAULT).unwrap();
659    }
660
661    #[test]
662    fn structural_validation_rejects_bad_geometry() {
663        // A non-contiguous locator.
664        let mut dir = sample_dir();
665        dir.entries[2].offset += 1;
666        assert_eq!(
667            dir.validate_structural(190, Limits::DEFAULT)
668                .unwrap_err()
669                .class(),
670            crate::ErrorClass::InvalidContainer
671        );
672
673        // The last record does not reach the end of the file.
674        let dir = sample_dir();
675        assert_eq!(
676            dir.validate_structural(191, Limits::DEFAULT)
677                .unwrap_err()
678                .class(),
679            crate::ErrorClass::InvalidContainer
680        );
681
682        // A class index whose count disagrees with a scan.
683        let mut dir = sample_dir();
684        dir.classes[0].count += 1;
685        assert_eq!(
686            dir.validate_structural(190, Limits::DEFAULT)
687                .unwrap_err()
688                .class(),
689            crate::ErrorClass::InvalidContainer
690        );
691    }
692
693    #[test]
694    fn validate_cross_checks_the_record_framing() {
695        let dir = sample_dir();
696        let sites = vec![
697            RecordSite {
698                tag: RecordTag::Directory as u8,
699                offset: HEADER_LEN as u64,
700                payload_len: 30,
701            },
702            RecordSite {
703                tag: RecordTag::Integrity as u8,
704                offset: 106,
705                payload_len: 40,
706            },
707            RecordSite {
708                tag: RecordTag::Trailer as u8,
709                offset: 158,
710                payload_len: 20,
711            },
712        ];
713        dir.validate(&sites, 190, Limits::DEFAULT).unwrap();
714
715        // A framing that disagrees with the locator must be rejected.
716        let mut wrong = sites.clone();
717        wrong[1].payload_len = 41;
718        assert_eq!(
719            dir.validate(&wrong, 190, Limits::DEFAULT)
720                .unwrap_err()
721                .class(),
722            crate::ErrorClass::InvalidContainer
723        );
724    }
725}