From 69b9abf09ebad2b11a6850a28271676f6bfeb108 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sat, 15 Aug 2026 11:04:31 -0400 Subject: [PATCH] cli: version SSH terminfo cache entries Derive a version from the encoded Ghostty terminfo and require callers to pass it explicitly when reading or writing the SSH cache. Cache entries created for older or different payloads no longer suppress a required installation. --- src/cli/ssh-cache/DiskCache.zig | 51 ++++++++++++++++++++------------- src/cli/ssh.zig | 20 ++++++++----- src/cli/ssh_cache.zig | 2 ++ src/terminfo/ghostty.zig | 9 ++++++ src/terminfo/main.zig | 1 + 5 files changed, 56 insertions(+), 27 deletions(-) diff --git a/src/cli/ssh-cache/DiskCache.zig b/src/cli/ssh-cache/DiskCache.zig index bc5619d71..ea64dfe83 100644 --- a/src/cli/ssh-cache/DiskCache.zig +++ b/src/cli/ssh-cache/DiskCache.zig @@ -58,6 +58,7 @@ pub fn add( self: DiskCache, alloc: Allocator, key: []const u8, + version: []const u8, timestamp: i64, ) !void { if (!isValidCacheKey(key)) return error.InvalidCacheKey; @@ -111,16 +112,19 @@ pub fn add( // `deinitEntries` defer to walk. if (entries.getPtr(key)) |existing| { existing.timestamp = timestamp; + const version_copy = try alloc.dupe(u8, version); + alloc.free(existing.terminfo_version); + existing.terminfo_version = version_copy; } else { const key_copy = try alloc.dupe(u8, key); errdefer alloc.free(key_copy); - const terminfo_copy = try alloc.dupe(u8, "xterm-ghostty"); - errdefer alloc.free(terminfo_copy); + const version_copy = try alloc.dupe(u8, version); + errdefer alloc.free(version_copy); try entries.put(key_copy, .{ .hostname = key_copy, .timestamp = timestamp, - .terminfo_version = terminfo_copy, + .terminfo_version = version_copy, }); } @@ -225,12 +229,13 @@ pub fn prune( return expired.items.len; } -/// Check if a key exists in the cache. -/// Returns false if the cache file doesn't exist. +/// Check if a key with `version` exists in the cache. +/// Returns false if the cache file doesn't exist or the version doesn't match. pub fn contains( self: DiskCache, alloc: Allocator, key: []const u8, + version: []const u8, ) !bool { if (!isValidCacheKey(key)) return error.InvalidCacheKey; @@ -249,7 +254,8 @@ pub fn contains( var entries = try readEntries(alloc, file); defer deinitEntries(alloc, &entries); - return entries.contains(key); + const entry = entries.get(key) orelse return false; + return std.mem.eql(u8, entry.terminfo_version, version); } fn fixupPermissions(file: std.Io.File) !void { @@ -506,20 +512,25 @@ test "disk cache operations" { // Setup our cache. Adding the same key twice exercises both the new // and existing-entry paths. const cache: DiskCache = .{ .path = path }; - try cache.add(alloc, "example.com", std.Io.Timestamp.now(testing.io, .real).toSeconds()); - try cache.add(alloc, "example.com", std.Io.Timestamp.now(testing.io, .real).toSeconds()); - try testing.expect(try cache.contains(alloc, "example.com")); + try cache.add(alloc, "example.com", "v1", std.Io.Timestamp.now(testing.io, .real).toSeconds()); + try testing.expect(!try cache.contains(alloc, "example.com", "v2")); + try cache.add(alloc, "example.com", "v2", std.Io.Timestamp.now(testing.io, .real).toSeconds()); + try testing.expect(try cache.contains(alloc, "example.com", "v2")); // List var entries = try cache.list(alloc); - deinitEntries(alloc, &entries); + defer deinitEntries(alloc, &entries); + try testing.expectEqualStrings( + "v2", + entries.get("example.com").?.terminfo_version, + ); // Remove reports that it removed the entry, and a second remove of the // same key reports nothing to remove. try testing.expect(try cache.remove(alloc, "example.com")); try testing.expect(!try cache.remove(alloc, "example.com")); - try testing.expect(!(try cache.contains(alloc, "example.com"))); - try cache.add(alloc, "example.com", std.Io.Timestamp.now(testing.io, .real).toSeconds()); + try testing.expect(!(try cache.contains(alloc, "example.com", "v2"))); + try cache.add(alloc, "example.com", "v2", std.Io.Timestamp.now(testing.io, .real).toSeconds()); } test "disk cache cleans up temp files" { @@ -535,8 +546,8 @@ test "disk cache cleans up temp files" { defer alloc.free(cache_path); const cache: DiskCache = .{ .path = cache_path }; - try cache.add(alloc, "example.com", std.Io.Timestamp.now(testing.io, .real).toSeconds()); - try cache.add(alloc, "example.org", std.Io.Timestamp.now(testing.io, .real).toSeconds()); + try cache.add(alloc, "example.com", "v1", std.Io.Timestamp.now(testing.io, .real).toSeconds()); + try cache.add(alloc, "example.org", "v1", std.Io.Timestamp.now(testing.io, .real).toSeconds()); // Verify only the cache file exists and no temp files left behind var count: usize = 0; @@ -566,20 +577,20 @@ test "disk cache prune" { const day = std.time.s_per_day; const hour = std.time.s_per_hour; const now = std.Io.Timestamp.now(testing.io, .real).toSeconds(); - try cache.add(alloc, "recent.com", now - hour); - try cache.add(alloc, "old.com", now - 100 * day); + try cache.add(alloc, "recent.com", "v1", now - hour); + try cache.add(alloc, "old.com", "v1", now - 100 * day); // Prune entries older than 90 days: only old.com goes. try testing.expectEqual(@as(usize, 1), try cache.prune(alloc, 90 * day)); - try testing.expect(try cache.contains(alloc, "recent.com")); - try testing.expect(!try cache.contains(alloc, "old.com")); + try testing.expect(try cache.contains(alloc, "recent.com", "v1")); + try testing.expect(!try cache.contains(alloc, "old.com", "v1")); // Pruning again removes nothing. try testing.expectEqual(@as(usize, 0), try cache.prune(alloc, 90 * day)); // Sub-day granularity: a 30-minute max age prunes the hour-old entry. try testing.expectEqual(@as(usize, 1), try cache.prune(alloc, 30 * std.time.s_per_min)); - try testing.expect(!try cache.contains(alloc, "recent.com")); + try testing.expect(!try cache.contains(alloc, "recent.com", "v1")); } test "disk cache prune missing file" { @@ -707,7 +718,7 @@ test "disk cache add survives allocation failure" { ); const alloc = failing.allocator(); - if (cache.add(alloc, "user@example.com", 100)) |_| { + if (cache.add(alloc, "user@example.com", "v1", 100)) |_| { if (!failing.has_induced_failure) break; } else |err| { try testing.expectEqual(error.OutOfMemory, err); diff --git a/src/cli/ssh.zig b/src/cli/ssh.zig index b3044766f..ed4a79755 100644 --- a/src/cli/ssh.zig +++ b/src/cli/ssh.zig @@ -7,7 +7,7 @@ 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 terminfopkg = @import("../terminfo/main.zig"); const global = @import("../global.zig"); const log = std.log.scoped(.ssh); @@ -252,7 +252,11 @@ fn runInner( } else null; if (cache) |c| { - const cached = c.contains(alloc, dest) catch |err| cached: { + const cached = c.contains( + alloc, + dest, + terminfopkg.version, + ) catch |err| cached: { if (DiskCache.isFailure(err)) warnPrint( stderr, "unable to read the cache '{s}': {t}", @@ -313,10 +317,12 @@ fn runInner( // 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.Io.Timestamp.now( - global.io(), - .real, - ).toSeconds())) |_| { + if (entry.cache.add( + alloc, + entry.dest, + terminfopkg.version, + std.Io.Timestamp.now(global.io(), .real).toSeconds(), + )) |_| { verbosePrint(opts, stderr, "cache: wrote {s}", .{entry.dest}); } else |err| { if (DiskCache.isFailure(err)) { @@ -473,7 +479,7 @@ fn installRemoteTerminfo( ) !void { var buf: std.Io.Writer.Allocating = .init(alloc); defer buf.deinit(); - try ghostty_terminfo.encode(&buf.writer); + try terminfopkg.ghostty.encode(&buf.writer); const terminfo = buf.written(); // ControlPath is in TMPDIR with a short, random basename. ssh uses diff --git a/src/cli/ssh_cache.zig b/src/cli/ssh_cache.zig index f509f69d4..4142e1600 100644 --- a/src/cli/ssh_cache.zig +++ b/src/cli/ssh_cache.zig @@ -5,6 +5,7 @@ const args = @import("args.zig"); const global = @import("../global.zig"); const Action = @import("ghostty.zig").Action; const Duration = @import("../config.zig").Config.Duration; +const terminfopkg = @import("../terminfo/main.zig"); pub const Entry = @import("ssh-cache/Entry.zig"); pub const DiskCache = @import("ssh-cache/DiskCache.zig"); @@ -198,6 +199,7 @@ pub fn runInner( cache.add( alloc, dest, + terminfopkg.version, std.Io.Timestamp.now(global.io(), .real).toSeconds(), ) catch |err| switch (err) { error.InvalidCacheKey => { diff --git a/src/terminfo/ghostty.zig b/src/terminfo/ghostty.zig index 2b7434cbf..ea64ffcf3 100644 --- a/src/terminfo/ghostty.zig +++ b/src/terminfo/ghostty.zig @@ -392,6 +392,15 @@ pub const ghostty: Source = .{ }, }; +/// A content-derived version of the encoded terminfo source. +pub const version = version: { + @setEvalBranchQuota(100_000); + var hashing: std.Io.Writer.Hashing(std.hash.Wyhash) = + .initHasher(.init(0), &.{}); + ghostty.encode(&hashing.writer) catch unreachable; + break :version std.fmt.comptimePrint("{x}", .{hashing.hasher.final()}); +}; + test "encode" { // Encode var buf: [1024 * 16]u8 = undefined; diff --git a/src/terminfo/main.zig b/src/terminfo/main.zig index c76c328cc..f6c709b91 100644 --- a/src/terminfo/main.zig +++ b/src/terminfo/main.zig @@ -6,6 +6,7 @@ //! extract this into a more full-featured library on its own. pub const ghostty = @import("ghostty.zig").ghostty; +pub const version = @import("ghostty.zig").version; pub const Source = @import("Source.zig"); test {