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