diff --git a/src/cli/ssh-cache/DiskCache.zig b/src/cli/ssh-cache/DiskCache.zig index d9232bea8..bb11e74fb 100644 --- a/src/cli/ssh-cache/DiskCache.zig +++ b/src/cli/ssh-cache/DiskCache.zig @@ -17,14 +17,6 @@ const MAX_CACHE_SIZE = 512 * 1024; /// Path to a file where the cache is stored. path: []const u8, -pub const DefaultPathError = Allocator.Error || error{ - /// The general error that is returned for any filesystem error - /// that may have resulted in the XDG lookup failing. - XdgLookupFailed, -}; - -pub const Error = error{ CacheIsLocked, HostnameIsInvalid }; - /// Returns the default path for the cache for a given program. /// /// On all platforms, this is `${XDG_STATE_HOME}/ghostty/ssh_cache`. @@ -33,7 +25,7 @@ pub const Error = error{ CacheIsLocked, HostnameIsInvalid }; pub fn defaultPath( alloc: Allocator, program: []const u8, -) DefaultPathError![]const u8 { +) ![]const u8 { const state_dir: []const u8 = xdg.state( alloc, .{ .subdir = program }, @@ -55,27 +47,15 @@ pub fn clear(self: DiskCache) !void { }; } -pub const AddResult = enum { added, updated }; - -pub const AddError = std.fs.Dir.MakeError || - std.fs.Dir.StatFileError || - std.fs.File.OpenError || - std.fs.File.ChmodError || - std.io.Reader.LimitedAllocError || - FixupPermissionsError || - ReadEntriesError || - WriteCacheFileError || - Error; - -/// Add or update a hostname entry in the cache. -/// Returns AddResult.added for new entries or AddResult.updated for existing ones. +/// Add or update an entry in the cache, recording `timestamp` (Unix seconds). /// The cache file is created if it doesn't exist with secure permissions (0600). pub fn add( self: DiskCache, alloc: Allocator, - hostname: []const u8, -) AddError!AddResult { - if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid; + key: []const u8, + timestamp: i64, +) !void { + if (!isValidCacheKey(key)) return error.InvalidCacheKey; // Create cache directory if needed if (std.fs.path.dirname(self.path)) |dir| { @@ -107,58 +87,49 @@ pub fn add( // Lock // Causes a compile failure in the Zig std library on Windows, see: // https://github.com/ziglang/zig/issues/18430 - if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheIsLocked; + if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheLocked; defer if (comptime builtin.os.tag != .windows) file.unlock(); var entries = try readEntries(alloc, file); defer deinitEntries(alloc, &entries); - // Add or update entry - const gop = try entries.getOrPut(hostname); - const result: AddResult = if (!gop.found_existing) add: { - const hostname_copy = try alloc.dupe(u8, hostname); - errdefer alloc.free(hostname_copy); + // Update the timestamp of an existing entry, or insert a new one. For a + // new entry, dupe both strings up front so a failed allocation never + // leaves a half-built slot (borrowed key, undefined value) for the + // `deinitEntries` defer to walk. + if (entries.getPtr(key)) |existing| { + existing.timestamp = timestamp; + } 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); - gop.key_ptr.* = hostname_copy; - gop.value_ptr.* = .{ - .hostname = gop.key_ptr.*, - .timestamp = std.time.timestamp(), + try entries.put(key_copy, .{ + .hostname = key_copy, + .timestamp = timestamp, .terminfo_version = terminfo_copy, - }; - break :add .added; - } else update: { - // Update timestamp for existing entry - gop.value_ptr.timestamp = std.time.timestamp(); - break :update .updated; - }; + }); + } - try self.writeCacheFile(entries, null); - return result; + try self.writeCacheFile(entries); } -pub const RemoveError = std.fs.File.OpenError || - FixupPermissionsError || - ReadEntriesError || - WriteCacheFileError || - Error; - -/// Remove a hostname entry from the cache. -/// No error is returned if the hostname doesn't exist or the cache file is missing. +/// Remove an entry from the cache. Returns true if an entry was removed, +/// false if the key wasn't present (or the cache file is missing). pub fn remove( self: DiskCache, alloc: Allocator, - hostname: []const u8, -) RemoveError!void { - if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid; + key: []const u8, +) !bool { + if (!isValidCacheKey(key)) return error.InvalidCacheKey; // Open our file const file = std.fs.openFileAbsolute( self.path, .{ .mode = .read_write }, ) catch |err| switch (err) { - error.FileNotFound => return, + error.FileNotFound => return false, else => return err, }; defer file.close(); @@ -167,7 +138,7 @@ pub fn remove( // Lock // Causes a compile failure in the Zig std library on Windows, see: // https://github.com/ziglang/zig/issues/18430 - if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheIsLocked; + if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheLocked; defer if (comptime builtin.os.tag != .windows) file.unlock(); // Read existing entries @@ -175,27 +146,73 @@ pub fn remove( defer deinitEntries(alloc, &entries); // Remove the entry if it exists and ensure we free the memory - if (entries.fetchRemove(hostname)) |kv| { + const removed = if (entries.fetchRemove(key)) |kv| removed: { + assert(kv.key.ptr == kv.value.hostname.ptr); + alloc.free(kv.value.hostname); + alloc.free(kv.value.terminfo_version); + break :removed true; + } else false; + + try self.writeCacheFile(entries); + return removed; +} + +/// Remove all entries older than `max_age_s` seconds and return how many +/// were pruned. Returns zero (and nothing written) if the cache file is +/// missing. +pub fn prune( + self: DiskCache, + alloc: Allocator, + max_age_s: u64, +) !usize { + const file = std.fs.openFileAbsolute( + self.path, + .{ .mode = .read_write }, + ) catch |err| switch (err) { + error.FileNotFound => return 0, + else => return err, + }; + defer file.close(); + try fixupPermissions(file); + + // Lock + // Causes a compile failure in the Zig std library on Windows, see: + // https://github.com/ziglang/zig/issues/18430 + if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheLocked; + defer if (comptime builtin.os.tag != .windows) file.unlock(); + + // Read existing entries + var entries = try readEntries(alloc, file); + defer deinitEntries(alloc, &entries); + + // Drop expired entries from the map, then persist what remains. + const now = std.time.timestamp(); + var expired: std.ArrayList([]const u8) = .empty; + defer expired.deinit(alloc); + var iter = entries.iterator(); + while (iter.next()) |kv| { + const age_s = now -| kv.value_ptr.timestamp; + if (age_s > max_age_s) try expired.append(alloc, kv.key_ptr.*); + } + for (expired.items) |key| { + const kv = entries.fetchRemove(key).?; assert(kv.key.ptr == kv.value.hostname.ptr); alloc.free(kv.value.hostname); alloc.free(kv.value.terminfo_version); } - try self.writeCacheFile(entries, null); + try self.writeCacheFile(entries); + return expired.items.len; } -pub const ContainsError = std.fs.File.OpenError || - ReadEntriesError || - error{HostnameIsInvalid}; - -/// Check if a hostname exists in the cache. +/// Check if a key exists in the cache. /// Returns false if the cache file doesn't exist. pub fn contains( self: DiskCache, alloc: Allocator, - hostname: []const u8, -) ContainsError!bool { - if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid; + key: []const u8, +) !bool { + if (!isValidCacheKey(key)) return error.InvalidCacheKey; // Open our file const file = std.fs.openFileAbsolute( @@ -211,12 +228,10 @@ pub fn contains( var entries = try readEntries(alloc, file); defer deinitEntries(alloc, &entries); - return entries.contains(hostname); + return entries.contains(key); } -pub const FixupPermissionsError = (std.fs.File.StatError || std.fs.File.ChmodError); - -fn fixupPermissions(file: std.fs.File) FixupPermissionsError!void { +fn fixupPermissions(file: std.fs.File) !void { // Windows does not support chmod if (comptime builtin.os.tag == .windows) return; @@ -228,18 +243,10 @@ fn fixupPermissions(file: std.fs.File) FixupPermissionsError!void { } } -pub const WriteCacheFileError = std.fs.Dir.OpenError || - std.fs.AtomicFile.InitError || - std.fs.AtomicFile.FlushError || - std.fs.AtomicFile.FinishError || - Entry.FormatError || - error{InvalidCachePath}; - fn writeCacheFile( self: DiskCache, entries: std.StringHashMap(Entry), - expire_days: ?u32, -) WriteCacheFileError!void { +) !void { const cache_dir = std.fs.path.dirname(self.path) orelse return error.InvalidCachePath; const cache_basename = std.fs.path.basename(self.path); @@ -255,8 +262,6 @@ fn writeCacheFile( var iter = entries.iterator(); while (iter.next()) |kv| { - // Only write non-expired entries - if (kv.value_ptr.isExpired(expire_days)) continue; try kv.value_ptr.format(&atomic_file.file_writer.interface); } @@ -299,12 +304,10 @@ pub fn deinitEntries( entries.deinit(); } -pub const ReadEntriesError = std.mem.Allocator.Error || std.io.Reader.LimitedAllocError; - fn readEntries( alloc: Allocator, file: std.fs.File, -) ReadEntriesError!std.StringHashMap(Entry) { +) !std.StringHashMap(Entry) { var reader = file.reader(&.{}); const content = try reader.interface.allocRemaining( alloc, @@ -365,7 +368,7 @@ fn readEntries( } // Supports both standalone hostnames and user@hostname format -fn isValidCacheKey(key: []const u8) bool { +pub fn isValidCacheKey(key: []const u8) bool { if (key.len == 0) return false; // Check for user@hostname format @@ -463,33 +466,23 @@ test "disk cache operations" { const path = try tmp.dir.realpathAlloc(alloc, "cache"); defer alloc.free(path); - // Setup our cache + // Setup our cache. Adding the same key twice exercises both the new + // and existing-entry paths. const cache: DiskCache = .{ .path = path }; - try testing.expectEqual( - AddResult.added, - try cache.add(alloc, "example.com"), - ); - try testing.expectEqual( - AddResult.updated, - try cache.add(alloc, "example.com"), - ); - try testing.expect( - try cache.contains(alloc, "example.com"), - ); + try cache.add(alloc, "example.com", std.time.timestamp()); + try cache.add(alloc, "example.com", std.time.timestamp()); + try testing.expect(try cache.contains(alloc, "example.com")); // List var entries = try cache.list(alloc); deinitEntries(alloc, &entries); - // Remove - try cache.remove(alloc, "example.com"); - try testing.expect( - !(try cache.contains(alloc, "example.com")), - ); - try testing.expectEqual( - AddResult.added, - try cache.add(alloc, "example.com"), - ); + // 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.time.timestamp()); } test "disk cache cleans up temp files" { @@ -505,8 +498,8 @@ test "disk cache cleans up temp files" { defer alloc.free(cache_path); const cache: DiskCache = .{ .path = cache_path }; - try testing.expectEqual(AddResult.added, try cache.add(alloc, "example.com")); - try testing.expectEqual(AddResult.added, try cache.add(alloc, "example.org")); + try cache.add(alloc, "example.com", std.time.timestamp()); + try cache.add(alloc, "example.org", std.time.timestamp()); // Verify only the cache file exists and no temp files left behind var count: usize = 0; @@ -518,6 +511,55 @@ test "disk cache cleans up temp files" { try testing.expectEqual(1, count); } +test "disk cache prune" { + const testing = std.testing; + const alloc = testing.allocator; + + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + const tmp_path = try tmp.dir.realpathAlloc(alloc, "."); + defer alloc.free(tmp_path); + const cache_path = try std.fs.path.join(alloc, &.{ tmp_path, "cache" }); + defer alloc.free(cache_path); + + const cache: DiskCache = .{ .path = cache_path }; + + // Back-date one entry an hour old and one 100 days old. + const day = std.time.s_per_day; + const hour = std.time.s_per_hour; + const now = std.time.timestamp(); + try cache.add(alloc, "recent.com", now - hour); + try cache.add(alloc, "old.com", 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")); + + // 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")); +} + +test "disk cache prune missing file" { + const testing = std.testing; + const alloc = testing.allocator; + + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + const tmp_path = try tmp.dir.realpathAlloc(alloc, "."); + defer alloc.free(tmp_path); + const cache_path = try std.fs.path.join(alloc, &.{ tmp_path, "cache" }); + defer alloc.free(cache_path); + + const cache: DiskCache = .{ .path = cache_path }; + try testing.expectEqual(@as(usize, 0), try cache.prune(alloc, 30)); +} + test "disk cache reads duplicate keys" { const testing = std.testing; const alloc = testing.allocator; @@ -600,6 +642,39 @@ test "disk cache reads survive allocation failure" { } } +test "disk cache add survives allocation failure" { + const testing = std.testing; + + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + const tmp_path = try tmp.dir.realpathAlloc(testing.allocator, "."); + defer testing.allocator.free(tmp_path); + const path = try std.fs.path.join(testing.allocator, &.{ tmp_path, "cache" }); + defer testing.allocator.free(path); + + const cache: DiskCache = .{ .path = path }; + + // Fail the Nth allocation for every N until add completes. A failed add + // must not leak or leave a half-built map entry. The FailingAllocator + // is backed by testing.allocator to catch either. Each iteration starts + // from a clean cache file. + var fail_index: usize = 0; + while (true) : (fail_index += 1) { + std.fs.cwd().deleteFile(path) catch {}; + var failing = std.testing.FailingAllocator.init( + testing.allocator, + .{ .fail_index = fail_index }, + ); + const alloc = failing.allocator(); + + if (cache.add(alloc, "user@example.com", 100)) |_| { + if (!failing.has_induced_failure) break; + } else |err| { + try testing.expectEqual(error.OutOfMemory, err); + } + } +} + test isValidHost { const testing = std.testing; diff --git a/src/cli/ssh-cache/Entry.zig b/src/cli/ssh-cache/Entry.zig index b586161f2..158694f9a 100644 --- a/src/cli/ssh-cache/Entry.zig +++ b/src/cli/ssh-cache/Entry.zig @@ -42,61 +42,6 @@ pub fn format(self: Entry, writer: *std.Io.Writer) FormatError!void { ); } -pub fn isExpired(self: Entry, expire_days_: ?u32) bool { - const expire_days = expire_days_ orelse return false; - const now = std.time.timestamp(); - const age_days = @divTrunc(now -| self.timestamp, std.time.s_per_day); - return age_days > expire_days; -} - -test "cache entry expiration" { - const testing = std.testing; - const now = std.time.timestamp(); - - const fresh_entry: Entry = .{ - .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: Entry = .{ - .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(null)); -} - -test "cache entry expiration exact boundary" { - const testing = std.testing; - const now = std.time.timestamp(); - - // Exactly at expiration boundary - const boundary_entry: Entry = .{ - .hostname = "example.com", - .timestamp = now - (std.time.s_per_day * 30), - .terminfo_version = "xterm-ghostty", - }; - try testing.expect(!boundary_entry.isExpired(30)); - try testing.expect(boundary_entry.isExpired(29)); -} - -test "cache entry expiration large timestamp" { - const testing = std.testing; - const now = std.time.timestamp(); - - const boundary_entry: Entry = .{ - .hostname = "example.com", - .timestamp = now + (std.time.s_per_day * 30), - .terminfo_version = "xterm-ghostty", - }; - try testing.expect(!boundary_entry.isExpired(30)); -} - test "cache entry parsing valid formats" { const testing = std.testing; diff --git a/src/cli/ssh.zig b/src/cli/ssh.zig index 7f808a6cd..76bfb10ee 100644 --- a/src/cli/ssh.zig +++ b/src/cli/ssh.zig @@ -302,7 +302,7 @@ 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)) |_| { + if (entry.cache.add(alloc, entry.dest, std.time.timestamp())) |_| { verbosePrint(opts, stderr, "cache: wrote {s}", .{entry.dest}); } else |err| { log.debug("cache add failed for '{s}': {}", .{ entry.dest, err }); diff --git a/src/cli/ssh_cache.zig b/src/cli/ssh_cache.zig index d3ee658af..83031e8e7 100644 --- a/src/cli/ssh_cache.zig +++ b/src/cli/ssh_cache.zig @@ -3,6 +3,7 @@ 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"); @@ -10,8 +11,7 @@ pub const Options = struct { clear: bool = false, add: ?[]const u8 = null, remove: ?[]const u8 = null, - host: ?[]const u8 = null, - @"expire-days": ?u32 = null, + prune: ?Duration = null, pub fn deinit(self: *Options) void { _ = self; @@ -25,27 +25,36 @@ pub const Options = struct { /// Manage the SSH terminfo cache for automatic remote host setup. /// -/// When SSH integration is enabled with `shell-integration-features = ssh-terminfo`, -/// Ghostty automatically installs its terminfo on remote hosts. This command -/// manages the cache of successful installations to avoid redundant uploads. +/// 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 hostnames (or user@hostname combinations) along with timestamps. -/// Entries older than the expiration period are automatically removed during cache -/// operations. By default, entries never expire. +/// The cache stores destinations (a hostname or user@hostname) along with +/// timestamps. /// -/// 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. +/// 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 hosts -/// ghostty +ssh-cache --host=example.com # Check if host is cached -/// ghostty +ssh-cache --add=example.com # Manually add host to cache -/// ghostty +ssh-cache --add=user@example.com # Add user@host combination -/// ghostty +ssh-cache --remove=example.com # Remove host from cache -/// ghostty +ssh-cache --clear # Clear entire cache -/// ghostty +ssh-cache --expire-days=30 # Set custom expiration period +/// 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(); @@ -54,12 +63,6 @@ pub fn run(alloc_gpa: Allocator) !u8 { var opts: Options = .{}; defer opts.deinit(); - { - var iter = try args.argsIterator(alloc_gpa); - defer iter.deinit(); - try args.parse(Options, alloc_gpa, &opts, &iter); - } - var stdout_buffer: [1024]u8 = undefined; var stdout_file: std.fs.File = .stdout(); var stdout_writer = stdout_file.writer(&stdout_buffer); @@ -70,7 +73,66 @@ pub fn run(alloc_gpa: Allocator) !u8 { var stderr_writer = stderr_file.writer(&stderr_buffer); const stderr = &stderr_writer.interface; - const result = runInner(alloc, opts, stdout, stderr); + // 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 {}; @@ -81,103 +143,126 @@ pub fn run(alloc_gpa: Allocator) !u8 { 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(); - try stdout.print("Cache cleared.\n", .{}); return 0; } - if (opts.add) |host| { - 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; + 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", - .{ host, err }, + .{ dest, err }, ); return 1; }, }; - - switch (result) { - .added => try stdout.print("Added '{s}' to cache.\n", .{host}), - .updated => try stdout.print("Updated '{s}' cache entry.\n", .{host}), - } return 0; } - if (opts.remove) |host| { - 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; + 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", - .{ host, err }, + .{ dest, err }, ); return 1; }, }; - try stdout.print("Removed '{s}' from cache.\n", .{host}); + // 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.host) |host| { - 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) { - try stdout.print( - "'{s}' has Ghostty terminfo installed.\n", - .{host}, + 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 0; - } else { - try stdout.print( - "'{s}' does not have Ghostty terminfo installed.\n", - .{host}, - ); - return 1; + 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; } - // Default action: list all hosts 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; } @@ -187,10 +272,7 @@ fn listEntries( entries: *const std.StringHashMap(Entry), writer: *std.Io.Writer, ) !void { - if (entries.count() == 0) { - try writer.print("No hosts in cache.\n", .{}); - return; - } + if (entries.count() == 0) return; // Sort entries by hostname for consistent output var items: std.ArrayList(Entry) = .empty; @@ -207,22 +289,200 @@ fn listEntries( } }.lessThan); - try writer.print("Cached hosts ({d}):\n", .{items.items.len}); - const now = std.time.timestamp(); - + // Align the timestamp column by padding destinations to the widest. + var widest: usize = 0; 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 }); - } + 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); +}