diff options
| author | sl0p <sl0p@sl0p.foo> | 2026-08-13 22:00:03 +0200 |
|---|---|---|
| committer | sl0p <sl0p@sl0p.foo> | 2026-08-13 22:00:03 +0200 |
| commit | 2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8 (patch) | |
| tree | 7cd6274303ba1da1f8bd57371aec336dd687fb58 /vendor/libghostty-vt/src/cli/ssh_cache.zig | |
| download | slosh-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.zig | 555 |
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); + } +} |
