a73x

c3ab0e21

fix: a hosts row is printed whole, so rm can match the line the list just showed

a73x   2026-08-28 19:53

Commit message
fix: a hosts row is printed whole, so rm can match the line the list just showed

src/cli/mux_main.zig
Old New
@@ -588,19 +588,30 @@ fn hostsList(arena: std.mem.Allocator, path: []const u8, out_fd: std.posix.fd_t)
588 const key = std.posix.getenv(xdg.key_env); 588 const key = std.posix.getenv(xdg.key_env);
589 for (lines.items) |line| { 589 for (lines.items) |line| {
590 const spec = wallview.resolveHost(arena, line, key, client.quic_idle_ms_default) catch |err| { 590 const spec = wallview.resolveHost(arena, line, key, client.quic_idle_ms_default) catch |err| {
591 printOut(out_fd, "{s}\t[bad host: {s}]\n", .{ line, hosts.reason(err) }); 591 printRow(out_fd, line, "\t[bad host: {s}]\n", .{hosts.reason(err)});
592 continue; 592 continue;
593 }; 593 };
594 var out: [proto.sessions_text_max]u8 = undefined; 594 var out: [proto.sessions_text_max]u8 = undefined;
595 const list = client.listSessions(arena, spec.poll_target, &out, hosts_list_ms, null) catch { 595 const list = client.listSessions(arena, spec.poll_target, &out, hosts_list_ms, null) catch {
596 printOut(out_fd, "{s}\t[unreachable]\n", .{line}); 596 printRow(out_fd, line, "\t[unreachable]\n", .{});
597 continue; 597 continue;
598 }; 598 };
599 printOut(out_fd, "{s}\t{d}\n", .{ line, countSessions(list) }); 599 printRow(out_fd, line, "\t{d}\n", .{countSessions(list)});
600 } 600 }
601 return 0; 601 return 0;
602 } 602 }
603 603
604 /// One listing row: the file's line verbatim, then a formatted verdict.
605 fn printRow(fd: std.posix.fd_t, line: []const u8, comptime fmt: []const u8, args: anytype) void {
606 // The line is written straight through rather than formatted into a
607 // buffer with the verdict. `wall.loadLines` accepts lines up to a MiB
608 // and `mux hosts rm` matches byte for byte, so a row that overflowed a
609 // fixed buffer used to vanish from the one command whose job is showing
610 // the user a line they then have to type back.
611 proto.writeAllFd(fd, line) catch return;
612 printOut(fd, fmt, args);
613 }
614
604 /// A listing someone ASKED for is output, like `--help`; every refusal 615 /// A listing someone ASKED for is output, like `--help`; every refusal
605 /// above and below stays on stderr. 616 /// above and below stays on stderr.
606 fn printOut(fd: std.posix.fd_t, comptime fmt: []const u8, args: anytype) void { 617 fn printOut(fd: std.posix.fd_t, comptime fmt: []const u8, args: anytype) void {
@@ -913,6 +924,43 @@ test "hosts list: a line the grammar refuses is named, not a refusal of the list
913 try std.testing.expect(std.mem.indexOf(u8, text, "\t[unreachable]") != null); 924 try std.testing.expect(std.mem.indexOf(u8, text, "\t[unreachable]") != null);
914 } 925 }
915 926
927 test "hosts list: a line longer than the row buffer is still shown, because rm matches what was shown" {
928 // `wall.loadLines` takes lines up to a MiB and `mux hosts rm` matches
929 // byte for byte, so a row this command drops is a line the user can
930 // neither see nor type back — the two halves of the repair loop have to
931 // agree about which lines exist.
932 const alloc = std.testing.allocator;
933 var tmp = try TmpDir.make();
934 defer tmp.cleanup();
935 var buf: [512]u8 = undefined;
936 const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()});
937
938 // A `--sock` path no `sun_path` could hold: refused at usage altitude,
939 // so the row is produced without a dial and the check is about the
940 // printing and nothing else.
941 var long: [900]u8 = undefined;
942 @memcpy(long[0..8], "--sock /");
943 @memset(long[8..], 'h');
944 var line_buf: [1024]u8 = undefined;
945 try wall.saveBytes(path, try std.fmt.bufPrint(&line_buf, "{s}\n", .{long}));
946
947 var arena_state = std.heap.ArenaAllocator.init(alloc);
948 defer arena_state.deinit();
949 const arena = arena_state.allocator();
950 var out_buf: [512]u8 = undefined;
951 const out_path = try std.fmt.bufPrint(&out_buf, "{s}/out", .{tmp.path()});
952 const out = try std.fs.createFileAbsolute(out_path, .{});
953 const rc = try hostsList(arena, path, out.handle);
954 out.close();
955 try std.testing.expectEqual(@as(u8, 0), rc);
956
957 const text = try std.fs.cwd().readFileAlloc(alloc, out_path, 8192);
958 defer alloc.free(text);
959 // Whole, and with its verdict: a truncated line is one `rm` cannot take.
960 try std.testing.expect(std.mem.startsWith(u8, text, &long));
961 try std.testing.expect(std.mem.endsWith(u8, text, "\t[bad host: socket path too long to bind]\n"));
962 }
963
916 test "wall: a LISTED local daemon that nothing answers on is one this wall starts" { 964 test "wall: a LISTED local daemon that nothing answers on is one this wall starts" {
917 const alloc = std.testing.allocator; 965 const alloc = std.testing.allocator;
918 var tmp = try TmpDir.make(); 966 var tmp = try TmpDir.make();