e3ad4ec2
refactor: the refusal is named for the grammar it enforces, not the defence it outlived
a73x 2026-08-29 01:13
Commit message
src/client/hosts.zig
| Old | New | ||
|---|---|---|---|
| @@ -12,7 +12,7 @@ const std = @import("std"); | |||
| 12 | const wall = @import("wall"); | 12 | const wall = @import("wall"); |
| 13 | 13 | ||
| 14 | pub const Spec = union(enum) { sock: []const u8, host: []const u8, quic: []const u8 }; | 14 | pub const Spec = union(enum) { sock: []const u8, host: []const u8, quic: []const u8 }; |
| 15 | pub const ParseError = error{ HasSession, EmptySpec, BadByte, ShellMeta }; | 15 | pub const ParseError = error{ HasSession, EmptySpec, BadByte, BadSpelling }; |
| 16 | 16 | ||
| 17 | pub fn parse(line: []const u8) ParseError!Spec { | 17 | pub fn parse(line: []const u8) ParseError!Spec { |
| 18 | for (line) |b| if (b < 0x20 or b == 0x7f) return error.BadByte; | 18 | for (line) |b| if (b < 0x20 or b == 0x7f) return error.BadByte; |
| @@ -28,7 +28,7 @@ pub fn parse(line: []const u8) ParseError!Spec { | |||
| 28 | if (line.len == 0) return error.EmptySpec; | 28 | if (line.len == 0) return error.EmptySpec; |
| 29 | // wall.zig owns the byte set: the hub's POST body reaches the same ssh | 29 | // wall.zig owns the byte set: the hub's POST body reaches the same ssh |
| 30 | // argv this file's poll does, so one refusal serves both mouths. | 30 | // argv this file's poll does, so one refusal serves both mouths. |
| 31 | if (wall.hasShellMeta(line)) return error.ShellMeta; | 31 | if (wall.hasBadSpelling(line)) return error.BadSpelling; |
| 32 | return .{ .host = line }; | 32 | return .{ .host = line }; |
| 33 | } | 33 | } |
| 34 | 34 | ||
| @@ -46,7 +46,7 @@ pub fn reason(err: anyerror) []const u8 { | |||
| 46 | error.HasSession => "names a session after '#': the wall lists daemons and shows every session they have", | 46 | error.HasSession => "names a session after '#': the wall lists daemons and shows every session they have", |
| 47 | error.EmptySpec => "empty host", | 47 | error.EmptySpec => "empty host", |
| 48 | error.BadByte => "control byte in host", | 48 | error.BadByte => "control byte in host", |
| 49 | error.ShellMeta => "shell metacharacter in host: a host line names a machine, not a command", | 49 | error.BadSpelling => "punctuation in host: a host line names a machine, not a command", |
| 50 | error.MissingSockPath => "names no path", | 50 | error.MissingSockPath => "names no path", |
| 51 | error.SockPathTooLong => "socket path too long to bind", | 51 | error.SockPathTooLong => "socket path too long to bind", |
| 52 | else => @errorName(err), | 52 | else => @errorName(err), |
| @@ -234,7 +234,7 @@ test "hosts.parse: a HOST spelling is one word — the file's grammar is one hos | |||
| 234 | "box 'two words'", | 234 | "box 'two words'", |
| 235 | "box*glob", | 235 | "box*glob", |
| 236 | "box\\esc", | 236 | "box\\esc", |
| 237 | }) |bad| try std.testing.expectError(error.ShellMeta, parse(bad)); | 237 | }) |bad| try std.testing.expectError(error.BadSpelling, parse(bad)); |
| 238 | // A tab is a word separator too, and the control-byte rule reaches it | 238 | // A tab is a word separator too, and the control-byte rule reaches it |
| 239 | // first — the refusal is what matters, not which rule spoke. | 239 | // first — the refusal is what matters, not which rule spoke. |
| 240 | try std.testing.expectError(error.BadByte, parse("box\ttab")); | 240 | try std.testing.expectError(error.BadByte, parse("box\ttab")); |
| @@ -255,7 +255,7 @@ test "hosts.parse: a HOST spelling is one word — the file's grammar is one hos | |||
| 255 | // business and a quic spelling is `quic.resolveHost`'s; neither is ever | 255 | // business and a quic spelling is `quic.resolveHost`'s; neither is ever |
| 256 | // a word in an argv, and a path with a space is legal. | 256 | // a word in an argv, and a path with a space is legal. |
| 257 | try std.testing.expectEqualStrings("/tmp/my sock", (try parse("--sock /tmp/my sock")).sock); | 257 | try std.testing.expectEqualStrings("/tmp/my sock", (try parse("--sock /tmp/my sock")).sock); |
| 258 | try std.testing.expect(std.mem.indexOf(u8, reason(error.ShellMeta), "not a command") != null); | 258 | try std.testing.expect(std.mem.indexOf(u8, reason(error.BadSpelling), "not a command") != null); |
| 259 | } | 259 | } |
| 260 | 260 | ||
| 261 | test "hosts: add dedups, load/save round-trip two hosts in order, and a doubled line loads once" { | 261 | test "hosts: add dedups, load/save round-trip two hosts in order, and a doubled line loads once" { |
src/client/wall.zig
| Old | New | ||
|---|---|---|---|
| @@ -26,7 +26,7 @@ pub const Spec = union(enum) { | |||
| 26 | }; | 26 | }; |
| 27 | 27 | ||
| 28 | pub const Parsed = struct { spec: Spec, session: []const u8 }; | 28 | pub const Parsed = struct { spec: Spec, session: []const u8 }; |
| 29 | pub const ParseError = error{ BadSession, EmptySpec, BadByte, ShellMeta }; | 29 | pub const ParseError = error{ BadSession, EmptySpec, BadByte, BadSpelling }; |
| 30 | 30 | ||
| 31 | /// The bytes a HOST spelling may not hold. The word is ONE argv element — | 31 | /// The bytes a HOST spelling may not hold. The word is ONE argv element — |
| 32 | /// `handoff.recipeFor` hands it to a `ssh` the client execs — and the wall | 32 | /// `handoff.recipeFor` hands it to a `ssh` the client execs — and the wall |
| @@ -34,10 +34,10 @@ pub const ParseError = error{ BadSession, EmptySpec, BadByte, ShellMeta }; | |||
| 34 | /// the rest are punctuation no resolver will ever answer. `[user@]host` | 34 | /// the rest are punctuation no resolver will ever answer. `[user@]host` |
| 35 | /// needs none of them; `--via` is where an arbitrary command is the | 35 | /// needs none of them; `--via` is where an arbitrary command is the |
| 36 | /// contract, and it is not spellable as a target. | 36 | /// contract, and it is not spellable as a target. |
| 37 | const shell_meta = " \t;&|`$()<>'\"\\*?{}[]!~"; | 37 | const unspellable = " \t;&|`$()<>'\"\\*?{}[]!~"; |
| 38 | 38 | ||
| 39 | pub fn hasShellMeta(word: []const u8) bool { | 39 | pub fn hasBadSpelling(word: []const u8) bool { |
| 40 | for (word) |b| if (std.mem.indexOfScalar(u8, shell_meta, b) != null) return true; | 40 | for (word) |b| if (std.mem.indexOfScalar(u8, unspellable, b) != null) return true; |
| 41 | return false; | 41 | return false; |
| 42 | } | 42 | } |
| 43 | 43 | ||
| @@ -78,7 +78,7 @@ pub fn parseSpelling(line: []const u8) ParseError!Parsed { | |||
| 78 | return .{ .spec = .{ .quic = hp }, .session = session }; | 78 | return .{ .spec = .{ .quic = hp }, .session = session }; |
| 79 | } | 79 | } |
| 80 | if (spec_str.len == 0) return error.EmptySpec; | 80 | if (spec_str.len == 0) return error.EmptySpec; |
| 81 | if (hasShellMeta(spec_str)) return error.ShellMeta; | 81 | if (hasBadSpelling(spec_str)) return error.BadSpelling; |
| 82 | return .{ .spec = .{ .host = spec_str }, .session = session }; | 82 | return .{ .spec = .{ .host = spec_str }, .session = session }; |
| 83 | } | 83 | } |
| 84 | 84 | ||
| @@ -122,7 +122,7 @@ pub fn reason(err: anyerror) []const u8 { | |||
| 122 | error.BadSession => "bad session name after '#' (printable ASCII, no space, no '/')", | 122 | error.BadSession => "bad session name after '#' (printable ASCII, no space, no '/')", |
| 123 | error.EmptySpec => "empty target", | 123 | error.EmptySpec => "empty target", |
| 124 | error.BadByte => "control byte in target", | 124 | error.BadByte => "control byte in target", |
| 125 | error.ShellMeta => "shell metacharacter in host: a tile spelling names a machine, not a command", | 125 | error.BadSpelling => "punctuation in host: a tile spelling names a machine, not a command", |
| 126 | error.MissingSockPath => "names no path", | 126 | error.MissingSockPath => "names no path", |
| 127 | error.SockPathTooLong => "socket path too long to bind", | 127 | error.SockPathTooLong => "socket path too long to bind", |
| 128 | else => @errorName(err), | 128 | else => @errorName(err), |
| @@ -765,7 +765,7 @@ test "parseSpelling: a HOST spelling is one word — the hub's POST body cannot | |||
| 765 | "box 'two words'", | 765 | "box 'two words'", |
| 766 | "box*glob", | 766 | "box*glob", |
| 767 | "box\\esc", | 767 | "box\\esc", |
| 768 | }) |bad| try std.testing.expectError(error.ShellMeta, parseSpelling(bad)); | 768 | }) |bad| try std.testing.expectError(error.BadSpelling, parseSpelling(bad)); |
| 769 | // The refusal is the HOST arm's: a socket path is dialed, never a word | 769 | // The refusal is the HOST arm's: a socket path is dialed, never a word |
| 770 | // in an argv, and `--sock ` itself holds a space. | 770 | // in an argv, and `--sock ` itself holds a space. |
| 771 | try std.testing.expectEqualStrings("/tmp/a b.sock", (try parseSpelling("--sock /tmp/a b.sock")).spec.sock); | 771 | try std.testing.expectEqualStrings("/tmp/a b.sock", (try parseSpelling("--sock /tmp/a b.sock")).spec.sock); |
src/client/webhub.zig
| Old | New | ||
|---|---|---|---|
| @@ -1133,7 +1133,7 @@ pub fn serveConn( | |||
| 1133 | error.BadSession => .{ .bad_request, "bad session name after '#'\n" }, | 1133 | error.BadSession => .{ .bad_request, "bad session name after '#'\n" }, |
| 1134 | error.EmptySpec => .{ .bad_request, "empty target\n" }, | 1134 | error.EmptySpec => .{ .bad_request, "empty target\n" }, |
| 1135 | error.BadByte => .{ .bad_request, "control byte in target\n" }, | 1135 | error.BadByte => .{ .bad_request, "control byte in target\n" }, |
| 1136 | error.ShellMeta => .{ .bad_request, "shell metacharacter in host: a tile spelling names a machine, not a command\n" }, | 1136 | error.BadSpelling => .{ .bad_request, "punctuation in host: a tile spelling names a machine, not a command\n" }, |
| 1137 | error.MissingKey => .{ .bad_request, "no key for quic:// target (mux d keygen, or MUX_KEY_FILE)\n" }, | 1137 | error.MissingKey => .{ .bad_request, "no key for quic:// target (mux d keygen, or MUX_KEY_FILE)\n" }, |
| 1138 | error.SockPathTooLong => .{ .bad_request, "socket path too long\n" }, | 1138 | error.SockPathTooLong => .{ .bad_request, "socket path too long\n" }, |
| 1139 | // The tile is NOT live: addTile rolls back on a | 1139 | // The tile is NOT live: addTile rolls back on a |