use std::collections::HashMap;
use std::io::{Cursor, Read};
use docling_core::PictureImage;
use quick_xml::events::Event;
use quick_xml::Reader;
use zip::ZipArchive;
pub(crate) fn max_part_bytes() -> u64 {
std::env::var("DOCLING_RS_MAX_PART_BYTES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(512 * 1024 * 1024)
}
#[derive(Clone)]
pub struct Package {
zip: ZipArchive<Cursor<std::sync::Arc<[u8]>>>,
}
impl Package {
pub fn open(bytes: &[u8]) -> Option<Self> {
ZipArchive::new(Cursor::new(std::sync::Arc::from(bytes)))
.ok()
.map(|zip| Self { zip })
}
pub fn read(&mut self, path: &str) -> Option<String> {
let bytes = self.read_bytes(path)?;
String::from_utf8(bytes).ok()
}
pub fn read_bytes(&mut self, path: &str) -> Option<Vec<u8>> {
self.read_bytes_capped(path, max_part_bytes())
}
fn read_bytes_capped(&mut self, path: &str, cap: u64) -> Option<Vec<u8>> {
let file = self.zip.by_name(path).ok()?;
if file.size() > cap {
return None;
}
let mut out = Vec::new();
file.take(cap + 1).read_to_end(&mut out).ok()?;
if out.len() as u64 > cap {
return None;
}
Some(out)
}
pub fn image_rels(&mut self, part: &str, base_dir: &str) -> HashMap<String, PictureImage> {
let rels = self.rels_for(part);
let mut out = HashMap::new();
for r in &rels {
if !r.rel_type.ends_with("/image") {
continue;
}
let path = resolve(base_dir, &r.target);
if let Some(bytes) = self.read_bytes(&path) {
if let Some(img) = picture_image(&path, bytes) {
out.insert(r.id.clone(), img);
}
}
}
out
}
pub fn rels_for(&mut self, part: &str) -> Vec<Relationship> {
let (dir, file) = split_path(part);
let rels_path = if dir.is_empty() {
format!("_rels/{file}.rels")
} else {
format!("{dir}/_rels/{file}.rels")
};
self.read(&rels_path)
.map(|x| parse_rels(&x))
.unwrap_or_default()
}
}
pub struct Relationship {
pub id: String,
pub rel_type: String,
pub target: String,
}
pub fn parse_rels(xml: &str) -> Vec<Relationship> {
let mut reader = Reader::from_str(xml);
let mut buf = Vec::new();
let mut out = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Empty(e)) | Ok(Event::Start(e)) if e.name().as_ref() == b"Relationship" => {
let (mut id, mut rel_type, mut target) =
(String::new(), String::new(), String::new());
for attr in e.attributes().flatten() {
let value = String::from_utf8_lossy(attr.value.as_ref()).into_owned();
match attr.key.as_ref() {
b"Id" => id = value,
b"Type" => rel_type = value,
b"Target" => target = value,
_ => {}
}
}
out.push(Relationship {
id,
rel_type,
target,
});
}
Ok(Event::Eof) | Err(_) => break,
_ => {}
}
buf.clear();
}
out
}
fn split_path(path: &str) -> (&str, &str) {
match path.rfind('/') {
Some(i) => (&path[..i], &path[i + 1..]),
None => ("", path),
}
}
pub fn resolve(base_dir: &str, target: &str) -> String {
if let Some(abs) = target.strip_prefix('/') {
return abs.to_string();
}
let mut parts: Vec<&str> = base_dir.split('/').filter(|p| !p.is_empty()).collect();
for seg in target.split('/') {
match seg {
"" | "." => {}
".." => {
parts.pop();
}
s => parts.push(s),
}
}
parts.join("/")
}
pub fn picture_image(path: &str, data: Vec<u8>) -> Option<PictureImage> {
let (width, height) = image::ImageReader::new(Cursor::new(&data))
.with_guessed_format()
.ok()?
.into_dimensions()
.ok()?;
Some(PictureImage {
mimetype: mime_for(path).to_string(),
width,
height,
data,
})
}
fn mime_for(path: &str) -> &'static str {
match path
.rsplit('.')
.next()
.unwrap_or("")
.to_ascii_lowercase()
.as_str()
{
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"bmp" => "image/bmp",
"tif" | "tiff" => "image/tiff",
"webp" => "image/webp",
_ => "image/png",
}
}
pub fn content_type(content_types_xml: &str, part: &str) -> Option<String> {
let mut reader = Reader::from_str(content_types_xml);
let mut buf = Vec::new();
let want_part = format!("/{}", part.trim_start_matches('/'));
let ext = part.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
let mut default: Option<String> = None;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Empty(e)) | Ok(Event::Start(e)) => {
let tag = e.name();
let attr = |key: &[u8]| -> Option<String> {
e.attributes()
.flatten()
.find(|a| a.key.as_ref() == key)
.map(|a| String::from_utf8_lossy(a.value.as_ref()).into_owned())
};
match tag.as_ref() {
b"Override" => {
if attr(b"PartName").as_deref() == Some(want_part.as_str()) {
return attr(b"ContentType");
}
}
b"Default"
if attr(b"Extension")
.map(|x| x.to_ascii_lowercase())
.as_deref()
== Some(ext.as_str()) =>
{
default = attr(b"ContentType");
}
_ => {}
}
}
Ok(Event::Eof) | Err(_) => break,
_ => {}
}
buf.clear();
}
default
}
#[cfg(test)]
mod zip_bomb_tests {
use super::Package;
use std::io::Write;
fn zip_with_part(name: &str, part_len: usize) -> Vec<u8> {
let mut buf = std::io::Cursor::new(Vec::new());
{
let mut zw = zip::ZipWriter::new(&mut buf);
let opts: zip::write::FileOptions<'_, ()> = zip::write::FileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
zw.start_file(name, opts).unwrap();
zw.write_all(&vec![b'a'; part_len]).unwrap();
zw.finish().unwrap();
}
buf.into_inner()
}
#[test]
fn oversized_part_is_rejected_not_truncated() {
let bytes = zip_with_part("word/document.xml", 1024 * 1024);
assert!(bytes.len() < 64 * 1024, "part should compress tiny");
let mut pkg = Package::open(&bytes).unwrap();
assert!(
pkg.read_bytes_capped("word/document.xml", 4096).is_none(),
"a part over the cap must be rejected"
);
let out = pkg
.read_bytes_capped("word/document.xml", 8 * 1024 * 1024)
.expect("part under the cap reads");
assert_eq!(out.len(), 1024 * 1024);
}
}