a73x

1f7a9934

refactor: one owner for the `HOST[:PORT]` grammar

a73x   2026-08-29 10:01

Commit message
refactor: one owner for the `HOST[:PORT]` grammar

`main.splitHostPort` was `quic.parseAddr`'s split half written a second
time: same brackets, same default port, same refusal of an unbracketed
IPv6 literal. The split moves to `quic.splitHostPort` and both callers
are now split + one last step — `resolveHost` for a dial, `parseIp` for
the daemon's bind.

Main's reading was the stricter of the two and it is the one that
survived: `[HOST]x` (a bracket group followed by anything but `:`) is a
typo, where parseAddr used to fall through to the resolver with the
brackets still attached. No message changes — `mux quic://[1.2.3.4]x`
printed "cannot resolve" before and prints it now — and quic.zig's own
`parseAddr` test passes unaltered.

The no-DNS policy stays in main.zig where it belongs: `parseBindAddr`
still ends in `std.net.Address.parseIp`, and its test "a hostname is
refused, not resolved" stays there to say so. Main's "splitHostPort:
literal addresses, bracketed and not" moved to quic.zig with its subject,
unchanged — it is the only test that pins `[::1]4433` and `::1:4433`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017wi2HnuF1EK8HgViU11YLV

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"));