Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
*.E?? filter=lfs diff=lfs merge=lfs -text
*.e?? filter=lfs diff=lfs merge=lfs -text
*.aff filter=lfs diff=lfs merge=lfs -text
*.AFF filter=lfs diff=lfs merge=lfs -text
*.dd filter=lfs diff=lfs merge=lfs -text
*.DD filter=lfs diff=lfs merge=lfs -text
2 changes: 2 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ jobs:
- windows-latest
env:
CARGO_TERM_COLOR: always
NTFS_TESTDATA_REQUIRED: 1
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
lfs: true

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
Expand Down
13 changes: 12 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,15 @@
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here http://doc.crates.io/guide.html#cargotoml-vs-cargolock
Cargo.lock
*.rmeta
*.rmeta

# Vendored third-party code lives in `external/`.
# Keep heavyweight deps ignored, but track `external/refs/` (specs/docs we cite from code).
external/*
# !external/refs/
!external/refs/**/*.md
!external/refs/**/*.pdf
# Temporary scratch space for downloaded artifacts / experiments.
external/refs/tmp/
.DS_Store
.cursor/debug.log
9 changes: 9 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
repos:
- repo: local
hooks:
- id: cargo-fmt
name: cargo fmt --all --check
entry: cargo fmt --all --check
language: system
pass_filenames: false
files: '\.rs$'
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ The crate root lives in `src/lib.rs` with supporting modules in `src/attribute`,

## Build, Test, and Development Commands
Run `cargo build --all-targets` for a full local compile, and `cargo test --all-features` before pushing to mirror CI. Use `cargo bench --bench benchmark` when touching performance-critical code paths. `cargo fmt` and `cargo clippy --all-targets --all-features` should produce a clean workspace; address new lints or explain why they cannot be resolved.
To catch formatting issues before CI, install the repo's pre-commit hook (`pre-commit install`) after installing `pre-commit`.

## Coding Style & Naming Conventions
Follow Rust 2024 idioms and let `rustfmt` enforce four-space indentation and line wrapping. Favour descriptive snake_case for modules, functions, and test names, and PascalCase for types and enums. Prefer early returns with `?`, explicit error types via `thiserror`, and structured logging through the `log` macros. Keep public API additions documented with concise doc comments.
Expand Down
9 changes: 9 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ authors = ["Omer Ben-Amram <omerbenamram@gmail.com>"]
edition = "2024"
rust-version = "1.90"

[workspace]
members = [
".",
"crates/forensic-image",
"crates/aff",
"crates/ntfs",
"crates/ntfs-explorer-gui",
]

[dependencies]
log = { version = "0.4", features = ["release_max_level_debug"] }
encoding = "0.2"
Expand Down
39 changes: 39 additions & 0 deletions crates/aff/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
[package]
name = "aff"
version = "0.1.0"
edition = "2024"
rust-version = "1.90"
license = "MIT/Apache-2.0"
description = "AFF (Advanced Forensic Format) reader with optional crypto/signature verification"
repository = "https://github.com/omerbenamram/mft"
readme = "README.md"

[dependencies]
forensic-image = { path = "../forensic-image" }
thiserror = "2"
log = { version = "0.4", features = ["release_max_level_debug"] }
lru = "0.16.1"
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }

# Optional: decrypt `/aes256` segments + verify `/sha256` signatures (AFFLIB semantics).
openssl = { version = "0.10", features = ["vendored"], optional = true }

# Optional: LZMA page decompression (AFFLIB uses LZMA-Alone framing).
lzma-rs = { version = "0.3", optional = true }

# CLI tools
clap = { version = "4", features = ["derive"] }
anyhow = "1"

[dev-dependencies]
tempfile = "3.23"
assert_cmd = "2.1.1"
predicates = "3.1"

[features]
default = ["crypto", "lzma"]

crypto = ["dep:openssl"]
lzma = ["dep:lzma-rs"]


35 changes: 35 additions & 0 deletions crates/aff/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# `aff` — Advanced Forensic Format (AFF) reader

This crate provides **read-only** access to AFF containers with behavior closely aligned to
**AFFLIBv3**.

## Supported containers

- **AFF1** single-file (`.aff`)
- **AFM** (`.afm`) metadata + split-raw payload (`.000`, `.001`, …)
- **AFD** directory container (`file_000.aff`, `file_001.aff`, …)

## Quick start

```rust
use aff::AffOpenOptions;
use forensic_image::ReadAt;

let img = AffOpenOptions::new().open("image.aff")?;
let mut buf = [0u8; 512];
img.read_exact_at(0, &mut buf)?;
# Ok::<(), aff::Error>(())
```

## Features

- **`crypto`** (default): decrypt `/aes256` segments (read-side) + verify `/sha256` signatures (read-side)
- **`lzma`** (default): LZMA page decompression (`AF_PAGE_COMP_ALG_LZMA`)

