Skip to main content

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}