purple-ssh 3.15.2

Open-source terminal SSH manager that keeps ~/.ssh/config in sync with your cloud infra. Spin up a VM on AWS, GCP, Azure, Hetzner or 12 other cloud providers and it appears in your host list. Destroy it and the entry dims. Search hundreds of hosts, transfer files, manage Docker and Podman over SSH, sign Vault SSH certs. Rust TUI, MIT licensed.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
//! Per-host SSH connection activity log.
//!
//! Records `(alias, timestamp)` events each time purple opens an SSH
//! session, exec command or tunnel for a host. Persisted to
//! `~/.purple/key_activity.json`. The Keys tab reads this log to render
//! per-key sparklines, last-touch hints and "hosts touched in last 30d"
//! metrics by pivoting events through `SshKeyInfo::linked_hosts` at
//! render time. We log per alias rather than per key fingerprint so we
//! never have to attribute connects to a specific key file; the alias
//! mapping already encodes the link.

use std::io;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};

use log::debug;
use serde::{Deserialize, Serialize};

use crate::fs_util;

/// Retention window for events. Older rows are dropped on load and on
/// every record so the file does not grow unbounded. 90 days is the
/// longest range any rendered widget needs (30d sparkline reads the
/// most recent month, "last touch" reads the most recent of any age).
const RETENTION_DAYS: u64 = 90;

const SECS_PER_DAY: u64 = 86_400;

/// Fixed reference timestamp used by demo data seeding and by
/// render-time helpers that need a deterministic "now". Picked at the
/// cutover date so visual goldens render deterministically; the date
/// itself only matters in concert with the timestamps demo.rs seeds.
pub const DEMO_NOW_SECS: u64 = 1_778_932_800; // 2026-05-16 12:00:00 UTC

#[cfg(test)]
thread_local! {
    static PATH_OVERRIDE: std::cell::RefCell<Option<PathBuf>> =
        const { std::cell::RefCell::new(None) };
}

/// Override the activity log path. Test-only. The override is
/// thread-local so parallel test threads stay isolated.
#[cfg(test)]
pub fn set_path_override(path: PathBuf) {
    PATH_OVERRIDE.with(|p| *p.borrow_mut() = Some(path));
}

fn activity_path() -> Option<PathBuf> {
    #[cfg(test)]
    {
        PATH_OVERRIDE.with(|p| p.borrow().clone())
    }
    #[cfg(not(test))]
    {
        dirs::home_dir().map(|h| h.join(".purple/key_activity.json"))
    }
}

/// Current wall-clock epoch seconds. Demo-aware rendering uses
/// `now_for_render()` instead, which substitutes `DEMO_NOW_SECS` so
/// sparkline rendering stays byte-stable across golden runs.
pub fn now_secs() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

