Phase 2d (ii): filesystem modification time (mtime)

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.
This commit is contained in:
Daniel Samson
2026-07-13 20:43:40 +01:00
parent 8a38540312
commit 67702fa250
8 changed files with 180 additions and 16 deletions
+43 -1
View File
@@ -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);
}