use crate::support::split_chunk_results;
use keyhog_core::testing as core_testing;
use keyhog_core::Source;
use keyhog_sources::testing::{SourceTestApi, TestApi};
use keyhog_sources::{reset_skipped_over_max_size, skip_counts, FilesystemSource};
use std::fs;
use std::io::Write;
use std::path::Path;
use std::sync::Arc;
fn chunks_without_source_errors<S: Source + ?Sized>(source: &S) -> Vec<keyhog_core::Chunk> {
let source_name = source.name().to_string();
let mut chunks = Vec::new();
let mut errors = Vec::new();
for row in source.chunks() {
match row {
Ok(chunk) => chunks.push(chunk),
Err(error) => errors.push(error),
}
}
assert!(
errors.is_empty(),
"{source_name} source emitted unexpected SourceError rows: {errors:?}"
);
chunks
}
fn chunks_of(dir: &Path) -> Vec<keyhog_core::Chunk> {
let source = FilesystemSource::new(dir.to_path_buf());
chunks_without_source_errors(&source)
}
fn combined_body(chunks: &[keyhog_core::Chunk]) -> String {
chunks.iter().map(|c| c.data.as_str().to_owned()).collect()
}
#[cfg(unix)]
fn symlink_mtime_ns(path: &Path) -> u64 {
fs::symlink_metadata(path)
.ok()
.and_then(|metadata| metadata.modified().ok())
.and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
.map(|duration| {
u64::try_from(
duration.as_secs() as u128 * 1_000_000_000 + duration.subsec_nanos() as u128,
)
.unwrap_or(u64::MAX)
})
.unwrap_or(0)
}
fn scan_single_file(path: &Path) -> Vec<keyhog_core::Chunk> {
let source = FilesystemSource::new(
path.parent()
.unwrap_or_else(|| Path::new("/"))
.to_path_buf(),
)
.with_include_paths(vec![path.to_path_buf()]);
chunks_without_source_errors(&source)
}
#[test]
#[cfg(unix)]
fn symlinked_regular_file_in_walk_is_not_followed() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("real.env"), "TOKEN=real_target_value").unwrap();
symlink(dir.path().join("real.env"), dir.path().join("alias.env")).unwrap();
let chunks = chunks_of(dir.path());
assert_eq!(
chunks.len(),
1,
"symlink alias must not produce a second chunk; paths: {:?}",
chunks
.iter()
.filter_map(|c| c.metadata.path.as_deref())
.collect::<Vec<_>>()
);
assert!(combined_body(&chunks).contains("TOKEN=real_target_value"));
}
#[test]
#[cfg(unix)]
fn symlink_to_external_secret_file_not_read_through_walk() {
use std::os::unix::fs::symlink;
let outside = tempfile::tempdir().unwrap();
fs::write(
outside.path().join("credentials"),
"AWS_SECRET=EXTERNAL_SHOULD_NOT_BE_READ",
)
.unwrap();
let root = tempfile::tempdir().unwrap();
fs::write(root.path().join("ok.txt"), "PUBLIC=inside_root").unwrap();
symlink(
outside.path().join("credentials"),
root.path().join("creds.txt"),
)
.unwrap();
let body = combined_body(&chunks_of(root.path()));
assert!(body.contains("PUBLIC=inside_root"));
assert!(
!body.contains("EXTERNAL_SHOULD_NOT_BE_READ"),
"follow_symlinks(false) must keep external symlink target out of scan"
);
}
#[test]
#[cfg(unix)]
fn included_symlinked_plain_file_is_canonicalized_then_read() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("secret_target.txt");
fs::write(&target, "API_KEY=LINK_TARGET_SECRET_0001").unwrap();
let link = dir.path().join("link.txt");
symlink(&target, &link).unwrap();
let chunks = scan_single_file(&link);
assert_eq!(
chunks.len(),
1,
"include path canonicalizes the symlink then reads the target"
);
assert!(chunks[0].data.contains("LINK_TARGET_SECRET_0001"));
}
#[test]
#[cfg(unix)]
fn merkle_skip_does_not_mask_symlink_refusal() {
use std::os::unix::fs::symlink;
let _guard = TestApi.skip_counter_guard();
TestApi.reset_skip_counters();
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target.txt");
fs::write(&target, "API_KEY=LINK_TARGET_SECRET_0002").unwrap();
let link = dir.path().join("cached-link.txt");
symlink(&target, &link).unwrap();
let link_meta = fs::symlink_metadata(&link).unwrap();
let link_size = link_meta.len();
let link_mtime_ns = symlink_mtime_ns(&link);
let idx = Arc::new(core_testing::CoreTestApi::merkle_empty(
&core_testing::TestApi,
));
core_testing::CoreTestApi::merkle_record_with_metadata(
&core_testing::TestApi,
&idx,
link.clone(),
link_mtime_ns,
link_size,
[0u8; 32],
);
let (rows, merkle_skipped) = TestApi.process_entry_with_merkle(link, link_size, 0, idx);
let (chunks, errors) = split_chunk_results(&rows);
assert!(
chunks.is_empty(),
"refused symlink must not scan target bytes or emit a clean chunk"
);
assert_eq!(
errors.len(),
1,
"symlink refusal must emit one visible SourceError row"
);
let error = errors[0].to_string();
assert!(
error.contains("cached-link.txt") && error.contains("file was not scanned"),
"symlink SourceError must name the refused path, got {error}"
);
assert_eq!(
merkle_skipped, 0,
"Merkle metadata skip must not hide a symlink refusal"
);
assert!(
skip_counts().unreadable >= 1,
"the refused symlink must surface as an unreadable coverage gap"
);
}
#[test]
#[cfg(unix)]
fn symlinked_directory_in_walk_is_not_descended() {
use std::os::unix::fs::symlink;
let secrets = tempfile::tempdir().unwrap();
fs::write(
secrets.path().join("buried.txt"),
"BURIED=SHOULD_NOT_SURFACE_42",
)
.unwrap();
let root = tempfile::tempdir().unwrap();
fs::write(root.path().join("top.txt"), "TOP=visible_99").unwrap();
symlink(secrets.path(), root.path().join("linkdir")).unwrap();
let body = combined_body(&chunks_of(root.path()));
assert!(body.contains("TOP=visible_99"));
assert!(
!body.contains("SHOULD_NOT_SURFACE_42"),
"symlinked directory must not be descended under follow_symlinks(false)"
);
}
#[test]
#[cfg(unix)]
fn self_referential_symlink_loop_yields_only_real_files() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let nested = dir.path().join("nested");
fs::create_dir_all(&nested).unwrap();
fs::write(nested.join("only.env"), "ONLY=one_real_file").unwrap();
symlink(dir.path(), nested.join("loop")).unwrap();
let chunks = chunks_of(dir.path());
assert_eq!(
chunks.len(),
1,
"loop must not multiply the single real file"
);
assert!(chunks[0].data.contains("ONLY=one_real_file"));
}
#[test]
fn hidden_dotfile_is_scanned_because_skip_hidden_is_false() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(".env"), "SECRET=dotfile_secret_value").unwrap();
let chunks = chunks_of(dir.path());
assert_eq!(
chunks.len(),
1,
"hidden .env must be scanned (skip_hidden=false)"
);
assert!(chunks[0].data.contains("SECRET=dotfile_secret_value"));
}
#[test]
fn hidden_subdirectory_contents_are_scanned() {
let dir = tempfile::tempdir().unwrap();
let hidden = dir.path().join(".config");
fs::create_dir_all(&hidden).unwrap();
fs::write(hidden.join("creds.txt"), "TOKEN=in_dot_config_dir").unwrap();
let body = combined_body(&chunks_of(dir.path()));
assert!(
body.contains("TOKEN=in_dot_config_dir"),
"files under a non-excluded hidden dir must be scanned"
);
}
#[test]
fn dot_git_directory_is_excluded() {
let dir = tempfile::tempdir().unwrap();
let git = dir.path().join(".git");
fs::create_dir_all(&git).unwrap();
fs::write(git.join("config"), "GIT_INTERNAL=should_be_skipped_77").unwrap();
fs::write(dir.path().join("app.env"), "APP=scanned_root_88").unwrap();
let body = combined_body(&chunks_of(dir.path()));
assert!(body.contains("APP=scanned_root_88"));
assert!(
!body.contains("should_be_skipped_77"),
".git tree must be excluded"
);
}
#[test]
fn hidden_dotfile_with_secret_in_nested_visible_dir() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("svc");
fs::create_dir_all(&sub).unwrap();
fs::write(sub.join(".secrets"), "DB_PASS=hidden_nested_pw").unwrap();
let body = combined_body(&chunks_of(dir.path()));
assert!(body.contains("DB_PASS=hidden_nested_pw"));
}
#[test]
fn oversize_plain_file_is_skipped_and_undersize_kept() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("small.txt"), "K=under_cap_marker").unwrap();
let big = "BIG=".to_string() + &"y".repeat(4096);
fs::write(dir.path().join("big.txt"), &big).unwrap();
let source = FilesystemSource::new(dir.path().to_path_buf()).with_max_file_size(512);
let rows: Vec<_> = source.chunks().collect();
let (chunks, errors) = split_chunk_results(&rows);
assert_eq!(chunks.len(), 1, "only the under-cap file should emit");
assert!(chunks[0].data.contains("K=under_cap_marker"));
assert_eq!(
errors.len(),
1,
"over-cap sibling must emit one SourceError row"
);
let error = errors[0].to_string();
assert!(
error.contains("big.txt")
&& error.contains("exceeds --max-file-size cap 512")
&& error.contains("file was not scanned"),
"over-cap SourceError must name the unscanned file and reason, got {error}"
);
assert!(
!chunks
.iter()
.any(|chunk| chunk.data.contains(&"y".repeat(64))),
"oversize file bytes must not leak through the cap"
);
}
#[test]
fn file_exactly_at_cap_is_kept() {
let dir = tempfile::tempdir().unwrap();
let content = b"ABCDEFGHIJ"; let path = dir.path().join("exact.txt");
fs::write(&path, content).unwrap();
assert_eq!(fs::metadata(&path).unwrap().len(), 10);
let source = FilesystemSource::new(dir.path().to_path_buf()).with_max_file_size(10);
let chunks = chunks_without_source_errors(&source);
assert_eq!(chunks.len(), 1, "file at exactly the cap must be kept");
assert!(chunks[0].data.contains("ABCDEFGHIJ"));
}
#[test]
fn file_one_byte_over_cap_is_skipped() {
let dir = tempfile::tempdir().unwrap();
let content = b"ABCDEFGHIJK"; fs::write(dir.path().join("over.txt"), content).unwrap();
let source = FilesystemSource::new(dir.path().to_path_buf()).with_max_file_size(10);
let rows: Vec<_> = source.chunks().collect();
let (chunks, errors) = split_chunk_results(&rows);
assert_eq!(chunks.len(), 0, "size == cap+1 must be skipped");
assert_eq!(
errors.len(),
1,
"size == cap+1 must emit a visible SourceError"
);
let error = errors[0].to_string();
assert!(
error.contains("over.txt") && error.contains("exceeds --max-file-size cap 10"),
"over-cap SourceError must name the skipped file and cap, got {error}"
);
}
#[test]
fn live_size_over_cap_wins_over_stale_recorded_size() {
let _guard = TestApi.skip_counter_guard();
reset_skipped_over_max_size();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("grown.txt");
fs::write(&path, "SECRET=stale-size-should-not-leak\n").unwrap();
let rows = TestApi.process_entry_with_recorded_size(path, 4, 8);
let (chunks, errors) = split_chunk_results(&rows);
assert!(
chunks.is_empty(),
"live over-cap file must not emit a partial prefix from stale walker size"
);
assert_eq!(
errors.len(),
1,
"live over-cap file must emit one SourceError"
);
let error = errors[0].to_string();
assert!(
error.contains("grown.txt")
&& error.contains("exceeds --max-file-size cap 8")
&& error.contains("file was not scanned"),
"live over-cap SourceError must name the unscanned file and cap, got {error}"
);
assert_eq!(
skip_counts().over_max_size,
1,
"live over-cap size must be surfaced in skip telemetry"
);
}
#[test]
fn max_file_size_zero_means_unlimited() {
let dir = tempfile::tempdir().unwrap();
let content = "Z=".to_string() + &"z".repeat(10_000);
fs::write(dir.path().join("a.txt"), &content).unwrap();
let source = FilesystemSource::new(dir.path().to_path_buf()).with_max_file_size(0);
let chunks = chunks_without_source_errors(&source);
assert_eq!(
chunks.len(),
1,
"max_file_size(0) is unlimited, not skip-all"
);
assert!(chunks[0].data.contains("Z="));
}
#[test]
fn max_file_size_zero_expands_nonempty_har() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("capture.har");
let har = r#"{
"log": {
"version": "1.2",
"creator": {"name": "test", "version": "1"},
"entries": [
{
"request": {
"method": "POST",
"url": "https://example.test/api?token=har_query_secret_123456",
"headers": [
{"name": "Authorization", "value": "Bearer har_header_secret_123456"}
],
"queryString": [
{"name": "token", "value": "har_query_secret_123456"}
],
"postData": {"text": "{\"api_key\":\"har_post_secret_123456\"}"}
},
"response": {
"status": 200,
"statusText": "OK",
"headers": [
{"name": "Content-Type", "value": "application/json"}
],
"content": {"text": "{\"token\":\"har_response_secret_123456\"}"}
}
}
]
}
}"#;
fs::write(&path, har).unwrap();
let source = FilesystemSource::new(dir.path().to_path_buf()).with_max_file_size(0);
let chunks = chunks_without_source_errors(&source);
assert_eq!(
chunks.len(),
2,
"max_file_size(0) must be uncapped for HAR expansion, not a zero-byte expansion budget"
);
assert!(chunks
.iter()
.any(|chunk| chunk.metadata.source_type.as_ref() == "wire:har:request"));
assert!(chunks
.iter()
.any(|chunk| chunk.metadata.source_type.as_ref() == "wire:har:response"));
let body = combined_body(&chunks);
assert!(body.contains("har_header_secret_123456"));
assert!(body.contains("har_response_secret_123456"));
}
#[test]
fn oversize_skip_increments_global_counter() {
let _guard = TestApi.skip_counter_guard();
reset_skipped_over_max_size();
let dir = tempfile::tempdir().unwrap();
let big = "B=".to_string() + &"q".repeat(2048);
fs::write(dir.path().join("toobig.txt"), &big).unwrap();
let source = FilesystemSource::new(dir.path().to_path_buf()).with_max_file_size(64);
let rows: Vec<_> = source.chunks().collect();
let (_chunks, errors) = split_chunk_results(&rows);
assert_eq!(
errors.len(),
1,
"over-cap file must emit one SourceError row"
);
assert!(
skip_counts().over_max_size >= 1,
"the over-cap file must have bumped the skip counter"
);
}
#[test]
fn cap_applies_to_included_single_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("inc.txt");
let big = "I=".to_string() + &"w".repeat(1000);
fs::write(&path, &big).unwrap();
let source = FilesystemSource::new(dir.path().to_path_buf())
.with_include_paths(vec![path.clone()])
.with_max_file_size(128);
let rows: Vec<_> = source.chunks().collect();
let (chunks, errors) = split_chunk_results(&rows);
assert_eq!(chunks.len(), 0, "cap must apply on the include path too");
assert_eq!(
errors.len(),
1,
"included over-cap file must emit one SourceError row"
);
}
#[test]
fn binary_extension_png_skipped_via_include_path() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("logo.png");
fs::write(&path, "this is actually text not a png API_KEY=x").unwrap();
let chunks = scan_single_file(&path);
assert!(
chunks.is_empty(),
".png is in SKIP_EXTENSIONS and must be skipped; got {} chunks",
chunks.len()
);
}
#[test]
fn binary_extension_match_is_case_insensitive() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("IMAGE.PNG");
fs::write(&path, "text content SECRET=should_not_emit").unwrap();
let chunks = scan_single_file(&path);
assert!(
chunks.is_empty(),
"uppercase .PNG must skip just like .png (ext lowercased)"
);
}
#[test]
fn binary_extension_exe_skipped_via_include_path() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("tool.exe");
fs::write(&path, "MZ-but-as-text TOKEN=nope").unwrap();
let chunks = scan_single_file(&path);
assert!(chunks.is_empty(), ".exe must be skipped by extension");
}
#[test]
fn dot_bin_extension_skipped_via_include_path() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("firmware.bin");
fs::write(&path, "AKIA-looking text TOKEN=ignored").unwrap();
let chunks = scan_single_file(&path);
assert!(chunks.is_empty(), ".bin must be skipped by extension");
}
#[test]
fn over_cap_binary_extension_skips_as_binary_not_source_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("firmware.bin");
fs::write(&path, vec![0u8; 4096]).unwrap();
let source = FilesystemSource::new(dir.path().to_path_buf()).with_max_file_size(64);
let rows: Vec<_> = source.chunks().collect();
let (chunks, errors) = split_chunk_results(&rows);
assert!(chunks.is_empty(), ".bin must not emit scan chunks");
assert!(
errors.is_empty(),
".bin over cap must remain a binary skip, not SourceError: {errors:?}"
);
}
#[test]
fn safetensors_extension_skipped_via_include_path() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("model.safetensors");
fs::write(&path, "weights-as-text KEY=irrelevant").unwrap();
let chunks = scan_single_file(&path);
assert!(
chunks.is_empty(),
".safetensors must be skipped by extension"
);
}
#[test]
fn tar_archive_content_is_unpacked_and_scanned() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bundle.tar");
let mut builder = tar::Builder::new(Vec::new());
let body = b"SECRET=found_inside_tar_entry";
let mut header = tar::Header::new_ustar();
header.set_path("leak.env").unwrap();
header.set_size(body.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder.append(&header, &body[..]).unwrap();
let tar_bytes = builder.into_inner().unwrap();
fs::write(&path, &tar_bytes).unwrap();
let chunks = scan_single_file(&path);
assert!(
!chunks.is_empty(),
".tar must be unpacked and its entries scanned, not skipped by extension"
);
let body = combined_body(&chunks);
assert!(
body.contains("SECRET=found_inside_tar_entry"),
".tar entry body must reach the scanner; got chunks: {chunks:#?}"
);
assert!(
chunks.iter().any(|c| c
.metadata
.path
.as_deref()
.is_some_and(|p| p.contains("//leak.env"))),
".tar entry chunk must carry the `<archive>//<entry>` path; got chunks: {chunks:#?}"
);
}
#[test]
fn zip_archive_default_excluded_entries_are_counted() {
let _guard = TestApi.skip_counter_guard();
TestApi.reset_skip_counters();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bundle.zip");
let file = fs::File::create(&path).unwrap();
let mut zip = zip::ZipWriter::new(file);
let opts =
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
zip.start_file("node_modules/hidden.env", opts).unwrap();
zip.write_all(b"SECRET=excluded_from_archive").unwrap();
zip.start_file("safe.env", opts).unwrap();
zip.write_all(b"SECRET=scanned_from_archive").unwrap();
zip.finish().unwrap();
let chunks = scan_single_file(&path);
let body = combined_body(&chunks);
assert!(
body.contains("SECRET=scanned_from_archive"),
"non-excluded zip entry must still be scanned; got chunks: {chunks:#?}"
);
assert!(
!body.contains("excluded_from_archive"),
"default-excluded zip entry must not be scanned; got chunks: {chunks:#?}"
);
assert_eq!(
skip_counts().excluded,
1,
"default-excluded archive entries must be counted as excluded coverage gaps"
);
}
#[test]
fn ordinary_text_extension_is_scanned() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ok.py");
fs::write(&path, "API_KEY = 'scanned_python_secret_01'").unwrap();
let chunks = scan_single_file(&path);
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].metadata.source_type.as_ref(), "filesystem");
assert!(chunks[0].data.contains("scanned_python_secret_01"));
}
#[test]
fn extensionless_elf_magic_is_skipped() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("loader"); let mut bytes = b"\x7fELF".to_vec();
bytes.extend_from_slice(b"\x02\x01\x01\x00 padding API_KEY=elf");
fs::write(&path, &bytes).unwrap();
let chunks = scan_single_file(&path);
assert!(
chunks.is_empty(),
"ELF-magic extensionless file must be skipped by the prefix sniff"
);
}
#[test]
fn elf_magic_with_nonskip_extension_falls_back_to_strings_not_text() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("a.out"); let mut bytes = b"\x7fELF".to_vec();
bytes.extend_from_slice(b"\x02\x01\x01\x00 padding API_KEY=elfmarker");
fs::write(&path, &bytes).unwrap();
let chunks = scan_single_file(&path);
assert!(
chunks
.iter()
.all(|c| c.metadata.source_type.as_ref() != "filesystem"),
"ELF magic must keep the decoder from treating it as plain text"
);
assert!(
chunks.iter().any(
|c| c.metadata.source_type.as_ref() == "filesystem:binary-strings"
&& c.data.contains("API_KEY=elfmarker")
),
"embedded printable run must surface via the binary-strings fallback"
);
}
#[test]
fn extensionless_mz_text_like_prefix_is_scanned() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("program"); fs::write(&path, b"MZ_TOKEN=text_prefix_value").unwrap();
let chunks = scan_single_file(&path);
assert_eq!(
chunks.len(),
1,
"bare MZ-prefixed text must stay on the recall-preserving scan path"
);
assert!(chunks[0].data.contains("MZ_TOKEN=text_prefix_value"));
}
#[test]
fn extensionless_pe_header_is_skipped_by_structural_sniff() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("program"); let mut bytes = vec![0u8; 160];
bytes[0..2].copy_from_slice(b"MZ");
bytes[60..64].copy_from_slice(&128u32.to_le_bytes());
bytes[128..132].copy_from_slice(b"PE\0\0");
bytes.extend_from_slice(b"KEY=pe_should_not_scan");
fs::write(&path, &bytes).unwrap();
let chunks = scan_single_file(&path);
assert!(
chunks.is_empty(),
"structural PE header in extensionless file must be skipped"
);
}
#[test]
fn extensionless_bm_text_like_prefix_is_scanned() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bitmap-note"); fs::write(&path, b"BM_TOKEN=text_prefix_value").unwrap();
let chunks = scan_single_file(&path);
assert_eq!(
chunks.len(),
1,
"bare BM-prefixed text must not be classified as BMP binary"
);
assert!(chunks[0].data.contains("BM_TOKEN=text_prefix_value"));
}
#[test]
fn extensionless_pdf_magic_is_skipped() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("doc"); fs::write(&path, b"%PDF-1.7 binary stuff SECRET=pdf").unwrap();
let chunks = scan_single_file(&path);
assert!(
chunks.is_empty(),
"%PDF-magic extensionless file must be skipped"
);
}
#[test]
fn extensionless_zip_magic_is_skipped_by_sniff() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("archive"); fs::write(&path, b"PK\x03\x04 zip-ish bytes TOKEN=pk").unwrap();
let chunks = scan_single_file(&path);
assert!(
chunks.is_empty(),
"PK magic extensionless file must be skipped"
);
}
#[test]
fn extensionless_repeated_nul_run_in_prefix_is_skipped() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("rawblob"); fs::write(&path, b"abc\0\0\0\0def more text KEY=nul").unwrap();
let chunks = scan_single_file(&path);
assert!(
chunks.is_empty(),
"a repeated NUL run within the prefix must trip the binary sniff"
);
}
#[test]
fn extensionless_clean_text_passes_the_sniff() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("Makefile"); fs::write(&path, "CONFIG_TOKEN=clean_no_extension_value").unwrap();
let chunks = scan_single_file(&path);
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].metadata.source_type.as_ref(), "filesystem");
assert!(chunks[0].data.contains("clean_no_extension_value"));
}
#[test]
fn extensionless_single_nul_in_prefix_is_not_caught_by_sniff() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("notes"); let mut bytes = b"clean leading sixteen bytes here ".to_vec(); bytes.extend_from_slice(b"and then KEY=late_nul_value");
bytes.push(0x00); fs::write(&path, &bytes).unwrap();
let chunks = scan_single_file(&path);
assert_eq!(
chunks.len(),
1,
"a single NUL must not trip the prefix sniff and survives decode"
);
assert!(chunks[0].data.contains("KEY=late_nul_value"));
}
#[test]
fn high_nul_density_binary_falls_back_to_printable_strings() {
let dir = tempfile::tempdir().unwrap();
let mut bytes = vec![0u8; 32];
bytes.extend_from_slice(b"AKIAFALLBACKSTRINGMARKER01");
bytes.extend_from_slice(&[0u8; 32]);
fs::write(dir.path().join("blob.dat"), &bytes).unwrap();
let chunks = chunks_of(dir.path());
assert!(
chunks.iter().any(
|c| c.metadata.source_type.as_ref() == "filesystem:binary-strings"
&& c.data.contains("AKIAFALLBACKSTRINGMARKER01")
),
"binary body must surface via printable-strings fallback; got {:?}",
chunks
.iter()
.map(|c| c.metadata.source_type.clone())
.collect::<Vec<_>>()
);
}
#[test]
fn mmap_binary_falls_back_to_printable_strings_without_reread() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("large-binary.dat");
let mut bytes = vec![0u8; 70 * 1024];
bytes.extend_from_slice(b"AKIAMMAPFALLBACKMARKER01");
bytes.extend_from_slice(&[0u8; 4096]);
fs::write(&path, &bytes).unwrap();
let chunks = scan_single_file(&path);
assert!(
chunks.iter().any(
|c| c.metadata.source_type.as_ref() == "filesystem:binary-strings"
&& c.data.contains("AKIAMMAPFALLBACKMARKER01")
),
"mmap-backed binary body must surface via printable-strings fallback; got {:?}",
chunks
.iter()
.map(|c| c.metadata.source_type.clone())
.collect::<Vec<_>>()
);
}
fn utf16le_no_bom(s: &str) -> Vec<u8> {
let mut out = Vec::with_capacity(s.len() * 2);
for unit in s.encode_utf16() {
out.extend_from_slice(&unit.to_le_bytes());
}
out
}
#[test]
fn large_utf16le_file_with_ascii_secret_is_decoded_not_windowed_nul_interleaved() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("powershell-transcript.txt");
let marker = "AKIA_UTF16_CONTIGUOUS_RECALL_MARKER_01";
let filler_line = "Write-Output 'routine benign transcript line padding'\r\n";
let mut text = String::new();
while text.len() < 1_200_000 {
text.push_str(filler_line);
}
text.push_str(marker);
text.push_str("\r\n");
while text.len() < 1_400_000 {
text.push_str(filler_line);
}
let mut bytes = vec![0xFF, 0xFE]; bytes.extend_from_slice(&utf16le_no_bom(&text));
assert!(
bytes.len() as u64 > 1024 * 1024,
"test file ({} bytes) must exceed the 1 MiB windowed-path threshold",
bytes.len()
);
fs::write(&path, &bytes).unwrap();
let chunks = scan_single_file(&path);
let source_types: Vec<String> = chunks
.iter()
.map(|c| c.metadata.source_type.to_string())
.collect();
let body = combined_body(&chunks);
assert!(
body.contains(marker),
"UTF-16 ASCII secret must survive de-interleaved; {} chunk(s), source_types {:?}",
chunks.len(),
source_types
);
let interleaved: String = marker.chars().flat_map(|c| [c, '\u{0}']).collect();
assert!(
!body.contains(&interleaved),
"secret must not appear NUL-interleaved (would mean the raw-byte windowed path ran)"
);
assert!(
chunks
.iter()
.all(|c| c.metadata.source_type.as_ref() != "filesystem/windowed"),
"UTF-16 large file must not be emitted as raw windowed chunks; source_types {source_types:?}"
);
}
#[test]
fn binary_with_only_short_runs_yields_no_chunk() {
let _guard = TestApi.skip_counter_guard();
TestApi.reset_skip_counters();
let dir = tempfile::tempdir().unwrap();
let bytes = b"abc\x00de\x00f\x00gh\x00ij\x00".to_vec();
fs::write(dir.path().join("tiny.dat"), &bytes).unwrap();
let chunks = chunks_of(dir.path());
assert!(
chunks.is_empty(),
"binary with only <8-char runs must yield no chunk; got {:?}",
chunks
.iter()
.map(|c| c.metadata.source_type.clone())
.collect::<Vec<_>>()
);
assert!(
skip_counts().binary >= 1,
"binary files with no printable scan chunk must be counted as skipped binary coverage"
);
}
#[test]
fn pdf_magic_dat_file_not_scanned_as_text() {
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join("file.dat"),
b"%PDF-1.4\n%binarygunk\x00\x01\x02",
)
.unwrap();
let chunks = chunks_of(dir.path());
assert!(
chunks
.iter()
.all(|c| c.metadata.source_type.as_ref() != "filesystem"),
"PDF-magic file must never be decoded as plain text"
);
}
#[test]
fn keyhogignore_file_excludes_matching_path() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(".keyhogignore"), "ignored.txt\n").unwrap();
fs::write(dir.path().join("ignored.txt"), "SECRET=should_be_ignored").unwrap();
fs::write(dir.path().join("kept.txt"), "SECRET=should_be_kept").unwrap();
let body = combined_body(&chunks_of(dir.path()));
assert!(body.contains("should_be_kept"));
assert!(
!body.contains("should_be_ignored"),
".keyhogignore pattern must exclude the listed file"
);
}
#[test]
fn keyhogignore_glob_pattern_excludes_by_extension() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(".keyhogignore"), "*.secret\n").unwrap();
fs::write(dir.path().join("a.secret"), "TOKEN=glob_excluded_a").unwrap();
fs::write(dir.path().join("b.secret"), "TOKEN=glob_excluded_b").unwrap();
fs::write(dir.path().join("c.txt"), "TOKEN=glob_kept_c").unwrap();
let body = combined_body(&chunks_of(dir.path()));
assert!(body.contains("glob_kept_c"));
assert!(!body.contains("glob_excluded_a"));
assert!(!body.contains("glob_excluded_b"));
}
#[test]
fn keyhogignore_directory_pattern_excludes_subtree() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(".keyhogignore"), "fixtures/\n").unwrap();
let fixtures = dir.path().join("fixtures");
fs::create_dir_all(&fixtures).unwrap();
fs::write(fixtures.join("data.txt"), "TOKEN=inside_fixtures_dir").unwrap();
fs::write(dir.path().join("real.txt"), "TOKEN=outside_fixtures").unwrap();
let body = combined_body(&chunks_of(dir.path()));
assert!(body.contains("outside_fixtures"));
assert!(
!body.contains("inside_fixtures_dir"),
"a directory pattern in .keyhogignore must exclude the subtree"
);
}
#[test]
fn ignore_patterns_via_with_ignore_paths_exclude_file() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("drop_me.log"), "SECRET=excluded_by_flag").unwrap();
fs::write(dir.path().join("keep_me.log"), "SECRET=kept_by_flag").unwrap();
let source = FilesystemSource::new(dir.path().to_path_buf())
.with_ignore_paths(vec!["**/drop_me.log".to_string()]);
let body = combined_body(&chunks_without_source_errors(&source));
assert!(body.contains("kept_by_flag"));
assert!(
!body.contains("excluded_by_flag"),
"with_ignore_paths pattern must exclude the matching file"
);
}
#[test]
fn respect_gitignore_false_still_scans_ignored_file() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(".gitignore"), "stash.txt\n").unwrap();
fs::write(dir.path().join("stash.txt"), "LEAK=found_despite_gitignore").unwrap();
let source = FilesystemSource::new(dir.path().to_path_buf()).with_respect_gitignore(false);
let body = combined_body(&chunks_without_source_errors(&source));
assert!(
body.contains("found_despite_gitignore"),
"respect_gitignore(false) must surface .gitignore'd files (scan-system mode)"
);
}
#[test]
fn respect_gitignore_true_hides_gitignored_file() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir(dir.path().join(".git")).unwrap();
fs::write(dir.path().join(".gitignore"), "stash.txt\n").unwrap();
fs::write(dir.path().join("stash.txt"), "LEAK=hidden_by_gitignore").unwrap();
fs::write(dir.path().join("visible.txt"), "OK=visible_default").unwrap();
let body = combined_body(&chunks_of(dir.path()));
assert!(body.contains("visible_default"));
assert!(
!body.contains("hidden_by_gitignore"),
"default respect_gitignore(true) must hide .gitignore'd files"
);
}
#[test]
fn keyhogignore_negation_reincludes_file() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(".keyhogignore"), "*.env\n!keep.env\n").unwrap();
fs::write(dir.path().join("drop.env"), "SECRET=dropped_env").unwrap();
fs::write(dir.path().join("keep.env"), "SECRET=reincluded_env").unwrap();
let body = combined_body(&chunks_of(dir.path()));
assert!(
body.contains("reincluded_env"),
"`!keep.env` negation must re-include the file"
);
assert!(
!body.contains("dropped_env"),
"`*.env` must still drop the non-negated env file"
);
}
#[test]
fn package_lock_json_excluded_by_filename() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("package-lock.json");
fs::write(&path, "{\"token\": \"SECRET=in_lockfile\"}").unwrap();
let chunks = scan_single_file(&path);
assert!(
chunks.is_empty(),
"package-lock.json must be dropped by is_default_excluded"
);
}
#[test]
fn min_js_excluded_by_filename_substring() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("app.min.js");
fs::write(&path, "var SECRET='minified_secret'").unwrap();
let chunks = scan_single_file(&path);
assert!(chunks.is_empty(), ".min.js must be excluded");
}
#[test]
fn bundle_js_excluded_by_filename_substring() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("vendor.bundle.js");
fs::write(&path, "var SECRET='bundled_secret'").unwrap();
let chunks = scan_single_file(&path);
assert!(chunks.is_empty(), ".bundle.js must be excluded");
}
#[test]
fn chunk_js_suffix_excluded() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("3.chunk.js");
fs::write(&path, "var SECRET='chunked'").unwrap();
let chunks = scan_single_file(&path);
assert!(chunks.is_empty(), ".chunk.js suffix must be excluded");
}
#[test]
fn tsconfig_json_excluded_by_filename() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("tsconfig.json");
fs::write(&path, "{\"compilerOptions\": {\"key\":\"SECRET=tsc\"}}").unwrap();
let chunks = scan_single_file(&path);
assert!(chunks.is_empty(), "tsconfig.json must be excluded");
}
#[test]
fn dot_map_suffix_excluded() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bundle.js.map");
fs::write(&path, "{\"mappings\":\"SECRET=sourcemap\"}").unwrap();
let chunks = scan_single_file(&path);
assert!(chunks.is_empty(), ".map suffix must be excluded");
}
#[test]
fn cargo_lock_filename_excluded_case_insensitive() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("Cargo.lock");
fs::write(&path, "name = \"SECRET=in_cargo_lock\"").unwrap();
let chunks = scan_single_file(&path);
assert!(
chunks.is_empty(),
"Cargo.lock must be excluded (case-insensitive filename match)"
);
}
#[test]
fn scanned_text_chunk_carries_path_and_size_metadata() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("conf.txt");
let content = "API_KEY=meta_check_value_123";
fs::write(&path, content).unwrap();
let size = fs::metadata(&path).unwrap().len();
let chunks = chunks_of(dir.path());
assert_eq!(chunks.len(), 1);
let meta = &chunks[0].metadata;
assert_eq!(meta.source_type.as_ref(), "filesystem");
assert_eq!(meta.size_bytes, Some(size));
assert!(meta.mtime_ns.is_some());
let p = meta.path.as_deref().expect("path metadata must be set");
assert!(
p.ends_with("conf.txt"),
"chunk path should end with the file name, got {p}"
);
}
#[test]
fn empty_file_yields_no_chunk() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("empty.txt"), b"").unwrap();
let chunks = chunks_of(dir.path());
assert!(chunks.is_empty(), "empty file must yield no chunk");
}
#[test]
fn name_is_filesystem() {
let dir = tempfile::tempdir().unwrap();
let source = FilesystemSource::new(dir.path().to_path_buf());
assert_eq!(source.name(), "filesystem");
}
#[test]
fn mixed_tree_only_eligible_files_emit() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(".env"), "A=hidden_env_secret").unwrap();
fs::write(dir.path().join("image.png"), [0x89, 0x50, 0x4e, 0x47]).unwrap();
fs::write(dir.path().join("app.py"), "B=python_secret").unwrap();
let nm = dir.path().join("node_modules");
fs::create_dir_all(&nm).unwrap();
fs::write(nm.join("secret.js"), "C=node_modules_secret").unwrap();
let chunks = chunks_of(dir.path());
assert_eq!(
chunks.len(),
2,
"expected exactly .env + app.py; paths: {:?}",
chunks
.iter()
.filter_map(|c| c.metadata.path.as_deref())
.collect::<Vec<_>>()
);
let body = combined_body(&chunks);
assert!(body.contains("hidden_env_secret"));
assert!(body.contains("python_secret"));
assert!(!body.contains("node_modules_secret"));
}
#[test]
fn nonexistent_root_yields_source_error_without_panic() {
let missing = std::env::temp_dir().join("keyhog-gap-nonexistent-root-xyz-77");
let _ = fs::remove_dir_all(&missing);
let source = FilesystemSource::new(missing);
let results: Vec<_> = source.chunks().collect();
assert_eq!(results.len(), 1, "missing root must yield one error");
assert!(
results[0].is_err(),
"missing root must yield SourceError, not content"
);
}
#[test]
fn deeply_nested_real_directories_are_walked() {
let dir = tempfile::tempdir().unwrap();
let mut p = dir.path().to_path_buf();
for i in 0..20 {
p = p.join(format!("d{i}"));
}
fs::create_dir_all(&p).unwrap();
fs::write(p.join("leaf.txt"), "DEEP=leaf_secret_value").unwrap();
let body = combined_body(&chunks_of(dir.path()));
assert!(
body.contains("DEEP=leaf_secret_value"),
"deep real directory chain must be fully walked"
);
}
#[test]
fn embedded_non_zip_file_starting_with_pk_is_scanned_as_text() {
let _guard = TestApi.skip_counter_guard();
TestApi.reset_skip_counters();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bundle.zip");
let file = fs::File::create(&path).unwrap();
let mut zip = zip::ZipWriter::new(file);
let opts =
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
zip.start_file("not_a_zip.zip", opts).unwrap();
zip.write_all(b"PK_is_the_start_but_this_is_plain_text_TOKEN=some_mock_secret_value")
.unwrap();
zip.finish().unwrap();
let chunks = scan_single_file(&path);
let body = combined_body(&chunks);
assert!(
body.contains("TOKEN=some_mock_secret_value"),
"embedded plain text file starting with PK must be successfully scanned; got chunks: {chunks:#?}"
);
}