cli: rework +ssh-cache internals and user interface (#12814)
This change primarily focused on a revised +ssh-cache user interface,
but it also reworks a bunch of the internals.
The primary CLI improvement is support for positional arguments and a
consistent list output format that includes both the ISO-formatted
timestamp and relative age.
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
Notable, we now support a --prune operation that replaces the previous
--expire-days flag that was never actually hooked up to anything (!!).
--prune also supports a wider range of Duration-based values.
We're also much more consistent with error codes: 0=success, 1=failure,
2=usage.
While working on those changes, I also reworked the cache internals,
particularly the code around timestamp handling and errors. For example,
I dropped the explicit error sets because they were growing unwieldy,
and in practice we only matched on a subset of those errors.
Lastly, overall test coverage should be much improved, especially around
the time- and allocation-related operations.
---
*AI Disclosure:* I made a lot of iterative, AI-assisted (Claude Opus
4.7) correctness passes over this work. It was particularly helpful in
tracing through the various failure modes, and it wrote those unit tests
in the process.
pull/12836/head
commit
756fda776b
|
|
@ -17,14 +17,6 @@ const MAX_CACHE_SIZE = 512 * 1024;
|
||||||
/// Path to a file where the cache is stored.
|
/// Path to a file where the cache is stored.
|
||||||
path: []const u8,
|
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.
|
/// Returns the default path for the cache for a given program.
|
||||||
///
|
///
|
||||||
/// On all platforms, this is `${XDG_STATE_HOME}/ghostty/ssh_cache`.
|
/// On all platforms, this is `${XDG_STATE_HOME}/ghostty/ssh_cache`.
|
||||||
|
|
@ -33,7 +25,7 @@ pub const Error = error{ CacheIsLocked, HostnameIsInvalid };
|
||||||
pub fn defaultPath(
|
pub fn defaultPath(
|
||||||
alloc: Allocator,
|
alloc: Allocator,
|
||||||
program: []const u8,
|
program: []const u8,
|
||||||
) DefaultPathError![]const u8 {
|
) ![]const u8 {
|
||||||
const state_dir: []const u8 = xdg.state(
|
const state_dir: []const u8 = xdg.state(
|
||||||
alloc,
|
alloc,
|
||||||
.{ .subdir = program },
|
.{ .subdir = program },
|
||||||
|
|
@ -55,27 +47,15 @@ pub fn clear(self: DiskCache) !void {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const AddResult = enum { added, updated };
|
/// Add or update an entry in the cache, recording `timestamp` (Unix seconds).
|
||||||
|
|
||||||
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.
|
|
||||||
/// The cache file is created if it doesn't exist with secure permissions (0600).
|
/// The cache file is created if it doesn't exist with secure permissions (0600).
|
||||||
pub fn add(
|
pub fn add(
|
||||||
self: DiskCache,
|
self: DiskCache,
|
||||||
alloc: Allocator,
|
alloc: Allocator,
|
||||||
hostname: []const u8,
|
key: []const u8,
|
||||||
) AddError!AddResult {
|
timestamp: i64,
|
||||||
if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid;
|
) !void {
|
||||||
|
if (!isValidCacheKey(key)) return error.InvalidCacheKey;
|
||||||
|
|
||||||
// Create cache directory if needed
|
// Create cache directory if needed
|
||||||
if (std.fs.path.dirname(self.path)) |dir| {
|
if (std.fs.path.dirname(self.path)) |dir| {
|
||||||
|
|
@ -107,58 +87,49 @@ pub fn add(
|
||||||
// Lock
|
// Lock
|
||||||
// Causes a compile failure in the Zig std library on Windows, see:
|
// Causes a compile failure in the Zig std library on Windows, see:
|
||||||
// https://github.com/ziglang/zig/issues/18430
|
// 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();
|
defer if (comptime builtin.os.tag != .windows) file.unlock();
|
||||||
|
|
||||||
var entries = try readEntries(alloc, file);
|
var entries = try readEntries(alloc, file);
|
||||||
defer deinitEntries(alloc, &entries);
|
defer deinitEntries(alloc, &entries);
|
||||||
|
|
||||||
// Add or update entry
|
// Update the timestamp of an existing entry, or insert a new one. For a
|
||||||
const gop = try entries.getOrPut(hostname);
|
// new entry, dupe both strings up front so a failed allocation never
|
||||||
const result: AddResult = if (!gop.found_existing) add: {
|
// leaves a half-built slot (borrowed key, undefined value) for the
|
||||||
const hostname_copy = try alloc.dupe(u8, hostname);
|
// `deinitEntries` defer to walk.
|
||||||
errdefer alloc.free(hostname_copy);
|
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");
|
const terminfo_copy = try alloc.dupe(u8, "xterm-ghostty");
|
||||||
errdefer alloc.free(terminfo_copy);
|
errdefer alloc.free(terminfo_copy);
|
||||||
|
|
||||||
gop.key_ptr.* = hostname_copy;
|
try entries.put(key_copy, .{
|
||||||
gop.value_ptr.* = .{
|
.hostname = key_copy,
|
||||||
.hostname = gop.key_ptr.*,
|
.timestamp = timestamp,
|
||||||
.timestamp = std.time.timestamp(),
|
|
||||||
.terminfo_version = terminfo_copy,
|
.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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const RemoveError = std.fs.File.OpenError ||
|
try self.writeCacheFile(entries);
|
||||||
FixupPermissionsError ||
|
}
|
||||||
ReadEntriesError ||
|
|
||||||
WriteCacheFileError ||
|
|
||||||
Error;
|
|
||||||
|
|
||||||
/// Remove a hostname entry from the cache.
|
/// Remove an entry from the cache. Returns true if an entry was removed,
|
||||||
/// No error is returned if the hostname doesn't exist or the cache file is missing.
|
/// false if the key wasn't present (or the cache file is missing).
|
||||||
pub fn remove(
|
pub fn remove(
|
||||||
self: DiskCache,
|
self: DiskCache,
|
||||||
alloc: Allocator,
|
alloc: Allocator,
|
||||||
hostname: []const u8,
|
key: []const u8,
|
||||||
) RemoveError!void {
|
) !bool {
|
||||||
if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid;
|
if (!isValidCacheKey(key)) return error.InvalidCacheKey;
|
||||||
|
|
||||||
// Open our file
|
// Open our file
|
||||||
const file = std.fs.openFileAbsolute(
|
const file = std.fs.openFileAbsolute(
|
||||||
self.path,
|
self.path,
|
||||||
.{ .mode = .read_write },
|
.{ .mode = .read_write },
|
||||||
) catch |err| switch (err) {
|
) catch |err| switch (err) {
|
||||||
error.FileNotFound => return,
|
error.FileNotFound => return false,
|
||||||
else => return err,
|
else => return err,
|
||||||
};
|
};
|
||||||
defer file.close();
|
defer file.close();
|
||||||
|
|
@ -167,7 +138,7 @@ pub fn remove(
|
||||||
// Lock
|
// Lock
|
||||||
// Causes a compile failure in the Zig std library on Windows, see:
|
// Causes a compile failure in the Zig std library on Windows, see:
|
||||||
// https://github.com/ziglang/zig/issues/18430
|
// 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();
|
defer if (comptime builtin.os.tag != .windows) file.unlock();
|
||||||
|
|
||||||
// Read existing entries
|
// Read existing entries
|
||||||
|
|
@ -175,27 +146,73 @@ pub fn remove(
|
||||||
defer deinitEntries(alloc, &entries);
|
defer deinitEntries(alloc, &entries);
|
||||||
|
|
||||||
// Remove the entry if it exists and ensure we free the memory
|
// 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);
|
assert(kv.key.ptr == kv.value.hostname.ptr);
|
||||||
alloc.free(kv.value.hostname);
|
alloc.free(kv.value.hostname);
|
||||||
alloc.free(kv.value.terminfo_version);
|
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 ||
|
/// Check if a key exists in the cache.
|
||||||
ReadEntriesError ||
|
|
||||||
error{HostnameIsInvalid};
|
|
||||||
|
|
||||||
/// Check if a hostname exists in the cache.
|
|
||||||
/// Returns false if the cache file doesn't exist.
|
/// Returns false if the cache file doesn't exist.
|
||||||
pub fn contains(
|
pub fn contains(
|
||||||
self: DiskCache,
|
self: DiskCache,
|
||||||
alloc: Allocator,
|
alloc: Allocator,
|
||||||
hostname: []const u8,
|
key: []const u8,
|
||||||
) ContainsError!bool {
|
) !bool {
|
||||||
if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid;
|
if (!isValidCacheKey(key)) return error.InvalidCacheKey;
|
||||||
|
|
||||||
// Open our file
|
// Open our file
|
||||||
const file = std.fs.openFileAbsolute(
|
const file = std.fs.openFileAbsolute(
|
||||||
|
|
@ -211,12 +228,10 @@ pub fn contains(
|
||||||
var entries = try readEntries(alloc, file);
|
var entries = try readEntries(alloc, file);
|
||||||
defer deinitEntries(alloc, &entries);
|
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) !void {
|
||||||
|
|
||||||
fn fixupPermissions(file: std.fs.File) FixupPermissionsError!void {
|
|
||||||
// Windows does not support chmod
|
// Windows does not support chmod
|
||||||
if (comptime builtin.os.tag == .windows) return;
|
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(
|
fn writeCacheFile(
|
||||||
self: DiskCache,
|
self: DiskCache,
|
||||||
entries: std.StringHashMap(Entry),
|
entries: std.StringHashMap(Entry),
|
||||||
expire_days: ?u32,
|
) !void {
|
||||||
) WriteCacheFileError!void {
|
|
||||||
const cache_dir = std.fs.path.dirname(self.path) orelse return error.InvalidCachePath;
|
const cache_dir = std.fs.path.dirname(self.path) orelse return error.InvalidCachePath;
|
||||||
const cache_basename = std.fs.path.basename(self.path);
|
const cache_basename = std.fs.path.basename(self.path);
|
||||||
|
|
||||||
|
|
@ -255,8 +262,6 @@ fn writeCacheFile(
|
||||||
|
|
||||||
var iter = entries.iterator();
|
var iter = entries.iterator();
|
||||||
while (iter.next()) |kv| {
|
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);
|
try kv.value_ptr.format(&atomic_file.file_writer.interface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -299,12 +304,10 @@ pub fn deinitEntries(
|
||||||
entries.deinit();
|
entries.deinit();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const ReadEntriesError = std.mem.Allocator.Error || std.io.Reader.LimitedAllocError;
|
|
||||||
|
|
||||||
fn readEntries(
|
fn readEntries(
|
||||||
alloc: Allocator,
|
alloc: Allocator,
|
||||||
file: std.fs.File,
|
file: std.fs.File,
|
||||||
) ReadEntriesError!std.StringHashMap(Entry) {
|
) !std.StringHashMap(Entry) {
|
||||||
var reader = file.reader(&.{});
|
var reader = file.reader(&.{});
|
||||||
const content = try reader.interface.allocRemaining(
|
const content = try reader.interface.allocRemaining(
|
||||||
alloc,
|
alloc,
|
||||||
|
|
@ -365,7 +368,7 @@ fn readEntries(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Supports both standalone hostnames and user@hostname format
|
// 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;
|
if (key.len == 0) return false;
|
||||||
|
|
||||||
// Check for user@hostname format
|
// Check for user@hostname format
|
||||||
|
|
@ -463,33 +466,23 @@ test "disk cache operations" {
|
||||||
const path = try tmp.dir.realpathAlloc(alloc, "cache");
|
const path = try tmp.dir.realpathAlloc(alloc, "cache");
|
||||||
defer alloc.free(path);
|
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 };
|
const cache: DiskCache = .{ .path = path };
|
||||||
try testing.expectEqual(
|
try cache.add(alloc, "example.com", std.time.timestamp());
|
||||||
AddResult.added,
|
try cache.add(alloc, "example.com", std.time.timestamp());
|
||||||
try cache.add(alloc, "example.com"),
|
try testing.expect(try cache.contains(alloc, "example.com"));
|
||||||
);
|
|
||||||
try testing.expectEqual(
|
|
||||||
AddResult.updated,
|
|
||||||
try cache.add(alloc, "example.com"),
|
|
||||||
);
|
|
||||||
try testing.expect(
|
|
||||||
try cache.contains(alloc, "example.com"),
|
|
||||||
);
|
|
||||||
|
|
||||||
// List
|
// List
|
||||||
var entries = try cache.list(alloc);
|
var entries = try cache.list(alloc);
|
||||||
deinitEntries(alloc, &entries);
|
deinitEntries(alloc, &entries);
|
||||||
|
|
||||||
// Remove
|
// Remove reports that it removed the entry, and a second remove of the
|
||||||
try cache.remove(alloc, "example.com");
|
// same key reports nothing to remove.
|
||||||
try testing.expect(
|
try testing.expect(try cache.remove(alloc, "example.com"));
|
||||||
!(try cache.contains(alloc, "example.com")),
|
try testing.expect(!try cache.remove(alloc, "example.com"));
|
||||||
);
|
try testing.expect(!(try cache.contains(alloc, "example.com")));
|
||||||
try testing.expectEqual(
|
try cache.add(alloc, "example.com", std.time.timestamp());
|
||||||
AddResult.added,
|
|
||||||
try cache.add(alloc, "example.com"),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test "disk cache cleans up temp files" {
|
test "disk cache cleans up temp files" {
|
||||||
|
|
@ -505,8 +498,8 @@ test "disk cache cleans up temp files" {
|
||||||
defer alloc.free(cache_path);
|
defer alloc.free(cache_path);
|
||||||
|
|
||||||
const cache: DiskCache = .{ .path = cache_path };
|
const cache: DiskCache = .{ .path = cache_path };
|
||||||
try testing.expectEqual(AddResult.added, try cache.add(alloc, "example.com"));
|
try cache.add(alloc, "example.com", std.time.timestamp());
|
||||||
try testing.expectEqual(AddResult.added, try cache.add(alloc, "example.org"));
|
try cache.add(alloc, "example.org", std.time.timestamp());
|
||||||
|
|
||||||
// Verify only the cache file exists and no temp files left behind
|
// Verify only the cache file exists and no temp files left behind
|
||||||
var count: usize = 0;
|
var count: usize = 0;
|
||||||
|
|
@ -518,6 +511,55 @@ test "disk cache cleans up temp files" {
|
||||||
try testing.expectEqual(1, count);
|
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" {
|
test "disk cache reads duplicate keys" {
|
||||||
const testing = std.testing;
|
const testing = std.testing;
|
||||||
const alloc = testing.allocator;
|
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 {
|
test isValidHost {
|
||||||
const testing = std.testing;
|
const testing = std.testing;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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" {
|
test "cache entry parsing valid formats" {
|
||||||
const testing = std.testing;
|
const testing = std.testing;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -302,7 +302,7 @@ fn runInner(
|
||||||
|
|
||||||
// Attempt to cache (if needed) on a successful ssh execution.
|
// Attempt to cache (if needed) on a successful ssh execution.
|
||||||
if (exit_code == 0) if (session.to_cache) |entry| {
|
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});
|
verbosePrint(opts, stderr, "cache: wrote {s}", .{entry.dest});
|
||||||
} else |err| {
|
} else |err| {
|
||||||
log.debug("cache add failed for '{s}': {}", .{ entry.dest, err });
|
log.debug("cache add failed for '{s}': {}", .{ entry.dest, err });
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ const fs = std.fs;
|
||||||
const Allocator = std.mem.Allocator;
|
const Allocator = std.mem.Allocator;
|
||||||
const args = @import("args.zig");
|
const args = @import("args.zig");
|
||||||
const Action = @import("ghostty.zig").Action;
|
const Action = @import("ghostty.zig").Action;
|
||||||
|
const Duration = @import("../config.zig").Config.Duration;
|
||||||
pub const Entry = @import("ssh-cache/Entry.zig");
|
pub const Entry = @import("ssh-cache/Entry.zig");
|
||||||
pub const DiskCache = @import("ssh-cache/DiskCache.zig");
|
pub const DiskCache = @import("ssh-cache/DiskCache.zig");
|
||||||
|
|
||||||
|
|
@ -10,8 +11,7 @@ pub const Options = struct {
|
||||||
clear: bool = false,
|
clear: bool = false,
|
||||||
add: ?[]const u8 = null,
|
add: ?[]const u8 = null,
|
||||||
remove: ?[]const u8 = null,
|
remove: ?[]const u8 = null,
|
||||||
host: ?[]const u8 = null,
|
prune: ?Duration = null,
|
||||||
@"expire-days": ?u32 = null,
|
|
||||||
|
|
||||||
pub fn deinit(self: *Options) void {
|
pub fn deinit(self: *Options) void {
|
||||||
_ = self;
|
_ = self;
|
||||||
|
|
@ -25,27 +25,36 @@ pub const Options = struct {
|
||||||
|
|
||||||
/// Manage the SSH terminfo cache for automatic remote host setup.
|
/// Manage the SSH terminfo cache for automatic remote host setup.
|
||||||
///
|
///
|
||||||
/// When SSH integration is enabled with `shell-integration-features = ssh-terminfo`,
|
/// The `+ssh` action installs Ghostty's terminfo on remote hosts and records
|
||||||
/// Ghostty automatically installs its terminfo on remote hosts. This command
|
/// each success in this cache so it doesn't re-upload on later connections.
|
||||||
/// manages the cache of successful installations to avoid redundant uploads.
|
/// (`+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.
|
/// The cache stores destinations (a hostname or user@hostname) along with
|
||||||
/// Entries older than the expiration period are automatically removed during cache
|
/// timestamps.
|
||||||
/// operations. By default, entries never expire.
|
|
||||||
///
|
///
|
||||||
/// Only one of `--clear`, `--add`, `--remove`, or `--host` can be specified.
|
/// A positional destination queries the cache: `user@hostname` shows that
|
||||||
/// If multiple are specified, one of the actions will be executed but
|
/// exact entry, while a bare `hostname` shows every cached entry for that
|
||||||
/// it isn't guaranteed which one. This is entirely unsafe so you should split
|
/// host regardless of user. With no destination and no action, the entire
|
||||||
/// multiple actions into separate commands.
|
/// 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:
|
/// Examples:
|
||||||
/// ghostty +ssh-cache # List all cached hosts
|
/// ghostty +ssh-cache # List all cached destinations
|
||||||
/// ghostty +ssh-cache --host=example.com # Check if host is cached
|
/// ghostty +ssh-cache user@example.com # Show that destination
|
||||||
/// ghostty +ssh-cache --add=example.com # Manually add host to cache
|
/// ghostty +ssh-cache example.com # Show all users on that host
|
||||||
/// ghostty +ssh-cache --add=user@example.com # Add user@host combination
|
/// ghostty +ssh-cache --add=user@example.com # Manually add a destination
|
||||||
/// ghostty +ssh-cache --remove=example.com # Remove host from cache
|
/// 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
|
/// ghostty +ssh-cache --clear # Clear entire cache
|
||||||
/// ghostty +ssh-cache --expire-days=30 # Set custom expiration period
|
|
||||||
pub fn run(alloc_gpa: Allocator) !u8 {
|
pub fn run(alloc_gpa: Allocator) !u8 {
|
||||||
var arena = std.heap.ArenaAllocator.init(alloc_gpa);
|
var arena = std.heap.ArenaAllocator.init(alloc_gpa);
|
||||||
defer arena.deinit();
|
defer arena.deinit();
|
||||||
|
|
@ -54,12 +63,6 @@ pub fn run(alloc_gpa: Allocator) !u8 {
|
||||||
var opts: Options = .{};
|
var opts: Options = .{};
|
||||||
defer opts.deinit();
|
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_buffer: [1024]u8 = undefined;
|
||||||
var stdout_file: std.fs.File = .stdout();
|
var stdout_file: std.fs.File = .stdout();
|
||||||
var stdout_writer = stdout_file.writer(&stdout_buffer);
|
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);
|
var stderr_writer = stderr_file.writer(&stderr_buffer);
|
||||||
const stderr = &stderr_writer.interface;
|
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...
|
// Flushing *shouldn't* fail but...
|
||||||
stdout.flush() catch {};
|
stdout.flush() catch {};
|
||||||
|
|
@ -81,103 +143,126 @@ pub fn run(alloc_gpa: Allocator) !u8 {
|
||||||
pub fn runInner(
|
pub fn runInner(
|
||||||
alloc: Allocator,
|
alloc: Allocator,
|
||||||
opts: Options,
|
opts: Options,
|
||||||
|
query: ?[]const u8,
|
||||||
stdout: *std.Io.Writer,
|
stdout: *std.Io.Writer,
|
||||||
stderr: *std.Io.Writer,
|
stderr: *std.Io.Writer,
|
||||||
) !u8 {
|
) !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
|
// Setup our disk cache to the standard location
|
||||||
const cache_path = try DiskCache.defaultPath(alloc, "ghostty");
|
const cache_path = try DiskCache.defaultPath(alloc, "ghostty");
|
||||||
const cache: DiskCache = .{ .path = cache_path };
|
const cache: DiskCache = .{ .path = cache_path };
|
||||||
|
|
||||||
if (opts.clear) {
|
if (opts.clear) {
|
||||||
try cache.clear();
|
try cache.clear();
|
||||||
try stdout.print("Cache cleared.\n", .{});
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.add) |host| {
|
if (opts.add) |dest| {
|
||||||
const result = cache.add(alloc, host) catch |err| switch (err) {
|
cache.add(alloc, dest, std.time.timestamp()) catch |err| switch (err) {
|
||||||
DiskCache.Error.HostnameIsInvalid => {
|
error.InvalidCacheKey => {
|
||||||
try stderr.print("Error: Invalid hostname format '{s}'\n", .{host});
|
try stderr.print(
|
||||||
try stderr.print("Expected format: hostname or user@hostname\n", .{});
|
"Error: Invalid destination '{s}' (expected hostname or user@hostname)\n",
|
||||||
return 1;
|
.{dest},
|
||||||
},
|
);
|
||||||
DiskCache.Error.CacheIsLocked => {
|
return 2;
|
||||||
try stderr.print("Error: Cache is busy, try again\n", .{});
|
|
||||||
return 1;
|
|
||||||
},
|
},
|
||||||
else => {
|
else => {
|
||||||
try stderr.print(
|
try stderr.print(
|
||||||
"Error: Unable to add '{s}' to cache. Error: {}\n",
|
"Error: Unable to add '{s}' to cache. Error: {}\n",
|
||||||
.{ host, err },
|
.{ dest, err },
|
||||||
);
|
);
|
||||||
return 1;
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.remove) |host| {
|
if (opts.remove) |dest| {
|
||||||
cache.remove(alloc, host) catch |err| switch (err) {
|
const removed = cache.remove(alloc, dest) catch |err| switch (err) {
|
||||||
DiskCache.Error.HostnameIsInvalid => {
|
error.InvalidCacheKey => {
|
||||||
try stderr.print("Error: Invalid hostname format '{s}'\n", .{host});
|
try stderr.print(
|
||||||
try stderr.print("Expected format: hostname or user@hostname\n", .{});
|
"Error: Invalid destination '{s}' (expected hostname or user@hostname)\n",
|
||||||
return 1;
|
.{dest},
|
||||||
},
|
);
|
||||||
DiskCache.Error.CacheIsLocked => {
|
return 2;
|
||||||
try stderr.print("Error: Cache is busy, try again\n", .{});
|
|
||||||
return 1;
|
|
||||||
},
|
},
|
||||||
else => {
|
else => {
|
||||||
try stderr.print(
|
try stderr.print(
|
||||||
"Error: Unable to remove '{s}' from cache. Error: {}\n",
|
"Error: Unable to remove '{s}' from cache. Error: {}\n",
|
||||||
.{ host, err },
|
.{ dest, err },
|
||||||
);
|
);
|
||||||
return 1;
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.host) |host| {
|
if (opts.prune) |max_age| {
|
||||||
const cached = cache.contains(alloc, host) catch |err| switch (err) {
|
const max_age_s = max_age.duration / std.time.ns_per_s;
|
||||||
error.HostnameIsInvalid => {
|
if (max_age_s == 0) {
|
||||||
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(
|
try stderr.print(
|
||||||
"Error: Unable to check host '{s}' in cache. Error: {}\n",
|
"Error: --prune requires a duration of at least one second.\n",
|
||||||
.{ host, err },
|
.{},
|
||||||
);
|
);
|
||||||
|
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;
|
return 1;
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
try stdout.print("Pruned cache entries: {d}\n", .{pruned});
|
||||||
if (cached) {
|
|
||||||
try stdout.print(
|
|
||||||
"'{s}' has Ghostty terminfo installed.\n",
|
|
||||||
.{host},
|
|
||||||
);
|
|
||||||
return 0;
|
return 0;
|
||||||
} else {
|
|
||||||
try stdout.print(
|
|
||||||
"'{s}' does not have Ghostty terminfo installed.\n",
|
|
||||||
.{host},
|
|
||||||
);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default action: list all hosts
|
|
||||||
var entries = try cache.list(alloc);
|
var entries = try cache.list(alloc);
|
||||||
defer DiskCache.deinitEntries(alloc, &entries);
|
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);
|
try listEntries(alloc, &entries, stdout);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
@ -187,10 +272,7 @@ fn listEntries(
|
||||||
entries: *const std.StringHashMap(Entry),
|
entries: *const std.StringHashMap(Entry),
|
||||||
writer: *std.Io.Writer,
|
writer: *std.Io.Writer,
|
||||||
) !void {
|
) !void {
|
||||||
if (entries.count() == 0) {
|
if (entries.count() == 0) return;
|
||||||
try writer.print("No hosts in cache.\n", .{});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort entries by hostname for consistent output
|
// Sort entries by hostname for consistent output
|
||||||
var items: std.ArrayList(Entry) = .empty;
|
var items: std.ArrayList(Entry) = .empty;
|
||||||
|
|
@ -207,22 +289,200 @@ fn listEntries(
|
||||||
}
|
}
|
||||||
}.lessThan);
|
}.lessThan);
|
||||||
|
|
||||||
try writer.print("Cached hosts ({d}):\n", .{items.items.len});
|
// Align the timestamp column by padding destinations to the widest.
|
||||||
const now = std.time.timestamp();
|
var widest: usize = 0;
|
||||||
|
|
||||||
for (items.items) |entry| {
|
for (items.items) |entry| {
|
||||||
const age_days = @divTrunc(now - entry.timestamp, std.time.s_per_day);
|
widest = @max(widest, entry.hostname.len);
|
||||||
if (age_days == 0) {
|
}
|
||||||
try writer.print(" {s} (today)\n", .{entry.hostname});
|
|
||||||
} else if (age_days == 1) {
|
const now = std.time.timestamp();
|
||||||
try writer.print(" {s} (yesterday)\n", .{entry.hostname});
|
for (items.items) |entry| {
|
||||||
} else {
|
try writer.print("{s}", .{entry.hostname});
|
||||||
try writer.print(" {s} ({d} days ago)\n", .{ entry.hostname, age_days });
|
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 {
|
test {
|
||||||
_ = DiskCache;
|
_ = DiskCache;
|
||||||
_ = Entry;
|
_ = 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);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue