From c5d20209abce1bda06b85fcae06d400afd0d3d86 Mon Sep 17 00:00:00 2001 From: Omer Ben-Amram Date: Fri, 2 Jan 2026 01:14:41 +0200 Subject: [PATCH 1/5] Refactor attribute parsing to borrow UTF-16LE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce `Utf16LeStr` backed by `utf16-simd` and refactor attribute parsing to be slice-based and zero-copy, delaying UTF-16LE → UTF-8 conversion until output. --- Cargo.toml | 2 +- src/attribute/header.rs | 199 ++++++++++++++++++----------- src/attribute/mod.rs | 164 +++++++++++------------- src/attribute/non_resident_attr.rs | 5 +- src/attribute/raw.rs | 23 +--- src/attribute/x10.rs | 2 +- src/attribute/x20.rs | 144 ++++++++++----------- src/attribute/x30.rs | 40 +++--- src/attribute/x80.rs | 25 ++-- src/attribute/x90.rs | 102 +++++++++------ src/bin/mft_dump.rs | 8 +- src/csv.rs | 6 +- src/entry.rs | 126 +++++++++--------- src/lib.rs | 9 ++ src/mft.rs | 12 +- src/utf16.rs | 175 +++++++++++++++++++++++++ src/utils.rs | 42 +----- tests/test_data_run.rs | 7 +- tests/test_entry.rs | 65 +++++----- tests/test_standard_information.rs | 2 +- 20 files changed, 667 insertions(+), 491 deletions(-) create mode 100644 src/utf16.rs diff --git a/Cargo.toml b/Cargo.toml index a7fe5ac..f4e307e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,6 @@ members = [ [dependencies] log = { version = "0.4", features = ["release_max_level_debug"] } -encoding = "0.2" byteorder = "1" bitflags = "2" serde = { version = "1", features = ["derive"] } @@ -46,6 +45,7 @@ simplelog = { version = "0.12", optional = true } dialoguer = { version = "0.12", optional = true } indoc = { version = "2.0", optional = true } sonic-rs = { version = "0.5.6", optional = true } +utf16-simd = "0.1.0" [features] default = ["mft_dump"] diff --git a/src/attribute/header.rs b/src/attribute/header.rs index a2a1daf..80fac6d 100644 --- a/src/attribute/header.rs +++ b/src/attribute/header.rs @@ -1,16 +1,42 @@ +use crate::Utf16LeStr; use crate::attribute::{AttributeDataFlags, MftAttributeType}; use crate::err::{Error, Result}; -use crate::utils::read_utf16_string; -use byteorder::{LittleEndian, ReadBytesExt}; +use byteorder::{ByteOrder, LittleEndian}; use num_traits::FromPrimitive; use serde::Serialize; -use std::io::{Read, Seek, SeekFrom}; +use std::io; + +fn get_slice(buf: &[u8], offset: usize, len: usize) -> io::Result<&[u8]> { + let end = offset + .checked_add(len) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "offset overflow"))?; + buf.get(offset..end) + .ok_or_else(|| io::Error::from(io::ErrorKind::UnexpectedEof)) +} + +fn read_u8(buf: &[u8], offset: usize) -> io::Result { + buf.get(offset) + .copied() + .ok_or_else(|| io::Error::from(io::ErrorKind::UnexpectedEof)) +} + +fn read_u16_le(buf: &[u8], offset: usize) -> io::Result { + Ok(LittleEndian::read_u16(get_slice(buf, offset, 2)?)) +} + +fn read_u32_le(buf: &[u8], offset: usize) -> io::Result { + Ok(LittleEndian::read_u32(get_slice(buf, offset, 4)?)) +} + +fn read_u64_le(buf: &[u8], offset: usize) -> io::Result { + Ok(LittleEndian::read_u64(get_slice(buf, offset, 8)?)) +} /// Represents the union defined in /// #[derive(Serialize, Clone, Debug)] -pub struct MftAttributeHeader { +pub struct MftAttributeHeader<'a> { pub type_code: MftAttributeType, /// The size of the attribute record, in bytes. /// This value reflects the required size for the record variant and is always rounded to the nearest quadword boundary. @@ -28,7 +54,7 @@ pub struct MftAttributeHeader { pub data_flags: AttributeDataFlags, /// The unique instance for this attribute in the file record. pub instance: u16, - pub name: String, + pub name: Utf16LeStr<'a>, /// start of the attribute; used for calculating relative offsets pub start_offset: u64, } @@ -40,72 +66,104 @@ pub enum ResidentialHeader { NonResident(NonResidentHeader), } -impl MftAttributeHeader { - /// Tries to read an AttributeHeader from the stream. - /// Will return `None` if the type code is $END. - pub fn from_stream(stream: &mut S) -> Result> { - let attribute_header_start_offset = stream.stream_position()?; - - let type_code_value = stream.read_u32::()?; - +impl<'a> MftAttributeHeader<'a> { + /// Parse an attribute header from an attribute record slice. + /// + /// Returns `Ok(None)` if the type code is `$END` (`0xFFFF_FFFF`). + pub fn from_slice( + record: &'a [u8], + attribute_start_offset: u64, + ) -> Result>> { + let type_code_value = read_u32_le(record, 0)?; if type_code_value == 0xFFFF_FFFF { return Ok(None); } - let type_code = match MftAttributeType::from_u32(type_code_value) { - Some(attribute_type) => attribute_type, - None => { - return Err(Error::UnknownAttributeType { - attribute_type: type_code_value, - }); - } - }; + let type_code = + MftAttributeType::from_u32(type_code_value).ok_or(Error::UnknownAttributeType { + attribute_type: type_code_value, + })?; - let attribute_size = stream.read_u32::()?; - let resident_flag = stream.read_u8()?; - let name_size = stream.read_u8()?; - let name_offset = { - // We always read the two bytes to advance the stream. - let value = stream.read_u16::()?; - if name_size > 0 { Some(value) } else { None } - }; + let record_length = read_u32_le(record, 4)?; + let form_code = read_u8(record, 8)?; + let name_size = read_u8(record, 9)?; + let name_offset_raw = read_u16_le(record, 10)?; + let name_offset = (name_size > 0).then_some(name_offset_raw); + + let data_flags = AttributeDataFlags::from_bits_truncate(read_u16_le(record, 12)?); + let instance = read_u16_le(record, 14)?; + + let residential_header = match form_code { + 0 => { + let data_size = read_u32_le(record, 16)?; + let data_offset = read_u16_le(record, 20)?; + let index_flag = read_u8(record, 22)?; + let padding = read_u8(record, 23)?; + ResidentialHeader::Resident(ResidentHeader { + data_size, + data_offset, + index_flag, + padding, + }) + } + 1 => { + let vnc_first = read_u64_le(record, 16)?; + let vnc_last = read_u64_le(record, 24)?; + let datarun_offset = read_u16_le(record, 32)?; + let unit_compression_size = read_u16_le(record, 34)?; + let padding = read_u32_le(record, 36)?; + let allocated_length = read_u64_le(record, 40)?; + let file_size = read_u64_le(record, 48)?; + let valid_data_length = read_u64_le(record, 56)?; - let data_flags = AttributeDataFlags::from_bits_truncate(stream.read_u16::()?); - let id = stream.read_u16::()?; + let total_allocated = if unit_compression_size > 0 { + Some(read_u64_le(record, 64)?) + } else { + None + }; - let residential_header = match resident_flag { - 0 => ResidentialHeader::Resident(ResidentHeader::from_stream(stream)?), - 1 => ResidentialHeader::NonResident(NonResidentHeader::from_stream(stream)?), + ResidentialHeader::NonResident(NonResidentHeader { + vnc_first, + vnc_last, + datarun_offset, + unit_compression_size, + padding, + allocated_length, + file_size, + valid_data_length, + total_allocated, + }) + } _ => { return Err(Error::UnhandledResidentFlag { - flag: resident_flag, - offset: stream.stream_position()?, + flag: form_code, + offset: attribute_start_offset, }); } }; - // Name is optional, and will not be present if size == 0. let name = if name_size > 0 { - stream.seek(SeekFrom::Start( - attribute_header_start_offset - + u64::from(name_offset.expect("name_size > 0 is invariant")), - ))?; - read_utf16_string(stream, Some(name_size as usize))? + let off = name_offset_raw as usize; + let len_bytes = name_size as usize * 2; + let name_bytes = record + .get(off..off + len_bytes) + .ok_or(Error::InvalidFilename)?; + Utf16LeStr::from_utf16le_bytes_until_nul(name_bytes) } else { - String::new() + Utf16LeStr::empty() }; Ok(Some(MftAttributeHeader { type_code, - record_length: attribute_size, - form_code: resident_flag, + record_length, + form_code, + residential_header, name_size, name_offset, data_flags, - instance: id, + instance, name, - residential_header, - start_offset: attribute_header_start_offset, + start_offset: attribute_start_offset, })) } } @@ -123,10 +181,11 @@ pub struct ResidentHeader { } impl ResidentHeader { - pub fn from_stream(reader: &mut R) -> Result { + pub fn from_stream(reader: &mut R) -> Result { + use byteorder::ReadBytesExt; Ok(ResidentHeader { - data_size: reader.read_u32::()?, - data_offset: reader.read_u16::()?, + data_size: reader.read_u32::()?, + data_offset: reader.read_u16::()?, index_flag: reader.read_u8()?, padding: reader.read_u8()?, }) @@ -159,18 +218,19 @@ pub struct NonResidentHeader { } impl NonResidentHeader { - pub fn from_stream(reader: &mut R) -> Result { - let vnc_first = reader.read_u64::()?; - let vnc_last = reader.read_u64::()?; - let datarun_offset = reader.read_u16::()?; - let unit_compression_size = reader.read_u16::()?; - let padding = reader.read_u32::()?; - let allocated_length = reader.read_u64::()?; - let file_size = reader.read_u64::()?; - let valid_data_length = reader.read_u64::()?; + pub fn from_stream(reader: &mut R) -> Result { + use byteorder::ReadBytesExt; + let vnc_first = reader.read_u64::()?; + let vnc_last = reader.read_u64::()?; + let datarun_offset = reader.read_u16::()?; + let unit_compression_size = reader.read_u16::()?; + let padding = reader.read_u32::()?; + let allocated_length = reader.read_u64::()?; + let file_size = reader.read_u64::()?; + let valid_data_length = reader.read_u64::()?; let total_allocated = if unit_compression_size > 0 { - Some(reader.read_u64::()?) + Some(reader.read_u64::()?) } else { None }; @@ -193,7 +253,6 @@ impl NonResidentHeader { mod tests { use super::MftAttributeHeader; use crate::attribute::MftAttributeType; - use std::io::Cursor; #[test] fn attribute_test_01_resident() { @@ -202,11 +261,9 @@ mod tests { 0x00, 0x00, 0x48, 0x00, 0x00, 0x00, 0x18, 0x00, 0x00, 0x00, ]; - let mut cursor = Cursor::new(raw); - - let attribute_header = MftAttributeHeader::from_stream(&mut cursor) - .expect("Should not be $End") - .expect("Shold parse correctly"); + let attribute_header = MftAttributeHeader::from_slice(raw, 0) + .expect("Shold parse correctly") + .expect("Should not be $End"); assert_eq!( attribute_header.type_code, @@ -229,11 +286,9 @@ mod tests { 0x0C, 0x32, 0xA0, 0x56, 0xE3, 0xE6, 0x24, 0x00, 0xFF, 0xFF, ]; - let mut cursor = Cursor::new(raw); - - let attribute_header = MftAttributeHeader::from_stream(&mut cursor) - .expect("Should not be $End") - .expect("Shold parse correctly"); + let attribute_header = MftAttributeHeader::from_slice(raw, 0) + .expect("Shold parse correctly") + .expect("Should not be $End"); assert_eq!(attribute_header.type_code, MftAttributeType::DATA); assert_eq!(attribute_header.record_length, 80); diff --git a/src/attribute/mod.rs b/src/attribute/mod.rs index fd3484b..c450f92 100644 --- a/src/attribute/mod.rs +++ b/src/attribute/mod.rs @@ -12,7 +12,7 @@ pub mod x90; use crate::err::Result; use crate::impl_serialize_for_bitflags; -use std::io::{Cursor, Read, Seek}; +use std::io::Cursor; use bitflags::bitflags; @@ -21,7 +21,8 @@ use crate::attribute::x10::StandardInfoAttr; use crate::attribute::x20::AttributeListAttr; use crate::attribute::x30::FileNameAttr; -use crate::attribute::header::{MftAttributeHeader, NonResidentHeader, ResidentHeader}; +use crate::attribute::data_run::decode_data_runs; +use crate::attribute::header::{MftAttributeHeader, ResidentialHeader}; use crate::attribute::non_resident_attr::NonResidentAttr; use crate::attribute::x40::ObjectIdAttr; use crate::attribute::x80::DataAttr; @@ -29,130 +30,111 @@ use crate::attribute::x90::IndexRootAttr; use serde::Serialize; #[derive(Serialize, Clone, Debug)] -pub struct MftAttribute { - pub header: MftAttributeHeader, - pub data: MftAttributeContent, +pub struct MftAttribute<'a> { + pub header: MftAttributeHeader<'a>, + pub data: MftAttributeContent<'a>, } -impl MftAttributeContent { - pub fn from_stream_non_resident( - stream: &mut S, - header: &MftAttributeHeader, - resident: &NonResidentHeader, - ) -> Result { - Ok(MftAttributeContent::DataRun(NonResidentAttr::from_stream( - stream, header, resident, - )?)) - } - - pub fn from_stream_resident( - stream: &mut S, - header: &MftAttributeHeader, - resident: &ResidentHeader, - ) -> Result { - match header.type_code { - MftAttributeType::StandardInformation => { - // `$STANDARD_INFORMATION` has multiple on-disk layouts. Its value size (48 vs 72) - // is the discriminator, so read exactly `data_size` and parse based on length. - let content_size = resident.data_size as usize; - let mut buf = vec![0_u8; content_size]; - stream.read_exact(&mut buf)?; - Ok(MftAttributeContent::AttrX10(StandardInfoAttr::from_slice( - &buf, - )?)) +impl<'a> MftAttributeContent<'a> { + pub fn from_record(record: &'a [u8], header: &MftAttributeHeader<'a>) -> Result { + match &header.residential_header { + ResidentialHeader::Resident(resident) => { + let data_offset = resident.data_offset as usize; + let data_size = resident.data_size as usize; + let end = + data_offset + .checked_add(data_size) + .ok_or_else(|| crate::err::Error::Any { + detail: "attribute resident data offset overflow".to_string(), + })?; + let value = record + .get(data_offset..end) + .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::UnexpectedEof))?; + + match header.type_code { + MftAttributeType::StandardInformation => Ok(MftAttributeContent::AttrX10( + StandardInfoAttr::from_slice(value)?, + )), + MftAttributeType::AttributeList => Ok(MftAttributeContent::AttrX20( + AttributeListAttr::from_slice(value)?, + )), + MftAttributeType::FileName => Ok(MftAttributeContent::AttrX30( + FileNameAttr::from_slice(value)?, + )), + MftAttributeType::DATA => { + Ok(MftAttributeContent::AttrX80(DataAttr::from_slice(value))) + } + MftAttributeType::ObjectId => Ok(MftAttributeContent::AttrX40( + ObjectIdAttr::from_stream(&mut Cursor::new(value), value.len())?, + )), + MftAttributeType::IndexRoot => Ok(MftAttributeContent::AttrX90( + IndexRootAttr::from_slice(value)?, + )), + _ => Ok(MftAttributeContent::Raw(RawAttribute::from_slice( + header.type_code.clone(), + value, + ))), + } } - MftAttributeType::AttributeList => { - // An attribute list is a buffer of attribute entries which are varying sizes if - // the attributes contain names. Thus, we must know when to stop reading. To - // do this, we will create a buffer of the attribute, and stop reading attribute - // entries when we reach the end of the buffer. - let content_size = resident.data_size; - - let mut attribute_buffer = vec![0; content_size as usize]; - stream.read_exact(&mut attribute_buffer)?; - - // Create a new stream that the attribute will read from. - let mut new_stream = Cursor::new(attribute_buffer); - - let attr_list = - AttributeListAttr::from_stream(&mut new_stream, Some(content_size as u64))?; - - Ok(MftAttributeContent::AttrX20(attr_list)) + ResidentialHeader::NonResident(nonresident) => { + let datarun_offset = nonresident.datarun_offset as usize; + let runs = record + .get(datarun_offset..) + .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::UnexpectedEof))?; + + let data_runs = decode_data_runs(runs).ok_or_else(|| { + crate::err::Error::FailedToDecodeDataRuns { + bad_data_runs: runs.to_vec(), + } + })?; + Ok(MftAttributeContent::DataRun(NonResidentAttr { data_runs })) } - MftAttributeType::FileName => Ok(MftAttributeContent::AttrX30( - FileNameAttr::from_stream(stream)?, - )), - // Resident DATA - MftAttributeType::DATA => Ok(MftAttributeContent::AttrX80(DataAttr::from_stream( - stream, - resident.data_size as usize, - )?)), - // Always Resident - MftAttributeType::ObjectId => Ok(MftAttributeContent::AttrX40( - ObjectIdAttr::from_stream(stream, resident.data_size as usize)?, - )), - // Always Resident - MftAttributeType::IndexRoot => Ok(MftAttributeContent::AttrX90( - IndexRootAttr::from_stream(stream)?, - )), - // An unparsed resident attribute - _ => Ok(MftAttributeContent::Raw(RawAttribute::from_stream( - stream, - header.type_code.clone(), - resident.data_size as usize, - )?)), } } - /// Converts the given attributes into a 'AttributeListAttr', consuming the object attribute object. - pub fn into_attribute_list(self) -> Option { + pub fn as_attribute_list(&self) -> Option<&AttributeListAttr<'a>> { match self { MftAttributeContent::AttrX20(content) => Some(content), _ => None, } } - /// Converts the given attributes into a `IndexRootAttr`, consuming the object attribute object. - pub fn into_index_root(self) -> Option { + pub fn as_index_root(&self) -> Option<&IndexRootAttr<'a>> { match self { MftAttributeContent::AttrX90(content) => Some(content), _ => None, } } - /// Converts the given attributes into a `ObjectIdAttr`, consuming the object attribute object. - pub fn into_object_id(self) -> Option { + pub fn as_object_id(&self) -> Option<&ObjectIdAttr> { match self { MftAttributeContent::AttrX40(content) => Some(content), _ => None, } } - /// Converts the given attributes into a `StandardInfoAttr`, consuming the object attribute object. - pub fn into_standard_info(self) -> Option { + + pub fn as_standard_info(&self) -> Option<&StandardInfoAttr> { match self { MftAttributeContent::AttrX10(content) => Some(content), _ => None, } } - /// Converts the given attributes into a `DataAttr`, consuming the object attribute object. - pub fn into_data(self) -> Option { + pub fn as_data(&self) -> Option<&DataAttr<'a>> { match self { MftAttributeContent::AttrX80(content) => Some(content), _ => None, } } - /// Converts the given attributes into a `FileNameAttr`, consuming the object attribute object. - pub fn into_file_name(self) -> Option { + pub fn as_file_name(&self) -> Option<&FileNameAttr<'a>> { match self { MftAttributeContent::AttrX30(content) => Some(content), _ => None, } } - /// Converts the given attributes into a `NonResidentAttr`, consuming the object attribute object. - pub fn into_data_runs(self) -> Option { + pub fn as_data_runs(&self) -> Option<&NonResidentAttr> { match self { MftAttributeContent::DataRun(content) => Some(content), _ => None, @@ -162,17 +144,15 @@ impl MftAttributeContent { #[derive(Serialize, Clone, Debug)] #[serde(untagged)] -pub enum MftAttributeContent { - Raw(RawAttribute), +pub enum MftAttributeContent<'a> { + Raw(RawAttribute<'a>), AttrX10(StandardInfoAttr), - AttrX20(AttributeListAttr), - AttrX30(FileNameAttr), + AttrX20(AttributeListAttr<'a>), + AttrX30(FileNameAttr<'a>), AttrX40(ObjectIdAttr), - AttrX80(DataAttr), - AttrX90(IndexRootAttr), + AttrX80(DataAttr<'a>), + AttrX90(IndexRootAttr<'a>), DataRun(NonResidentAttr), - /// Empty - used when data is non resident. - None, } /// MFT Possible attribute types, from diff --git a/src/attribute/non_resident_attr.rs b/src/attribute/non_resident_attr.rs index be26ad2..784e25b 100644 --- a/src/attribute/non_resident_attr.rs +++ b/src/attribute/non_resident_attr.rs @@ -54,6 +54,7 @@ impl NonResidentAttr { #[cfg(test)] mod tests { use super::*; + use crate::Utf16LeStr; use crate::attribute::data_run::RunType; use crate::attribute::header::ResidentialHeader; use crate::attribute::{AttributeDataFlags, MftAttributeType}; @@ -63,7 +64,7 @@ mod tests { resident: &NonResidentHeader, record_length: u32, start_offset: u64, - ) -> MftAttributeHeader { + ) -> MftAttributeHeader<'static> { MftAttributeHeader { type_code: MftAttributeType::DATA, record_length, @@ -73,7 +74,7 @@ mod tests { name_offset: None, data_flags: AttributeDataFlags::empty(), instance: 0, - name: String::new(), + name: Utf16LeStr::empty(), start_offset, } } diff --git a/src/attribute/raw.rs b/src/attribute/raw.rs index 60d1470..8ef3841 100644 --- a/src/attribute/raw.rs +++ b/src/attribute/raw.rs @@ -1,32 +1,21 @@ -use std::io::{Read, Seek}; - use crate::attribute::MftAttributeType; -use crate::err::Result; use crate::utils; use serde::{Serialize, ser}; /// Placeholder attribute for currently unparsed attributes. #[derive(Serialize, Clone, Debug)] -pub struct RawAttribute { +pub struct RawAttribute<'a> { pub attribute_type: MftAttributeType, #[serde(serialize_with = "data_as_hex")] - pub data: Vec, + pub data: &'a [u8], } -impl RawAttribute { - pub fn from_stream( - stream: &mut S, - attribute_type: MftAttributeType, - data_size: usize, - ) -> Result { - let mut data = vec![0_u8; data_size]; - - stream.read_exact(&mut data)?; - - Ok(RawAttribute { +impl<'a> RawAttribute<'a> { + pub fn from_slice(attribute_type: MftAttributeType, data: &'a [u8]) -> Self { + RawAttribute { attribute_type, data, - }) + } } } diff --git a/src/attribute/x10.rs b/src/attribute/x10.rs index 87232e3..90cc55a 100644 --- a/src/attribute/x10.rs +++ b/src/attribute/x10.rs @@ -177,7 +177,7 @@ mod tests { // layout is 72 bytes. When a value is 48 bytes, the additional fields are not present // and must not be read from subsequent bytes in the file record. // - // See: https://flatcap.github.io/linux-ntfs/ntfs/attributes/standard_information.html + // See: external/refs/specs/NTFS-standard-information.md let buf48 = &STDINFO_72[..48]; let attr = StandardInfoAttr::from_slice(buf48).unwrap(); diff --git a/src/attribute/x20.rs b/src/attribute/x20.rs index 8de0e92..efed93a 100644 --- a/src/attribute/x20.rs +++ b/src/attribute/x20.rs @@ -1,26 +1,22 @@ +use crate::Utf16LeStr; use crate::err::{Error, Result}; -use byteorder::{LittleEndian, ReadBytesExt}; -use encoding::all::UTF_16LE; -use encoding::{DecoderTrap, Encoding}; - use serde::Serialize; -use std::io::{Read, Seek, SeekFrom}; +use std::io::Cursor; use winstructs::ntfs::mft_reference::MftReference; /// The AttributeListAttr represents the $20 attribute, which contains a list /// of attribute entries in child entries. /// #[derive(Serialize, Clone, Debug)] -pub struct AttributeListAttr { +pub struct AttributeListAttr<'a> { /// A list of AttributeListEntry that make up this AttributeListAttr - pub entries: Vec, + pub entries: Vec>, } -impl AttributeListAttr { - /// Read AttributeListAttr from stream. Stream should be the size of the attribute's data itself - /// if no stream_size is passed in. +impl<'a> AttributeListAttr<'a> { + /// Read AttributeListAttr from a resident attribute value slice. /// /// # Example /// @@ -49,49 +45,33 @@ impl AttributeListAttr { /// 0x54,0x00,0x41,0x00,0x00,0x00,0x00,0x00 /// ]; /// - /// let attribute_list = AttributeListAttr::from_stream( - /// &mut Cursor::new(attribute_content_buffer), - /// None - /// ).unwrap(); + /// let attribute_list = AttributeListAttr::from_slice(attribute_content_buffer).unwrap(); /// /// assert_eq!(attribute_list.entries.len(), 7); /// ``` - pub fn from_stream( - mut stream: &mut S, - stream_size: Option, - ) -> Result { - let mut start_offset = stream.stream_position()?; - let end_offset = match stream_size { - Some(s) => s, - None => { - // If no stream size was passed in we seek to the end of the stream, - // then tell to get the ending offset, then seek back to the start, - // thus, its better to just pass the stream size. - stream.seek(SeekFrom::End(0))?; - - let offset = stream.stream_position()?; + pub fn from_slice(value: &'a [u8]) -> Result> { + let mut entries: Vec> = Vec::new(); - stream.rewind()?; - - offset + let mut offset = 0usize; + while offset < value.len() { + if value.len().saturating_sub(offset) < AttributeListEntry::MIN_LEN { + break; } - }; - - let mut entries: Vec = Vec::new(); - - // iterate attribute content parsing attribute list entries - while start_offset < end_offset { - // parse the entry from the stream - let attr_entry = AttributeListEntry::from_stream(&mut stream)?; - - // update the starting offset - start_offset += attr_entry.record_length as u64; - // add attribute entry to entries vec - entries.push(attr_entry); + let entry = AttributeListEntry::from_slice_at(value, offset)?; + let record_length = entry.record_length as usize; + if record_length == 0 { + return Err(Error::Any { + detail: "attribute list entry record_length is 0".to_string(), + }); + } - // seek the stream to next start offset to avoid padding - stream.seek(SeekFrom::Start(start_offset))?; + entries.push(entry); + offset = offset + .checked_add(record_length) + .ok_or_else(|| Error::Any { + detail: "attribute list offset overflow".to_string(), + })?; } Ok(Self { entries }) @@ -102,7 +82,7 @@ impl AttributeListAttr { /// /// #[derive(Serialize, Clone, Debug)] -pub struct AttributeListEntry { +pub struct AttributeListEntry<'a> { /// The attribute code pub attribute_type: u32, /// This entry length @@ -120,10 +100,12 @@ pub struct AttributeListEntry { /// The attribute's id pub reserved: u16, /// The attribute's name - pub name: String, + pub name: Utf16LeStr<'a>, } -impl AttributeListEntry { - /// Create AttributeListEntry from a stream. +impl<'a> AttributeListEntry<'a> { + const MIN_LEN: usize = 26; + + /// Create AttributeListEntry from a resident attribute value slice at `offset`. /// /// # Example /// @@ -139,9 +121,7 @@ impl AttributeListEntry { /// 0x00,0x00,0x12,0x07,0x80,0xF8,0xFF,0xFF /// ]; /// - /// let attribute_entry = AttributeListEntry::from_stream( - /// &mut Cursor::new(attribute_buffer) - /// ).unwrap(); + /// let attribute_entry = AttributeListEntry::from_slice_at(attribute_buffer, 0).unwrap(); /// /// assert_eq!(attribute_entry.attribute_type, 16); /// assert_eq!(attribute_entry.record_length, 32); @@ -151,32 +131,48 @@ impl AttributeListEntry { /// assert_eq!(attribute_entry.segment_reference.entry, 10019); /// assert_eq!(attribute_entry.segment_reference.sequence, 1); /// assert_eq!(attribute_entry.reserved, 0); - /// assert_eq!(attribute_entry.name, "".to_string()); + /// assert!(attribute_entry.name.is_empty()); /// ``` - pub fn from_stream(stream: &mut S) -> Result { - let start_offset = stream.stream_position()?; + pub fn from_slice_at(value: &'a [u8], offset: usize) -> Result> { + if value.len().saturating_sub(offset) < Self::MIN_LEN { + return Err(std::io::Error::from(std::io::ErrorKind::UnexpectedEof).into()); + } - let attribute_type = stream.read_u32::()?; - let record_length = stream.read_u16::()?; - let name_length = stream.read_u8()?; - let name_offset = stream.read_u8()?; - let lowest_vcn = stream.read_u64::()?; - let segment_reference = - MftReference::from_reader(stream).map_err(Error::failed_to_read_mft_reference)?; - let reserved = stream.read_u16::()?; + let attribute_type = u32::from_le_bytes([ + value[offset], + value[offset + 1], + value[offset + 2], + value[offset + 3], + ]); + let record_length = u16::from_le_bytes([value[offset + 4], value[offset + 5]]); + let name_length = value[offset + 6]; + let name_offset = value[offset + 7]; + let lowest_vcn = u64::from_le_bytes([ + value[offset + 8], + value[offset + 9], + value[offset + 10], + value[offset + 11], + value[offset + 12], + value[offset + 13], + value[offset + 14], + value[offset + 15], + ]); + + let segment_reference = { + let mut cursor = Cursor::new(&value[offset + 16..offset + 24]); + MftReference::from_reader(&mut cursor).map_err(Error::failed_to_read_mft_reference)? + }; + let reserved = u16::from_le_bytes([value[offset + 24], value[offset + 25]]); let name = if name_length > 0 { - stream.seek(SeekFrom::Start(start_offset + u64::from(name_offset)))?; - - let mut name_buffer = vec![0; name_length as usize * 2]; - stream.read_exact(&mut name_buffer)?; - - match UTF_16LE.decode(&name_buffer, DecoderTrap::Ignore) { - Ok(s) => s, - Err(_e) => return Err(Error::InvalidFilename {}), - } + let name_off = offset + name_offset as usize; + let name_len_bytes = name_length as usize * 2; + let name_bytes = value + .get(name_off..name_off + name_len_bytes) + .ok_or(Error::InvalidFilename)?; + Utf16LeStr::from_utf16le_bytes(name_bytes) } else { - String::new() + Utf16LeStr::empty() }; Ok(AttributeListEntry { diff --git a/src/attribute/x30.rs b/src/attribute/x30.rs index 304bfdd..ee66964 100644 --- a/src/attribute/x30.rs +++ b/src/attribute/x30.rs @@ -1,20 +1,17 @@ -use std::io::{Read, Seek}; - +use crate::Utf16LeStr; use crate::attribute::FileAttributeFlags; use crate::err::{Error, Result}; use log::trace; use byteorder::{LittleEndian, ReadBytesExt}; -use encoding::all::UTF_16LE; -use encoding::{DecoderTrap, Encoding}; - use jiff::Timestamp; use num_traits::FromPrimitive; use serde::Serialize; +use std::io::Cursor; use winstructs::ntfs::mft_reference::MftReference; -#[derive(FromPrimitive, Serialize, Clone, Debug, PartialOrd, PartialEq)] +#[derive(FromPrimitive, Serialize, Clone, Copy, Debug, PartialOrd, PartialEq)] #[repr(u8)] pub enum FileNamespace { POSIX = 0, @@ -24,7 +21,7 @@ pub enum FileNamespace { } #[derive(Serialize, Clone, Debug, PartialEq)] -pub struct FileNameAttr { +pub struct FileNameAttr<'a> { pub parent: MftReference, #[serde(serialize_with = "crate::utils::serialize_timestamp_chrono_compat")] pub created: Timestamp, @@ -40,10 +37,10 @@ pub struct FileNameAttr { pub reparse_value: u32, pub name_length: u8, pub namespace: FileNamespace, - pub name: String, + pub name: Utf16LeStr<'a>, } -impl FileNameAttr { +impl<'a> FileNameAttr<'a> { /// Parse a Filename attrbiute buffer. /// /// # Example @@ -62,7 +59,7 @@ impl FileNameAttr { /// 0x65,0x00,0x00,0x00,0x00,0x00,0x00,0x00 /// ]; /// - /// let attribute = FileNameAttr::from_stream(&mut Cursor::new(attribute_buffer)).unwrap(); + /// let attribute = FileNameAttr::from_slice(attribute_buffer).unwrap(); /// /// assert_eq!(attribute.parent.entry, 5); /// assert_eq!(attribute.created.as_second(), 1370144608); @@ -75,12 +72,14 @@ impl FileNameAttr { /// assert_eq!(attribute.reparse_value, 0); /// assert_eq!(attribute.name_length, 8); /// assert_eq!(attribute.namespace, FileNamespace::Win32AndDos); - /// assert_eq!(attribute.name, "$LogFile"); + /// assert_eq!(attribute.name.to_utf8_string(), "$LogFile"); /// ``` - pub fn from_stream(stream: &mut S) -> Result { - trace!("Offset {}: FilenameAttr", stream.stream_position()?); + pub fn from_slice(value: &'a [u8]) -> Result> { + let mut stream = Cursor::new(value); + trace!("Offset {}: FilenameAttr", stream.position()); + let parent = - MftReference::from_reader(stream).map_err(Error::failed_to_read_mft_reference)?; + MftReference::from_reader(&mut stream).map_err(Error::failed_to_read_mft_reference)?; // Timestamps are stored as Windows FILETIME (100ns since 1601-01-01 UTC). let created = crate::utils::windows_filetime_to_timestamp(stream.read_u64::()?)?; @@ -100,13 +99,12 @@ impl FileNameAttr { let namespace = FileNamespace::from_u8(namespace).ok_or(Error::UnknownNamespace { namespace })?; - let mut name_buffer = vec![0; name_length as usize * 2]; - stream.read_exact(&mut name_buffer)?; - - let name = match UTF_16LE.decode(&name_buffer, DecoderTrap::Ignore) { - Ok(s) => s, - Err(_e) => return Err(Error::InvalidFilename {}), - }; + let name_pos = stream.position() as usize; + let name_len_bytes = name_length as usize * 2; + let name_bytes = value + .get(name_pos..name_pos + name_len_bytes) + .ok_or(Error::InvalidFilename)?; + let name = Utf16LeStr::from_utf16le_bytes(name_bytes); Ok(FileNameAttr { parent, diff --git a/src/attribute/x80.rs b/src/attribute/x80.rs index 7ea191d..6185c7f 100644 --- a/src/attribute/x80.rs +++ b/src/attribute/x80.rs @@ -1,32 +1,25 @@ -use std::io::{Read, Seek}; - -use crate::err::Result; use crate::utils; use serde::ser; /// $Data Attribute -#[derive(Clone, Debug)] -pub struct DataAttr(Vec); - -impl DataAttr { - pub fn from_stream(stream: &mut S, data_size: usize) -> Result { - let mut data = vec![0_u8; data_size]; - - stream.read_exact(&mut data)?; +#[derive(Clone, Copy, Debug)] +pub struct DataAttr<'a>(&'a [u8]); - Ok(DataAttr(data)) +impl<'a> DataAttr<'a> { + pub fn from_slice(data: &'a [u8]) -> DataAttr<'a> { + DataAttr(data) } - pub fn data(&self) -> &[u8] { - &self.0 + pub fn data(&self) -> &'a [u8] { + self.0 } } -impl ser::Serialize for DataAttr { +impl ser::Serialize for DataAttr<'_> { fn serialize(&self, serializer: S) -> ::std::result::Result where S: ser::Serializer, { - serializer.serialize_str(&utils::to_hex_string(&self.0)) + serializer.serialize_str(&utils::to_hex_string(self.0)) } } diff --git a/src/attribute/x90.rs b/src/attribute/x90.rs index 97e0883..2f882ee 100644 --- a/src/attribute/x90.rs +++ b/src/attribute/x90.rs @@ -1,6 +1,3 @@ -use std::io::{Read, Seek}; - -use crate::attribute::x30::FileNameAttr; use crate::err::{Error, Result}; use crate::impl_serialize_for_bitflags; @@ -10,12 +7,14 @@ use bitflags::bitflags; use num_derive::FromPrimitive; use num_traits::FromPrimitive; use serde::Serialize; -use std::io::SeekFrom; +use std::io::Cursor; use winstructs::ntfs::mft_reference::MftReference; +use crate::attribute::x30::FileNameAttr; + /// $IndexRoot Attribute #[derive(Serialize, Clone, Debug)] -pub struct IndexRootAttr { +pub struct IndexRootAttr<'a> { /// Unique Id assigned to file pub attribute_type: u32, /// Collation rule used to sort the index entries. @@ -30,7 +29,7 @@ pub struct IndexRootAttr { pub index_node_length: u32, pub index_node_allocation_length: u32, pub index_root_flags: IndexRootFlags, // 0x00 = Small Index (fits in Index Root); 0x01 = Large index (Index Allocation needed) - pub index_entries: IndexEntries, + pub index_entries: IndexEntries<'a>, } /// Enum sources: @@ -59,9 +58,11 @@ bitflags! { } impl_serialize_for_bitflags! {IndexRootFlags} -impl IndexRootAttr { +impl<'a> IndexRootAttr<'a> { /// Data size should be either 16 or 64 - pub fn from_stream(stream: &mut S) -> Result { + pub fn from_slice(value: &'a [u8]) -> Result> { + let mut stream = Cursor::new(value); + let attribute_type = stream.read_u32::()?; let collation_rule_val = stream.read_u32::()?; let collation_rule = IndexCollationRules::from_u32(collation_rule_val); @@ -75,14 +76,18 @@ impl IndexRootAttr { }; let index_entry_size = stream.read_u32::()?; let index_entry_number_of_cluster_blocks = stream.read_u32::()?; - let index_node_start_pos = stream.stream_position().unwrap(); + let index_node_start_pos = stream.position() as usize; let relative_offset_to_index_node = stream.read_u32::()?; let index_node_length = stream.read_u32::()?; let index_node_allocation_length = stream.read_u32::()?; let index_root_flags = IndexRootFlags::from_bits_truncate(stream.read_u32::()?); - let index_entries = - IndexEntries::from_stream(stream, index_node_length, index_node_start_pos)?; + let index_entries = IndexEntries::from_slice( + value, + index_node_length, + index_node_start_pos, + stream.position() as usize, + )?; Ok(IndexRootAttr { attribute_type, @@ -99,12 +104,12 @@ impl IndexRootAttr { } #[derive(Serialize, Clone, Debug, PartialEq)] -pub struct IndexEntryHeader { +pub struct IndexEntryHeader<'a> { pub mft_reference: MftReference, pub index_record_length: u16, pub attr_fname_length: u16, pub flags: IndexEntryFlags, - pub fname_info: FileNameAttr, + pub fname_info: FileNameAttr<'a>, } bitflags! { #[derive(Clone, Debug, PartialEq)] @@ -115,28 +120,39 @@ bitflags! { } impl_serialize_for_bitflags! {IndexEntryFlags} -impl IndexEntryHeader { - pub fn from_stream(stream: &mut S) -> Result> { - let start_pos = stream.stream_position().unwrap(); +impl<'a> IndexEntryHeader<'a> { + pub fn from_slice_at( + value: &'a [u8], + offset: usize, + ) -> Result, usize)>> { + let mut stream = Cursor::new(value); + stream.set_position(offset as u64); + let start_pos = stream.position() as usize; let mft_reference = - MftReference::from_reader(stream).map_err(Error::failed_to_read_mft_reference)?; + MftReference::from_reader(&mut stream).map_err(Error::failed_to_read_mft_reference)?; if mft_reference.entry > 0 && mft_reference.sequence > 0 { let index_record_length = stream.read_u16::()?; - let end_pos = start_pos + u64::from(index_record_length); + let end_pos = start_pos + usize::from(index_record_length); let attr_fname_length = stream.read_u16::()?; let flags = IndexEntryFlags::from_bits_truncate(stream.read_u32::()?); - let fname_info = FileNameAttr::from_stream(stream)?; - - stream.seek(SeekFrom::Start(end_pos)).unwrap(); - Ok(Some(IndexEntryHeader { - mft_reference, - index_record_length, - attr_fname_length, - flags, - fname_info, - })) + let fname_start = stream.position() as usize; + if fname_start > end_pos || end_pos > value.len() { + return Err(std::io::Error::from(std::io::ErrorKind::UnexpectedEof).into()); + } + let fname_info = FileNameAttr::from_slice(&value[fname_start..end_pos])?; + + Ok(Some(( + IndexEntryHeader { + mft_reference, + index_record_length, + attr_fname_length, + flags, + fname_info, + }, + end_pos, + ))) } else { Ok(None) } @@ -144,23 +160,29 @@ impl IndexEntryHeader { } #[derive(Serialize, Clone, Debug)] -pub struct IndexEntries { - pub index_entries: Vec, +pub struct IndexEntries<'a> { + pub index_entries: Vec>, } -impl IndexEntries { - pub fn from_stream( - stream: &mut S, +impl<'a> IndexEntries<'a> { + pub fn from_slice( + value: &'a [u8], index_node_length: u32, - index_node_start_pos: u64, + index_node_start_pos: usize, + mut offset: usize, ) -> Result { - let end_pos = index_node_start_pos + u64::from(index_node_length); + let end_pos = index_node_start_pos + index_node_length as usize; + if end_pos > value.len() { + return Err(std::io::Error::from(std::io::ErrorKind::UnexpectedEof).into()); + } - let mut index_entries: Vec = Vec::new(); - while stream.stream_position().unwrap() < end_pos { - let index_entry = IndexEntryHeader::from_stream(stream)?; - match index_entry { - Some(inner) => index_entries.push(inner), + let mut index_entries: Vec> = Vec::new(); + while offset < end_pos { + match IndexEntryHeader::from_slice_at(value, offset)? { + Some((entry, next_offset)) => { + index_entries.push(entry); + offset = next_offset; + } None => break, } } diff --git a/src/bin/mft_dump.rs b/src/bin/mft_dump.rs index b8b80e5..628840f 100644 --- a/src/bin/mft_dump.rs +++ b/src/bin/mft_dump.rs @@ -269,8 +269,8 @@ impl MftDump { .filter_map(|a| { if a.header.type_code == MftAttributeType::DATA { // resident - let name = a.header.name.clone(); - a.data.into_data().map(|data| (name, data)) + let name = a.header.name; + a.data.as_data().copied().map(|data| (name, data)) } else { None } @@ -338,10 +338,10 @@ impl MftDump { self.json_buf.clear(); if self.output_format == OutputFormat::JSON { - serde_json::to_writer_pretty(&mut self.json_buf, &entry)?; + serde_json::to_writer_pretty(&mut self.json_buf, entry)?; } else { // JSONL is the performance-critical mode; use a faster serializer. - sonic_json::to_writer(&mut self.json_buf, &entry)?; + sonic_json::to_writer(&mut self.json_buf, entry)?; } self.json_buf.push(b'\n'); out.write_all(&self.json_buf)?; diff --git a/src/csv.rs b/src/csv.rs index e1a7de5..a8e14a1 100644 --- a/src/csv.rs +++ b/src/csv.rs @@ -78,12 +78,14 @@ impl FlatMftEntryWithName { let file_name = entry_attributes .iter() .find(|a| a.header.type_code == MftAttributeType::FileName) - .and_then(|a| a.data.clone().into_file_name()); + .and_then(|a| a.data.as_file_name()) + .cloned(); let standard_info = entry_attributes .iter() .find(|a| a.header.type_code == MftAttributeType::StandardInformation) - .and_then(|a| a.data.clone().into_standard_info()); + .and_then(|a| a.data.as_standard_info()) + .cloned(); let data_attr = entry_attributes .iter() diff --git a/src/entry.rs b/src/entry.rs index 9805f69..43d89ec 100644 --- a/src/entry.rs +++ b/src/entry.rs @@ -11,13 +11,11 @@ use bitflags::bitflags; use serde::Serialize; use serde::ser::{self, SerializeSeq, SerializeStruct, Serializer}; -use crate::attribute::header::{MftAttributeHeader, ResidentialHeader}; +use crate::attribute::header::MftAttributeHeader; use crate::attribute::x30::{FileNameAttr, FileNamespace}; use crate::attribute::{MftAttribute, MftAttributeContent, MftAttributeType}; -use std::io::Read; -use std::io::SeekFrom; -use std::io::{Cursor, Seek}; +use std::io::{self, Cursor, Read}; pub const ZERO_HEADER: &[u8; 4] = b"\x00\x00\x00\x00"; pub const BAAD_HEADER: &[u8; 4] = b"BAAD"; @@ -232,25 +230,31 @@ impl MftEntry { /// Retrieves most human-readable representation of a file path entry. /// Will prefer `Win32` file name attributes, and fallback to `Dos` paths. - pub fn find_best_name_attribute(&self) -> Option { - let file_name_attributes: Vec = self + pub fn find_best_name_attribute(&self) -> Option> { + let mut first: Option> = None; + let mut best_win32: Option> = None; + + for attr in self .iter_attributes_matching(Some(vec![MftAttributeType::FileName])) .filter_map(Result::ok) - .filter_map(|a| a.data.into_file_name()) - .collect(); - - // Try to find a human-readable filename first - let win32_filename = file_name_attributes - .iter() - .find(|a| [FileNamespace::Win32, FileNamespace::Win32AndDos].contains(&a.namespace)); - - match win32_filename { - Some(filename) => Some(filename.clone()), - None => { - // Try to take anything - file_name_attributes.first().cloned() + { + let Some(fname) = attr.data.as_file_name() else { + continue; + }; + + if first.is_none() { + first = Some(fname.clone()); + } + + if matches!( + fname.namespace, + FileNamespace::Win32 | FileNamespace::Win32AndDos + ) { + best_win32 = Some(fname.clone()); } } + + best_win32.or(first) } /// Applies the update sequence array fixups. @@ -289,7 +293,7 @@ impl MftEntry { } /// Returns an iterator over all the attributes of the entry. - pub fn iter_attributes(&self) -> impl Iterator> + '_ { + pub fn iter_attributes(&self) -> impl Iterator>> + '_ { self.iter_attributes_matching(None) } @@ -297,43 +301,58 @@ impl MftEntry { pub fn iter_attributes_matching( &self, types: Option>, - ) -> impl Iterator> + '_ { - let mut cursor = Cursor::new(&self.data); - let mut offset = u64::from(self.header.first_attribute_record_offset); + ) -> impl Iterator>> + '_ { + let data = self.data.as_slice(); + let mut offset = self.header.first_attribute_record_offset as usize; let mut exhausted = false; std::iter::from_fn(move || { - // We use a loop here to allow skipping filtered attributes. loop { if exhausted { return None; } - if let Err(e) = cursor.seek(SeekFrom::Start(offset)) { + // Need at least type_code + record_length. + if offset + 8 > data.len() { exhausted = true; - return Some(Err(e.into())); - }; + return Some(Err(io::Error::from(io::ErrorKind::UnexpectedEof).into())); + } + + let type_code_value = u32::from_le_bytes([ + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + ]); + if type_code_value == 0xFFFF_FFFF { + return None; + } - let header = MftAttributeHeader::from_stream(&mut cursor); + let record_length = u32::from_le_bytes([ + data[offset + 4], + data[offset + 5], + data[offset + 6], + data[offset + 7], + ]) as usize; - // Unexpected I/O error, return err and stop iterating - let header = match header { - Ok(h) => h, + if record_length == 0 || offset + record_length > data.len() { + exhausted = true; + return Some(Err(io::Error::from(io::ErrorKind::UnexpectedEof).into())); + } + + let record = &data[offset..offset + record_length]; + let start_offset = offset as u64; + offset += record_length; + + let header = match MftAttributeHeader::from_slice(record, start_offset) { + Ok(Some(h)) => h, + Ok(None) => return None, Err(e) => { exhausted = true; return Some(Err(e)); } }; - let header = match header { - Some(attribute_header) => attribute_header, - // Header is 0xFFFF_FFFF, we are finished - None => return None, - }; - - // Increment offset before moving header. - offset += u64::from(header.record_length); - // Skip attribute if filtered if let Some(filter) = &types && !filter.contains(&header.type_code) @@ -341,33 +360,14 @@ impl MftEntry { continue; } - // Check if the header is resident, and if it is, read the attribute content. - let attribute_content = match header.residential_header { - ResidentialHeader::Resident(ref resident) => { - match MftAttributeContent::from_stream_resident( - &mut cursor, - &header, - resident, - ) { - Ok(content) => content, - Err(e) => return Some(Err(e)), - } - } - ResidentialHeader::NonResident(ref resident) => { - match MftAttributeContent::from_stream_non_resident( - &mut cursor, - &header, - resident, - ) { - Ok(content) => content, - Err(e) => return Some(Err(e)), - } - } + let content = match MftAttributeContent::from_record(record, &header) { + Ok(c) => c, + Err(e) => return Some(Err(e)), }; return Some(Ok(MftAttribute { header, - data: attribute_content, + data: content, })); } }) diff --git a/src/lib.rs b/src/lib.rs index 6b81d58..931aefb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,9 +20,18 @@ pub mod entry; pub mod err; pub mod mft; pub mod ntfs; +pub mod utf16; pub(crate) mod macros; pub(crate) mod utils; +// Public utilities (used by `mft_dump` and helpful for library consumers). +pub use utils::{ + serialize_option_timestamp_chrono_compat, serialize_timestamp_chrono_compat, + windows_filetime_to_timestamp, +}; + +pub use utf16::Utf16LeStr; + #[cfg(test)] pub(crate) mod tests; diff --git a/src/mft.rs b/src/mft.rs index 3d16323..0ce2607 100644 --- a/src/mft.rs +++ b/src/mft.rs @@ -138,26 +138,24 @@ impl MftParser { match entry.find_best_name_attribute() { Some(filename_header) => { let parent_entry_id = filename_header.parent.entry; + let name = filename_header.name.to_utf8_string(); // MFT entry 5 is the root path. if parent_entry_id == 5 { - return Ok(Some(PathBuf::from(filename_header.name))); + return Ok(Some(PathBuf::from(name))); } if parent_entry_id == entry_id { trace!("Found self-referential file path, for entry ID {entry_id}"); - return Ok(Some(PathBuf::from("[Orphaned]").join(filename_header.name))); + return Ok(Some(PathBuf::from("[Orphaned]").join(&name))); } if parent_entry_id > 0 { - Ok(Some(self.inner_get_entry( - parent_entry_id, - Some(&filename_header.name), - ))) + Ok(Some(self.inner_get_entry(parent_entry_id, Some(&name)))) } else { trace!("Found orphaned entry ID {entry_id}"); - let orphan = PathBuf::from("[Orphaned]").join(filename_header.name); + let orphan = PathBuf::from("[Orphaned]").join(&name); self.entries_cache .put(entry.header.record_number, orphan.clone()); diff --git a/src/utf16.rs b/src/utf16.rs new file mode 100644 index 0000000..de8aa1d --- /dev/null +++ b/src/utf16.rs @@ -0,0 +1,175 @@ +use serde::Serialize; +use serde::ser::{Error as _, Serializer}; +use std::cell::RefCell; +use std::fmt; +use utf16_simd::Scratch; + +thread_local! { + static UTF16_SCRATCH: RefCell = RefCell::new(Scratch::new()); +} + +/// Borrowed UTF-16LE string data. +/// +/// This is a zero-copy view into an underlying byte buffer (typically an MFT entry buffer). +/// The bytes are interpreted as UTF-16LE code units. +/// +/// Notes: +/// - The view is **not** required to be valid UTF-16. Lone surrogates are dropped when converting +/// to UTF-8 (WTF-16 style), matching `utf16-simd`'s semantics. +/// - This type is intentionally optimized for “decode only at output time” use-cases. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Utf16LeStr<'a> { + utf16le: &'a [u8], +} + +impl<'a> Utf16LeStr<'a> { + /// Construct a borrowed UTF-16LE string from bytes. + /// + /// The slice length should be a multiple of 2. If it's not, the trailing odd byte is ignored. + pub fn from_utf16le_bytes(utf16le: &'a [u8]) -> Self { + let len = utf16le.len() & !1; + Self { + utf16le: &utf16le[..len], + } + } + + /// Construct a borrowed UTF-16LE string from bytes, truncating at the first UTF-16 NUL + /// (`0x0000`) code unit, if present. + pub fn from_utf16le_bytes_until_nul(utf16le: &'a [u8]) -> Self { + let len = utf16le.len() & !1; + let utf16le = &utf16le[..len]; + + for i in (0..len).step_by(2) { + if utf16le[i] == 0 && utf16le[i + 1] == 0 { + return Self { + utf16le: &utf16le[..i], + }; + } + } + + Self { utf16le } + } + + pub fn empty() -> Self { + Self { utf16le: &[] } + } + + pub fn is_empty(&self) -> bool { + self.utf16le.is_empty() + } + + pub fn as_utf16le_bytes(&self) -> &'a [u8] { + self.utf16le + } + + pub fn len_units(&self) -> usize { + self.utf16le.len() / 2 + } + + /// Execute `f` with a temporary UTF-8 view of this string. + /// + /// This does not allocate per call (it reuses a thread-local scratch buffer), but callers + /// must not try to re-enter `Utf16LeStr` conversion APIs from inside `f`. + pub fn with_utf8(&self, f: impl FnOnce(&str) -> R) -> R { + if self.is_empty() { + return f(""); + } + + UTF16_SCRATCH.with(|cell| { + let mut scratch = cell.borrow_mut(); + let out = scratch.escape_utf16le_raw(self.utf16le, self.len_units()); + let s = std::str::from_utf8(out).expect("utf16-simd outputs valid UTF-8"); + f(s) + }) + } + + /// Allocate a UTF-8 `String` for this UTF-16LE data. + pub fn to_utf8_string(&self) -> String { + self.with_utf8(|s| s.to_owned()) + } + + /// Compare this UTF-16LE string to a UTF-8 `&str` without allocating. + /// + /// Fast path: if `other` is ASCII, compare directly against UTF-16LE bytes by requiring + /// `hi == 0` for each code unit. + pub fn eq_utf8(&self, other: &str) -> bool { + if other.is_ascii() { + let other = other.as_bytes(); + if self.utf16le.len() != other.len() * 2 { + return false; + } + for (i, &b) in other.iter().enumerate() { + if self.utf16le[i * 2] != b || self.utf16le[i * 2 + 1] != 0 { + return false; + } + } + true + } else { + self.with_utf8(|s| s == other) + } + } + + /// ASCII-only case-insensitive equality against a UTF-8 `&str`, without allocating. + /// + /// Fast path: if `other` is ASCII, compare directly against UTF-16LE bytes by requiring + /// `hi == 0` for each code unit and using `to_ascii_lowercase()` on the low byte. + pub fn eq_ignore_ascii_case(&self, other: &str) -> bool { + if other.is_ascii() { + let other = other.as_bytes(); + if self.utf16le.len() != other.len() * 2 { + return false; + } + for (i, &b) in other.iter().enumerate() { + let lo = self.utf16le[i * 2]; + let hi = self.utf16le[i * 2 + 1]; + if hi != 0 { + return false; + } + if !lo.eq_ignore_ascii_case(&b) { + return false; + } + } + true + } else { + self.with_utf8(|s| s.eq_ignore_ascii_case(other)) + } + } +} + +impl PartialEq<&str> for Utf16LeStr<'_> { + fn eq(&self, other: &&str) -> bool { + self.eq_utf8(other) + } +} + +impl Serialize for Utf16LeStr<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + if self.is_empty() { + return serializer.serialize_str(""); + } + + UTF16_SCRATCH.with(|cell| { + let mut scratch = cell.borrow_mut(); + let out = scratch.escape_utf16le_raw(self.utf16le, self.len_units()); + let s = std::str::from_utf8(out).map_err(S::Error::custom)?; + serializer.serialize_str(s) + }) + } +} + +impl fmt::Debug for Utf16LeStr<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("Utf16LeStr") + .field(&self.with_utf8(|s| s.to_owned())) + .finish() + } +} + +impl fmt::Display for Utf16LeStr<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.with_utf8(|s| f.write_str(s)) + } +} diff --git a/src/utils.rs b/src/utils.rs index bf82830..c5cb907 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,11 +1,8 @@ -use byteorder::ReadBytesExt; use jiff::Timestamp; use jiff::fmt::StdFmtWrite; use jiff::fmt::temporal::DateTimePrinter; use serde::Serializer; -use std::char::decode_utf16; use std::fmt; -use std::io::{self, Read, Seek}; const TIMESTAMP_PRINTER_P0: DateTimePrinter = DateTimePrinter::new().precision(Some(0)); const TIMESTAMP_PRINTER_P3: DateTimePrinter = DateTimePrinter::new().precision(Some(3)); @@ -34,7 +31,7 @@ impl fmt::Display for ChronoRfc3339Compat<'_> { } } -pub(crate) fn serialize_timestamp_chrono_compat( +pub fn serialize_timestamp_chrono_compat( ts: &Timestamp, serializer: S, ) -> Result @@ -44,7 +41,7 @@ where serializer.collect_str(&ChronoRfc3339Compat(ts)) } -pub(crate) fn serialize_option_timestamp_chrono_compat( +pub fn serialize_option_timestamp_chrono_compat( ts: &Option, serializer: S, ) -> Result @@ -63,7 +60,7 @@ where /// /// Returns an error instead of panicking when the resulting timestamp is outside `jiff::Timestamp`'s /// supported range (roughly years -9999 to 9999). -pub(crate) fn windows_filetime_to_timestamp(filetime_100ns: u64) -> crate::err::Result { +pub fn windows_filetime_to_timestamp(filetime_100ns: u64) -> crate::err::Result { // Match historical behavior (`winstructs::timestamp::WinTimestamp::to_datetime`): // FILETIME is 100ns resolution, but the conversion truncates to microseconds. const WINDOWS_TO_UNIX_EPOCH_MICROS: i64 = 11_644_473_600_000_000; @@ -91,39 +88,6 @@ pub fn to_hex_string(bytes: &[u8]) -> String { s } -/// Reads a utf16 string from the given stream. -/// If `len` is given, exactly `len` u16 values are read from the stream. -/// If `len` is None, the string is assumed to be null terminated and the stream will be read to the first null (0). -pub fn read_utf16_string(stream: &mut T, len: Option) -> io::Result { - let mut buffer = match len { - Some(len) => Vec::with_capacity(len), - None => Vec::new(), - }; - - match len { - Some(len) => { - for _ in 0..len { - let next_char = stream.read_u16::()?; - buffer.push(next_char); - } - } - None => loop { - let next_char = stream.read_u16::()?; - - if next_char == 0 { - break; - } - - buffer.push(next_char); - }, - } - - // We need to stop if we see a NUL byte, even if asked for more bytes. - decode_utf16(buffer.into_iter().take_while(|&byte| byte != 0x00)) - .map(|r| r.map_err(|_e| io::Error::from(io::ErrorKind::InvalidData))) - .collect() -} - #[cfg(test)] mod tests { use super::*; diff --git a/tests/test_data_run.rs b/tests/test_data_run.rs index 3f94d23..cbcfe98 100644 --- a/tests/test_data_run.rs +++ b/tests/test_data_run.rs @@ -1,8 +1,8 @@ mod fixtures; use fixtures::*; +use mft::attribute::MftAttributeType; use mft::attribute::data_run::{DataRun, RunType, decode_data_runs}; -use mft::attribute::{MftAttribute, MftAttributeType}; use mft::mft::MftParser; #[test] @@ -364,11 +364,10 @@ fn test_data_runs_at_offset() { let mut parser = MftParser::from_path(sample).unwrap(); for record in parser.iter_entries().take(1).filter_map(|a| a.ok()) { - let attributes: Vec = - record.iter_attributes().filter_map(Result::ok).collect(); + let attributes: Vec<_> = record.iter_attributes().filter_map(Result::ok).collect(); for attribute in attributes { if attribute.header.type_code == MftAttributeType::DATA { - let data_runs = attribute.data.into_data_runs().unwrap(); + let data_runs = attribute.data.as_data_runs().unwrap(); assert_eq!(data_runs.data_runs.len(), 53); assert_eq!(data_runs.data_runs[0].lcn_offset, 0); assert_eq!(data_runs.data_runs[0].lcn_length, 517248); diff --git a/tests/test_entry.rs b/tests/test_entry.rs index 7bdb62a..6cd8b46 100644 --- a/tests/test_entry.rs +++ b/tests/test_entry.rs @@ -2,14 +2,13 @@ mod fixtures; use fixtures::*; use mft::Timestamp; +use mft::attribute::MftAttributeType; use mft::attribute::header::ResidentialHeader; -use mft::attribute::x30::{FileNameAttr, FileNamespace}; -use mft::attribute::x90::{IndexCollationRules, IndexEntryFlags, IndexEntryHeader}; -use mft::attribute::{FileAttributeFlags, MftAttribute, MftAttributeType}; +use mft::attribute::x30::FileNamespace; +use mft::attribute::x90::{IndexCollationRules, IndexEntryFlags}; use mft::entry::MftEntry; use mft::err::Error as MftError; use mft::mft::MftParser; -use winstructs::ntfs::mft_reference::MftReference; fn filetime_bytes_to_timestamp(bytes: [u8; 8]) -> Timestamp { // Windows FILETIME: 100ns intervals since 1601-01-01 UTC. @@ -50,16 +49,15 @@ fn test_entry_index_root() { let mut parser = MftParser::from_path(sample).unwrap(); for record in parser.iter_entries().take(1).filter_map(|a| a.ok()) { - let attributes: Vec = - record.iter_attributes().filter_map(Result::ok).collect(); + let attributes: Vec<_> = record.iter_attributes().filter_map(Result::ok).collect(); for attribute in attributes { if attribute.header.type_code == MftAttributeType::IndexRoot { - let index_root = attribute.data.into_index_root().unwrap(); + let index_root = attribute.data.as_index_root().unwrap(); assert_eq!( index_root.collation_rule, IndexCollationRules::CollationFilename ); - let index_entries = index_root.index_entries.index_entries; + let index_entries = &index_root.index_entries.index_entries; assert_eq!(index_entries.len(), 4); let created = @@ -67,34 +65,31 @@ fn test_entry_index_root() { let mft_modified = filetime_bytes_to_timestamp([0x76, 0x86, 0xF6, 0x8C, 0x04, 0x64, 0xCA, 0x01]); - let index_entry_comp = IndexEntryHeader { - mft_reference: MftReference { - entry: 26399, - sequence: 1, - }, - index_record_length: 136, - attr_fname_length: 110, - flags: IndexEntryFlags::INDEX_ENTRY_NODE, - fname_info: FileNameAttr { - parent: MftReference { - entry: 26359, - sequence: 1, - }, - created, - modified: created, - mft_modified, - accessed: mft_modified, - logical_size: 4096, - physical_size: 1484, - flags: FileAttributeFlags::FILE_ATTRIBUTE_ARCHIVE, - reparse_value: 0, - name_length: 22, - namespace: FileNamespace::Win32, - name: "test_returnfuncptrs.py".to_string(), - }, - }; let last_index_entry = &index_entries[3]; - assert_eq!(last_index_entry, &index_entry_comp); + assert_eq!(last_index_entry.mft_reference.entry, 26399); + assert_eq!(last_index_entry.mft_reference.sequence, 1); + assert_eq!(last_index_entry.index_record_length, 136); + assert_eq!(last_index_entry.attr_fname_length, 110); + assert_eq!(last_index_entry.flags, IndexEntryFlags::INDEX_ENTRY_NODE); + assert_eq!(last_index_entry.fname_info.parent.entry, 26359); + assert_eq!(last_index_entry.fname_info.parent.sequence, 1); + assert_eq!(last_index_entry.fname_info.created, created); + assert_eq!(last_index_entry.fname_info.modified, created); + assert_eq!(last_index_entry.fname_info.mft_modified, mft_modified); + assert_eq!(last_index_entry.fname_info.accessed, mft_modified); + assert_eq!(last_index_entry.fname_info.logical_size, 4096); + assert_eq!(last_index_entry.fname_info.physical_size, 1484); + assert_eq!( + last_index_entry.fname_info.flags, + mft::attribute::FileAttributeFlags::FILE_ATTRIBUTE_ARCHIVE + ); + assert_eq!(last_index_entry.fname_info.reparse_value, 0); + assert_eq!(last_index_entry.fname_info.name_length, 22); + assert_eq!(last_index_entry.fname_info.namespace, FileNamespace::Win32); + assert_eq!( + last_index_entry.fname_info.name.to_utf8_string(), + "test_returnfuncptrs.py" + ); } } } diff --git a/tests/test_standard_information.rs b/tests/test_standard_information.rs index 3cae1f6..7b9745e 100644 --- a/tests/test_standard_information.rs +++ b/tests/test_standard_information.rs @@ -34,7 +34,7 @@ fn standard_information_len_48_does_not_leak_next_attribute_header_bytes() { let si = attr .data - .into_standard_info() + .as_standard_info() .expect("expected parsed standard info"); // Extended fields are absent in the 48-byte layout => should be zero. From 268d6d30c690d850acba3226a41348a2d4187b96 Mon Sep 17 00:00:00 2001 From: Omer Ben-Amram Date: Sat, 3 Jan 2026 13:44:29 +0200 Subject: [PATCH 2/5] Fix find_best_name_attribute Win32 selection Preserve the first Win32/Win32AndDos FILE_NAME attribute to match prior behavior and avoid unstable results when multiple Win32 names are present. --- src/entry.rs | 100 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 98 insertions(+), 2 deletions(-) diff --git a/src/entry.rs b/src/entry.rs index 43d89ec..06b553f 100644 --- a/src/entry.rs +++ b/src/entry.rs @@ -250,7 +250,12 @@ impl MftEntry { fname.namespace, FileNamespace::Win32 | FileNamespace::Win32AndDos ) { - best_win32 = Some(fname.clone()); + // Preserve the first Win32/Win32AndDos name for stability. + // MFT entries can contain multiple Win32 names (e.g. hard links), and there + // isn't a canonical choice without directory context. + if best_win32.is_none() { + best_win32 = Some(fname.clone()); + } } } @@ -376,7 +381,8 @@ impl MftEntry { #[cfg(test)] mod tests { - use super::EntryHeader; + use super::{EntryHeader, MftEntry}; + use crate::attribute::x30::FileNamespace; use std::io::Cursor; #[test] @@ -405,4 +411,94 @@ mod tests { assert_eq!(entry_header.first_attribute_id, 6); assert_eq!(entry_header.record_number, 38357); } + + fn filename_value_bytes(name: &str, namespace: FileNamespace) -> Vec { + // Based on the example in `attribute::x30` docs; timestamps/flags are arbitrary + // but valid. Only `namespace` + `name` matter for this test. + let mut v = Vec::new(); + + // Parent directory reference (8 bytes): entry=5, sequence=5. + v.extend_from_slice(&[0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, 0x00]); + + // 4x FILETIME timestamps. + const FT: [u8; 8] = [0xD5, 0x2D, 0x48, 0x58, 0x43, 0x5F, 0xCE, 0x01]; + v.extend_from_slice(&FT); + v.extend_from_slice(&FT); + v.extend_from_slice(&FT); + v.extend_from_slice(&FT); + + // Sizes. + v.extend_from_slice(&[0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00]); // 64MiB + v.extend_from_slice(&[0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00]); // 64MiB + + // Flags (6) + reparse value (0). + v.extend_from_slice(&[0x06, 0x00, 0x00, 0x00]); + v.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); + + // Name length + namespace. + let name_len: u8 = name.len().try_into().expect("name too long for test"); + v.push(name_len); + v.push(namespace as u8); + + // UTF-16LE bytes (ASCII-only for this test). + for b in name.as_bytes() { + v.push(*b); + v.push(0); + } + + v + } + + fn resident_attribute_record(type_code: u32, value: &[u8]) -> Vec { + // Attribute header is 24 bytes for resident attributes with no name. + const HEADER_LEN: usize = 24; + let mut record_length = HEADER_LEN + value.len(); + // Attributes are quadword-aligned on disk; keep it aligned to match real layout. + record_length = (record_length + 7) & !7; + + let mut r = vec![0u8; record_length]; + r[0..4].copy_from_slice(&type_code.to_le_bytes()); + r[4..8].copy_from_slice(&(record_length as u32).to_le_bytes()); + r[8] = 0; // resident + r[9] = 0; // name length + r[10..12].copy_from_slice(&0u16.to_le_bytes()); // name offset (unused) + r[12..14].copy_from_slice(&0u16.to_le_bytes()); // flags + r[14..16].copy_from_slice(&0u16.to_le_bytes()); // instance + r[16..20].copy_from_slice(&(value.len() as u32).to_le_bytes()); // value length + r[20..22].copy_from_slice(&(HEADER_LEN as u16).to_le_bytes()); // value offset + r[22] = 0; // index flag + r[23] = 0; // padding + + r[HEADER_LEN..HEADER_LEN + value.len()].copy_from_slice(value); + r + } + + #[test] + fn find_best_name_attribute_prefers_first_win32_over_later_win32() { + // Regression test: avoid returning the *last* Win32 FILE_NAME attribute. + let v1 = filename_value_bytes("first.txt", FileNamespace::Win32); + let v2 = filename_value_bytes("second.txt", FileNamespace::Win32); + + let a1 = resident_attribute_record(0x30, &v1); + let a2 = resident_attribute_record(0x30, &v2); + + let mut data = Vec::new(); + data.extend_from_slice(&a1); + data.extend_from_slice(&a2); + data.extend_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); // $END + + let mut header = EntryHeader::zero(); + header.first_attribute_record_offset = 0; + + let entry = MftEntry { + header, + data, + valid_fixup: None, + }; + + let best = entry + .find_best_name_attribute() + .expect("expected FILE_NAME attribute"); + assert_eq!(best.name.to_utf8_string(), "first.txt"); + } } From 4bf1297572523c7d77fef8d9f046341d68094ea9 Mon Sep 17 00:00:00 2001 From: Omer Ben-Amram Date: Sat, 3 Jan 2026 14:09:53 +0200 Subject: [PATCH 3/5] Fix attribute iteration on $END marker Stop attribute iteration when only the 4-byte 0xFFFF_FFFF terminator remains, instead of erroring with UnexpectedEof. Add regression coverage for the packed terminator case and 32-bit length overflow. --- .cursorignore | 3 ++ src/entry.rs | 94 ++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 .cursorignore diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 0000000..0b9538f --- /dev/null +++ b/.cursorignore @@ -0,0 +1,3 @@ +!/external +!/external/refs + diff --git a/src/entry.rs b/src/entry.rs index 06b553f..e1c335a 100644 --- a/src/entry.rs +++ b/src/entry.rs @@ -317,8 +317,16 @@ impl MftEntry { return None; } - // Need at least type_code + record_length. - if offset + 8 > data.len() { + // Need at least type_code (u32). The $END marker is just a type_code + // (0xFFFF_FFFF) and has no record_length. + let type_code_end = match offset.checked_add(4) { + Some(end) => end, + None => { + exhausted = true; + return Some(Err(io::Error::from(io::ErrorKind::UnexpectedEof).into())); + } + }; + if type_code_end > data.len() { exhausted = true; return Some(Err(io::Error::from(io::ErrorKind::UnexpectedEof).into())); } @@ -333,6 +341,19 @@ impl MftEntry { return None; } + // Need at least type_code + record_length for real attributes. + let header_end = match offset.checked_add(8) { + Some(end) => end, + None => { + exhausted = true; + return Some(Err(io::Error::from(io::ErrorKind::UnexpectedEof).into())); + } + }; + if header_end > data.len() { + exhausted = true; + return Some(Err(io::Error::from(io::ErrorKind::UnexpectedEof).into())); + } + let record_length = u32::from_le_bytes([ data[offset + 4], data[offset + 5], @@ -340,14 +361,22 @@ impl MftEntry { data[offset + 7], ]) as usize; - if record_length == 0 || offset + record_length > data.len() { + let record_end = match offset.checked_add(record_length) { + Some(end) => end, + None => { + exhausted = true; + return Some(Err(io::Error::from(io::ErrorKind::UnexpectedEof).into())); + } + }; + + if record_length == 0 || record_end > data.len() { exhausted = true; return Some(Err(io::Error::from(io::ErrorKind::UnexpectedEof).into())); } - let record = &data[offset..offset + record_length]; + let record = &data[offset..record_end]; let start_offset = offset as u64; - offset += record_length; + offset = record_end; let header = match MftAttributeHeader::from_slice(record, start_offset) { Ok(Some(h)) => h, @@ -412,6 +441,35 @@ mod tests { assert_eq!(entry_header.record_number, 38357); } + #[test] + #[cfg(target_pointer_width = "32")] + fn iter_attributes_rejects_overflowing_record_length_without_panicking() { + // Regression test for 32-bit overflow: + // prior code used `offset + record_length` without checked arithmetic, which can wrap and + // allow an invalid slice like `data[100..50]` to panic. + let offset: usize = 100; + let record_length = u32::MAX - 50; // `offset + record_length` wraps on 32-bit. + + let mut data = vec![0u8; 256]; + data[offset..offset + 4].copy_from_slice(&0x30u32.to_le_bytes()); // FILE_NAME (arbitrary) + data[offset + 4..offset + 8].copy_from_slice(&record_length.to_le_bytes()); + + let mut header = EntryHeader::zero(); + header.first_attribute_record_offset = offset as u16; + + let entry = MftEntry { + header, + data, + valid_fixup: None, + }; + + let first = entry + .iter_attributes() + .next() + .expect("expected an iterator item"); + assert!(first.is_err()); + } + fn filename_value_bytes(name: &str, namespace: FileNamespace) -> Vec { // Based on the example in `attribute::x30` docs; timestamps/flags are arbitrary // but valid. Only `namespace` + `name` matter for this test. @@ -501,4 +559,30 @@ mod tests { .expect("expected FILE_NAME attribute"); assert_eq!(best.name.to_utf8_string(), "first.txt"); } + + #[test] + fn iter_attributes_stops_on_end_marker_without_record_length() { + // If the attribute list is tightly packed and ends with only the 4-byte $END marker + // (0xFFFF_FFFF), the iterator must stop cleanly (return None) without trying to read + // a non-existent record length. + let v1 = filename_value_bytes("one.txt", FileNamespace::Win32); + let a1 = resident_attribute_record(0x30, &v1); + + let mut data = Vec::new(); + data.extend_from_slice(&a1); + data.extend_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); // $END (exactly 4 bytes) + + let mut header = EntryHeader::zero(); + header.first_attribute_record_offset = 0; + + let entry = MftEntry { + header, + data, + valid_fixup: None, + }; + + let mut it = entry.iter_attributes(); + assert!(it.next().expect("expected first attribute").is_ok()); + assert!(it.next().is_none()); + } } From 12b222e8e8f63b0998bf0b9088e0e692e35137a1 Mon Sep 17 00:00:00 2001 From: Omer Ben-Amram Date: Sat, 3 Jan 2026 14:34:51 +0200 Subject: [PATCH 4/5] Fix non-resident empty mapping pairs parsing Treat an empty mapping pairs section as an empty runlist to preserve pre-refactor behavior and avoid spurious FailedToDecodeDataRuns errors. --- src/attribute/mod.rs | 66 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 61 insertions(+), 5 deletions(-) diff --git a/src/attribute/mod.rs b/src/attribute/mod.rs index c450f92..17d41ce 100644 --- a/src/attribute/mod.rs +++ b/src/attribute/mod.rs @@ -82,11 +82,19 @@ impl<'a> MftAttributeContent<'a> { .get(datarun_offset..) .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::UnexpectedEof))?; - let data_runs = decode_data_runs(runs).ok_or_else(|| { - crate::err::Error::FailedToDecodeDataRuns { - bad_data_runs: runs.to_vec(), - } - })?; + // The mapping pairs array is normally terminated by a 0 byte. However, older + // versions of this crate accepted the edge-case where the mapping pairs section + // is empty (`datarun_offset == record_length`), treating it as an empty runlist. + // Preserve that behavior for compatibility with such records. + let data_runs = if runs.is_empty() { + Vec::new() + } else { + decode_data_runs(runs).ok_or_else(|| { + crate::err::Error::FailedToDecodeDataRuns { + bad_data_runs: runs.to_vec(), + } + })? + }; Ok(MftAttributeContent::DataRun(NonResidentAttr { data_runs })) } } @@ -243,3 +251,51 @@ bitflags! { } impl_serialize_for_bitflags! {AttributeDataFlags} + +#[cfg(test)] +mod tests { + use super::*; + use crate::attribute::header::MftAttributeHeader; + + #[test] + fn nonresident_attribute_allows_empty_mapping_pairs_section() { + // Regression test: + // `NonResidentAttr::from_stream` historically treated `data_run_bytes_count == 0` + // (i.e. `datarun_offset == record_length`) as a valid empty runlist. + // The slice-based parser must preserve that behavior to avoid rejecting such records. + let record_length: u32 = 64; + let mut record = vec![0u8; record_length as usize]; + + // Common attribute record header. + record[0..4].copy_from_slice(&(MftAttributeType::DATA as u32).to_le_bytes()); + record[4..8].copy_from_slice(&record_length.to_le_bytes()); + record[8] = 1; // non-resident + record[9] = 0; // name length + record[10..12].copy_from_slice(&0u16.to_le_bytes()); // name offset (unused) + record[12..14].copy_from_slice(&0u16.to_le_bytes()); // flags + record[14..16].copy_from_slice(&0u16.to_le_bytes()); // instance + + // Non-resident header. + // vnc_first (16..24) and vnc_last (24..32) are 0. + record[32..34].copy_from_slice(&(record_length as u16).to_le_bytes()); // datarun_offset + record[34..36].copy_from_slice(&0u16.to_le_bytes()); // compression unit size + // padding (36..40) = 0 + // allocated_length (40..48) = 0 + // file_size (48..56) = 0 + // valid_data_length (56..64) = 0 + + let header = MftAttributeHeader::from_slice(&record, 0) + .expect("expected header parse to succeed") + .expect("expected attribute type != $END"); + + let content = MftAttributeContent::from_record(&record, &header) + .expect("expected non-resident attribute to parse successfully"); + + match content { + MftAttributeContent::DataRun(nonresident) => { + assert!(nonresident.data_runs.is_empty()); + } + other => panic!("expected DataRun content, got {other:?}"), + } + } +} From b38da6e0f662614f9c8cee44d4e4e7ee173b5a9f Mon Sep 17 00:00:00 2001 From: Omer Ben-Amram Date: Sat, 3 Jan 2026 14:34:56 +0200 Subject: [PATCH 5/5] Harden index entry parsing bounds checks Reject zero/short index entry lengths and prevent offset overflow to avoid invalid reads and non-advancing loops when parsing index nodes. --- src/attribute/x90.rs | 126 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 122 insertions(+), 4 deletions(-) diff --git a/src/attribute/x90.rs b/src/attribute/x90.rs index 2f882ee..d40a83d 100644 --- a/src/attribute/x90.rs +++ b/src/attribute/x90.rs @@ -125,6 +125,8 @@ impl<'a> IndexEntryHeader<'a> { value: &'a [u8], offset: usize, ) -> Result, usize)>> { + const INDEX_ENTRY_HEADER_LEN: u16 = 16; + let mut stream = Cursor::new(value); stream.set_position(offset as u64); let start_pos = stream.position() as usize; @@ -133,7 +135,23 @@ impl<'a> IndexEntryHeader<'a> { MftReference::from_reader(&mut stream).map_err(Error::failed_to_read_mft_reference)?; if mft_reference.entry > 0 && mft_reference.sequence > 0 { let index_record_length = stream.read_u16::()?; - let end_pos = start_pos + usize::from(index_record_length); + if index_record_length == 0 { + return Err(Error::Any { + detail: "index entry record_length is 0".to_string(), + }); + } + if index_record_length < INDEX_ENTRY_HEADER_LEN { + return Err(Error::Any { + detail: format!( + "index entry record_length {index_record_length} is smaller than minimum {INDEX_ENTRY_HEADER_LEN}" + ), + }); + } + let end_pos = start_pos + .checked_add(usize::from(index_record_length)) + .ok_or_else(|| Error::Any { + detail: "index entry offset overflow".to_string(), + })?; let attr_fname_length = stream.read_u16::()?; let flags = IndexEntryFlags::from_bits_truncate(stream.read_u32::()?); @@ -171,15 +189,33 @@ impl<'a> IndexEntries<'a> { index_node_start_pos: usize, mut offset: usize, ) -> Result { - let end_pos = index_node_start_pos + index_node_length as usize; - if end_pos > value.len() { + let index_node_end = index_node_start_pos + .checked_add(index_node_length as usize) + .ok_or_else(|| Error::Any { + detail: "index node end offset overflow".to_string(), + })?; + if index_node_end > value.len() { return Err(std::io::Error::from(std::io::ErrorKind::UnexpectedEof).into()); } let mut index_entries: Vec> = Vec::new(); - while offset < end_pos { + while offset < index_node_end { match IndexEntryHeader::from_slice_at(value, offset)? { Some((entry, next_offset)) => { + if next_offset <= offset { + return Err(Error::Any { + detail: format!( + "index entry next offset {next_offset} did not advance past {offset}" + ), + }); + } + if next_offset > index_node_end { + return Err(Error::Any { + detail: format!( + "index entry end offset {next_offset} exceeds index node end {index_node_end}" + ), + }); + } index_entries.push(entry); offset = next_offset; } @@ -190,3 +226,85 @@ impl<'a> IndexEntries<'a> { Ok(IndexEntries { index_entries }) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::attribute::x30::FileNamespace; + + fn mft_reference_bytes(entry: u64, sequence: u16) -> [u8; 8] { + // NTFS MFT reference is a 48-bit entry number plus a 16-bit sequence number (little-endian). + let mut out = [0u8; 8]; + out[0] = (entry & 0xFF) as u8; + out[1] = ((entry >> 8) & 0xFF) as u8; + out[2] = ((entry >> 16) & 0xFF) as u8; + out[3] = ((entry >> 24) & 0xFF) as u8; + out[4] = ((entry >> 32) & 0xFF) as u8; + out[5] = ((entry >> 40) & 0xFF) as u8; + out[6] = (sequence & 0xFF) as u8; + out[7] = (sequence >> 8) as u8; + out + } + + #[test] + fn index_entry_record_length_zero_is_error() { + let mut buf = Vec::new(); + buf.extend_from_slice(&mft_reference_bytes(1, 1)); + buf.extend_from_slice(&0u16.to_le_bytes()); // record_length + buf.extend_from_slice(&0u16.to_le_bytes()); // attr_fname_length + buf.extend_from_slice(&0u32.to_le_bytes()); // flags/reserved + + let err = IndexEntryHeader::from_slice_at(&buf, 0).unwrap_err(); + match err { + Error::Any { detail } => assert!(detail.contains("record_length is 0")), + other => panic!("unexpected error: {other:?}"), + } + } + + #[test] + fn index_entry_end_offset_exceeds_index_node_is_error() { + // Build one valid index entry of 82 bytes (header 16 + FileNameAttr 66), + // but claim the index node only has 40 bytes. + let mut fname = Vec::new(); + fname.extend_from_slice(&mft_reference_bytes(5, 1)); // parent + fname.extend_from_slice(&0u64.to_le_bytes()); // created + fname.extend_from_slice(&0u64.to_le_bytes()); // modified + fname.extend_from_slice(&0u64.to_le_bytes()); // mft_modified + fname.extend_from_slice(&0u64.to_le_bytes()); // accessed + fname.extend_from_slice(&0u64.to_le_bytes()); // logical size + fname.extend_from_slice(&0u64.to_le_bytes()); // physical size + fname.extend_from_slice(&0u32.to_le_bytes()); // flags + fname.extend_from_slice(&0u32.to_le_bytes()); // reparse + fname.push(0); // name_length + fname.push(FileNamespace::Win32 as u8); // namespace + assert_eq!(fname.len(), 66); + + let mut buf = Vec::new(); + buf.extend_from_slice(&mft_reference_bytes(1, 1)); // mft_reference + buf.extend_from_slice(&(82u16).to_le_bytes()); // record_length + buf.extend_from_slice(&(66u16).to_le_bytes()); // attr_fname_length + buf.extend_from_slice(&0u32.to_le_bytes()); // flags/reserved + buf.extend_from_slice(&fname); + assert_eq!(buf.len(), 82); + + let err = IndexEntries::from_slice(&buf, 40, 0, 0).unwrap_err(); + match err { + Error::Any { detail } => assert!(detail.contains("exceeds index node end")), + other => panic!("unexpected error: {other:?}"), + } + } + + #[test] + #[cfg(target_pointer_width = "32")] + fn index_node_end_offset_overflow_is_error() { + let buf = [0u8; 64]; + + // On 32-bit targets, `index_node_start_pos + index_node_length` can overflow if the + // length comes from untrusted on-disk data. + let err = IndexEntries::from_slice(&buf, u32::MAX, 16, 32).unwrap_err(); + match err { + Error::Any { detail } => assert!(detail.contains("index node end offset overflow")), + other => panic!("unexpected error: {other:?}"), + } + } +}