/// Demo-aware "now" for render-time callers. Returns the frozen
/// `DEMO_NOW_SECS` when the process is in demo mode (so visual goldens
/// land byte-stable), otherwise the wall clock. Record-time callers
/// must use `now_secs()` directly and pass the result through; mixing
/// the two would let a render-time freeze leak into persisted events.
pub fn now_for_render() -> u64 {
    if crate::demo_flag::is_demo() {
        DEMO_NOW_SECS
    } else {
        now_secs()
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ConnectEvent {
    pub alias: String,
    /// Seconds since UNIX epoch.
    pub ts: u64,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct KeyActivityLog {
    pub events: Vec<ConnectEvent>,
}

impl KeyActivityLog {
    /// Read the log from disk, pruning anything past the retention window.
    /// Missing files yield an empty log. Corrupt files are renamed aside
    /// to `<path>.corrupt-<unix_ts>` before defaulting so a future
    /// debugger can recover the data.
    pub fn load() -> Self {
        let Some(path) = activity_path() else {
            return Self::default();
        };
        match std::fs::read_to_string(&path) {
            Ok(s) => match serde_json::from_str::<Self>(&s) {
                Ok(mut log) => {
                    log.prune(now_secs());
                    log
                }
                Err(e) => {
                    let backup = path.with_extension(format!("json.corrupt-{}", now_secs()));
                    if let Err(rename_err) = std::fs::rename(&path, &backup) {
                        debug!(
                            "[purple] key_activity: parse failed and could not preserve corrupt file: parse={e} rename={rename_err}",
                        );
                    } else {
                        debug!(
                            "[purple] key_activity: parse failed, preserved corrupt file at {}: {e}",
                            backup.display(),
                        );
                    }
                    Self::default()
                }
            },
            Err(e) => {
                if e.kind() != io::ErrorKind::NotFound {
                    debug!("[purple] key_activity: read failed: {e}");
                }
                Self::default()
            }
        }
    }

    /// Append an event for `alias` at the supplied `now` timestamp.
    /// Prunes anything past the retention window using the same `now`
    /// so the prune cutoff matches the recorded event. Caller decides
    /// whether to flush; production call sites pass `now_secs()`.
    pub fn record(&mut self, alias: &str, now: u64) {
        self.events.push(ConnectEvent {
            alias: alias.to_string(),
            ts: now,
        });
        self.prune(now);
    }

    fn prune(&mut self, now: u64) {
        let cutoff = now.saturating_sub(RETENTION_DAYS * SECS_PER_DAY);
        self.events.retain(|e| e.ts >= cutoff);
    }

    /// Serialize to JSON and write atomically. Suppressed in demo mode so
    /// `--demo` never mutates the user's real activity log. Tests that
    /// exercise this path set `PATH_OVERRIDE` to redirect writes into a
    /// tempdir, so the demo check fires uniformly in production and tests.
    /// The demo-suppress branch logs intent so `--demo --verbose` shows
    /// that recording is happening, just not landing on disk.
    pub fn flush(&self) -> io::Result<()> {
        if crate::demo_flag::is_demo() {
            debug!(
                "[purple] key_activity: demo mode, skipping disk flush ({} events held in memory)",
                self.events.len(),
            );
            return Ok(());
        }
        let Some(path) = activity_path() else {
            return Ok(());
        };
        let body = serde_json::to_vec_pretty(self)
            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
        fs_util::atomic_write(&path, &body)
    }

    /// One-shot record. For non-TUI call sites (CLI mode) that do not
    /// hold an in-memory log between connects. Caller passes `now`;
    /// production CLI paths pass `now_secs()`.
    pub fn record_oneshot(alias: &str, now: u64) {
        let mut log = Self::load();
        log.record(alias, now);
        if let Err(e) = log.flush() {
            debug!("[purple] key_activity: flush failed: {e}");
        }
    }

    /// Timestamp of the most recent event whose alias appears in `aliases`.
    pub fn last_use_for_aliases(&self, aliases: &[String]) -> Option<u64> {
        let lookup = alias_set(aliases);
        self.events
            .iter()
            .filter(|e| lookup.contains(e.alias.as_str()))
            .map(|e| e.ts)
            .max()
    }

    /// All event timestamps for the given aliases, used by the shared
    /// activity chart renderer which auto-scales the time window from
    /// the oldest entry.
    pub fn timestamps_for_aliases(&self, aliases: &[String]) -> Vec<u64> {
        let lookup = alias_set(aliases);
        self.events
            .iter()
            .filter(|e| lookup.contains(e.alias.as_str()))
            .map(|e| e.ts)
            .collect()
    }
}

/// Field-disjoint helper: record + flush the activity log without
/// holding `&mut App`. Lets the event loop record a connect while
/// another sub-state (FileBrowser, TunnelState) still holds a mutable
/// borrow on `App`, where the `App::record_key_use` method would be
/// rejected by the borrow checker. Caller passes `now`; production
/// call sites pass `now_secs()`.
pub fn record_and_flush(log: &mut KeyActivityLog, alias: &str, now: u64) {
    log.record(alias, now);
    if let Err(e) = log.flush() {
        debug!("[purple] key_activity: flush failed: {e}");
    }
}

/// Build a `HashSet<&str>` lookup from an alias slice. Used once per
/// query so per-event membership check is O(1) instead of O(aliases).
fn alias_set(aliases: &[String]) -> std::collections::HashSet<&str> {
    aliases.iter().map(String::as_str).collect()
}

/// Format the gap between `now` and `ts` as a compact `Nu ago` label
/// (`N` count, `u` unit). Mirrors the rhythm Linear / GitHub use:
/// `just now`, `14m ago`, `3h ago`, `2d ago`, `3w ago`, `2mo ago`,
/// `1y ago`.
pub fn humanize_last_use(now: u64, ts: u64) -> String {
    let diff = now.saturating_sub(ts);
    if diff < 60 {
        return "just now".to_string();
    }
    let minutes = diff / 60;
    if minutes < 60 {
        return format!("{minutes}m ago");
    }
    let hours = minutes / 60;
    if hours < 24 {
        return format!("{hours}h ago");
    }
    let days = hours / 24;
    if days < 7 {
        return format!("{days}d ago");
    }
    let weeks = days / 7;
    if weeks < 5 {
        return format!("{weeks}w ago");
    }
    let months = days / 30;
    if months < 12 {
        return format!("{months}mo ago");
    }
    let years = days / 365;
    format!("{years}y ago")
}

/// Format a file mtime as `YYYY-MM-DD (<age> ago)` for the Created
/// label. Uses `humanize_last_use` for the age tail so the rhythm
/// matches the Last touch tile.
pub fn format_created(now: u64, mtime_ts: u64) -> String {
    let date = format_yyyy_mm_dd(mtime_ts);
    let age = humanize_last_use(now, mtime_ts);
    format!("{date} ({age})")
}

/// `YYYY-MM-DD` from a UNIX timestamp using the proleptic Gregorian
/// calendar. Avoids pulling in `chrono` just for one date format.
fn format_yyyy_mm_dd(ts: u64) -> String {
    let days_since_epoch = (ts / SECS_PER_DAY) as i64;
    let (y, m, d) = civil_from_days(days_since_epoch);
    format!("{:04}-{:02}-{:02}", y, m, d)
}

/// Convert "days since 1970-01-01" to proleptic Gregorian (year, month,
/// day). Algorithm from Howard Hinnant's date library; bounded constant
/// time, no allocations, works for any reasonable timestamp.
fn civil_from_days(z: i64) -> (i32, u32, u32) {
    let z = z + 719_468;
    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
    let doe = (z - era * 146_097) as u64;
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
    let y = yoe as i64 + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = y + if m <= 2 { 1 } else { 0 };
    (y as i32, m as u32, d as u32)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Cross-crate lock: shares `demo_flag::GLOBAL_TEST_LOCK` with the
    /// preferences and visual regression suites. `now_secs()` no longer
    /// touches the demo flag, but `flush()` still early-returns when
    /// demo mode is active, so a concurrent test flipping the flag
    /// between `record()` and `flush()` would silently suppress the
    /// write. The mutex serialises every test that exercises that path.
    fn setup() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
        let guard = crate::demo_flag::GLOBAL_TEST_LOCK
            .lock()
            .unwrap_or_else(|p| p.into_inner());
        let dir = tempfile::tempdir().expect("tempdir");
        set_path_override(dir.path().join("key_activity.json"));
        (dir, guard)
    }

    #[test]
    fn record_appends_event() {
        let (_g, _lock) = setup();
        let mut log = KeyActivityLog::default();
        log.record("prod-eu1", now_secs());
        assert_eq!(log.events.len(), 1);
        assert_eq!(log.events[0].alias, "prod-eu1");
    }

    #[test]
    fn record_prunes_events_past_retention() {
        let (_g, _lock) = setup();
        let mut log = KeyActivityLog::default();
        let now = now_secs();
        let very_old = now - (RETENTION_DAYS + 10) * SECS_PER_DAY;
        log.events.push(ConnectEvent {
            alias: "ancient".into(),
            ts: very_old,
        });
        log.record("fresh", now);
        assert_eq!(log.events.len(), 1);
        assert_eq!(log.events[0].alias, "fresh");
    }

    #[test]
    fn load_after_flush_roundtrips() {
        let (_g, _lock) = setup();
        let mut log = KeyActivityLog::default();
        let now = now_secs();
        log.record("eric-bastion", now);
        log.record("aws-api-prod", now);
        log.flush().unwrap();
        let reloaded = KeyActivityLog::load();
        assert_eq!(reloaded.events.len(), 2);
    }

    #[test]
    fn load_missing_file_returns_default() {
        let (_g, _lock) = setup();
        let log = KeyActivityLog::load();
        assert!(log.events.is_empty());
    }

    #[test]
    fn last_use_returns_most_recent() {
        let (_g, _lock) = setup();
        let mut log = KeyActivityLog::default();
        log.events.push(ConnectEvent {
            alias: "h".into(),
            ts: 100,
        });
        log.events.push(ConnectEvent {
            alias: "h".into(),
            ts: 500,
        });
        log.events.push(ConnectEvent {
            alias: "h".into(),
            ts: 300,
        });
        let aliases = vec!["h".to_string()];
        assert_eq!(log.last_use_for_aliases(&aliases), Some(500));
    }

    #[test]
    fn last_use_none_for_no_matches() {
        let (_g, _lock) = setup();
        let log = KeyActivityLog::default();
        let aliases = vec!["nobody".to_string()];
        assert!(log.last_use_for_aliases(&aliases).is_none());
    }

    #[test]
    fn humanize_last_use_buckets() {
        assert_eq!(humanize_last_use(1000, 999), "just now");
        assert_eq!(humanize_last_use(1000, 600), "6m ago");
        assert_eq!(humanize_last_use(SECS_PER_DAY * 2, 0), "2d ago");
        assert_eq!(humanize_last_use(SECS_PER_DAY * 14, 0), "2w ago");
        assert_eq!(humanize_last_use(SECS_PER_DAY * 60, 0), "2mo ago");
        assert_eq!(humanize_last_use(SECS_PER_DAY * 400, 0), "1y ago");
    }

    #[test]
    fn record_oneshot_persists_to_disk() {
        let (_g, _lock) = setup();
        KeyActivityLog::record_oneshot("h1", now_secs());
        let reloaded = KeyActivityLog::load();
        assert_eq!(reloaded.events.len(), 1);
        assert_eq!(reloaded.events[0].alias, "h1");
    }

    #[test]
    fn civil_from_days_known_dates() {
        // 1970-01-01 is day 0.
        assert_eq!(civil_from_days(0), (1970, 1, 1));
        // 2024-03-12 is 19794 days after 1970-01-01.
        assert_eq!(civil_from_days(19794), (2024, 3, 12));
        // 2026-05-16 is 20589 days after 1970-01-01.
        assert_eq!(civil_from_days(20589), (2026, 5, 16));
    }

    #[test]
    fn format_yyyy_mm_dd_known() {
        // 1778932800 = 2026-05-16 12:00 UTC.
        assert_eq!(format_yyyy_mm_dd(1_778_932_800), "2026-05-16");
        // 1710244800 = 2024-03-12 12:00 UTC.
        assert_eq!(format_yyyy_mm_dd(1_710_244_800), "2024-03-12");
    }

    #[test]
    fn format_created_combines_date_and_age() {
        let now = 1_778_932_800;
        let created = 1_710_244_800; // ~2y 2mo ago
        let out = format_created(now, created);
        assert!(out.starts_with("2024-03-12 ("));
        assert!(out.ends_with(" ago)"));
    }

    // --- Boundary regression tests (added during code review) ---

    #[test]
    fn humanize_boundary_60s_is_1m_not_just_now() {
        assert_eq!(humanize_last_use(1000, 940), "1m ago");
    }

    #[test]
    fn humanize_boundary_exactly_1h() {
        assert_eq!(humanize_last_use(3600, 0), "1h ago");
    }

    #[test]
    fn humanize_boundary_exactly_7d() {
        assert_eq!(humanize_last_use(SECS_PER_DAY * 7, 0), "1w ago");
    }

    #[test]
    fn humanize_boundary_35d_falls_to_months() {
        // weeks=5 short-circuits the weeks branch, so the months bucket
        // takes over. 35 days / 30 = 1 month.
        assert_eq!(humanize_last_use(SECS_PER_DAY * 35, 0), "1mo ago");
    }

    #[test]
    fn prune_keeps_event_at_exactly_retention_boundary() {
        let (_g, _lock) = setup();
        let now = 200 * SECS_PER_DAY;
        let mut log = KeyActivityLog::default();
        log.events.push(ConnectEvent {
            alias: "edge".into(),
            ts: now - RETENTION_DAYS * SECS_PER_DAY,
        });
        log.prune(now);
        assert_eq!(log.events.len(), 1);
    }

    #[test]
    fn civil_from_days_leap_day_2000() {
        // 2000-02-29 is 11017 days after 1970-01-01.
        assert_eq!(civil_from_days(11016), (2000, 2, 29));
    }

    #[test]
    fn load_corrupt_json_returns_empty_log() {
        let (g, _lock) = setup();
        let path = g.path().join("key_activity.json");
        std::fs::write(&path, b"not valid json {{").unwrap();
        let log = KeyActivityLog::load();
        assert!(log.events.is_empty());
    }

    #[test]
    fn load_corrupt_json_preserves_file_under_corrupt_suffix() {
        let (g, _lock) = setup();
        let path = g.path().join("key_activity.json");
        std::fs::write(&path, b"definitely not json").unwrap();
        let _ = KeyActivityLog::load();
        // Original file must be gone.
        assert!(!path.exists(), "corrupt file should have been renamed");
        // A sibling with the .corrupt- suffix must exist with original bytes.
        let preserved: Vec<_> = std::fs::read_dir(g.path())
            .unwrap()
            .filter_map(|e| e.ok())
            .filter(|e| {
                e.file_name()
                    .to_string_lossy()
                    .contains("key_activity.json.corrupt-")
            })
            .collect();
        assert_eq!(preserved.len(), 1);
        let body = std::fs::read(preserved[0].path()).unwrap();
        assert_eq!(body, b"definitely not json");
    }

    #[test]
    fn flush_in_demo_mode_does_not_write_file() {
        let (g, _lock) = setup();
        crate::demo_flag::enable();
        let mut log = KeyActivityLog::default();
        log.record("h", now_secs());
        let result = log.flush();
        crate::demo_flag::disable();

        assert!(result.is_ok());
        let path = g.path().join("key_activity.json");
        assert!(
            !path.exists(),
            "demo mode must not write the activity log to disk"
        );
    }

    #[test]
    fn now_for_render_returns_demo_constant_in_demo_mode() {
        let (_g, _lock) = setup();
        crate::demo_flag::enable();
        let n = now_for_render();
        crate::demo_flag::disable();
        assert_eq!(n, DEMO_NOW_SECS);
    }

    #[test]
    fn now_for_render_returns_wall_clock_outside_demo() {
        let (_g, _lock) = setup();
        // Sanity: outside demo mode the function must NOT freeze at
        // DEMO_NOW_SECS. Compare against now_secs() which the helper
        // delegates to in the wall-clock branch.
        let before = now_secs();
        let n = now_for_render();
        let after = now_secs();
        assert!(n >= before && n <= after);
    }

    #[test]
    fn timestamps_for_aliases_filters_to_matching() {
        let mut log = KeyActivityLog::default();
        log.events.push(ConnectEvent {
            alias: "a".into(),
            ts: 100,
        });
        log.events.push(ConnectEvent {
            alias: "b".into(),
            ts: 200,
        });
        log.events.push(ConnectEvent {
            alias: "a".into(),
            ts: 300,
        });
        let ts = log.timestamps_for_aliases(&["a".to_string()]);
        assert_eq!(ts, vec![100, 300]);
    }
}