summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-14 14:28:15 -0300
committerGabriel Schneider <[email protected]>2026-09-16 11:18:48 -0300
commitae310a207534b33b7321dd2b9f423a73b1969159 (patch)
treeb4c2e85091a4ff6657e0a04ba1b2ef030d588ac8
parent5f24c4a2284a0af84fb9d116f7a39f6a58e42ae9 (diff)
downloadcloud9-ae310a207534b33b7321dd2b9f423a73b1969159.tar.gz
cloud9-ae310a207534b33b7321dd2b9f423a73b1969159.zip
Add reusable HTTP transport, serial gateway, and WASM file browser
-rw-r--r--README.md34
-rw-r--r--app/client.zig176
-rw-r--r--app/main.zig204
-rw-r--r--app/web/app.mjs183
-rw-r--r--app/web/client.mjs138
-rw-r--r--app/web/index.html53
-rw-r--r--app/web/style.css87
-rw-r--r--build.zig53
-rw-r--r--build.zig.zon2
-rw-r--r--docs/design.md7
-rw-r--r--docs/http.md216
-rw-r--r--docs/results/http-e2e.json45
-rw-r--r--docs/validation.md30
-rw-r--r--src/http.zig339
-rw-r--r--src/root.zig1
-rw-r--r--test/differential/webfixture/main.go83
-rw-r--r--test/http.zig92
-rw-r--r--test/web/e2e.mjs268
-rw-r--r--test/web/native.zig66
-rw-r--r--test/web/serial.py25
20 files changed, 2099 insertions, 3 deletions
diff --git a/README.md b/README.md
index 226ec78..57de62e 100644
--- a/README.md
+++ b/README.md
@@ -5,14 +5,35 @@ A Zig 0.16 library for base **9P2000** clients, servers, and transports.
The protocol core uses caller-owned buffers and bounded request tables. It has
no heap allocation, OS calls, threads, or filesystem policy. TCP and Unix stream
adapters support `std.Io`; nonblocking POSIX adapters support existing event loops.
+HTTP/WebSocket transport supports native HTTPS and caller-owned byte streams.
Optional QUIC transport takes caller-provided OpenSSL bindings and an ALPN name.
Mounting, namespace discovery, exported trees, permissions, and application
lifecycle stay with the application. There are no 9P2000.u or 9P2000.L messages.
+Run the HTTP gateway and its WebAssembly file browser:
+
+```sh
+zig build serve -Doptimize=ReleaseSafe -- --upstream tcp:127.0.0.1:564
+# Open http://127.0.0.1:8080; files load automatically
+```
+
+This requires a running 9P server at `127.0.0.1:564`; the gateway does not start
+one. Set `--upstream unix:/actual/path/to/9p.sock` to use a running Pardes instance.
+If port 8080 is occupied, add `--listen 127.0.0.1:0` and open the URL printed at
+startup. Browser attach defaults can be set with `--user NAME --tree NAME`;
+there is no connection form in the page. File links use ordinary paths such as
+`/self/listeners`; gateway assets and endpoints live under `/_cloud9/`.
+
+It also accepts Unix sockets and configured serial devices. The HTTP transport
+is public library code; [HTTP/HTTPS usage and the browser tests](docs/http.md)
+explain how to embed it and where HTTP/3 support belongs.
+
```sh
zig build test
zig build transport-test
+zig build http-test
+zig build e2e -Doptimize=ReleaseSafe # Chromium + native HTTPS + Unix + serial
zig build quic-test -Dquic=true # system OpenSSL 3.6+
zig build fuzz -Doptimize=ReleaseSafe -- 4200 1000000
zig build differential -Doptimize=ReleaseSafe -- --seed 4200 --rounds 1000
@@ -25,8 +46,17 @@ and saves a corpus, configuration, and JSON report under
runs the cloud9 client against go9p's filesystem server over local pipes.
Malformed-input probes run only against cloud9. No remote targets are contacted.
-To use a sibling checkout, add `.cloud9 = .{ .path = "../cloud9" }` to the package
-dependencies, then import its module:
+Consume the published package by pinning a commit in `build.zig.zon` — this is
+what makes a build reproducible from the manifest alone:
+
+```sh
+zig fetch --save=cloud9 git+https://git.sr.ht/~gbrls/cloud9#<commit>
+```
+
+The toolchain has no `git+ssh` support, so the manifest carries the read-only
+HTTPS URL. Push to `[email protected]:~gbrls/cloud9`. For work on both packages at
+once, substitute `.cloud9 = .{ .path = "../cloud9" }` while editing, then
+re-pin. Either way, import the module the same way:
```zig
const cloud9 = b.dependency("cloud9", .{
diff --git a/app/client.zig b/app/client.zig
new file mode 100644
index 0000000..2ec9163
--- /dev/null
+++ b/app/client.zig
@@ -0,0 +1,176 @@
+//! Browser ABI. Protocol state, encoding, reply validation, and directory decoding
+//! all use the same cloud9 sources as native clients. No allocator or host imports.
+const std = @import("std");
+const c9 = @import("cloud9");
+const capacity = 65536;
+var input: [capacity]u8 = undefined;
+var output: [capacity]u8 = undefined;
+var staging: [capacity]u8 = undefined;
+var json: [capacity * 2]u8 = undefined;
+var client: c9.Client = undefined;
+var data: []const u8 = "";
+var number: u32 = 0;
+var directory: bool = false;
+
+export fn init() void {
+ client = .init(.{ .in = &input, .out = &output });
+ data = "";
+ number = 0;
+ directory = false;
+}
+export fn input_ptr() [*]u8 {
+ return &staging;
+}
+export fn input_capacity() u32 {
+ return capacity;
+}
+export fn output_ptr() [*]const u8 {
+ return client.output().ptr;
+}
+export fn output_len() u32 {
+ return @intCast(client.output().len);
+}
+export fn sent() void {
+ client.wrote(client.output().len);
+}
+export fn data_ptr() [*]const u8 {
+ return data.ptr;
+}
+export fn data_len() u32 {
+ return @intCast(data.len);
+}
+export fn result_number() u32 {
+ return number;
+}
+export fn result_directory() bool {
+ return directory;
+}
+export fn is_dead() bool {
+ return client.dead;
+}
+export fn max_read() u32 {
+ return client.maxRead();
+}
+export fn max_write() u32 {
+ return client.maxWrite();
+}
+
+fn fail(message: []const u8) i32 {
+ data = message;
+ return -1;
+}
+fn submit(request: c9.Client.Request) i32 {
+ _ = client.submit(request) catch |err| return fail(@errorName(err));
+ return 0;
+}
+export fn version() i32 {
+ return submit(.{ .version = .{} });
+}
+export fn attach(user_len: u32, tree_len: u32) i32 {
+ if (user_len > capacity or tree_len > capacity - user_len) return fail("InputTooLarge");
+ return submit(.{ .attach = .{ .fid = 0, .uname = staging[0..user_len], .aname = staging[user_len..][0..tree_len] } });
+}
+export fn walk(fid: u32, newfid: u32, len: u32) i32 {
+ if (len > capacity) return fail("InputTooLarge");
+ var names: [c9.max_welem][]const u8 = undefined;
+ var count: usize = 0;
+ var it = std.mem.splitScalar(u8, staging[0..len], '/');
+ while (it.next()) |name| {
+ if (name.len == 0) continue;
+ if (count == names.len) return fail("TooManyNames");
+ names[count] = name;
+ count += 1;
+ }
+ return submit(.{ .walk = .{ .fid = fid, .newfid = newfid, .names = names[0..count] } });
+}
+export fn open(fid: u32, mode: u8) i32 {
+ return submit(.{ .open = .{ .fid = fid, .mode = mode } });
+}
+export fn read(fid: u32, offset: u64, count: u32) i32 {
+ return submit(.{ .read = .{ .fid = fid, .offset = offset, .count = count } });
+}
+export fn write(fid: u32, offset: u64, len: u32) i32 {
+ if (len > capacity) return fail("InputTooLarge");
+ return submit(.{ .write = .{ .fid = fid, .offset = offset, .data = staging[0..len] } });
+}
+export fn clunk(fid: u32) i32 {
+ return submit(.{ .clunk = .{ .fid = fid } });
+}
+export fn stat(fid: u32) i32 {
+ return submit(.{ .stat = .{ .fid = fid } });
+}
+
+fn writeStat(s: c9.Stat, writer: *std.Io.Writer) !void {
+ try std.json.Stringify.value(.{ .name = s.name, .directory = s.qid.type & c9.qtdir != 0, .length = s.length, .mode = s.mode }, .{}, writer);
+}
+
+/// Result codes: 0 incomplete, -1 error, 1 version, 2 attach, 3 walk,
+/// 4 open, 5 read, 6 write, 7 clunk, 8 stat. Data is borrowed until next call.
+export fn receive(len: u32) i32 {
+ if (len > capacity) return fail("InputTooLarge");
+ if (client.push(staging[0..len]) != len) return fail("InputFull");
+ const done = client.take() orelse return if (client.dead) fail("ProtocolError") else 0;
+ data = "";
+ number = 0;
+ directory = false;
+ return switch (done.result) {
+ .fail => |message| fail(message),
+ .version => |v| blk: {
+ data = v.version;
+ number = v.msize;
+ break :blk 1;
+ },
+ .attach => |qid| blk: {
+ directory = qid.type & c9.qtdir != 0;
+ break :blk 2;
+ },
+ .walk => |w| blk: {
+ number = w.nwqid;
+ if (w.nwqid != 0) directory = w.wqid[w.nwqid - 1].type & c9.qtdir != 0;
+ break :blk 3;
+ },
+ .open => |o| blk: {
+ number = o.iounit;
+ directory = o.qid.type & c9.qtdir != 0;
+ break :blk 4;
+ },
+ .read => |bytes| blk: {
+ data = bytes;
+ break :blk 5;
+ },
+ .write => |count| blk: {
+ number = count;
+ break :blk 6;
+ },
+ .clunk => 7,
+ .stat => |s| blk: {
+ var writer: std.Io.Writer = .fixed(&json);
+ writeStat(s, &writer) catch return fail("OutputTooLarge");
+ data = writer.buffered();
+ break :blk 8;
+ },
+ else => fail("UnexpectedReply"),
+ };
+}
+
+/// Parse complete directory stat records from one Rread, using cloud9.Stat.
+export fn decode_directory(len: u32) i32 {
+ if (len > capacity) return fail("InputTooLarge");
+ var remaining: []const u8 = staging[0..len];
+ var writer: std.Io.Writer = .fixed(&json);
+ writer.writeByte('[') catch unreachable;
+ var first = true;
+ while (remaining.len != 0) {
+ if (remaining.len < 2) return fail("TruncatedDirectory");
+ const size: usize = @as(usize, std.mem.readInt(u16, remaining[0..2], .little)) + 2;
+ if (size > remaining.len) return fail("TruncatedDirectory");
+ const s = c9.Stat.decode(remaining[0..size]) catch return fail("InvalidDirectory");
+ if (!first) writer.writeByte(',') catch return fail("OutputTooLarge");
+ writeStat(s, &writer) catch return fail("OutputTooLarge");
+ first = false;
+ remaining = remaining[size..];
+ }
+ writer.writeByte(']') catch return fail("OutputTooLarge");
+ data = writer.buffered();
+ return 0;
+}
diff --git a/app/main.zig b/app/main.zig
new file mode 100644
index 0000000..720503f
--- /dev/null
+++ b/app/main.zig
@@ -0,0 +1,204 @@
+//! HTTP application; no web or namespace policy is added to the cloud9 library.
+const std = @import("std");
+const c9 = @import("cloud9");
+const Io = std.Io;
+const max_frame = 65536;
+const max_connections = 32;
+var serial_busy: std.atomic.Value(bool) = .init(false);
+const Upstream = union(enum) { network: c9.transport.Address, file: []const u8 };
+var connections: std.atomic.Value(u32) = .init(0);
+
+const Config = struct {
+ upstream: Upstream,
+ host: []const u8,
+ origin: []const u8,
+ public_host: []const u8,
+ timeout_ms: u32,
+ browser_config: []const u8,
+};
+
+pub fn main(init: std.process.Init) !void {
+ const allocator = init.arena.allocator();
+ const args = try init.minimal.args.toSlice(allocator);
+ var listen_text: []const u8 = "127.0.0.1:8080";
+ var upstream_text: []const u8 = "tcp:127.0.0.1:564";
+ var timeout_ms: u32 = 300000;
+ var user: []const u8 = "user";
+ var tree: []const u8 = "";
+ var public_origin: ?[]const u8 = null;
+ var i: usize = 1;
+ while (i < args.len) : (i += 1) {
+ if (std.mem.eql(u8, args[i], "--help")) {
+ std.debug.print("Usage: cloud9-http [--listen IP:PORT] [--upstream tcp:IP:PORT|unix:PATH|file:PATH] [--origin https://HOST:PORT] [--timeout-ms 300000] [--user NAME] [--tree NAME]\nDefault: http://127.0.0.1:8080 -> tcp:127.0.0.1:564\n", .{});
+ return;
+ }
+ if (i + 1 == args.len) return error.MissingArgument;
+ if (std.mem.eql(u8, args[i], "--listen")) {
+ i += 1;
+ listen_text = args[i];
+ } else if (std.mem.eql(u8, args[i], "--upstream")) {
+ i += 1;
+ upstream_text = args[i];
+ } else if (std.mem.eql(u8, args[i], "--origin")) {
+ i += 1;
+ public_origin = args[i];
+ } else if (std.mem.eql(u8, args[i], "--timeout-ms")) {
+ i += 1;
+ timeout_ms = try std.fmt.parseInt(u32, args[i], 10);
+ } else if (std.mem.eql(u8, args[i], "--user")) {
+ i += 1;
+ user = args[i];
+ } else if (std.mem.eql(u8, args[i], "--tree")) {
+ i += 1;
+ tree = args[i];
+ } else return error.UnknownArgument;
+ }
+ const address = try Io.net.IpAddress.parseLiteral(listen_text);
+ const upstream: Upstream = if (std.mem.startsWith(u8, upstream_text, "tcp:"))
+ .{ .network = .{ .tcp = try Io.net.IpAddress.parseLiteral(upstream_text[4..]) } }
+ else if (std.mem.startsWith(u8, upstream_text, "unix:"))
+ .{ .network = .{ .unix = try allocator.dupeZ(u8, upstream_text[5..]) } }
+ else if (std.mem.startsWith(u8, upstream_text, "file:"))
+ .{ .file = upstream_text[5..] }
+ else
+ return error.InvalidUpstream;
+ const io = init.io;
+ var listener = c9.transport.listen(io, .{ .tcp = address }, max_connections) catch |err| {
+ if (err == error.AddressInUse) {
+ std.debug.print("cloud9-http: cannot listen on {s}: address already in use.\nUse --listen 127.0.0.1:0 to select a free port; the URL is printed at startup.\n", .{listen_text});
+ } else {
+ std.debug.print("cloud9-http: cannot listen on {s}: {s}\n", .{ listen_text, @errorName(err) });
+ }
+ return err;
+ };
+ defer listener.deinit(io);
+ const host = try std.fmt.allocPrint(allocator, "{f}", .{listener.socket.address});
+ const origin_text = public_origin orelse try std.fmt.allocPrint(allocator, "http://{s}", .{host});
+ const origin_uri = try std.Uri.parse(origin_text);
+ if ((!std.mem.eql(u8, origin_uri.scheme, "https") and !std.mem.eql(u8, origin_uri.scheme, "http")) or
+ origin_uri.host == null or origin_uri.user != null or origin_uri.password != null or
+ origin_uri.path.percent_encoded.len != 0 or origin_uri.query != null or origin_uri.fragment != null) return error.InvalidOrigin;
+ if (user.len > 256 or tree.len > 256) return error.AttachNameTooLong;
+ const browser_config = try std.json.Stringify.valueAlloc(allocator, .{ .user = user, .tree = tree }, .{});
+ const config: Config = .{ .upstream = upstream, .host = host, .origin = origin_text, .public_host = origin_text[origin_uri.scheme.len + 3 ..], .timeout_ms = timeout_ms, .browser_config = browser_config };
+ std.debug.print("cloud9-http {s} -> {s}\n", .{ config.origin, upstream_text });
+ var group: Io.Group = .init;
+ defer group.cancel(io);
+ while (true) {
+ const stream = try listener.accept(io);
+ if (connections.fetchAdd(1, .monotonic) >= max_connections) {
+ _ = connections.fetchSub(1, .monotonic);
+ stream.close(io);
+ continue;
+ }
+ group.concurrent(io, handle, .{ io, stream, config }) catch |err| {
+ _ = connections.fetchSub(1, .monotonic);
+ stream.close(io);
+ return err;
+ };
+ }
+}
+
+fn handle(io: Io, stream: Io.net.Stream, config: Config) void {
+ defer _ = connections.fetchSub(1, .monotonic);
+ defer stream.close(io);
+ var work: Connection = .{ .io = io, .stream = stream, .config = config };
+ var group: Io.Group = .init;
+ defer group.cancel(io);
+ group.concurrent(io, Connection.run, .{&work}) catch return;
+ const timeout: Io.Timeout = if (config.timeout_ms == 0) .none else .{ .duration = .{ .raw = .fromMilliseconds(config.timeout_ms), .clock = .awake } };
+ work.done.waitTimeout(io, timeout) catch {};
+}
+
+const Connection = struct {
+ io: Io,
+ stream: Io.net.Stream,
+ config: Config,
+ done: Io.Event = .unset,
+ fn run(connection: *Connection) void {
+ defer connection.done.set(connection.io);
+ serve(connection.io, connection.stream, connection.config) catch {};
+ }
+};
+
+fn respond(request: *std.http.Server.Request, content: []const u8, mime: []const u8, status: std.http.Status) !void {
+ try request.respond(content, .{ .status = status, .keep_alive = false, .extra_headers = &.{
+ .{ .name = "content-type", .value = mime },
+ .{ .name = "cache-control", .value = "no-store" },
+ .{ .name = "x-content-type-options", .value = "nosniff" },
+ .{ .name = "content-security-policy", .value = "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'" },
+ } });
+}
+
+fn serve(io: Io, stream: Io.net.Stream, config: Config) !void {
+ var input: [max_frame + 14]u8 = undefined;
+ var output: [8192]u8 = undefined;
+ var reader = stream.reader(io, &input);
+ var writer = stream.writer(io, &output);
+ var http: std.http.Server = .init(&reader.interface, &writer.interface);
+ http.reader.max_head_len = 8192;
+ var request = try http.receiveHead();
+ var host: ?[]const u8 = null;
+ var origin: ?[]const u8 = null;
+ var version: ?[]const u8 = null;
+ var headers = request.iterateHeaders();
+ while (headers.next()) |header| {
+ if (std.ascii.eqlIgnoreCase(header.name, "host")) host = header.value;
+ if (std.ascii.eqlIgnoreCase(header.name, "origin")) origin = header.value;
+ if (std.ascii.eqlIgnoreCase(header.name, "sec-websocket-version")) version = header.value;
+ }
+ if (!std.mem.eql(u8, host orelse "", config.host) and !std.mem.eql(u8, host orelse "", config.public_host)) return respond(&request, "Unexpected Host\n", "text/plain", .forbidden);
+ if (request.head.method != .GET) return respond(&request, "Use GET\n", "text/plain", .method_not_allowed);
+ const path = std.mem.sliceTo(request.head.target, '?');
+ if (std.mem.eql(u8, path, "/_cloud9/config.json")) return respond(&request, config.browser_config, "application/json", .ok);
+ if (std.mem.eql(u8, path, "/_cloud9/app.mjs")) return respond(&request, @embedFile("web/app.mjs"), "text/javascript; charset=utf-8", .ok);
+ if (std.mem.eql(u8, path, "/_cloud9/client.mjs")) return respond(&request, @embedFile("web/client.mjs"), "text/javascript; charset=utf-8", .ok);
+ if (std.mem.eql(u8, path, "/_cloud9/style.css")) return respond(&request, @embedFile("web/style.css"), "text/css; charset=utf-8", .ok);
+ if (std.mem.eql(u8, path, "/_cloud9/cloud9.wasm")) return respond(&request, @embedFile("client.wasm"), "application/wasm", .ok);
+ if (!std.mem.eql(u8, path, "/_cloud9/9p")) {
+ if (std.mem.eql(u8, path, "/_cloud9") or std.mem.startsWith(u8, path, "/_cloud9/"))
+ return respond(&request, "Not found\n", "text/plain", .not_found);
+ // File URLs boot the same browser client. The client resolves the path
+ // in the upstream 9P namespace, never in this machine's filesystem.
+ return respond(&request, @embedFile("web/index.html"), "text/html; charset=utf-8", .ok);
+ }
+ if (!std.mem.eql(u8, origin orelse "", config.origin)) return respond(&request, "Unexpected Origin\n", "text/plain", .forbidden);
+ if (!std.mem.eql(u8, version orelse "", "13")) return respond(&request, "WebSocket version 13 required\n", "text/plain", .bad_request);
+ switch (config.upstream) {
+ .network => |address| {
+ const upstream = c9.transport.connect(io, address) catch return respond(&request, "9P upstream unavailable\n", "text/plain", .bad_gateway);
+ defer upstream.close(io);
+ var in_buffer: [8192]u8 = undefined;
+ var out_buffer: [8192]u8 = undefined;
+ var upstream_reader = upstream.reader(io, &in_buffer);
+ var upstream_writer = upstream.writer(io, &out_buffer);
+ try bridge(io, &request, &upstream_reader.interface, &upstream_writer.interface);
+ },
+ .file => |path_name| {
+ if (serial_busy.swap(true, .acquire)) return respond(&request, "Device already in use\n", "text/plain", .service_unavailable);
+ defer serial_busy.store(false, .release);
+ const file = Io.Dir.cwd().openFile(io, path_name, .{ .mode = .read_write }) catch return respond(&request, "9P device unavailable\n", "text/plain", .bad_gateway);
+ defer file.close(io);
+ var in_buffer: [8192]u8 = undefined;
+ var out_buffer: [8192]u8 = undefined;
+ var upstream_reader = file.readerStreaming(io, &in_buffer);
+ var upstream_writer = file.writerStreaming(io, &out_buffer);
+ try bridge(io, &request, &upstream_reader.interface, &upstream_writer.interface);
+ },
+ }
+}
+
+fn bridge(io: Io, request: *std.http.Server.Request, reader: *Io.Reader, writer: *Io.Writer) !void {
+ var ws = try c9.http.accept(request);
+ var requests: [max_frame]u8 = undefined;
+ var replies: [max_frame]u8 = undefined;
+ var relay: c9.http.Bridge = .{
+ .socket = &ws,
+ .upstream_reader = reader,
+ .upstream_writer = writer,
+ .request_buffer = &requests,
+ .reply_buffer = &replies,
+ .frame_limit = max_frame,
+ };
+ try relay.run(io, .none);
+}
diff --git a/app/web/app.mjs b/app/web/app.mjs
new file mode 100644
index 0000000..a6985f0
--- /dev/null
+++ b/app/web/app.mjs
@@ -0,0 +1,183 @@
+import { Client } from './client.mjs';
+const $ = id => document.getElementById(id);
+const text = new TextDecoder('utf-8', { fatal: true });
+let client = null, current = null, currentPath = '/', busy = false, dirty = false;
+let configuration = null;
+
+function status(message, error = false) {
+ $('status').textContent = message;
+ $('status').classList.toggle('error', error);
+}
+function controls() {
+ for (const id of ['go', 'reload']) $(id).disabled = busy;
+ $('save').disabled = busy || !current || current.stat.directory || $('editor').hidden || !dirty;
+ $('download').disabled = busy || !current;
+ $('editor').readOnly = busy;
+ $('edit-status').textContent = dirty ? 'Unsaved changes.' : 'No unsaved changes.';
+ document.querySelector('main').setAttribute('aria-busy', String(busy));
+}
+async function action(operation) {
+ if (busy) return;
+ busy = true;
+ controls();
+ try { await operation(); }
+ catch (error) { status(error.message, true); }
+ finally { busy = false; controls(); }
+}
+async function connectedClient() {
+ if (client && !client.closed) return client;
+ if (!configuration) {
+ const response = await fetch('/_cloud9/config.json');
+ if (!response.ok) throw new Error('Unable to load server settings. Reload to try again.');
+ configuration = await response.json();
+ }
+ try { client = await Client.connect('/_cloud9/9p', configuration.user, configuration.tree); }
+ catch { throw new Error('Unable to reach the file server. Reload to try again.'); }
+ return client;
+}
+async function readPath(path) {
+ const active = await connectedClient();
+ try { return await active.readPath(path); }
+ catch (error) {
+ // A timed-out/closed transport has lost its fids. Retry reads once on a
+ // fresh session. Writes are never replayed after an uncertain result.
+ if (!active.closed) throw error;
+ return (await connectedClient()).readPath(path);
+ }
+}
+function normalizePath(path) {
+ const parts = [];
+ for (const name of path.split('/')) {
+ if (!name || name === '.') continue;
+ if (name === '..') parts.pop(); else parts.push(name);
+ }
+ return '/' + parts.join('/');
+}
+function pathURL(path) {
+ const parts = path.split('/').map(encodeURIComponent);
+ // Preserve access to an upstream directory named _cloud9 without colliding
+ // with the literal gateway prefix. Decode each component exactly once.
+ if (parts[1] === '_cloud9') parts[1] = '%5Fcloud9';
+ return parts.join('/');
+}
+function locationPath() {
+ const parts = location.pathname.split('/').map(part => {
+ let name;
+ try { name = decodeURIComponent(part); }
+ catch { throw new Error('The URL contains invalid path encoding.'); }
+ if (name.includes('/') || name.includes('\0')) throw new Error('The URL contains an invalid path component.');
+ return name;
+ });
+ return normalizePath(parts.join('/'));
+}
+function child(path, name) { return `${path.replace(/\/$/, '')}/${name}`; }
+function mode(stat) {
+ let result = stat.directory ? 'd' : '-';
+ for (let bit = 8; bit >= 0; bit--) result += stat.mode & (1 << bit) ? 'rwx'[(8 - bit) % 3] : '-';
+ return result;
+}
+function pathLink(label, path, className = '') {
+ const link = document.createElement('a');
+ link.textContent = label;
+ link.href = pathURL(path);
+ link.dataset.path = path;
+ link.className = className;
+ return link;
+}
+function breadcrumbs(path) {
+ const node = $('breadcrumbs');
+ node.replaceChildren(pathLink('root', '/'));
+ let walked = '';
+ for (const name of path.split('/').filter(Boolean)) {
+ walked += '/' + name;
+ const separator = document.createElement('span');
+ separator.textContent = '/'; separator.className = 'separator';
+ node.append(separator, pathLink(name, walked));
+ }
+ node.lastElementChild.setAttribute('aria-current', 'page');
+ const parent = path.replace(/\/[^/]+$/, '') || '/';
+ $('up').hidden = path === '/';
+ $('up').href = pathURL(parent);
+ $('up').dataset.path = parent;
+}
+async function navigate(path, historyMode = 'push') {
+ path = normalizePath(path);
+ status('Loading…');
+ const result = await readPath(path);
+ current = result; currentPath = path; dirty = false;
+ $('path').value = path;
+ const url = pathURL(path);
+ if (historyMode === 'push' && location.pathname + location.search + location.hash !== url) history.pushState(null, '', url);
+ document.title = `${path} — cloud9`;
+ breadcrumbs(path);
+ $('directory').hidden = !result.stat.directory;
+ $('file').hidden = result.stat.directory;
+ if (result.stat.directory) {
+ const rows = document.createDocumentFragment();
+ const sorted = result.entries.sort((a, b) => Number(b.directory) - Number(a.directory) || a.name.localeCompare(b.name));
+ for (const entry of sorted) {
+ const row = document.createElement('tr');
+ const permissions = document.createElement('td'), name = document.createElement('td'), size = document.createElement('td');
+ permissions.className = 'mode'; permissions.textContent = mode(entry);
+ name.append(pathLink(entry.name + (entry.directory ? '/' : ''), child(path, entry.name), 'entry' + (entry.directory ? ' directory' : '')));
+ size.className = 'size'; size.textContent = entry.directory ? '—' : entry.length.toLocaleString();
+ row.append(permissions, name, size); rows.append(row);
+ }
+ $('entries').replaceChildren(rows);
+ $('empty').hidden = sorted.length !== 0;
+ status(`${sorted.length} ${sorted.length === 1 ? 'entry' : 'entries'}`);
+ } else {
+ $('filename').textContent = result.stat.name;
+ $('file-mode').textContent = mode(result.stat);
+ $('file-size').textContent = `${result.bytes.length.toLocaleString()} bytes`;
+ let editable = true;
+ try {
+ if (result.bytes.includes(0)) throw new Error('Binary');
+ $('editor').value = text.decode(result.bytes);
+ } catch { editable = false; $('editor').value = ''; }
+ $('editor').hidden = !editable;
+ $('binary').hidden = editable;
+ $('edit-actions').hidden = !editable;
+ status('File loaded.');
+ }
+}
+function mayLeave() { return !dirty || window.confirm('Discard unsaved changes?'); }
+document.addEventListener('click', event => {
+ const link = event.target.closest('a[data-path]');
+ if (!link || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
+ event.preventDefault();
+ if (!busy && mayLeave()) action(() => navigate(link.dataset.path));
+});
+$('path-form').onsubmit = event => {
+ event.preventDefault();
+ if (mayLeave()) action(() => navigate($('path').value));
+};
+$('reload').onclick = () => { if (mayLeave()) action(() => navigate(current ? currentPath : locationPath(), 'none')); };
+$('editor').oninput = () => { dirty = true; controls(); };
+$('save').onclick = () => action(async () => {
+ const bytes = new TextEncoder().encode($('editor').value);
+ const active = await connectedClient();
+ let count;
+ try { count = await active.writePath(currentPath, bytes); }
+ catch (error) {
+ if (active.closed) throw new Error('Save interrupted. The file may be partially written; your edits are still here.');
+ throw error;
+ }
+ // Keep the editor intact if refreshing after a successful write fails.
+ dirty = false;
+ await navigate(currentPath, 'none');
+ status(`Saved ${count.toLocaleString()} bytes.`);
+});
+$('download').onclick = () => {
+ const url = URL.createObjectURL(new Blob([current.bytes]));
+ const link = document.createElement('a'); link.href = url; link.download = current.stat.name; link.click();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+};
+window.addEventListener('popstate', () => {
+ if (busy || !mayLeave()) { history.pushState(null, '', pathURL(currentPath)); return; }
+ action(() => navigate(locationPath(), 'none'));
+});
+window.addEventListener('beforeunload', event => {
+ if (dirty) { event.preventDefault(); event.returnValue = ''; }
+});
+action(() => navigate(locationPath(), 'none'));
diff --git a/app/web/client.mjs b/app/web/client.mjs
new file mode 100644
index 0000000..69f44d2
--- /dev/null
+++ b/app/web/client.mjs
@@ -0,0 +1,138 @@
+// Browser transport and file operations. All 9P serialization lives in WASM.
+const encoder = new TextEncoder();
+const decoder = new TextDecoder();
+export class Client {
+ static async connect(url = '/_cloud9/9p', user = 'user', tree = '') {
+ const { instance } = await WebAssembly.instantiateStreaming(fetch(new URL('./cloud9.wasm', import.meta.url)), {});
+ const wsURL = new URL(url, location.href);
+ wsURL.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const socket = new WebSocket(wsURL);
+ socket.binaryType = 'arraybuffer';
+ const client = new Client(instance.exports, socket);
+ try {
+ await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error('Connection timed out')), 10000);
+ socket.onopen = () => { clearTimeout(timer); resolve(); };
+ socket.onerror = () => { clearTimeout(timer); reject(new Error('Cannot connect to 9P bridge')); };
+ });
+ await client.ask('version');
+ const u = encoder.encode(user), a = encoder.encode(tree);
+ client.stage(new Uint8Array([...u, ...a]));
+ await client.ask('attach', u.length, a.length);
+ return client;
+ } catch (error) { client.close(); throw error; }
+ }
+ constructor(wasm, socket) {
+ this.wasm = wasm;
+ this.socket = socket;
+ this.pending = null;
+ this.queue = Promise.resolve();
+ this.closed = false;
+ wasm.init();
+ socket.onmessage = ({ data }) => {
+ if (!this.pending) { this.close(); return; }
+ try {
+ this.stage(new Uint8Array(data));
+ const kind = wasm.receive(data.byteLength);
+ if (!kind) return;
+ const result = { kind, data: this.data(), number: wasm.result_number(), directory: Boolean(wasm.result_directory()) };
+ const pending = this.pending;
+ this.pending = null;
+ clearTimeout(pending.timer);
+ if (kind < 0) {
+ const error = new Error(decoder.decode(result.data));
+ pending.reject(error);
+ if (wasm.is_dead()) this.abort(error);
+ }
+ else pending.resolve(result);
+ } catch (error) { this.abort(error); }
+ };
+ socket.onclose = () => this.abort(new Error('Connection closed'));
+ }
+ data() { return new Uint8Array(this.wasm.memory.buffer, this.wasm.data_ptr(), this.wasm.data_len()).slice(); }
+ stage(bytes) {
+ if (bytes.length > this.wasm.input_capacity()) throw new Error('Input exceeds 64 KiB');
+ new Uint8Array(this.wasm.memory.buffer, this.wasm.input_ptr(), bytes.length).set(bytes);
+ return bytes.length;
+ }
+ ask(operation, ...args) {
+ if (this.closed) return Promise.reject(new Error('Connection closed'));
+ if (this.pending) return Promise.reject(new Error('Request already in flight'));
+ if (this.wasm[operation](...args) < 0) return Promise.reject(new Error(decoder.decode(this.data())));
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => this.abort(new Error('9P request timed out')), 15000);
+ this.pending = { resolve, reject, timer };
+ try {
+ this.socket.send(new Uint8Array(this.wasm.memory.buffer, this.wasm.output_ptr(), this.wasm.output_len()));
+ this.wasm.sent();
+ } catch (error) { this.abort(error); }
+ });
+ }
+ abort(error) {
+ this.closed = true;
+ if (this.pending) { clearTimeout(this.pending.timer); this.pending.reject(error); this.pending = null; }
+ this.socket.close();
+ }
+ close() { this.abort(new Error('Disconnected')); }
+ serial(operation) {
+ const result = this.queue.then(operation);
+ this.queue = result.catch(() => {});
+ return result;
+ }
+ async withFile(path, operation) {
+ const names = path.split('/').filter(Boolean);
+ let live = false;
+ try {
+ // Walk in spec-sized batches, retaining an unopened root fid.
+ for (let index = 0; index < Math.max(1, names.length); index += 16) {
+ const batch = names.slice(index, index + 16);
+ const len = this.stage(encoder.encode(batch.join('/')));
+ const result = await this.ask('walk', index === 0 ? 0 : 1, 1, len);
+ live = live || result.number > 0 || batch.length === 0;
+ if (result.number !== batch.length) throw new Error('Path does not exist');
+ }
+ return await operation();
+ } finally {
+ if (live && !this.closed) await this.ask('clunk', 1);
+ }
+ }
+ readPath(path) {
+ return this.serial(() => this.withFile(path, async () => {
+ const stat = JSON.parse(decoder.decode((await this.ask('stat', 1)).data));
+ const opened = await this.ask('open', 1, 0);
+ const count = Math.min(this.wasm.max_read(), opened.number || Infinity);
+ let offset = 0;
+ const chunks = [], entries = [];
+ while (true) {
+ const { data } = await this.ask('read', 1, BigInt(offset), count);
+ if (!data.length) break;
+ offset += data.length;
+ if (offset > 8 * 1024 * 1024) throw new Error('Browser view is limited to 8 MiB');
+ if (stat.directory) {
+ this.stage(data);
+ if (this.wasm.decode_directory(data.length) < 0) throw new Error(decoder.decode(this.data()));
+ entries.push(...JSON.parse(decoder.decode(this.data())));
+ } else chunks.push(data);
+ }
+ const bytes = new Uint8Array(stat.directory ? 0 : offset);
+ let position = 0;
+ for (const chunk of chunks) { bytes.set(chunk, position); position += chunk.length; }
+ return { stat, entries, bytes };
+ }));
+ }
+ writePath(path, bytes) {
+ if (!(bytes instanceof Uint8Array) || bytes.length > 8 * 1024 * 1024) return Promise.reject(new Error('Write is limited to 8 MiB'));
+ return this.serial(() => this.withFile(path, async () => {
+ const opened = await this.ask('open', 1, 1 | 16); // OWRITE | OTRUNC
+ const count = Math.min(this.wasm.max_write(), opened.number || Infinity);
+ let offset = 0;
+ while (offset < bytes.length) {
+ const len = this.stage(bytes.subarray(offset, offset + count));
+ const result = await this.ask('write', 1, BigInt(offset), len);
+ if (!result.number) throw new Error('Server made no write progress');
+ offset += result.number;
+ }
+ return offset;
+ }));
+ }
+}
diff --git a/app/web/index.html b/app/web/index.html
new file mode 100644
index 0000000..aeca5d3
--- /dev/null
+++ b/app/web/index.html
@@ -0,0 +1,53 @@
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width,initial-scale=1">
+ <title>cloud9 — files</title>
+ <link rel="stylesheet" href="/_cloud9/style.css">
+ <script type="module" src="/_cloud9/app.mjs"></script>
+</head>
+<body>
+ <header>
+ <a class="brand" href="/" data-path="/">cloud9</a>
+ <div class="description">A web interface to your file server.</div>
+ </header>
+ <nav class="toolbar" aria-label="File actions">
+ <a class="selected" href="/" data-path="/">files</a>
+ <button id="reload" type="button">reload</button>
+ <form id="path-form">
+ <label for="path">path</label>
+ <input id="path" name="path" value="/" aria-label="Path" autocomplete="off" spellcheck="false">
+ <button id="go">go</button>
+ </form>
+ </nav>
+ <main>
+ <nav id="breadcrumbs" aria-label="Breadcrumb"><a href="/" data-path="/">root</a></nav>
+ <div class="summary">
+ <p id="status" role="status" aria-live="polite">Loading files…</p>
+ <a id="up" href="/" data-path="/" hidden>parent directory</a>
+ </div>
+ <section id="directory" aria-label="Directory">
+ <table>
+ <thead><tr><th scope="col">Mode</th><th scope="col">Name</th><th scope="col" class="size">Size</th></tr></thead>
+ <tbody id="entries"></tbody>
+ </table>
+ <p id="empty" hidden>This directory is empty.</p>
+ </section>
+ <section id="file" hidden>
+ <div class="file-heading">
+ <h1 id="filename"></h1>
+ <span id="file-mode"></span><span id="file-size"></span>
+ <button id="download" type="button">download</button>
+ </div>
+ <textarea id="editor" aria-label="File contents" spellcheck="false"></textarea>
+ <p id="binary" hidden>Binary file. Download to view its contents.</p>
+ <div id="edit-actions" class="actions">
+ <button id="save" type="button">save changes</button>
+ <span id="edit-status">No unsaved changes.</span>
+ </div>
+ </section>
+ </main>
+ <footer>served by cloud9</footer>
+</body>
+</html>
diff --git a/app/web/style.css b/app/web/style.css
new file mode 100644
index 0000000..a8605f5
--- /dev/null
+++ b/app/web/style.css
@@ -0,0 +1,87 @@
+:root {
+ font: 14px/1.45 sans-serif;
+ color: #333;
+ background: white;
+ font-synthesis: none;
+}
+* { box-sizing: border-box; }
+body { margin: 0; padding: 22px 3%; }
+a { color: #07539b; text-decoration: none; }
+a:hover { text-decoration: underline; }
+header { padding: 0 8px 17px; }
+.brand { font-size: 30px; font-weight: bold; color: #222; letter-spacing: -1px; }
+.description { color: #777; margin-top: 1px; }
+.toolbar {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ border-bottom: 3px solid #ccc;
+ padding: 0 7px;
+}
+.toolbar > a, .toolbar > button {
+ padding: 5px 14px;
+ color: #555;
+ background: none;
+ border: 0;
+ font-size: 15px;
+}
+.toolbar .selected { background: #ccc; color: #111; }
+button, input, textarea { font: inherit; color: inherit; }
+button {
+ cursor: pointer;
+ border: 1px solid #aaa;
+ padding: 2px 9px;
+ background: #f5f5f5;
+ border-radius: 0;
+}
+button:hover:enabled { background: #e8e8e8; }
+button:disabled { color: #999; cursor: default; }
+#path-form { margin-left: auto; display: flex; align-items: center; gap: 6px; padding-bottom: 5px; }
+#path-form label { color: #777; font-size: 12px; }
+#path { width: 250px; border: 1px solid #bbb; padding: 2px 5px; font: 13px/1.5 monospace; }
+main { padding: 0 8px; }
+#breadcrumbs { display: flex; flex-wrap: wrap; gap: 7px; padding: 13px 0 5px; font-family: monospace; overflow-wrap: anywhere; }
+#breadcrumbs .separator { color: #999; }
+#breadcrumbs [aria-current] { color: #333; font-weight: bold; }
+.summary { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin: 2px 0 13px; font-size: 12px; }
+#status { color: #777; margin: 0; min-height: 18px; }
+#status.error { color: #a22; }
+#up { white-space: nowrap; }
+table { width: 100%; border-collapse: collapse; text-align: left; }
+th { background: #eee; padding: 4px 8px; font-weight: bold; border-bottom: 1px solid #ccc; }
+td { padding: 3px 8px; vertical-align: top; }
+tbody tr:nth-child(even) { background: #f7f7f7; }
+tbody tr:hover { background: #edf3f8; }
+.mode { width: 120px; color: #777; font: 12px/1.7 monospace; white-space: nowrap; }
+.entry { font-family: monospace; overflow-wrap: anywhere; }
+.entry.directory { font-weight: bold; }
+.size { width: 110px; text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
+td.size { color: #666; font-size: 12px; }
+#empty { color: #777; padding: 4px 8px; }
+.file-heading { display: flex; flex-wrap: wrap; align-items: baseline; gap: 16px; padding: 6px 8px; background: #eee; border-bottom: 1px solid #ccc; }
+h1 { font: bold 14px monospace; margin: 0; overflow-wrap: anywhere; }
+#file-mode, #file-size { color: #666; font: 12px monospace; }
+#download { margin-left: auto; font-size: 12px; }
+textarea { display: block; width: 100%; min-height: 420px; resize: vertical; border: 1px solid #ddd; border-top: 0; padding: 10px; font: 13px/1.6 monospace; tab-size: 4; white-space: pre; overflow: auto; }
+#binary { color: #777; padding: 16px 8px; }
+.actions { display: flex; align-items: center; gap: 12px; margin-top: 10px; font-size: 12px; }
+#edit-status { color: #777; }
+footer { margin: 30px 8px 0; padding-top: 7px; border-top: 1px solid #ddd; text-align: right; color: #999; font-size: 11px; }
+a:focus-visible, button:focus-visible { outline: 2px solid #07539b; outline-offset: 2px; }
+[hidden] { display: none !important; }
+@media (max-width: 600px) {
+ body { padding: 14px 10px; }
+ header { padding-left: 3px; }
+ .brand { font-size: 26px; }
+ .toolbar { flex-wrap: wrap; padding: 0; }
+ .toolbar > a, .toolbar > button { padding: 5px 10px; }
+ #path-form { order: -1; width: 100%; margin: 0 0 6px; }
+ #path { width: auto; min-width: 0; flex: 1; }
+ main { padding: 0; }
+ th, td { padding-left: 5px; padding-right: 5px; }
+ .mode { width: 86px; font-size: 10px; }
+ .size { width: 65px; }
+ .summary { align-items: start; }
+ .file-heading { gap: 7px 12px; }
+ textarea { min-height: 350px; }
+}
diff --git a/build.zig b/build.zig
index 160efdf..3228cf2 100644
--- a/build.zig
+++ b/build.zig
@@ -9,6 +9,59 @@ pub fn build(b: *std.Build) void {
});
const tests = b.addTest(.{ .root_module = module });
b.step("test", "Run protocol and session tests").dependOn(&b.addRunArtifact(tests).step);
+ const wasm_target = b.resolveTargetQuery(.{ .cpu_arch = .wasm32, .os_tag = .freestanding });
+ const wasm_core = b.createModule(.{
+ .root_source_file = b.path("src/root.zig"),
+ .target = wasm_target,
+ .optimize = optimize,
+ });
+ const wasm = b.addExecutable(.{ .name = "cloud9", .root_module = b.createModule(.{
+ .root_source_file = b.path("app/client.zig"),
+ .target = wasm_target,
+ .optimize = optimize,
+ .imports = &.{.{ .name = "cloud9", .module = wasm_core }},
+ }) });
+ wasm.entry = .disabled;
+ wasm.rdynamic = true;
+ wasm.export_memory = true;
+ wasm.stack_size = 256 * 1024;
+ wasm.initial_memory = 1024 * 1024;
+ wasm.max_memory = 1024 * 1024;
+ const web = b.step("web", "Install the standalone browser client into zig-out/web");
+ web.dependOn(&b.addInstallFileWithDir(wasm.getEmittedBin(), .{ .custom = "web/_cloud9" }, "cloud9.wasm").step);
+ for ([_][]const u8{ "index.html", "app.mjs", "client.mjs", "style.css" }) |file| {
+ web.dependOn(&b.addInstallFileWithDir(b.path(b.fmt("app/web/{s}", .{file})), .{ .custom = if (std.mem.eql(u8, file, "index.html")) "web" else "web/_cloud9" }, file).step);
+ }
+ const bridge = b.addExecutable(.{ .name = "cloud9-http", .root_module = b.createModule(.{
+ .root_source_file = b.path("app/main.zig"),
+ .target = target,
+ .optimize = optimize,
+ .link_libc = true,
+ .imports = &.{.{ .name = "cloud9", .module = module }},
+ }) });
+ bridge.root_module.addAnonymousImport("client.wasm", .{ .root_source_file = wasm.getEmittedBin() });
+ b.installArtifact(bridge);
+ const run_bridge = b.addRunArtifact(bridge);
+ if (b.args) |args| run_bridge.addArgs(args);
+ b.step("serve", "Run the HTTP/WebSocket bridge (--upstream tcp:127.0.0.1:564)").dependOn(&run_bridge.step);
+ const native_http = b.addExecutable(.{ .name = "cloud9-http-test", .root_module = b.createModule(.{
+ .root_source_file = b.path("test/web/native.zig"),
+ .target = target,
+ .optimize = optimize,
+ .link_libc = true,
+ .imports = &.{.{ .name = "cloud9", .module = module }},
+ }) });
+ const http_tests = b.addTest(.{ .root_module = b.createModule(.{
+ .root_source_file = b.path("test/http.zig"),
+ .target = target,
+ .optimize = optimize,
+ .imports = &.{.{ .name = "cloud9", .module = module }},
+ }) });
+ b.step("http-test", "Test HTTP WebSocket transport framing").dependOn(&b.addRunArtifact(http_tests).step);
+ const e2e = b.addSystemCommand(&.{ "node", "test/web/e2e.mjs" });
+ e2e.addArtifactArg(bridge);
+ e2e.addArtifactArg(native_http);
+ b.step("e2e", "Run Chromium through the bridge against a local go9p server").dependOn(&e2e.step);
const probe = b.addExecutable(.{
.name = "cloud9-probe",
.root_module = b.createModule(.{
diff --git a/build.zig.zon b/build.zig.zon
index 0dbc3cf..1afd382 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -3,5 +3,5 @@
.fingerprint = 0xc8b2d5eaa3adfca,
.version = "0.1.0",
.minimum_zig_version = "0.16.0",
- .paths = .{ "build.zig", "build.zig.zon", "src", "test", "docs", "README.md" },
+ .paths = .{ "build.zig", "build.zig.zon", "src", "app", "test", "docs", "README.md" },
}
diff --git a/docs/design.md b/docs/design.md
index ed9a66e..d8d55c4 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -52,6 +52,13 @@ absolute monotonic deadline. The application closes descriptors, limits its
connections, selects addresses and deadlines, and manages Unix path permissions
and removal. No mounting or namespace policy is performed.
+`http.WebSocket` adapts established HTTP streams without owning their sockets
+or buffers. `http.Client` negotiates HTTP/1.1 upgrades using `std.http.Client`,
+including its certificate-verified HTTPS support. `http.Bridge` relays between
+an accepted WebSocket and arbitrary standard readers/writers, including UART
+adapters. The runnable application's web UI, device leases, deadlines, and
+origin policy remain in `app/`. See [HTTP ownership and usage](http.md).
+
`Quic(OpenSSL, alpn)` is an optional OpenSSL 3.6+ adapter with a single ordered
bidirectional stream per connection. It preserves the previous Pardes transport:
ephemeral self-signed certificates, no peer authentication. Applications needing
diff --git a/docs/http.md b/docs/http.md
new file mode 100644
index 0000000..f486d52
--- /dev/null
+++ b/docs/http.md
@@ -0,0 +1,216 @@
+# 9P over HTTP
+
+cloud9 provides a reusable HTTP transport and a standalone browser gateway.
+The transport carries unchanged base 9P2000 messages. Filesystem permissions,
+mounting, UART configuration, and board drivers remain with their applications.
+
+```mermaid
+flowchart LR
+ Browser[Browser: cloud9 WASM client] -->|WebSocket / HTTPS| Gateway[HTTP gateway]
+ Native[Native cloud9 client] -->|WebSocket / HTTPS| Gateway
+ Gateway -->|TCP or Unix stream| Server[9P server]
+ Gateway -->|Serial byte stream| Board[9P firmware / ESP32]
+```
+
+## Run
+
+```sh
+zig build -Doptimize=ReleaseSafe
+./zig-out/bin/cloud9-http --upstream tcp:127.0.0.1:564
+# Or run directly:
+zig build serve -Doptimize=ReleaseSafe -- --upstream unix:/path/to/9p.sock
+```
+
+Open **http://127.0.0.1:8080**. Files load automatically.
+The upstream must already be running: the example assumes a 9P server on port
+564. For Pardes, pass its actual 9P Unix socket with `--upstream unix:PATH`.
+If HTTP port 8080 is occupied, add `--listen 127.0.0.1:0`; the operating system
+selects a free port and the gateway prints the URL to open.
+
+The binary embeds HTML, CSS, JavaScript, and WebAssembly; it can run from any
+working directory. `zig build web` also installs the separate assets into
+`zig-out/web` for applications that host their own frontend. Its layout is
+`index.html` plus assets under `_cloud9/`. A custom host must serve `index.html`
+for file routes, reserve `/_cloud9/` for the assets and endpoints, and forward
+WebSocket upgrades at `/_cloud9/9p`.
+
+The browser uses a compact cgit-inspired table with modes, names, sizes, and
+breadcrumb links. File URLs can be bookmarked, reloaded, or opened in new tabs;
+browser back/forward navigation works. File URLs use ordinary paths such as
+`/self/listeners` and `/notes/caf%C3%A9.txt`, with each component encoded separately.
+The gateway serves the browser application at these URLs; the client resolves the
+path in the upstream 9P filesystem. They are browser views, not raw-file HTTP
+responses: missing files are reported by the page after the 9P lookup.
+
+The literal `/_cloud9/` prefix is reserved for assets, configuration, and the
+WebSocket endpoint. This leaves names such as `/app.mjs`, `/config.json`, and
+`/9p` available to the upstream filesystem. If the upstream itself has a root
+entry named `_cloud9`, generated links escape its leading underscore as
+`/%5Fcloud9/`. Percent escapes are decoded exactly once; encoded slashes, NULs,
+and malformed escapes are rejected. Reverse proxies must preserve the encoded
+path when forwarding requests.
+
+The browser establishes 9P automatically on load
+and reconnects when needed, preserving unsaved edits across connection expiry.
+Interrupted writes are not automatically replayed. The default attach name is
+`user` and the default export name is empty; set `--user NAME` and `--tree NAME`
+on the gateway when the upstream requires different values. These are 9P attach
+parameters, not a browser login. The `/_cloud9/config.json` endpoint supplies these public
+defaults to the page; they do not restrict other clients' attach requests.
+
+The browser lists directories, follows paths longer than 16 components, previews
+UTF-8 text, downloads binary files, and saves edits to existing files. Saving uses
+OWRITE|OTRUNC and is not atomic. Empty saves truncate the file. It respects both
+negotiated msize and each open's iounit, handles short writes, and clunks temporary
+fids even after partial walks or filesystem errors. Views and writes are limited
+to 8 MiB. The UI does not perform a 9P authentication exchange; it attaches using
+NOFID, so authenticated filesystems need their own client authentication flow.
+
+Options:
+
+| Option | Default | Meaning |
+| --- | --- | --- |
+| `--listen IP:PORT` | `127.0.0.1:8080` | HTTP listener; IPv4 or bracketed IPv6 |
+| `--upstream tcp:IP:PORT` | `tcp:127.0.0.1:564` | A new 9P connection per WebSocket |
+| `--upstream unix:PATH` | — | A new Unix connection per WebSocket |
+| `--upstream file:PATH` | — | An already configured duplex device, one session at a time |
+| `--origin SCHEME://HOST[:PORT]` | Listener origin | Public origin when a reverse proxy serves the gateway |
+| `--user NAME` | `user` | Browser's 9P attach user (`uname`), at most 256 bytes |
+| `--tree NAME` | empty | Browser's named export (`aname`), at most 256 bytes |
+| `--timeout-ms N` | `300000` | Maximum connection lifetime, including HTTP headers; 0 disables it |
+
+The gateway bounds concurrent HTTP/WebSocket connections at 32, HTTP headers at
+8 KiB, and 9P frames at 64 KiB. Excess connections close. A connection ending or
+expiring cancels both relay directions before releasing buffers and descriptors.
+Each network connection has a separate upstream fid/tag space. A device lease
+prevents multiple clients from mixing transactions on one physical UART stream.
+A new serial session starts with Tversion; the device owner is responsible for
+link reset/recovery if an interrupted physical link leaves stale bytes in transit.
+
+For a configured serial device:
+
+```sh
+# Set raw mode, baud rate, and flow control with your platform's serial tools.
+./zig-out/bin/cloud9-http --upstream file:/dev/ttyUSB0
+```
+
+The serial path transports 9P bytes; it does not interpret ESP32 boot logs or
+configure pins, UART framing, baud rates, or firmware. A custom embedded runtime
+can instead supply its own `std.Io.Reader` and `std.Io.Writer` to the library relay.
+
+## HTTPS and QUIC
+
+The native connector uses `std.http.Client` for HTTP/1.1 or certificate-verified
+HTTPS. The browser uses its own WebSocket/TLS stack. For the runnable gateway,
+terminate HTTPS in a reverse proxy that forwards WebSocket upgrades, and set
+`--origin https://files.example` to the public origin. Serve assets and `/_cloud9/9p`
+under that same origin. Host must match the listener or public authority; the
+upgrade Origin must match the configured origin exactly. Gateway access grants
+the upstream's existing 9P rights; identity/access policy belongs to the proxy
+or filesystem server. The configured attach name is not proof of identity.
+
+HTTP/3 is **not implemented by this connector or binary**. HTTPS alone does not
+select QUIC. WebSockets over HTTP/2 and HTTP/3 require Extended CONNECT support
+in the HTTP stack ([RFC 8441](https://www.rfc-editor.org/rfc/rfc8441.html),
+[RFC 9220](https://www.rfc-editor.org/rfc/rfc9220.html)). Such a stack can provide
+an established stream to `http.WebSocket`; its handshake and stream lifecycle
+remain the stack's responsibility. cloud9's existing direct `Quic` transport is
+separate from HTTP/3.
+
+## Library API
+
+`cloud9.http.WebSocket` operates on caller-owned standard readers/writers, with
+no allocation or socket dependency. A complete binary WebSocket message contains
+one complete 9P frame. It handles masked client frames, fragmented messages, and
+interleaved ping/pong/close controls. Text messages and WebSocket extensions are
+outside this transport contract. Its framing follows
+[RFC 6455](https://www.rfc-editor.org/rfc/rfc6455.html); the payload is the existing
+[9P2000 format](https://9fans.github.io/plan9port/man/man9/intro.html).
+
+`receive(buffer)` returns borrowed bytes; keep that buffer stable across control
+messages during a fragmented message. `send` flushes its writer;
+`sendUnflushed` lets a layered connection control flushing. Client-role callers
+must supply a fresh unpredictable mask. A framing/I/O error terminates the
+transport. One reader and one writer may run concurrently, but writes, including
+control replies, must be serialized.
+
+`cloud9.http.Client.connect(&http_client, url, origin)` negotiates and validates
+an HTTP upgrade. The HTTP client and URL bytes must outlive the tunnel. It uses
+the HTTP client's allocator, CA bundle, and I/O provider; it does not disable
+certificate verification or follow redirects. `deinit()` closes the connection
+instead of returning the upgraded stream to the HTTP pool.
+
+```zig
+var http_client: std.http.Client = .{ .allocator = allocator, .io = io };
+defer http_client.deinit();
+var tunnel = try cloud9.http.Client.connect(
+ &http_client,
+ "https://files.example/_cloud9/9p",
+ "https://files.example",
+);
+defer tunnel.deinit();
+
+// Drive the existing cloud9.Client state machine as with any other transport.
+_ = try client.submit(.{ .version = .{} });
+try tunnel.send(client.output());
+client.wrote(client.output().len);
+const reply = try tunnel.receive(frame_buffer);
+if (client.push(reply) != reply.len) return error.InputFull;
+const done = client.take() orelse return error.MissingReply;
+```
+
+`Client.receive` is a serial convenience method that answers control frames. For
+concurrent drivers, use `socket.receive` and coordinate all writes, including
+TLS connection flushing. The 9P client still owns tag matching, negotiated size,
+reply validation, and flush semantics. Split batched client output at 9P frame
+boundaries before sending individual WebSocket messages.
+
+For a server, call `http.accept(&request)` after checking route, Host, Origin,
+and application authorization. Supply the resulting WebSocket, upstream standard
+reader/writer, two disjoint frame buffers, and frame limit to `http.Bridge`, then
+call `run(io, timeout)`. The relay forwards requests and replies concurrently,
+including overlapping tags and delayed replies. It performs framing and direction
+checks; it does not allocate fids, mount filesystems, or implement a filesystem.
+Each Bridge value runs once. Timeout and cancellation stop both pumps before
+returning. The caller owns and closes the underlying streams.
+
+`app/client.zig` is a browser ABI around the same `cloud9.Client` and `Stat`
+implementations. Its WASM memory is fixed at 1 MiB and it imports no host
+functions. `app/web/client.mjs` supplies asynchronous WebSocket I/O and file
+operations. Each browser Client owns a separate WASM instance. The JavaScript
+contains no 9P encoder or decoder.
+
+## End-to-end tests
+
+```sh
+zig build test http-test transport-test
+zig build e2e -Doptimize=ReleaseSafe
+# Alternate Chromium executable:
+CLOUD9_CHROME=/path/to/chromium zig build e2e -Doptimize=ReleaseSafe
+```
+
+E2E requires Node 22+ (including its global WebSocket), Go, Chromium, OpenSSL,
+and Python 3 on Linux. It builds the pinned go9p v1.18.0 fixture from the existing
+test-only Go module. No npm packages, public servers, physical boards, or mounted
+filesystems are used. Chrome runs headless against loopback; test TLS uses a
+fresh local certificate explicitly trusted by the native client.
+
+9P itself has connection-scoped sessions: Tversion negotiates/resets the session,
+Tattach establishes a root fid, and later requests refer to fids retained by the
+server. The page handles this state without presenting connection controls.
+
+The suite drives actual DOM controls and the shipped WASM in Chromium. It checks
+automatic startup, configured attach defaults, clean file URLs and reloads,
+encoded filenames, collisions with asset names, invalid path encoding, back/forward
+navigation, reconnecting with unsaved edits, paged directories, UTF-8 names, literal HTML-like names, text editing and
+reconnection, byte-exact multi-frame binary reads/writes and browser downloads, mobile layout,
+empty truncation,
+18-component walks, concurrent sessions, queued operations, and recovery after
+Rerror. The native client verifies HTTP, trusted HTTPS, rejection of untrusted
+certificates and hostname mismatches, overlapping requests, Unix upstreams, and
+a raw PTY serial path. It also checks serial lease release and connection deadlines.
+The reference server deliberately fragments reads and writes. Framing unit tests
+cover length boundaries, one-byte I/O, interleaved controls, invalid frames, and
+upgrade nonce validation, using only cloud9 for invalid-input checks.
+
+Each run leaves a JSON result and browser screenshot in `.zig-cache/e2e-*/`.
diff --git a/docs/results/http-e2e.json b/docs/results/http-e2e.json
new file mode 100644
index 0000000..028002e
--- /dev/null
+++ b/docs/results/http-e2e.json
@@ -0,0 +1,45 @@
+{
+ "browser": "Chromium",
+ "reference": "go9p v1.18.0",
+ "wasmHostImports": 0,
+ "checks": {
+ "binary": 180000,
+ "written": 150000,
+ "queued": 2,
+ "independent": "Hello from 9P.\n"
+ },
+ "passed": [
+ "clean path links and reloads",
+ "encoded filename round trips",
+ "gateway asset name collisions",
+ "escaped gateway-prefix file",
+ "invalid path encoding rejected",
+ "automatic connection",
+ "bookmarkable file URLs",
+ "browser back and forward",
+ "automatic reconnect preserves edits",
+ "configured attach defaults",
+ "directory paging",
+ "UTF-8 paths and contents",
+ "text save and reconnect",
+ "binary read/write",
+ "browser download",
+ "mobile layout",
+ "truncate to empty",
+ "18-component walk",
+ "concurrent sessions",
+ "queued operations",
+ "Rerror recovery",
+ "HTTP routing and origin policy",
+ "native HTTP",
+ "native verified HTTPS",
+ "untrusted certificate rejection",
+ "hostname mismatch rejection",
+ "Unix upstream",
+ "PTY serial upstream",
+ "serial lease released after disconnect",
+ "connection deadline",
+ "overlapping native requests",
+ "fragmented upstream I/O"
+ ]
+}
diff --git a/docs/validation.md b/docs/validation.md
index acc35c3..98b73d6 100644
--- a/docs/validation.md
+++ b/docs/validation.md
@@ -71,3 +71,33 @@ passed earlier, but later full reruns encountered hard-coded /tmp shell-fixture
creation failures from the machine's quota. The final protocol and transport
checks were run separately. The `--fuzz` compiler-runner limitation is described
above. Firmware hardware behavior and non-Linux native transports were not run.
+
+## HTTP transport and browser gateway (2026-09-14)
+
+`zig build test http-test transport-test` passed **33 tests** in both Debug and
+ReleaseSafe: 24 protocol/session, 6 HTTP framing/handshake, and 3 stream transport
+tests. `zig build e2e -Doptimize=ReleaseSafe` passed **22 scenarios**, using actual
+Chromium, the shipped WASM, native cloud9 clients, and a pinned local go9p server.
+Coverage includes binary downloads, mobile layout, multi-frame reads/writes,
+verified HTTPS and certificate rejection, concurrent tags, Unix sockets, PTY
+serial transport, serial lease cleanup, and connection deadlines. The serial
+fixture is a pseudo-terminal; no physical UART or ESP32 hardware was exercised.
+HTTP/3 is not implemented or tested.
+
+The [HTTP E2E report](results/http-e2e.json) records the scenarios and byte counts.
+The run's screenshots and TLS fixtures remain in `.zig-cache/e2e-3HAs6G/`.
+`zig build -Doptimize=ReleaseSafe` installs the standalone `cloud9-http` binary;
+`zig build web -Doptimize=ReleaseSafe` also installs the separate browser assets.
+See [HTTP usage and ownership](http.md) for the reusable API and runnable gateway.
+
+The cgit-inspired UI update passed **27 E2E scenarios**, adding automatic attach,
+configured user/export defaults, bookmarkable file URLs, browser back/forward,
+and automatic reconnection after expiry while preserving unsaved edits. The
+latest screenshots are in `.zig-cache/e2e-IYt96E/` and the tracked HTTP report
+has been updated.
+
+The clean-URL update passed **32 E2E scenarios**. File routes now occupy ordinary
+URL paths and gateway endpoints use `/_cloud9/`. Added checks cover per-component
+encoding, direct reloads, filenames matching asset names, an upstream `_cloud9`
+directory, and invalid-URL rejection with navigation recovery. Latest artifacts:
+`.zig-cache/e2e-YIV80o/`. Both the embedded binary and standalone web assets built.
diff --git a/src/http.zig b/src/http.zig
new file mode 100644
index 0000000..0a07160
--- /dev/null
+++ b/src/http.zig
@@ -0,0 +1,339 @@
+//! 9P over HTTP WebSockets. The framing layer accepts arbitrary std.Io readers
+//! and writers; it does not own sockets, TLS, UARTs, filesystems, or mounts.
+//! HTTP/1.1 connection setup uses std.http.Client (including verified HTTPS).
+const std = @import("std");
+const wire = @import("wire.zig");
+const Io = std.Io;
+const guid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
+
+pub const Opcode = enum(u4) { continuation = 0, binary = 2, close = 8, ping = 9, pong = 10, _ };
+pub const Role = enum { client, server };
+pub const Message = struct { opcode: Opcode, data: []const u8 };
+
+/// Caller owns both streams and serializes writes. One read and one write may
+/// run concurrently. Keep the receive buffer stable across interleaved controls.
+/// On any protocol/I/O error, discard the WebSocket and close its underlying stream.
+pub const WebSocket = struct {
+ input: *Io.Reader,
+ output: *Io.Writer,
+ role: Role,
+ fragmented: bool = false,
+ used: usize = 0,
+ control: [125]u8 = undefined,
+
+ /// One complete binary message contains exactly one 9P frame. Fragmented
+ /// WebSocket messages and interleaved control frames are supported.
+ pub fn receive(s: *WebSocket, buffer: []u8) !Message {
+ while (true) {
+ var header: [2]u8 = undefined;
+ try s.input.readSliceAll(&header);
+ const fin = header[0] & 0x80 != 0;
+ if (header[0] & 0x70 != 0) return error.ReservedBits;
+ const opcode: Opcode = @enumFromInt(header[0] & 0x0f);
+ const control = header[0] & 8 != 0;
+ const masked = header[1] & 0x80 != 0;
+ if (masked != (s.role == .server)) return error.InvalidMask;
+ const short = header[1] & 0x7f;
+ const size: u64 = switch (short) {
+ 126 => value: {
+ const n = try readInt(s.input, u16);
+ if (n < 126) return error.NonCanonicalLength;
+ break :value n;
+ },
+ 127 => value: {
+ const n = try readInt(s.input, u64);
+ if (n < 65536 or n >> 63 != 0) return error.NonCanonicalLength;
+ break :value n;
+ },
+ else => short,
+ };
+ if (control and (!fin or size > 125)) return error.InvalidControl;
+ switch (opcode) {
+ .binary => if (s.fragmented) return error.ExpectedContinuation,
+ .continuation => if (!s.fragmented) return error.UnexpectedContinuation,
+ .close, .ping, .pong => {},
+ else => return error.ExpectedBinary,
+ }
+ var mask: [4]u8 = @splat(0);
+ if (masked) try s.input.readSliceAll(&mask);
+ const dest = if (control) s.control[0..] else buffer[s.used..];
+ if (size > dest.len) return error.MessageTooLarge;
+ const payload = dest[0..@intCast(size)];
+ try s.input.readSliceAll(payload);
+ if (masked) for (payload, 0..) |*byte, i| {
+ byte.* ^= mask[i % 4];
+ };
+ if (control) {
+ if (opcode == .close) try validateClose(payload);
+ return .{ .opcode = opcode, .data = payload };
+ }
+ s.used += payload.len;
+ s.fragmented = !fin;
+ if (fin) {
+ const frame = buffer[0..s.used];
+ s.used = 0;
+ _ = try wire.decode(frame);
+ return .{ .opcode = .binary, .data = frame };
+ }
+ }
+ }
+
+ /// Clients must supply a fresh, unpredictable mask for every message.
+ /// Servers pass null. This call flushes the supplied writer.
+ pub fn send(s: *WebSocket, bytes: []const u8, opcode: Opcode, mask: ?[4]u8) !void {
+ try s.sendUnflushed(bytes, opcode, mask);
+ try s.output.flush();
+ }
+
+ /// For layered writers whose outer connection controls flushing (e.g. TLS).
+ pub fn sendUnflushed(s: *WebSocket, bytes: []const u8, opcode: Opcode, mask: ?[4]u8) !void {
+ if ((mask != null) != (s.role == .client)) return error.InvalidMask;
+ switch (opcode) {
+ .binary => {
+ _ = try wire.decode(bytes);
+ },
+ .close => {
+ if (bytes.len > 125) return error.InvalidControl;
+ try validateClose(bytes);
+ },
+ .ping, .pong => if (bytes.len > 125) return error.InvalidControl,
+ else => return error.ExpectedBinary,
+ }
+ const out = s.output;
+ try out.writeByte(0x80 | @as(u8, @intFromEnum(opcode)));
+ const bit: u8 = if (mask != null) 0x80 else 0;
+ if (bytes.len < 126) {
+ try out.writeByte(bit | @as(u8, @intCast(bytes.len)));
+ } else if (bytes.len <= 65535) {
+ try out.writeByte(bit | 126);
+ try out.writeInt(u16, @intCast(bytes.len), .big);
+ } else {
+ try out.writeByte(bit | 127);
+ try out.writeInt(u64, bytes.len, .big);
+ }
+ if (mask) |key| {
+ try out.writeAll(&key);
+ var scratch: [1024]u8 = undefined;
+ var offset: usize = 0;
+ while (offset < bytes.len) {
+ const count = @min(scratch.len, bytes.len - offset);
+ for (scratch[0..count], bytes[offset..][0..count], 0..) |*to, from, i| to.* = from ^ key[(offset + i) % 4];
+ try out.writeAll(scratch[0..count]);
+ offset += count;
+ }
+ } else try out.writeAll(bytes);
+ }
+};
+
+fn readInt(reader: *Io.Reader, comptime T: type) !T {
+ var bytes: [@sizeOf(T)]u8 = undefined;
+ try reader.readSliceAll(&bytes);
+ return std.mem.readInt(T, &bytes, .big);
+}
+
+fn validateClose(bytes: []const u8) !void {
+ if (bytes.len == 1) return error.InvalidClose;
+ if (bytes.len < 2) return;
+ const code = std.mem.readInt(u16, bytes[0..2], .big);
+ if (code < 1000 or code >= 5000 or code == 1004 or code == 1005 or code == 1006 or (code >= 1015 and code < 3000)) return error.InvalidClose;
+ if (!std.unicode.utf8ValidateSlice(bytes[2..])) return error.InvalidClose;
+}
+
+fn hasToken(value: []const u8, token: []const u8) bool {
+ var it = std.mem.splitScalar(u8, value, ',');
+ while (it.next()) |part| if (std.ascii.eqlIgnoreCase(std.mem.trim(u8, part, " \t"), token)) return true;
+ return false;
+}
+
+/// Validate and accept an HTTP/1.1 upgrade. The application must check the
+/// request route, Host, Origin, and authorization before calling this function.
+pub fn accept(request: *std.http.Server.Request) !WebSocket {
+ var connection = false;
+ var version = false;
+ var it = request.iterateHeaders();
+ while (it.next()) |header| {
+ if (std.ascii.eqlIgnoreCase(header.name, "connection")) connection = hasToken(header.value, "upgrade");
+ if (std.ascii.eqlIgnoreCase(header.name, "sec-websocket-version")) version = std.mem.eql(u8, header.value, "13");
+ }
+ if (!connection or !version or (request.head.content_length orelse 0) != 0 or request.head.transfer_encoding != .none) return error.InvalidUpgrade;
+ const key = switch (request.upgradeRequested()) {
+ .websocket => |value| value orelse return error.InvalidUpgrade,
+ else => return error.InvalidUpgrade,
+ };
+ if (key.len != 24) return error.InvalidUpgrade;
+ const nonce_len = std.base64.standard.Decoder.calcSizeForSlice(key) catch return error.InvalidUpgrade;
+ if (nonce_len != 16) return error.InvalidUpgrade;
+ var nonce: [16]u8 = undefined;
+ std.base64.standard.Decoder.decode(&nonce, key) catch return error.InvalidUpgrade;
+ var ws = try request.respondWebSocket(.{ .key = key });
+ try ws.flush();
+ return .{ .input = ws.input, .output = ws.output, .role = .server };
+}
+
+/// Owns one upgraded connection in the caller's std.http.Client. Both that
+/// client and the URL bytes must outlive this value. deinit closes the connection;
+/// upgraded connections are never returned to the HTTP keep-alive pool.
+pub const Client = struct {
+ request: std.http.Client.Request,
+ socket: WebSocket,
+
+ pub fn connect(http: *std.http.Client, url: []const u8, origin: []const u8) !Client {
+ const uri = try std.Uri.parse(url);
+ if (!std.mem.eql(u8, uri.scheme, "http") and !std.mem.eql(u8, uri.scheme, "https")) return error.UnsupportedUriScheme;
+ if (std.mem.findScalar(u8, origin, '\r') != null or std.mem.findScalar(u8, origin, '\n') != null) return error.InvalidOrigin;
+ var nonce: [16]u8 = undefined;
+ try http.io.randomSecure(&nonce);
+ var key: [24]u8 = undefined;
+ _ = std.base64.standard.Encoder.encode(&key, &nonce);
+ const headers: []const std.http.Header = &.{
+ .{ .name = "upgrade", .value = "websocket" },
+ .{ .name = "sec-websocket-version", .value = "13" },
+ .{ .name = "sec-websocket-key", .value = &key },
+ .{ .name = "origin", .value = origin },
+ };
+ var request = try http.request(.GET, uri, .{
+ .redirect_behavior = .unhandled,
+ .headers = .{ .connection = .{ .override = "Upgrade" }, .accept_encoding = .omit },
+ .extra_headers = headers,
+ });
+ errdefer {
+ request.connection.?.closing = true;
+ request.deinit();
+ }
+ try request.sendBodiless();
+ const response = try request.receiveHead(&.{});
+ request.connection.?.closing = true;
+ if (response.head.status != .switching_protocols) return error.UpgradeRejected;
+ var sha = std.crypto.hash.Sha1.init(.{});
+ sha.update(&key);
+ sha.update(guid);
+ var digest: [20]u8 = undefined;
+ sha.final(&digest);
+ var expected: [28]u8 = undefined;
+ _ = std.base64.standard.Encoder.encode(&expected, &digest);
+ var accepted = false;
+ var upgraded = false;
+ var connection = false;
+ var it = response.head.iterateHeaders();
+ while (it.next()) |header| {
+ if (std.ascii.eqlIgnoreCase(header.name, "sec-websocket-accept")) accepted = std.mem.eql(u8, header.value, &expected);
+ if (std.ascii.eqlIgnoreCase(header.name, "upgrade")) upgraded = std.ascii.eqlIgnoreCase(header.value, "websocket");
+ if (std.ascii.eqlIgnoreCase(header.name, "connection")) connection = hasToken(header.value, "upgrade");
+ if (std.ascii.eqlIgnoreCase(header.name, "sec-websocket-extensions") or std.ascii.eqlIgnoreCase(header.name, "sec-websocket-protocol")) return error.UnsupportedExtension;
+ }
+ if (!accepted or !upgraded or !connection) return error.InvalidUpgrade;
+ request.extra_headers = &.{};
+ const stream = request.connection.?;
+ return .{ .request = request, .socket = .{ .input = stream.reader(), .output = stream.writer(), .role = .client } };
+ }
+
+ pub fn deinit(client: *Client) void {
+ client.request.deinit();
+ client.* = undefined;
+ }
+
+ pub fn send(client: *Client, frame: []const u8) !void {
+ try client.sendMessage(frame, .binary);
+ }
+
+ fn sendMessage(client: *Client, bytes: []const u8, opcode: Opcode) !void {
+ var mask: [4]u8 = undefined;
+ try client.request.client.io.randomSecure(&mask);
+ try client.socket.sendUnflushed(bytes, opcode, mask);
+ try client.request.connection.?.flush();
+ }
+
+ /// Serial convenience API. Handles ping/pong and close; use socket.receive
+ /// and separately serialized writes when driving a concurrent session.
+ pub fn receive(client: *Client, buffer: []u8) ![]const u8 {
+ while (true) {
+ const message = try client.socket.receive(buffer);
+ switch (message.opcode) {
+ .binary => return message.data,
+ .ping => try client.sendMessage(message.data, .pong),
+ .pong => {},
+ .close => {
+ try client.sendMessage(message.data, .close);
+ return error.EndOfStream;
+ },
+ else => unreachable,
+ }
+ }
+ }
+};
+
+/// Concurrent relay between an accepted WebSocket and any raw 9P byte stream.
+/// Suitable for socket, pipe, serial, or firmware-provided std.Io adapters.
+/// Both frame buffers must be disjoint and at least frame_limit bytes long.
+/// Each Bridge value runs once. All streams and buffers remain caller-owned. Cancellation stops both pumps
+/// before returning, including when the other peer is blocked waiting for I/O.
+pub const Bridge = struct {
+ socket: *WebSocket,
+ upstream_reader: *Io.Reader,
+ upstream_writer: *Io.Writer,
+ request_buffer: []u8,
+ reply_buffer: []u8,
+ frame_limit: u32,
+ mutex: Io.Mutex = .init,
+ done: Io.Event = .unset,
+ errors: [2]?anyerror = .{ null, null },
+ finished: std.atomic.Value(u8) = .init(2),
+
+ pub fn run(b: *Bridge, io: Io, timeout: Io.Timeout) !void {
+ std.debug.assert(b.socket.role == .server);
+ std.debug.assert(b.frame_limit >= 24);
+ std.debug.assert(b.request_buffer.len >= b.frame_limit and b.reply_buffer.len >= b.frame_limit);
+ var group: Io.Group = .init;
+ defer group.cancel(io);
+ try group.concurrent(io, pump, .{ b, io, 0 });
+ try group.concurrent(io, pump, .{ b, io, 1 });
+ try b.done.waitTimeout(io, timeout);
+ group.cancel(io);
+ if (b.errors[b.finished.load(.acquire)]) |err| return err;
+ }
+
+ fn pump(b: *Bridge, io: Io, direction: u1) void {
+ defer {
+ if (b.finished.cmpxchgStrong(2, direction, .release, .monotonic) == null) b.done.set(io);
+ }
+ if (direction == 0) b.requests(io) catch |err| {
+ b.errors[0] = err;
+ } else b.replies(io) catch |err| {
+ b.errors[1] = err;
+ };
+ }
+ fn send(b: *Bridge, io: Io, bytes: []const u8, opcode: Opcode) !void {
+ try b.mutex.lock(io);
+ defer b.mutex.unlock(io);
+ try b.socket.send(bytes, opcode, null);
+ }
+ fn requests(b: *Bridge, io: Io) !void {
+ while (true) {
+ const message = try b.socket.receive(b.request_buffer[0..b.frame_limit]);
+ switch (message.opcode) {
+ .ping => try b.send(io, message.data, .pong),
+ .pong => {},
+ .close => {
+ try b.send(io, message.data, .close);
+ return;
+ },
+ .binary => {
+ const decoded = try wire.decode(message.data);
+ if (!wire.isT(decoded.msg.msgType())) return error.ExpectedRequest;
+ if (decoded.msg == .tversion and decoded.msg.tversion.msize > b.frame_limit) return error.MessageTooLarge;
+ try @import("transport.zig").writeFrame(b.upstream_writer, message.data, b.frame_limit);
+ try b.upstream_writer.flush();
+ },
+ else => unreachable,
+ }
+ }
+ }
+ fn replies(b: *Bridge, io: Io) !void {
+ while (true) {
+ const frame = try @import("transport.zig").readFrame(b.upstream_reader, b.reply_buffer, b.frame_limit);
+ const decoded = try wire.decode(frame);
+ if (wire.isT(decoded.msg.msgType())) return error.ExpectedReply;
+ try b.send(io, frame, .binary);
+ }
+ }
+};
diff --git a/src/root.zig b/src/root.zig
index 47303be..dadeb6d 100644
--- a/src/root.zig
+++ b/src/root.zig
@@ -46,6 +46,7 @@ pub const orclose: u8 = 64;
test {
@import("std").testing.refAllDecls(@This());
}
+pub const http = @import("http.zig");
pub const transport = @import("transport.zig");
pub const Quic = @import("quic.zig").Quic;
test {
diff --git a/test/differential/webfixture/main.go b/test/differential/webfixture/main.go
new file mode 100644
index 0000000..9d23399
--- /dev/null
+++ b/test/differential/webfixture/main.go
@@ -0,0 +1,83 @@
+// Local reference filesystem for HTTP/browser tests. No external target.
+package main
+
+import (
+ "fmt"
+ "github.com/knusbaum/go9p"
+ "github.com/knusbaum/go9p/fs"
+ "net"
+ "os"
+)
+
+type fragmented struct{ net.Conn }
+
+func (f fragmented) Read(p []byte) (int, error) {
+ if len(p) > 17 {
+ p = p[:17]
+ }
+ return f.Conn.Read(p)
+}
+func (f fragmented) Write(p []byte) (int, error) {
+ total := 0
+ for len(p) > 0 {
+ n := len(p)
+ if n > 31 {
+ n = 31
+ }
+ written, err := f.Conn.Write(p[:n])
+ total += written
+ p = p[written:]
+ if err != nil {
+ return total, err
+ }
+ }
+ return total, nil
+}
+func check(err error) {
+ if err != nil {
+ panic(err)
+ }
+}
+func main() {
+ tree, root := fs.NewFS("user", "user", 0755)
+ check(root.AddChild(fs.NewStaticFile(tree.NewStat("hello.txt", "user", "user", 0644), []byte("Hello from 9P.\n"))))
+ check(root.AddChild(fs.NewStaticFile(tree.NewStat("edit.txt", "user", "user", 0644), []byte("Edit me.\n"))))
+ large := make([]byte, 180000)
+ for i := range large {
+ large[i] = byte(i % 251)
+ }
+ check(root.AddChild(fs.NewStaticFile(tree.NewStat("large.bin", "user", "user", 0644), large)))
+ check(root.AddChild(fs.NewStaticFile(tree.NewStat("<literal>.txt", "user", "user", 0644), []byte("Names are text.\n"))))
+ for _, name := range []string{"app.mjs", "style.css", "client.mjs", "config.json", "cloud9.wasm", "9p", "space #?% ü.txt", "literal%2F.txt", "back\\slash.txt"} {
+ check(root.AddChild(fs.NewStaticFile(tree.NewStat(name, "user", "user", 0644), []byte("Path: "+name+"\n"))))
+ }
+ internal := fs.NewStaticDir(tree.NewStat("_cloud9", "user", "user", 0755))
+ check(root.AddChild(internal))
+ check(internal.AddChild(fs.NewStaticFile(tree.NewStat("app.mjs", "user", "user", 0644), []byte("Upstream private-looking path.\n"))))
+ dir := fs.NewStaticDir(tree.NewStat("notes", "user", "user", 0755))
+ check(root.AddChild(dir))
+ check(dir.AddChild(fs.NewStaticFile(tree.NewStat("café.txt", "user", "user", 0644), []byte("Olá, 世界.\n"))))
+ for i := 0; i < 150; i++ {
+ name := fmt.Sprintf("item-%03d.txt", i)
+ check(dir.AddChild(fs.NewStaticFile(tree.NewStat(name, "user", "user", 0644), nil)))
+ }
+ parent := root
+ for i := 0; i < 18; i++ {
+ d := fs.NewStaticDir(tree.NewStat("deep", "user", "user", 0755))
+ check(parent.AddChild(d))
+ parent = d
+ }
+ check(parent.AddChild(fs.NewStaticFile(tree.NewStat("end.txt", "user", "user", 0644), []byte("Deep walk.\n"))))
+ network, address := "tcp", "127.0.0.1:0"
+ if len(os.Args) == 2 {
+ network, address = "unix", os.Args[1]
+ }
+ listener, err := net.Listen(network, address)
+ check(err)
+ fmt.Println(listener.Addr().String())
+ for {
+ conn, err := listener.Accept()
+ check(err)
+ go func() { defer conn.Close(); f := fragmented{conn}; _ = go9p.ServeReadWriter(f, f, tree.Server()) }()
+ }
+}
diff --git a/test/http.zig b/test/http.zig
new file mode 100644
index 0000000..d5064e4
--- /dev/null
+++ b/test/http.zig
@@ -0,0 +1,92 @@
+const std = @import("std");
+const c9 = @import("cloud9");
+const testing = std.testing;
+const http = c9.http;
+
+test "client and server preserve 9P frames at WebSocket length boundaries" {
+ var bytes: [70000]u8 = undefined;
+ var data: [66000]u8 = @splat(0xa5);
+ var receive_buffer: [70000]u8 = undefined;
+ for ([_]usize{ 0, 114, 115, 65524, 65525 }) |length| {
+ const frame = try c9.encode(.{ .rread = .{ .data = data[0..length] } }, 7, &bytes);
+ for ([_]http.Role{ .client, .server }) |role| {
+ var writer: std.Io.Writer.Allocating = .init(testing.allocator);
+ defer writer.deinit();
+ var empty: std.Io.Reader = .fixed("");
+ var sender: http.WebSocket = .{ .input = &empty, .output = &writer.writer, .role = role };
+ try sender.send(frame, .binary, if (role == .client) .{ 1, 2, 3, 4 } else null);
+ var reader: testing.Reader = .init(&.{}, &.{.{ .buffer = writer.written() }});
+ reader.artificial_limit = .limited(1);
+ var receiver: http.WebSocket = .{ .input = &reader.interface, .output = &writer.writer, .role = if (role == .client) .server else .client };
+ const message = try receiver.receive(&receive_buffer);
+ try testing.expectEqual(.binary, message.opcode);
+ try testing.expectEqualSlices(u8, frame, message.data);
+ }
+ }
+}
+
+test "fragmented message survives an interleaved ping" {
+ // Rflush tag 1 split after its size field. Ping between the fragments.
+ var reader: std.Io.Reader = .fixed(&.{ 0x02, 4, 7, 0, 0, 0, 0x89, 1, 'x', 0x80, 3, 109, 1, 0 });
+ var writer: std.Io.Writer = .fixed(&.{});
+ var socket: http.WebSocket = .{ .input = &reader, .output = &writer, .role = .client };
+ var buffer: [64]u8 = undefined;
+ const ping = try socket.receive(&buffer);
+ try testing.expectEqual(.ping, ping.opcode);
+ try testing.expectEqualStrings("x", ping.data);
+ const frame = try socket.receive(&buffer);
+ const decoded = try c9.decode(frame.data);
+ try testing.expectEqual(c9.Type.rflush, decoded.msg.msgType());
+ try testing.expectEqual(@as(u16, 1), decoded.tag);
+}
+
+test "framing rejects invalid masks, reserved bits, lengths and controls" {
+ const cases = .{
+ .{ &[_]u8{ 0x82, 0x80 }, error.InvalidMask },
+ .{ &[_]u8{ 0xc2, 0 }, error.ReservedBits },
+ .{ &[_]u8{ 0x82, 126, 0, 7 }, error.NonCanonicalLength },
+ .{ &[_]u8{ 0x89, 126, 0, 126 }, error.InvalidControl },
+ .{ &[_]u8{ 0x09, 0 }, error.InvalidControl },
+ .{ &[_]u8{ 0x80, 0 }, error.UnexpectedContinuation },
+ .{ &[_]u8{ 0x81, 0 }, error.ExpectedBinary },
+ .{ &[_]u8{ 0x88, 1, 0 }, error.InvalidClose },
+ .{ &[_]u8{ 0x88, 2, 3, 237 }, error.InvalidClose },
+ .{ &[_]u8{ 0x88, 3, 3, 232, 255 }, error.InvalidClose },
+ .{ &[_]u8{ 0x82, 100 }, error.MessageTooLarge },
+ };
+ inline for (cases) |case| {
+ var reader: std.Io.Reader = .fixed(case[0]);
+ var writer: std.Io.Writer = .fixed(&.{});
+ var socket: http.WebSocket = .{ .input = &reader, .output = &writer, .role = .client };
+ var buffer: [64]u8 = undefined;
+ try testing.expectError(case[1], socket.receive(&buffer));
+ }
+}
+
+test "binary messages must contain exactly one complete 9P frame" {
+ var reader: std.Io.Reader = .fixed(&.{ 0x82, 7, 8, 0, 0, 0, 109, 1, 0 });
+ var writer: std.Io.Writer = .fixed(&.{});
+ var socket: http.WebSocket = .{ .input = &reader, .output = &writer, .role = .client };
+ var buffer: [64]u8 = undefined;
+ try testing.expectError(error.Truncated, socket.receive(&buffer));
+}
+
+test "upgrade validates nonce and uses RFC 6455 accept value" {
+ var reader: std.Io.Reader = .fixed("GET /9p HTTP/1.1\r\nHost: localhost\r\nConnection: keep-alive, Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n");
+ var writer: std.Io.Writer.Allocating = .init(testing.allocator);
+ defer writer.deinit();
+ var server: std.http.Server = .init(&reader, &writer.writer);
+ var request = try server.receiveHead();
+ _ = try http.accept(&request);
+ try testing.expect(std.mem.find(u8, writer.written(), "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=") != null);
+}
+
+test "upgrade rejects invalid nonce before responding" {
+ inline for (.{ "not-a-nonce", "AAAAAAAAAAAAAAAAAAAAAAAA", "AAAAAAAAAAAAAAAAAAAAAAA=", "!!!!!!!!!!!!!!!!!!!!!!==" }) |nonce| {
+ var reader: std.Io.Reader = .fixed("GET /9p HTTP/1.1\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: " ++ nonce ++ "\r\n\r\n");
+ var writer: std.Io.Writer = .fixed(&.{});
+ var server: std.http.Server = .init(&reader, &writer);
+ var request = try server.receiveHead();
+ try testing.expectError(error.InvalidUpgrade, http.accept(&request));
+ }
+}
diff --git a/test/web/e2e.mjs b/test/web/e2e.mjs
new file mode 100644
index 0000000..2ff83ae
--- /dev/null
+++ b/test/web/e2e.mjs
@@ -0,0 +1,268 @@
+// Real Chromium + native cloud9 HTTPS client + pinned go9p. No npm packages.
+import assert from 'node:assert/strict';
+import { spawn } from 'node:child_process';
+import { once } from 'node:events';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import net from 'node:net';
+import tls from 'node:tls';
+import http from 'node:http';
+
+const [bridgeBinary, nativeBinary] = process.argv.slice(2).map(p => path.resolve(p));
+if (!nativeBinary) throw new Error('usage: node test/web/e2e.mjs BRIDGE NATIVE_TEST');
+const root = path.resolve('.');
+await fs.mkdir('.zig-cache/tmp', { recursive: true });
+const work = await fs.mkdtemp(path.join(root, '.zig-cache/e2e-'));
+const children = [], logs = [], sockets = new Set();
+let browserSocket, tlsServer;
+const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
+function start(command, args, options = {}) {
+ const child = spawn(command, args, { ...options, env: { ...process.env, TMPDIR: path.join(root, '.zig-cache/tmp'), ...options.env }, stdio: ['ignore', 'pipe', 'pipe'] });
+ child.output = ''; child.errors = '';
+ child.stdout.on('data', b => { child.output += b; });
+ child.stderr.on('data', b => { child.errors = (child.errors + b).slice(-20000); });
+ child.on('error', error => { child.errors += error.message; });
+ children.push(child); return child;
+}
+async function run(command, args, options = {}, success = true) {
+ const child = start(command, args, options);
+ const timer = setTimeout(() => child.kill('SIGKILL'), 60000);
+ const [code] = await once(child, 'exit'); clearTimeout(timer);
+ if (success) assert.equal(code, 0, `${command}: ${child.errors}`);
+ else assert.notEqual(code, 0, 'untrusted TLS certificate must fail');
+ return child;
+}
+async function wait(check, message, timeout = 15000) {
+ const end = Date.now() + timeout;
+ while (Date.now() < end) { try { const result = await check(); if (result) return result; } catch {} await delay(40); }
+ throw new Error(`Timed out: ${message}`);
+}
+async function launchBridge(upstream, origin, extra = []) {
+ const child = start(bridgeBinary, ['--listen', '127.0.0.1:0', '--upstream', upstream, ...(origin ? ['--origin', origin] : []), ...extra]);
+ // With a public origin the log does not include the bound address. Tests
+ // requiring a proxy instead reserve a port and pass it explicitly below.
+ const match = await wait(() => child.errors.match(/cloud9-http (http:\/\/127\.0\.0\.1:\d+)/), 'bridge ready');
+ return { child, url: match[1] };
+}
+async function port() { const s = net.createServer(); s.listen(0, '127.0.0.1'); await once(s, 'listening'); const p = s.address().port; await new Promise(r => s.close(r)); return p; }
+let nextId = 0; const pending = new Map();
+function cdp(method, params = {}) {
+ const id = ++nextId;
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => { pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, 30000);
+ pending.set(id, { resolve, reject, timer }); browserSocket.send(JSON.stringify({ id, method, params }));
+ });
+}
+async function evaluate(expression) {
+ const result = await cdp('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
+ if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text);
+ return result.result.value;
+}
+async function click(selector) { await evaluate(`document.querySelector(${JSON.stringify(selector)}).click()`); }
+async function statusIncludes(text) { await wait(() => evaluate(`document.getElementById('status').textContent.includes(${JSON.stringify(text)})`), `status: ${text}`); }
+async function openPath(value) {
+ await evaluate(`document.getElementById('path').value=${JSON.stringify(value)};document.getElementById('path-form').requestSubmit()`);
+}
+try {
+ const fixture = path.join(work, 'fixture');
+ await run('go', ['build', '-o', fixture, './webfixture'], { cwd: path.join(root, 'test/differential') });
+ const server = start(fixture, []);
+ const upstream = await wait(() => server.output.match(/127\.0\.0\.1:\d+/)?.[0], 'go9p ready');
+ const bridge = await launchBridge(`tcp:${upstream}`);
+ const url = bridge.url;
+ const wasmResponse = await fetch(`${url}/_cloud9/cloud9.wasm`);
+ assert.equal(wasmResponse.headers.get('content-type'), 'application/wasm');
+ const module = await WebAssembly.compile(await wasmResponse.arrayBuffer());
+ assert.deepEqual(WebAssembly.Module.imports(module), [], 'WASM uses cloud9 without host imports');
+ assert.equal((await fetch(`${url}/_cloud9/absent`)).status, 404);
+ assert.equal((await fetch(`${url}/`, { method: 'POST' })).status, 405);
+ assert.equal((await fetch(`${url}/_cloud9/9p`)).status, 403);
+ assert.equal(await new Promise((resolve,reject)=>{const r=http.get(url,{headers:{Host:'unrelated.example'}},response=>{response.resume();resolve(response.statusCode)});r.on('error',reject)}), 403);
+ await run(nativeBinary, [`${url}/_cloud9/9p`, url]);
+
+ const debugPort = await port();
+ const chrome = start(process.env.CLOUD9_CHROME || 'google-chrome-stable', ['--headless=new', '--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check', `--user-data-dir=${work}/chrome`, `--remote-debugging-port=${debugPort}`, 'about:blank']);
+ const target = await wait(async () => {
+ const response = await fetch(`http://127.0.0.1:${debugPort}/json/new?about:blank`, { method: 'PUT' }); return response.ok && response.json();
+ }, 'Chromium ready');
+ browserSocket = new WebSocket(target.webSocketDebuggerUrl);
+ await once(browserSocket, 'open');
+ browserSocket.addEventListener('message', ({ data }) => {
+ const message = JSON.parse(data);
+ if (message.id) {
+ const request = pending.get(message.id); if (!request) return;
+ clearTimeout(request.timer); pending.delete(message.id);
+ if (message.error) request.reject(new Error(JSON.stringify(message.error))); else request.resolve(message.result);
+ } else if (message.method === 'Runtime.exceptionThrown') logs.push(message.params.exceptionDetails);
+ });
+ await cdp('Runtime.enable'); await cdp('Page.enable');
+ await cdp('Browser.setDownloadBehavior', { behavior: 'allow', downloadPath: path.join(work, 'downloads') });
+ await cdp('Emulation.setDeviceMetricsOverride', { width: 1120, height: 900, deviceScaleFactor: 1, mobile: false });
+ await cdp('Page.navigate', { url });
+ await wait(() => evaluate(`typeof document.getElementById('path-form')?.onsubmit === 'function'`), 'UI loaded');
+ await statusIncludes('entries');
+ assert.equal(await evaluate(`document.querySelectorAll('#connect,#disconnect,#user,#tree').length`), 0);
+ assert.deepEqual(await (await fetch(`${url}/_cloud9/config.json`)).json(), {user:'user',tree:''});
+ assert.ok(await evaluate(`[...document.querySelectorAll('.entry')].some(e=>e.textContent==='hello.txt')`));
+ assert.ok(await evaluate(`[...document.querySelectorAll('.entry')].some(e=>e.textContent==='<literal>.txt')`));
+ assert.equal(await evaluate(`document.querySelectorAll('literal').length`), 0);
+ await openPath('/hello.txt'); await statusIncludes('File loaded');
+ assert.equal(await evaluate(`document.getElementById('editor').value`), 'Hello from 9P.\n');
+ assert.equal(await evaluate(`location.pathname`), '/hello.txt');
+ await evaluate('history.back()'); await statusIncludes('entries');
+ await evaluate('history.forward()'); await statusIncludes('File loaded');
+ assert.ok(await evaluate(`document.querySelector('#breadcrumbs a[aria-current]').textContent==='hello.txt'`));
+ await openPath('/notes'); await statusIncludes('151 entries');
+ await openPath('/notes/café.txt'); await statusIncludes('File loaded');
+ assert.equal(await evaluate(`document.getElementById('editor').value`), 'Olá, 世界.\n');
+ await openPath('/edit.txt'); await statusIncludes('File loaded');
+ await evaluate(`document.getElementById('editor').value='Saved from Chromium.\\n';document.getElementById('editor').dispatchEvent(new Event('input'))`);
+ await click('#save'); await statusIncludes('Saved 21 bytes');
+ await evaluate('window.beforeReload=true');
+ await cdp('Page.reload');
+ await wait(() => evaluate(`!window.beforeReload && document.getElementById('status')?.textContent.includes('File loaded')`), 'automatic attach after page reload');
+ assert.equal(await evaluate(`location.pathname`), '/edit.txt');
+ assert.equal(await evaluate(`document.getElementById('editor').value`), 'Saved from Chromium.\n');
+ await openPath('/missing/child'); await statusIncludes('');
+ await wait(() => evaluate(`document.getElementById('status').classList.contains('error') && !document.getElementById('go').disabled`), 'missing path error');
+ await openPath('/hello.txt'); await statusIncludes('File loaded');
+ await openPath('/large.bin'); await statusIncludes('File loaded');
+ assert.equal(await evaluate(`document.getElementById('binary').hidden`), false);
+ assert.equal(await evaluate(`document.getElementById('save').disabled`), true);
+ await click('#download');
+ const download = await wait(async () => {
+ const bytes = await fs.readFile(path.join(work, 'downloads/large.bin'));
+ return bytes.length === 180000 && bytes;
+ }, 'binary download');
+ assert.ok(download.every((value, index) => value === index % 251));
+
+ // Invoke the shipped JS/WASM API in the browser for byte-exact, multi-frame
+ // transfers, queued operations, independent fid spaces, and long walks.
+ const checks = await evaluate(`(async()=>{
+ const {Client}=await import('/_cloud9/client.mjs');
+ const a=await Client.connect(), b=await Client.connect();
+ try {
+ const [large,other]=await Promise.all([a.readPath('/large.bin'),b.readPath('/hello.txt')]);
+ if(large.bytes.length!==180000||large.bytes.some((v,i)=>v!==i%251))throw Error('binary mismatch');
+ const bytes=Uint8Array.from({length:150000},(_,i)=>(i*17)%251);
+ await a.writePath('/edit.txt',bytes);
+ const read=await b.readPath('/edit.txt');
+ if(read.bytes.length!==bytes.length||read.bytes.some((v,i)=>v!==bytes[i]))throw Error('write mismatch');
+ await a.writePath('/edit.txt',new Uint8Array());
+ if((await b.readPath('/edit.txt')).bytes.length!==0)throw Error('truncate mismatch');
+ const deep=await a.readPath('/'+Array(18).fill('deep').join('/')+'/end.txt');
+ if(new TextDecoder().decode(deep.bytes)!=='Deep walk.\\n')throw Error('deep walk');
+ const queued=await Promise.all([a.readPath('/hello.txt'),a.readPath('/notes/café.txt')]);
+ return {binary:large.bytes.length,written:bytes.length,queued:queued.length,independent:new TextDecoder().decode(other.bytes)};
+ }finally{a.close();b.close()}
+ })()`);
+ assert.equal(checks.binary, 180000); assert.equal(checks.written, 150000); assert.equal(checks.queued, 2);
+ await openPath('/'); await statusIncludes('entries');
+ await fs.writeFile(path.join(work, 'browser.png'), Buffer.from((await cdp('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
+ await cdp('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 1, mobile: true });
+ assert.ok(await evaluate('document.documentElement.scrollWidth <= innerWidth'), 'mobile layout fits viewport');
+ await fs.writeFile(path.join(work, 'mobile.png'), Buffer.from((await cdp('Page.captureScreenshot', { format: 'png' })).data, 'base64'));
+ assert.deepEqual(logs, [], 'no uncaught browser exceptions');
+
+ // File paths share no routes with gateway assets. Read them through actual
+ // generated links, then reload those URLs in a fresh browser document.
+ for (const name of ['app.mjs', 'style.css', 'client.mjs', 'config.json', 'cloud9.wasm', '9p', 'space #?% ü.txt', 'literal%2F.txt', 'back\\slash.txt']) {
+ await openPath('/'); await statusIncludes('entries');
+ const expectedPath = '/' + encodeURIComponent(name);
+ const href = await evaluate(`document.querySelector('a[data-path='+CSS.escape(${JSON.stringify('/'+name)})+']').getAttribute('href')`);
+ assert.equal(href, expectedPath);
+ await evaluate(`document.querySelector('a[data-path='+CSS.escape(${JSON.stringify('/'+name)})+']').click()`);
+ await statusIncludes('File loaded');
+ assert.equal(await evaluate('location.pathname'), expectedPath);
+ assert.equal(await evaluate('location.search + location.hash'), '');
+ assert.equal(await evaluate(`document.getElementById('editor').value`), 'Path: '+name+'\n');
+ await evaluate('window.beforeReload=true');
+ await cdp('Page.reload');
+ await wait(() => evaluate(`!window.beforeReload && document.getElementById('status')?.textContent.includes('File loaded')`), 'encoded file reload');
+ assert.equal(await evaluate(`document.getElementById('editor').value`), 'Path: '+name+'\n');
+ }
+ await openPath('/_cloud9/app.mjs'); await statusIncludes('File loaded');
+ assert.equal(await evaluate('location.pathname'), '/%5Fcloud9/app.mjs');
+ await evaluate('window.beforeReload=true'); await cdp('Page.reload');
+ await wait(() => evaluate(`!window.beforeReload && document.getElementById('editor')?.value==='Upstream private-looking path.\\n'`), 'escaped gateway-prefix file');
+ for (const path of ['/bad%escape', '/notes%2Fcaf%C3%A9.txt', '/bad%00name']) {
+ await cdp('Page.navigate', {url:url+path});
+ await wait(() => evaluate(`location.pathname===${JSON.stringify(path)} && document.getElementById('status')?.textContent.includes('The URL contains')`), 'invalid path rejected');
+ await click('.brand'); await statusIncludes('entries');
+ assert.equal(await evaluate('location.pathname'), '/');
+ }
+
+ // Expiry is transport bookkeeping: edits survive it, and Save establishes
+ // a fresh session without a user-facing connection flow.
+ const shortBridge = await launchBridge(`tcp:${upstream}`, null, ['--timeout-ms', '1000', '--user', 'user', '--tree', '']);
+ await cdp('Page.navigate', {url:shortBridge.url+'/edit.txt'});
+ await wait(() => evaluate(`location.port===${JSON.stringify(new URL(shortBridge.url).port)} && document.getElementById('status')?.textContent.includes('File loaded')`), 'direct file URL');
+ await evaluate(`document.getElementById('editor').value='After expiry.\\n';document.getElementById('editor').dispatchEvent(new Event('input'))`);
+ await delay(1300);
+ assert.equal(await evaluate(`document.getElementById('editor').value`), 'After expiry.\n');
+ await click('#save'); await statusIncludes('Saved 14 bytes');
+ assert.equal(await evaluate(`document.getElementById('editor').value`), 'After expiry.\n');
+ const configured = await launchBridge(`tcp:${upstream}`, null, ['--user', 'reader', '--tree', 'named-export']);
+ assert.deepEqual(await (await fetch(configured.url+'/_cloud9/config.json')).json(), {user:'reader',tree:'named-export'});
+ await cdp('Page.navigate', {url:'about:blank'});
+
+ // Same public native API over certificate-verified TLS. The TLS terminator
+ // forwards bytes; the 9P server still runs behind the HTTP bridge over TCP.
+ const key = path.join(work, 'key.pem'), cert = path.join(work, 'cert.pem');
+ await run('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-noenc', '-keyout', key, '-out', cert, '-days', '1', '-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost']);
+ const httpsPort = await port(), backendPort = await port();
+ const origin = `https://localhost:${httpsPort}`;
+ const secureBridge = start(bridgeBinary, ['--listen', `127.0.0.1:${backendPort}`, '--upstream', `tcp:${upstream}`, '--origin', origin]);
+ await wait(() => secureBridge.errors.includes('cloud9-http'), 'TLS backend ready');
+ tlsServer = tls.createServer({ key: await fs.readFile(key), cert: await fs.readFile(cert), ALPNProtocols: ['http/1.1'] }, socket => {
+ const upstreamSocket = net.connect(backendPort, '127.0.0.1');
+ sockets.add(socket); sockets.add(upstreamSocket);
+ socket.on('error', () => upstreamSocket.destroy()); upstreamSocket.on('error', () => socket.destroy());
+ socket.on('close', () => { sockets.delete(socket); upstreamSocket.destroy(); });
+ upstreamSocket.on('close', () => { sockets.delete(upstreamSocket); socket.destroy(); });
+ socket.pipe(upstreamSocket).pipe(socket);
+ });
+ tlsServer.on('tlsClientError', () => {});
+ tlsServer.listen(httpsPort); await once(tlsServer, 'listening');
+ await run(nativeBinary, [`${origin}/_cloud9/9p`, origin, cert]);
+ await run(nativeBinary, [`${origin}/_cloud9/9p`, origin], {}, false);
+ await run(nativeBinary, [`https://127.0.0.1:${httpsPort}/_cloud9/9p`, origin, cert], {}, false);
+
+ const unixPath = path.join(work, '9p.sock');
+ const unixServer = start(fixture, [unixPath]);
+ await wait(() => unixServer.output.includes(unixPath), 'Unix go9p ready');
+ const unixBridge = await launchBridge(`unix:${unixPath}`);
+ await run(nativeBinary, [`${unixBridge.url}/_cloud9/9p`, unixBridge.url]);
+ const serial = start('python3', ['test/web/serial.py', upstream]);
+ const serialPath = await wait(() => serial.output.match(/\/dev\/pts\/\d+/)?.[0], 'serial fixture ready');
+ const serialBridge = await launchBridge(`file:${serialPath}`);
+ await run(nativeBinary, [`${serialBridge.url}/_cloud9/9p`, serialBridge.url]);
+ // Closing a browser connection must cancel a blocked serial read and release
+ // the exclusive device lease before another session attaches.
+ await delay(100);
+ await run(nativeBinary, [`${serialBridge.url}/_cloud9/9p`, serialBridge.url]);
+
+ const deadlineBridge = start(bridgeBinary, ['--listen', '127.0.0.1:0', '--upstream', `tcp:${upstream}`, '--timeout-ms', '100']);
+ const deadlineURL = await wait(() => deadlineBridge.errors.match(/http:\/\/127\.0\.0\.1:\d+/)?.[0], 'deadline bridge');
+ const stalled = net.connect(Number(new URL(deadlineURL).port), '127.0.0.1');
+ await once(stalled, 'connect');
+ stalled.write('GET / HTTP/1.1\r\n');
+ await Promise.race([once(stalled, 'close'), delay(2000).then(()=>{stalled.destroy();throw Error('header deadline did not close connection')})]);
+ const report = { browser: 'Chromium', reference: 'go9p v1.18.0', wasmHostImports: 0, checks, passed: ['clean path links and reloads', 'encoded filename round trips', 'gateway asset name collisions', 'escaped gateway-prefix file', 'invalid path encoding rejected', 'automatic connection', 'bookmarkable file URLs', 'browser back and forward', 'automatic reconnect preserves edits', 'configured attach defaults', 'directory paging', 'UTF-8 paths and contents', 'text save and reconnect', 'binary read/write', 'browser download', 'mobile layout', 'truncate to empty', '18-component walk', 'concurrent sessions', 'queued operations', 'Rerror recovery', 'HTTP routing and origin policy', 'native HTTP', 'native verified HTTPS', 'untrusted certificate rejection', 'hostname mismatch rejection', 'Unix upstream', 'PTY serial upstream', 'serial lease released after disconnect', 'connection deadline', 'overlapping native requests', 'fragmented upstream I/O'] };
+ await fs.writeFile(path.join(work, 'result.json'), JSON.stringify(report, null, 2)+'\n');
+ console.log(`E2E passed: ${report.passed.length} scenarios. Artifacts: ${path.relative(root, work)}`);
+} catch (error) {
+ console.error(error.stack);
+ if (browserSocket?.readyState === WebSocket.OPEN) console.error('Browser state:', await evaluate(`({status:document.getElementById('status')?.textContent,body:document.body?.innerText})`).catch(String));
+ console.error('Browser exceptions:', logs);
+ for (const child of children) if (child.errors) console.error(child.spawnargs.join(' '), '\n', child.errors);
+ process.exitCode = 1;
+} finally {
+ for (const request of pending.values()) clearTimeout(request.timer);
+ browserSocket?.close();
+ for (const socket of sockets) socket.destroy();
+ tlsServer?.close();
+ for (const child of children.reverse()) if (child.exitCode === null) child.kill('SIGTERM');
+ await delay(300);
+ for (const child of children) if (child.exitCode === null) child.kill('SIGKILL');
+}
diff --git a/test/web/native.zig b/test/web/native.zig
new file mode 100644
index 0000000..ad11386
--- /dev/null
+++ b/test/web/native.zig
@@ -0,0 +1,66 @@
+//! Native use of the public HTTPS transport, exercised by the browser E2E runner.
+const std = @import("std");
+const c9 = @import("cloud9");
+const Session = struct {
+ tunnel: *c9.http.Client,
+ client: c9.Client,
+ fn ask(s: *Session, request: c9.Client.Request) !c9.Client.Result {
+ const tag = try s.client.submit(request);
+ try s.tunnel.send(s.client.output());
+ s.client.wrote(s.client.output().len);
+ var buffer: [65536]u8 = undefined;
+ const frame = try s.tunnel.receive(&buffer);
+ if (s.client.push(frame) != frame.len) return error.InputFull;
+ const done = s.client.take() orelse return error.MissingReply;
+ if (done.tag != tag or done.result == .fail) return error.UnexpectedReply;
+ return done.result;
+ }
+};
+pub fn main(init: std.process.Init) !void {
+ const args = try init.minimal.args.toSlice(init.arena.allocator());
+ if (args.len < 3 or args.len > 4) return error.Arguments;
+ var http: std.http.Client = .{ .allocator = init.gpa, .io = init.io };
+ defer http.deinit();
+ if (args.len == 4) {
+ http.now = std.Io.Clock.real.now(init.io);
+ try http.ca_bundle.addCertsFromFilePathAbsolute(init.gpa, init.io, http.now.?, args[3]);
+ }
+ var tunnel = try c9.http.Client.connect(&http, args[1], args[2]);
+ defer tunnel.deinit();
+ var input: [65536]u8 = undefined;
+ var output: [65536]u8 = undefined;
+ var session: Session = .{ .tunnel = &tunnel, .client = .init(.{ .in = &input, .out = &output }) };
+ _ = try session.ask(.{ .version = .{} });
+ _ = try session.ask(.{ .attach = .{ .fid = 0, .uname = "user" } });
+ _ = try session.ask(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{"hello.txt"} } });
+ _ = try session.ask(.{ .open = .{ .fid = 1, .mode = c9.oread } });
+ const result = try session.ask(.{ .read = .{ .fid = 1, .offset = 0, .count = 1024 } });
+ if (!std.mem.eql(u8, result.read, "Hello from 9P.\n")) return error.WrongContents;
+ // Two outstanding tags exercise the full-duplex bridge independently of
+ // the browser UI's serialized file-operation queue.
+ const read_tag = try session.client.submit(.{ .read = .{ .fid = 1, .offset = 0, .count = 1024 } });
+ try tunnel.send(session.client.output());
+ session.client.wrote(session.client.output().len);
+ const stat_tag = try session.client.submit(.{ .stat = .{ .fid = 0 } });
+ try tunnel.send(session.client.output());
+ session.client.wrote(session.client.output().len);
+ var seen_read = false;
+ var seen_stat = false;
+ var reply_buffer: [65536]u8 = undefined;
+ for (0..2) |_| {
+ const frame = try tunnel.receive(&reply_buffer);
+ if (session.client.push(frame) != frame.len) return error.InputFull;
+ const done = session.client.take() orelse return error.MissingReply;
+ if (done.tag == read_tag and !seen_read and done.result == .read) {
+ if (!std.mem.eql(u8, done.result.read, "Hello from 9P.\n")) return error.WrongContents;
+ seen_read = true;
+ } else if (done.tag == stat_tag and !seen_stat and done.result == .stat) {
+ if (done.result.stat.qid.type & c9.qtdir == 0) return error.WrongStat;
+ seen_stat = true;
+ } else return error.UnexpectedReply;
+ }
+ if (!seen_read or !seen_stat) return error.MissingReply;
+ _ = try session.ask(.{ .clunk = .{ .fid = 1 } });
+ _ = try session.ask(.{ .clunk = .{ .fid = 0 } });
+ std.debug.print("native HTTP transport passed\n", .{});
+}
diff --git a/test/web/serial.py b/test/web/serial.py
new file mode 100644
index 0000000..83833a4
--- /dev/null
+++ b/test/web/serial.py
@@ -0,0 +1,25 @@
+"""Raw PTY to local reference server: exercise a UART-like byte stream."""
+import os
+import pty
+import select
+import socket
+import sys
+import tty
+
+master, slave = pty.openpty()
+tty.setraw(slave)
+host, port = sys.argv[1].split(":")
+peer = socket.create_connection((host, int(port)))
+print(os.ttyname(slave), flush=True)
+while True:
+ ready, _, _ = select.select([master, peer], [], [])
+ for source in ready:
+ data = os.read(master, 19) if source == master else peer.recv(23)
+ if not data:
+ sys.exit(0)
+ if source == master:
+ peer.sendall(data)
+ else:
+ while data:
+ written = os.write(master, data)
+ data = data[written:]