0698569c
refactor: the sun_path bound belongs to the one binder
a73x 2026-08-30 08:11
Commit message
src/cli/mux_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -361,7 +361,7 @@ pub fn main(args: []const [:0]const u8) !u8 { | |||
| 361 | /// Whether the wall has to start the local daemon itself. Asked of the OS, | 361 | /// Whether the wall has to start the local daemon itself. Asked of the OS, |
| 362 | /// not of the file: the daemon dies on every reboot while its line lives on. | 362 | /// not of the file: the daemon dies on every reboot while its line lives on. |
| 363 | fn localNeedsStart(h: *const hosts.Hosts, sock: []const u8) bool { | 363 | fn localNeedsStart(h: *const hosts.Hosts, sock: []const u8) bool { |
| 364 | var buf: [sockpath.max_sun_path + "--sock ".len]u8 = undefined; | 364 | var buf: [std.fs.max_path_bytes + "--sock ".len]u8 = undefined; |
| 365 | const line = std.fmt.bufPrint(&buf, "--sock {s}", .{sock}) catch return false; | 365 | const line = std.fmt.bufPrint(&buf, "--sock {s}", .{sock}) catch return false; |
| 366 | return h.has(line) and !sockpath.answers(sock); | 366 | return h.has(line) and !sockpath.answers(sock); |
| 367 | } | 367 | } |
| @@ -392,13 +392,6 @@ fn attachLocal( | |||
| 392 | return 2; | 392 | return 2; |
| 393 | } | 393 | } |
| 394 | 394 | ||
| 395 | // WHERE the shared guard (sockpath.tooLong) sits is this binary's | ||
| 396 | // decision: before the auto-start, not at the connect. Otherwise mux | ||
| 397 | // forks a daemon that refuses the path instantly, then polls the full | ||
| 398 | // 2s into "daemon did not answer" — a timeout story about a path that | ||
| 399 | // was doomed at parse. | ||
| 400 | if (sockpath.tooLong("mux", sock_path)) return 1; | ||
| 401 | |||
| 402 | if (!sockpath.answers(sock_path) and !try startLocalDaemon(alloc, sock_path)) return 1; | 395 | if (!sockpath.answers(sock_path) and !try startLocalDaemon(alloc, sock_path)) return 1; |
| 403 | return wallview.runAttach( | 396 | return wallview.runAttach( |
| 404 | alloc, | 397 | alloc, |
| @@ -714,11 +707,9 @@ fn hostsAdd(arena: std.mem.Allocator, spellings: []const []const u8, path: []con | |||
| 714 | // The one refusal that belongs to the transport rather than the | 707 | // The one refusal that belongs to the transport rather than the |
| 715 | // grammar: a sun_path that cannot be bound is a host that could | 708 | // grammar: a sun_path that cannot be bound is a host that could |
| 716 | // never dial, and add time is the only moment the user is still | 709 | // never dial, and add time is the only moment the user is still |
| 717 | // looking at what they typed. | 710 | // looking at what they typed. Asked of the owner, in the owner's |
| 718 | if (spec == .sock and spec.sock.len > sockpath.max_sun_path) { | 711 | // words — the bound is the kernel's and lives in one place. |
| 719 | std.debug.print("mux hosts add: {s}: {s}\n", .{ s, hosts.reason(error.SockPathTooLong) }); | 712 | if (spec == .sock and sockpath.tooLong("mux hosts add", spec.sock)) return 2; |
| 720 | return 2; | ||
| 721 | } | ||
| 722 | } | 713 | } |
| 723 | // Strict: growing a file whose existing content is not understood would | 714 | // Strict: growing a file whose existing content is not understood would |
| 724 | // re-save garbage as if it had been read. | 715 | // re-save garbage as if it had been read. |
| @@ -922,12 +913,12 @@ test "hosts list: a line longer than the row buffer is still shown, because rm m | |||
| 922 | var buf: [512]u8 = undefined; | 913 | var buf: [512]u8 = undefined; |
| 923 | const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()}); | 914 | const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()}); |
| 924 | 915 | ||
| 925 | // A `--sock` path no `sun_path` could hold: refused at usage altitude, | 916 | // A host the grammar refuses, so the row is produced without a dial |
| 926 | // so the row is produced without a dial and the check is about the | 917 | // and the check is about the printing and nothing else. Long by the |
| 927 | // printing and nothing else. | 918 | // host name, which is the half a fixed row buffer would have cut. |
| 928 | var long: [900]u8 = undefined; | 919 | var long: [900]u8 = undefined; |
| 929 | @memcpy(long[0..8], "--sock /"); | 920 | @memset(&long, 'h'); |
| 930 | @memset(long[8..], 'h'); | 921 | long[899] = '#'; |
| 931 | var line_buf: [1024]u8 = undefined; | 922 | var line_buf: [1024]u8 = undefined; |
| 932 | try hosts.saveBytes(path, try std.fmt.bufPrint(&line_buf, "{s}\n", .{long})); | 923 | try hosts.saveBytes(path, try std.fmt.bufPrint(&line_buf, "{s}\n", .{long})); |
| 933 | 924 | ||
| @@ -945,7 +936,11 @@ test "hosts list: a line longer than the row buffer is still shown, because rm m | |||
| 945 | defer alloc.free(text); | 936 | defer alloc.free(text); |
| 946 | // Whole, and with its verdict: a truncated line is one `rm` cannot take. | 937 | // Whole, and with its verdict: a truncated line is one `rm` cannot take. |
| 947 | try std.testing.expect(std.mem.startsWith(u8, text, &long)); | 938 | try std.testing.expect(std.mem.startsWith(u8, text, &long)); |
| 948 | try std.testing.expect(std.mem.endsWith(u8, text, "\t[bad host: socket path too long to bind]\n")); | 939 | try std.testing.expect(std.mem.endsWith( |
| 940 | u8, | ||
| 941 | text, | ||
| 942 | "\t[bad host: names a session after '#': the wall lists daemons and shows every session they have]\n", | ||
| 943 | )); | ||
| 949 | } | 944 | } |
| 950 | 945 | ||
| 951 | test "wall: a LISTED local daemon that nothing answers on is one this wall starts" { | 946 | test "wall: a LISTED local daemon that nothing answers on is one this wall starts" { |
src/cli/muxa.zig
| Old | New | ||
|---|---|---|---|
| @@ -1136,19 +1136,6 @@ pub fn main(args: []const [:0]const u8) !u8 { | |||
| 1136 | else => |e| return e, | 1136 | else => |e| return e, |
| 1137 | }; | 1137 | }; |
| 1138 | 1138 | ||
| 1139 | // Refused by name, before connecting: connect would bounce a too-long | ||
| 1140 | // path off the kernel with a generic error, and the path is the whole | ||
| 1141 | // story. Every binary owes this check in its own words (sockpath). | ||
| 1142 | if (sock_path.len > sockpath.max_sun_path) { | ||
| 1143 | var buf: [64]u8 = undefined; | ||
| 1144 | const detail = std.fmt.bufPrint( | ||
| 1145 | &buf, | ||
| 1146 | "{d} bytes, max {d}", | ||
| 1147 | .{ sock_path.len, sockpath.max_sun_path }, | ||
| 1148 | ) catch "too long"; | ||
| 1149 | return fail("socket path too long", detail); | ||
| 1150 | } | ||
| 1151 | |||
| 1152 | var conn = Conn.open(alloc, sock_path) catch |e| { | 1139 | var conn = Conn.open(alloc, sock_path) catch |e| { |
| 1153 | // The path goes in the detail: an agent client pointed at the wrong socket | 1140 | // The path goes in the detail: an agent client pointed at the wrong socket |
| 1154 | // is this binary's likeliest field failure, and an agent reading | 1141 | // is this binary's likeliest field failure, and an agent reading |
src/cli/webhub_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -155,13 +155,6 @@ pub fn main(args: []const [:0]const u8) !u8 { | |||
| 155 | ); | 155 | ); |
| 156 | return 2; | 156 | return 2; |
| 157 | }, | 157 | }, |
| 158 | error.SockPathTooLong => { | ||
| 159 | std.debug.print( | ||
| 160 | "mux web: socket path too long (max {d} bytes)\n", | ||
| 161 | .{sockpath.max_sun_path}, | ||
| 162 | ); | ||
| 163 | return 2; | ||
| 164 | }, | ||
| 165 | else => return err, | 158 | else => return err, |
| 166 | }; | 159 | }; |
| 167 | } | 160 | } |
src/client/client.zig
| Old | New | ||
|---|---|---|---|
| @@ -275,14 +275,13 @@ pub const Target = union(enum) { | |||
| 275 | /// `asked` is required, never defaulted: see `HandoffTarget.fromRecipe`. | 275 | /// `asked` is required, never defaulted: see `HandoffTarget.fromRecipe`. |
| 276 | pub fn fromSpec(alloc: std.mem.Allocator, spec: hosts.Spec, key: ?[]const u8, idle_ms: u32, asked: bool) SpecError!Target { | 276 | pub fn fromSpec(alloc: std.mem.Allocator, spec: hosts.Spec, key: ?[]const u8, idle_ms: u32, asked: bool) SpecError!Target { |
| 277 | return switch (spec) { | 277 | return switch (spec) { |
| 278 | // sun_path is a fixed array in the kernel's struct: a longer | 278 | // No length guard here, deliberately: `Address.initUnix` |
| 279 | // path cannot be dialed at all, so it is refused here, at | 279 | // answers `NameTooLong` and truncates nothing, so a doomed |
| 280 | // usage altitude, rather than at a connect that fails with a | 280 | // path fails as a dial like any other and the picker row |
| 281 | // truncated name nobody typed. | 281 | // quotes the kernel's own word. The one binder is where a |
| 282 | .sock => |path| if (path.len > sockpath.max_sun_path) | 282 | // path is refused by name, because it is the only place a |
| 283 | error.SockPathTooLong | 283 | // refusal saves anything. |
| 284 | else | 284 | .sock => |path| .{ .sock = try alloc.dupe(u8, path) }, |
| 285 | .{ .sock = try alloc.dupe(u8, path) }, | ||
| 286 | .host => |h| blk: { | 285 | .host => |h| blk: { |
| 287 | const hd = try alloc.dupe(u8, h); | 286 | const hd = try alloc.dupe(u8, h); |
| 288 | const r = try handoff.recipeFor(alloc, hd, false); | 287 | const r = try handoff.recipeFor(alloc, hd, false); |
| @@ -320,7 +319,7 @@ pub const Target = union(enum) { | |||
| 320 | 319 | ||
| 321 | /// What resolving a spelling can fail at: a key that is not there to prove | 320 | /// What resolving a spelling can fail at: a key that is not there to prove |
| 322 | /// the dial with, and a socket path the kernel cannot hold. | 321 | /// the dial with, and a socket path the kernel cannot hold. |
| 323 | pub const SpecError = error{ MissingKey, SockPathTooLong, OutOfMemory }; | 322 | pub const SpecError = error{ MissingKey, OutOfMemory }; |
| 324 | 323 | ||
| 325 | /// What the open produced — the live wire. Distinct from `Target` because a | 324 | /// What the open produced — the live wire. Distinct from `Target` because a |
| 326 | /// `hand` recipe yields either a quic or a pipe link, and which one is | 325 | /// `hand` recipe yields either a quic or a pipe link, and which one is |
| @@ -2775,13 +2774,3 @@ test "Target.fromSpec: a quic spelling with no key frees the path it refused" { | |||
| 2775 | Target.fromSpec(alloc, .{ .quic = "h:1" }, null, 30_000, false), | 2774 | Target.fromSpec(alloc, .{ .quic = "h:1" }, null, 30_000, false), |
| 2776 | ); | 2775 | ); |
| 2777 | } | 2776 | } |
| 2778 | |||
| 2779 | test "Target.fromSpec: a sun_path-overflowing socket is refused at usage altitude" { | ||
| 2780 | // Refused where it was typed, not at a connect(2) that would bind a | ||
| 2781 | // truncated name nobody asked for. | ||
| 2782 | const long = "/" ++ "x" ** 200; | ||
| 2783 | try std.testing.expectError( | ||
| 2784 | error.SockPathTooLong, | ||
| 2785 | Target.fromSpec(std.testing.allocator, .{ .sock = long }, null, 30_000, false), | ||
| 2786 | ); | ||
| 2787 | } | ||
src/client/hosts.zig
| Old | New | ||
|---|---|---|---|
| @@ -100,7 +100,6 @@ pub fn reason(err: anyerror) []const u8 { | |||
| 100 | error.BadByte => "control byte in host", | 100 | error.BadByte => "control byte in host", |
| 101 | error.BadSpelling => "punctuation in host: a host line names a machine, not a command", | 101 | error.BadSpelling => "punctuation in host: a host line names a machine, not a command", |
| 102 | error.MissingSockPath => "names no path", | 102 | error.MissingSockPath => "names no path", |
| 103 | error.SockPathTooLong => "socket path too long to bind", | ||
| 104 | else => @errorName(err), | 103 | else => @errorName(err), |
| 105 | }; | 104 | }; |
| 106 | } | 105 | } |
src/sockpath.zig
| Old | New | ||
|---|---|---|---|
| @@ -10,15 +10,17 @@ | |||
| 10 | const std = @import("std"); | 10 | const std = @import("std"); |
| 11 | 11 | ||
| 12 | /// The usable bytes of `sockaddr_un.sun_path`: the field is 108 and the | 12 | /// The usable bytes of `sockaddr_un.sun_path`: the field is 108 and the |
| 13 | /// last one belongs to the NUL. Every binary refuses a longer path by | 13 | /// last one belongs to the NUL. Private, and that is the point — the |
| 14 | /// name before acting on it — the number is the kernel's and belongs in | 14 | /// number is the kernel's, and every binary that once re-compared it |
| 15 | /// one place. | 15 | /// grew its own wording for the same refusal. |
| 16 | pub const max_sun_path = 107; | 16 | const max_sun_path = 107; |
| 17 | 17 | ||
| 18 | /// Checked before anything acts on the path: connect would bounce a | 18 | /// The refusal, for whoever is about to BIND. Everyone else dials and |
| 19 | /// too-long one off the kernel with a generic error, and the path is the | 19 | /// reads the kernel's own `NameTooLong`. |
| 20 | /// whole story. `mux a` answers in JSON and keeps its own wording. | ||
| 21 | pub fn tooLong(prefix: []const u8, path: []const u8) bool { | 20 | pub fn tooLong(prefix: []const u8, path: []const u8) bool { |
| 21 | // Named rather than bounced off a connect: the bind would fail with a | ||
| 22 | // generic error, and the path is the whole story. `mux a` answers in | ||
| 23 | // JSON and keeps its own wording. | ||
| 22 | if (path.len <= max_sun_path) return false; | 24 | if (path.len <= max_sun_path) return false; |
| 23 | std.debug.print( | 25 | std.debug.print( |
| 24 | "{s}: socket path too long ({d} bytes, max {d}): {s}\n", | 26 | "{s}: socket path too long ({d} bytes, max {d}): {s}\n", |
| @@ -94,11 +96,13 @@ pub fn answers(path: []const u8) bool { | |||
| 94 | s.close(); | 96 | s.close(); |
| 95 | return true; | 97 | return true; |
| 96 | } else |err| return switch (err) { | 98 | } else |err| return switch (err) { |
| 97 | // Two errors are a no; every other one is a yes. A path whose | 99 | // Three errors are a no: nothing at the path, nothing listening, |
| 98 | // connect failed for some third reason may still be a live | 100 | // and a path the kernel's `sun_path` cannot even hold — no daemon |
| 99 | // daemon's, and a false here is a licence to unlink it — the field | 101 | // is reachable through any of them. Every OTHER error is a yes, |
| 100 | // incident `claim` below exists for. | 102 | // because a connect that failed for some third reason may still be |
| 101 | error.FileNotFound, error.ConnectionRefused => false, | 103 | // a live daemon's, and a false here is a licence to unlink it — |
| 104 | // the field incident `claim` below exists for. | ||
| 105 | error.FileNotFound, error.ConnectionRefused, error.NameTooLong => false, | ||
| 102 | else => true, | 106 | else => true, |
| 103 | }; | 107 | }; |
| 104 | } | 108 | } |
| @@ -174,6 +178,12 @@ test "answers: a live listener, a stale socket file, and a path with nothing on | |||
| 174 | listener.deinit(); | 178 | listener.deinit(); |
| 175 | try std.testing.expect(!answers(path)); | 179 | try std.testing.expect(!answers(path)); |
| 176 | try std.fs.cwd().access(path, .{}); | 180 | try std.fs.cwd().access(path, .{}); |
| 181 | |||
| 182 | // A path longer than `sun_path` is a no as flatly as a missing one: | ||
| 183 | // nothing is listening there and nothing could be. A yes here would | ||
| 184 | // tell the client's attach a daemon was already up and send it | ||
| 185 | // straight to a dial, instead of to the one binder's refusal. | ||
| 186 | try std.testing.expect(!answers("/" ++ "x" ** 200)); | ||
| 177 | } | 187 | } |
| 178 | 188 | ||
| 179 | test "PathId: names the file it was taken from, not the path, and not a successor" { | 189 | test "PathId: names the file it was taken from, not the path, and not a successor" { |
src/tui/wall_test_host.zig
| Old | New | ||
|---|---|---|---|
| @@ -566,14 +566,6 @@ test "addHost: the prompt adds a DAEMON — a flag, a session and a host already | |||
| 566 | try std.testing.expectEqualStrings("[no room on the wall for another host]", wv.takeNotice(&shared, &buf)); | 566 | try std.testing.expectEqualStrings("[no room on the wall for another host]", wv.takeNotice(&shared, &buf)); |
| 567 | } | 567 | } |
| 568 | 568 | ||
| 569 | test "resolveHost refuses a sun_path-overflowing sock path" { | ||
| 570 | const alloc = std.testing.allocator; | ||
| 571 | // Refused at usage altitude, not at a connect that fails with a | ||
| 572 | // truncated sun_path nobody typed. | ||
| 573 | const long = "--sock /" ++ "x" ** 200; | ||
| 574 | try std.testing.expectError(error.SockPathTooLong, client.resolveHost(alloc, long, null, 30_000)); | ||
| 575 | } | ||
| 576 | |||
| 577 | test "resolveHost: an ssh host is polled by a recipe that cannot prompt and cannot narrate" { | 569 | test "resolveHost: an ssh host is polled by a recipe that cannot prompt and cannot narrate" { |
| 578 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); | 570 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); |
| 579 | defer arena.deinit(); | 571 | defer arena.deinit(); |
test/e2e_01_boot.sh
| Old | New | ||
|---|---|---|---|
| @@ -250,16 +250,31 @@ grep -qaF "$(printf '\033[22;0t')" "$OUT.dead" && { | |||
| 250 | echo "e2e FAIL: a client that entered nothing still pushed the title stack" | 250 | echo "e2e FAIL: a client that entered nothing still pushed the title stack" |
| 251 | cat -v "$OUT.dead"; exit 1; } | 251 | cat -v "$OUT.dead"; exit 1; } |
| 252 | 252 | ||
| 253 | # M13 spec amendment: a socket path past sun_path's 107 usable bytes is | 253 | # A socket path past sun_path's 107 usable bytes is refused by name, and |
| 254 | # refused by name in both binaries, before either acts on it — the failure | 254 | # the refusal belongs to the one binder: `mux d` reads its own flags, |
| 255 | # it replaced was a 2s poll ending in "daemon did not answer", a timeout | 255 | # refuses at parse and exits 1 before any fork. The client no longer |
| 256 | # story about a path that was doomed at parse. | 256 | # compares the length at all — it asks for a daemon and relays what the |
| 257 | # daemon says — so this is also the pin on that relay: the DAEMON's words, | ||
| 258 | # on the client's stderr, fast, with no `starting` line in front of them. | ||
| 259 | # The failure both replaced was a 2s poll ending in "daemon did not | ||
| 260 | # answer", a timeout story about a path doomed at parse. | ||
| 257 | LONGSOCK="/tmp/$(printf 'a%.0s' $(seq 1 110)).sock" | 261 | LONGSOCK="/tmp/$(printf 'a%.0s' $(seq 1 110)).sock" |
| 258 | "$MUX" d dump --sock "$LONGSOCK" > "$OUT.long" 2>&1 && { echo "e2e FAIL: mux d accepted long sock"; exit 1; } | 262 | "$MUX" d dump --sock "$LONGSOCK" > "$OUT.long" 2>&1 && { echo "e2e FAIL: mux d accepted long sock"; exit 1; } |
| 259 | grep -q "mux d: socket path too long" "$OUT.long" || { echo "e2e FAIL:"; cat "$OUT.long"; exit 1; } | 263 | grep -q "mux d: socket path too long" "$OUT.long" || { echo "e2e FAIL:"; cat "$OUT.long"; exit 1; } |
| 264 | LONGT0=$(date +%s) | ||
| 260 | "$MUX" --sock "$LONGSOCK" > "$OUT.longc" 2>&1 && { echo "e2e FAIL: mux accepted long sock"; exit 1; } | 265 | "$MUX" --sock "$LONGSOCK" > "$OUT.longc" 2>&1 && { echo "e2e FAIL: mux accepted long sock"; exit 1; } |
| 261 | grep -q "mux: socket path too long" "$OUT.longc" || { echo "e2e FAIL:"; cat "$OUT.longc"; exit 1; } | 266 | LONGEL=$(( $(date +%s) - LONGT0 )) |
| 267 | grep -q "mux d: socket path too long" "$OUT.longc" || { | ||
| 268 | echo "e2e FAIL: the client did not relay the daemon's refusal:"; cat "$OUT.longc"; exit 1; } | ||
| 262 | grep -q "daemon did not answer" "$OUT.longc" && { echo "e2e FAIL: still the timeout story"; cat "$OUT.longc"; exit 1; } | 269 | grep -q "daemon did not answer" "$OUT.longc" && { echo "e2e FAIL: still the timeout story"; cat "$OUT.longc"; exit 1; } |
| 270 | # No `starting` line: the daemon refuses at parse, so nothing was ever | ||
| 271 | # forked and nothing should have announced that it was about to be. This | ||
| 272 | # is what a re-comparison in the client used to buy, and what the owner's | ||
| 273 | # own refusal buys now. | ||
| 274 | grep -q "starting" "$OUT.longc" && { | ||
| 275 | echo "e2e FAIL: a doomed path still announced a start:"; cat "$OUT.longc"; exit 1; } | ||
| 276 | [ "$LONGEL" -le 1 ] || { | ||
| 277 | echo "e2e FAIL: a doomed --sock took ${LONGEL}s to be refused (want <=1)"; exit 1; } | ||
| 263 | "$MUX" d --version --sock "$LONGSOCK" >/dev/null 2>&1 || { echo "e2e FAIL: --version refused over sock length"; exit 1; } | 278 | "$MUX" d --version --sock "$LONGSOCK" >/dev/null 2>&1 || { echo "e2e FAIL: --version refused over sock length"; exit 1; } |
| 264 | 279 | ||
| 265 | rm_swept "$OUT.dead" "$OUT.long" "$OUT.longc" | 280 | rm_swept "$OUT.dead" "$OUT.long" "$OUT.longc" |