Skip to main content

ad1/
lib.rs

1//! `ad1` — pure-Rust reader for the **AccessData AD1** logical image container
2//! (FTK Imager "Custom Content Image").
3//!
4//! AD1 is a *logical* file/folder container — NOT a sector-level disk image. It
5//! stores a tree of files + per-file metadata (name, timestamps, attributes) +
6//! the file data in zlib-compressed chunks + stored MD5/SHA1 hashes, across one
7//! or more segments (`.ad1`, `.ad2`, …). So this reader exposes a **virtual
8//! filesystem** (path → bytes + metadata), like a zip/tar reader — there is no
9//! block device / partition / filesystem layer underneath it.
10//!
11//! The on-disk layout follows the al3ks1s/AD1-tools reverse-engineered reference
12//! (see `docs/format.md`). All integers are little-endian; tree addresses are
13//! logical offsets handled internally by `segment::SegmentSet` (private).
14
15#![forbid(unsafe_code)]
16#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
17
18mod segment;
19
20#[cfg(feature = "testfix")]
21pub mod testfix;
22
23#[cfg(feature = "vfs")]
24mod vfs;
25#[cfg(feature = "vfs")]
26pub use vfs::Ad1Vfs;
27
28use std::collections::HashSet;
29use std::fs::File;
30use std::io::Read;
31use std::path::Path;
32
33use flate2::read::ZlibDecoder;
34use safe_read::{le_u32, le_u64};
35use segment::SegmentSet;
36
37/// Marker string present in every AD1 segment header.
38pub const AD1_SEGMENTED_MARKER: &[u8] = b"ADSEGMENTEDFILE\x00";
39
40// --- hardening limits (Paranoid Gatekeeper) ------------------------------
41/// Physical bytes read up front to parse the segment + logical headers.
42const HEADER_WINDOW: usize = 0x300;
43/// Largest accepted item-name length.
44const MAX_NAME_LEN: usize = 4096;
45/// Largest accepted metadata record payload.
46const MAX_META_DATA: usize = 65_536;
47/// Largest accepted metadata-record count per item.
48const MAX_META_RECORDS: usize = 4096;
49/// Largest accepted total tree-node count.
50const MAX_ENTRIES: usize = 5_000_000;
51/// Largest accepted zlib chunk size (64 MiB; the format's typical value is 64 KiB).
52const MAX_CHUNK_SIZE: u32 = 64 * 1024 * 1024;
53
54/// Item-type value for a folder node (`AD1_FOLDER_SIGNATURE`).
55const ITEM_TYPE_FOLDER: u32 = 0x05;
56
57/// Errors from reading an AD1 image.
58#[derive(Debug, thiserror::Error)]
59pub enum Ad1Error {
60    #[error("I/O error: {0}")]
61    Io(#[from] std::io::Error),
62    #[error("not an AD1 image: {0}")]
63    NotAd1(String),
64    #[error("unsupported AD1 feature: {0}")] // e.g. ADCRYPT (encrypted)
65    Unsupported(String),
66    #[error("malformed AD1 structure: {0}")]
67    Malformed(String),
68}
69
70/// One node in the AD1 logical file tree.
71#[derive(Debug, Clone)]
72pub struct Ad1Entry {
73    /// Logical path within the image (POSIX-style, `/`-separated).
74    pub path: String,
75    /// True for a directory node (no file data).
76    pub is_dir: bool,
77    /// Uncompressed file size in bytes (0 for directories).
78    pub size: u64,
79    /// Raw item-type code from the header (0 = file, 5 = folder).
80    pub item_type: u32,
81    /// Stored MD5 (lowercase hex) if the image carries one for this file.
82    pub md5: Option<String>,
83    /// Stored SHA1 (lowercase hex) if present.
84    pub sha1: Option<String>,
85    /// Stored modified timestamp (`YYYYMMDDThhmmss`) if present.
86    pub modified: Option<String>,
87    /// Stored accessed timestamp if present.
88    pub accessed: Option<String>,
89    /// Stored changed/created timestamp if present.
90    pub changed: Option<String>,
91    /// Logical address of this file's zlib chunk table (0 if none).
92    pub(crate) zlib_addr: u64,
93}
94
95/// A reader over an AD1 logical image (its first segment plus any `.ad2`…).
96#[derive(Debug)]
97pub struct Ad1Reader {
98    segments: SegmentSet,
99    image_version: u32,
100    chunk_size: u32,
101    segment_count: u32,
102    entries: Vec<Ad1Entry>,
103}
104
105/// Parsed item-header fields needed to walk the tree.
106struct RawItem {
107    next_item_addr: u64,
108    first_child_addr: u64,
109    first_metadata_addr: u64,
110    zlib_metadata_addr: u64,
111    decompressed_size: u64,
112    item_type: u32,
113    name: String,
114}
115
116impl Ad1Reader {
117    /// Open an AD1 image given the path to its **first** segment (`*.ad1`);
118    /// subsequent segments are discovered alongside it.
119    ///
120    /// # Errors
121    /// - [`Ad1Error::NotAd1`] if the signature is absent (the bytes are shown),
122    /// - [`Ad1Error::Unsupported`] for `ADCRYPT` (encrypted) images,
123    /// - [`Ad1Error::Malformed`] / [`Ad1Error::Io`] for structural / I/O faults.
124    pub fn open(first_segment: &Path) -> Result<Self, Ad1Error> {
125        let mut f = File::open(first_segment)?;
126        let mut head = vec![0u8; HEADER_WINDOW];
127        let n = f.read(&mut head)?;
128        head.truncate(n);
129
130        // --- detection --------------------------------------------------
131        if head.len() >= 8 && &head[0..7] == b"ADCRYPT" {
132            return Err(Ad1Error::Unsupported(format!(
133                "ADCRYPT (encrypted AD1) — decryption is out of scope; signature {}",
134                hex_preview(&head)
135            )));
136        }
137        if head.len() < 15 || &head[0..15] != b"ADSEGMENTEDFILE" {
138            return Err(Ad1Error::NotAd1(format!(
139                "expected ADSEGMENTEDFILE, found signature {}",
140                hex_preview(&head)
141            )));
142        }
143
144        // --- segment header ---------------------------------------------
145        let segment_count = le_u32(&head, 0x1c).max(1);
146        let fragments_size = le_u32(&head, 0x22);
147        if fragments_size == 0 {
148            return Err(Ad1Error::Malformed(
149                "segment header fragments_size is 0".into(),
150            ));
151        }
152
153        // --- logical header ---------------------------------------------
154        let image_version = le_u32(&head, 0x210);
155        let chunk_size = le_u32(&head, 0x218);
156        let first_item_addr = le_u64(&head, 0x224);
157        if chunk_size == 0 || chunk_size > MAX_CHUNK_SIZE {
158            return Err(Ad1Error::Malformed(format!(
159                "implausible zlib chunk size {chunk_size} (image version {image_version})"
160            )));
161        }
162
163        let segments = SegmentSet::open(first_segment, segment_count, fragments_size)?;
164
165        let mut entries = Vec::new();
166        if first_item_addr != 0 {
167            walk_tree(&segments, first_item_addr, &mut entries)?;
168        }
169
170        Ok(Self {
171            segments,
172            image_version,
173            chunk_size,
174            segment_count,
175            entries,
176        })
177    }
178
179    /// The logical file tree (depth-first, directories before their children).
180    #[must_use]
181    pub fn entries(&self) -> &[Ad1Entry] {
182        &self.entries
183    }
184
185    /// AD1 format version recorded in the logical header (commonly 3 or 4).
186    #[must_use]
187    pub fn image_version(&self) -> u32 {
188        self.image_version
189    }
190
191    /// Maximum decompressed bytes per zlib data chunk.
192    #[must_use]
193    pub fn chunk_size(&self) -> u32 {
194        self.chunk_size
195    }
196
197    /// Number of segments declared by the image header.
198    #[must_use]
199    pub fn segment_count(&self) -> u32 {
200        self.segment_count
201    }
202
203    /// 1-based indices of segments the header declares but that are absent on
204    /// disk (feeds the `AD1-SEGMENT-MISSING` audit). Empty for a complete image.
205    #[must_use]
206    pub fn missing_segments(&self) -> Vec<u32> {
207        self.segments.missing()
208    }
209
210    /// Read up to `buf.len()` decompressed bytes of `entry` starting at
211    /// `offset`, inflating only the zlib chunks the range overlaps.
212    ///
213    /// Returns the number of bytes written (0 at or past end of file, or for a
214    /// directory).
215    ///
216    /// # Errors
217    /// [`Ad1Error::Malformed`] / [`Ad1Error::Io`] on a corrupt chunk table or
218    /// decompression failure.
219    pub fn read_at(
220        &self,
221        entry: &Ad1Entry,
222        offset: u64,
223        buf: &mut [u8],
224    ) -> Result<usize, Ad1Error> {
225        if entry.is_dir || entry.size == 0 || offset >= entry.size || buf.is_empty() {
226            return Ok(0);
227        }
228        let want_total = (buf.len() as u64).min(entry.size - offset) as usize;
229        if want_total == 0 {
230            return Ok(0);
231        }
232        if entry.zlib_addr == 0 {
233            return Err(Ad1Error::Malformed(format!(
234                "file '{}' has {} bytes but no chunk table",
235                entry.path, entry.size
236            )));
237        }
238
239        let cs = u64::from(self.chunk_size);
240        let count = le_u64(&self.segments.read(entry.zlib_addr, 8)?, 0);
241        // A chunk table cannot have more addresses than the image has bytes/8.
242        // `count >= max_addrs` is the overflow-safe form of `count + 1 > max_addrs`.
243        let max_addrs = self.segments.capacity() / 8 + 2;
244        if count == 0 || count >= max_addrs {
245            return Err(Ad1Error::Malformed(format!(
246                "file '{}' declares implausible chunk count {count}",
247                entry.path
248            )));
249        }
250        let table_len = (count as usize).saturating_add(1).saturating_mul(8);
251        let addr_bytes = self
252            .segments
253            .read(entry.zlib_addr.saturating_add(8), table_len)?;
254        let addr = |i: u64| le_u64(&addr_bytes, (i.saturating_mul(8)) as usize);
255
256        let end = offset.saturating_add(want_total as u64);
257        let mut produced = 0usize;
258        let mut cur = offset;
259        let mut ci = offset / cs;
260        while cur < end && ci < count {
261            let (start, stop) = (addr(ci), addr(ci + 1));
262            if stop < start {
263                return Err(Ad1Error::Malformed(format!(
264                    "file '{}' chunk {ci} has non-monotonic addresses",
265                    entry.path
266                )));
267            }
268            let comp = self.segments.read(start, (stop - start) as usize)?;
269            let raw = inflate(&comp, self.chunk_size as usize)?;
270            let chunk_base = ci.saturating_mul(cs);
271            let chunk_end = chunk_base.saturating_add(raw.len() as u64);
272            // A chunk that inflated to fewer than `cs` bytes leaves a hole before
273            // this chunk's base: the declared size lies. Stop with what we have
274            // (the auditor flags the short read as AD1-SIZE-LIE).
275            if cur < chunk_base {
276                break;
277            }
278            if cur < chunk_end {
279                let from = (cur - chunk_base) as usize;
280                let n = (raw.len() - from).min((end - cur) as usize);
281                buf[produced..produced + n].copy_from_slice(&raw[from..from + n]);
282                produced += n;
283                cur += n as u64;
284            }
285            ci += 1;
286        }
287        Ok(produced)
288    }
289}
290
291/// Format up to the first 16 bytes of `buf` as lowercase hex for diagnostics.
292fn hex_preview(buf: &[u8]) -> String {
293    use std::fmt::Write as _;
294    let take = buf.len().min(16);
295    let mut s = String::with_capacity(take * 2);
296    for b in &buf[..take] {
297        let _ = write!(s, "{b:02x}");
298    }
299    s
300}
301
302/// Inflate one zlib chunk, capping output at `max` bytes (decompression-bomb guard).
303fn inflate(comp: &[u8], max: usize) -> Result<Vec<u8>, Ad1Error> {
304    let mut out = Vec::new();
305    ZlibDecoder::new(comp)
306        .take(max as u64)
307        .read_to_end(&mut out)?;
308    Ok(out)
309}
310
311/// Read the item-header fields at logical `addr`.
312fn read_item(seg: &SegmentSet, addr: u64) -> Result<RawItem, Ad1Error> {
313    let head = seg.read(addr, 0x30)?;
314    let name_len = le_u32(&head, 0x2c) as usize;
315    if name_len > MAX_NAME_LEN {
316        return Err(Ad1Error::Malformed(format!(
317            "item at {addr:#x} declares name length {name_len} (> {MAX_NAME_LEN})"
318        )));
319    }
320    let name_bytes = seg.read(addr + 0x30, name_len)?;
321    // Match the reference: map '/' to '_' so names never break path joins.
322    let name: String = String::from_utf8_lossy(&name_bytes)
323        .chars()
324        .map(|c| if c == '/' { '_' } else { c })
325        .collect();
326    Ok(RawItem {
327        next_item_addr: le_u64(&head, 0x00),
328        first_child_addr: le_u64(&head, 0x08),
329        first_metadata_addr: le_u64(&head, 0x10),
330        zlib_metadata_addr: le_u64(&head, 0x18),
331        decompressed_size: le_u64(&head, 0x20),
332        item_type: le_u32(&head, 0x28),
333        name,
334    })
335}
336
337/// Metadata fields collected for one item.
338#[derive(Default)]
339struct Meta {
340    md5: Option<String>,
341    sha1: Option<String>,
342    modified: Option<String>,
343    accessed: Option<String>,
344    changed: Option<String>,
345}
346
347/// Walk an item's metadata linked list, collecting the fields we surface.
348fn read_metadata(seg: &SegmentSet, first_addr: u64) -> Result<Meta, Ad1Error> {
349    let mut meta = Meta::default();
350    let mut addr = first_addr;
351    let mut seen = HashSet::new();
352    let mut count = 0usize;
353    while addr != 0 {
354        if !seen.insert(addr) {
355            break; // cycle in the metadata chain — stop, keep what we have
356        }
357        count += 1;
358        // cov:unreachable in unit tests — DoS backstop against an unbounded
359        // metadata chain; exercised by the fuzz target rather than a contrived
360        // 4097-record fixture.
361        if count > MAX_META_RECORDS {
362            return Err(Ad1Error::Malformed(format!(
363                "metadata chain exceeds {MAX_META_RECORDS} records"
364            )));
365        }
366        let h = seg.read(addr, 0x14)?;
367        let next = le_u64(&h, 0x00);
368        let category = le_u32(&h, 0x08);
369        let key = le_u32(&h, 0x0c);
370        let dlen = le_u32(&h, 0x10) as usize;
371        if dlen > MAX_META_DATA {
372            return Err(Ad1Error::Malformed(format!(
373                "metadata record at {addr:#x} declares data length {dlen} (> {MAX_META_DATA})"
374            )));
375        }
376        let data = seg.read(addr + 0x14, dlen)?;
377        let as_str = || {
378            String::from_utf8_lossy(&data)
379                .trim_end_matches('\0')
380                .to_string()
381        };
382        match (category, key) {
383            (0x01, 0x5001) => meta.md5 = Some(as_str()),
384            (0x01, 0x5002) => meta.sha1 = Some(as_str()),
385            (0x05, 0x07) => meta.accessed = Some(as_str()),
386            (0x05, 0x08) => meta.modified = Some(as_str()),
387            (0x05, 0x09) => meta.changed = Some(as_str()),
388            _ => {}
389        }
390        addr = next;
391    }
392    Ok(meta)
393}
394
395/// Walk the file tree from `first_item_addr`, producing entries in DFS preorder.
396///
397/// Iterative (explicit stack) so a deep or wide tree cannot overflow the call
398/// stack, with a visited-set guard against cyclic `next`/`child` pointers.
399fn walk_tree(
400    seg: &SegmentSet,
401    first_item_addr: u64,
402    entries: &mut Vec<Ad1Entry>,
403) -> Result<(), Ad1Error> {
404    let mut stack: Vec<(u64, Option<String>)> = vec![(first_item_addr, None)];
405    let mut seen = HashSet::new();
406    while let Some((addr, parent_path)) = stack.pop() {
407        if addr == 0 {
408            continue;
409        }
410        if !seen.insert(addr) {
411            return Err(Ad1Error::Malformed(format!(
412                "tree cycle: item at {addr:#x} visited twice"
413            )));
414        }
415        // cov:unreachable in unit tests — DoS backstop against an unbounded tree;
416        // exercised by the fuzz target rather than a 5M-node fixture.
417        if entries.len() >= MAX_ENTRIES {
418            return Err(Ad1Error::Malformed(format!(
419                "tree exceeds {MAX_ENTRIES} entries"
420            )));
421        }
422        let item = read_item(seg, addr)?;
423        let path = match &parent_path {
424            None => item.name.clone(),
425            Some(p) => format!("{p}/{}", item.name),
426        };
427        let is_dir = item.item_type == ITEM_TYPE_FOLDER;
428        let meta = read_metadata(seg, item.first_metadata_addr)?;
429        entries.push(Ad1Entry {
430            path: path.clone(),
431            is_dir,
432            size: item.decompressed_size,
433            item_type: item.item_type,
434            md5: meta.md5,
435            sha1: meta.sha1,
436            modified: meta.modified,
437            accessed: meta.accessed,
438            changed: meta.changed,
439            zlib_addr: item.zlib_metadata_addr,
440        });
441        // Push sibling first, then child, so the child subtree is emitted first.
442        if item.next_item_addr != 0 {
443            stack.push((item.next_item_addr, parent_path.clone()));
444        }
445        if item.first_child_addr != 0 {
446            stack.push((item.first_child_addr, Some(path)));
447        }
448    }
449    Ok(())
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    #[test]
457    fn marker_is_the_documented_string() {
458        assert_eq!(&AD1_SEGMENTED_MARKER[..15], b"ADSEGMENTEDFILE");
459    }
460
461    #[test]
462    fn open_missing_file_is_io_error() {
463        assert!(matches!(
464            Ad1Reader::open(Path::new("/nonexistent.ad1")),
465            Err(Ad1Error::Io(_))
466        ));
467    }
468
469    #[test]
470    fn hex_preview_caps_at_16_bytes() {
471        let buf = [0xabu8; 32];
472        assert_eq!(hex_preview(&buf).len(), 32); // 16 bytes -> 32 hex chars
473    }
474}