use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use std::io::{Read, Write};
use quick_xml::events::Event;
use quick_xml::{Reader, XmlVersion};
use zip::write::SimpleFileOptions;
use crate::write::xml::esc_attr;
use crate::xmltree::{NodeId, XmlTree};
use crate::{Error, Result};
const MAX_PART: usize = 64 << 20;
const MAX_TOTAL: usize = 512 << 20;
const MAX_ENTRIES: usize = 65_536;
const MAX_NAME_LEN: usize = 4096;
const CONTENT_TYPES: &str = "[Content_Types].xml";
#[cfg(test)]
thread_local! {
static TEST_MAX_PART: std::cell::Cell<usize> = const { std::cell::Cell::new(MAX_PART) };
}
#[cfg(test)]
pub(crate) fn set_test_max_part(n: usize) {
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));
}
fn max_part() -> usize {
#[cfg(test)]
{
TEST_MAX_PART.with(|c| c.get())
}
#[cfg(not(test))]
{
MAX_PART
}
}
#[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
}
}
#[derive(Debug, Clone)]
enum Part {
Raw(Vec<u8>),
Xml(XmlTree),
}
impl Part {
fn bytes(&self) -> Cow<'_, [u8]> {
match self {
Part::Raw(b) => Cow::Borrowed(b.as_slice()),
Part::Xml(t) => Cow::Owned(t.serialize()),
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct Rel {
pub(crate) id: String,
#[cfg_attr(not(test), allow(dead_code))] pub(crate) rel_type: String,
pub(crate) target: String,
pub(crate) external: bool,
}
#[derive(Debug, Clone)]
struct ContentTypes {
defaults: Vec<(String, String)>,
overrides: Vec<(String, String)>,
}
impl ContentTypes {
fn fallback() -> ContentTypes {
ContentTypes {
defaults: vec![("rels".to_string(), crate::write::xml::CT_RELS.to_string())],
overrides: Vec::new(),
}
}
fn resolves(&self, part_name: &str) -> bool {
let pn = override_part_name(part_name);
if self
.overrides
.iter()
.any(|(p, _)| p.eq_ignore_ascii_case(&pn))
{
return true;
}
let Some((_, ext)) = part_name.rsplit_once('.') else {
return false;
};
self.defaults
.iter()
.any(|(e, _)| e.eq_ignore_ascii_case(ext))
}
}
fn override_part_name(part_name: &str) -> String {
format!("/{}", canonical_part_name(part_name))
}
#[derive(Debug, Clone)]
pub(crate) struct Package {
order: Vec<String>,
parts: HashMap<String, Part>,
ctypes: ContentTypes,
#[cfg_attr(not(test), allow(dead_code))]
rels: HashMap<String, Vec<Rel>>,
#[cfg_attr(not(test), allow(dead_code))]
rid_next: u64,
touched: HashSet<String>,
complete: bool,
meta_lossy: bool,
#[cfg_attr(not(test), allow(dead_code))]
ct_rels_injected: bool,
}
impl Package {
pub(crate) fn from_bytes(bytes: &[u8]) -> Result<Package> {
check_zip_entry_budget(bytes)?;
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes))
.map_err(|_| Error::Zip("not a valid spreadsheet ZIP container"))?;
crate::ziputil::validate_compression(&mut zip)?;
if zip.len() > max_entries() {
return Err(Error::Zip("OOXML package has too many entries"));
}
let mut order: Vec<String> = Vec::new();
let mut parts: HashMap<String, Part> = HashMap::new();
let mut seen_dirs: HashSet<String> = HashSet::new();
let mut seen: HashSet<String> = HashSet::new();
let mut seen_ci: HashSet<String> = HashSet::new();
let mut seen_canon: HashSet<String> = HashSet::new();
let mut seen_canon_ci: HashSet<String> = HashSet::new();
let mut complete = true;
let mut meta_lossy = false;
let mut total: usize = 0;
for idx in 0..zip.len() {
let mut file = match zip.by_index(idx) {
Ok(f) => f,
Err(_) => {
complete = false;
continue;
}
};
let name = file.name().to_string();
if name.len() > MAX_NAME_LEN {
return Err(Error::Zip("OOXML package entry name is too long"));
}
if name.ends_with('/') {
if seen_dirs.insert(name.clone()) {
order.push(name);
}
continue;
}
if !file.is_file() {
complete = false;
continue;
}
if file.size() > max_part() as u64 {
return Err(Error::Zip("OOXML package entry is too large"));
}
let mut data = Vec::new();
let read = file
.by_ref()
.take(max_part() as u64 + 1)
.read_to_end(&mut data);
if read.is_err() {
complete = false;
continue;
}
if data.len() > max_part() {
return Err(Error::Zip("OOXML package entry is too large"));
}
total = total
.checked_add(data.len())
.ok_or(Error::Zip("OOXML package is too large"))?;
if total > MAX_TOTAL {
return Err(Error::Zip("OOXML package is too large"));
}
if !seen.insert(name.clone()) {
parts.insert(name, Part::Raw(data));
continue;
}
if !seen_ci.insert(name.to_ascii_lowercase()) {
meta_lossy = true;
}
let canon = canonical_part_name(&name);
if !seen_canon.insert(canon.clone()) {
meta_lossy = true;
}
if !seen_canon_ci.insert(canon.to_ascii_lowercase()) {
meta_lossy = true;
}
order.push(name.clone());
parts.insert(name, Part::Raw(data));
}
let (ctypes, ct_meta_lossy, ct_rels_injected) = match parts.get(CONTENT_TYPES) {
Some(Part::Raw(bytes)) => match parse_content_types(bytes) {
Some((ctypes, injected)) => (ctypes, false, injected),
None => (ContentTypes::fallback(), true, false),
},
_ => (ContentTypes::fallback(), true, false),
};
meta_lossy = meta_lossy || ct_meta_lossy;
let mut rels: HashMap<String, Vec<Rel>> = HashMap::new();
for name in order.iter().filter(|n| is_rels_part(n)) {
let Some(Part::Raw(bytes)) = parts.get(name) else {
continue;
};
match parse_rels(bytes) {
Some(entries) => {
rels.insert(name.clone(), entries);
}
None => meta_lossy = true,
}
}
let rid_next = rels
.values()
.flatten()
.filter_map(|r| r.id.strip_prefix("rId").and_then(|n| n.parse::<u64>().ok()))
.max()
.map_or(1, |m| m.saturating_add(1));
Ok(Package {
order,
parts,
ctypes,
rels,
rid_next,
touched: HashSet::new(),
complete,
meta_lossy,
ct_rels_injected,
})
}
pub(crate) fn to_bytes(&self) -> Result<Vec<u8>> {
let mut touched: Vec<&String> = self.touched.iter().collect();
touched.sort();
let live_ctypes = self.live_content_types();
for name in &touched {
if name.ends_with('/') || name.as_str() == CONTENT_TYPES {
continue;
}
if !self.parts.contains_key(name.as_str()) {
continue; }
if !live_ctypes.resolves(name) {
return Err(Error::Zip(
"OOXML package part has no resolvable content type",
));
}
}
for name in &touched {
if !is_rels_part(name) {
continue;
}
let Some(part) = self.parts.get(name.as_str()) else {
continue; };
let bytes = part.bytes();
let entries =
parse_rels(&bytes).ok_or(Error::Zip("touched .rels part is not well-formed"))?;
let source_part = source_part_of_rels_path(name).ok_or(Error::Zip(
"relationships part path is not a valid .rels path",
))?;
for rel in &entries {
if rel.external {
continue;
}
let target = Package::resolve_rel_target(&source_part, &rel.target);
if !self.has_part(&target) {
return Err(Error::Zip(
"relationship in a touched .rels part targets a missing part",
));
}
}
}
let in_order: HashSet<&str> = self.order.iter().map(String::as_str).collect();
let mut extra: Vec<&String> = self
.parts
.keys()
.filter(|k| !in_order.contains(k.as_str()))
.collect();
extra.sort();
if self.order.len().saturating_add(extra.len()) > max_entries() {
return Err(Error::Zip("OOXML package has too many entries"));
}
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let opt = SimpleFileOptions::default();
let mut total: usize = 0;
for name in &self.order {
if name.ends_with('/') {
if name.len() > MAX_NAME_LEN {
return Err(Error::Zip("OOXML package entry name is too long"));
}
out.add_directory(name.as_str(), opt)
.map_err(|_| Error::Zip("failed to write OOXML package entry"))?;
} else if let Some(part) = self.parts.get(name) {
let data = part.bytes();
write_part(&mut out, &mut total, name, &data, opt)?;
}
}
for name in extra {
if let Some(part) = self.parts.get(name) {
let data = part.bytes();
write_part(&mut out, &mut total, name, &data, opt)?;
}
}
out.finish()
.map(|c| c.into_inner())
.map_err(|_| Error::Zip("failed to finish OOXML package"))
}
pub(crate) fn part_bytes(&self, name: &str) -> Option<&[u8]> {
let key = self.find_part_key(name)?;
match self.parts.get(key)?.bytes() {
Cow::Borrowed(b) => Some(b),
Cow::Owned(_) => None,
}
}
pub(crate) fn part_names(&self) -> impl Iterator<Item = &str> {
self.order.iter().map(String::as_str)
}
#[cfg_attr(not(test), allow(dead_code))] pub(crate) fn replace_part(&mut self, name: &str, data: Vec<u8>) -> Result<String> {
if data.len() > max_part() {
return Err(Error::Zip("OOXML package entry is too large"));
}
let key = self
.find_part_key(name)
.cloned()
.ok_or(Error::Zip("OOXML package part is missing"))?;
self.touched.insert(key.clone());
self.parts.insert(key.clone(), Part::Raw(data));
Ok(key)
}
pub(crate) fn remove_part(&mut self, name: &str) -> Option<String> {
let key = self.find_part_key(name)?.clone();
self.touched.insert(key.clone());
self.parts.remove(&key);
if is_rels_part(&key) {
self.rels.remove(&key);
}
Some(key)
}
#[cfg_attr(not(test), allow(dead_code))] pub(crate) fn set_part(&mut self, name: &str, bytes: Vec<u8>, content_type: Option<&str>) {
self.set_part_raw(name, bytes);
if let Some(ct) = content_type {
self.ensure_content_type(name, ct);
}
}
pub(crate) fn has_part(&self, name: &str) -> bool {
self.find_part_key(name).is_some()
}
pub(crate) fn touched_parts(&self) -> Vec<String> {
let mut v: Vec<String> = self.touched.iter().cloned().collect();
v.sort();
v
}
pub(crate) fn is_complete(&self) -> bool {
self.complete
}
pub(crate) fn is_meta_lossy(&self) -> bool {
self.meta_lossy
}
pub(crate) fn part_tree_mut(&mut self, name: &str) -> Result<&mut XmlTree> {
let key = self
.find_part_key(name)
.cloned()
.ok_or(Error::Zip("OOXML package part is missing"))?;
let entry = self
.parts
.get_mut(&key)
.expect("key resolved by find_part_key must exist in parts");
if let Part::Raw(bytes) = entry {
*entry = Part::Xml(XmlTree::parse(bytes)?);
}
self.touched.insert(key);
match entry {
Part::Xml(t) => Ok(t),
Part::Raw(_) => unreachable!("just promoted to Xml"),
}
}
pub(crate) fn part_tree_ref(&self, name: &str) -> Option<&XmlTree> {
let key = self.find_part_key(name)?;
match self.parts.get(key)? {
Part::Xml(t) => Some(t),
Part::Raw(_) => None,
}
}
#[cfg_attr(not(test), allow(dead_code))] pub(crate) fn ensure_content_type(&mut self, part: &str, content_type: &str) {
let pn = override_part_name(part);
let already = self
.ctypes
.overrides
.iter()
.any(|(p, ct)| p.eq_ignore_ascii_case(&pn) && ct == content_type);
if already {
return;
}
self.ctypes
.overrides
.retain(|(p, _)| !p.eq_ignore_ascii_case(&pn));
self.ctypes.overrides.push((pn, content_type.to_string()));
self.regen_content_types();
}
pub(crate) fn remove_content_type(&mut self, part: &str) -> Result<bool> {
let part_name = override_part_name(part);
let old_len = self.ctypes.overrides.len();
self.ctypes
.overrides
.retain(|(name, _)| !name.eq_ignore_ascii_case(&part_name));
let removed_cached = self.ctypes.overrides.len() != old_len;
let Some(key) = self.find_part_key(CONTENT_TYPES).cloned() else {
return Ok(removed_cached);
};
if !matches!(self.parts.get(&key), Some(Part::Xml(_))) {
if removed_cached {
self.regen_content_types();
}
return Ok(removed_cached);
}
let mut removed_live = false;
if let Some(Part::Xml(tree)) = self.parts.get_mut(&key) {
if let Some(root) = tree.root_element() {
let matches: Vec<NodeId> = tree
.children_of(root)
.iter()
.copied()
.filter(|&node| {
tree.element_name(node)
.is_some_and(|name| local_name(name).eq_ignore_ascii_case(b"Override"))
&& tree.attr_value(node, b"PartName").is_some_and(|value| {
String::from_utf8_lossy(value).eq_ignore_ascii_case(&part_name)
})
})
.collect();
for node in matches {
tree.remove_child(root, node)?;
removed_live = true;
}
}
}
if removed_live {
self.touched.insert(key);
}
Ok(removed_cached || removed_live)
}
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("/")
}
#[cfg_attr(not(test), allow(dead_code))] pub(crate) 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 let Some(rest) = new_part.strip_prefix(&format!("{src_dir}/")) {
return rest.to_string();
}
format!("/{new_part}")
}
#[cfg_attr(not(test), allow(dead_code))] pub(crate) fn rels_path_of(part: &str) -> String {
match part.rsplit_once('/') {
Some((dir, file)) => format!("{dir}/_rels/{file}.rels"),
None if part.is_empty() => "_rels/.rels".to_string(),
None => format!("_rels/{part}.rels"),
}
}
#[cfg_attr(not(test), allow(dead_code))] pub(crate) fn relationships_of(&self, part: &str) -> &[Rel] {
self.rels
.get(&Self::rels_path_of(part))
.map(Vec::as_slice)
.unwrap_or(&[])
}
pub(crate) fn relationship_entries(&self) -> Vec<(String, Rel)> {
let mut entries = Vec::new();
for (rels_path, relationships) in &self.rels {
let Some(source) = source_part_of_rels_path(rels_path) else {
continue;
};
entries.extend(
relationships
.iter()
.cloned()
.map(|relationship| (source.clone(), relationship)),
);
}
entries.sort_by(|left, right| {
(&left.0, &left.1.id, &left.1.rel_type, &left.1.target).cmp(&(
&right.0,
&right.1.id,
&right.1.rel_type,
&right.1.target,
))
});
entries
}
#[cfg_attr(not(test), allow(dead_code))] pub(crate) fn add_relationship(
&mut self,
src_part: &str,
rel_type: &str,
target: &str,
external: bool,
) -> String {
let rid = self.alloc_rid();
let rels_path = Self::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: target.to_string(),
external,
});
self.regen_rels(&rels_path);
rid
}
pub(crate) fn remove_relationship(&mut self, src_part: &str, id: &str) -> Result<bool> {
let rels_path = Self::rels_path_of(src_part);
let removed_cached = self.rels.get_mut(&rels_path).is_some_and(|rels| {
let old_len = rels.len();
rels.retain(|rel| rel.id != id);
rels.len() != old_len
});
let Some(key) = self.find_part_key(&rels_path).cloned() else {
return Ok(removed_cached);
};
if !matches!(self.parts.get(&key), Some(Part::Xml(_))) {
if removed_cached {
self.regen_rels(&rels_path);
}
return Ok(removed_cached);
}
let mut removed_live = false;
if let Some(Part::Xml(tree)) = self.parts.get_mut(&key) {
if let Some(root) = tree.root_element() {
let matches: Vec<NodeId> = tree
.children_of(root)
.iter()
.copied()
.filter(|&node| {
tree.element_name(node).is_some_and(|name| {
local_name(name).eq_ignore_ascii_case(b"Relationship")
}) && tree.attr_value(node, b"Id") == Some(id.as_bytes())
})
.collect();
for node in matches {
tree.remove_child(root, node)?;
removed_live = true;
}
}
}
if removed_live {
self.touched.insert(key);
if self.ct_rels_injected {
self.regen_content_types();
}
}
Ok(removed_cached || removed_live)
}
pub(crate) fn update_relationship_target(
&mut self,
src_part: &str,
id: &str,
target: &str,
external: bool,
) -> Result<bool> {
let rels_path = Self::rels_path_of(src_part);
let Some(relationship) = self
.rels
.get_mut(&rels_path)
.and_then(|relationships| relationships.iter_mut().find(|rel| rel.id == id))
else {
return Ok(false);
};
if relationship.target == target && relationship.external == external {
return Ok(true);
}
relationship.target = target.to_string();
relationship.external = external;
let Some(key) = self.find_part_key(&rels_path).cloned() else {
return Ok(false);
};
if !matches!(self.parts.get(&key), Some(Part::Xml(_))) {
self.regen_rels(&rels_path);
return Ok(true);
}
if let Some(Part::Xml(tree)) = self.parts.get_mut(&key) {
let Some(root) = tree.root_element() else {
return Ok(false);
};
let Some(node) = tree.children_of(root).iter().copied().find(|&node| {
tree.element_name(node)
.is_some_and(|name| local_name(name).eq_ignore_ascii_case(b"Relationship"))
&& tree.attr_value(node, b"Id") == Some(id.as_bytes())
}) else {
return Ok(false);
};
tree.set_attr(node, b"Target", target.as_bytes())?;
if external {
tree.set_attr(node, b"TargetMode", b"External")?;
} else {
tree.remove_attr(node, b"TargetMode");
}
}
self.touched.insert(key);
Ok(true)
}
#[cfg_attr(not(test), allow(dead_code))] fn alloc_rid(&mut self) -> String {
let id = format!("rId{}", self.rid_next);
self.rid_next = self.rid_next.saturating_add(1);
id
}
#[cfg_attr(not(test), allow(dead_code))] fn regen_content_types(&mut self) {
if let Some(key) = self.find_part_key(CONTENT_TYPES).cloned() {
if matches!(self.parts.get(&key), Some(Part::Xml(_))) {
self.merge_content_types_into_tree(&key);
return;
}
}
let mut xml = String::from(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">"#,
);
for (ext, ct) in &self.ctypes.defaults {
xml.push_str(&format!(
r#"<Default Extension="{}" ContentType="{}"/>"#,
esc_attr(ext),
esc_attr(ct)
));
}
for (part, ct) in &self.ctypes.overrides {
xml.push_str(&format!(
r#"<Override PartName="{}" ContentType="{}"/>"#,
esc_attr(part),
esc_attr(ct)
));
}
xml.push_str("</Types>");
self.set_part_raw(CONTENT_TYPES, xml.into_bytes());
}
#[cfg_attr(not(test), allow(dead_code))] fn merge_content_types_into_tree(&mut self, key: &str) {
let defaults = self.ctypes.defaults.clone();
let overrides = self.ctypes.overrides.clone();
let Some(Part::Xml(tree)) = self.parts.get_mut(key) else {
return;
};
let Some(root) = tree.root_element() else {
return;
};
for (ext, ct) in &defaults {
merge_child_element(tree, root, "Default", "Extension", ext, "ContentType", ct);
}
for (part_name, ct) in &overrides {
merge_child_element(
tree,
root,
"Override",
"PartName",
part_name,
"ContentType",
ct,
);
}
self.touched.insert(key.to_string());
}
#[cfg_attr(not(test), allow(dead_code))] fn regen_rels(&mut self, rels_path: &str) {
if let Some(key) = self.find_part_key(rels_path).cloned() {
if matches!(self.parts.get(&key), Some(Part::Xml(_))) {
self.merge_rels_into_tree(&key, rels_path);
if self.ct_rels_injected {
self.regen_content_types();
}
return;
}
}
let entries = self.rels.get(rels_path).cloned().unwrap_or_default();
let mut xml = String::from(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#,
);
for rel in &entries {
xml.push_str(&format!(
r#"<Relationship Id="{}" Type="{}" Target="{}""#,
esc_attr(&rel.id),
esc_attr(&rel.rel_type),
esc_attr(&rel.target)
));
if rel.external {
xml.push_str(r#" TargetMode="External""#);
}
xml.push_str("/>");
}
xml.push_str("</Relationships>");
self.set_part_raw(rels_path, xml.into_bytes());
if self.ct_rels_injected {
self.regen_content_types();
}
}
#[cfg_attr(not(test), allow(dead_code))] fn merge_rels_into_tree(&mut self, key: &str, rels_path: &str) {
let entries = self.rels.get(rels_path).cloned().unwrap_or_default();
let Some(Part::Xml(tree)) = self.parts.get_mut(key) else {
return;
};
let Some(root) = tree.root_element() else {
return;
};
for rel in &entries {
merge_relationship_element(tree, root, rel);
}
self.touched.insert(key.to_string());
}
#[cfg_attr(not(test), allow(dead_code))] fn set_part_raw(&mut self, name: &str, bytes: Vec<u8>) -> String {
let key = self.find_part_key(name).cloned().unwrap_or_else(|| {
self.order.push(name.to_string());
name.to_string()
});
self.touched.insert(key.clone());
self.parts.insert(key.clone(), Part::Raw(bytes));
key
}
fn find_part_key(&self, name: &str) -> Option<&String> {
if let Some(k) = self.parts.keys().find(|k| k.as_str() == name) {
return Some(k);
}
let canon = canonical_part_name(name);
if let Some(k) = self.first_matching_key_in_order(|k| canonical_part_name(k) == canon) {
return Some(k);
}
let canon_lower = canon.to_ascii_lowercase();
self.first_matching_key_in_order(|k| {
canonical_part_name(k).to_ascii_lowercase() == canon_lower
})
}
fn first_matching_key_in_order(&self, pred: impl Fn(&str) -> bool) -> Option<&String> {
self.order
.iter()
.filter(|name| pred(name.as_str()))
.find_map(|name| self.parts.get_key_value(name.as_str()))
.map(|(k, _)| k)
}
fn live_content_types(&self) -> ContentTypes {
match self.parts.get(CONTENT_TYPES) {
Some(part) => {
let bytes = part.bytes();
match parse_content_types(&bytes) {
Some((ctypes, _)) => ctypes,
None => self.ctypes.clone(),
}
}
None => ContentTypes::fallback(),
}
}
}
fn write_part(
out: &mut zip::ZipWriter<std::io::Cursor<Vec<u8>>>,
total: &mut usize,
name: &str,
data: &[u8],
opt: SimpleFileOptions,
) -> Result<()> {
if name.len() > MAX_NAME_LEN {
return Err(Error::Zip("OOXML package entry name is too long"));
}
if data.len() > max_part() {
return Err(Error::Zip("OOXML package entry is too large"));
}
*total = total
.checked_add(data.len())
.ok_or(Error::Zip("OOXML package is too large"))?;
if *total > MAX_TOTAL {
return Err(Error::Zip("OOXML package is too large"));
}
out.start_file(name, opt)
.map_err(|_| Error::Zip("failed to write OOXML package entry"))?;
out.write_all(data)
.map_err(|_| Error::Zip("failed to write OOXML package entry"))?;
Ok(())
}
fn canonical_part_name(name: &str) -> String {
name.replace('\\', "/").trim_start_matches('/').to_string()
}
#[cfg_attr(not(test), allow(dead_code))] fn merge_child_element(
tree: &mut XmlTree,
parent: NodeId,
tag: &str,
key_attr: &str,
key_val: &str,
val_attr: &str,
val: &str,
) {
let children: Vec<NodeId> = tree.children_of(parent).to_vec();
for id in children {
if tree.element_name(id) == Some(tag.as_bytes()) {
let matches = tree
.attr_value(id, key_attr.as_bytes())
.map(|v| String::from_utf8_lossy(v).eq_ignore_ascii_case(key_val))
.unwrap_or(false);
if matches {
let _ = tree.set_attr(id, val_attr.as_bytes(), val.as_bytes());
return;
}
}
}
let frag = format!(
r#"<{tag} {key_attr}="{}" {val_attr}="{}"/>"#,
esc_attr(key_val),
esc_attr(val)
);
let idx = tree.children_of(parent).len();
let _ = tree.insert_fragment_at(parent, idx, frag.as_bytes());
}
#[cfg_attr(not(test), allow(dead_code))] fn merge_relationship_element(tree: &mut XmlTree, parent: NodeId, rel: &Rel) {
let children: Vec<NodeId> = tree.children_of(parent).to_vec();
for id in children {
if tree.element_name(id) == Some(b"Relationship")
&& tree.attr_value(id, b"Id") == Some(rel.id.as_bytes())
{
let _ = tree.set_attr(id, b"Type", rel.rel_type.as_bytes());
let _ = tree.set_attr(id, b"Target", rel.target.as_bytes());
if rel.external {
let _ = tree.set_attr(id, b"TargetMode", b"External");
} else {
tree.remove_attr(id, b"TargetMode");
}
return;
}
}
let mut frag = format!(
r#"<Relationship Id="{}" Type="{}" Target="{}""#,
esc_attr(&rel.id),
esc_attr(&rel.rel_type),
esc_attr(&rel.target)
);
if rel.external {
frag.push_str(r#" TargetMode="External""#);
}
frag.push_str("/>");
let idx = tree.children_of(parent).len();
let _ = tree.insert_fragment_at(parent, idx, frag.as_bytes());
}
fn is_rels_part(name: &str) -> bool {
match name.rsplit_once('/') {
Some((dir, file)) => file.ends_with(".rels") && (dir == "_rels" || dir.ends_with("/_rels")),
None => false,
}
}
fn source_part_of_rels_path(rels_path: &str) -> Option<String> {
let (dir, file) = rels_path.rsplit_once('/')?;
let parent_dir = dir.strip_suffix("_rels")?.trim_end_matches('/');
let part_name = file.strip_suffix(".rels")?;
if part_name.is_empty() {
Some(parent_dir.to_string())
} else if parent_dir.is_empty() {
Some(part_name.to_string())
} else {
Some(format!("{parent_dir}/{part_name}"))
}
}
fn local_name(name: &[u8]) -> &[u8] {
name.rsplit(|&b| b == b':').next().unwrap_or(name)
}
fn attr_value(e: &quick_xml::events::BytesStart<'_>, key: &[u8]) -> Option<String> {
e.attributes().flatten().find_map(|a| {
if local_name(a.key.as_ref()) == key {
a.decoded_and_normalized_value_with(
XmlVersion::Implicit1_0,
e.decoder(),
1,
quick_xml::escape::resolve_xml_entity,
)
.ok()
.map(|v| v.into_owned())
} else {
None
}
})
}
fn parse_content_types(bytes: &[u8]) -> Option<(ContentTypes, bool)> {
if !crate::xml_reference_bytes_within_budget(bytes) {
return None;
}
let mut reader = Reader::from_reader(bytes);
let mut buf = Vec::new();
let mut defaults = Vec::new();
let mut overrides = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Eof) => break,
Ok(Event::Empty(e)) | Ok(Event::Start(e)) => match local_name(e.name().as_ref()) {
b"Default" => {
let ext = attr_value(&e, b"Extension")?;
let ct = attr_value(&e, b"ContentType")?;
defaults.push((ext.to_ascii_lowercase(), ct));
}
b"Override" => {
let pn = attr_value(&e, b"PartName")?;
let ct = attr_value(&e, b"ContentType")?;
overrides.push((pn, ct));
}
_ => {}
},
Ok(_) => {}
Err(_) => return None,
}
buf.clear();
}
let injected = if defaults.iter().any(|(ext, _)| ext == "rels") {
false
} else {
defaults.push(("rels".to_string(), crate::write::xml::CT_RELS.to_string()));
true
};
Some((
ContentTypes {
defaults,
overrides,
},
injected,
))
}
fn parse_rels(bytes: &[u8]) -> Option<Vec<Rel>> {
if !crate::xml_reference_bytes_within_budget(bytes) {
return None;
}
let mut reader = Reader::from_reader(bytes);
let mut buf = Vec::new();
let mut rels = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Eof) => break,
Ok(Event::Empty(e)) | Ok(Event::Start(e)) => {
if local_name(e.name().as_ref()) == b"Relationship" {
let id = attr_value(&e, b"Id")?;
let rel_type = attr_value(&e, b"Type")?;
let target = attr_value(&e, b"Target")?;
let external = attr_value(&e, b"TargetMode").as_deref() == Some("External");
rels.push(Rel {
id,
rel_type,
target,
external,
});
}
}
Ok(_) => {}
Err(_) => return None,
}
buf.clear();
}
Some(rels)
}
fn check_zip_entry_budget(bytes: &[u8]) -> Result<()> {
if let Some(count) = eocd_entry_count(bytes) {
if count > max_entries() as u64 {
return Err(Error::Zip("OOXML package has too many entries"));
}
}
Ok(())
}
fn eocd_entry_count(bytes: &[u8]) -> Option<u64> {
const EOCD_SIG: [u8; 4] = [0x50, 0x4B, 0x05, 0x06];
const EOCD_MIN: usize = 22;
if bytes.len() < EOCD_MIN {
return None;
}
let max_comment = 65_535usize;
let search_start = bytes.len().saturating_sub(EOCD_MIN + max_comment);
let window = &bytes[search_start..];
let sig_pos = window.windows(4).rposition(|w| w == EOCD_SIG)?;
let eocd_off = search_start + sig_pos;
if eocd_off + EOCD_MIN > bytes.len() {
return None;
}
let entries16 = u16::from_le_bytes([bytes[eocd_off + 10], bytes[eocd_off + 11]]) as u64;
if entries16 != 0xFFFF {
return Some(entries16);
}
const ZIP64_LOC_SIG: [u8; 4] = [0x50, 0x4B, 0x06, 0x07];
const ZIP64_LOC_LEN: usize = 20;
if eocd_off < ZIP64_LOC_LEN {
return None;
}
let loc_off = eocd_off - ZIP64_LOC_LEN;
if bytes.get(loc_off..loc_off + 4)? != ZIP64_LOC_SIG {
return None;
}
let zip64_eocd_off = u64::from_le_bytes(bytes.get(loc_off + 8..loc_off + 16)?.try_into().ok()?);
let zip64_eocd_off = usize::try_from(zip64_eocd_off).ok()?;
const ZIP64_EOCD_SIG: [u8; 4] = [0x50, 0x4B, 0x06, 0x06];
if zip64_eocd_off.checked_add(56)? > bytes.len() {
return None;
}
if bytes.get(zip64_eocd_off..zip64_eocd_off + 4)? != ZIP64_EOCD_SIG {
return None;
}
let entries64 = u64::from_le_bytes(
bytes
.get(zip64_eocd_off + 32..zip64_eocd_off + 40)?
.try_into()
.ok()?,
);
Some(entries64)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::write::xml::{CT_CORE_PROPS, CT_SST, CT_STYLES, CT_WORKBOOK};
fn zip_bytes(parts: &[(&str, &[u8])]) -> Vec<u8> {
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let opt = zip::write::SimpleFileOptions::default();
for (name, bytes) in parts {
zip.start_file(*name, opt).unwrap();
std::io::Write::write_all(&mut zip, bytes).unwrap();
}
zip.finish().unwrap().into_inner()
}
fn minimal_xlsx() -> Vec<u8> {
zip_bytes(&[
(
"[Content_Types].xml",
br#"<?xml version="1.0" encoding="UTF-8"?><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"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/></Types>"#,
),
(
"_rels/.rels",
br#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>"#,
),
(
"xl/workbook.xml",
br#"<?xml version="1.0" encoding="UTF-8"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"/>"#,
),
])
}
#[test]
fn raw_package_roundtrip_preserves_part_payloads() {
let input = zip_bytes(&[
("[Content_Types].xml", b"<Types/>"),
("xl/workbook.xml", b"<workbook/>"),
("xl/vbaProject.bin", b"macro bytes"),
]);
let package = Package::from_bytes(&input).unwrap();
let output = package.to_bytes().unwrap();
let reread = Package::from_bytes(&output).unwrap();
assert_eq!(
reread.part_bytes("xl/vbaProject.bin"),
Some(&b"macro bytes"[..])
);
assert_eq!(
reread.part_bytes("/xl/workbook.xml"),
Some(&b"<workbook/>"[..])
);
}
#[test]
fn raw_package_rejects_oversized_part_names() {
let name = format!("{}.xml", "x".repeat(4096));
let input = zip_bytes(&[(&name, b"x")]);
assert!(Package::from_bytes(&input).is_err());
}
#[test]
fn part_names_returns_original_zip_order() {
let input = zip_bytes(&[
("[Content_Types].xml", b"<Types/>"),
("_rels/.rels", b"<Relationships/>"),
("xl/workbook.xml", b"<workbook/>"),
]);
let package = Package::from_bytes(&input).unwrap();
assert_eq!(
package.part_names().collect::<Vec<_>>(),
vec!["[Content_Types].xml", "_rels/.rels", "xl/workbook.xml"]
);
}
#[test]
fn replace_part_errors_when_part_missing() {
let mut package = Package::from_bytes(&minimal_xlsx()).unwrap();
assert!(package
.replace_part("xl/missing.xml", b"x".to_vec())
.is_err());
}
#[test]
fn replace_part_is_case_insensitive_and_returns_stored_name() {
let mut package = Package::from_bytes(&minimal_xlsx()).unwrap();
let stored = package
.replace_part("XL/WORKBOOK.XML", b"<workbook edited=\"1\"/>".to_vec())
.unwrap();
assert_eq!(stored, "xl/workbook.xml");
assert_eq!(
package.part_bytes("xl/workbook.xml"),
Some(&b"<workbook edited=\"1\"/>"[..])
);
}
#[test]
fn remove_part_returns_stored_name_case_preserved_and_drops_from_output() {
let mut package = Package::from_bytes(&minimal_xlsx()).unwrap();
let removed = package.remove_part("XL/WORKBOOK.XML").unwrap();
assert_eq!(removed, "xl/workbook.xml");
assert!(package.remove_part("xl/workbook.xml").is_none());
let bytes = package.to_bytes().unwrap();
let reread = Package::from_bytes(&bytes).unwrap();
assert!(reread.part_bytes("xl/workbook.xml").is_none());
}
#[test]
fn from_bytes_rejects_over_budget_part_size() {
set_test_max_part(4);
let input = zip_bytes(&[("xl/workbook.xml", b"toolong")]);
let result = Package::from_bytes(&input);
reset_test_max_part();
assert!(result.is_err());
}
#[test]
fn from_bytes_rejects_over_budget_entry_count() {
set_test_max_entries(1);
let input = zip_bytes(&[("a.xml", b"1"), ("b.xml", b"2")]);
let result = Package::from_bytes(&input);
reset_test_max_entries();
assert!(result.is_err());
}
#[test]
fn eocd_entry_count_reads_a_real_small_archive() {
let bytes = zip_bytes(&[("a.xml", b"1"), ("b.xml", b"2"), ("c.xml", b"3")]);
assert_eq!(eocd_entry_count(&bytes), Some(3));
}
#[test]
fn eocd_preflight_rejects_before_zip_archive_parses() {
set_test_max_entries(0);
let input = zip_bytes(&[("a.xml", b"1")]);
let result = Package::from_bytes(&input);
reset_test_max_entries();
assert!(result.is_err());
}
#[test]
fn directory_entries_round_trip() {
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let opt = zip::write::SimpleFileOptions::default();
zip.add_directory("xl/", opt).unwrap();
zip.start_file("xl/workbook.xml", opt).unwrap();
std::io::Write::write_all(&mut zip, b"<workbook/>").unwrap();
let input = zip.finish().unwrap().into_inner();
let package = Package::from_bytes(&input).unwrap();
assert!(package.part_names().any(|n| n == "xl/"));
let output = package.to_bytes().unwrap();
let reread = Package::from_bytes(&output).unwrap();
assert!(reread.part_names().any(|n| n == "xl/"));
assert_eq!(
reread.part_bytes("xl/workbook.xml"),
Some(&b"<workbook/>"[..])
);
}
#[test]
fn case_colliding_part_names_set_meta_lossy_but_keep_both() {
let input = zip_bytes(&[("xl/Sheet.xml", b"upper"), ("xl/sheet.xml", b"lower")]);
let package = Package::from_bytes(&input).unwrap();
assert!(package.is_meta_lossy());
assert_eq!(package.part_bytes("xl/Sheet.xml"), Some(&b"upper"[..]));
assert_eq!(package.part_bytes("xl/sheet.xml"), Some(&b"lower"[..]));
}
#[test]
fn canonically_colliding_raw_distinct_part_names_set_meta_lossy() {
let input = zip_bytes(&[
(
"[Content_Types].xml",
br#"<?xml version="1.0"?><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>"#,
),
("xl/Sheet.xml", b"first"),
(r"xl\Sheet.xml", b"second"),
]);
let package = Package::from_bytes(&input).unwrap();
assert!(package.is_meta_lossy());
assert!(package.parts.contains_key("xl/Sheet.xml"));
assert!(package.parts.contains_key(r"xl\Sheet.xml"));
}
#[test]
fn find_part_key_ambiguous_tier_resolves_the_same_key_every_time() {
let input = zip_bytes(&[("xl/Sheet.xml", b"first"), (r"xl\Sheet.xml", b"second")]);
let mut winners: HashSet<String> = HashSet::new();
for _ in 0..500 {
let package = Package::from_bytes(&input).unwrap();
let key = package.find_part_key("/xl/Sheet.xml").unwrap().clone();
winners.insert(key);
}
assert_eq!(
winners,
HashSet::from(["xl/Sheet.xml".to_string()]),
"ambiguous canonical match must deterministically resolve to the \
first key in source order every time, not vary by hasher state"
);
}
#[test]
fn from_bytes_marks_incomplete_when_an_original_part_is_a_symlink() {
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let opt = zip::write::SimpleFileOptions::default();
zip.add_symlink("xl/workbook.xml", "somewhere/else.xml", opt)
.unwrap();
let bytes = zip.finish().unwrap().into_inner();
let package = Package::from_bytes(&bytes).unwrap();
assert!(!package.is_complete());
assert!(!package.has_part("xl/workbook.xml"));
}
#[test]
fn from_zip_marks_incomplete_when_entry_data_is_corrupted() {
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let opt = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored);
zip.start_file("[Content_Types].xml", opt).unwrap();
std::io::Write::write_all(&mut zip, b"<Types/>").unwrap();
zip.start_file("xl/workbook.xml", opt).unwrap();
std::io::Write::write_all(&mut zip, b"CORRUPTME").unwrap();
let mut bytes = zip.finish().unwrap().into_inner();
let pos = bytes
.windows(b"CORRUPTME".len())
.rposition(|w| w == b"CORRUPTME")
.unwrap();
bytes[pos] ^= 0xFF;
let package = Package::from_bytes(&bytes).unwrap();
assert!(!package.is_complete());
assert_eq!(
package.part_bytes("[Content_Types].xml"),
Some(&b"<Types/>"[..])
);
}
#[test]
fn content_types_missing_entirely_sets_meta_lossy_and_still_opens() {
let input = zip_bytes(&[("xl/workbook.xml", b"<workbook/>")]);
let package = Package::from_bytes(&input).unwrap();
assert!(package.is_meta_lossy());
assert_eq!(
package.part_bytes("xl/workbook.xml"),
Some(&b"<workbook/>"[..])
);
}
#[test]
fn content_types_malformed_sets_meta_lossy_but_still_opens() {
let input = zip_bytes(&[
("[Content_Types].xml", b"<Types><Default Extension"), ("xl/workbook.xml", b"<workbook/>"),
]);
let package = Package::from_bytes(&input).unwrap();
assert!(package.is_meta_lossy());
}
#[test]
fn rels_malformed_sets_meta_lossy_but_still_opens() {
let input = zip_bytes(&[
("[Content_Types].xml", b"<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/></Types>"),
("_rels/.rels", b"<Relationships><Relationship"), ]);
let package = Package::from_bytes(&input).unwrap();
assert!(package.is_meta_lossy());
}
#[test]
fn ct_rels_default_missing_is_injected_not_meta_lossy() {
let input = zip_bytes(&[(
"[Content_Types].xml",
br#"<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/></Types>"#,
)]);
let package = Package::from_bytes(&input).unwrap();
assert!(!package.is_meta_lossy());
assert!(package.ct_rels_injected);
}
#[test]
fn rid_next_seeds_above_existing_rids() {
let input = zip_bytes(&[(
"xl/_rels/workbook.xml.rels",
br#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId5" Type="t" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="t" Target="styles.xml"/></Relationships>"#,
)]);
let package = Package::from_bytes(&input).unwrap();
assert_eq!(package.rid_next, 6);
}
#[test]
fn part_tree_mut_promotes_raw_to_xml_and_marks_touched() {
let mut package = Package::from_bytes(&minimal_xlsx()).unwrap();
assert!(package.touched_parts().is_empty());
assert!(package.part_tree_ref("xl/workbook.xml").is_none());
let tree = package.part_tree_mut("xl/workbook.xml").unwrap();
let root = tree.root_element().unwrap();
tree.set_attr(root, b"edited", b"1").unwrap();
assert_eq!(package.touched_parts(), vec!["xl/workbook.xml".to_string()]);
assert!(package.part_tree_ref("xl/workbook.xml").is_some());
let bytes = package.part_bytes("xl/workbook.xml");
assert!(bytes.is_none(), "a promoted part cannot borrow fresh bytes");
}
#[test]
fn part_tree_mut_failed_parse_leaves_touched_unchanged() {
let mut package = Package::from_bytes(&minimal_xlsx()).unwrap();
package.set_part("xl/broken.xml", b"<a><b>".to_vec(), None);
package.touched.clear();
let err = package.part_tree_mut("xl/broken.xml");
assert!(err.is_err());
assert!(package.touched_parts().is_empty());
assert_eq!(package.part_bytes("xl/broken.xml"), Some(&b"<a><b>"[..]));
}
#[test]
fn part_tree_ref_never_promotes() {
let package = Package::from_bytes(&minimal_xlsx()).unwrap();
assert!(package.part_tree_ref("xl/workbook.xml").is_none());
assert!(package.touched_parts().is_empty());
}
#[test]
fn part_tree_mut_missing_part_errors() {
let mut package = Package::from_bytes(&minimal_xlsx()).unwrap();
assert!(package.part_tree_mut("xl/missing.xml").is_err());
}
#[test]
fn set_part_adds_new_part_and_ensures_content_type() {
let mut package = Package::from_bytes(&minimal_xlsx()).unwrap();
package.set_part("xl/styles.xml", b"<styleSheet/>".to_vec(), Some(CT_STYLES));
assert!(package.has_part("xl/styles.xml"));
assert_eq!(
package.part_bytes("xl/styles.xml"),
Some(&b"<styleSheet/>"[..])
);
assert!(package
.touched_parts()
.contains(&"xl/styles.xml".to_string()));
assert!(package
.touched_parts()
.contains(&"[Content_Types].xml".to_string()));
let saved = package.to_bytes().unwrap();
let ct = Package::from_bytes(&saved)
.unwrap()
.part_bytes(CONTENT_TYPES)
.map(|b| String::from_utf8_lossy(b).into_owned())
.unwrap();
assert!(ct.contains(CT_STYLES));
assert!(ct.contains("/xl/styles.xml"));
}
#[test]
fn set_part_case_insensitive_preserves_original_casing() {
let mut package = Package::from_bytes(&minimal_xlsx()).unwrap();
package.set_part(
"XL/Workbook.XML",
b"<workbook edited=\"1\"/>".to_vec(),
None,
);
assert_eq!(package.touched_parts(), vec!["xl/workbook.xml".to_string()]);
assert_eq!(
package.part_bytes("xl/workbook.xml"),
Some(&b"<workbook edited=\"1\"/>"[..])
);
}
#[test]
fn has_part_is_case_and_slash_insensitive() {
let package = Package::from_bytes(&minimal_xlsx()).unwrap();
assert!(package.has_part("xl/workbook.xml"));
assert!(package.has_part("/xl/workbook.xml"));
assert!(package.has_part("XL/WORKBOOK.XML"));
assert!(package.has_part(r"xl\workbook.xml"));
assert!(!package.has_part("xl/missing.xml"));
}
#[test]
fn touched_parts_returns_sorted_touched_set() {
let mut package = Package::from_bytes(&minimal_xlsx()).unwrap();
package.set_part("xl/z.xml", b"<a/>".to_vec(), None);
package.set_part("xl/a.xml", b"<a/>".to_vec(), None);
assert_eq!(
package.touched_parts(),
vec!["xl/a.xml".to_string(), "xl/z.xml".to_string()]
);
}
#[test]
fn ensure_content_type_is_idempotent_and_marks_content_types_touched() {
let mut package = Package::from_bytes(&minimal_xlsx()).unwrap();
package.ensure_content_type("xl/workbook.xml", CT_WORKBOOK);
assert!(
package.touched_parts().is_empty(),
"no-op must not touch anything"
);
package.ensure_content_type("xl/sharedStrings.xml", CT_SST);
assert_eq!(
package.touched_parts(),
vec!["[Content_Types].xml".to_string()]
);
let before = package.to_bytes().unwrap();
package.ensure_content_type("xl/sharedStrings.xml", CT_SST);
let after = package.to_bytes().unwrap();
assert_eq!(before, after);
}
#[test]
fn ensure_content_type_replaces_a_conflicting_existing_override() {
let mut package = Package::from_bytes(&minimal_xlsx()).unwrap();
package.ensure_content_type("xl/workbook.xml", CT_CORE_PROPS); let bytes = package.to_bytes().unwrap();
let ct = Package::from_bytes(&bytes)
.unwrap()
.part_bytes(CONTENT_TYPES)
.map(|b| String::from_utf8_lossy(b).into_owned())
.unwrap();
assert!(ct.contains(CT_CORE_PROPS));
assert!(!ct.contains(CT_WORKBOOK));
assert_eq!(ct.matches("PartName=\"/xl/workbook.xml\"").count(), 1);
}
#[test]
fn resolve_rel_target_handles_relative_dotdot_and_absolute() {
assert_eq!(
Package::resolve_rel_target("xl/workbook.xml", "worksheets/sheet1.xml"),
"xl/worksheets/sheet1.xml"
);
assert_eq!(
Package::resolve_rel_target("xl/worksheets/sheet1.xml", "../styles.xml"),
"xl/styles.xml"
);
assert_eq!(
Package::resolve_rel_target("xl/workbook.xml", "/xl/styles.xml"),
"xl/styles.xml"
);
assert_eq!(
Package::resolve_rel_target("", "xl/workbook.xml"),
"xl/workbook.xml"
);
}
#[test]
fn rel_target_computes_relative_or_absolute_path() {
assert_eq!(
Package::rel_target("xl/workbook.xml", "xl/worksheets/sheet1.xml"),
"worksheets/sheet1.xml"
);
assert_eq!(
Package::rel_target("xl/workbook.xml", "docProps/core.xml"),
"/docProps/core.xml"
);
assert_eq!(
Package::rel_target("", "xl/workbook.xml"),
"xl/workbook.xml"
);
}
#[test]
fn resolve_and_rel_target_round_trip() {
let target_str = Package::rel_target("xl/workbook.xml", "xl/worksheets/sheet1.xml");
assert_eq!(
Package::resolve_rel_target("xl/workbook.xml", &target_str),
"xl/worksheets/sheet1.xml"
);
}
#[test]
fn add_relationship_allocates_rid_and_regenerates_rels_part() {
let mut package = Package::from_bytes(&minimal_xlsx()).unwrap();
package.set_part("xl/styles.xml", b"<styleSheet/>".to_vec(), None);
let rid = package.add_relationship(
"xl/workbook.xml",
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles",
"styles.xml",
false,
);
assert_eq!(rid, "rId2");
let rels = package.relationships_of("xl/workbook.xml");
assert_eq!(rels.len(), 1);
assert_eq!(rels[0].id, rid);
assert_eq!(rels[0].target, "styles.xml");
assert!(!rels[0].external);
assert!(package
.touched_parts()
.contains(&"xl/_rels/workbook.xml.rels".to_string()));
let bytes = package.to_bytes().unwrap();
let reread = Package::from_bytes(&bytes).unwrap();
assert_eq!(reread.relationships_of("xl/workbook.xml").len(), 1);
}
#[test]
fn add_relationship_forces_content_types_regen_when_rels_default_was_injected() {
let input = zip_bytes(&[(
"[Content_Types].xml",
br#"<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/></Types>"#,
)]);
let mut package = Package::from_bytes(&input).unwrap();
assert!(package.ct_rels_injected);
package.set_part("xl/styles.xml", b"<styleSheet/>".to_vec(), None);
package.add_relationship("xl/workbook.xml", "t", "styles.xml", false);
assert!(package
.touched_parts()
.contains(&"[Content_Types].xml".to_string()));
let bytes = package.to_bytes().unwrap();
let ct = Package::from_bytes(&bytes)
.unwrap()
.part_bytes(CONTENT_TYPES)
.map(|b| String::from_utf8_lossy(b).into_owned())
.unwrap();
assert!(ct.contains("Extension=\"rels\""));
}
#[test]
fn relationships_of_root_package_reads_dot_rels() {
let package = Package::from_bytes(&minimal_xlsx()).unwrap();
let rels = package.relationships_of("");
assert_eq!(rels.len(), 1);
assert_eq!(rels[0].target, "xl/workbook.xml");
}
#[test]
fn add_relationship_after_direct_tree_edit_does_not_clobber_the_tree_edit() {
let mut package = Package::from_bytes(&minimal_xlsx()).unwrap();
let tree = package.part_tree_mut("_rels/.rels").unwrap();
let root = tree.root_element().unwrap();
let idx = tree.children_of(root).len();
tree.insert_fragment_at(
root,
idx,
br#"<Relationship Id="rIdTreeEdit" Type="t" Target="xl/workbook.xml"/>"#,
)
.unwrap();
let live = package.part_tree_ref("_rels/.rels").unwrap().serialize();
assert!(
String::from_utf8_lossy(&live).contains("rIdTreeEdit"),
"tree edit must be live before add_relationship is called"
);
let rid = package.add_relationship("", "t2", "xl/workbook.xml", false);
let bytes = package.to_bytes().unwrap();
let reread = Package::from_bytes(&bytes).unwrap();
let rels = reread.relationships_of("");
assert!(
rels.iter().any(|r| r.id == "rIdTreeEdit"),
"the direct tree edit must survive to_bytes() output, got {rels:?}"
);
assert!(
rels.iter().any(|r| r.id == rid),
"the add_relationship-added relationship must also be present, got {rels:?}"
);
}
#[test]
fn update_relationship_target_keeps_promoted_tree_and_cache_in_sync() {
let mut package = Package::from_bytes(&minimal_xlsx()).unwrap();
let tree = package.part_tree_mut("_rels/.rels").unwrap();
let root = tree.root_element().unwrap();
let relationship = tree
.children_of(root)
.iter()
.copied()
.find(|&node| tree.attr_value(node, b"Id") == Some(b"rId1"))
.unwrap();
tree.set_attr(relationship, b"Custom", b"preserved")
.unwrap();
assert!(package
.update_relationship_target("", "rId1", "https://example.com/book", true)
.unwrap());
let live = package.part_tree_ref("_rels/.rels").unwrap().serialize();
let live = String::from_utf8(live).unwrap();
assert!(live.contains(r#"Custom="preserved""#));
assert!(live.contains(r#"Target="https://example.com/book""#));
assert!(live.contains(r#"TargetMode="External""#));
let bytes = package.to_bytes().unwrap();
let reread = Package::from_bytes(&bytes).unwrap();
let relationship = &reread.relationships_of("")[0];
assert_eq!(relationship.id, "rId1");
assert_eq!(relationship.target, "https://example.com/book");
assert!(relationship.external);
}
#[test]
fn ensure_content_type_after_direct_tree_edit_does_not_clobber_the_tree_edit() {
let mut package = Package::from_bytes(&minimal_xlsx()).unwrap();
let tree = package.part_tree_mut(CONTENT_TYPES).unwrap();
let root = tree.root_element().unwrap();
let idx = tree.children_of(root).len();
tree.insert_fragment_at(
root,
idx,
br#"<Override PartName="/xl/treeEdit.xml" ContentType="application/tree-edit"/>"#,
)
.unwrap();
let live = package.part_tree_ref(CONTENT_TYPES).unwrap().serialize();
assert!(
String::from_utf8_lossy(&live).contains("treeEdit"),
"tree edit must be live before ensure_content_type is called"
);
package.ensure_content_type("xl/sharedStrings.xml", CT_SST);
let bytes = package.to_bytes().unwrap();
let ct = Package::from_bytes(&bytes)
.unwrap()
.part_bytes(CONTENT_TYPES)
.map(|b| String::from_utf8_lossy(b).into_owned())
.unwrap();
assert!(
ct.contains("treeEdit"),
"the direct tree edit must survive to_bytes() output, got {ct:?}"
);
assert!(
ct.contains("/xl/sharedStrings.xml") && ct.contains(CT_SST),
"the ensure_content_type-added override must also be present, got {ct:?}"
);
}
#[test]
fn to_bytes_rejects_touched_part_with_unresolvable_content_type() {
let mut package = Package::from_bytes(&zip_bytes(&[(
"[Content_Types].xml",
br#"<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/></Types>"#,
)]))
.unwrap();
package.replace_part_or_add("xl/unknownpart", b"data".to_vec());
assert!(package.to_bytes().is_err());
}
#[test]
fn to_bytes_allows_untouched_part_with_unresolvable_content_type() {
let input = zip_bytes(&[
(
"[Content_Types].xml",
br#"<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/></Types>"#,
),
("xl/mystery", b"untouched original data"),
]);
let package = Package::from_bytes(&input).unwrap();
let bytes = package.to_bytes().unwrap();
let reread = Package::from_bytes(&bytes).unwrap();
assert_eq!(
reread.part_bytes("xl/mystery"),
Some(&b"untouched original data"[..])
);
}
#[test]
fn to_bytes_rejects_touched_rels_with_dangling_target() {
let mut package = Package::from_bytes(&minimal_xlsx()).unwrap();
package.add_relationship("xl/workbook.xml", "t", "worksheets/missing.xml", false);
assert!(package.to_bytes().is_err());
}
#[test]
fn to_bytes_allows_untouched_rels_with_preexisting_dangling_target() {
let input = zip_bytes(&[
(
"[Content_Types].xml",
br#"<?xml version="1.0"?><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>"#,
),
(
"xl/_rels/workbook.xml.rels",
br#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="t" Target="worksheets/missing.xml"/></Relationships>"#,
),
("xl/workbook.xml", b"<workbook/>"),
]);
let package = Package::from_bytes(&input).unwrap();
assert!(package.to_bytes().is_ok());
}
#[test]
fn to_bytes_rejects_over_budget_entry_count_on_regenerated_output() {
let mut package = Package::from_bytes(&minimal_xlsx()).unwrap();
set_test_max_entries(3); package.set_part("xl/extra.xml", b"<a/>".to_vec(), None);
let result = package.to_bytes();
reset_test_max_entries();
assert!(result.is_err());
}
#[test]
fn to_bytes_parts_not_in_order_are_appended_alphabetically() {
let mut package = Package::from_bytes(&minimal_xlsx()).unwrap();
package
.parts
.insert("xl/z.xml".to_string(), Part::Raw(b"<a/>".to_vec()));
package
.parts
.insert("xl/a.xml".to_string(), Part::Raw(b"<a/>".to_vec()));
let bytes = package.to_bytes().unwrap();
let names: Vec<String> = {
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
(0..zip.len())
.map(|i| zip.by_index(i).unwrap().name().to_string())
.collect()
};
let a_pos = names.iter().position(|n| n == "xl/a.xml").unwrap();
let z_pos = names.iter().position(|n| n == "xl/z.xml").unwrap();
assert!(
a_pos < z_pos,
"extra parts must be appended alphabetically: {names:?}"
);
}
impl Package {
fn replace_part_or_add(&mut self, name: &str, bytes: Vec<u8>) {
self.set_part(name, bytes, None);
}
}
}