Skip to main content

onelf_format/
reader.rs

1//! Bounds checking for the regions a package footer describes.
2//!
3//! A packed file is untrusted input: the runtime parses one on an end user's
4//! machine, and the packer's inspection commands parse whatever they are
5//! handed. Every offset and size in the footer, and in the blocks the
6//! manifest carries, is an attacker-controlled `u64`.
7//!
8//! These checks used to live only in the runtime, so `onelf info` on a
9//! truncated download tried to allocate whatever the footer claimed and
10//! aborted. Both sides now validate here, before allocating or seeking.
11
12use std::io;
13
14use crate::entry::Block;
15use crate::footer::Footer;
16
17fn invalid(msg: &'static str) -> io::Error {
18    io::Error::new(io::ErrorKind::InvalidData, msg)
19}
20
21/// True when `off + len` stays within `file_size` without overflowing.
22fn in_bounds(off: u64, len: u64, file_size: u64) -> bool {
23    off.checked_add(len).is_some_and(|end| end <= file_size)
24}
25
26/// Check every region `footer` points at against the real file size.
27///
28/// Callers MUST run this before acting on any footer field. It is what makes
29/// a later `vec![0; size]` safe to perform.
30pub fn validate_footer(footer: &Footer, file_size: u64) -> io::Result<()> {
31    if !in_bounds(
32        footer.manifest_offset,
33        footer.manifest_compressed,
34        file_size,
35    ) {
36        return Err(invalid("manifest region out of bounds"));
37    }
38    if !in_bounds(footer.payload_offset, footer.payload_size, file_size) {
39        return Err(invalid("payload region out of bounds"));
40    }
41    // The manifest decompresses into a buffer sized by this field, so it has
42    // to be backed by something even though it describes no file region.
43    if footer.manifest_original > MAX_MANIFEST_ORIGINAL
44        || footer.manifest_original > file_size.saturating_mul(MAX_MANIFEST_EXPANSION)
45    {
46        return Err(invalid("manifest expands implausibly"));
47    }
48    if footer.dict_size > 0 && !in_bounds(footer.dict_offset, footer.dict_size as u64, file_size) {
49        return Err(invalid("dictionary region out of bounds"));
50    }
51    Ok(())
52}
53
54/// Ceiling on how far the manifest may claim to decompress, relative to the
55/// whole file. Compressed manifests are small and text-like; anything beyond
56/// this is a crafted header rather than a real package.
57const MAX_MANIFEST_EXPANSION: u64 = 1024;
58
59/// Absolute ceiling on the decompressed manifest. The ratio above still
60/// lets a large package claim gigabytes; a manifest is a few dozen bytes
61/// per entry, so this covers millions of entries and nothing real beyond.
62const MAX_MANIFEST_ORIGINAL: u64 = 256 << 20;
63
64/// Absolute file offset of `block`'s compressed bytes, checked against the
65/// payload region `footer` declares.
66///
67/// Returns the offset and the compressed length, so the caller can allocate
68/// knowing the file can back it.
69pub fn block_extent(footer: &Footer, block: &Block) -> io::Result<(u64, usize)> {
70    let abs = footer
71        .payload_offset
72        .checked_add(block.payload_offset)
73        .ok_or_else(|| invalid("payload offset overflow"))?;
74    let payload_end = footer
75        .payload_offset
76        .checked_add(footer.payload_size)
77        .ok_or_else(|| invalid("payload region overflow"))?;
78    if !in_bounds(abs, block.compressed_size, payload_end) {
79        return Err(invalid("block extends past the payload region"));
80    }
81    let len = usize::try_from(block.compressed_size)
82        .map_err(|_| invalid("block larger than this address space"))?;
83    Ok((abs, len))
84}
85
86/// Decompressed size of `block`, rejected when it exceeds any plausible
87/// block size.
88///
89/// Both zstd and the dictionary path size their output buffer from this, so
90/// an unchecked value is an allocation an attacker chooses. The bound is
91/// deliberately absolute rather than a compression ratio: real content
92/// reaches extreme ratios, a run of zeroes compressing by four orders of
93/// magnitude, and a ratio test rejects those legitimate packages.
94pub fn block_original_size(block: &Block) -> io::Result<usize> {
95    if block.original_size > MAX_BLOCK_ORIGINAL {
96        return Err(invalid("block decompresses to an implausible size"));
97    }
98    usize::try_from(block.original_size)
99        .map_err(|_| invalid("block larger than this address space"))
100}
101
102/// Ceiling on one block's decompressed size. The packer emits blocks of
103/// 64 KiB by default and 32 MiB at most, so this sits well above anything
104/// real; it exists only to bound the allocation. zstd then rejects any
105/// block whose actual output does not match what the header claimed.
106const MAX_BLOCK_ORIGINAL: u64 = 256 * 1024 * 1024;
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use crate::footer::Flags;
112
113    fn footer() -> Footer {
114        Footer {
115            format_version: 1,
116            flags: Flags::empty(),
117            manifest_offset: 100,
118            manifest_compressed: 50,
119            manifest_original: 200,
120            payload_offset: 200,
121            payload_size: 300,
122            dict_offset: 0,
123            dict_size: 0,
124            manifest_checksum: [0; 4],
125        }
126    }
127
128    fn block(offset: u64, compressed: u64, original: u64) -> Block {
129        Block {
130            payload_offset: offset,
131            compressed_size: compressed,
132            original_size: original,
133            content_hash: [0u8; 32],
134        }
135    }
136
137    #[test]
138    fn well_formed_footer_passes() {
139        assert!(validate_footer(&footer(), 1000).is_ok());
140    }
141
142    #[test]
143    fn regions_past_the_file_are_rejected() {
144        let mut f = footer();
145        f.manifest_compressed = u64::MAX;
146        assert!(validate_footer(&f, 1000).is_err());
147
148        let mut f = footer();
149        f.payload_size = 10_000;
150        assert!(validate_footer(&f, 1000).is_err());
151
152        let mut f = footer();
153        f.dict_size = 900;
154        f.dict_offset = 500;
155        assert!(validate_footer(&f, 1000).is_err());
156    }
157
158    #[test]
159    fn overflowing_offsets_are_rejected() {
160        let mut f = footer();
161        f.manifest_offset = u64::MAX;
162        f.manifest_compressed = 1;
163        assert!(validate_footer(&f, 1000).is_err());
164    }
165
166    #[test]
167    fn absurd_manifest_expansion_is_rejected() {
168        let mut f = footer();
169        f.manifest_original = u64::MAX;
170        assert!(validate_footer(&f, 1000).is_err());
171    }
172
173    #[test]
174    fn block_within_payload_resolves() {
175        let f = footer();
176        let (abs, len) = block_extent(&f, &block(10, 20, 40)).unwrap();
177        assert_eq!((abs, len), (210, 20));
178    }
179
180    #[test]
181    fn block_past_the_payload_is_rejected() {
182        let f = footer();
183        assert!(block_extent(&f, &block(290, 20, 40)).is_err());
184        assert!(block_extent(&f, &block(u64::MAX, 1, 1)).is_err());
185        assert!(block_extent(&f, &block(0, u64::MAX, 1)).is_err());
186    }
187
188    #[test]
189    fn absurd_block_size_is_rejected() {
190        assert!(block_original_size(&block(0, 10, 40)).is_ok());
191        assert!(block_original_size(&block(0, 1, u64::MAX)).is_err());
192        // A stored block reports equal sizes and must stay acceptable.
193        assert!(block_original_size(&block(0, 4096, 4096)).is_ok());
194    }
195
196    /// Real content reaches extreme compression ratios: a 256 KiB run of
197    /// zeroes lands near 30 bytes, roughly 9000:1. A ratio-based bound
198    /// rejected exactly such a package, so the shape is pinned here.
199    #[test]
200    fn extreme_but_real_compression_ratios_are_accepted() {
201        let compressed = 30u64;
202        let original = 256 * 1024u64;
203        assert!(original / compressed > 4096, "fixture must be extreme");
204        assert!(block_original_size(&block(0, compressed, original)).is_ok());
205    }
206}