strypt_core/formats/odf.rs
1//! `OpenDocument` — `.odt`, `.ods`, `.odp`.
2//!
3//! A ZIP package like Office Open XML, and the ZIP layer ([`crate::container::zip`], ADR-0028)
4//! and the one-level descent into embedded images ([`crate::container::package`], ADR-0029) are
5//! shared with it unchanged. **What is inside the package is not shared, and the differences are
6//! not cosmetic** — ADR-0031 records them. The four that shape this module:
7//!
8//! 1. **Parts are found by name, not by declared type.** ADR-0030 matches Office property parts
9//! on their content type, because `docProps/` is a convention and the content type is the
10//! contract. ODF is the other way round: `meta.xml`, `settings.xml`, and
11//! `META-INF/manifest.xml` are *named* by ODF 1.3 Part 2 §3.1, while the manifest gives
12//! `meta.xml` the media type `text/xml` — the same as every other XML part in the package.
13//! There is no type to match on, so the rule that is right for one format is unusable in the
14//! other.
15//! 2. **Authorship is element text, not an attribute.** See [`rules`].
16//! 3. **An encrypted package does not look encrypted to ZIP.** ODF encrypts entry data itself
17//! and records it in the manifest (Part 2 §3.4), so the general-purpose-bit refusal that
18//! catches an encrypted Office document passes an encrypted `OpenDocument` through. Refusing it
19//! is this handler's job, and it is the most dangerous thing in this module.
20//! 4. **An embedded chart is not a nested container.** `LibreOffice` stores one as ordinary
21//! entries in the same archive — `Object 1/content.xml`, `Object 1/meta.xml` — so its author
22//! metadata is reachable in the same pass, with no recursion at all. The equivalent Office
23//! document holds a whole `.xlsx` inside itself and is refused (§7.6). Same feature, opposite
24//! outcome, because of how the two formats store it.
25//!
26//! # Where the metadata is
27//!
28//! - `meta.xml` — the document's own metadata: `meta:initial-creator` and `dc:creator` (which in
29//! ODF is the *last* person to save it), `meta:creation-date` and `dc:date`, `meta:printed-by`
30//! and `meta:print-date`, `meta:generator` (which names the operating system as well as the
31//! application), `meta:user-defined` properties, `meta:template` pointing at a file on the
32//! author's machine, and the pair with no Office counterpart worth calling equivalent:
33//! `meta:editing-cycles` and `meta:editing-duration`.
34//! - `settings.xml` — window geometry, the last cursor position, and — the reason it is removed
35//! rather than scrubbed — the printer's name and its base64 setup blob, plus a per-release set
36//! of configuration keys that fingerprints the producing build.
37//! - `Thumbnails/thumbnail.png` — a rendered preview of the first page (Part 2 §3.8). It
38//! survives every redaction applied to the text, exactly as an Exif thumbnail survives
39//! cropping.
40//! - `Configurations2/` — the producer's saved user-interface configuration.
41//! - `Pictures/` — whole JPEG, PNG, and WebP files with whatever their cameras wrote.
42//! - `content.xml` and `styles.xml` — comment and revision authorship, and fields holding a
43//! cached copy of the author's name.
44//! - The ZIP entry headers themselves, as for every package format.
45
46use std::collections::BTreeSet;
47
48use crate::container::package::{self, Action, Decision, Embedded, Part, as_u64};
49use crate::container::zip::{self, Method, Output};
50use crate::detect::Format;
51use crate::error::{MalformedDetail, Result, StryptError};
52use crate::formats::{MetadataHandler, ParseLimits, StripOptions, Stripped};
53use crate::report::{
54 Finding, InspectOptions, MetadataKind, MetadataReport, MetadataValue, Note, StripReport,
55};
56
57mod rules;
58
59/// The package's own one-line statement of what it is. ODF 1.3 Part 2 §3.3.
60const MIMETYPE: &str = "mimetype";
61/// The part that lists every other part. Required in every `OpenDocument` package (Part 2 §2.2.1).
62const MANIFEST: &str = "META-INF/manifest.xml";
63
64/// The three media types this release handles.
65pub(crate) const MEDIA_TYPES: [(&str, Format); 3] = [
66 ("application/vnd.oasis.opendocument.text", Format::Odt),
67 (
68 "application/vnd.oasis.opendocument.spreadsheet",
69 Format::Ods,
70 ),
71 (
72 "application/vnd.oasis.opendocument.presentation",
73 Format::Odp,
74 ),
75];
76
77/// The `OpenDocument` media-type prefix, used to *name* the ones this release does not handle.
78///
79/// Drawings, formulas, charts, databases, and every `-template` variant share this prefix. They
80/// are refused, and saying "an `OpenDocument` type strypt does not handle yet" is more use to
81/// someone than "a ZIP container": the first tells them the file was understood and declined,
82/// the second sounds like the file was not recognised at all.
83pub(crate) const MEDIA_TYPE_PREFIX: &str = "application/vnd.oasis.opendocument.";
84
85/// The media type a package's manifest declares for the package as a whole.
86///
87/// Exposed for detection, which has to identify the package before any handler is chosen, and
88/// must not have a second, more permissive idea of what an `OpenDocument` package looks like than
89/// the handler it routes to.
90pub(crate) fn root_media_type_of(manifest: &str) -> Option<String> {
91 rules::root_media_type(manifest)
92}
93
94/// The format a package's declared media type corresponds to, if this release handles it.
95pub(crate) fn format_for_media_type(media_type: &str) -> Option<Format> {
96 MEDIA_TYPES
97 .iter()
98 .find(|(candidate, _)| *candidate == media_type.trim())
99 .map(|(_, format)| *format)
100}
101
102/// Removal of metadata from `OpenDocument` documents.
103///
104/// One handler type serving three formats, instantiated once per format rather than branching
105/// internally, so that [`MetadataHandler::format`] keeps returning the format the registry
106/// dispatched on.
107#[derive(Debug, Clone, Copy)]
108pub struct OdfHandler {
109 format: Format,
110}
111
112impl OdfHandler {
113 /// The handler for text documents.
114 pub const ODT: Self = Self {
115 format: Format::Odt,
116 };
117 /// The handler for spreadsheets.
118 pub const ODS: Self = Self {
119 format: Format::Ods,
120 };
121 /// The handler for presentations.
122 pub const ODP: Self = Self {
123 format: Format::Odp,
124 };
125}
126
127impl MetadataHandler for OdfHandler {
128 fn name(&self) -> &'static str {
129 self.format.id()
130 }
131
132 fn format(&self) -> Format {
133 self.format
134 }
135
136 fn inspect(&self, input: &[u8], options: &InspectOptions) -> Result<MetadataReport> {
137 // The same pass stripping uses, with the output discarded — so "everything `strip`
138 // removes is something `inspect` can see" holds by construction rather than by two code
139 // paths agreeing to stay in step (ADR-0029).
140 let processed = process(input, self.format, options, &ParseLimits::default())?;
141 Ok(MetadataReport {
142 format: self.format,
143 findings: processed.findings,
144 notes: processed.notes,
145 })
146 }
147
148 fn strip(&self, input: &[u8], options: &StripOptions) -> Result<Stripped> {
149 let processed = process(input, self.format, &options.inspect, &options.limits)?;
150 Ok(Stripped {
151 report: StripReport {
152 format: self.format,
153 removed: processed.findings,
154 retained: Vec::new(),
155 notes: processed.notes,
156 input_bytes: as_u64(input.len()),
157 output_bytes: as_u64(processed.output.len()),
158 },
159 bytes: processed.output,
160 })
161 }
162}
163
164/// The result of one pass over a document.
165struct Processed {
166 findings: Vec<Finding>,
167 notes: Vec<Note>,
168 output: Vec<u8>,
169}
170
171/// Walk the package once and produce both the report and the sanitised archive.
172fn process(
173 input: &[u8],
174 format: Format,
175 options: &InspectOptions,
176 limits: &ParseLimits,
177) -> Result<Processed> {
178 let parts = package::read_parts(input, format, limits)?;
179 confirm_package(&parts, format)?;
180
181 let mut findings = Vec::new();
182 let mut notes = Vec::new();
183
184 package::refuse_nested_containers(&parts, format, &mut notes)?;
185
186 let dropped: BTreeSet<String> = parts
187 .iter()
188 .filter_map(|part| {
189 let name = part.name()?;
190 removed_whole(name).map(|_| name.to_owned())
191 })
192 .collect();
193
194 let mut outputs: Vec<Output<'_>> = Vec::with_capacity(parts.len());
195
196 // The `mimetype` entry shall be the first file in the package and shall not be compressed
197 // (Part 2 §3.3). Emitting it first is the only place this handler reorders anything, and it
198 // is what keeps output a conforming package when the input was written by a producer that
199 // did not put it there — a reader that checks the first entry to identify the file would
200 // otherwise be handed something it does not recognise as OpenDocument at all.
201 if let Some(part) = parts.iter().find(|p| p.name() == Some(MIMETYPE)) {
202 outputs.push(mimetype_output(part));
203 }
204
205 for part in &parts {
206 if part.name() == Some(MIMETYPE) {
207 continue;
208 }
209 let decision = decide(part, &dropped, options, limits)?;
210 findings.extend(decision.findings);
211 notes.extend(decision.notes);
212 match decision.action {
213 Action::Copy => outputs.push(Output::Copied(part.entry.clone())),
214 Action::Drop => {}
215 Action::Rewrite(data) => outputs.push(Output::Rewritten {
216 name: part.entry.name.to_vec(),
217 data,
218 flags: part.entry.flags,
219 }),
220 }
221 }
222
223 findings.extend(package::container_findings(&parts));
224
225 let output = zip::write(&outputs).map_err(|e| e.into_strypt(format))?;
226 Ok(Processed {
227 findings,
228 notes,
229 output,
230 })
231}
232
233/// The `mimetype` entry as it will be written: first, and stored.
234///
235/// A conforming package already stores it, in which case its bytes are copied through untouched.
236/// One that deflated it is re-emitted stored, which is a change to the input — recorded here
237/// rather than glossed, and the narrowest one available: the alternative is emitting a package
238/// that violates the clause every ODF reader uses to identify the format.
239fn mimetype_output<'a>(part: &Part<'a>) -> Output<'a> {
240 if part.entry.method == Method::Stored {
241 return Output::Copied(part.entry.clone());
242 }
243 Output::Rewritten {
244 name: part.entry.name.to_vec(),
245 data: part.data.clone().unwrap_or_default(),
246 flags: part.entry.flags,
247 }
248}
249
250/// Refuse anything that is not the `OpenDocument` package this handler was dispatched for.
251///
252/// Three separate refusals, none of them optional:
253///
254/// - **No manifest.** Part 2 §2.2.1 requires `META-INF/manifest.xml`. Without it there is no
255/// package, only a ZIP of loose XML, and treating it as a document would mean guessing.
256/// - **An encrypted package.** See [`rules::declares_encryption`] — ZIP cannot see this, and a
257/// package whose parts are ciphertext would otherwise be reported clean having been examined
258/// by nobody.
259/// - **A package that says it is something else.** Detection routes on the same declaration, so
260/// a mismatch means the two disagree, and guessing is how a handler ends up confidently
261/// reporting on a file it does not understand.
262fn confirm_package(parts: &[Part<'_>], format: Format) -> Result<()> {
263 let manifest = parts
264 .iter()
265 .find(|p| p.name() == Some(MANIFEST))
266 .ok_or_else(|| malformed(format, MalformedDetail::MissingMarker))?;
267 let manifest_text = manifest
268 .text()
269 .ok_or_else(|| malformed(format, MalformedDetail::BrokenIndex))?;
270
271 if rules::declares_encryption(manifest_text) {
272 return Err(malformed(format, MalformedDetail::UnsupportedFeature));
273 }
274
275 let declared = declared_format(parts, manifest_text)?;
276 if declared == Some(format) {
277 Ok(())
278 } else {
279 Err(malformed(format, MalformedDetail::MissingMarker))
280 }
281}
282
283/// What the package says it is, from its `mimetype` entry and its manifest.
284///
285/// Part 2 §3.3 requires the two to agree where both are present. Where they do not, the package
286/// is refused rather than resolved in either direction: a file with two different answers to
287/// "what am I" is one where different readers will disagree about what they are opening, and
288/// picking a winner would mean strypt deciding which of two documents the user has.
289fn declared_format(parts: &[Part<'_>], manifest_text: &str) -> Result<Option<Format>> {
290 let from_mimetype = parts
291 .iter()
292 .find(|p| p.name() == Some(MIMETYPE))
293 .and_then(Part::text)
294 .map(str::trim)
295 .map(str::to_owned);
296 let from_manifest = rules::root_media_type(manifest_text);
297
298 if let (Some(mime), Some(root)) = (&from_mimetype, &from_manifest)
299 && mime != root
300 {
301 return Err(StryptError::Malformed {
302 format: from_mimetype
303 .as_deref()
304 .and_then(format_for_media_type)
305 .unwrap_or(Format::Odt),
306 offset: None,
307 detail: MalformedDetail::BrokenIndex,
308 });
309 }
310 Ok(from_mimetype
311 .or(from_manifest)
312 .as_deref()
313 .and_then(format_for_media_type))
314}
315
316/// Whether a part is metadata in its entirety, and what it exposes.
317///
318/// **Matched on the name**, which is the inversion of ADR-0030 explained in the module header:
319/// ODF fixes these names in Part 2 §3.1, and gives them no media type that distinguishes them
320/// from any other XML in the package.
321///
322/// The leaf name rather than the whole path, so that an embedded object's own metadata — the
323/// `Object 1/meta.xml` of a chart, which carries the name of whoever made the chart — is removed
324/// by the same rule as the document's.
325fn removed_whole(name: &str) -> Option<MetadataKind> {
326 // A subtree, directory marker included: `Thumbnails/` and `Configurations2/` are removed
327 // whole, so an entry anywhere beneath them goes with them.
328 if name.starts_with("Thumbnails/") {
329 return Some(MetadataKind::Thumbnail);
330 }
331 if name.starts_with("Configurations2/") {
332 return Some(MetadataKind::SoftwareFingerprint);
333 }
334 let leaf = name.rsplit_once('/').map_or(name, |(_, leaf)| leaf);
335 match leaf {
336 "meta.xml" => Some(MetadataKind::PersonalIdentity),
337 "settings.xml" => Some(MetadataKind::SoftwareFingerprint),
338 // A binary cache of where the producer laid the text out, written by LibreOffice to make
339 // reopening faster. Its format is undocumented, it holds no payload, and nothing refers
340 // to it — so unlike an unrecognised part (which may be load-bearing and is copied with a
341 // note, §7.6) there is nothing to weigh against removing it.
342 "layout-cache" => Some(MetadataKind::Other),
343 _ => None,
344 }
345}
346
347/// Decide about one part.
348fn decide(
349 part: &Part<'_>,
350 dropped: &BTreeSet<String>,
351 options: &InspectOptions,
352 limits: &ParseLimits,
353) -> Result<Decision> {
354 let Some(name) = part.name() else {
355 // A part whose name is not UTF-8 cannot be one this handler knows, and cannot be named
356 // by the manifest, whose paths are text. Copied, and declared.
357 return Ok(Decision::unexamined(
358 "an entry whose name is not valid UTF-8",
359 part.entry.compressed.len(),
360 ));
361 };
362
363 if part.entry.is_directory() {
364 return Ok(if dropped.contains(name) {
365 Decision {
366 action: Action::Drop,
367 findings: Vec::new(),
368 notes: Vec::new(),
369 }
370 } else {
371 Decision::copy()
372 });
373 }
374
375 // The manifest, which has to stop listing whatever went. Handled here rather than in a pass
376 // of its own so that it keeps its position in the archive and is written exactly once —
377 // writing an index part in a second pass is the bug §7.6 records, where every reader
378 // tolerated the duplicate and only byte-identical idempotence noticed.
379 if name == MANIFEST {
380 let Some(text) = part.text() else {
381 return Ok(Decision::copy());
382 };
383 return Ok(Decision {
384 action: match rules::drop_manifest_entries(text, dropped) {
385 Some(rewritten) => Action::Rewrite(rewritten.into_bytes()),
386 None => Action::Copy,
387 },
388 findings: Vec::new(),
389 notes: Vec::new(),
390 });
391 }
392
393 if let Some(kind) = removed_whole(name) {
394 return Ok(Decision {
395 action: Action::Drop,
396 findings: metadata_part_findings(part, name, kind, options),
397 notes: Vec::new(),
398 });
399 }
400
401 let Some(data) = part.data.as_deref() else {
402 return Ok(Decision::copy());
403 };
404
405 // A photograph in `Pictures/` goes through the *same* handler the CLI uses on a loose file,
406 // one level deep and images only (ADR-0029).
407 if let Some(embedded) = package::embedded_image_format(data) {
408 return match package::strip_embedded_image(embedded, data, name, options, limits)? {
409 Embedded::Unchanged => Ok(Decision::copy()),
410 Embedded::Stripped {
411 bytes,
412 findings,
413 notes,
414 } => Ok(Decision {
415 action: Action::Rewrite(bytes),
416 findings,
417 notes,
418 }),
419 };
420 }
421
422 match part.text() {
423 Some(text) => {
424 let scrubbed = rules::scrub(text, name, options);
425 Ok(Decision {
426 action: match scrubbed.output {
427 // An unchanged part keeps its original compressed bytes, so a document with
428 // nothing to remove differs from its input only in its entry headers.
429 None => Action::Copy,
430 Some(rewritten) => Action::Rewrite(rewritten.into_bytes()),
431 },
432 findings: scrubbed.findings,
433 notes: scrubbed.notes,
434 })
435 }
436 // Not text, not an image strypt handles: a font, an embedded object's replacement
437 // rendering, a binary blob.
438 None => Ok(Decision::unexamined(name, data.len())),
439 }
440}
441
442/// Report what a metadata part held, before it is dropped.
443///
444/// The part goes whole either way, and naming its fields is what makes `strypt show` worth
445/// running before deciding to publish.
446fn metadata_part_findings(
447 part: &Part<'_>,
448 name: &str,
449 kind: MetadataKind,
450 options: &InspectOptions,
451) -> Vec<Finding> {
452 let leaf = name.rsplit_once('/').map_or(name, |(_, leaf)| leaf);
453 if let Some(text) = part.text() {
454 let findings = match leaf {
455 "meta.xml" => rules::meta_findings(text, name, options),
456 "settings.xml" => rules::settings_findings(text, name, options),
457 _ => Vec::new(),
458 };
459 if !findings.is_empty() {
460 return findings;
461 }
462 }
463
464 // The thumbnail, which is an image rather than XML, and anything else this pass does not
465 // itemise. An empty metadata part is still a part that should not be published, and a report
466 // that said nothing about it would be a report claiming there was nothing there.
467 vec![
468 Finding::new(
469 kind,
470 name.to_owned(),
471 as_u64(part.data.as_ref().map_or(0, Vec::len)),
472 )
473 .with_value(options, || MetadataValue::Opaque {
474 bytes: as_u64(part.data.as_ref().map_or(0, Vec::len)),
475 }),
476 ]
477}
478
479/// A malformed-structure error for this format.
480const fn malformed(format: Format, detail: MalformedDetail) -> StryptError {
481 StryptError::Malformed {
482 format,
483 offset: None,
484 detail,
485 }
486}
487
488#[cfg(test)]
489mod tests {
490 #![allow(clippy::unwrap_used)]
491
492 use super::*;
493
494 #[test]
495 fn the_parts_removed_whole_are_the_ones_odf_names() {
496 for (name, expected) in [
497 ("meta.xml", Some(MetadataKind::PersonalIdentity)),
498 ("settings.xml", Some(MetadataKind::SoftwareFingerprint)),
499 ("Thumbnails/thumbnail.png", Some(MetadataKind::Thumbnail)),
500 ("Thumbnails/", Some(MetadataKind::Thumbnail)),
501 (
502 "Configurations2/accelerator/current.xml",
503 Some(MetadataKind::SoftwareFingerprint),
504 ),
505 ("layout-cache", Some(MetadataKind::Other)),
506 // An embedded chart's own metadata, reachable in the same pass because ODF stores an
507 // embedded object as ordinary entries rather than as a nested archive.
508 ("Object 1/meta.xml", Some(MetadataKind::PersonalIdentity)),
509 ("content.xml", None),
510 ("styles.xml", None),
511 ("Pictures/image1.jpg", None),
512 ("META-INF/manifest.xml", None),
513 ] {
514 assert_eq!(removed_whole(name), expected, "{name}");
515 }
516 }
517
518 #[test]
519 fn a_media_type_this_release_does_not_handle_is_not_claimed() {
520 assert_eq!(
521 format_for_media_type("application/vnd.oasis.opendocument.text"),
522 Some(Format::Odt)
523 );
524 assert_eq!(
525 format_for_media_type("application/vnd.oasis.opendocument.spreadsheet"),
526 Some(Format::Ods)
527 );
528 // A drawing and a template are OpenDocument and are not in this format group.
529 assert_eq!(
530 format_for_media_type("application/vnd.oasis.opendocument.graphics"),
531 None
532 );
533 assert_eq!(
534 format_for_media_type("application/vnd.oasis.opendocument.text-template"),
535 None
536 );
537 }
538}