use super::display_path;
use super::filter::{is_default_excluded, skip_extensions};
use super::read;
use keyhog_core::merkle_index::MerkleIndex;
use keyhog_core::{Chunk, ChunkMetadata, SourceError};
use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
fn is_symlink(path: &Path) -> bool {
std::fs::symlink_metadata(path)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
const MMAP_THRESHOLD: u64 = 64 * 1024;
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
const MMAP_THRESHOLD: u64 = 1024 * 1024;
pub(super) fn process_entry(
entry: codewalk::FileEntry,
merkle: &Option<Arc<MerkleIndex>>,
skipped: &Arc<AtomicUsize>,
max_size: u64,
window_size: usize,
window_overlap: usize,
emit: &mut dyn FnMut(Result<Chunk, SourceError>) -> bool,
) {
let path = entry.path;
let file_size = entry.size;
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if is_default_excluded(filename) {
return;
}
if filename.contains(".min.")
|| filename.contains(".bundle.")
|| filename.ends_with(".chunk.js")
|| filename.ends_with(".min.js")
|| filename.ends_with(".bundle.js")
{
return;
}
if max_size > 0 && file_size > max_size {
tracing::warn!(
path = %path.display(),
size_bytes = file_size,
max_size,
"skipping file: size exceeds --max-file-size cap"
);
crate::SKIPPED_OVER_MAX_SIZE.fetch_add(1, Ordering::Relaxed);
return;
}
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
if skip_extensions().contains(ext.as_str()) {
return;
}
if ext.is_empty() {
if let Ok(mut f) = std::fs::File::open(&path) {
let mut buf = [0u8; 16];
if let Ok(n) = f.read(&mut buf) {
if n > 0 {
let is_binary = buf[..n].iter().any(|&b| b == 0)
|| buf.starts_with(b"\x7fELF")
|| buf.starts_with(b"MZ")
|| buf.starts_with(b"%PDF")
|| buf.starts_with(b"PK\x03\x04");
if is_binary {
return;
}
}
}
}
}
let live_mtime_ns = file_mtime_ns(&path);
if let (Some(idx), Some(mtime_ns)) = (merkle.as_ref(), live_mtime_ns) {
if idx.metadata_unchanged(&path, mtime_ns, file_size) {
skipped.fetch_add(1, Ordering::Relaxed);
return;
}
}
if ext == "zip" || ext == "apk" || ext == "ipa" || ext == "crx" || ext == "jar" {
if is_symlink(&path) {
tracing::warn!(
archive = %path.display(),
"refusing to open archive at a symlink path - \
prevents the link-swap attack class"
);
return;
}
let archive_display = display_path(&path);
let mut total_uncompressed: u64 = 0;
let total_budget: u64 = max_size.saturating_mul(4);
if let Ok(pack) = openpack::OpenPack::open_default(&path) {
if let Ok(entries) = pack.entries() {
for archive_entry in entries {
if archive_entry.is_dir || is_default_excluded(&archive_entry.name) {
continue;
}
if archive_entry.uncompressed_size > max_size {
tracing::warn!(
archive = %path.display(),
entry = %archive_entry.name,
size = archive_entry.uncompressed_size,
"skipping archive entry: uncompressed size exceeds per-file cap"
);
continue;
}
total_uncompressed =
total_uncompressed.saturating_add(archive_entry.uncompressed_size);
if total_uncompressed > total_budget {
tracing::warn!(
archive = %path.display(),
"aborting archive extraction: total uncompressed size exceeds 4x file cap (zip-bomb guard)"
);
break;
}
if let Ok(content) = pack.read_entry(&archive_entry.name) {
let entry_path = || format!("{}//{}", archive_display, archive_entry.name);
let chunk = match String::from_utf8(content) {
Ok(s) => Some(Ok(Chunk {
data: s.into(),
metadata: ChunkMetadata {
source_type: "filesystem/archive".into(),
path: Some(entry_path()),
..Default::default()
},
})),
Err(error) => {
let content = error.into_bytes();
let strings =
crate::strings::extract_printable_strings(&content, 8);
if strings.is_empty() {
None
} else {
Some(Ok(Chunk {
data: keyhog_core::SensitiveString::join(&strings, "\n"),
metadata: ChunkMetadata {
source_type: "filesystem/archive-binary".into(),
path: Some(entry_path()),
..Default::default()
},
}))
}
}
};
if let Some(chunk) = chunk {
if !emit(chunk) {
return;
}
}
}
}
}
}
return;
} else if ext == "tar" {
if is_symlink(&path) {
tracing::warn!(
archive = %path.display(),
"refusing to open archive at a symlink path - \
prevents the link-swap attack class"
);
return;
}
if let Ok(bytes) = read::read_file_safe(&path, file_size) {
if looks_like_tar(&bytes) {
emit_tar_entries(&bytes, &display_path(&path), max_size, emit);
return;
}
}
} else if ext == "gz" || ext == "zst" || ext == "lz4" || ext == "sz" || ext == "tgz" {
extract_compressed_chunks(&path, max_size, emit);
return;
} else if ext == "har" {
if let Ok(bytes) = read::read_file_safe(&path, file_size) {
let path_str = display_path(&path);
if let Some(har_chunks) = crate::har::try_expand_har(&bytes, &path_str, max_size) {
for chunk in har_chunks {
if !emit(chunk) {
return;
}
}
return;
}
}
}
if file_size > window_size as u64 {
let display = display_path(&path);
if let Some(windows) = read::read_file_windowed_mmap(&path, window_size, window_overlap) {
for w in windows {
let chunk = Ok(Chunk {
data: w.text.into(),
metadata: ChunkMetadata {
source_type: "filesystem/windowed".to_string(),
path: Some(display.clone()),
base_offset: w.offset,
base_line: w.base_line,
mtime_ns: live_mtime_ns,
size_bytes: Some(file_size),
..Default::default()
},
});
if !emit(chunk) {
return;
}
}
return;
}
if let Ok(mut file) = std::fs::File::open(&path) {
let mut current_offset = 0;
let mut current_base_line = 0usize;
let mut buffer = vec![0u8; window_size];
loop {
let mut filled = 0;
let mut hit_eof = false;
while filled < window_size {
match file.read(&mut buffer[filled..]) {
Ok(0) => {
hit_eof = true;
break;
}
Ok(n) => filled += n,
Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(_) => {
return;
}
}
}
if filled == 0 {
break;
}
let data = String::from_utf8_lossy(&buffer[..filled]).into_owned();
let chunk = Ok(Chunk {
data: data.into(),
metadata: ChunkMetadata {
source_type: "filesystem/windowed".to_string(),
path: Some(display.clone()),
base_offset: current_offset,
base_line: current_base_line,
mtime_ns: live_mtime_ns,
size_bytes: Some(file_size),
..Default::default()
},
});
if !emit(chunk) {
return;
}
if hit_eof || filled < window_size {
break;
}
match file.seek(SeekFrom::Current(-(window_overlap as i64))) {
Ok(_) => {
let advanced = filled - window_overlap;
current_base_line +=
memchr::memchr_iter(b'\n', &buffer[..advanced]).count();
current_offset += advanced;
}
Err(_) => {
current_base_line +=
memchr::memchr_iter(b'\n', &buffer[..filled]).count();
current_offset += filled;
}
}
}
}
return;
}
let file_text = if file_size >= MMAP_THRESHOLD {
read::read_file_mmap(&path)
} else {
read::read_file_buffered(&path, file_size)
};
let (content, source_type) = match file_text {
Some(text) if !text.is_empty() => (text.into(), "filesystem"),
_ => {
if let Ok(bytes) = read::read_file_safe(&path, file_size) {
let strings = crate::strings::extract_printable_strings(&bytes, 8);
if strings.is_empty() {
return;
}
(
keyhog_core::SensitiveString::join(&strings, "\n"),
"filesystem:binary-strings",
)
} else {
return;
}
}
};
let _ = emit(Ok(Chunk {
data: content,
metadata: ChunkMetadata {
source_type: source_type.to_string(),
path: Some(display_path(&path)),
mtime_ns: live_mtime_ns,
size_bytes: Some(file_size),
..Default::default()
},
}));
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum CompressedFormat {
Gzip,
Zstd,
Lz4,
Snappy,
}
impl CompressedFormat {
fn from_ext(ext: &str) -> Self {
match ext {
"gz" | "tgz" => CompressedFormat::Gzip,
"zst" => CompressedFormat::Zstd,
"lz4" => CompressedFormat::Lz4,
_ => CompressedFormat::Snappy,
}
}
}
pub(crate) fn budget_window_log_max(budget: usize) -> u32 {
let b = (budget.max(1 << 10)) as u64; let log = 64 - (b - 1).leading_zeros(); log.clamp(10, 31)
}
fn decompress_to_bytes(
format: CompressedFormat,
compressed: &[u8],
budget: usize,
) -> Option<Vec<u8>> {
use std::io::Read as _;
let take_limit = (budget as u64).saturating_add(1);
let mut out = Vec::new();
let read_result = match format {
CompressedFormat::Gzip => {
let mut dec = flate2::read::MultiGzDecoder::new(compressed).take(take_limit);
dec.read_to_end(&mut out)
}
CompressedFormat::Zstd => match zstd::stream::read::Decoder::new(compressed) {
Ok(mut dec) => {
match dec.window_log_max(budget_window_log_max(budget)) {
Ok(()) => dec.take(take_limit).read_to_end(&mut out),
Err(e) => Err(e),
}
}
Err(e) => Err(e),
},
CompressedFormat::Lz4 => {
let mut dec = lz4_flex::frame::FrameDecoder::new(compressed).take(take_limit);
dec.read_to_end(&mut out)
}
CompressedFormat::Snappy => {
let mut dec = snap::read::FrameDecoder::new(compressed).take(take_limit);
dec.read_to_end(&mut out)
}
};
match read_result {
Ok(_) => Some(out),
Err(_) if !out.is_empty() => Some(out),
Err(_) => None,
}
}
fn looks_like_tar(data: &[u8]) -> bool {
data.len() >= 512 && (&data[257..262] == b"ustar" || &data[257..265] == b"ustar \0")
}
fn emit_tar_entries(
tar_bytes: &[u8],
container_display: &str,
max_size: u64,
emit: &mut dyn FnMut(Result<Chunk, SourceError>) -> bool,
) {
use std::io::Read as _;
let mut archive = tar::Archive::new(std::io::Cursor::new(tar_bytes));
let entries = match archive.entries() {
Ok(e) => e,
Err(error) => {
tracing::warn!(archive = %container_display, %error, "failed to read tar entries");
return;
}
};
let total_budget: u64 = max_size.saturating_mul(4);
let mut total_uncompressed: u64 = 0;
for entry in entries {
let mut entry = match entry {
Ok(e) => e,
Err(error) => {
tracing::warn!(archive = %container_display, %error, "skipping unreadable tar entry");
continue;
}
};
if entry.header().entry_type() != tar::EntryType::Regular {
continue;
}
let entry_size = entry.header().size().unwrap_or(0);
let entry_name = entry
.path()
.ok()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| "<tar-entry>".to_string());
if super::filter::is_default_excluded(&entry_name) {
continue;
}
if max_size > 0 && entry_size > max_size {
tracing::warn!(
archive = %container_display,
entry = %entry_name,
size = entry_size,
"skipping tar entry: uncompressed size exceeds per-file cap"
);
continue;
}
total_uncompressed = total_uncompressed.saturating_add(entry_size);
if total_budget > 0 && total_uncompressed > total_budget {
tracing::warn!(
archive = %container_display,
"aborting tar extraction: total uncompressed size exceeds 4x file cap (tar-bomb guard)"
);
break;
}
let mut content: Vec<u8> = Vec::with_capacity(entry_size.min(max_size.max(1)) as usize);
let read_cap = if max_size > 0 { max_size } else { u64::MAX };
if entry
.by_ref()
.take(read_cap)
.read_to_end(&mut content)
.is_err()
{
tracing::warn!(archive = %container_display, entry = %entry_name, "failed to read tar entry body");
continue;
}
let entry_path = format!("{container_display}//{entry_name}");
let chunk = match String::from_utf8(content) {
Ok(s) if !s.is_empty() => Some(Ok(Chunk {
data: s.into(),
metadata: ChunkMetadata {
source_type: "filesystem/archive".into(),
path: Some(entry_path),
..Default::default()
},
})),
Ok(_) => None,
Err(error) => {
let bytes = error.into_bytes();
let strings = crate::strings::extract_printable_strings(&bytes, 8);
if strings.is_empty() {
None
} else {
Some(Ok(Chunk {
data: keyhog_core::SensitiveString::join(&strings, "\n"),
metadata: ChunkMetadata {
source_type: "filesystem/archive-binary".into(),
path: Some(entry_path),
..Default::default()
},
}))
}
}
};
if let Some(chunk) = chunk {
if !emit(chunk) {
return;
}
}
}
}
fn extract_compressed_chunks(
path: &Path,
max_size: u64,
emit: &mut dyn FnMut(Result<Chunk, SourceError>) -> bool,
) {
if is_symlink(path) {
tracing::warn!(
path = %path.display(),
"refusing to open compressed file at a symlink path (link-swap guard)"
);
return;
}
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let format = CompressedFormat::from_ext(&ext);
let file_bytes = match read::read_file_for_compressed_input(path, max_size) {
Some(b) => b,
None => return,
};
let compressed = file_bytes.as_slice();
let total_budget: usize = max_size.saturating_mul(4) as usize;
let budget = if total_budget == 0 {
1024 * 1024 * 1024
} else {
total_budget
};
let decompressed = match decompress_to_bytes(format, compressed, budget) {
Some(d) => d,
None => {
tracing::warn!(path = %path.display(), "failed to decompress file; skipping");
return;
}
};
if decompressed.len() >= budget {
tracing::warn!(
path = %path.display(),
bytes = decompressed.len(),
cap = budget,
"compressed extraction hit the 4x decompressed-size cap (zip-bomb guard); scanning the truncated prefix"
);
}
let path_display = display_path(path);
if ext == "tgz" || looks_like_tar(&decompressed) {
emit_tar_entries(&decompressed, &path_display, max_size, emit);
return;
}
let (data, source_type) = match String::from_utf8(decompressed) {
Ok(s) if !s.is_empty() => (s.into(), "filesystem/compressed"),
Ok(_) => return,
Err(error) => {
let bytes = error.into_bytes();
let strings = crate::strings::extract_printable_strings(&bytes, 8);
if strings.is_empty() {
return;
}
(
keyhog_core::SensitiveString::join(&strings, "\n"),
"filesystem/compressed-binary",
)
}
};
let _ = emit(Ok(Chunk {
data,
metadata: ChunkMetadata {
source_type: source_type.into(),
path: Some(path_display),
..Default::default()
},
}));
}
fn file_mtime_ns(path: &Path) -> Option<u64> {
let meta = std::fs::metadata(path).ok()?;
let modified = meta.modified().ok()?;
let dur = modified.duration_since(std::time::UNIX_EPOCH).ok()?;
let nanos = dur.as_secs() as u128 * 1_000_000_000 + dur.subsec_nanos() as u128;
Some(u64::try_from(nanos).unwrap_or(u64::MAX))
}