aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/cli/ssh.zig
diff options
context:
space:
mode:
Diffstat (limited to 'vendor/libghostty-vt/src/cli/ssh.zig')
-rw-r--r--vendor/libghostty-vt/src/cli/ssh.zig635
1 files changed, 635 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/cli/ssh.zig b/vendor/libghostty-vt/src/cli/ssh.zig
new file mode 100644
index 00000000..76bfb10e
--- /dev/null
+++ b/vendor/libghostty-vt/src/cli/ssh.zig
@@ -0,0 +1,635 @@
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const ArenaAllocator = std.heap.ArenaAllocator;
+const cli_args = @import("args.zig");
+const diagnostics = @import("diagnostics.zig");
+const Action = @import("ghostty.zig").Action;
+const DiskCache = @import("ssh_cache.zig").DiskCache;
+const internal_os = @import("../os/main.zig");
+const ghostty_terminfo = @import("../terminfo/main.zig").ghostty;
+
+const log = std.log.scoped(.ssh);
+
+const usage =
+ \\Usage: ghostty +ssh [flags] [--] <ssh args...>
+ \\
+ \\Flags:
+ \\ --forward-env[=bool] Enable TERM / SendEnv forwarding. Default: true.
+ \\ --terminfo[=bool] Install Ghostty terminfo on first connect. Default: true.
+ \\ --cache[=bool] Use the terminfo install cache. Default: true.
+ \\ --ssh=<path> Path to the ssh binary. Default: first `ssh` on PATH.
+ \\ --verbose Print +ssh status lines to stderr.
+ \\ --help Show full help.
+ \\
+ \\ssh flags and the destination go after +ssh's own flags (or after `--`).
+ \\
+;
+
+pub const Options = struct {
+ /// Set by the CLI parser for deinit.
+ _arena: ?ArenaAllocator = null,
+
+ /// Maps to the `ssh-env` shell integration feature.
+ @"forward-env": bool = true,
+
+ /// Maps to the `ssh-terminfo` shell integration feature.
+ terminfo: bool = true,
+
+ /// When false, both cache read and write are bypassed.
+ cache: bool = true,
+
+ /// The wrapped `ssh` binary.
+ /// `/`-containing values are treated as paths; otherwise resolved via PATH.
+ ssh: []const u8 = "ssh",
+
+ /// When true, print verbose output to stderr.
+ verbose: bool = false,
+
+ /// Arguments passed through to `ssh` verbatim. Populated by
+ /// `parseManuallyHook` when we reach the first non-flag argument (or
+ /// an explicit `--`).
+ _ssh_args: std.ArrayList([]const u8) = .empty,
+
+ /// Enables arg parsing diagnostics so unknown flags become
+ /// diagnostics rather than fatal errors.
+ _diagnostics: diagnostics.DiagnosticList = .{},
+
+ pub fn deinit(self: *Options) void {
+ if (self._arena) |arena| arena.deinit();
+ self.* = undefined;
+ }
+
+ /// Enables `-h` and `--help` to work.
+ pub fn help(_: Options) !void {
+ return Action.help_error;
+ }
+
+ /// Manual parse hook. For each argument:
+ /// - If it's a literal `--`, consume everything after it as ssh
+ /// args and stop parsing.
+ /// - If it doesn't start with `--`, this is the start of the ssh
+ /// argv. Consume this arg and everything after as ssh args and
+ /// stop parsing.
+ /// - Otherwise (a `--foo` arg), return true so the generic parser
+ /// handles it as one of our own flags.
+ pub fn parseManuallyHook(
+ self: *Options,
+ alloc: Allocator,
+ arg: []const u8,
+ iter: anytype,
+ ) Allocator.Error!bool {
+ if (std.mem.eql(u8, arg, "--")) {
+ while (iter.next()) |rest| {
+ try self._ssh_args.append(alloc, try alloc.dupe(u8, rest));
+ }
+ return false;
+ }
+
+ if (!std.mem.startsWith(u8, arg, "--")) {
+ try self._ssh_args.append(alloc, try alloc.dupe(u8, arg));
+ while (iter.next()) |rest| {
+ try self._ssh_args.append(alloc, try alloc.dupe(u8, rest));
+ }
+ return false;
+ }
+
+ return true;
+ }
+};
+
+/// Wrap `ssh` to automatically configure Ghostty terminal integration on
+/// remote hosts.
+///
+/// Any arguments that aren't recognized as `+ssh` flags are passed to
+/// the real `ssh` binary unchanged. You can use `--` as an explicit
+/// disambiguator if needed, though it's almost never required: `ssh`
+/// has no long flags, and `+ssh` defines no short flags, so there's
+/// nothing to collide.
+///
+/// This is typically called via Ghostty's shell integration. When
+/// `shell-integration-features` includes `ssh-env` or `ssh-terminfo`,
+/// each shell defines an `ssh` function that runs:
+///
+/// ghostty +ssh <flags> -- "$@"
+///
+/// You can also run `ghostty +ssh` directly, or alias it yourself (e.g.
+/// `alias ssh='ghostty +ssh --'`) if you prefer not to use the shell
+/// integration.
+///
+/// `+ssh` performs up to two pieces of setup before launching `ssh`:
+///
+/// 1. **Environment forwarding** (`--forward-env`). Sets `TERM` to
+/// `xterm-256color` and requests `SendEnv` forwarding of
+/// `COLORTERM`, `TERM_PROGRAM`, and `TERM_PROGRAM_VERSION` so the
+/// remote shell can still detect that it's running inside Ghostty.
+/// The remote `sshd_config` must list these in `AcceptEnv` for
+/// forwarding to succeed.
+///
+/// 2. **Terminfo install** (`--terminfo`). On the first connection to a
+/// given destination, installs Ghostty's terminfo entry on the remote
+/// host using `infocmp -x xterm-ghostty | ssh tic -x -` over a
+/// shared `ControlMaster` connection. Successful installs are cached
+/// (see `ghostty +ssh-cache`) so subsequent connections skip this
+/// step. When terminfo is successfully installed or already cached,
+/// `TERM` is set to `xterm-ghostty` instead of `xterm-256color`.
+///
+/// If `--terminfo` install fails (e.g. `tic` not available on the
+/// remote, filesystem permissions), a warning is logged and the
+/// connection continues with `TERM=xterm-256color`.
+///
+/// Flags:
+///
+/// * `--forward-env=<bool>`: Enable `TERM` / `SendEnv` environment
+/// forwarding. Default: `true`.
+///
+/// * `--terminfo=<bool>`: Enable automatic terminfo install on first
+/// connection. Default: `true`.
+///
+/// * `--cache=<bool>`: Use the terminfo install cache. Default: `true`.
+/// When `false`, both the cache read (skip-if-installed) and the
+/// cache write (record-on-success) are bypassed, and every
+/// connection performs the install. To one-shot reinstall a single
+/// host while keeping the cache in use, prefer `ghostty +ssh-cache
+/// --remove=<host>` followed by a normal connection.
+///
+/// * `--ssh=<path>`: Path to the `ssh` binary to execute. Default: the
+/// first `ssh` found on `PATH`.
+///
+/// * `--verbose`: Print +ssh status lines to stderr, and surface
+/// remote stderr during the terminfo install.
+///
+/// Examples:
+///
+/// # Basic invocation using defaults:
+/// ghostty +ssh user@example.com
+///
+/// # Forward Ghostty env vars but skip the terminfo install:
+/// ghostty +ssh --terminfo=false user@example.com
+///
+/// # `ssh` flags (short-form `-p`, etc.) pass through unchanged:
+/// ghostty +ssh -p 2222 -i ~/.ssh/id_ed25519 user@example.com
+///
+/// # Use `--` explicitly if your ssh args might collide with our flags:
+/// ghostty +ssh -- --some-rare-ssh-arg user@example.com
+///
+/// Pass `--verbose` to see what `+ssh` is doing. For cache inspection
+/// and management, see `ghostty +ssh-cache`.
+///
+/// Available since: 1.4.0
+pub fn run(alloc_gpa: Allocator) !u8 {
+ var opts: Options = .{};
+ defer opts.deinit();
+
+ {
+ var iter = try cli_args.argsIterator(alloc_gpa);
+ defer iter.deinit();
+ try cli_args.parse(Options, alloc_gpa, &opts, &iter);
+ }
+
+ var stderr_buffer: [1024]u8 = undefined;
+ var stderr_file: std.fs.File = .stderr();
+ var stderr_writer = stderr_file.writer(&stderr_buffer);
+ const stderr = &stderr_writer.interface;
+
+ // Any diagnostic from the arg parser is an unknown flag or bad
+ // value. Reject loudly — silently forwarding `--typo` to ssh would
+ // produce confusing downstream errors.
+ if (!opts._diagnostics.empty()) {
+ for (opts._diagnostics.items()) |diag| {
+ if (diag.key.len > 0) {
+ stderr.print(
+ "Error: unknown flag `--{s}`.\n",
+ .{diag.key},
+ ) catch {};
+ } else {
+ stderr.print("Error: {s}\n", .{diag.message}) catch {};
+ }
+ }
+ stderr.print("\n{s}", .{usage}) catch {};
+ stderr.flush() catch {};
+ return 2;
+ }
+
+ const result = runInner(alloc_gpa, &opts, stderr);
+
+ stderr.flush() catch {};
+ return result;
+}
+
+fn runInner(
+ gpa: Allocator,
+ opts: *const Options,
+ stderr: *std.Io.Writer,
+) !u8 {
+ var arena = ArenaAllocator.init(gpa);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ if (opts._ssh_args.items.len == 0) {
+ try stderr.print("Error: no ssh arguments provided.\n\n{s}", .{usage});
+ return 2;
+ }
+
+ const session: struct {
+ term: []const u8,
+ to_cache: ?struct { cache: DiskCache, dest: []const u8 } = null,
+ } = session: {
+ if (!opts.terminfo) break :session .{ .term = "xterm-256color" };
+
+ const dest = resolveDestination(alloc, opts.ssh, opts._ssh_args.items) orelse {
+ warnPrint(stderr, "could not resolve ssh destination; skipping terminfo install", .{});
+ break :session .{ .term = "xterm-256color" };
+ };
+
+ const cache: ?DiskCache = if (opts.cache) cache: {
+ const path = DiskCache.defaultPath(alloc, "ghostty") catch |err| {
+ warnPrint(stderr, "ghostty terminfo cache unavailable: {}", .{err});
+ break :session .{ .term = "xterm-256color" };
+ };
+ break :cache .{ .path = path };
+ } else null;
+
+ if (cache) |c| {
+ if (c.contains(alloc, dest) catch false) {
+ verbosePrint(opts, stderr, "dest: {s} (cached, skipping install)", .{dest});
+ break :session .{ .term = "xterm-ghostty" };
+ } else {
+ verbosePrint(opts, stderr, "dest: {s} (not cached, will install)", .{dest});
+ }
+ } else {
+ verbosePrint(opts, stderr, "dest: {s} (cache disabled, will install)", .{dest});
+ }
+
+ stderr.print("Setting up xterm-ghostty terminfo on {s}...\n", .{dest}) catch {};
+ stderr.flush() catch {};
+
+ installRemoteTerminfo(alloc, opts, stderr) catch |err| {
+ warnPrint(stderr, "failed to install terminfo: {}", .{err});
+ break :session .{ .term = "xterm-256color" };
+ };
+ break :session .{
+ .term = "xterm-ghostty",
+ .to_cache = if (cache) |c| .{ .cache = c, .dest = dest } else null,
+ };
+ };
+
+ // Build the full argv: [ssh, ...our opts, ...user args]
+ const env_opts: []const []const u8 = if (opts.@"forward-env") env_opts: {
+ const set_term = try std.fmt.allocPrint(
+ alloc,
+ "SetEnv=TERM={s}",
+ .{session.term},
+ );
+ break :env_opts &.{
+ "-o", set_term,
+ "-o", "SendEnv=COLORTERM",
+ "-o", "SendEnv=TERM_PROGRAM",
+ "-o", "SendEnv=TERM_PROGRAM_VERSION",
+ };
+ } else &.{};
+ const argv = try std.mem.concat(alloc, []const u8, &.{
+ &.{opts.ssh},
+ env_opts,
+ opts._ssh_args.items,
+ });
+ verbosePrint(opts, stderr, "exec: {f}", .{Joined{ .items = argv }});
+
+ const exit_code = childExec(alloc, argv) catch |err| {
+ try stderr.print("Error: failed to run {s}: {}\n", .{ argv[0], err });
+ return 1;
+ };
+ verbosePrint(opts, stderr, "exit: {d}", .{exit_code});
+
+ // Attempt to cache (if needed) on a successful ssh execution.
+ if (exit_code == 0) if (session.to_cache) |entry| {
+ if (entry.cache.add(alloc, entry.dest, std.time.timestamp())) |_| {
+ verbosePrint(opts, stderr, "cache: wrote {s}", .{entry.dest});
+ } else |err| {
+ log.debug("cache add failed for '{s}': {}", .{ entry.dest, err });
+ }
+ };
+
+ return exit_code;
+}
+
+/// Log to `.ssh` and, if `--verbose`, also print to stderr.
+fn verbosePrint(
+ opts: *const Options,
+ stderr: *std.Io.Writer,
+ comptime fmt: []const u8,
+ args: anytype,
+) void {
+ log.debug(fmt, args);
+ if (!opts.verbose) return;
+ stderr.print("+ssh: " ++ fmt ++ "\n", args) catch return;
+ stderr.flush() catch return;
+}
+
+/// Log a warning and also print a `Warning: <msg>` line to stderr.
+fn warnPrint(
+ stderr: *std.Io.Writer,
+ comptime fmt: []const u8,
+ args: anytype,
+) void {
+ log.warn(fmt, args);
+ stderr.print("Warning: " ++ fmt ++ "\n", args) catch return;
+ stderr.flush() catch return;
+}
+
+/// Space-joined items, formattable as `{f}`.
+const Joined = struct {
+ items: []const []const u8,
+
+ pub fn format(self: Joined, writer: *std.Io.Writer) !void {
+ for (self.items, 0..) |a, i| {
+ if (i > 0) try writer.writeByte(' ');
+ try writer.writeAll(a);
+ }
+ }
+
+ test {
+ const testing = std.testing;
+ var buf: [128]u8 = undefined;
+ {
+ var w: std.Io.Writer = .fixed(&buf);
+ try w.print("{f}", .{Joined{ .items = &.{} }});
+ try testing.expectEqualStrings("", buf[0..w.end]);
+ }
+ {
+ var w: std.Io.Writer = .fixed(&buf);
+ try w.print("{f}", .{Joined{ .items = &.{"only"} }});
+ try testing.expectEqualStrings("only", buf[0..w.end]);
+ }
+ {
+ var w: std.Io.Writer = .fixed(&buf);
+ try w.print("{f}", .{Joined{ .items = &.{ "a", "b", "c" } }});
+ try testing.expectEqualStrings("a b c", buf[0..w.end]);
+ }
+ }
+};
+
+fn checkExit(term: std.process.Child.Term, label: []const u8) error{ChildFailed}!void {
+ switch (term) {
+ .Exited => |rc| if (rc != 0) {
+ log.warn("{s} exited with non-zero status: {d}", .{ label, rc });
+ return error.ChildFailed;
+ },
+ else => {
+ log.warn("{s} terminated abnormally: {}", .{ label, term });
+ return error.ChildFailed;
+ },
+ }
+}
+
+/// Run `ssh -G <args>` and parse the output for `user` and `hostname`.
+/// Returns the resolved `user@hostname`, or null if the destination
+/// could not be resolved.
+fn resolveDestination(
+ alloc: Allocator,
+ ssh: []const u8,
+ args: []const []const u8,
+) ?[]const u8 {
+ const argv = std.mem.concat(alloc, []const u8, &.{
+ &.{ ssh, "-G" },
+ args,
+ }) catch return null;
+ const result = std.process.Child.run(.{
+ .allocator = alloc,
+ .argv = argv,
+ }) catch |err| {
+ log.warn("ssh -G spawn failed: {}", .{err});
+ return null;
+ };
+ checkExit(result.term, "ssh -G") catch return null;
+ return parseDestination(alloc, result.stdout);
+}
+
+/// Parse `ssh -G` output for `user` and `hostname` and return the
+/// formatted `user@hostname`. Returns null if either key is missing
+/// or formatting fails.
+fn parseDestination(alloc: Allocator, stdout: []const u8) ?[]const u8 {
+ var user: []const u8 = "";
+ var host: []const u8 = "";
+ var it = std.mem.tokenizeScalar(u8, stdout, '\n');
+ while (it.next()) |line| {
+ const space = std.mem.indexOfScalar(u8, line, ' ') orelse continue;
+ const key = line[0..space];
+ const value = line[space + 1 ..];
+ if (std.mem.eql(u8, key, "user")) {
+ user = value;
+ } else if (std.mem.eql(u8, key, "hostname")) {
+ host = value;
+ }
+ if (user.len > 0 and host.len > 0) break;
+ }
+
+ if (user.len == 0) {
+ log.warn("ssh -G output missing user", .{});
+ return null;
+ }
+ if (host.len == 0) {
+ log.warn("ssh -G output missing hostname", .{});
+ return null;
+ }
+
+ return std.fmt.allocPrint(alloc, "{s}@{s}", .{ user, host }) catch null;
+}
+
+/// Install Ghostty's terminfo on the remote host over a short-lived SSH
+/// ControlMaster connection. The master tears down with the client
+/// (`ControlPersist=no`) so no socket lingers.
+fn installRemoteTerminfo(
+ alloc: Allocator,
+ opts: *const Options,
+ stderr: *std.Io.Writer,
+) !void {
+ var buf: std.Io.Writer.Allocating = .init(alloc);
+ defer buf.deinit();
+ try ghostty_terminfo.encode(&buf.writer);
+ const terminfo = buf.written();
+
+ // ControlPath is in TMPDIR with a short, random basename. ssh uses
+ // ControlPath as the bind address for a Unix domain socket; macOS
+ // limits sockaddr_un.sun_path to ~104 bytes, so keeping the path
+ // short leaves margin.
+ const control_path = try internal_os.randomTmpPath(alloc, "ghostty-ssh-");
+ const control_path_opt = try std.fmt.allocPrint(
+ alloc,
+ "ControlPath={s}",
+ .{control_path},
+ );
+
+ // Under --verbose, let remote stderr through (the `tic` step is
+ // the most common failure source) and inherit ssh's stderr so it
+ // reaches the user's terminal. Other steps stay quiet either way.
+ const remote_script = if (opts.verbose)
+ \\infocmp xterm-ghostty >/dev/null 2>&1 && exit 0
+ \\command -v tic >/dev/null 2>&1 || exit 1
+ \\mkdir -p ~/.terminfo 2>/dev/null && tic -x - && exit 0
+ \\exit 1
+ else
+ \\infocmp xterm-ghostty >/dev/null 2>&1 && exit 0
+ \\command -v tic >/dev/null 2>&1 || exit 1
+ \\mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0
+ \\exit 1
+ ;
+
+ // Set up an SSH ControlMaster scoped to this single install:
+ // - ControlMaster=yes makes our client also act as the master,
+ // so `infocmp | ssh tic` runs over a single connection.
+ // - ControlPersist=no tears the master down when our client
+ // exits; no socket lingers on the remote side.
+ const argv = try std.mem.concat(alloc, []const u8, &.{
+ &.{opts.ssh},
+ &.{
+ "-o", "ControlMaster=yes",
+ "-o", "ControlPersist=no",
+ "-o", control_path_opt,
+ },
+ opts._ssh_args.items,
+ &.{remote_script},
+ });
+ verbosePrint(opts, stderr, "exec: {f}", .{Joined{ .items = argv }});
+
+ var child: std.process.Child = .init(argv, alloc);
+ child.stdin_behavior = .Pipe;
+ child.stdout_behavior = .Ignore;
+ child.stderr_behavior = if (opts.verbose) .Inherit else .Ignore;
+
+ child.spawn() catch |err| {
+ log.warn("terminfo install spawn failed: {}", .{err});
+ return error.InstallFailed;
+ };
+
+ if (child.stdin) |stdin| {
+ stdin.writeAll(terminfo) catch {};
+ stdin.close();
+ child.stdin = null;
+ }
+
+ const term = child.wait() catch |err| {
+ log.warn("terminfo install wait failed: {}", .{err});
+ return error.InstallFailed;
+ };
+ checkExit(term, "terminfo install") catch return error.InstallFailed;
+}
+
+/// Returns `128 + signum` for signal-killed children, matching shell convention.
+fn childExec(alloc: Allocator, argv: []const []const u8) !u8 {
+ var child: std.process.Child = .init(argv, alloc);
+ child.stdin_behavior = .Inherit;
+ child.stdout_behavior = .Inherit;
+ child.stderr_behavior = .Inherit;
+
+ try child.spawn();
+ const term = try child.wait();
+ return switch (term) {
+ .Exited => |rc| rc,
+ .Signal => |sig| @as(u8, 128) + @as(u8, @intCast(@min(sig, 127))),
+ .Stopped, .Unknown => 1,
+ };
+}
+
+fn parseTestArgs(alloc: Allocator, opts: *Options, line: []const u8) !void {
+ var iter = try std.process.ArgIteratorGeneral(.{}).init(alloc, line);
+ defer iter.deinit();
+ try cli_args.parse(Options, alloc, opts, &iter);
+}
+
+test "parseManuallyHook: bare destination starts ssh args" {
+ const testing = std.testing;
+ var opts: Options = .{};
+ defer opts.deinit();
+ try parseTestArgs(testing.allocator, &opts, "--terminfo=false user@example.com");
+ try testing.expectEqual(false, opts.terminfo);
+ try testing.expectEqual(true, opts.@"forward-env");
+ try testing.expectEqual(@as(usize, 1), opts._ssh_args.items.len);
+ try testing.expectEqualStrings("user@example.com", opts._ssh_args.items[0]);
+}
+
+test "parseManuallyHook: short ssh flags pass through verbatim" {
+ const testing = std.testing;
+ var opts: Options = .{};
+ defer opts.deinit();
+ try parseTestArgs(testing.allocator, &opts, "-p 2222 user@example.com");
+ try testing.expectEqual(@as(usize, 3), opts._ssh_args.items.len);
+ try testing.expectEqualStrings("-p", opts._ssh_args.items[0]);
+ try testing.expectEqualStrings("2222", opts._ssh_args.items[1]);
+ try testing.expectEqualStrings("user@example.com", opts._ssh_args.items[2]);
+}
+
+test "parseManuallyHook: explicit -- separator" {
+ const testing = std.testing;
+ var opts: Options = .{};
+ defer opts.deinit();
+ try parseTestArgs(
+ testing.allocator,
+ &opts,
+ "--verbose -- --some-rare-ssh-arg user@example.com",
+ );
+ try testing.expectEqual(true, opts.verbose);
+ try testing.expectEqual(@as(usize, 2), opts._ssh_args.items.len);
+ try testing.expectEqualStrings("--some-rare-ssh-arg", opts._ssh_args.items[0]);
+ try testing.expectEqualStrings("user@example.com", opts._ssh_args.items[1]);
+}
+
+test "parseDestination: typical ssh -G output" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const stdout =
+ \\user alice
+ \\hostname example.com
+ \\port 22
+ \\identityfile ~/.ssh/id_ed25519
+ \\
+ ;
+ const result = parseDestination(arena.allocator(), stdout);
+ try testing.expectEqualStrings("alice@example.com", result.?);
+}
+
+test "parseDestination: hostname before user" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const stdout =
+ \\hostname example.com
+ \\port 22
+ \\user alice
+ \\
+ ;
+ const result = parseDestination(arena.allocator(), stdout);
+ try testing.expectEqualStrings("alice@example.com", result.?);
+}
+
+test "parseDestination: missing hostname returns null" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const stdout = "user alice\nport 22\n";
+ try testing.expectEqual(@as(?[]const u8, null), parseDestination(arena.allocator(), stdout));
+}
+
+test "parseDestination: missing user returns null" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const stdout = "hostname example.com\nport 22\n";
+ try testing.expectEqual(@as(?[]const u8, null), parseDestination(arena.allocator(), stdout));
+}
+
+test "parseDestination: empty input returns null" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ try testing.expectEqual(@as(?[]const u8, null), parseDestination(arena.allocator(), ""));
+}
+
+test "parseDestination: IPv6 hostname" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const stdout = "user alice\nhostname ::1\n";
+ const result = parseDestination(arena.allocator(), stdout);
+ try testing.expectEqualStrings("alice@::1", result.?);
+}