320 lines
15 KiB
Zig
320 lines
15 KiB
Zig
//! system/services/fat — the FAT filesystem server. Spawned as a boot service, it
|
|
//! opens the block device (a USB stick via usb-storage) under `.block`, mounts the
|
|
//! FAT filesystem on it (the pure engine in engine.zig), and mounts itself into
|
|
//! the VFS at /volumes/usb. From then on the VFS forwards every open/read/write/
|
|
//! status/readdir/close under /volumes/usb to this server, which serves the same
|
|
//! vfs-protocol as a backend — turning block reads into file reads.
|
|
//!
|
|
//! The block data path never crosses IPC: a DMA bounce buffer is handed to the
|
|
//! block driver by physical address, and the engine copies sectors in and out of
|
|
//! it.
|
|
|
|
const std = @import("std");
|
|
const ipc = @import("ipc");
|
|
const process = @import("process");
|
|
const service = @import("service");
|
|
const time = @import("time");
|
|
const block = @import("block");
|
|
const file_system = @import("file-system");
|
|
const memory = @import("memory");
|
|
const logging = @import("logging");
|
|
const engine = @import("engine.zig");
|
|
const on_disk = @import("on-disk.zig");
|
|
const vfs_protocol = @import("vfs-protocol");
|
|
|
|
const mount_point = "/volumes/usb";
|
|
|
|
// The engine's BlockDevice, backed by the `.block` driver plus a DMA bounce
|
|
// buffer the driver reads/writes by physical address.
|
|
const IpcBlock = struct {
|
|
device: block.Device,
|
|
bounce: memory.DmaRegion, // engine.max_transfer_sectors * 512 bytes
|
|
|
|
fn readBlocks(context: *anyopaque, lba: u64, count: u32, buffer: []u8) bool {
|
|
const self: *IpcBlock = @ptrCast(@alignCast(context));
|
|
if (count == 0 or count > engine.max_transfer_sectors) return false;
|
|
const len = count * 512;
|
|
if (!self.device.read(lba, count, self.bounce.physical)) return false;
|
|
const source: [*]const u8 = @ptrFromInt(self.bounce.virtual);
|
|
@memcpy(buffer[0..len], source[0..len]);
|
|
return true;
|
|
}
|
|
fn writeBlocks(context: *anyopaque, lba: u64, count: u32, buffer: []const u8) bool {
|
|
const self: *IpcBlock = @ptrCast(@alignCast(context));
|
|
if (count == 0 or count > engine.max_transfer_sectors) return false;
|
|
const len = count * 512;
|
|
const destination: [*]u8 = @ptrFromInt(self.bounce.virtual);
|
|
@memcpy(destination[0..len], buffer[0..len]);
|
|
if (!self.device.write(lba, count, self.bounce.physical)) return false;
|
|
device_dirty = true; // a block reached the device; a close will flush it
|
|
return true;
|
|
}
|
|
};
|
|
|
|
var ipc_block: IpcBlock = undefined;
|
|
// Set whenever a block is written, cleared when the device cache is flushed on a
|
|
// file close — so writes are committed to stable media before a power-off.
|
|
var device_dirty: bool = false;
|
|
var filesystem: engine.FileSystem = undefined;
|
|
|
|
// Open handles the VFS holds against this backend: each maps a node id to a
|
|
// resolved engine node.
|
|
const OpenNode = struct { used: bool = false, node: engine.Node = undefined, owner: u32 = 0 };
|
|
var open_nodes = [_]OpenNode{.{}} ** 32;
|
|
|
|
fn allocOpen() ?usize {
|
|
for (&open_nodes, 0..) |*o, i| {
|
|
if (!o.used) return i;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
fn openAt(id: u64) ?*OpenNode {
|
|
if (id >= open_nodes.len) return null;
|
|
const o = &open_nodes[@intCast(id)];
|
|
return if (o.used) o else null;
|
|
}
|
|
|
|
fn writeReply(out: []u8, reply: vfs_protocol.Reply, payload: []const u8) usize {
|
|
@memcpy(out[0..vfs_protocol.reply_size], std.mem.asBytes(&reply));
|
|
const n = @min(payload.len, out.len - vfs_protocol.reply_size);
|
|
@memcpy(out[vfs_protocol.reply_size..][0..n], payload[0..n]);
|
|
return vfs_protocol.reply_size + n;
|
|
}
|
|
|
|
fn fail(out: []u8) usize {
|
|
return writeReply(out, .{ .status = -1 }, &.{});
|
|
}
|
|
|
|
/// How often to look for a block device while none is mounted. Storage arriving
|
|
/// is EVENT-shaped (the usb chain registering, possibly after a driver restart),
|
|
/// but the registry has no subscription — a slow poll from our own harness loop
|
|
/// keeps the service responsive (ping, terminate) while it waits, and keeps it
|
|
/// alive to catch storage that appears LATE (a restarted usb-storage after a
|
|
/// transient failure — the resilience half of docs/logging.md's storage story).
|
|
const mount_retry_ms = 500;
|
|
|
|
var mounted = false;
|
|
var service_endpoint: ipc.Handle = 0;
|
|
|
|
fn initialise(endpoint: ipc.Handle) bool {
|
|
service_endpoint = endpoint;
|
|
_ = logging.write("/system/services/fat: starting, waiting for a block device\n");
|
|
// With the router in the kernel, clients hold OUR node ids directly; sweep
|
|
// a dead client's open handles via the published exit events (the pattern
|
|
// the old userspace router used for its own table).
|
|
_ = process.subscribeExits(endpoint);
|
|
tryBringUp();
|
|
if (!mounted) _ = time.timerOnce(endpoint, mount_retry_ms);
|
|
return true; // serve regardless: requests fail politely until storage mounts
|
|
}
|
|
|
|
/// One storage bring-up attempt: block device -> FAT mount -> VFS mounts. Sets
|
|
/// `mounted` on success; a failure leaves everything untouched for the next tick.
|
|
fn tryBringUp() void {
|
|
if (mounted) return;
|
|
const device = block.tryOpen() orelse return;
|
|
const geometry = device.geometry() orelse {
|
|
_ = logging.write("/system/services/fat: block geometry unavailable\n");
|
|
return;
|
|
};
|
|
// Shareable so the buffer's capability can be attached down the chain (block server
|
|
// -> controller), making its physical addresses reachable by the device under an
|
|
// enforcing IOMMU. No-op binding otherwise.
|
|
const bounce = memory.dmaAlloc(engine.max_transfer_sectors * 512, memory.dma_coherent | memory.dma_shareable) orelse return;
|
|
if (bounce.handle) |handle| {
|
|
if (!device.attach(handle)) {
|
|
_ = logging.write("/system/services/fat: could not attach the DMA bounce buffer\n");
|
|
return;
|
|
}
|
|
_ = ipc.close(handle); // the binding holds its own reference now
|
|
}
|
|
ipc_block = .{ .device = device, .bounce = bounce };
|
|
|
|
const block_device = engine.BlockDevice{
|
|
.context = &ipc_block,
|
|
.block_size = geometry.block_size,
|
|
.block_count = geometry.block_count,
|
|
.readBlocksFn = IpcBlock.readBlocks,
|
|
.writeBlocksFn = IpcBlock.writeBlocks,
|
|
};
|
|
filesystem = engine.FileSystem.mount(block_device) orelse {
|
|
_ = logging.write("/system/services/fat: not a FAT filesystem\n");
|
|
return;
|
|
};
|
|
std.log.info("mounted FAT ({s}, {d} clusters, partition lba {d})", .{ @tagName(filesystem.geometry.fat_type), filesystem.geometry.cluster_count, filesystem.base_lba });
|
|
|
|
// Mount ourselves into the kernel VFS at /volumes/usb — and serve
|
|
// /system/configuration and /system/logs from the volume's identically-named
|
|
// subtrees (the boot volume is hierarchy-shaped, so rewrite == prefix), so
|
|
// hierarchy paths (the logger's /system/logs) stay decoupled from which
|
|
// volume carries them.
|
|
if (file_system.mount(mount_point, endpointForMount())) {
|
|
std.log.info("mounted {s}", .{mount_point});
|
|
} else {
|
|
_ = logging.write("/system/services/fat: could not mount /volumes/usb\n");
|
|
}
|
|
if (file_system.mountRewritten("/system/configuration", endpointForMount(), "/system/configuration")) {
|
|
std.log.info("mounted /system/configuration", .{});
|
|
} else {
|
|
_ = logging.write("/system/services/fat: could not mount /system/configuration\n");
|
|
}
|
|
if (file_system.mountRewritten("/system/logs", endpointForMount(), "/system/logs")) {
|
|
std.log.info("mounted /system/logs", .{});
|
|
} else {
|
|
_ = logging.write("/system/services/fat: could not mount /system/logs\n");
|
|
}
|
|
mounted = true;
|
|
}
|
|
|
|
fn endpointForMount() ipc.Handle {
|
|
return service_endpoint;
|
|
}
|
|
|
|
/// A subscribed process-exit event: release every open handle the dead client
|
|
/// held, so a crashed reader can't pin table slots (or, later, locks).
|
|
fn onNotification(badge: u64) void {
|
|
const got = ipc.Received{ .len = 0, .badge = badge, .cap = null };
|
|
if (got.isTimer()) {
|
|
tryBringUp();
|
|
if (!mounted) _ = time.timerOnce(service_endpoint, mount_retry_ms);
|
|
return;
|
|
}
|
|
if (!got.isChildExit()) return;
|
|
const dead = got.childProcessId();
|
|
var released: u32 = 0;
|
|
for (&open_nodes) |*o| {
|
|
if (o.used and o.owner == dead) {
|
|
o.* = .{};
|
|
released += 1;
|
|
}
|
|
}
|
|
if (released != 0) std.log.info("released {d} handle(s) for dead client {d}", .{ released, dead });
|
|
}
|
|
|
|
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, sender: u32) usize {
|
|
var node = filesystem.resolve(path);
|
|
if (node == null and flags & vfs_protocol.create != 0) {
|
|
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
|
|
// (frees the old chain, so a shorter rewrite leaves no stale tail).
|
|
if (flags & vfs_protocol.truncate != 0 and !resolved.is_directory) {
|
|
filesystem.truncate(&resolved);
|
|
}
|
|
const index = allocOpen() orelse return fail(out);
|
|
open_nodes[index] = .{ .used = true, .node = resolved, .owner = sender };
|
|
return writeReply(out, .{ .status = 0, .node = index }, &.{});
|
|
}
|
|
|
|
fn onMessage(message: []const u8, out: []u8, sender: u32, capability: ?ipc.Handle) usize {
|
|
_ = capability;
|
|
if (!mounted) return fail(out); // storage not up (yet): fail politely, clients retry
|
|
if (message.len < vfs_protocol.request_size) return fail(out);
|
|
const request = std.mem.bytesToValue(vfs_protocol.Request, message[0..vfs_protocol.request_size]);
|
|
const payload = message[vfs_protocol.request_size..];
|
|
|
|
// Stamp create/write with the current wall-clock time (mtime). Cheap, and it
|
|
// keeps the engine pure (it takes the time as data, not a syscall).
|
|
filesystem.current_time_epoch = time.wallClock();
|
|
|
|
switch (request.operation) {
|
|
.open => return handleOpen(out, payload[0..@min(payload.len, request.len)], request.flags, sender),
|
|
.read => {
|
|
const o = openAt(request.node) orelse return fail(out);
|
|
var buffer: [vfs_protocol.maximum_payload]u8 = undefined;
|
|
const want = @min(@as(usize, request.len), buffer.len);
|
|
const n = filesystem.readFile(o.node, @intCast(request.offset), buffer[0..want]);
|
|
return writeReply(out, .{ .status = 0, .len = @intCast(n) }, buffer[0..n]);
|
|
},
|
|
.write => {
|
|
const o = openAt(request.node) orelse return fail(out);
|
|
const data = payload[0..@min(payload.len, request.len)];
|
|
const n = filesystem.writeFile(&o.node, @intCast(request.offset), data);
|
|
return writeReply(out, .{ .status = 0, .len = @intCast(n) }, &.{});
|
|
},
|
|
.status => {
|
|
const o = openAt(request.node) orelse return fail(out);
|
|
const kind: vfs_protocol.NodeKind = if (o.node.is_directory) .directory else .regular;
|
|
const status = vfs_protocol.FileStatus{ .size = o.node.size, .kind = @intFromEnum(kind), .mtime = o.node.mtime };
|
|
return writeReply(out, .{ .status = 0, .len = @sizeOf(vfs_protocol.FileStatus) }, std.mem.asBytes(&status));
|
|
},
|
|
.readdir => {
|
|
const o = openAt(request.node) orelse return fail(out);
|
|
if (!o.node.is_directory) return writeReply(out, .{ .status = 0, .len = 0 }, &.{});
|
|
const listing = filesystem.listEntry(o.node, @intCast(request.offset)) orelse return writeReply(out, .{ .status = 0, .len = 0 }, &.{});
|
|
const kind: vfs_protocol.NodeKind = if (listing.is_directory) .directory else .regular;
|
|
const header = vfs_protocol.DirectoryEntry{ .kind = @intFromEnum(kind), .name_len = @intCast(listing.name_len), .size = listing.size };
|
|
var buffer: [vfs_protocol.maximum_payload]u8 = undefined;
|
|
@memcpy(buffer[0..vfs_protocol.directory_entry_size], std.mem.asBytes(&header));
|
|
const nlen = @min(listing.name_len, buffer.len - vfs_protocol.directory_entry_size);
|
|
@memcpy(buffer[vfs_protocol.directory_entry_size..][0..nlen], listing.name_buffer[0..nlen]);
|
|
const total = vfs_protocol.directory_entry_size + nlen;
|
|
return writeReply(out, .{ .status = 0, .len = @intCast(total) }, buffer[0..total]);
|
|
},
|
|
.close => {
|
|
if (openAt(request.node)) |o| o.used = false;
|
|
// Durable-on-close: if any block reached the device since the last
|
|
// flush, commit its cache to stable media now (best-effort). This is
|
|
// what makes init's shutdown log flush survive a real power-off, and is
|
|
// the right default for removable media the user may unplug.
|
|
if (device_dirty) {
|
|
_ = ipc_block.device.flush();
|
|
device_dirty = false;
|
|
}
|
|
return writeReply(out, .{ .status = 0 }, &.{});
|
|
},
|
|
.mkdir => {
|
|
const path = payload[0..@min(payload.len, request.len)];
|
|
if (filesystem.resolve(path) != null) return fail(out); // already exists — no duplicate entries
|
|
const split = splitParent(path);
|
|
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 }, &.{});
|
|
},
|
|
.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),
|
|
}
|
|
}
|
|
|
|
pub fn main() void {
|
|
service.run(vfs_protocol.message_maximum, .{
|
|
.service = .fat,
|
|
.init = initialise,
|
|
.on_message = onMessage,
|
|
.on_notification = onNotification,
|
|
});
|
|
}
|