Skip to main content

btrfs_core/
extent.rs

1//! P3 EXTENT_DATA → file content: assemble a file's bytes from its
2//! `btrfs_file_extent_item`s (inline, regular, prealloc, hole), decompressing
3//! zlib / LZO / zstd extents.
4//!
5//! The FS_TREE keys a file's data as `EXTENT_DATA` ([`EXTENT_DATA_KEY`] = 108)
6//! items, one per file-logical region, ordered by the key's `offset` (the
7//! file-logical byte where the region starts). Each item is a
8//! `btrfs_file_extent_item`:
9//!
10//! ```text
11//! generation(u64)@0  ram_bytes(u64)@8  compression(u8)@16  encryption(u8)@17
12//! other_encoding(u16)@18  type(u8)@20
13//!   type 0 = INLINE   : the data bytes follow the 21-byte header, in the leaf
14//!                       (possibly compressed to ram_bytes)
15//!   type 1 = REG / 2 = PREALLOC :
16//!       disk_bytenr(u64)@21   disk_num_bytes(u64)@29
17//!       offset(u64)@37        num_bytes(u64)@45
18//!       disk_bytenr == 0  ⇒  a HOLE (num_bytes zeros, no image read)
19//! ```
20//!
21//! Offsets verified byte-for-byte against `btrfs inspect-internal dump-tree`
22//! on the minted oracle (small.txt/leaf.txt inline, mid.bin regular; see
23//! `tests/data/README.md`, "P2 ground truth" — the same FS_TREE leaf).
24//!
25//! # Compression (batteries-included)
26//!
27//! zlib (`flate2`), zstd (`ruzstd`, pure-Rust), and btrfs-framed LZO (`lzo` +
28//! the [MS-agnostic] per-sector framing from the kernel `fs/btrfs/lzo.c`) are
29//! **compiled in unconditionally** — never behind a feature the analyst must
30//! know to enable. `ram_bytes` is the decompressed size.
31//!
32//! # Safety
33//!
34//! Parses untrusted, attacker-controllable images. Every field is read through
35//! the bounds-checked [`crate::bytes`] helpers; every `num_bytes` / `ram_bytes`
36//! / `disk_num_bytes` is range-checked against the image length before it can
37//! size an allocation, so a lying length yields a loud
38//! [`BtrfsError::AllocationBomb`] rather than an OOM (the Paranoid Gatekeeper
39//! standard). Compression output is capped at `ram_bytes`.
40
41use std::io::Read;
42
43use crate::bytes::{le_u64, u8_at};
44use crate::error::BtrfsError;
45use crate::fstree::{read_by_path, INODE_ITEM_KEY};
46use crate::node::{read_node, ChunkMap, Node};
47use crate::superblock::Superblock;
48
49/// `BTRFS_EXTENT_DATA_KEY` — a `btrfs_file_extent_item` (file data).
50pub const EXTENT_DATA_KEY: u8 = 108;
51
52/// The fixed `btrfs_file_extent_item` header length before the type-specific
53/// tail (inline data, or the regular/prealloc `disk_bytenr` block).
54const EXTENT_HEADER_LEN: usize = 21;
55
56// `btrfs_file_extent_item` field offsets (verified vs dump-tree).
57mod ext_off {
58    pub const RAM_BYTES: usize = 8;
59    pub const COMPRESSION: usize = 16;
60    pub const TYPE: usize = 20;
61    // Regular / prealloc tail (from offset 21):
62    pub const DISK_BYTENR: usize = 21;
63    pub const OFFSET: usize = 37;
64    pub const NUM_BYTES: usize = 45;
65}
66
67/// `btrfs_file_extent_item` `type` byte.
68const EXTENT_TYPE_INLINE: u8 = 0;
69const EXTENT_TYPE_REG: u8 = 1;
70const EXTENT_TYPE_PREALLOC: u8 = 2;
71
72/// The compression algorithm of an extent (`btrfs_file_extent_item.compression`).
73/// Unknown values carry their raw byte so an unsupported codec is identifiable
74/// (fail-loud: the offending value is shown, never dropped).
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76#[non_exhaustive]
77pub enum Compression {
78    /// `0` — no compression; the extent bytes are the file bytes.
79    None,
80    /// `1` — zlib (a plain zlib stream per extent; `flate2`).
81    Zlib,
82    /// `2` — LZO, in btrfs's per-sector framing (`lzo` crate + framing).
83    Lzo,
84    /// `3` — zstd (one zstd frame per extent; `ruzstd`, pure-Rust).
85    Zstd,
86    /// Any other `BTRFS_COMPRESS_*` value, carrying the raw byte.
87    Other(u8),
88}
89
90impl Compression {
91    /// Classify a raw `compression` byte.
92    #[must_use]
93    pub fn from_byte(b: u8) -> Self {
94        match b {
95            0 => Compression::None,
96            1 => Compression::Zlib,
97            2 => Compression::Lzo,
98            3 => Compression::Zstd,
99            other => Compression::Other(other),
100        }
101    }
102}
103
104/// The floor for the logical-content allocation bound (64 MiB), used when the
105/// image is empty (always-on synthetic leaves) so a small hole/size still
106/// allocates while a `u64`-scale lying length is still rejected.
107const LOGICAL_ALLOC_FLOOR: u64 = 64 * 1024 * 1024;
108
109/// Assemble the file content for objectid `ino` from an already-parsed FS_TREE
110/// `leaf`, reading regular extents from `image` via `map`.
111///
112/// The inode's `size` (from its `INODE_ITEM`) truncates the assembled bytes;
113/// extents are taken in file-logical (`key.offset`) order and holes
114/// (`disk_bytenr == 0`) contribute zeros. `sectorsize` is needed for LZO
115/// framing.
116///
117/// # Errors
118///
119/// - [`BtrfsError::Truncated`] if the leaf holds no `INODE_ITEM` for `ino` (a
120///   loud miss — an absent inode is never an empty file).
121/// - [`BtrfsError::AllocationBomb`] if any extent's length field exceeds the
122///   image length.
123/// - Any error from translating/reading a regular extent's `disk_bytenr`, or a
124///   decompression failure.
125pub fn read_file_from_leaf(
126    leaf: &Node,
127    image: &[u8],
128    map: &ChunkMap,
129    sectorsize: u32,
130    ino: u64,
131) -> Result<Vec<u8>, BtrfsError> {
132    // The inode must exist in this leaf — a loud miss, never an empty file.
133    let size = leaf
134        .leaf_items()
135        .find(|(k, _)| k.objectid == ino && k.key_type == INODE_ITEM_KEY)
136        .map(|(_, data)| le_u64(data, crate::fstree::INODE_SIZE_OFFSET))
137        .ok_or(BtrfsError::Truncated {
138            structure: "INODE_ITEM for the requested objectid (read_file)",
139            need: ino as usize,
140            have: 0,
141        })?;
142
143    // Collect the file's EXTENT_DATA items in file-logical (key.offset) order.
144    let mut extents: Vec<(u64, &[u8])> = leaf
145        .leaf_items()
146        .filter(|(k, _)| k.objectid == ino && k.key_type == EXTENT_DATA_KEY)
147        .map(|(k, data)| (k.offset, data))
148        .collect();
149    extents.sort_by_key(|(file_off, _)| *file_off);
150
151    // The logical-content allocation bound: a file's assembled bytes cannot
152    // reasonably exceed the image it lives in, but always-on synthetic leaves
153    // pass an empty image while still holding small holes, so floor it at
154    // [`LOGICAL_ALLOC_FLOOR`]. A `size` / `num_bytes` above this bound is a lying
155    // length rejected as an allocation bomb (never blindly allocated).
156    let image_len = image.len() as u64;
157    let logical_bound = image_len.max(LOGICAL_ALLOC_FLOOR);
158    guard_alloc("inode size", size, logical_bound)?;
159    let mut out: Vec<u8> = Vec::new();
160
161    for (_file_off, data) in extents {
162        let etype = u8_at(data, ext_off::TYPE);
163        let ram_bytes = le_u64(data, ext_off::RAM_BYTES);
164        let compression = Compression::from_byte(u8_at(data, ext_off::COMPRESSION));
165
166        match etype {
167            EXTENT_TYPE_INLINE => {
168                let payload = data.get(EXTENT_HEADER_LEN..).unwrap_or(&[]);
169                let bytes = decompress_extent(compression, payload, ram_bytes, sectorsize)?;
170                out.extend_from_slice(&bytes);
171            }
172            EXTENT_TYPE_REG | EXTENT_TYPE_PREALLOC => {
173                let disk_bytenr = le_u64(data, ext_off::DISK_BYTENR);
174                let ext_offset = le_u64(data, ext_off::OFFSET);
175                let num_bytes = le_u64(data, ext_off::NUM_BYTES);
176
177                if disk_bytenr == 0 {
178                    // A HOLE: num_bytes zeros, no image read. Guard the count.
179                    guard_alloc("hole num_bytes", num_bytes, logical_bound)?;
180                    out.resize(out.len() + num_bytes as usize, 0);
181                    continue;
182                }
183
184                let bytes = read_regular_extent(
185                    image,
186                    map,
187                    sectorsize,
188                    compression,
189                    disk_bytenr,
190                    ext_offset,
191                    num_bytes,
192                    ram_bytes,
193                )?;
194                out.extend_from_slice(&bytes);
195            }
196            _ => {
197                // An unknown extent type: fail loud with the offending value
198                // rather than silently produce wrong (short) content.
199                return Err(BtrfsError::Truncated {
200                    structure: "btrfs_file_extent_item type (unknown extent type)",
201                    need: etype as usize,
202                    have: 0,
203                });
204            }
205        }
206    }
207
208    // The inode's logical size is authoritative: truncate a tail that extent
209    // rounding produced, or extend a sparse tail with zeros. `size` was already
210    // guarded against `logical_bound` above, so the resize cannot bomb.
211    let size = size as usize;
212    if out.len() > size {
213        out.truncate(size);
214    } else if out.len() < size {
215        // A sparse tail past the last extent (NO_HOLES filesystems omit trailing
216        // hole items): the file logically continues as zeros to `size`.
217        out.resize(size, 0);
218    }
219    Ok(out)
220}
221
222/// Read one regular/prealloc extent's bytes: translate `disk_bytenr` (logical)
223/// to a physical offset via `map`, slice the extent's compressed source, and
224/// (for a compressed extent) decompress it, then take the `[offset, offset +
225/// num_bytes)` window that this file-extent references.
226#[allow(clippy::too_many_arguments)]
227fn read_regular_extent(
228    image: &[u8],
229    map: &ChunkMap,
230    sectorsize: u32,
231    compression: Compression,
232    disk_bytenr: u64,
233    ext_offset: u64,
234    num_bytes: u64,
235    ram_bytes: u64,
236) -> Result<Vec<u8>, BtrfsError> {
237    let image_len = image.len() as u64;
238    guard_alloc("extent num_bytes", num_bytes, image_len)?;
239
240    let (_devid, phys) = map
241        .logical_to_physical(disk_bytenr)
242        .ok_or(BtrfsError::Truncated {
243            structure: "extent disk_bytenr (no chunk mapping)",
244            need: disk_bytenr as usize,
245            have: 0,
246        })?;
247
248    if compression == Compression::None {
249        // Uncompressed: the file bytes are `num_bytes` at physical
250        // `phys + ext_offset` (ext_offset is the intra-extent byte offset).
251        let start = phys.saturating_add(ext_offset) as usize;
252        let end = start.saturating_add(num_bytes as usize);
253        let slice = image.get(start..end).ok_or(BtrfsError::Truncated {
254            structure: "regular extent data (out of image)",
255            need: end,
256            have: image.len(),
257        })?;
258        return Ok(slice.to_vec());
259    }
260
261    // Compressed: the whole extent is `ram_bytes` uncompressed; the compressed
262    // source occupies the extent's chunk region. btrfs stores the *compressed*
263    // bytes as one unit; decompress it to `ram_bytes`, then take the file's
264    // `[ext_offset, ext_offset + num_bytes)` window.
265    // The compressed source length is disk_num_bytes; for our reader we take the
266    // compressed bytes up to the image end and let the codec stop at frame end.
267    let start = phys as usize;
268    // Cap the compressed source read at ram_bytes' worst case is not knowable
269    // here; the codecs are self-terminating (zlib/zstd stop at stream end, LZO
270    // uses its own length header), so pass from `start` to image end.
271    let src = image.get(start..).ok_or(BtrfsError::Truncated {
272        structure: "compressed extent source (out of image)",
273        need: start,
274        have: image.len(),
275    })?;
276    let whole = decompress_extent(compression, src, ram_bytes, sectorsize)?;
277
278    let win_start = ext_offset as usize;
279    let win_end = win_start.saturating_add(num_bytes as usize);
280    let window = whole
281        .get(win_start..win_end.min(whole.len()))
282        .unwrap_or(&[]);
283    Ok(window.to_vec())
284}
285
286/// Decompress one extent's `src` bytes to at most `ram_bytes` output using
287/// `algo`. `sectorsize` is needed only for LZO's per-sector framing.
288///
289/// Batteries-included: zlib / zstd / btrfs-LZO are always compiled in.
290///
291/// # Errors
292///
293/// - [`BtrfsError::AllocationBomb`] if `ram_bytes` is implausibly large versus
294///   `src` (a lying decompressed size, capped before any pre-allocation).
295/// - [`BtrfsError::Truncated`] if a codec fails (malformed compressed data) or
296///   the algorithm is unsupported (`Compression::Other`), naming the value.
297pub fn decompress_extent(
298    algo: Compression,
299    src: &[u8],
300    ram_bytes: u64,
301    sectorsize: u32,
302) -> Result<Vec<u8>, BtrfsError> {
303    if algo == Compression::None {
304        // No compression: the source *is* the content, truncated to ram_bytes.
305        let take = (ram_bytes as usize).min(src.len());
306        return Ok(src.get(..take).unwrap_or(src).to_vec());
307    }
308
309    // Allocation-bomb guard: a compressed stream expands by a bounded ratio.
310    // Reject a claimed ram_bytes wildly larger than the source could produce.
311    // btrfs compressed extents are per-128KiB; a 1024× cap over the source (plus
312    // a small floor for tiny inputs) is far above any real ratio yet blocks a
313    // multi-GiB claim from a few-byte source.
314    let bound = (src.len() as u64)
315        .saturating_mul(1024)
316        .saturating_add(1 << 20);
317    guard_alloc("compressed ram_bytes", ram_bytes, bound)?;
318    let cap = ram_bytes as usize;
319
320    match algo {
321        Compression::Zlib => decompress_zlib(src, cap),
322        Compression::Zstd => decompress_zstd(src, cap),
323        Compression::Lzo => decompress_lzo_btrfs(src, cap, sectorsize),
324        // An unknown/unsupported codec fails loud with the offending byte.
325        // `Compression::None` returned early at the top of the function, so the
326        // wildcard here only ever binds `Other(b)`; the `0` fallback byte is a
327        // guard that cannot fire.
328        other => Err(BtrfsError::Truncated {
329            structure: "extent compression algorithm (unsupported codec)",
330            need: usize::from(unsupported_codec_byte(other)),
331            have: 0,
332        }),
333    }
334}
335
336/// The raw byte to report for an unsupported codec. Only `Other(b)` reaches this
337/// (both `None` — early-returned — and the three real codecs are handled), so
338/// the non-`Other` fallback is an unreachable guard.
339fn unsupported_codec_byte(algo: Compression) -> u8 {
340    match algo {
341        Compression::Other(b) => b,
342        _ => 0, // cov:unreachable: only Compression::Other reaches decompress_extent's wildcard arm; None returns early and Zlib/Lzo/Zstd are matched before it
343    }
344}
345
346/// Decompress a plain zlib stream (btrfs zlib extent) to at most `cap` bytes.
347fn decompress_zlib(src: &[u8], cap: usize) -> Result<Vec<u8>, BtrfsError> {
348    let dec = flate2::read::ZlibDecoder::new(src);
349    let mut out = Vec::with_capacity(cap);
350    // Bound the read at cap so a corrupt stream cannot spin producing bytes.
351    match dec.take(cap as u64).read_to_end(&mut out) {
352        Ok(_) => Ok(out),
353        Err(_) => Err(BtrfsError::Truncated {
354            structure: "zlib extent stream (decompression failed)",
355            need: cap,
356            have: out.len(),
357        }),
358    }
359}
360
361/// Decompress a single zstd frame (btrfs zstd extent) to at most `cap` bytes.
362fn decompress_zstd(src: &[u8], cap: usize) -> Result<Vec<u8>, BtrfsError> {
363    let dec = ruzstd::decoding::StreamingDecoder::new(src).map_err(|_| BtrfsError::Truncated {
364        structure: "zstd extent frame (bad frame header)",
365        need: cap,
366        have: 0,
367    })?;
368    let mut out = Vec::with_capacity(cap);
369    match dec.take(cap as u64).read_to_end(&mut out) {
370        Ok(_) => Ok(out),
371        Err(_) => Err(BtrfsError::Truncated {
372            structure: "zstd extent frame (decompression failed)",
373            need: cap,
374            have: out.len(),
375        }),
376    }
377}
378
379/// Decompress a btrfs-framed LZO extent to at most `cap` bytes.
380///
381/// btrfs LZO framing (kernel `fs/btrfs/lzo.c`): a 4-byte LE32 header = the total
382/// compressed size (including the header), then one or more segments. Each
383/// segment is a 4-byte LE32 length + an LZO1X block yielding up to one sector of
384/// output. A **segment header never crosses a `sectorsize` boundary** — if the 4
385/// header bytes would not fit in the current sector, the writer pads with zeros
386/// to the next sector boundary; the segment *payload* is contiguous (may span
387/// sectors). Each LZO1X block is decoded via the `lzo` crate.
388fn decompress_lzo_btrfs(src: &[u8], cap: usize, sectorsize: u32) -> Result<Vec<u8>, BtrfsError> {
389    const LZO_LEN: usize = 4;
390    let sectorsize = (sectorsize as usize).max(1);
391
392    let total = le_u64(&le32_padded(src, 0), 0) as usize; // total compressed incl. header
393    let total = total.min(src.len()).max(LZO_LEN);
394    let mut cur = LZO_LEN;
395    let mut out: Vec<u8> = Vec::with_capacity(cap);
396    // A generous per-segment output buffer: one sector, plus LZO worst-case slack.
397    let seg_out_cap = sectorsize
398        .saturating_add(sectorsize / 16)
399        .saturating_add(64);
400
401    while cur < total && out.len() < cap {
402        // The segment header must fit inside the current sector; if not, skip
403        // the padding to the next sector boundary.
404        if cur % sectorsize + LZO_LEN > sectorsize {
405            let next = cur
406                .checked_add(sectorsize - cur % sectorsize)
407                .unwrap_or(total);
408            cur = next;
409            if cur >= total {
410                break; // cov:unreachable: btrfs always places a segment (or ends) before this padding runs off the total; guarded for a corrupt frame
411            }
412        }
413
414        let Some(seg_len_bytes) = src.get(cur..cur + LZO_LEN) else {
415            break; // cov:unreachable: cur < total <= src.len() and the header fits the sector by the check above, so the 4 bytes are present
416        };
417        let seg_len = u32::from_le_bytes([
418            seg_len_bytes[0],
419            seg_len_bytes[1],
420            seg_len_bytes[2],
421            seg_len_bytes[3],
422        ]) as usize;
423        cur += LZO_LEN;
424
425        if seg_len == 0 {
426            break; // cov:unreachable: a real btrfs LZO frame has no zero-length segment; guarded so a corrupt count cannot loop forever
427        }
428        let Some(seg) = src.get(cur..cur.saturating_add(seg_len).min(src.len())) else {
429            break; // cov:unreachable: the slice is clamped to src.len(), so get() always returns Some
430        };
431        cur = cur.saturating_add(seg_len);
432
433        let mut dst = vec![0u8; seg_out_cap];
434        match lzo::decompress_into(seg, &mut dst) {
435            Ok(n) => {
436                let want = (cap - out.len()).min(n);
437                out.extend_from_slice(dst.get(..want).unwrap_or(&dst[..n]));
438            }
439            Err(_) => {
440                return Err(BtrfsError::Truncated {
441                    structure: "LZO extent segment (decompression failed)",
442                    need: seg_len,
443                    have: out.len(),
444                });
445            }
446        }
447    }
448
449    out.truncate(cap);
450    Ok(out)
451}
452
453/// Read a 4-byte LE prefix from `src` at `off` into an 8-byte buffer so
454/// [`le_u64`] can decode it uniformly (the high 4 bytes are 0).
455fn le32_padded(src: &[u8], off: usize) -> [u8; 8] {
456    let mut b = [0u8; 8];
457    if let Some(s) = src.get(off..off + 4) {
458        b[..4].copy_from_slice(s);
459    }
460    b
461}
462
463/// Reject a length/count field that exceeds `bound` as an allocation bomb.
464fn guard_alloc(field: &'static str, claimed: u64, bound: u64) -> Result<(), BtrfsError> {
465    if claimed > bound {
466        return Err(BtrfsError::AllocationBomb {
467            field,
468            claimed,
469            bound,
470        });
471    }
472    Ok(())
473}
474
475/// Assemble the file content for objectid `ino` over the whole `image`: locate
476/// the FS_TREE root from the root tree, read its leaf, and delegate to
477/// [`read_file_from_leaf`].
478///
479/// # Errors
480///
481/// - Any error from [`crate::fs_tree_root`] / [`read_node`] locating and
482///   reading the FS_TREE leaf, or from [`read_file_from_leaf`].
483pub fn read_file(
484    image: &[u8],
485    sb: &Superblock,
486    map: &ChunkMap,
487    ino: u64,
488) -> Result<Vec<u8>, BtrfsError> {
489    let leaf = fs_tree_leaf(image, sb, map)?;
490    read_file_from_leaf(&leaf, image, map, sb.sectorsize, ino)
491}
492
493/// Resolve `path` from the FS_TREE root directory and read the resolved inode's
494/// content over the whole `image`.
495///
496/// # Errors
497///
498/// - [`BtrfsError::Truncated`] naming the path if it does not resolve (a loud
499///   miss, never an empty file).
500/// - Any error from locating/reading the FS_TREE leaf or assembling content.
501pub fn read_by_path_content(
502    image: &[u8],
503    sb: &Superblock,
504    map: &ChunkMap,
505    path: &str,
506) -> Result<Vec<u8>, BtrfsError> {
507    let leaf = fs_tree_leaf(image, sb, map)?;
508    let (ino, _inode) = read_by_path(&leaf, path).ok_or(BtrfsError::Truncated {
509        structure: "path (unresolved in FS_TREE)",
510        need: path.len(),
511        have: 0,
512    })?;
513    read_file_from_leaf(&leaf, image, map, sb.sectorsize, ino)
514}
515
516/// Locate and read the FS_TREE leaf node for the whole image.
517fn fs_tree_leaf(image: &[u8], sb: &Superblock, map: &ChunkMap) -> Result<Node, BtrfsError> {
518    let root = crate::fstree::fs_tree_root(image, sb, map)?;
519    read_node(image, sb, map, root.bytenr)
520}