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:
+14
-10
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user