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}