mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-27 20:07:19 -06:00
Each picture, and each embedded object or macro part kept for saving, was copied out of the unzipped package (bytes.to_vec()), so a document with pictures held them twice while it opened, and the package's copies were left behind as freed memory. Package now holds its parts as Arc<Vec<u8>>, the type the document keeps media and passthrough parts in, and hands them over with get_shared. keep_unmodelled also searches the main part in place instead of copying it. A 92 MB document with 20 pictures on macOS 27: the app's footprint once open 866 -> 772 MB (freed blocks kept 206 -> 118 MB, peak 915-932 -> 821-823 MB); wordcraft-cli info peak 319 -> 231 MB. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
501 lines
25 KiB
Rust
501 lines
25 KiB
Rust
//! OPC package access: bounded zip reading, relationships, part names.
|
|
|
|
use std::collections::{BTreeMap, HashMap};
|
|
use std::io::{Cursor, Read, Write};
|
|
use std::sync::Arc;
|
|
|
|
use crate::DocxError;
|
|
use crate::xml::{self, El};
|
|
|
|
/// Largest single part we decompress.
|
|
pub const MAX_PART: u64 = 256 * 1024 * 1024;
|
|
/// Largest total of all decompressed parts.
|
|
pub const MAX_TOTAL: u64 = 512 * 1024 * 1024;
|
|
/// Most zip entries we look at.
|
|
pub const MAX_ENTRIES: usize = 20_000;
|
|
|
|
/// Relationship type suffixes (after `.../relationships/`).
|
|
pub mod rt {
|
|
pub const OFFICE_DOC: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument";
|
|
pub const CORE: &str = "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties";
|
|
pub const APP: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties";
|
|
pub const CUSTOM: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties";
|
|
pub const CUSTOM_XML_PROPS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXmlProps";
|
|
pub const STYLES: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles";
|
|
pub const NUMBERING: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering";
|
|
pub const SETTINGS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings";
|
|
pub const ATTACHED_TEMPLATE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/attachedTemplate";
|
|
pub const FONT_TABLE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable";
|
|
pub const THEME: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme";
|
|
pub const FOOTNOTES: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes";
|
|
pub const ENDNOTES: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes";
|
|
pub const COMMENTS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments";
|
|
pub const COMMENTS_EX: &str = "http://schemas.microsoft.com/office/2011/relationships/commentsExtended";
|
|
pub const COMMENTS_IDS: &str = "http://schemas.microsoft.com/office/2016/09/relationships/commentsIds";
|
|
pub const COMMENTS_EXTENSIBLE: &str = "http://schemas.microsoft.com/office/2018/08/relationships/commentsExtensible";
|
|
pub const HEADER: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header";
|
|
pub const FOOTER: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer";
|
|
pub const IMAGE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
|
|
pub const HYPERLINK: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
|
|
pub const VBA_PROJECT: &str = "http://schemas.microsoft.com/office/2006/relationships/vbaProject";
|
|
pub const CHART: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart";
|
|
pub const DIAGRAM_DATA: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramData";
|
|
pub const DIAGRAM_DRAWING: &str = "http://schemas.microsoft.com/office/2007/relationships/diagramDrawing";
|
|
pub const DIAGRAM_LAYOUT: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramLayout";
|
|
pub const DIAGRAM_QUICK_STYLE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramQuickStyle";
|
|
pub const DIAGRAM_COLORS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramColors";
|
|
pub const CUSTOM_XML: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml";
|
|
pub const GLOSSARY: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/glossaryDocument";
|
|
}
|
|
|
|
/// Content type of a glossary document part (ECMA-376 Part 1 §11.3.8).
|
|
pub const GLOSSARY_CONTENT_TYPE: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.document.glossary+xml";
|
|
/// `Document::passthrough` key: a fingerprint of `Document::glossary` as read, so a save can tell
|
|
/// an untouched glossary (written back verbatim) from an edited one (written from the model).
|
|
/// Not a package part: WordCraft's own bookkeeping.
|
|
pub const GLOSSARY_READ: &str = "wordcraft:glossary.read";
|
|
/// Most building blocks read from one glossary.
|
|
pub const MAX_GLOSSARY_ENTRIES: usize = 10_000;
|
|
/// The glossary entry types `w:type` takes (ST_DocPartType); others aren't kept.
|
|
pub const GLOSSARY_TYPES: [&str; 7] = ["none", "normal", "autoExp", "toolbar", "speller", "formFld", "bbPlcHdr"];
|
|
/// The insertion behaviours `w:behavior` takes (ST_DocPartBehavior); others aren't kept.
|
|
pub const GLOSSARY_BEHAVIORS: [&str; 3] = ["content", "p", "pg"];
|
|
|
|
/// A fingerprint of `glossary` (FNV-1a over its debug form): stable across runs and builds that
|
|
/// keep the model's shape, and cheap (nothing is allocated).
|
|
pub fn glossary_fingerprint(glossary: &[wordcraft_doc::BuildingBlock]) -> String {
|
|
struct Fnv(u64);
|
|
impl std::fmt::Write for Fnv {
|
|
fn write_str(&mut self, s: &str) -> std::fmt::Result {
|
|
for b in s.bytes() {
|
|
self.0 = (self.0 ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3);
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
let mut h = Fnv(0xcbf2_9ce4_8422_2325);
|
|
// Writing into `Fnv` never fails.
|
|
let _ = std::fmt::Write::write_fmt(&mut h, format_args!("{glossary:?}"));
|
|
format!("{:016x}", h.0)
|
|
}
|
|
|
|
/// `Document::passthrough` key for a macro project, kept as opaque bytes (never parsed or run).
|
|
pub const VBA_PROJECT_PART: &str = "word/vbaProject.bin";
|
|
/// `Document::passthrough` key listing the parts the project relates to (VBA data, signatures…),
|
|
/// one `relationship type \t part name \t content type` line each; each part's bytes are kept
|
|
/// under its part name. Not a package part: WordCraft's own bookkeeping.
|
|
pub const VBA_RELATED: &str = "wordcraft:vbaProject.related";
|
|
/// Most parts we carry along with a macro project.
|
|
pub const MAX_VBA_RELATED: usize = 64;
|
|
|
|
/// `Document::passthrough` key listing the parts that objects read from a file refer to (charts,
|
|
/// SmartArt diagrams, OLE objects: see `wordcraft_doc::graphic::Embedded`), with their content
|
|
/// types and own relationships, as [`EmbeddedManifest`] lines. Each part's bytes are kept under its
|
|
/// part name. Not a package part: WordCraft's own bookkeeping.
|
|
pub const EMBEDDED_PARTS: &str = "wordcraft:embedded.parts";
|
|
/// The font table kept from a file (read by `Reader::keep_part_tree`), with the fonts it embeds.
|
|
pub const FONT_TABLE_PART: &str = "word/fontTable.xml";
|
|
/// Custom XML roots kept by the package (P) or main document (D): `owner\ttype\tpart`.
|
|
/// Their opaque bytes and related parts use the same bounded manifest as embedded objects.
|
|
pub const CUSTOM_XML_ROOTS: &str = "wordcraft:customXml.roots";
|
|
/// The original `word/settings.xml` when it holds settings the model doesn't (merged into on
|
|
/// save by `write::settings_merge`). Not a package part: WordCraft's own bookkeeping.
|
|
pub const SETTINGS_ORIG: &str = "wordcraft:settings.orig";
|
|
/// Extended properties (`docProps/app.xml`) the model doesn't hold but a save keeps
|
|
/// ([`APP_KEPT_PROPS`]): `name\tvalue` lines. Not a package part: WordCraft's own bookkeeping.
|
|
pub const APP_KEPT: &str = "wordcraft:app.kept";
|
|
/// The `docProps/app.xml` properties carried over from the file. Counts (pages, lines,
|
|
/// characters, editing time) and the producing application's version are not: they describe
|
|
/// the file Word saved, not the one WordCraft writes.
|
|
pub const APP_KEPT_PROPS: &[&str] =
|
|
&["Template", "Manager", "Company", "HyperlinkBase", "ScaleCrop", "LinksUpToDate", "SharedDoc", "HyperlinksChanged"];
|
|
/// Parts WordCraft doesn't model, carried verbatim: `owner\ttype\tpart` lines (owner `P` for
|
|
/// the package, `D` for the main document), with their bytes and relationships in the
|
|
/// [`EmbeddedManifest`]. Not a package part: WordCraft's own bookkeeping.
|
|
pub const UNMODELLED_ROOTS: &str = "wordcraft:unmodelled.roots";
|
|
/// Most parts we carry along for a file's embedded objects.
|
|
pub const MAX_EMBEDDED_PARTS: usize = 4096;
|
|
/// Most relationships we keep for one such part.
|
|
pub const MAX_EMBEDDED_RELS: usize = 1024;
|
|
/// Longest markup we keep for one embedded object.
|
|
pub const MAX_EMBEDDED_XML: usize = 1024 * 1024;
|
|
|
|
/// One part an embedded object needs: its content type and its own relationships (internal
|
|
/// targets are part names).
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct EmbeddedPart {
|
|
pub content_type: String,
|
|
pub rels: Vec<Rel>,
|
|
}
|
|
|
|
/// The parts kept for embedded objects, by part name (see [`EMBEDDED_PARTS`]). Stored as text:
|
|
/// `P\tpart\tcontent type` per part, then `R\tpart\tid\ttype\ttarget\tI|E` per relationship of
|
|
/// that part (`E`: `target` is an external URL).
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct EmbeddedManifest {
|
|
pub parts: BTreeMap<String, EmbeddedPart>,
|
|
}
|
|
|
|
/// Can `s` be a field of a manifest line?
|
|
pub fn manifest_field_ok(s: &str) -> bool {
|
|
!s.is_empty() && !s.contains(['\t', '\r', '\n'])
|
|
}
|
|
|
|
impl EmbeddedManifest {
|
|
pub fn parse(bytes: &[u8]) -> EmbeddedManifest {
|
|
let mut m = EmbeddedManifest::default();
|
|
for line in String::from_utf8_lossy(bytes).lines() {
|
|
let f: Vec<&str> = line.split('\t').collect();
|
|
match f.as_slice() {
|
|
["P", part, ct] if m.parts.len() < MAX_EMBEDDED_PARTS => {
|
|
m.parts.entry((*part).to_string()).or_insert_with(|| EmbeddedPart { content_type: (*ct).to_string(), rels: Vec::new() });
|
|
}
|
|
["R", part, id, kind, target, mode] => {
|
|
if let Some(p) = m.parts.get_mut(*part)
|
|
&& p.rels.len() < MAX_EMBEDDED_RELS
|
|
&& !p.rels.iter().any(|r| r.id == *id)
|
|
{
|
|
p.rels.push(Rel { id: (*id).to_string(), kind: (*kind).to_string(), target: (*target).to_string(), external: *mode == "E" });
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
m
|
|
}
|
|
|
|
pub fn to_bytes(&self) -> Vec<u8> {
|
|
let mut s = String::new();
|
|
for (part, p) in &self.parts {
|
|
if !manifest_field_ok(part) || !manifest_field_ok(&p.content_type) {
|
|
continue;
|
|
}
|
|
s.push_str(&format!("P\t{part}\t{}\n", p.content_type));
|
|
}
|
|
for (part, p) in &self.parts {
|
|
for r in p.rels.iter().filter(|r| [&r.id, &r.kind, &r.target].iter().all(|f| manifest_field_ok(f))) {
|
|
s.push_str(&format!("R\t{part}\t{}\t{}\t{}\t{}\n", r.id, r.kind, r.target, if r.external { "E" } else { "I" }));
|
|
}
|
|
}
|
|
s.into_bytes()
|
|
}
|
|
}
|
|
|
|
/// Does relationship type `t` end with `suffix` (ignoring the transitional/strict prefix)?
|
|
pub fn rel_is(t: &str, full: &str) -> bool {
|
|
let tail = |s: &str| s.rsplit('/').next().unwrap_or(s).to_string();
|
|
t == full || tail(t) == tail(full)
|
|
}
|
|
|
|
/// The decompressed parts of a package (bounded). Shared, so a part the document keeps as it is (a
|
|
/// picture, an embedded object) is handed over rather than copied.
|
|
pub struct Package {
|
|
files: BTreeMap<String, Arc<Vec<u8>>>,
|
|
}
|
|
|
|
impl Package {
|
|
pub fn open(bytes: &[u8]) -> Result<Package, DocxError> {
|
|
Package::open_limited(bytes, MAX_PART, MAX_TOTAL)
|
|
}
|
|
|
|
pub fn open_limited(bytes: &[u8], max_part: u64, max_total: u64) -> Result<Package, DocxError> {
|
|
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| DocxError::Zip(e.to_string()))?;
|
|
if zip.len() > MAX_ENTRIES {
|
|
return Err(DocxError::Limit(format!("{} zip entries", zip.len())));
|
|
}
|
|
let mut files = BTreeMap::new();
|
|
let mut total: u64 = 0;
|
|
for i in 0..zip.len() {
|
|
let mut f = match zip.by_index(i) {
|
|
Ok(f) => f,
|
|
Err(e) => {
|
|
log::warn!("docx: skipping unreadable zip entry {i}: {e}");
|
|
continue;
|
|
}
|
|
};
|
|
if f.is_dir() {
|
|
continue;
|
|
}
|
|
let name = f.name().trim_start_matches('/').to_string();
|
|
if f.size() > max_part {
|
|
return Err(DocxError::Limit(format!("part {name} declares {} bytes", f.size())));
|
|
}
|
|
let room = max_part.min(max_total.saturating_sub(total));
|
|
let mut buf = Vec::with_capacity(f.size().min(16 * 1024 * 1024) as usize);
|
|
let read = (&mut f).take(room + 1).read_to_end(&mut buf);
|
|
if let Err(e) = read {
|
|
// A corrupt entry: keep going unless it's a part we can't do without.
|
|
log::warn!("docx: zip entry {name} unreadable: {e}");
|
|
continue;
|
|
}
|
|
if buf.len() as u64 > room {
|
|
return Err(DocxError::Limit(format!("part {name} decompresses past the size limit")));
|
|
}
|
|
total = total.saturating_add(buf.len() as u64);
|
|
files.insert(name, Arc::new(buf));
|
|
}
|
|
Ok(Package { files })
|
|
}
|
|
|
|
/// A package from parts already in memory (a Flat OPC file, a converted document), by part
|
|
/// name without the leading `/`.
|
|
pub fn from_parts(files: BTreeMap<String, Vec<u8>>) -> Package {
|
|
Package { files: files.into_iter().map(|(name, bytes)| (name, Arc::new(bytes))).collect() }
|
|
}
|
|
|
|
/// Part bytes by name (leading `/` ignored; case-insensitive fallback).
|
|
pub fn get(&self, name: &str) -> Option<&[u8]> {
|
|
self.get_shared(name).map(|v| v.as_slice())
|
|
}
|
|
|
|
/// [`Package::get`], as the shared bytes themselves: for a part the document keeps whole, which
|
|
/// then costs no copy (a picture document's pictures used to be held twice while it opened).
|
|
pub fn get_shared(&self, name: &str) -> Option<&Arc<Vec<u8>>> {
|
|
let n = name.trim_start_matches('/');
|
|
if let Some(v) = self.files.get(n) {
|
|
return Some(v);
|
|
}
|
|
self.files.iter().find(|(k, _)| k.eq_ignore_ascii_case(n)).map(|(_, v)| v)
|
|
}
|
|
|
|
pub fn xml(&self, name: &str) -> Result<Option<El>, DocxError> {
|
|
match self.get(name) {
|
|
Some(b) => xml::parse(b).map(Some),
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
/// Relationships of `part` (from `dir/_rels/name.rels`).
|
|
pub fn rels(&self, part: &str) -> Rels {
|
|
let (dir, file) = split_dir(part);
|
|
let path = if dir.is_empty() { format!("_rels/{file}.rels") } else { format!("{dir}/_rels/{file}.rels") };
|
|
let mut rels = Rels::default();
|
|
let root = match self.xml(&path) {
|
|
Ok(Some(root)) => root,
|
|
Ok(None) => return rels,
|
|
Err(e) => {
|
|
log::warn!("docx: ignoring unreadable relationships {path}: {e}");
|
|
return rels;
|
|
}
|
|
};
|
|
for r in root.els().filter(|e| e.local() == "Relationship") {
|
|
let (Some(id), Some(target)) = (r.attr("Id"), r.attr("Target")) else { continue };
|
|
let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External"));
|
|
let resolved = if external { target.to_string() } else { resolve(dir, target) };
|
|
rels.list.push(Rel { id: id.to_string(), kind: r.attr("Type").unwrap_or("").to_string(), target: resolved, external });
|
|
}
|
|
rels
|
|
}
|
|
}
|
|
|
|
/// `[Content_Types].xml`: Override by part name, then Default by extension (both case-insensitive).
|
|
#[derive(Default)]
|
|
pub struct ContentTypes {
|
|
overrides: HashMap<String, String>,
|
|
defaults: HashMap<String, String>,
|
|
}
|
|
|
|
impl ContentTypes {
|
|
pub fn read(pkg: &Package) -> ContentTypes {
|
|
let mut ct = ContentTypes::default();
|
|
let root = match pkg.xml("[Content_Types].xml") {
|
|
Ok(Some(root)) => root,
|
|
Ok(None) => return ct,
|
|
Err(e) => {
|
|
log::warn!("docx: ignoring unreadable [Content_Types].xml: {e}");
|
|
return ct;
|
|
}
|
|
};
|
|
for e in root.els() {
|
|
let (Some(key), Some(t)) = (e.attr("PartName").or_else(|| e.attr("Extension")), e.attr("ContentType")) else { continue };
|
|
let map = if e.local() == "Override" { &mut ct.overrides } else { &mut ct.defaults };
|
|
map.entry(key.trim_start_matches('/').to_ascii_lowercase()).or_insert_with(|| t.to_string());
|
|
}
|
|
ct
|
|
}
|
|
|
|
/// Content type of `part` (a package path without the leading `/`).
|
|
pub fn of(&self, part: &str) -> Option<&str> {
|
|
let part = part.trim_start_matches('/').to_ascii_lowercase();
|
|
if let Some(t) = self.overrides.get(&part) {
|
|
return Some(t);
|
|
}
|
|
let ext = part.rsplit_once('.').map(|(_, e)| e)?;
|
|
self.defaults.get(ext).map(String::as_str)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct Rel {
|
|
pub id: String,
|
|
pub kind: String,
|
|
/// Package path (internal) or URL (external).
|
|
pub target: String,
|
|
pub external: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct Rels {
|
|
pub list: Vec<Rel>,
|
|
}
|
|
|
|
impl Rels {
|
|
pub fn by_id(&self, id: &str) -> Option<&Rel> {
|
|
self.list.iter().find(|r| r.id == id)
|
|
}
|
|
pub fn by_type(&self, full: &str) -> Option<&Rel> {
|
|
self.list.iter().find(|r| rel_is(&r.kind, full))
|
|
}
|
|
}
|
|
|
|
fn split_dir(part: &str) -> (&str, &str) {
|
|
let p = part.trim_start_matches('/');
|
|
match p.rfind('/') {
|
|
Some(i) => (p.get(..i).unwrap_or(""), p.get(i + 1..).unwrap_or(p)),
|
|
None => ("", p),
|
|
}
|
|
}
|
|
|
|
/// Resolve a relative target against a source directory into a package path.
|
|
pub fn resolve(dir: &str, target: &str) -> String {
|
|
let target = target.split('#').next().unwrap_or(target);
|
|
let mut parts: Vec<&str> = if target.starts_with('/') { Vec::new() } else { dir.split('/').filter(|s| !s.is_empty()).collect() };
|
|
for seg in target.split('/') {
|
|
match seg {
|
|
"" | "." => {}
|
|
".." => {
|
|
parts.pop();
|
|
}
|
|
s => parts.push(s),
|
|
}
|
|
}
|
|
parts.join("/")
|
|
}
|
|
|
|
/// The relative reference from part `from` to part `to` (both package paths), as a
|
|
/// relationship target: `word/document.xml` → `word/charts/chart1.xml` is `charts/chart1.xml`.
|
|
pub fn relative(from: &str, to: &str) -> String {
|
|
let from_dir: Vec<&str> = from.split('/').filter(|s| !s.is_empty()).collect();
|
|
let from_dir = from_dir.get(..from_dir.len().saturating_sub(1)).unwrap_or(&[]);
|
|
let to: Vec<&str> = to.split('/').filter(|s| !s.is_empty()).collect();
|
|
let to_dir = to.get(..to.len().saturating_sub(1)).unwrap_or(&[]);
|
|
let common = from_dir.iter().zip(to_dir).take_while(|(a, b)| a == b).count();
|
|
let mut out: Vec<&str> = vec![".."; from_dir.len() - common];
|
|
out.extend(to.get(common..).unwrap_or(&[]));
|
|
let s = out.join("/");
|
|
// A relative reference whose first segment has a colon would read as a URI scheme.
|
|
if s.split('/').next().is_some_and(|seg| seg.contains(':')) { format!("./{s}") } else { s }
|
|
}
|
|
|
|
/// Build a zip from (name, bytes) entries.
|
|
pub fn zip_entries(entries: &[(String, Vec<u8>)]) -> Result<Vec<u8>, DocxError> {
|
|
let mut zw = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
|
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated).compression_level(Some(6));
|
|
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
|
for (name, bytes) in entries {
|
|
// Already-compressed media gain nothing from deflate.
|
|
let lower = name.to_ascii_lowercase();
|
|
let o = if [".png", ".jpg", ".jpeg", ".gif", ".webp"].iter().any(|e| lower.ends_with(e)) { stored } else { opts };
|
|
zw.start_file(name.as_str(), o).map_err(|e| DocxError::Zip(e.to_string()))?;
|
|
zw.write_all(bytes).map_err(|e| DocxError::Zip(e.to_string()))?;
|
|
}
|
|
let c = zw.finish().map_err(|e| DocxError::Zip(e.to_string()))?;
|
|
Ok(c.into_inner())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn zip_of(entries: &[(&str, Vec<u8>)]) -> Vec<u8> {
|
|
let e: Vec<(String, Vec<u8>)> = entries.iter().map(|(n, b)| (n.to_string(), b.clone())).collect();
|
|
zip_entries(&e).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn decompression_bomb_is_rejected() {
|
|
let z = zip_of(&[("word/document.xml", vec![b' '; 2_000_000])]);
|
|
assert!(z.len() < 100_000);
|
|
assert!(matches!(Package::open_limited(&z, 1_000_000, 10_000_000), Err(DocxError::Limit(_))));
|
|
assert!(matches!(Package::open_limited(&z, 10_000_000, 1_000_000), Err(DocxError::Limit(_))));
|
|
assert!(Package::open_limited(&z, 10_000_000, 10_000_000).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn lying_declared_size_is_rejected_or_bounded() {
|
|
let mut z = zip_of(&[("a.xml", b"<a/>".to_vec())]);
|
|
// Patch the uncompressed size in the central directory and local header to ~4 GB.
|
|
let mut patched = 0;
|
|
for sig in [[0x50u8, 0x4B, 0x01, 0x02], [0x50, 0x4B, 0x03, 0x04]] {
|
|
if let Some(pos) = z.windows(4).position(|w| w == sig) {
|
|
let off = if sig[2] == 1 { pos + 24 } else { pos + 22 };
|
|
z[off..off + 4].copy_from_slice(&0xFFFF_FFF0u32.to_le_bytes());
|
|
patched += 1;
|
|
}
|
|
}
|
|
assert_eq!(patched, 2);
|
|
match Package::open(&z) {
|
|
Err(_) => {}
|
|
Ok(p) => assert!(p.get("a.xml").is_none_or(|b| b.len() < 1024)),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_document_shares_its_pictures_with_the_package() {
|
|
// Each picture was copied out of the package, so a picture document held its pictures
|
|
// twice while it opened, and left the package's copies behind as freed memory.
|
|
let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture""#;
|
|
let document = format!(
|
|
r#"<w:document {ns}><w:body><w:p><w:r><w:drawing><wp:inline><wp:extent cx="635000" cy="635000"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="rIdImg"/></pic:blipFill></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p></w:body></w:document>"#
|
|
);
|
|
let rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdImg" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/pic.png"/></Relationships>"#;
|
|
let z = zip_of(&[
|
|
("word/document.xml", document.into_bytes()),
|
|
("word/_rels/document.xml.rels", rels.as_bytes().to_vec()),
|
|
("word/media/pic.png", b"\x89PNG picture bytes".to_vec()),
|
|
]);
|
|
let pkg = Package::open(&z).unwrap();
|
|
let doc = crate::read::read_opened(&pkg).unwrap();
|
|
let held = doc.media.values().next().expect("the picture is read");
|
|
assert!(Arc::ptr_eq(held, pkg.get_shared("word/media/pic.png").unwrap()), "the same bytes, not a copy");
|
|
assert_eq!(pkg.get("/WORD/media/PIC.png"), Some(&b"\x89PNG picture bytes"[..]), "lookups are unchanged");
|
|
}
|
|
|
|
#[test]
|
|
fn resolves_targets() {
|
|
assert_eq!(resolve("word", "media/image1.png"), "word/media/image1.png");
|
|
assert_eq!(resolve("word", "../customXml/item1.xml"), "customXml/item1.xml");
|
|
assert_eq!(resolve("word", "/word/styles.xml"), "word/styles.xml");
|
|
assert_eq!(resolve("", "word/document.xml"), "word/document.xml");
|
|
assert_eq!(resolve("word", "../../../x"), "x");
|
|
}
|
|
|
|
#[test]
|
|
fn relative_targets_resolve_back() {
|
|
for (from, to, want) in [
|
|
("word/document.xml", "word/charts/chart1.xml", "charts/chart1.xml"),
|
|
("word/charts/chart1.xml", "word/embeddings/book.xlsx", "../embeddings/book.xlsx"),
|
|
("word/charts/chart1.xml", "word/charts/colors1.xml", "colors1.xml"),
|
|
("word/document.xml", "customXml/item1.xml", "../customXml/item1.xml"),
|
|
("word/document.xml", "word/a:b.bin", "./a:b.bin"),
|
|
] {
|
|
let r = relative(from, to);
|
|
assert_eq!(r, want);
|
|
assert_eq!(resolve(from.rsplit_once('/').map_or("", |(d, _)| d), &r), to);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn embedded_manifest_round_trips_and_ignores_junk() {
|
|
let mut m = EmbeddedManifest::default();
|
|
let rel = Rel { id: "rId1".into(), kind: "k".into(), target: "word/embeddings/x.xlsx".into(), external: false };
|
|
let url = Rel { id: "rId2".into(), kind: "k".into(), target: "https://example.com/x".into(), external: true };
|
|
m.parts.insert("word/charts/chart1.xml".into(), EmbeddedPart { content_type: "ct".into(), rels: vec![rel, url] });
|
|
let back = EmbeddedManifest::parse(&m.to_bytes());
|
|
let p = &back.parts["word/charts/chart1.xml"];
|
|
assert_eq!((p.content_type.as_str(), p.rels.len(), p.rels[1].external), ("ct", 2, true));
|
|
let junk = EmbeddedManifest::parse(b"R\tnope\trId1\tk\tt\tI\nP\tonly-two\n\t\t\n\xff\xfe");
|
|
assert!(junk.parts.is_empty());
|
|
}
|
|
}
|