From 96c18ab17b19beaf4a9b865dff6d2b5b3754c35e Mon Sep 17 00:00:00 2001 From: Omer Ben-Amram Date: Fri, 26 Dec 2025 16:11:09 +0200 Subject: [PATCH 1/8] Add NTFS tooling, GUI explorer, and LFS test fixtures Squashed from 11 commits on the ntfs branch. Includes: - New ntfs crate (filesystem + image backends) and CLI tools - ntfs-explorer-gui updates - LFS-backed NTFS fixtures + provenance/checksums - CI checkout configured for LFS --- .gitattributes | 6 + .github/workflows/test.yml | 2 + .gitignore | 6 +- Cargo.toml | 7 + crates/ntfs-explorer-gui/Cargo.toml | 40 + crates/ntfs-explorer-gui/src/app.rs | 1272 ++++++++++++ .../src/components/address_bar.css | 156 ++ .../src/components/address_bar.rs | 73 + .../src/components/context_menu.css | 163 ++ .../src/components/context_menu.rs | 101 + .../src/components/details_view.css | 262 +++ .../src/components/details_view.rs | 570 ++++++ .../ntfs-explorer-gui/src/components/mod.rs | 15 + .../src/components/navigation_pane.css | 183 ++ .../src/components/navigation_pane.rs | 172 ++ .../src/components/resize.css | 36 + .../src/components/resize.rs | 35 + .../src/components/status_bar.css | 55 + .../src/components/status_bar.rs | 51 + .../src/components/toolbar.css | 128 ++ .../src/components/toolbar.rs | 63 + crates/ntfs-explorer-gui/src/icons.rs | 573 ++++++ crates/ntfs-explorer-gui/src/main.rs | 41 + crates/ntfs-explorer-gui/src/menus.rs | 75 + crates/ntfs-explorer-gui/src/mft_only.rs | 199 ++ crates/ntfs-explorer-gui/src/settings.rs | 119 ++ crates/ntfs-explorer-gui/src/styles.rs | 74 + crates/ntfs-explorer-gui/src/styles/base.css | 193 ++ .../ntfs-explorer-gui/src/styles/tokens.css | 183 ++ crates/ntfs/Cargo.toml | 64 + crates/ntfs/README.md | 7 + crates/ntfs/examples/ntfs_extract.rs | 38 + crates/ntfs/src/bin/ntfs_info.rs | 671 ++++++ crates/ntfs/src/bin/ntfs_info/cli_utils.rs | 11 + crates/ntfs/src/bin/ntfs_mount.rs | 121 ++ crates/ntfs/src/image/aff.rs | 229 +++ crates/ntfs/src/image/ewf.rs | 852 ++++++++ crates/ntfs/src/image/mod.rs | 75 + crates/ntfs/src/image/raw.rs | 58 + crates/ntfs/src/lib.rs | 9 + crates/ntfs/src/ntfs/compression/lznt1.rs | 165 ++ crates/ntfs/src/ntfs/compression/mod.rs | 1 + crates/ntfs/src/ntfs/data_stream.rs | 598 ++++++ crates/ntfs/src/ntfs/efs/crypto.rs | 630 ++++++ crates/ntfs/src/ntfs/efs/metadata.rs | 1218 +++++++++++ crates/ntfs/src/ntfs/efs/mod.rs | 28 + crates/ntfs/src/ntfs/efs/pfx.rs | 220 ++ crates/ntfs/src/ntfs/error.rs | 29 + .../ntfs/src/ntfs/filesystem/file_system.rs | 1814 +++++++++++++++++ crates/ntfs/src/ntfs/filesystem/mod.rs | 58 + crates/ntfs/src/ntfs/index/mod.rs | 286 +++ crates/ntfs/src/ntfs/mod.rs | 15 + crates/ntfs/src/ntfs/name/file_name.rs | 97 + crates/ntfs/src/ntfs/name/mod.rs | 100 + crates/ntfs/src/ntfs/name/upcase.rs | 63 + crates/ntfs/src/ntfs/usn/journal.rs | 338 +++ crates/ntfs/src/ntfs/usn/mod.rs | 431 ++++ crates/ntfs/src/ntfs/volume.rs | 126 ++ crates/ntfs/src/ntfs/volume_header.rs | 320 +++ crates/ntfs/src/parse/error.rs | 84 + crates/ntfs/src/parse/hexdump.rs | 81 + crates/ntfs/src/parse/mod.rs | 8 + crates/ntfs/src/parse/reader.rs | 178 ++ crates/ntfs/src/tools/mod.rs | 3 + crates/ntfs/src/tools/mount/dokan.rs | 300 +++ crates/ntfs/src/tools/mount/fuse.rs | 372 ++++ crates/ntfs/src/tools/mount/mod.rs | 12 + crates/ntfs/src/tools/mount/vfs.rs | 169 ++ crates/ntfs/tests/common/mod.rs | 68 + crates/ntfs/tests/test_dir_traversal.rs | 40 + crates/ntfs/tests/test_efs_decrypt.rs | 111 + crates/ntfs/tests/test_efs_key_size.rs | 56 + crates/ntfs/tests/test_efs_metadata.rs | 52 + crates/ntfs/tests/test_ewf_regressions.rs | 124 ++ crates/ntfs/tests/test_file_reads.rs | 135 ++ crates/ntfs/tests/test_filesystem_helpers.rs | 68 + crates/ntfs/tests/test_fsntfsinfo_cli.rs | 102 + crates/ntfs/tests/test_image_backends.rs | 81 + crates/ntfs/tests/test_ntfs_undelete_7.rs | 161 ++ crates/ntfs/tests/test_upcase_table.rs | 25 + crates/ntfs/tests/test_volume_mft.rs | 37 + src/entry.rs | 52 +- src/lib.rs | 1 + src/ntfs.rs | 118 ++ testdata/ntfs/7-undel-ntfs/7-ntfs-undel.dd | 3 + testdata/ntfs/7-undel-ntfs/COPYING-GNU.txt | 340 +++ testdata/ntfs/7-undel-ntfs/README.txt | 18 + testdata/ntfs/7-undel-ntfs/index.html | 196 ++ testdata/ntfs/7-undel-ntfs/results.txt | 30 + testdata/ntfs/README.md | 63 + testdata/ntfs/SHA256SUMS | 12 + testdata/ntfs/narrative.txt | 20 + testdata/ntfs/ntfs1-gen0.E01 | 3 + testdata/ntfs/ntfs1-gen0.aff | 3 + testdata/ntfs/ntfs1-gen1.E01 | 3 + testdata/ntfs/ntfs1-gen1.aff | 3 + testdata/ntfs/ntfs1-gen2.E01 | 3 + testdata/ntfs/ntfs1-gen2.xml | 512 +++++ 98 files changed, 17132 insertions(+), 40 deletions(-) create mode 100644 .gitattributes create mode 100644 crates/ntfs-explorer-gui/Cargo.toml create mode 100644 crates/ntfs-explorer-gui/src/app.rs create mode 100644 crates/ntfs-explorer-gui/src/components/address_bar.css create mode 100644 crates/ntfs-explorer-gui/src/components/address_bar.rs create mode 100644 crates/ntfs-explorer-gui/src/components/context_menu.css create mode 100644 crates/ntfs-explorer-gui/src/components/context_menu.rs create mode 100644 crates/ntfs-explorer-gui/src/components/details_view.css create mode 100644 crates/ntfs-explorer-gui/src/components/details_view.rs create mode 100644 crates/ntfs-explorer-gui/src/components/mod.rs create mode 100644 crates/ntfs-explorer-gui/src/components/navigation_pane.css create mode 100644 crates/ntfs-explorer-gui/src/components/navigation_pane.rs create mode 100644 crates/ntfs-explorer-gui/src/components/resize.css create mode 100644 crates/ntfs-explorer-gui/src/components/resize.rs create mode 100644 crates/ntfs-explorer-gui/src/components/status_bar.css create mode 100644 crates/ntfs-explorer-gui/src/components/status_bar.rs create mode 100644 crates/ntfs-explorer-gui/src/components/toolbar.css create mode 100644 crates/ntfs-explorer-gui/src/components/toolbar.rs create mode 100644 crates/ntfs-explorer-gui/src/icons.rs create mode 100644 crates/ntfs-explorer-gui/src/main.rs create mode 100644 crates/ntfs-explorer-gui/src/menus.rs create mode 100644 crates/ntfs-explorer-gui/src/mft_only.rs create mode 100644 crates/ntfs-explorer-gui/src/settings.rs create mode 100644 crates/ntfs-explorer-gui/src/styles.rs create mode 100644 crates/ntfs-explorer-gui/src/styles/base.css create mode 100644 crates/ntfs-explorer-gui/src/styles/tokens.css create mode 100644 crates/ntfs/Cargo.toml create mode 100644 crates/ntfs/README.md create mode 100644 crates/ntfs/examples/ntfs_extract.rs create mode 100644 crates/ntfs/src/bin/ntfs_info.rs create mode 100644 crates/ntfs/src/bin/ntfs_info/cli_utils.rs create mode 100644 crates/ntfs/src/bin/ntfs_mount.rs create mode 100644 crates/ntfs/src/image/aff.rs create mode 100644 crates/ntfs/src/image/ewf.rs create mode 100644 crates/ntfs/src/image/mod.rs create mode 100644 crates/ntfs/src/image/raw.rs create mode 100644 crates/ntfs/src/lib.rs create mode 100644 crates/ntfs/src/ntfs/compression/lznt1.rs create mode 100644 crates/ntfs/src/ntfs/compression/mod.rs create mode 100644 crates/ntfs/src/ntfs/data_stream.rs create mode 100644 crates/ntfs/src/ntfs/efs/crypto.rs create mode 100644 crates/ntfs/src/ntfs/efs/metadata.rs create mode 100644 crates/ntfs/src/ntfs/efs/mod.rs create mode 100644 crates/ntfs/src/ntfs/efs/pfx.rs create mode 100644 crates/ntfs/src/ntfs/error.rs create mode 100644 crates/ntfs/src/ntfs/filesystem/file_system.rs create mode 100644 crates/ntfs/src/ntfs/filesystem/mod.rs create mode 100644 crates/ntfs/src/ntfs/index/mod.rs create mode 100644 crates/ntfs/src/ntfs/mod.rs create mode 100644 crates/ntfs/src/ntfs/name/file_name.rs create mode 100644 crates/ntfs/src/ntfs/name/mod.rs create mode 100644 crates/ntfs/src/ntfs/name/upcase.rs create mode 100644 crates/ntfs/src/ntfs/usn/journal.rs create mode 100644 crates/ntfs/src/ntfs/usn/mod.rs create mode 100644 crates/ntfs/src/ntfs/volume.rs create mode 100644 crates/ntfs/src/ntfs/volume_header.rs create mode 100644 crates/ntfs/src/parse/error.rs create mode 100644 crates/ntfs/src/parse/hexdump.rs create mode 100644 crates/ntfs/src/parse/mod.rs create mode 100644 crates/ntfs/src/parse/reader.rs create mode 100644 crates/ntfs/src/tools/mod.rs create mode 100644 crates/ntfs/src/tools/mount/dokan.rs create mode 100644 crates/ntfs/src/tools/mount/fuse.rs create mode 100644 crates/ntfs/src/tools/mount/mod.rs create mode 100644 crates/ntfs/src/tools/mount/vfs.rs create mode 100644 crates/ntfs/tests/common/mod.rs create mode 100644 crates/ntfs/tests/test_dir_traversal.rs create mode 100644 crates/ntfs/tests/test_efs_decrypt.rs create mode 100644 crates/ntfs/tests/test_efs_key_size.rs create mode 100644 crates/ntfs/tests/test_efs_metadata.rs create mode 100644 crates/ntfs/tests/test_ewf_regressions.rs create mode 100644 crates/ntfs/tests/test_file_reads.rs create mode 100644 crates/ntfs/tests/test_filesystem_helpers.rs create mode 100644 crates/ntfs/tests/test_fsntfsinfo_cli.rs create mode 100644 crates/ntfs/tests/test_image_backends.rs create mode 100644 crates/ntfs/tests/test_ntfs_undelete_7.rs create mode 100644 crates/ntfs/tests/test_upcase_table.rs create mode 100644 crates/ntfs/tests/test_volume_mft.rs create mode 100644 src/ntfs.rs create mode 100644 testdata/ntfs/7-undel-ntfs/7-ntfs-undel.dd create mode 100644 testdata/ntfs/7-undel-ntfs/COPYING-GNU.txt create mode 100644 testdata/ntfs/7-undel-ntfs/README.txt create mode 100644 testdata/ntfs/7-undel-ntfs/index.html create mode 100644 testdata/ntfs/7-undel-ntfs/results.txt create mode 100644 testdata/ntfs/README.md create mode 100644 testdata/ntfs/SHA256SUMS create mode 100644 testdata/ntfs/narrative.txt create mode 100644 testdata/ntfs/ntfs1-gen0.E01 create mode 100644 testdata/ntfs/ntfs1-gen0.aff create mode 100644 testdata/ntfs/ntfs1-gen1.E01 create mode 100644 testdata/ntfs/ntfs1-gen1.aff create mode 100644 testdata/ntfs/ntfs1-gen2.E01 create mode 100644 testdata/ntfs/ntfs1-gen2.xml diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a7fd440 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dcf88ba..fbda0aa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/.gitignore b/.gitignore index f50c861..cb6c529 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,8 @@ # 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 \ No newline at end of file +*.rmeta + +external/ +.DS_Store +.cursor/debug.log diff --git a/Cargo.toml b/Cargo.toml index 10ba4b7..db8eb4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,13 @@ authors = ["Omer Ben-Amram "] edition = "2024" rust-version = "1.90" +[workspace] +members = [ + ".", + "crates/ntfs", + "crates/ntfs-explorer-gui", +] + [dependencies] log = { version = "0.4", features = ["release_max_level_debug"] } encoding = "0.2" diff --git a/crates/ntfs-explorer-gui/Cargo.toml b/crates/ntfs-explorer-gui/Cargo.toml new file mode 100644 index 0000000..d753b81 --- /dev/null +++ b/crates/ntfs-explorer-gui/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "ntfs-explorer-gui" +version = "0.1.0" +edition = "2024" + +[features] +default = ["desktop"] +desktop = ["dioxus/desktop", "dep:rfd"] +liveview = ["dioxus/liveview", "dep:dioxus-liveview"] +web = ["dioxus/web"] + +[dependencies] +dioxus = { version = "0.7.0" } +dioxus-liveview = { version = "0.7.2", optional = true, features = ["axum"] } + +# Native file dialogs (desktop only) +rfd = { version = "0.16.0", optional = true } + +# Background work / async +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } + +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Snapshot reader +ntfs = { path = "../ntfs" } +mft = { path = "../.." } + +# Distributed slice for stylesheet registration +linkme = "0.3" + +# Human-readable sizes / dates +jiff = { version = "0.2.16", default-features = false, features = ["std", "alloc", "tz-system", "tz-fat", "tzdb-zoneinfo", "tzdb-concatenated", "tzdb-bundle-platform", "perf-inline"] } +bytesize = "2" + +# Settings persistence +serde = { version = "1", features = ["derive"] } +serde_json = "1" + + diff --git a/crates/ntfs-explorer-gui/src/app.rs b/crates/ntfs-explorer-gui/src/app.rs new file mode 100644 index 0000000..777908e --- /dev/null +++ b/crates/ntfs-explorer-gui/src/app.rs @@ -0,0 +1,1272 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use dioxus::prelude::*; +use ntfs::ntfs::filesystem::{is_dot_dir_entry, join_ntfs_child_path}; + +use crate::components::{ + AddressBar, ContextMenu, ContextMenuState, DetailsView, EntryRow, NavigationPane, ResizeHandle, + ResizeOverlay, StatusBar, Toolbar, TreeNode, +}; +#[cfg(feature = "desktop")] +use crate::menus; +use crate::mft_only; +use crate::settings; +use crate::styles; + +#[derive(Clone)] +struct LoadedSnapshot { + path: PathBuf, + backend: SnapshotBackend, +} + +#[derive(Clone)] +enum SnapshotBackend { + Ntfs(ntfs::ntfs::FileSystem), + MftOnly(Arc), +} + +impl SnapshotBackend { + fn is_mft_only(&self) -> bool { + matches!(self, Self::MftOnly(_)) + } + + fn can_export(&self) -> bool { + matches!(self, Self::Ntfs(_)) + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct NavResizeState { + start_x: f64, + start_width_px: i32, +} + +pub fn app() -> Element { + // Theme (light by default, like Windows 11) + let theme = use_signal(|| "theme-light"); + + // Persisted UI state (layout widths etc). + let ui_state: Signal = use_signal(settings::load_ui_state); + + // Layout (resizing state only; widths are stored in `ui_state`) + let nav_resize: Signal> = use_signal(|| None); + + // Snapshot state + let snapshot: Signal> = use_signal(|| None); + let snapshot_error: Signal> = use_signal(|| None); + let snapshot_loading = use_signal(|| false); + let action_error: Signal> = use_signal(|| None); + + // Tree state + let tree_root: Signal> = use_signal(|| None); + let selected_dir_id: Signal> = use_signal(|| None); + + // Directory view state + let current_path = use_signal(|| "\\".to_string()); + let dir_entries: Signal> = use_signal(Vec::new); + let dir_loading = use_signal(|| false); + let dir_error: Signal> = use_signal(|| None); + let context_menu: Signal> = use_signal(|| None); + let selected_entry: Signal> = use_signal(|| None); + + // Native menubar events (Desktop). + #[cfg(feature = "desktop")] + { + use dioxus::desktop::use_muda_event_handler; + + let mut snapshot = snapshot; + let mut snapshot_error = snapshot_error; + let mut snapshot_loading = snapshot_loading; + let mut action_error = action_error; + let mut tree_root = tree_root; + let mut selected_dir_id = selected_dir_id; + let mut current_path = current_path; + let mut dir_entries = dir_entries; + let mut dir_loading = dir_loading; + let mut dir_error = dir_error; + let mut context_menu = context_menu; + let mut selected_entry = selected_entry; + + use_muda_event_handler(move |event| { + let id = event.id.as_ref(); + tracing::debug!(target = "ntfs_explorer.menu", id, "menu event"); + + if id == menus::MENU_CLOSE_SNAPSHOT { + snapshot.set(None); + snapshot_error.set(None); + snapshot_loading.set(false); + action_error.set(None); + tree_root.set(None); + selected_dir_id.set(None); + current_path.set("\\".to_string()); + dir_entries.set(Vec::new()); + dir_loading.set(false); + dir_error.set(None); + context_menu.set(None); + selected_entry.set(None); + return; + } + + if id == menus::MENU_REFRESH { + let Some(s) = snapshot.read().clone() else { + return; + }; + let Some(dir_id) = *selected_dir_id.read() else { + return; + }; + + context_menu.set(None); + action_error.set(None); + + let backend = s.backend.clone(); + dir_loading.set(true); + dir_error.set(None); + spawn(async move { + let res = + tokio::task::spawn_blocking(move || load_dir_listing(backend, dir_id)) + .await; + match res { + Ok(Ok(v)) => dir_entries.set(v), + Ok(Err(e)) => dir_error.set(Some(e)), + Err(e) => dir_error.set(Some(format!("list task failed: {e}"))), + } + dir_loading.set(false); + }); + return; + } + + if id == menus::MENU_OPEN_SNAPSHOT { + spawn(async move { + let Some(handle) = rfd::AsyncFileDialog::new() + .add_filter("Disk images", &["img", "dd", "raw", "e01", "aff"]) + .pick_file() + .await + else { + return; + }; + let path = handle.path().to_path_buf(); + + snapshot_loading.set(true); + snapshot_error.set(None); + action_error.set(None); + snapshot.set(None); + tree_root.set(None); + selected_dir_id.set(None); + current_path.set("\\".to_string()); + dir_entries.set(Vec::new()); + dir_loading.set(false); + dir_error.set(None); + context_menu.set(None); + selected_entry.set(None); + + let opened = tokio::task::spawn_blocking(move || open_ntfs_image(path)).await; + match opened { + Ok(Ok(s)) => snapshot.set(Some(s)), + Ok(Err(e)) => snapshot_error.set(Some(e)), + Err(e) => snapshot_error.set(Some(format!("open task failed: {e}"))), + } + + snapshot_loading.set(false); + }); + } + + if id == menus::MENU_OPEN_MFT_SNAPSHOT { + spawn(async move { + // Intentionally no extension filter: `$MFT` snapshots are often saved without + // an extension (e.g. `MFT`). + let Some(handle) = rfd::AsyncFileDialog::new().pick_file().await else { + return; + }; + let path = handle.path().to_path_buf(); + + snapshot_loading.set(true); + snapshot_error.set(None); + action_error.set(None); + snapshot.set(None); + tree_root.set(None); + selected_dir_id.set(None); + current_path.set("\\".to_string()); + dir_entries.set(Vec::new()); + dir_loading.set(false); + dir_error.set(None); + context_menu.set(None); + selected_entry.set(None); + + let opened = tokio::task::spawn_blocking(move || open_mft_snapshot(path)).await; + match opened { + Ok(Ok(s)) => snapshot.set(Some(s)), + Ok(Err(e)) => snapshot_error.set(Some(e)), + Err(e) => snapshot_error.set(Some(format!("open task failed: {e}"))), + } + + snapshot_loading.set(false); + }); + } + }); + } + + // LiveView runs without a native menubar, so provide a path-based "open" flow. + let mut open_path: Signal = use_signal(String::new); + + let on_close_snapshot = { + let mut snapshot = snapshot; + let mut snapshot_error = snapshot_error; + let mut snapshot_loading = snapshot_loading; + let mut action_error = action_error; + let mut tree_root = tree_root; + let mut selected_dir_id = selected_dir_id; + let mut current_path = current_path; + let mut dir_entries = dir_entries; + let mut dir_loading = dir_loading; + let mut dir_error = dir_error; + let mut context_menu = context_menu; + let mut selected_entry = selected_entry; + + Callback::new(move |(): ()| { + snapshot.set(None); + snapshot_error.set(None); + snapshot_loading.set(false); + action_error.set(None); + tree_root.set(None); + selected_dir_id.set(None); + current_path.set("\\".to_string()); + dir_entries.set(Vec::new()); + dir_loading.set(false); + dir_error.set(None); + context_menu.set(None); + selected_entry.set(None); + }) + }; + + let on_open_image_path = { + let open_path = open_path.to_owned(); + let mut snapshot = snapshot; + let mut snapshot_error = snapshot_error; + let mut snapshot_loading = snapshot_loading; + let mut action_error = action_error; + let mut tree_root = tree_root; + let mut selected_dir_id = selected_dir_id; + let mut current_path = current_path; + let mut dir_entries = dir_entries; + let mut dir_loading = dir_loading; + let mut dir_error = dir_error; + let mut context_menu = context_menu; + let mut selected_entry = selected_entry; + + Callback::new(move |(): ()| { + let raw = open_path.read().trim().to_string(); + if raw.is_empty() { + snapshot_error.set(Some( + "Enter a path to an NTFS image (e.g. .img/.dd/.raw/.E01/.aff).".to_string(), + )); + return; + } + let path = PathBuf::from(raw); + + snapshot_loading.set(true); + snapshot_error.set(None); + action_error.set(None); + snapshot.set(None); + tree_root.set(None); + selected_dir_id.set(None); + current_path.set("\\".to_string()); + dir_entries.set(Vec::new()); + dir_loading.set(false); + dir_error.set(None); + context_menu.set(None); + selected_entry.set(None); + + spawn(async move { + let opened = tokio::task::spawn_blocking(move || open_ntfs_image(path)).await; + match opened { + Ok(Ok(s)) => snapshot.set(Some(s)), + Ok(Err(e)) => snapshot_error.set(Some(e)), + Err(e) => snapshot_error.set(Some(format!("open task failed: {e}"))), + } + snapshot_loading.set(false); + }); + }) + }; + + let on_open_mft_path = { + let open_path = open_path.to_owned(); + let mut snapshot = snapshot; + let mut snapshot_error = snapshot_error; + let mut snapshot_loading = snapshot_loading; + let mut action_error = action_error; + let mut tree_root = tree_root; + let mut selected_dir_id = selected_dir_id; + let mut current_path = current_path; + let mut dir_entries = dir_entries; + let mut dir_loading = dir_loading; + let mut dir_error = dir_error; + let mut context_menu = context_menu; + let mut selected_entry = selected_entry; + + Callback::new(move |(): ()| { + let raw = open_path.read().trim().to_string(); + if raw.is_empty() { + snapshot_error.set(Some( + "Enter a path to an $MFT snapshot file (often named `MFT`).".to_string(), + )); + return; + } + let path = PathBuf::from(raw); + + snapshot_loading.set(true); + snapshot_error.set(None); + action_error.set(None); + snapshot.set(None); + tree_root.set(None); + selected_dir_id.set(None); + current_path.set("\\".to_string()); + dir_entries.set(Vec::new()); + dir_loading.set(false); + dir_error.set(None); + context_menu.set(None); + selected_entry.set(None); + + spawn(async move { + let opened = tokio::task::spawn_blocking(move || open_mft_snapshot(path)).await; + match opened { + Ok(Ok(s)) => snapshot.set(Some(s)), + Ok(Err(e)) => snapshot_error.set(Some(e)), + Err(e) => snapshot_error.set(Some(format!("open task failed: {e}"))), + } + snapshot_loading.set(false); + }); + }) + }; + + // Initialize explorer state when snapshot loads + { + let snapshot = snapshot.to_owned(); + let mut tree_root = tree_root; + let mut selected_dir_id = selected_dir_id; + let mut current_path = current_path; + let mut dir_entries = dir_entries; + let mut dir_loading = dir_loading; + let mut dir_error = dir_error; + let mut context_menu = context_menu; + let mut selected_entry = selected_entry; + + use_effect(move || { + let snap = snapshot.read().clone(); + match snap { + None => { + tree_root.set(None); + selected_dir_id.set(None); + current_path.set("\\".to_string()); + dir_entries.set(Vec::new()); + dir_loading.set(false); + dir_error.set(None); + context_menu.set(None); + selected_entry.set(None); + } + Some(s) => { + // Root directory is MFT entry 5. + let root = TreeNode { + name: "\\".to_string(), + entry_id: 5, + path: "\\".to_string(), + is_deleted: false, + expanded: true, + children_loaded: false, + children_loading: true, + children: Vec::new(), + }; + tree_root.set(Some(root)); + selected_dir_id.set(Some(5)); + current_path.set("\\".to_string()); + selected_entry.set(None); + + let backend_tree = s.backend.clone(); + + // Load root child directories for the tree. + { + let mut tree_root = tree_root; + spawn(async move { + let res = tokio::task::spawn_blocking(move || { + load_child_dirs(backend_tree, 5, "\\") + }) + .await; + let children = match res { + Ok(Ok(v)) => v, + Ok(Err(e)) => { + tracing::warn!(target="ntfs_explorer.tree", error=%e, "load root tree failed"); + Vec::new() + } + Err(e) => { + tracing::warn!(target="ntfs_explorer.tree", error=%e, "load root tree task failed"); + Vec::new() + } + }; + if let Some(root) = tree_root.write().as_mut() { + root.children = children; + root.children_loaded = true; + root.children_loading = false; + } + }); + } + + // Load root directory entries for the details view. + { + let backend = s.backend.clone(); + let mut dir_entries = dir_entries; + let mut dir_loading = dir_loading; + let mut dir_error = dir_error; + dir_loading.set(true); + dir_error.set(None); + spawn(async move { + let res = + tokio::task::spawn_blocking(move || load_dir_listing(backend, 5)) + .await; + match res { + Ok(Ok(v)) => dir_entries.set(v), + Ok(Err(e)) => dir_error.set(Some(e)), + Err(e) => dir_error.set(Some(format!("list task failed: {e}"))), + } + dir_loading.set(false); + }); + } + } + } + }); + } + + // Callbacks + let on_select_dir = { + let snapshot = snapshot.to_owned(); + let mut selected_dir_id = selected_dir_id; + let mut current_path = current_path; + let mut dir_entries = dir_entries; + let mut dir_loading = dir_loading; + let mut dir_error = dir_error; + let mut context_menu = context_menu; + let mut selected_entry = selected_entry; + Callback::new(move |(dir_entry_id, path): (u64, String)| { + let Some(s) = snapshot.read().clone() else { + return; + }; + + selected_dir_id.set(Some(dir_entry_id)); + current_path.set(path); + context_menu.set(None); + selected_entry.set(None); + + let backend = s.backend.clone(); + dir_loading.set(true); + dir_error.set(None); + spawn(async move { + let res = + tokio::task::spawn_blocking(move || load_dir_listing(backend, dir_entry_id)) + .await; + match res { + Ok(Ok(v)) => dir_entries.set(v), + Ok(Err(e)) => dir_error.set(Some(e)), + Err(e) => dir_error.set(Some(format!("list task failed: {e}"))), + } + dir_loading.set(false); + }); + }) + }; + + let on_navigate_to_path = { + let snapshot = snapshot.to_owned(); + Callback::new(move |path: String| { + let Some(s) = snapshot.read().clone() else { + return; + }; + + let backend = s.backend.clone(); + spawn(async move { + let path2 = path.clone(); + let res = tokio::task::spawn_blocking(move || match backend { + SnapshotBackend::Ntfs(fs) => { + if path2 == "\\" { + Ok(5_u64) + } else { + fs.resolve_path_including_deleted(path2.as_str()) + .map_err(|e| e.to_string()) + } + } + SnapshotBackend::MftOnly(snap) => { + snap.resolve_dir_path_including_deleted(&path2) + } + }) + .await; + + match res { + Ok(Ok(id)) => on_select_dir.call((id, path)), + Ok(Err(_e)) => on_select_dir.call((5, "\\".to_string())), + Err(_e) => on_select_dir.call((5, "\\".to_string())), + } + }); + }) + }; + + let on_navigate_up = { + let current_path = current_path.to_owned(); + Callback::new(move |(): ()| { + let path = current_path.read().clone(); + if path == "\\" { + return; + } + // Find parent path + let parent = if let Some(idx) = path.rfind('\\') { + if idx == 0 { + "\\".to_string() + } else { + path[..idx].to_string() + } + } else { + "\\".to_string() + }; + + on_navigate_to_path.call(parent); + }) + }; + + let on_clear_selection = { + let mut context_menu = context_menu; + let mut selected_entry = selected_entry; + Callback::new(move |(): ()| { + context_menu.set(None); + selected_entry.set(None); + }) + }; + + let on_toggle_tree = { + let snapshot = snapshot.to_owned(); + let mut tree_root = tree_root; + Callback::new(move |dir_entry_id: u64| { + let Some(s) = snapshot.read().clone() else { + return; + }; + let backend = s.backend.clone(); + + let (should_load, parent_path) = { + let mut root_opt = tree_root.write(); + let Some(root) = root_opt.as_mut() else { + return; + }; + let Some(node) = root.find_mut(dir_entry_id) else { + return; + }; + node.expanded = !node.expanded; + if node.expanded && !node.children_loaded && !node.children_loading { + node.children_loading = true; + (true, node.path.clone()) + } else { + (false, String::new()) + } + }; + + if !should_load { + return; + } + + let mut tree_root2 = tree_root; + spawn(async move { + let res = tokio::task::spawn_blocking(move || { + load_child_dirs(backend, dir_entry_id, &parent_path) + }) + .await; + let children = match res { + Ok(Ok(v)) => v, + Ok(Err(e)) => { + tracing::warn!(target="ntfs_explorer.tree", error=%e, "load tree children failed"); + Vec::new() + } + Err(e) => { + tracing::warn!(target="ntfs_explorer.tree", error=%e, "load tree children task failed"); + Vec::new() + } + }; + let mut root_opt = tree_root2.write(); + if let Some(node) = root_opt + .as_mut() + .and_then(|root| root.find_mut(dir_entry_id)) + { + node.children = children; + node.children_loaded = true; + node.children_loading = false; + } + }); + }) + }; + + let on_show_context_menu = { + let mut context_menu = context_menu; + Callback::new(move |state: ContextMenuState| context_menu.set(Some(state))) + }; + + let on_hide_context_menu = { + let mut context_menu = context_menu; + Callback::new(move |(): ()| context_menu.set(None)) + }; + + #[cfg(feature = "desktop")] + let on_save_as = { + let snapshot = snapshot.to_owned(); + let mut action_error = action_error; + let mut context_menu = context_menu; + Callback::new(move |row: EntryRow| { + let Some(s) = snapshot.read().clone() else { + return; + }; + if row.is_dir { + return; + } + + context_menu.set(None); + action_error.set(None); + + let backend = s.backend.clone(); + if !backend.can_export() { + action_error.set(Some( + "Export is not available for MFT-only snapshots (metadata-only mode)." + .to_string(), + )); + return; + } + spawn(async move { + let Some(handle) = rfd::AsyncFileDialog::new() + .set_file_name(&row.name) + .save_file() + .await + else { + return; + }; + let out_path = handle.path().to_path_buf(); + + let entry_id = row.entry_id; + let res = tokio::task::spawn_blocking(move || { + export_file_default_stream(backend, entry_id, out_path) + }) + .await; + match res { + Ok(Ok(())) => {} + Ok(Err(e)) => action_error.set(Some(e)), + Err(e) => action_error.set(Some(format!("export task failed: {e}"))), + } + }); + }) + }; + + #[cfg(feature = "liveview")] + let on_save_as = { + let mut action_error = action_error; + let mut context_menu = context_menu; + Callback::new(move |_row: EntryRow| { + context_menu.set(None); + action_error.set(Some( + "Export is not implemented in LiveView yet (it runs on the server). Use the desktop build for now." + .to_string(), + )); + }) + }; + + #[cfg(feature = "web")] + let on_save_as = { + let mut action_error = action_error; + Callback::new(move |_row: EntryRow| { + action_error.set(Some( + "File export is not available in the web version.".to_string(), + )); + }) + }; + + let on_select_entry = { + let mut selected_entry = selected_entry; + let mut context_menu = context_menu; + Callback::new(move |row: EntryRow| { + selected_entry.set(Some((row.entry_id, row.name.clone()))); + // Explorer-style: clicking elsewhere dismisses context menus. + context_menu.set(None); + }) + }; + + let on_persist_ui_state = { + let ui_state = ui_state.to_owned(); + Callback::new(move |(): ()| { + let state = ui_state.read().clone(); + spawn(async move { + let res = tokio::task::spawn_blocking(move || settings::save_ui_state(state)).await; + match res { + Ok(Ok(())) => {} + Ok(Err(e)) => { + tracing::warn!(target="ntfs_explorer.ui_state", error=%e, "save ui state failed"); + } + Err(e) => { + tracing::warn!(target="ntfs_explorer.ui_state", error=%e, "save ui state task failed"); + } + } + }); + }) + }; + + let on_set_col_modified_px = { + let mut ui_state = ui_state; + Callback::new(move |px: i32| ui_state.write().col_modified_px = px.clamp(90, 420)) + }; + let on_set_col_type_px = { + let mut ui_state = ui_state; + Callback::new(move |px: i32| ui_state.write().col_type_px = px.clamp(90, 420)) + }; + let on_set_col_size_px = { + let mut ui_state = ui_state; + Callback::new(move |px: i32| ui_state.write().col_size_px = px.clamp(90, 420)) + }; + + // Resizing: left navigation pane width. + let on_nav_resize_start = { + let ui_state = ui_state.to_owned(); + let mut nav_resize = nav_resize; + Callback::new(move |e: MouseEvent| { + e.prevent_default(); + let x = e.client_coordinates().x; + nav_resize.set(Some(NavResizeState { + start_x: x, + start_width_px: ui_state.read().nav_width_px.clamp(220, 520), + })); + }) + }; + let on_nav_resize_move = { + let nav_resize = nav_resize.to_owned(); + let mut ui_state = ui_state; + Callback::new(move |e: MouseEvent| { + let Some(state) = *nav_resize.read() else { + return; + }; + let x = e.client_coordinates().x; + let delta = x - state.start_x; + let next = (state.start_width_px as f64 + delta).round() as i32; + let next = next.clamp(220, 520); + ui_state.write().nav_width_px = next; + }) + }; + let on_nav_resize_end = { + let mut nav_resize = nav_resize; + Callback::new(move |_e: MouseEvent| { + nav_resize.set(None); + on_persist_ui_state.call(()); + }) + }; + + // Computed values + let theme_class = theme.read().to_string(); + let snapshot_path = snapshot + .read() + .as_ref() + .map(|s| s.path.display().to_string()); + let is_mft_only = snapshot + .read() + .as_ref() + .is_some_and(|s| s.backend.is_mft_only()); + let can_export = snapshot + .read() + .as_ref() + .is_some_and(|s| s.backend.can_export()); + let entries_now = dir_entries.read().clone(); + let (items_total, items_deleted, items_encrypted) = { + let total = entries_now.len(); + let deleted = entries_now.iter().filter(|e| e.is_deleted).count(); + let encrypted = entries_now.iter().filter(|e| e.efs_encrypted).count(); + (total, deleted, encrypted) + }; + let can_go_up = current_path.read().as_str() != "\\"; + let nav_width_px = ui_state.read().nav_width_px.clamp(220, 520); + let col_modified_px = ui_state.read().col_modified_px.clamp(90, 420); + let col_type_px = ui_state.read().col_type_px.clamp(90, 420); + let col_size_px = ui_state.read().col_size_px.clamp(90, 420); + let selected_entry_now = selected_entry.read().clone(); + + rsx! { + // Stylesheets + // + // - Desktop/Web: load via the asset pipeline (`asset!()`). + // - LiveView: inline CSS (the default LiveView axum adapter doesn't serve static assets). + if cfg!(feature = "liveview") { + style { "{styles::INLINE_CSS}" } + } else { + for sheet in styles::stylesheets().iter().copied() { + document::Stylesheet { href: sheet.href } + } + } + + div { class: "app {theme_class}", + // Toolbar + Toolbar { + snapshot_path, + is_loading: *snapshot_loading.read(), + is_mft_only, + } + + // LiveView runs without a native menubar, so provide a path-based "open" flow. + if cfg!(feature = "liveview") { + div { + style: "display:flex; gap:8px; align-items:center; padding:8px 12px; border-bottom:1px solid rgba(0,0,0,0.08);", + span { style: "opacity:0.8; font-size:12px;", "Open path:" } + input { + r#type: "text", + placeholder: "/path/to/image.E01 or /path/to/MFT", + value: "{open_path.read()}", + style: "flex:1; min-width: 240px;", + oninput: move |e| open_path.set(e.value()), + } + button { + disabled: *snapshot_loading.read(), + onclick: move |_| on_open_image_path.call(()), + "Open image" + } + button { + disabled: *snapshot_loading.read(), + onclick: move |_| on_open_mft_path.call(()), + "Open MFT" + } + if snapshot.read().is_some() { + button { + disabled: *snapshot_loading.read(), + onclick: move |_| on_close_snapshot.call(()), + "Close" + } + } + } + } + + // Error banners + if let Some(err) = snapshot_error.read().as_ref() { + div { class: "error-banner", "{err}" } + } + if let Some(err) = action_error.read().as_ref() { + div { class: "error-banner", "{err}" } + } + + // Address bar + AddressBar { + path: current_path.read().clone(), + on_navigate_up, + on_navigate_to_path, + can_go_up, + } + + // Main content area (nav pane + details) + div { class: "main-content", + div { style: "width: {nav_width_px}px;", + NavigationPane { + tree_root: tree_root.read().clone(), + selected_path: current_path.read().clone(), + on_toggle: on_toggle_tree, + on_select: on_select_dir, + } + } + + ResizeHandle { + active: nav_resize.read().is_some(), + on_start: on_nav_resize_start, + } + + DetailsView { + entries: entries_now, + base_path: current_path.read().clone(), + is_loading: *dir_loading.read(), + error: dir_error.read().clone(), + has_snapshot: snapshot.read().is_some(), + selected: selected_entry_now, + col_modified_px, + col_type_px, + col_size_px, + on_set_col_modified_px, + on_set_col_type_px, + on_set_col_size_px, + on_persist_layout: on_persist_ui_state, + on_navigate_up, + on_clear_selection, + on_select_dir, + on_select_entry, + on_show_context_menu, + } + } + + if nav_resize.read().is_some() { + ResizeOverlay { + on_move: on_nav_resize_move, + on_end: on_nav_resize_end, + } + } + + // Context menu overlay + ContextMenu { + state: context_menu.read().clone(), + on_close: on_hide_context_menu, + on_save_as, + can_export, + } + + // Status bar + StatusBar { + items_total, + items_deleted, + items_encrypted, + selected_dir_id: *selected_dir_id.read(), + } + } + } +} + +// --------------------------------------------------------------------------- +// Background task helpers +// --------------------------------------------------------------------------- + +fn open_ntfs_volume_auto( + img: Arc, +) -> Result { + fn read_sector(img: &Arc, offset: u64) -> Result<[u8; 512], String> { + let mut buf = [0u8; 512]; + img.read_exact_at(offset, &mut buf) + .map_err(|e| format!("read sector @ 0x{offset:x}: {e}"))?; + Ok(buf) + } + + fn oem_id(sector: &[u8; 512]) -> String { + let raw = §or[3..11]; + String::from_utf8_lossy(raw) + .trim_matches(['\0', ' ']) + .to_string() + } + + fn is_mbr(sector: &[u8; 512]) -> bool { + sector[510] == 0x55 && sector[511] == 0xaa + } + + fn parse_mbr_partitions(sector: &[u8; 512]) -> Vec<(usize, u8, u32, u32)> { + let mut out = Vec::new(); + let pt = §or[446..446 + 64]; + for i in 0..4 { + let e = &pt[i * 16..(i + 1) * 16]; + let ptype = e[4]; + let start_lba = u32::from_le_bytes(e[8..12].try_into().expect("len=4")); + let sectors = u32::from_le_bytes(e[12..16].try_into().expect("len=4")); + if ptype == 0 || start_lba == 0 || sectors == 0 { + continue; + } + out.push((i, ptype, start_lba, sectors)); + } + out + } + + // 1) Fast path: try offset 0. + let open0 = ntfs::ntfs::volume::Volume::open(img.clone(), 0); + if let Ok(v) = open0 { + return Ok(v); + } + let open0_err = open0.unwrap_err().to_string(); + + // 2) Heuristic: check if this looks like an MBR-partitioned disk, and try partition starts. + let sector0 = read_sector(&img, 0)?; + let mut notes: Vec = Vec::new(); + notes.push(format!("offset 0 OEM={}", oem_id(§or0))); + + if is_mbr(§or0) { + let parts = parse_mbr_partitions(§or0); + if parts.is_empty() { + notes + .push("MBR signature present but no non-empty partition entries found".to_string()); + } + + for (idx, ptype, start_lba, sectors) in parts { + let part_offset = u64::from(start_lba) + .checked_mul(512) + .ok_or_else(|| "partition offset overflow".to_string())?; + let boot = read_sector(&img, part_offset)?; + let oem = oem_id(&boot); + notes.push(format!( + "MBR partition {idx}: type=0x{ptype:02x} start_lba={start_lba} sectors={sectors} offset=0x{part_offset:x} OEM={oem}" + )); + + // Only attempt to open candidates that actually look like NTFS. + if &boot[3..11] != b"NTFS " { + continue; + } + if let Ok(v) = ntfs::ntfs::volume::Volume::open(img.clone(), part_offset) { + return Ok(v); + } + } + } + + // If we got here, we failed to locate an NTFS volume. + let mut msg = String::new(); + msg.push_str(&format!("{open0_err}\n")); + msg.push_str("This image does not appear to contain an NTFS volume that this tool can open.\n"); + msg.push_str("Diagnostics:\n"); + for n in notes { + msg.push_str(&format!("- {n}\n")); + } + Err(msg) +} + +fn open_ntfs_image(path: PathBuf) -> Result { + let img = ntfs::image::Image::open(&path).map_err(|e| format!("open image: {e}"))?; + let img: Arc = Arc::new(img); + + let volume = open_ntfs_volume_auto(img).map_err(|e| format!("open NTFS volume: {e}"))?; + let fs = ntfs::ntfs::filesystem::FileSystem::new(volume); + + Ok(LoadedSnapshot { + path, + backend: SnapshotBackend::Ntfs(fs), + }) +} + +fn open_mft_snapshot(path: PathBuf) -> Result { + let snap = mft_only::MftOnlySnapshot::open(&path)?; + Ok(LoadedSnapshot { + path, + backend: SnapshotBackend::MftOnly(snap), + }) +} + +fn load_child_dirs( + backend: SnapshotBackend, + dir_entry_id: u64, + parent_path: &str, +) -> Result, String> { + match backend { + SnapshotBackend::Ntfs(fs) => { + let entries = fs + .read_dir_including_deleted(dir_entry_id) + .map_err(|e| e.to_string())?; + let mut out = Vec::new(); + + for e in entries { + if is_dot_dir_entry(e.name.as_str()) { + continue; + } + let entry = fs + .volume() + .read_mft_entry(e.entry_id) + .map_err(|err| err.to_string())?; + if should_hide_dos_alias(&entry, dir_entry_id, e.name.as_str()) { + continue; + } + if !entry.is_dir() { + continue; + } + let allocated = entry + .header + .flags + .contains(mft::entry::EntryFlags::ALLOCATED); + let child_path = join_ntfs_child_path(parent_path, e.name.as_str()); + out.push(TreeNode { + name: e.name, + entry_id: e.entry_id, + path: child_path, + is_deleted: !allocated, + expanded: false, + children_loaded: false, + children_loading: false, + children: Vec::new(), + }); + } + + out.sort_by(|a, b| { + a.name + .to_ascii_lowercase() + .cmp(&b.name.to_ascii_lowercase()) + }); + Ok(out) + } + SnapshotBackend::MftOnly(snap) => { + let entries = snap.list_children(dir_entry_id); + let mut out = Vec::new(); + + for e in entries { + if is_dot_dir_entry(e.name.as_str()) { + continue; + } + let Some(meta) = snap.entry_meta(e.entry_id) else { + continue; + }; + if !meta.is_dir { + continue; + } + + let child_path = join_ntfs_child_path(parent_path, e.name.as_str()); + out.push(TreeNode { + name: e.name, + entry_id: e.entry_id, + path: child_path, + is_deleted: !meta.is_allocated, + expanded: false, + children_loaded: false, + children_loading: false, + children: Vec::new(), + }); + } + + out.sort_by(|a, b| { + a.name + .to_ascii_lowercase() + .cmp(&b.name.to_ascii_lowercase()) + }); + Ok(out) + } + } +} + +fn load_dir_listing(backend: SnapshotBackend, dir_entry_id: u64) -> Result, String> { + match backend { + SnapshotBackend::Ntfs(fs) => { + let entries = fs + .read_dir_including_deleted(dir_entry_id) + .map_err(|e| e.to_string())?; + + let mut out = Vec::with_capacity(entries.len()); + for e in entries { + if is_dot_dir_entry(e.name.as_str()) { + continue; + } + let entry = fs + .volume() + .read_mft_entry(e.entry_id) + .map_err(|err| err.to_string())?; + if should_hide_dos_alias(&entry, dir_entry_id, e.name.as_str()) { + continue; + } + let is_dir = entry.is_dir(); + let allocated = entry + .header + .flags + .contains(mft::entry::EntryFlags::ALLOCATED); + let efs_encrypted = is_entry_efs_encrypted(&entry); + let (size_bytes, modified_unix_s) = entry + .find_best_name_attribute() + .map(|n| (n.logical_size, n.modified.as_second())) + .unwrap_or((0, 0)); + out.push(EntryRow { + name: e.name, + entry_id: e.entry_id, + is_dir, + is_deleted: !allocated, + efs_encrypted, + size_bytes: if is_dir { 0 } else { size_bytes }, + modified_unix_s, + }); + } + + out.sort_by(|a, b| match (a.is_dir, b.is_dir) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a + .name + .to_ascii_lowercase() + .cmp(&b.name.to_ascii_lowercase()), + }); + Ok(out) + } + SnapshotBackend::MftOnly(snap) => { + let entries = snap.list_children(dir_entry_id); + let mut out = Vec::with_capacity(entries.len()); + + for e in entries { + if is_dot_dir_entry(e.name.as_str()) { + continue; + } + let Some(meta) = snap.entry_meta(e.entry_id) else { + continue; + }; + + out.push(EntryRow { + name: e.name, + entry_id: e.entry_id, + is_dir: meta.is_dir, + is_deleted: !meta.is_allocated, + efs_encrypted: meta.efs_encrypted, + size_bytes: if meta.is_dir { 0 } else { e.logical_size }, + modified_unix_s: e.modified_unix_s, + }); + } + + out.sort_by(|a, b| match (a.is_dir, b.is_dir) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a + .name + .to_ascii_lowercase() + .cmp(&b.name.to_ascii_lowercase()), + }); + Ok(out) + } + } +} + +/// Hide the DOS 8.3 alias (`SYSTEM~1`) when a Win32 long name exists for the same parent dir. +/// +/// This matches the default Windows Explorer behavior (it does not show 8.3 aliases as separate +/// directory entries). +fn should_hide_dos_alias(entry: &mft::MftEntry, parent_dir_id: u64, dir_entry_name: &str) -> bool { + use mft::attribute::MftAttributeType; + use mft::attribute::x30::FileNamespace; + + let mut has_win32 = false; + let mut is_this_dos = false; + for attr in entry + .iter_attributes_matching(Some(vec![MftAttributeType::FileName])) + .filter_map(std::result::Result::ok) + { + let Some(fname) = attr.data.into_file_name() else { + continue; + }; + if fname.parent.entry != parent_dir_id { + continue; + } + + if matches!( + fname.namespace, + FileNamespace::Win32 | FileNamespace::Win32AndDos + ) { + has_win32 = true; + } + if fname.name == dir_entry_name && fname.namespace == FileNamespace::DOS { + is_this_dos = true; + } + } + + is_this_dos && has_win32 +} + +fn is_entry_efs_encrypted(entry: &mft::MftEntry) -> bool { + for attr in entry + .iter_attributes_matching(Some(vec![ + mft::attribute::MftAttributeType::StandardInformation, + ])) + .filter_map(std::result::Result::ok) + { + if let Some(si) = attr.data.into_standard_info() + && si + .file_flags + .contains(mft::attribute::FileAttributeFlags::FILE_ATTRIBUTE_ENCRYPTED) + { + return true; + } + } + false +} + +#[cfg(feature = "desktop")] +fn export_file_default_stream( + backend: SnapshotBackend, + entry_id: u64, + out_path: PathBuf, +) -> Result<(), String> { + match backend { + SnapshotBackend::Ntfs(fs) => fs + .export_file_default_stream_to_path(entry_id, &out_path) + .map_err(|e| format!("export $DATA: {e}")), + SnapshotBackend::MftOnly(_) => Err( + "export is not available for MFT-only snapshots (no NTFS cluster access)".to_string(), + ), + } +} diff --git a/crates/ntfs-explorer-gui/src/components/address_bar.css b/crates/ntfs-explorer-gui/src/components/address_bar.css new file mode 100644 index 0000000..4604fce --- /dev/null +++ b/crates/ntfs-explorer-gui/src/components/address_bar.css @@ -0,0 +1,156 @@ +/* ===== Address Bar (Windows 11 Breadcrumb style) ===== */ +.address-bar { + display: flex; + align-items: center; + height: 44px; + padding: 0 12px; + background: var(--bg-surface); + border-bottom: 1px solid var(--border-subtle); + gap: 10px; + flex-shrink: 0; +} + +/* Navigation button group */ +.address-nav-group { + display: flex; + align-items: center; + gap: 2px; +} + +/* Navigation buttons */ +.address-nav-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: transparent; + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + color: var(--text-secondary); + transition: all 0.08s ease; + padding: 0; +} + +.address-nav-btn:hover:not(:disabled) { + background: var(--bg-hover); + border-color: var(--border-subtle); + color: var(--text-primary); +} + +.address-nav-btn:active:not(:disabled) { + background: var(--bg-selected); +} + +.address-nav-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.address-nav-icon { + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; +} + +.address-nav-icon svg { + width: 16px; + height: 16px; +} + +/* Path breadcrumbs container */ +.address-path { + display: flex; + align-items: center; + flex: 1; + height: 34px; + padding: 0 8px; + background: var(--address-bg); + border: 1px solid var(--address-border); + border-radius: 4px; + overflow: hidden; + gap: 2px; +} + +.address-path:focus-within { + border-color: var(--address-border-focus); + box-shadow: 0 0 0 1px var(--address-border-focus); +} + +/* Breadcrumb segment */ +.address-segment { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; + white-space: nowrap; + transition: background 0.08s ease; + background: transparent; + border: none; + font-family: inherit; + color: var(--text-secondary); +} + +.address-segment:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.address-segment.is-current { + font-weight: 500; + color: var(--text-primary); + cursor: default; +} + +.address-segment.is-current:hover { + background: transparent; +} + +.address-segment.is-root { + color: var(--text-secondary); +} + +.address-segment-icon { + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.address-segment-icon svg { + width: 16px; + height: 16px; +} + +/* Scale down folder icons in breadcrumbs */ +.address-segment:not(.is-root) .address-segment-icon svg { + width: 14px; + height: 14px; +} + +.address-segment-text { + font-size: 13px; +} + +/* Separator */ +.address-separator { + color: var(--text-tertiary); + display: flex; + align-items: center; + justify-content: center; + width: 12px; + height: 16px; + flex-shrink: 0; +} + +.address-separator svg { + width: 8px; + height: 16px; +} diff --git a/crates/ntfs-explorer-gui/src/components/address_bar.rs b/crates/ntfs-explorer-gui/src/components/address_bar.rs new file mode 100644 index 0000000..b0e0971 --- /dev/null +++ b/crates/ntfs-explorer-gui/src/components/address_bar.rs @@ -0,0 +1,73 @@ +use dioxus::prelude::*; + +use crate::icons; + +#[linkme::distributed_slice(crate::styles::STYLESHEETS)] +static ADDRESS_BAR_CSS: crate::styles::Stylesheet = crate::styles::Stylesheet { + order: 200, + name: "components/address_bar", + href: asset!("/src/components/address_bar.css"), +}; + +#[component] +pub fn AddressBar( + path: String, + on_navigate_up: Callback<()>, + on_navigate_to_path: Callback, + can_go_up: bool, +) -> Element { + // Split path into breadcrumb segments + let segments: Vec<&str> = path.split('\\').filter(|s| !s.is_empty()).collect(); + let mut crumbs: Vec<(String, String, bool)> = Vec::with_capacity(segments.len()); + let mut cur = String::from("\\"); + for (i, seg) in segments.iter().enumerate() { + if cur != "\\" { + cur.push('\\'); + } + cur.push_str(seg); + crumbs.push(( + seg.to_string(), + cur.clone(), + i == segments.len().saturating_sub(1), + )); + } + + rsx! { + div { class: "address-bar", + // Navigation buttons + div { class: "address-nav-group", + button { + class: "address-nav-btn", + disabled: !can_go_up, + onclick: move |_| on_navigate_up.call(()), + title: "Up one level (Alt+Up)", + span { class: "address-nav-icon", {icons::arrow_up()} } + } + } + + // Breadcrumb path + div { class: "address-path", + // Root / Volume + button { + r#type: "button", + class: "address-segment is-root", + onclick: move |_| on_navigate_to_path.call("\\".to_string()), + span { class: "address-segment-icon", {icons::this_pc()} } + span { class: "address-segment-text", "Volume" } + } + + // Path segments + for (label, crumb_path, is_current) in crumbs { + span { class: "address-separator", {icons::breadcrumb_sep()} } + button { + r#type: "button", + class: if is_current { "address-segment is-current" } else { "address-segment" }, + onclick: move |_| on_navigate_to_path.call(crumb_path.clone()), + span { class: "address-segment-icon", {icons::folder_closed()} } + span { class: "address-segment-text", "{label}" } + } + } + } + } + } +} diff --git a/crates/ntfs-explorer-gui/src/components/context_menu.css b/crates/ntfs-explorer-gui/src/components/context_menu.css new file mode 100644 index 0000000..7d5302c --- /dev/null +++ b/crates/ntfs-explorer-gui/src/components/context_menu.css @@ -0,0 +1,163 @@ +/* ===== Context Menu (Windows 11 Acrylic style) ===== */ +.context-overlay { + position: fixed; + inset: 0; + z-index: 1000; + /* Transparent overlay to catch clicks */ +} + +.context-menu { + position: fixed; + min-width: 240px; + max-width: 340px; + background: var(--bg-solid); + border: 1px solid var(--border-default); + border-radius: 8px; + box-shadow: var(--shadow-context-menu); + overflow: hidden; + z-index: 1001; + animation: context-appear 0.12s ease-out; +} + +@keyframes context-appear { + from { + opacity: 0; + transform: scale(0.96) translateY(-4px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +/* Header */ +.context-header { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + background: var(--bg-subtle); +} + +.context-icon { + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.context-icon svg { + width: 20px; + height: 20px; +} + +.context-name { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Divider */ +.context-divider { + height: 1px; + background: var(--border-subtle); + margin: 4px 0; +} + +/* Menu item */ +.context-item { + display: flex; + align-items: center; + width: 100%; + padding: 10px 16px; + gap: 12px; + background: none; + border: none; + cursor: pointer; + text-align: left; + transition: background 0.06s ease; + font-family: inherit; +} + +.context-item:hover:not(:disabled) { + background: var(--bg-hover); +} + +.context-item:active:not(:disabled) { + background: var(--bg-selected); +} + +.context-item:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.context-item-icon { + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--text-secondary); +} + +.context-item-icon svg { + width: 16px; + height: 16px; +} + +.context-item-text { + flex: 1; + font-size: 13px; + color: var(--text-primary); +} + +.context-item-hint { + font-size: 11px; + color: var(--text-tertiary); +} + +/* Info section */ +.context-info { + padding: 10px 16px; +} + +.context-info-row { + display: flex; + align-items: center; + gap: 10px; + padding: 4px 0; +} + +.context-info-icon { + width: 12px; + height: 12px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.context-info-icon svg { + width: 12px; + height: 12px; +} + +.context-info-icon.is-deleted { + color: var(--badge-deleted-text); +} + +.context-info-icon.is-encrypted { + color: var(--badge-efs-text); +} + +.context-info-text { + font-size: 12px; + color: var(--text-secondary); +} diff --git a/crates/ntfs-explorer-gui/src/components/context_menu.rs b/crates/ntfs-explorer-gui/src/components/context_menu.rs new file mode 100644 index 0000000..75746b5 --- /dev/null +++ b/crates/ntfs-explorer-gui/src/components/context_menu.rs @@ -0,0 +1,101 @@ +use dioxus::prelude::*; + +use crate::components::EntryRow; +use crate::icons; + +#[linkme::distributed_slice(crate::styles::STYLESHEETS)] +static CONTEXT_MENU_CSS: crate::styles::Stylesheet = crate::styles::Stylesheet { + order: 300, + name: "components/context_menu", + href: asset!("/src/components/context_menu.css"), +}; + +/// State for the context menu popup. +#[derive(Clone, Debug, PartialEq)] +pub struct ContextMenuState { + pub x: f64, + pub y: f64, + pub row: EntryRow, +} + +#[component] +pub fn ContextMenu( + state: Option, + on_close: Callback<()>, + on_save_as: Callback, + can_export: bool, +) -> Element { + let Some(cm) = state else { + return VNode::empty(); + }; + + let pos = format!("left: {}px; top: {}px;", cm.x, cm.y); + let row = cm.row.clone(); + let is_dir = row.is_dir; + let is_deleted = row.is_deleted; + let is_encrypted = row.efs_encrypted; + let export_disabled = is_dir || !can_export; + + rsx! { + div { + class: "context-overlay", + onclick: move |_| on_close.call(()), + oncontextmenu: move |e| e.prevent_default(), + + div { + class: "context-menu", + style: "{pos}", + onclick: move |e| e.stop_propagation(), + + // Header with file info + div { class: "context-header", + span { class: "context-icon", + if is_dir { + if is_deleted { {icons::folder_deleted()} } else { {icons::folder_closed()} } + } else if is_deleted { + {icons::file_deleted()} + } else { + {icons::file_generic()} + } + } + span { class: "context-name", "{row.name}" } + } + + div { class: "context-divider" } + + // Menu items + button { + class: "context-item", + disabled: export_disabled, + onclick: move |_| on_save_as.call(row.clone()), + span { class: "context-item-icon", {icons::download()} } + span { class: "context-item-text", "Save As…" } + if is_dir { + span { class: "context-item-hint", "(folders not supported)" } + } else if !can_export { + span { class: "context-item-hint", "(MFT-only mode)" } + } + } + + if is_deleted || is_encrypted { + div { class: "context-divider" } + + div { class: "context-info", + if is_deleted { + div { class: "context-info-row", + span { class: "context-info-icon is-deleted", {icons::deleted_badge()} } + span { class: "context-info-text", "This file has been deleted" } + } + } + if is_encrypted { + div { class: "context-info-row", + span { class: "context-info-icon is-encrypted", {icons::encrypted_badge()} } + span { class: "context-info-text", "EFS encrypted (raw export)" } + } + } + } + } + } + } + } +} diff --git a/crates/ntfs-explorer-gui/src/components/details_view.css b/crates/ntfs-explorer-gui/src/components/details_view.css new file mode 100644 index 0000000..4d2af8d --- /dev/null +++ b/crates/ntfs-explorer-gui/src/components/details_view.css @@ -0,0 +1,262 @@ +/* ===== Details View (Windows 11 File List) ===== */ +.details-view { + display: flex; + flex-direction: column; + flex: 1; + background: var(--bg-solid); + overflow: hidden; + /* Column widths (resizable). */ + --col-modified: 180px; + --col-type: 120px; + --col-size: 100px; +} + +/* Column header row */ +.details-header { + display: grid; + grid-template-columns: minmax(200px, 1fr) var(--col-modified) var(--col-type) var(--col-size); + height: 32px; + padding: 0 8px; + background: var(--header-bg); + border-bottom: 1px solid var(--header-border); + gap: 0; + flex-shrink: 0; +} + +.details-header-cell { + position: relative; + display: flex; + align-items: center; + gap: 6px; + height: 100%; + padding: 0 10px; + background: transparent; + border: none; + border-radius: 0; + font-size: 12px; + font-weight: 400; + color: var(--header-text); + user-select: none; + cursor: pointer; + text-align: left; + font-family: inherit; + transition: background 0.06s ease; +} + +.details-header-cell:hover { + background: var(--bg-hover); +} + +.details-header-cell.is-sorted { + color: var(--text-primary); +} + +.details-header-cell:not(.col-size)::after { + content: ""; + position: absolute; + top: 8px; + right: 0; + width: 1px; + height: 16px; + background: var(--border-default); +} + +.details-header-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.details-sort-indicator { + width: 10px; + height: 10px; + display: flex; + align-items: center; + justify-content: center; + color: var(--header-sort-icon); + flex-shrink: 0; +} + +.details-sort-indicator svg { + width: 10px; + height: 10px; +} + +.details-header-cell.is-sorted .details-sort-indicator { + color: var(--accent); +} + +.details-header-cell.col-size { + justify-content: flex-end; + text-align: right; +} + +.details-header-cell.col-size .details-sort-indicator { + margin-left: 6px; +} + +/* Column resizer (header only) */ +.col-resizer { + position: absolute; + top: 0; + right: -3px; + width: 6px; + height: 100%; + cursor: col-resize; + background: transparent; + z-index: 1; +} + +.col-resizer:hover { + background: var(--accent-light); +} + +/* Body (scrollable) */ +.details-body { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 4px 0; +} + +/* Row */ +.details-row { + display: grid; + grid-template-columns: minmax(200px, 1fr) var(--col-modified) var(--col-type) var(--col-size); + height: 28px; + padding: 0 8px; + gap: 0; + cursor: default; + user-select: none; + transition: background 0.06s ease; + border-radius: 4px; + margin: 1px 4px; +} + +.details-row:hover { + background: var(--bg-hover); +} + +.details-row.is-selected { + background: var(--bg-selected); +} + +.details-row.is-selected:hover { + background: var(--bg-selected-hover); +} + +.details-row:active { + background: var(--bg-selected); +} + +/* Deleted row styling */ +.details-row.is-deleted { + opacity: 0.75; +} + +.details-row.is-deleted .details-name { + text-decoration: line-through; + text-decoration-color: var(--text-tertiary); +} + +/* Column definitions */ +.details-col { + padding: 0 10px; + font-size: 13px; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: flex; + align-items: center; +} + +.col-name { + display: flex; + align-items: center; + gap: 8px; +} + +.col-modified { + color: var(--text-secondary); + font-size: 12px; +} + +.col-type { + color: var(--text-secondary); + font-size: 12px; +} + +.col-size { + text-align: right; + justify-content: flex-end; + color: var(--text-secondary); + font-family: "Cascadia Code", "Segoe UI Mono", ui-monospace, Consolas, monospace; + font-size: 11px; +} + +/* Icon */ +.details-icon { + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.details-icon svg { + width: 20px; + height: 20px; +} + +.details-row.is-deleted .details-icon { + opacity: 0.8; +} + +/* Name */ +.details-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Badges */ +.details-badge { + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; + padding: 2px 6px; + border-radius: 3px; + flex-shrink: 0; + margin-left: 8px; +} + +.details-badge.is-deleted { + background: var(--badge-deleted-bg); + border: 1px solid var(--badge-deleted-border); + color: var(--badge-deleted-text); +} + +.details-badge.is-encrypted { + background: var(--badge-efs-bg); + border: 1px solid var(--badge-efs-border); + color: var(--badge-efs-text); +} + +/* Loading spinner */ +.details-spinner { + width: 28px; + height: 28px; + border: 3px solid var(--accent-light); + border-top-color: var(--accent); + border-radius: 50%; + animation: details-spin 0.8s linear infinite; +} + +@keyframes details-spin { + to { + transform: rotate(360deg); + } +} diff --git a/crates/ntfs-explorer-gui/src/components/details_view.rs b/crates/ntfs-explorer-gui/src/components/details_view.rs new file mode 100644 index 0000000..f2acbbc --- /dev/null +++ b/crates/ntfs-explorer-gui/src/components/details_view.rs @@ -0,0 +1,570 @@ +use dioxus::prelude::*; +use ntfs::ntfs::filesystem::join_ntfs_child_path; + +use crate::components::{ContextMenuState, ResizeOverlay}; +use crate::icons; + +#[linkme::distributed_slice(crate::styles::STYLESHEETS)] +static DETAILS_VIEW_CSS: crate::styles::Stylesheet = crate::styles::Stylesheet { + order: 200, + name: "components/details_view", + href: asset!("/src/components/details_view.css"), +}; + +/// A file or folder entry in the details list. +#[derive(Clone, Debug, PartialEq)] +pub struct EntryRow { + pub name: String, + pub entry_id: u64, + pub is_dir: bool, + pub is_deleted: bool, + pub efs_encrypted: bool, + pub size_bytes: u64, + pub modified_unix_s: i64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SortKey { + Name, + Modified, + Type, + Size, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SortDir { + Asc, + Desc, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ResizeCol { + Modified, + Type, + Size, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct ColResizeState { + col: ResizeCol, + start_x: f64, + start_px: i32, +} + +#[component] +pub fn DetailsView( + entries: Vec, + base_path: String, + is_loading: bool, + error: Option, + has_snapshot: bool, + selected: Option<(u64, String)>, + + col_modified_px: i32, + col_type_px: i32, + col_size_px: i32, + on_set_col_modified_px: Callback, + on_set_col_type_px: Callback, + on_set_col_size_px: Callback, + on_persist_layout: Callback<()>, + + on_navigate_up: Callback<()>, + on_clear_selection: Callback<()>, + on_select_dir: Callback<(u64, String)>, + on_select_entry: Callback, + on_show_context_menu: Callback, +) -> Element { + // Sorting state (local to the table). + let sort_key: Signal = use_signal(|| SortKey::Name); + let sort_dir: Signal = use_signal(|| SortDir::Asc); + + let col_resize: Signal> = use_signal(|| None); + + let on_sort = { + let mut sort_key = sort_key; + let mut sort_dir = sort_dir; + Callback::new(move |key: SortKey| { + if *sort_key.read() == key { + let next = match *sort_dir.read() { + SortDir::Asc => SortDir::Desc, + SortDir::Desc => SortDir::Asc, + }; + sort_dir.set(next); + return; + } + + sort_key.set(key); + let default_dir = match key { + SortKey::Name => SortDir::Asc, + SortKey::Type => SortDir::Asc, + SortKey::Modified => SortDir::Desc, + SortKey::Size => SortDir::Desc, + }; + sort_dir.set(default_dir); + }) + }; + + let on_col_resize_start = { + let mut col_resize = col_resize; + Callback::new(move |(col, e): (ResizeCol, MouseEvent)| { + e.prevent_default(); + e.stop_propagation(); + let x = e.client_coordinates().x; + let start_px = match col { + ResizeCol::Modified => col_modified_px, + ResizeCol::Type => col_type_px, + ResizeCol::Size => col_size_px, + }; + col_resize.set(Some(ColResizeState { + col, + start_x: x, + start_px, + })); + }) + }; + let on_col_resize_move = { + let col_resize = col_resize.to_owned(); + Callback::new(move |e: MouseEvent| { + let Some(state) = *col_resize.read() else { + return; + }; + let x = e.client_coordinates().x; + let delta = x - state.start_x; + let next = (state.start_px as f64 + delta).round() as i32; + let next = next.clamp(90, 420); + match state.col { + ResizeCol::Modified => on_set_col_modified_px.call(next), + ResizeCol::Type => on_set_col_type_px.call(next), + ResizeCol::Size => on_set_col_size_px.call(next), + } + }) + }; + let on_col_resize_end = { + let mut col_resize = col_resize; + Callback::new(move |_e: MouseEvent| { + col_resize.set(None); + on_persist_layout.call(()); + }) + }; + + let sort_key_now = *sort_key.read(); + let sort_dir_now = *sort_dir.read(); + + let mut entries = entries; + sort_entries_in_place(&mut entries, sort_key_now, sort_dir_now); + + let entries_for_keys = entries.clone(); + let selected_for_keys = selected.clone(); + let base_path_for_keys = base_path.clone(); + + let table_style = format!( + "--col-modified: {}px; --col-type: {}px; --col-size: {}px;", + col_modified_px, col_type_px, col_size_px + ); + + rsx! { + div { + class: "details-view", + id: "details-kbd-root", + tabindex: "0", + style: "{table_style}", + onkeydown: move |e: KeyboardEvent| { + // Ignore modified shortcuts (Cmd/Ctrl/etc); those are handled by menus. + let mods = e.modifiers(); + if mods.contains(Modifiers::ALT) + || mods.contains(Modifiers::CONTROL) + || mods.contains(Modifiers::META) + || mods.contains(Modifiers::SUPER) + { + return; + } + + let len = entries_for_keys.len(); + if len == 0 { + if e.code() == Code::Escape { + e.prevent_default(); + on_clear_selection.call(()); + } + return; + } + + let cur_idx = selected_for_keys.as_ref().and_then(|(id, name)| { + entries_for_keys + .iter() + .position(|r| r.entry_id == *id && r.name == *name) + }); + + let select_idx = |idx: usize| { + let idx = idx.min(len.saturating_sub(1)); + on_select_entry.call(entries_for_keys[idx].clone()); + // Keep selection visible. + let _ = document::eval( + "document.querySelector('.details-row.is-selected')?.scrollIntoView({block:'nearest'});", + ); + }; + + match e.code() { + Code::ArrowDown => { + e.prevent_default(); + let next = cur_idx.map(|i| (i + 1).min(len - 1)).unwrap_or(0); + select_idx(next); + } + Code::ArrowUp => { + e.prevent_default(); + let next = cur_idx + .and_then(|i| i.checked_sub(1)) + .unwrap_or(len.saturating_sub(1)); + select_idx(next); + } + Code::Home => { + e.prevent_default(); + select_idx(0); + } + Code::End => { + e.prevent_default(); + select_idx(len.saturating_sub(1)); + } + Code::PageDown => { + e.prevent_default(); + let step = 12usize; + let next = cur_idx.map(|i| (i + step).min(len - 1)).unwrap_or(0); + select_idx(next); + } + Code::PageUp => { + e.prevent_default(); + let step = 12usize; + let next = cur_idx.map(|i| i.saturating_sub(step)).unwrap_or(0); + select_idx(next); + } + Code::Enter | Code::NumpadEnter | Code::ArrowRight => { + // Enter/right: open selected folder. + if let Some(i) = cur_idx { + let r = &entries_for_keys[i]; + if r.is_dir { + e.prevent_default(); + let next_path = join_ntfs_child_path( + base_path_for_keys.as_str(), + r.name.as_str(), + ); + on_select_dir.call((r.entry_id, next_path)); + } + } + } + Code::Backspace | Code::ArrowLeft => { + e.prevent_default(); + on_navigate_up.call(()); + } + Code::Escape => { + e.prevent_default(); + on_clear_selection.call(()); + } + _ => {} + } + }, + // Column headers (sortable + resizable) + div { class: "details-header", + button { + r#type: "button", + class: format!("details-header-cell col-name {}", if sort_key_now == SortKey::Name { "is-sorted" } else { "" }), + onclick: move |_| on_sort.call(SortKey::Name), + span { class: "details-header-text", "Name" } + span { class: "details-sort-indicator", + {sort_indicator(sort_key_now == SortKey::Name, sort_dir_now)} + } + } + button { + r#type: "button", + class: format!("details-header-cell col-modified {}", if sort_key_now == SortKey::Modified { "is-sorted" } else { "" }), + onclick: move |_| on_sort.call(SortKey::Modified), + span { class: "details-header-text", "Date modified" } + span { class: "details-sort-indicator", + {sort_indicator(sort_key_now == SortKey::Modified, sort_dir_now)} + } + div { + class: "col-resizer", + onmousedown: move |e| on_col_resize_start.call((ResizeCol::Modified, e)), + } + } + button { + r#type: "button", + class: format!("details-header-cell col-type {}", if sort_key_now == SortKey::Type { "is-sorted" } else { "" }), + onclick: move |_| on_sort.call(SortKey::Type), + span { class: "details-header-text", "Type" } + span { class: "details-sort-indicator", + {sort_indicator(sort_key_now == SortKey::Type, sort_dir_now)} + } + div { + class: "col-resizer", + onmousedown: move |e| on_col_resize_start.call((ResizeCol::Type, e)), + } + } + button { + r#type: "button", + class: format!("details-header-cell col-size {}", if sort_key_now == SortKey::Size { "is-sorted" } else { "" }), + onclick: move |_| on_sort.call(SortKey::Size), + span { class: "details-header-text", "Size" } + span { class: "details-sort-indicator", + {sort_indicator(sort_key_now == SortKey::Size, sort_dir_now)} + } + div { + class: "col-resizer", + onmousedown: move |e| on_col_resize_start.call((ResizeCol::Size, e)), + } + } + } + + // Body + div { + class: "details-body", + onclick: move |_| { + on_clear_selection.call(()); + let _ = document::eval("document.getElementById('details-kbd-root')?.focus();"); + }, + if let Some(err) = error { + div { class: "error-banner", "{err}" } + } else if is_loading { + div { class: "empty-state", + span { class: "details-spinner" } + span { class: "empty-state-title", "Loading…" } + } + } else if !has_snapshot { + div { class: "empty-state", + span { class: "empty-state-icon", {icons::empty_folder_large()} } + span { class: "empty-state-title", "No snapshot loaded" } + span { class: "empty-state-subtitle", + "Open an NTFS image (full features) or an MFT snapshot (metadata-only)" + } + } + } else if entries.is_empty() { + div { class: "empty-state", + span { class: "empty-state-icon", {icons::empty_folder_large()} } + span { class: "empty-state-title", "This folder is empty" } + } + } else { + for entry in entries { + DetailsRow { + // Combine name + entry_id for unique key (handles deleted + live duplicates) + key: "{entry.name}-{entry.entry_id}", + entry: entry.clone(), + base_path: base_path.clone(), + selected: selected + .as_ref() + .is_some_and(|(id, name)| *id == entry.entry_id && name == &entry.name), + on_select_dir, + on_select_entry, + on_show_context_menu, + } + } + } + } + + if col_resize.read().is_some() { + ResizeOverlay { + on_move: on_col_resize_move, + on_end: on_col_resize_end, + } + } + } + } +} + +fn sort_indicator(active: bool, dir: SortDir) -> Element { + if !active { + return rsx! {}; + } + rsx! { + svg { + width: "10", + height: "10", + view_box: "0 0 10 10", + fill: "none", + xmlns: "http://www.w3.org/2000/svg", + if dir == SortDir::Asc { + path { + d: "M5 2L8 6H2L5 2Z", + fill: "currentColor", + } + } else { + path { + d: "M5 8L2 4H8L5 8Z", + fill: "currentColor", + } + } + } + } +} + +#[component] +fn DetailsRow( + entry: EntryRow, + base_path: String, + selected: bool, + on_select_dir: Callback<(u64, String)>, + on_select_entry: Callback, + on_show_context_menu: Callback, +) -> Element { + let mut row_class = String::from("details-row"); + if selected { + row_class.push_str(" is-selected"); + } + if entry.is_deleted { + row_class.push_str(" is-deleted"); + } + if entry.efs_encrypted { + row_class.push_str(" is-encrypted"); + } + + let type_label = if entry.is_dir { "File folder" } else { "File" }; + + let size_str = if entry.is_dir { + String::new() + } else { + format_size(entry.size_bytes) + }; + + let modified_str = format_timestamp(entry.modified_unix_s); + + // Closures need owned copies + let entry_for_click = entry.clone(); + let entry_for_dblclick = entry.clone(); + let entry_for_context = entry.clone(); + let base_for_dblclick = base_path.clone(); + + rsx! { + div { + class: "{row_class}", + onclick: move |e: MouseEvent| { + e.stop_propagation(); + on_select_entry.call(entry_for_click.clone()); + let _ = document::eval("document.getElementById('details-kbd-root')?.focus();"); + }, + ondoubleclick: move |_| { + if entry_for_dblclick.is_dir { + let next_path = join_ntfs_child_path( + base_for_dblclick.as_str(), + entry_for_dblclick.name.as_str(), + ); + on_select_dir.call((entry_for_dblclick.entry_id, next_path)); + } + }, + oncontextmenu: move |e: MouseEvent| { + e.stop_propagation(); + e.prevent_default(); + on_select_entry.call(entry_for_context.clone()); + let _ = document::eval("document.getElementById('details-kbd-root')?.focus();"); + let xy = e.client_coordinates(); + on_show_context_menu.call(ContextMenuState { + x: xy.x, + y: xy.y, + row: entry_for_context.clone(), + }); + }, + + // Name column + div { class: "details-col col-name", + span { class: "details-icon", + {entry_icon(&entry)} + } + span { class: "details-name", "{entry.name}" } + if entry.is_deleted { + span { class: "details-badge is-deleted", "Deleted" } + } + if entry.efs_encrypted { + span { class: "details-badge is-encrypted", "Encrypted" } + } + } + + // Modified column + div { class: "details-col col-modified", "{modified_str}" } + + // Type column + div { class: "details-col col-type", "{type_label}" } + + // Size column + div { class: "details-col col-size", "{size_str}" } + } + } +} + +fn entry_icon(entry: &EntryRow) -> Element { + if entry.is_dir { + if entry.is_deleted { + icons::folder_deleted() + } else { + icons::folder_closed() + } + } else if entry.is_deleted { + icons::file_deleted() + } else { + icons::file_generic() + } +} + +fn format_size(bytes: u64) -> String { + use bytesize::ByteSize; + ByteSize(bytes).to_string() +} + +fn format_timestamp(unix_s: i64) -> String { + use jiff::Timestamp; + use jiff::tz::TimeZone; + + let ts = match Timestamp::from_second(unix_s) { + Ok(ts) => ts, + Err(_) => return "—".to_string(), + }; + ts.to_zoned(TimeZone::system()) + .strftime("%Y-%m-%d %H:%M") + .to_string() +} + +fn sort_entries_in_place(entries: &mut [EntryRow], key: SortKey, dir: SortDir) { + use std::cmp::Ordering; + + let cmp_name = |a: &EntryRow, b: &EntryRow| { + a.name + .to_ascii_lowercase() + .cmp(&b.name.to_ascii_lowercase()) + }; + + let key_cmp = |a: &EntryRow, b: &EntryRow| -> Ordering { + match key { + SortKey::Name => cmp_name(a, b), + SortKey::Modified => a.modified_unix_s.cmp(&b.modified_unix_s), + SortKey::Size => a.size_bytes.cmp(&b.size_bytes), + SortKey::Type => file_type_key(a).cmp(&file_type_key(b)), + } + }; + + entries.sort_by(|a, b| { + // Explorer-style: folders first. + match (a.is_dir, b.is_dir) { + (true, false) => return Ordering::Less, + (false, true) => return Ordering::Greater, + _ => {} + } + + let mut ord = key_cmp(a, b); + if ord == Ordering::Equal { + ord = cmp_name(a, b); + } + if ord == Ordering::Equal { + ord = a.entry_id.cmp(&b.entry_id); + } + + match dir { + SortDir::Asc => ord, + SortDir::Desc => ord.reverse(), + } + }); +} + +fn file_type_key(e: &EntryRow) -> String { + if e.is_dir { + return "folder".to_string(); + } + match e.name.rsplit_once('.') { + Some((_base, ext)) if !ext.is_empty() => ext.to_ascii_lowercase(), + _ => String::new(), + } +} diff --git a/crates/ntfs-explorer-gui/src/components/mod.rs b/crates/ntfs-explorer-gui/src/components/mod.rs new file mode 100644 index 0000000..ece8021 --- /dev/null +++ b/crates/ntfs-explorer-gui/src/components/mod.rs @@ -0,0 +1,15 @@ +mod address_bar; +mod context_menu; +mod details_view; +mod navigation_pane; +mod resize; +mod status_bar; +mod toolbar; + +pub use address_bar::AddressBar; +pub use context_menu::{ContextMenu, ContextMenuState}; +pub use details_view::{DetailsView, EntryRow}; +pub use navigation_pane::{NavigationPane, TreeNode}; +pub use resize::{ResizeHandle, ResizeOverlay}; +pub use status_bar::StatusBar; +pub use toolbar::Toolbar; diff --git a/crates/ntfs-explorer-gui/src/components/navigation_pane.css b/crates/ntfs-explorer-gui/src/components/navigation_pane.css new file mode 100644 index 0000000..fb4b4a0 --- /dev/null +++ b/crates/ntfs-explorer-gui/src/components/navigation_pane.css @@ -0,0 +1,183 @@ +/* ===== Navigation Pane (Windows 11 TreeView) ===== */ +.nav-pane { + display: flex; + flex-direction: column; + width: 100%; + min-width: 0; + height: 100%; + min-height: 0; + background: var(--bg-surface); + flex-shrink: 0; +} + +.nav-pane-header { + display: flex; + align-items: center; + height: 36px; + padding: 0 16px; + border-bottom: 1px solid var(--border-subtle); +} + +.nav-pane-title { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-secondary); +} + +.nav-pane-body { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding: 6px 0; +} + +/* Tree container */ +.tree { + display: flex; + flex-direction: column; +} + +/* Tree row */ +.tree-row { + display: flex; + align-items: center; + height: 30px; + padding-right: 12px; + gap: 4px; + cursor: pointer; + user-select: none; + transition: background 0.06s ease; + border-radius: 4px; + margin: 1px 6px; +} + +.tree-row:hover { + background: var(--bg-hover); +} + +.tree-row.is-selected { + background: var(--bg-selected); +} + +.tree-row.is-selected:hover { + background: var(--bg-selected-hover); +} + +.tree-row.is-deleted { + opacity: 0.70; +} + +.tree-row.is-deleted .tree-name { + text-decoration: line-through; + text-decoration-color: var(--text-tertiary); +} + +/* Chevron (expand/collapse) */ +.tree-chevron { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + background: none; + border: none; + cursor: pointer; + color: var(--text-tertiary); + flex-shrink: 0; + border-radius: 4px; + transition: all 0.1s ease; + padding: 0; +} + +.tree-chevron:hover { + background: var(--bg-hover-strong); + color: var(--text-primary); +} + +.tree-chevron-icon { + display: flex; + align-items: center; + justify-content: center; + width: 12px; + height: 12px; + transition: transform 0.15s ease; +} + +.tree-chevron-icon svg { + width: 12px; + height: 12px; +} + +.tree-chevron-icon.is-expanded { + /* Already pointing down */ +} + +.tree-chevron-placeholder { + width: 20px; + flex-shrink: 0; +} + +/* Loading spinner */ +.tree-spinner { + width: 10px; + height: 10px; + border: 1.5px solid var(--border-default); + border-top-color: var(--accent); + border-radius: 50%; + animation: tree-spin 0.6s linear infinite; +} + +@keyframes tree-spin { + to { + transform: rotate(360deg); + } +} + +/* Folder icon */ +.tree-icon { + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.tree-icon svg { + width: 20px; + height: 20px; +} + +.tree-icon.is-deleted { + opacity: 0.8; +} + +/* Name */ +.tree-name { + flex: 1; + font-size: 13px; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Badge */ +.tree-badge { + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.4px; + padding: 2px 6px; + border-radius: 3px; + flex-shrink: 0; +} + +.tree-badge.is-deleted { + background: var(--badge-deleted-bg); + border: 1px solid var(--badge-deleted-border); + color: var(--badge-deleted-text); +} diff --git a/crates/ntfs-explorer-gui/src/components/navigation_pane.rs b/crates/ntfs-explorer-gui/src/components/navigation_pane.rs new file mode 100644 index 0000000..b873033 --- /dev/null +++ b/crates/ntfs-explorer-gui/src/components/navigation_pane.rs @@ -0,0 +1,172 @@ +use dioxus::prelude::*; + +use crate::icons; + +#[linkme::distributed_slice(crate::styles::STYLESHEETS)] +static NAVIGATION_PANE_CSS: crate::styles::Stylesheet = crate::styles::Stylesheet { + order: 200, + name: "components/navigation_pane", + href: asset!("/src/components/navigation_pane.css"), +}; + +/// A node in the folder tree. +#[derive(Clone, Debug, PartialEq)] +pub struct TreeNode { + pub name: String, + pub entry_id: u64, + pub path: String, + pub is_deleted: bool, + pub expanded: bool, + pub children_loaded: bool, + pub children_loading: bool, + pub children: Vec, +} + +impl TreeNode { + pub fn find_mut(&mut self, entry_id: u64) -> Option<&mut TreeNode> { + if self.entry_id == entry_id { + return Some(self); + } + for c in &mut self.children { + if let Some(hit) = c.find_mut(entry_id) { + return Some(hit); + } + } + None + } +} + +#[component] +pub fn NavigationPane( + tree_root: Option, + selected_path: String, + on_toggle: Callback, + on_select: Callback<(u64, String)>, +) -> Element { + rsx! { + div { class: "nav-pane", + div { class: "nav-pane-header", + span { class: "nav-pane-title", "Folders" } + } + + div { class: "nav-pane-body", + if let Some(root) = tree_root { + {render_tree(&root, selected_path.as_str(), on_toggle, on_select)} + } else { + div { class: "empty-state", + span { class: "empty-state-icon", {icons::empty_folder_large()} } + span { class: "empty-state-title", "No snapshot loaded" } + span { class: "empty-state-subtitle", "Open an NTFS image to browse" } + } + } + } + } + } +} + +fn render_tree( + root: &TreeNode, + selected_path: &str, + on_toggle: Callback, + on_select: Callback<(u64, String)>, +) -> Element { + let mut rows: Vec<(TreeNode, usize)> = Vec::new(); + collect_tree_rows(root, 0, &mut rows); + + rsx! { + div { class: "tree", + for (node, depth) in rows { + TreeRow { + // Use path as key since entry_id can repeat for deleted/hard-linked items + key: "{node.path}", + node: node.clone(), + depth, + selected: node.path == selected_path, + on_toggle, + on_select, + } + } + } + } +} + +fn collect_tree_rows(node: &TreeNode, depth: usize, out: &mut Vec<(TreeNode, usize)>) { + out.push((node.clone(), depth)); + if node.expanded { + for c in &node.children { + collect_tree_rows(c, depth + 1, out); + } + } +} + +#[component] +fn TreeRow( + node: TreeNode, + depth: usize, + selected: bool, + on_toggle: Callback, + on_select: Callback<(u64, String)>, +) -> Element { + let has_children = !node.children_loaded || !node.children.is_empty(); + + let mut row_class = String::from("tree-row"); + if selected { + row_class.push_str(" is-selected"); + } + if node.is_deleted { + row_class.push_str(" is-deleted"); + } + + let indent_px = (depth as i32) * 20; + let entry_id = node.entry_id; + let path = node.path.clone(); + let name = node.name.clone(); + + rsx! { + div { + class: "{row_class}", + style: "padding-left: {indent_px}px;", + onclick: move |_| on_select.call((entry_id, path.clone())), + + // Expand/collapse chevron + if has_children { + button { + class: "tree-chevron", + onclick: move |e| { + e.stop_propagation(); + on_toggle.call(entry_id); + }, + if node.children_loading { + span { class: "tree-spinner" } + } else if node.expanded { + span { class: "tree-chevron-icon is-expanded", {icons::chevron_down()} } + } else { + span { class: "tree-chevron-icon", {icons::chevron_right()} } + } + } + } else { + span { class: "tree-chevron-placeholder" } + } + + // Folder icon + span { + class: if node.is_deleted { "tree-icon is-deleted" } else { "tree-icon" }, + if node.is_deleted { + {icons::folder_deleted()} + } else if node.expanded { + {icons::folder_open()} + } else { + {icons::folder_closed()} + } + } + + // Name + span { class: "tree-name", "{name}" } + + // Deleted badge + if node.is_deleted { + span { class: "tree-badge is-deleted", "DEL" } + } + } + } +} diff --git a/crates/ntfs-explorer-gui/src/components/resize.css b/crates/ntfs-explorer-gui/src/components/resize.css new file mode 100644 index 0000000..b7cca41 --- /dev/null +++ b/crates/ntfs-explorer-gui/src/components/resize.css @@ -0,0 +1,36 @@ +/* ===== Resize Handle + Overlay (Explorer-style splitters) ===== */ + +.resize-handle { + width: 6px; + cursor: col-resize; + flex-shrink: 0; + position: relative; + background: transparent; + z-index: 10; +} + +/* Subtle divider line */ +.resize-handle::before { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 2px; + width: 1px; + background: var(--border-subtle); +} + +.resize-handle:hover::before, +.resize-handle.is-active::before { + background: var(--accent); +} + +/* Overlay to capture mouse events during resize */ +.resize-overlay { + position: fixed; + inset: 0; + z-index: 9999; + cursor: col-resize; +} + + diff --git a/crates/ntfs-explorer-gui/src/components/resize.rs b/crates/ntfs-explorer-gui/src/components/resize.rs new file mode 100644 index 0000000..aafcb82 --- /dev/null +++ b/crates/ntfs-explorer-gui/src/components/resize.rs @@ -0,0 +1,35 @@ +use dioxus::prelude::*; + +#[linkme::distributed_slice(crate::styles::STYLESHEETS)] +static RESIZE_CSS: crate::styles::Stylesheet = crate::styles::Stylesheet { + order: 250, + name: "components/resize", + href: asset!("/src/components/resize.css"), +}; + +#[component] +pub fn ResizeOverlay(on_move: Callback, on_end: Callback) -> Element { + rsx! { + div { + class: "resize-overlay", + onmousemove: move |e| on_move.call(e), + onmouseup: move |e| on_end.call(e), + } + } +} + +#[component] +pub fn ResizeHandle(active: bool, on_start: Callback) -> Element { + let class = if active { + "resize-handle is-active" + } else { + "resize-handle" + }; + + rsx! { + div { + class, + onmousedown: move |e| on_start.call(e), + } + } +} diff --git a/crates/ntfs-explorer-gui/src/components/status_bar.css b/crates/ntfs-explorer-gui/src/components/status_bar.css new file mode 100644 index 0000000..8451210 --- /dev/null +++ b/crates/ntfs-explorer-gui/src/components/status_bar.css @@ -0,0 +1,55 @@ +/* ===== Status Bar (Windows 11 style) ===== */ +.status-bar { + display: flex; + align-items: center; + justify-content: space-between; + height: 28px; + padding: 0 16px; + background: var(--bg-surface); + border-top: 1px solid var(--border-subtle); + flex-shrink: 0; +} + +.status-left, +.status-right { + display: flex; + align-items: center; + gap: 4px; +} + +.status-item { + font-size: 12px; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 4px; +} + +.status-item.is-muted { + color: var(--text-tertiary); + font-family: "Cascadia Code", "Segoe UI Mono", ui-monospace, Consolas, monospace; + font-size: 11px; +} + +.status-item.is-deleted .status-count { + color: var(--badge-deleted-text); +} + +.status-item.is-encrypted .status-count { + color: var(--badge-efs-text); +} + +.status-count { + font-weight: 500; +} + +.status-label { + font-weight: 400; +} + +.status-separator { + width: 1px; + height: 14px; + background: var(--border-default); + margin: 0 10px; +} diff --git a/crates/ntfs-explorer-gui/src/components/status_bar.rs b/crates/ntfs-explorer-gui/src/components/status_bar.rs new file mode 100644 index 0000000..9fb0e2d --- /dev/null +++ b/crates/ntfs-explorer-gui/src/components/status_bar.rs @@ -0,0 +1,51 @@ +use dioxus::prelude::*; + +#[linkme::distributed_slice(crate::styles::STYLESHEETS)] +static STATUS_BAR_CSS: crate::styles::Stylesheet = crate::styles::Stylesheet { + order: 200, + name: "components/status_bar", + href: asset!("/src/components/status_bar.css"), +}; + +#[component] +pub fn StatusBar( + items_total: usize, + items_deleted: usize, + items_encrypted: usize, + selected_dir_id: Option, +) -> Element { + rsx! { + div { class: "status-bar", + div { class: "status-left", + span { class: "status-item", + span { class: "status-count", "{items_total}" } + span { class: "status-label", "items" } + } + + if items_deleted > 0 { + span { class: "status-separator" } + span { class: "status-item is-deleted", + span { class: "status-count", "{items_deleted}" } + span { class: "status-label", "deleted" } + } + } + + if items_encrypted > 0 { + span { class: "status-separator" } + span { class: "status-item is-encrypted", + span { class: "status-count", "{items_encrypted}" } + span { class: "status-label", "encrypted" } + } + } + } + + div { class: "status-right", + if let Some(id) = selected_dir_id { + span { class: "status-item is-muted", + "MFT #{id}" + } + } + } + } + } +} diff --git a/crates/ntfs-explorer-gui/src/components/toolbar.css b/crates/ntfs-explorer-gui/src/components/toolbar.css new file mode 100644 index 0000000..c322a89 --- /dev/null +++ b/crates/ntfs-explorer-gui/src/components/toolbar.css @@ -0,0 +1,128 @@ +/* ===== Toolbar (Windows 11 Command Bar style) ===== */ +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + height: 52px; + padding: 0 16px; + background: var(--bg-solid); + border-bottom: 1px solid var(--border-subtle); + gap: 16px; + flex-shrink: 0; +} + +.toolbar-section { + display: flex; + align-items: center; + gap: 12px; +} + +.toolbar-section-left { + min-width: 200px; +} + +.toolbar-section-center { + flex: 1; + justify-content: center; +} + +.toolbar-section-right { + min-width: 200px; + justify-content: flex-end; +} + +/* App title */ +.toolbar-title { + display: flex; + align-items: center; + gap: 12px; +} + +.toolbar-icon { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; +} + +.toolbar-icon svg { + width: 24px; + height: 24px; +} + +.toolbar-title-text { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + letter-spacing: -0.01em; +} + +/* Status in center */ +.toolbar-status { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + color: var(--text-secondary); + max-width: 600px; + overflow: hidden; +} + +.toolbar-status.is-muted { + color: var(--text-tertiary); +} + +.toolbar-status.is-muted kbd { + display: inline-block; + padding: 3px 8px; + font-size: 11px; + font-family: inherit; + background: var(--bg-subtle); + border: 1px solid var(--border-default); + border-radius: 4px; + color: var(--text-secondary); + margin: 0 2px; +} + +.toolbar-status.is-loading { + color: var(--text-accent); +} + +.toolbar-status-icon { + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.toolbar-status-icon svg { + width: 20px; + height: 20px; +} + +.toolbar-status-path { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: "Cascadia Code", "Segoe UI Mono", ui-monospace, Consolas, monospace; + font-size: 12px; +} + +/* Loading spinner - Windows 11 style ring */ +.toolbar-spinner { + width: 16px; + height: 16px; + border: 2px solid var(--accent-light); + border-top-color: var(--accent); + border-radius: 50%; + animation: toolbar-spin 0.8s linear infinite; +} + +@keyframes toolbar-spin { + to { + transform: rotate(360deg); + } +} diff --git a/crates/ntfs-explorer-gui/src/components/toolbar.rs b/crates/ntfs-explorer-gui/src/components/toolbar.rs new file mode 100644 index 0000000..d3a8c37 --- /dev/null +++ b/crates/ntfs-explorer-gui/src/components/toolbar.rs @@ -0,0 +1,63 @@ +use dioxus::prelude::*; + +use crate::icons; + +#[linkme::distributed_slice(crate::styles::STYLESHEETS)] +static TOOLBAR_CSS: crate::styles::Stylesheet = crate::styles::Stylesheet { + order: 200, + name: "components/toolbar", + href: asset!("/src/components/toolbar.css"), +}; + +#[component] +pub fn Toolbar(snapshot_path: Option, is_loading: bool, is_mft_only: bool) -> Element { + rsx! { + div { class: "toolbar", + div { class: "toolbar-section toolbar-section-left", + div { class: "toolbar-title", + span { class: "toolbar-icon", {icons::app_icon()} } + span { class: "toolbar-title-text", "NTFS Explorer" } + } + } + + div { class: "toolbar-section toolbar-section-center", + if is_loading { + div { class: "toolbar-status is-loading", + span { class: "toolbar-spinner" } + span { "Loading…" } + } + } else if let Some(path) = snapshot_path { + div { class: "toolbar-status", + span { class: "toolbar-status-icon", + if is_mft_only { + {icons::file_generic()} + } else { + {icons::hard_drive()} + } + } + span { class: "toolbar-status-path", + if is_mft_only { + "MFT snapshot • " + } else { + "NTFS image • " + } + "{path}" + } + } + } else { + div { class: "toolbar-status is-muted", + span { "No snapshot loaded • Press " } + kbd { "Ctrl+O" } + span { " (image) or " } + kbd { "Ctrl+Shift+O" } + span { " (MFT)" } + } + } + } + + div { class: "toolbar-section toolbar-section-right", + // Future: view toggle buttons, etc. + } + } + } +} diff --git a/crates/ntfs-explorer-gui/src/icons.rs b/crates/ntfs-explorer-gui/src/icons.rs new file mode 100644 index 0000000..07905a2 --- /dev/null +++ b/crates/ntfs-explorer-gui/src/icons.rs @@ -0,0 +1,573 @@ +//! Windows 11 Fluent-style SVG icons +//! +//! These SVGs are inspired by the Windows 11 File Explorer iconography. + +#![allow(dead_code)] + +use dioxus::prelude::*; + +/// Windows 11 style folder icon (closed) +pub fn folder_closed() -> Element { + rsx! { + svg { + width: "20", + height: "20", + view_box: "0 0 20 20", + fill: "none", + xmlns: "http://www.w3.org/2000/svg", + // Folder back + path { + d: "M2 5.5C2 4.11929 3.11929 3 4.5 3H7.17157C7.70201 3 8.21071 3.21071 8.58579 3.58579L9.5 4.5H15.5C16.8807 4.5 18 5.61929 18 7V14.5C18 15.8807 16.8807 17 15.5 17H4.5C3.11929 17 2 15.8807 2 14.5V5.5Z", + fill: "#DCBA6A", + } + // Folder front + path { + d: "M2 7.5C2 6.67157 2.67157 6 3.5 6H16.5C17.3284 6 18 6.67157 18 7.5V14.5C18 15.8807 16.8807 17 15.5 17H4.5C3.11929 17 2 15.8807 2 14.5V7.5Z", + fill: "#F2D675", + } + } + } +} + +/// Windows 11 style folder icon (open) +pub fn folder_open() -> Element { + rsx! { + svg { + width: "20", + height: "20", + view_box: "0 0 20 20", + fill: "none", + xmlns: "http://www.w3.org/2000/svg", + // Folder back + path { + d: "M2 5.5C2 4.11929 3.11929 3 4.5 3H7.17157C7.70201 3 8.21071 3.21071 8.58579 3.58579L9.5 4.5H15.5C16.8807 4.5 18 5.61929 18 7V8H4C2.89543 8 2 8.89543 2 10V5.5Z", + fill: "#DCBA6A", + } + // Folder front (angled open) + path { + d: "M1 10C1 9.17157 1.67157 8.5 2.5 8.5H17.5C18.3284 8.5 19 9.17157 19 10L17.5 15.5C17.2239 16.3284 16.5523 17 15.5 17H4.5C3.44772 17 2.77614 16.3284 2.5 15.5L1 10Z", + fill: "#F2D675", + } + } + } +} + +/// Windows 11 style folder icon (deleted/grayed) +pub fn folder_deleted() -> Element { + rsx! { + svg { + width: "20", + height: "20", + view_box: "0 0 20 20", + fill: "none", + xmlns: "http://www.w3.org/2000/svg", + style: "opacity: 0.6;", + path { + d: "M2 5.5C2 4.11929 3.11929 3 4.5 3H7.17157C7.70201 3 8.21071 3.21071 8.58579 3.58579L9.5 4.5H15.5C16.8807 4.5 18 5.61929 18 7V14.5C18 15.8807 16.8807 17 15.5 17H4.5C3.11929 17 2 15.8807 2 14.5V5.5Z", + fill: "#9A8866", + } + path { + d: "M2 7.5C2 6.67157 2.67157 6 3.5 6H16.5C17.3284 6 18 6.67157 18 7.5V14.5C18 15.8807 16.8807 17 15.5 17H4.5C3.11929 17 2 15.8807 2 14.5V7.5Z", + fill: "#B8A575", + } + } + } +} + +/// Windows 11 style generic file icon +pub fn file_generic() -> Element { + rsx! { + svg { + width: "20", + height: "20", + view_box: "0 0 20 20", + fill: "none", + xmlns: "http://www.w3.org/2000/svg", + // Page body + path { + d: "M5 2C4.44772 2 4 2.44772 4 3V17C4 17.5523 4.44772 18 5 18H15C15.5523 18 16 17.5523 16 17V7L11 2H5Z", + fill: "#FFFFFF", + stroke: "#C4C4C4", + stroke_width: "1", + } + // Folded corner + path { + d: "M11 2V6C11 6.55228 11.4477 7 12 7H16", + stroke: "#C4C4C4", + stroke_width: "1", + fill: "none", + } + path { + d: "M11 2L16 7H12C11.4477 7 11 6.55228 11 6V2Z", + fill: "#E8E8E8", + } + } + } +} + +/// Windows 11 style file icon (deleted) +pub fn file_deleted() -> Element { + rsx! { + svg { + width: "20", + height: "20", + view_box: "0 0 20 20", + fill: "none", + xmlns: "http://www.w3.org/2000/svg", + style: "opacity: 0.6;", + path { + d: "M5 2C4.44772 2 4 2.44772 4 3V17C4 17.5523 4.44772 18 5 18H15C15.5523 18 16 17.5523 16 17V7L11 2H5Z", + fill: "#E8E8E8", + stroke: "#AAAAAA", + stroke_width: "1", + } + path { + d: "M11 2V6C11 6.55228 11.4477 7 12 7H16", + stroke: "#AAAAAA", + stroke_width: "1", + fill: "none", + } + path { + d: "M11 2L16 7H12C11.4477 7 11 6.55228 11 6V2Z", + fill: "#D0D0D0", + } + } + } +} + +/// Windows 11 style This PC / Computer icon +pub fn this_pc() -> Element { + rsx! { + svg { + width: "20", + height: "20", + view_box: "0 0 20 20", + fill: "none", + xmlns: "http://www.w3.org/2000/svg", + // Monitor body + path { + d: "M2 4C2 3.44772 2.44772 3 3 3H17C17.5523 3 18 3.44772 18 4V12C18 12.5523 17.5523 13 17 13H3C2.44772 13 2 12.5523 2 12V4Z", + fill: "#0078D4", + } + // Screen + path { + d: "M3 4.5H17V11.5H3V4.5Z", + fill: "#50E6FF", + } + // Stand + path { + d: "M7 13H13V15H7V13Z", + fill: "#505050", + } + // Base + path { + d: "M5 15H15V16C15 16.5523 14.5523 17 14 17H6C5.44772 17 5 16.5523 5 16V15Z", + fill: "#707070", + } + } + } +} + +/// Windows 11 style hard drive icon +pub fn hard_drive() -> Element { + rsx! { + svg { + width: "20", + height: "20", + view_box: "0 0 20 20", + fill: "none", + xmlns: "http://www.w3.org/2000/svg", + // Drive body + path { + d: "M3 6C3 4.89543 3.89543 4 5 4H15C16.1046 4 17 4.89543 17 6V14C17 15.1046 16.1046 16 15 16H5C3.89543 16 3 15.1046 3 14V6Z", + fill: "#E0E0E0", + stroke: "#A0A0A0", + stroke_width: "1", + } + // Activity LED + circle { + cx: "5.5", + cy: "13.5", + r: "1", + fill: "#00CC6A", + } + // Label area + rect { + x: "5", + y: "6", + width: "10", + height: "5", + rx: "1", + fill: "#F5F5F5", + } + } + } +} + +/// Navigation arrow: Up +pub fn arrow_up() -> Element { + rsx! { + svg { + width: "16", + height: "16", + view_box: "0 0 16 16", + fill: "none", + xmlns: "http://www.w3.org/2000/svg", + path { + d: "M8 3L13 8H10V13H6V8H3L8 3Z", + fill: "currentColor", + } + } + } +} + +/// Navigation arrow: Back +pub fn arrow_back() -> Element { + rsx! { + svg { + width: "16", + height: "16", + view_box: "0 0 16 16", + fill: "none", + xmlns: "http://www.w3.org/2000/svg", + path { + d: "M10 3L5 8L10 13", + stroke: "currentColor", + stroke_width: "1.5", + stroke_linecap: "round", + stroke_linejoin: "round", + fill: "none", + } + } + } +} + +/// Navigation arrow: Forward +pub fn arrow_forward() -> Element { + rsx! { + svg { + width: "16", + height: "16", + view_box: "0 0 16 16", + fill: "none", + xmlns: "http://www.w3.org/2000/svg", + path { + d: "M6 3L11 8L6 13", + stroke: "currentColor", + stroke_width: "1.5", + stroke_linecap: "round", + stroke_linejoin: "round", + fill: "none", + } + } + } +} + +/// Chevron right (for tree expand) +pub fn chevron_right() -> Element { + rsx! { + svg { + width: "12", + height: "12", + view_box: "0 0 12 12", + fill: "none", + xmlns: "http://www.w3.org/2000/svg", + path { + d: "M4.5 2.5L8 6L4.5 9.5", + stroke: "currentColor", + stroke_width: "1.2", + stroke_linecap: "round", + stroke_linejoin: "round", + fill: "none", + } + } + } +} + +/// Chevron down (for tree collapse) +pub fn chevron_down() -> Element { + rsx! { + svg { + width: "12", + height: "12", + view_box: "0 0 12 12", + fill: "none", + xmlns: "http://www.w3.org/2000/svg", + path { + d: "M2.5 4.5L6 8L9.5 4.5", + stroke: "currentColor", + stroke_width: "1.2", + stroke_linecap: "round", + stroke_linejoin: "round", + fill: "none", + } + } + } +} + +/// Breadcrumb separator +pub fn breadcrumb_sep() -> Element { + rsx! { + svg { + width: "8", + height: "16", + view_box: "0 0 8 16", + fill: "none", + xmlns: "http://www.w3.org/2000/svg", + path { + d: "M2 4L6 8L2 12", + stroke: "currentColor", + stroke_width: "1.2", + stroke_linecap: "round", + stroke_linejoin: "round", + fill: "none", + } + } + } +} + +/// Download / Export icon +pub fn download() -> Element { + rsx! { + svg { + width: "16", + height: "16", + view_box: "0 0 16 16", + fill: "none", + xmlns: "http://www.w3.org/2000/svg", + path { + d: "M8 2V10M8 10L5 7M8 10L11 7", + stroke: "currentColor", + stroke_width: "1.5", + stroke_linecap: "round", + stroke_linejoin: "round", + } + path { + d: "M3 13H13", + stroke: "currentColor", + stroke_width: "1.5", + stroke_linecap: "round", + } + } + } +} + +/// Copy icon +pub fn copy() -> Element { + rsx! { + svg { + width: "16", + height: "16", + view_box: "0 0 16 16", + fill: "none", + xmlns: "http://www.w3.org/2000/svg", + rect { + x: "5", + y: "5", + width: "9", + height: "9", + rx: "1", + stroke: "currentColor", + stroke_width: "1.2", + fill: "none", + } + path { + d: "M11 5V3C11 2.44772 10.5523 2 10 2H3C2.44772 2 2 2.44772 2 3V10C2 10.5523 2.44772 11 3 11H5", + stroke: "currentColor", + stroke_width: "1.2", + fill: "none", + } + } + } +} + +/// Info icon +pub fn info() -> Element { + rsx! { + svg { + width: "16", + height: "16", + view_box: "0 0 16 16", + fill: "none", + xmlns: "http://www.w3.org/2000/svg", + circle { + cx: "8", + cy: "8", + r: "6.5", + stroke: "currentColor", + stroke_width: "1.2", + fill: "none", + } + path { + d: "M8 7V11", + stroke: "currentColor", + stroke_width: "1.2", + stroke_linecap: "round", + } + circle { + cx: "8", + cy: "5", + r: "0.75", + fill: "currentColor", + } + } + } +} + +/// Refresh icon +pub fn refresh() -> Element { + rsx! { + svg { + width: "16", + height: "16", + view_box: "0 0 16 16", + fill: "none", + xmlns: "http://www.w3.org/2000/svg", + path { + d: "M13 8C13 10.7614 10.7614 13 8 13C5.23858 13 3 10.7614 3 8C3 5.23858 5.23858 3 8 3C9.65685 3 11.1212 3.83579 12 5.10102", + stroke: "currentColor", + stroke_width: "1.5", + stroke_linecap: "round", + fill: "none", + } + path { + d: "M12 2V5.5H8.5", + stroke: "currentColor", + stroke_width: "1.5", + stroke_linecap: "round", + stroke_linejoin: "round", + fill: "none", + } + } + } +} + +/// Deleted/Trash indicator +pub fn deleted_badge() -> Element { + rsx! { + svg { + width: "12", + height: "12", + view_box: "0 0 12 12", + fill: "none", + xmlns: "http://www.w3.org/2000/svg", + circle { + cx: "6", + cy: "6", + r: "5", + fill: "#FFE0E0", + stroke: "#D13438", + stroke_width: "1", + } + path { + d: "M4 4L8 8M8 4L4 8", + stroke: "#D13438", + stroke_width: "1.2", + stroke_linecap: "round", + } + } + } +} + +/// Encrypted (EFS) indicator +pub fn encrypted_badge() -> Element { + rsx! { + svg { + width: "12", + height: "12", + view_box: "0 0 12 12", + fill: "none", + xmlns: "http://www.w3.org/2000/svg", + rect { + x: "2.5", + y: "5", + width: "7", + height: "5.5", + rx: "1", + fill: "#DFF6FF", + stroke: "#0078D4", + stroke_width: "1", + } + path { + d: "M4 5V3.5C4 2.39543 4.89543 1.5 6 1.5C7.10457 1.5 8 2.39543 8 3.5V5", + stroke: "#0078D4", + stroke_width: "1", + fill: "none", + } + } + } +} + +/// NTFS Explorer app icon +pub fn app_icon() -> Element { + rsx! { + svg { + width: "24", + height: "24", + view_box: "0 0 24 24", + fill: "none", + xmlns: "http://www.w3.org/2000/svg", + // Hard drive with magnifying glass + rect { + x: "2", + y: "6", + width: "16", + height: "12", + rx: "2", + fill: "#0078D4", + } + // Drive face + rect { + x: "3", + y: "7", + width: "14", + height: "10", + rx: "1", + fill: "#FFFFFF", + } + // LED + circle { + cx: "5", + cy: "15", + r: "1", + fill: "#00CC6A", + } + // Magnifying glass + circle { + cx: "17", + cy: "10", + r: "5", + fill: "#50E6FF", + stroke: "#0078D4", + stroke_width: "2", + } + path { + d: "M20 13L23 16", + stroke: "#0078D4", + stroke_width: "2.5", + stroke_linecap: "round", + } + } + } +} + +/// Empty state folder icon (large) +pub fn empty_folder_large() -> Element { + rsx! { + svg { + width: "64", + height: "64", + view_box: "0 0 64 64", + fill: "none", + xmlns: "http://www.w3.org/2000/svg", + path { + d: "M8 16C8 12.6863 10.6863 10 14 10H24.6863C26.0125 10 27.2845 10.5268 28.2222 11.4645L32 15.2426H50C53.3137 15.2426 56 17.929 56 21.2426V48C56 51.3137 53.3137 54 50 54H14C10.6863 54 8 51.3137 8 48V16Z", + fill: "#DCBA6A", + } + path { + d: "M8 24C8 21.7909 9.79086 20 12 20H52C54.2091 20 56 21.7909 56 24V48C56 51.3137 53.3137 54 50 54H14C10.6863 54 8 51.3137 8 48V24Z", + fill: "#F2D675", + } + } + } +} diff --git a/crates/ntfs-explorer-gui/src/main.rs b/crates/ntfs-explorer-gui/src/main.rs new file mode 100644 index 0000000..924712e --- /dev/null +++ b/crates/ntfs-explorer-gui/src/main.rs @@ -0,0 +1,41 @@ +mod app; +mod components; +mod icons; +#[cfg(feature = "desktop")] +mod menus; +mod mft_only; +mod settings; +mod styles; + +fn main() { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + #[cfg(feature = "desktop")] + { + use dioxus::LaunchBuilder; + use dioxus::desktop::{Config, WindowBuilder}; + + LaunchBuilder::desktop() + .with_cfg( + Config::new().with_menu(menus::build_menu()).with_window( + WindowBuilder::new() + .with_title("NTFS Explorer") + .with_inner_size(dioxus::desktop::LogicalSize::new(1200.0, 800.0)), + ), + ) + .launch(app::app); + } + + // LiveView runs the app natively, but renders the UI in the browser. + #[cfg(feature = "liveview")] + { + dioxus::launch(app::app); + } + + #[cfg(feature = "web")] + { + dioxus::launch(app::app); + } +} diff --git a/crates/ntfs-explorer-gui/src/menus.rs b/crates/ntfs-explorer-gui/src/menus.rs new file mode 100644 index 0000000..f0c16a2 --- /dev/null +++ b/crates/ntfs-explorer-gui/src/menus.rs @@ -0,0 +1,75 @@ +use dioxus::desktop::muda::accelerator::{Accelerator, CMD_OR_CTRL, Code, Modifiers}; +use dioxus::desktop::muda::{Menu, MenuItem, PredefinedMenuItem, Submenu}; + +pub const MENU_OPEN_SNAPSHOT: &str = "file-open-snapshot"; +pub const MENU_OPEN_MFT_SNAPSHOT: &str = "file-open-mft-snapshot"; +pub const MENU_CLOSE_SNAPSHOT: &str = "file-close-snapshot"; +pub const MENU_REFRESH: &str = "view-refresh"; + +pub fn build_menu() -> Menu { + let menu = Menu::new(); + + let file_menu = Submenu::new("File", true); + file_menu + .append_items(&[ + &MenuItem::with_id( + MENU_OPEN_SNAPSHOT, + "Open NTFS Image…", + true, + Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyO)), + ), + &MenuItem::with_id( + MENU_OPEN_MFT_SNAPSHOT, + "Open MFT Snapshot…", + true, + Some(Accelerator::new( + Some(CMD_OR_CTRL | Modifiers::SHIFT), + Code::KeyO, + )), + ), + &MenuItem::with_id(MENU_CLOSE_SNAPSHOT, "Close Snapshot", true, None), + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::quit(None), + ]) + .expect("append File menu items"); + + let view_menu = Submenu::new("View", true); + view_menu + .append_items(&[&MenuItem::with_id( + MENU_REFRESH, + "Refresh", + true, + Some(Accelerator::new(None, Code::F5)), + )]) + .expect("append View menu items"); + + let edit_menu = Submenu::new("Edit", true); + edit_menu + .append_items(&[ + &PredefinedMenuItem::undo(None), + &PredefinedMenuItem::redo(None), + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::cut(None), + &PredefinedMenuItem::copy(None), + &PredefinedMenuItem::paste(None), + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::select_all(None), + ]) + .expect("append Edit menu items"); + + let window_menu = Submenu::new("Window", true); + window_menu + .append_items(&[ + &PredefinedMenuItem::fullscreen(None), + &PredefinedMenuItem::maximize(None), + &PredefinedMenuItem::minimize(None), + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::close_window(None), + ]) + .expect("append Window menu items"); + + menu.append_items(&[&file_menu, &edit_menu, &view_menu, &window_menu]) + .expect("append menubar"); + + menu +} diff --git a/crates/ntfs-explorer-gui/src/mft_only.rs b/crates/ntfs-explorer-gui/src/mft_only.rs new file mode 100644 index 0000000..660e883 --- /dev/null +++ b/crates/ntfs-explorer-gui/src/mft_only.rs @@ -0,0 +1,199 @@ +use std::cmp::Reverse; +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; + +use mft::attribute::x30::FileNamespace; +use mft::attribute::{FileAttributeFlags, MftAttributeType}; + +/// A read-only, metadata-only view built from a standalone `$MFT` snapshot. +/// +/// This is intentionally limited: +/// - Directory listings are derived from `FILE_NAME` attributes (so hardlinks can appear). +/// - File content export is **not** available (no clusters / data runs can be followed). +#[derive(Debug)] +pub struct MftOnlySnapshot { + entry_meta: HashMap, + children_by_parent: HashMap>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct EntryMeta { + pub is_dir: bool, + pub is_allocated: bool, + pub efs_encrypted: bool, +} + +#[derive(Clone, Debug)] +pub struct DirChild { + pub name: String, + pub entry_id: u64, + pub namespace: FileNamespace, + pub logical_size: u64, + pub modified_unix_s: i64, +} + +impl MftOnlySnapshot { + pub fn open(path: &Path) -> Result, String> { + let mut parser = + mft::MftParser::from_path(path).map_err(|e| format!("open MFT snapshot: {e}"))?; + let entry_count = parser.get_entry_count(); + + // Group `FILE_NAME` attrs by (entry_id, parent_id) so we can filter DOS 8.3 aliases when a + // Win32 long name exists for the same parent. + let mut file_names_by_entry_parent: HashMap<(u64, u64), Vec> = HashMap::new(); + let mut entry_meta: HashMap = HashMap::new(); + + for entry_id in 0..entry_count { + let entry = match parser.get_entry(entry_id) { + Ok(e) => e, + Err(_) => continue, + }; + if !entry.header.is_valid() { + continue; + } + + entry_meta.insert( + entry_id, + EntryMeta { + is_dir: entry.is_dir(), + is_allocated: entry.is_allocated(), + efs_encrypted: is_entry_efs_encrypted(&entry), + }, + ); + + for attr in entry + .iter_attributes_matching(Some(vec![MftAttributeType::FileName])) + .filter_map(std::result::Result::ok) + { + let Some(fname) = attr.data.into_file_name() else { + continue; + }; + + let parent_id = fname.parent.entry; + file_names_by_entry_parent + .entry((entry_id, parent_id)) + .or_default() + .push(DirChild { + name: fname.name, + entry_id, + namespace: fname.namespace, + logical_size: fname.logical_size, + modified_unix_s: fname.modified.as_second(), + }); + } + } + + // Apply DOS alias filtering and invert into parent -> children. + let mut children_by_parent: HashMap> = HashMap::new(); + for ((_entry_id, parent_id), mut items) in file_names_by_entry_parent { + let has_win32 = items.iter().any(|i| { + matches!( + i.namespace, + FileNamespace::Win32 | FileNamespace::Win32AndDos + ) + }); + if has_win32 { + items.retain(|i| i.namespace != FileNamespace::DOS); + } + + children_by_parent + .entry(parent_id) + .or_default() + .extend(items); + } + + Ok(Arc::new(Self { + entry_meta, + children_by_parent, + })) + } + + pub fn entry_meta(&self, entry_id: u64) -> Option { + self.entry_meta.get(&entry_id).copied() + } + + pub fn list_children(&self, parent_id: u64) -> Vec { + self.children_by_parent + .get(&parent_id) + .cloned() + .unwrap_or_default() + } + + /// Resolves an NTFS-style directory path (e.g. `\Windows\System32`) to an MFT entry id. + /// + /// Notes: + /// - Resolution is case-insensitive (ASCII). + /// - Only directories are returned (since the GUI currently navigates directories). + pub fn resolve_dir_path_including_deleted(&self, path: &str) -> Result { + let mut p = path.trim().replace('/', "\\"); + if p.is_empty() || p == "\\" { + return Ok(5); + } + + // Allow drive-letter paths (`C:\Windows`). + if p.len() >= 2 && p.as_bytes()[1] == b':' { + p = p[2..].to_string(); + } + + let p = p.trim_matches('\\'); + if p.is_empty() { + return Ok(5); + } + + let mut cur = 5_u64; + for part in p.split('\\').filter(|s| !s.is_empty() && *s != ".") { + let Some(children) = self.children_by_parent.get(&cur) else { + return Err(format!("path not found: {path}")); + }; + + let next = children + .iter() + .filter(|c| c.name.eq_ignore_ascii_case(part)) + .filter_map(|c| { + let meta = self.entry_meta(c.entry_id)?; + if !meta.is_dir { + return None; + } + let key = ( + meta.is_allocated, + namespace_rank(c.namespace.clone()), + Reverse(c.entry_id), + ); + Some((c.entry_id, key)) + }) + .max_by_key(|(_id, key)| *key) + .map(|(id, _)| id) + .ok_or_else(|| format!("path not found: {path}"))?; + + cur = next; + } + + Ok(cur) + } +} + +fn namespace_rank(ns: FileNamespace) -> u8 { + match ns { + FileNamespace::Win32AndDos => 3, + FileNamespace::Win32 => 2, + FileNamespace::POSIX => 1, + FileNamespace::DOS => 0, + } +} + +fn is_entry_efs_encrypted(entry: &mft::MftEntry) -> bool { + for attr in entry + .iter_attributes_matching(Some(vec![MftAttributeType::StandardInformation])) + .filter_map(std::result::Result::ok) + { + if let Some(si) = attr.data.into_standard_info() + && si + .file_flags + .contains(FileAttributeFlags::FILE_ATTRIBUTE_ENCRYPTED) + { + return true; + } + } + false +} diff --git a/crates/ntfs-explorer-gui/src/settings.rs b/crates/ntfs-explorer-gui/src/settings.rs new file mode 100644 index 0000000..58be5e4 --- /dev/null +++ b/crates/ntfs-explorer-gui/src/settings.rs @@ -0,0 +1,119 @@ +use std::{ + env, fs, + path::{Path, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; + +pub const CONFIG_ENV_VAR: &str = "NTFS_EXPLORER_UI_STATE"; + +fn default_nav_width_px() -> i32 { + 280 +} +fn default_col_modified_px() -> i32 { + 220 +} +fn default_col_type_px() -> i32 { + 120 +} +fn default_col_size_px() -> i32 { + 120 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UiState { + #[serde(default = "default_nav_width_px")] + pub nav_width_px: i32, + + #[serde(default = "default_col_modified_px")] + pub col_modified_px: i32, + #[serde(default = "default_col_type_px")] + pub col_type_px: i32, + #[serde(default = "default_col_size_px")] + pub col_size_px: i32, +} + +impl Default for UiState { + fn default() -> Self { + Self { + nav_width_px: default_nav_width_px(), + col_modified_px: default_col_modified_px(), + col_type_px: default_col_type_px(), + col_size_px: default_col_size_px(), + } + } +} + +fn home_dir() -> Option { + if cfg!(target_os = "windows") { + env::var_os("USERPROFILE").map(PathBuf::from) + } else { + env::var_os("HOME").map(PathBuf::from) + } +} + +fn user_config_dir() -> Option { + if cfg!(target_os = "windows") { + let base = env::var_os("APPDATA") + .or_else(|| env::var_os("LOCALAPPDATA")) + .map(PathBuf::from)?; + return Some(base.join("NTFS Explorer")); + } + + if cfg!(target_os = "macos") { + let home = home_dir()?; + return Some( + home.join("Library") + .join("Application Support") + .join("NTFS Explorer"), + ); + } + + // Linux / other unix: XDG_CONFIG_HOME or ~/.config + let base = env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| home_dir().map(|h| h.join(".config")))?; + Some(base.join("ntfs-explorer")) +} + +fn config_path() -> Option { + if let Some(p) = env::var_os(CONFIG_ENV_VAR) { + return Some(PathBuf::from(p)); + } + user_config_dir().map(|d| d.join("ui_state.json")) +} + +pub fn load_ui_state() -> UiState { + let Some(path) = config_path() else { + return UiState::default(); + }; + let raw = match fs::read_to_string(&path) { + Ok(s) => s, + Err(_) => return UiState::default(), + }; + serde_json::from_str(&raw).unwrap_or_default() +} + +pub fn save_ui_state(state: UiState) -> Result<(), String> { + let Some(path) = config_path() else { + return Ok(()); + }; + if let Some(dir) = path.parent() { + fs::create_dir_all(dir).map_err(|e| format!("create config dir {dir:?}: {e}"))?; + } + + let data = serde_json::to_vec_pretty(&state).map_err(|e| e.to_string())?; + atomic_write(&path, &data).map_err(|e| format!("write {path:?}: {e}"))?; + Ok(()) +} + +fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> { + let tmp = path.with_extension("tmp"); + + fs::write(&tmp, data)?; + + // `rename` on Windows fails if the destination exists. + let _ = fs::remove_file(path); + fs::rename(tmp, path)?; + Ok(()) +} diff --git a/crates/ntfs-explorer-gui/src/styles.rs b/crates/ntfs-explorer-gui/src/styles.rs new file mode 100644 index 0000000..9c8ab32 --- /dev/null +++ b/crates/ntfs-explorer-gui/src/styles.rs @@ -0,0 +1,74 @@ +use std::sync::LazyLock; + +use dioxus::prelude::*; + +#[derive(Clone, Copy)] +pub struct Stylesheet { + pub order: u16, + pub name: &'static str, + pub href: Asset, +} + +#[linkme::distributed_slice] +pub static STYLESHEETS: [Stylesheet] = [..]; + +static SORTED_STYLESHEETS: LazyLock> = LazyLock::new(|| { + let mut sheets: Vec = STYLESHEETS.iter().copied().collect(); + sheets.sort_unstable_by(|a, b| (a.order, a.name).cmp(&(b.order, b.name))); + sheets +}); + +pub fn stylesheets() -> &'static [Stylesheet] { + SORTED_STYLESHEETS.as_slice() +} + +// --------------------------------------------------------------------------- +// LiveView +// --------------------------------------------------------------------------- +// +// LiveView's default Axum adapter does not serve static assets (it uses a catch-all route that +// returns the LiveView HTML shell). Instead of relying on `asset!()` + ``, +// inline the CSS so the UI renders correctly. +#[cfg(feature = "liveview")] +pub const INLINE_CSS: &str = concat!( + include_str!("styles/tokens.css"), + "\n", + include_str!("styles/base.css"), + "\n", + // Components + include_str!("components/toolbar.css"), + "\n", + include_str!("components/address_bar.css"), + "\n", + include_str!("components/navigation_pane.css"), + "\n", + include_str!("components/details_view.css"), + "\n", + include_str!("components/resize.css"), + "\n", + include_str!("components/context_menu.css"), + "\n", + include_str!("components/status_bar.css"), + "\n", +); + +#[cfg(not(feature = "liveview"))] +pub const INLINE_CSS: &str = ""; + +// --------------------------------------------------------------------------- +// Core stylesheets (tokens → base → components) +// --------------------------------------------------------------------------- + +#[linkme::distributed_slice(STYLESHEETS)] +static TOKENS_STYLESHEET: Stylesheet = Stylesheet { + order: 0, + name: "styles/tokens", + href: asset!("/src/styles/tokens.css"), +}; + +#[linkme::distributed_slice(STYLESHEETS)] +static BASE_STYLESHEET: Stylesheet = Stylesheet { + order: 100, + name: "styles/base", + href: asset!("/src/styles/base.css"), +}; diff --git a/crates/ntfs-explorer-gui/src/styles/base.css b/crates/ntfs-explorer-gui/src/styles/base.css new file mode 100644 index 0000000..f29c046 --- /dev/null +++ b/crates/ntfs-explorer-gui/src/styles/base.css @@ -0,0 +1,193 @@ +/* ===== Base Styles (Windows 11 Explorer) ===== */ + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + /* Segoe UI Variable is the Windows 11 system font */ + font-family: "Segoe UI Variable", "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, + "Helvetica Neue", sans-serif; + font-size: 14px; + line-height: 1.4; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-feature-settings: "cv01", "cv02", "cv03", "cv04"; +} + +/* ===== App Container ===== */ +.app { + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; + color: var(--text-primary); + background: var(--bg-mica); +} + +/* ===== Scrollbars (Windows 11 style - thin overlay) ===== */ +.app { + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); +} + +.app ::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +.app ::-webkit-scrollbar-track { + background: var(--scrollbar-track); +} + +.app ::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border-radius: 10px; + border: 3px solid transparent; + background-clip: content-box; +} + +.app ::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-hover); + background-clip: content-box; +} + +/* Hide scrollbar by default, show on hover - Windows 11 behavior */ +.app .details-body::-webkit-scrollbar-thumb, +.app .nav-pane-body::-webkit-scrollbar-thumb { + background: transparent; +} + +.app .details-body:hover::-webkit-scrollbar-thumb, +.app .nav-pane-body:hover::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + background-clip: content-box; +} + +/* ===== Main Content Split ===== */ +.main-content { + display: flex; + flex: 1; + overflow: hidden; +} + +/* ===== Error Banner ===== */ +.error-banner { + background: rgba(196, 43, 28, 0.08); + border: 1px solid rgba(196, 43, 28, 0.20); + border-left: 3px solid var(--error); + color: var(--error); + padding: 12px 16px; + margin: 8px 12px; + border-radius: 4px; + font-size: 13px; + display: flex; + align-items: center; + gap: 10px; +} + +.error-banner::before { + content: ""; + display: inline-block; + width: 16px; + height: 16px; + background: currentColor; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm0 10.5a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5zM8.75 4v4.5a.75.75 0 0 1-1.5 0V4a.75.75 0 0 1 1.5 0z'/%3E%3C/svg%3E") center / contain no-repeat; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm0 10.5a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5zM8.75 4v4.5a.75.75 0 0 1-1.5 0V4a.75.75 0 0 1 1.5 0z'/%3E%3C/svg%3E") center / contain no-repeat; + flex-shrink: 0; +} + +/* ===== Empty State ===== */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-tertiary); + text-align: center; + padding: 48px; + gap: 16px; +} + +.empty-state-icon { + width: 64px; + height: 64px; + opacity: 0.6; +} + +.empty-state-icon svg { + width: 100%; + height: 100%; +} + +.empty-state-title { + font-size: 16px; + font-weight: 600; + color: var(--text-secondary); +} + +.empty-state-subtitle { + font-size: 13px; + color: var(--text-tertiary); +} + +/* ===== Focus states ===== */ +button:focus-visible, +input:focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} + +/* ===== Selection ===== */ +::selection { + background: var(--accent); + color: var(--text-on-accent); +} + +/* ===== Utility: SVG Icon containers ===== */ +.icon-sm { + width: 12px; + height: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.icon-md { + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.icon-lg { + width: 20px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.icon-xl { + width: 24px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.icon-sm svg, +.icon-md svg, +.icon-lg svg, +.icon-xl svg { + width: 100%; + height: 100%; +} diff --git a/crates/ntfs-explorer-gui/src/styles/tokens.css b/crates/ntfs-explorer-gui/src/styles/tokens.css new file mode 100644 index 0000000..83daca8 --- /dev/null +++ b/crates/ntfs-explorer-gui/src/styles/tokens.css @@ -0,0 +1,183 @@ +/* ===== Windows 11 Design Tokens ===== */ +/* Accurate Windows 11 File Explorer colors and effects */ + +/* ---------- Light Theme (Default) ---------- */ +.app.theme-light { + /* Windows 11 accent blue - exact values */ + --accent: #0067c0; + --accent-hover: #1975c5; + --accent-active: #0052a3; + --accent-light: rgba(0, 103, 192, 0.08); + --accent-text: #003d73; + + /* Windows 11 Mica-inspired backgrounds */ + --bg-mica: #f3f3f3; + --bg-solid: #ffffff; + --bg-surface: #f9f9f9; + --bg-subtle: #f5f5f5; + --bg-card: #ffffff; + + /* Hover/selection states */ + --bg-hover: rgba(0, 0, 0, 0.03); + --bg-hover-strong: rgba(0, 0, 0, 0.05); + --bg-selected: rgba(0, 103, 192, 0.06); + --bg-selected-hover: rgba(0, 103, 192, 0.10); + + /* Windows 11 borders - subtle card elevation */ + --border-subtle: rgba(0, 0, 0, 0.04); + --border-default: rgba(0, 0, 0, 0.08); + --border-strong: rgba(0, 0, 0, 0.14); + --border-card: rgba(0, 0, 0, 0.05); + --border-focus: var(--accent); + + /* Windows 11 text colors */ + --text-primary: #1a1a1a; + --text-secondary: #626262; + --text-tertiary: #8b8b8b; + --text-disabled: #a8a8a8; + --text-accent: var(--accent); + --text-on-accent: #ffffff; + + /* Semantic colors */ + --success: #0f7b0f; + --warning: #9d5d00; + --error: #c42b1c; + --info: var(--accent); + + /* Windows 11 elevation shadows */ + --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04); + --shadow-flyout: 0 8px 16px rgba(0, 0, 0, 0.14), 0 0 0 1px rgba(0, 0, 0, 0.05); + --shadow-context-menu: 0 8px 20px rgba(0, 0, 0, 0.12), 0 4px 8px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.05); + --shadow-tooltip: 0 4px 12px rgba(0, 0, 0, 0.12); + + /* Scrollbar - Windows 11 thin style */ + --scrollbar-track: transparent; + --scrollbar-thumb: rgba(0, 0, 0, 0.38); + --scrollbar-thumb-hover: rgba(0, 0, 0, 0.50); + + /* Focus ring - Windows 11 style */ + --focus-ring: 0 0 0 2px var(--bg-solid), 0 0 0 4px var(--accent); + + /* Badge colors - deleted items */ + --badge-deleted-bg: rgba(196, 43, 28, 0.10); + --badge-deleted-border: rgba(196, 43, 28, 0.25); + --badge-deleted-text: #a4262c; + + /* Badge colors - EFS encrypted */ + --badge-efs-bg: rgba(0, 103, 192, 0.10); + --badge-efs-border: rgba(0, 103, 192, 0.25); + --badge-efs-text: #0052a3; + + /* Tree navigation */ + --tree-indent: 20px; + --tree-connector: rgba(0, 0, 0, 0.08); + + /* Icon colors */ + --icon-folder: #dcb67a; + --icon-folder-deleted: #9a7c52; + --icon-file: #6b6b6b; + --icon-file-deleted: #999999; + + /* Address bar */ + --address-bg: #ffffff; + --address-border: rgba(0, 0, 0, 0.08); + --address-border-focus: var(--accent); + + /* Header row - Windows Explorer style */ + --header-bg: #f9f9f9; + --header-border: rgba(0, 0, 0, 0.08); + --header-text: #5c5c5c; + --header-sort-icon: #767676; + + color-scheme: light; +} + +/* ---------- Dark Theme ---------- */ +.app.theme-dark { + /* Windows 11 accent blue - dark theme adjusted */ + --accent: #4cc2ff; + --accent-hover: #60cdff; + --accent-active: #3cb8ff; + --accent-light: rgba(76, 194, 255, 0.12); + --accent-text: #4cc2ff; + + /* Windows 11 dark Mica-inspired backgrounds */ + --bg-mica: #202020; + --bg-solid: #282828; + --bg-surface: #2d2d2d; + --bg-subtle: #323232; + --bg-card: #2d2d2d; + + /* Hover/selection states */ + --bg-hover: rgba(255, 255, 255, 0.05); + --bg-hover-strong: rgba(255, 255, 255, 0.08); + --bg-selected: rgba(76, 194, 255, 0.10); + --bg-selected-hover: rgba(76, 194, 255, 0.15); + + /* Borders */ + --border-subtle: rgba(255, 255, 255, 0.05); + --border-default: rgba(255, 255, 255, 0.08); + --border-strong: rgba(255, 255, 255, 0.12); + --border-card: rgba(255, 255, 255, 0.06); + --border-focus: var(--accent); + + /* Text */ + --text-primary: #ffffff; + --text-secondary: #c5c5c5; + --text-tertiary: #8a8a8a; + --text-disabled: #5c5c5c; + --text-accent: var(--accent); + --text-on-accent: #000000; + + /* Semantic colors */ + --success: #6ccb5f; + --warning: #fce100; + --error: #ff99a4; + --info: var(--accent); + + /* Shadows */ + --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.05); + --shadow-flyout: 0 8px 16px rgba(0, 0, 0, 0.50), 0 0 0 1px rgba(255, 255, 255, 0.06); + --shadow-context-menu: 0 8px 20px rgba(0, 0, 0, 0.45), 0 4px 8px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.06); + --shadow-tooltip: 0 4px 12px rgba(0, 0, 0, 0.45); + + /* Scrollbar */ + --scrollbar-track: transparent; + --scrollbar-thumb: rgba(255, 255, 255, 0.30); + --scrollbar-thumb-hover: rgba(255, 255, 255, 0.45); + + /* Focus ring */ + --focus-ring: 0 0 0 2px var(--bg-solid), 0 0 0 4px var(--accent); + + /* Badge colors */ + --badge-deleted-bg: rgba(255, 153, 164, 0.15); + --badge-deleted-border: rgba(255, 153, 164, 0.30); + --badge-deleted-text: #ff99a4; + + --badge-efs-bg: rgba(76, 194, 255, 0.15); + --badge-efs-border: rgba(76, 194, 255, 0.30); + --badge-efs-text: #4cc2ff; + + /* Tree */ + --tree-indent: 20px; + --tree-connector: rgba(255, 255, 255, 0.08); + + /* Icon colors */ + --icon-folder: #e8c77b; + --icon-folder-deleted: #9a8a5a; + --icon-file: #aaaaaa; + --icon-file-deleted: #666666; + + /* Address bar */ + --address-bg: #323232; + --address-border: rgba(255, 255, 255, 0.08); + --address-border-focus: var(--accent); + + /* Header row */ + --header-bg: #2d2d2d; + --header-border: rgba(255, 255, 255, 0.08); + --header-text: #c5c5c5; + --header-sort-icon: #8a8a8a; + + color-scheme: dark; +} diff --git a/crates/ntfs/Cargo.toml b/crates/ntfs/Cargo.toml new file mode 100644 index 0000000..45459c5 --- /dev/null +++ b/crates/ntfs/Cargo.toml @@ -0,0 +1,64 @@ +[package] +name = "ntfs" +description = "NTFS volume + filesystem tooling" +homepage = "https://github.com/omerbenamram/mft" +repository = "https://github.com/omerbenamram/mft" +license = "MIT/Apache-2.0" +readme = "README.md" + +version = "0.1.0" +authors = ["Omer Ben-Amram "] +edition = "2024" +rust-version = "1.90" + +[dependencies] +log = { version = "0.4", features = ["release_max_level_debug"] } +thiserror = "2" +byteorder = "1" +jiff = { version = "0.2.16", default-features = false, features = ["std", "alloc", "serde", "perf-inline"] } +lru = "0.16.1" +flate2 = { version = "1", default-features = false, features = ["rust_backend"] } +bitflags = "2" +aes = "0.8" +des = "0.8" +md-5 = "0.10" +openssl = { version = "0.10", features = ["vendored"] } +clap = { version = "4", features = ["derive"] } +hex = "0.4" + +# Reuse the existing MFT parser for record decoding (MIT/Apache). +mft = { path = "../..", version = "0.7.0" } + +[dev-dependencies] +# Used by regression tests that validate file content hashes against `testdata/ntfs/ntfs1-gen2.xml`. +sha1 = "0.10" +assert_cmd = "2.1.1" +predicates = "3.1" +tempfile = "3.23" + +[target.'cfg(target_os = "linux")'.dependencies] +# Optional mount frontend: FUSE (Linux-only; `fuser` requires libfuse on non-Linux platforms). +fuser = { version = "0.16.0", optional = true } +libc = { version = "0.2", optional = true } + +[target.'cfg(windows)'.dependencies] +# Optional mount frontend: Dokan +dokan = { version = "0.3.1", optional = true } +# `dokan`'s public traits use `widestring` types (e.g. `U16CStr`). +widestring = { version = "0.4.3", optional = true } +# Use the idiomatic Windows projection (requested). +windows = { version = ">=0.59, <=0.62", optional = true, features = ["Win32_Foundation", "Win32_Storage_FileSystem"] } + +[features] +default = [] + +fuse = ["dep:fuser", "dep:libc"] +dokan = ["dep:dokan", "dep:windows", "dep:widestring"] + +[[bin]] +name = "ntfs-info" +path = "src/bin/ntfs_info.rs" + +[[bin]] +name = "ntfs-mount" +path = "src/bin/ntfs_mount.rs" diff --git a/crates/ntfs/README.md b/crates/ntfs/README.md new file mode 100644 index 0000000..dff1cda --- /dev/null +++ b/crates/ntfs/README.md @@ -0,0 +1,7 @@ +## ntfs + +A library for reading and writing NTFS volumes and MFT entries. + +This crate is licensed under **MIT/Apache-2.0**. + + diff --git a/crates/ntfs/examples/ntfs_extract.rs b/crates/ntfs/examples/ntfs_extract.rs new file mode 100644 index 0000000..90ec9c8 --- /dev/null +++ b/crates/ntfs/examples/ntfs_extract.rs @@ -0,0 +1,38 @@ +//! Dev helper: extract an NTFS file's default stream to a local path. +//! +//! Usage: +//! - `cargo run -p ntfs --example ntfs_extract -- ` +//! +//! Example: +//! - `cargo run -p ntfs --example ntfs_extract -- testdata/ntfs/ntfs1-gen2.E01 \\Raw\\NIST_logo.jpg /tmp/NIST_logo.jpg` + +#![forbid(unsafe_code)] + +use ntfs::image::EwfImage; +use ntfs::ntfs::{FileSystem, Volume}; +use std::path::PathBuf; +use std::sync::Arc; + +fn main() { + let img_path = std::env::args_os() + .nth(1) + .map(PathBuf::from) + .expect("missing "); + let ntfs_path = std::env::args().nth(2).expect("missing "); + let out_path = std::env::args_os() + .nth(3) + .map(PathBuf::from) + .expect("missing "); + + let img = EwfImage::open(img_path).expect("failed to open EWF image"); + let volume = Volume::open(Arc::new(img), 0).expect("failed to open NTFS volume"); + let fs = FileSystem::new(volume); + + let entry_id = fs.resolve_path(&ntfs_path).expect("failed to resolve path"); + let bytes = fs + .read_file_default_stream(entry_id) + .expect("failed to read default stream"); + + std::fs::write(&out_path, bytes).expect("failed to write output"); + eprintln!("wrote {}", out_path.display()); +} diff --git a/crates/ntfs/src/bin/ntfs_info.rs b/crates/ntfs/src/bin/ntfs_info.rs new file mode 100644 index 0000000..aecad1b --- /dev/null +++ b/crates/ntfs/src/bin/ntfs_info.rs @@ -0,0 +1,671 @@ +//! Port of `external/libfsntfs/fsntfsinfo`. +#![forbid(unsafe_code)] + +#[path = "ntfs_info/cli_utils.rs"] +mod cli_utils; + +use clap::{Parser, Subcommand}; +use md5::{Digest as _, Md5}; +use mft::attribute::MftAttributeType; +use mft::attribute::header::ResidentialHeader; +use ntfs::image::Image; +use ntfs::ntfs::efs::EfsRsaKeyBag; +use ntfs::ntfs::{Error, FileSystem, Result, Volume}; +use std::fs; +use std::io::{self, Write as _}; +use std::path::PathBuf; +use std::sync::Arc; + +#[derive(Debug, Parser)] +#[command( + about = "ntfs-info (Rust): inspect NTFS volumes and MFT entries", + version +)] +struct Cli { + /// Source image path (raw, E01, AFF). + #[arg(value_name = "SOURCE")] + image: PathBuf, + + /// Byte offset of the NTFS volume inside the image. + #[arg(short = 'o', long, default_value_t = 0, value_parser = cli_utils::parse_u64)] + offset: u64, + + /// Output file system hierarchy as a bodyfile. + #[arg( + short = 'B', + conflicts_with_all = ["mft_entry_index", "file_entry_path", "show_usn"] + )] + bodyfile: Option, + + /// Calculate an MD5 hash of a file entry to include in the bodyfile. + #[arg(short = 'd', requires = "bodyfile")] + calculate_md5: bool, + + /// Show information about a specific MFT entry index, or "all". + #[arg( + short = 'E', + conflicts_with_all = ["bodyfile", "file_entry_path", "show_hierarchy", "show_usn"] + )] + mft_entry_index: Option, + + /// Show information about a specific file entry path. + #[arg( + short = 'F', + conflicts_with_all = ["bodyfile", "mft_entry_index", "show_hierarchy", "show_usn"] + )] + file_entry_path: Option, + + /// Show the file system hierarchy. + #[arg(short = 'H', conflicts_with_all = ["mft_entry_index", "file_entry_path", "show_usn"])] + show_hierarchy: bool, + + /// Show information from the USN change journal ($UsnJrnl). + #[arg( + short = 'U', + conflicts_with_all = ["bodyfile", "mft_entry_index", "file_entry_path", "show_hierarchy"] + )] + show_usn: bool, + + /// Verbose output (diagnostics). + #[arg(short = 'v')] + verbose: bool, + + /// New-style subcommands (kept for convenience). If provided, legacy flags are ignored. + #[command(subcommand)] + command: Option, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Print basic NTFS boot/volume header information. + Volume, + + /// Inspect an MFT entry. + Mft { + /// MFT entry index (record number) or "all". + #[arg(value_name = "ENTRY")] + entry: String, + }, + + /// Inspect a file entry by path (e.g. "\\Windows\\System32"). + File { + /// File entry path. + #[arg(value_name = "PATH")] + path: String, + }, + + /// Print file system hierarchy (optionally as a bodyfile). + Hierarchy { + /// Output a SleuthKit bodyfile to this path. + #[arg(long)] + bodyfile: Option, + + /// Include MD5 hashes in bodyfile output. + #[arg(long)] + md5: bool, + }, + + /// Print USN change journal ($UsnJrnl) records. + Usn, + + /// Read a $DATA stream from an MFT entry (supports ADS via --stream). + Read { + /// MFT entry number (record number). + #[arg(value_parser = cli_utils::parse_u64)] + entry: u64, + + /// Stream name (empty for default unnamed stream). For `file:ADS` use `--stream ADS`. + #[arg(long, default_value = "")] + stream: String, + + /// Write output to a file instead of stdout. + #[arg(long)] + out: Option, + + /// Print MD5 of the stream (and still write bytes if --out is set). + #[arg(long)] + md5: bool, + + /// Decrypt EFS-encrypted $DATA using an RSA key from a PKCS#12/PFX file. + #[arg(long)] + pfx: Option, + + /// PFX password (omit for empty password). + #[arg(long)] + pfx_password: Option, + }, +} + +fn main() { + match run() { + Ok(()) => {} + Err(Error::Io(e)) if e.kind() == io::ErrorKind::BrokenPipe => { + // Common when piping to `head`, etc. Treat as success. + } + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + }; +} + +fn run() -> Result<()> { + let cli = Cli::parse(); + + let img = Image::open(&cli.image)?; + let volume = Volume::open(Arc::new(img), cli.offset)?; + let fs = FileSystem::new(volume); + + if cli.verbose { + eprintln!("ntfs-info: verbose enabled"); + } + + if let Some(command) = cli.command { + return match command { + Command::Volume => cmd_volume(&fs), + Command::Mft { entry } => cmd_mft_selector(&fs, &entry), + Command::File { path } => cmd_file_entry_by_path(&fs, &path), + Command::Hierarchy { bodyfile, md5 } => cmd_hierarchy(&fs, bodyfile, md5), + Command::Usn => cmd_usn(&fs), + Command::Read { + entry, + stream, + out, + md5, + pfx, + pfx_password, + } => cmd_read(&fs, entry, &stream, out, md5, pfx, pfx_password), + }; + } + + // Legacy / C-compatible mode selection (matches external/libfsntfs/fsntfstools/fsntfsinfo.c). + if let Some(sel) = cli.mft_entry_index.as_deref() { + return cmd_mft_selector(&fs, sel); + } + if let Some(path) = cli.file_entry_path.as_deref() { + return cmd_file_entry_by_path(&fs, path); + } + if cli.bodyfile.is_some() || cli.show_hierarchy { + return cmd_hierarchy(&fs, cli.bodyfile, cli.calculate_md5); + } + if cli.show_usn { + return cmd_usn(&fs); + } + + cmd_volume(&fs) +} + +fn cmd_volume(fs: &FileSystem) -> Result<()> { + let mut out = io::stdout().lock(); + write!(out, "{}", fs.volume().header)?; + Ok(()) +} + +fn cmd_mft_selector(fs: &FileSystem, selector: &str) -> Result<()> { + let selector = selector.trim(); + if selector.eq_ignore_ascii_case("all") { + return cmd_mft_all(fs); + } + let entry_id = cli_utils::parse_u64(selector).map_err(|e| Error::InvalidData { message: e })?; + cmd_mft(fs, entry_id) +} + +fn cmd_mft_all(fs: &FileSystem) -> Result<()> { + // Strict: if we cannot determine the MFT entry count, fail instead of guessing. + let count = estimate_mft_entry_count(fs.volume())?.min(200_000); + let mut out = io::stdout().lock(); + for i in 0..count { + let entry = fs.volume().read_mft_entry(i)?; + let allocated = entry + .header + .flags + .contains(mft::entry::EntryFlags::ALLOCATED); + let is_dir = entry.is_dir(); + let name = entry.find_best_name_attribute().map(|n| n.name); + if let Some(name) = name { + writeln!(out, "{i}\tallocated={allocated}\tdir={is_dir}\tname={name}")?; + } else { + writeln!(out, "{i}\tallocated={allocated}\tdir={is_dir}\tname=")?; + } + } + Ok(()) +} + +fn cmd_mft(fs: &FileSystem, entry_id: u64) -> Result<()> { + let entry = fs.volume().read_mft_entry(entry_id)?; + let allocated = entry + .header + .flags + .contains(mft::entry::EntryFlags::ALLOCATED); + + let name = entry.find_best_name_attribute().map(|n| n.name); + + let mut out = io::stdout().lock(); + + writeln!(out, "record_number: {}", entry.header.record_number)?; + let sig = std::str::from_utf8(&entry.header.signature).unwrap_or("????"); + writeln!(out, "signature: {:?}", sig)?; + writeln!(out, "allocated: {}", allocated)?; + writeln!(out, "is_dir: {}", entry.is_dir())?; + writeln!( + out, + "base_reference: {}-{}", + entry.header.base_reference.entry, entry.header.base_reference.sequence + )?; + if let Some(name) = name { + writeln!(out, "best_name: {}", name)?; + } + + // Summarize all attributes (high level). Strict: do not ignore attribute parse errors. + for attr in entry.iter_attributes() { + let attr = attr?; + let name = if attr.header.name.is_empty() { + "".to_string() + } else { + attr.header.name.clone() + }; + match &attr.header.residential_header { + ResidentialHeader::Resident(r) => { + writeln!( + out, + "attr: type={:?} name={} resident size={} instance={}", + attr.header.type_code, name, r.data_size, attr.header.instance + )?; + } + ResidentialHeader::NonResident(nr) => { + writeln!( + out, + "attr: type={:?} name={} nonresident size={} vcn_first={} vcn_last={} instance={}", + attr.header.type_code, + name, + nr.file_size, + nr.vnc_first, + nr.vnc_last, + attr.header.instance + )?; + } + } + } + + Ok(()) +} + +fn cmd_file_entry_by_path(fs: &FileSystem, path: &str) -> Result<()> { + // Strict: do not resolve via MFT parent-scan fallbacks. + let entry_id = fs.resolve_path_strict(path)?; + + let mut out = io::stdout().lock(); + writeln!(out, "path: {path}")?; + writeln!(out, "mft_entry: {entry_id}")?; + cmd_mft(fs, entry_id) +} + +fn cmd_hierarchy(fs: &FileSystem, bodyfile: Option, calculate_md5: bool) -> Result<()> { + use ntfs::ntfs::filesystem::{is_dot_dir_entry, join_ntfs_child_path}; + use std::collections::{HashSet, VecDeque}; + use std::io::BufWriter; + + enum Sink<'a> { + Stdout(io::StdoutLock<'a>), + Bodyfile { + out: BufWriter, + calculate_md5: bool, + }, + } + + impl Sink<'_> { + fn emit(&mut self, fs: &FileSystem, entry_id: u64, path: &str) -> Result<()> { + match self { + Sink::Stdout(out) => { + writeln!(out, "{path}\t(entry {entry_id})")?; + Ok(()) + } + Sink::Bodyfile { out, calculate_md5 } => { + write_bodyfile_for_entry(fs, entry_id, path, *calculate_md5, out) + } + } + } + } + + let mut sink = match bodyfile { + Some(p) => Sink::Bodyfile { + out: BufWriter::new(fs::File::create(p)?), + calculate_md5, + }, + None => Sink::Stdout(io::stdout().lock()), + }; + + if let Sink::Stdout(out) = &mut sink { + writeln!(out, "File system hierarchy:")?; + } + + // Match upstream behavior: include the root directory itself. + sink.emit(fs, 5, "\\")?; + + let mut queue: VecDeque<(u64, String)> = VecDeque::new(); + queue.push_back((5, "\\".to_string())); + + let mut visited_dirs: HashSet = HashSet::new(); + + while let Some((dir_id, dir_path)) = queue.pop_front() { + if !visited_dirs.insert(dir_id) { + continue; + } + + let entries = fs.read_dir_strict(dir_id)?; + + for e in entries { + if is_dot_dir_entry(e.name.as_str()) { + continue; + } + + let child_path = join_ntfs_child_path(&dir_path, e.name.as_str()); + sink.emit(fs, e.entry_id, &child_path)?; + + let child_entry = fs.volume().read_mft_entry(e.entry_id)?; + if child_entry.is_dir() { + queue.push_back((e.entry_id, child_path)); + } + } + } + + Ok(()) +} + +fn cmd_usn(fs: &FileSystem) -> Result<()> { + let mut out = io::stdout().lock(); + + let Some(mut journal) = fs.open_usn_change_journal()? else { + writeln!(out, "USN change journal: N/A")?; + return Ok(()); + }; + + writeln!(out, "USN change journal: \\$Extend\\$UsnJrnl")?; + writeln!(out)?; + + for raw in journal.iter_record_bytes() { + let raw = raw?; + + let rec = ntfs::ntfs::usn::UsnRecord::parse(&raw.bytes, raw.offset)?; + match rec { + ntfs::ntfs::usn::UsnRecord::V2(rec) => { + writeln!(out, "USN record:")?; + writeln!(out, "\tOffset\t\t\t\t: 0x{:x}", raw.offset)?; + writeln!( + out, + "\tUpdate time\t\t\t: 0x{:016x}", + rec.timestamp_filetime + )?; + writeln!(out, "\tUpdate sequence number\t\t: {}", rec.usn)?; + + writeln!( + out, + "\tUpdate reason flags\t\t: 0x{:08x}", + rec.reason.bits() + )?; + for name in ntfs::ntfs::usn::reason_flag_names(rec.reason) { + writeln!(out, "\t\t({name})")?; + } + writeln!(out)?; + + writeln!( + out, + "\tUpdate source flags\t\t: 0x{:08x}", + rec.source_info.bits() + )?; + for name in ntfs::ntfs::usn::source_flag_names(rec.source_info) { + writeln!(out, "\t\t({name})")?; + } + writeln!(out)?; + + writeln!(out, "\tName\t\t\t\t: {}", rec.name)?; + + writeln!( + out, + "\tFile reference\t\t\t: {}", + format_file_reference(rec.file_reference) + )?; + writeln!( + out, + "\tParent file reference\t\t: {}", + format_file_reference(rec.parent_file_reference) + )?; + + writeln!( + out, + "\tFile attribute flags\t\t: 0x{:08x}", + rec.file_attributes.bits() + )?; + for name in ntfs::ntfs::usn::file_attribute_flag_names(rec.file_attributes) { + writeln!(out, "\t\t({name})")?; + } + + writeln!(out)?; + } + } + } + + Ok(()) +} + +fn cmd_read( + fs: &FileSystem, + entry_id: u64, + stream_name: &str, + out: Option, + md5: bool, + pfx: Option, + pfx_password: Option, +) -> Result<()> { + let bytes = if let Some(pfx_path) = pfx { + if !stream_name.is_empty() { + return Err(ntfs::ntfs::Error::Unsupported { + what: "EFS decryption is only supported for the default unnamed $DATA stream" + .to_string(), + }); + } + let pfx_bytes = fs::read(pfx_path)?; + let bag = EfsRsaKeyBag::from_pkcs12_der(&pfx_bytes, pfx_password.as_deref())?; + fs.read_file_default_stream_decrypted(entry_id, &bag)? + } else { + fs.read_file_stream(entry_id, stream_name)? + }; + + if md5 { + let mut h = Md5::new(); + h.update(&bytes); + let digest = h.finalize(); + let mut out = io::stdout().lock(); + writeln!(out, "{}", hex::encode(digest))?; + } + + match out { + Some(path) => fs::write(path, &bytes)?, + None if !md5 => { + let mut stdout = io::stdout().lock(); + stdout.write_all(&bytes)?; + } + None => {} + } + + Ok(()) +} + +fn estimate_mft_entry_count(volume: &Volume) -> Result { + let entry0 = volume.read_mft_entry(0)?; + for attr in entry0.iter_attributes_matching(Some(vec![MftAttributeType::DATA])) { + let attr = attr?; + if !attr.header.name.is_empty() { + continue; + } + if let ResidentialHeader::NonResident(nr) = &attr.header.residential_header { + if volume.header.mft_entry_size == 0 { + return Err(Error::InvalidData { + message: "mft_entry_size is 0".to_string(), + }); + } + return Ok(nr.file_size / volume.header.mft_entry_size as u64); + } + } + Err(Error::NotFound { + what: "missing $MFT $DATA mapping in entry 0".to_string(), + }) +} + +fn format_file_reference(file_ref: u64) -> String { + if file_ref == 0 { + return "0".to_string(); + } + let entry = file_ref & 0x0000_FFFF_FFFF_FFFF; + let seq = file_ref >> 48; + format!("{entry}-{seq}") +} + +fn write_bodyfile_for_entry( + fs: &FileSystem, + entry_id: u64, + path: &str, + calculate_md5: bool, + out: &mut impl io::Write, +) -> Result<()> { + let entry = fs.volume().read_mft_entry(entry_id)?; + + // Strict: require $STANDARD_INFORMATION for timestamps and flags. + let mut si_iter = + entry.iter_attributes_matching(Some(vec![MftAttributeType::StandardInformation])); + let si_attr = si_iter.next().ok_or_else(|| Error::NotFound { + what: format!("missing $STANDARD_INFORMATION for entry {entry_id}"), + })??; + let si = si_attr + .data + .into_standard_info() + .ok_or_else(|| Error::InvalidData { + message: format!("failed to parse $STANDARD_INFORMATION for entry {entry_id}"), + })?; + + let crtime = ts_to_bodyfile_time(&si.created); + let mtime = ts_to_bodyfile_time(&si.modified); + let ctime = ts_to_bodyfile_time(&si.mft_modified); + let atime = ts_to_bodyfile_time(&si.accessed); + let readonly = si + .file_flags + .contains(mft::attribute::FileAttributeFlags::FILE_ATTRIBUTE_READONLY); + + let mut mode_bytes: [u8; 10] = if entry.is_dir() { + *b"drwxrwxrwx" + } else { + *b"-rwxrwxrwx" + }; + if readonly { + for idx in [2_usize, 5, 8] { + mode_bytes[idx] = b'-'; + } + } + let mode = String::from_utf8_lossy(&mode_bytes).to_string(); + + // Directory: emit a single line (size=0). + if entry.is_dir() { + let md5 = "00000000000000000000000000000000"; + let inode = format!("{}-{}", entry.header.record_number, entry.header.sequence); + writeln!( + out, + "{}|{}|{}|{}|0|0|0|{}|{}|{}|{}", + md5, + escape_bodyfile_name(path), + inode, + mode, + atime, + mtime, + ctime, + crtime + ) + .map_err(Error::Io)?; + return Ok(()); + } + + // Files: emit one line per DATA stream (default + ADS). + let mut any = false; + for attr in entry.iter_attributes_matching(Some(vec![MftAttributeType::DATA])) { + let attr = attr?; + let stream_name = attr.header.name.clone(); + let size = match &attr.header.residential_header { + ResidentialHeader::Resident(r) => r.data_size as u64, + ResidentialHeader::NonResident(nr) => nr.file_size, + }; + + let md5 = if calculate_md5 { + fs.md5_file_stream(entry_id, &stream_name)? + } else { + "00000000000000000000000000000000".to_string() + }; + + let inode = format!("{}-{}", entry.header.record_number, entry.header.sequence); + let mut name = escape_bodyfile_name(path); + if !stream_name.is_empty() { + name.push(':'); + name.push_str(&escape_bodyfile_name(&stream_name)); + } + + writeln!( + out, + "{}|{}|{}|{}|0|0|{}|{}|{}|{}|{}", + md5, name, inode, mode, size, atime, mtime, ctime, crtime + ) + .map_err(Error::Io)?; + any = true; + } + + if !any { + // No $DATA; emit a placeholder. + let md5 = "00000000000000000000000000000000"; + let inode = format!("{}-{}", entry.header.record_number, entry.header.sequence); + writeln!( + out, + "{}|{}|{}|{}|0|0|0|{}|{}|{}|{}", + md5, + escape_bodyfile_name(path), + inode, + mode, + atime, + mtime, + ctime, + crtime + ) + .map_err(Error::Io)?; + } + + Ok(()) +} + +fn ts_to_bodyfile_time(ts: &jiff::Timestamp) -> String { + let sec = ts.as_second(); + let nanos = ts.subsec_nanosecond() as i64; + // Bodyfile uses 100ns ticks (7 digits). + let frac_100ns = (nanos / 100).clamp(0, 9_999_999); + format!("{sec}.{frac_100ns:07}") +} + +fn escape_bodyfile_name(s: &str) -> String { + // Escape characters that would break bodyfile parsing. + // + // - Column separator: `|` + // - Control chars: U+0000–U+001F and U+007F–U+009F + // + // We keep all other Unicode scalar values as UTF-8. + let mut out = String::with_capacity(s.len()); + for ch in s.chars() { + let cp = ch as u32; + let is_ctrl = (cp <= 0x1f) || ((0x7f..=0x9f).contains(&cp)); + if ch == '|' || is_ctrl { + // Matches upstream style: \xNN for control characters. + out.push('\\'); + out.push('x'); + let b = (cp & 0xff) as u8; + out.push(char::from_digit((b >> 4) as u32, 16).unwrap()); + out.push(char::from_digit((b & 0x0f) as u32, 16).unwrap()); + } else { + out.push(ch); + } + } + out +} diff --git a/crates/ntfs/src/bin/ntfs_info/cli_utils.rs b/crates/ntfs/src/bin/ntfs_info/cli_utils.rs new file mode 100644 index 0000000..11f466b --- /dev/null +++ b/crates/ntfs/src/bin/ntfs_info/cli_utils.rs @@ -0,0 +1,11 @@ +pub(crate) fn parse_u64(s: &str) -> std::result::Result { + let s = s.trim(); + let (radix, digits) = s + .strip_prefix("0x") + .or_else(|| s.strip_prefix("0X")) + .map(|d| (16, d)) + .unwrap_or((10, s)); + u64::from_str_radix(digits, radix).map_err(|e| e.to_string()) +} + + diff --git a/crates/ntfs/src/bin/ntfs_mount.rs b/crates/ntfs/src/bin/ntfs_mount.rs new file mode 100644 index 0000000..d4bdd9b --- /dev/null +++ b/crates/ntfs/src/bin/ntfs_mount.rs @@ -0,0 +1,121 @@ +#![forbid(unsafe_code)] + +use clap::Parser; +use ntfs::image::Image; +use ntfs::ntfs::efs::EfsRsaKeyBag; +use ntfs::ntfs::{Error, FileSystem, Result, Volume}; +use ntfs::tools::mount::vfs::Vfs; +use std::path::PathBuf; +use std::sync::Arc; + +#[derive(Debug, Parser)] +#[command( + about = "ntfs-mount (Rust): mount an NTFS volume (read-only) via FUSE (unix) or Dokan (windows)", + version +)] +struct Cli { + /// Source image path (raw, E01, AFF). + #[arg(value_name = "SOURCE")] + image: PathBuf, + + /// Where to mount the filesystem. + /// + /// - Unix (FUSE): a directory path + /// - Windows (Dokan): a drive letter (e.g. \"M:\\\") or an empty directory + #[arg(value_name = "MOUNTPOINT")] + mountpoint: PathBuf, + + /// Byte offset of the NTFS volume inside the image. + #[arg(short = 'o', long, default_value_t = 0, value_parser = parse_u64)] + offset: u64, + + /// Strict traversal (do not fall back to scanning MFT parent references when indexes are missing). + #[arg(long)] + strict: bool, + + /// Decrypt EFS-encrypted files using an RSA key from a PKCS#12/PFX file. + #[arg(long)] + pfx: Option, + + /// PFX password (omit for empty password). + #[arg(long)] + pfx_password: Option, + + /// Enable debug output (backend-dependent). + #[arg(long)] + debug: bool, + + /// Number of worker threads (Dokan only; 0 lets Dokan pick a default). + #[arg(long, default_value_t = 0)] + threads: u16, +} + +fn main() { + match run() { + Ok(()) => {} + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + } +} + +fn run() -> Result<()> { + let cli = Cli::parse(); + + let img = Image::open(&cli.image).map_err(Error::Io)?; + let volume = Volume::open(Arc::new(img), cli.offset)?; + let fs = FileSystem::new(volume); + + let keys = if let Some(pfx_path) = cli.pfx.as_ref() { + let pfx = std::fs::read(pfx_path)?; + Some(EfsRsaKeyBag::from_pkcs12_der( + &pfx, + cli.pfx_password.as_deref(), + )?) + } else { + None + }; + + let vfs = Vfs::new(fs).with_strict(cli.strict).with_efs_keys(keys); + + // --- Unix / FUSE --- + #[cfg(all(feature = "fuse", target_os = "linux"))] + { + return ntfs::tools::mount::fuse::mount(vfs, &cli.mountpoint).map_err(Error::Io); + } + + // --- Windows / Dokan --- + #[cfg(all(feature = "dokan", windows))] + { + let mp = cli.mountpoint.to_string_lossy().to_string(); + return ntfs::tools::mount::dokan::mount(vfs, &mp, cli.threads, cli.debug).map_err(|e| { + Error::InvalidData { + message: format!("dokan mount failed: {e}"), + } + }); + } + + // --- No backend available --- + #[cfg(not(any( + all(feature = "fuse", target_os = "linux"), + all(feature = "dokan", windows) + )))] + { + let _ = vfs; // keep variable used + Err(Error::Unsupported { + what: "ntfs-mount was built without a mount backend. Rebuild with `--features fuse` (unix) or `--features dokan` (windows)." + .to_string(), + }) + } +} + +fn parse_u64(s: &str) -> std::result::Result { + let s = s.trim(); + let (radix, digits) = s + .strip_prefix("0x") + .or_else(|| s.strip_prefix("0X")) + .map(|d| (16, d)) + .unwrap_or((10, s)); + u64::from_str_radix(digits, radix).map_err(|e| e.to_string()) +} diff --git a/crates/ntfs/src/image/aff.rs b/crates/ntfs/src/image/aff.rs new file mode 100644 index 0000000..7dc6e15 --- /dev/null +++ b/crates/ntfs/src/image/aff.rs @@ -0,0 +1,229 @@ +use crate::image::ReadAt; +use flate2::read::ZlibDecoder; +use lru::LruCache; +use std::collections::BTreeMap; +use std::io::{self, Read}; +use std::num::NonZeroUsize; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +#[derive(Debug, Clone, Copy)] +struct PageEntry { + data_offset: usize, + compressed_len: usize, + is_zlib: bool, +} + +/// Minimal AFF (AFF1) reader that supports page segments (`pageNNN`) compressed with zlib. +#[derive(Debug)] +pub struct AffImage { + data: Arc<[u8]>, + page_size: usize, + pages: Vec>, + cache: Mutex>>, +} + +impl AffImage { + pub fn open(path: impl AsRef) -> io::Result { + let data: Arc<[u8]> = std::fs::read(path)?.into(); + + if data.len() < 8 || &data[0..4] != b"AFF1" { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "missing AFF signature", + )); + } + + // Skip file signature: "AFF10\\r\\n\\0" + let mut cursor = 8usize; + + let mut page_size: Option = None; + let mut pages_map: BTreeMap = BTreeMap::new(); + + // Parsing strategy: + // - The first segment starts directly with magic "AFF\\0". + // - Subsequent segments are preceded by a 4-byte prefix (ignored here), then "AFF\\0". + let mut expect_prefix = false; + while cursor + 4 <= data.len() { + if expect_prefix { + if cursor + 4 > data.len() { + break; + } + cursor += 4; // ignore prefix + } + + if cursor + 4 > data.len() { + break; + } + if &data[cursor..cursor + 4] != b"AFF\0" { + // If we got desynced, stop early; callers can fall back to other formats. + break; + } + cursor += 4; + + let name_len = read_u32_be(&data, &mut cursor)? as usize; + let data_len = read_u32_be(&data, &mut cursor)? as usize; + let arg = read_u32_be(&data, &mut cursor)?; + + let name_bytes = read_slice(&data, &mut cursor, name_len)?; + let name = std::str::from_utf8(name_bytes) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "non-utf8 segment name"))?; + + let data_offset = cursor; + cursor = cursor + .checked_add(data_len) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "overflow"))?; + if cursor > data.len() { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "segment data out of bounds", + )); + } + + // Segment type trailer (e.g. "ATT\\0") + let trailer = read_slice(&data, &mut cursor, 4)?; + let _trailer = trailer; + + if name == "pagesize" { + // In our fixtures pagesize is stored in the arg field. + page_size = Some(arg as usize); + } else if let Some(page_index) = name.strip_prefix("page") + && let Ok(page) = page_index.parse::() + { + let is_zlib = data_len >= 2 + && data + .get(data_offset..data_offset + 2) + .is_some_and(|h| h[0] == 0x78 && matches!(h[1], 0x01 | 0x5e | 0x9c | 0xda)); + + pages_map.insert( + page, + PageEntry { + data_offset, + compressed_len: data_len, + is_zlib, + }, + ); + } + + expect_prefix = true; + } + + let page_size = page_size.ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "AFF missing pagesize segment") + })?; + if page_size == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "AFF pagesize cannot be 0", + )); + } + + let max_page = pages_map.keys().copied().max().unwrap_or(0); + let mut pages = vec![None; (max_page as usize).saturating_add(1)]; + for (idx, entry) in pages_map { + if let Some(slot) = pages.get_mut(idx as usize) { + *slot = Some(entry); + } + } + + Ok(Self { + data, + page_size, + pages, + // Pages are very large in our fixtures (16MiB), keep the cache small. + cache: Mutex::new(LruCache::new(NonZeroUsize::new(2).expect("2 > 0"))), + }) + } + + pub fn page_size(&self) -> usize { + self.page_size + } + + fn read_page(&self, page_index: u64) -> io::Result> { + if let Some(hit) = self.cache.lock().expect("poisoned").get(&page_index) { + return Ok(hit.clone()); + } + + let idx = usize::try_from(page_index) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "page index overflow"))?; + let entry = self + .pages + .get(idx) + .and_then(|x| *x) + .ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "page not present"))?; + + let compressed = self + .data + .get(entry.data_offset..entry.data_offset + entry.compressed_len) + .ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "page out of bounds"))?; + + let mut out = vec![0u8; self.page_size]; + if entry.is_zlib { + let cursor = io::Cursor::new(compressed); + let mut decoder = ZlibDecoder::new(cursor); + decoder.read_exact(&mut out)?; + } else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unsupported AFF page compression", + )); + } + + self.cache + .lock() + .expect("poisoned") + .put(page_index, out.clone()); + Ok(out) + } +} + +impl ReadAt for AffImage { + fn len(&self) -> u64 { + self.pages.len() as u64 * self.page_size as u64 + } + + 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 page = self.read_page(page_index)?; + let take = remaining.min(self.page_size - within); + buf[out_pos..out_pos + take].copy_from_slice(&page[within..within + take]); + + out_pos += take; + remaining -= take; + cur = cur.saturating_add(take as u64); + } + + Ok(()) + } +} + +fn read_u32_be(data: &[u8], cursor: &mut usize) -> io::Result { + let bytes = read_slice(data, cursor, 4)?; + Ok(u32::from_be_bytes(bytes.try_into().expect("len=4"))) +} + +fn read_slice<'a>(data: &'a [u8], cursor: &mut usize, len: usize) -> io::Result<&'a [u8]> { + let start = *cursor; + let end = start + .checked_add(len) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "overflow"))?; + if end > data.len() { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "out of bounds", + )); + } + *cursor = end; + Ok(&data[start..end]) +} diff --git a/crates/ntfs/src/image/ewf.rs b/crates/ntfs/src/image/ewf.rs new file mode 100644 index 0000000..a3ef41f --- /dev/null +++ b/crates/ntfs/src/image/ewf.rs @@ -0,0 +1,852 @@ +//! EWF/E01 (Expert Witness Format) random-access reader. +//! +//! This module provides `EwfImage`, an implementation of `ReadAt` over EWF v1 (classic `.E01`) +//! images. The focus is **correctness** and faithful behavior compared to `external/libewf` +//! (notably: descriptor and table checksums, and the v1 2 GiB wraparound offset encoding). +//! +//! Current limitations: +//! - Only **single-segment** EWF v1 files are supported (a lone `.E01` without `.E02`, …). +//! - EWF v2 and encrypted EWF are not supported yet. + +use crate::image::ReadAt; +use flate2::read::ZlibDecoder; +use lru::LruCache; +use std::io::{self, Read}; +use std::num::NonZeroUsize; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +const EWF1_EVF_SIGNATURE: [u8; 8] = [0x45, 0x56, 0x46, 0x09, 0x0d, 0x0a, 0xff, 0x00]; +const EWF1_LVF_SIGNATURE: [u8; 8] = [0x4c, 0x56, 0x46, 0x09, 0x0d, 0x0a, 0xff, 0x00]; +const EWF2_EVF_SIGNATURE: [u8; 8] = [0x45, 0x56, 0x46, 0x32, 0x0d, 0x0a, 0x81, 0x00]; +const EWF2_LEF_SIGNATURE: [u8; 8] = [0x4c, 0x45, 0x46, 0x32, 0x0d, 0x0a, 0x81, 0x00]; + +const EWF1_FILE_HEADER_SIZE: usize = 8 + 1 + 2 + 2; +const EWF1_SECTION_DESCRIPTOR_SIZE: usize = 16 + 8 + 8 + 40 + 4; +const EWF1_TABLE_HEADER_SIZE: usize = 4 + 4 + 8 + 4 + 4; + +/// Random-access view over a single-segment EWF v1 (`.E01`) image. +#[derive(Debug)] +pub struct EwfImage { + /// Entire segment file contents. + /// + /// For now this reader maps an EWF image by loading the `.E01` segment into memory. + /// (This keeps the implementation simple while we harden correctness; we can switch to + /// file-backed reading later without changing the public `ReadAt` interface.) + data: Arc<[u8]>, + + /// Logical media size in bytes (the size of the emulated disk), as declared in the `volume` + /// (or `disk`) section. + media_size: u64, + + /// Size in bytes of one EWF "chunk" of media data. + /// + /// This is derived from `sectors_per_chunk * bytes_per_sector` in the `volume` section. + chunk_size: usize, + + /// Chunk tables in this segment. + /// + /// Some writers emit multiple `sectors` + `table`/`table2` groups within the same `.E01`. + /// Each group provides offsets for a contiguous range of chunk indices. + chunk_groups: Vec, + + /// Total number of chunks across all groups. + chunk_count: u64, + + /// In-memory LRU cache of decoded chunks (keyed by chunk index). + cache: Mutex>>, +} + +#[derive(Debug)] +struct EwfChunkGroup { + /// Global chunk index of the first entry in this group. + first_chunk_index: u64, + + /// Base file offset for this group's entries. + chunk_base: u64, + + /// Table entries for this group (v1 `table` / `table2`) storing per-chunk offsets and the + /// compression bit. + chunk_entries: Vec, + + /// Absolute file offset where the chunk data region for this group ends. + /// + /// This is typically the end of the corresponding `sectors` section. + chunk_data_end: u64, +} + +impl EwfImage { + /// Opens a single-segment EWF v1 image from `path`. + pub fn open(path: impl AsRef) -> io::Result { + let data: Arc<[u8]> = std::fs::read(path)?.into(); + + let header = Ewf1FileHeader::parse(&data)?; + if header.segment_number != 1 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "only single-segment EWF v1 images are supported (expected segment_number=1)", + )); + } + let sections = parse_ewf1_section_descriptors(&data, header.sections_start_offset())?; + + // Some writers store volume parameters in a `disk` section (not `volume`). + let volume_desc = sections + .iter() + .find(|s| s.type_string == "volume" || s.type_string == "disk") + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "missing required section `volume` (or `disk`)", + ) + })?; + let volume = parse_volume_section_v1(&data, volume_desc)?; + + // Prefer `table2` if present. Some images contain *multiple* tables that must be + // concatenated (each associated with a `sectors` section). + let use_table2 = sections.iter().any(|s| s.type_string == "table2"); + let table_type = if use_table2 { "table2" } else { "table" }; + + let mut chunk_groups: Vec = Vec::new(); + let mut chunk_count: u64 = 0; + let mut pending_sectors_end: Option = None; + + for desc in §ions { + match desc.type_string.as_str() { + // Chunk data section. The table that follows describes offsets into this region. + "sectors" | "sector" => { + let end = desc.start_offset.saturating_add(desc.size); + pending_sectors_end = Some(end); + } + x if x == table_type => { + let table = parse_table_section_v1(&data, desc)?; + if table.entries.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "table has no entries", + )); + } + + if table.base_offset > data.len() as u64 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "table base_offset out of bounds", + )); + } + + let last_entry = *table.entries.last().expect("non-empty"); + let chunk_data_end = match pending_sectors_end.take() { + Some(end) => end, + None => compute_chunk_data_end_offset_v1(desc, table.base_offset, last_entry)?, + }; + + if chunk_data_end > data.len() as u64 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "chunk data end out of bounds", + )); + } + + let entries_len_u64 = u64::try_from(table.entries.len()).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "table entry count overflow") + })?; + + chunk_groups.push(EwfChunkGroup { + first_chunk_index: chunk_count, + chunk_base: table.base_offset, + chunk_entries: table.entries, + chunk_data_end, + }); + + chunk_count = chunk_count.saturating_add(entries_len_u64); + } + _ => {} + } + } + + if chunk_groups.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("no `{table_type}` sections found"), + )); + } + + if volume.number_of_chunks as u64 != chunk_count { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "volume/table chunk count mismatch: volume={} table={}", + volume.number_of_chunks, chunk_count + ), + )); + } + + let expected_chunks_from_media = + div_ceil_u64(volume.media_size, volume.chunk_size as u64); + if expected_chunks_from_media != chunk_count { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "media size/chunk size mismatch: media_size={} chunk_size={} expected_chunks={} table_chunks={}", + volume.media_size, volume.chunk_size, expected_chunks_from_media, chunk_count + ), + )); + } + + Ok(Self { + data, + media_size: volume.media_size, + chunk_size: volume.chunk_size, + chunk_groups, + chunk_count, + cache: Mutex::new(LruCache::new(NonZeroUsize::new(256).expect("256 > 0"))), + }) + } + + /// Returns the logical EWF chunk size in bytes. + pub fn chunk_size(&self) -> usize { + self.chunk_size + } + + /// Returns the number of chunks in the logical media. + pub fn chunk_count(&self) -> u64 { + self.chunk_count + } + + fn read_chunk(&self, chunk_index: u64) -> io::Result> { + if let Some(hit) = self.cache.lock().expect("poisoned").get(&chunk_index) { + return Ok(hit.clone()); + } + + if chunk_index >= self.chunk_count() { + return Err(io::Error::from(io::ErrorKind::UnexpectedEof)); + } + + let (start, end, is_compressed) = self.chunk_range(chunk_index)?; + + let start_usize = usize::try_from(start) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "chunk start overflow"))?; + let end_usize = usize::try_from(end) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "chunk end overflow"))?; + + if end_usize > self.data.len() || start_usize >= end_usize { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "chunk offsets out of bounds", + )); + } + + let slice = &self.data[start_usize..end_usize]; + + let mut out = vec![0u8; self.chunk_size]; + + if is_compressed { + let cursor = io::Cursor::new(slice); + let mut decoder = ZlibDecoder::new(cursor); + decoder.read_exact(&mut out)?; + } else { + // Uncompressed chunks are stored as: [chunk bytes][u32 adler32 checksum] + let required = self + .chunk_size + .checked_add(4) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "chunk size overflow"))?; + if slice.len() < required { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "short uncompressed chunk", + )); + } + + let data_part = &slice[..self.chunk_size]; + let checksum_part = &slice[self.chunk_size..self.chunk_size + 4]; + let stored = u32::from_le_bytes(checksum_part.try_into().expect("len=4")); + let calculated = adler32_rfc1950(data_part); + + if stored != calculated { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "uncompressed chunk checksum mismatch", + )); + } + + out.copy_from_slice(data_part); + } + + self.cache + .lock() + .expect("poisoned") + .put(chunk_index, out.clone()); + + Ok(out) + } + + /// Computes the file-backed byte range for a given chunk index. + /// + /// Mirrors `libewf` v1 logic, including the 2 GiB wraparound encoding used by some writers. + fn chunk_range(&self, chunk_index: u64) -> io::Result<(u64, u64, bool)> { + let (group, idx) = self.group_for_chunk(chunk_index)?; + + let current = group.chunk_entries[idx]; + let next = group.chunk_entries.get(idx + 1).copied(); + + let is_compressed = (current >> 31) != 0; + let current_off = current & 0x7fff_ffff; + + let start = group.chunk_base.saturating_add(current_off as u64); + + let end = if let Some(next) = next { + let next_off = next & 0x7fff_ffff; + + // libewf: if next_off < current_off, compute size from the *stored* (unmasked) next entry. + let size = if next_off < current_off { + if next < current_off { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "table offsets out of order", + )); + } + (next - current_off) as u64 + } else { + (next_off - current_off) as u64 + }; + + start + .checked_add(size) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "chunk end overflow"))? + } else { + // There is no indication how large the last chunk is. It is derived from the offset of + // the next section, following libewf v1 behavior. + group.chunk_data_end + }; + + if end <= start { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid chunk range", + )); + } + + Ok((start, end, is_compressed)) + } + + fn group_for_chunk(&self, chunk_index: u64) -> io::Result<(&EwfChunkGroup, usize)> { + // Find the last group whose `first_chunk_index` is <= chunk_index. + let pos = self + .chunk_groups + .partition_point(|g| g.first_chunk_index <= chunk_index); + let group_idx = pos.checked_sub(1).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "chunk index out of range") + })?; + let group = &self.chunk_groups[group_idx]; + + let local_u64 = chunk_index.saturating_sub(group.first_chunk_index); + let local = usize::try_from(local_u64) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "chunk index overflow"))?; + if local >= group.chunk_entries.len() { + return Err(io::Error::from(io::ErrorKind::UnexpectedEof)); + } + Ok((group, local)) + } +} + +impl ReadAt for EwfImage { + fn len(&self) -> u64 { + self.media_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 chunk_index = cur / self.chunk_size as u64; + let within = (cur % self.chunk_size as u64) as usize; + + let chunk = self.read_chunk(chunk_index)?; + let take = remaining.min(self.chunk_size - within); + buf[out_pos..out_pos + take].copy_from_slice(&chunk[within..within + take]); + + out_pos += take; + remaining -= take; + cur = cur.saturating_add(take as u64); + } + + Ok(()) + } +} + +#[derive(Debug, Clone, Copy)] +struct Ewf1FileHeader { + segment_number: u16, +} + +impl Ewf1FileHeader { + fn parse(data: &[u8]) -> io::Result { + if data.len() < EWF1_FILE_HEADER_SIZE { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "short ewf header", + )); + } + + let sig: [u8; 8] = data[0..8].try_into().expect("len=8"); + if sig == EWF1_EVF_SIGNATURE || sig == EWF1_LVF_SIGNATURE { + let segment_number = u16::from_le_bytes(data[9..11].try_into().expect("len=2")); + return Ok(Self { segment_number }); + } + + if sig == EWF2_EVF_SIGNATURE || sig == EWF2_LEF_SIGNATURE { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "EWF v2 not supported yet", + )); + } + + Err(io::Error::new( + io::ErrorKind::InvalidData, + "unsupported EWF signature", + )) + } + + fn sections_start_offset(&self) -> u64 { + // EWF v1 sections start immediately after the fixed-size v1 file header. + EWF1_FILE_HEADER_SIZE as u64 + } +} + +#[derive(Debug, Clone)] +struct Ewf1SectionDescriptor { + start_offset: u64, + type_string: String, + size: u64, +} + +impl Ewf1SectionDescriptor { + fn parse_at(data: &[u8], start_offset: u64) -> io::Result { + let start = usize::try_from(start_offset) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "section offset overflow"))?; + + let raw = data + .get(start..start + EWF1_SECTION_DESCRIPTOR_SIZE) + .ok_or_else(|| { + io::Error::new(io::ErrorKind::UnexpectedEof, "truncated section descriptor") + })?; + + let stored_checksum = u32::from_le_bytes( + raw[EWF1_SECTION_DESCRIPTOR_SIZE - 4..] + .try_into() + .expect("len=4"), + ); + let calculated_checksum = adler32_rfc1950(&raw[..EWF1_SECTION_DESCRIPTOR_SIZE - 4]); + if stored_checksum != calculated_checksum { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "section descriptor checksum mismatch", + )); + } + + let type_string = parse_ascii_nul_terminated(&raw[0..16]); + let next_offset = u64::from_le_bytes(raw[16..24].try_into().expect("len=8")); + let mut size = u64::from_le_bytes(raw[24..32].try_into().expect("len=8")); + + // libewf behavior: some writers leave size = 0, but set next_offset; infer size from that. + if size == 0 && next_offset != start_offset && next_offset >= start_offset { + size = next_offset - start_offset; + } + + Ok(Self { + start_offset, + type_string, + size, + }) + } + + fn data_range<'a>(&self, file: &'a [u8]) -> io::Result<&'a [u8]> { + let start = self + .start_offset + .checked_add(EWF1_SECTION_DESCRIPTOR_SIZE as u64) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "overflow"))?; + let end = self + .start_offset + .checked_add(self.size) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "overflow"))?; + + let start = usize::try_from(start) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "range overflow"))?; + let end = usize::try_from(end) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "range overflow"))?; + + file.get(start..end).ok_or_else(|| { + io::Error::new(io::ErrorKind::UnexpectedEof, "section data out of bounds") + }) + } +} + +fn parse_ewf1_section_descriptors( + data: &[u8], + first_section_offset: u64, +) -> io::Result> { + let mut sections = Vec::new(); + let mut offset = first_section_offset; + + // Hard safety cap: avoid pathological scans on corrupted inputs. + for _ in 0..100_000 { + if offset == 0 || offset >= data.len() as u64 { + break; + } + + let desc = Ewf1SectionDescriptor::parse_at(data, offset)?; + let is_last = desc.type_string == "next" || desc.type_string == "done"; + + let advance = if desc.size != 0 { + desc.size + } else { + // libewf: for last sections (`next`/`done`) some writers set size=0; advance by descriptor size. + EWF1_SECTION_DESCRIPTOR_SIZE as u64 + }; + + if advance == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "zero advance while scanning sections", + )); + } + + sections.push(desc); + if is_last { + break; + } + + offset = offset.saturating_add(advance); + } + + if sections.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "no EWF sections found", + )); + } + + Ok(sections) +} + +#[derive(Debug, Clone, Copy)] +struct VolumeV1 { + number_of_chunks: u32, + chunk_size: usize, + media_size: u64, +} + +fn parse_volume_section_v1( + data: &[u8], + volume_desc: &Ewf1SectionDescriptor, +) -> io::Result { + let volume_data = volume_desc.data_range(data)?; + + if volume_data.len() < 24 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "volume section data too small", + )); + } + + let number_of_chunks = u32::from_le_bytes(volume_data[4..8].try_into().expect("len=4")); + let sectors_per_chunk = u32::from_le_bytes(volume_data[8..12].try_into().expect("len=4")); + let bytes_per_sector = u32::from_le_bytes(volume_data[12..16].try_into().expect("len=4")); + let number_of_sectors = u64::from_le_bytes(volume_data[16..24].try_into().expect("len=8")); + + if number_of_chunks == 0 || sectors_per_chunk == 0 || bytes_per_sector == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid volume parameters", + )); + } + + let chunk_size = sectors_per_chunk + .checked_mul(bytes_per_sector) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "chunk size overflow"))? + as usize; + + let media_size = number_of_sectors + .checked_mul(bytes_per_sector as u64) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "media size overflow"))?; + + Ok(VolumeV1 { + number_of_chunks, + chunk_size, + media_size, + }) +} + +#[derive(Debug, Clone)] +struct TableV1 { + base_offset: u64, + entries: Vec, +} + +fn parse_table_section_v1(data: &[u8], table_desc: &Ewf1SectionDescriptor) -> io::Result { + let section_data = table_desc.data_range(data)?; + + let header = section_data + .get(..EWF1_TABLE_HEADER_SIZE) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "table header too small"))?; + + let stored_header_checksum = u32::from_le_bytes( + header[EWF1_TABLE_HEADER_SIZE - 4..] + .try_into() + .expect("len=4"), + ); + let calculated_header_checksum = adler32_rfc1950(&header[..EWF1_TABLE_HEADER_SIZE - 4]); + if stored_header_checksum != calculated_header_checksum { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "table header checksum mismatch", + )); + } + + let number_of_entries = u32::from_le_bytes(header[0..4].try_into().expect("len=4")); + let base_offset = u64::from_le_bytes(header[8..16].try_into().expect("len=8")); + + if number_of_entries == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "table number_of_entries is 0", + )); + } + + let entries_len = usize::try_from(number_of_entries) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "table entry count overflow"))?; + let entries_bytes = entries_len + .checked_mul(4) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "entries size overflow"))?; + + let entries_start = EWF1_TABLE_HEADER_SIZE; + let entries_end = entries_start + .checked_add(entries_bytes) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "entries end overflow"))?; + + let entries_data = section_data + .get(entries_start..entries_end) + .ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "truncated table entries"))?; + + // Optional entries checksum (footer): immediately follows entries. + if let Some(footer) = section_data.get(entries_end..entries_end + 4) { + let stored_entries_checksum = u32::from_le_bytes(footer.try_into().expect("len=4")); + let calculated_entries_checksum = adler32_rfc1950(entries_data); + if stored_entries_checksum != calculated_entries_checksum { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "table entries checksum mismatch", + )); + } + } + + let mut out = Vec::with_capacity(entries_len); + for chunk in entries_data.chunks_exact(4) { + out.push(u32::from_le_bytes(chunk.try_into().expect("len=4"))); + } + + Ok(TableV1 { + base_offset, + entries: out, + }) +} + +fn parse_ascii_nul_terminated(bytes: &[u8]) -> String { + let len = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len()); + let slice = &bytes[..len]; + // EWF section type strings are ASCII; non-ASCII bytes are mapped lossily to keep parsing robust. + String::from_utf8_lossy(slice).to_string() +} + +fn div_ceil_u64(a: u64, b: u64) -> u64 { + if b == 0 { + return 0; + } + a / b + u64::from(!a.is_multiple_of(b)) +} + +fn compute_chunk_data_end_offset_v1( + table_desc: &Ewf1SectionDescriptor, + base_offset: u64, + last_entry: u32, +) -> io::Result { + let last_chunk_data_offset = base_offset.saturating_add((last_entry & 0x7fff_ffff) as u64); + + let end = if table_desc.type_string == "table2" { + // libewf: For table2 the chunk data is stored 2 sections before the table2 section. + table_desc.start_offset.saturating_sub(table_desc.size) + } else if last_chunk_data_offset < table_desc.start_offset { + // Chunk data stored before the table section. + table_desc.start_offset + } else { + // Chunk data stored inside the table section. + table_desc.start_offset.saturating_add(table_desc.size) + }; + + if end <= last_chunk_data_offset { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "last chunk end offset out of bounds", + )); + } + + Ok(end) +} + +fn adler32_rfc1950(data: &[u8]) -> u32 { + // RFC1950 adler32; same as zlib's adler32. + const MOD_ADLER: u32 = 65521; + let mut a: u32 = 1; + let mut b: u32 = 0; + + for &byte in data { + a = (a + u32::from(byte)) % MOD_ADLER; + b = (b + a) % MOD_ADLER; + } + + (b << 16) | a +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write as _; + + fn make_section_descriptor(type_string: &str, start_offset: u64, size: u64) -> [u8; EWF1_SECTION_DESCRIPTOR_SIZE] { + let mut raw = [0u8; EWF1_SECTION_DESCRIPTOR_SIZE]; + + // type string (ASCII, NUL-terminated) + let mut type_bytes = [0u8; 16]; + let src = type_string.as_bytes(); + let copy_len = src.len().min(type_bytes.len().saturating_sub(1)); + type_bytes[..copy_len].copy_from_slice(&src[..copy_len]); + raw[..16].copy_from_slice(&type_bytes); + + // next_offset (best-effort; not used by our scanner if size != 0) + let next_offset = start_offset.saturating_add(size); + raw[16..24].copy_from_slice(&next_offset.to_le_bytes()); + + // size + raw[24..32].copy_from_slice(&size.to_le_bytes()); + + // reserved bytes (40) left as zeros + + let checksum = adler32_rfc1950(&raw[..EWF1_SECTION_DESCRIPTOR_SIZE - 4]); + raw[EWF1_SECTION_DESCRIPTOR_SIZE - 4..].copy_from_slice(&checksum.to_le_bytes()); + raw + } + + fn make_table_header(number_of_entries: u32, base_offset: u64) -> [u8; EWF1_TABLE_HEADER_SIZE] { + let mut hdr = [0u8; EWF1_TABLE_HEADER_SIZE]; + hdr[0..4].copy_from_slice(&number_of_entries.to_le_bytes()); + // hdr[4..8] reserved/unknown = 0 + hdr[8..16].copy_from_slice(&base_offset.to_le_bytes()); + // hdr[16..20] reserved/unknown = 0 + let checksum = adler32_rfc1950(&hdr[..EWF1_TABLE_HEADER_SIZE - 4]); + hdr[EWF1_TABLE_HEADER_SIZE - 4..].copy_from_slice(&checksum.to_le_bytes()); + hdr + } + + #[test] + fn test_open_disk_section_and_multi_table2_groups() -> io::Result<()> { + // Build a minimal EWF v1 EVF file with: + // - `disk` section (instead of `volume`) + // - two `sectors` + `table2` groups (each group contains one chunk) + // - `done` terminator + // + // Each chunk is zlib-compressed and should decompress to 512 bytes. + + let chunk_size = 512usize; + let chunk0 = vec![b'A'; chunk_size]; + let chunk1 = vec![b'B'; chunk_size]; + let chunk0_z = { + use flate2::{Compression, write::ZlibEncoder}; + let mut enc = ZlibEncoder::new(Vec::new(), Compression::default()); + enc.write_all(&chunk0).unwrap(); + enc.finish().unwrap() + }; + let chunk1_z = { + use flate2::{Compression, write::ZlibEncoder}; + let mut enc = ZlibEncoder::new(Vec::new(), Compression::default()); + enc.write_all(&chunk1).unwrap(); + enc.finish().unwrap() + }; + + // --- File header --- + let mut file: Vec = Vec::new(); + file.extend_from_slice(&EWF1_EVF_SIGNATURE); + file.push(0); // unknown byte + file.extend_from_slice(&1u16.to_le_bytes()); // segment_number + file.extend_from_slice(&0u16.to_le_bytes()); // unknown + assert_eq!(file.len(), EWF1_FILE_HEADER_SIZE); + + // Helper to append a section: descriptor + body, returning its start_offset. + let mut sections: Vec<(String, u64, u64)> = Vec::new(); + let mut append_section = |typ: &str, body: &[u8]| { + let start_offset = file.len() as u64; + let size = (EWF1_SECTION_DESCRIPTOR_SIZE + body.len()) as u64; + let desc = make_section_descriptor(typ, start_offset, size); + file.extend_from_slice(&desc); + file.extend_from_slice(body); + sections.push((typ.to_string(), start_offset, size)); + start_offset + }; + + // disk section body: layout matches parse_volume_section_v1 (we only use fields starting at offset 4). + let mut disk_body = vec![0u8; 24]; + disk_body[0..4].copy_from_slice(&1u32.to_le_bytes()); // version/unknown + disk_body[4..8].copy_from_slice(&2u32.to_le_bytes()); // number_of_chunks + disk_body[8..12].copy_from_slice(&1u32.to_le_bytes()); // sectors_per_chunk + disk_body[12..16].copy_from_slice(&512u32.to_le_bytes()); // bytes_per_sector + disk_body[16..24].copy_from_slice(&2u64.to_le_bytes()); // number_of_sectors + append_section("disk", &disk_body); + + // group 0: sectors (chunk0_z) + table2 (1 entry) + let sectors0_start = append_section("sectors", &chunk0_z); + let chunk0_file_off = (sectors0_start + EWF1_SECTION_DESCRIPTOR_SIZE as u64) as u32; + + let mut table2_0_body: Vec = Vec::new(); + table2_0_body.extend_from_slice(&make_table_header(1, 0)); + table2_0_body.extend_from_slice(&(chunk0_file_off | 0x8000_0000).to_le_bytes()); + append_section("table2", &table2_0_body); + + // group 1: sectors (chunk1_z) + table2 (1 entry) + let sectors1_start = append_section("sectors", &chunk1_z); + let chunk1_file_off = (sectors1_start + EWF1_SECTION_DESCRIPTOR_SIZE as u64) as u32; + + let mut table2_1_body: Vec = Vec::new(); + table2_1_body.extend_from_slice(&make_table_header(1, 0)); + table2_1_body.extend_from_slice(&(chunk1_file_off | 0x8000_0000).to_le_bytes()); + append_section("table2", &table2_1_body); + + append_section("done", &[]); + + // Write to a temp file so we exercise the real open path. + let dir = tempfile::tempdir()?; + let path = dir.path().join("test.E01"); + std::fs::write(&path, &file)?; + + let img = EwfImage::open(&path)?; + assert_eq!(img.len(), 1024); + assert_eq!(img.chunk_size(), 512); + assert_eq!(img.chunk_count(), 2); + + let mut buf = vec![0u8; 1024]; + img.read_exact_at(0, &mut buf)?; + assert_eq!(&buf[..512], &chunk0[..]); + assert_eq!(&buf[512..], &chunk1[..]); + + // Cross-chunk read. + let mut mid = vec![0u8; 40]; + img.read_exact_at(500, &mut mid)?; + assert_eq!(&mid[..12], &vec![b'A'; 12]); + assert_eq!(&mid[12..], &vec![b'B'; 28]); + + Ok(()) + } +} diff --git a/crates/ntfs/src/image/mod.rs b/crates/ntfs/src/image/mod.rs new file mode 100644 index 0000000..c10765a --- /dev/null +++ b/crates/ntfs/src/image/mod.rs @@ -0,0 +1,75 @@ +//! Abstractions for reading from disk images (raw, EWF/E01, AFF). + +mod aff; +mod ewf; +mod raw; + +use std::io; +use std::path::Path; + +pub use aff::AffImage; +pub use ewf::EwfImage; +pub use raw::RawImage; + +/// Random-access reading over an image-like source. +/// +/// This is intentionally minimal: higher layers (NTFS, VFS) should not care how bytes are +/// retrieved (raw file, EWF, AFF, etc.). +pub trait ReadAt: Send + Sync { + fn len(&self) -> u64; + fn read_exact_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()>; + + fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +#[derive(Debug)] +pub enum Image { + Raw(RawImage), + Ewf(EwfImage), + Aff(AffImage), +} + +impl Image { + pub fn open(path: impl AsRef) -> io::Result { + let path = path.as_ref(); + let lower = path + .extension() + .and_then(|s| s.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + + match lower.as_str() { + "e01" => Ok(Image::Ewf(EwfImage::open(path)?)), + "aff" => Ok(Image::Aff(AffImage::open(path)?)), + _ => Ok(Image::Raw(RawImage::open(path)?)), + } + } + + pub fn len(&self) -> u64 { + match self { + Image::Raw(x) => x.len(), + Image::Ewf(x) => x.len(), + Image::Aff(x) => x.len(), + } + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +impl ReadAt for Image { + fn len(&self) -> u64 { + self.len() + } + + fn read_exact_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + match self { + Image::Raw(x) => x.read_exact_at(offset, buf), + Image::Ewf(x) => x.read_exact_at(offset, buf), + Image::Aff(x) => x.read_exact_at(offset, buf), + } + } +} diff --git a/crates/ntfs/src/image/raw.rs b/crates/ntfs/src/image/raw.rs new file mode 100644 index 0000000..a831771 --- /dev/null +++ b/crates/ntfs/src/image/raw.rs @@ -0,0 +1,58 @@ +use crate::image::ReadAt; +use std::fs::File; +use std::io; +use std::path::Path; + +#[cfg(unix)] +use std::os::unix::fs::FileExt; +#[cfg(windows)] +use std::os::windows::fs::FileExt; + +#[cfg(unix)] +fn file_read_at(file: &File, buf: &mut [u8], offset: u64) -> io::Result { + file.read_at(buf, offset) +} + +#[cfg(windows)] +fn file_read_at(file: &File, buf: &mut [u8], offset: u64) -> io::Result { + file.seek_read(buf, offset) +} + +#[derive(Debug)] +pub struct RawImage { + file: File, + len: u64, +} + +impl RawImage { + pub fn open(path: impl AsRef) -> io::Result { + let file = File::open(path)?; + let len = file.metadata()?.len(); + Ok(Self { file, len }) + } +} + +impl ReadAt for RawImage { + fn len(&self) -> u64 { + self.len + } + + fn read_exact_at(&self, offset: u64, mut 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 cur_offset = offset; + while !buf.is_empty() { + let read = file_read_at(&self.file, buf, cur_offset)?; + if read == 0 { + return Err(io::Error::from(io::ErrorKind::UnexpectedEof)); + } + cur_offset = cur_offset.saturating_add(read as u64); + let tmp = buf; + buf = &mut tmp[read..]; + } + + Ok(()) + } +} diff --git a/crates/ntfs/src/lib.rs b/crates/ntfs/src/lib.rs new file mode 100644 index 0000000..36bf7e0 --- /dev/null +++ b/crates/ntfs/src/lib.rs @@ -0,0 +1,9 @@ +#![forbid(unsafe_code)] +#![deny(unused_must_use)] +// Don't allow dbg! prints in release. +#![cfg_attr(not(debug_assertions), deny(clippy::dbg_macro))] + +pub mod image; +pub mod ntfs; +pub mod parse; +pub mod tools; diff --git a/crates/ntfs/src/ntfs/compression/lznt1.rs b/crates/ntfs/src/ntfs/compression/lznt1.rs new file mode 100644 index 0000000..2e98864 --- /dev/null +++ b/crates/ntfs/src/ntfs/compression/lznt1.rs @@ -0,0 +1,165 @@ +use crate::ntfs::{Error, Result}; + +/// Decompresses an MS-XCA LZNT1 stream into exactly `expected_len` bytes. +/// +/// NTFS uses LZNT1 for compressed attributes. The stream is chunked into 4KiB blocks, each +/// preceded by a 2-byte header. +pub fn decompress_lznt1_to_len(input: &[u8], expected_len: usize) -> Result> { + let mut out = Vec::with_capacity(expected_len); + let mut pos = 0usize; + + while out.len() < expected_len { + if pos + 2 > input.len() { + // Truncated: pad with zeros (best effort). + out.resize(expected_len, 0); + return Ok(out); + } + + let header = u16::from_le_bytes([input[pos], input[pos + 1]]); + pos += 2; + + let chunk_len = ((header & 0x0FFF) as usize).saturating_add(1); + let is_compressed = (header & 0x8000) != 0; + + if pos + chunk_len > input.len() { + // Truncated chunk: pad. + out.resize(expected_len, 0); + return Ok(out); + } + + let chunk = &input[pos..pos + chunk_len]; + pos += chunk_len; + + if !is_compressed { + // Uncompressed chunk: copy as-is. + let remaining = expected_len - out.len(); + let take = remaining.min(chunk.len()); + out.extend_from_slice(&chunk[..take]); + } else { + // Compressed chunk: decompress to up to 4KiB. + let remaining = expected_len - out.len(); + let max_out = remaining.min(4096); + decompress_lznt1_chunk(chunk, &mut out, max_out)?; + } + } + + Ok(out) +} + +fn decompress_lznt1_chunk(input: &[u8], out: &mut Vec, max_out: usize) -> Result<()> { + let base_len = out.len(); + let mut in_pos = 0usize; + + while out.len() - base_len < max_out { + if in_pos >= input.len() { + break; + } + + let flags = input[in_pos]; + in_pos += 1; + + for bit in 0..8 { + if out.len() - base_len >= max_out { + break; + } + if in_pos >= input.len() { + break; + } + + if (flags & (1 << bit)) == 0 { + // literal + out.push(input[in_pos]); + in_pos += 1; + continue; + } + + // copy token + if in_pos + 2 > input.len() { + return Err(Error::InvalidData { + message: "truncated lznt1 copy token".to_string(), + }); + } + let token = u16::from_le_bytes([input[in_pos], input[in_pos + 1]]); + in_pos += 2; + + let cur = out.len() - base_len; + if cur == 0 { + return Err(Error::InvalidData { + message: "lznt1 copy token at start of chunk".to_string(), + }); + } + + let (offset, length) = decode_lznt1_copy_token(token, cur)?; + if offset == 0 || offset > cur { + return Err(Error::InvalidData { + message: format!("lznt1 invalid offset {offset} at pos {cur}"), + }); + } + + let src_start = out.len().saturating_sub(offset); + for i in 0..length { + if out.len() - base_len >= max_out { + break; + } + let b = out + .get(src_start + i) + .copied() + .ok_or_else(|| Error::InvalidData { + message: "lznt1 copy source out of bounds".to_string(), + })?; + out.push(b); + } + } + } + + Ok(()) +} + +fn decode_lznt1_copy_token(token: u16, cur_out_len: usize) -> Result<(usize, usize)> { + // Dynamic split: number of offset bits grows with output position. + // + // A good mental model is: + // - offset_bits = ceil(log2(cur_out_len)) + // - clamp to [4, 12] + // - offset is stored in the high bits, length in the low bits. + let mut offset_bits = if cur_out_len <= 1 { + 0 + } else { + // ceil(log2(cur_out_len)) == floor(log2(cur_out_len - 1)) + 1 + let mut x = cur_out_len - 1; + let mut bits = 0u16; + while x > 0 { + bits += 1; + x >>= 1; + } + bits + }; + + offset_bits = offset_bits.clamp(4, 12); + + let offset_shift = 16u16.saturating_sub(offset_bits); + let length_mask = (1u16 << offset_shift).saturating_sub(1); + + let length = (token & length_mask) as usize + 3; + let offset = (token >> offset_shift) as usize + 1; + + Ok((offset, length)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decompress_uncompressed_chunk_roundtrip() { + // Build an "uncompressed" chunk header for 4 bytes. + // header: bit15=0, len=(4-1)=3. + let header = 0x0003u16; + let mut input = Vec::new(); + input.extend_from_slice(&header.to_le_bytes()); + input.extend_from_slice(b"test"); + + let out = decompress_lznt1_to_len(&input, 4).unwrap(); + assert_eq!(&out, b"test"); + } +} diff --git a/crates/ntfs/src/ntfs/compression/mod.rs b/crates/ntfs/src/ntfs/compression/mod.rs new file mode 100644 index 0000000..6daed08 --- /dev/null +++ b/crates/ntfs/src/ntfs/compression/mod.rs @@ -0,0 +1 @@ +pub mod lznt1; diff --git a/crates/ntfs/src/ntfs/data_stream.rs b/crates/ntfs/src/ntfs/data_stream.rs new file mode 100644 index 0000000..181a0c6 --- /dev/null +++ b/crates/ntfs/src/ntfs/data_stream.rs @@ -0,0 +1,598 @@ +//! NTFS non-resident `$DATA` stream readers. +//! +//! This module provides random-access [`crate::image::ReadAt`] implementations for non-resident +//! NTFS attributes backed by *data runs* (mapping pairs). +//! +//! Two stream flavors are supported: +//! - [`DataRunsStream`]: uncompressed non-resident streams (standard + sparse runs). +//! - [`CompressedDataRunsStream`]: NTFS-compressed streams (LZNT1), implementing random access by +//! caching decompressed *compression units*. +//! +//! The focus is **correctness** and robust behavior on real-world images: +//! - Sparse runs are surfaced as zero-filled ranges. +//! - Overflows are checked and reported as structured errors. +//! - Reads that extend beyond the described runs fail (rather than silently truncating). +//! +//! Current limitations: +//! - `CompressedDataRunsStream` treats “mixed sparse + standard within a single compression unit” +//! as unsupported (best-effort error), because reconstructing interleaved holes inside a unit is +//! subtle and currently not implemented. +//! - Compression is assumed to be NTFS LZNT1 (as used by NTFS compressed attributes). + +use crate::image::ReadAt; +use crate::ntfs::compression::lznt1::decompress_lznt1_to_len; +use crate::ntfs::{Error, Result, Volume}; +use lru::LruCache; +use mft::attribute::data_run::{DataRun, RunType}; +use std::num::NonZeroUsize; +use std::sync::Mutex; + +#[derive(Debug, Clone)] +struct RunMapping { + /// Starting VCN for this run (in clusters). + vcn_start: u64, + /// The data run covering `[vcn_start, vcn_start + run.lcn_length)`. + run: DataRun, +} + +fn build_run_mappings(data_runs: &[DataRun]) -> Vec { + let mut out = Vec::with_capacity(data_runs.len()); + let mut vcn = 0u64; + for run in data_runs { + out.push(RunMapping { + vcn_start: vcn, + run: *run, + }); + vcn = vcn.saturating_add(run.lcn_length); + } + out +} + +/// A non-resident stream backed by NTFS data runs (uncompressed). +/// +/// This is the simplest view over a non-resident attribute: runs are interpreted as either: +/// - [`RunType::Standard`]: allocated clusters mapped to on-disk LCNs, or +/// - [`RunType::Sparse`]: holes that read as zeros. +#[derive(Debug, Clone)] +pub struct DataRunsStream { + /// Underlying volume for physical reads. + volume: Volume, + /// Mapping pairs describing the stream layout. + data_runs: Vec, + /// Logical stream length in bytes (may be smaller than the total run coverage). + len: u64, +} + +impl DataRunsStream { + /// Creates a new stream over `data_runs` with a logical `len` in bytes. + pub fn new(volume: Volume, data_runs: Vec, len: u64) -> Self { + Self { + volume, + data_runs, + len, + } + } + + /// Returns the logical stream length in bytes. + pub fn len(&self) -> u64 { + self.len + } + + /// Returns `true` if the logical stream length is 0. + pub fn is_empty(&self) -> bool { + self.len == 0 + } + + /// Returns a reference to the underlying [`Volume`]. + pub fn volume(&self) -> &Volume { + &self.volume + } +} + +impl ReadAt for DataRunsStream { + fn len(&self) -> u64 { + self.len + } + + fn read_exact_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> { + // Delegate to volume mapping; convert our Result to io::Result. + match read_from_data_runs(&self.volume, &self.data_runs, offset, buf) { + Ok(()) => Ok(()), + Err(e) => Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + e.to_string(), + )), + } + } +} + +/// A compressed non-resident stream backed by NTFS data runs. +/// +/// This implements random-access by caching decompressed compression units. +/// +/// NTFS compressed attributes are stored in *compression units*: fixed-size groups of clusters. +/// Each unit is either: +/// - Stored uncompressed (all clusters allocated), or +/// - Stored compressed (fewer clusters allocated) and decompressed with LZNT1 into the full unit, +/// - Entirely sparse (no clusters allocated), reading back as zeros. +/// +/// This reader builds a run-to-VCN mapping once, then serves reads by: +/// 1. Locating the unit for the requested offset. +/// 2. Reading the unit’s allocated clusters from disk. +/// 3. Decompressing (when needed) into a fixed-size buffer. +/// 4. Caching the decompressed unit in an LRU keyed by unit index. +#[derive(Debug)] +pub struct CompressedDataRunsStream { + /// Underlying volume for physical reads. + volume: Volume, + /// Data runs annotated with their starting VCN (in clusters). + run_mappings: Vec, + /// Logical stream length in bytes. + len: u64, + /// Cluster size in bytes. + cluster_size: u64, + /// Compression unit size in clusters. + unit_clusters: u64, + /// Compression unit size in bytes (`unit_clusters * cluster_size`). + unit_bytes: u64, + /// LRU cache of decompressed units keyed by `unit_index`. + cache: Mutex>>, +} + +impl CompressedDataRunsStream { + /// Creates a compressed stream backed by `data_runs`. + /// + /// - `len` is the logical stream length in bytes. + /// - `unit_clusters` is the compression-unit size in clusters (as declared in the attribute). + pub fn new( + volume: Volume, + data_runs: Vec, + len: u64, + unit_clusters: u64, + ) -> Result { + let cluster_size = volume.header.cluster_size as u64; + if cluster_size == 0 { + return Err(Error::InvalidData { + message: "cluster size is 0".to_string(), + }); + } + if unit_clusters == 0 { + return Err(Error::InvalidData { + message: "compression unit clusters is 0".to_string(), + }); + } + let unit_bytes = + unit_clusters + .checked_mul(cluster_size) + .ok_or_else(|| Error::InvalidData { + message: "unit size overflow".to_string(), + })?; + + Ok(Self { + volume, + run_mappings: build_run_mappings(&data_runs), + len, + cluster_size, + unit_clusters, + unit_bytes, + cache: Mutex::new(LruCache::new(NonZeroUsize::new(32).expect("32 > 0"))), + }) + } + + pub fn len(&self) -> u64 { + self.len + } + + /// Returns `true` if the logical stream length is 0. + pub fn is_empty(&self) -> bool { + self.len == 0 + } + + fn read_unit(&self, unit_index: u64) -> Result> { + if let Some(hit) = self.cache.lock().expect("poisoned").get(&unit_index) { + return Ok(hit.clone()); + } + + let unit_vcn_start = unit_index.saturating_mul(self.unit_clusters); + let unit_vcn_end = unit_vcn_start.saturating_add(self.unit_clusters); + + // Collect physical segments for this unit. + let mut segments: Vec<(u64, u64)> = Vec::new(); // (lcn, len_clusters) + let mut has_sparse = false; + let mut saw_sparse = false; + + for mapping in &self.run_mappings { + let run_vcn_start = mapping.vcn_start; + let run_vcn_end = run_vcn_start.saturating_add(mapping.run.lcn_length); + + if run_vcn_end <= unit_vcn_start { + continue; + } + if run_vcn_start >= unit_vcn_end { + break; + } + + let overlap_start = run_vcn_start.max(unit_vcn_start); + let overlap_end = run_vcn_end.min(unit_vcn_end); + let overlap_len = overlap_end.saturating_sub(overlap_start); + if overlap_len == 0 { + continue; + } + + match mapping.run.run_type { + RunType::Sparse => { + has_sparse = true; + saw_sparse = true; + } + RunType::Standard => { + if saw_sparse { + // Best effort: mixed sparse/standard inside a compression unit is tricky. + // We'll treat this as unsupported for now. + return Err(Error::InvalidData { + message: "unsupported: standard clusters after sparse within compression unit".to_string(), + }); + } + let lcn = mapping + .run + .lcn_offset + .saturating_add(overlap_start - run_vcn_start); + segments.push((lcn, overlap_len)); + } + } + } + + let allocated_clusters: u64 = segments.iter().map(|(_, len)| *len).sum(); + let mut unit_out = vec![0u8; self.unit_bytes as usize]; + + if allocated_clusters == 0 && has_sparse { + // Entire unit is sparse => zeros. + } else if !has_sparse && allocated_clusters == self.unit_clusters { + // Stored uncompressed: read the full unit. + let mut dst_off = 0usize; + for (lcn, len_clusters) in segments { + let bytes = len_clusters.saturating_mul(self.cluster_size); + let mut tmp = vec![0u8; bytes as usize]; + let off = lcn.saturating_mul(self.cluster_size); + self.volume.read_exact_at(off, &mut tmp)?; + unit_out[dst_off..dst_off + tmp.len()].copy_from_slice(&tmp); + dst_off += tmp.len(); + } + } else { + // Stored compressed: read allocated clusters and decompress. + let comp_bytes = allocated_clusters.saturating_mul(self.cluster_size) as usize; + let mut comp = Vec::with_capacity(comp_bytes); + for (lcn, len_clusters) in segments { + let bytes = len_clusters.saturating_mul(self.cluster_size) as usize; + let mut tmp = vec![0u8; bytes]; + let off = lcn.saturating_mul(self.cluster_size); + self.volume.read_exact_at(off, &mut tmp)?; + comp.extend_from_slice(&tmp); + } + + let decompressed = decompress_lznt1_to_len(&comp, self.unit_bytes as usize)?; + unit_out.copy_from_slice(&decompressed); + } + + self.cache + .lock() + .expect("poisoned") + .put(unit_index, unit_out.clone()); + + Ok(unit_out) + } +} + +impl ReadAt for CompressedDataRunsStream { + fn len(&self) -> u64 { + self.len + } + + fn read_exact_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> { + if offset.saturating_add(buf.len() as u64) > self.len { + return Err(std::io::Error::from(std::io::ErrorKind::UnexpectedEof)); + } + + let mut remaining = buf.len(); + let mut out_pos = 0usize; + let mut cur = offset; + + while remaining > 0 { + let unit_index = cur / self.unit_bytes; + let within = (cur % self.unit_bytes) as usize; + let unit = self + .read_unit(unit_index) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?; + + let take = remaining.min((self.unit_bytes as usize).saturating_sub(within)); + buf[out_pos..out_pos + take].copy_from_slice(&unit[within..within + take]); + + out_pos += take; + remaining -= take; + cur = cur.saturating_add(take as u64); + } + + Ok(()) + } +} + +/// Reads bytes from a runlist into `buf`, starting at `offset` within the logical stream. +/// +/// This is the shared “runlist reader” used by [`DataRunsStream`] and various filesystem readers. +/// +/// Behavior: +/// - [`RunType::Standard`]: data is read from the volume at +/// `lcn_offset * cluster_size + within_run`. +/// - [`RunType::Sparse`]: the corresponding range is filled with zeros. +/// - If `offset + buf.len()` extends beyond the covered runs, this returns an error. +pub fn read_from_data_runs( + volume: &Volume, + data_runs: &[DataRun], + offset: u64, + buf: &mut [u8], +) -> Result<()> { + let cluster_size = volume.header.cluster_size as u64; + if cluster_size == 0 { + return Err(Error::InvalidData { + message: "cluster_size is 0".to_string(), + }); + } + + let mut remaining = buf.len(); + let mut out_pos = 0usize; + let mut cur_off = offset; + + // Stream offset at the start of the current run (in bytes). + let mut stream_pos = 0u64; + + for run in data_runs { + let run_len_bytes = + run.lcn_length + .checked_mul(cluster_size) + .ok_or_else(|| Error::InvalidData { + message: "data run length overflow".to_string(), + })?; + + if cur_off >= stream_pos.saturating_add(run_len_bytes) { + stream_pos = stream_pos.saturating_add(run_len_bytes); + continue; + } + + while remaining > 0 && cur_off < stream_pos.saturating_add(run_len_bytes) { + let within = (cur_off - stream_pos) as usize; + let available = (run_len_bytes as usize).saturating_sub(within); + let take = remaining.min(available); + + match run.run_type { + RunType::Sparse => buf[out_pos..out_pos + take].fill(0), + RunType::Standard => { + let vol_off = run.lcn_offset.saturating_mul(cluster_size) + within as u64; + volume.read_exact_at(vol_off, &mut buf[out_pos..out_pos + take])?; + } + } + + out_pos += take; + remaining -= take; + cur_off = cur_off.saturating_add(take as u64); + } + + if remaining == 0 { + break; + } + + stream_pos = stream_pos.saturating_add(run_len_bytes); + } + + if remaining != 0 { + return Err(Error::InvalidData { + message: "read beyond end of data runs".to_string(), + }); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ntfs::VolumeHeader; + use std::io; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[derive(Debug, Clone)] + struct MemImage { + data: Arc<[u8]>, + reads: Arc, + } + + impl MemImage { + fn new(data: Vec) -> Self { + Self { + data: data.into(), + reads: Arc::new(AtomicUsize::new(0)), + } + } + } + + impl ReadAt for MemImage { + fn len(&self) -> u64 { + self.data.len() as u64 + } + + fn read_exact_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + let end = offset + .checked_add(buf.len() as u64) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "offset overflow"))?; + if end > self.len() { + return Err(io::Error::from(io::ErrorKind::UnexpectedEof)); + } + + let start = usize::try_from(offset) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "offset overflow"))?; + let end = usize::try_from(end) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "end overflow"))?; + + self.reads.fetch_add(1, Ordering::Relaxed); + buf.copy_from_slice(&self.data[start..end]); + Ok(()) + } + } + + fn test_volume(image: Arc, cluster_size: u32) -> Volume { + let header = VolumeHeader { + bytes_per_sector: 0, + sectors_per_cluster: 0, + cluster_size, + total_sectors: 0, + mft_lcn: 0, + mirror_mft_lcn: 0, + mft_entry_size: 0, + index_entry_size: 0, + volume_serial_number: 0, + }; + Volume::new_for_tests(image, 0, header) + } + + #[test] + fn read_from_data_runs_reads_standard_and_fills_sparse_across_runs() { + let cluster_size = 4u32; + + // Physical clusters: + // cluster 0: 1..=4 + // cluster 1: 5..=8 + // cluster 2: 9..=12 + let mut data = vec![0u8; 64]; + data[0..4].copy_from_slice(&[1, 2, 3, 4]); + data[4..8].copy_from_slice(&[5, 6, 7, 8]); + data[8..12].copy_from_slice(&[9, 10, 11, 12]); + + let img = MemImage::new(data); + let volume = test_volume(Arc::new(img), cluster_size); + + let runs = vec![ + DataRun { + lcn_offset: 0, + lcn_length: 2, + run_type: RunType::Standard, + }, + DataRun { + lcn_offset: 0, + lcn_length: 1, + run_type: RunType::Sparse, + }, + DataRun { + lcn_offset: 2, + lcn_length: 1, + run_type: RunType::Standard, + }, + ]; + + let mut buf = vec![0u8; 16]; + read_from_data_runs(&volume, &runs, 0, &mut buf).unwrap(); + assert_eq!(buf, [1, 2, 3, 4, 5, 6, 7, 8, 0, 0, 0, 0, 9, 10, 11, 12]); + + let mut buf2 = vec![0u8; 10]; + read_from_data_runs(&volume, &runs, 6, &mut buf2).unwrap(); + assert_eq!(buf2, [7, 8, 0, 0, 0, 0, 9, 10, 11, 12]); + } + + #[test] + fn read_from_data_runs_errors_on_overread() { + let cluster_size = 4u32; + let img = MemImage::new(vec![0u8; 64]); + let volume = test_volume(Arc::new(img), cluster_size); + + let runs = vec![DataRun { + lcn_offset: 0, + lcn_length: 1, // 4 bytes total + run_type: RunType::Standard, + }]; + + let mut buf = vec![0u8; 2]; + let err = read_from_data_runs(&volume, &runs, 3, &mut buf).unwrap_err(); + match err { + Error::InvalidData { message } => assert_eq!(message, "read beyond end of data runs"), + other => panic!("unexpected error: {other:?}"), + } + } + + #[test] + fn data_runs_stream_converts_errors_to_io() { + let cluster_size = 4u32; + let img = MemImage::new(vec![0u8; 64]); + let volume = test_volume(Arc::new(img), cluster_size); + + let runs = vec![DataRun { + lcn_offset: 0, + lcn_length: 1, + run_type: RunType::Standard, + }]; + + let stream = DataRunsStream::new(volume, runs, 4); + + let mut buf = vec![0u8; 2]; + let err = stream.read_exact_at(3, &mut buf).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + } + + #[test] + fn compressed_data_runs_stream_reads_uncompressed_unit_and_caches() { + let cluster_size = 4u32; + + let mut data = vec![0u8; 64]; + data[0..16].copy_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]); + + let img = MemImage::new(data); + let reads = img.reads.clone(); + let volume = test_volume(Arc::new(img), cluster_size); + + let runs = vec![DataRun { + lcn_offset: 0, + lcn_length: 4, + run_type: RunType::Standard, + }]; + + let stream = CompressedDataRunsStream::new(volume, runs, 16, 4).unwrap(); + + let mut buf = vec![0u8; 16]; + stream.read_exact_at(0, &mut buf).unwrap(); + assert_eq!(buf, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]); + assert_eq!(reads.load(Ordering::Relaxed), 1); + + // Same unit should be served from cache (no additional reads). + let mut buf2 = vec![0u8; 16]; + stream.read_exact_at(0, &mut buf2).unwrap(); + assert_eq!(buf2, buf); + assert_eq!(reads.load(Ordering::Relaxed), 1); + } + + #[test] + fn compressed_data_runs_stream_errors_on_standard_after_sparse_within_unit() { + let cluster_size = 4u32; + let img = MemImage::new(vec![0u8; 64]); + let volume = test_volume(Arc::new(img), cluster_size); + + // One unit is 4 clusters. Make it sparse for first half, then standard => unsupported. + let runs = vec![ + DataRun { + lcn_offset: 0, + lcn_length: 2, + run_type: RunType::Sparse, + }, + DataRun { + lcn_offset: 0, + lcn_length: 2, + run_type: RunType::Standard, + }, + ]; + + let stream = CompressedDataRunsStream::new(volume, runs, 16, 4).unwrap(); + let mut buf = vec![0u8; 16]; + let err = stream.read_exact_at(0, &mut buf).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + assert!( + err.to_string() + .contains("standard clusters after sparse within compression unit") + ); + } +} diff --git a/crates/ntfs/src/ntfs/efs/crypto.rs b/crates/ntfs/src/ntfs/efs/crypto.rs new file mode 100644 index 0000000..9a8bb89 --- /dev/null +++ b/crates/ntfs/src/ntfs/efs/crypto.rs @@ -0,0 +1,630 @@ +//! EFS FEK unwrap + sector decryption. +//! +//! This module implements the cryptographic pieces needed for offline EFS reads: +//! +//! - **RSA unwrap** of the on-disk “Encrypted FEK” blob into the FEK plaintext structure +//! (ref: `external/refs/specs/MS-EFSR.md` §2.2.2.1.5 “Encrypted FEK”; reference behavior: +//! `external/refs/repos/ntfsprogs-plus__ntfsprogs-plus@*/src/deprecated/ntfsdecrypt.c`, +//! function `ntfs_raw_fek_decrypt`). +//! - **Sector decryption** in 512-byte units with Windows’ IV derivation scheme +//! (ref: `ntfsdecrypt.c` function `ntfs_fek_decrypt_sector`). +//! - **DESX key expansion** (MD5 + salts) matching Windows EFS +//! (ref: `ntfsdecrypt.c` function `ntfs_desx_key_expand`). +//! +//! Important byte-order note: the on-disk RSA ciphertext bytes are stored byte-reversed +//! (“little-endian” as a byte array), and the reference implementation reverses them before RSA +//! math (ref: `ntfsdecrypt.c` `ntfs_raw_fek_decrypt` calls `ntfs_buffer_reverse`). +//! +//! ## Current limitations +//! +//! - Only `flags == 0` (RSA-wrapped FEK) is supported. Smartcard AES-wrapped FEKs (flags=1) are +//! not yet supported (ref: `external/refs/specs/MS-EFSR.md` §2.2.2.1.2 “Flags”). + +use crate::ntfs::{Error, Result}; +use crate::parse::Reader; +use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit}; +use des::{Des, TdesEde3}; +use md5::{Digest as _, Md5}; +use openssl::pkey::Private; +use openssl::rsa::{Padding, Rsa}; + +use super::metadata::EfsMetadataV1; +use super::pfx::EfsRsaKeyBag; + +/// Supported EFS FEK algorithms (ALG_ID). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EfsFekAlgorithm { + /// CALG_DESX (`0x6604`) with the Windows EFS key expansion. + Desx, + /// CALG_3DES (`0x6603`) in CBC mode. + Tdes, + /// CALG_AES_256 (`0x6610`) in CBC mode. + Aes256, +} + +/// A parsed and expanded File Encryption Key (FEK) for decrypting file content. +#[derive(Debug, Clone)] +pub enum EfsFek { + /// DESX keying material expanded into: + /// - DES key (8 bytes) + /// - input/output whitening (64-bit each) + Desx { + des_key: [u8; 8], + out_whitening: u64, + in_whitening: u64, + }, + /// 3DES EDE key (24 bytes). + Tdes { key: [u8; 24] }, + /// AES-256 key (32 bytes). + Aes256 { key: [u8; 32] }, +} + +impl EfsFek { + pub fn algorithm(&self) -> EfsFekAlgorithm { + match self { + Self::Desx { .. } => EfsFekAlgorithm::Desx, + Self::Tdes { .. } => EfsFekAlgorithm::Tdes, + Self::Aes256 { .. } => EfsFekAlgorithm::Aes256, + } + } + + /// Decrypt a buffer in-place, interpreting it as a sequence of 512-byte sectors. + /// + /// `start_offset` is the file offset (in bytes) corresponding to `buf[0]` in the stream being + /// decrypted. For normal file reads starting at the beginning, this is `0`. + pub fn decrypt_in_place(&self, buf: &mut [u8], start_offset: u64) -> Result<()> { + if !buf.len().is_multiple_of(512) { + return Err(Error::InvalidData { + message: format!( + "EFS decrypt expects a whole number of 512-byte sectors, got len={}", + buf.len() + ), + }); + } + + for i in 0..(buf.len() / 512) { + let off = start_offset.saturating_add((i as u64).saturating_mul(512)); + let sector = &mut buf[i * 512..(i + 1) * 512]; + self.decrypt_sector_in_place(sector, off)?; + } + + Ok(()) + } + + /// Decrypt a single 512-byte sector in-place. + pub fn decrypt_sector_in_place(&self, sector: &mut [u8], offset: u64) -> Result<()> { + if sector.len() != 512 { + return Err(Error::InvalidData { + message: format!("expected 512-byte sector, got {}", sector.len()), + }); + } + + match self { + EfsFek::Desx { + des_key, + out_whitening, + in_whitening, + } => { + // CBC-like chaining is handled manually (per 8-byte block) and the EFS per-sector IV + // is applied after decryption to the first 8 bytes. + let des = Des::new_from_slice(des_key).map_err(|_e| Error::InvalidData { + message: "invalid DES key length".to_string(), + })?; + + let mut prev_blk: u64 = 0; + for k in (0..512).step_by(8) { + let curr_blk = u64::from_le_bytes(sector[k..k + 8].try_into().unwrap()); + let mut tmp = (curr_blk ^ *out_whitening).to_le_bytes(); + des.encrypt_block((&mut tmp).into()); + let plain = u64::from_le_bytes(tmp) ^ *in_whitening ^ prev_blk; + prev_blk = curr_blk; + sector[k..k + 8].copy_from_slice(&plain.to_le_bytes()); + } + + // Apply the IV (all non-AES algorithms share the same IV scheme). + let iv = 0x1691_1962_9891_ad13_u64.wrapping_add(offset); + let p0 = u64::from_le_bytes(sector[0..8].try_into().unwrap()) ^ iv; + sector[0..8].copy_from_slice(&p0.to_le_bytes()); + } + EfsFek::Tdes { key } => { + let tdes = TdesEde3::new_from_slice(key).map_err(|_e| Error::InvalidData { + message: "invalid 3DES key length".to_string(), + })?; + + let iv = 0x1691_1962_9891_ad13_u64.wrapping_add(offset).to_le_bytes(); + let mut prev = iv; + + for k in (0..512).step_by(8) { + let mut block = [0u8; 8]; + block.copy_from_slice(§or[k..k + 8]); + let cipher_block = block; + + tdes.decrypt_block((&mut block).into()); + for i in 0..8 { + block[i] ^= prev[i]; + } + + sector[k..k + 8].copy_from_slice(&block); + prev = cipher_block; + } + } + EfsFek::Aes256 { key } => { + let aes = aes::Aes256::new_from_slice(key).map_err(|_e| Error::InvalidData { + message: "invalid AES-256 key length".to_string(), + })?; + + // AES uses a 16-byte IV derived from two 64-bit constants plus the sector offset. + let iv0 = 0x5816_657b_e916_1312_u64.wrapping_add(offset).to_le_bytes(); + let iv1 = 0x1989_adbe_4491_8961_u64.wrapping_add(offset).to_le_bytes(); + let mut prev = [0u8; 16]; + prev[0..8].copy_from_slice(&iv0); + prev[8..16].copy_from_slice(&iv1); + + for k in (0..512).step_by(16) { + let mut block = [0u8; 16]; + block.copy_from_slice(§or[k..k + 16]); + let cipher_block = block; + + aes.decrypt_block((&mut block).into()); + for i in 0..16 { + block[i] ^= prev[i]; + } + + sector[k..k + 16].copy_from_slice(&block); + prev = cipher_block; + } + } + } + + Ok(()) + } +} + +/// Helper that unwraps an FEK from `$EFS` metadata and decrypts file sectors. +#[derive(Debug, Clone)] +pub struct EfsFekDecryptor { + fek: EfsFek, +} + +impl EfsFekDecryptor { + /// Unwrap the FEK from the DDF entries in the given `$EFS` metadata. + /// + /// This selects candidate RSA keys **by matching certificate thumbprints** (MS-EFSR + /// §2.2.2.1.4), then attempts to unwrap the FEK for each entry. + /// + /// This is intentionally deterministic: we do not try RSA keys whose certificate thumbprints + /// do not match the DDF thumbprint. + pub fn from_metadata_v1(meta: &EfsMetadataV1, keys: &EfsRsaKeyBag) -> Result { + for entry in &meta.ddf { + if entry.flags != 0 { + // Smartcard AES-wrapped FEKs are not supported yet. + continue; + } + + if let Some(tp) = entry.cert_thumbprint_sha1.as_ref() { + // Try keys that match the thumbprint. + let mut matched_any = false; + for rsa in keys.iter_matching_thumbprint(tp) { + matched_any = true; + if let Some(fek) = try_unwrap_fek_rsa(&entry.encrypted_fek, rsa)? { + return Ok(Self { fek }); + } + } + + // If no key matched this entry's thumbprint, keep trying other DDF entries (other + // users/recovery agents may be present in the list). + if !matched_any { + continue; + } + } else { + // Metadata without thumbprints is unexpected for v1, but keep a best-effort path: + // try all keys. + for rsa in keys.iter() { + if let Some(fek) = try_unwrap_fek_rsa(&entry.encrypted_fek, rsa)? { + return Ok(Self { fek }); + } + } + } + } + + Err(Error::NotFound { + what: format_thumbprint_mismatch(meta, keys), + }) + } + + pub fn fek(&self) -> &EfsFek { + &self.fek + } + + pub fn decrypt_in_place(&self, buf: &mut [u8], start_offset: u64) -> Result<()> { + self.fek.decrypt_in_place(buf, start_offset) + } +} + +fn try_unwrap_fek_rsa(encrypted_fek: &[u8], rsa: &Rsa) -> Result> { + // In on-disk `$EFS` metadata, the RSA ciphertext bytes are stored byte-reversed (little-endian). + // Try the "Windows" direction first, then fall back to the raw order for robustness. + let mut ct_rev = encrypted_fek.to_vec(); + ct_rev.reverse(); + + if let Some(fek) = try_unwrap_fek_rsa_inner(&ct_rev, rsa)? { + return Ok(Some(fek)); + } + if let Some(fek) = try_unwrap_fek_rsa_inner(encrypted_fek, rsa)? { + return Ok(Some(fek)); + } + + Ok(None) +} + +fn try_unwrap_fek_rsa_inner(ciphertext: &[u8], rsa: &Rsa) -> Result> { + // Use raw RSA (no padding) and strip PKCS#1 v1.5 padding ourselves. + // This matches the behavior of common reference implementations. + let mut out = vec![0u8; rsa.size() as usize]; + let n = match rsa.private_decrypt(ciphertext, &mut out, Padding::NONE) { + Ok(n) => n, + Err(_) => return Ok(None), + }; + out.truncate(n); + + if let Some(pt) = strip_pkcs1v15(&out) + && let Some(fek) = parse_fek_plaintext(pt)? + { + return Ok(Some(fek)); + } + + // Fallback: ntfsdecrypt.c-style stripping (less strict than PKCS#1 parsing). + // Ref: `external/refs/repos/ntfsprogs-plus__ntfsprogs-plus@*/src/deprecated/ntfsdecrypt.c`, + // `ntfs_raw_fek_decrypt` uses `strnlen()+1` to strip padding after MPI-to-bytes conversion. + if let Some(pt) = strip_ntfsdecrypt_style(&out) { + return parse_fek_plaintext(pt); + } + + Ok(None) +} + +fn strip_pkcs1v15(buf: &[u8]) -> Option<&[u8]> { + // Accept both: + // - 0x00 0x02 ... 0x00 (full-length RSA block) + // - 0x02 ... 0x00 (leading 0x00 dropped by integer-to-bytes conversion) + if buf.len() < 3 { + return None; + } + + let (start, prefix_ok) = if buf[0] == 0x00 { + (2usize, buf.get(1) == Some(&0x02)) + } else { + (1usize, buf[0] == 0x02) + }; + if !prefix_ok { + return None; + } + + let sep = buf[start..].iter().position(|&b| b == 0x00)? + start; + buf.get(sep + 1..) +} + +fn strip_ntfsdecrypt_style(buf: &[u8]) -> Option<&[u8]> { + // Mimic libgcrypt MPI printing: drop leading zeros, then strip everything up to and including + // the first NUL byte (ref: ntfsdecrypt.c `ntfs_raw_fek_decrypt`). + let first_non_zero = buf.iter().position(|&b| b != 0)?; + let stripped = &buf[first_non_zero..]; + let z = stripped.iter().position(|&b| b == 0)?; + stripped.get(z + 1..) +} + +fn format_thumbprint_mismatch(meta: &EfsMetadataV1, keys: &EfsRsaKeyBag) -> String { + fn hex20(tp: &[u8; 20]) -> String { + let mut s = String::with_capacity(40); + for b in tp { + use std::fmt::Write as _; + let _ = write!(&mut s, "{:02x}", b); + } + s + } + + let mut out = String::new(); + out.push_str( + "no DDF entry could be decrypted with the provided RSA key(s) (thumbprint-first)\n", + ); + + out.push_str("ddf_entries:\n"); + for (i, e) in meta.ddf.iter().enumerate() { + let tp = e + .cert_thumbprint_sha1 + .as_ref() + .map(hex20) + .unwrap_or_else(|| "".to_string()); + out.push_str(&format!( + "- ddf[{i}]: flags={} encrypted_fek_len={} cert_thumbprint_sha1={tp}\n", + e.flags, + e.encrypted_fek.len() + )); + } + + out.push_str("pfx_keys:\n"); + for (i, (rsa, tp)) in keys.iter_with_thumbprints().enumerate() { + let tp = tp + .map(|t| hex20(&t)) + .unwrap_or_else(|| "".to_string()); + out.push_str(&format!( + "- key[{i}]: rsa_size={} cert_thumbprint_sha1={tp}\n", + rsa.size() + )); + } + + out +} + +fn parse_fek_plaintext(pt: &[u8]) -> Result> { + // MS-EFSR 2.2.2.1.5: KeyLength, Entropy, Algorithm, Reserved, Key[..]. + if pt.len() < 16 { + return Ok(None); + } + + let mut r = Reader::new(pt); + let key_len = r.u32_le("efs.fek.key_length")? as usize; + let _entropy = r.u32_le("efs.fek.entropy")?; + let alg = r.u32_le("efs.fek.algorithm")?; + let reserved = r.u32_le("efs.fek.reserved")?; + if reserved != 0 { + return Ok(None); + } + + let key_bytes = r.take("efs.fek.key", key_len)?; + + const CALG_DES: u32 = 0x6601; + const CALG_3DES: u32 = 0x6603; + const CALG_DESX: u32 = 0x6604; + const CALG_AES_256: u32 = 0x6610; + + let fek = match alg { + CALG_DESX => { + if key_len != 16 { + return Ok(None); + } + let on_disk_key: [u8; 16] = key_bytes.try_into().expect("len checked"); + let (des_key, out_whitening, in_whitening) = desx_expand_key(&on_disk_key); + EfsFek::Desx { + des_key, + out_whitening, + in_whitening, + } + } + CALG_3DES => { + if key_len != 24 { + return Ok(None); + } + EfsFek::Tdes { + key: key_bytes.try_into().expect("len checked"), + } + } + CALG_AES_256 => { + if key_len != 32 { + return Ok(None); + } + EfsFek::Aes256 { + key: key_bytes.try_into().expect("len checked"), + } + } + CALG_DES => { + // Explicitly unsupported: weak and uncommon in modern EFS. + return Ok(None); + } + _ => return Ok(None), + }; + + Ok(Some(fek)) +} + +fn desx_expand_key(on_disk_key: &[u8; 16]) -> ([u8; 8], u64, u64) { + // Matches the DESX expansion used by Windows EFS (as documented by reference implementations). + // + // Important: the salts include the trailing NUL byte (12 bytes total). + const SALT1: &[u8; 12] = b"Dan Simon \0"; + const SALT2: &[u8; 12] = b"Scott Field\0"; + + let d1 = md5_concat(on_disk_key, SALT1); + let w0 = u32::from_le_bytes(d1[0..4].try_into().unwrap()); + let w1 = u32::from_le_bytes(d1[4..8].try_into().unwrap()); + let w2 = u32::from_le_bytes(d1[8..12].try_into().unwrap()); + let w3 = u32::from_le_bytes(d1[12..16].try_into().unwrap()); + let des0 = w0 ^ w1; + let des1 = w2 ^ w3; + let mut des_key = [0u8; 8]; + des_key[0..4].copy_from_slice(&des0.to_le_bytes()); + des_key[4..8].copy_from_slice(&des1.to_le_bytes()); + + let d2 = md5_concat(on_disk_key, SALT2); + let out_whitening = u64::from_le_bytes(d2[0..8].try_into().unwrap()); + let in_whitening = u64::from_le_bytes(d2[8..16].try_into().unwrap()); + + (des_key, out_whitening, in_whitening) +} + +fn md5_concat(a: &[u8], b: &[u8]) -> [u8; 16] { + let mut h = Md5::new(); + h.update(a); + h.update(b); + h.finalize().into() +} + +#[cfg(test)] +mod tests { + use super::*; + use openssl::asn1::Asn1Time; + use openssl::hash::MessageDigest; + use openssl::nid::Nid; + use openssl::pkcs12::Pkcs12; + use openssl::pkey::PKey; + use openssl::x509::{X509, X509NameBuilder}; + + fn build_fek_plaintext_aes256(key_byte: u8) -> Vec { + let mut pt = Vec::new(); + pt.extend_from_slice(&(32u32).to_le_bytes()); // key len + pt.extend_from_slice(&(256u32).to_le_bytes()); // entropy (ignored) + pt.extend_from_slice(&(0x6610u32).to_le_bytes()); // CALG_AES_256 + pt.extend_from_slice(&(0u32).to_le_bytes()); // reserved + pt.extend([key_byte; 32]); + pt + } + + fn build_pkcs12_with_rsa(password: &str) -> (Vec, EfsRsaKeyBag, [u8; 20]) { + let rsa = Rsa::generate(1024).expect("RSA keygen"); + let pkey = PKey::from_rsa(rsa).expect("PKey::from_rsa"); + + let mut name = X509NameBuilder::new().expect("X509NameBuilder"); + name.append_entry_by_nid(Nid::COMMONNAME, "ntfs-crypto-test") + .unwrap(); + let name = name.build(); + + let mut builder = X509::builder().expect("X509::builder"); + builder.set_version(2).unwrap(); + builder.set_subject_name(&name).unwrap(); + builder.set_issuer_name(&name).unwrap(); + builder.set_pubkey(&pkey).unwrap(); + builder + .set_not_before(&Asn1Time::days_from_now(0).unwrap()) + .unwrap(); + builder + .set_not_after(&Asn1Time::days_from_now(1).unwrap()) + .unwrap(); + builder.sign(&pkey, MessageDigest::sha256()).unwrap(); + let cert = builder.build(); + + let digest = cert.digest(MessageDigest::sha1()).unwrap(); + let mut tp = [0u8; 20]; + tp.copy_from_slice(&digest); + + let p12 = Pkcs12::builder() + .name("ntfs-crypto-test") + .pkey(&pkey) + .cert(&cert) + .build2(password) + .unwrap(); + let der = p12.to_der().unwrap(); + + let keys = EfsRsaKeyBag::from_pkcs12_der(&der, Some(password)).unwrap(); + (der, keys, tp) + } + + #[test] + fn strip_pkcs1v15_accepts_full_block_prefix() { + let buf = [0x00, 0x02, 0xAA, 0xBB, 0x00, 0x11, 0x22]; + assert_eq!(strip_pkcs1v15(&buf), Some(&buf[5..])); + } + + #[test] + fn strip_pkcs1v15_accepts_missing_leading_zero_prefix() { + let buf = [0x02, 0xAA, 0xBB, 0x00, 0x11, 0x22]; + assert_eq!(strip_pkcs1v15(&buf), Some(&buf[4..])); + } + + #[test] + fn strip_ntfsdecrypt_style_drops_leading_zeros_and_splits_on_nul() { + let buf = [0x00, 0x00, 0x11, 0x22, 0x00, 0xAA, 0xBB]; + assert_eq!(strip_ntfsdecrypt_style(&buf), Some(&buf[5..])); + } + + #[test] + fn parse_fek_plaintext_accepts_aes256() { + let pt = build_fek_plaintext_aes256(0xAB); + let fek = parse_fek_plaintext(&pt).unwrap().unwrap(); + assert_eq!(fek.algorithm(), EfsFekAlgorithm::Aes256); + match fek { + EfsFek::Aes256 { key } => assert!(key.iter().all(|&b| b == 0xAB)), + _ => panic!("expected aes256"), + } + } + + #[test] + fn try_unwrap_fek_rsa_succeeds_for_on_disk_reversed_ciphertext() { + let (_der, keys, tp) = build_pkcs12_with_rsa("password"); + let rsa = keys.iter().next().unwrap(); + + let pt = build_fek_plaintext_aes256(0x42); + let mut ct = vec![0u8; rsa.size() as usize]; + let n = rsa + .public_encrypt(&pt, &mut ct, Padding::PKCS1) + .expect("public_encrypt"); + ct.truncate(n); + assert_eq!(ct.len(), rsa.size() as usize); + + // Simulate on-disk byte-reversed ciphertext. + let mut on_disk = ct.clone(); + on_disk.reverse(); + + let fek = try_unwrap_fek_rsa(&on_disk, rsa).unwrap().unwrap(); + assert_eq!(fek.algorithm(), EfsFekAlgorithm::Aes256); + + // Smoke-check that metadata+thumbprint selection can succeed with this key. + let entry = crate::ntfs::efs::metadata::KeyListEntry { + length: 0, + public_key_info_offset: 0, + encrypted_fek_length: on_disk.len() as u32, + encrypted_fek_offset: 0, + flags: 0, + encrypted_fek: on_disk, + cert_thumbprint_sha1: Some(tp), + owner_hint_sid: None, + cert_container_name: None, + cert_provider_name: None, + cert_display_name: None, + }; + let meta = crate::ntfs::efs::metadata::EfsMetadataV1 { + length: 0, + efs_version: 2, + efs_id: [0u8; 16], + efs_hash: [0u8; 16], + ddf: vec![entry], + drf: Vec::new(), + }; + let dec = EfsFekDecryptor::from_metadata_v1(&meta, &keys).unwrap(); + assert_eq!(dec.fek().algorithm(), EfsFekAlgorithm::Aes256); + } + + #[test] + fn from_metadata_v1_errors_on_thumbprint_mismatch_and_includes_diagnostics() { + let (_der, keys, tp) = build_pkcs12_with_rsa("password"); + let rsa = keys.iter().next().unwrap(); + + let pt = build_fek_plaintext_aes256(0x42); + let mut ct = vec![0u8; rsa.size() as usize]; + let n = rsa.public_encrypt(&pt, &mut ct, Padding::PKCS1).unwrap(); + ct.truncate(n); + let mut on_disk = ct.clone(); + on_disk.reverse(); + + let mut wrong_tp = tp; + wrong_tp[0] ^= 0xff; + + let entry = crate::ntfs::efs::metadata::KeyListEntry { + length: 0, + public_key_info_offset: 0, + encrypted_fek_length: on_disk.len() as u32, + encrypted_fek_offset: 0, + flags: 0, + encrypted_fek: on_disk, + cert_thumbprint_sha1: Some(wrong_tp), + owner_hint_sid: None, + cert_container_name: None, + cert_provider_name: None, + cert_display_name: None, + }; + let meta = crate::ntfs::efs::metadata::EfsMetadataV1 { + length: 0, + efs_version: 2, + efs_id: [0u8; 16], + efs_hash: [0u8; 16], + ddf: vec![entry], + drf: Vec::new(), + }; + + let err = EfsFekDecryptor::from_metadata_v1(&meta, &keys).unwrap_err(); + let s = err.to_string(); + assert!(s.contains("no DDF entry could be decrypted")); + // both thumbprints should appear, hex-encoded. + assert!(s.contains(&hex::encode(wrong_tp))); + assert!(s.contains(&hex::encode(tp))); + } +} diff --git a/crates/ntfs/src/ntfs/efs/metadata.rs b/crates/ntfs/src/ntfs/efs/metadata.rs new file mode 100644 index 0000000..c49f1e3 --- /dev/null +++ b/crates/ntfs/src/ntfs/efs/metadata.rs @@ -0,0 +1,1218 @@ +//! `$EFS` metadata parsing (EFSRPC Metadata v1 on disk). +//! +//! NTFS stores EFS information in an attribute of type `LoggedUtilityStream` (`0x100`) named +//! `"$EFS"` (ref: `external/refs/repos/ntfsprogs-plus__ntfsprogs-plus@*/include/layout.h`, +//! “`$EFS Data Structure`” notes + `EFS_ATTR_HEADER`). +//! +//! The on-disk payload format we parse here is **EFSRPC Metadata Version 1** (ref: +//! `external/refs/specs/MS-EFSR.md` §2.2.2.1). +//! +//! ## What we parse (and why) +//! +//! - **DDF/DRF key list entries** (ref: MS‑EFSR §2.2.2.1.1 + §2.2.2.1.2): needed to locate both the +//! per-file **Encrypted FEK** and the **Public Key Information** block describing which X.509 +//! certificate was used to encrypt it. +//! - **Encrypted FEK bytes** (ref: MS‑EFSR §2.2.2.1.5): passed to the crypto layer for RSA unwrap. +//! - **PublicKeyInfo / CertificateData thumbprint** (ref: MS‑EFSR §2.2.2.1.3 + §2.2.2.1.4): used +//! by higher-level code to deterministically pick the correct private key from a `.pfx` +//! (thumbprint-first, not trial-and-error). This is parsed in a dedicated step of the EFS work. +//! +//! ## Invariants we enforce (anti-“guessing”) +//! +//! - **All offsets are relative to the start of their containing structure** +//! (ref: MS‑EFSR §2.2.2.1.2–§2.2.2.1.4). +//! - Referenced sub-fields must be **in-bounds** and **non-overlapping** +//! (ref: MS‑EFSR §2.2.2.1.2 “Data Fields” constraints). +//! - Where MS‑EFSR specifies it, we also validate the “no unused areas > 8 contiguous bytes” +//! property inside “Data Fields” (ref: MS‑EFSR §2.2.2.1.2 and §2.2.2.1.3). +//! +//! ## Current limitations +//! +//! - This module targets **Metadata Version 1** (EFS header `EFS Version` 1–3) and does not yet +//! implement Metadata Version 2 (ref: MS‑EFSR §2.2.2.2). + +use crate::parse::{ParseError, Reader, Result}; +use std::io; + +/// Parsed form of the on-disk `$EFS` stream (EFSRPC Metadata Version 1). +/// +/// In MS-EFSR terms, this corresponds to "EFSRPC Metadata Version 1" (section 2.2.2.1), which +/// contains a header, followed by the DDF and (optionally) DRF key lists. +#[derive(Debug, Clone)] +pub struct EfsMetadataV1 { + /// Total length in bytes of this metadata, as stored in the header. + /// + /// For well-formed `$EFS` attributes this should match the attribute's byte length. + pub length: u32, + + /// Highest EFS version supported by the implementation that created this metadata. + /// + /// MS-EFSR defines: + /// - `1`: DESX FEK, RSA-only wrapping + /// - `2`: DESX/3DES/AES-256 FEK, RSA-only wrapping + /// - `3`: DESX/3DES/AES-256 FEK, RSA or AES-256 wrapping (smartcard optimization) + pub efs_version: u32, + + /// Per-machine GUID of the computer that created this metadata (16 bytes). + pub efs_id: [u8; 16], + + /// Implementation-defined hash field (often zero on modern Windows). + pub efs_hash: [u8; 16], + + /// Data Decryption Field (DDF) key list. + pub ddf: Vec, + + /// Data Recovery Field (DRF) key list, if present. + pub drf: Vec, +} + +/// A single DDF/DRF key list entry (MS-EFSR 2.2.2.1.2). +#[derive(Debug, Clone)] +pub struct KeyListEntry { + /// Total length in bytes of this entry. + pub length: u32, + + /// Offset from the start of this entry to the Public Key Information field. + pub public_key_info_offset: u32, + + /// Length in bytes of the encrypted FEK blob. + pub encrypted_fek_length: u32, + + /// Offset from the start of this entry to the encrypted FEK blob. + pub encrypted_fek_offset: u32, + + /// Flags describing how the FEK blob is wrapped. + /// + /// MS-EFSR defines: + /// - `0`: RSA-wrapped FEK (most common) + /// - `1`: AES-256-wrapped FEK using a key derived from an RSA smartcard signature + pub flags: u32, + + /// The encrypted FEK blob bytes. + pub encrypted_fek: Vec, + + /// Certificate thumbprint identifying the X.509 certificate used to encrypt this entry's FEK. + /// + /// This is the SHA-1 hash of the DER-encoded certificate (ref: + /// `external/refs/specs/MS-EFSR.md` §2.2.2.1.4 “Certificate Thumbprint”). + /// + /// Note: this is extracted from the entry's PublicKeyInfo / CertificateData fields (ref: + /// MS‑EFSR §2.2.2.1.3 + §2.2.2.1.4). + pub cert_thumbprint_sha1: Option<[u8; 20]>, + + /// Optional SID hint identifying the key owner (ref: MS‑EFSR §2.2.2.1.3 “Owner Hint”). + pub owner_hint_sid: Option>, + + /// Optional certificate container name hint (UTF-16, ref: MS‑EFSR §2.2.2.1.4). + pub cert_container_name: Option, + + /// Optional certificate provider name hint (UTF-16, ref: MS‑EFSR §2.2.2.1.4). + pub cert_provider_name: Option, + + /// Optional display name hint (UTF-16, ref: MS‑EFSR §2.2.2.1.4). + pub cert_display_name: Option, +} + +impl EfsMetadataV1 { + /// Parse an on-disk `$EFS` attribute buffer. + /// + /// `base_offset` is used only for better error messages (it labels offsets in [`ParseError`]). + pub fn parse(buf: &[u8], base_offset: u64) -> Result { + let mut r = Reader::with_base_offset(buf, base_offset); + + let length = r.u32_le("efs.length")?; + let _reserved1 = r.u32_le("efs.reserved1")?; + let efs_version = r.u32_le("efs.efs_version")?; + let _reserved2 = r.u32_le("efs.reserved2")?; + + let efs_id = take_array::<16>(&mut r, "efs.efs_id")?; + let efs_hash = take_array::<16>(&mut r, "efs.efs_hash")?; + let _reserved3 = take_array::<16>(&mut r, "efs.reserved3")?; + + let ddf_offset = r.u32_le("efs.ddf_offset")? as usize; + let drf_offset = r.u32_le("efs.drf_offset")? as usize; + let _reserved4 = r.take("efs.reserved4", 12)?; + + // Validate reported length. + if length as usize != buf.len() { + // Keep this as a hard error: downstream parsing becomes ambiguous. + return Err(crate::parse::ParseError::new( + r.base_offset(), + "efs.length", + format!( + "metadata length mismatch: header={length} actual={}", + buf.len() + ), + "", + Box::new(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "length mismatch", + )), + )); + } + + let ddf = parse_key_list(buf, base_offset, ddf_offset, "efs.ddf")?; + let drf = if drf_offset == 0 { + Vec::new() + } else { + parse_key_list(buf, base_offset, drf_offset, "efs.drf")? + }; + + Ok(Self { + length, + efs_version, + efs_id, + efs_hash, + ddf, + drf, + }) + } +} + +fn parse_key_list( + buf: &[u8], + base_offset: u64, + list_offset: usize, + label: &'static str, +) -> Result> { + let mut top = Reader::with_base_offset(buf, base_offset); + top.seek(label, list_offset)?; + + let count = top.u32_le("efs.key_list.count")? as usize; + let mut entries = Vec::with_capacity(count); + + let mut pos = top.position(); + for _ in 0..count { + top.seek("efs.key_list.entry", pos)?; + + // Peek the entry length (do not advance), then take the entire entry as a slice so that + // intra-entry offsets are bounds-checked relative to the entry. + let len_bytes = top.peek("efs.key_list.entry.length", 4)?; + let entry_len = u32::from_le_bytes(len_bytes.try_into().expect("len=4")) as usize; + + let entry = top.take("efs.key_list.entry.bytes", entry_len)?; + let mut r = Reader::with_base_offset(entry, base_offset.saturating_add(pos as u64)); + + let length = r.u32_le("efs.key_list.entry.length")?; + let public_key_info_offset = r.u32_le("efs.key_list.entry.public_key_info_offset")?; + let encrypted_fek_length = r.u32_le("efs.key_list.entry.encrypted_fek_length")?; + let encrypted_fek_offset = r.u32_le("efs.key_list.entry.encrypted_fek_offset")?; + let flags = r.u32_le("efs.key_list.entry.flags")?; + + // Validate the KeyListEntry “Data Fields” constraints: PublicKeyInfo and EncryptedFEK must + // be non-overlapping, contained in the data fields region, and the region must not have + // gaps > 8 bytes (ref: `external/refs/specs/MS-EFSR.md` §2.2.2.1.2). + { + const DATA_FIELDS_START: usize = 20; + let entry_base_offset = base_offset.saturating_add(pos as u64); + + let pk_off = usize::try_from(public_key_info_offset).map_err(|_| { + ParseError::capture_from_slice( + entry, + entry_base_offset, + 4, + "efs.key_list.entry.public_key_info_offset", + "public_key_info_offset overflow", + Box::new(io::Error::new(io::ErrorKind::InvalidData, "overflow")), + ) + })?; + if pk_off + 4 > entry.len() { + return Err(ParseError::capture_from_slice( + entry, + entry_base_offset, + pk_off, + "efs.public_key_info.length", + "PublicKeyInfo length field out of bounds", + Box::new(io::Error::from(io::ErrorKind::UnexpectedEof)), + )); + } + let pk_len = + u32::from_le_bytes(entry[pk_off..pk_off + 4].try_into().expect("len=4")) as usize; + let pk_end = pk_off.checked_add(pk_len).ok_or_else(|| { + ParseError::capture_from_slice( + entry, + entry_base_offset, + pk_off, + "efs.public_key_info.length", + "PublicKeyInfo length overflow", + Box::new(io::Error::new(io::ErrorKind::InvalidData, "overflow")), + ) + })?; + + let fek_off = usize::try_from(encrypted_fek_offset).map_err(|_| { + ParseError::capture_from_slice( + entry, + entry_base_offset, + 12, + "efs.key_list.entry.encrypted_fek_offset", + "encrypted_fek_offset overflow", + Box::new(io::Error::new(io::ErrorKind::InvalidData, "overflow")), + ) + })?; + let fek_len = usize::try_from(encrypted_fek_length).map_err(|_| { + ParseError::capture_from_slice( + entry, + entry_base_offset, + 8, + "efs.key_list.entry.encrypted_fek_length", + "encrypted_fek_length overflow", + Box::new(io::Error::new(io::ErrorKind::InvalidData, "overflow")), + ) + })?; + let fek_end = fek_off.checked_add(fek_len).ok_or_else(|| { + ParseError::capture_from_slice( + entry, + entry_base_offset, + fek_off, + "efs.key_list.entry.encrypted_fek", + "encrypted_fek length overflow", + Box::new(io::Error::new(io::ErrorKind::InvalidData, "overflow")), + ) + })?; + + let mut ranges = vec![(pk_off, pk_end), (fek_off, fek_end)]; + validate_dense_data_fields( + entry, + entry_base_offset, + "efs.key_list.entry.data_fields", + DATA_FIELDS_START, + entry.len(), + &mut ranges, + )?; + } + + // Extract encrypted FEK bytes. + r.seek( + "efs.key_list.entry.encrypted_fek", + encrypted_fek_offset as usize, + )?; + let encrypted_fek = r + .take( + "efs.key_list.entry.encrypted_fek", + encrypted_fek_length as usize, + )? + .to_vec(); + + // Parse Public Key Information (thumbprint + hints) for deterministic key selection. + let pk_info = parse_entry_public_key_info( + entry, + base_offset.saturating_add(pos as u64), + public_key_info_offset, + )?; + + entries.push(KeyListEntry { + length, + public_key_info_offset, + encrypted_fek_length, + encrypted_fek_offset, + flags, + encrypted_fek, + cert_thumbprint_sha1: Some(pk_info.certificate_data.thumbprint_sha1), + owner_hint_sid: pk_info.owner_hint.map(|s| s.bytes), + cert_container_name: pk_info.certificate_data.container_name, + cert_provider_name: pk_info.certificate_data.provider_name, + cert_display_name: pk_info.certificate_data.display_name, + }); + + pos = top.position(); + } + + Ok(entries) +} + +fn take_array(r: &mut Reader<'_>, field: &'static str) -> Result<[u8; N]> { + let bytes = r.take(field, N)?; + Ok(bytes.try_into().expect("slice length checked")) +} + +/// Parse the PublicKeyInfo block for a key list entry. +/// +/// MS-EFSR requires this to be present and well-formed for v1 metadata entries (ref: +/// `external/refs/specs/MS-EFSR.md` §2.2.2.1.2–§2.2.2.1.4). +fn parse_entry_public_key_info( + entry: &[u8], + entry_base_offset: u64, + public_key_info_offset: u32, +) -> Result { + let pk_off = public_key_info_offset as usize; + if pk_off == 0 { + return Err(ParseError::capture_from_slice( + entry, + entry_base_offset, + 0, + "efs.key_list.entry.public_key_info_offset", + "public_key_info_offset is 0 (Public Key Information missing)", + Box::new(io::Error::new( + io::ErrorKind::InvalidData, + "missing public key info", + )), + )); + } + if pk_off + 4 > entry.len() { + return Err(ParseError::capture_from_slice( + entry, + entry_base_offset, + pk_off, + "efs.public_key_info.length", + "PublicKeyInfo length field out of bounds", + Box::new(io::Error::from(io::ErrorKind::UnexpectedEof)), + )); + } + let pk_len = u32::from_le_bytes(entry[pk_off..pk_off + 4].try_into().expect("len=4")) as usize; + if pk_len == 0 || pk_off + pk_len > entry.len() { + return Err(ParseError::capture_from_slice( + entry, + entry_base_offset, + pk_off, + "efs.public_key_info.length", + format!( + "PublicKeyInfo length out of bounds: pk_len={pk_len} entry_len={}", + entry.len() + ), + Box::new(io::Error::new(io::ErrorKind::InvalidData, "invalid length")), + )); + } + let pk = &entry[pk_off..pk_off + pk_len]; + let info = PublicKeyInfo::parse(pk, entry_base_offset.saturating_add(pk_off as u64))?; + Ok(info) +} + +#[derive(Debug, Clone)] +struct PublicKeyInfo { + owner_hint: Option, + certificate_data: CertificateData, +} + +impl PublicKeyInfo { + fn parse(buf: &[u8], base_offset: u64) -> Result { + const HEADER_LEN: usize = 28; + if buf.len() < HEADER_LEN { + return Err(ParseError::capture_from_slice( + buf, + base_offset, + 0, + "efs.public_key_info", + format!( + "PublicKeyInfo too small: len={} (< {HEADER_LEN})", + buf.len() + ), + Box::new(io::Error::from(io::ErrorKind::UnexpectedEof)), + )); + } + + let mut r = Reader::with_base_offset(buf, base_offset); + let length = r.u32_le("efs.public_key_info.length")? as usize; + let owner_hint_offset = r.u32_le("efs.public_key_info.owner_hint_offset")? as usize; + let info_type = r.u32_le("efs.public_key_info.type")?; + let certificate_data_length = + r.u32_le("efs.public_key_info.certificate_data_length")? as usize; + let certificate_data_offset = + r.u32_le("efs.public_key_info.certificate_data_offset")? as usize; + let reserved = r.take("efs.public_key_info.reserved", 8)?; + + if length != buf.len() { + return Err(ParseError::capture_from_slice( + buf, + base_offset, + 0, + "efs.public_key_info.length", + format!("length mismatch: header={length} actual={}", buf.len()), + Box::new(io::Error::new( + io::ErrorKind::InvalidData, + "length mismatch", + )), + )); + } + + // MS-EFSR shows this field as the constant 0x00000003 for the on-disk certificate form + // (ref: `external/refs/specs/MS-EFSR.md` §2.2.2.1.3). + if info_type != 3 { + return Err(ParseError::capture_from_slice( + buf, + base_offset, + 8, + "efs.public_key_info.type", + format!("unexpected PublicKeyInfo type: {info_type} (expected 3)"), + Box::new(io::Error::new( + io::ErrorKind::InvalidData, + "unexpected type", + )), + )); + } + + if reserved != [0u8; 8] { + return Err(ParseError::capture_from_slice( + buf, + base_offset, + 20, + "efs.public_key_info.reserved", + "reserved field is not zero", + Box::new(io::Error::new( + io::ErrorKind::InvalidData, + "reserved not zero", + )), + )); + } + + // Validate CertificateData location. + if certificate_data_offset < HEADER_LEN { + return Err(ParseError::capture_from_slice( + buf, + base_offset, + 16, + "efs.public_key_info.certificate_data_offset", + format!( + "certificate_data_offset points into header: {certificate_data_offset} (< {HEADER_LEN})" + ), + Box::new(io::Error::new(io::ErrorKind::InvalidData, "bad offset")), + )); + } + let cert_end = certificate_data_offset + .checked_add(certificate_data_length) + .ok_or_else(|| { + ParseError::capture_from_slice( + buf, + base_offset, + 12, + "efs.public_key_info.certificate_data_length", + "certificate_data length overflow", + Box::new(io::Error::new(io::ErrorKind::InvalidData, "overflow")), + ) + })?; + if cert_end > buf.len() { + return Err(ParseError::capture_from_slice( + buf, + base_offset, + 16, + "efs.public_key_info.certificate_data_offset", + "certificate_data out of bounds", + Box::new(io::Error::from(io::ErrorKind::UnexpectedEof)), + )); + } + + // Parse Owner Hint (SID) if present. + let mut ranges: Vec<(usize, usize)> = vec![( + certificate_data_offset, + certificate_data_offset + certificate_data_length, + )]; + let owner_hint = if owner_hint_offset == 0 { + None + } else { + if owner_hint_offset < HEADER_LEN { + return Err(ParseError::capture_from_slice( + buf, + base_offset, + 4, + "efs.public_key_info.owner_hint_offset", + format!( + "owner_hint_offset points into header: {owner_hint_offset} (< {HEADER_LEN})" + ), + Box::new(io::Error::new(io::ErrorKind::InvalidData, "bad offset")), + )); + } + let sid = RpcSid::parse(buf, base_offset, owner_hint_offset)?; + let end = owner_hint_offset + .checked_add(sid.bytes.len()) + .ok_or_else(|| { + ParseError::capture_from_slice( + buf, + base_offset, + owner_hint_offset, + "efs.public_key_info.owner_hint", + "owner hint length overflow", + Box::new(io::Error::new(io::ErrorKind::InvalidData, "overflow")), + ) + })?; + ranges.push((owner_hint_offset, end)); + Some(sid) + }; + + // MS-EFSR requires that the “Data Fields” area is densely packed (no unused area > 8 bytes). + // (ref: `external/refs/specs/MS-EFSR.md` §2.2.2.1.3 “Data Fields” constraints). + validate_dense_data_fields( + buf, + base_offset, + "efs.public_key_info.data_fields", + HEADER_LEN, + buf.len(), + &mut ranges, + )?; + + // Parse CertificateData as its own bounded slice. + let cert_slice = &buf[certificate_data_offset..cert_end]; + let certificate_data = CertificateData::parse( + cert_slice, + base_offset.saturating_add(certificate_data_offset as u64), + )?; + + Ok(Self { + owner_hint, + certificate_data, + }) + } +} + +#[derive(Debug, Clone)] +struct RpcSid { + bytes: Vec, +} + +impl RpcSid { + fn parse(buf: &[u8], base_offset: u64, offset: usize) -> Result { + // Minimal SID structure: revision (u8), sub_authority_count (u8), + // identifier_authority (6 bytes), sub_authorities (4 * count bytes). + // MS-EFSR calls this “RPC SID” / “SID in RPC marshaling format” (ref: MS‑EFSR §2.2.2.1.3). + const MIN_LEN: usize = 8; + if offset + MIN_LEN > buf.len() { + return Err(ParseError::capture_from_slice( + buf, + base_offset, + offset, + "efs.public_key_info.owner_hint", + "owner hint SID out of bounds", + Box::new(io::Error::from(io::ErrorKind::UnexpectedEof)), + )); + } + let count = buf[offset + 1] as usize; + let len = MIN_LEN + count.saturating_mul(4); + if offset + len > buf.len() { + return Err(ParseError::capture_from_slice( + buf, + base_offset, + offset, + "efs.public_key_info.owner_hint", + "owner hint SID truncated", + Box::new(io::Error::from(io::ErrorKind::UnexpectedEof)), + )); + } + Ok(Self { + bytes: buf[offset..offset + len].to_vec(), + }) + } +} + +#[derive(Debug, Clone)] +struct CertificateData { + thumbprint_sha1: [u8; 20], + container_name: Option, + provider_name: Option, + display_name: Option, +} + +impl CertificateData { + fn parse(buf: &[u8], base_offset: u64) -> Result { + const HEADER_LEN: usize = 20; + if buf.len() < HEADER_LEN { + return Err(ParseError::capture_from_slice( + buf, + base_offset, + 0, + "efs.certificate_data", + format!( + "CertificateData too small: len={} (< {HEADER_LEN})", + buf.len() + ), + Box::new(io::Error::from(io::ErrorKind::UnexpectedEof)), + )); + } + + let mut r = Reader::with_base_offset(buf, base_offset); + let thumbprint_offset = r.u32_le("efs.certificate_data.thumbprint_offset")? as usize; + let thumbprint_length = r.u32_le("efs.certificate_data.thumbprint_length")? as usize; + let container_name_offset = + r.u32_le("efs.certificate_data.container_name_offset")? as usize; + let provider_name_offset = r.u32_le("efs.certificate_data.provider_name_offset")? as usize; + let display_name_offset = r.u32_le("efs.certificate_data.display_name_offset")? as usize; + + if thumbprint_offset < HEADER_LEN { + return Err(ParseError::capture_from_slice( + buf, + base_offset, + 0, + "efs.certificate_data.thumbprint_offset", + format!( + "thumbprint_offset points into header: {thumbprint_offset} (< {HEADER_LEN})" + ), + Box::new(io::Error::new(io::ErrorKind::InvalidData, "bad offset")), + )); + } + if thumbprint_length != 20 { + return Err(ParseError::capture_from_slice( + buf, + base_offset, + 4, + "efs.certificate_data.thumbprint_length", + format!("unexpected thumbprint length: {thumbprint_length} (expected 20)"), + Box::new(io::Error::new(io::ErrorKind::InvalidData, "bad length")), + )); + } + if thumbprint_offset + 20 > buf.len() { + return Err(ParseError::capture_from_slice( + buf, + base_offset, + thumbprint_offset, + "efs.certificate_data.thumbprint", + "thumbprint out of bounds", + Box::new(io::Error::from(io::ErrorKind::UnexpectedEof)), + )); + } + + // MS-EFSR requires ProviderName iff ContainerName. + // (ref: `external/refs/specs/MS-EFSR.md` §2.2.2.1.4). + if (container_name_offset == 0) != (provider_name_offset == 0) { + return Err(ParseError::capture_from_slice( + buf, + base_offset, + 8, + "efs.certificate_data.container_name_offset", + "container/provider presence mismatch (must either both be present or both absent)", + Box::new(io::Error::new(io::ErrorKind::InvalidData, "bad offsets")), + )); + } + + let thumbprint_sha1: [u8; 20] = buf[thumbprint_offset..thumbprint_offset + 20] + .try_into() + .expect("len=20"); + + let mut ranges: Vec<(usize, usize)> = vec![(thumbprint_offset, thumbprint_offset + 20)]; + + let container_name = if container_name_offset == 0 { + None + } else { + if container_name_offset < HEADER_LEN { + return Err(ParseError::capture_from_slice( + buf, + base_offset, + 8, + "efs.certificate_data.container_name_offset", + format!( + "container_name_offset points into header: {container_name_offset} (< {HEADER_LEN})" + ), + Box::new(io::Error::new(io::ErrorKind::InvalidData, "bad offset")), + )); + } + let (s, len) = parse_utf16_nul_terminated( + buf, + base_offset, + container_name_offset, + "efs.certificate_data.container_name", + )?; + ranges.push((container_name_offset, container_name_offset + len)); + Some(s) + }; + + let provider_name = if provider_name_offset == 0 { + None + } else { + if provider_name_offset < HEADER_LEN { + return Err(ParseError::capture_from_slice( + buf, + base_offset, + 12, + "efs.certificate_data.provider_name_offset", + format!( + "provider_name_offset points into header: {provider_name_offset} (< {HEADER_LEN})" + ), + Box::new(io::Error::new(io::ErrorKind::InvalidData, "bad offset")), + )); + } + let (s, len) = parse_utf16_nul_terminated( + buf, + base_offset, + provider_name_offset, + "efs.certificate_data.provider_name", + )?; + ranges.push((provider_name_offset, provider_name_offset + len)); + Some(s) + }; + + let display_name = if display_name_offset == 0 { + None + } else { + if display_name_offset < HEADER_LEN { + return Err(ParseError::capture_from_slice( + buf, + base_offset, + 16, + "efs.certificate_data.display_name_offset", + format!( + "display_name_offset points into header: {display_name_offset} (< {HEADER_LEN})" + ), + Box::new(io::Error::new(io::ErrorKind::InvalidData, "bad offset")), + )); + } + let (s, len) = parse_utf16_nul_terminated( + buf, + base_offset, + display_name_offset, + "efs.certificate_data.display_name", + )?; + ranges.push((display_name_offset, display_name_offset + len)); + Some(s) + }; + + validate_dense_data_fields( + buf, + base_offset, + "efs.certificate_data.data_fields", + HEADER_LEN, + buf.len(), + &mut ranges, + )?; + + Ok(Self { + thumbprint_sha1, + container_name, + provider_name, + display_name, + }) + } +} + +fn parse_utf16_nul_terminated( + buf: &[u8], + base_offset: u64, + offset: usize, + field: &'static str, +) -> Result<(String, usize)> { + if offset >= buf.len() { + return Err(ParseError::capture_from_slice( + buf, + base_offset, + offset, + field, + "string offset out of bounds", + Box::new(io::Error::from(io::ErrorKind::UnexpectedEof)), + )); + } + if !offset.is_multiple_of(2) { + return Err(ParseError::capture_from_slice( + buf, + base_offset, + offset, + field, + "UTF-16LE string offset is not 2-byte aligned", + Box::new(io::Error::new( + io::ErrorKind::InvalidData, + "unaligned utf16", + )), + )); + } + + let mut u16s: Vec = Vec::new(); + let mut pos = offset; + loop { + if pos + 2 > buf.len() { + return Err(ParseError::capture_from_slice( + buf, + base_offset, + pos, + field, + "unterminated UTF-16LE string", + Box::new(io::Error::from(io::ErrorKind::UnexpectedEof)), + )); + } + let w = u16::from_le_bytes(buf[pos..pos + 2].try_into().expect("len=2")); + pos += 2; + if w == 0 { + break; + } + u16s.push(w); + } + + let s: String = std::char::decode_utf16(u16s) + .map(|r| r.unwrap_or(char::REPLACEMENT_CHARACTER)) + .collect(); + + Ok((s, pos - offset)) +} + +fn validate_dense_data_fields( + buf: &[u8], + base_offset: u64, + field: &'static str, + data_fields_start: usize, + data_fields_end: usize, + ranges: &mut [(usize, usize)], +) -> Result<()> { + if data_fields_start > data_fields_end || data_fields_end > buf.len() { + return Err(ParseError::capture_from_slice( + buf, + base_offset, + data_fields_start, + field, + "invalid data fields bounds", + Box::new(io::Error::new(io::ErrorKind::InvalidData, "bad bounds")), + )); + } + + // Normalize + sort. + for (s, e) in ranges.iter_mut() { + if *e < *s { + return Err(ParseError::capture_from_slice( + buf, + base_offset, + *s, + field, + "range end before start", + Box::new(io::Error::new(io::ErrorKind::InvalidData, "bad range")), + )); + } + } + ranges.sort_by_key(|(s, _)| *s); + + // Bounds + containment. + for (s, e) in ranges.iter() { + if *s < data_fields_start { + return Err(ParseError::capture_from_slice( + buf, + base_offset, + *s, + field, + format!( + "sub-field starts before Data Fields: start={s} data_fields_start={data_fields_start}" + ), + Box::new(io::Error::new(io::ErrorKind::InvalidData, "bad offset")), + )); + } + if *e > data_fields_end { + return Err(ParseError::capture_from_slice( + buf, + base_offset, + *s, + field, + "sub-field extends past Data Fields end", + Box::new(io::Error::from(io::ErrorKind::UnexpectedEof)), + )); + } + } + + // Non-overlap + “no unused area > 8 bytes” (MS-EFSR invariant for Data Fields). + let mut prev_end = data_fields_start; + for (s, e) in ranges.iter() { + if *s < prev_end { + return Err(ParseError::capture_from_slice( + buf, + base_offset, + *s, + field, + "sub-fields overlap", + Box::new(io::Error::new(io::ErrorKind::InvalidData, "overlap")), + )); + } + let gap = s.saturating_sub(prev_end); + if gap > 8 { + return Err(ParseError::capture_from_slice( + buf, + base_offset, + prev_end, + field, + format!("unused gap too large: gap={gap} (max=8)"), + Box::new(io::Error::new(io::ErrorKind::InvalidData, "gap too large")), + )); + } + prev_end = *e; + } + + let tail_gap = data_fields_end.saturating_sub(prev_end); + if tail_gap > 8 { + return Err(ParseError::capture_from_slice( + buf, + base_offset, + prev_end, + field, + format!("trailing unused gap too large: gap={tail_gap} (max=8)"), + Box::new(io::Error::new(io::ErrorKind::InvalidData, "gap too large")), + )); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn le32(x: u32) -> [u8; 4] { + x.to_le_bytes() + } + + fn utf16le_nul(s: &str) -> Vec { + let mut out = Vec::new(); + for u in s.encode_utf16() { + out.extend_from_slice(&u.to_le_bytes()); + } + out.extend_from_slice(&0u16.to_le_bytes()); + out + } + + fn build_certificate_data( + thumbprint: [u8; 20], + container: Option<&str>, + provider: Option<&str>, + display: Option<&str>, + ) -> Vec { + // CertificateData header is 5 * u32. + let header_len = 20usize; + let mut buf = vec![0u8; header_len]; + + // Place thumbprint immediately after the header (no gaps). + let mut cursor = header_len; + let thumb_off = cursor; + let thumb_len = 20usize; + buf.extend_from_slice(&thumbprint); + cursor += thumb_len; + + let (container_off, container_bytes) = if let Some(s) = container { + let bytes = utf16le_nul(s); + let off = cursor; + buf.extend_from_slice(&bytes); + cursor += bytes.len(); + (off, Some(bytes)) + } else { + (0usize, None) + }; + + let (provider_off, _provider_bytes) = if let Some(s) = provider { + let bytes = utf16le_nul(s); + let off = cursor; + buf.extend_from_slice(&bytes); + cursor += bytes.len(); + (off, Some(bytes)) + } else { + (0usize, None) + }; + + let (display_off, _display_bytes) = if let Some(s) = display { + let bytes = utf16le_nul(s); + let off = cursor; + buf.extend_from_slice(&bytes); + (off, Some(bytes)) + } else { + (0usize, None) + }; + + // Write header fields. + buf[0..4].copy_from_slice(&le32(thumb_off as u32)); + buf[4..8].copy_from_slice(&le32(thumb_len as u32)); + buf[8..12].copy_from_slice(&le32(container_off as u32)); + buf[12..16].copy_from_slice(&le32(provider_off as u32)); + buf[16..20].copy_from_slice(&le32(display_off as u32)); + + // Enforce the MS-EFSR constraint in the builder: if one of container/provider is present, + // the other must be present too (ref: MS-EFSR §2.2.2.1.4). + if (container_bytes.is_some() as u8) != (provider.is_some() as u8) { + panic!("invalid test builder usage: container/provider presence must match"); + } + + buf + } + + fn build_public_key_info(cert_data: &[u8], owner_hint: Option<&[u8]>) -> Vec { + // PublicKeyInfo header is: + // - length (u32) + // - owner_hint_offset (u32) + // - type (u32) = 3 + // - cert_data_len (u32) + // - cert_data_offset (u32) + // - reserved (8 bytes) + let header_len = 28usize; + let mut buf = vec![0u8; header_len]; + + let mut cursor = header_len; + let owner_off = if let Some(bytes) = owner_hint { + let off = cursor; + buf.extend_from_slice(bytes); + cursor += bytes.len(); + off + } else { + 0usize + }; + + let cert_off = cursor; + buf.extend_from_slice(cert_data); + cursor += cert_data.len(); + + let total_len = cursor; + buf[0..4].copy_from_slice(&le32(total_len as u32)); + buf[4..8].copy_from_slice(&le32(owner_off as u32)); + buf[8..12].copy_from_slice(&le32(3)); + buf[12..16].copy_from_slice(&le32(cert_data.len() as u32)); + buf[16..20].copy_from_slice(&le32(cert_off as u32)); + // reserved is already zero + buf + } + + #[test] + fn certificate_data_parses_thumbprint() { + let tp = [0x11u8; 20]; + let buf = build_certificate_data(tp, None, None, None); + let parsed = CertificateData::parse(&buf, 0).unwrap(); + assert_eq!(parsed.thumbprint_sha1, tp); + assert!(parsed.container_name.is_none()); + assert!(parsed.provider_name.is_none()); + } + + #[test] + fn certificate_data_parses_utf16_strings() { + let tp = [0x22u8; 20]; + let buf = build_certificate_data(tp, Some("cont"), Some("prov"), Some("disp")); + let parsed = CertificateData::parse(&buf, 0).unwrap(); + assert_eq!(parsed.thumbprint_sha1, tp); + assert_eq!(parsed.container_name.as_deref(), Some("cont")); + assert_eq!(parsed.provider_name.as_deref(), Some("prov")); + assert_eq!(parsed.display_name.as_deref(), Some("disp")); + } + + #[test] + fn certificate_data_rejects_provider_without_container() { + let tp = [0x33u8; 20]; + + // Build a buffer that violates MS-EFSR: provider present but container absent. + let mut buf = build_certificate_data(tp, None, None, None); + // Force provider offset to a non-zero value (points to thumbprint, but that's fine for a + // negative test). + buf[12..16].copy_from_slice(&le32(20)); + assert!(CertificateData::parse(&buf, 0).is_err()); + } + + #[test] + fn certificate_data_rejects_thumbprint_length_not_20() { + let tp = [0x66u8; 20]; + let mut buf = build_certificate_data(tp, None, None, None); + buf[4..8].copy_from_slice(&le32(19)); + assert!(CertificateData::parse(&buf, 0).is_err()); + } + + #[test] + fn certificate_data_rejects_large_unused_gap_in_data_fields() { + // Header (20) + padding gap (16) + thumbprint (20). + let mut buf = vec![0u8; 20 + 16 + 20]; + let thumb_off = 36u32; + buf[0..4].copy_from_slice(&le32(thumb_off)); + buf[4..8].copy_from_slice(&le32(20)); + // container/provider/display offsets are 0 + // Fill thumbprint bytes. + for b in &mut buf[thumb_off as usize..thumb_off as usize + 20] { + *b = 0x77; + } + assert!(CertificateData::parse(&buf, 0).is_err()); + } + + #[test] + fn public_key_info_parses_certificate_data() { + let tp = [0x44u8; 20]; + let cd = build_certificate_data(tp, None, None, None); + let pk = build_public_key_info(&cd, None); + let parsed = PublicKeyInfo::parse(&pk, 0).unwrap(); + assert_eq!(parsed.certificate_data.thumbprint_sha1, tp); + assert!(parsed.owner_hint.is_none()); + } + + #[test] + fn public_key_info_rejects_large_unused_gap_in_data_fields() { + let tp = [0x88u8; 20]; + let cd = build_certificate_data(tp, None, None, None); + + // PublicKeyInfo header (28) + padding gap (12) + cert data. + let header_len = 28usize; + let gap = 12usize; + let cert_off = header_len + gap; + let total_len = cert_off + cd.len(); + + let mut buf = vec![0u8; total_len]; + buf[0..4].copy_from_slice(&le32(total_len as u32)); + buf[4..8].copy_from_slice(&le32(0)); // no owner hint + buf[8..12].copy_from_slice(&le32(3)); + buf[12..16].copy_from_slice(&le32(cd.len() as u32)); + buf[16..20].copy_from_slice(&le32(cert_off as u32)); + // reserved is 0 + buf[cert_off..cert_off + cd.len()].copy_from_slice(&cd); + + assert!(PublicKeyInfo::parse(&buf, 0).is_err()); + } + + #[test] + fn metadata_key_list_entry_extracts_thumbprint() { + let tp = [0x55u8; 20]; + let cd = build_certificate_data(tp, None, None, None); + let pk = build_public_key_info(&cd, None); + let encrypted_fek = vec![0xAAu8; 128]; + + let entry_header_len = 20usize; + let pk_off = entry_header_len; + let fek_off = pk_off + pk.len(); + let entry_len = fek_off + encrypted_fek.len(); + + let mut entry = Vec::with_capacity(entry_len); + entry.extend_from_slice(&le32(entry_len as u32)); + entry.extend_from_slice(&le32(pk_off as u32)); + entry.extend_from_slice(&le32(encrypted_fek.len() as u32)); + entry.extend_from_slice(&le32(fek_off as u32)); + entry.extend_from_slice(&le32(0)); // flags=0 (RSA) + entry.extend_from_slice(&pk); + entry.extend_from_slice(&encrypted_fek); + + // Build full EFS metadata with a single DDF entry. + let header_len = 84usize; + let ddf_offset = header_len; + + let mut meta = vec![0u8; header_len]; + // Write DDF offset and DRF offset in the header. + meta[64..68].copy_from_slice(&le32(ddf_offset as u32)); + meta[68..72].copy_from_slice(&le32(0)); // no DRF + + // DDF key list structure: count + entries. + meta.extend_from_slice(&le32(1)); + meta.extend_from_slice(&entry); + + // Write length field. + let total_len = meta.len() as u32; + meta[0..4].copy_from_slice(&le32(total_len)); + // Set efs_version=2. + meta[8..12].copy_from_slice(&le32(2)); + + let parsed = EfsMetadataV1::parse(&meta, 0).unwrap(); + assert_eq!(parsed.ddf.len(), 1); + assert_eq!(parsed.ddf[0].encrypted_fek.len(), 128); + assert_eq!(parsed.ddf[0].cert_thumbprint_sha1, Some(tp)); + } + + #[test] + fn metadata_key_list_entry_rejects_large_unused_gap_between_fields() { + let tp = [0x99u8; 20]; + let cd = build_certificate_data(tp, None, None, None); + let pk = build_public_key_info(&cd, None); + let encrypted_fek = vec![0xBBu8; 128]; + + let entry_header_len = 20usize; + let pk_off = entry_header_len; + let gap = 16usize; // > 8, should be rejected + let fek_off = pk_off + pk.len() + gap; + let entry_len = fek_off + encrypted_fek.len(); + + let mut entry = Vec::with_capacity(entry_len); + entry.extend_from_slice(&le32(entry_len as u32)); + entry.extend_from_slice(&le32(pk_off as u32)); + entry.extend_from_slice(&le32(encrypted_fek.len() as u32)); + entry.extend_from_slice(&le32(fek_off as u32)); + entry.extend_from_slice(&le32(0)); // flags=0 (RSA) + entry.extend_from_slice(&pk); + entry.extend_from_slice(&vec![0u8; gap]); + entry.extend_from_slice(&encrypted_fek); + + let header_len = 84usize; + let ddf_offset = header_len; + + let mut meta = vec![0u8; header_len]; + meta[64..68].copy_from_slice(&le32(ddf_offset as u32)); + meta[68..72].copy_from_slice(&le32(0)); // no DRF + meta.extend_from_slice(&le32(1)); + meta.extend_from_slice(&entry); + let total_len = meta.len() as u32; + meta[0..4].copy_from_slice(&le32(total_len)); + meta[8..12].copy_from_slice(&le32(2)); + + assert!(EfsMetadataV1::parse(&meta, 0).is_err()); + } +} diff --git a/crates/ntfs/src/ntfs/efs/mod.rs b/crates/ntfs/src/ntfs/efs/mod.rs new file mode 100644 index 0000000..7d282a4 --- /dev/null +++ b/crates/ntfs/src/ntfs/efs/mod.rs @@ -0,0 +1,28 @@ +//! Encrypting File System (EFS) support. +//! +//! This module implements **offline EFS decryption** for NTFS volumes: +//! +//! - Parse the on-disk `$EFS` metadata stored in the `LoggedUtilityStream` attribute +//! (`$LOGGED_UTILITY_STREAM`, attribute type `0x100`) named `"$EFS"`. +//! - Unwrap the **File Encryption Key (FEK)** from the DDF/DRF entries using an RSA private key +//! (typically supplied as a PKCS#12 / `.pfx` file). +//! - Decrypt file `$DATA` **by 512-byte sectors**, using the FEK and the per-sector IV scheme used +//! by Windows NTFS. +//! +//! ## References +//! +//! - `external/refs/specs/MS-EFSR.md` (MS-EFSR): structures used for the `$EFS` metadata and the +//! "Encrypted FEK" structure. +//! - Reference implementations (vendored for offline reading only): +//! - `external/refs/repos/ntfsprogs-plus__ntfsprogs-plus@*/src/deprecated/ntfsdecrypt.c` +//! - `external/refs/repos/tuxera__ntfs-3g@*/libntfs-3g/efs.c` +//! +//! **Important**: this crate does not compile or link any code from `external/refs/`. + +pub mod crypto; +pub mod metadata; +pub mod pfx; + +pub use crypto::{EfsFek, EfsFekAlgorithm, EfsFekDecryptor}; +pub use metadata::{EfsMetadataV1, KeyListEntry}; +pub use pfx::EfsRsaKeyBag; diff --git a/crates/ntfs/src/ntfs/efs/pfx.rs b/crates/ntfs/src/ntfs/efs/pfx.rs new file mode 100644 index 0000000..6392d22 --- /dev/null +++ b/crates/ntfs/src/ntfs/efs/pfx.rs @@ -0,0 +1,220 @@ +//! PKCS#12 (`.pfx`) handling for EFS private keys. +//! +//! In Windows EFS workflows, user/DRA private keys are commonly exported as PKCS#12 files. We +//! parse the `.pfx` and extract RSA private keys to unwrap the FEK from `$EFS` metadata. +//! +//! The on-disk `$EFS` metadata identifies the intended certificate via a **certificate thumbprint** +//! which is defined as `SHA1(DER(X.509 certificate))` (ref: `external/refs/specs/MS-EFSR.md` +//! §2.2.2.1.4 “Certificate Thumbprint”). Reference tooling extracts the same SHA‑1 fingerprint from +//! the PFX’s embedded certificate when selecting the matching key (ref: +//! `external/refs/repos/ntfsprogs-plus__ntfsprogs-plus@*/src/deprecated/ntfsdecrypt.c`, +//! function `ntfs_pkcs12_extract_rsa_key`). +//! +//! ## Current limitations +//! +//! - We currently extract only RSA keys. + +use crate::ntfs::{Error, Result}; +use openssl::hash::MessageDigest; +use openssl::pkcs12::Pkcs12; +use openssl::pkey::Private; +use openssl::rsa::Rsa; + +/// Collection of RSA private keys loaded from a PKCS#12/PFX. +/// +/// This bag retains (when available) the SHA-1 thumbprint of the X.509 certificate associated +/// with each RSA key. This enables deterministic “thumbprint-first” selection against `$EFS` +/// metadata (ref: `external/refs/specs/MS-EFSR.md` §2.2.2.1.4). +#[derive(Debug, Clone)] +pub struct EfsRsaKeyBag { + keys: Vec, +} + +#[derive(Debug, Clone)] +struct EfsRsaKeyEntry { + rsa: Rsa, + cert_thumbprint_sha1: Option<[u8; 20]>, +} + +impl EfsRsaKeyBag { + /// Load RSA keys from a PKCS#12/PFX blob (`.pfx`). + /// + /// - `password`: pass `None` for a password-less PFX, or `Some("")` for an empty password. + pub fn from_pkcs12_der(pfx: &[u8], password: Option<&str>) -> Result { + let password = password.unwrap_or(""); + + // OpenSSL 3.x disables several legacy algorithms (notably RC2) by default. Real-world PKCS#12 + // files (and our fixture) can still use these. Since we build with the vendored OpenSSL, + // attempt to load the `legacy` provider to enable RC2-based PBE. + let _legacy = openssl::provider::Provider::try_load(None, "legacy", true).map_err(|e| { + Error::InvalidData { + message: format!("failed to load OpenSSL legacy provider: {e}"), + } + })?; + + let parsed = Pkcs12::from_der(pfx) + .and_then(|p12| p12.parse2(password)) + .map_err(|e| Error::InvalidData { + message: format!("failed to parse PKCS#12/PFX: {e}"), + })?; + + let cert_thumbprint_sha1 = if let Some(cert) = parsed.cert.as_ref() { + let digest = cert + .digest(MessageDigest::sha1()) + .map_err(|e| Error::InvalidData { + message: format!("failed to compute certificate SHA-1 thumbprint: {e}"), + })?; + if digest.len() != 20 { + return Err(Error::InvalidData { + message: format!( + "unexpected certificate SHA-1 thumbprint length: {} (expected 20)", + digest.len() + ), + }); + } + let mut out = [0u8; 20]; + out.copy_from_slice(&digest); + Some(out) + } else { + None + }; + + let mut keys = Vec::new(); + if let Some(pkey) = parsed.pkey { + let rsa = pkey.rsa().map_err(|e| Error::InvalidData { + message: format!("PKCS#12 private key is not usable as RSA: {e}"), + })?; + keys.push(EfsRsaKeyEntry { + rsa, + cert_thumbprint_sha1, + }); + } + + if keys.is_empty() { + return Err(Error::NotFound { + what: "no RSA private keys found in PKCS#12/PFX".to_string(), + }); + } + + Ok(Self { keys }) + } + + /// Iterate all RSA keys stored in this bag. + pub fn iter(&self) -> impl Iterator> { + self.keys.iter().map(|k| &k.rsa) + } + + /// Iterate the stored certificate SHA-1 thumbprints (if present) alongside their keys. + pub fn iter_with_thumbprints(&self) -> impl Iterator, Option<[u8; 20]>)> { + self.keys.iter().map(|k| (&k.rsa, k.cert_thumbprint_sha1)) + } + + /// Iterate the stored certificate SHA-1 thumbprints (if present). + pub fn thumbprints(&self) -> impl Iterator> + '_ { + self.keys.iter().map(|k| k.cert_thumbprint_sha1) + } + + /// Iterate RSA keys whose associated certificate thumbprint matches `thumbprint`. + pub fn iter_matching_thumbprint<'a>( + &'a self, + thumbprint: &'a [u8; 20], + ) -> impl Iterator> + 'a { + self.keys.iter().filter_map(move |k| { + if k.cert_thumbprint_sha1.as_ref() == Some(thumbprint) { + Some(&k.rsa) + } else { + None + } + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use openssl::asn1::Asn1Time; + use openssl::nid::Nid; + use openssl::pkey::PKey; + use openssl::x509::{X509, X509Name, X509NameBuilder}; + + fn build_self_signed_rsa_pkcs12(password: &str) -> (Vec, [u8; 20]) { + let rsa = Rsa::generate(1024).expect("RSA keygen"); + let pkey = PKey::from_rsa(rsa).expect("PKey::from_rsa"); + + let mut name = X509NameBuilder::new().expect("X509NameBuilder"); + name.append_entry_by_nid(Nid::COMMONNAME, "ntfs-test") + .expect("append CN"); + let name: X509Name = name.build(); + + let mut builder = X509::builder().expect("X509::builder"); + builder.set_version(2).expect("set_version"); + builder.set_subject_name(&name).expect("set_subject_name"); + builder.set_issuer_name(&name).expect("set_issuer_name"); + builder.set_pubkey(&pkey).expect("set_pubkey"); + builder + .set_not_before(&Asn1Time::days_from_now(0).unwrap()) + .unwrap(); + builder + .set_not_after(&Asn1Time::days_from_now(1).unwrap()) + .unwrap(); + builder + .sign(&pkey, MessageDigest::sha256()) + .expect("sign cert"); + let cert: X509 = builder.build(); + + let digest = cert + .digest(MessageDigest::sha1()) + .expect("cert sha1 digest"); + let mut tp = [0u8; 20]; + tp.copy_from_slice(&digest); + + let p12 = Pkcs12::builder() + .name("ntfs-test") + .pkey(&pkey) + .cert(&cert) + .build2(password) + .expect("build pkcs12"); + (p12.to_der().expect("to_der"), tp) + } + + #[test] + fn parses_pkcs12_and_exposes_cert_thumbprint() { + let (der, expected_tp) = build_self_signed_rsa_pkcs12("password"); + let bag = EfsRsaKeyBag::from_pkcs12_der(&der, Some("password")).unwrap(); + + let tps: Vec> = bag.thumbprints().collect(); + assert_eq!(tps, vec![Some(expected_tp)]); + + // Ensure the key is present and modulus size is as expected for 1024-bit RSA. + let rsa = bag.iter().next().unwrap(); + assert_eq!(rsa.size(), 128); + } + + #[test] + fn iter_matching_thumbprint_filters_keys() { + let (der, expected_tp) = build_self_signed_rsa_pkcs12("password"); + let bag = EfsRsaKeyBag::from_pkcs12_der(&der, Some("password")).unwrap(); + + assert_eq!(bag.iter_matching_thumbprint(&expected_tp).count(), 1); + assert_eq!(bag.iter_matching_thumbprint(&[0u8; 20]).count(), 0); + } + + #[test] + fn parses_pkcs12_even_if_cert_missing_sets_thumbprint_none() { + // Create a PKCS#12 which contains only a private key (no certificate). OpenSSL allows this + // via PKCS12_create with a NULL cert pointer (used internally by Pkcs12Builder::build2). + let rsa = Rsa::generate(1024).expect("RSA keygen"); + let pkey = PKey::from_rsa(rsa).expect("PKey::from_rsa"); + + let p12 = Pkcs12::builder() + .name("ntfs-test") + .pkey(&pkey) + .build2("password") + .expect("build pkcs12"); + let der = p12.to_der().expect("to_der"); + + let bag = EfsRsaKeyBag::from_pkcs12_der(&der, Some("password")).unwrap(); + assert_eq!(bag.iter().count(), 1); + assert_eq!(bag.thumbprints().collect::>(), vec![None]); + } +} diff --git a/crates/ntfs/src/ntfs/error.rs b/crates/ntfs/src/ntfs/error.rs new file mode 100644 index 0000000..7197845 --- /dev/null +++ b/crates/ntfs/src/ntfs/error.rs @@ -0,0 +1,29 @@ +use crate::parse::ParseError; +use std::io; +use thiserror::Error; + +pub type Result = std::result::Result; + +#[derive(Debug, Error)] +pub enum Error { + #[error("I/O error")] + Io(#[from] io::Error), + + #[error("{0}")] + Parse(#[from] ParseError), + + #[error("MFT parser error")] + Mft(#[from] mft::err::Error), + + #[error("Invalid NTFS boot sector: {message}")] + InvalidBootSector { message: &'static str }, + + #[error("Invalid filesystem data: {message}")] + InvalidData { message: String }, + + #[error("Not found: {what}")] + NotFound { what: String }, + + #[error("Unsupported: {what}")] + Unsupported { what: String }, +} diff --git a/crates/ntfs/src/ntfs/filesystem/file_system.rs b/crates/ntfs/src/ntfs/filesystem/file_system.rs new file mode 100644 index 0000000..e6a02ba --- /dev/null +++ b/crates/ntfs/src/ntfs/filesystem/file_system.rs @@ -0,0 +1,1814 @@ +use crate::image::ReadAt; +use crate::ntfs::data_stream::{ + CompressedDataRunsStream, DataRunsStream, read_from_data_runs, +}; +use crate::ntfs::efs::{EfsFekDecryptor, EfsMetadataV1, EfsRsaKeyBag}; +use crate::ntfs::index::{IndexRoot, IndexValueFlags, apply_index_record_fixups}; +use crate::ntfs::name::{ + FileNameKey, UpcaseTable, eq_case_insensitive_ntfs, eq_case_sensitive, +}; +use crate::ntfs::{Error, Result, Volume}; +use md5::{Digest as _, Md5}; +use mft::attribute::AttributeDataFlags; +use mft::attribute::MftAttributeType; +use mft::attribute::header::ResidentialHeader; +use mft::attribute::non_resident_attr::NonResidentAttr; +use mft::attribute::x20::AttributeListAttr; +use std::collections::{HashSet, VecDeque}; +use std::fs::File; +use std::io::Write as _; +use std::path::Path; +use std::sync::Arc; +use std::sync::OnceLock; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct DirectoryEntry { + pub name: String, + pub name_utf16: Vec, + pub entry_id: u64, +} + +#[derive(Debug, Clone)] +pub struct FileSystem { + volume: Volume, + upcase_table: Arc>>, +} + +impl FileSystem { + pub fn new(volume: Volume) -> Self { + Self { + volume, + upcase_table: Arc::new(OnceLock::new()), + } + } + + pub fn volume(&self) -> &Volume { + &self.volume + } + + fn upcase_table(&self) -> Result> { + if let Some(t) = self.upcase_table.get() { + return Ok(Arc::clone(t)); + } + + // `$UpCase` is MFT entry 10 (unnamed `$DATA` stream). + let raw = self.read_file_default_stream(10)?; + let table = Arc::new(UpcaseTable::from_bytes(&raw)?); + + // Handle races: if another thread sets it first, we just use the stored one. + let _ = self.upcase_table.set(Arc::clone(&table)); + Ok(Arc::clone( + self.upcase_table + .get() + .expect("upcase table should be initialized"), + )) + } + + /// Reads the directory entries for the directory at `dir_entry_id`. + /// + /// This uses `$INDEX_ROOT` and, when present, `$INDEX_ALLOCATION` for `$I30`. + pub fn read_dir(&self, dir_entry_id: u64) -> Result> { + let entry = self.volume.read_mft_entry(dir_entry_id)?; + if !entry.is_dir() { + return Err(Error::InvalidData { + message: format!("entry {dir_entry_id} is not a directory"), + }); + } + + let (index_root, has_allocation) = match self.read_i30_index_root(&entry) { + Ok(x) => x, + Err(_) => { + // Some volumes might have damaged/missing index structures; fall back to scanning + // the MFT based on parent references. + return self.read_dir_parent_scan(dir_entry_id); + } + }; + + // Collect values from root. + let mut out: Vec = Vec::new(); + let mut seen: HashSet<(u64, Vec)> = HashSet::new(); + let mut sub_nodes: VecDeque = VecDeque::new(); + + for v in &index_root.node.values { + if let Some(fname) = &v.file_name + && !v.flags.contains(IndexValueFlags::IS_LAST) + { + let entry_id = v.file_reference_raw & 0x0000_FFFF_FFFF_FFFF; + let name_utf16 = fname.name_utf16().to_vec(); + let name = String::from_utf16_lossy(&name_utf16); + if seen.insert((entry_id, name_utf16.clone())) { + out.push(DirectoryEntry { + name, + name_utf16, + entry_id, + }); + } + } + + if v.flags.contains(IndexValueFlags::IS_BRANCH_NODE) + && let Some(vcn) = v.sub_node_vcn + { + sub_nodes.push_back(vcn); + } + } + + if has_allocation { + let data_runs = self.read_i30_index_allocation_runs(&entry)?; + let mut visited_vcns: HashSet = HashSet::new(); + + while let Some(vcn) = sub_nodes.pop_front() { + if !visited_vcns.insert(vcn) { + continue; + } + + let mut record = vec![0u8; self.volume.header.index_entry_size as usize]; + let offset = vcn.saturating_mul(self.volume.header.cluster_size as u64); + read_from_data_runs(&self.volume, &data_runs, offset, &mut record)?; + + // Validate signature. + if record.len() < 4 || &record[0..4] != b"INDX" { + continue; + } + + if let Err(_e) = apply_index_record_fixups(&mut record) { + // Best-effort: skip corrupted/non-conforming index records. + continue; + } + + // The index node header begins after the index record header (24 bytes). + let node_start = 24; + let node = crate::ntfs::index::IndexNode::parse_from_node_start( + &record, + self.volume.volume_offset() + offset, + node_start, + )?; + + for v in &node.values { + if let Some(fname) = &v.file_name + && !v.flags.contains(IndexValueFlags::IS_LAST) + { + let entry_id = v.file_reference_raw & 0x0000_FFFF_FFFF_FFFF; + let name_utf16 = fname.name_utf16().to_vec(); + let name = String::from_utf16_lossy(&name_utf16); + if seen.insert((entry_id, name_utf16.clone())) { + out.push(DirectoryEntry { + name, + name_utf16, + entry_id, + }); + } + } + + if v.flags.contains(IndexValueFlags::IS_BRANCH_NODE) + && let Some(child) = v.sub_node_vcn + { + sub_nodes.push_back(child); + } + } + } + } + + if out.is_empty() { + // Fallback: some volumes have missing/corrupt $I30 nodes even though FILE_NAME parent + // references are intact. + return self.read_dir_parent_scan(dir_entry_id); + } + + Ok(out) + } + + /// Reads the directory entries for the directory at `dir_entry_id` **strictly**. + /// + /// Unlike [`read_dir`], this method does **not** fall back to scanning the MFT based on parent + /// references. This is intended for tooling that prefers a hard failure over a partial or + /// potentially misleading directory listing. + pub fn read_dir_strict(&self, dir_entry_id: u64) -> Result> { + let entry = self.volume.read_mft_entry(dir_entry_id)?; + if !entry.is_dir() { + return Err(Error::InvalidData { + message: format!("entry {dir_entry_id} is not a directory"), + }); + } + + let (index_root, has_allocation) = self.read_i30_index_root(&entry)?; + + // Collect values from root. + let mut out: Vec = Vec::new(); + let mut seen: HashSet<(u64, Vec)> = HashSet::new(); + let mut sub_nodes: VecDeque = VecDeque::new(); + + for v in &index_root.node.values { + if let Some(fname) = &v.file_name + && !v.flags.contains(IndexValueFlags::IS_LAST) + { + let entry_id = v.file_reference_raw & 0x0000_FFFF_FFFF_FFFF; + let name_utf16 = fname.name_utf16().to_vec(); + let name = String::from_utf16_lossy(&name_utf16); + if seen.insert((entry_id, name_utf16.clone())) { + out.push(DirectoryEntry { + name, + name_utf16, + entry_id, + }); + } + } + + if v.flags.contains(IndexValueFlags::IS_BRANCH_NODE) + && let Some(vcn) = v.sub_node_vcn + { + sub_nodes.push_back(vcn); + } + } + + if has_allocation { + let data_runs = self.read_i30_index_allocation_runs(&entry)?; + let mut visited_vcns: HashSet = HashSet::new(); + + while let Some(vcn) = sub_nodes.pop_front() { + if !visited_vcns.insert(vcn) { + continue; + } + + let mut record = vec![0u8; self.volume.header.index_entry_size as usize]; + let offset = vcn.saturating_mul(self.volume.header.cluster_size as u64); + read_from_data_runs(&self.volume, &data_runs, offset, &mut record)?; + + // Referenced nodes should be valid index records. + if record.len() < 4 || &record[0..4] != b"INDX" { + return Err(Error::InvalidData { + message: format!( + "index record signature mismatch at vcn={vcn} offset=0x{:x}", + self.volume.volume_offset() + offset + ), + }); + } + + apply_index_record_fixups(&mut record).map_err(|e| Error::InvalidData { + message: format!( + "index record fixup failed at vcn={vcn} offset=0x{:x}: {e}", + self.volume.volume_offset() + offset + ), + })?; + + // The index node header begins after the index record header (24 bytes). + let node_start = 24; + let node = crate::ntfs::index::IndexNode::parse_from_node_start( + &record, + self.volume.volume_offset() + offset, + node_start, + )?; + + for v in &node.values { + if let Some(fname) = &v.file_name + && !v.flags.contains(IndexValueFlags::IS_LAST) + { + let entry_id = v.file_reference_raw & 0x0000_FFFF_FFFF_FFFF; + let name_utf16 = fname.name_utf16().to_vec(); + let name = String::from_utf16_lossy(&name_utf16); + if seen.insert((entry_id, name_utf16.clone())) { + out.push(DirectoryEntry { + name, + name_utf16, + entry_id, + }); + } + } + + if v.flags.contains(IndexValueFlags::IS_BRANCH_NODE) + && let Some(child) = v.sub_node_vcn + { + sub_nodes.push_back(child); + } + } + } + } + + Ok(out) + } + + /// Reads directory entries and includes entries that are **not present** in `$I30`. + /// + /// This is useful for "undelete" style workflows: deleted files/directories are typically + /// removed from the parent directory index, but their `FILE_NAME` attributes may still exist + /// in their MFT records. This method returns a union of: + /// - `$I30` traversal results (fast, allocated view) + /// - a parent-reference scan of the MFT (slower, includes deleted/unlinked names) + pub fn read_dir_including_deleted(&self, dir_entry_id: u64) -> Result> { + let mut out = self.read_dir(dir_entry_id)?; + let scan = self.read_dir_parent_scan(dir_entry_id)?; + + let mut seen: HashSet<(u64, Vec)> = out + .iter() + .map(|e| (e.entry_id, e.name_utf16.clone())) + .collect(); + for e in scan { + if seen.insert((e.entry_id, e.name_utf16.clone())) { + out.push(e); + } + } + Ok(out) + } + + /// Reads the default `$DATA` stream (unnamed) of a file entry into memory. + pub fn read_file_default_stream(&self, entry_id: u64) -> Result> { + self.read_file_stream(entry_id, "") + } + + /// Opens the USN change journal (`$Extend\\$UsnJrnl:$J`) as a stateful record reader. + /// + /// Returns: + /// - `Ok(None)` if the journal is not present (`$UsnJrnl` entry or `$J` stream absent) + /// - `Ok(Some(_))` if present + /// - `Err(_)` for invalid/unsupported layouts (strict) + pub fn open_usn_change_journal( + &self, + ) -> Result> { + use crate::ntfs::usn::journal::DEFAULT_USN_JOURNAL_BLOCK_SIZE; + + let usn_entry_id = match self.resolve_path_strict("\\$Extend\\$UsnJrnl") { + Ok(id) => id, + Err(Error::NotFound { .. }) => return Ok(None), + Err(e) => return Err(e), + }; + + let entry = self.volume.read_mft_entry(usn_entry_id)?; + + // Prefer the canonical ADS name "$J", but allow "J" (some tools/images omit `$`). + let mut data_extents = self.collect_data_extents(&entry, "$J")?; + if data_extents.is_empty() { + data_extents = self.collect_data_extents(&entry, "J")?; + } + if data_extents.is_empty() { + // `$UsnJrnl` exists but `$J` stream is absent => treat as N/A (matches upstream). + return Ok(None); + } + + if data_extents.iter().any(|e| e.is_compressed) { + return Err(Error::Unsupported { + what: "compressed $UsnJrnl:$J".to_string(), + }); + } + + // Use the logical file size from the first extent (should be identical across extents). + let file_size = data_extents[0].file_size; + + let cluster_size = self.volume.header.cluster_size as u64; + if cluster_size == 0 { + return Err(Error::InvalidData { + message: "cluster_size is 0".to_string(), + }); + } + + let stream: Arc = Arc::new(DataExtentsReadAt { + volume: self.volume.clone(), + cluster_size, + file_size, + extents: data_extents, + }); + + Ok(Some(crate::ntfs::usn::UsnChangeJournal::new( + stream, + DEFAULT_USN_JOURNAL_BLOCK_SIZE, + )?)) + } + + /// Returns `true` if the MFT entry is marked allocated/in-use. + /// + /// This is useful for “show deleted” UX: entries that are not allocated are typically deleted, + /// even if their metadata/streams may still be recoverable. + pub fn is_entry_allocated(&self, entry_id: u64) -> Result { + let entry = self.volume.read_mft_entry(entry_id)?; + Ok(entry + .header + .flags + .contains(mft::entry::EntryFlags::ALLOCATED)) + } + + /// Returns `true` if the entry has the `FILE_ATTRIBUTE_ENCRYPTED` flag (EFS). + pub fn is_entry_efs_encrypted(&self, entry_id: u64) -> Result { + let entry = self.volume.read_mft_entry(entry_id)?; + Ok(is_entry_efs_encrypted(&entry)) + } + + /// Exports the default unnamed `$DATA` stream to `out`, streaming in chunks. + /// + /// This avoids allocating the entire file into memory and supports: + /// - resident and non-resident data + /// - sparse regions (written as zeros) + /// - NTFS-compressed `$DATA` (single-extent only) + pub fn export_file_default_stream_to_path(&self, entry_id: u64, out: &Path) -> Result<()> { + let entry = self.volume.read_mft_entry(entry_id)?; + if entry.is_dir() { + return Err(Error::InvalidData { + message: format!("entry {entry_id} is a directory"), + }); + } + + let mut f = File::create(out)?; + + // If this is an extension record, follow to the base record first (mirrors `collect_data_extents`). + let base_entry_id = if entry.header.base_reference.entry != 0 { + entry.header.base_reference.entry + } else { + entry.header.record_number + }; + let base_entry = if base_entry_id == entry.header.record_number { + entry.clone() + } else { + self.volume.read_mft_entry(base_entry_id)? + }; + + // Resident fast-path. + for attr in base_entry + .iter_attributes_matching(Some(vec![MftAttributeType::DATA])) + .filter_map(std::result::Result::ok) + .filter(|a| a.header.name.is_empty()) + { + if let ResidentialHeader::Resident(rh) = &attr.header.residential_header { + let start = attr.header.start_offset as usize + rh.data_offset as usize; + let end = start + rh.data_size as usize; + let data = base_entry + .data + .get(start..end) + .ok_or_else(|| Error::InvalidData { + message: "resident data out of bounds".to_string(), + })?; + f.write_all(data)?; + return Ok(()); + } + } + + // Non-resident: gather extents (including attribute list) and stream in chunks. + let data_extents = self.collect_data_extents(&entry, "")?; + if data_extents.is_empty() { + return Err(Error::NotFound { + what: "missing $DATA stream ``".to_string(), + }); + } + + let is_compressed = data_extents.iter().any(|e| e.is_compressed); + if is_compressed && data_extents.len() != 1 { + return Err(Error::Unsupported { + what: "compressed $DATA with attribute list (multiple extents)".to_string(), + }); + } + + let file_size = data_extents[0].file_size; + + if is_compressed { + let extent = &data_extents[0]; + let unit_clusters = + extent + .compression_unit_clusters + .ok_or_else(|| Error::InvalidData { + message: "missing compression unit size".to_string(), + })?; + let stream = CompressedDataRunsStream::new( + self.volume.clone(), + extent.data_runs.clone(), + extent.file_size, + unit_clusters, + )?; + + let mut buf = vec![0u8; 1024 * 1024]; + let mut off = 0u64; + while off < file_size { + let n = (file_size - off).min(buf.len() as u64) as usize; + stream + .read_exact_at(off, &mut buf[..n]) + .map_err(Error::Io)?; + f.write_all(&buf[..n])?; + off = off.saturating_add(n as u64); + } + return Ok(()); + } + + // Uncompressed: read across extents, filling gaps with zeros (same overlap logic as md5). + let cluster_size = self.volume.header.cluster_size as u64; + if cluster_size == 0 { + return Err(Error::InvalidData { + message: "cluster_size is 0".to_string(), + }); + } + + let mut buf = vec![0u8; 1024 * 1024]; + let mut off = 0u64; + + while off < file_size { + let n = (file_size - off).min(buf.len() as u64) as usize; + buf[..n].fill(0); + + for extent in &data_extents { + let extent_start = extent.vcn_first.saturating_mul(cluster_size); + let extent_end = + extent_start.saturating_add(extent.vcn_len.saturating_mul(cluster_size)); + + let overlap_start = off.max(extent_start); + let overlap_end = (off + n as u64).min(extent_end); + if overlap_start >= overlap_end { + continue; + } + + let dst_off = (overlap_start - off) as usize; + let src_off = overlap_start.saturating_sub(extent_start); + let overlap_len = (overlap_end - overlap_start) as usize; + + read_from_data_runs( + &self.volume, + &extent.data_runs, + src_off, + &mut buf[dst_off..dst_off + overlap_len], + )?; + } + + f.write_all(&buf[..n])?; + off = off.saturating_add(n as u64); + } + + Ok(()) + } + + /// Reads a `$DATA` stream of a file entry into memory. + /// + /// - Use `stream_name = ""` for the default unnamed stream. + /// - Use a non-empty stream name to read an Alternate Data Stream (ADS), e.g. `stream_name = + /// "Zone.Identifier"`. + pub fn read_file_stream(&self, entry_id: u64, stream_name: &str) -> Result> { + let entry = self.volume.read_mft_entry(entry_id)?; + if entry.is_dir() { + return Err(Error::InvalidData { + message: format!("entry {entry_id} is a directory"), + }); + } + + self.read_data_stream_from_entry(&entry, stream_name) + } + + /// Opens a `$DATA` stream of a file entry as a random-access reader. + /// + /// This is intended for mount-style consumers (FUSE/Dokan) that need to satisfy reads by + /// `(offset, length)` without loading entire files into memory. + /// + /// Notes: + /// - The returned reader is **read-only**. + /// - Compressed streams are supported (LZNT1), but currently only for a single extent. + /// - Alternate Data Streams (ADS) are supported via `stream_name`. + pub fn open_file_stream_read_at( + &self, + entry_id: u64, + stream_name: &str, + ) -> Result> { + let entry = self.volume.read_mft_entry(entry_id)?; + if entry.is_dir() { + return Err(Error::InvalidData { + message: format!("entry {entry_id} is a directory"), + }); + } + self.open_data_stream_from_entry_read_at(&entry, stream_name, None) + } + + /// Opens the default unnamed `$DATA` stream as a random-access reader. + pub fn open_file_default_stream_read_at(&self, entry_id: u64) -> Result> { + self.open_file_stream_read_at(entry_id, "") + } + + /// Opens the default unnamed `$DATA` stream as a random-access reader, returning plaintext if + /// the file is EFS-encrypted. + /// + /// This supports **sector-aligned** EFS decryption (512-byte units), allowing random reads + /// without reading the full file into memory. + /// + /// Current limitation: EFS + NTFS compression is not supported. + pub fn open_file_default_stream_read_at_decrypted( + &self, + entry_id: u64, + efs_keys: &EfsRsaKeyBag, + ) -> Result> { + let entry = self.volume.read_mft_entry(entry_id)?; + if entry.is_dir() { + return Err(Error::InvalidData { + message: format!("entry {entry_id} is a directory"), + }); + } + + if !is_entry_efs_encrypted(&entry) { + return self.open_data_stream_from_entry_read_at(&entry, "", None); + } + + // Detect resident $DATA early: EFS decryption is sector-based and requires reading full + // 512-byte sectors from disk. Resident attributes do not preserve the padded ciphertext + // bytes (if any), so this layout is treated as unsupported (and is extremely uncommon in + // practice). + for attr in entry + .iter_attributes_matching(Some(vec![MftAttributeType::DATA])) + .filter_map(std::result::Result::ok) + .filter(|a| a.header.name.is_empty()) + { + if let ResidentialHeader::Resident(rh) = &attr.header.residential_header { + if rh.data_size == 0 { + return Ok(Arc::new(ResidentReadAt::new(Vec::new()))); + } + return Err(Error::Unsupported { + what: "EFS-encrypted resident $DATA".to_string(), + }); + } + } + + // Parse `$EFS` metadata and unwrap the FEK. + let efs_blob = read_efs_attribute_blob(&self.volume, &entry)?; + let meta = EfsMetadataV1::parse(&efs_blob, 0)?; + let fek = EfsFekDecryptor::from_metadata_v1(&meta, efs_keys)?; + + // Open ciphertext stream, but allow reads up to whole sectors. + let data_extents = self.collect_data_extents(&entry, "")?; + if data_extents.is_empty() { + // No non-resident extents and no resident $DATA above => treat as absent. + return Err(Error::NotFound { + what: "missing $DATA stream ``".to_string(), + }); + } + if data_extents.iter().any(|e| e.is_compressed) { + return Err(Error::Unsupported { + what: "EFS-encrypted + compressed $DATA".to_string(), + }); + } + + let file_size = data_extents[0].file_size; + if file_size == 0 { + return Ok(Arc::new(ResidentReadAt::new(Vec::new()))); + } + + let cipher_len = file_size.div_ceil(512).saturating_mul(512); + let cipher = self.open_data_stream_from_entry_read_at(&entry, "", Some(cipher_len))?; + + Ok(Arc::new(EfsDecryptingReadAt::new(cipher, fek, file_size))) + } + + /// Computes an MD5 over a `$DATA` stream of a file entry, without loading the entire stream + /// into memory. + /// + /// This is intended for tooling (e.g. bodyfile generation) where streams can be very large + /// (`$BadClus:$Bad`), and allocating `Vec` would be impractical. + /// + /// The returned string is lowercase hex, matching SleuthKit bodyfile convention. + pub fn md5_file_stream(&self, entry_id: u64, stream_name: &str) -> Result { + let entry = self.volume.read_mft_entry(entry_id)?; + if entry.is_dir() { + return Err(Error::InvalidData { + message: format!("entry {entry_id} is a directory"), + }); + } + + // Resident fast-path. + for attr in entry.iter_attributes_matching(Some(vec![MftAttributeType::DATA])) { + let attr = attr?; + if attr.header.name != stream_name { + continue; + } + if let ResidentialHeader::Resident(rh) = &attr.header.residential_header { + let start = attr.header.start_offset as usize + rh.data_offset as usize; + let end = start + rh.data_size as usize; + let data = entry + .data + .get(start..end) + .ok_or_else(|| Error::InvalidData { + message: "resident data out of bounds".to_string(), + })?; + return Ok(md5_of_bytes_hex_lower(data)); + } + } + + // Non-resident: gather extents (including attribute list) and stream in chunks. + let data_extents = self.collect_data_extents(&entry, stream_name)?; + if data_extents.is_empty() { + return Err(Error::NotFound { + what: format!("missing $DATA stream `{stream_name}`"), + }); + } + + let is_compressed = data_extents.iter().any(|e| e.is_compressed); + if is_compressed && data_extents.len() != 1 { + return Err(Error::Unsupported { + what: "compressed $DATA with attribute list (multiple extents)".to_string(), + }); + } + + let file_size = data_extents[0].file_size; + + if is_compressed { + let extent = &data_extents[0]; + let unit_clusters = + extent + .compression_unit_clusters + .ok_or_else(|| Error::InvalidData { + message: "missing compression unit size".to_string(), + })?; + let stream = CompressedDataRunsStream::new( + self.volume.clone(), + extent.data_runs.clone(), + extent.file_size, + unit_clusters, + )?; + return md5_readat(&stream, file_size).map_err(Error::Io); + } + + // Uncompressed: read across extents, filling gaps with zeros. + let cluster_size = self.volume.header.cluster_size as u64; + if cluster_size == 0 { + return Err(Error::InvalidData { + message: "cluster_size is 0".to_string(), + }); + } + + let mut h = Md5::new(); + let mut chunk = vec![0u8; 1024 * 1024]; + let mut off = 0u64; + + while off < file_size { + let n = (file_size - off).min(chunk.len() as u64) as usize; + chunk[..n].fill(0); + + for extent in &data_extents { + let extent_start = extent.vcn_first.saturating_mul(cluster_size); + let extent_end = + extent_start.saturating_add(extent.vcn_len.saturating_mul(cluster_size)); + + let overlap_start = off.max(extent_start); + let overlap_end = (off + n as u64).min(extent_end); + if overlap_start >= overlap_end { + continue; + } + + let dst_off = (overlap_start - off) as usize; + let src_off = overlap_start.saturating_sub(extent_start); + let overlap_len = (overlap_end - overlap_start) as usize; + + read_from_data_runs( + &self.volume, + &extent.data_runs, + src_off, + &mut chunk[dst_off..dst_off + overlap_len], + )?; + } + + h.update(&chunk[..n]); + off = off.saturating_add(n as u64); + } + + Ok(hex::encode(h.finalize())) + } + + /// Reads the default `$DATA` stream and returns **plaintext** if the file is EFS-encrypted. + /// + /// - If the file is **not** EFS-encrypted, this behaves like [`read_file_default_stream`]. + /// - If the file **is** EFS-encrypted, this method requires an RSA private key (usually from a + /// `.pfx`) to unwrap the FEK from `$EFS` metadata and decrypt the `$DATA` content. + pub fn read_file_default_stream_decrypted( + &self, + entry_id: u64, + efs_keys: &EfsRsaKeyBag, + ) -> Result> { + let entry = self.volume.read_mft_entry(entry_id)?; + if entry.is_dir() { + return Err(Error::InvalidData { + message: format!("entry {entry_id} is a directory"), + }); + } + + if !is_entry_efs_encrypted(&entry) { + return self.read_data_stream_from_entry(&entry, ""); + } + + // Parse `$EFS` metadata and unwrap the FEK. + let efs_blob = read_efs_attribute_blob(&self.volume, &entry)?; + let meta = EfsMetadataV1::parse(&efs_blob, 0)?; + + let fek = EfsFekDecryptor::from_metadata_v1(&meta, efs_keys)?; + + // Read ciphertext up to a whole number of sectors (512 bytes). + let data_extents = self.collect_data_extents(&entry, "")?; + if data_extents.is_empty() { + return Err(Error::NotFound { + what: "missing $DATA stream ``".to_string(), + }); + } + if data_extents.iter().any(|e| e.is_compressed) { + return Err(Error::Unsupported { + what: "EFS-encrypted + compressed $DATA".to_string(), + }); + } + + let file_size = data_extents[0].file_size; + let cipher_len = file_size.div_ceil(512).saturating_mul(512); + let mut bytes = self.read_nonresident_data_extents_to_len(data_extents, cipher_len)?; + + fek.decrypt_in_place(&mut bytes, 0)?; + bytes.truncate(file_size as usize); + Ok(bytes) + } + + fn read_data_stream_from_entry( + &self, + entry: &mft::MftEntry, + stream_name: &str, + ) -> Result> { + // Fast-path: resident data stream in the base record. + for attr in entry + .iter_attributes_matching(Some(vec![MftAttributeType::DATA])) + .filter_map(std::result::Result::ok) + .filter(|a| a.header.name == stream_name) + { + if let ResidentialHeader::Resident(rh) = &attr.header.residential_header { + let start = attr.header.start_offset as usize + rh.data_offset as usize; + let end = start + rh.data_size as usize; + let data = entry + .data + .get(start..end) + .ok_or_else(|| Error::InvalidData { + message: "resident data out of bounds".to_string(), + })?; + return Ok(data.to_vec()); + } + } + + // Gather DATA attributes (including via attribute list) and read. + let data_extents = self.collect_data_extents(entry, stream_name)?; + if data_extents.is_empty() { + return Err(Error::NotFound { + what: format!("missing $DATA stream `{stream_name}`"), + }); + } + + // Compression is currently only supported for a single extent. + let is_compressed = data_extents.iter().any(|e| e.is_compressed); + if is_compressed && data_extents.len() != 1 { + return Err(Error::Unsupported { + what: "compressed $DATA with attribute list (multiple extents)".to_string(), + }); + } + + let file_size = data_extents[0].file_size; + + if is_compressed { + let mut out = vec![0u8; file_size as usize]; + let extent = &data_extents[0]; + let unit_clusters = + extent + .compression_unit_clusters + .ok_or_else(|| Error::InvalidData { + message: "missing compression unit size".to_string(), + })?; + let stream = CompressedDataRunsStream::new( + self.volume.clone(), + extent.data_runs.clone(), + extent.file_size, + unit_clusters, + )?; + stream.read_exact_at(0, &mut out).map_err(Error::Io)?; + return Ok(out); + } + + self.read_nonresident_data_extents_to_len(data_extents, file_size) + } + + fn open_data_stream_from_entry_read_at( + &self, + entry: &mft::MftEntry, + stream_name: &str, + len_override: Option, + ) -> Result> { + // Resident fast-path: the stream content lives inside the MFT record. + for attr in entry + .iter_attributes_matching(Some(vec![MftAttributeType::DATA])) + .filter_map(std::result::Result::ok) + .filter(|a| a.header.name == stream_name) + { + if let ResidentialHeader::Resident(rh) = &attr.header.residential_header { + let start = attr.header.start_offset as usize + rh.data_offset as usize; + let end = start + rh.data_size as usize; + let data = entry + .data + .get(start..end) + .ok_or_else(|| Error::InvalidData { + message: "resident data out of bounds".to_string(), + })?; + + let bytes = data.to_vec(); + if let Some(want) = len_override + && want != bytes.len() as u64 + { + return Err(Error::InvalidData { + message: format!( + "resident stream length mismatch: len_override={want} resident_len={}", + bytes.len() + ), + }); + } + return Ok(Arc::new(ResidentReadAt::new(bytes))); + } + } + + // Gather extents (including attribute list) and open. + let data_extents = self.collect_data_extents(entry, stream_name)?; + if data_extents.is_empty() { + return Err(Error::NotFound { + what: format!("missing $DATA stream `{stream_name}`"), + }); + } + + // Compression is currently only supported for a single extent. + let is_compressed = data_extents.iter().any(|e| e.is_compressed); + if is_compressed && data_extents.len() != 1 { + return Err(Error::Unsupported { + what: "compressed $DATA with attribute list (multiple extents)".to_string(), + }); + } + + let file_size = data_extents[0].file_size; + let logical_len = len_override.unwrap_or(file_size); + + if is_compressed { + // For compressed streams, the logical length must match the NTFS file size. + if logical_len != file_size { + return Err(Error::InvalidData { + message: format!( + "compressed stream length mismatch: len_override={logical_len} file_size={file_size}" + ), + }); + } + + let extent = &data_extents[0]; + let unit_clusters = + extent + .compression_unit_clusters + .ok_or_else(|| Error::InvalidData { + message: "missing compression unit size".to_string(), + })?; + let stream = CompressedDataRunsStream::new( + self.volume.clone(), + extent.data_runs.clone(), + extent.file_size, + unit_clusters, + )?; + return Ok(Arc::new(stream)); + } + + // Uncompressed: either a single extent (fast), or a multi-extent view. + if data_extents.len() == 1 { + let extent = &data_extents[0]; + return Ok(Arc::new(DataRunsStream::new( + self.volume.clone(), + extent.data_runs.clone(), + logical_len, + ))); + } + + let cluster_size = self.volume.header.cluster_size as u64; + if cluster_size == 0 { + return Err(Error::InvalidData { + message: "cluster_size is 0".to_string(), + }); + } + + Ok(Arc::new(DataExtentsReadAt { + volume: self.volume.clone(), + cluster_size, + file_size: logical_len, + extents: data_extents, + })) + } + + fn read_nonresident_data_extents_to_len( + &self, + data_extents: Vec, + out_len: u64, + ) -> Result> { + if out_len > usize::MAX as u64 { + return Err(Error::Unsupported { + what: format!("requested stream length too large: {out_len}"), + }); + } + + let mut out = vec![0u8; out_len as usize]; + + for extent in data_extents { + let start_byte = extent + .vcn_first + .saturating_mul(self.volume.header.cluster_size as u64); + if start_byte >= out_len { + continue; + } + + let max_len = out_len - start_byte; + let read_len = (extent + .vcn_len + .saturating_mul(self.volume.header.cluster_size as u64)) + .min(max_len); + + let mut tmp = vec![0u8; read_len as usize]; + read_from_data_runs(&self.volume, &extent.data_runs, 0, &mut tmp)?; + out[start_byte as usize..start_byte as usize + tmp.len()].copy_from_slice(&tmp); + } + + Ok(out) + } + + /// Resolves a path (e.g. `\\Raw\\file.txt`) into an MFT entry id. + pub fn resolve_path(&self, path: &str) -> Result { + let mut cur = 5_u64; // root directory + for component in split_path(path) { + let entries = self.read_dir(cur)?; + cur = self.resolve_component_in_dir(cur, &entries, component)?; + } + Ok(cur) + } + + /// Resolves a path (e.g. `\\Raw\\file.txt`) into an MFT entry id **strictly**. + /// + /// This uses [`read_dir_strict`] for each component and does not attempt to recover from + /// missing/corrupted `$I30` structures by scanning the MFT. + pub fn resolve_path_strict(&self, path: &str) -> Result { + let mut cur = 5_u64; // root directory + for component in split_path(path) { + let entries = self.read_dir_strict(cur)?; + cur = self.resolve_component_in_dir(cur, &entries, component)?; + } + Ok(cur) + } + + /// Resolves a single path component under a directory entry (best-effort). + /// + /// This uses [`read_dir`], which may fall back to an MFT parent-reference scan when `$I30` + /// structures are missing/corrupt. + pub fn lookup_in_dir(&self, dir_entry_id: u64, component: &str) -> Result { + let entries = self.read_dir(dir_entry_id)?; + self.resolve_component_in_dir(dir_entry_id, &entries, component) + } + + /// Resolves a single path component under a directory entry (strict). + /// + /// This uses [`read_dir_strict`] and does not fall back to MFT scans. + pub fn lookup_in_dir_strict(&self, dir_entry_id: u64, component: &str) -> Result { + let entries = self.read_dir_strict(dir_entry_id)?; + self.resolve_component_in_dir(dir_entry_id, &entries, component) + } + + /// Resolves a path **including deleted/unlinked directory entries**, into an MFT entry id. + /// + /// This is similar to [`resolve_path`], but it uses [`read_dir_including_deleted`] at each + /// path component. This allows resolving paths that no longer exist in directory indexes (e.g. + /// deleted directories). + pub fn resolve_path_including_deleted(&self, path: &str) -> Result { + let mut cur = 5_u64; // root directory + for component in split_path(path) { + let entries = self.read_dir_including_deleted(cur)?; + cur = self.resolve_component_in_dir(cur, &entries, component)?; + } + Ok(cur) + } + + fn resolve_component_in_dir( + &self, + dir_entry_id: u64, + entries: &[DirectoryEntry], + component: &str, + ) -> Result { + let case_sensitive = self.is_directory_case_sensitive(dir_entry_id)?; + let upcase = if case_sensitive { + None + } else { + Some(self.upcase_table()?) + }; + + resolve_component_in_entries( + dir_entry_id, + entries, + component, + case_sensitive, + upcase.as_deref(), + ) + } + + /// Fallback directory listing based on scanning FILE_NAME parent references in the MFT. + /// + /// This is slower than index traversal but works even when `$I30` structures are missing or + /// zeroed. + fn read_dir_parent_scan(&self, dir_entry_id: u64) -> Result> { + let entry_count = self + .estimate_mft_entry_count() + .unwrap_or(10_000) + .min(200_000); + + let mut out = Vec::new(); + let mut seen: HashSet<(u64, Vec)> = HashSet::new(); + + for i in 0..entry_count { + let entry = match self.volume.read_mft_entry(i) { + Ok(e) => e, + Err(_) => continue, + }; + + let child_id = entry.header.record_number; + for attr in entry + .iter_attributes_matching(Some(vec![MftAttributeType::FileName])) + .filter_map(std::result::Result::ok) + { + let (start, end) = match &attr.header.residential_header { + ResidentialHeader::Resident(rh) => { + let start = attr.header.start_offset as usize + rh.data_offset as usize; + let end = start + rh.data_size as usize; + (start, end) + } + ResidentialHeader::NonResident(_) => { + return Err(Error::Unsupported { + what: "non-resident $FILE_NAME attribute".to_string(), + }); + } + }; + + let raw = entry + .data + .get(start..end) + .ok_or_else(|| Error::InvalidData { + message: "FILE_NAME resident content out of bounds".to_string(), + })?; + + // Fast parent check using the first 8 bytes. Only fully parse names for this directory. + if raw.len() < 8 { + return Err(Error::InvalidData { + message: "FILE_NAME attribute too small for parent reference".to_string(), + }); + } + let parent_raw = u64::from_le_bytes(raw[0..8].try_into().expect("len=8")); + let parent_entry_id = parent_raw & 0x0000_FFFF_FFFF_FFFF; + if parent_entry_id != dir_entry_id { + continue; + } + + let key = FileNameKey::parse(raw, 0)?; + let name_utf16 = key.name_utf16().to_vec(); + let name = String::from_utf16_lossy(&name_utf16); + + if seen.insert((child_id, name_utf16.clone())) { + out.push(DirectoryEntry { + name, + name_utf16, + entry_id: child_id, + }); + } + } + } + + Ok(out) + } + + fn is_directory_case_sensitive(&self, dir_entry_id: u64) -> Result { + let entry = self.volume.read_mft_entry(dir_entry_id)?; + if !entry.is_dir() { + return Err(Error::InvalidData { + message: format!("entry {dir_entry_id} is not a directory"), + }); + } + + let attr = entry + .iter_attributes_matching(Some(vec![MftAttributeType::StandardInformation])) + .next() + .ok_or_else(|| Error::NotFound { + what: format!("missing $STANDARD_INFORMATION in entry {dir_entry_id}"), + })??; + + let Some(si) = attr.data.into_standard_info() else { + return Err(Error::InvalidData { + message: format!( + "$STANDARD_INFORMATION attribute had unexpected content in entry {dir_entry_id}" + ), + }); + }; + + // Mirror upstream behavior: + // case-sensitive iff (maximum_number_of_versions == 0 && version_number == 1) + Ok(standard_info_indicates_case_sensitive(&si)) + } + + fn estimate_mft_entry_count(&self) -> Option { + let entry0 = self.volume.read_mft_entry(0).ok()?; + for attr in entry0 + .iter_attributes_matching(Some(vec![MftAttributeType::DATA])) + .filter_map(std::result::Result::ok) + .filter(|a| a.header.name.is_empty()) + { + if let ResidentialHeader::NonResident(nr) = &attr.header.residential_header { + let bytes = nr.file_size; + if self.volume.header.mft_entry_size == 0 { + return None; + } + return Some(bytes / self.volume.header.mft_entry_size as u64); + } + } + None + } + + fn read_i30_index_root(&self, entry: &mft::MftEntry) -> Result<(IndexRoot, bool)> { + let mut attrs = entry + .iter_attributes_matching(Some(vec![MftAttributeType::IndexRoot])) + .filter_map(std::result::Result::ok) + .filter(|a| a.header.name == "$I30") + .collect::>(); + + let attr = attrs.pop().ok_or_else(|| Error::NotFound { + what: "missing $I30 $INDEX_ROOT".to_string(), + })?; + + let (data_offset, data_size) = match &attr.header.residential_header { + ResidentialHeader::Resident(h) => (h.data_offset as usize, h.data_size as usize), + ResidentialHeader::NonResident(_) => { + return Err(Error::InvalidData { + message: "$INDEX_ROOT cannot be non-resident".to_string(), + }); + } + }; + + let start = attr.header.start_offset as usize + data_offset; + let end = start + data_size; + let buf = entry + .data + .get(start..end) + .ok_or_else(|| Error::InvalidData { + message: "index root content out of bounds".to_string(), + })?; + + let root = IndexRoot::parse(buf, self.volume.volume_offset() + start as u64)?; + let has_alloc = root + .node + .header + .flags + .contains(crate::ntfs::index::IndexNodeFlags::HAS_ALLOCATION_ATTRIBUTE); + Ok((root, has_alloc)) + } + + fn read_i30_index_allocation_runs( + &self, + entry: &mft::MftEntry, + ) -> Result> { + let mut attrs = entry + .iter_attributes_matching(Some(vec![MftAttributeType::IndexAllocation])) + .filter_map(std::result::Result::ok) + .filter(|a| a.header.name == "$I30") + .collect::>(); + + let attr = attrs.pop().ok_or_else(|| Error::NotFound { + what: "missing $I30 $INDEX_ALLOCATION".to_string(), + })?; + + let NonResidentAttr { data_runs } = match attr.data.into_data_runs() { + Some(dr) => dr, + None => { + return Err(Error::InvalidData { + message: "expected non-resident data runs for $INDEX_ALLOCATION".to_string(), + }); + } + }; + + Ok(data_runs) + } + + fn collect_data_extents( + &self, + entry: &mft::MftEntry, + stream_name: &str, + ) -> Result> { + // If this is an extension record, follow to the base record first. + let base_entry_id = if entry.header.base_reference.entry != 0 { + entry.header.base_reference.entry + } else { + entry.header.record_number + }; + + let base_entry = if base_entry_id == entry.header.record_number { + entry.clone() + } else { + self.volume.read_mft_entry(base_entry_id)? + }; + + let mut extents = Vec::new(); + + // Direct DATA attributes in base record. + for attr in base_entry + .iter_attributes_matching(Some(vec![MftAttributeType::DATA])) + .filter_map(std::result::Result::ok) + .filter(|a| a.header.name == stream_name) + { + if let ResidentialHeader::NonResident(nr) = &attr.header.residential_header { + let runs = attr + .data + .clone() + .into_data_runs() + .ok_or_else(|| Error::InvalidData { + message: "expected data runs".to_string(), + })? + .data_runs; + + extents.push(DataExtent::from_non_resident_attr(&attr, nr, runs)?); + } + } + + // Attribute list extents. + if let Some(attr_list) = find_attribute_list(&base_entry)? { + for al in attr_list.entries { + if al.attribute_type != MftAttributeType::DATA as u32 { + continue; + } + if al.name != stream_name { + continue; + } + + let seg_id = al.segment_reference.entry; + let seg = self.volume.read_mft_entry(seg_id)?; + + // Try to match by instance id. + let instance = al.reserved; + let mut found = None; + for attr in seg + .iter_attributes_matching(Some(vec![MftAttributeType::DATA])) + .filter_map(std::result::Result::ok) + .filter(|a| a.header.name == stream_name) + { + if attr.header.instance == instance { + found = Some(attr); + break; + } + } + let attr = found.ok_or_else(|| Error::NotFound { + what: format!( + "attribute list references missing DATA extent in entry {seg_id}" + ), + })?; + + let nr = match &attr.header.residential_header { + ResidentialHeader::NonResident(nr) => nr, + _ => { + return Err(Error::InvalidData { + message: "attribute list referenced non non-resident extent" + .to_string(), + }); + } + }; + let runs = attr + .data + .clone() + .into_data_runs() + .ok_or_else(|| Error::InvalidData { + message: "expected data runs".to_string(), + })? + .data_runs; + + let mut extent = DataExtent::from_non_resident_attr(&attr, nr, runs)?; + extent.vcn_first = al.lowest_vcn; + extents.push(extent); + } + } + + // Sort by VCN. + extents.sort_by_key(|e| e.vcn_first); + Ok(extents) + } +} + +fn is_entry_efs_encrypted(entry: &mft::MftEntry) -> bool { + for attr in entry + .iter_attributes_matching(Some(vec![MftAttributeType::StandardInformation])) + .filter_map(std::result::Result::ok) + { + if let Some(si) = attr.data.into_standard_info() + && si + .file_flags + .contains(mft::attribute::FileAttributeFlags::FILE_ATTRIBUTE_ENCRYPTED) + { + return true; + } + } + false +} + +fn read_efs_attribute_blob(volume: &Volume, entry: &mft::MftEntry) -> Result> { + let attr = entry + .iter_attributes_matching(Some(vec![MftAttributeType::LoggedUtilityStream])) + .filter_map(std::result::Result::ok) + .find(|a| a.header.name == "$EFS") + .ok_or_else(|| Error::NotFound { + what: "missing $EFS logged utility stream".to_string(), + })?; + + match &attr.header.residential_header { + ResidentialHeader::Resident(rh) => { + let start = attr.header.start_offset as usize + rh.data_offset as usize; + let end = start + rh.data_size as usize; + let data = entry + .data + .get(start..end) + .ok_or_else(|| Error::InvalidData { + message: "$EFS resident data out of bounds".to_string(), + })?; + Ok(data.to_vec()) + } + ResidentialHeader::NonResident(nr) => { + if nr.file_size > usize::MAX as u64 { + return Err(Error::Unsupported { + what: format!("$EFS attribute too large: {}", nr.file_size), + }); + } + let runs = attr + .data + .clone() + .into_data_runs() + .ok_or_else(|| Error::InvalidData { + message: "expected data runs for $EFS".to_string(), + })? + .data_runs; + let mut buf = vec![0u8; nr.file_size as usize]; + read_from_data_runs(volume, &runs, 0, &mut buf)?; + Ok(buf) + } + } +} + +fn md5_of_bytes_hex_lower(data: &[u8]) -> String { + let mut h = Md5::new(); + h.update(data); + hex::encode(h.finalize()) +} + +fn md5_readat(src: &impl ReadAt, len: u64) -> std::io::Result { + let mut h = Md5::new(); + let mut buf = vec![0u8; 1024 * 1024]; + let mut off = 0u64; + + while off < len { + let n = (len - off).min(buf.len() as u64) as usize; + src.read_exact_at(off, &mut buf[..n])?; + h.update(&buf[..n]); + off = off.saturating_add(n as u64); + } + + Ok(hex::encode(h.finalize())) +} + +fn standard_info_indicates_case_sensitive(si: &mft::attribute::x10::StandardInfoAttr) -> bool { + si.max_version == 0 && si.version == 1 +} + +fn resolve_component_in_entries( + dir_entry_id: u64, + entries: &[DirectoryEntry], + component: &str, + case_sensitive: bool, + upcase: Option<&UpcaseTable>, +) -> Result { + let needle_utf16 = component.encode_utf16().collect::>(); + + let mut matches = Vec::new(); + if case_sensitive { + for e in entries { + if eq_case_sensitive(&e.name_utf16, &needle_utf16) { + matches.push(e); + } + } + } else { + let upcase = upcase.ok_or_else(|| Error::InvalidData { + message: "missing $UpCase table for case-insensitive comparison".to_string(), + })?; + for e in entries { + if eq_case_insensitive_ntfs(upcase, &e.name_utf16, &needle_utf16) { + matches.push(e); + } + } + } + + if matches.is_empty() { + return Err(Error::NotFound { + what: format!("path component `{component}` under entry {dir_entry_id}"), + }); + } + + let mut entry_ids: HashSet = matches.iter().map(|e| e.entry_id).collect(); + if entry_ids.len() == 1 { + return Ok(entry_ids.drain().next().expect("len=1")); + } + + // Ambiguous: multiple different entry IDs match. Provide candidates deterministically. + let mut candidates = matches + .iter() + .map(|e| (e.entry_id, e.name.clone())) + .collect::>(); + candidates.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1))); + candidates.dedup(); + + let listed = candidates + .into_iter() + .map(|(id, name)| format!("{name} (entry {id})")) + .collect::>() + .join(", "); + + Err(Error::InvalidData { + message: format!( + "ambiguous path component `{component}` under entry {dir_entry_id}: {listed}" + ), + }) +} + +fn split_path(path: &str) -> impl Iterator { + path.split(['\\', '/']) + .filter(|s| !s.is_empty() && *s != ".") +} + +#[cfg(test)] +mod case_sensitivity_tests { + use super::standard_info_indicates_case_sensitive; + use jiff::Timestamp; + use mft::attribute::FileAttributeFlags; + use mft::attribute::x10::StandardInfoAttr; + + #[test] + fn test_standard_information_case_sensitive_flag() { + let ts = Timestamp::new(0, 0).unwrap(); + let mk = |max_version: u32, version: u32| StandardInfoAttr { + created: ts, + modified: ts, + mft_modified: ts, + accessed: ts, + file_flags: FileAttributeFlags::from_bits_truncate(0), + max_version, + version, + class_id: 0, + owner_id: 0, + security_id: 0, + quota: 0, + usn: 0, + }; + + assert!(standard_info_indicates_case_sensitive(&mk(0, 1))); + assert!(!standard_info_indicates_case_sensitive(&mk(0, 0))); + assert!(!standard_info_indicates_case_sensitive(&mk(1, 1))); + } +} + +#[cfg(test)] +mod path_resolution_tests { + use super::resolve_component_in_entries; + use crate::ntfs::Error; + use crate::ntfs::filesystem::DirectoryEntry; + use crate::ntfs::name::UpcaseTable; + use crate::ntfs::name::upcase::UPCASE_CHARACTER_COUNT; + + fn ascii_upcase_for_tests() -> UpcaseTable { + let mut map = (0u32..UPCASE_CHARACTER_COUNT as u32) + .map(|v| v as u16) + .collect::>(); + for (lower, upper) in (b'a'..=b'z').zip(b'A'..=b'Z') { + map[lower as usize] = upper as u16; + } + UpcaseTable::from_mapping_for_tests(map) + } + + fn dirent(name: &str, entry_id: u64) -> DirectoryEntry { + DirectoryEntry { + name: name.to_string(), + name_utf16: name.encode_utf16().collect(), + entry_id, + } + } + + #[test] + fn case_insensitive_match_errors_on_ambiguous_different_entry_ids() { + let up = ascii_upcase_for_tests(); + let entries = vec![dirent("foo", 1), dirent("FOO", 2)]; + let err = resolve_component_in_entries(5, &entries, "foo", false, Some(&up)).unwrap_err(); + assert!(matches!(err, Error::InvalidData { .. })); + assert!(err.to_string().contains("ambiguous path component `foo`")); + } + + #[test] + fn case_insensitive_match_allows_multiple_names_for_same_entry_id() { + let up = ascii_upcase_for_tests(); + let entries = vec![dirent("foo", 1), dirent("FOO", 1)]; + let id = resolve_component_in_entries(5, &entries, "foo", false, Some(&up)).unwrap(); + assert_eq!(id, 1); + } + + #[test] + fn case_sensitive_match_requires_exact_utf16() { + let entries = vec![dirent("foo", 1), dirent("FOO", 2)]; + let id = resolve_component_in_entries(5, &entries, "foo", true, None).unwrap(); + assert_eq!(id, 1); + assert!(resolve_component_in_entries(5, &entries, "Foo", true, None).is_err()); + } +} + +#[derive(Debug, Clone)] +struct DataExtent { + vcn_first: u64, + vcn_len: u64, + file_size: u64, + is_compressed: bool, + compression_unit_clusters: Option, + data_runs: Vec, +} + +/// A `ReadAt` view over a non-resident `$DATA` stream represented as multiple extents. +/// +/// This is used for `$UsnJrnl:$J` to support fragmentation / attribute list scenarios while keeping +/// the USN journal reader generic over `ReadAt`. +#[derive(Debug, Clone)] +struct DataExtentsReadAt { + volume: Volume, + cluster_size: u64, + file_size: u64, + extents: Vec, +} + +impl ReadAt for DataExtentsReadAt { + fn len(&self) -> u64 { + self.file_size + } + + fn read_exact_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> { + use std::io; + + let end = offset + .checked_add(buf.len() as u64) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "offset overflow"))?; + if end > self.file_size { + return Err(io::Error::from(io::ErrorKind::UnexpectedEof)); + } + + buf.fill(0); + + for extent in &self.extents { + let extent_start = extent.vcn_first.saturating_mul(self.cluster_size); + let extent_end = + extent_start.saturating_add(extent.vcn_len.saturating_mul(self.cluster_size)); + + let overlap_start = offset.max(extent_start); + let overlap_end = end.min(extent_end); + if overlap_start >= overlap_end { + continue; + } + + let dst_off = (overlap_start - offset) as usize; + let src_off = overlap_start.saturating_sub(extent_start); + let overlap_len = (overlap_end - overlap_start) as usize; + + read_from_data_runs( + &self.volume, + &extent.data_runs, + src_off, + &mut buf[dst_off..dst_off + overlap_len], + ) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?; + } + + Ok(()) + } +} + +/// A simple in-memory [`ReadAt`] implementation. +#[derive(Debug, Clone)] +struct ResidentReadAt { + data: Arc<[u8]>, +} + +impl ResidentReadAt { + fn new(bytes: Vec) -> Self { + Self { data: bytes.into() } + } +} + +impl ReadAt for ResidentReadAt { + fn len(&self) -> u64 { + self.data.len() as u64 + } + + fn read_exact_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> { + use std::io; + + let end = offset + .checked_add(buf.len() as u64) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "offset overflow"))?; + if end > self.len() { + return Err(io::Error::from(io::ErrorKind::UnexpectedEof)); + } + + let start = usize::try_from(offset) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "offset overflow"))?; + let end = usize::try_from(end) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "end overflow"))?; + + buf.copy_from_slice(&self.data[start..end]); + Ok(()) + } +} + +/// A [`ReadAt`] wrapper that decrypts EFS ciphertext on-the-fly. +/// +/// Decryption is performed in 512-byte sectors, matching Windows NTFS EFS behavior. +struct EfsDecryptingReadAt { + cipher: Arc, + decryptor: EfsFekDecryptor, + plain_len: u64, +} + +impl EfsDecryptingReadAt { + fn new(cipher: Arc, decryptor: EfsFekDecryptor, plain_len: u64) -> Self { + Self { + cipher, + decryptor, + plain_len, + } + } +} + +impl ReadAt for EfsDecryptingReadAt { + fn len(&self) -> u64 { + self.plain_len + } + + fn read_exact_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> { + use std::io; + + if buf.is_empty() { + return Ok(()); + } + + let end = offset + .checked_add(buf.len() as u64) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "offset overflow"))?; + if end > self.plain_len { + return Err(io::Error::from(io::ErrorKind::UnexpectedEof)); + } + + let aligned_start = (offset / 512).saturating_mul(512); + let aligned_end = end.div_ceil(512).saturating_mul(512); + let aligned_len = aligned_end + .checked_sub(aligned_start) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "range underflow"))?; + + // `aligned_len` is always a multiple of 512 bytes. + let mut tmp = vec![0u8; aligned_len as usize]; + self.cipher.read_exact_at(aligned_start, &mut tmp)?; + self.decryptor + .decrypt_in_place(&mut tmp, aligned_start) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?; + + let start_in_tmp = usize::try_from(offset - aligned_start) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "offset overflow"))?; + buf.copy_from_slice(&tmp[start_in_tmp..start_in_tmp + buf.len()]); + Ok(()) + } +} + +impl DataExtent { + fn from_non_resident_attr( + attr: &mft::attribute::MftAttribute, + nr: &mft::attribute::header::NonResidentHeader, + data_runs: Vec, + ) -> Result { + let file_size = nr.file_size; + let vcn_first = nr.vnc_first; + let vcn_len = nr.vnc_last.saturating_sub(nr.vnc_first).saturating_add(1); + + let is_compressed = attr + .header + .data_flags + .contains(AttributeDataFlags::IS_COMPRESSED) + || nr.unit_compression_size > 0; + + let compression_unit_clusters = if is_compressed { + // Interpret as a shift count (common NTFS behavior): unit_clusters = 1 << shift. + let shift = (nr.unit_compression_size & 0x00ff) as u32; + Some(1u64 << shift) + } else { + None + }; + + Ok(Self { + vcn_first, + vcn_len, + file_size, + is_compressed, + compression_unit_clusters, + data_runs, + }) + } +} + +fn find_attribute_list(entry: &mft::MftEntry) -> Result> { + for attr in entry + .iter_attributes_matching(Some(vec![MftAttributeType::AttributeList])) + .filter_map(std::result::Result::ok) + { + if let Some(list) = attr.data.into_attribute_list() { + return Ok(Some(list)); + } + } + Ok(None) +} diff --git a/crates/ntfs/src/ntfs/filesystem/mod.rs b/crates/ntfs/src/ntfs/filesystem/mod.rs new file mode 100644 index 0000000..918560d --- /dev/null +++ b/crates/ntfs/src/ntfs/filesystem/mod.rs @@ -0,0 +1,58 @@ +mod file_system; + +pub use file_system::{DirectoryEntry, FileSystem}; + +/// Returns `true` if the directory entry name is `"."` or `".."`. +/// +/// These entries are typically present in directory indexes but are not useful for most callers. +pub fn is_dot_dir_entry(name: &str) -> bool { + name == "." || name == ".." +} + +/// Joins an NTFS path (using `\` separators) with a child name. +/// +/// This helper keeps the root path as `\` (i.e. `join_ntfs_child_path("\\", "Windows")` +/// becomes `\\Windows`). +pub fn join_ntfs_child_path(parent_path: &str, child_name: &str) -> String { + if parent_path == "\\" { + return format!("\\{child_name}"); + } + if parent_path.ends_with('\\') { + return format!("{parent_path}{child_name}"); + } + format!("{parent_path}\\{child_name}") +} + +#[cfg(test)] +mod tests { + use super::{is_dot_dir_entry, join_ntfs_child_path}; + + #[test] + fn test_is_dot_dir_entry() { + assert!(is_dot_dir_entry(".")); + assert!(is_dot_dir_entry("..")); + assert!(!is_dot_dir_entry("...")); + assert!(!is_dot_dir_entry("Windows")); + } + + #[test] + fn test_join_ntfs_child_path_root() { + assert_eq!(join_ntfs_child_path("\\", "Windows"), "\\Windows"); + } + + #[test] + fn test_join_ntfs_child_path_non_root() { + assert_eq!( + join_ntfs_child_path("\\Windows", "System32"), + "\\Windows\\System32" + ); + } + + #[test] + fn test_join_ntfs_child_path_parent_with_trailing_separator() { + assert_eq!( + join_ntfs_child_path("\\Windows\\", "System32"), + "\\Windows\\System32" + ); + } +} diff --git a/crates/ntfs/src/ntfs/index/mod.rs b/crates/ntfs/src/ntfs/index/mod.rs new file mode 100644 index 0000000..f1cff54 --- /dev/null +++ b/crates/ntfs/src/ntfs/index/mod.rs @@ -0,0 +1,286 @@ +use crate::ntfs::name::FileNameKey; +use crate::ntfs::{Error, Result}; +use crate::parse::Reader; +use bitflags::bitflags; +use mft::ntfs::apply_update_sequence_array_fixups_in_place; + +pub const INDEX_RECORD_SIGNATURE: &[u8; 4] = b"INDX"; + +#[derive(Debug, Clone)] +pub struct IndexRootHeader { + pub attribute_type: u32, + pub collation_type: u32, + pub index_entry_size: u32, + pub index_entry_number_of_cluster_blocks: u32, +} + +impl IndexRootHeader { + pub fn parse(r: &mut Reader<'_>) -> Result { + Ok(Self { + attribute_type: r.u32_le("index_root.attribute_type")?, + collation_type: r.u32_le("index_root.collation_type")?, + index_entry_size: r.u32_le("index_root.index_entry_size")?, + index_entry_number_of_cluster_blocks: r + .u32_le("index_root.index_entry_number_of_cluster_blocks")?, + }) + } +} + +bitflags! { + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct IndexNodeFlags: u32 { + /// Indicates the presence of an $INDEX_ALLOCATION attribute. + const HAS_ALLOCATION_ATTRIBUTE = 0x0000_0001; + } +} + +bitflags! { + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct IndexValueFlags: u32 { + const IS_BRANCH_NODE = 0x0000_0001; + const IS_LAST = 0x0000_0002; + } +} + +#[derive(Debug, Clone)] +pub struct IndexNodeHeader { + /// Offset relative to the start of the index node header. + pub index_values_offset: u32, + pub size: u32, + pub allocated_size: u32, + pub flags: IndexNodeFlags, +} + +impl IndexNodeHeader { + pub fn parse(r: &mut Reader<'_>) -> Result { + let index_values_offset = r.u32_le("index_node.index_values_offset")?; + let size = r.u32_le("index_node.size")?; + let allocated_size = r.u32_le("index_node.allocated_size")?; + let flags = IndexNodeFlags::from_bits_truncate(r.u32_le("index_node.flags")?); + + if size > 0 && size < 16 { + return Err(Error::InvalidData { + message: format!("index node size too small: {size}"), + }); + } + if size > 0 + && (index_values_offset < 16 + || index_values_offset > size + || index_values_offset % 8 != 0) + { + return Err(Error::InvalidData { + message: format!( + "index values offset out of bounds: offset={index_values_offset} size={size}" + ), + }); + } + + Ok(Self { + index_values_offset, + size, + allocated_size, + flags, + }) + } +} + +#[derive(Debug, Clone)] +pub struct IndexRecordHeader { + pub signature: [u8; 4], + pub fixup_values_offset: u16, + pub number_of_fixup_values: u16, + pub journal_sequence_number: u64, + pub vcn: u64, +} + +impl IndexRecordHeader { + pub fn parse(r: &mut Reader<'_>) -> Result { + let sig = r.take("index_record.signature", 4)?; + let signature: [u8; 4] = sig.try_into().expect("len=4"); + Ok(Self { + signature, + fixup_values_offset: r.u16_le("index_record.fixup_values_offset")?, + number_of_fixup_values: r.u16_le("index_record.number_of_fixup_values")?, + journal_sequence_number: r.u64_le("index_record.journal_sequence_number")?, + vcn: r.u64_le("index_record.vcn")?, + }) + } +} + +#[derive(Debug, Clone)] +pub struct IndexValue { + pub file_reference_raw: u64, + pub size: u16, + pub key_data_size: u16, + pub flags: IndexValueFlags, + pub file_name: Option, + pub sub_node_vcn: Option, +} + +impl IndexValue { + pub fn is_last(&self) -> bool { + self.flags.contains(IndexValueFlags::IS_LAST) + } + + pub fn parse(r: &mut Reader<'_>) -> Result { + let file_reference_raw = r.u64_le("index_value.file_reference")?; + let size = r.u16_le("index_value.size")?; + let key_data_size = r.u16_le("index_value.key_data_size")?; + let flags = IndexValueFlags::from_bits_truncate(r.u32_le("index_value.flags")?); + + if size < 16 { + return Err(Error::InvalidData { + message: format!("index value size too small: {size}"), + }); + } + + let payload_len = (size as usize).saturating_sub(16); + let payload_offset = r.stream_offset(); + let payload = r.take("index_value.payload", payload_len)?; + + let key_len = key_data_size as usize; + if key_len > payload.len() { + return Err(Error::InvalidData { + message: format!( + "index value key length out of bounds: key_len={key_len} payload_len={}", + payload.len() + ), + }); + } + + let key = &payload[..key_len]; + let file_name = if !key.is_empty() { + // In $I30 indexes, the key is a FILE_NAME attribute. + Some(FileNameKey::parse(key, payload_offset)?) + } else { + None + }; + + let sub_node_vcn = if flags.contains(IndexValueFlags::IS_BRANCH_NODE) { + if payload.len() < 8 { + return Err(Error::InvalidData { + message: "branch node index value missing sub-node VCN".to_string(), + }); + } + let tail = &payload[payload.len() - 8..]; + Some(u64::from_le_bytes(tail.try_into().expect("len=8"))) + } else { + None + }; + + Ok(Self { + file_reference_raw, + size, + key_data_size, + flags, + file_name, + sub_node_vcn, + }) + } +} + +#[derive(Debug, Clone)] +pub struct IndexNode { + pub header: IndexNodeHeader, + pub values: Vec, +} + +impl IndexNode { + pub fn parse_from_node_start(buf: &[u8], base_offset: u64, node_start: usize) -> Result { + let node_buf = buf.get(node_start..).ok_or_else(|| Error::InvalidData { + message: "index node start out of bounds".to_string(), + })?; + + let mut r = Reader::with_base_offset(node_buf, base_offset + node_start as u64); + let header = IndexNodeHeader::parse(&mut r)?; + + if header.size == 0 { + return Ok(Self { + header, + values: Vec::new(), + }); + } + + let values_start = node_start + header.index_values_offset as usize; + let values_end = node_start + header.size as usize; + if values_end > buf.len() || values_start > values_end { + return Err(Error::InvalidData { + message: format!( + "index values region out of bounds: start={values_start} end={values_end} buf_len={}", + buf.len() + ), + }); + } + + let mut values = Vec::new(); + let mut cur = values_start; + + while cur < values_end { + let mut vr = Reader::with_base_offset( + buf.get(cur..values_end).ok_or_else(|| Error::InvalidData { + message: "index value slice out of bounds".to_string(), + })?, + base_offset + cur as u64, + ); + let value = IndexValue::parse(&mut vr)?; + let value_size = value.size as usize; + values.push(value.clone()); + + if value.is_last() { + break; + } + + if value_size == 0 { + break; + } + cur = cur.saturating_add(value_size); + } + + Ok(Self { header, values }) + } +} + +#[derive(Debug, Clone)] +pub struct IndexRoot { + pub root_header: IndexRootHeader, + pub node: IndexNode, +} + +impl IndexRoot { + pub fn parse(buf: &[u8], base_offset: u64) -> Result { + let mut r = Reader::with_base_offset(buf, base_offset); + let root_header = IndexRootHeader::parse(&mut r)?; + + // The node header begins immediately after the root header (16 bytes). + let node_start = 16; + let node = IndexNode::parse_from_node_start(buf, base_offset, node_start)?; + + Ok(Self { root_header, node }) + } +} + +/// Applies USA fixups in-place to an INDX record buffer. +pub fn apply_index_record_fixups(record: &mut [u8]) -> Result<()> { + if record.len() < 24 { + return Err(Error::InvalidData { + message: "index record too small".to_string(), + }); + } + + let fixup_values_offset = u16::from_le_bytes(record[4..6].try_into().expect("len=2")); + let number_of_fixup_values = u16::from_le_bytes(record[6..8].try_into().expect("len=2")); + + // Best-effort: apply fixups even if the update-sequence value does not match. + // We still error on structurally invalid/corrupt fixup arrays. + match apply_update_sequence_array_fixups_in_place( + record, + fixup_values_offset, + number_of_fixup_values, + ) { + Ok(_) => Ok(()), + Err(mft::err::Error::Any { detail }) => Err(Error::InvalidData { message: detail }), + Err(e) => Err(Error::InvalidData { + message: e.to_string(), + }), + } +} diff --git a/crates/ntfs/src/ntfs/mod.rs b/crates/ntfs/src/ntfs/mod.rs new file mode 100644 index 0000000..c9be3eb --- /dev/null +++ b/crates/ntfs/src/ntfs/mod.rs @@ -0,0 +1,15 @@ +pub mod compression; +pub mod data_stream; +pub mod efs; +mod error; +pub mod filesystem; +pub mod index; +pub mod name; +pub mod usn; +pub mod volume; +pub mod volume_header; + +pub use error::{Error, Result}; +pub use filesystem::FileSystem; +pub use volume::Volume; +pub use volume_header::VolumeHeader; diff --git a/crates/ntfs/src/ntfs/name/file_name.rs b/crates/ntfs/src/ntfs/name/file_name.rs new file mode 100644 index 0000000..bcc68de --- /dev/null +++ b/crates/ntfs/src/ntfs/name/file_name.rs @@ -0,0 +1,97 @@ +use crate::ntfs::{Error, Result}; + +/// A parsed NTFS `FILE_NAME` attribute value as used as a key in the `$I30` index. +/// +/// This is a minimal, **strict** parser that preserves the raw UTF-16 code units of the name. +/// It intentionally does not attempt to interpret the UTF-16 as Unicode scalar values. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileNameKey { + parent_reference_raw: u64, + name_space: u8, + name_utf16: Vec, +} + +impl FileNameKey { + /// Parses a `FILE_NAME` attribute value from `buf`. + /// + /// `base_offset` is used only for error messages. + pub fn parse(buf: &[u8], base_offset: u64) -> Result { + // FILE_NAME fixed prefix is 66 bytes, then name_length * 2 bytes of UTF-16LE. + const FIXED_LEN: usize = 66; + if buf.len() < FIXED_LEN { + return Err(Error::InvalidData { + message: format!( + "FILE_NAME key too small at 0x{base_offset:x}: len={} < {FIXED_LEN}", + buf.len() + ), + }); + } + + let parent_reference_raw = u64::from_le_bytes( + buf.get(0..8) + .ok_or_else(|| Error::InvalidData { + message: "FILE_NAME missing parent reference".to_string(), + })? + .try_into() + .expect("len=8"), + ); + + let name_length = buf[64] as usize; + let name_space = buf[65]; + + let name_bytes_len = name_length + .checked_mul(2) + .ok_or_else(|| Error::InvalidData { + message: format!( + "FILE_NAME name length overflow at 0x{base_offset:x}: name_length={name_length}" + ), + })?; + let expected_len = FIXED_LEN + .checked_add(name_bytes_len) + .ok_or_else(|| Error::InvalidData { + message: format!( + "FILE_NAME length overflow at 0x{base_offset:x}: fixed={FIXED_LEN} name_bytes={name_bytes_len}" + ), + })?; + + if buf.len() != expected_len { + return Err(Error::InvalidData { + message: format!( + "FILE_NAME key length mismatch at 0x{base_offset:x}: expected {expected_len} bytes (name_length={name_length}), got {}", + buf.len() + ), + }); + } + + let name_bytes = &buf[FIXED_LEN..]; + debug_assert_eq!(name_bytes.len(), name_bytes_len); + + let name_utf16 = name_bytes + .chunks_exact(2) + .map(|two| u16::from_le_bytes([two[0], two[1]])) + .collect::>(); + + Ok(Self { + parent_reference_raw, + name_space, + name_utf16, + }) + } + + /// Parent directory entry number (file record number), masked to 48 bits. + pub fn parent_entry_id(&self) -> u64 { + self.parent_reference_raw & 0x0000_FFFF_FFFF_FFFF + } + + pub fn name_space(&self) -> u8 { + self.name_space + } + + pub fn name_utf16(&self) -> &[u16] { + &self.name_utf16 + } + + pub fn into_name_utf16(self) -> Vec { + self.name_utf16 + } +} diff --git a/crates/ntfs/src/ntfs/name/mod.rs b/crates/ntfs/src/ntfs/name/mod.rs new file mode 100644 index 0000000..b389b60 --- /dev/null +++ b/crates/ntfs/src/ntfs/name/mod.rs @@ -0,0 +1,100 @@ +//! NTFS name matching primitives (UTF-16 code units + deterministic case folding). +//! +//! Key properties: +//! - Comparisons operate on **UTF-16 code units** (`u16`), not Unicode scalar values. +//! NTFS names may contain **unpaired surrogates**, which must be preserved and compared +//! deterministically. +//! - Case-insensitive comparisons use the per-volume `$UpCase` table, making behavior +//! deterministic and independent of host locale/Unicode library behavior. + +pub mod file_name; +pub mod upcase; + +use std::cmp::Ordering; + +pub use file_name::FileNameKey; +pub use upcase::UpcaseTable; + +/// Case-sensitive equality over UTF-16 code units. +pub fn eq_case_sensitive(a: &[u16], b: &[u16]) -> bool { + a == b +} + +/// NTFS case-insensitive equality over UTF-16 code units using a `$UpCase` table. +pub fn eq_case_insensitive_ntfs(upcase: &UpcaseTable, a: &[u16], b: &[u16]) -> bool { + if a.len() != b.len() { + return false; + } + a.iter() + .zip(b.iter()) + .all(|(&aa, &bb)| upcase.map_u16(aa) == upcase.map_u16(bb)) +} + +/// NTFS case-insensitive ordering over UTF-16 code units using a `$UpCase` table. +/// +/// This matches the typical collation behavior used by the `$I30` filename index: +/// compare the uppercased code units lexicographically, then by length. +pub fn cmp_case_insensitive_ntfs(upcase: &UpcaseTable, a: &[u16], b: &[u16]) -> Ordering { + for (&aa, &bb) in a.iter().zip(b.iter()) { + let aa = upcase.map_u16(aa); + let bb = upcase.map_u16(bb); + if aa != bb { + return aa.cmp(&bb); + } + } + a.len().cmp(&b.len()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_eq_case_sensitive_works_for_surrogates() { + let a = vec![0x0041, 0xD800, 0x0042]; // 'A', unpaired high surrogate, 'B' + let b = vec![0x0041, 0xD800, 0x0042]; + let c = vec![0x0041, 0xD801, 0x0042]; + assert!(eq_case_sensitive(&a, &b)); + assert!(!eq_case_sensitive(&a, &c)); + } + + #[test] + fn test_eq_case_insensitive_ntfs_ascii_and_non_ascii_with_synthetic_table() { + let mut map = (0u32..upcase::UPCASE_CHARACTER_COUNT as u32) + .map(|v| v as u16) + .collect::>(); + + // ASCII mapping for test. + map[b'a' as usize] = b'A' as u16; + map[b'z' as usize] = b'Z' as u16; + + // Non-ASCII example: ä (U+00E4) -> Ä (U+00C4) + map[0x00E4] = 0x00C4; + + let up = UpcaseTable::from_mapping_for_tests(map); + + assert!(eq_case_insensitive_ntfs( + &up, + &[b'a' as u16], + &[b'A' as u16] + )); + assert!(eq_case_insensitive_ntfs(&up, &[0x00E4], &[0x00C4])); + + // Surrogates remain deterministic (identity mapping by default). + assert!(eq_case_insensitive_ntfs(&up, &[0xD800], &[0xD800])); + assert!(!eq_case_insensitive_ntfs(&up, &[0xD800], &[0xD801])); + } + + #[test] + fn test_cmp_case_insensitive_ntfs_is_lexicographic_then_by_length() { + let up = UpcaseTable::identity_for_tests(); + assert_eq!( + cmp_case_insensitive_ntfs(&up, &[b'a' as u16], &[b'b' as u16]), + std::cmp::Ordering::Less + ); + assert_eq!( + cmp_case_insensitive_ntfs(&up, &[b'a' as u16], &[b'a' as u16, b'a' as u16]), + std::cmp::Ordering::Less + ); + } +} diff --git a/crates/ntfs/src/ntfs/name/upcase.rs b/crates/ntfs/src/ntfs/name/upcase.rs new file mode 100644 index 0000000..73adb75 --- /dev/null +++ b/crates/ntfs/src/ntfs/name/upcase.rs @@ -0,0 +1,63 @@ +use crate::ntfs::{Error, Result}; + +/// Number of UTF-16 code units in the NTFS `$UpCase` mapping table (BMP only). +pub const UPCASE_CHARACTER_COUNT: usize = 65_536; + +/// Size of the `$UpCase` table in bytes (65536 * 2). +pub const UPCASE_TABLE_SIZE_BYTES: usize = UPCASE_CHARACTER_COUNT * 2; + +/// A deterministic uppercasing table used by NTFS for case-insensitive name comparisons. +/// +/// NTFS stores this mapping in the `$UpCase` system file (MFT entry 10). +/// The table is defined over **UTF-16 code units** (`u16`) and therefore supports unpaired +/// surrogates (they will typically map to themselves). +#[derive(Debug, Clone)] +pub struct UpcaseTable { + map: Vec, +} + +impl UpcaseTable { + /// Parses a `$UpCase` table from its on-disk bytes. + /// + /// Strict validation: + /// - input must be exactly 131072 bytes (65536 u16 values) + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() != UPCASE_TABLE_SIZE_BYTES { + return Err(Error::InvalidData { + message: format!( + "invalid $UpCase size: expected {UPCASE_TABLE_SIZE_BYTES} bytes, got {}", + bytes.len() + ), + }); + } + + let map = bytes + .chunks_exact(2) + .map(|two| u16::from_le_bytes([two[0], two[1]])) + .collect::>(); + + debug_assert_eq!(map.len(), UPCASE_CHARACTER_COUNT); + + Ok(Self { map }) + } + + /// Maps a UTF-16 code unit to its uppercase equivalent per the `$UpCase` table. + #[inline] + pub fn map_u16(&self, u: u16) -> u16 { + self.map[u as usize] + } + + #[cfg(test)] + pub(crate) fn identity_for_tests() -> Self { + let map = (0u32..UPCASE_CHARACTER_COUNT as u32) + .map(|v| v as u16) + .collect(); + Self { map } + } + + #[cfg(test)] + pub(crate) fn from_mapping_for_tests(map: Vec) -> Self { + assert_eq!(map.len(), UPCASE_CHARACTER_COUNT); + Self { map } + } +} diff --git a/crates/ntfs/src/ntfs/usn/journal.rs b/crates/ntfs/src/ntfs/usn/journal.rs new file mode 100644 index 0000000..39d9154 --- /dev/null +++ b/crates/ntfs/src/ntfs/usn/journal.rs @@ -0,0 +1,338 @@ +//! USN change journal (`$Extend\\$UsnJrnl:$J`) record reader. +//! +//! This is the *block-based* reader layer modeled after upstream +//! - Reads fixed-size **journal blocks** (default 0x1000 bytes) +//! - Interprets the first 4 bytes at the current block offset as `record_len` (LE u32) +//! - Treats `record_len == 0` as **end-of-block** and advances to the next block +//! - Rejects invalid sizes **strictly** (no skipping, no placeholders) +//! +//! It intentionally does **not** parse record semantics. Use [`super::UsnRecord::parse`] (or +//! [`super::UsnRecordV2::parse`]) on the returned bytes. + +use crate::image::ReadAt; +use crate::ntfs::{Error, Result}; +use std::sync::Arc; + +/// Default journal block size. +/// +pub const DEFAULT_USN_JOURNAL_BLOCK_SIZE: usize = 0x1000; + +/// Minimum USN record size in bytes (fixed fields before variable-length name). +/// +/// This is the size of a minimal `USN_RECORD_V2` with an empty file name. +pub const MIN_USN_RECORD_SIZE: usize = 60; + +/// A raw USN record read from `$UsnJrnl:$J`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UsnRawRecord { + /// Logical stream offset where this record begins (relative to `$J` start). + pub offset: u64, + /// Record bytes (`record_len` bytes, starting with `record_len` itself). + pub bytes: Vec, +} + +/// Stateful `$J` reader yielding one record at a time. +#[derive(Clone)] +pub struct UsnChangeJournal { + stream: Arc, + journal_block_size: usize, + + // Current logical stream offset (relative to `$J` start). + offset: u64, + + // Cached block. + block_start: u64, + block_valid_len: usize, + block: Vec, + block_loaded: bool, +} + +impl UsnChangeJournal { + /// Create a new journal reader over the provided `$J` stream. + pub fn new(stream: Arc, journal_block_size: usize) -> Result { + if journal_block_size < MIN_USN_RECORD_SIZE { + return Err(Error::InvalidData { + message: format!( + "invalid USN journal block size: {journal_block_size} (min {MIN_USN_RECORD_SIZE})" + ), + }); + } + if journal_block_size > usize::MAX / 2 { + // Defensive: avoid absurd allocations. + return Err(Error::InvalidData { + message: format!("invalid USN journal block size: {journal_block_size}"), + }); + } + + Ok(Self { + stream, + journal_block_size, + offset: 0, + block_start: 0, + block_valid_len: 0, + block: vec![0u8; journal_block_size], + block_loaded: false, + }) + } + + /// Returns the logical `$J` stream length in bytes. + pub fn len(&self) -> u64 { + self.stream.len() + } + + /// Returns `true` if the `$J` stream length is 0. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Returns the current logical offset within `$J` (relative to `$J` start). + pub fn offset(&self) -> u64 { + self.offset + } + + /// Returns the journal block size in bytes. + pub fn journal_block_size(&self) -> usize { + self.journal_block_size + } + + /// Reads the next USN record. + /// + /// - Returns `Ok(None)` on EOF. + /// - Returns an error on invalid record layout (strict). + pub fn read_record_bytes(&mut self) -> Result> { + loop { + let stream_len = self.stream.len(); + if self.offset >= stream_len { + return Ok(None); + } + + let block_size_u64 = self.journal_block_size as u64; + let within = (self.offset % block_size_u64) as usize; + let block_start = self.offset - within as u64; + + if !self.block_loaded || self.block_start != block_start { + self.load_block(block_start)?; + } + + let remaining_in_block = self.journal_block_size.saturating_sub(within); + + // If there is not enough room for even the smallest record, skip to next block. + // (Matches upstream intent; records are not expected to straddle blocks.) + if remaining_in_block < MIN_USN_RECORD_SIZE { + self.offset = block_start.saturating_add(block_size_u64); + continue; + } + + let record_len = + u32::from_le_bytes(self.block[within..within + 4].try_into().expect("4 bytes")) + as usize; + + if record_len == 0 { + // End-of-block marker. + self.offset = block_start.saturating_add(block_size_u64); + continue; + } + + if record_len < MIN_USN_RECORD_SIZE { + return Err(Error::InvalidData { + message: format!( + "invalid USN record size: len={record_len} at stream_offset=0x{:x}", + block_start + within as u64 + ), + }); + } + if record_len > remaining_in_block { + return Err(Error::InvalidData { + message: format!( + "invalid USN record size: len={record_len} overflows journal block at stream_offset=0x{:x}", + block_start + within as u64 + ), + }); + } + + let record_end = (block_start + within as u64) + .checked_add(record_len as u64) + .ok_or_else(|| Error::InvalidData { + message: "USN record end offset overflow".to_string(), + })?; + if record_end > stream_len { + return Err(Error::InvalidData { + message: format!( + "USN record extends beyond end of $J stream: record_end=0x{:x} stream_len=0x{:x}", + record_end, stream_len + ), + }); + } + + // Note: `block_valid_len` is only smaller than `journal_block_size` on the final block. + // If the record is within `stream_len`, it must be within `block_valid_len` as well. + if within + .saturating_add(record_len) + .saturating_sub(self.block_valid_len) + > 0 + { + return Err(Error::InvalidData { + message: format!( + "USN record extends beyond readable bytes in journal block: len={record_len} at stream_offset=0x{:x}", + block_start + within as u64 + ), + }); + } + + let bytes = self.block[within..within + record_len].to_vec(); + let record_offset = block_start + within as u64; + + self.offset = self.offset.saturating_add(record_len as u64); + + return Ok(Some(UsnRawRecord { + offset: record_offset, + bytes, + })); + } + } + + /// Returns an iterator over raw USN records (as owned `Vec`). + pub fn iter_record_bytes(&mut self) -> UsnRawRecordIter<'_> { + UsnRawRecordIter { + j: self, + done: false, + } + } + + fn load_block(&mut self, block_start: u64) -> Result<()> { + let stream_len = self.stream.len(); + if block_start >= stream_len { + // Should be handled by caller, but keep this robust. + self.block_loaded = true; + self.block_start = block_start; + self.block_valid_len = 0; + self.block.fill(0); + return Ok(()); + } + + let to_read = (stream_len - block_start) + .min(self.journal_block_size as u64) + .try_into() + .map_err(|_| Error::InvalidData { + message: "journal block read length overflow".to_string(), + })?; + + self.block.fill(0); + self.stream + .read_exact_at(block_start, &mut self.block[..to_read]) + .map_err(Error::Io)?; + + self.block_loaded = true; + self.block_start = block_start; + self.block_valid_len = to_read; + Ok(()) + } +} + +pub struct UsnRawRecordIter<'a> { + j: &'a mut UsnChangeJournal, + done: bool, +} + +impl Iterator for UsnRawRecordIter<'_> { + type Item = Result; + + fn next(&mut self) -> Option { + if self.done { + return None; + } + match self.j.read_record_bytes() { + Ok(Some(r)) => Some(Ok(r)), + Ok(None) => { + self.done = true; + None + } + Err(e) => { + self.done = true; + Some(Err(e)) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io; + use std::sync::Arc; + + #[derive(Debug)] + struct MemReadAt { + data: Arc<[u8]>, + } + + impl MemReadAt { + fn new(data: Vec) -> Self { + Self { data: data.into() } + } + } + + impl ReadAt for MemReadAt { + fn len(&self) -> u64 { + self.data.len() as u64 + } + + fn read_exact_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + let end = offset + .checked_add(buf.len() as u64) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "offset overflow"))?; + if end > self.len() { + return Err(io::Error::from(io::ErrorKind::UnexpectedEof)); + } + let start = usize::try_from(offset) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "offset overflow"))?; + let end = usize::try_from(end) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "offset overflow"))?; + buf.copy_from_slice(&self.data[start..end]); + Ok(()) + } + } + + #[test] + fn reader_yields_records_and_skips_end_of_block_marker() { + let block_size = 0x1000usize; + let mut data = vec![0u8; block_size * 2]; + + // Block 0: record(60) + end-of-block marker (0). + data[0..4].copy_from_slice(&(60u32).to_le_bytes()); + // Next record header at offset 60: record_len == 0 => end of block. + data[60..64].copy_from_slice(&0u32.to_le_bytes()); + + // Block 1: record(60) + end-of-block. + let b1 = block_size; + data[b1..b1 + 4].copy_from_slice(&(60u32).to_le_bytes()); + data[b1 + 60..b1 + 64].copy_from_slice(&0u32.to_le_bytes()); + + let stream: Arc = Arc::new(MemReadAt::new(data)); + let mut j = UsnChangeJournal::new(stream, block_size).unwrap(); + + let recs = j.iter_record_bytes().collect::>>().unwrap(); + assert_eq!(recs.len(), 2); + assert_eq!(recs[0].offset, 0); + assert_eq!(recs[0].bytes.len(), 60); + assert_eq!(recs[1].offset, block_size as u64); + assert_eq!(recs[1].bytes.len(), 60); + } + + #[test] + fn reader_errors_on_record_overflowing_block() { + let block_size = 0x1000usize; + let mut data = vec![0u8; block_size]; + + // Place record header at the last possible start for a minimal record (60 bytes remain). + let off = block_size - MIN_USN_RECORD_SIZE; + data[off..off + 4].copy_from_slice(&(256u32).to_le_bytes()); // too large for remaining + + let stream: Arc = Arc::new(MemReadAt::new(data)); + let mut j = UsnChangeJournal::new(stream, block_size).unwrap(); + j.offset = off as u64; + + let err = j.read_record_bytes().unwrap_err(); + assert!(matches!(err, Error::InvalidData { .. })); + } +} diff --git a/crates/ntfs/src/ntfs/usn/mod.rs b/crates/ntfs/src/ntfs/usn/mod.rs new file mode 100644 index 0000000..c346301 --- /dev/null +++ b/crates/ntfs/src/ntfs/usn/mod.rs @@ -0,0 +1,431 @@ +//! USN change journal (`$Extend\\$UsnJrnl:$J`) support. +//! +//! ## Supported record versions +//! - **Supported**: `USN_RECORD_V2` (`major_version == 2`) +//! - **Unsupported (strict error)**: any other major version (future work) +//! +//! This module is **strict by default**: unsupported versions, invalid sizes, and invalid UTF-16LE +//! file names are hard errors (no silent skipping). + +pub mod journal; + +use crate::ntfs::{Error, Result}; +use crate::parse::Reader; +use bitflags::bitflags; +use mft::attribute::FileAttributeFlags; +use std::char::decode_utf16; + +pub use journal::{ + DEFAULT_USN_JOURNAL_BLOCK_SIZE, MIN_USN_RECORD_SIZE, UsnChangeJournal, UsnRawRecord, +}; + +bitflags! { + /// USN update reason flags (`USN_REASON_*`). + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct UsnReasonFlags: u32 { + const DATA_OVERWRITE = 0x0000_0001; + const DATA_EXTEND = 0x0000_0002; + const DATA_TRUNCATION = 0x0000_0004; + + const NAMED_DATA_OVERWRITE = 0x0000_0010; + const NAMED_DATA_EXTEND = 0x0000_0020; + const NAMED_DATA_TRUNCATION = 0x0000_0040; + + const FILE_CREATE = 0x0000_0100; + const FILE_DELETE = 0x0000_0200; + const EXTENDED_ATTRIBUTE_CHANGE = 0x0000_0400; + const SECURITY_CHANGE = 0x0000_0800; + const RENAME_OLD_NAME = 0x0000_1000; + const RENAME_NEW_NAME = 0x0000_2000; + const INDEXABLE_CHANGE = 0x0000_4000; + const BASIC_INFO_CHANGE = 0x0000_8000; + const HARD_LINK_CHANGE = 0x0001_0000; + const COMPRESSION_CHANGE = 0x0002_0000; + const ENCRYPTION_CHANGE = 0x0004_0000; + const OBJECT_IDENTIFIER_CHANGE = 0x0008_0000; + const REPARSE_POINT_CHANGE = 0x0010_0000; + const STREAM_CHANGE = 0x0020_0000; + const TRANSACTED_CHANGE = 0x0040_0000; + + const CLOSE = 0x8000_0000; + } +} + +bitflags! { + /// USN update source flags (`USN_SOURCE_*`). + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct UsnSourceFlags: u32 { + const DATA_MANAGEMENT = 0x0000_0001; + const AUXILIARY_DATA = 0x0000_0002; + const REPLICATION_MANAGEMENT = 0x0000_0004; + } +} + +/// Parsed USN record as stored in `$UsnJrnl:$J`. +#[derive(Debug, Clone, PartialEq)] +pub enum UsnRecord { + V2(UsnRecordV2), +} + +impl UsnRecord { + /// Parse a USN record from an on-disk byte buffer (strict). + /// + /// `base_offset` is used only for parse error reporting. + pub fn parse(buf: &[u8], base_offset: u64) -> Result { + if buf.len() < MIN_USN_RECORD_SIZE { + return Err(Error::InvalidData { + message: format!( + "USN record buffer too small: len={} (min {MIN_USN_RECORD_SIZE})", + buf.len() + ), + }); + } + + // Peek the major version for dispatch. + let mut r = Reader::with_base_offset(buf, base_offset); + let record_len = r.u32_le("usn.record_length")? as usize; + if record_len != buf.len() { + return Err(Error::InvalidData { + message: format!( + "USN record length mismatch: header={} actual={}", + record_len, + buf.len() + ), + }); + } + let major = r.u16_le("usn.major_version")?; + + match major { + 2 => Ok(Self::V2(UsnRecordV2::parse(buf, base_offset)?)), + other => Err(Error::Unsupported { + what: format!("USN record major version {other} (supported: 2)"), + }), + } + } +} + +/// Parsed `USN_RECORD_V2` (major version 2). +/// +/// Layout reference (Windows): `USN_RECORD_V2`. +#[derive(Debug, Clone, PartialEq)] +pub struct UsnRecordV2 { + pub major_version: u16, + pub minor_version: u16, + pub file_reference: u64, + pub parent_file_reference: u64, + pub usn: u64, + /// Windows FILETIME (100ns since 1601-01-01 UTC). + pub timestamp_filetime: u64, + pub reason: UsnReasonFlags, + pub source_info: UsnSourceFlags, + pub security_id: u32, + pub file_attributes: FileAttributeFlags, + pub name: String, +} + +impl UsnRecordV2 { + /// Parse a USN record from an on-disk byte buffer. + /// + /// `base_offset` is used only for parse error reporting. + pub fn parse(buf: &[u8], base_offset: u64) -> Result { + let mut r = Reader::with_base_offset(buf, base_offset); + + let record_len = r.u32_le("usn.record_length")? as usize; + if record_len != buf.len() { + return Err(Error::InvalidData { + message: format!( + "USN record length mismatch: header={} actual={}", + record_len, + buf.len() + ), + }); + } + + let major = r.u16_le("usn.major_version")?; + let minor = r.u16_le("usn.minor_version")?; + if major != 2 { + return Err(Error::Unsupported { + what: format!("USN record major version {major} (expected 2)"), + }); + } + + let file_reference = r.u64_le("usn.file_reference")?; + let parent_file_reference = r.u64_le("usn.parent_file_reference")?; + let usn = r.u64_le("usn.usn")?; + let timestamp_filetime = r.u64_le("usn.timestamp")?; + let reason_raw = r.u32_le("usn.reason")?; + let source_raw = r.u32_le("usn.source_info")?; + let security_id = r.u32_le("usn.security_id")?; + let file_attributes_raw = r.u32_le("usn.file_attributes")?; + let name_len = r.u16_le("usn.file_name_length")? as usize; + let name_off = r.u16_le("usn.file_name_offset")? as usize; + + if name_off > buf.len() || name_len > buf.len().saturating_sub(name_off) { + return Err(Error::InvalidData { + message: format!( + "USN record name out of bounds: off={} len={} buf_len={}", + name_off, + name_len, + buf.len() + ), + }); + } + if !name_len.is_multiple_of(2) { + return Err(Error::InvalidData { + message: format!("USN record name length is not UTF-16LE: len={name_len}"), + }); + } + + let name_bytes = &buf[name_off..name_off + name_len]; + + let reason = UsnReasonFlags::from_bits(reason_raw).ok_or_else(|| Error::Unsupported { + what: format!("unsupported USN reason flags: 0x{reason_raw:08x} (unknown bits set)"), + })?; + let source_info = + UsnSourceFlags::from_bits(source_raw).ok_or_else(|| Error::Unsupported { + what: format!( + "unsupported USN source flags: 0x{source_raw:08x} (unknown bits set)" + ), + })?; + let file_attributes = + FileAttributeFlags::from_bits(file_attributes_raw).ok_or_else(|| Error::Unsupported { + what: format!( + "unsupported USN file attributes: 0x{file_attributes_raw:08x} (unknown bits set)" + ), + })?; + + let name = decode_utf16le_strict(name_bytes, base_offset + name_off as u64)?; + + Ok(Self { + major_version: major, + minor_version: minor, + file_reference, + parent_file_reference, + usn, + timestamp_filetime, + reason, + source_info, + security_id, + file_attributes, + name, + }) + } +} + +fn decode_utf16le_strict(bytes: &[u8], base_offset: u64) -> Result { + debug_assert!(bytes.len().is_multiple_of(2), "validated by caller"); + let mut u16s = Vec::with_capacity(bytes.len() / 2); + for chunk in bytes.chunks_exact(2) { + u16s.push(u16::from_le_bytes([chunk[0], chunk[1]])); + } + + // Strict: treat invalid surrogate pairs as invalid data. + let mut out = String::new(); + for ch in decode_utf16(u16s.into_iter()) { + let ch = ch.map_err(|_| Error::InvalidData { + message: format!("invalid UTF-16LE in USN record name at offset 0x{base_offset:x}"), + })?; + out.push(ch); + } + Ok(out) +} + +/// Returns the names of set `USN_REASON_*` flags, matching upstream spelling. +pub fn reason_flag_names(flags: UsnReasonFlags) -> impl Iterator { + const MAP: &[(UsnReasonFlags, &str)] = &[ + (UsnReasonFlags::DATA_OVERWRITE, "USN_REASON_DATA_OVERWRITE"), + (UsnReasonFlags::DATA_EXTEND, "USN_REASON_DATA_EXTEND"), + ( + UsnReasonFlags::DATA_TRUNCATION, + "USN_REASON_DATA_TRUNCATION", + ), + ( + UsnReasonFlags::NAMED_DATA_OVERWRITE, + "USN_REASON_NAMED_DATA_OVERWRITE", + ), + ( + UsnReasonFlags::NAMED_DATA_EXTEND, + "USN_REASON_NAMED_DATA_EXTEND", + ), + ( + UsnReasonFlags::NAMED_DATA_TRUNCATION, + "USN_REASON_NAMED_DATA_TRUNCATION", + ), + (UsnReasonFlags::FILE_CREATE, "USN_REASON_FILE_CREATE"), + (UsnReasonFlags::FILE_DELETE, "USN_REASON_FILE_DELETE"), + ( + UsnReasonFlags::EXTENDED_ATTRIBUTE_CHANGE, + "USN_REASON_EA_CHANGE", + ), + ( + UsnReasonFlags::SECURITY_CHANGE, + "USN_REASON_SECURITY_CHANGE", + ), + ( + UsnReasonFlags::RENAME_OLD_NAME, + "USN_REASON_RENAME_OLD_NAME", + ), + ( + UsnReasonFlags::RENAME_NEW_NAME, + "USN_REASON_RENAME_NEW_NAME", + ), + ( + UsnReasonFlags::INDEXABLE_CHANGE, + "USN_REASON_INDEXABLE_CHANGE", + ), + ( + UsnReasonFlags::BASIC_INFO_CHANGE, + "USN_REASON_BASIC_INFO_CHANGE", + ), + ( + UsnReasonFlags::HARD_LINK_CHANGE, + "USN_REASON_HARD_LINK_CHANGE", + ), + ( + UsnReasonFlags::COMPRESSION_CHANGE, + "USN_REASON_COMPRESSION_CHANGE", + ), + ( + UsnReasonFlags::ENCRYPTION_CHANGE, + "USN_REASON_ENCRYPTION_CHANGE", + ), + ( + UsnReasonFlags::OBJECT_IDENTIFIER_CHANGE, + "USN_REASON_OBJECT_IDENTIFIER_CHANGE", + ), + ( + UsnReasonFlags::REPARSE_POINT_CHANGE, + "USN_REASON_REPARSE_POINT_CHANGE", + ), + (UsnReasonFlags::STREAM_CHANGE, "USN_REASON_STREAM_CHANGE"), + ( + UsnReasonFlags::TRANSACTED_CHANGE, + "USN_REASON_TRANSACTED_CHANGE", + ), + (UsnReasonFlags::CLOSE, "USN_REASON_CLOSE"), + ]; + + MAP.iter().filter_map(move |(flag, name)| { + if flags.contains(*flag) { + Some(*name) + } else { + None + } + }) +} + +/// Returns the names of set `USN_SOURCE_*` flags, matching upstream spelling. +pub fn source_flag_names(flags: UsnSourceFlags) -> impl Iterator { + const MAP: &[(UsnSourceFlags, &str)] = &[ + ( + UsnSourceFlags::DATA_MANAGEMENT, + "USN_SOURCE_DATA_MANAGEMENT", + ), + (UsnSourceFlags::AUXILIARY_DATA, "USN_SOURCE_AUXILIARY_DATA"), + ( + UsnSourceFlags::REPLICATION_MANAGEMENT, + "USN_SOURCE_REPLICATION_MANAGEMENT", + ), + ]; + MAP.iter().filter_map(move |(flag, name)| { + if flags.contains(*flag) { + Some(*name) + } else { + None + } + }) +} + +/// Returns the names of set `FILE_ATTRIBUTE_*` flags, matching upstream spelling. +pub fn file_attribute_flag_names(flags: FileAttributeFlags) -> impl Iterator { + // Keep these aligned with `mft::attribute::FileAttributeFlags` / Windows constants. + const MAP: &[(u32, &str)] = &[ + (0x0000_0001, "FILE_ATTRIBUTE_READ_ONLY"), + (0x0000_0002, "FILE_ATTRIBUTE_HIDDEN"), + (0x0000_0004, "FILE_ATTRIBUTE_SYSTEM"), + (0x0000_0010, "FILE_ATTRIBUTE_DIRECTORY"), + (0x0000_0020, "FILE_ATTRIBUTE_ARCHIVE"), + (0x0000_0040, "FILE_ATTRIBUTE_DEVICE"), + (0x0000_0080, "FILE_ATTRIBUTE_NORMAL"), + (0x0000_0100, "FILE_ATTRIBUTE_TEMPORARY"), + (0x0000_0200, "FILE_ATTRIBUTE_SPARSE_FILE"), + (0x0000_0400, "FILE_ATTRIBUTE_REPARSE_POINT"), + (0x0000_0800, "FILE_ATTRIBUTE_COMPRESSED"), + (0x0000_1000, "FILE_ATTRIBUTE_OFFLINE"), + (0x0000_2000, "FILE_ATTRIBUTE_NOT_CONTENT_INDEXED"), + (0x0000_4000, "FILE_ATTRIBUTE_ENCRYPTED"), + (0x0001_0000, "FILE_ATTRIBUTE_VIRTUAL"), + ]; + let bits = flags.bits(); + MAP.iter().filter_map(move |(mask, name)| { + if (bits & mask) != 0 { + Some(*name) + } else { + None + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn utf16le(s: &str) -> Vec { + s.encode_utf16() + .flat_map(|u| u.to_le_bytes()) + .collect::>() + } + + #[test] + fn parse_usn_record_v2_roundtrip_minimal() { + let name = utf16le("test.txt"); + let record_len = 60 + name.len(); + let name_offset = 60u16; + let name_len = name.len() as u16; + let reason = (UsnReasonFlags::FILE_CREATE | UsnReasonFlags::CLOSE).bits(); + let source = UsnSourceFlags::DATA_MANAGEMENT.bits(); + + let mut buf = vec![0u8; record_len]; + buf[0..4].copy_from_slice(&(record_len as u32).to_le_bytes()); + buf[4..6].copy_from_slice(&2u16.to_le_bytes()); // major + buf[6..8].copy_from_slice(&0u16.to_le_bytes()); // minor + buf[8..16].copy_from_slice(&0x1122_3344_5566_7788u64.to_le_bytes()); // file ref + buf[16..24].copy_from_slice(&0x8877_6655_4433_2211u64.to_le_bytes()); // parent ref + buf[24..32].copy_from_slice(&123u64.to_le_bytes()); // usn + buf[32..40].copy_from_slice(&0x0102_0304_0506_0708u64.to_le_bytes()); // timestamp + buf[40..44].copy_from_slice(&reason.to_le_bytes()); + buf[44..48].copy_from_slice(&source.to_le_bytes()); + buf[48..52].copy_from_slice(&0x99u32.to_le_bytes()); // security id + buf[52..56].copy_from_slice(&0x20u32.to_le_bytes()); // file attrs + buf[56..58].copy_from_slice(&name_len.to_le_bytes()); + buf[58..60].copy_from_slice(&name_offset.to_le_bytes()); + buf[60..60 + name.len()].copy_from_slice(&name); + + let rec = UsnRecordV2::parse(&buf, 0).unwrap(); + assert_eq!(rec.major_version, 2); + assert_eq!(rec.minor_version, 0); + assert_eq!(rec.file_reference, 0x1122_3344_5566_7788); + assert_eq!(rec.parent_file_reference, 0x8877_6655_4433_2211); + assert_eq!(rec.usn, 123); + assert_eq!(rec.timestamp_filetime, 0x0102_0304_0506_0708); + assert_eq!(rec.reason.bits(), reason); + assert_eq!(rec.source_info.bits(), source); + assert_eq!(rec.security_id, 0x99); + assert_eq!( + rec.file_attributes, + FileAttributeFlags::FILE_ATTRIBUTE_ARCHIVE + ); + assert_eq!(rec.name, "test.txt"); + } + + #[test] + fn parse_usn_record_v2_rejects_length_mismatch() { + let mut buf = vec![0u8; 60]; + buf[0..4].copy_from_slice(&61u32.to_le_bytes()); // bogus length + buf[4..6].copy_from_slice(&2u16.to_le_bytes()); + buf[6..8].copy_from_slice(&0u16.to_le_bytes()); + + let err = UsnRecordV2::parse(&buf, 0).unwrap_err(); + assert!(matches!(err, Error::InvalidData { .. })); + } +} diff --git a/crates/ntfs/src/ntfs/volume.rs b/crates/ntfs/src/ntfs/volume.rs new file mode 100644 index 0000000..e5b50cc --- /dev/null +++ b/crates/ntfs/src/ntfs/volume.rs @@ -0,0 +1,126 @@ +use crate::image::ReadAt; +use crate::ntfs::Result; +use crate::ntfs::data_stream::read_from_data_runs; +use crate::ntfs::volume_header::VolumeHeader; +use mft::attribute::MftAttributeType; +use std::fmt; +use std::sync::Arc; + +/// A view over an NTFS volume inside an image. +/// +/// MFT entry reading uses the `$MFT` file's data runs (extracted from entry 0) so it works even +/// when `$MFT` is fragmented. +#[derive(Clone)] +pub struct Volume { + image: Arc, + volume_offset: u64, + pub header: VolumeHeader, + mft_data_runs: Vec, +} + +impl fmt::Debug for Volume { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Volume") + .field("volume_offset", &self.volume_offset) + .field("header", &self.header) + .field("image_len", &self.image.len()) + .field("mft_data_runs", &self.mft_data_runs.len()) + .finish() + } +} + +impl Volume { + pub fn open(image: Arc, volume_offset: u64) -> Result { + let header = VolumeHeader::read_from_image(image.as_ref(), volume_offset)?; + let mut volume = Self { + image, + volume_offset, + header, + mft_data_runs: Vec::new(), + }; + + // Bootstrap: read MFT entry 0 using the boot-sector-provided MFT location, and use its + // `$DATA` mapping pairs to read arbitrary entries. + let entry0_buf = volume.read_mft_entry_raw_contiguous(0)?; + let entry0 = mft::MftEntry::from_buffer(entry0_buf, 0)?; + + for attr in entry0 + .iter_attributes_matching(Some(vec![MftAttributeType::DATA])) + .filter_map(std::result::Result::ok) + .filter(|a| a.header.name.is_empty()) + { + if let Some(dr) = attr.data.into_data_runs() { + volume.mft_data_runs = dr.data_runs; + break; + } + } + + Ok(volume) + } + + pub fn volume_offset(&self) -> u64 { + self.volume_offset + } + + pub fn read_exact_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> { + self.image + .read_exact_at(self.volume_offset.saturating_add(offset), buf) + } + + /// Reads raw bytes of the MFT entry with `entry_number`. + pub fn read_mft_entry_raw(&self, entry_number: u64) -> Result> { + let entry_size = self.header.mft_entry_size as usize; + + let mut buf = vec![0u8; entry_size]; + if self.mft_data_runs.is_empty() { + // Fallback for unusual/corrupt volumes where we couldn't obtain the $MFT mapping. + let entry_offset = self + .header + .mft_offset_bytes() + .saturating_add(entry_number.saturating_mul(self.header.mft_entry_size as u64)); + self.read_exact_at(entry_offset, &mut buf)?; + return Ok(buf); + } + + let mft_offset = entry_number.saturating_mul(self.header.mft_entry_size as u64); + read_from_data_runs(self, &self.mft_data_runs, mft_offset, &mut buf)?; + Ok(buf) + } + + pub fn read_mft_entry(&self, entry_number: u64) -> Result { + let buf = self.read_mft_entry_raw(entry_number)?; + Ok(mft::MftEntry::from_buffer(buf, entry_number)?) + } + + fn read_mft_entry_raw_contiguous(&self, entry_number: u64) -> Result> { + let entry_size = self.header.mft_entry_size as usize; + let entry_offset = self + .header + .mft_offset_bytes() + .saturating_add(entry_number.saturating_mul(self.header.mft_entry_size as u64)); + + let mut buf = vec![0u8; entry_size]; + self.read_exact_at(entry_offset, &mut buf)?; + Ok(buf) + } +} + +#[cfg(test)] +impl Volume { + /// Test helper: constructs a volume from an arbitrary [`ReadAt`] and a pre-parsed header. + /// + /// This bypasses NTFS bootstrapping (reading `$MFT` entry 0 to obtain mapping pairs) and is + /// intended for unit tests that exercise lower-level helpers such as data-run readers. + pub(crate) fn new_for_tests( + image: Arc, + volume_offset: u64, + header: VolumeHeader, + ) -> Self { + Self { + image, + volume_offset, + header, + mft_data_runs: Vec::new(), + } + } +} diff --git a/crates/ntfs/src/ntfs/volume_header.rs b/crates/ntfs/src/ntfs/volume_header.rs new file mode 100644 index 0000000..cb02c6b --- /dev/null +++ b/crates/ntfs/src/ntfs/volume_header.rs @@ -0,0 +1,320 @@ +//! NTFS volume boot record (VBR) parser. +//! +//! This module provides [`VolumeHeader`], a compact parser for the NTFS boot sector fields that +//! the rest of this crate needs to interpret on-disk structures (cluster sizing, `$MFT` location, +//! record sizing, and the volume serial number). +//! +//! The focus is **correctness** and actionable diagnostics on corrupted inputs. Parsing uses +//! [`crate::parse::Reader`], so offset-aware parse errors can be reported relative to the source +//! image by threading a `base_offset` through the reader. +//! +//! Current limitations: +//! - Only the canonical OEM ID `NTFS ` is accepted. +//! - Only the first 512 bytes of the boot sector are parsed; the backup VBR is not consulted. +//! - Only a small subset of BPB fields are validated (enough to derive layout parameters). + +use crate::image::ReadAt; +use crate::ntfs::{Error, Result}; +use crate::parse::Reader; +use std::fmt; + +/// NTFS Volume Boot Record (VBR) derived parameters. +/// +#[derive(Debug, Clone)] +pub struct VolumeHeader { + /// Bytes per sector. + pub bytes_per_sector: u16, + /// Sectors per cluster. + pub sectors_per_cluster: u8, + /// Cluster size in bytes (`bytes_per_sector * sectors_per_cluster`). + pub cluster_size: u32, + + /// Total sectors in the volume. + pub total_sectors: u64, + /// Logical cluster number (LCN) of `$MFT`. + pub mft_lcn: u64, + /// Logical cluster number (LCN) of `$MFTMirr`. + pub mirror_mft_lcn: u64, + + /// MFT file record size in bytes. + pub mft_entry_size: u32, + /// Index record size in bytes. + pub index_entry_size: u32, + + /// Volume serial number. + pub volume_serial_number: u64, +} + +impl fmt::Display for VolumeHeader { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "bytes_per_sector: {}", self.bytes_per_sector)?; + writeln!(f, "sectors_per_cluster: {}", self.sectors_per_cluster)?; + writeln!(f, "cluster_size: {}", self.cluster_size)?; + writeln!(f, "total_sectors: {}", self.total_sectors)?; + writeln!(f, "volume_size_bytes: {}", self.volume_size_bytes())?; + writeln!(f, "mft_lcn: {}", self.mft_lcn)?; + writeln!(f, "mirror_mft_lcn: {}", self.mirror_mft_lcn)?; + writeln!(f, "mft_entry_size: {}", self.mft_entry_size)?; + writeln!(f, "index_entry_size: {}", self.index_entry_size)?; + writeln!( + f, + "volume_serial_number: 0x{:016x}", + self.volume_serial_number + )?; + Ok(()) + } +} + +impl VolumeHeader { + /// Reads and parses the NTFS boot sector at `volume_offset`. + /// + /// `volume_offset` should point at the start of the NTFS volume within `image` + /// (for example: the partition start for a partitioned disk image). + pub fn read_from_image(image: &dyn ReadAt, volume_offset: u64) -> Result { + let mut boot = [0u8; 512]; + image.read_exact_at(volume_offset, &mut boot)?; + Self::from_boot_sector(&boot, volume_offset) + } + + /// Parses an NTFS boot sector buffer. + /// + /// `base_offset` is used only for error reporting: it tells the underlying [`Reader`] what + /// offset to report in parse errors (useful when `boot` is a slice taken from a larger image). + pub fn from_boot_sector(boot: &[u8; 512], base_offset: u64) -> Result { + // Validate OEM ID. + if &boot[3..11] != b"NTFS " { + return Err(Error::InvalidBootSector { + message: "missing OEM ID (expected `NTFS ` at offset 0x03)", + }); + } + + // Validate signature. + if boot[510] != 0x55 || boot[511] != 0xAA { + return Err(Error::InvalidBootSector { + message: "missing boot sector signature (expected 0x55AA at offset 0x1FE)", + }); + } + + let mut r = Reader::with_base_offset(boot, base_offset); + + // Skip jump (3) + OEM ID (8) + r.skip("jump+oem_id", 11)?; + + let bytes_per_sector = r.u16_le("bytes_per_sector")?; + let sectors_per_cluster = r.u8("sectors_per_cluster")?; + + // Skip: reserved sectors (2), zeros (3), unused (2), media (1), zeros (2), + // sectors/track (2), heads (2), hidden sectors (4), unused (4), unused (4). + r.skip("bpb_skip", 2 + 3 + 2 + 1 + 2 + 2 + 2 + 4 + 4 + 4)?; + + let total_sectors = r.u64_le("total_sectors")?; + let mft_lcn = r.u64_le("mft_lcn")?; + let mirror_mft_lcn = r.u64_le("mirror_mft_lcn")?; + + let clusters_per_mft_record_raw = r.u8("clusters_per_mft_record_raw")? as i8; + r.skip("clusters_per_mft_record_padding", 3)?; + + let clusters_per_index_record_raw = r.u8("clusters_per_index_record_raw")? as i8; + r.skip("clusters_per_index_record_padding", 3)?; + + let volume_serial_number = r.u64_le("volume_serial_number")?; + + if bytes_per_sector == 0 || sectors_per_cluster == 0 { + return Err(Error::InvalidBootSector { + message: "bytes_per_sector or sectors_per_cluster is zero", + }); + } + + let cluster_size = u32::from(bytes_per_sector) * u32::from(sectors_per_cluster); + + let mft_entry_size = + compute_record_size("mft_entry_size", clusters_per_mft_record_raw, cluster_size)?; + let index_entry_size = compute_record_size( + "index_entry_size", + clusters_per_index_record_raw, + cluster_size, + )?; + + Ok(Self { + bytes_per_sector, + sectors_per_cluster, + cluster_size, + total_sectors, + mft_lcn, + mirror_mft_lcn, + mft_entry_size, + index_entry_size, + volume_serial_number, + }) + } + + pub fn volume_size_bytes(&self) -> u64 { + self.total_sectors + .saturating_mul(self.bytes_per_sector as u64) + } + + /// Returns the byte offset of `$MFT` relative to the start of the volume. + /// + /// Note this does **not** include any partition/volume base offset; callers reading from a + /// larger disk image should add the volume base offset separately. + pub fn mft_offset_bytes(&self) -> u64 { + self.mft_lcn.saturating_mul(self.cluster_size as u64) + } +} + +/// Decodes an NTFS record size field from the boot sector. +/// +/// NTFS encodes these record sizes as a signed 8-bit integer: +/// - If the value is positive, it is a count of clusters. +/// - If the value is negative, the size is \(2^{|v|}\) bytes. +/// - A value of 0 is invalid. +fn compute_record_size( + _field: &'static str, + clusters_per_record: i8, + cluster_size: u32, +) -> Result { + if clusters_per_record > 0 { + Ok(cluster_size.saturating_mul(clusters_per_record as u32)) + } else if clusters_per_record < 0 { + // Avoid overflow on `i8::MIN` (negating -128 is undefined in `i8`). + let shift = u32::from(clusters_per_record.unsigned_abs()); + if shift >= 31 { + return Err(Error::InvalidBootSector { + message: "record size exponent out of range", + }); + } + Ok(1u32 << shift) + } else { + Err(Error::InvalidBootSector { + message: "record size value cannot be 0", + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn boot_sector_template() -> [u8; 512] { + let mut boot = [0u8; 512]; + // OEM ID. + boot[3..11].copy_from_slice(b"NTFS "); + // Signature. + boot[510] = 0x55; + boot[511] = 0xAA; + boot + } + + fn write_u16_le(buf: &mut [u8], off: usize, v: u16) { + buf[off..off + 2].copy_from_slice(&v.to_le_bytes()); + } + + fn write_u64_le(buf: &mut [u8], off: usize, v: u64) { + buf[off..off + 8].copy_from_slice(&v.to_le_bytes()); + } + + #[test] + fn parses_valid_boot_sector() { + let mut boot = boot_sector_template(); + write_u16_le(&mut boot, 0x0b, 512); + boot[0x0d] = 8; // sectors per cluster => cluster_size=4096 + + write_u64_le(&mut boot, 0x28, 0x1122_3344_5566_7788); + write_u64_le(&mut boot, 0x30, 4); + write_u64_le(&mut boot, 0x38, 8); + + boot[0x40] = 0xF6; // -10 => 1024 bytes + boot[0x44] = 0x01; // 1 cluster => 4096 bytes + + write_u64_le(&mut boot, 0x48, 0xAABB_CCDD_1122_3344); + + let h = VolumeHeader::from_boot_sector(&boot, 0).unwrap(); + assert_eq!(h.bytes_per_sector, 512); + assert_eq!(h.sectors_per_cluster, 8); + assert_eq!(h.cluster_size, 4096); + assert_eq!(h.total_sectors, 0x1122_3344_5566_7788); + assert_eq!(h.mft_lcn, 4); + assert_eq!(h.mirror_mft_lcn, 8); + assert_eq!(h.mft_entry_size, 1024); + assert_eq!(h.index_entry_size, 4096); + assert_eq!(h.volume_serial_number, 0xAABB_CCDD_1122_3344); + } + + #[test] + fn rejects_bad_oem_id() { + let mut boot = boot_sector_template(); + boot[3..11].copy_from_slice(b"NOTNTFS!"); + let err = VolumeHeader::from_boot_sector(&boot, 0).unwrap_err(); + match err { + Error::InvalidBootSector { .. } => {} + other => panic!("unexpected error: {other:?}"), + } + } + + #[test] + fn rejects_missing_signature() { + let mut boot = boot_sector_template(); + boot[510] = 0; + boot[511] = 0; + let err = VolumeHeader::from_boot_sector(&boot, 0).unwrap_err(); + match err { + Error::InvalidBootSector { .. } => {} + other => panic!("unexpected error: {other:?}"), + } + } + + #[test] + fn rejects_zero_bytes_per_sector_or_sectors_per_cluster() { + let mut boot = boot_sector_template(); + write_u16_le(&mut boot, 0x0b, 0); + boot[0x0d] = 8; + assert!(matches!( + VolumeHeader::from_boot_sector(&boot, 0), + Err(Error::InvalidBootSector { .. }) + )); + + let mut boot = boot_sector_template(); + write_u16_le(&mut boot, 0x0b, 512); + boot[0x0d] = 0; + assert!(matches!( + VolumeHeader::from_boot_sector(&boot, 0), + Err(Error::InvalidBootSector { .. }) + )); + } + + #[test] + fn record_size_exponent_out_of_range_is_error_not_panic() { + let mut boot = boot_sector_template(); + write_u16_le(&mut boot, 0x0b, 512); + boot[0x0d] = 8; + + // i8::MIN => abs=128, out of supported range; should error (and not overflow/panic). + boot[0x40] = 0x80; // -128 + boot[0x44] = 0x01; + + let err = VolumeHeader::from_boot_sector(&boot, 0).unwrap_err(); + match err { + Error::InvalidBootSector { message } => { + assert_eq!(message, "record size exponent out of range"); + } + other => panic!("unexpected error: {other:?}"), + } + } + + #[test] + fn record_size_zero_is_error() { + let mut boot = boot_sector_template(); + write_u16_le(&mut boot, 0x0b, 512); + boot[0x0d] = 8; + + boot[0x40] = 0; // invalid + boot[0x44] = 1; + let err = VolumeHeader::from_boot_sector(&boot, 0).unwrap_err(); + match err { + Error::InvalidBootSector { message } => { + assert_eq!(message, "record size value cannot be 0"); + } + other => panic!("unexpected error: {other:?}"), + } + } +} diff --git a/crates/ntfs/src/parse/error.rs b/crates/ntfs/src/parse/error.rs new file mode 100644 index 0000000..f80ac9f --- /dev/null +++ b/crates/ntfs/src/parse/error.rs @@ -0,0 +1,84 @@ +use crate::parse::hexdump::hexdump_around; +use std::error::Error as StdError; +use std::fmt; + +pub type Result = std::result::Result; + +/// A parsing error that captures the logical stream offset and a small hexdump window. +/// +/// This is intentionally *not* an enum at the moment: in practice we want rich context (field +/// name + offset + bytes). Higher layers can categorize errors if/when needed. +#[derive(Debug)] +pub struct ParseError { + offset: u64, + field: &'static str, + message: String, + hexdump: String, + source: Box, +} + +impl ParseError { + pub fn new( + offset: u64, + field: &'static str, + message: impl Into, + hexdump: impl Into, + source: Box, + ) -> Self { + Self { + offset, + field, + message: message.into(), + hexdump: hexdump.into(), + source, + } + } + + pub fn offset(&self) -> u64 { + self.offset + } + + pub fn field(&self) -> &'static str { + self.field + } + + pub fn hexdump(&self) -> &str { + &self.hexdump + } + + pub(crate) fn capture_from_slice( + buf: &[u8], + base_offset: u64, + pos: usize, + field: &'static str, + message: impl Into, + source: Box, + ) -> Self { + let offset = base_offset.saturating_add(pos as u64); + let hexdump = hexdump_around(buf, base_offset, pos, 96, 64); + Self::new(offset, field, message, hexdump, source) + } +} + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "Offset `0x{offset:08x} ({offset})` - failed to parse `{field}`\n\ + {message}\n\n\ + Source: {source}\n\n\ + Hexdump:\n{hexdump}", + offset = self.offset, + field = self.field, + message = self.message, + source = self.source, + hexdump = self.hexdump + ) + } +} + +impl StdError for ParseError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + Some(self.source.as_ref()) + } +} diff --git a/crates/ntfs/src/parse/hexdump.rs b/crates/ntfs/src/parse/hexdump.rs new file mode 100644 index 0000000..8fc1fd1 --- /dev/null +++ b/crates/ntfs/src/parse/hexdump.rs @@ -0,0 +1,81 @@ +/// Creates a compact hexdump around `pos` in `buf`. +/// +/// `base_offset` is used for labeling offsets in the dump (useful when `buf` is a window into a +/// larger stream). +pub(crate) fn hexdump_around( + buf: &[u8], + base_offset: u64, + pos: usize, + lookbehind: usize, + lookahead: usize, +) -> String { + if buf.is_empty() { + return "".to_string(); + } + + let start = pos.saturating_sub(lookbehind); + let end = (pos + lookahead).min(buf.len()); + + // 16 bytes per line. + let mut out = String::new(); + let mut i = start - (start % 16); + while i < end { + let line_start = i; + let line_end = (i + 16).min(buf.len()); + + // Offset. + out.push_str(&format!( + "0x{off:08x} ", + off = base_offset.saturating_add(line_start as u64) + )); + + // Hex bytes. + for b in buf[line_start..line_end] + .iter() + .copied() + .map(Some) + .chain(std::iter::repeat(None)) + .take(16) + { + match b { + Some(b) => out.push_str(&format!("{b:02x} ")), + None => out.push_str(" "), + } + } + + // ASCII. + out.push(' '); + for b in buf[line_start..line_end] + .iter() + .copied() + .map(Some) + .chain(std::iter::repeat(None)) + .take(16) + { + match b { + Some(b) => { + let c = if b.is_ascii_graphic() || b == b' ' { + b as char + } else { + '.' + }; + out.push(c); + } + None => out.push(' '), + } + } + + // Mark the cursor line/position. + if pos >= line_start && pos < line_start + 16 { + let caret_pos = 10 + 2 + (pos - line_start) * 3; // after "0x........ " + out.push('\n'); + out.push_str(&" ".repeat(caret_pos)); + out.push('^'); + } + + out.push('\n'); + i += 16; + } + + out +} diff --git a/crates/ntfs/src/parse/mod.rs b/crates/ntfs/src/parse/mod.rs new file mode 100644 index 0000000..1dc1a89 --- /dev/null +++ b/crates/ntfs/src/parse/mod.rs @@ -0,0 +1,8 @@ +//! Shared parsing utilities for NTFS structures. + +mod error; +mod hexdump; +mod reader; + +pub use error::{ParseError, Result}; +pub use reader::Reader; diff --git a/crates/ntfs/src/parse/reader.rs b/crates/ntfs/src/parse/reader.rs new file mode 100644 index 0000000..f233c12 --- /dev/null +++ b/crates/ntfs/src/parse/reader.rs @@ -0,0 +1,178 @@ +use crate::parse::error::{ParseError, Result}; +use byteorder::{LittleEndian, ReadBytesExt}; +use std::io; + +/// A small cursor over an in-memory buffer with offset-aware errors. +/// +/// `base_offset` lets callers label errors with the original stream offset when `buf` is a slice +/// of a larger stream. +#[derive(Debug, Clone)] +pub struct Reader<'a> { + buf: &'a [u8], + base_offset: u64, + pos: usize, +} + +impl<'a> Reader<'a> { + pub fn new(buf: &'a [u8]) -> Self { + Self { + buf, + base_offset: 0, + pos: 0, + } + } + + pub fn with_base_offset(buf: &'a [u8], base_offset: u64) -> Self { + Self { + buf, + base_offset, + pos: 0, + } + } + + pub fn base_offset(&self) -> u64 { + self.base_offset + } + + pub fn position(&self) -> usize { + self.pos + } + + pub fn stream_offset(&self) -> u64 { + self.base_offset.saturating_add(self.pos as u64) + } + + pub fn len(&self) -> usize { + self.buf.len() + } + + pub fn is_empty(&self) -> bool { + self.buf.is_empty() + } + + pub fn remaining(&self) -> usize { + self.buf.len().saturating_sub(self.pos) + } + + pub fn seek(&mut self, field: &'static str, pos: usize) -> Result<()> { + if pos > self.buf.len() { + return Err(ParseError::capture_from_slice( + self.buf, + self.base_offset, + self.pos, + field, + format!("seek out of bounds: pos={pos} len={}", self.buf.len()), + Box::new(io::Error::from(io::ErrorKind::UnexpectedEof)), + )); + } + self.pos = pos; + Ok(()) + } + + pub fn skip(&mut self, field: &'static str, n: usize) -> Result<()> { + let new_pos = self.pos.saturating_add(n); + self.seek(field, new_pos) + } + + pub fn take(&mut self, field: &'static str, n: usize) -> Result<&'a [u8]> { + if self.pos + n > self.buf.len() { + return Err(ParseError::capture_from_slice( + self.buf, + self.base_offset, + self.pos, + field, + format!( + "unexpected EOF: wanted {n} bytes, remaining {}", + self.remaining() + ), + Box::new(io::Error::from(io::ErrorKind::UnexpectedEof)), + )); + } + let start = self.pos; + let end = start + n; + self.pos = end; + Ok(&self.buf[start..end]) + } + + pub fn peek(&self, field: &'static str, n: usize) -> Result<&'a [u8]> { + if self.pos + n > self.buf.len() { + return Err(ParseError::capture_from_slice( + self.buf, + self.base_offset, + self.pos, + field, + format!( + "unexpected EOF: wanted {n} bytes, remaining {}", + self.remaining() + ), + Box::new(io::Error::from(io::ErrorKind::UnexpectedEof)), + )); + } + Ok(&self.buf[self.pos..(self.pos + n)]) + } + + pub fn u8(&mut self, field: &'static str) -> Result { + Ok(*self.take(field, 1)?.first().expect("len=1")) + } + + pub fn u16_le(&mut self, field: &'static str) -> Result { + let bytes = self.take(field, 2)?; + (&mut &*bytes) + .read_u16::() + .map_err(|e| self.wrap_io(field, e)) + } + + pub fn u32_le(&mut self, field: &'static str) -> Result { + let bytes = self.take(field, 4)?; + (&mut &*bytes) + .read_u32::() + .map_err(|e| self.wrap_io(field, e)) + } + + pub fn u64_le(&mut self, field: &'static str) -> Result { + let bytes = self.take(field, 8)?; + (&mut &*bytes) + .read_u64::() + .map_err(|e| self.wrap_io(field, e)) + } + + fn wrap_io(&self, field: &'static str, e: io::Error) -> ParseError { + ParseError::capture_from_slice( + self.buf, + self.base_offset, + self.pos, + field, + "failed to decode integer", + Box::new(e), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn take_and_seek() { + let buf = [1_u8, 2, 3, 4, 5]; + let mut r = Reader::with_base_offset(&buf, 0x1000); + + assert_eq!(r.u8("a").unwrap(), 1); + assert_eq!(r.position(), 1); + assert_eq!(r.take("b", 2).unwrap(), &[2, 3]); + assert_eq!(r.position(), 3); + + r.seek("rewind", 0).unwrap(); + assert_eq!(r.u16_le("u16").unwrap(), 0x0201); + } + + #[test] + fn oob_reports_offset() { + let buf = [0_u8; 4]; + let mut r = Reader::with_base_offset(&buf, 0x2000); + let err = r.u64_le("too_big").unwrap_err(); + assert_eq!(err.offset(), 0x2000); + assert_eq!(err.field(), "too_big"); + assert!(err.hexdump().contains("0x00002000")); + } +} diff --git a/crates/ntfs/src/tools/mod.rs b/crates/ntfs/src/tools/mod.rs new file mode 100644 index 0000000..26a3ae8 --- /dev/null +++ b/crates/ntfs/src/tools/mod.rs @@ -0,0 +1,3 @@ +//! CLI tooling + mount frontends. + +pub mod mount; diff --git a/crates/ntfs/src/tools/mount/dokan.rs b/crates/ntfs/src/tools/mount/dokan.rs new file mode 100644 index 0000000..b33c222 --- /dev/null +++ b/crates/ntfs/src/tools/mount/dokan.rs @@ -0,0 +1,300 @@ +//! Dokan frontend (Windows) for mounting an NTFS image via the mount-agnostic [`super::vfs::Vfs`]. +//! +//! This is intentionally **read-only**. + +use super::vfs::{ROOT_ENTRY_ID, Vfs}; +use crate::image::ReadAt; +use crate::ntfs::Error; +use dokan::{ + CreateFileInfo, DiskSpaceInfo, Drive, FileInfo, FileSystemHandler, MountFlags, OperationError, + OperationInfo, VolumeInfo, +}; +use std::sync::Arc; +use std::time::SystemTime; +use widestring::{U16CStr, U16CString}; +use windows::Win32::Foundation::{ + ERROR_ACCESS_DENIED, ERROR_FILE_NOT_FOUND, ERROR_INVALID_PARAMETER, +}; +use windows::Win32::Storage::FileSystem::{ + FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_READONLY, +}; + +// ZwCreateFile create dispositions (ntifs.h) +const FILE_OPEN: u32 = 1; +const FILE_OPEN_IF: u32 = 3; + +#[derive(Debug, Clone)] +struct NtfsDokan { + vfs: Vfs, +} + +#[derive(Debug, Clone)] +struct NtfsContext { + entry_id: u64, + is_dir: bool, + stream: Option>, + len: u64, +} + +pub fn mount( + vfs: Vfs, + mount_point: &str, + thread_count: u16, + debug: bool, +) -> Result<(), dokan::MountError> { + let handler = NtfsDokan { vfs }; + + let mount_point = + U16CString::from_str(mount_point).map_err(|_e| dokan::MountError::MountPointError)?; + + let mut flags = MountFlags::WRITE_PROTECT; + if debug { + flags |= MountFlags::DEBUG | MountFlags::STDERR; + } + + let mut drive = Drive::new(); + drive.mount_point(&mount_point); + drive.thread_count(thread_count); + drive.flags(flags); + + // Informational: match the underlying NTFS parameters when available. + let h = &handler.vfs.fs().volume().header; + drive.sector_size(h.bytes_per_sector as u32); + drive.allocation_unit_size(h.cluster_size); + + drive.mount(&handler) +} + +impl<'a, 'b: 'a> FileSystemHandler<'a, 'b> for NtfsDokan { + type Context = NtfsContext; + + fn create_file( + &'b self, + file_name: &U16CStr, + _security_context: &dokan::DOKAN_IO_SECURITY_CONTEXT, + _desired_access: u32, + _file_attributes: u32, + _share_access: u32, + create_disposition: u32, + _create_options: u32, + _info: &mut OperationInfo<'a, 'b, Self>, + ) -> Result, OperationError> { + // Read-only: allow opening existing objects only. + if create_disposition != FILE_OPEN && create_disposition != FILE_OPEN_IF { + return Err(OperationError::Win32(ERROR_ACCESS_DENIED.0)); + } + + let path = normalize_dokan_path(file_name); + let entry_id = if path == "\\" { + ROOT_ENTRY_ID + } else { + self.vfs + .resolve_path(&path) + .map_err(|_| OperationError::Win32(ERROR_FILE_NOT_FOUND.0))? + }; + + let meta = self + .vfs + .metadata(entry_id) + .map_err(|_| OperationError::Win32(ERROR_FILE_NOT_FOUND.0))?; + + let (stream, len) = if meta.is_dir { + (None, 0) + } else { + let s = self + .vfs + .open_file_default_stream(entry_id) + .map_err(map_ntfs_error)?; + let len = s.len(); + (Some(s), len) + }; + + Ok(CreateFileInfo { + context: NtfsContext { + entry_id, + is_dir: meta.is_dir, + stream, + len, + }, + is_dir: meta.is_dir, + new_file_created: false, + }) + } + + fn read_file( + &'b self, + _file_name: &U16CStr, + offset: i64, + buffer: &mut [u8], + _info: &OperationInfo<'a, 'b, Self>, + context: &'a Self::Context, + ) -> Result { + if context.is_dir { + return Err(OperationError::Win32(ERROR_ACCESS_DENIED.0)); + } + if offset < 0 { + return Err(OperationError::Win32(ERROR_INVALID_PARAMETER.0)); + } + let Some(stream) = context.stream.as_ref() else { + return Err(OperationError::Win32(ERROR_ACCESS_DENIED.0)); + }; + + let off = offset as u64; + if off >= context.len || buffer.is_empty() { + return Ok(0); + } + + let want = (buffer.len() as u64).min(context.len - off) as usize; + stream + .read_exact_at(off, &mut buffer[..want]) + .map_err(|e| OperationError::Win32(map_io_error_to_win32(e).0))?; + Ok(want as u32) + } + + fn get_file_information( + &'b self, + _file_name: &U16CStr, + _info: &OperationInfo<'a, 'b, Self>, + context: &'a Self::Context, + ) -> Result { + let meta = self + .vfs + .metadata(context.entry_id) + .map_err(map_ntfs_error)?; + + let attributes = if meta.is_dir { + FILE_ATTRIBUTE_DIRECTORY.0 | FILE_ATTRIBUTE_READONLY.0 + } else { + FILE_ATTRIBUTE_NORMAL.0 | FILE_ATTRIBUTE_READONLY.0 + }; + + Ok(FileInfo { + attributes, + creation_time: meta.created, + last_access_time: meta.accessed, + last_write_time: meta.modified, + file_size: meta.size, + number_of_links: 1, + file_index: context.entry_id, + }) + } + + fn find_files( + &'b self, + _file_name: &U16CStr, + mut fill_find_data: impl FnMut(&dokan::FindData) -> Result<(), dokan::FillDataError>, + _info: &OperationInfo<'a, 'b, Self>, + context: &'a Self::Context, + ) -> Result<(), OperationError> { + if !context.is_dir { + return Err(OperationError::Win32(ERROR_ACCESS_DENIED.0)); + } + + // Include dot entries (helps some callers). + for name in [".", ".."] { + let fd = dokan::FindData { + attributes: FILE_ATTRIBUTE_DIRECTORY.0 | FILE_ATTRIBUTE_READONLY.0, + creation_time: SystemTime::UNIX_EPOCH, + last_access_time: SystemTime::UNIX_EPOCH, + last_write_time: SystemTime::UNIX_EPOCH, + file_size: 0, + file_name: U16CString::from_str(name) + .map_err(|_| OperationError::Win32(ERROR_INVALID_PARAMETER.0))?, + }; + fill_find_data(&fd)?; + } + + let entries = self.vfs.readdir(context.entry_id).map_err(map_ntfs_error)?; + for e in entries { + if e.name == "." || e.name == ".." { + continue; + } + let meta = self.vfs.metadata(e.entry_id).map_err(map_ntfs_error)?; + + let attributes = if meta.is_dir { + FILE_ATTRIBUTE_DIRECTORY.0 | FILE_ATTRIBUTE_READONLY.0 + } else { + FILE_ATTRIBUTE_NORMAL.0 | FILE_ATTRIBUTE_READONLY.0 + }; + + let fd = dokan::FindData { + attributes, + creation_time: meta.created, + last_access_time: meta.accessed, + last_write_time: meta.modified, + file_size: meta.size, + file_name: U16CString::from_str(&e.name) + .map_err(|_| OperationError::Win32(ERROR_INVALID_PARAMETER.0))?, + }; + fill_find_data(&fd)?; + } + + Ok(()) + } + + fn get_disk_free_space( + &'b self, + _info: &OperationInfo<'a, 'b, Self>, + ) -> Result { + let h = &self.vfs.fs().volume().header; + let bytes = h.volume_size_bytes(); + Ok(DiskSpaceInfo { + byte_count: bytes, + free_byte_count: 0, + available_byte_count: 0, + }) + } + + fn get_volume_information( + &'b self, + _info: &OperationInfo<'a, 'b, Self>, + ) -> Result { + let h = &self.vfs.fs().volume().header; + let name = U16CString::from_str("ntfs") + .map_err(|_| OperationError::Win32(ERROR_INVALID_PARAMETER.0))?; + let fs_name = U16CString::from_str("NTFS") + .map_err(|_| OperationError::Win32(ERROR_INVALID_PARAMETER.0))?; + + Ok(VolumeInfo { + name, + serial_number: (h.volume_serial_number & 0xffff_ffff) as u32, + max_component_length: 255, + fs_flags: 0, + fs_name, + }) + } +} + +fn normalize_dokan_path(s: &U16CStr) -> String { + // Dokan passes paths with a leading backslash. Keep NTFS path semantics. + let mut p = s.to_string_lossy().to_string(); + if p.is_empty() { + p.push('\\'); + } + if !p.starts_with('\\') { + p.insert(0, '\\'); + } + p +} + +fn map_ntfs_error(e: Error) -> OperationError { + match e { + Error::NotFound { .. } => OperationError::Win32(ERROR_FILE_NOT_FOUND.0), + Error::Unsupported { .. } => OperationError::Win32(ERROR_ACCESS_DENIED.0), + Error::Io(ioe) => OperationError::Win32(map_io_error_to_win32(ioe).0), + _ => OperationError::Win32(ERROR_ACCESS_DENIED.0), + } +} + +fn map_io_error_to_win32(e: std::io::Error) -> windows::Win32::Foundation::WIN32_ERROR { + use windows::Win32::Foundation::{ + ERROR_ACCESS_DENIED, ERROR_FILE_NOT_FOUND, ERROR_GEN_FAILURE, ERROR_INVALID_PARAMETER, + }; + + match e.kind() { + std::io::ErrorKind::NotFound => ERROR_FILE_NOT_FOUND, + std::io::ErrorKind::PermissionDenied => ERROR_ACCESS_DENIED, + std::io::ErrorKind::InvalidInput => ERROR_INVALID_PARAMETER, + _ => ERROR_GEN_FAILURE, + } +} diff --git a/crates/ntfs/src/tools/mount/fuse.rs b/crates/ntfs/src/tools/mount/fuse.rs new file mode 100644 index 0000000..9ad114e --- /dev/null +++ b/crates/ntfs/src/tools/mount/fuse.rs @@ -0,0 +1,372 @@ +//! FUSE frontend (Unix) for mounting an NTFS image via the mount-agnostic [`super::vfs::Vfs`]. +//! +//! This is intentionally **read-only**. + +use super::vfs::{ROOT_ENTRY_ID, Vfs}; +use crate::image::ReadAt; +use crate::ntfs::Error; +use crate::ntfs::filesystem::is_dot_dir_entry; +use fuser::{ + FileAttr, FileType, Filesystem, MountOption, ReplyAttr, ReplyData, ReplyDirectory, ReplyEmpty, + ReplyEntry, ReplyOpen, ReplyStatfs, Request, +}; +use std::collections::HashMap; +use std::ffi::OsStr; +use std::path::Path; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const TTL: Duration = Duration::from_secs(1); + +pub fn mount(vfs: Vfs, mountpoint: &Path) -> std::io::Result<()> { + // Keep options minimal and safe. Let the user configure kernel-side permission checks. + let opts = [MountOption::RO, MountOption::FSName("ntfs".to_string())]; + fuser::mount2(NtfsFuse::new(vfs), mountpoint, &opts) +} + +#[derive(Debug)] +struct NtfsFuse { + vfs: Vfs, + + // Stable inode mapping (required because FUSE expects root inode = 1). + entry_to_ino: HashMap, + ino_to_entry: HashMap, + next_ino: u64, + + // Open file handles -> stream + fh_to_stream: HashMap>, + next_fh: u64, +} + +impl NtfsFuse { + fn new(vfs: Vfs) -> Self { + let mut entry_to_ino = HashMap::new(); + let mut ino_to_entry = HashMap::new(); + + entry_to_ino.insert(ROOT_ENTRY_ID, 1); + ino_to_entry.insert(1, ROOT_ENTRY_ID); + + Self { + vfs, + entry_to_ino, + ino_to_entry, + next_ino: 2, + fh_to_stream: HashMap::new(), + next_fh: 1, + } + } + + fn ino_to_entry_id(&self, ino: u64) -> Option { + self.ino_to_entry.get(&ino).copied() + } + + fn entry_id_to_ino(&mut self, entry_id: u64) -> u64 { + if let Some(&ino) = self.entry_to_ino.get(&entry_id) { + return ino; + } + + let ino = self.next_ino; + self.next_ino = self.next_ino.saturating_add(1); + + self.entry_to_ino.insert(entry_id, ino); + self.ino_to_entry.insert(ino, entry_id); + ino + } + + fn to_file_attr( + &mut self, + ino: u64, + meta: super::vfs::EntryMetadata, + req: &Request<'_>, + ) -> FileAttr { + let kind = if meta.is_dir { + FileType::Directory + } else { + FileType::RegularFile + }; + + let perm = if meta.is_dir { 0o555 } else { 0o444 }; + let size = meta.size; + let blocks = size.div_ceil(512); + + FileAttr { + ino, + size, + blocks, + atime: meta.accessed, + mtime: meta.modified, + ctime: meta.mft_modified, + crtime: meta.created, + kind, + perm, + nlink: if meta.is_dir { 2 } else { 1 }, + uid: req.uid(), + gid: req.gid(), + rdev: 0, + blksize: 512, + flags: 0, + } + } +} + +impl Filesystem for NtfsFuse { + fn lookup(&mut self, req: &Request<'_>, parent: u64, name: &OsStr, reply: ReplyEntry) { + let Some(parent_entry) = self.ino_to_entry_id(parent) else { + reply.error(libc::ENOENT); + return; + }; + let Some(name) = name.to_str() else { + reply.error(libc::ENOENT); + return; + }; + if name.is_empty() || name == "." { + let ino = parent; + let Ok(meta) = self.vfs.metadata(parent_entry) else { + reply.error(libc::EIO); + return; + }; + let attr = self.to_file_attr(ino, meta, req); + reply.entry(&TTL, &attr, 0); + return; + } + + match self.vfs.lookup(parent_entry, name) { + Ok(child_entry) => match self.vfs.metadata(child_entry) { + Ok(meta) => { + let ino = self.entry_id_to_ino(child_entry); + let attr = self.to_file_attr(ino, meta, req); + reply.entry(&TTL, &attr, 0); + } + Err(e) => reply.error(err_to_errno(&e)), + }, + Err(e) => reply.error(err_to_errno(&e)), + } + } + + fn getattr(&mut self, req: &Request<'_>, ino: u64, _fh: Option, reply: ReplyAttr) { + let Some(entry_id) = self.ino_to_entry_id(ino) else { + reply.error(libc::ENOENT); + return; + }; + + match self.vfs.metadata(entry_id) { + Ok(meta) => { + let attr = self.to_file_attr(ino, meta, req); + reply.attr(&TTL, &attr); + } + Err(e) => reply.error(err_to_errno(&e)), + } + } + + fn open(&mut self, _req: &Request<'_>, ino: u64, flags: i32, reply: ReplyOpen) { + let Some(entry_id) = self.ino_to_entry_id(ino) else { + reply.error(libc::ENOENT); + return; + }; + + if (flags & libc::O_ACCMODE) != libc::O_RDONLY { + reply.error(libc::EACCES); + return; + } + + let Ok(meta) = self.vfs.metadata(entry_id) else { + reply.error(libc::EIO); + return; + }; + if meta.is_dir { + reply.error(libc::EISDIR); + return; + } + + match self.vfs.open_file_default_stream(entry_id) { + Ok(stream) => { + let fh = self.next_fh; + self.next_fh = self.next_fh.saturating_add(1); + self.fh_to_stream.insert(fh, stream); + reply.opened(fh, 0); + } + Err(e) => reply.error(err_to_errno(&e)), + } + } + + fn read( + &mut self, + _req: &Request<'_>, + _ino: u64, + fh: u64, + offset: i64, + size: u32, + _flags: i32, + _lock_owner: Option, + reply: ReplyData, + ) { + let Some(stream) = self.fh_to_stream.get(&fh) else { + reply.error(libc::EBADF); + return; + }; + + if offset < 0 { + reply.error(libc::EINVAL); + return; + } + + let offset = offset as u64; + let len = stream.len(); + if offset >= len { + reply.data(&[]); + return; + } + + let want = (size as u64).min(len - offset); + let mut buf = vec![0u8; want as usize]; + if let Err(e) = stream.read_exact_at(offset, &mut buf) { + reply.error(err_to_errno(&Error::Io(e))); + return; + } + + reply.data(&buf); + } + + fn release( + &mut self, + _req: &Request<'_>, + _ino: u64, + fh: u64, + _flags: i32, + _lock_owner: Option, + _flush: bool, + reply: ReplyEmpty, + ) { + self.fh_to_stream.remove(&fh); + reply.ok(); + } + + fn opendir(&mut self, _req: &Request<'_>, ino: u64, _flags: i32, reply: ReplyOpen) { + let Some(entry_id) = self.ino_to_entry_id(ino) else { + reply.error(libc::ENOENT); + return; + }; + match self.vfs.metadata(entry_id) { + Ok(meta) if meta.is_dir => reply.opened(0, 0), + Ok(_) => reply.error(libc::ENOTDIR), + Err(e) => reply.error(err_to_errno(&e)), + } + } + + fn readdir( + &mut self, + req: &Request<'_>, + ino: u64, + _fh: u64, + offset: i64, + mut reply: ReplyDirectory, + ) { + let Some(dir_entry_id) = self.ino_to_entry_id(ino) else { + reply.error(libc::ENOENT); + return; + }; + + let Ok(meta) = self.vfs.metadata(dir_entry_id) else { + reply.error(libc::EIO); + return; + }; + if !meta.is_dir { + reply.error(libc::ENOTDIR); + return; + } + + let mut idx = 0i64; + + // offset semantics: caller passes the "next" offset to resume from. + // We use: + // - 1 => "." + // - 2 => ".." + // - 3+ => directory entries + if offset <= 0 { + let _full = reply.add(ino, 1, FileType::Directory, "."); + idx = 1; + } + if offset <= 1 { + let _full = reply.add(ino, 2, FileType::Directory, ".."); + idx = 2; + } + + let entries = match self.vfs.readdir(dir_entry_id) { + Ok(v) => v, + Err(e) => { + reply.error(err_to_errno(&e)); + return; + } + }; + + let mut off = 3i64; + for e in entries { + if is_dot_dir_entry(&e.name) { + continue; + } + + if off < offset { + off += 1; + continue; + } + + let child_ino = self.entry_id_to_ino(e.entry_id); + let kind = match self.vfs.metadata(e.entry_id) { + Ok(m) => { + if m.is_dir { + FileType::Directory + } else { + FileType::RegularFile + } + } + Err(_) => FileType::RegularFile, + }; + + // `add` returns `true` if the buffer is full. + if reply.add(child_ino, off + 1, kind, e.name) { + break; + } + off += 1; + } + + // Ensure reply is finalized. + reply.ok(); + + // Keep `req` used (some builds warn on unused in certain cfg combos). + let _ = req.uid(); + let _ = idx; + } + + fn statfs(&mut self, _req: &Request<'_>, _ino: u64, reply: ReplyStatfs) { + // Best-effort: provide minimal statfs. + // Values are mostly informational for read-only forensic mounts. + reply.statfs(0, 0, 0, 0, 0, 512, 255, 0); + } +} + +fn err_to_errno(e: &Error) -> i32 { + match e { + Error::NotFound { .. } => libc::ENOENT, + Error::Unsupported { .. } => libc::EOPNOTSUPP, + Error::InvalidBootSector { .. } => libc::EIO, + Error::InvalidData { .. } => libc::EIO, + Error::Parse(_) => libc::EIO, + Error::Mft(_) => libc::EIO, + Error::Io(ioe) => match ioe.kind() { + std::io::ErrorKind::NotFound => libc::ENOENT, + std::io::ErrorKind::PermissionDenied => libc::EACCES, + std::io::ErrorKind::UnexpectedEof => libc::EIO, + std::io::ErrorKind::InvalidInput => libc::EINVAL, + _ => libc::EIO, + }, + } +} + +fn _system_time_or_epoch(x: SystemTime) -> SystemTime { + // Placeholder for future: normalize weird timestamps if needed. + if x == SystemTime::UNIX_EPOCH { + UNIX_EPOCH + } else { + x + } +} diff --git a/crates/ntfs/src/tools/mount/mod.rs b/crates/ntfs/src/tools/mount/mod.rs new file mode 100644 index 0000000..6e48f9a --- /dev/null +++ b/crates/ntfs/src/tools/mount/mod.rs @@ -0,0 +1,12 @@ +//! Mount frontends (FUSE/Dokan) + a mount-agnostic VFS core. +//! +//! The goal is to keep OS-specific glue thin and concentrate NTFS-specific behavior (path +//! resolution, directory listing, file reads including compression + EFS) in `vfs`. + +pub mod vfs; + +#[cfg(all(feature = "fuse", target_os = "linux"))] +pub mod fuse; + +#[cfg(all(feature = "dokan", windows))] +pub mod dokan; diff --git a/crates/ntfs/src/tools/mount/vfs.rs b/crates/ntfs/src/tools/mount/vfs.rs new file mode 100644 index 0000000..dbdd707 --- /dev/null +++ b/crates/ntfs/src/tools/mount/vfs.rs @@ -0,0 +1,169 @@ +use crate::image::ReadAt; +use crate::ntfs::efs::EfsRsaKeyBag; +use crate::ntfs::filesystem::DirectoryEntry; +use crate::ntfs::{Error, FileSystem, Result}; +use jiff::Timestamp; +use mft::attribute::MftAttributeType; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// NTFS root directory MFT entry id. +pub const ROOT_ENTRY_ID: u64 = 5; + +#[derive(Debug, Clone)] +pub struct Vfs { + fs: FileSystem, + strict: bool, + efs_keys: Option>, +} + +#[derive(Debug, Clone)] +pub struct EntryMetadata { + pub is_dir: bool, + pub size: u64, + pub readonly: bool, + pub created: SystemTime, + pub modified: SystemTime, + pub mft_modified: SystemTime, + pub accessed: SystemTime, +} + +impl Vfs { + pub fn new(fs: FileSystem) -> Self { + Self { + fs, + strict: false, + efs_keys: None, + } + } + + pub fn fs(&self) -> &FileSystem { + &self.fs + } + + /// If `true`, directory traversal does **not** fall back to MFT parent-reference scans when + /// `$I30` structures are missing/corrupt. + pub fn with_strict(mut self, strict: bool) -> Self { + self.strict = strict; + self + } + + /// If provided, EFS-encrypted file reads are transparently decrypted. + pub fn with_efs_keys(mut self, keys: Option) -> Self { + self.efs_keys = keys.map(Arc::new); + self + } + + pub fn root_entry_id(&self) -> u64 { + ROOT_ENTRY_ID + } + + pub fn resolve_path(&self, path: &str) -> Result { + if self.strict { + self.fs.resolve_path_strict(path) + } else { + self.fs.resolve_path(path) + } + } + + pub fn lookup(&self, dir_entry_id: u64, name: &str) -> Result { + if self.strict { + self.fs.lookup_in_dir_strict(dir_entry_id, name) + } else { + self.fs.lookup_in_dir(dir_entry_id, name) + } + } + + pub fn readdir(&self, dir_entry_id: u64) -> Result> { + if self.strict { + self.fs.read_dir_strict(dir_entry_id) + } else { + self.fs.read_dir(dir_entry_id) + } + } + + pub fn metadata(&self, entry_id: u64) -> Result { + let entry = self.fs.volume().read_mft_entry(entry_id)?; + let is_dir = entry.is_dir(); + + // Timestamps + readonly from $STANDARD_INFORMATION (strict: require it). + let mut si = None; + for attr in + entry.iter_attributes_matching(Some(vec![MftAttributeType::StandardInformation])) + { + let attr = attr?; + if let Some(x) = attr.data.into_standard_info() { + si = Some(x); + break; + } + } + let si = si.ok_or_else(|| Error::NotFound { + what: format!("missing $STANDARD_INFORMATION for entry {entry_id}"), + })?; + + let readonly = si + .file_flags + .contains(mft::attribute::FileAttributeFlags::FILE_ATTRIBUTE_READONLY); + + let created = timestamp_to_system_time(&si.created); + let modified = timestamp_to_system_time(&si.modified); + let mft_modified = timestamp_to_system_time(&si.mft_modified); + let accessed = timestamp_to_system_time(&si.accessed); + + // Use the default $DATA stream size for files; directories report 0. + let size = if is_dir { + 0 + } else { + // For metadata, do not require EFS keys; size is well-defined either way. + self.fs + .open_file_default_stream_read_at(entry_id) + .map(|s| s.len()) + .unwrap_or(0) + }; + + Ok(EntryMetadata { + is_dir, + size, + readonly, + created, + modified, + mft_modified, + accessed, + }) + } + + /// Opens the default unnamed `$DATA` stream as a random-access reader. + /// + /// If the file is EFS-encrypted, this returns plaintext **only if** keys were provided via + /// [`with_efs_keys`]. Otherwise, this returns an error. + pub fn open_file_default_stream(&self, entry_id: u64) -> Result> { + // If encrypted, we require keys (to avoid silently serving ciphertext under a mount). + if self.fs.is_entry_efs_encrypted(entry_id)? { + let Some(keys) = self.efs_keys.as_ref() else { + return Err(Error::Unsupported { + what: "EFS-encrypted file read requires --pfx (PKCS#12)".to_string(), + }); + }; + return self + .fs + .open_file_default_stream_read_at_decrypted(entry_id, keys.as_ref()); + } + + self.fs.open_file_default_stream_read_at(entry_id) + } +} + +fn timestamp_to_system_time(ts: &Timestamp) -> SystemTime { + let secs = ts.as_second(); + let nanos = ts.subsec_nanosecond() as u32; + + if secs >= 0 { + UNIX_EPOCH + Duration::new(secs as u64, nanos) + } else { + // Best-effort for pre-epoch timestamps. + let secs_abs = secs.unsigned_abs(); + UNIX_EPOCH + .checked_sub(Duration::new(secs_abs, nanos)) + .unwrap_or(UNIX_EPOCH) + } +} diff --git a/crates/ntfs/tests/common/mod.rs b/crates/ntfs/tests/common/mod.rs new file mode 100644 index 0000000..f834157 --- /dev/null +++ b/crates/ntfs/tests/common/mod.rs @@ -0,0 +1,68 @@ +#![allow(dead_code)] + +use std::env; +use std::fs::File; +use std::io::Read; +use std::path::{Path, PathBuf}; + +pub fn ntfs_fixture_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../testdata/ntfs") +} + +pub fn ntfs_fixture_path(name: &str) -> PathBuf { + ntfs_fixture_root().join(name) +} + +pub fn undelete7_fixture_path(name: &str) -> PathBuf { + ntfs_fixture_root().join("7-undel-ntfs").join(name) +} + +fn is_git_lfs_pointer(path: &Path) -> bool { + let Ok(mut f) = File::open(path) else { + return false; + }; + + let mut buf = [0u8; 200]; + let Ok(n) = f.read(&mut buf) else { + return false; + }; + + let Ok(s) = std::str::from_utf8(&buf[..n]) else { + return false; + }; + + s.starts_with("version https://git-lfs.github.com/spec/v1") +} + +fn fixture_missing_behavior(msg: &str) -> bool { + if env::var_os("NTFS_TESTDATA_REQUIRED").is_some() { + panic!("{msg}"); + } + + eprintln!("{msg}"); + false +} + +/// Returns `true` iff `path` exists and is not a Git LFS pointer file. +/// +/// If `NTFS_TESTDATA_REQUIRED=1` is set, missing/placeholder fixtures will **panic** (useful for CI). +/// Otherwise, the caller should `return` early from the test to skip it. +pub fn ensure_fixture(path: &Path) -> bool { + if !path.exists() { + return fixture_missing_behavior(&format!( + "skipping: missing test fixture `{}` (did you forget to fetch testdata?)", + path.display() + )); + } + + if is_git_lfs_pointer(path) { + return fixture_missing_behavior(&format!( + "skipping: `{}` looks like a Git LFS pointer file; run `git lfs install && git lfs pull`", + path.display() + )); + } + + true +} + + diff --git a/crates/ntfs/tests/test_dir_traversal.rs b/crates/ntfs/tests/test_dir_traversal.rs new file mode 100644 index 0000000..5ad2063 --- /dev/null +++ b/crates/ntfs/tests/test_dir_traversal.rs @@ -0,0 +1,40 @@ +use ntfs::image::AffImage; +use ntfs::ntfs::{FileSystem, Volume}; +use std::collections::HashSet; +use std::sync::Arc; + +mod common; + +#[test] +fn test_root_directory_contains_expected_directories() { + let path = common::ntfs_fixture_path("ntfs1-gen0.aff"); + if !common::ensure_fixture(&path) { + return; + } + let img = AffImage::open(path).unwrap(); + let volume = Volume::open(Arc::new(img), 0).unwrap(); + let fs = FileSystem::new(volume); + + let entries = fs.read_dir(5).unwrap(); + let names: HashSet = entries.into_iter().map(|e| e.name).collect(); + + assert!(names.iter().any(|n| n.eq_ignore_ascii_case("Raw"))); + assert!(names.iter().any(|n| n.eq_ignore_ascii_case("Compressed"))); + assert!(names.iter().any(|n| n.eq_ignore_ascii_case("Encrypted"))); +} + +#[test] +fn test_resolve_path_raw_directory() { + // gen0 has an empty Raw directory; use gen1 to validate non-empty directory traversal. + let path = common::ntfs_fixture_path("ntfs1-gen1.aff"); + if !common::ensure_fixture(&path) { + return; + } + let img = AffImage::open(path).unwrap(); + let volume = Volume::open(Arc::new(img), 0).unwrap(); + let fs = FileSystem::new(volume); + + let raw_id = fs.resolve_path("\\Raw").unwrap(); + let entries = fs.read_dir(raw_id).unwrap(); + assert!(!entries.is_empty()); +} diff --git a/crates/ntfs/tests/test_efs_decrypt.rs b/crates/ntfs/tests/test_efs_decrypt.rs new file mode 100644 index 0000000..725ed23 --- /dev/null +++ b/crates/ntfs/tests/test_efs_decrypt.rs @@ -0,0 +1,111 @@ +use mft::attribute::header::ResidentialHeader; +use mft::attribute::MftAttributeType; +use ntfs::image::EwfImage; +use ntfs::ntfs::efs::{EfsMetadataV1, EfsRsaKeyBag}; +use ntfs::ntfs::{FileSystem, Volume}; +use std::sync::Arc; + +mod common; + +#[test] +fn test_efs_decrypt_reports_thumbprint_mismatch_against_fixture_pfx() { + let img_path = common::ntfs_fixture_path("ntfs1-gen2.E01"); + if !common::ensure_fixture(&img_path) { + return; + } + let img = EwfImage::open(img_path).unwrap(); + let volume = Volume::open(Arc::new(img), 0).unwrap(); + let fs = FileSystem::new(volume); + + // The fixture includes a PFX in the image root. The password is documented in + // `EFS-key-info.txt`. + let pfx_id = fs.resolve_path("\\EFS-key-password.pfx").unwrap(); + let pfx = fs.read_file_default_stream(pfx_id).unwrap(); + let keys = EfsRsaKeyBag::from_pkcs12_der(&pfx, Some("password")).unwrap(); + + let encrypted_id = fs.resolve_path("\\Encrypted\\NIST_logo.jpg").unwrap(); + let entry = fs.volume().read_mft_entry(encrypted_id).unwrap(); + + // Extract and parse `$EFS` metadata so we can compare the DDF thumbprint(s) with the PFX + // thumbprint(s). + let attr = entry + .iter_attributes_matching(Some(vec![MftAttributeType::LoggedUtilityStream])) + .filter_map(std::result::Result::ok) + .find(|a| a.header.name == "$EFS") + .expect("missing $EFS attribute"); + + let efs_blob = match &attr.header.residential_header { + ResidentialHeader::Resident(rh) => { + let start = attr.header.start_offset as usize + rh.data_offset as usize; + let end = start + rh.data_size as usize; + entry.data[start..end].to_vec() + } + ResidentialHeader::NonResident(nr) => { + let runs = attr.data.clone().into_data_runs().unwrap().data_runs; + let mut buf = vec![0u8; nr.file_size as usize]; + ntfs::ntfs::data_stream::read_from_data_runs(fs.volume(), &runs, 0, &mut buf).unwrap(); + buf + } + }; + + let meta = EfsMetadataV1::parse(&efs_blob, 0).unwrap(); + let ddf_tps = meta + .ddf + .iter() + .filter_map(|e| e.cert_thumbprint_sha1) + .map(hex::encode) + .collect::>(); + let pfx_tps = keys + .thumbprints() + .flatten() + .map(hex::encode) + .collect::>(); + + let err = fs + .read_file_default_stream_decrypted(encrypted_id, &keys) + .unwrap_err(); + let s = err.to_string(); + + // The failure mode should be deterministic and include both the DDF and PFX thumbprints. + for tp in ddf_tps { + assert!( + s.contains(&tp), + "expected decrypt error to include DDF thumbprint {tp}, got:\n{s}" + ); + } + for tp in pfx_tps { + assert!( + s.contains(&tp), + "expected decrypt error to include PFX thumbprint {tp}, got:\n{s}" + ); + } +} + +#[test] +fn test_efs_decrypt_logfile1_matches_raw_with_fixture_pfx() { + let img_path = common::ntfs_fixture_path("ntfs1-gen2.E01"); + if !common::ensure_fixture(&img_path) { + return; + } + let img = EwfImage::open(img_path).unwrap(); + let volume = Volume::open(Arc::new(img), 0).unwrap(); + let fs = FileSystem::new(volume); + + // The fixture includes a password-less PFX export in the image root. + let pfx_id = fs.resolve_path("\\EFS-key-no-password.pfx").unwrap(); + let pfx = fs.read_file_default_stream(pfx_id).unwrap(); + let keys = EfsRsaKeyBag::from_pkcs12_der(&pfx, None).unwrap(); + + // In gen2, `\\Encrypted\\logfile1.txt` uses the certificate whose SHA-1 thumbprint matches the + // exported PFX. Validate the end-to-end decrypt path by comparing against the plaintext copy + // in `\\Raw`. + let encrypted_id = fs.resolve_path("\\Encrypted\\logfile1.txt").unwrap(); + let raw_id = fs.resolve_path("\\Raw\\logfile1.txt").unwrap(); + + let raw = fs.read_file_default_stream(raw_id).unwrap(); + let decrypted = fs + .read_file_default_stream_decrypted(encrypted_id, &keys) + .unwrap(); + + assert_eq!(decrypted, raw); +} diff --git a/crates/ntfs/tests/test_efs_key_size.rs b/crates/ntfs/tests/test_efs_key_size.rs new file mode 100644 index 0000000..cc49a1a --- /dev/null +++ b/crates/ntfs/tests/test_efs_key_size.rs @@ -0,0 +1,56 @@ +use mft::attribute::header::ResidentialHeader; +use mft::attribute::MftAttributeType; +use ntfs::image::EwfImage; +use ntfs::ntfs::efs::{EfsMetadataV1, EfsRsaKeyBag}; +use ntfs::ntfs::{FileSystem, Volume}; +use std::sync::Arc; + +mod common; + +#[test] +fn test_efs_rsa_key_size_matches_ddf_ciphertext_size() { + let img_path = common::ntfs_fixture_path("ntfs1-gen2.E01"); + if !common::ensure_fixture(&img_path) { + return; + } + let img = EwfImage::open(img_path).unwrap(); + let volume = Volume::open(Arc::new(img), 0).unwrap(); + let fs = FileSystem::new(volume); + + let pfx_id = fs.resolve_path("\\EFS-key-no-password.pfx").unwrap(); + let pfx = fs.read_file_default_stream(pfx_id).unwrap(); + let keys = EfsRsaKeyBag::from_pkcs12_der(&pfx, None).unwrap(); + let rsa = keys.iter().next().unwrap(); + + let encrypted_id = fs.resolve_path("\\Encrypted\\NIST_logo.jpg").unwrap(); + let entry = fs.volume().read_mft_entry(encrypted_id).unwrap(); + let attr = entry + .iter_attributes_matching(Some(vec![MftAttributeType::LoggedUtilityStream])) + .filter_map(std::result::Result::ok) + .find(|a| a.header.name == "$EFS") + .unwrap(); + + let efs_blob = match &attr.header.residential_header { + ResidentialHeader::Resident(rh) => { + let start = attr.header.start_offset as usize + rh.data_offset as usize; + let end = start + rh.data_size as usize; + entry.data[start..end].to_vec() + } + ResidentialHeader::NonResident(nr) => { + let runs = attr.data.clone().into_data_runs().unwrap().data_runs; + let mut buf = vec![0u8; nr.file_size as usize]; + ntfs::ntfs::data_stream::read_from_data_runs(fs.volume(), &runs, 0, &mut buf) + .unwrap(); + buf + } + }; + + let meta = EfsMetadataV1::parse(&efs_blob, 0).unwrap(); + let first = meta.ddf.iter().find(|e| e.flags == 0).unwrap(); + + assert_eq!( + rsa.size() as u32, + first.encrypted_fek_length, + "RSA modulus size should match Encrypted FEK length" + ); +} diff --git a/crates/ntfs/tests/test_efs_metadata.rs b/crates/ntfs/tests/test_efs_metadata.rs new file mode 100644 index 0000000..3561e81 --- /dev/null +++ b/crates/ntfs/tests/test_efs_metadata.rs @@ -0,0 +1,52 @@ +use mft::attribute::header::ResidentialHeader; +use mft::attribute::MftAttributeType; +use ntfs::image::EwfImage; +use ntfs::ntfs::efs::EfsMetadataV1; +use ntfs::ntfs::{FileSystem, Volume}; +use std::sync::Arc; + +mod common; + +#[test] +fn test_parse_efs_metadata_v1_has_ddf_entries() { + let img_path = common::ntfs_fixture_path("ntfs1-gen2.E01"); + if !common::ensure_fixture(&img_path) { + return; + } + let img = EwfImage::open(img_path).unwrap(); + let volume = Volume::open(Arc::new(img), 0).unwrap(); + let fs = FileSystem::new(volume); + + let encrypted_id = fs.resolve_path("\\Encrypted\\NIST_logo.jpg").unwrap(); + let entry = fs.volume().read_mft_entry(encrypted_id).unwrap(); + + let attr = entry + .iter_attributes_matching(Some(vec![MftAttributeType::LoggedUtilityStream])) + .filter_map(std::result::Result::ok) + .find(|a| a.header.name == "$EFS") + .expect("missing $EFS attribute"); + + let efs_blob = match &attr.header.residential_header { + ResidentialHeader::Resident(rh) => { + let start = attr.header.start_offset as usize + rh.data_offset as usize; + let end = start + rh.data_size as usize; + entry.data[start..end].to_vec() + } + ResidentialHeader::NonResident(nr) => { + let runs = attr.data.clone().into_data_runs().unwrap().data_runs; + let mut buf = vec![0u8; nr.file_size as usize]; + ntfs::ntfs::data_stream::read_from_data_runs(fs.volume(), &runs, 0, &mut buf) + .unwrap(); + buf + } + }; + + let meta = EfsMetadataV1::parse(&efs_blob, 0).unwrap(); + assert!(!meta.ddf.is_empty(), "expected at least one DDF entry"); + + // The fixture should include an RSA-wrapped FEK (flags=0) in at least one entry. + assert!( + meta.ddf.iter().any(|e| e.flags == 0), + "expected at least one RSA-wrapped DDF entry" + ); +} diff --git a/crates/ntfs/tests/test_ewf_regressions.rs b/crates/ntfs/tests/test_ewf_regressions.rs new file mode 100644 index 0000000..3b88085 --- /dev/null +++ b/crates/ntfs/tests/test_ewf_regressions.rs @@ -0,0 +1,124 @@ +use ntfs::image::EwfImage; +use ntfs::ntfs::{FileSystem, Volume}; +use md5::{Digest as _, Md5}; +use sha1::Sha1; +use std::collections::{HashMap, HashSet}; +use std::fs::File; +use std::io::{self, BufRead, BufReader}; +use std::path::Path; +use std::sync::Arc; + +mod common; + +fn md5_hex(bytes: &[u8]) -> String { + hex::encode(Md5::digest(bytes)) +} + +fn sha1_hex(bytes: &[u8]) -> String { + hex::encode(Sha1::digest(bytes)) +} + +fn extract_tag_value(line: &str, tag: &str) -> Option { + let open = format!("<{tag}>"); + let close = format!(""); + let start = line.find(&open)? + open.len(); + let end = line[start..].find(&close)? + start; + Some(line[start..end].to_string()) +} + +/// Parses `ntfs1-gen2.xml` and returns `{ filename -> (md5, sha1) }` for the requested files. +fn read_reference_hashes( + path: &Path, + wanted: &[&str], +) -> io::Result> { + let wanted_set: HashSet<&str> = wanted.iter().copied().collect(); + let f = File::open(path)?; + let r = BufReader::new(f); + + let mut out: HashMap = HashMap::new(); + + let mut cur_filename: Option = None; + let mut cur_md5: Option = None; + let mut cur_sha1: Option = None; + + for line in r.lines() { + let line = line?; + if let Some(v) = extract_tag_value(&line, "filename") { + cur_filename = Some(v); + } + if let Some(v) = extract_tag_value(&line, "md5") { + cur_md5 = Some(v); + } + if let Some(v) = extract_tag_value(&line, "sha1") { + cur_sha1 = Some(v); + } + + if line.contains("") { + if let (Some(filename), Some(md5), Some(sha1)) = + (cur_filename.take(), cur_md5.take(), cur_sha1.take()) + { + if wanted_set.contains(filename.as_str()) { + out.insert(filename, (md5, sha1)); + } + } else { + cur_filename = None; + cur_md5 = None; + cur_sha1 = None; + } + } + } + + Ok(out) +} + +#[test] +fn test_ewf_gen2_file_hashes_match_reference_xml() { + // Pick a mix of: + // - small root directory files (including a non-resident PFX) + // - large files inside a directory + // - a compressed-directory file to ensure NTFS decompression and EWF offsets stay correct + let wanted = [ + "EFS-key-info.txt", + "EFS-key-no-password.pfx", + "EFS-key-password.pfx", + "EFS-key-password-strong-protection.pfx", + "RAW/NIST_logo.jpg", + "Compressed/NIST_logo.jpg", + ]; + + let xml_path = common::ntfs_fixture_path("ntfs1-gen2.xml"); + if !common::ensure_fixture(&xml_path) { + return; + } + let expected = read_reference_hashes(&xml_path, &wanted).expect("read reference hashes"); + assert_eq!( + expected.len(), + wanted.len(), + "reference xml did not contain all wanted files" + ); + + let img_path = common::ntfs_fixture_path("ntfs1-gen2.E01"); + if !common::ensure_fixture(&img_path) { + return; + } + let img = EwfImage::open(img_path).unwrap(); + let volume = Volume::open(Arc::new(img), 0).unwrap(); + let fs = FileSystem::new(volume); + + for filename in wanted { + let (md5_expected, sha1_expected) = expected + .get(filename) + .unwrap_or_else(|| panic!("missing expected hashes for {filename}")) + .clone(); + + let entry_id = fs.resolve_path(filename).unwrap(); + let bytes = fs.read_file_default_stream(entry_id).unwrap(); + + assert_eq!(md5_hex(&bytes), md5_expected, "md5 mismatch for {filename}"); + assert_eq!( + sha1_hex(&bytes), + sha1_expected, + "sha1 mismatch for {filename}" + ); + } +} diff --git a/crates/ntfs/tests/test_file_reads.rs b/crates/ntfs/tests/test_file_reads.rs new file mode 100644 index 0000000..193b65b --- /dev/null +++ b/crates/ntfs/tests/test_file_reads.rs @@ -0,0 +1,135 @@ +use mft::attribute::header::ResidentialHeader; +use mft::attribute::{AttributeDataFlags, MftAttributeType}; +use ntfs::image::AffImage; +use ntfs::ntfs::{FileSystem, Volume}; +use std::collections::HashMap; +use std::sync::Arc; + +mod common; + +fn build_name_map( + entries: Vec, +) -> HashMap { + let mut m = HashMap::new(); + for e in entries { + if e.name == "." || e.name.starts_with('$') || e.name.contains('~') { + continue; + } + m.insert(e.name.clone(), e.entry_id); + } + m +} + +fn data_info(fs: &FileSystem, entry_id: u64) -> (u64, bool) { + let entry = fs.volume().read_mft_entry(entry_id).unwrap(); + let attr = entry + .iter_attributes_matching(Some(vec![MftAttributeType::DATA])) + .filter_map(std::result::Result::ok) + .find(|a| a.header.name.is_empty()); + + let Some(attr) = attr else { + return (0, false); + }; + + match &attr.header.residential_header { + ResidentialHeader::Resident(r) => (r.data_size as u64, false), + ResidentialHeader::NonResident(nr) => { + let is_compressed = attr + .header + .data_flags + .contains(AttributeDataFlags::IS_COMPRESSED) + || nr.unit_compression_size > 0; + (nr.file_size, is_compressed) + } + } +} + +fn has_attribute_list(fs: &FileSystem, entry_id: u64) -> bool { + let entry = fs.volume().read_mft_entry(entry_id).unwrap(); + entry + .iter_attributes_matching(Some(vec![MftAttributeType::AttributeList])) + .any(|a| a.is_ok()) +} + +#[test] +fn test_read_compressed_file_matches_raw() { + let path = common::ntfs_fixture_path("ntfs1-gen1.aff"); + if !common::ensure_fixture(&path) { + return; + } + let img = AffImage::open(path).unwrap(); + let volume = Volume::open(Arc::new(img), 0).unwrap(); + let fs = FileSystem::new(volume); + + let raw_dir = fs.resolve_path("\\Raw").unwrap(); + let compressed_dir = fs.resolve_path("\\Compressed").unwrap(); + + let raw_map = build_name_map(fs.read_dir(raw_dir).unwrap()); + let compressed_map = build_name_map(fs.read_dir(compressed_dir).unwrap()); + + // Pick the smallest compressed file that exists in both directories. + let mut best: Option<(String, u64, u64, u64)> = None; // (name, raw_id, compressed_id, size) + for (name, &compressed_id) in &compressed_map { + let Some(&raw_id) = raw_map.get(name) else { + continue; + }; + let (size, is_compressed) = data_info(&fs, compressed_id); + if !is_compressed || size == 0 { + continue; + } + match best { + None => best = Some((name.clone(), raw_id, compressed_id, size)), + Some((_, _, _, best_size)) if size < best_size => { + best = Some((name.clone(), raw_id, compressed_id, size)) + } + _ => {} + } + } + + let (name, raw_id, compressed_id, size) = best.expect("no compressed file found in fixture"); + // Keep the test fast. + assert!( + size <= 2_000_000, + "picked file too large: {name} size={size}" + ); + + let raw_bytes = fs.read_file_default_stream(raw_id).unwrap(); + let compressed_bytes = fs.read_file_default_stream(compressed_id).unwrap(); + assert_eq!(raw_bytes, compressed_bytes, "content mismatch for {name}"); +} + +#[test] +fn test_read_attribute_list_backed_file_len_matches() { + let path = common::ntfs_fixture_path("ntfs1-gen1.aff"); + if !common::ensure_fixture(&path) { + return; + } + let img = AffImage::open(path).unwrap(); + let volume = Volume::open(Arc::new(img), 0).unwrap(); + let fs = FileSystem::new(volume); + + // Scan root directory for a reasonably-sized file that uses an attribute list. + let mut candidate: Option<(String, u64, u64)> = None; // (name, id, size) + for e in fs.read_dir(5).unwrap() { + if e.name == "." { + continue; + } + if !has_attribute_list(&fs, e.entry_id) { + continue; + } + let (size, _compressed) = data_info(&fs, e.entry_id); + if size == 0 || size > 2_000_000 { + continue; + } + candidate = Some((e.name, e.entry_id, size)); + break; + } + + // If the fixture doesn't contain a small attribute-list-backed file in root, don't fail the suite. + let Some((name, entry_id, size)) = candidate else { + return; + }; + + let bytes = fs.read_file_default_stream(entry_id).unwrap(); + assert_eq!(bytes.len() as u64, size, "unexpected size for {name}"); +} diff --git a/crates/ntfs/tests/test_filesystem_helpers.rs b/crates/ntfs/tests/test_filesystem_helpers.rs new file mode 100644 index 0000000..ba2d116 --- /dev/null +++ b/crates/ntfs/tests/test_filesystem_helpers.rs @@ -0,0 +1,68 @@ +use ntfs::image::{EwfImage, RawImage}; +use ntfs::ntfs::{FileSystem, Volume}; +use std::sync::Arc; + +mod common; + +#[test] +fn test_filesystem_is_entry_allocated_matches_deleted_fixture_7() { + let img_path = common::undelete7_fixture_path("7-ntfs-undel.dd"); + if !common::ensure_fixture(&img_path) { + return; + } + let img = RawImage::open(img_path).unwrap(); + let volume = Volume::open(Arc::new(img), 0).unwrap(); + let fs = FileSystem::new(volume); + + // Root directory is allocated. + assert!(fs.is_entry_allocated(5).unwrap()); + + // Known deleted entries from DFTT image #7. + for id in [29_u64, 30, 31, 32, 35, 36, 37, 38] { + assert!( + !fs.is_entry_allocated(id).unwrap(), + "expected entry {id} to be not allocated (deleted)" + ); + } +} + +#[test] +fn test_filesystem_is_entry_efs_encrypted_detects_encrypted_fixture_gen2() { + let img_path = common::ntfs_fixture_path("ntfs1-gen2.E01"); + if !common::ensure_fixture(&img_path) { + return; + } + let img = EwfImage::open(img_path).unwrap(); + let volume = Volume::open(Arc::new(img), 0).unwrap(); + let fs = FileSystem::new(volume); + + let encrypted_id = fs.resolve_path("\\Encrypted\\logfile1.txt").unwrap(); + let raw_id = fs.resolve_path("\\Raw\\logfile1.txt").unwrap(); + + assert!(fs.is_entry_efs_encrypted(encrypted_id).unwrap()); + assert!(!fs.is_entry_efs_encrypted(raw_id).unwrap()); +} + +#[test] +fn test_export_file_default_stream_to_path_matches_read_bytes() { + let img_path = common::undelete7_fixture_path("7-ntfs-undel.dd"); + if !common::ensure_fixture(&img_path) { + return; + } + let img = RawImage::open(img_path).unwrap(); + let volume = Volume::open(Arc::new(img), 0).unwrap(); + let fs = FileSystem::new(volume); + + // One resident + one non-resident case. + for id in [37_u64, 32] { + let expected = fs.read_file_default_stream(id).unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let out = dir.path().join(format!("entry-{id}.bin")); + + fs.export_file_default_stream_to_path(id, &out).unwrap(); + + let got = std::fs::read(&out).unwrap(); + assert_eq!(got, expected, "export mismatch for entry {id}"); + } +} diff --git a/crates/ntfs/tests/test_fsntfsinfo_cli.rs b/crates/ntfs/tests/test_fsntfsinfo_cli.rs new file mode 100644 index 0000000..f9f479f --- /dev/null +++ b/crates/ntfs/tests/test_fsntfsinfo_cli.rs @@ -0,0 +1,102 @@ +use assert_cmd::cargo::cargo_bin_cmd; +use predicates::str::contains; +mod common; + +#[test] +fn ntfsinfo_volume_subcommand_works() { + let img_path = common::undelete7_fixture_path("7-ntfs-undel.dd"); + if !common::ensure_fixture(&img_path) { + return; + } + let mut cmd = cargo_bin_cmd!("ntfs-info"); + cmd.arg(&img_path).arg("volume"); + cmd.assert() + .success() + .stdout(contains("bytes_per_sector: 512")) + .stdout(contains("cluster_size: 1024")); +} + +#[test] +fn ntfsinfo_c_style_e_single_entry_works() { + let img_path = common::undelete7_fixture_path("7-ntfs-undel.dd"); + if !common::ensure_fixture(&img_path) { + return; + } + let mut cmd = cargo_bin_cmd!("ntfs-info"); + cmd.arg("-E").arg("32").arg(&img_path); + cmd.assert() + .success() + .stdout(contains("record_number: 32")) + .stdout(contains("best_name: mult1.dat")); +} + +#[test] +fn ntfsinfo_c_style_hierarchy_prints_and_does_not_panic() { + let img_path = common::ntfs_fixture_path("ntfs1-gen0.aff"); + if !common::ensure_fixture(&img_path) { + return; + } + let mut cmd = cargo_bin_cmd!("ntfs-info"); + cmd.arg("-H").arg(&img_path); + cmd.assert() + .success() + .stdout(contains("File system hierarchy:")) + .stdout(contains("\\$MFT")); +} + +#[test] +fn ntfsinfo_c_style_bodyfile_writes_file() { + let img_path = common::ntfs_fixture_path("ntfs1-gen0.aff"); + if !common::ensure_fixture(&img_path) { + return; + } + let tmp = tempfile::tempdir().unwrap(); + let out_path = tmp.path().join("bodyfile.txt"); + + let mut cmd = cargo_bin_cmd!("ntfs-info"); + cmd.arg("-B").arg(&out_path).arg(&img_path); + cmd.assert().success(); + + let body = std::fs::read_to_string(out_path).unwrap(); + assert!(body.contains("|\\|"), "expected root path in bodyfile"); +} + +#[test] +fn ntfsinfo_c_style_f_strict_fails_for_deleted_paths() { + // On the undelete fixture, these files are deleted and typically not present in directory indexes, + // so strict -F should fail. + let img_path = common::undelete7_fixture_path("7-ntfs-undel.dd"); + if !common::ensure_fixture(&img_path) { + return; + } + let mut cmd = cargo_bin_cmd!("ntfs-info"); + cmd.arg("-F").arg("\\mult1.dat").arg(&img_path); + cmd.assert().failure().stderr(contains("Not found")); +} + +#[test] +fn ntfsinfo_c_style_f_strict_is_case_insensitive_for_ascii_names() { + // `$MFT` exists on all NTFS volumes. Root is typically case-insensitive, so `$mft` should + // resolve to the same entry. + let img_path = common::ntfs_fixture_path("ntfs1-gen0.aff"); + if !common::ensure_fixture(&img_path) { + return; + } + let mut cmd = cargo_bin_cmd!("ntfs-info"); + cmd.arg("-F").arg("\\$mft").arg(&img_path); + cmd.assert().success().stdout(contains("mft_entry: 0")); +} + +#[test] +fn ntfsinfo_usn_n_a_is_success() { + // Most small fixtures do not contain a USN journal; tool should report N/A and exit 0. + let img_path = common::undelete7_fixture_path("7-ntfs-undel.dd"); + if !common::ensure_fixture(&img_path) { + return; + } + let mut cmd = cargo_bin_cmd!("ntfs-info"); + cmd.arg("-U").arg(&img_path); + cmd.assert() + .success() + .stdout(contains("USN change journal: N/A")); +} diff --git a/crates/ntfs/tests/test_image_backends.rs b/crates/ntfs/tests/test_image_backends.rs new file mode 100644 index 0000000..a0966b8 --- /dev/null +++ b/crates/ntfs/tests/test_image_backends.rs @@ -0,0 +1,81 @@ +use ntfs::image::{AffImage, EwfImage, ReadAt}; +mod common; + +fn assert_ntfs_boot_sector(image: &impl ReadAt) { + let mut boot = [0u8; 512]; + image.read_exact_at(0, &mut boot).unwrap(); + assert_eq!(&boot[3..11], b"NTFS "); + // Boot sector signature. + assert_eq!(&boot[510..512], &[0x55, 0xAA]); +} + +#[test] +fn test_aff_gen0_reads_ntfs_boot_sector() { + let path = common::ntfs_fixture_path("ntfs1-gen0.aff"); + if !common::ensure_fixture(&path) { + return; + } + let img = AffImage::open(path).unwrap(); + assert_ntfs_boot_sector(&img); +} + +#[test] +fn test_ewf_gen0_reads_ntfs_boot_sector() { + let path = common::ntfs_fixture_path("ntfs1-gen0.E01"); + if !common::ensure_fixture(&path) { + return; + } + let img = EwfImage::open(path).unwrap(); + assert_ntfs_boot_sector(&img); +} + +#[test] +fn test_gen0_aff_and_ewf_boot_sector_match() { + let aff_path = common::ntfs_fixture_path("ntfs1-gen0.aff"); + if !common::ensure_fixture(&aff_path) { + return; + } + let ewf_path = common::ntfs_fixture_path("ntfs1-gen0.E01"); + if !common::ensure_fixture(&ewf_path) { + return; + } + + let aff = AffImage::open(aff_path).unwrap(); + let ewf = EwfImage::open(ewf_path).unwrap(); + + let mut boot_aff = [0u8; 512]; + let mut boot_ewf = [0u8; 512]; + aff.read_exact_at(0, &mut boot_aff).unwrap(); + ewf.read_exact_at(0, &mut boot_ewf).unwrap(); + assert_eq!(boot_aff, boot_ewf); +} + +#[test] +fn test_aff_gen1_reads_ntfs_boot_sector() { + let path = common::ntfs_fixture_path("ntfs1-gen1.aff"); + if !common::ensure_fixture(&path) { + return; + } + let img = AffImage::open(path).unwrap(); + assert_ntfs_boot_sector(&img); +} + +#[test] +fn test_ewf_gen1_reads_ntfs_boot_sector() { + let path = common::ntfs_fixture_path("ntfs1-gen1.E01"); + if !common::ensure_fixture(&path) { + return; + } + let img = EwfImage::open(path).unwrap(); + assert_ntfs_boot_sector(&img); +} + +#[test] +fn test_ewf_gen2_reads_ntfs_boot_sector() { + let path = common::ntfs_fixture_path("ntfs1-gen2.E01"); + if !common::ensure_fixture(&path) { + return; + } + let img = EwfImage::open(path).unwrap(); + assert_ntfs_boot_sector(&img); +} diff --git a/crates/ntfs/tests/test_ntfs_undelete_7.rs b/crates/ntfs/tests/test_ntfs_undelete_7.rs new file mode 100644 index 0000000..1103eca --- /dev/null +++ b/crates/ntfs/tests/test_ntfs_undelete_7.rs @@ -0,0 +1,161 @@ +use md5::{Digest as _, Md5}; +use ntfs::image::RawImage; +use ntfs::ntfs::{FileSystem, Volume}; +use std::sync::Arc; + +mod common; + +fn md5_hex(bytes: &[u8]) -> String { + let mut h = Md5::new(); + h.update(bytes); + hex::encode(h.finalize()) +} + +fn assert_stream_md5( + fs: &FileSystem, + entry_id: u64, + stream_name: &str, + expected_len: usize, + expected_md5: &str, +) { + let bytes = fs.read_file_stream(entry_id, stream_name).unwrap(); + assert_eq!( + bytes.len(), + expected_len, + "unexpected len for entry {entry_id} stream `{stream_name}`" + ); + assert_eq!( + md5_hex(&bytes), + expected_md5, + "unexpected MD5 for entry {entry_id} stream `{stream_name}`" + ); +} + +#[test] +fn test_ntfs_undelete_7_deleted_file_stream_md5s_match_reference() { + // DFXML "Digital Forensics Tool Testing Image (#7)", NTFS Undelete Test #1. + // Reference values are listed in `testdata/ntfs/7-undel-ntfs/index.html`. + let img_path = common::undelete7_fixture_path("7-ntfs-undel.dd"); + if !common::ensure_fixture(&img_path) { + return; + } + let img = RawImage::open(img_path).unwrap(); + let volume = Volume::open(Arc::new(img), 0).unwrap(); + let fs = FileSystem::new(volume); + + // Resident file. + assert_stream_md5(&fs, 37, "", 101, "9036637712b491904cd0bfbdbe648453"); + // Single cluster file. + assert_stream_md5(&fs, 31, "", 780, "59b20779f69ff9f0ac5fcd2c38835a79"); + // Multiple cluster file (non-fragmented). + assert_stream_md5(&fs, 32, "", 3801, "ffd27bd782bdce67750b6b9ee069d2ef"); + // Alternate Data Stream. + assert_stream_md5(&fs, 32, "ADS", 1234, "ba1b9eedb1c091ddca253d35dde8f616"); + // Fragmented files. + assert_stream_md5(&fs, 29, "", 1584, "7a3bc5b763bef201202108f4ba128149"); + assert_stream_md5(&fs, 30, "", 3873, "0e80ab84ef0087e60dfc67b88a1cf13e"); + // File in deleted directories. + assert_stream_md5(&fs, 36, "", 1715, "59cf0e9cd107bc1e75afb7374f6e05bb"); + assert_stream_md5(&fs, 35, "", 2027, "21121699487f3fbbdb9a4b3391b6d3e0"); + // File whose parent directory entry has been reallocated. + assert_stream_md5(&fs, 38, "", 1005, "c229626f6a71b167ad7e50c4f2fccdb1"); +} + +#[test] +fn test_ntfs_undelete_7_md5_streaming_matches_reference() { + // Validate the streaming MD5 implementation used by ntfsinfo bodyfile output. + let img_path = common::undelete7_fixture_path("7-ntfs-undel.dd"); + if !common::ensure_fixture(&img_path) { + return; + } + let img = RawImage::open(img_path).unwrap(); + let volume = Volume::open(Arc::new(img), 0).unwrap(); + let fs = FileSystem::new(volume); + + let cases = [ + (37_u64, "", "9036637712b491904cd0bfbdbe648453"), + (31, "", "59b20779f69ff9f0ac5fcd2c38835a79"), + (32, "", "ffd27bd782bdce67750b6b9ee069d2ef"), + (32, "ADS", "ba1b9eedb1c091ddca253d35dde8f616"), + (29, "", "7a3bc5b763bef201202108f4ba128149"), + (30, "", "0e80ab84ef0087e60dfc67b88a1cf13e"), + (36, "", "59cf0e9cd107bc1e75afb7374f6e05bb"), + (35, "", "21121699487f3fbbdb9a4b3391b6d3e0"), + (38, "", "c229626f6a71b167ad7e50c4f2fccdb1"), + ]; + + for (entry_id, stream, expected) in cases { + let got = fs.md5_file_stream(entry_id, stream).unwrap(); + assert_eq!( + got, expected, + "unexpected streaming MD5 for entry {entry_id} stream `{stream}`" + ); + } +} + +#[test] +fn test_ntfs_undelete_7_deleted_file_names_visible_in_mft() { + let img_path = common::undelete7_fixture_path("7-ntfs-undel.dd"); + if !common::ensure_fixture(&img_path) { + return; + } + let img = RawImage::open(img_path).unwrap(); + let volume = Volume::open(Arc::new(img), 0).unwrap(); + let fs = FileSystem::new(volume); + + // Deleted entries should still expose their FILE_NAME attributes, which is the minimum needed + // to “see deleted file names”. + let cases = [ + (29_u64, "frag1.dat"), + (30, "frag2.dat"), + (31, "sing1.dat"), + (32, "mult1.dat"), + (35, "frag3.dat"), + (36, "mult2.dat"), + (37, "res1.dat"), + (38, "sing2.dat"), + ]; + + for (entry_id, expected_name) in cases { + let entry = fs.volume().read_mft_entry(entry_id).unwrap(); + let name = entry + .find_best_name_attribute() + .expect("missing FILE_NAME attribute") + .name; + assert!( + name.eq_ignore_ascii_case(expected_name), + "unexpected name for entry {entry_id}: got={name:?} expected={expected_name:?}" + ); + } +} + +#[test] +fn test_ntfs_undelete_7_resolve_deleted_paths_via_parent_scan() { + let img_path = common::undelete7_fixture_path("7-ntfs-undel.dd"); + if !common::ensure_fixture(&img_path) { + return; + } + let img = RawImage::open(img_path).unwrap(); + let volume = Volume::open(Arc::new(img), 0).unwrap(); + let fs = FileSystem::new(volume); + + // Deleted directories are typically removed from `$I30`, so plain `resolve_path` should fail. + // The `*_including_deleted` variant uses a parent-reference scan to recover names. + assert!(fs.resolve_path("\\dir1").is_err()); + + assert_eq!(fs.resolve_path_including_deleted("\\dir1").unwrap(), 33); + assert_eq!( + fs.resolve_path_including_deleted("\\dir1\\dir2").unwrap(), + 34 + ); + assert_eq!( + fs.resolve_path_including_deleted("\\dir1\\mult2.dat") + .unwrap(), + 36 + ); + assert_eq!( + fs.resolve_path_including_deleted("\\dir1\\dir2\\frag3.dat") + .unwrap(), + 35 + ); +} diff --git a/crates/ntfs/tests/test_upcase_table.rs b/crates/ntfs/tests/test_upcase_table.rs new file mode 100644 index 0000000..57da2e8 --- /dev/null +++ b/crates/ntfs/tests/test_upcase_table.rs @@ -0,0 +1,25 @@ +use ntfs::image::AffImage; +use ntfs::ntfs::name::UpcaseTable; +use ntfs::ntfs::name::upcase::UPCASE_TABLE_SIZE_BYTES; +use ntfs::ntfs::{FileSystem, Volume}; +use std::sync::Arc; + +mod common; + +#[test] +fn test_read_upcase_table_from_fixture() { + let path = common::ntfs_fixture_path("ntfs1-gen0.aff"); + if !common::ensure_fixture(&path) { + return; + } + let img = AffImage::open(path).unwrap(); + let volume = Volume::open(Arc::new(img), 0).unwrap(); + let fs = FileSystem::new(volume); + + let raw = fs.read_file_default_stream(10).unwrap(); + assert_eq!(raw.len(), UPCASE_TABLE_SIZE_BYTES); + + let table = UpcaseTable::from_bytes(&raw).unwrap(); + assert_eq!(table.map_u16(b'a' as u16), b'A' as u16); + assert_eq!(table.map_u16(b'Z' as u16), b'Z' as u16); +} diff --git a/crates/ntfs/tests/test_volume_mft.rs b/crates/ntfs/tests/test_volume_mft.rs new file mode 100644 index 0000000..4c3c8b6 --- /dev/null +++ b/crates/ntfs/tests/test_volume_mft.rs @@ -0,0 +1,37 @@ +use ntfs::image::{AffImage, EwfImage}; +use ntfs::ntfs::Volume; +use std::sync::Arc; + +mod common; + +fn assert_volume_can_read_mft_entry0(volume: &Volume) { + assert_eq!(volume.header.bytes_per_sector, 512); + assert!(volume.header.cluster_size > 0); + assert!(volume.header.mft_entry_size > 0); + + let entry0 = volume.read_mft_entry(0).unwrap(); + assert!(entry0.header.is_valid()); + assert_eq!(entry0.header.record_number, 0); +} + +#[test] +fn test_volume_from_aff_gen0_reads_mft_entry0() { + let path = common::ntfs_fixture_path("ntfs1-gen0.aff"); + if !common::ensure_fixture(&path) { + return; + } + let img = AffImage::open(path).unwrap(); + let volume = Volume::open(Arc::new(img), 0).unwrap(); + assert_volume_can_read_mft_entry0(&volume); +} + +#[test] +fn test_volume_from_ewf_gen0_reads_mft_entry0() { + let path = common::ntfs_fixture_path("ntfs1-gen0.E01"); + if !common::ensure_fixture(&path) { + return; + } + let img = EwfImage::open(path).unwrap(); + let volume = Volume::open(Arc::new(img), 0).unwrap(); + assert_volume_can_read_mft_entry0(&volume); +} diff --git a/src/entry.rs b/src/entry.rs index 7791c6a..9805f69 100644 --- a/src/entry.rs +++ b/src/entry.rs @@ -19,8 +19,6 @@ use std::io::Read; use std::io::SeekFrom; use std::io::{Cursor, Seek}; -const SEQUENCE_NUMBER_STRIDE: usize = 512; - pub const ZERO_HEADER: &[u8; 4] = b"\x00\x00\x00\x00"; pub const BAAD_HEADER: &[u8; 4] = b"BAAD"; pub const FILE_HEADER: &[u8; 4] = b"FILE"; @@ -262,48 +260,24 @@ impl MftEntry { /// The returned result is true if all fixup blocks had the fixup array value, or /// false if a block's fixup value did not match the array's value. fn apply_fixups(header: &EntryHeader, buffer: &mut [u8]) -> Result { - let mut valid_fixup = true; - let number_of_fixups = u32::from(header.usa_size - 1); + let number_of_fixups = u32::from(header.usa_size.saturating_sub(1)); trace!("Number of fixups: {number_of_fixups}"); - // Each fixup is a 2-byte element, and there are `usa_size` of them. - let fixups_start_offset = header.usa_offset as usize; - let fixups_end_offset = fixups_start_offset + (header.usa_size * 2) as usize; - - let fixups = buffer[fixups_start_offset..fixups_end_offset].to_vec(); - let mut fixups = fixups.chunks(2); - - // There should always be bytes here, but just in case we put zeroes, so it will fail later. - let update_sequence = fixups.next().unwrap_or(&[0, 0]); - - // We need to compare each last two bytes each 512-bytes stride with the update_sequence, - // And if they match, replace those bytes with the matching bytes from the fixup_sequence. - for (stride_number, fixup_bytes) in (0_usize..number_of_fixups as usize).zip(fixups) { - let sector_start_offset = stride_number * SEQUENCE_NUMBER_STRIDE; - - let end_of_sector_bytes_end_offset = sector_start_offset + SEQUENCE_NUMBER_STRIDE; - let end_of_sector_bytes_start_offset = end_of_sector_bytes_end_offset - 2; - - let end_of_sector_bytes = - &mut buffer[end_of_sector_bytes_start_offset..end_of_sector_bytes_end_offset]; - - if end_of_sector_bytes != update_sequence { - // An item in the block did not match the fixup array value + let entry_id = header.record_number; + crate::ntfs::apply_update_sequence_array_fixups_in_place_with( + buffer, + header.usa_offset, + header.usa_size, + |m| { warn!( "[entry: {}] fixup bytes are not equal to update sequence value - stride_number: {}, end_of_sector_bytes: {:?}, fixup_bytes: {:?}", - header.record_number, - stride_number, - end_of_sector_bytes.to_vec(), - fixup_bytes.to_vec() + entry_id, + m.sector_idx, + m.end_of_sector_bytes.to_vec(), + m.replacement_bytes.to_vec() ); - - valid_fixup = false; - } - - end_of_sector_bytes.copy_from_slice(fixup_bytes); - } - - Ok(valid_fixup) + }, + ) } pub fn is_allocated(&self) -> bool { diff --git a/src/lib.rs b/src/lib.rs index df3d691..6b81d58 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,6 +19,7 @@ pub mod csv; pub mod entry; pub mod err; pub mod mft; +pub mod ntfs; pub(crate) mod macros; pub(crate) mod utils; diff --git a/src/ntfs.rs b/src/ntfs.rs new file mode 100644 index 0000000..c9bdb25 --- /dev/null +++ b/src/ntfs.rs @@ -0,0 +1,118 @@ +use crate::err::{Error, Result}; + +/// NTFS Update Sequence Array (USA) fixups are applied at **512-byte** strides regardless of the +/// volume bytes-per-sector setting. +pub const UPDATE_SEQUENCE_STRIDE_BYTES: usize = 512; + +/// Applies NTFS Update Sequence Array (USA) fixups in-place. +/// +/// This is the "multi-sector transfer" protection used by structures like `FILE` (MFT record) and +/// `INDX` (index record): the last 2 bytes of each 512-byte sector are temporarily replaced with an +/// update-sequence number, and the original bytes are stored in the USA array. This function +/// restores those original bytes. +/// +/// Returns: +/// - `Ok(true)` if all processed sectors matched the update-sequence number. +/// - `Ok(false)` if any sector mismatch was detected **or** if the record ended before all +/// fixups could be applied (best-effort behavior). +/// +/// Notes: +/// - `usa_count` is the number of 2-byte values in the array, including the initial update-sequence +/// number, so the number of fixups to apply is `usa_count - 1`. +/// - This function does not fail on mismatch; it still applies fixups to enable best-effort parsing. +pub fn apply_update_sequence_array_fixups_in_place( + buffer: &mut [u8], + usa_offset: u16, + usa_count: u16, +) -> Result { + apply_update_sequence_array_fixups_in_place_with(buffer, usa_offset, usa_count, |_ctx| {}) +} + +/// Context provided to the mismatch callback in +/// [`apply_update_sequence_array_fixups_in_place_with`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UsaMismatch { + /// 0-based sector index. + pub sector_idx: usize, + pub end_of_sector_bytes: [u8; 2], + pub update_sequence: [u8; 2], + pub replacement_bytes: [u8; 2], +} + +/// Same as [`apply_update_sequence_array_fixups_in_place`], but allows observing mismatches. +/// +/// The callback is invoked only when a sector mismatch is detected. +pub fn apply_update_sequence_array_fixups_in_place_with( + buffer: &mut [u8], + usa_offset: u16, + usa_count: u16, + mut on_mismatch: F, +) -> Result +where + F: FnMut(UsaMismatch), +{ + if usa_count < 2 { + return Err(Error::Any { + detail: "invalid number_of_fixup_values".to_string(), + }); + } + + let usa_offset = usa_offset as usize; + let usa_size_bytes = (usa_count as usize) + .checked_mul(2) + .ok_or_else(|| Error::Any { + detail: "fixup array size overflow".to_string(), + })?; + let usa_end = usa_offset + .checked_add(usa_size_bytes) + .ok_or_else(|| Error::Any { + detail: "fixup array end offset overflow".to_string(), + })?; + + if usa_end > buffer.len() || usa_offset >= buffer.len() { + return Err(Error::Any { + detail: "fixup array out of bounds".to_string(), + }); + } + + // Copy out the update-sequence bytes (avoid borrowing the buffer while mutating it). + let update_sequence = [buffer[usa_offset], buffer[usa_offset + 1]]; + + let sector_count = (usa_count as usize).saturating_sub(1); + let mut valid_fixup = true; + + for sector_idx in 0..sector_count { + let sector_end = (sector_idx + 1) * UPDATE_SEQUENCE_STRIDE_BYTES; + if sector_end < 2 || sector_end > buffer.len() { + // Best-effort: the record is shorter than expected for the declared number of sectors. + // We treat it as invalid fixup but still keep any fixups applied so far. + valid_fixup = false; + break; + } + + let replacement_offset = usa_offset + (sector_idx + 1) * 2; + if replacement_offset + 2 > usa_end { + return Err(Error::Any { + detail: "fixup array out of bounds".to_string(), + }); + } + let replacement_bytes = [buffer[replacement_offset], buffer[replacement_offset + 1]]; + + let end_of_sector = &mut buffer[sector_end - 2..sector_end]; + let end_of_sector_bytes = [end_of_sector[0], end_of_sector[1]]; + + if end_of_sector_bytes != update_sequence { + valid_fixup = false; + on_mismatch(UsaMismatch { + sector_idx, + end_of_sector_bytes, + update_sequence, + replacement_bytes, + }); + } + + end_of_sector.copy_from_slice(&replacement_bytes); + } + + Ok(valid_fixup) +} diff --git a/testdata/ntfs/7-undel-ntfs/7-ntfs-undel.dd b/testdata/ntfs/7-undel-ntfs/7-ntfs-undel.dd new file mode 100644 index 0000000..5602379 --- /dev/null +++ b/testdata/ntfs/7-undel-ntfs/7-ntfs-undel.dd @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4138cc42148e3381e3c66eb50090f4a30416ae8c20247c2b9bad27cf8c764d88 +size 6160384 diff --git a/testdata/ntfs/7-undel-ntfs/COPYING-GNU.txt b/testdata/ntfs/7-undel-ntfs/COPYING-GNU.txt new file mode 100644 index 0000000..5b6e7c6 --- /dev/null +++ b/testdata/ntfs/7-undel-ntfs/COPYING-GNU.txt @@ -0,0 +1,340 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc. + 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Library General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Library General +Public License instead of this License. diff --git a/testdata/ntfs/7-undel-ntfs/README.txt b/testdata/ntfs/7-undel-ntfs/README.txt new file mode 100644 index 0000000..8b1b57d --- /dev/null +++ b/testdata/ntfs/7-undel-ntfs/README.txt @@ -0,0 +1,18 @@ + + Test 7 - NTFS Undelete Image #1 + February 2004 + + http://dftt.sf.net + Brian Carrier + +--------------------------------------------------------------------------- +This is a test image for testing digital forensic analysis tools. +Import it into the tool of your choice (it is a NTFS file system +image in a raw format) and try and recover the deleted files and +directories. The file details can be found in 'index.html' file. + +This image is released under the terms of the GNU General Public +License as published by the Free Software Foundation. It is included +in the COPYING file. + +This work is not sponsored by Purdue University or CERIAS. diff --git a/testdata/ntfs/7-undel-ntfs/index.html b/testdata/ntfs/7-undel-ntfs/index.html new file mode 100644 index 0000000..d0db537 --- /dev/null +++ b/testdata/ntfs/7-undel-ntfs/index.html @@ -0,0 +1,196 @@ + + + NTFS Undelete Test #1 + + + + +
+

NTFS Undelete Test #1

+ +

Digital Forensics Tool Testing Image (#7)

+ + + http://dftt.sf.net + +
+ +

Introduction

+

+This test image is a 6MB NTFS file system with eight deleted files, +two deleted directories, and a deleted alternate data stream. The +files range from resident files, single cluster files, and multiple +fragments. No data structures were modified in this process to +thwart recovery. They were created in Windows XP, deleted in XP, +and imaged in Linux. + + +

Download

+

+This test image is a 'raw' partition image (i.e. 'dd') of a NTFS +file system. The file system is 6MB and is compressed to 186 KB (lots +of zeros). The MD5 of the image is +e7dbb96759d9cd62b729463ebfe61dab. This image is released +under the +GPL, so anyone can use it. + +

+ + +

Files

+

+These are the files that should be recovered, their sizes, and their +MD5 values. + (Fill in the blank results form) + +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NumMFT EntryNameSizeMD5Note
137\res1.dat1019036637712b491904cd0bfbdbe648453Resident file (data is stored in MFT entry and not in a cluster)
231\sing1.dat78059b20779f69ff9f0ac5fcd2c38835a79single cluster file
332-128-3\mult1.dat3801ffd27bd782bdce67750b6b9ee069d2efmultiple cluster, non-fragmented file
432-128-6\mult1.dat:ADS1234ba1b9eedb1c091ddca253d35dde8f616multiple cluster, second data attribute (Alternate Data Stream)
529\frag1.dat15847a3bc5b763bef201202108f4ba128149fragmented file
630\frag2.dat38730e80ab84ef0087e60dfc67b88a1cf13efragmented file with frag1.dat mixed in
733\dir1\1024N/Adirectory
836\dir1\mult2.dat171559cf0e9cd107bc1e75afb7374f6e05bbmultiple cluster, non-fragmented in deleted directory
934\dir1\dir2\1024N/Adirectory in deleted directory
1035\dir1\dir2\frag3.dat202721121699487f3fbbdb9a4b3391b6d3e0fragmented file in deleted directories
1138\dir3\sing2.dat1005c229626f6a71b167ad7e50c4f2fccdb1single cluster file in a directory whose MFT entry has been reallocated (to res1.dat)
+ + +

+

Layout

+

+Here is the actual layout of the image. + +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ClusterFile
4073\frag1.dat (part 1 of 2)
4074\frag2.dat (part 1 of 3)
4075\frag1.dat (part 2 of 2)
4076-4077\frag2.dat (part 2 of 3)
4078\sing1.dat
4079-4082\mult1.dat
4083-4084\mult1.dat:ADS
4085\frag2.dat (part 3 of 3)
4086-4089\$Secure:$SDH (Not deleted)
4090\dir1\dir2\frag3.dat (part 1 of 2)
4091-4092\dir1\mult2.dat
4093\dir1\dir2\frag3.dat (part 2 of 2)
4094\dir3\sing2.dat
4089-4090\mult1.dat:ADS
+ +

+

Bonus

+

+This image was created on Feb 29, 2004 so check the dates in your +tools to see if your tool properly handles leap year. + +

Author

+Brian Carrier (carrier at cerias.purdue.edu) created the test cases +and the test image. +This test was released on February 29, 2004. + + +

Disclaimers

+

+Neither Purdue University or CERIAS sponsor this work. + +

+These tests are not a complete test suite. These were the first +ones that I thought of and no formal theory was put into their +design. + + +

+Passing these tests provides no guarantees about a tool. Always +use additional test cases (and email them to me so we can all +benefit!). + + +

+


+ + + + + +
Brian Carrier [carrier AT cerias.purdue.edu] +Last Updated: Feb 29, 2004
+ + + + diff --git a/testdata/ntfs/7-undel-ntfs/results.txt b/testdata/ntfs/7-undel-ntfs/results.txt new file mode 100644 index 0000000..34e297f --- /dev/null +++ b/testdata/ntfs/7-undel-ntfs/results.txt @@ -0,0 +1,30 @@ +NTFS Undelete Image #1 + +Tool: +Version: + +1. Can you see any of the deleted file names? Which ones? + +2. Can you recover the res1.dat file? Does it have the correct MD5? + +3. Can you recover the sing1.dat file? Does it have the correct MD5? + +4. Can you recover the dir3\sing2.dat file? Does it have the correct MD5? + +5. Can you recover the mult1.dat file? Does it have the correct MD5? + +6. Can you recover the mult1.dat:ADS file? Does it have the correct MD5? + +7. Can you recover the dir1\mult2.dat file? Does it have the +correct MD5? + +8. Can you recover the frag1.dat file? Does it have the correct MD5? + +9. Can you recover the frag2.dat file? Does it have the correct MD5? + +10. Can you recover the dir1\dir2\frag3.dat file? Does it have the +correct MD5? + +11. Are the dates propely shown to be from Feb 29, 2004? (testing leap +year support) + diff --git a/testdata/ntfs/README.md b/testdata/ntfs/README.md new file mode 100644 index 0000000..3fb4cef --- /dev/null +++ b/testdata/ntfs/README.md @@ -0,0 +1,63 @@ +# NTFS test fixtures (Git LFS) + +This directory contains **binary NTFS test images** used by the `ntfs` crate integration tests under `crates/ntfs/tests/`. + +These files are stored in **Git LFS** (see the repo root `.gitattributes`). If tests fail with parse errors or if you see tiny “pointer” files, run: + +```bash +git lfs install +git lfs pull +``` + +## What’s here + +### `NPS 2009 NTFS1` (Digital Corpora) + +Files: + +- `ntfs1-gen0.{aff,E01}` +- `ntfs1-gen1.{aff,E01}` +- `ntfs1-gen2.E01` +- `ntfs1-gen2.xml` (DFXML / `fiwalk` output; used as ground truth for per-file hashes) +- `narrative.txt` (dataset description) + +Used by tests: + +- Image backend parity / smoke: `crates/ntfs/tests/test_image_backends.rs` +- Volume/MFT open: `crates/ntfs/tests/test_volume_mft.rs` +- Directory traversal + compression + attribute list reads: `crates/ntfs/tests/test_dir_traversal.rs`, `crates/ntfs/tests/test_file_reads.rs` +- EFS metadata + decrypt regressions: `crates/ntfs/tests/test_efs_*.rs`, `crates/ntfs/tests/test_ewf_regressions.rs` + +Provenance: + +- CFReDS dataset page: `https://cfreds.nist.gov/all/DigitalCorpora/NPS2009NTFS1` + - The CFReDS page links to a Digital Corpora download location for the dataset. + +### `DFTT image #7` — NTFS Undelete Test #1 (GPL) + +Files: + +- `7-undel-ntfs/7-ntfs-undel.dd` (raw NTFS partition image, ~6MB) +- `7-undel-ntfs/index.html`, `results.txt` (reference values used by tests) +- `7-undel-ntfs/README.txt`, `COPYING-GNU.txt` (upstream provenance + license) + +Used by tests: + +- Deleted file recovery + ADS + parent-scan path resolution: `crates/ntfs/tests/test_ntfs_undelete_7.rs` +- Misc filesystem helpers: `crates/ntfs/tests/test_fsntfsinfo_cli.rs`, `crates/ntfs/tests/test_filesystem_helpers.rs` + +Provenance / reference: + +- Upstream project: `http://dftt.sourceforge.net/` (see `7-undel-ntfs/README.txt`) +- Upstream MD5 (from `7-undel-ntfs/index.html`): `e7dbb96759d9cd62b729463ebfe61dab` + +License note: + +- The `7-undel-ntfs` fixture is **GPL-2.0-only**, per the upstream `README.txt` and `COPYING-GNU.txt`. +- This repo also contains MIT/Apache-2.0 code; the GPL license here applies to the **fixture files in `7-undel-ntfs/`**, not automatically to the rest of the repository. (If your org has strict “no GPL artifacts anywhere” policies, don’t pull these LFS objects and/or remove this directory.) + +## Integrity + +See `SHA256SUMS` for SHA-256 checksums of all files in this directory (including `7-undel-ntfs/*`). + + diff --git a/testdata/ntfs/SHA256SUMS b/testdata/ntfs/SHA256SUMS new file mode 100644 index 0000000..3996ec1 --- /dev/null +++ b/testdata/ntfs/SHA256SUMS @@ -0,0 +1,12 @@ +4138cc42148e3381e3c66eb50090f4a30416ae8c20247c2b9bad27cf8c764d88 ./7-undel-ntfs/7-ntfs-undel.dd +204d8eff92f95aac4df6c8122bc1505f468f3a901e5a4cc08940e0ede1938994 ./7-undel-ntfs/COPYING-GNU.txt +5c2acbc509e5d3657f92fe359b2005299458def3434b9c6f12b4562b51cab83e ./7-undel-ntfs/README.txt +221cab41ddad798ec01b2aa1847ff24b51ccee97dc4f5f23e5a6f3de091c08b8 ./7-undel-ntfs/index.html +67d7d56e58c8723bba1e30011b303d9af1464cb9d34a2894fa432fa1748882e2 ./7-undel-ntfs/results.txt +97c52467f98aff6002595d21d46534cf1205ed7b497b69014cb5973695458241 ./narrative.txt +96e525f53d50f986461151f8e9c07588633215477a6b8a3f744b2eeebe512460 ./ntfs1-gen0.E01 +bf0291a0ee8403962f2de8ea93d908088e4265a02438dfb5b1c85efc07037b76 ./ntfs1-gen0.aff +ed26b63cb37350fba5aaf18f8c871515ff787db98bfa1c5d92b179185168dd6e ./ntfs1-gen1.E01 +33528f2d44fed0dac1d96b90b444cf9309207413948bf4c4f685b0332da86cc5 ./ntfs1-gen1.aff +2badead91bef56c80155d7731671ad1d93c08f32cd4ce17566fdf02d5769feea ./ntfs1-gen2.E01 +efe48e07ed327d3b80f6b208c6dace55e17a0c23636d4cdf831b17a260daaab8 ./ntfs1-gen2.xml diff --git a/testdata/ntfs/narrative.txt b/testdata/ntfs/narrative.txt new file mode 100644 index 0000000..0dcebdb --- /dev/null +++ b/testdata/ntfs/narrative.txt @@ -0,0 +1,20 @@ +NTFS1 (NTFS) + +An NTFS file system containing three directories: Raw, Compressed, and +Encrypted. + +In each directory are the same files: some publicly available PDFs, +some JPEGs, and a "logfile" that is incrementally written one line at +a time to each of the directories with interleved writes (so that the +files will naturally be fragmented.) + +The EFS encryption key is present in the root directory in two forms +--- one exported without a password, one exported with the password +"password." + +Please note that the report.xml and report.txt files are not present +due to the inability of our tools to properly handle compressed and +encrypted files at this time. + + + diff --git a/testdata/ntfs/ntfs1-gen0.E01 b/testdata/ntfs/ntfs1-gen0.E01 new file mode 100644 index 0000000..0f55d5e --- /dev/null +++ b/testdata/ntfs/ntfs1-gen0.E01 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:96e525f53d50f986461151f8e9c07588633215477a6b8a3f744b2eeebe512460 +size 1089252 diff --git a/testdata/ntfs/ntfs1-gen0.aff b/testdata/ntfs/ntfs1-gen0.aff new file mode 100644 index 0000000..19ec4c0 --- /dev/null +++ b/testdata/ntfs/ntfs1-gen0.aff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bf0291a0ee8403962f2de8ea93d908088e4265a02438dfb5b1c85efc07037b76 +size 277228 diff --git a/testdata/ntfs/ntfs1-gen1.E01 b/testdata/ntfs/ntfs1-gen1.E01 new file mode 100644 index 0000000..e16740f --- /dev/null +++ b/testdata/ntfs/ntfs1-gen1.E01 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ed26b63cb37350fba5aaf18f8c871515ff787db98bfa1c5d92b179185168dd6e +size 9332369 diff --git a/testdata/ntfs/ntfs1-gen1.aff b/testdata/ntfs/ntfs1-gen1.aff new file mode 100644 index 0000000..d14eb0a --- /dev/null +++ b/testdata/ntfs/ntfs1-gen1.aff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:33528f2d44fed0dac1d96b90b444cf9309207413948bf4c4f685b0332da86cc5 +size 8481452 diff --git a/testdata/ntfs/ntfs1-gen2.E01 b/testdata/ntfs/ntfs1-gen2.E01 new file mode 100644 index 0000000..6a900eb --- /dev/null +++ b/testdata/ntfs/ntfs1-gen2.E01 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2badead91bef56c80155d7731671ad1d93c08f32cd4ce17566fdf02d5769feea +size 36083007 diff --git a/testdata/ntfs/ntfs1-gen2.xml b/testdata/ntfs/ntfs1-gen2.xml new file mode 100644 index 0000000..69a4743 --- /dev/null +++ b/testdata/ntfs/ntfs1-gen2.xml @@ -0,0 +1,512 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +]> + + +Disk Image + + + +/corp/drives/nps/nps-2009-ntfs1/ntfs1-gen2.raw +0.5.7 +Thu Apr 21 14:52:29 2011 +3.0.1 +3.3.8 + + +0 +512 +1 +ntfs +1008895 +0 +1008894 + +Compressed/20076517123273.pdf +1 +1001647 +1 +1 +1 +1 +40 +1 +511 +2 +0 +0 +1230765330 +1230765336 +1230765380 +1230765380 +1 + +2e167810afd3e5398b3fc439a99a4ef2 +597646983a74e2946c8238bdf8ecdf0b86d42580 + + +Compressed/logfile1.txt +2 +21888890 +1 +1 +1 +1 +48 +1 +511 +1 +0 +0 +1231192885 +1231192885 +1231192885 +1231192820 +1 + +be2828dda150f19edf9a0fc87e3ab640 +4a97f6bacc9d3abbfa7626ae140829aaaa7a6d03 + + +Compressed/NISTSP800-88_rev1.pdf +3 +554121 +1 +1 +1 +1 +34 +1 +511 +2 +0 +0 +1230764762 +1230764762 +1230764777 +1230764777 +1 + +e9103614acfb2cdb5d1ac52e387bf2a3 +e0b99f17ae8d4bc645ff6ce4672717792514bad5 + + +Compressed/NIST_logo.jpg +4 +2205 +1 +1 +1 +1 +31 +1 +511 +2 +0 +0 +1230763607 +1230763607 +1231191700 +1230764606 +3 + +d651f3132416847bfe874d3d63a4198a +0687c17813d6caa10fd14e1db77fc2cfe867d542 + + +Compressed/report02-3.pdf +5 +1421998 +1 +1 +1 +1 +37 +1 +511 +2 +0 +0 +1230764913 +1230764913 +1230764971 +1230764971 +1 + +dede94f84fb2d00dc93ed00fda272a18 +3c078d039398c44611b6365e8afdeadeb61967d4 + + +EFS-key-info.txt +6 +57 +1 +1 +1 +46 +1 +511 +2 +0 +0 +1231191948 +1231191948 +1231271053 +1231191947 +2 + +db0ef76b97e71938e3ce67c800d75f99 +4af18449c9b53ca1042a28e204487b3013de23e4 + + +EFS-key-no-password.pfx +7 +1730 +1 +1 +1 +43 +1 +511 +2 +0 +0 +1231191849 +1231191849 +1231191876 +1231191846 +2 + +a564f834ca9b33e5e7ccc708441064c8 +328e18cd06ddeb88404e3073d5f9c73beb499755 + + +EFS-key-password-strong-protection.pfx +8 +1734 +1 +1 +1 +45 +1 +511 +2 +0 +0 +1231191912 +1231191912 +1231191912 +1231191910 +2 + +3cb1f6d875eaed6b3180ec99a35f7ff4 +3f79bac9ab998ea0f51a6e0b7f80013c745e6571 + + +EFS-key-password.pfx +9 +1730 +1 +1 +1 +44 +1 +511 +2 +0 +0 +1231191884 +1231191884 +1231191884 +1231191882 +2 + +ba1563f6aa24cd1425d15366ff4b7189 +c1469503dd1630961eae649441fc3fda9526c3e1 + + +Encrypted/20076517123273.pdf +10 +1001647 +1 +1 +1 +41 +1 +511 +2 +0 +0 +1230765330 +1230765336 +1230765386 +1230765386 +1 + +6e372ce575c2a1616230f7396c8270af +072657f8cf6bab8a1204dfda63ea04a2109f8dc1 + + +Encrypted/logfile1.txt +11 +21888890 +1 +1 +1 +49 +1 +511 +1 +0 +0 +1231192886 +1231192886 +1231192886 +1231192820 +1 + +cb45c6ad5abb2ff240217aead1e85f13 +3bbdcb934d042d9bccc2b8e8d01314b314261a6e + + +Encrypted/NISTSP800-88_rev1.pdf +12 +554121 +1 +1 +1 +35 +1 +511 +2 +0 +0 +1230764762 +1230764762 +1230764781 +1230764781 +1 + +aa4ee625cdcd7966e283b561c44b35c1 +075ff98619b697cd0457a9a6e62bdb2f2933c17d + + +Encrypted/NIST_logo.jpg +13 +2205 +1 +1 +1 +32 +1 +511 +2 +0 +0 +1230763607 +1230763607 +1230765416 +1230764612 +1 + +b1a027257ac43ec3d41d0d815de826ed +6289d7eddf7e33be6fe7c247d305a7158898c999 + + +Encrypted/report02-3.pdf +14 +1421998 +1 +1 +1 +38 +1 +511 +2 +0 +0 +1230764913 +1230764913 +1230764975 +1230764975 +1 + +350845c4eb0007dbb5ac833e83d1f493 +59df04d7cceec3e976258f8a914cfc522ddc8ab0 + + +RAW/20076517123273.pdf +15 +1001647 +1 +1 +1 +42 +1 +511 +2 +0 +0 +1230765330 +1230765336 +1230765389 +1230765389 +1 + +2e167810afd3e5398b3fc439a99a4ef2 +597646983a74e2946c8238bdf8ecdf0b86d42580 + + +RAW/logfile1.txt +16 +21888890 +1 +1 +1 +47 +1 +511 +1 +0 +0 +1231192883 +1231192883 +1231192883 +1231192820 +1 + +be2828dda150f19edf9a0fc87e3ab640 +4a97f6bacc9d3abbfa7626ae140829aaaa7a6d03 + + +RAW/NISTSP800-88_rev1.pdf +17 +554121 +1 +1 +1 +36 +1 +511 +2 +0 +0 +1230764762 +1230764762 +1230764784 +1230764784 +1 + +e9103614acfb2cdb5d1ac52e387bf2a3 +e0b99f17ae8d4bc645ff6ce4672717792514bad5 + + +RAW/NIST_logo.jpg +18 +2205 +1 +1 +1 +33 +1 +511 +2 +0 +0 +1230763607 +1230763607 +1230764987 +1230764616 +1 + +d651f3132416847bfe874d3d63a4198a +0687c17813d6caa10fd14e1db77fc2cfe867d542 + + +RAW/report02-3.pdf +19 +1421998 +1 +1 +1 +39 +1 +511 +2 +0 +0 +1230764913 +1230764913 +1230764978 +1230764978 +1 + +dede94f84fb2d00dc93ed00fda272a18 +3c078d039398c44611b6365e8afdeadeb61967d4 + + + + +0 +0 +8122368 +2089 +0 +0 +0 +4 +Thu Apr 21 14:52:30 2011 + From 125e8654277b2b2d9e96fdf69d516e063fe8ba59 Mon Sep 17 00:00:00 2001 From: Omer Ben-Amram Date: Fri, 26 Dec 2025 16:37:43 +0200 Subject: [PATCH 2/8] fmt --- crates/ntfs-explorer-gui/Cargo.toml | 1 + crates/ntfs-explorer-gui/src/app.rs | 140 ++++++++++-------- crates/ntfs-explorer-gui/src/settings.rs | 26 +++- crates/ntfs/src/bin/ntfs_info/cli_utils.rs | 2 - crates/ntfs/src/image/ewf.rs | 13 +- .../ntfs/src/ntfs/filesystem/file_system.rs | 12 +- crates/ntfs/tests/common/mod.rs | 2 - crates/ntfs/tests/test_efs_decrypt.rs | 2 +- crates/ntfs/tests/test_efs_key_size.rs | 5 +- crates/ntfs/tests/test_efs_metadata.rs | 5 +- crates/ntfs/tests/test_ewf_regressions.rs | 2 +- crates/ntfs/tests/test_file_reads.rs | 4 +- 12 files changed, 118 insertions(+), 96 deletions(-) diff --git a/crates/ntfs-explorer-gui/Cargo.toml b/crates/ntfs-explorer-gui/Cargo.toml index d753b81..baa54a6 100644 --- a/crates/ntfs-explorer-gui/Cargo.toml +++ b/crates/ntfs-explorer-gui/Cargo.toml @@ -36,5 +36,6 @@ bytesize = "2" # Settings persistence serde = { version = "1", features = ["derive"] } serde_json = "1" +tempfile = "3.23" diff --git a/crates/ntfs-explorer-gui/src/app.rs b/crates/ntfs-explorer-gui/src/app.rs index 777908e..9398e09 100644 --- a/crates/ntfs-explorer-gui/src/app.rs +++ b/crates/ntfs-explorer-gui/src/app.rs @@ -607,75 +607,93 @@ pub fn app() -> Element { Callback::new(move |(): ()| context_menu.set(None)) }; - #[cfg(feature = "desktop")] + // `cargo clippy --all-features` enables *all* platform features at once. Use mutually + // exclusive cfg blocks so we still define `on_save_as` exactly once. let on_save_as = { - let snapshot = snapshot.to_owned(); - let mut action_error = action_error; - let mut context_menu = context_menu; - Callback::new(move |row: EntryRow| { - let Some(s) = snapshot.read().clone() else { - return; - }; - if row.is_dir { - return; - } + #[cfg(feature = "desktop")] + { + let snapshot = snapshot.to_owned(); + let mut action_error = action_error; + let mut context_menu = context_menu; + Callback::new(move |row: EntryRow| { + let Some(s) = snapshot.read().clone() else { + return; + }; + if row.is_dir { + return; + } - context_menu.set(None); - action_error.set(None); + context_menu.set(None); + action_error.set(None); - let backend = s.backend.clone(); - if !backend.can_export() { + let backend = s.backend.clone(); + if !backend.can_export() { + action_error.set(Some( + "Export is not available for MFT-only snapshots (metadata-only mode)." + .to_string(), + )); + return; + } + spawn(async move { + let Some(handle) = rfd::AsyncFileDialog::new() + .set_file_name(&row.name) + .save_file() + .await + else { + return; + }; + let out_path = handle.path().to_path_buf(); + + let entry_id = row.entry_id; + let res = tokio::task::spawn_blocking(move || { + export_file_default_stream(backend, entry_id, out_path) + }) + .await; + match res { + Ok(Ok(())) => {} + Ok(Err(e)) => action_error.set(Some(e)), + Err(e) => action_error.set(Some(format!("export task failed: {e}"))), + } + }); + }) + } + + #[cfg(all(not(feature = "desktop"), feature = "liveview"))] + { + let mut action_error = action_error; + let mut context_menu = context_menu; + Callback::new(move |_row: EntryRow| { + context_menu.set(None); action_error.set(Some( - "Export is not available for MFT-only snapshots (metadata-only mode)." + "Export is not implemented in LiveView yet (it runs on the server). Use the desktop build for now." .to_string(), )); - return; - } - spawn(async move { - let Some(handle) = rfd::AsyncFileDialog::new() - .set_file_name(&row.name) - .save_file() - .await - else { - return; - }; - let out_path = handle.path().to_path_buf(); - - let entry_id = row.entry_id; - let res = tokio::task::spawn_blocking(move || { - export_file_default_stream(backend, entry_id, out_path) - }) - .await; - match res { - Ok(Ok(())) => {} - Ok(Err(e)) => action_error.set(Some(e)), - Err(e) => action_error.set(Some(format!("export task failed: {e}"))), - } - }); - }) - }; + }) + } - #[cfg(feature = "liveview")] - let on_save_as = { - let mut action_error = action_error; - let mut context_menu = context_menu; - Callback::new(move |_row: EntryRow| { - context_menu.set(None); - action_error.set(Some( - "Export is not implemented in LiveView yet (it runs on the server). Use the desktop build for now." - .to_string(), - )); - }) - }; + #[cfg(all(not(feature = "desktop"), not(feature = "liveview"), feature = "web"))] + { + let mut action_error = action_error; + Callback::new(move |_row: EntryRow| { + action_error.set(Some( + "File export is not available in the web version.".to_string(), + )); + }) + } - #[cfg(feature = "web")] - let on_save_as = { - let mut action_error = action_error; - Callback::new(move |_row: EntryRow| { - action_error.set(Some( - "File export is not available in the web version.".to_string(), - )); - }) + #[cfg(all( + not(feature = "desktop"), + not(feature = "liveview"), + not(feature = "web") + ))] + { + let mut action_error = action_error; + Callback::new(move |_row: EntryRow| { + action_error.set(Some( + "File export is not available in this build.".to_string(), + )); + }) + } }; let on_select_entry = { diff --git a/crates/ntfs-explorer-gui/src/settings.rs b/crates/ntfs-explorer-gui/src/settings.rs index 58be5e4..fc03a53 100644 --- a/crates/ntfs-explorer-gui/src/settings.rs +++ b/crates/ntfs-explorer-gui/src/settings.rs @@ -1,9 +1,11 @@ use std::{ env, fs, + io::Write, path::{Path, PathBuf}, }; use serde::{Deserialize, Serialize}; +use tempfile::Builder; pub const CONFIG_ENV_VAR: &str = "NTFS_EXPLORER_UI_STATE"; @@ -108,12 +110,22 @@ pub fn save_ui_state(state: UiState) -> Result<(), String> { } fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> { - let tmp = path.with_extension("tmp"); - - fs::write(&tmp, data)?; - - // `rename` on Windows fails if the destination exists. - let _ = fs::remove_file(path); - fs::rename(tmp, path)?; + let dir = path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + + // Use the ecosystem to create a unique temp file safely (same directory => same filesystem), + // then persist it to the target path. `tempfile`'s `persist` will atomically replace an + // existing destination file where supported (including Windows). + let mut tmp = Builder::new() + .prefix(".ui_state.") + .suffix(".tmp") + .tempfile_in(dir)?; + tmp.write_all(data)?; + tmp.as_file().sync_all()?; + + // Close the file handle before persisting (required on Windows). + tmp.into_temp_path().persist(path)?; Ok(()) } diff --git a/crates/ntfs/src/bin/ntfs_info/cli_utils.rs b/crates/ntfs/src/bin/ntfs_info/cli_utils.rs index 11f466b..d9ecae8 100644 --- a/crates/ntfs/src/bin/ntfs_info/cli_utils.rs +++ b/crates/ntfs/src/bin/ntfs_info/cli_utils.rs @@ -7,5 +7,3 @@ pub(crate) fn parse_u64(s: &str) -> std::result::Result { .unwrap_or((10, s)); u64::from_str_radix(digits, radix).map_err(|e| e.to_string()) } - - diff --git a/crates/ntfs/src/image/ewf.rs b/crates/ntfs/src/image/ewf.rs index a3ef41f..dd1b2f0 100644 --- a/crates/ntfs/src/image/ewf.rs +++ b/crates/ntfs/src/image/ewf.rs @@ -136,7 +136,9 @@ impl EwfImage { let last_entry = *table.entries.last().expect("non-empty"); let chunk_data_end = match pending_sectors_end.take() { Some(end) => end, - None => compute_chunk_data_end_offset_v1(desc, table.base_offset, last_entry)?, + None => { + compute_chunk_data_end_offset_v1(desc, table.base_offset, last_entry)? + } }; if chunk_data_end > data.len() as u64 { @@ -180,8 +182,7 @@ impl EwfImage { )); } - let expected_chunks_from_media = - div_ceil_u64(volume.media_size, volume.chunk_size as u64); + let expected_chunks_from_media = div_ceil_u64(volume.media_size, volume.chunk_size as u64); if expected_chunks_from_media != chunk_count { return Err(io::Error::new( io::ErrorKind::InvalidData, @@ -717,7 +718,11 @@ mod tests { use super::*; use std::io::Write as _; - fn make_section_descriptor(type_string: &str, start_offset: u64, size: u64) -> [u8; EWF1_SECTION_DESCRIPTOR_SIZE] { + fn make_section_descriptor( + type_string: &str, + start_offset: u64, + size: u64, + ) -> [u8; EWF1_SECTION_DESCRIPTOR_SIZE] { let mut raw = [0u8; EWF1_SECTION_DESCRIPTOR_SIZE]; // type string (ASCII, NUL-terminated) diff --git a/crates/ntfs/src/ntfs/filesystem/file_system.rs b/crates/ntfs/src/ntfs/filesystem/file_system.rs index e6a02ba..4f0671b 100644 --- a/crates/ntfs/src/ntfs/filesystem/file_system.rs +++ b/crates/ntfs/src/ntfs/filesystem/file_system.rs @@ -1,12 +1,8 @@ use crate::image::ReadAt; -use crate::ntfs::data_stream::{ - CompressedDataRunsStream, DataRunsStream, read_from_data_runs, -}; +use crate::ntfs::data_stream::{CompressedDataRunsStream, DataRunsStream, read_from_data_runs}; use crate::ntfs::efs::{EfsFekDecryptor, EfsMetadataV1, EfsRsaKeyBag}; use crate::ntfs::index::{IndexRoot, IndexValueFlags, apply_index_record_fixups}; -use crate::ntfs::name::{ - FileNameKey, UpcaseTable, eq_case_insensitive_ntfs, eq_case_sensitive, -}; +use crate::ntfs::name::{FileNameKey, UpcaseTable, eq_case_insensitive_ntfs, eq_case_sensitive}; use crate::ntfs::{Error, Result, Volume}; use md5::{Digest as _, Md5}; use mft::attribute::AttributeDataFlags; @@ -320,9 +316,7 @@ impl FileSystem { /// - `Ok(None)` if the journal is not present (`$UsnJrnl` entry or `$J` stream absent) /// - `Ok(Some(_))` if present /// - `Err(_)` for invalid/unsupported layouts (strict) - pub fn open_usn_change_journal( - &self, - ) -> Result> { + pub fn open_usn_change_journal(&self) -> Result> { use crate::ntfs::usn::journal::DEFAULT_USN_JOURNAL_BLOCK_SIZE; let usn_entry_id = match self.resolve_path_strict("\\$Extend\\$UsnJrnl") { diff --git a/crates/ntfs/tests/common/mod.rs b/crates/ntfs/tests/common/mod.rs index f834157..adf92e5 100644 --- a/crates/ntfs/tests/common/mod.rs +++ b/crates/ntfs/tests/common/mod.rs @@ -64,5 +64,3 @@ pub fn ensure_fixture(path: &Path) -> bool { true } - - diff --git a/crates/ntfs/tests/test_efs_decrypt.rs b/crates/ntfs/tests/test_efs_decrypt.rs index 725ed23..a9f0012 100644 --- a/crates/ntfs/tests/test_efs_decrypt.rs +++ b/crates/ntfs/tests/test_efs_decrypt.rs @@ -1,5 +1,5 @@ -use mft::attribute::header::ResidentialHeader; use mft::attribute::MftAttributeType; +use mft::attribute::header::ResidentialHeader; use ntfs::image::EwfImage; use ntfs::ntfs::efs::{EfsMetadataV1, EfsRsaKeyBag}; use ntfs::ntfs::{FileSystem, Volume}; diff --git a/crates/ntfs/tests/test_efs_key_size.rs b/crates/ntfs/tests/test_efs_key_size.rs index cc49a1a..96ca397 100644 --- a/crates/ntfs/tests/test_efs_key_size.rs +++ b/crates/ntfs/tests/test_efs_key_size.rs @@ -1,5 +1,5 @@ -use mft::attribute::header::ResidentialHeader; use mft::attribute::MftAttributeType; +use mft::attribute::header::ResidentialHeader; use ntfs::image::EwfImage; use ntfs::ntfs::efs::{EfsMetadataV1, EfsRsaKeyBag}; use ntfs::ntfs::{FileSystem, Volume}; @@ -39,8 +39,7 @@ fn test_efs_rsa_key_size_matches_ddf_ciphertext_size() { ResidentialHeader::NonResident(nr) => { let runs = attr.data.clone().into_data_runs().unwrap().data_runs; let mut buf = vec![0u8; nr.file_size as usize]; - ntfs::ntfs::data_stream::read_from_data_runs(fs.volume(), &runs, 0, &mut buf) - .unwrap(); + ntfs::ntfs::data_stream::read_from_data_runs(fs.volume(), &runs, 0, &mut buf).unwrap(); buf } }; diff --git a/crates/ntfs/tests/test_efs_metadata.rs b/crates/ntfs/tests/test_efs_metadata.rs index 3561e81..e0e9cad 100644 --- a/crates/ntfs/tests/test_efs_metadata.rs +++ b/crates/ntfs/tests/test_efs_metadata.rs @@ -1,5 +1,5 @@ -use mft::attribute::header::ResidentialHeader; use mft::attribute::MftAttributeType; +use mft::attribute::header::ResidentialHeader; use ntfs::image::EwfImage; use ntfs::ntfs::efs::EfsMetadataV1; use ntfs::ntfs::{FileSystem, Volume}; @@ -35,8 +35,7 @@ fn test_parse_efs_metadata_v1_has_ddf_entries() { ResidentialHeader::NonResident(nr) => { let runs = attr.data.clone().into_data_runs().unwrap().data_runs; let mut buf = vec![0u8; nr.file_size as usize]; - ntfs::ntfs::data_stream::read_from_data_runs(fs.volume(), &runs, 0, &mut buf) - .unwrap(); + ntfs::ntfs::data_stream::read_from_data_runs(fs.volume(), &runs, 0, &mut buf).unwrap(); buf } }; diff --git a/crates/ntfs/tests/test_ewf_regressions.rs b/crates/ntfs/tests/test_ewf_regressions.rs index 3b88085..8bc4e52 100644 --- a/crates/ntfs/tests/test_ewf_regressions.rs +++ b/crates/ntfs/tests/test_ewf_regressions.rs @@ -1,6 +1,6 @@ +use md5::{Digest as _, Md5}; use ntfs::image::EwfImage; use ntfs::ntfs::{FileSystem, Volume}; -use md5::{Digest as _, Md5}; use sha1::Sha1; use std::collections::{HashMap, HashSet}; use std::fs::File; diff --git a/crates/ntfs/tests/test_file_reads.rs b/crates/ntfs/tests/test_file_reads.rs index 193b65b..51a2652 100644 --- a/crates/ntfs/tests/test_file_reads.rs +++ b/crates/ntfs/tests/test_file_reads.rs @@ -7,9 +7,7 @@ use std::sync::Arc; mod common; -fn build_name_map( - entries: Vec, -) -> HashMap { +fn build_name_map(entries: Vec) -> HashMap { let mut m = HashMap::new(); for e in entries { if e.name == "." || e.name.starts_with('$') || e.name.contains('~') { From 38bb92f3820c5e92aeddea3b96418dbd524ebf63 Mon Sep 17 00:00:00 2001 From: Omer Ben-Amram Date: Fri, 26 Dec 2025 19:12:47 +0200 Subject: [PATCH 3/8] aff minimal update --- crates/ntfs/src/image/aff.rs | 158 +++++++++++++++++---- crates/ntfs/tests/test_aff_sparse_pages.rs | 144 +++++++++++++++++++ 2 files changed, 274 insertions(+), 28 deletions(-) create mode 100644 crates/ntfs/tests/test_aff_sparse_pages.rs diff --git a/crates/ntfs/src/image/aff.rs b/crates/ntfs/src/image/aff.rs index 7dc6e15..fda0bc0 100644 --- a/crates/ntfs/src/image/aff.rs +++ b/crates/ntfs/src/image/aff.rs @@ -10,15 +10,21 @@ use std::sync::{Arc, Mutex}; #[derive(Debug, Clone, Copy)] struct PageEntry { data_offset: usize, - compressed_len: usize, - is_zlib: bool, + data_len: usize, + flags: u32, } -/// Minimal AFF (AFF1) reader that supports page segments (`pageNNN`) compressed with zlib. +/// Minimal AFF (AFF1) reader. +/// +/// Notes (per AFFLIBv3 semantics): +/// - Missing pages represent zero-filled regions. +/// - Pages may be stored uncompressed, zlib-compressed, or as a special "ZERO" compressor +/// (4-byte segment value indicating the number of NUL bytes). #[derive(Debug)] pub struct AffImage { data: Arc<[u8]>, page_size: usize, + image_size: u64, pages: Vec>, cache: Mutex>>, } @@ -38,6 +44,9 @@ impl AffImage { let mut cursor = 8usize; let mut page_size: Option = None; + let mut image_size: Option = None; + let mut sector_size: Option = None; + let mut device_sectors: Option = None; let mut pages_map: BTreeMap = BTreeMap::new(); // Parsing strategy: @@ -87,20 +96,39 @@ impl AffImage { if name == "pagesize" { // In our fixtures pagesize is stored in the arg field. page_size = Some(arg as usize); + } else if name == "sectorsize" { + sector_size = Some(arg); + } else if name == "devicesectors" { + if data_len != 8 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "AFF devicesectors segment must be 8 bytes", + )); + } + let quad = data + .get(data_offset..data_offset + data_len) + .ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "segment data"))?; + device_sectors = Some(read_aff_quad(quad)?); + } else if name == "imagesize" { + if data_len != 8 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "AFF imagesize segment must be 8 bytes", + )); + } + let quad = data + .get(data_offset..data_offset + data_len) + .ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "segment data"))?; + image_size = Some(read_aff_quad(quad)?); } else if let Some(page_index) = name.strip_prefix("page") && let Ok(page) = page_index.parse::() { - let is_zlib = data_len >= 2 - && data - .get(data_offset..data_offset + 2) - .is_some_and(|h| h[0] == 0x78 && matches!(h[1], 0x01 | 0x5e | 0x9c | 0xda)); - pages_map.insert( page, PageEntry { data_offset, - compressed_len: data_len, - is_zlib, + data_len, + flags: arg, }, ); } @@ -126,9 +154,19 @@ impl AffImage { } } + let image_size = image_size + .or_else(|| match (device_sectors, sector_size) { + (Some(sectors), Some(bytes_per_sector)) => { + sectors.checked_mul(bytes_per_sector as u64) + } + _ => None, + }) + .unwrap_or_else(|| pages.len() as u64 * page_size as u64); + Ok(Self { data, page_size, + image_size, pages, // Pages are very large in our fixtures (16MiB), keep the cache small. cache: Mutex::new(LruCache::new(NonZeroUsize::new(2).expect("2 > 0"))), @@ -139,6 +177,10 @@ impl AffImage { self.page_size } + fn blank_page(&self) -> Vec { + vec![0u8; self.page_size] + } + fn read_page(&self, page_index: u64) -> io::Result> { if let Some(hit) = self.cache.lock().expect("poisoned").get(&page_index) { return Ok(hit.clone()); @@ -146,27 +188,70 @@ impl AffImage { let idx = usize::try_from(page_index) .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "page index overflow"))?; - let entry = self - .pages - .get(idx) - .and_then(|x| *x) - .ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "page not present"))?; - - let compressed = self + let Some(entry) = self.pages.get(idx).and_then(|x| *x) else { + // Sparse / missing page => zero-filled region. + let out = self.blank_page(); + self.cache + .lock() + .expect("poisoned") + .put(page_index, out.clone()); + return Ok(out); + }; + + let seg = self .data - .get(entry.data_offset..entry.data_offset + entry.compressed_len) + .get(entry.data_offset..entry.data_offset + entry.data_len) .ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "page out of bounds"))?; - let mut out = vec![0u8; self.page_size]; - if entry.is_zlib { - let cursor = io::Cursor::new(compressed); - let mut decoder = ZlibDecoder::new(cursor); - decoder.read_exact(&mut out)?; + // AFFLIBv3 flags (see `include/afflib/afflib.h`) + const AF_PAGE_COMPRESSED: u32 = 0x0001; + const AF_PAGE_COMP_ALG_MASK: u32 = 0x00F0; + const AF_PAGE_COMP_ALG_ZLIB: u32 = 0x0000; + const AF_PAGE_COMP_ALG_LZMA: u32 = 0x0020; + const AF_PAGE_COMP_ALG_ZERO: u32 = 0x0030; + + let mut out = self.blank_page(); + if (entry.flags & AF_PAGE_COMPRESSED) == 0 { + // Uncompressed page data stored directly in the segment (possibly partial for the last page). + let take = seg.len().min(out.len()); + out[..take].copy_from_slice(&seg[..take]); } else { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "unsupported AFF page compression", - )); + match entry.flags & AF_PAGE_COMP_ALG_MASK { + AF_PAGE_COMP_ALG_ZERO => { + // ZERO compressor: segment is a 4-byte count of NUL bytes (AFFLIB uses ntohl()). + // The page content is all zeros. + if seg.len() != 4 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "AFF ZERO-compressed page must have 4 bytes of data", + )); + } + } + AF_PAGE_COMP_ALG_ZLIB => { + let cursor = io::Cursor::new(seg); + let mut decoder = ZlibDecoder::new(cursor); + let mut written = 0usize; + while written < out.len() { + let n = decoder.read(&mut out[written..])?; + if n == 0 { + break; + } + written += n; + } + } + AF_PAGE_COMP_ALG_LZMA => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unsupported AFF page compression: LZMA", + )); + } + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unsupported AFF page compression", + )); + } + } } self.cache @@ -179,7 +264,7 @@ impl AffImage { impl ReadAt for AffImage { fn len(&self) -> u64 { - self.pages.len() as u64 * self.page_size as u64 + self.image_size } fn read_exact_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { @@ -208,6 +293,23 @@ impl ReadAt for AffImage { } } +/// Reads an AFFLIB `aff_quad` (8 bytes) as `u64`. +/// +/// The encoding is **little-endian in 32-bit words**: +/// - bytes `[0..4]` are the low 32 bits in network order (`htonl(low)`), +/// - bytes `[4..8]` are the high 32 bits in network order (`htonl(high)`). +fn read_aff_quad(bytes: &[u8]) -> io::Result { + if bytes.len() != 8 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "AFF quad must be 8 bytes", + )); + } + let low = u32::from_be_bytes(bytes[0..4].try_into().expect("len=4")); + let high = u32::from_be_bytes(bytes[4..8].try_into().expect("len=4")); + Ok(((high as u64) << 32) | (low as u64)) +} + fn read_u32_be(data: &[u8], cursor: &mut usize) -> io::Result { let bytes = read_slice(data, cursor, 4)?; Ok(u32::from_be_bytes(bytes.try_into().expect("len=4"))) diff --git a/crates/ntfs/tests/test_aff_sparse_pages.rs b/crates/ntfs/tests/test_aff_sparse_pages.rs new file mode 100644 index 0000000..b2d667f --- /dev/null +++ b/crates/ntfs/tests/test_aff_sparse_pages.rs @@ -0,0 +1,144 @@ +use ntfs::image::{AffImage, ReadAt}; +use std::io::Write; + +fn aff_quad_u64(v: u64) -> [u8; 8] { + // AFFLIB stores 64-bit values as an `aff_quad`: + // - low 32 bits, then high 32 bits + // - each 32-bit word is in network byte order (big-endian) + let low = (v & 0xffff_ffff) as u32; + let high = (v >> 32) as u32; + let mut out = [0u8; 8]; + out[0..4].copy_from_slice(&low.to_be_bytes()); + out[4..8].copy_from_slice(&high.to_be_bytes()); + out +} + +fn zlib_compress(bytes: &[u8]) -> Vec { + let mut enc = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + enc.write_all(bytes).unwrap(); + enc.finish().unwrap() +} + +fn aff_segment(name: &str, data: &[u8], arg: u32) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(b"AFF\0"); + out.extend_from_slice(&(name.len() as u32).to_be_bytes()); + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + out.extend_from_slice(&arg.to_be_bytes()); + out.extend_from_slice(name.as_bytes()); + out.extend_from_slice(data); + out.extend_from_slice(b"ATT\0"); + out +} + +fn build_aff1_file(segments: Vec>) -> Vec { + let mut out = Vec::new(); + // File signature: "AFF10\r\n\0" (8 bytes) + out.extend_from_slice(b"AFF10\r\n\0"); + + for (i, seg) in segments.into_iter().enumerate() { + if i != 0 { + // Prefix/back-pointer field (AFFLIB uses this for reverse traversal). + // The current Rust parser ignores it but expects 4 bytes between segments. + out.extend_from_slice(&0u32.to_be_bytes()); + } + out.extend_from_slice(&seg); + } + + out +} + +#[test] +fn test_sparse_and_missing_pages_are_zero_filled_and_len_uses_imagesize() { + // Per AFFLIB: missing pages represent zero-filled regions. Also, logical length is derived + // from the `imagesize` segment rather than `(max_page+1)*pagesize`. + let page_size = 8usize; + let image_size = 32u64; // 4 pages + + // Page flags (from AFFLIB `afflib.h`) + const AF_PAGE_COMPRESSED: u32 = 0x0001; + const AF_PAGE_COMP_ALG_ZLIB: u32 = 0x0000; + + let page0 = b"AAAAAAAA"; + let page2 = b"CCCCCCCC"; + let page0_z = zlib_compress(page0); + let page2_z = zlib_compress(page2); + + let segments = vec![ + aff_segment("pagesize", &[], page_size as u32), + aff_segment("imagesize", &aff_quad_u64(image_size), 2), + aff_segment( + "page0", + &page0_z, + AF_PAGE_COMPRESSED | AF_PAGE_COMP_ALG_ZLIB, + ), + aff_segment( + "page2", + &page2_z, + AF_PAGE_COMPRESSED | AF_PAGE_COMP_ALG_ZLIB, + ), + ]; + + let bytes = build_aff1_file(segments); + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), bytes).unwrap(); + + let img = AffImage::open(tmp.path()).unwrap(); + assert_eq!(img.len(), image_size); + + let mut buf = vec![0u8; image_size as usize]; + img.read_exact_at(0, &mut buf).unwrap(); + + assert_eq!(&buf[0..8], page0); + assert_eq!(&buf[8..16], [0u8; 8].as_slice()); // missing page1 => zeros + assert_eq!(&buf[16..24], page2); + assert_eq!(&buf[24..32], [0u8; 8].as_slice()); // missing page3 (out of range) => zeros +} + +#[test] +fn test_zero_compressed_page_returns_zeros() { + let page_size = 8usize; + let image_size = 24u64; // 3 pages + + // Page flags (from AFFLIB `afflib.h`) + const AF_PAGE_COMPRESSED: u32 = 0x0001; + const AF_PAGE_COMP_ALG_ZLIB: u32 = 0x0000; + const AF_PAGE_COMP_ALG_ZERO: u32 = 0x0030; + + let page0 = b"AAAAAAAA"; + let page2 = b"RRRRRRRR"; + let page0_z = zlib_compress(page0); + + // ZERO compressor encodes a 4-byte count of NUL bytes, in network order (ntohl()). + let zero_len = (page_size as u32).to_be_bytes(); + + let segments = vec![ + aff_segment("pagesize", &[], page_size as u32), + aff_segment("imagesize", &aff_quad_u64(image_size), 2), + aff_segment( + "page0", + &page0_z, + AF_PAGE_COMPRESSED | AF_PAGE_COMP_ALG_ZLIB, + ), + aff_segment( + "page1", + &zero_len, + AF_PAGE_COMPRESSED | AF_PAGE_COMP_ALG_ZERO, + ), + aff_segment("page2", page2, 0), + ]; + + let bytes = build_aff1_file(segments); + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), bytes).unwrap(); + + let img = AffImage::open(tmp.path()).unwrap(); + assert_eq!(img.len(), image_size); + + let mut buf = vec![0u8; image_size as usize]; + img.read_exact_at(0, &mut buf).unwrap(); + + assert_eq!(&buf[0..8], page0); + assert_eq!(&buf[8..16], [0u8; 8].as_slice()); // ZERO compressor => zeros + assert_eq!(&buf[16..24], page2); // uncompressed => raw bytes +} From 8d3335480d8feeb213d76629e424b376999ef9ce Mon Sep 17 00:00:00 2001 From: Omer Ben-Amram Date: Sat, 27 Dec 2025 11:43:06 +0200 Subject: [PATCH 4/8] AFF --- .gitignore | 9 +- Cargo.toml | 2 + crates/aff/Cargo.toml | 39 + crates/aff/README.md | 35 + crates/aff/src/backends/afd.rs | 178 ++++ crates/aff/src/backends/aff1.rs | 781 ++++++++++++++++++ crates/aff/src/backends/afm.rs | 151 ++++ crates/aff/src/backends/backend.rs | 43 + crates/aff/src/backends/mod.rs | 15 + crates/aff/src/backends/open.rs | 271 ++++++ crates/aff/src/backends/split_raw.rs | 306 +++++++ crates/aff/src/bin/aff-cat.rs | 64 ++ crates/aff/src/bin/aff-info.rs | 50 ++ crates/aff/src/bin/aff-verify.rs | 51 ++ crates/aff/src/crypto/mod.rs | 16 + crates/aff/src/crypto/wrapper.rs | 531 ++++++++++++ crates/aff/src/error.rs | 20 + crates/aff/src/format.rs | 75 ++ crates/aff/src/lib.rs | 55 ++ crates/aff/src/verify.rs | 211 +++++ crates/aff/tests/common.rs | 66 ++ .../aff/tests/test_afm_and_afd_synthetic.rs | 113 +++ crates/aff/tests/test_cli_tools.rs | 138 ++++ .../aff/tests/test_crypto_decrypt_fixture.rs | 26 + .../test_crypto_unseal_keyfile_synthetic.rs | 127 +++ crates/aff/tests/test_real_fixtures_ntfs1.rs | 31 + .../tests/test_signature_verify_synthetic.rs | 249 ++++++ crates/forensic-image/Cargo.toml | 13 + crates/forensic-image/README.md | 19 + crates/forensic-image/src/lib.rs | 49 ++ crates/ntfs/Cargo.toml | 6 + crates/ntfs/src/image/aff.rs | 359 ++------ crates/ntfs/src/image/mod.rs | 15 +- crates/ntfs/tests/test_aff_sparse_pages.rs | 10 +- 34 files changed, 3801 insertions(+), 323 deletions(-) create mode 100644 crates/aff/Cargo.toml create mode 100644 crates/aff/README.md create mode 100644 crates/aff/src/backends/afd.rs create mode 100644 crates/aff/src/backends/aff1.rs create mode 100644 crates/aff/src/backends/afm.rs create mode 100644 crates/aff/src/backends/backend.rs create mode 100644 crates/aff/src/backends/mod.rs create mode 100644 crates/aff/src/backends/open.rs create mode 100644 crates/aff/src/backends/split_raw.rs create mode 100644 crates/aff/src/bin/aff-cat.rs create mode 100644 crates/aff/src/bin/aff-info.rs create mode 100644 crates/aff/src/bin/aff-verify.rs create mode 100644 crates/aff/src/crypto/mod.rs create mode 100644 crates/aff/src/crypto/wrapper.rs create mode 100644 crates/aff/src/error.rs create mode 100644 crates/aff/src/format.rs create mode 100644 crates/aff/src/lib.rs create mode 100644 crates/aff/src/verify.rs create mode 100644 crates/aff/tests/common.rs create mode 100644 crates/aff/tests/test_afm_and_afd_synthetic.rs create mode 100644 crates/aff/tests/test_cli_tools.rs create mode 100644 crates/aff/tests/test_crypto_decrypt_fixture.rs create mode 100644 crates/aff/tests/test_crypto_unseal_keyfile_synthetic.rs create mode 100644 crates/aff/tests/test_real_fixtures_ntfs1.rs create mode 100644 crates/aff/tests/test_signature_verify_synthetic.rs create mode 100644 crates/forensic-image/Cargo.toml create mode 100644 crates/forensic-image/README.md create mode 100644 crates/forensic-image/src/lib.rs diff --git a/.gitignore b/.gitignore index cb6c529..50dd5cb 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,13 @@ Cargo.lock *.rmeta -external/ +# 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 diff --git a/Cargo.toml b/Cargo.toml index db8eb4f..c341f66 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,8 @@ rust-version = "1.90" [workspace] members = [ ".", + "crates/forensic-image", + "crates/aff", "crates/ntfs", "crates/ntfs-explorer-gui", ] diff --git a/crates/aff/Cargo.toml b/crates/aff/Cargo.toml new file mode 100644 index 0000000..e11847f --- /dev/null +++ b/crates/aff/Cargo.toml @@ -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"] + + diff --git a/crates/aff/README.md b/crates/aff/README.md new file mode 100644 index 0000000..3f49579 --- /dev/null +++ b/crates/aff/README.md @@ -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. + + diff --git a/crates/aff/src/backends/afd.rs b/crates/aff/src/backends/afd.rs new file mode 100644 index 0000000..fe8d47f --- /dev/null +++ b/crates/aff/src/backends/afd.rs @@ -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>, + page_size: usize, + image_size: u64, + page_map: HashMap, // page_index -> file index +} + +impl AfdImage { + pub(crate) fn open_with(path: impl AsRef, page_cache_pages: usize) -> Result { + 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 = 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 { + // 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::>(); + out.sort(); + out + } + + fn read_segment(&self, name: &str) -> io::Result> { + 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 { + // 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::().ok() +} + + diff --git a/crates/aff/src/backends/aff1.rs b/crates/aff/src/backends/aff1.rs new file mode 100644 index 0000000..8752254 --- /dev/null +++ b/crates/aff/src/backends/aff1.rs @@ -0,0 +1,781 @@ +//! AFF1 (`.aff`) single-file backend. +//! +//! This is a minimal, read-only implementation designed to match **AFFLIBv3** behavior: +//! - Segment framing: `AF_SEGHEAD` + header fields + name + data + `AF_SEGTAIL` + segment length +//! - Page addressing via `page` segments +//! - Missing pages are treated as **zero-filled** regions +//! - Page compression flags follow `include/afflib/afflib.h` +//! +//! The current implementation loads the entire `.aff` file into memory. This keeps the logic +//! simple and deterministic while we harden correctness; it can be switched to a file-backed +//! reader later without changing the public [`forensic_image::ReadAt`] API. + +use crate::format; +use crate::{Error, Result}; +use flate2::read::ZlibDecoder; +use forensic_image::ReadAt; +use lru::LruCache; +use std::collections::HashMap; +use std::io::{self, Read}; +use std::num::NonZeroUsize; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +use super::backend::{Backend, ContainerKind, Segment}; + +#[derive(Debug, Clone, Copy)] +struct PageEntry { + data_offset: usize, + data_len: usize, + flags: u32, + is_aes256: bool, +} + +#[derive(Debug, Clone, Copy)] +struct SegmentEntry { + data_offset: usize, + data_len: usize, + arg: u32, +} + +/// Read-only AFF1 backend. +#[derive(Debug)] +pub(crate) struct Aff1Image { + data: Arc<[u8]>, + page_size: usize, + image_size: u64, + segments: HashMap, + pages: HashMap, + cache: Mutex>>, +} + +impl Aff1Image { + pub(crate) fn open_with(path: impl AsRef, page_cache_pages: usize) -> Result { + let data: Arc<[u8]> = std::fs::read(path)?.into(); + + if data.len() < format::AFF1_HEADER.len() || &data[0..8] != format::AFF1_HEADER { + return Err(Error::InvalidFormat { + message: "missing AFF1 header", + }); + } + + let mut cursor = 8usize; + + let mut page_size: Option = None; + let mut image_size: Option = None; + + let mut segments: HashMap = HashMap::new(); + let mut pages: HashMap = HashMap::new(); + + while cursor + 4 <= data.len() { + // Segment header + let magic = data + .get(cursor..cursor + 4) + .ok_or_else(|| Error::Io(io_eof()))?; + if magic != format::SEG_MAGIC { + break; + } + cursor += 4; + + let name_len = read_u32_be(&data, &mut cursor)? as usize; + let data_len = read_u32_be(&data, &mut cursor)? as usize; + let arg = read_u32_be(&data, &mut cursor)?; + + let name_bytes = read_slice(&data, &mut cursor, name_len)?; + let name = std::str::from_utf8(name_bytes).map_err(|_| Error::InvalidData { + message: "non-utf8 segment name".to_string(), + })?; + let is_aes256 = name.ends_with(format::AES256_SUFFIX); + let logical_name = name.strip_suffix(format::AES256_SUFFIX).unwrap_or(name); + + let data_offset = cursor; + cursor = cursor + .checked_add(data_len) + .ok_or_else(|| Error::InvalidData { + message: "segment overflow".to_string(), + })?; + if cursor > data.len() { + return Err(Error::Io(io_eof())); + } + + // Segment tail + segment_len + let trailer = read_slice(&data, &mut cursor, 4)?; + if trailer != format::SEG_TRAILER { + return Err(Error::InvalidData { + message: "segment missing ATT\\0 trailer".to_string(), + }); + } + let seg_len = read_u32_be(&data, &mut cursor)? as usize; + + // Validate segment_len. + let expected = 16usize + .checked_add(name_len) + .and_then(|v| v.checked_add(data_len)) + .and_then(|v| v.checked_add(8)) + .ok_or_else(|| Error::InvalidData { + message: "segment length overflow".to_string(), + })?; + if seg_len != expected { + return Err(Error::InvalidData { + message: format!("segment length mismatch: expected {expected}, got {seg_len}"), + }); + } + + // Record segment in the TOC (last write wins). + segments.insert( + name.to_string(), + SegmentEntry { + data_offset, + data_len, + arg, + }, + ); + + // Parse well-known metadata segments. + match logical_name { + format::SEG_PAGESIZE | format::SEG_SEGSIZE_DEPRECATED => { + // AFFLIB stores the page size in the `arg` field and uses `data_len == 0`. + // (It also supports the deprecated alias `segsize`.) + if data_len != 0 { + return Err(Error::InvalidData { + message: "pagesize/segsize segment must have empty data".to_string(), + }); + } + page_size = Some(arg as usize); + } + format::SEG_IMAGESIZE => { + // If this segment is encrypted (`imagesize/aes256`) we can't parse it + // without decryption; defer to the AFFLIB-style imagesize scan below. + if is_aes256 { + continue; + } + let quad = data + .get(data_offset..data_offset + data_len) + .ok_or_else(|| Error::Io(io_eof()))?; + if quad.len() != 8 { + return Err(Error::InvalidData { + message: "imagesize segment must be 8 bytes".to_string(), + }); + } + image_size = Some(read_aff_quad(quad)?); + } + _ => {} + } + + // Page segments: `page` (new) or `seg` (deprecated). + if let Some(page_index) = parse_page_number(logical_name) { + pages.insert( + page_index, + PageEntry { + data_offset, + data_len, + flags: arg, + is_aes256, + }, + ); + } + } + + let page_size = page_size.ok_or(Error::InvalidFormat { + message: "missing pagesize/segsize segment", + })?; + if page_size == 0 { + return Err(Error::InvalidData { + message: "pagesize cannot be 0".to_string(), + }); + } + + let image_size = (|| -> Result { + if let Some(v) = image_size { + return Ok(v); + } + + // AFFLIBv3 behavior (`af_read_sizes`): if `imagesize` is missing, compute it by + // finding the highest present page number and then asking for that page’s logical + // length (`af_get_page(..., data==NULL)`), which may require decompression. + let Some(max_page) = pages.keys().copied().max() else { + return Ok(0); + }; + let base = max_page.saturating_mul(page_size as u64); + let Some(last) = pages.get(&max_page).copied() else { + return Ok(base); + }; + + let page_logical_len = |entry: PageEntry| -> Result { + let seg = data + .get(entry.data_offset..entry.data_offset + entry.data_len) + .ok_or_else(|| Error::Io(io_eof()))?; + + // Uncompressed: the segment length is the logical length (for the last page this + // may be shorter than `pagesize`). + if (entry.flags & format::AF_PAGE_COMPRESSED) == 0 { + let mut len = entry.data_len as u64; + if entry.is_aes256 && !len.is_multiple_of(16) { + // AFFLIB `af_aes_decrypt(..., data==NULL)`: if ciphertext_len % 16 != 0, + // subtract one full AES block to recover the original length. + if len < 16 { + return Err(Error::InvalidData { + message: "encrypted page segment too small".to_string(), + }); + } + len -= 16; + } + return Ok(len); + } + + // Compressed: AFFLIB inflates the page even when just requesting the length. + // We cannot do this for encrypted compressed pages without the decryption key. + if entry.is_aes256 { + return Err(Error::InvalidData { + message: "cannot infer imagesize from encrypted+compressed last page (missing imagesize)".to_string(), + }); + } + + match entry.flags & format::AF_PAGE_COMP_ALG_MASK { + format::AF_PAGE_COMP_ALG_ZERO => { + if seg.len() != 4 { + return Err(Error::InvalidData { + message: "AFF ZERO-compressed page must have 4 bytes of data" + .to_string(), + }); + } + let count = u32::from_be_bytes(seg[0..4].try_into().expect("len=4")) as u64; + Ok(count.min(page_size as u64)) + } + format::AF_PAGE_COMP_ALG_ZLIB => { + let cursor = io::Cursor::new(seg); + let mut decoder = ZlibDecoder::new(cursor); + let mut out = vec![0u8; page_size]; + let mut written = 0usize; + while written < out.len() { + let n = decoder.read(&mut out[written..])?; + if n == 0 { + break; + } + written += n; + } + Ok(written as u64) + } + format::AF_PAGE_COMP_ALG_LZMA => { + #[cfg(feature = "lzma")] + { + let mut input = io::Cursor::new(seg); + let mut out = vec![0u8; page_size]; + let mut output = io::Cursor::new(&mut out[..]); + lzma_rs::lzma_decompress(&mut input, &mut output).map_err(|e| { + Error::InvalidData { + message: format!("LZMA: {e}"), + } + })?; + Ok(output.position()) + } + #[cfg(not(feature = "lzma"))] + { + Err(Error::InvalidData { + message: + "AFF page uses LZMA compression but feature `lzma` is disabled" + .to_string(), + }) + } + } + _ => Err(Error::InvalidData { + message: "unsupported AFF page compression".to_string(), + }), + } + }; + + let last_len = page_logical_len(last)?; + Ok(base.saturating_add(last_len)) + })()?; + + let cache_pages = + NonZeroUsize::new(page_cache_pages).ok_or_else(|| Error::InvalidData { + message: "page_cache_pages cannot be 0".to_string(), + })?; + + Ok(Self { + data, + page_size, + image_size, + segments, + pages, + // Keep small: typical pages are large (MiBs). + cache: Mutex::new(LruCache::new(cache_pages)), + }) + } + + pub(crate) fn page_indices(&self) -> impl Iterator + '_ { + self.pages.keys().copied() + } + + fn blank_page(&self) -> Vec { + vec![0u8; self.page_size] + } + + fn read_page(&self, page_index: u64) -> io::Result> { + if let Some(hit) = self.cache.lock().expect("poisoned").get(&page_index) { + return Ok(hit.clone()); + } + + let Some(entry) = self.pages.get(&page_index).copied() else { + let out = self.blank_page(); + self.cache + .lock() + .expect("poisoned") + .put(page_index, out.clone()); + return Ok(out); + }; + + let seg = self + .data + .get(entry.data_offset..entry.data_offset + entry.data_len) + .ok_or_else(io_eof)?; + + let mut out = self.blank_page(); + if (entry.flags & format::AF_PAGE_COMPRESSED) == 0 { + let take = seg.len().min(out.len()); + out[..take].copy_from_slice(&seg[..take]); + } else { + match entry.flags & format::AF_PAGE_COMP_ALG_MASK { + format::AF_PAGE_COMP_ALG_ZERO => { + if seg.len() != 4 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "AFF ZERO-compressed page must have 4 bytes of data", + )); + } + // Output remains all-zero. + } + format::AF_PAGE_COMP_ALG_ZLIB => { + let cursor = io::Cursor::new(seg); + let mut decoder = ZlibDecoder::new(cursor); + let mut written = 0usize; + while written < out.len() { + let n = decoder.read(&mut out[written..])?; + if n == 0 { + break; + } + written += n; + } + } + format::AF_PAGE_COMP_ALG_LZMA => { + #[cfg(feature = "lzma")] + { + let mut input = io::Cursor::new(seg); + let mut output = io::Cursor::new(&mut out[..]); + lzma_rs::lzma_decompress(&mut input, &mut output).map_err(|e| { + io::Error::new(io::ErrorKind::InvalidData, format!("LZMA: {e}")) + })?; + } + #[cfg(not(feature = "lzma"))] + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "AFF page uses LZMA compression but feature `lzma` is disabled", + )); + } + } + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unsupported AFF page compression", + )); + } + } + } + + self.cache + .lock() + .expect("poisoned") + .put(page_index, out.clone()); + Ok(out) + } +} + +impl ReadAt for Aff1Image { + 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_eof()); + } + + 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 page = self.read_page(page_index)?; + let take = remaining.min(self.page_size - within); + buf[out_pos..out_pos + take].copy_from_slice(&page[within..within + take]); + + out_pos += take; + remaining -= take; + cur = cur.saturating_add(take as u64); + } + + Ok(()) + } +} + +impl Backend for Aff1Image { + fn kind(&self) -> ContainerKind { + ContainerKind::Aff1 + } + + fn page_size(&self) -> usize { + self.page_size + } + + fn segment_names(&self) -> Vec { + let mut out = self.segments.keys().cloned().collect::>(); + out.sort(); + out + } + + fn read_segment(&self, name: &str) -> io::Result> { + let Some(ent) = self.segments.get(name).copied() else { + return Ok(None); + }; + let data = self + .data + .get(ent.data_offset..ent.data_offset + ent.data_len) + .ok_or_else(io_eof)?; + Ok(Some(Segment { + name: name.to_string(), + arg: ent.arg, + data: data.to_vec(), + })) + } +} + +fn io_eof() -> io::Error { + io::Error::from(io::ErrorKind::UnexpectedEof) +} + +fn read_u32_be(data: &[u8], cursor: &mut usize) -> Result { + let bytes = read_slice(data, cursor, 4)?; + Ok(u32::from_be_bytes(bytes.try_into().expect("len=4"))) +} + +fn read_slice<'a>(data: &'a [u8], cursor: &mut usize, len: usize) -> Result<&'a [u8]> { + let start = *cursor; + let end = start.checked_add(len).ok_or_else(|| Error::InvalidData { + message: "overflow".to_string(), + })?; + if end > data.len() { + return Err(Error::Io(io_eof())); + } + *cursor = end; + Ok(&data[start..end]) +} + +fn parse_page_number(name: &str) -> Option { + name.strip_prefix("page") + .or_else(|| name.strip_prefix("seg")) + .and_then(|rest| rest.parse::().ok()) +} + +/// Reads an AFFLIB `aff_quad` (8 bytes) as `u64`. +/// +/// The encoding is **little-endian in 32-bit words**: +/// - bytes `[0..4]` are the low 32 bits in network order (`htonl(low)`), +/// - bytes `[4..8]` are the high 32 bits in network order (`htonl(high)`). +fn read_aff_quad(bytes: &[u8]) -> Result { + if bytes.len() != 8 { + return Err(Error::InvalidData { + message: "AFF quad must be 8 bytes".to_string(), + }); + } + let low = u32::from_be_bytes(bytes[0..4].try_into().expect("len=4")); + let high = u32::from_be_bytes(bytes[4..8].try_into().expect("len=4")); + Ok(((high as u64) << 32) | (low as u64)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn aff_quad_u64(v: u64) -> [u8; 8] { + let low = (v & 0xffff_ffff) as u32; + let high = (v >> 32) as u32; + let mut out = [0u8; 8]; + out[0..4].copy_from_slice(&low.to_be_bytes()); + out[4..8].copy_from_slice(&high.to_be_bytes()); + out + } + + fn aff_segment(name: &str, data: &[u8], arg: u32) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(format::SEG_MAGIC); + out.extend_from_slice(&(name.len() as u32).to_be_bytes()); + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + out.extend_from_slice(&arg.to_be_bytes()); + out.extend_from_slice(name.as_bytes()); + out.extend_from_slice(data); + out.extend_from_slice(format::SEG_TRAILER); + let seg_len = (16 + name.len() + data.len() + 8) as u32; + out.extend_from_slice(&seg_len.to_be_bytes()); + out + } + + fn aff_segment_with_len(name: &str, data: &[u8], arg: u32, seg_len: u32) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(format::SEG_MAGIC); + out.extend_from_slice(&(name.len() as u32).to_be_bytes()); + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + out.extend_from_slice(&arg.to_be_bytes()); + out.extend_from_slice(name.as_bytes()); + out.extend_from_slice(data); + out.extend_from_slice(format::SEG_TRAILER); + out.extend_from_slice(&seg_len.to_be_bytes()); + out + } + + fn build_aff1_file(segments: Vec>) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(format::AFF1_HEADER); + for seg in segments { + out.extend_from_slice(&seg); + } + out + } + + #[test] + fn test_read_aff_quad_roundtrip() { + let v = 0x11223344_55667788u64; + let bytes = aff_quad_u64(v); + assert_eq!(read_aff_quad(&bytes).unwrap(), v); + } + + #[test] + fn test_pagesize_deprecated_segsize_is_accepted() { + let page_size = 8usize; + let image_size = page_size as u64; + + let bytes = build_aff1_file(vec![ + aff_segment(format::SEG_SEGSIZE_DEPRECATED, &[], page_size as u32), + aff_segment("imagesize", &aff_quad_u64(image_size), 2), + aff_segment("page0", b"ABCDEFGH", 0), + ]); + + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), bytes).unwrap(); + let img = Aff1Image::open_with(tmp.path(), 2).unwrap(); + + assert_eq!(img.page_size(), page_size); + assert_eq!(img.len(), image_size); + + let mut out = [0u8; 8]; + img.read_exact_at(0, &mut out).unwrap(); + assert_eq!(&out, b"ABCDEFGH"); + } + + #[test] + fn test_page_segment_deprecated_seg_prefix_is_accepted() { + let page_size = 8usize; + let image_size = page_size as u64; + + let bytes = build_aff1_file(vec![ + aff_segment("pagesize", &[], page_size as u32), + aff_segment("imagesize", &aff_quad_u64(image_size), 2), + aff_segment("seg0", b"ABCDEFGH", 0), + ]); + + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), bytes).unwrap(); + let img = Aff1Image::open_with(tmp.path(), 2).unwrap(); + + let mut out = [0u8; 8]; + img.read_exact_at(0, &mut out).unwrap(); + assert_eq!(&out, b"ABCDEFGH"); + } + + #[test] + fn test_open_rejects_bad_segment_len() { + let good = 16u32 + "pagesize".len() as u32 + 8; + let bad = good + 1; + let bytes = build_aff1_file(vec![aff_segment_with_len("pagesize", &[], 4096, bad)]); + + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), bytes).unwrap(); + + let err = Aff1Image::open_with(tmp.path(), 2).unwrap_err(); + let msg = match err { + Error::InvalidData { message } => message, + other => panic!("expected InvalidData, got {other:?}"), + }; + assert!(msg.contains("segment length mismatch")); + } + + #[test] + fn test_imagesize_inferred_adjusts_aes256_extra_len_like_afflib() { + // Mimic AFFLIB `af_aes_decrypt(..., data==0)`: if ciphertext_len % 16 != 0, logical length + // is reduced by one AES block (16). + let page_size = 16usize; + let ciphertext_len = 17usize; // extra=1 => subtract 16 + + let mut payload = vec![0xABu8; ciphertext_len]; + // Ensure the file has enough bytes for the segment. + payload[0] = 0xCD; + + let bytes = build_aff1_file(vec![ + aff_segment("pagesize", &[], page_size as u32), + aff_segment(&format!("page1{}", format::AES256_SUFFIX), &payload, 0), + ]); + + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), bytes).unwrap(); + let img = Aff1Image::open_with(tmp.path(), 2).unwrap(); + + assert_eq!(img.page_size(), page_size); + assert_eq!(img.len(), 17); + } + + #[test] + fn test_imagesize_inferred_from_last_page_len_when_missing() { + // Mirrors AFFLIB `af_read_sizes` behavior: if `imagesize` is missing, infer it from the + // highest page number + the logical length of that page. + let page_size = 8usize; + + let page0 = b"ABC"; + let mut enc = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + enc.write_all(page0).unwrap(); + let page0_z = enc.finish().unwrap(); + + let bytes = build_aff1_file(vec![ + aff_segment("pagesize", &[], page_size as u32), + // Intentionally omit `imagesize`. + aff_segment( + "page0", + &page0_z, + format::AF_PAGE_COMPRESSED | format::AF_PAGE_COMP_ALG_ZLIB, + ), + ]); + + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), bytes).unwrap(); + let img = Aff1Image::open_with(tmp.path(), 2).unwrap(); + + assert_eq!(img.len(), page0.len() as u64); + } + + #[test] + fn test_imagesize_inferred_from_zero_page_count_when_missing() { + let page_size = 8usize; + let count = 3u32; + let bytes = build_aff1_file(vec![ + aff_segment("pagesize", &[], page_size as u32), + // Intentionally omit `imagesize`. + aff_segment( + "page0", + &count.to_be_bytes(), + format::AF_PAGE_COMPRESSED | format::AF_PAGE_COMP_ALG_ZERO, + ), + ]); + + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), bytes).unwrap(); + let img = Aff1Image::open_with(tmp.path(), 2).unwrap(); + + assert_eq!(img.len(), count as u64); + } + + #[test] + #[cfg(feature = "lzma")] + fn test_lzma_page_decompression_roundtrip() { + let page_size = 64usize; + let image_size = page_size as u64; + + let mut page = [0u8; 64]; + for (i, b) in page.iter_mut().enumerate() { + *b = (i as u8).wrapping_mul(7); + } + + let mut input = std::io::BufReader::new(std::io::Cursor::new(page)); + let mut compressed = Vec::new(); + lzma_rs::lzma_compress(&mut input, &mut compressed).unwrap(); + + let bytes = build_aff1_file(vec![ + aff_segment("pagesize", &[], page_size as u32), + aff_segment("imagesize", &aff_quad_u64(image_size), 2), + aff_segment( + "page0", + &compressed, + format::AF_PAGE_COMPRESSED | format::AF_PAGE_COMP_ALG_LZMA, + ), + ]); + + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), bytes).unwrap(); + let img = Aff1Image::open_with(tmp.path(), 2).unwrap(); + + let mut out = vec![0u8; page_size]; + img.read_exact_at(0, &mut out).unwrap(); + assert_eq!(out.as_slice(), page.as_slice()); + } + + #[test] + fn test_zero_page_reads_as_zeros() { + let page_size = 8usize; + let image_size = page_size as u64; + + let zero_len = (page_size as u32).to_be_bytes(); + let bytes = build_aff1_file(vec![ + aff_segment("pagesize", &[], page_size as u32), + aff_segment("imagesize", &aff_quad_u64(image_size), 2), + aff_segment( + "page0", + &zero_len, + format::AF_PAGE_COMPRESSED | format::AF_PAGE_COMP_ALG_ZERO, + ), + ]); + + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), bytes).unwrap(); + let img = Aff1Image::open_with(tmp.path(), 2).unwrap(); + + let mut out = vec![0xAAu8; page_size]; + img.read_exact_at(0, &mut out).unwrap(); + assert_eq!(out, vec![0u8; page_size]); + } + + #[test] + fn test_zlib_page_decompression_roundtrip() { + let page_size = 32usize; + let image_size = page_size as u64; + + let mut page = [0u8; 32]; + for (i, b) in page.iter_mut().enumerate() { + *b = (i as u8).wrapping_mul(13); + } + + let mut enc = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + enc.write_all(&page).unwrap(); + let compressed = enc.finish().unwrap(); + + let bytes = build_aff1_file(vec![ + aff_segment("pagesize", &[], page_size as u32), + aff_segment("imagesize", &aff_quad_u64(image_size), 2), + aff_segment( + "page0", + &compressed, + format::AF_PAGE_COMPRESSED | format::AF_PAGE_COMP_ALG_ZLIB, + ), + ]); + + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), bytes).unwrap(); + let img = Aff1Image::open_with(tmp.path(), 2).unwrap(); + + let mut out = vec![0u8; page_size]; + img.read_exact_at(0, &mut out).unwrap(); + assert_eq!(out.as_slice(), page.as_slice()); + } +} diff --git a/crates/aff/src/backends/afm.rs b/crates/aff/src/backends/afm.rs new file mode 100644 index 0000000..4a37393 --- /dev/null +++ b/crates/aff/src/backends/afm.rs @@ -0,0 +1,151 @@ +//! AFM (`.afm`) backend. +//! +//! An AFM container is a **compound** format: +//! - Metadata is stored as an AFF1 file (segments) in the `.afm` path. +//! - The disk bytes are stored as one or more split-raw files, where the first file extension +//! is given by the segment `raw_image_file_extension` (usually `"000"`). +//! +//! This backend is **read-only** in this workspace. + +use super::aff1::Aff1Image; +use super::backend::{Backend, ContainerKind, Segment}; +use super::split_raw::SplitRawImage; +use crate::format; +use crate::{Error, Result}; +use forensic_image::ReadAt; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +#[derive(Debug)] +pub(crate) struct AfmImage { + meta: Arc, + raw: SplitRawImage, + page_size: usize, + image_size: u64, +} + +impl AfmImage { + pub(crate) fn open_with(path: impl AsRef, page_cache_pages: usize) -> Result { + let path = path.as_ref(); + + let meta = Arc::new(Aff1Image::open_with(path, page_cache_pages)?); + let page_size = meta.page_size(); + + let ext = read_raw_extension(&meta)?; + let raw_path = replace_extension(path, &ext)?; + + let raw = SplitRawImage::open(&raw_path).map_err(Error::Io)?; + let image_size = raw.len(); + + Ok(Self { + meta, + raw, + page_size, + image_size, + }) + } + + fn read_page_segment(&self, page_index: u64) -> io::Result> { + let page_size = self.page_size; + let pos = page_index.saturating_mul(page_size as u64); + if pos >= self.image_size { + return Ok(Vec::new()); + } + let take = (self.image_size - pos).min(page_size as u64) as usize; + let mut buf = vec![0u8; take]; + self.raw.read_exact_at(pos, &mut buf)?; + Ok(buf) + } +} + +impl ReadAt for AfmImage { + fn len(&self) -> u64 { + self.image_size + } + + fn read_exact_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + self.raw.read_exact_at(offset, buf) + } +} + +impl Backend for AfmImage { + fn kind(&self) -> ContainerKind { + ContainerKind::Afm + } + + fn page_size(&self) -> usize { + self.page_size + } + + fn segment_names(&self) -> Vec { + // Metadata segments only (listing all page segments would be enormous). + self.meta.segment_names() + } + + fn read_segment(&self, name: &str) -> io::Result> { + if let Some(seg) = self.meta.read_segment(name)? { + return Ok(Some(seg)); + } + + // If the caller asks for `page`, read it from the split-raw payload. + if let Some(page_index) = name.strip_prefix("page").and_then(|r| r.parse::().ok()) { + let data = self.read_page_segment(page_index)?; + return Ok(Some(Segment { + name: name.to_string(), + arg: 0, + data, + })); + } + + Ok(None) + } +} + +fn read_raw_extension(meta: &Aff1Image) -> Result { + let seg = meta + .read_segment(format::AF_RAW_IMAGE_FILE_EXTENSION) + .map_err(Error::Io)? + .ok_or(Error::InvalidFormat { + message: "AFM missing raw_image_file_extension segment", + })?; + + let s = std::str::from_utf8(&seg.data).map_err(|_| Error::InvalidData { + message: "AFM raw_image_file_extension is not UTF-8".to_string(), + })?; + let s = s.trim_matches(char::from(0)).trim(); + + if s.is_empty() { + return Err(Error::InvalidData { + message: "AFM raw_image_file_extension is empty".to_string(), + }); + } + if s.len() != 3 { + return Err(Error::InvalidData { + message: "AFM raw_image_file_extension must be 3 characters".to_string(), + }); + } + if s.contains('.') || s.contains('/') || s.contains('\\') { + return Err(Error::InvalidData { + message: "AFM raw_image_file_extension contains invalid characters".to_string(), + }); + } + + Ok(s.to_string()) +} + +fn replace_extension(path: &Path, new_ext: &str) -> Result { + let Some(old_ext) = path.extension().and_then(|e| e.to_str()) else { + return Err(Error::InvalidData { + message: "AFM path has no extension".to_string(), + }); + }; + if old_ext.len() != 3 { + return Err(Error::InvalidData { + message: "AFM path extension must be 3 characters".to_string(), + }); + } + Ok(path.with_extension(new_ext)) +} + + diff --git a/crates/aff/src/backends/backend.rs b/crates/aff/src/backends/backend.rs new file mode 100644 index 0000000..9941c31 --- /dev/null +++ b/crates/aff/src/backends/backend.rs @@ -0,0 +1,43 @@ +//! Backend abstraction shared by all container implementations. + +use forensic_image::ReadAt; +use std::io; + +/// Concrete container kind backing an [`crate::backends::AffImage`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContainerKind { + /// AFF1 single-file (`.aff`). + Aff1, + /// AFM metadata file (`.afm`) + split-raw payload. + Afm, + /// AFD directory container. + Afd, +} + +/// Raw segment bytes as stored by the underlying container (no decryption, no signature checks). +/// +/// Higher layers (crypto wrapper) may expose **decrypted** views in the future. +#[derive(Debug, Clone)] +pub struct Segment { + /// Segment name (e.g. `pagesize`, `imagesize`, `page0`). + pub name: String, + /// Segment `arg` field (AFFLIB calls this `flag` in `af_segment_head`). + pub arg: u32, + /// Segment data bytes. + pub data: Vec, +} + +pub(crate) trait Backend: ReadAt { + fn kind(&self) -> ContainerKind; + fn page_size(&self) -> usize; + + /// Lists segment names present in the container. + fn segment_names(&self) -> Vec; + + /// Reads a segment by name. + /// + /// Returns `Ok(None)` if the segment does not exist. + fn read_segment(&self, name: &str) -> io::Result>; +} + + diff --git a/crates/aff/src/backends/mod.rs b/crates/aff/src/backends/mod.rs new file mode 100644 index 0000000..f8bc525 --- /dev/null +++ b/crates/aff/src/backends/mod.rs @@ -0,0 +1,15 @@ +//! Container backends and high-level entry points. +//! +//! This module provides the crate’s main public surface: +//! - [`AffOpenOptions`] for opening images with optional crypto configuration +//! - [`AffImage`], a read-only disk image view implementing [`forensic_image::ReadAt`] + +mod afd; +mod aff1; +mod afm; +pub(crate) mod backend; +mod open; +mod split_raw; + +pub use backend::{ContainerKind, Segment}; +pub use open::{AffImage, AffOpenOptions}; diff --git a/crates/aff/src/backends/open.rs b/crates/aff/src/backends/open.rs new file mode 100644 index 0000000..f9f7c0f --- /dev/null +++ b/crates/aff/src/backends/open.rs @@ -0,0 +1,271 @@ +use crate::Result; +use crate::backends::backend::Backend; +use crate::backends::{ContainerKind, Segment}; +use forensic_image::ReadAt; +use std::io::Read as _; +use std::path::Path; +use std::sync::Arc; + +use crate::backends::afd::AfdImage; +use crate::backends::aff1::Aff1Image; +use crate::backends::afm::AfmImage; + +/// Open options for AFF containers. +/// +/// This type will expand as more AFFLIB-compatible features are ported (AFM/AFD, crypto, etc.). +#[derive(Debug, Clone)] +pub struct AffOpenOptions { + /// Optional passphrase for decrypting `affkey_aes256` + `/aes256` segments. + pub passphrase: Option, + + /// Optional PEM private key path used to unseal `affkey_evp%d` segments. + pub unseal_keyfile: Option, + + /// If true, enable auto-decryption of `/aes256` segments (when a key is available). + pub auto_decrypt: bool, + + /// Number of decompressed pages to cache in memory for page-based containers (AFF1/AFD). + /// + /// This is a performance knob; it does not affect correctness. + pub page_cache_pages: usize, +} + +impl AffOpenOptions { + /// Creates a new set of open options. + /// + /// ## Example + /// + /// ```no_run + /// use aff::AffOpenOptions; + /// + /// let opts = AffOpenOptions::new(); + /// # Ok::<(), aff::Error>(()) + /// ``` + pub fn new() -> Self { + Self { + passphrase: None, + unseal_keyfile: None, + auto_decrypt: true, + page_cache_pages: 2, + } + } + + /// Opens an AFF-like container from a path and returns an [`AffImage`]. + /// + /// The backend is determined from the on-disk content and/or extension. + /// + /// ## Examples + /// + /// ```no_run + /// use aff::AffOpenOptions; + /// + /// let img = AffOpenOptions::new().open("image.aff")?; + /// # Ok::<(), aff::Error>(()) + /// ``` + pub fn open(&self, path: impl AsRef) -> Result { + AffImage::open_with(path, self) + } +} + +impl Default for AffOpenOptions { + fn default() -> Self { + Self::new() + } +} + +/// A read-only random-access view over an AFF-like container. +/// +/// The concrete backend is selected automatically on open. +#[derive(Clone)] +pub struct AffImage { + inner: Arc, +} + +impl std::fmt::Debug for AffImage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AffImage") + .field("kind", &self.kind()) + .field("len", &self.len()) + .field("page_size", &self.page_size()) + .finish() + } +} + +impl AffImage { + /// Opens an AFF-like container from a path. + /// + /// ## Example + /// + /// ```no_run + /// use aff::AffImage; + /// use forensic_image::ReadAt; + /// + /// let img = AffImage::open("image.aff")?; + /// println!("len={}", img.len()); + /// # Ok::<(), aff::Error>(()) + /// ``` + pub fn open(path: impl AsRef) -> Result { + Self::open_with(path, &AffOpenOptions::new()) + } + + /// Opens an AFF-like container from a path using [`AffOpenOptions`]. + /// + /// ## Example + /// + /// ```no_run + /// use aff::AffOpenOptions; + /// + /// let mut opts = AffOpenOptions::new(); + /// opts.passphrase = Some("password".to_string()); + /// let img = opts.open("encrypted.aff")?; + /// # Ok::<(), aff::Error>(()) + /// ``` + pub fn open_with(path: impl AsRef, opts: &AffOpenOptions) -> Result { + let path = path.as_ref(); + + let data: Arc = if path.is_dir() { + Arc::new(AfdImage::open_with(path, opts.page_cache_pages)?) + } else { + // Prefer content sniffing when possible. + let mut header = [0u8; 8]; + let mut f = std::fs::File::open(path)?; + let n = f.read(&mut header)?; + drop(f); + + if n == header.len() && header == *crate::format::AFF1_HEADER { + // `.afm` is also an AFF1 container, but AFM has additional split-raw semantics. + // Use extension as a disambiguator if present. + match path + .extension() + .and_then(|s| s.to_str()) + .unwrap_or_default() + { + "afm" | "AFM" => Arc::new(AfmImage::open_with(path, opts.page_cache_pages)?), + _ => Arc::new(Aff1Image::open_with(path, opts.page_cache_pages)?), + } + } else { + match path + .extension() + .and_then(|s| s.to_str()) + .unwrap_or_default() + { + "afm" | "AFM" => Arc::new(AfmImage::open_with(path, opts.page_cache_pages)?), + "aff" | "AFF" => Arc::new(Aff1Image::open_with(path, opts.page_cache_pages)?), + other => { + return Err(crate::Error::Unsupported { + what: format!("unknown AFF container extension: {other}"), + }); + } + } + } + }; + + // Wrap with crypto layer if enabled (feature-gated). + #[cfg(feature = "crypto")] + { + let data = crate::crypto::wrap_backend(data, opts)?; + Ok(Self { inner: data }) + } + #[cfg(not(feature = "crypto"))] + { + let _ = opts; + Ok(Self { inner: data }) + } + } + + /// Returns the container kind backing this image. + /// + /// ## Example + /// + /// ```no_run + /// use aff::AffImage; + /// + /// let img = AffImage::open("image.aff")?; + /// println!("{:?}", img.kind()); + /// # Ok::<(), aff::Error>(()) + /// ``` + pub fn kind(&self) -> ContainerKind { + self.inner.kind() + } + + /// Returns the natural page size for this container, if applicable. + /// + /// ## Example + /// + /// ```no_run + /// use aff::AffImage; + /// + /// let img = AffImage::open("image.aff")?; + /// println!("pagesize={}", img.page_size()); + /// # Ok::<(), aff::Error>(()) + /// ``` + pub fn page_size(&self) -> usize { + self.inner.page_size() + } + + /// Returns `true` if a segment exists. + /// + /// ## Example + /// + /// ```no_run + /// use aff::AffImage; + /// + /// let img = AffImage::open("image.aff")?; + /// if img.has_segment("imagesize")? { + /// println!("has imagesize"); + /// } + /// # Ok::<(), aff::Error>(()) + /// ``` + pub fn has_segment(&self, name: &str) -> std::io::Result { + Ok(self.inner.read_segment(name)?.is_some()) + } + + /// Lists segment names present in this container. + /// + /// Note: some backends intentionally only list *metadata* segments (e.g. AFM) + /// to avoid materializing extremely large page lists. + /// + /// ## Example + /// + /// ```no_run + /// use aff::AffImage; + /// + /// let img = AffImage::open("image.aff")?; + /// let names = img.segment_names(); + /// assert!(names.iter().any(|n| n == "pagesize")); + /// # Ok::<(), aff::Error>(()) + /// ``` + pub fn segment_names(&self) -> Vec { + self.inner.segment_names() + } + + /// Reads a segment by name. + /// + /// When the `crypto` feature is enabled and auto-decryption is active, this may transparently + /// return a decrypted view of `"{name}/aes256"` as `name`, matching AFFLIB behavior. + /// + /// ## Example + /// + /// ```no_run + /// use aff::AffImage; + /// + /// let img = AffImage::open("image.aff")?; + /// if let Some(seg) = img.read_segment("pagesize")? { + /// println!("pagesize arg={}", seg.arg); + /// } + /// # Ok::<(), aff::Error>(()) + /// ``` + pub fn read_segment(&self, name: &str) -> std::io::Result> { + self.inner.read_segment(name) + } +} + +impl ReadAt for AffImage { + fn len(&self) -> u64 { + self.inner.len() + } + + fn read_exact_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> { + self.inner.read_exact_at(offset, buf) + } +} diff --git a/crates/aff/src/backends/split_raw.rs b/crates/aff/src/backends/split_raw.rs new file mode 100644 index 0000000..fb3acb8 --- /dev/null +++ b/crates/aff/src/backends/split_raw.rs @@ -0,0 +1,306 @@ +//! Split-raw backend (AFM payload). +//! +//! AFFLIB’s AFM container stores metadata in an AFF file (`.afm`) and stores the actual disk +//! bytes in one or more *raw* files whose extension is provided by the metadata segment +//! `raw_image_file_extension` (typically `"000"`). +//! +//! The payload may be split across multiple files by incrementing the **3-character extension** +//! (e.g. `.000`, `.001`, …, `.999`, `.A00`, …) using the same scheme as AFFLIBv3. + +use forensic_image::ReadAt; +use std::fs::File; +use std::io; +use std::path::{Path, PathBuf}; + +#[cfg(unix)] +use std::os::unix::fs::FileExt; +#[cfg(windows)] +use std::os::windows::fs::FileExt; + +#[cfg(unix)] +fn file_read_at(file: &File, buf: &mut [u8], offset: u64) -> io::Result { + file.read_at(buf, offset) +} + +#[cfg(windows)] +fn file_read_at(file: &File, buf: &mut [u8], offset: u64) -> io::Result { + file.seek_read(buf, offset) +} + +fn io_eof() -> io::Error { + io::Error::from(io::ErrorKind::UnexpectedEof) +} + +#[derive(Debug)] +pub(crate) struct SplitRawImage { + files: Vec, + /// Size of a “full” chunk file (the first file size) when split across multiple files. + /// + /// If the image is not split, this is `0` and `files.len() == 1`. + maxsize: u64, + image_size: u64, +} + +impl SplitRawImage { + pub(crate) fn open(first_path: impl AsRef) -> io::Result { + let first_path = first_path.as_ref().to_path_buf(); + let mut files = Vec::new(); + + let first = File::open(&first_path)?; + let first_len = first.metadata()?.len(); + files.push(first); + + // Try to open additional files by incrementing the 3-char extension. + let mut next_path = first_path.clone(); + let mut must_be_last = false; + let mut maxsize = 0u64; + let mut last_len = first_len; + + loop { + if !increment_split_raw_extension(&mut next_path) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "split_raw: too many files (extension overflow)", + )); + } + + match File::open(&next_path) { + Ok(f) => { + let len = f.metadata()?.len(); + files.push(f); + + if must_be_last { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "split_raw: found file after a short final segment", + )); + } + + if maxsize == 0 { + // Second file exists: lock in the maxsize as the first file size. + maxsize = first_len; + } + + if maxsize != 0 && len != maxsize { + // This file is smaller than the maxsize => must be the last file. + must_be_last = true; + } + + last_len = len; + } + Err(e) if e.kind() == io::ErrorKind::NotFound => { + break; + } + Err(e) => return Err(e), + } + } + + let image_size = if maxsize == 0 { + first_len + } else { + last_len + maxsize.saturating_mul(files.len().saturating_sub(1) as u64) + }; + + Ok(Self { + files, + maxsize, + image_size, + }) + } +} + +impl ReadAt for SplitRawImage { + fn len(&self) -> u64 { + self.image_size + } + + fn read_exact_at(&self, offset: u64, mut buf: &mut [u8]) -> io::Result<()> { + if offset.saturating_add(buf.len() as u64) > self.len() { + return Err(io_eof()); + } + + let mut cur = offset; + while !buf.is_empty() { + let (file_idx, file_off) = if self.maxsize == 0 { + (0usize, cur) + } else { + let idx = (cur / self.maxsize) as usize; + let off = cur % self.maxsize; + (idx, off) + }; + + let file = self + .files + .get(file_idx) + .ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "missing split file"))?; + + let max_in_file = if self.maxsize == 0 { + // Single file. + buf.len() as u64 + } else { + // Don't cross file boundary. + (self.maxsize - file_off).min(buf.len() as u64) + } as usize; + + let mut tmp = &mut buf[..max_in_file]; + let mut file_cursor = file_off; + while !tmp.is_empty() { + let n = file_read_at(file, tmp, file_cursor)?; + if n == 0 { + return Err(io_eof()); + } + file_cursor = file_cursor.saturating_add(n as u64); + let t = tmp; + tmp = &mut t[n..]; + } + + cur = cur.saturating_add(max_in_file as u64); + let t = buf; + buf = &mut t[max_in_file..]; + } + + Ok(()) + } +} + +/// Increments the filename extension in-place according to AFFLIBv3 rules. +/// +/// Returns `false` if the path does not have a 3-character extension (cannot be incremented). +fn increment_split_raw_extension(path: &mut PathBuf) -> bool { + let Some(ext) = path.extension().and_then(|e| e.to_str()) else { + return false; + }; + if ext.len() != 3 { + return false; + } + + let mut bytes = [0u8; 3]; + bytes.copy_from_slice(ext.as_bytes()); + + // Numeric case: 000..999 then A00. + if bytes.iter().all(|b| b.is_ascii_digit()) { + let num = ((bytes[0] - b'0') as u32) * 100 + ((bytes[1] - b'0') as u32) * 10 + (bytes[2] - b'0') as u32; + let next = if num == 999 { + *b"A00" + } else { + let n = (num + 1) % 1000; + [ + b'0' + ((n / 100) as u8), + b'0' + (((n / 10) % 10) as u8), + b'0' + ((n % 10) as u8), + ] + }; + let next_ext = std::str::from_utf8(&next).unwrap(); + path.set_extension(next_ext); + return true; + } + + // Base36 case: uppercase for increment, preserve original case if first char was lowercase. + let lower = bytes[0].is_ascii_lowercase(); + for b in &mut bytes { + if b.is_ascii_alphabetic() { + *b = b.to_ascii_uppercase(); + } + } + + fn incval(ch: &mut u8) -> bool { + match *ch { + b'Z' => { + *ch = b'0'; + true + } + b'9' => { + *ch = b'A'; + false + } + _ => { + *ch = (*ch).saturating_add(1); + false + } + } + } + + let carry2 = incval(&mut bytes[2]); + let carry1 = carry2 && incval(&mut bytes[1]); + let carry0 = carry1 && incval(&mut bytes[0]); + if carry0 { + // Too many files — AFFLIB would error; here we stop incrementing. + return false; + } + + if lower { + for b in &mut bytes { + if b.is_ascii_alphabetic() { + *b = b.to_ascii_lowercase(); + } + } + } + + let next_ext = std::str::from_utf8(&bytes).unwrap(); + path.set_extension(next_ext); + true +} + +#[cfg(test)] +mod tests { + use super::increment_split_raw_extension; + use std::path::{Path, PathBuf}; + + fn ext(path: &Path) -> String { + path.extension().unwrap().to_string_lossy().to_string() + } + + #[test] + fn test_increment_numeric() { + let mut p = PathBuf::from("image.000"); + assert!(increment_split_raw_extension(&mut p)); + assert_eq!(ext(&p), "001"); + + let mut p = PathBuf::from("image.009"); + assert!(increment_split_raw_extension(&mut p)); + assert_eq!(ext(&p), "010"); + + let mut p = PathBuf::from("image.999"); + assert!(increment_split_raw_extension(&mut p)); + assert_eq!(ext(&p), "A00"); + } + + #[test] + fn test_increment_base36_uppercase() { + let mut p = PathBuf::from("image.A00"); + assert!(increment_split_raw_extension(&mut p)); + assert_eq!(ext(&p), "A01"); + + let mut p = PathBuf::from("image.A0Z"); + assert!(increment_split_raw_extension(&mut p)); + assert_eq!(ext(&p), "A10"); + + let mut p = PathBuf::from("image.AZZ"); + assert!(increment_split_raw_extension(&mut p)); + assert_eq!(ext(&p), "B00"); + } + + #[test] + fn test_increment_preserves_lowercase() { + let mut p = PathBuf::from("image.a00"); + assert!(increment_split_raw_extension(&mut p)); + assert_eq!(ext(&p), "a01"); + + let mut p = PathBuf::from("image.a0z"); + assert!(increment_split_raw_extension(&mut p)); + assert_eq!(ext(&p), "a10"); + } + + #[test] + fn test_increment_requires_three_char_extension() { + let mut p = PathBuf::from("image.raw"); + assert!(increment_split_raw_extension(&mut p)); + assert_eq!(ext(&p), "rax"); + + let mut p = PathBuf::from("image.00"); + assert!(!increment_split_raw_extension(&mut p)); + assert_eq!(ext(&p), "00"); + } +} + + diff --git a/crates/aff/src/bin/aff-cat.rs b/crates/aff/src/bin/aff-cat.rs new file mode 100644 index 0000000..e462f7e --- /dev/null +++ b/crates/aff/src/bin/aff-cat.rs @@ -0,0 +1,64 @@ +use aff::AffOpenOptions; +use clap::Parser; +use forensic_image::ReadAt; +use std::io::Write; +use std::path::PathBuf; + +/// Stream bytes from an AFF container to stdout. +/// +/// This is a read-only tool intended for piping into other utilities. +#[derive(Debug, Parser)] +#[command(author, version, about)] +struct Cli { + /// Path to an AFF1 file, an AFM file, or an AFD directory. + path: PathBuf, + + /// Passphrase for decrypting `/aes256` segments (AFFLIB `affkey_aes256`). + #[arg(long)] + passphrase: Option, + + /// PEM private key used to unseal `affkey_evp%d` segments. + #[arg(long)] + unseal_keyfile: Option, + + /// Starting offset (bytes). + #[arg(long, default_value_t = 0)] + offset: u64, + + /// Number of bytes to read (defaults to the remainder of the image). + #[arg(long)] + length: Option, +} + +fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + + let mut opts = AffOpenOptions::new(); + opts.passphrase = cli.passphrase; + opts.unseal_keyfile = cli.unseal_keyfile; + + let img = opts.open(&cli.path)?; + + let len = img.len(); + if cli.offset > len { + anyhow::bail!("offset {} is past end-of-image {}", cli.offset, len); + } + let to_read = cli.length.unwrap_or_else(|| len - cli.offset); + + let mut stdout = std::io::stdout().lock(); + let mut buf = vec![0u8; 1024 * 1024]; + + let mut remaining = to_read; + let mut cur = cli.offset; + while remaining > 0 { + let take = (remaining as usize).min(buf.len()); + img.read_exact_at(cur, &mut buf[..take])?; + stdout.write_all(&buf[..take])?; + cur = cur.saturating_add(take as u64); + remaining -= take as u64; + } + stdout.flush()?; + Ok(()) +} + + diff --git a/crates/aff/src/bin/aff-info.rs b/crates/aff/src/bin/aff-info.rs new file mode 100644 index 0000000..c33c783 --- /dev/null +++ b/crates/aff/src/bin/aff-info.rs @@ -0,0 +1,50 @@ +use aff::AffOpenOptions; +use clap::Parser; +use forensic_image::ReadAt; +use std::path::PathBuf; + +/// Print basic information about an AFF container. +#[derive(Debug, Parser)] +#[command(author, version, about)] +struct Cli { + /// Path to an AFF1 file, an AFM file, or an AFD directory. + path: PathBuf, + + /// Passphrase for decrypting `/aes256` segments (AFFLIB `affkey_aes256`). + #[arg(long)] + passphrase: Option, + + /// PEM private key used to unseal `affkey_evp%d` segments. + #[arg(long)] + unseal_keyfile: Option, + + /// Print all segment names. + #[arg(long)] + segments: bool, +} + +fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + + let mut opts = AffOpenOptions::new(); + opts.passphrase = cli.passphrase; + opts.unseal_keyfile = cli.unseal_keyfile; + + let img = opts.open(&cli.path)?; + + println!("kind: {:?}", img.kind()); + println!("len: {}", img.len()); + println!("page_size: {}", img.page_size()); + + let names = img.segment_names(); + println!("segments: {}", names.len()); + if cli.segments { + for n in names { + println!("{n}"); + } + } + + Ok(()) +} + + diff --git a/crates/aff/src/bin/aff-verify.rs b/crates/aff/src/bin/aff-verify.rs new file mode 100644 index 0000000..37ea31e --- /dev/null +++ b/crates/aff/src/bin/aff-verify.rs @@ -0,0 +1,51 @@ +use aff::{AffOpenOptions, SignatureStatus, Verifier}; +use clap::Parser; +use std::path::PathBuf; + +/// Verify `/sha256` signature segments in an AFF container. +#[derive(Debug, Parser)] +#[command(author, version, about)] +struct Cli { + /// Path to an AFF1 file, an AFM file, or an AFD directory. + path: PathBuf, + + /// Passphrase for decrypting `/aes256` segments (AFFLIB `affkey_aes256`). + #[arg(long)] + passphrase: Option, + + /// PEM private key used to unseal `affkey_evp%d` segments. + #[arg(long)] + unseal_keyfile: Option, +} + +fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + + let mut opts = AffOpenOptions::new(); + opts.passphrase = cli.passphrase; + opts.unseal_keyfile = cli.unseal_keyfile; + + let img = opts.open(&cli.path)?; + let verifier = Verifier::new(&img); + let results = verifier.verify_all()?; + + if results.is_empty() { + println!("no signature segments found"); + return Ok(()); + } + + let mut bad = false; + for (name, status) in results { + println!("{status:?}\t{name}"); + if status != SignatureStatus::Good { + bad = true; + } + } + + if bad { + std::process::exit(1); + } + Ok(()) +} + + diff --git a/crates/aff/src/crypto/mod.rs b/crates/aff/src/crypto/mod.rs new file mode 100644 index 0000000..63b1197 --- /dev/null +++ b/crates/aff/src/crypto/mod.rs @@ -0,0 +1,16 @@ +//! AFF crypto (read-side). +//! +//! This module provides a wrapper layer around a container backend which can: +//! - derive an AFF AES key from `affkey_aes256` using a passphrase +//! - unseal `affkey_evp%d` using an RSA private key (PEM) +//! - auto-decrypt `/aes256` segments on read (CBC with IV derived from segment name) +//! +//! Signature verification is implemented in [`crate::verify`]. +//! +//! The implementation is modeled after AFFLIBv3 (`lib/crypto.cpp` + `lib/afflib.cpp`). + +mod wrapper; + +pub(crate) use wrapper::wrap_backend; + + diff --git a/crates/aff/src/crypto/wrapper.rs b/crates/aff/src/crypto/wrapper.rs new file mode 100644 index 0000000..39cddaa --- /dev/null +++ b/crates/aff/src/crypto/wrapper.rs @@ -0,0 +1,531 @@ +use crate::backends::AffOpenOptions; +use crate::backends::backend::Backend; +use crate::format; +use crate::format::AF_AFFKEY; +use crate::{ContainerKind, Segment}; +use crate::{Error, Result}; +use forensic_image::ReadAt; +use openssl::hash::{MessageDigest, hash}; +use openssl::pkey::PKey; +use openssl::rsa::Rsa; +use openssl::symm::{Cipher, Crypter, Mode}; +use std::io; +use std::io::Read; +use std::path::Path; +use std::sync::Arc; + +pub(crate) fn wrap_backend( + inner: Arc, + opts: &AffOpenOptions, +) -> Result> { + let mut state = CryptoState::new(inner.clone()); + + if let Some(pass) = opts.passphrase.as_deref() { + state.try_set_passphrase(&*inner, pass)?; + } + if let Some(keyfile) = opts.unseal_keyfile.as_deref() { + state.try_set_unseal_keyfile(&*inner, keyfile)?; + } + + Ok(Arc::new(CryptoBackend { + inner, + state, + auto_decrypt: opts.auto_decrypt, + })) +} + +#[derive(Debug, Clone)] +struct CryptoState { + aes_key: Option<[u8; 32]>, +} + +impl CryptoState { + fn new(_inner: Arc) -> Self { + Self { aes_key: None } + } + + fn try_set_passphrase(&mut self, inner: &dyn Backend, passphrase: &str) -> Result<()> { + // Derive SHA256(passphrase) + let hash = hash(MessageDigest::sha256(), passphrase.as_bytes()).map_err(|e| { + Error::InvalidData { + message: format!("sha256(passphrase): {e}"), + } + })?; + + let seg = match inner.read_segment(AF_AFFKEY).map_err(Error::Io)? { + Some(s) => s, + None => { + return Err(Error::InvalidData { + message: "missing affkey_aes256 segment".to_string(), + }); + } + }; + + // On-disk can be 52 bytes (correct) or 56 bytes (legacy packing bug). + if seg.data.len() < 52 { + return Err(Error::InvalidData { + message: format!("affkey_aes256 too small: {}", seg.data.len()), + }); + } + + let version = u32::from_be_bytes(seg.data[0..4].try_into().expect("len=4")); + if version != 1 { + return Err(Error::InvalidData { + message: format!("affkey_aes256 wrong version: {version}"), + }); + } + + let mut affkey_enc = [0u8; 32]; + affkey_enc.copy_from_slice(&seg.data[4..36]); + let mut zeros_enc = [0u8; 16]; + zeros_enc.copy_from_slice(&seg.data[36..52]); + + fn aes256_ecb_decrypt_block(key: &[u8], block16: &[u8]) -> Result<[u8; 16]> { + let cipher = Cipher::aes_256_ecb(); + let mut dec = + Crypter::new(cipher, Mode::Decrypt, key, None).map_err(|e| Error::InvalidData { + message: format!("aes256-ecb init: {e}"), + })?; + dec.pad(false); + + let mut out = [0u8; 32]; + let mut n = dec + .update(block16, &mut out) + .map_err(|e| Error::InvalidData { + message: format!("aes update: {e}"), + })?; + n += dec + .finalize(&mut out[n..]) + .map_err(|e| Error::InvalidData { + message: format!("aes finalize: {e}"), + })?; + if n != 16 { + return Err(Error::InvalidData { + message: "aes ecb decrypt produced unexpected length".to_string(), + }); + } + + Ok(out[..16].try_into().expect("len=16")) + } + + let block0 = aes256_ecb_decrypt_block(hash.as_ref(), &affkey_enc[0..16])?; + let block1 = aes256_ecb_decrypt_block(hash.as_ref(), &affkey_enc[16..32])?; + let zeros = aes256_ecb_decrypt_block(hash.as_ref(), &zeros_enc)?; + + if zeros.iter().any(|&b| b != 0) { + return Err(Error::InvalidData { + message: "wrong passphrase (zeros check failed)".to_string(), + }); + } + + let mut aes_key = [0u8; 32]; + aes_key[0..16].copy_from_slice(&block0); + aes_key[16..32].copy_from_slice(&block1); + self.aes_key = Some(aes_key); + Ok(()) + } + + fn try_set_unseal_keyfile( + &mut self, + inner: &dyn Backend, + private_keyfile: &Path, + ) -> Result<()> { + // Port of AFFLIB's `af_get_affkey_using_keyfile` rev-1 only. + let pem = std::fs::read(private_keyfile)?; + let rsa = Rsa::private_key_from_pem(&pem).map_err(|e| Error::InvalidData { + message: format!("failed to parse PEM private key: {e}"), + })?; + let pkey = PKey::from_rsa(rsa).map_err(|e| Error::InvalidData { + message: format!("failed to build PKey: {e}"), + })?; + + for i in 0..1000u32 { + let name = format!("affkey_evp{i}"); + let Some(seg) = inner.read_segment(&name).map_err(Error::Io)? else { + // AFFLIB treats missing as failure; stop searching. + break; + }; + + // Layout: + // u32 version (BE) == 1 + // u32 ek_size (BE) + // u32 total_encrypted_bytes (BE) + // iv[EVP_MAX_IV_LENGTH==16] + // ek[ek_size] + // encrypted_affkey[total_encrypted_bytes] + if seg.data.len() < 12 + 16 { + continue; + } + let v = u32::from_be_bytes(seg.data[0..4].try_into().unwrap()); + if v != 1 { + continue; + } + let ek_size = u32::from_be_bytes(seg.data[4..8].try_into().unwrap()) as usize; + let total = u32::from_be_bytes(seg.data[8..12].try_into().unwrap()) as usize; + let expected = 12 + 16 + ek_size + total; + if seg.data.len() != expected { + continue; + } + let iv = &seg.data[12..12 + 16]; + let ek = &seg.data[12 + 16..12 + 16 + ek_size]; + let encrypted = &seg.data[12 + 16 + ek_size..]; + + // Use OpenSSL high-level decrypt: EVP_OpenInit/OpenUpdate/OpenFinal. + // In Rust bindings we implement the same using `openssl::symm::Crypter` is not suitable here; + // use `openssl::pkey` + `openssl::rsa` is not enough. We approximate with `openssl::symm::Crypter` + // by decrypting the session key (ek) using RSA, then decrypting encrypted_affkey using AES-256-CBC. + // + // Note: In AFFLIB, `ek` is produced by EVP_SealInit and encrypted with the cert pubkey. With RSA, + // we can recover it with private key decrypt. + let rsa = pkey.rsa().map_err(|_| Error::InvalidData { + message: "unseal key is not RSA".to_string(), + })?; + let mut session_key = vec![0u8; 256]; + let n = rsa + .private_decrypt(ek, &mut session_key, openssl::rsa::Padding::PKCS1) + .map_err(|e| Error::InvalidData { + message: format!("RSA private_decrypt failed: {e}"), + })?; + session_key.truncate(n); + + // Decrypt encrypted_affkey with AES-256-CBC using recovered session_key and IV. + if session_key.len() != 32 { + continue; + } + let cipher = Cipher::aes_256_cbc(); + let mut dec = + Crypter::new(cipher, Mode::Decrypt, &session_key, Some(iv)).map_err(|e| { + Error::InvalidData { + message: format!("aes-256-cbc init failed: {e}"), + } + })?; + dec.pad(true); + let mut out = vec![0u8; encrypted.len() + cipher.block_size()]; + let mut count = dec + .update(encrypted, &mut out) + .map_err(|e| Error::InvalidData { + message: format!("aes update failed: {e}"), + })?; + count += dec + .finalize(&mut out[count..]) + .map_err(|e| Error::InvalidData { + message: format!("aes finalize failed: {e}"), + })?; + out.truncate(count); + if out.len() < 32 { + continue; + } + let mut aes_key = [0u8; 32]; + aes_key.copy_from_slice(&out[..32]); + self.aes_key = Some(aes_key); + return Ok(()); + } + + Err(Error::InvalidData { + message: "failed to unseal affkey using private key".to_string(), + }) + } + + // Signature verification lives in `crate::verify`. +} + +/// Crypto wrapper backend. +#[derive(Clone)] +struct CryptoBackend { + inner: Arc, + state: CryptoState, + auto_decrypt: bool, +} + +impl std::fmt::Debug for CryptoBackend { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CryptoBackend") + .field("kind", &self.kind()) + .field("auto_decrypt", &self.auto_decrypt) + .finish() + } +} + +impl ReadAt for CryptoBackend { + fn len(&self) -> u64 { + self.inner.len() + } + + 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 page_size = self.page_size(); + if page_size == 0 { + return Err(io::Error::new(io::ErrorKind::InvalidData, "page_size is 0")); + } + + let mut remaining = buf.len(); + let mut out_pos = 0usize; + let mut cur = offset; + + while remaining > 0 { + let page_index = cur / page_size as u64; + let within = (cur % page_size as u64) as usize; + let take = remaining.min(page_size - within); + + let page = self.read_page(page_index)?; + buf[out_pos..out_pos + take].copy_from_slice(&page[within..within + take]); + + out_pos += take; + remaining -= take; + cur = cur.saturating_add(take as u64); + } + + Ok(()) + } +} + +impl Backend for CryptoBackend { + fn kind(&self) -> ContainerKind { + self.inner.kind() + } + + fn page_size(&self) -> usize { + self.inner.page_size() + } + + fn segment_names(&self) -> Vec { + self.inner.segment_names() + } + + fn read_segment(&self, name: &str) -> io::Result> { + // Implement AFFLIB-style auto-decrypt: + // - if auto_decrypt and key present: try "/aes256" first; if found, decrypt and return as `name`. + // - otherwise return plain segment. + + if self.auto_decrypt + && let Some(_key) = self.state.aes_key + { + let enc_name = format!("{name}{}", format::AES256_SUFFIX); + if let Some(mut enc) = self.inner.read_segment(&enc_name)? { + // Decrypt in-place (CBC with IV derived from segname, padded with zeros). + let mut data = enc.data; + let mut datalen = data.len(); + // AFFLIB trims any extra bytes so that datalen is a multiple of 16. + let extra = datalen % 16; + let pad = (16 - extra) % 16; + if extra != 0 && datalen < 16 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "encrypted segment too small", + )); + } + datalen -= extra; + data.truncate(datalen); + + let mut iv = [0u8; 16]; + let n = name.len().min(16); + iv[..n].copy_from_slice(&name.as_bytes()[..n]); + + let cipher = Cipher::aes_256_cbc(); + let key = self.state.aes_key.expect("checked"); + let mut dec = Crypter::new(cipher, Mode::Decrypt, &key, Some(&iv)) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?; + dec.pad(false); + let mut out = vec![0u8; data.len() + 16]; + let mut count = dec + .update(&data, &mut out) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?; + count += dec + .finalize(&mut out[count..]) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?; + out.truncate(count); + + // Remove padding bytes (AFFLIB writes PKCS7-ish pad values into a padded block). + if pad > 0 && out.len() >= pad { + out.truncate(out.len() - pad); + } + + enc.name = name.to_string(); + enc.data = out; + return Ok(Some(enc)); + } + } + + self.inner.read_segment(name) + } +} + +impl CryptoBackend { + fn read_page(&self, page_index: u64) -> io::Result> { + let page_size = self.page_size(); + let mut out = vec![0u8; page_size]; + + // AFFLIB supports both `page` and the deprecated `seg` nomenclature. + let mut name = format!("page{page_index}"); + let mut seg = self.read_segment(&name)?; + if seg.is_none() { + name = format!("seg{page_index}"); + seg = self.read_segment(&name)?; + } + let Some(seg) = seg else { + return Ok(out); + }; + + // Decode page bytes from the (possibly decrypted) segment data. + if (seg.arg & format::AF_PAGE_COMPRESSED) == 0 { + let take = seg.data.len().min(out.len()); + out[..take].copy_from_slice(&seg.data[..take]); + return Ok(out); + } + + match seg.arg & format::AF_PAGE_COMP_ALG_MASK { + format::AF_PAGE_COMP_ALG_ZERO => { + // ZERO compressor => page is all zero. + Ok(out) + } + format::AF_PAGE_COMP_ALG_ZLIB => { + let cursor = io::Cursor::new(seg.data); + let mut decoder = flate2::read::ZlibDecoder::new(cursor); + let mut written = 0usize; + while written < out.len() { + let n = decoder.read(&mut out[written..])?; + if n == 0 { + break; + } + written += n; + } + Ok(out) + } + format::AF_PAGE_COMP_ALG_LZMA => { + #[cfg(feature = "lzma")] + { + let mut input = io::Cursor::new(seg.data); + let mut output = io::Cursor::new(&mut out[..]); + lzma_rs::lzma_decompress(&mut input, &mut output).map_err(|e| { + io::Error::new(io::ErrorKind::InvalidData, format!("LZMA: {e}")) + })?; + Ok(out) + } + #[cfg(not(feature = "lzma"))] + { + let _ = seg; + Err(io::Error::new( + io::ErrorKind::InvalidData, + "LZMA page but feature `lzma` is disabled", + )) + } + } + _ => Err(io::Error::new( + io::ErrorKind::InvalidData, + "unsupported page compression", + )), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::io; + + #[derive(Debug)] + struct MemBackend { + kind: ContainerKind, + page_size: usize, + segments: HashMap, + } + + impl ReadAt for MemBackend { + fn len(&self) -> u64 { + 0 + } + + fn read_exact_at(&self, _offset: u64, _buf: &mut [u8]) -> io::Result<()> { + Err(io::Error::from(io::ErrorKind::UnexpectedEof)) + } + } + + impl Backend for MemBackend { + fn kind(&self) -> ContainerKind { + self.kind + } + + fn page_size(&self) -> usize { + self.page_size + } + + fn segment_names(&self) -> Vec { + let mut out = self.segments.keys().cloned().collect::>(); + out.sort(); + out + } + + fn read_segment(&self, name: &str) -> io::Result> { + Ok(self.segments.get(name).cloned()) + } + } + + fn aff_encrypt_like_afflib(segname: &str, key: &[u8; 32], plaintext: &[u8]) -> Vec { + // Match `af_update_segf` + `af_aes_decrypt` behavior: + // - derive IV from segname + // - encrypt plaintext padded to a block boundary (pad bytes are value `pad+extra`) + // - append `extra` bytes so ciphertext_len % 16 == plaintext_len % 16 + let extra = plaintext.len() % 16; + let pad = (16 - extra) % 16; + + let mut padded = Vec::with_capacity(plaintext.len() + pad); + padded.extend_from_slice(plaintext); + padded.extend(std::iter::repeat_n((pad + extra) as u8, pad)); + + let mut iv = [0u8; 16]; + let n = segname.len().min(16); + iv[..n].copy_from_slice(&segname.as_bytes()[..n]); + + let cipher = Cipher::aes_256_cbc(); + let mut enc = Crypter::new(cipher, Mode::Encrypt, key, Some(&iv)).unwrap(); + enc.pad(false); + + let mut out = vec![0u8; padded.len() + 16]; + let mut count = enc.update(&padded, &mut out).unwrap(); + count += enc.finalize(&mut out[count..]).unwrap(); + out.truncate(count); + + if extra != 0 { + out.extend(std::iter::repeat_n(0u8, extra)); + } + out + } + + #[test] + fn test_auto_decrypt_aes256_segment_trims_extra_and_padding() { + let segname = "hello"; + let plaintext = b"hello from aff"; // len=14 => extra=14, pad=2 + + let key = [0x11u8; 32]; + let enc = aff_encrypt_like_afflib(segname, &key, plaintext); + + let mut segments = HashMap::new(); + segments.insert( + format!("{segname}{}", format::AES256_SUFFIX), + Segment { + name: format!("{segname}{}", format::AES256_SUFFIX), + arg: 0, + data: enc, + }, + ); + + let inner = Arc::new(MemBackend { + kind: ContainerKind::Aff1, + page_size: 4096, + segments, + }); + + let crypto = CryptoBackend { + inner, + state: CryptoState { aes_key: Some(key) }, + auto_decrypt: true, + }; + + let seg = crypto.read_segment(segname).unwrap().unwrap(); + assert_eq!(seg.name, segname); + assert_eq!(seg.data, plaintext); + } +} diff --git a/crates/aff/src/error.rs b/crates/aff/src/error.rs new file mode 100644 index 0000000..a8f86b4 --- /dev/null +++ b/crates/aff/src/error.rs @@ -0,0 +1,20 @@ +use std::io; +use thiserror::Error; + +pub type Result = std::result::Result; + +/// Errors returned by the `aff` crate. +#[derive(Debug, Error)] +pub enum Error { + #[error("I/O error")] + Io(#[from] io::Error), + + #[error("Invalid AFF container: {message}")] + InvalidFormat { message: &'static str }, + + #[error("Invalid AFF data: {message}")] + InvalidData { message: String }, + + #[error("Unsupported: {what}")] + Unsupported { what: String }, +} diff --git a/crates/aff/src/format.rs b/crates/aff/src/format.rs new file mode 100644 index 0000000..83fbb5d --- /dev/null +++ b/crates/aff/src/format.rs @@ -0,0 +1,75 @@ +//! AFF format constants and well-known segment names. +//! +//! This module intentionally only defines **names and numeric constants**. +//! Parsing logic lives in the backend implementations under [`crate::backends`]. + +/// AFF1 file signature (first 4 bytes). +pub const AFF1_MAGIC: &[u8; 4] = b"AFF1"; + +/// AFF1 file header (8 bytes): `b"AFF10\\r\\n\\0"`. +pub const AFF1_HEADER: &[u8; 8] = b"AFF10\r\n\0"; + +/// Segment magic prefix. +pub const SEG_MAGIC: &[u8; 4] = b"AFF\0"; + +/// Segment trailer (AFFLIB calls this the segment tail magic). +pub const SEG_TRAILER: &[u8; 4] = b"ATT\0"; + +/// Segment name storing the page size (stored in `arg`, with `data_len == 0` in common writers). +pub const SEG_PAGESIZE: &str = "pagesize"; + +/// Deprecated alias for [`SEG_PAGESIZE`] used by early AFF writers (AFFLIB `AF_SEGSIZE_D`). +pub const SEG_SEGSIZE_DEPRECATED: &str = "segsize"; + +/// Segment name storing the logical image size as an AFFLIB `aff_quad` (8 bytes). +pub const SEG_IMAGESIZE: &str = "imagesize"; + +/// Segment name storing the sector size in bytes (stored in `arg`). +pub const SEG_SECTORSIZE: &str = "sectorsize"; + +/// Segment name storing the device sector count as an AFFLIB `aff_quad` (8 bytes). +pub const SEG_DEVICESECTORS: &str = "devicesectors"; + +/// Segment storing the split-raw file extension for AFM containers (3 bytes, e.g. `"000"`). +pub const AF_RAW_IMAGE_FILE_EXTENSION: &str = "raw_image_file_extension"; + +/// Segment storing pages-per-raw-file for AFM containers, as an AFFLIB `aff_quad` (8 bytes). +pub const AF_PAGES_PER_RAW_IMAGE_FILE: &str = "pages_per_raw_image_file"; + +/// Segment storing the AES-256 session key encrypted using SHA-256(passphrase). +pub const AF_AFFKEY: &str = "affkey_aes256"; + +/// `printf`-style name in AFFLIB; in this Rust port we model it as `format!("affkey_evp{n}")`. +pub const AF_AFFKEY_EVP_PREFIX: &str = "affkey_evp"; + +/// Suffix for encrypted segments. +pub const AES256_SUFFIX: &str = "/aes256"; + +/// Suffix for signature segments (SHA-256). +pub const SIG256_SUFFIX: &str = "/sha256"; + +/// Segment name storing the signing certificate for SHA-256 signatures. +pub const SIGN256_CERT: &str = "cert-sha256"; + +/// Signature mode 0: signature covers `(segname, arg, segment_data)`. +pub const AF_SIGNATURE_MODE0: u32 = 0x0000; + +/// Signature mode 1: signature covers `(segname, 0, uncompressed_page_bytes)` for page segments. +pub const AF_SIGNATURE_MODE1: u32 = 0x0001; + +// ---- Page flags (AFFLIBv3 `include/afflib/afflib.h`) ---- + +/// Page segment is compressed. +pub const AF_PAGE_COMPRESSED: u32 = 0x0001; + +/// Mask for the compression algorithm bits. +pub const AF_PAGE_COMP_ALG_MASK: u32 = 0x00F0; + +/// Zlib compression algorithm. +pub const AF_PAGE_COMP_ALG_ZLIB: u32 = 0x0000; + +/// LZMA compression algorithm. +pub const AF_PAGE_COMP_ALG_LZMA: u32 = 0x0020; + +/// ZERO compression algorithm: segment data is 4 bytes indicating the number of NUL bytes. +pub const AF_PAGE_COMP_ALG_ZERO: u32 = 0x0030; diff --git a/crates/aff/src/lib.rs b/crates/aff/src/lib.rs new file mode 100644 index 0000000..3acfde2 --- /dev/null +++ b/crates/aff/src/lib.rs @@ -0,0 +1,55 @@ +//! AFF (Advanced Forensic Format) image reader. +//! +//! This crate provides **read-only** access to AFF containers with behavior closely aligned to +//! **AFFLIBv3** (vendored under `external/refs/` for reference-only). +//! +//! Supported containers (this workspace): +//! - **AFF1** single-file (`.aff`) +//! - **AFM** (`.afm`) metadata + split-raw payload +//! - **AFD** directory container +//! +//! Supported page compression (AFF1 pages): +//! - **Uncompressed** +//! - **Zlib** +//! - **ZERO** (special compression storing a 4-byte count; page reads as all-zero) +//! - **LZMA** (LZMA-Alone framing; optional feature) +//! +//! Optional crypto/signature verification (feature `crypto`): +//! - Decrypt `/aes256` segments (read-side only) +//! - Verify `/sha256` signature segments using `cert-sha256` (read-side only) +//! +//! ## Quick start +//! +//! ```no_run +//! use aff::{AffImage, 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>(()) +//! ``` +//! +//! ## Design notes +//! +//! - The primary API implements [`forensic_image::ReadAt`], so higher-level filesystems can +//! consume an AFF image without pulling in AFF-specific dependencies. +//! - Missing pages in sparse containers are treated as **zero-filled regions**, matching typical +//! forensic expectations and AFFLIB behavior. + +#![forbid(unsafe_code)] +#![deny(unused_must_use)] +#![cfg_attr(not(debug_assertions), deny(clippy::dbg_macro))] + +pub mod backends; +#[cfg(feature = "crypto")] +pub mod crypto; +pub mod error; +pub mod format; +pub mod verify; + +pub use backends::AffImage; +pub use backends::AffOpenOptions; +pub use backends::{ContainerKind, Segment}; +pub use error::{Error, Result}; +pub use verify::{SignatureStatus, Verifier}; diff --git a/crates/aff/src/verify.rs b/crates/aff/src/verify.rs new file mode 100644 index 0000000..e3d3ded --- /dev/null +++ b/crates/aff/src/verify.rs @@ -0,0 +1,211 @@ +//! Signature verification for AFF segments. +//! +//! AFFLIBv3 supports attaching SHA-256 RSA signatures to segments by writing a companion segment +//! named `"{segname}/sha256"`. The signing certificate is stored in the segment `cert-sha256` as +//! a PEM-encoded X.509 certificate. +//! +//! This module provides read-side signature verification matching AFFLIB semantics. +//! +//! ## Example +//! +//! ```no_run +//! use aff::{AffOpenOptions, Verifier}; +//! +//! let img = AffOpenOptions::new().open("image.aff")?; +//! let v = Verifier::new(&img); +//! let status = v.verify_segment("pagesize")?; +//! println!("{status:?}"); +//! # Ok::<(), aff::Error>(()) +//! ``` + +use crate::{AffImage, Error, Result}; +use forensic_image::ReadAt; + +/// Signature verification outcome. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SignatureStatus { + /// The segment verified successfully. + Good, + /// The signature segment is missing. + MissingSignature, + /// The signing certificate segment (`cert-sha256`) is missing. + MissingCertificate, + /// The segment being verified is missing. + MissingSegment, + /// The signature did not verify. + Bad, + /// Unsupported signature mode (unknown arg in the `*/sha256` segment). + UnsupportedMode(u32), + /// Signature verification requires the `crypto` feature. + CryptoDisabled, +} + +/// Signature verification helper bound to a specific [`AffImage`]. +pub struct Verifier<'a> { + img: &'a AffImage, +} + +impl<'a> Verifier<'a> { + /// Creates a verifier for an opened image. + /// + /// ## Example + /// + /// ```no_run + /// use aff::{AffImage, Verifier}; + /// + /// let img = AffImage::open("image.aff")?; + /// let v = Verifier::new(&img); + /// # Ok::<(), aff::Error>(()) + /// ``` + pub fn new(img: &'a AffImage) -> Self { + Self { img } + } + + /// Verifies a single segment against its companion `"{segname}/sha256"` signature segment. + /// + /// ## Example + /// + /// ```no_run + /// use aff::{AffImage, SignatureStatus, Verifier}; + /// + /// let img = AffImage::open("image.aff")?; + /// let v = Verifier::new(&img); + /// let status = v.verify_segment("page0")?; + /// assert!(matches!(status, SignatureStatus::Good | SignatureStatus::MissingSignature)); + /// # Ok::<(), aff::Error>(()) + /// ``` + pub fn verify_segment(&self, segname: &str) -> Result { + self.verify_segment_impl(segname) + } + + /// Verifies all signature segments present in the container. + /// + /// This method iterates `segment_names()`, finds segments ending with `/sha256`, and verifies + /// their corresponding base segment. + /// + /// ## Example + /// + /// ```no_run + /// use aff::{AffImage, Verifier}; + /// + /// let img = AffImage::open("image.aff")?; + /// let v = Verifier::new(&img); + /// for (name, status) in v.verify_all()? { + /// println!("{status:?}\t{name}"); + /// } + /// # Ok::<(), aff::Error>(()) + /// ``` + pub fn verify_all(&self) -> Result> { + let mut out = Vec::new(); + for name in self.img.segment_names() { + if let Some(base) = name.strip_suffix(crate::format::SIG256_SUFFIX) { + let status = self.verify_segment(base)?; + out.push((base.to_string(), status)); + } + } + Ok(out) + } + + fn verify_segment_impl(&self, segname: &str) -> Result { + #[cfg(not(feature = "crypto"))] + { + let _ = segname; + return Ok(SignatureStatus::CryptoDisabled); + } + + #[cfg(feature = "crypto")] + { + use openssl::hash::MessageDigest; + use openssl::sign::Verifier as OpenSslVerifier; + use openssl::x509::X509; + + let sigseg = format!("{segname}{}", crate::format::SIG256_SUFFIX); + + let Some(sig) = self.img.read_segment(&sigseg)? else { + return Ok(SignatureStatus::MissingSignature); + }; + + let sigmode = sig.arg; + if sigmode != crate::format::AF_SIGNATURE_MODE0 + && sigmode != crate::format::AF_SIGNATURE_MODE1 + { + return Ok(SignatureStatus::UnsupportedMode(sigmode)); + } + + let Some(certseg) = self.img.read_segment(crate::format::SIGN256_CERT)? else { + return Ok(SignatureStatus::MissingCertificate); + }; + + let cert = X509::from_pem(&certseg.data).map_err(|e| Error::InvalidData { + message: format!("failed to parse cert-sha256 PEM: {e}"), + })?; + let pubkey = cert.public_key().map_err(|e| Error::InvalidData { + message: format!("failed to extract public key: {e}"), + })?; + + let mut verifier = + OpenSslVerifier::new(MessageDigest::sha256(), &pubkey).map_err(|e| { + Error::InvalidData { + message: format!("OpenSSL verifier init failed: {e}"), + } + })?; + + // AFFLIB signs/verifies with: + // - segname including a terminating NUL byte + // - arg in network byte order + // - segment bytes + verifier.update(segname.as_bytes()).map_err(|e| Error::InvalidData { + message: format!("verifier update(segname) failed: {e}"), + })?; + verifier.update(&[0]).map_err(|e| Error::InvalidData { + message: format!("verifier update(NUL) failed: {e}"), + })?; + + let (arg_net, bytes) = if sigmode == crate::format::AF_SIGNATURE_MODE1 { + // Mode1: arg=0 and uncompressed page bytes for page segments. + let page_index = segname + .strip_prefix("page") + .or_else(|| segname.strip_prefix("seg")) + .and_then(|r| r.parse::().ok()) + .ok_or_else(|| Error::InvalidData { + message: format!("MODE1 signature for non-page segment: {segname}"), + })?; + + let page_size = self.img.page_size() as u64; + let base = page_index.saturating_mul(page_size); + let mut buf = vec![0u8; self.img.page_size()]; + if base < self.img.len() { + let take = (self.img.len() - base).min(page_size) as usize; + self.img + .read_exact_at(base, &mut buf[..take]) + .map_err(Error::Io)?; + } + (0u32.to_be_bytes(), buf) + } else { + let Some(seg) = self.img.read_segment(segname)? else { + return Ok(SignatureStatus::MissingSegment); + }; + (seg.arg.to_be_bytes(), seg.data) + }; + + verifier.update(&arg_net).map_err(|e| Error::InvalidData { + message: format!("verifier update(arg) failed: {e}"), + })?; + verifier.update(&bytes).map_err(|e| Error::InvalidData { + message: format!("verifier update(data) failed: {e}"), + })?; + + let ok = verifier.verify(&sig.data).map_err(|e| Error::InvalidData { + message: format!("signature verify failed: {e}"), + })?; + + Ok(if ok { + SignatureStatus::Good + } else { + SignatureStatus::Bad + }) + } + } +} + + diff --git a/crates/aff/tests/common.rs b/crates/aff/tests/common.rs new file mode 100644 index 0000000..999fa85 --- /dev/null +++ b/crates/aff/tests/common.rs @@ -0,0 +1,66 @@ +#![allow(dead_code)] + +use std::env; +use std::fs::File; +use std::io::Read; +use std::path::{Path, PathBuf}; + +pub fn ntfs_fixture_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../testdata/ntfs") +} + +pub fn ntfs_fixture_path(name: &str) -> PathBuf { + ntfs_fixture_root().join(name) +} + +fn is_git_lfs_pointer(path: &Path) -> bool { + let Ok(mut f) = File::open(path) else { + return false; + }; + + let mut buf = [0u8; 200]; + let Ok(n) = f.read(&mut buf) else { + return false; + }; + + let Ok(s) = std::str::from_utf8(&buf[..n]) else { + return false; + }; + + s.starts_with("version https://git-lfs.github.com/spec/v1") +} + +fn fixture_missing_behavior(msg: &str) -> bool { + // Mirror `ntfs` crate behavior, but allow an AFF-specific env var too. + if env::var_os("AFF_TESTDATA_REQUIRED").is_some() + || env::var_os("NTFS_TESTDATA_REQUIRED").is_some() + { + panic!("{msg}"); + } + + eprintln!("{msg}"); + false +} + +/// Returns `true` iff `path` exists and is not a Git LFS pointer file. +/// +/// If `AFF_TESTDATA_REQUIRED=1` (or `NTFS_TESTDATA_REQUIRED=1`) is set, missing/placeholder +/// fixtures will **panic** (useful for CI). Otherwise, the caller should `return` early from the +/// test to skip it. +pub fn ensure_fixture(path: &Path) -> bool { + if !path.exists() { + return fixture_missing_behavior(&format!( + "skipping: missing test fixture `{}` (did you forget to fetch testdata?)", + path.display() + )); + } + + if is_git_lfs_pointer(path) { + return fixture_missing_behavior(&format!( + "skipping: `{}` looks like a Git LFS pointer file; run `git lfs install && git lfs pull`", + path.display() + )); + } + + true +} diff --git a/crates/aff/tests/test_afm_and_afd_synthetic.rs b/crates/aff/tests/test_afm_and_afd_synthetic.rs new file mode 100644 index 0000000..b862785 --- /dev/null +++ b/crates/aff/tests/test_afm_and_afd_synthetic.rs @@ -0,0 +1,113 @@ +use aff::{AffImage, ContainerKind}; +use forensic_image::ReadAt; + +fn aff_segment(name: &str, data: &[u8], arg: u32) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(b"AFF\0"); + out.extend_from_slice(&(name.len() as u32).to_be_bytes()); + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + out.extend_from_slice(&arg.to_be_bytes()); + out.extend_from_slice(name.as_bytes()); + out.extend_from_slice(data); + out.extend_from_slice(b"ATT\0"); + let seg_len = (16 + name.len() + data.len() + 8) as u32; + out.extend_from_slice(&seg_len.to_be_bytes()); + out +} + +fn build_aff1_file(segments: Vec>) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(b"AFF10\r\n\0"); + for seg in segments { + out.extend_from_slice(&seg); + } + out +} + +#[test] +fn test_afm_reads_split_raw_payload() { + let tmp = tempfile::tempdir().unwrap(); + let afm_path = tmp.path().join("sample.afm"); + let raw_path = tmp.path().join("sample.000"); + + let page_size = 8usize; + let payload = b"abcdefgh0123"; // 12 bytes + std::fs::write(&raw_path, payload).unwrap(); + + let segments = vec![ + aff_segment("pagesize", &[], page_size as u32), + aff_segment(aff::format::AF_RAW_IMAGE_FILE_EXTENSION, b"000", 0), + ]; + let bytes = build_aff1_file(segments); + std::fs::write(&afm_path, bytes).unwrap(); + + let img = AffImage::open(&afm_path).unwrap(); + assert_eq!(img.kind(), ContainerKind::Afm); + assert_eq!(img.page_size(), page_size); + assert_eq!(img.len(), payload.len() as u64); + + let mut buf = vec![0u8; payload.len()]; + img.read_exact_at(0, &mut buf).unwrap(); + assert_eq!(&buf, payload); + + // AFM page segments are served from the split-raw payload. + let page0 = img.read_segment("page0").unwrap().unwrap(); + assert_eq!(page0.data, payload[..page_size].to_vec()); +} + +#[test] +fn test_afd_unions_files_and_zero_fills_missing_pages() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("container.afd"); + std::fs::create_dir_all(&dir).unwrap(); + + let page_size = 8usize; + let image_size = 24u64; // 3 pages + + let file0 = build_aff1_file(vec![ + aff_segment("pagesize", &[], page_size as u32), + aff_segment("imagesize", &{ + let low = (image_size & 0xffff_ffff) as u32; + let high = (image_size >> 32) as u32; + let mut q = [0u8; 8]; + q[0..4].copy_from_slice(&low.to_be_bytes()); + q[4..8].copy_from_slice(&high.to_be_bytes()); + q + }, 2), + aff_segment("foo", b"from0", 0), + aff_segment("page0", b"AAAAAAAA", 0), + ]); + let file1 = build_aff1_file(vec![ + aff_segment("pagesize", &[], page_size as u32), + aff_segment("imagesize", &{ + let low = (image_size & 0xffff_ffff) as u32; + let high = (image_size >> 32) as u32; + let mut q = [0u8; 8]; + q[0..4].copy_from_slice(&low.to_be_bytes()); + q[4..8].copy_from_slice(&high.to_be_bytes()); + q + }, 2), + aff_segment("foo", b"from1", 0), + aff_segment("page1", b"BBBBBBBB", 0), + ]); + + std::fs::write(dir.join("file_000.aff"), file0).unwrap(); + std::fs::write(dir.join("file_001.aff"), file1).unwrap(); + + let img = AffImage::open(&dir).unwrap(); + assert_eq!(img.kind(), ContainerKind::Afd); + assert_eq!(img.page_size(), page_size); + assert_eq!(img.len(), image_size); + + // Segment resolution: first subfile wins. + let foo = img.read_segment("foo").unwrap().unwrap(); + assert_eq!(foo.data, b"from0".to_vec()); + + let mut buf = vec![0u8; image_size as usize]; + img.read_exact_at(0, &mut buf).unwrap(); + assert_eq!(&buf[0..8], b"AAAAAAAA"); + assert_eq!(&buf[8..16], b"BBBBBBBB"); + assert_eq!(&buf[16..24], [0u8; 8].as_slice()); +} + + diff --git a/crates/aff/tests/test_cli_tools.rs b/crates/aff/tests/test_cli_tools.rs new file mode 100644 index 0000000..52bb736 --- /dev/null +++ b/crates/aff/tests/test_cli_tools.rs @@ -0,0 +1,138 @@ +use assert_cmd::Command; +use predicates::prelude::*; + +#[cfg(feature = "crypto")] +fn build_aff_with_signed_segment(segname: &str, arg: u32, data: &[u8]) -> Vec { + use openssl::asn1::{Asn1Integer, Asn1Time}; + use openssl::bn::{BigNum, MsbOption}; + use openssl::hash::MessageDigest; + use openssl::pkey::PKey; + use openssl::rsa::Rsa; + use openssl::sign::Signer; + use openssl::x509::{X509NameBuilder, X509}; + + fn aff_segment(name: &str, data: &[u8], arg: u32) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(b"AFF\0"); + out.extend_from_slice(&(name.len() as u32).to_be_bytes()); + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + out.extend_from_slice(&arg.to_be_bytes()); + out.extend_from_slice(name.as_bytes()); + out.extend_from_slice(data); + out.extend_from_slice(b"ATT\0"); + let seg_len = (16 + name.len() + data.len() + 8) as u32; + out.extend_from_slice(&seg_len.to_be_bytes()); + out + } + + let rsa = Rsa::generate(2048).unwrap(); + let pkey = PKey::from_rsa(rsa).unwrap(); + + let mut name = X509NameBuilder::new().unwrap(); + name.append_entry_by_text("CN", "aff-test").unwrap(); + let name = name.build(); + + let mut serial = BigNum::new().unwrap(); + serial.rand(64, MsbOption::MAYBE_ZERO, false).unwrap(); + let serial = Asn1Integer::from_bn(&serial).unwrap(); + + let mut builder = X509::builder().unwrap(); + builder.set_version(2).unwrap(); + builder.set_serial_number(&serial).unwrap(); + builder.set_subject_name(&name).unwrap(); + builder.set_issuer_name(&name).unwrap(); + builder.set_pubkey(&pkey).unwrap(); + builder + .set_not_before(&Asn1Time::days_from_now(0).unwrap()) + .unwrap(); + builder + .set_not_after(&Asn1Time::days_from_now(365).unwrap()) + .unwrap(); + builder.sign(&pkey, MessageDigest::sha256()).unwrap(); + let cert = builder.build(); + let cert_pem = cert.to_pem().unwrap(); + + let mut signer = Signer::new(MessageDigest::sha256(), &pkey).unwrap(); + signer.update(segname.as_bytes()).unwrap(); + signer.update(&[0]).unwrap(); + signer.update(&arg.to_be_bytes()).unwrap(); + signer.update(data).unwrap(); + let sig = signer.sign_to_vec().unwrap(); + + let sigseg = format!("{segname}{}", aff::format::SIG256_SUFFIX); + + let mut out = Vec::new(); + out.extend_from_slice(b"AFF10\r\n\0"); + out.extend_from_slice(&aff_segment("pagesize", &[], 4096)); + out.extend_from_slice(&aff_segment(aff::format::SIGN256_CERT, &cert_pem, 0)); + out.extend_from_slice(&aff_segment(segname, data, arg)); + out.extend_from_slice(&aff_segment(&sigseg, &sig, aff::format::AF_SIGNATURE_MODE0)); + out +} + +#[test] +fn test_aff_cat_decrypts_afflib_fixture() { + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../external/refs/repos/sshock__AFFLIBv3@f6e51a8367cff73ea24c0adf09e533483c80ecd4/tests"); + let aff_path = root.join("encrypted.aff"); + let raw_path = root.join("encrypted.raw"); + let raw = std::fs::read(&raw_path).unwrap(); + + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("aff-cat")); + cmd.arg(&aff_path) + .arg("--passphrase") + .arg("password"); + + cmd.assert() + .success() + .stdout(predicate::eq(raw)); +} + +#[test] +fn test_aff_info_runs_and_reports_len() { + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../external/refs/repos/sshock__AFFLIBv3@f6e51a8367cff73ea24c0adf09e533483c80ecd4/tests"); + let aff_path = root.join("encrypted.aff"); + + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("aff-info")); + cmd.arg(&aff_path); + + cmd.assert() + .success() + .stdout(predicate::str::contains("len: 32")); +} + +#[test] +fn test_aff_verify_exits_nonzero_on_bad_signature() { + #[cfg(not(feature = "crypto"))] + { + return; + } + + #[cfg(feature = "crypto")] + { + let good = build_aff_with_signed_segment("hello", 123, b"world"); + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), &good).unwrap(); + + Command::new(assert_cmd::cargo::cargo_bin!("aff-verify")) + .arg(tmp.path()) + .assert() + .success(); + + let mut bad = good; + let pos = bad + .windows(5) + .position(|w| w == b"world") + .expect("world present"); + bad[pos] ^= 0x01; + std::fs::write(tmp.path(), &bad).unwrap(); + + Command::new(assert_cmd::cargo::cargo_bin!("aff-verify")) + .arg(tmp.path()) + .assert() + .failure(); + } +} + + diff --git a/crates/aff/tests/test_crypto_decrypt_fixture.rs b/crates/aff/tests/test_crypto_decrypt_fixture.rs new file mode 100644 index 0000000..4246007 --- /dev/null +++ b/crates/aff/tests/test_crypto_decrypt_fixture.rs @@ -0,0 +1,26 @@ +use aff::AffOpenOptions; +use forensic_image::ReadAt; + +#[test] +fn test_decrypt_afflib_encrypted_fixture_matches_raw() { + // AFFLIBv3 fixture: encrypted.aff is encrypted with passphrase "password". + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../external/refs/repos/sshock__AFFLIBv3@f6e51a8367cff73ea24c0adf09e533483c80ecd4/tests"); + let aff_path = root.join("encrypted.aff"); + let raw_path = root.join("encrypted.raw"); + + let mut opts = AffOpenOptions::new(); + opts.passphrase = Some("password".to_string()); + opts.auto_decrypt = true; + + let img = opts.open(&aff_path).expect("open encrypted.aff"); + + let raw = std::fs::read(&raw_path).expect("read encrypted.raw"); + assert_eq!(img.len(), raw.len() as u64); + + let mut out = vec![0u8; raw.len()]; + img.read_exact_at(0, &mut out).expect("read decrypted bytes"); + assert_eq!(out, raw, "decrypted content mismatch"); +} + + diff --git a/crates/aff/tests/test_crypto_unseal_keyfile_synthetic.rs b/crates/aff/tests/test_crypto_unseal_keyfile_synthetic.rs new file mode 100644 index 0000000..b9a0346 --- /dev/null +++ b/crates/aff/tests/test_crypto_unseal_keyfile_synthetic.rs @@ -0,0 +1,127 @@ +use aff::AffOpenOptions; + +#[cfg(feature = "crypto")] +fn aff_segment(name: &str, data: &[u8], arg: u32) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(b"AFF\0"); + out.extend_from_slice(&(name.len() as u32).to_be_bytes()); + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + out.extend_from_slice(&arg.to_be_bytes()); + out.extend_from_slice(name.as_bytes()); + out.extend_from_slice(data); + out.extend_from_slice(b"ATT\0"); + let seg_len = (16 + name.len() + data.len() + 8) as u32; + out.extend_from_slice(&seg_len.to_be_bytes()); + out +} + +#[cfg(feature = "crypto")] +fn aff_encrypt_segment_data(segname: &str, key: &[u8; 32], plaintext: &[u8]) -> Vec { + use openssl::symm::{Cipher, Crypter, Mode}; + + let mut iv = [0u8; 16]; + let name_bytes = segname.as_bytes(); + let take = name_bytes.len().min(iv.len()); + iv[..take].copy_from_slice(&name_bytes[..take]); + + // AFFLIB's segment encryption pads to a block boundary and then appends `extra` bytes so + // ciphertext_len % 16 == plaintext_len % 16. + let extra = plaintext.len() % 16; + let pad = (16 - extra) % 16; + + let mut padded = Vec::with_capacity(plaintext.len() + pad); + padded.extend_from_slice(plaintext); + padded.extend(std::iter::repeat_n((pad + extra) as u8, pad)); + + let mut enc = Crypter::new(Cipher::aes_256_cbc(), Mode::Encrypt, key, Some(&iv)).unwrap(); + enc.pad(false); + let mut out = vec![0u8; padded.len() + 16]; + let mut n = enc.update(&padded, &mut out).unwrap(); + n += enc.finalize(&mut out[n..]).unwrap(); + out.truncate(n); + + if extra != 0 { + out.extend(std::iter::repeat_n(0u8, extra)); + } + out +} + +#[test] +fn test_unseal_keyfile_derives_affkey_and_decrypts_aes256_segment() { + #[cfg(not(feature = "crypto"))] + { + return; + } + + #[cfg(feature = "crypto")] + { + use openssl::pkey::PKey; + use openssl::rand::rand_bytes; + use openssl::rsa::{Padding, Rsa}; + use openssl::symm::{encrypt, Cipher}; + + // RSA keypair for sealing/unsealing. + let rsa = Rsa::generate(2048).unwrap(); + let priv_pem = rsa.private_key_to_pem().unwrap(); + let pub_rsa = Rsa::public_key_from_pem(&rsa.public_key_to_pem().unwrap()).unwrap(); + let pub_pkey = PKey::from_rsa(pub_rsa).unwrap(); + + // Real AFF key (what we want to recover via affkey_evp0). + let mut affkey = [0u8; 32]; + rand_bytes(&mut affkey).unwrap(); + + // Session key + IV used for the envelope payload. + let mut session_key = [0u8; 32]; + rand_bytes(&mut session_key).unwrap(); + let mut iv = [0u8; 16]; + rand_bytes(&mut iv).unwrap(); + + // Encrypt (seal) the session key with the public key (PKCS1). + let mut ek = vec![0u8; rsa.size() as usize]; + let ek_len = pub_pkey + .rsa() + .unwrap() + .public_encrypt(&session_key, &mut ek, Padding::PKCS1) + .unwrap(); + ek.truncate(ek_len); + + // Encrypt the AFF key using AES-256-CBC with PKCS7 padding (like EVP_Seal* would do). + let encrypted_affkey = encrypt(Cipher::aes_256_cbc(), &session_key, Some(&iv), &affkey) + .unwrap(); + + // Build `affkey_evp0` segment data. + let mut evp = Vec::new(); + evp.extend_from_slice(&1u32.to_be_bytes()); // version + evp.extend_from_slice(&(ek.len() as u32).to_be_bytes()); + evp.extend_from_slice(&(encrypted_affkey.len() as u32).to_be_bytes()); + evp.extend_from_slice(&iv); + evp.extend_from_slice(&ek); + evp.extend_from_slice(&encrypted_affkey); + + // Add an encrypted segment we can decrypt with the recovered affkey. + let plaintext = b"hello from aff"; + let encrypted = aff_encrypt_segment_data("hello", &affkey, plaintext); + + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"AFF10\r\n\0"); + bytes.extend_from_slice(&aff_segment("pagesize", &[], 4096)); + bytes.extend_from_slice(&aff_segment("affkey_evp0", &evp, 0)); + bytes.extend_from_slice(&aff_segment("hello/aes256", &encrypted, 0)); + + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), &bytes).unwrap(); + + let key = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(key.path(), &priv_pem).unwrap(); + + let mut opts = AffOpenOptions::new(); + opts.unseal_keyfile = Some(key.path().to_path_buf()); + opts.auto_decrypt = true; + + let img = opts.open(tmp.path()).unwrap(); + let seg = img.read_segment("hello").unwrap().unwrap(); + assert_eq!(seg.data, plaintext); + } +} + + diff --git a/crates/aff/tests/test_real_fixtures_ntfs1.rs b/crates/aff/tests/test_real_fixtures_ntfs1.rs new file mode 100644 index 0000000..ebba226 --- /dev/null +++ b/crates/aff/tests/test_real_fixtures_ntfs1.rs @@ -0,0 +1,31 @@ +use aff::AffImage; +use forensic_image::ReadAt; + +mod common; + +fn assert_ntfs_boot_sector(image: &impl ReadAt) { + let mut boot = [0u8; 512]; + image.read_exact_at(0, &mut boot).unwrap(); + assert_eq!(&boot[3..11], b"NTFS "); + assert_eq!(&boot[510..512], &[0x55, 0xAA]); +} + +#[test] +fn test_open_ntfs1_gen0_aff_reads_ntfs_boot_sector() { + let path = common::ntfs_fixture_path("ntfs1-gen0.aff"); + if !common::ensure_fixture(&path) { + return; + } + let img = AffImage::open(path).unwrap(); + assert_ntfs_boot_sector(&img); +} + +#[test] +fn test_open_ntfs1_gen1_aff_reads_ntfs_boot_sector() { + let path = common::ntfs_fixture_path("ntfs1-gen1.aff"); + if !common::ensure_fixture(&path) { + return; + } + let img = AffImage::open(path).unwrap(); + assert_ntfs_boot_sector(&img); +} diff --git a/crates/aff/tests/test_signature_verify_synthetic.rs b/crates/aff/tests/test_signature_verify_synthetic.rs new file mode 100644 index 0000000..338665f --- /dev/null +++ b/crates/aff/tests/test_signature_verify_synthetic.rs @@ -0,0 +1,249 @@ +use aff::{AffOpenOptions, SignatureStatus, Verifier}; + +#[cfg(feature = "crypto")] +fn build_aff_with_signed_segment(segname: &str, arg: u32, data: &[u8]) -> Vec { + use openssl::hash::MessageDigest; + use openssl::pkey::PKey; + use openssl::rsa::Rsa; + use openssl::sign::Signer; + use openssl::asn1::Asn1Time; + use openssl::bn::BigNum; + use openssl::asn1::Asn1Integer; + use openssl::x509::{X509NameBuilder, X509}; + + fn aff_segment(name: &str, data: &[u8], arg: u32) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(b"AFF\0"); + out.extend_from_slice(&(name.len() as u32).to_be_bytes()); + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + out.extend_from_slice(&arg.to_be_bytes()); + out.extend_from_slice(name.as_bytes()); + out.extend_from_slice(data); + out.extend_from_slice(b"ATT\0"); + let seg_len = (16 + name.len() + data.len() + 8) as u32; + out.extend_from_slice(&seg_len.to_be_bytes()); + out + } + + // Generate a keypair and a self-signed cert. + let rsa = Rsa::generate(2048).unwrap(); + let pkey = PKey::from_rsa(rsa).unwrap(); + + let mut name = X509NameBuilder::new().unwrap(); + name.append_entry_by_text("CN", "aff-test").unwrap(); + let name = name.build(); + + let mut builder = X509::builder().unwrap(); + builder.set_version(2).unwrap(); + let mut serial = BigNum::new().unwrap(); + serial.rand(64, openssl::bn::MsbOption::MAYBE_ZERO, false).unwrap(); + let serial = Asn1Integer::from_bn(&serial).unwrap(); + builder.set_serial_number(&serial).unwrap(); + builder.set_subject_name(&name).unwrap(); + builder.set_issuer_name(&name).unwrap(); + builder.set_pubkey(&pkey).unwrap(); + builder + .set_not_before(&Asn1Time::days_from_now(0).unwrap()) + .unwrap(); + builder + .set_not_after(&Asn1Time::days_from_now(365).unwrap()) + .unwrap(); + builder.sign(&pkey, MessageDigest::sha256()).unwrap(); + let cert = builder.build(); + let cert_pem = cert.to_pem().unwrap(); + + // Sign according to AFFLIB: sha256(segname + NUL + arg_be + data) + let mut signer = Signer::new(MessageDigest::sha256(), &pkey).unwrap(); + signer.update(segname.as_bytes()).unwrap(); + signer.update(&[0]).unwrap(); + signer.update(&arg.to_be_bytes()).unwrap(); + signer.update(data).unwrap(); + let sig = signer.sign_to_vec().unwrap(); + + let sigseg = format!("{segname}{}", aff::format::SIG256_SUFFIX); + + let mut out = Vec::new(); + out.extend_from_slice(b"AFF10\r\n\0"); + out.extend_from_slice(&aff_segment("pagesize", &[], 4096)); + out.extend_from_slice(&aff_segment(aff::format::SIGN256_CERT, &cert_pem, 0)); + out.extend_from_slice(&aff_segment(segname, data, arg)); + out.extend_from_slice(&aff_segment(&sigseg, &sig, aff::format::AF_SIGNATURE_MODE0)); + out +} + +#[cfg(feature = "crypto")] +fn build_aff_with_signed_page_mode1(segname: &str, page0: &[u8]) -> Vec { + use openssl::asn1::{Asn1Integer, Asn1Time}; + use openssl::bn::{BigNum, MsbOption}; + use openssl::hash::MessageDigest; + use openssl::pkey::PKey; + use openssl::rsa::Rsa; + use openssl::sign::Signer; + use openssl::x509::{X509NameBuilder, X509}; + + fn aff_segment(name: &str, data: &[u8], arg: u32) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(b"AFF\0"); + out.extend_from_slice(&(name.len() as u32).to_be_bytes()); + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + out.extend_from_slice(&arg.to_be_bytes()); + out.extend_from_slice(name.as_bytes()); + out.extend_from_slice(data); + out.extend_from_slice(b"ATT\0"); + let seg_len = (16 + name.len() + data.len() + 8) as u32; + out.extend_from_slice(&seg_len.to_be_bytes()); + out + } + + // Generate a keypair and a self-signed cert. + let rsa = Rsa::generate(2048).unwrap(); + let pkey = PKey::from_rsa(rsa).unwrap(); + + let mut name = X509NameBuilder::new().unwrap(); + name.append_entry_by_text("CN", "aff-test").unwrap(); + let name = name.build(); + + let mut serial = BigNum::new().unwrap(); + serial.rand(64, MsbOption::MAYBE_ZERO, false).unwrap(); + let serial = Asn1Integer::from_bn(&serial).unwrap(); + + let mut builder = X509::builder().unwrap(); + builder.set_version(2).unwrap(); + builder.set_serial_number(&serial).unwrap(); + builder.set_subject_name(&name).unwrap(); + builder.set_issuer_name(&name).unwrap(); + builder.set_pubkey(&pkey).unwrap(); + builder + .set_not_before(&Asn1Time::days_from_now(0).unwrap()) + .unwrap(); + builder + .set_not_after(&Asn1Time::days_from_now(365).unwrap()) + .unwrap(); + builder.sign(&pkey, MessageDigest::sha256()).unwrap(); + let cert = builder.build(); + let cert_pem = cert.to_pem().unwrap(); + + let pagesize = page0.len() as u32; + let imagesize = page0.len() as u64; + let imagesize_quad = { + let low = (imagesize & 0xffff_ffff) as u32; + let high = (imagesize >> 32) as u32; + let mut out = [0u8; 8]; + out[0..4].copy_from_slice(&low.to_be_bytes()); + out[4..8].copy_from_slice(&high.to_be_bytes()); + out + }; + + // MODE1: sha256(segname + NUL + 0_be + uncompressed_page_bytes) + let mut signer = Signer::new(MessageDigest::sha256(), &pkey).unwrap(); + signer.update(segname.as_bytes()).unwrap(); + signer.update(&[0]).unwrap(); + signer.update(&0u32.to_be_bytes()).unwrap(); + signer.update(page0).unwrap(); + let sig = signer.sign_to_vec().unwrap(); + + let sigseg = format!("{segname}{}", aff::format::SIG256_SUFFIX); + + let mut out = Vec::new(); + out.extend_from_slice(b"AFF10\r\n\0"); + out.extend_from_slice(&aff_segment("pagesize", &[], pagesize)); + out.extend_from_slice(&aff_segment("imagesize", &imagesize_quad, 2)); + out.extend_from_slice(&aff_segment(aff::format::SIGN256_CERT, &cert_pem, 0)); + out.extend_from_slice(&aff_segment(segname, page0, 0)); + out.extend_from_slice(&aff_segment( + &sigseg, + &sig, + aff::format::AF_SIGNATURE_MODE1, + )); + out +} + +#[test] +fn test_verify_synthetic_signature_good_and_bad() { + #[cfg(not(feature = "crypto"))] + { + return; + } + + #[cfg(feature = "crypto")] + { + let bytes = build_aff_with_signed_segment("hello", 123, b"world"); + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), &bytes).unwrap(); + + let img = AffOpenOptions::new().open(tmp.path()).unwrap(); + let v = Verifier::new(&img); + assert_eq!(v.verify_segment("hello").unwrap(), SignatureStatus::Good); + + // Tamper: overwrite the segment data to break the signature. + let mut tampered = bytes.clone(); + // Find the first occurrence of b"world" and flip a bit. + let pos = tampered + .windows(5) + .position(|w| w == b"world") + .expect("world present"); + tampered[pos] ^= 0x01; + + std::fs::write(tmp.path(), &tampered).unwrap(); + let img2 = AffOpenOptions::new().open(tmp.path()).unwrap(); + let v2 = Verifier::new(&img2); + assert_eq!(v2.verify_segment("hello").unwrap(), SignatureStatus::Bad); + } +} + +#[test] +fn test_verify_mode1_page_signature() { + #[cfg(not(feature = "crypto"))] + { + return; + } + + #[cfg(feature = "crypto")] + { + let page0 = b"ABCDEFGH"; + let bytes = build_aff_with_signed_page_mode1("page0", page0); + + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), &bytes).unwrap(); + + let img = AffOpenOptions::new().open(tmp.path()).unwrap(); + let v = Verifier::new(&img); + assert_eq!(v.verify_segment("page0").unwrap(), SignatureStatus::Good); + + // Tamper with the page data and verify signature becomes Bad. + let mut tampered = bytes; + let pos = tampered + .windows(page0.len()) + .position(|w| w == page0) + .expect("page0 bytes present"); + tampered[pos] ^= 0x01; + std::fs::write(tmp.path(), &tampered).unwrap(); + + let img2 = AffOpenOptions::new().open(tmp.path()).unwrap(); + let v2 = Verifier::new(&img2); + assert_eq!(v2.verify_segment("page0").unwrap(), SignatureStatus::Bad); + } +} + +#[test] +fn test_verify_mode1_page_signature_deprecated_seg_prefix() { + #[cfg(not(feature = "crypto"))] + { + return; + } + + #[cfg(feature = "crypto")] + { + let page0 = b"ABCDEFGH"; + let bytes = build_aff_with_signed_page_mode1("seg0", page0); + + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), &bytes).unwrap(); + + let img = AffOpenOptions::new().open(tmp.path()).unwrap(); + let v = Verifier::new(&img); + assert_eq!(v.verify_segment("seg0").unwrap(), SignatureStatus::Good); + } +} + + diff --git a/crates/forensic-image/Cargo.toml b/crates/forensic-image/Cargo.toml new file mode 100644 index 0000000..704381d --- /dev/null +++ b/crates/forensic-image/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "forensic-image" +version = "0.1.0" +edition = "2024" +rust-version = "1.90" +license = "MIT/Apache-2.0" +description = "Shared random-access image reading traits for forensic disk image formats" +repository = "https://github.com/omerbenamram/mft" +readme = "README.md" + +[dependencies] + + diff --git a/crates/forensic-image/README.md b/crates/forensic-image/README.md new file mode 100644 index 0000000..797008a --- /dev/null +++ b/crates/forensic-image/README.md @@ -0,0 +1,19 @@ +# `forensic-image` — shared random-access trait + +This crate provides a tiny abstraction (`ReadAt`) for random-access reads over disk-image-like +sources (raw files, AFF, EWF, etc.). + +## Example + +```rust +use forensic_image::ReadAt; +use std::sync::Arc; + +# fn open_some_image() -> Arc { todo!() } +let img = open_some_image(); +let mut sector0 = [0u8; 512]; +img.read_exact_at(0, &mut sector0)?; +# Ok::<(), std::io::Error>(()) +``` + + diff --git a/crates/forensic-image/src/lib.rs b/crates/forensic-image/src/lib.rs new file mode 100644 index 0000000..6581ad3 --- /dev/null +++ b/crates/forensic-image/src/lib.rs @@ -0,0 +1,49 @@ +//! Shared abstractions for reading from disk images. +//! +//! Many forensic formats provide a **logical** byte-addressable view of a disk (or partition) +//! even when the underlying container is segmented, compressed, sparse, or encrypted. +//! This crate defines a minimal random-access API (`ReadAt`) that higher-level parsers can +//! depend on without committing to a specific container format. +//! +//! ## Design goals +//! +//! - **Small API surface**: only what most parsers need (length + random reads). +//! - **No `unsafe`**: implementations can be fully safe Rust. +//! - **Format-agnostic**: no NTFS/EWF/AFF-specific behavior in this crate. +//! +//! ## Example +//! +//! ```no_run +//! use forensic_image::ReadAt; +//! use std::sync::Arc; +//! +//! # fn open_some_image() -> Arc { todo!() } +//! let img = open_some_image(); +//! let mut sector0 = [0u8; 512]; +//! img.read_exact_at(0, &mut sector0).unwrap(); +//! ``` + +#![forbid(unsafe_code)] +#![deny(unused_must_use)] +#![cfg_attr(not(debug_assertions), deny(clippy::dbg_macro))] + +use std::io; + +/// Random-access reading over an image-like source. +/// +/// Implementations should treat the image as a **logical byte array** of length `len()`. +/// Reads beyond `len()` must return `UnexpectedEof`. +pub trait ReadAt: Send + Sync { + /// Returns the logical image size in bytes. + fn len(&self) -> u64; + + /// Reads exactly `buf.len()` bytes starting at `offset`. + /// + /// Implementations must return `UnexpectedEof` if the requested range is out of bounds. + fn read_exact_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()>; + + /// Returns `true` iff `len() == 0`. + fn is_empty(&self) -> bool { + self.len() == 0 + } +} diff --git a/crates/ntfs/Cargo.toml b/crates/ntfs/Cargo.toml index 45459c5..6efbb33 100644 --- a/crates/ntfs/Cargo.toml +++ b/crates/ntfs/Cargo.toml @@ -26,6 +26,12 @@ openssl = { version = "0.10", features = ["vendored"] } clap = { version = "4", features = ["derive"] } hex = "0.4" +# Shared random-access trait used by image backends. +forensic-image = { path = "../forensic-image" } + +# Reuse the workspace AFF reader implementation. +aff = { path = "../aff" } + # Reuse the existing MFT parser for record decoding (MIT/Apache). mft = { path = "../..", version = "0.7.0" } diff --git a/crates/ntfs/src/image/aff.rs b/crates/ntfs/src/image/aff.rs index fda0bc0..51c1bba 100644 --- a/crates/ntfs/src/image/aff.rs +++ b/crates/ntfs/src/image/aff.rs @@ -1,331 +1,84 @@ use crate::image::ReadAt; -use flate2::read::ZlibDecoder; -use lru::LruCache; -use std::collections::BTreeMap; -use std::io::{self, Read}; -use std::num::NonZeroUsize; +use std::io; use std::path::Path; -use std::sync::{Arc, Mutex}; -#[derive(Debug, Clone, Copy)] -struct PageEntry { - data_offset: usize, - data_len: usize, - flags: u32, -} - -/// Minimal AFF (AFF1) reader. +/// AFF (Advanced Forensic Format) image wrapper. /// -/// Notes (per AFFLIBv3 semantics): -/// - Missing pages represent zero-filled regions. -/// - Pages may be stored uncompressed, zlib-compressed, or as a special "ZERO" compressor -/// (4-byte segment value indicating the number of NUL bytes). -#[derive(Debug)] +/// This is a thin compatibility layer over the workspace `aff` crate so existing `ntfs` callers +/// can continue using `ntfs::image::AffImage`. +#[derive(Debug, Clone)] pub struct AffImage { - data: Arc<[u8]>, - page_size: usize, - image_size: u64, - pages: Vec>, - cache: Mutex>>, + inner: ::aff::AffImage, } impl AffImage { + /// Opens an AFF-like container from a path. + /// + /// Currently, `ntfs` selects the AFF backend by the `.aff` extension (see `image::Image::open`), + /// but the underlying `aff` crate also supports content sniffing. + /// + /// ## Example + /// + /// ```no_run + /// use ntfs::image::{AffImage, ReadAt}; + /// + /// let img = AffImage::open("disk.aff")?; + /// let mut sector0 = [0u8; 512]; + /// img.read_exact_at(0, &mut sector0)?; + /// # Ok::<(), std::io::Error>(()) + /// ``` pub fn open(path: impl AsRef) -> io::Result { - let data: Arc<[u8]> = std::fs::read(path)?.into(); - - if data.len() < 8 || &data[0..4] != b"AFF1" { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "missing AFF signature", - )); - } - - // Skip file signature: "AFF10\\r\\n\\0" - let mut cursor = 8usize; - - let mut page_size: Option = None; - let mut image_size: Option = None; - let mut sector_size: Option = None; - let mut device_sectors: Option = None; - let mut pages_map: BTreeMap = BTreeMap::new(); - - // Parsing strategy: - // - The first segment starts directly with magic "AFF\\0". - // - Subsequent segments are preceded by a 4-byte prefix (ignored here), then "AFF\\0". - let mut expect_prefix = false; - while cursor + 4 <= data.len() { - if expect_prefix { - if cursor + 4 > data.len() { - break; - } - cursor += 4; // ignore prefix - } - - if cursor + 4 > data.len() { - break; - } - if &data[cursor..cursor + 4] != b"AFF\0" { - // If we got desynced, stop early; callers can fall back to other formats. - break; - } - cursor += 4; - - let name_len = read_u32_be(&data, &mut cursor)? as usize; - let data_len = read_u32_be(&data, &mut cursor)? as usize; - let arg = read_u32_be(&data, &mut cursor)?; - - let name_bytes = read_slice(&data, &mut cursor, name_len)?; - let name = std::str::from_utf8(name_bytes) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "non-utf8 segment name"))?; - - let data_offset = cursor; - cursor = cursor - .checked_add(data_len) - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "overflow"))?; - if cursor > data.len() { - return Err(io::Error::new( - io::ErrorKind::UnexpectedEof, - "segment data out of bounds", - )); - } - - // Segment type trailer (e.g. "ATT\\0") - let trailer = read_slice(&data, &mut cursor, 4)?; - let _trailer = trailer; - - if name == "pagesize" { - // In our fixtures pagesize is stored in the arg field. - page_size = Some(arg as usize); - } else if name == "sectorsize" { - sector_size = Some(arg); - } else if name == "devicesectors" { - if data_len != 8 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "AFF devicesectors segment must be 8 bytes", - )); - } - let quad = data - .get(data_offset..data_offset + data_len) - .ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "segment data"))?; - device_sectors = Some(read_aff_quad(quad)?); - } else if name == "imagesize" { - if data_len != 8 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "AFF imagesize segment must be 8 bytes", - )); - } - let quad = data - .get(data_offset..data_offset + data_len) - .ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "segment data"))?; - image_size = Some(read_aff_quad(quad)?); - } else if let Some(page_index) = name.strip_prefix("page") - && let Ok(page) = page_index.parse::() - { - pages_map.insert( - page, - PageEntry { - data_offset, - data_len, - flags: arg, - }, - ); - } - - expect_prefix = true; - } - - let page_size = page_size.ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidData, "AFF missing pagesize segment") - })?; - if page_size == 0 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "AFF pagesize cannot be 0", - )); - } - - let max_page = pages_map.keys().copied().max().unwrap_or(0); - let mut pages = vec![None; (max_page as usize).saturating_add(1)]; - for (idx, entry) in pages_map { - if let Some(slot) = pages.get_mut(idx as usize) { - *slot = Some(entry); - } - } - - let image_size = image_size - .or_else(|| match (device_sectors, sector_size) { - (Some(sectors), Some(bytes_per_sector)) => { - sectors.checked_mul(bytes_per_sector as u64) - } - _ => None, - }) - .unwrap_or_else(|| pages.len() as u64 * page_size as u64); - - Ok(Self { - data, - page_size, - image_size, - pages, - // Pages are very large in our fixtures (16MiB), keep the cache small. - cache: Mutex::new(LruCache::new(NonZeroUsize::new(2).expect("2 > 0"))), - }) + ::aff::AffImage::open(path.as_ref()) + .map(|inner| Self { inner }) + .map_err(aff_error_to_io) } - pub fn page_size(&self) -> usize { - self.page_size + /// Returns the underlying AFF container kind. + /// + /// ## Example + /// + /// ```no_run + /// use ntfs::image::AffImage; + /// + /// let img = AffImage::open("disk.aff")?; + /// println!("{:?}", img.kind()); + /// # Ok::<(), std::io::Error>(()) + /// ``` + pub fn kind(&self) -> ::aff::ContainerKind { + self.inner.kind() } - fn blank_page(&self) -> Vec { - vec![0u8; self.page_size] - } - - fn read_page(&self, page_index: u64) -> io::Result> { - if let Some(hit) = self.cache.lock().expect("poisoned").get(&page_index) { - return Ok(hit.clone()); - } - - let idx = usize::try_from(page_index) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "page index overflow"))?; - let Some(entry) = self.pages.get(idx).and_then(|x| *x) else { - // Sparse / missing page => zero-filled region. - let out = self.blank_page(); - self.cache - .lock() - .expect("poisoned") - .put(page_index, out.clone()); - return Ok(out); - }; - - let seg = self - .data - .get(entry.data_offset..entry.data_offset + entry.data_len) - .ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "page out of bounds"))?; - - // AFFLIBv3 flags (see `include/afflib/afflib.h`) - const AF_PAGE_COMPRESSED: u32 = 0x0001; - const AF_PAGE_COMP_ALG_MASK: u32 = 0x00F0; - const AF_PAGE_COMP_ALG_ZLIB: u32 = 0x0000; - const AF_PAGE_COMP_ALG_LZMA: u32 = 0x0020; - const AF_PAGE_COMP_ALG_ZERO: u32 = 0x0030; - - let mut out = self.blank_page(); - if (entry.flags & AF_PAGE_COMPRESSED) == 0 { - // Uncompressed page data stored directly in the segment (possibly partial for the last page). - let take = seg.len().min(out.len()); - out[..take].copy_from_slice(&seg[..take]); - } else { - match entry.flags & AF_PAGE_COMP_ALG_MASK { - AF_PAGE_COMP_ALG_ZERO => { - // ZERO compressor: segment is a 4-byte count of NUL bytes (AFFLIB uses ntohl()). - // The page content is all zeros. - if seg.len() != 4 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "AFF ZERO-compressed page must have 4 bytes of data", - )); - } - } - AF_PAGE_COMP_ALG_ZLIB => { - let cursor = io::Cursor::new(seg); - let mut decoder = ZlibDecoder::new(cursor); - let mut written = 0usize; - while written < out.len() { - let n = decoder.read(&mut out[written..])?; - if n == 0 { - break; - } - written += n; - } - } - AF_PAGE_COMP_ALG_LZMA => { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "unsupported AFF page compression: LZMA", - )); - } - _ => { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "unsupported AFF page compression", - )); - } - } - } - - self.cache - .lock() - .expect("poisoned") - .put(page_index, out.clone()); - Ok(out) + /// Returns the container page size. + /// + /// ## Example + /// + /// ```no_run + /// use ntfs::image::AffImage; + /// + /// let img = AffImage::open("disk.aff")?; + /// println!("pagesize={}", img.page_size()); + /// # Ok::<(), std::io::Error>(()) + /// ``` + pub fn page_size(&self) -> usize { + self.inner.page_size() } } impl ReadAt for AffImage { fn len(&self) -> u64 { - self.image_size + self.inner.len() } 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 page = self.read_page(page_index)?; - let take = remaining.min(self.page_size - within); - buf[out_pos..out_pos + take].copy_from_slice(&page[within..within + take]); - - out_pos += take; - remaining -= take; - cur = cur.saturating_add(take as u64); - } - - Ok(()) + self.inner.read_exact_at(offset, buf) } } -/// Reads an AFFLIB `aff_quad` (8 bytes) as `u64`. -/// -/// The encoding is **little-endian in 32-bit words**: -/// - bytes `[0..4]` are the low 32 bits in network order (`htonl(low)`), -/// - bytes `[4..8]` are the high 32 bits in network order (`htonl(high)`). -fn read_aff_quad(bytes: &[u8]) -> io::Result { - if bytes.len() != 8 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "AFF quad must be 8 bytes", - )); +fn aff_error_to_io(e: ::aff::Error) -> io::Error { + match e { + ::aff::Error::Io(e) => e, + other => io::Error::new(io::ErrorKind::InvalidData, other.to_string()), } - let low = u32::from_be_bytes(bytes[0..4].try_into().expect("len=4")); - let high = u32::from_be_bytes(bytes[4..8].try_into().expect("len=4")); - Ok(((high as u64) << 32) | (low as u64)) } -fn read_u32_be(data: &[u8], cursor: &mut usize) -> io::Result { - let bytes = read_slice(data, cursor, 4)?; - Ok(u32::from_be_bytes(bytes.try_into().expect("len=4"))) -} -fn read_slice<'a>(data: &'a [u8], cursor: &mut usize, len: usize) -> io::Result<&'a [u8]> { - let start = *cursor; - let end = start - .checked_add(len) - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "overflow"))?; - if end > data.len() { - return Err(io::Error::new( - io::ErrorKind::UnexpectedEof, - "out of bounds", - )); - } - *cursor = end; - Ok(&data[start..end]) -} diff --git a/crates/ntfs/src/image/mod.rs b/crates/ntfs/src/image/mod.rs index c10765a..b831846 100644 --- a/crates/ntfs/src/image/mod.rs +++ b/crates/ntfs/src/image/mod.rs @@ -7,23 +7,12 @@ mod raw; use std::io; use std::path::Path; +pub use forensic_image::ReadAt; + pub use aff::AffImage; pub use ewf::EwfImage; pub use raw::RawImage; -/// Random-access reading over an image-like source. -/// -/// This is intentionally minimal: higher layers (NTFS, VFS) should not care how bytes are -/// retrieved (raw file, EWF, AFF, etc.). -pub trait ReadAt: Send + Sync { - fn len(&self) -> u64; - fn read_exact_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()>; - - fn is_empty(&self) -> bool { - self.len() == 0 - } -} - #[derive(Debug)] pub enum Image { Raw(RawImage), diff --git a/crates/ntfs/tests/test_aff_sparse_pages.rs b/crates/ntfs/tests/test_aff_sparse_pages.rs index b2d667f..cd297db 100644 --- a/crates/ntfs/tests/test_aff_sparse_pages.rs +++ b/crates/ntfs/tests/test_aff_sparse_pages.rs @@ -28,6 +28,9 @@ fn aff_segment(name: &str, data: &[u8], arg: u32) -> Vec { out.extend_from_slice(name.as_bytes()); out.extend_from_slice(data); out.extend_from_slice(b"ATT\0"); + // Segment length (big-endian) includes header + name + data + trailer+length. + let seg_len = (16 + name.len() + data.len() + 8) as u32; + out.extend_from_slice(&seg_len.to_be_bytes()); out } @@ -36,12 +39,7 @@ fn build_aff1_file(segments: Vec>) -> Vec { // File signature: "AFF10\r\n\0" (8 bytes) out.extend_from_slice(b"AFF10\r\n\0"); - for (i, seg) in segments.into_iter().enumerate() { - if i != 0 { - // Prefix/back-pointer field (AFFLIB uses this for reverse traversal). - // The current Rust parser ignores it but expects 4 bytes between segments. - out.extend_from_slice(&0u32.to_be_bytes()); - } + for seg in segments { out.extend_from_slice(&seg); } From 0397356694d11908d7d59008a76313a183270e45 Mon Sep 17 00:00:00 2001 From: Omer Ben-Amram Date: Sat, 27 Dec 2025 12:23:14 +0200 Subject: [PATCH 5/8] fmt: apply rustfmt --- crates/aff/src/backends/afd.rs | 6 +-- crates/aff/src/backends/afm.rs | 7 ++-- crates/aff/src/backends/backend.rs | 2 - crates/aff/src/backends/split_raw.rs | 13 +++--- crates/aff/src/bin/aff-cat.rs | 2 - crates/aff/src/bin/aff-info.rs | 2 - crates/aff/src/bin/aff-verify.rs | 2 - crates/aff/src/crypto/mod.rs | 2 - crates/aff/src/verify.rs | 10 ++--- .../aff/tests/test_afm_and_afd_synthetic.rs | 42 +++++++++++-------- crates/aff/tests/test_cli_tools.rs | 22 ++++------ .../aff/tests/test_crypto_decrypt_fixture.rs | 10 ++--- .../test_crypto_unseal_keyfile_synthetic.rs | 8 ++-- .../tests/test_signature_verify_synthetic.rs | 22 ++++------ crates/ntfs/src/image/aff.rs | 2 - 15 files changed, 68 insertions(+), 84 deletions(-) diff --git a/crates/aff/src/backends/afd.rs b/crates/aff/src/backends/afd.rs index fe8d47f..ef6be04 100644 --- a/crates/aff/src/backends/afd.rs +++ b/crates/aff/src/backends/afd.rs @@ -38,7 +38,9 @@ impl AfdImage { 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 }; + let Some(s) = file_name.to_str() else { + continue; + }; if let Some(idx) = parse_afd_file_index(s) { found.push((idx, entry.path())); } @@ -174,5 +176,3 @@ fn parse_afd_file_index(name: &str) -> Option { } digits.parse::().ok() } - - diff --git a/crates/aff/src/backends/afm.rs b/crates/aff/src/backends/afm.rs index 4a37393..e2cc73e 100644 --- a/crates/aff/src/backends/afm.rs +++ b/crates/aff/src/backends/afm.rs @@ -89,7 +89,10 @@ impl Backend for AfmImage { } // If the caller asks for `page`, read it from the split-raw payload. - if let Some(page_index) = name.strip_prefix("page").and_then(|r| r.parse::().ok()) { + if let Some(page_index) = name + .strip_prefix("page") + .and_then(|r| r.parse::().ok()) + { let data = self.read_page_segment(page_index)?; return Ok(Some(Segment { name: name.to_string(), @@ -147,5 +150,3 @@ fn replace_extension(path: &Path, new_ext: &str) -> Result { } Ok(path.with_extension(new_ext)) } - - diff --git a/crates/aff/src/backends/backend.rs b/crates/aff/src/backends/backend.rs index 9941c31..8504a43 100644 --- a/crates/aff/src/backends/backend.rs +++ b/crates/aff/src/backends/backend.rs @@ -39,5 +39,3 @@ pub(crate) trait Backend: ReadAt { /// Returns `Ok(None)` if the segment does not exist. fn read_segment(&self, name: &str) -> io::Result>; } - - diff --git a/crates/aff/src/backends/split_raw.rs b/crates/aff/src/backends/split_raw.rs index fb3acb8..f499a5f 100644 --- a/crates/aff/src/backends/split_raw.rs +++ b/crates/aff/src/backends/split_raw.rs @@ -129,10 +129,9 @@ impl ReadAt for SplitRawImage { (idx, off) }; - let file = self - .files - .get(file_idx) - .ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "missing split file"))?; + let file = self.files.get(file_idx).ok_or_else(|| { + io::Error::new(io::ErrorKind::UnexpectedEof, "missing split file") + })?; let max_in_file = if self.maxsize == 0 { // Single file. @@ -179,7 +178,9 @@ fn increment_split_raw_extension(path: &mut PathBuf) -> bool { // Numeric case: 000..999 then A00. if bytes.iter().all(|b| b.is_ascii_digit()) { - let num = ((bytes[0] - b'0') as u32) * 100 + ((bytes[1] - b'0') as u32) * 10 + (bytes[2] - b'0') as u32; + let num = ((bytes[0] - b'0') as u32) * 100 + + ((bytes[1] - b'0') as u32) * 10 + + (bytes[2] - b'0') as u32; let next = if num == 999 { *b"A00" } else { @@ -302,5 +303,3 @@ mod tests { assert_eq!(ext(&p), "00"); } } - - diff --git a/crates/aff/src/bin/aff-cat.rs b/crates/aff/src/bin/aff-cat.rs index e462f7e..a727b56 100644 --- a/crates/aff/src/bin/aff-cat.rs +++ b/crates/aff/src/bin/aff-cat.rs @@ -60,5 +60,3 @@ fn main() -> anyhow::Result<()> { stdout.flush()?; Ok(()) } - - diff --git a/crates/aff/src/bin/aff-info.rs b/crates/aff/src/bin/aff-info.rs index c33c783..614fcf4 100644 --- a/crates/aff/src/bin/aff-info.rs +++ b/crates/aff/src/bin/aff-info.rs @@ -46,5 +46,3 @@ fn main() -> anyhow::Result<()> { Ok(()) } - - diff --git a/crates/aff/src/bin/aff-verify.rs b/crates/aff/src/bin/aff-verify.rs index 37ea31e..167f9f1 100644 --- a/crates/aff/src/bin/aff-verify.rs +++ b/crates/aff/src/bin/aff-verify.rs @@ -47,5 +47,3 @@ fn main() -> anyhow::Result<()> { } Ok(()) } - - diff --git a/crates/aff/src/crypto/mod.rs b/crates/aff/src/crypto/mod.rs index 63b1197..b1ebde2 100644 --- a/crates/aff/src/crypto/mod.rs +++ b/crates/aff/src/crypto/mod.rs @@ -12,5 +12,3 @@ mod wrapper; pub(crate) use wrapper::wrap_backend; - - diff --git a/crates/aff/src/verify.rs b/crates/aff/src/verify.rs index e3d3ded..141e919 100644 --- a/crates/aff/src/verify.rs +++ b/crates/aff/src/verify.rs @@ -154,9 +154,11 @@ impl<'a> Verifier<'a> { // - segname including a terminating NUL byte // - arg in network byte order // - segment bytes - verifier.update(segname.as_bytes()).map_err(|e| Error::InvalidData { - message: format!("verifier update(segname) failed: {e}"), - })?; + verifier + .update(segname.as_bytes()) + .map_err(|e| Error::InvalidData { + message: format!("verifier update(segname) failed: {e}"), + })?; verifier.update(&[0]).map_err(|e| Error::InvalidData { message: format!("verifier update(NUL) failed: {e}"), })?; @@ -207,5 +209,3 @@ impl<'a> Verifier<'a> { } } } - - diff --git a/crates/aff/tests/test_afm_and_afd_synthetic.rs b/crates/aff/tests/test_afm_and_afd_synthetic.rs index b862785..c562369 100644 --- a/crates/aff/tests/test_afm_and_afd_synthetic.rs +++ b/crates/aff/tests/test_afm_and_afd_synthetic.rs @@ -66,27 +66,35 @@ fn test_afd_unions_files_and_zero_fills_missing_pages() { let file0 = build_aff1_file(vec![ aff_segment("pagesize", &[], page_size as u32), - aff_segment("imagesize", &{ - let low = (image_size & 0xffff_ffff) as u32; - let high = (image_size >> 32) as u32; - let mut q = [0u8; 8]; - q[0..4].copy_from_slice(&low.to_be_bytes()); - q[4..8].copy_from_slice(&high.to_be_bytes()); - q - }, 2), + aff_segment( + "imagesize", + &{ + let low = (image_size & 0xffff_ffff) as u32; + let high = (image_size >> 32) as u32; + let mut q = [0u8; 8]; + q[0..4].copy_from_slice(&low.to_be_bytes()); + q[4..8].copy_from_slice(&high.to_be_bytes()); + q + }, + 2, + ), aff_segment("foo", b"from0", 0), aff_segment("page0", b"AAAAAAAA", 0), ]); let file1 = build_aff1_file(vec![ aff_segment("pagesize", &[], page_size as u32), - aff_segment("imagesize", &{ - let low = (image_size & 0xffff_ffff) as u32; - let high = (image_size >> 32) as u32; - let mut q = [0u8; 8]; - q[0..4].copy_from_slice(&low.to_be_bytes()); - q[4..8].copy_from_slice(&high.to_be_bytes()); - q - }, 2), + aff_segment( + "imagesize", + &{ + let low = (image_size & 0xffff_ffff) as u32; + let high = (image_size >> 32) as u32; + let mut q = [0u8; 8]; + q[0..4].copy_from_slice(&low.to_be_bytes()); + q[4..8].copy_from_slice(&high.to_be_bytes()); + q + }, + 2, + ), aff_segment("foo", b"from1", 0), aff_segment("page1", b"BBBBBBBB", 0), ]); @@ -109,5 +117,3 @@ fn test_afd_unions_files_and_zero_fills_missing_pages() { assert_eq!(&buf[8..16], b"BBBBBBBB"); assert_eq!(&buf[16..24], [0u8; 8].as_slice()); } - - diff --git a/crates/aff/tests/test_cli_tools.rs b/crates/aff/tests/test_cli_tools.rs index 52bb736..8125038 100644 --- a/crates/aff/tests/test_cli_tools.rs +++ b/crates/aff/tests/test_cli_tools.rs @@ -9,7 +9,7 @@ fn build_aff_with_signed_segment(segname: &str, arg: u32, data: &[u8]) -> Vec Vec { let mut out = Vec::new(); @@ -72,26 +72,24 @@ fn build_aff_with_signed_segment(segname: &str, arg: u32, data: &[u8]) -> Vec Vec { + use openssl::asn1::Asn1Integer; + use openssl::asn1::Asn1Time; + use openssl::bn::BigNum; use openssl::hash::MessageDigest; use openssl::pkey::PKey; use openssl::rsa::Rsa; use openssl::sign::Signer; - use openssl::asn1::Asn1Time; - use openssl::bn::BigNum; - use openssl::asn1::Asn1Integer; - use openssl::x509::{X509NameBuilder, X509}; + use openssl::x509::{X509, X509NameBuilder}; fn aff_segment(name: &str, data: &[u8], arg: u32) -> Vec { let mut out = Vec::new(); @@ -36,7 +36,9 @@ fn build_aff_with_signed_segment(segname: &str, arg: u32, data: &[u8]) -> Vec Vec { use openssl::pkey::PKey; use openssl::rsa::Rsa; use openssl::sign::Signer; - use openssl::x509::{X509NameBuilder, X509}; + use openssl::x509::{X509, X509NameBuilder}; fn aff_segment(name: &str, data: &[u8], arg: u32) -> Vec { let mut out = Vec::new(); @@ -150,11 +152,7 @@ fn build_aff_with_signed_page_mode1(segname: &str, page0: &[u8]) -> Vec { out.extend_from_slice(&aff_segment("imagesize", &imagesize_quad, 2)); out.extend_from_slice(&aff_segment(aff::format::SIGN256_CERT, &cert_pem, 0)); out.extend_from_slice(&aff_segment(segname, page0, 0)); - out.extend_from_slice(&aff_segment( - &sigseg, - &sig, - aff::format::AF_SIGNATURE_MODE1, - )); + out.extend_from_slice(&aff_segment(&sigseg, &sig, aff::format::AF_SIGNATURE_MODE1)); out } @@ -245,5 +243,3 @@ fn test_verify_mode1_page_signature_deprecated_seg_prefix() { assert_eq!(v.verify_segment("seg0").unwrap(), SignatureStatus::Good); } } - - diff --git a/crates/ntfs/src/image/aff.rs b/crates/ntfs/src/image/aff.rs index 51c1bba..49a89fd 100644 --- a/crates/ntfs/src/image/aff.rs +++ b/crates/ntfs/src/image/aff.rs @@ -80,5 +80,3 @@ fn aff_error_to_io(e: ::aff::Error) -> io::Error { other => io::Error::new(io::ErrorKind::InvalidData, other.to_string()), } } - - From 12ecef1d802b921357a5d03ea62bd4565966b73c Mon Sep 17 00:00:00 2001 From: Omer Ben-Amram Date: Sat, 27 Dec 2025 12:25:22 +0200 Subject: [PATCH 6/8] Add pre-commit rustfmt hook --- .pre-commit-config.yaml | 9 +++++++++ AGENTS.md | 1 + 2 files changed, 10 insertions(+) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..535136b --- /dev/null +++ b/.pre-commit-config.yaml @@ -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$' diff --git a/AGENTS.md b/AGENTS.md index 38dd1bc..17f9e9b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. From 47b2ba9312b304df1ea3163abe3e19221c7d5482 Mon Sep 17 00:00:00 2001 From: Omer Ben-Amram Date: Sat, 27 Dec 2025 12:47:52 +0200 Subject: [PATCH 7/8] aff: fix AFD reads when subfile imagesize is smaller Match AFFLIBv3 by reading cached pages by index instead of delegating per-subfile stream reads that enforce subfile imagesize bounds. --- crates/aff/src/backends/afd.rs | 92 ++++++++++++++++++++++++++++++++- crates/aff/src/backends/aff1.rs | 2 +- 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/crates/aff/src/backends/afd.rs b/crates/aff/src/backends/afd.rs index ef6be04..2fa4673 100644 --- a/crates/aff/src/backends/afd.rs +++ b/crates/aff/src/backends/afd.rs @@ -115,7 +115,16 @@ impl ReadAt for AfdImage { 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])?; + // Important: do **not** delegate to `Aff1Image::read_exact_at(cur, ...)` here. + // + // In AFD, the global image size is the max across subfiles, but an individual + // subfile may advertise a smaller `imagesize` (e.g. partial last page) while still + // storing a full `page` segment. AFFLIB satisfies AFD reads by fetching pages by + // index via `af_get_page` / `af_get_seg` (see `lib/afflib_stream.cpp` + + // `lib/vnode_afd.cpp` in the vendored AFFLIBv3 snapshot), not by performing a + // per-subfile stream read with an `imagesize` bounds check. + let page = file.read_page(page_index)?; + buf[out_pos..out_pos + take].copy_from_slice(&page[within..within + take]); } else { buf[out_pos..out_pos + take].fill(0); } @@ -176,3 +185,84 @@ fn parse_afd_file_index(name: &str) -> Option { } digits.parse::().ok() } + +#[cfg(test)] +mod tests { + use super::*; + use crate::format; + + fn aff_quad_u64(v: u64) -> [u8; 8] { + let low = (v & 0xffff_ffff) as u32; + let high = (v >> 32) as u32; + let mut out = [0u8; 8]; + out[0..4].copy_from_slice(&low.to_be_bytes()); + out[4..8].copy_from_slice(&high.to_be_bytes()); + out + } + + fn aff_segment(name: &str, data: &[u8], arg: u32) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(format::SEG_MAGIC); + out.extend_from_slice(&(name.len() as u32).to_be_bytes()); + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + out.extend_from_slice(&arg.to_be_bytes()); + out.extend_from_slice(name.as_bytes()); + out.extend_from_slice(data); + out.extend_from_slice(format::SEG_TRAILER); + let seg_len = (16 + name.len() + data.len() + 8) as u32; + out.extend_from_slice(&seg_len.to_be_bytes()); + out + } + + fn build_aff1_file(segments: Vec>) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(format::AFF1_HEADER); + for seg in segments { + out.extend_from_slice(&seg); + } + out + } + + #[test] + fn test_read_exact_at_ignores_subfile_imagesize_when_page_exists() { + // Regression test for a subtle AFD behavior: + // + // In AFFLIBv3, AFD reads are satisfied by fetching `page` segments from whichever + // subfile contains them, while the *overall* stream length is the max `imagesize` + // across the directory (`afd_vstat` in `external/refs/repos/sshock__AFFLIBv3@.../lib/vnode_afd.cpp`). + // + // That means a subfile may advertise a smaller `imagesize` (partial last page) while still + // storing a full `page` segment; reads within that page are valid as long as the AFD's + // global `imagesize` permits them. + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("container.afd"); + std::fs::create_dir_all(&dir).unwrap(); + + let page_size = 8usize; + + // file_000: claims imagesize=9 (partial last page), but stores a full page1 segment. + let file0 = build_aff1_file(vec![ + aff_segment("pagesize", &[], page_size as u32), + aff_segment("imagesize", &aff_quad_u64(9), 2), + aff_segment("page0", b"AAAAAAAA", 0), + aff_segment("page1", b"BBBBBBBB", 0), + ]); + + // file_001: bumps the AFD global imagesize to 16 (2 full pages). + let file1 = build_aff1_file(vec![ + aff_segment("pagesize", &[], page_size as u32), + aff_segment("imagesize", &aff_quad_u64(16), 2), + ]); + + std::fs::write(dir.join("file_000.aff"), file0).unwrap(); + std::fs::write(dir.join("file_001.aff"), file1).unwrap(); + + let afd = AfdImage::open_with(&dir, 2).unwrap(); + assert_eq!(afd.page_size(), page_size); + assert_eq!(afd.len(), 16); + + let mut page1 = [0u8; 8]; + afd.read_exact_at(8, &mut page1).unwrap(); + assert_eq!(&page1, b"BBBBBBBB"); + } +} diff --git a/crates/aff/src/backends/aff1.rs b/crates/aff/src/backends/aff1.rs index 8752254..b86067b 100644 --- a/crates/aff/src/backends/aff1.rs +++ b/crates/aff/src/backends/aff1.rs @@ -312,7 +312,7 @@ impl Aff1Image { vec![0u8; self.page_size] } - fn read_page(&self, page_index: u64) -> io::Result> { + pub(crate) fn read_page(&self, page_index: u64) -> io::Result> { if let Some(hit) = self.cache.lock().expect("poisoned").get(&page_index) { return Ok(hit.clone()); } From cfaf33532d209818e76b92c530b5d90ed46a26a7 Mon Sep 17 00:00:00 2001 From: Omer Ben-Amram Date: Sat, 27 Dec 2025 12:49:20 +0200 Subject: [PATCH 8/8] aff: verify MODE1 page signatures using logical page length Match AFFLIBv3 by hashing only the bytes returned by af_get_page for the final (partial) page, and update the synthetic tests accordingly. --- crates/aff/src/verify.rs | 21 +++++++++++++------ .../tests/test_signature_verify_synthetic.rs | 9 ++++---- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/crates/aff/src/verify.rs b/crates/aff/src/verify.rs index 141e919..ffd0302 100644 --- a/crates/aff/src/verify.rs +++ b/crates/aff/src/verify.rs @@ -165,6 +165,10 @@ impl<'a> Verifier<'a> { let (arg_net, bytes) = if sigmode == crate::format::AF_SIGNATURE_MODE1 { // Mode1: arg=0 and uncompressed page bytes for page segments. + // + // Important: AFFLIB hashes only the bytes returned by `af_get_page()`, which may be + // less than `page_size` for the final (partial) page. Do **not** include any + // implicit zero-padding in the hash input. let page_index = segname .strip_prefix("page") .or_else(|| segname.strip_prefix("seg")) @@ -175,13 +179,18 @@ impl<'a> Verifier<'a> { let page_size = self.img.page_size() as u64; let base = page_index.saturating_mul(page_size); - let mut buf = vec![0u8; self.img.page_size()]; - if base < self.img.len() { - let take = (self.img.len() - base).min(page_size) as usize; - self.img - .read_exact_at(base, &mut buf[..take]) - .map_err(Error::Io)?; + let len = self.img.len(); + let take = if base < len { + (len - base).min(page_size) as usize + } else { + 0 + }; + + let mut buf = vec![0u8; take]; + if take > 0 { + self.img.read_exact_at(base, &mut buf).map_err(Error::Io)?; } + (0u32.to_be_bytes(), buf) } else { let Some(seg) = self.img.read_segment(segname)? else { diff --git a/crates/aff/tests/test_signature_verify_synthetic.rs b/crates/aff/tests/test_signature_verify_synthetic.rs index 12db665..9b565d5 100644 --- a/crates/aff/tests/test_signature_verify_synthetic.rs +++ b/crates/aff/tests/test_signature_verify_synthetic.rs @@ -74,7 +74,7 @@ fn build_aff_with_signed_segment(segname: &str, arg: u32, data: &[u8]) -> Vec Vec { +fn build_aff_with_signed_page_mode1(segname: &str, pagesize: u32, page0: &[u8]) -> Vec { use openssl::asn1::{Asn1Integer, Asn1Time}; use openssl::bn::{BigNum, MsbOption}; use openssl::hash::MessageDigest; @@ -125,7 +125,6 @@ fn build_aff_with_signed_page_mode1(segname: &str, page0: &[u8]) -> Vec { let cert = builder.build(); let cert_pem = cert.to_pem().unwrap(); - let pagesize = page0.len() as u32; let imagesize = page0.len() as u64; let imagesize_quad = { let low = (imagesize & 0xffff_ffff) as u32; @@ -199,7 +198,9 @@ fn test_verify_mode1_page_signature() { #[cfg(feature = "crypto")] { let page0 = b"ABCDEFGH"; - let bytes = build_aff_with_signed_page_mode1("page0", page0); + // Regression: MODE1 hashes only the bytes returned by `af_get_page()`. For the final page, + // that can be less than `pagesize` (no implicit zero-padding). + let bytes = build_aff_with_signed_page_mode1("page0", 4096, page0); let tmp = tempfile::NamedTempFile::new().unwrap(); std::fs::write(tmp.path(), &bytes).unwrap(); @@ -233,7 +234,7 @@ fn test_verify_mode1_page_signature_deprecated_seg_prefix() { #[cfg(feature = "crypto")] { let page0 = b"ABCDEFGH"; - let bytes = build_aff_with_signed_page_mode1("seg0", page0); + let bytes = build_aff_with_signed_page_mode1("seg0", 4096, page0); let tmp = tempfile::NamedTempFile::new().unwrap(); std::fs::write(tmp.path(), &bytes).unwrap();