use std::collections::BTreeSet;
use crate::container::package::{self, Action, Decision, Embedded, Part, as_u64};
use crate::container::zip::{self, Method, Output};
use crate::detect::Format;
use crate::error::{MalformedDetail, Result, StryptError};
use crate::formats::{MetadataHandler, ParseLimits, StripOptions, Stripped};
use crate::report::{
Finding, InspectOptions, MetadataKind, MetadataReport, MetadataValue, Note, StripReport,
};
mod rules;
const MIMETYPE: &str = "mimetype";
const MANIFEST: &str = "META-INF/manifest.xml";
pub(crate) const MEDIA_TYPES: [(&str, Format); 3] = [
("application/vnd.oasis.opendocument.text", Format::Odt),
(
"application/vnd.oasis.opendocument.spreadsheet",
Format::Ods,
),
(
"application/vnd.oasis.opendocument.presentation",
Format::Odp,
),
];
pub(crate) const MEDIA_TYPE_PREFIX: &str = "application/vnd.oasis.opendocument.";
pub(crate) fn root_media_type_of(manifest: &str) -> Option<String> {
rules::root_media_type(manifest)
}
pub(crate) fn format_for_media_type(media_type: &str) -> Option<Format> {
MEDIA_TYPES
.iter()
.find(|(candidate, _)| *candidate == media_type.trim())
.map(|(_, format)| *format)
}
#[derive(Debug, Clone, Copy)]
pub struct OdfHandler {
format: Format,
}
impl OdfHandler {
pub const ODT: Self = Self {
format: Format::Odt,
};
pub const ODS: Self = Self {
format: Format::Ods,
};
pub const ODP: Self = Self {
format: Format::Odp,
};
}
impl MetadataHandler for OdfHandler {
fn name(&self) -> &'static str {
self.format.id()
}
fn format(&self) -> Format {
self.format
}
fn inspect(&self, input: &[u8], options: &InspectOptions) -> Result<MetadataReport> {
let processed = process(input, self.format, options, &ParseLimits::default())?;
Ok(MetadataReport {
format: self.format,
findings: processed.findings,
notes: processed.notes,
})
}
fn strip(&self, input: &[u8], options: &StripOptions) -> Result<Stripped> {
let processed = process(input, self.format, &options.inspect, &options.limits)?;
Ok(Stripped {
report: StripReport {
format: self.format,
removed: processed.findings,
retained: Vec::new(),
notes: processed.notes,
input_bytes: as_u64(input.len()),
output_bytes: as_u64(processed.output.len()),
},
bytes: processed.output,
})
}
}
struct Processed {
findings: Vec<Finding>,
notes: Vec<Note>,
output: Vec<u8>,
}
fn process(
input: &[u8],
format: Format,
options: &InspectOptions,
limits: &ParseLimits,
) -> Result<Processed> {
let parts = package::read_parts(input, format, limits)?;
confirm_package(&parts, format)?;
let mut findings = Vec::new();
let mut notes = Vec::new();
package::refuse_nested_containers(&parts, format, &mut notes)?;
let dropped: BTreeSet<String> = parts
.iter()
.filter_map(|part| {
let name = part.name()?;
removed_whole(name).map(|_| name.to_owned())
})
.collect();
let mut outputs: Vec<Output<'_>> = Vec::with_capacity(parts.len());
if let Some(part) = parts.iter().find(|p| p.name() == Some(MIMETYPE)) {
outputs.push(mimetype_output(part));
}
for part in &parts {
if part.name() == Some(MIMETYPE) {
continue;
}
let decision = decide(part, &dropped, options, limits)?;
findings.extend(decision.findings);
notes.extend(decision.notes);
match decision.action {
Action::Copy => outputs.push(Output::Copied(part.entry.clone())),
Action::Drop => {}
Action::Rewrite(data) => outputs.push(Output::Rewritten {
name: part.entry.name.to_vec(),
data,
flags: part.entry.flags,
}),
}
}
findings.extend(package::container_findings(&parts));
let output = zip::write(&outputs).map_err(|e| e.into_strypt(format))?;
Ok(Processed {
findings,
notes,
output,
})
}
fn mimetype_output<'a>(part: &Part<'a>) -> Output<'a> {
if part.entry.method == Method::Stored {
return Output::Copied(part.entry.clone());
}
Output::Rewritten {
name: part.entry.name.to_vec(),
data: part.data.clone().unwrap_or_default(),
flags: part.entry.flags,
}
}
fn confirm_package(parts: &[Part<'_>], format: Format) -> Result<()> {
let manifest = parts
.iter()
.find(|p| p.name() == Some(MANIFEST))
.ok_or_else(|| malformed(format, MalformedDetail::MissingMarker))?;
let manifest_text = manifest
.text()
.ok_or_else(|| malformed(format, MalformedDetail::BrokenIndex))?;
if rules::declares_encryption(manifest_text) {
return Err(malformed(format, MalformedDetail::UnsupportedFeature));
}
let declared = declared_format(parts, manifest_text)?;
if declared == Some(format) {
Ok(())
} else {
Err(malformed(format, MalformedDetail::MissingMarker))
}
}
fn declared_format(parts: &[Part<'_>], manifest_text: &str) -> Result<Option<Format>> {
let from_mimetype = parts
.iter()
.find(|p| p.name() == Some(MIMETYPE))
.and_then(Part::text)
.map(str::trim)
.map(str::to_owned);
let from_manifest = rules::root_media_type(manifest_text);
if let (Some(mime), Some(root)) = (&from_mimetype, &from_manifest)
&& mime != root
{
return Err(StryptError::Malformed {
format: from_mimetype
.as_deref()
.and_then(format_for_media_type)
.unwrap_or(Format::Odt),
offset: None,
detail: MalformedDetail::BrokenIndex,
});
}
Ok(from_mimetype
.or(from_manifest)
.as_deref()
.and_then(format_for_media_type))
}
fn removed_whole(name: &str) -> Option<MetadataKind> {
if name.starts_with("Thumbnails/") {
return Some(MetadataKind::Thumbnail);
}
if name.starts_with("Configurations2/") {
return Some(MetadataKind::SoftwareFingerprint);
}
let leaf = name.rsplit_once('/').map_or(name, |(_, leaf)| leaf);
match leaf {
"meta.xml" => Some(MetadataKind::PersonalIdentity),
"settings.xml" => Some(MetadataKind::SoftwareFingerprint),
"layout-cache" => Some(MetadataKind::Other),
_ => None,
}
}
fn decide(
part: &Part<'_>,
dropped: &BTreeSet<String>,
options: &InspectOptions,
limits: &ParseLimits,
) -> Result<Decision> {
let Some(name) = part.name() else {
return Ok(Decision::unexamined(
"an entry whose name is not valid UTF-8",
part.entry.compressed.len(),
));
};
if part.entry.is_directory() {
return Ok(if dropped.contains(name) {
Decision {
action: Action::Drop,
findings: Vec::new(),
notes: Vec::new(),
}
} else {
Decision::copy()
});
}
if name == MANIFEST {
let Some(text) = part.text() else {
return Ok(Decision::copy());
};
return Ok(Decision {
action: match rules::drop_manifest_entries(text, dropped) {
Some(rewritten) => Action::Rewrite(rewritten.into_bytes()),
None => Action::Copy,
},
findings: Vec::new(),
notes: Vec::new(),
});
}
if let Some(kind) = removed_whole(name) {
return Ok(Decision {
action: Action::Drop,
findings: metadata_part_findings(part, name, kind, options),
notes: Vec::new(),
});
}
let Some(data) = part.data.as_deref() else {
return Ok(Decision::copy());
};
if let Some(embedded) = package::embedded_image_format(data) {
return match package::strip_embedded_image(embedded, data, name, options, limits)? {
Embedded::Unchanged => Ok(Decision::copy()),
Embedded::Stripped {
bytes,
findings,
notes,
} => Ok(Decision {
action: Action::Rewrite(bytes),
findings,
notes,
}),
};
}
match part.text() {
Some(text) => {
let scrubbed = rules::scrub(text, name, options);
Ok(Decision {
action: match scrubbed.output {
None => Action::Copy,
Some(rewritten) => Action::Rewrite(rewritten.into_bytes()),
},
findings: scrubbed.findings,
notes: scrubbed.notes,
})
}
None => Ok(Decision::unexamined(name, data.len())),
}
}
fn metadata_part_findings(
part: &Part<'_>,
name: &str,
kind: MetadataKind,
options: &InspectOptions,
) -> Vec<Finding> {
let leaf = name.rsplit_once('/').map_or(name, |(_, leaf)| leaf);
if let Some(text) = part.text() {
let findings = match leaf {
"meta.xml" => rules::meta_findings(text, name, options),
"settings.xml" => rules::settings_findings(text, name, options),
_ => Vec::new(),
};
if !findings.is_empty() {
return findings;
}
}
vec![
Finding::new(
kind,
name.to_owned(),
as_u64(part.data.as_ref().map_or(0, Vec::len)),
)
.with_value(options, || MetadataValue::Opaque {
bytes: as_u64(part.data.as_ref().map_or(0, Vec::len)),
}),
]
}
const fn malformed(format: Format, detail: MalformedDetail) -> StryptError {
StryptError::Malformed {
format,
offset: None,
detail,
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn the_parts_removed_whole_are_the_ones_odf_names() {
for (name, expected) in [
("meta.xml", Some(MetadataKind::PersonalIdentity)),
("settings.xml", Some(MetadataKind::SoftwareFingerprint)),
("Thumbnails/thumbnail.png", Some(MetadataKind::Thumbnail)),
("Thumbnails/", Some(MetadataKind::Thumbnail)),
(
"Configurations2/accelerator/current.xml",
Some(MetadataKind::SoftwareFingerprint),
),
("layout-cache", Some(MetadataKind::Other)),
("Object 1/meta.xml", Some(MetadataKind::PersonalIdentity)),
("content.xml", None),
("styles.xml", None),
("Pictures/image1.jpg", None),
("META-INF/manifest.xml", None),
] {
assert_eq!(removed_whole(name), expected, "{name}");
}
}
#[test]
fn a_media_type_this_release_does_not_handle_is_not_claimed() {
assert_eq!(
format_for_media_type("application/vnd.oasis.opendocument.text"),
Some(Format::Odt)
);
assert_eq!(
format_for_media_type("application/vnd.oasis.opendocument.spreadsheet"),
Some(Format::Ods)
);
assert_eq!(
format_for_media_type("application/vnd.oasis.opendocument.graphics"),
None
);
assert_eq!(
format_for_media_type("application/vnd.oasis.opendocument.text-template"),
None
);
}
}