cli: rewrite ssh-cache diskcache and test IO

This commit is contained in:
Mitchell Hashimoto
2025-07-09 07:28:56 -07:00
parent 7cfb026e84
commit 8ab3010bb8
4 changed files with 829 additions and 655 deletions

View File

@@ -4,21 +4,15 @@ const Allocator = std.mem.Allocator;
const xdg = @import("../os/xdg.zig");
const args = @import("args.zig");
const Action = @import("action.zig").Action;
pub const CacheError = error{
InvalidCacheKey,
CacheLocked,
} || fs.File.OpenError || fs.File.WriteError || Allocator.Error;
const MAX_CACHE_SIZE = 512 * 1024; // 512KB - sufficient for approximately 10k entries
const NEVER_EXPIRE = 0;
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,
host: ?[]const u8 = null,
@"expire-days": u32 = NEVER_EXPIRE,
@"expire-days": ?u32 = null,
pub fn deinit(self: *Options) void {
_ = self;
@@ -30,373 +24,6 @@ pub const Options = struct {
}
};
const CacheEntry = struct {
hostname: []const u8,
timestamp: i64,
terminfo_version: []const u8,
fn parse(line: []const u8) ?CacheEntry {
const trimmed = std.mem.trim(u8, line, " \t\r\n");
if (trimmed.len == 0) return null;
// Parse format: hostname|timestamp|terminfo_version
var iter = std.mem.tokenizeScalar(u8, trimmed, '|');
const hostname = iter.next() orelse return null;
const timestamp_str = iter.next() orelse return null;
const terminfo_version = iter.next() orelse "xterm-ghostty";
const timestamp = std.fmt.parseInt(i64, timestamp_str, 10) catch |err| {
std.log.warn("Invalid timestamp in cache entry: {s} err={}", .{ timestamp_str, err });
return null;
};
return .{
.hostname = hostname,
.timestamp = timestamp,
.terminfo_version = terminfo_version,
};
}
fn format(self: CacheEntry, writer: anytype) !void {
try writer.print("{s}|{d}|{s}\n", .{ self.hostname, self.timestamp, self.terminfo_version });
}
fn isExpired(self: CacheEntry, expire_days: u32) bool {
if (expire_days == NEVER_EXPIRE) return false;
const now = std.time.timestamp();
const age_days = @divTrunc(now - self.timestamp, std.time.s_per_day);
return age_days > expire_days;
}
};
const AddResult = enum {
added,
updated,
};
fn getCachePath(allocator: Allocator) ![]const u8 {
const state_dir = try xdg.state(allocator, .{ .subdir = "ghostty" });
defer allocator.free(state_dir);
return try std.fs.path.join(allocator, &.{ state_dir, "ssh_cache" });
}
// Supports both standalone hostnames and user@hostname format
fn isValidCacheKey(key: []const u8) bool {
// 253 + 1 + 64 for user@hostname
if (key.len == 0 or key.len > 320) return false;
// Check for user@hostname format
if (std.mem.indexOf(u8, key, "@")) |at_pos| {
const user = key[0..at_pos];
const hostname = key[at_pos + 1 ..];
return isValidUser(user) and isValidHostname(hostname);
}
return isValidHostname(key);
}
// Basic hostname validation - accepts domains and IPs
// (including IPv6 in brackets)
fn isValidHostname(host: []const u8) bool {
if (host.len == 0 or host.len > 253) return false;
// Handle IPv6 addresses in brackets
if (host.len >= 4 and host[0] == '[' and host[host.len - 1] == ']') {
const ipv6_part = host[1 .. host.len - 1];
if (ipv6_part.len == 0) return false;
var has_colon = false;
for (ipv6_part) |c| {
switch (c) {
'a'...'f', 'A'...'F', '0'...'9' => {},
':' => has_colon = true,
else => return false,
}
}
return has_colon;
}
// Standard hostname/domain validation
for (host) |c| {
switch (c) {
'a'...'z', 'A'...'Z', '0'...'9', '.', '-' => {},
else => return false,
}
}
// No leading/trailing dots or hyphens, no consecutive dots
if (host[0] == '.' or host[0] == '-' or
host[host.len - 1] == '.' or host[host.len - 1] == '-')
{
return false;
}
return std.mem.indexOf(u8, host, "..") == null;
}
fn isValidUser(user: []const u8) bool {
if (user.len == 0 or user.len > 64) return false;
for (user) |c| {
switch (c) {
'a'...'z', 'A'...'Z', '0'...'9', '_', '-', '.' => {},
else => return false,
}
}
return true;
}
fn acquireFileLock(file: fs.File) CacheError!void {
_ = file.tryLock(.exclusive) catch {
return CacheError.CacheLocked;
};
}
fn readCacheFile(
alloc: Allocator,
path: []const u8,
entries: *std.StringHashMap(CacheEntry),
) !void {
const file = fs.openFileAbsolute(path, .{}) catch |err| switch (err) {
error.FileNotFound => return,
else => return err,
};
defer file.close();
const content = try file.readToEndAlloc(alloc, MAX_CACHE_SIZE);
defer alloc.free(content);
var lines = std.mem.tokenizeScalar(u8, content, '\n');
while (lines.next()) |line| {
const trimmed = std.mem.trim(u8, line, " \t\r");
if (CacheEntry.parse(trimmed)) |entry| {
// Always allocate hostname first to avoid key pointer confusion
const hostname_copy = try alloc.dupe(u8, entry.hostname);
errdefer alloc.free(hostname_copy);
const gop = try entries.getOrPut(hostname_copy);
if (!gop.found_existing) {
const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version);
gop.value_ptr.* = .{
.hostname = hostname_copy,
.timestamp = entry.timestamp,
.terminfo_version = terminfo_copy,
};
} else {
// Don't need the copy since entry already exists
alloc.free(hostname_copy);
// Handle duplicate entries - keep newer timestamp
if (entry.timestamp > gop.value_ptr.timestamp) {
gop.value_ptr.timestamp = entry.timestamp;
if (!std.mem.eql(u8, gop.value_ptr.terminfo_version, entry.terminfo_version)) {
alloc.free(gop.value_ptr.terminfo_version);
const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version);
gop.value_ptr.terminfo_version = terminfo_copy;
}
}
}
}
}
}
// Atomic write via temp file + rename, filters out expired entries
fn writeCacheFile(
alloc: Allocator,
path: []const u8,
entries: *const std.StringHashMap(CacheEntry),
expire_days: u32,
) !void {
// Ensure parent directory exists
const dir = std.fs.path.dirname(path).?;
fs.makeDirAbsolute(dir) catch |err| switch (err) {
error.PathAlreadyExists => {},
else => return err,
};
// Write to temp file first
const tmp_path = try std.fmt.allocPrint(alloc, "{s}.tmp", .{path});
defer alloc.free(tmp_path);
const tmp_file = try fs.createFileAbsolute(tmp_path, .{ .mode = 0o600 });
defer tmp_file.close();
errdefer fs.deleteFileAbsolute(tmp_path) catch {};
const writer = tmp_file.writer();
// Only write non-expired entries
var iter = entries.iterator();
while (iter.next()) |kv| {
if (!kv.value_ptr.isExpired(expire_days)) {
try kv.value_ptr.format(writer);
}
}
// Atomic replace
try fs.renameAbsolute(tmp_path, path);
}
fn checkHost(alloc: Allocator, host: []const u8) !bool {
if (!isValidCacheKey(host)) return CacheError.InvalidCacheKey;
const path = try getCachePath(alloc);
var entries = std.StringHashMap(CacheEntry).init(alloc);
try readCacheFile(alloc, path, &entries);
return entries.contains(host);
}
fn addHost(alloc: Allocator, host: []const u8) !AddResult {
if (!isValidCacheKey(host)) return CacheError.InvalidCacheKey;
const path = try getCachePath(alloc);
// Create cache directory if needed
const dir = std.fs.path.dirname(path).?;
fs.makeDirAbsolute(dir) catch |err| switch (err) {
error.PathAlreadyExists => {},
else => return err,
};
// Open or create cache file with secure permissions
const file = fs.createFileAbsolute(path, .{
.read = true,
.truncate = false,
.mode = 0o600,
}) catch |err| switch (err) {
error.PathAlreadyExists => blk: {
const existing_file = fs.openFileAbsolute(path, .{ .mode = .read_write }) catch |open_err| {
return open_err;
};
// Verify and fix permissions on existing file
const stat = existing_file.stat() catch |stat_err| {
existing_file.close();
return stat_err;
};
// Ensure file has correct permissions (readable/writable by owner only)
if (stat.mode & 0o777 != 0o600) {
existing_file.chmod(0o600) catch |chmod_err| {
existing_file.close();
return chmod_err;
};
}
break :blk existing_file;
},
else => return err,
};
defer file.close();
try acquireFileLock(file);
defer file.unlock();
var entries = std.StringHashMap(CacheEntry).init(alloc);
try readCacheFile(alloc, path, &entries);
// Add or update entry
const gop = try entries.getOrPut(host);
const result = if (!gop.found_existing) blk: {
gop.key_ptr.* = try alloc.dupe(u8, host);
gop.value_ptr.* = .{
.hostname = gop.key_ptr.*,
.timestamp = std.time.timestamp(),
.terminfo_version = "xterm-ghostty",
};
break :blk AddResult.added;
} else blk: {
// Update timestamp for existing entry
gop.value_ptr.timestamp = std.time.timestamp();
break :blk AddResult.updated;
};
try writeCacheFile(alloc, path, &entries, NEVER_EXPIRE);
return result;
}
fn removeHost(alloc: Allocator, host: []const u8) !void {
if (!isValidCacheKey(host)) return CacheError.InvalidCacheKey;
const path = try getCachePath(alloc);
const file = fs.openFileAbsolute(path, .{ .mode = .read_write }) catch |err| switch (err) {
error.FileNotFound => return,
else => return err,
};
defer file.close();
try acquireFileLock(file);
defer file.unlock();
var entries = std.StringHashMap(CacheEntry).init(alloc);
try readCacheFile(alloc, path, &entries);
_ = entries.fetchRemove(host);
try writeCacheFile(alloc, path, &entries, NEVER_EXPIRE);
}
fn listHosts(alloc: Allocator, writer: anytype) !void {
const path = try getCachePath(alloc);
var entries = std.StringHashMap(CacheEntry).init(alloc);
readCacheFile(alloc, path, &entries) catch |err| switch (err) {
error.FileNotFound => {
try writer.print("No hosts in cache.\n", .{});
return;
},
else => return err,
};
if (entries.count() == 0) {
try writer.print("No hosts in cache.\n", .{});
return;
}
// Sort entries by hostname for consistent output
var items = std.ArrayList(CacheEntry).init(alloc);
defer items.deinit();
var iter = entries.iterator();
while (iter.next()) |kv| {
try items.append(kv.value_ptr.*);
}
std.mem.sort(CacheEntry, items.items, {}, struct {
fn lessThan(_: void, a: CacheEntry, b: CacheEntry) bool {
return std.mem.lessThan(u8, a.hostname, b.hostname);
}
}.lessThan);
try writer.print("Cached hosts ({d}):\n", .{items.items.len});
const now = std.time.timestamp();
for (items.items) |entry| {
const age_days = @divTrunc(now - entry.timestamp, std.time.s_per_day);
if (age_days == 0) {
try writer.print(" {s} (today)\n", .{entry.hostname});
} else if (age_days == 1) {
try writer.print(" {s} (yesterday)\n", .{entry.hostname});
} else {
try writer.print(" {s} ({d} days ago)\n", .{ entry.hostname, age_days });
}
}
}
fn clearCache(alloc: Allocator) !void {
const path = try getCachePath(alloc);
fs.deleteFileAbsolute(path) catch |err| switch (err) {
error.FileNotFound => {},
else => return err,
};
}
/// Manage the SSH terminfo cache for automatic remote host setup.
///
/// When SSH integration is enabled with `shell-integration-features = ssh-terminfo`,
@@ -407,6 +34,11 @@ fn clearCache(alloc: Allocator) !void {
/// Entries older than the expiration period are automatically removed during cache
/// operations. By default, entries never expire.
///
/// Only one of `--clear`, `--add`, `--remove`, or `--host` can be specified.
/// If multiple are specified, one of the actions will be executed but
/// it isn't guaranteed which one. This is entirely unsafe so you should split
/// multiple actions into separate commands.
///
/// Examples:
/// ghostty +ssh-cache # List all cached hosts
/// ghostty +ssh-cache --host=example.com # Check if host is cached
@@ -432,34 +64,34 @@ pub fn run(alloc_gpa: Allocator) !u8 {
const stdout = std.io.getStdOut().writer();
const stderr = std.io.getStdErr().writer();
// 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 clearCache(alloc);
try cache.clear();
try stdout.print("Cache cleared.\n", .{});
return 0;
}
if (opts.add) |host| {
const result = addHost(alloc, host) catch |err| {
const Error = error{PermissionDenied} || @TypeOf(err);
switch (@as(Error, err)) {
CacheError.InvalidCacheKey => {
try stderr.print("Error: Invalid hostname format '{s}'\n", .{host});
try stderr.print("Expected format: hostname or user@hostname\n", .{});
return 1;
},
CacheError.CacheLocked => {
try stderr.print("Error: Cache is busy, try again\n", .{});
return 1;
},
error.AccessDenied, error.PermissionDenied => {
try stderr.print("Error: Permission denied\n", .{});
return 1;
},
else => {
try stderr.print("Error: Unable to add '{s}' to cache\n", .{host});
return 1;
},
}
const result = cache.add(alloc, host) catch |err| switch (err) {
DiskCache.Error.HostnameIsInvalid => {
try stderr.print("Error: Invalid hostname format '{s}'\n", .{host});
try stderr.print("Expected format: hostname or user@hostname\n", .{});
return 1;
},
DiskCache.Error.CacheIsLocked => {
try stderr.print("Error: Cache is busy, try again\n", .{});
return 1;
},
else => {
try stderr.print(
"Error: Unable to add '{s}' to cache. Error: {}\n",
.{ host, err },
);
return 1;
},
};
switch (result) {
@@ -470,50 +102,42 @@ pub fn run(alloc_gpa: Allocator) !u8 {
}
if (opts.remove) |host| {
removeHost(alloc, host) catch |err| {
const Error = error{PermissionDenied} || @TypeOf(err);
switch (@as(Error, err)) {
CacheError.InvalidCacheKey => {
try stderr.print("Error: Invalid hostname format '{s}'\n", .{host});
try stderr.print("Expected format: hostname or user@hostname\n", .{});
return 1;
},
CacheError.CacheLocked => {
try stderr.print("Error: Cache is busy, try again\n", .{});
return 1;
},
error.AccessDenied, error.PermissionDenied => {
try stderr.print("Error: Permission denied\n", .{});
return 1;
},
else => {
try stderr.print("Error: Unable to remove '{s}' from cache\n", .{host});
return 1;
},
}
cache.remove(alloc, host) catch |err| switch (err) {
DiskCache.Error.HostnameIsInvalid => {
try stderr.print("Error: Invalid hostname format '{s}'\n", .{host});
try stderr.print("Expected format: hostname or user@hostname\n", .{});
return 1;
},
DiskCache.Error.CacheIsLocked => {
try stderr.print("Error: Cache is busy, try again\n", .{});
return 1;
},
else => {
try stderr.print(
"Error: Unable to remove '{s}' from cache. Error: {}\n",
.{ host, err },
);
return 1;
},
};
try stdout.print("Removed '{s}' from cache.\n", .{host});
return 0;
}
if (opts.host) |host| {
const cached = checkHost(alloc, host) catch |err| {
const Error = error{PermissionDenied} || @TypeOf(err);
switch (@as(Error, err)) {
CacheError.InvalidCacheKey => {
try stderr.print("Error: Invalid hostname format '{s}'\n", .{host});
try stderr.print("Expected format: hostname or user@hostname\n", .{});
return 1;
},
error.AccessDenied, error.PermissionDenied => {
try stderr.print("Error: Permission denied\n", .{});
return 1;
},
else => {
try stderr.print("Error: Unable to check host '{s}' in cache\n", .{host});
return 1;
},
}
const cached = cache.contains(alloc, host) catch |err| switch (err) {
error.HostnameIsInvalid => {
try stderr.print("Error: Invalid hostname format '{s}'\n", .{host});
try stderr.print("Expected format: hostname or user@hostname\n", .{});
return 1;
},
else => {
try stderr.print(
"Error: Unable to check host '{s}' in cache. Error: {}\n",
.{ host, err },
);
return 1;
},
};
if (cached) {
@@ -532,224 +156,53 @@ pub fn run(alloc_gpa: Allocator) !u8 {
}
// Default action: list all hosts
try listHosts(alloc, stdout);
var entries = try cache.list(alloc);
defer DiskCache.deinitEntries(alloc, &entries);
try listEntries(alloc, &entries, stdout);
return 0;
}
// Tests
test "hostname validation - valid cases" {
const testing = std.testing;
try testing.expect(isValidHostname("example.com"));
try testing.expect(isValidHostname("sub.example.com"));
try testing.expect(isValidHostname("host-name.domain.org"));
try testing.expect(isValidHostname("192.168.1.1"));
try testing.expect(isValidHostname("a"));
try testing.expect(isValidHostname("1"));
}
test "hostname validation - IPv6 addresses" {
const testing = std.testing;
try testing.expect(isValidHostname("[::1]"));
try testing.expect(isValidHostname("[2001:db8::1]"));
try testing.expect(!isValidHostname("[fe80::1%eth0]")); // Interface notation not supported
try testing.expect(!isValidHostname("[]")); // Empty IPv6
try testing.expect(!isValidHostname("[invalid]")); // No colons
}
test "hostname validation - invalid cases" {
const testing = std.testing;
try testing.expect(!isValidHostname(""));
try testing.expect(!isValidHostname("host\nname"));
try testing.expect(!isValidHostname(".example.com"));
try testing.expect(!isValidHostname("example.com."));
try testing.expect(!isValidHostname("host..domain"));
try testing.expect(!isValidHostname("-hostname"));
try testing.expect(!isValidHostname("hostname-"));
try testing.expect(!isValidHostname("host name"));
try testing.expect(!isValidHostname("host_name"));
try testing.expect(!isValidHostname("host@domain"));
try testing.expect(!isValidHostname("host:port"));
// Too long
const long_host = "a" ** 254;
try testing.expect(!isValidHostname(long_host));
}
test "user validation - valid cases" {
const testing = std.testing;
try testing.expect(isValidUser("user"));
try testing.expect(isValidUser("deploy"));
try testing.expect(isValidUser("test-user"));
try testing.expect(isValidUser("user_name"));
try testing.expect(isValidUser("user.name"));
try testing.expect(isValidUser("user123"));
try testing.expect(isValidUser("a"));
}
test "user validation - complex realistic cases" {
const testing = std.testing;
try testing.expect(isValidUser("git"));
try testing.expect(isValidUser("ubuntu"));
try testing.expect(isValidUser("root"));
try testing.expect(isValidUser("service.account"));
try testing.expect(isValidUser("user-with-dashes"));
}
test "user validation - invalid cases" {
const testing = std.testing;
try testing.expect(!isValidUser(""));
try testing.expect(!isValidUser("user name"));
try testing.expect(!isValidUser("user@domain"));
try testing.expect(!isValidUser("user:group"));
try testing.expect(!isValidUser("user\nname"));
// Too long
const long_user = "a" ** 65;
try testing.expect(!isValidUser(long_user));
}
test "cache key validation - hostname format" {
const testing = std.testing;
try testing.expect(isValidCacheKey("example.com"));
try testing.expect(isValidCacheKey("sub.example.com"));
try testing.expect(isValidCacheKey("192.168.1.1"));
try testing.expect(isValidCacheKey("[::1]"));
try testing.expect(!isValidCacheKey(""));
try testing.expect(!isValidCacheKey(".invalid.com"));
}
test "cache key validation - user@hostname format" {
const testing = std.testing;
try testing.expect(isValidCacheKey("user@example.com"));
try testing.expect(isValidCacheKey("deploy@prod.server.com"));
try testing.expect(isValidCacheKey("test-user@192.168.1.1"));
try testing.expect(isValidCacheKey("user_name@host.domain.org"));
try testing.expect(isValidCacheKey("git@github.com"));
try testing.expect(isValidCacheKey("ubuntu@[::1]"));
try testing.expect(!isValidCacheKey("@example.com"));
try testing.expect(!isValidCacheKey("user@"));
try testing.expect(!isValidCacheKey("user@@host"));
try testing.expect(!isValidCacheKey("user@.invalid.com"));
}
test "cache entry expiration" {
const testing = std.testing;
const now = std.time.timestamp();
const fresh_entry = CacheEntry{
.hostname = "test.com",
.timestamp = now - std.time.s_per_day, // 1 day old
.terminfo_version = "xterm-ghostty",
};
try testing.expect(!fresh_entry.isExpired(90));
const old_entry = CacheEntry{
.hostname = "old.com",
.timestamp = now - (std.time.s_per_day * 100), // 100 days old
.terminfo_version = "xterm-ghostty",
};
try testing.expect(old_entry.isExpired(90));
// Test never-expire case
try testing.expect(!old_entry.isExpired(NEVER_EXPIRE));
}
test "cache entry expiration - boundary cases" {
const testing = std.testing;
const now = std.time.timestamp();
// Exactly at expiration boundary
const boundary_entry = CacheEntry{
.hostname = "boundary.com",
.timestamp = now - (std.time.s_per_day * 30), // Exactly 30 days old
.terminfo_version = "xterm-ghostty",
};
try testing.expect(!boundary_entry.isExpired(30)); // Should not be expired
try testing.expect(boundary_entry.isExpired(29)); // Should be expired
}
test "cache entry parsing - valid formats" {
const testing = std.testing;
const entry = CacheEntry.parse("example.com|1640995200|xterm-ghostty").?;
try testing.expectEqualStrings("example.com", entry.hostname);
try testing.expectEqual(@as(i64, 1640995200), entry.timestamp);
try testing.expectEqualStrings("xterm-ghostty", entry.terminfo_version);
// Test default terminfo version
const entry_no_version = CacheEntry.parse("test.com|1640995200").?;
try testing.expectEqualStrings("xterm-ghostty", entry_no_version.terminfo_version);
// Test complex hostnames
const complex_entry = CacheEntry.parse("user@server.example.com|1640995200|xterm-ghostty").?;
try testing.expectEqualStrings("user@server.example.com", complex_entry.hostname);
}
test "cache entry parsing - invalid formats" {
const testing = std.testing;
try testing.expect(CacheEntry.parse("") == null);
try testing.expect(CacheEntry.parse("v1") == null); // Invalid format (no pipe)
try testing.expect(CacheEntry.parse("example.com") == null); // Missing timestamp
try testing.expect(CacheEntry.parse("example.com|invalid") == null); // Invalid timestamp
try testing.expect(CacheEntry.parse("example.com|1640995200|") != null); // Empty terminfo should default
}
test "cache entry parsing - malformed data resilience" {
const testing = std.testing;
// Extra pipes should not break parsing
try testing.expect(CacheEntry.parse("host|123|term|extra") != null);
// Whitespace handling
try testing.expect(CacheEntry.parse(" host|123|term ") != null);
try testing.expect(CacheEntry.parse("\n") == null);
try testing.expect(CacheEntry.parse(" \t \n") == null);
}
test "duplicate cache entries - memory management" {
const testing = std.testing;
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var entries = std.StringHashMap(CacheEntry).init(alloc);
defer entries.deinit();
// Simulate reading a cache file with duplicate hostnames
const cache_content = "example.com|1640995200|xterm-ghostty\nexample.com|1640995300|xterm-ghostty-v2\n";
var lines = std.mem.tokenizeScalar(u8, cache_content, '\n');
while (lines.next()) |line| {
const trimmed = std.mem.trim(u8, line, " \t\r");
if (CacheEntry.parse(trimmed)) |entry| {
const gop = try entries.getOrPut(entry.hostname);
if (!gop.found_existing) {
const hostname_copy = try alloc.dupe(u8, entry.hostname);
const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version);
gop.key_ptr.* = hostname_copy;
gop.value_ptr.* = CacheEntry{
.hostname = hostname_copy,
.timestamp = entry.timestamp,
.terminfo_version = terminfo_copy,
};
} else {
// Test the duplicate handling logic
if (entry.timestamp > gop.value_ptr.timestamp) {
gop.value_ptr.timestamp = entry.timestamp;
if (!std.mem.eql(u8, gop.value_ptr.terminfo_version, entry.terminfo_version)) {
alloc.free(gop.value_ptr.terminfo_version);
const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version);
gop.value_ptr.terminfo_version = terminfo_copy;
}
}
}
}
fn listEntries(
alloc: Allocator,
entries: *const std.StringHashMap(Entry),
writer: anytype,
) !void {
if (entries.count() == 0) {
try writer.print("No hosts in cache.\n", .{});
return;
}
// Verify only one entry exists with the newer timestamp
try testing.expect(entries.count() == 1);
const entry = entries.get("example.com").?;
try testing.expectEqual(@as(i64, 1640995300), entry.timestamp);
try testing.expectEqualStrings("xterm-ghostty-v2", entry.terminfo_version);
// Sort entries by hostname for consistent output
var items = std.ArrayList(Entry).init(alloc);
defer items.deinit();
var iter = entries.iterator();
while (iter.next()) |kv| {
try items.append(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);
try writer.print("Cached hosts ({d}):\n", .{items.items.len});
const now = std.time.timestamp();
for (items.items) |entry| {
const age_days = @divTrunc(now - entry.timestamp, std.time.s_per_day);
if (age_days == 0) {
try writer.print(" {s} (today)\n", .{entry.hostname});
} else if (age_days == 1) {
try writer.print(" {s} (yesterday)\n", .{entry.hostname});
} else {
try writer.print(" {s} ({d} days ago)\n", .{ entry.hostname, age_days });
}
}
}
test {
_ = DiskCache;
_ = Entry;
}