diff --git a/core/src/vfs.rs b/core/src/vfs.rs index 0857b0a..11b8298 100644 --- a/core/src/vfs.rs +++ b/core/src/vfs.rs @@ -19,7 +19,7 @@ use forensic_vfs::{ }; use forensicnomicon::ntfs::{attr_types, filename_namespace, mft_records}; -use crate::attribute::AttributeBody; +use crate::attribute::{Attribute, AttributeBody}; use crate::error::NtfsError; use crate::file_name::FileName; use crate::fs::NtfsFs; @@ -34,6 +34,84 @@ use crate::time::Filetime; /// directory bit. const FN_FLAG_DIRECTORY: u32 = 0x1000_0000; +/// `$REPARSE_POINT` attribute type (Windows symlinks and junctions). +const ATTR_REPARSE_POINT: u32 = 0xC0; + +/// Windows reparse tag for a symbolic link. +const REPARSE_TAG_SYMLINK: u32 = 0xA000_000C; + +/// Windows reparse tag for a mount point (junction). +const REPARSE_TAG_MOUNT_POINT: u32 = 0xA000_0003; + +/// ntfs-3g (Linux) stores symlinks as a resident unnamed `$DATA` whose content +/// starts with this 7-byte magic (followed by a version byte and a UTF-16LE +/// target path). Windows-created symlinks instead carry a `$REPARSE_POINT` +/// attribute; both forms are recognized. +const NTFS_3G_SYMLINK_MAGIC: &[u8] = b"IntxLNK"; + +fn utf16le(bytes: &[u8]) -> Vec { + bytes + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect() +} + +/// Decode a `$REPARSE_POINT` value for a symlink or mount point: the +/// substitute name (the on-disk truth, usually `\??\C:\…`), UTF-16LE. +/// `None` for an unrecognized tag or a malformed buffer — never a fabricated +/// target. +fn decode_reparse_buffer(content: &[u8]) -> Option { + if content.len() < 8 { + return None; + } + let tag = u32::from_le_bytes(content[0..4].try_into().ok()?); + if tag != REPARSE_TAG_SYMLINK && tag != REPARSE_TAG_MOUNT_POINT { + return None; + } + let data_len = usize::from(u16::from_le_bytes(content[4..6].try_into().ok()?)); + let data = content.get(8..8 + data_len)?; + // SymbolicLinkReparseBuffer / MountPointReparseBuffer share the layout: + // SubstituteNameOffset(2) SubstituteNameLength(2) PrintNameOffset(2) + // PrintNameLength(2) PathBuffer[…] — the substitute name is offset from + // the start of PathBuffer (data + 8). + if data.len() < 8 { + return None; + } + let sub_off = usize::from(u16::from_le_bytes(data[0..2].try_into().ok()?)); + let sub_len = usize::from(u16::from_le_bytes(data[2..4].try_into().ok()?)); + let path = data.get(8 + sub_off..8 + sub_off + sub_len)?; + Some(String::from_utf16_lossy(&utf16le(path))) +} + +/// Decode the ntfs-3g `IntxLNK` `$DATA` payload: the 7-byte magic (the +/// version byte is not validated — matching ntfs-3g and 7-Zip, which accept +/// any version), then the UTF-16LE target path. +fn decode_ntfs3g_symlink(data: &[u8]) -> Option { + if data.len() >= 8 && data.starts_with(NTFS_3G_SYMLINK_MAGIC) { + Some(String::from_utf16_lossy(&utf16le(&data[8..]))) + } else { + None + } +} + +/// The symlink target of an MFT record, when it is one: the `$REPARSE_POINT` +/// substitute name (Windows) or the ntfs-3g `IntxLNK` `$DATA` payload (Linux). +/// `None` for a regular file or directory. +fn reparse_target(rec: &[u8], attrs: &[Attribute]) -> Option { + if let Some(content) = attrs + .iter() + .find(|a| a.type_code == ATTR_REPARSE_POINT) + .and_then(|a| a.resident_content(rec)) + { + return decode_reparse_buffer(content); + } + let data = attrs + .iter() + .find(|a| a.type_code == attr_types::DATA && a.name.is_none())? + .resident_content(rec)?; + decode_ntfs3g_symlink(data) +} + /// The MFT record number carried by a [`FileId`]. Only NTFS references address /// this filesystem; any other identity domain is a caller error, surfaced loud. fn entry_of(id: FileId) -> VfsResult { @@ -128,6 +206,8 @@ fn build_meta(entry: u64, rec: &[u8]) -> VfsResult { ino: entry, kind: if header.is_directory() { NodeKind::Dir + } else if reparse_target(rec, &attrs).is_some() { + NodeKind::Symlink } else { NodeKind::File }, @@ -246,6 +326,26 @@ fn unallocated_runs( .collect() } +impl NtfsFs { + /// True when the MFT record at `entry` is a symlink: it carries a + /// `$REPARSE_POINT` attribute (Windows symlinks/junctions) or its resident + /// unnamed `$DATA` starts with the ntfs-3g `IntxLNK` magic (Linux-created + /// symlinks). A read/parse miss degrades to `false` — the entry then reads + /// as a regular file rather than failing the whole listing. + fn is_symlink_record(&self, entry: u64) -> bool { + let Ok(rec) = self.read_record(entry) else { + return false; + }; + let Ok(header) = MftRecordHeader::parse(&rec) else { + return false; + }; + let Ok(attrs) = parse_attributes(&rec, header.first_attribute_offset as usize) else { + return false; + }; + reparse_target(&rec, &attrs).is_some() + } +} + impl FileSystem for NtfsFs { fn kind(&self) -> FsKind { FsKind::NTFS @@ -319,6 +419,8 @@ impl FileSystem for NtfsFs { e.file_name.map(|fnm| { let kind = if fnm.flags & FN_FLAG_DIRECTORY != 0 { NodeKind::Dir + } else if self.is_symlink_record(file_ref.record_number) { + NodeKind::Symlink } else { NodeKind::File }; @@ -402,10 +504,16 @@ impl FileSystem for NtfsFs { Ok(n) } - fn read_link(&self, _ino: FileId, _cap: usize) -> VfsResult> { - // NTFS reparse points (symlinks/junctions) are out of scope for this - // adapter; a node with none reads as an empty target. - Ok(Vec::new()) + fn read_link(&self, ino: FileId, _cap: usize) -> VfsResult> { + let entry = entry_of(ino)?; + let rec = self.read_record(entry).map_err(map_err)?; + let header = MftRecordHeader::parse(&rec).map_err(map_err)?; + let attrs = + parse_attributes(&rec, header.first_attribute_offset as usize).map_err(map_err)?; + // A non-link node reads as an empty target, not a per-node error. + Ok(reparse_target(&rec, &attrs) + .unwrap_or_default() + .into_bytes()) } fn deleted(&self) -> VfsResult { @@ -511,9 +619,10 @@ impl FileSystem for NtfsFs { #[cfg(test)] mod tests { use super::{ - best_file_name, build_meta, entry_of, free_runs, map_err, namespace_rank, stream_name, - unallocated_runs, + best_file_name, build_meta, decode_ntfs3g_symlink, decode_reparse_buffer, entry_of, + free_runs, map_err, namespace_rank, reparse_target, stream_name, unallocated_runs, }; + use crate::attribute::{Attribute, AttributeBody}; use crate::error::NtfsError; use forensic_vfs::{FileId, RunAlloc, StreamId, VfsError}; use forensicnomicon::ntfs::filename_namespace; @@ -525,6 +634,174 @@ mod tests { // adapter must reject an identity it cannot address rather than coerce it // into a plausible-looking record number. + #[test] + fn reparse_buffer_decodes_symlink_substitute_name() { + // A Windows symlink reparse buffer: tag 0xA000000C; the substitute + // name "\\??\\C:\\link" (11 UTF-16LE chars = 22 bytes) sits at + // PathBuffer offset 0. ReparseDataLength = 8 header + 22 = 30. + let path_bytes: Vec = "\\??\\C:\\link" + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect(); + let mut buf = Vec::new(); + buf.extend_from_slice(&0xA000_000Cu32.to_le_bytes()); + buf.extend_from_slice(&(8 + path_bytes.len() as u16).to_le_bytes()); + buf.extend_from_slice(&0u16.to_le_bytes()); + buf.extend_from_slice(&0u16.to_le_bytes()); // SubstituteNameOffset + buf.extend_from_slice(&(path_bytes.len() as u16).to_le_bytes()); // SubstituteNameLength + buf.extend_from_slice(&(path_bytes.len() as u16).to_le_bytes()); // PrintNameOffset (end) + buf.extend_from_slice(&0u16.to_le_bytes()); // PrintNameLength + buf.extend_from_slice(&path_bytes); + let decoded = decode_reparse_buffer(&buf).expect("symlink buffer decodes"); + assert_eq!(decoded, "\\??\\C:\\link"); + } + + #[test] + fn reparse_buffer_accepts_mount_point_and_rejects_others() { + // Mount point (junction) tag 0xA0000003, substitute "\\??\\C:\\mount" + // (22 UTF-16LE bytes; ReparseDataLength = 8 + 22 = 30). + let path_bytes: Vec = "\\??\\C:\\mount" + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect(); + let mut buf = Vec::new(); + buf.extend_from_slice(&0xA000_0003u32.to_le_bytes()); + buf.extend_from_slice(&(8 + path_bytes.len() as u16).to_le_bytes()); + buf.extend_from_slice(&0u16.to_le_bytes()); + buf.extend_from_slice(&0u16.to_le_bytes()); + buf.extend_from_slice(&(path_bytes.len() as u16).to_le_bytes()); + buf.extend_from_slice(&(path_bytes.len() as u16).to_le_bytes()); + buf.extend_from_slice(&0u16.to_le_bytes()); + buf.extend_from_slice(&path_bytes); + assert_eq!( + decode_reparse_buffer(&buf).as_deref(), + Some("\\??\\C:\\mount") + ); + // An unrelated tag (OneDrive placeholder 0x80000018) is not a link. + buf[0..4].copy_from_slice(&0x8000_0018u32.to_le_bytes()); + assert_eq!(decode_reparse_buffer(&buf), None); + // Truncated buffers decode to None, never panic. + assert_eq!(decode_reparse_buffer(&buf[..7]), None); + assert_eq!(decode_reparse_buffer(b"short"), None); + assert_eq!(decode_reparse_buffer(&[]), None); + } + + #[test] + fn ntfs3g_intxlnk_decodes_utf16_target() { + let mut data = Vec::new(); + data.extend_from_slice(b"IntxLNK"); + data.push(0x01); + for u in "../README.txt".encode_utf16() { + data.extend_from_slice(&u.to_le_bytes()); + } + assert_eq!( + decode_ntfs3g_symlink(&data).as_deref(), + Some("../README.txt") + ); + // A plain file's data is not a link. + assert_eq!(decode_ntfs3g_symlink(b"README.txt"), None); + assert_eq!(decode_ntfs3g_symlink(b""), None); + } + + /// Build a minimal synthetic MFT record carrying one resident attribute + /// whose content sits at `content_offset`, and the matching [`Attribute`] + /// descriptor — enough for the `reparse_target` classifier. + fn crafted_record(attrs: &[(u32, &[u8])]) -> (Vec, Vec) { + let mut rec = vec![0u8; 256]; + let mut out = Vec::new(); + let mut offset = 64usize; + for (type_code, content) in attrs { + rec[offset..offset + content.len()].copy_from_slice(content); + out.push(Attribute { + type_code: *type_code, + length: 0, + non_resident: false, + name: None, + flags: 0, + attribute_id: 0, + offset: 0, + body: AttributeBody::Resident { + content_offset: offset as u16, + content_length: content.len() as u32, + }, + }); + offset += content.len() + 16; + } + (rec, out) + } + + #[test] + fn reparse_target_windows_reparse_attribute_decodes_substitute_name() { + // A Windows-created symlink: the $REPARSE_POINT (0xC0) attribute holds + // a symlink reparse buffer whose substitute name is the on-disk truth. + let path_bytes: Vec = "\\??\\C:\\link" + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect(); + let mut buf = Vec::new(); + buf.extend_from_slice(&0xA000_000Cu32.to_le_bytes()); // tag: symlink + buf.extend_from_slice(&(8 + path_bytes.len() as u16).to_le_bytes()); // ReparseDataLength + buf.extend_from_slice(&0u16.to_le_bytes()); // Reserved + buf.extend_from_slice(&0u16.to_le_bytes()); // SubstituteNameOffset + buf.extend_from_slice(&(path_bytes.len() as u16).to_le_bytes()); // SubstituteNameLength + buf.extend_from_slice(&(path_bytes.len() as u16).to_le_bytes()); // PrintNameOffset (end) + buf.extend_from_slice(&0u16.to_le_bytes()); // PrintNameLength + buf.extend_from_slice(&path_bytes); + let (rec, attrs) = crafted_record(&[(0xC0, &buf)]); + assert_eq!( + reparse_target(&rec, &attrs).as_deref(), + Some("\\??\\C:\\link") + ); + } + + #[test] + fn reparse_target_ntfs3g_intxlnk_data_decodes_target() { + let mut data = Vec::new(); + data.extend_from_slice(b"IntxLNK"); + data.push(0x01); + for u in "../README.txt".encode_utf16() { + data.extend_from_slice(&u.to_le_bytes()); + } + let (rec, attrs) = crafted_record(&[(0x80, &data)]); + assert_eq!( + reparse_target(&rec, &attrs).as_deref(), + Some("../README.txt") + ); + } + + #[test] + fn reparse_target_plain_file_and_unknown_tags_are_not_links() { + // A plain file: regular $DATA without the magic → None. + let (rec, attrs) = crafted_record(&[(0x80, b"README.txt")]); + assert_eq!(reparse_target(&rec, &attrs), None); + // A $REPARSE_POINT with an unrelated tag (OneDrive placeholder) → None. + let mut buf = Vec::new(); + buf.extend_from_slice(&0x8000_0018u32.to_le_bytes()); + buf.extend_from_slice(&0u16.to_le_bytes()); + buf.extend_from_slice(&0u16.to_le_bytes()); + let (rec, attrs) = crafted_record(&[(0xC0, &buf)]); + assert_eq!(reparse_target(&rec, &attrs), None); + // A record with no $DATA and no reparse attribute → None. + let (rec, attrs) = crafted_record(&[]); + assert_eq!(reparse_target(&rec, &attrs), None); + // The IntxLNK magic alone (without the version byte + target) → None. + let (rec, attrs) = crafted_record(&[(0x80, b"IntxLNK")]); + assert_eq!(reparse_target(&rec, &attrs), None); + } + + #[test] + fn reparse_buffer_and_intxlnk_decoders_are_bounds_safe() { + assert_eq!(decode_reparse_buffer(&[]), None); + assert_eq!(decode_reparse_buffer(&[0u8; 7]), None); + // ReparseDataLength larger than the buffer → None, never a panic. + let mut buf = vec![0u8; 16]; + buf[0..4].copy_from_slice(&0xA000_000Cu32.to_le_bytes()); + buf[4..6].copy_from_slice(&0xFFFFu16.to_le_bytes()); + assert_eq!(decode_reparse_buffer(&buf), None); + assert_eq!(decode_ntfs3g_symlink(b""), None); + assert_eq!(decode_ntfs3g_symlink(b"IntxLNK"), None); + } + #[test] fn entry_of_rejects_a_non_ntfs_identity() { // An ext4 inode is a different identity domain entirely. Silently reading diff --git a/core/tests/vfs_ntfs.rs b/core/tests/vfs_ntfs.rs index 289f750..1e62df0 100644 --- a/core/tests/vfs_ntfs.rs +++ b/core/tests/vfs_ntfs.rs @@ -20,8 +20,8 @@ use std::io::{Cursor, Read}; use std::sync::Arc; use forensic_vfs::{ - Allocation, FileId, FileSystem, FsKind, NodeKind, ResidencyKind, RunAlloc, SectorSizes, - StreamId, TimeZonePolicy, + Allocation, DirEntry, FileId, FileSystem, FsKind, NodeKind, ResidencyKind, RunAlloc, + SectorSizes, StreamId, TimeZonePolicy, }; use ntfs_core::NtfsFs; @@ -30,6 +30,10 @@ use ntfs_core::NtfsFs; /// of the repo-root fixture is safe here (matches `parity_mft.rs` / `real_image.rs`). const SAMPLE_ZIP: &[u8] = include_bytes!("../../tests/data/SampleTinyNtfsVolume.zip"); +/// The committed tiny NTFS volume with an ntfs-3g `IntxLNK` symlink (provenance +/// in `tests/data/README.md`). +const TINY_ZIP: &[u8] = include_bytes!("../../tests/data/tiny.zip"); + /// Extract `partition.dd` from the zip in memory and open it as an /// `Arc` — proving `NtfsFs` composes object-safely. fn open_real_volume() -> Arc { @@ -57,6 +61,74 @@ fn raw_volume() -> Vec { dd } +/// Walks `fs` recursively and returns the entry whose path is `rel` (joined +/// with `/`), if present. +fn find_entry(fs: &dyn FileSystem, rel: &str) -> Option { + // Cycle-guarded like the engine's own walker: NTFS emits `.`/`..`-style + // FILE_NAME entries that carry the directory flag, so a naive walk of a + // real volume would loop forever on the parent reference. + let mut visited = std::collections::HashSet::new(); + let mut stack = vec![(fs.root(), String::new())]; + while let Some((id, prefix)) = stack.pop() { + if !visited.insert(id) { + continue; + } + for e in fs.read_dir(id).ok()?.collect::, _>>().ok()? { + let name = String::from_utf8_lossy(&e.name).into_owned(); + let path = if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}/{name}") + }; + if path == rel { + return Some(e); + } + if e.kind == NodeKind::Dir { + stack.push((e.id, path)); + } + } + } + None +} + +#[test] +fn symlink_surfaces_as_symlink_with_target() { + // The committed tiny.zip volume carries an ntfs-3g-authored `IntxLNK` + // symlink; the patched adapter must classify it and decode the target + // exactly as ntfs-3g's own mount does (../README.txt). + let mut archive = zip::ZipArchive::new(Cursor::new(TINY_ZIP)).expect("open tiny zip"); + let mut dd = Vec::new(); + archive + .by_name("tiny.img") + .expect("tiny.img present") + .read_to_end(&mut dd) + .expect("read tiny.img"); + let fs = NtfsFs::open(Cursor::new(dd)).expect("open NTFS volume"); + + let link = find_entry(&fs, "nested/readme-link.txt").expect("readme-link.txt present"); + assert_eq!( + link.kind, + NodeKind::Symlink, + "the adapter must classify the IntxLNK record as a symlink" + ); + assert_eq!( + fs.read_link(link.id, 4096).expect("read_link"), + b"../README.txt", + "the decoded target must match ntfs-3g's own resolution" + ); + let meta = fs.meta(link.id).expect("symlink meta"); + assert_eq!(meta.kind, NodeKind::Symlink); + + // A regular file is not a link and reads an empty target, not an error. + let readme = find_entry(&fs, "README.txt").expect("README.txt present"); + assert_eq!(readme.kind, NodeKind::File); + assert_eq!( + fs.read_link(readme.id, 4096) + .expect("read_link regular file"), + b"" + ); +} + #[test] fn identity_matches_tsk_geometry() { let fs = open_real_volume(); diff --git a/tests/data/README.md b/tests/data/README.md index 37709b5..50bd853 100644 --- a/tests/data/README.md +++ b/tests/data/README.md @@ -95,3 +95,14 @@ fixup against genuine on-disk bytes (doer-checker) rather than a self-encoded sy | File | Bytes | MD5 | |---|---|---| | `real_logfile_rcrd_page.bin` | 4096 | `b5ef734e91222a606b675ced9db2ea92` | +### tiny.zip — NTFS volume with an ntfs-3g `IntxLNK` symlink + +- **Source:** an 8 MiB NTFS volume authored with `mkntfs` (ntfs-3g 2022.10.3) inside a + privileged `ubuntu:24.04` container (loop mount, no FUSE), populated via `cp -a` from a + small tree: `README.txt`, `nested/file.txt`, and a symlink `nested/readme-link.txt -> + ../README.txt` created by the Linux UDF/ntfs-3g driver. ntfs-3g stores symlinks as a + resident unnamed `$DATA` whose content is the 8-byte `IntxLNK\x01` magic followed by the + UTF-16LE target — no `$REPARSE_POINT` attribute is written. +- **Ground truth:** ntfs-3g's own mount resolves the symlink to `../README.txt`; the same + record is listed by 7-Zip as a symlink (34 bytes). +- **Consumed by:** `core/tests/vfs_ntfs.rs` `symlink_surfaces_as_symlink_with_target`. diff --git a/tests/data/tiny.zip b/tests/data/tiny.zip new file mode 100644 index 0000000..4d94c90 Binary files /dev/null and b/tests/data/tiny.zip differ