cli: add an ssh-wrapping +ssh action (#12582)

Add a drop-in `ssh` wrapper that sets up the remote environment for
Ghostty. Anything not consumed as one of our own flags is forwarded to
the real, wrapped `ssh` binary. It can be used directly (`ghostty +ssh
user@host`), aliased (`alias ssh='ghostty +ssh --'`), or invoked through
Ghostty's shell integration.

Before exec'ing ssh, `+ssh`:

- Forwards Ghostty environment to the remote (`--forward-env`): sets
TERM=xterm-256color and requests SendEnv forwarding of COLORTERM,
TERM_PROGRAM, and TERM_PROGRAM_VERSION.
- Installs Ghostty's terminfo on the remote (`--terminfo`), informed by
our existing `ssh-cache` system and using our internal xterm-ghostty
terminfo representation.

A third flag, `--cache`, controls cache use; `--cache=false` bypasses
both read and write, which is useful for scripting and for debugging
install failures without polluting the cache.

For shell integration, this replaces the per-shell logic (which made up
roughly a third of our shell integration scripts) with a simple wrapper
function that translates GHOSTTY_SHELL_FEATURES into a `ghostty +ssh`
command line.
This commit is contained in:
Mitchell Hashimoto
2026-05-22 09:04:36 -07:00
committed by GitHub
7 changed files with 686 additions and 342 deletions

View File

@@ -11,6 +11,7 @@ const list_keybinds = @import("list_keybinds.zig");
const list_themes = @import("list_themes.zig");
const list_colors = @import("list_colors.zig");
const list_actions = @import("list_actions.zig");
const ssh = @import("ssh.zig");
const ssh_cache = @import("ssh_cache.zig");
const edit_config = @import("edit_config.zig");
const show_config = @import("show_config.zig");
@@ -47,6 +48,9 @@ pub const Action = enum {
/// List keybind actions
@"list-actions",
/// Wrap `ssh` to configure Ghostty terminal integration on remote hosts
ssh,
/// Manage SSH terminfo cache for automatic remote host setup
@"ssh-cache",
@@ -148,6 +152,7 @@ pub const Action = enum {
.@"list-colors" => try list_colors.run(alloc),
.@"list-actions" => try list_actions.run(alloc),
.@"ssh-cache" => try ssh_cache.run(alloc),
.ssh => try ssh.run(alloc),
.@"edit-config" => try edit_config.run(alloc),
.@"show-config" => try show_config.run(alloc),
.@"explain-config" => try explain_config.run(alloc),
@@ -189,6 +194,7 @@ pub const Action = enum {
.@"list-colors" => list_colors.Options,
.@"list-actions" => list_actions.Options,
.@"ssh-cache" => ssh_cache.Options,
.ssh => ssh.Options,
.@"edit-config" => edit_config.Options,
.@"show-config" => show_config.Options,
.@"explain-config" => explain_config.Options,

635
src/cli/ssh.zig Normal file
View File

@@ -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)) |_| {
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.?);
}