//! An HTTP view of the upstream 9P tree under `/fs/`, served through the //! shared `Mux` at the fid level. It is file operations, not verbs: HTTP //! methods map onto the tree. //! //! GET /fs/ bytes (Content-Type sniffed; Range honored) //! GET /fs/?follow=1 server-sent events, one per blocking read //! GET /fs/ JSON array of entries (HTML with Accept: text/html) //! HEAD /fs/ stat as headers //! PUT /fs/ write (create if missing; truncate unless ?append=1) //! PUT /fs// create a directory //! DELETE /fs/ 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("") catch return error.Upstream; writeHtml(&w, st.name); w.writeAll("
    ") 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("
  • ") catch return error.Upstream; writeHtml(&w, entry.name); if (entry.qid.type & c9.qtdir != 0) w.writeByte('/') catch {}; w.writeAll("
  • ") 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("
") 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("<") catch {}, '>' => w.writeAll(">") catch {}, '&' => w.writeAll("&") catch {}, '"' => w.writeAll(""") 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, }); }