Skip to main content

disk_forensic/
logical.rs

1//! Logical file containers — image formats that hold a *file tree*, not a raw
2//! disk.
3//!
4//! Some forensic containers carry captured files rather than a block device:
5//! AccessData **AD1** (FTK "Custom Content Image") and **AFF4-Logical**
6//! (`aff4:FileImage` collections). They have no partition table or filesystem to
7//! walk with the partition parsers, so they do not fit [`crate::container::open`]
8//! (which yields a `Read + Seek` *disk* view). [`open`] is their home: it lists
9//! entries (logical path + metadata) and reads a file's bytes.
10//!
11//! ```no_run
12//! let mut img = disk_forensic::logical::open(std::path::Path::new("evidence.ad1"))?;
13//! for e in img.entries() {
14//!     println!("{} ({} bytes){}", e.path, e.size, if e.is_dir { " [dir]" } else { "" });
15//! }
16//! # Ok::<(), Box<dyn std::error::Error>>(())
17//! ```
18
19use std::fs::File;
20use std::path::Path;
21
22use crate::container::{sniff, ContainerFormat};
23
24/// One entry in a logical container's file tree.
25#[derive(Debug, Clone, PartialEq, Eq)]
26#[cfg_attr(feature = "serde", derive(serde::Serialize))]
27pub struct LogicalEntry {
28    /// Logical path as recorded in the image (`/`-separated).
29    pub path: String,
30    /// `true` for a directory node (no file data). AFF4-Logical carries only
31    /// files, so its entries are always `false`.
32    pub is_dir: bool,
33    /// Uncompressed content length in bytes (`0` for directories).
34    pub size: u64,
35}
36
37/// Failure opening or reading a logical container.
38#[derive(Debug, thiserror::Error)]
39pub enum LogicalError {
40    /// I/O failure opening or reading the file.
41    #[error("I/O error: {0}")]
42    Io(#[from] std::io::Error),
43    /// The file is not a logical container this module handles (e.g. it is a raw
44    /// disk image or a *physical* AFF4 — open those with
45    /// [`crate::container::open`]). The message names the sniffed format.
46    #[error("{0:?} is not a logical file container: {1}")]
47    NotLogical(ContainerFormat, String),
48    /// An AD1 read failed (corrupt structure, encrypted image, I/O).
49    #[error("AD1 error: {0}")]
50    Ad1(#[from] ad1::Ad1Error),
51    /// An AFF4-Logical read failed.
52    #[error("AFF4 error: {0}")]
53    Aff4(#[from] aff4::Aff4Error),
54    /// A DAR read failed.
55    #[error("DAR error: {0}")]
56    Dar(#[from] dar::DarError),
57    /// [`LogicalImage::read_file`] was given an out-of-range entry index.
58    #[error("no entry at index {0}")]
59    NoSuchEntry(usize),
60    /// [`LogicalImage::read_file`] was asked to read a directory entry.
61    #[error("entry '{0}' is a directory, not a file")]
62    IsDirectory(String),
63}
64
65/// The reader backing a [`LogicalImage`], one per logical container format.
66enum Backend {
67    /// AD1 (`ad1-core`).
68    Ad1(ad1::Ad1Reader),
69    /// AFF4-Logical (`aff4::LogicalContainer`).
70    Aff4(aff4::LogicalContainer),
71    /// DAR (`dar-core`).
72    Dar(dar::DarReader<std::fs::File>),
73}
74
75/// An opened logical file container: its entry list plus the backend reader.
76pub struct LogicalImage {
77    format: ContainerFormat,
78    entries: Vec<LogicalEntry>,
79    backend: Backend,
80}
81
82impl core::fmt::Debug for LogicalImage {
83    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
84        f.debug_struct("LogicalImage")
85            .field("format", &self.format)
86            .field("entries", &self.entries.len())
87            .finish()
88    }
89}
90
91impl LogicalImage {
92    /// The logical container format that was opened.
93    #[must_use]
94    pub fn format(&self) -> ContainerFormat {
95        self.format
96    }
97
98    /// The file tree: one [`LogicalEntry`] per captured file/directory, in the
99    /// order the backend records them. Index into this slice for
100    /// [`Self::read_file`].
101    #[must_use]
102    pub fn entries(&self) -> &[LogicalEntry] {
103        &self.entries
104    }
105
106    /// Read the full content of the file at `index` in [`Self::entries`].
107    ///
108    /// # Errors
109    /// [`LogicalError::NoSuchEntry`] if `index` is out of range,
110    /// [`LogicalError::IsDirectory`] for a directory entry, or the backend's
111    /// [`LogicalError::Ad1`] / [`LogicalError::Aff4`] on a read fault.
112    pub fn read_file(&mut self, index: usize) -> Result<Vec<u8>, LogicalError> {
113        let meta = self
114            .entries
115            .get(index)
116            .ok_or(LogicalError::NoSuchEntry(index))?;
117        if meta.is_dir {
118            return Err(LogicalError::IsDirectory(meta.path.clone()));
119        }
120        match &mut self.backend {
121            Backend::Ad1(reader) => {
122                let entry = reader
123                    .entries()
124                    .get(index)
125                    .ok_or(LogicalError::NoSuchEntry(index))?;
126                let size = entry.size;
127                let mut buf = vec![0u8; usize::try_from(size).unwrap_or(usize::MAX)];
128                let mut filled: u64 = 0;
129                while filled < size {
130                    let n = reader.read_at(entry, filled, &mut buf[filled as usize..])?;
131                    if n == 0 {
132                        break;
133                    }
134                    filled += n as u64;
135                }
136                buf.truncate(filled as usize);
137                Ok(buf)
138            }
139            Backend::Aff4(container) => {
140                // aff4's read_file borrows &mut self while the entry is borrowed
141                // from &self.files(); clone the entry to release that borrow.
142                let entry = container
143                    .files()
144                    .get(index)
145                    .ok_or(LogicalError::NoSuchEntry(index))?
146                    .clone();
147                Ok(container.read_file(&entry)?)
148            }
149            Backend::Dar(reader) => {
150                // entries() returns an owned Vec, so the borrow ends before the
151                // &mut extract; index by position to recover the byte-exact path.
152                let entry = reader
153                    .entries()
154                    .into_iter()
155                    .nth(index)
156                    .ok_or(LogicalError::NoSuchEntry(index))?;
157                Ok(reader.extract(&entry.path)?)
158            }
159        }
160    }
161}
162
163/// Open a logical file container (AD1 or AFF4-Logical) at `path`.
164///
165/// Sniffs the format and dispatches to the matching reader. A raw disk image or
166/// a *physical* AFF4 is rejected with [`LogicalError::NotLogical`] naming the
167/// format — open those with [`crate::container::open`] instead.
168///
169/// # Errors
170/// [`LogicalError::Io`] on a read failure, [`LogicalError::NotLogical`] for a
171/// non-logical input, or the backend error for a corrupt/encrypted container.
172pub fn open(path: &Path) -> Result<LogicalImage, LogicalError> {
173    let format = {
174        let mut file = File::open(path)?;
175        sniff(&mut file)?
176    };
177    match format {
178        ContainerFormat::Ad1 => open_ad1(path),
179        ContainerFormat::Aff4 => open_aff4_logical(path),
180        ContainerFormat::Dar => open_dar(path),
181        other => Err(LogicalError::NotLogical(
182            other,
183            "not a logical file container — try disk_forensic::container::open".into(),
184        )),
185    }
186}
187
188/// Open an AD1 image and project its entries.
189fn open_ad1(path: &Path) -> Result<LogicalImage, LogicalError> {
190    let reader = ad1::Ad1Reader::open(path)?;
191    let entries = reader
192        .entries()
193        .iter()
194        .map(|e| LogicalEntry {
195            path: e.path.clone(),
196            is_dir: e.is_dir,
197            size: e.size,
198        })
199        .collect();
200    Ok(LogicalImage {
201        format: ContainerFormat::Ad1,
202        entries,
203        backend: Backend::Ad1(reader),
204    })
205}
206
207/// Open an AFF4-Logical container and project its entries.
208///
209/// A *physical* AFF4 (or an encrypted one) is not a logical container: classify
210/// first and reject it with [`LogicalError::NotLogical`] so the caller is sent
211/// to [`crate::container::open`], rather than getting an opaque parse error.
212fn open_aff4_logical(path: &Path) -> Result<LogicalImage, LogicalError> {
213    match aff4::container_kind(path)? {
214        aff4::ContainerKind::Logical => {}
215        aff4::ContainerKind::Disk => {
216            return Err(LogicalError::NotLogical(
217                ContainerFormat::Aff4,
218                "this AFF4 is a physical disk image — open it with disk_forensic::container::open"
219                    .into(),
220            ));
221        }
222        aff4::ContainerKind::Encrypted => {
223            return Err(LogicalError::NotLogical(
224                ContainerFormat::Aff4,
225                "this AFF4 is encrypted (aff4:EncryptedStream) — needs a password".into(),
226            ));
227        }
228    }
229    let container = aff4::LogicalContainer::open(path)?;
230    let entries = container
231        .files()
232        .iter()
233        .map(|e| LogicalEntry {
234            path: e.original_file_name.clone(),
235            is_dir: false,
236            size: e.size,
237        })
238        .collect();
239    Ok(LogicalImage {
240        format: ContainerFormat::Aff4,
241        entries,
242        backend: Backend::Aff4(container),
243    })
244}
245
246/// Open a DAR archive and project its entries.
247///
248/// DAR records paths as raw bytes (it archives filesystems that do not guarantee
249/// UTF-8); the listed [`LogicalEntry::path`] is the lossy-UTF-8 display form,
250/// while [`LogicalImage::read_file`] indexes back into the reader's own entries
251/// for the byte-exact extraction key.
252fn open_dar(path: &Path) -> Result<LogicalImage, LogicalError> {
253    let reader = dar::DarReader::open(File::open(path)?)?;
254    let entries = reader
255        .entries()
256        .iter()
257        .map(|e| LogicalEntry {
258            path: String::from_utf8_lossy(&e.path).into_owned(),
259            is_dir: matches!(e.kind, dar::EntryKind::Directory),
260            size: e.size,
261        })
262        .collect();
263    Ok(LogicalImage {
264        format: ContainerFormat::Dar,
265        entries,
266        backend: Backend::Dar(reader),
267    })
268}