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
No known key found for this signature in database
GPG Key ID: A9EB2589C60F7268
7 changed files with 117 additions and 12 deletions

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
map until the kernel populates a non-empty `envp`.
**Status (Phase 2a landed):** `truncate` is done end to end — `engine.truncate` frees
the cluster chain, an O_TRUNC open flag wires it through the VFS and `runtime.fs`, and
the boot-log flush uses it (closing the stale-tail bug). The engine also has
`createDirectory` (mkdir) and `removeFile` (unlink), both host-tested (LFN-aware
deletion included). Still to do: expose mkdir/unlink as VFS operations + `runtime.fs`
methods (they are new path-based ops needing router cases); `rename`; and the richer
`stat` (blocked on wall-clock).
**Status (Phase 2a2b landed):** `truncate` (O_TRUNC end to end, closing the boot-log
stale-tail bug), `mkdir`, and `unlink` are all wired through the FAT engine, the VFS
protocol + router, and `runtime.fs` (`makeDirectory`/`remove`) — host-tested and
QEMU-tested (the `fat-mutations` case creates a directory, writes+reads a file in it,
then removes it through the mount). `removeFile` is LFN-aware. Still to do: `rename`
(engine + wiring), and then the richer `stat` — mtime/mode — which needs **wall-clock**
(a kernel RTC read), the natural next kernel-side step.
### Phase 3 — Single-threaded, self-linked compiler bring-up

View File

@ -218,6 +218,25 @@ pub fn openDirectory(path: []const u8) ?Directory {
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`;
/// 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

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) {
while (true) {
_ = runtime.system.write("fat-test: ok\n");

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
}
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 {
var node = filesystem.resolve(path);
if (node == null and flags & protocol.create != 0) {
const slash = std.mem.lastIndexOfScalar(u8, path, '/');
const parent_path = if (slash) |s| (if (s == 0) "/" else path[0..s]) else "/";
const leaf = if (slash) |s| path[s + 1 ..] else path;
const parent = filesystem.resolve(parent_path) orelse return fail(out);
node = filesystem.createFile(parent, leaf);
const split = splitParent(path);
const parent = filesystem.resolve(split.parent) orelse return fail(out);
node = filesystem.createFile(parent, split.leaf);
}
var resolved = node orelse return fail(out);
// 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;
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.
.mount, .unmount => return fail(out),
}

View File

@ -21,6 +21,10 @@ pub const Operation = enum(u32) {
readdir, // readdir(dir_node, cursor=offset) -> one DirectoryEntry (len==0 => EOF)
mount, // mount(prefix payload, capability = backend endpoint)
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

View File

@ -161,6 +161,22 @@ fn forwardRequest(out: []u8, backend: ipc.Handle, request: protocol.Request, pay
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
/// handles are swept the backend must not leak the vfs's opens).
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 }, &.{});
},
.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);
},
}
}

View File

@ -320,6 +320,15 @@ CASES = [
"timeout": 150,
"expect": r"fat: mounted /mnt/usb[\s\S]*fat-test: ok",
"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
# 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