A 7-dimension adversarial review of the engine, tool, and routing found nine real defects (host tests + the in-VM drill missed them). Fixed: - geometryOf now rejects a crafted VBR whose cluster shift exceeds the exFAT ceiling (bytes+sectors shift > 25) or whose cluster_count exceeds the spec max (0xFFFFFFF5) — either would overflow the engine's u32 cluster-byte / cluster-bounds arithmetic and panic under ReleaseSafe on untrusted removable media. validCluster/allocateCluster widened to u64, and writeFile's clusters_needed widened, for a >4 GiB file near the u32 offset boundary. - writeFile no longer claims valid_data_length = size unconditionally: a sparse write past a foreign file's old valid boundary now zero-fills the skipped gap on disk, so a read there returns zero, not stale bytes. - ensureDirCapacity rewrites a grown subdirectory's own DataLength, so a spec-compliant reader that bounds a directory by DataLength sees the new entries (danos itself bounds by the end marker, but chkdsk / other OSes do not). - make-exfat-image lays the allocation bitmap across as many clusters as it needs; a >128 MiB image (whose bitmap exceeds one cluster) was self-inconsistent. Verified: the engine mounts+reads both the 48 MiB fixture and a 256 MiB image. Documented (not fixed here — a shared vfs-layer limit, like the u32 offset cap): non-ASCII names fold to '?', the same as the FAT engine. New host tests pin each fix (crafted-VBR rejection, sparse-gap zero, subdir-grows-and-records-size). Full suite 131/131, bounds green.
476 lines
21 KiB
Zig
476 lines
21 KiB
Zig
//! The on-disk layout of an exFAT filesystem — the Main Boot Sector (VBR) and the
|
|
//! six 32-byte directory-entry types — as `align(1)` extern structs that bit-cast
|
|
//! straight out of a sector (multi-byte fields little-endian). Pure data, plus the
|
|
//! three exFAT checksums (boot region, up-case table, directory-entry set), the
|
|
//! name hash, and the packed timestamp <-> Unix-epoch conversion. Host-testable.
|
|
//!
|
|
//! exFAT departs from FAT in three ways this file encodes: geometry lives in a
|
|
//! MustBeZero-guarded VBR (byte 11 is zero, which is exactly why the FAT prober
|
|
//! rejects an exFAT volume — it reads a zero bytes-per-sector); a file is a SET of
|
|
//! entries (a File entry, a Stream Extension, and one or more File Name entries)
|
|
//! validated by a rotate-right checksum; and names are compared case-folded through
|
|
//! the volume's own on-disk up-case table (the folding itself lives in the engine,
|
|
//! which holds the loaded table; the hash it feeds is here).
|
|
|
|
const std = @import("std");
|
|
|
|
// exFAT's fixed on-disk widths — byte and UTF-16-unit counts the FORMAT defines,
|
|
// not ceilings danos chooses. Named so the wire-format structs carry no bare
|
|
// literal lengths; the values are facts of the spec.
|
|
pub const entry_bytes: usize = 32; // every directory entry
|
|
const jump_boot_bytes = 3;
|
|
const filesystem_name_bytes = 8; // "EXFAT "
|
|
const must_be_zero_bytes = 53; // the FAT-BPB overlap the format holds zero
|
|
const boot_code_bytes = 390;
|
|
const volume_label_units = 11;
|
|
|
|
// --- the Main Boot Sector (VBR, sector 0) ------------------------------------
|
|
|
|
/// The exFAT Main Boot Sector. `must_be_zero` (offset 11..64) overlaps where a
|
|
/// FAT BPB keeps bytes-per-sector/sectors-per-cluster/etc.; exFAT holds it zero,
|
|
/// so a FAT prober reading a zero bytes-per-sector rejects the volume — the
|
|
/// mutual-exclusion the two engines rely on.
|
|
pub const MainBootSector = extern struct {
|
|
jump_boot: [jump_boot_bytes]u8, // 0
|
|
filesystem_name: [filesystem_name_bytes]u8, // 3 "EXFAT "
|
|
must_be_zero: [must_be_zero_bytes]u8, // 11
|
|
partition_offset: u64 align(1), // 64 sectors, informational
|
|
volume_length: u64 align(1), // 72 sectors
|
|
fat_offset: u32 align(1), // 80 sectors from volume start
|
|
fat_length: u32 align(1), // 84 sectors, per FAT
|
|
cluster_heap_offset: u32 align(1), // 88 sectors from volume start
|
|
cluster_count: u32 align(1), // 92
|
|
first_cluster_of_root: u32 align(1), // 96
|
|
volume_serial_number: u32 align(1), // 100
|
|
filesystem_revision: u16 align(1), // 104
|
|
volume_flags: u16 align(1), // 106 (skipped by the boot checksum)
|
|
bytes_per_sector_shift: u8, // 108 9..12
|
|
sectors_per_cluster_shift: u8, // 109
|
|
number_of_fats: u8, // 110 1 (2 for TexFAT)
|
|
drive_select: u8, // 111
|
|
percent_in_use: u8, // 112 (skipped by the boot checksum)
|
|
reserved: [7]u8, // 113
|
|
boot_code: [boot_code_bytes]u8, // 120
|
|
boot_signature: u16 align(1), // 510 0xAA55
|
|
};
|
|
|
|
// --- directory entries (32 bytes each) ---------------------------------------
|
|
|
|
/// Entry-type bytes. The high bit (0x80) is InUse: a type with it clear is not in
|
|
/// use, and 0x00 ends the directory. Deleting an entry clears bit 7 (0x85 -> 0x05).
|
|
pub const entry_type_allocation_bitmap: u8 = 0x81;
|
|
pub const entry_type_upcase_table: u8 = 0x82;
|
|
pub const entry_type_volume_label: u8 = 0x83;
|
|
pub const entry_type_file: u8 = 0x85;
|
|
pub const entry_type_stream_extension: u8 = 0xC0;
|
|
pub const entry_type_file_name: u8 = 0xC1;
|
|
pub const entry_type_in_use_bit: u8 = 0x80;
|
|
pub const entry_type_end_of_directory: u8 = 0x00;
|
|
|
|
/// A raw 32-byte entry, for type dispatch before it is reinterpreted as a
|
|
/// specific entry.
|
|
pub const RawEntry = extern struct {
|
|
entry_type: u8,
|
|
data: [entry_bytes - 1]u8,
|
|
|
|
pub fn inUse(self: RawEntry) bool {
|
|
return self.entry_type & entry_type_in_use_bit != 0;
|
|
}
|
|
pub fn isEnd(self: RawEntry) bool {
|
|
return self.entry_type == entry_type_end_of_directory;
|
|
}
|
|
};
|
|
|
|
/// 0x81 — the Allocation Bitmap: one bit per cluster (cluster 2 = bit 0), the
|
|
/// authority for which clusters are free. The deepest departure from FAT, where
|
|
/// the chain itself was the authority.
|
|
pub const AllocationBitmapEntry = extern struct {
|
|
entry_type: u8, // 0 0x81
|
|
bitmap_flags: u8, // 1
|
|
reserved: [18]u8, // 2
|
|
first_cluster: u32 align(1), // 20
|
|
data_length: u64 align(1), // 24
|
|
};
|
|
|
|
/// 0x82 — the Up-case Table: the on-disk case-fold map (code unit -> uppercase),
|
|
/// referenced by cluster and validated by `table_checksum`.
|
|
pub const UpcaseTableEntry = extern struct {
|
|
entry_type: u8, // 0 0x82
|
|
reserved1: [3]u8, // 1
|
|
table_checksum: u32 align(1), // 4
|
|
reserved2: [12]u8, // 8
|
|
first_cluster: u32 align(1), // 20
|
|
data_length: u64 align(1), // 24
|
|
};
|
|
|
|
/// 0x83 — the Volume Label (up to 11 UTF-16 units).
|
|
pub const VolumeLabelEntry = extern struct {
|
|
entry_type: u8, // 0 0x83
|
|
character_count: u8, // 1
|
|
volume_label: [volume_label_units]u16 align(1), // 2
|
|
reserved: [8]u8, // 24
|
|
};
|
|
|
|
/// 0x85 — the File entry: the head of a set, carrying the attributes,
|
|
/// timestamps, the secondary-entry count, and the set checksum.
|
|
pub const FileEntry = extern struct {
|
|
entry_type: u8, // 0 0x85
|
|
secondary_count: u8, // 1 stream (1) + name entries
|
|
set_checksum: u16 align(1), // 2 over the whole set, skipping these two bytes
|
|
file_attributes: u16 align(1), // 4
|
|
reserved1: u16 align(1), // 6
|
|
create_timestamp: u32 align(1), // 8
|
|
last_modified_timestamp: u32 align(1), // 12
|
|
last_accessed_timestamp: u32 align(1), // 16
|
|
create_10ms: u8, // 20
|
|
last_modified_10ms: u8, // 21
|
|
create_utc_offset: u8, // 22
|
|
last_modified_utc_offset: u8, // 23
|
|
last_accessed_utc_offset: u8, // 24
|
|
reserved2: [7]u8, // 25
|
|
};
|
|
|
|
/// 0xC0 — the Stream Extension: the second entry of every file set, carrying the
|
|
/// name length + hash and the data location (first cluster, sizes, the
|
|
/// no-FAT-chain flag).
|
|
pub const StreamExtensionEntry = extern struct {
|
|
entry_type: u8, // 0 0xC0
|
|
general_secondary_flags: u8, // 1
|
|
reserved1: u8, // 2
|
|
name_length: u8, // 3 UTF-16 units
|
|
name_hash: u16 align(1), // 4
|
|
reserved2: u16 align(1), // 6
|
|
valid_data_length: u64 align(1), // 8
|
|
reserved3: u32 align(1), // 16
|
|
first_cluster: u32 align(1), // 20
|
|
data_length: u64 align(1), // 24
|
|
};
|
|
|
|
/// 0xC1 — a File Name entry: 15 UTF-16 units of the name; a set carries
|
|
/// ceil(name_length / 15) of them.
|
|
pub const FileNameEntry = extern struct {
|
|
entry_type: u8, // 0 0xC1
|
|
general_secondary_flags: u8, // 1
|
|
file_name: [name_units_per_entry]u16 align(1), // 2
|
|
};
|
|
|
|
pub const name_units_per_entry: usize = 15;
|
|
|
|
// General secondary flags (Stream Extension + File Name entries).
|
|
pub const secondary_flag_allocation_possible: u8 = 0x01;
|
|
pub const secondary_flag_no_fat_chain: u8 = 0x02;
|
|
|
|
// File attributes (same bit assignments as FAT).
|
|
pub const attribute_read_only: u16 = 0x0001;
|
|
pub const attribute_hidden: u16 = 0x0002;
|
|
pub const attribute_system: u16 = 0x0004;
|
|
pub const attribute_directory: u16 = 0x0010;
|
|
pub const attribute_archive: u16 = 0x0020;
|
|
|
|
// FAT special cluster values (exFAT's FAT is 32-bit; used only for a fragmented
|
|
// chain, i.e. when no_fat_chain is clear).
|
|
pub const first_data_cluster: u32 = 2;
|
|
pub const end_of_chain: u32 = 0xFFFFFFFF;
|
|
pub const bad_cluster: u32 = 0xFFFFFFF7;
|
|
|
|
pub const boot_signature_offset: usize = 510; // 0x55 0xAA
|
|
|
|
// --- geometry ----------------------------------------------------------------
|
|
|
|
pub const Geometry = struct {
|
|
bytes_per_sector: u32,
|
|
sectors_per_cluster: u32,
|
|
cluster_count: u32,
|
|
fat_offset_sectors: u32, // from volume start
|
|
fat_length_sectors: u32,
|
|
cluster_heap_offset_sectors: u32, // from volume start
|
|
first_cluster_of_root: u32,
|
|
volume_serial_number: u32,
|
|
volume_length_sectors: u64,
|
|
number_of_fats: u32,
|
|
};
|
|
|
|
/// Derive the geometry from a Main Boot Sector. Returns null unless it is a
|
|
/// plausible exFAT VBR: the "EXFAT " name, an all-zero MustBeZero region, the
|
|
/// 0xAA55 signature, and sane shifts. Accepting ONLY these is what keeps exFAT and
|
|
/// FAT from ever claiming each other's volumes.
|
|
pub fn geometryOf(sector: []const u8) ?Geometry {
|
|
if (sector.len < 512) return null;
|
|
if (sector[boot_signature_offset] != 0x55 or sector[boot_signature_offset + 1] != 0xAA) return null;
|
|
const vbr = std.mem.bytesToValue(MainBootSector, sector[0..@sizeOf(MainBootSector)]);
|
|
if (!std.mem.eql(u8, &vbr.filesystem_name, "EXFAT ")) return null;
|
|
for (vbr.must_be_zero) |byte| if (byte != 0) return null;
|
|
if (vbr.bytes_per_sector_shift < 9 or vbr.bytes_per_sector_shift > 12) return null;
|
|
// The exFAT spec caps a cluster at 2^25 bytes (32 MiB): bytes-per-sector-shift
|
|
// plus sectors-per-cluster-shift must not exceed 25. Enforcing it here is also
|
|
// what keeps the engine's u32 cluster-byte arithmetic (sectors_per_cluster *
|
|
// 512) from overflowing on a crafted VBR off untrusted removable media.
|
|
if (@as(u16, vbr.bytes_per_sector_shift) + vbr.sectors_per_cluster_shift > 25) return null;
|
|
// cluster_count is capped at 0xFFFFFFF5 (the spec's ClusterCount maximum), so
|
|
// cluster_count + first_data_cluster cannot overflow u32 in the bounds checks.
|
|
if (vbr.number_of_fats == 0 or vbr.cluster_count == 0 or vbr.cluster_count > 0xFFFFFFF5) return null;
|
|
if (vbr.first_cluster_of_root < first_data_cluster) return null;
|
|
return .{
|
|
.bytes_per_sector = @as(u32, 1) << @intCast(vbr.bytes_per_sector_shift),
|
|
.sectors_per_cluster = @as(u32, 1) << @intCast(vbr.sectors_per_cluster_shift),
|
|
.cluster_count = vbr.cluster_count,
|
|
.fat_offset_sectors = vbr.fat_offset,
|
|
.fat_length_sectors = vbr.fat_length,
|
|
.cluster_heap_offset_sectors = vbr.cluster_heap_offset,
|
|
.first_cluster_of_root = vbr.first_cluster_of_root,
|
|
.volume_serial_number = vbr.volume_serial_number,
|
|
.volume_length_sectors = vbr.volume_length,
|
|
.number_of_fats = vbr.number_of_fats,
|
|
};
|
|
}
|
|
|
|
// --- checksums and the name hash ---------------------------------------------
|
|
|
|
/// The directory-entry-SET checksum (a File entry's `set_checksum`), a 16-bit
|
|
/// rotate-right sum over every byte of the set, skipping the two checksum bytes
|
|
/// themselves (offset 2..3 of the first entry). `entries` is the whole set:
|
|
/// (secondary_count + 1) * 32 bytes.
|
|
pub fn setChecksum(entries: []const u8) u16 {
|
|
var checksum: u16 = 0;
|
|
for (entries, 0..) |byte, i| {
|
|
if (i == 2 or i == 3) continue;
|
|
checksum = std.math.rotr(u16, checksum, 1) +% byte;
|
|
}
|
|
return checksum;
|
|
}
|
|
|
|
/// The up-case-table checksum (an Up-case entry's `table_checksum`), a 32-bit
|
|
/// rotate-right sum over the table's on-disk bytes.
|
|
pub fn upcaseChecksum(table_bytes: []const u8) u32 {
|
|
var checksum: u32 = 0;
|
|
for (table_bytes) |byte| checksum = std.math.rotr(u32, checksum, 1) +% byte;
|
|
return checksum;
|
|
}
|
|
|
|
/// The boot-region checksum — the u32 the checksum sector repeats — a 32-bit
|
|
/// rotate-right sum over the first eleven sectors, skipping VolumeFlags (offset
|
|
/// 106..107) and PercentInUse (offset 112) of the first sector.
|
|
pub fn bootChecksum(region: []const u8) u32 {
|
|
var checksum: u32 = 0;
|
|
for (region, 0..) |byte, i| {
|
|
if (i == 106 or i == 107 or i == 112) continue;
|
|
checksum = std.math.rotr(u32, checksum, 1) +% byte;
|
|
}
|
|
return checksum;
|
|
}
|
|
|
|
/// The name hash a Stream entry carries: a 16-bit rotate-right sum over the
|
|
/// UP-CASED name's bytes (low byte then high byte of each UTF-16 unit). The caller
|
|
/// up-cases through the volume's table first; a mismatch lets a lookup reject a
|
|
/// name without reading its File Name entries.
|
|
pub fn nameHash(upcased: []const u16) u16 {
|
|
var hash: u16 = 0;
|
|
for (upcased) |unit| {
|
|
hash = std.math.rotr(u16, hash, 1) +% @as(u8, @truncate(unit));
|
|
hash = std.math.rotr(u16, hash, 1) +% @as(u8, @truncate(unit >> 8));
|
|
}
|
|
return hash;
|
|
}
|
|
|
|
// --- timestamps --------------------------------------------------------------
|
|
//
|
|
// exFAT packs a timestamp into one u32: the high 16 bits are a DOS date
|
|
// (year-1980 | month | day), the low 16 a DOS time (hour | minute | second/2).
|
|
// Same field layout as FAT, so the epoch math matches; there is no timezone in
|
|
// the packed value (a separate UTC-offset byte carries that, which danos leaves
|
|
// zero = UTC).
|
|
|
|
fn isLeapYear(year: u32) bool {
|
|
return (year % 4 == 0 and year % 100 != 0) or (year % 400 == 0);
|
|
}
|
|
|
|
const days_in_month = [_]u8{ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
|
|
|
|
/// Convert a packed exFAT timestamp to Unix epoch seconds (UTC). 0 for unset.
|
|
pub fn timestampToEpoch(timestamp: u32) u64 {
|
|
if (timestamp == 0) return 0;
|
|
const date: u32 = timestamp >> 16;
|
|
const time: u32 = timestamp & 0xFFFF;
|
|
const day: u32 = date & 0x1F;
|
|
const month: u32 = (date >> 5) & 0x0F;
|
|
const year: u32 = 1980 + (date >> 9);
|
|
if (month < 1 or month > 12 or day < 1) return 0;
|
|
const second: u32 = (time & 0x1F) * 2;
|
|
const minute: u32 = (time >> 5) & 0x3F;
|
|
const hour: u32 = (time >> 11) & 0x1F;
|
|
|
|
var days: u64 = 0;
|
|
var y: u32 = 1970;
|
|
while (y < year) : (y += 1) days += if (isLeapYear(y)) 366 else 365;
|
|
var m: u32 = 1;
|
|
while (m < month) : (m += 1) {
|
|
days += days_in_month[m - 1];
|
|
if (m == 2 and isLeapYear(year)) days += 1;
|
|
}
|
|
days += day - 1;
|
|
return ((days * 24 + hour) * 60 + minute) * 60 + second;
|
|
}
|
|
|
|
/// Convert Unix epoch seconds (UTC) to a packed exFAT timestamp. 0 for epoch 0 or
|
|
/// any time before 1980 (unrepresentable).
|
|
pub fn epochToTimestamp(epoch: u64) u32 {
|
|
if (epoch == 0) return 0;
|
|
var remaining = epoch;
|
|
const second: u32 = @intCast(remaining % 60);
|
|
remaining /= 60;
|
|
const minute: u32 = @intCast(remaining % 60);
|
|
remaining /= 60;
|
|
const hour: u32 = @intCast(remaining % 24);
|
|
remaining /= 24;
|
|
var days: u32 = @intCast(remaining);
|
|
|
|
var year: u32 = 1970;
|
|
while (true) {
|
|
const year_days: u32 = if (isLeapYear(year)) 366 else 365;
|
|
if (days < year_days) break;
|
|
days -= year_days;
|
|
year += 1;
|
|
}
|
|
if (year < 1980) return 0;
|
|
var month: u32 = 1;
|
|
while (true) {
|
|
var month_days: u32 = days_in_month[month - 1];
|
|
if (month == 2 and isLeapYear(year)) month_days += 1;
|
|
if (days < month_days) break;
|
|
days -= month_days;
|
|
month += 1;
|
|
}
|
|
const day = days + 1;
|
|
const date: u32 = ((year - 1980) << 9) | (month << 5) | day;
|
|
const time: u32 = (hour << 11) | (minute << 5) | (second / 2);
|
|
return (date << 16) | time;
|
|
}
|
|
|
|
// --- tests -------------------------------------------------------------------
|
|
|
|
test "on-disk struct sizes match the specification" {
|
|
try std.testing.expectEqual(@as(usize, 512), @sizeOf(MainBootSector));
|
|
try std.testing.expectEqual(@as(usize, 32), @sizeOf(RawEntry));
|
|
try std.testing.expectEqual(@as(usize, 32), @sizeOf(AllocationBitmapEntry));
|
|
try std.testing.expectEqual(@as(usize, 32), @sizeOf(UpcaseTableEntry));
|
|
try std.testing.expectEqual(@as(usize, 32), @sizeOf(VolumeLabelEntry));
|
|
try std.testing.expectEqual(@as(usize, 32), @sizeOf(FileEntry));
|
|
try std.testing.expectEqual(@as(usize, 32), @sizeOf(StreamExtensionEntry));
|
|
try std.testing.expectEqual(@as(usize, 32), @sizeOf(FileNameEntry));
|
|
}
|
|
|
|
test "MainBootSector field offsets" {
|
|
try std.testing.expectEqual(@as(usize, 3), @offsetOf(MainBootSector, "filesystem_name"));
|
|
try std.testing.expectEqual(@as(usize, 11), @offsetOf(MainBootSector, "must_be_zero"));
|
|
try std.testing.expectEqual(@as(usize, 80), @offsetOf(MainBootSector, "fat_offset"));
|
|
try std.testing.expectEqual(@as(usize, 88), @offsetOf(MainBootSector, "cluster_heap_offset"));
|
|
try std.testing.expectEqual(@as(usize, 96), @offsetOf(MainBootSector, "first_cluster_of_root"));
|
|
try std.testing.expectEqual(@as(usize, 106), @offsetOf(MainBootSector, "volume_flags"));
|
|
try std.testing.expectEqual(@as(usize, 112), @offsetOf(MainBootSector, "percent_in_use"));
|
|
try std.testing.expectEqual(@as(usize, 510), @offsetOf(MainBootSector, "boot_signature"));
|
|
// The Stream Extension's data location must sit where the spec places it.
|
|
try std.testing.expectEqual(@as(usize, 20), @offsetOf(StreamExtensionEntry, "first_cluster"));
|
|
try std.testing.expectEqual(@as(usize, 24), @offsetOf(StreamExtensionEntry, "data_length"));
|
|
}
|
|
|
|
test "geometryOf accepts exFAT and the MustBeZero guard rejects a FAT-shaped sector" {
|
|
var sector = [_]u8{0} ** 512;
|
|
@memcpy(sector[3..11], "EXFAT ");
|
|
sector[510] = 0x55;
|
|
sector[511] = 0xAA;
|
|
// fat_offset=128, fat_length=64, cluster_heap_offset=256, cluster_count=1000,
|
|
// root cluster=5, bytes/sector=512 (shift 9), sectors/cluster=8 (shift 3), 1 FAT.
|
|
std.mem.writeInt(u32, sector[80..84], 128, .little);
|
|
std.mem.writeInt(u32, sector[84..88], 64, .little);
|
|
std.mem.writeInt(u32, sector[88..92], 256, .little);
|
|
std.mem.writeInt(u32, sector[92..96], 1000, .little);
|
|
std.mem.writeInt(u32, sector[96..100], 5, .little);
|
|
sector[108] = 9; // bytes_per_sector_shift
|
|
sector[109] = 3; // sectors_per_cluster_shift
|
|
sector[110] = 1; // number_of_fats
|
|
const geo = geometryOf(§or) orelse return error.ShouldParse;
|
|
try std.testing.expectEqual(@as(u32, 512), geo.bytes_per_sector);
|
|
try std.testing.expectEqual(@as(u32, 8), geo.sectors_per_cluster);
|
|
try std.testing.expectEqual(@as(u32, 1000), geo.cluster_count);
|
|
try std.testing.expectEqual(@as(u32, 5), geo.first_cluster_of_root);
|
|
|
|
// A non-zero byte in MustBeZero (where a FAT BPB keeps bytes-per-sector) is
|
|
// rejected — the mutual exclusion between the engines.
|
|
sector[11] = 0x02;
|
|
try std.testing.expect(geometryOf(§or) == null);
|
|
sector[11] = 0;
|
|
// Wrong name is rejected too.
|
|
sector[3] = 'F';
|
|
try std.testing.expect(geometryOf(§or) == null);
|
|
sector[3] = 'E';
|
|
}
|
|
|
|
test "geometryOf rejects crafted VBRs that would overflow u32 cluster arithmetic" {
|
|
var sector = [_]u8{0} ** 512;
|
|
@memcpy(sector[3..11], "EXFAT ");
|
|
sector[510] = 0x55;
|
|
sector[511] = 0xAA;
|
|
std.mem.writeInt(u32, sector[92..96], 1000, .little); // cluster_count
|
|
std.mem.writeInt(u32, sector[96..100], 5, .little); // root cluster
|
|
sector[108] = 9; // bytes_per_sector_shift
|
|
sector[110] = 1; // number_of_fats
|
|
// A cluster shift past the exFAT ceiling (9 + 17 = 26 > 25) would make
|
|
// sectors_per_cluster * 512 overflow u32 — rejected.
|
|
sector[109] = 17;
|
|
try std.testing.expect(geometryOf(§or) == null);
|
|
sector[109] = 3; // sane again
|
|
try std.testing.expect(geometryOf(§or) != null);
|
|
// cluster_count above the spec maximum (0xFFFFFFF5) would overflow
|
|
// cluster_count + first_data_cluster in the bounds checks — rejected.
|
|
std.mem.writeInt(u32, sector[92..96], 0xFFFFFFFF, .little);
|
|
try std.testing.expect(geometryOf(§or) == null);
|
|
}
|
|
|
|
test "set checksum skips its own two bytes and depends on the rest" {
|
|
var set = [_]u8{0} ** 64; // a File entry + one secondary
|
|
set[0] = entry_type_file;
|
|
set[1] = 1;
|
|
set[4] = 0x20; // an attribute byte
|
|
set[40] = 0xAB; // a byte in the secondary entry
|
|
const base = setChecksum(&set);
|
|
// Changing the checksum field itself must NOT change the computed checksum.
|
|
set[2] = 0xFF;
|
|
set[3] = 0xEE;
|
|
try std.testing.expectEqual(base, setChecksum(&set));
|
|
// Changing any other byte MUST change it.
|
|
set[4] = 0x21;
|
|
try std.testing.expect(setChecksum(&set) != base);
|
|
}
|
|
|
|
test "name hash is deterministic and order-sensitive" {
|
|
const readme = [_]u16{ 'R', 'E', 'A', 'D', 'M', 'E' };
|
|
const different = [_]u16{ 'E', 'R', 'A', 'D', 'M', 'E' };
|
|
try std.testing.expectEqual(nameHash(&readme), nameHash(&readme));
|
|
try std.testing.expect(nameHash(&readme) != nameHash(&different));
|
|
}
|
|
|
|
test "boot checksum skips VolumeFlags and PercentInUse" {
|
|
var region = [_]u8{0} ** 1536; // three 512-byte sectors is enough to exercise the skips
|
|
region[64] = 0x11;
|
|
const base = bootChecksum(®ion);
|
|
for ([_]usize{ 106, 107, 112 }) |skipped| {
|
|
var copy = region;
|
|
copy[skipped] = 0xFF;
|
|
try std.testing.expectEqual(base, bootChecksum(©));
|
|
}
|
|
var copy = region;
|
|
copy[108] = 0xFF; // a non-skipped byte
|
|
try std.testing.expect(bootChecksum(©) != base);
|
|
}
|
|
|
|
test "exFAT timestamp <-> Unix epoch round trip" {
|
|
for ([_]u64{ 1_577_836_800, 1_700_000_000, 1_262_304_000, 1_783_971_244 }) |epoch| {
|
|
try std.testing.expectEqual(epoch, timestampToEpoch(epochToTimestamp(epoch)));
|
|
}
|
|
// 1577836800 is 2020-01-01 00:00:00 UTC.
|
|
const stamp = epochToTimestamp(1_577_836_800);
|
|
try std.testing.expectEqual(@as(u32, 2020), 1980 + (stamp >> 16 >> 9));
|
|
try std.testing.expectEqual(@as(u64, 0), timestampToEpoch(0));
|
|
try std.testing.expectEqual(@as(u32, 0), epochToTimestamp(0));
|
|
}
|