use super::display_path;
use super::filter::{is_default_excluded, is_skip_extension};
use super::read;
use keyhog_core::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;
mod archive;
mod compressed;
mod hexnib;
mod image_metadata;
mod pdf;
#[cfg(fuzzing)]
pub use pdf::fuzz_extract_pdf_text;
mod rar;
mod seven_zip;
mod tex_package;
pub(crate) use archive::validate_scan_archive_entry_name;
pub(super) const UNCAPPED_ARCHIVE_BUDGET: u64 = 1024 * 1024 * 1024;
const EXTENSIONLESS_BINARY_PREFIX_SNIFF_BYTES: usize = 1024;
const GIT_LFS_POINTER_MAX_BYTES: usize = 1024;
fn file_is_git_lfs_pointer(path: &Path) -> bool {
let mut buf = [0u8; GIT_LFS_POINTER_MAX_BYTES];
match read::read_file_prefix_safe(path, &mut buf) {
Ok(n) => keyhog_core::git_lfs::is_git_lfs_pointer(&buf[..n]),
Err(_read_error) => false,
}
}
pub(crate) fn extraction_total_budget(max_size: u64) -> u64 {
if max_size == 0 {
UNCAPPED_ARCHIVE_BUDGET
} else {
max_size.saturating_mul(4)
}
}
pub(super) fn extraction_total_budget_usize(max_size: u64) -> usize {
match usize::try_from(extraction_total_budget(max_size)) {
Ok(value) => value,
Err(_error) => usize::MAX,
}
}
pub(crate) fn duplicate_zip_central_entries_error_for_test(path: &Path) -> Result<String, String> {
archive::duplicate_zip_central_entries_error_for_test(path)
}
pub(crate) fn duplicate_zip_local_entry_data_error_for_test(
path: &Path,
compressed_size: u64,
) -> Result<String, String> {
archive::duplicate_zip_local_entry_data_error_for_test(path, compressed_size)
}
pub(crate) fn duplicate_zip_reopen_error_for_test(path: &Path) -> Option<String> {
archive::duplicate_zip_reopen_error_for_test(path)
}
fn is_symlink(path: &Path) -> bool {
std::fs::symlink_metadata(path)
.map(|m| m.file_type().is_symlink())
.map_or(false, |is_link| is_link)
}
fn file_starts_with_utf16_bom(path: &Path) -> bool {
let mut bom = [0u8; 2];
matches!(read::read_file_prefix_safe(path, &mut bom), Ok(2))
&& (bom == [0xFF, 0xFE] || bom == [0xFE, 0xFF])
}
pub(super) fn record_binary_without_printable_strings(path: &str) {
let _event = crate::record_skip_event(crate::SourceSkipEvent::Binary);
tracing::warn!(
path,
"binary content yielded no printable strings; NOT scanned"
);
}
pub(super) fn record_default_excluded_archive_entry(archive_display: &str, entry_name: &str) {
let _event = crate::record_skip_event(crate::SourceSkipEvent::Excluded);
tracing::debug!(
archive = archive_display,
entry = entry_name,
"skipping archive entry: default-excluded path; NOT scanned"
);
}
pub(super) fn chunk_from_extracted_entry(
content: Vec<u8>,
entry_path: String,
text_source_type: &str,
binary_source_type: &str,
) -> Option<Result<Chunk, SourceError>> {
match read::decode_text_file_owned_or_bytes(content) {
Ok(text) if !text.is_empty() => Some(Ok(Chunk {
data: text.into(),
metadata: ChunkMetadata {
source_type: text_source_type.into(),
path: Some(entry_path.into()),
..Default::default()
},
})),
Ok(_) => None, Err(bytes) => {
let strings = crate::strings::extract_printable_strings(
&bytes,
crate::strings::MIN_PRINTABLE_STRING_LEN,
);
if strings.is_empty() {
record_binary_without_printable_strings(&entry_path);
None
} else {
tracing::info!(
entry = %entry_path,
"archive/compressed entry is not decodable text; scanning printable strings"
);
Some(Ok(Chunk {
data: crate::strings::join_sensitive_strings(&strings, "\n"),
metadata: ChunkMetadata {
source_type: binary_source_type.into(),
path: Some(entry_path.into()),
..Default::default()
},
}))
}
}
}
}
fn emit_tex_comment_chunks(
text: &str,
member_display: &str,
provenance: &tex_package::TexMemberProvenance,
emit: &mut dyn FnMut(Result<Chunk, SourceError>) -> bool,
) -> bool {
for &(start, end) in &provenance.comment_spans {
let Some(comment) = text.get(start..end) else {
continue;
};
let base_line = text.as_bytes()[..start]
.iter()
.filter(|&&byte| byte == b'\n')
.count();
if !emit(Ok(Chunk {
data: comment.to_string().into(),
metadata: ChunkMetadata {
source_type: provenance.role.comment_source_type().into(),
path: Some(member_display.into()),
base_offset: start,
base_line,
..Default::default()
},
})) {
return false;
}
}
true
}
fn emit_archive_leaf_member(
content: Vec<u8>,
member_display: &str,
provenance: Option<&tex_package::TexMemberProvenance>,
emit: &mut dyn FnMut(Result<Chunk, SourceError>) -> bool,
) -> bool {
if let (Some(provenance), Ok(text)) = (provenance, std::str::from_utf8(&content)) {
if !emit_tex_comment_chunks(text, member_display, provenance, emit) {
return false;
}
}
let text_source_type = provenance
.map(|item| item.role.source_type())
.unwrap_or("filesystem/archive"); let binary_source_type = match provenance.map(|item| item.role) {
Some(tex_package::TexMemberRole::Root) => "filesystem/archive-binary/tex-root",
Some(tex_package::TexMemberRole::Referenced) => "filesystem/archive-binary/tex-referenced",
Some(tex_package::TexMemberRole::Orphaned) => "filesystem/archive-binary/tex-orphaned",
None => "filesystem/archive-binary",
};
match chunk_from_extracted_entry(
content,
member_display.to_string(),
text_source_type,
binary_source_type,
) {
Some(chunk) => emit(chunk),
None => true,
}
}
pub(super) const MAX_NESTED_ARCHIVE_DEPTH: usize = 8;
#[allow(clippy::too_many_arguments)]
pub(super) fn emit_archive_member(
entry_name: &str,
content: Vec<u8>,
member_display: &str,
max_size: u64,
total_uncompressed: &mut u64,
nested_depth: usize,
respect_default_excludes: bool,
emit: &mut dyn FnMut(Result<Chunk, SourceError>) -> bool,
) -> bool {
emit_archive_member_with_tex_provenance(
entry_name,
content,
member_display,
max_size,
total_uncompressed,
nested_depth,
respect_default_excludes,
None,
emit,
)
}
#[allow(clippy::too_many_arguments)]
fn emit_archive_member_with_tex_provenance(
entry_name: &str,
content: Vec<u8>,
member_display: &str,
max_size: u64,
total_uncompressed: &mut u64,
nested_depth: usize,
respect_default_excludes: bool,
provenance: Option<&tex_package::TexMemberProvenance>,
emit: &mut dyn FnMut(Result<Chunk, SourceError>) -> bool,
) -> bool {
let is_tar = compressed::entry_is_embedded_tar(entry_name, &content);
let is_zip = !is_tar && archive::member_is_embedded_zip(entry_name, &content);
let compressed_format = if is_tar || is_zip {
None
} else {
compressed::compressed_member_format(entry_name)
};
if (is_tar || is_zip || compressed_format.is_some()) && nested_depth >= MAX_NESTED_ARCHIVE_DEPTH
{
let _event = crate::record_skip_event(crate::SourceSkipEvent::Unreadable);
return emit(Err(SourceError::Other(format!(
"failed to scan embedded archive '{member_display}': maximum nested archive depth {MAX_NESTED_ARCHIVE_DEPTH} exceeded; embedded archive was not scanned"
))));
}
if is_tar {
compressed::emit_tar_entries_with_state(
&content,
member_display,
max_size,
total_uncompressed,
nested_depth + 1,
respect_default_excludes,
emit,
);
return true;
}
if is_zip {
return archive::emit_embedded_zip_member(
content,
member_display,
max_size,
total_uncompressed,
nested_depth,
respect_default_excludes,
emit,
);
}
if let Some(format) = compressed_format {
return compressed::emit_decompressed_member(
format,
&content,
member_display,
max_size,
total_uncompressed,
nested_depth,
respect_default_excludes,
emit,
);
}
emit_archive_leaf_member(content, member_display, provenance, emit)
}
pub(super) fn report_archive_truncation(
archive_display: &str,
attempted_total: u64,
total_budget: u64,
) -> SourceError {
eprintln!(
"keyhog: WARNING: aborting archive extraction of {archive_display} at {attempted_total} bytes \
(> {total_budget} = 4x --max-file-size; archive-bomb guard) - remaining entries were \
NOT scanned."
);
let _event = crate::record_skip_event(crate::SourceSkipEvent::ArchiveTruncated);
SourceError::Other(format!(
"archive extraction of '{archive_display}' was truncated at {attempted_total} bytes by the archive-bomb guard (budget {total_budget}); remaining entries were not scanned"
))
}
fn filesystem_over_max_size_error(path: &Path, size_bytes: u64, max_size: u64) -> SourceError {
SourceError::Other(format!(
"failed to scan filesystem file '{}': size {size_bytes} exceeds --max-file-size cap {max_size}; file was not scanned",
display_path(path)
))
}
#[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;
#[derive(Clone, Copy)]
struct FileLiveMetadata {
mtime_ns: Option<u64>,
size_bytes: u64,
is_symlink: bool,
}
#[allow(clippy::too_many_arguments)]
pub(super) fn process_entry(
entry: codewalk::FileEntry,
merkle: &Option<Arc<MerkleIndex>>,
skipped: &Arc<AtomicUsize>,
default_exclude_root: &Path,
max_size: u64,
window_size: usize,
window_overlap: usize,
respect_default_excludes: bool,
emit: &mut dyn FnMut(Result<Chunk, SourceError>) -> bool,
) {
let path = entry.path;
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); let default_exclude_path = match path.strip_prefix(default_exclude_root) {
Ok(relative) => relative.to_string_lossy(),
Err(_) => std::borrow::Cow::Borrowed(filename), };
if respect_default_excludes && is_default_excluded(&default_exclude_path) {
let _event = crate::record_skip_event(crate::SourceSkipEvent::Excluded);
return;
}
let live_metadata = file_live_metadata(&path);
let file_size = live_metadata.map_or(entry.size, |meta| meta.size_bytes);
let live_mtime_ns = live_metadata.and_then(|meta| meta.mtime_ns);
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let image_kind = match image_metadata::probe_kind(&path, ext) {
Ok(kind) => kind,
Err(error) => {
let _event = crate::record_skip_event(crate::SourceSkipEvent::Unreadable);
emit(Err(SourceError::Other(format!(
"failed to inspect image metadata signature for '{}': {error}; image metadata was not scanned",
display_path(&path)
))));
return;
}
};
if is_skip_extension(ext) {
if file_size <= GIT_LFS_POINTER_MAX_BYTES as u64 && file_is_git_lfs_pointer(&path) {
let _event = crate::record_skip_event(crate::SourceSkipEvent::GitLfsPointer);
return;
}
if image_kind.is_none() {
let _event = crate::record_skip_event(crate::SourceSkipEvent::Binary);
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"
);
let _event = crate::record_skip_event(crate::SourceSkipEvent::OverMaxSize);
if !emit(Err(filesystem_over_max_size_error(
&path, file_size, max_size,
))) {
return;
}
return;
}
if let (Some(idx), Some(meta)) = (merkle.as_ref(), live_metadata) {
if !meta.is_symlink {
if let Some(mtime_ns) = meta.mtime_ns {
if idx.metadata_unchanged(&path, mtime_ns, meta.size_bytes) {
skipped.fetch_add(1, Ordering::Relaxed);
return;
}
}
}
}
if let Some(kind) = image_kind {
match image_metadata::extract(&path, kind, file_size, live_mtime_ns, max_size) {
Ok(extraction) => {
for chunk in extraction.chunks {
if !emit(Ok(chunk)) {
return;
}
}
if let Some(error) = extraction.coverage_error {
let _event = crate::record_skip_event(
crate::SourceSkipEvent::StructuredSourceParseFailure,
);
emit(Err(error));
}
}
Err(error) => {
let _event =
crate::record_skip_event(crate::SourceSkipEvent::StructuredSourceParseFailure);
emit(Err(error));
}
}
return;
}
if ext.is_empty() {
let mut buf = [0u8; EXTENSIONLESS_BINARY_PREFIX_SNIFF_BYTES];
if let Ok(n) = read::read_file_prefix_safe(&path, &mut buf) {
let head = &buf[..n];
if read::looks_binary_prefix(head) {
let _event = crate::record_skip_event(crate::SourceSkipEvent::Binary);
return;
}
}
}
if ext.eq_ignore_ascii_case("pdf") {
pdf::extract_pdf_chunks(&path, file_size, live_mtime_ns, max_size, emit);
return;
} else if ext.eq_ignore_ascii_case("7z") {
seven_zip::extract_seven_zip_chunks(&path, max_size, respect_default_excludes, emit);
return;
} else if ext.eq_ignore_ascii_case("rar") {
rar::extract_rar_chunks(&path, max_size, respect_default_excludes, emit);
return;
} else if archive::is_openpack_archive_ext(ext) {
archive::extract_openpack_archive(&path, ext, max_size, respect_default_excludes, emit);
return;
} else if ext.eq_ignore_ascii_case("tar") {
if is_symlink(&path) {
tracing::warn!(
archive = %path.display(),
"refusing to open archive at a symlink path - \
prevents the link-swap attack class"
);
let _event = crate::record_skip_event(crate::SourceSkipEvent::Unreadable);
if !emit(Err(SourceError::Other(format!(
"failed to scan tar file '{}': refusing to open archive at a symlink path; tar file was not scanned",
display_path(&path)
)))) {
return;
}
return;
}
match read::read_file_safe(&path, file_size) {
Ok(bytes) => {
if compressed::looks_like_tar(&bytes) {
compressed::emit_tar_entries(
&bytes,
&display_path(&path),
max_size,
respect_default_excludes,
emit,
);
return;
}
tracing::info!(
archive = %path.display(),
"file has .tar extension but is not a tar archive; scanning as plain file"
);
}
Err(error) => {
tracing::warn!(
archive = %path.display(),
%error,
"cannot read tar file; skipping"
);
let _event = crate::record_skip_event(crate::SourceSkipEvent::Unreadable);
if !emit(Err(SourceError::Other(format!(
"failed to scan tar file '{}': cannot read tar file ({error}); tar file was not scanned",
display_path(&path)
)))) {
return;
}
return;
}
}
} else if compressed::is_compressed_ext(ext) {
compressed::extract_compressed_chunks(&path, ext, max_size, respect_default_excludes, emit);
return;
} else if ext.eq_ignore_ascii_case("har") {
match read::read_file_safe(&path, file_size) {
Ok(bytes) => {
let path_str = display_path(&path);
match crate::har::try_expand_har(&bytes, &path_str, max_size) {
Some(har_chunks) => {
for chunk in har_chunks {
if !emit(chunk) {
return;
}
}
return;
}
None => {
tracing::info!(
path = %path.display(),
"HAR parse failed; scanning as plain file"
);
}
}
}
Err(error) => {
tracing::warn!(
path = %path.display(),
%error,
"cannot read HAR file; skipping"
);
let _event = crate::record_skip_event(crate::SourceSkipEvent::Unreadable);
if !emit(Err(SourceError::Other(format!(
"failed to scan HAR file '{}': cannot read HAR file ({error}); HAR file was not scanned",
display_path(&path)
)))) {
return;
}
return;
}
}
}
if live_metadata.map_or_else(|| is_symlink(&path), |meta| meta.is_symlink) {
tracing::warn!(
path = %path.display(),
"refusing to read content at a symlink path - prevents the link-swap attack class"
);
let _event = crate::record_skip_event(crate::SourceSkipEvent::Unreadable);
if !emit(Err(SourceError::Other(format!(
"failed to scan filesystem file '{}': refusing to read content at a symlink path; file was not scanned",
display_path(&path)
)))) {
return;
}
return;
}
if file_size > window_size as u64 && !file_starts_with_utf16_bom(&path) {
let display = display_path(&path);
let mut consumer_stopped = false;
let windowed_mmap_outcome = read::for_each_file_windowed_mmap(
&path,
window_size,
window_overlap,
|row| match row {
Ok(w) => {
let chunk = Ok(Chunk {
data: w.text.into(),
metadata: ChunkMetadata {
source_type: "filesystem/windowed".into(),
path: Some(display.clone().into()),
base_offset: w.offset,
base_line: w.base_line,
mtime_ns: live_mtime_ns,
size_bytes: Some(file_size),
decoded_span: None,
..Default::default()
},
});
if !emit(chunk) {
consumer_stopped = true;
return false;
}
true
}
Err(error) => {
if !emit(Err(error)) {
consumer_stopped = true;
return false;
}
true
}
},
);
match windowed_mmap_outcome {
read::WindowedMmapOutcome::Consumed => {
if consumer_stopped {
return;
}
return;
}
read::WindowedMmapOutcome::Fallback(mut file) => {
match file.metadata() {
Ok(meta) if meta.len() > read::MMAP_TOCTOU_SANITY_CAP_BYTES => {
tracing::warn!(
path = %path.display(),
live_size = meta.len(),
cap = read::MMAP_TOCTOU_SANITY_CAP_BYTES,
"refusing large-file buffered fallback: live size exceeds mmap sanity cap"
);
let _event = crate::record_skip_event(crate::SourceSkipEvent::OverMaxSize);
if !emit(Err(SourceError::Other(format!(
"failed to scan filesystem file '{}': live size {} exceeded the {}-byte large-file fallback sanity cap; file was not scanned",
display_path(&path),
meta.len(),
read::MMAP_TOCTOU_SANITY_CAP_BYTES
)))) {
return;
}
return;
}
Ok(_) => {}
Err(error) => {
tracing::warn!(
path = %path.display(),
%error,
"cannot stat large file for buffered fallback sanity cap; skipping"
);
let _event = crate::record_skip_event(crate::SourceSkipEvent::Unreadable);
if !emit(Err(SourceError::Io(error))) {
return;
}
return;
}
}
#[cfg(unix)]
{
use std::os::unix::io::AsRawFd;
let fd = file.as_raw_fd();
if unsafe { libc::flock(fd, libc::LOCK_SH | libc::LOCK_NB) } != 0 {
tracing::warn!(
path = %path.display(),
"large file is locked by another process; skipping buffered fallback to avoid scanning a torn write"
);
let _event = crate::record_skip_event(crate::SourceSkipEvent::Unreadable);
let error = std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"large file is locked by another process",
);
emit(Err(keyhog_core::SourceError::Io(error)));
return;
}
}
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(error) => {
tracing::warn!(
path = %path.display(),
%error,
"cannot read large file; stopping scan of this file"
);
let _event =
crate::record_skip_event(crate::SourceSkipEvent::Unreadable);
emit(Err(keyhog_core::SourceError::Io(error)));
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".into(),
path: Some(display.clone().into()),
base_offset: current_offset,
base_line: current_base_line,
mtime_ns: live_mtime_ns,
size_bytes: Some(file_size),
decoded_span: None,
..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(_error) => {
current_base_line +=
memchr::memchr_iter(b'\n', &buffer[..filled]).count();
current_offset += filled;
}
}
}
}
}
return;
}
let content_source = if file_size >= MMAP_THRESHOLD {
read::read_file_mmap(&path)
} else {
read::read_file_buffered(&path, file_size)
};
let (content, source_type) = match content_source {
Some(read::BufferedFileRead::Text(text)) if text.is_empty() => return,
Some(read::BufferedFileRead::Text(text)) => (text.into(), "filesystem"),
Some(read::BufferedFileRead::Bytes(bytes)) => {
let strings = crate::strings::extract_printable_strings(
&bytes,
crate::strings::MIN_PRINTABLE_STRING_LEN,
);
if strings.is_empty() {
record_binary_without_printable_strings(&display_path(&path));
return;
}
tracing::info!(
path = %path.display(),
"file is not valid text; scanning printable strings only"
);
(
crate::strings::join_sensitive_strings(&strings, "\n"),
"filesystem:binary-strings",
)
}
Some(read::BufferedFileRead::Mmap(mmap)) => {
let strings = crate::strings::extract_printable_strings(
&mmap,
crate::strings::MIN_PRINTABLE_STRING_LEN,
);
if strings.is_empty() {
record_binary_without_printable_strings(&display_path(&path));
return;
}
tracing::info!(
path = %path.display(),
"file is not valid text; scanning mmap-backed printable strings only"
);
(
crate::strings::join_sensitive_strings(&strings, "\n"),
"filesystem:binary-strings",
)
}
_ => {
if !emit(Err(keyhog_core::SourceError::Other(format!(
"failed to scan filesystem file '{}': primary read path refused the file; file was not scanned",
display_path(&path)
)))) {
return;
}
return;
}
};
if content.len() <= GIT_LFS_POINTER_MAX_BYTES
&& keyhog_core::git_lfs::is_git_lfs_pointer(content.as_bytes())
{
let _event = crate::record_skip_event(crate::SourceSkipEvent::GitLfsPointer);
}
if !emit(Ok(Chunk {
data: content,
metadata: ChunkMetadata {
source_type: source_type.into(),
path: Some(display_path(&path).into()),
mtime_ns: live_mtime_ns,
size_bytes: Some(file_size),
decoded_span: None,
..Default::default()
},
})) {
tracing::debug!("filesystem chunk consumer stopped before final chunk");
}
}
fn file_live_metadata(path: &Path) -> Option<FileLiveMetadata> {
let meta = std::fs::symlink_metadata(path).ok()?; let file_type = meta.file_type();
let mtime_ns = meta
.modified()
.ok() .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok()) .map(|dur| {
let nanos = dur.as_secs() as u128 * 1_000_000_000 + dur.subsec_nanos() as u128;
u64::try_from(nanos).map_or(u64::MAX, |nanos| nanos)
});
Some(FileLiveMetadata {
mtime_ns,
size_bytes: meta.len(),
is_symlink: file_type.is_symlink(),
})
}