Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489const std = @import("std");const fs = std.fs;const Allocator = std.mem.Allocator;const args = @import("args.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 cachepub 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.fs.File = .stdout(); var stdout_writer = stdout_file.writer(&stdout_buffer); const stdout = &stdout_writer.interface;
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;
// 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); 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, }; }
const result = runInner(alloc, opts, query, 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, 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; }
// Setup our disk cache to the standard location const cache_path = try DiskCache.defaultPath(alloc, "ghostty"); const cache: DiskCache = .{ .path = cache_path };
if (opts.clear) { try cache.clear(); return 0; }
if (opts.add) |dest| { cache.add(alloc, dest, std.time.timestamp()) 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. Error: {}\n", .{ dest, 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. Error: {}\n", .{ dest, 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. Error: {}\n", .{err}); return 1; }; try stdout.print("Pruned cache entries: {d}\n", .{pruned}); return 0; }
var entries = try cache.list(alloc); 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.time.timestamp(); 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 code = try runInner(alloc, .{ .add = "example.com", .remove = "other.com", }, null, &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", &stdout.writer, &stderr.writer); try testing.expectEqual(@as(u8, 2), code2); try testing.expect(std.mem.indexOf(u8, stderr.written(), "only one") != null);}