Phase 2b: wire mkdir + unlink through the VFS to runtime.fs

The engine gained mkdir/unlink in Phase 2a; this exposes them as first-class
filesystem operations so programs can use them.

- vfs protocol: two new path-based operations, mkdir and unlink (appended, so
  existing opcodes/offsets are unchanged).
- VFS router: a forwardPath helper relays a path-based op under a mount to its
  backend; the mkdir/unlink cases forward to the mounted filesystem (the flat
  ramfs refuses them — it has no directories).
- fat server: mkdir -> engine.createDirectory, unlink -> engine.removeFile, each
  resolving the parent via a shared splitParent helper (also used by open-create).
- runtime.fs: makeDirectory(path) and remove(path).
- fat-test now exercises the whole path — mkdir /mnt/usb/TESTDIR, create + write +
  read a file inside it, then remove it — behind a new `fat-mutations` QEMU case.

Verified: zig build, zig build test, zig build check-fat-image, and a sequential
QEMU sweep — fat-mount, fat-mutations (mkdir/write/read/unlink through the mount),
vfs, vfs-client-death, log-flush, orderly-shutdown, initial-ramdisk, smoke — green.
This commit is contained in:
Daniel Samson
2026-07-13 20:12:07 +01:00
parent a32eed877d
commit 184d90c2c6
7 changed files with 117 additions and 12 deletions
+7 -7
View File
@@ -251,13 +251,13 @@ 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 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`. map until the kernel populates a non-empty `envp`.
**Status (Phase 2a landed):** `truncate` is done end to end — `engine.truncate` frees **Status (Phase 2a–2b landed):** `truncate` (O_TRUNC end to end, closing the boot-log
the cluster chain, an O_TRUNC open flag wires it through the VFS and `runtime.fs`, and stale-tail bug), `mkdir`, and `unlink` are all wired through the FAT engine, the VFS
the boot-log flush uses it (closing the stale-tail bug). The engine also has protocol + router, and `runtime.fs` (`makeDirectory`/`remove`) — host-tested and
`createDirectory` (mkdir) and `removeFile` (unlink), both host-tested (LFN-aware QEMU-tested (the `fat-mutations` case creates a directory, writes+reads a file in it,
deletion included). Still to do: expose mkdir/unlink as VFS operations + `runtime.fs` then removes it through the mount). `removeFile` is LFN-aware. Still to do: `rename`
methods (they are new path-based ops needing router cases); `rename`; and the richer (engine + wiring), and then the richer `stat` — mtime/mode — which needs **wall-clock**
`stat` (blocked on wall-clock). (a kernel RTC read), the natural next kernel-side step.
### Phase 3 — Single-threaded, self-linked compiler bring-up ### Phase 3 — Single-threaded, self-linked compiler bring-up
+19
View File
@@ -218,6 +218,25 @@ pub fn openDirectory(path: []const u8) ?Directory {
return .{ .node = file.node }; return .{ .node = file.node };
} }
// A path-based request that returns only a status (mkdir, unlink).
fn pathOperation(operation: protocol.Operation, path: []const u8) bool {
const request = protocol.Request{ .operation = operation, .node = 0, .offset = 0, .len = @intCast(path.len), .flags = 0 };
const r = transact(request, path, &.{}) orelse return false;
return r.reply.status == 0;
}
/// Create a directory at `path` (its parent must already exist). Returns true on
/// success. Only works under a mounted filesystem that supports directories.
pub fn makeDirectory(path: []const u8) bool {
return pathOperation(.mkdir, path);
}
/// 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 {
return pathOperation(.unlink, path);
}
/// Mount a filesystem backend (its server endpoint) at absolute path `target`; /// Mount a filesystem backend (its server endpoint) at absolute path `target`;
/// the VFS then routes everything under `target` to that backend. This is the one /// the VFS then routes everything under `target` to that backend. This is the one
/// call that hands the VFS a capability (the backend endpoint). Returns true on /// call that hands the VFS a capability (the backend endpoint). Returns true on
+28
View File
@@ -53,6 +53,34 @@ pub fn main(init: runtime.process.Init) void {
} }
} }
// Exercise directory + file mutation through the mount: mkdir, create a file
// inside it, read it back, then remove it — proof mkdir/unlink reach the engine.
if (fs.makeDirectory("/mnt/usb/TESTDIR")) {
var wrote = false;
if (fs.open("/mnt/usb/TESTDIR/HELLO.TXT", .{ .create = true, .truncate = true })) |created| {
var f = created;
wrote = (f.writeAll("mutation-ok") orelse 0) == "mutation-ok".len;
f.close();
}
var readback = false;
if (fs.open("/mnt/usb/TESTDIR/HELLO.TXT", .{})) |reopened| {
var f = reopened;
var buf: [16]u8 = undefined;
const got = f.read(&buf) orelse 0;
f.close();
readback = std.mem.eql(u8, buf[0..got], "mutation-ok");
}
const removed = fs.remove("/mnt/usb/TESTDIR/HELLO.TXT");
const gone = !fs.exists("/mnt/usb/TESTDIR/HELLO.TXT");
if (wrote and readback and removed and gone) {
_ = runtime.system.write("fat-test: mutations ok\n");
} else {
writeLine("fat-test: mutations FAILED (wrote={} read={} removed={} gone={})\n", .{ wrote, readback, removed, gone });
}
} else {
_ = runtime.system.write("fat-test: mkdir /mnt/usb/TESTDIR failed\n");
}
if (count > 0) { if (count > 0) {
while (true) { while (true) {
_ = runtime.system.write("fat-test: ok\n"); _ = runtime.system.write("fat-test: ok\n");
+27 -5
View File
@@ -115,14 +115,24 @@ fn initialise(endpoint: runtime.ipc.Handle) bool {
return true; // still serve directly, even if the namespace mount didn't take return true; // still serve directly, even if the namespace mount didn't take
} }
const ParentLeaf = struct { parent: []const u8, leaf: []const u8 };
// Split a path into its parent directory and final component: "/a/b" -> ("/a",
// "b"); "/b" -> ("/", "b"); "b" -> ("/", "b").
fn splitParent(path: []const u8) ParentLeaf {
const slash = std.mem.lastIndexOfScalar(u8, path, '/');
return .{
.parent = if (slash) |s| (if (s == 0) "/" else path[0..s]) else "/",
.leaf = if (slash) |s| path[s + 1 ..] else path,
};
}
fn handleOpen(out: []u8, path: []const u8, flags: u32) usize { fn handleOpen(out: []u8, path: []const u8, flags: u32) usize {
var node = filesystem.resolve(path); var node = filesystem.resolve(path);
if (node == null and flags & protocol.create != 0) { if (node == null and flags & protocol.create != 0) {
const slash = std.mem.lastIndexOfScalar(u8, path, '/'); const split = splitParent(path);
const parent_path = if (slash) |s| (if (s == 0) "/" else path[0..s]) else "/"; const parent = filesystem.resolve(split.parent) orelse return fail(out);
const leaf = if (slash) |s| path[s + 1 ..] else path; node = filesystem.createFile(parent, split.leaf);
const parent = filesystem.resolve(parent_path) orelse return fail(out);
node = filesystem.createFile(parent, leaf);
} }
var resolved = node orelse return fail(out); var resolved = node orelse return fail(out);
// O_TRUNC: replace an existing file's contents rather than overwriting in place // O_TRUNC: replace an existing file's contents rather than overwriting in place
@@ -180,6 +190,18 @@ fn onMessage(message: []const u8, out: []u8, sender: u32, capability: ?runtime.i
if (openAt(request.node)) |o| o.used = false; if (openAt(request.node)) |o| o.used = false;
return writeReply(out, .{ .status = 0 }, &.{}); return writeReply(out, .{ .status = 0 }, &.{});
}, },
.mkdir => {
const split = splitParent(payload[0..@min(payload.len, request.len)]);
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 }, &.{});
},
.unlink => {
const split = splitParent(payload[0..@min(payload.len, request.len)]);
const parent = filesystem.resolve(split.parent) orelse return fail(out);
if (!filesystem.removeFile(parent, split.leaf)) return fail(out);
return writeReply(out, .{ .status = 0 }, &.{});
},
// A backend is never itself a mount target. // A backend is never itself a mount target.
.mount, .unmount => return fail(out), .mount, .unmount => return fail(out),
} }
+4
View File
@@ -21,6 +21,10 @@ pub const Operation = enum(u32) {
readdir, // readdir(dir_node, cursor=offset) -> one DirectoryEntry (len==0 => EOF) readdir, // readdir(dir_node, cursor=offset) -> one DirectoryEntry (len==0 => EOF)
mount, // mount(prefix payload, capability = backend endpoint) mount, // mount(prefix payload, capability = backend endpoint)
unmount, // unmount(prefix payload) unmount, // unmount(prefix payload)
// Appended for filesystem mutation (Phase 2). Path-based (the path is the
// payload); a mounted backend handles them, the flat ramfs refuses them.
mkdir, // mkdir(path payload) -> status
unlink, // unlink(path payload) -> status
}; };
/// The type of a filesystem node, aligned to the FSH file-type table /// The type of a filesystem node, aligned to the FSH file-type table
+23
View File
@@ -161,6 +161,22 @@ fn forwardRequest(out: []u8, backend: ipc.Handle, request: protocol.Request, pay
return copy; return copy;
} }
/// Forward a path-based operation (mkdir, unlink) under a mount to its backend and
/// relay the reply. No handle is created — these operate by path and return only a
/// status.
fn forwardPath(out: []u8, backend: ipc.Handle, operation: protocol.Operation, relative: []const u8) usize {
const request = protocol.Request{ .operation = operation, .node = 0, .offset = 0, .len = @intCast(relative.len), .flags = 0 };
var message: [protocol.message_maximum]u8 = undefined;
@memcpy(message[0..protocol.request_size], std.mem.asBytes(&request));
const rel = relative[0..@min(relative.len, protocol.maximum_payload)];
@memcpy(message[protocol.request_size..][0..rel.len], rel);
var reply: [protocol.message_maximum]u8 = undefined;
const n = ipc.call(backend, message[0 .. protocol.request_size + rel.len], &reply) catch return fail(out);
const copy = @min(n, out.len);
@memcpy(out[0..copy], reply[0..copy]);
return copy;
}
/// Best-effort close of a backend node (used when a dead client's forwarding /// Best-effort close of a backend node (used when a dead client's forwarding
/// handles are swept — the backend must not leak the vfs's opens). /// handles are swept — the backend must not leak the vfs's opens).
fn forwardClose(backend: ipc.Handle, backend_node: u64) void { fn forwardClose(backend: ipc.Handle, backend_node: u64) void {
@@ -305,6 +321,13 @@ fn handle(message: []const u8, out: []u8, sender: u32, capability: ?ipc.Handle)
} }
return writeReply(out, .{ .status = 0 }, &.{}); return writeReply(out, .{ .status = 0 }, &.{});
}, },
.mkdir, .unlink => {
const name = payload[0..@min(payload.len, request.len)];
if (longestMount(name)) |m| return forwardPath(out, m.backend, request.operation, m.relative);
// Only a mounted backend has real directories; the flat ramfs cannot
// create or remove them (and a bare-name path is not a mount target).
return fail(out);
},
} }
} }
+9
View File
@@ -320,6 +320,15 @@ CASES = [
"timeout": 150, "timeout": 150,
"expect": r"fat: mounted /mnt/usb[\s\S]*fat-test: ok", "expect": r"fat: mounted /mnt/usb[\s\S]*fat-test: ok",
"fail": r"DANOS-TEST-RESULT: FAIL"}, "fail": r"DANOS-TEST-RESULT: FAIL"},
# Phase 2b: mkdir/unlink through the mount. Reuses the fat-mount build — the
# fat-test client, after listing, makes a directory, writes+reads a file inside
# it, then removes the file, exercising the whole VFS -> fat mutation path.
{"name": "fat-mutations",
"build_case": "fat-mount",
"smp": 4,
"timeout": 150,
"expect": r"fat-test: mutations ok",
"fail": r"fat-test: mutations FAILED|fat-test: mkdir .* failed|DANOS-TEST-RESULT: FAIL"},
# Boot-from-USB smoke: the whole system now boots off the FAT32 image on a # 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 # 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 # reaching its PASS marker at all proves the USB boot path end to end. Reuses