feat(cli): port ghostty +action CLI commands
Ports ZIG/src/cli into ghostty-cli: action detection/dispatch with per-action --help, and the actions help, version, list-actions, list-colors, list-keybinds, list-themes (plain listing plus an interactive ratatui browser mirroring Ghostty's libvaxis UI), show-config, validate-config, edit-config, crash-report (listing), ssh-cache and boo (235 embedded frames). Verified byte-for-byte against the installed Ghostty 1.3.1 for stdout, stderr and exit codes of most actions, incl. tty output in a pty at widths 20-200. Known differences: +list-colors --plain prints all 782 colors (Ghostty truncates at 617 because it never flushes), +version has placeholder build info until build_info::set is wired up. Deferred: list-fonts, show-face (need font discovery integration) and new-window (needs GTK IPC); they return NotYetAvailable for now. Test Plan: - cargo test -p ghostty-cli (86 tests) - cargo run -p ghostty-cli --example cli -- +list-keybinds --plain and diff against `ghostty +list-keybinds --plain`
This commit is contained in:
1 parent
a97ae04f1d
commit
6286e02f07
46 files changed
+7135
-6
No files matched your search
@@ -0,0 +1,486 @@
|
||||
//! An SSH terminfo entry cache that stores its cache data on disk
|
||||
//! (ZIG/src/cli/ssh-cache/DiskCache.zig). The cache only stores metadata
|
||||
//! (hostname, terminfo value, etc.) and does not store any sensitive data.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::net::Ipv6Addr;
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::entry::{Entry, now_timestamp};
|
||||
|
||||
/// 512KB - sufficient for approximately 10k entries
|
||||
const MAX_CACHE_SIZE: u64 = 512 * 1024;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("CacheIsLocked")]
|
||||
CacheIsLocked,
|
||||
#[error("HostnameIsInvalid")]
|
||||
HostnameIsInvalid,
|
||||
/// The general error that is returned for any filesystem error
|
||||
/// that may have resulted in the XDG lookup failing.
|
||||
#[error("XdgLookupFailed")]
|
||||
XdgLookupFailed,
|
||||
#[error("InvalidCachePath")]
|
||||
InvalidCachePath,
|
||||
#[error("StreamTooLong")]
|
||||
StreamTooLong,
|
||||
#[error("{0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AddResult {
|
||||
Added,
|
||||
Updated,
|
||||
}
|
||||
|
||||
pub type Entries = BTreeMap<String, Entry>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiskCache {
|
||||
/// Path to a file where the cache is stored.
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
impl DiskCache {
|
||||
/// Returns the default path for the cache for a given program.
|
||||
///
|
||||
/// On all platforms, this is `${XDG_STATE_HOME}/ghostty/ssh_cache`.
|
||||
pub fn default_path(program: &str) -> Result<PathBuf, Error> {
|
||||
let state_dir = ghostty_os::xdg::state(&ghostty_os::xdg::Options {
|
||||
subdir: Some(program),
|
||||
home: None,
|
||||
})
|
||||
.map_err(|_| Error::XdgLookupFailed)?;
|
||||
Ok(PathBuf::from(state_dir).join("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) -> Result<(), Error> {
|
||||
match std::fs::remove_file(&self.path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add or update a hostname entry in the cache.
|
||||
/// Returns `AddResult::Added` for new entries or `AddResult::Updated` for
|
||||
/// existing ones. The cache file is created if it doesn't exist with
|
||||
/// secure permissions (0600).
|
||||
pub fn add(&self, hostname: &str) -> Result<AddResult, Error> {
|
||||
if !is_valid_cache_key(hostname) {
|
||||
return Err(Error::HostnameIsInvalid);
|
||||
}
|
||||
|
||||
// Create cache directory if needed
|
||||
if let Some(dir) = self.path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
|
||||
// Open or create cache file with secure permissions
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.mode(0o600)
|
||||
.open(&self.path)?;
|
||||
fixup_permissions(&file)?;
|
||||
|
||||
// Lock
|
||||
let _lock = lock(&file)?;
|
||||
|
||||
let mut entries = read_entries(&file)?;
|
||||
|
||||
// Add or update entry
|
||||
let result = if let Some(e) = entries.get_mut(hostname) {
|
||||
// Update timestamp for existing entry
|
||||
e.timestamp = now_timestamp();
|
||||
AddResult::Updated
|
||||
} else {
|
||||
entries.insert(
|
||||
hostname.to_string(),
|
||||
Entry {
|
||||
hostname: hostname.to_string(),
|
||||
timestamp: now_timestamp(),
|
||||
terminfo_version: "xterm-ghostty".to_string(),
|
||||
},
|
||||
);
|
||||
AddResult::Added
|
||||
};
|
||||
|
||||
self.write_cache_file(&entries, None)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Remove a hostname entry from the cache.
|
||||
/// No error is returned if the hostname doesn't exist or the cache file
|
||||
/// is missing.
|
||||
pub fn remove(&self, hostname: &str) -> Result<(), Error> {
|
||||
if !is_valid_cache_key(hostname) {
|
||||
return Err(Error::HostnameIsInvalid);
|
||||
}
|
||||
|
||||
// Open our file
|
||||
let file = match OpenOptions::new().read(true).write(true).open(&self.path) {
|
||||
Ok(f) => f,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
fixup_permissions(&file)?;
|
||||
|
||||
// Lock
|
||||
let _lock = lock(&file)?;
|
||||
|
||||
// Read existing entries
|
||||
let mut entries = read_entries(&file)?;
|
||||
entries.remove(hostname);
|
||||
|
||||
self.write_cache_file(&entries, None)
|
||||
}
|
||||
|
||||
/// Check if a hostname exists in the cache.
|
||||
/// Returns false if the cache file doesn't exist.
|
||||
pub fn contains(&self, hostname: &str) -> Result<bool, Error> {
|
||||
if !is_valid_cache_key(hostname) {
|
||||
return Err(Error::HostnameIsInvalid);
|
||||
}
|
||||
|
||||
// Open our file
|
||||
let file = match File::open(&self.path) {
|
||||
Ok(f) => f,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
let entries = read_entries(&file)?;
|
||||
Ok(entries.contains_key(hostname))
|
||||
}
|
||||
|
||||
/// List all entries in the cache.
|
||||
/// Returns an empty map if the cache file doesn't exist.
|
||||
pub fn list(&self) -> Result<Entries, Error> {
|
||||
let file = match File::open(&self.path) {
|
||||
Ok(f) => f,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Entries::new()),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
read_entries(&file)
|
||||
}
|
||||
|
||||
/// Atomically (temp file + rename) write the non-expired entries.
|
||||
fn write_cache_file(&self, entries: &Entries, expire_days: Option<u32>) -> Result<(), Error> {
|
||||
let cache_dir = self.path.parent().ok_or(Error::InvalidCachePath)?;
|
||||
let cache_basename = self.path.file_name().ok_or(Error::InvalidCachePath)?;
|
||||
|
||||
let mut data = String::new();
|
||||
for e in entries.values() {
|
||||
// Only write non-expired entries
|
||||
if e.is_expired(expire_days) {
|
||||
continue;
|
||||
}
|
||||
e.format(&mut data);
|
||||
}
|
||||
|
||||
let mut tmp_name = cache_basename.to_os_string();
|
||||
tmp_name.push(format!(".{}.tmp", std::process::id()));
|
||||
let tmp_path = cache_dir.join(tmp_name);
|
||||
|
||||
let result = (|| -> std::io::Result<()> {
|
||||
let mut f = OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(&tmp_path)?;
|
||||
f.write_all(data.as_bytes())?;
|
||||
f.sync_all()?;
|
||||
std::fs::rename(&tmp_path, &self.path)
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
}
|
||||
Ok(result?)
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds an advisory exclusive lock on a file until dropped.
|
||||
struct Lock<'a>(Option<&'a File>);
|
||||
|
||||
impl Drop for Lock<'_> {
|
||||
fn drop(&mut self) {
|
||||
if let Some(f) = self.0 {
|
||||
let _ = f.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Zig: `_ = file.tryLock(.exclusive) catch return error.CacheIsLocked;`
|
||||
/// The boolean result is ignored upstream, so a lock held by someone else
|
||||
/// does not fail; only a real error does.
|
||||
fn lock(file: &File) -> Result<Lock<'_>, Error> {
|
||||
match file.try_lock() {
|
||||
Ok(()) => Ok(Lock(Some(file))),
|
||||
Err(std::fs::TryLockError::WouldBlock) => Ok(Lock(None)),
|
||||
Err(std::fs::TryLockError::Error(_)) => Err(Error::CacheIsLocked),
|
||||
}
|
||||
}
|
||||
|
||||
fn fixup_permissions(file: &File) -> Result<(), Error> {
|
||||
// Ensure file has correct permissions (readable/writable by
|
||||
// owner only)
|
||||
let meta = file.metadata()?;
|
||||
if meta.permissions().mode() & 0o777 != 0o600 {
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_entries(file: &File) -> Result<Entries, Error> {
|
||||
let mut content = Vec::new();
|
||||
let mut f = file;
|
||||
(&mut f)
|
||||
.take(MAX_CACHE_SIZE + 1)
|
||||
.read_to_end(&mut content)?;
|
||||
if content.len() as u64 > MAX_CACHE_SIZE {
|
||||
return Err(Error::StreamTooLong);
|
||||
}
|
||||
let content = String::from_utf8_lossy(&content);
|
||||
|
||||
let mut entries = Entries::new();
|
||||
for line in content.split('\n').filter(|l| !l.is_empty()) {
|
||||
let trimmed = line.trim_matches([' ', '\t', '\r']);
|
||||
let Some(entry) = Entry::parse(trimmed) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match entries.get_mut(&entry.hostname) {
|
||||
None => {
|
||||
entries.insert(entry.hostname.clone(), entry);
|
||||
}
|
||||
Some(existing) => {
|
||||
// Handle duplicate entries - keep newer timestamp
|
||||
if entry.timestamp > existing.timestamp {
|
||||
existing.timestamp = entry.timestamp;
|
||||
if existing.terminfo_version != entry.terminfo_version {
|
||||
existing.terminfo_version = entry.terminfo_version;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
// Supports both standalone hostnames and user@hostname format
|
||||
fn is_valid_cache_key(key: &str) -> bool {
|
||||
if key.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for user@hostname format
|
||||
if let Some(at_pos) = key.find('@') {
|
||||
let user = &key[..at_pos];
|
||||
let hostname = &key[at_pos + 1..];
|
||||
return is_valid_user(user) && is_valid_host(hostname);
|
||||
}
|
||||
|
||||
is_valid_host(key)
|
||||
}
|
||||
|
||||
// Checks if a host is a valid hostname or IP address
|
||||
fn is_valid_host(host: &str) -> bool {
|
||||
// First check for valid hostnames because this is assumed to be the more
|
||||
// likely ssh host format.
|
||||
if ghostty_os::hostname::is_valid(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.
|
||||
host.parse::<Ipv6Addr>().is_ok()
|
||||
}
|
||||
|
||||
fn is_valid_user(user: &str) -> bool {
|
||||
if user.is_empty() || user.len() > 64 {
|
||||
return false;
|
||||
}
|
||||
user.bytes()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, b'_' | b'-' | b'.'))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn disk_cache_default_path() {
|
||||
let path = DiskCache::default_path("ghostty").unwrap();
|
||||
assert!(!path.as_os_str().is_empty());
|
||||
assert!(path.ends_with("ghostty/ssh_cache"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disk_cache_clear() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("cache");
|
||||
std::fs::write(&path, "HELLO!").unwrap();
|
||||
|
||||
let cache = DiskCache { path: path.clone() };
|
||||
cache.clear().unwrap();
|
||||
|
||||
// Verify the file is gone
|
||||
assert_eq!(
|
||||
std::io::ErrorKind::NotFound,
|
||||
File::open(&path).unwrap_err().kind()
|
||||
);
|
||||
// Clearing again is not an error.
|
||||
cache.clear().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disk_cache_operations() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("cache");
|
||||
std::fs::write(&path, "HELLO!").unwrap();
|
||||
|
||||
// Setup our cache
|
||||
let cache = DiskCache { path };
|
||||
assert_eq!(AddResult::Added, cache.add("example.com").unwrap());
|
||||
assert_eq!(AddResult::Updated, cache.add("example.com").unwrap());
|
||||
assert!(cache.contains("example.com").unwrap());
|
||||
|
||||
// List
|
||||
let entries = cache.list().unwrap();
|
||||
assert_eq!(1, entries.len());
|
||||
|
||||
// Remove
|
||||
cache.remove("example.com").unwrap();
|
||||
assert!(!cache.contains("example.com").unwrap());
|
||||
assert_eq!(AddResult::Added, cache.add("example.com").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disk_cache_cleans_up_temp_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cache = DiskCache {
|
||||
path: tmp.path().join("cache"),
|
||||
};
|
||||
assert_eq!(AddResult::Added, cache.add("example.com").unwrap());
|
||||
assert_eq!(AddResult::Added, cache.add("example.org").unwrap());
|
||||
|
||||
// Verify only the cache file exists and no temp files left behind
|
||||
let names: Vec<_> = std::fs::read_dir(tmp.path())
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().file_name())
|
||||
.collect();
|
||||
assert_eq!(vec![std::ffi::OsString::from("cache")], names);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disk_cache_file_permissions_and_invalid_host() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cache = DiskCache {
|
||||
path: tmp.path().join("sub/cache"),
|
||||
};
|
||||
cache.add("example.com").unwrap();
|
||||
let mode = std::fs::metadata(&cache.path).unwrap().permissions().mode();
|
||||
assert_eq!(0o600, mode & 0o777);
|
||||
assert!(matches!(
|
||||
cache.add("bad host"),
|
||||
Err(Error::HostnameIsInvalid)
|
||||
));
|
||||
assert!(matches!(
|
||||
cache.contains("host name"),
|
||||
Err(Error::HostnameIsInvalid)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_entries_keep_newer_timestamp() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("cache");
|
||||
std::fs::write(&path, "a.com|10|old\na.com|20|new\na.com|15|mid\n").unwrap();
|
||||
let e = DiskCache { path }.list().unwrap();
|
||||
assert_eq!(20, e["a.com"].timestamp);
|
||||
assert_eq!("new", e["a.com"].terminfo_version);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_valid_host_cases() {
|
||||
// Valid hostnames
|
||||
assert!(is_valid_host("localhost"));
|
||||
assert!(is_valid_host("example.com"));
|
||||
assert!(is_valid_host("sub.example.com"));
|
||||
|
||||
// IPv4 addresses
|
||||
assert!(is_valid_host("127.0.0.1"));
|
||||
assert!(is_valid_host("192.168.1.1"));
|
||||
|
||||
// IPv6 addresses
|
||||
assert!(is_valid_host("::1"));
|
||||
assert!(is_valid_host("2001:db8::1"));
|
||||
assert!(is_valid_host("2001:db8:0:1:1:1:1:1"));
|
||||
assert!(!is_valid_host("fe80::1%eth0")); // scopes not supported
|
||||
|
||||
// Invalid hosts
|
||||
assert!(!is_valid_host(""));
|
||||
assert!(!is_valid_host("host\nname"));
|
||||
assert!(!is_valid_host(".example.com"));
|
||||
assert!(!is_valid_host("host..domain"));
|
||||
assert!(!is_valid_host("-hostname"));
|
||||
assert!(!is_valid_host("hostname-"));
|
||||
assert!(!is_valid_host("host name"));
|
||||
assert!(!is_valid_host("host_name"));
|
||||
assert!(!is_valid_host("host@domain"));
|
||||
assert!(!is_valid_host("host:port"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_valid_user_cases() {
|
||||
// Valid
|
||||
assert!(is_valid_user("user"));
|
||||
assert!(is_valid_user("user-user"));
|
||||
assert!(is_valid_user("user_name"));
|
||||
assert!(is_valid_user("user.name"));
|
||||
assert!(is_valid_user("user123"));
|
||||
|
||||
// Invalid
|
||||
assert!(!is_valid_user(""));
|
||||
assert!(!is_valid_user("user name"));
|
||||
assert!(!is_valid_user("user@example"));
|
||||
assert!(!is_valid_user("user:group"));
|
||||
assert!(!is_valid_user("user\nname"));
|
||||
assert!(!is_valid_user(&"a".repeat(65))); // too long
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_valid_cache_key_cases() {
|
||||
// Valid
|
||||
assert!(is_valid_cache_key("example.com"));
|
||||
assert!(is_valid_cache_key("sub.example.com"));
|
||||
assert!(is_valid_cache_key("192.168.1.1"));
|
||||
assert!(is_valid_cache_key("::1"));
|
||||
assert!(is_valid_cache_key("user@example.com"));
|
||||
assert!(is_valid_cache_key("user@192.168.1.1"));
|
||||
assert!(is_valid_cache_key("user@::1"));
|
||||
|
||||
// Invalid
|
||||
assert!(!is_valid_cache_key(""));
|
||||
assert!(!is_valid_cache_key(".example.com"));
|
||||
assert!(!is_valid_cache_key("@example.com"));
|
||||
assert!(!is_valid_cache_key("user@"));
|
||||
assert!(!is_valid_cache_key("user@@example"));
|
||||
assert!(!is_valid_cache_key("user@.example.com"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! A single entry within our SSH entry cache (ZIG/src/cli/ssh-cache/Entry.zig).
|
||||
//! Our SSH entry cache stores which hosts we've sent our terminfo to so that
|
||||
//! we don't have to send it again. It doesn't store any sensitive information.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const S_PER_DAY: i64 = 86_400;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Entry {
|
||||
pub hostname: String,
|
||||
pub timestamp: i64,
|
||||
pub terminfo_version: String,
|
||||
}
|
||||
|
||||
/// `std.time.timestamp()`: seconds since the unix epoch.
|
||||
#[must_use]
|
||||
pub fn now_timestamp() -> i64 {
|
||||
match SystemTime::now().duration_since(UNIX_EPOCH) {
|
||||
Ok(d) => i64::try_from(d.as_secs()).unwrap_or(i64::MAX),
|
||||
Err(e) => -i64::try_from(e.duration().as_secs()).unwrap_or(i64::MAX),
|
||||
}
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
/// Parse a `hostname|timestamp|terminfo_version` line.
|
||||
#[must_use]
|
||||
pub fn parse(line: &str) -> Option<Entry> {
|
||||
let trimmed = line.trim_matches([' ', '\t', '\r', '\n']);
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Parse format: hostname|timestamp|terminfo_version
|
||||
// (`tokenizeScalar` skips empty tokens.)
|
||||
let mut iter = trimmed.split('|').filter(|s| !s.is_empty());
|
||||
let hostname = iter.next()?;
|
||||
let timestamp_str = iter.next()?;
|
||||
let terminfo_version = iter.next().unwrap_or("xterm-ghostty");
|
||||
let timestamp = match timestamp_str.parse::<i64>() {
|
||||
Ok(t) => t,
|
||||
Err(err) => {
|
||||
log::warn!("Invalid timestamp in cache entry: {timestamp_str} err={err}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
Some(Entry {
|
||||
hostname: hostname.to_string(),
|
||||
timestamp,
|
||||
terminfo_version: terminfo_version.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Write the entry in the on-disk format (`hostname|timestamp|terminfo\n`).
|
||||
pub fn format(&self, out: &mut String) {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{}|{}|{}",
|
||||
self.hostname, self.timestamp, self.terminfo_version
|
||||
);
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_expired(&self, expire_days: Option<u32>) -> bool {
|
||||
self.is_expired_at(expire_days, now_timestamp())
|
||||
}
|
||||
|
||||
fn is_expired_at(&self, expire_days: Option<u32>, now: i64) -> bool {
|
||||
let Some(expire_days) = expire_days else {
|
||||
return false;
|
||||
};
|
||||
let age_days = now.saturating_sub(self.timestamp) / S_PER_DAY;
|
||||
age_days > i64::from(expire_days)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
fn entry(host: &str, timestamp: i64) -> Entry {
|
||||
Entry {
|
||||
hostname: host.to_string(),
|
||||
timestamp,
|
||||
terminfo_version: "xterm-ghostty".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_entry_expiration() {
|
||||
let now = now_timestamp();
|
||||
let fresh_entry = entry("test.com", now - S_PER_DAY); // 1 day old
|
||||
assert!(!fresh_entry.is_expired(Some(90)));
|
||||
|
||||
let old_entry = entry("old.com", now - S_PER_DAY * 100); // 100 days old
|
||||
assert!(old_entry.is_expired(Some(90)));
|
||||
|
||||
// Test never-expire case
|
||||
assert!(!old_entry.is_expired(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_entry_expiration_exact_boundary() {
|
||||
let now = now_timestamp();
|
||||
// Exactly at expiration boundary
|
||||
let boundary_entry = entry("example.com", now - S_PER_DAY * 30);
|
||||
assert!(!boundary_entry.is_expired_at(Some(30), now));
|
||||
assert!(boundary_entry.is_expired_at(Some(29), now));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_entry_expiration_large_timestamp() {
|
||||
let now = now_timestamp();
|
||||
let boundary_entry = entry("example.com", now + S_PER_DAY * 30);
|
||||
assert!(!boundary_entry.is_expired(Some(30)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_entry_parsing_valid_formats() {
|
||||
let e = Entry::parse("example.com|1640995200|xterm-ghostty").unwrap();
|
||||
assert_eq!("example.com", e.hostname);
|
||||
assert_eq!(1_640_995_200, e.timestamp);
|
||||
assert_eq!("xterm-ghostty", e.terminfo_version);
|
||||
|
||||
// Test default terminfo version
|
||||
let e = Entry::parse("test.com|1640995200").unwrap();
|
||||
assert_eq!("xterm-ghostty", e.terminfo_version);
|
||||
|
||||
// Test complex hostnames
|
||||
let e = Entry::parse("user@server.example.com|1640995200|xterm-ghostty").unwrap();
|
||||
assert_eq!("user@server.example.com", e.hostname);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_entry_parsing_invalid_formats() {
|
||||
assert!(Entry::parse("").is_none());
|
||||
|
||||
// Invalid format (no pipe)
|
||||
assert!(Entry::parse("v1").is_none());
|
||||
|
||||
// Missing timestamp
|
||||
assert!(Entry::parse("example.com").is_none());
|
||||
|
||||
// Invalid timestamp
|
||||
assert!(Entry::parse("example.com|invalid").is_none());
|
||||
|
||||
// Empty terminfo should default
|
||||
assert!(Entry::parse("example.com|1640995200|").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_entry_parsing_malformed_data_resilience() {
|
||||
// Extra pipes should not break parsing
|
||||
assert!(Entry::parse("host|123|term|extra").is_some());
|
||||
|
||||
// Whitespace handling
|
||||
assert!(Entry::parse(" host|123|term ").is_some());
|
||||
assert!(Entry::parse("\n").is_none());
|
||||
assert!(Entry::parse(" \t \n").is_none());
|
||||
|
||||
// Extremely large timestamp
|
||||
assert!(
|
||||
Entry::parse("host|999999999999999999999999999999999999999999999999|xterm-ghostty")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_entry_format_round_trip() {
|
||||
let e = entry("a.com", 5);
|
||||
let mut s = String::new();
|
||||
e.format(&mut s);
|
||||
assert_eq!("a.com|5|xterm-ghostty\n", s);
|
||||
assert_eq!(Some(e), Entry::parse(&s));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user