aboutsummaryrefslogtreecommitdiffstats
path: root/vendor/libghostty-vt/src/cli/ssh_cache.zig
diff options
context:
space:
mode:
authorsl0p <sl0p@sl0p.foo>2026-08-13 22:00:03 +0200
committersl0p <sl0p@sl0p.foo>2026-08-13 22:00:03 +0200
commit2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8 (patch)
tree7cd6274303ba1da1f8bd57371aec336dd687fb58 /vendor/libghostty-vt/src/cli/ssh_cache.zig
downloadslosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.tar.gz
slosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.tar.xz
slosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.zip
sl0ptty: M0 — libghostty-vt vendored, one pane, compositor, diff renderer
Design contract in DESIGN.md. libghostty-vt vendored fresh from ghostty main@bedb6f2 (zig 0.16), built to a 14MB static lib. M0: pty spawn (no libutil), a lib-vt terminal per pane, a screen cell buffer, a diff emitter that repaints only what changed, raw mode, SIGWINCH, and a ~120Hz frame cap that coalesces pty reads. Also the seed of the headless harness: --headless dumps the composited screen without a tty, so composition is testable in milliseconds. tests/live_m0.py covers what headless cannot: raw mode, the diff emitter, resize propagation, and the quit path. 6/6 green.
Diffstat (limited to 'vendor/libghostty-vt/src/cli/ssh_cache.zig')
-rw-r--r--vendor/libghostty-vt/src/cli/ssh_cache.zig555
1 files changed, 555 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/cli/ssh_cache.zig b/vendor/libghostty-vt/src/cli/ssh_cache.zig
new file mode 100644
index 0000000..f509f69
--- /dev/null
+++ b/vendor/libghostty-vt/src/cli/ssh_cache.zig
@@ -0,0 +1,555 @@
+const std = @import("std");
+const fs = std.fs;
+const Allocator = std.mem.Allocator;
+const args = @import("args.zig");
+const global = @import("../global.zig");
+const Action = @import("ghostty.zig").Action;
+const Duration = @import("../config.zig").Config.Duration;
+pub const Entry = @import("ssh-cache/Entry.zig");
+pub const DiskCache = @import("ssh-cache/DiskCache.zig");
+
+pub const Options = struct {
+ clear: bool = false,
+ add: ?[]const u8 = null,
+ remove: ?[]const u8 = null,
+ prune: ?Duration = null,
+
+ pub fn deinit(self: *Options) void {
+ _ = self;
+ }
+
+ pub fn help(self: Options) !void {
+ _ = self;
+ return Action.help_error;
+ }
+};
+
+/// Manage the SSH terminfo cache for automatic remote host setup.
+///
+/// The `+ssh` action installs Ghostty's terminfo on remote hosts and records
+/// each success in this cache so it doesn't re-upload on later connections.
+/// (`+ssh` runs automatically from the shell integration when
+/// `shell-integration-features` includes `ssh-terminfo`.) This command
+/// inspects and maintains that cache.
+///
+/// The cache stores destinations (a hostname or user@hostname) along with
+/// timestamps.
+///
+/// A positional destination queries the cache: `user@hostname` shows that
+/// exact entry, while a bare `hostname` shows every cached entry for that
+/// host regardless of user. With no destination and no action, the entire
+/// cache is listed. A query that matches nothing exits 1.
+///
+/// At most one action (`--clear`, `--add`, `--remove`, or `--prune`) may be
+/// specified, and not together with a positional destination; combining them
+/// is an error.
+///
+/// `--prune` takes a duration with unit suffixes (`s`, `m`, `h`, `d`, `w`,
+/// `y`) and removes every entry older than it, e.g. `--prune=30d`,
+/// `--prune=6h`, `--prune=1y`.
+///
+/// Examples:
+/// ghostty +ssh-cache # List all cached destinations
+/// ghostty +ssh-cache user@example.com # Show that destination
+/// ghostty +ssh-cache example.com # Show all users on that host
+/// ghostty +ssh-cache --add=user@example.com # Manually add a destination
+/// ghostty +ssh-cache --remove=user@example.com # Remove a destination
+/// ghostty +ssh-cache --prune=30d # Remove entries older than 30 days
+/// ghostty +ssh-cache --clear # Clear entire cache
+pub fn run(alloc_gpa: Allocator) !u8 {
+ var arena = std.heap.ArenaAllocator.init(alloc_gpa);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var opts: Options = .{};
+ defer opts.deinit();
+
+ var stdout_buffer: [1024]u8 = undefined;
+ var stdout_file: std.Io.File = .stdout();
+ var stdout_writer = stdout_file.writer(global.io(), &stdout_buffer);
+ const stdout = &stdout_writer.interface;
+
+ var stderr_buffer: [1024]u8 = undefined;
+ var stderr_file: std.Io.File = .stderr();
+ var stderr_writer = stderr_file.writer(global.io(), &stderr_buffer);
+ const stderr = &stderr_writer.interface;
+
+ // The cache is queried by a positional destination (`user@host` or a
+ // bare `host`). `args.parse` rejects non-`--` tokens, so we lift the
+ // positional out here and parse only the remaining flags. `--host=X`
+ // is accepted as a deprecated spelling of the positional (it was the
+ // original shipped flag name).
+ var query: ?[]const u8 = null;
+ var flags: std.ArrayList([]const u8) = .empty;
+ {
+ var iter = try args.argsIterator(alloc_gpa, global.args());
+ defer iter.deinit();
+ while (iter.next()) |arg| {
+ const is_host_flag = std.mem.startsWith(u8, arg, "--host=");
+ if (is_host_flag) {
+ try stderr.print(
+ "Warning: --host is deprecated; pass the destination " ++
+ "directly, e.g. `ghostty +ssh-cache {s}`.\n",
+ .{arg["--host=".len..]},
+ );
+ }
+ const dest: ?[]const u8 = if (is_host_flag)
+ arg["--host=".len..]
+ else if (!std.mem.startsWith(u8, arg, "-"))
+ arg
+ else
+ null;
+
+ if (dest) |d| {
+ if (query != null) {
+ try stderr.print(
+ "Error: only one destination may be specified.\n",
+ .{},
+ );
+ stderr.flush() catch {};
+ return 2;
+ }
+ query = try alloc.dupe(u8, d);
+ } else {
+ try flags.append(alloc, try alloc.dupe(u8, arg));
+ }
+ }
+ }
+
+ {
+ var iter = args.sliceIterator(flags.items);
+ args.parse(Options, alloc_gpa, &opts, &iter) catch |err| switch (err) {
+ error.InvalidField => {
+ try stderr.print("Error: unknown flag.\n", .{});
+ stderr.flush() catch {};
+ return 2;
+ },
+ error.InvalidValue, error.ValueRequired => {
+ try stderr.print("Error: invalid flag value.\n", .{});
+ stderr.flush() catch {};
+ return 2;
+ },
+ else => return err,
+ };
+ }
+
+ // Setup our disk cache to the standard location
+ const cache_path = DiskCache.defaultPath(alloc, "ghostty") catch |err| {
+ try stderr.print(
+ "Error: unable to determine the cache path: {t}\n",
+ .{err},
+ );
+ stderr.flush() catch {};
+ return 1;
+ };
+
+ const result = runInner(
+ alloc,
+ opts,
+ query,
+ .{ .path = cache_path },
+ stdout,
+ stderr,
+ );
+
+ // Flushing *shouldn't* fail but...
+ stdout.flush() catch {};
+ stderr.flush() catch {};
+ return result;
+}
+
+pub fn runInner(
+ alloc: Allocator,
+ opts: Options,
+ query: ?[]const u8,
+ cache: DiskCache,
+ stdout: *std.Io.Writer,
+ stderr: *std.Io.Writer,
+) !u8 {
+ // At most one action may be specified, and a query (positional
+ // destination) is itself an action.
+ const action_count =
+ @as(usize, @intFromBool(opts.clear)) +
+ @intFromBool(opts.add != null) +
+ @intFromBool(opts.remove != null) +
+ @intFromBool(opts.prune != null) +
+ @intFromBool(query != null);
+ if (action_count > 1) {
+ try stderr.print(
+ "Error: only one of a destination, --clear, --add, --remove, " ++
+ "or --prune may be specified.\n",
+ .{},
+ );
+ return 2;
+ }
+
+ if (opts.clear) {
+ cache.clear() catch |err| {
+ try stderr.print(
+ "Error: unable to clear cache '{s}': {t}\n",
+ .{ cache.path, err },
+ );
+ return 1;
+ };
+ return 0;
+ }
+
+ if (opts.add) |dest| {
+ cache.add(
+ alloc,
+ dest,
+ std.Io.Timestamp.now(global.io(), .real).toSeconds(),
+ ) catch |err| switch (err) {
+ error.InvalidCacheKey => {
+ try stderr.print(
+ "Error: invalid destination '{s}' (expected hostname or user@hostname)\n",
+ .{dest},
+ );
+ return 2;
+ },
+ else => {
+ try stderr.print(
+ "Error: unable to add '{s}' to cache '{s}': {t}\n",
+ .{ dest, cache.path, err },
+ );
+ return 1;
+ },
+ };
+ return 0;
+ }
+
+ if (opts.remove) |dest| {
+ const removed = cache.remove(alloc, dest) catch |err| switch (err) {
+ error.InvalidCacheKey => {
+ try stderr.print(
+ "Error: invalid destination '{s}' (expected hostname or user@hostname)\n",
+ .{dest},
+ );
+ return 2;
+ },
+ else => {
+ try stderr.print(
+ "Error: unable to remove '{s}' from cache '{s}': {t}\n",
+ .{ dest, cache.path, err },
+ );
+ return 1;
+ },
+ };
+ // Silence on success; a no-op removal is an error (exit 1).
+ if (!removed) {
+ try stderr.print("Error: '{s}' is not in the cache.\n", .{dest});
+ return 1;
+ }
+ return 0;
+ }
+
+ if (opts.prune) |max_age| {
+ const max_age_s = max_age.duration / std.time.ns_per_s;
+ if (max_age_s == 0) {
+ try stderr.print(
+ "Error: --prune requires a duration of at least one second.\n",
+ .{},
+ );
+ return 2;
+ }
+ const pruned = cache.prune(alloc, max_age_s) catch |err| {
+ try stderr.print(
+ "Error: unable to prune cache '{s}': {t}\n",
+ .{ cache.path, err },
+ );
+ return 1;
+ };
+ try stdout.print("Pruned cache entries: {d}\n", .{pruned});
+ return 0;
+ }
+
+ var entries = cache.list(alloc) catch |err| {
+ try stderr.print(
+ "Error: unable to read cache '{s}': {t}\n",
+ .{ cache.path, err },
+ );
+ return 1;
+ };
+ defer DiskCache.deinitEntries(alloc, &entries);
+
+ // A positional query filters the listing: an exact `user@host` match,
+ // or every entry on a bare `host`.
+ if (query) |q| {
+ if (!DiskCache.isValidCacheKey(q)) {
+ try stderr.print(
+ "Error: invalid destination '{s}' (expected hostname or user@hostname)\n",
+ .{q},
+ );
+ return 2;
+ }
+
+ var matches: std.StringHashMap(Entry) = .init(alloc);
+ defer matches.deinit();
+ var iter = entries.iterator();
+ while (iter.next()) |kv| {
+ const key = kv.key_ptr.*;
+ if (matchesQuery(key, q)) try matches.put(key, kv.value_ptr.*);
+ }
+
+ if (matches.count() == 0) return 1;
+ try listEntries(alloc, &matches, stdout);
+ return 0;
+ }
+
+ // List all destinations by default.
+ try listEntries(alloc, &entries, stdout);
+ return 0;
+}
+
+fn listEntries(
+ alloc: Allocator,
+ entries: *const std.StringHashMap(Entry),
+ writer: *std.Io.Writer,
+) !void {
+ if (entries.count() == 0) return;
+
+ // Sort entries by hostname for consistent output
+ var items: std.ArrayList(Entry) = .empty;
+ defer items.deinit(alloc);
+
+ var iter = entries.iterator();
+ while (iter.next()) |kv| {
+ try items.append(alloc, kv.value_ptr.*);
+ }
+
+ std.mem.sort(Entry, items.items, {}, struct {
+ fn lessThan(_: void, a: Entry, b: Entry) bool {
+ return std.mem.lessThan(u8, a.hostname, b.hostname);
+ }
+ }.lessThan);
+
+ // Align the timestamp column by padding destinations to the widest.
+ var widest: usize = 0;
+ for (items.items) |entry| {
+ widest = @max(widest, entry.hostname.len);
+ }
+
+ const now = std.Io.Timestamp.now(global.io(), .real).toSeconds();
+ for (items.items) |entry| {
+ try writer.print("{s}", .{entry.hostname});
+ try writer.splatByteAll(' ', widest - entry.hostname.len + 2);
+
+ var iso_buf: [20]u8 = undefined;
+ var age_buf: [32]u8 = undefined;
+ try writer.print("{s} ({s})\n", .{
+ formatTimestamp(&iso_buf, entry.timestamp),
+ relativeAge(&age_buf, now, entry.timestamp),
+ });
+ }
+}
+
+/// Whether a cache `key` matches a positional `query`. A `user@host` query
+/// (containing `@`) matches one exact key; a bare `host` query matches every
+/// key on that host regardless of user, comparing against the key's host
+/// component (everything after its first `@`, or the whole key if userless).
+fn matchesQuery(key: []const u8, query: []const u8) bool {
+ if (std.mem.indexOfScalar(u8, query, '@') != null) {
+ return std.mem.eql(u8, key, query);
+ }
+
+ const at = std.mem.indexOfScalar(u8, key, '@');
+ const host = if (at) |i| key[i + 1 ..] else key;
+ return std.mem.eql(u8, host, query);
+}
+
+test matchesQuery {
+ const testing = std.testing;
+
+ // Exact user@host: only the identical key.
+ try testing.expect(matchesQuery("user@example.com", "user@example.com"));
+ try testing.expect(!matchesQuery("root@example.com", "user@example.com"));
+ try testing.expect(!matchesQuery("example.com", "user@example.com"));
+
+ // Bare host: every key on that host, plus a keyless entry for it.
+ try testing.expect(matchesQuery("user@example.com", "example.com"));
+ try testing.expect(matchesQuery("root@example.com", "example.com"));
+ try testing.expect(matchesQuery("example.com", "example.com"));
+ try testing.expect(!matchesQuery("user@other.com", "example.com"));
+}
+
+/// Format a Unix timestamp as an ISO-8601 UTC string
+/// (`YYYY-MM-DDTHH:MM:SSZ`) into `buf`, which must be at least 20 bytes.
+/// Out-of-range input is clamped so this can't crash on a garbage cache line.
+fn formatTimestamp(buf: []u8, timestamp: i64) []const u8 {
+ // Clamp to [epoch, last second of 9999-12-31Z]: `std.time.epoch`
+ // accumulates the year in a `u16` (panics beyond that), and the buffer
+ // only fits a 4-digit year.
+ const secs: u64 = @intCast(std.math.clamp(timestamp, 0, 253402300799));
+
+ const epoch = std.time.epoch;
+ const epoch_secs: epoch.EpochSeconds = .{ .secs = secs };
+ const day = epoch_secs.getEpochDay();
+ const year_day = day.calculateYearDay();
+ const month_day = year_day.calculateMonthDay();
+ const ds = epoch_secs.getDaySeconds();
+ return std.fmt.bufPrint(buf, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}Z", .{
+ year_day.year,
+ month_day.month.numeric(),
+ month_day.day_index + 1,
+ ds.getHoursIntoDay(),
+ ds.getMinutesIntoHour(),
+ ds.getSecondsIntoMinute(),
+ }) catch unreachable;
+}
+
+test formatTimestamp {
+ const testing = std.testing;
+ var buf: [20]u8 = undefined;
+
+ try testing.expectEqualStrings(
+ "2026-05-05T22:49:33Z",
+ formatTimestamp(&buf, 1778021373),
+ );
+
+ // Epoch.
+ try testing.expectEqualStrings(
+ "1970-01-01T00:00:00Z",
+ formatTimestamp(&buf, 0),
+ );
+
+ // Out-of-range inputs clamp instead of overflowing the [20]u8 /
+ // panicking inside std: negatives floor at the epoch, huge values cap
+ // at the last second of year 9999.
+ try testing.expectEqualStrings(
+ "1970-01-01T00:00:00Z",
+ formatTimestamp(&buf, -5),
+ );
+ try testing.expectEqualStrings(
+ "9999-12-31T23:59:59Z",
+ formatTimestamp(&buf, std.math.maxInt(i64)),
+ );
+}
+
+/// Format the age of `timestamp` (relative to `now`, both Unix seconds)
+/// as a coarse relative time into `buf`, e.g. "2w ago". Uses `Duration`'s
+/// unit vocabulary but keeps only the single largest unit for scannability.
+/// A non-positive age (timestamp at or after `now`) is "now".
+fn relativeAge(buf: []u8, now: i64, timestamp: i64) []const u8 {
+ // Saturating so a garbage timestamp can't overflow; clamp at 0 so a
+ // future timestamp becomes a zero age rather than going negative.
+ const age: u64 = @intCast(@max(0, now -| timestamp));
+ if (age == 0) return "now";
+
+ // Round down to the largest unit that fits, so Duration.format emits
+ // only that unit (e.g. 19d -> 2w, 90m -> 1h).
+ const units = [_]u64{
+ 365 * std.time.s_per_day, // y
+ std.time.s_per_week, // w
+ std.time.s_per_day, // d
+ std.time.s_per_hour, // h
+ std.time.s_per_min, // m
+ 1, // s
+ };
+ const unit = for (units) |u| {
+ if (age >= u) break u;
+ } else 1;
+
+ // Cap the age so `age * ns_per_s` can't overflow u64 (a garbage, e.g.
+ // hugely negative, timestamp otherwise yields an age near i64-max).
+ const max_age = std.math.maxInt(u64) / std.time.ns_per_s;
+ const rounded = @min(age, max_age) / unit * unit;
+ const d: Duration = .{ .duration = rounded * std.time.ns_per_s };
+ return std.fmt.bufPrint(buf, "{f} ago", .{d}) catch unreachable;
+}
+
+test relativeAge {
+ const testing = std.testing;
+ var buf: [32]u8 = undefined;
+ const now: i64 = 2_000_000_000; // fixed reference
+ const min = std.time.s_per_min;
+ const hour = std.time.s_per_hour;
+ const day = std.time.s_per_day;
+
+ // Out-of-range timestamps don't crash: a huge future one saturates to
+ // a non-positive age ("now"); a negative one is a large but real age.
+ try testing.expectEqualStrings("now", relativeAge(&buf, now, std.math.maxInt(i64)));
+ try testing.expectEqualStrings("63y ago", relativeAge(&buf, now, -100));
+
+ // A huge age (garbage timestamp) saturates the ns conversion instead of
+ // overflowing; it must not crash and must fit the buffer.
+ try testing.expect(std.mem.endsWith(u8, relativeAge(&buf, std.math.maxInt(i64), 0), " ago"));
+
+ // Future timestamp (clock skew) and same-instant read "now".
+ try testing.expectEqualStrings("now", relativeAge(&buf, now, now + 100));
+ try testing.expectEqualStrings("now", relativeAge(&buf, now, now));
+
+ // Only the single largest unit is kept (smaller units rounded away).
+ try testing.expectEqualStrings("30s ago", relativeAge(&buf, now, now - 30));
+ try testing.expectEqualStrings("1m ago", relativeAge(&buf, now, now - min));
+ try testing.expectEqualStrings("1m ago", relativeAge(&buf, now, now - 90)); // 90s -> 1m
+ try testing.expectEqualStrings("1h ago", relativeAge(&buf, now, now - hour));
+ try testing.expectEqualStrings("1h ago", relativeAge(&buf, now, now - (hour + 30 * min))); // 1h30m -> 1h
+ try testing.expectEqualStrings("1d ago", relativeAge(&buf, now, now - day));
+ try testing.expectEqualStrings("2w ago", relativeAge(&buf, now, now - 19 * day)); // 19d -> 2w
+}
+
+test {
+ _ = DiskCache;
+ _ = Entry;
+}
+
+test "runInner rejects multiple actions" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var stdout: std.Io.Writer.Allocating = .init(alloc);
+ defer stdout.deinit();
+ var stderr: std.Io.Writer.Allocating = .init(alloc);
+ defer stderr.deinit();
+
+ // The check runs before any cache access, so it never touches disk.
+ const cache: DiskCache = .{ .path = "/nonexistent/ssh_cache" };
+ const code = try runInner(alloc, .{
+ .add = "example.com",
+ .remove = "other.com",
+ }, null, cache, &stdout.writer, &stderr.writer);
+
+ try testing.expectEqual(@as(u8, 2), code);
+ try testing.expectEqualStrings("", stdout.written());
+ try testing.expect(std.mem.indexOf(u8, stderr.written(), "only one") != null);
+
+ // A positional query is itself an action: query + a flag conflicts.
+ stderr.clearRetainingCapacity();
+ const code2 = try runInner(alloc, .{
+ .clear = true,
+ }, "example.com", cache, &stdout.writer, &stderr.writer);
+ try testing.expectEqual(@as(u8, 2), code2);
+ try testing.expect(std.mem.indexOf(u8, stderr.written(), "only one") != null);
+}
+
+test "runInner fails cleanly when the cache is unusable" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ const io = testing.io;
+
+ // A directory where the cache file belongs fails every operation.
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.createDirPath(io, "ssh_cache");
+ const tmp_path = try tmp.dir.realPathFileAlloc(io, ".", alloc);
+ defer alloc.free(tmp_path);
+ const cache_path = try std.fs.path.join(alloc, &.{ tmp_path, "ssh_cache" });
+ defer alloc.free(cache_path);
+ const cache: DiskCache = .{ .path = cache_path };
+
+ var stdout: std.Io.Writer.Allocating = .init(alloc);
+ defer stdout.deinit();
+ var stderr: std.Io.Writer.Allocating = .init(alloc);
+ defer stderr.deinit();
+
+ const opts: []const Options = &.{
+ .{}, // the default listing
+ .{ .add = "example.com" },
+ .{ .remove = "example.com" },
+ .{ .prune = .{ .duration = std.time.ns_per_s } },
+ };
+ for (opts) |o| {
+ const code = try runInner(alloc, o, null, cache, &stdout.writer, &stderr.writer);
+ try testing.expectEqual(@as(u8, 1), code);
+ }
+}