## Reference materials

This repo vendors reference materials under `external/refs/` (AFFLIBv3 snapshot + public specs).
These are used for **correctness** and **parity testing**; the `aff` crate itself does not link to
AFFLIB.


178 changes: 178 additions & 0 deletions crates/aff/src/backends/afd.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
//! AFD (AFF directory) backend.
//!
//! An AFD container is a directory containing multiple `.aff` files named like:
//! - `file_000.aff`
//! - `file_001.aff`
//! - ...
//!
//! Segment lookup follows AFFLIB’s semantics: the first subfile containing a segment wins.
//! Missing pages are treated as zero-filled regions.

use super::aff1::Aff1Image;
use super::backend::{Backend, ContainerKind, Segment};
use crate::{Error, Result};
use forensic_image::ReadAt;
use std::collections::{HashMap, HashSet};
use std::io;
use std::path::Path;
use std::sync::Arc;

#[derive(Debug)]
pub(crate) struct AfdImage {
files: Vec<Arc<Aff1Image>>,
page_size: usize,
image_size: u64,
page_map: HashMap<u64, usize>, // page_index -> file index
}

impl AfdImage {
pub(crate) fn open_with(path: impl AsRef<Path>, page_cache_pages: usize) -> Result<Self> {
let path = path.as_ref();
if !path.is_dir() {
return Err(Error::InvalidFormat {
message: "AFD path is not a directory",
});
}

let mut found = Vec::new();
for entry in std::fs::read_dir(path)? {
let entry = entry?;
let file_name = entry.file_name();
let Some(s) = file_name.to_str() else {
continue;
};
if let Some(idx) = parse_afd_file_index(s) {
found.push((idx, entry.path()));
}
}
found.sort_by_key(|(i, _)| *i);

if found.is_empty() {
return Err(Error::InvalidFormat {
message: "AFD directory contains no file_###.aff entries",
});
}

let mut files = Vec::new();
for (_idx, p) in found {
files.push(Arc::new(Aff1Image::open_with(p, page_cache_pages)?));
}

let page_size = files[0].page_size();
if page_size == 0 {
return Err(Error::InvalidData {
message: "AFD pagesize cannot be 0".to_string(),
});
}
for f in &files[1..] {
if f.page_size() != page_size {
return Err(Error::InvalidData {
message: "AFD pagesize mismatch across subfiles".to_string(),
});
}
}

let mut image_size = 0u64;
for f in &files {
image_size = image_size.max(f.len());
}

// Build a page -> file mapping (first file wins).
let mut page_map: HashMap<u64, usize> = HashMap::new();
for (file_idx, f) in files.iter().enumerate() {
for page in f.page_indices() {
page_map.entry(page).or_insert(file_idx);
}
}

Ok(Self {
files,
page_size,
image_size,
page_map,
})
}
}

impl ReadAt for AfdImage {
fn len(&self) -> u64 {
self.image_size
}

fn read_exact_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
if offset.saturating_add(buf.len() as u64) > self.len() {
return Err(io::Error::from(io::ErrorKind::UnexpectedEof));
}

let mut remaining = buf.len();
let mut out_pos = 0usize;
let mut cur = offset;

while remaining > 0 {
let page_index = cur / self.page_size as u64;
let within = (cur % self.page_size as u64) as usize;
let take = remaining.min(self.page_size - within);

if let Some(&file_idx) = self.page_map.get(&page_index) {
let file = &self.files[file_idx];
file.read_exact_at(cur, &mut buf[out_pos..out_pos + take])?;
} else {
buf[out_pos..out_pos + take].fill(0);
}

out_pos += take;
remaining -= take;
cur = cur.saturating_add(take as u64);
}

Ok(())
}
}

impl Backend for AfdImage {
fn kind(&self) -> ContainerKind {
ContainerKind::Afd
}

fn page_size(&self) -> usize {
self.page_size
}

fn segment_names(&self) -> Vec<String> {
// Union across all subfiles (deduped + sorted).
let mut seen = HashSet::new();
for f in &self.files {
for n in f.segment_names() {
seen.insert(n);
}
}
let mut out = seen.into_iter().collect::<Vec<_>>();
out.sort();
out
}

fn read_segment(&self, name: &str) -> io::Result<Option<Segment>> {
for f in &self.files {
if let Some(seg) = f.read_segment(name)? {
return Ok(Some(seg));
}
}
Ok(None)
}
}

fn parse_afd_file_index(name: &str) -> Option<u32> {
// Matches "file_###.aff"
let (prefix, rest) = name.split_once('_')?;
if prefix != "file" {
return None;
}
let (digits, ext) = rest.split_once('.')?;
if ext != "aff" {
return None;
}
if digits.len() != 3 || !digits.as_bytes().iter().all(|b| b.is_ascii_digit()) {
return None;
}
digits.parse::<u32>().ok()
}
Loading