use crate::args::WatchArgs;
use crate::orchestrator::load_rule_suppressor;
use crate::orchestrator::{setup_default_scan_runtime, DefaultScanRuntime};
use crate::skip_dirs::SkipDirPolicy;
use crate::style;
use anyhow::{Context, Result};
use keyhog_core::{Chunk, ChunkMetadata, RawMatch, RuleSuppressor};
use notify::{Event, EventKind, RecursiveMode, Watcher};
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::channel;
use std::time::{Duration, Instant};
const DEDUP_WINDOW: Duration = Duration::from_millis(750);
const DEDUP_PRUNE_INTERVAL: usize = 128;
const DEDUP_MAX_ENTRIES: usize = 4096;
const WATCH_DIR_RECONCILE_MAX_FILES: usize = 10_000;
const WATCH_DIR_RECONCILE_MAX_DEPTH: usize = 64;
const WATCH_ROOT_REESTABLISH_ATTEMPTS: usize = 30;
const WATCH_ROOT_REESTABLISH_INTERVAL: Duration = Duration::from_secs(1);
const FNV_OFFSET_BASIS: u64 = keyhog_scanner::FNV_OFFSET_BASIS;
const FNV_PRIME: u64 = keyhog_scanner::FNV_PRIME;
#[derive(Default)]
struct WatchDedupeState {
entries: HashMap<PathBuf, (Instant, u64)>,
entry_order: VecDeque<PathBuf>,
finding_entries: HashMap<PathBuf, (Instant, [u8; 32])>,
finding_order: VecDeque<PathBuf>,
scans_since_prune: usize,
}
fn cap_map_fifo<V>(
map: &mut HashMap<PathBuf, V>,
order: &mut VecDeque<PathBuf>,
path: &std::path::Path,
value: V,
) {
if map.contains_key(path) {
map.insert(path.to_path_buf(), value);
return;
}
map.insert(path.to_path_buf(), value);
order.push_back(path.to_path_buf());
while map.len() > DEDUP_MAX_ENTRIES {
if let Some(old) = order.pop_front() {
map.remove(&old);
} else {
break;
}
}
}
pub(crate) fn run(args: WatchArgs) -> Result<()> {
let watch_roots = resolve_watch_roots(&args.paths)?;
let roots_hint = roots_hint(&watch_roots);
let max_file_size = match args.max_file_size {
None | Some(0) => keyhog_core::DEFAULT_MAX_FILE_SIZE_BYTES,
Some(n) => n,
};
let max_consecutive_failures = if args.max_consecutive_failures == 0 {
crate::args::DEFAULT_WATCH_MAX_CONSECUTIVE_SCAN_FAILURES
} else {
args.max_consecutive_failures
};
let setup_span = keyhog_profile::span(keyhog_profile::Stage::Preprocess);
let backend_override = crate::orchestrator::explicit_backend_override(args.backend.as_deref())?;
let scan_runtime = setup_default_scan_runtime(
&args.detectors,
args.detectors_cli_explicit,
args.cache_dir.clone(),
None,
backend_override,
"keyhog watch",
false,
watch_roots.first().map(PathBuf::as_path),
)?
.prepare_persistent_watch(backend_override)?;
let detector_count = scan_runtime.detector_count();
let mut rule_suppressors: HashMap<PathBuf, RuleSuppressor> =
HashMap::with_capacity(watch_roots.len());
for root in &watch_roots {
rule_suppressors.insert(root.clone(), load_rule_suppressor(Some(root))?);
}
drop(setup_span);
if watch_roots.len() > 1 && !args.quiet {
let palette = style::for_stderr();
eprintln!(
"{} keyhog watch: loaded per-root .keyhogignore.toml for {} roots; \
.keyhog.toml detector config still uses primary root {}",
style::warn("WARN", &palette),
watch_roots.len(),
watch_roots[0].display()
);
}
let (tx, rx) = channel::<notify::Result<Event>>();
let notify_channel_closed_for_callback = AtomicBool::new(false);
let roots_hint_for_callback = roots_hint.clone();
let mut watcher = notify::recommended_watcher(move |res| {
if tx.send(res).is_err()
&& !notify_channel_closed_for_callback.swap(true, Ordering::Relaxed)
{
let palette = style::for_stderr();
eprintln!(
"{} keyhog watch: internal watcher event channel closed; a filesystem \
event could not be delivered and the changed path was NOT re-scanned. \
Restart watch, or run `keyhog scan {}` for a full one-shot rescan.",
style::warn("WARN", &palette),
roots_hint_for_callback
);
}
})
.map_err(|e| {
anyhow::anyhow!(
"failed to build filesystem watcher for {roots}: {e}\n \
Fix: on Linux raise watcher limits with:\n \
sudo sysctl fs.inotify.max_user_instances=1024 fs.inotify.max_user_watches=524288\n \
then retry, or run `keyhog scan {roots}` for a one-shot scan.",
roots = roots_hint,
)
})?;
for root in &watch_roots {
watcher.watch(root, RecursiveMode::Recursive).map_err(|e| {
anyhow::anyhow!(
"failed to watch {root}: {e}\n \
On Linux a large tree usually exhausts the inotify watch limit; raise it with:\n \
sudo sysctl fs.inotify.max_user_watches=524288 (persist in /etc/sysctl.conf)\n \
or run a one-shot `keyhog scan {root}` instead of watch.",
root = root.display(),
)
})?;
}
let skip_dirs = SkipDirPolicy::load()?;
let mut session = WatchSession {
scan_runtime: &scan_runtime,
watch_roots: &watch_roots,
rule_suppressors: &rule_suppressors,
skip_dirs: &skip_dirs,
roots_hint: &roots_hint,
max_file_size,
max_consecutive_failures,
recently_scanned: WatchDedupeState::default(),
consecutive_scan_failures: 0,
};
for root in &watch_roots {
let mut initial = Vec::new();
collect_directory_files(root, &skip_dirs, &mut initial, &roots_hint);
session.scan_paths(initial)?;
}
if !args.quiet {
eprintln!(
"\u{1F441} keyhog watch (\u{2630} {} detectors compiled)",
detector_count
);
eprintln!(" workers: {}", scan_runtime.worker_threads());
for root in &watch_roots {
eprintln!(" watching: {}", root.display());
}
eprintln!(" Ctrl-C to exit");
eprintln!();
}
for event in rx {
let event = match event {
Ok(e) => e,
Err(e) => {
let palette = style::for_stderr();
eprintln!(
"{} keyhog watch: filesystem watcher error ({e}); one or more change \
events were DROPPED and those files were NOT re-scanned. \
If this recurs under heavy file churn, raise \
fs.inotify.max_queued_events or run `keyhog scan {}` for a \
full one-shot rescan.",
style::warn("WARN", &palette),
roots_hint
);
continue;
}
};
if matches!(event.kind, EventKind::Remove(_)) {
if let Some(lost) = event
.paths
.iter()
.find(|path| watch_roots.iter().any(|root| root == *path))
.cloned()
{
reestablish_watched_root(
&mut watcher,
&lost,
&skip_dirs,
&roots_hint,
&mut session,
)?;
}
continue;
}
let interesting = matches!(event.kind, EventKind::Modify(_) | EventKind::Create(_));
if !interesting {
continue;
}
let pending = session.expand_event_paths(event.paths);
session.scan_paths(pending)?;
}
Ok(())
}
fn reestablish_watched_root(
watcher: &mut notify::RecommendedWatcher,
root: &std::path::Path,
skip_dirs: &SkipDirPolicy,
roots_hint: &str,
session: &mut WatchSession<'_>,
) -> Result<()> {
let palette = style::for_stderr();
eprintln!(
"{} keyhog watch: watched root {} was removed; its filesystem watch is gone and \
changes under that path are NOT being observed. Waiting up to {}s for it to \
return.",
style::warn("WARN", &palette),
root.display(),
WATCH_ROOT_REESTABLISH_ATTEMPTS,
);
for _ in 0..WATCH_ROOT_REESTABLISH_ATTEMPTS {
std::thread::sleep(WATCH_ROOT_REESTABLISH_INTERVAL);
if !root.is_dir() {
continue;
}
if let Err(error) = watcher.watch(root, RecursiveMode::Recursive) {
eprintln!(
"{} keyhog watch: {} returned but its watch could not be re-registered \
({error}); still not observed.",
style::warn("WARN", &palette),
root.display(),
);
continue;
}
let mut pending = Vec::new();
collect_directory_files(root, skip_dirs, &mut pending, roots_hint);
let reconciled = pending.len();
session.scan_paths(pending)?;
eprintln!(
"{} keyhog watch: {} is being watched again; rescanned {reconciled} file(s) \
to cover the gap while it was missing.",
style::warn("OK", &palette),
root.display(),
);
return Ok(());
}
anyhow::bail!(
"keyhog watch: watched root {root} did not return within {secs}s; its filesystem \
watch is gone and changes under that path cannot be observed. Exiting rather than \
reporting a clean tree that is not being watched. Recreate the path and restart \
watch, or run `keyhog scan {roots_hint}`.",
root = root.display(),
secs = WATCH_ROOT_REESTABLISH_ATTEMPTS,
);
}
struct WatchSession<'a> {
scan_runtime: &'a DefaultScanRuntime,
watch_roots: &'a [PathBuf],
rule_suppressors: &'a HashMap<PathBuf, RuleSuppressor>,
skip_dirs: &'a SkipDirPolicy,
roots_hint: &'a str,
max_file_size: u64,
max_consecutive_failures: usize,
recently_scanned: WatchDedupeState,
consecutive_scan_failures: usize,
}
impl WatchSession<'_> {
fn expand_event_paths(&self, paths: Vec<PathBuf>) -> Vec<PathBuf> {
let mut pending = Vec::with_capacity(paths.len());
for path in paths {
if should_skip(&path, self.skip_dirs) {
continue;
}
if path.is_dir() {
collect_directory_files(&path, self.skip_dirs, &mut pending, self.roots_hint);
} else {
pending.push(path);
}
}
pending
}
fn scan_paths(&mut self, pending: Vec<PathBuf>) -> Result<()> {
for path in pending {
let rule_suppressor =
rule_suppressor_for_path(&path, self.watch_roots, self.rule_suppressors);
let outcome = scan_file(
self.scan_runtime,
rule_suppressor,
&path,
self.max_file_size,
&mut self.recently_scanned,
)
.with_context(|| format!("scan changed path {}", path.display()))?;
match outcome {
WatchScanOutcome::Ok => self.consecutive_scan_failures = 0,
WatchScanOutcome::PolicySkip => {}
WatchScanOutcome::EngineFailure => {
self.consecutive_scan_failures =
self.consecutive_scan_failures.saturating_add(1);
let failures = self.consecutive_scan_failures;
let limit = self.max_consecutive_failures;
if failures >= limit {
anyhow::bail!(
"keyhog watch: {failures} consecutive per-file scan failures \
(limit {limit}); exiting so a wedged scanner cannot silently \
drop secrets under editor saves (KH-1334). Fix the scanner \
fault and restart watch, or run `keyhog scan {}` for a full \
rescan.",
self.roots_hint
);
}
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WatchScanOutcome {
Ok,
PolicySkip,
EngineFailure,
}
fn collect_directory_files(
dir: &std::path::Path,
skip_dirs: &SkipDirPolicy,
out: &mut Vec<PathBuf>,
roots_hint: &str,
) {
let start = out.len();
let mut stack: Vec<(PathBuf, usize)> = vec![(dir.to_path_buf(), 0)];
let mut truncated: Option<&'static str> = None;
while let Some((current, depth)) = stack.pop() {
if depth > WATCH_DIR_RECONCILE_MAX_DEPTH {
truncated = Some("directory depth");
continue;
}
let entries = match std::fs::read_dir(¤t) {
Ok(entries) => entries,
Err(error) => {
if error.kind() != std::io::ErrorKind::NotFound {
let palette = style::for_stderr();
eprintln!(
"{} keyhog watch: could not list {} ({}); files under it were NOT scanned",
style::warn("WARN", &palette),
current.display(),
error.kind()
);
}
continue;
}
};
for entry in entries.flatten() {
let path = entry.path();
if should_skip(&path, skip_dirs) {
continue;
}
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_dir() {
stack.push((path, depth + 1));
} else if file_type.is_file() {
if out.len() - start >= WATCH_DIR_RECONCILE_MAX_FILES {
truncated = Some("file count");
break;
}
out.push(path);
}
}
if truncated == Some("file count") {
break;
}
}
if let Some(limit) = truncated {
let palette = style::for_stderr();
eprintln!(
"{} keyhog watch: {} appeared in a watched tree and exceeded the {limit} limit \
({} files enumerated, max {}); the remainder was NOT scanned. Run \
`keyhog scan {roots_hint}` for full coverage of that path.",
style::warn("WARN", &palette),
dir.display(),
out.len() - start,
WATCH_DIR_RECONCILE_MAX_FILES,
);
}
}
fn resolve_watch_roots(requested: &[PathBuf]) -> Result<Vec<PathBuf>> {
let folded = crate::sources::resolve_scan_roots(requested)?;
let mut roots = Vec::with_capacity(folded.len());
for root in folded {
let canonical = root
.canonicalize()
.with_context(|| format!("canonicalize watch root {}", root.display()))?;
if !canonical.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.",
canonical.display(),
canonical.display()
);
}
roots.push(canonical);
}
Ok(roots)
}
fn roots_hint(roots: &[PathBuf]) -> String {
roots
.iter()
.map(|root| root.display().to_string())
.collect::<Vec<_>>()
.join(" ")
}
fn content_hash(data: &[u8]) -> u64 {
let mut h: u64 = FNV_OFFSET_BASIS;
for b in data {
h ^= *b as u64;
h = h.wrapping_mul(FNV_PRIME);
}
h
}
fn read_watched_file(path: &std::path::Path, max_file_size: u64) -> std::io::Result<Vec<u8>> {
keyhog_sources::read_file_safe_bytes(path, max_file_size)
}
fn read_error_outcome(path: &std::path::Path, error: &std::io::Error) -> WatchScanOutcome {
if path
.symlink_metadata()
.is_ok_and(|meta| meta.file_type().is_symlink())
{
return WatchScanOutcome::PolicySkip;
}
match error.kind() {
std::io::ErrorKind::InvalidInput => WatchScanOutcome::PolicySkip,
std::io::ErrorKind::InvalidData => WatchScanOutcome::PolicySkip,
_ => WatchScanOutcome::EngineFailure,
}
}
fn rule_suppressor_for_path<'a>(
path: &std::path::Path,
roots: &[PathBuf],
suppressors: &'a HashMap<PathBuf, RuleSuppressor>,
) -> &'a RuleSuppressor {
let mut best: Option<&PathBuf> = None;
for root in roots {
if path.starts_with(root) {
match best {
None => best = Some(root),
Some(current) if root.as_os_str().len() > current.as_os_str().len() => {
best = Some(root);
}
_ => {}
}
}
}
let key = best.unwrap_or(&roots[0]);
suppressors
.get(key)
.unwrap_or_else(|| panic!("every watch root has a suppressor entry"))
}
fn scan_file(
scan_runtime: &DefaultScanRuntime,
rule_suppressor: &RuleSuppressor,
path: &std::path::Path,
max_file_size: u64,
recently_scanned: &mut WatchDedupeState,
) -> Result<WatchScanOutcome> {
let bytes = match read_watched_file(path, max_file_size) {
Ok(b) => b,
Err(error) => {
if error.kind() == std::io::ErrorKind::NotFound {
return Ok(WatchScanOutcome::Ok);
}
let outcome = read_error_outcome(path, &error);
let palette = style::for_stderr();
let reason = match outcome {
WatchScanOutcome::PolicySkip => "; `keyhog scan` skips it too",
_ => "",
};
eprintln!(
"{} keyhog watch: could not read {} ({}); it was NOT scanned{reason}",
style::warn("WARN", &palette),
path.display(),
error.kind()
);
return Ok(outcome);
}
};
if suppress_duplicate_event(path, &bytes, Instant::now(), recently_scanned) {
return Ok(WatchScanOutcome::Ok);
}
let Some(data) = keyhog_sources::decode_file_bytes(&bytes) else {
return Ok(WatchScanOutcome::Ok);
};
if data.is_empty() {
return Ok(WatchScanOutcome::Ok);
}
let source_size_bytes = bytes.len() as u64;
let chunk = Chunk {
data: data.into(),
metadata: ChunkMetadata {
base_offset: 0,
base_line: 0,
source_type: "filesystem".into(),
path: Some(path.display().to_string().into()),
commit: None,
author: None,
date: None,
mtime_ns: None,
size_bytes: Some(source_size_bytes),
decoded_span: None,
},
};
scan_runtime.clear_fragment_cache();
let scan_result = scan_runtime.scan_chunk(&chunk);
scan_runtime.clear_fragment_cache();
let raw_matches = match scan_result {
Ok(matches) => matches,
Err(error) => {
let palette = style::for_stderr();
eprintln!("{} keyhog watch: {error}", style::fail("FAIL", &palette));
return Ok(WatchScanOutcome::EngineFailure);
}
};
let matches = match scan_runtime.filter_and_resolve(raw_matches) {
Ok(matches) => matches,
Err(error) => {
let palette = style::for_stderr();
eprintln!("{} keyhog watch: {error}", style::fail("FAIL", &palette));
return Ok(WatchScanOutcome::EngineFailure);
}
};
let matches = filter_rule_suppressed(&rule_suppressor, matches);
if suppress_duplicate_findings(
path,
findings_fingerprint(&matches),
Instant::now(),
recently_scanned,
) {
return Ok(WatchScanOutcome::Ok);
}
for m in matches {
let credential_identity = format!(
"{} sha256:{}",
keyhog_core::redact(&m.credential),
keyhog_core::hex_encode(m.credential_hash.as_bytes())
);
crate::style::print_diagnostic_finding(
"\u{1F50D}",
&m.detector_id,
&path.display().to_string(),
m.location.line,
m.severity,
m.confidence,
&credential_identity,
)
.with_context(|| format!("write watch finding for {}", path.display()))?;
}
Ok(WatchScanOutcome::Ok)
}
fn suppress_duplicate_event(
path: &std::path::Path,
bytes: &[u8],
now: Instant,
recently_scanned: &mut WatchDedupeState,
) -> bool {
let hash = content_hash(bytes);
if let Some((last, last_hash)) = recently_scanned.entries.get(path) {
if *last_hash == hash && now.saturating_duration_since(*last) < DEDUP_WINDOW {
return true;
}
}
cap_map_fifo(
&mut recently_scanned.entries,
&mut recently_scanned.entry_order,
path,
(now, hash),
);
recently_scanned.scans_since_prune = recently_scanned.scans_since_prune.saturating_add(1);
if recently_scanned.scans_since_prune >= DEDUP_PRUNE_INTERVAL {
recently_scanned.scans_since_prune = 0;
if recently_scanned.entries.len() < DEDUP_MAX_ENTRIES / 2 {
recently_scanned
.entries
.retain(|_, (last, _)| now.saturating_duration_since(*last) < DEDUP_WINDOW);
recently_scanned
.finding_entries
.retain(|_, (last, _)| now.saturating_duration_since(*last) < DEDUP_WINDOW);
recently_scanned
.entry_order
.retain(|p| recently_scanned.entries.contains_key(p));
recently_scanned
.finding_order
.retain(|p| recently_scanned.finding_entries.contains_key(p));
}
}
false
}
fn findings_fingerprint(matches: &[keyhog_core::RawMatch]) -> [u8; 32] {
let mut identities = Vec::with_capacity(matches.len());
for m in matches {
let mut identity = crate::stable_hash::StableHasher::new("watch-finding-identity-v1");
identity
.field_str("detector_id", &m.detector_id)
.field_bytes("credential_hash", m.credential_hash.as_bytes())
.field_str("location.source", &m.location.source)
.field_option_str("location.file_path", m.location.file_path.as_deref())
.field_option_usize("location.line", m.location.line)
.field_usize("location.offset", m.location.offset)
.field_option_str("location.commit", m.location.commit.as_deref())
.field_option_str("location.author", m.location.author.as_deref())
.field_option_str("location.date", m.location.date.as_deref());
identities.push(identity.finish_256());
}
identities.sort_unstable();
let mut set = crate::stable_hash::StableHasher::new("watch-finding-set-v1");
set.field_usize("findings", identities.len());
for (index, identity) in identities.iter().enumerate() {
set.field_usize("finding.index", index)
.field_bytes("finding.identity", identity);
}
set.finish_256()
}
fn suppress_duplicate_findings(
path: &std::path::Path,
fingerprint: [u8; 32],
now: Instant,
recently_scanned: &mut WatchDedupeState,
) -> bool {
if let Some((last, last_fp)) = recently_scanned.finding_entries.get(path) {
if *last_fp == fingerprint && now.saturating_duration_since(*last) < DEDUP_WINDOW {
return true;
}
}
cap_map_fifo(
&mut recently_scanned.finding_entries,
&mut recently_scanned.finding_order,
path,
(now, fingerprint),
);
false
}
pub(crate) mod testing {
use anyhow::Result;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
pub(crate) fn content_hash(data: &[u8]) -> u64 {
super::content_hash(data)
}
pub(crate) fn resolve_watch_roots(requested: &[PathBuf]) -> Result<Vec<PathBuf>> {
super::resolve_watch_roots(requested)
}
pub(crate) fn roots_hint(roots: &[PathBuf]) -> String {
super::roots_hint(roots)
}
#[cfg(test)]
pub(crate) fn rule_suppressor_for_path<'a>(
path: &Path,
roots: &[PathBuf],
suppressors: &'a std::collections::HashMap<PathBuf, keyhog_core::RuleSuppressor>,
) -> &'a keyhog_core::RuleSuppressor {
super::rule_suppressor_for_path(path, roots, suppressors)
}
pub(crate) fn duplicate_event_decisions(
first: &[u8],
second: &[u8],
elapsed: Duration,
) -> (bool, bool) {
let mut recently_scanned = super::WatchDedupeState::default();
let path = Path::new("watched-file.txt");
let first_at = Instant::now();
let second_at = first_at + elapsed;
let first_suppressed =
super::suppress_duplicate_event(path, first, first_at, &mut recently_scanned);
let second_suppressed =
super::suppress_duplicate_event(path, second, second_at, &mut recently_scanned);
(first_suppressed, second_suppressed)
}
pub(crate) fn findings_fingerprint(matches: &[keyhog_core::RawMatch]) -> [u8; 32] {
super::findings_fingerprint(matches)
}
#[cfg(test)]
pub(crate) fn scan_file_surviving_detector_ids(
root: &Path,
file_name: &str,
body: &str,
) -> Result<Vec<String>> {
use crate::orchestrator::load_rule_suppressor;
use keyhog_core::{Chunk, ChunkMetadata};
let file_path = root.join(file_name);
std::fs::write(&file_path, body)?;
let embedded_sentinel = std::path::Path::new("detectors");
#[cfg(feature = "simd")]
let test_backend = keyhog_scanner::ScanBackend::SimdCpu;
#[cfg(not(feature = "simd"))]
let test_backend = keyhog_scanner::ScanBackend::CpuFallback;
let runtime = crate::orchestrator::setup_default_scan_runtime_for_test(
embedded_sentinel,
false,
None,
Some(rayon::current_num_threads()),
Some(test_backend),
"keyhog watch",
false,
Some(root),
)?;
let chunk = Chunk {
data: body.to_string().into(),
metadata: ChunkMetadata {
source_type: "filesystem".into(),
path: Some(file_path.display().to_string().into()),
..Default::default()
},
};
let matches = runtime.scan_chunk(&chunk)?;
let filtered = runtime.filter_and_resolve(matches)?;
let rule_suppressor = load_rule_suppressor(Some(root))?;
let kept = super::filter_rule_suppressed(&rule_suppressor, filtered);
Ok(kept
.iter()
.map(|m| m.detector_id.as_ref().to_string())
.collect())
}
pub(crate) fn duplicate_findings_decisions(
first: [u8; 32],
second: [u8; 32],
elapsed: Duration,
) -> (bool, bool) {
let mut recently_scanned = super::WatchDedupeState::default();
let path = Path::new("watched-file.txt");
let first_at = Instant::now();
let second_at = first_at + elapsed;
let first_suppressed =
super::suppress_duplicate_findings(path, first, first_at, &mut recently_scanned);
let second_suppressed =
super::suppress_duplicate_findings(path, second, second_at, &mut recently_scanned);
(first_suppressed, second_suppressed)
}
}
pub(crate) fn filter_rule_suppressed(
rule_suppressor: &RuleSuppressor,
matches: Vec<RawMatch>,
) -> Vec<RawMatch> {
let _eval_span = keyhog_profile::span(keyhog_profile::Stage::Suppression);
matches
.into_iter()
.filter(|m| !rule_suppressor.matches_raw_match(m))
.collect()
}
fn should_skip(path: &std::path::Path, skip_dirs: &SkipDirPolicy) -> bool {
path.components().any(|c| {
if let std::path::Component::Normal(os) = c {
if let Some(s) = os.to_str() {
return skip_dirs.is_watch_component(s);
}
}
false
})
}
#[cfg(test)]
mod tests;