Skip to main content

fat/
exfat.rs

1//! exFAT reader primitives: the main boot-sector parse, the boot-region
2//! checksum (\[MS\] §3.4), and the typed 32-byte directory-entry-set decode
3//! (File 0x85 + Stream Extension 0xC0 + File Name 0xC1).
4//!
5//! exFAT is architecturally distinct from FAT12/16/32: the allocation bitmap —
6//! not the FAT — records cluster allocation, contiguous files set a NoFatChain
7//! flag and skip the FAT entirely, and names are UTF-16 across File Name
8//! entries rather than 8.3 + VFAT.
9
10use crate::boot::{FatVariant, Geometry};
11use crate::bytes::{le_u16, le_u32, le_u64, u8_at};
12use crate::error::{FatError, Result};
13use crate::time::{decode as decode_time, FatTimestamp};
14
15/// exFAT `FileAttributes` directory bit (\[MS\] §7.4.4).
16const ATTR_DIRECTORY: u16 = 0x0010;
17
18/// Entry-type base values (after masking off the 0x80 in-use bit).
19const TYPE_FILE: u8 = 0x05;
20const TYPE_STREAM_EXT: u8 = 0x40;
21const TYPE_FILE_NAME: u8 = 0x41;
22
23/// A decoded exFAT directory entry set (File + Stream Extension + File Name).
24#[derive(Debug, Clone)]
25pub struct ExfatDirEntry {
26    /// The reassembled UTF-16 name.
27    pub name: String,
28    /// Raw `FileAttributes` field.
29    pub attributes: u16,
30    /// Whether this is a directory.
31    pub is_dir: bool,
32    /// Whether the entry set is deleted (in-use bit clear).
33    pub deleted: bool,
34    /// First cluster of the data (`0` = empty).
35    pub first_cluster: u32,
36    /// Data length in bytes.
37    pub size: u64,
38    /// Whether the data is contiguous (NoFatChain — skip the FAT).
39    pub contiguous: bool,
40    /// 32-byte slot index of the File (`0x85`) entry.
41    pub index: u16,
42    /// Decoded creation timestamp.
43    pub created: Option<FatTimestamp>,
44    /// Decoded last-modified timestamp.
45    pub modified: Option<FatTimestamp>,
46    /// Decoded last-access timestamp.
47    pub accessed: Option<FatTimestamp>,
48}
49
50/// Split an exFAT 32-bit timestamp into a `(date, time)` pair for the shared
51/// FAT decoder (high 16 bits = date, low 16 bits = time; identical packing).
52fn ts_decode(raw: u32, tenths: u8) -> Option<FatTimestamp> {
53    decode_time((raw >> 16) as u16, (raw & 0xFFFF) as u16, tenths)
54}
55
56/// Parse an exFAT directory's raw bytes into decoded entry sets, reassembling
57/// UTF-16 names and surfacing deleted sets. Non-file primaries (allocation
58/// bitmap, up-case table, volume label) are skipped; parsing stops at the first
59/// end-of-directory marker (type `0x00`).
60pub fn parse_directory(data: &[u8]) -> Vec<ExfatDirEntry> {
61    let slots: Vec<&[u8]> = data.chunks_exact(32).collect();
62    let mut out = Vec::new();
63    let mut i = 0;
64    while i < slots.len() {
65        let e = slots[i];
66        let ty = e[0];
67        if ty == 0x00 {
68            break;
69        }
70        if ty & 0x7F != TYPE_FILE {
71            i += 1;
72            continue;
73        }
74        let secondary_count = usize::from(e[1]);
75        let entry = decode_set(&slots, i, secondary_count, e);
76        out.push(entry);
77        i += 1 + secondary_count;
78    }
79    out
80}
81
82/// Decode one File entry set starting at slot `i`.
83fn decode_set(slots: &[&[u8]], i: usize, secondary_count: usize, file: &[u8]) -> ExfatDirEntry {
84    let deleted = file[0] & 0x80 == 0;
85    let attributes = le_u16(file, 4);
86
87    let mut first_cluster = 0u32;
88    let mut size = 0u64;
89    let mut contiguous = false;
90    let mut name_len = 0usize;
91    let mut units: Vec<u16> = Vec::new();
92
93    for j in 1..=secondary_count {
94        let Some(s) = slots.get(i + j) else {
95            break;
96        };
97        match s[0] & 0x7F {
98            TYPE_STREAM_EXT => {
99                contiguous = s[1] & 0x02 != 0;
100                name_len = usize::from(s[3]);
101                first_cluster = le_u32(s, 20);
102                size = le_u64(s, 24);
103            }
104            TYPE_FILE_NAME => {
105                // FileName field is offset 2..32 (15 UTF-16LE units) per [MS] §7.7.
106                for k in 0..15 {
107                    units.push(le_u16(s, 2 + k * 2));
108                }
109            }
110            _ => {}
111        }
112    }
113    units.truncate(name_len);
114
115    ExfatDirEntry {
116        name: String::from_utf16_lossy(&units),
117        attributes,
118        is_dir: attributes & ATTR_DIRECTORY != 0,
119        deleted,
120        first_cluster,
121        size,
122        contiguous,
123        index: u16::try_from(i).unwrap_or(u16::MAX),
124        created: ts_decode(le_u32(file, 8), u8_at(file, 20)),
125        modified: ts_decode(le_u32(file, 12), u8_at(file, 21)),
126        accessed: ts_decode(le_u32(file, 16), 0),
127    }
128}
129
130/// Parse the exFAT main boot sector into a [`Geometry`] (variant
131/// [`FatVariant::ExFat`]). `data_start` is set to the cluster-heap offset so
132/// the shared `cluster_offset` maps clusters uniformly with the FAT path.
133///
134/// Fails loud, naming the offending value, on any invalid field.
135pub fn parse_boot(boot: &[u8]) -> Result<Geometry> {
136    if boot.get(3..11) != Some(b"EXFAT   ") {
137        return Err(FatError::NotFat(format!(
138            "exFAT signature at 0x03 is {:02X?}, expected \"EXFAT   \"",
139            boot.get(3..11).unwrap_or(&[])
140        )));
141    }
142    let sig = crate::bytes::le_u16(boot, 510);
143    if sig != 0xAA55 {
144        return Err(FatError::InvalidBoot(format!(
145            "boot signature at 0x1FE is {sig:#06x}, expected 0x55AA"
146        )));
147    }
148
149    let bps_shift = u8_at(boot, 108);
150    if !(9..=12).contains(&bps_shift) {
151        return Err(FatError::InvalidBoot(format!(
152            "BytesPerSectorShift at 0x6C is {bps_shift}, not in 9..=12 (512..4096)"
153        )));
154    }
155    let spc_shift = u8_at(boot, 109);
156    // Cluster size (bytes) must not exceed 32 MiB (shift sum <= 25) per [MS] §3.1.6.
157    if u32::from(bps_shift) + u32::from(spc_shift) > 25 {
158        return Err(FatError::InvalidBoot(format!(
159            "SectorsPerClusterShift at 0x6D is {spc_shift}; cluster size exceeds 32 MiB"
160        )));
161    }
162    let bytes_per_sector = 1u32 << bps_shift;
163    let sectors_per_cluster = 1u32 << spc_shift;
164
165    let num_fats = u8_at(boot, 110);
166    if num_fats == 0 || num_fats > 2 {
167        return Err(FatError::InvalidBoot(format!(
168            "NumberOfFats at 0x6E is {num_fats}, must be 1 or 2"
169        )));
170    }
171
172    let fat_offset = le_u32(boot, 80);
173    let fat_length = le_u32(boot, 84);
174    let cluster_heap_offset = le_u32(boot, 88);
175    let count_of_clusters = le_u32(boot, 92);
176    let root_cluster = le_u32(boot, 96);
177
178    if root_cluster < 2 || root_cluster > count_of_clusters.saturating_add(1) {
179        return Err(FatError::InvalidBoot(format!(
180            "root cluster {root_cluster} outside 2..={}",
181            count_of_clusters.saturating_add(1)
182        )));
183    }
184
185    let bps = u64::from(bytes_per_sector);
186    Ok(Geometry {
187        variant: FatVariant::ExFat,
188        bytes_per_sector,
189        sectors_per_cluster,
190        cluster_size: bytes_per_sector * sectors_per_cluster,
191        reserved_sectors: 0,
192        num_fats: u32::from(num_fats),
193        fat_size_sectors: fat_length,
194        root_entry_count: 0,
195        total_sectors: crate::bytes::le_u64(boot, 72),
196        root_cluster,
197        count_of_clusters,
198        fat_start: u64::from(fat_offset) * bps,
199        root_dir_start: 0,
200        root_dir_bytes: 0,
201        data_start: u64::from(cluster_heap_offset) * bps,
202    })
203}
204
205/// The four-byte boot-region checksum over the first 11 sectors, excluding the
206/// `VolumeFlags` (offsets 106, 107) and `PercentInUse` (offset 112) fields
207/// (\[MS\] §3.4). A mismatch against the stored checksum is a tamper signal.
208pub fn boot_checksum(sectors: &[u8], bytes_per_sector: u32) -> u32 {
209    let n = (bytes_per_sector as usize)
210        .saturating_mul(11)
211        .min(sectors.len());
212    let mut checksum: u32 = 0;
213    for (i, &b) in sectors.iter().take(n).enumerate() {
214        if i == 106 || i == 107 || i == 112 {
215            continue;
216        }
217        checksum = checksum.rotate_right(1).wrapping_add(u32::from(b));
218    }
219    checksum
220}
221
222#[cfg(test)]
223mod tests {
224    use super::{boot_checksum, parse_boot, parse_directory};
225    use crate::boot::FatVariant;
226
227    /// exFAT File directory entry (0x85, or 0x05 when deleted).
228    fn file_entry(deleted: bool, secondary_count: u8, attrs: u16) -> [u8; 32] {
229        let mut e = [0u8; 32];
230        e[0] = if deleted { 0x05 } else { 0x85 };
231        e[1] = secondary_count;
232        e[4..6].copy_from_slice(&attrs.to_le_bytes());
233        e
234    }
235
236    /// exFAT Stream Extension entry (0xC0, or 0x40 when deleted).
237    fn stream_ext(
238        deleted: bool,
239        no_fat_chain: bool,
240        name_len: u8,
241        first: u32,
242        size: u64,
243    ) -> [u8; 32] {
244        let mut e = [0u8; 32];
245        e[0] = if deleted { 0x40 } else { 0xC0 };
246        e[1] = if no_fat_chain { 0x03 } else { 0x01 }; // AllocationPossible|NoFatChain
247        e[3] = name_len;
248        e[20..24].copy_from_slice(&first.to_le_bytes());
249        e[24..32].copy_from_slice(&size.to_le_bytes());
250        e
251    }
252
253    /// exFAT File Name entry (0xC1, or 0x41 when deleted), up to 15 UTF-16 units.
254    fn file_name(deleted: bool, chars: &[u16]) -> [u8; 32] {
255        let mut e = [0u8; 32];
256        e[0] = if deleted { 0x41 } else { 0xC1 };
257        // FileName field starts at offset 2 ([MS] §7.7).
258        for (i, &c) in chars.iter().take(15).enumerate() {
259            e[2 + i * 2..4 + i * 2].copy_from_slice(&c.to_le_bytes());
260        }
261        e
262    }
263
264    fn entry_set(
265        deleted: bool,
266        name: &str,
267        attrs: u16,
268        contiguous: bool,
269        first: u32,
270        size: u64,
271    ) -> Vec<u8> {
272        let chars: Vec<u16> = name.encode_utf16().collect();
273        let mut v = Vec::new();
274        v.extend_from_slice(&file_entry(deleted, 2, attrs));
275        v.extend_from_slice(&stream_ext(
276            deleted,
277            contiguous,
278            chars.len() as u8,
279            first,
280            size,
281        ));
282        v.extend_from_slice(&file_name(deleted, &chars));
283        v
284    }
285
286    fn synth_boot() -> Vec<u8> {
287        let mut b = vec![0u8; 512];
288        b[0] = 0xEB;
289        b[1] = 0x76;
290        b[2] = 0x90;
291        b[3..11].copy_from_slice(b"EXFAT   ");
292        b[80..84].copy_from_slice(&24u32.to_le_bytes()); // FatOffset (sectors)
293        b[84..88].copy_from_slice(&8u32.to_le_bytes()); // FatLength
294        b[88..92].copy_from_slice(&32u32.to_le_bytes()); // ClusterHeapOffset
295        b[92..96].copy_from_slice(&100u32.to_le_bytes()); // ClusterCount
296        b[96..100].copy_from_slice(&5u32.to_le_bytes()); // root cluster
297        b[108] = 9; // BytesPerSectorShift → 512
298        b[109] = 3; // SectorsPerClusterShift → 8 sectors → 4096-byte cluster
299        b[110] = 1; // NumberOfFats
300        b[510] = 0x55;
301        b[511] = 0xAA;
302        b
303    }
304
305    #[test]
306    fn parses_exfat_geometry() {
307        let g = parse_boot(&synth_boot()).unwrap();
308        assert_eq!(g.variant, FatVariant::ExFat);
309        assert_eq!(g.bytes_per_sector, 512);
310        assert_eq!(g.cluster_size, 4096);
311        assert_eq!(g.fat_start, 24 * 512);
312        assert_eq!(g.data_start, 32 * 512); // cluster heap
313        assert_eq!(g.root_cluster, 5);
314        assert_eq!(g.count_of_clusters, 100);
315    }
316
317    #[test]
318    fn rejects_bad_signature() {
319        let mut b = synth_boot();
320        b[510] = 0;
321        assert!(parse_boot(&b).is_err());
322    }
323
324    #[test]
325    fn rejects_out_of_range_sector_shift() {
326        let mut b = synth_boot();
327        b[108] = 20; // 1 MiB sector — out of the 512..4096 range
328        assert!(parse_boot(&b).is_err());
329    }
330
331    #[test]
332    fn rejects_wrong_exfat_signature() {
333        let mut b = synth_boot();
334        b[3..11].copy_from_slice(b"NOTEXFAT");
335        assert!(parse_boot(&b).is_err());
336    }
337
338    #[test]
339    fn rejects_oversize_cluster_shift() {
340        let mut b = synth_boot();
341        b[109] = 20; // 512-byte sector * 2^20 clusters → > 32 MiB cluster
342        assert!(parse_boot(&b).is_err());
343    }
344
345    #[test]
346    fn rejects_bad_fat_count() {
347        let mut b = synth_boot();
348        b[110] = 3; // must be 1 or 2
349        assert!(parse_boot(&b).is_err());
350        b[110] = 0;
351        assert!(parse_boot(&b).is_err());
352    }
353
354    #[test]
355    fn rejects_root_cluster_out_of_range() {
356        let mut b = synth_boot();
357        b[96..100].copy_from_slice(&1u32.to_le_bytes()); // < 2
358        assert!(parse_boot(&b).is_err());
359        b[96..100].copy_from_slice(&9999u32.to_le_bytes()); // > count+1
360        assert!(parse_boot(&b).is_err());
361    }
362
363    #[test]
364    fn parses_a_file_entry_set() {
365        let mut dir = Vec::new();
366        dir.extend_from_slice(&entry_set(false, "Hi.txt", 0x20, true, 10, 5));
367        let entries = parse_directory(&dir);
368        assert_eq!(entries.len(), 1);
369        let e = &entries[0];
370        assert_eq!(e.name, "Hi.txt");
371        assert_eq!(e.size, 5);
372        assert_eq!(e.first_cluster, 10);
373        assert!(e.contiguous);
374        assert!(!e.is_dir);
375        assert!(!e.deleted);
376    }
377
378    #[test]
379    fn parses_directory_and_deleted_and_skips_system_entries() {
380        let mut dir = Vec::new();
381        // an allocation-bitmap primary (0x81) must be skipped, not treated as a file
382        let mut bitmap = [0u8; 32];
383        bitmap[0] = 0x81;
384        dir.extend_from_slice(&bitmap);
385        dir.extend_from_slice(&entry_set(false, "sub", 0x10, false, 20, 0)); // directory
386        dir.extend_from_slice(&entry_set(true, "gone.txt", 0x20, true, 30, 7)); // deleted
387        let entries = parse_directory(&dir);
388        assert_eq!(entries.len(), 2);
389        assert_eq!(entries[0].name, "sub");
390        assert!(entries[0].is_dir);
391        assert_eq!(entries[1].name, "gone.txt");
392        assert!(entries[1].deleted);
393    }
394
395    #[test]
396    fn lying_secondary_count_is_bounded() {
397        // SecondaryCount claims 9 secondaries but the directory ends after one.
398        let mut dir = Vec::new();
399        dir.extend_from_slice(&file_entry(false, 9, 0x20));
400        dir.extend_from_slice(&stream_ext(false, true, 1, 5, 1));
401        // no more slots → the secondary walk breaks without panicking
402        let entries = parse_directory(&dir);
403        assert_eq!(entries.len(), 1);
404    }
405
406    #[test]
407    fn unknown_secondary_type_is_ignored() {
408        let mut dir = Vec::new();
409        dir.extend_from_slice(&file_entry(false, 2, 0x20));
410        dir.extend_from_slice(&stream_ext(false, true, 1, 5, 1));
411        let mut vendor = [0u8; 32];
412        vendor[0] = 0xE0; // Vendor Extension secondary — not Stream/Name
413        dir.extend_from_slice(&vendor);
414        assert_eq!(parse_directory(&dir).len(), 1);
415    }
416
417    #[test]
418    fn stops_at_end_of_directory_marker() {
419        let mut dir = Vec::new();
420        dir.extend_from_slice(&entry_set(false, "a.txt", 0x20, true, 5, 1));
421        dir.extend_from_slice(&[0u8; 32]); // type 0x00 → end
422        dir.extend_from_slice(&entry_set(false, "b.txt", 0x20, true, 6, 1));
423        assert_eq!(parse_directory(&dir).len(), 1);
424    }
425
426    #[test]
427    fn long_name_spans_two_file_name_entries() {
428        // 20-char name → two File Name entries (15 + 5).
429        let name = "twentycharsname_ok!!";
430        let chars: Vec<u16> = name.encode_utf16().collect();
431        let mut dir = Vec::new();
432        dir.extend_from_slice(&file_entry(false, 3, 0x20));
433        dir.extend_from_slice(&stream_ext(false, true, chars.len() as u8, 40, 3));
434        dir.extend_from_slice(&file_name(false, &chars[..15]));
435        dir.extend_from_slice(&file_name(false, &chars[15..]));
436        assert_eq!(parse_directory(&dir)[0].name, name);
437    }
438
439    #[test]
440    fn checksum_excludes_volume_flags_and_percent_in_use() {
441        // 11 sectors of 0x00 except a couple of set bytes; the spec excludes
442        // indices 106, 107, 112 from the sum, so mutating them must NOT change it.
443        let bps = 512usize;
444        let mut region = vec![0u8; 11 * bps];
445        region[3] = 0x41;
446        let base = boot_checksum(&region, bps as u32);
447        region[106] = 0xFF;
448        region[107] = 0xFF;
449        region[112] = 0xFF;
450        assert_eq!(boot_checksum(&region, bps as u32), base);
451        // A byte outside the excluded set does change it.
452        region[5] = 0xFF;
453        assert_ne!(boot_checksum(&region, bps as u32), base);
454    }
455}