use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use std::io::{Read, Write};
use quick_xml::events::Event;
use quick_xml::name::{Namespace, ResolveResult};
use quick_xml::NsReader;
use zip::write::SimpleFileOptions;
use zip::{ZipArchive, ZipWriter};
use crate::error::{Error, Result};
use crate::xmltree::XmlTree;
type CtRecordIdentity = (String, String);
const CT_NS: &str = "http://schemas.openxmlformats.org/package/2006/content-types";
const REL_NS: &str = "http://schemas.openxmlformats.org/package/2006/relationships";
const CT_RELS: &str = "application/vnd.openxmlformats-package.relationships+xml";
const XML_DECL: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#;
const CONTENT_TYPES: &str = "[Content_Types].xml";
const MAX_PART: u64 = 64 << 20;
#[cfg(test)]
thread_local! {
static TEST_MAX_PART: std::cell::Cell<u64> = const { std::cell::Cell::new(MAX_PART) };
}
#[cfg(test)]
pub(crate) fn set_test_max_part(n: u64) {
TEST_MAX_PART.with(|c| c.set(n));
}
#[cfg(test)]
pub(crate) fn reset_test_max_part() {
TEST_MAX_PART.with(|c| c.set(MAX_PART));
}
pub(crate) fn max_part() -> u64 {
#[cfg(test)]
{
TEST_MAX_PART.with(|c| c.get())
}
#[cfg(not(test))]
{
MAX_PART
}
}
const MAX_TOTAL: u64 = 512 << 20;
const MAX_ENTRIES: usize = 1 << 16;
#[cfg(test)]
thread_local! {
static TEST_MAX_ENTRIES: std::cell::Cell<usize> = const { std::cell::Cell::new(MAX_ENTRIES) };
}
#[cfg(test)]
pub(crate) fn set_test_max_entries(n: usize) {
TEST_MAX_ENTRIES.with(|c| c.set(n));
}
#[cfg(test)]
pub(crate) fn reset_test_max_entries() {
TEST_MAX_ENTRIES.with(|c| c.set(MAX_ENTRIES));
}
fn max_entries() -> usize {
#[cfg(test)]
{
TEST_MAX_ENTRIES.with(|c| c.get())
}
#[cfg(not(test))]
{
MAX_ENTRIES
}
}
pub(crate) const MAX_NAME_LEN: usize = 4096;
const MAX_META_RECORDS: usize = 1 << 16;
#[cfg(test)]
thread_local! {
static TEST_MAX_META: std::cell::Cell<usize> = const { std::cell::Cell::new(MAX_META_RECORDS) };
}
#[cfg(test)]
pub(crate) fn set_test_max_meta(n: usize) {
TEST_MAX_META.with(|c| c.set(n));
}
fn max_meta_records() -> usize {
#[cfg(test)]
{
TEST_MAX_META.with(|c| c.get())
}
#[cfg(not(test))]
{
MAX_META_RECORDS
}
}
#[derive(Debug, Clone)]
pub(crate) struct Rel {
pub id: String,
pub rel_type: String,
pub target: String,
pub external: bool,
}
#[derive(Debug, Clone)]
struct CtDefault {
extension: String,
content_type: String,
}
impl CtDefault {
fn new(extension: impl Into<String>, content_type: impl Into<String>) -> Self {
Self {
extension: extension.into(),
content_type: content_type.into(),
}
}
}
#[derive(Debug, Clone)]
struct CtOverride {
part_name: String,
content_type: String,
}
impl CtOverride {
fn new(part_name: impl Into<String>, content_type: impl Into<String>) -> Self {
Self {
part_name: part_name.into(),
content_type: content_type.into(),
}
}
}
#[derive(Debug, Default, Clone)]
struct ContentTypes {
defaults: Vec<CtDefault>,
overrides: Vec<CtOverride>,
rels_default_injected: bool,
}
impl ContentTypes {
fn resolves(&self, part: &str) -> bool {
let pn = format!("/{part}");
if self
.overrides
.iter()
.any(|o| o.part_name.eq_ignore_ascii_case(&pn))
{
return true;
}
match part.rsplit_once('.') {
Some((_, ext)) => self
.defaults
.iter()
.any(|d| d.extension.eq_ignore_ascii_case(ext)),
None => false,
}
}
fn resolves_as(&self, part: &str, content_type: &str) -> bool {
let pn = format!("/{part}");
if let Some(o) = self
.overrides
.iter()
.find(|o| o.part_name.eq_ignore_ascii_case(&pn))
{
return o.content_type == content_type;
}
match part.rsplit_once('.') {
Some((_, ext)) => self
.defaults
.iter()
.any(|d| d.extension.eq_ignore_ascii_case(ext) && d.content_type == content_type),
None => false,
}
}
}
#[derive(Clone)]
enum Part {
Raw(Vec<u8>),
Xml(XmlTree),
}
impl Part {
fn bytes(&self) -> Cow<'_, [u8]> {
match self {
Part::Raw(b) => Cow::Borrowed(b),
Part::Xml(t) => Cow::Owned(t.serialize()),
}
}
}
#[derive(Clone)]
pub(crate) struct Package {
order: Vec<String>,
parts: HashMap<String, Part>,
ctypes: ContentTypes,
rels: HashMap<String, Vec<Rel>>,
rid_next: u64,
touched: HashSet<String>,
complete: bool,
meta_lossy: bool,
ct_rels_injected: bool,
}
impl Package {
pub(crate) fn is_complete(&self) -> bool {
self.complete
}
pub(crate) fn is_meta_lossy(&self) -> bool {
self.meta_lossy
}
}
impl Package {
pub(crate) fn from_zip(bytes: &[u8]) -> Result<Package> {
check_zip_entry_budget(bytes)?;
let mut zip = ZipArchive::new(std::io::Cursor::new(bytes))
.map_err(|e| Error::Docx(format!("not a valid OPC (zip) package: {e}")))?;
let entry_cap = max_entries();
if zip.len() > entry_cap {
return Err(Error::Docx(format!(
"package has too many entries ({} > {entry_cap})",
zip.len()
)));
}
let mut order = Vec::new();
let mut seen: HashSet<String> = HashSet::new(); let mut seen_ci: HashSet<String> = HashSet::new();
let mut parts: HashMap<String, Part> = HashMap::new();
let mut total: u64 = 0;
let mut meta_lossy = false;
let mut complete = true;
for i in 0..zip.len() {
let mut f = match zip.by_index(i) {
Ok(f) => f,
Err(_) => {
complete = false;
continue;
}
};
let name = f.name().to_string();
if name.len() > MAX_NAME_LEN {
return Err(Error::Docx("part name too long".into()));
}
if name.ends_with('/') {
if seen.insert(name.clone()) {
order.push(name);
}
continue;
}
if !f.is_file() {
continue;
}
if f.size() > MAX_PART {
return Err(Error::Docx(format!("part {name} exceeds the size budget")));
}
let mut buf = Vec::new();
if (&mut f).take(MAX_PART + 1).read_to_end(&mut buf).is_err() {
complete = false; continue;
}
total = total.saturating_add(buf.len() as u64);
if buf.len() as u64 > MAX_PART || total > MAX_TOTAL {
return Err(Error::Docx(format!("part {name} exceeds the size budget")));
}
if !seen.insert(name.clone()) {
continue;
}
if !seen_ci.insert(name.to_ascii_lowercase()) {
meta_lossy = true;
}
order.push(name.clone());
parts.insert(name, Part::Raw(buf));
}
let ctypes = match parts.get(CONTENT_TYPES) {
Some(p) => match parse_content_types(p.bytes().as_ref()) {
Ok(c) => c,
Err(_) => {
meta_lossy = true;
ContentTypes {
defaults: vec![CtDefault::new("rels", CT_RELS)],
overrides: Vec::new(),
rels_default_injected: false,
}
}
},
None => {
meta_lossy = true;
ContentTypes {
defaults: vec![CtDefault::new("rels", CT_RELS)],
overrides: Vec::new(),
rels_default_injected: false,
}
}
};
let ct_rels_injected = ctypes.rels_default_injected;
let mut rels: HashMap<String, Vec<Rel>> = HashMap::new();
for name in &order {
if is_rels(name) {
if let Some(p) = parts.get(name) {
match parse_rels(p.bytes().as_ref()) {
Ok(r) => {
rels.insert(name.clone(), r);
}
Err(_) => meta_lossy = true, }
}
}
}
let rid_next = rels
.values()
.flat_map(|v| v.iter())
.filter_map(|r| r.id.strip_prefix("rId").and_then(|n| n.parse::<u64>().ok()))
.max()
.map(|m| m.saturating_add(1)) .unwrap_or(1);
Ok(Package {
order,
parts,
ctypes,
rels,
rid_next,
touched: HashSet::new(),
complete,
meta_lossy,
ct_rels_injected,
})
}
pub(crate) fn to_zip(&self) -> Result<Vec<u8>> {
for name in &self.touched {
if name.ends_with('/') || name == CONTENT_TYPES {
continue;
}
if !self.ctypes.resolves(name) {
return Err(Error::Docx(format!(
"part {name} has no resolvable content type"
)));
}
}
let meta_cap = max_meta_records();
if self.touched.contains(CONTENT_TYPES)
&& self
.ctypes
.defaults
.len()
.saturating_add(self.ctypes.overrides.len())
> meta_cap
{
return Err(Error::Docx(
"[Content_Types].xml has too many entries on save".into(),
));
}
for (rels_path, entries) in &self.rels {
if self.touched.contains(rels_path) && entries.len() > meta_cap {
return Err(Error::Docx(format!(
"{rels_path} has too many relationships on save"
)));
}
}
for (rels_path, entries) in &self.rels {
if !self.touched.contains(rels_path) {
continue;
}
let source_part = source_part_of_rels_path(rels_path).ok_or_else(|| {
Error::Docx(format!(
"{rels_path} is not a valid relationships part path"
))
})?;
for rel in entries {
if rel.external {
continue;
}
let target = resolve_rel_target(&source_part, rel_target_part_path(&rel.target));
if !self.has_part(&target) {
return Err(Error::Docx(format!(
"{rels_path} relationship {} targets missing part {target}",
rel.id
)));
}
}
}
let in_order: HashSet<&String> = self.order.iter().collect();
let added_after_open = self.parts.keys().filter(|k| !in_order.contains(k)).count();
if self.order.len().saturating_add(added_after_open) > max_entries() {
return Err(Error::Docx("package has too many entries on save".into()));
}
let mut zw = ZipWriter::new(std::io::Cursor::new(Vec::new()));
let opt = SimpleFileOptions::default();
let mut total: u64 = 0;
let part_budget = max_part();
let emit =
|zw: &mut ZipWriter<_>, total: &mut u64, name: &str, bytes: &[u8]| -> Result<()> {
if name.len() > MAX_NAME_LEN {
return Err(Error::Docx(format!("part name too long on save: {name}")));
}
if bytes.len() as u64 > part_budget {
return Err(Error::Docx(format!(
"part {name} exceeds the per-part size budget on save"
)));
}
*total = total.saturating_add(bytes.len() as u64);
if *total > MAX_TOTAL {
return Err(Error::Docx(
"package exceeds the total size budget on save".into(),
));
}
zw.start_file(name, opt)
.map_err(|e| Error::Docx(format!("zip start {name}: {e}")))?;
zw.write_all(bytes)
.map_err(|e| Error::Docx(format!("zip write {name}: {e}")))?;
Ok(())
};
for name in &self.order {
if name.ends_with('/') {
zw.add_directory(name.as_str(), opt)
.map_err(|e| Error::Docx(format!("zip dir {name}: {e}")))?;
} else if let Some(p) = self.parts.get(name) {
emit(&mut zw, &mut total, name, p.bytes().as_ref())?;
}
}
let mut extra: Vec<&String> = self
.parts
.keys()
.filter(|k| !in_order.contains(k))
.collect();
extra.sort();
for name in extra {
if let Some(p) = self.parts.get(name) {
emit(&mut zw, &mut total, name, p.bytes().as_ref())?;
}
}
let cur = zw
.finish()
.map_err(|e| Error::Docx(format!("zip finish: {e}")))?;
Ok(cur.into_inner())
}
pub(crate) fn part(&self, name: &str) -> Option<Vec<u8>> {
self.parts.get(name).map(|p| p.bytes().into_owned())
}
pub(crate) fn has_part(&self, name: &str) -> bool {
self.parts.keys().any(|p| p.eq_ignore_ascii_case(name))
}
pub(crate) fn part_names(&self) -> impl Iterator<Item = &str> {
self.parts.keys().map(String::as_str)
}
pub(crate) fn touched_parts(&self) -> Vec<String> {
let mut parts: Vec<String> = self.touched.iter().cloned().collect();
parts.sort();
parts
}
pub(crate) fn part_tree_ref(&self, name: &str) -> Option<&XmlTree> {
match self.parts.get(name) {
Some(Part::Xml(t)) => Some(t),
_ => None,
}
}
pub(crate) fn part_tree_mut(&mut self, name: &str) -> Result<&mut XmlTree> {
let entry = self
.parts
.get_mut(name)
.ok_or_else(|| Error::Docx(format!("no part {name}")))?;
if let Part::Raw(bytes) = entry {
*entry = Part::Xml(XmlTree::parse(bytes)?);
}
self.touched.insert(name.to_string());
match entry {
Part::Xml(t) => Ok(t),
Part::Raw(_) => unreachable!("just promoted to Xml"),
}
}
pub(crate) fn set_part(&mut self, name: &str, bytes: Vec<u8>, content_type: Option<&str>) {
let store_name = match self
.parts
.keys()
.find(|p| p.eq_ignore_ascii_case(name))
.cloned()
{
Some(existing) => existing,
None => {
self.order.push(name.to_string());
name.to_string()
}
};
self.touched.insert(store_name.clone());
self.parts.insert(store_name.clone(), Part::Raw(bytes));
if let Some(ct) = content_type {
let pn = format!("/{store_name}");
let mut matched = false;
let mut changed = false;
for o in self
.ctypes
.overrides
.iter_mut()
.filter(|o| o.part_name.eq_ignore_ascii_case(&pn))
{
matched = true;
if o.content_type != ct {
o.content_type = ct.to_string(); changed = true;
}
}
if matched {
if changed {
self.regen_content_types();
}
} else if !self.ctypes.resolves_as(&store_name, ct) {
self.ctypes.overrides.push(CtOverride::new(pn, ct));
self.regen_content_types();
}
}
}
pub(crate) fn ensure_content_type(&mut self, name: &str, content_type: &str) {
let pn = format!("/{name}");
let mut matched = false;
let mut changed = false;
for o in self
.ctypes
.overrides
.iter_mut()
.filter(|o| o.part_name.eq_ignore_ascii_case(&pn))
{
matched = true;
if o.content_type != content_type {
o.content_type = content_type.to_string();
changed = true;
}
}
if matched {
if changed {
self.regen_content_types();
}
} else {
self.ctypes
.overrides
.push(CtOverride::new(pn, content_type));
self.regen_content_types();
}
self.touched.insert(name.to_string());
}
pub(crate) fn rels_for(&self, content_part: &str) -> &[Rel] {
self.rels
.get(&rels_path_of(content_part))
.map(|v| v.as_slice())
.unwrap_or(&[])
}
#[cfg(test)]
pub(crate) fn part_has_content_type(&self, part: &str) -> bool {
self.ctypes.resolves(part)
}
pub(crate) fn alloc_rid(&mut self) -> String {
loop {
let cand = self.rid_next;
self.rid_next = self.rid_next.wrapping_add(1);
if !self.rid_in_use(cand) {
return format!("rId{cand}");
}
}
}
fn rid_in_use(&self, n: u64) -> bool {
self.rels
.values()
.flatten()
.any(|r| r.id.strip_prefix("rId").and_then(|s| s.parse::<u64>().ok()) == Some(n))
}
pub(crate) fn add_related_part(
&mut self,
src_part: &str,
rel_type: &str,
new_part: &str,
content_type: Option<&str>,
bytes: Vec<u8>,
) -> String {
self.set_part(new_part, bytes, content_type);
let rid = self.alloc_rid();
let target = rel_target(src_part, new_part);
let rels_path = rels_path_of(src_part);
self.rels.entry(rels_path.clone()).or_default().push(Rel {
id: rid.clone(),
rel_type: rel_type.to_string(),
target,
external: false,
});
self.regen_rels(&rels_path);
rid
}
pub(crate) fn ensure_relationship(
&mut self,
src_part: &str,
rel_type: &str,
target_part: &str,
) -> String {
let rels_path = rels_path_of(src_part);
if let Some(existing) = self.rels.get(&rels_path).and_then(|entries| {
entries.iter().find(|rel| {
!rel.external
&& rel.rel_type == rel_type
&& resolve_rel_target(src_part, &rel.target).eq_ignore_ascii_case(target_part)
})
}) {
return existing.id.clone();
}
let rid = self.alloc_rid();
let target = rel_target(src_part, target_part);
self.rels.entry(rels_path.clone()).or_default().push(Rel {
id: rid.clone(),
rel_type: rel_type.to_string(),
target,
external: false,
});
self.regen_rels(&rels_path);
rid
}
pub(crate) fn set_external_relationship_target(
&mut self,
src_part: &str,
rel_type: &str,
rel_id: &str,
target: &str,
) -> Result<()> {
if target.is_empty() {
return Err(Error::Docx("relationship target must not be empty".into()));
}
if target.chars().any(|c| !is_xml_legal_char(c)) {
return Err(Error::Docx(
"relationship target contains an XML-illegal character".into(),
));
}
let rels_path = rels_path_of(src_part);
let changed = {
let entries = self
.rels
.get_mut(&rels_path)
.ok_or_else(|| Error::Docx(format!("missing relationships for {src_part}")))?;
let rel = entries
.iter_mut()
.find(|rel| rel.id == rel_id && rel.rel_type == rel_type && rel.external)
.ok_or_else(|| {
Error::Docx(format!(
"relationship {rel_id:?} is not an external relationship of the expected type"
))
})?;
if rel.target == target {
false
} else {
rel.target = target.to_string();
true
}
};
if changed {
self.regen_rels(&rels_path);
}
Ok(())
}
fn regen_content_types(&mut self) {
let mut s = String::new();
s.push_str(XML_DECL);
s.push_str(&format!(r#"<Types xmlns="{CT_NS}">"#));
for d in &self.ctypes.defaults {
s.push_str(&format!(
r#"<Default Extension="{}" ContentType="{}"/>"#,
esc(&d.extension),
esc(&d.content_type)
));
}
for o in &self.ctypes.overrides {
s.push_str(&format!(
r#"<Override PartName="{}" ContentType="{}"/>"#,
esc(&o.part_name),
esc(&o.content_type)
));
}
s.push_str("</Types>");
if !self.parts.contains_key(CONTENT_TYPES) {
self.order.insert(0, CONTENT_TYPES.to_string());
}
self.parts
.insert(CONTENT_TYPES.to_string(), Part::Raw(s.into_bytes()));
self.touched.insert(CONTENT_TYPES.to_string());
}
fn regen_rels(&mut self, rels_path: &str) {
let Some(entries) = self.rels.get(rels_path) else {
return;
};
let mut s = String::new();
s.push_str(XML_DECL);
s.push_str(&format!(r#"<Relationships xmlns="{REL_NS}">"#));
for r in entries {
s.push_str(&format!(
r#"<Relationship Id="{}" Type="{}" Target="{}""#,
esc(&r.id),
esc(&r.rel_type),
esc(&r.target)
));
if r.external {
s.push_str(r#" TargetMode="External""#);
}
s.push_str("/>");
}
s.push_str("</Relationships>");
if !self.parts.contains_key(rels_path) {
self.order.push(rels_path.to_string());
}
self.parts
.insert(rels_path.to_string(), Part::Raw(s.into_bytes()));
self.touched.insert(rels_path.to_string());
if self.ct_rels_injected {
self.regen_content_types();
}
}
}
fn rels_path_of(part: &str) -> String {
match part.rsplit_once('/') {
Some((dir, file)) => format!("{dir}/_rels/{file}.rels"),
None => format!("_rels/{part}.rels"),
}
}
fn source_part_of_rels_path(rels_path: &str) -> Option<String> {
if rels_path == "_rels/.rels" {
return Some(String::new());
}
let rels_source = rels_path.strip_suffix(".rels")?;
if let Some(file) = rels_source.strip_prefix("_rels/") {
return (!file.is_empty()).then(|| file.to_string());
}
let (dir, file) = rels_source.rsplit_once("/_rels/")?;
(!dir.is_empty() && !file.is_empty()).then(|| format!("{dir}/{file}"))
}
fn is_rels(name: &str) -> bool {
name.ends_with(".rels") && (name.starts_with("_rels/") || name.contains("/_rels/"))
}
fn rel_target(src_part: &str, new_part: &str) -> String {
let src_dir = src_part.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
if src_dir.is_empty() {
return new_part.to_string();
}
if !src_dir.is_empty() {
if let Some(rest) = new_part.strip_prefix(&format!("{src_dir}/")) {
return rest.to_string();
}
}
format!("/{new_part}")
}
fn rel_target_part_path(target: &str) -> &str {
let before_fragment = target.split_once('#').map_or(target, |(path, _)| path);
before_fragment
.split_once('?')
.map_or(before_fragment, |(path, _)| path)
}
pub(crate) fn resolve_rel_target(src_part: &str, target: &str) -> String {
let base: Vec<&str> = if target.starts_with('/') {
Vec::new()
} else {
src_part
.rsplit_once('/')
.map(|(dir, _)| dir.split('/').filter(|s| !s.is_empty()).collect())
.unwrap_or_default()
};
let mut segs = base;
for seg in target.split('/') {
match seg {
"" | "." => {}
".." => {
segs.pop();
}
s => segs.push(s),
}
}
segs.join("/")
}
fn esc(s: &str) -> String {
let mut o = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => o.push_str("&"),
'<' => o.push_str("<"),
'>' => o.push_str(">"),
'"' => o.push_str("""),
_ if is_xml_legal_char(c) => o.push(c),
_ => {}
}
}
o
}
fn is_xml_legal_char(c: char) -> bool {
matches!(c, '\t' | '\n' | '\r')
|| matches!(
c as u32,
0x20..=0xD7FF | 0xE000..=0xFFFD | 0x10000..=0x10FFFF
)
}
fn is_xml_whitespace(bytes: &[u8]) -> bool {
bytes
.iter()
.all(|b| matches!(b, b' ' | b'\t' | b'\r' | b'\n'))
}
fn local(name: &[u8]) -> &[u8] {
match name.iter().position(|&b| b == b':') {
Some(i) => &name[i + 1..],
None => name,
}
}
fn attrs_of(e: &quick_xml::events::BytesStart<'_>) -> Result<Vec<(Vec<u8>, String)>> {
let mut out = Vec::new();
for a in e.attributes() {
if out.len() >= max_meta_records() {
return Err(Error::Docx("opc element has too many attributes".into()));
}
let a = a.map_err(|err| Error::Docx(format!("opc attr: {err}")))?;
let v = a
.unescape_value()
.map_err(|err| Error::Docx(format!("opc attr value: {err}")))?
.into_owned();
if v.chars().any(|c| !is_xml_legal_char(c)) {
return Err(Error::Docx(
"opc attr value contains an XML-illegal character".into(),
));
}
out.push((a.key.as_ref().to_vec(), v));
}
Ok(out)
}
fn validate_modeled_attrs(
attrs: &[(Vec<u8>, String)],
modeled: &[&[u8]],
where_: &str,
) -> Result<()> {
for (k, _) in attrs {
if !modeled.contains(&k.as_slice()) {
return Err(Error::Docx(format!("{where_}: unmodeled attribute")));
}
}
Ok(())
}
fn validate_opc_root_attrs(attrs: &[(Vec<u8>, String)], want_ns: &str, part: &str) -> Result<()> {
match attrs {
[(k, v)] if k.as_slice() == b"xmlns" && v == want_ns => Ok(()),
_ => Err(Error::Docx(format!(
"{part}: root must carry only the default OPC namespace declaration"
))),
}
}
fn find_attr<'a>(attrs: &'a [(Vec<u8>, String)], key: &[u8]) -> Option<&'a str> {
attrs
.iter()
.find(|(k, _)| k.as_slice() == key)
.map(|(_, v)| v.as_str())
}
pub(crate) fn check_zip_entry_budget(bytes: &[u8]) -> Result<()> {
if let Some(n) = eocd_entry_count(bytes) {
if n > max_entries() as u64 {
return Err(Error::Docx(format!(
"package declares too many entries ({n} > {})",
max_entries()
)));
}
}
Ok(())
}
fn eocd_entry_count(bytes: &[u8]) -> Option<u64> {
const EOCD_SIG: [u8; 4] = [0x50, 0x4b, 0x05, 0x06];
const Z64_LOC_SIG: [u8; 4] = [0x50, 0x4b, 0x06, 0x07];
const Z64_EOCD_SIG: [u8; 4] = [0x50, 0x4b, 0x06, 0x06];
const EOCD_MIN: usize = 22; if bytes.len() < EOCD_MIN {
return None;
}
let scan_start = bytes.len().saturating_sub(EOCD_MIN + 0xFFFF);
let mut e = None;
let mut i = bytes.len() - EOCD_MIN;
loop {
if bytes[i..i + 4] == EOCD_SIG {
let comment_len = u16::from_le_bytes([bytes[i + 20], bytes[i + 21]]) as usize;
if i + EOCD_MIN + comment_len == bytes.len() {
e = Some(i);
break;
}
}
if i == scan_start {
break;
}
i -= 1;
}
let e = e?;
let total16 = u16::from_le_bytes([bytes[e + 10], bytes[e + 11]]);
if total16 != 0xFFFF {
return Some(u64::from(total16));
}
let loc = e.checked_sub(20)?;
if bytes[loc..loc + 4] != Z64_LOC_SIG {
return Some(u64::from(total16));
}
let z64_off = u64::from_le_bytes(bytes.get(loc + 8..loc + 16)?.try_into().ok()?) as usize;
let z64 = bytes.get(z64_off..z64_off.checked_add(40)?)?;
if z64[0..4] != Z64_EOCD_SIG {
return None;
}
Some(u64::from_le_bytes(z64[32..40].try_into().ok()?))
}
fn is_ct_record_name(name: &[u8]) -> bool {
matches!(local(name), b"Default" | b"Override")
}
fn ct_child(
ns: &ResolveResult<'_>,
e: &quick_xml::events::BytesStart<'_>,
ct: &mut ContentTypes,
) -> Result<()> {
if ns_is(ns, CT_NS) {
ct_record(e, ct)
} else if is_ct_record_name(e.name().as_ref()) {
Err(Error::Docx(
"[Content_Types].xml: a Default/Override is outside the content-types namespace".into(),
))
} else {
Ok(())
}
}
fn rel_child(
ns: &ResolveResult<'_>,
e: &quick_xml::events::BytesStart<'_>,
out: &mut Vec<Rel>,
) -> Result<()> {
if ns_is(ns, REL_NS) {
rel_record(e, out)
} else if local(e.name().as_ref()) == b"Relationship" {
Err(Error::Docx(
".rels: a Relationship is outside the relationships namespace".into(),
))
} else {
Ok(())
}
}
fn is_mime_token(s: &str) -> bool {
!s.is_empty()
&& s.bytes().all(|b| {
b.is_ascii()
&& !b.is_ascii_control()
&& !b.is_ascii_whitespace()
&& !matches!(
b,
b'(' | b')'
| b'<'
| b'>'
| b'@'
| b','
| b';'
| b':'
| b'\\'
| b'"'
| b'/'
| b'['
| b']'
| b'?'
| b'='
)
})
}
fn valid_media_type(value: &str) -> bool {
let value = value.trim();
if value.is_empty() {
return false;
}
let core = value.split(';').next().unwrap_or("").trim_end();
let Some((ty, subtype)) = core.split_once('/') else {
return false;
};
is_mime_token(ty) && is_mime_token(subtype) && !subtype.contains('/')
}
fn has_valid_percent_escapes(s: &str) -> bool {
let b = s.as_bytes();
let mut i = 0;
while i < b.len() {
if b[i] == b'%' {
if i + 2 >= b.len() || !b[i + 1].is_ascii_hexdigit() || !b[i + 2].is_ascii_hexdigit() {
return false;
}
i += 3;
} else {
i += 1;
}
}
true
}
fn valid_ct_extension(ext: &str) -> bool {
!ext.is_empty()
&& ext.trim() == ext
&& !ext
.chars()
.any(|c| !is_xml_legal_char(c) || c.is_ascii_whitespace())
&& !ext
.as_bytes()
.iter()
.any(|&b| matches!(b, b'/' | b'\\' | b'.'))
&& has_valid_percent_escapes(ext)
}
fn ct_record(e: &quick_xml::events::BytesStart<'_>, ct: &mut ContentTypes) -> Result<()> {
match local(e.name().as_ref()) {
b"Default" => {
let a = attrs_of(e)?;
validate_modeled_attrs(
&a,
&[&b"Extension"[..], &b"ContentType"[..]],
"[Content_Types].xml: <Default>",
)?;
match (find_attr(&a, b"Extension"), find_attr(&a, b"ContentType")) {
(Some(x), Some(c)) => {
if !valid_ct_extension(x) {
return Err(Error::Docx(format!(
"[Content_Types].xml: <Default> has an invalid Extension {x:?}"
)));
}
if !valid_media_type(c) {
return Err(Error::Docx(format!(
"[Content_Types].xml: <Default> has an invalid ContentType {c:?}"
)));
}
ct.defaults.push(CtDefault::new(x, c));
}
_ => {
return Err(Error::Docx(
"[Content_Types].xml: <Default> missing Extension/ContentType".into(),
))
}
}
}
b"Override" => {
let a = attrs_of(e)?;
validate_modeled_attrs(
&a,
&[&b"PartName"[..], &b"ContentType"[..]],
"[Content_Types].xml: <Override>",
)?;
match (find_attr(&a, b"PartName"), find_attr(&a, b"ContentType")) {
(Some(p), Some(c)) => {
if !valid_override_part_name(p) {
return Err(Error::Docx(format!(
"[Content_Types].xml: <Override> has an invalid PartName {p:?}"
)));
}
if !valid_media_type(c) {
return Err(Error::Docx(format!(
"[Content_Types].xml: <Override> has an invalid ContentType {c:?}"
)));
}
ct.overrides.push(CtOverride::new(p, c));
}
_ => {
return Err(Error::Docx(
"[Content_Types].xml: <Override> missing PartName/ContentType".into(),
))
}
}
}
_ => {}
}
Ok(())
}
fn valid_override_part_name(name: &str) -> bool {
if name.eq_ignore_ascii_case("/[Content_Types].xml") {
return false;
}
let Some(rest) = name.strip_prefix('/') else {
return false;
};
if rest.is_empty() || rest.len() > MAX_NAME_LEN {
return false;
}
rest.split('/').all(|seg| {
!seg.is_empty()
&& seg != "."
&& seg != ".."
&& !seg.as_bytes().contains(&b'\\')
&& has_valid_percent_escapes(seg)
})
}
fn ns_is(ns: &ResolveResult<'_>, want: &str) -> bool {
matches!(ns, ResolveResult::Bound(Namespace(uri)) if *uri == want.as_bytes())
}
fn validate_opc_root(
ns: &ResolveResult<'_>,
e: &quick_xml::events::BytesStart<'_>,
want_ns: &str,
want_local: &[u8],
part: &str,
saw_root: bool,
) -> Result<()> {
if saw_root {
return Err(Error::Docx(format!("{part}: multiple root elements")));
}
if !ns_is(ns, want_ns) || local(e.name().as_ref()) != want_local {
return Err(Error::Docx(format!(
"{part}: root is not <{}> in the expected namespace",
String::from_utf8_lossy(want_local)
)));
}
Ok(())
}
fn parse_content_types(xml: &[u8]) -> Result<ContentTypes> {
let mut r = NsReader::from_reader(xml);
r.config_mut().check_end_names = true; let mut ct = ContentTypes::default();
let mut buf = Vec::new();
let mut depth: i32 = 0;
let mut open_record_depth: Option<i32> = None;
let mut saw_root = false;
let mut saw_decl = false;
loop {
if ct.defaults.len() + ct.overrides.len() > max_meta_records() {
return Err(Error::Docx(
"[Content_Types].xml has too many entries".into(),
));
}
match r.read_resolved_event_into(&mut buf) {
Ok((ns, Event::Start(e))) => {
if open_record_depth.is_some_and(|d| depth >= d) {
return Err(Error::Docx(
"[Content_Types].xml: Default/Override must be empty".into(),
));
}
if depth == 0 {
validate_opc_root(&ns, &e, CT_NS, b"Types", "[Content_Types].xml", saw_root)?;
let attrs = attrs_of(&e)?;
validate_opc_root_attrs(&attrs, CT_NS, "[Content_Types].xml")?;
saw_root = true;
} else if depth == 1 {
ct_child(&ns, &e, &mut ct)?;
if ns_is(&ns, CT_NS) && is_ct_record_name(e.name().as_ref()) {
open_record_depth = Some(depth + 1);
}
} else if is_ct_record_name(e.name().as_ref()) {
return Err(Error::Docx(
"[Content_Types].xml: Default/Override is not a direct child of Types"
.into(),
));
}
depth += 1;
}
Ok((ns, Event::Empty(e))) => {
if open_record_depth.is_some_and(|d| depth >= d) {
return Err(Error::Docx(
"[Content_Types].xml: Default/Override must be empty".into(),
));
}
if depth == 0 {
validate_opc_root(&ns, &e, CT_NS, b"Types", "[Content_Types].xml", saw_root)?;
let attrs = attrs_of(&e)?;
validate_opc_root_attrs(&attrs, CT_NS, "[Content_Types].xml")?;
saw_root = true;
} else if depth == 1 {
ct_child(&ns, &e, &mut ct)?;
} else if is_ct_record_name(e.name().as_ref()) {
return Err(Error::Docx(
"[Content_Types].xml: Default/Override is not a direct child of Types"
.into(),
));
}
}
Ok((_, Event::End(_))) => {
depth -= 1;
if open_record_depth.is_some_and(|d| depth < d) {
open_record_depth = None;
}
}
Ok((_, Event::Text(t))) => {
if open_record_depth.is_some_and(|d| depth >= d) && !t.as_ref().is_empty() {
return Err(Error::Docx(
"[Content_Types].xml: Default/Override must be empty".into(),
));
}
if !is_xml_whitespace(t.as_ref()) {
return Err(Error::Docx(
"[Content_Types].xml: non-whitespace text outside metadata records".into(),
));
}
}
Ok((_, Event::Comment(_))) | Ok((_, Event::PI(_)))
if open_record_depth.is_some_and(|d| depth >= d) =>
{
return Err(Error::Docx(
"[Content_Types].xml: Default/Override must be empty".into(),
));
}
Ok((_, Event::Decl(_))) => {
if depth != 0 || saw_root || saw_decl {
return Err(Error::Docx(
"[Content_Types].xml: XML declaration is only allowed before the root"
.into(),
));
}
saw_decl = true;
}
Ok((_, Event::CData(_))) => {
return Err(Error::Docx(
"[Content_Types].xml: character data outside metadata records".into(),
));
}
Ok((_, Event::DocType(_))) => {
return Err(Error::Docx(
"[Content_Types].xml: doctype is not allowed".into(),
));
}
Ok((_, Event::Eof)) => {
if depth != 0 {
return Err(Error::Docx("[Content_Types].xml: unclosed element".into()));
}
break;
}
Err(e) => return Err(Error::Docx(format!("[Content_Types].xml parse: {e}"))),
_ => {}
}
buf.clear();
}
if !saw_root {
return Err(Error::Docx(
"[Content_Types].xml: no <Types> root element".into(),
));
}
dedup_identical_defaults(&mut ct.defaults);
dedup_identical_overrides(&mut ct.overrides);
if has_default_conflict(&ct.defaults) || has_override_conflict(&ct.overrides) {
return Err(Error::Docx(
"[Content_Types].xml has conflicting duplicate records".into(),
));
}
match ct
.defaults
.iter()
.find(|d| d.extension.eq_ignore_ascii_case("rels"))
{
Some(d) if d.content_type == CT_RELS => {}
Some(_) => {
return Err(Error::Docx(
"[Content_Types].xml: `rels` Default has the wrong content type".into(),
));
}
None => {
ct.defaults.push(CtDefault::new("rels", CT_RELS));
ct.rels_default_injected = true;
}
}
Ok(ct)
}
fn dedup_identical_defaults(defaults: &mut Vec<CtDefault>) {
let mut seen: HashSet<CtRecordIdentity> = HashSet::new();
defaults.retain(|d| seen.insert((d.extension.to_ascii_lowercase(), d.content_type.clone())));
}
fn dedup_identical_overrides(overrides: &mut Vec<CtOverride>) {
let mut seen: HashSet<CtRecordIdentity> = HashSet::new();
overrides.retain(|o| seen.insert((o.part_name.to_ascii_lowercase(), o.content_type.clone())));
}
fn has_content_type_conflict<I>(items: I) -> bool
where
I: IntoIterator<Item = (String, String)>,
{
let mut seen: HashMap<String, String> = HashMap::new();
for (key, value) in items {
if let Some(prev) = seen.insert(key, value.clone()) {
if prev != value {
return true;
}
}
}
false
}
fn has_default_conflict(defaults: &[CtDefault]) -> bool {
has_content_type_conflict(
defaults
.iter()
.map(|d| (d.extension.to_ascii_lowercase(), d.content_type.clone())),
)
}
fn has_override_conflict(overrides: &[CtOverride]) -> bool {
has_content_type_conflict(
overrides
.iter()
.map(|o| (o.part_name.to_ascii_lowercase(), o.content_type.clone())),
)
}
fn is_xml_ncname_start(c: char) -> bool {
let u = c as u32;
c == '_'
|| matches!(
u,
0x41..=0x5A
| 0x61..=0x7A
| 0xC0..=0xD6
| 0xD8..=0xF6
| 0xF8..=0x2FF
| 0x370..=0x37D
| 0x37F..=0x1FFF
| 0x200C..=0x200D
| 0x2070..=0x218F
| 0x2C00..=0x2FEF
| 0x3001..=0xD7FF
| 0xF900..=0xFDCF
| 0xFDF0..=0xFFFD
| 0x10000..=0xEFFFF
)
}
fn is_xml_ncname_char(c: char) -> bool {
let u = c as u32;
is_xml_ncname_start(c)
|| c == '-'
|| c == '.'
|| matches!(u, 0x30..=0x39 | 0xB7 | 0x0300..=0x036F | 0x203F..=0x2040)
}
fn valid_rel_id(id: &str) -> bool {
let mut chars = id.chars();
let Some(first) = chars.next() else {
return false;
};
is_xml_ncname_start(first) && chars.all(is_xml_ncname_char)
}
fn rel_record(e: &quick_xml::events::BytesStart<'_>, out: &mut Vec<Rel>) -> Result<()> {
if local(e.name().as_ref()) == b"Relationship" {
let a = attrs_of(e)?;
validate_modeled_attrs(
&a,
&[&b"Id"[..], &b"Type"[..], &b"Target"[..], &b"TargetMode"[..]],
".rels: <Relationship>",
)?;
match (
find_attr(&a, b"Id"),
find_attr(&a, b"Type"),
find_attr(&a, b"Target"),
) {
(Some(id), Some(rel_type), Some(target)) => {
if !valid_rel_id(id) {
return Err(Error::Docx(format!(
".rels: <Relationship> has an invalid Id {id:?}"
)));
}
if rel_type.trim().is_empty() {
return Err(Error::Docx(
".rels: <Relationship> has an empty Type".into(),
));
}
if target.is_empty() {
return Err(Error::Docx(
".rels: <Relationship> has an empty Target".into(),
));
}
let external = match find_attr(&a, b"TargetMode") {
None | Some("Internal") => false,
Some("External") => true,
Some(other) => {
return Err(Error::Docx(format!(
".rels: <Relationship> has an invalid TargetMode {other:?}"
)))
}
};
out.push(Rel {
id: id.to_string(),
rel_type: rel_type.to_string(),
target: target.to_string(),
external,
});
}
_ => {
return Err(Error::Docx(
".rels: <Relationship> missing Id/Type/Target".into(),
))
}
}
}
Ok(())
}
fn parse_rels(xml: &[u8]) -> Result<Vec<Rel>> {
let mut r = NsReader::from_reader(xml);
r.config_mut().check_end_names = true;
let mut out = Vec::new();
let mut buf = Vec::new();
let mut depth: i32 = 0;
let mut open_record_depth: Option<i32> = None;
let mut saw_root = false;
let mut saw_decl = false;
loop {
if out.len() > max_meta_records() {
return Err(Error::Docx(
"a .rels part has too many relationships".into(),
));
}
match r.read_resolved_event_into(&mut buf) {
Ok((ns, Event::Start(e))) => {
if open_record_depth.is_some_and(|d| depth >= d) {
return Err(Error::Docx(".rels: Relationship must be empty".into()));
}
if depth == 0 {
validate_opc_root(&ns, &e, REL_NS, b"Relationships", ".rels", saw_root)?;
let attrs = attrs_of(&e)?;
validate_opc_root_attrs(&attrs, REL_NS, ".rels")?;
saw_root = true;
} else if depth == 1 {
rel_child(&ns, &e, &mut out)?;
if ns_is(&ns, REL_NS) && local(e.name().as_ref()) == b"Relationship" {
open_record_depth = Some(depth + 1);
}
} else if local(e.name().as_ref()) == b"Relationship" {
return Err(Error::Docx(
".rels: Relationship is not a direct child of Relationships".into(),
));
}
depth += 1;
}
Ok((ns, Event::Empty(e))) => {
if open_record_depth.is_some_and(|d| depth >= d) {
return Err(Error::Docx(".rels: Relationship must be empty".into()));
}
if depth == 0 {
validate_opc_root(&ns, &e, REL_NS, b"Relationships", ".rels", saw_root)?;
let attrs = attrs_of(&e)?;
validate_opc_root_attrs(&attrs, REL_NS, ".rels")?;
saw_root = true;
} else if depth == 1 {
rel_child(&ns, &e, &mut out)?;
} else if local(e.name().as_ref()) == b"Relationship" {
return Err(Error::Docx(
".rels: Relationship is not a direct child of Relationships".into(),
));
}
}
Ok((_, Event::End(_))) => {
depth -= 1;
if open_record_depth.is_some_and(|d| depth < d) {
open_record_depth = None;
}
}
Ok((_, Event::Text(t))) => {
if open_record_depth.is_some_and(|d| depth >= d) && !t.as_ref().is_empty() {
return Err(Error::Docx(".rels: Relationship must be empty".into()));
}
if !is_xml_whitespace(t.as_ref()) {
return Err(Error::Docx(
".rels: non-whitespace text outside relationship records".into(),
));
}
}
Ok((_, Event::Comment(_))) | Ok((_, Event::PI(_)))
if open_record_depth.is_some_and(|d| depth >= d) =>
{
return Err(Error::Docx(".rels: Relationship must be empty".into()));
}
Ok((_, Event::Decl(_))) => {
if depth != 0 || saw_root || saw_decl {
return Err(Error::Docx(
".rels: XML declaration is only allowed before the root".into(),
));
}
saw_decl = true;
}
Ok((_, Event::CData(_))) => {
return Err(Error::Docx(
".rels: character data outside relationship records".into(),
));
}
Ok((_, Event::DocType(_))) => {
return Err(Error::Docx(".rels: doctype is not allowed".into()));
}
Ok((_, Event::Eof)) => {
if depth != 0 {
return Err(Error::Docx(".rels: unclosed element".into()));
}
break;
}
Err(e) => return Err(Error::Docx(format!(".rels parse: {e}"))),
_ => {}
}
buf.clear();
}
if !saw_root {
return Err(Error::Docx(".rels: no <Relationships> root element".into()));
}
let mut ids: HashSet<&str> = HashSet::with_capacity(out.len());
for r in &out {
if !ids.insert(r.id.as_str()) {
return Err(Error::Docx(".rels has duplicate relationship Id".into()));
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn oversized_opc_metadata_is_rejected() {
set_test_max_meta(4);
let mut rels = String::from(
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#,
);
for i in 0..6 {
rels.push_str(&format!(
r#"<Relationship Id="rId{i}" Type="t" Target="x"/>"#
));
}
rels.push_str("</Relationships>");
let rels_res = parse_rels(rels.as_bytes());
let mut ct = String::from(
r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">"#,
);
for i in 0..6 {
ct.push_str(&format!(r#"<Override PartName="/p{i}" ContentType="x"/>"#));
}
ct.push_str("</Types>");
let ct_res = parse_content_types(ct.as_bytes());
let mut exact = String::from(
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#,
);
for i in 0..4 {
exact.push_str(&format!(
r#"<Relationship Id="rId{i}" Type="t" Target="x"/>"#
));
}
exact.push_str("</Relationships>");
let exact_res = parse_rels(exact.as_bytes());
set_test_max_meta(MAX_META_RECORDS);
assert!(rels_res.is_err(), "oversized .rels not rejected");
assert!(ct_res.is_err(), "oversized [Content_Types] not rejected");
assert_eq!(
exact_res.map(|v| v.len()).ok(),
Some(4),
"exactly-the-cap records must parse"
);
}
fn sample_docx() -> Vec<u8> {
use crate::model::{Block, DocModel, ParaProps, Paragraph, Run};
let model = DocModel {
blocks: vec![Block::Paragraph(Paragraph {
props: ParaProps {
heading_level: Some(1),
..ParaProps::default()
},
runs: vec![Run {
text: "본문 hello".to_string(),
..Run::default()
}],
})],
..DocModel::default()
};
crate::write_docx(&model)
}
fn part_set(bytes: &[u8]) -> std::collections::BTreeMap<String, Vec<u8>> {
let mut z = ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut m = std::collections::BTreeMap::new();
for i in 0..z.len() {
let mut f = z.by_index(i).unwrap();
let n = f.name().to_string();
let mut b = Vec::new();
f.read_to_end(&mut b).unwrap();
m.insert(n, b);
}
m
}
#[test]
fn roundtrip_preserves_every_part_byte_for_byte() {
let orig = sample_docx();
let pkg = Package::from_zip(&orig).expect("open");
let out = pkg.to_zip().expect("save");
let a = part_set(&orig);
let b = part_set(&out);
assert_eq!(
a.keys().collect::<Vec<_>>(),
b.keys().collect::<Vec<_>>(),
"part set changed"
);
for (name, abytes) in &a {
assert_eq!(
abytes, &b[name],
"part {name} not byte-stable on no-op round-trip"
);
}
}
#[test]
fn parses_content_types_and_rels_and_seeds_allocator() {
let pkg = Package::from_zip(&sample_docx()).unwrap();
assert!(pkg.part("word/document.xml").is_some());
assert!(pkg.part("[Content_Types].xml").is_some());
assert!(
!pkg.rels_for("").is_empty() || !pkg.rels_for("word/document.xml").is_empty(),
"no rels parsed"
);
let mut pkg = pkg;
let existing: std::collections::HashSet<String> = pkg
.rels
.values()
.flat_map(|v| v.iter().map(|r| r.id.clone()))
.collect();
for _ in 0..5 {
let id = pkg.alloc_rid();
assert!(!existing.contains(&id), "alloc collided with {id}");
}
}
#[test]
fn set_part_reuses_matching_default_and_survives_roundtrip() {
let mut pkg = Package::from_zip(&sample_docx()).unwrap();
pkg.set_part("word/custom.xml", b"<x/>".to_vec(), Some("application/xml"));
let out = pkg.to_zip().unwrap();
let reopened = Package::from_zip(&out).unwrap();
assert_eq!(
reopened.part("word/custom.xml").as_deref(),
Some(&b"<x/>"[..])
);
assert!(reopened.part_has_content_type("word/custom.xml"));
assert!(
!reopened
.part("[Content_Types].xml")
.map(|b| String::from_utf8_lossy(&b).contains("/word/custom.xml"))
.unwrap_or(false),
"matching Default should avoid a redundant Override"
);
}
#[test]
fn add_related_part_allocates_rid_and_rels() {
let mut pkg = Package::from_zip(&sample_docx()).unwrap();
let before = pkg.rels_for("word/document.xml").len();
let rid = pkg.add_related_part(
"word/document.xml",
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",
"word/media/imageZ.png",
Some("image/png"),
vec![1, 2, 3],
);
assert!(rid.starts_with("rId"));
let after = pkg.rels_for("word/document.xml");
assert_eq!(after.len(), before + 1);
assert!(after
.iter()
.any(|r| r.id == rid && r.target == "media/imageZ.png"));
}
#[test]
fn garbage_bytes_error_not_panic() {
assert!(Package::from_zip(&[1, 2, 3, 4]).is_err());
assert!(Package::from_zip(b"PK\x03\x04not a zip").is_err());
}
#[test]
fn malformed_metadata_attributes_rejected() {
let dup_ct = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" Extension="xml" ContentType="application/xml"/></Types>"#;
assert!(parse_content_types(dup_ct.as_bytes()).is_err());
let dup_rel = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="r1" Id="r1" Type="t" Target="x"/></Relationships>"#;
assert!(parse_rels(dup_rel.as_bytes()).is_err());
assert!(parse_content_types(
br#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/></Types>"#
)
.is_ok());
assert!(parse_rels(
br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="r1" Type="t" Target="x"/></Relationships>"#
)
.is_ok());
}
#[test]
fn metadata_record_elements_must_be_empty() {
let ct_child = br#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="/word/document.xml" ContentType="application/xml"><Foreign/></Override></Types>"#;
assert!(
parse_content_types(ct_child).is_err(),
"non-empty Override must make content types read-only"
);
let rel_child = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="t" Target="x"><Foreign/></Relationship></Relationships>"#;
assert!(
parse_rels(rel_child).is_err(),
"non-empty Relationship must make rels read-only"
);
let ct_decl = br#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"><?xml version="1.0"?></Default></Types>"#;
assert!(
parse_content_types(ct_decl).is_err(),
"XML declaration inside Default must make content types read-only"
);
let rel_decl = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="t" Target="x"><?xml version="1.0"?></Relationship></Relationships>"#;
assert!(
parse_rels(rel_decl).is_err(),
"XML declaration inside Relationship must make rels read-only"
);
assert!(parse_content_types(
br#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="/word/document.xml" ContentType="application/xml"/></Types>"#
)
.is_ok());
assert!(parse_rels(
br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="t" Target="x"/></Relationships>"#
)
.is_ok());
}
fn zip_crc32(data: &[u8]) -> u32 {
let mut crc = 0xFFFF_FFFFu32;
for &b in data {
crc ^= u32::from(b);
for _ in 0..8 {
crc = (crc >> 1) ^ (0xEDB8_8320 & (!(crc & 1)).wrapping_add(1));
}
}
!crc
}
fn build_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
let (mut local, mut cd, mut offs) = (Vec::new(), Vec::new(), Vec::new());
for (name, data) in entries {
offs.push(local.len() as u32);
let crc = zip_crc32(data);
local.extend_from_slice(&[0x50, 0x4b, 0x03, 0x04]);
local.extend_from_slice(&20u16.to_le_bytes());
local.extend_from_slice(&[0u8; 8]); local.extend_from_slice(&crc.to_le_bytes());
local.extend_from_slice(&(data.len() as u32).to_le_bytes());
local.extend_from_slice(&(data.len() as u32).to_le_bytes());
local.extend_from_slice(&(name.len() as u16).to_le_bytes());
local.extend_from_slice(&0u16.to_le_bytes());
local.extend_from_slice(name.as_bytes());
local.extend_from_slice(data);
}
let cd_off = local.len() as u32;
for ((name, data), off) in entries.iter().zip(&offs) {
cd.extend_from_slice(&[0x50, 0x4b, 0x01, 0x02]);
cd.extend_from_slice(&20u16.to_le_bytes());
cd.extend_from_slice(&20u16.to_le_bytes());
cd.extend_from_slice(&[0u8; 8]); cd.extend_from_slice(&zip_crc32(data).to_le_bytes());
cd.extend_from_slice(&(data.len() as u32).to_le_bytes());
cd.extend_from_slice(&(data.len() as u32).to_le_bytes());
cd.extend_from_slice(&(name.len() as u16).to_le_bytes());
cd.extend_from_slice(&[0u8; 8]); cd.extend_from_slice(&0u32.to_le_bytes()); cd.extend_from_slice(&off.to_le_bytes());
cd.extend_from_slice(name.as_bytes());
}
let (cd_size, n) = (cd.len() as u32, entries.len() as u16);
let mut out = local;
out.extend_from_slice(&cd);
out.extend_from_slice(&[0x50, 0x4b, 0x05, 0x06]);
out.extend_from_slice(&[0u8; 4]); out.extend_from_slice(&n.to_le_bytes());
out.extend_from_slice(&n.to_le_bytes());
out.extend_from_slice(&cd_size.to_le_bytes());
out.extend_from_slice(&cd_off.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes()); out
}
#[test]
fn duplicate_part_names_collapse_deterministically() {
let ct = br#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/></Types>"#;
let zip = build_zip(&[
("[Content_Types].xml", ct),
("word/document.xml", b"FIRST"),
("word/document.xml", b"SECOND"),
]);
let pkg = Package::from_zip(&zip).unwrap();
assert!(!pkg.is_meta_lossy());
let kept = pkg.part("word/document.xml").unwrap();
assert_eq!(
kept, b"SECOND",
"duplicate name should resolve to the last value"
);
let out = pkg.to_zip().unwrap();
let parts = part_set(&out);
assert_eq!(
parts.get("word/document.xml").map(|v| v.as_slice()),
Some(kept.as_slice()),
"re-emit must keep exactly the opened occurrence"
);
let mut z = ZipArchive::new(std::io::Cursor::new(out)).unwrap();
let count = (0..z.len())
.filter(|&i| z.by_index(i).unwrap().name() == "word/document.xml")
.count();
assert_eq!(count, 1, "duplicate part re-emitted more than once");
}
#[test]
fn conflicting_or_duplicate_metadata_rejected() {
const CT: &str = "http://schemas.openxmlformats.org/package/2006/content-types";
const RL: &str = "http://schemas.openxmlformats.org/package/2006/relationships";
assert!(parse_content_types(
format!(r#"<Types xmlns="{CT}"><Default Extension="png" ContentType="image/png"/><Default Extension="png" ContentType="image/jpeg"/></Types>"#).as_bytes()
).is_err());
assert!(parse_content_types(
format!(r#"<Types xmlns="{CT}"><Override PartName="/a.xml" ContentType="application/xml"/><Override PartName="/a.xml" ContentType="text/xml"/></Types>"#).as_bytes()
).is_err());
assert!(parse_content_types(
format!(r#"<Types xmlns="{CT}"><Default Extension="rels" ContentType="text/plain"/></Types>"#).as_bytes()
).is_err());
assert!(parse_rels(
format!(r#"<Relationships xmlns="{RL}"><Relationship Id="rId1" Type="t" Target="a"/><Relationship Id="rId1" Type="t" Target="b"/></Relationships>"#).as_bytes()
).is_err());
assert!(parse_content_types(
format!(r#"<Types xmlns="{CT}"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="png" ContentType="image/png"/><Default Extension="png" ContentType="image/png"/></Types>"#).as_bytes()
).is_ok());
}
#[test]
fn content_type_part_and_extension_identity_is_ascii_case_insensitive() {
const MAIN: &str =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml";
let ct = format!(
r#"<Types xmlns="{CT_NS}"><Default Extension="rels" ContentType="{CT_RELS}"/><Override PartName="/word/Document.xml" ContentType="{MAIN}"/></Types>"#
);
let zip = build_zip(&[
(CONTENT_TYPES, ct.as_bytes()),
("word/document.xml", br#"<w:document/>"#),
("word/media/Image1.png", b"png"),
]);
let mut pkg = Package::from_zip(&zip).unwrap();
pkg.ensure_content_type("word/document.xml", MAIN);
let out = String::from_utf8(pkg.part(CONTENT_TYPES).unwrap()).unwrap();
assert_eq!(
out.to_ascii_lowercase()
.matches(r#"partname="/word/document.xml""#)
.count(),
1,
"case-variant Override was duplicated: {out}"
);
assert!(
pkg.has_part("word/media/image1.png"),
"case-variant media part should collide"
);
let mut pkg = Package::from_zip(&zip).unwrap();
pkg.set_part(
"word/document.xml",
b"<w:document/>".to_vec(),
Some("application/xml"),
);
let out = String::from_utf8(pkg.part(CONTENT_TYPES).unwrap()).unwrap();
assert_eq!(
out.to_ascii_lowercase()
.matches(r#"partname="/word/document.xml""#)
.count(),
1,
"set_part duplicated a case-variant Override: {out}"
);
assert!(
out.contains(r#"ContentType="application/xml""#),
"set_part did not update the existing Override: {out}"
);
let conflicting = format!(
r#"<Types xmlns="{CT_NS}"><Default Extension="rels" ContentType="{CT_RELS}"/><Override PartName="/word/Document.xml" ContentType="{MAIN}"/><Override PartName="/word/document.xml" ContentType="application/xml"/></Types>"#
);
assert!(
parse_content_types(conflicting.as_bytes()).is_err(),
"case-variant conflicting Overrides must be read-only metadata"
);
}
#[test]
fn identical_default_extensions_dedup_case_insensitively() {
let ct = format!(
r#"<Types xmlns="{CT_NS}"><Default Extension="rels" ContentType="{CT_RELS}"/><Default Extension="PNG" ContentType="image/png"/><Default Extension="png" ContentType="image/png"/></Types>"#
);
let parsed = parse_content_types(ct.as_bytes()).unwrap();
assert_eq!(
parsed
.defaults
.iter()
.filter(|d| d.extension.eq_ignore_ascii_case("png") && d.content_type == "image/png")
.count(),
1,
"case-variant identical Defaults must collapse"
);
}
#[test]
fn metadata_records_must_be_direct_children() {
let nested_ct = format!(
r#"<Types xmlns="{CT_NS}"><Default Extension="rels" ContentType="{CT_RELS}"/><wrap><Override PartName="/word/document.xml" ContentType="application/xml"/></wrap></Types>"#
);
assert!(
parse_content_types(nested_ct.as_bytes()).is_err(),
"nested Override must not be folded as a real content type"
);
let nested_rel = format!(
r#"<Relationships xmlns="{REL_NS}"><wrap><Relationship Id="rId1" Type="t" Target="x"/></wrap></Relationships>"#
);
assert!(
parse_rels(nested_rel.as_bytes()).is_err(),
"nested Relationship must not be folded as a real relationship"
);
let foreign = format!(
r#"<Types xmlns="{CT_NS}"><Default Extension="rels" ContentType="{CT_RELS}"/><x:wrap xmlns:x="urn:x"><x:Future Value="ok"/></x:wrap></Types>"#
);
assert!(
parse_content_types(foreign.as_bytes()).is_ok(),
"foreign non-record extension elements remain forward-compatible"
);
}
#[test]
fn metadata_attribute_cap_uses_test_budget() {
set_test_max_meta(2);
let ct = format!(
r#"<Types xmlns="{CT_NS}"><Default Extension="rels" ContentType="{CT_RELS}" Extra="x"/></Types>"#
);
let res = parse_content_types(ct.as_bytes());
set_test_max_meta(MAX_META_RECORDS);
assert!(res.is_err(), "metadata attributes ignored the lowered cap");
}
#[test]
fn to_zip_rechecks_regenerated_metadata_record_cap() {
let ct = format!(
r#"<Types xmlns="{CT_NS}"><Default Extension="rels" ContentType="{CT_RELS}"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/xml"/></Types>"#
);
set_test_max_meta(3);
let zip = build_zip(&[
(CONTENT_TYPES, ct.as_bytes()),
("word/document.xml", b"<w:document/>"),
]);
let mut pkg = Package::from_zip(&zip).unwrap();
pkg.set_part("word/media/image1.png", b"png".to_vec(), Some("image/png"));
let ct_res = pkg.to_zip();
set_test_max_meta(MAX_META_RECORDS);
assert!(
ct_res.is_err(),
"save emitted [Content_Types].xml past the reopen cap"
);
let mut rels = format!(r#"<Relationships xmlns="{REL_NS}">"#);
for i in 0..10 {
rels.push_str(&format!(
r#"<Relationship Id="rId{i}" Type="t" Target="x{i}"/>"#
));
}
rels.push_str("</Relationships>");
let ct = format!(
r#"<Types xmlns="{CT_NS}"><Default Extension="rels" ContentType="{CT_RELS}"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/xml"/></Types>"#
);
set_test_max_meta(10);
let zip = build_zip(&[
(CONTENT_TYPES, ct.as_bytes()),
("word/document.xml", b"<w:document/>"),
("word/_rels/document.xml.rels", rels.as_bytes()),
]);
let mut pkg = Package::from_zip(&zip).unwrap();
pkg.add_related_part(
"word/document.xml",
"urn:image",
"word/media/image1.png",
None,
b"png".to_vec(),
);
let rels_res = pkg.to_zip();
set_test_max_meta(MAX_META_RECORDS);
assert!(rels_res.is_err(), "save emitted .rels past the reopen cap");
}
#[test]
fn to_zip_rejects_touched_internal_relationship_to_missing_part() {
let ct = format!(
r#"<Types xmlns="{CT_NS}"><Default Extension="rels" ContentType="{CT_RELS}"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/xml"/></Types>"#
);
let zip = build_zip(&[
(CONTENT_TYPES, ct.as_bytes()),
("word/document.xml", b"<w:document/>"),
]);
let mut pkg = Package::from_zip(&zip).unwrap();
pkg.ensure_relationship("word/document.xml", "urn:missing", "word/missing.xml");
let save = pkg.to_zip();
assert!(
save.is_err(),
"save emitted a touched .rels part targeting a missing package part"
);
}
#[test]
fn to_zip_rejects_touched_root_relationship_to_missing_part() {
let ct = format!(
r#"<Types xmlns="{CT_NS}"><Default Extension="rels" ContentType="{CT_RELS}"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/xml"/></Types>"#
);
let zip = build_zip(&[
(CONTENT_TYPES, ct.as_bytes()),
("word/document.xml", b"<w:document/>"),
]);
let mut pkg = Package::from_zip(&zip).unwrap();
pkg.ensure_relationship("", "urn:missing-root", "docProps/missing.xml");
let save = pkg.to_zip();
assert!(
save.is_err(),
"save emitted a touched root .rels part targeting a missing package part"
);
}
#[test]
fn missing_rels_default_typed_via_regen_on_edit() {
const CT: &str = "http://schemas.openxmlformats.org/package/2006/content-types";
let ct = format!(
r#"<Types xmlns="{CT}"><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/media/image1.png" ContentType="image/png"/></Types>"#
);
let zip = build_zip(&[
("[Content_Types].xml", ct.as_bytes()),
("word/document.xml", b"<w:document/>"),
]);
let mut pkg = Package::from_zip(&zip).expect("missing rels Default is injected, not fatal");
assert!(!pkg.is_meta_lossy());
pkg.add_related_part(
"word/document.xml",
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",
"word/media/image1.png",
Some("image/png"),
b"PNGDATA".to_vec(),
);
let out = pkg.to_zip().unwrap();
let reopened = Package::from_zip(&out).unwrap();
let ct_out = reopened.part(CONTENT_TYPES).unwrap();
assert!(
String::from_utf8_lossy(&ct_out).contains(r#"Extension="rels""#),
"edit writing a .rels must regenerate [Content_Types].xml with the rels Default"
);
assert!(reopened.part("word/_rels/document.xml.rels").is_some());
assert!(reopened.part_has_content_type("word/_rels/document.xml.rels"));
}
#[test]
fn wrong_namespace_metadata_is_read_only_not_regenerated() {
let ct = r#"<Types><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/></Types>"#;
let zip = build_zip(&[
("[Content_Types].xml", ct.as_bytes()),
("word/document.xml", b"<w:document/>"),
]);
let pkg = Package::from_zip(&zip).expect("malformed metadata must still open (lenient)");
assert!(
pkg.is_meta_lossy(),
"wrong-namespace [Content_Types].xml must be read-only, not an editable empty graph"
);
let out = pkg.to_zip().unwrap();
let reopened_ct = Package::from_zip(&out)
.unwrap()
.part(CONTENT_TYPES)
.unwrap();
assert_eq!(
reopened_ct,
ct.as_bytes(),
"no-op save must preserve the raw [Content_Types].xml, never regenerate it"
);
}
#[test]
fn identical_duplicate_overrides_deduped_so_edit_does_not_strand_one() {
const CT: &str = "http://schemas.openxmlformats.org/package/2006/content-types";
const RELS: &str = "application/vnd.openxmlformats-package.relationships+xml";
const WML_MAIN: &str =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml";
let ct = format!(
r#"<Types xmlns="{CT}"><Default Extension="rels" ContentType="{RELS}"/><Override PartName="/word/document.xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/xml"/></Types>"#
);
let zip = build_zip(&[
("[Content_Types].xml", ct.as_bytes()),
("word/document.xml", b"<w:document/>"),
]);
let mut pkg = Package::from_zip(&zip).expect("identical duplicates are not a conflict");
assert!(!pkg.is_meta_lossy());
pkg.ensure_content_type("word/document.xml", WML_MAIN);
let out = pkg.to_zip().unwrap();
let reopened =
Package::from_zip(&out).expect("saved package must reopen (no conflicting overrides)");
assert!(
!reopened.is_meta_lossy(),
"stranded a duplicate override → saved [Content_Types].xml is self-conflicting"
);
let ct_out = String::from_utf8_lossy(&reopened.part(CONTENT_TYPES).unwrap()).into_owned();
assert_eq!(
ct_out.matches(r#"PartName="/word/document.xml""#).count(),
1,
"exactly one override should remain for the part"
);
assert!(
ct_out.contains(WML_MAIN),
"the override must carry the repaired type"
);
}
#[test]
fn malformed_opc_metadata_shape_rejected() {
const CT: &str = "http://schemas.openxmlformats.org/package/2006/content-types";
const RL: &str = "http://schemas.openxmlformats.org/package/2006/relationships";
assert!(parse_content_types(
br#"<Types><Default Extension="rels" ContentType="x"/></Types>"#
)
.is_err());
assert!(parse_rels(
br#"<Relationships><Relationship Id="r" Type="t" Target="x"/></Relationships>"#
)
.is_err());
assert!(parse_content_types(format!(r#"<NotTypes xmlns="{CT}"/>"#).as_bytes()).is_err());
assert!(parse_rels(format!(r#"<NotRels xmlns="{RL}"/>"#).as_bytes()).is_err());
assert!(parse_content_types(
format!(r#"<Types xmlns="{CT}"><Default Extension="rels"/></Types>"#).as_bytes()
)
.is_err());
assert!(parse_content_types(
format!(
r#"<Types xmlns="{CT}"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="/a"/></Types>"#
)
.as_bytes()
)
.is_err());
for bad_part_name in [
"",
"word/document.xml",
"/",
"/word//document.xml",
"/word/../document.xml",
] {
assert!(
parse_content_types(
format!(
r#"<Types xmlns="{CT}"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="{bad_part_name}" ContentType="application/xml"/></Types>"#
)
.as_bytes()
)
.is_err(),
"malformed Override PartName {bad_part_name:?} must be rejected"
);
}
assert!(parse_rels(
format!(
r#"<Relationships xmlns="{RL}"><Relationship Id="r" Type="t"/></Relationships>"#
)
.as_bytes()
)
.is_err());
assert!(parse_content_types(br#"<?xml version="1.0"?>"#).is_err());
assert!(parse_rels(br#"<?xml version="1.0"?>"#).is_err());
assert!(parse_content_types(
format!(r#"<Types xmlns="{CT}"/><Types xmlns="{CT}"/>"#).as_bytes()
)
.is_err());
assert!(parse_rels(
format!(r#"<Relationships xmlns="{RL}"/><Relationships xmlns="{RL}"/>"#).as_bytes()
)
.is_err());
for ct in [
format!(r#"junk<Types xmlns="{CT}"/>"#),
format!(r#"<Types xmlns="{CT}"/>junk"#),
format!(r#"<![CDATA[junk]]><Types xmlns="{CT}"/>"#),
format!(r#"<!DOCTYPE Types><Types xmlns="{CT}"/>"#),
] {
assert!(
parse_content_types(ct.as_bytes()).is_err(),
"malformed top-level content type metadata was accepted: {ct}"
);
}
for rels in [
format!(r#"junk<Relationships xmlns="{RL}"/>"#),
format!(r#"<Relationships xmlns="{RL}"/>junk"#),
format!(r#"<![CDATA[junk]]><Relationships xmlns="{RL}"/>"#),
format!(r#"<!DOCTYPE Relationships><Relationships xmlns="{RL}"/>"#),
] {
assert!(
parse_rels(rels.as_bytes()).is_err(),
"malformed top-level relationship metadata was accepted: {rels}"
);
}
assert!(
parse_content_types(
format!(
"\n<Types xmlns=\"{CT}\"><Default Extension=\"rels\" ContentType=\"{CT_RELS}\"/></Types>\n"
)
.as_bytes()
)
.is_ok(),
"top-level whitespace around metadata root must remain accepted"
);
assert!(
parse_rels(format!("\n<Relationships xmlns=\"{RL}\"/>\n").as_bytes()).is_ok(),
"top-level whitespace around rels root must remain accepted"
);
assert!(parse_content_types(
format!(r#"<Types xmlns="{CT}"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override xmlns="" PartName="/a.xml" ContentType="application/xml"/></Types>"#).as_bytes()
)
.is_err());
assert!(parse_rels(
format!(r#"<Relationships xmlns="{RL}"><Relationship xmlns="" Id="r" Type="t" Target="x"/></Relationships>"#).as_bytes()
)
.is_err());
assert!(parse_rels(
format!(r#"<Relationships xmlns="{RL}"><Relationship Id="r" Type="t" Target="http://x" TargetMode="external"/></Relationships>"#).as_bytes()
)
.is_err());
assert!(parse_rels(
format!(r#"<Relationships xmlns="{RL}"><Relationship Id="r" Type="t" Target="http://x" TargetMode="External"/></Relationships>"#).as_bytes()
)
.is_ok());
assert!(parse_rels(
format!(r#"<Relationships xmlns="{RL}"><Relationship Id="r" Type="t" Target="x" TargetMode="Internal"/></Relationships>"#).as_bytes()
)
.is_ok());
assert!(parse_content_types(
format!(r#"<Types xmlns="{CT}"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/></Types>"#).as_bytes()
)
.is_ok());
assert!(parse_rels(format!(r#"<Relationships xmlns="{RL}"/>"#).as_bytes()).is_ok());
}
#[test]
fn malformed_content_type_values_are_read_only() {
const CT: &str = "http://schemas.openxmlformats.org/package/2006/content-types";
const RELS_DEFAULT: &str = r#"<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>"#;
for bad in [
"",
" ",
"garbage",
"application/",
"/xml",
"application/x/y",
] {
assert!(
parse_content_types(
format!(
r#"<Types xmlns="{CT}">{RELS_DEFAULT}<Override PartName="/word/document.xml" ContentType="{bad}"/></Types>"#
)
.as_bytes()
)
.is_err(),
"malformed Override ContentType {bad:?} must be rejected"
);
assert!(
parse_content_types(
format!(
r#"<Types xmlns="{CT}"><Default Extension="xml" ContentType="{bad}"/>{RELS_DEFAULT}</Types>"#
)
.as_bytes()
)
.is_err(),
"malformed Default ContentType {bad:?} must be rejected"
);
}
assert!(
parse_content_types(
format!(
r#"<Types xmlns="{CT}">{RELS_DEFAULT}<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml; charset=UTF-8"/></Types>"#
)
.as_bytes()
)
.is_ok(),
"valid +xml media type with parameters must stay accepted"
);
let ct = format!(
r#"<Types xmlns="{CT}">{RELS_DEFAULT}<Override PartName="/word/document.xml" ContentType="application/xml"/><Override PartName="/word/styles.xml" ContentType=""/></Types>"#
);
let zip = build_zip(&[
(CONTENT_TYPES, ct.as_bytes()),
("word/document.xml", b"<w:document/>"),
("word/styles.xml", b"<w:styles/>"),
]);
let pkg = Package::from_zip(&zip).expect("malformed metadata still opens for passthrough");
assert!(
pkg.is_meta_lossy(),
"empty content type must make package read-only"
);
assert_eq!(
pkg.part(CONTENT_TYPES).as_deref(),
Some(ct.as_bytes()),
"no-op save must preserve malformed [Content_Types].xml"
);
}
#[test]
fn malformed_default_extensions_are_read_only() {
const CT: &str = "http://schemas.openxmlformats.org/package/2006/content-types";
const RELS_DEFAULT: &str = r#"<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>"#;
for bad in ["", "x/y", r"x\y", "x.y", "x y", "x\t"] {
assert!(
parse_content_types(
format!(
r#"<Types xmlns="{CT}"><Default Extension="{bad}" ContentType="application/xml"/>{RELS_DEFAULT}</Types>"#
)
.as_bytes()
)
.is_err(),
"malformed Default Extension {bad:?} must be rejected"
);
}
let ct = format!(
r#"<Types xmlns="{CT}"><Default Extension="x/y" ContentType="application/xml"/>{RELS_DEFAULT}</Types>"#
);
let zip = build_zip(&[
(CONTENT_TYPES, ct.as_bytes()),
("word/document.xml", b"<w:document/>"),
]);
let pkg = Package::from_zip(&zip).expect("malformed metadata still opens for passthrough");
assert!(
pkg.is_meta_lossy(),
"invalid Default Extension must make package read-only"
);
assert_eq!(pkg.part(CONTENT_TYPES).as_deref(), Some(ct.as_bytes()));
}
#[test]
fn malformed_override_part_name_edges_are_read_only() {
const CT: &str = "http://schemas.openxmlformats.org/package/2006/content-types";
const RELS_DEFAULT: &str = r#"<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>"#;
for bad_part_name in [
"/[Content_Types].xml",
"/[content_types].xml",
"/word/a%zz.xml",
"/word/a%.xml",
"/word/a%2.xml",
] {
assert!(
parse_content_types(
format!(
r#"<Types xmlns="{CT}">{RELS_DEFAULT}<Override PartName="{bad_part_name}" ContentType="application/xml"/></Types>"#
)
.as_bytes()
)
.is_err(),
"malformed Override PartName {bad_part_name:?} must be rejected"
);
}
assert!(
parse_content_types(
format!(
r#"<Types xmlns="{CT}">{RELS_DEFAULT}<Override PartName="/word/a%20b.xml" ContentType="application/xml"/></Types>"#
)
.as_bytes()
)
.is_ok(),
"well-formed percent escapes must stay accepted"
);
}
#[test]
fn malformed_relationship_values_are_rejected() {
const RL: &str = "http://schemas.openxmlformats.org/package/2006/relationships";
for bad_id in ["", "1abc", "r d", "r:id"] {
assert!(
parse_rels(
format!(
r#"<Relationships xmlns="{RL}"><Relationship Id="{bad_id}" Type="urn:t" Target="x.xml"/></Relationships>"#
)
.as_bytes()
)
.is_err(),
"malformed Relationship Id {bad_id:?} must be rejected"
);
}
assert!(
parse_rels(
format!(
r#"<Relationships xmlns="{RL}"><Relationship Id="rId007" Type="urn:t" Target="x.xml"/></Relationships>"#
)
.as_bytes()
)
.is_ok(),
"valid NCName ids with leading-zero suffixes must stay accepted"
);
for rel in [
r#"<Relationship Id="rId1" Type="" Target="x.xml"/>"#,
r#"<Relationship Id="rId1" Type=" " Target="x.xml"/>"#,
r#"<Relationship Id="rId1" Type="urn:t" Target=""/>"#,
r#"<Relationship Id="rId1" Type="urn:t" Target="" TargetMode="External"/>"#,
] {
assert!(
parse_rels(
format!(r#"<Relationships xmlns="{RL}">{rel}</Relationships>"#).as_bytes()
)
.is_err(),
"empty Relationship Type/Target must be rejected: {rel}"
);
}
}
#[test]
fn illegal_xml_controls_in_metadata_values_are_rejected() {
const CT: &str = "http://schemas.openxmlformats.org/package/2006/content-types";
const RL: &str = "http://schemas.openxmlformats.org/package/2006/relationships";
assert!(
parse_content_types(
format!(
r#"<Types xmlns="{CT}"><Default Extension="rels" ContentType="{CT_RELS}"/><Override PartName="/word/styles.xml" ContentType="application/xml"/></Types>"#
)
.as_bytes()
)
.is_err(),
"character references to XML-illegal controls must not become editable metadata"
);
assert!(
parse_rels(
format!(
r#"<Relationships xmlns="{RL}"><Relationship Id="rId1" Type="urn:t" Target="x.xml"/></Relationships>"#
)
.as_bytes()
)
.is_err(),
"Relationship attrs with XML-illegal controls must be rejected"
);
assert!(
parse_content_types(
format!(
r#"<Types xmlns="{CT}"><Default Extension="x{}" ContentType="application/xml"/><Default Extension="rels" ContentType="{CT_RELS}"/></Types>"#,
'\u{FFFF}'
)
.as_bytes()
)
.is_err(),
"raw XML-forbidden scalars in metadata attrs must be rejected"
);
assert!(
parse_rels(
format!(
r#"<Relationships xmlns="{RL}"><Relationship Id="rId1" Type="urn:t" Target="x{}.xml"/></Relationships>"#,
'\u{FFFF}'
)
.as_bytes()
)
.is_err(),
"raw XML-forbidden scalars in relationship attrs must be rejected"
);
}
#[test]
fn case_colliding_zip_part_names_are_read_only() {
let ct = br#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="/word/document.xml" ContentType="application/xml"/></Types>"#;
let zip = build_zip(&[
(CONTENT_TYPES, ct),
("word/document.xml", b"A"),
("word/Document.xml", b"B"),
]);
let pkg = Package::from_zip(&zip).expect("case-colliding package still opens");
assert!(
pkg.is_meta_lossy(),
"case-colliding part identities must make package read-only"
);
let out = pkg.to_zip().unwrap();
let parts = part_set(&out);
assert_eq!(
parts.get("word/document.xml").map(Vec::as_slice),
Some(&b"A"[..])
);
assert_eq!(
parts.get("word/Document.xml").map(Vec::as_slice),
Some(&b"B"[..])
);
}
fn assert_meta_lossy_noop_preserves(zip: &[u8], expected: &[(&str, &[u8])]) {
let pkg = Package::from_zip(zip).expect("lossy metadata package still opens");
assert!(
pkg.is_meta_lossy(),
"foreign/unmodeled metadata attributes must make the package read-only"
);
let saved = pkg.to_zip().expect("no-op save should still be allowed");
let parts = part_set(&saved);
for (name, bytes) in expected {
assert_eq!(
parts.get(*name).map(Vec::as_slice),
Some(*bytes),
"no-op save changed raw bytes for {name}"
);
}
}
#[test]
fn foreign_metadata_record_attributes_make_package_read_only() {
let ct = format!(
r#"<Types xmlns="{CT_NS}"><Default Extension="rels" ContentType="{CT_RELS}"/><Default Extension="xml" ContentType="application/xml" data-keep="ct"/><Override PartName="/word/document.xml" ContentType="application/xml" data-keep="override"/></Types>"#
);
let rels = format!(
r#"<Relationships xmlns="{REL_NS}"><Relationship Id="rId1" Type="urn:styles" Target="styles.xml" data-keep="rel"/></Relationships>"#
);
let zip = build_zip(&[
(CONTENT_TYPES, ct.as_bytes()),
("word/document.xml", b"<w:document/>"),
("word/_rels/document.xml.rels", rels.as_bytes()),
]);
assert_meta_lossy_noop_preserves(
&zip,
&[
(CONTENT_TYPES, ct.as_bytes()),
("word/_rels/document.xml.rels", rels.as_bytes()),
],
);
}
#[test]
fn foreign_metadata_root_attributes_make_package_read_only() {
let ct = format!(
r#"<Types xmlns="{CT_NS}" data-root="ct"><Default Extension="rels" ContentType="{CT_RELS}"/><Default Extension="xml" ContentType="application/xml"/></Types>"#
);
let rels = format!(
r#"<Relationships xmlns="{REL_NS}" xmlns:x="urn:x"><Relationship Id="rId1" Type="urn:styles" Target="styles.xml"/></Relationships>"#
);
let zip = build_zip(&[
(CONTENT_TYPES, ct.as_bytes()),
("word/document.xml", b"<w:document/>"),
("word/_rels/document.xml.rels", rels.as_bytes()),
]);
assert_meta_lossy_noop_preserves(
&zip,
&[
(CONTENT_TYPES, ct.as_bytes()),
("word/_rels/document.xml.rels", rels.as_bytes()),
],
);
}
#[test]
fn invalid_metadata_attribute_names_make_package_read_only() {
for bad_attr in ["a/b", "1bad", ".x"] {
let ct = format!(
r#"<Types xmlns="{CT_NS}"><Default Extension="rels" ContentType="{CT_RELS}"/><Default Extension="xml" ContentType="application/xml" {bad_attr}="ct"/></Types>"#
);
let zip = build_zip(&[
(CONTENT_TYPES, ct.as_bytes()),
("word/document.xml", b"<w:document/>"),
]);
assert_meta_lossy_noop_preserves(&zip, &[(CONTENT_TYPES, ct.as_bytes())]);
}
}
#[test]
fn malformed_override_part_name_makes_package_read_only() {
const CT: &str = "http://schemas.openxmlformats.org/package/2006/content-types";
let ct = format!(
r#"<Types xmlns="{CT}"><Default Extension="rels" ContentType="{CT_RELS}"/><Override PartName="word/document.xml" ContentType="application/xml"/></Types>"#
);
let zip = build_zip(&[
(CONTENT_TYPES, ct.as_bytes()),
("word/document.xml", b"<w:document/>"),
]);
let pkg = Package::from_zip(&zip).expect("malformed metadata still opens for passthrough");
assert!(
pkg.is_meta_lossy(),
"bad Override PartName must make metadata read-only"
);
assert_eq!(
pkg.part(CONTENT_TYPES).as_deref(),
Some(ct.as_bytes()),
"no-op save path must preserve malformed metadata bytes"
);
}
#[test]
fn entry_count_open_save_no_double_count() {
let ct = br#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/></Types>"#;
set_test_max_entries(3);
let zip3 = build_zip(&[
("[Content_Types].xml", ct),
("a.xml", b"A"),
("b.xml", b"B"),
]);
let pkg = Package::from_zip(&zip3).expect("at-cap package should open");
assert!(
pkg.to_zip().is_ok(),
"no-op save must not double-count entries"
);
let zip4 = build_zip(&[
("[Content_Types].xml", ct),
("a.xml", b"A"),
("b.xml", b"B"),
("c.xml", b"C"),
]);
let over = Package::from_zip(&zip4);
reset_test_max_entries();
assert!(over.is_err(), "over-cap package must be rejected on open");
}
#[test]
fn foreign_namespace_opc_records_are_rejected() {
const CT: &str = "http://schemas.openxmlformats.org/package/2006/content-types";
const RL: &str = "http://schemas.openxmlformats.org/package/2006/relationships";
const RELS_DEFAULT: &str = r#"<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>"#;
assert!(parse_content_types(
format!(r#"<Types xmlns="{CT}">{RELS_DEFAULT}<x:Override xmlns:x="urn:foo" PartName="/word/media/image1.png" ContentType="image/png"/></Types>"#).as_bytes()
).is_err());
let ct_ok = parse_content_types(
format!(r#"<Types xmlns="{CT}">{RELS_DEFAULT}<Override PartName="/word/media/image1.png" ContentType="image/png"/></Types>"#).as_bytes()
).unwrap();
assert!(ct_ok.resolves("word/media/image1.png"));
assert!(parse_content_types(
format!(r#"<Types xmlns="{CT}">{RELS_DEFAULT}<x:Whatever xmlns:x="urn:foo" foo="bar"/></Types>"#).as_bytes()
).is_ok());
assert!(parse_rels(
format!(r#"<Relationships xmlns="{RL}"><x:Relationship xmlns:x="urn:foo" Id="rIdX" Type="t" Target="x"/></Relationships>"#).as_bytes()
).is_err());
let rels_ok = parse_rels(
format!(r#"<Relationships xmlns="{RL}"><Relationship Id="rId1" Type="t" Target="x"/></Relationships>"#).as_bytes()
).unwrap();
assert_eq!(rels_ok.len(), 1);
}
#[test]
fn eocd_preflight_rejects_huge_zip64_entry_count() {
let real = eocd_entry_count(&sample_docx()).unwrap();
assert!(real > 0 && real <= MAX_ENTRIES as u64);
let mut b = vec![0u8; 56]; b[0..4].copy_from_slice(&[0x50, 0x4b, 0x06, 0x06]);
b[32..40].copy_from_slice(&u64::MAX.to_le_bytes()); let mut loc = vec![0u8; 20];
loc[0..4].copy_from_slice(&[0x50, 0x4b, 0x06, 0x07]);
loc[8..16].copy_from_slice(&0u64.to_le_bytes()); b.extend_from_slice(&loc);
let mut eocd = vec![0u8; 22];
eocd[0..4].copy_from_slice(&[0x50, 0x4b, 0x05, 0x06]);
eocd[10..12].copy_from_slice(&0xFFFFu16.to_le_bytes());
b.extend_from_slice(&eocd);
assert_eq!(eocd_entry_count(&b), Some(u64::MAX));
assert!(Package::from_zip(&b).is_err());
}
#[test]
fn malformed_metadata_unclosed_or_bad_entity_rejected() {
const CT_NS_DECL: &str =
r#"xmlns="http://schemas.openxmlformats.org/package/2006/content-types""#;
const REL_NS_DECL: &str =
r#"xmlns="http://schemas.openxmlformats.org/package/2006/relationships""#;
assert!(parse_content_types(format!("<Types {CT_NS_DECL}>").as_bytes()).is_err());
assert!(parse_rels(format!("<Relationships {REL_NS_DECL}>").as_bytes()).is_err());
assert!(parse_content_types(
format!(
r#"<Types {CT_NS_DECL}><Override PartName="/a" ContentType="x&bogus;"/></Types>"#
)
.as_bytes()
)
.is_err());
assert!(parse_rels(
format!(r#"<Relationships {REL_NS_DECL}><Relationship Id="r" Type="t" Target="x&bogus;"/></Relationships>"#).as_bytes()
)
.is_err());
}
#[test]
fn completeness_tracks_retained_parts() {
let pkg = Package::from_zip(&sample_docx()).unwrap();
assert!(pkg.is_complete(), "clean package should be complete");
let mut incomplete = pkg;
incomplete.complete = false;
assert!(!incomplete.is_complete());
}
#[test]
fn namespace_prefixed_opc_roots_make_package_read_only() {
let ct = format!(
r#"<ct:Types xmlns:ct="{CT_NS}"><ct:Default Extension="rels" ContentType="{CT_RELS}"/><ct:Override PartName="/word/document.xml" ContentType="application/xml"/></ct:Types>"#
);
let rels = format!(
r#"<pr:Relationships xmlns:pr="{REL_NS}"><pr:Relationship Id="rId7" Type="urn:styles" Target="styles.xml"/></pr:Relationships>"#
);
let zip = build_zip(&[
(CONTENT_TYPES, ct.as_bytes()),
("word/_rels/document.xml.rels", rels.as_bytes()),
("word/document.xml", b"<w:document/>"),
]);
assert_meta_lossy_noop_preserves(
&zip,
&[
(CONTENT_TYPES, ct.as_bytes()),
("word/_rels/document.xml.rels", rels.as_bytes()),
],
);
}
}