mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
Add craft-opc: the OPC package layer of DOCX, XLSX and PPTX files (#883)
One crate for what WordCraft's docx::package, GridCraft's xlsx::package and DeckCraft's pptx::opc each carried: bounded ZIP readers (every part up front, or on demand), part names, relationships, content types and a package writer. The declared entry count is checked from the end records before the zip crate indexes the entries. Limits have no defaults: each format passes its own.
This commit is contained in:
1 parent
ccd0046430
commit
f46dc936a5
12 files changed
+1760
No files matched your search
Generated
+11
@@ -1106,6 +1106,17 @@ dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-opc"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"craft-xml",
|
||||
"flate2",
|
||||
"log",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-pcraft-format"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -52,6 +52,7 @@ craft-mime = { path = "crates/mime" }
|
||||
craft-lottie = { path = "crates/lottie" }
|
||||
craft-menu = { path = "crates/menu" }
|
||||
craft-metafile = { path = "crates/metafile" }
|
||||
craft-opc = { path = "crates/opc" }
|
||||
craft-pcraft-format = { path = "crates/pcraft-format" }
|
||||
craft-prepress = { path = "crates/prepress" }
|
||||
craft-print = { path = "crates/print" }
|
||||
|
||||
@@ -136,6 +136,9 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
header fields, MIME parts and multiparts, encoded-words, RFC 2231 parameters, base64 and
|
||||
quoted-printable, character sets, address lists, dates, mbox files and the `multipart/related`
|
||||
lookups of MHT web archives. What a message means (body, attachments, flags) stays in each app.
|
||||
- [`craft-opc`](crates/opc/README.md): Open Packaging Conventions, the ZIP container of DOCX, XLSX and PPTX
|
||||
files: bounded readers (every part up front, or on demand), part names, relationships, content
|
||||
types and a deterministic package writer. What the parts hold stays with each format's crate.
|
||||
- [`craft-pcraft-format`](crates/pcraft-format/README.md): the envelope of PhotoCraft's `.pcraft`
|
||||
bundle: `Manifest<D>` with the document as a type parameter, `TileRef`, the entry paths, the
|
||||
manifest decode limits and the migration runner. The document model stays in PhotoCraft.
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "craft-opc"
|
||||
description = "Open Packaging Conventions (ECMA-376 Part 2): the ZIP container of DOCX, XLSX and PPTX files, with bounded reads, part names, relationships and content types, and a package writer."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
# `zip64_entry_count_sample`, the regression input for a reader's own entry-count test.
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
craft-xml = { workspace = true }
|
||||
log = "0.4"
|
||||
thiserror = { workspace = true }
|
||||
# `deflate-flate2` with flate2's miniz_oxide backend (chosen below), as in craft-update: zip's own
|
||||
# `deflate` feature would switch a whole app's flate2 to zlib-rs.
|
||||
zip = { version = "9", default-features = false, features = ["deflate-flate2"] }
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
|
||||
[dev-dependencies]
|
||||
zip = { version = "9", default-features = false, features = ["deflate-flate2"] }
|
||||
@@ -0,0 +1,93 @@
|
||||
# craft-opc
|
||||
|
||||
Open Packaging Conventions (ECMA-376 Part 2): the ZIP container that DOCX, XLSX and PPTX files
|
||||
share. It replaces the package layer each Craft app carried for its own format (WordCraft's
|
||||
`docx::package`, GridCraft's `xlsx::package`, DeckCraft's `pptx::opc`).
|
||||
|
||||
What the parts hold stays with each format's crate. Parts are read with `zip` and XML with
|
||||
`craft-xml`.
|
||||
|
||||
```rust
|
||||
use craft_opc::{Limits, Package, PackageOut, RelsOut};
|
||||
|
||||
let mut out = PackageOut::default();
|
||||
out.xml_part("xl/workbook.xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml", b"<workbook/>".to_vec());
|
||||
out.binary("xl/media/image1.png", "image/png", vec![0x89, b'P', b'N', b'G']);
|
||||
let mut rels = RelsOut::default();
|
||||
rels.add("officeDocument", "xl/workbook.xml", false);
|
||||
out.rels("", &rels);
|
||||
let bytes = out.finish().unwrap();
|
||||
|
||||
let limits = Limits::new(10_000, 64 << 20, 256 << 20);
|
||||
let pkg = Package::open(&bytes, &limits).unwrap();
|
||||
let main = pkg.rels("").first("officeDocument").map(|r| r.target.clone()).unwrap();
|
||||
assert_eq!(main, "xl/workbook.xml");
|
||||
assert_eq!(pkg.content_types().of("xl/media/image1.png"), Some("image/png"));
|
||||
assert!(pkg.get("XL/Workbook.xml").is_some());
|
||||
```
|
||||
|
||||
## Reading
|
||||
|
||||
Two readers, because the apps read in two ways:
|
||||
|
||||
| | `Package` | `LazyPackage` |
|
||||
| --- | --- | --- |
|
||||
| Inflates | every part, in `open` | a part when `read` asks for it |
|
||||
| Borrows the file's bytes | no | yes |
|
||||
| A part over a limit | refuses the package, or is left out (`Limits::oversized`) | is that read's error |
|
||||
| A damaged entry | left out with a logged warning | is that read's error, and ends the budget (`read_optional_limited`: costs only its own allowance) |
|
||||
| Came from | WordCraft (refuse), DeckCraft (leave out) | GridCraft |
|
||||
|
||||
Both look parts up without regard to case, leading `/` or `\` for `/`.
|
||||
|
||||
### Limits
|
||||
|
||||
`Limits` has no defaults: each format keeps its own numbers.
|
||||
|
||||
| Limit | Checked | Error |
|
||||
| --- | --- | --- |
|
||||
| `max_entries` | from the end records, before `zip` indexes the entries (`check_entry_count`), and again on the count `zip` found | `TooManyEntries`, `TooManyEndRecords` |
|
||||
| `max_part` | on the declared size, then on what is inflated (a read stops one byte past it) | `PartTooLarge` |
|
||||
| `max_total` | on the bytes kept | `PackageTooLarge` |
|
||||
| `max_work` | on the bytes inflated, kept or not: an entry inflates in full before its checksum is compared, so entries that fail it would otherwise be free | `PackageTooLarge` |
|
||||
|
||||
`Limits::new` sets `max_work` to `max_total` and refuses oversized parts. At most 16 MiB is
|
||||
reserved for a part before its bytes arrive, whatever size it declares.
|
||||
|
||||
`check_entry_count` is a bytes-only check of what the end of central directory records declare.
|
||||
It does not validate the archive; `zip` does.
|
||||
|
||||
### Names and relationships
|
||||
|
||||
- `Package::canonical(name)` is the stored spelling. When entries differ only in case, they are
|
||||
one part: the spelling a relationship names is kept, else the first in byte order.
|
||||
- `Rels`: `by_id`, `target`, `first(type)`, `all(type)`. A type is matched by its full URI or
|
||||
by its last segment, so `first("image")` finds the transitional and the strict spelling
|
||||
(`type_is`). The first relationship of a repeated id wins.
|
||||
- `Rel::target` is a part name for an internal target and the URL for an external one;
|
||||
`target_raw` is what the file wrote. A target is looked up as written, then with `%hh`
|
||||
escapes decoded; when the package has the part, `target` is its stored spelling.
|
||||
- `resolve(dir, target)` drops a fragment and cannot climb out of the package. `relative(from, to)`
|
||||
is its inverse.
|
||||
- `ContentTypes::of(part)`: Override by part name, then Default by extension.
|
||||
|
||||
A missing or unreadable relationships or content types part reads as empty, with a logged
|
||||
warning for an unreadable one.
|
||||
|
||||
## Writing
|
||||
|
||||
- `PackageOut`: `xml_part` (with an Override), `binary` (a Default for its extension, or an
|
||||
Override when the extension already has another type), `raw`, `rels`; `finish` writes
|
||||
`[Content_Types].xml` first and leaves out a second part under a name already written.
|
||||
- `RelsOut`: `add` reuses the relationship already made for a type, target and mode; `add_new`
|
||||
never does. Ids are `rId1`, `rId2`…
|
||||
- `ZipOut` and `zip_entries` for a writer that builds its own content types. Output is
|
||||
deterministic: deflate level 6, one fixed timestamp. Names with an already-compressed
|
||||
extension are stored (`compression_for`).
|
||||
|
||||
## Not here
|
||||
|
||||
- Flat OPC (`pkg:package` XML) and encrypted packages (a compound file holding
|
||||
`EncryptedPackage`): still with the apps.
|
||||
- A full ZIP preflight (overlapping entries, lying offsets): `check_entry_count` covers the
|
||||
entry count only.
|
||||
@@ -0,0 +1,192 @@
|
||||
//! A bytes-only check of a ZIP package's declared entry count before the `zip` crate opens it.
|
||||
//!
|
||||
//! `zip::ZipArchive::new` reserves room for, and parses, as many central directory headers as the
|
||||
//! end record declares before its caller can look at `len()`. A ZIP64 end record can declare
|
||||
//! millions of entries for a file of a few hundred megabytes (only 46 bytes each have to fit), and
|
||||
//! each parsed entry costs several times that in memory. Here the end records are found the way
|
||||
//! `zip` finds them (APPNOTE.TXT §4.3.14 to §4.3.16), and a package declaring more than the
|
||||
//! reader's limit is refused before `zip` parses anything. Anything else that is wrong is left for
|
||||
//! `zip` to report.
|
||||
|
||||
use crate::Error;
|
||||
|
||||
const EOCD: &[u8; 4] = b"PK\x05\x06";
|
||||
const EOCD64_LOCATOR: &[u8; 4] = b"PK\x06\x07";
|
||||
const EOCD64: &[u8; 4] = b"PK\x06\x06";
|
||||
const CENTRAL_HEADER: &[u8; 4] = b"PK\x01\x02";
|
||||
|
||||
/// End records looked at, at most. A package has one; a few more can turn up inside stored data.
|
||||
const MAX_END_RECORDS: usize = 16;
|
||||
|
||||
/// Refuse `bytes` when the end record `zip` would use declares more than `max_entries` entries.
|
||||
///
|
||||
/// `zip` searches the file backwards for an end of central directory record. A ZIP64 one points
|
||||
/// through its locator at the ZIP64 end record, which `zip` looks for from the locator's offset
|
||||
/// on: every such record there is checked, and as `zip` may fall back to an earlier end record
|
||||
/// when one doesn't parse, the search goes on. A plain one is used when it declares no entries, or
|
||||
/// a central directory header lies between its directory offset and itself: it is the last one
|
||||
/// looked at.
|
||||
pub fn check_entry_count(bytes: &[u8], max_entries: u64) -> Result<(), Error> {
|
||||
let u16_at = |at: usize| bytes.get(at..at.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]]));
|
||||
let u32_at = |at: usize| bytes.get(at..at.checked_add(4)?).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]));
|
||||
let u64_at = |at: usize| bytes.get(at..at.checked_add(8)?).and_then(|b| <[u8; 8]>::try_from(b).ok()).map(u64::from_le_bytes);
|
||||
let refuse =
|
||||
|n: u64| -> Result<(), Error> { if n > max_entries { Err(Error::TooManyEntries { declared: n, limit: max_entries }) } else { Ok(()) } };
|
||||
let mut end = bytes.len();
|
||||
let mut seen = 0;
|
||||
while let Some(at) = bytes.get(..end).and_then(|b| b.windows(4).rposition(|w| w == EOCD)) {
|
||||
end = at;
|
||||
seen += 1;
|
||||
if seen > MAX_END_RECORDS {
|
||||
return Err(Error::TooManyEndRecords(MAX_END_RECORDS));
|
||||
}
|
||||
// `zip` skips a record whose comment runs past the end of the file.
|
||||
let Some(comment) = u16_at(at + 20) else { continue };
|
||||
if at + 22 + usize::from(comment) > bytes.len() {
|
||||
continue;
|
||||
}
|
||||
let (Some(on_disk), Some(total), Some(cd_offset)) = (u16_at(at + 8), u16_at(at + 10), u32_at(at + 16)) else { continue };
|
||||
let zip64 = total == u16::MAX || cd_offset == u32::MAX;
|
||||
if zip64
|
||||
&& let Some(locator) = at.checked_sub(20)
|
||||
&& bytes.get(locator..locator + 4) == Some(EOCD64_LOCATOR.as_slice())
|
||||
{
|
||||
let (Some(eocd64), Some(disks)) = (u64_at(locator + 8), u32_at(locator + 16)) else { continue };
|
||||
let Some(from) = usize::try_from(eocd64).ok().filter(|&f| f < locator) else { continue };
|
||||
if disks > 1 {
|
||||
continue;
|
||||
}
|
||||
let records = bytes.get(from..locator).unwrap_or_default().windows(4).enumerate().filter(|(_, w)| *w == EOCD64);
|
||||
for (i, _) in records {
|
||||
let p = from + i;
|
||||
refuse(u64_at(p + 24).unwrap_or(0).max(u64_at(p + 32).unwrap_or(0)))?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if total == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let cd = cd_offset as usize;
|
||||
if cd < at && bytes.get(cd..at).is_some_and(|b| b.windows(4).any(|w| w == CENTRAL_HEADER)) {
|
||||
return refuse(u64::from(on_disk.max(total)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A package of one empty stored file, `a`, whose central directory lists it `entries` times (at
|
||||
/// most as many as fit in 64 MiB) under a ZIP64 end record that declares them all: `zip`
|
||||
/// parses and keeps every one of them before its caller can count them. Regression input for the
|
||||
/// readers' tests: built only for tests, this crate's and (through the `test-support` feature,
|
||||
/// which they turn on as a dev-dependency) the readers', never into an app.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn zip64_entry_count_sample(entries: u64) -> Vec<u8> {
|
||||
// Local file header (APPNOTE.TXT §4.3.7): version 10, stored, no data, name "a".
|
||||
let mut f = b"PK\x03\x04".to_vec();
|
||||
f.extend_from_slice(&[10, 0, 0, 0, 0, 0, 0, 0, 0x21, 0]); // version, flags, method, time, date
|
||||
f.extend_from_slice(&[0; 12]); // crc, sizes
|
||||
f.extend_from_slice(&[1, 0, 0, 0]); // name and extra lengths
|
||||
f.push(b'a');
|
||||
let cd_at = f.len() as u64;
|
||||
// Its central header (§4.3.12), 47 bytes.
|
||||
let mut header = CENTRAL_HEADER.to_vec();
|
||||
header.extend_from_slice(&[10, 0, 10, 0, 0, 0, 0, 0, 0, 0, 0x21, 0]); // versions, flags, method, time, date
|
||||
header.extend_from_slice(&[0; 12]); // crc, sizes
|
||||
header.extend_from_slice(&[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); // name, extra, comment, disk, attributes
|
||||
header.extend_from_slice(&[0; 4]); // local header offset
|
||||
header.push(b'a');
|
||||
let count = entries.min((64 << 20) / header.len() as u64);
|
||||
for _ in 0..count {
|
||||
f.extend_from_slice(&header);
|
||||
}
|
||||
let cd_len = f.len() as u64 - cd_at;
|
||||
let eocd64_at = f.len() as u64;
|
||||
// ZIP64 end of central directory record (§4.3.14), 56 bytes.
|
||||
f.extend_from_slice(EOCD64);
|
||||
f.extend_from_slice(&44u64.to_le_bytes()); // size of the rest of the record
|
||||
f.extend_from_slice(&[45, 0, 45, 0]); // versions
|
||||
f.extend_from_slice(&[0; 8]); // this disk, disk with the central directory
|
||||
f.extend_from_slice(&entries.to_le_bytes()); // entries on this disk
|
||||
f.extend_from_slice(&entries.to_le_bytes()); // entries
|
||||
f.extend_from_slice(&cd_len.to_le_bytes());
|
||||
f.extend_from_slice(&cd_at.to_le_bytes());
|
||||
// Its locator (§4.3.15), 20 bytes.
|
||||
f.extend_from_slice(EOCD64_LOCATOR);
|
||||
f.extend_from_slice(&0u32.to_le_bytes());
|
||||
f.extend_from_slice(&eocd64_at.to_le_bytes());
|
||||
f.extend_from_slice(&1u32.to_le_bytes());
|
||||
// End of central directory record (§4.3.16), 22 bytes, each count and offset "see ZIP64".
|
||||
f.extend_from_slice(EOCD);
|
||||
f.extend_from_slice(&[0; 4]);
|
||||
f.extend_from_slice(&[0xFF; 12]);
|
||||
f.extend_from_slice(&[0; 2]);
|
||||
f
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{CENTRAL_HEADER, EOCD, check_entry_count as check, zip64_entry_count_sample};
|
||||
use crate::Error;
|
||||
|
||||
/// A plain package's tail: one central header at 0, then an end record declaring `entries`.
|
||||
fn plain(entries: u16, comment: &[u8]) -> Vec<u8> {
|
||||
let mut f = CENTRAL_HEADER.to_vec();
|
||||
f.resize(46, 0);
|
||||
f.extend_from_slice(EOCD);
|
||||
f.extend_from_slice(&[0, 0, 0, 0]);
|
||||
f.extend_from_slice(&entries.to_le_bytes());
|
||||
f.extend_from_slice(&entries.to_le_bytes());
|
||||
f.extend_from_slice(&46u32.to_le_bytes());
|
||||
f.extend_from_slice(&0u32.to_le_bytes());
|
||||
f.extend_from_slice(&(comment.len() as u16).to_le_bytes());
|
||||
f.extend_from_slice(comment);
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zip64_end_record_declaring_too_many_entries_is_refused() {
|
||||
// `zip` would reserve and parse them all before the reader's cap.
|
||||
let sample = zip64_entry_count_sample(20_001);
|
||||
assert_eq!(check(&sample, 20_000), Err(Error::TooManyEntries { declared: 20_001, limit: 20_000 }));
|
||||
assert!(check(&zip64_entry_count_sample(5), 20_000).is_ok());
|
||||
// Junk before the package (a self-extracting stub) doesn't hide it.
|
||||
let mut prefixed = vec![0x55u8; 4096];
|
||||
prefixed.extend_from_slice(&sample);
|
||||
assert!(check(&prefixed, 20_000).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plain_end_record_is_checked_as_zip_reads_it() {
|
||||
assert!(check(&plain(3, b""), 20_000).is_ok());
|
||||
assert_eq!(check(&plain(30_000, b"a comment"), 20_000), Err(Error::TooManyEntries { declared: 30_000, limit: 20_000 }));
|
||||
// A record whose comment runs past the end is not the one `zip` uses.
|
||||
let mut cut = plain(30_000, b"");
|
||||
cut.truncate(cut.len() - 2);
|
||||
cut.extend_from_slice(&500u16.to_le_bytes());
|
||||
assert!(check(&cut, 20_000).is_ok());
|
||||
assert!(check(b"not a package", 20_000).is_ok());
|
||||
assert!(check(b"", 20_000).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fake_end_record_after_the_real_one_does_not_hide_it() {
|
||||
// `zip` skips a last record with no central header before it and uses the one before.
|
||||
let mut f = zip64_entry_count_sample(1_000_000);
|
||||
f.extend_from_slice(EOCD);
|
||||
f.extend_from_slice(&[0, 0, 0, 0]);
|
||||
f.extend_from_slice(&7u16.to_le_bytes());
|
||||
f.extend_from_slice(&7u16.to_le_bytes());
|
||||
f.extend_from_slice(&0u32.to_le_bytes());
|
||||
f.extend_from_slice(&((f.len() - 12) as u32).to_le_bytes());
|
||||
f.extend_from_slice(&0u16.to_le_bytes());
|
||||
assert!(check(&f, 20_000).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_end_records_are_refused() {
|
||||
// Each declares one entry and has no central header before it, so `zip` would try them all.
|
||||
let record = [0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
let f: Vec<u8> = (0..20).flat_map(|_| EOCD.iter().copied().chain(record)).collect();
|
||||
assert_eq!(check(&f, 20_000), Err(Error::TooManyEndRecords(16)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
//! Open Packaging Conventions (ECMA-376 Part 2): the ZIP container that DOCX, XLSX and PPTX
|
||||
//! files share.
|
||||
//!
|
||||
//! - [`Package`] inflates every part up front, within [`Limits`]. [`LazyPackage`] indexes the
|
||||
//! archive and inflates a part when it is asked for, against a running total.
|
||||
//! - [`Rels`] and [`ContentTypes`] read `_rels/*.rels` and `[Content_Types].xml`.
|
||||
//! - [`resolve`], [`relative`], [`rels_path`] and the other part-name functions.
|
||||
//! - [`PackageOut`], [`RelsOut`] and [`ZipOut`] write a package.
|
||||
//! - [`check_entry_count`] refuses an archive that declares too many entries before the `zip`
|
||||
//! crate indexes them. Both readers call it.
|
||||
//!
|
||||
//! The limits have no defaults: each format keeps its own numbers.
|
||||
//!
|
||||
//! ```
|
||||
//! use craft_opc::{Limits, Package, PackageOut, RelsOut};
|
||||
//!
|
||||
//! let mut out = PackageOut::default();
|
||||
//! out.xml_part("word/document.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml", b"<w:document/>".to_vec());
|
||||
//! let mut rels = RelsOut::default();
|
||||
//! rels.add("officeDocument", "word/document.xml", false);
|
||||
//! out.rels("", &rels);
|
||||
//! let bytes = out.finish()?;
|
||||
//!
|
||||
//! let pkg = Package::open(&bytes, &Limits::new(1000, 1 << 20, 1 << 22))?;
|
||||
//! let main = pkg.rels("").first("officeDocument").map(|r| r.target.clone());
|
||||
//! assert_eq!(main.as_deref(), Some("word/document.xml"));
|
||||
//! assert!(pkg.content_types().of("word/document.xml").is_some_and(|t| t.ends_with("main+xml")));
|
||||
//! # Ok::<(), craft_opc::Error>(())
|
||||
//! ```
|
||||
|
||||
#[cfg(doctest)]
|
||||
#[doc = include_str!("../README.md")]
|
||||
struct ReadmeExamples;
|
||||
|
||||
mod guard;
|
||||
mod name;
|
||||
mod read;
|
||||
mod rels;
|
||||
mod write;
|
||||
|
||||
pub use guard::check_entry_count;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub use guard::zip64_entry_count_sample;
|
||||
pub use name::{dir_of, file_of, normalize, percent_decode, relative, rels_path, resolve, split_dir};
|
||||
pub use read::{LazyPackage, Package, declared_oversized};
|
||||
pub use rels::{ContentTypes, Rel, Rels, type_is};
|
||||
pub use write::{Compression, PackageOut, RelOut, RelsOut, ZipOut, compression_for, zip_entries};
|
||||
|
||||
/// The relationship type namespace of ECMA-376 transitional packages: a type is this and a
|
||||
/// last segment (`image`, `styles`…).
|
||||
pub const RT_BASE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/";
|
||||
/// The namespace of a relationships part.
|
||||
pub const NS_RELATIONSHIPS: &str = "http://schemas.openxmlformats.org/package/2006/relationships";
|
||||
/// The namespace of `[Content_Types].xml`.
|
||||
pub const NS_CONTENT_TYPES: &str = "http://schemas.openxmlformats.org/package/2006/content-types";
|
||||
/// The content type of a relationships part.
|
||||
pub const CT_RELATIONSHIPS: &str = "application/vnd.openxmlformats-package.relationships+xml";
|
||||
|
||||
/// What a reader refuses to exceed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Limits {
|
||||
/// Most ZIP entries. Checked before the `zip` crate indexes them.
|
||||
pub max_entries: usize,
|
||||
/// Most bytes one part inflates to.
|
||||
pub max_part: u64,
|
||||
/// Most bytes kept from the whole package.
|
||||
pub max_total: u64,
|
||||
/// Most bytes inflated for the whole package, counting parts that are then left out (too
|
||||
/// large, or failing part-way). Never less than useful below `max_total`.
|
||||
pub max_work: u64,
|
||||
/// What [`Package::open`] does with a part over a limit.
|
||||
pub oversized: Oversized,
|
||||
}
|
||||
|
||||
/// What [`Package::open`] does with a part over a limit.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Oversized {
|
||||
/// The package is not opened.
|
||||
Refuse,
|
||||
/// The part is left out with a logged warning; reading stops when `max_work` is spent.
|
||||
Skip,
|
||||
}
|
||||
|
||||
impl Limits {
|
||||
/// `max_work` is `max_total` (every inflated byte counts, kept or not) and a part over a
|
||||
/// limit refuses the package.
|
||||
pub const fn new(max_entries: usize, max_part: u64, max_total: u64) -> Limits {
|
||||
Limits { max_entries, max_part, max_total, max_work: max_total, oversized: Oversized::Refuse }
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a package was not read or written.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum Error {
|
||||
/// The bytes are not a ZIP archive the `zip` crate can read, or an entry is damaged.
|
||||
#[error("zip: {0}")]
|
||||
Zip(String),
|
||||
/// The archive declares more entries than [`Limits::max_entries`].
|
||||
#[error("the package declares {declared} entries, more than {limit}")]
|
||||
TooManyEntries { declared: u64, limit: u64 },
|
||||
/// More end of central directory records than a package can have.
|
||||
#[error("more than {0} end of central directory records")]
|
||||
TooManyEndRecords(usize),
|
||||
/// A part declares or inflates to more than `limit` bytes.
|
||||
#[error("part {part} is larger than {limit} bytes")]
|
||||
PartTooLarge { part: String, limit: u64 },
|
||||
/// The parts together inflate to more than `limit` bytes.
|
||||
#[error("the package expands to more than {limit} bytes")]
|
||||
PackageTooLarge { limit: u64 },
|
||||
/// An XML part could not be read.
|
||||
#[error("{part}: {error}")]
|
||||
Xml { part: String, error: craft_xml::Error },
|
||||
/// The archive could not be written.
|
||||
#[error("could not write the package: {0}")]
|
||||
Write(String),
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Was a limit exceeded (as opposed to the archive being damaged)?
|
||||
pub fn is_limit(&self) -> bool {
|
||||
match self {
|
||||
Error::TooManyEntries { .. } | Error::TooManyEndRecords(_) | Error::PartTooLarge { .. } | Error::PackageTooLarge { .. } => true,
|
||||
Error::Xml { error, .. } => error.is_limit(),
|
||||
Error::Zip(_) | Error::Write(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Part names: `word/document.xml`, without the leading `/`.
|
||||
|
||||
/// The key a part name is looked up by: `\` as `/`, no leading `/`, ASCII lower case (part
|
||||
/// names are case-insensitive, ECMA-376 Part 2 §6.2.2.3).
|
||||
pub fn normalize(name: &str) -> String {
|
||||
name.replace('\\', "/").trim_start_matches('/').to_ascii_lowercase()
|
||||
}
|
||||
|
||||
/// `xl/worksheets/sheet1.xml` → (`xl/worksheets`, `sheet1.xml`).
|
||||
pub 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("")),
|
||||
None => ("", p),
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder of a part: `ppt/slides/slide1.xml` → `ppt/slides`.
|
||||
pub fn dir_of(part: &str) -> &str {
|
||||
split_dir(part).0
|
||||
}
|
||||
|
||||
/// The last segment of a part name.
|
||||
pub fn file_of(part: &str) -> &str {
|
||||
part.rsplit('/').next().unwrap_or(part)
|
||||
}
|
||||
|
||||
/// The relationships part of `part`: `xl/workbook.xml` → `xl/_rels/workbook.xml.rels`. The
|
||||
/// package's own relationships are those of `""`: `_rels/.rels`.
|
||||
pub fn rels_path(part: &str) -> String {
|
||||
let (dir, file) = split_dir(part);
|
||||
if dir.is_empty() { format!("_rels/{file}.rels") } else { format!("{dir}/_rels/{file}.rels") }
|
||||
}
|
||||
|
||||
/// `%hh` escapes decoded; a `%` not followed by two hex digits stays.
|
||||
pub fn percent_decode(s: &str) -> String {
|
||||
let b = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(b.len());
|
||||
let mut i = 0;
|
||||
while let Some(&c) = b.get(i) {
|
||||
if c == b'%'
|
||||
&& let Some(h) = b
|
||||
.get(i + 1..i + 3)
|
||||
.filter(|h| h.iter().all(u8::is_ascii_hexdigit))
|
||||
.and_then(|h| std::str::from_utf8(h).ok())
|
||||
.and_then(|h| u8::from_str_radix(h, 16).ok())
|
||||
{
|
||||
out.push(h);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
/// Resolves a relationship target against the source part's folder into a part name. A
|
||||
/// fragment is dropped, `\` reads as `/`, and `..` cannot climb out of the package. `%hh`
|
||||
/// escapes are kept as written: [`Rels`](crate::Rels) decodes them when no part has the
|
||||
/// escaped name.
|
||||
pub fn resolve(dir: &str, target: &str) -> String {
|
||||
let target = target.split('#').next().unwrap_or("").replace('\\', "/");
|
||||
let mut segs: Vec<&str> = if target.starts_with('/') { Vec::new() } else { dir.split('/').filter(|s| !s.is_empty()).collect() };
|
||||
for seg in target.split('/') {
|
||||
match seg {
|
||||
"" | "." => {}
|
||||
".." => {
|
||||
segs.pop();
|
||||
}
|
||||
s => segs.push(s),
|
||||
}
|
||||
}
|
||||
segs.join("/")
|
||||
}
|
||||
|
||||
/// The relative reference from part `from` to part `to`, 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> = dir_of(from).split('/').filter(|s| !s.is_empty()).collect();
|
||||
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().saturating_sub(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 }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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");
|
||||
assert_eq!(resolve("xl/worksheets", "../drawings/d1.xml"), "xl/drawings/d1.xml");
|
||||
assert_eq!(resolve("ppt/slides", "../media/a.png#frag"), "ppt/media/a.png");
|
||||
assert_eq!(resolve("ppt/slides", "..\\media\\image1.png"), "ppt/media/image1.png");
|
||||
assert_eq!(resolve("ppt/slides", "\\ppt\\media\\a.png"), "ppt/media/a.png");
|
||||
// An escaped `#` is part of the name, not a fragment.
|
||||
assert_eq!(percent_decode(&resolve("ppt/slides", "../media/a%23b.png")), "ppt/media/a#b.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percent_decoding_requires_two_hex_digits() {
|
||||
assert_eq!(percent_decode("a%+5b.png"), "a%+5b.png");
|
||||
assert_eq!(percent_decode("a%20b.png"), "a b.png");
|
||||
assert_eq!(percent_decode("100%"), "100%");
|
||||
assert_eq!(percent_decode("%e2%82%ac"), "€");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_and_names_relationship_parts() {
|
||||
assert_eq!(split_dir("xl/workbook.xml"), ("xl", "workbook.xml"));
|
||||
assert_eq!(split_dir("x.xml"), ("", "x.xml"));
|
||||
assert_eq!(split_dir("/word/document.xml"), ("word", "document.xml"));
|
||||
assert_eq!(rels_path("ppt/presentation.xml"), "ppt/_rels/presentation.xml.rels");
|
||||
assert_eq!(rels_path("[Content_Types].xml"), "_rels/[Content_Types].xml.rels");
|
||||
assert_eq!(rels_path(""), "_rels/.rels");
|
||||
assert_eq!(normalize("\\PPT\\Slides/Slide1.xml"), "ppt/slides/slide1.xml");
|
||||
}
|
||||
|
||||
#[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"),
|
||||
("ppt/slides/slide1.xml", "ppt/media/image1.png", "../media/image1.png"),
|
||||
("ppt/presentation.xml", "ppt/slides/slide1.xml", "slides/slide1.xml"),
|
||||
("", "word/document.xml", "word/document.xml"),
|
||||
] {
|
||||
let r = relative(from, to);
|
||||
assert_eq!(r, want);
|
||||
assert_eq!(resolve(dir_of(from), &r), to);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
//! Reading a package: every part up front ([`Package`]) or on demand ([`LazyPackage`]).
|
||||
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::io::{Cursor, Read};
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_xml::{El, Options};
|
||||
|
||||
use crate::guard::check_entry_count;
|
||||
use crate::name::{normalize, rels_path};
|
||||
use crate::rels::{ContentTypes, Rels, targets_of};
|
||||
use crate::{Error, Limits, Oversized};
|
||||
|
||||
/// Most bytes reserved for a part before any of it has been read, whatever size it declares.
|
||||
const MAX_PREALLOC: u64 = 16 * 1024 * 1024;
|
||||
|
||||
type Zip<'a> = zip::ZipArchive<Cursor<&'a [u8]>>;
|
||||
|
||||
fn open_zip<'a>(bytes: &'a [u8], limits: &Limits) -> Result<Zip<'a>, Error> {
|
||||
// Before `zip` parses every entry the end record declares.
|
||||
check_entry_count(bytes, limits.max_entries as u64)?;
|
||||
let zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| Error::Zip(e.to_string()))?;
|
||||
if zip.len() > limits.max_entries {
|
||||
return Err(Error::TooManyEntries { declared: zip.len() as u64, limit: limits.max_entries as u64 });
|
||||
}
|
||||
Ok(zip)
|
||||
}
|
||||
|
||||
/// A part name as stored: `\` as `/` (code that splits paths on `/` must not meet a `\`), no
|
||||
/// leading `/`.
|
||||
fn stored_name(name: &str) -> String {
|
||||
name.replace('\\', "/").trim_start_matches('/').to_string()
|
||||
}
|
||||
|
||||
/// The inflated parts of a package, within [`Limits`]. Shared, so a part the caller keeps as it
|
||||
/// is (a picture, an embedded object) is handed over rather than copied.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Package {
|
||||
files: BTreeMap<String, Arc<Vec<u8>>>,
|
||||
/// Lowercased part name → its stored spelling: part names are case-insensitive (ECMA-376
|
||||
/// Part 2 §6.2.2.3), so every spelling is one part.
|
||||
lower: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Package {
|
||||
/// Inflates every part.
|
||||
///
|
||||
/// An entry the archive cannot produce (a failed checksum, an unknown method) is left out
|
||||
/// with a logged warning, and what it inflated is charged to [`Limits::max_work`]: an entry
|
||||
/// inflates in full before its checksum is compared, so failed entries would otherwise
|
||||
/// inflate without limit. A part over a limit refuses the package or is left out
|
||||
/// ([`Limits::oversized`]).
|
||||
pub fn open(bytes: &[u8], limits: &Limits) -> Result<Package, Error> {
|
||||
let mut zip = open_zip(bytes, limits)?;
|
||||
let skip = limits.oversized == Oversized::Skip;
|
||||
let mut files = BTreeMap::new();
|
||||
let (mut total, mut work) = (0u64, 0u64);
|
||||
for i in 0..zip.len() {
|
||||
if skip && work >= limits.max_work {
|
||||
log::warn!("opc: decompression budget spent; skipping the remaining {} entries", zip.len().saturating_sub(i));
|
||||
break;
|
||||
}
|
||||
let mut f = match zip.by_index(i) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
log::warn!("opc: skipping unreadable zip entry {i}: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if f.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let name = match f.name() {
|
||||
Ok(name) => stored_name(&name),
|
||||
Err(e) => {
|
||||
log::warn!("opc: skipping zip entry {i} with an unreadable name: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if f.size() > limits.max_part {
|
||||
if skip {
|
||||
log::warn!("opc: skipping oversized part {name} ({} bytes)", f.size());
|
||||
continue;
|
||||
}
|
||||
return Err(Error::PartTooLarge { part: name, limit: limits.max_part });
|
||||
}
|
||||
let left = limits.max_total.saturating_sub(total).min(limits.max_work.saturating_sub(work));
|
||||
let room = limits.max_part.min(left);
|
||||
let mut buf = Vec::with_capacity(f.size().min(MAX_PREALLOC) as usize);
|
||||
let read = (&mut f).take(room.saturating_add(1)).read_to_end(&mut buf);
|
||||
// Charged whether or not the part is kept.
|
||||
work = work.saturating_add(buf.len() as u64);
|
||||
if buf.len() as u64 > room {
|
||||
if skip {
|
||||
log::warn!("opc: part {name} exceeds size limits; skipped");
|
||||
continue;
|
||||
}
|
||||
return Err(if left < limits.max_part {
|
||||
Error::PackageTooLarge { limit: limits.max_total.min(limits.max_work) }
|
||||
} else {
|
||||
Error::PartTooLarge { part: name, limit: limits.max_part }
|
||||
});
|
||||
}
|
||||
if let Err(e) = read {
|
||||
log::warn!("opc: zip entry {name} unreadable: {e}");
|
||||
continue;
|
||||
}
|
||||
total = total.saturating_add(buf.len() as u64);
|
||||
files.insert(name, Arc::new(buf));
|
||||
}
|
||||
Ok(Package::new(files))
|
||||
}
|
||||
|
||||
/// A package from parts already in memory (a Flat OPC file, a converted document), by part
|
||||
/// name.
|
||||
pub fn from_parts(files: BTreeMap<String, Vec<u8>>) -> Package {
|
||||
Package::new(files.into_iter().map(|(name, bytes)| (stored_name(&name), Arc::new(bytes))).collect())
|
||||
}
|
||||
|
||||
fn new(mut files: BTreeMap<String, Arc<Vec<u8>>>) -> Package {
|
||||
let mut lower: HashMap<String, String> = HashMap::new();
|
||||
let mut collides = false;
|
||||
for name in files.keys() {
|
||||
match lower.entry(name.to_ascii_lowercase()) {
|
||||
Entry::Occupied(_) => collides = true,
|
||||
Entry::Vacant(v) => {
|
||||
v.insert(name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if !collides {
|
||||
return Package { files, lower };
|
||||
}
|
||||
// Entries whose names differ only in case are one part, so every spelling of the name
|
||||
// reaches the same stored entry. The spelling kept is the one a relationship names, so
|
||||
// the part the document points at is the one read; the first in byte order only when no
|
||||
// relationship names any of them.
|
||||
let mut named = HashSet::new();
|
||||
for (name, bytes) in &files {
|
||||
targets_of(name, bytes, |t| {
|
||||
named.insert(t);
|
||||
});
|
||||
}
|
||||
for name in files.keys().rev().filter(|n| named.contains(n.as_str())) {
|
||||
lower.insert(name.to_ascii_lowercase(), name.clone());
|
||||
}
|
||||
let before = files.len();
|
||||
files.retain(|name, _| lower.get(&name.to_ascii_lowercase()) == Some(name));
|
||||
let dropped = before.saturating_sub(files.len());
|
||||
log::warn!("opc: {dropped} zip entries are other spellings of parts already read; they are left out");
|
||||
Package { files, lower }
|
||||
}
|
||||
|
||||
/// The bytes of all its parts.
|
||||
pub fn size(&self) -> u64 {
|
||||
self.files.values().map(|f| f.len() as u64).sum()
|
||||
}
|
||||
|
||||
/// Every part name as stored, in byte order.
|
||||
pub fn names(&self) -> impl Iterator<Item = &str> {
|
||||
self.files.keys().map(String::as_str)
|
||||
}
|
||||
|
||||
/// The stored spelling of part `name` (leading `/` ignored; case-insensitive).
|
||||
pub fn canonical(&self, name: &str) -> Option<&str> {
|
||||
self.lower.get(&normalize(name)).map(String::as_str)
|
||||
}
|
||||
|
||||
/// Part bytes by name (leading `/` ignored; case-insensitive).
|
||||
pub fn get(&self, name: &str) -> Option<&[u8]> {
|
||||
self.get_shared(name).map(|v| v.as_slice())
|
||||
}
|
||||
|
||||
/// [`Package::get`], as the shared bytes themselves: a part the caller keeps whole then
|
||||
/// costs no copy.
|
||||
pub fn get_shared(&self, name: &str) -> Option<&Arc<Vec<u8>>> {
|
||||
self.canonical(name).and_then(|n| self.files.get(n))
|
||||
}
|
||||
|
||||
/// Part `name` as XML, read with `options` (which carry the caller's limits and its choice
|
||||
/// of names and strictness). `Ok(None)` when there is no such part.
|
||||
pub fn xml(&self, name: &str, options: &Options) -> Result<Option<El>, Error> {
|
||||
match self.get(name) {
|
||||
Some(b) => options.parse(b).map(Some).map_err(|error| Error::Xml { part: name.to_string(), error }),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The relationships of `part` (from `dir/_rels/name.rels`); those of the package itself
|
||||
/// for `""`. Internal targets name the part as stored, so a part reached under several
|
||||
/// spellings is one part. A missing or unreadable relationships part gives none.
|
||||
pub fn rels(&self, part: &str) -> Rels {
|
||||
match self.get(&rels_path(part)) {
|
||||
Some(bytes) => Rels::parse_in(bytes, part, |n| self.canonical(n).map(str::to_string)),
|
||||
None => Rels::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `[Content_Types].xml`.
|
||||
pub fn content_types(&self) -> ContentTypes {
|
||||
self.get("[Content_Types].xml").map(ContentTypes::parse).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A package whose parts are inflated when they are asked for, against a running total: for a
|
||||
/// reader that needs a few parts of a large file, or one part at a time.
|
||||
pub struct LazyPackage<'a> {
|
||||
zip: Zip<'a>,
|
||||
/// Normalized part name → zip index (the first entry of a name).
|
||||
names: HashMap<String, usize>,
|
||||
total: u64,
|
||||
limits: Limits,
|
||||
}
|
||||
|
||||
impl<'a> LazyPackage<'a> {
|
||||
/// Indexes the archive. Nothing is inflated; [`Limits::max_work`] and
|
||||
/// [`Limits::oversized`] are not used.
|
||||
pub fn open(bytes: &'a [u8], limits: &Limits) -> Result<LazyPackage<'a>, Error> {
|
||||
let zip = open_zip(bytes, limits)?;
|
||||
let mut names = HashMap::new();
|
||||
for i in 0..zip.len() {
|
||||
if let Some(Ok(n)) = zip.name_for_index(i) {
|
||||
names.entry(normalize(&n)).or_insert(i);
|
||||
}
|
||||
}
|
||||
Ok(LazyPackage { zip, names, total: 0, limits: *limits })
|
||||
}
|
||||
|
||||
/// Every part's normalized name ([`normalize`]), sorted.
|
||||
pub fn names(&self) -> Vec<&str> {
|
||||
let mut v: Vec<&str> = self.names.keys().map(String::as_str).collect();
|
||||
v.sort_unstable();
|
||||
v
|
||||
}
|
||||
|
||||
pub fn has(&self, part: &str) -> bool {
|
||||
self.names.contains_key(&normalize(part))
|
||||
}
|
||||
|
||||
/// Bytes charged to [`Limits::max_total`] so far. It is the whole of it once a read has
|
||||
/// failed: see [`LazyPackage::read_limited`].
|
||||
pub fn spent(&self) -> u64 {
|
||||
self.total
|
||||
}
|
||||
|
||||
/// A part's bytes, up to [`Limits::max_part`]; `Ok(None)` when it doesn't exist.
|
||||
pub fn read(&mut self, part: &str) -> Result<Option<Vec<u8>>, Error> {
|
||||
self.read_limited(part, self.limits.max_part)
|
||||
}
|
||||
|
||||
/// A part the reader can do without (the caller warns and goes on when it fails). A read
|
||||
/// that fails part-way is charged this part's whole allowance (`maximum`, and the byte that
|
||||
/// detects an overrun) instead of ending the package's: the decompression work stays
|
||||
/// bounded, and the parts read after it still open.
|
||||
pub fn read_optional_limited(&mut self, part: &str, maximum: u64) -> Result<Option<Vec<u8>>, Error> {
|
||||
let before = self.total;
|
||||
let read = self.read_limited(part, maximum);
|
||||
if let Err(Error::Zip(_)) = &read {
|
||||
self.total = before.saturating_add(maximum.min(self.limits.max_part).saturating_add(1)).min(self.limits.max_total);
|
||||
}
|
||||
read
|
||||
}
|
||||
|
||||
/// A part's bytes, up to `maximum` (and [`Limits::max_part`]): for a small metadata part
|
||||
/// that must not expand to the general limit.
|
||||
///
|
||||
/// A read that fails or overruns spends the whole of [`Limits::max_total`]: it has already
|
||||
/// cost decompression work the total never saw, and a loop over many broken parts must not
|
||||
/// pay it again and again. Every later read is then a cheap error.
|
||||
pub fn read_limited(&mut self, part: &str, maximum: u64) -> Result<Option<Vec<u8>>, Error> {
|
||||
let maximum = maximum.min(self.limits.max_part);
|
||||
let Some(&idx) = self.names.get(&normalize(part)) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut f = self.zip.by_index(idx).map_err(|e| Error::Zip(format!("{part}: {e}")))?;
|
||||
if f.size() > maximum {
|
||||
return Err(Error::PartTooLarge { part: part.to_string(), limit: maximum });
|
||||
}
|
||||
let remaining = self.limits.max_total.saturating_sub(self.total);
|
||||
let limit = maximum.min(remaining);
|
||||
let mut out = Vec::with_capacity(f.size().min(MAX_PREALLOC) as usize);
|
||||
let read = (&mut f).take(limit.saturating_add(1)).read_to_end(&mut out);
|
||||
if out.len() as u64 > limit {
|
||||
self.total = self.limits.max_total;
|
||||
return Err(if remaining < maximum {
|
||||
Error::PackageTooLarge { limit: self.limits.max_total }
|
||||
} else {
|
||||
Error::PartTooLarge { part: part.to_string(), limit: maximum }
|
||||
});
|
||||
}
|
||||
if let Err(e) = read {
|
||||
self.total = self.limits.max_total;
|
||||
return Err(Error::Zip(format!("{part}: {e}")));
|
||||
}
|
||||
self.total = self.total.saturating_add(out.len() as u64);
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
/// Part `name` as XML, read with `options`. `Ok(None)` when there is no such part.
|
||||
pub fn read_xml(&mut self, part: &str, options: &Options) -> Result<Option<El>, Error> {
|
||||
match self.read(part)? {
|
||||
Some(b) => options.parse(&b).map(Some).map_err(|error| Error::Xml { part: part.to_string(), error }),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The relationships of `part`. A missing or unparseable relationships part gives none; one
|
||||
/// that cannot be inflated is the error.
|
||||
pub fn rels(&mut self, part: &str) -> Result<Rels, Error> {
|
||||
let Some(bytes) = self.read(&rels_path(part))? else { return Ok(Rels::default()) };
|
||||
Ok(Rels::parse_in(&bytes, part, |n| self.has(n).then(|| n.to_string())))
|
||||
}
|
||||
}
|
||||
|
||||
/// The first part whose declared size is over [`Limits::max_part`], with that size: for telling
|
||||
/// the user which part made a package too large.
|
||||
pub fn declared_oversized(bytes: &[u8], limits: &Limits) -> Option<(String, u64)> {
|
||||
let mut zip = open_zip(bytes, limits).ok()?;
|
||||
(0..zip.len()).find_map(|i| {
|
||||
let f = zip.by_index_raw(i).ok()?;
|
||||
(f.size() > limits.max_part).then(|| (f.name().map(|n| n.into_owned()).unwrap_or_default(), f.size()))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::zip64_entry_count_sample;
|
||||
|
||||
#[test]
|
||||
fn a_package_declaring_too_many_entries_is_refused_before_zip_reads_them() {
|
||||
let limits = Limits::new(20_000, 1 << 20, 1 << 20);
|
||||
let bomb = zip64_entry_count_sample(20_001);
|
||||
let refused = Some(Error::TooManyEntries { declared: 20_001, limit: 20_000 });
|
||||
assert_eq!(Package::open(&bomb, &limits).err(), refused);
|
||||
assert_eq!(LazyPackage::open(&bomb, &limits).err(), refused);
|
||||
assert!(declared_oversized(&bomb, &limits).is_none());
|
||||
// Within the limit, the same archive is the `zip` crate's to judge.
|
||||
assert!(!matches!(Package::open(&zip64_entry_count_sample(5), &limits), Err(Error::TooManyEntries { .. })));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
//! Relationships (`_rels/*.rels`) and content types (`[Content_Types].xml`).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use craft_xml::{El, Local, Options};
|
||||
|
||||
use crate::name::{dir_of, normalize, percent_decode, resolve};
|
||||
|
||||
/// Is relationship type `t` the type `ty`? `ty` is a full type URI or its last segment
|
||||
/// (`image`). Only the last segment is compared when the URIs differ, so the transitional,
|
||||
/// strict and vendor spellings of one type are the same type.
|
||||
pub fn type_is(t: &str, ty: &str) -> bool {
|
||||
fn tail(s: &str) -> &str {
|
||||
s.rsplit('/').next().unwrap_or(s)
|
||||
}
|
||||
t == ty || tail(t) == tail(ty)
|
||||
}
|
||||
|
||||
/// One relationship.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Rel {
|
||||
pub id: String,
|
||||
/// The full relationship type URI.
|
||||
pub ty: String,
|
||||
/// `Target` as the relationships part writes it (a relative reference or a URL).
|
||||
pub target_raw: String,
|
||||
/// The part name for an internal target, spelled as the package stores it when the package
|
||||
/// has the part; the URL for an external one.
|
||||
pub target: String,
|
||||
pub external: bool,
|
||||
}
|
||||
|
||||
impl Rel {
|
||||
/// The last segment of the type (`slide`, `image`, `styles`…): the same for the
|
||||
/// transitional and strict namespaces.
|
||||
pub fn kind(&self) -> &str {
|
||||
self.ty.rsplit('/').next().unwrap_or(&self.ty)
|
||||
}
|
||||
}
|
||||
|
||||
/// The relationships of one part, in document order.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Rels {
|
||||
pub list: Vec<Rel>,
|
||||
/// Each id's first relationship in `list`, so a lookup doesn't scan every relationship.
|
||||
ids: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
impl Rels {
|
||||
/// The relationships of `part` from the bytes of its relationships part. Internal targets
|
||||
/// resolve against `part`'s folder, with `%hh` escapes decoded. Unreadable XML gives what
|
||||
/// was read before the damage, or nothing.
|
||||
pub fn parse(bytes: &[u8], part: &str) -> Rels {
|
||||
Rels::parse_in(bytes, part, |_| None)
|
||||
}
|
||||
|
||||
/// [`Rels::parse`] for a package: `stored` gives the stored spelling of a part name, or
|
||||
/// `None` when the package has no such part. A target is looked up as written, then with
|
||||
/// its `%hh` escapes decoded.
|
||||
pub fn parse_in(bytes: &[u8], part: &str, stored: impl Fn(&str) -> Option<String>) -> Rels {
|
||||
match Options::local().parse(bytes) {
|
||||
Ok(root) => Rels::from_el(&root, part, stored),
|
||||
Err(e) => {
|
||||
log::warn!("opc: ignoring unreadable relationships of {part}: {e}");
|
||||
Rels::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn from_el(root: &El, part: &str, stored: impl Fn(&str) -> Option<String>) -> Rels {
|
||||
let dir = dir_of(part);
|
||||
let mut rels = Rels::default();
|
||||
for r in root.children(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 {
|
||||
let path = resolve(dir, target);
|
||||
stored(&path).unwrap_or_else(|| {
|
||||
let decoded = percent_decode(&path);
|
||||
stored(&decoded).unwrap_or(decoded)
|
||||
})
|
||||
};
|
||||
rels.ids.entry(id.to_string()).or_insert(rels.list.len());
|
||||
rels.list.push(Rel {
|
||||
id: id.to_string(),
|
||||
ty: r.attr("Type").unwrap_or("").to_string(),
|
||||
target_raw: target.to_string(),
|
||||
target: resolved,
|
||||
external,
|
||||
});
|
||||
}
|
||||
rels
|
||||
}
|
||||
|
||||
/// The relationship with this id: the first, when a damaged part repeats one.
|
||||
pub fn by_id(&self, id: &str) -> Option<&Rel> {
|
||||
self.ids.get(id).and_then(|&i| self.list.get(i))
|
||||
}
|
||||
/// The target of the relationship with this id.
|
||||
pub fn target(&self, id: &str) -> Option<&str> {
|
||||
self.by_id(id).map(|r| r.target.as_str())
|
||||
}
|
||||
/// The first relationship of type `ty` ([`type_is`]: a full URI or a last segment).
|
||||
pub fn first(&self, ty: &str) -> Option<&Rel> {
|
||||
self.list.iter().find(|r| type_is(&r.ty, ty))
|
||||
}
|
||||
/// Every relationship of type `ty`.
|
||||
pub fn all<'a>(&'a self, ty: &'a str) -> impl Iterator<Item = &'a Rel> + 'a {
|
||||
self.list.iter().filter(move |r| type_is(&r.ty, ty))
|
||||
}
|
||||
}
|
||||
|
||||
/// Every internal part name the relationships part `name` (with bytes `bytes`) targets, as
|
||||
/// written and with escapes decoded.
|
||||
pub(crate) fn targets_of(name: &str, bytes: &[u8], mut each: impl FnMut(String)) {
|
||||
// `dir/_rels/file.rels` relates `dir/file`, so its targets resolve against `dir`.
|
||||
let lower = name.to_ascii_lowercase();
|
||||
let Some(at) = lower.rfind("_rels/").filter(|_| lower.ends_with(".rels")) else { return };
|
||||
let dir = name.get(..at).unwrap_or("").trim_end_matches('/');
|
||||
let Ok(root) = Options::local().parse(bytes) else { return };
|
||||
for r in root.children(Local("Relationship")) {
|
||||
let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External"));
|
||||
if let (Some(target), false) = (r.attr("Target"), external) {
|
||||
let path = resolve(dir, target);
|
||||
let decoded = percent_decode(&path);
|
||||
if decoded != path {
|
||||
each(decoded);
|
||||
}
|
||||
each(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `[Content_Types].xml`: Override by part name, then Default by extension (both
|
||||
/// case-insensitive). The first entry for a name or an extension wins.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ContentTypes {
|
||||
overrides: HashMap<String, String>,
|
||||
defaults: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl ContentTypes {
|
||||
/// Unreadable XML gives no content types.
|
||||
pub fn parse(bytes: &[u8]) -> ContentTypes {
|
||||
let mut ct = ContentTypes::default();
|
||||
let root = match Options::local().parse(bytes) {
|
||||
Ok(root) => root,
|
||||
Err(e) => {
|
||||
log::warn!("opc: ignoring unreadable [Content_Types].xml: {e}");
|
||||
return ct;
|
||||
}
|
||||
};
|
||||
for e in root.elements() {
|
||||
let Some(t) = e.attr("ContentType") else { continue };
|
||||
let (map, key) = match (e.name.as_str(), e.attr("PartName"), e.attr("Extension")) {
|
||||
("Override", Some(part), _) => (&mut ct.overrides, normalize(part)),
|
||||
("Default", _, Some(ext)) => (&mut ct.defaults, ext.to_ascii_lowercase()),
|
||||
_ => continue,
|
||||
};
|
||||
map.entry(key).or_insert_with(|| t.to_string());
|
||||
}
|
||||
ct
|
||||
}
|
||||
|
||||
/// The content type of `part`.
|
||||
pub fn of(&self, part: &str) -> Option<&str> {
|
||||
let part = normalize(part);
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{NS_RELATIONSHIPS, RT_BASE};
|
||||
|
||||
#[test]
|
||||
fn types_compare_by_last_segment() {
|
||||
let strict = "http://purl.oclc.org/ooxml/officeDocument/relationships/image";
|
||||
assert!(type_is(strict, &format!("{RT_BASE}image")));
|
||||
assert!(type_is(strict, "image"));
|
||||
assert!(type_is("image", "image"));
|
||||
assert!(!type_is(strict, "images"));
|
||||
assert!(!type_is(&format!("{RT_BASE}notimage"), "image"));
|
||||
assert!(!type_is("", "image"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_relationships_are_found_by_index_and_first_id_wins() {
|
||||
let n = 50_000;
|
||||
let mut x = format!(r#"<Relationships xmlns="{NS_RELATIONSHIPS}">"#);
|
||||
for i in 0..n {
|
||||
x.push_str(&format!(r#"<Relationship Id="rId{i}" Type="{RT_BASE}image" Target="../media/image{i}.png"/>"#));
|
||||
}
|
||||
x.push_str(&format!(r#"<Relationship Id="rId0" Type="{RT_BASE}image" Target="../media/dup.png"/></Relationships>"#));
|
||||
let rels = Rels::parse(x.as_bytes(), "ppt/slides/slide1.xml");
|
||||
assert_eq!(rels.target("rId0"), Some("ppt/media/image0.png"), "the first of a repeated id wins");
|
||||
assert!((0..n).all(|i| rels.target(&format!("rId{i}")) == Some(&*format!("ppt/media/image{i}.png"))));
|
||||
assert!(rels.by_id(&format!("rId{n}")).is_none());
|
||||
assert_eq!(rels.all("image").count(), n + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targets_keep_what_was_written_and_resolve() {
|
||||
let x = format!(
|
||||
r#"<r:Relationships xmlns:r="{NS_RELATIONSHIPS}"><r:Relationship Id="a" Type="{RT_BASE}image" Target="../media/a%20b.png#x"/><r:Relationship Id="b" Type="{RT_BASE}hyperlink" Target="https://example.com/a%20b" TargetMode="external"/><r:Relationship Type="t" Target="no-id"/></r:Relationships>"#
|
||||
);
|
||||
let rels = Rels::parse(x.as_bytes(), "/ppt/slides/slide1.xml");
|
||||
assert_eq!(rels.list.len(), 2, "a relationship without an id is left out");
|
||||
let a = rels.by_id("a").unwrap();
|
||||
assert_eq!((a.target.as_str(), a.target_raw.as_str(), a.kind(), a.external), ("ppt/media/a b.png", "../media/a%20b.png#x", "image", false));
|
||||
let b = rels.first("hyperlink").unwrap();
|
||||
assert_eq!((b.target.as_str(), b.external), ("https://example.com/a%20b", true), "an external target is not resolved");
|
||||
assert!(Rels::parse(b"<Relationships><Relationship", "a.xml").list.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_target_is_looked_up_as_written_before_it_is_decoded() {
|
||||
let x = r#"<Relationships><Relationship Id="a" Type="image" Target="a%20b.png"/><Relationship Id="b" Type="image" Target="c%20d.png"/></Relationships>"#;
|
||||
let stored = |n: &str| ["word/a%20b.png", "word/C d.png"].iter().find(|s| s.eq_ignore_ascii_case(n)).map(|s| s.to_string());
|
||||
let rels = Rels::parse_in(x.as_bytes(), "word/document.xml", stored);
|
||||
assert_eq!(rels.target("a"), Some("word/a%20b.png"));
|
||||
assert_eq!(rels.target("b"), Some("word/C d.png"), "decoded, in the stored spelling");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_types_by_override_then_extension() {
|
||||
let ct = ContentTypes::parse(
|
||||
br#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="PNG" ContentType="image/png"/><Default Extension="png" ContentType="image/other"/><Override PartName="/Word/Document.xml" ContentType="main"/><Override PartName="/word/document.xml" ContentType="second"/><Default ContentType="none"/></Types>"#,
|
||||
);
|
||||
assert_eq!(ct.of("word/document.xml"), Some("main"), "the first override wins, in any case");
|
||||
assert_eq!(ct.of("/word/media/A.PNG"), Some("image/png"));
|
||||
assert_eq!(ct.of("word/styles.xml"), None);
|
||||
assert_eq!(ct.of("noextension"), None);
|
||||
assert_eq!(ContentTypes::parse(b"<Types><Default").of("a.png"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
//! Writing a package.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
use craft_xml::Writer;
|
||||
|
||||
use crate::name::{normalize, rels_path};
|
||||
use crate::{CT_RELATIONSHIPS, Error, NS_CONTENT_TYPES, NS_RELATIONSHIPS, RT_BASE};
|
||||
|
||||
/// How an entry is stored.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Compression {
|
||||
Deflate,
|
||||
Store,
|
||||
}
|
||||
|
||||
/// [`Compression::Store`] for a name whose extension says the data is already compressed
|
||||
/// (pictures, video, audio, archives), which gains nothing from deflate; otherwise
|
||||
/// [`Compression::Deflate`].
|
||||
pub fn compression_for(name: &str) -> Compression {
|
||||
const PACKED: [&str; 19] =
|
||||
["png", "jpg", "jpeg", "gif", "webp", "mp4", "m4v", "m4a", "mov", "mp3", "wmv", "wma", "avi", "ogg", "webm", "zip", "docx", "xlsx", "pptx"];
|
||||
let ext = name.rsplit_once('.').map_or("", |(_, e)| e);
|
||||
if PACKED.iter().any(|p| ext.eq_ignore_ascii_case(p)) { Compression::Store } else { Compression::Deflate }
|
||||
}
|
||||
|
||||
/// A ZIP archive being written. The output is deterministic: deflate level 6 and the same
|
||||
/// (1980) timestamp on every entry.
|
||||
pub struct ZipOut {
|
||||
zip: zip::ZipWriter<Cursor<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl Default for ZipOut {
|
||||
fn default() -> ZipOut {
|
||||
ZipOut { zip: zip::ZipWriter::new(Cursor::new(Vec::new())) }
|
||||
}
|
||||
}
|
||||
|
||||
impl ZipOut {
|
||||
pub fn new() -> ZipOut {
|
||||
ZipOut::default()
|
||||
}
|
||||
|
||||
/// Adds an entry. The caller keeps names unique.
|
||||
pub fn add(&mut self, name: &str, data: &[u8], compression: Compression) -> Result<(), Error> {
|
||||
let method = match compression {
|
||||
Compression::Deflate => zip::CompressionMethod::Deflated,
|
||||
Compression::Store => zip::CompressionMethod::Stored,
|
||||
};
|
||||
let opts = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(method)
|
||||
.compression_level((compression == Compression::Deflate).then_some(6))
|
||||
.last_modified_time(zip::DateTime::default())
|
||||
// Deflate can grow incompressible data a little, so well before 4 GiB.
|
||||
.large_file(data.len() as u64 >= u64::from(u32::MAX) / 2);
|
||||
self.zip.start_file(name, opts).map_err(|e| Error::Write(e.to_string()))?;
|
||||
self.zip.write_all(data).map_err(|e| Error::Write(e.to_string()))
|
||||
}
|
||||
|
||||
pub fn finish(self) -> Result<Vec<u8>, Error> {
|
||||
self.zip.finish().map(Cursor::into_inner).map_err(|e| Error::Write(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// A ZIP archive of `entries` in order, each stored as [`compression_for`] its name says.
|
||||
pub fn zip_entries(entries: &[(String, Vec<u8>)]) -> Result<Vec<u8>, Error> {
|
||||
let mut zip = ZipOut::new();
|
||||
for (name, bytes) in entries {
|
||||
zip.add(name, bytes, compression_for(name))?;
|
||||
}
|
||||
zip.finish()
|
||||
}
|
||||
|
||||
/// One relationship being written.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RelOut {
|
||||
pub id: String,
|
||||
/// The full relationship type URI.
|
||||
pub ty: String,
|
||||
pub target: String,
|
||||
pub external: bool,
|
||||
}
|
||||
|
||||
/// The relationships being built for one part. Ids are `rId1`, `rId2`… in the order added.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct RelsOut {
|
||||
pub items: Vec<RelOut>,
|
||||
/// (type, target, external) → id of the first relationship made for it.
|
||||
added: HashMap<(String, String, bool), String>,
|
||||
}
|
||||
|
||||
fn full_type(kind: &str) -> String {
|
||||
if kind.starts_with("http") { kind.to_string() } else { format!("{RT_BASE}{kind}") }
|
||||
}
|
||||
|
||||
impl RelsOut {
|
||||
/// Adds a relationship, or reuses the one already made for this type, target and mode.
|
||||
/// `kind` is a full type URI or a last segment of the standard namespace ([`RT_BASE`]).
|
||||
/// Returns its id.
|
||||
pub fn add(&mut self, kind: &str, target: &str, external: bool) -> String {
|
||||
let key = (full_type(kind), target.to_string(), external);
|
||||
match self.added.get(&key) {
|
||||
Some(id) => id.clone(),
|
||||
None => self.add_new(kind, target, external),
|
||||
}
|
||||
}
|
||||
|
||||
/// Always adds a new relationship, even if an identical one exists. [`RelsOut::add`] reuses
|
||||
/// the first relationship for a target, whichever call made it.
|
||||
pub fn add_new(&mut self, kind: &str, target: &str, external: bool) -> String {
|
||||
let ty = full_type(kind);
|
||||
let id = format!("rId{}", self.items.len() + 1);
|
||||
self.added.entry((ty.clone(), target.to_string(), external)).or_insert_with(|| id.clone());
|
||||
self.items.push(RelOut { id: id.clone(), ty, target: target.to_string(), external });
|
||||
id
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.items.is_empty()
|
||||
}
|
||||
|
||||
/// The relationships part.
|
||||
pub fn to_xml(&self) -> Vec<u8> {
|
||||
let mut w = Writer::document();
|
||||
w.open("Relationships", &[("xmlns", NS_RELATIONSHIPS)]);
|
||||
for r in &self.items {
|
||||
w.tag("Relationship")
|
||||
.attr("Id", &r.id)
|
||||
.attr("Type", &r.ty)
|
||||
.attr("Target", &r.target)
|
||||
.attr_opt("TargetMode", r.external.then_some("External"))
|
||||
.empty();
|
||||
}
|
||||
w.into_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
/// A package being written: parts, their content types, and `[Content_Types].xml` as the first
|
||||
/// entry.
|
||||
pub struct PackageOut {
|
||||
files: Vec<(String, Vec<u8>, Compression)>,
|
||||
defaults: Vec<(String, String)>,
|
||||
overrides: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Default for PackageOut {
|
||||
fn default() -> PackageOut {
|
||||
PackageOut {
|
||||
files: Vec::new(),
|
||||
defaults: vec![("rels".into(), CT_RELATIONSHIPS.into()), ("xml".into(), "application/xml".into())],
|
||||
overrides: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PackageOut {
|
||||
/// Adds an XML part with a content type Override.
|
||||
pub fn xml_part(&mut self, name: &str, content_type: &str, data: Vec<u8>) {
|
||||
self.overrides.push((format!("/{name}"), content_type.to_string()));
|
||||
self.files.push((name.to_string(), data, Compression::Deflate));
|
||||
}
|
||||
|
||||
/// Adds (or replaces) the relationships part of `part`; of the package itself for `""`.
|
||||
/// Nothing is written for no relationships.
|
||||
pub fn rels(&mut self, part: &str, rels: &RelsOut) {
|
||||
if rels.is_empty() {
|
||||
return;
|
||||
}
|
||||
let path = rels_path(part);
|
||||
match self.files.iter_mut().find(|(name, ..)| *name == path) {
|
||||
Some(existing) => existing.1 = rels.to_xml(),
|
||||
None => self.files.push((path, rels.to_xml(), Compression::Deflate)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a part with no content type entry of its own: one the `rels` and `xml` Defaults
|
||||
/// cover.
|
||||
pub fn raw(&mut self, name: &str, data: Vec<u8>) {
|
||||
self.files.push((name.to_string(), data, compression_for(name)));
|
||||
}
|
||||
|
||||
/// Adds a binary part. Its extension gets a Default content type; when the extension
|
||||
/// already has another, the part gets an Override.
|
||||
pub fn binary(&mut self, name: &str, content_type: &str, data: Vec<u8>) {
|
||||
let ext = name.rsplit('.').next().unwrap_or("bin").to_ascii_lowercase();
|
||||
if !self.defaults.iter().any(|(e, _)| *e == ext) {
|
||||
self.defaults.push((ext, content_type.to_string()));
|
||||
} else if !self.defaults.iter().any(|(e, c)| *e == ext && c == content_type) {
|
||||
self.overrides.push((format!("/{name}"), content_type.to_string()));
|
||||
}
|
||||
self.files.push((name.to_string(), data, compression_for(name)));
|
||||
}
|
||||
|
||||
/// `[Content_Types].xml` for what has been added.
|
||||
pub fn content_types_xml(&self) -> Vec<u8> {
|
||||
let mut w = Writer::document();
|
||||
w.open("Types", &[("xmlns", NS_CONTENT_TYPES)]);
|
||||
for (e, c) in &self.defaults {
|
||||
w.empty("Default", &[("Extension", e), ("ContentType", c)]);
|
||||
}
|
||||
for (p, c) in &self.overrides {
|
||||
w.empty("Override", &[("PartName", p), ("ContentType", c)]);
|
||||
}
|
||||
w.into_bytes()
|
||||
}
|
||||
|
||||
/// The archive. A second part under a name already written (in any case) is left out with
|
||||
/// a logged warning: entry names must be unique, or readers reject the package or resolve
|
||||
/// it differently.
|
||||
pub fn finish(self) -> Result<Vec<u8>, Error> {
|
||||
let mut zip = ZipOut::new();
|
||||
zip.add("[Content_Types].xml", &self.content_types_xml(), Compression::Deflate)?;
|
||||
let mut written = HashSet::new();
|
||||
for (name, data, compression) in &self.files {
|
||||
if !written.insert(normalize(name)) {
|
||||
log::warn!("opc: part {name} written twice; keeping the first");
|
||||
continue;
|
||||
}
|
||||
zip.add(name, data, *compression)?;
|
||||
}
|
||||
zip.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
//! The package readers and writer against the cases the DOCX, XLSX and PPTX readers they came
|
||||
//! from were tested with.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Cursor, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_opc::{
|
||||
Compression, Error, LazyPackage, Limits, Oversized, Package, PackageOut, RT_BASE, RelsOut, ZipOut, compression_for, declared_oversized,
|
||||
zip_entries,
|
||||
};
|
||||
|
||||
const LIMITS: Limits = Limits::new(20_000, 256 << 20, 512 << 20);
|
||||
|
||||
fn limits(max_part: u64, max_total: u64) -> Limits {
|
||||
Limits::new(20_000, max_part, max_total)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/// Flips the stored checksum of every entry, in its central and local header.
|
||||
fn break_checksums(z: &mut [u8]) -> usize {
|
||||
let mut patched = 0;
|
||||
for sig in [[0x50u8, 0x4B, 0x01, 0x02], [0x50, 0x4B, 0x03, 0x04]] {
|
||||
let crc_at = if sig[2] == 1 { 16 } else { 14 };
|
||||
let found: Vec<usize> = z.windows(4).enumerate().filter(|(_, w)| *w == sig).map(|(i, _)| i).collect();
|
||||
for pos in found {
|
||||
z[pos + crc_at] ^= 0xFF;
|
||||
patched += 1;
|
||||
}
|
||||
}
|
||||
patched
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_colliding_entries_are_one_part() {
|
||||
let z = zip_of(&[("word/media/Image.png", b"upper".to_vec()), ("word/media/image.png", b"lower".to_vec())]);
|
||||
let p = Package::open(&z, &LIMITS).unwrap();
|
||||
for spelling in ["word/media/Image.png", "word/media/image.png", "/WORD/MEDIA/IMAGE.PNG", "word\\media\\image.png"] {
|
||||
assert_eq!(p.canonical(spelling), Some("word/media/Image.png"), "{spelling}");
|
||||
assert_eq!(p.get(spelling), Some(&b"upper"[..]), "{spelling}");
|
||||
}
|
||||
assert_eq!(p.names().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_spelling_a_relationship_names_is_kept() {
|
||||
// The first spelling in byte order ("Image.png") was kept even when the document's
|
||||
// relationship named the other one, so the picture shown was not the one it pointed at.
|
||||
let rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="image" Target="media/image.png"/><Relationship Id="rId2" Type="image" Target="media/SPACED%20name.png"/></Relationships>"#;
|
||||
let z = zip_of(&[
|
||||
("word/_rels/document.xml.rels", rels.as_bytes().to_vec()),
|
||||
("word/media/Image.png", b"upper".to_vec()),
|
||||
("word/media/image.png", b"lower".to_vec()),
|
||||
("word/media/Other.png", b"first".to_vec()),
|
||||
("word/media/other.png", b"second".to_vec()),
|
||||
("word/media/Spaced name.png", b"one".to_vec()),
|
||||
("word/media/SPACED name.png", b"two".to_vec()),
|
||||
]);
|
||||
let p = Package::open(&z, &LIMITS).unwrap();
|
||||
assert_eq!(p.canonical("word/media/IMAGE.png"), Some("word/media/image.png"));
|
||||
assert_eq!(p.get("word/media/Image.png"), Some(&b"lower"[..]));
|
||||
assert_eq!(p.get("word/media/OTHER.PNG"), Some(&b"first"[..]), "no relationship names it: first in byte order");
|
||||
assert_eq!(p.get("word/media/spaced name.png"), Some(&b"two"[..]), "named with an escape");
|
||||
assert_eq!(p.names().count(), 4);
|
||||
let r = p.rels("word/document.xml");
|
||||
assert_eq!(r.target("rId1"), Some("word/media/image.png"));
|
||||
assert_eq!(r.target("rId2"), Some("word/media/SPACED name.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_entry_count_zip_finds_is_checked() {
|
||||
let z = zip_of(&[("a", vec![]), ("b", vec![]), ("c", vec![])]);
|
||||
let two = Limits::new(2, 100, 100);
|
||||
assert_eq!(Package::open(&z, &two).err(), Some(Error::TooManyEntries { declared: 3, limit: 2 }));
|
||||
assert_eq!(LazyPackage::open(&z, &two).err(), Some(Error::TooManyEntries { declared: 3, limit: 2 }));
|
||||
assert!(Package::open(&z, &two).is_err_and(|e| e.is_limit()));
|
||||
assert!(Package::open(&z, &Limits::new(3, 100, 100)).is_ok());
|
||||
assert!(matches!(Package::open(b"not a zip", &LIMITS), Err(Error::Zip(e)) if !Error::Zip(e.clone()).is_limit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relationships_are_looked_up_by_id_the_first_one_winning() {
|
||||
let mut rels = String::from(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#);
|
||||
for i in 0..20_000 {
|
||||
rels.push_str(&format!(r#"<Relationship Id="rId{i}" Type="hyperlink" Target="https://example.com/{i}" TargetMode="External"/>"#));
|
||||
}
|
||||
rels.push_str(r#"<Relationship Id="rId7" Type="hyperlink" Target="https://example.com/dup" TargetMode="External"/></Relationships>"#);
|
||||
let z = zip_of(&[("word/_rels/document.xml.rels", rels.into_bytes())]);
|
||||
let r = Package::open(&z, &LIMITS).unwrap().rels("word/document.xml");
|
||||
assert_eq!(r.by_id("rId7").map(|r| r.target.as_str()), Some("https://example.com/7"), "the first rId7 wins");
|
||||
assert_eq!(r.by_id("rId19999").map(|r| r.target.as_str()), Some("https://example.com/19999"));
|
||||
assert!(r.by_id("rId20000").is_none());
|
||||
assert_eq!((0..20_000).filter(|i| r.by_id(&format!("rId{i}")).is_some()).count(), 20_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompression_bomb_is_rejected() {
|
||||
let z = zip_of(&[("word/document.xml", vec![b' '; 2_000_000])]);
|
||||
assert!(z.len() < 100_000);
|
||||
assert_eq!(
|
||||
Package::open(&z, &limits(1_000_000, 10_000_000)).err(),
|
||||
Some(Error::PartTooLarge { part: "word/document.xml".into(), limit: 1_000_000 })
|
||||
);
|
||||
assert_eq!(Package::open(&z, &limits(10_000_000, 1_000_000)).err(), Some(Error::PackageTooLarge { limit: 1_000_000 }));
|
||||
assert_eq!(Package::open(&z, &limits(10_000_000, 10_000_000)).map(|p| p.size()), Ok(2_000_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_that_fail_their_check_still_count_against_the_total() {
|
||||
// Each entry inflates in full before its CRC is checked; a failed one dropped without being
|
||||
// charged would let any number of them inflate.
|
||||
let names: Vec<String> = (0..20).map(|i| format!("p{i}.bin")).collect();
|
||||
let entries: Vec<(&str, Vec<u8>)> = names.iter().map(|n| (n.as_str(), vec![0u8; 1_000_000])).collect();
|
||||
let mut z = zip_of(&entries);
|
||||
assert_eq!(break_checksums(&mut z), 40);
|
||||
assert_eq!(Package::open(&z, &limits(2_000_000, 4_000_000)).err(), Some(Error::PackageTooLarge { limit: 4_000_000 }));
|
||||
// With room for all of them the damaged entries are left out, not fatal.
|
||||
let p = Package::open(&z, &limits(2_000_000, 40_000_000)).unwrap();
|
||||
assert_eq!(p.names().count(), 0);
|
||||
}
|
||||
|
||||
#[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);
|
||||
assert_eq!(declared_oversized(&z, &LIMITS), Some(("a.xml".to_string(), 0xFFFF_FFF0)));
|
||||
match Package::open(&z, &LIMITS) {
|
||||
Err(_) => {}
|
||||
Ok(p) => assert!(p.get("a.xml").is_none_or(|b| b.len() < 1024)),
|
||||
}
|
||||
let mut lazy = LazyPackage::open(&z, &LIMITS).unwrap();
|
||||
assert!(lazy.read("a.xml").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parts_are_shared_not_copied() {
|
||||
let z = zip_of(&[("word/media/pic.png", b"\x89PNG picture bytes".to_vec())]);
|
||||
let pkg = Package::open(&z, &LIMITS).unwrap();
|
||||
let held = Arc::clone(pkg.get_shared("/WORD/media/PIC.png").unwrap());
|
||||
assert!(Arc::ptr_eq(&held, pkg.get_shared("word/media/pic.png").unwrap()));
|
||||
assert_eq!(pkg.get("word/media/pic.png"), Some(&b"\x89PNG picture bytes"[..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skipped_parts_count_against_the_inflate_budget() {
|
||||
// Kept bytes cap at 100: b, c and d are inflated past what's left and left out. That work
|
||||
// is charged, so after d the budget (130) is spent and e is not read, although it would
|
||||
// have fit.
|
||||
let mut zw = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
for (name, len) in [("a", 80), ("b", 80), ("c", 80), ("d", 80), ("e", 5), ("big", 2000)] {
|
||||
zw.start_file(name, o).unwrap();
|
||||
zw.write_all(&vec![b'x'; len]).unwrap();
|
||||
}
|
||||
let z = zw.finish().unwrap().into_inner();
|
||||
let skip = |max_work| Limits { max_entries: 100, max_part: 1000, max_total: 100, max_work, oversized: Oversized::Skip };
|
||||
let p = Package::open(&z, &skip(130)).unwrap();
|
||||
assert!(p.get("a").is_some());
|
||||
assert!(p.get("b").is_none() && p.get("d").is_none());
|
||||
assert!(p.get("e").is_none(), "the inflate budget stops reading");
|
||||
// With room to spare the same small part is kept, and a part declaring more than the part
|
||||
// limit is left out without being inflated.
|
||||
let p = Package::open(&z, &skip(10_000)).unwrap();
|
||||
assert!(p.get("e").is_some());
|
||||
assert!(p.get("big").is_none());
|
||||
assert_eq!(p.size(), 85);
|
||||
// The same package under `Refuse` is an error at the first part that does not fit.
|
||||
let refuse = Limits { oversized: Oversized::Refuse, ..skip(10_000) };
|
||||
assert_eq!(Package::open(&z, &refuse).err(), Some(Error::PackageTooLarge { limit: 100 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backslash_zip_entry_names_are_found() {
|
||||
let z = zip_of(&[("PPT\\Slides\\Slide1.xml", b"<a/>".to_vec()), ("/docProps/core.xml", b"<b/>".to_vec())]);
|
||||
let p = Package::open(&z, &LIMITS).unwrap();
|
||||
assert!(p.get("ppt/slides/slide1.xml").is_some());
|
||||
assert_eq!(p.canonical("ppt/slides/slide1.xml"), Some("PPT/Slides/Slide1.xml"));
|
||||
assert_eq!(p.canonical("docprops/core.xml"), Some("docProps/core.xml"));
|
||||
let mut lazy = LazyPackage::open(&z, &LIMITS).unwrap();
|
||||
assert_eq!(lazy.names(), ["docprops/core.xml", "ppt/slides/slide1.xml"]);
|
||||
assert_eq!(lazy.read("PPT/slides/slide1.xml").unwrap().as_deref(), Some(&b"<a/>"[..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xml_parts_are_read_with_the_callers_options() {
|
||||
let z = zip_of(&[("a.xml", b"<w:a><w:b/></w:a>".to_vec()), ("bad.xml", b"<a><b></a>".to_vec())]);
|
||||
let p = Package::open(&z, &LIMITS).unwrap();
|
||||
let local = craft_xml::Options::local();
|
||||
assert_eq!(p.xml("a.xml", &local).unwrap().map(|e| e.name), Some("a".to_string()));
|
||||
assert_eq!(p.xml("a.xml", &craft_xml::Options::default()).unwrap().map(|e| e.name), Some("w:a".to_string()));
|
||||
assert_eq!(p.xml("missing.xml", &local), Ok(None));
|
||||
assert!(p.xml("bad.xml", &local).unwrap().is_some(), "lenient");
|
||||
assert!(matches!(p.xml("bad.xml", &local.strict()), Err(Error::Xml { part, .. }) if part == "bad.xml"));
|
||||
let tiny = local.with_limits(craft_xml::Limits { max_nodes: 1, ..craft_xml::Limits::DEFAULT });
|
||||
assert!(p.xml("a.xml", &tiny).is_err_and(|e| e.is_limit()));
|
||||
let mut lazy = LazyPackage::open(&z, &LIMITS).unwrap();
|
||||
assert_eq!(lazy.read_xml("a.xml", &local).unwrap().map(|e| e.name), Some("a".to_string()));
|
||||
assert!(lazy.read_xml("bad.xml", &local.strict()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_parts_behaves_like_an_opened_package() {
|
||||
let mut parts = BTreeMap::new();
|
||||
parts.insert("/word/document.xml".to_string(), b"<d/>".to_vec());
|
||||
parts.insert(
|
||||
"word/_rels/document.xml.rels".to_string(),
|
||||
br#"<Relationships><Relationship Id="s" Type="x/styles" Target="Styles.xml"/></Relationships>"#.to_vec(),
|
||||
);
|
||||
parts.insert("word/styles.xml".to_string(), b"<s/>".to_vec());
|
||||
let p = Package::from_parts(parts);
|
||||
assert_eq!(p.get("word/document.xml"), Some(&b"<d/>"[..]));
|
||||
assert_eq!(p.rels("word/document.xml").first("styles").map(|r| r.target.as_str()), Some("word/styles.xml"), "the stored spelling");
|
||||
assert!(p.rels("word/styles.xml").list.is_empty());
|
||||
assert_eq!(p.content_types().of("word/document.xml"), None);
|
||||
}
|
||||
|
||||
/// An over-budget read must spend the package's allowance once and for all: loops over many
|
||||
/// broken parts otherwise re-decompress the remaining allowance again and again.
|
||||
#[test]
|
||||
fn a_failed_lazy_read_ends_the_package_budget() {
|
||||
let z = zip_of(&[("a.xml", b"0123456789".to_vec()), ("b.xml", b"hello".to_vec())]);
|
||||
let mut pkg = LazyPackage::open(&z, &limits(100, 12)).unwrap();
|
||||
assert!(pkg.has("A.xml") && !pkg.has("c.xml"));
|
||||
assert_eq!(pkg.read("c.xml"), Ok(None));
|
||||
assert!(pkg.read("a.xml").unwrap().is_some());
|
||||
assert_eq!(pkg.spent(), 10);
|
||||
assert_eq!(pkg.read("b.xml"), Err(Error::PackageTooLarge { limit: 12 }));
|
||||
assert_eq!(pkg.spent(), 12, "the failed read is charged");
|
||||
assert_eq!(pkg.read("b.xml"), Err(Error::PackageTooLarge { limit: 12 }));
|
||||
// The per-read maximum is its own error and costs nothing: the declared size is enough.
|
||||
let mut pkg = LazyPackage::open(&z, &limits(100, 100)).unwrap();
|
||||
assert_eq!(pkg.read_limited("a.xml", 4), Err(Error::PartTooLarge { part: "a.xml".into(), limit: 4 }));
|
||||
assert_eq!(pkg.spent(), 0);
|
||||
assert_eq!(pkg.read_limited("b.xml", 5).unwrap().as_deref(), Some(&b"hello"[..]));
|
||||
}
|
||||
|
||||
/// A part the reader can do without, damaged inside an intact archive, costs its own
|
||||
/// allowance: the parts after it are still read.
|
||||
#[test]
|
||||
fn a_damaged_optional_part_costs_only_its_allowance() {
|
||||
let mut w = ZipOut::new();
|
||||
w.add("a.xml", b"<a>damaged</a>", Compression::Store).unwrap();
|
||||
w.add("b.xml", b"hello", Compression::Deflate).unwrap();
|
||||
w.add(
|
||||
"xl/_rels/workbook.xml.rels",
|
||||
b"<Relationships><Relationship Id=\"r\" Type=\"t/worksheet\" Target=\"/B.XML\"/></Relationships>",
|
||||
Compression::Deflate,
|
||||
)
|
||||
.unwrap();
|
||||
let mut z = w.finish().unwrap();
|
||||
let at = z.windows(7).position(|x| x == b"damaged").unwrap();
|
||||
z[at] ^= 0x20;
|
||||
let max_total = 1 << 20;
|
||||
let mut pkg = LazyPackage::open(&z, &limits(1 << 16, max_total)).unwrap();
|
||||
assert!(matches!(pkg.read_optional_limited("a.xml", 1024), Err(Error::Zip(_))));
|
||||
assert_eq!(pkg.spent(), 1025, "the failed read is charged its allowance");
|
||||
assert_eq!(pkg.read("b.xml").unwrap().as_deref(), Some(&b"hello"[..]));
|
||||
let rels = pkg.rels("xl/workbook.xml").unwrap();
|
||||
assert_eq!(rels.first("worksheet").map(|r| (r.target.as_str(), r.kind())), Some(("B.XML", "worksheet")));
|
||||
assert!(pkg.rels("xl/styles.xml").unwrap().list.is_empty());
|
||||
// An ordinary read of it still ends the package's budget.
|
||||
assert!(pkg.read("a.xml").is_err());
|
||||
assert_eq!(pkg.spent(), max_total);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relationships_being_written_reuse_identical_targets() {
|
||||
let n = 5_000;
|
||||
let mut out = RelsOut::default();
|
||||
let ids: Vec<String> = (0..n).map(|i| out.add("hyperlink", &format!("https://example.com/{i}"), true)).collect();
|
||||
assert_eq!(out.add("hyperlink", "https://example.com/7", true), ids[7], "an identical target reuses its id");
|
||||
assert_eq!(out.add("hyperlink", "https://example.com/7", false), format!("rId{}", n + 1), "internal is distinct");
|
||||
let fresh = out.add_new("image", "../media/a.png", false);
|
||||
assert_ne!(out.add_new("image", "../media/a.png", false), fresh);
|
||||
assert_eq!(out.add("image", "../media/a.png", false), fresh, "add reuses the first add_new made");
|
||||
assert_eq!(out.add("http://schemas.microsoft.com/office/2007/relationships/media", "a&b.mp4", false), format!("rId{}", n + 4));
|
||||
let back = craft_opc::Rels::parse(&out.to_xml(), "ppt/slides/slide1.xml");
|
||||
assert_eq!(back.list.len(), n + 4);
|
||||
assert_eq!(back.by_id(&fresh).map(|r| (r.ty.clone(), r.target.as_str())), Some((format!("{RT_BASE}image"), "ppt/media/a.png")));
|
||||
assert_eq!(back.first("media").map(|r| r.target_raw.as_str()), Some("a&b.mp4"), "escaped on the way out");
|
||||
assert!(back.by_id("rId8").is_some_and(|r| r.external));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_written_package_reads_back() {
|
||||
let mut out = PackageOut::default();
|
||||
out.xml_part("ppt/presentation.xml", "application/presentation+xml", b"<p/>".to_vec());
|
||||
out.binary("ppt/media/image1.png", "image/png", b"png".to_vec());
|
||||
out.binary("ppt/media/image2.png", "image/png", b"png2".to_vec());
|
||||
out.binary("ppt/media/odd.png", "image/x-odd", b"odd".to_vec());
|
||||
out.raw("docProps/thumbnail.xml", b"<t/>".to_vec());
|
||||
out.xml_part("PPT/Presentation.xml", "second", b"<second/>".to_vec());
|
||||
let mut rels = RelsOut::default();
|
||||
let id = rels.add("image", "media/image1.png", false);
|
||||
out.rels("ppt/presentation.xml", &RelsOut::default());
|
||||
out.rels("ppt/presentation.xml", &rels);
|
||||
rels.add("image", "media/image2.png", false);
|
||||
out.rels("ppt/presentation.xml", &rels);
|
||||
let mut root = RelsOut::default();
|
||||
root.add("officeDocument", "ppt/presentation.xml", false);
|
||||
out.rels("", &root);
|
||||
let bytes = out.finish().unwrap();
|
||||
|
||||
let p = Package::open(&bytes, &LIMITS).unwrap();
|
||||
assert_eq!(p.names().next(), Some("[Content_Types].xml"), "names sort it first; it is also the first entry");
|
||||
assert!(bytes.windows(19).position(|w| w == b"[Content_Types].xml").is_some_and(|at| at < 64));
|
||||
assert_eq!(p.get("ppt/presentation.xml"), Some(&b"<p/>"[..]), "the first part under a name is the one written");
|
||||
let ct = p.content_types();
|
||||
assert_eq!(ct.of("ppt/presentation.xml"), Some("application/presentation+xml"));
|
||||
assert_eq!(ct.of("ppt/media/image2.png"), Some("image/png"));
|
||||
assert_eq!(ct.of("ppt/media/odd.png"), Some("image/x-odd"), "an Override where the extension is taken");
|
||||
assert_eq!(ct.of("ppt/_rels/presentation.xml.rels"), Some(craft_opc::CT_RELATIONSHIPS));
|
||||
assert_eq!(ct.of("docProps/thumbnail.xml"), Some("application/xml"));
|
||||
let main = p.rels("").first("officeDocument").map(|r| r.target.clone()).unwrap();
|
||||
let r = p.rels(&main);
|
||||
assert_eq!(r.target(&id), Some("ppt/media/image1.png"));
|
||||
assert_eq!(r.list.len(), 2, "the relationships part was replaced, not written twice");
|
||||
// Writing the same package again gives the same bytes.
|
||||
assert_eq!(zip_entries(&[("a.xml".into(), b"<a/>".to_vec())]), zip_entries(&[("a.xml".into(), b"<a/>".to_vec())]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packed_media_are_stored() {
|
||||
assert_eq!(compression_for("word/media/image1.PNG"), Compression::Store);
|
||||
assert_eq!(compression_for("ppt/media/movie.mp4"), Compression::Store);
|
||||
assert_eq!(compression_for("word/document.xml"), Compression::Deflate);
|
||||
assert_eq!(compression_for("word/media/image1.emf"), Compression::Deflate);
|
||||
assert_eq!(compression_for("noextension"), Compression::Deflate);
|
||||
let data = vec![b'a'; 100_000];
|
||||
let stored = zip_entries(&[("a.png".into(), data.clone())]).unwrap();
|
||||
let deflated = zip_entries(&[("a.xml".into(), data.clone())]).unwrap();
|
||||
assert!(stored.len() > 100_000 && deflated.len() < 10_000);
|
||||
for z in [stored, deflated] {
|
||||
let p = Package::open(&z, &LIMITS).unwrap();
|
||||
assert_eq!(p.size(), 100_000);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user