1use 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
22const RETENTION_DAYS: u64 = 90;
27
28const SECS_PER_DAY: u64 = 86_400;
29
30pub const DEMO_NOW_SECS: u64 = 1_778_932_800; fn activity_path(paths: Option<&Paths>) -> Option<PathBuf> {
37 paths.map(Paths::key_activity)
38}
39
40pub fn now_secs() -> u64 {
44 SystemTime::now()
45 .duration_since(UNIX_EPOCH)
46 .map(|d| d.as_secs())
47 .unwrap_or(0)
48}
49
50pub 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 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 pub fn load(paths: Option<&Paths>) -> Self {
81 let Some(path) = activity_path(paths) else {
82 return Self::default();
83 };
84 match std::fs::read_to_string(&path) {
85 Ok(s) => match serde_json::from_str::<Self>(&s) {
86 Ok(mut log) => {
87 log.prune(now_secs());
88 log
89 }
90 Err(e) => {
91 let backup = path.with_extension(format!("json.corrupt-{}", now_secs()));
92 if let Err(rename_err) = std::fs::rename(&path, &backup) {
93 debug!(
94 "[purple] key_activity: parse failed and could not preserve corrupt file: parse={e} rename={rename_err}",
95 );
96 } else {
97 debug!(
98 "[purple] key_activity: parse failed, preserved corrupt file at {}: {e}",
99 backup.display(),
100 );
101 }
102 Self::default()
103 }
104 },
105 Err(e) => {
106 if e.kind() != io::ErrorKind::NotFound {
107 debug!("[purple] key_activity: read failed: {e}");
108 }
109 Self::default()
110 }
111 }
112 }
113
114 pub fn record(&mut self, alias: &str, now: u64) {
119 self.events.push(ConnectEvent {
120 alias: alias.to_string(),
121 ts: now,
122 });
123 self.prune(now);
124 }
125
126 fn prune(&mut self, now: u64) {
127 let cutoff = now.saturating_sub(RETENTION_DAYS * SECS_PER_DAY);
128 self.events.retain(|e| e.ts >= cutoff);
129 }
130
131 pub fn flush(&self, paths: Option<&Paths>) -> io::Result<()> {
138 if crate::demo_flag::is_demo() {
139 debug!(
140 "[purple] key_activity: demo mode, skipping disk flush ({} events held in memory)",
141 self.events.len(),
142 );
143 return Ok(());
144 }
145 let Some(path) = activity_path(paths) else {
146 return Ok(());
147 };
148 let body = serde_json::to_vec_pretty(self)
149 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
150 fs_util::atomic_write(&path, &body)
151 }
152
153 pub fn record_oneshot(alias: &str, now: u64, paths: Option<&Paths>) {
157 let mut log = Self::load(paths);
158 log.record(alias, now);
159 if let Err(e) = log.flush(paths) {
160 debug!("[purple] key_activity: flush failed: {e}");
161 }
162 }
163
164 pub fn last_use_for_aliases(&self, aliases: &[String]) -> Option<u64> {
166 let lookup = alias_set(aliases);
167 self.events
168 .iter()
169 .filter(|e| lookup.contains(e.alias.as_str()))
170 .map(|e| e.ts)
171 .max()
172 }
173
174 pub fn timestamps_for_aliases(&self, aliases: &[String]) -> Vec<u64> {
178 let lookup = alias_set(aliases);
179 self.events
180 .iter()
181 .filter(|e| lookup.contains(e.alias.as_str()))
182 .map(|e| e.ts)
183 .collect()
184 }
185}
186
187pub fn record_and_flush(log: &mut KeyActivityLog, alias: &str, now: u64, paths: Option<&Paths>) {
194 log.record(alias, now);
195 if let Err(e) = log.flush(paths) {
196 debug!("[purple] key_activity: flush failed: {e}");
197 }
198}
199
200fn alias_set(aliases: &[String]) -> std::collections::HashSet<&str> {
203 aliases.iter().map(String::as_str).collect()
204}
205
206pub fn humanize_last_use(now: u64, ts: u64) -> String {
211 let diff = now.saturating_sub(ts);
212 if diff < 60 {
213 return "just now".to_string();
214 }
215 let minutes = diff / 60;
216 if minutes < 60 {
217 return format!("{minutes}m ago");
218 }
219 let hours = minutes / 60;
220 if hours < 24 {
221 return format!("{hours}h ago");
222 }
223 let days = hours / 24;
224 if days < 7 {
225 return format!("{days}d ago");
226 }
227 let weeks = days / 7;
228 if weeks < 5 {
229 return format!("{weeks}w ago");
230 }
231 let months = days / 30;
232 if months < 12 {
233 return format!("{months}mo ago");
234 }
235 let years = days / 365;
236 format!("{years}y ago")
237}
238
239pub fn format_created(now: u64, mtime_ts: u64) -> String {
243 let date = format_yyyy_mm_dd(mtime_ts);
244 let age = humanize_last_use(now, mtime_ts);
245 format!("{date} ({age})")
246}
247
248fn format_yyyy_mm_dd(ts: u64) -> String {
251 let days_since_epoch = (ts / SECS_PER_DAY) as i64;
252 let (y, m, d) = civil_from_days(days_since_epoch);
253 format!("{:04}-{:02}-{:02}", y, m, d)
254}
255
256fn civil_from_days(z: i64) -> (i32, u32, u32) {
260 let z = z + 719_468;
261 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
262 let doe = (z - era * 146_097) as u64;
263 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
264 let y = yoe as i64 + era * 400;
265 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
266 let mp = (5 * doy + 2) / 153;
267 let d = doy - (153 * mp + 2) / 5 + 1;
268 let m = if mp < 10 { mp + 3 } else { mp - 9 };
269 let y = y + if m <= 2 { 1 } else { 0 };
270 (y as i32, m as u32, d as u32)
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 fn setup() -> (tempfile::TempDir, Paths, std::sync::MutexGuard<'static, ()>) {
284 let guard = crate::demo_flag::GLOBAL_TEST_LOCK
285 .lock()
286 .unwrap_or_else(|p| p.into_inner());
287 let dir = tempfile::tempdir().expect("tempdir");
288 let paths = Paths::new(dir.path());
289 (dir, paths, guard)
290 }
291
292 #[test]
293 fn record_appends_event() {
294 let (_g, _paths, _lock) = setup();
295 let mut log = KeyActivityLog::default();
296 log.record("prod-eu1", now_secs());
297 assert_eq!(log.events.len(), 1);
298 assert_eq!(log.events[0].alias, "prod-eu1");
299 }
300
301 #[test]
302 fn record_prunes_events_past_retention() {
303 let (_g, _paths, _lock) = setup();
304 let mut log = KeyActivityLog::default();
305 let now = now_secs();
306 let very_old = now - (RETENTION_DAYS + 10) * SECS_PER_DAY;
307 log.events.push(ConnectEvent {
308 alias: "ancient".into(),
309 ts: very_old,
310 });
311 log.record("fresh", now);
312 assert_eq!(log.events.len(), 1);
313 assert_eq!(log.events[0].alias, "fresh");
314 }
315
316 #[test]
317 fn load_after_flush_roundtrips() {
318 let (_g, paths, _lock) = setup();
319 let mut log = KeyActivityLog::default();
320 let now = now_secs();
321 log.record("eric-bastion", now);
322 log.record("aws-api-prod", now);
323 log.flush(Some(&paths)).unwrap();
324 let reloaded = KeyActivityLog::load(Some(&paths));
325 assert_eq!(reloaded.events.len(), 2);
326 }
327
328 #[test]
329 fn load_missing_file_returns_default() {
330 let (_g, paths, _lock) = setup();
331 let log = KeyActivityLog::load(Some(&paths));
332 assert!(log.events.is_empty());
333 }
334
335 #[test]
336 fn last_use_returns_most_recent() {
337 let (_g, _paths, _lock) = setup();
338 let mut log = KeyActivityLog::default();
339 log.events.push(ConnectEvent {
340 alias: "h".into(),
341 ts: 100,
342 });
343 log.events.push(ConnectEvent {
344 alias: "h".into(),
345 ts: 500,
346 });
347 log.events.push(ConnectEvent {
348 alias: "h".into(),
349 ts: 300,
350 });
351 let aliases = vec!["h".to_string()];
352 assert_eq!(log.last_use_for_aliases(&aliases), Some(500));
353 }
354
355 #[test]
356 fn last_use_none_for_no_matches() {
357 let (_g, _paths, _lock) = setup();
358 let log = KeyActivityLog::default();
359 let aliases = vec!["nobody".to_string()];
360 assert!(log.last_use_for_aliases(&aliases).is_none());
361 }
362
363 #[test]
364 fn humanize_last_use_buckets() {
365 assert_eq!(humanize_last_use(1000, 999), "just now");
366 assert_eq!(humanize_last_use(1000, 600), "6m ago");
367 assert_eq!(humanize_last_use(SECS_PER_DAY * 2, 0), "2d ago");
368 assert_eq!(humanize_last_use(SECS_PER_DAY * 14, 0), "2w ago");
369 assert_eq!(humanize_last_use(SECS_PER_DAY * 60, 0), "2mo ago");
370 assert_eq!(humanize_last_use(SECS_PER_DAY * 400, 0), "1y ago");
371 }
372
373 #[test]
374 fn record_oneshot_persists_to_disk() {
375 let (_g, paths, _lock) = setup();
376 KeyActivityLog::record_oneshot("h1", now_secs(), Some(&paths));
377 let reloaded = KeyActivityLog::load(Some(&paths));
378 assert_eq!(reloaded.events.len(), 1);
379 assert_eq!(reloaded.events[0].alias, "h1");
380 }
381
382 #[test]
383 fn civil_from_days_known_dates() {
384 assert_eq!(civil_from_days(0), (1970, 1, 1));
386 assert_eq!(civil_from_days(19794), (2024, 3, 12));
388 assert_eq!(civil_from_days(20589), (2026, 5, 16));
390 }
391
392 #[test]
393 fn format_yyyy_mm_dd_known() {
394 assert_eq!(format_yyyy_mm_dd(1_778_932_800), "2026-05-16");
396 assert_eq!(format_yyyy_mm_dd(1_710_244_800), "2024-03-12");
398 }
399
400 #[test]
401 fn format_created_combines_date_and_age() {
402 let now = 1_778_932_800;
403 let created = 1_710_244_800; let out = format_created(now, created);
405 assert!(out.starts_with("2024-03-12 ("));
406 assert!(out.ends_with(" ago)"));
407 }
408
409 #[test]
412 fn humanize_boundary_60s_is_1m_not_just_now() {
413 assert_eq!(humanize_last_use(1000, 940), "1m ago");
414 }
415
416 #[test]
417 fn humanize_boundary_exactly_1h() {
418 assert_eq!(humanize_last_use(3600, 0), "1h ago");
419 }
420
421 #[test]
422 fn humanize_boundary_exactly_7d() {
423 assert_eq!(humanize_last_use(SECS_PER_DAY * 7, 0), "1w ago");
424 }
425
426 #[test]
427 fn humanize_boundary_35d_falls_to_months() {
428 assert_eq!(humanize_last_use(SECS_PER_DAY * 35, 0), "1mo ago");
431 }
432
433 #[test]
434 fn prune_keeps_event_at_exactly_retention_boundary() {
435 let (_g, _paths, _lock) = setup();
436 let now = 200 * SECS_PER_DAY;
437 let mut log = KeyActivityLog::default();
438 log.events.push(ConnectEvent {
439 alias: "edge".into(),
440 ts: now - RETENTION_DAYS * SECS_PER_DAY,
441 });
442 log.prune(now);
443 assert_eq!(log.events.len(), 1);
444 }
445
446 #[test]
447 fn civil_from_days_leap_day_2000() {
448 assert_eq!(civil_from_days(11016), (2000, 2, 29));
450 }
451
452 #[test]
453 fn load_corrupt_json_returns_empty_log() {
454 let (_g, paths, _lock) = setup();
455 let path = paths.key_activity();
456 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
457 std::fs::write(&path, b"not valid json {{").unwrap();
458 let log = KeyActivityLog::load(Some(&paths));
459 assert!(log.events.is_empty());
460 }
461
462 #[test]
463 fn load_corrupt_json_preserves_file_under_corrupt_suffix() {
464 let (_g, paths, _lock) = setup();
465 let path = paths.key_activity();
466 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
467 std::fs::write(&path, b"definitely not json").unwrap();
468 let _ = KeyActivityLog::load(Some(&paths));
469 assert!(!path.exists(), "corrupt file should have been renamed");
471 let preserved: Vec<_> = std::fs::read_dir(path.parent().unwrap())
473 .unwrap()
474 .filter_map(|e| e.ok())
475 .filter(|e| {
476 e.file_name()
477 .to_string_lossy()
478 .contains("key_activity.json.corrupt-")
479 })
480 .collect();
481 assert_eq!(preserved.len(), 1);
482 let body = std::fs::read(preserved[0].path()).unwrap();
483 assert_eq!(body, b"definitely not json");
484 }
485
486 #[test]
487 fn flush_in_demo_mode_does_not_write_file() {
488 let (_g, paths, _lock) = setup();
489 crate::demo_flag::enable();
490 let mut log = KeyActivityLog::default();
491 log.record("h", now_secs());
492 let result = log.flush(Some(&paths));
493 crate::demo_flag::disable();
494
495 assert!(result.is_ok());
496 let path = paths.key_activity();
497 assert!(
498 !path.exists(),
499 "demo mode must not write the activity log to disk"
500 );
501 }
502
503 #[test]
504 fn now_for_render_returns_demo_constant_in_demo_mode() {
505 let (_g, _paths, _lock) = setup();
506 crate::demo_flag::enable();
507 let n = now_for_render();
508 crate::demo_flag::disable();
509 assert_eq!(n, DEMO_NOW_SECS);
510 }
511
512 #[test]
513 fn now_for_render_returns_wall_clock_outside_demo() {
514 let (_g, _paths, _lock) = setup();
515 let before = now_secs();
519 let n = now_for_render();
520 let after = now_secs();
521 assert!(n >= before && n <= after);
522 }
523
524 #[test]
525 fn timestamps_for_aliases_filters_to_matching() {
526 let mut log = KeyActivityLog::default();
527 log.events.push(ConnectEvent {
528 alias: "a".into(),
529 ts: 100,
530 });
531 log.events.push(ConnectEvent {
532 alias: "b".into(),
533 ts: 200,
534 });
535 log.events.push(ConnectEvent {
536 alias: "a".into(),
537 ts: 300,
538 });
539 let ts = log.timestamps_for_aliases(&["a".to_string()]);
540 assert_eq!(ts, vec![100, 300]);
541 }
542}