diff --git a/system/services/exfat/engine.zig b/system/services/exfat/engine.zig index 48043ed..51c0e98 100644 --- a/system/services/exfat/engine.zig +++ b/system/services/exfat/engine.zig @@ -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); +} diff --git a/system/services/exfat/on-disk.zig b/system/services/exfat/on-disk.zig index cef941f..585ab67 100644 --- a/system/services/exfat/on-disk.zig +++ b/system/services/exfat/on-disk.zig @@ -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" { diff --git a/tools/make-exfat-image.py b/tools/make-exfat-image.py index 9eaa3a3..d887496 100644 --- a/tools/make-exfat-image.py +++ b/tools/make-exfat-image.py @@ -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("