use crate::args::WatchArgs;
use anyhow::{Context, Result};
use keyhog_core::{Chunk, ChunkMetadata};
use keyhog_scanner::CompiledScanner;
use notify::{Event, EventKind, RecursiveMode, Watcher};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::mpsc::channel;
use std::time::{Duration, Instant};
const DEDUP_WINDOW: Duration = Duration::from_millis(750);
pub fn run(args: WatchArgs) -> Result<()> {
let watch_root = std::fs::canonicalize(&args.path)
.with_context(|| format!("canonicalize {}", args.path.display()))?;
if !watch_root.is_dir() {
anyhow::bail!(
"watch path '{}' is not a directory. \
Fix: pass a directory to monitor, or run `keyhog scan {}` for a one-shot file scan.",
watch_root.display(),
watch_root.display()
);
}
let detectors = crate::orchestrator_config::load_detectors_or_embedded(&args.detectors)?;
let detector_count = detectors.len();
let scanner = CompiledScanner::compile(detectors)
.map_err(|e| anyhow::anyhow!("scanner compile failed: {e:?}"))?;
if !args.quiet {
eprintln!(
"\u{1F441} keyhog watch (\u{2630} {} detectors compiled)",
detector_count
);
eprintln!(" watching: {}", watch_root.display());
eprintln!(" Ctrl-C to exit");
eprintln!();
}
let (tx, rx) = channel::<notify::Result<Event>>();
let mut watcher = notify::recommended_watcher(move |res| {
let _ = tx.send(res);
})
.map_err(|e| anyhow::anyhow!("failed to build filesystem watcher: {e}"))?;
watcher
.watch(&watch_root, RecursiveMode::Recursive)
.map_err(|e| anyhow::anyhow!("failed to watch {}: {e}", watch_root.display()))?;
let mut recently_scanned: HashMap<PathBuf, (Instant, u64)> = HashMap::new();
for event in rx {
let event = match event {
Ok(e) => e,
Err(e) => {
tracing::warn!("watcher error: {e}");
continue;
}
};
let interesting = matches!(event.kind, EventKind::Modify(_) | EventKind::Create(_));
if !interesting {
continue;
}
for path in event.paths {
if path.is_dir() || should_skip(&path) {
continue;
}
scan_file(&scanner, &path, &mut recently_scanned);
}
}
Ok(())
}
fn content_hash(data: &str) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in data.as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
h
}
fn scan_file(
scanner: &CompiledScanner,
path: &std::path::Path,
recently_scanned: &mut HashMap<PathBuf, (Instant, u64)>,
) {
let data = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(_) => return, };
if data.is_empty() {
return;
}
let now = Instant::now();
let hash = content_hash(&data);
if let Some((last, last_hash)) = recently_scanned.get(path) {
if *last_hash == hash && now.duration_since(*last) < DEDUP_WINDOW {
return;
}
}
recently_scanned.insert(path.to_path_buf(), (now, hash));
recently_scanned.retain(|_, (last, _)| now.duration_since(*last) < DEDUP_WINDOW);
let chunk = Chunk {
data: data.into(),
metadata: ChunkMetadata {
base_offset: 0,
base_line: 0,
source_type: "filesystem/watch".into(),
path: Some(path.display().to_string()),
commit: None,
author: None,
date: None,
mtime_ns: None,
size_bytes: None,
},
};
let matches = scanner.scan(&chunk);
for m in matches {
let line = m.location.line.map(|l| format!(":{l}")).unwrap_or_default();
let conf = m
.confidence
.map(|c| format!(" ({:.2})", c))
.unwrap_or_default();
println!(
"\u{1F50D} {} {}{} {:?}{} {}",
m.detector_id,
path.display(),
line,
m.severity,
conf,
keyhog_core::redact(&m.credential)
);
}
}
fn should_skip(path: &std::path::Path) -> bool {
const SKIP_NAMES: &[&str] = &[
".git",
".svn",
".hg",
"node_modules",
"target",
".cargo",
".cache",
".venv",
"venv",
"__pycache__",
".next",
".turbo",
"dist",
"build",
];
path.components().any(|c| {
if let std::path::Component::Normal(os) = c {
if let Some(s) = os.to_str() {
return SKIP_NAMES.iter().any(|skip| s.eq_ignore_ascii_case(skip));
}
}
false
})
}