use keyhog_core::{Severity, VerifiedFinding};
use std::io::Write;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use std::time::Instant;
use crate::style::{terminal_clear_line_prefix, terminal_palette};
pub(crate) fn stream_finding_preview<W: Write>(w: &mut W, f: &VerifiedFinding) {
let path = f.location.file_path.as_deref().unwrap_or("<stdin>"); let line = f
.location
.line
.map(|n| n.to_string())
.unwrap_or_else(|| "?".into()); if let Err(error) = writeln!(
w,
"[stream] {sev:<8} {service}/{detector} {path}:{line} {redacted}",
sev = f.severity.as_str().to_uppercase(),
service = f.service,
detector = f.detector_id,
path = path,
line = line,
redacted = f.credential_redacted,
) {
tracing::debug!(%error, "stream finding preview write error");
}
}
pub(crate) fn stream_report_previews(findings: &[VerifiedFinding]) {
if findings.is_empty() {
return;
}
let mut w = std::io::LineWriter::new(std::io::stderr());
for f in findings {
stream_finding_preview(&mut w, f);
}
let _ = w.flush(); }
pub(crate) fn scanner_panic_notice(panicked: bool) -> Option<String> {
panicked.then(|| {
"SCAN INCOMPLETE: the scanner thread panicked mid-scan. The findings below \
are PARTIAL: chunks in flight when it crashed were NOT scanned, so a \
\"0 secrets\" / low count is NOT a clean result. The process exits with a \
distinct scanner-panic code. Re-run; if it persists, file a bug with the \
input that triggered it."
.to_string()
})
}
#[derive(Default, Debug, PartialEq, Eq)]
pub(crate) struct VerificationBreakdown {
pub live: usize,
pub inactive: usize,
pub skipped: usize,
pub unverifiable: usize,
pub incomplete: usize,
}
pub(crate) fn verification_breakdown(findings: &[VerifiedFinding]) -> VerificationBreakdown {
use keyhog_core::VerificationResult as V;
let mut b = VerificationBreakdown::default();
for f in findings {
match &f.verification {
V::Live => b.live += 1,
V::Revoked | V::Dead => b.inactive += 1,
V::Skipped => b.skipped += 1,
V::Unverifiable => b.unverifiable += 1,
V::RateLimited | V::Error(_) => b.incomplete += 1,
}
}
b
}
fn count_token(count: usize, label: &str, color_code: &str, color: bool) -> String {
crate::style::paint(format!("{count} {label}"), color_code, color)
}
pub(crate) fn secret_noun(count: usize) -> &'static str {
if count == 1 {
"secret"
} else {
"secrets"
}
}
pub(crate) fn finding_noun(count: usize) -> &'static str {
if count == 1 {
"finding"
} else {
"findings"
}
}
fn dot_join(parts: &[String], color: bool) -> String {
let sep = if color {
format!("{C_MUTED} · {C_RESET}")
} else {
" · ".to_string()
};
parts.join(&sep)
}
fn severity_color(severity: Severity) -> &'static str {
match severity {
Severity::Critical => C_CRITICAL,
Severity::High => C_HIGH,
Severity::Medium => C_MEDIUM,
Severity::Low => C_LOW,
Severity::ClientSafe => C_SAFE,
Severity::Info => C_MUTED,
}
}
pub(crate) fn render_severity_line(findings: &[VerifiedFinding], color: bool) -> Option<String> {
if findings.is_empty() {
return None;
}
let mut critical = 0usize;
let mut high = 0usize;
let mut medium = 0usize;
let mut low = 0usize;
let mut client_safe = 0usize;
let mut info = 0usize;
for finding in findings {
match finding.severity {
Severity::Critical => critical += 1,
Severity::High => high += 1,
Severity::Medium => medium += 1,
Severity::Low => low += 1,
Severity::ClientSafe => client_safe += 1,
Severity::Info => info += 1,
}
}
let counts = [
(Severity::Critical, critical),
(Severity::High, high),
(Severity::Medium, medium),
(Severity::Low, low),
(Severity::ClientSafe, client_safe),
(Severity::Info, info),
];
let parts: Vec<String> = counts
.into_iter()
.filter(|(_, count)| *count > 0)
.map(|(severity, count)| {
count_token(count, severity.as_str(), severity_color(severity), color)
})
.collect();
let (muted, reset) = if color { (C_MUTED, C_RESET) } else { ("", "") };
Some(format!(
"{muted}↳ severity: {reset}{}",
dot_join(&parts, color)
))
}
pub(crate) fn render_verification_line(
b: &VerificationBreakdown,
total: usize,
color: bool,
) -> Option<String> {
if total == 0 {
return None;
}
let (muted, brand, amber, reset) = if color {
(C_MUTED, C_BRAND, C_AMBER, C_RESET)
} else {
("", "", "", "")
};
if b.skipped == total {
return Some(format!(
"{muted}↳ verification: {amber}not checked{reset}{muted}: liveness check did not run; pass {brand}--verify{reset}{muted} \
to confirm which are active{reset}"
));
}
let mut parts: Vec<String> = Vec::new();
if b.live > 0 {
parts.push(count_token(b.live, "live", C_CRITICAL, color));
}
if b.inactive > 0 {
parts.push(count_token(b.inactive, "revoked/dead", C_SAFE, color));
}
if b.skipped > 0 {
parts.push(count_token(b.skipped, "not checked", C_AMBER, color));
}
if b.unverifiable > 0 {
parts.push(count_token(b.unverifiable, "no verifier", C_AMBER, color));
}
if b.incomplete > 0 {
parts.push(count_token(b.incomplete, "inconclusive", C_AMBER, color));
}
Some(format!(
"{muted}↳ verification: {reset}{}",
dot_join(&parts, color)
))
}
pub(crate) fn report_completion_summary(
findings: &[VerifiedFinding],
elapsed: f64,
ansi: bool,
backend_override: Option<keyhog_scanner::ScanBackend>,
) {
let count = findings.len();
let palette = terminal_palette(ansi, false);
let completion =
if crate::BACKEND_RECOVERY_EVENTS.load(std::sync::atomic::Ordering::Relaxed) > 0 {
"Scan complete after recovery."
} else {
"Scan complete."
};
if let Some(notice) =
scanner_panic_notice(crate::SCANNER_PANICKED.load(std::sync::atomic::Ordering::Relaxed))
{
eprintln!("{}FAIL{} {notice}", palette.red, palette.reset);
}
if count == 0 {
eprintln!(
"\n{completion} Found {}0{} secrets in {}{:.2}s{}.",
palette.green, palette.reset, palette.yellow, elapsed, palette.reset
);
} else {
let noun = secret_noun(count);
eprintln!(
"\n{completion} Found {}{}{} {} in {}{:.2}s{}.",
palette.red, count, palette.reset, noun, palette.yellow, elapsed, palette.reset
);
if let Some(line) = render_severity_line(findings, ansi) {
eprintln!("{line}");
}
if let Some(line) = render_verification_line(&verification_breakdown(findings), count, ansi)
{
eprintln!("{line}");
}
}
report_skip_summary(ansi);
report_backend_summary(ansi, backend_override);
}
pub(crate) fn report_backend_summary(
ansi: bool,
backend_override: Option<keyhog_scanner::ScanBackend>,
) {
use std::sync::atomic::Ordering;
let total = crate::SCANNED_CHUNKS.load(Ordering::Relaxed);
if total == 0 {
return;
}
let gpu = crate::GPU_SCANNED_CHUNKS.load(Ordering::Relaxed).min(total);
let non_gpu = total - gpu;
let recovery_events = crate::BACKEND_RECOVERY_EVENTS.load(Ordering::Relaxed);
let recovered_chunks = crate::BACKEND_RECOVERED_CHUNKS.load(Ordering::Relaxed);
let recovered_bytes = crate::BACKEND_RECOVERED_BYTES.load(Ordering::Relaxed);
let recovery_summaries = crate::backend_recovery_summaries();
let invalid_autoroute_recovered = recovery_summaries
.iter()
.any(|summary| summary.failed_backend == "autoroute-invalid");
let hw = keyhog_scanner::hw_probe::probe_hardware();
let line = if let Some(backend) = backend_override {
format!("backend: {} (forced via --backend)", backend.label())
} else if recovery_events > 0 && invalid_autoroute_recovered {
format!(
"backend: automatic routing included scalar correctness recovery for invalid autoroute state; recovered {recovered_chunks} chunk(s), {recovered_bytes} byte(s) across {recovery_events} event(s); scan coverage is complete; repair: keyhog calibrate-autoroute"
)
} else if recovery_events > 0 {
format!(
"backend: an automatic route faulted and completed through exact recovery; recovered {recovered_chunks} chunk(s), {recovered_bytes} byte(s) across {recovery_events} event(s); scan coverage is complete; repair: keyhog calibrate-autoroute"
)
} else if gpu > 0 && non_gpu > 0 {
format!(
"backend: calibrated GPU route ({gpu} chunk(s)) + calibrated non-GPU route ({non_gpu} chunk(s)); inspect `keyhog backend --autoroute` for exact per-bucket routes"
)
} else if gpu > 0 {
"backend: calibrated GPU driver peer (inspect `keyhog backend --autoroute` for the exact route)".to_string()
} else if hw.gpu_available && !hw.gpu_is_software {
let name = hw.gpu_name.as_deref().unwrap_or("a GPU").trim().to_string(); format!(
"backend: calibrated non-GPU route; {name} was eligible but was not the \
fastest measured-correct route for the exact workload bucket(s) scanned. \
Inspect the persisted decision with `keyhog backend --autoroute`; explicit \
`--backend gpu-cuda` or `--backend gpu-wgpu` is diagnostic only."
)
} else {
"backend: calibrated non-GPU route (no hardware GPU available on this host)".to_string()
};
let palette = terminal_palette(ansi, false);
eprintln!("{}INFO{} {line}", palette.cyan, palette.reset);
}
use crate::style::{
SEV_AMBER as C_AMBER, SEV_BOLD as C_BOLD, SEV_BRAND as C_BRAND, SEV_CRITICAL as C_CRITICAL,
SEV_HIGH as C_HIGH, SEV_LOW as C_LOW, SEV_MEDIUM as C_MEDIUM, SEV_MUTED as C_MUTED,
SEV_RAIL as C_RAIL, SEV_RESET as C_RESET, SEV_SAFE as C_SAFE,
};
const FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const BAR_WIDTH: usize = 22;
pub(crate) fn render_progress_bar(frac: f64, width: usize, color: bool) -> String {
const PARTIALS: [char; 8] = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉'];
let frac = frac.clamp(0.0, 1.0);
let eighths = (frac * width as f64 * 8.0).round() as usize;
let full = (eighths / 8).min(width);
let rem = eighths % 8;
let mut fill = "█".repeat(full);
let mut used = full;
if full < width && rem > 0 {
fill.push(PARTIALS[rem]);
used += 1;
}
let rail = "░".repeat(width.saturating_sub(used));
if color {
let mut s = String::new();
if !fill.is_empty() {
s.push_str(C_BRAND);
s.push_str(&fill);
}
if !rail.is_empty() {
s.push_str(C_RAIL);
s.push_str(&rail);
}
s.push_str(C_RESET);
s
} else {
format!("{fill}{rail}")
}
}
fn render_indeterminate_bar(phase: usize, width: usize, color: bool) -> String {
let band = 4usize;
let span = width + band;
let head = phase % span;
let mut cells = String::with_capacity(width * 4);
for i in 0..width {
let lit = head >= i && head < i + band;
if color {
cells.push_str(if lit { C_AMBER } else { C_RAIL });
}
cells.push(if lit { '█' } else { '░' });
}
if color {
cells.push_str(C_RESET);
}
cells
}
pub(crate) fn fmt_secs(s: f64) -> String {
if s < 59.95 {
format!("{s:.1}s")
} else {
let total = s.round() as u64;
let m = total / 60;
let r = total % 60;
format!("{m}m{r:02}s")
}
}
pub(crate) fn render_ticker_line(
scanned: usize,
total: usize,
findings: usize,
elapsed: f64,
frame: usize,
color: bool,
) -> String {
let (brand, amber, muted, rail, bold, reset) = if color {
(C_BRAND, C_AMBER, C_MUTED, C_RAIL, C_BOLD, C_RESET)
} else {
("", "", "", "", "", "")
};
let spin = FRAMES[frame % FRAMES.len()];
let noun = finding_noun(findings);
let find_seg = if findings > 0 {
format!("{bold}{amber}{findings}{reset} {muted}{noun}{reset}")
} else {
format!("{muted}0 {noun}{reset}")
};
if total == 0 {
let sweep = render_indeterminate_bar(frame, BAR_WIDTH, color);
format!(
"{brand}{spin}{reset} {bold}preparing{reset} {muted}·{reset} {sweep} {muted}·{reset} warming backend, discovering files {muted}·{reset} {find_seg} {muted}·{reset} {muted}{}{reset}",
fmt_secs(elapsed)
)
} else {
let shown = scanned.min(total);
let frac = shown as f64 / total as f64;
let pct = (frac * 100.0).floor() as u64;
let bar = render_progress_bar(frac, BAR_WIDTH, color);
let rate = if elapsed > 0.05 {
scanned as f64 / elapsed
} else {
0.0
};
let eta = if rate > 0.5 && shown < total {
format!(
" {muted}eta {}{reset}",
fmt_secs((total - shown) as f64 / rate)
)
} else {
String::new()
};
let label = if shown >= total {
"finalizing"
} else {
"scanning"
};
format!(
"{brand}{spin}{reset} {bold}{label}{reset} {rail}▕{reset}{bar}{rail}▏{reset} {bold}{pct:>3}%{reset} {muted}{shown}/{total}{reset} {muted}·{reset} {find_seg} {muted}·{reset} {muted}{rate:.0}/s{reset} {muted}·{reset} {muted}{}{reset}{eta}",
fmt_secs(elapsed)
)
}
}
pub(crate) fn render_verification_ticker_line(
total: usize,
elapsed: f64,
frame: usize,
color: bool,
) -> String {
let (brand, muted, bold, reset) = if color {
(C_BRAND, C_MUTED, C_BOLD, C_RESET)
} else {
("", "", "", "")
};
let spin = FRAMES[frame % FRAMES.len()];
let sweep = render_indeterminate_bar(frame, BAR_WIDTH, color);
let noun = secret_noun(total);
format!(
"{brand}{spin}{reset} {bold}verifying{reset} {muted}·{reset} {sweep} {muted}·{reset} checking {bold}{total}{reset} {noun} {muted}·{reset} {muted}{}{reset}",
fmt_secs(elapsed)
)
}
pub(crate) fn render_reporting_ticker_line(
total: usize,
elapsed: f64,
frame: usize,
color: bool,
) -> String {
let (brand, muted, bold, reset) = if color {
(C_BRAND, C_MUTED, C_BOLD, C_RESET)
} else {
("", "", "", "")
};
let spin = FRAMES[frame % FRAMES.len()];
let sweep = render_indeterminate_bar(frame, BAR_WIDTH, color);
let noun = finding_noun(total);
format!(
"{brand}{spin}{reset} {bold}reporting{reset} {muted}·{reset} {sweep} {muted}·{reset} writing {bold}{total}{reset} {noun} {muted}·{reset} {muted}{}{reset}",
fmt_secs(elapsed)
)
}
pub(crate) struct TickerGuard {
done: Arc<AtomicBool>,
handle: Option<std::thread::JoinHandle<()>>,
label: &'static str,
}
impl TickerGuard {
pub(crate) fn spawn<F>(label: &'static str, run: F) -> Self
where
F: FnOnce(Arc<AtomicBool>, Instant) + Send + 'static,
{
let done = Arc::new(AtomicBool::new(false));
let ticker_done = Arc::clone(&done);
let started = Instant::now();
let handle = std::thread::spawn(move || run(ticker_done, started));
Self {
done,
handle: Some(handle),
label,
}
}
pub(crate) fn stop(mut self) {
self.stop_inner();
}
fn stop_inner(&mut self) {
use std::sync::atomic::Ordering;
self.done.store(true, Ordering::Relaxed);
if let Some(handle) = self.handle.take() {
if handle.join().is_err() {
tracing::debug!(
ticker = self.label,
"progress thread panicked while shutting down"
);
}
}
}
}
impl Drop for TickerGuard {
fn drop(&mut self) {
self.stop_inner();
}
}
fn terminal_ticker_loop<F>(
done: Arc<AtomicBool>,
started: Instant,
redraw_error_label: &'static str,
mut render: F,
) where
F: FnMut(f64, usize, bool) -> String,
{
use std::io::IsTerminal;
use std::sync::atomic::Ordering;
use std::time::Duration;
if !std::io::stderr().is_terminal() {
return;
}
let color = !crate::style::no_color_requested();
let tick = Duration::from_millis(90);
let mut frame = 0usize;
loop {
let elapsed = started.elapsed().as_secs_f64();
let clear = terminal_clear_line_prefix(true);
let line = render(elapsed, frame, color);
let mut err = std::io::stderr().lock();
if let Err(error) = write!(err, "{clear}{line}") {
tracing::debug!(%error, ticker = redraw_error_label, "progress redraw write error");
}
let _ = err.flush(); drop(err);
if done.load(Ordering::Relaxed) {
break;
}
for _ in 0..9 {
if done.load(Ordering::Relaxed) {
break;
}
std::thread::sleep(tick / 9);
}
frame = frame.wrapping_add(1);
}
let mut err = std::io::stderr().lock();
let _ = write!(err, "{}", terminal_clear_line_prefix(true)); let _ = err.flush(); }
pub(crate) fn progress_ticker(done: Arc<AtomicBool>, started: Instant) {
terminal_ticker_loop(done, started, "scan", |elapsed, frame, color| {
let scanned = crate::SCANNED_CHUNKS.load(std::sync::atomic::Ordering::Relaxed);
let total = crate::TOTAL_CHUNKS.load(std::sync::atomic::Ordering::Relaxed);
let findings = crate::FINDINGS_COUNT.load(std::sync::atomic::Ordering::Relaxed);
render_ticker_line(scanned, total, findings, elapsed, frame, color)
});
}
#[cfg(feature = "verify")]
pub(crate) fn verification_ticker(done: Arc<AtomicBool>, started: Instant, total: usize) {
terminal_ticker_loop(done, started, "verification", |elapsed, frame, color| {
render_verification_ticker_line(total, elapsed, frame, color)
});
}
pub(crate) fn reporting_ticker(done: Arc<AtomicBool>, started: Instant, total: usize) {
terminal_ticker_loop(done, started, "reporting", |elapsed, frame, color| {
render_reporting_ticker_line(total, elapsed, frame, color)
});
}
pub(crate) fn report_skip_summary(ansi: bool) {
use crate::reporting::{CoverageCounts, CoverageGapKind, CoverageSeverity};
let counts = CoverageCounts::current();
for kind in CoverageGapKind::ALL {
let n = kind.count(&counts);
if n == 0 {
continue;
}
let palette = terminal_palette(ansi, false);
let (label, color) = match kind.severity() {
CoverageSeverity::Fail => ("FAIL", palette.red),
CoverageSeverity::Warn => ("WARN", palette.yellow),
};
let msg = kind.human_reason(n);
eprintln!("{color}{label} {msg}{}", palette.reset);
}
}
pub(crate) fn dump_dogfood_trace() {
if !keyhog_scanner::telemetry::is_dogfood_enabled() {
return;
}
let events = keyhog_scanner::telemetry::drain_events();
let suppressed = keyhog_scanner::telemetry::example_suppression_count();
let static_recovery_rejections = keyhog_scanner::telemetry::static_recovery_rejection_counts();
let detail_events_dropped = keyhog_scanner::telemetry::dogfood_detail_events_dropped();
let payload = serde_json::json!({
"dogfood": {
"example_suppressions_total": suppressed,
"static_recovery_rejections": static_recovery_rejections,
"detail_events_dropped": detail_events_dropped,
"events": events,
}
});
eprintln!("{payload}");
}
#[cfg(test)]
mod tests;