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