diff --git a/Cargo.lock b/Cargo.lock index b81957f5c..ebce5f5ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 6a8a60ad5..507ab1410 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/README.md b/README.md index 9cb2bd741..be5e2936e 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,7 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " Result<(), Box> { + let mut w = PackageWriter::new(mime::SPREADSHEET); + w.file("content.xml", "text/xml", b""); + 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"".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. diff --git a/crates/odf/src/crypt.rs b/crates/odf/src/crypt.rs new file mode 100644 index 000000000..ff6eb7742 --- /dev/null +++ b/crates/odf/src/crypt.rs @@ -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)> { + 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>, name: &str) -> Option> { + 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, password: &str) -> Result>, 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>, CryptError> { + let mac = hmac::Hmac::::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>, 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, 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, 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, password: &str) -> Result, 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> { + 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, 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("").map(|e| e + "".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, 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, 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, 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, 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}\">", + 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("") 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!("") { + 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); + } +} diff --git a/crates/odf/src/flat.rs b/crates/odf/src/flat.rs new file mode 100644 index 000000000..d71fbffe9 --- /dev/null +++ b/crates/odf/src/flat.rs @@ -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!(""); + 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(" &'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" Option { + let mut from = 0; + while let Some(i) = xml.get(from..).and_then(|r| r.find("').and_then(|e| r.get(..e))).unwrap_or(""); + let next = tag.get(")>, 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(""); + 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 { "" } else { "" }; + let Some(end) = from.find(close) else { + out.push_str(before); + rest = from; + break; + }; + let open_len = if picture { "".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(""); + ["', '\n', '\t', '\r']))) + .map(|el| (i, el)) + }); + match holder { + Some((at, el)) => { + let name = if el == " out.push_str(before), + } + } else { + let dir = format!("Object flat{n}"); + out.push_str(before); + out.push_str(&format!("")); + // 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, + /// `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)>, + /// 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 { + 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, 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, 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 { 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 ``). + 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!("")).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("` → `…`, 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!(">{}", STANDARD.encode(&data))); + if closed { + flat.push_str(""); + } + rest = past_tag; + } else if tag.starts_with("{}", STANDARD.encode(&data))); + rest = past("draw:object-ole"); + } else if tag.starts_with("").map_or(content.as_str(), |(_, m)| m).trim().to_string() + }; + // A plain `draw:object` around it. + flat.push_str(before.rfind("{math}")); + 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!( + "\n{flat}\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}>{}{}", 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!( + "{}", + 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("').and_then(|e| root.get(" { + 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!("\n{body}") + }; + 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 { + 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("').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 = "x".repeat(1200); + let outer = format!("{inner}"); + let xml = format!("{outer}"); + assert_eq!(element(&xml, "office:body"), outer); + // Nested deeper than any XML here: malformed, to the end, as an unclosed one. + let deep = format!("{}{}", "".repeat(600), "".repeat(600)); + assert_eq!(element(&deep, "office:body"), deep); + assert_eq!(element("", "office:body"), ""); + } +} diff --git a/crates/odf/src/lib.rs b/crates/odf/src/lib.rs new file mode 100644 index 000000000..174eb50cb --- /dev/null +++ b/crates/odf/src/lib.rs @@ -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""); +//! 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"".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; diff --git a/crates/odf/src/manifest.rs b/crates/odf/src/manifest.rs new file mode 100644 index 000000000..d355c6047 --- /dev/null +++ b/crates/odf/src/manifest.rs @@ -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, + /// `manifest:algorithm-name`. + pub algorithm: String, + /// `manifest:initialisation-vector`, decoded. + pub iv: Vec, + /// `manifest:start-key-generation-name` (`SHA1` when absent). + pub start_key: String, + /// The start key's `manifest:key-size`. + pub start_key_size: Option, + /// `manifest:key-derivation-name`. + pub derivation: String, + /// `manifest:iteration-count` (1024 when absent). + pub iterations: u32, + /// `manifest:salt`, decoded. + pub salt: Vec, + /// The derived key's `manifest:key-size`. + pub key_size: Option, +} + +/// 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, + /// `manifest:size`: the file's size before encryption. + pub size: Option, + /// Present when the file is encrypted. + pub encryption: Option, +} + +/// A package's manifest. +#[derive(Default, Debug, Clone, PartialEq, Eq)] +pub struct Manifest { + /// `manifest:version` of the root element. + pub version: Option, + pub entries: Vec, +} + +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!("{}\n"); + for e in &self.entries { + let _ = write!(o, " { + o.push('>'); + encryption_xml(&mut o, c); + o.push_str("\n"); + } + None => o.push_str("/>\n"), + } + } + o.push_str("\n"); + o + } +} + +fn encryption_xml(o: &mut String, c: &Encryption) { + let size = |s: Option| s.map(|s| format!(" manifest:key-size=\"{s}\"")).unwrap_or_default(); + let _ = write!( + o, + "", + 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 { + fn num(e: &El, name: &str) -> Option { + 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, + }) +} diff --git a/crates/odf/src/package.rs b/crates/odf/src/package.rs new file mode 100644 index 000000000..89977223b --- /dev/null +++ b/crates/odf/src/package.rs @@ -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>, + /// The files' names (folders and names that are not text left out), in the ZIP's order. + names: Vec, + 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, 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 { + 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 { + 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>, 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 { + 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, + 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, + entries: Vec, +} + +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, 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()) + } +} diff --git a/crates/odf/src/tests.rs b/crates/odf/src/tests.rs new file mode 100644 index 000000000..0894271ae --- /dev/null +++ b/crates/odf/src/tests.rs @@ -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 { + 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 { + let mut w = PackageWriter::new(mime::TEXT); + w.file("content.xml", "text/xml", b"Top secret words."); + w.file("styles.xml", "text/xml", b""); + 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::>(), ["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#""#, + r#""#, + r#""#, + ] { + 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#""[..], b"\xff\xfe\x00", b"", b"]>"] { + assert!(Manifest::parse(junk).entries.is_empty()); + } + // More entries than a package may hold are dropped. + let many = format!("{}", r#""#.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 { + 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"")]); + 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#""# + ) + }; + 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#""#; + let names: Vec = (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#" + + Flat title + + + + + Some words. + +{PNG_B64} + + x + + +"# + ) +} + +#[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(""#), "{}", doc.content); + assert!(doc.content.contains(r#""#)); + 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!("{PNG_B64}")), "{again}"); + assert!(again.contains("x"), "{again}"); + assert!( + again.contains( + r#"office:mimetype="application/vnd.oasis.opendocument.chart">"# + ), + "{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 = ""; + 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"", &Limits::DEFAULT), Err(Error::Flat("no office:document"))); + assert!(flat::split(b"", &Limits::DEFAULT).is_err()); + for xml in [ + "!!!not base64", + "!!!", + "AAAA", + ] { + 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!("{}", "".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"")]), &Limits::DEFAULT), Err(Error::Flat("the package has no media type"))); +} diff --git a/crates/odf/src/zip_guard.rs b/crates/odf/src/zip_guard.rs new file mode 100644 index 000000000..fe31708d8 --- /dev/null +++ b/crates/odf/src/zip_guard.rs @@ -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 { + // 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 { + 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 = (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"))); + } +}