use keyhog_core::{Chunk, ChunkMetadata, SourceError};
use std::path::{Component, Path};
use super::walk_util::is_default_excluded;
fn sanitize_archive_entry_name(name: &str) -> Option<String> {
if name.contains('\0') {
return None;
}
let normalized = name.replace('\\', "/");
let mut segments: Vec<String> = Vec::new();
for component in Path::new(&normalized).components() {
match component {
Component::Normal(part) => {
segments.push(part.to_string_lossy().into_owned());
}
Component::CurDir => {} Component::ParentDir => {
if segments.pop().is_none() {
return None;
}
}
Component::RootDir | Component::Prefix(_) => return None,
}
}
if segments.is_empty() {
None
} else {
Some(segments.join("/"))
}
}
pub(super) fn extract_archive_chunks(
path: &Path,
max_size: u64,
) -> Vec<Result<Chunk, SourceError>> {
if std::fs::symlink_metadata(path)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
tracing::warn!(
archive = %path.display(),
"refusing to open archive at a symlink path — \
prevents the link-swap attack class"
);
return Vec::new();
}
let mut archive_chunks = Vec::new();
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;
}
let safe_name = match sanitize_archive_entry_name(&archive_entry.name) {
Some(n) => n,
None => {
tracing::warn!(
archive = %path.display(),
entry = %archive_entry.name,
"skipping archive entry: name contains path traversal or \
NUL byte (zip-slip guard)"
);
continue;
}
};
if archive_entry.uncompressed_size > max_size {
tracing::warn!(
archive = %path.display(),
entry = %safe_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) {
if let Ok(s) = String::from_utf8(content.clone()) {
archive_chunks.push(Ok(Chunk {
data: s.into(),
metadata: ChunkMetadata {
source_type: "filesystem/archive".into(),
path: Some(format!(
"{}//{}",
path.display(),
safe_name
)),
..Default::default()
},
}));
} else {
let strings = crate::strings::extract_printable_strings(&content, 8);
if !strings.is_empty() {
archive_chunks.push(Ok(Chunk {
data: keyhog_core::SensitiveString::join(&strings, "\n"),
metadata: ChunkMetadata {
source_type: "filesystem/archive-binary".into(),
path: Some(format!(
"{}//{}",
path.display(),
safe_name
)),
..Default::default()
},
}));
}
}
}
}
}
}
archive_chunks
}