use crate::bytes::le_u16;
pub const ATTR_VOLUME_ID: u8 = 0x08;
pub const ATTR_DIRECTORY: u8 = 0x10;
pub const ATTR_LFN: u8 = 0x0F;
const DELETED: u8 = 0xE5;
const END: u8 = 0x00;
#[derive(Debug, Clone)]
pub struct DirEntry {
pub name: String,
pub short_name: String,
pub attributes: u8,
pub is_dir: bool,
pub is_volume_label: bool,
pub deleted: bool,
pub first_cluster: u32,
pub size: u32,
pub index: u16,
pub created: (u16, u16, u8),
pub modified: (u16, u16),
pub accessed: u16,
}
pub fn parse_directory(data: &[u8]) -> Vec<DirEntry> {
let mut out = Vec::new();
let mut lfn_parts: Vec<(u8, [u16; 13])> = Vec::new();
let mut lfn_checksum: Option<u8> = None;
for (idx, chunk) in data.chunks_exact(32).enumerate() {
let first = chunk[0];
if first == END {
break;
}
let attr = chunk[11];
if first == DELETED {
lfn_parts.clear();
lfn_checksum = None;
if attr == ATTR_LFN {
continue;
}
out.push(decode_short(chunk, idx, None, true));
continue;
}
if attr == ATTR_LFN {
let seq = first & 0x1F;
lfn_parts.push((seq, lfn_chars(chunk)));
lfn_checksum = Some(chunk[13]);
continue;
}
let long = reassemble_long(&lfn_parts, lfn_checksum, &chunk[0..11]);
out.push(decode_short(chunk, idx, long, false));
lfn_parts.clear();
lfn_checksum = None;
}
out
}
fn decode_short(chunk: &[u8], idx: usize, long_name: Option<String>, deleted: bool) -> DirEntry {
let attr = chunk[11];
let is_volume_label = attr & ATTR_VOLUME_ID != 0 && attr != ATTR_LFN;
let nt_flags = chunk[12];
let short_name = decode_short_name(&chunk[0..11], nt_flags, is_volume_label, deleted);
let first_cluster = (u32::from(le_u16(chunk, 20)) << 16) | u32::from(le_u16(chunk, 26));
DirEntry {
name: long_name.unwrap_or_else(|| short_name.clone()),
short_name,
attributes: attr,
is_dir: attr & ATTR_DIRECTORY != 0 && !is_volume_label,
is_volume_label,
deleted,
first_cluster,
size: u32::from(le_u16(chunk, 28)) | (u32::from(le_u16(chunk, 30)) << 16),
index: u16::try_from(idx).unwrap_or(u16::MAX),
created: (le_u16(chunk, 16), le_u16(chunk, 14), chunk[13]),
modified: (le_u16(chunk, 24), le_u16(chunk, 22)),
accessed: le_u16(chunk, 18),
}
}
fn decode_short_name(raw: &[u8], nt_flags: u8, is_volume_label: bool, deleted: bool) -> String {
let mut bytes = [0u8; 11];
bytes.copy_from_slice(&raw[0..11]);
if bytes[0] == 0x05 {
bytes[0] = 0xE5;
}
if deleted {
bytes[0] = b'?'; }
if is_volume_label {
return trim(&bytes);
}
let mut base = trim(&bytes[0..8]);
let mut ext = trim(&bytes[8..11]);
if nt_flags & 0x08 != 0 {
base = base.to_ascii_lowercase();
}
if nt_flags & 0x10 != 0 {
ext = ext.to_ascii_lowercase();
}
if ext.is_empty() {
base
} else {
format!("{base}.{ext}")
}
}
fn trim(bytes: &[u8]) -> String {
let end = bytes.iter().rposition(|&b| b != b' ').map_or(0, |p| p + 1);
bytes[..end].iter().map(|&b| b as char).collect()
}
fn lfn_chars(chunk: &[u8]) -> [u16; 13] {
const OFFSETS: [usize; 13] = [1, 3, 5, 7, 9, 14, 16, 18, 20, 22, 24, 28, 30];
let mut chars = [0u16; 13];
for (i, &off) in OFFSETS.iter().enumerate() {
chars[i] = le_u16(chunk, off);
}
chars
}
fn reassemble_long(
parts: &[(u8, [u16; 13])],
checksum: Option<u8>,
short_raw: &[u8],
) -> Option<String> {
let checksum = checksum?;
if parts.is_empty() || checksum != short_checksum(short_raw) {
return None;
}
let mut ordered: Vec<&(u8, [u16; 13])> = parts.iter().collect();
ordered.sort_by_key(|(seq, _)| *seq);
let mut units: Vec<u16> = Vec::new();
for (_, chars) in ordered {
for &c in chars {
if c == 0x0000 {
break;
}
if c == 0xFFFF {
continue;
}
units.push(c);
}
}
if units.is_empty() {
return None;
}
Some(String::from_utf16_lossy(&units))
}
fn short_checksum(name: &[u8]) -> u8 {
let mut sum: u8 = 0;
for &b in &name[0..11.min(name.len())] {
sum = ((sum & 1) << 7).wrapping_add(sum >> 1).wrapping_add(b);
}
sum
}
#[cfg(test)]
mod tests {
use super::{parse_directory, ATTR_DIRECTORY};
fn short_entry(name: &[u8; 11], attr: u8, cluster: u32, size: u32) -> [u8; 32] {
let mut e = [0u8; 32];
e[0..11].copy_from_slice(name);
e[11] = attr;
e[20..22].copy_from_slice(&((cluster >> 16) as u16).to_le_bytes()); e[26..28].copy_from_slice(&(cluster as u16).to_le_bytes()); e[28..32].copy_from_slice(&size.to_le_bytes());
e
}
fn lfn_checksum(name: &[u8; 11]) -> u8 {
let mut sum: u8 = 0;
for &b in name {
sum = ((sum & 1) << 7).wrapping_add(sum >> 1).wrapping_add(b);
}
sum
}
fn lfn_entry(seq: u8, last: bool, checksum: u8, chars: &[u16]) -> [u8; 32] {
let mut e = [0u8; 32];
e[0] = if last { seq | 0x40 } else { seq };
e[11] = 0x0F; e[13] = checksum;
let put = |e: &mut [u8; 32], slot: usize, v: u16| {
e[slot..slot + 2].copy_from_slice(&v.to_le_bytes());
};
let offsets = [1, 3, 5, 7, 9, 14, 16, 18, 20, 22, 24, 28, 30];
for (i, &off) in offsets.iter().enumerate() {
let v = match chars.get(i) {
Some(&c) => c,
None if i == chars.len() => 0x0000,
None => 0xFFFF,
};
put(&mut e, off, v);
}
e
}
#[test]
fn decodes_short_entry() {
let e = short_entry(b"HELLO TXT", 0x20, 5, 17);
let entries = parse_directory(&e);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "HELLO.TXT");
assert_eq!(entries[0].first_cluster, 5);
assert_eq!(entries[0].size, 17);
assert!(!entries[0].is_dir);
assert!(!entries[0].deleted);
}
#[test]
fn flags_directory_and_deleted() {
let mut dir = Vec::new();
dir.extend_from_slice(&short_entry(b"SUBDIR ", ATTR_DIRECTORY, 10, 0));
let mut del = short_entry(b"GONE TXT", 0x20, 7, 3);
del[0] = 0xE5;
dir.extend_from_slice(&del);
let entries = parse_directory(&dir);
assert_eq!(entries.len(), 2);
assert!(entries[0].is_dir);
assert!(entries[1].deleted);
}
#[test]
fn reassembles_long_name() {
let short = b"README~1TXT";
let sum = lfn_checksum(short);
let chars: Vec<u16> = "readme file.txt".encode_utf16().collect();
let mut dir = Vec::new();
dir.extend_from_slice(&lfn_entry(2, true, sum, &chars[13..]));
dir.extend_from_slice(&lfn_entry(1, false, sum, &chars[..13]));
dir.extend_from_slice(&short_entry(short, 0x20, 5, 42));
let entries = parse_directory(&dir);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "readme file.txt");
assert_eq!(entries[0].short_name, "README~1.TXT");
}
#[test]
fn nt_lowercase_flag_lowercases_short_name() {
let mut e = short_entry(b"SUBDIR ", ATTR_DIRECTORY, 10, 0);
e[12] = 0x08;
assert_eq!(parse_directory(&e)[0].name, "subdir");
let mut e = short_entry(b"HELLO TXT", 0x20, 5, 1);
e[12] = 0x10;
assert_eq!(parse_directory(&e)[0].name, "HELLO.txt");
}
#[test]
fn volume_label_marked() {
let e = short_entry(b"MYVOLUME ", 0x08, 0, 0);
let entries = parse_directory(&e);
assert!(entries[0].is_volume_label);
}
#[test]
fn stops_at_end_marker() {
let mut dir = Vec::new();
dir.extend_from_slice(&short_entry(b"FIRST TXT", 0x20, 5, 1));
dir.extend_from_slice(&[0u8; 32]); dir.extend_from_slice(&short_entry(b"AFTER TXT", 0x20, 6, 1));
let entries = parse_directory(&dir);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "FIRST.TXT");
}
#[test]
fn kanji_lead_byte_0x05_restored() {
let mut e = short_entry(b"\x05ELLO TXT", 0x20, 5, 1);
e[0] = 0x05;
let name = parse_directory(&e)[0].name.clone();
assert!(name.starts_with('\u{00E5}'));
}
#[test]
fn deleted_lfn_entry_is_skipped() {
let mut lfn = lfn_entry(1, true, 0xAA, &"x".encode_utf16().collect::<Vec<_>>());
lfn[0] = 0xE5;
assert!(parse_directory(&lfn).is_empty());
}
#[test]
fn lfn_padding_ffff_is_ignored() {
let short = b"README~1TXT";
let sum = lfn_checksum(short);
let chars = [u16::from(b'x'), 0xFFFF, u16::from(b'y')];
let mut dir = Vec::new();
dir.extend_from_slice(&lfn_entry(1, true, sum, &chars));
dir.extend_from_slice(&short_entry(short, 0x20, 5, 1));
assert_eq!(parse_directory(&dir)[0].name, "xy");
}
#[test]
fn empty_long_name_falls_back_to_short() {
let short = b"HELLO TXT";
let sum = lfn_checksum(short);
let mut dir = Vec::new();
dir.extend_from_slice(&lfn_entry(1, true, sum, &[])); dir.extend_from_slice(&short_entry(short, 0x20, 5, 1));
assert_eq!(parse_directory(&dir)[0].name, "HELLO.TXT");
}
#[test]
fn mismatched_lfn_checksum_falls_back_to_short() {
let short = b"HELLO TXT";
let chars: Vec<u16> = "orphan.txt".encode_utf16().collect();
let mut dir = Vec::new();
dir.extend_from_slice(&lfn_entry(1, true, 0xAB, &chars)); dir.extend_from_slice(&short_entry(short, 0x20, 5, 1));
let entries = parse_directory(&dir);
assert_eq!(entries[0].name, "HELLO.TXT"); }
}