diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-01 23:05:23 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-01 23:05:51 +0300 |
| commit | 5bd21b7ed468b5ff4d1a5b2ed7e211f401eb138a (patch) | |
| tree | 672fa4625cca0ecb3e9376fc7f8e33d7b0e9deef /vendor/libghostty-vt/src/cli | |
| parent | 32abb3776668ee768200019c170956f67fe33a86 (diff) | |
chore: update libghostty vendor
refs #276
Diffstat (limited to 'vendor/libghostty-vt/src/cli')
| -rw-r--r-- | vendor/libghostty-vt/src/cli/ghostty.zig | 12 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/cli/ssh-cache/DiskCache.zig | 412 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/cli/ssh-cache/Entry.zig | 55 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/cli/ssh.zig | 635 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/cli/ssh_cache.zig | 454 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/cli/toggle_quick_terminal.zig | 62 |
6 files changed, 1356 insertions, 274 deletions
diff --git a/vendor/libghostty-vt/src/cli/ghostty.zig b/vendor/libghostty-vt/src/cli/ghostty.zig index 3acb9004..6e836851 100644 --- a/vendor/libghostty-vt/src/cli/ghostty.zig +++ b/vendor/libghostty-vt/src/cli/ghostty.zig @@ -11,6 +11,7 @@ const list_keybinds = @import("list_keybinds.zig"); const list_themes = @import("list_themes.zig"); const list_colors = @import("list_colors.zig"); const list_actions = @import("list_actions.zig"); +const ssh = @import("ssh.zig"); const ssh_cache = @import("ssh_cache.zig"); const edit_config = @import("edit_config.zig"); const show_config = @import("show_config.zig"); @@ -20,6 +21,7 @@ const crash_report = @import("crash_report.zig"); const show_face = @import("show_face.zig"); const boo = @import("boo.zig"); const new_window = @import("new_window.zig"); +const toggle_quick_terminal = @import("toggle_quick_terminal.zig"); /// Special commands that can be invoked via CLI flags. These are all /// invoked by using `+<action>` as a CLI flag. The only exception is @@ -46,6 +48,9 @@ pub const Action = enum { /// List keybind actions @"list-actions", + /// Wrap `ssh` to configure Ghostty terminal integration on remote hosts + ssh, + /// Manage SSH terminfo cache for automatic remote host setup @"ssh-cache", @@ -73,6 +78,9 @@ pub const Action = enum { // Use IPC to tell the running Ghostty to open a new window. @"new-window", + // Use IPC to tell the running Ghostty to toggle the quick terminal. + @"toggle-quick-terminal", + pub fn detectSpecialCase(arg: []const u8) ?SpecialCase(Action) { // If we see a "-e" and we haven't seen a command yet, then // we are done looking for commands. This special case enables @@ -144,6 +152,7 @@ pub const Action = enum { .@"list-colors" => try list_colors.run(alloc), .@"list-actions" => try list_actions.run(alloc), .@"ssh-cache" => try ssh_cache.run(alloc), + .ssh => try ssh.run(alloc), .@"edit-config" => try edit_config.run(alloc), .@"show-config" => try show_config.run(alloc), .@"explain-config" => try explain_config.run(alloc), @@ -152,6 +161,7 @@ pub const Action = enum { .@"show-face" => try show_face.run(alloc), .boo => try boo.run(alloc), .@"new-window" => try new_window.run(alloc), + .@"toggle-quick-terminal" => try toggle_quick_terminal.run(alloc), }; } @@ -184,6 +194,7 @@ pub const Action = enum { .@"list-colors" => list_colors.Options, .@"list-actions" => list_actions.Options, .@"ssh-cache" => ssh_cache.Options, + .ssh => ssh.Options, .@"edit-config" => edit_config.Options, .@"show-config" => show_config.Options, .@"explain-config" => explain_config.Options, @@ -192,6 +203,7 @@ pub const Action = enum { .@"show-face" => show_face.Options, .boo => boo.Options, .@"new-window" => new_window.Options, + .@"toggle-quick-terminal" => toggle_quick_terminal.Options, }; } } diff --git a/vendor/libghostty-vt/src/cli/ssh-cache/DiskCache.zig b/vendor/libghostty-vt/src/cli/ssh-cache/DiskCache.zig index 6fa74b43..bb11e74f 100644 --- a/vendor/libghostty-vt/src/cli/ssh-cache/DiskCache.zig +++ b/vendor/libghostty-vt/src/cli/ssh-cache/DiskCache.zig @@ -17,14 +17,6 @@ const MAX_CACHE_SIZE = 512 * 1024; /// Path to a file where the cache is stored. path: []const u8, -pub const DefaultPathError = Allocator.Error || error{ - /// The general error that is returned for any filesystem error - /// that may have resulted in the XDG lookup failing. - XdgLookupFailed, -}; - -pub const Error = error{ CacheIsLocked, HostnameIsInvalid }; - /// Returns the default path for the cache for a given program. /// /// On all platforms, this is `${XDG_STATE_HOME}/ghostty/ssh_cache`. @@ -33,7 +25,7 @@ pub const Error = error{ CacheIsLocked, HostnameIsInvalid }; pub fn defaultPath( alloc: Allocator, program: []const u8, -) DefaultPathError![]const u8 { +) ![]const u8 { const state_dir: []const u8 = xdg.state( alloc, .{ .subdir = program }, @@ -55,27 +47,15 @@ pub fn clear(self: DiskCache) !void { }; } -pub const AddResult = enum { added, updated }; - -pub const AddError = std.fs.Dir.MakeError || - std.fs.Dir.StatFileError || - std.fs.File.OpenError || - std.fs.File.ChmodError || - std.io.Reader.LimitedAllocError || - FixupPermissionsError || - ReadEntriesError || - WriteCacheFileError || - Error; - -/// Add or update a hostname entry in the cache. -/// Returns AddResult.added for new entries or AddResult.updated for existing ones. +/// Add or update an entry in the cache, recording `timestamp` (Unix seconds). /// The cache file is created if it doesn't exist with secure permissions (0600). pub fn add( self: DiskCache, alloc: Allocator, - hostname: []const u8, -) AddError!AddResult { - if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid; + key: []const u8, + timestamp: i64, +) !void { + if (!isValidCacheKey(key)) return error.InvalidCacheKey; // Create cache directory if needed if (std.fs.path.dirname(self.path)) |dir| { @@ -107,58 +87,49 @@ pub fn add( // Lock // Causes a compile failure in the Zig std library on Windows, see: // https://github.com/ziglang/zig/issues/18430 - if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheIsLocked; + if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheLocked; defer if (comptime builtin.os.tag != .windows) file.unlock(); var entries = try readEntries(alloc, file); defer deinitEntries(alloc, &entries); - // Add or update entry - const gop = try entries.getOrPut(hostname); - const result: AddResult = if (!gop.found_existing) add: { - const hostname_copy = try alloc.dupe(u8, hostname); - errdefer alloc.free(hostname_copy); + // Update the timestamp of an existing entry, or insert a new one. For a + // new entry, dupe both strings up front so a failed allocation never + // leaves a half-built slot (borrowed key, undefined value) for the + // `deinitEntries` defer to walk. + if (entries.getPtr(key)) |existing| { + existing.timestamp = timestamp; + } else { + const key_copy = try alloc.dupe(u8, key); + errdefer alloc.free(key_copy); const terminfo_copy = try alloc.dupe(u8, "xterm-ghostty"); errdefer alloc.free(terminfo_copy); - gop.key_ptr.* = hostname_copy; - gop.value_ptr.* = .{ - .hostname = gop.key_ptr.*, - .timestamp = std.time.timestamp(), + try entries.put(key_copy, .{ + .hostname = key_copy, + .timestamp = timestamp, .terminfo_version = terminfo_copy, - }; - break :add .added; - } else update: { - // Update timestamp for existing entry - gop.value_ptr.timestamp = std.time.timestamp(); - break :update .updated; - }; + }); + } - try self.writeCacheFile(entries, null); - return result; + try self.writeCacheFile(entries); } -pub const RemoveError = std.fs.File.OpenError || - FixupPermissionsError || - ReadEntriesError || - WriteCacheFileError || - Error; - -/// Remove a hostname entry from the cache. -/// No error is returned if the hostname doesn't exist or the cache file is missing. +/// Remove an entry from the cache. Returns true if an entry was removed, +/// false if the key wasn't present (or the cache file is missing). pub fn remove( self: DiskCache, alloc: Allocator, - hostname: []const u8, -) RemoveError!void { - if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid; + key: []const u8, +) !bool { + if (!isValidCacheKey(key)) return error.InvalidCacheKey; // Open our file const file = std.fs.openFileAbsolute( self.path, .{ .mode = .read_write }, ) catch |err| switch (err) { - error.FileNotFound => return, + error.FileNotFound => return false, else => return err, }; defer file.close(); @@ -167,7 +138,7 @@ pub fn remove( // Lock // Causes a compile failure in the Zig std library on Windows, see: // https://github.com/ziglang/zig/issues/18430 - if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheIsLocked; + if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheLocked; defer if (comptime builtin.os.tag != .windows) file.unlock(); // Read existing entries @@ -175,27 +146,73 @@ pub fn remove( defer deinitEntries(alloc, &entries); // Remove the entry if it exists and ensure we free the memory - if (entries.fetchRemove(hostname)) |kv| { + const removed = if (entries.fetchRemove(key)) |kv| removed: { assert(kv.key.ptr == kv.value.hostname.ptr); alloc.free(kv.value.hostname); alloc.free(kv.value.terminfo_version); - } + break :removed true; + } else false; - try self.writeCacheFile(entries, null); + try self.writeCacheFile(entries); + return removed; } -pub const ContainsError = std.fs.File.OpenError || - ReadEntriesError || - error{HostnameIsInvalid}; +/// 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(); -/// Check if a hostname exists in the cache. + // Read existing entries + var entries = try readEntries(alloc, file); + defer deinitEntries(alloc, &entries); + + // Drop expired entries from the map, then persist what remains. + const now = std.time.timestamp(); + var expired: std.ArrayList([]const u8) = .empty; + defer expired.deinit(alloc); + var iter = entries.iterator(); + while (iter.next()) |kv| { + const age_s = now -| kv.value_ptr.timestamp; + if (age_s > max_age_s) try expired.append(alloc, kv.key_ptr.*); + } + for (expired.items) |key| { + const kv = entries.fetchRemove(key).?; + assert(kv.key.ptr == kv.value.hostname.ptr); + alloc.free(kv.value.hostname); + alloc.free(kv.value.terminfo_version); + } + + try self.writeCacheFile(entries); + return expired.items.len; +} + +/// Check if a key exists in the cache. /// Returns false if the cache file doesn't exist. pub fn contains( self: DiskCache, alloc: Allocator, - hostname: []const u8, -) ContainsError!bool { - if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid; + key: []const u8, +) !bool { + if (!isValidCacheKey(key)) return error.InvalidCacheKey; // Open our file const file = std.fs.openFileAbsolute( @@ -211,12 +228,10 @@ pub fn contains( var entries = try readEntries(alloc, file); defer deinitEntries(alloc, &entries); - return entries.contains(hostname); + return entries.contains(key); } -pub const FixupPermissionsError = (std.fs.File.StatError || std.fs.File.ChmodError); - -fn fixupPermissions(file: std.fs.File) FixupPermissionsError!void { +fn fixupPermissions(file: std.fs.File) !void { // Windows does not support chmod if (comptime builtin.os.tag == .windows) return; @@ -228,18 +243,10 @@ fn fixupPermissions(file: std.fs.File) FixupPermissionsError!void { } } -pub const WriteCacheFileError = std.fs.Dir.OpenError || - std.fs.AtomicFile.InitError || - std.fs.AtomicFile.FlushError || - std.fs.AtomicFile.FinishError || - Entry.FormatError || - error{InvalidCachePath}; - fn writeCacheFile( self: DiskCache, entries: std.StringHashMap(Entry), - expire_days: ?u32, -) WriteCacheFileError!void { +) !void { const cache_dir = std.fs.path.dirname(self.path) orelse return error.InvalidCachePath; const cache_basename = std.fs.path.basename(self.path); @@ -255,8 +262,6 @@ fn writeCacheFile( var iter = entries.iterator(); while (iter.next()) |kv| { - // Only write non-expired entries - if (kv.value_ptr.isExpired(expire_days)) continue; try kv.value_ptr.format(&atomic_file.file_writer.interface); } @@ -299,12 +304,10 @@ pub fn deinitEntries( entries.deinit(); } -pub const ReadEntriesError = std.mem.Allocator.Error || std.io.Reader.LimitedAllocError; - fn readEntries( alloc: Allocator, file: std.fs.File, -) ReadEntriesError!std.StringHashMap(Entry) { +) !std.StringHashMap(Entry) { var reader = file.reader(&.{}); const content = try reader.interface.allocRemaining( alloc, @@ -313,26 +316,35 @@ fn readEntries( defer alloc.free(content); var entries = std.StringHashMap(Entry).init(alloc); + errdefer deinitEntries(alloc, &entries); + 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); + // Dupe both strings up front, before inserting, so the map never + // holds a half-built entry (a borrowed key or a freed/undefined + // value) for `deinitEntries` to walk if an allocation fails. + var hostname: ?[]u8 = try alloc.dupe(u8, entry.hostname); + errdefer if (hostname) |h| alloc.free(h); + var terminfo: ?[]u8 = try alloc.dupe(u8, entry.terminfo_version); + errdefer if (terminfo) |t| alloc.free(t); - const gop = try entries.getOrPut(hostname); + const gop = try entries.getOrPut(hostname.?); if (!gop.found_existing) { - const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version); + // New entry: transfer both copies to the map. gop.value_ptr.* = .{ - .hostname = hostname, + .hostname = hostname.?, .timestamp = entry.timestamp, - .terminfo_version = terminfo_copy, + .terminfo_version = terminfo.?, }; + hostname = null; + terminfo = null; } else { - // Don't need the copy since entry already exists - alloc.free(hostname); + // Duplicate key: the map keeps its existing key, so free ours. + alloc.free(hostname.?); + hostname = null; // Handle duplicate entries - keep newer timestamp if (entry.timestamp > gop.value_ptr.timestamp) { @@ -340,13 +352,15 @@ fn readEntries( if (!std.mem.eql( u8, gop.value_ptr.terminfo_version, - entry.terminfo_version, + terminfo.?, )) { alloc.free(gop.value_ptr.terminfo_version); - const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version); - gop.value_ptr.terminfo_version = terminfo_copy; + gop.value_ptr.terminfo_version = terminfo.?; + terminfo = null; } } + if (terminfo) |t| alloc.free(t); + terminfo = null; } } @@ -354,7 +368,7 @@ fn readEntries( } // Supports both standalone hostnames and user@hostname format -fn isValidCacheKey(key: []const u8) bool { +pub fn isValidCacheKey(key: []const u8) bool { if (key.len == 0) return false; // Check for user@hostname format @@ -452,33 +466,23 @@ test "disk cache operations" { const path = try tmp.dir.realpathAlloc(alloc, "cache"); defer alloc.free(path); - // Setup our cache + // Setup our cache. Adding the same key twice exercises both the new + // and existing-entry paths. const cache: DiskCache = .{ .path = path }; - try testing.expectEqual( - AddResult.added, - try cache.add(alloc, "example.com"), - ); - try testing.expectEqual( - AddResult.updated, - try cache.add(alloc, "example.com"), - ); - try testing.expect( - try cache.contains(alloc, "example.com"), - ); + try cache.add(alloc, "example.com", std.time.timestamp()); + try cache.add(alloc, "example.com", std.time.timestamp()); + try testing.expect(try cache.contains(alloc, "example.com")); // List var entries = try cache.list(alloc); deinitEntries(alloc, &entries); - // Remove - try cache.remove(alloc, "example.com"); - try testing.expect( - !(try cache.contains(alloc, "example.com")), - ); - try testing.expectEqual( - AddResult.added, - try cache.add(alloc, "example.com"), - ); + // Remove reports that it removed the entry, and a second remove of the + // same key reports nothing to remove. + try testing.expect(try cache.remove(alloc, "example.com")); + try testing.expect(!try cache.remove(alloc, "example.com")); + try testing.expect(!(try cache.contains(alloc, "example.com"))); + try cache.add(alloc, "example.com", std.time.timestamp()); } test "disk cache cleans up temp files" { @@ -494,8 +498,8 @@ test "disk cache cleans up temp files" { defer alloc.free(cache_path); const cache: DiskCache = .{ .path = cache_path }; - try testing.expectEqual(AddResult.added, try cache.add(alloc, "example.com")); - try testing.expectEqual(AddResult.added, try cache.add(alloc, "example.org")); + try cache.add(alloc, "example.com", std.time.timestamp()); + try cache.add(alloc, "example.org", std.time.timestamp()); // Verify only the cache file exists and no temp files left behind var count: usize = 0; @@ -507,6 +511,170 @@ test "disk cache cleans up temp files" { try testing.expectEqual(1, count); } +test "disk cache prune" { + const testing = std.testing; + const alloc = testing.allocator; + + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + const tmp_path = try tmp.dir.realpathAlloc(alloc, "."); + defer alloc.free(tmp_path); + const cache_path = try std.fs.path.join(alloc, &.{ tmp_path, "cache" }); + defer alloc.free(cache_path); + + const cache: DiskCache = .{ .path = cache_path }; + + // Back-date one entry an hour old and one 100 days old. + const day = std.time.s_per_day; + const hour = std.time.s_per_hour; + const now = std.time.timestamp(); + try cache.add(alloc, "recent.com", now - hour); + try cache.add(alloc, "old.com", now - 100 * day); + + // Prune entries older than 90 days: only old.com goes. + try testing.expectEqual(@as(usize, 1), try cache.prune(alloc, 90 * day)); + try testing.expect(try cache.contains(alloc, "recent.com")); + try testing.expect(!try cache.contains(alloc, "old.com")); + + // Pruning again removes nothing. + try testing.expectEqual(@as(usize, 0), try cache.prune(alloc, 90 * day)); + + // Sub-day granularity: a 30-minute max age prunes the hour-old entry. + try testing.expectEqual(@as(usize, 1), try cache.prune(alloc, 30 * std.time.s_per_min)); + try testing.expect(!try cache.contains(alloc, "recent.com")); +} + +test "disk cache prune missing file" { + const testing = std.testing; + const alloc = testing.allocator; + + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + const tmp_path = try tmp.dir.realpathAlloc(alloc, "."); + defer alloc.free(tmp_path); + const cache_path = try std.fs.path.join(alloc, &.{ tmp_path, "cache" }); + defer alloc.free(cache_path); + + const cache: DiskCache = .{ .path = cache_path }; + try testing.expectEqual(@as(usize, 0), try cache.prune(alloc, 30)); +} + +test "disk cache reads duplicate keys" { + const testing = std.testing; + const alloc = testing.allocator; + + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + // Exercise readEntries' found_existing branch: replace the existing + // key with the updated entry and ensure (via testing.allocator) that + // we don't double-free or leak. + { + var file = try tmp.dir.createFile("cache", .{}); + defer file.close(); + var buf: [256]u8 = undefined; + var file_writer = file.writer(&buf); + try file_writer.interface.writeAll( + "example.com|100|xterm-ghostty\nexample.com|200|xterm-newer\n", + ); + try file_writer.interface.flush(); + } + const path = try tmp.dir.realpathAlloc(alloc, "cache"); + defer alloc.free(path); + + const cache: DiskCache = .{ .path = path }; + var entries = try cache.list(alloc); + defer deinitEntries(alloc, &entries); + + try testing.expectEqual(@as(u32, 1), entries.count()); + const entry = entries.get("example.com").?; + try testing.expectEqual(@as(i64, 200), entry.timestamp); + try testing.expectEqualStrings("xterm-newer", entry.terminfo_version); +} + +test "disk cache reads survive allocation failure" { + const testing = std.testing; + + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + // Exercise a populated cache containing a duplicate key to ensure + // that we hit all of the possible allocation behaviors below. + { + var file = try tmp.dir.createFile("cache", .{}); + defer file.close(); + var buf: [256]u8 = undefined; + var file_writer = file.writer(&buf); + try file_writer.interface.writeAll( + "a.com|100|xterm-ghostty\n" ++ + "b.com|100|xterm-ghostty\n" ++ + "c.com|100|xterm-ghostty\n" ++ + "a.com|200|xterm-newer\n", + ); + try file_writer.interface.flush(); + } + const path = try tmp.dir.realpathAlloc(testing.allocator, "cache"); + defer testing.allocator.free(path); + + const cache: DiskCache = .{ .path = path }; + + // Fail the Nth allocation for every N until the read completes. The + // FailingAllocator is backed by testing.allocator so we also ensure + // that we don't double-free or leak; this can only completely succeed + // or fail with OutOfMemory. + var fail_index: usize = 0; + while (true) : (fail_index += 1) { + var failing = std.testing.FailingAllocator.init( + testing.allocator, + .{ .fail_index = fail_index }, + ); + const alloc = failing.allocator(); + + if (cache.list(alloc)) |entries_const| { + var entries = entries_const; + deinitEntries(alloc, &entries); + // Reached a run with no induced failure: every path covered. + if (!failing.has_induced_failure) break; + } else |err| { + try testing.expectEqual(error.OutOfMemory, err); + } + } +} + +test "disk cache add survives allocation failure" { + const testing = std.testing; + + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + const tmp_path = try tmp.dir.realpathAlloc(testing.allocator, "."); + defer testing.allocator.free(tmp_path); + const path = try std.fs.path.join(testing.allocator, &.{ tmp_path, "cache" }); + defer testing.allocator.free(path); + + const cache: DiskCache = .{ .path = path }; + + // Fail the Nth allocation for every N until add completes. A failed add + // must not leak or leave a half-built map entry. The FailingAllocator + // is backed by testing.allocator to catch either. Each iteration starts + // from a clean cache file. + var fail_index: usize = 0; + while (true) : (fail_index += 1) { + std.fs.cwd().deleteFile(path) catch {}; + var failing = std.testing.FailingAllocator.init( + testing.allocator, + .{ .fail_index = fail_index }, + ); + const alloc = failing.allocator(); + + if (cache.add(alloc, "user@example.com", 100)) |_| { + if (!failing.has_induced_failure) break; + } else |err| { + try testing.expectEqual(error.OutOfMemory, err); + } + } +} + test isValidHost { const testing = std.testing; diff --git a/vendor/libghostty-vt/src/cli/ssh-cache/Entry.zig b/vendor/libghostty-vt/src/cli/ssh-cache/Entry.zig index b586161f..158694f9 100644 --- a/vendor/libghostty-vt/src/cli/ssh-cache/Entry.zig +++ b/vendor/libghostty-vt/src/cli/ssh-cache/Entry.zig @@ -42,61 +42,6 @@ pub fn format(self: Entry, writer: *std.Io.Writer) FormatError!void { ); } -pub fn isExpired(self: Entry, expire_days_: ?u32) bool { - const expire_days = expire_days_ orelse return false; - const now = std.time.timestamp(); - const age_days = @divTrunc(now -| self.timestamp, std.time.s_per_day); - return age_days > expire_days; -} - -test "cache entry expiration" { - const testing = std.testing; - const now = std.time.timestamp(); - - const fresh_entry: Entry = .{ - .hostname = "test.com", - .timestamp = now - std.time.s_per_day, // 1 day old - .terminfo_version = "xterm-ghostty", - }; - try testing.expect(!fresh_entry.isExpired(90)); - - const old_entry: Entry = .{ - .hostname = "old.com", - .timestamp = now - (std.time.s_per_day * 100), // 100 days old - .terminfo_version = "xterm-ghostty", - }; - try testing.expect(old_entry.isExpired(90)); - - // Test never-expire case - try testing.expect(!old_entry.isExpired(null)); -} - -test "cache entry expiration exact boundary" { - const testing = std.testing; - const now = std.time.timestamp(); - - // Exactly at expiration boundary - const boundary_entry: Entry = .{ - .hostname = "example.com", - .timestamp = now - (std.time.s_per_day * 30), - .terminfo_version = "xterm-ghostty", - }; - try testing.expect(!boundary_entry.isExpired(30)); - try testing.expect(boundary_entry.isExpired(29)); -} - -test "cache entry expiration large timestamp" { - const testing = std.testing; - const now = std.time.timestamp(); - - const boundary_entry: Entry = .{ - .hostname = "example.com", - .timestamp = now + (std.time.s_per_day * 30), - .terminfo_version = "xterm-ghostty", - }; - try testing.expect(!boundary_entry.isExpired(30)); -} - test "cache entry parsing valid formats" { const testing = std.testing; diff --git a/vendor/libghostty-vt/src/cli/ssh.zig b/vendor/libghostty-vt/src/cli/ssh.zig new file mode 100644 index 00000000..76bfb10e --- /dev/null +++ b/vendor/libghostty-vt/src/cli/ssh.zig @@ -0,0 +1,635 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const cli_args = @import("args.zig"); +const diagnostics = @import("diagnostics.zig"); +const Action = @import("ghostty.zig").Action; +const DiskCache = @import("ssh_cache.zig").DiskCache; +const internal_os = @import("../os/main.zig"); +const ghostty_terminfo = @import("../terminfo/main.zig").ghostty; + +const log = std.log.scoped(.ssh); + +const usage = + \\Usage: ghostty +ssh [flags] [--] <ssh args...> + \\ + \\Flags: + \\ --forward-env[=bool] Enable TERM / SendEnv forwarding. Default: true. + \\ --terminfo[=bool] Install Ghostty terminfo on first connect. Default: true. + \\ --cache[=bool] Use the terminfo install cache. Default: true. + \\ --ssh=<path> Path to the ssh binary. Default: first `ssh` on PATH. + \\ --verbose Print +ssh status lines to stderr. + \\ --help Show full help. + \\ + \\ssh flags and the destination go after +ssh's own flags (or after `--`). + \\ +; + +pub const Options = struct { + /// Set by the CLI parser for deinit. + _arena: ?ArenaAllocator = null, + + /// Maps to the `ssh-env` shell integration feature. + @"forward-env": bool = true, + + /// Maps to the `ssh-terminfo` shell integration feature. + terminfo: bool = true, + + /// When false, both cache read and write are bypassed. + cache: bool = true, + + /// The wrapped `ssh` binary. + /// `/`-containing values are treated as paths; otherwise resolved via PATH. + ssh: []const u8 = "ssh", + + /// When true, print verbose output to stderr. + verbose: bool = false, + + /// Arguments passed through to `ssh` verbatim. Populated by + /// `parseManuallyHook` when we reach the first non-flag argument (or + /// an explicit `--`). + _ssh_args: std.ArrayList([]const u8) = .empty, + + /// Enables arg parsing diagnostics so unknown flags become + /// diagnostics rather than fatal errors. + _diagnostics: diagnostics.DiagnosticList = .{}, + + pub fn deinit(self: *Options) void { + if (self._arena) |arena| arena.deinit(); + self.* = undefined; + } + + /// Enables `-h` and `--help` to work. + pub fn help(_: Options) !void { + return Action.help_error; + } + + /// Manual parse hook. For each argument: + /// - If it's a literal `--`, consume everything after it as ssh + /// args and stop parsing. + /// - If it doesn't start with `--`, this is the start of the ssh + /// argv. Consume this arg and everything after as ssh args and + /// stop parsing. + /// - Otherwise (a `--foo` arg), return true so the generic parser + /// handles it as one of our own flags. + pub fn parseManuallyHook( + self: *Options, + alloc: Allocator, + arg: []const u8, + iter: anytype, + ) Allocator.Error!bool { + if (std.mem.eql(u8, arg, "--")) { + while (iter.next()) |rest| { + try self._ssh_args.append(alloc, try alloc.dupe(u8, rest)); + } + return false; + } + + if (!std.mem.startsWith(u8, arg, "--")) { + try self._ssh_args.append(alloc, try alloc.dupe(u8, arg)); + while (iter.next()) |rest| { + try self._ssh_args.append(alloc, try alloc.dupe(u8, rest)); + } + return false; + } + + return true; + } +}; + +/// Wrap `ssh` to automatically configure Ghostty terminal integration on +/// remote hosts. +/// +/// Any arguments that aren't recognized as `+ssh` flags are passed to +/// the real `ssh` binary unchanged. You can use `--` as an explicit +/// disambiguator if needed, though it's almost never required: `ssh` +/// has no long flags, and `+ssh` defines no short flags, so there's +/// nothing to collide. +/// +/// This is typically called via Ghostty's shell integration. When +/// `shell-integration-features` includes `ssh-env` or `ssh-terminfo`, +/// each shell defines an `ssh` function that runs: +/// +/// ghostty +ssh <flags> -- "$@" +/// +/// You can also run `ghostty +ssh` directly, or alias it yourself (e.g. +/// `alias ssh='ghostty +ssh --'`) if you prefer not to use the shell +/// integration. +/// +/// `+ssh` performs up to two pieces of setup before launching `ssh`: +/// +/// 1. **Environment forwarding** (`--forward-env`). Sets `TERM` to +/// `xterm-256color` and requests `SendEnv` forwarding of +/// `COLORTERM`, `TERM_PROGRAM`, and `TERM_PROGRAM_VERSION` so the +/// remote shell can still detect that it's running inside Ghostty. +/// The remote `sshd_config` must list these in `AcceptEnv` for +/// forwarding to succeed. +/// +/// 2. **Terminfo install** (`--terminfo`). On the first connection to a +/// given destination, installs Ghostty's terminfo entry on the remote +/// host using `infocmp -x xterm-ghostty | ssh tic -x -` over a +/// shared `ControlMaster` connection. Successful installs are cached +/// (see `ghostty +ssh-cache`) so subsequent connections skip this +/// step. When terminfo is successfully installed or already cached, +/// `TERM` is set to `xterm-ghostty` instead of `xterm-256color`. +/// +/// If `--terminfo` install fails (e.g. `tic` not available on the +/// remote, filesystem permissions), a warning is logged and the +/// connection continues with `TERM=xterm-256color`. +/// +/// Flags: +/// +/// * `--forward-env=<bool>`: Enable `TERM` / `SendEnv` environment +/// forwarding. Default: `true`. +/// +/// * `--terminfo=<bool>`: Enable automatic terminfo install on first +/// connection. Default: `true`. +/// +/// * `--cache=<bool>`: Use the terminfo install cache. Default: `true`. +/// When `false`, both the cache read (skip-if-installed) and the +/// cache write (record-on-success) are bypassed, and every +/// connection performs the install. To one-shot reinstall a single +/// host while keeping the cache in use, prefer `ghostty +ssh-cache +/// --remove=<host>` followed by a normal connection. +/// +/// * `--ssh=<path>`: Path to the `ssh` binary to execute. Default: the +/// first `ssh` found on `PATH`. +/// +/// * `--verbose`: Print +ssh status lines to stderr, and surface +/// remote stderr during the terminfo install. +/// +/// Examples: +/// +/// # Basic invocation using defaults: +/// ghostty +ssh user@example.com +/// +/// # Forward Ghostty env vars but skip the terminfo install: +/// ghostty +ssh --terminfo=false user@example.com +/// +/// # `ssh` flags (short-form `-p`, etc.) pass through unchanged: +/// ghostty +ssh -p 2222 -i ~/.ssh/id_ed25519 user@example.com +/// +/// # Use `--` explicitly if your ssh args might collide with our flags: +/// ghostty +ssh -- --some-rare-ssh-arg user@example.com +/// +/// Pass `--verbose` to see what `+ssh` is doing. For cache inspection +/// and management, see `ghostty +ssh-cache`. +/// +/// Available since: 1.4.0 +pub fn run(alloc_gpa: Allocator) !u8 { + var opts: Options = .{}; + defer opts.deinit(); + + { + var iter = try cli_args.argsIterator(alloc_gpa); + defer iter.deinit(); + try cli_args.parse(Options, alloc_gpa, &opts, &iter); + } + + var stderr_buffer: [1024]u8 = undefined; + var stderr_file: std.fs.File = .stderr(); + var stderr_writer = stderr_file.writer(&stderr_buffer); + const stderr = &stderr_writer.interface; + + // Any diagnostic from the arg parser is an unknown flag or bad + // value. Reject loudly — silently forwarding `--typo` to ssh would + // produce confusing downstream errors. + if (!opts._diagnostics.empty()) { + for (opts._diagnostics.items()) |diag| { + if (diag.key.len > 0) { + stderr.print( + "Error: unknown flag `--{s}`.\n", + .{diag.key}, + ) catch {}; + } else { + stderr.print("Error: {s}\n", .{diag.message}) catch {}; + } + } + stderr.print("\n{s}", .{usage}) catch {}; + stderr.flush() catch {}; + return 2; + } + + const result = runInner(alloc_gpa, &opts, stderr); + + stderr.flush() catch {}; + return result; +} + +fn runInner( + gpa: Allocator, + opts: *const Options, + stderr: *std.Io.Writer, +) !u8 { + var arena = ArenaAllocator.init(gpa); + defer arena.deinit(); + const alloc = arena.allocator(); + + if (opts._ssh_args.items.len == 0) { + try stderr.print("Error: no ssh arguments provided.\n\n{s}", .{usage}); + return 2; + } + + const session: struct { + term: []const u8, + to_cache: ?struct { cache: DiskCache, dest: []const u8 } = null, + } = session: { + if (!opts.terminfo) break :session .{ .term = "xterm-256color" }; + + const dest = resolveDestination(alloc, opts.ssh, opts._ssh_args.items) orelse { + warnPrint(stderr, "could not resolve ssh destination; skipping terminfo install", .{}); + break :session .{ .term = "xterm-256color" }; + }; + + const cache: ?DiskCache = if (opts.cache) cache: { + const path = DiskCache.defaultPath(alloc, "ghostty") catch |err| { + warnPrint(stderr, "ghostty terminfo cache unavailable: {}", .{err}); + break :session .{ .term = "xterm-256color" }; + }; + break :cache .{ .path = path }; + } else null; + + if (cache) |c| { + if (c.contains(alloc, dest) catch false) { + verbosePrint(opts, stderr, "dest: {s} (cached, skipping install)", .{dest}); + break :session .{ .term = "xterm-ghostty" }; + } else { + verbosePrint(opts, stderr, "dest: {s} (not cached, will install)", .{dest}); + } + } else { + verbosePrint(opts, stderr, "dest: {s} (cache disabled, will install)", .{dest}); + } + + stderr.print("Setting up xterm-ghostty terminfo on {s}...\n", .{dest}) catch {}; + stderr.flush() catch {}; + + installRemoteTerminfo(alloc, opts, stderr) catch |err| { + warnPrint(stderr, "failed to install terminfo: {}", .{err}); + break :session .{ .term = "xterm-256color" }; + }; + break :session .{ + .term = "xterm-ghostty", + .to_cache = if (cache) |c| .{ .cache = c, .dest = dest } else null, + }; + }; + + // Build the full argv: [ssh, ...our opts, ...user args] + const env_opts: []const []const u8 = if (opts.@"forward-env") env_opts: { + const set_term = try std.fmt.allocPrint( + alloc, + "SetEnv=TERM={s}", + .{session.term}, + ); + break :env_opts &.{ + "-o", set_term, + "-o", "SendEnv=COLORTERM", + "-o", "SendEnv=TERM_PROGRAM", + "-o", "SendEnv=TERM_PROGRAM_VERSION", + }; + } else &.{}; + const argv = try std.mem.concat(alloc, []const u8, &.{ + &.{opts.ssh}, + env_opts, + opts._ssh_args.items, + }); + verbosePrint(opts, stderr, "exec: {f}", .{Joined{ .items = argv }}); + + const exit_code = childExec(alloc, argv) catch |err| { + try stderr.print("Error: failed to run {s}: {}\n", .{ argv[0], err }); + return 1; + }; + verbosePrint(opts, stderr, "exit: {d}", .{exit_code}); + + // Attempt to cache (if needed) on a successful ssh execution. + if (exit_code == 0) if (session.to_cache) |entry| { + if (entry.cache.add(alloc, entry.dest, std.time.timestamp())) |_| { + verbosePrint(opts, stderr, "cache: wrote {s}", .{entry.dest}); + } else |err| { + log.debug("cache add failed for '{s}': {}", .{ entry.dest, err }); + } + }; + + return exit_code; +} + +/// Log to `.ssh` and, if `--verbose`, also print to stderr. +fn verbosePrint( + opts: *const Options, + stderr: *std.Io.Writer, + comptime fmt: []const u8, + args: anytype, +) void { + log.debug(fmt, args); + if (!opts.verbose) return; + stderr.print("+ssh: " ++ fmt ++ "\n", args) catch return; + stderr.flush() catch return; +} + +/// Log a warning and also print a `Warning: <msg>` line to stderr. +fn warnPrint( + stderr: *std.Io.Writer, + comptime fmt: []const u8, + args: anytype, +) void { + log.warn(fmt, args); + stderr.print("Warning: " ++ fmt ++ "\n", args) catch return; + stderr.flush() catch return; +} + +/// Space-joined items, formattable as `{f}`. +const Joined = struct { + items: []const []const u8, + + pub fn format(self: Joined, writer: *std.Io.Writer) !void { + for (self.items, 0..) |a, i| { + if (i > 0) try writer.writeByte(' '); + try writer.writeAll(a); + } + } + + test { + const testing = std.testing; + var buf: [128]u8 = undefined; + { + var w: std.Io.Writer = .fixed(&buf); + try w.print("{f}", .{Joined{ .items = &.{} }}); + try testing.expectEqualStrings("", buf[0..w.end]); + } + { + var w: std.Io.Writer = .fixed(&buf); + try w.print("{f}", .{Joined{ .items = &.{"only"} }}); + try testing.expectEqualStrings("only", buf[0..w.end]); + } + { + var w: std.Io.Writer = .fixed(&buf); + try w.print("{f}", .{Joined{ .items = &.{ "a", "b", "c" } }}); + try testing.expectEqualStrings("a b c", buf[0..w.end]); + } + } +}; + +fn checkExit(term: std.process.Child.Term, label: []const u8) error{ChildFailed}!void { + switch (term) { + .Exited => |rc| if (rc != 0) { + log.warn("{s} exited with non-zero status: {d}", .{ label, rc }); + return error.ChildFailed; + }, + else => { + log.warn("{s} terminated abnormally: {}", .{ label, term }); + return error.ChildFailed; + }, + } +} + +/// Run `ssh -G <args>` and parse the output for `user` and `hostname`. +/// Returns the resolved `user@hostname`, or null if the destination +/// could not be resolved. +fn resolveDestination( + alloc: Allocator, + ssh: []const u8, + args: []const []const u8, +) ?[]const u8 { + const argv = std.mem.concat(alloc, []const u8, &.{ + &.{ ssh, "-G" }, + args, + }) catch return null; + const result = std.process.Child.run(.{ + .allocator = alloc, + .argv = argv, + }) catch |err| { + log.warn("ssh -G spawn failed: {}", .{err}); + return null; + }; + checkExit(result.term, "ssh -G") catch return null; + return parseDestination(alloc, result.stdout); +} + +/// Parse `ssh -G` output for `user` and `hostname` and return the +/// formatted `user@hostname`. Returns null if either key is missing +/// or formatting fails. +fn parseDestination(alloc: Allocator, stdout: []const u8) ?[]const u8 { + var user: []const u8 = ""; + var host: []const u8 = ""; + var it = std.mem.tokenizeScalar(u8, stdout, '\n'); + while (it.next()) |line| { + const space = std.mem.indexOfScalar(u8, line, ' ') orelse continue; + const key = line[0..space]; + const value = line[space + 1 ..]; + if (std.mem.eql(u8, key, "user")) { + user = value; + } else if (std.mem.eql(u8, key, "hostname")) { + host = value; + } + if (user.len > 0 and host.len > 0) break; + } + + if (user.len == 0) { + log.warn("ssh -G output missing user", .{}); + return null; + } + if (host.len == 0) { + log.warn("ssh -G output missing hostname", .{}); + return null; + } + + return std.fmt.allocPrint(alloc, "{s}@{s}", .{ user, host }) catch null; +} + +/// Install Ghostty's terminfo on the remote host over a short-lived SSH +/// ControlMaster connection. The master tears down with the client +/// (`ControlPersist=no`) so no socket lingers. +fn installRemoteTerminfo( + alloc: Allocator, + opts: *const Options, + stderr: *std.Io.Writer, +) !void { + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + try ghostty_terminfo.encode(&buf.writer); + const terminfo = buf.written(); + + // ControlPath is in TMPDIR with a short, random basename. ssh uses + // ControlPath as the bind address for a Unix domain socket; macOS + // limits sockaddr_un.sun_path to ~104 bytes, so keeping the path + // short leaves margin. + const control_path = try internal_os.randomTmpPath(alloc, "ghostty-ssh-"); + const control_path_opt = try std.fmt.allocPrint( + alloc, + "ControlPath={s}", + .{control_path}, + ); + + // Under --verbose, let remote stderr through (the `tic` step is + // the most common failure source) and inherit ssh's stderr so it + // reaches the user's terminal. Other steps stay quiet either way. + const remote_script = if (opts.verbose) + \\infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 + \\command -v tic >/dev/null 2>&1 || exit 1 + \\mkdir -p ~/.terminfo 2>/dev/null && tic -x - && exit 0 + \\exit 1 + else + \\infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 + \\command -v tic >/dev/null 2>&1 || exit 1 + \\mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 + \\exit 1 + ; + + // Set up an SSH ControlMaster scoped to this single install: + // - ControlMaster=yes makes our client also act as the master, + // so `infocmp | ssh tic` runs over a single connection. + // - ControlPersist=no tears the master down when our client + // exits; no socket lingers on the remote side. + const argv = try std.mem.concat(alloc, []const u8, &.{ + &.{opts.ssh}, + &.{ + "-o", "ControlMaster=yes", + "-o", "ControlPersist=no", + "-o", control_path_opt, + }, + opts._ssh_args.items, + &.{remote_script}, + }); + verbosePrint(opts, stderr, "exec: {f}", .{Joined{ .items = argv }}); + + var child: std.process.Child = .init(argv, alloc); + child.stdin_behavior = .Pipe; + child.stdout_behavior = .Ignore; + child.stderr_behavior = if (opts.verbose) .Inherit else .Ignore; + + child.spawn() catch |err| { + log.warn("terminfo install spawn failed: {}", .{err}); + return error.InstallFailed; + }; + + if (child.stdin) |stdin| { + stdin.writeAll(terminfo) catch {}; + stdin.close(); + child.stdin = null; + } + + const term = child.wait() catch |err| { + log.warn("terminfo install wait failed: {}", .{err}); + return error.InstallFailed; + }; + checkExit(term, "terminfo install") catch return error.InstallFailed; +} + +/// Returns `128 + signum` for signal-killed children, matching shell convention. +fn childExec(alloc: Allocator, argv: []const []const u8) !u8 { + var child: std.process.Child = .init(argv, alloc); + child.stdin_behavior = .Inherit; + child.stdout_behavior = .Inherit; + child.stderr_behavior = .Inherit; + + try child.spawn(); + const term = try child.wait(); + return switch (term) { + .Exited => |rc| rc, + .Signal => |sig| @as(u8, 128) + @as(u8, @intCast(@min(sig, 127))), + .Stopped, .Unknown => 1, + }; +} + +fn parseTestArgs(alloc: Allocator, opts: *Options, line: []const u8) !void { + var iter = try std.process.ArgIteratorGeneral(.{}).init(alloc, line); + defer iter.deinit(); + try cli_args.parse(Options, alloc, opts, &iter); +} + +test "parseManuallyHook: bare destination starts ssh args" { + const testing = std.testing; + var opts: Options = .{}; + defer opts.deinit(); + try parseTestArgs(testing.allocator, &opts, "--terminfo=false user@example.com"); + try testing.expectEqual(false, opts.terminfo); + try testing.expectEqual(true, opts.@"forward-env"); + try testing.expectEqual(@as(usize, 1), opts._ssh_args.items.len); + try testing.expectEqualStrings("user@example.com", opts._ssh_args.items[0]); +} + +test "parseManuallyHook: short ssh flags pass through verbatim" { + const testing = std.testing; + var opts: Options = .{}; + defer opts.deinit(); + try parseTestArgs(testing.allocator, &opts, "-p 2222 user@example.com"); + try testing.expectEqual(@as(usize, 3), opts._ssh_args.items.len); + try testing.expectEqualStrings("-p", opts._ssh_args.items[0]); + try testing.expectEqualStrings("2222", opts._ssh_args.items[1]); + try testing.expectEqualStrings("user@example.com", opts._ssh_args.items[2]); +} + +test "parseManuallyHook: explicit -- separator" { + const testing = std.testing; + var opts: Options = .{}; + defer opts.deinit(); + try parseTestArgs( + testing.allocator, + &opts, + "--verbose -- --some-rare-ssh-arg user@example.com", + ); + try testing.expectEqual(true, opts.verbose); + try testing.expectEqual(@as(usize, 2), opts._ssh_args.items.len); + try testing.expectEqualStrings("--some-rare-ssh-arg", opts._ssh_args.items[0]); + try testing.expectEqualStrings("user@example.com", opts._ssh_args.items[1]); +} + +test "parseDestination: typical ssh -G output" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const stdout = + \\user alice + \\hostname example.com + \\port 22 + \\identityfile ~/.ssh/id_ed25519 + \\ + ; + const result = parseDestination(arena.allocator(), stdout); + try testing.expectEqualStrings("alice@example.com", result.?); +} + +test "parseDestination: hostname before user" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const stdout = + \\hostname example.com + \\port 22 + \\user alice + \\ + ; + const result = parseDestination(arena.allocator(), stdout); + try testing.expectEqualStrings("alice@example.com", result.?); +} + +test "parseDestination: missing hostname returns null" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const stdout = "user alice\nport 22\n"; + try testing.expectEqual(@as(?[]const u8, null), parseDestination(arena.allocator(), stdout)); +} + +test "parseDestination: missing user returns null" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const stdout = "hostname example.com\nport 22\n"; + try testing.expectEqual(@as(?[]const u8, null), parseDestination(arena.allocator(), stdout)); +} + +test "parseDestination: empty input returns null" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + try testing.expectEqual(@as(?[]const u8, null), parseDestination(arena.allocator(), "")); +} + +test "parseDestination: IPv6 hostname" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const stdout = "user alice\nhostname ::1\n"; + const result = parseDestination(arena.allocator(), stdout); + try testing.expectEqualStrings("alice@::1", result.?); +} diff --git a/vendor/libghostty-vt/src/cli/ssh_cache.zig b/vendor/libghostty-vt/src/cli/ssh_cache.zig index d3ee658a..83031e8e 100644 --- a/vendor/libghostty-vt/src/cli/ssh_cache.zig +++ b/vendor/libghostty-vt/src/cli/ssh_cache.zig @@ -3,6 +3,7 @@ const fs = std.fs; const Allocator = std.mem.Allocator; const args = @import("args.zig"); const Action = @import("ghostty.zig").Action; +const Duration = @import("../config.zig").Config.Duration; pub const Entry = @import("ssh-cache/Entry.zig"); pub const DiskCache = @import("ssh-cache/DiskCache.zig"); @@ -10,8 +11,7 @@ pub const Options = struct { clear: bool = false, add: ?[]const u8 = null, remove: ?[]const u8 = null, - host: ?[]const u8 = null, - @"expire-days": ?u32 = null, + prune: ?Duration = null, pub fn deinit(self: *Options) void { _ = self; @@ -25,27 +25,36 @@ pub const Options = struct { /// Manage the SSH terminfo cache for automatic remote host setup. /// -/// When SSH integration is enabled with `shell-integration-features = ssh-terminfo`, -/// Ghostty automatically installs its terminfo on remote hosts. This command -/// manages the cache of successful installations to avoid redundant uploads. +/// The `+ssh` action installs Ghostty's terminfo on remote hosts and records +/// each success in this cache so it doesn't re-upload on later connections. +/// (`+ssh` runs automatically from the shell integration when +/// `shell-integration-features` includes `ssh-terminfo`.) This command +/// inspects and maintains that cache. /// -/// The cache stores hostnames (or user@hostname combinations) along with timestamps. -/// Entries older than the expiration period are automatically removed during cache -/// operations. By default, entries never expire. +/// The cache stores destinations (a hostname or user@hostname) along with +/// timestamps. /// -/// Only one of `--clear`, `--add`, `--remove`, or `--host` can be specified. -/// If multiple are specified, one of the actions will be executed but -/// it isn't guaranteed which one. This is entirely unsafe so you should split -/// multiple actions into separate commands. +/// A positional destination queries the cache: `user@hostname` shows that +/// exact entry, while a bare `hostname` shows every cached entry for that +/// host regardless of user. With no destination and no action, the entire +/// cache is listed. A query that matches nothing exits 1. +/// +/// At most one action (`--clear`, `--add`, `--remove`, or `--prune`) may be +/// specified, and not together with a positional destination; combining them +/// is an error. +/// +/// `--prune` takes a duration with unit suffixes (`s`, `m`, `h`, `d`, `w`, +/// `y`) and removes every entry older than it, e.g. `--prune=30d`, +/// `--prune=6h`, `--prune=1y`. /// /// Examples: -/// ghostty +ssh-cache # List all cached hosts -/// ghostty +ssh-cache --host=example.com # Check if host is cached -/// ghostty +ssh-cache --add=example.com # Manually add host to cache -/// ghostty +ssh-cache --add=user@example.com # Add user@host combination -/// ghostty +ssh-cache --remove=example.com # Remove host from cache -/// ghostty +ssh-cache --clear # Clear entire cache -/// ghostty +ssh-cache --expire-days=30 # Set custom expiration period +/// ghostty +ssh-cache # List all cached destinations +/// ghostty +ssh-cache user@example.com # Show that destination +/// ghostty +ssh-cache example.com # Show all users on that host +/// ghostty +ssh-cache --add=user@example.com # Manually add a destination +/// ghostty +ssh-cache --remove=user@example.com # Remove a destination +/// ghostty +ssh-cache --prune=30d # Remove entries older than 30 days +/// ghostty +ssh-cache --clear # Clear entire cache pub fn run(alloc_gpa: Allocator) !u8 { var arena = std.heap.ArenaAllocator.init(alloc_gpa); defer arena.deinit(); @@ -54,12 +63,6 @@ pub fn run(alloc_gpa: Allocator) !u8 { var opts: Options = .{}; defer opts.deinit(); - { - var iter = try args.argsIterator(alloc_gpa); - defer iter.deinit(); - try args.parse(Options, alloc_gpa, &opts, &iter); - } - var stdout_buffer: [1024]u8 = undefined; var stdout_file: std.fs.File = .stdout(); var stdout_writer = stdout_file.writer(&stdout_buffer); @@ -70,7 +73,66 @@ pub fn run(alloc_gpa: Allocator) !u8 { var stderr_writer = stderr_file.writer(&stderr_buffer); const stderr = &stderr_writer.interface; - const result = runInner(alloc, opts, stdout, stderr); + // The cache is queried by a positional destination (`user@host` or a + // bare `host`). `args.parse` rejects non-`--` tokens, so we lift the + // positional out here and parse only the remaining flags. `--host=X` + // is accepted as a deprecated spelling of the positional (it was the + // original shipped flag name). + var query: ?[]const u8 = null; + var flags: std.ArrayList([]const u8) = .empty; + { + var iter = try args.argsIterator(alloc_gpa); + defer iter.deinit(); + while (iter.next()) |arg| { + const is_host_flag = std.mem.startsWith(u8, arg, "--host="); + if (is_host_flag) { + try stderr.print( + "Warning: --host is deprecated; pass the destination " ++ + "directly, e.g. `ghostty +ssh-cache {s}`.\n", + .{arg["--host=".len..]}, + ); + } + const dest: ?[]const u8 = if (is_host_flag) + arg["--host=".len..] + else if (!std.mem.startsWith(u8, arg, "-")) + arg + else + null; + + if (dest) |d| { + if (query != null) { + try stderr.print( + "Error: only one destination may be specified.\n", + .{}, + ); + stderr.flush() catch {}; + return 2; + } + query = try alloc.dupe(u8, d); + } else { + try flags.append(alloc, try alloc.dupe(u8, arg)); + } + } + } + + { + var iter = args.sliceIterator(flags.items); + args.parse(Options, alloc_gpa, &opts, &iter) catch |err| switch (err) { + error.InvalidField => { + try stderr.print("Error: unknown flag.\n", .{}); + stderr.flush() catch {}; + return 2; + }, + error.InvalidValue, error.ValueRequired => { + try stderr.print("Error: invalid flag value.\n", .{}); + stderr.flush() catch {}; + return 2; + }, + else => return err, + }; + } + + const result = runInner(alloc, opts, query, stdout, stderr); // Flushing *shouldn't* fail but... stdout.flush() catch {}; @@ -81,103 +143,126 @@ pub fn run(alloc_gpa: Allocator) !u8 { pub fn runInner( alloc: Allocator, opts: Options, + query: ?[]const u8, stdout: *std.Io.Writer, stderr: *std.Io.Writer, ) !u8 { + // At most one action may be specified, and a query (positional + // destination) is itself an action. + const action_count = + @as(usize, @intFromBool(opts.clear)) + + @intFromBool(opts.add != null) + + @intFromBool(opts.remove != null) + + @intFromBool(opts.prune != null) + + @intFromBool(query != null); + if (action_count > 1) { + try stderr.print( + "Error: only one of a destination, --clear, --add, --remove, " ++ + "or --prune may be specified.\n", + .{}, + ); + return 2; + } + // Setup our disk cache to the standard location const cache_path = try DiskCache.defaultPath(alloc, "ghostty"); const cache: DiskCache = .{ .path = cache_path }; if (opts.clear) { try cache.clear(); - try stdout.print("Cache cleared.\n", .{}); return 0; } - if (opts.add) |host| { - const result = cache.add(alloc, host) catch |err| switch (err) { - DiskCache.Error.HostnameIsInvalid => { - try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); - try stderr.print("Expected format: hostname or user@hostname\n", .{}); - return 1; - }, - DiskCache.Error.CacheIsLocked => { - try stderr.print("Error: Cache is busy, try again\n", .{}); - return 1; + if (opts.add) |dest| { + cache.add(alloc, dest, std.time.timestamp()) catch |err| switch (err) { + error.InvalidCacheKey => { + try stderr.print( + "Error: Invalid destination '{s}' (expected hostname or user@hostname)\n", + .{dest}, + ); + return 2; }, else => { try stderr.print( "Error: Unable to add '{s}' to cache. Error: {}\n", - .{ host, err }, + .{ dest, err }, ); return 1; }, }; - - switch (result) { - .added => try stdout.print("Added '{s}' to cache.\n", .{host}), - .updated => try stdout.print("Updated '{s}' cache entry.\n", .{host}), - } return 0; } - if (opts.remove) |host| { - cache.remove(alloc, host) catch |err| switch (err) { - DiskCache.Error.HostnameIsInvalid => { - try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); - try stderr.print("Expected format: hostname or user@hostname\n", .{}); - return 1; - }, - DiskCache.Error.CacheIsLocked => { - try stderr.print("Error: Cache is busy, try again\n", .{}); - return 1; + if (opts.remove) |dest| { + const removed = cache.remove(alloc, dest) catch |err| switch (err) { + error.InvalidCacheKey => { + try stderr.print( + "Error: Invalid destination '{s}' (expected hostname or user@hostname)\n", + .{dest}, + ); + return 2; }, else => { try stderr.print( "Error: Unable to remove '{s}' from cache. Error: {}\n", - .{ host, err }, + .{ dest, err }, ); return 1; }, }; - try stdout.print("Removed '{s}' from cache.\n", .{host}); + // Silence on success; a no-op removal is an error (exit 1). + if (!removed) { + try stderr.print("Error: '{s}' is not in the cache.\n", .{dest}); + return 1; + } return 0; } - if (opts.host) |host| { - const cached = cache.contains(alloc, host) catch |err| switch (err) { - error.HostnameIsInvalid => { - try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); - try stderr.print("Expected format: hostname or user@hostname\n", .{}); - return 1; - }, - else => { - try stderr.print( - "Error: Unable to check host '{s}' in cache. Error: {}\n", - .{ host, err }, - ); - return 1; - }, - }; - - if (cached) { - try stdout.print( - "'{s}' has Ghostty terminfo installed.\n", - .{host}, + if (opts.prune) |max_age| { + const max_age_s = max_age.duration / std.time.ns_per_s; + if (max_age_s == 0) { + try stderr.print( + "Error: --prune requires a duration of at least one second.\n", + .{}, ); - return 0; - } else { - try stdout.print( - "'{s}' does not have Ghostty terminfo installed.\n", - .{host}, - ); - return 1; + return 2; } + const pruned = cache.prune(alloc, max_age_s) catch |err| { + try stderr.print("Error: Unable to prune cache. Error: {}\n", .{err}); + return 1; + }; + try stdout.print("Pruned cache entries: {d}\n", .{pruned}); + return 0; } - // Default action: list all hosts var entries = try cache.list(alloc); defer DiskCache.deinitEntries(alloc, &entries); + + // A positional query filters the listing: an exact `user@host` match, + // or every entry on a bare `host`. + if (query) |q| { + if (!DiskCache.isValidCacheKey(q)) { + try stderr.print( + "Error: Invalid destination '{s}' (expected hostname or user@hostname)\n", + .{q}, + ); + return 2; + } + + var matches: std.StringHashMap(Entry) = .init(alloc); + defer matches.deinit(); + var iter = entries.iterator(); + while (iter.next()) |kv| { + const key = kv.key_ptr.*; + if (matchesQuery(key, q)) try matches.put(key, kv.value_ptr.*); + } + + if (matches.count() == 0) return 1; + try listEntries(alloc, &matches, stdout); + return 0; + } + + // List all destinations by default. try listEntries(alloc, &entries, stdout); return 0; } @@ -187,10 +272,7 @@ fn listEntries( entries: *const std.StringHashMap(Entry), writer: *std.Io.Writer, ) !void { - if (entries.count() == 0) { - try writer.print("No hosts in cache.\n", .{}); - return; - } + if (entries.count() == 0) return; // Sort entries by hostname for consistent output var items: std.ArrayList(Entry) = .empty; @@ -207,22 +289,200 @@ fn listEntries( } }.lessThan); - try writer.print("Cached hosts ({d}):\n", .{items.items.len}); - const now = std.time.timestamp(); + // Align the timestamp column by padding destinations to the widest. + var widest: usize = 0; + for (items.items) |entry| { + widest = @max(widest, entry.hostname.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 }); - } + try writer.print("{s}", .{entry.hostname}); + try writer.splatByteAll(' ', widest - entry.hostname.len + 2); + + var iso_buf: [20]u8 = undefined; + var age_buf: [32]u8 = undefined; + try writer.print("{s} ({s})\n", .{ + formatTimestamp(&iso_buf, entry.timestamp), + relativeAge(&age_buf, now, entry.timestamp), + }); + } +} + +/// Whether a cache `key` matches a positional `query`. A `user@host` query +/// (containing `@`) matches one exact key; a bare `host` query matches every +/// key on that host regardless of user, comparing against the key's host +/// component (everything after its first `@`, or the whole key if userless). +fn matchesQuery(key: []const u8, query: []const u8) bool { + if (std.mem.indexOfScalar(u8, query, '@') != null) { + return std.mem.eql(u8, key, query); } + + const at = std.mem.indexOfScalar(u8, key, '@'); + const host = if (at) |i| key[i + 1 ..] else key; + return std.mem.eql(u8, host, query); +} + +test matchesQuery { + const testing = std.testing; + + // Exact user@host: only the identical key. + try testing.expect(matchesQuery("user@example.com", "user@example.com")); + try testing.expect(!matchesQuery("root@example.com", "user@example.com")); + try testing.expect(!matchesQuery("example.com", "user@example.com")); + + // Bare host: every key on that host, plus a keyless entry for it. + try testing.expect(matchesQuery("user@example.com", "example.com")); + try testing.expect(matchesQuery("root@example.com", "example.com")); + try testing.expect(matchesQuery("example.com", "example.com")); + try testing.expect(!matchesQuery("user@other.com", "example.com")); +} + +/// Format a Unix timestamp as an ISO-8601 UTC string +/// (`YYYY-MM-DDTHH:MM:SSZ`) into `buf`, which must be at least 20 bytes. +/// Out-of-range input is clamped so this can't crash on a garbage cache line. +fn formatTimestamp(buf: []u8, timestamp: i64) []const u8 { + // Clamp to [epoch, last second of 9999-12-31Z]: `std.time.epoch` + // accumulates the year in a `u16` (panics beyond that), and the buffer + // only fits a 4-digit year. + const secs: u64 = @intCast(std.math.clamp(timestamp, 0, 253402300799)); + + const epoch = std.time.epoch; + const epoch_secs: epoch.EpochSeconds = .{ .secs = secs }; + const day = epoch_secs.getEpochDay(); + const year_day = day.calculateYearDay(); + const month_day = year_day.calculateMonthDay(); + const ds = epoch_secs.getDaySeconds(); + return std.fmt.bufPrint(buf, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}Z", .{ + year_day.year, + month_day.month.numeric(), + month_day.day_index + 1, + ds.getHoursIntoDay(), + ds.getMinutesIntoHour(), + ds.getSecondsIntoMinute(), + }) catch unreachable; +} + +test formatTimestamp { + const testing = std.testing; + var buf: [20]u8 = undefined; + + try testing.expectEqualStrings( + "2026-05-05T22:49:33Z", + formatTimestamp(&buf, 1778021373), + ); + + // Epoch. + try testing.expectEqualStrings( + "1970-01-01T00:00:00Z", + formatTimestamp(&buf, 0), + ); + + // Out-of-range inputs clamp instead of overflowing the [20]u8 / + // panicking inside std: negatives floor at the epoch, huge values cap + // at the last second of year 9999. + try testing.expectEqualStrings( + "1970-01-01T00:00:00Z", + formatTimestamp(&buf, -5), + ); + try testing.expectEqualStrings( + "9999-12-31T23:59:59Z", + formatTimestamp(&buf, std.math.maxInt(i64)), + ); +} + +/// Format the age of `timestamp` (relative to `now`, both Unix seconds) +/// as a coarse relative time into `buf`, e.g. "2w ago". Uses `Duration`'s +/// unit vocabulary but keeps only the single largest unit for scannability. +/// A non-positive age (timestamp at or after `now`) is "now". +fn relativeAge(buf: []u8, now: i64, timestamp: i64) []const u8 { + // Saturating so a garbage timestamp can't overflow; clamp at 0 so a + // future timestamp becomes a zero age rather than going negative. + const age: u64 = @intCast(@max(0, now -| timestamp)); + if (age == 0) return "now"; + + // Round down to the largest unit that fits, so Duration.format emits + // only that unit (e.g. 19d -> 2w, 90m -> 1h). + const units = [_]u64{ + 365 * std.time.s_per_day, // y + std.time.s_per_week, // w + std.time.s_per_day, // d + std.time.s_per_hour, // h + std.time.s_per_min, // m + 1, // s + }; + const unit = for (units) |u| { + if (age >= u) break u; + } else 1; + + // Cap the age so `age * ns_per_s` can't overflow u64 (a garbage, e.g. + // hugely negative, timestamp otherwise yields an age near i64-max). + const max_age = std.math.maxInt(u64) / std.time.ns_per_s; + const rounded = @min(age, max_age) / unit * unit; + const d: Duration = .{ .duration = rounded * std.time.ns_per_s }; + return std.fmt.bufPrint(buf, "{f} ago", .{d}) catch unreachable; +} + +test relativeAge { + const testing = std.testing; + var buf: [32]u8 = undefined; + const now: i64 = 2_000_000_000; // fixed reference + const min = std.time.s_per_min; + const hour = std.time.s_per_hour; + const day = std.time.s_per_day; + + // Out-of-range timestamps don't crash: a huge future one saturates to + // a non-positive age ("now"); a negative one is a large but real age. + try testing.expectEqualStrings("now", relativeAge(&buf, now, std.math.maxInt(i64))); + try testing.expectEqualStrings("63y ago", relativeAge(&buf, now, -100)); + + // A huge age (garbage timestamp) saturates the ns conversion instead of + // overflowing; it must not crash and must fit the buffer. + try testing.expect(std.mem.endsWith(u8, relativeAge(&buf, std.math.maxInt(i64), 0), " ago")); + + // Future timestamp (clock skew) and same-instant read "now". + try testing.expectEqualStrings("now", relativeAge(&buf, now, now + 100)); + try testing.expectEqualStrings("now", relativeAge(&buf, now, now)); + + // Only the single largest unit is kept (smaller units rounded away). + try testing.expectEqualStrings("30s ago", relativeAge(&buf, now, now - 30)); + try testing.expectEqualStrings("1m ago", relativeAge(&buf, now, now - min)); + try testing.expectEqualStrings("1m ago", relativeAge(&buf, now, now - 90)); // 90s -> 1m + try testing.expectEqualStrings("1h ago", relativeAge(&buf, now, now - hour)); + try testing.expectEqualStrings("1h ago", relativeAge(&buf, now, now - (hour + 30 * min))); // 1h30m -> 1h + try testing.expectEqualStrings("1d ago", relativeAge(&buf, now, now - day)); + try testing.expectEqualStrings("2w ago", relativeAge(&buf, now, now - 19 * day)); // 19d -> 2w } test { _ = DiskCache; _ = Entry; } + +test "runInner rejects multiple actions" { + const testing = std.testing; + const alloc = testing.allocator; + + var stdout: std.Io.Writer.Allocating = .init(alloc); + defer stdout.deinit(); + var stderr: std.Io.Writer.Allocating = .init(alloc); + defer stderr.deinit(); + + // The check runs before any cache access, so it never touches disk. + const code = try runInner(alloc, .{ + .add = "example.com", + .remove = "other.com", + }, null, &stdout.writer, &stderr.writer); + + try testing.expectEqual(@as(u8, 2), code); + try testing.expectEqualStrings("", stdout.written()); + try testing.expect(std.mem.indexOf(u8, stderr.written(), "only one") != null); + + // A positional query is itself an action: query + a flag conflicts. + stderr.clearRetainingCapacity(); + const code2 = try runInner(alloc, .{ + .clear = true, + }, "example.com", &stdout.writer, &stderr.writer); + try testing.expectEqual(@as(u8, 2), code2); + try testing.expect(std.mem.indexOf(u8, stderr.written(), "only one") != null); +} diff --git a/vendor/libghostty-vt/src/cli/toggle_quick_terminal.zig b/vendor/libghostty-vt/src/cli/toggle_quick_terminal.zig new file mode 100644 index 00000000..233700f7 --- /dev/null +++ b/vendor/libghostty-vt/src/cli/toggle_quick_terminal.zig @@ -0,0 +1,62 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Action = @import("../cli.zig").ghostty.Action; +const apprt = @import("../apprt.zig"); + +pub const Options = struct { + /// If set, connect to a custom instance of Ghostty. + class: ?[:0]const u8 = null, + + pub fn deinit(self: *Options) void { + self.* = undefined; + } + + /// Enables "-h" and "--help" to work. + pub fn help(self: Options) !void { + _ = self; + return Action.help_error; + } +}; + +/// The `+toggle-quick-terminal` command will use native platform IPC to toggle +/// the quick terminal in a running instance of Ghostty. +/// +/// If the `--class` flag is not set, the command will try and connect to the +/// default running Ghostty instance. Otherwise it will contact a Ghostty +/// instance configured with the given `class`. +/// +/// On GTK, D-Bus activation must be properly configured. Ghostty does not need +/// to be running, as D-Bus will handle launching a new instance if it is not +/// already running. +/// +/// Only supported on GTK. +/// +/// Flags: +/// +/// * `--class=<class>`: If set, connect to a custom instance of Ghostty. +/// The class must be a valid GTK application ID. +/// +/// Available since: 1.4.0 +pub fn run(alloc: Allocator) !u8 { + var buf: [256]u8 = undefined; + var stderr_writer = std.fs.File.stderr().writer(&buf); + const stderr = &stderr_writer.interface; + + if (apprt.App.performIpc( + alloc, + .detect, + .toggle_quick_terminal, + {}, + ) catch |err| switch (err) { + error.IPCFailed => { + return 1; + }, + else => { + try stderr.print("Sending the IPC failed: {}\n", .{err}); + return 1; + }, + }) return 0; + + try stderr.print("+toggle-quick-terminal is not supported on this platform.\n", .{}); + return 1; +} |
