From e6aa834d91c28b36b370f5f458562dbf659c21f4 Mon Sep 17 00:00:00 2001 From: Elia <83713217+eliahilse@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:17:29 +0200 Subject: [PATCH] fix(redact): scrub unknown binary parts --- .changeset/redact-binary-allowlist.md | 5 + crates/ooxml-redact/src/lib.rs | 37 +- crates/ooxml-redact/src/rels.rs | 74 ++ crates/ooxml-redact/src/scrub.rs | 563 ++++++++++++ crates/ooxml-redact/src/tests.rs | 1136 +++++++++++++++++++++++++ crates/ooxml-redact/src/xml.rs | 44 +- 6 files changed, 1806 insertions(+), 53 deletions(-) create mode 100644 .changeset/redact-binary-allowlist.md create mode 100644 crates/ooxml-redact/src/rels.rs create mode 100644 crates/ooxml-redact/src/scrub.rs diff --git a/.changeset/redact-binary-allowlist.md b/.changeset/redact-binary-allowlist.md new file mode 100644 index 00000000..ac11e6c3 --- /dev/null +++ b/.changeset/redact-binary-allowlist.md @@ -0,0 +1,5 @@ +--- +"@betteroffice/rust-crates": patch +--- + +Scrub unrecognized binary parts during redaction. A part is kept only when it is recognized media or XML; every other part is emptied, and it is removed outright — together with its owned relationship part, that part's exclusive targets and its content-type declaration — when no surviving relationship points at it. The XML rewriter and the scrubber now share one reading of relationship markup, so they cannot disagree about which targets leave the package, and a part is only removed when every surviving relationship resolves to a stored entry. diff --git a/crates/ooxml-redact/src/lib.rs b/crates/ooxml-redact/src/lib.rs index ee93a168..3665fb24 100644 --- a/crates/ooxml-redact/src/lib.rs +++ b/crates/ooxml-redact/src/lib.rs @@ -1,11 +1,15 @@ mod media; +mod rels; +mod scrub; mod xml; +use std::collections::HashSet; use std::fmt; use thiserror::Error; use crate::media::replace_media; +use crate::scrub::{normalize_part_name, prune_scrubbed_parts}; use crate::xml::redact_xml; #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] @@ -90,15 +94,25 @@ pub fn redact_with_report( format: detected, ..RedactionReport::default() }; + let scrubbed: HashSet = parts + .iter() + .map(|(path, _)| normalize_part_name(path)) + .filter(|name| !media::is_replaceable_part(name) && !is_xml_part(name)) + .collect(); + report.binary_parts = scrubbed.len(); + let blanked = if scrubbed.is_empty() { + HashSet::new() + } else { + prune_scrubbed_parts(&mut parts, &scrubbed)? + }; for (path, data) in &mut parts { - let lower = path.to_ascii_lowercase(); - if media::is_replaceable_part(&lower) { - *data = replace_media(path, data, &mut report)?; - } else if is_xml_part(&lower) { - *data = redact_xml(detected, path, data, &mut report)?; - } else if is_sensitive_binary(&lower) { + let canonical = normalize_part_name(path); + if blanked.contains(&canonical) { data.clear(); - report.binary_parts += 1; + } else if media::is_replaceable_part(&canonical) { + *data = replace_media(&canonical, data, &mut report)?; + } else { + *data = redact_xml(detected, &canonical, data, &mut report)?; } } @@ -109,7 +123,7 @@ pub fn redact_with_report( fn detect_parts(parts: &[(String, Vec)]) -> Result { if let Some((_, content_types)) = parts .iter() - .find(|(path, _)| path.eq_ignore_ascii_case("[Content_Types].xml")) + .find(|(path, _)| normalize_part_name(path) == "[content_types].xml") { let text = String::from_utf8_lossy(content_types).to_ascii_lowercase(); if text.contains("wordprocessingml.document.main+xml") @@ -149,12 +163,5 @@ fn is_xml_part(path: &str) -> bool { path.ends_with(".xml") || path.ends_with(".rels") || path.ends_with(".vml") } -fn is_sensitive_binary(path: &str) -> bool { - path.ends_with("vbaproject.bin") - || path.contains("/embeddings/") - || path.contains("/activex/") && path.ends_with(".bin") - || path.contains("/printersettings/") -} - #[cfg(test)] mod tests; diff --git a/crates/ooxml-redact/src/rels.rs b/crates/ooxml-redact/src/rels.rs new file mode 100644 index 00000000..92c39469 --- /dev/null +++ b/crates/ooxml-redact/src/rels.rs @@ -0,0 +1,74 @@ +//! One reading of OPC relationship markup, shared by the XML rewriter and the +//! scrubber so the two cannot disagree about which targets leave the package. + +/// Whether the attribute name carries no namespace prefix. Only unqualified +/// OPC attributes take part in relationship decisions. +pub(crate) fn is_unqualified(key: &str) -> bool { + !key.contains(':') +} + +pub(crate) fn attribute_local(name: &str) -> &str { + name.rsplit_once(':').map_or(name, |(_, local)| local) +} + +/// The value of an unqualified attribute with this name. OPC names are +/// case-sensitive, so the exact spelling a consumer reads wins over a tolerated +/// variant appearing earlier. +pub(crate) fn unqualified_value<'a>( + attributes: &'a [(String, String)], + expected: &str, +) -> Option<&'a str> { + let mut variant = None; + for (name, value) in attributes { + if !is_unqualified(name) { + continue; + } + if name == expected { + return Some(value.as_str()); + } + if variant.is_none() && name.eq_ignore_ascii_case(expected) { + variant = Some(value.as_str()); + } + } + variant +} + +/// Whether a relationship's attributes mark it as pointing outside the +/// package. `package_part` enables reading the target's shape, which only a +/// `.rels` part's consumers resolve; a shape is read from the exact-case +/// `Target` those consumers use. +pub(crate) fn external_relationship(attributes: &[(String, String)], package_part: bool) -> bool { + attributes.iter().any(|(key, value)| { + if !is_unqualified(key) { + return false; + } + let local = attribute_local(key); + local.eq_ignore_ascii_case("TargetMode") && value.trim().eq_ignore_ascii_case("External") + || package_part && local == "Target" && external_target(value) + }) +} + +/// Whether a relationship target points outside the package. Query and +/// fragment are dropped first: neither names a part, and either may carry a +/// URI of its own. +pub(crate) fn external_target(target: &str) -> bool { + let lower = target + .trim() + .split(['?', '#']) + .next() + .unwrap_or_default() + .to_ascii_lowercase(); + lower.starts_with("//") + || lower.starts_with(r"\\") + || lower + .split_once(':') + .is_some_and(|(scheme, _)| is_uri_scheme(scheme)) +} + +fn is_uri_scheme(scheme: &str) -> bool { + let mut chars = scheme.chars(); + if !matches!(chars.next(), Some(first) if first.is_ascii_alphabetic()) { + return false; + } + chars.all(|character| character.is_ascii_alphanumeric() || matches!(character, '+' | '-' | '.')) +} diff --git a/crates/ooxml-redact/src/scrub.rs b/crates/ooxml-redact/src/scrub.rs new file mode 100644 index 00000000..61dcf9dc --- /dev/null +++ b/crates/ooxml-redact/src/scrub.rs @@ -0,0 +1,563 @@ +use std::collections::{HashMap, HashSet}; +use std::fmt; + +use quick_xml::events::{BytesStart, Event}; +use quick_xml::{Reader, Writer, XmlVersion}; + +use crate::RedactError; +use crate::rels::{self, unqualified_value}; + +/// Drop scrubbed parts together with the relationship parts they own, the +/// internal parts those relationships target (unless a retained part also +/// references them), their content-type declarations, and the internal +/// relationships that target any of them. Returns the scrubbed parts a +/// surviving relationship still points at: those keep their zip entry, +/// declaration and relationship, and the caller empties their bytes. +pub(crate) fn prune_scrubbed_parts( + parts: &mut Vec<(String, Vec)>, + scrubbed: &HashSet, +) -> Result, RedactError> { + let known: HashSet = parts + .iter() + .map(|(path, _)| normalize_part_name(path)) + .collect(); + if has_unresolvable_reference(parts, scrubbed, &known)? { + return Ok(scrubbed.clone()); + } + let (mut removed, blanked) = cascade_owned_relationships(parts, scrubbed, &known)?; + removed.retain(|name| !blanked.contains(name)); + parts.retain(|(path, _)| !removed.contains(&normalize_part_name(path))); + let kept_extensions: HashSet = parts + .iter() + .filter_map(|(path, _)| extension(path)) + .collect(); + for (path, bytes) in parts.iter_mut() { + let lower = normalize_part_name(path); + if lower == CONTENT_TYPES { + *bytes = prune_content_types(bytes, &removed, &kept_extensions, &known, path)?; + } else if lower.ends_with(".rels") { + *bytes = prune_relationships(bytes, &removed, &known, path)?; + } + } + Ok(blanked) +} + +const CONTENT_TYPES: &str = "[content_types].xml"; +const ROOT_RELATIONSHIPS: &str = "_rels/.rels"; + +/// Whether a relationship part that can outlive scrubbing names an internal +/// target that resolves to no stored entry. Which parts such a target stands +/// for is then unknown, so no deletion can be proven safe and every scrubbed +/// part is blanked in place instead. +fn has_unresolvable_reference( + parts: &[(String, Vec)], + scrubbed: &HashSet, + known: &HashSet, +) -> Result { + for (path, bytes) in parts { + let name = normalize_part_name(path); + if !name.ends_with(".rels") { + continue; + } + match owner_of_rels_path(&name) { + Some(owner) if scrubbed.contains(&owner) || !known.contains(&owner) => continue, + None if name != ROOT_RELATIONSHIPS => continue, + _ => {} + } + if names_a_missing_part(bytes, &name, known)? { + return Ok(true); + } + } + Ok(false) +} + +/// Whether any internal relationship here fails to name a stored entry. +fn names_a_missing_part( + bytes: &[u8], + relationship_path: &str, + known: &HashSet, +) -> Result { + let mut reader = Reader::from_reader(bytes); + loop { + let event = reader + .read_event() + .map_err(|error| xml_error(relationship_path, error))?; + match event { + Event::Start(start) | Event::Empty(start) + if start.name().local_name().as_ref() == b"Relationship" => + { + let values = attributes(&reader, &start, relationship_path)?; + if rels::external_relationship(&values, true) { + continue; + } + let Some(target) = unqualified_value(&values, "Target") else { + continue; + }; + match resolve_relationship_target(relationship_path, target, known) { + Some(resolved) if known.contains(&resolved) => {} + _ => return Ok(true), + } + } + Event::Eof => return Ok(false), + _ => {} + } + } +} + +/// The package's own control entries; nothing may cascade them away. +fn control_part(name: &str) -> bool { + name == CONTENT_TYPES || name == ROOT_RELATIONSHIPS +} + +/// Removes scrubbed parts plus their owned relationship parts and targets, +/// sparing targets still referenced by surviving parts. The second set holds +/// the scrubbed parts a surviving relationship still points at. +fn cascade_owned_relationships( + parts: &[(String, Vec)], + scrubbed: &HashSet, + known: &HashSet, +) -> Result<(HashSet, HashSet), RedactError> { + let by_name: HashMap> = parts + .iter() + .map(|(path, bytes)| (normalize_part_name(path), bytes)) + .collect(); + let mut spared: HashSet = HashSet::new(); + let mut target_cache: HashMap> = HashMap::new(); + loop { + let removed = removal_closure(&by_name, scrubbed, &spared, known, &mut target_cache)?; + let targets = survivor_targets(&by_name, &removed, known, &mut target_cache)?; + let mut changed = false; + for target in &targets { + if scrubbed.contains(target) + || !removed.contains(target) + || !spared.insert(target.clone()) + { + continue; + } + changed = true; + } + if !changed { + let blanked = targets + .into_iter() + .filter(|target| scrubbed.contains(target)) + .collect(); + return Ok((removed, blanked)); + } + } +} + +/// Collects scrubbed parts, their owned `.rels`, and those `.rels` targets; +/// `spared` parts act as barriers. +fn removal_closure( + by_name: &HashMap>, + scrubbed: &HashSet, + spared: &HashSet, + known: &HashSet, + target_cache: &mut HashMap>, +) -> Result, RedactError> { + let mut removed = scrubbed.clone(); + let mut queue: Vec = scrubbed.iter().cloned().collect(); + while let Some(current) = queue.pop() { + let owned = owned_rels_path(¤t); + if !by_name.contains_key(&owned) || !removed.insert(owned.clone()) { + continue; + } + queue.push(owned.clone()); + if let Some(bytes) = by_name.get(&owned) { + let parsed = match target_cache.get_mut(&owned) { + Some(parsed) => parsed, + None => { + let parsed = internal_targets(bytes, &owned, known)?; + target_cache.insert(owned.clone(), parsed); + target_cache.get(&owned).unwrap() + } + }; + for target in parsed.clone() { + if spared.contains(&target) + || control_part(&target) + || !by_name.contains_key(&target) + { + continue; + } + if removed.insert(target.clone()) { + queue.push(target); + } + } + } + } + Ok(removed) +} + +/// Targets of surviving, owner-having `.rels` parts; orphans don't count. +fn survivor_targets( + by_name: &HashMap>, + removed: &HashSet, + known: &HashSet, + cache: &mut HashMap>, +) -> Result, RedactError> { + let mut targets = Vec::new(); + for (name, bytes) in by_name { + if removed.contains(name) || !name.ends_with(".rels") { + continue; + } + if let Some(owner) = owner_of_rels_path(name) { + match by_name.get(&owner) { + Some(_) if !removed.contains(&owner) => {} + _ => continue, + } + } else if name != ROOT_RELATIONSHIPS { + continue; + } + let parsed = match cache.get(name) { + Some(parsed) => parsed, + None => { + let parsed = internal_targets(bytes, name, known)?; + cache.insert(name.clone(), parsed); + cache.get(name).unwrap() + } + }; + targets.extend(parsed.iter().cloned()); + } + Ok(targets) +} + +/// The package part a `*_rels/*.rels` path belongs to, if well-formed. +fn owner_of_rels_path(path: &str) -> Option { + if let Some((directory, file)) = path.rsplit_once("/_rels/") { + let file = file.strip_suffix(".rels")?; + if directory.is_empty() || file.is_empty() { + return None; + } + Some(format!("{directory}/{file}")) + } else { + path.strip_prefix("_rels/")? + .strip_suffix(".rels") + .filter(|file| !file.is_empty() && *file != ".") + .map(str::to_owned) + } +} + +fn internal_targets( + bytes: &[u8], + relationship_path: &str, + known: &HashSet, +) -> Result, RedactError> { + let mut reader = Reader::from_reader(bytes); + let mut targets = Vec::new(); + loop { + let event = reader + .read_event() + .map_err(|error| xml_error(relationship_path, error))?; + match event { + Event::Start(start) | Event::Empty(start) + if start.name().local_name().as_ref() == b"Relationship" => + { + let values = attributes(&reader, &start, relationship_path)?; + if rels::external_relationship(&values, true) { + continue; + } + if let Some(target) = unqualified_value(&values, "Target").and_then(|target| { + resolve_relationship_target(relationship_path, target, known) + }) { + targets.push(target); + } + } + Event::Eof => return Ok(targets), + _ => {} + } + } +} + +fn owned_rels_path(part_name: &str) -> String { + match part_name.rsplit_once('/') { + Some((directory, name)) => format!("{directory}/_rels/{name}.rels"), + None => format!("_rels/{part_name}.rels"), + } +} + +fn prune_content_types( + bytes: &[u8], + removed: &HashSet, + kept_extensions: &HashSet, + known: &HashSet, + path: &str, +) -> Result, RedactError> { + let mut reader = Reader::from_reader(bytes); + let mut writer = Writer::new(Vec::with_capacity(bytes.len())); + let mut skip_depth = 0_usize; + loop { + let event = reader + .read_event() + .map_err(|error| xml_error(path, error))?; + if skip_depth > 0 { + match event { + Event::Start(_) => skip_depth += 1, + Event::End(_) => skip_depth -= 1, + Event::Eof => return Err(unexpected_eof(path)), + _ => {} + } + continue; + } + match event { + Event::Start(start) => { + let values = attributes(&reader, &start, path)?; + if drops_entry(&start, &values, removed, kept_extensions, known) { + skip_depth = 1; + } else { + write_start(&mut writer, start.into_owned(), false, path)?; + } + } + Event::Empty(start) => { + let values = attributes(&reader, &start, path)?; + if !drops_entry(&start, &values, removed, kept_extensions, known) { + write_start(&mut writer, start.into_owned(), true, path)?; + } + } + Event::Eof => return Ok(writer.into_inner()), + other => write(&mut writer, other, path)?, + } + } +} + +fn drops_entry( + start: &BytesStart<'_>, + values: &[(String, String)], + removed: &HashSet, + kept_extensions: &HashSet, + known: &HashSet, +) -> bool { + match start.name().local_name().as_ref() { + b"Override" => unqualified_value(values, "PartName").is_some_and(|name| { + removed.contains(&preferred_spelling(normalize_part_name(name), known)) + }), + b"Default" => unqualified_value(values, "Extension") + .is_some_and(|ext| !kept_extensions.contains(&ext.to_lowercase())), + _ => false, + } +} + +fn prune_relationships( + bytes: &[u8], + removed: &HashSet, + known: &HashSet, + path: &str, +) -> Result, RedactError> { + let mut reader = Reader::from_reader(bytes); + let mut writer = Writer::new(Vec::with_capacity(bytes.len())); + let mut skip_depth = 0_usize; + loop { + let event = reader + .read_event() + .map_err(|error| xml_error(path, error))?; + if skip_depth > 0 { + match event { + Event::Start(_) => skip_depth += 1, + Event::End(_) => skip_depth -= 1, + Event::Eof => return Err(unexpected_eof(path)), + _ => {} + } + continue; + } + match event { + Event::Start(start) => { + let remove = start.name().local_name().as_ref() == b"Relationship" + && removes_target(&attributes(&reader, &start, path)?, removed, known, path); + if remove { + skip_depth = 1; + } else { + write_start(&mut writer, start.into_owned(), false, path)?; + } + } + Event::Empty(start) => { + let remove = start.name().local_name().as_ref() == b"Relationship" + && removes_target(&attributes(&reader, &start, path)?, removed, known, path); + if !remove { + write_start(&mut writer, start.into_owned(), true, path)?; + } + } + Event::Eof => return Ok(writer.into_inner()), + other => write(&mut writer, other, path)?, + } + } +} + +fn removes_target( + values: &[(String, String)], + removed: &HashSet, + known: &HashSet, + relationship_path: &str, +) -> bool { + if rels::external_relationship(values, true) { + return false; + } + unqualified_value(values, "Target") + .and_then(|target| resolve_relationship_target(relationship_path, target, known)) + .is_some_and(|target| removed.contains(&target)) +} + +/// Resolves a relationship target against the source part's directory, +/// handling absolute, relative, and `..` targets. Separators are normalized +/// before the root test, the way `ooxml-opc`'s `normalized_security_path` keys +/// the entries this has to match. A target is an IRI, so its percent-decoded +/// spelling names the part when the literal one does not; `known` decides +/// which of the two the package holds. +fn resolve_relationship_target( + relationship_path: &str, + target: &str, + known: &HashSet, +) -> Option { + let resolved = resolve_target_path(relationship_path, target)?; + Some(preferred_spelling(resolved, known)) +} + +/// The decoded name when the package holds it, otherwise the literal one. +fn preferred_spelling(canonical: String, known: &HashSet) -> String { + match percent_decoded(&canonical).map(|decoded| normalize_part_name(&decoded)) { + Some(decoded) if known.contains(&decoded) => decoded, + _ => canonical, + } +} + +/// Decodes `%NN` escapes, or `None` when the value has none that decode to +/// different, valid UTF-8. +fn percent_decoded(value: &str) -> Option { + if !value.contains('%') { + return None; + } + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + match hex_pair(bytes, index) { + Some(byte) => { + decoded.push(byte); + index += 3; + } + None => { + decoded.push(bytes[index]); + index += 1; + } + } + } + String::from_utf8(decoded) + .ok() + .filter(|decoded| decoded != value) +} + +fn hex_pair(bytes: &[u8], index: usize) -> Option { + if bytes.get(index) != Some(&b'%') { + return None; + } + let high = (*bytes.get(index + 1)? as char).to_digit(16)?; + let low = (*bytes.get(index + 2)? as char).to_digit(16)?; + u8::try_from(high * 16 + low).ok() +} + +fn resolve_target_path(relationship_path: &str, target: &str) -> Option { + if target.is_empty() || rels::external_target(target) { + return None; + } + let clean_target = target + .split(['?', '#']) + .next() + .unwrap_or_default() + .replace('\\', "/"); + let relationship_path = relationship_path.replace('\\', "/"); + let source_directory = if let Some((directory, _)) = relationship_path.rsplit_once("/_rels/") { + directory.to_owned() + } else if relationship_path.starts_with("_rels/") { + String::new() + } else { + relationship_path.clone() + }; + let mut segments: Vec = + if clean_target.starts_with('/') || relationship_path.eq_ignore_ascii_case("_rels/.rels") { + Vec::new() + } else { + source_directory + .split('/') + .filter(|segment| !segment.is_empty() && *segment != ".") + .map(str::to_owned) + .collect() + }; + let clean_target = clean_target.trim_start_matches('/'); + for segment in clean_target + .split('/') + .filter(|segment| !segment.is_empty() && *segment != ".") + { + if segment == ".." { + segments.pop()?; + } else { + segments.push(segment.to_owned()); + } + } + Some(segments.join("/").to_lowercase()) +} + +/// Canonical package-entry form: separators normalized, empty and `.` segments +/// collapsed, lowercased the way `ooxml-opc`'s `normalized_security_path` does +/// it, so entries that layer treats as one part are one part here too. +pub(crate) fn normalize_part_name(path: &str) -> String { + path.replace('\\', "/") + .split('/') + .filter(|segment| !segment.is_empty() && *segment != ".") + .collect::>() + .join("/") + .to_lowercase() +} + +fn extension(path: &str) -> Option { + path.rsplit_once('/') + .map_or(path, |(_, name)| name) + .rsplit_once('.') + .map(|(_, ext)| ext.to_lowercase()) +} + +fn attributes( + reader: &Reader<&[u8]>, + start: &BytesStart<'_>, + path: &str, +) -> Result, RedactError> { + start + .attributes() + .map(|attribute| { + let attribute = attribute.map_err(|error| xml_error(path, error))?; + let key = String::from_utf8_lossy(attribute.key.as_ref()).into_owned(); + let value = attribute + .decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder()) + .map_err(|error| xml_error(path, error))? + .into_owned(); + Ok((key, value)) + }) + .collect() +} + +fn write_start( + writer: &mut Writer>, + start: BytesStart<'_>, + empty: bool, + path: &str, +) -> Result<(), RedactError> { + if empty { + write(writer, Event::Empty(start), path) + } else { + write(writer, Event::Start(start), path) + } +} + +fn write(writer: &mut Writer>, event: Event<'_>, path: &str) -> Result<(), RedactError> { + writer + .write_event(event) + .map_err(|error| xml_error(path, error)) +} + +fn unexpected_eof(path: &str) -> RedactError { + xml_error(path, "unexpected EOF") +} + +fn xml_error(path: &str, message: impl fmt::Display) -> RedactError { + RedactError::Xml { + part: path.to_owned(), + message: message.to_string(), + } +} diff --git a/crates/ooxml-redact/src/tests.rs b/crates/ooxml-redact/src/tests.rs index 0b60b6c3..eeb1854d 100644 --- a/crates/ooxml-redact/src/tests.rs +++ b/crates/ooxml-redact/src/tests.rs @@ -82,6 +82,1142 @@ fn rejects_explicit_format_mismatch() { assert!(matches!(error, RedactError::FormatMismatch { .. })); } +#[test] +fn scrubs_unrecognized_binary_parts_by_default() { + let source = package(vec![ + ( + "[Content_Types].xml", + xml( + r#""#, + ), + ), + ( + "_rels/.rels", + xml( + r#""#, + ), + ), + ( + "xl/workbook.xml", + xml( + r#""#, + ), + ), + ( + "xl/_rels/workbook.xml.rels", + xml( + r#""#, + ), + ), + ( + "xl/worksheets/sheet1.xml", + xml( + r#"1"#, + ), + ), + ( + "xl/worksheets/_rels/sheet1.xml.rels", + xml( + r#""#, + ), + ), + ("xl/model/item.data", b"POWERPIVOT_MODEL_SECRET".to_vec()), + ( + "xl/embeddings/oleObject1.bin", + b"OLE_EMBEDDED_SECRET".to_vec(), + ), + ( + "xl/vbaProjectSignature.bin", + b"VBA_SIGNATURE_SECRET".to_vec(), + ), + ( + "ppt/fonts/font1.fntdata", + b"FONT_GLYPH_SUBSET_SECRET".to_vec(), + ), + ( + "_xmlsignatures/origin.sigs", + b"XML_SIGNATURE_ORIGIN_SECRET".to_vec(), + ), + ( + "_xmlsignatures/_rels/origin.sigs.rels", + xml( + r#""#, + ), + ), + ( + "_xmlsignatures/sig1.xml", + xml( + r##"b64DIGESTVALUEb64SIGNATUREVALUEX509_CERT_CHAIN_BLOBEMAILSIGNER_SECRET@example.comSIGNER_NAME_SECRETSIGNER_NAME_SECRET"##, + ), + ), + ]); + let (output, report) = redact_with_report(&source, Format::Auto).unwrap(); + assert_eq!(report.format, Format::Xlsx); + let parts = ooxml_opc::unzip_parts(&output).unwrap(); + xlsx_parse::parse_workbook(&parts).unwrap(); + for path in [ + "xl/vbaProjectSignature.bin", + "ppt/fonts/font1.fntdata", + "_xmlsignatures/origin.sigs", + "_xmlsignatures/_rels/origin.sigs.rels", + "_xmlsignatures/sig1.xml", + ] { + assert!( + parts.iter().all(|(candidate, _)| candidate != path), + "unreferenced binary part survived: {path}" + ); + } + for path in ["xl/model/item.data", "xl/embeddings/oleObject1.bin"] { + assert_eq!( + part(&parts, path), + b"", + "referenced binary part must stay as an empty entry: {path}" + ); + } + assert_eq!(report.binary_parts, 5); + + for secret in [ + "POWERPIVOT_MODEL_SECRET", + "OLE_EMBEDDED_SECRET", + "VBA_SIGNATURE_SECRET", + "FONT_GLYPH_SUBSET_SECRET", + "XML_SIGNATURE_ORIGIN_SECRET", + "X509_CERT_CHAIN_BLOB", + "EMAILSIGNER_SECRET", + "SIGNER_NAME_SECRET", + ] { + assert!( + parts + .iter() + .all(|(_, bytes)| !String::from_utf8_lossy(bytes).contains(secret)), + "secret survived: {secret}" + ); + } + + let content_types = String::from_utf8_lossy(part(&parts, "[Content_Types].xml")); + assert!(content_types.contains("spreadsheetml.sheet.main+xml")); + assert!(!content_types.contains("fntdata")); + assert!(!content_types.contains(r#"Extension="sigs""#)); + assert!( + content_types.contains(r#"Extension="bin""#) + && content_types.contains(r#"Extension="data""#), + "emptied parts keep their declarations: {content_types}" + ); + + let workbook_rels = String::from_utf8_lossy(part(&parts, "xl/_rels/workbook.xml.rels")); + assert!(workbook_rels.contains("worksheets/sheet1.xml")); + assert!(workbook_rels.contains("item.data")); + assert!(workbook_rels.contains("oleObject1.bin")); + + let sheet_rels = String::from_utf8_lossy(part(&parts, "xl/worksheets/_rels/sheet1.xml.rels")); + assert!(sheet_rels.contains("oleObject1.bin")); + assert!( + sheet_rels.contains("https://example.com"), + "external relationships must survive" + ); + + let workbook = String::from_utf8_lossy(part(&parts, "xl/workbook.xml")); + assert!( + workbook.contains("rIdModel") && workbook_rels.contains(r#"Id="rIdModel""#), + "host r:id references must still resolve to a relationship" + ); +} + +#[test] +fn referenced_binaries_keep_host_references_resolvable() { + let source = package(vec![ + ( + "[Content_Types].xml", + xml( + r#""#, + ), + ), + ( + "_rels/.rels", + xml( + r#""#, + ), + ), + ( + "xl/workbook.xml", + xml( + r#""#, + ), + ), + ( + "xl/_rels/workbook.xml.rels", + xml( + r#""#, + ), + ), + ( + "xl/worksheets/sheet1.xml", + xml( + r#""#, + ), + ), + ( + "xl/worksheets/_rels/sheet1.xml.rels", + xml( + r#""#, + ), + ), + ( + "xl/printerSettings/printerSettings1.bin", + b"PRINTER_QUEUE_SECRET".to_vec(), + ), + ]); + let (output, report) = redact_with_report(&source, Format::Auto).unwrap(); + assert_eq!(report.binary_parts, 1); + let parts = ooxml_opc::unzip_parts(&output).unwrap(); + xlsx_parse::parse_workbook(&parts).unwrap(); + assert_eq!( + part(&parts, "xl/printerSettings/printerSettings1.bin"), + b"", + "a referenced binary keeps its entry, emptied" + ); + let sheet_rels = String::from_utf8_lossy(part(&parts, "xl/worksheets/_rels/sheet1.xml.rels")); + assert!( + sheet_rels.contains(r#"Id="rIdPrinter""#), + "the relationship a surviving body still references must survive: {sheet_rels}" + ); + let content_types = String::from_utf8_lossy(part(&parts, "[Content_Types].xml")); + assert!(content_types.contains(r#"Extension="bin""#)); + assert!( + parts + .iter() + .all(|(_, bytes)| !String::from_utf8_lossy(bytes).contains("PRINTER_QUEUE_SECRET")) + ); +} + +#[test] +fn case_folded_entry_names_match_the_opc_layer() { + let source = package(vec![ + ( + "[Content_Types].xml", + xml( + r#""#, + ), + ), + ( + "_rels/.rels", + xml( + r#""#, + ), + ), + ( + "word/document.xml", + xml( + r#""#, + ), + ), + ( + "word/_rels/document.xml.rels", + xml( + r#""#, + ), + ), + // U+212A KELVIN SIGN: `to_lowercase` folds it to `k`, `to_ascii_lowercase` does not. + ("word/media/\u{212a}ey.png", placeholder_png()), + ( + "word/embeddings/\u{212a}.bin", + b"CASE_FOLDED_SECRET".to_vec(), + ), + ( + "word/embeddings/_rels/k.bin.rels", + xml(&format!( + r#""#, + kelvin = '\u{212a}' + )), + ), + ]); + let (output, _) = redact_with_report(&source, Format::Auto).unwrap(); + let parts = ooxml_opc::unzip_parts(&output).unwrap(); + assert!( + parts + .iter() + .any(|(path, _)| path == "word/media/\u{212a}ey.png"), + "a media part a surviving relationship still targets must survive" + ); + assert!( + parts + .iter() + .all(|(path, _)| !path.ends_with("k.bin.rels") && !path.ends_with(".bin")), + "the scrubbed part and the relationships it owns must both go" + ); + assert!( + parts + .iter() + .all(|(_, bytes)| !String::from_utf8_lossy(bytes).contains("CASE_FOLDED_SECRET")) + ); +} + +#[test] +fn percent_encoded_targets_resolve_to_their_decoded_parts() { + let source = package(vec![ + ( + "[Content_Types].xml", + xml( + r#""#, + ), + ), + ( + "_rels/.rels", + xml( + r#""#, + ), + ), + ( + "xl/workbook.xml", + xml( + r#""#, + ), + ), + ( + "xl/_rels/workbook.xml.rels", + xml( + r#""#, + ), + ), + ( + "xl/embeddings/ole one~a.bin", + b"ENCODED_TARGET_SECRET".to_vec(), + ), + ]); + let (output, _) = redact_with_report(&source, Format::Auto).unwrap(); + let parts = ooxml_opc::unzip_parts(&output).unwrap(); + assert_eq!( + part(&parts, "xl/embeddings/ole one~a.bin"), + b"", + "a percent-encoded target must be recognised as pointing at its part" + ); + let workbook_rels = String::from_utf8_lossy(part(&parts, "xl/_rels/workbook.xml.rels")); + assert!( + workbook_rels.contains(r#"Id="rIdOle""#), + "the relationship must not be left dangling: {workbook_rels}" + ); + assert!( + parts + .iter() + .all(|(_, bytes)| !String::from_utf8_lossy(bytes).contains("ENCODED_TARGET_SECRET")) + ); +} + +#[test] +fn encoded_spellings_resolve_after_case_folding_and_prefer_the_stored_part() { + let source = package(vec![ + ( + "[Content_Types].xml", + xml( + r#""#, + ), + ), + ( + "_rels/.rels", + xml( + r#""#, + ), + ), + ( + "xl/workbook.xml", + xml( + r#""#, + ), + ), + ( + "xl/_rels/workbook.xml.rels", + xml( + r#""#, + ), + ), + // `%4B` is uppercase `K`: resolution lowercases before decoding, so the + // decoded spelling has to be normalized again to match the entry key. + ("xl/custom/Key.bin", b"ENCODED_CASE_SECRET".to_vec()), + ("xl/custom/twin.bin", b"TWIN_TARGET_SECRET".to_vec()), + ("xl/custom/twin%2Ebin", b"TWIN_LITERAL_SECRET".to_vec()), + ]); + let (output, _) = redact_with_report(&source, Format::Auto).unwrap(); + let parts = ooxml_opc::unzip_parts(&output).unwrap(); + assert_eq!(part(&parts, "xl/custom/Key.bin"), b""); + assert_eq!(part(&parts, "xl/custom/twin.bin"), b""); + assert!( + parts.iter().all(|(path, _)| path != "xl/custom/twin%2Ebin"), + "the literal spelling nothing targets must be deleted" + ); + let workbook_rels = String::from_utf8_lossy(part(&parts, "xl/_rels/workbook.xml.rels")); + assert!( + workbook_rels.contains(r#"Id="rIdKey""#) && workbook_rels.contains(r#"Id="rIdTwin""#), + "relationships to retained parts must survive: {workbook_rels}" + ); + let content_types = String::from_utf8_lossy(part(&parts, "[Content_Types].xml")); + assert!( + content_types.contains("ms-office.opaque"), + "the encoded Override names a retained part: {content_types}" + ); + for secret in [ + "ENCODED_CASE_SECRET", + "TWIN_TARGET_SECRET", + "TWIN_LITERAL_SECRET", + ] { + assert!( + parts + .iter() + .all(|(_, bytes)| !String::from_utf8_lossy(bytes).contains(secret)), + "secret survived: {secret}" + ); + } +} + +#[test] +fn package_control_parts_are_never_cascade_targets() { + let source = package(vec![ + ( + "[Content_Types].xml", + xml( + r#""#, + ), + ), + ( + "_rels/.rels", + xml( + r#""#, + ), + ), + ( + "xl/workbook.xml", + xml( + r#""#, + ), + ), + ( + "xl/embeddings/payload.bin", + b"CONTROL_TARGET_SECRET".to_vec(), + ), + ( + "xl/embeddings/_rels/payload.bin.rels", + xml( + r#""#, + ), + ), + ]); + let (output, _) = redact_with_report(&source, Format::Auto).unwrap(); + let parts = ooxml_opc::unzip_parts(&output).unwrap(); + for path in ["[Content_Types].xml", "_rels/.rels"] { + assert!( + parts.iter().any(|(candidate, _)| candidate == path), + "a package control part was cascaded away: {path}" + ); + } + assert_eq!(detect_format(&output).unwrap(), Format::Xlsx); + assert!( + parts + .iter() + .all(|(_, bytes)| !String::from_utf8_lossy(bytes).contains("CONTROL_TARGET_SECRET")) + ); +} + +#[test] +fn orphans_do_not_spare_targets_and_noncanonical_control_parts_are_pruned() { + let source = package(vec![ + ( + "./[Content_Types].xml", + xml( + r#""#, + ), + ), + ( + "_rels/.rels", + xml( + r#""#, + ), + ), + ( + "xl/workbook.xml", + xml( + r#""#, + ), + ), + ("payload.bin", b"ORPHAN_PAYLOAD_SECRET".to_vec()), + ( + "_rels/payload.bin.rels", + xml( + r#""#, + ), + ), + ("secret.xml", xml("ORPHAN_TARGET_SECRET")), + ( + "./_rels/orphan.bin.rels", + xml( + r#""#, + ), + ), + ]); + let (output, _) = redact_with_report(&source, Format::Auto).unwrap(); + let parts = ooxml_opc::unzip_parts(&output).unwrap(); + for path in ["payload.bin", "_rels/payload.bin.rels", "secret.xml"] { + assert!( + parts.iter().all(|(candidate, _)| candidate != path), + "part survived: {path}" + ); + } + let content_types = parts + .iter() + .find(|(path, _)| path.to_ascii_lowercase().ends_with("[content_types].xml")) + .map(|(_, bytes)| String::from_utf8_lossy(bytes).into_owned()) + .expect("content types part must survive"); + assert!(!content_types.contains(r#"Extension="bin""#)); + + let orphan_rels = parts + .iter() + .find(|(path, _)| path.to_ascii_lowercase().ends_with("orphan.bin.rels")) + .map(|(_, bytes)| String::from_utf8_lossy(bytes).into_owned()) + .expect("orphan rels must survive as a part"); + assert!( + !orphan_rels.contains("secret.xml"), + "dangling relationship to a removed target must be pruned" + ); +} + +#[test] +fn unsupported_media_owned_by_scrubbed_binaries_is_removed_not_transformed() { + let source = package(vec![ + ( + "[Content_Types].xml", + xml( + r#""#, + ), + ), + ( + "_rels/.rels", + xml( + r#""#, + ), + ), + ( + "xl/workbook.xml", + xml( + r#""#, + ), + ), + ("xl/payload.bin", b"CASCADE_MEDIA_SECRET".to_vec()), + ( + "xl/_rels/payload.bin.rels", + xml( + r#""#, + ), + ), + ("xl/media/thumb.dat", b"NOT_A_DECODABLE_IMAGE".to_vec()), + ]); + let (output, report) = redact_with_report(&source, Format::Auto).unwrap(); + let parts = ooxml_opc::unzip_parts(&output).unwrap(); + for path in [ + "xl/payload.bin", + "xl/_rels/payload.bin.rels", + "xl/media/thumb.dat", + ] { + assert!( + parts.iter().all(|(candidate, _)| candidate != path), + "part survived: {path}" + ); + } + assert_eq!(report.binary_parts, 1); +} + +#[test] +fn noncanonical_xml_aliases_are_still_redacted() { + let source = package(vec![ + ( + "[Content_Types].xml", + xml( + r#""#, + ), + ), + ( + "_rels/.rels", + xml( + r#""#, + ), + ), + ( + "xl/workbook.xml", + xml( + r#""#, + ), + ), + ( + "./docProps/core.xml", + xml( + r#"CORE_CREATOR_SECRETCORE_TITLE_SECRET"#, + ), + ), + ]); + let (output, _) = redact_with_report(&source, Format::Auto).unwrap(); + let parts = ooxml_opc::unzip_parts(&output).unwrap(); + let core = parts + .iter() + .find(|(path, _)| path.to_ascii_lowercase().ends_with("core.xml")) + .map(|(_, bytes)| String::from_utf8_lossy(bytes).into_owned()) + .expect("core properties must survive as a part"); + assert!(!core.contains("CORE_CREATOR_SECRET")); + assert!(!core.contains("CORE_TITLE_SECRET")); +} + +#[test] +fn root_level_owned_relationships_cascade_to_their_targets() { + let source = package(vec![ + ( + "[Content_Types].xml", + xml( + r#""#, + ), + ), + ( + "_rels/.rels", + xml( + r#""#, + ), + ), + ( + "xl/workbook.xml", + xml( + r#""#, + ), + ), + ("payload.bin", b"ROOT_PAYLOAD_SECRET".to_vec()), + ( + "_rels/payload.bin.rels", + xml( + r#""#, + ), + ), + ( + "secret.xml", + xml("ROOT_SIGNATURE_TARGET_SECRET"), + ), + ]); + let (output, _) = redact_with_report(&source, Format::Auto).unwrap(); + let parts = ooxml_opc::unzip_parts(&output).unwrap(); + for path in ["payload.bin", "_rels/payload.bin.rels", "secret.xml"] { + assert!( + parts.iter().all(|(candidate, _)| candidate != path), + "part survived: {path}" + ); + } + assert!( + !parts + .iter() + .any(|(_, bytes)| String::from_utf8_lossy(bytes) + .contains("ROOT_SIGNATURE_TARGET_SECRET")), + "cascade target content must not survive in any part" + ); +} + +#[test] +fn shared_relationship_targets_survive_scrubbing() { + let source = package(vec![ + ( + "[Content_Types].xml", + xml( + r#""#, + ), + ), + ( + "_rels/.rels", + xml( + r#""#, + ), + ), + ( + "xl/workbook.xml", + xml( + r#""#, + ), + ), + ( + "xl/_rels/workbook.xml.rels", + xml( + r#""#, + ), + ), + ( + "xl/worksheets/sheet1.xml", + xml( + r#""#, + ), + ), + ( + "xl/worksheets/_rels/sheet1.xml.rels", + xml( + r#""#, + ), + ), + ("xl/embeddings/binary.bin", b"BINARY_SECRET".to_vec()), + ( + "xl/embeddings/_rels/binary.bin.rels", + xml( + r#""#, + ), + ), + ("xl/media/shared.png", placeholder_png()), + ]); + let (output, report) = redact_with_report(&source, Format::Auto).unwrap(); + assert_eq!(report.format, Format::Xlsx); + assert_eq!(report.binary_parts, 1); + let parts = ooxml_opc::unzip_parts(&output).unwrap(); + assert!( + parts.iter().all(|(path, _)| !path.contains("binary.bin")), + "binary part or its rels survived" + ); + assert!( + parts.iter().any(|(path, _)| path == "xl/media/shared.png"), + "shared target must survive scrubbing" + ); + assert!( + parts + .iter() + .all(|(_, bytes)| !String::from_utf8_lossy(bytes).contains("BINARY_SECRET")) + ); + let sheet_rels = String::from_utf8_lossy(part(&parts, "xl/worksheets/_rels/sheet1.xml.rels")); + assert!( + sheet_rels.contains("../media/shared.png"), + "retained reference to the shared target must survive" + ); + let content_types = String::from_utf8_lossy(part(&parts, "[Content_Types].xml")); + assert!(!content_types.contains("binary.bin")); + assert!(content_types.contains(r#"Extension="png""#)); +} + +#[test] +fn prunes_declarations_of_noncanonically_named_entries() { + let source = package(vec![ + ( + "[Content_Types].xml", + xml( + r#""#, + ), + ), + ( + "_rels/.rels", + xml( + r#""#, + ), + ), + ( + "xl/workbook.xml", + xml( + r#""#, + ), + ), + ( + "xl/_rels/workbook.xml.rels", + xml( + r#""#, + ), + ), + ( + "xl/worksheets/sheet1.xml", + xml( + r#""#, + ), + ), + ( + "xl//embeddings/binary.bin", + b"ALIASED_BINARY_SECRET".to_vec(), + ), + ( + "./xl/embeddings/orphan.bin", + b"ALIASED_ORPHAN_SECRET".to_vec(), + ), + ]); + let (output, report) = redact_with_report(&source, Format::Auto).unwrap(); + assert_eq!(report.binary_parts, 2); + let parts = ooxml_opc::unzip_parts(&output).unwrap(); + assert_eq!( + part(&parts, "xl//embeddings/binary.bin"), + b"", + "the aliased entry must be recognised as the relationship's target" + ); + assert!( + parts.iter().all(|(path, _)| !path.contains("orphan.bin")), + "aliased unreferenced binary survived" + ); + for secret in ["ALIASED_BINARY_SECRET", "ALIASED_ORPHAN_SECRET"] { + assert!( + parts + .iter() + .all(|(_, bytes)| !String::from_utf8_lossy(bytes).contains(secret)), + "secret survived: {secret}" + ); + } + let content_types = String::from_utf8_lossy(part(&parts, "[Content_Types].xml")); + assert!( + !content_types.contains("orphan.bin"), + "content-type Override outlived its part: {content_types}" + ); + assert!(!content_types.contains("ms-office.orphan")); + assert!(content_types.contains("ms-office.embedded")); + let workbook_rels = String::from_utf8_lossy(part(&parts, "xl/_rels/workbook.xml.rels")); + assert!( + workbook_rels.contains("binary.bin"), + "relationship to a retained part was pruned: {workbook_rels}" + ); + assert!(workbook_rels.contains("worksheets/sheet1.xml")); +} + +#[test] +fn a_uri_in_a_target_fragment_keeps_the_part_it_names() { + let source = package(vec![ + ( + "[Content_Types].xml", + xml( + r#""#, + ), + ), + ( + "_rels/.rels", + xml( + r#""#, + ), + ), + ( + "word/document.xml", + xml( + r#""#, + ), + ), + // The fragment carries a URI of its own; only the part before it names a part. + ( + "word/_rels/document.xml.rels", + xml( + r#""#, + ), + ), + ("word/payload.bin", b"FRAGMENT_TARGET_SECRET".to_vec()), + ]); + let (output, _) = redact_with_report(&source, Format::Auto).unwrap(); + let parts = ooxml_opc::unzip_parts(&output).unwrap(); + assert_eq!( + part(&parts, "word/payload.bin"), + b"", + "a target whose fragment holds a URI still names its part" + ); + let relationships = String::from_utf8_lossy(part(&parts, "word/_rels/document.xml.rels")); + assert!( + relationships.contains(r#"Id="rIdFragment""#), + "the relationship must not be left dangling: {relationships}" + ); + assert!( + parts + .iter() + .all(|(_, bytes)| !String::from_utf8_lossy(bytes).contains("FRAGMENT_TARGET_SECRET")) + ); +} + +#[test] +fn a_foreign_qualified_target_mode_does_not_strand_its_part() { + let source = package(vec![ + ( + "[Content_Types].xml", + xml( + r#""#, + ), + ), + ( + "_rels/.rels", + xml( + r#""#, + ), + ), + ( + "word/document.xml", + xml( + r#""#, + ), + ), + // Only the unqualified OPC `TargetMode` may send a relationship outside. + ( + "word/_rels/document.xml.rels", + xml( + r#""#, + ), + ), + ("word/payload.bin", b"FOREIGN_MODE_SECRET".to_vec()), + ]); + let (output, _) = redact_with_report(&source, Format::Auto).unwrap(); + let parts = ooxml_opc::unzip_parts(&output).unwrap(); + assert_eq!( + part(&parts, "word/payload.bin"), + b"", + "a foreign-qualified TargetMode must not make the target external" + ); + let relationships = String::from_utf8_lossy(part(&parts, "word/_rels/document.xml.rels")); + assert!( + relationships.contains(r#"Id="rIdForeign""#), + "the relationship must not be left dangling: {relationships}" + ); + assert!( + parts + .iter() + .all(|(_, bytes)| !String::from_utf8_lossy(bytes).contains("FOREIGN_MODE_SECRET")) + ); +} + +#[test] +fn a_literal_percent_spelling_is_kept_when_the_package_holds_only_it() { + let source = package(vec![ + ( + "[Content_Types].xml", + xml( + r#""#, + ), + ), + ( + "_rels/.rels", + xml( + r#""#, + ), + ), + ( + "xl/workbook.xml", + xml( + r#""#, + ), + ), + // `%25` encodes a literal `%`; the decoded spelling names no entry here, + // so the stored one is what the relationship resolves to. + ( + "xl/_rels/workbook.xml.rels", + xml( + r#""#, + ), + ), + ( + "xl/custom/literal%25.bin", + b"LITERAL_PERCENT_SECRET".to_vec(), + ), + ]); + let (output, _) = redact_with_report(&source, Format::Auto).unwrap(); + let parts = ooxml_opc::unzip_parts(&output).unwrap(); + assert_eq!( + part(&parts, "xl/custom/literal%25.bin"), + b"", + "the stored spelling is the one the relationship names" + ); + let relationships = String::from_utf8_lossy(part(&parts, "xl/_rels/workbook.xml.rels")); + assert!( + relationships.contains(r#"Id="rIdLiteral""#), + "the relationship must not be left dangling: {relationships}" + ); + assert!( + parts + .iter() + .all(|(_, bytes)| !String::from_utf8_lossy(bytes).contains("LITERAL_PERCENT_SECRET")) + ); +} + +#[test] +fn a_backslash_rooted_target_names_the_part_at_the_package_root() { + let source = package(vec![ + ( + "[Content_Types].xml", + xml( + r#""#, + ), + ), + ( + "_rels/.rels", + xml( + r#""#, + ), + ), + ( + "word/document.xml", + xml( + r#""#, + ), + ), + // A single leading backslash roots the target, the way `ooxml-opc` + // keys entries; two would make it UNC and send it outside. + ( + "word/_rels/document.xml.rels", + xml( + r#""#, + ), + ), + ("word/payload.bin", b"BACKSLASH_ROOT_SECRET".to_vec()), + ]); + let (output, _) = redact_with_report(&source, Format::Auto).unwrap(); + let parts = ooxml_opc::unzip_parts(&output).unwrap(); + assert_eq!( + part(&parts, "word/payload.bin"), + b"", + "a backslash-rooted target still names its part" + ); + let relationships = String::from_utf8_lossy(part(&parts, "word/_rels/document.xml.rels")); + assert!( + relationships.contains(r#"Id="rIdRooted""#), + "the relationship must not be left dangling: {relationships}" + ); + assert!( + parts + .iter() + .all(|(_, bytes)| !String::from_utf8_lossy(bytes).contains("BACKSLASH_ROOT_SECRET")) + ); +} + +#[test] +fn an_unresolvable_internal_target_blanks_instead_of_deleting() { + let source = package(vec![ + ( + "[Content_Types].xml", + xml( + r#""#, + ), + ), + ( + "_rels/.rels", + xml( + r#""#, + ), + ), + ( + "word/document.xml", + xml( + r#""#, + ), + ), + // This target climbs past the package root, so which part it stands for + // cannot be established; nothing may be deleted on that basis. + ( + "word/_rels/document.xml.rels", + xml( + r#""#, + ), + ), + ("word/payload.bin", b"UNRESOLVABLE_TARGET_SECRET".to_vec()), + ("word/lonely.bin", b"UNREFERENCED_SECRET".to_vec()), + ]); + let (output, _) = redact_with_report(&source, Format::Auto).unwrap(); + let parts = ooxml_opc::unzip_parts(&output).unwrap(); + for path in ["word/payload.bin", "word/lonely.bin"] { + assert_eq!( + part(&parts, path), + b"", + "{path} must be emptied in place, not removed" + ); + } + let relationships = String::from_utf8_lossy(part(&parts, "word/_rels/document.xml.rels")); + assert!( + relationships.contains(r#"Id="rIdLost""#), + "the relationship must survive alongside its part: {relationships}" + ); + for secret in ["UNRESOLVABLE_TARGET_SECRET", "UNREFERENCED_SECRET"] { + assert!( + parts + .iter() + .all(|(_, bytes)| !String::from_utf8_lossy(bytes).contains(secret)), + "secret survived: {secret}" + ); + } +} + +#[test] +fn the_exact_case_target_is_the_one_followed() { + let source = package(vec![ + ( + "[Content_Types].xml", + xml( + r#""#, + ), + ), + ( + "_rels/.rels", + xml( + r#""#, + ), + ), + ( + "word/document.xml", + xml( + r#""#, + ), + ), + // Both spellings name stored parts, so only attribute precedence decides + // which one the relationship keeps alive. + ( + "word/_rels/document.xml.rels", + xml( + r#""#, + ), + ), + ("word/payload.bin", b"EXACT_TARGET_SECRET".to_vec()), + ("word/decoy.bin", b"DECOY_TARGET_SECRET".to_vec()), + ]); + let (output, _) = redact_with_report(&source, Format::Auto).unwrap(); + let parts = ooxml_opc::unzip_parts(&output).unwrap(); + assert_eq!( + part(&parts, "word/payload.bin"), + b"", + "the exact-case Target names the part that must survive" + ); + assert!( + parts.iter().all(|(path, _)| path != "word/decoy.bin"), + "the tolerated variant names no relationship target" + ); + for secret in ["EXACT_TARGET_SECRET", "DECOY_TARGET_SECRET"] { + assert!( + parts + .iter() + .all(|(_, bytes)| !String::from_utf8_lossy(bytes).contains(secret)), + "secret survived: {secret}" + ); + } +} + +#[test] +fn a_case_variant_extension_does_not_drop_the_declaration() { + let source = package(vec![ + ( + "[Content_Types].xml", + xml( + r#""#, + ), + ), + ( + "_rels/.rels", + xml( + r#""#, + ), + ), + ( + "word/document.xml", + xml( + r#""#, + ), + ), + ( + "word/_rels/document.xml.rels", + xml( + r#""#, + ), + ), + ("word/payload.bin", b"DECLARED_PART_SECRET".to_vec()), + ]); + let (output, _) = redact_with_report(&source, Format::Auto).unwrap(); + let parts = ooxml_opc::unzip_parts(&output).unwrap(); + assert_eq!(part(&parts, "word/payload.bin"), b""); + let content_types = String::from_utf8_lossy(part(&parts, "[Content_Types].xml")); + assert!( + content_types.contains(r#"Extension="bin""#), + "the retained part keeps its declaration: {content_types}" + ); + assert!( + parts + .iter() + .all(|(_, bytes)| !String::from_utf8_lossy(bytes).contains("DECLARED_PART_SECRET")) + ); +} + fn assert_fixture_properties(source: &[u8], output: &[u8], secrets: &[&str], media_path: &str) { let before = ooxml_opc::unzip_parts(source).unwrap(); let after = ooxml_opc::unzip_parts(output).unwrap(); diff --git a/crates/ooxml-redact/src/xml.rs b/crates/ooxml-redact/src/xml.rs index 4e832483..20b62d0a 100644 --- a/crates/ooxml-redact/src/xml.rs +++ b/crates/ooxml-redact/src/xml.rs @@ -1,6 +1,7 @@ use quick_xml::events::{BytesCData, BytesStart, BytesText, Event}; use quick_xml::{Reader, Writer, XmlVersion}; +use crate::rels::{self, attribute_local, is_unqualified}; use crate::{Format, RedactError, RedactionReport}; pub(crate) fn redact_xml( @@ -371,50 +372,17 @@ fn local_name(name: &[u8]) -> String { String::from_utf8_lossy(name).into_owned() } -fn attribute_local(name: &str) -> &str { - name.rsplit_once(':').map_or(name, |(_, local)| local) -} - /// Whether the element is a relationship in a package relationship part, and -/// whether it points outside the package. Only unqualified OPC attributes take -/// part in the decision; a target shape is read only from the exact-case -/// `Target` a `.rels` part's consumers resolve. +/// whether it points outside the package. fn relationship_mode(path: &str, element: &str, attributes: &[(String, String)]) -> (bool, bool) { if !element.eq_ignore_ascii_case("Relationship") { return (false, false); } let package_part = path.to_ascii_lowercase().ends_with(".rels"); - let external = attributes.iter().any(|(key, value)| { - if !is_unqualified(key) { - return false; - } - let local = attribute_local(key); - local.eq_ignore_ascii_case("TargetMode") && value.trim().eq_ignore_ascii_case("External") - || package_part && local == "Target" && external_target(value) - }); - (package_part, external) -} - -fn is_unqualified(key: &str) -> bool { - !key.contains(':') -} - -/// True for relationship targets pointing outside the package. -fn external_target(target: &str) -> bool { - let lower = target.trim().to_ascii_lowercase(); - lower.starts_with("//") - || lower.starts_with(r"\\") - || lower - .split_once(':') - .is_some_and(|(scheme, _)| is_uri_scheme(scheme)) -} - -fn is_uri_scheme(scheme: &str) -> bool { - let mut chars = scheme.chars(); - if !matches!(chars.next(), Some(first) if first.is_ascii_alphabetic()) { - return false; - } - chars.all(|character| character.is_ascii_alphanumeric() || matches!(character, '+' | '-' | '.')) + ( + package_part, + rels::external_relationship(attributes, package_part), + ) } fn xml_error(path: &str, error: impl fmt::Display) -> RedactError {