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
+98
View File
@@ -739,6 +739,75 @@ pub const FileSystem = struct {
}
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 ----------------------------------------
@@ -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.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;
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;
if (fs.open("/mnt/usb/TESTDIR/HELLO.TXT", .{})) |reopened| {
if (fs.open("/mnt/usb/TESTDIR/RENAMED.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) {
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) {
_ = runtime.system.write("fat-test: mutations ok\n");
} 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 {
_ = 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);
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.
.mount, .unmount => return fail(out),
}