diff options
| author | sl0p <sl0p@sl0p.foo> | 2026-08-13 22:00:03 +0200 |
|---|---|---|
| committer | sl0p <sl0p@sl0p.foo> | 2026-08-13 22:00:03 +0200 |
| commit | 2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8 (patch) | |
| tree | 7cd6274303ba1da1f8bd57371aec336dd687fb58 /vendor/libghostty-vt/src/cli/ssh-cache/DiskCache.zig | |
| download | slosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.tar.gz slosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.tar.xz slosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.zip | |
sl0ptty: M0 — libghostty-vt vendored, one pane, compositor, diff renderer
Design contract in DESIGN.md. libghostty-vt vendored fresh from ghostty
main@bedb6f2 (zig 0.16), built to a 14MB static lib.
M0: pty spawn (no libutil), a lib-vt terminal per pane, a screen cell buffer,
a diff emitter that repaints only what changed, raw mode, SIGWINCH, and a
~120Hz frame cap that coalesces pty reads.
Also the seed of the headless harness: --headless dumps the composited screen
without a tty, so composition is testable in milliseconds.
tests/live_m0.py covers what headless cannot: raw mode, the diff emitter,
resize propagation, and the quit path. 6/6 green.
Diffstat (limited to 'vendor/libghostty-vt/src/cli/ssh-cache/DiskCache.zig')
| -rw-r--r-- | vendor/libghostty-vt/src/cli/ssh-cache/DiskCache.zig | 796 |
1 files changed, 796 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/cli/ssh-cache/DiskCache.zig b/vendor/libghostty-vt/src/cli/ssh-cache/DiskCache.zig new file mode 100644 index 0000000..bc5619d --- /dev/null +++ b/vendor/libghostty-vt/src/cli/ssh-cache/DiskCache.zig @@ -0,0 +1,796 @@ +/// 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"); +const global = @import("../../global.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 { + var environ_map = try global.environMap(); + defer environ_map.deinit(); + const state_dir: []const u8 = xdg.state( + global.io(), + alloc, + &environ_map, + .{ .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.Io.Dir.cwd().deleteFile(global.io(), 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.Io.Dir.cwd().createDirPath(global.io(), dir) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; + } + + // Open or create cache file with secure permissions + const file = std.Io.Dir.createFileAbsolute(global.io(), self.path, .{ + .read = true, + .truncate = false, + .permissions = if (builtin.os.tag != .windows and std.posix.mode_t != u0) + .fromMode(0o600) + else + .default_file, + }) catch |err| switch (err) { + error.PathAlreadyExists => blk: { + const existing_file = try std.Io.Dir.openFileAbsolute( + global.io(), + self.path, + .{ .mode = .read_write }, + ); + errdefer existing_file.close(global.io()); + try fixupPermissions(existing_file); + break :blk existing_file; + }, + else => return err, + }; + defer file.close(global.io()); + + // 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( + global.io(), + .exclusive, + ) catch return error.CacheLocked; + defer if (comptime builtin.os.tag != .windows) file.unlock(global.io()); + + 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.Io.Dir.openFileAbsolute( + global.io(), + self.path, + .{ .mode = .read_write }, + ) catch |err| switch (err) { + error.FileNotFound => return false, + else => return err, + }; + defer file.close(global.io()); + 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( + global.io(), + .exclusive, + ) catch return error.CacheLocked; + defer if (comptime builtin.os.tag != .windows) file.unlock(global.io()); + + // 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.Io.Dir.openFileAbsolute( + global.io(), + self.path, + .{ .mode = .read_write }, + ) catch |err| switch (err) { + error.FileNotFound => return 0, + else => return err, + }; + defer file.close(global.io()); + 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( + global.io(), + .exclusive, + ) catch return error.CacheLocked; + defer if (comptime builtin.os.tag != .windows) file.unlock(global.io()); + + // 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.Io.Timestamp.now(global.io(), .real).toSeconds(); + 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.Io.Dir.openFileAbsolute( + global.io(), + self.path, + .{}, + ) catch |err| switch (err) { + error.FileNotFound => return false, + else => return err, + }; + defer file.close(global.io()); + + // Read existing entries + var entries = try readEntries(alloc, file); + defer deinitEntries(alloc, &entries); + + return entries.contains(key); +} + +fn fixupPermissions(file: std.Io.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(global.io()); + if (stat.permissions.toMode() & 0o777 != 0o600) { + try file.setPermissions(global.io(), .fromMode(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.Io.Dir.cwd().openDir(global.io(), cache_dir, .{}); + defer dir.close(global.io()); + + var buf: [1024]u8 = undefined; + var atomic_file = try dir.createFileAtomic(global.io(), cache_basename, .{ + .permissions = if (builtin.os.tag != .windows and std.posix.mode_t != u0) + .fromMode(0o600) + else + .default_file, + .replace = true, + }); + defer atomic_file.deinit(global.io()); + var file_writer = atomic_file.file.writer(global.io(), &buf); + + var iter = entries.iterator(); + while (iter.next()) |kv| { + try kv.value_ptr.format(&file_writer.interface); + } + + try file_writer.flush(); + try atomic_file.replace(global.io()); +} + +/// 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.Io.Dir.openFileAbsolute( + global.io(), + self.path, + .{}, + ) catch |err| switch (err) { + error.FileNotFound => return .init(alloc), + else => return err, + }; + defer file.close(global.io()); + 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.Io.File, +) !std.StringHashMap(Entry) { + var reader = file.reader(global.io(), &.{}); + 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; +} + +/// Whether an error from a cache operation reflects a problem with the cache +/// itself. A destination we can't key on and a lock held by another process +/// both leave the cache healthy, so neither is worth reporting. +pub fn isFailure(err: anyerror) bool { + return switch (err) { + error.InvalidCacheKey, error.CacheLocked => false, + else => true, + }; +} + +// 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 (std.Io.net.HostName.validate(host)) |_| { + return true; + } else |_| {} + + // 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.Io.net.IpAddress.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(testing.io, "cache", .{}); + defer file.close(testing.io); + var file_writer = file.writer(testing.io, &buf); + try file_writer.interface.writeAll("HELLO!"); + } + const path = try tmp.dir.realPathFileAlloc(testing.io, "cache", alloc); + 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(testing.io, "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(testing.io, "cache", .{}); + defer file.close(testing.io); + var file_writer = file.writer(testing.io, &buf); + const writer = &file_writer.interface; + try writer.writeAll("HELLO!"); + try writer.flush(); + } + const path = try tmp.dir.realPathFileAlloc(testing.io, "cache", alloc); + 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.Io.Timestamp.now(testing.io, .real).toSeconds()); + try cache.add(alloc, "example.com", std.Io.Timestamp.now(testing.io, .real).toSeconds()); + 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.Io.Timestamp.now(testing.io, .real).toSeconds()); +} + +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.realPathFileAlloc(testing.io, ".", 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.Io.Timestamp.now(testing.io, .real).toSeconds()); + try cache.add(alloc, "example.org", std.Io.Timestamp.now(testing.io, .real).toSeconds()); + + // 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(testing.io)) |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; + const io = testing.io; + + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + const tmp_path = try tmp.dir.realPathFileAlloc(io, ".", 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.Io.Timestamp.now(testing.io, .real).toSeconds(); + 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; + const io = testing.io; + + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + const tmp_path = try tmp.dir.realPathFileAlloc(io, ".", 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; + const io = testing.io; + + 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(io, "cache", .{}); + defer file.close(io); + var buf: [256]u8 = undefined; + var file_writer = file.writer(io, &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.realPathFileAlloc(io, "cache", alloc); + 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(testing.io, "cache", .{}); + defer file.close(testing.io); + var buf: [256]u8 = undefined; + var file_writer = file.writer(testing.io, &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.realPathFileAlloc(testing.io, "cache", testing.allocator); + 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.realPathFileAlloc(testing.io, ".", 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.Io.Dir.cwd().deleteFile(testing.io, 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 isFailure { + const testing = std.testing; + + try testing.expect(!isFailure(error.InvalidCacheKey)); + try testing.expect(!isFailure(error.CacheLocked)); + try testing.expect(isFailure(error.AccessDenied)); + try testing.expect(isFailure(error.IsDir)); +} + +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")); +} |
