// vfs: dispatch layer keyed off a 1-bit superblock tag. // // The shape is deliberately small: a two-slot fixed mount // table, prefix-based path dispatch, one vtable per backend. No // inode cache, no dentry cache, no path normalization, no // sys_mount. Future work revisits when it needs `..` and relative // paths, caches, and mode bits. // // Mount layout (locked in DOCUMENTATION.md §3): initramfs is `/`, // FAT32 mounts at `/mnt`. Dispatch is "starts-with `/mnt/`" -> // FAT32 slot, anything else -> initramfs slot. // // The vtable carries a single `open` entry; the separate // open_fn/open_out pair was unused and removed. const std = @import("std"); const builtin = @import("builtin"); const file_mod = @import("file"); const defs = @import("syscall_defs"); pub const File = file_mod.File; // readdir ABI surface, re-exported from the shared ABI file (its // canonical home) so the vtable signature, the backends, and the host // tests all name one type — same pattern as SuperBlock / OpenResult // living here. pub const Dirent = defs.Dirent; pub const DT_REG = defs.DT_REG; pub const DT_DIR = defs.DT_DIR; // 1-bit superblock tag. enum(u8) (not enum(u1)) so it drops straight // into SuperBlock's extern-struct `fs_type: u8` byte; non-exhaustive // so a future backend id doesn't force a parser change here. pub const FsType = enum(u8) { INITRAMFS = 0, FAT32 = 1, _, }; // Per-mount state. `private` is backend-owned (initramfs ignores it; // FAT32 stashes its volume-descriptor pointer there). // `ops` is the dispatch vtable — null until the backend's init() // wires it. pub const SuperBlock = extern struct { fs_type: u8, _pad: [7]u8 = .{0} ** 7, private: u64 = 0, ops: ?*const VfsOps = null, }; // What a backend's open hands back: enough to populate File.private + // File.size. For initramfs: private = KVA pointer to the entry's data // bytes, size = entry.data.len. For FAT32: private = packed // (first_cluster | cluster_count << 32), size = the dir-entry's size. // extern struct because it crosses the callconv(.c) vtable boundary // by pointer. pub const OpenResult = extern struct { private: u64 = 0, size: u64 = 0, // Per-file permission metadata. Backends fill these at // open; the syscall layer copies them into the File and gates // access on the caller's effective ids. The 0 defaults mean // "root-owned, no permission bits" — a backend that never sets // them denies every non-root access, which is the safe direction. // Appended last so the extern-struct layout of the older fields // (and the callconv(.c) vtable ABI) stays byte-identical. mode: u32 = 0, uid: u32 = 0, gid: u32 = 0, // On-disk directory-entry location of the opened file, for backends // that rewrite the entry on write (FAT32: giving a previously empty // file its first data cluster, and growing file_size). dirent_off is // the byte offset of the entry within the dirent_lba sector. Appended // last so the extern layout of the older fields stays byte-identical; // 0 = unset (read-only backends never rewrite, so they leave these 0). dirent_lba: u32 = 0, dirent_off: u32 = 0, }; // Backend vtable. All entries are C-ABI function pointers so the // indirect call site has a fixed, objdump-inspectable convention — // a future unified ?*File table will reuse the same shape. pub const VfsOps = extern struct { // open: resolve `path` (already mount-prefix-stripped) against the // backend. Returns 0 and fills `out` on hit; -1 on miss-or-error // (the caller decides what a miss means — sys_openFile maps it to // a -1 fd, [TEST] vfs-dispatch to a failed scenario). The path // crosses as ptr+len, not a slice: callconv(.c) forbids slice // params (no guaranteed in-memory representation). open: *const fn (sb: *SuperBlock, path_ptr: [*]const u8, path_len: usize, out: *OpenResult) callconv(.c) c_int, // read: copy up to `len` bytes from `f`'s current offset into // `buf`. Returns bytes copied, 0 on EOF, -1 on error. Advances // f.offset. read: *const fn (sb: *SuperBlock, f: *File, buf: [*]u8, len: u64) callconv(.c) i64, // seek: validate the target against f.size + the backend's // seekability. Returns the new absolute offset, -1 on a bad // whence or an out-of-range target. seek: *const fn (sb: *SuperBlock, f: *File, off: i64, whence: i32) callconv(.c) i64, // close: backend cleanup hook. Most backends are no-ops — the File // page lifetime is owned by file.zig's refcount, not the backend. close: *const fn (sb: *SuperBlock, f: *File) callconv(.c) void, // write: copy up to `len` bytes from `buf` into `f` at f.offset. // Returns bytes written, -1 on error (EROFS, ENOSPC, bad fd). Read- // only backends (initramfs) return -1 unconditionally. Advances // f.offset on partial-or-full success. write: *const fn (sb: *SuperBlock, f: *File, buf: [*]const u8, len: u64) callconv(.c) i64, // readdir: fill `out` with the `index`-th entry of the directory at // `path` (already mount-prefix-stripped). Returns 0 on a hit, -1 at // end-of-directory or on a bad path. Stateless — the caller passes // a fresh index each call, so there is no fd cursor and no per-open // allocation. initramfs synthesises directories from path prefixes; // FAT32 renders 8.3 root entries. Path crosses as ptr+len for the // same callconv(.c) reason as open. Defaults to the empty-directory // sentinel so a backend that does not enumerate (or has not wired it // yet — readdir support lands per backend) is // safely non-enumerable rather than a null call. readdir: *const fn (sb: *SuperBlock, path_ptr: [*]const u8, path_len: usize, index: u64, out: *Dirent) callconv(.c) c_int = defaultReaddir, // create: make a new empty entry at `path` (already mount-prefix- // stripped) and fill `out` as open does, but for a *writable* handle — // dirent_lba/off point at the fresh entry so a following write grows it. // Returns 0 on success, -1 on exists / bad-name / no-space / EROFS. Path // crosses as ptr+len for the same callconv(.c) reason as open. Defaults to // the EROFS stub so a read-only backend (initramfs) is non-destructive // until it wires a real impl. Appended after readdir so the extern-struct // VfsOps ABI of the existing slots stays byte-identical. create: *const fn (sb: *SuperBlock, path_ptr: [*]const u8, path_len: usize, out: *OpenResult) callconv(.c) c_int = defaultCreate, // unlink: remove the entry at `path` (tombstone its directory entry and // free its cluster chain). Returns 0 on success, -1 on missing / EROFS. unlink: *const fn (sb: *SuperBlock, path_ptr: [*]const u8, path_len: usize) callconv(.c) c_int = defaultUnlink, // rename: rename `old` to `new` within the *same* directory (an in-place // 8.3 name rewrite, no data move). Returns 0 on success, -1 on missing / // bad-name / target-exists / EROFS. The vfs_rename wrapper rejects a // cross-superblock rename before dispatch, so a backend only ever sees // same-mount pairs. rename: *const fn (sb: *SuperBlock, old_ptr: [*]const u8, old_len: usize, new_ptr: [*]const u8, new_len: usize) callconv(.c) c_int = defaultRename, }; // Empty-directory readdir: returns the end sentinel (-1) at every // index. The default for the VfsOps.readdir field above; backends // override it with a real walk. fn defaultReaddir(_: *SuperBlock, _: [*]const u8, _: usize, _: u64, _: *Dirent) callconv(.c) c_int { return -1; } // EROFS defaults for the write-side vtable slots: a backend that has not // wired create/unlink/rename (initramfs, or any future read-only mount) // fails the mutation closed (-1) rather than dispatching through a null // pointer. Same safe-direction posture as defaultReaddir. fn defaultCreate(_: *SuperBlock, _: [*]const u8, _: usize, _: *OpenResult) callconv(.c) c_int { return -1; } fn defaultUnlink(_: *SuperBlock, _: [*]const u8, _: usize) callconv(.c) c_int { return -1; } fn defaultRename(_: *SuperBlock, _: [*]const u8, _: usize, _: [*]const u8, _: usize) callconv(.c) c_int { return -1; } // Two-slot fixed mount table. Slot 0 = root (initramfs), slot 1 = // /mnt (FAT32). A future sys_mount generalises this to N slots with // a registered-prefix list; until then the two prefixes are hard- // coded so the syscall hot path stays a single startsWith branch. pub var mount_table: [2]?*SuperBlock = .{ null, null }; // FAT32 mount prefix. The trailing slash is load-bearing: it makes // `/mnt/foo` (FAT32) and `/mnt2/foo` (initramfs) unambiguously // different, and `/mnt` with no slash stays an initramfs path. // // FIXME: when fsh grows path normalization (collapse `//`, // strip a trailing `/`), this startsWith match becomes brittle — // switch to a per-segment compare walking the path one `/` at a time, // the same algorithm Linux's vfs path walk uses. const MNT_PREFIX = "/mnt/"; // Byte-wise prefix compare against MNT_PREFIX. Forwards to // utilc.mem_eql_bytes; see that helper for the strict-alignment // rationale. extern fn mem_eql_bytes(a: [*]const u8, b: [*]const u8, n: u64) bool; fn hasMntPrefix(path: []const u8) bool { if (path.len < MNT_PREFIX.len) return false; return mem_eql_bytes(MNT_PREFIX.ptr, path.ptr, MNT_PREFIX.len); } // Kernel high-mem (TTBR1) alias base — same constant as src/sys.zig's // sys_call_table_relocate. const LINEAR_MAP_BASE: u64 = 0xFFFF000000000000; // Re-point a backend's vtable entries to their high-mem (TTBR1) // aliases. The file syscalls run at EL1 with TTBR0 holding the *user* // pgd; an indirect `blr` through a low link-address vtable entry // instruction-aborts because the user pgd does not map kernel low // memory. Mirrors sys_call_table_relocate in src/sys.zig. `| BASE` is // idempotent, so a double call is harmless. No-op on host builds // (no TTBR split). Each backend's init() calls this on its vtable // before registering the mount. pub fn relocateOps(ops: *VfsOps) void { if (comptime builtin.target.os.tag != .freestanding) return; ops.open = @ptrFromInt(@intFromPtr(ops.open) | LINEAR_MAP_BASE); ops.read = @ptrFromInt(@intFromPtr(ops.read) | LINEAR_MAP_BASE); ops.seek = @ptrFromInt(@intFromPtr(ops.seek) | LINEAR_MAP_BASE); ops.close = @ptrFromInt(@intFromPtr(ops.close) | LINEAR_MAP_BASE); ops.write = @ptrFromInt(@intFromPtr(ops.write) | LINEAR_MAP_BASE); ops.readdir = @ptrFromInt(@intFromPtr(ops.readdir) | LINEAR_MAP_BASE); ops.create = @ptrFromInt(@intFromPtr(ops.create) | LINEAR_MAP_BASE); ops.unlink = @ptrFromInt(@intFromPtr(ops.unlink) | LINEAR_MAP_BASE); ops.rename = @ptrFromInt(@intFromPtr(ops.rename) | LINEAR_MAP_BASE); } // Wire a superblock into the root (initramfs) slot. Called from the // backend's init() at kernel bring-up. pub fn register_initramfs(sb: *SuperBlock) void { sb.fs_type = @intFromEnum(FsType.INITRAMFS); mount_table[0] = sb; } // Wire a superblock into the /mnt (FAT32) slot. pub fn register_fat32(sb: *SuperBlock) void { sb.fs_type = @intFromEnum(FsType.FAT32); mount_table[1] = sb; } // Path-to-superblock dispatch. Returns the matching SB plus the // residual path the backend should see: initramfs gets the full path; // FAT32 gets the path with `/mnt` stripped but the leading `/` kept, // so each backend keys off its own root. Returns null when the target // slot is unmounted. pub fn resolve(path: []const u8) ?struct { sb: *SuperBlock, sub_path: []const u8 } { if (hasMntPrefix(path)) { const sb = mount_table[1] orelse return null; return .{ .sb = sb, .sub_path = path[MNT_PREFIX.len - 1 ..] }; // keep leading '/' } const sb = mount_table[0] orelse return null; return .{ .sb = sb, .sub_path = path }; } // Resolve + dispatch to the backend's open. On hit returns the SB // (the caller stashes it in File.sb for later read/seek/close // dispatch) and fills `out`. Returns null on an unmounted slot, a // missing vtable, or a backend miss. pub fn vfs_open(path: []const u8, out: *OpenResult) ?*SuperBlock { const r = resolve(path) orelse return null; const ops = r.sb.ops orelse return null; if (ops.open(r.sb, r.sub_path.ptr, r.sub_path.len, out) < 0) return null; return r.sb; } pub fn vfs_read(sb: *SuperBlock, f: *File, buf: [*]u8, len: u64) i64 { const ops = sb.ops orelse return -1; return ops.read(sb, f, buf, len); } pub fn vfs_seek(sb: *SuperBlock, f: *File, off: i64, whence: i32) i64 { const ops = sb.ops orelse return -1; return ops.seek(sb, f, off, whence); } pub fn vfs_close(sb: *SuperBlock, f: *File) void { if (sb.ops) |ops| ops.close(sb, f); } pub fn vfs_write(sb: *SuperBlock, f: *File, buf: [*]const u8, len: u64) i64 { const ops = sb.ops orelse return -1; return ops.write(sb, f, buf, len); } // Resolve `path` to its backend and fill `out` with the `index`-th // directory entry. Returns 0 on a hit, -1 on an unmounted slot, a // missing vtable, a bad path, or end-of-directory. Stateless: unlike // vfs_open it installs no File — the caller owns the index walk. pub fn vfs_readdir(path: []const u8, index: u64, out: *Dirent) c_int { const r = resolve(path) orelse return -1; const ops = r.sb.ops orelse return -1; return ops.readdir(r.sb, r.sub_path.ptr, r.sub_path.len, index, out); } // Resolve + dispatch to the backend's create. Mirrors vfs_open: on hit // returns the SB (the caller stashes it in File.sb for later read/write/ // close dispatch) and fills `out` for the new writable handle. Returns // null on an unmounted slot, a missing vtable, or a backend failure // (exists / bad-name / no-space / EROFS). pub fn vfs_create(path: []const u8, out: *OpenResult) ?*SuperBlock { const r = resolve(path) orelse return null; const ops = r.sb.ops orelse return null; if (ops.create(r.sb, r.sub_path.ptr, r.sub_path.len, out) < 0) return null; return r.sb; } // Resolve + dispatch to the backend's unlink. Returns 0 on success, -1 on // an unmounted slot, a missing vtable, or a backend failure. pub fn vfs_unlink(path: []const u8) c_int { const r = resolve(path) orelse return -1; const ops = r.sb.ops orelse return -1; return ops.unlink(r.sb, r.sub_path.ptr, r.sub_path.len); } // Resolve both paths and dispatch to the backend's rename. A cross- // superblock rename is rejected here, before dispatch: an in-place 8.3 // name rewrite cannot move bytes between mounts, so a true cross-mount // move is the caller's copy+unlink job (mv's fallback), not the kernel's. // Returns 0 on success, -1 on an unmounted slot, a missing vtable, a // cross-mount pair, or a backend failure. pub fn vfs_rename(old: []const u8, new: []const u8) c_int { const ro = resolve(old) orelse return -1; const rn = resolve(new) orelse return -1; if (ro.sb != rn.sb) return -1; const ops = ro.sb.ops orelse return -1; return ops.rename(ro.sb, ro.sub_path.ptr, ro.sub_path.len, rn.sub_path.ptr, rn.sub_path.len); } // ---- Host tests ---- // // The VFS tests run against in-test SuperBlock fixtures and a fake // vtable — no real backend leaks into the link graph (see // tests/host_stubs_vfs.zig for why the stub file deliberately excludes // the initramfs/fat32 backends). const testing = std.testing; var fake_initramfs_sb: SuperBlock = .{ .fs_type = 0 }; var fake_fat32_sb: SuperBlock = .{ .fs_type = 1 }; fn resetMounts() void { mount_table[0] = null; mount_table[1] = null; fake_initramfs_sb = .{ .fs_type = 0 }; fake_fat32_sb = .{ .fs_type = 1 }; } test "resolve routes a /mnt/ prefix to slot 1, stripped to a leading slash" { resetMounts(); mount_table[0] = &fake_initramfs_sb; mount_table[1] = &fake_fat32_sb; const r = resolve("/mnt/foo") orelse return error.NotResolved; try testing.expectEqual(@as(*SuperBlock, &fake_fat32_sb), r.sb); try testing.expectEqualStrings("/foo", r.sub_path); } test "resolve routes a non-/mnt path to slot 0 with the full path" { resetMounts(); mount_table[0] = &fake_initramfs_sb; mount_table[1] = &fake_fat32_sb; const r = resolve("/sbin/init") orelse return error.NotResolved; try testing.expectEqual(@as(*SuperBlock, &fake_initramfs_sb), r.sb); try testing.expectEqualStrings("/sbin/init", r.sub_path); } test "resolve returns null when the target slot is empty" { resetMounts(); mount_table[1] = &fake_fat32_sb; // slot 0 deliberately left null try testing.expectEqual( @as(?*SuperBlock, null), if (resolve("/anything")) |r| r.sb else null, ); } test "resolve treats /mnt with no trailing slash as an initramfs path" { resetMounts(); mount_table[0] = &fake_initramfs_sb; mount_table[1] = &fake_fat32_sb; const r = resolve("/mnt") orelse return error.NotResolved; try testing.expectEqual(@as(*SuperBlock, &fake_initramfs_sb), r.sb); try testing.expectEqualStrings("/mnt", r.sub_path); } test "resolve treats /mnt2/... as an initramfs path (prefix needs the slash)" { resetMounts(); mount_table[0] = &fake_initramfs_sb; mount_table[1] = &fake_fat32_sb; const r = resolve("/mnt2/foo") orelse return error.NotResolved; try testing.expectEqual(@as(*SuperBlock, &fake_initramfs_sb), r.sb); try testing.expectEqualStrings("/mnt2/foo", r.sub_path); } // Fake backend: `open` echoes a fixed payload for "/hit", misses // otherwise; `read` returns f.private so the test can prove the // payload threaded through File. seek/close are inert. fn fakeOpen(_: *SuperBlock, path_ptr: [*]const u8, path_len: usize, out: *OpenResult) callconv(.c) c_int { const path = path_ptr[0..path_len]; if (std.mem.eql(u8, path, "/hit")) { out.private = 0xABCD; out.size = 7; out.mode = 0o100640; out.uid = 1; out.gid = 2; return 0; } return -1; } fn fakeRead(_: *SuperBlock, f: *File, _: [*]u8, _: u64) callconv(.c) i64 { return @bitCast(f.private); } fn fakeSeek(_: *SuperBlock, _: *File, _: i64, _: i32) callconv(.c) i64 { return -1; } fn fakeClose(_: *SuperBlock, _: *File) callconv(.c) void {} fn fakeWrite(_: *SuperBlock, f: *File, _: [*]const u8, _: u64) callconv(.c) i64 { return @bitCast(f.private); } // fakeReaddir: one synthetic entry (`bin`, a directory) at index 0 of // "/", miss otherwise — enough to prove the vtable dispatch threads the // path + index in and the Dirent out. fn fakeReaddir(_: *SuperBlock, path_ptr: [*]const u8, path_len: usize, index: u64, out: *Dirent) callconv(.c) c_int { const path = path_ptr[0..path_len]; if (std.mem.eql(u8, path, "/") and index == 0) { const name = "bin"; @memcpy(out.name[0..name.len], name); out.name[name.len] = 0; out.d_type = defs.DT_DIR; return 0; } return -1; } // fakeCreate: echo a fresh writable payload for "/new", miss otherwise — // proves vfs_create threads OpenResult (including the dirent location) back. fn fakeCreate(_: *SuperBlock, path_ptr: [*]const u8, path_len: usize, out: *OpenResult) callconv(.c) c_int { const path = path_ptr[0..path_len]; if (std.mem.eql(u8, path, "/new")) { out.private = 0; out.size = 0; out.mode = 0o100644; out.uid = 3; out.gid = 4; out.dirent_lba = 6; out.dirent_off = 64; return 0; } return -1; } // fakeUnlink: succeeds for "/gone", misses otherwise. fn fakeUnlink(_: *SuperBlock, path_ptr: [*]const u8, path_len: usize) callconv(.c) c_int { const path = path_ptr[0..path_len]; return if (std.mem.eql(u8, path, "/gone")) 0 else -1; } // fakeRename: succeeds for old "/a" -> new "/b", misses otherwise. fn fakeRename(_: *SuperBlock, old_ptr: [*]const u8, old_len: usize, new_ptr: [*]const u8, new_len: usize) callconv(.c) c_int { const old = old_ptr[0..old_len]; const new = new_ptr[0..new_len]; return if (std.mem.eql(u8, old, "/a") and std.mem.eql(u8, new, "/b")) 0 else -1; } const fake_ops: VfsOps = .{ .open = fakeOpen, .read = fakeRead, .seek = fakeSeek, .close = fakeClose, .write = fakeWrite, .readdir = fakeReaddir, .create = fakeCreate, .unlink = fakeUnlink, .rename = fakeRename, }; // A vtable that leaves the write-side slots at their EROFS defaults — proves // a backend that wires only the read path stays non-destructive. const readonly_ops: VfsOps = .{ .open = fakeOpen, .read = fakeRead, .seek = fakeSeek, .close = fakeClose, .write = fakeWrite, .readdir = fakeReaddir, }; test "vfs_open dispatches through the vtable and threads OpenResult back" { resetMounts(); fake_initramfs_sb.ops = &fake_ops; mount_table[0] = &fake_initramfs_sb; var out: OpenResult = .{}; const sb = vfs_open("/hit", &out) orelse return error.NotResolved; try testing.expectEqual(@as(*SuperBlock, &fake_initramfs_sb), sb); try testing.expectEqual(@as(u64, 0xABCD), out.private); try testing.expectEqual(@as(u64, 7), out.size); // The permission metadata threads through the same vtable. try testing.expectEqual(@as(u32, 0o100640), out.mode); try testing.expectEqual(@as(u32, 1), out.uid); try testing.expectEqual(@as(u32, 2), out.gid); // A backend miss routes to the same SB but resolves to null. var out_miss: OpenResult = .{}; try testing.expectEqual(@as(?*SuperBlock, null), vfs_open("/miss", &out_miss)); } test "vfs_open returns null when the resolved SB has no vtable" { resetMounts(); fake_initramfs_sb.ops = null; mount_table[0] = &fake_initramfs_sb; var out: OpenResult = .{}; try testing.expectEqual(@as(?*SuperBlock, null), vfs_open("/anything", &out)); } test "vfs_read threads File.private through the backend vtable" { resetMounts(); fake_initramfs_sb.ops = &fake_ops; var f: File = .{}; f.private = 0x1234; try testing.expectEqual(@as(i64, 0x1234), vfs_read(&fake_initramfs_sb, &f, undefined, 0)); } test "vfs_read / vfs_seek return -1 when the SB has no vtable" { resetMounts(); fake_initramfs_sb.ops = null; var f: File = .{}; try testing.expectEqual(@as(i64, -1), vfs_read(&fake_initramfs_sb, &f, undefined, 0)); try testing.expectEqual(@as(i64, -1), vfs_seek(&fake_initramfs_sb, &f, 0, 0)); } test "vfs_write threads File.private through the backend vtable" { resetMounts(); fake_initramfs_sb.ops = &fake_ops; var f: File = .{}; f.private = 0x5678; try testing.expectEqual(@as(i64, 0x5678), vfs_write(&fake_initramfs_sb, &f, undefined, 0)); } test "vfs_write returns -1 when the SB has no vtable" { resetMounts(); fake_initramfs_sb.ops = null; var f: File = .{}; try testing.expectEqual(@as(i64, -1), vfs_write(&fake_initramfs_sb, &f, undefined, 0)); } test "vfs_readdir dispatches through the vtable and fills the Dirent" { resetMounts(); fake_initramfs_sb.ops = &fake_ops; mount_table[0] = &fake_initramfs_sb; var d: Dirent = .{}; try testing.expectEqual(@as(c_int, 0), vfs_readdir("/", 0, &d)); try testing.expectEqualStrings("bin", std.mem.sliceTo(&d.name, 0)); try testing.expectEqual(@as(u8, defs.DT_DIR), d.d_type); // Past the last entry returns the end sentinel. try testing.expectEqual(@as(c_int, -1), vfs_readdir("/", 1, &d)); } test "vfs_readdir returns -1 when the resolved SB has no vtable" { resetMounts(); fake_initramfs_sb.ops = null; mount_table[0] = &fake_initramfs_sb; var d: Dirent = .{}; try testing.expectEqual(@as(c_int, -1), vfs_readdir("/anything", 0, &d)); } test "vfs_create dispatches through the vtable and threads OpenResult back" { resetMounts(); fake_initramfs_sb.ops = &fake_ops; mount_table[0] = &fake_initramfs_sb; var out: OpenResult = .{}; const sb = vfs_create("/new", &out) orelse return error.NotCreated; try testing.expectEqual(@as(*SuperBlock, &fake_initramfs_sb), sb); try testing.expectEqual(@as(u32, 0o100644), out.mode); try testing.expectEqual(@as(u32, 3), out.uid); try testing.expectEqual(@as(u32, 4), out.gid); // The dirent location threads through so a following write can grow it. try testing.expectEqual(@as(u32, 6), out.dirent_lba); try testing.expectEqual(@as(u32, 64), out.dirent_off); // A backend failure routes to the same SB but resolves to null. var out_miss: OpenResult = .{}; try testing.expectEqual(@as(?*SuperBlock, null), vfs_create("/miss", &out_miss)); } test "vfs_create returns null when the resolved SB has no vtable" { resetMounts(); fake_initramfs_sb.ops = null; mount_table[0] = &fake_initramfs_sb; var out: OpenResult = .{}; try testing.expectEqual(@as(?*SuperBlock, null), vfs_create("/new", &out)); } test "vfs_unlink dispatches through the vtable" { resetMounts(); fake_initramfs_sb.ops = &fake_ops; mount_table[0] = &fake_initramfs_sb; try testing.expectEqual(@as(c_int, 0), vfs_unlink("/gone")); try testing.expectEqual(@as(c_int, -1), vfs_unlink("/still-here")); } test "vfs_rename dispatches a same-mount pair" { resetMounts(); fake_initramfs_sb.ops = &fake_ops; mount_table[0] = &fake_initramfs_sb; try testing.expectEqual(@as(c_int, 0), vfs_rename("/a", "/b")); try testing.expectEqual(@as(c_int, -1), vfs_rename("/a", "/c")); } test "vfs_rename rejects a cross-superblock pair before dispatch" { resetMounts(); fake_initramfs_sb.ops = &fake_ops; fake_fat32_sb.ops = &fake_ops; mount_table[0] = &fake_initramfs_sb; mount_table[1] = &fake_fat32_sb; // old resolves to slot 0 (initramfs), new to slot 1 (FAT32) — a true move // across mounts, which an in-place rename cannot do: must fail closed. try testing.expectEqual(@as(c_int, -1), vfs_rename("/a", "/mnt/b")); } test "write-side vtable slots default to EROFS (-1)" { resetMounts(); fake_initramfs_sb.ops = &readonly_ops; mount_table[0] = &fake_initramfs_sb; var out: OpenResult = .{}; // A backend that wires only the read path leaves create/unlink/rename at // their EROFS defaults — every mutation fails closed. try testing.expectEqual(@as(?*SuperBlock, null), vfs_create("/new", &out)); try testing.expectEqual(@as(c_int, -1), vfs_unlink("/gone")); try testing.expectEqual(@as(c_int, -1), vfs_rename("/a", "/b")); }