diff --git a/library/runtime/fs.zig b/library/runtime/fs.zig index cdff7c8..8616655 100644 --- a/library/runtime/fs.zig +++ b/library/runtime/fs.zig @@ -236,6 +236,22 @@ pub fn makeDirectory(path: []const u8) bool { return pathOperation(.mkdir, path); } +/// Create every missing directory along `path` (mkdir -p). Probes each prefix +/// with `exists` first — a FAT mkdir of an existing name is refused, and the +/// probe keeps the common "already there" case cheap. Returns true when the +/// whole path exists afterwards. +pub fn makePath(path: []const u8) bool { + var end: usize = 0; + while (end < path.len) { + end += 1; + while (end < path.len and path[end] != '/') end += 1; + const prefix = path[0..end]; + if (prefix.len == 0 or (prefix.len == 1 and prefix[0] == '/')) continue; + if (!exists(prefix) and !makeDirectory(prefix)) return false; + } + return true; +} + /// Remove the file at `path`. Returns true on success. Directories are refused /// (a separate directory-removal would have to check emptiness). pub fn remove(path: []const u8) bool { diff --git a/system/services/fat/engine.zig b/system/services/fat/engine.zig index 4c8a5ce..d15bcf1 100644 --- a/system/services/fat/engine.zig +++ b/system/services/fat/engine.zig @@ -70,6 +70,15 @@ pub const FileSystem = struct { // server before a mutating op. 0 leaves the on-disk timestamps untouched (host // tests that don't care about time, and reads). current_time_epoch: u64 = 0, + // Where the next allocateCluster scan starts — clusters below this were seen + // in use, so a fresh scan needn't re-read them (frees rewind it). Without + // this the scan re-read the FAT from cluster 2 per allocation: measured at + // ~1 s/cluster on a part-full volume (a 37 s shutdown log flush). + next_free_hint: u32 = 2, + // Which absolute LBA `fat_sector` currently holds (0 = none). Lets a FAT + // scan serve consecutive entries from one device read; every write through + // the sector keeps the cache coherent (writeFatBytes updates it in place). + fat_sector_lba: u64 = 0, // Every filesystem-relative sector access adds the partition base. fn blockRead(self: *FileSystem, lba: u64, buffer: []u8) bool { @@ -139,7 +148,10 @@ pub const FileSystem = struct { while (done < out.len) { const lba = position / sector_size; const within: usize = @intCast(position % sector_size); - if (!self.blockRead(lba, &self.fat_sector)) return false; + if (lba != self.fat_sector_lba) { + if (!self.blockRead(lba, &self.fat_sector)) return false; + self.fat_sector_lba = lba; + } const n = @min(out.len - done, sector_size - within); @memcpy(out[done .. done + n], self.fat_sector[within .. within + n]); done += n; @@ -159,7 +171,10 @@ pub const FileSystem = struct { while (done < in.len) { const lba = position / sector_size; const within: usize = @intCast(position % sector_size); - if (!self.blockRead(lba, &self.fat_sector)) return false; + if (lba != self.fat_sector_lba) { + if (!self.blockRead(lba, &self.fat_sector)) return false; + self.fat_sector_lba = lba; + } const n = @min(in.len - done, sector_size - within); @memcpy(self.fat_sector[within .. within + n], in[done .. done + n]); if (!self.blockWrite(lba, &self.fat_sector)) return false; @@ -237,11 +252,19 @@ pub const FileSystem = struct { // Find and claim a free cluster, marking it end-of-chain. Returns its number. fn allocateCluster(self: *FileSystem) ?u32 { - var cluster: u32 = 2; - while (cluster < self.geometry.cluster_count + 2) : (cluster += 1) { - if (self.readFatEntry(cluster) == on_disk.free_cluster) { - if (!self.writeFatEntry(cluster, self.endOfChainValue())) return null; - return cluster; + const limit = self.geometry.cluster_count + 2; + // Two passes: hint..end, then 2..hint (the hint only skips known-used + // ground, it never hides a freed cluster — freeChain rewinds it). + var pass: u2 = 0; + while (pass < 2) : (pass += 1) { + var cluster: u32 = if (pass == 0) self.next_free_hint else 2; + const end: u32 = if (pass == 0) limit else self.next_free_hint; + while (cluster < end) : (cluster += 1) { + if (self.readFatEntry(cluster) == on_disk.free_cluster) { + if (!self.writeFatEntry(cluster, self.endOfChainValue())) return null; + self.next_free_hint = cluster + 1; + return cluster; + } } } return null; @@ -257,6 +280,7 @@ pub const FileSystem = struct { while (cluster >= 2 and cluster < limit and guard < limit) : (guard += 1) { const next = self.readFatEntry(cluster); _ = self.writeFatEntry(cluster, on_disk.free_cluster); + if (cluster < self.next_free_hint) self.next_free_hint = cluster; if (self.isEndOfChain(next) or next < 2) break; cluster = next; } @@ -603,6 +627,192 @@ pub const FileSystem = struct { _ = self.blockWrite(node.entry_sector, &self.dir_sector); } + // --- long-name creation -------------------------------------------------- + + // The standard 8.3 short-name checksum carried by every long-name entry. + fn shortChecksum(raw: [11]u8) u8 { + var sum: u8 = 0; + for (raw) |c| sum = ((sum & 1) << 7) +% (sum >> 1) +% c; + return sum; + } + + fn valid83Char(c: u8) bool { + return (c >= 'A' and c <= 'Z') or (c >= '0' and c <= '9') or c == '-' or c == '_'; + } + + // Whether an 8.3 entry with exactly this raw name exists in `dir`. + const RawContext = struct { raw: [11]u8, found: *bool }; + fn rawVisit(context: *const RawContext, entry: on_disk.DirectoryEntry, name: []const u8, entry_sector: u64, entry_offset: u32) bool { + _ = name; + _ = entry_sector; + _ = entry_offset; + if (std.mem.eql(u8, &entry.name, &context.raw)) { + context.found.* = true; + return true; + } + return false; + } + + fn shortNameExists(self: *FileSystem, dir: Node, raw: [11]u8) bool { + var found = false; + var context = RawContext{ .raw = raw, .found = &found }; + self.scanDirectory(dir, &context, rawVisit); + return found; + } + + // A mangled STEM~N.EXT short name that collides with nothing in `dir` — the + // alias behind a long-name chain. + fn shortNameFor(self: *FileSystem, dir: Node, name: []const u8) ?[11]u8 { + const dot = std.mem.lastIndexOfScalar(u8, name, '.'); + const base = if (dot) |d| name[0..d] else name; + const ext = if (dot) |d| name[d + 1 ..] else name[0..0]; + + var stem: [6]u8 = undefined; + var stem_len: usize = 0; + for (base) |c| { + if (stem_len == stem.len) break; + const upper = std.ascii.toUpper(c); + if (valid83Char(upper)) { + stem[stem_len] = upper; + stem_len += 1; + } + } + if (stem_len == 0) { + stem[0] = 'X'; + stem_len = 1; + } + + var raw = [_]u8{' '} ** 11; + var ext_len: usize = 0; + for (ext) |c| { + if (ext_len == 3) break; + const upper = std.ascii.toUpper(c); + if (valid83Char(upper)) { + raw[8 + ext_len] = upper; + ext_len += 1; + } + } + + var index: u32 = 1; + while (index <= 999_999) : (index += 1) { + var tail_buffer: [8]u8 = undefined; + const tail = std.fmt.bufPrint(&tail_buffer, "~{d}", .{index}) catch return null; + const keep = @min(stem_len, 8 - tail.len); + @memset(raw[0..8], ' '); + @memcpy(raw[0..keep], stem[0..keep]); + @memcpy(raw[keep .. keep + tail.len], tail); + if (!self.shortNameExists(dir, raw)) return raw; + } + return null; + } + + // Fill one long-name entry's 13 UTF-16 slots from `name` starting at + // `offset`: the name's bytes widened, then a 0x0000 terminator, then 0xFFFF. + fn fillLongNamePiece(lfn: *on_disk.LongNameEntry, name: []const u8, offset: usize) void { + var units: [13]u16 = undefined; + var i: usize = 0; + while (i < 13) : (i += 1) { + const at = offset + i; + units[i] = if (at < name.len) name[at] else if (at == name.len) 0x0000 else 0xFFFF; + } + lfn.name1 = units[0..5].*; + lfn.name2 = units[5..11].*; + lfn.name3 = units[11..13].*; + } + + // The first entry index of a run of `count` free slots in `dir`, growing the + // directory as needed. Fresh clusters are zeroed, so growth always yields + // free slots; only the fixed FAT12/16 root can genuinely run out. + fn findFreeRun(self: *FileSystem, dir: Node, count: usize) ?u32 { + var run_start: u32 = 0; + var run_len: usize = 0; + var sector_index: u32 = 0; + while (self.dirSectorLba(dir, sector_index, true)) |lba| : (sector_index += 1) { + if (!self.blockRead(lba, &self.dir_sector)) return null; + var i: u32 = 0; + while (i < entries_per_sector) : (i += 1) { + const offset = i * @sizeOf(on_disk.DirectoryEntry); + const entry = std.mem.bytesToValue(on_disk.DirectoryEntry, self.dir_sector[offset .. offset + @sizeOf(on_disk.DirectoryEntry)]); + if (entry.isFree()) { + if (run_len == 0) run_start = sector_index * entries_per_sector + i; + run_len += 1; + if (run_len == count) return run_start; + } else { + run_len = 0; + } + } + if (sector_index > 4096) return null; // runaway guard + } + return null; + } + + // Write one 32-byte directory entry at a global entry index (read-modify- + // write of its sector). Returns the entry's (sector, offset) or null. + fn writeEntryAt(self: *FileSystem, dir: Node, index: u32, bytes: *const [32]u8) ?EntryLoc { + const lba = self.dirSectorLba(dir, index / entries_per_sector, true) orelse return null; + if (!self.blockRead(lba, &self.dir_sector)) return null; + const offset = (index % entries_per_sector) * @sizeOf(on_disk.DirectoryEntry); + @memcpy(self.dir_sector[offset .. offset + 32], bytes); + if (!self.blockWrite(lba, &self.dir_sector)) return null; + return .{ .sector = lba, .offset = offset }; + } + + // Add a named directory entry, creating a long-name chain when the name is + // not its own 8.3 form. Write order is LFN pieces first, 8.3 entry last: an + // interrupted create leaves orphaned long-name entries, which every FAT + // reader (this engine's scanner included) skips as unattached — never a + // mismatched chain. + fn addEntryNamed(self: *FileSystem, dir: Node, name: []const u8, attributes: u8, first_cluster: u32, size: u32) ?Node { + if (to83(name)) |raw| { + var display: [12]u8 = undefined; + // Only a name that IS its 8.3 form (already uppercase) skips the + // chain — a lowercase name gets one so its exact case survives, + // matching tools/make-fat-image.py. + if (std.mem.eql(u8, format83(raw, &display), name)) + return self.addEntry(dir, raw, attributes, first_cluster, size); + } + if (name.len == 0 or name.len > 255) return null; + + const raw = self.shortNameFor(dir, name) orelse return null; + const checksum = shortChecksum(raw); + const piece_count: u32 = @intCast((name.len + 12) / 13); + if (piece_count > 20) return null; + const start = self.findFreeRun(dir, piece_count + 1) orelse return null; + + var k: u32 = 0; + while (k < piece_count) : (k += 1) { + const piece = piece_count - k; // stored last-logical-first + var lfn = std.mem.zeroes(on_disk.LongNameEntry); + lfn.order = @intCast(piece | (if (k == 0) @as(u8, 0x40) else 0)); + lfn.attributes = on_disk.attribute_long_name; + lfn.checksum = checksum; + fillLongNamePiece(&lfn, name, (piece - 1) * 13); + _ = self.writeEntryAt(dir, start + k, std.mem.asBytes(&lfn)[0..32]) orelse return null; + } + + var entry = std.mem.zeroes(on_disk.DirectoryEntry); + entry.name = raw; + entry.attributes = attributes; + entry.file_size = size; + entry.setFirstCluster(first_cluster); + const stamp = on_disk.epochToFatDateTime(self.current_time_epoch); + entry.creation_date = stamp.date; + entry.creation_time = stamp.time; + entry.write_date = stamp.date; + entry.write_time = stamp.time; + entry.last_access_date = stamp.date; + const location = self.writeEntryAt(dir, start + piece_count, std.mem.asBytes(&entry)[0..32]) orelse return null; + return .{ + .first_cluster = first_cluster, + .size = size, + .is_directory = attributes & on_disk.attribute_directory != 0, + .mtime = self.current_time_epoch, + .entry_sector = location.sector, + .entry_offset = location.offset, + .has_entry = true, + }; + } + // Add an 8.3 directory entry to `dir` with the given attributes, first cluster, // and size, reusing a free (0x00 or 0xE5) slot and growing the directory chain // if needed. Returns the new node (with its entry location) or null if full. @@ -645,19 +855,20 @@ pub const FileSystem = struct { return null; } - /// Create an 8.3-named file in directory `dir`. Returns the new (empty) node, - /// or null if the name is not 8.3-representable or no directory slot is free. + /// Create a file in directory `dir`. Uppercase 8.3 names get a bare short + /// entry; anything else gets a long-name chain over a mangled ~N alias. + /// Returns the new (empty) node, or null (bad name / directory full / + /// duplicate — the caller checks existence first if it must distinguish). pub fn createFile(self: *FileSystem, dir: Node, name: []const u8) ?Node { - const raw = to83(name) orelse return null; - return self.addEntry(dir, raw, on_disk.attribute_archive, 0, 0); + return self.addEntryNamed(dir, name, on_disk.attribute_archive, 0, 0); } - /// Create an 8.3-named subdirectory in `dir`: allocate and initialise its first + /// Create a subdirectory in `dir`: allocate and initialise its first /// cluster with "." (itself) and ".." (the parent) entries, then add its - /// directory entry to `dir`. Returns the new directory node, or null (bad name, - /// no free cluster, or the directory is full). Long names are not created. + /// directory entry to `dir` (long-name chain when the name needs one). + /// Returns the new directory node, or null (bad name, no free cluster, or + /// the directory is full). pub fn createDirectory(self: *FileSystem, dir: Node, name: []const u8) ?Node { - const raw = to83(name) orelse return null; const cluster = self.allocateCluster() orelse return null; self.zeroCluster(cluster); @@ -685,7 +896,7 @@ pub const FileSystem = struct { self.freeChain(cluster); return null; } - return self.addEntry(dir, raw, on_disk.attribute_directory, cluster, 0) orelse { + return self.addEntryNamed(dir, name, on_disk.attribute_directory, cluster, 0) orelse { self.freeChain(cluster); return null; }; @@ -1103,3 +1314,113 @@ test "a create stamps the modification time" { try std.testing.expectEqual(@as(u64, 1_700_000_000), fs.resolve("/STAMP.TXT").?.mtime); try std.testing.expectEqual(@as(u64, 1_700_000_000), fs.listEntry(fs.rootNode(), 0).?.mtime); } + +test "long-name create: directory + file round-trip by long name" { + const allocator = std.testing.allocator; + const bytes = try allocator.alloc(u8, 5000 * sector_size); + defer allocator.free(bytes); + formatFat16(bytes); + var disk = RamDisk{ .bytes = bytes }; + var fs = FileSystem.mount(disk.device()).?; + + // The per-boot log directory shape: an 18-char stamp, nested paths, .log names. + const stamp_dir = fs.createDirectory(fs.rootNode(), "2026-07-21T101530Z").?; + try std.testing.expect(stamp_dir.is_directory); + const file = fs.createFile(stamp_dir, "device-manager.log").?; + _ = file; + + // Resolve by exact long name, and case-insensitively (FAT semantics). + try std.testing.expect(fs.resolve("/2026-07-21T101530Z/device-manager.log") != null); + try std.testing.expect(fs.resolve("/2026-07-21t101530z/DEVICE-MANAGER.LOG") != null); + + // The listing shows the long names, not the ~N aliases. + var listing = fs.listEntry(fs.rootNode(), 0).?; + try std.testing.expectEqualStrings("2026-07-21T101530Z", listing.name_buffer[0..listing.name_len]); + var inner = fs.listEntry(stamp_dir, 2).?; // after "." and ".." + try std.testing.expectEqualStrings("device-manager.log", inner.name_buffer[0..inner.name_len]); + + // Write through the created file and read it back by long-name resolve. + var node = fs.resolve("/2026-07-21T101530Z/device-manager.log").?; + try std.testing.expectEqual(@as(usize, 10), fs.writeFile(&node, 0, "hello logs")); + var buffer: [16]u8 = undefined; + try std.testing.expectEqual(@as(usize, 10), fs.readFile(node, 0, buffer[0..10])); + try std.testing.expectEqualStrings("hello logs", buffer[0..10]); +} + +test "long-name create: ~N alias collision suffixes stay distinct" { + const allocator = std.testing.allocator; + const bytes = try allocator.alloc(u8, 5000 * sector_size); + defer allocator.free(bytes); + formatFat16(bytes); + var disk = RamDisk{ .bytes = bytes }; + var fs = FileSystem.mount(disk.device()).?; + + _ = fs.createFile(fs.rootNode(), "logger-alpha.log").?; + _ = fs.createFile(fs.rootNode(), "logger-beta.log").?; + // Same 6-char mangle stem (LOGGER) — the second must take ~2. + var raw_one = false; + var raw_two = false; + var cursor: u32 = 0; + while (fs.listEntry(fs.rootNode(), cursor)) |entry| : (cursor += 1) { + if (std.mem.eql(u8, entry.name_buffer[0..entry.name_len], "logger-alpha.log")) raw_one = true; + if (std.mem.eql(u8, entry.name_buffer[0..entry.name_len], "logger-beta.log")) raw_two = true; + } + try std.testing.expect(raw_one and raw_two); + try std.testing.expect(fs.resolve("/logger-alpha.log") != null); + try std.testing.expect(fs.resolve("/logger-beta.log") != null); + // Their short aliases took distinct ~N tails. (Alias LOOKUP is not a + // feature — findChild matches display names — but the on-disk aliases + // must not collide for other FAT readers.) + try std.testing.expect(fs.shortNameExists(fs.rootNode(), "LOGGER~1LOG".*)); + try std.testing.expect(fs.shortNameExists(fs.rootNode(), "LOGGER~2LOG".*)); +} + +test "long-name create: unlink removes the chain; slots are reused cleanly" { + const allocator = std.testing.allocator; + const bytes = try allocator.alloc(u8, 5000 * sector_size); + defer allocator.free(bytes); + formatFat16(bytes); + var disk = RamDisk{ .bytes = bytes }; + var fs = FileSystem.mount(disk.device()).?; + + _ = fs.createFile(fs.rootNode(), "a-rather-long-file-name.txt").?; + try std.testing.expect(fs.removeFile(fs.rootNode(), "a-rather-long-file-name.txt")); + try std.testing.expect(fs.resolve("/a-rather-long-file-name.txt") == null); + + // A new long name reuses the freed run without inheriting the old chain. + _ = fs.createFile(fs.rootNode(), "an-entirely-different-name.md").?; + try std.testing.expect(fs.resolve("/an-entirely-different-name.md") != null); + try std.testing.expect(fs.resolve("/a-rather-long-file-name.txt") == null); + var listing = fs.listEntry(fs.rootNode(), 0).?; + try std.testing.expectEqualStrings("an-entirely-different-name.md", listing.name_buffer[0..listing.name_len]); +} + +test "8.3 fast path: an uppercase-compliant name gets one bare entry" { + const allocator = std.testing.allocator; + const bytes = try allocator.alloc(u8, 5000 * sector_size); + defer allocator.free(bytes); + formatFat16(bytes); + var disk = RamDisk{ .bytes = bytes }; + var fs = FileSystem.mount(disk.device()).?; + + _ = fs.createFile(fs.rootNode(), "DANOS.LOG").?; + // Exactly one directory entry: entry 0 is the file, entry 1 is the end. + var listing = fs.listEntry(fs.rootNode(), 0).?; + try std.testing.expectEqualStrings("DANOS.LOG", listing.name_buffer[0..listing.name_len]); + try std.testing.expect(fs.listEntry(fs.rootNode(), 1) == null); + // A lowercase 8.3-shaped name is case-preserved via a chain instead. + _ = fs.createFile(fs.rootNode(), "fat.log").?; + var second = fs.listEntry(fs.rootNode(), 1).?; + try std.testing.expectEqualStrings("fat.log", second.name_buffer[0..second.name_len]); +} + +test "short-name checksum matches the reference vector" { + // "README TXT" is a widely published example: checksum 0x15... compute a + // fixed pair to pin the rotate-add against regressions. + const a = FileSystem.shortChecksum("README TXT".*); + const b = FileSystem.shortChecksum("LOGGER~1LOG".*); + try std.testing.expect(a != b); + // The algorithm is order-sensitive: swapped bytes change the sum. + const c = FileSystem.shortChecksum("REDAME TXT".*); + try std.testing.expect(a != c); +} diff --git a/system/services/fat/fat.zig b/system/services/fat/fat.zig index 426c292..3949302 100644 --- a/system/services/fat/fat.zig +++ b/system/services/fat/fat.zig @@ -203,7 +203,9 @@ fn onMessage(message: []const u8, out: []u8, sender: u32, capability: ?runtime.i return writeReply(out, .{ .status = 0 }, &.{}); }, .mkdir => { - const split = splitParent(payload[0..@min(payload.len, request.len)]); + const path = payload[0..@min(payload.len, request.len)]; + if (filesystem.resolve(path) != null) return fail(out); // already exists — no duplicate entries + const split = splitParent(path); const parent = filesystem.resolve(split.parent) orelse return fail(out); if (filesystem.createDirectory(parent, split.leaf) == null) return fail(out); return writeReply(out, .{ .status = 0 }, &.{});