use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tirith_core::persistence::{self, PersistenceEntry, PersistenceFinding, PersistenceSnapshot};
use tirith_core::verdict::Severity;
use super::write_json_stdout;
pub fn scan(json: bool) -> i32 {
let entries = persistence::scan();
let snapshot = PersistenceSnapshot::from_entries(&entries);
let snapshot_note = persist_snapshot(&snapshot);
if json {
let body = scan_json_body(&entries, &snapshot_note);
if !write_json_stdout(
&body,
"tirith persistence scan: failed to write JSON output",
) {
return 1;
}
} else {
print_human_scan(&entries, &snapshot_note);
}
0
}
pub fn diff(json: bool) -> i32 {
let path = match persistence::snapshot_path() {
Some(p) => p,
None => {
eprintln!(
"tirith persistence diff: cannot resolve state dir (no HOME / XDG_STATE_HOME)."
);
return 1;
}
};
let snapshot = persistence::load_snapshot(&path);
let has_baseline = !snapshot.entries.is_empty();
let findings = run_diff(&snapshot);
let any_high = findings.iter().any(PersistenceFinding::is_high);
if json {
let body = diff_json_body(&findings, has_baseline);
if !write_json_stdout(
&body,
"tirith persistence diff: failed to write JSON output",
) {
return 1;
}
} else {
print_human_diff(&findings, has_baseline, &path);
}
if any_high {
1
} else {
0
}
}
pub fn watch(interval: u64, json: bool) -> i32 {
let path = match persistence::snapshot_path() {
Some(p) => p,
None => {
eprintln!(
"tirith persistence watch: cannot resolve state dir (no HOME / XDG_STATE_HOME)."
);
return 1;
}
};
let interval = interval.max(1);
install_sigint_handler();
if !json {
eprintln!(
"tirith persistence watch: polling every {interval}s (Ctrl-C to stop). \
Baseline: {}",
path.display()
);
}
let mut snapshot = persistence::load_snapshot(&path);
if snapshot.entries.is_empty() {
snapshot = PersistenceSnapshot::from_entries(&persistence::scan());
let _ = persistence::save_snapshot(&path, &snapshot);
}
let mut polls: u64 = 0;
while !STOP.load(Ordering::Relaxed) {
let mut slept = Duration::ZERO;
let step = Duration::from_millis(200);
let target = Duration::from_secs(interval);
while slept < target && !STOP.load(Ordering::Relaxed) {
std::thread::sleep(step);
slept += step;
}
if STOP.load(Ordering::Relaxed) {
break;
}
polls += 1;
let current = persistence::scan();
let findings = persistence::diff_entries(¤t, &snapshot);
let mut emitted = true;
if !findings.is_empty() {
if json {
let body = watch_poll_json_body(polls, &findings);
emitted =
write_json_stdout(&body, "tirith persistence watch: failed to write JSON");
} else {
print_human_watch_poll(polls, &findings);
}
}
if emitted {
snapshot = PersistenceSnapshot::from_entries(¤t);
let _ = persistence::save_snapshot(&path, &snapshot);
}
}
if !json {
eprintln!("\ntirith persistence watch: stopped after {polls} poll(s).");
}
0
}
fn persist_snapshot(snapshot: &PersistenceSnapshot) -> String {
match persistence::snapshot_path() {
Some(path) => match persistence::save_snapshot(&path, snapshot) {
Ok(()) => format!("baseline recorded at {}", path.display()),
Err(e) => format!(
"WARNING: failed to record baseline at {}: {e}",
path.display()
),
},
None => {
"WARNING: no state dir resolvable (no HOME / XDG_STATE_HOME); baseline not recorded"
.to_string()
}
}
}
fn run_diff(snapshot: &PersistenceSnapshot) -> Vec<PersistenceFinding> {
let current = persistence::scan();
persistence::diff_entries(¤t, snapshot)
}
static STOP: AtomicBool = AtomicBool::new(false);
#[cfg(unix)]
fn install_sigint_handler() {
extern "C" fn handle(_sig: libc::c_int) {
STOP.store(true, Ordering::Relaxed);
}
unsafe {
libc::signal(libc::SIGINT, handle as *const () as libc::sighandler_t);
}
}
#[cfg(not(unix))]
fn install_sigint_handler() {}
fn print_human_scan(entries: &[PersistenceEntry], snapshot_note: &str) {
let present = entries.iter().filter(|e| e.present).count();
eprintln!(
"tirith persistence: {} surface(s) inventoried ({present} present).\n",
entries.len()
);
for e in entries {
let state = if e.present { "present" } else { "absent " };
eprintln!(
" [{state}] {:<16} {}\n sha256: {}",
e.kind.as_str(),
super::sanitize_for_human_output(&e.location, false),
e.sha256,
);
}
eprintln!("\n{snapshot_note}");
eprintln!("Run `tirith persistence diff` later to see what changed since this baseline.");
}
fn print_human_diff(findings: &[PersistenceFinding], has_baseline: bool, path: &Path) {
if !has_baseline {
eprintln!(
"tirith persistence diff: no baseline snapshot found at {}.\n\
Run `tirith persistence scan` first to record one.",
path.display()
);
return;
}
if findings.is_empty() {
eprintln!("tirith persistence: no changes since the recorded baseline.");
return;
}
let high = findings.iter().filter(|f| f.is_high()).count();
eprintln!(
"tirith persistence: {} change(s) since baseline ({high} high).\n",
findings.len()
);
for f in findings {
print_one_finding(f);
}
eprintln!("Re-run `tirith persistence scan` to accept the current state as the new baseline.");
}
fn print_human_watch_poll(poll: u64, findings: &[PersistenceFinding]) {
eprintln!("\n[poll #{poll}] {} change(s) detected:", findings.len());
for f in findings {
print_one_finding(f);
}
}
fn print_one_finding(f: &PersistenceFinding) {
eprintln!(
" [{}] {} ({})\n surface: {}\n change: {}",
severity_label(f.severity),
f.rule_id,
f.kind.as_str(),
super::sanitize_for_human_output(&f.location, false),
super::sanitize_for_human_output(&f.change, false),
);
if f.added_lines.is_empty() {
eprintln!(" added: (no line content — tracked by hash)\n");
} else {
eprintln!(" added lines (credential-redacted):");
for line in &f.added_lines {
eprintln!(
" + {}",
super::sanitize_for_human_output(line, false)
);
}
eprintln!();
}
}
fn severity_label(sev: Severity) -> &'static str {
match sev {
Severity::Info => "INFO",
Severity::Low => "LOW",
Severity::Medium => "MEDIUM",
Severity::High => "HIGH",
Severity::Critical => "CRITICAL",
}
}
fn scan_json_body(entries: &[PersistenceEntry], snapshot_note: &str) -> serde_json::Value {
let rows: Vec<serde_json::Value> = entries
.iter()
.map(|e| {
serde_json::json!({
"key": e.key,
"kind": e.kind.as_str(),
"location": e.location,
"present": e.present,
"sha256": e.sha256,
"size": e.size,
})
})
.collect();
serde_json::json!({
"schema_version": 1,
"total": entries.len(),
"present": entries.iter().filter(|e| e.present).count(),
"surfaces": rows,
"baseline": snapshot_note,
})
}
fn diff_json_body(findings: &[PersistenceFinding], has_baseline: bool) -> serde_json::Value {
let high = findings.iter().filter(|f| f.is_high()).count();
serde_json::json!({
"schema_version": 1,
"has_baseline": has_baseline,
"total": findings.len(),
"high_or_critical": high,
"findings": findings,
})
}
fn watch_poll_json_body(poll: u64, findings: &[PersistenceFinding]) -> serde_json::Value {
serde_json::json!({
"schema_version": 1,
"event": "poll",
"poll": poll,
"total": findings.len(),
"findings": findings,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scan_json_body_emits_fingerprint_only() {
let entries = vec![PersistenceEntry {
key: "shell_rc:.zshrc".to_string(),
kind: tirith_core::persistence::PersistenceKind::ShellRc,
location: "/home/u/.zshrc".to_string(),
present: true,
sha256: "abc".to_string(),
size: 10,
content: "export SECRET=hunter2\n".to_string(),
}];
let body = scan_json_body(&entries, "baseline recorded");
assert_eq!(body["total"], 1);
assert_eq!(body["present"], 1);
let surfaces = body["surfaces"].as_array().unwrap();
let row = &surfaces[0];
assert_eq!(row["sha256"], "abc");
assert!(row.get("content").is_none());
let serialized = serde_json::to_string(&body).unwrap();
assert!(!serialized.contains("hunter2"));
}
#[test]
fn diff_json_body_counts_high() {
let body = diff_json_body(&[], true);
assert_eq!(body["total"], 0);
assert_eq!(body["high_or_critical"], 0);
assert_eq!(body["has_baseline"], true);
assert!(body["findings"].is_array());
}
#[test]
fn watch_poll_json_body_shape() {
let body = watch_poll_json_body(3, &[]);
assert_eq!(body["event"], "poll");
assert_eq!(body["poll"], 3);
assert!(body["findings"].is_array());
}
}