diff --git a/src/cli/ssh-cache/DiskCache.zig b/src/cli/ssh-cache/DiskCache.zig new file mode 100644 index 000000000..10f38a27c --- /dev/null +++ b/src/cli/ssh-cache/DiskCache.zig @@ -0,0 +1,549 @@ +/// An SSH terminfo entry cache that stores its cache data on +/// disk. The cache only stores metadata (hostname, terminfo value, +/// etc.) and does not store any sensitive data. +const DiskCache = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const xdg = @import("../../os/main.zig").xdg; +const TempDir = @import("../../os/main.zig").TempDir; +const Entry = @import("Entry.zig"); + +// 512KB - sufficient for approximately 10k entries +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`. +/// +/// The returned value is allocated and must be freed by the caller. +pub fn defaultPath( + alloc: Allocator, + program: []const u8, +) DefaultPathError![]const u8 { + const state_dir: []const u8 = xdg.state( + alloc, + .{ .subdir = program }, + ) catch |err| return switch (err) { + error.OutOfMemory => error.OutOfMemory, + else => error.XdgLookupFailed, + }; + defer alloc.free(state_dir); + return try std.fs.path.join(alloc, &.{ state_dir, "ssh_cache" }); +} + +/// Clear all cache data stored in the disk cache. +/// This removes the cache file from disk, effectively clearing all cached +/// SSH terminfo entries. +pub fn clear(self: DiskCache) !void { + std.fs.cwd().deleteFile(self.path) catch |err| switch (err) { + error.FileNotFound => {}, + else => return err, + }; +} + +pub const AddResult = enum { added, updated }; + +/// 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). +pub fn add( + self: DiskCache, + alloc: Allocator, + hostname: []const u8, +) !AddResult { + if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid; + + // Create cache directory if needed + if (std.fs.path.dirname(self.path)) |dir| { + std.fs.makeDirAbsolute(dir) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; + } + + // Open or create cache file with secure permissions + const file = std.fs.createFileAbsolute(self.path, .{ + .read = true, + .truncate = false, + .mode = 0o600, + }) catch |err| switch (err) { + error.PathAlreadyExists => blk: { + const existing_file = try std.fs.openFileAbsolute( + self.path, + .{ .mode = .read_write }, + ); + errdefer existing_file.close(); + try fixupPermissions(existing_file); + break :blk existing_file; + }, + else => return err, + }; + defer file.close(); + + // Lock + _ = file.tryLock(.exclusive) catch return error.CacheIsLocked; + defer 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); + 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(), + .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(alloc, entries, null); + return result; +} + +/// Remove a hostname entry from the cache. +/// No error is returned if the hostname doesn't exist or the cache file is missing. +pub fn remove( + self: DiskCache, + alloc: Allocator, + hostname: []const u8, +) !void { + if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid; + + // Open our file + const file = std.fs.openFileAbsolute( + self.path, + .{ .mode = .read_write }, + ) catch |err| switch (err) { + error.FileNotFound => return, + else => return err, + }; + defer file.close(); + try fixupPermissions(file); + + // Acquire exclusive lock + _ = file.tryLock(.exclusive) catch return error.CacheIsLocked; + defer file.unlock(); + + // Read existing entries + var entries = try readEntries(alloc, file); + defer deinitEntries(alloc, &entries); + + // Remove the entry if it exists and ensure we free the memory + if (entries.fetchRemove(hostname)) |kv| { + assert(kv.key.ptr == kv.value.hostname.ptr); + alloc.free(kv.value.hostname); + alloc.free(kv.value.terminfo_version); + } + + try self.writeCacheFile(alloc, entries, null); +} + +/// Check if a hostname exists in the cache. +/// Returns false if the cache file doesn't exist. +pub fn contains( + self: DiskCache, + alloc: Allocator, + hostname: []const u8, +) !bool { + if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid; + + // Open our file + const file = std.fs.openFileAbsolute( + self.path, + .{ .mode = .read_write }, + ) catch |err| switch (err) { + error.FileNotFound => return false, + else => return err, + }; + defer file.close(); + try fixupPermissions(file); + + // Read existing entries + var entries = try readEntries(alloc, file); + defer deinitEntries(alloc, &entries); + + return entries.contains(hostname); +} + +fn fixupPermissions(file: std.fs.File) !void { + // Ensure file has correct permissions (readable/writable by + // owner only) + const stat = try file.stat(); + if (stat.mode & 0o777 != 0o600) { + try file.chmod(0o600); + } +} + +fn writeCacheFile( + self: DiskCache, + alloc: Allocator, + entries: std.StringHashMap(Entry), + expire_days: ?u32, +) !void { + var td: TempDir = try .init(); + defer td.deinit(); + + const tmp_file = try td.dir.createFile("ssh-cache", .{ .mode = 0o600 }); + defer tmp_file.close(); + const tmp_path = try td.dir.realpathAlloc(alloc, "ssh-cache"); + defer alloc.free(tmp_path); + + const writer = tmp_file.writer(); + 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(writer); + } + + // Atomic replace + try std.fs.renameAbsolute(tmp_path, self.path); +} + +/// List all entries in the cache. +/// The returned HashMap must be freed using `deinitEntries`. +/// Returns an empty map if the cache file doesn't exist. +pub fn list( + self: DiskCache, + alloc: Allocator, +) !std.StringHashMap(Entry) { + // Open our file + const file = std.fs.openFileAbsolute( + self.path, + .{}, + ) catch |err| switch (err) { + error.FileNotFound => return .init(alloc), + else => return err, + }; + defer file.close(); + return readEntries(alloc, file); +} + +/// Free memory allocated by the `list` function. +/// This must be called to properly deallocate all entry data. +pub fn deinitEntries( + alloc: Allocator, + entries: *std.StringHashMap(Entry), +) void { + // All our entries we dupe the memory owned by the hostname and the + // terminfo, and we always match the hostname key and value. + var it = entries.iterator(); + while (it.next()) |entry| { + assert(entry.key_ptr.*.ptr == entry.value_ptr.hostname.ptr); + alloc.free(entry.value_ptr.hostname); + alloc.free(entry.value_ptr.terminfo_version); + } + entries.deinit(); +} + +fn readEntries( + alloc: Allocator, + file: std.fs.File, +) !std.StringHashMap(Entry) { + const content = try file.readToEndAlloc(alloc, MAX_CACHE_SIZE); + defer alloc.free(content); + + var entries = std.StringHashMap(Entry).init(alloc); + var lines = std.mem.tokenizeScalar(u8, content, '\n'); + while (lines.next()) |line| { + const trimmed = std.mem.trim(u8, line, " \t\r"); + const entry = Entry.parse(trimmed) orelse continue; + + // Always allocate hostname first to avoid key pointer confusion + const hostname = try alloc.dupe(u8, entry.hostname); + errdefer alloc.free(hostname); + + const gop = try entries.getOrPut(hostname); + if (!gop.found_existing) { + const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version); + gop.value_ptr.* = .{ + .hostname = hostname, + .timestamp = entry.timestamp, + .terminfo_version = terminfo_copy, + }; + } else { + // Don't need the copy since entry already exists + alloc.free(hostname); + + // 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; + } + } + } + } + + return entries; +} + +// Supports both standalone hostnames and user@hostname format +fn isValidCacheKey(key: []const u8) bool { + // 253 + 1 + 64 for user@hostname + if (key.len == 0 or key.len > 320) return false; + + // Check for user@hostname format + if (std.mem.indexOf(u8, key, "@")) |at_pos| { + const user = key[0..at_pos]; + const hostname = key[at_pos + 1 ..]; + return isValidUser(user) and isValidHostname(hostname); + } + + return isValidHostname(key); +} + +// Basic hostname validation - accepts domains and IPs +// (including IPv6 in brackets) +fn isValidHostname(host: []const u8) bool { + if (host.len == 0 or host.len > 253) return false; + + // Handle IPv6 addresses in brackets + if (host.len >= 4 and host[0] == '[' and host[host.len - 1] == ']') { + const ipv6_part = host[1 .. host.len - 1]; + if (ipv6_part.len == 0) return false; + var has_colon = false; + for (ipv6_part) |c| { + switch (c) { + 'a'...'f', 'A'...'F', '0'...'9' => {}, + ':' => has_colon = true, + else => return false, + } + } + return has_colon; + } + + // Standard hostname/domain validation + for (host) |c| { + switch (c) { + 'a'...'z', 'A'...'Z', '0'...'9', '.', '-' => {}, + else => return false, + } + } + + // No leading/trailing dots or hyphens, no consecutive dots + if (host[0] == '.' or host[0] == '-' or + host[host.len - 1] == '.' or host[host.len - 1] == '-') + { + return false; + } + + return std.mem.indexOf(u8, host, "..") == null; +} + +fn isValidUser(user: []const u8) bool { + if (user.len == 0 or user.len > 64) return false; + for (user) |c| { + switch (c) { + 'a'...'z', 'A'...'Z', '0'...'9', '_', '-', '.' => {}, + else => return false, + } + } + return true; +} + +test "disk cache default path" { + const testing = std.testing; + const alloc = std.testing.allocator; + + const path = try DiskCache.defaultPath(alloc, "ghostty"); + defer alloc.free(path); + try testing.expect(path.len > 0); +} + +test "disk cache clear" { + const testing = std.testing; + const alloc = testing.allocator; + + // Create our path + var td: TempDir = try .init(); + defer td.deinit(); + { + var file = try td.dir.createFile("cache", .{}); + defer file.close(); + try file.writer().writeAll("HELLO!"); + } + const path = try td.dir.realpathAlloc(alloc, "cache"); + defer alloc.free(path); + + // Setup our cache + const cache: DiskCache = .{ .path = path }; + try cache.clear(); + + // Verify the file is gone + try testing.expectError( + error.FileNotFound, + td.dir.openFile("cache", .{}), + ); +} + +test "disk cache operations" { + const testing = std.testing; + const alloc = testing.allocator; + + // Create our path + var td: TempDir = try .init(); + defer td.deinit(); + { + var file = try td.dir.createFile("cache", .{}); + defer file.close(); + try file.writer().writeAll("HELLO!"); + } + const path = try td.dir.realpathAlloc(alloc, "cache"); + defer alloc.free(path); + + // Setup our cache + 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"), + ); + + // 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"), + ); +} + +// Tests +test "hostname validation - valid cases" { + const testing = std.testing; + try testing.expect(isValidHostname("example.com")); + try testing.expect(isValidHostname("sub.example.com")); + try testing.expect(isValidHostname("host-name.domain.org")); + try testing.expect(isValidHostname("192.168.1.1")); + try testing.expect(isValidHostname("a")); + try testing.expect(isValidHostname("1")); +} + +test "hostname validation - IPv6 addresses" { + const testing = std.testing; + try testing.expect(isValidHostname("[::1]")); + try testing.expect(isValidHostname("[2001:db8::1]")); + try testing.expect(!isValidHostname("[fe80::1%eth0]")); // Interface notation not supported + try testing.expect(!isValidHostname("[]")); // Empty IPv6 + try testing.expect(!isValidHostname("[invalid]")); // No colons +} + +test "hostname validation - invalid cases" { + const testing = std.testing; + try testing.expect(!isValidHostname("")); + try testing.expect(!isValidHostname("host\nname")); + try testing.expect(!isValidHostname(".example.com")); + try testing.expect(!isValidHostname("example.com.")); + try testing.expect(!isValidHostname("host..domain")); + try testing.expect(!isValidHostname("-hostname")); + try testing.expect(!isValidHostname("hostname-")); + try testing.expect(!isValidHostname("host name")); + try testing.expect(!isValidHostname("host_name")); + try testing.expect(!isValidHostname("host@domain")); + try testing.expect(!isValidHostname("host:port")); + + // Too long + const long_host = "a" ** 254; + try testing.expect(!isValidHostname(long_host)); +} + +test "user validation - valid cases" { + const testing = std.testing; + try testing.expect(isValidUser("user")); + try testing.expect(isValidUser("deploy")); + try testing.expect(isValidUser("test-user")); + try testing.expect(isValidUser("user_name")); + try testing.expect(isValidUser("user.name")); + try testing.expect(isValidUser("user123")); + try testing.expect(isValidUser("a")); +} + +test "user validation - complex realistic cases" { + const testing = std.testing; + try testing.expect(isValidUser("git")); + try testing.expect(isValidUser("ubuntu")); + try testing.expect(isValidUser("root")); + try testing.expect(isValidUser("service.account")); + try testing.expect(isValidUser("user-with-dashes")); +} + +test "user validation - invalid cases" { + const testing = std.testing; + try testing.expect(!isValidUser("")); + try testing.expect(!isValidUser("user name")); + try testing.expect(!isValidUser("user@domain")); + try testing.expect(!isValidUser("user:group")); + try testing.expect(!isValidUser("user\nname")); + + // Too long + const long_user = "a" ** 65; + try testing.expect(!isValidUser(long_user)); +} + +test "cache key validation - hostname format" { + const testing = std.testing; + try testing.expect(isValidCacheKey("example.com")); + try testing.expect(isValidCacheKey("sub.example.com")); + try testing.expect(isValidCacheKey("192.168.1.1")); + try testing.expect(isValidCacheKey("[::1]")); + try testing.expect(!isValidCacheKey("")); + try testing.expect(!isValidCacheKey(".invalid.com")); +} + +test "cache key validation - user@hostname format" { + const testing = std.testing; + try testing.expect(isValidCacheKey("user@example.com")); + try testing.expect(isValidCacheKey("deploy@prod.server.com")); + try testing.expect(isValidCacheKey("test-user@192.168.1.1")); + try testing.expect(isValidCacheKey("user_name@host.domain.org")); + try testing.expect(isValidCacheKey("git@github.com")); + try testing.expect(isValidCacheKey("ubuntu@[::1]")); + try testing.expect(!isValidCacheKey("@example.com")); + try testing.expect(!isValidCacheKey("user@")); + try testing.expect(!isValidCacheKey("user@@host")); + try testing.expect(!isValidCacheKey("user@.invalid.com")); +} diff --git a/src/cli/ssh-cache/Entry.zig b/src/cli/ssh-cache/Entry.zig new file mode 100644 index 000000000..3a691be80 --- /dev/null +++ b/src/cli/ssh-cache/Entry.zig @@ -0,0 +1,154 @@ +/// A single entry within our SSH entry cache. Our SSH entry cache +/// stores which hosts we've sent our terminfo to so that we don't have +/// to send it again. It doesn't store any sensitive information. +const Entry = @This(); + +const std = @import("std"); + +hostname: []const u8, +timestamp: i64, +terminfo_version: []const u8, + +pub fn parse(line: []const u8) ?Entry { + const trimmed = std.mem.trim(u8, line, " \t\r\n"); + if (trimmed.len == 0) return null; + + // Parse format: hostname|timestamp|terminfo_version + var iter = std.mem.tokenizeScalar(u8, trimmed, '|'); + const hostname = iter.next() orelse return null; + const timestamp_str = iter.next() orelse return null; + const terminfo_version = iter.next() orelse "xterm-ghostty"; + const timestamp = std.fmt.parseInt(i64, timestamp_str, 10) catch |err| { + std.log.warn( + "Invalid timestamp in cache entry: {s} err={}", + .{ timestamp_str, err }, + ); + return null; + }; + + return .{ + .hostname = hostname, + .timestamp = timestamp, + .terminfo_version = terminfo_version, + }; +} + +pub fn format(self: Entry, writer: anytype) !void { + try writer.print( + "{s}|{d}|{s}\n", + .{ self.hostname, self.timestamp, self.terminfo_version }, + ); +} + +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; + + const entry = Entry.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 = Entry.parse("test.com|1640995200").?; + try testing.expectEqualStrings( + "xterm-ghostty", + entry_no_version.terminfo_version, + ); + + // Test complex hostnames + const complex_entry = Entry.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(Entry.parse("") == null); + + // Invalid format (no pipe) + try testing.expect(Entry.parse("v1") == null); + + // Missing timestamp + try testing.expect(Entry.parse("example.com") == null); + + // Invalid timestamp + try testing.expect(Entry.parse("example.com|invalid") == null); + + // Empty terminfo should default + try testing.expect(Entry.parse("example.com|1640995200|") != null); +} + +test "cache entry parsing malformed data resilience" { + const testing = std.testing; + + // Extra pipes should not break parsing + try testing.expect(Entry.parse("host|123|term|extra") != null); + + // Whitespace handling + try testing.expect(Entry.parse(" host|123|term ") != null); + try testing.expect(Entry.parse("\n") == null); + try testing.expect(Entry.parse(" \t \n") == null); + + // Extremely large timestamp + try testing.expect( + Entry.parse("host|999999999999999999999999999999999999999999999999|xterm-ghostty") == null, + ); +} diff --git a/src/cli/ssh_cache.zig b/src/cli/ssh_cache.zig index 2912addc1..c8e2e1123 100644 --- a/src/cli/ssh_cache.zig +++ b/src/cli/ssh_cache.zig @@ -4,21 +4,15 @@ const Allocator = std.mem.Allocator; const xdg = @import("../os/xdg.zig"); const args = @import("args.zig"); const Action = @import("action.zig").Action; - -pub const CacheError = error{ - InvalidCacheKey, - CacheLocked, -} || fs.File.OpenError || fs.File.WriteError || Allocator.Error; - -const MAX_CACHE_SIZE = 512 * 1024; // 512KB - sufficient for approximately 10k entries -const NEVER_EXPIRE = 0; +pub const Entry = @import("ssh-cache/Entry.zig"); +pub const DiskCache = @import("ssh-cache/DiskCache.zig"); pub const Options = struct { clear: bool = false, add: ?[]const u8 = null, remove: ?[]const u8 = null, host: ?[]const u8 = null, - @"expire-days": u32 = NEVER_EXPIRE, + @"expire-days": ?u32 = null, pub fn deinit(self: *Options) void { _ = self; @@ -30,373 +24,6 @@ pub const Options = struct { } }; -const CacheEntry = struct { - hostname: []const u8, - timestamp: i64, - terminfo_version: []const u8, - - fn parse(line: []const u8) ?CacheEntry { - const trimmed = std.mem.trim(u8, line, " \t\r\n"); - if (trimmed.len == 0) return null; - - // Parse format: hostname|timestamp|terminfo_version - var iter = std.mem.tokenizeScalar(u8, trimmed, '|'); - const hostname = iter.next() orelse return null; - const timestamp_str = iter.next() orelse return null; - const terminfo_version = iter.next() orelse "xterm-ghostty"; - - const timestamp = std.fmt.parseInt(i64, timestamp_str, 10) catch |err| { - std.log.warn("Invalid timestamp in cache entry: {s} err={}", .{ timestamp_str, err }); - return null; - }; - - return .{ - .hostname = hostname, - .timestamp = timestamp, - .terminfo_version = terminfo_version, - }; - } - - fn format(self: CacheEntry, writer: anytype) !void { - try writer.print("{s}|{d}|{s}\n", .{ self.hostname, self.timestamp, self.terminfo_version }); - } - - fn isExpired(self: CacheEntry, expire_days: u32) bool { - if (expire_days == NEVER_EXPIRE) return false; - const now = std.time.timestamp(); - const age_days = @divTrunc(now - self.timestamp, std.time.s_per_day); - return age_days > expire_days; - } -}; - -const AddResult = enum { - added, - updated, -}; - -fn getCachePath(allocator: Allocator) ![]const u8 { - const state_dir = try xdg.state(allocator, .{ .subdir = "ghostty" }); - defer allocator.free(state_dir); - return try std.fs.path.join(allocator, &.{ state_dir, "ssh_cache" }); -} - -// Supports both standalone hostnames and user@hostname format -fn isValidCacheKey(key: []const u8) bool { - // 253 + 1 + 64 for user@hostname - if (key.len == 0 or key.len > 320) return false; - - // Check for user@hostname format - if (std.mem.indexOf(u8, key, "@")) |at_pos| { - const user = key[0..at_pos]; - const hostname = key[at_pos + 1 ..]; - return isValidUser(user) and isValidHostname(hostname); - } - - return isValidHostname(key); -} - -// Basic hostname validation - accepts domains and IPs -// (including IPv6 in brackets) -fn isValidHostname(host: []const u8) bool { - if (host.len == 0 or host.len > 253) return false; - - // Handle IPv6 addresses in brackets - if (host.len >= 4 and host[0] == '[' and host[host.len - 1] == ']') { - const ipv6_part = host[1 .. host.len - 1]; - if (ipv6_part.len == 0) return false; - var has_colon = false; - for (ipv6_part) |c| { - switch (c) { - 'a'...'f', 'A'...'F', '0'...'9' => {}, - ':' => has_colon = true, - else => return false, - } - } - return has_colon; - } - - // Standard hostname/domain validation - for (host) |c| { - switch (c) { - 'a'...'z', 'A'...'Z', '0'...'9', '.', '-' => {}, - else => return false, - } - } - - // No leading/trailing dots or hyphens, no consecutive dots - if (host[0] == '.' or host[0] == '-' or - host[host.len - 1] == '.' or host[host.len - 1] == '-') - { - return false; - } - - return std.mem.indexOf(u8, host, "..") == null; -} - -fn isValidUser(user: []const u8) bool { - if (user.len == 0 or user.len > 64) return false; - for (user) |c| { - switch (c) { - 'a'...'z', 'A'...'Z', '0'...'9', '_', '-', '.' => {}, - else => return false, - } - } - return true; -} - -fn acquireFileLock(file: fs.File) CacheError!void { - _ = file.tryLock(.exclusive) catch { - return CacheError.CacheLocked; - }; -} - -fn readCacheFile( - alloc: Allocator, - path: []const u8, - entries: *std.StringHashMap(CacheEntry), -) !void { - const file = fs.openFileAbsolute(path, .{}) catch |err| switch (err) { - error.FileNotFound => return, - else => return err, - }; - defer file.close(); - - const content = try file.readToEndAlloc(alloc, MAX_CACHE_SIZE); - defer alloc.free(content); - - var lines = std.mem.tokenizeScalar(u8, content, '\n'); - - while (lines.next()) |line| { - const trimmed = std.mem.trim(u8, line, " \t\r"); - - if (CacheEntry.parse(trimmed)) |entry| { - // Always allocate hostname first to avoid key pointer confusion - const hostname_copy = try alloc.dupe(u8, entry.hostname); - errdefer alloc.free(hostname_copy); - - const gop = try entries.getOrPut(hostname_copy); - if (!gop.found_existing) { - const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version); - gop.value_ptr.* = .{ - .hostname = hostname_copy, - .timestamp = entry.timestamp, - .terminfo_version = terminfo_copy, - }; - } else { - // Don't need the copy since entry already exists - alloc.free(hostname_copy); - - // Handle duplicate entries - keep newer timestamp - if (entry.timestamp > gop.value_ptr.timestamp) { - gop.value_ptr.timestamp = entry.timestamp; - if (!std.mem.eql(u8, gop.value_ptr.terminfo_version, entry.terminfo_version)) { - alloc.free(gop.value_ptr.terminfo_version); - const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version); - gop.value_ptr.terminfo_version = terminfo_copy; - } - } - } - } - } -} - -// Atomic write via temp file + rename, filters out expired entries -fn writeCacheFile( - alloc: Allocator, - path: []const u8, - entries: *const std.StringHashMap(CacheEntry), - expire_days: u32, -) !void { - // Ensure parent directory exists - const dir = std.fs.path.dirname(path).?; - fs.makeDirAbsolute(dir) catch |err| switch (err) { - error.PathAlreadyExists => {}, - else => return err, - }; - - // Write to temp file first - const tmp_path = try std.fmt.allocPrint(alloc, "{s}.tmp", .{path}); - defer alloc.free(tmp_path); - - const tmp_file = try fs.createFileAbsolute(tmp_path, .{ .mode = 0o600 }); - defer tmp_file.close(); - errdefer fs.deleteFileAbsolute(tmp_path) catch {}; - - const writer = tmp_file.writer(); - - // Only write non-expired entries - var iter = entries.iterator(); - while (iter.next()) |kv| { - if (!kv.value_ptr.isExpired(expire_days)) { - try kv.value_ptr.format(writer); - } - } - - // Atomic replace - try fs.renameAbsolute(tmp_path, path); -} - -fn checkHost(alloc: Allocator, host: []const u8) !bool { - if (!isValidCacheKey(host)) return CacheError.InvalidCacheKey; - - const path = try getCachePath(alloc); - - var entries = std.StringHashMap(CacheEntry).init(alloc); - - try readCacheFile(alloc, path, &entries); - return entries.contains(host); -} - -fn addHost(alloc: Allocator, host: []const u8) !AddResult { - if (!isValidCacheKey(host)) return CacheError.InvalidCacheKey; - - const path = try getCachePath(alloc); - - // Create cache directory if needed - const dir = std.fs.path.dirname(path).?; - fs.makeDirAbsolute(dir) catch |err| switch (err) { - error.PathAlreadyExists => {}, - else => return err, - }; - - // Open or create cache file with secure permissions - const file = fs.createFileAbsolute(path, .{ - .read = true, - .truncate = false, - .mode = 0o600, - }) catch |err| switch (err) { - error.PathAlreadyExists => blk: { - const existing_file = fs.openFileAbsolute(path, .{ .mode = .read_write }) catch |open_err| { - return open_err; - }; - - // Verify and fix permissions on existing file - const stat = existing_file.stat() catch |stat_err| { - existing_file.close(); - return stat_err; - }; - - // Ensure file has correct permissions (readable/writable by owner only) - if (stat.mode & 0o777 != 0o600) { - existing_file.chmod(0o600) catch |chmod_err| { - existing_file.close(); - return chmod_err; - }; - } - - break :blk existing_file; - }, - else => return err, - }; - defer file.close(); - - try acquireFileLock(file); - defer file.unlock(); - - var entries = std.StringHashMap(CacheEntry).init(alloc); - - try readCacheFile(alloc, path, &entries); - - // Add or update entry - const gop = try entries.getOrPut(host); - const result = if (!gop.found_existing) blk: { - gop.key_ptr.* = try alloc.dupe(u8, host); - gop.value_ptr.* = .{ - .hostname = gop.key_ptr.*, - .timestamp = std.time.timestamp(), - .terminfo_version = "xterm-ghostty", - }; - break :blk AddResult.added; - } else blk: { - // Update timestamp for existing entry - gop.value_ptr.timestamp = std.time.timestamp(); - break :blk AddResult.updated; - }; - - try writeCacheFile(alloc, path, &entries, NEVER_EXPIRE); - return result; -} - -fn removeHost(alloc: Allocator, host: []const u8) !void { - if (!isValidCacheKey(host)) return CacheError.InvalidCacheKey; - - const path = try getCachePath(alloc); - - const file = fs.openFileAbsolute(path, .{ .mode = .read_write }) catch |err| switch (err) { - error.FileNotFound => return, - else => return err, - }; - defer file.close(); - - try acquireFileLock(file); - defer file.unlock(); - - var entries = std.StringHashMap(CacheEntry).init(alloc); - - try readCacheFile(alloc, path, &entries); - - _ = entries.fetchRemove(host); - - try writeCacheFile(alloc, path, &entries, NEVER_EXPIRE); -} - -fn listHosts(alloc: Allocator, writer: anytype) !void { - const path = try getCachePath(alloc); - - var entries = std.StringHashMap(CacheEntry).init(alloc); - - readCacheFile(alloc, path, &entries) catch |err| switch (err) { - error.FileNotFound => { - try writer.print("No hosts in cache.\n", .{}); - return; - }, - else => return err, - }; - - if (entries.count() == 0) { - try writer.print("No hosts in cache.\n", .{}); - return; - } - - // Sort entries by hostname for consistent output - var items = std.ArrayList(CacheEntry).init(alloc); - defer items.deinit(); - - var iter = entries.iterator(); - while (iter.next()) |kv| { - try items.append(kv.value_ptr.*); - } - - std.mem.sort(CacheEntry, items.items, {}, struct { - fn lessThan(_: void, a: CacheEntry, b: CacheEntry) bool { - return std.mem.lessThan(u8, a.hostname, b.hostname); - } - }.lessThan); - - try writer.print("Cached hosts ({d}):\n", .{items.items.len}); - const now = std.time.timestamp(); - - for (items.items) |entry| { - const age_days = @divTrunc(now - entry.timestamp, std.time.s_per_day); - if (age_days == 0) { - try writer.print(" {s} (today)\n", .{entry.hostname}); - } else if (age_days == 1) { - try writer.print(" {s} (yesterday)\n", .{entry.hostname}); - } else { - try writer.print(" {s} ({d} days ago)\n", .{ entry.hostname, age_days }); - } - } -} - -fn clearCache(alloc: Allocator) !void { - const path = try getCachePath(alloc); - - fs.deleteFileAbsolute(path) catch |err| switch (err) { - error.FileNotFound => {}, - else => return err, - }; -} - /// Manage the SSH terminfo cache for automatic remote host setup. /// /// When SSH integration is enabled with `shell-integration-features = ssh-terminfo`, @@ -407,6 +34,11 @@ fn clearCache(alloc: Allocator) !void { /// Entries older than the expiration period are automatically removed during cache /// operations. By default, entries never expire. /// +/// Only one of `--clear`, `--add`, `--remove`, or `--host` can be specified. +/// If multiple are specified, one of the actions will be executed but +/// it isn't guaranteed which one. This is entirely unsafe so you should split +/// multiple actions into separate commands. +/// /// Examples: /// ghostty +ssh-cache # List all cached hosts /// ghostty +ssh-cache --host=example.com # Check if host is cached @@ -432,34 +64,34 @@ pub fn run(alloc_gpa: Allocator) !u8 { const stdout = std.io.getStdOut().writer(); const stderr = std.io.getStdErr().writer(); + // Setup our disk cache to the standard location + const cache_path = try DiskCache.defaultPath(alloc, "ghostty"); + const cache: DiskCache = .{ .path = cache_path }; + if (opts.clear) { - try clearCache(alloc); + try cache.clear(); try stdout.print("Cache cleared.\n", .{}); return 0; } if (opts.add) |host| { - const result = addHost(alloc, host) catch |err| { - const Error = error{PermissionDenied} || @TypeOf(err); - switch (@as(Error, err)) { - CacheError.InvalidCacheKey => { - try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); - try stderr.print("Expected format: hostname or user@hostname\n", .{}); - return 1; - }, - CacheError.CacheLocked => { - try stderr.print("Error: Cache is busy, try again\n", .{}); - return 1; - }, - error.AccessDenied, error.PermissionDenied => { - try stderr.print("Error: Permission denied\n", .{}); - return 1; - }, - else => { - try stderr.print("Error: Unable to add '{s}' to cache\n", .{host}); - return 1; - }, - } + const result = cache.add(alloc, host) catch |err| switch (err) { + DiskCache.Error.HostnameIsInvalid => { + try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); + try stderr.print("Expected format: hostname or user@hostname\n", .{}); + return 1; + }, + DiskCache.Error.CacheIsLocked => { + try stderr.print("Error: Cache is busy, try again\n", .{}); + return 1; + }, + else => { + try stderr.print( + "Error: Unable to add '{s}' to cache. Error: {}\n", + .{ host, err }, + ); + return 1; + }, }; switch (result) { @@ -470,50 +102,42 @@ pub fn run(alloc_gpa: Allocator) !u8 { } if (opts.remove) |host| { - removeHost(alloc, host) catch |err| { - const Error = error{PermissionDenied} || @TypeOf(err); - switch (@as(Error, err)) { - CacheError.InvalidCacheKey => { - try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); - try stderr.print("Expected format: hostname or user@hostname\n", .{}); - return 1; - }, - CacheError.CacheLocked => { - try stderr.print("Error: Cache is busy, try again\n", .{}); - return 1; - }, - error.AccessDenied, error.PermissionDenied => { - try stderr.print("Error: Permission denied\n", .{}); - return 1; - }, - else => { - try stderr.print("Error: Unable to remove '{s}' from cache\n", .{host}); - return 1; - }, - } + cache.remove(alloc, host) catch |err| switch (err) { + DiskCache.Error.HostnameIsInvalid => { + try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); + try stderr.print("Expected format: hostname or user@hostname\n", .{}); + return 1; + }, + DiskCache.Error.CacheIsLocked => { + try stderr.print("Error: Cache is busy, try again\n", .{}); + return 1; + }, + else => { + try stderr.print( + "Error: Unable to remove '{s}' from cache. Error: {}\n", + .{ host, err }, + ); + return 1; + }, }; try stdout.print("Removed '{s}' from cache.\n", .{host}); return 0; } if (opts.host) |host| { - const cached = checkHost(alloc, host) catch |err| { - const Error = error{PermissionDenied} || @TypeOf(err); - switch (@as(Error, err)) { - CacheError.InvalidCacheKey => { - try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); - try stderr.print("Expected format: hostname or user@hostname\n", .{}); - return 1; - }, - error.AccessDenied, error.PermissionDenied => { - try stderr.print("Error: Permission denied\n", .{}); - return 1; - }, - else => { - try stderr.print("Error: Unable to check host '{s}' in cache\n", .{host}); - return 1; - }, - } + const cached = cache.contains(alloc, host) catch |err| switch (err) { + error.HostnameIsInvalid => { + try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); + try stderr.print("Expected format: hostname or user@hostname\n", .{}); + return 1; + }, + else => { + try stderr.print( + "Error: Unable to check host '{s}' in cache. Error: {}\n", + .{ host, err }, + ); + return 1; + }, }; if (cached) { @@ -532,224 +156,53 @@ pub fn run(alloc_gpa: Allocator) !u8 { } // Default action: list all hosts - try listHosts(alloc, stdout); + var entries = try cache.list(alloc); + defer DiskCache.deinitEntries(alloc, &entries); + try listEntries(alloc, &entries, stdout); return 0; } -// Tests -test "hostname validation - valid cases" { - const testing = std.testing; - try testing.expect(isValidHostname("example.com")); - try testing.expect(isValidHostname("sub.example.com")); - try testing.expect(isValidHostname("host-name.domain.org")); - try testing.expect(isValidHostname("192.168.1.1")); - try testing.expect(isValidHostname("a")); - try testing.expect(isValidHostname("1")); -} - -test "hostname validation - IPv6 addresses" { - const testing = std.testing; - try testing.expect(isValidHostname("[::1]")); - try testing.expect(isValidHostname("[2001:db8::1]")); - try testing.expect(!isValidHostname("[fe80::1%eth0]")); // Interface notation not supported - try testing.expect(!isValidHostname("[]")); // Empty IPv6 - try testing.expect(!isValidHostname("[invalid]")); // No colons -} - -test "hostname validation - invalid cases" { - const testing = std.testing; - try testing.expect(!isValidHostname("")); - try testing.expect(!isValidHostname("host\nname")); - try testing.expect(!isValidHostname(".example.com")); - try testing.expect(!isValidHostname("example.com.")); - try testing.expect(!isValidHostname("host..domain")); - try testing.expect(!isValidHostname("-hostname")); - try testing.expect(!isValidHostname("hostname-")); - try testing.expect(!isValidHostname("host name")); - try testing.expect(!isValidHostname("host_name")); - try testing.expect(!isValidHostname("host@domain")); - try testing.expect(!isValidHostname("host:port")); - - // Too long - const long_host = "a" ** 254; - try testing.expect(!isValidHostname(long_host)); -} - -test "user validation - valid cases" { - const testing = std.testing; - try testing.expect(isValidUser("user")); - try testing.expect(isValidUser("deploy")); - try testing.expect(isValidUser("test-user")); - try testing.expect(isValidUser("user_name")); - try testing.expect(isValidUser("user.name")); - try testing.expect(isValidUser("user123")); - try testing.expect(isValidUser("a")); -} - -test "user validation - complex realistic cases" { - const testing = std.testing; - try testing.expect(isValidUser("git")); - try testing.expect(isValidUser("ubuntu")); - try testing.expect(isValidUser("root")); - try testing.expect(isValidUser("service.account")); - try testing.expect(isValidUser("user-with-dashes")); -} - -test "user validation - invalid cases" { - const testing = std.testing; - try testing.expect(!isValidUser("")); - try testing.expect(!isValidUser("user name")); - try testing.expect(!isValidUser("user@domain")); - try testing.expect(!isValidUser("user:group")); - try testing.expect(!isValidUser("user\nname")); - - // Too long - const long_user = "a" ** 65; - try testing.expect(!isValidUser(long_user)); -} - -test "cache key validation - hostname format" { - const testing = std.testing; - try testing.expect(isValidCacheKey("example.com")); - try testing.expect(isValidCacheKey("sub.example.com")); - try testing.expect(isValidCacheKey("192.168.1.1")); - try testing.expect(isValidCacheKey("[::1]")); - try testing.expect(!isValidCacheKey("")); - try testing.expect(!isValidCacheKey(".invalid.com")); -} - -test "cache key validation - user@hostname format" { - const testing = std.testing; - try testing.expect(isValidCacheKey("user@example.com")); - try testing.expect(isValidCacheKey("deploy@prod.server.com")); - try testing.expect(isValidCacheKey("test-user@192.168.1.1")); - try testing.expect(isValidCacheKey("user_name@host.domain.org")); - try testing.expect(isValidCacheKey("git@github.com")); - try testing.expect(isValidCacheKey("ubuntu@[::1]")); - try testing.expect(!isValidCacheKey("@example.com")); - try testing.expect(!isValidCacheKey("user@")); - try testing.expect(!isValidCacheKey("user@@host")); - try testing.expect(!isValidCacheKey("user@.invalid.com")); -} - -test "cache entry expiration" { - const testing = std.testing; - const now = std.time.timestamp(); - - const fresh_entry = CacheEntry{ - .hostname = "test.com", - .timestamp = now - std.time.s_per_day, // 1 day old - .terminfo_version = "xterm-ghostty", - }; - try testing.expect(!fresh_entry.isExpired(90)); - - const old_entry = CacheEntry{ - .hostname = "old.com", - .timestamp = now - (std.time.s_per_day * 100), // 100 days old - .terminfo_version = "xterm-ghostty", - }; - try testing.expect(old_entry.isExpired(90)); - - // Test never-expire case - try testing.expect(!old_entry.isExpired(NEVER_EXPIRE)); -} - -test "cache entry expiration - boundary cases" { - const testing = std.testing; - const now = std.time.timestamp(); - - // Exactly at expiration boundary - const boundary_entry = CacheEntry{ - .hostname = "boundary.com", - .timestamp = now - (std.time.s_per_day * 30), // Exactly 30 days old - .terminfo_version = "xterm-ghostty", - }; - try testing.expect(!boundary_entry.isExpired(30)); // Should not be expired - try testing.expect(boundary_entry.isExpired(29)); // Should be expired -} - -test "cache entry parsing - valid formats" { - const testing = std.testing; - - const entry = CacheEntry.parse("example.com|1640995200|xterm-ghostty").?; - try testing.expectEqualStrings("example.com", entry.hostname); - try testing.expectEqual(@as(i64, 1640995200), entry.timestamp); - try testing.expectEqualStrings("xterm-ghostty", entry.terminfo_version); - - // Test default terminfo version - const entry_no_version = CacheEntry.parse("test.com|1640995200").?; - try testing.expectEqualStrings("xterm-ghostty", entry_no_version.terminfo_version); - - // Test complex hostnames - const complex_entry = CacheEntry.parse("user@server.example.com|1640995200|xterm-ghostty").?; - try testing.expectEqualStrings("user@server.example.com", complex_entry.hostname); -} - -test "cache entry parsing - invalid formats" { - const testing = std.testing; - - try testing.expect(CacheEntry.parse("") == null); - try testing.expect(CacheEntry.parse("v1") == null); // Invalid format (no pipe) - try testing.expect(CacheEntry.parse("example.com") == null); // Missing timestamp - try testing.expect(CacheEntry.parse("example.com|invalid") == null); // Invalid timestamp - try testing.expect(CacheEntry.parse("example.com|1640995200|") != null); // Empty terminfo should default -} - -test "cache entry parsing - malformed data resilience" { - const testing = std.testing; - - // Extra pipes should not break parsing - try testing.expect(CacheEntry.parse("host|123|term|extra") != null); - - // Whitespace handling - try testing.expect(CacheEntry.parse(" host|123|term ") != null); - try testing.expect(CacheEntry.parse("\n") == null); - try testing.expect(CacheEntry.parse(" \t \n") == null); -} - -test "duplicate cache entries - memory management" { - const testing = std.testing; - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const alloc = arena.allocator(); - - var entries = std.StringHashMap(CacheEntry).init(alloc); - defer entries.deinit(); - - // Simulate reading a cache file with duplicate hostnames - const cache_content = "example.com|1640995200|xterm-ghostty\nexample.com|1640995300|xterm-ghostty-v2\n"; - - var lines = std.mem.tokenizeScalar(u8, cache_content, '\n'); - while (lines.next()) |line| { - const trimmed = std.mem.trim(u8, line, " \t\r"); - if (CacheEntry.parse(trimmed)) |entry| { - const gop = try entries.getOrPut(entry.hostname); - if (!gop.found_existing) { - const hostname_copy = try alloc.dupe(u8, entry.hostname); - const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version); - gop.key_ptr.* = hostname_copy; - gop.value_ptr.* = CacheEntry{ - .hostname = hostname_copy, - .timestamp = entry.timestamp, - .terminfo_version = terminfo_copy, - }; - } else { - // Test the duplicate handling logic - if (entry.timestamp > gop.value_ptr.timestamp) { - gop.value_ptr.timestamp = entry.timestamp; - if (!std.mem.eql(u8, gop.value_ptr.terminfo_version, entry.terminfo_version)) { - alloc.free(gop.value_ptr.terminfo_version); - const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version); - gop.value_ptr.terminfo_version = terminfo_copy; - } - } - } - } +fn listEntries( + alloc: Allocator, + entries: *const std.StringHashMap(Entry), + writer: anytype, +) !void { + if (entries.count() == 0) { + try writer.print("No hosts in cache.\n", .{}); + return; } - // Verify only one entry exists with the newer timestamp - try testing.expect(entries.count() == 1); - const entry = entries.get("example.com").?; - try testing.expectEqual(@as(i64, 1640995300), entry.timestamp); - try testing.expectEqualStrings("xterm-ghostty-v2", entry.terminfo_version); + // Sort entries by hostname for consistent output + var items = std.ArrayList(Entry).init(alloc); + defer items.deinit(); + + var iter = entries.iterator(); + while (iter.next()) |kv| { + try items.append(kv.value_ptr.*); + } + + std.mem.sort(Entry, items.items, {}, struct { + fn lessThan(_: void, a: Entry, b: Entry) bool { + return std.mem.lessThan(u8, a.hostname, b.hostname); + } + }.lessThan); + + try writer.print("Cached hosts ({d}):\n", .{items.items.len}); + const now = std.time.timestamp(); + + for (items.items) |entry| { + const age_days = @divTrunc(now - entry.timestamp, std.time.s_per_day); + if (age_days == 0) { + try writer.print(" {s} (today)\n", .{entry.hostname}); + } else if (age_days == 1) { + try writer.print(" {s} (yesterday)\n", .{entry.hostname}); + } else { + try writer.print(" {s} ({d} days ago)\n", .{ entry.hostname, age_days }); + } + } +} + +test { + _ = DiskCache; + _ = Entry; } diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index 36ae116d5..469ff2859 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -177,10 +177,28 @@ pub fn setupFeatures( }; var buffer = try std.BoundedArray(u8, capacity).init(0); - inline for (fields) |field| { - if (@field(features, field.name)) { + // Sort the fields so that the output is deterministic. This is + // done at comptime so it has no runtime cost + const fields_sorted: [fields.len][]const u8 = comptime fields: { + var fields_sorted: [fields.len][]const u8 = undefined; + for (fields, 0..) |field, i| fields_sorted[i] = field.name; + std.mem.sortUnstable( + []const u8, + &fields_sorted, + {}, + (struct { + fn lessThan(_: void, lhs: []const u8, rhs: []const u8) bool { + return std.ascii.orderIgnoreCase(lhs, rhs) == .lt; + } + }).lessThan, + ); + break :fields fields_sorted; + }; + + inline for (fields_sorted) |name| { + if (@field(features, name)) { if (buffer.len > 0) try buffer.append(','); - try buffer.appendSlice(field.name); + try buffer.appendSlice(name); } } @@ -220,7 +238,7 @@ test "setup features" { defer env.deinit(); try setupFeatures(&env, .{ .cursor = false, .sudo = true, .title = false, .@"ssh-env" = true, .@"ssh-terminfo" = false }); - try testing.expectEqualStrings("sudo,ssh-env", env.get("GHOSTTY_SHELL_FEATURES").?); + try testing.expectEqualStrings("ssh-env,sudo", env.get("GHOSTTY_SHELL_FEATURES").?); } }