Skip to main content

dar/
lib.rs

1//! Pure-Rust reader for Denis Corbin DAR (Disk ARchiver) archives.
2//!
3//! Supports DAR formats 7–11 (produced by dar 2.3–2.8) and the legacy ≤7 grammar.
4//! Passware Kit Mobile produces format-9 archives; dar 2.8.5 produces 11.3.
5//! Entries and the catalogue compressed with gzip, bzip2, xz, zstd, lz4 or lzo
6//! are transparently decompressed (pure-Rust; each an optional feature, all on by
7//! default); encryption is not decoded.
8//!
9//! ## Format sketch
10//!
11//! ```text
12//! Slice header:
13//!   [4]  magic = 00 00 00 7b  (SAUV_MAGIC_NUMBER = 123, big-endian u32)
14//!   [10] internal_name label
15//!   [1]  flag  [1]  ext_char
16//!   TLV list:  infinint(count) + count × (u16 type + infinint len + data)
17//!   ← archive_origin: all catalog archive_offset values are relative to here
18//!
19//! Archive body:
20//!   escaped sequences (seqt_file, seqt_saved, …) + raw file bytes
21//!
22//! Catalog  (located by seqt_catalogue escape: AD FD EA 77 21 43):
23//!   [10] label  +  (NUL working-dir path, format 11.1+ only)  +  entries
24//!
25//!   Each entry: cat_sig byte where (cat_sig & 0x1f | 0x60) gives type
26//!     'd' directory  → NUL-name + inode [+ FSA]  (push to dir stack)
27//!     'f' file       → NUL-name + inode [+ FSA] + file-specific fields
28//!     'z' EOD        → pop dir stack; depth=0 → done
29//! ```
30//!
31//! ## Key non-obvious invariants
32//!
33//! - **Infinint**: variable-length. The common form is 5 bytes
34//!   (`0x80 XX XX XX XX`, a big-endian u32); timestamps past 2^32 use the
35//!   9-byte `0x40` form (big-endian u64). Encodings wider than 64 bits are
36//!   rejected as corrupt — this reader decodes to `u64` or errors, never
37//!   truncates.
38//! - **Permissions**: 2-byte big-endian u16, *not* an infinint.
39//! - **Timestamps**: format 8 stores a bare seconds infinint; format 9+ prefix
40//!   a unit byte (`'s'`/`'u'`/`'n'`) and add a sub-second infinint for `'u'`/`'n'`.
41//! - **FSA** (format 9+ only): inode flag bit `0x10` (FSA-full) adds inode
42//!   infinints and an FSA block; format 8 has no FSA.
43//! - **archive_offset**: points *directly* to the raw file bytes, not to the
44//!   data-section header that precedes them in the body stream.
45//!   `seek(archive_origin + archive_offset)` then `read(stored_size)`.
46//!
47//! Full format notes: `docs/implementation-notes.md`.
48
49// Production code is panic-free (no unwrap/expect, enforced by the workspace
50// lints); tests legitimately use them.
51#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
52
53use std::fs::File;
54use std::io::{Cursor, Read, Seek, SeekFrom, Write};
55use std::path::{Path, PathBuf};
56
57use thiserror::Error;
58
59// Adapt `DarReader` to the forensic-vfs `FileSystem` contract (behind the `vfs`
60// feature) so a DAR archive's file tree composes as `Arc<dyn FileSystem>`.
61#[cfg(feature = "vfs")]
62mod vfs;
63#[cfg(feature = "vfs")]
64pub use vfs::DarVfs;
65
66/// `00 00 00 7b` — DAR magic (SAUV_MAGIC_NUMBER = 123, big-endian u32).
67const DAR_MAGIC: [u8; 4] = [0x00, 0x00, 0x00, 0x7b];
68
69/// Upper bound on the compressed catalogue bytes read from the archive tail and
70/// on the inflated catalogue, guarding against a decompression bomb (per-file
71/// streams need no such constant — they are bounded by the entry's known size).
72const MAX_CATALOGUE_COMPRESSED: u64 = 512 * 1024 * 1024;
73const MAX_CATALOGUE_INFLATED: u64 = 1024 * 1024 * 1024;
74
75/// Upper bound on a per-file CRC width (libdar uses 4 bytes per gigabyte, so
76/// 64 KiB covers a 16 TiB file); a larger declared width is treated as corrupt.
77const MAX_CRC_SIZE: u64 = 64 * 1024;
78
79/// Upper bound on the per-block uncompressed block size (`compr_bs`); a header
80/// declaring more is treated as not block-compressed (allocation-bomb guard).
81/// dar's default is 240 KiB; 256 MiB is far beyond any practical setting.
82const MAX_BLOCK_SIZE: u64 = 256 * 1024 * 1024;
83
84/// Escape sequence marking the catalog: `AD FD EA 77 21 43`.
85const SEQT_CATALOGUE: [u8; 6] = [0xAD, 0xFD, 0xEA, 0x77, 0x21, 0x43];
86
87/// First archive format with an in-place (working-directory) path in the
88/// catalog header — `archive_version(11,1)` → `value() = 11*256 + 1`.
89/// Formats 8, 9, 10 and 11.0 have no such field.
90const FORMAT_11_1: u32 = 11 * 256 + 1;
91
92/// Errors returned by [`DarReader`].
93#[derive(Debug, Error)]
94pub enum DarError {
95    #[error("I/O error: {0}")]
96    Io(#[from] std::io::Error),
97    #[error("not a DAR archive")]
98    NotADar,
99    #[error("corrupt archive: {0}")]
100    Corrupt(String),
101    #[error("entry not found: '{0}'")]
102    EntryNotFound(String),
103}
104
105/// Outcome of verifying a file entry's stored CRC against its decompressed data
106/// (see [`DarReader::verify`]). CRC values are lowercase hex.
107#[derive(Debug, Clone, PartialEq, Eq)]
108#[cfg_attr(feature = "serde", derive(serde::Serialize))]
109pub enum CrcStatus {
110    /// The stored CRC matches the data.
111    Match,
112    /// The stored CRC disagrees with the data — consistent with corruption or
113    /// tampering of the archived bytes.
114    Mismatch {
115        /// CRC recorded in the catalogue (lowercase hex).
116        stored: String,
117        /// CRC computed over the decompressed data (lowercase hex).
118        computed: String,
119    },
120    /// No CRC is stored for this entry (edition-1 archives record none), so
121    /// integrity cannot be checked.
122    NotStored,
123}
124
125impl core::fmt::Display for CrcStatus {
126    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
127        match self {
128            CrcStatus::Match => f.write_str("CRC match"),
129            CrcStatus::Mismatch { stored, computed } => {
130                write!(f, "CRC mismatch: stored {stored}, computed {computed}")
131            }
132            CrcStatus::NotStored => f.write_str("no CRC stored"),
133        }
134    }
135}
136
137/// The kind of filesystem object a catalog entry describes.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139#[cfg_attr(feature = "serde", derive(serde::Serialize))]
140pub enum EntryKind {
141    File,
142    Directory,
143    Symlink,
144    NamedPipe,
145    Socket,
146    CharDevice,
147    BlockDevice,
148    Hardlink,
149    /// A catalog entry type this reader does not model (the raw `cat_sig` letter).
150    Unknown(char),
151}
152
153/// Metadata about one archived filesystem object.
154///
155/// Paths and symlink targets are exposed as raw bytes — DAR (like the
156/// filesystems it archives) does not guarantee UTF-8, and a forensic reader
157/// must never lose or reject a byte-exact name. Use [`DarEntry::path_lossy`] for
158/// display.
159#[derive(Debug, Clone)]
160#[cfg_attr(feature = "serde", derive(serde::Serialize))]
161pub struct DarEntry {
162    /// Path as stored, raw bytes — may not be valid UTF-8. In JSON this is the
163    /// lossy-UTF-8 display string (use the field directly for byte-exact data).
164    #[cfg_attr(feature = "serde", serde(serialize_with = "serialize_bytes_lossy"))]
165    pub path: Vec<u8>,
166    /// What kind of filesystem object this entry describes.
167    pub kind: EntryKind,
168    /// Uncompressed size in bytes (0 for entries with no data).
169    pub size: u64,
170    /// Owner user id.
171    pub uid: u64,
172    /// Owner group id.
173    pub gid: u64,
174    /// Permission bits (the low bits of the mode).
175    pub mode: u16,
176    /// Access time, seconds since the Unix epoch.
177    pub atime: i64,
178    /// Modification time, seconds since the Unix epoch.
179    pub mtime: i64,
180    /// Status-change time, seconds since the Unix epoch; `None` for formats
181    /// before 8, which do not record it.
182    pub ctime: Option<i64>,
183    /// Target of a symbolic link, raw bytes; `None` for non-symlinks. In JSON
184    /// this is the lossy-UTF-8 display string (or null).
185    #[cfg_attr(feature = "serde", serde(serialize_with = "serialize_opt_bytes_lossy"))]
186    pub symlink_target: Option<Vec<u8>>,
187}
188
189impl DarEntry {
190    /// The path decoded as lossy UTF-8 (invalid byte sequences become U+FFFD).
191    #[must_use]
192    pub fn path_lossy(&self) -> std::borrow::Cow<'_, str> {
193        String::from_utf8_lossy(&self.path)
194    }
195}
196
197/// Serialize raw path/target bytes as a lossy-UTF-8 string for JSON export.
198/// The byte-exact value remains available via the typed field; this is a
199/// human-readable display projection (serde_json escapes control characters).
200#[cfg(feature = "serde")]
201fn serialize_bytes_lossy<S: serde::Serializer>(bytes: &[u8], s: S) -> Result<S::Ok, S::Error> {
202    s.serialize_str(&String::from_utf8_lossy(bytes))
203}
204
205// serde's `serialize_with` calls this with `&self.field`, so the signature must
206// take `&Option<_>` (not `Option<&_>`); the lint does not apply here.
207#[cfg(feature = "serde")]
208#[allow(clippy::ref_option)]
209fn serialize_opt_bytes_lossy<S: serde::Serializer>(
210    target: &Option<Vec<u8>>,
211    s: S,
212) -> Result<S::Ok, S::Error> {
213    match target {
214        Some(bytes) => s.serialize_some(&String::from_utf8_lossy(bytes)),
215        None => s.serialize_none(),
216    }
217}
218
219#[derive(Debug, Clone)]
220struct EntryRef {
221    path: Vec<u8>,
222    kind: EntryKind,
223    size: u64,
224    uid: u64,
225    gid: u64,
226    mode: u16,
227    atime: i64,
228    mtime: i64,
229    ctime: Option<i64>,
230    symlink_target: Option<Vec<u8>>,
231    archive_offset: u64,
232    stored_size: u64,
233    compression: u8,
234    /// Stored per-file data CRC (raw bytes); `None` when the format records none
235    /// (edition 1) or the width is zero.
236    crc: Option<Vec<u8>>,
237}
238
239impl EntryRef {
240    /// Project the internal entry into the public [`DarEntry`] (one clone of the
241    /// owned path/target fields).
242    fn to_dar_entry(&self) -> DarEntry {
243        DarEntry {
244            path: self.path.clone(),
245            kind: self.kind,
246            size: self.size,
247            uid: self.uid,
248            gid: self.gid,
249            mode: self.mode,
250            atime: self.atime,
251            mtime: self.mtime,
252            ctime: self.ctime,
253            symlink_target: self.symlink_target.clone(),
254        }
255    }
256}
257
258/// Read-only DAR archive reader.
259pub struct DarReader<R: Read + Seek> {
260    inner: R,
261    /// Byte position immediately after the slice header TLV block.
262    /// `archive_origin + archive_offset` = absolute position of raw file bytes.
263    archive_origin: u64,
264    /// Archive format major version (`value() >> 8`). Format 1 stores no
265    /// per-entry `storage_size`, so a compressed format-1 entry is decoded by
266    /// streaming the codec to its natural end rather than reading a fixed length.
267    format_major: u32,
268    /// Whether the catalog parsed to a clean root EOD (see [`DarReader::is_complete`]).
269    complete: bool,
270    /// Uncompressed block size from the header (`FLAG_HAS_COMPRESS_BS`); non-zero
271    /// means the archive uses dar's per-block compression framing, zero means a
272    /// single codec stream. Governs both the catalogue and every entry.
273    compr_bs: u64,
274    entries: Vec<EntryRef>,
275}
276
277impl<R: Read + Seek> DarReader<R> {
278    // The archive/slice-header parser is one cohesive state machine; splitting
279    // it would scatter the format logic across helpers and hurt readability.
280    #[allow(clippy::too_many_lines)]
281    pub fn open(mut reader: R) -> Result<Self, DarError> {
282        let mut magic = [0u8; 4];
283        reader
284            .read_exact(&mut magic)
285            .map_err(|_| DarError::NotADar)?;
286        if magic != DAR_MAGIC {
287            return Err(DarError::NotADar);
288        }
289
290        let mut label = [0u8; 10];
291        reader.read_exact(&mut label)?; // internal_name label
292        let _flag = read_u8(&mut reader)?; // slice flag ('T' terminal / 'N' / 'E')
293        let extension = read_u8(&mut reader)?; // 'T' = TLV (format 8+); 'N'/'S' = legacy (<= 7)
294
295        // Format 8+ carries a TLV list and a `seqt_catalogue` escape; format <= 7
296        // has neither — its catalogue is located via the end `terminateur` trailer
297        // (libdar header.cpp extension handling; terminateur.cpp).
298        let entries;
299        let archive_origin;
300        let format_major;
301        let complete;
302        let compr_bs;
303        if extension == b'T' {
304            // TLV list: infinint(count) then count × (u16 type + infinint len + data)
305            let tlv_count = read_infinint(&mut reader).map_err(|e| match e {
306                DarError::Io(_) => DarError::Corrupt("truncated TLV block".into()),
307                other => other,
308            })?;
309            // The archive's data_name (TLV type 0x0003, a 10-byte label) is the
310            // identity the catalogue's ref_data_name points at. It is preserved
311            // when an archive is re-sliced (dar_xform) even though the slice's own
312            // internal_name changes, so it — not the slice label — locates a
313            // tape-marks-off catalogue. For a normally-created archive the two are
314            // identical, so this is a no-op there.
315            let mut data_name: Option<[u8; 10]> = None;
316            for _ in 0..tlv_count {
317                let mut typ = [0u8; 2];
318                reader.read_exact(&mut typ)?;
319                let len = read_infinint(&mut reader)?;
320                if typ == [0x00, 0x03] && len == 10 {
321                    let mut dn = [0u8; 10];
322                    reader.read_exact(&mut dn)?;
323                    data_name = Some(dn);
324                } else {
325                    skip(&mut reader, len)?;
326                }
327            }
328
329            archive_origin = reader.stream_position()?;
330            let format_value = read_format_value(&mut reader);
331            // The archive's global compression algorithm is the byte immediately
332            // after the version string; it tells us whether (and how) the
333            // catalogue stream is compressed. Unreadable → treat as stored.
334            let global_comp = read_u8(&mut reader).unwrap_or(b'n');
335            // The cursor now sits at the command-line string; read on to the
336            // compression block size (zero = single-stream, non-zero = per-block).
337            compr_bs = read_compr_bs(&mut reader, format_value >> 8);
338            reader.seek(SeekFrom::Start(archive_origin))?;
339
340            // true → seqt_catalogue tape mark found (catalog has label + maybe path);
341            // false → located by its ref_data_name label (tape marks off, e.g. Passware).
342            let via_escape = find_catalogue(&mut reader, data_name.as_ref().unwrap_or(&label))?;
343            format_major = format_value >> 8;
344            if via_escape && is_compressed(global_comp) {
345                // The catalogue is a single stream compressed with the archive
346                // codec, beginning right after the seqt_catalogue escape and
347                // running to the trailer. Inflate it, then parse from the
348                // plaintext buffer — which begins with the in-catalog label and
349                // optional in-place path, exactly like the uncompressed case.
350                let mut compressed = Vec::new();
351                reader
352                    .by_ref()
353                    .take(MAX_CATALOGUE_COMPRESSED)
354                    .read_to_end(&mut compressed)?;
355                let inflated = inflate_catalogue(&compressed, global_comp, compr_bs)?;
356                let mut cur = Cursor::new(inflated);
357                skip(&mut cur, 10)?; // catalog label
358                if format_value >= FORMAT_11_1 {
359                    skip_nul_string(&mut cur)?;
360                }
361                (entries, complete) = parse_catalog(&mut cur, format_major, global_comp)?;
362            } else {
363                // The catalogue opens with a 10-byte label and, from format 11.1,
364                // an in-place path NUL-string before the entries. When located by
365                // the seqt_catalogue escape the reader sits before the label; when
366                // located by ref_data_name match (tape marks off) scan_window has
367                // already consumed the matched label, so only the path remains.
368                if via_escape {
369                    skip(&mut reader, 10)?; // catalog label
370                }
371                if format_value >= FORMAT_11_1 {
372                    skip_nul_string(&mut reader)?;
373                }
374                (entries, complete) = parse_catalog(&mut reader, format_major, global_comp)?;
375            }
376        } else if extension == b'N' || extension == b'S' {
377            // Legacy editions (<= 7) predate block compression — always a stream.
378            compr_bs = 0;
379            if extension == b'S' {
380                read_infinint(&mut reader)?; // slice size (multi-slice header); unused
381            }
382            archive_origin = reader.stream_position()?;
383            let format_value = read_format_value(&mut reader); // 3-byte edition: value = major*256
384            format_major = format_value >> 8;
385            // The global compression char follows the version string (same as
386            // format 8+). Formats <= 7 carry no per-entry compression byte, so
387            // this single char governs both the catalogue and every entry's data.
388            let global_comp = read_u8(&mut reader).unwrap_or(b'n');
389            let cat_offset = read_terminateur(&mut reader)?;
390            let cat_start = archive_origin
391                .checked_add(cat_offset)
392                .ok_or_else(|| DarError::Corrupt("catalogue offset overflows".into()))?;
393            let end = reader.seek(SeekFrom::End(0))?;
394            if cat_start >= end {
395                return Err(DarError::Corrupt(format!(
396                    "catalogue start {cat_start} past archive end {end}"
397                )));
398            }
399            reader.seek(SeekFrom::Start(cat_start))?;
400            // Legacy catalogue: no 10-byte label, no path — entries begin here.
401            // When the archive is compressed, the catalogue is a single codec
402            // stream (the terminateur addresses its start); inflate it first.
403            if is_compressed(global_comp) {
404                let mut compressed = Vec::new();
405                reader
406                    .by_ref()
407                    .take(MAX_CATALOGUE_COMPRESSED)
408                    .read_to_end(&mut compressed)?;
409                let inflated = inflate_catalogue(&compressed, global_comp, compr_bs)?;
410                (entries, complete) =
411                    parse_catalog(&mut Cursor::new(inflated), format_major, global_comp)?;
412            } else {
413                (entries, complete) = parse_catalog(&mut reader, format_major, global_comp)?;
414            }
415        } else {
416            return Err(DarError::Corrupt(format!(
417                "unknown slice-header extension {extension:#04x}"
418            )));
419        }
420
421        Ok(Self {
422            inner: reader,
423            archive_origin,
424            format_major,
425            complete,
426            compr_bs,
427            entries,
428        })
429    }
430
431    /// Number of catalogue entries, in O(1) — without materialising or cloning
432    /// the entry list (cheap even for a multi-hundred-thousand-entry archive).
433    #[must_use]
434    pub fn entry_count(&self) -> usize {
435        self.entries.len()
436    }
437
438    /// Iterate the catalogue entries lazily, cloning one [`DarEntry`] at a time
439    /// rather than allocating the whole `Vec` up front — for streaming over a
440    /// large archive (hashing, timelining, filtering) without holding every
441    /// entry in memory at once. Use [`entries`](Self::entries) when you want them
442    /// all collected.
443    pub fn iter_entries(&self) -> impl Iterator<Item = DarEntry> + '_ {
444        self.entries.iter().map(EntryRef::to_dar_entry)
445    }
446
447    /// List all archived file entries (path and uncompressed size).
448    pub fn entries(&self) -> Vec<DarEntry> {
449        self.iter_entries().collect()
450    }
451
452    /// Whether the catalog was parsed to a clean end.
453    ///
454    /// `false` means parsing stopped early — typically at a catalog entry type
455    /// this reader does not model (e.g. a hardlink or device node) or at
456    /// corruption — so [`entries`](Self::entries) may be an *incomplete* listing.
457    /// A forensic caller should treat an incomplete listing as "more may exist".
458    #[must_use]
459    pub fn is_complete(&self) -> bool {
460        self.complete
461    }
462
463    /// Verify a file entry's data against the CRC stored in the catalogue,
464    /// decompressing the entry as needed. Returns [`CrcStatus::Match`],
465    /// [`CrcStatus::Mismatch`], or [`CrcStatus::NotStored`]. Unlike a
466    /// verify-on-extract design, this never refuses to hand over the bytes —
467    /// a forensic caller can still [`extract`](Self::extract) data that fails
468    /// its CRC in order to examine the corruption.
469    pub fn verify<P: AsRef<[u8]>>(&mut self, path: P) -> Result<CrcStatus, DarError> {
470        let path = path.as_ref();
471        let stored = self
472            .entries
473            .iter()
474            .find(|e| e.path.as_slice() == path)
475            .ok_or_else(|| DarError::EntryNotFound(String::from_utf8_lossy(path).into_owned()))?
476            .crc
477            .clone();
478        let Some(stored) = stored else {
479            return Ok(CrcStatus::NotStored);
480        };
481        // The CRC covers the plaintext, so verify against the decompressed data.
482        let data = self.extract(path)?;
483        let computed = dar_crc(&data, stored.len());
484        if computed == stored {
485            Ok(CrcStatus::Match)
486        } else {
487            Ok(CrcStatus::Mismatch {
488                stored: to_hex(&stored),
489                computed: to_hex(&computed),
490            })
491        }
492    }
493
494    /// Extract a file by path, streaming its (decompressed) bytes to `out` and
495    /// returning the number of bytes written. Unlike [`extract`](Self::extract),
496    /// this never holds the whole file in memory, so it is safe for multi-GiB
497    /// entries (and composes with hashing, scanning, or writing to disk).
498    pub fn extract_to<P: AsRef<[u8]>, W: Write>(
499        &mut self,
500        path: P,
501        out: &mut W,
502    ) -> Result<u64, DarError> {
503        let path = path.as_ref();
504        let name = String::from_utf8_lossy(path);
505        let entry = self
506            .entries
507            .iter()
508            .find(|e| e.path.as_slice() == path)
509            .ok_or_else(|| DarError::EntryNotFound(name.clone().into_owned()))?
510            .clone();
511
512        // The raw bytes live at archive_origin + archive_offset. Both fields are
513        // attacker-controlled, so the sum is checked and the claimed length
514        // validated against the bytes that actually exist before reading.
515        let start = self
516            .archive_origin
517            .checked_add(entry.archive_offset)
518            .ok_or_else(|| {
519                DarError::Corrupt(format!("'{name}' archive offset overflows file position"))
520            })?;
521        let end = self.inner.seek(SeekFrom::End(0))?;
522        if start > end {
523            return Err(DarError::Corrupt(format!(
524                "'{name}' starts at {start}, past archive end {end}"
525            )));
526        }
527        let available = end - start;
528        self.inner.seek(SeekFrom::Start(start))?;
529
530        // Stored: stream the raw bytes straight through, no buffering.
531        if !is_compressed(entry.compression) {
532            if entry.stored_size > available {
533                return Err(DarError::Corrupt(format!(
534                    "'{name}' claims {} stored bytes but only {available} remain",
535                    entry.stored_size
536                )));
537            }
538            return Ok(std::io::copy(
539                &mut self.inner.by_ref().take(entry.stored_size),
540                out,
541            )?);
542        }
543
544        // Compressed: decode straight to `out`, capped at the declared size so a
545        // forged stream cannot over-inflate (streaming decompression-bomb guard).
546        let mut cap = CapWriter {
547            inner: out,
548            written: 0,
549            max: entry.size,
550        };
551        if self.format_major == 1 {
552            // Format 1 stores no storage_size; the codec stream (dar 1.x is
553            // gzip/zlib-only) runs from the offset to its own natural end.
554            decode_stream(self.inner.by_ref(), entry.compression, &mut cap)?;
555        } else {
556            // 8+/2-7: exactly stored_size compressed bytes on disk.
557            if entry.stored_size > available {
558                return Err(DarError::Corrupt(format!(
559                    "'{name}' claims {} stored bytes but only {available} remain",
560                    entry.stored_size
561                )));
562            }
563            let mut data = vec![0u8; entry.stored_size as usize];
564            self.inner.read_exact(&mut data)?;
565            decode_data(&data[..], entry.compression, self.compr_bs, &mut cap)?;
566        }
567        if cap.written != entry.size {
568            return Err(DarError::Corrupt(format!(
569                "'{name}' decompressed to {} bytes but catalog declares {}",
570                cap.written, entry.size
571            )));
572        }
573        Ok(cap.written)
574    }
575
576    /// Extract a file by path, returning its raw bytes. Buffers the whole entry
577    /// in memory; prefer [`extract_to`](Self::extract_to) for large files.
578    pub fn extract<P: AsRef<[u8]>>(&mut self, path: P) -> Result<Vec<u8>, DarError> {
579        let mut buf = Vec::new();
580        self.extract_to(path, &mut buf)?;
581        Ok(buf)
582    }
583}
584
585// ── Catalog parser ────────────────────────────────────────────────────────────
586
587/// On archives larger than this, the catalog scan starts this many bytes
588/// before EOF (the catalog always lives at the tail), avoiding a full read of
589/// a multi-gigabyte forensic archive before falling back to a full scan.
590const TAIL_SCAN: u64 = 256 * 1024 * 1024;
591
592const CHUNK: usize = 4 * 1024 * 1024;
593// OVERLAP = max(SEQT_CATALOGUE.len(), label.len()) - 1; carries bytes across chunk boundaries.
594const OVERLAP: usize = 9;
595
596/// Scan forward from the current reader position searching for either the
597/// `seqt_catalogue` escape or the archive `label`.
598///
599/// Returns `Some(true)` if the escape was found (reader positioned just after it),
600/// `Some(false)` if the label was found (reader positioned just after it),
601/// `None` if EOF was reached without a match.
602fn scan_window<R: Read + Seek>(
603    r: &mut R,
604    label: &[u8; 10],
605    use_label: bool,
606) -> Result<Option<bool>, DarError> {
607    let mut buf = vec![0u8; CHUNK + OVERLAP];
608    let mut overlap_len: usize = 0;
609    loop {
610        let chunk_file_pos = r.stream_position()?;
611        let n = r.read(&mut buf[overlap_len..overlap_len + CHUNK])?;
612        if n == 0 {
613            break;
614        }
615        let total = overlap_len + n;
616        // buf[0..overlap_len]  → tail of previous chunk (file pos: chunk_file_pos - overlap_len)
617        // buf[overlap_len..total] → newly read bytes
618        let buf_base = chunk_file_pos - overlap_len as u64;
619
620        if let Some(i) = buf[..total]
621            .windows(SEQT_CATALOGUE.len())
622            .position(|w| w == SEQT_CATALOGUE)
623        {
624            r.seek(SeekFrom::Start(
625                buf_base + i as u64 + SEQT_CATALOGUE.len() as u64,
626            ))?;
627            return Ok(Some(true));
628        }
629        if use_label {
630            if let Some(i) = buf[..total]
631                .windows(label.len())
632                .position(|w| w == label.as_ref())
633            {
634                r.seek(SeekFrom::Start(buf_base + i as u64 + label.len() as u64))?;
635                return Ok(Some(false));
636            }
637        }
638
639        let keep = OVERLAP.min(total);
640        buf.copy_within(total - keep..total, 0);
641        overlap_len = keep;
642    }
643    Ok(None)
644}
645
646/// Locate the catalog section and position the reader at its first entry.
647///
648/// Returns `true` when the `seqt_catalogue` escape is found — the caller then
649/// skips the 10-byte in-catalog label and (format 11.1+) the path NUL string.
650/// The escape is a *sequential-read tape mark*; it is present only when the
651/// archive was written with tape marks (libdar's default).
652///
653/// Returns `false` when the catalog is located by its `ref_data_name` label
654/// directly. Archives written with tape marks disabled (e.g. by Passware Kit
655/// Mobile, equivalent to `dar -at`) omit the escape; their catalog still begins
656/// with the 10-byte `ref_data_name`, which equals the slice `label`, so scanning
657/// for `label` in the tail finds it — a structural marker, not a heuristic.
658///
659/// Returns `Err(Corrupt)` when neither marker is found.
660///
661/// Strategy: DAR catalogs always live at the tail of the archive.  On forensic
662/// archives ≥ 256 MiB we jump straight to the last 256 MiB and scan forward
663/// from there, then fall back to a full forward scan from `archive_origin` if
664/// needed.  This reduces the I/O for a 92 GiB archive from ~99 GiB to ~107 MiB.
665fn find_catalogue<R: Read + Seek>(r: &mut R, label: &[u8; 10]) -> Result<bool, DarError> {
666    find_catalogue_within(r, label, TAIL_SCAN)
667}
668
669/// Implementation of [`find_catalogue`] with the tail-scan window size as a
670/// parameter so the full-scan fallback can be exercised without a 256 MiB
671/// fixture.
672fn find_catalogue_within<R: Read + Seek>(
673    r: &mut R,
674    label: &[u8; 10],
675    tail_scan: u64,
676) -> Result<bool, DarError> {
677    // All-zero labels cannot be used as a reliable catalog marker (too common
678    // in zero-padded archive bodies).
679    let use_label = !label.iter().all(|&b| b == 0);
680
681    let archive_origin = r.stream_position()?;
682    let file_end = r.seek(SeekFrom::End(0))?;
683
684    if file_end <= archive_origin {
685        return Err(DarError::Corrupt("archive body too short".into()));
686    }
687
688    // Jump to at most tail_scan bytes before end; for small files this equals archive_origin.
689    let tail_start = archive_origin.max(file_end.saturating_sub(tail_scan));
690    r.seek(SeekFrom::Start(tail_start))?;
691
692    if let Some(result) = scan_window(r, label, use_label)? {
693        return Ok(result);
694    }
695
696    // Tail scan missed.  Fall back to a full scan from archive_origin.
697    if tail_start > archive_origin {
698        r.seek(SeekFrom::Start(archive_origin))?;
699        if let Some(result) = scan_window(r, label, use_label)? {
700            return Ok(result);
701        }
702    }
703
704    Err(DarError::Corrupt("seqt_catalogue not found".into()))
705}
706
707/// The byte length of one slice's header (`magic + label + flag + extension +
708/// optional TLV / slice-size`). Every slice of a multi-volume archive begins
709/// with this header; slice 1's header is the archive's own slice header, while
710/// later slices' headers are stripped so only their data regions join the
711/// logical stream. Mirrors the header prefix parsed by [`DarReader::open`].
712fn slice_header_len<R: Read + Seek>(r: &mut R) -> Result<u64, DarError> {
713    let mut magic = [0u8; 4];
714    r.read_exact(&mut magic).map_err(|_| DarError::NotADar)?;
715    if magic != DAR_MAGIC {
716        return Err(DarError::NotADar);
717    }
718    skip(r, 10)?; // internal_name label
719    let _flag = read_u8(r)?;
720    match read_u8(r)? {
721        b'T' => {
722            // TLV list: infinint(count) then count × (u16 type + infinint len + data).
723            let tlv_count = read_infinint(r)?;
724            for _ in 0..tlv_count {
725                skip(r, 2)?;
726                let len = read_infinint(r)?;
727                skip(r, len)?;
728            }
729        }
730        b'N' => {}
731        b'S' => {
732            read_infinint(r)?; // legacy slice-size field
733        }
734        other => {
735            return Err(DarError::Corrupt(format!(
736                "unknown slice-header extension {other:#04x}"
737            )));
738        }
739    }
740    Ok(r.stream_position()?)
741}
742
743/// One slice's contribution to the logical archive stream.
744struct SliceSpan {
745    file: File,
746    /// Byte offset within the slice file where this slice's contributed data
747    /// begins — 0 for slice 1 (its header is kept), the header length otherwise.
748    file_data_start: u64,
749    /// Where this slice begins in the logical (de-sliced) stream.
750    logical_start: u64,
751    /// Number of logical bytes this slice contributes.
752    logical_len: u64,
753}
754
755/// A `Read + Seek` view over a multi-volume DAR archive (`base.1.dar`,
756/// `base.2.dar`, …) presenting the slices as one contiguous logical stream:
757/// slice 1 in full (its header is the archive's slice header) followed by every
758/// later slice with its own slice header stripped. This is byte-identical to the
759/// equivalent unsliced archive, so the catalogue and per-entry offsets resolve
760/// across slice boundaries with no other change to the reader.
761pub struct SliceReader {
762    slices: Vec<SliceSpan>,
763    pos: u64,
764    total: u64,
765}
766
767impl SliceReader {
768    /// Build the logical stream from an explicit, ordered list of slice files
769    /// (`base.1.dar`, `base.2.dar`, …); the first path is slice 1.
770    pub fn open(paths: &[PathBuf]) -> Result<Self, DarError> {
771        if paths.is_empty() {
772            return Err(DarError::Corrupt("no slices provided".into()));
773        }
774        let mut slices = Vec::with_capacity(paths.len());
775        let mut logical_start = 0u64;
776        for (i, path) in paths.iter().enumerate() {
777            let mut file = File::open(path)?;
778            let len = file.seek(SeekFrom::End(0))?;
779            file.seek(SeekFrom::Start(0))?;
780            let file_data_start = if i == 0 {
781                0
782            } else {
783                slice_header_len(&mut file)?
784            };
785            // libdar's SAR layer ends every slice with a 1-byte flag ('N' = a slice
786            // follows, 'T' = terminal). On a non-terminal slice that flag sits in
787            // the middle of the file data and must be dropped; the terminal slice's
788            // flag is the archive's own final byte and is kept — so the logical
789            // stream ends byte-identically to an unsliced archive and the
790            // end-relative terminateur (tape-marks-off catalogues) still resolves.
791            let trailer = u64::from(i + 1 < paths.len());
792            if len < file_data_start + trailer {
793                return Err(DarError::Corrupt(
794                    "slice smaller than its header + flag".into(),
795                ));
796            }
797            let logical_len = len - file_data_start - trailer;
798            slices.push(SliceSpan {
799                file,
800                file_data_start,
801                logical_start,
802                logical_len,
803            });
804            logical_start = logical_start
805                .checked_add(logical_len)
806                .ok_or_else(|| DarError::Corrupt("total slice length overflows".into()))?;
807        }
808        Ok(Self {
809            slices,
810            pos: 0,
811            total: logical_start,
812        })
813    }
814}
815
816impl Read for SliceReader {
817    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
818        // Fill `buf` across slice boundaries — only stopping short at end-of-archive
819        // or an underlying short read — so callers that issue a single `read()` and
820        // assume a full buffer (as they may for an in-memory `Cursor`) behave
821        // identically over a sliced archive.
822        let mut written = 0;
823        while written < buf.len() {
824            let pos = self.pos;
825            // The first slice whose data extends past `pos` contains it (slices are
826            // contiguous from 0); no such slice means end-of-archive.
827            let Some(idx) = self
828                .slices
829                .iter()
830                .position(|s| pos < s.logical_start + s.logical_len)
831            else {
832                break;
833            };
834            let n = {
835                let span = &mut self.slices[idx];
836                let within = pos - span.logical_start;
837                let want = (buf.len() - written).min((span.logical_len - within) as usize);
838                span.file
839                    .seek(SeekFrom::Start(span.file_data_start + within))?;
840                span.file.read(&mut buf[written..written + want])?
841            };
842            if n == 0 {
843                break; // truncated slice: stop, do not spin
844            }
845            self.pos += n as u64;
846            written += n;
847        }
848        Ok(written)
849    }
850}
851
852impl Seek for SliceReader {
853    fn seek(&mut self, from: SeekFrom) -> std::io::Result<u64> {
854        let target: i128 = match from {
855            SeekFrom::Start(n) => i128::from(n),
856            SeekFrom::End(n) => i128::from(self.total) + i128::from(n),
857            SeekFrom::Current(n) => i128::from(self.pos) + i128::from(n),
858        };
859        if target < 0 {
860            return Err(std::io::Error::new(
861                std::io::ErrorKind::InvalidInput,
862                "seek before start of archive",
863            ));
864        }
865        self.pos = target as u64;
866        Ok(self.pos)
867    }
868}
869
870impl DarReader<SliceReader> {
871    /// Open a multi-volume (sliced) archive from its basename: `base` resolves
872    /// `base.1.dar`, `base.2.dar`, … until a slice is missing. The catalogue
873    /// lives in the last slice and entry data may span slices — both are handled
874    /// transparently. Errors if no `base.1.dar` exists.
875    pub fn open_slices(basename: &Path) -> Result<Self, DarError> {
876        let parent = basename
877            .parent()
878            .filter(|p| !p.as_os_str().is_empty())
879            .unwrap_or_else(|| Path::new("."));
880        let stem = basename
881            .file_name()
882            .and_then(|s| s.to_str())
883            .ok_or_else(|| DarError::Corrupt("invalid slice basename".into()))?;
884        let mut paths = Vec::new();
885        let mut n = 1u64;
886        loop {
887            let p = parent.join(format!("{stem}.{n}.dar"));
888            if !p.exists() {
889                break;
890            }
891            paths.push(p);
892            n += 1;
893        }
894        if paths.is_empty() {
895            return Err(DarError::Corrupt(format!(
896                "no slices found for basename {}",
897                basename.display()
898            )));
899        }
900        DarReader::open(SliceReader::open(&paths)?)
901    }
902}
903
904/// Read the NUL-terminated `version_string` at the current position and return
905/// `archive_version::value()` = `major*256 + fix`, where `major = b0*256 + b1`
906/// and each byte is `value + 48`. Format <= 7 stores only `"NN"` (fix implicitly
907/// 0); format 8+ stores `"NNf"`. Returns `u32::MAX` for an unreadable string so
908/// an unknown future format is treated as newest.
909fn read_format_value<R: Read>(r: &mut R) -> u32 {
910    let b = read_nul_bytes(r).unwrap_or_default();
911    if b.len() >= 2 {
912        let major = u32::from(b[0].saturating_sub(48)) * 256 + u32::from(b[1].saturating_sub(48));
913        let fix = if b.len() >= 3 {
914            u32::from(b[2].saturating_sub(48))
915        } else {
916            0
917        };
918        major * 256 + fix
919    } else {
920        u32::MAX
921    }
922}
923
924/// Read the multi-byte header flag field (libdar header_flags.cpp): bytes are
925/// accumulated most-significant-first, the low bit (`0x01`) of each byte signals
926/// that another byte follows, and the value bits are `byte & 0xFE`.
927fn read_header_flags<R: Read>(r: &mut R) -> Result<u64, DarError> {
928    let mut bits: u64 = 0;
929    loop {
930        let a = read_u8(r)?;
931        if bits >> 56 != 0 {
932            return Err(DarError::Corrupt("header flag field too large".into()));
933        }
934        bits = (bits << 8) | u64::from(a & 0xFE);
935        if a & 0x01 == 0 {
936            return Ok(bits);
937        }
938    }
939}
940
941/// Read the compression block size from the archive header (cursor positioned
942/// just after the global compression byte). A non-zero result selects dar's
943/// per-block decompression; 0 means a single codec stream.
944///
945/// Returns 0 for edition 1 (no flags), when no block size is recorded, when the
946/// value is implausibly large ([`MAX_BLOCK_SIZE`]), or when the header carries
947/// fields this reader does not parse (encryption / KDF / isolated-catalogue
948/// slicing — none of which are decodable anyway). Best-effort: a read error also
949/// degrades to 0, so a genuinely block-framed stream then fails loudly at the
950/// decode step rather than being silently mis-read. Existing single-stream
951/// archives are unaffected — they have no block size and resolve to 0.
952fn read_compr_bs<R: Read>(r: &mut R, format_major: u32) -> u64 {
953    fn inner<R: Read>(r: &mut R, format_major: u32) -> Result<u64, DarError> {
954        const INITIAL_OFFSET: u64 = 0x08;
955        const HAS_COMPRESS_BS: u64 = 0x0800;
956        // Fields sitting between the flags and the block size that this reader
957        // does not parse; archives that set them (encrypted / KDF / isolated
958        // catalogue) are not decodable regardless.
959        const COMPLEX: u64 = 0x20 | 0x04 | 0x02 | 0x0400; // scrambled | crypted-key | ref-slicing | kdf
960
961        skip_nul_string(r)?; // command line
962        if format_major < 2 {
963            return Ok(0); // the flag field was introduced at edition 2
964        }
965        let flags = read_header_flags(r)?;
966        if flags & COMPLEX != 0 || flags & HAS_COMPRESS_BS == 0 {
967            return Ok(0);
968        }
969        if flags & INITIAL_OFFSET != 0 {
970            read_infinint(r)?; // skip the initial offset
971        }
972        let bs = read_infinint(r)?;
973        Ok(if bs > MAX_BLOCK_SIZE { 0 } else { bs })
974    }
975    inner(r, format_major).unwrap_or(0)
976}
977
978/// True when a libdar compression char names a known compression algorithm.
979/// `compression2char` emits the algorithm letter in lowercase for streamed mode
980/// and uppercase for per-block mode (`z`=gzip, `y`=bzip2, `x`=xz, `l`/`j`/`k`=lzo
981/// variants, `d`=zstd, `q`=lz4); `n` is stored. Any other byte — e.g. a header
982/// placeholder in a non-dar-produced archive — is treated as not compressed, so
983/// the catalogue/entry is read verbatim rather than mis-decoded.
984fn is_compressed(algo: u8) -> bool {
985    matches!(
986        algo.to_ascii_lowercase(),
987        b'z' | b'y' | b'x' | b'l' | b'j' | b'k' | b'd' | b'q'
988    )
989}
990
991/// Inflate a compressed catalogue into a single buffer, routing through the same
992/// [`decode_stream`]/[`CapWriter`] path the per-file extractor uses and capping
993/// output at `MAX_CATALOGUE_INFLATED` (decompression-bomb guard). Trailing bytes
994/// after the codec stream (the archive trailer) are ignored by the decoder.
995fn inflate_catalogue(compressed: &[u8], algo: u8, block_size: u64) -> Result<Vec<u8>, DarError> {
996    let mut out = Vec::new();
997    let mut cap = CapWriter {
998        inner: &mut out,
999        written: 0,
1000        max: MAX_CATALOGUE_INFLATED,
1001    };
1002    decode_data(compressed, algo, block_size, &mut cap)?;
1003    Ok(out)
1004}
1005
1006/// Decode a compressed data span. The archive uses dar's per-block framing (see
1007/// [`decode_blocks`]) when a block size is recorded (`block_size > 0`) or the
1008/// codec is lz4/lzo — which have no streamed form and so are always block-framed
1009/// (dar applies a default block size that it does not store in the header).
1010/// Otherwise it is a single codec stream (see [`decode_stream`]).
1011fn decode_data<W: Write>(
1012    data: &[u8],
1013    algo: u8,
1014    block_size: u64,
1015    out: &mut W,
1016) -> Result<(), DarError> {
1017    let always_block = matches!(algo.to_ascii_lowercase(), b'q' | b'l' | b'j' | b'k');
1018    if block_size > 0 || always_block {
1019        decode_blocks(data, algo, block_size, out)
1020    } else {
1021        decode_stream(data, algo, out)
1022    }
1023}
1024
1025/// Decode a dar `block_compressor` stream: a sequence of blocks, each
1026/// `[type: 1 byte][infinint compressed_size][compressed_size bytes]`, terminated
1027/// by an `H_EOF` block (size 0). Each `H_DATA` block is decompressed
1028/// independently and appended to `out` (libdar block_compressor.cpp /
1029/// compress_block_header.cpp).
1030///
1031/// For lz4 each block is a raw LZ4 block decoded into a `block_size`-byte buffer;
1032/// for the other codecs each block is a complete, self-delimiting codec stream
1033/// decoded via [`decode_stream`]. `block_size` is the archive's uncompressed
1034/// block size (the lz4 destination capacity). Each block's compressed size is
1035/// bounded by the remaining input, which also bounds the loop to O(input)
1036/// iterations.
1037fn decode_blocks<W: Write>(
1038    data: &[u8],
1039    algo: u8,
1040    block_size: u64,
1041    out: &mut W,
1042) -> Result<(), DarError> {
1043    const H_DATA: u8 = 1;
1044    const H_EOF: u8 = 2;
1045
1046    let mut input = data;
1047    // Reusable destination buffer for the raw block codecs (lz4, lzo): their
1048    // blocks carry no uncompressed size, so each decodes into a buffer seeded to
1049    // the declared block size, or to cover dar's default (240 KiB) when the
1050    // archive records none — a block that overflows it is genuine corruption,
1051    // surfaced as a decode error rather than silently grown.
1052    let mut raw_block_buf: Vec<u8> =
1053        if matches!(algo.to_ascii_lowercase(), b'q' | b'l' | b'j' | b'k') {
1054            let seed = if block_size > 0 {
1055                block_size.min(MAX_BLOCK_SIZE) as usize
1056            } else {
1057                256 * 1024
1058            };
1059            vec![0u8; seed]
1060        } else {
1061            Vec::new()
1062        };
1063
1064    loop {
1065        let typ = read_u8(&mut input)
1066            .map_err(|_| DarError::Corrupt("truncated block stream: missing end marker".into()))?;
1067        let size = read_infinint(&mut input)?;
1068        match typ {
1069            H_EOF => {
1070                if size != 0 {
1071                    return Err(DarError::Corrupt(
1072                        "non-zero size on end-of-blocks marker".into(),
1073                    ));
1074                }
1075                return Ok(());
1076            }
1077            H_DATA => {
1078                if size == 0 {
1079                    return Err(DarError::Corrupt("zero-size compressed block".into()));
1080                }
1081                // A block cannot be larger than the bytes that remain in the
1082                // (already bounded) input. This both caps the allocation and,
1083                // since every block consumes at least its `size` bytes, bounds
1084                // the loop to O(input) iterations — no separate block-count cap.
1085                if size > input.len() as u64 {
1086                    return Err(DarError::Corrupt(
1087                        "compressed block size exceeds remaining input".into(),
1088                    ));
1089                }
1090                let mut block = vec![0u8; size as usize];
1091                input
1092                    .read_exact(&mut block)
1093                    .map_err(|_| DarError::Corrupt("truncated compressed block".into()))?;
1094                match algo.to_ascii_lowercase() {
1095                    b'q' => decode_lz4_block(&block, &mut raw_block_buf, out)?,
1096                    b'l' | b'j' | b'k' => decode_lzo_block(&block, &mut raw_block_buf, out)?,
1097                    // gzip/bzip2/xz/zstd block = a complete self-delimiting stream.
1098                    _ => decode_stream(&block[..], algo, out)?,
1099                }
1100            }
1101            other => {
1102                return Err(DarError::Corrupt(format!(
1103                    "unknown compressed block type {other}"
1104                )));
1105            }
1106        }
1107    }
1108}
1109
1110/// Decompress one raw lz4 block into `out` using `buf` (sized to the block size)
1111/// as the destination. A block that does not fit (or is malformed) is a decode
1112/// error — dar never writes a block larger than the archive's block size.
1113fn decode_lz4_block<W: Write>(block: &[u8], buf: &mut [u8], out: &mut W) -> Result<(), DarError> {
1114    let n = lz4_flex::block::decompress_into(block, buf)
1115        .map_err(|e| DarError::Corrupt(format!("lz4 block decode failed: {e}")))?;
1116    out.write_all(&buf[..n])?;
1117    Ok(())
1118}
1119
1120/// Decompress one raw lzo1x block into `out` using `buf` (sized to the block
1121/// size) as the destination. A block that does not fit, or is not a valid lzo1x
1122/// block, is a decode error — dar never writes a block larger than the archive's
1123/// block size, and the [`lzo`] decoder is bounds-checked, so malformed input
1124/// surfaces as a typed error rather than a panic.
1125fn decode_lzo_block<W: Write>(block: &[u8], buf: &mut [u8], out: &mut W) -> Result<(), DarError> {
1126    let n = lzo::decompress_into(block, buf)
1127        .map_err(|e| DarError::Corrupt(format!("lzo block decode failed: {e}")))?;
1128    out.write_all(&buf[..n])?;
1129    Ok(())
1130}
1131
1132/// A `Write` adapter that forwards to `inner`, counting bytes written and failing
1133/// once more than `max` would be written — the streaming decompression-bomb
1134/// guard used by [`DarReader::extract_to`].
1135struct CapWriter<'a, W: Write> {
1136    inner: &'a mut W,
1137    written: u64,
1138    max: u64,
1139}
1140
1141impl<W: Write> Write for CapWriter<'_, W> {
1142    fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
1143        if self.written + data.len() as u64 > self.max {
1144            return Err(std::io::Error::other("decompressed data exceeds bound"));
1145        }
1146        self.inner.write_all(data)?;
1147        self.written += data.len() as u64;
1148        Ok(data.len())
1149    }
1150
1151    fn flush(&mut self) -> std::io::Result<()> {
1152        self.inner.flush()
1153    }
1154}
1155
1156/// Stream-decode a compressed input to `out`, dispatching on the libdar codec
1157/// char. The Read decoders stop at the codec stream's end (ignoring trailing
1158/// bytes); lzma-rs rejects trailing bytes only after fully validating the
1159/// stream, so that one error is treated as success.
1160fn decode_stream<R: Read, W: Write>(input: R, algo: u8, out: &mut W) -> Result<(), DarError> {
1161    match algo.to_ascii_lowercase() {
1162        b'z' => {
1163            std::io::copy(&mut flate2::read::ZlibDecoder::new(input), out)
1164                .map_err(|e| DarError::Corrupt(format!("zlib decode failed: {e}")))?;
1165            Ok(())
1166        }
1167        b'y' => {
1168            std::io::copy(&mut bzip2_rs::DecoderReader::new(input), out)
1169                .map_err(|e| DarError::Corrupt(format!("bzip2 decode failed: {e}")))?;
1170            Ok(())
1171        }
1172        b'x' => {
1173            let mut br = std::io::BufReader::new(input);
1174            match lzma_rs::xz_decompress(&mut br, out) {
1175                Ok(()) => {}
1176                Err(lzma_rs::error::Error::XzError(ref m))
1177                    if m == "Unexpected data after last XZ block" => {}
1178                Err(e) => return Err(DarError::Corrupt(format!("xz decode failed: {e}"))),
1179            }
1180            Ok(())
1181        }
1182        b'd' => {
1183            // dar's streamed zstd is a standard zstd frame (ZSTD_compressStream).
1184            let mut dec = ruzstd::StreamingDecoder::new(input)
1185                .map_err(|e| DarError::Corrupt(format!("zstd decode failed: {e}")))?;
1186            std::io::copy(&mut dec, out)
1187                .map_err(|e| DarError::Corrupt(format!("zstd decode failed: {e}")))?;
1188            Ok(())
1189        }
1190        // An unrecognised codec char lands here — a clear error, never a silent
1191        // misread. (Single line so the e2e-coverage allowlist matches one specific line.)
1192        #[rustfmt::skip]
1193        other => Err(DarError::Corrupt(format!("unrecognised compression codec '{}'", other as char))),
1194    }
1195}
1196
1197/// Locate the catalogue in a pre-format-8 archive via the end `terminateur`
1198/// trailer (libdar terminateur.cpp:95-138), returning the catalogue start offset
1199/// relative to `archive_origin`.
1200///
1201/// From EOF, count trailing `0xFF` padding bytes (8 bits each); the first
1202/// non-`0xFF` byte encodes the remaining count in unary as its set high bits.
1203/// `byte_offset = total_bits * 4` is the distance back from that byte to the
1204/// catalogue-position infinint. The `0xFF` run is bounded so a hostile all-`0xFF`
1205/// tail cannot spin or overflow.
1206fn read_terminateur<R: Read + Seek>(r: &mut R) -> Result<u64, DarError> {
1207    const BLOCK_SIZE: u64 = 4;
1208    const MAX_BITS: u64 = 4096; // far beyond any real terminator
1209
1210    let mut pos = r.seek(SeekFrom::End(0))?;
1211    let mut bits: u64 = 0;
1212    let terminal = loop {
1213        if pos == 0 {
1214            return Err(DarError::Corrupt("terminator underflows archive".into()));
1215        }
1216        pos -= 1;
1217        r.seek(SeekFrom::Start(pos))?;
1218        let b = read_u8(r)?;
1219        if b == 0xFF {
1220            bits += 8;
1221            if bits > MAX_BITS {
1222                return Err(DarError::Corrupt("terminator padding too long".into()));
1223            }
1224        } else {
1225            break b;
1226        }
1227    };
1228    // The terminator byte must have its top bit set; count consecutive set MSBs.
1229    if terminal & 0x80 == 0 {
1230        return Err(DarError::Corrupt(format!(
1231            "invalid terminator byte {terminal:#04x}"
1232        )));
1233    }
1234    let mut x = terminal;
1235    while x != 0 {
1236        if x & 0x80 == 0 {
1237            return Err(DarError::Corrupt("malformed terminator bit run".into()));
1238        }
1239        bits += 1;
1240        x <<= 1;
1241    }
1242    let byte_offset = bits * BLOCK_SIZE;
1243    let infinint_start = pos
1244        .checked_sub(byte_offset)
1245        .ok_or_else(|| DarError::Corrupt("terminator offset underflows".into()))?;
1246    r.seek(SeekFrom::Start(infinint_start))?;
1247    read_infinint(r)
1248}
1249
1250/// Parse all catalog entries, returning file entries with their extraction info.
1251///
1252/// Stops when the root directory is closed (depth reaches zero) or an unknown
1253/// entry type is encountered (slice trailer).
1254fn parse_catalog<R: Read + Seek>(
1255    r: &mut R,
1256    format_major: u32,
1257    global_comp: u8,
1258) -> Result<(Vec<EntryRef>, bool), DarError> {
1259    let mut entries = Vec::new();
1260    let mut dir_stack: Vec<Vec<u8>> = Vec::new();
1261    let mut depth: u32 = 0;
1262    // True once the catalog is walked to its closing root EOD; left false if we
1263    // stop early (unknown entry type or a truncated stream).
1264    let mut complete = false;
1265
1266    loop {
1267        let mut buf = [0u8; 1];
1268        match r.read_exact(&mut buf) {
1269            Ok(()) => {}
1270            Err(_) => break,
1271        }
1272
1273        // Lower 5 bits of cat_sig + 0x60 gives the ASCII type letter.
1274        let entry_type = ((buf[0] & 0x1f) | 0x60) as char;
1275
1276        match entry_type {
1277            'z' => {
1278                // End of directory
1279                depth = depth.saturating_sub(1);
1280                dir_stack.pop();
1281                if depth == 0 {
1282                    complete = true; // reached the closing root EOD — clean end
1283                    break;
1284                }
1285            }
1286            'd' => {
1287                let name = read_nul_bytes(r)?;
1288                let inode = read_inode_base(r, format_major)?;
1289                if format_major >= 9 && (inode.flags >> 4) & 1 != 0 {
1290                    skip_fsa(r)?;
1291                }
1292                let is_root = depth == 0;
1293                depth += 1;
1294                // The archive root (`<ROOT>`, or `"root"` in formats 1/9) is a
1295                // virtual node: `<ROOT>` is dropped entirely; a named root becomes
1296                // the path prefix. Neither is listed as an entry. Real
1297                // sub-directories are listed with their full path.
1298                if name != b"<ROOT>" {
1299                    let path = join_path(&dir_stack, &name);
1300                    if !is_root {
1301                        entries.push(meta_entry(path, EntryKind::Directory, &inode, None));
1302                    }
1303                    dir_stack.push(name);
1304                }
1305            }
1306            'f' => {
1307                let name = read_nul_bytes(r)?;
1308                let inode = read_inode_base(r, format_major)?;
1309                if format_major >= 9 && (inode.flags >> 4) & 1 != 0 {
1310                    skip_fsa(r)?;
1311                }
1312
1313                let FileFields {
1314                    size,
1315                    archive_offset,
1316                    stored_size,
1317                    compression,
1318                    crc,
1319                } = read_file_fields(r, format_major, global_comp)?;
1320
1321                entries.push(EntryRef {
1322                    path: join_path(&dir_stack, &name),
1323                    kind: EntryKind::File,
1324                    size,
1325                    uid: inode.uid,
1326                    gid: inode.gid,
1327                    mode: inode.mode,
1328                    atime: inode.atime,
1329                    mtime: inode.mtime,
1330                    ctime: inode.ctime,
1331                    symlink_target: None,
1332                    archive_offset,
1333                    stored_size,
1334                    compression,
1335                    crc,
1336                });
1337            }
1338            'l' => {
1339                // Symbolic link: inode + NUL-terminated target path.
1340                let name = read_nul_bytes(r)?;
1341                let inode = read_inode_base(r, format_major)?;
1342                if format_major >= 9 && (inode.flags >> 4) & 1 != 0 {
1343                    skip_fsa(r)?;
1344                }
1345                let target = read_nul_bytes(r)?;
1346                let path = join_path(&dir_stack, &name);
1347                entries.push(meta_entry(path, EntryKind::Symlink, &inode, Some(target)));
1348            }
1349            'p' | 's' => {
1350                // Named pipe (FIFO) / unix socket: a bare inode, no data and no
1351                // type-specific fields.
1352                let name = read_nul_bytes(r)?;
1353                let inode = read_inode_base(r, format_major)?;
1354                if format_major >= 9 && (inode.flags >> 4) & 1 != 0 {
1355                    skip_fsa(r)?;
1356                }
1357                let kind = if entry_type == 'p' {
1358                    EntryKind::NamedPipe
1359                } else {
1360                    EntryKind::Socket
1361                };
1362                entries.push(meta_entry(join_path(&dir_stack, &name), kind, &inode, None));
1363            }
1364            _ => break, // unknown type = slice trailer or unhandled entry
1365        }
1366    }
1367
1368    Ok((entries, complete))
1369}
1370
1371/// The file-specific catalog fields that follow a file inode.
1372struct FileFields {
1373    size: u64,
1374    archive_offset: u64,
1375    stored_size: u64,
1376    compression: u8,
1377    crc: Option<Vec<u8>>,
1378}
1379
1380/// Read the file-specific catalog fields after the inode. Layout differs by
1381/// format (libdar cat_file.cpp / crc.cpp):
1382/// - 8+: storage_size · file_data_status(1) · comp(1) · length-prefixed CRC.
1383/// - 2-7: storage_size · fixed 2-byte CRC; no status/comp byte — the
1384///   archive-global codec applies.
1385/// - 1: size · offset only; storage_size synthesised, global codec applies.
1386fn read_file_fields<R: Read + Seek>(
1387    r: &mut R,
1388    format_major: u32,
1389    global_comp: u8,
1390) -> Result<FileFields, DarError> {
1391    let size = read_infinint(r)?;
1392    let archive_offset = read_infinint(r)?;
1393    let (mut stored_size, compression, crc) = if format_major >= 8 {
1394        let ss = read_infinint(r)?;
1395        let _file_data_status = read_u8(r)?;
1396        let comp = read_u8(r)?;
1397        let crc = read_crc(r)?; // infinint width + that many raw bytes
1398        (ss, comp, crc)
1399    } else if format_major >= 2 {
1400        let ss = read_infinint(r)?;
1401        let mut crcbuf = [0u8; 2]; // legacy: fixed 2-byte CRC, no width prefix
1402        r.read_exact(&mut crcbuf)?;
1403        (ss, global_comp, Some(crcbuf.to_vec()))
1404    } else {
1405        (size, global_comp, None) // format 1: storage_size synthesised, no CRC
1406    };
1407    // Pre-8: storage_size 0 means the data is stored uncompressed.
1408    if format_major <= 7 && stored_size == 0 {
1409        stored_size = size;
1410    }
1411    Ok(FileFields {
1412        size,
1413        archive_offset,
1414        stored_size,
1415        compression,
1416        crc,
1417    })
1418}
1419
1420/// Read a format-8+ length-prefixed CRC: an infinint width then that many raw
1421/// bytes. A zero width (abnormal — libdar uses >= 1) yields `None`; a width past
1422/// [`MAX_CRC_SIZE`] is rejected as corrupt (allocation-bomb guard).
1423fn read_crc<R: Read>(r: &mut R) -> Result<Option<Vec<u8>>, DarError> {
1424    let crc_size = read_infinint(r)?;
1425    if crc_size == 0 {
1426        return Ok(None);
1427    }
1428    if crc_size > MAX_CRC_SIZE {
1429        return Err(DarError::Corrupt(format!(
1430            "CRC width {crc_size} exceeds {MAX_CRC_SIZE}-byte bound"
1431        )));
1432    }
1433    let mut buf = vec![0u8; crc_size as usize];
1434    r.read_exact(&mut buf)?;
1435    Ok(Some(buf))
1436}
1437
1438/// libdar's per-file CRC: an XOR-fold of `data` into a `width`-byte accumulator,
1439/// byte `i` into slot `i mod width` (zero-init, read out slot 0 first; no final
1440/// transform). `width` must be non-zero (a zero-width CRC is treated as absent).
1441fn dar_crc(data: &[u8], width: usize) -> Vec<u8> {
1442    let mut acc = vec![0u8; width];
1443    for (i, &b) in data.iter().enumerate() {
1444        acc[i % width] ^= b;
1445    }
1446    acc
1447}
1448
1449/// Lowercase hex encoding of `bytes`.
1450fn to_hex(bytes: &[u8]) -> String {
1451    const HEX: [u8; 16] = *b"0123456789abcdef";
1452    let mut s = String::with_capacity(bytes.len() * 2);
1453    for &b in bytes {
1454        // Each nibble is masked to 0..16, so the table index can never be out of
1455        // bounds — panic-free without `unwrap`.
1456        s.push(HEX[(b >> 4) as usize] as char);
1457        s.push(HEX[(b & 0xf) as usize] as char);
1458    }
1459    s
1460}
1461
1462/// Join a directory stack and a leaf name into a `/`-separated raw-byte path.
1463fn join_path(stack: &[Vec<u8>], name: &[u8]) -> Vec<u8> {
1464    let mut path = Vec::new();
1465    for component in stack {
1466        path.extend_from_slice(component);
1467        path.push(b'/');
1468    }
1469    path.extend_from_slice(name);
1470    path
1471}
1472
1473/// Build an `EntryRef` for a non-file inode (dir/symlink/pipe/socket): it carries
1474/// metadata but no archive data.
1475fn meta_entry(
1476    path: Vec<u8>,
1477    kind: EntryKind,
1478    inode: &Inode,
1479    symlink_target: Option<Vec<u8>>,
1480) -> EntryRef {
1481    EntryRef {
1482        path,
1483        kind,
1484        size: 0,
1485        uid: inode.uid,
1486        gid: inode.gid,
1487        mode: inode.mode,
1488        atime: inode.atime,
1489        mtime: inode.mtime,
1490        ctime: inode.ctime,
1491        symlink_target,
1492        archive_offset: 0,
1493        stored_size: 0,
1494        compression: b'n',
1495        crc: None,
1496    }
1497}
1498
1499// ── Low-level I/O helpers ─────────────────────────────────────────────────────
1500
1501/// Read a DAR variable-length infinint, decoded to `u64`.
1502///
1503/// Format (TG=4): optional leading `0x00` skip-bytes, then a terminal byte
1504/// with exactly one bit set; `pos = terminal.leading_zeros()` and the value
1505/// occupies `(skip_count * 8 + pos + 1) * 4` big-endian bytes.
1506///
1507/// A `u64` holds at most 8 data bytes.  Any encoding wider than that — i.e.
1508/// *any* leading `0x00` (which alone implies ≥ 36 bytes) or a terminal below
1509/// `0x40` (`pos > 1`) — cannot be represented and is rejected as `Corrupt`
1510/// rather than silently truncated.  This single bound also removes the
1511/// `(skip * 8 …)` arithmetic-overflow panic and caps the leading-zero scan, so
1512/// a malicious all-zero run can never spin or overflow the skip counter.
1513fn read_infinint<R: Read>(r: &mut R) -> Result<u64, DarError> {
1514    let terminal = read_u8(r)?;
1515    if terminal == 0x00 {
1516        // A skip-byte group is at least 36 data bytes — far beyond u64.
1517        return Err(DarError::Corrupt(
1518            "infinint exceeds 64-bit range (multi-group encoding)".into(),
1519        ));
1520    }
1521    if terminal.count_ones() != 1 {
1522        return Err(DarError::Corrupt(format!(
1523            "invalid infinint terminal: {terminal:#04x}"
1524        )));
1525    }
1526    let pos = terminal.leading_zeros(); // 0 ..= 7
1527    if pos > 1 {
1528        // data_bytes = (pos + 1) * 4 > 8 → does not fit in u64.
1529        return Err(DarError::Corrupt(format!(
1530            "infinint exceeds 64-bit range: terminal {terminal:#04x} implies {} bytes",
1531            (pos + 1) * 4
1532        )));
1533    }
1534    let data_bytes = (pos + 1) * 4; // 4 (terminal 0x80) or 8 (terminal 0x40)
1535    let mut val: u64 = 0;
1536    for _ in 0..data_bytes {
1537        val = (val << 8) | u64::from(read_u8(r)?);
1538    }
1539    Ok(val)
1540}
1541
1542fn read_u8<R: Read>(r: &mut R) -> Result<u8, DarError> {
1543    let mut b = [0u8; 1];
1544    r.read_exact(&mut b)?;
1545    Ok(b[0])
1546}
1547
1548/// Upper bound on a NUL-terminated path/name field.  Real DAR entries stay
1549/// well under this; the cap stops a NUL-free region of a hostile archive from
1550/// growing the buffer until EOF (or OOM on a multi-GiB stream).
1551const MAX_NUL_STRING: usize = 64 * 1024;
1552
1553/// Read a NUL-terminated byte string (raw, not UTF-8 validated), consuming the
1554/// NUL. Length-capped at `MAX_NUL_STRING` so a NUL-free hostile region can't grow
1555/// the buffer to EOF.
1556fn read_nul_bytes<R: Read>(r: &mut R) -> Result<Vec<u8>, DarError> {
1557    let mut bytes = Vec::new();
1558    loop {
1559        let b = read_u8(r)?;
1560        if b == 0 {
1561            break;
1562        }
1563        if bytes.len() >= MAX_NUL_STRING {
1564            return Err(DarError::Corrupt(format!(
1565                "NUL-terminated string exceeds {MAX_NUL_STRING} bytes"
1566            )));
1567        }
1568        bytes.push(b);
1569    }
1570    Ok(bytes)
1571}
1572
1573/// Skip a NUL-terminated string without collecting the bytes.
1574fn skip_nul_string<R: Read>(r: &mut R) -> Result<(), DarError> {
1575    let mut len: usize = 0;
1576    loop {
1577        if read_u8(r)? == 0 {
1578            return Ok(());
1579        }
1580        len += 1;
1581        if len > MAX_NUL_STRING {
1582            return Err(DarError::Corrupt(format!(
1583                "NUL-terminated string exceeds {MAX_NUL_STRING} bytes"
1584            )));
1585        }
1586    }
1587}
1588
1589/// Seek past `n` bytes.
1590fn skip<R: Seek>(r: &mut R, n: u64) -> Result<(), DarError> {
1591    if n > 0 {
1592        // `SeekFrom::Current` takes an i64; a value above i64::MAX would cast to
1593        // a negative offset and seek *backwards* (re-reading earlier bytes on a
1594        // File).  No real DAR field is that large — reject it outright.
1595        let off = i64::try_from(n)
1596            .map_err(|_| DarError::Corrupt(format!("skip length {n} exceeds seekable range")))?;
1597        r.seek(SeekFrom::Current(off)).map_err(DarError::Io)?;
1598    }
1599    Ok(())
1600}
1601
1602/// Skip one DAR timestamp field.
1603///
1604/// Timestamps are prefixed with a type byte:
1605/// - `'s'` (0x73) and others: seconds only — one infinint follows
1606/// - `'n'` (0x6e): nanosecond precision — two infinints follow (seconds + nanoseconds)
1607fn read_timestamp<R: Read + Seek>(r: &mut R, format_major: u32) -> Result<i64, DarError> {
1608    // Format 8 and earlier store a bare seconds infinint with NO precision byte
1609    // (libdar datetime.cpp:372). Format 9+ prefix a unit byte ('s' seconds,
1610    // 'u' microsecond, 'n' nanosecond); sub-second units add a second infinint,
1611    // which we read and discard (seconds resolution is what we expose).
1612    if format_major < 9 {
1613        return Ok(read_infinint(r)? as i64);
1614    }
1615    let ts_type = read_u8(r)?;
1616    let secs = read_infinint(r)? as i64;
1617    if ts_type == b'n' || ts_type == b'u' {
1618        read_infinint(r)?;
1619    }
1620    Ok(secs)
1621}
1622
1623/// Read a 2-byte big-endian `u16` (uid/gid for format <= 7, and permission bits).
1624fn read_u16<R: Read>(r: &mut R) -> Result<u16, DarError> {
1625    let mut b = [0u8; 2];
1626    r.read_exact(&mut b)?;
1627    Ok(u16::from_be_bytes(b))
1628}
1629
1630/// Decoded inode metadata shared by every catalog entry type.
1631struct Inode {
1632    flags: u8,
1633    uid: u64,
1634    gid: u64,
1635    mode: u16,
1636    atime: i64,
1637    mtime: i64,
1638    ctime: Option<i64>,
1639}
1640
1641/// Read one inode's base fields and return them. Layout in order: an optional
1642/// flags byte (format 2+), uid, gid, a `u16` perms field, atime, mtime, and a
1643/// ctime for format 8+. uid/gid are a 2-byte `u16` for format `<= 7` and an
1644/// infinint for 8+; each timestamp is decoded by [`read_timestamp`]. FSA inode
1645/// fields (format 9+, when flag bit `0x10` is set) are consumed and discarded.
1646fn read_inode_base<R: Read + Seek>(r: &mut R, format_major: u32) -> Result<Inode, DarError> {
1647    // Format 1 predates extended attributes and has NO leading flag byte
1648    // (libdar cat_inode.cpp); formats 2+ store it. Synthesise 0 for format 1.
1649    let flags = if format_major >= 2 { read_u8(r)? } else { 0 };
1650    // uid/gid: 2-byte u16 for format <= 7 (libdar cat_inode.cpp:171), infinint for 8+.
1651    let (uid, gid) = if format_major <= 7 {
1652        (u64::from(read_u16(r)?), u64::from(read_u16(r)?))
1653    } else {
1654        (read_infinint(r)?, read_infinint(r)?)
1655    };
1656    let mode = read_u16(r)?; // perms: a 2-byte big-endian u16, never an infinint
1657    let atime = read_timestamp(r, format_major)?;
1658    let mtime = read_timestamp(r, format_major)?;
1659    // ctime (last_cha) exists only from format 8 (libdar cat_inode.cpp:197).
1660    let ctime = if format_major >= 8 {
1661        Some(read_timestamp(r, format_major)?)
1662    } else {
1663        None
1664    };
1665    // FSA inode fields exist only from format 9 (libdar cat_inode.cpp:264); bit
1666    // 0x10 is the FSA-full status. Formats <= 8 have no FSA.
1667    if format_major >= 9 && (flags >> 4) & 1 != 0 {
1668        read_infinint(r)?;
1669        read_infinint(r)?;
1670    }
1671    Ok(Inode {
1672        flags,
1673        uid,
1674        gid,
1675        mode,
1676        atime,
1677        mtime,
1678        ctime,
1679    })
1680}
1681
1682/// Skip one FSA (filesystem attributes) block.
1683///
1684/// Format: infinint(family_tag) + infinint(data_size) + data_size bytes.
1685fn skip_fsa<R: Read + Seek>(r: &mut R) -> Result<(), DarError> {
1686    let _tag = read_infinint(r)?;
1687    let size = read_infinint(r)?;
1688    skip(r, size)
1689}
1690
1691// ── Unit tests ────────────────────────────────────────────────────────────────
1692
1693#[cfg(test)]
1694mod tests {
1695    use super::*;
1696    use std::io::Cursor;
1697
1698    // ── SliceReader truncated-slice guard ─────────────────────────────────────
1699
1700    #[test]
1701    fn slicereader_stops_on_truncated_slice() {
1702        use std::io::Read;
1703        // A span claiming more bytes than its file holds (only constructible
1704        // internally — `open` always measures the real file). Reading must stop at
1705        // the real EOF instead of spinning on the missing tail.
1706        let path = std::env::temp_dir().join(format!("dar_ms_trunc_{}.bin", std::process::id()));
1707        std::fs::write(&path, [1u8, 2, 3, 4]).unwrap();
1708        let mut sr = SliceReader {
1709            slices: vec![SliceSpan {
1710                file: File::open(&path).unwrap(),
1711                file_data_start: 0,
1712                logical_start: 0,
1713                logical_len: 100, // lies: only 4 bytes exist
1714            }],
1715            pos: 0,
1716            total: 100,
1717        };
1718        let mut buf = [0u8; 50];
1719        assert_eq!(sr.read(&mut buf).unwrap(), 4);
1720        assert_eq!(&buf[..4], &[1, 2, 3, 4]);
1721        let _ = std::fs::remove_file(&path);
1722    }
1723
1724    // ── read_infinint ─────────────────────────────────────────────────────────
1725
1726    #[test]
1727    fn infinint_decodes_value() {
1728        let data = [0x80u8, 0x00, 0x00, 0x00, 0x0d];
1729        assert_eq!(read_infinint(&mut Cursor::new(&data[..])).unwrap(), 13);
1730    }
1731
1732    #[test]
1733    fn infinint_bad_preamble_returns_corrupt() {
1734        // 0x03 = two bits set — not a valid infinint terminal.
1735        let data = [0x03u8, 0x00, 0x00, 0x00, 0x00];
1736        let err = read_infinint(&mut Cursor::new(&data[..])).unwrap_err();
1737        assert!(matches!(&err, DarError::Corrupt(_)));
1738    }
1739
1740    #[test]
1741    fn infinint_truncated_returns_io() {
1742        // Only 2 bytes — read_exact needs 5.
1743        let err = read_infinint(&mut Cursor::new(&[0x80u8, 0x00][..])).unwrap_err();
1744        assert!(matches!(err, DarError::Io(_)));
1745    }
1746
1747    #[test]
1748    fn infinint_0x40_preamble_reads_8_data_bytes() {
1749        // 0x40 terminal: leading_zeros=1, pos=1, data_bytes=(0*8+1+1)*4=8
1750        // Encodes the value 0x5d15_9331 in 8 big-endian bytes.
1751        let mut data = vec![0x40u8];
1752        data.extend_from_slice(&[0x00, 0x00, 0x00, 0x00, 0x5d, 0x15, 0x93, 0x31]);
1753        assert_eq!(
1754            read_infinint(&mut Cursor::new(data)).unwrap(),
1755            0x5d15_9331u64
1756        );
1757    }
1758
1759    #[test]
1760    fn infinint_multi_bit_terminal_returns_corrupt() {
1761        // 0x60 = 0110_0000 — two bits set, not a valid terminal.
1762        let data = [0x60u8, 0x00, 0x00, 0x00, 0x00];
1763        let err = read_infinint(&mut Cursor::new(&data[..])).unwrap_err();
1764        assert!(matches!(&err, DarError::Corrupt(_)));
1765    }
1766
1767    // ── read_u8 ───────────────────────────────────────────────────────────────
1768
1769    #[test]
1770    fn read_u8_reads_single_byte() {
1771        assert_eq!(read_u8(&mut Cursor::new(&[0x42u8][..])).unwrap(), 0x42);
1772    }
1773
1774    #[test]
1775    fn read_u8_eof_returns_io() {
1776        let err = read_u8(&mut Cursor::new(&[][..])).unwrap_err();
1777        assert!(matches!(err, DarError::Io(_)));
1778    }
1779
1780    // ── read_nul_bytes ──────────────────────────────────────────────────────
1781
1782    #[test]
1783    fn nul_bytes_reads_until_nul() {
1784        let data = b"hello\x00world";
1785        assert_eq!(
1786            read_nul_bytes(&mut Cursor::new(&data[..])).unwrap(),
1787            b"hello"
1788        );
1789    }
1790
1791    #[test]
1792    fn nul_bytes_preserves_non_utf8() {
1793        // Raw bytes are kept verbatim — a non-UTF-8 name must NOT be rejected.
1794        let data = [0xFF, 0x80, 0x00];
1795        assert_eq!(
1796            read_nul_bytes(&mut Cursor::new(&data[..])).unwrap(),
1797            vec![0xFF, 0x80]
1798        );
1799    }
1800
1801    #[test]
1802    fn nul_bytes_eof_before_nul_returns_io() {
1803        let err = read_nul_bytes(&mut Cursor::new(b"no-nul".to_vec())).unwrap_err();
1804        assert!(matches!(err, DarError::Io(_)));
1805    }
1806
1807    // ── skip_nul_string ───────────────────────────────────────────────────────
1808
1809    #[test]
1810    fn skip_nul_string_advances_past_nul() {
1811        let data = b"skip\x00rest";
1812        let mut c = Cursor::new(data.to_vec());
1813        skip_nul_string(&mut c).unwrap();
1814        assert_eq!(c.position(), 5); // "skip\0" = 5 bytes consumed
1815    }
1816
1817    #[test]
1818    fn skip_nul_string_eof_returns_io() {
1819        let err = skip_nul_string(&mut Cursor::new(b"no-nul".to_vec())).unwrap_err();
1820        assert!(matches!(err, DarError::Io(_)));
1821    }
1822
1823    // ── find_catalogue ────────────────────────────────────────────────────────
1824
1825    #[test]
1826    fn find_catalogue_body_too_short() {
1827        // Fewer than 6 bytes — can't fill the initial window; label also too short.
1828        let label = [0u8; 10];
1829        let err = find_catalogue(&mut Cursor::new(&[0x01u8, 0x02, 0x03][..]), &label).unwrap_err();
1830        assert!(
1831            matches!(&err, DarError::Corrupt(s) if s == "archive body too short"
1832            || s == "seqt_catalogue not found")
1833        );
1834    }
1835
1836    #[test]
1837    fn find_catalogue_escape_at_start() {
1838        let mut data = [0xAD, 0xFD, 0xEA, 0x77, 0x21, 0x43, 0xFF];
1839        let mut c = Cursor::new(&mut data[..]);
1840        let via_escape = find_catalogue(&mut c, &[0u8; 10]).unwrap();
1841        assert!(via_escape);
1842        assert_eq!(c.position(), 6);
1843    }
1844
1845    #[test]
1846    fn find_catalogue_escape_not_found() {
1847        // 10 bytes of zeros, label is 0xFF×10 so label scan also fails.
1848        let label = [0xFFu8; 10];
1849        let err = find_catalogue(&mut Cursor::new(&[0u8; 10][..]), &label).unwrap_err();
1850        assert!(matches!(&err, DarError::Corrupt(s) if s == "seqt_catalogue not found"));
1851    }
1852
1853    #[test]
1854    fn find_catalogue_label_fallback() {
1855        let label: [u8; 10] = [0xA1, 0xB2, 0xC3, 0xD4, 0xE5, 0xF6, 0x07, 0x18, 0x29, 0x3A];
1856        // Prefix junk (no escape) followed by the label bytes.
1857        let mut data = vec![0x00u8; 5];
1858        data.extend_from_slice(&label);
1859        let mut c = Cursor::new(data);
1860        let via_escape = find_catalogue(&mut c, &label).unwrap();
1861        assert!(!via_escape);
1862        assert_eq!(c.position(), 15); // 5 junk + 10 label consumed
1863    }
1864
1865    // ── skip ──────────────────────────────────────────────────────────────────
1866
1867    #[test]
1868    fn skip_zero_does_not_move_cursor() {
1869        let mut c = Cursor::new(vec![0xFFu8; 10]);
1870        skip(&mut c, 0).unwrap();
1871        assert_eq!(c.position(), 0);
1872    }
1873
1874    #[test]
1875    fn skip_n_advances_cursor() {
1876        let mut c = Cursor::new(vec![0xFFu8; 10]);
1877        skip(&mut c, 7).unwrap();
1878        assert_eq!(c.position(), 7);
1879    }
1880
1881    // ── read_inode_base ───────────────────────────────────────────────────────
1882
1883    #[test]
1884    fn inode_base_bit4_clear_reads_31_bytes() {
1885        // flags(1) + uid(5) + gid(5) + perms(2) + 3×[type(1)+secs(5)] = 31 bytes
1886        let mut data = vec![0x00u8]; // flags (bit4=0)
1887        data.extend_from_slice(&[0x80, 0x00, 0x00, 0x00, 0x00]); // uid
1888        data.extend_from_slice(&[0x80, 0x00, 0x00, 0x00, 0x00]); // gid
1889        data.extend_from_slice(&[0x00, 0x00]); // perms
1890        for _ in 0..3 {
1891            data.push(b's'); // timestamp type
1892            data.extend_from_slice(&[0x80, 0x00, 0x00, 0x00, 0x00]); // seconds
1893        }
1894        data.push(0xFF); // sentinel — must not be consumed
1895        let mut c = Cursor::new(data);
1896        assert_eq!(read_inode_base(&mut c, 11).unwrap().flags, 0x00);
1897        assert_eq!(c.position(), 31);
1898    }
1899
1900    #[test]
1901    fn inode_base_bit4_set_reads_41_bytes() {
1902        // flags(1) + uid(5) + gid(5) + perms(2) + 3×[type(1)+secs(5)] + nlink(5) + field9(5) = 41
1903        let mut data = vec![0x10u8]; // flags (bit4=1)
1904        data.extend_from_slice(&[0x80, 0x00, 0x00, 0x00, 0x00]); // uid
1905        data.extend_from_slice(&[0x80, 0x00, 0x00, 0x00, 0x00]); // gid
1906        data.extend_from_slice(&[0x00, 0x00]); // perms
1907        for _ in 0..3 {
1908            data.push(b's');
1909            data.extend_from_slice(&[0x80, 0x00, 0x00, 0x00, 0x00]);
1910        }
1911        data.extend_from_slice(&[0x80, 0x00, 0x00, 0x00, 0x00]); // nlink
1912        data.extend_from_slice(&[0x80, 0x00, 0x00, 0x00, 0x00]); // field9
1913        data.push(0xFF); // sentinel
1914        let mut c = Cursor::new(data);
1915        assert_eq!(read_inode_base(&mut c, 11).unwrap().flags, 0x10);
1916        assert_eq!(c.position(), 41);
1917    }
1918
1919    // ── skip_fsa ─────────────────────────────────────────────────────────────
1920
1921    #[test]
1922    fn skip_fsa_consumes_tag_size_and_data() {
1923        // tag=infinint(5) + size=infinint(3) + 3 data bytes
1924        let mut data = Vec::new();
1925        data.extend_from_slice(&[0x80, 0x00, 0x00, 0x00, 0x05]); // tag
1926        data.extend_from_slice(&[0x80, 0x00, 0x00, 0x00, 0x03]); // size=3
1927        data.extend_from_slice(&[0xAA, 0xBB, 0xCC]); // data
1928        data.push(0xFF); // sentinel
1929        let mut c = Cursor::new(data);
1930        skip_fsa(&mut c).unwrap();
1931        assert_eq!(c.position(), 13); // 5 + 5 + 3 = 13
1932    }
1933
1934    // ── hardening: malicious / corrupted infinint encodings ───────────────────
1935    //
1936    // A `u64` holds at most 8 data bytes.  The reader's contract is "decode to
1937    // u64 or return Corrupt" — it must never silently truncate an over-wide
1938    // value, overflow while computing the byte count, or loop on a zero run.
1939
1940    #[test]
1941    fn infinint_leading_zero_byte_returns_corrupt() {
1942        // A leading 0x00 skip-byte implies a ≥36-byte group — far beyond u64.
1943        // Must be rejected as Corrupt, not mislabelled as an I/O shortage.
1944        let data = [0x00u8, 0x80, 0x00, 0x00, 0x00, 0x00];
1945        let err = read_infinint(&mut Cursor::new(&data[..])).unwrap_err();
1946        assert!(matches!(err, DarError::Corrupt(_)), "got {err:?}");
1947    }
1948
1949    #[test]
1950    fn infinint_12_byte_group_exceeds_u64_returns_corrupt() {
1951        // 0x20 terminal → pos=2 → 12 data bytes → cannot fit in u64.
1952        // Must error rather than silently truncate to a wrong value.
1953        let mut data = vec![0x20u8];
1954        data.extend_from_slice(&[0x11; 12]);
1955        let err = read_infinint(&mut Cursor::new(data)).unwrap_err();
1956        assert!(matches!(err, DarError::Corrupt(_)), "got {err:?}");
1957    }
1958
1959    #[test]
1960    fn infinint_all_zero_run_returns_corrupt_without_hanging() {
1961        // A run of zero bytes must terminate promptly with Corrupt, never spin
1962        // consuming the whole stream (and never overflow-panic the skip count).
1963        let data = vec![0u8; 4096];
1964        let err = read_infinint(&mut Cursor::new(data)).unwrap_err();
1965        assert!(matches!(err, DarError::Corrupt(_)), "got {err:?}");
1966    }
1967
1968    // ── hardening: unbounded NUL-terminated strings ───────────────────────────
1969
1970    #[test]
1971    fn nul_bytes_without_terminator_is_length_bounded() {
1972        // No NUL in 200 KiB of data: must be rejected once the path cap is hit,
1973        // not grow the buffer until EOF (or OOM on a multi-GiB stream).
1974        let data = vec![b'A'; 200_000];
1975        let err = read_nul_bytes(&mut Cursor::new(data)).unwrap_err();
1976        assert!(matches!(err, DarError::Corrupt(_)), "got {err:?}");
1977    }
1978
1979    #[test]
1980    fn skip_nul_string_without_terminator_is_length_bounded() {
1981        let data = vec![b'A'; 200_000];
1982        let err = skip_nul_string(&mut Cursor::new(data)).unwrap_err();
1983        assert!(matches!(err, DarError::Corrupt(_)), "got {err:?}");
1984    }
1985
1986    // ── hardening: skip must never seek backwards ─────────────────────────────
1987
1988    #[test]
1989    fn skip_value_above_i64_max_returns_corrupt() {
1990        // n > i64::MAX casts to a negative i64 → SeekFrom::Current would seek
1991        // *backwards* on a File (re-reading earlier bytes).  Must be rejected,
1992        // and the stream position must not move.
1993        let mut c = Cursor::new(vec![0u8; 64]);
1994        c.set_position(32);
1995        let err = skip(&mut c, 0x8000_0000_0000_0000).unwrap_err();
1996        assert!(matches!(err, DarError::Corrupt(_)), "got {err:?}");
1997        assert_eq!(c.position(), 32); // unchanged on a rejected skip
1998    }
1999
2000    // ── terminateur trailer (pre-8 catalog locator) ───────────────────────────
2001
2002    #[test]
2003    fn terminateur_reads_catalogue_offset() {
2004        // pos infinint 0x18 = 24; terminator 0xc0 → two leading ones → 2*4 = 8
2005        // bytes back to the infinint.
2006        let data = vec![0x80u8, 0x00, 0x00, 0x00, 0x18, 0x00, 0x00, 0x00, 0xc0];
2007        assert_eq!(read_terminateur(&mut Cursor::new(data)).unwrap(), 24);
2008    }
2009
2010    #[test]
2011    fn terminateur_all_ff_underflows_returns_corrupt() {
2012        let err = read_terminateur(&mut Cursor::new(vec![0xFFu8; 4])).unwrap_err();
2013        assert!(matches!(err, DarError::Corrupt(_)), "got {err:?}");
2014    }
2015
2016    #[test]
2017    fn terminateur_excessive_ff_padding_returns_corrupt() {
2018        let err = read_terminateur(&mut Cursor::new(vec![0xFFu8; 600])).unwrap_err();
2019        assert!(matches!(err, DarError::Corrupt(_)), "got {err:?}");
2020    }
2021
2022    #[test]
2023    fn terminateur_low_terminator_byte_returns_corrupt() {
2024        // Terminator byte 0x01 has no top bit set.
2025        let data = vec![0x80u8, 0x00, 0x00, 0x00, 0x18, 0x01];
2026        let err = read_terminateur(&mut Cursor::new(data)).unwrap_err();
2027        assert!(matches!(err, DarError::Corrupt(_)), "got {err:?}");
2028    }
2029
2030    #[test]
2031    fn terminateur_noncontiguous_high_bits_returns_corrupt() {
2032        // 0xA0 = 1010_0000: top bit set but the high-bit run is not contiguous.
2033        let data = vec![0x80u8, 0x00, 0x00, 0x00, 0x18, 0xA0];
2034        let err = read_terminateur(&mut Cursor::new(data)).unwrap_err();
2035        assert!(matches!(err, DarError::Corrupt(_)), "got {err:?}");
2036    }
2037
2038    // ── find_catalogue: full-scan fallback + body-too-short ────────────────────
2039
2040    #[test]
2041    fn find_catalogue_falls_back_to_full_scan() {
2042        // Escape near the start; a tiny tail window misses it, forcing the
2043        // archive_origin full-scan fallback.
2044        let mut data = vec![0x11u8, 0x22]; // junk before the escape
2045        data.extend_from_slice(&SEQT_CATALOGUE);
2046        data.extend_from_slice(&[0x33u8; 12]); // trailing bytes beyond the tail window
2047        let mut c = Cursor::new(data);
2048        let via_escape = find_catalogue_within(&mut c, &[0u8; 10], 4).unwrap();
2049        assert!(via_escape);
2050        assert_eq!(c.position(), 2 + SEQT_CATALOGUE.len() as u64);
2051    }
2052
2053    #[test]
2054    fn find_catalogue_full_scan_miss_returns_not_found() {
2055        // No escape and no matching label anywhere; a tiny tail window forces
2056        // the full-scan fallback, which also misses → "not found".
2057        let mut c = Cursor::new(vec![0x11u8; 16]);
2058        let err = find_catalogue_within(&mut c, &[0xABu8; 10], 4).unwrap_err();
2059        assert!(matches!(&err, DarError::Corrupt(s) if s == "seqt_catalogue not found"));
2060    }
2061
2062    #[test]
2063    fn find_catalogue_body_too_short_when_origin_at_eof() {
2064        let mut c = Cursor::new(vec![0u8; 6]);
2065        c.seek(SeekFrom::Start(6)).unwrap();
2066        let err = find_catalogue(&mut c, &[0u8; 10]).unwrap_err();
2067        assert!(matches!(&err, DarError::Corrupt(s) if s == "archive body too short"));
2068    }
2069
2070    // ── decode_stream / CapWriter ────────────────────────────────────────────
2071
2072    #[test]
2073    fn decode_stream_caps_decompression_bomb() {
2074        use flate2::{write::ZlibEncoder, Compression};
2075        use std::io::Write;
2076        let mut enc = ZlibEncoder::new(Vec::new(), Compression::default());
2077        enc.write_all(&[0u8; 4096]).unwrap();
2078        let blob = enc.finish().unwrap();
2079        // Inflates to 4096 bytes but the CapWriter caps output at 16.
2080        let mut sink = Vec::new();
2081        let mut cap = CapWriter {
2082            inner: &mut sink,
2083            written: 0,
2084            max: 16,
2085        };
2086        let err = decode_stream(&blob[..], b'z', &mut cap).unwrap_err();
2087        assert!(matches!(&err, DarError::Corrupt(s) if s.contains("exceeds bound")));
2088    }
2089
2090    #[test]
2091    fn decode_stream_rejects_malformed_zlib() {
2092        let err = decode_stream(
2093            b"not a zlib stream at all".as_slice(),
2094            b'z',
2095            &mut Vec::new(),
2096        )
2097        .unwrap_err();
2098        assert!(matches!(&err, DarError::Corrupt(s) if s.contains("zlib decode failed")));
2099    }
2100
2101    #[test]
2102    fn decode_stream_rejects_malformed_bzip2() {
2103        let err =
2104            decode_stream(b"not a bzip2 stream".as_slice(), b'y', &mut Vec::new()).unwrap_err();
2105        assert!(matches!(&err, DarError::Corrupt(s) if s.contains("bzip2 decode failed")));
2106    }
2107
2108    #[test]
2109    fn decode_stream_rejects_malformed_xz() {
2110        let err = decode_stream(
2111            b"this is not an xz stream".as_slice(),
2112            b'x',
2113            &mut Vec::new(),
2114        )
2115        .unwrap_err();
2116        assert!(matches!(&err, DarError::Corrupt(s) if s.contains("xz decode failed")));
2117    }
2118
2119    #[test]
2120    fn decode_stream_rejects_malformed_zstd() {
2121        let err = decode_stream(b"not a zstd frame".as_slice(), b'd', &mut Vec::new()).unwrap_err();
2122        assert!(matches!(&err, DarError::Corrupt(s) if s.contains("zstd decode failed")));
2123    }
2124
2125    #[test]
2126    fn decode_stream_rejects_unknown_codec() {
2127        // No streamed codec routes here in a full build; a stray byte must error.
2128        let err = decode_stream(b"data".as_slice(), b'?', &mut Vec::new()).unwrap_err();
2129        assert!(
2130            matches!(&err, DarError::Corrupt(s) if s.contains("unrecognised compression codec"))
2131        );
2132    }
2133
2134    #[test]
2135    fn header_flags_single_two_byte_and_overlong() {
2136        // Single byte (low bit clear): value is `byte & 0xFE`.
2137        assert_eq!(read_header_flags(&mut [0x10u8].as_slice()).unwrap(), 0x10);
2138        // Two bytes (first low bit set = continuation): 0x09,0x08 -> 0x0808.
2139        assert_eq!(
2140            read_header_flags(&mut [0x09u8, 0x08].as_slice()).unwrap(),
2141            0x0808
2142        );
2143        // A field that never terminates within 8 bytes is rejected.
2144        let err = read_header_flags(&mut [0xFFu8; 9].as_slice()).unwrap_err();
2145        assert!(matches!(&err, DarError::Corrupt(s) if s.contains("flag field too large")));
2146    }
2147
2148    #[test]
2149    fn compr_bs_edition_one_is_zero() {
2150        // Edition < 2 has no flag field, hence no block size.
2151        assert_eq!(read_compr_bs(&mut b"cmdline\x00rest".as_slice(), 1), 0);
2152    }
2153
2154    #[test]
2155    fn compr_bs_read_after_initial_offset() {
2156        // cmd_line "\0" | flags 0x0808 (HAS_COMPRESS_BS + INITIAL_OFFSET) |
2157        // initial_offset (skipped) | compr_bs = 42.
2158        let mut buf = vec![0x00u8]; // empty command line
2159        buf.extend_from_slice(&[0x09, 0x08]); // flags = 0x0808
2160        buf.extend_from_slice(&[0x80, 0, 0, 0, 0]); // initial_offset = 0
2161        buf.extend_from_slice(&[0x80, 0, 0, 0, 42]); // compr_bs = 42
2162        assert_eq!(read_compr_bs(&mut buf.as_slice(), 11), 42);
2163    }
2164
2165    #[test]
2166    fn cap_writer_forwards_within_bound_and_fails_over() {
2167        use std::io::Write;
2168        let mut sink = Vec::new();
2169        let mut w = CapWriter {
2170            inner: &mut sink,
2171            written: 0,
2172            max: 4,
2173        };
2174        assert_eq!(w.write(b"ab").unwrap(), 2); // within bound
2175        w.flush().unwrap();
2176        let err = w.write(b"cde").unwrap_err(); // 2 + 3 > 4
2177        assert_eq!(err.to_string(), "decompressed data exceeds bound");
2178        assert_eq!(sink, b"ab");
2179    }
2180}