From 67702fa25084b2dbb92db0c86a5f515e00321b8f Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:43:40 +0100 Subject: [PATCH] Phase 2d (ii): filesystem modification time (mtime) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes Phase 2: the FAT filesystem now stamps and reports a real modification time, built on the Phase 2d(i) kernel wall-clock. This is the last stat field the compiler's build cache needs to reason about (source vs cached output). - on-disk.zig: fatToEpoch / epochToFatDateTime convert between the two 16-bit DOS date/time fields and Unix epoch seconds (UTC — FAT has no timezone). Host-tested round-trip + an absolute check (1577836800 == 2020-01-01). - engine: a settable current_time_epoch that create/write stamp into the entry's write (and creation) date/time; Node/Listing gained an mtime decoded from those fields on read. Host test: a create stamps the mtime, read back through resolve and listEntry. - vfs protocol FileStatus + runtime.fs.Attributes gained an mtime field; the fat server sets current_time_epoch from runtime.system.wallClock() per request and returns mtime from stat. The flat ramfs reports 0 (it has no timestamps). - fat-test reads the created file's mtime through stat and checks it is a real current time, behind a new `fat-mtime` QEMU case. Verified against the host: the guest stamped mtime 1783971676 while the host clock was 1783971680 (boot+test lag) — the file's mtime is real current time. zig build, zig build test (the epoch<->DOS conversions + the engine mtime test), zig build check-fat-image, and a sequential QEMU sweep — fat-mount, fat-mutations, fat-rename, fat-mtime, vfs, vfs-client-death, log-flush, orderly-shutdown, initial-ramdisk, smoke, wall-clock, usb-storage — all green. mode/inode remain. --- docs/zig-self-hosting.md | 24 +++++---- library/runtime/fs.zig | 9 +++- system/services/fat/engine.zig | 44 +++++++++++++++- system/services/fat/fat-test.zig | 12 ++++- system/services/fat/fat.zig | 6 ++- system/services/fat/on-disk.zig | 90 ++++++++++++++++++++++++++++++++ system/services/vfs/protocol.zig | 3 ++ test/qemu_test.py | 8 +++ 8 files changed, 180 insertions(+), 16 deletions(-) diff --git a/docs/zig-self-hosting.md b/docs/zig-self-hosting.md index 9cc65fc..23e67f2 100644 --- a/docs/zig-self-hosting.md +++ b/docs/zig-self-hosting.md @@ -165,9 +165,9 @@ What the seam needs, and what danos already provides: | monotonic clock | `clock` syscall | none | | args / argv | SysV entry stack ([sysv.md](sysv.md)), `runtime.process.Init` | none | | stdout / stderr | `debug_write` today | wire fd 1/2 to a console **byte** stream | -| **mkdir / unlink / rename / truncate** | — | **missing** in the VFS protocol *and* FAT engine | -| **stat fields** | `{size, kind}` only | needs **mtime + inode + mode** (cache validity) | -| **wall-clock / realtime** | monotonic only | needs an RTC/time service | +| mkdir / unlink / rename / truncate | done — engine + VFS + `runtime.fs` (Phase 2) | — | +| stat fields | `{size, kind, mtime}` | **mode / inode** still missing (cache validity) | +| wall-clock / realtime | done — `wall_clock` syscall (CMOS RTC, Phase 2d) | — | | **environment variables** | `Init` has no env field | missing (can start empty) | | **cwd / chdir** | paths are absolute or bare | missing (no cwd anchor) | | **entropy / random** | — | missing (needed behind `vtable.random`) | @@ -251,15 +251,19 @@ Because `std.fs`/`std.Io` have no per-OS branches, finishing this in `runtime.os lights up the whole file tower for the compiler at once. Environment can stay an empty map until the kernel populates a non-empty `envp`. -**Status (Phase 2a–2c landed):** `truncate` (O_TRUNC, closing the boot-log stale-tail +**Status — Phase 2 complete.** `truncate` (O_TRUNC, closing the boot-log stale-tail bug), `mkdir`, `unlink`, and `rename` are all wired through the FAT engine, the VFS protocol + router, and `runtime.fs` (`makeDirectory` / `remove` / `rename`) — -host-tested and QEMU-tested (the `fat-mutations` + `fat-rename` cases make a directory, -write+read a file in it, rename it, then remove it through the mount). `removeFile` and -`rename` are LFN-aware; `rename` is same-directory + 8.3 (cross-directory and -long-name-preserving rename are noted limitations). **Remaining Phase 2:** the richer -`stat` — mtime/mode — which needs **wall-clock** (a kernel RTC read behind a syscall, -consistent with time being a kernel concern), the natural next kernel-side step. +host-tested and QEMU-tested (`fat-mutations` + `fat-rename` make a directory, write+read +a file in it, rename it, then remove it through the mount). `removeFile` and `rename` +are LFN-aware; `rename` is same-directory + 8.3 (cross-directory and long-name- +preserving rename are noted limitations). Wall-clock is now a kernel syscall +(`wall_clock`, a CMOS-RTC read anchored to the monotonic clock), and the FAT engine +stamps and reports **mtime** — `stat` / `runtime.fs.Attributes` carry a real +modification time (the `fat-mtime` case reads it back within seconds of the host clock). +The remaining `stat` fields, `mode`/`inode`, are deferred (not needed until the +compiler's cache layer wants them). **Everything past here is gated on Phase 0 (the +fork):** the `runtime.os` seam, `cwd`, stdio-as-fds, and the compiler bring-up. ### Phase 3 — Single-threaded, self-linked compiler bring-up diff --git a/library/runtime/fs.zig b/library/runtime/fs.zig index a42d3a5..cdff7c8 100644 --- a/library/runtime/fs.zig +++ b/library/runtime/fs.zig @@ -19,7 +19,12 @@ const protocol = @import("vfs-protocol"); pub const Kind = protocol.NodeKind; /// A node's metadata (the answer to a status request). -pub const Attributes = struct { size: u64, kind: Kind }; +pub const Attributes = struct { + size: u64, + kind: Kind, + /// Modification time — Unix epoch seconds, UTC. 0 if the filesystem has none. + mtime: u64 = 0, +}; // Map a wire `NodeKind` value to the enum, defaulting anything unrecognised to // `.regular` (the server is trusted, but a value outside the enum would be @@ -138,7 +143,7 @@ pub const File = struct { const r = transact(request, &.{}, &buffer) orelse return null; if (r.reply.status != 0 or r.payload.len < @sizeOf(protocol.FileStatus)) return null; const status = std.mem.bytesToValue(protocol.FileStatus, buffer[0..@sizeOf(protocol.FileStatus)]); - return .{ .size = status.size, .kind = kindFromWire(status.kind) }; + return .{ .size = status.size, .kind = kindFromWire(status.kind), .mtime = status.mtime }; } /// Release the VFS's open handle for this file. diff --git a/system/services/fat/engine.zig b/system/services/fat/engine.zig index d2edd71..4c8a5ce 100644 --- a/system/services/fat/engine.zig +++ b/system/services/fat/engine.zig @@ -41,6 +41,9 @@ pub const Node = struct { first_cluster: u32, size: u32, is_directory: bool, + // Modification time (Unix epoch seconds, UTC), decoded from the directory + // entry's DOS write date/time. 0 if unset. + mtime: u64 = 0, // The absolute sector and byte offset of this node's 8.3 directory entry, so // size/first-cluster changes can be written back. Absent for the root. entry_sector: u64 = 0, @@ -63,6 +66,10 @@ pub const FileSystem = struct { sector: [sector_size]u8 = undefined, fat_sector: [sector_size]u8 = undefined, dir_sector: [sector_size]u8 = undefined, + // Wall-clock time (Unix epoch seconds) to stamp on create/write, set by the + // 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, // Every filesystem-relative sector access adds the partition base. fn blockRead(self: *FileSystem, lba: u64, buffer: []u8) bool { @@ -428,6 +435,7 @@ pub const FileSystem = struct { .first_cluster = entry.firstCluster(), .size = entry.file_size, .is_directory = entry.isDirectory(), + .mtime = on_disk.fatToEpoch(entry.write_date, entry.write_time), .entry_sector = entry_sector, .entry_offset = entry_offset, .has_entry = true, @@ -456,7 +464,7 @@ pub const FileSystem = struct { /// The `cursor`th real entry of a directory (for readdir): its display name, /// kind, and size. Returns null past the end. - pub const Listing = struct { name_buffer: [260]u8 = undefined, name_len: usize = 0, is_directory: bool = false, size: u32 = 0 }; + pub const Listing = struct { name_buffer: [260]u8 = undefined, name_len: usize = 0, is_directory: bool = false, size: u32 = 0, mtime: u64 = 0 }; const ListContext = struct { target: u32, index: u32 = 0, out: *Listing, done: bool = false }; fn listVisit(context: *ListContext, entry: on_disk.DirectoryEntry, name: []const u8, entry_sector: u64, entry_offset: u32) bool { @@ -468,6 +476,7 @@ pub const FileSystem = struct { context.out.name_len = n; context.out.is_directory = entry.isDirectory(); context.out.size = entry.file_size; + context.out.mtime = on_disk.fatToEpoch(entry.write_date, entry.write_time); context.done = true; return true; } @@ -582,6 +591,14 @@ pub const FileSystem = struct { var entry = std.mem.bytesToValue(on_disk.DirectoryEntry, self.dir_sector[node.entry_offset .. node.entry_offset + @sizeOf(on_disk.DirectoryEntry)]); entry.file_size = node.size; entry.setFirstCluster(node.first_cluster); + // A write updates the modification time (leave it if no time is set, so host + // tests and reads don't zero it). + if (self.current_time_epoch != 0) { + const stamp = on_disk.epochToFatDateTime(self.current_time_epoch); + entry.write_date = stamp.date; + entry.write_time = stamp.time; + entry.last_access_date = stamp.date; + } @memcpy(self.dir_sector[node.entry_offset .. node.entry_offset + @sizeOf(on_disk.DirectoryEntry)], std.mem.asBytes(&entry)); _ = self.blockWrite(node.entry_sector, &self.dir_sector); } @@ -603,12 +620,19 @@ pub const FileSystem = struct { 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; @memcpy(self.dir_sector[offset .. offset + @sizeOf(on_disk.DirectoryEntry)], std.mem.asBytes(&entry)); if (!self.blockWrite(lba, &self.dir_sector)) return null; return .{ .first_cluster = first_cluster, .size = size, .is_directory = attributes & on_disk.attribute_directory != 0, + .mtime = self.current_time_epoch, .entry_sector = lba, .entry_offset = offset, .has_entry = true, @@ -1061,3 +1085,21 @@ test "rename a file in place, keeping its contents" { // After the refused renames, NEW.TXT is untouched. try std.testing.expect(fs.resolve("/NEW.TXT") != null); } + +test "a create stamps the modification time" { + 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.current_time_epoch = 1_700_000_000; // an even-second UTC time + var node = fs.createFile(fs.rootNode(), "STAMP.TXT").?; + _ = fs.writeFile(&node, 0, "hi"); + + // The persisted entry carries the stamped mtime (even seconds round-trip exactly), + // as does a fresh listing. + 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); +} diff --git a/system/services/fat/fat-test.zig b/system/services/fat/fat-test.zig index b90e899..8f11736 100644 --- a/system/services/fat/fat-test.zig +++ b/system/services/fat/fat-test.zig @@ -62,6 +62,14 @@ pub fn main(init: runtime.process.Init) void { wrote = (f.writeAll("mutation-ok") orelse 0) == "mutation-ok".len; f.close(); } + // The created file carries a real modification time (stamped from the RTC). + var mtime_ok = false; + if (fs.attributes("/mnt/usb/TESTDIR/HELLO.TXT")) |attrs| { + writeLine("fat-test: mtime {d}\n", .{attrs.mtime}); + mtime_ok = attrs.mtime > 1_577_836_800; // after 2020-01-01 + } + if (mtime_ok) _ = runtime.system.write("fat-test: mtime ok\n"); + // Rename it, then read from the new name and confirm the old name is gone. const renamed = fs.rename("/mnt/usb/TESTDIR/HELLO.TXT", "/mnt/usb/TESTDIR/RENAMED.TXT"); const old_gone = !fs.exists("/mnt/usb/TESTDIR/HELLO.TXT"); @@ -76,10 +84,10 @@ pub fn main(init: runtime.process.Init) void { } const removed = fs.remove("/mnt/usb/TESTDIR/RENAMED.TXT"); const gone = !fs.exists("/mnt/usb/TESTDIR/RENAMED.TXT"); - if (wrote and renamed and old_gone and readback and removed and gone) { + if (wrote and mtime_ok and renamed and old_gone and readback and removed and gone) { _ = runtime.system.write("fat-test: mutations ok\n"); } else { - writeLine("fat-test: mutations FAILED (wrote={} renamed={} oldgone={} read={} removed={} gone={})\n", .{ wrote, renamed, old_gone, readback, removed, gone }); + writeLine("fat-test: mutations FAILED (wrote={} mtime={} renamed={} oldgone={} read={} removed={} gone={})\n", .{ wrote, mtime_ok, renamed, old_gone, readback, removed, gone }); } } else { _ = runtime.system.write("fat-test: mkdir /mnt/usb/TESTDIR failed\n"); diff --git a/system/services/fat/fat.zig b/system/services/fat/fat.zig index 38719ce..82890ef 100644 --- a/system/services/fat/fat.zig +++ b/system/services/fat/fat.zig @@ -152,6 +152,10 @@ fn onMessage(message: []const u8, out: []u8, sender: u32, capability: ?runtime.i const request = std.mem.bytesToValue(protocol.Request, message[0..protocol.request_size]); const payload = message[protocol.request_size..]; + // Stamp create/write with the current wall-clock time (mtime). Cheap, and it + // keeps the engine pure (it takes the time as data, not a syscall). + filesystem.current_time_epoch = runtime.system.wallClock(); + switch (request.operation) { .open => return handleOpen(out, payload[0..@min(payload.len, request.len)], request.flags), .read => { @@ -170,7 +174,7 @@ fn onMessage(message: []const u8, out: []u8, sender: u32, capability: ?runtime.i .status => { const o = openAt(request.node) orelse return fail(out); const kind: protocol.NodeKind = if (o.node.is_directory) .directory else .regular; - const status = protocol.FileStatus{ .size = o.node.size, .kind = @intFromEnum(kind) }; + const status = protocol.FileStatus{ .size = o.node.size, .kind = @intFromEnum(kind), .mtime = o.node.mtime }; return writeReply(out, .{ .status = 0, .len = @sizeOf(protocol.FileStatus) }, std.mem.asBytes(&status)); }, .readdir => { diff --git a/system/services/fat/on-disk.zig b/system/services/fat/on-disk.zig index e58b73f..f70e972 100644 --- a/system/services/fat/on-disk.zig +++ b/system/services/fat/on-disk.zig @@ -202,6 +202,96 @@ pub fn geometryOf(sector: []const u8) ?Geometry { }; } +// --- DOS date/time <-> Unix epoch -------------------------------------------- +// +// FAT stamps a file's modification time as two 16-bit DOS fields. There is no +// timezone, so danos treats them as UTC. `date`: year-1980(7)|month(4)|day(5); +// `time`: hour(5)|minute(6)|(second/2)(5). + +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 FAT date+time to Unix epoch seconds (UTC). Returns 0 for an unset +/// (zero) date. +pub fn fatToEpoch(date: u16, time: u16) u64 { + if (date == 0) return 0; + 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 = @as(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; +} + +pub const FatDateTime = struct { date: u16, time: u16 }; + +/// Convert Unix epoch seconds (UTC) to a FAT date+time. Returns {0,0} for epoch 0 or +/// any time before 1980 (which DOS cannot represent). +pub fn epochToFatDateTime(epoch: u64) FatDateTime { + if (epoch == 0) return .{ .date = 0, .time = 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); // whole days since 1970-01-01 + + var year: u32 = 1970; + while (true) { + const y_days: u32 = if (isLeapYear(year)) 366 else 365; + if (days < y_days) break; + days -= y_days; + year += 1; + } + if (year < 1980) return .{ .date = 0, .time = 0 }; + var month: u32 = 1; + while (true) { + var m_days: u32 = days_in_month[month - 1]; + if (month == 2 and isLeapYear(year)) m_days += 1; + if (days < m_days) break; + days -= m_days; + month += 1; + } + const day = days + 1; + return .{ + .date = @intCast(((year - 1980) << 9) | (month << 5) | day), + .time = @intCast((hour << 11) | (minute << 5) | (second / 2)), + }; +} + +test "FAT date/time <-> Unix epoch round trip" { + // Even-second UTC times (FAT stores seconds/2, so even seconds round-trip exactly). + for ([_]u64{ 1_577_836_800, 1_700_000_000, 1_262_304_000, 1_783_971_244 }) |epoch| { + const fat = epochToFatDateTime(epoch); + try std.testing.expectEqual(epoch, fatToEpoch(fat.date, fat.time)); + } + // Absolute check: 1577836800 is 2020-01-01 00:00:00 UTC. + const y2020 = epochToFatDateTime(1_577_836_800); + try std.testing.expectEqual(@as(u16, 2020), 1980 + (y2020.date >> 9)); + try std.testing.expectEqual(@as(u16, 1), (y2020.date >> 5) & 0x0F); // month + try std.testing.expectEqual(@as(u16, 1), y2020.date & 0x1F); // day + // 0 is "unset" both ways. + try std.testing.expectEqual(@as(u64, 0), fatToEpoch(0, 0)); + try std.testing.expectEqual(@as(u16, 0), epochToFatDateTime(0).date); +} + test "on-disk struct sizes match the specification" { try std.testing.expectEqual(@as(usize, 36), @sizeOf(BiosParameterBlock)); try std.testing.expectEqual(@as(usize, 26), @sizeOf(ExtendedBootRecord16)); diff --git a/system/services/vfs/protocol.zig b/system/services/vfs/protocol.zig index 4738ad8..a1b93e6 100644 --- a/system/services/vfs/protocol.zig +++ b/system/services/vfs/protocol.zig @@ -81,6 +81,9 @@ pub const FileStatus = extern struct { size: u64, kind: u32, _padding: u32 = 0, + /// Modification time — Unix epoch seconds, UTC. 0 if the backend has none (the + /// flat ramfs). Filled from the FAT directory entry's write date/time. + mtime: u64 = 0, }; pub const message_maximum: usize = 256; diff --git a/test/qemu_test.py b/test/qemu_test.py index 20cdc89..181e20e 100644 --- a/test/qemu_test.py +++ b/test/qemu_test.py @@ -342,6 +342,14 @@ CASES = [ "timeout": 150, "expect": r"fat-test: rename ok", "fail": r"fat-test: mutations FAILED|DANOS-TEST-RESULT: FAIL"}, + # Phase 2d: filesystem timestamps — a freshly-created file's mtime is a real + # current wall-clock time (stamped from the RTC), read back through stat. + {"name": "fat-mtime", + "build_case": "fat-mount", + "smp": 4, + "timeout": 150, + "expect": r"fat-test: mtime ok", + "fail": r"fat-test: mutations FAILED|DANOS-TEST-RESULT: FAIL"}, # Boot-from-USB smoke: the whole system now boots off the FAT32 image on a # usb-storage device (OVMF -> \EFI\BOOT\BOOTX64.efi -> kernel), so the kernel # reaching its PASS marker at all proves the USB boot path end to end. Reuses