Skip to main content

purple_ssh/
key_activity.rs

1//! Per-host SSH connection activity log.
2//!
3//! Records `(alias, timestamp)` events each time purple opens an SSH
4//! session, exec command or tunnel for a host. Persisted to
5//! `~/.purple/key_activity.json`. The Keys tab reads this log to render
6//! per-key sparklines, last-touch hints and "hosts touched in last 30d"
7//! metrics by pivoting events through `SshKeyInfo::linked_hosts` at
8//! render time. We log per alias rather than per key fingerprint so we
9//! never have to attribute connects to a specific key file; the alias
10//! mapping already encodes the link.
11
12use std::io;
13use std::path::PathBuf;
14use std::time::{SystemTime, UNIX_EPOCH};
15
16use log::debug;
17use serde::{Deserialize, Serialize};
18
19use crate::fs_util;
20use crate::runtime::env::Paths;
21
22/// Retention window for events. Older rows are dropped on load and on
23/// every record so the file does not grow unbounded. 90 days is the
24/// longest range any rendered widget needs (30d sparkline reads the
25/// most recent month, "last touch" reads the most recent of any age).
26const RETENTION_DAYS: u64 = 90;
27
28const SECS_PER_DAY: u64 = 86_400;
29
30/// Fixed reference timestamp used by demo data seeding and by
31/// render-time helpers that need a deterministic "now". Picked at the
32/// cutover date so visual goldens render deterministically; the date
33/// itself only matters in concert with the timestamps demo.rs seeds.
34pub const DEMO_NOW_SECS: u64 = 1_778_932_800; // 2026-05-16 12:00:00 UTC
35
36fn activity_path(paths: Option<&Paths>) -> Option<PathBuf> {
37    paths.map(Paths::key_activity)
38}
39
40/// Current wall-clock epoch seconds. Demo-aware rendering uses
41/// `now_for_render()` instead, which substitutes `DEMO_NOW_SECS` so
42/// sparkline rendering stays byte-stable across golden runs.
43pub fn now_secs() -> u64 {
44    SystemTime::now()
45        .duration_since(UNIX_EPOCH)
46        .map(|d| d.as_secs())
47        .unwrap_or(0)
48}
49
50/// Demo-aware "now" for render-time callers. Returns the frozen
51/// `DEMO_NOW_SECS` when the process is in demo mode (so visual goldens
52/// land byte-stable), otherwise the wall clock. Record-time callers
53/// must use `now_secs()` directly and pass the result through; mixing
54/// the two would let a render-time freeze leak into persisted events.
55pub fn now_for_render() -> u64 {
56    if crate::demo_flag::is_demo() {
57        DEMO_NOW_SECS
58    } else {
59        now_secs()
60    }
61}
62
63#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
64pub struct ConnectEvent {
65    pub alias: String,
66    /// Seconds since UNIX epoch.
67    pub ts: u64,
68}
69
70#[derive(Debug, Clone, Default, Serialize, Deserialize)]
71pub struct KeyActivityLog {
72    pub events: Vec<ConnectEvent>,
73}
74
75impl KeyActivityLog {
76    /// Read the log from disk, pruning anything past the retention window.
77    /// Missing files yield an empty log. Corrupt files are renamed aside
78    /// to `<path>.corrupt-<unix_ts>` before defaulting so a future
79    /// debugger can recover the data.
80    pub fn load(paths: Option<&Paths>) -> Self {
81        let Some(path) = activity_path(paths) else {
82            return Self::default();
83        };
84        match crate::fs_util::read_json_recovering::<Self>(&path, now_secs()) {
85            Some(mut log) => {
86                log.prune(now_secs());
87                log
88            }
89            None => Self::default(),
90        }
91    }
92
93    /// Append an event for `alias` at the supplied `now` timestamp.
94    /// Prunes anything past the retention window using the same `now`
95    /// so the prune cutoff matches the recorded event. Caller decides
96    /// whether to flush; production call sites pass `now_secs()`.
97    pub fn record(&mut self, alias: &str, now: u64) {
98        self.events.push(ConnectEvent {
99            alias: alias.to_string(),
100            ts: now,
101        });
102        self.prune(now);
103    }
104
105    fn prune(&mut self, now: u64) {
106        let cutoff = now.saturating_sub(RETENTION_DAYS * SECS_PER_DAY);
107        self.events.retain(|e| e.ts >= cutoff);
108    }
109
110    /// Serialize to JSON and write atomically. Suppressed in demo mode so
111    /// `--demo` never mutates the user's real activity log. The path is
112    /// resolved from the injected `paths`; a `None` (no home) silently
113    /// skips the write. The demo-suppress branch logs intent so
114    /// `--demo --verbose` shows that recording is happening, just not
115    /// landing on disk.
116    pub fn flush(&self, paths: Option<&Paths>) -> io::Result<()> {
117        if crate::demo_flag::is_demo() {
118            debug!(
119                "[purple] key_activity: demo mode, skipping disk flush ({} events held in memory)",
120                self.events.len(),
121            );
122            return Ok(());
123        }
124        let Some(path) = activity_path(paths) else {
125            return Ok(());
126        };
127        fs_util::write_json_pretty(&path, self)
128    }
129
130    /// One-shot record. For non-TUI call sites (CLI mode) that do not
131    /// hold an in-memory log between connects. Caller passes `now`;
132    /// production CLI paths pass `now_secs()`.
133    pub fn record_oneshot(alias: &str, now: u64, paths: Option<&Paths>) {
134        let mut log = Self::load(paths);
135        log.record(alias, now);
136        if let Err(e) = log.flush(paths) {
137            debug!("[purple] key_activity: flush failed: {e}");
138        }
139    }
140
141    /// Timestamp of the most recent event whose alias appears in `aliases`.
142    pub fn last_use_for_aliases(&self, aliases: &[String]) -> Option<u64> {
143        let lookup = alias_set(aliases);
144        self.events
145            .iter()
146            .filter(|e| lookup.contains(e.alias.as_str()))
147            .map(|e| e.ts)
148            .max()
149    }
150
151    /// All event timestamps for the given aliases, used by the shared
152    /// activity chart renderer which auto-scales the time window from
153    /// the oldest entry.
154    pub fn timestamps_for_aliases(&self, aliases: &[String]) -> Vec<u64> {
155        let lookup = alias_set(aliases);
156        self.events
157            .iter()
158            .filter(|e| lookup.contains(e.alias.as_str()))
159            .map(|e| e.ts)
160            .collect()
161    }
162}
163
164/// Field-disjoint helper: record + flush the activity log without
165/// holding `&mut App`. Lets the event loop record a connect while
166/// another sub-state (FileBrowser, TunnelState) still holds a mutable
167/// borrow on `App`, where the `App::record_key_use` method would be
168/// rejected by the borrow checker. Caller passes `now`; production
169/// call sites pass `now_secs()`.
170pub fn record_and_flush(log: &mut KeyActivityLog, alias: &str, now: u64, paths: Option<&Paths>) {
171    log.record(alias, now);
172    if let Err(e) = log.flush(paths) {
173        debug!("[purple] key_activity: flush failed: {e}");
174    }
175}
176
177/// Build a `HashSet<&str>` lookup from an alias slice. Used once per
178/// query so per-event membership check is O(1) instead of O(aliases).
179fn alias_set(aliases: &[String]) -> std::collections::HashSet<&str> {
180    aliases.iter().map(String::as_str).collect()
181}
182
183/// Format the gap between `now` and `ts` as a compact `Nu ago` label
184/// (`N` count, `u` unit). Mirrors the rhythm Linear / GitHub use:
185/// `just now`, `14m ago`, `3h ago`, `2d ago`, `3w ago`, `2mo ago`,
186/// `1y ago`.
187pub fn humanize_last_use(now: u64, ts: u64) -> String {
188    let diff = now.saturating_sub(ts);
189    if diff < 60 {
190        return "just now".to_string();
191    }
192    let minutes = diff / 60;
193    if minutes < 60 {
194        return format!("{minutes}m ago");
195    }
196    let hours = minutes / 60;
197    if hours < 24 {
198        return format!("{hours}h ago");
199    }
200    let days = hours / 24;
201    if days < 7 {
202        return format!("{days}d ago");
203    }
204    let weeks = days / 7;
205    if weeks < 5 {
206        return format!("{weeks}w ago");
207    }
208    let months = days / 30;
209    if months < 12 {
210        return format!("{months}mo ago");
211    }
212    let years = days / 365;
213    format!("{years}y ago")
214}
215
216/// Format a file mtime as `YYYY-MM-DD (<age> ago)` for the Created
217/// label. Uses `humanize_last_use` for the age tail so the rhythm
218/// matches the Last touch tile.
219pub fn format_created(now: u64, mtime_ts: u64) -> String {
220    let date = format_yyyy_mm_dd(mtime_ts);
221    let age = humanize_last_use(now, mtime_ts);
222    format!("{date} ({age})")
223}
224
225/// `YYYY-MM-DD` from a UNIX timestamp using the proleptic Gregorian
226/// calendar. Avoids pulling in `chrono` just for one date format.
227fn format_yyyy_mm_dd(ts: u64) -> String {
228    let days_since_epoch = (ts / SECS_PER_DAY) as i64;
229    let (y, m, d) = civil_from_days(days_since_epoch);
230    format!("{:04}-{:02}-{:02}", y, m, d)
231}
232
233/// Convert "days since 1970-01-01" to proleptic Gregorian (year, month,
234/// day). Algorithm from Howard Hinnant's date library; bounded constant
235/// time, no allocations, works for any reasonable timestamp.
236fn civil_from_days(z: i64) -> (i32, u32, u32) {
237    let z = z + 719_468;
238    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
239    let doe = (z - era * 146_097) as u64;
240    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
241    let y = yoe as i64 + era * 400;
242    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
243    let mp = (5 * doy + 2) / 153;
244    let d = doy - (153 * mp + 2) / 5 + 1;
245    let m = if mp < 10 { mp + 3 } else { mp - 9 };
246    let y = y + if m <= 2 { 1 } else { 0 };
247    (y as i32, m as u32, d as u32)
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    /// Cross-crate lock: shares `demo_flag::GLOBAL_TEST_LOCK` with the
255    /// preferences and visual regression suites. `now_secs()` no longer
256    /// touches the demo flag, but `flush()` still early-returns when
257    /// demo mode is active, so a concurrent test flipping the flag
258    /// between `record()` and `flush()` would silently suppress the
259    /// write. The mutex serialises every test that exercises that path.
260    fn setup() -> (tempfile::TempDir, Paths, std::sync::MutexGuard<'static, ()>) {
261        let guard = crate::demo_flag::GLOBAL_TEST_LOCK
262            .lock()
263            .unwrap_or_else(|p| p.into_inner());
264        let dir = tempfile::tempdir().expect("tempdir");
265        let paths = Paths::new(dir.path());
266        (dir, paths, guard)
267    }
268
269    #[test]
270    fn record_appends_event() {
271        let (_g, _paths, _lock) = setup();
272        let mut log = KeyActivityLog::default();
273        log.record("prod-eu1", now_secs());
274        assert_eq!(log.events.len(), 1);
275        assert_eq!(log.events[0].alias, "prod-eu1");
276    }
277
278    #[test]
279    fn record_prunes_events_past_retention() {
280        let (_g, _paths, _lock) = setup();
281        let mut log = KeyActivityLog::default();
282        let now = now_secs();
283        let very_old = now - (RETENTION_DAYS + 10) * SECS_PER_DAY;
284        log.events.push(ConnectEvent {
285            alias: "ancient".into(),
286            ts: very_old,
287        });
288        log.record("fresh", now);
289        assert_eq!(log.events.len(), 1);
290        assert_eq!(log.events[0].alias, "fresh");
291    }
292
293    #[test]
294    fn load_after_flush_roundtrips() {
295        let (_g, paths, _lock) = setup();
296        let mut log = KeyActivityLog::default();
297        let now = now_secs();
298        log.record("eric-bastion", now);
299        log.record("aws-api-prod", now);
300        log.flush(Some(&paths)).unwrap();
301        let reloaded = KeyActivityLog::load(Some(&paths));
302        assert_eq!(reloaded.events.len(), 2);
303    }
304
305    #[test]
306    fn load_missing_file_returns_default() {
307        let (_g, paths, _lock) = setup();
308        let log = KeyActivityLog::load(Some(&paths));
309        assert!(log.events.is_empty());
310    }
311
312    #[test]
313    fn last_use_returns_most_recent() {
314        let (_g, _paths, _lock) = setup();
315        let mut log = KeyActivityLog::default();
316        log.events.push(ConnectEvent {
317            alias: "h".into(),
318            ts: 100,
319        });
320        log.events.push(ConnectEvent {
321            alias: "h".into(),
322            ts: 500,
323        });
324        log.events.push(ConnectEvent {
325            alias: "h".into(),
326            ts: 300,
327        });
328        let aliases = vec!["h".to_string()];
329        assert_eq!(log.last_use_for_aliases(&aliases), Some(500));
330    }
331
332    #[test]
333    fn last_use_none_for_no_matches() {
334        let (_g, _paths, _lock) = setup();
335        let log = KeyActivityLog::default();
336        let aliases = vec!["nobody".to_string()];
337        assert!(log.last_use_for_aliases(&aliases).is_none());
338    }
339
340    #[test]
341    fn humanize_last_use_buckets() {
342        assert_eq!(humanize_last_use(1000, 999), "just now");
343        assert_eq!(humanize_last_use(1000, 600), "6m ago");
344        assert_eq!(humanize_last_use(SECS_PER_DAY * 2, 0), "2d ago");
345        assert_eq!(humanize_last_use(SECS_PER_DAY * 14, 0), "2w ago");
346        assert_eq!(humanize_last_use(SECS_PER_DAY * 60, 0), "2mo ago");
347        assert_eq!(humanize_last_use(SECS_PER_DAY * 400, 0), "1y ago");
348    }
349
350    #[test]
351    fn record_oneshot_persists_to_disk() {
352        let (_g, paths, _lock) = setup();
353        KeyActivityLog::record_oneshot("h1", now_secs(), Some(&paths));
354        let reloaded = KeyActivityLog::load(Some(&paths));
355        assert_eq!(reloaded.events.len(), 1);
356        assert_eq!(reloaded.events[0].alias, "h1");
357    }
358
359    #[test]
360    fn civil_from_days_known_dates() {
361        // 1970-01-01 is day 0.
362        assert_eq!(civil_from_days(0), (1970, 1, 1));
363        // 2024-03-12 is 19794 days after 1970-01-01.
364        assert_eq!(civil_from_days(19794), (2024, 3, 12));
365        // 2026-05-16 is 20589 days after 1970-01-01.
366        assert_eq!(civil_from_days(20589), (2026, 5, 16));
367    }
368
369    #[test]
370    fn format_yyyy_mm_dd_known() {
371        // 1778932800 = 2026-05-16 12:00 UTC.
372        assert_eq!(format_yyyy_mm_dd(1_778_932_800), "2026-05-16");
373        // 1710244800 = 2024-03-12 12:00 UTC.
374        assert_eq!(format_yyyy_mm_dd(1_710_244_800), "2024-03-12");
375    }
376
377    #[test]
378    fn format_created_combines_date_and_age() {
379        let now = 1_778_932_800;
380        let created = 1_710_244_800; // ~2y 2mo ago
381        let out = format_created(now, created);
382        assert!(out.starts_with("2024-03-12 ("));
383        assert!(out.ends_with(" ago)"));
384    }
385
386    // --- Boundary regression tests (added during code review) ---
387
388    #[test]
389    fn humanize_boundary_60s_is_1m_not_just_now() {
390        assert_eq!(humanize_last_use(1000, 940), "1m ago");
391    }
392
393    #[test]
394    fn humanize_boundary_exactly_1h() {
395        assert_eq!(humanize_last_use(3600, 0), "1h ago");
396    }
397
398    #[test]
399    fn humanize_boundary_exactly_7d() {
400        assert_eq!(humanize_last_use(SECS_PER_DAY * 7, 0), "1w ago");
401    }
402
403    #[test]
404    fn humanize_boundary_35d_falls_to_months() {
405        // weeks=5 short-circuits the weeks branch, so the months bucket
406        // takes over. 35 days / 30 = 1 month.
407        assert_eq!(humanize_last_use(SECS_PER_DAY * 35, 0), "1mo ago");
408    }
409
410    #[test]
411    fn prune_keeps_event_at_exactly_retention_boundary() {
412        let (_g, _paths, _lock) = setup();
413        let now = 200 * SECS_PER_DAY;
414        let mut log = KeyActivityLog::default();
415        log.events.push(ConnectEvent {
416            alias: "edge".into(),
417            ts: now - RETENTION_DAYS * SECS_PER_DAY,
418        });
419        log.prune(now);
420        assert_eq!(log.events.len(), 1);
421    }
422
423    #[test]
424    fn civil_from_days_leap_day_2000() {
425        // 2000-02-29 is 11017 days after 1970-01-01.
426        assert_eq!(civil_from_days(11016), (2000, 2, 29));
427    }
428
429    #[test]
430    fn load_corrupt_json_returns_empty_log() {
431        let (_g, paths, _lock) = setup();
432        let path = paths.key_activity();
433        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
434        std::fs::write(&path, b"not valid json {{").unwrap();
435        let log = KeyActivityLog::load(Some(&paths));
436        assert!(log.events.is_empty());
437    }
438
439    #[test]
440    fn load_corrupt_json_preserves_file_under_corrupt_suffix() {
441        let (_g, paths, _lock) = setup();
442        let path = paths.key_activity();
443        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
444        std::fs::write(&path, b"definitely not json").unwrap();
445        let _ = KeyActivityLog::load(Some(&paths));
446        // Original file must be gone.
447        assert!(!path.exists(), "corrupt file should have been renamed");
448        // A sibling with the .corrupt- suffix must exist with original bytes.
449        let preserved: Vec<_> = std::fs::read_dir(path.parent().unwrap())
450            .unwrap()
451            .filter_map(|e| e.ok())
452            .filter(|e| {
453                e.file_name()
454                    .to_string_lossy()
455                    .contains("key_activity.json.corrupt-")
456            })
457            .collect();
458        assert_eq!(preserved.len(), 1);
459        let body = std::fs::read(preserved[0].path()).unwrap();
460        assert_eq!(body, b"definitely not json");
461    }
462
463    #[test]
464    fn flush_in_demo_mode_does_not_write_file() {
465        let (_g, paths, _lock) = setup();
466        crate::demo_flag::enable();
467        let mut log = KeyActivityLog::default();
468        log.record("h", now_secs());
469        let result = log.flush(Some(&paths));
470        crate::demo_flag::disable();
471
472        assert!(result.is_ok());
473        let path = paths.key_activity();
474        assert!(
475            !path.exists(),
476            "demo mode must not write the activity log to disk"
477        );
478    }
479
480    #[test]
481    fn now_for_render_returns_demo_constant_in_demo_mode() {
482        let (_g, _paths, _lock) = setup();
483        crate::demo_flag::enable();
484        let n = now_for_render();
485        crate::demo_flag::disable();
486        assert_eq!(n, DEMO_NOW_SECS);
487    }
488
489    #[test]
490    fn now_for_render_returns_wall_clock_outside_demo() {
491        let (_g, _paths, _lock) = setup();
492        // Sanity: outside demo mode the function must NOT freeze at
493        // DEMO_NOW_SECS. Compare against now_secs() which the helper
494        // delegates to in the wall-clock branch.
495        let before = now_secs();
496        let n = now_for_render();
497        let after = now_secs();
498        assert!(n >= before && n <= after);
499    }
500
501    #[test]
502    fn timestamps_for_aliases_filters_to_matching() {
503        let mut log = KeyActivityLog::default();
504        log.events.push(ConnectEvent {
505            alias: "a".into(),
506            ts: 100,
507        });
508        log.events.push(ConnectEvent {
509            alias: "b".into(),
510            ts: 200,
511        });
512        log.events.push(ConnectEvent {
513            alias: "a".into(),
514            ts: 300,
515        });
516        let ts = log.timestamps_for_aliases(&["a".to_string()]);
517        assert_eq!(ts, vec![100, 300]);
518    }
519}