exfat: adversarial-review fixes — overflow safety, sparse gaps, dir size, big-image bitmap (S4 step 9)
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.
This commit is contained in:
@@ -183,7 +183,7 @@ pub const FileSystem = struct {
|
||||
}
|
||||
|
||||
fn validCluster(self: *const FileSystem, cluster: u32) bool {
|
||||
return cluster >= on_disk.first_data_cluster and cluster < self.geometry.cluster_count + on_disk.first_data_cluster;
|
||||
return cluster >= on_disk.first_data_cluster and cluster < @as(u64, self.geometry.cluster_count) + on_disk.first_data_cluster;
|
||||
}
|
||||
|
||||
/// Read the 32-bit FAT entry for `cluster` (exFAT's FAT is only consulted for a
|
||||
@@ -393,6 +393,14 @@ pub const FileSystem = struct {
|
||||
const name_entry = std.mem.bytesToValue(on_disk.FileNameEntry, &fn_raw);
|
||||
for (name_entry.file_name) |unit| {
|
||||
if (name_len >= stream.name_length) break;
|
||||
// KNOWN LIMITATION: danos represents file names as ASCII bytes
|
||||
// through the whole VFS layer, so a non-ASCII UTF-16 unit becomes
|
||||
// '?'. This is not an exFAT shortcut — the FAT engine folds LFN
|
||||
// names the same way, and it is the same class of surface limit
|
||||
// as the u32 file-offset cap: fixing it means teaching the vfs
|
||||
// name representation UTF-8, a separate cross-cutting change.
|
||||
// Consequence on foreign media: distinct non-ASCII names collapse
|
||||
// to one skeleton and resolve by the true UTF-8 name misses.
|
||||
if (name_len < name_buf.len) name_buf[name_len] = if (unit < 0x80) @truncate(unit) else '?';
|
||||
name_len += 1;
|
||||
}
|
||||
@@ -527,7 +535,7 @@ pub const FileSystem = struct {
|
||||
/// Allocate one free cluster (mark its bit), or null if the volume is full.
|
||||
fn allocateCluster(self: *FileSystem) ?u32 {
|
||||
var cluster: u32 = on_disk.first_data_cluster;
|
||||
const end = self.geometry.cluster_count + on_disk.first_data_cluster;
|
||||
const end: u64 = @as(u64, self.geometry.cluster_count) + on_disk.first_data_cluster;
|
||||
while (cluster < end) : (cluster += 1) {
|
||||
if (!self.isAllocated(cluster)) {
|
||||
return if (self.setAllocated(cluster, true)) cluster else null;
|
||||
@@ -651,20 +659,33 @@ pub const FileSystem = struct {
|
||||
const have: u32 = @intCast((@as(u64, dir.size) + cluster_bytes - 1) / cluster_bytes);
|
||||
return clusters_needed <= have;
|
||||
}
|
||||
if (!self.validCluster(dir.first_cluster)) return false;
|
||||
// Count the existing chain to its last cluster.
|
||||
var cluster = dir.first_cluster;
|
||||
if (!self.validCluster(cluster)) return false;
|
||||
var have: u32 = 1;
|
||||
while (have < clusters_needed) : (have += 1) {
|
||||
while (true) {
|
||||
const next = self.readFatEntry(cluster);
|
||||
if (self.isEndOfChain(next) or !self.validCluster(next)) {
|
||||
const fresh = self.allocateCluster() orelse return false;
|
||||
if (!self.zeroCluster(fresh)) return false;
|
||||
if (!self.writeFatEntry(cluster, fresh)) return false;
|
||||
if (!self.writeFatEntry(fresh, on_disk.end_of_chain)) return false;
|
||||
cluster = fresh;
|
||||
} else {
|
||||
cluster = next;
|
||||
}
|
||||
if (self.isEndOfChain(next) or !self.validCluster(next)) break;
|
||||
cluster = next;
|
||||
have += 1;
|
||||
}
|
||||
if (have >= clusters_needed) return true;
|
||||
// Extend from the last cluster.
|
||||
while (have < clusters_needed) : (have += 1) {
|
||||
const fresh = self.allocateCluster() orelse return false;
|
||||
if (!self.zeroCluster(fresh)) return false;
|
||||
if (!self.writeFatEntry(cluster, fresh)) return false;
|
||||
if (!self.writeFatEntry(fresh, on_disk.end_of_chain)) return false;
|
||||
cluster = fresh;
|
||||
}
|
||||
// A grown non-root directory's recorded DataLength must track its chain, or
|
||||
// a spec-compliant reader that bounds a directory read by DataLength would
|
||||
// stop before the new entries. The root has no directory entry to update.
|
||||
if (dir.has_entry) {
|
||||
var grown = dir;
|
||||
grown.size = clampU32(@as(u64, have) * cluster_bytes);
|
||||
grown.valid_data_length = grown.size;
|
||||
self.updateStream(grown);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -778,9 +799,12 @@ pub const FileSystem = struct {
|
||||
if (data.len == 0) return 0;
|
||||
const write_len: u32 = @intCast(@min(data.len, @as(usize, std.math.maxInt(u32) - offset)));
|
||||
if (write_len == 0) return 0;
|
||||
const old_valid = node.valid_data_length;
|
||||
if (node.no_fat_chain and !self.ensureFatChain(node)) return 0;
|
||||
const cluster_bytes = self.clusterBytes();
|
||||
const clusters_needed = (offset + write_len + cluster_bytes - 1) / cluster_bytes;
|
||||
// u64 so offset+write_len near 2^32 (a >4 GiB file at the u32 boundary)
|
||||
// cannot overflow the round-up; the quotient fits u32.
|
||||
const clusters_needed: u32 = @intCast((@as(u64, offset) + write_len + cluster_bytes - 1) / cluster_bytes);
|
||||
if (!self.ensureFileClusters(node, clusters_needed)) return 0;
|
||||
|
||||
var produced: usize = 0;
|
||||
@@ -803,12 +827,45 @@ pub const FileSystem = struct {
|
||||
}
|
||||
const written_end = offset + @as(u32, @intCast(produced));
|
||||
if (written_end > node.size) node.size = written_end;
|
||||
// Every allocated cluster is zeroed, so all bytes up to size are valid.
|
||||
node.valid_data_length = node.size;
|
||||
// valid_data_length is the contiguous-from-zero written prefix. A write that
|
||||
// starts past the old boundary leaves a gap [old_valid, offset) that must
|
||||
// read as zero — our freshly-allocated clusters are zeroed, but a FOREIGN
|
||||
// file's existing clusters are not, so zero the gap on disk before claiming
|
||||
// it valid. (The common append/overwrite path has offset <= old_valid, no
|
||||
// gap.)
|
||||
if (offset > old_valid and self.validCluster(node.first_cluster)) {
|
||||
self.zeroFileRange(node.*, old_valid, offset - old_valid);
|
||||
}
|
||||
node.valid_data_length = @max(old_valid, written_end);
|
||||
self.updateStream(node.*);
|
||||
return produced;
|
||||
}
|
||||
|
||||
/// Zero `len` bytes of a file starting at `start`, over its existing clusters
|
||||
/// (the caller has ensured they are allocated). Used to fill a sparse gap.
|
||||
fn zeroFileRange(self: *FileSystem, node: Node, start: u32, len: u32) void {
|
||||
const cluster_bytes = self.clusterBytes();
|
||||
const zero = [_]u8{0} ** sector_size;
|
||||
var position = start;
|
||||
var remaining = len;
|
||||
while (remaining > 0) {
|
||||
const cluster = self.clusterOfChain(node.first_cluster, node.no_fat_chain, position / cluster_bytes) orelse break;
|
||||
const in_cluster = position % cluster_bytes;
|
||||
const lba = self.clusterSector(cluster, in_cluster / sector_size);
|
||||
const in_sector = in_cluster % sector_size;
|
||||
const n = @min(remaining, sector_size - in_sector);
|
||||
if (in_sector == 0 and n == sector_size) {
|
||||
if (!self.blockWrite(lba, &zero)) break;
|
||||
} else {
|
||||
if (!self.blockRead(lba, &self.sector)) break;
|
||||
@memset(self.sector[in_sector .. in_sector + n], 0);
|
||||
if (!self.blockWrite(lba, &self.sector)) break;
|
||||
}
|
||||
position += n;
|
||||
remaining -= n;
|
||||
}
|
||||
}
|
||||
|
||||
/// Empty a file: free its chain and zero its stream.
|
||||
pub fn truncate(self: *FileSystem, node: *Node) void {
|
||||
if (self.validCluster(node.first_cluster)) {
|
||||
@@ -960,6 +1017,7 @@ const test_notes_bytes = 1500; // a three-cluster write in the create/write test
|
||||
const test_tail_bytes = 4;
|
||||
const test_inner_bytes = 300;
|
||||
const test_rename_bytes = 800;
|
||||
const test_name_bytes = 8; // scratch for the synthetic "F0".."F7" file names
|
||||
|
||||
fn testCluster(cluster: u32) usize {
|
||||
return (test_heap_sector + (cluster - 2)) * sector_size;
|
||||
@@ -1294,3 +1352,50 @@ test "the exFAT engine rejects a FAT volume (mutual exclusion at mount)" {
|
||||
formatExfat(bytes);
|
||||
try std.testing.expect(FileSystem.mount(disk.device()) != null);
|
||||
}
|
||||
|
||||
test "a sparse write leaves the skipped gap reading as zero" {
|
||||
const allocator = std.testing.allocator;
|
||||
const bytes = try allocator.alloc(u8, 128 * sector_size);
|
||||
defer allocator.free(bytes);
|
||||
var disk = RamDisk{ .bytes = bytes };
|
||||
formatExfat(bytes);
|
||||
var fs = FileSystem.mount(disk.device()).?;
|
||||
fs.current_time_epoch = 1_700_000_000;
|
||||
|
||||
var node = fs.createFile(fs.rootNode(), "SPARSE").?;
|
||||
var tail = [_]u8{0xEE} ** 4;
|
||||
_ = fs.writeFile(&node, 1000, &tail); // a gap [0, 1000) is never written
|
||||
const resolved = fs.resolve("/SPARSE").?;
|
||||
try std.testing.expectEqual(@as(u32, 1004), resolved.size);
|
||||
var readback: [test_read_bytes]u8 = undefined; // 1024 >= 1004
|
||||
try std.testing.expectEqual(@as(usize, 1004), fs.readFile(resolved, 0, readback[0..1004]));
|
||||
for (readback[0..1000]) |b| try std.testing.expectEqual(@as(u8, 0), b); // the gap reads zero
|
||||
try std.testing.expectEqualSlices(u8, &tail, readback[1000..1004]);
|
||||
}
|
||||
|
||||
test "a subdirectory that outgrows one cluster records its new size" {
|
||||
const allocator = std.testing.allocator;
|
||||
const bytes = try allocator.alloc(u8, 128 * sector_size);
|
||||
defer allocator.free(bytes);
|
||||
var disk = RamDisk{ .bytes = bytes };
|
||||
formatExfat(bytes);
|
||||
var fs = FileSystem.mount(disk.device()).?;
|
||||
fs.current_time_epoch = 1_700_000_000;
|
||||
|
||||
const dir = fs.createDirectory(fs.rootNode(), "BIG").?;
|
||||
try std.testing.expectEqual(@as(u32, sector_size), dir.size); // one 512-byte cluster
|
||||
// Each file is a 3-entry set; a 512-byte cluster holds 16 entries, so 8 files
|
||||
// (24 entries) force the directory onto a second cluster.
|
||||
var i: u8 = 0;
|
||||
var namebuf: [test_name_bytes]u8 = undefined;
|
||||
while (i < 8) : (i += 1) {
|
||||
const name = std.fmt.bufPrint(&namebuf, "F{d}", .{i}) catch unreachable;
|
||||
_ = fs.createFile(fs.resolve("/BIG").?, name).?;
|
||||
}
|
||||
const grown = fs.resolve("/BIG").?;
|
||||
try std.testing.expect(grown.size > sector_size); // its recorded DataLength grew with the chain
|
||||
// Every file is still reachable across the two clusters.
|
||||
var count: u32 = 0;
|
||||
while (fs.listEntry(fs.resolve("/BIG").?, count) != null) count += 1;
|
||||
try std.testing.expectEqual(@as(u32, 8), count);
|
||||
}
|
||||
|
||||
@@ -201,8 +201,14 @@ pub fn geometryOf(sector: []const u8) ?Geometry {
|
||||
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;
|
||||
if (vbr.sectors_per_cluster_shift > 25) return null;
|
||||
if (vbr.number_of_fats == 0 or vbr.cluster_count == 0) 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),
|
||||
@@ -396,6 +402,28 @@ test "geometryOf accepts exFAT and the MustBeZero guard rejects a FAT-shaped sec
|
||||
// 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" {
|
||||
|
||||
+33
-15
@@ -97,7 +97,16 @@ class ExfatImage:
|
||||
if self.cluster_count < 16:
|
||||
sys.exit(f"error: image too small for exFAT ({self.cluster_count} clusters)")
|
||||
self.cluster_bytes = self.spc * SECTOR
|
||||
self.root_cluster = 4 # 2=bitmap, 3=up-case, 4=root
|
||||
# Layout: the allocation bitmap (as many clusters as it needs — one per
|
||||
# 8*cluster_bytes clusters of the volume), then the up-case table, the root
|
||||
# directory, and the seeded file. A single-cluster bitmap (small volumes,
|
||||
# e.g. the 48 MiB fixture) puts root at cluster 4, as before.
|
||||
self.bitmap_bytes = (self.cluster_count + 7) // 8
|
||||
self.bitmap_clusters = (self.bitmap_bytes + self.cluster_bytes - 1) // self.cluster_bytes
|
||||
self.bitmap_cluster = 2
|
||||
self.upcase_cluster = self.bitmap_cluster + self.bitmap_clusters
|
||||
self.root_cluster = self.upcase_cluster + 1
|
||||
self.hello_cluster = self.root_cluster + 1
|
||||
self.image = bytearray(self.total_sectors * SECTOR)
|
||||
|
||||
def cluster_offset(self, cluster):
|
||||
@@ -148,37 +157,46 @@ class ExfatImage:
|
||||
# Backup boot region (sectors 12..23) is a copy of 0..11.
|
||||
self.image[12 * SECTOR : 24 * SECTOR] = self.image[0 : 12 * SECTOR]
|
||||
|
||||
# FAT: reserved entries + the metadata/file chains (each a single cluster).
|
||||
# FAT: reserved entries, then a single-cluster chain per metadata object,
|
||||
# except the bitmap which spans self.bitmap_clusters (a real FAT chain).
|
||||
self.set_fat(0, 0xFFFFFFF8)
|
||||
self.set_fat(1, 0xFFFFFFFF)
|
||||
for cluster in (2, 3, 4, 5):
|
||||
used = []
|
||||
for i in range(self.bitmap_clusters):
|
||||
cluster = self.bitmap_cluster + i
|
||||
self.set_fat(cluster, 0xFFFFFFFF if i == self.bitmap_clusters - 1 else cluster + 1)
|
||||
used.append(cluster)
|
||||
for cluster in (self.upcase_cluster, self.root_cluster, self.hello_cluster):
|
||||
self.set_fat(cluster, 0xFFFFFFFF)
|
||||
used.append(cluster)
|
||||
|
||||
# Allocation bitmap (cluster 2): clusters 2,3,4,5 in use.
|
||||
for cluster in (2, 3, 4, 5):
|
||||
# Allocation bitmap: every metadata/file cluster in use.
|
||||
for cluster in used:
|
||||
self.mark_allocated(cluster)
|
||||
|
||||
# Up-case table (cluster 3): 256 explicit units, a-z -> A-Z.
|
||||
# Up-case table: 256 explicit units, a-z -> A-Z.
|
||||
upcase = bytearray(UPCASE_UNITS * 2)
|
||||
for i in range(UPCASE_UNITS):
|
||||
struct.pack_into("<H", upcase, i * 2, ascii_upper(i))
|
||||
self.image[self.cluster_offset(3) : self.cluster_offset(3) + len(upcase)] = upcase
|
||||
off = self.cluster_offset(self.upcase_cluster)
|
||||
self.image[off : off + len(upcase)] = upcase
|
||||
table_checksum = upcase_checksum(upcase)
|
||||
|
||||
# Seed file HELLO.TXT (cluster 5, contiguous).
|
||||
# Seed file HELLO.TXT (contiguous, one cluster).
|
||||
content = b"exfat hello danos\n"
|
||||
self.image[self.cluster_offset(5) : self.cluster_offset(5) + len(content)] = content
|
||||
off = self.cluster_offset(self.hello_cluster)
|
||||
self.image[off : off + len(content)] = content
|
||||
|
||||
# Root directory (cluster 4): bitmap, up-case, volume label, HELLO set.
|
||||
root = self.cluster_offset(4)
|
||||
# Root directory: bitmap, up-case, volume label, HELLO set.
|
||||
root = self.cluster_offset(self.root_cluster)
|
||||
# 0x81 Allocation Bitmap
|
||||
struct.pack_into("<BBB", self.image, root, 0x81, 0, 0)
|
||||
struct.pack_into("<I", self.image, root + 20, 2)
|
||||
struct.pack_into("<Q", self.image, root + 24, (self.cluster_count + 7) // 8)
|
||||
struct.pack_into("<I", self.image, root + 20, self.bitmap_cluster)
|
||||
struct.pack_into("<Q", self.image, root + 24, self.bitmap_bytes)
|
||||
# 0x82 Up-case Table
|
||||
struct.pack_into("<B", self.image, root + 32, 0x82)
|
||||
struct.pack_into("<I", self.image, root + 32 + 4, table_checksum)
|
||||
struct.pack_into("<I", self.image, root + 32 + 20, 3)
|
||||
struct.pack_into("<I", self.image, root + 32 + 20, self.upcase_cluster)
|
||||
struct.pack_into("<Q", self.image, root + 32 + 24, UPCASE_UNITS * 2)
|
||||
# 0x83 Volume Label
|
||||
label_units = [ord(c) for c in self.label[:11]]
|
||||
@@ -187,7 +205,7 @@ class ExfatImage:
|
||||
struct.pack_into("<H", self.image, root + 64 + 2 + i * 2, u)
|
||||
# HELLO.TXT set: File (0x85) + Stream (0xC0) + Name (0xC1)
|
||||
name = "HELLO.TXT"
|
||||
self.write_file_set(root + 96, name, first_cluster=5, length=len(content))
|
||||
self.write_file_set(root + 96, name, first_cluster=self.hello_cluster, length=len(content))
|
||||
|
||||
def write_file_set(self, offset, name, first_cluster, length):
|
||||
entries = bytearray(32 * 3)
|
||||
|
||||
Reference in New Issue
Block a user