mod render;
mod worker;
use std::io::{self, Write};
use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::mpsc::{channel, Receiver};
use std::sync::Arc;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use keyhog_core::{RawMatch, Severity};
use keyhog_scanner::engine::CompiledScanner;
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use crate::args::TuiArgs;
#[derive(Clone)]
struct FindingEvent {
severity: Severity,
service: String,
detector_id: String,
path: String,
line: Option<usize>,
redacted: String,
}
impl From<&RawMatch> for FindingEvent {
fn from(m: &RawMatch) -> Self {
Self {
severity: m.severity,
service: m.service.to_string(),
detector_id: m.detector_id.to_string(),
path: m
.location
.file_path
.as_deref()
.map(str::to_owned)
.unwrap_or_else(|| "<stdin>".to_string()),
line: m.location.line,
redacted: keyhog_core::redact(&m.credential).into_owned(),
}
}
}
struct Counters {
files_total: AtomicUsize,
files_done: AtomicUsize,
bytes_done: AtomicU64,
findings_total: AtomicUsize,
done: AtomicBool,
current_file: std::sync::RwLock<String>,
}
pub fn run(args: TuiArgs) -> Result<ExitCode> {
let target = args
.path
.canonicalize()
.with_context(|| format!("resolving scan target {}", args.path.display()))?;
if !target.exists() {
anyhow::bail!("scan target does not exist: {}", target.display());
}
let mut detectors = Vec::new();
for (path, toml_str) in keyhog_core::embedded_detector_tomls() {
match keyhog_core::load_detectors_from_str(toml_str) {
Ok(mut ds) => detectors.append(&mut ds),
Err(e) => {
tracing::debug!(detector = %path, error = %e, "skipping malformed embedded detector");
}
}
}
if detectors.is_empty() {
anyhow::bail!("embedded detector corpus is empty (build-time issue)");
}
let scanner = CompiledScanner::compile(detectors).context("compiling scanner")?;
let scanner = Arc::new(scanner);
let counters = Arc::new(Counters {
files_total: AtomicUsize::new(0),
files_done: AtomicUsize::new(0),
bytes_done: AtomicU64::new(0),
findings_total: AtomicUsize::new(0),
done: AtomicBool::new(false),
current_file: std::sync::RwLock::new(String::new()),
});
let cancel = Arc::new(AtomicBool::new(false));
let (sender, receiver) = channel::<FindingEvent>();
let worker_counters = Arc::clone(&counters);
let worker_scanner = Arc::clone(&scanner);
let worker_cancel = Arc::clone(&cancel);
let worker_target = target.clone();
let worker_max_files = args.max_files;
let worker_throttle = args.throttle_ms;
let worker = std::thread::spawn(move || {
worker::scan_worker(
worker_target,
worker_scanner,
worker_counters,
worker_cancel,
sender,
worker_max_files,
worker_throttle,
);
});
let outcome = run_ui(
target,
scanner,
counters,
cancel,
receiver,
args.feed_depth.max(1),
);
let _ = worker.join();
outcome
}
fn run_ui(
target: PathBuf,
scanner: Arc<CompiledScanner>,
counters: Arc<Counters>,
cancel: Arc<AtomicBool>,
receiver: Receiver<FindingEvent>,
feed_depth: usize,
) -> Result<ExitCode> {
static TUI_PANIC_HOOK_INSTALLED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
if TUI_PANIC_HOOK_INSTALLED.set(()).is_ok() {
let prior = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
prior(info);
}));
}
enable_raw_mode().context("entering raw mode")?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen).context("entering alt screen")?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend).context("creating terminal")?;
let backend_label = scanner
.gpu_backend_label()
.map(str::to_owned)
.unwrap_or_else(|| "cpu".to_string());
let preferred_backend = scanner.preferred_backend_label().to_string();
let pattern_count = scanner.pattern_count();
let started = Instant::now();
let mut findings_feed: std::collections::VecDeque<FindingEvent> =
std::collections::VecDeque::with_capacity(feed_depth);
let result: Result<()> = loop {
while let Ok(ev) = receiver.try_recv() {
if findings_feed.len() >= feed_depth {
findings_feed.pop_front();
}
findings_feed.push_back(ev);
}
let done = counters.done.load(Ordering::Relaxed);
if let Err(e) = terminal.draw(|frame| {
render::render(
frame,
&target,
&backend_label,
&preferred_backend,
pattern_count,
&counters,
&findings_feed,
started.elapsed(),
done,
);
}) {
break Err(anyhow::Error::new(e).context("drawing TUI frame"));
}
if event::poll(Duration::from_millis(50)).context("polling keyboard")? {
if let Event::Key(k) = event::read().context("reading keyboard event")? {
if k.kind == KeyEventKind::Press
&& (matches!(k.code, KeyCode::Char('q') | KeyCode::Esc)
|| (k.code == KeyCode::Char('c')
&& k.modifiers.contains(KeyModifiers::CONTROL))
|| done)
{
cancel.store(true, Ordering::Relaxed);
break Ok(());
}
}
}
};
disable_raw_mode().ok();
let mut stdout = io::stdout();
execute!(stdout, LeaveAlternateScreen).ok();
stdout.flush().ok();
result.map(|_| {
if counters.findings_total.load(Ordering::Relaxed) > 0 {
ExitCode::from(1)
} else {
ExitCode::SUCCESS
}
})
}