use std::collections::{HashSet, VecDeque};
use std::io::BufRead;
use std::path::{Path, PathBuf};
use std::process::Command;
use gix::objs::Kind;
use keyhog_core::{Chunk, ChunkMetadata, Source, SourceError};
const MAX_GIT_TOTAL_BYTES: usize = 256 * 1024 * 1024;
const MAX_GIT_BLOB_BYTES: u64 = 10 * 1024 * 1024;
const MAX_GIT_CHUNKS: usize = 500_000;
pub struct GitSource {
repo_path: PathBuf,
max_commits: Option<usize>,
}
impl GitSource {
pub fn new(repo_path: PathBuf) -> Self {
Self {
repo_path,
max_commits: None,
}
}
pub fn with_max_commits(mut self, n: usize) -> Self {
self.max_commits = Some(n);
self
}
}
impl Source for GitSource {
fn name(&self) -> &str {
"git"
}
fn chunks(&self) -> Box<dyn Iterator<Item = Result<Chunk, SourceError>> + '_> {
match stream_git_blobs(&self.repo_path, self.max_commits) {
Ok(iter) => Box::new(iter),
Err(e) => Box::new(std::iter::once(Err(e))),
}
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
fn stream_git_blobs(
repo_path: &Path,
max_commits: Option<usize>,
) -> Result<impl Iterator<Item = Result<Chunk, SourceError>>, SourceError> {
let repo_arg = super::validate_repo_path(repo_path)?;
let mut log_cmd = Command::new(super::git_bin()?);
log_cmd.args([
"-C",
&repo_arg,
"log",
"--all",
"--branches",
"--tags",
"-m", "--format=%H %an",
]);
if let Some(limit) = max_commits {
log_cmd.args(["--max-count", &limit.to_string()]);
}
log_cmd.arg("--end-of-options");
log_cmd.stdout(std::process::Stdio::piped());
let mut log_child = log_cmd.spawn().map_err(SourceError::Io)?;
let log_stdout = log_child
.stdout
.take()
.ok_or_else(|| SourceError::Io(std::io::Error::other("missing log stdout")))?;
let mut log_lines = std::io::BufReader::new(log_stdout).lines();
let repo_owned = repo_path.to_path_buf();
let repo_handle = gix::open(&repo_owned)
.map_err(|e| SourceError::Io(std::io::Error::other(format!("gix open: {e}"))))?;
let head_blobs = match collect_head_blob_set(&repo_handle) {
Some(set) => set,
None => {
tracing::warn!(
"git: HEAD blob walk produced no set; all findings will be \
labelled git/history (lower severity). The scan continues \
but you may underweight live-in-HEAD leaks. Common causes: \
unborn HEAD, partial clone without tree objects, \
corrupt ref."
);
HashSet::new()
}
};
let mut current_tree_blobs: VecDeque<Chunk> = VecDeque::new();
let mut seen_blobs: HashSet<gix::ObjectId> = HashSet::new();
let mut seen_commits: HashSet<gix::ObjectId> = HashSet::new();
let mut total_bytes = 0usize;
let mut chunk_count = 0usize;
let mut done = false;
Ok(std::iter::from_fn(move || {
if done {
return None;
}
loop {
if let Some(chunk) = current_tree_blobs.pop_front() {
return Some(Ok(chunk));
}
if total_bytes >= MAX_GIT_TOTAL_BYTES || chunk_count >= MAX_GIT_CHUNKS {
done = true;
return None;
}
let line = match log_lines.next() {
Some(Ok(l)) => l,
Some(Err(e)) => {
done = true;
return Some(Err(SourceError::Io(e)));
}
None => {
done = true;
return None;
}
};
let parts: Vec<&str> = line.splitn(2, ' ').collect();
if parts.len() < 2 {
continue;
}
let commit_id = parts[0];
let author = parts[1];
let repo = &repo_handle;
let Ok(id) = gix::ObjectId::from_hex(commit_id.as_bytes()) else {
continue;
};
if !seen_commits.insert(id) {
continue;
}
let Ok(obj) = repo.find_object(id) else {
continue;
};
let Ok(commit) = obj.try_into_commit() else {
continue;
};
let Ok(tree) = commit.tree() else {
continue;
};
let mut blob_metadata = Vec::new();
collect_tree_blobs_metadata(repo, &tree, &mut seen_blobs, &mut blob_metadata, b"");
if !blob_metadata.is_empty() {
let repo_cloned = repo.clone();
let commit_id_str = commit_id.to_string();
let author_str = author.to_string();
let head_blobs_ref = &head_blobs;
for (oid, filepath) in blob_metadata {
if total_bytes >= MAX_GIT_TOTAL_BYTES || chunk_count >= MAX_GIT_CHUNKS {
break;
}
let Ok(header) = repo_cloned.find_header(oid) else {
continue;
};
if header.kind() != Kind::Blob || header.size() > MAX_GIT_BLOB_BYTES {
continue;
}
let Ok(obj) = repo_cloned.find_object(oid) else {
continue;
};
let Some(file_text) = decode_git_blob(&obj.data) else {
continue;
};
let path = String::from_utf8_lossy(&filepath).to_string();
let in_head = head_blobs_ref.contains(&oid);
let chunk = Chunk {
data: file_text.into(),
metadata: ChunkMetadata {
base_offset: 0,
base_line: 0,
source_type: if in_head { "git/head" } else { "git/history" }.into(),
path: Some(path),
commit: Some(commit_id_str.clone()),
author: Some(author_str.clone()),
date: None,
mtime_ns: None,
size_bytes: Some(header.size()),
},
};
total_bytes = total_bytes.saturating_add(chunk.data.len());
chunk_count += 1;
current_tree_blobs.push_back(chunk);
}
if let Some(chunk) = current_tree_blobs.pop_front() {
return Some(Ok(chunk));
}
}
}
}))
}
fn decode_git_blob(data: &[u8]) -> Option<String> {
if data.is_empty() {
return Some(String::new());
}
if looks_binary_blob(data) {
return None;
}
if let Ok(s) = std::str::from_utf8(data) {
return Some(s.to_owned());
}
Some(String::from_utf8_lossy(data).into_owned())
}
fn looks_binary_blob(data: &[u8]) -> bool {
const MAGIC_HEADERS: &[&[u8]] = &[
b"%PDF-",
b"PK\x03\x04",
b"\x89PNG\r\n\x1a\n",
b"\xD0\xCF\x11\xE0",
b"\x7fELF",
b"\xfe\xed\xfa\xce",
b"\xfe\xed\xfa\xcf",
b"\xcf\xfa\xed\xfe",
b"\xca\xfe\xba\xbe",
b"MZ",
b"\x1f\x8b",
b"BZh",
b"\xfd7zXZ\x00",
b"7z\xbc\xaf\x27\x1c",
b"Rar!\x1a\x07",
b"GIF87a",
b"GIF89a",
b"\xff\xd8\xff",
b"\x00\x00\x01\x00",
b"OggS",
b"ID3",
b"fLaC",
b"\x00asm",
b"!<arch>\n",
b"\x80\x02",
];
if MAGIC_HEADERS.iter().any(|h| data.starts_with(h)) {
return true;
}
let utf16_bom = data.len() >= 4
&& ((data[0] == 0xFF && data[1] == 0xFE) || (data[0] == 0xFE && data[1] == 0xFF));
if utf16_bom {
return true;
}
if let Some(first_nul) = data.iter().position(|&b| b == 0) {
if first_nul < 1024 {
let is_utf16 = data.len() >= 4
&& ((data[0] == 0 && data[1] != 0) || (data[0] != 0 && data[1] == 0));
if !is_utf16 {
return true;
}
}
}
let total = data.len() as u64;
if total == 0 {
return false;
}
let mut suspicious: u64 = 0;
for &byte in data {
if byte < 0x20 && !matches!(byte, b'\n' | b'\r' | b'\t' | 0x0C) {
suspicious += 1;
if suspicious * 20 > total {
return true;
}
}
}
false
}
fn collect_tree_blobs_metadata(
repo: &gix::Repository,
tree: &gix::Tree<'_>,
seen_blobs: &mut HashSet<gix::ObjectId>,
blob_metadata: &mut Vec<(gix::ObjectId, Vec<u8>)>,
prefix: &[u8],
) {
for entry_ref in tree.iter() {
let entry = match entry_ref {
Ok(e) => e,
Err(error) => {
tracing::debug!(%error, "git tree entry read failed; skipping");
continue;
}
};
let name = entry.filename();
if name == b"node_modules"
|| name == b"target"
|| name == b".git"
|| name == b"__pycache__"
|| name == b"dist"
|| name == b"build"
|| name == b"vendor"
{
continue;
}
let oid = entry.oid().to_owned();
let filepath = if prefix.is_empty() {
entry.filename().to_vec()
} else {
let mut p = prefix.to_vec();
p.push(b'/');
p.extend_from_slice(entry.filename());
p
};
let mode = entry.mode();
if mode.is_tree() {
if let Ok(obj) = repo.find_object(oid) {
if let Ok(subtree) = obj.try_into_tree() {
collect_tree_blobs_metadata(
repo,
&subtree,
seen_blobs,
blob_metadata,
&filepath,
);
}
}
continue;
}
if !mode.is_blob() {
continue;
}
if seen_blobs.insert(oid) {
blob_metadata.push((oid, filepath));
}
}
}
fn collect_head_blob_set(repo: &gix::Repository) -> Option<HashSet<gix::ObjectId>> {
let head = repo.head().ok()?;
let head_id = head.try_into_peeled_id().ok().flatten()?;
let commit = repo.find_object(head_id).ok()?.try_into_commit().ok()?;
let tree = commit.tree().ok()?;
let mut out = HashSet::new();
walk_tree_for_blobs(repo, &tree, &mut out);
Some(out)
}
fn walk_tree_for_blobs(
repo: &gix::Repository,
tree: &gix::Tree<'_>,
out: &mut HashSet<gix::ObjectId>,
) {
for entry_ref in tree.iter() {
let Ok(entry) = entry_ref else { continue };
let oid = entry.oid().to_owned();
let mode = entry.mode();
if mode.is_tree() {
if let Ok(obj) = repo.find_object(oid) {
if let Ok(subtree) = obj.try_into_tree() {
walk_tree_for_blobs(repo, &subtree, out);
}
}
} else if mode.is_blob() {
out.insert(oid);
}
}
}