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}