pub const ENTRY_SIZE: usize = 32;
pub const ENTRY_ALLOCATION_BITMAP: u8 = 0x81;
pub const ENTRY_UPCASE_TABLE: u8 = 0x82;
pub const ENTRY_VOLUME_LABEL: u8 = 0x83;
pub const ENTRY_FILE: u8 = 0x85;
pub const ENTRY_STREAM_EXTENSION: u8 = 0xC0;
pub const ENTRY_FILE_NAME: u8 = 0xC1;
pub const ENTRY_INUSE: u8 = 0x80;
pub const ATTR_READ_ONLY: u16 = 0x0001;
pub const ATTR_HIDDEN: u16 = 0x0002;
pub const ATTR_SYSTEM: u16 = 0x0004;
pub const ATTR_DIRECTORY: u16 = 0x0010;
pub const ATTR_ARCHIVE: u16 = 0x0020;
pub const SECFLAG_ALLOC_POSSIBLE: u8 = 0x01;
pub const SECFLAG_NO_FAT_CHAIN: u8 = 0x02;
#[derive(Debug, Clone)]
pub struct FileEntrySet {
pub file_attributes: u16,
pub create_timestamp: u32,
pub last_modified_timestamp: u32,
pub last_accessed_timestamp: u32,
pub is_directory: bool,
pub secondary_flags: u8,
pub name_length: u8,
pub name_hash: u16,
pub valid_data_length: u64,
pub first_cluster: u32,
pub data_length: u64,
pub name: String,
pub name_utf16: Vec<u16>,
}
impl FileEntrySet {
pub fn no_fat_chain(&self) -> bool {
self.secondary_flags & SECFLAG_NO_FAT_CHAIN != 0
}
}
#[derive(Debug, Clone)]
pub enum RawSlot<'a> {
EndOfDirectory,
Unused,
AllocationBitmap {
bitmap_flags: u8,
first_cluster: u32,
data_length: u64,
},
UpcaseTable {
checksum: u32,
first_cluster: u32,
data_length: u64,
},
VolumeLabel(Vec<u16>),
File {
secondary_count: u8,
set_checksum: u16,
bytes: &'a [u8; ENTRY_SIZE],
},
Other { entry_type: u8 },
}
pub fn classify_slot(slot: &[u8; ENTRY_SIZE]) -> RawSlot<'_> {
let t = slot[0];
if t == 0x00 {
return RawSlot::EndOfDirectory;
}
if t & ENTRY_INUSE == 0 {
return RawSlot::Unused;
}
match t {
ENTRY_ALLOCATION_BITMAP => RawSlot::AllocationBitmap {
bitmap_flags: slot[1],
first_cluster: u32::from_le_bytes(slot[20..24].try_into().unwrap()),
data_length: u64::from_le_bytes(slot[24..32].try_into().unwrap()),
},
ENTRY_UPCASE_TABLE => RawSlot::UpcaseTable {
checksum: u32::from_le_bytes(slot[4..8].try_into().unwrap()),
first_cluster: u32::from_le_bytes(slot[20..24].try_into().unwrap()),
data_length: u64::from_le_bytes(slot[24..32].try_into().unwrap()),
},
ENTRY_VOLUME_LABEL => {
let n = (slot[1] as usize).min(11);
let mut units = Vec::with_capacity(n);
for i in 0..n {
let off = 2 + i * 2;
units.push(u16::from_le_bytes(slot[off..off + 2].try_into().unwrap()));
}
RawSlot::VolumeLabel(units)
}
ENTRY_FILE => RawSlot::File {
secondary_count: slot[1],
set_checksum: u16::from_le_bytes(slot[2..4].try_into().unwrap()),
bytes: slot,
},
other => RawSlot::Other { entry_type: other },
}
}
pub fn set_checksum(set: &[u8]) -> u16 {
let mut sum: u16 = 0;
for (i, &b) in set.iter().enumerate() {
if i == 2 || i == 3 {
continue;
}
sum = sum.rotate_right(1).wrapping_add(b as u16);
}
sum
}
pub fn name_hash(upcased_le_bytes: &[u8]) -> u16 {
let mut hash: u16 = 0;
for &b in upcased_le_bytes {
hash = hash.rotate_right(1).wrapping_add(b as u16);
}
hash
}
pub fn parse_file_set(set: &[u8]) -> crate::Result<FileEntrySet> {
if set.len() < 3 * ENTRY_SIZE {
return Err(crate::Error::InvalidImage(
"exfat: file entry set is shorter than 3 entries".into(),
));
}
let primary: &[u8; ENTRY_SIZE] = (&set[..ENTRY_SIZE]).try_into().unwrap();
if primary[0] != ENTRY_FILE {
return Err(crate::Error::InvalidImage(format!(
"exfat: expected primary FileDirectoryEntry (0x85), got 0x{:02X}",
primary[0]
)));
}
let secondary_count = primary[1] as usize;
let expected_len = (1 + secondary_count) * ENTRY_SIZE;
if set.len() < expected_len {
return Err(crate::Error::InvalidImage(format!(
"exfat: file entry set wants {expected_len} bytes, got {}",
set.len()
)));
}
let on_disk_checksum = u16::from_le_bytes(primary[2..4].try_into().unwrap());
let computed = set_checksum(&set[..expected_len]);
if on_disk_checksum != computed {
return Err(crate::Error::InvalidImage(format!(
"exfat: file-set checksum mismatch (on-disk 0x{on_disk_checksum:04X}, \
computed 0x{computed:04X})"
)));
}
let file_attributes = u16::from_le_bytes(primary[4..6].try_into().unwrap());
let create_timestamp = u32::from_le_bytes(primary[8..12].try_into().unwrap());
let last_modified_timestamp = u32::from_le_bytes(primary[12..16].try_into().unwrap());
let last_accessed_timestamp = u32::from_le_bytes(primary[16..20].try_into().unwrap());
let is_directory = file_attributes & ATTR_DIRECTORY != 0;
let stream: &[u8; ENTRY_SIZE] = (&set[ENTRY_SIZE..2 * ENTRY_SIZE]).try_into().unwrap();
if stream[0] != ENTRY_STREAM_EXTENSION {
return Err(crate::Error::InvalidImage(format!(
"exfat: expected StreamExtension (0xC0) at slot 1, got 0x{:02X}",
stream[0]
)));
}
let secondary_flags = stream[1];
let name_length = stream[3];
let name_hash_disk = u16::from_le_bytes(stream[4..6].try_into().unwrap());
let valid_data_length = u64::from_le_bytes(stream[8..16].try_into().unwrap());
let first_cluster = u32::from_le_bytes(stream[20..24].try_into().unwrap());
let data_length = u64::from_le_bytes(stream[24..32].try_into().unwrap());
let mut name_utf16: Vec<u16> = Vec::with_capacity(name_length as usize);
let name_entries = secondary_count.saturating_sub(1);
for i in 0..name_entries {
let off = (2 + i) * ENTRY_SIZE;
let slot: &[u8; ENTRY_SIZE] = (&set[off..off + ENTRY_SIZE]).try_into().unwrap();
if slot[0] != ENTRY_FILE_NAME {
return Err(crate::Error::InvalidImage(format!(
"exfat: expected FileName (0xC1) at slot {}, got 0x{:02X}",
2 + i,
slot[0]
)));
}
for j in 0..15 {
let p = 2 + j * 2;
name_utf16.push(u16::from_le_bytes(slot[p..p + 2].try_into().unwrap()));
}
}
name_utf16.truncate(name_length as usize);
let name = String::from_utf16(&name_utf16)
.map_err(|_| crate::Error::InvalidImage("exfat: file name is not valid UTF-16".into()))?;
let _ = name_hash_disk;
Ok(FileEntrySet {
file_attributes,
create_timestamp,
last_modified_timestamp,
last_accessed_timestamp,
is_directory,
secondary_flags,
name_length,
name_hash: name_hash_disk,
valid_data_length,
first_cluster,
data_length,
name,
name_utf16,
})
}
pub fn decode_volume_label(units: &[u16]) -> String {
String::from_utf16_lossy(units)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_primary(secondary_count: u8, file_attributes: u16) -> [u8; ENTRY_SIZE] {
let mut e = [0u8; ENTRY_SIZE];
e[0] = ENTRY_FILE;
e[1] = secondary_count;
e[4..6].copy_from_slice(&file_attributes.to_le_bytes());
e
}
fn make_stream(
secondary_flags: u8,
name_length: u8,
valid_data_length: u64,
first_cluster: u32,
data_length: u64,
) -> [u8; ENTRY_SIZE] {
let mut e = [0u8; ENTRY_SIZE];
e[0] = ENTRY_STREAM_EXTENSION;
e[1] = secondary_flags;
e[3] = name_length;
e[8..16].copy_from_slice(&valid_data_length.to_le_bytes());
e[20..24].copy_from_slice(&first_cluster.to_le_bytes());
e[24..32].copy_from_slice(&data_length.to_le_bytes());
e
}
fn make_name(units: &[u16]) -> [u8; ENTRY_SIZE] {
let mut e = [0u8; ENTRY_SIZE];
e[0] = ENTRY_FILE_NAME;
for (i, &u) in units.iter().enumerate().take(15) {
let off = 2 + i * 2;
e[off..off + 2].copy_from_slice(&u.to_le_bytes());
}
e
}
#[test]
fn checksum_skips_bytes_2_and_3() {
let primary = make_primary(2, 0);
let stream = make_stream(SECFLAG_NO_FAT_CHAIN, 5, 5, 4, 5);
let name = make_name(&[
b'H' as u16,
b'E' as u16,
b'L' as u16,
b'L' as u16,
b'O' as u16,
]);
let mut set = Vec::new();
set.extend_from_slice(&primary);
set.extend_from_slice(&stream);
set.extend_from_slice(&name);
let csum1 = set_checksum(&set);
set[2] = 0xAB;
set[3] = 0xCD;
let csum2 = set_checksum(&set);
assert_eq!(csum1, csum2);
set[5] = 0x55;
let csum3 = set_checksum(&set);
assert_ne!(csum1, csum3);
}
#[test]
fn parse_known_good_set() {
let name_units: Vec<u16> = "hello.txt".encode_utf16().collect();
let mut primary = make_primary(2, 0); let stream = make_stream(SECFLAG_NO_FAT_CHAIN, name_units.len() as u8, 11, 5, 11);
let name = make_name(&name_units);
let mut set = Vec::new();
set.extend_from_slice(&primary);
set.extend_from_slice(&stream);
set.extend_from_slice(&name);
let csum = set_checksum(&set);
primary[2..4].copy_from_slice(&csum.to_le_bytes());
set[..ENTRY_SIZE].copy_from_slice(&primary);
let parsed = parse_file_set(&set).unwrap();
assert_eq!(parsed.name, "hello.txt");
assert_eq!(parsed.name_length, 9);
assert_eq!(parsed.first_cluster, 5);
assert_eq!(parsed.data_length, 11);
assert_eq!(parsed.valid_data_length, 11);
assert!(!parsed.is_directory);
assert!(parsed.no_fat_chain());
}
#[test]
fn parse_rejects_bad_checksum() {
let name_units: Vec<u16> = "x".encode_utf16().collect();
let primary = make_primary(2, 0);
let stream = make_stream(0, 1, 0, 0, 0);
let name = make_name(&name_units);
let mut set = Vec::new();
set.extend_from_slice(&primary);
set.extend_from_slice(&stream);
set.extend_from_slice(&name);
let err = parse_file_set(&set).unwrap_err();
match err {
crate::Error::InvalidImage(msg) => assert!(msg.contains("checksum")),
other => panic!("expected InvalidImage, got {other:?}"),
}
}
#[test]
fn parse_two_filename_entries() {
let name_str = "this_filename_is_just_long_enough.bin";
let name_units: Vec<u16> = name_str.encode_utf16().collect();
assert!(name_units.len() > 15);
let n_name_entries = name_units.len().div_ceil(15);
let secondary_count = (1 + n_name_entries) as u8;
let mut primary = make_primary(secondary_count, 0);
let stream = make_stream(SECFLAG_NO_FAT_CHAIN, name_units.len() as u8, 100, 10, 100);
let mut set = Vec::new();
set.extend_from_slice(&primary);
set.extend_from_slice(&stream);
for chunk in name_units.chunks(15) {
let entry = make_name(chunk);
set.extend_from_slice(&entry);
}
let csum = set_checksum(&set);
primary[2..4].copy_from_slice(&csum.to_le_bytes());
set[..ENTRY_SIZE].copy_from_slice(&primary);
let parsed = parse_file_set(&set).unwrap();
assert_eq!(parsed.name, name_str);
}
#[test]
fn classify_basic() {
let mut slot = [0u8; ENTRY_SIZE];
matches!(classify_slot(&slot), RawSlot::EndOfDirectory);
slot[0] = 0x05; matches!(classify_slot(&slot), RawSlot::Unused);
slot[0] = ENTRY_VOLUME_LABEL;
slot[1] = 3; slot[2..4].copy_from_slice(&(b'T' as u16).to_le_bytes());
slot[4..6].copy_from_slice(&(b'M' as u16).to_le_bytes());
slot[6..8].copy_from_slice(&(b'P' as u16).to_le_bytes());
match classify_slot(&slot) {
RawSlot::VolumeLabel(units) => {
assert_eq!(decode_volume_label(&units), "TMP");
}
_ => panic!("expected VolumeLabel"),
}
}
}