mirror-ghostty/src/cli/ssh-cache/DiskCache.zig

748 lines
24 KiB
Zig

/// 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 builtin = @import("builtin");
const assert = @import("../../quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
const internal_os = @import("../../os/main.zig");
const xdg = internal_os.xdg;
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,
/// 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,
) ![]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,
};
}
/// 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,
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| {
std.fs.cwd().makePath(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
// 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();
var entries = try readEntries(alloc, file);
defer deinitEntries(alloc, &entries);
// 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);
try entries.put(key_copy, .{
.hostname = key_copy,
.timestamp = timestamp,
.terminfo_version = terminfo_copy,
});
}
try self.writeCacheFile(entries);
}
/// 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,
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 false,
else => return err,
};
defer file.close();
try fixupPermissions(file);
// Lock
// Causes a compile failure in the Zig std library on Windows, see:
// https://github.com/ziglang/zig/issues/18430
if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheLocked;
defer if (comptime builtin.os.tag != .windows) file.unlock();
// Read existing entries
var entries = try readEntries(alloc, file);
defer deinitEntries(alloc, &entries);
// Remove the entry if it exists and ensure we free the memory
const removed = if (entries.fetchRemove(key)) |kv| removed: {
assert(kv.key.ptr == kv.value.hostname.ptr);
alloc.free(kv.value.hostname);
alloc.free(kv.value.terminfo_version);
break :removed true;
} else false;
try self.writeCacheFile(entries);
return removed;
}
/// Remove all entries older than `max_age_s` seconds and return how many
/// were pruned. Returns zero (and nothing written) if the cache file is
/// missing.
pub fn prune(
self: DiskCache,
alloc: Allocator,
max_age_s: u64,
) !usize {
const file = std.fs.openFileAbsolute(
self.path,
.{ .mode = .read_write },
) catch |err| switch (err) {
error.FileNotFound => return 0,
else => return err,
};
defer file.close();
try fixupPermissions(file);
// Lock
// Causes a compile failure in the Zig std library on Windows, see:
// https://github.com/ziglang/zig/issues/18430
if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheLocked;
defer if (comptime builtin.os.tag != .windows) file.unlock();
// Read existing entries
var entries = try readEntries(alloc, file);
defer deinitEntries(alloc, &entries);
// Drop expired entries from the map, then persist what remains.
const now = std.time.timestamp();
var expired: std.ArrayList([]const u8) = .empty;
defer expired.deinit(alloc);
var iter = entries.iterator();
while (iter.next()) |kv| {
const age_s = now -| kv.value_ptr.timestamp;
if (age_s > max_age_s) try expired.append(alloc, kv.key_ptr.*);
}
for (expired.items) |key| {
const kv = entries.fetchRemove(key).?;
assert(kv.key.ptr == kv.value.hostname.ptr);
alloc.free(kv.value.hostname);
alloc.free(kv.value.terminfo_version);
}
try self.writeCacheFile(entries);
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,
key: []const u8,
) !bool {
if (!isValidCacheKey(key)) return error.InvalidCacheKey;
// Open our file
const file = std.fs.openFileAbsolute(
self.path,
.{},
) catch |err| switch (err) {
error.FileNotFound => return false,
else => return err,
};
defer file.close();
// Read existing entries
var entries = try readEntries(alloc, file);
defer deinitEntries(alloc, &entries);
return entries.contains(key);
}
fn fixupPermissions(file: std.fs.File) !void {
// Windows does not support chmod
if (comptime builtin.os.tag == .windows) return;
// 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,
entries: std.StringHashMap(Entry),
) !void {
const cache_dir = std.fs.path.dirname(self.path) orelse return error.InvalidCachePath;
const cache_basename = std.fs.path.basename(self.path);
var dir = try std.fs.cwd().openDir(cache_dir, .{});
defer dir.close();
var buf: [1024]u8 = undefined;
var atomic_file = try dir.atomicFile(cache_basename, .{
.mode = 0o600,
.write_buffer = &buf,
});
defer atomic_file.deinit();
var iter = entries.iterator();
while (iter.next()) |kv| {
try kv.value_ptr.format(&atomic_file.file_writer.interface);
}
try atomic_file.finish();
}
/// 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) {
var reader = file.reader(&.{});
const content = try reader.interface.allocRemaining(
alloc,
.limited(MAX_CACHE_SIZE),
);
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;
// 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.?);
if (!gop.found_existing) {
// New entry: transfer both copies to the map.
gop.value_ptr.* = .{
.hostname = hostname.?,
.timestamp = entry.timestamp,
.terminfo_version = terminfo.?,
};
hostname = null;
terminfo = null;
} else {
// 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) {
gop.value_ptr.timestamp = entry.timestamp;
if (!std.mem.eql(
u8,
gop.value_ptr.terminfo_version,
terminfo.?,
)) {
alloc.free(gop.value_ptr.terminfo_version);
gop.value_ptr.terminfo_version = terminfo.?;
terminfo = null;
}
}
if (terminfo) |t| alloc.free(t);
terminfo = null;
}
}
return entries;
}
// Supports both standalone hostnames and user@hostname format
pub fn isValidCacheKey(key: []const u8) bool {
if (key.len == 0) return false;
// Check for user@hostname format
if (std.mem.indexOfScalar(u8, key, '@')) |at_pos| {
const user = key[0..at_pos];
const hostname = key[at_pos + 1 ..];
return isValidUser(user) and isValidHost(hostname);
}
return isValidHost(key);
}
// Checks if a host is a valid hostname or IP address
fn isValidHost(host: []const u8) bool {
// First check for valid hostnames because this is assumed to be the more
// likely ssh host format.
if (internal_os.hostname.isValid(host)) {
return true;
}
// We also accept valid IP addresses. In practice, IPv4 addresses are also
// considered valid hostnames due to their overlapping syntax, so we can
// simplify this check to be IPv6-specific.
if (std.net.Address.parseIp6(host, 0)) |_| {
return true;
} else |_| {
return false;
}
}
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 tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var buf: [4096]u8 = undefined;
{
var file = try tmp.dir.createFile("cache", .{});
defer file.close();
var file_writer = file.writer(&buf);
try file_writer.interface.writeAll("HELLO!");
}
const path = try tmp.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,
tmp.dir.openFile("cache", .{}),
);
}
test "disk cache operations" {
const testing = std.testing;
const alloc = testing.allocator;
// Create our path
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var buf: [4096]u8 = undefined;
{
var file = try tmp.dir.createFile("cache", .{});
defer file.close();
var file_writer = file.writer(&buf);
const writer = &file_writer.interface;
try writer.writeAll("HELLO!");
try writer.flush();
}
const path = try tmp.dir.realpathAlloc(alloc, "cache");
defer alloc.free(path);
// Setup our cache. Adding the same key twice exercises both the new
// and existing-entry paths.
const cache: DiskCache = .{ .path = path };
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 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" {
const testing = std.testing;
const alloc = testing.allocator;
var tmp = testing.tmpDir(.{ .iterate = true });
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 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;
var iter = tmp.dir.iterate();
while (try iter.next()) |entry| {
count += 1;
try testing.expectEqualStrings("cache", entry.name);
}
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;
// Valid hostnames
try testing.expect(isValidHost("localhost"));
try testing.expect(isValidHost("example.com"));
try testing.expect(isValidHost("sub.example.com"));
// IPv4 addresses
try testing.expect(isValidHost("127.0.0.1"));
try testing.expect(isValidHost("192.168.1.1"));
// IPv6 addresses
try testing.expect(isValidHost("::1"));
try testing.expect(isValidHost("2001:db8::1"));
try testing.expect(isValidHost("2001:db8:0:1:1:1:1:1"));
try testing.expect(!isValidHost("fe80::1%eth0")); // scopes not supported
// Invalid hosts
try testing.expect(!isValidHost(""));
try testing.expect(!isValidHost("host\nname"));
try testing.expect(!isValidHost(".example.com"));
try testing.expect(!isValidHost("host..domain"));
try testing.expect(!isValidHost("-hostname"));
try testing.expect(!isValidHost("hostname-"));
try testing.expect(!isValidHost("host name"));
try testing.expect(!isValidHost("host_name"));
try testing.expect(!isValidHost("host@domain"));
try testing.expect(!isValidHost("host:port"));
}
test isValidUser {
const testing = std.testing;
// Valid
try testing.expect(isValidUser("user"));
try testing.expect(isValidUser("user-user"));
try testing.expect(isValidUser("user_name"));
try testing.expect(isValidUser("user.name"));
try testing.expect(isValidUser("user123"));
// Invalid
try testing.expect(!isValidUser(""));
try testing.expect(!isValidUser("user name"));
try testing.expect(!isValidUser("user@example"));
try testing.expect(!isValidUser("user:group"));
try testing.expect(!isValidUser("user\nname"));
try testing.expect(!isValidUser("a" ** 65)); // too long
}
test isValidCacheKey {
const testing = std.testing;
// Valid
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("user@example.com"));
try testing.expect(isValidCacheKey("user@192.168.1.1"));
try testing.expect(isValidCacheKey("user@::1"));
// Invalid
try testing.expect(!isValidCacheKey(""));
try testing.expect(!isValidCacheKey(".example.com"));
try testing.expect(!isValidCacheKey("@example.com"));
try testing.expect(!isValidCacheKey("user@"));
try testing.expect(!isValidCacheKey("user@@example"));
try testing.expect(!isValidCacheKey("user@.example.com"));
}