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::sync::mpsc::channel;
pub fn run(args: WatchArgs) -> Result<()> {
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:?}"))?;
let watch_root = std::fs::canonicalize(&args.path)
.with_context(|| format!("canonicalize {}", args.path.display()))?;
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()))?;
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);
}
}
Ok(())
}
fn scan_file(scanner: &CompiledScanner, path: &std::path::Path) {
let data = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(_) => return, };
if data.is_empty() {
return;
}
let chunk = Chunk {
data: data.into(),
metadata: ChunkMetadata {
base_offset: 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
})
}