mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
craft-odf: OpenDocument packages, password protection and flat documents (#896)
The container every OpenDocument file shares, moved from WordCraft (crates/formats/src/odt/crypt.rs, odt/flat.rs, the package reading and writing in odt/import.rs and odt/package.rs, crates/doc/src/zip_guard.rs at 06c2ae6f) so that the ODT and ODS readers build on one copy. - Package and PackageWriter: the ZIP with its mimetype and manifest, within Limits (entries, bytes per file, bytes per package). - Manifest: read by namespace whatever the prefix, as GridCraft's ODS reader did; written as LibreOffice lays it out. - crypt: AES-256-CBC with PBKDF2, read and written, with the per-file and per-package key-derivation budgets. - flat: a flat document to a package and back, for any media type (the WordCraft code wrote the text one only). - zip_guard: the entry-count check before zip parses a directory. No document content is read here.
This commit is contained in:
1 parent
7db4645d1f
commit
ce72c01bdc
12 files changed
+2003
No files matched your search
Generated
+17
@@ -1304,6 +1304,23 @@ dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-odf"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.23.1",
|
||||
"craft-xml",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"hmac",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.1",
|
||||
"thiserror 2.0.21",
|
||||
"zeroize",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-offcrypto"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -66,6 +66,7 @@ craft-media = { path = "crates/media", default-features = false }
|
||||
craft-media-testkit = { path = "crates/media-testkit" }
|
||||
craft-midi = { path = "crates/midi" }
|
||||
craft-mime = { path = "crates/mime" }
|
||||
craft-odf = { path = "crates/odf" }
|
||||
craft-prc = { path = "crates/prc" }
|
||||
craft-lottie = { path = "crates/lottie" }
|
||||
craft-menu = { path = "crates/menu" }
|
||||
|
||||
@@ -201,6 +201,7 @@ 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-odf`](crates/odf/README.md): OpenDocument packages (ODF 1.3 Part 2), read and written: the ZIP container with its `mimetype` and manifest within limits, password protection (AES-256-CBC with PBKDF2, as LibreOffice writes it) and flat single-file documents. The container only: what the XML inside means belongs to the readers of each document type.
|
||||
- [`craft-prc`](crates/prc/README.md): a reader for PRC (ISO 14739-1), the 3D model format of PDF 3D
|
||||
annotations: container, bit-level primitives, schema, tessellations and the product tree as
|
||||
placed, coloured triangle meshes and a model tree. Bounded on hostile input. From PdfCraft;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "craft-odf"
|
||||
description = "OpenDocument packages: the ZIP container with its mimetype and manifest, password protection (AES-256-CBC, PBKDF2) and flat single-file documents, read and written."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-xml = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
# `deflate-flate2` with flate2's default miniz_oxide backend, as craft-update pins it (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"] }
|
||||
base64 = "0.23"
|
||||
# Password-protected packages (ODF 1.3 Part 2 section 3.4): AES-256-CBC, SHA-1/SHA-256, PBKDF2
|
||||
# (HMAC-SHA-1).
|
||||
aes = "0.9"
|
||||
sha1 = "0.11"
|
||||
sha2 = "0.11"
|
||||
hmac = "0.13"
|
||||
getrandom = "0.3"
|
||||
zeroize = "1"
|
||||
|
||||
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
|
||||
# The browser's `crypto.getRandomValues` for the salts and vectors of encrypted saves.
|
||||
getrandom = { version = "0.3", features = ["wasm_js"] }
|
||||
@@ -0,0 +1,69 @@
|
||||
# craft-odf
|
||||
|
||||
OpenDocument packages (ODF 1.3 Part 2): what every OpenDocument file has in common, whatever it
|
||||
holds. Text documents, spreadsheets, presentations and drawings all sit in the same container;
|
||||
this crate reads and writes that container and nothing inside it.
|
||||
|
||||
| Module | What it does |
|
||||
| --- | --- |
|
||||
| `Package`, `PackageWriter` | The ZIP container: the `mimetype` file, the manifest, the files, within `Limits`. |
|
||||
| `Manifest` | `META-INF/manifest.xml`: each file's media type, and how an encrypted file is encrypted. Read by namespace, whatever prefix the file uses. |
|
||||
| `crypt` | Password-protected packages: AES-256-CBC, SHA-256 start key, PBKDF2 (HMAC-SHA-1), SHA-256 or SHA-1 checksums. A package in, a package out. |
|
||||
| `flat` | Flat single-file documents (`.fodt`, `.fods`, `.fodp`, `.fodg`): one `office:document` taken apart into a package's files, and a package put together into one. |
|
||||
| `zip_guard` | Refuses a ZIP whose end record declares too many entries, before `zip` reserves room for them. |
|
||||
|
||||
```rust
|
||||
use craft_odf::{Limits, Package, PackageWriter, crypt, flat, mime};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut w = PackageWriter::new(mime::SPREADSHEET);
|
||||
w.file("content.xml", "text/xml", b"<office:document-content/>");
|
||||
w.stored("Pictures/chart.png", "image/png", b"...");
|
||||
let plain = w.finish()?;
|
||||
|
||||
let locked = crypt::encrypt(&plain, "password")?;
|
||||
assert!(crypt::is_encrypted(&locked));
|
||||
let opened = crypt::decrypt(&locked, "password")?;
|
||||
|
||||
let mut p = Package::open(&opened, &Limits::DEFAULT)?;
|
||||
assert_eq!(p.mimetype().as_deref(), Some(mime::SPREADSHEET));
|
||||
assert_eq!(p.read("content.xml")?.as_deref(), Some(b"<office:document-content/>".as_slice()));
|
||||
|
||||
let one_file = flat::from_package(&opened, &Limits::DEFAULT)?;
|
||||
let again = flat::to_package(&one_file, mime::SPREADSHEET, &Limits::DEFAULT)?;
|
||||
assert_eq!(Package::open(&again, &Limits::DEFAULT)?.mimetype().as_deref(), Some(mime::SPREADSHEET));
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## Limits
|
||||
|
||||
Input is hostile. Nothing here panics on any bytes.
|
||||
|
||||
| Limit | Default | Where |
|
||||
| --- | --- | --- |
|
||||
| Entries a ZIP may declare | 20,000 | `Limits::max_entries`, checked on the raw bytes before `zip` parses the directory |
|
||||
| Bytes one file inflates to | 256 MiB | `Limits::max_entry`; the size a file declares is not trusted |
|
||||
| Bytes read from one package | 2 GiB | `Limits::max_total` |
|
||||
| `mimetype` file | 255 bytes | longer is no media type |
|
||||
| Manifest entries | 20,000 | the rest are dropped |
|
||||
| PBKDF2 iterations for one file | 10,000,000 | more is refused |
|
||||
| Key-derivation work for a package | 200,000,000 iteration-blocks | a thousand files as LibreOffice writes them |
|
||||
| Files decrypted | 10,000 | |
|
||||
| Pictures and objects taken out of a flat document | 10,000 | the rest are dropped |
|
||||
| Nesting of one element name in a flat document | 512 | deeper is treated as unclosed |
|
||||
|
||||
## Not supported
|
||||
|
||||
- Blowfish CFB (the default before ODF 1.2) and OpenPGP encryption: such packages are refused with
|
||||
a message that names the algorithm.
|
||||
- Digital signatures (`META-INF/documentsignatures.xml`) are not checked or written; the file is
|
||||
kept like any other.
|
||||
- The meaning of `content.xml`, `styles.xml`, `meta.xml` and `settings.xml`.
|
||||
|
||||
## Provenance
|
||||
|
||||
Moved from WordCraft (`crates/formats/src/odt/crypt.rs`, `odt/flat.rs`, the package reading and
|
||||
writing in `odt/import.rs` and `odt/package.rs`, and `crates/doc/src/zip_guard.rs`), with
|
||||
GridCraft's namespace-aware manifest reading. Written from the OASIS OpenDocument 1.3
|
||||
specification, W3C XML Encryption (padding), RFC 2898 (PBKDF2) and PKWARE's APPNOTE.TXT.
|
||||
@@ -0,0 +1,409 @@
|
||||
//! Password-protected packages (ODF 1.3 Part 2 §3.4, §4.4–4.16): each encrypted file is
|
||||
//! deflated, then encrypted with a key derived from the password, and described in the
|
||||
//! manifest (`manifest:encryption-data`). Read: the package decrypted into a plain one, which
|
||||
//! the reader opens as any other. Written as LibreOffice writes it: AES-256-CBC, a SHA-256
|
||||
//! start key, PBKDF2 with HMAC-SHA-1 and a salt of its own for each file, a SHA-256 checksum of
|
||||
//! the first 1024 bytes of each deflated file.
|
||||
//!
|
||||
//! Blowfish (the old default, §3.4.2) isn't supported: such files say so.
|
||||
//!
|
||||
//! [`decrypt`] gives a plain package, which a reader opens as any other; [`encrypt`] takes a
|
||||
//! plain package. Neither looks at what the files hold.
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
use aes::cipher::{BlockCipherDecrypt, BlockCipherEncrypt, KeyInit};
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use hmac::Mac;
|
||||
use sha2::Digest;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::manifest::{Encryption as Params, MANIFEST_PATH, Manifest};
|
||||
use crate::{Limits, zip_guard};
|
||||
|
||||
/// Most bytes one file of a package decrypts or inflates to.
|
||||
const MAX_ENTRY: u64 = Limits::DEFAULT.max_entry;
|
||||
/// Most entries a package may declare.
|
||||
const MAX_ZIP_ENTRIES: u64 = Limits::DEFAULT.max_entries;
|
||||
|
||||
/// Why an encrypted package can't be opened, or a package can't be encrypted.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CryptError {
|
||||
/// The password doesn't decrypt it (the checksums don't match).
|
||||
WrongPassword,
|
||||
/// An encryption that isn't supported, a broken package, or (saving) no random numbers.
|
||||
Unsupported(String),
|
||||
}
|
||||
|
||||
impl std::error::Error for CryptError {}
|
||||
|
||||
impl std::fmt::Display for CryptError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CryptError::WrongPassword => write!(f, "the password is incorrect"),
|
||||
CryptError::Unsupported(why) => write!(f, "{why}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const AES256_CBC: &str = "http://www.w3.org/2001/04/xmlenc#aes256-cbc";
|
||||
const SHA256: &str = "http://www.w3.org/2000/09/xmldsig#sha256";
|
||||
const SHA256_1K: &str = "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0#sha256-1k";
|
||||
/// PBKDF2 iterations written (LibreOffice's), and most read (a hostile count would hang).
|
||||
const ITERATIONS: u32 = 100_000;
|
||||
const MAX_ITERATIONS: u32 = 10_000_000;
|
||||
/// Most files in a package decrypted.
|
||||
const MAX_FILES: usize = 10_000;
|
||||
/// Most PBKDF2 work (iterations times the key's 20-byte blocks) for a whole package: a thousand
|
||||
/// files as LibreOffice writes them (100,000 iterations, a 32-byte key), so many
|
||||
/// files at the per-file cap can't add up to a hang.
|
||||
const MAX_WORK: u64 = 200_000_000;
|
||||
|
||||
/// `p`'s key derivation added to `spent`, an error once the package's work passes [`MAX_WORK`].
|
||||
fn charge(spent: &mut u64, p: &Params) -> Result<(), CryptError> {
|
||||
let blocks = p.key_size.unwrap_or(16).clamp(16, 32).div_ceil(20) as u64;
|
||||
*spent = spent.saturating_add(u64::from(p.iterations.min(MAX_ITERATIONS)).saturating_mul(blocks));
|
||||
if *spent > MAX_WORK {
|
||||
return Err(CryptError::Unsupported("the package's key derivations would take too long".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Each encrypted file's parameters and declared size, by path, from a manifest.
|
||||
fn read_manifest(xml: &[u8]) -> Vec<(String, Params, Option<usize>)> {
|
||||
Manifest::parse(xml)
|
||||
.entries
|
||||
.into_iter()
|
||||
.filter_map(|e| Some((e.path, e.encryption?, e.size.and_then(|s| usize::try_from(s).ok()))))
|
||||
.take(MAX_FILES)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `name` read from `z`, at most [`MAX_ENTRY`] bytes of it.
|
||||
fn read_entry(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str) -> Option<Vec<u8>> {
|
||||
let f = z.by_name(name).ok()?;
|
||||
if f.size() > MAX_ENTRY {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
f.take(MAX_ENTRY).read_to_end(&mut out).ok()?;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Does this package hold encrypted files?
|
||||
pub fn is_encrypted(bytes: &[u8]) -> bool {
|
||||
// A package with too many entries isn't parsed here: opening it refuses it (`zip_guard`).
|
||||
if zip_guard::check(bytes, MAX_ZIP_ENTRIES).is_err() {
|
||||
return false;
|
||||
}
|
||||
let Ok(mut z) = zip::ZipArchive::new(Cursor::new(bytes)) else { return false };
|
||||
read_entry(&mut z, MANIFEST_PATH).is_some_and(|m| !read_manifest(&m).is_empty())
|
||||
}
|
||||
|
||||
/// The start key: the password's UTF-8 digest (§4.16.6), `size` bytes of it.
|
||||
fn start_key(name: &str, size: Option<usize>, password: &str) -> Result<Zeroizing<Vec<u8>>, CryptError> {
|
||||
let mut k = match name {
|
||||
SHA256 | "SHA256" => sha2::Sha256::digest(password.as_bytes()).to_vec(),
|
||||
"SHA1" | "http://www.w3.org/2000/09/xmldsig#sha1" => sha1::Sha1::digest(password.as_bytes()).to_vec(),
|
||||
other => return Err(CryptError::Unsupported(format!("a start key made with {other} isn't supported"))),
|
||||
};
|
||||
if let Some(s) = size.filter(|s| *s > 0 && *s < k.len()) {
|
||||
k.truncate(s);
|
||||
}
|
||||
Ok(Zeroizing::new(k))
|
||||
}
|
||||
|
||||
/// PBKDF2 with HMAC-SHA-1 (RFC 2898 §5.2): `len` bytes.
|
||||
fn pbkdf2(password: &[u8], salt: &[u8], iterations: u32, len: usize) -> Result<Zeroizing<Vec<u8>>, CryptError> {
|
||||
let mac = hmac::Hmac::<sha1::Sha1>::new_from_slice(password).map_err(|_| CryptError::Unsupported("bad key".into()))?;
|
||||
let mut out = Zeroizing::new(Vec::with_capacity(len + 20));
|
||||
let mut block = 1u32;
|
||||
while out.len() < len {
|
||||
let mut m = mac.clone();
|
||||
m.update(salt);
|
||||
m.update(&block.to_be_bytes());
|
||||
let mut u = m.finalize().into_bytes();
|
||||
let mut t = u;
|
||||
for _ in 1..iterations {
|
||||
let mut m = mac.clone();
|
||||
m.update(&u);
|
||||
u = m.finalize().into_bytes();
|
||||
t.iter_mut().zip(u.iter()).for_each(|(a, b)| *a ^= b);
|
||||
}
|
||||
out.extend_from_slice(&t);
|
||||
block = block.saturating_add(1);
|
||||
}
|
||||
out.truncate(len);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The key for a file with parameters `p`.
|
||||
fn file_key(p: &Params, password: &str) -> Result<Zeroizing<Vec<u8>>, CryptError> {
|
||||
let start = start_key(&p.start_key, p.start_key_size, password)?;
|
||||
if !(p.derivation == "PBKDF2" || p.derivation.ends_with("#pbkdf2")) {
|
||||
return Err(CryptError::Unsupported(format!("key derivation {:?} isn't supported", p.derivation)));
|
||||
}
|
||||
if p.iterations == 0 || p.iterations > MAX_ITERATIONS {
|
||||
return Err(CryptError::Unsupported("the key derivation's iteration count is out of range".into()));
|
||||
}
|
||||
pbkdf2(&start, &p.salt, p.iterations, p.key_size.unwrap_or(16).clamp(16, 32))
|
||||
}
|
||||
|
||||
/// AES-256-CBC, `data` whole blocks.
|
||||
fn cbc(key: &[u8], iv: &[u8], data: &[u8], encrypt: bool) -> Result<Vec<u8>, CryptError> {
|
||||
let aes = aes::Aes256::new_from_slice(key).map_err(|_| CryptError::Unsupported("the key isn't 256 bits".into()))?;
|
||||
let mut prev: [u8; 16] = iv.try_into().map_err(|_| CryptError::Unsupported("the initialisation vector isn't 16 bytes".into()))?;
|
||||
let (blocks, rest) = data.as_chunks::<16>();
|
||||
if !rest.is_empty() {
|
||||
return Err(CryptError::Unsupported("encrypted data isn't a whole number of blocks".into()));
|
||||
}
|
||||
let mut out = Vec::with_capacity(data.len());
|
||||
for b in blocks {
|
||||
if encrypt {
|
||||
let mut x = aes::Block::from(*b);
|
||||
x.iter_mut().zip(prev.iter()).for_each(|(a, p)| *a ^= p);
|
||||
aes.encrypt_block(&mut x);
|
||||
prev = x.into();
|
||||
out.extend_from_slice(&prev);
|
||||
} else {
|
||||
let mut x = aes::Block::from(*b);
|
||||
aes.decrypt_block(&mut x);
|
||||
x.iter_mut().zip(prev.iter()).for_each(|(a, p)| *a ^= p);
|
||||
out.extend_from_slice(&x);
|
||||
prev = *b;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The checksum of a deflated file (§4.16.3): of its first 1024 bytes.
|
||||
fn checksum(kind: &str, deflated: &[u8]) -> Result<Vec<u8>, CryptError> {
|
||||
let head = deflated.get(..deflated.len().min(1024)).unwrap_or_default();
|
||||
match kind {
|
||||
SHA256_1K => Ok(sha2::Sha256::digest(head).to_vec()),
|
||||
"SHA1/1K" | "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0#sha1-1k" => Ok(sha1::Sha1::digest(head).to_vec()),
|
||||
other => Err(CryptError::Unsupported(format!("checksum {other} isn't supported"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// One encrypted file decrypted and inflated.
|
||||
fn decrypt_file(data: &[u8], p: &Params, size: Option<usize>, password: &str) -> Result<Vec<u8>, CryptError> {
|
||||
if p.algorithm != AES256_CBC {
|
||||
let what = if p.algorithm.to_ascii_lowercase().contains("blowfish") { "Blowfish".to_string() } else { format!("{:?}", p.algorithm) };
|
||||
return Err(CryptError::Unsupported(format!("this file is encrypted with {what}, which isn't supported")));
|
||||
}
|
||||
let key = file_key(p, password)?;
|
||||
let plain = Zeroizing::new(cbc(&key, &p.iv, data, false)?);
|
||||
// W3C XML Encryption padding: the last byte says how many bytes were added.
|
||||
let pad = usize::from(plain.last().copied().unwrap_or(0));
|
||||
let deflated = (1..=16).contains(&pad).then(|| plain.get(..plain.len().saturating_sub(pad))).flatten().ok_or(CryptError::WrongPassword)?;
|
||||
if !p.checksum.is_empty() && checksum(&p.checksum_type, deflated)? != p.checksum {
|
||||
return Err(CryptError::WrongPassword);
|
||||
}
|
||||
let limit = size.unwrap_or(MAX_ENTRY as usize).min(MAX_ENTRY as usize);
|
||||
inflate(deflated, limit).ok_or(CryptError::WrongPassword)
|
||||
}
|
||||
|
||||
/// Raw deflate data inflated, `None` when it is damaged or longer than `limit`.
|
||||
fn inflate(deflated: &[u8], limit: usize) -> Option<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
flate2::read::DeflateDecoder::new(deflated).take(limit as u64 + 1).read_to_end(&mut out).ok()?;
|
||||
(out.len() <= limit).then_some(out)
|
||||
}
|
||||
|
||||
fn deflate(data: &[u8]) -> Result<Vec<u8>, std::io::Error> {
|
||||
let mut e = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::new(6));
|
||||
e.write_all(data)?;
|
||||
e.finish()
|
||||
}
|
||||
|
||||
/// The manifest without its encryption data (each entry keeps its `manifest:size`).
|
||||
fn plain_manifest(xml: &str) -> String {
|
||||
let mut out = String::with_capacity(xml.len());
|
||||
let mut rest = xml;
|
||||
while let Some(i) = rest.find("<manifest:encryption-data") {
|
||||
out.push_str(rest.get(..i).unwrap_or(""));
|
||||
let after = rest.get(i..).unwrap_or("");
|
||||
let end =
|
||||
after.find("</manifest:encryption-data>").map(|e| e + "</manifest:encryption-data>".len()).or_else(|| after.find("/>").map(|e| e + 2));
|
||||
rest = end.and_then(|e| after.get(e..)).unwrap_or("");
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// A password-protected package decrypted into a plain one.
|
||||
pub fn decrypt(bytes: &[u8], password: &str) -> Result<Vec<u8>, CryptError> {
|
||||
let broken = |e: zip::result::ZipError| CryptError::Unsupported(format!("not a valid package ({e})"));
|
||||
zip_guard::check(bytes, MAX_ZIP_ENTRIES).map_err(CryptError::Unsupported)?;
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(broken)?;
|
||||
let manifest = read_entry(&mut z, MANIFEST_PATH).unwrap_or_default();
|
||||
let encrypted = read_manifest(&manifest);
|
||||
let mut w = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
let deflated = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
let io = |e: std::io::Error| CryptError::Unsupported(e.to_string());
|
||||
let mut checked = false;
|
||||
let mut work = 0u64;
|
||||
for i in 0..z.len().min(MAX_FILES) {
|
||||
let Ok(mut f) = z.by_index(i) else { continue };
|
||||
let Ok(name) = f.name().map(|n| n.into_owned()) else { continue };
|
||||
if f.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
f.by_ref().take(MAX_ENTRY).read_to_end(&mut data).map_err(io)?;
|
||||
let data = match encrypted.iter().find(|(p, ..)| *p == name) {
|
||||
Some((_, p, size)) => {
|
||||
charge(&mut work, p)?;
|
||||
let plain = decrypt_file(&data, p, *size, password)?;
|
||||
checked = true;
|
||||
plain
|
||||
}
|
||||
None if name == MANIFEST_PATH => plain_manifest(&String::from_utf8_lossy(&data)).into_bytes(),
|
||||
None => data,
|
||||
};
|
||||
w.start_file(name.as_str(), if name == "mimetype" { stored } else { deflated }).map_err(broken)?;
|
||||
w.write_all(&data).map_err(io)?;
|
||||
}
|
||||
if !checked {
|
||||
return Err(CryptError::Unsupported("no encrypted file could be read".into()));
|
||||
}
|
||||
Ok(w.finish().map_err(broken)?.into_inner())
|
||||
}
|
||||
|
||||
/// An attribute value's text (the manifest's own entity escapes).
|
||||
fn unescape(s: &str) -> String {
|
||||
s.replace(""", "\"").replace("'", "'").replace("<", "<").replace(">", ">").replace("&", "&")
|
||||
}
|
||||
|
||||
fn random(n: usize) -> Result<Vec<u8>, CryptError> {
|
||||
let mut v = vec![0u8; n];
|
||||
getrandom::fill(&mut v).map_err(|e| CryptError::Unsupported(format!("no random numbers for encryption ({e})")))?;
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// A package encrypted with `password`: every file but the `mimetype` and the manifest, the
|
||||
/// manifest saying how.
|
||||
pub fn encrypt(bytes: &[u8], password: &str) -> Result<Vec<u8>, CryptError> {
|
||||
let err = |e: zip::result::ZipError| CryptError::Unsupported(e.to_string());
|
||||
let io = |e: std::io::Error| CryptError::Unsupported(e.to_string());
|
||||
zip_guard::check(bytes, MAX_ZIP_ENTRIES).map_err(CryptError::Unsupported)?;
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(err)?;
|
||||
let start = start_key(SHA256, Some(32), password)?;
|
||||
let mut files: Vec<(String, Vec<u8>, bool)> = Vec::new();
|
||||
let mut manifest = String::new();
|
||||
// name → its encryption data and size, for the manifest.
|
||||
let mut data_of: Vec<(String, String)> = Vec::new();
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).map_err(err)?;
|
||||
if f.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Ok(name) = f.name().map(|n| n.into_owned()) else { continue };
|
||||
let mut data = Vec::new();
|
||||
f.read_to_end(&mut data).map_err(io)?;
|
||||
if name == MANIFEST_PATH {
|
||||
manifest = String::from_utf8_lossy(&data).into_owned();
|
||||
continue;
|
||||
}
|
||||
if name == "mimetype" {
|
||||
files.push((name, data, false));
|
||||
continue;
|
||||
}
|
||||
let size = data.len();
|
||||
let mut deflated = deflate(&data).map_err(io)?;
|
||||
let sum = checksum(SHA256_1K, &deflated)?;
|
||||
// W3C XML Encryption padding: random bytes, the last one their count.
|
||||
let pad = 16 - deflated.len() % 16;
|
||||
let mut tail = random(pad)?;
|
||||
if let Some(last) = tail.last_mut() {
|
||||
*last = pad as u8;
|
||||
}
|
||||
deflated.extend_from_slice(&tail);
|
||||
let (salt, iv) = (random(16)?, random(16)?);
|
||||
let key = pbkdf2(&start, &salt, ITERATIONS, 32)?;
|
||||
let enc = cbc(&key, &iv, &deflated, true)?;
|
||||
data_of.push((
|
||||
name.clone(),
|
||||
format!(
|
||||
" manifest:size=\"{size}\"><manifest:encryption-data manifest:checksum-type=\"{SHA256_1K}\" manifest:checksum=\"{}\"><manifest:algorithm manifest:algorithm-name=\"{AES256_CBC}\" manifest:initialisation-vector=\"{}\"/><manifest:start-key-generation manifest:start-key-generation-name=\"{SHA256}\" manifest:key-size=\"32\"/><manifest:key-derivation manifest:key-derivation-name=\"PBKDF2\" manifest:iteration-count=\"{ITERATIONS}\" manifest:salt=\"{}\" manifest:key-size=\"32\"/></manifest:encryption-data></manifest:file-entry>",
|
||||
STANDARD.encode(&sum),
|
||||
STANDARD.encode(&iv),
|
||||
STANDARD.encode(&salt)
|
||||
),
|
||||
));
|
||||
files.push((name, enc, true));
|
||||
}
|
||||
// Each encrypted file's entry gets its size and encryption data; a file the manifest
|
||||
// doesn't list gets an entry of its own.
|
||||
let mut listed = std::collections::HashSet::new();
|
||||
let mut out = String::with_capacity(manifest.len() + data_of.len() * 600);
|
||||
let mut rest = manifest.as_str();
|
||||
while let Some(i) = rest.find("<manifest:file-entry ") {
|
||||
out.push_str(rest.get(..i).unwrap_or(""));
|
||||
let after = rest.get(i..).unwrap_or("");
|
||||
let Some(end) = after.find("/>") else { break };
|
||||
let tag = after.get(..end).unwrap_or("");
|
||||
let path = tag.split("manifest:full-path=\"").nth(1).and_then(|r| r.split('"').next()).map(unescape).unwrap_or_default();
|
||||
match data_of.iter().find(|(n, _)| *n == path) {
|
||||
Some((n, enc)) => {
|
||||
listed.insert(n.clone());
|
||||
out.push_str(tag);
|
||||
out.push_str(enc);
|
||||
}
|
||||
None => out.push_str(after.get(..end + 2).unwrap_or("")),
|
||||
}
|
||||
rest = after.get(end + 2..).unwrap_or("");
|
||||
}
|
||||
let unlisted: String = data_of
|
||||
.iter()
|
||||
.filter(|(n, _)| !listed.contains(n))
|
||||
.map(|(n, enc)| format!("<manifest:file-entry manifest:full-path=\"{}\" manifest:media-type=\"\"{enc}\n", craft_xml::esc_attr(n)))
|
||||
.collect();
|
||||
match rest.rfind("</manifest:manifest>") {
|
||||
Some(e) => {
|
||||
out.push_str(rest.get(..e).unwrap_or(""));
|
||||
out.push_str(&unlisted);
|
||||
out.push_str(rest.get(e..).unwrap_or(""));
|
||||
}
|
||||
None => out.push_str(rest),
|
||||
}
|
||||
let mut w = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
let deflated = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
for (name, data, _) in files.iter().filter(|(n, _, _)| n == "mimetype") {
|
||||
w.start_file(name.as_str(), stored).map_err(err)?;
|
||||
w.write_all(data).map_err(io)?;
|
||||
}
|
||||
w.start_file(MANIFEST_PATH, deflated).map_err(err)?;
|
||||
w.write_all(out.as_bytes()).map_err(io)?;
|
||||
// Encrypted files are stored: they're deflated already (§3.4.1).
|
||||
for (name, data, _) in files.iter().filter(|(n, _, _)| n != "mimetype") {
|
||||
w.start_file(name.as_str(), stored).map_err(err)?;
|
||||
w.write_all(data).map_err(io)?;
|
||||
}
|
||||
Ok(w.finish().map_err(err)?.into_inner())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod work_tests {
|
||||
use super::*;
|
||||
|
||||
/// Key derivations are budgeted over the whole package, not only per file: a thousand of
|
||||
/// LibreOffice's fit, twenty-one at the per-file cap don't.
|
||||
#[test]
|
||||
fn a_packages_key_derivations_share_one_budget() {
|
||||
let ours = Params { iterations: ITERATIONS, key_size: Some(32), ..Default::default() };
|
||||
let mut spent = 0;
|
||||
for _ in 0..1000 {
|
||||
assert!(charge(&mut spent, &ours).is_ok());
|
||||
}
|
||||
assert!(charge(&mut spent, &ours).is_err());
|
||||
let hostile = Params { iterations: MAX_ITERATIONS, key_size: Some(16), ..Default::default() };
|
||||
let mut spent = 0;
|
||||
let ok = (0..30).take_while(|_| charge(&mut spent, &hostile).is_ok()).count();
|
||||
assert_eq!(ok, 20);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
//! Flat OpenDocument files (`.fodt`, `.fods`, `.fodp`, `.fodg`; ODF 1.3 Part 3 §2.1.2 and
|
||||
//! §3.1.2): the whole document in one `office:document` element, its pictures inline
|
||||
//! (`office:binary-data`, base64) and its embedded objects inline (`math:math` or another
|
||||
//! `office:document` inside `draw:object`). [`to_package`] takes one apart into a package's
|
||||
//! files; [`from_package`] puts a package's files together. Neither looks at what the document
|
||||
//! says.
|
||||
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
|
||||
use crate::{Error, Limits, Package, PackageWriter};
|
||||
|
||||
/// Deepest nesting of one element name followed (deeper is malformed).
|
||||
const MAX_XML_DEPTH: usize = 512;
|
||||
|
||||
/// The top-level parts of a flat document, in the order a package's files take them.
|
||||
const META: &str = "office:meta";
|
||||
const SETTINGS: &str = "office:settings";
|
||||
const FONTS: &str = "office:font-face-decls";
|
||||
const STYLES: &str = "office:styles";
|
||||
const AUTOMATIC: &str = "office:automatic-styles";
|
||||
const MASTERS: &str = "office:master-styles";
|
||||
const BODY: &str = "office:body";
|
||||
|
||||
/// Most pictures and formulas taken out of a flat document (hostile files).
|
||||
const MAX_INLINE: usize = 10_000;
|
||||
|
||||
/// Element `name` in `xml` (its first one, start to end, or self-closed); "" when there is none.
|
||||
fn element<'a>(xml: &'a str, name: &str) -> &'a str {
|
||||
let open = format!("<{name}");
|
||||
let Some(start) = xml
|
||||
.match_indices(&open)
|
||||
.map(|(i, _)| i)
|
||||
.find(|&i| xml.get(i + open.len()..).and_then(|r| r.chars().next()).is_some_and(|c| c == '>' || c == '/' || c.is_whitespace()))
|
||||
else {
|
||||
return "";
|
||||
};
|
||||
let Some(tag_end) = xml.get(start..).and_then(|r| r.find('>')).map(|e| start + e) else { return "" };
|
||||
if xml.get(..tag_end).is_some_and(|t| t.ends_with('/')) {
|
||||
return xml.get(start..=tag_end).unwrap_or("");
|
||||
}
|
||||
// Its end: the close tag at its own depth (a chart object inside holds an `office:body`,
|
||||
// an `office:document` of its own).
|
||||
let close = format!("</{name}>");
|
||||
let mut depth = 0usize;
|
||||
let mut at = tag_end;
|
||||
// Every step moves past a tag, so this ends; nesting is bounded like any XML here (deeper is
|
||||
// malformed), however many elements of the name sit side by side within.
|
||||
loop {
|
||||
let rest = xml.get(at..).unwrap_or("");
|
||||
let next_close = rest.find(&close);
|
||||
// Only opens before that close count (and only they are looked for).
|
||||
let before = next_close.and_then(|c| rest.get(..c)).unwrap_or(rest);
|
||||
let next_open = before.match_indices(&open).map(|(i, _)| i).find(|&i| {
|
||||
let after = rest.get(i + open.len()..).unwrap_or("");
|
||||
after.chars().next().is_some_and(|c| c == '>' || c == '/' || c.is_whitespace())
|
||||
// Not self-closed.
|
||||
&& after.find('>').and_then(|e| after.get(..e)).is_some_and(|t| !t.ends_with('/'))
|
||||
});
|
||||
match (next_open, next_close) {
|
||||
(Some(o), Some(c)) if o < c => {
|
||||
depth += 1;
|
||||
if depth > MAX_XML_DEPTH {
|
||||
return xml.get(start..).unwrap_or("");
|
||||
}
|
||||
at += o + open.len();
|
||||
}
|
||||
(_, Some(c)) if depth > 0 => {
|
||||
depth -= 1;
|
||||
at += c + close.len();
|
||||
}
|
||||
(_, Some(c)) => return xml.get(start..at + c + close.len()).unwrap_or(""),
|
||||
// Unclosed (malformed): to the end.
|
||||
(_, None) => return xml.get(start..).unwrap_or(""),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What is inside element `el` (an element string as [`element`] gives).
|
||||
fn inner(el: &str) -> &str {
|
||||
let Some(tag_end) = el.find('>') else { return "" };
|
||||
if el.get(..tag_end).is_some_and(|t| t.ends_with('/')) {
|
||||
return "";
|
||||
}
|
||||
let end = el.rfind("</").unwrap_or(el.len());
|
||||
el.get(tag_end + 1..end.max(tag_end + 1)).unwrap_or("")
|
||||
}
|
||||
|
||||
/// The extension of picture `data`, by its first bytes.
|
||||
fn picture_ext(data: &[u8]) -> &'static str {
|
||||
if data.starts_with(b"\x89PNG") {
|
||||
"png"
|
||||
} else if data.starts_with(b"\xff\xd8") {
|
||||
"jpg"
|
||||
} else if data.starts_with(b"GIF8") {
|
||||
"gif"
|
||||
} else if data.starts_with(b"<svg") || data.starts_with(b"<?xml") {
|
||||
"svg"
|
||||
} else {
|
||||
"bin"
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the first `draw:object` holding its object inline (no `xlink:href`, not self-closed)
|
||||
/// starts in `xml`.
|
||||
fn inline_object(xml: &str) -> Option<usize> {
|
||||
let mut from = 0;
|
||||
while let Some(i) = xml.get(from..).and_then(|r| r.find("<draw:object")).map(|i| from + i) {
|
||||
let tag = xml.get(i..).and_then(|r| r.find('>').and_then(|e| r.get(..e))).unwrap_or("");
|
||||
let next = tag.get("<draw:object".len()..).and_then(|r| r.chars().next());
|
||||
if next.is_none_or(|c| c.is_whitespace()) && !tag.contains("xlink:href") && !tag.ends_with('/') {
|
||||
return Some(i);
|
||||
}
|
||||
from = i + "<draw:object".len();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `xml` with its inline pictures and formulas moved into `files` (named as a package names
|
||||
/// them) and pointed at there, as a package's content holds them.
|
||||
fn take_out(xml: &str, files: &mut Vec<(String, Vec<u8>)>, folders: &mut Vec<(String, String)>, max_entry: u64) -> String {
|
||||
let mut out = String::with_capacity(xml.len());
|
||||
let mut rest = xml;
|
||||
loop {
|
||||
let pic = rest.find("<office:binary-data>");
|
||||
let obj = inline_object(rest);
|
||||
let (at, picture) = match (pic, obj) {
|
||||
(Some(p), Some(o)) if o < p => (o, false),
|
||||
(Some(p), _) => (p, true),
|
||||
(None, Some(o)) => (o, false),
|
||||
(None, None) => break,
|
||||
};
|
||||
let (before, from) = rest.split_at(at);
|
||||
let close = if picture { "</office:binary-data>" } else { "</draw:object>" };
|
||||
let Some(end) = from.find(close) else {
|
||||
out.push_str(before);
|
||||
rest = from;
|
||||
break;
|
||||
};
|
||||
let open_len = if picture { "<office:binary-data>".len() } else { from.find('>').map_or(0, |e| e + 1) };
|
||||
let body = from.get(open_len..end).unwrap_or("");
|
||||
let after = from.get(end + close.len()..).unwrap_or("");
|
||||
let n = files.len() + 1;
|
||||
if files.len() >= MAX_INLINE {
|
||||
out.push_str(before);
|
||||
rest = after;
|
||||
continue;
|
||||
}
|
||||
if picture {
|
||||
// The picture's `draw:image` start tag (just before) gets the package's reference.
|
||||
let b64: String = body.chars().filter(|c| !c.is_whitespace()).collect();
|
||||
let data = STANDARD.decode(b64.as_bytes()).ok().filter(|d| d.len() as u64 <= max_entry).unwrap_or_default();
|
||||
// The element holding the data: the start tag just before it.
|
||||
let holder = before.rfind('<').filter(|_| !data.is_empty()).and_then(|i| {
|
||||
let tag = before.get(i..).unwrap_or("");
|
||||
["<draw:image", "<draw:object-ole"]
|
||||
.into_iter()
|
||||
.find(|el| tag.starts_with(el) && tag.get(el.len()..).is_some_and(|r| r.starts_with([' ', '>', '\n', '\t', '\r'])))
|
||||
.map(|el| (i, el))
|
||||
});
|
||||
match holder {
|
||||
Some((at, el)) => {
|
||||
let name = if el == "<draw:image" { format!("Pictures/flat{n}.{}", picture_ext(&data)) } else { format!("Object flat{n}") };
|
||||
let (head, tag) = before.split_at(at);
|
||||
out.push_str(head);
|
||||
out.push_str(&format!("{el} xlink:href=\"{}\"", if el == "<draw:image" { name.clone() } else { format!("./{name}") }));
|
||||
out.push_str(tag.get(el.len()..).unwrap_or(""));
|
||||
files.push((name, data));
|
||||
}
|
||||
None => out.push_str(before),
|
||||
}
|
||||
} else {
|
||||
let dir = format!("Object flat{n}");
|
||||
out.push_str(before);
|
||||
out.push_str(&format!("<draw:object xlink:href=\"./{dir}\"/>"));
|
||||
// A sub-document (a chart, a spreadsheet) goes back into its files; a formula is
|
||||
// its `content.xml`.
|
||||
match package_parts(body.trim()) {
|
||||
Some(parts) => {
|
||||
if let Some(mime) = mimetype_of(body.trim()) {
|
||||
folders.push((format!("{dir}/"), mime));
|
||||
}
|
||||
for (name, xml) in ["content.xml", "styles.xml", "meta.xml", "settings.xml"].into_iter().zip(parts) {
|
||||
files.push((format!("{dir}/{name}"), xml.into_bytes()));
|
||||
}
|
||||
}
|
||||
None => files.push((format!("{dir}/content.xml"), body.as_bytes().to_vec())),
|
||||
}
|
||||
}
|
||||
rest = after;
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// A flat document taken apart.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct FlatDocument {
|
||||
/// The root's `office:mimetype`.
|
||||
pub mimetype: Option<String>,
|
||||
/// `content.xml`: fonts, automatic styles and the body.
|
||||
pub content: String,
|
||||
/// `styles.xml`: fonts, styles, automatic styles and master styles.
|
||||
pub styles: String,
|
||||
/// `meta.xml`.
|
||||
pub meta: String,
|
||||
/// `settings.xml`.
|
||||
pub settings: String,
|
||||
/// The pictures and embedded objects that were inline, named as a package names them
|
||||
/// (`Pictures/flat1.png`, `Object flat2/content.xml`); `content` and `styles` point at them.
|
||||
pub files: Vec<(String, Vec<u8>)>,
|
||||
/// The folders of embedded sub-documents and their media types (`Object flat2/`).
|
||||
pub folders: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// Takes a flat document apart. At most 10,000 pictures and objects are taken out (the rest
|
||||
/// are dropped), and a picture larger than [`Limits::max_entry`] is left out.
|
||||
pub fn split(bytes: &[u8], limits: &Limits) -> Result<FlatDocument, Error> {
|
||||
let xml = std::str::from_utf8(bytes).map_err(|_| Error::Flat("not UTF-8 text"))?;
|
||||
let root = element(xml, "office:document");
|
||||
let [content, styles, meta, settings] = package_parts(root).ok_or(Error::Flat("no office:document"))?;
|
||||
let (mut files, mut folders) = (Vec::new(), Vec::new());
|
||||
let content = take_out(&content, &mut files, &mut folders, limits.max_entry);
|
||||
let styles = take_out(&styles, &mut files, &mut folders, limits.max_entry);
|
||||
Ok(FlatDocument { mimetype: mimetype_of(root), content, styles, meta, settings, files, folders })
|
||||
}
|
||||
|
||||
/// A flat document as a package, of the media type its root declares, else `default_mime`.
|
||||
/// The files are stored, not deflated: the package is for a reader to open, not to keep.
|
||||
pub fn to_package(bytes: &[u8], default_mime: &str, limits: &Limits) -> Result<Vec<u8>, Error> {
|
||||
let doc = split(bytes, limits)?;
|
||||
let mut w = PackageWriter::new(doc.mimetype.as_deref().unwrap_or(default_mime));
|
||||
for (name, xml) in [("content.xml", &doc.content), ("styles.xml", &doc.styles), ("meta.xml", &doc.meta), ("settings.xml", &doc.settings)] {
|
||||
w.stored(name, "text/xml", xml.as_bytes());
|
||||
}
|
||||
for (path, mime) in &doc.folders {
|
||||
w.folder(path, mime);
|
||||
}
|
||||
for (name, data) in &doc.files {
|
||||
w.stored(name, if name.ends_with(".xml") { "text/xml" } else { "" }, data);
|
||||
}
|
||||
w.finish()
|
||||
}
|
||||
|
||||
/// A package as a flat document: its files in one `office:document`, the automatic styles of
|
||||
/// its content and its styles together, pictures and embedded objects inline.
|
||||
pub fn from_package(package: &[u8], limits: &Limits) -> Result<Vec<u8>, Error> {
|
||||
let mut z = Package::open(package, limits)?;
|
||||
let manifest = z.manifest();
|
||||
let mime = z
|
||||
.mimetype()
|
||||
.filter(|m| !m.is_empty())
|
||||
.or_else(|| manifest.media_type("/").map(str::to_string))
|
||||
.ok_or(Error::Flat("the package has no media type"))?;
|
||||
let mut bytes_of = |name: &str| -> Vec<u8> { z.read(name).ok().flatten().unwrap_or_default() };
|
||||
let mut read = |name: &str| -> String { String::from_utf8_lossy(&bytes_of(name)).into_owned() };
|
||||
let (content, styles, meta, settings) = (read("content.xml"), read("styles.xml"), read("meta.xml"), read("settings.xml"));
|
||||
let body = flat_parts(&content, &styles, &meta, &settings);
|
||||
// Pictures and formulas in the document, not beside it.
|
||||
let mut flat = String::with_capacity(body.len());
|
||||
let mut rest = body.as_str();
|
||||
while let Some(at) = rest.find("xlink:href=\"") {
|
||||
let (before, from) = rest.split_at(at);
|
||||
let target_start = "xlink:href=\"".len();
|
||||
let Some(len) = from.get(target_start..).and_then(|r| r.find('"')) else { break };
|
||||
let target = from.get(target_start..target_start + len).unwrap_or("");
|
||||
let after = from.get(target_start + len + 1..).unwrap_or("");
|
||||
let tag = before.rfind('<').and_then(|i| before.get(i..)).unwrap_or("");
|
||||
// A picture, or an embedded object's replacement picture.
|
||||
let picture = target.starts_with("Pictures/") || target.trim_start_matches("./").starts_with("ObjectReplacements/");
|
||||
// The rest of the linking element's start tag, whether it closes itself, and what follows
|
||||
// the tag (markup kept as read may have `<draw:image …></draw:image>`).
|
||||
let gt = after.find('>').unwrap_or(after.len());
|
||||
let closed = after.get(..gt).is_some_and(|t| t.ends_with('/'));
|
||||
let tag_rest = after.get(..gt).unwrap_or("").trim_end_matches('/');
|
||||
let past_tag = after.get(gt + 1..).unwrap_or("");
|
||||
// What follows the element: past its tag, or past its end tag.
|
||||
let past = |el: &str| {
|
||||
if closed { past_tag } else { past_tag.find(&format!("</{el}>")).and_then(|e| past_tag.get(e + el.len() + 3..)).unwrap_or("") }
|
||||
};
|
||||
// The link's other attributes go with it.
|
||||
let attrs: String = tag_rest.split_whitespace().filter(|a| !a.starts_with("xlink:")).map(|a| format!(" {a}")).collect();
|
||||
if tag.starts_with("<draw:image") && picture {
|
||||
// `<draw:image xlink:href="…" …/>` → `<draw:image …><office:binary-data>…`, before
|
||||
// anything else it holds.
|
||||
let data = bytes_of(target.trim_start_matches("./"));
|
||||
flat.push_str(before.trim_end());
|
||||
flat.push_str(&attrs);
|
||||
flat.push_str(&format!("><office:binary-data>{}</office:binary-data>", STANDARD.encode(&data)));
|
||||
if closed {
|
||||
flat.push_str("</draw:image>");
|
||||
}
|
||||
rest = past_tag;
|
||||
} else if tag.starts_with("<draw:object-ole") && target.starts_with("./Object ") {
|
||||
// An OLE object's file inline (§10.4.7 allows `office:binary-data` in it).
|
||||
let data = bytes_of(target.trim_start_matches("./"));
|
||||
flat.push_str(before.rfind("<draw:object-ole").and_then(|i| before.get(..i)).unwrap_or(before));
|
||||
flat.push_str(&format!("<draw:object-ole><office:binary-data>{}</office:binary-data></draw:object-ole>", STANDARD.encode(&data)));
|
||||
rest = past("draw:object-ole");
|
||||
} else if tag.starts_with("<draw:object") && target.starts_with("./Object ") {
|
||||
let dir = target.trim_start_matches("./");
|
||||
let mut text = |name: &str| String::from_utf8_lossy(&bytes_of(&format!("{dir}/{name}"))).into_owned();
|
||||
let content = text("content.xml");
|
||||
// A sub-document (a chart, a spreadsheet) goes in as an `office:document` (§3.1.2)
|
||||
// of its media type, its files together; a formula as its `math:math`.
|
||||
let math = if content.contains("<office:document-content") {
|
||||
let mime = manifest.media_type(&format!("{dir}/")).unwrap_or(crate::mime::GRAPHICS);
|
||||
let (styles, meta, settings) = (text("styles.xml"), text("meta.xml"), text("settings.xml"));
|
||||
flat_document(mime, &content, &styles, &meta, &settings)
|
||||
} else {
|
||||
content.split_once("?>").map_or(content.as_str(), |(_, m)| m).trim().to_string()
|
||||
};
|
||||
// A plain `draw:object` around it.
|
||||
flat.push_str(before.rfind("<draw:object").and_then(|i| before.get(..i)).unwrap_or(before));
|
||||
flat.push_str(&format!("<draw:object>{math}</draw:object>"));
|
||||
rest = past("draw:object");
|
||||
} else {
|
||||
flat.push_str(before);
|
||||
flat.push_str(from.get(..target_start + len + 1).unwrap_or(""));
|
||||
rest = after;
|
||||
}
|
||||
}
|
||||
flat.push_str(rest);
|
||||
Ok(format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<office:document {} office:version=\"1.3\" office:mimetype=\"{}\">{flat}</office:document>\n",
|
||||
merged_namespaces(&[&content, &styles, &meta, &settings]),
|
||||
craft_xml::esc_attr(&mime)
|
||||
)
|
||||
.into_bytes())
|
||||
}
|
||||
|
||||
/// Every namespace the parts declare (the settings' `config`, say), each prefix once.
|
||||
fn merged_namespaces(parts: &[&str]) -> String {
|
||||
let mut ns: Vec<&str> = Vec::new();
|
||||
for part in parts {
|
||||
for decl in namespaces(part) {
|
||||
let prefix = decl.split('=').next().unwrap_or("");
|
||||
if !ns.iter().any(|d| d.split('=').next() == Some(prefix)) {
|
||||
ns.push(decl);
|
||||
}
|
||||
}
|
||||
}
|
||||
ns.join(" ")
|
||||
}
|
||||
|
||||
/// What a flat document holds, from a package's parts in the order §3.1.2 gives them: one
|
||||
/// font declaration list and one automatic styles list for its content and its styles together.
|
||||
fn flat_parts(content: &str, styles: &str, meta: &str, settings: &str) -> String {
|
||||
let both = |name: &str| {
|
||||
let (a, b) = (element(styles, name), element(content, name));
|
||||
if a.is_empty() && b.is_empty() { String::new() } else { format!("<{name}>{}{}</{name}>", inner(a), inner(b)) }
|
||||
};
|
||||
[
|
||||
element(meta, META).to_string(),
|
||||
element(settings, SETTINGS).to_string(),
|
||||
both(FONTS),
|
||||
element(styles, STYLES).to_string(),
|
||||
both(AUTOMATIC),
|
||||
element(styles, MASTERS).to_string(),
|
||||
element(content, BODY).to_string(),
|
||||
]
|
||||
.concat()
|
||||
}
|
||||
|
||||
/// A sub-document's parts as one `office:document` of media type `mime`.
|
||||
fn flat_document(mime: &str, content: &str, styles: &str, meta: &str, settings: &str) -> String {
|
||||
format!(
|
||||
"<office:document {} office:version=\"1.3\" office:mimetype=\"{}\">{}</office:document>",
|
||||
merged_namespaces(&[content, styles, meta, settings]),
|
||||
craft_xml::esc_attr(mime),
|
||||
flat_parts(content, styles, meta, settings)
|
||||
)
|
||||
}
|
||||
|
||||
/// An `office:document` element taken apart into a package's `content.xml`, `styles.xml`,
|
||||
/// `meta.xml` and `settings.xml` (each part's root with the document's attributes but its
|
||||
/// media type). `None` when `root` is no `office:document`.
|
||||
fn package_parts(root: &str) -> Option<[String; 4]> {
|
||||
if !root.starts_with("<office:document") || root.starts_with("<office:document-") {
|
||||
return None;
|
||||
}
|
||||
let attrs = root.find('>').and_then(|e| root.get("<office:document".len()..e)).map(|a| a.trim_end_matches('/'))?;
|
||||
// Without its media type, which only the flat root has.
|
||||
let attrs = match attrs.find(" office:mimetype=\"") {
|
||||
Some(i) => {
|
||||
let from = i + " office:mimetype=\"".len();
|
||||
let end = attrs.get(from..).and_then(|r| r.find('"')).map_or(attrs.len(), |e| from + e + 1);
|
||||
format!("{}{}", attrs.get(..i).unwrap_or(""), attrs.get(end..).unwrap_or(""))
|
||||
}
|
||||
None => attrs.to_string(),
|
||||
};
|
||||
let doc = |name: &str, parts: &[&str]| -> String {
|
||||
let body: String = parts.iter().map(|p| element(root, p)).collect();
|
||||
format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<office:{name}{attrs}>{body}</office:{name}>")
|
||||
};
|
||||
Some([
|
||||
doc("document-content", &[FONTS, AUTOMATIC, BODY]),
|
||||
doc("document-styles", &[FONTS, STYLES, AUTOMATIC, MASTERS]),
|
||||
doc("document-meta", &[META]),
|
||||
doc("document-settings", &[SETTINGS]),
|
||||
])
|
||||
}
|
||||
|
||||
/// The `office:mimetype` of an `office:document` element.
|
||||
fn mimetype_of(root: &str) -> Option<String> {
|
||||
let tag = root.find('>').and_then(|e| root.get(..e))?;
|
||||
let from = tag.find(" office:mimetype=\"")? + " office:mimetype=\"".len();
|
||||
let value = tag.get(from..)?;
|
||||
Some(craft_xml::unescape(value.get(..value.find('"')?)?))
|
||||
}
|
||||
|
||||
/// The namespace declarations (`xmlns:p="…"`) on the root element of `xml`.
|
||||
fn namespaces(xml: &str) -> Vec<&str> {
|
||||
let start = xml.find("<office:").unwrap_or(0);
|
||||
let tag = xml.get(start..).and_then(|r| r.find('>').and_then(|e| r.get(..e))).unwrap_or("");
|
||||
let mut out = Vec::new();
|
||||
let mut rest = tag;
|
||||
while let Some(i) = rest.find("xmlns:") {
|
||||
let from = rest.get(i..).unwrap_or("");
|
||||
// `xmlns:p="…"`: up to the closing quote.
|
||||
let Some(q1) = from.find('"') else { break };
|
||||
let Some(q2) = from.get(q1 + 1..).and_then(|r| r.find('"')) else { break };
|
||||
out.push(from.get(..q1 + 1 + q2 + 1).unwrap_or(""));
|
||||
rest = from.get(q1 + 1 + q2 + 1..).unwrap_or("");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod element_tests {
|
||||
use super::element;
|
||||
|
||||
/// An element ends at its own close tag however many elements of its name it holds side by
|
||||
/// side (inline objects' own `office:body`s), not after a count of tags.
|
||||
#[test]
|
||||
fn an_element_holding_many_of_its_name_ends_at_its_own_close() {
|
||||
let inner = "<office:body><text:p>x</text:p></office:body>".repeat(1200);
|
||||
let outer = format!("<office:body>{inner}</office:body>");
|
||||
let xml = format!("<office:document>{outer}<office:settings/></office:document>");
|
||||
assert_eq!(element(&xml, "office:body"), outer);
|
||||
// Nested deeper than any XML here: malformed, to the end, as an unclosed one.
|
||||
let deep = format!("{}{}", "<office:body>".repeat(600), "</office:body>".repeat(600));
|
||||
assert_eq!(element(&deep, "office:body"), deep);
|
||||
assert_eq!(element("<office:body><x/>", "office:body"), "<office:body><x/>");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
//! OpenDocument packages (ODF 1.3 Part 2): what every OpenDocument file has in common, whatever
|
||||
//! it holds (text, a spreadsheet, a presentation, a drawing, a chart, a formula).
|
||||
//!
|
||||
//! - [`Package`] opens the ZIP container within [`Limits`] and reads its `mimetype`, its
|
||||
//! [`Manifest`] and its files. [`PackageWriter`] writes one: the `mimetype` first and stored,
|
||||
//! the manifest, then the files.
|
||||
//! - [`crypt`] opens and saves password-protected packages (AES-256-CBC with PBKDF2, as
|
||||
//! LibreOffice writes them).
|
||||
//! - [`flat`] turns a flat single-file document (`.fodt`, `.fods`, `.fodp`, `.fodg`) into a
|
||||
//! package and back.
|
||||
//! - [`zip_guard`] refuses a ZIP that declares too many entries before `zip` parses them.
|
||||
//!
|
||||
//! What the XML inside means (paragraphs, cells, styles) is not here.
|
||||
//!
|
||||
//! Input is hostile: entries, sizes, key-derivation work and nesting are bounded, and nothing
|
||||
//! here panics on any bytes.
|
||||
//!
|
||||
//! ```
|
||||
//! use craft_odf::{Limits, Package, PackageWriter, mime};
|
||||
//!
|
||||
//! let mut w = PackageWriter::new(mime::TEXT);
|
||||
//! w.file("content.xml", "text/xml", b"<office:document-content/>");
|
||||
//! let bytes = w.finish()?;
|
||||
//!
|
||||
//! let mut p = Package::open(&bytes, &Limits::DEFAULT)?;
|
||||
//! assert_eq!(p.mimetype().as_deref(), Some(mime::TEXT));
|
||||
//! assert_eq!(p.read("content.xml")?.as_deref(), Some(b"<office:document-content/>".as_slice()));
|
||||
//! assert_eq!(p.manifest().media_type("content.xml"), Some("text/xml"));
|
||||
//! # Ok::<(), craft_odf::Error>(())
|
||||
//! ```
|
||||
|
||||
#[cfg(doctest)]
|
||||
#[doc = include_str!("../README.md")]
|
||||
struct ReadmeExamples;
|
||||
|
||||
pub mod crypt;
|
||||
pub mod flat;
|
||||
mod manifest;
|
||||
mod package;
|
||||
pub mod zip_guard;
|
||||
|
||||
pub use manifest::{Encryption, FileEntry, MANIFEST_NS, MANIFEST_PATH, Manifest};
|
||||
pub use package::{Limits, Package, PackageWriter};
|
||||
|
||||
/// The media types of OpenDocument files (ODF 1.3 Part 3 section 3.3, and the `mimetype` file of
|
||||
/// Part 2 section 3.3).
|
||||
pub mod mime {
|
||||
pub const TEXT: &str = "application/vnd.oasis.opendocument.text";
|
||||
pub const TEXT_TEMPLATE: &str = "application/vnd.oasis.opendocument.text-template";
|
||||
pub const SPREADSHEET: &str = "application/vnd.oasis.opendocument.spreadsheet";
|
||||
pub const SPREADSHEET_TEMPLATE: &str = "application/vnd.oasis.opendocument.spreadsheet-template";
|
||||
pub const PRESENTATION: &str = "application/vnd.oasis.opendocument.presentation";
|
||||
pub const PRESENTATION_TEMPLATE: &str = "application/vnd.oasis.opendocument.presentation-template";
|
||||
pub const GRAPHICS: &str = "application/vnd.oasis.opendocument.graphics";
|
||||
pub const GRAPHICS_TEMPLATE: &str = "application/vnd.oasis.opendocument.graphics-template";
|
||||
pub const CHART: &str = "application/vnd.oasis.opendocument.chart";
|
||||
pub const FORMULA: &str = "application/vnd.oasis.opendocument.formula";
|
||||
}
|
||||
|
||||
/// Why a package was not read or written.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum Error {
|
||||
/// The ZIP declares more entries than [`Limits::max_entries`], or has too many end records.
|
||||
#[error("{0}")]
|
||||
TooManyEntries(String),
|
||||
/// A file is larger than [`Limits::max_entry`].
|
||||
#[error("the file {0} in the package is larger than {1} bytes")]
|
||||
EntryTooLarge(String, u64),
|
||||
/// The files read add up to more than [`Limits::max_total`].
|
||||
#[error("the package's files are larger than {0} bytes together")]
|
||||
TotalTooLarge(u64),
|
||||
/// The bytes are no ZIP package, or one that cannot be read or written.
|
||||
#[error("not a valid package ({0})")]
|
||||
Zip(String),
|
||||
/// A flat document that is no `office:document`, or not UTF-8.
|
||||
#[error("not a flat OpenDocument file ({0})")]
|
||||
Flat(&'static str),
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Was a limit exceeded (as opposed to the package being wrong)?
|
||||
pub fn is_limit(&self) -> bool {
|
||||
matches!(self, Error::TooManyEntries(_) | Error::EntryTooLarge(..) | Error::TotalTooLarge(_))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,193 @@
|
||||
//! The manifest (`META-INF/manifest.xml`, ODF 1.3 Part 2 section 4): each file's path and
|
||||
//! media type, and how an encrypted file is encrypted.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use craft_xml::{Canonical, El, Options, esc_attr};
|
||||
|
||||
/// The manifest's path in a package.
|
||||
pub const MANIFEST_PATH: &str = "META-INF/manifest.xml";
|
||||
/// The manifest's XML namespace.
|
||||
pub const MANIFEST_NS: &str = "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0";
|
||||
|
||||
/// Most file entries read from a manifest (as many as a package may hold by default).
|
||||
pub const MAX_ENTRIES: usize = 20_000;
|
||||
|
||||
/// How one file is encrypted (a `manifest:encryption-data`, section 4.4 to 4.7).
|
||||
#[derive(Default, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Encryption {
|
||||
/// `manifest:checksum-type` (`SHA1/1K` when absent).
|
||||
pub checksum_type: String,
|
||||
/// `manifest:checksum`, decoded.
|
||||
pub checksum: Vec<u8>,
|
||||
/// `manifest:algorithm-name`.
|
||||
pub algorithm: String,
|
||||
/// `manifest:initialisation-vector`, decoded.
|
||||
pub iv: Vec<u8>,
|
||||
/// `manifest:start-key-generation-name` (`SHA1` when absent).
|
||||
pub start_key: String,
|
||||
/// The start key's `manifest:key-size`.
|
||||
pub start_key_size: Option<usize>,
|
||||
/// `manifest:key-derivation-name`.
|
||||
pub derivation: String,
|
||||
/// `manifest:iteration-count` (1024 when absent).
|
||||
pub iterations: u32,
|
||||
/// `manifest:salt`, decoded.
|
||||
pub salt: Vec<u8>,
|
||||
/// The derived key's `manifest:key-size`.
|
||||
pub key_size: Option<usize>,
|
||||
}
|
||||
|
||||
/// One `manifest:file-entry`.
|
||||
#[derive(Default, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FileEntry {
|
||||
/// `manifest:full-path`: `/` is the package itself, a path ending in `/` a folder (a
|
||||
/// sub-document).
|
||||
pub path: String,
|
||||
/// `manifest:media-type` (may be empty).
|
||||
pub media_type: String,
|
||||
/// `manifest:version`.
|
||||
pub version: Option<String>,
|
||||
/// `manifest:size`: the file's size before encryption.
|
||||
pub size: Option<u64>,
|
||||
/// Present when the file is encrypted.
|
||||
pub encryption: Option<Encryption>,
|
||||
}
|
||||
|
||||
/// A package's manifest.
|
||||
#[derive(Default, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Manifest {
|
||||
/// `manifest:version` of the root element.
|
||||
pub version: Option<String>,
|
||||
pub entries: Vec<FileEntry>,
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
/// Reads a manifest. Elements are found by namespace, whatever prefix the file uses. What
|
||||
/// can't be read is left out: damaged XML gives the entries before the damage, and entries
|
||||
/// past [`MAX_ENTRIES`] are dropped.
|
||||
pub fn parse(xml: &[u8]) -> Manifest {
|
||||
let canonical = Canonical::new(&[("manifest", MANIFEST_NS)]);
|
||||
let (root, _) = Options::canonical(&canonical).parse_streaming_partial(xml, &[], |_| Ok::<(), craft_xml::Error>(()));
|
||||
let Some(root) = root.filter(|r| named(r, "manifest")) else { return Manifest::default() };
|
||||
let entries = root.elements().filter(|e| named(e, "file-entry")).take(MAX_ENTRIES).filter_map(file_entry).collect();
|
||||
Manifest { version: attr(&root, "version").map(str::to_string), entries }
|
||||
}
|
||||
|
||||
/// The entry for `path`.
|
||||
pub fn entry(&self, path: &str) -> Option<&FileEntry> {
|
||||
self.entries.iter().find(|e| e.path == path)
|
||||
}
|
||||
|
||||
/// The media type the manifest gives `path` (`/` for the package, `Object 1/` for a
|
||||
/// sub-document).
|
||||
pub fn media_type(&self, path: &str) -> Option<&str> {
|
||||
self.entry(path).map(|e| e.media_type.as_str())
|
||||
}
|
||||
|
||||
/// Does any file say it is encrypted?
|
||||
pub fn is_encrypted(&self) -> bool {
|
||||
self.entries.iter().any(|e| e.encryption.is_some())
|
||||
}
|
||||
|
||||
/// The manifest as XML, as LibreOffice lays it out: one entry a line, encryption data
|
||||
/// included.
|
||||
pub fn to_xml(&self) -> String {
|
||||
let mut o = format!("{}<manifest:manifest xmlns:manifest=\"{MANIFEST_NS}\"", craft_xml::DECL);
|
||||
if let Some(v) = &self.version {
|
||||
let _ = write!(o, " manifest:version=\"{}\"", esc_attr(v));
|
||||
}
|
||||
o.push_str(">\n");
|
||||
for e in &self.entries {
|
||||
let _ = write!(o, "<manifest:file-entry manifest:full-path=\"{}\"", esc_attr(&e.path));
|
||||
if let Some(v) = &e.version {
|
||||
let _ = write!(o, " manifest:version=\"{}\"", esc_attr(v));
|
||||
}
|
||||
let _ = write!(o, " manifest:media-type=\"{}\"", esc_attr(&e.media_type));
|
||||
if let Some(size) = e.size {
|
||||
let _ = write!(o, " manifest:size=\"{size}\"");
|
||||
}
|
||||
match &e.encryption {
|
||||
Some(c) => {
|
||||
o.push('>');
|
||||
encryption_xml(&mut o, c);
|
||||
o.push_str("</manifest:file-entry>\n");
|
||||
}
|
||||
None => o.push_str("/>\n"),
|
||||
}
|
||||
}
|
||||
o.push_str("</manifest:manifest>\n");
|
||||
o
|
||||
}
|
||||
}
|
||||
|
||||
fn encryption_xml(o: &mut String, c: &Encryption) {
|
||||
let size = |s: Option<usize>| s.map(|s| format!(" manifest:key-size=\"{s}\"")).unwrap_or_default();
|
||||
let _ = write!(
|
||||
o,
|
||||
"<manifest:encryption-data manifest:checksum-type=\"{}\" manifest:checksum=\"{}\"><manifest:algorithm manifest:algorithm-name=\"{}\" manifest:initialisation-vector=\"{}\"/><manifest:start-key-generation manifest:start-key-generation-name=\"{}\"{}/><manifest:key-derivation manifest:key-derivation-name=\"{}\" manifest:iteration-count=\"{}\" manifest:salt=\"{}\"{}/></manifest:encryption-data>",
|
||||
esc_attr(&c.checksum_type),
|
||||
STANDARD.encode(&c.checksum),
|
||||
esc_attr(&c.algorithm),
|
||||
STANDARD.encode(&c.iv),
|
||||
esc_attr(&c.start_key),
|
||||
size(c.start_key_size),
|
||||
esc_attr(&c.derivation),
|
||||
c.iterations,
|
||||
STANDARD.encode(&c.salt),
|
||||
size(c.key_size)
|
||||
);
|
||||
}
|
||||
|
||||
/// Is `e` the manifest element `local`: in the manifest namespace, or (a prefix nothing declares,
|
||||
/// a file with no namespace at all) in none?
|
||||
fn named(e: &El, local: &str) -> bool {
|
||||
e.name.strip_prefix("manifest:").unwrap_or(&e.name) == local
|
||||
}
|
||||
|
||||
fn child<'a>(e: &'a El, local: &str) -> Option<&'a El> {
|
||||
e.elements().find(|c| named(c, local))
|
||||
}
|
||||
|
||||
/// A manifest attribute: in the manifest namespace, or (as some writers leave it) in none.
|
||||
fn attr<'a>(e: &'a El, name: &str) -> Option<&'a str> {
|
||||
e.attr(&format!("manifest:{name}")).or_else(|| e.attr(name))
|
||||
}
|
||||
|
||||
fn file_entry(e: &El) -> Option<FileEntry> {
|
||||
fn num<T: std::str::FromStr>(e: &El, name: &str) -> Option<T> {
|
||||
attr(e, name).and_then(|s| s.trim().parse().ok())
|
||||
}
|
||||
let b64 = |e: &El, name: &str| attr(e, name).and_then(|s| STANDARD.decode(s.trim()).ok()).unwrap_or_default();
|
||||
let encryption = child(e, "encryption-data").map(|d| {
|
||||
let mut c = Encryption {
|
||||
checksum_type: attr(d, "checksum-type").unwrap_or("SHA1/1K").to_string(),
|
||||
checksum: b64(d, "checksum"),
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(a) = child(d, "algorithm") {
|
||||
c.algorithm = attr(a, "algorithm-name").unwrap_or_default().to_string();
|
||||
c.iv = b64(a, "initialisation-vector");
|
||||
}
|
||||
if let Some(k) = child(d, "start-key-generation") {
|
||||
c.start_key = attr(k, "start-key-generation-name").unwrap_or("SHA1").to_string();
|
||||
c.start_key_size = num(k, "key-size");
|
||||
}
|
||||
if let Some(k) = child(d, "key-derivation") {
|
||||
c.derivation = attr(k, "key-derivation-name").unwrap_or_default().to_string();
|
||||
c.iterations = num(k, "iteration-count").unwrap_or(1024);
|
||||
c.salt = b64(k, "salt");
|
||||
c.key_size = num(k, "key-size");
|
||||
}
|
||||
c
|
||||
});
|
||||
Some(FileEntry {
|
||||
path: attr(e, "full-path")?.to_string(),
|
||||
media_type: attr(e, "media-type").unwrap_or_default().to_string(),
|
||||
version: attr(e, "version").map(str::to_string),
|
||||
size: num(e, "size"),
|
||||
encryption,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! The ZIP container (ODF 1.3 Part 2 section 3): a `mimetype` file first and stored, a
|
||||
//! manifest, and the document's files.
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
use crate::manifest::{FileEntry, MANIFEST_PATH, Manifest};
|
||||
use crate::{Error, zip_guard};
|
||||
|
||||
/// What a [`Package`] refuses to exceed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Limits {
|
||||
/// Most entries the ZIP may declare, checked before `zip` parses them. Default 20,000.
|
||||
pub max_entries: u64,
|
||||
/// Most bytes one file may inflate to. Default 256 MiB.
|
||||
pub max_entry: u64,
|
||||
/// Most bytes all the files read from one [`Package`] may inflate to together (a file read
|
||||
/// twice counts twice). Default 2 GiB.
|
||||
pub max_total: u64,
|
||||
}
|
||||
|
||||
impl Limits {
|
||||
pub const DEFAULT: Limits = Limits { max_entries: 20_000, max_entry: 256 << 20, max_total: 2 << 30 };
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Limits {
|
||||
Limits::DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
/// The longest `mimetype` file read: media types are short, and a longer one is no media type.
|
||||
const MAX_MIMETYPE: u64 = 255;
|
||||
|
||||
pub(crate) fn zip_err(e: zip::result::ZipError) -> Error {
|
||||
Error::Zip(e.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn io_err(e: std::io::Error) -> Error {
|
||||
Error::Zip(e.to_string())
|
||||
}
|
||||
|
||||
/// An OpenDocument package open for reading.
|
||||
pub struct Package<'a> {
|
||||
zip: zip::ZipArchive<Cursor<&'a [u8]>>,
|
||||
/// The files' names (folders and names that are not text left out), in the ZIP's order.
|
||||
names: Vec<String>,
|
||||
limits: Limits,
|
||||
/// Bytes inflated so far, against [`Limits::max_total`].
|
||||
spent: u64,
|
||||
}
|
||||
|
||||
impl<'a> Package<'a> {
|
||||
/// Opens `bytes` as a ZIP package. The declared entry count is checked against
|
||||
/// [`Limits::max_entries`] before anything is parsed. No file is inflated yet.
|
||||
pub fn open(bytes: &'a [u8], limits: &Limits) -> Result<Package<'a>, Error> {
|
||||
zip_guard::check(bytes, limits.max_entries).map_err(Error::TooManyEntries)?;
|
||||
let zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(zip_err)?;
|
||||
if zip.len() as u64 > limits.max_entries {
|
||||
return Err(Error::TooManyEntries(format!("the package has {} entries, more than {}", zip.len(), limits.max_entries)));
|
||||
}
|
||||
let names = zip.file_names().filter_map(|n| n.ok()).filter(|n| !n.ends_with('/')).map(|n| n.into_owned()).collect();
|
||||
Ok(Package { zip, names, limits: *limits, spent: 0 })
|
||||
}
|
||||
|
||||
/// The names of the package's files, folders left out, in the order the ZIP lists them.
|
||||
pub fn names(&self) -> impl Iterator<Item = &str> {
|
||||
self.names.iter().map(String::as_str)
|
||||
}
|
||||
|
||||
/// Is there a file `name`?
|
||||
pub fn contains(&self, name: &str) -> bool {
|
||||
self.zip.index_for_name(name).is_some()
|
||||
}
|
||||
|
||||
/// The size the ZIP declares for `name` (what it claims to inflate to; [`Package::read`]
|
||||
/// does not trust it).
|
||||
pub fn declared_size(&mut self, name: &str) -> Option<u64> {
|
||||
self.zip.by_name(name).ok().map(|f| f.size())
|
||||
}
|
||||
|
||||
/// File `name` inflated. `Ok(None)` when the package has no such file, or it cannot be
|
||||
/// inflated (damaged data); an error when it is larger than [`Limits::max_entry`] or takes
|
||||
/// the package past [`Limits::max_total`]. A file that inflates to more than it declares is
|
||||
/// held to the same limits.
|
||||
pub fn read(&mut self, name: &str) -> Result<Option<Vec<u8>>, Error> {
|
||||
let (max_entry, room) = (self.limits.max_entry, self.limits.max_total.saturating_sub(self.spent));
|
||||
let Ok(f) = self.zip.by_name(name) else { return Ok(None) };
|
||||
if f.size() > max_entry {
|
||||
return Err(Error::EntryTooLarge(name.to_string(), max_entry));
|
||||
}
|
||||
if f.size() > room {
|
||||
return Err(Error::TotalTooLarge(self.limits.max_total));
|
||||
}
|
||||
let cap = max_entry.min(room);
|
||||
let mut out = Vec::new();
|
||||
// One byte past the cap tells a file at the cap from one beyond it.
|
||||
if f.take(cap.saturating_add(1)).read_to_end(&mut out).is_err() {
|
||||
self.spent = self.spent.saturating_add(out.len() as u64);
|
||||
return Ok(None);
|
||||
}
|
||||
self.spent = self.spent.saturating_add(out.len() as u64);
|
||||
if out.len() as u64 > cap {
|
||||
return Err(if cap == max_entry {
|
||||
Error::EntryTooLarge(name.to_string(), max_entry)
|
||||
} else {
|
||||
Error::TotalTooLarge(self.limits.max_total)
|
||||
});
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
/// Bytes inflated from this package so far.
|
||||
pub fn bytes_read(&self) -> u64 {
|
||||
self.spent
|
||||
}
|
||||
|
||||
/// The package's media type: what its `mimetype` file holds, surrounding whitespace
|
||||
/// dropped. `None` when it has none, or one longer than a media type can be.
|
||||
pub fn mimetype(&mut self) -> Option<String> {
|
||||
let f = self.zip.by_name("mimetype").ok()?;
|
||||
if f.size() > MAX_MIMETYPE {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
f.take(MAX_MIMETYPE + 1).read_to_end(&mut out).ok()?;
|
||||
if out.len() as u64 > MAX_MIMETYPE {
|
||||
return None;
|
||||
}
|
||||
self.spent = self.spent.saturating_add(out.len() as u64);
|
||||
Some(String::from_utf8_lossy(&out).trim().to_string())
|
||||
}
|
||||
|
||||
/// The package's manifest; empty when it has none or it cannot be read.
|
||||
pub fn manifest(&mut self) -> Manifest {
|
||||
self.read(MANIFEST_PATH).ok().flatten().map(|xml| Manifest::parse(&xml)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Does the manifest say any file is encrypted ([`crate::crypt`])?
|
||||
pub fn is_encrypted(&mut self) -> bool {
|
||||
self.manifest().is_encrypted()
|
||||
}
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
path: String,
|
||||
data: Vec<u8>,
|
||||
stored: bool,
|
||||
}
|
||||
|
||||
/// Writes an OpenDocument package: the `mimetype` file first and stored (so the media type can
|
||||
/// be read at a fixed offset, section 3.3), then the manifest, then the files in the order they
|
||||
/// were added.
|
||||
pub struct PackageWriter {
|
||||
mime: String,
|
||||
manifest: Manifest,
|
||||
manifest_xml: Option<String>,
|
||||
entries: Vec<Entry>,
|
||||
}
|
||||
|
||||
impl PackageWriter {
|
||||
/// A package of media type `mime` ([`crate::mime`]), ODF version 1.3.
|
||||
pub fn new(mime: &str) -> PackageWriter {
|
||||
let root = FileEntry { path: "/".into(), media_type: mime.into(), version: Some("1.3".into()), ..Default::default() };
|
||||
PackageWriter {
|
||||
mime: mime.into(),
|
||||
manifest: Manifest { version: Some("1.3".into()), entries: vec![root] },
|
||||
manifest_xml: None,
|
||||
entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn add(&mut self, path: &str, media_type: &str, data: &[u8], stored: bool) {
|
||||
self.entries.push(Entry { path: path.into(), data: data.to_vec(), stored });
|
||||
if self.manifest.entry(path).is_none() {
|
||||
self.manifest.entries.push(FileEntry { path: path.into(), media_type: media_type.into(), ..Default::default() });
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds file `path`, deflated, listed in the manifest with `media_type`.
|
||||
pub fn file(&mut self, path: &str, media_type: &str, data: &[u8]) {
|
||||
self.add(path, media_type, data, false);
|
||||
}
|
||||
|
||||
/// Adds file `path` stored as it is: for data that is compressed already (PNG, JPEG).
|
||||
pub fn stored(&mut self, path: &str, media_type: &str, data: &[u8]) {
|
||||
self.add(path, media_type, data, true);
|
||||
}
|
||||
|
||||
/// Lists a folder in the manifest (`Object 1/`, a sub-document with its own media type and
|
||||
/// version 1.3). It has no ZIP entry of its own.
|
||||
pub fn folder(&mut self, path: &str, media_type: &str) {
|
||||
if self.manifest.entry(path).is_none() {
|
||||
self.manifest.entries.push(FileEntry {
|
||||
path: path.into(),
|
||||
media_type: media_type.into(),
|
||||
version: Some("1.3".into()),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The manifest that will be written, to adjust (an entry's version, entries for files
|
||||
/// written some other way).
|
||||
pub fn manifest_mut(&mut self) -> &mut Manifest {
|
||||
&mut self.manifest
|
||||
}
|
||||
|
||||
/// Writes `xml` as the manifest instead of the one built from the files added: for a
|
||||
/// writer that keeps a manifest as it was read.
|
||||
pub fn manifest_xml(&mut self, xml: String) {
|
||||
self.manifest_xml = Some(xml);
|
||||
}
|
||||
|
||||
/// The package's bytes.
|
||||
pub fn finish(self) -> Result<Vec<u8>, Error> {
|
||||
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
let deflated = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
z.start_file("mimetype", stored).map_err(zip_err)?;
|
||||
z.write_all(self.mime.as_bytes()).map_err(io_err)?;
|
||||
let manifest = self.manifest_xml.unwrap_or_else(|| self.manifest.to_xml());
|
||||
z.start_file(MANIFEST_PATH, deflated).map_err(zip_err)?;
|
||||
z.write_all(manifest.as_bytes()).map_err(io_err)?;
|
||||
for e in &self.entries {
|
||||
z.start_file(e.path.as_str(), if e.stored { stored } else { deflated }).map_err(zip_err)?;
|
||||
z.write_all(&e.data).map_err(io_err)?;
|
||||
}
|
||||
Ok(z.finish().map_err(zip_err)?.into_inner())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
//! Packages, manifests, limits, password protection and flat documents.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use crate::crypt::{CryptError, decrypt, encrypt, is_encrypted};
|
||||
use crate::{Error, Limits, Manifest, Package, PackageWriter, flat, mime, zip_guard};
|
||||
|
||||
/// A ZIP of `files`, stored.
|
||||
fn zip_of(files: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
for (name, data) in files {
|
||||
z.start_file(*name, stored).unwrap();
|
||||
z.write_all(data).unwrap();
|
||||
}
|
||||
z.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn secret() -> Vec<u8> {
|
||||
let mut w = PackageWriter::new(mime::TEXT);
|
||||
w.file("content.xml", "text/xml", b"<office:document-content>Top secret words.</office:document-content>");
|
||||
w.file("styles.xml", "text/xml", b"<office:document-styles/>");
|
||||
w.stored("Pictures/a.png", "image/png", b"\x89PNG not really");
|
||||
w.finish().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_written_package_reads_back() {
|
||||
let bytes = secret();
|
||||
// The media type first and stored, at the offset `file` looks for it (section 3.3).
|
||||
assert_eq!(bytes.get(30..38), Some(b"mimetype".as_slice()));
|
||||
assert_eq!(bytes.get(38..38 + mime::TEXT.len()), Some(mime::TEXT.as_bytes()));
|
||||
let mut p = Package::open(&bytes, &Limits::DEFAULT).unwrap();
|
||||
assert_eq!(p.mimetype().as_deref(), Some(mime::TEXT));
|
||||
assert_eq!(p.names().collect::<Vec<_>>(), ["mimetype", "META-INF/manifest.xml", "content.xml", "styles.xml", "Pictures/a.png"]);
|
||||
assert!(p.contains("styles.xml") && !p.contains("nothing.xml"));
|
||||
assert_eq!(p.read("nothing.xml"), Ok(None));
|
||||
assert_eq!(p.read("Pictures/a.png").unwrap().as_deref(), Some(b"\x89PNG not really".as_slice()));
|
||||
let m = p.manifest();
|
||||
assert_eq!(m.version.as_deref(), Some("1.3"));
|
||||
assert_eq!(m.media_type("/"), Some(mime::TEXT));
|
||||
assert_eq!(m.media_type("Pictures/a.png"), Some("image/png"));
|
||||
assert!(!m.is_encrypted() && !p.is_encrypted());
|
||||
assert_eq!(Manifest::parse(m.to_xml().as_bytes()), m);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_manifest_is_read_by_namespace_and_survives_damage() {
|
||||
// Another prefix, a default namespace with plain attributes, and a prefix nothing declares.
|
||||
for xml in [
|
||||
r#"<m:manifest xmlns:m="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"><m:file-entry m:full-path="/" m:media-type="a/b"/><m:file-entry m:full-path="x & y.xml" m:media-type="text/xml" m:size="12"/></m:manifest>"#,
|
||||
r#"<manifest xmlns="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"><file-entry full-path="/" media-type="a/b"/><file-entry full-path="x & y.xml" media-type="text/xml" size="12"/></manifest>"#,
|
||||
r#"<manifest:manifest><manifest:file-entry manifest:full-path="/" manifest:media-type="a/b"/><manifest:file-entry manifest:full-path="x & y.xml" manifest:media-type="text/xml" manifest:size="12"/></manifest:manifest>"#,
|
||||
] {
|
||||
let m = Manifest::parse(xml.as_bytes());
|
||||
assert_eq!(m.media_type("/"), Some("a/b"), "{xml}");
|
||||
assert_eq!(m.entry("x & y.xml").and_then(|e| e.size), Some(12), "{xml}");
|
||||
}
|
||||
// Cut short: the entries before the cut.
|
||||
let cut = r#"<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"><manifest:file-entry manifest:full-path="/" manifest:media-type="a/b"/><manifest:file-entry manifest:full-pa"#;
|
||||
assert_eq!(Manifest::parse(cut.as_bytes()).entries.len(), 1);
|
||||
// Not a manifest, not XML, a document type: nothing, no panic.
|
||||
for junk in [&b"<html><file-entry full-path='/'/></html>"[..], b"\xff\xfe\x00", b"", b"<!DOCTYPE x [<!ENTITY a 'b'>]><manifest/>"] {
|
||||
assert!(Manifest::parse(junk).entries.is_empty());
|
||||
}
|
||||
// More entries than a package may hold are dropped.
|
||||
let many = format!("<manifest>{}</manifest>", r#"<file-entry full-path="a"/>"#.repeat(crate::manifest::MAX_ENTRIES + 5));
|
||||
assert_eq!(Manifest::parse(many.as_bytes()).entries.len(), crate::manifest::MAX_ENTRIES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_package_is_held_to_its_limits() {
|
||||
let bytes = zip_of(&[("mimetype", mime::SPREADSHEET.as_bytes()), ("a.xml", &[b'a'; 100]), ("b.xml", &[b'b'; 100])]);
|
||||
// Entries.
|
||||
assert!(matches!(Package::open(&bytes, &Limits { max_entries: 2, ..Limits::DEFAULT }), Err(Error::TooManyEntries(_))));
|
||||
let bomb = zip_guard::zip64_entry_count_sample(Limits::DEFAULT.max_entries + 1);
|
||||
assert!(matches!(Package::open(&bomb, &Limits::DEFAULT), Err(e) if e.is_limit()));
|
||||
// One file.
|
||||
let mut p = Package::open(&bytes, &Limits { max_entry: 99, ..Limits::DEFAULT }).unwrap();
|
||||
assert_eq!(p.read("a.xml"), Err(Error::EntryTooLarge("a.xml".into(), 99)));
|
||||
let mut p = Package::open(&bytes, &Limits { max_entry: 100, ..Limits::DEFAULT }).unwrap();
|
||||
assert_eq!(p.read("a.xml").map(|d| d.map(|d| d.len())), Ok(Some(100)));
|
||||
// All files together; a file read twice counts twice.
|
||||
let mut p = Package::open(&bytes, &Limits { max_total: 250, ..Limits::DEFAULT }).unwrap();
|
||||
assert!(p.read("a.xml").is_ok() && p.read("b.xml").is_ok());
|
||||
assert_eq!(p.bytes_read(), 200);
|
||||
assert_eq!(p.read("a.xml"), Err(Error::TotalTooLarge(250)));
|
||||
// Not a ZIP.
|
||||
assert!(matches!(Package::open(b"not a zip", &Limits::DEFAULT), Err(Error::Zip(_))));
|
||||
}
|
||||
|
||||
/// A deflated entry whose headers declare `declared` bytes but which inflates to `real.len()`.
|
||||
fn lying_zip(real: &[u8], declared: u32) -> Vec<u8> {
|
||||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
z.start_file("a.xml", zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated)).unwrap();
|
||||
z.write_all(real).unwrap();
|
||||
let mut bytes = z.finish().unwrap().into_inner();
|
||||
// The uncompressed size, in the central directory header (offset 24) and the local one (22).
|
||||
let want = (real.len() as u32).to_le_bytes();
|
||||
let central = bytes.windows(4).rposition(|w| w == b"PK\x01\x02").unwrap();
|
||||
assert_eq!(&bytes[central + 24..central + 28], &want);
|
||||
bytes[central + 24..central + 28].copy_from_slice(&declared.to_le_bytes());
|
||||
assert_eq!(&bytes[22..26], &want);
|
||||
bytes[22..26].copy_from_slice(&declared.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_inflates_past_what_it_declares_is_not_read_past_the_limit() {
|
||||
let bytes = lying_zip(&vec![b'a'; 100_000], 10);
|
||||
let mut p = Package::open(&bytes, &Limits { max_entry: 1000, ..Limits::DEFAULT }).unwrap();
|
||||
assert_eq!(p.declared_size("a.xml"), Some(10));
|
||||
// Refused or unreadable, and never more than the limit (and the byte that shows it) inflated.
|
||||
assert!(!matches!(p.read("a.xml"), Ok(Some(_))));
|
||||
assert!(p.bytes_read() <= 1001, "{}", p.bytes_read());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mimetype_is_short_or_nothing() {
|
||||
let long = "x".repeat(300);
|
||||
let mut p_bytes = zip_of(&[("mimetype", long.as_bytes())]);
|
||||
assert_eq!(Package::open(&p_bytes, &Limits::DEFAULT).unwrap().mimetype(), None);
|
||||
p_bytes = zip_of(&[("mimetype", b" application/vnd.oasis.opendocument.spreadsheet\n")]);
|
||||
assert_eq!(Package::open(&p_bytes, &Limits::DEFAULT).unwrap().mimetype().as_deref(), Some(mime::SPREADSHEET));
|
||||
p_bytes = zip_of(&[("content.xml", b"<a/>")]);
|
||||
assert_eq!(Package::open(&p_bytes, &Limits::DEFAULT).unwrap().mimetype(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_encrypted_package_opens_with_its_password_only() {
|
||||
let plain = secret();
|
||||
assert!(!is_encrypted(&plain));
|
||||
let enc = encrypt(&plain, "pässword").unwrap();
|
||||
assert!(is_encrypted(&enc));
|
||||
// Nothing readable is left: the content is encrypted, the manifest says how.
|
||||
let mut p = Package::open(&enc, &Limits::DEFAULT).unwrap();
|
||||
assert_eq!(p.names().next(), Some("mimetype"));
|
||||
assert!(!enc.windows(10).any(|w| w == b"Top secret"));
|
||||
let manifest = String::from_utf8(p.read("META-INF/manifest.xml").unwrap().unwrap()).unwrap();
|
||||
for want in [
|
||||
r#"<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml" manifest:size=""#,
|
||||
"http://www.w3.org/2001/04/xmlenc#aes256-cbc",
|
||||
r#"manifest:start-key-generation-name="http://www.w3.org/2000/09/xmldsig#sha256" manifest:key-size="32""#,
|
||||
r#"manifest:key-derivation-name="PBKDF2" manifest:iteration-count="100000""#,
|
||||
"#sha256-1k",
|
||||
] {
|
||||
assert!(manifest.contains(want), "{want} in {manifest}");
|
||||
}
|
||||
let m = p.manifest();
|
||||
let c = m.entry("content.xml").and_then(|e| e.encryption.as_ref()).unwrap();
|
||||
assert_eq!((c.iterations, c.key_size, c.iv.len(), c.salt.len(), c.checksum.len()), (100_000, Some(32), 16, 16, 32));
|
||||
assert!(m.entry("/").is_some_and(|e| e.encryption.is_none()));
|
||||
assert_eq!(decrypt(&enc, "wrong"), Err(CryptError::WrongPassword));
|
||||
let opened = decrypt(&enc, "pässword").unwrap();
|
||||
assert!(!is_encrypted(&opened));
|
||||
let (mut a, mut b) = (Package::open(&plain, &Limits::DEFAULT).unwrap(), Package::open(&opened, &Limits::DEFAULT).unwrap());
|
||||
for name in ["mimetype", "content.xml", "styles.xml", "Pictures/a.png"] {
|
||||
assert_eq!(a.read(name), b.read(name), "{name}");
|
||||
}
|
||||
// The manifest is the plain one again, each file keeping the size it was given.
|
||||
let mut reopened = b.manifest();
|
||||
assert_eq!(reopened.entry("content.xml").and_then(|e| e.size), Some(68));
|
||||
reopened.entries.iter_mut().for_each(|e| e.size = None);
|
||||
assert_eq!(a.manifest(), reopened);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_and_hostile_encryption_is_refused() {
|
||||
let manifest = |alg: &str, extra: &str| {
|
||||
format!(
|
||||
r#"<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"><manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml" manifest:size="99999999999"><manifest:encryption-data manifest:checksum-type="SHA1/1K" manifest:checksum="AAAA"><manifest:algorithm manifest:algorithm-name="{alg}" manifest:initialisation-vector="AAAAAAAAAAA="/><manifest:key-derivation manifest:key-derivation-name="PBKDF2" {extra} manifest:salt="AAAA"/></manifest:encryption-data></manifest:file-entry></manifest:manifest>"#
|
||||
)
|
||||
};
|
||||
let pkg = |m: String, content: &[u8]| {
|
||||
zip_of(&[("mimetype", b"application/vnd.oasis.opendocument.text"), ("META-INF/manifest.xml", m.as_bytes()), ("content.xml", content)])
|
||||
};
|
||||
// Blowfish: said, not guessed at.
|
||||
let blowfish = pkg(manifest("Blowfish CFB", r#"manifest:iteration-count="1024""#), &[0u8; 64]);
|
||||
assert!(is_encrypted(&blowfish));
|
||||
assert!(matches!(decrypt(&blowfish, "x"), Err(CryptError::Unsupported(w)) if w.contains("Blowfish")));
|
||||
// An iteration count that would hang, a vector of the wrong size, ragged data: refused.
|
||||
for (extra, data) in [
|
||||
(r#"manifest:iteration-count="4000000000""#, vec![0u8; 32]),
|
||||
(r#"manifest:iteration-count="1""#, vec![0u8; 33]),
|
||||
(r#"manifest:iteration-count="1""#, vec![]),
|
||||
] {
|
||||
assert!(decrypt(&pkg(manifest("http://www.w3.org/2001/04/xmlenc#aes256-cbc", extra), &data), "x").is_err(), "{extra}");
|
||||
}
|
||||
// Junk that isn't a package.
|
||||
assert!(!is_encrypted(b"not a zip"));
|
||||
assert!(decrypt(b"not a zip", "x").is_err());
|
||||
// A plain package has nothing to decrypt.
|
||||
assert!(matches!(decrypt(&secret(), "x"), Err(CryptError::Unsupported(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_package_declaring_too_many_entries_is_refused_before_it_is_decrypted() {
|
||||
let bomb = zip_guard::zip64_entry_count_sample(Limits::DEFAULT.max_entries + 1);
|
||||
assert!(!is_encrypted(&bomb));
|
||||
assert!(matches!(decrypt(&bomb, "x"), Err(CryptError::Unsupported(e)) if e.contains("declares")));
|
||||
assert!(matches!(encrypt(&bomb, "x"), Err(CryptError::Unsupported(e)) if e.contains("declares")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_past_the_most_decrypted_are_not_reached() {
|
||||
// The one encrypted file sits after 10,000 others: it is never derived a key for.
|
||||
let manifest = r#"<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"><manifest:file-entry manifest:full-path="z.xml" manifest:media-type="text/xml"><manifest:encryption-data><manifest:algorithm manifest:algorithm-name="http://www.w3.org/2001/04/xmlenc#aes256-cbc" manifest:initialisation-vector="AAAAAAAAAAAAAAAAAAAAAA=="/><manifest:start-key-generation manifest:start-key-generation-name="SHA1"/><manifest:key-derivation manifest:key-derivation-name="PBKDF2" manifest:iteration-count="1" manifest:salt="AAAA"/></manifest:encryption-data></manifest:file-entry></manifest:manifest>"#;
|
||||
let names: Vec<String> = (0..10_000).map(|i| format!("f{i}")).collect();
|
||||
let mut files: Vec<(&str, &[u8])> = vec![("META-INF/manifest.xml", manifest.as_bytes())];
|
||||
files.extend(names.iter().map(|n| (n.as_str(), &b""[..])));
|
||||
files.push(("z.xml", &[0u8; 32]));
|
||||
let last = zip_of(&files);
|
||||
assert_eq!(decrypt(&last, "x"), Err(CryptError::Unsupported("no encrypted file could be read".into())));
|
||||
// Within reach, the same file is tried, and refused for what it is.
|
||||
let first = zip_of(&[("META-INF/manifest.xml", manifest.as_bytes()), ("z.xml", &[0u8; 32])]);
|
||||
assert_eq!(decrypt(&first, "x"), Err(CryptError::Unsupported("the key isn't 256 bits".into())));
|
||||
}
|
||||
|
||||
/// A 1×1 PNG, base64.
|
||||
const PNG_B64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
|
||||
|
||||
fn flat_doc(mimetype: &str) -> String {
|
||||
format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:math="http://www.w3.org/1998/Math/MathML" office:version="1.3" office:mimetype="{mimetype}">
|
||||
<office:meta><dc:title>Flat title</dc:title></office:meta>
|
||||
<office:styles><style:style style:name="Quiet" style:family="paragraph"/></office:styles>
|
||||
<office:automatic-styles><style:style style:name="T1" style:family="text"/></office:automatic-styles>
|
||||
<office:master-styles><style:master-page style:name="Standard"/></office:master-styles>
|
||||
<office:body><office:text>
|
||||
<text:p text:style-name="Quiet">Some words.</text:p>
|
||||
<text:p><draw:frame><draw:image><office:binary-data>
|
||||
{PNG_B64}
|
||||
</office:binary-data></draw:image></draw:frame></text:p>
|
||||
<text:p><draw:frame><draw:object><math:math><math:mi>x</math:mi></math:math></draw:object></draw:frame></text:p>
|
||||
<text:p><draw:frame><draw:object><office:document office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.chart"><office:body><office:chart/></office:body></office:document></draw:object></draw:frame></text:p>
|
||||
</office:text></office:body>
|
||||
</office:document>"#
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flat_document_becomes_a_package_and_back() {
|
||||
let png = {
|
||||
use base64::Engine as _;
|
||||
base64::engine::general_purpose::STANDARD.decode(PNG_B64).unwrap()
|
||||
};
|
||||
let doc = flat::split(flat_doc(mime::TEXT).as_bytes(), &Limits::DEFAULT).unwrap();
|
||||
assert_eq!(doc.mimetype.as_deref(), Some(mime::TEXT));
|
||||
// Each part has what a package's file has; the root's media type stays with the package.
|
||||
assert!(doc.content.contains("<office:document-content") && doc.content.contains("T1") && !doc.content.contains("Quiet\" style:family"));
|
||||
assert!(!doc.content.contains("office:mimetype=\"application/vnd.oasis.opendocument.text\""));
|
||||
assert!(doc.styles.contains("<office:document-styles") && doc.styles.contains("Quiet") && doc.styles.contains("Standard"));
|
||||
assert!(doc.meta.contains("Flat title") && doc.settings.contains("<office:document-settings"));
|
||||
// The picture, the formula and the chart are files the content points at.
|
||||
assert!(doc.content.contains(r#"<draw:image xlink:href="Pictures/flat1.png">"#), "{}", doc.content);
|
||||
assert!(doc.content.contains(r#"<draw:object xlink:href="./Object flat2"/>"#));
|
||||
let names: Vec<&str> = doc.files.iter().map(|(n, _)| n.as_str()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"Pictures/flat1.png",
|
||||
"Object flat2/content.xml",
|
||||
"Object flat3/content.xml",
|
||||
"Object flat3/styles.xml",
|
||||
"Object flat3/meta.xml",
|
||||
"Object flat3/settings.xml"
|
||||
]
|
||||
);
|
||||
assert_eq!(doc.files.first().map(|(_, d)| d.as_slice()), Some(png.as_slice()));
|
||||
assert_eq!(doc.folders, [("Object flat3/".to_string(), mime::CHART.to_string())]);
|
||||
|
||||
let package = flat::to_package(flat_doc(mime::TEXT).as_bytes(), mime::GRAPHICS, &Limits::DEFAULT).unwrap();
|
||||
let mut p = Package::open(&package, &Limits::DEFAULT).unwrap();
|
||||
assert_eq!(p.mimetype().as_deref(), Some(mime::TEXT));
|
||||
assert_eq!(p.read("Pictures/flat1.png").unwrap().as_deref(), Some(png.as_slice()));
|
||||
assert_eq!(p.manifest().media_type("Object flat3/"), Some(mime::CHART));
|
||||
|
||||
// And back: one document of the same media type, the picture, formula and chart inline.
|
||||
let again = String::from_utf8(flat::from_package(&package, &Limits::DEFAULT).unwrap()).unwrap();
|
||||
assert!(again.contains(&format!("office:mimetype=\"{}\">", mime::TEXT)), "{again}");
|
||||
assert!(again.contains(&format!("<draw:image><office:binary-data>{PNG_B64}</office:binary-data></draw:image>")), "{again}");
|
||||
assert!(again.contains("<draw:object><math:math><math:mi>x</math:mi></math:math></draw:object>"), "{again}");
|
||||
assert!(
|
||||
again.contains(
|
||||
r#"office:mimetype="application/vnd.oasis.opendocument.chart"><office:body><office:chart/></office:body></office:document></draw:object>"#
|
||||
),
|
||||
"{again}"
|
||||
);
|
||||
assert!(!again.contains("xlink:href"), "{again}");
|
||||
// Taking that apart gives the same files.
|
||||
let twice = flat::split(again.as_bytes(), &Limits::DEFAULT).unwrap();
|
||||
assert_eq!((twice.files.len(), &twice.folders), (doc.files.len(), &doc.folders));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flat_document_of_another_kind_keeps_its_media_type() {
|
||||
let package = flat::to_package(flat_doc(mime::SPREADSHEET).as_bytes(), mime::TEXT, &Limits::DEFAULT).unwrap();
|
||||
assert_eq!(Package::open(&package, &Limits::DEFAULT).unwrap().mimetype().as_deref(), Some(mime::SPREADSHEET));
|
||||
// One that declares none takes the caller's.
|
||||
let bare = "<office:document><office:body/></office:document>";
|
||||
let package = flat::to_package(bare.as_bytes(), mime::PRESENTATION, &Limits::DEFAULT).unwrap();
|
||||
assert_eq!(Package::open(&package, &Limits::DEFAULT).unwrap().mimetype().as_deref(), Some(mime::PRESENTATION));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_flat_documents_do_not_panic() {
|
||||
assert_eq!(flat::split(b"\xff\xfe", &Limits::DEFAULT), Err(Error::Flat("not UTF-8 text")));
|
||||
assert_eq!(flat::split(b"<html/>", &Limits::DEFAULT), Err(Error::Flat("no office:document")));
|
||||
assert!(flat::split(b"<office:document-content/>", &Limits::DEFAULT).is_err());
|
||||
for xml in [
|
||||
"<office:document",
|
||||
"<office:document><office:body><draw:image><office:binary-data>!!!not base64",
|
||||
"<office:document><office:body><draw:image><office:binary-data>!!!</office:binary-data></draw:image><draw:object><math:math>",
|
||||
"<office:document><office:body><office:binary-data>AAAA</office:binary-data></office:body></office:document>",
|
||||
] {
|
||||
let _ = flat::split(xml.as_bytes(), &Limits::DEFAULT);
|
||||
let _ = flat::to_package(xml.as_bytes(), mime::TEXT, &Limits::DEFAULT);
|
||||
}
|
||||
// A picture over the per-file limit is left out; the document still opens.
|
||||
let doc = flat::split(flat_doc(mime::TEXT).as_bytes(), &Limits { max_entry: 10, ..Limits::DEFAULT }).unwrap();
|
||||
assert!(!doc.files.iter().any(|(n, _)| n.starts_with("Pictures/")));
|
||||
// More inline objects than are taken out: the first 10,000 become files, the rest go.
|
||||
let many = format!("<office:document><office:body>{}</office:body></office:document>", "<draw:object><math:math/></draw:object>".repeat(10_050));
|
||||
assert_eq!(flat::split(many.as_bytes(), &Limits::DEFAULT).unwrap().files.len(), 10_000);
|
||||
// A package with no media type has no flat form.
|
||||
assert_eq!(flat::from_package(&zip_of(&[("content.xml", b"<a/>")]), &Limits::DEFAULT), Err(Error::Flat("the package has no media type")));
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//! A bytes-only check of a ZIP package's declared entry count before the `zip` crate opens it.
|
||||
//! [`crate::Package::open`] calls [`check`] first; a reader of any other ZIP package may too.
|
||||
//!
|
||||
//! `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.
|
||||
|
||||
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(bytes: &[u8], max_entries: u64) -> Result<(), String> {
|
||||
let u16_at = |at: usize| bytes.get(at..at.checked_add(2)?).and_then(|b| <[u8; 2]>::try_from(b).ok()).map(u16::from_le_bytes);
|
||||
let u32_at = |at: usize| bytes.get(at..at.checked_add(4)?).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(u32::from_le_bytes);
|
||||
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<(), String> {
|
||||
if n > max_entries { Err(format!("the package declares {n} entries, more than {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(format!("more than {MAX_END_RECORDS} end of central directory 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 readers' tests.
|
||||
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, zip64_entry_count_sample};
|
||||
|
||||
/// 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!(check(&sample, 20_000).is_err_and(|e| e.contains("20001 entries")));
|
||||
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!(check(&plain(30_000, b"a comment"), 20_000).is_err_and(|e| e.contains("30000 entries")));
|
||||
// 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!(check(&f, 20_000).is_err_and(|e| e.contains("end of central directory records")));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user