1f7a9934
refactor: one owner for the `HOST[:PORT]` grammar
a73x 2026-08-29 10:01
Commit message
src/cli/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -242,36 +242,10 @@ fn usageExit(u: Usage) u8 { | |||
| 242 | return usageCode(u); | 242 | return usageCode(u); |
| 243 | } | 243 | } |
| 244 | 244 | ||
| 245 | /// `HOST[:PORT]` where HOST is a literal address — `127.0.0.1:4433`, | 245 | /// No DNS, deliberately: a name resolving to several addresses is a |
| 246 | /// `0.0.0.0:4433`, `[::]:4433`. An omitted port means `quic.default_port`; | 246 | /// question, not an answer, and this one is to BIND. |
| 247 | /// an empty one (`127.0.0.1:`) is still a mistake and still refused. | ||
| 248 | /// Deliberately no DNS: this is the address to bind, and a name resolving | ||
| 249 | /// to several is a question, not an answer. | ||
| 250 | fn splitHostPort(s: []const u8) !struct { host: []const u8, port: u16 } { | ||
| 251 | if (s.len > 0 and s[0] == '[') { | ||
| 252 | const close = std.mem.indexOfScalar(u8, s, ']') orelse return error.MalformedAddress; | ||
| 253 | if (close + 1 == s.len) return .{ .host = s[1..close], .port = quic.default_port }; | ||
| 254 | if (s[close + 1] != ':') return error.MalformedAddress; | ||
| 255 | return .{ .host = s[1..close], .port = try parsePort(s[close + 2 ..]) }; | ||
| 256 | } | ||
| 257 | const colon = std.mem.lastIndexOfScalar(u8, s, ':') orelse | ||
| 258 | return .{ .host = s, .port = quic.default_port }; | ||
| 259 | // An unbracketed IPv6 literal carries colons of its own, and splitting | ||
| 260 | // on the last one would quietly take its final group as a port: | ||
| 261 | // `fe80::1:4433` reads equally well as host `fe80::1` port 4433 and as | ||
| 262 | // host `fe80::1:4433` with the port left off. Brackets are how that | ||
| 263 | // ambiguity is spelled out, so without them it is refused rather than | ||
| 264 | // guessed at. | ||
| 265 | if (std.mem.indexOfScalar(u8, s[0..colon], ':') != null) return error.MalformedAddress; | ||
| 266 | return .{ .host = s[0..colon], .port = try parsePort(s[colon + 1 ..]) }; | ||
| 267 | } | ||
| 268 | |||
| 269 | fn parsePort(s: []const u8) !u16 { | ||
| 270 | return std.fmt.parseInt(u16, s, 10) catch error.MalformedAddress; | ||
| 271 | } | ||
| 272 | |||
| 273 | fn parseBindAddr(s: []const u8) !std.net.Address { | 247 | fn parseBindAddr(s: []const u8) !std.net.Address { |
| 274 | const hp = try splitHostPort(s); | 248 | const hp = try quic.splitHostPort(s); |
| 275 | return std.net.Address.parseIp(hp.host, hp.port); | 249 | return std.net.Address.parseIp(hp.host, hp.port); |
| 276 | } | 250 | } |
| 277 | 251 | ||
| @@ -1375,45 +1349,6 @@ test "usage names every subcommand" { | |||
| 1375 | } | 1349 | } |
| 1376 | } | 1350 | } |
| 1377 | 1351 | ||
| 1378 | test "splitHostPort: literal addresses, bracketed and not" { | ||
| 1379 | const v4 = try splitHostPort("127.0.0.1:4433"); | ||
| 1380 | try std.testing.expectEqualStrings("127.0.0.1", v4.host); | ||
| 1381 | try std.testing.expectEqual(@as(u16, 4433), v4.port); | ||
| 1382 | |||
| 1383 | const v6 = try splitHostPort("[::1]:4433"); | ||
| 1384 | try std.testing.expectEqualStrings("::1", v6.host); | ||
| 1385 | try std.testing.expectEqual(@as(u16, 4433), v6.port); | ||
| 1386 | |||
| 1387 | const any6 = try splitHostPort("[::]:1"); | ||
| 1388 | try std.testing.expectEqualStrings("::", any6.host); | ||
| 1389 | try std.testing.expectEqual(@as(u16, 1), any6.port); | ||
| 1390 | |||
| 1391 | // No port names the default. 4433 is mux's convention; an explicit | ||
| 1392 | // port always wins. | ||
| 1393 | // | ||
| 1394 | // The number is spelled out rather than written `quic.default_port`: | ||
| 1395 | // comparing the parse's answer against the same constant the parse | ||
| 1396 | // reads holds for ANY value, so it would pin the wiring and say | ||
| 1397 | // nothing about the port — and 4433 is the half both ends of a | ||
| 1398 | // connection have to agree on. | ||
| 1399 | const dflt = try splitHostPort("127.0.0.1"); | ||
| 1400 | try std.testing.expectEqualStrings("127.0.0.1", dflt.host); | ||
| 1401 | try std.testing.expectEqual(@as(u16, 4433), dflt.port); | ||
| 1402 | |||
| 1403 | const dflt6 = try splitHostPort("[::1]"); | ||
| 1404 | try std.testing.expectEqualStrings("::1", dflt6.host); | ||
| 1405 | try std.testing.expectEqual(@as(u16, 4433), dflt6.port); | ||
| 1406 | |||
| 1407 | try std.testing.expectError(error.MalformedAddress, splitHostPort("127.0.0.1:")); | ||
| 1408 | try std.testing.expectError(error.MalformedAddress, splitHostPort("127.0.0.1:99999")); | ||
| 1409 | try std.testing.expectError(error.MalformedAddress, splitHostPort("[::1]4433")); | ||
| 1410 | |||
| 1411 | // An IPv6 literal without brackets is ambiguous about where the address | ||
| 1412 | // stops, so it is refused instead of being read either way. | ||
| 1413 | try std.testing.expectError(error.MalformedAddress, splitHostPort("::1:4433")); | ||
| 1414 | try std.testing.expectError(error.MalformedAddress, splitHostPort("fe80::1:4433")); | ||
| 1415 | } | ||
| 1416 | |||
| 1417 | test "parseBindAddr: a hostname is refused, not resolved" { | 1352 | test "parseBindAddr: a hostname is refused, not resolved" { |
| 1418 | const a = try parseBindAddr("127.0.0.1:4433"); | 1353 | const a = try parseBindAddr("127.0.0.1:4433"); |
| 1419 | try std.testing.expectEqual(@as(u16, 4433), a.getPort()); | 1354 | try std.testing.expectEqual(@as(u16, 4433), a.getPort()); |
src/quic.zig
| Old | New | ||
|---|---|---|---|
| @@ -73,35 +73,45 @@ pub const alpn = "\x03mux"; | |||
| 73 | // The dial address grammar | 73 | // The dial address grammar |
| 74 | // --------------------------------------------------------------------------- | 74 | // --------------------------------------------------------------------------- |
| 75 | 75 | ||
| 76 | /// `HOST[:PORT]`, as a client types it: `mux quic://HOST:PORT` and | 76 | /// A name is resolved rather than refused: `--quic` names an address to |
| 77 | /// `mux a --quic HOST:PORT` accept exactly these spellings, brackets and | 77 | /// BIND, this names a box to reach, and a box is normally spelled with a |
| 78 | /// all. One owner for the same reason `default_port` has one — an agent | 78 | /// name. |
| 79 | /// and a human pointing at the same daemon must be able to type the same | ||
| 80 | /// thing, and two copies of a grammar drift into two dialects of one flag. | ||
| 81 | /// | ||
| 82 | /// A name is resolved rather than refused: unlike the daemon's `--quic`, which | ||
| 83 | /// names an address to BIND, this one names a box to reach, and a box is | ||
| 84 | /// normally spelled with a name. | ||
| 85 | pub fn parseAddr(alloc: std.mem.Allocator, host_port: []const u8) !std.net.Address { | 79 | pub fn parseAddr(alloc: std.mem.Allocator, host_port: []const u8) !std.net.Address { |
| 86 | // `[::1]` — bracketed and portless: the brackets say where the address | 80 | const hp = try splitHostPort(host_port); |
| 87 | // stops, so the port can default. | 81 | return resolveHost(alloc, hp.host, hp.port); |
| 88 | if (host_port.len >= 2 and host_port[0] == '[' and host_port[host_port.len - 1] == ']') | 82 | } |
| 89 | return resolveHost(alloc, host_port[1 .. host_port.len - 1], default_port); | 83 | |
| 90 | const colon = std.mem.lastIndexOfScalar(u8, host_port, ':') orelse | 84 | /// The grammar half, with nothing resolved: `HOST[:PORT]` as a client |
| 91 | return resolveHost(alloc, host_port, default_port); | 85 | /// types it, where an omitted port means `default_port` and an empty one |
| 92 | var host = host_port[0..colon]; | 86 | /// (`127.0.0.1:`) is still a mistake. `mux quic://HOST:PORT`, |
| 93 | const port_s = host_port[colon + 1 ..]; | 87 | /// `mux a --quic HOST:PORT` and the daemon's own `--quic` bind address all |
| 94 | // `[::1]:4433` — brackets are how an IPv6 literal says where it stops. | 88 | /// read exactly these spellings — an agent, a human and the box they point |
| 95 | if (host.len >= 2 and host[0] == '[' and host[host.len - 1] == ']') { | 89 | /// at must be able to type the same thing, and two copies of a grammar |
| 96 | host = host[1 .. host.len - 1]; | 90 | /// drift into two dialects of one flag. |
| 97 | } else if (std.mem.indexOfScalar(u8, host, ':') != null) { | 91 | pub fn splitHostPort(s: []const u8) !struct { host: []const u8, port: u16 } { |
| 98 | // Unbracketed and full of colons: an IPv6 literal missing its | 92 | if (s.len > 0 and s[0] == '[') { |
| 99 | // brackets, which would otherwise have its last group taken as a | 93 | // Bracketed: the brackets say where the address stops, so the port |
| 100 | // port. Refused rather than guessed at. | 94 | // can default — and anything but a `:` after them is a typo, not a |
| 101 | return error.MalformedAddress; | 95 | // spelling to guess at. |
| 96 | const close = std.mem.indexOfScalar(u8, s, ']') orelse return error.MalformedAddress; | ||
| 97 | if (close + 1 == s.len) return .{ .host = s[1..close], .port = default_port }; | ||
| 98 | if (s[close + 1] != ':') return error.MalformedAddress; | ||
| 99 | return .{ .host = s[1..close], .port = try parsePort(s[close + 2 ..]) }; | ||
| 102 | } | 100 | } |
| 103 | const port = std.fmt.parseInt(u16, port_s, 10) catch return error.MalformedAddress; | 101 | const colon = std.mem.lastIndexOfScalar(u8, s, ':') orelse |
| 104 | return resolveHost(alloc, host, port); | 102 | return .{ .host = s, .port = default_port }; |
| 103 | // An unbracketed IPv6 literal carries colons of its own, and splitting | ||
| 104 | // on the last one would quietly take its final group as a port: | ||
| 105 | // `fe80::1:4433` reads equally well as host `fe80::1` port 4433 and as | ||
| 106 | // host `fe80::1:4433` with the port left off. Brackets are how that | ||
| 107 | // ambiguity is spelled out, so without them it is refused rather than | ||
| 108 | // guessed at. | ||
| 109 | if (std.mem.indexOfScalar(u8, s[0..colon], ':') != null) return error.MalformedAddress; | ||
| 110 | return .{ .host = s[0..colon], .port = try parsePort(s[colon + 1 ..]) }; | ||
| 111 | } | ||
| 112 | |||
| 113 | fn parsePort(s: []const u8) !u16 { | ||
| 114 | return std.fmt.parseInt(u16, s, 10) catch error.MalformedAddress; | ||
| 105 | } | 115 | } |
| 106 | 116 | ||
| 107 | /// A host that is already known to be unambiguous, plus the port it goes | 117 | /// A host that is already known to be unambiguous, plus the port it goes |
| @@ -116,6 +126,45 @@ pub fn resolveHost(alloc: std.mem.Allocator, host: []const u8, port: u16) !std.n | |||
| 116 | return list.addrs[0]; | 126 | return list.addrs[0]; |
| 117 | } | 127 | } |
| 118 | 128 | ||
| 129 | test "splitHostPort: literal addresses, bracketed and not" { | ||
| 130 | const v4 = try splitHostPort("127.0.0.1:4433"); | ||
| 131 | try std.testing.expectEqualStrings("127.0.0.1", v4.host); | ||
| 132 | try std.testing.expectEqual(@as(u16, 4433), v4.port); | ||
| 133 | |||
| 134 | const v6 = try splitHostPort("[::1]:4433"); | ||
| 135 | try std.testing.expectEqualStrings("::1", v6.host); | ||
| 136 | try std.testing.expectEqual(@as(u16, 4433), v6.port); | ||
| 137 | |||
| 138 | const any6 = try splitHostPort("[::]:1"); | ||
| 139 | try std.testing.expectEqualStrings("::", any6.host); | ||
| 140 | try std.testing.expectEqual(@as(u16, 1), any6.port); | ||
| 141 | |||
| 142 | // No port names the default. 4433 is mux's convention; an explicit | ||
| 143 | // port always wins. | ||
| 144 | // | ||
| 145 | // The number is spelled out rather than written `quic.default_port`: | ||
| 146 | // comparing the parse's answer against the same constant the parse | ||
| 147 | // reads holds for ANY value, so it would pin the wiring and say | ||
| 148 | // nothing about the port — and 4433 is the half both ends of a | ||
| 149 | // connection have to agree on. | ||
| 150 | const dflt = try splitHostPort("127.0.0.1"); | ||
| 151 | try std.testing.expectEqualStrings("127.0.0.1", dflt.host); | ||
| 152 | try std.testing.expectEqual(@as(u16, 4433), dflt.port); | ||
| 153 | |||
| 154 | const dflt6 = try splitHostPort("[::1]"); | ||
| 155 | try std.testing.expectEqualStrings("::1", dflt6.host); | ||
| 156 | try std.testing.expectEqual(@as(u16, 4433), dflt6.port); | ||
| 157 | |||
| 158 | try std.testing.expectError(error.MalformedAddress, splitHostPort("127.0.0.1:")); | ||
| 159 | try std.testing.expectError(error.MalformedAddress, splitHostPort("127.0.0.1:99999")); | ||
| 160 | try std.testing.expectError(error.MalformedAddress, splitHostPort("[::1]4433")); | ||
| 161 | |||
| 162 | // An IPv6 literal without brackets is ambiguous about where the address | ||
| 163 | // stops, so it is refused instead of being read either way. | ||
| 164 | try std.testing.expectError(error.MalformedAddress, splitHostPort("::1:4433")); | ||
| 165 | try std.testing.expectError(error.MalformedAddress, splitHostPort("fe80::1:4433")); | ||
| 166 | } | ||
| 167 | |||
| 119 | test "parseAddr: literals, brackets, and the spellings that are refused" { | 168 | test "parseAddr: literals, brackets, and the spellings that are refused" { |
| 120 | const alloc = std.testing.allocator; | 169 | const alloc = std.testing.allocator; |
| 121 | // Literals only here: a name would send this test to a resolver, and | 170 | // Literals only here: a name would send this test to a resolver, and |
| @@ -142,7 +191,7 @@ test "parseAddr: literals, brackets, and the spellings that are refused" { | |||
| 142 | 191 | ||
| 143 | // An unbracketed IPv6 literal would have its last group read as a | 192 | // An unbracketed IPv6 literal would have its last group read as a |
| 144 | // port. Refused rather than guessed at — the same refusal the daemon's | 193 | // port. Refused rather than guessed at — the same refusal the daemon's |
| 145 | // splitHostPort makes about its bind address. | 194 | // `parseBindAddr` inherits from the shared split. |
| 146 | try std.testing.expectError(error.MalformedAddress, parseAddr(alloc, "fe80::1:4433")); | 195 | try std.testing.expectError(error.MalformedAddress, parseAddr(alloc, "fe80::1:4433")); |
| 147 | try std.testing.expectError(error.MalformedAddress, parseAddr(alloc, "127.0.0.1:")); | 196 | try std.testing.expectError(error.MalformedAddress, parseAddr(alloc, "127.0.0.1:")); |
| 148 | try std.testing.expectError(error.MalformedAddress, parseAddr(alloc, "127.0.0.1:99999")); | 197 | try std.testing.expectError(error.MalformedAddress, parseAddr(alloc, "127.0.0.1:99999")); |