summaryrefslogtreecommitdiff
path: root/src/cli/ssh_cache.zig
diff options
context:
space:
mode:
authorJason Rayne <yo@arcayne.dev>2025-07-03 20:11:45 -0700
committerJason Rayne <yo@arcayne.dev>2025-07-03 20:11:45 -0700
commit75c703071a6ab176f2d7982a17cde1e593e14737 (patch)
tree2ee675f81ef272b16d57178dbb6647522b772c93 /src/cli/ssh_cache.zig
parente25aa9f424097938362a9d6b02fa32c1714ab97f (diff)
feat(ssh): rewrite SSH cache system in native Zig
- Eliminates standalone bash dependency - Consolidates `+list-ssh-cache` and `+clear-ssh-cache` actions into single `+ssh-cache` action with args - Structured cache format with timestamps and expiration support - Memory-safe entry handling with proper file locking - Comprehensive hostname validation (IPv4/IPv6/domains) - Atomic updates via temp file + rename - Updated shell integrations for improved cross-platform support and reliability - Cache operations are now unit-testable
Diffstat (limited to 'src/cli/ssh_cache.zig')
-rw-r--r--src/cli/ssh_cache.zig767
1 files changed, 735 insertions, 32 deletions
diff --git a/src/cli/ssh_cache.zig b/src/cli/ssh_cache.zig
index 02462816c..71c47a7a7 100644
--- a/src/cli/ssh_cache.zig
+++ b/src/cli/ssh_cache.zig
@@ -1,51 +1,754 @@
const std = @import("std");
+const fs = std.fs;
const Allocator = std.mem.Allocator;
-const Child = std.process.Child;
+const xdg = @import("../os/xdg.zig");
+const args = @import("args.zig");
+const Action = @import("action.zig").Action;
-/// Get the path to the shared cache script
-fn getCacheScriptPath(alloc: Allocator) ![]u8 {
- // Use GHOSTTY_RESOURCES_DIR if available, otherwise assume relative path
- const resources_dir = std.process.getEnvVarOwned(alloc, "GHOSTTY_RESOURCES_DIR") catch {
- // Fallback: assume we're running from build directory
- return try alloc.dupe(u8, "src");
+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;
+const SECONDS_PER_DAY = 86400;
+
+pub const Options = struct {
+ clear: bool = false,
+ add: ?[]const u8 = null,
+ remove: ?[]const u8 = null,
+ host: ?[]const u8 = null,
+ @"expire-days": u32 = NEVER_EXPIRE,
+
+ pub fn deinit(self: *Options) void {
+ _ = self;
+ }
+
+ pub fn help(self: Options) !void {
+ _ = self;
+ return Action.help_error;
+ }
+};
+
+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 CacheEntry{
+ .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, SECONDS_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 {
+ if (key.len == 0 or key.len > 320) return false; // 253 + 1 + 64 for user@hostname
+
+ // 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', ':' => {
+ if (c == ':') 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.* = CacheEntry{
+ .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 alloc.free(resources_dir);
+ defer file.close();
+
+ try acquireFileLock(file);
+ defer file.unlock();
+
+ var entries = std.StringHashMap(CacheEntry).init(alloc);
- return try std.fs.path.join(alloc, &[_][]const u8{ resources_dir, "shell-integration", "shared", "ghostty-ssh-cache" });
+ try readCacheFile(alloc, path, &entries);
+
+ _ = entries.fetchRemove(host);
+
+ try writeCacheFile(alloc, path, &entries, NEVER_EXPIRE);
}
-/// Generic function to run cache script commands
-fn runCacheCommand(alloc: Allocator, writer: anytype, command: []const u8) !void {
- const script_path = try getCacheScriptPath(alloc);
- defer alloc.free(script_path);
+fn listHosts(alloc: Allocator, writer: anytype) !void {
+ const path = try getCachePath(alloc);
- var child = Child.init(&[_][]const u8{ script_path, command }, alloc);
- child.stdout_behavior = .Pipe;
- child.stderr_behavior = .Pipe;
+ var entries = std.StringHashMap(CacheEntry).init(alloc);
- try child.spawn();
+ readCacheFile(alloc, path, &entries) catch |err| switch (err) {
+ error.FileNotFound => {
+ try writer.print("No hosts in cache.\n", .{});
+ return;
+ },
+ else => return err,
+ };
- const stdout = try child.stdout.?.readToEndAlloc(alloc, std.math.maxInt(usize));
- defer alloc.free(stdout);
+ 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();
- const stderr = try child.stderr.?.readToEndAlloc(alloc, std.math.maxInt(usize));
- defer alloc.free(stderr);
+ var iter = entries.iterator();
+ while (iter.next()) |kv| {
+ try items.append(kv.value_ptr.*);
+ }
- _ = try child.wait();
+ 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);
- // Output the results regardless of exit code
- try writer.writeAll(stdout);
- if (stderr.len > 0) {
- try writer.writeAll(stderr);
+ 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, SECONDS_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 });
+ }
}
}
-/// List cached hosts by calling the external script
-pub fn listCachedHosts(alloc: Allocator, writer: anytype) !void {
- try runCacheCommand(alloc, writer, "list");
+fn clearCache(alloc: Allocator) !void {
+ const path = try getCachePath(alloc);
+
+ fs.deleteFileAbsolute(path) catch |err| switch (err) {
+ error.FileNotFound => {},
+ else => return err,
+ };
}
-/// Clear cache by calling the external script
-pub fn clearCache(alloc: Allocator, writer: anytype) !void {
- try runCacheCommand(alloc, writer, "clear");
+/// 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 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.
+///
+/// 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
+pub fn run(alloc_gpa: Allocator) !u8 {
+ var arena = std.heap.ArenaAllocator.init(alloc_gpa);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var opts: Options = .{};
+ defer opts.deinit();
+
+ {
+ var iter = try args.argsIterator(alloc_gpa);
+ defer iter.deinit();
+ try args.parse(Options, alloc_gpa, &opts, &iter);
+ }
+
+ const stdout = std.io.getStdOut().writer();
+ const stderr = std.io.getStdErr().writer();
+
+ if (opts.clear) {
+ try clearCache(alloc);
+ try stdout.print("Cache cleared.\n", .{});
+ return 0;
+ }
+
+ if (opts.add) |host| {
+ const result = addHost(alloc, host) catch |err| switch (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;
+ },
+ };
+
+ 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| {
+ removeHost(alloc, host) catch |err| switch (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;
+ },
+ };
+ try stdout.print("Removed '{s}' from cache.\n", .{host});
+ return 0;
+ }
+
+ if (opts.host) |host| {
+ const cached = checkHost(alloc, host) catch |err| switch (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;
+ },
+ };
+
+ if (cached) {
+ try stdout.print("'{s}' has Ghostty terminfo installed.\n", .{host});
+ return 0;
+ } else {
+ try stdout.print("'{s}' does not have Ghostty terminfo installed.\n", .{host});
+ return 1;
+ }
+ }
+
+ // Default action: list all hosts
+ try listHosts(alloc, 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]") == false); // Interface notation not supported
+ try testing.expect(isValidHostname("[]") == false); // Empty IPv6
+ try testing.expect(isValidHostname("[invalid]") == false); // 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 - SECONDS_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 - (SECONDS_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 - (SECONDS_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;
+ }
+ }
+ }
+ }
+ }
+
+ // 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);
+}
+
+test "concurrent access simulation - file locking" {
+ const testing = std.testing;
+
+ // This test simulates the file locking mechanism
+ // In practice, this would require actual file operations
+ // but we can test the error handling logic
+
+ const TestError = error{CacheLocked};
+
+ const result = TestError.CacheLocked;
+ try testing.expectError(TestError.CacheLocked, result);
+}
+