6e57f5f4
refactor: the host grammar and the one atomic writer live in hosts.zig
a73x 2026-08-29 12:14
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -186,11 +186,12 @@ const mod_table = [_]ModSpec{ | |||
| 186 | // Engine plus protocol and nothing else, same shape as delta — pure, | 186 | // Engine plus protocol and nothing else, same shape as delta — pure, |
| 187 | // socket-free, and its own tests drive it with no daemon in sight. | 187 | // socket-free, and its own tests drive it with no daemon in sight. |
| 188 | .{ .name = "cmd", .path = "src/server/cmd.zig", .layer = 1, .imports = &.{ "engine", "protocol" } }, | 188 | .{ .name = "cmd", .path = "src/server/cmd.zig", .layer = 1, .imports = &.{ "engine", "protocol" } }, |
| 189 | // The wall: the TARGET spelling grammar, the ordered list, and the | 189 | // The wall: the host spelling grammar, the daemon list, and the state |
| 190 | // state file behind them. One owner for a grammar the hub, the state | 190 | // file behind them, plus the one atomic writer every state file uses. |
| 191 | // file and later the CLI all have to agree on — protocol plus xdg for | 191 | // One owner for a grammar the hub, the file and the CLI all have to |
| 192 | // where that file lives, so its tests need no hub, no daemon and no socket. | 192 | // agree on — xdg for where that file lives, so its tests need no hub, |
| 193 | .{ .name = "wall", .path = "src/client/wall.zig", .layer = 1, .imports = &.{ "protocol", "xdg" }, .test_imports = &.{"testtmp"} }, | 193 | // no daemon and no socket. |
| 194 | .{ .name = "hosts", .path = "src/client/hosts.zig", .layer = 1, .imports = &.{"xdg"}, .test_imports = &.{"testtmp"} }, | ||
| 194 | // The upgrade vocabulary: the version skew rule and the manifest an | 195 | // The upgrade vocabulary: the version skew rule and the manifest an |
| 195 | // exec-ing daemon leaves for its replacement. Protocol only — the | 196 | // exec-ing daemon leaves for its replacement. Protocol only — the |
| 196 | // manifest is a stranger's format (length-prefixed sections, unknown | 197 | // manifest is a stranger's format (length-prefixed sections, unknown |
| @@ -233,11 +234,10 @@ const mod_table = [_]ModSpec{ | |||
| 233 | // under it. | 234 | // under it. |
| 234 | .{ .name = "ptyclient", .path = "test/ptyclient.zig", .layer = 1, .link_libc = true, .imports = &.{ "pty", "script" } }, | 235 | .{ .name = "ptyclient", .path = "test/ptyclient.zig", .layer = 1, .link_libc = true, .imports = &.{ "pty", "script" } }, |
| 235 | // ---- layer 2 ---- | 236 | // ---- layer 2 ---- |
| 236 | // The host file: the wall as a list of DAEMONS. It borrows wall's argv | 237 | // The browser hub's private wall file, being retired: the `#SESSION` |
| 237 | // grammar rather than forking a second one, and wall is layer 1 — so | 238 | // spelling half the hub has not yet stopped reading. It borrows the |
| 238 | // this sits at 2, above its lender and below every consumer, instead | 239 | // host grammar rather than forking a second one, so it sits above it. |
| 239 | // of flattening the stratum it depends on. | 240 | .{ .name = "wall", .path = "src/client/wall.zig", .layer = 2, .imports = &.{ "protocol", "hosts", "xdg" }, .test_imports = &.{"testtmp"} }, |
| 240 | .{ .name = "hosts", .path = "src/client/hosts.zig", .layer = 2, .imports = &.{ "wall", "xdg" }, .test_imports = &.{"testtmp"} }, | ||
| 241 | // Everything that happens between a user at a terminal and one already | 241 | // Everything that happens between a user at a terminal and one already |
| 242 | // open session: the chord table, the wheel splitter, prediction, the | 242 | // open session: the chord table, the wheel splitter, prediction, the |
| 243 | // side channels, terminal ownership. It sits BELOW client because it | 243 | // side channels, terminal ownership. It sits BELOW client because it |
| @@ -268,7 +268,7 @@ const mod_table = [_]ModSpec{ | |||
| 268 | // attach records its own tile (the wall is attach history), and the | 268 | // attach records its own tile (the wall is attach history), and the |
| 269 | // chord switches that re-dial from inside client.attach have to record | 269 | // chord switches that re-dial from inside client.attach have to record |
| 270 | // theirs too, so the writer cannot live up in mux_main. | 270 | // theirs too, so the writer cannot live up in mux_main. |
| 271 | .{ .name = "client", .path = "src/client/client.zig", .layer = 3, .link_libc = true, .imports = &.{ "protocol", "replica", "keymap", "quic", "handoff", "wall", "xdg", "sockpath" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, | 271 | .{ .name = "client", .path = "src/client/client.zig", .layer = 3, .link_libc = true, .imports = &.{ "protocol", "replica", "keymap", "quic", "handoff", "hosts", "xdg", "sockpath" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, |
| 272 | // The daemon entrypoint loads the key and constructs the listener, so | 272 | // The daemon entrypoint loads the key and constructs the listener, so |
| 273 | // it needs quic/quic_server directly rather than through the server. | 273 | // it needs quic/quic_server directly rather than through the server. |
| 274 | // `muxd endpoint` prints the announce line handoff spells; sockpath is | 274 | // `muxd endpoint` prints the announce line handoff spells; sockpath is |
| @@ -290,7 +290,7 @@ const mod_table = [_]ModSpec{ | |||
| 290 | // typed-at session; the overlay machinery itself is interact's, shared — | 290 | // typed-at session; the overlay machinery itself is interact's, shared — |
| 291 | // and phase 3 promotes the tile into that core rather than growing a | 291 | // and phase 3 promotes the tile into that core rather than growing a |
| 292 | // second copy of it. | 292 | // second copy of it. |
| 293 | .{ .name = "wallview", .path = "src/tui/wallview.zig", .layer = 4, .link_libc = true, .imports = &.{ "protocol", "client", "interact", "wall", "hosts", "handoff", "proxy", "engine", "paint", "select", "layout" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, | 293 | .{ .name = "wallview", .path = "src/tui/wallview.zig", .layer = 4, .link_libc = true, .imports = &.{ "protocol", "client", "interact", "hosts", "handoff", "proxy", "engine", "paint", "select", "layout" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, |
| 294 | // ---- layer 5 ---- | 294 | // ---- layer 5 ---- |
| 295 | // wall owns the spelling grammar and the state file, so argv is parsed | 295 | // wall owns the spelling grammar and the state file, so argv is parsed |
| 296 | // by the SAME rules the page's POST /tiles and the restored file are — | 296 | // by the SAME rules the page's POST /tiles and the restored file are — |
| @@ -304,7 +304,7 @@ const mod_table = [_]ModSpec{ | |||
| 304 | // daemon downstream has to notice and refuse. Layer 5 since `mux wall` | 304 | // daemon downstream has to notice and refuse. Layer 5 since `mux wall` |
| 305 | // pulled in wallview (layer 4); wall rides along for the no-arg wall | 305 | // pulled in wallview (layer 4); wall rides along for the no-arg wall |
| 306 | // (the state file the browser hub builds). | 306 | // (the state file the browser hub builds). |
| 307 | .{ .name = "client_main", .path = "src/cli/mux_main.zig", .layer = 5, .link_libc = true, .imports = &.{ "client", "protocol", "xdg", "spawn", "handoff", "sockpath", "wallview", "wall", "hosts", "cliflags" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, | 307 | .{ .name = "client_main", .path = "src/cli/mux_main.zig", .layer = 5, .link_libc = true, .imports = &.{ "client", "protocol", "xdg", "spawn", "handoff", "sockpath", "wallview", "hosts", "cliflags" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, |
| 308 | // ---- layer 6: the one binary ---- | 308 | // ---- layer 6: the one binary ---- |
| 309 | // Four words, one image. Nothing but the mode letter lives here, which | 309 | // Four words, one image. Nothing but the mode letter lives here, which |
| 310 | // is why it may sit above every other main without owning any of them. | 310 | // is why it may sit above every other main without owning any of them. |
docscheck.budget
| Old | New | ||
|---|---|---|---|
| @@ -14,6 +14,7 @@ layout.zig 761 | |||
| 14 | main.zig 0 | 14 | main.zig 0 |
| 15 | muxa.zig 0 | 15 | muxa.zig 0 |
| 16 | mux_main.zig 0 | 16 | mux_main.zig 0 |
| 17 | mux.zig 0 | ||
| 17 | paint.zig 0 | 18 | paint.zig 0 |
| 18 | predict.zig 0 | 19 | predict.zig 0 |
| 19 | protocol.zig 0 | 20 | protocol.zig 0 |
| @@ -24,38 +25,37 @@ quic_server.zig 0 | |||
| 24 | quic.zig 0 | 25 | quic.zig 0 |
| 25 | replica.zig 0 | 26 | replica.zig 0 |
| 26 | select.zig 0 | 27 | select.zig 0 |
| 28 | server_agent.zig 0 | ||
| 29 | server_sessions.zig 0 | ||
| 30 | server_test_agent.zig 0 | ||
| 31 | server_test_attach.zig 0 | ||
| 32 | server_test_await.zig 0 | ||
| 33 | server_test_clipboard.zig 0 | ||
| 34 | server_test_deliver.zig 0 | ||
| 35 | server_test_harness.zig 0 | ||
| 36 | server_test_modes.zig 0 | ||
| 37 | server_test_quic.zig 0 | ||
| 38 | server_test_session.zig 0 | ||
| 39 | server_test_upgrade.zig 0 | ||
| 27 | server.zig 0 | 40 | server.zig 0 |
| 28 | shellint.zig 0 | 41 | shellint.zig 0 |
| 29 | sockpath.zig 0 | 42 | sockpath.zig 0 |
| 30 | spawn.zig 0 | 43 | spawn.zig 0 |
| 31 | testtmp.zig 0 | 44 | testtmp.zig 0 |
| 32 | upgrade.zig 269 | 45 | upgrade.zig 269 |
| 33 | wallview.zig 0 | ||
| 34 | wall.zig 0 | ||
| 35 | wasm_core.zig 0 | ||
| 36 | webhub_main.zig 0 | ||
| 37 | webhub.zig 0 | ||
| 38 | xdg.zig 0 | ||
| 39 | server_test_harness.zig 0 | ||
| 40 | server_test_attach.zig 0 | ||
| 41 | server_test_deliver.zig 0 | ||
| 42 | server_test_modes.zig 0 | ||
| 43 | server_test_session.zig 0 | ||
| 44 | server_test_quic.zig 0 | ||
| 45 | server_test_await.zig 0 | ||
| 46 | server_test_clipboard.zig 0 | ||
| 47 | server_test_agent.zig 0 | ||
| 48 | server_test_upgrade.zig 0 | ||
| 49 | server_agent.zig 0 | ||
| 50 | server_sessions.zig 0 | ||
| 51 | wall_host.zig 0 | 46 | wall_host.zig 0 |
| 47 | wall_layout.zig 1432 | ||
| 52 | wall_picker.zig 0 | 48 | wall_picker.zig 0 |
| 53 | wall_pump.zig 0 | 49 | wall_pump.zig 0 |
| 54 | wall_layout.zig 1432 | ||
| 55 | wall_test_harness.zig 0 | 50 | wall_test_harness.zig 0 |
| 56 | wall_test_host.zig 0 | 51 | wall_test_host.zig 0 |
| 52 | wall_test_layout.zig 0 | ||
| 57 | wall_test_picker.zig 0 | 53 | wall_test_picker.zig 0 |
| 58 | wall_test_pump.zig 0 | 54 | wall_test_pump.zig 0 |
| 59 | wall_test_layout.zig 0 | ||
| 60 | wall_test_wall.zig 0 | 55 | wall_test_wall.zig 0 |
| 61 | mux.zig 0 | 56 | wallview.zig 0 |
| 57 | wall.zig 0 | ||
| 58 | wasm_core.zig 0 | ||
| 59 | webhub_main.zig 0 | ||
| 60 | webhub.zig 0 | ||
| 61 | xdg.zig 0 | ||
src/cli/mux_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -23,7 +23,6 @@ const handoff = @import("handoff"); | |||
| 23 | const sockpath = @import("sockpath"); | 23 | const sockpath = @import("sockpath"); |
| 24 | const wallview = @import("wallview"); | 24 | const wallview = @import("wallview"); |
| 25 | const hosts = @import("hosts"); | 25 | const hosts = @import("hosts"); |
| 26 | const wall = @import("wall"); | ||
| 27 | const cliflags = @import("cliflags"); | 26 | const cliflags = @import("cliflags"); |
| 28 | const TmpDir = @import("testtmp").TmpDir; | 27 | const TmpDir = @import("testtmp").TmpDir; |
| 29 | 28 | ||
| @@ -518,7 +517,7 @@ fn hostsList(arena: std.mem.Allocator, path: []const u8, out_fd: std.posix.fd_t) | |||
| 518 | // to SEE a line they have to fix, and a strict read would refuse to | 517 | // to SEE a line they have to fix, and a strict read would refuse to |
| 519 | // show it to them. Nothing here writes the file, so there is no | 518 | // show it to them. Nothing here writes the file, so there is no |
| 520 | // half-understood content to protect. | 519 | // half-understood content to protect. |
| 521 | const lines = wall.loadLines(arena, path) catch |err| return refuseFile(arena, "mux hosts", path, err); | 520 | const lines = hosts.loadLines(arena, path) catch |err| return refuseFile(arena, "mux hosts", path, err); |
| 522 | const key = std.posix.getenv(xdg.key_env); | 521 | const key = std.posix.getenv(xdg.key_env); |
| 523 | for (lines.items) |line| { | 522 | for (lines.items) |line| { |
| 524 | const spec = wallview.resolveHost(arena, line, key, client.quic_idle_ms_default) catch |err| { | 523 | const spec = wallview.resolveHost(arena, line, key, client.quic_idle_ms_default) catch |err| { |
| @@ -541,7 +540,7 @@ fn hostsList(arena: std.mem.Allocator, path: []const u8, out_fd: std.posix.fd_t) | |||
| 541 | /// One listing row: the file's line verbatim, then a formatted verdict. | 540 | /// One listing row: the file's line verbatim, then a formatted verdict. |
| 542 | fn printRow(fd: std.posix.fd_t, line: []const u8, comptime fmt: []const u8, args: anytype) void { | 541 | fn printRow(fd: std.posix.fd_t, line: []const u8, comptime fmt: []const u8, args: anytype) void { |
| 543 | // The line is written straight through rather than formatted into a | 542 | // The line is written straight through rather than formatted into a |
| 544 | // buffer with the verdict. `wall.loadLines` accepts lines up to a MiB | 543 | // buffer with the verdict. `hosts.loadLines` accepts lines up to a MiB |
| 545 | // and `mux hosts rm` matches byte for byte, so a row that overflowed a | 544 | // and `mux hosts rm` matches byte for byte, so a row that overflowed a |
| 546 | // fixed buffer used to vanish from the one command whose job is showing | 545 | // fixed buffer used to vanish from the one command whose job is showing |
| 547 | // the user a line they then have to type back. | 546 | // the user a line they then have to type back. |
| @@ -569,7 +568,7 @@ fn refuseFile(arena: std.mem.Allocator, who: []const u8, path: []const u8, err: | |||
| 569 | // file: an `OutOfMemory` on a file that also holds one stale line used | 568 | // file: an `OutOfMemory` on a file that also holds one stale line used |
| 570 | // to print "OutOfMemory" and then exit 2 with a list of lines to fix. | 569 | // to print "OutOfMemory" and then exit 2 with a list of lines to fix. |
| 571 | if (!hosts.isParse(err)) return 1; | 570 | if (!hosts.isParse(err)) return 1; |
| 572 | const lines = wall.loadLines(arena, path) catch return 2; | 571 | const lines = hosts.loadLines(arena, path) catch return 2; |
| 573 | for (lines.items) |l| { | 572 | for (lines.items) |l| { |
| 574 | _ = hosts.parse(l) catch std.debug.print(" {s}\n", .{l}); | 573 | _ = hosts.parse(l) catch std.debug.print(" {s}\n", .{l}); |
| 575 | } | 574 | } |
| @@ -630,7 +629,7 @@ fn hostsEdit( | |||
| 630 | // way too. | 629 | // way too. |
| 631 | var i: usize = 0; | 630 | var i: usize = 0; |
| 632 | while (i < args.len) : (i += 1) { | 631 | while (i < args.len) : (i += 1) { |
| 633 | const n = wall.spellingFromArgv(arena, args, i) catch |err| switch (err) { | 632 | const n = hosts.spellingFromArgv(arena, args, i) catch |err| switch (err) { |
| 634 | error.MissingSockPath => { | 633 | error.MissingSockPath => { |
| 635 | std.debug.print("mux hosts rm: '--sock' names no path\n", .{}); | 634 | std.debug.print("mux hosts rm: '--sock' names no path\n", .{}); |
| 636 | return 2; | 635 | return 2; |
| @@ -680,7 +679,7 @@ fn hostsEdit( | |||
| 680 | /// The file verbatim, when the next thing the user must do is name one of | 679 | /// The file verbatim, when the next thing the user must do is name one of |
| 681 | /// its lines back at the program. | 680 | /// its lines back at the program. |
| 682 | fn showFile(arena: std.mem.Allocator, path: []const u8) void { | 681 | fn showFile(arena: std.mem.Allocator, path: []const u8) void { |
| 683 | const lines = wall.loadLines(arena, path) catch return; | 682 | const lines = hosts.loadLines(arena, path) catch return; |
| 684 | for (lines.items) |l| std.debug.print(" {s}\n", .{l}); | 683 | for (lines.items) |l| std.debug.print(" {s}\n", .{l}); |
| 685 | } | 684 | } |
| 686 | 685 | ||
| @@ -835,7 +834,7 @@ test "hosts: a line the file cannot hold exits 2 wherever the file is read" { | |||
| 835 | const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()}); | 834 | const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()}); |
| 836 | // Two hosts, one of them hand-edited into a spelling the grammar | 835 | // Two hosts, one of them hand-edited into a spelling the grammar |
| 837 | // refuses — the state a user is actually in when they reach for `rm`. | 836 | // refuses — the state a user is actually in when they reach for `rm`. |
| 838 | try wall.saveBytes(path, "--sock /tmp/x.sock\nbox#old\n"); | 837 | try hosts.saveBytes(path, "--sock /tmp/x.sock\nbox#old\n"); |
| 839 | 838 | ||
| 840 | // The write path and the wall agree: 2, the user's file to fix. `rm` | 839 | // The write path and the wall agree: 2, the user's file to fix. `rm` |
| 841 | // reads verbatim and repairs it, and only then does `add` grow it. | 840 | // reads verbatim and repairs it, and only then does `add` grow it. |
| @@ -853,7 +852,7 @@ test "hosts list: a line the grammar refuses is named, not a refusal of the list | |||
| 853 | var buf2: [512]u8 = undefined; | 852 | var buf2: [512]u8 = undefined; |
| 854 | const dead = try std.fmt.bufPrint(&buf2, "{s}/absent.sock", .{tmp.path()}); | 853 | const dead = try std.fmt.bufPrint(&buf2, "{s}/absent.sock", .{tmp.path()}); |
| 855 | var line_buf: [1024]u8 = undefined; | 854 | var line_buf: [1024]u8 = undefined; |
| 856 | try wall.saveBytes(path, try std.fmt.bufPrint(&line_buf, "box#old\n--sock {s}\n", .{dead})); | 855 | try hosts.saveBytes(path, try std.fmt.bufPrint(&line_buf, "box#old\n--sock {s}\n", .{dead})); |
| 857 | 856 | ||
| 858 | var arena_state = std.heap.ArenaAllocator.init(alloc); | 857 | var arena_state = std.heap.ArenaAllocator.init(alloc); |
| 859 | defer arena_state.deinit(); | 858 | defer arena_state.deinit(); |
| @@ -883,7 +882,7 @@ test "refuseFile: the exit code names whose fault it was, and a bad line in the | |||
| 883 | defer tmp.cleanup(); | 882 | defer tmp.cleanup(); |
| 884 | var buf: [512]u8 = undefined; | 883 | var buf: [512]u8 = undefined; |
| 885 | const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()}); | 884 | const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()}); |
| 886 | try wall.saveBytes(path, "box#old\nbox\n"); | 885 | try hosts.saveBytes(path, "box#old\nbox\n"); |
| 887 | 886 | ||
| 888 | var arena_state = std.heap.ArenaAllocator.init(alloc); | 887 | var arena_state = std.heap.ArenaAllocator.init(alloc); |
| 889 | defer arena_state.deinit(); | 888 | defer arena_state.deinit(); |
| @@ -894,7 +893,7 @@ test "refuseFile: the exit code names whose fault it was, and a bad line in the | |||
| 894 | } | 893 | } |
| 895 | 894 | ||
| 896 | test "hosts list: a line longer than the row buffer is still shown, because rm matches what was shown" { | 895 | test "hosts list: a line longer than the row buffer is still shown, because rm matches what was shown" { |
| 897 | // `wall.loadLines` takes lines up to a MiB and `mux hosts rm` matches | 896 | // `hosts.loadLines` takes lines up to a MiB and `mux hosts rm` matches |
| 898 | // byte for byte, so a row this command drops is a line the user can | 897 | // byte for byte, so a row this command drops is a line the user can |
| 899 | // neither see nor type back — the two halves of the repair loop have to | 898 | // neither see nor type back — the two halves of the repair loop have to |
| 900 | // agree about which lines exist. | 899 | // agree about which lines exist. |
| @@ -911,7 +910,7 @@ test "hosts list: a line longer than the row buffer is still shown, because rm m | |||
| 911 | @memcpy(long[0..8], "--sock /"); | 910 | @memcpy(long[0..8], "--sock /"); |
| 912 | @memset(long[8..], 'h'); | 911 | @memset(long[8..], 'h'); |
| 913 | var line_buf: [1024]u8 = undefined; | 912 | var line_buf: [1024]u8 = undefined; |
| 914 | try wall.saveBytes(path, try std.fmt.bufPrint(&line_buf, "{s}\n", .{long})); | 913 | try hosts.saveBytes(path, try std.fmt.bufPrint(&line_buf, "{s}\n", .{long})); |
| 915 | 914 | ||
| 916 | var arena_state = std.heap.ArenaAllocator.init(alloc); | 915 | var arena_state = std.heap.ArenaAllocator.init(alloc); |
| 917 | defer arena_state.deinit(); | 916 | defer arena_state.deinit(); |
src/client/client.zig
| Old | New | ||
|---|---|---|---|
| @@ -22,7 +22,7 @@ const proto = @import("protocol"); | |||
| 22 | const TmpDir = @import("testtmp").TmpDir; | 22 | const TmpDir = @import("testtmp").TmpDir; |
| 23 | const quic = @import("quic"); | 23 | const quic = @import("quic"); |
| 24 | const handoff = @import("handoff"); | 24 | const handoff = @import("handoff"); |
| 25 | const wall = @import("wall"); | 25 | const hosts = @import("hosts"); |
| 26 | const xdg = @import("xdg"); | 26 | const xdg = @import("xdg"); |
| 27 | const sockpath = @import("sockpath"); | 27 | const sockpath = @import("sockpath"); |
| 28 | 28 | ||
| @@ -261,7 +261,7 @@ pub const Target = union(enum) { | |||
| 261 | quic: QuicTarget, | 261 | quic: QuicTarget, |
| 262 | hand: HandoffTarget, | 262 | hand: HandoffTarget, |
| 263 | 263 | ||
| 264 | /// The one road from a spelling's `wall.Spec` to the dial it names: | 264 | /// The one road from a spelling's `hosts.Spec` to the dial it names: |
| 265 | /// the wall's host lines and the hub's tiles resolve here, and the | 265 | /// the wall's host lines and the hub's tiles resolve here, and the |
| 266 | /// CLI's `.host` arm shares `fromRecipe`. What can fail is | 266 | /// CLI's `.host` arm shares `fromRecipe`. What can fail is |
| 267 | /// `SpecError`; the word for it is each door's own. | 267 | /// `SpecError`; the word for it is each door's own. |
| @@ -270,7 +270,7 @@ pub const Target = union(enum) { | |||
| 270 | /// be a scratch buffer, and a target outlives the read that made it. | 270 | /// be a scratch buffer, and a target outlives the read that made it. |
| 271 | /// | 271 | /// |
| 272 | /// `asked` is required, never defaulted: see `HandoffTarget.fromRecipe`. | 272 | /// `asked` is required, never defaulted: see `HandoffTarget.fromRecipe`. |
| 273 | pub fn fromSpec(alloc: std.mem.Allocator, spec: wall.Spec, key: ?[]const u8, idle_ms: u32, asked: bool) SpecError!Target { | 273 | pub fn fromSpec(alloc: std.mem.Allocator, spec: hosts.Spec, key: ?[]const u8, idle_ms: u32, asked: bool) SpecError!Target { |
| 274 | return switch (spec) { | 274 | return switch (spec) { |
| 275 | // sun_path is a fixed array in the kernel's struct: a longer | 275 | // sun_path is a fixed array in the kernel's struct: a longer |
| 276 | // path cannot be dialed at all, so it is refused here, at | 276 | // path cannot be dialed at all, so it is refused here, at |
| @@ -1061,8 +1061,8 @@ pub fn openFailure(buf: []u8, target: Target, err: anyerror) OpenFailure { | |||
| 1061 | 1061 | ||
| 1062 | /// What turning a `Target` back into a spelling can fail with — a tile's | 1062 | /// What turning a `Target` back into a spelling can fail with — a tile's |
| 1063 | /// label bar and the layout sidecar's leaf key. `NoSpelling` is not a | 1063 | /// label bar and the layout sidecar's leaf key. `NoSpelling` is not a |
| 1064 | /// defect: the grammar shared by wall.zig and hosts.zig has no form for | 1064 | /// defect: the host grammar (hosts.zig) has no form for `--via`, so for |
| 1065 | /// `--via`, so for that transport there is nothing truthful to write down. | 1065 | /// that transport there is nothing truthful to write down. |
| 1066 | pub const SpellingError = error{ NoSpelling, NoSpace }; | 1066 | pub const SpellingError = error{ NoSpelling, NoSpace }; |
| 1067 | 1067 | ||
| 1068 | /// One session's tile as a user could have typed it: a single argv string | 1068 | /// One session's tile as a user could have typed it: a single argv string |
| @@ -1099,7 +1099,7 @@ pub fn spellingCap(target: Target) usize { | |||
| 1099 | 1099 | ||
| 1100 | // Whether a line RESTORED from the wall file may create the session it | 1100 | // Whether a line RESTORED from the wall file may create the session it |
| 1101 | // names. The browser hub's per-tile pump is the door that asks; the CLI | 1101 | // names. The browser hub's per-tile pump is the door that asks; the CLI |
| 1102 | // wall no longer restores sessions at all. It sits on `Target` rather than on `wall.Spec` because | 1102 | // wall no longer restores sessions at all. It sits on `Target` rather than on `hosts.Spec` because |
| 1103 | // that is what both doors still hold at the moment they decide — the | 1103 | // that is what both doors still hold at the moment they decide — the |
| 1104 | // spelling is resolved away long before. | 1104 | // spelling is resolved away long before. |
| 1105 | // | 1105 | // |
| @@ -2338,12 +2338,12 @@ test "client: a target spells itself back as one wall argument per session" { | |||
| 2338 | ); | 2338 | ); |
| 2339 | } | 2339 | } |
| 2340 | 2340 | ||
| 2341 | test "client: every spelling this writes, the wall grammar reads back the same" { | 2341 | test "client: every spelling this writes, the host grammar reads back the same" { |
| 2342 | // Writer/reader identity, pinned across the module boundary. A tile's | 2342 | // Writer/reader identity, pinned across the module boundary. A tile's |
| 2343 | // label and the sidecar leaf keyed by it are this string, and the | 2343 | // label and the sidecar leaf keyed by it are this string, and the |
| 2344 | // sidecar's restore parses it back — a drift in either half heals a | 2344 | // host half of it is what `hosts.parse` answers for — a drift in |
| 2345 | // saved layout onto the wrong tile. `refAllDecls` compiles both; only | 2345 | // either half heals a saved layout onto the wrong tile. |
| 2346 | // this executes both. | 2346 | // `refAllDecls` compiles both; only this executes both. |
| 2347 | var buf: [256]u8 = undefined; | 2347 | var buf: [256]u8 = undefined; |
| 2348 | const cases = .{ | 2348 | const cases = .{ |
| 2349 | .{ Target{ .sock = "/run/user/1000/muxd.sock" }, "0" }, | 2349 | .{ Target{ .sock = "/run/user/1000/muxd.sock" }, "0" }, |
| @@ -2357,12 +2357,15 @@ test "client: every spelling this writes, the wall grammar reads back the same" | |||
| 2357 | }; | 2357 | }; |
| 2358 | inline for (cases) |c| { | 2358 | inline for (cases) |c| { |
| 2359 | const spelling = try wallSpelling(&buf, c[0], c[1]); | 2359 | const spelling = try wallSpelling(&buf, c[0], c[1]); |
| 2360 | const p = try wall.parseSpelling(spelling); | 2360 | // `#NAME` is last and a session name may not hold a '#', so the |
| 2361 | try std.testing.expectEqualStrings(c[1], p.session); | 2361 | // split is the last one — the same rule the label bar reads by. |
| 2362 | const hash = std.mem.lastIndexOfScalar(u8, spelling, '#').?; | ||
| 2363 | try std.testing.expectEqualStrings(c[1], spelling[hash + 1 ..]); | ||
| 2364 | const spec = try hosts.parse(spelling[0..hash]); | ||
| 2362 | switch (c[0]) { | 2365 | switch (c[0]) { |
| 2363 | .sock => |path| try std.testing.expectEqualStrings(path, p.spec.sock), | 2366 | .sock => |path| try std.testing.expectEqualStrings(path, spec.sock), |
| 2364 | .hand => |h| try std.testing.expectEqualStrings(h.host, p.spec.host), | 2367 | .hand => |h| try std.testing.expectEqualStrings(h.host, spec.host), |
| 2365 | .quic => |q| try std.testing.expectEqualStrings(q.host_port, p.spec.quic), | 2368 | .quic => |q| try std.testing.expectEqualStrings(q.host_port, spec.quic), |
| 2366 | .via => unreachable, | 2369 | .via => unreachable, |
| 2367 | } | 2370 | } |
| 2368 | } | 2371 | } |
src/client/hosts.zig
| Old | New | ||
|---|---|---|---|
| @@ -4,20 +4,41 @@ | |||
| 4 | //! nothing here names a session and nothing here can resurrect one. | 4 | //! nothing here names a session and nothing here can resurrect one. |
| 5 | //! Strict on load: a host line is authored intent. | 5 | //! Strict on load: a host line is authored intent. |
| 6 | //! | 6 | //! |
| 7 | //! ONE grammar for every mouth — argv, the file, the picker's `a` — so a | ||
| 8 | //! user who learns a refusal in one reads it in the others, and one atomic | ||
| 9 | //! writer for every state file mux keeps. | ||
| 10 | //! | ||
| 7 | //! Every write is an unlocked read-modify-write over an atomic rename, so | 11 | //! Every write is an unlocked read-modify-write over an atomic rename, so |
| 8 | //! no reader tears — but two writers lose one update, and every attach now | 12 | //! no reader tears — but two writers lose one update, and every attach now |
| 9 | //! writes. What fails: an attach recording its daemon at the same moment as | 13 | //! writes. What fails: an attach recording its daemon at the same moment as |
| 10 | //! a `mux hosts add` elsewhere drops one of the two lines. | 14 | //! a `mux hosts add` elsewhere drops one of the two lines. "Atomic" is |
| 15 | //! writer-vs-writer only: nothing here fsyncs, so a crash can still leave | ||
| 16 | //! the rename torn. No crash-durability claim is made. | ||
| 11 | const std = @import("std"); | 17 | const std = @import("std"); |
| 12 | const wall = @import("wall"); | ||
| 13 | const xdg = @import("xdg"); | 18 | const xdg = @import("xdg"); |
| 14 | 19 | ||
| 15 | /// The wall grammar's, not a second copy of it: `hosts.parse` is the | 20 | /// What a host line names. `client.Target.fromSpec` dials it. |
| 16 | /// strict door onto the same three spellings `wall.parseSpelling` reads, | 21 | pub const Spec = union(enum) { |
| 17 | /// and one type is what lets `client.Target.fromSpec` answer both. | 22 | sock: []const u8, |
| 18 | pub const Spec = wall.Spec; | 23 | host: []const u8, |
| 24 | quic: []const u8, | ||
| 25 | }; | ||
| 26 | |||
| 19 | pub const ParseError = error{ HasSession, EmptySpec, BadByte, BadSpelling }; | 27 | pub const ParseError = error{ HasSession, EmptySpec, BadByte, BadSpelling }; |
| 20 | 28 | ||
| 29 | /// The bytes a HOST spelling may not hold. The word is ONE argv element — | ||
| 30 | /// `handoff.recipeFor` hands it to a `ssh` the client execs — and the wall | ||
| 31 | /// grammar is whitespace-separated, so a space here is a second host and | ||
| 32 | /// the rest are punctuation no resolver will ever answer. `[user@]host` | ||
| 33 | /// needs none of them; `--via` is where an arbitrary command is the | ||
| 34 | /// contract, and it is not spellable as a host. | ||
| 35 | const unspellable = " \t;&|`$()<>'\"\\*?{}[]!~"; | ||
| 36 | |||
| 37 | pub fn hasBadSpelling(word: []const u8) bool { | ||
| 38 | for (word) |b| if (std.mem.indexOfScalar(u8, unspellable, b) != null) return true; | ||
| 39 | return false; | ||
| 40 | } | ||
| 41 | |||
| 21 | pub fn parse(line: []const u8) ParseError!Spec { | 42 | pub fn parse(line: []const u8) ParseError!Spec { |
| 22 | for (line) |b| if (b < 0x20 or b == 0x7f) return error.BadByte; | 43 | for (line) |b| if (b < 0x20 or b == 0x7f) return error.BadByte; |
| 23 | if (std.mem.indexOfScalar(u8, line, '#') != null) return error.HasSession; | 44 | if (std.mem.indexOfScalar(u8, line, '#') != null) return error.HasSession; |
| @@ -30,12 +51,39 @@ pub fn parse(line: []const u8) ParseError!Spec { | |||
| 30 | return if (h.len == 0) error.EmptySpec else .{ .quic = h }; | 51 | return if (h.len == 0) error.EmptySpec else .{ .quic = h }; |
| 31 | } | 52 | } |
| 32 | if (line.len == 0) return error.EmptySpec; | 53 | if (line.len == 0) return error.EmptySpec; |
| 33 | // wall.zig owns the byte set: the hub's POST body reaches the same ssh | 54 | if (hasBadSpelling(line)) return error.BadSpelling; |
| 34 | // argv this file's poll does, so one refusal serves both mouths. | ||
| 35 | if (wall.hasBadSpelling(line)) return error.BadSpelling; | ||
| 36 | return .{ .host = line }; | 55 | return .{ .host = line }; |
| 37 | } | 56 | } |
| 38 | 57 | ||
| 58 | pub const ArgvError = error{ MissingSockPath, FlagLikeTarget } || std.mem.Allocator.Error; | ||
| 59 | |||
| 60 | /// Every mouth's rule: a host starting with a dash is a mistyped flag. | ||
| 61 | pub fn flagLike(target: []const u8) bool { | ||
| 62 | return target.len > 0 and target[0] == '-' and !std.mem.startsWith(u8, target, "--sock "); | ||
| 63 | } | ||
| 64 | |||
| 65 | /// Both `--sock` dialects reach one spelling: one parser, one line format. | ||
| 66 | pub fn spellingFromArgv( | ||
| 67 | alloc: std.mem.Allocator, | ||
| 68 | args: []const [:0]const u8, | ||
| 69 | i: usize, | ||
| 70 | ) ArgvError!struct { spelling: []u8, consumed: usize } { | ||
| 71 | if (std.mem.eql(u8, args[i], "--sock")) { | ||
| 72 | // A trailing `--sock` names no path: a usage mistake, reported as | ||
| 73 | // one rather than read off the end of argv. | ||
| 74 | if (i + 1 >= args.len) return error.MissingSockPath; | ||
| 75 | return .{ .spelling = try std.fmt.allocPrint(alloc, "--sock {s}", .{args[i + 1]}), .consumed = 2 }; | ||
| 76 | } | ||
| 77 | // A wall takes hosts, and no host starts with a dash: bare `--sock` | ||
| 78 | // took its path and returned above, and the one-piece `--sock PATH` | ||
| 79 | // dialect is `flagLike`'s exemption. Left to fall through, `mux hosts | ||
| 80 | // add -A box` became a host named `-A` that failed to resolve somewhere | ||
| 81 | // far from the typo — and a wall has no per-host agent flag to have | ||
| 82 | // meant, `-A` belonging to a single attach. | ||
| 83 | if (flagLike(args[i])) return error.FlagLikeTarget; | ||
| 84 | return .{ .spelling = try alloc.dupe(u8, args[i]), .consumed = 1 }; | ||
| 85 | } | ||
| 86 | |||
| 39 | /// Whether the grammar refused a line — the user's spelling to fix — as | 87 | /// Whether the grammar refused a line — the user's spelling to fix — as |
| 40 | /// opposed to the file not being readable at all, which is not. | 88 | /// opposed to the file not being readable at all, which is not. |
| 41 | pub fn isParse(err: anyerror) bool { | 89 | pub fn isParse(err: anyerror) bool { |
| @@ -96,7 +144,7 @@ pub fn load(alloc: std.mem.Allocator, path: []const u8) !Hosts { | |||
| 96 | } | 144 | } |
| 97 | 145 | ||
| 98 | pub fn save(h: *const Hosts, path: []const u8) !void { | 146 | pub fn save(h: *const Hosts, path: []const u8) !void { |
| 99 | return wall.saveLines(h.lines.items, path); | 147 | return saveLines(h.lines.items, path); |
| 100 | } | 148 | } |
| 101 | 149 | ||
| 102 | pub fn record(alloc: std.mem.Allocator, path: []const u8, spelling: []const u8) !bool { | 150 | pub fn record(alloc: std.mem.Allocator, path: []const u8, spelling: []const u8) !bool { |
| @@ -133,8 +181,8 @@ pub fn forgetMany( | |||
| 133 | // applied. EVERY copy, because `load` folds duplicate lines into one | 181 | // applied. EVERY copy, because `load` folds duplicate lines into one |
| 134 | // wall entry — stopping at the first match let a `rm` exit 0, print | 182 | // wall entry — stopping at the first match let a `rm` exit 0, print |
| 135 | // nothing, and leave the host still polled. | 183 | // nothing, and leave the host still polled. |
| 136 | var lines = try wall.loadLines(alloc, path); | 184 | var lines = try loadLines(alloc, path); |
| 137 | defer wall.freeLines(alloc, &lines); | 185 | defer freeLines(alloc, &lines); |
| 138 | var removed = false; | 186 | var removed = false; |
| 139 | var i: usize = 0; | 187 | var i: usize = 0; |
| 140 | while (i < lines.items.len) { | 188 | while (i < lines.items.len) { |
| @@ -154,7 +202,7 @@ pub fn forgetMany( | |||
| 154 | // A save that changed nothing is still a write another writer can lose | 202 | // A save that changed nothing is still a write another writer can lose |
| 155 | // an update to (see the header), so a `rm` that matched nothing does not | 203 | // an update to (see the header), so a `rm` that matched nothing does not |
| 156 | // make one. | 204 | // make one. |
| 157 | if (removed) try wall.saveLines(lines.items, path); | 205 | if (removed) try saveLines(lines.items, path); |
| 158 | } | 206 | } |
| 159 | 207 | ||
| 160 | pub fn statePath(alloc: std.mem.Allocator) ![]const u8 { | 208 | pub fn statePath(alloc: std.mem.Allocator) ![]const u8 { |
| @@ -165,6 +213,65 @@ pub fn statePathFrom(alloc: std.mem.Allocator, xdg_state_home: ?[]const u8, home | |||
| 165 | return xdg.pathFrom(alloc, xdg_state_home, home, ".local/state", "hosts"); | 213 | return xdg.pathFrom(alloc, xdg_state_home, home, ".local/state", "hosts"); |
| 166 | } | 214 | } |
| 167 | 215 | ||
| 216 | /// Beside the hosts file. What the sidecar HOLDS is `wall_layout`'s. | ||
| 217 | pub fn layoutPath(alloc: std.mem.Allocator) ![]const u8 { | ||
| 218 | return xdg.statePath(alloc, "layout"); | ||
| 219 | } | ||
| 220 | |||
| 221 | pub fn layoutPathFrom(alloc: std.mem.Allocator, xdg_state_home: ?[]const u8, home: ?[]const u8) ![]const u8 { | ||
| 222 | return xdg.pathFrom(alloc, xdg_state_home, home, ".local/state", "layout"); | ||
| 223 | } | ||
| 224 | |||
| 225 | /// One atomic-write idiom in this module: join then `saveBytes`. | ||
| 226 | pub fn saveLines(lines: []const []const u8, path: []const u8) !void { | ||
| 227 | const ga = std.heap.page_allocator; | ||
| 228 | var joined: std.ArrayList(u8) = .empty; | ||
| 229 | defer joined.deinit(ga); | ||
| 230 | for (lines) |t| { | ||
| 231 | try joined.appendSlice(ga, t); | ||
| 232 | try joined.append(ga, '\n'); | ||
| 233 | } | ||
| 234 | try saveBytes(path, joined.items); | ||
| 235 | } | ||
| 236 | |||
| 237 | /// Every line of the file, verbatim, with NO grammar applied. | ||
| 238 | /// | ||
| 239 | /// `load` refuses a line that no longer parses: silently dropping a host | ||
| 240 | /// the user wrote down is worse. But that left one hand-edited line | ||
| 241 | /// unrepairable by the command whose whole job is removing a line, so | ||
| 242 | /// removal reads with this instead. | ||
| 243 | /// | ||
| 244 | /// NOT for `record` or `Hosts.add`: growing a file whose content is not | ||
| 245 | /// understood builds on garbage and re-saves it as if it had been read. | ||
| 246 | pub fn loadLines(alloc: std.mem.Allocator, path: []const u8) !std.ArrayList([]u8) { | ||
| 247 | var lines: std.ArrayList([]u8) = .empty; | ||
| 248 | errdefer freeLines(alloc, &lines); | ||
| 249 | const data = std.fs.cwd().readFileAlloc(alloc, path, 1024 * 1024) catch |err| switch (err) { | ||
| 250 | error.FileNotFound => return lines, | ||
| 251 | else => return err, | ||
| 252 | }; | ||
| 253 | defer alloc.free(data); | ||
| 254 | var it = std.mem.tokenizeScalar(u8, data, '\n'); | ||
| 255 | while (it.next()) |line| try lines.append(alloc, try alloc.dupe(u8, line)); | ||
| 256 | return lines; | ||
| 257 | } | ||
| 258 | |||
| 259 | pub fn freeLines(alloc: std.mem.Allocator, lines: *std.ArrayList([]u8)) void { | ||
| 260 | for (lines.items) |l| alloc.free(l); | ||
| 261 | lines.deinit(alloc); | ||
| 262 | } | ||
| 263 | |||
| 264 | /// One atomic writer for every state file mux keeps: the hosts file here | ||
| 265 | /// and the layout sidecar in `wall_layout`, so there is one temp+rename | ||
| 266 | /// idiom and not two. | ||
| 267 | pub fn saveBytes(path: []const u8, bytes: []const u8) !void { | ||
| 268 | var write_buf: [4096]u8 = undefined; | ||
| 269 | var af = try std.fs.cwd().atomicFile(path, .{ .make_path = true, .write_buffer = &write_buf }); | ||
| 270 | defer af.deinit(); | ||
| 271 | try af.file_writer.interface.writeAll(bytes); | ||
| 272 | try af.finish(); | ||
| 273 | } | ||
| 274 | |||
| 168 | /// cliflags hooks for `mux hosts add|rm SPELLING...`, each word validated | 275 | /// cliflags hooks for `mux hosts add|rm SPELLING...`, each word validated |
| 169 | /// here at usage altitude rather than downstream as a host that will not dial. | 276 | /// here at usage altitude rather than downstream as a host that will not dial. |
| 170 | pub const Argv = struct { | 277 | pub const Argv = struct { |
| @@ -172,7 +279,7 @@ pub const Argv = struct { | |||
| 172 | list: std.ArrayList([]const u8) = .empty, | 279 | list: std.ArrayList([]const u8) = .empty, |
| 173 | /// A hook answers yes or no, so one that refused for a REASON leaves | 280 | /// A hook answers yes or no, so one that refused for a REASON leaves |
| 174 | /// the word and the why for the caller's message. | 281 | /// the word and the why for the caller's message. |
| 175 | err: ?struct { word: []const u8, err: (wall.ArgvError || ParseError) } = null, | 282 | err: ?struct { word: []const u8, err: (ArgvError || ParseError) } = null, |
| 176 | 283 | ||
| 177 | pub fn deinit(self: *Argv) void { | 284 | pub fn deinit(self: *Argv) void { |
| 178 | for (self.list.items) |t| self.alloc.free(t); | 285 | for (self.list.items) |t| self.alloc.free(t); |
| @@ -182,7 +289,7 @@ pub const Argv = struct { | |||
| 182 | return self.take(word); | 289 | return self.take(word); |
| 183 | } | 290 | } |
| 184 | pub fn extra(self: *Argv, rest: []const [:0]const u8) usize { | 291 | pub fn extra(self: *Argv, rest: []const [:0]const u8) usize { |
| 185 | const n = wall.spellingFromArgv(self.alloc, rest, 0) catch |e| { | 292 | const n = spellingFromArgv(self.alloc, rest, 0) catch |e| { |
| 186 | if (e != error.FlagLikeTarget) _ = self.refuse(rest[0], e); | 293 | if (e != error.FlagLikeTarget) _ = self.refuse(rest[0], e); |
| 187 | return 0; | 294 | return 0; |
| 188 | }; | 295 | }; |
| @@ -201,7 +308,7 @@ pub const Argv = struct { | |||
| 201 | _ = parse(copy) catch |e| return self.refuse(copy, e); | 308 | _ = parse(copy) catch |e| return self.refuse(copy, e); |
| 202 | return true; | 309 | return true; |
| 203 | } | 310 | } |
| 204 | fn refuse(self: *Argv, word: []const u8, e: (wall.ArgvError || ParseError)) bool { | 311 | fn refuse(self: *Argv, word: []const u8, e: (ArgvError || ParseError)) bool { |
| 205 | self.err = .{ .word = word, .err = e }; | 312 | self.err = .{ .word = word, .err = e }; |
| 206 | return false; | 313 | return false; |
| 207 | } | 314 | } |
| @@ -289,7 +396,7 @@ test "hosts: add dedups, load/save round-trip two hosts in order, and a doubled | |||
| 289 | // A hand-edited file that lists a daemon twice loads as one host. The | 396 | // A hand-edited file that lists a daemon twice loads as one host. The |
| 290 | // wall would otherwise poll it twice and show every session of it | 397 | // wall would otherwise poll it twice and show every session of it |
| 291 | // twice, and `add` would then report a line it did not write. | 398 | // twice, and `add` would then report a line it did not write. |
| 292 | try wall.saveBytes(path, "box\n--sock /tmp/a.sock\nbox\n"); | 399 | try saveBytes(path, "box\n--sock /tmp/a.sock\nbox\n"); |
| 293 | var doubled = try load(alloc, path); | 400 | var doubled = try load(alloc, path); |
| 294 | defer doubled.deinit(alloc); | 401 | defer doubled.deinit(alloc); |
| 295 | try std.testing.expectEqual(@as(usize, 2), doubled.lines.items.len); | 402 | try std.testing.expectEqual(@as(usize, 2), doubled.lines.items.len); |
| @@ -303,7 +410,7 @@ test "hosts.load is strict: a session line in the file is an error, not a skippe | |||
| 303 | defer tmp.cleanup(); | 410 | defer tmp.cleanup(); |
| 304 | const path = try std.fmt.allocPrint(alloc, "{s}/hosts", .{tmp.path()}); | 411 | const path = try std.fmt.allocPrint(alloc, "{s}/hosts", .{tmp.path()}); |
| 305 | defer alloc.free(path); | 412 | defer alloc.free(path); |
| 306 | try wall.saveBytes(path, "box\nbox#old\n"); | 413 | try saveBytes(path, "box\nbox#old\n"); |
| 307 | try std.testing.expectError(error.HasSession, load(alloc, path)); | 414 | try std.testing.expectError(error.HasSession, load(alloc, path)); |
| 308 | } | 415 | } |
| 309 | 416 | ||
| @@ -313,7 +420,7 @@ test "hosts.forget removes a line strict load refuses; record still will not gro | |||
| 313 | defer tmp.cleanup(); | 420 | defer tmp.cleanup(); |
| 314 | const path = try std.fmt.allocPrint(alloc, "{s}/hosts", .{tmp.path()}); | 421 | const path = try std.fmt.allocPrint(alloc, "{s}/hosts", .{tmp.path()}); |
| 315 | defer alloc.free(path); | 422 | defer alloc.free(path); |
| 316 | try wall.saveBytes(path, "box\nbox#old\n"); | 423 | try saveBytes(path, "box\nbox#old\n"); |
| 317 | 424 | ||
| 318 | // Growing a file whose content is not understood re-saves the garbage | 425 | // Growing a file whose content is not understood re-saves the garbage |
| 319 | // as if it had been read, so record is refused while the bad line sits. | 426 | // as if it had been read, so record is refused while the bad line sits. |
| @@ -336,11 +443,11 @@ test "hosts.forget removes EVERY copy, so a rm cannot report success and change | |||
| 336 | defer tmp.cleanup(); | 443 | defer tmp.cleanup(); |
| 337 | const path = try std.fmt.allocPrint(alloc, "{s}/hosts", .{tmp.path()}); | 444 | const path = try std.fmt.allocPrint(alloc, "{s}/hosts", .{tmp.path()}); |
| 338 | defer alloc.free(path); | 445 | defer alloc.free(path); |
| 339 | try wall.saveBytes(path, "box\nkeep\nbox\n"); | 446 | try saveBytes(path, "box\nkeep\nbox\n"); |
| 340 | 447 | ||
| 341 | try std.testing.expect(try forget(alloc, path, "box")); | 448 | try std.testing.expect(try forget(alloc, path, "box")); |
| 342 | var lines = try wall.loadLines(alloc, path); | 449 | var lines = try loadLines(alloc, path); |
| 343 | defer wall.freeLines(alloc, &lines); | 450 | defer freeLines(alloc, &lines); |
| 344 | try std.testing.expectEqual(@as(usize, 1), lines.items.len); | 451 | try std.testing.expectEqual(@as(usize, 1), lines.items.len); |
| 345 | try std.testing.expectEqualStrings("keep", lines.items[0]); | 452 | try std.testing.expectEqualStrings("keep", lines.items[0]); |
| 346 | try std.testing.expect(!try forget(alloc, path, "box")); | 453 | try std.testing.expect(!try forget(alloc, path, "box")); |
| @@ -360,14 +467,14 @@ test "hosts.forgetMany: several names leave in ONE read-modify-write, and the su | |||
| 360 | defer tmp.cleanup(); | 467 | defer tmp.cleanup(); |
| 361 | const path = try std.fmt.allocPrint(alloc, "{s}/hosts", .{tmp.path()}); | 468 | const path = try std.fmt.allocPrint(alloc, "{s}/hosts", .{tmp.path()}); |
| 362 | defer alloc.free(path); | 469 | defer alloc.free(path); |
| 363 | try wall.saveBytes(path, "a\nb\nkeep\na\nlast\n"); | 470 | try saveBytes(path, "a\nb\nkeep\na\nlast\n"); |
| 364 | 471 | ||
| 365 | var gone = [_]bool{ false, false, false }; | 472 | var gone = [_]bool{ false, false, false }; |
| 366 | try forgetMany(alloc, path, &.{ "a", "absent", "b" }, &gone); | 473 | try forgetMany(alloc, path, &.{ "a", "absent", "b" }, &gone); |
| 367 | try std.testing.expectEqualSlices(bool, &.{ true, false, true }, &gone); | 474 | try std.testing.expectEqualSlices(bool, &.{ true, false, true }, &gone); |
| 368 | 475 | ||
| 369 | var lines = try wall.loadLines(alloc, path); | 476 | var lines = try loadLines(alloc, path); |
| 370 | defer wall.freeLines(alloc, &lines); | 477 | defer freeLines(alloc, &lines); |
| 371 | try std.testing.expectEqual(@as(usize, 2), lines.items.len); | 478 | try std.testing.expectEqual(@as(usize, 2), lines.items.len); |
| 372 | try std.testing.expectEqualStrings("keep", lines.items[0]); | 479 | try std.testing.expectEqualStrings("keep", lines.items[0]); |
| 373 | try std.testing.expectEqualStrings("last", lines.items[1]); | 480 | try std.testing.expectEqualStrings("last", lines.items[1]); |
| @@ -405,3 +512,82 @@ test "hosts.Argv: a taken word is validated, a refusal names it, a flag leaves n | |||
| 405 | try std.testing.expect(a.err.?.err == error.HasSession); | 512 | try std.testing.expect(a.err.?.err == error.HasSession); |
| 406 | try std.testing.expectEqualStrings("box#x", a.err.?.word); | 513 | try std.testing.expectEqualStrings("box#x", a.err.?.word); |
| 407 | } | 514 | } |
| 515 | |||
| 516 | test "spellingFromArgv: both --sock dialects reach the same spelling" { | ||
| 517 | const alloc = std.testing.allocator; | ||
| 518 | const argv = [_][:0]const u8{ "--sock", "/tmp/x.sock", "--sock /tmp/x.sock", "box", "quic://h:4433" }; | ||
| 519 | |||
| 520 | // Two arguments joined, and one argument passed through: same string, | ||
| 521 | // which is the point — the file only ever holds this one. | ||
| 522 | const joined = try spellingFromArgv(alloc, &argv, 0); | ||
| 523 | defer alloc.free(joined.spelling); | ||
| 524 | try std.testing.expectEqualStrings("--sock /tmp/x.sock", joined.spelling); | ||
| 525 | try std.testing.expectEqual(@as(usize, 2), joined.consumed); | ||
| 526 | |||
| 527 | const whole = try spellingFromArgv(alloc, &argv, 2); | ||
| 528 | defer alloc.free(whole.spelling); | ||
| 529 | try std.testing.expectEqualStrings("--sock /tmp/x.sock", whole.spelling); | ||
| 530 | try std.testing.expectEqual(@as(usize, 1), whole.consumed); | ||
| 531 | try std.testing.expectEqualStrings("/tmp/x.sock", (try parse(whole.spelling)).sock); | ||
| 532 | |||
| 533 | // Host and quic spellings are already whole; nothing is consumed after. | ||
| 534 | const host = try spellingFromArgv(alloc, &argv, 3); | ||
| 535 | defer alloc.free(host.spelling); | ||
| 536 | try std.testing.expectEqualStrings("box", host.spelling); | ||
| 537 | try std.testing.expectEqual(@as(usize, 1), host.consumed); | ||
| 538 | const q = try spellingFromArgv(alloc, &argv, 4); | ||
| 539 | defer alloc.free(q.spelling); | ||
| 540 | try std.testing.expectEqualStrings("quic://h:4433", q.spelling); | ||
| 541 | try std.testing.expectEqual(@as(usize, 1), q.consumed); | ||
| 542 | } | ||
| 543 | |||
| 544 | test "spellingFromArgv: a trailing --sock is a usage error, not a read off the end" { | ||
| 545 | const argv = [_][:0]const u8{ "box", "--sock" }; | ||
| 546 | try std.testing.expectError(error.MissingSockPath, spellingFromArgv(std.testing.allocator, &argv, 1)); | ||
| 547 | } | ||
| 548 | |||
| 549 | test "spellingFromArgv: a flag is not a host" { | ||
| 550 | // `mux hosts add -A box` used to make a host named `-A`, which then | ||
| 551 | // failed to resolve somewhere far from the typo. A wall has no per-host | ||
| 552 | // agent flag at all — `-A` belongs to a single attach — so every | ||
| 553 | // flag-shaped argument here is the same mistake. | ||
| 554 | const argv = [_][:0]const u8{ "-A", "box", "--sock /tmp/x", "quic://h:1" }; | ||
| 555 | try std.testing.expectError(error.FlagLikeTarget, spellingFromArgv(std.testing.allocator, &argv, 0)); | ||
| 556 | |||
| 557 | // The one spelling that legitimately starts with a dash still passes. | ||
| 558 | const whole = try spellingFromArgv(std.testing.allocator, &argv, 2); | ||
| 559 | defer std.testing.allocator.free(whole.spelling); | ||
| 560 | try std.testing.expectEqualStrings("--sock /tmp/x", whole.spelling); | ||
| 561 | } | ||
| 562 | |||
| 563 | test "saveLines: a hosts file bigger than any stack buffer still round-trips" { | ||
| 564 | const alloc = std.testing.allocator; | ||
| 565 | var tmp = try TmpDir.make(); | ||
| 566 | defer tmp.cleanup(); | ||
| 567 | const path = try std.fmt.allocPrint(alloc, "{s}/bighosts", .{tmp.path()}); | ||
| 568 | defer alloc.free(path); | ||
| 569 | |||
| 570 | // 200 lines of ~100 bytes each = ~20 KiB, well past an 8 KiB stack buffer. | ||
| 571 | var lines: std.ArrayList([]const u8) = .empty; | ||
| 572 | defer { | ||
| 573 | for (lines.items) |l| alloc.free(l); | ||
| 574 | lines.deinit(alloc); | ||
| 575 | } | ||
| 576 | for (0..200) |i| { | ||
| 577 | const line = try std.fmt.allocPrint(alloc, "host-{d:0>3}.example.com-{d:0>3}", .{ i, i }); | ||
| 578 | try lines.append(alloc, line); | ||
| 579 | } | ||
| 580 | try saveLines(lines.items, path); | ||
| 581 | |||
| 582 | var back = try loadLines(alloc, path); | ||
| 583 | defer freeLines(alloc, &back); | ||
| 584 | try std.testing.expectEqual(@as(usize, 200), back.items.len); | ||
| 585 | try std.testing.expectEqualStrings("host-199.example.com-199", back.items[199]); | ||
| 586 | } | ||
| 587 | |||
| 588 | // Forces semantic analysis of every pub decl under `zig build test`, so an | ||
| 589 | // unreferenced decl must at least compile (the silent-module-loss hazard, | ||
| 590 | // decisions.md). | ||
| 591 | test { | ||
| 592 | std.testing.refAllDeclsRecursive(@This()); | ||
| 593 | } | ||
src/client/wall.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,47 +1,18 @@ | |||
| 1 | //! The wall: an ordered list of TARGET spellings — attach history. mux | 1 | //! The browser hub's private wall file, being retired: an ordered list of |
| 2 | //! records a grid-claiming attach on its first state; the hub records its | 2 | //! TARGET spellings, one per line of `$XDG_STATE_HOME/mux/wall`. The |
| 3 | //! wall mutations (argv tiles, POST /tiles), never an attach. One owner | 3 | //! grammar, the atomic writer and the argv collector all live in |
| 4 | //! for the spelling grammar, the session split, and the persisted file, | 4 | //! `hosts.zig` now; what is left here is the `#SESSION` half the hub has |
| 5 | //! so the wall built in a browser is the wall the CLI sees. | 5 | //! not yet stopped reading. |
| 6 | //! | ||
| 7 | //! Spelling grammar (one string; also the line format of the state file | ||
| 8 | //! and the body of the hub's POST /tiles): | ||
| 9 | //! HOST[#SESSION] | quic://HOST[:PORT][#SESSION] | --sock PATH[#SESSION] | ||
| 10 | //! The session splits at the LAST '#' because validSessionName refuses | ||
| 11 | //! '#', so any earlier one belongs to the target's own spelling. | ||
| 12 | //! | ||
| 13 | //! The file is `$XDG_STATE_HOME/mux/wall`, one spelling per line, in wall | ||
| 14 | //! order. Every mutation rewrites it atomically (temp + rename); two | ||
| 15 | //! concurrent writers resolve as last-rename-wins, acceptable for a single | ||
| 16 | //! user's state file. "Atomic" is writer-vs-writer only: `save` fsyncs | ||
| 17 | //! nothing, so a crash can still leave the rename torn. No crash-durability | ||
| 18 | //! claim is made here. | ||
| 19 | const std = @import("std"); | 6 | const std = @import("std"); |
| 20 | const proto = @import("protocol"); | 7 | const proto = @import("protocol"); |
| 8 | const hosts = @import("hosts"); | ||
| 21 | const xdg = @import("xdg"); | 9 | const xdg = @import("xdg"); |
| 22 | 10 | ||
| 23 | pub const Spec = union(enum) { | 11 | pub const Spec = hosts.Spec; |
| 24 | sock: []const u8, | ||
| 25 | host: []const u8, | ||
| 26 | quic: []const u8, | ||
| 27 | }; | ||
| 28 | 12 | ||
| 29 | pub const Parsed = struct { spec: Spec, session: []const u8 }; | 13 | pub const Parsed = struct { spec: Spec, session: []const u8 }; |
| 30 | pub const ParseError = error{ BadSession, EmptySpec, BadByte, BadSpelling }; | 14 | pub const ParseError = error{ BadSession, EmptySpec, BadByte, BadSpelling }; |
| 31 | 15 | ||
| 32 | /// The bytes a HOST spelling may not hold. The word is ONE argv element — | ||
| 33 | /// `handoff.recipeFor` hands it to a `ssh` the client execs — and the wall | ||
| 34 | /// grammar is whitespace-separated, so a space here is a second tile and | ||
| 35 | /// the rest are punctuation no resolver will ever answer. `[user@]host` | ||
| 36 | /// needs none of them; `--via` is where an arbitrary command is the | ||
| 37 | /// contract, and it is not spellable as a target. | ||
| 38 | const unspellable = " \t;&|`$()<>'\"\\*?{}[]!~"; | ||
| 39 | |||
| 40 | pub fn hasBadSpelling(word: []const u8) bool { | ||
| 41 | for (word) |b| if (std.mem.indexOfScalar(u8, unspellable, b) != null) return true; | ||
| 42 | return false; | ||
| 43 | } | ||
| 44 | |||
| 45 | /// Splits and classifies one spelling. Refuses here, at usage altitude, | 16 | /// Splits and classifies one spelling. Refuses here, at usage altitude, |
| 46 | /// what would otherwise surface as a rejected attach far from the typo: | 17 | /// what would otherwise surface as a rejected attach far from the typo: |
| 47 | /// a malformed session name, or a spelling whose target part is empty | 18 | /// a malformed session name, or a spelling whose target part is empty |
| @@ -79,54 +50,17 @@ pub fn parseSpelling(line: []const u8) ParseError!Parsed { | |||
| 79 | return .{ .spec = .{ .quic = hp }, .session = session }; | 50 | return .{ .spec = .{ .quic = hp }, .session = session }; |
| 80 | } | 51 | } |
| 81 | if (spec_str.len == 0) return error.EmptySpec; | 52 | if (spec_str.len == 0) return error.EmptySpec; |
| 82 | if (hasBadSpelling(spec_str)) return error.BadSpelling; | 53 | if (hosts.hasBadSpelling(spec_str)) return error.BadSpelling; |
| 83 | return .{ .spec = .{ .host = spec_str }, .session = session }; | 54 | return .{ .spec = .{ .host = spec_str }, .session = session }; |
| 84 | } | 55 | } |
| 85 | 56 | ||
| 86 | pub const ArgvError = error{ MissingSockPath, FlagLikeTarget } || std.mem.Allocator.Error; | 57 | /// The one refusal this grammar has that the host grammar does not: a |
| 87 | 58 | /// `#SESSION` it will not take. Everything else is `hosts.reason`'s, so | |
| 88 | /// Every mouth's rule: a target starting with a dash is a mistyped flag. | 59 | /// the two mouths refuse in the same words. |
| 89 | pub fn flagLike(target: []const u8) bool { | ||
| 90 | return target.len > 0 and target[0] == '-' and !std.mem.startsWith(u8, target, "--sock "); | ||
| 91 | } | ||
| 92 | |||
| 93 | /// Both binaries accept both `--sock` dialects: one parser, one spelling. | ||
| 94 | pub fn spellingFromArgv( | ||
| 95 | alloc: std.mem.Allocator, | ||
| 96 | args: []const [:0]const u8, | ||
| 97 | i: usize, | ||
| 98 | ) ArgvError!struct { spelling: []u8, consumed: usize } { | ||
| 99 | if (std.mem.eql(u8, args[i], "--sock")) { | ||
| 100 | // A trailing `--sock` names no path: a usage mistake, reported as | ||
| 101 | // one rather than read off the end of argv. | ||
| 102 | if (i + 1 >= args.len) return error.MissingSockPath; | ||
| 103 | return .{ .spelling = try std.fmt.allocPrint(alloc, "--sock {s}", .{args[i + 1]}), .consumed = 2 }; | ||
| 104 | } | ||
| 105 | // A wall takes targets, and no target starts with a dash: bare `--sock` | ||
| 106 | // took its path and returned above, and the one-piece `--sock PATH` | ||
| 107 | // dialect is `flagLike`'s exemption. Left to fall through, `mux wall | ||
| 108 | // -A host` became a tile for a host named `-A` and failed to resolve | ||
| 109 | // somewhere far from the typo — and a wall has no per-tile agent flag | ||
| 110 | // to have meant, `-A` belonging to a single attach. Shared with the | ||
| 111 | // prompt, which is this argv typed from inside a running wall. | ||
| 112 | if (flagLike(args[i])) return error.FlagLikeTarget; | ||
| 113 | return .{ .spelling = try alloc.dupe(u8, args[i]), .consumed = 1 }; | ||
| 114 | } | ||
| 115 | |||
| 116 | /// Why one spelling cannot be a tile, in ONE vocabulary: argv, the state | ||
| 117 | /// file and the hub's POST body are refused in the same words, so a user | ||
| 118 | /// who learns the message in one mouth reads it in the others. Errors | ||
| 119 | /// from outside this grammar (the resolver's) keep an arm here for the | ||
| 120 | /// same reason: the caller printing it does not care which layer said no. | ||
| 121 | pub fn reason(err: anyerror) []const u8 { | 60 | pub fn reason(err: anyerror) []const u8 { |
| 122 | return switch (err) { | 61 | return switch (err) { |
| 123 | error.BadSession => "bad session name after '#' (printable ASCII, no space, no '/')", | 62 | error.BadSession => "bad session name after '#' (printable ASCII, no space, no '/')", |
| 124 | error.EmptySpec => "empty target", | 63 | else => hosts.reason(err), |
| 125 | error.BadByte => "control byte in target", | ||
| 126 | error.BadSpelling => "punctuation in host: a tile spelling names a machine, not a command", | ||
| 127 | error.MissingSockPath => "names no path", | ||
| 128 | error.SockPathTooLong => "socket path too long to bind", | ||
| 129 | else => @errorName(err), | ||
| 130 | }; | 64 | }; |
| 131 | } | 65 | } |
| 132 | 66 | ||
| @@ -141,7 +75,7 @@ pub const Argv = struct { | |||
| 141 | /// A hook answers yes or no, so one that refused for a REASON has | 75 | /// A hook answers yes or no, so one that refused for a REASON has |
| 142 | /// nowhere to say so: it leaves the word and the why for the caller, | 76 | /// nowhere to say so: it leaves the word and the why for the caller, |
| 143 | /// whose message can then name WHICH tile of several was refused. | 77 | /// whose message can then name WHICH tile of several was refused. |
| 144 | err: ?struct { word: []const u8, err: (ArgvError || ParseError) } = null, | 78 | err: ?struct { word: []const u8, err: (hosts.ArgvError || ParseError) } = null, |
| 145 | 79 | ||
| 146 | pub fn deinit(self: *Argv) void { | 80 | pub fn deinit(self: *Argv) void { |
| 147 | for (self.tiles.items) |t| self.alloc.free(t); | 81 | for (self.tiles.items) |t| self.alloc.free(t); |
| @@ -158,7 +92,7 @@ pub const Argv = struct { | |||
| 158 | /// target this program rejects but one it never saw, left for cliflags | 92 | /// target this program rejects but one it never saw, left for cliflags |
| 159 | /// to name as the unknown flag it is. | 93 | /// to name as the unknown flag it is. |
| 160 | pub fn extra(self: *Argv, rest: []const [:0]const u8) usize { | 94 | pub fn extra(self: *Argv, rest: []const [:0]const u8) usize { |
| 161 | const n = spellingFromArgv(self.alloc, rest, 0) catch |e| { | 95 | const n = hosts.spellingFromArgv(self.alloc, rest, 0) catch |e| { |
| 162 | if (e != error.FlagLikeTarget) _ = self.refuse(rest[0], e); | 96 | if (e != error.FlagLikeTarget) _ = self.refuse(rest[0], e); |
| 163 | return 0; | 97 | return 0; |
| 164 | }; | 98 | }; |
| @@ -179,7 +113,7 @@ pub const Argv = struct { | |||
| 179 | return true; | 113 | return true; |
| 180 | } | 114 | } |
| 181 | 115 | ||
| 182 | fn refuse(self: *Argv, word: []const u8, e: (ArgvError || ParseError)) bool { | 116 | fn refuse(self: *Argv, word: []const u8, e: (hosts.ArgvError || ParseError)) bool { |
| 183 | self.err = .{ .word = word, .err = e }; | 117 | self.err = .{ .word = word, .err = e }; |
| 184 | return false; | 118 | return false; |
| 185 | } | 119 | } |
| @@ -246,49 +180,7 @@ pub fn load(alloc: std.mem.Allocator, path: []const u8) !Wall { | |||
| 246 | } | 180 | } |
| 247 | 181 | ||
| 248 | pub fn save(w: *const Wall, path: []const u8) !void { | 182 | pub fn save(w: *const Wall, path: []const u8) !void { |
| 249 | return saveLines(w.targets.items, path); | 183 | return hosts.saveLines(w.targets.items, path); |
| 250 | } | ||
| 251 | |||
| 252 | /// One atomic-write idiom in this module: join then `saveBytes`. | ||
| 253 | pub fn saveLines(lines: []const []const u8, path: []const u8) !void { | ||
| 254 | const ga = std.heap.page_allocator; | ||
| 255 | var joined: std.ArrayList(u8) = .empty; | ||
| 256 | defer joined.deinit(ga); | ||
| 257 | for (lines) |t| { | ||
| 258 | try joined.appendSlice(ga, t); | ||
| 259 | try joined.append(ga, '\n'); | ||
| 260 | } | ||
| 261 | try saveBytes(path, joined.items); | ||
| 262 | } | ||
| 263 | |||
| 264 | /// Every line of the wall file, verbatim, with NO grammar applied. | ||
| 265 | /// | ||
| 266 | /// `load` refuses a line that no longer parses: silently dropping a tile | ||
| 267 | /// the user wrote down is worse. But that left one hand-edited line | ||
| 268 | /// unrepairable by the command whose whole job is removing a line, so | ||
| 269 | /// removal reads with this instead. | ||
| 270 | /// | ||
| 271 | /// NOT for `record` or `Wall.add`: growing a file whose content is not | ||
| 272 | /// understood builds on garbage and re-saves it as if it had been read. | ||
| 273 | pub fn loadLines(alloc: std.mem.Allocator, path: []const u8) !std.ArrayList([]u8) { | ||
| 274 | var lines: std.ArrayList([]u8) = .empty; | ||
| 275 | errdefer { | ||
| 276 | for (lines.items) |l| alloc.free(l); | ||
| 277 | lines.deinit(alloc); | ||
| 278 | } | ||
| 279 | const data = std.fs.cwd().readFileAlloc(alloc, path, 1024 * 1024) catch |err| switch (err) { | ||
| 280 | error.FileNotFound => return lines, | ||
| 281 | else => return err, | ||
| 282 | }; | ||
| 283 | defer alloc.free(data); | ||
| 284 | var it = std.mem.tokenizeScalar(u8, data, '\n'); | ||
| 285 | while (it.next()) |line| try lines.append(alloc, try alloc.dupe(u8, line)); | ||
| 286 | return lines; | ||
| 287 | } | ||
| 288 | |||
| 289 | pub fn freeLines(alloc: std.mem.Allocator, lines: *std.ArrayList([]u8)) void { | ||
| 290 | for (lines.items) |l| alloc.free(l); | ||
| 291 | lines.deinit(alloc); | ||
| 292 | } | 184 | } |
| 293 | 185 | ||
| 294 | /// Dedup is on the SPELLING, never on session identity: the same session | 186 | /// Dedup is on the SPELLING, never on session identity: the same session |
| @@ -303,40 +195,10 @@ pub fn record(alloc: std.mem.Allocator, path: []const u8, spelling: []const u8) | |||
| 303 | return true; | 195 | return true; |
| 304 | } | 196 | } |
| 305 | 197 | ||
| 306 | /// The *From split is xdg.zig's pattern: setenv is unsafe in Zig tests. | ||
| 307 | pub fn statePath(alloc: std.mem.Allocator) ![]const u8 { | 198 | pub fn statePath(alloc: std.mem.Allocator) ![]const u8 { |
| 308 | return xdg.statePath(alloc, "wall"); | 199 | return xdg.statePath(alloc, "wall"); |
| 309 | } | 200 | } |
| 310 | 201 | ||
| 311 | pub fn statePathFrom(alloc: std.mem.Allocator, xdg_state_home: ?[]const u8, home: ?[]const u8) ![]const u8 { | ||
| 312 | return xdg.pathFrom(alloc, xdg_state_home, home, ".local/state", "wall"); | ||
| 313 | } | ||
| 314 | |||
| 315 | /// The layout sidecar sits beside the wall file; same *From split. | ||
| 316 | pub fn layoutPath(alloc: std.mem.Allocator) ![]const u8 { | ||
| 317 | return xdg.statePath(alloc, "layout"); | ||
| 318 | } | ||
| 319 | |||
| 320 | pub fn layoutPathFrom(alloc: std.mem.Allocator, xdg_state_home: ?[]const u8, home: ?[]const u8) ![]const u8 { | ||
| 321 | return xdg.pathFrom(alloc, xdg_state_home, home, ".local/state", "layout"); | ||
| 322 | } | ||
| 323 | |||
| 324 | /// One atomic writer for state files: `saveLines` and the layout sidecar | ||
| 325 | /// both go through it, so there is one temp+rename idiom and not two. | ||
| 326 | pub fn saveBytes(path: []const u8, bytes: []const u8) !void { | ||
| 327 | var write_buf: [4096]u8 = undefined; | ||
| 328 | var af = try std.fs.cwd().atomicFile(path, .{ .make_path = true, .write_buffer = &write_buf }); | ||
| 329 | defer af.deinit(); | ||
| 330 | try af.file_writer.interface.writeAll(bytes); | ||
| 331 | try af.finish(); | ||
| 332 | } | ||
| 333 | |||
| 334 | /// Every failure is the same null: a caller degrades identically regardless | ||
| 335 | /// of why the layout was absent. | ||
| 336 | pub fn loadLayout(alloc: std.mem.Allocator, path: []const u8) ?[]u8 { | ||
| 337 | return std.fs.cwd().readFileAlloc(alloc, path, 1024 * 1024) catch null; | ||
| 338 | } | ||
| 339 | |||
| 340 | test "Argv: bare words and both --sock dialects all become owned spellings" { | 202 | test "Argv: bare words and both --sock dialects all become owned spellings" { |
| 341 | var a = Argv{ .alloc = std.testing.allocator }; | 203 | var a = Argv{ .alloc = std.testing.allocator }; |
| 342 | defer a.deinit(); | 204 | defer a.deinit(); |
| @@ -384,42 +246,6 @@ test "Argv: a refusal records WHICH word and why; a flag-shaped word records not | |||
| 384 | try std.testing.expectEqual(@as(usize, 0), d.tiles.items.len); | 246 | try std.testing.expectEqual(@as(usize, 0), d.tiles.items.len); |
| 385 | } | 247 | } |
| 386 | 248 | ||
| 387 | test "spellingFromArgv: both --sock dialects reach the same spelling" { | ||
| 388 | const alloc = std.testing.allocator; | ||
| 389 | const argv = [_][:0]const u8{ "--sock", "/tmp/x.sock#b", "--sock /tmp/x.sock#b", "host#b", "quic://h:4433#b" }; | ||
| 390 | |||
| 391 | // Two arguments joined, and one argument passed through: same string, | ||
| 392 | // which is the point — the state file only ever holds this one. | ||
| 393 | const joined = try spellingFromArgv(alloc, &argv, 0); | ||
| 394 | defer alloc.free(joined.spelling); | ||
| 395 | try std.testing.expectEqualStrings("--sock /tmp/x.sock#b", joined.spelling); | ||
| 396 | try std.testing.expectEqual(@as(usize, 2), joined.consumed); | ||
| 397 | |||
| 398 | const whole = try spellingFromArgv(alloc, &argv, 2); | ||
| 399 | defer alloc.free(whole.spelling); | ||
| 400 | try std.testing.expectEqualStrings("--sock /tmp/x.sock#b", whole.spelling); | ||
| 401 | try std.testing.expectEqual(@as(usize, 1), whole.consumed); | ||
| 402 | |||
| 403 | // #SESSION rides both paths intact, down to the split. | ||
| 404 | try std.testing.expectEqualStrings("b", (try parseSpelling(joined.spelling)).session); | ||
| 405 | try std.testing.expectEqualStrings("/tmp/x.sock", (try parseSpelling(whole.spelling)).spec.sock); | ||
| 406 | |||
| 407 | // Host and quic spellings are already whole; nothing is consumed after. | ||
| 408 | const host = try spellingFromArgv(alloc, &argv, 3); | ||
| 409 | defer alloc.free(host.spelling); | ||
| 410 | try std.testing.expectEqualStrings("host#b", host.spelling); | ||
| 411 | try std.testing.expectEqual(@as(usize, 1), host.consumed); | ||
| 412 | const q = try spellingFromArgv(alloc, &argv, 4); | ||
| 413 | defer alloc.free(q.spelling); | ||
| 414 | try std.testing.expectEqualStrings("quic://h:4433#b", q.spelling); | ||
| 415 | try std.testing.expectEqual(@as(usize, 1), q.consumed); | ||
| 416 | } | ||
| 417 | |||
| 418 | test "spellingFromArgv: a trailing --sock is a usage error, not a read off the end" { | ||
| 419 | const argv = [_][:0]const u8{ "host", "--sock" }; | ||
| 420 | try std.testing.expectError(error.MissingSockPath, spellingFromArgv(std.testing.allocator, &argv, 1)); | ||
| 421 | } | ||
| 422 | |||
| 423 | test "parseSpelling: three spellings classify; session splits at the LAST '#'" { | 249 | test "parseSpelling: three spellings classify; session splits at the LAST '#'" { |
| 424 | try std.testing.expectEqualStrings("box1", (try parseSpelling("box1")).spec.host); | 250 | try std.testing.expectEqualStrings("box1", (try parseSpelling("box1")).spec.host); |
| 425 | try std.testing.expectEqualStrings("", (try parseSpelling("box1")).session); | 251 | try std.testing.expectEqualStrings("", (try parseSpelling("box1")).session); |
| @@ -515,35 +341,6 @@ test "wall: save/load round-trip; missing file loads empty; bad line refuses" { | |||
| 515 | } | 341 | } |
| 516 | } | 342 | } |
| 517 | 343 | ||
| 518 | test "saveLines: a wall bigger than any stack buffer still round-trips" { | ||
| 519 | const testtmp = @import("testtmp"); | ||
| 520 | const alloc = std.testing.allocator; | ||
| 521 | var tmp = try testtmp.TmpDir.make(); | ||
| 522 | defer tmp.cleanup(); | ||
| 523 | const path = try std.fmt.allocPrint(alloc, "{s}/bigwall", .{tmp.path()}); | ||
| 524 | defer alloc.free(path); | ||
| 525 | |||
| 526 | // 200 lines of ~100 bytes each = ~20 KiB, well past an 8 KiB stack buffer. | ||
| 527 | var lines: std.ArrayList([]const u8) = .empty; | ||
| 528 | defer { | ||
| 529 | for (lines.items) |l| alloc.free(l); | ||
| 530 | lines.deinit(alloc); | ||
| 531 | } | ||
| 532 | for (0..200) |i| { | ||
| 533 | const line = try std.fmt.allocPrint(alloc, "host-{d:0>3}.example.com#session-{d:0>3}", .{ i, i }); | ||
| 534 | try lines.append(alloc, line); | ||
| 535 | } | ||
| 536 | try saveLines(lines.items, path); | ||
| 537 | |||
| 538 | const back = try std.fs.cwd().readFileAlloc(alloc, path, 1024 * 1024); | ||
| 539 | defer alloc.free(back); | ||
| 540 | var it = std.mem.tokenizeScalar(u8, back, '\n'); | ||
| 541 | var count: usize = 0; | ||
| 542 | while (it.next()) |_| count += 1; | ||
| 543 | try std.testing.expectEqual(@as(usize, 200), count); | ||
| 544 | try std.testing.expect(std.mem.indexOf(u8, back, "host-199.example.com#session-199") != null); | ||
| 545 | } | ||
| 546 | |||
| 547 | test "record: appends once per spelling, dedups byte-exactly, keeps order" { | 344 | test "record: appends once per spelling, dedups byte-exactly, keeps order" { |
| 548 | const testtmp = @import("testtmp"); | 345 | const testtmp = @import("testtmp"); |
| 549 | const alloc = std.testing.allocator; | 346 | const alloc = std.testing.allocator; |
| @@ -586,45 +383,6 @@ test "record: an unwritable wall file is an error the caller may swallow" { | |||
| 586 | try std.testing.expectError(error.IsDir, record(alloc, path, "a#0")); | 383 | try std.testing.expectError(error.IsDir, record(alloc, path, "a#0")); |
| 587 | } | 384 | } |
| 588 | 385 | ||
| 589 | test "statePathFrom: XDG wins when set and non-empty, HOME default otherwise" { | ||
| 590 | const alloc = std.testing.allocator; | ||
| 591 | { | ||
| 592 | const p = try statePathFrom(alloc, "/xs", "/home/u"); | ||
| 593 | defer alloc.free(p); | ||
| 594 | try std.testing.expectEqualStrings("/xs/mux/wall", p); | ||
| 595 | } | ||
| 596 | { | ||
| 597 | const p = try statePathFrom(alloc, "", "/home/u"); | ||
| 598 | defer alloc.free(p); | ||
| 599 | try std.testing.expectEqualStrings("/home/u/.local/state/mux/wall", p); | ||
| 600 | } | ||
| 601 | try std.testing.expectError(error.NoHome, statePathFrom(alloc, null, null)); | ||
| 602 | } | ||
| 603 | |||
| 604 | test "layout sidecar: save round-trips through load; a missing file is null" { | ||
| 605 | const testtmp = @import("testtmp"); | ||
| 606 | const alloc = std.testing.allocator; | ||
| 607 | var tmp = try testtmp.TmpDir.make(); | ||
| 608 | defer tmp.cleanup(); | ||
| 609 | const path = try std.fmt.allocPrint(alloc, "{s}/layout", .{tmp.path()}); | ||
| 610 | defer alloc.free(path); | ||
| 611 | try std.testing.expect(loadLayout(alloc, path) == null); | ||
| 612 | try saveBytes(path, "mux-layout 1\nleaf 0 x\n"); | ||
| 613 | const got = loadLayout(alloc, path) orelse return error.TestUnexpectedResult; | ||
| 614 | defer alloc.free(got); | ||
| 615 | try std.testing.expectEqualStrings("mux-layout 1\nleaf 0 x\n", got); | ||
| 616 | } | ||
| 617 | |||
| 618 | test "layoutPathFrom: sits beside the wall file" { | ||
| 619 | const alloc = std.testing.allocator; | ||
| 620 | const p = try layoutPathFrom(alloc, "/xdg", null); | ||
| 621 | defer alloc.free(p); | ||
| 622 | try std.testing.expectEqualStrings("/xdg/mux/layout", p); | ||
| 623 | const q = try layoutPathFrom(alloc, null, "/home/u"); | ||
| 624 | defer alloc.free(q); | ||
| 625 | try std.testing.expectEqualStrings("/home/u/.local/state/mux/layout", q); | ||
| 626 | } | ||
| 627 | |||
| 628 | // Forces semantic analysis of every pub decl under `zig build test`, so an | 386 | // Forces semantic analysis of every pub decl under `zig build test`, so an |
| 629 | // unreferenced decl must at least compile (the silent-module-loss hazard, | 387 | // unreferenced decl must at least compile (the silent-module-loss hazard, |
| 630 | // decisions.md). | 388 | // decisions.md). |
| @@ -632,20 +390,6 @@ test { | |||
| 632 | std.testing.refAllDeclsRecursive(@This()); | 390 | std.testing.refAllDeclsRecursive(@This()); |
| 633 | } | 391 | } |
| 634 | 392 | ||
| 635 | test "spellingFromArgv: a flag is not a target" { | ||
| 636 | // `mux wall -A host` used to make a tile for a host named `-A`, which | ||
| 637 | // then failed to resolve somewhere far from the typo. A wall has no | ||
| 638 | // per-tile agent flag at all — `-A` belongs to a single attach — so | ||
| 639 | // every flag-shaped argument here is the same mistake. | ||
| 640 | const argv = [_][:0]const u8{ "-A", "host", "--sock /tmp/x", "quic://h:1" }; | ||
| 641 | try std.testing.expectError(error.FlagLikeTarget, spellingFromArgv(std.testing.allocator, &argv, 0)); | ||
| 642 | |||
| 643 | // The two spellings that legitimately start with a dash still pass. | ||
| 644 | const whole = try spellingFromArgv(std.testing.allocator, &argv, 2); | ||
| 645 | defer std.testing.allocator.free(whole.spelling); | ||
| 646 | try std.testing.expectEqualStrings("--sock /tmp/x", whole.spelling); | ||
| 647 | } | ||
| 648 | |||
| 649 | test "parseSpelling: a HOST spelling is one word — the hub's POST body cannot smuggle a second" { | 393 | test "parseSpelling: a HOST spelling is one word — the hub's POST body cannot smuggle a second" { |
| 650 | // The hub's POST /tiles body is a spelling, and a HOST spelling becomes | 394 | // The hub's POST /tiles body is a spelling, and a HOST spelling becomes |
| 651 | // one argv word of `handoff.recipeFor`'s ssh line. hosts.zig refuses | 395 | // one argv word of `handoff.recipeFor`'s ssh line. hosts.zig refuses |
src/tui/wall_host.zig
| Old | New | ||
|---|---|---|---|
| @@ -6,7 +6,6 @@ | |||
| 6 | const std = @import("std"); | 6 | const std = @import("std"); |
| 7 | const proto = @import("protocol"); | 7 | const proto = @import("protocol"); |
| 8 | const client = @import("client"); | 8 | const client = @import("client"); |
| 9 | const wall = @import("wall"); | ||
| 10 | const hosts = @import("hosts"); | 9 | const hosts = @import("hosts"); |
| 11 | const handoff = @import("handoff"); | 10 | const handoff = @import("handoff"); |
| 12 | const wall_layout = @import("wall_layout.zig"); | 11 | const wall_layout = @import("wall_layout.zig"); |
| @@ -129,7 +128,7 @@ pub fn addHost( | |||
| 129 | // The prompt is `mux hosts add` typed from inside, so it refuses what | 128 | // The prompt is `mux hosts add` typed from inside, so it refuses what |
| 130 | // that refuses: `-A box` is a mistyped flag, not a host. Ahead of the | 129 | // that refuses: `-A box` is a mistyped flag, not a host. Ahead of the |
| 131 | // dupe, so the answer to a typo allocates nothing. | 130 | // dupe, so the answer to a typo allocates nothing. |
| 132 | if (wall.flagLike(spelling)) { | 131 | if (hosts.flagLike(spelling)) { |
| 133 | badHost(shared, error.FlagLikeTarget); | 132 | badHost(shared, error.FlagLikeTarget); |
| 134 | return .refused; | 133 | return .refused; |
| 135 | } | 134 | } |
src/tui/wall_layout.zig
| Old | New | ||
|---|---|---|---|
| @@ -4,7 +4,7 @@ | |||
| 4 | //! per leaf against the hosts' live lists. | 4 | //! per leaf against the hosts' live lists. |
| 5 | const std = @import("std"); | 5 | const std = @import("std"); |
| 6 | const proto = @import("protocol"); | 6 | const proto = @import("protocol"); |
| 7 | const wall = @import("wall"); | 7 | const hosts = @import("hosts"); |
| 8 | const interact = @import("interact"); | 8 | const interact = @import("interact"); |
| 9 | const layout = @import("layout"); | 9 | const layout = @import("layout"); |
| 10 | const wall_host = @import("wall_host.zig"); | 10 | const wall_host = @import("wall_host.zig"); |
| @@ -284,6 +284,12 @@ pub fn restoreLayout( | |||
| 284 | return true; | 284 | return true; |
| 285 | } | 285 | } |
| 286 | 286 | ||
| 287 | /// Every failure is the same null: a caller degrades the same way whatever | ||
| 288 | /// kept the layout from arriving. | ||
| 289 | pub fn loadLayout(alloc: std.mem.Allocator, path: []const u8) ?[]u8 { | ||
| 290 | return std.fs.cwd().readFileAlloc(alloc, path, 1024 * 1024) catch null; | ||
| 291 | } | ||
| 292 | |||
| 287 | /// `serialize` indexes spellings by leaf ID (tile index), so the array is | 293 | /// `serialize` indexes spellings by leaf ID (tile index), so the array is |
| 288 | /// tile-indexed: holes get "" and are never serialized (the tree dropped | 294 | /// tile-indexed: holes get "" and are never serialized (the tree dropped |
| 289 | /// them). A failed write prints one stderr line and returns. | 295 | /// them). A failed write prints one stderr line and returns. |
| @@ -308,7 +314,7 @@ pub fn saveLayoutTo( | |||
| 308 | std.debug.print("mux: wall layout not saved: {s}\n", .{@errorName(err)}); | 314 | std.debug.print("mux: wall layout not saved: {s}\n", .{@errorName(err)}); |
| 309 | return; | 315 | return; |
| 310 | }; | 316 | }; |
| 311 | wall.saveBytes(path, buf.items) catch |err| { | 317 | hosts.saveBytes(path, buf.items) catch |err| { |
| 312 | std.debug.print("mux: wall layout not saved: {s}\n", .{@errorName(err)}); | 318 | std.debug.print("mux: wall layout not saved: {s}\n", .{@errorName(err)}); |
| 313 | }; | 319 | }; |
| 314 | } | 320 | } |
| @@ -319,7 +325,7 @@ pub fn saveSidecar(w: Wall) void { | |||
| 319 | // a tree it saved would be a tree the next TERMINAL restores over the | 325 | // a tree it saved would be a tree the next TERMINAL restores over the |
| 320 | // aspect rule. See `restoreSidecar` for the cost of the other half. | 326 | // aspect rule. See `restoreSidecar` for the cost of the other half. |
| 321 | if (!w.shared.is_tty) return; | 327 | if (!w.shared.is_tty) return; |
| 322 | const path = wall.layoutPath(w.alloc) catch |err| { | 328 | const path = hosts.layoutPath(w.alloc) catch |err| { |
| 323 | std.debug.print("mux: wall layout not saved: {s}\n", .{@errorName(err)}); | 329 | std.debug.print("mux: wall layout not saved: {s}\n", .{@errorName(err)}); |
| 324 | return; | 330 | return; |
| 325 | }; | 331 | }; |
| @@ -340,9 +346,9 @@ pub fn restoreSidecar(w: Wall, focus_out: *?usize) ?void { | |||
| 340 | // that was already the right shape. The CLAIM is innocent; it stopped | 346 | // that was already the right shape. The CLAIM is innocent; it stopped |
| 341 | // sending a resize (see the pump's focus-claim block). | 347 | // sending a resize (see the pump's focus-claim block). |
| 342 | if (!w.shared.is_tty) return null; | 348 | if (!w.shared.is_tty) return null; |
| 343 | const path = wall.layoutPath(w.alloc) catch return null; | 349 | const path = hosts.layoutPath(w.alloc) catch return null; |
| 344 | defer w.alloc.free(path); | 350 | defer w.alloc.free(path); |
| 345 | const bytes = wall.loadLayout(w.alloc, path) orelse return null; | 351 | const bytes = loadLayout(w.alloc, path) orelse return null; |
| 346 | defer w.alloc.free(bytes); | 352 | defer w.alloc.free(bytes); |
| 347 | return restoreLayoutFrom(w, bytes, focus_out); | 353 | return restoreLayoutFrom(w, bytes, focus_out); |
| 348 | } | 354 | } |
src/tui/wall_test_host.zig
| Old | New | ||
|---|---|---|---|
| @@ -174,7 +174,7 @@ test "recordHost: a file it cannot write comes BACK — the wall may be on the a | |||
| 174 | // out loud, and WHERE it may say it depends on which caller it is: | 174 | // out loud, and WHERE it may say it depends on which caller it is: |
| 175 | // stderr before the screen is taken, a notice after. | 175 | // stderr before the screen is taken, a notice after. |
| 176 | const blocker = try std.fmt.bufPrint(&blocker_buf, "{s}/notadir", .{tmp.path()}); | 176 | const blocker = try std.fmt.bufPrint(&blocker_buf, "{s}/notadir", .{tmp.path()}); |
| 177 | try wall.saveBytes(blocker, "x"); | 177 | try hosts.saveBytes(blocker, "x"); |
| 178 | const bad = try std.fmt.bufPrint(&bad_buf, "{s}/notadir/hosts", .{tmp.path()}); | 178 | const bad = try std.fmt.bufPrint(&bad_buf, "{s}/notadir/hosts", .{tmp.path()}); |
| 179 | const ok = try std.fmt.bufPrint(&ok_buf, "{s}/hosts", .{tmp.path()}); | 179 | const ok = try std.fmt.bufPrint(&ok_buf, "{s}/hosts", .{tmp.path()}); |
| 180 | try std.testing.expect(wall_host.recordHost(alloc, .{ .sock = "/a" }, "--sock /a", bad) != null); | 180 | try std.testing.expect(wall_host.recordHost(alloc, .{ .sock = "/a" }, "--sock /a", bad) != null); |
| @@ -614,7 +614,7 @@ test "otherHosts: the dialled host is not tiled twice, and the rest follow in fi | |||
| 614 | const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()}); | 614 | const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()}); |
| 615 | // Off-origin: the host the user dialled is the file's SECOND line, so a | 615 | // Off-origin: the host the user dialled is the file's SECOND line, so a |
| 616 | // skip that only ever worked on line 0 is caught here. | 616 | // skip that only ever worked on line 0 is caught here. |
| 617 | try wall.saveBytes(path, "--sock /a\n--sock /b\n"); | 617 | try hosts.saveBytes(path, "--sock /a\n--sock /b\n"); |
| 618 | 618 | ||
| 619 | var arena_state = std.heap.ArenaAllocator.init(alloc); | 619 | var arena_state = std.heap.ArenaAllocator.init(alloc); |
| 620 | defer arena_state.deinit(); | 620 | defer arena_state.deinit(); |
| @@ -629,7 +629,7 @@ test "otherHosts: the dialled host is not tiled twice, and the rest follow in fi | |||
| 629 | 629 | ||
| 630 | // A file the grammar refuses costs the user the WALL, never the session | 630 | // A file the grammar refuses costs the user the WALL, never the session |
| 631 | // they asked for: the entry host stands alone and the attach goes on. | 631 | // they asked for: the entry host stands alone and the attach goes on. |
| 632 | try wall.saveBytes(path, "--sock /a\nbox#old\n"); | 632 | try hosts.saveBytes(path, "--sock /a\nbox#old\n"); |
| 633 | var one: std.ArrayList(HostSpec) = .empty; | 633 | var one: std.ArrayList(HostSpec) = .empty; |
| 634 | try one.append(arena, .{ .spelling = "--sock /b", .target = .{ .sock = "/b" }, .poll_target = .{ .sock = "/b" } }); | 634 | try one.append(arena, .{ .spelling = "--sock /b", .target = .{ .sock = "/b" }, .poll_target = .{ .sock = "/b" } }); |
| 635 | wall_host.otherHosts(arena, &one, "--sock /b", path, null, 0); | 635 | wall_host.otherHosts(arena, &one, "--sock /b", path, null, 0); |
src/tui/wall_test_layout.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,7 +1,7 @@ | |||
| 1 | //! The pane tree, the rects it cuts and the layout sidecar (wall_layout.zig). | 1 | //! The pane tree, the rects it cuts and the layout sidecar (wall_layout.zig). |
| 2 | const std = @import("std"); | 2 | const std = @import("std"); |
| 3 | const proto = @import("protocol"); | 3 | const proto = @import("protocol"); |
| 4 | const wall = @import("wall"); | 4 | const hosts = @import("hosts"); |
| 5 | const layout = @import("layout"); | 5 | const layout = @import("layout"); |
| 6 | const TmpDir = @import("testtmp").TmpDir; | 6 | const TmpDir = @import("testtmp").TmpDir; |
| 7 | const fixture = @import("wall_test_harness.zig"); | 7 | const fixture = @import("wall_test_harness.zig"); |
| @@ -648,7 +648,7 @@ test "saveLayoutTo writes the sidecar for the present tiles, spellings verbatim" | |||
| 648 | } | 648 | } |
| 649 | var present = [_]bool{ true, true }; | 649 | var present = [_]bool{ true, true }; |
| 650 | wall_layout.saveLayoutTo(alloc, path, tiles, &present, &shared); | 650 | wall_layout.saveLayoutTo(alloc, path, tiles, &present, &shared); |
| 651 | const got = wall.loadLayout(alloc, path) orelse return error.TestUnexpectedResult; | 651 | const got = wall_layout.loadLayout(alloc, path) orelse return error.TestUnexpectedResult; |
| 652 | defer alloc.free(got); | 652 | defer alloc.free(got); |
| 653 | try std.testing.expect(std.mem.startsWith(u8, got, "mux-layout 1\nbeside 0\n")); | 653 | try std.testing.expect(std.mem.startsWith(u8, got, "mux-layout 1\nbeside 0\n")); |
| 654 | try std.testing.expect(std.mem.indexOf(u8, got, "--sock /tmp/x#b\n") != null); | 654 | try std.testing.expect(std.mem.indexOf(u8, got, "--sock /tmp/x#b\n") != null); |
| @@ -698,7 +698,7 @@ test "saveLayoutTo handles a vanished middle tile without panicking" { | |||
| 698 | } | 698 | } |
| 699 | var present = [_]bool{ true, false, true }; | 699 | var present = [_]bool{ true, false, true }; |
| 700 | wall_layout.saveLayoutTo(alloc, path, tiles, &present, &shared); | 700 | wall_layout.saveLayoutTo(alloc, path, tiles, &present, &shared); |
| 701 | const got = wall.loadLayout(alloc, path) orelse return error.TestUnexpectedResult; | 701 | const got = wall_layout.loadLayout(alloc, path) orelse return error.TestUnexpectedResult; |
| 702 | defer alloc.free(got); | 702 | defer alloc.free(got); |
| 703 | // Tile 2's label is verbatim; the hole (tile 1) is never serialized. | 703 | // Tile 2's label is verbatim; the hole (tile 1) is never serialized. |
| 704 | try std.testing.expect(std.mem.indexOf(u8, got, "--sock /tmp/x#c\n") != null); | 704 | try std.testing.expect(std.mem.indexOf(u8, got, "--sock /tmp/x#c\n") != null); |
| @@ -914,3 +914,26 @@ test "restore: focus_out maps the saved focus through the spelling match" { | |||
| 914 | try std.testing.expectEqual(@as(?u8, null), focus_out); | 914 | try std.testing.expectEqual(@as(?u8, null), focus_out); |
| 915 | } | 915 | } |
| 916 | } | 916 | } |
| 917 | |||
| 918 | test "layout sidecar: save round-trips through load; a missing file is null" { | ||
| 919 | const alloc = std.testing.allocator; | ||
| 920 | var tmp = try TmpDir.make(); | ||
| 921 | defer tmp.cleanup(); | ||
| 922 | const path = try std.fmt.allocPrint(alloc, "{s}/layout", .{tmp.path()}); | ||
| 923 | defer alloc.free(path); | ||
| 924 | try std.testing.expect(wall_layout.loadLayout(alloc, path) == null); | ||
| 925 | try hosts.saveBytes(path, "mux-layout 1\nleaf 0 x\n"); | ||
| 926 | const got = wall_layout.loadLayout(alloc, path) orelse return error.TestUnexpectedResult; | ||
| 927 | defer alloc.free(got); | ||
| 928 | try std.testing.expectEqualStrings("mux-layout 1\nleaf 0 x\n", got); | ||
| 929 | } | ||
| 930 | |||
| 931 | test "layoutPathFrom: the sidecar sits beside the hosts file" { | ||
| 932 | const alloc = std.testing.allocator; | ||
| 933 | const p = try hosts.layoutPathFrom(alloc, "/xdg", null); | ||
| 934 | defer alloc.free(p); | ||
| 935 | try std.testing.expectEqualStrings("/xdg/mux/layout", p); | ||
| 936 | const q = try hosts.layoutPathFrom(alloc, null, "/home/u"); | ||
| 937 | defer alloc.free(q); | ||
| 938 | try std.testing.expectEqualStrings("/home/u/.local/state/mux/layout", q); | ||
| 939 | } | ||
src/tui/wallview.zig
| Old | New | ||
|---|---|---|---|
| @@ -20,7 +20,6 @@ | |||
| 20 | const std = @import("std"); | 20 | const std = @import("std"); |
| 21 | const proto = @import("protocol"); | 21 | const proto = @import("protocol"); |
| 22 | const client = @import("client"); | 22 | const client = @import("client"); |
| 23 | const wall = @import("wall"); | ||
| 24 | const hosts = @import("hosts"); | 23 | const hosts = @import("hosts"); |
| 25 | const handoff = @import("handoff"); | 24 | const handoff = @import("handoff"); |
| 26 | const proxy = @import("proxy"); | 25 | const proxy = @import("proxy"); |