c518814b
feat: cliflags lets a field type parse itself; idle-ms and session names do
a73x 2026-08-27 07:49
Commit message
src/cli/flags.zig
| Old | New | ||
|---|---|---|---|
| @@ -3,7 +3,8 @@ | |||
| 3 | //! a flag and there is no second list to keep in step. The grammar is | 3 | //! a flag and there is no second list to keep in step. The grammar is |
| 4 | //! `--flag VALUE`, space-separated, with a bare `--` ending the flags and | 4 | //! `--flag VALUE`, space-separated, with a bare `--` ending the flags and |
| 5 | //! everything after it payload. What a flag MEANS stays with the caller, in | 5 | //! everything after it payload. What a flag MEANS stays with the caller, in |
| 6 | //! post-checks over the parsed struct. | 6 | //! post-checks over the parsed struct, or in a field TYPE that declares |
| 7 | //! `parseCLI` — a rule one type owns is a rule no new caller can forget. | ||
| 7 | const std = @import("std"); | 8 | const std = @import("std"); |
| 8 | 9 | ||
| 9 | pub const Outcome = union(enum) { | 10 | pub const Outcome = union(enum) { |
| @@ -17,8 +18,8 @@ pub const Outcome = union(enum) { | |||
| 17 | unknown_arg: []const u8, | 18 | unknown_arg: []const u8, |
| 18 | /// A value-taking flag at the end of argv, with nothing left to consume. | 19 | /// A value-taking flag at the end of argv, with nothing left to consume. |
| 19 | missing_value: []const u8, | 20 | missing_value: []const u8, |
| 20 | /// An integer field whose value did not parse. | 21 | /// The field's type would not hold the word after the flag. |
| 21 | bad_number: []const u8, | 22 | bad_value: []const u8, |
| 22 | }; | 23 | }; |
| 23 | 24 | ||
| 24 | /// An optional field is its child type: null is a default, not an arity. | 25 | /// An optional field is its child type: null is a default, not an arity. |
| @@ -39,7 +40,10 @@ pub fn parse(comptime T: type, dst: *T, args: []const [:0]const u8) Outcome { | |||
| 39 | comptime for (fields) |f| { | 40 | comptime for (fields) |f| { |
| 40 | if (f.name[0] == '_') continue; | 41 | if (f.name[0] == '_') continue; |
| 41 | const B = Bare(f.type); | 42 | const B = Bare(f.type); |
| 42 | if (B != bool and B != []const u8 and @typeInfo(B) != .int) | 43 | // A struct is an arity only through `parseCLI`: it takes one word |
| 44 | // like a string, and answers `error.Invalid` for what it cannot hold. | ||
| 45 | const owns = @typeInfo(B) == .@"struct" and @hasDecl(B, "parseCLI"); | ||
| 46 | if (B != bool and B != []const u8 and @typeInfo(B) != .int and !owns) | ||
| 43 | @compileError("cliflags: no arity for field `" ++ f.name ++ "`: " ++ @typeName(f.type)); | 47 | @compileError("cliflags: no arity for field `" ++ f.name ++ "`: " ++ @typeName(f.type)); |
| 44 | }; | 48 | }; |
| 45 | comptime { | 49 | comptime { |
| @@ -83,7 +87,8 @@ pub fn parse(comptime T: type, dst: *T, args: []const [:0]const u8) Outcome { | |||
| 83 | if (i + 1 >= args.len) return .{ .missing_value = a }; | 87 | if (i + 1 >= args.len) return .{ .missing_value = a }; |
| 84 | i += 1; | 88 | i += 1; |
| 85 | @field(dst, f.name) = switch (@typeInfo(B)) { | 89 | @field(dst, f.name) = switch (@typeInfo(B)) { |
| 86 | .int => std.fmt.parseInt(B, args[i], 10) catch return .{ .bad_number = a }, | 90 | .int => std.fmt.parseInt(B, args[i], 10) catch return .{ .bad_value = a }, |
| 91 | .@"struct" => B.parseCLI(args[i]) catch return .{ .bad_value = a }, | ||
| 87 | else => args[i], | 92 | else => args[i], |
| 88 | }; | 93 | }; |
| 89 | } | 94 | } |
| @@ -267,17 +272,17 @@ test "parse: a flag given twice, the last wins" { | |||
| 267 | try std.testing.expectEqual(@as(u16, 42), o.cols); | 272 | try std.testing.expectEqual(@as(u16, 42), o.cols); |
| 268 | } | 273 | } |
| 269 | 274 | ||
| 270 | test "parse: a non-number for an integer field is bad_number naming the flag" { | 275 | test "parse: a value the field's type refuses is bad_value naming the flag" { |
| 271 | var o: Demo = .{}; | 276 | var o: Demo = .{}; |
| 272 | const r = parse(Demo, &o, &.{ "--cols", "wide" }); | 277 | const r = parse(Demo, &o, &.{ "--cols", "wide" }); |
| 273 | try std.testing.expect(r == .bad_number); | 278 | try std.testing.expect(r == .bad_value); |
| 274 | try std.testing.expectEqualStrings("--cols", r.bad_number); | 279 | try std.testing.expectEqualStrings("--cols", r.bad_value); |
| 275 | 280 | ||
| 276 | // Out of the field's range, and negative into an unsigned, are the same | 281 | // Out of the field's range, and negative into an unsigned, are the same |
| 277 | // mistake: the flag cannot hold what was typed. | 282 | // mistake: the flag cannot hold what was typed. |
| 278 | var p: Demo = .{}; | 283 | var p: Demo = .{}; |
| 279 | try std.testing.expect(parse(Demo, &p, &.{ "--cols", "99999" }) == .bad_number); | 284 | try std.testing.expect(parse(Demo, &p, &.{ "--cols", "99999" }) == .bad_value); |
| 280 | try std.testing.expect(parse(Demo, &p, &.{ "--quic-idle-ms", "-5" }) == .bad_number); | 285 | try std.testing.expect(parse(Demo, &p, &.{ "--quic-idle-ms", "-5" }) == .bad_value); |
| 281 | } | 286 | } |
| 282 | 287 | ||
| 283 | test "flagName: underscores become dashes" { | 288 | test "flagName: underscores become dashes" { |
| @@ -449,3 +454,45 @@ test "assertDocumented: an alias documents its field" { | |||
| 449 | ; | 454 | ; |
| 450 | comptime assertDocumented(Positional, text, &.{}); | 455 | comptime assertDocumented(Positional, text, &.{}); |
| 451 | } | 456 | } |
| 457 | |||
| 458 | test "parse: a field type with parseCLI owns its value, and its refusal is bad_value" { | ||
| 459 | // Stands in for `quic.IdleMs`: a type that owns the rule for its own | ||
| 460 | // values, so the rule travels with the field instead of being copied | ||
| 461 | // into a post-check every caller can forget. | ||
| 462 | const Port = struct { | ||
| 463 | n: u16 = 8080, | ||
| 464 | |||
| 465 | pub fn parseCLI(s: []const u8) error{Invalid}!@This() { | ||
| 466 | const n = std.fmt.parseInt(u16, s, 10) catch return error.Invalid; | ||
| 467 | if (n == 0) return error.Invalid; | ||
| 468 | return .{ .n = n }; | ||
| 469 | } | ||
| 470 | }; | ||
| 471 | const Typed = struct { port: Port = .{}, alt: ?Port = null }; | ||
| 472 | |||
| 473 | var o: Typed = .{}; | ||
| 474 | try std.testing.expect(parse(Typed, &o, &.{ "--port", "9000", "--alt", "81" }) == .ok); | ||
| 475 | try std.testing.expectEqual(@as(u16, 9000), o.port.n); | ||
| 476 | try std.testing.expectEqual(@as(u16, 81), o.alt.?.n); | ||
| 477 | |||
| 478 | // Untouched, each field keeps the default its declaration gave it — the | ||
| 479 | // TYPE's for the plain one, null for the optional. | ||
| 480 | var d: Typed = .{}; | ||
| 481 | try std.testing.expect(parse(Typed, &d, &.{}) == .ok); | ||
| 482 | try std.testing.expectEqual(@as(u16, 8080), d.port.n); | ||
| 483 | try std.testing.expect(d.alt == null); | ||
| 484 | |||
| 485 | // 0 fits a u16 and the TYPE still refuses it: the rule being enforced is | ||
| 486 | // parseCLI's, not the integer parse's. The outcome names the FLAG, which | ||
| 487 | // is the half that says which word on the line was refused. | ||
| 488 | const r = parse(Typed, &d, &.{ "--port", "0" }); | ||
| 489 | try std.testing.expect(r == .bad_value); | ||
| 490 | try std.testing.expectEqualStrings("--port", r.bad_value); | ||
| 491 | try std.testing.expect(parse(Typed, &d, &.{ "--port", "wat" }) == .bad_value); | ||
| 492 | // Left at its default by a refused word, never half-set. | ||
| 493 | try std.testing.expectEqual(@as(u16, 8080), d.port.n); | ||
| 494 | |||
| 495 | // It takes one value the way a string field does, so at the end of argv | ||
| 496 | // it is missing_value and not a bare flag quietly set. | ||
| 497 | try std.testing.expect(parse(Typed, &d, &.{"--port"}) == .missing_value); | ||
| 498 | } | ||
src/cli/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -47,10 +47,6 @@ fn envKey() ?[]const u8 { | |||
| 47 | return if (v.len == 0) null else v; | 47 | return if (v.len == 0) null else v; |
| 48 | } | 48 | } |
| 49 | 49 | ||
| 50 | /// The daemon's name for the shared default; see `quic.default_idle_ms` | ||
| 51 | /// for what the number means and why it lives there. | ||
| 52 | const default_quic_idle_ms: u32 = quic.default_idle_ms; | ||
| 53 | |||
| 54 | const Cmd = enum { run, dump, stats, proxy, endpoint, version, help, keygen, start, stop, upgrade }; | 50 | const Cmd = enum { run, dump, stats, proxy, endpoint, version, help, keygen, start, stop, upgrade }; |
| 55 | 51 | ||
| 56 | /// One row per verb. Adding a subcommand used to mean editing the usage | 52 | /// One row per verb. Adding a subcommand used to mean editing the usage |
| @@ -152,13 +148,12 @@ const Opts = struct { | |||
| 152 | /// `--key` without `--quic` is settled here, because that one has no | 148 | /// `--key` without `--quic` is settled here, because that one has no |
| 153 | /// reading that makes it sensible. | 149 | /// reading that makes it sensible. |
| 154 | key: ?[]const u8 = null, | 150 | key: ?[]const u8 = null, |
| 155 | /// u32 rather than u64 so that an absurd value is a parse failure rather | 151 | /// The type refuses zero and anything wider than u32 — see `quic.IdleMs`. |
| 156 | /// than an overflow where it is multiplied out to nanoseconds. | 152 | quic_idle_ms: quic.IdleMs = .{}, |
| 157 | quic_idle_ms: u32 = default_quic_idle_ms, | ||
| 158 | /// The session `dump` names. Optional so that only a name that was TYPED | 153 | /// The session `dump` names. Optional so that only a name that was TYPED |
| 159 | /// is validated: `""` is the wire's own default spelling — no tail at | 154 | /// is offered to the type: `""` is the wire's own default spelling — no |
| 160 | /// all — and would pass a check written against the empty string. | 155 | /// tail at all — and would fail a rule written for a name a user typed. |
| 161 | session: ?[]const u8 = null, | 156 | session: ?proto.SessionName = null, |
| 162 | /// The inherited manifest descriptor an upgrade exec'd us with. Not a | 157 | /// The inherited manifest descriptor an upgrade exec'd us with. Not a |
| 163 | /// user flag: the old daemon writes it into our argv. Its presence is | 158 | /// user flag: the old daemon writes it into our argv. Its presence is |
| 164 | /// what makes `run` an ADOPTION rather than a start, and it is also | 159 | /// what makes `run` an ADOPTION rather than a start, and it is also |
| @@ -196,12 +191,9 @@ const Usage = union(enum) { | |||
| 196 | unknown_arg: []const u8, | 191 | unknown_arg: []const u8, |
| 197 | /// A flag at the end of argv with nothing left to consume. | 192 | /// A flag at the end of argv with nothing left to consume. |
| 198 | missing_value: []const u8, | 193 | missing_value: []const u8, |
| 199 | /// The flag whose value would not parse as the number it wants. | 194 | /// The flag whose value its own type would not hold. |
| 200 | bad_number: []const u8, | 195 | bad_value: []const u8, |
| 201 | key_without_quic, | 196 | key_without_quic, |
| 202 | /// The name itself, not the flag: `muxd: bad session name: {s}` names | ||
| 203 | /// what was typed, which is the actionable half. | ||
| 204 | bad_session_name: []const u8, | ||
| 205 | }; | 197 | }; |
| 206 | 198 | ||
| 207 | const ParseResult = union(enum) { ok: Opts, err: Usage }; | 199 | const ParseResult = union(enum) { ok: Opts, err: Usage }; |
| @@ -228,20 +220,9 @@ fn parseArgs(args: []const [:0]const u8) ParseResult { | |||
| 228 | .version => return .{ .ok = .{ ._cmd = .version } }, | 220 | .version => return .{ .ok = .{ ._cmd = .version } }, |
| 229 | .unknown_arg => |a| return .{ .err = .{ .unknown_arg = a } }, | 221 | .unknown_arg => |a| return .{ .err = .{ .unknown_arg = a } }, |
| 230 | .missing_value => |f| return .{ .err = .{ .missing_value = f } }, | 222 | .missing_value => |f| return .{ .err = .{ .missing_value = f } }, |
| 231 | .bad_number => |f| return .{ .err = .{ .bad_number = f } }, | 223 | .bad_value => |f| return .{ .err = .{ .bad_value = f } }, |
| 232 | } | ||
| 233 | |||
| 234 | // Refused here rather than carried to the wire as a payload nothing could | ||
| 235 | // ever look up — the same "check before it becomes a frame" the socket | ||
| 236 | // path length guard follows. | ||
| 237 | if (o.session) |name| { | ||
| 238 | if (!proto.validSessionName(name)) return .{ .err = .{ .bad_session_name = name } }; | ||
| 239 | } | 224 | } |
| 240 | 225 | ||
| 241 | // Zero is refused because ngtcp2 reads it as "no idle timeout", the | ||
| 242 | // opposite of what the flag says. | ||
| 243 | if (o.quic_idle_ms == 0) return .{ .err = .{ .bad_number = "--quic-idle-ms" } }; | ||
| 244 | |||
| 245 | // A key with nowhere to listen is a mistake parse can see the whole of. | 226 | // A key with nowhere to listen is a mistake parse can see the whole of. |
| 246 | // The mirror case is NOT one: `--quic` with no `--key` may still be | 227 | // The mirror case is NOT one: `--quic` with no `--key` may still be |
| 247 | // answered by MUX_KEY_FILE or the default key path, neither of which | 228 | // answered by MUX_KEY_FILE or the default key path, neither of which |
| @@ -263,12 +244,11 @@ fn usageExit(u: Usage) u8 { | |||
| 263 | .unknown_command => std.debug.print("{s}", .{usage}), | 244 | .unknown_command => std.debug.print("{s}", .{usage}), |
| 264 | .unknown_arg => |a| std.debug.print("unknown argument: {s}\n{s}", .{ a, usage }), | 245 | .unknown_arg => |a| std.debug.print("unknown argument: {s}\n{s}", .{ a, usage }), |
| 265 | .missing_value => |f| std.debug.print("muxd: {s} needs a value\n{s}", .{ f, usage }), | 246 | .missing_value => |f| std.debug.print("muxd: {s} needs a value\n{s}", .{ f, usage }), |
| 266 | .bad_number => |f| std.debug.print("muxd: {s} needs a positive number\n{s}", .{ f, usage }), | 247 | .bad_value => |f| std.debug.print("muxd: {s} was given a value it cannot hold\n{s}", .{ f, usage }), |
| 267 | .key_without_quic => std.debug.print( | 248 | .key_without_quic => std.debug.print( |
| 268 | "muxd: --key without --quic has nothing to listen on; name both or neither\n", | 249 | "muxd: --key without --quic has nothing to listen on; name both or neither\n", |
| 269 | .{}, | 250 | .{}, |
| 270 | ), | 251 | ), |
| 271 | .bad_session_name => |n| std.debug.print("muxd: bad session name: {s}\n{s}", .{ n, usage }), | ||
| 272 | } | 252 | } |
| 273 | return usageCode(u); | 253 | return usageCode(u); |
| 274 | } | 254 | } |
| @@ -375,7 +355,7 @@ pub fn main() !u8 { | |||
| 375 | .keygen => return keygen(alloc), | 355 | .keygen => return keygen(alloc), |
| 376 | .start => return startCmd(alloc, sock_path, args[2..]), | 356 | .start => return startCmd(alloc, sock_path, args[2..]), |
| 377 | .run => return if (o.resume_fd) |fd| resumeRun(alloc, o, fd) else run(alloc, o, sock_path), | 357 | .run => return if (o.resume_fd) |fd| resumeRun(alloc, o, fd) else run(alloc, o, sock_path), |
| 378 | .dump => return dump(alloc, sock_path, o.vt, o.session orelse ""), | 358 | .dump => return dump(alloc, sock_path, o.vt, if (o.session) |n| n.name else ""), |
| 379 | .stats => return stats(alloc, sock_path), | 359 | .stats => return stats(alloc, sock_path), |
| 380 | .stop => return stopCmd(alloc, sock_path), | 360 | .stop => return stopCmd(alloc, sock_path), |
| 381 | .upgrade => return upgradeCmd(alloc, sock_path, o.allow_same_version), | 361 | .upgrade => return upgradeCmd(alloc, sock_path, o.allow_same_version), |
| @@ -620,7 +600,7 @@ fn run(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8) !u8 { | |||
| 620 | // syscall further along. | 600 | // syscall further along. |
| 621 | var listener: ?*quic_server.Listener = null; | 601 | var listener: ?*quic_server.Listener = null; |
| 622 | if (quic_bind) |addr| { | 602 | if (quic_bind) |addr| { |
| 623 | listener = quic_server.Listener.bind(alloc, addr, quic_key, o.quic_idle_ms) catch |err| switch (err) { | 603 | listener = quic_server.Listener.bind(alloc, addr, quic_key, o.quic_idle_ms.ms) catch |err| switch (err) { |
| 624 | // The QUIC edition of "a daemon is already running", refused for | 604 | // The QUIC edition of "a daemon is already running", refused for |
| 625 | // the same reason: the listener sets no SO_REUSEADDR, so rather | 605 | // the same reason: the listener sets no SO_REUSEADDR, so rather |
| 626 | // than silently splitting a port's datagrams with the daemon | 606 | // than silently splitting a port's datagrams with the daemon |
| @@ -1299,13 +1279,15 @@ test "parseArgs: subcommands and their existing flags" { | |||
| 1299 | test "parse: dump --session rides into the payload" { | 1279 | test "parse: dump --session rides into the payload" { |
| 1300 | const d = parse(&.{ "muxd", "dump", "--session", "b", "--sock", "/tmp/x.sock" }); | 1280 | const d = parse(&.{ "muxd", "dump", "--session", "b", "--sock", "/tmp/x.sock" }); |
| 1301 | try std.testing.expect(d == .ok); | 1281 | try std.testing.expect(d == .ok); |
| 1302 | try std.testing.expectEqualStrings("b", d.ok.session.?); | 1282 | try std.testing.expectEqualStrings("b", d.ok.session.?.name); |
| 1303 | 1283 | ||
| 1304 | // A name no tool could ever address is refused at parse — usage on | 1284 | // A name no tool could ever address is refused at parse — usage on |
| 1305 | // stderr, never carried to the wire as a payload nothing can look up. | 1285 | // stderr, never carried to the wire as a payload nothing can look up. |
| 1286 | // The flag is what the refusal names now, so a user is told which of | ||
| 1287 | // several values on the line was the one refused. | ||
| 1306 | const bad = parse(&.{ "muxd", "dump", "--session", "has space" }); | 1288 | const bad = parse(&.{ "muxd", "dump", "--session", "has space" }); |
| 1307 | try std.testing.expect(bad.err == .bad_session_name); | 1289 | try std.testing.expect(bad.err == .bad_value); |
| 1308 | try std.testing.expectEqualStrings("has space", bad.err.bad_session_name); | 1290 | try std.testing.expectEqualStrings("--session", bad.err.bad_value); |
| 1309 | } | 1291 | } |
| 1310 | 1292 | ||
| 1311 | test "parseArgs: --key without --quic is refused; --quic alone defers to main" { | 1293 | test "parseArgs: --key without --quic is refused; --quic alone defers to main" { |
| @@ -1332,30 +1314,30 @@ test "parseArgs: --key without --quic is refused; --quic alone defers to main" { | |||
| 1332 | 1314 | ||
| 1333 | test "parseArgs: --quic-idle-ms defaults, parses, and refuses nonsense" { | 1315 | test "parseArgs: --quic-idle-ms defaults, parses, and refuses nonsense" { |
| 1334 | const dflt = parse(&.{ "muxd", "run", "--quic", "127.0.0.1:1", "--key", "/k" }); | 1316 | const dflt = parse(&.{ "muxd", "run", "--quic", "127.0.0.1:1", "--key", "/k" }); |
| 1335 | // Spelled out rather than written `default_quic_idle_ms`: asserting | 1317 | // Spelled out rather than written `quic.default_idle_ms`: asserting |
| 1336 | // against the same constant the parser reads would hold for any value, | 1318 | // against the same constant the parser reads would hold for any value, |
| 1337 | // so it could never catch the number changing. | 1319 | // so it could never catch the number changing. |
| 1338 | try std.testing.expectEqual(@as(u32, 15_000), dflt.ok.quic_idle_ms); | 1320 | try std.testing.expectEqual(@as(u32, 15_000), dflt.ok.quic_idle_ms.ms); |
| 1339 | 1321 | ||
| 1340 | const set = parse(&.{ "muxd", "run", "--quic", "127.0.0.1:1", "--key", "/k", "--quic-idle-ms", "2500" }); | 1322 | const set = parse(&.{ "muxd", "run", "--quic", "127.0.0.1:1", "--key", "/k", "--quic-idle-ms", "2500" }); |
| 1341 | try std.testing.expectEqual(@as(u32, 2500), set.ok.quic_idle_ms); | 1323 | try std.testing.expectEqual(@as(u32, 2500), set.ok.quic_idle_ms.ms); |
| 1342 | 1324 | ||
| 1343 | // Zero means "no idle timeout" to ngtcp2 — the opposite of what anyone | 1325 | // Zero means "no idle timeout" to ngtcp2 — the opposite of what anyone |
| 1344 | // typing a timeout of zero is asking for, so it is refused rather than | 1326 | // typing a timeout of zero is asking for, so it is refused rather than |
| 1345 | // silently inverted. | 1327 | // silently inverted. |
| 1346 | try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "0" }).err == .bad_number); | 1328 | try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "0" }).err == .bad_value); |
| 1347 | try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "soon" }).err == .bad_number); | 1329 | try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "soon" }).err == .bad_value); |
| 1348 | try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "-5" }).err == .bad_number); | 1330 | try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "-5" }).err == .bad_value); |
| 1349 | // Wider than u32: refused at the parse rather than overflowing where it | 1331 | // Wider than u32: refused at the parse rather than overflowing where it |
| 1350 | // is multiplied out to nanoseconds. | 1332 | // is multiplied out to nanoseconds. |
| 1351 | try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "99999999999" }).err == .bad_number); | 1333 | try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "99999999999" }).err == .bad_value); |
| 1352 | // The idle flag alone does not turn QUIC on, and must not smuggle the | 1334 | // The idle flag alone does not turn QUIC on, and must not smuggle the |
| 1353 | // both-or-neither rule past the check. | 1335 | // both-or-neither rule past the check. |
| 1354 | try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "2500" }) == .ok); | 1336 | try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "2500" }) == .ok); |
| 1355 | 1337 | ||
| 1356 | // Same treatment for the numbers that were already here. | 1338 | // Same treatment for the numbers that were already here. |
| 1357 | try std.testing.expect(parse(&.{ "muxd", "run", "--cols", "wide" }).err == .bad_number); | 1339 | try std.testing.expect(parse(&.{ "muxd", "run", "--cols", "wide" }).err == .bad_value); |
| 1358 | try std.testing.expect(parse(&.{ "muxd", "run", "--rows", "99999" }).err == .bad_number); | 1340 | try std.testing.expect(parse(&.{ "muxd", "run", "--rows", "99999" }).err == .bad_value); |
| 1359 | } | 1341 | } |
| 1360 | 1342 | ||
| 1361 | test "parseArgs: a value-taking flag at the end of argv names itself" { | 1343 | test "parseArgs: a value-taking flag at the end of argv names itself" { |
| @@ -1556,7 +1538,7 @@ test "parseArgs: run --resume-fd N --check is the old daemon's dry run" { | |||
| 1556 | 1538 | ||
| 1557 | // A number, like --cols: an fd that is not one would be read as a | 1539 | // A number, like --cols: an fd that is not one would be read as a |
| 1558 | // descriptor the daemon never passed. | 1540 | // descriptor the daemon never passed. |
| 1559 | try std.testing.expect(parse(&.{ "muxd", "run", "--resume-fd", "x" }).err == .bad_number); | 1541 | try std.testing.expect(parse(&.{ "muxd", "run", "--resume-fd", "x" }).err == .bad_value); |
| 1560 | 1542 | ||
| 1561 | const f = parse(&.{ "muxd", "run", "--resume-fd", "3", "--resume-fail-at", "session" }); | 1543 | const f = parse(&.{ "muxd", "run", "--resume-fd", "3", "--resume-fail-at", "session" }); |
| 1562 | try std.testing.expectEqualStrings("session", f.ok.resume_fail_at.?); | 1544 | try std.testing.expectEqualStrings("session", f.ok.resume_fail_at.?); |
src/cli/mux_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -183,10 +183,11 @@ const Opts = struct { | |||
| 183 | sock: ?[]const u8 = null, | 183 | sock: ?[]const u8 = null, |
| 184 | via: ?[]const u8 = null, | 184 | via: ?[]const u8 = null, |
| 185 | key: ?[]const u8 = null, | 185 | key: ?[]const u8 = null, |
| 186 | /// Optional so that only a name that was TYPED is validated: `""` is the | 186 | /// Optional so that only a name that was TYPED reaches the type: `""` is |
| 187 | /// wire's own default spelling and would fail a check written for a name. | 187 | /// the wire's own default spelling and would fail a rule written for a |
| 188 | session: ?[]const u8 = null, | 188 | /// name a user typed. |
| 189 | quic_idle_ms: u32 = client.quic_idle_ms_default, | 189 | session: ?proto.SessionName = null, |
| 190 | quic_idle_ms: client.IdleMs = .{}, | ||
| 190 | agent: bool = false, | 191 | agent: bool = false, |
| 191 | /// The three below are not flags, and the leading underscore is what | 192 | /// The three below are not flags, and the leading underscore is what |
| 192 | /// says so: they are what `positional` saw. | 193 | /// says so: they are what `positional` saw. |
| @@ -224,22 +225,11 @@ fn parseArgs(args: []const [:0]const u8, env_key: ?[]const u8) ParseResult { | |||
| 224 | .ok => {}, | 225 | .ok => {}, |
| 225 | .help => return .help, | 226 | .help => return .help, |
| 226 | .version => return .version, | 227 | .version => return .version, |
| 227 | .unknown_arg, .missing_value, .bad_number => return .usage_error, | 228 | .unknown_arg, .missing_value, .bad_value => return .usage_error, |
| 228 | } | 229 | } |
| 229 | 230 | ||
| 230 | if (o._conflict) return .conflict; | 231 | if (o._conflict) return .conflict; |
| 231 | 232 | ||
| 232 | // A name that cannot be spelled must not become wire bytes: caught here, | ||
| 233 | // at usage-error altitude, rather than downstream where it would look | ||
| 234 | // like a rejected attach. | ||
| 235 | if (o.session) |name| { | ||
| 236 | if (!proto.validSessionName(name)) return .usage_error; | ||
| 237 | } | ||
| 238 | |||
| 239 | // Zero means "no idle timeout" to ngtcp2, the inverse of what anyone | ||
| 240 | // typing a timeout of zero is asking for. | ||
| 241 | if (o.quic_idle_ms == 0) return .usage_error; | ||
| 242 | |||
| 243 | // Every pairing of the four is two transports for one session. | 233 | // Every pairing of the four is two transports for one session. |
| 244 | const named: u8 = @as(u8, @intFromBool(o.sock != null)) + | 234 | const named: u8 = @as(u8, @intFromBool(o.sock != null)) + |
| 245 | @intFromBool(o.via != null) + @intFromBool(o._host != null) + | 235 | @intFromBool(o.via != null) + @intFromBool(o._host != null) + |
| @@ -251,7 +241,7 @@ fn parseArgs(args: []const [:0]const u8, env_key: ?[]const u8) ParseResult { | |||
| 251 | // escape hatch — it applies whichever spelling wins. So does `agent`, | 241 | // escape hatch — it applies whichever spelling wins. So does `agent`, |
| 252 | // and for the same reason: an offer to answer for this client's agent is | 242 | // and for the same reason: an offer to answer for this client's agent is |
| 253 | // about the client, not the wire it reached the daemon over. | 243 | // about the client, not the wire it reached the daemon over. |
| 254 | const session = o.session orelse ""; | 244 | const session = if (o.session) |n| n.name else ""; |
| 255 | 245 | ||
| 256 | if (o._quic) |host_port| { | 246 | if (o._quic) |host_port| { |
| 257 | // Neither spelling of the key being set is not a refusal: main has a | 247 | // Neither spelling of the key being set is not a refusal: main has a |
| @@ -259,7 +249,7 @@ fn parseArgs(args: []const [:0]const u8, env_key: ?[]const u8) ParseResult { | |||
| 259 | return .{ .quic = .{ | 249 | return .{ .quic = .{ |
| 260 | .host_port = host_port, | 250 | .host_port = host_port, |
| 261 | .key = xdg.pickKey(o.key, env_key), | 251 | .key = xdg.pickKey(o.key, env_key), |
| 262 | .idle_ms = o.quic_idle_ms, | 252 | .idle_ms = o.quic_idle_ms.ms, |
| 263 | .session = session, | 253 | .session = session, |
| 264 | .agent = o.agent, | 254 | .agent = o.agent, |
| 265 | } }; | 255 | } }; |
| @@ -269,7 +259,7 @@ fn parseArgs(args: []const [:0]const u8, env_key: ?[]const u8) ParseResult { | |||
| 269 | // listener was meant, here it is one env var away from being set for | 259 | // listener was meant, here it is one env var away from being set for |
| 270 | // every invocation in a shell, and refusing `mux --sock ...` because | 260 | // every invocation in a shell, and refusing `mux --sock ...` because |
| 271 | // MUX_KEY_FILE happens to be exported would be absurd. | 261 | // MUX_KEY_FILE happens to be exported would be absurd. |
| 272 | if (o._host) |h| return .{ .host = .{ .name = h, .idle_ms = o.quic_idle_ms, .session = session, .agent = o.agent } }; | 262 | if (o._host) |h| return .{ .host = .{ .name = h, .idle_ms = o.quic_idle_ms.ms, .session = session, .agent = o.agent } }; |
| 273 | return .{ .attach = .{ .sock = o.sock, .via = o.via, .session = session, .agent = o.agent } }; | 263 | return .{ .attach = .{ .sock = o.sock, .via = o.via, .session = session, .agent = o.agent } }; |
| 274 | } | 264 | } |
| 275 | 265 | ||
| @@ -466,7 +456,7 @@ pub fn main() !u8 { | |||
| 466 | /// documents it here. | 456 | /// documents it here. |
| 467 | const WallOpts = struct { | 457 | const WallOpts = struct { |
| 468 | key: ?[]const u8 = null, | 458 | key: ?[]const u8 = null, |
| 469 | quic_idle_ms: u32 = client.quic_idle_ms_default, | 459 | quic_idle_ms: client.IdleMs = .{}, |
| 470 | _argv: wall.Argv, | 460 | _argv: wall.Argv, |
| 471 | 461 | ||
| 472 | pub fn positional(self: *WallOpts, w: []const u8) bool { | 462 | pub fn positional(self: *WallOpts, w: []const u8) bool { |
| @@ -515,7 +505,7 @@ fn wallMain(alloc: std.mem.Allocator, args: []const [:0]const u8) !u8 { | |||
| 515 | .ok => {}, | 505 | .ok => {}, |
| 516 | .help => return cliflags.help(usage), | 506 | .help => return cliflags.help(usage), |
| 517 | .version => return cliflags.version("mux", build_options.version), | 507 | .version => return cliflags.version("mux", build_options.version), |
| 518 | .missing_value, .bad_number => { | 508 | .missing_value, .bad_value => { |
| 519 | std.debug.print("{s}", .{usage}); | 509 | std.debug.print("{s}", .{usage}); |
| 520 | return 2; | 510 | return 2; |
| 521 | }, | 511 | }, |
| @@ -524,14 +514,8 @@ fn wallMain(alloc: std.mem.Allocator, args: []const [:0]const u8) !u8 { | |||
| 524 | return 2; | 514 | return 2; |
| 525 | }, | 515 | }, |
| 526 | } | 516 | } |
| 527 | // Zero means "no idle timeout" to ngtcp2, the inverse of what anyone | ||
| 528 | // typing a timeout of zero is asking for. | ||
| 529 | if (w_opts.quic_idle_ms == 0) { | ||
| 530 | std.debug.print("{s}", .{usage}); | ||
| 531 | return 2; | ||
| 532 | } | ||
| 533 | const key = w_opts.key; | 517 | const key = w_opts.key; |
| 534 | const idle_ms = w_opts.quic_idle_ms; | 518 | const idle_ms = w_opts.quic_idle_ms.ms; |
| 535 | var spellings = w_opts._argv.tiles; | 519 | var spellings = w_opts._argv.tiles; |
| 536 | 520 | ||
| 537 | var from_file = false; | 521 | var from_file = false; |
src/cli/muxa.zig
| Old | New | ||
|---|---|---|---|
| @@ -57,10 +57,10 @@ const Opts = struct { | |||
| 57 | // would turn every await into an unbounded wait. | 57 | // would turn every await into an unbounded wait. |
| 58 | timeout: u32 = 30_000, | 58 | timeout: u32 = 30_000, |
| 59 | vt: bool = false, | 59 | vt: bool = false, |
| 60 | /// Optional so that only a name that was TYPED is validated: `""` is | 60 | /// Optional so that only a name that was TYPED reaches the type: `""` |
| 61 | /// the wire's own default spelling and would fail a check written for | 61 | /// is the wire's own default spelling and would fail a rule written |
| 62 | /// a name. `sessionName` is what the frames actually carry. | 62 | /// for a name. `sessionName` is what the frames actually carry. |
| 63 | session: ?[]const u8 = null, | 63 | session: ?proto.SessionName = null, |
| 64 | /// Null until `parseArgs` reads argv[1]; a parse that returned an Opts | 64 | /// Null until `parseArgs` reads argv[1]; a parse that returned an Opts |
| 65 | /// has one. | 65 | /// has one. |
| 66 | _verb: ?Verb = null, | 66 | _verb: ?Verb = null, |
| @@ -71,7 +71,7 @@ const Opts = struct { | |||
| 71 | // attached-tail equality rule (server.zig) never sees a mismatch | 71 | // attached-tail equality rule (server.zig) never sees a mismatch |
| 72 | // out of this binary. Empty is the wire's own default spelling, so | 72 | // out of this binary. Empty is the wire's own default spelling, so |
| 73 | // a bare `muxa status` builds the frames it always did. | 73 | // a bare `muxa status` builds the frames it always did. |
| 74 | return o.session orelse ""; | 74 | return if (o.session) |n| n.name else ""; |
| 75 | } | 75 | } |
| 76 | 76 | ||
| 77 | /// The verb's argument. A second is a mistake: no verb here takes two. | 77 | /// The verb's argument. A second is a mistake: no verb here takes two. |
| @@ -106,14 +106,9 @@ fn parseArgs(args: []const [:0]const u8) ParseError!Opts { | |||
| 106 | .ok => {}, | 106 | .ok => {}, |
| 107 | .help => return error.Help, | 107 | .help => return error.Help, |
| 108 | .version => return error.Version, | 108 | .version => return error.Version, |
| 109 | .unknown_arg, .missing_value, .bad_number => return error.Usage, | 109 | .unknown_arg, .missing_value, .bad_value => return error.Usage, |
| 110 | } | 110 | } |
| 111 | 111 | ||
| 112 | // Refused here rather than carried to the wire as a payload nothing | ||
| 113 | // could ever look up: usage exit (2), not a frame. | ||
| 114 | if (o.session) |name| { | ||
| 115 | if (!proto.validSessionName(name)) return error.Usage; | ||
| 116 | } | ||
| 117 | // Name ONE transport. A `--sock` silently ignored beside a `--quic` | 112 | // Name ONE transport. A `--sock` silently ignored beside a `--quic` |
| 118 | // would send an agent's frames somewhere other than the socket it | 113 | // would send an agent's frames somewhere other than the socket it |
| 119 | // named, and the two answers differ — this is the mistake `mux` | 114 | // named, and the two answers differ — this is the mistake `mux` |
src/cli/webhub_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -49,7 +49,7 @@ const usage = | |||
| 49 | const Parsed = struct { | 49 | const Parsed = struct { |
| 50 | port: u16 = webhub.default_port, | 50 | port: u16 = webhub.default_port, |
| 51 | key: ?[]const u8 = null, | 51 | key: ?[]const u8 = null, |
| 52 | quic_idle_ms: u32 = client.quic_idle_ms_default, | 52 | quic_idle_ms: client.IdleMs = .{}, |
| 53 | _argv: wall.Argv, | 53 | _argv: wall.Argv, |
| 54 | 54 | ||
| 55 | pub fn positional(self: *Parsed, w: []const u8) bool { | 55 | pub fn positional(self: *Parsed, w: []const u8) bool { |
| @@ -101,7 +101,7 @@ fn parseArgs( | |||
| 101 | } | 101 | } |
| 102 | switch (outcome) { | 102 | switch (outcome) { |
| 103 | .ok => {}, | 103 | .ok => {}, |
| 104 | .unknown_arg, .missing_value, .bad_number => return error.Usage, | 104 | .unknown_arg, .missing_value, .bad_value => return error.Usage, |
| 105 | // The two non-error early returns, so the two that still free for | 105 | // The two non-error early returns, so the two that still free for |
| 106 | // themselves: errdefer does not run on the way out with a result | 106 | // themselves: errdefer does not run on the way out with a result |
| 107 | // in hand. | 107 | // in hand. |
| @@ -115,7 +115,6 @@ fn parseArgs( | |||
| 115 | // like `--quic-idle-ms 0` and for the same reason: the number inverts | 115 | // like `--quic-idle-ms 0` and for the same reason: the number inverts |
| 116 | // what typing it means. | 116 | // what typing it means. |
| 117 | if (p.port == 0) return error.Usage; | 117 | if (p.port == 0) return error.Usage; |
| 118 | if (p.quic_idle_ms == 0) return error.Usage; | ||
| 119 | 118 | ||
| 120 | // No targets is not a usage error any more: it asks for the wall the | 119 | // No targets is not a usage error any more: it asks for the wall the |
| 121 | // last run persisted. main decides what an empty argv means; the parse | 120 | // last run persisted. main decides what an empty argv means; the parse |
| @@ -178,7 +177,7 @@ pub fn main() !u8 { | |||
| 178 | // so the two binaries cannot drift on what a bare HOST or a `quic://` | 177 | // so the two binaries cannot drift on what a bare HOST or a `quic://` |
| 179 | // means, and a tile POSTed by the page means what one typed on the | 178 | // means, and a tile POSTed by the page means what one typed on the |
| 180 | // command line. | 179 | // command line. |
| 181 | var hub = webhub.Hub.init(arena, w, state_path, parsed.key, parsed.quic_idle_ms) catch |err| switch (err) { | 180 | var hub = webhub.Hub.init(arena, w, state_path, parsed.key, parsed.quic_idle_ms.ms) catch |err| switch (err) { |
| 182 | error.MissingKey => { | 181 | error.MissingKey => { |
| 183 | std.debug.print( | 182 | std.debug.print( |
| 184 | "muxweb: no key for a quic:// tile: pass --key, set MUX_KEY_FILE, or run `muxd keygen`\n", | 183 | "muxweb: no key for a quic:// tile: pass --key, set MUX_KEY_FILE, or run `muxd keygen`\n", |
src/client.zig
| Old | New | ||
|---|---|---|---|
| @@ -199,6 +199,7 @@ pub fn lostMsg(target: Target, session_epoch: u64) []const u8 { | |||
| 199 | /// The client's name for the shared default; see `quic.default_idle_ms` | 199 | /// The client's name for the shared default; see `quic.default_idle_ms` |
| 200 | /// for what the number means and why it lives there. | 200 | /// for what the number means and why it lives there. |
| 201 | pub const quic_idle_ms_default: u32 = quic_client.default_idle_ms; | 201 | pub const quic_idle_ms_default: u32 = quic_client.default_idle_ms; |
| 202 | pub const IdleMs = quic_client.IdleMs; | ||
| 202 | 203 | ||
| 203 | /// What a QUIC attach was asked for: where to dial and what key to prove | 204 | /// What a QUIC attach was asked for: where to dial and what key to prove |
| 204 | /// ourselves with. | 205 | /// ourselves with. |
src/protocol.zig
| Old | New | ||
|---|---|---|---|
| @@ -853,6 +853,17 @@ pub fn validSessionName(name: []const u8) bool { | |||
| 853 | return true; | 853 | return true; |
| 854 | } | 854 | } |
| 855 | 855 | ||
| 856 | /// A `--session` field of this type is refused at the parse, so no caller | ||
| 857 | /// carries an unspellable name as far as the wire. | ||
| 858 | pub const SessionName = struct { | ||
| 859 | name: []const u8, | ||
| 860 | |||
| 861 | pub fn parseCLI(s: []const u8) error{Invalid}!SessionName { | ||
| 862 | if (!validSessionName(s)) return error.Invalid; | ||
| 863 | return .{ .name = s }; | ||
| 864 | } | ||
| 865 | }; | ||
| 866 | |||
| 856 | pub fn encodeAttach(cols: u16, rows: u16, have_seq: u64, have_epoch: u64) [attach_len]u8 { | 867 | pub fn encodeAttach(cols: u16, rows: u16, have_seq: u64, have_epoch: u64) [attach_len]u8 { |
| 857 | var buf: [attach_len]u8 = undefined; | 868 | var buf: [attach_len]u8 = undefined; |
| 858 | std.mem.writeInt(u16, buf[0..2], cols, .little); | 869 | std.mem.writeInt(u16, buf[0..2], cols, .little); |
src/quic.zig
| Old | New | ||
|---|---|---|---|
| @@ -50,6 +50,19 @@ pub const default_port: u16 = 4433; | |||
| 50 | /// binaries default to are two numbers, and they drift in silence. | 50 | /// binaries default to are two numbers, and they drift in silence. |
| 51 | pub const default_idle_ms: u32 = 15_000; | 51 | pub const default_idle_ms: u32 = 15_000; |
| 52 | 52 | ||
| 53 | /// Zero means NO idle timeout to ngtcp2, the inverse of what anyone typing | ||
| 54 | /// zero means, so `--quic-idle-ms`'s own type refuses it — once, rather | ||
| 55 | /// than in a post-check each binary copies. | ||
| 56 | pub const IdleMs = struct { | ||
| 57 | ms: u32 = default_idle_ms, | ||
| 58 | |||
| 59 | pub fn parseCLI(s: []const u8) error{Invalid}!IdleMs { | ||
| 60 | const ms = std.fmt.parseInt(u32, s, 10) catch return error.Invalid; | ||
| 61 | if (ms == 0) return error.Invalid; | ||
| 62 | return .{ .ms = ms }; | ||
| 63 | } | ||
| 64 | }; | ||
| 65 | |||
| 53 | pub const max_udp = 1452; // conservative IPv4 datagram that avoids fragmenting | 66 | pub const max_udp = 1452; // conservative IPv4 datagram that avoids fragmenting |
| 54 | 67 | ||
| 55 | pub const psk_identity: [*:0]const u8 = "mux"; | 68 | pub const psk_identity: [*:0]const u8 = "mux"; |
src/quic_client.zig
| Old | New | ||
|---|---|---|---|
| @@ -30,6 +30,7 @@ pub const Key = quic.Key; | |||
| 30 | pub const key_len = quic.key_len; | 30 | pub const key_len = quic.key_len; |
| 31 | pub const default_port = quic.default_port; | 31 | pub const default_port = quic.default_port; |
| 32 | pub const default_idle_ms = quic.default_idle_ms; | 32 | pub const default_idle_ms = quic.default_idle_ms; |
| 33 | pub const IdleMs = quic.IdleMs; | ||
| 33 | /// The refusal words, re-exported for the same reason `Key` is: client.zig | 34 | /// The refusal words, re-exported for the same reason `Key` is: client.zig |
| 34 | /// prints the daemon's sentences for a key the daemon would also refuse, | 35 | /// prints the daemon's sentences for a key the daemon would also refuse, |
| 35 | /// and it reaches the vocabulary module through this one. | 36 | /// and it reaches the vocabulary module through this one. |