diff --git a/build.zig b/build.zig index a478b0d..7f46488 100644 --- a/build.zig +++ b/build.zig @@ -638,6 +638,8 @@ pub fn build(b: *std.Build) void { "system/drivers/usb-hid/hid-report.zig", // HID boot-report keyboard/mouse decode "system/drivers/usb-storage/bulk-only-transport.zig", // CBW/CSW wrapper sizes "system/drivers/usb-storage/scsi.zig", // SCSI CDB encodings (big-endian) + "system/services/vfs/path.zig", // mount-prefix path matching + "system/services/vfs/protocol.zig", // NodeKind / DirectoryEntry sizes + op values }) |root| { const mod_tests = b.addTest(.{ .root_module = b.createModule(.{ diff --git a/library/posix/unistd.zig b/library/posix/unistd.zig index cca4721..897bad2 100644 --- a/library/posix/unistd.zig +++ b/library/posix/unistd.zig @@ -146,3 +146,61 @@ pub fn close(fd: i32) void { _ = transact(request, &.{}, &.{}); f.used = false; } + +/// Mount a filesystem backend (its server endpoint) at absolute path `target`; +/// the VFS then routes every path under `target` to that backend. Returns 0 or +/// -1. This is the one call that hands the VFS a capability (the backend). +pub fn mount(target: []const u8, backend: usize) i32 { + const h = vfs() orelse return -1; + const request = protocol.Request{ .operation = .mount, .node = 0, .offset = 0, .len = @intCast(target.len), .flags = 0 }; + var message: [protocol.message_maximum]u8 = undefined; + @memcpy(message[0..protocol.request_size], std.mem.asBytes(&request)); + const tlen = @min(target.len, protocol.maximum_payload); + @memcpy(message[protocol.request_size..][0..tlen], target[0..tlen]); + var rbuf: [protocol.message_maximum]u8 = undefined; + const result = ipc.callCap(h, message[0 .. protocol.request_size + tlen], &rbuf, backend) catch return -1; + if (result.len < protocol.reply_size) return -1; + return if (std.mem.bytesToValue(protocol.Reply, rbuf[0..protocol.reply_size]).status == 0) 0 else -1; +} + +/// A directory entry filled by `readdir`. +pub const DirEntry = struct { + kind: u32 = 0, // a protocol.NodeKind + size: u64 = 0, + name_buffer: [64]u8 = undefined, + name_len: usize = 0, + + pub fn name(self: *const DirEntry) []const u8 { + return self.name_buffer[0..self.name_len]; + } +}; + +/// Open a directory for reading with `readdir`. Returns an fd or -1. +pub fn opendir(path: []const u8) i32 { + return open(path, protocol.directory); +} + +/// Read the next entry of a directory fd into `entry`; returns false at EOF or on +/// error. Advances the fd's cursor by one entry. +pub fn readdir(fd: i32, entry: *DirEntry) bool { + const f = fdPtr(fd) orelse return false; + const request = protocol.Request{ .operation = .readdir, .node = f.node, .offset = f.offset, .len = 0, .flags = 0 }; + var buffer: [protocol.message_maximum]u8 = undefined; + const r = transact(request, &.{}, &buffer) orelse return false; + if (r.reply.status != 0 or r.reply.len == 0) return false; // error or EOF + if (r.payload.len < protocol.directory_entry_size) return false; + const header = std.mem.bytesToValue(protocol.DirectoryEntry, r.payload[0..protocol.directory_entry_size]); + entry.kind = header.kind; + entry.size = header.size; + const source = r.payload[protocol.directory_entry_size..]; + const nlen = @min(@min(@as(usize, header.name_len), source.len), entry.name_buffer.len); + @memcpy(entry.name_buffer[0..nlen], source[0..nlen]); + entry.name_len = nlen; + f.offset += 1; + return true; +} + +/// Close a directory fd (same as `close`). +pub fn closedir(fd: i32) void { + close(fd); +} diff --git a/system/services/vfs/path.zig b/system/services/vfs/path.zig new file mode 100644 index 0000000..028de25 --- /dev/null +++ b/system/services/vfs/path.zig @@ -0,0 +1,39 @@ +//! Pure path utilities for the VFS mount router — no IPC, no state, so they are +//! host-testable in isolation. The router uses these to decide whether an opened +//! path lies under a mount point and, if so, what it looks like relative to that +//! mount. + +const std = @import("std"); + +/// If `path` lies under `mount_prefix` — equal to it, or the prefix followed by a +/// path separator — return the path relative to the mount ("/" for an exact +/// match, otherwise the tail beginning with '/'). Returns null when `path` is not +/// under the mount, so a prefix like "/mnt/usb" never captures "/mnt/usbextra". +pub fn underMount(path: []const u8, mount_prefix: []const u8) ?[]const u8 { + if (path.len < mount_prefix.len) return null; + if (!std.mem.eql(u8, path[0..mount_prefix.len], mount_prefix)) return null; + if (path.len == mount_prefix.len) return "/"; + if (path[mount_prefix.len] != '/') return null; + return path[mount_prefix.len..]; +} + +/// Whether `path` is absolute (rooted at '/'). Bare names — what the flat ramfs +/// uses — are relative and never route through a mount. +pub fn isAbsolute(path: []const u8) bool { + return path.len > 0 and path[0] == '/'; +} + +test "underMount matches only at path boundaries" { + try std.testing.expectEqualStrings("/", underMount("/mnt/usb", "/mnt/usb").?); + try std.testing.expectEqualStrings("/system/kernel", underMount("/mnt/usb/system/kernel", "/mnt/usb").?); + try std.testing.expect(underMount("/mnt/usbextra", "/mnt/usb") == null); // not a boundary + try std.testing.expect(underMount("/mnt", "/mnt/usb") == null); // shorter than the prefix + try std.testing.expect(underMount("/other", "/mnt/usb") == null); + try std.testing.expect(underMount("greeting", "/mnt/usb") == null); // a bare name +} + +test "isAbsolute distinguishes paths from bare names" { + try std.testing.expect(isAbsolute("/mnt/usb")); + try std.testing.expect(!isAbsolute("greeting")); + try std.testing.expect(!isAbsolute("")); +} diff --git a/system/services/vfs/protocol.zig b/system/services/vfs/protocol.zig index c2ce854..61b34ec 100644 --- a/system/services/vfs/protocol.zig +++ b/system/services/vfs/protocol.zig @@ -17,8 +17,36 @@ pub const Operation = enum(u32) { read, // read(node, offset, len) -> bytes write, // write(node, offset, bytes) -> count status, // status(node) -> FileStatus + // Appended for the mount router (M5). Values stay stable, so existing clients + // and the flat-ramfs tests are unaffected. + readdir, // readdir(dir_node, cursor=offset) -> one DirectoryEntry (len==0 => EOF) + mount, // mount(prefix payload, capability = backend endpoint) + unmount, // unmount(prefix payload) }; +/// The type of a filesystem node, aligned to the FSH file-type table +/// (docs/danos-file-system-hierarchy-FSH.md). Fills `FileStatus.kind` and +/// `DirectoryEntry.kind`; `regular = 0` keeps the historical hardcoded value. +pub const NodeKind = enum(u32) { + regular = 0, + directory = 1, + character_device = 2, + block_device = 3, + symbolic_link = 4, + fifo = 5, + socket = 6, +}; + +/// One directory entry, returned by `readdir`: a fixed header followed inline in +/// the reply payload by `name_len` bytes of name. A zero-length reply is EOF. +pub const DirectoryEntry = extern struct { + kind: u32, // a NodeKind + name_len: u32, + size: u64, +}; + +pub const directory_entry_size: usize = @sizeOf(DirectoryEntry); + /// Request header. `node` is the server-side open-file id (from a prior open); /// for `open` the path is the payload and `len` is its length. `offset`/`len` /// carry the read/write position and count. @@ -57,3 +85,17 @@ pub const maximum_payload: usize = message_maximum - request_size; /// Open flags (danos-native; the POSIX layer maps `O_CREAT` onto `create`). pub const create: u32 = 1; +/// Open a directory (for readdir) rather than a file. A mounted backend uses +/// this to open a directory node; the flat ramfs ignores it. +pub const directory: u32 = 2; + +test "protocol struct sizes and node kinds" { + const std = @import("std"); + try std.testing.expectEqual(@as(u32, 0), @intFromEnum(NodeKind.regular)); + try std.testing.expectEqual(@as(u32, 1), @intFromEnum(NodeKind.directory)); + try std.testing.expectEqual(@as(usize, 16), @sizeOf(DirectoryEntry)); + // The appended operations keep the original values. + try std.testing.expectEqual(@as(u32, 0), @intFromEnum(Operation.open)); + try std.testing.expectEqual(@as(u32, 4), @intFromEnum(Operation.status)); + try std.testing.expectEqual(@as(u32, 5), @intFromEnum(Operation.readdir)); +} diff --git a/system/services/vfs/vfs.zig b/system/services/vfs/vfs.zig index 4a63d78..0bea186 100644 --- a/system/services/vfs/vfs.zig +++ b/system/services/vfs/vfs.zig @@ -3,14 +3,24 @@ //! file API marshals open/read/write/stat/close into calls to this server's //! endpoint, published under the well-known `vfs` service id). //! -//! For now the namespace is a small in-memory ramfs (opening a name creates it): -//! enough to prove the whole path — client file API -> IPC -> server dispatch -> -//! reply. Device nodes backed by user-space drivers (/device) layer on top in M10, -//! where `open` on a /device name forwards to the owning driver's endpoint. +//! Two namespaces meet here (M5): +//! - a small in-memory **ramfs** — opening a bare name creates it — enough to +//! prove the round trip and to back the existing tests; +//! - **mounted filesystems**: a mount table maps an absolute path prefix (e.g. +//! `/mnt/usb`) to a backend server's endpoint. An open of a path under a mount +//! is *forwarded* to that backend (which speaks this same protocol), and every +//! later read/write/status/readdir/close on the resulting handle is relayed to +//! it. The VFS is the router; a filesystem (FAT) is the backend. +//! +//! A path routes through a mount only when it is absolute and lies under a mount +//! prefix; bare names always resolve in the flat ramfs — the backward-compat +//! contract the `vfs` / `vfs-client-death` tests rely on. const std = @import("std"); const runtime = @import("runtime"); const protocol = runtime.vfs_protocol; +const path = @import("path.zig"); +const ipc = runtime.ipc; const Node = struct { used: bool = false, @@ -22,15 +32,29 @@ const Node = struct { const OpenFile = struct { used: bool = false, + // For a local handle: an index into `nodes`. For a forwarding handle: the + // node id the backend returned. (usize == u64 here, so it holds either.) node: usize = 0, + // Non-null for a handle that forwards to a mounted backend. + backend: ?ipc.Handle = null, // The client (task id — an IPC badge is one) that opened this handle. What // release-on-death sweeps by: a service must never depend on its clients // cleaning up after themselves (docs/process-lifecycle.md). owner: u32 = 0, }; +// One mounted filesystem: an absolute path prefix and the backend endpoint that +// serves everything under it. +const Mount = struct { + used: bool = false, + prefix: [64]u8 = undefined, + prefix_len: usize = 0, + backend: ipc.Handle = 0, +}; + var nodes = [_]Node{.{}} ** 8; var opens = [_]OpenFile{.{}} ** 16; +var mounts = [_]Mount{.{}} ** 8; fn findNode(name: []const u8) ?usize { for (&nodes, 0..) |*n, i| { @@ -57,6 +81,26 @@ fn openAt(id: u64) ?*OpenFile { return if (o.used) o else null; } +/// The mount whose prefix most specifically contains `name`, and the path +/// relative to it. Only absolute paths route; bare names never match. +const MountMatch = struct { backend: ipc.Handle, relative: []const u8 }; +fn longestMount(name: []const u8) ?MountMatch { + if (!path.isAbsolute(name)) return null; + var best: ?MountMatch = null; + var best_len: usize = 0; + for (&mounts) |*m| { + if (!m.used) continue; + const prefix = m.prefix[0..m.prefix_len]; + if (path.underMount(name, prefix)) |relative| { + if (best == null or prefix.len >= best_len) { + best_len = prefix.len; + best = .{ .backend = m.backend, .relative = relative }; + } + } + } + return best; +} + /// Serialise a reply header + payload into `out`; returns the total length. fn writeReply(out: []u8, reply: protocol.Reply, payload: []const u8) usize { @memcpy(out[0..protocol.reply_size], std.mem.asBytes(&reply)); @@ -76,13 +120,96 @@ fn writeLine(comptime fmt: []const u8, arguments: anytype) void { _ = runtime.system.write(std.fmt.bufPrint(&line, fmt, arguments) catch return); } +// --- mount routing ---------------------------------------------------------- + +/// Forward an open under a mount to its backend and, on success, allocate a local +/// forwarding handle that remembers the backend's node id. +fn forwardOpen(out: []u8, backend: ipc.Handle, relative: []const u8, flags: u32, sender: u32) usize { + const request = protocol.Request{ .operation = .open, .node = 0, .offset = 0, .len = @intCast(relative.len), .flags = flags }; + 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); + if (n < protocol.reply_size) return fail(out); + const backend_reply = std.mem.bytesToValue(protocol.Reply, reply[0..protocol.reply_size]); + if (backend_reply.status != 0) return writeReply(out, .{ .status = backend_reply.status }, &.{}); + + for (&opens, 0..) |*o, i| { + if (!o.used) { + o.* = .{ .used = true, .node = @intCast(backend_reply.node), .backend = backend, .owner = sender }; + return writeReply(out, .{ .status = 0, .node = i }, &.{}); + } + } + return fail(out); +} + +/// Relay a read/write/status/readdir/close on a forwarding handle to the backend +/// (the node already rewritten to the backend's id) and copy its reply out. +fn forwardRequest(out: []u8, backend: ipc.Handle, request: protocol.Request, payload: []const u8) usize { + var message: [protocol.message_maximum]u8 = undefined; + @memcpy(message[0..protocol.request_size], std.mem.asBytes(&request)); + const plen = @min(payload.len, protocol.maximum_payload); + @memcpy(message[protocol.request_size..][0..plen], payload[0..plen]); + + var reply: [protocol.message_maximum]u8 = undefined; + const n = ipc.call(backend, message[0 .. protocol.request_size + plen], &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 { + const request = protocol.Request{ .operation = .close, .node = backend_node, .offset = 0, .len = 0, .flags = 0 }; + var reply: [protocol.message_maximum]u8 = undefined; + _ = ipc.call(backend, std.mem.asBytes(&request), &reply) catch {}; +} + +fn doMount(out: []u8, prefix: []const u8, backend: ipc.Handle) usize { + for (&mounts) |*m| { + if (m.used and std.mem.eql(u8, m.prefix[0..m.prefix_len], prefix)) { + m.backend = backend; + writeLine("/system/services/vfs: remounted {s}\n", .{prefix}); + return writeReply(out, .{ .status = 0 }, &.{}); + } + } + for (&mounts) |*m| { + if (!m.used) { + const l = @min(prefix.len, m.prefix.len); + m.used = true; + @memcpy(m.prefix[0..l], prefix[0..l]); + m.prefix_len = l; + m.backend = backend; + writeLine("/system/services/vfs: mounted {s}\n", .{prefix[0..l]}); + return writeReply(out, .{ .status = 0 }, &.{}); + } + } + return fail(out); +} + +fn doUnmount(out: []u8, prefix: []const u8) usize { + for (&mounts) |*m| { + if (m.used and std.mem.eql(u8, m.prefix[0..m.prefix_len], prefix)) { + m.used = false; + writeLine("/system/services/vfs: unmounted {s}\n", .{prefix}); + return writeReply(out, .{ .status = 0 }, &.{}); + } + } + return fail(out); +} + /// Release every open handle `client` held — called on that client's published -/// exit event. The nodes (the files) stay: ramfs contents outlive their writers, -/// only the dead client's handles go. +/// exit event. Forwarding handles also tell their backend to release; local +/// nodes (the ramfs files) stay, since ramfs contents outlive their writers. fn releaseClientHandles(client: u32) void { var released: u32 = 0; for (&opens) |*o| { if (o.used and o.owner == client) { + if (o.backend) |backend| forwardClose(backend, o.node); o.used = false; released += 1; } @@ -91,19 +218,28 @@ fn releaseClientHandles(client: u32) void { } /// Handle one request from `sender`; write the reply into `out`, return its length. -fn handle(message: []const u8, out: []u8, sender: u32, capability: ?runtime.ipc.Handle) usize { - _ = capability; +fn handle(message: []const u8, out: []u8, sender: u32, capability: ?ipc.Handle) usize { if (message.len < protocol.request_size) return fail(out); const request = std.mem.bytesToValue(protocol.Request, message[0..protocol.request_size]); const payload = message[protocol.request_size..]; switch (request.operation) { + .mount => { + const prefix = payload[0..@min(payload.len, request.len)]; + const backend = capability orelse return fail(out); + return doMount(out, prefix, backend); + }, + .unmount => { + const prefix = payload[0..@min(payload.len, request.len)]; + return doUnmount(out, prefix); + }, .open => { const name = payload[0..@min(payload.len, request.len)]; + if (longestMount(name)) |m| return forwardOpen(out, m.backend, m.relative, request.flags, sender); const ni = findNode(name) orelse createNode(name) orelse return fail(out); for (&opens, 0..) |*o, i| { if (!o.used) { - o.* = .{ .used = true, .node = ni, .owner = sender }; + o.* = .{ .used = true, .node = ni, .backend = null, .owner = sender }; return writeReply(out, .{ .status = 0, .node = i }, &.{}); } } @@ -111,7 +247,12 @@ fn handle(message: []const u8, out: []u8, sender: u32, capability: ?runtime.ipc. }, .read => { const of = openAt(request.node) orelse return fail(out); - const nd = &nodes[of.node]; + if (of.backend) |backend| { + var forwarded = request; + forwarded.node = of.node; + return forwardRequest(out, backend, forwarded, payload); + } + const nd = &nodes[@intCast(of.node)]; const off: usize = @intCast(request.offset); if (off >= nd.size) return writeReply(out, .{ .status = 0, .len = 0 }, &.{}); // EOF const n = @min(@min(nd.size - off, request.len), protocol.maximum_payload); @@ -119,7 +260,12 @@ fn handle(message: []const u8, out: []u8, sender: u32, capability: ?runtime.ipc. }, .write => { const of = openAt(request.node) orelse return fail(out); - const nd = &nodes[of.node]; + if (of.backend) |backend| { + var forwarded = request; + forwarded.node = of.node; + return forwardRequest(out, backend, forwarded, payload); + } + const nd = &nodes[@intCast(of.node)]; const off: usize = @intCast(request.offset); if (off > nd.data.len) return fail(out); const n = @min(@min(payload.len, request.len), nd.data.len - off); @@ -129,11 +275,30 @@ fn handle(message: []const u8, out: []u8, sender: u32, capability: ?runtime.ipc. }, .status => { const of = openAt(request.node) orelse return fail(out); - const st = protocol.FileStatus{ .size = nodes[of.node].size, .kind = 0 }; + if (of.backend) |backend| { + var forwarded = request; + forwarded.node = of.node; + return forwardRequest(out, backend, forwarded, payload); + } + const st = protocol.FileStatus{ .size = nodes[@intCast(of.node)].size, .kind = @intFromEnum(protocol.NodeKind.regular) }; return writeReply(out, .{ .status = 0, .len = @sizeOf(protocol.FileStatus) }, std.mem.asBytes(&st)); }, + .readdir => { + const of = openAt(request.node) orelse return fail(out); + if (of.backend) |backend| { + var forwarded = request; + forwarded.node = of.node; + return forwardRequest(out, backend, forwarded, payload); + } + // The flat ramfs has no directories: report EOF. + return writeReply(out, .{ .status = 0, .len = 0 }, &.{}); + }, .close => { - if (request.node < opens.len) opens[@intCast(request.node)].used = false; + const of = openAt(request.node); + if (of) |o| { + if (o.backend) |backend| forwardClose(backend, o.node); + o.used = false; + } return writeReply(out, .{ .status = 0 }, &.{}); }, } @@ -142,7 +307,7 @@ fn handle(message: []const u8, out: []u8, sender: u32, capability: ?runtime.ipc. /// Startup, under the harness: subscribe to the published exit events — when a /// client dies holding open handles, the exit notification is how the VFS learns /// to release them (docs/process-lifecycle.md). -fn initialise(endpoint: runtime.ipc.Handle) bool { +fn initialise(endpoint: ipc.Handle) bool { if (!runtime.process.subscribeExits(endpoint)) { _ = runtime.system.write("/system/services/vfs: exit subscription failed\n"); } @@ -152,8 +317,8 @@ fn initialise(endpoint: runtime.ipc.Handle) bool { /// A non-signal notification: the only kind the VFS subscribes to is exit events. fn onNotification(badge: u64) void { - if (badge & runtime.ipc.notify_exit_bit != 0) { - releaseClientHandles(@intCast(badge & ~(runtime.ipc.notify_badge_bit | runtime.ipc.notify_exit_bit))); + if (badge & ipc.notify_exit_bit != 0) { + releaseClientHandles(@intCast(badge & ~(ipc.notify_badge_bit | ipc.notify_exit_bit))); } }