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}