Skip to main content

iris_format/
directory.rs

1//! Reading a container's metadata without holding the container.
2//!
3//! A container is a header, the sections, a footer and a trailer, and everything a host needs in
4//! order to decide what to do is in three of those four. The sections are the payload and they are
5//! the only part that is large. Splitting the metadata out from the bytes it describes is what lets
6//! a host open a forty gigabyte file by reading about a kilobyte of it.
7//!
8//! There are two pieces because they are found in two steps, and the order is forced by the layout.
9//! [`Placement`] comes from the last [`Placement::TRAILER_LEN`] bytes of the file and says where
10//! the footer is. [`Directory`] comes from the first [`HEADER_SIZE`](crate::layout::HEADER_SIZE)
11//! bytes and the footer those bytes point at, and says what the container holds. A host that can
12//! read a range twice can open a container of any size.
13//!
14//! # One parser
15//!
16//! [`crate::Container`] is this plus the payload. It is built on these types rather than beside
17//! them, so there is exactly one piece of code in the workspace that reads untrusted container
18//! metadata and exactly one fuzz target that has to cover it. A second parser for the windowed case
19//! would have been the easier change and it would have meant that half the parsing in the project
20//! was fuzzed.
21//!
22//! The rules the container parser follows apply here unchanged: nothing is indexed without a bounds
23//! check, no arithmetic on a length out of the file is unchecked, and nothing is allocated in
24//! proportion to a number the file claims rather than to bytes that are there.
25
26use iris_abi::{Reader, Tag};
27
28use crate::container::FileHeader;
29use crate::digest::Digest;
30use crate::error::{Error, Result};
31use crate::layout::{
32    DecoderLocation, FORMAT_MAJOR, HEADER_SIZE, MAGIC, MIN_SIZE, TRAILER_SIZE, tag,
33};
34use crate::meta::{Dataset, DecoderRef, Schema, Section};
35
36/// Where the footer is, read from the trailer.
37///
38/// This is the first thing a host learns about a container and the only thing it can learn without
39/// being told where to look, because the trailer is at a known distance from the end. Everything
40/// else follows from it.
41#[derive(Clone, Copy, PartialEq, Eq, Debug)]
42pub struct Placement {
43    file_len: u64,
44    footer_at: u64,
45    footer_len: u32,
46    root: Digest,
47}
48
49impl Placement {
50    /// How many bytes at the end of the file the trailer occupies.
51    pub const TRAILER_LEN: usize = TRAILER_SIZE;
52
53    /// Where the trailer starts in a file of this length.
54    ///
55    /// # Errors
56    ///
57    /// [`Error::Truncated`] if the file is too short to be a container at all, which is checked
58    /// here so that a host cannot be told to read from a negative offset.
59    pub fn trailer_at(file_len: u64) -> Result<u64> {
60        if file_len < MIN_SIZE as u64 {
61            return Err(Error::Truncated {
62                what: "the container",
63                needed: MIN_SIZE as u64,
64                available: file_len,
65            });
66        }
67        Ok(file_len - TRAILER_SIZE as u64)
68    }
69
70    /// Reads the trailer of a file of `file_len` bytes.
71    ///
72    /// `trailer` is the [`Placement::TRAILER_LEN`] bytes at [`Placement::trailer_at`].
73    ///
74    /// # Errors
75    ///
76    /// [`Error::Truncated`] if the file is too short or does not end with the magic,
77    /// [`Error::Reserved`] if a field that has to be zero is not, and [`Error::Truncated`] again if
78    /// the footer the trailer names does not fit between the header and the trailer.
79    pub fn read(trailer: &[u8], file_len: u64) -> Result<Self> {
80        let trailer_at = Self::trailer_at(file_len)?;
81        // A fixed size array rather than a slice, so every field below is read at an index the
82        // compiler has already checked and there is no way for this to panic on a short read.
83        let t: &[u8; TRAILER_SIZE] = trailer.first_chunk().ok_or(Error::Truncated {
84            what: "the trailer",
85            needed: TRAILER_SIZE as u64,
86            available: trailer.len() as u64,
87        })?;
88
89        if t[48..56] != MAGIC {
90            return Err(Error::Truncated {
91                what: "the container, which does not end with the magic",
92                needed: file_len,
93                available: file_len,
94            });
95        }
96
97        let footer_at = u64::from_le_bytes([t[0], t[1], t[2], t[3], t[4], t[5], t[6], t[7]]);
98        let footer_len = u32::from_le_bytes([t[8], t[9], t[10], t[11]]);
99        let reserved = u32::from_le_bytes([t[12], t[13], t[14], t[15]]);
100        if reserved != 0 {
101            return Err(Error::Reserved { what: "trailer" });
102        }
103        let root: [u8; 32] = core::array::from_fn(|i| t[16 + i]);
104
105        // The footer has to sit between the header and the trailer. Both ends are checked, because
106        // a footer that starts inside the header would let a writer describe its own magic as
107        // records, and a footer that runs into the trailer would let it describe its own digest.
108        let footer_end = footer_at
109            .checked_add(u64::from(footer_len))
110            .ok_or(Error::Truncated {
111                what: "the footer",
112                needed: u64::MAX,
113                available: file_len,
114            })?;
115        if footer_at < HEADER_SIZE as u64 || footer_end > trailer_at {
116            return Err(Error::Truncated {
117                what: "the footer",
118                needed: footer_end,
119                available: trailer_at,
120            });
121        }
122
123        Ok(Self {
124            file_len,
125            footer_at,
126            footer_len,
127            root: Digest(root),
128        })
129    }
130
131    /// How long the file is.
132    #[must_use]
133    pub const fn file_len(&self) -> u64 {
134        self.file_len
135    }
136
137    /// Where the footer starts.
138    #[must_use]
139    pub const fn footer_at(&self) -> u64 {
140        self.footer_at
141    }
142
143    /// How long the footer is.
144    ///
145    /// A `usize` because it is a `u32` in the file and every target this builds for has a `usize`
146    /// at least that wide, so a host can read it in one call without a conversion that could fail.
147    #[must_use]
148    pub const fn footer_len(&self) -> usize {
149        self.footer_len as usize
150    }
151
152    /// The digest that covers the header and the footer.
153    #[must_use]
154    pub const fn root_digest(&self) -> Digest {
155        self.root
156    }
157}
158
159/// What a container holds, without the bytes it holds.
160///
161/// Parsed from the header and the footer, so it borrows the footer and nothing else. A host that
162/// read those two ranges out of a file it is not holding has everything here that a host holding
163/// the whole file has, minus the ability to hand over a section's bytes.
164#[derive(Clone, Debug)]
165pub struct Directory<'a> {
166    header: FileHeader,
167    raw_header: [u8; HEADER_SIZE],
168    footer: &'a [u8],
169    placement: Placement,
170    dataset: Dataset,
171    schema: Option<Schema<'a>>,
172    decoder: Option<DecoderRef<'a>>,
173    sections: Vec<Section>,
174}
175
176impl<'a> Directory<'a> {
177    /// Parses the metadata and checks that it is the metadata that was written.
178    ///
179    /// `header` is the first [`HEADER_SIZE`](crate::layout::HEADER_SIZE) bytes of the file and
180    /// `footer` is the [`Placement::footer_len`] bytes at [`Placement::footer_at`]. The root digest
181    /// covers exactly those two ranges, so a directory that parses is one nobody has edited since
182    /// it was written.
183    ///
184    /// # Errors
185    ///
186    /// Returns [`Error`] describing the first thing that was wrong, including
187    /// [`Error::DigestMismatch`] if the bytes do not hash to the root digest in the trailer.
188    pub fn parse(header: &[u8], footer: &'a [u8], placement: Placement) -> Result<Self> {
189        let directory = Self::parse_without_root_digest(header, footer, placement)?;
190        let actual = directory.compute_root();
191        if actual != placement.root {
192            return Err(Error::DigestMismatch {
193                what: "footer".to_owned(),
194                expected: placement.root.to_string(),
195                actual: actual.to_string(),
196            });
197        }
198        Ok(directory)
199    }
200
201    /// Parses the metadata without checking the root digest.
202    ///
203    /// This exists for the fuzzer, for the same reason the container has one: checking the digest
204    /// first would mean essentially every generated input is rejected before the parser behind it
205    /// runs, and the parser is the part that needs the fuzzing.
206    ///
207    /// # Errors
208    ///
209    /// The same as [`Directory::parse`], minus the digest mismatch.
210    pub fn parse_without_root_digest(
211        header: &[u8],
212        footer: &'a [u8],
213        placement: Placement,
214    ) -> Result<Self> {
215        let raw_header: [u8; HEADER_SIZE] = *header.first_chunk().ok_or(Error::Truncated {
216            what: "the header",
217            needed: HEADER_SIZE as u64,
218            available: header.len() as u64,
219        })?;
220
221        if raw_header[..MAGIC.len()] != MAGIC {
222            return Err(Error::NotAContainer {
223                found: raw_header[..MAGIC.len()].to_vec(),
224                expected: MAGIC.to_vec(),
225            });
226        }
227
228        // The trailer said how long the footer is, so a footer of a different length means the
229        // caller read the wrong range and the records in it would be parsed against the wrong
230        // boundary. Better to say so than to parse whatever arrived.
231        if footer.len() != placement.footer_len() {
232            return Err(Error::Truncated {
233                what: "the footer",
234                needed: placement.footer_len() as u64,
235                available: footer.len() as u64,
236            });
237        }
238
239        let mut directory = Self {
240            header: parse_header(&raw_header)?,
241            raw_header,
242            footer,
243            placement,
244            dataset: Dataset {
245                rows: 0,
246                name: String::new(),
247            },
248            schema: None,
249            decoder: None,
250            sections: Vec::new(),
251        };
252        directory.parse_footer(footer)?;
253        directory.check_sections()?;
254        Ok(directory)
255    }
256
257    fn parse_footer(&mut self, footer: &'a [u8]) -> Result<()> {
258        let mut seen_dataset = false;
259        let mut p = Reader::new(footer);
260        while !p.is_empty() {
261            let (header, mut body) = p.record()?;
262            match header.tag {
263                tag::DATASET => {
264                    expect_version(header.tag, header.version, Dataset::VERSION)?;
265                    if seen_dataset {
266                        return Err(Error::RepeatedRecord(tag::DATASET));
267                    }
268                    self.dataset = Dataset::decode(&mut body)?;
269                    seen_dataset = true;
270                }
271                tag::SCHEMA => {
272                    expect_version(header.tag, header.version, Schema::VERSION)?;
273                    if self.schema.is_some() {
274                        return Err(Error::RepeatedRecord(tag::SCHEMA));
275                    }
276                    self.schema = Some(Schema::decode(&mut body)?);
277                }
278                tag::DECODER => {
279                    expect_version(header.tag, header.version, DecoderRef::VERSION)?;
280                    if self.decoder.is_some() {
281                        return Err(Error::RepeatedRecord(tag::DECODER));
282                    }
283                    self.decoder = Some(DecoderRef::decode(&mut body)?);
284                }
285                tag::SECTION => {
286                    expect_version(header.tag, header.version, Section::VERSION)?;
287                    self.sections.push(Section::decode(&mut body)?);
288                }
289                // An unknown record is stepped over, which is the whole reason the footer is framed
290                // this way. A tool that only wants the section table should not be stopped by a
291                // record a newer writer added.
292                _ => {}
293            }
294        }
295        if !seen_dataset {
296            return Err(Error::MissingRecord(tag::DATASET));
297        }
298        Ok(())
299    }
300
301    fn check_sections(&self) -> Result<()> {
302        let file_len = self.placement.file_len();
303        for (i, section) in self.sections.iter().enumerate() {
304            let end = section.end().ok_or(Error::SectionOutOfBounds {
305                id: section.id,
306                offset: section.offset,
307                end: u64::MAX,
308                file_len,
309            })?;
310            if section.offset < HEADER_SIZE as u64 || end > self.placement.footer_at() {
311                return Err(Error::SectionOutOfBounds {
312                    id: section.id,
313                    offset: section.offset,
314                    end,
315                    file_len,
316                });
317            }
318            if self.sections[..i].iter().any(|s| s.id == section.id) {
319                return Err(Error::DuplicateSection { id: section.id });
320            }
321        }
322        Ok(())
323    }
324
325    fn compute_root(&self) -> Digest {
326        let mut hasher = blake3::Hasher::new();
327        hasher.update(&self.raw_header);
328        hasher.update(self.footer);
329        Digest(*hasher.finalize().as_bytes())
330    }
331
332    /// The format version this container was written at.
333    #[must_use]
334    pub const fn header(&self) -> FileHeader {
335        self.header
336    }
337
338    /// Where the footer is and how long the file is.
339    #[must_use]
340    pub const fn placement(&self) -> Placement {
341        self.placement
342    }
343
344    /// The digest that covers the header and the footer.
345    #[must_use]
346    pub const fn root_digest(&self) -> Digest {
347        self.placement.root_digest()
348    }
349
350    /// What the dataset is.
351    #[must_use]
352    pub const fn dataset(&self) -> &Dataset {
353        &self.dataset
354    }
355
356    /// The schema, if there is one.
357    #[must_use]
358    pub const fn schema(&self) -> Option<&Schema<'a>> {
359        self.schema.as_ref()
360    }
361
362    /// The decoder reference, if there is one.
363    #[must_use]
364    pub const fn decoder(&self) -> Option<&DecoderRef<'a>> {
365        self.decoder.as_ref()
366    }
367
368    /// Every section, in the order the footer listed them.
369    #[must_use]
370    pub fn sections(&self) -> &[Section] {
371        &self.sections
372    }
373
374    /// The section with this id.
375    #[must_use]
376    pub fn section(&self, id: u32) -> Option<&Section> {
377        self.sections.iter().find(|s| s.id == id)
378    }
379
380    /// The section the decoder module is in, if the decoder is embedded and that section exists.
381    ///
382    /// A host holding the whole file follows this with [`crate::Container::section_bytes`]. A host
383    /// that is not holding the file reads the range itself, which is the only difference between
384    /// the two paths and the reason this returns a section rather than bytes.
385    #[must_use]
386    pub fn decoder_section(&self) -> Option<&Section> {
387        let decoder = self.decoder.as_ref()?;
388        let DecoderLocation::Embedded { section } = decoder.location else {
389            return None;
390        };
391        self.section(section)
392    }
393}
394
395fn parse_header(header: &[u8; HEADER_SIZE]) -> Result<FileHeader> {
396    let major = u16::from_le_bytes([header[8], header[9]]);
397    let minor = u16::from_le_bytes([header[10], header[11]]);
398    if major != FORMAT_MAJOR {
399        return Err(Error::UnsupportedFormat {
400            major,
401            minor,
402            supported_major: FORMAT_MAJOR,
403        });
404    }
405    let flags = u32::from_le_bytes([header[12], header[13], header[14], header[15]]);
406    if flags != 0 {
407        return Err(Error::Reserved { what: "header" });
408    }
409    Ok(FileHeader { major, minor })
410}
411
412fn expect_version(tag: Tag, found: u16, supported: u16) -> Result<()> {
413    if found > supported {
414        return Err(Error::UnsupportedRecord {
415            tag,
416            version: found,
417        });
418    }
419    Ok(())
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    #[test]
427    fn a_file_shorter_than_the_smallest_container_has_no_trailer_to_read() {
428        assert!(matches!(
429            Placement::trailer_at(MIN_SIZE as u64 - 1),
430            Err(Error::Truncated { .. })
431        ));
432        assert_eq!(
433            Placement::trailer_at(MIN_SIZE as u64).expect("the smallest container has a trailer"),
434            (MIN_SIZE - TRAILER_SIZE) as u64
435        );
436    }
437
438    fn trailer(footer_at: u64, footer_len: u32) -> [u8; TRAILER_SIZE] {
439        let mut t = [0u8; TRAILER_SIZE];
440        t[0..8].copy_from_slice(&footer_at.to_le_bytes());
441        t[8..12].copy_from_slice(&footer_len.to_le_bytes());
442        t[48..56].copy_from_slice(&MAGIC);
443        t
444    }
445
446    #[test]
447    fn a_trailer_that_does_not_end_with_the_magic_is_not_a_container() {
448        let mut t = trailer(HEADER_SIZE as u64, 8);
449        t[55] = 0;
450        assert!(matches!(
451            Placement::read(&t, 1024),
452            Err(Error::Truncated { .. })
453        ));
454    }
455
456    #[test]
457    fn a_footer_that_overlaps_the_header_or_the_trailer_is_refused() {
458        // Starting inside the header would let a writer describe the magic as records.
459        assert!(matches!(
460            Placement::read(&trailer(HEADER_SIZE as u64 - 1, 8), 1024),
461            Err(Error::Truncated { .. })
462        ));
463        // Running into the trailer would let it describe its own digest.
464        assert!(matches!(
465            Placement::read(&trailer(1024 - TRAILER_SIZE as u64, 8), 1024),
466            Err(Error::Truncated { .. })
467        ));
468        // An end that would wrap round saturates into the same refusal.
469        assert!(matches!(
470            Placement::read(&trailer(u64::MAX, 8), 1024),
471            Err(Error::Truncated { .. })
472        ));
473    }
474
475    #[test]
476    fn a_reserved_field_that_is_not_zero_is_refused() {
477        let mut t = trailer(HEADER_SIZE as u64, 8);
478        t[12] = 1;
479        assert!(matches!(
480            Placement::read(&t, 1024),
481            Err(Error::Reserved { what: "trailer" })
482        ));
483    }
484
485    #[test]
486    fn a_footer_of_the_wrong_length_is_refused_rather_than_parsed() {
487        let placement = Placement::read(&trailer(HEADER_SIZE as u64, 8), 1024)
488            .expect("the placement is well formed");
489        let mut header = [0u8; HEADER_SIZE];
490        header[..MAGIC.len()].copy_from_slice(&MAGIC);
491        header[8..10].copy_from_slice(&FORMAT_MAJOR.to_le_bytes());
492        assert!(matches!(
493            Directory::parse_without_root_digest(&header, &[0u8; 4], placement),
494            Err(Error::Truncated {
495                what: "the footer",
496                ..
497            })
498        ));
499    }
500}