use std::collections::BTreeSet;
use crate::formats::xml::{self, Cut, Kind, Tag};
use crate::report::{Finding, InspectOptions, MetadataKind, MetadataValue, Note};
const ANNOTATION_ELEMENT: &str = "office:annotation";
const CHANGE_INFO_ELEMENT: &str = "office:change-info";
const ATTRIBUTION_ELEMENTS: [(&str, MetadataKind); 3] = [
("dc:creator", MetadataKind::PersonalIdentity),
("dc:date", MetadataKind::Timestamp),
("meta:date-string", MetadataKind::Timestamp),
];
const CACHED_METADATA_FIELDS: [(&str, MetadataKind); 7] = [
("text:creator", MetadataKind::PersonalIdentity),
("text:initial-creator", MetadataKind::PersonalIdentity),
("text:author-name", MetadataKind::PersonalIdentity),
("text:author-initials", MetadataKind::PersonalIdentity),
("text:printed-by", MetadataKind::PersonalIdentity),
("text:editing-cycles", MetadataKind::EditingHistory),
("text:editing-duration", MetadataKind::EditingHistory),
];
const DISPLAYED_TIME_FIELDS: [&str; 6] = [
"text:creation-date",
"text:creation-time",
"text:modification-date",
"text:modification-time",
"text:print-date",
"text:print-time",
];
const META_ELEMENTS: [(&str, MetadataKind); 12] = [
("meta:initial-creator", MetadataKind::PersonalIdentity),
("dc:creator", MetadataKind::PersonalIdentity),
("meta:printed-by", MetadataKind::PersonalIdentity),
("meta:creation-date", MetadataKind::Timestamp),
("dc:date", MetadataKind::Timestamp),
("meta:print-date", MetadataKind::Timestamp),
("meta:editing-cycles", MetadataKind::EditingHistory),
("meta:editing-duration", MetadataKind::EditingHistory),
("meta:generator", MetadataKind::SoftwareFingerprint),
("dc:title", MetadataKind::Comment),
("dc:description", MetadataKind::Comment),
("dc:subject", MetadataKind::Comment),
];
const USER_DEFINED_ELEMENT: &str = "meta:user-defined";
const STATISTIC_ELEMENT: &str = "meta:document-statistic";
const TEMPLATE_ELEMENT: &str = "meta:template";
const SENSITIVE_SETTINGS: [(&str, MetadataKind); 6] = [
("PrinterName", MetadataKind::DeviceIdentity),
("PrinterSetup", MetadataKind::DeviceIdentity),
("CurrentDatabaseDataSource", MetadataKind::PersonalIdentity),
("CurrentDatabaseCommand", MetadataKind::PersonalIdentity),
("BuildId", MetadataKind::SoftwareFingerprint),
("ColorPalettes", MetadataKind::SoftwareFingerprint),
];
pub(super) struct Scrubbed {
pub(super) output: Option<String>,
pub(super) findings: Vec<Finding>,
pub(super) notes: Vec<Note>,
}
pub(super) fn scrub(src: &str, part: &str, options: &InspectOptions) -> Scrubbed {
let tags = xml::tags(src);
let mut cuts: Vec<Cut> = Vec::new();
let mut removed: Vec<(&'static str, MetadataKind, u64, Option<String>)> = Vec::new();
let mut notes = Vec::new();
let mut open = xml::OpenElements::new();
for (index, tag) in tags.iter().enumerate() {
if tag.kind == Kind::Open {
let attribution = open.inside(ANNOTATION_ELEMENT) || open.inside(CHANGE_INFO_ELEMENT);
let rule = if attribution {
lookup(&ATTRIBUTION_ELEMENTS, tag.name)
} else {
None
}
.or_else(|| lookup(&CACHED_METADATA_FIELDS, tag.name));
if let Some((name, kind)) = rule
&& let Some((cut, value)) = value_span(&tags, index, src)
&& !cut.is_empty()
{
cuts.push(cut);
record(
&mut removed,
name,
kind,
cut.len(),
options.include_values.then(|| value.to_owned()),
);
}
}
if !open.observe(tag) {
return Scrubbed {
output: None,
findings: Vec::new(),
notes: vec![Note::UnparsedRegion {
location: format!("{part} (markup the scanner could not follow)"),
bytes: u64::try_from(src.len()).unwrap_or(u64::MAX),
}],
};
}
}
notes.extend(content_notes(src, part));
let findings = removed
.into_iter()
.map(|(name, kind, bytes, value)| {
Finding::new(kind, part.to_owned(), bytes)
.with_field(name.to_owned())
.with_value(options, || MetadataValue::Text(value.unwrap_or_default()))
})
.collect();
Scrubbed {
output: xml::apply(src, cuts),
findings,
notes,
}
}
fn value_span<'a>(tags: &[Tag<'a>], index: usize, src: &'a str) -> Option<(Cut, &'a str)> {
if let Some(text) = xml::element_text(tags, index, src) {
return Some((text.range, text.value));
}
let span = xml::element_span(tags, index)?;
Some((span, src.get(span.start..span.end).unwrap_or_default()))
}
fn content_notes(src: &str, part: &str) -> Vec<Note> {
let mut notes = Vec::new();
if src.contains("<text:tracked-changes") || src.contains("<table:tracked-changes") {
notes.push(Note::OutOfScopeContent {
location: format!("{part} (tracked changes; their authors and dates were removed)"),
});
}
if src.contains("<office:annotation") {
notes.push(Note::OutOfScopeContent {
location: format!("{part} (comment text; its authors and dates were removed)"),
});
}
if DISPLAYED_TIME_FIELDS
.iter()
.any(|field| src.contains(&format!("<{field}")))
{
notes.push(Note::OutOfScopeContent {
location: format!("{part} (a date or time field the document displays)"),
});
}
notes
}
fn lookup(
table: &[(&'static str, MetadataKind)],
name: &str,
) -> Option<(&'static str, MetadataKind)> {
table
.iter()
.find(|(candidate, _)| *candidate == name)
.copied()
}
fn record(
removed: &mut Vec<(&'static str, MetadataKind, u64, Option<String>)>,
name: &'static str,
kind: MetadataKind,
bytes: usize,
value: Option<String>,
) {
let bytes = u64::try_from(bytes).unwrap_or(u64::MAX);
if let Some(existing) = removed.iter_mut().find(|(n, _, _, _)| *n == name) {
existing.2 = existing.2.saturating_add(bytes);
return;
}
removed.push((name, kind, bytes, value));
}
pub(super) fn meta_findings(src: &str, part: &str, options: &InspectOptions) -> Vec<Finding> {
let tags = xml::tags(src);
let mut findings = Vec::new();
for (index, tag) in tags.iter().enumerate() {
if tag.kind == Kind::Close {
continue;
}
match tag.name {
STATISTIC_ELEMENT => {
for attribute in xml::attributes(tag.raw) {
findings.push(
Finding::new(
MetadataKind::Other,
part.to_owned(),
u64::try_from(attribute.value.len()).unwrap_or(u64::MAX),
)
.with_field(attribute.name.to_owned())
.with_value(options, || MetadataValue::Text(attribute.value.to_owned())),
);
}
}
TEMPLATE_ELEMENT => {
if let Some(href) = tag.attribute("xlink:href") {
findings.push(
Finding::new(
MetadataKind::PersonalIdentity,
part.to_owned(),
u64::try_from(href.len()).unwrap_or(u64::MAX),
)
.with_field("meta:template xlink:href".to_owned())
.with_value(options, || MetadataValue::Text(href.to_owned())),
);
}
}
USER_DEFINED_ELEMENT => {
let name = tag.attribute("meta:name").unwrap_or("meta:user-defined");
let text = xml::element_text(&tags, index, src);
findings.push(
Finding::new(
MetadataKind::PersonalIdentity,
part.to_owned(),
text.as_ref()
.map_or(0, |t| u64::try_from(t.value.len()).unwrap_or(u64::MAX)),
)
.with_field(format!("meta:user-defined ({name})"))
.with_value(options, || {
MetadataValue::Text(
text.as_ref()
.map(|t| t.value)
.unwrap_or_default()
.to_owned(),
)
}),
);
}
name => {
let Some((_, kind)) = lookup(&META_ELEMENTS, name) else {
continue;
};
let Some(text) = xml::element_text(&tags, index, src) else {
continue;
};
if text.value.trim().is_empty() {
continue;
}
findings.push(
Finding::new(
kind,
part.to_owned(),
u64::try_from(text.value.len()).unwrap_or(u64::MAX),
)
.with_field(name.to_owned())
.with_value(options, || MetadataValue::Text(text.value.to_owned())),
);
}
}
}
findings
}
pub(super) fn settings_findings(src: &str, part: &str, options: &InspectOptions) -> Vec<Finding> {
let tags = xml::tags(src);
let mut findings = Vec::new();
let mut items = 0usize;
for (index, tag) in tags.iter().enumerate() {
if tag.name != "config:config-item" || tag.kind == Kind::Close {
continue;
}
items = items.saturating_add(1);
let Some(name) = tag.attribute("config:name") else {
continue;
};
let Some((_, kind)) = lookup(&SENSITIVE_SETTINGS, name) else {
continue;
};
let text = xml::element_text(&tags, index, src);
findings.push(
Finding::new(
kind,
part.to_owned(),
text.as_ref()
.map_or(0, |t| u64::try_from(t.value.len()).unwrap_or(u64::MAX)),
)
.with_field(format!("config:config-item ({name})"))
.with_value(options, || {
MetadataValue::Text(
text.as_ref()
.map(|t| t.value)
.unwrap_or_default()
.to_owned(),
)
}),
);
}
findings.push(
Finding::new(
MetadataKind::SoftwareFingerprint,
part.to_owned(),
u64::try_from(items).unwrap_or(u64::MAX),
)
.with_field("config:config-item".to_owned()),
);
findings
}
pub(super) fn drop_manifest_entries(src: &str, dropped: &BTreeSet<String>) -> Option<String> {
xml::drop_tags(src, |tag: &Tag<'_>| {
tag.name == "manifest:file-entry"
&& tag
.attribute("manifest:full-path")
.is_some_and(|path| dropped.contains(path.strip_prefix('/').unwrap_or(path)))
})
}
pub(super) fn declares_encryption(src: &str) -> bool {
xml::tags(src)
.iter()
.any(|tag| tag.name == "manifest:encryption-data")
}
pub(super) fn root_media_type(src: &str) -> Option<String> {
xml::tags(src).into_iter().find_map(|tag| {
(tag.name == "manifest:file-entry" && tag.attribute("manifest:full-path") == Some("/"))
.then(|| tag.attribute("manifest:media-type").map(str::to_owned))
.flatten()
})
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::indexing_slicing)]
use super::*;
#[test]
fn a_comment_loses_its_author_and_keeps_its_words() {
let src = "<office:annotation><dc:creator>A Name</dc:creator>\
<dc:date>2021-03-04T05:06:07</dc:date>\
<text:p>PRESERVED-COMMENT-TEXT</text:p></office:annotation>";
let scrubbed = scrub(src, "content.xml", &InspectOptions::names_only());
let out = scrubbed.output.unwrap();
assert!(!out.contains("A Name"));
assert!(!out.contains("2021-03-04"));
assert!(
out.contains("PRESERVED-COMMENT-TEXT"),
"the comment's words are the document's payload (PRD §8.1)"
);
assert!(
out.contains("<dc:creator></dc:creator>"),
"the element stays; only its value goes"
);
assert!(
scrubbed
.notes
.iter()
.any(|n| matches!(n, Note::OutOfScopeContent { .. })),
"the comment that remains has to be declared, not left silent"
);
}
#[test]
fn a_tracked_change_loses_its_author_and_keeps_its_words() {
let src = "<text:tracked-changes><text:changed-region><text:insertion>\
<office:change-info><dc:creator>A Name</dc:creator>\
<dc:date>2021-03-04T05:06:07</dc:date></office:change-info>\
</text:insertion></text:changed-region></text:tracked-changes>\
<text:p>PRESERVED-INSERTED-TEXT</text:p>";
let out = scrub(src, "content.xml", &InspectOptions::names_only())
.output
.unwrap();
assert!(!out.contains("A Name"));
assert!(out.contains("PRESERVED-INSERTED-TEXT"));
assert!(
out.contains("<text:insertion>"),
"the revision itself stays; only its attribution goes"
);
}
#[test]
fn dc_creator_outside_an_attribution_is_left_alone() {
let src = "<text:p><dc:creator>NOT-AN-ATTRIBUTION</dc:creator></text:p>";
assert!(
scrub(src, "content.xml", &InspectOptions::names_only())
.output
.is_none()
);
}
#[test]
fn a_cached_author_field_loses_its_value_and_keeps_its_element() {
let src = "<text:p>By <text:creator>A Name</text:creator></text:p>";
let out = scrub(src, "content.xml", &InspectOptions::names_only())
.output
.unwrap();
assert_eq!(out, "<text:p>By <text:creator></text:creator></text:p>");
}
#[test]
fn an_attribution_with_children_is_removed_entire_rather_than_left() {
let src = "<office:annotation><dc:creator><a>A Name</a></dc:creator></office:annotation>";
let out = scrub(src, "content.xml", &InspectOptions::names_only())
.output
.unwrap();
assert!(!out.contains("A Name"));
}
#[test]
fn a_displayed_date_field_is_reported_rather_than_blanked() {
let src = "<text:p><text:creation-date>2021-03-04</text:creation-date></text:p>";
let scrubbed = scrub(src, "content.xml", &InspectOptions::names_only());
assert!(scrubbed.output.is_none(), "the visible date stays");
assert!(
scrubbed
.notes
.iter()
.any(|n| matches!(n, Note::OutOfScopeContent { .. })),
"and the user is told it is there"
);
}
#[test]
fn scrubbing_is_idempotent() {
let src = "<office:annotation><dc:creator>A Name</dc:creator>\
<text:p>text</text:p></office:annotation>";
let once = scrub(src, "content.xml", &InspectOptions::names_only())
.output
.unwrap();
assert!(
scrub(&once, "content.xml", &InspectOptions::names_only())
.output
.is_none(),
"a second pass must find nothing, or verification would never converge"
);
}
#[test]
fn meta_reports_the_editing_statistics_odf_keeps_and_office_does_not() {
let src = "<office:document-meta><office:meta>\
<meta:initial-creator>A Name</meta:initial-creator>\
<meta:editing-cycles>37</meta:editing-cycles>\
<meta:editing-duration>PT4H32M17S</meta:editing-duration>\
<meta:generator>LibreOffice/7.4$Linux_X86_64</meta:generator>\
<meta:document-statistic meta:page-count=\"3\" meta:word-count=\"412\"/>\
<meta:user-defined meta:name=\"MatterNumber\">A-1234</meta:user-defined>\
<meta:template xlink:href=\"file:///home/aname/t.ott\"/>\
</office:meta></office:document-meta>";
let findings = meta_findings(src, "meta.xml", &InspectOptions::names_only());
let fields: Vec<&str> = findings.iter().filter_map(|f| f.field.as_deref()).collect();
for expected in [
"meta:initial-creator",
"meta:editing-cycles",
"meta:editing-duration",
"meta:generator",
"meta:page-count",
"meta:word-count",
"meta:template xlink:href",
] {
assert!(fields.contains(&expected), "{expected} was not reported");
}
assert!(
fields.iter().any(|f| f.starts_with("meta:user-defined (")),
"a user-defined property must be named, not counted"
);
assert!(
findings
.iter()
.any(|f| f.kind == MetadataKind::EditingHistory),
"editing-cycles and editing-duration are the pair ODF records and Office does not"
);
}
#[test]
fn settings_names_the_printer_and_counts_the_rest() {
let src = "<office:settings><config:config-item-set config:name=\"ooo:view-settings\">\
<config:config-item config:name=\"PrinterName\" config:type=\"string\">\
A Printer</config:config-item>\
<config:config-item config:name=\"ViewAreaTop\" config:type=\"int\">0\
</config:config-item></config:config-item-set></office:settings>";
let findings = settings_findings(src, "settings.xml", &InspectOptions::names_only());
assert!(findings.iter().any(|f| f.field.as_deref()
== Some("config:config-item (PrinterName)")
&& f.kind == MetadataKind::DeviceIdentity));
assert!(
findings
.iter()
.any(|f| f.field.as_deref() == Some("config:config-item")),
"the part goes whole, so the report has to account for it whole"
);
}
#[test]
fn values_are_withheld_unless_the_caller_asks() {
let src = "<office:meta><dc:creator>A Name</dc:creator></office:meta>";
assert!(
meta_findings(src, "meta.xml", &InspectOptions::names_only())
.iter()
.all(|f| f.value.is_none())
);
assert_eq!(
meta_findings(src, "meta.xml", &InspectOptions::with_values())
.iter()
.find(|f| f.field.as_deref() == Some("dc:creator"))
.and_then(|f| f.value.clone()),
Some(MetadataValue::Text("A Name".to_owned()))
);
}
#[test]
fn an_encrypted_package_is_visible_in_the_manifest_where_zip_cannot_see_it() {
let src = "<manifest:manifest><manifest:file-entry manifest:full-path=\"content.xml\">\
<manifest:encryption-data manifest:checksum=\"x\"/></manifest:file-entry>\
</manifest:manifest>";
assert!(declares_encryption(src));
assert!(!declares_encryption(
"<manifest:manifest><manifest:file-entry manifest:full-path=\"content.xml\"/>\
</manifest:manifest>"
));
}
#[test]
fn the_manifest_names_the_package_and_loses_entries_for_removed_parts() {
let src = "<manifest:manifest>\
<manifest:file-entry manifest:full-path=\"/\" \
manifest:media-type=\"application/vnd.oasis.opendocument.text\"/>\
<manifest:file-entry manifest:full-path=\"meta.xml\" \
manifest:media-type=\"text/xml\"/>\
<manifest:file-entry manifest:full-path=\"content.xml\" \
manifest:media-type=\"text/xml\"/></manifest:manifest>";
assert_eq!(
root_media_type(src).as_deref(),
Some("application/vnd.oasis.opendocument.text")
);
let dropped: BTreeSet<String> = ["meta.xml".to_owned()].into_iter().collect();
let out = drop_manifest_entries(src, &dropped).unwrap();
assert!(!out.contains("meta.xml"));
assert!(out.contains("content.xml"));
assert!(drop_manifest_entries(src, &BTreeSet::new()).is_none());
}
#[test]
fn arbitrary_text_does_not_panic_the_rules() {
let cases = [
"<",
"<a",
"<a=",
"</",
"<!",
"<!--",
"<![CDATA[",
"<?",
"<a b=",
"<office:annotation>",
"<dc:creator>",
"<office:change-info><dc:creator>",
"<meta:document-statistic",
"</office:annotation>",
"<<<<>>>>",
];
for case in cases {
let _ = scrub(case, "content.xml", &InspectOptions::names_only());
let _ = meta_findings(case, "meta.xml", &InspectOptions::with_values());
let _ = settings_findings(case, "settings.xml", &InspectOptions::with_values());
let _ = drop_manifest_entries(case, &BTreeSet::new());
let _ = declares_encryption(case);
let _ = root_media_type(case);
}
}
#[test]
fn every_prefix_of_a_real_part_is_survivable() {
let src = "<?xml version=\"1.0\"?><office:document-content>\
<office:body><text:p><office:annotation><dc:creator>A</dc:creator>\
<text:p>x</text:p></office:annotation></text:p></office:body>\
</office:document-content>";
for n in 0..=src.len() {
let prefix = src.get(0..n).unwrap_or_default();
let _ = scrub(prefix, "content.xml", &InspectOptions::names_only());
}
}
}