Phase 2c: rename, wired through the VFS to runtime.fs

Completes the Phase 2 FAT mutation set (truncate, mkdir, unlink, rename).

- engine: rename(dir, old_name, new_name) rewrites an existing entry's 8.3 name in
  place within the same directory. Refuses a missing source, a non-8.3 target, or a
  name that already exists; drops any long-name entries on the old file (it takes
  its new 8.3 name), LFN-aware like removeFile. Cross-directory and long-name-
  preserving rename are noted limitations. Host-tested (rename keeps contents;
  collision, non-8.3, and missing-source are refused).
- vfs protocol: a `rename` operation whose payload is old-path, a 0x00 separator,
  then new-path.
- VFS router: a forwardRename helper + a `.rename` case that requires both paths
  under the same mount (cross-filesystem rename is refused) and forwards the
  mount-relative old+new.
- fat server: a `.rename` handler that requires the same parent directory and calls
  engine.rename.
- runtime.fs: rename(old_path, new_path).
- fat-test now renames the file it created (before removing it) and asserts the old
  name is gone, behind a new `fat-rename` QEMU case.

Verified: zig build, zig build test (the engine rename unit test), zig build
check-fat-image, and a sequential QEMU sweep — fat-mount, fat-mutations, fat-rename,
vfs, vfs-client-death, log-flush, orderly-shutdown, initial-ramdisk, smoke — green.
This commit is contained in:
Daniel Samson
2026-07-13 20:22:32 +01:00
parent 184d90c2c6
commit 54635eecf5
8 changed files with 186 additions and 12 deletions
+9 -7
View File
@@ -251,13 +251,15 @@ 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–2b landed):** `truncate` (O_TRUNC end to end, closing the boot-log **Status (Phase 2a–2c landed):** `truncate` (O_TRUNC, closing the boot-log stale-tail
stale-tail bug), `mkdir`, and `unlink` are all wired through the FAT engine, the VFS bug), `mkdir`, `unlink`, and `rename` are all wired through the FAT engine, the VFS
protocol + router, and `runtime.fs` (`makeDirectory`/`remove`) — host-tested and protocol + router, and `runtime.fs` (`makeDirectory` / `remove` / `rename`) —
QEMU-tested (the `fat-mutations` case creates a directory, writes+reads a file in it, host-tested and QEMU-tested (the `fat-mutations` + `fat-rename` cases make a directory,
then removes it through the mount). `removeFile` is LFN-aware. Still to do: `rename` write+read a file in it, rename it, then remove it through the mount). `removeFile` and
(engine + wiring), and then the richer `stat` — mtime/mode — which needs **wall-clock** `rename` are LFN-aware; `rename` is same-directory + 8.3 (cross-directory and
(a kernel RTC read), the natural next kernel-side step. 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.
### Phase 3 — Single-threaded, self-linked compiler bring-up ### Phase 3 — Single-threaded, self-linked compiler bring-up
+14
View File
@@ -237,6 +237,20 @@ pub fn remove(path: []const u8) bool {
return pathOperation(.unlink, path); return pathOperation(.unlink, path);
} }
/// Rename `old_path` to `new_path`. Both must be in the same directory (same-
/// directory, 8.3-name rename only for now). Returns true on success.
pub fn rename(old_path: []const u8, new_path: []const u8) bool {
const total = old_path.len + 1 + new_path.len;
if (total > protocol.maximum_payload) return false;
var payload: [protocol.maximum_payload]u8 = undefined;
@memcpy(payload[0..old_path.len], old_path);
payload[old_path.len] = 0;
@memcpy(payload[old_path.len + 1 ..][0..new_path.len], new_path);
const request = protocol.Request{ .operation = .rename, .node = 0, .offset = 0, .len = @intCast(total), .flags = 0 };
const r = transact(request, payload[0..total], &.{}) orelse return false;
return r.reply.status == 0;
}
/// 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
+98
View File
@@ -739,6 +739,75 @@ pub const FileSystem = struct {
} }
return false; return false;
} }
/// Rename `old_name` to `new_name` within the SAME directory `dir`, rewriting
/// the 8.3 entry's name in place. Refuses if `old_name` is missing, `new_name`
/// is not 8.3-representable, or `new_name` already exists. Any long-name entries
/// on the old file are dropped (the file takes its new 8.3 name); cross-directory
/// and long-name-preserving rename are not implemented. Returns true on success.
pub fn rename(self: *FileSystem, dir: Node, old_name: []const u8, new_name: []const u8) bool {
const raw = to83(new_name) orelse return false;
if (self.findChild(dir, new_name) != null) return false; // target already exists
var run: [21]EntryLoc = undefined; // the long-name entries before the 8.3 one
var run_len: usize = 0;
var long_name: [260]u8 = undefined;
var long_len: usize = 0;
var sector_index: u32 = 0;
while (self.dirSectorLba(dir, sector_index, false)) |lba| : (sector_index += 1) {
if (!self.blockRead(lba, &self.dir_sector)) return false;
var i: u32 = 0;
while (i < entries_per_sector) : (i += 1) {
const offset = i * @sizeOf(on_disk.DirectoryEntry);
const entry = std.mem.bytesToValue(on_disk.DirectoryEntry, self.dir_sector[offset .. offset + @sizeOf(on_disk.DirectoryEntry)]);
if (entry.isEnd()) return false;
if (entry.name[0] == 0xE5) {
run_len = 0;
long_len = 0;
continue;
}
if (entry.isLongName()) {
if (run_len < run.len) {
run[run_len] = .{ .sector = lba, .offset = offset };
run_len += 1;
}
const lfn = std.mem.bytesToValue(on_disk.LongNameEntry, self.dir_sector[offset .. offset + @sizeOf(on_disk.LongNameEntry)]);
const order = lfn.order & 0x1F;
if (order >= 1 and order <= 20) {
var chunk: [13]u8 = undefined;
const n = longNameChars(lfn, &chunk);
const start = (order - 1) * 13;
if (start + n <= long_name.len) {
@memcpy(long_name[start .. start + n], chunk[0..n]);
if (lfn.order & 0x40 != 0) long_len = start + n;
}
}
continue;
}
if (entry.isVolumeLabel()) {
run_len = 0;
long_len = 0;
continue;
}
// A real 8.3 entry.
var short: [12]u8 = undefined;
const display = if (long_len > 0) long_name[0..long_len] else format83(entry.name, &short);
if (nameMatches(display, old_name)) {
var updated = entry;
updated.name = raw;
@memcpy(self.dir_sector[offset .. offset + @sizeOf(on_disk.DirectoryEntry)], std.mem.asBytes(&updated));
if (!self.blockWrite(lba, &self.dir_sector)) return false;
// Drop the old long name, if any, so the new 8.3 name is what shows.
var r: usize = 0;
while (r < run_len) : (r += 1) self.markDeleted(run[r].sector, run[r].offset);
return true;
}
run_len = 0;
long_len = 0;
}
}
return false;
}
}; };
// --- tests: a RAM-backed FAT16 image ---------------------------------------- // --- tests: a RAM-backed FAT16 image ----------------------------------------
@@ -963,3 +1032,32 @@ test "create a subdirectory with . and .. and a file inside" {
try std.testing.expectEqualStrings("SUB", listing.name_buffer[0..listing.name_len]); try std.testing.expectEqualStrings("SUB", listing.name_buffer[0..listing.name_len]);
try std.testing.expect(listing.is_directory); try std.testing.expect(listing.is_directory);
} }
test "rename a file in place, keeping its contents" {
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()).?;
var node = fs.createFile(fs.rootNode(), "OLD.TXT").?;
_ = fs.writeFile(&node, 0, "content");
try std.testing.expect(fs.rename(fs.rootNode(), "OLD.TXT", "NEW.TXT"));
try std.testing.expect(fs.resolve("/OLD.TXT") == null);
const renamed = fs.resolve("/NEW.TXT").?;
var buf: [16]u8 = undefined;
const n = fs.readFile(renamed, 0, &buf);
try std.testing.expectEqualStrings("content", buf[0..n]);
// Refuse a collision with an existing name.
_ = fs.createFile(fs.rootNode(), "OTHER.TXT").?;
try std.testing.expect(!fs.rename(fs.rootNode(), "NEW.TXT", "OTHER.TXT"));
// Refuse a non-8.3 target name.
try std.testing.expect(!fs.rename(fs.rootNode(), "NEW.TXT", "toolongbasename.txt"));
// Refuse a missing source.
try std.testing.expect(!fs.rename(fs.rootNode(), "NOPE.TXT", "X.TXT"));
// After the refused renames, NEW.TXT is untouched.
try std.testing.expect(fs.resolve("/NEW.TXT") != null);
}
+9 -5
View File
@@ -62,20 +62,24 @@ pub fn main(init: runtime.process.Init) void {
wrote = (f.writeAll("mutation-ok") orelse 0) == "mutation-ok".len; wrote = (f.writeAll("mutation-ok") orelse 0) == "mutation-ok".len;
f.close(); f.close();
} }
// 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");
if (renamed and old_gone) _ = runtime.system.write("fat-test: rename ok\n");
var readback = false; var readback = false;
if (fs.open("/mnt/usb/TESTDIR/HELLO.TXT", .{})) |reopened| { if (fs.open("/mnt/usb/TESTDIR/RENAMED.TXT", .{})) |reopened| {
var f = reopened; var f = reopened;
var buf: [16]u8 = undefined; var buf: [16]u8 = undefined;
const got = f.read(&buf) orelse 0; const got = f.read(&buf) orelse 0;
f.close(); f.close();
readback = std.mem.eql(u8, buf[0..got], "mutation-ok"); readback = std.mem.eql(u8, buf[0..got], "mutation-ok");
} }
const removed = fs.remove("/mnt/usb/TESTDIR/HELLO.TXT"); const removed = fs.remove("/mnt/usb/TESTDIR/RENAMED.TXT");
const gone = !fs.exists("/mnt/usb/TESTDIR/HELLO.TXT"); const gone = !fs.exists("/mnt/usb/TESTDIR/RENAMED.TXT");
if (wrote and readback and removed and gone) { if (wrote and renamed and old_gone and readback and removed and gone) {
_ = runtime.system.write("fat-test: mutations ok\n"); _ = runtime.system.write("fat-test: mutations ok\n");
} else { } else {
writeLine("fat-test: mutations FAILED (wrote={} read={} removed={} gone={})\n", .{ wrote, readback, removed, gone }); writeLine("fat-test: mutations FAILED (wrote={} renamed={} oldgone={} read={} removed={} gone={})\n", .{ wrote, renamed, old_gone, readback, removed, gone });
} }
} else { } else {
_ = runtime.system.write("fat-test: mkdir /mnt/usb/TESTDIR failed\n"); _ = runtime.system.write("fat-test: mkdir /mnt/usb/TESTDIR failed\n");
+11
View File
@@ -202,6 +202,17 @@ fn onMessage(message: []const u8, out: []u8, sender: u32, capability: ?runtime.i
if (!filesystem.removeFile(parent, split.leaf)) return fail(out); if (!filesystem.removeFile(parent, split.leaf)) return fail(out);
return writeReply(out, .{ .status = 0 }, &.{}); return writeReply(out, .{ .status = 0 }, &.{});
}, },
.rename => {
const both = payload[0..@min(payload.len, request.len)];
const sep = std.mem.indexOfScalar(u8, both, 0) orelse return fail(out);
const old_split = splitParent(both[0..sep]);
const new_split = splitParent(both[sep + 1 ..]);
// Same-directory rename only.
if (!std.mem.eql(u8, old_split.parent, new_split.parent)) return fail(out);
const parent = filesystem.resolve(old_split.parent) orelse return fail(out);
if (!filesystem.rename(parent, old_split.leaf, new_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),
} }
+3
View File
@@ -25,6 +25,9 @@ pub const Operation = enum(u32) {
// payload); a mounted backend handles them, the flat ramfs refuses them. // payload); a mounted backend handles them, the flat ramfs refuses them.
mkdir, // mkdir(path payload) -> status mkdir, // mkdir(path payload) -> status
unlink, // unlink(path payload) -> status unlink, // unlink(path payload) -> status
// rename: the payload is the old path, a single 0x00 separator, then the new
// path. Same-directory rename only (the router requires both under one mount).
rename, // rename(old\0new 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
+34
View File
@@ -177,6 +177,28 @@ fn forwardPath(out: []u8, backend: ipc.Handle, operation: protocol.Operation, re
return copy; return copy;
} }
/// Forward a rename to its backend: the payload is the mount-relative old path, a
/// 0x00 separator, then the mount-relative new path. Relays the backend's reply.
fn forwardRename(out: []u8, backend: ipc.Handle, old_relative: []const u8, new_relative: []const u8) usize {
const total = old_relative.len + 1 + new_relative.len;
var message: [protocol.message_maximum]u8 = undefined;
if (protocol.request_size + total > message.len) return fail(out);
const request = protocol.Request{ .operation = .rename, .node = 0, .offset = 0, .len = @intCast(total), .flags = 0 };
@memcpy(message[0..protocol.request_size], std.mem.asBytes(&request));
var p = protocol.request_size;
@memcpy(message[p..][0..old_relative.len], old_relative);
p += old_relative.len;
message[p] = 0;
p += 1;
@memcpy(message[p..][0..new_relative.len], new_relative);
p += new_relative.len;
var reply: [protocol.message_maximum]u8 = undefined;
const n = ipc.call(backend, message[0..p], &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 {
@@ -328,6 +350,18 @@ fn handle(message: []const u8, out: []u8, sender: u32, capability: ?ipc.Handle)
// create or remove them (and a bare-name path is not a mount target). // create or remove them (and a bare-name path is not a mount target).
return fail(out); return fail(out);
}, },
.rename => {
const both = payload[0..@min(payload.len, request.len)];
const sep = std.mem.indexOfScalar(u8, both, 0) orelse return fail(out);
const old_path = both[0..sep];
const new_path = both[sep + 1 ..];
const mo = longestMount(old_path) orelse return fail(out);
const mn = longestMount(new_path) orelse return fail(out);
// Both paths must live under the same mount — cross-filesystem rename is
// not supported.
if (mo.backend != mn.backend) return fail(out);
return forwardRename(out, mo.backend, mo.relative, mn.relative);
},
} }
} }
+8
View File
@@ -329,6 +329,14 @@ CASES = [
"timeout": 150, "timeout": 150,
"expect": r"fat-test: mutations ok", "expect": r"fat-test: mutations ok",
"fail": r"fat-test: mutations FAILED|fat-test: mkdir .* failed|DANOS-TEST-RESULT: FAIL"}, "fail": r"fat-test: mutations FAILED|fat-test: mkdir .* failed|DANOS-TEST-RESULT: FAIL"},
# Phase 2c: rename through the mount — fat-test renames the file it created
# before removing it, and confirms the old name is gone.
{"name": "fat-rename",
"build_case": "fat-mount",
"smp": 4,
"timeout": 150,
"expect": r"fat-test: rename 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 # 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