#![cfg(feature = "git")]
use std::path::{Path, PathBuf};
use std::process::Command;
use keyhog_core::{Chunk, Source, SourceError};
use keyhog_sources::GitSource;
fn git(repo: &Path, args: &[&str]) {
let output = Command::new("git")
.args(args)
.current_dir(repo)
.output()
.unwrap_or_else(|e| panic!("git {args:?} failed to spawn: {e}"));
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
fn init_repo() -> (tempfile::TempDir, PathBuf) {
let temp = tempfile::tempdir().expect("tempdir");
let repo = temp.path().to_path_buf();
git(&repo, &["init", "-b", "main"]);
git(&repo, &["config", "user.email", "gap@test.example"]);
git(&repo, &["config", "user.name", "Gap Author"]);
(temp, repo)
}
fn commit_file(repo: &Path, relpath: &str, content: &[u8], message: &str) -> String {
let path = repo.join(relpath);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("mkdir parent");
}
std::fs::write(&path, content).expect("write fixture");
git(repo, &["add", relpath]);
commit_only(repo, message)
}
fn commit_only(repo: &Path, message: &str) -> String {
let output = Command::new("git")
.args(["commit", "-m", message])
.current_dir(repo)
.output()
.expect("git commit spawn");
assert!(
output.status.success(),
"git commit failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let rev = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo)
.output()
.expect("rev-parse spawn");
assert!(rev.status.success(), "rev-parse failed");
String::from_utf8_lossy(&rev.stdout).trim().to_string()
}
fn collect_chunks(repo: &Path, max_commits: usize) -> Vec<Chunk> {
GitSource::new(repo.to_path_buf())
.with_max_commits(max_commits)
.chunks()
.map(|r| r.expect("git chunk should not error"))
.collect()
}
fn chunk_for<'a>(chunks: &'a [Chunk], suffix: &str) -> Option<&'a Chunk> {
chunks.iter().find(|c| {
c.metadata
.path
.as_deref()
.is_some_and(|p| p.ends_with(suffix))
})
}
#[test]
fn non_utf8_blob_is_scanned_lossily_not_dropped() {
let (_t, repo) = init_repo();
let mut bytes = Vec::new();
bytes.extend_from_slice(b"# don\x92t drop me\n");
bytes.extend_from_slice(b"AWS=AKIAIOSFODNN7EXAMPLE\n");
assert!(
std::str::from_utf8(&bytes).is_err(),
"fixture must be non-UTF-8"
);
commit_file(&repo, "cfg.ini", &bytes, "non-utf8 config");
let chunks = collect_chunks(&repo, 1);
let c = chunk_for(&chunks, "cfg.ini").expect("cfg.ini chunk present");
assert!(
c.data.contains("AKIAIOSFODNN7EXAMPLE"),
"credential beside a stray high byte must survive lossy decode; got {:?}",
c.data.to_string()
);
assert!(
c.data.contains("drop me"),
"surrounding text must be preserved; got {:?}",
c.data.to_string()
);
assert!(
c.data.contains('\u{FFFD}'),
"the invalid byte must become the replacement char; got {:?}",
c.data.to_string()
);
}
#[test]
fn latin1_high_bytes_decoded_lossily() {
let (_t, repo) = init_repo();
let mut bytes = Vec::new();
bytes.extend_from_slice("caf".as_bytes());
bytes.push(0xE9); bytes.extend_from_slice(b" TOKEN=ghp_latin1Survives0000000000001\n");
assert!(std::str::from_utf8(&bytes).is_err());
commit_file(&repo, "notes.txt", &bytes, "latin1");
let chunks = collect_chunks(&repo, 1);
let c = chunk_for(&chunks, "notes.txt").expect("notes.txt present");
assert!(c.data.contains("ghp_latin1Survives0000000000001"));
}
#[test]
fn empty_blob_emits_empty_chunk_with_zero_size() {
let (_t, repo) = init_repo();
commit_file(&repo, "empty.txt", b"", "empty file");
let chunks = collect_chunks(&repo, 1);
let c = chunk_for(&chunks, "empty.txt").expect("empty.txt chunk emitted");
assert_eq!(c.data.len(), 0, "empty blob -> empty data");
assert_eq!(c.data.to_string(), "");
assert_eq!(
c.metadata.size_bytes,
Some(0),
"header.size() of an empty blob is 0"
);
}
#[test]
fn pure_binary_blob_is_skipped_not_emitted() {
let (_t, repo) = init_repo();
let mut elf = Vec::new();
elf.extend_from_slice(b"\x7fELF");
elf.extend_from_slice(&[0u8; 64]);
elf.extend_from_slice(b"AKIAIOSFODNN7EXAMPLE"); commit_file(&repo, "a.out", &elf, "binary");
commit_file(
&repo,
"real.env",
b"KEY=ghp_realFileSurvives000000000001\n",
"text",
);
let chunks = collect_chunks(&repo, 5);
assert!(
chunk_for(&chunks, "a.out").is_none(),
"binary blob (ELF magic) must be skipped entirely"
);
assert!(
chunk_for(&chunks, "real.env").is_some(),
"sibling text file must still be scanned"
);
}
#[test]
fn png_magic_blob_is_skipped() {
let (_t, repo) = init_repo();
let mut png = Vec::new();
png.extend_from_slice(b"\x89PNG\r\n\x1a\n");
png.extend_from_slice(b"some_pixels_that_look_like AKIAIOSFODNN7EXAMPLE");
commit_file(&repo, "img.png", &png, "png");
commit_file(&repo, "keep.txt", b"x=1\n", "keep");
let chunks = collect_chunks(&repo, 5);
assert!(
chunk_for(&chunks, "img.png").is_none(),
"PNG magic -> skipped"
);
assert!(chunk_for(&chunks, "keep.txt").is_some());
}
#[test]
fn early_nul_byte_marks_blob_binary_and_skips() {
let (_t, repo) = init_repo();
let mut bytes = Vec::new();
bytes.extend_from_slice(b"abc");
bytes.push(0x00);
bytes.extend_from_slice(b"SECRET=AKIAIOSFODNN7EXAMPLE\n");
commit_file(&repo, "blob.dat", &bytes, "early nul");
commit_file(&repo, "ok.txt", b"y=2\n", "ok");
let chunks = collect_chunks(&repo, 5);
assert!(
chunk_for(&chunks, "blob.dat").is_none(),
"early NUL (non-UTF16) -> binary -> skipped"
);
assert!(chunk_for(&chunks, "ok.txt").is_some());
}
#[test]
fn high_c0_control_density_marks_binary() {
let (_t, repo) = init_repo();
let mut bytes = vec![0xFFu8]; for _ in 0..10 {
bytes.push(0x01);
}
bytes.extend_from_slice(&[b'a'; 80]);
bytes.extend_from_slice(b"AKIAIOSFODNN7EXAMPLE");
assert!(std::str::from_utf8(&bytes).is_err());
assert!(!bytes.contains(&0u8));
commit_file(&repo, "dense.bin", &bytes, "dense controls");
commit_file(&repo, "plain.txt", b"z=3\n", "plain");
let chunks = collect_chunks(&repo, 5);
assert!(
chunk_for(&chunks, "dense.bin").is_none(),
">5% C0-control density -> binary -> skipped"
);
assert!(chunk_for(&chunks, "plain.txt").is_some());
}
#[test]
fn low_c0_control_density_below_threshold_is_kept() {
let (_t, repo) = init_repo();
let mut bytes = vec![0xFFu8]; bytes.push(0x01); bytes.extend_from_slice(b"PLENTY_OF_NORMAL_TEXT_AKIAIOSFODNN7EXAMPLE_xxxxx"); let total = bytes.len() as u64;
assert!(20 <= total, "need total>=20 so 1*20 !> total");
assert!(std::str::from_utf8(&bytes).is_err());
commit_file(&repo, "sparse.txt", &bytes, "sparse control");
let chunks = collect_chunks(&repo, 1);
let c = chunk_for(&chunks, "sparse.txt").expect("sparse.txt kept");
assert!(
c.data.contains("AKIAIOSFODNN7EXAMPLE"),
"below-threshold control density must be scanned lossily; got {:?}",
c.data.to_string()
);
}
#[test]
fn valid_utf8_blob_kept_byte_for_byte() {
let (_t, repo) = init_repo();
let content = "user=café\nGITHUB=ghp_validUtf8Multibyte000000000001\n";
commit_file(&repo, "u.txt", content.as_bytes(), "utf8");
let chunks = collect_chunks(&repo, 1);
let c = chunk_for(&chunks, "u.txt").expect("u.txt present");
assert_eq!(c.data.to_string(), content, "valid UTF-8 must be verbatim");
assert!(
!c.data.contains('\u{FFFD}'),
"no lossy replacement for valid UTF-8"
);
}
#[test]
fn head_blob_is_labelled_git_head() {
let (_t, repo) = init_repo();
commit_file(
&repo,
"live.env",
b"K=ghp_liveInHead00000000000000000001\n",
"live",
);
let chunks = collect_chunks(&repo, 1);
let c = chunk_for(&chunks, "live.env").expect("live.env present");
assert_eq!(
c.metadata.source_type, "git/head",
"blob reachable from HEAD tree is labelled git/head"
);
}
#[test]
fn removed_blob_is_labelled_git_history() {
let (_t, repo) = init_repo();
commit_file(
&repo,
"rot.env",
b"OLD=ghp_removedFromHead0000000000001\n",
"add secret",
);
commit_file(&repo, "rot.env", b"OLD=redacted\n", "scrub secret");
let chunks = collect_chunks(&repo, 5);
let hist = chunks
.iter()
.find(|c| c.data.contains("ghp_removedFromHead0000000000001"))
.expect("historical secret blob must still be surfaced");
assert_eq!(
hist.metadata.source_type, "git/history",
"a blob no longer in HEAD must be labelled git/history"
);
let live = chunks
.iter()
.find(|c| c.data.contains("redacted"))
.expect("current blob present");
assert_eq!(live.metadata.source_type, "git/head");
}
#[test]
fn commit_hash_attribution_is_full_40_hex() {
let (_t, repo) = init_repo();
let hash = commit_file(&repo, "c.txt", b"v=1\n", "attrib");
let chunks = collect_chunks(&repo, 1);
let c = chunk_for(&chunks, "c.txt").expect("c.txt present");
let got = c.metadata.commit.as_deref().expect("commit set");
assert_eq!(got, hash, "chunk commit must equal the actual HEAD hash");
assert_eq!(got.len(), 40, "git log %H is a full 40-char SHA-1");
assert!(got.chars().all(|ch| ch.is_ascii_hexdigit()));
}
#[test]
fn author_attribution_matches_commit_author_name() {
let (_t, repo) = init_repo();
commit_file(&repo, "a.txt", b"v=1\n", "author");
let chunks = collect_chunks(&repo, 1);
let c = chunk_for(&chunks, "a.txt").expect("a.txt present");
assert_eq!(
c.metadata.author.as_deref(),
Some("Gap Author"),
"author must be %an from git log"
);
}
#[test]
fn author_with_internal_space_is_preserved_by_splitn() {
let (_t, repo) = init_repo();
git(&repo, &["config", "user.name", "Ada B. Lovelace"]);
commit_file(&repo, "ada.txt", b"v=1\n", "multi-word author");
let chunks = collect_chunks(&repo, 1);
let c = chunk_for(&chunks, "ada.txt").expect("ada.txt present");
assert_eq!(
c.metadata.author.as_deref(),
Some("Ada B. Lovelace"),
"splitn(2,' ') keeps the full author name including spaces"
);
}
#[test]
fn size_bytes_equals_raw_blob_byte_length() {
let (_t, repo) = init_repo();
let content = b"FOO=barbaz\n"; commit_file(&repo, "s.txt", content, "size");
let chunks = collect_chunks(&repo, 1);
let c = chunk_for(&chunks, "s.txt").expect("s.txt present");
assert_eq!(
c.metadata.size_bytes,
Some(content.len() as u64),
"size_bytes must be the raw blob byte length (11)"
);
}
#[test]
fn size_bytes_counts_bytes_not_chars_for_non_utf8() {
let (_t, repo) = init_repo();
let mut bytes = b"abc".to_vec();
bytes.push(0x92); bytes.push(b'\n');
let raw_len = bytes.len() as u64; commit_file(&repo, "nb.txt", &bytes, "bytes");
let chunks = collect_chunks(&repo, 1);
let c = chunk_for(&chunks, "nb.txt").expect("nb.txt present");
assert_eq!(
c.metadata.size_bytes,
Some(raw_len),
"size_bytes is the raw byte count (5), not lossy-decoded char/byte count"
);
assert!(
c.data.len() as u64 > raw_len,
"lossy decode of the high byte inflates the in-memory data length"
);
}
#[test]
fn date_metadata_is_always_none_for_git_source() {
let (_t, repo) = init_repo();
commit_file(&repo, "d.txt", b"v=1\n", "date");
let chunks = collect_chunks(&repo, 1);
for c in &chunks {
assert_eq!(
c.metadata.date, None,
"GitSource must never set a date; got {:?}",
c.metadata.date
);
}
}
#[test]
fn base_offset_and_mtime_are_zero_and_none() {
let (_t, repo) = init_repo();
commit_file(&repo, "m.txt", b"v=1\n", "meta");
let chunks = collect_chunks(&repo, 1);
let c = chunk_for(&chunks, "m.txt").expect("m.txt present");
assert_eq!(c.metadata.base_offset, 0);
assert_eq!(c.metadata.mtime_ns, None);
}
#[test]
fn nested_path_is_slash_joined_under_prefix() {
let (_t, repo) = init_repo();
commit_file(
&repo,
"src/inner/deep.env",
b"K=ghp_nestedPath00000000000000000001\n",
"nested",
);
let chunks = collect_chunks(&repo, 1);
let c = chunks
.iter()
.find(|c| c.data.contains("ghp_nestedPath00000000000000000001"))
.expect("nested blob present");
assert_eq!(
c.metadata.path.as_deref(),
Some("src/inner/deep.env"),
"nested path must be slash-joined from the tree prefix"
);
}
#[test]
fn source_name_is_git() {
let source = GitSource::new(PathBuf::from("."));
assert_eq!(source.name(), "git");
}
#[test]
fn gitignore_file_itself_is_scanned() {
let (_t, repo) = init_repo();
commit_file(&repo, ".gitignore", b"*.log\nsecrets.txt\n", "add ignore");
let chunks = collect_chunks(&repo, 1);
let c = chunk_for(&chunks, ".gitignore").expect(".gitignore must be scanned");
assert!(
c.data.contains("*.log"),
"the .gitignore contents are scannable"
);
assert_eq!(c.metadata.source_type, "git/head");
}
#[test]
fn untracked_ignored_file_is_not_in_any_tree() {
let (_t, repo) = init_repo();
commit_file(&repo, ".gitignore", b"ignored.env\n", "ignore rule");
std::fs::write(
repo.join("ignored.env"),
b"SECRET=ghp_neverCommitted0000000001\n",
)
.expect("write ignored");
let chunks = collect_chunks(&repo, 5);
assert!(
chunk_for(&chunks, "ignored.env").is_none(),
"an untracked, ignored file is in no commit tree -> never scanned"
);
assert!(
!chunks
.iter()
.any(|c| c.data.contains("ghp_neverCommitted0000000001")),
"its secret must not appear anywhere in git source output"
);
}
#[test]
fn force_added_ignored_file_is_still_scanned() {
let (_t, repo) = init_repo();
commit_file(&repo, ".gitignore", b"forced.env\n", "ignore rule");
std::fs::write(
repo.join("forced.env"),
b"SECRET=ghp_forceAddedSecret00000001\n",
)
.expect("write forced");
git(&repo, &["add", "-f", "forced.env"]);
commit_only(&repo, "force add ignored file");
let chunks = collect_chunks(&repo, 5);
let c = chunk_for(&chunks, "forced.env").expect("force-added file must be scanned");
assert!(
c.data.contains("ghp_forceAddedSecret00000001"),
"git ignore does not protect a force-committed secret from history scan"
);
}
#[test]
fn node_modules_subtree_is_skipped() {
let (_t, repo) = init_repo();
commit_file(
&repo,
"node_modules/pkg/leak.env",
b"K=ghp_insideNodeModules0000000001\n",
"vendored dep",
);
commit_file(
&repo,
"app.env",
b"K=ghp_appLevelSecret00000000000001\n",
"app secret",
);
let chunks = collect_chunks(&repo, 5);
assert!(
chunk_for(&chunks, "node_modules/pkg/leak.env").is_none(),
"node_modules subtree must be skipped by name"
);
assert!(
!chunks
.iter()
.any(|c| c.data.contains("ghp_insideNodeModules0000000001")),
"no node_modules content may be emitted"
);
assert!(
chunk_for(&chunks, "app.env").is_some(),
"non-excluded sibling must still be scanned"
);
}
#[test]
fn each_excluded_dir_name_is_skipped() {
for dirname in [
"node_modules",
"target",
"__pycache__",
"dist",
"build",
"vendor",
] {
let (_t, repo) = init_repo();
let rel = format!("{dirname}/leak.env");
commit_file(
&repo,
&rel,
b"K=ghp_excludedDirSecret000000000001\n",
"leak",
);
commit_file(
&repo,
"keep.env",
b"K=ghp_keepMe000000000000000000001\n",
"keep",
);
let chunks = collect_chunks(&repo, 5);
assert!(
chunk_for(&chunks, &rel).is_none(),
"{dirname}/ subtree must be skipped"
);
assert!(
!chunks
.iter()
.any(|c| c.data.contains("ghp_excludedDirSecret000000000001")),
"{dirname} content must not be emitted"
);
assert!(
chunk_for(&chunks, "keep.env").is_some(),
"sibling outside {dirname} must survive"
);
}
}
#[test]
fn excluded_name_match_is_exact_not_prefix() {
let (_t, repo) = init_repo();
commit_file(
&repo,
"vendored/keep.env",
b"K=ghp_vendoredNotExcluded000001\n",
"vendored",
);
commit_file(
&repo,
"buildtools/keep.env",
b"K=ghp_buildtoolsNotExcluded01\n",
"buildtools",
);
let chunks = collect_chunks(&repo, 5);
assert!(
chunks
.iter()
.any(|c| c.data.contains("ghp_vendoredNotExcluded000001")),
"'vendored' != 'vendor' so it must NOT be excluded"
);
assert!(
chunks
.iter()
.any(|c| c.data.contains("ghp_buildtoolsNotExcluded01")),
"'buildtools' != 'build' so it must NOT be excluded"
);
}
#[test]
fn excluded_name_also_skips_plain_files_not_just_dirs() {
let (_t, repo) = init_repo();
commit_file(
&repo,
"vendor",
b"SECRET=ghp_fileNamedVendor00000001\n",
"file vendor",
);
commit_file(
&repo,
"build",
b"SECRET=ghp_fileNamedBuild000000001\n",
"file build",
);
commit_file(&repo, "real.txt", b"ok=1\n", "real");
let chunks = collect_chunks(&repo, 5);
assert!(
!chunks
.iter()
.any(|c| c.data.contains("ghp_fileNamedVendor00000001")),
"a file named exactly 'vendor' is skipped by the name check"
);
assert!(
!chunks
.iter()
.any(|c| c.data.contains("ghp_fileNamedBuild000000001")),
"a file named exactly 'build' is skipped by the name check"
);
assert!(chunk_for(&chunks, "real.txt").is_some());
}
#[test]
fn blob_over_10_mib_is_skipped() {
let (_t, repo) = init_repo();
let big_len = 11 * 1024 * 1024usize;
let mut big = vec![b'a'; big_len];
big.extend_from_slice(b"\nSECRET=ghp_oversizeBlobShouldSkip01\n");
commit_file(&repo, "huge.txt", &big, "oversize blob");
commit_file(
&repo,
"small.txt",
b"K=ghp_smallKept000000000000000001\n",
"small",
);
let chunks = collect_chunks(&repo, 5);
assert!(
chunk_for(&chunks, "huge.txt").is_none(),
"a blob larger than 10 MiB must be skipped (header.size() > MAX_GIT_BLOB_BYTES)"
);
assert!(
!chunks
.iter()
.any(|c| c.data.contains("ghp_oversizeBlobShouldSkip01")),
"the oversize blob's content must never reach a chunk"
);
assert!(
chunk_for(&chunks, "small.txt").is_some(),
"the under-cap sibling must still be scanned"
);
}
#[test]
fn blob_just_under_10_mib_is_scanned() {
let (_t, repo) = init_repo();
let body = b"\nSECRET=ghp_underCapBlobScanned0001\n";
let pad = 10 * 1024 * 1024 - body.len() - 1; let mut blob = vec![b'b'; pad];
blob.extend_from_slice(body);
assert!((blob.len() as u64) < 10 * 1024 * 1024);
commit_file(&repo, "near.txt", &blob, "near cap");
let chunks = collect_chunks(&repo, 1);
let c = chunk_for(&chunks, "near.txt").expect("near-cap blob must be scanned");
assert!(
c.data.contains("ghp_underCapBlobScanned0001"),
"a blob just under the 10 MiB cap must be fully scanned"
);
assert_eq!(
c.metadata.size_bytes,
Some(blob.len() as u64),
"size_bytes reflects the full under-cap blob size"
);
}
#[test]
fn identical_blob_content_is_emitted_once_across_paths() {
let (_t, repo) = init_repo();
let content = b"DUPLICATE=ghp_sameContentTwoFiles01\n";
std::fs::write(repo.join("first.env"), content).unwrap();
std::fs::write(repo.join("second.env"), content).unwrap();
git(&repo, &["add", "first.env", "second.env"]);
commit_only(&repo, "two files identical content");
let chunks = collect_chunks(&repo, 1);
let n = chunks
.iter()
.filter(|c| c.data.contains("ghp_sameContentTwoFiles01"))
.count();
assert_eq!(
n, 1,
"identical blob OID must be emitted exactly once (seen_blobs dedup); got {n}"
);
}
#[test]
fn distinct_content_same_basename_in_different_dirs_both_emitted() {
let (_t, repo) = init_repo();
commit_file(
&repo,
"a/conf.env",
b"K=ghp_distinctA0000000000000000001\n",
"a",
);
commit_file(
&repo,
"b/conf.env",
b"K=ghp_distinctB0000000000000000001\n",
"b",
);
let chunks = collect_chunks(&repo, 5);
assert!(chunks
.iter()
.any(|c| c.data.contains("ghp_distinctA0000000000000000001")));
assert!(chunks
.iter()
.any(|c| c.data.contains("ghp_distinctB0000000000000000001")));
}
#[test]
fn secret_only_on_feature_branch_is_found_via_all_refs() {
let (_t, repo) = init_repo();
commit_file(&repo, "main.txt", b"base=1\n", "base on main");
git(&repo, &["checkout", "-b", "feature"]);
commit_file(
&repo,
"feature.env",
b"K=ghp_onlyOnFeatureBranch00000001\n",
"feature secret",
);
git(&repo, &["checkout", "main"]);
let chunks = collect_chunks(&repo, 50);
let c = chunks
.iter()
.find(|c| c.data.contains("ghp_onlyOnFeatureBranch00000001"))
.expect("feature-branch secret must be found via --all");
assert_eq!(
c.metadata.source_type, "git/history",
"a feature-branch-only blob is not in HEAD -> git/history"
);
}
#[test]
fn secret_only_on_tag_is_found() {
let (_t, repo) = init_repo();
commit_file(&repo, "v1.env", b"K=ghp_taggedReleaseSecret0000001\n", "v1");
git(&repo, &["tag", "v1.0"]);
commit_file(&repo, "v1.env", b"K=scrubbed\n", "scrub for v2");
let chunks = collect_chunks(&repo, 50);
assert!(
chunks
.iter()
.any(|c| c.data.contains("ghp_taggedReleaseSecret0000001")),
"a secret reachable only through a tag must be scanned via --tags"
);
}
#[test]
fn max_commits_one_limits_history_walk() {
let (_t, repo) = init_repo();
commit_file(
&repo,
"f.env",
b"OLD=ghp_oldCommitOnly00000000000001\n",
"old",
);
commit_file(&repo, "f.env", b"OLD=current\n", "new");
let chunks = collect_chunks(&repo, 1);
assert!(
!chunks
.iter()
.any(|c| c.data.contains("ghp_oldCommitOnly00000000000001")),
"max_commits=1 walks only HEAD's tree; the removed-then older blob is excluded"
);
assert!(
chunks.iter().any(|c| c.data.contains("current")),
"HEAD's current blob is present"
);
}
#[test]
fn without_max_commits_full_history_is_walked() {
let (_t, repo) = init_repo();
commit_file(&repo, "g.env", b"OLD=ghp_fullHistoryReachable0001\n", "old");
commit_file(&repo, "g.env", b"OLD=current\n", "new");
let bodies: Vec<String> = GitSource::new(repo.clone())
.chunks() .map(|r| r.expect("chunk ok"))
.map(|c| c.data.to_string())
.collect();
assert!(
bodies
.iter()
.any(|b| b.contains("ghp_fullHistoryReachable0001")),
"the full history walk must surface the removed older secret"
);
}
#[test]
fn non_repo_directory_yields_single_error_chunk() {
let temp = tempfile::tempdir().expect("tempdir");
let results: Vec<Result<Chunk, SourceError>> =
GitSource::new(temp.path().to_path_buf()).chunks().collect();
assert_eq!(
results.len(),
1,
"non-repo path must yield exactly one error item"
);
let err = results
.into_iter()
.next()
.unwrap()
.expect_err("must be Err");
let msg = err.to_string();
assert!(
msg.contains("not a git repository"),
"error must explain the path is not a repo; got: {msg}"
);
}
#[test]
fn nonexistent_path_yields_canonicalize_error() {
let missing = PathBuf::from("/nonexistent/keyhog/gap/repo/path/xyzzy");
let results: Vec<Result<Chunk, SourceError>> = GitSource::new(missing).chunks().collect();
assert_eq!(results.len(), 1);
let err = results
.into_iter()
.next()
.unwrap()
.expect_err("must be Err");
let msg = err.to_string();
assert!(
msg.contains("failed to canonicalize repo path"),
"missing path must surface a canonicalize failure; got: {msg}"
);
}
#[test]
fn repo_path_with_leading_dash_is_rejected() {
let results: Vec<Result<Chunk, SourceError>> =
GitSource::new(PathBuf::from("-oops")).chunks().collect();
assert_eq!(results.len(), 1);
let err = results
.into_iter()
.next()
.unwrap()
.expect_err("must be Err");
assert!(matches!(err, SourceError::Other(_)));
}
#[test]
fn iterator_is_fused_after_exhaustion() {
let (_t, repo) = init_repo();
commit_file(&repo, "one.txt", b"v=1\n", "one");
let src = GitSource::new(repo.clone()).with_max_commits(1);
let mut iter = src.chunks();
let mut count = 0;
for r in iter.by_ref() {
r.expect("ok");
count += 1;
}
assert!(count >= 1, "at least the one.txt blob");
assert!(iter.next().is_none());
assert!(iter.next().is_none());
}
#[test]
fn every_emitted_chunk_carries_path_and_commit() {
let (_t, repo) = init_repo();
commit_file(&repo, "p1.txt", b"a=1\n", "c1");
commit_file(&repo, "p2.txt", b"b=2\n", "c2");
let chunks = collect_chunks(&repo, 5);
assert!(!chunks.is_empty());
for c in &chunks {
assert!(c.metadata.path.is_some(), "every git chunk has a path");
assert!(c.metadata.commit.is_some(), "every git chunk has a commit");
assert!(c.metadata.author.is_some(), "every git chunk has an author");
assert!(
c.metadata.size_bytes.is_some(),
"every git chunk has size_bytes"
);
assert!(
c.metadata.source_type == "git/head" || c.metadata.source_type == "git/history",
"source_type is one of the two git buckets; got {:?}",
c.metadata.source_type
);
}
}