use std::collections::{BTreeMap, BTreeSet};
use crate::container::package::{self, Action, Decision, Embedded, Part, as_u64};
use crate::container::zip::{self, Output};
use crate::detect::Format;
use crate::error::{MalformedDetail, Result, StryptError};
use crate::formats::xml;
use crate::formats::{MetadataHandler, ParseLimits, StripOptions, Stripped};
use crate::report::{
Finding, InspectOptions, MetadataKind, MetadataReport, MetadataValue, Note, StripReport,
};
mod rules;
const CONTENT_TYPES: &str = "[Content_Types].xml";
const ROOT_RELS: &str = "_rels/.rels";
const PROPERTY_CONTENT_TYPES: [(&str, MetadataKind); 3] = [
(
"application/vnd.openxmlformats-package.core-properties+xml",
MetadataKind::PersonalIdentity,
),
(
"application/vnd.openxmlformats-officedocument.extended-properties+xml",
MetadataKind::SoftwareFingerprint,
),
(
"application/vnd.openxmlformats-officedocument.custom-properties+xml",
MetadataKind::PersonalIdentity,
),
];
const THUMBNAIL_RELATIONSHIP: &str =
"http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail";
const MAIN_PART_TYPES: [(&str, Format); 3] = [
(
"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml",
Format::Docx,
),
(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml",
Format::Xlsx,
),
(
"application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml",
Format::Pptx,
),
];
#[derive(Debug, Clone, Copy)]
pub struct OoxmlHandler {
format: Format,
}
impl OoxmlHandler {
pub const DOCX: Self = Self {
format: Format::Docx,
};
pub const XLSX: Self = Self {
format: Format::Xlsx,
};
pub const PPTX: Self = Self {
format: Format::Pptx,
};
}
impl MetadataHandler for OoxmlHandler {
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)?;
let types = ContentTypes::parse(&parts, format)?;
types.confirm_format(format)?;
let mut findings = Vec::new();
let mut notes = Vec::new();
let rels = Relationships::parse(&parts);
let dropped = parts_to_drop(&parts, &types, &rels);
let dead_rels = dead_relationships(&parts);
package::refuse_nested_containers(&parts, format, &mut notes)?;
let mut outputs: Vec<Output<'_>> = Vec::with_capacity(parts.len());
for part in &parts {
let decision = decide(part, &types, &dropped, &dead_rels, 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,
})
}
struct ContentTypes {
overrides: Vec<(String, String)>,
defaults: Vec<(String, String)>,
}
impl ContentTypes {
fn parse(parts: &[Part<'_>], format: Format) -> Result<Self> {
let part = parts
.iter()
.find(|p| p.name() == Some(CONTENT_TYPES))
.ok_or_else(|| malformed(format, MalformedDetail::MissingMarker))?;
let text = part
.text()
.ok_or_else(|| malformed(format, MalformedDetail::BrokenIndex))?;
let mut overrides = Vec::new();
let mut defaults = Vec::new();
for tag in xml::tags(text) {
match tag.name {
"Override" => {
if let (Some(name), Some(kind)) =
(tag.attribute("PartName"), tag.attribute("ContentType"))
{
overrides.push((normalise_part_name(name), kind.to_owned()));
}
}
"Default" => {
if let (Some(ext), Some(kind)) =
(tag.attribute("Extension"), tag.attribute("ContentType"))
{
defaults.push((ext.to_ascii_lowercase(), kind.to_owned()));
}
}
_ => {}
}
}
Ok(Self {
overrides,
defaults,
})
}
fn type_of(&self, part: &str) -> Option<&str> {
if let Some((_, kind)) = self.overrides.iter().find(|(name, _)| name == part) {
return Some(kind);
}
let ext = part.rsplit_once('.')?.1.to_ascii_lowercase();
self.defaults
.iter()
.find(|(candidate, _)| *candidate == ext)
.map(|(_, kind)| kind.as_str())
}
fn confirm_format(&self, format: Format) -> Result<()> {
let declared = self.overrides.iter().find_map(|(_, kind)| {
MAIN_PART_TYPES
.iter()
.find(|(candidate, _)| candidate == kind)
.map(|(_, f)| *f)
});
if declared == Some(format) {
Ok(())
} else {
Err(malformed(format, MalformedDetail::MissingMarker))
}
}
}
#[derive(Default)]
struct Relationships {
metadata_targets: Vec<String>,
external_targets: Vec<String>,
}
impl Relationships {
fn parse(parts: &[Part<'_>]) -> Self {
let Some(text) = parts
.iter()
.find(|p| p.name() == Some(ROOT_RELS))
.and_then(Part::text)
else {
return Self::default();
};
let mut rels = Self::default();
for tag in xml::tags(text) {
if tag.name != "Relationship" {
continue;
}
let Some(target) = tag.attribute("Target") else {
continue;
};
if tag.attribute("Type") == Some(THUMBNAIL_RELATIONSHIP) {
rels.metadata_targets.push(normalise_part_name(target));
}
if tag.attribute("TargetMode") == Some("External") {
rels.external_targets.push(target.to_owned());
}
}
rels
}
}
fn parts_to_drop(
parts: &[Part<'_>],
types: &ContentTypes,
rels: &Relationships,
) -> BTreeSet<String> {
let mut dropped: BTreeSet<String> = rels.metadata_targets.iter().cloned().collect();
for part in parts {
let Some(name) = part.name() else { continue };
let Some(kind) = types.type_of(name) else {
continue;
};
if PROPERTY_CONTENT_TYPES
.iter()
.any(|(candidate, _)| *candidate == kind)
{
dropped.insert(name.to_owned());
}
}
dropped
}
fn decide(
part: &Part<'_>,
types: &ContentTypes,
dropped: &BTreeSet<String>,
dead_rels: &DeadRelationships,
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(Decision::copy());
}
if name == CONTENT_TYPES || name == ROOT_RELS {
let Some(text) = part.text() else {
return Ok(Decision::copy());
};
let dereferenced = rules::drop_references(text, dropped);
let base = dereferenced.as_deref().unwrap_or(text);
let scrubbed = rules::scrub(base, name, dead_rels.for_part(name), options);
let action = match scrubbed.output.or(dereferenced) {
Some(rewritten) => Action::Rewrite(rewritten.into_bytes()),
None => Action::Copy,
};
return Ok(Decision {
action,
findings: scrubbed.findings,
notes: scrubbed.notes,
});
}
if dropped.contains(name) {
return Ok(Decision {
action: Action::Drop,
findings: property_findings(part, name, types, 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)? {
package::Embedded::Unchanged => Ok(Decision::copy()),
Embedded::Stripped {
bytes,
findings,
notes,
} => Ok(Decision {
action: Action::Rewrite(bytes),
findings,
notes,
}),
};
}
match part.text() {
Some(text) => Ok(scrub_part(text, name, dead_rels.for_part(name), options)),
None => Ok(Decision::unexamined(name, data.len())),
}
}
fn property_findings(
part: &Part<'_>,
name: &str,
types: &ContentTypes,
options: &InspectOptions,
) -> Vec<Finding> {
let kind = types
.type_of(name)
.and_then(|declared| {
PROPERTY_CONTENT_TYPES
.iter()
.find(|(candidate, _)| *candidate == declared)
.map(|(_, kind)| *kind)
})
.unwrap_or(MetadataKind::Other);
let Some(text) = part.text() else {
return vec![Finding::new(
MetadataKind::Thumbnail,
name.to_owned(),
as_u64(part.data.as_ref().map_or(0, Vec::len)),
)];
};
let mut findings = Vec::new();
for element in xml::elements_with_text(text) {
if element.text.trim().is_empty() {
continue;
}
findings.push(
Finding::new(
kind_of_property(element.name, kind),
name.to_owned(),
as_u64(element.text.len()),
)
.with_field(element.name.to_owned())
.with_value(options, || MetadataValue::Text(element.text.to_owned())),
);
}
if findings.is_empty() {
findings.push(Finding::new(kind, name.to_owned(), as_u64(text.len())));
}
findings
}
fn kind_of_property(element: &str, fallback: MetadataKind) -> MetadataKind {
match element {
"dc:creator" | "cp:lastModifiedBy" | "Manager" | "Company" => {
MetadataKind::PersonalIdentity
}
"dcterms:created" | "dcterms:modified" | "cp:lastPrinted" => MetadataKind::Timestamp,
"Application" | "AppVersion" | "Template" => MetadataKind::SoftwareFingerprint,
"TotalTime" | "cp:revision" => MetadataKind::EditingHistory,
"cp:contentStatus" | "dc:description" | "cp:keywords" | "dc:subject" => {
MetadataKind::Comment
}
_ => fallback,
}
}
fn scrub_part(
text: &str,
name: &str,
dead_rel_ids: &BTreeSet<String>,
options: &InspectOptions,
) -> Decision {
let scrubbed = rules::scrub(text, name, dead_rel_ids, options);
let action = match scrubbed.output {
None => Action::Copy,
Some(text) => Action::Rewrite(text.into_bytes()),
};
Decision {
action,
findings: scrubbed.findings,
notes: scrubbed.notes,
}
}
#[derive(Default)]
struct DeadRelationships {
by_part: BTreeMap<String, BTreeSet<String>>,
}
impl DeadRelationships {
fn for_part(&self, name: &str) -> &BTreeSet<String> {
static NONE: BTreeSet<String> = BTreeSet::new();
self.by_part.get(name).unwrap_or(&NONE)
}
}
fn dead_relationships(parts: &[Part<'_>]) -> DeadRelationships {
let mut dead = DeadRelationships::default();
for part in parts {
let (Some(name), Some(text)) = (part.name(), part.text()) else {
continue;
};
if !name.to_ascii_lowercase().ends_with(".rels") {
continue;
}
let ids = rules::external_local_relationships(text);
if ids.is_empty() {
continue;
}
if let Some(owner) = owner_part(name) {
dead.by_part.entry(owner).or_default().extend(ids.clone());
}
dead.by_part.entry(name.to_owned()).or_default().extend(ids);
}
dead
}
fn owner_part(rels_name: &str) -> Option<String> {
let stem = rels_name.strip_suffix(".rels")?;
let (directory, file) = match stem.rsplit_once('/') {
Some((directory, file)) => (directory, file),
None => ("", stem),
};
let base = directory.strip_suffix("_rels")?;
if file.is_empty() {
return None;
}
Some(format!("{base}{file}"))
}
fn normalise_part_name(name: &str) -> String {
name.strip_prefix('/').unwrap_or(name).to_owned()
}
const fn malformed(format: Format, detail: MalformedDetail) -> StryptError {
StryptError::Malformed {
format,
offset: None,
detail,
}
}