summaryrefslogtreecommitdiff
path: root/web/httpfs.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-20 03:53:17 -0300
committerGabriel Schneider <[email protected]>2026-09-20 03:53:17 -0300
commitf1b53c1533539aecbf16ad19fd9156deae091f92 (patch)
tree38ff00810ef5e0ad271c26e218cf6af4578c71ce /web/httpfs.zig
parentba7ec40782ba7020d82a56896a5eb1b52578d6aa (diff)
downloadcloud9-f1b53c1533539aecbf16ad19fd9156deae091f92.tar.gz
cloud9-f1b53c1533539aecbf16ad19fd9156deae091f92.zip
9web: multiplexer, HTTP view of the tree, live streams, richer page
One upstream 9P connection now serves any number of browser WebSocket sessions and, with --serve, plain 9P clients over TCP or Unix (a 9pserve- style frame remux: tags and fids remapped, Tflush forwarded, reconnect on upstream loss). /fs/<path> maps HTTP onto the tree: GET file or directory (JSON or HTML), Range, HEAD with 9P headers, PUT (create, truncate, append, trailing slash makes a directory), DELETE, and ?follow=1 or text/event-stream turning a blocking read into server-sent events with Tflush on disconnect. The page gains a lazy tree, stat panel, create, rename, delete, upload and follow mode. --probe embeds 9proc for self-introspection. New mux-test and http-fs-test steps; e2e still passes. Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'web/httpfs.zig')
-rw-r--r--web/httpfs.zig583
1 files changed, 583 insertions, 0 deletions
diff --git a/web/httpfs.zig b/web/httpfs.zig
new file mode 100644
index 0000000..fa783e5
--- /dev/null
+++ b/web/httpfs.zig
@@ -0,0 +1,583 @@
+//! An HTTP view of the upstream 9P tree under `/fs/<path>`, served through the
+//! shared `Mux` at the fid level. It is file operations, not verbs: HTTP
+//! methods map onto the tree.
+//!
+//! GET /fs/<file> bytes (Content-Type sniffed; Range honored)
+//! GET /fs/<file>?follow=1 server-sent events, one per blocking read
+//! GET /fs/<dir> JSON array of entries (HTML with Accept: text/html)
+//! HEAD /fs/<path> stat as headers
+//! PUT /fs/<file> write (create if missing; truncate unless ?append=1)
+//! PUT /fs/<dir>/ create a directory
+//! DELETE /fs/<path> remove
+//!
+//! A 9P Rerror becomes 404/403/409/500 with the ename in the body. Two
+//! caller-owned scratch buffers are used: `big` (>= msize) for read/dir/body
+//! data, `small` (>= a few KiB) for control replies and response framing.
+const std = @import("std");
+const c9 = @import("cloud9");
+const wire = c9.wire;
+const Server = std.http.Server;
+const Status = std.http.Status;
+const Request = c9.Client.Request;
+
+const max_components = 64;
+const name_bytes = 8192;
+
+pub fn handle(m: anytype, request: *Server.Request, path: []const u8, big: []u8, small: []u8) void {
+ const M = @TypeOf(m.*);
+ var fs: FS(M) = .{ .m = m, .request = request, .big = big, .small = small };
+ fs.serve(path) catch |err| switch (err) {
+ error.Handled => {},
+ error.NotFound => fs.fail(.not_found, "No such file or directory\n"),
+ error.BadPath => fs.fail(.bad_request, "Invalid path\n"),
+ error.Upstream, error.Disconnected => fs.fail(.bad_gateway, "9P upstream unavailable\n"),
+ else => fs.fail(.internal_server_error, "Internal error\n"),
+ };
+ fs.cleanup();
+}
+
+fn FS(comptime M: type) type {
+ return struct {
+ const Self = @This();
+
+ m: *M,
+ request: *Server.Request,
+ big: []u8,
+ small: []u8,
+ root_fid: ?u32 = null,
+ leaf_fid: ?u32 = null,
+ responded: bool = false,
+
+ fn rpcSmall(self: *Self, req: Request) !wire.Decoded {
+ var r: M.Rpc = .{ .buf = self.small };
+ return self.m.rpc(req, &r);
+ }
+
+ fn fail(self: *Self, status: Status, text: []const u8) void {
+ if (self.responded) return;
+ self.responded = true;
+ self.request.respond(text, .{ .status = status, .keep_alive = false, .extra_headers = &.{
+ .{ .name = "content-type", .value = "text/plain; charset=utf-8" },
+ .{ .name = "cache-control", .value = "no-store" },
+ .{ .name = "x-content-type-options", .value = "nosniff" },
+ } }) catch {};
+ }
+
+ /// A 9P Rerror: map to a status and answer, then stop the request.
+ fn rerr(self: *Self, ename: []const u8) anyerror {
+ var buf: [160]u8 = undefined;
+ const body = std.fmt.bufPrint(&buf, "{s}\n", .{ename[0..@min(ename.len, 128)]}) catch "error\n";
+ self.fail(mapError(ename), body);
+ return error.Handled;
+ }
+
+ fn cleanup(self: *Self) void {
+ if (self.leaf_fid) |f| {
+ _ = self.rpcSmall(.{ .clunk = .{ .fid = f } }) catch {};
+ self.leaf_fid = null;
+ }
+ if (self.root_fid) |f| {
+ _ = self.rpcSmall(.{ .clunk = .{ .fid = f } }) catch {};
+ self.root_fid = null;
+ }
+ }
+
+ // -- session helpers ----------------------------------------------
+
+ fn attach(self: *Self) !void {
+ if (self.root_fid != null) return;
+ const fid = self.m.takeFid() orelse return error.Upstream;
+ const got = try self.rpcSmall(.{ .attach = .{ .fid = fid, .uname = self.m.user, .aname = self.m.tree } });
+ if (got.msg == .rerror) {
+ self.m.dropFid(fid);
+ return self.rerr(got.msg.rerror.ename);
+ }
+ if (got.msg != .rattach) {
+ self.m.dropFid(fid);
+ return error.Upstream;
+ }
+ self.root_fid = fid;
+ }
+
+ /// Walks from the root to `names`, leaving `leaf_fid` on the target and
+ /// returning its stat. A missing component is `error.NotFound`.
+ fn walkTo(self: *Self, names: [][]const u8) !wire.Stat {
+ try self.attach();
+ if (self.leaf_fid) |old| {
+ _ = self.rpcSmall(.{ .clunk = .{ .fid = old } }) catch {};
+ self.leaf_fid = null;
+ }
+ const leaf = self.m.takeFid() orelse return error.Upstream;
+ var start: usize = 0;
+ var src = self.root_fid.?;
+ var first = true;
+ while (first or start < names.len) {
+ const batch = names[start..@min(start + 16, names.len)];
+ const got = try self.rpcSmall(.{ .walk = .{ .fid = src, .newfid = leaf, .names = batch } });
+ if (got.msg == .rerror) {
+ self.m.dropFid(leaf);
+ return self.rerr(got.msg.rerror.ename);
+ }
+ if (got.msg != .rwalk or got.msg.rwalk.nwqid != batch.len) {
+ self.m.dropFid(leaf); // short walk: newfid was not bound
+ return error.NotFound;
+ }
+ self.leaf_fid = leaf;
+ src = leaf;
+ start += batch.len;
+ first = false;
+ if (batch.len == 0) break;
+ }
+ const st = try self.rpcSmall(.{ .stat = .{ .fid = leaf } });
+ if (st.msg == .rerror) return self.rerr(st.msg.rerror.ename);
+ if (st.msg != .rstat) return error.Upstream;
+ return st.msg.rstat.stat;
+ }
+
+ // -- dispatch ------------------------------------------------------
+
+ fn serve(self: *Self, path: []const u8) !void {
+ const rel = path[3..]; // after "/fs"
+ const trailing = rel.len != 0 and rel[rel.len - 1] == '/';
+ var storage: [name_bytes]u8 = undefined;
+ var names: [max_components][]const u8 = undefined;
+ const count = try decodePath(rel, &storage, &names);
+ const comps = names[0..count];
+
+ switch (self.request.head.method) {
+ .GET => try self.get(comps),
+ .HEAD => try self.head(comps),
+ .PUT => if (trailing) try self.mkdir(comps) else try self.put(comps),
+ .DELETE => try self.delete(comps),
+ else => self.fail(.method_not_allowed, "Method not allowed\n"),
+ }
+ }
+
+ fn head(self: *Self, comps: [][]const u8) !void {
+ const st = try self.walkTo(comps);
+ const dir = st.qid.type & c9.qtdir != 0;
+ var len_buf: [24]u8 = undefined;
+ const clen = std.fmt.bufPrint(&len_buf, "{d}", .{st.length}) catch "0";
+ var qid_buf: [48]u8 = undefined;
+ const qid = std.fmt.bufPrint(&qid_buf, "{d}.{d}.{d}", .{ st.qid.type, st.qid.version, st.qid.path }) catch "";
+ var mode_buf: [16]u8 = undefined;
+ const mode = std.fmt.bufPrint(&mode_buf, "{o}", .{st.mode}) catch "";
+ var mtime_buf: [16]u8 = undefined;
+ const mtime = std.fmt.bufPrint(&mtime_buf, "{d}", .{st.mtime}) catch "";
+ self.responded = true;
+ self.request.respond("", .{ .status = .ok, .keep_alive = false, .transfer_encoding = .none, .extra_headers = &.{
+ .{ .name = "content-type", .value = if (dir) "application/json" else "application/octet-stream" },
+ .{ .name = "content-length", .value = clen },
+ .{ .name = "x-9p-qid", .value = qid },
+ .{ .name = "x-9p-mode", .value = mode },
+ .{ .name = "x-9p-mtime", .value = mtime },
+ .{ .name = "accept-ranges", .value = "bytes" },
+ .{ .name = "cache-control", .value = "no-store" },
+ } }) catch {};
+ }
+
+ fn get(self: *Self, comps: [][]const u8) !void {
+ const follow = self.wantsFollow();
+ const st = try self.walkTo(comps);
+ if (st.qid.type & c9.qtdir != 0) return self.listDir(st);
+ if (follow) return self.followFile();
+ return self.getFile(st);
+ }
+
+ // -- files ---------------------------------------------------------
+
+ fn getFile(self: *Self, st: wire.Stat) !void {
+ const leaf = self.leaf_fid.?;
+ const opened = try self.rpcSmall(.{ .open = .{ .fid = leaf, .mode = c9.oread } });
+ if (opened.msg == .rerror) return self.rerr(opened.msg.rerror.ename);
+ if (opened.msg != .ropen) return error.Upstream;
+
+ const range = self.parseRange(st.length);
+ const status: Status = if (range.partial) .partial_content else .ok;
+
+ var offset: u64 = range.start;
+ const first = try self.readAt(leaf, offset, self.chunk());
+ if (first.msg == .rerror) return self.rerr(first.msg.rerror.ename);
+ if (first.msg != .rread) return error.Upstream;
+ const ctype = sniff(first.msg.rread.data);
+
+ var cr_buf: [64]u8 = undefined;
+ var headers: [4]std.http.Header = undefined;
+ var nh: usize = 0;
+ headers[nh] = .{ .name = "content-type", .value = ctype };
+ nh += 1;
+ headers[nh] = .{ .name = "accept-ranges", .value = "bytes" };
+ nh += 1;
+ headers[nh] = .{ .name = "cache-control", .value = "no-store" };
+ nh += 1;
+ if (range.partial) {
+ headers[nh] = .{ .name = "content-range", .value = std.fmt.bufPrint(&cr_buf, "bytes {d}-{d}/{d}", .{ range.start, range.end - 1, st.length }) catch return error.Upstream };
+ nh += 1;
+ }
+
+ self.responded = true;
+ var bw = try self.request.respondStreaming(self.small, .{
+ .respond_options = .{ .status = status, .keep_alive = false, .extra_headers = headers[0..nh] },
+ });
+ // A synthetic/control file reports length 0 but streams content, so
+ // clamp to the length only for an explicit Range.
+ const limit: u64 = if (range.partial) range.end - range.start else std.math.maxInt(u64);
+ var sent: u64 = 0;
+ var data = first.msg.rread.data;
+ while (true) {
+ const take = @min(@as(u64, data.len), limit - sent);
+ try bw.writer.writeAll(data[0..@intCast(take)]);
+ sent += take;
+ if (sent >= limit or data.len == 0) break;
+ offset += data.len;
+ const r = try self.readAt(leaf, offset, self.chunk());
+ if (r.msg != .rread) break;
+ data = r.msg.rread.data;
+ if (data.len == 0) break;
+ }
+ try bw.end();
+ }
+
+ fn followFile(self: *Self) !void {
+ const leaf = self.leaf_fid.?;
+ const opened = try self.rpcSmall(.{ .open = .{ .fid = leaf, .mode = c9.oread } });
+ if (opened.msg == .rerror) return self.rerr(opened.msg.rerror.ename);
+ if (opened.msg != .ropen) return error.Upstream;
+
+ self.responded = true;
+ var bw = try self.request.respondStreaming(self.small, .{
+ .respond_options = .{ .status = .ok, .keep_alive = false, .extra_headers = &.{
+ .{ .name = "content-type", .value = "text/event-stream; charset=utf-8" },
+ .{ .name = "cache-control", .value = "no-store" },
+ .{ .name = "x-content-type-options", .value = "nosniff" },
+ } },
+ });
+ bw.writer.writeAll(": follow\n\n") catch return;
+ flushBody(&bw) catch return;
+ var offset: u64 = 0;
+ while (true) {
+ var r: M.Rpc = .{ .buf = self.big };
+ // On client disconnect / deadline this rpc is cancelled; the
+ // mux Tflushes the parked upstream read before returning.
+ const got = self.m.rpc(.{ .read = .{ .fid = leaf, .offset = offset, .count = self.chunk() } }, &r) catch return;
+ if (got.msg != .rread) return;
+ const data = got.msg.rread.data;
+ if (data.len == 0) {
+ bw.writer.writeAll("event: eof\ndata:\n\n") catch return;
+ flushBody(&bw) catch return;
+ return;
+ }
+ offset += data.len;
+ self.writeSse(&bw, data) catch return;
+ flushBody(&bw) catch return;
+ }
+ }
+
+ /// Drains the body writer's buffer as a chunk, then flushes to the peer.
+ fn flushBody(bw: anytype) !void {
+ try bw.writer.flush();
+ try bw.flush();
+ }
+
+ fn writeSse(self: *Self, bw: anytype, data: []const u8) !void {
+ _ = self;
+ var it = std.mem.splitScalar(u8, std.mem.trimEnd(u8, data, "\n"), '\n');
+ while (it.next()) |line| {
+ try bw.writer.writeAll("data: ");
+ try bw.writer.writeAll(line);
+ try bw.writer.writeByte('\n');
+ }
+ try bw.writer.writeByte('\n');
+ }
+
+ // -- directories ---------------------------------------------------
+
+ fn listDir(self: *Self, st: wire.Stat) !void {
+ const html = self.wantsHtml();
+ const leaf = self.leaf_fid.?;
+ const opened = try self.rpcSmall(.{ .open = .{ .fid = leaf, .mode = c9.oread } });
+ if (opened.msg == .rerror) return self.rerr(opened.msg.rerror.ename);
+ if (opened.msg != .ropen) return error.Upstream;
+
+ // JSON/HTML is built into `big`; directory data is read into `small`.
+ var w: std.Io.Writer = .fixed(self.big);
+ if (html) {
+ w.writeAll("<!doctype html><meta charset=utf-8><title>") catch return error.Upstream;
+ writeHtml(&w, st.name);
+ w.writeAll("</title><ul>") catch return error.Upstream;
+ } else {
+ w.writeByte('[') catch return error.Upstream;
+ }
+ var offset: u64 = 0;
+ var first = true;
+ while (true) {
+ var r: M.Rpc = .{ .buf = self.small };
+ const got = try self.m.rpc(.{ .read = .{ .fid = leaf, .offset = offset, .count = self.chunk() } }, &r);
+ if (got.msg != .rread) break;
+ const data = got.msg.rread.data;
+ if (data.len == 0) break;
+ offset += data.len;
+ var rest: []const u8 = data;
+ while (rest.len >= 2) {
+ const size: usize = @as(usize, std.mem.readInt(u16, rest[0..2], .little)) + 2;
+ if (size > rest.len) break;
+ const entry = c9.Stat.decode(rest[0..size]) catch break;
+ rest = rest[size..];
+ if (html) {
+ w.writeAll("<li>") catch return error.Upstream;
+ writeHtml(&w, entry.name);
+ if (entry.qid.type & c9.qtdir != 0) w.writeByte('/') catch {};
+ w.writeAll("</li>") catch return error.Upstream;
+ } else {
+ if (!first) w.writeByte(',') catch return error.Upstream;
+ first = false;
+ writeEntryJson(&w, entry) catch return error.Upstream;
+ }
+ }
+ }
+ if (html) w.writeAll("</ul>") catch return error.Upstream else w.writeByte(']') catch return error.Upstream;
+ self.responded = true;
+ self.request.respond(w.buffered(), .{ .status = .ok, .keep_alive = false, .extra_headers = &.{
+ .{ .name = "content-type", .value = if (html) "text/html; charset=utf-8" else "application/json" },
+ .{ .name = "cache-control", .value = "no-store" },
+ .{ .name = "x-content-type-options", .value = "nosniff" },
+ } }) catch {};
+ }
+
+ // -- writes --------------------------------------------------------
+
+ fn put(self: *Self, comps: [][]const u8) !void {
+ if (comps.len == 0) return error.BadPath;
+ const append = self.wantsAppend();
+ const existed = self.tryWalk(comps) catch |err| switch (err) {
+ error.NotFound => false,
+ else => return err,
+ };
+ var write_off: u64 = 0;
+ if (existed) {
+ const leaf = self.leaf_fid.?;
+ if (append) {
+ const st = try self.rpcSmall(.{ .stat = .{ .fid = leaf } });
+ if (st.msg == .rstat) write_off = st.msg.rstat.stat.length;
+ const o = try self.rpcSmall(.{ .open = .{ .fid = leaf, .mode = c9.owrite } });
+ if (o.msg == .rerror) return self.rerr(o.msg.rerror.ename);
+ if (o.msg != .ropen) return error.Upstream;
+ } else {
+ const o = try self.rpcSmall(.{ .open = .{ .fid = leaf, .mode = c9.owrite | c9.otrunc } });
+ if (o.msg == .rerror) return self.rerr(o.msg.rerror.ename);
+ if (o.msg != .ropen) return error.Upstream;
+ }
+ } else {
+ _ = try self.walkTo(comps[0 .. comps.len - 1]);
+ const parent = self.leaf_fid.?;
+ const cr = try self.rpcSmall(.{ .create = .{ .fid = parent, .name = comps[comps.len - 1], .perm = 0o644, .mode = c9.owrite } });
+ if (cr.msg == .rerror) return self.rerr(cr.msg.rerror.ename);
+ if (cr.msg != .rcreate) return error.Upstream;
+ }
+ const written = try self.streamBody(self.leaf_fid.?, write_off);
+ var buf: [48]u8 = undefined;
+ const body = std.fmt.bufPrint(&buf, "{{\"written\":{d}}}\n", .{written}) catch "{}\n";
+ self.responded = true;
+ self.request.respond(body, .{ .status = if (existed) .ok else .created, .keep_alive = false, .extra_headers = &.{
+ .{ .name = "content-type", .value = "application/json" },
+ .{ .name = "cache-control", .value = "no-store" },
+ } }) catch {};
+ }
+
+ fn mkdir(self: *Self, comps: [][]const u8) !void {
+ if (comps.len == 0) return error.BadPath;
+ _ = try self.walkTo(comps[0 .. comps.len - 1]);
+ const parent = self.leaf_fid.?;
+ const cr = try self.rpcSmall(.{ .create = .{ .fid = parent, .name = comps[comps.len - 1], .perm = c9.dmdir | 0o755, .mode = c9.oread } });
+ if (cr.msg == .rerror) return self.rerr(cr.msg.rerror.ename);
+ if (cr.msg != .rcreate) return error.Upstream;
+ self.fail(.created, "created\n");
+ }
+
+ fn delete(self: *Self, comps: [][]const u8) !void {
+ if (comps.len == 0) return error.BadPath;
+ _ = try self.walkTo(comps);
+ const leaf = self.leaf_fid.?;
+ const rm = try self.rpcSmall(.{ .remove = .{ .fid = leaf } });
+ self.m.dropFid(leaf); // Tremove clunks the fid upstream regardless
+ self.leaf_fid = null;
+ if (rm.msg == .rerror) return self.rerr(rm.msg.rerror.ename);
+ self.fail(.no_content, "");
+ }
+
+ /// walkTo that reports missing as `false` rather than an HTTP error.
+ fn tryWalk(self: *Self, comps: [][]const u8) !bool {
+ _ = self.walkTo(comps) catch |err| switch (err) {
+ error.NotFound => return false,
+ else => return err,
+ };
+ return true;
+ }
+
+ fn streamBody(self: *Self, fid: u32, start: u64) !u64 {
+ const body_buf = self.big[0..16384];
+ const cbuf = self.big[16384..];
+ const br = self.request.readerExpectContinue(body_buf) catch return error.Upstream;
+ const maxw = @min(self.m.negotiatedMsize() -| 23, cbuf.len);
+ var offset: u64 = start;
+ var total: u64 = 0;
+ while (true) {
+ const n = br.readSliceShort(cbuf[0..maxw]) catch return error.Upstream;
+ if (n == 0) break;
+ var done: usize = 0;
+ while (done < n) {
+ const w = try self.rpcSmall(.{ .write = .{ .fid = fid, .offset = offset + done, .data = cbuf[done..n] } });
+ if (w.msg == .rerror) return self.rerr(w.msg.rerror.ename);
+ if (w.msg != .rwrite or w.msg.rwrite.count == 0) return error.Upstream;
+ done += w.msg.rwrite.count;
+ }
+ offset += n;
+ total += n;
+ }
+ return total;
+ }
+
+ // -- small helpers -------------------------------------------------
+
+ fn readAt(self: *Self, fid: u32, offset: u64, count: u32) !wire.Decoded {
+ var r: M.Rpc = .{ .buf = self.big };
+ return self.m.rpc(.{ .read = .{ .fid = fid, .offset = offset, .count = count } }, &r);
+ }
+
+ fn chunk(self: *Self) u32 {
+ const ms = self.m.negotiatedMsize();
+ return if (ms > 11) ms - 11 else 0;
+ }
+
+ const Range = struct { start: u64, end: u64, partial: bool };
+ fn parseRange(self: *Self, length: u64) Range {
+ var it = self.request.iterateHeaders();
+ while (it.next()) |h| {
+ if (!std.ascii.eqlIgnoreCase(h.name, "range")) continue;
+ if (!std.mem.startsWith(u8, h.value, "bytes=")) break;
+ const spec = h.value[6..];
+ const dash = std.mem.indexOfScalar(u8, spec, '-') orelse break;
+ const start = std.fmt.parseInt(u64, spec[0..dash], 10) catch break;
+ var end: u64 = length;
+ if (dash + 1 < spec.len) {
+ const e = std.fmt.parseInt(u64, spec[dash + 1 ..], 10) catch break;
+ end = @min(e + 1, length);
+ }
+ if (start >= length or start >= end) break;
+ return .{ .start = start, .end = end, .partial = true };
+ }
+ return .{ .start = 0, .end = length, .partial = false };
+ }
+
+ fn wantsFollow(self: *Self) bool {
+ if (std.mem.indexOf(u8, self.request.head.target, "follow=1") != null) return true;
+ return self.acceptHas("text/event-stream");
+ }
+ fn wantsHtml(self: *Self) bool {
+ return self.acceptHas("text/html");
+ }
+ fn wantsAppend(self: *Self) bool {
+ return std.mem.indexOf(u8, self.request.head.target, "append=1") != null;
+ }
+ fn acceptHas(self: *Self, what: []const u8) bool {
+ var it = self.request.iterateHeaders();
+ while (it.next()) |h| {
+ if (std.ascii.eqlIgnoreCase(h.name, "accept") and std.mem.indexOf(u8, h.value, what) != null) return true;
+ }
+ return false;
+ }
+ };
+}
+
+fn mapError(ename: []const u8) Status {
+ if (contains(ename, "No such file") or contains(ename, "does not exist") or contains(ename, "not found") or contains(ename, "unknown")) return .not_found;
+ if (contains(ename, "permission") or contains(ename, "not permitted") or contains(ename, "denied")) return .forbidden;
+ if (contains(ename, "exists")) return .conflict;
+ if (contains(ename, "not empty") or contains(ename, "in use") or contains(ename, "Is a directory") or contains(ename, "Not a directory")) return .conflict;
+ return .internal_server_error;
+}
+
+fn contains(haystack: []const u8, needle: []const u8) bool {
+ return std.mem.indexOf(u8, haystack, needle) != null;
+}
+
+fn sniff(data: []const u8) []const u8 {
+ const n = @min(data.len, 1024);
+ for (data[0..n]) |b| if (b == 0) return "application/octet-stream";
+ return "text/plain; charset=utf-8";
+}
+
+/// Decodes a `/fs`-relative path into non-empty, percent-decoded components.
+fn decodePath(rel: []const u8, storage: []u8, names: *[max_components][]const u8) !usize {
+ var out: usize = 0;
+ var count: usize = 0;
+ var it = std.mem.splitScalar(u8, rel, '/');
+ while (it.next()) |raw| {
+ if (raw.len == 0) continue;
+ var i: usize = 0;
+ const begin = out;
+ while (i < raw.len) {
+ const ch = raw[i];
+ if (ch == '%') {
+ if (i + 2 >= raw.len) return error.BadPath;
+ const hi = hex(raw[i + 1]) orelse return error.BadPath;
+ const lo = hex(raw[i + 2]) orelse return error.BadPath;
+ const byte = (hi << 4) | lo;
+ if (byte == 0 or byte == '/') return error.BadPath;
+ if (out >= storage.len) return error.BadPath;
+ storage[out] = byte;
+ out += 1;
+ i += 3;
+ } else {
+ if (out >= storage.len) return error.BadPath;
+ storage[out] = ch;
+ out += 1;
+ i += 1;
+ }
+ }
+ const comp = storage[begin..out];
+ if (std.mem.eql(u8, comp, ".")) {
+ out = begin;
+ continue;
+ }
+ if (std.mem.eql(u8, comp, "..")) return error.BadPath;
+ if (count >= max_components) return error.BadPath;
+ names[count] = comp;
+ count += 1;
+ }
+ return count;
+}
+
+fn hex(c: u8) ?u8 {
+ return switch (c) {
+ '0'...'9' => c - '0',
+ 'a'...'f' => c - 'a' + 10,
+ 'A'...'F' => c - 'A' + 10,
+ else => null,
+ };
+}
+
+fn writeHtml(w: *std.Io.Writer, text: []const u8) void {
+ for (text) |c| switch (c) {
+ '<' => w.writeAll("&lt;") catch {},
+ '>' => w.writeAll("&gt;") catch {},
+ '&' => w.writeAll("&amp;") catch {},
+ '"' => w.writeAll("&quot;") catch {},
+ else => w.writeByte(c) catch {},
+ };
+}
+
+fn writeEntryJson(w: *std.Io.Writer, e: wire.Stat) !void {
+ const dir = e.qid.type & c9.qtdir != 0;
+ try w.writeAll("{\"name\":");
+ try std.json.Stringify.value(e.name, .{}, w);
+ try w.print(",\"dir\":{s},\"length\":{d},\"mode\":{d},\"mtime\":{d},\"qid\":{{\"type\":{d},\"version\":{d},\"path\":{d}}}}}", .{
+ if (dir) "true" else "false",
+ e.length,
+ e.mode,
+ e.mtime,
+ e.qid.type,
+ e.qid.version,
+ e.qid.path,
+ });
+}