use crate::core::config::ExtractionConfig;
use crate::types::{ArchiveEntry, ProcessingWarning};
use std::borrow::Cow;
use std::io::{Cursor, Read};
fn embedded_objects_warning(source_label: &str, message: String) -> ProcessingWarning {
ProcessingWarning {
source: Cow::Owned(format!("{}_embedded_objects", source_label)),
message: Cow::Owned(message),
}
}
fn collect_embedding_names(archive: &mut zip::ZipArchive<Cursor<&[u8]>>, embeddings_prefix: &str) -> Vec<String> {
(0..archive.len())
.filter_map(|i| {
let file = archive.by_index(i).ok()?;
let name = file.name().ok()?.into_owned();
if name.starts_with(embeddings_prefix) && name.len() > embeddings_prefix.len() {
Some(name)
} else {
None
}
})
.collect()
}
fn enforce_max_files_in_archive(
embedding_names: &mut Vec<String>,
max_files_in_archive: usize,
embeddings_prefix: &str,
source_label: &str,
warnings: &mut Vec<ProcessingWarning>,
) {
if embedding_names.len() <= max_files_in_archive {
return;
}
let skipped = embedding_names.len() - max_files_in_archive;
warnings.push(embedded_objects_warning(
source_label,
format!(
"Skipped {} embedded object(s) under '{}': max_files_in_archive ({}) reached",
skipped, embeddings_prefix, max_files_in_archive
),
));
embedding_names.truncate(max_files_in_archive);
}
fn embedded_file_capacity_cap(
config: &ExtractionConfig,
security_limits: &crate::extractors::security::SecurityLimits,
) -> u64 {
config
.max_embedded_file_bytes
.unwrap_or(security_limits.max_archive_size as u64)
}
fn clamp_declared_size(declared: u64, cap: u64) -> u64 {
declared.min(cap)
}
pub(crate) async fn extract_ooxml_embedded_objects(
zip_bytes: &[u8],
embeddings_prefix: &str,
source_label: &str,
config: &ExtractionConfig,
) -> (Vec<ArchiveEntry>, Vec<ProcessingWarning>) {
let mut children = Vec::new();
let mut warnings = Vec::new();
let cursor = Cursor::new(zip_bytes);
let mut archive = match zip::ZipArchive::new(cursor) {
Ok(a) => a,
Err(_) => return (children, warnings),
};
let mut embedding_names = collect_embedding_names(&mut archive, embeddings_prefix);
if embedding_names.is_empty() {
return (children, warnings);
}
let security_limits = config.security_limits.clone().unwrap_or_default();
enforce_max_files_in_archive(
&mut embedding_names,
security_limits.max_files_in_archive,
embeddings_prefix,
source_label,
&mut warnings,
);
if config.max_archive_depth == 0 {
warnings.push(embedded_objects_warning(
source_label,
format!(
"Skipped {} embedded object(s) under '{}': max_archive_depth reached",
embedding_names.len(),
embeddings_prefix
),
));
return (children, warnings);
}
let mut child_config = config.clone();
child_config.max_archive_depth = config.max_archive_depth.saturating_sub(1);
let embedded_capacity_cap = embedded_file_capacity_cap(config, &security_limits);
for entry_name in &embedding_names {
let (child, mut entry_warnings) = process_embedded_entry(
&mut archive,
entry_name,
embeddings_prefix,
source_label,
embedded_capacity_cap,
&child_config,
)
.await;
warnings.append(&mut entry_warnings);
if let Some(child) = child {
children.push(child);
}
}
(children, warnings)
}
async fn process_embedded_entry(
archive: &mut zip::ZipArchive<Cursor<&[u8]>>,
entry_name: &str,
embeddings_prefix: &str,
source_label: &str,
embedded_capacity_cap: u64,
child_config: &ExtractionConfig,
) -> (Option<ArchiveEntry>, Vec<ProcessingWarning>) {
let mut warnings = Vec::new();
let filename = entry_name
.strip_prefix(embeddings_prefix)
.unwrap_or(entry_name)
.to_string();
let Some(data) = read_embedded_entry_bytes(
archive,
entry_name,
&filename,
embedded_capacity_cap,
source_label,
&mut warnings,
) else {
return (None, warnings);
};
let is_ole_binary = data.len() >= 4 && data[0..4] == [0xD0, 0xCF, 0x11, 0xE0];
let child = if is_ole_binary {
extract_ole_entry(&data, filename, child_config, source_label, &mut warnings).await
} else {
extract_regular_entry(&data, filename, child_config, source_label, &mut warnings).await
};
(child, warnings)
}
fn read_embedded_entry_bytes(
archive: &mut zip::ZipArchive<Cursor<&[u8]>>,
entry_name: &str,
filename: &str,
embedded_capacity_cap: u64,
source_label: &str,
warnings: &mut Vec<ProcessingWarning>,
) -> Option<Vec<u8>> {
let data = match archive.by_name(entry_name) {
Ok(file) => {
let capacity_hint = clamp_declared_size(file.size(), embedded_capacity_cap) as usize;
let mut buf = Vec::with_capacity(capacity_hint);
let read_cap = embedded_capacity_cap.saturating_add(1);
if file.take(read_cap).read_to_end(&mut buf).is_err() {
warnings.push(embedded_objects_warning(
source_label,
format!("Failed to read embedded file '{}'", filename),
));
return None;
}
buf
}
Err(_) => return None,
};
if data.is_empty() {
return None;
}
if data.len() as u64 > embedded_capacity_cap {
warnings.push(embedded_objects_warning(
source_label,
format!(
"Skipped embedded file '{}': size {} bytes exceeds cap {} bytes",
filename,
data.len(),
embedded_capacity_cap
),
));
return None;
}
Some(data)
}
async fn extract_ole_entry(
data: &[u8],
filename: String,
child_config: &ExtractionConfig,
source_label: &str,
warnings: &mut Vec<ProcessingWarning>,
) -> Option<ArchiveEntry> {
match extract_ole_embedded_object(data) {
Some((inner_bytes, inner_mime)) => {
match crate::core::extractor::extract_bytes(&inner_bytes, &inner_mime, child_config).await {
Ok(result) => Some(ArchiveEntry {
path: filename,
mime_type: inner_mime,
result: Box::new(result),
}),
Err(e) => {
warnings.push(embedded_objects_warning(
source_label,
format!("Failed to extract embedded OLE object '{}': {}", filename, e),
));
None
}
}
}
None => {
warnings.push(embedded_objects_warning(
source_label,
format!(
"Skipped OLE compound file '{}': format identification not supported",
filename
),
));
None
}
}
}
async fn extract_regular_entry(
data: &[u8],
filename: String,
child_config: &ExtractionConfig,
source_label: &str,
warnings: &mut Vec<ProcessingWarning>,
) -> Option<ArchiveEntry> {
let detected_mime = crate::core::mime::detect_mime_type_from_bytes(data).ok().or_else(|| {
std::path::Path::new(&filename)
.extension()
.and_then(|ext| ext.to_str())
.and_then(|ext| mime_guess::from_ext(ext).first())
.map(|m| m.to_string())
});
let file_mime = match detected_mime {
Some(m) if m != "application/octet-stream" => m,
_ => {
warnings.push(embedded_objects_warning(
source_label,
format!(
"Skipped embedded file '{}': MIME type could not be determined",
filename
),
));
return None;
}
};
match crate::core::extractor::extract_bytes(data, &file_mime, child_config).await {
Ok(result) => Some(ArchiveEntry {
path: filename,
mime_type: file_mime,
result: Box::new(result),
}),
Err(e) => {
warnings.push(embedded_objects_warning(
source_label,
format!("Failed to extract embedded '{}': {}", filename, e),
));
None
}
}
}
#[cfg(any(feature = "office", feature = "hwp", feature = "email"))]
pub(crate) fn extract_ole_embedded_object(data: &[u8]) -> Option<(Vec<u8>, String)> {
let mut compound_file = cfb::CompoundFile::open(Cursor::new(data)).ok()?;
if compound_file.exists("Package") {
let mut stream = compound_file.open_stream("Package").ok()?;
let mut buf = Vec::new();
stream.read_to_end(&mut buf).ok()?;
if buf.is_empty() {
return None;
}
let mime = crate::core::mime::detect_mime_type_from_bytes(&buf).ok()?;
return Some((buf, mime));
}
let legacy_mime = if compound_file.exists("WordDocument") {
"application/msword"
} else if compound_file.exists("PowerPoint Document") {
"application/vnd.ms-powerpoint"
} else if compound_file.exists("Workbook") || compound_file.exists("Book") {
"application/vnd.ms-excel"
} else {
return None;
};
Some((data.to_vec(), legacy_mime.to_string()))
}
#[cfg(not(any(feature = "office", feature = "hwp", feature = "email")))]
pub(crate) fn extract_ole_embedded_object(_data: &[u8]) -> Option<(Vec<u8>, String)> {
None
}
#[cfg(all(test, feature = "office"))]
mod tests;