Skip to main content

oxml_opc/
package.rs

1//! OPC Package reader and writer for ZIP-based OOXML packages.
2
3use std::collections::HashMap;
4use std::io::{Read, Seek, SeekFrom, Write};
5use std::path::Path;
6
7use zip::ZipWriter;
8use zip::read::ZipArchive;
9use zip::write::SimpleFileOptions;
10
11use crate::content_types::ContentTypes;
12use crate::error::{OpcError, Result};
13use crate::relationship::{Relationships, rel_types};
14
15const EOCD_SIGNATURE: [u8; 4] = [0x50, 0x4b, 0x05, 0x06];
16const ZIP64_EOCD_LOCATOR_SIGNATURE: [u8; 4] = [0x50, 0x4b, 0x06, 0x07];
17const ZIP64_EOCD_SIGNATURE: [u8; 4] = [0x50, 0x4b, 0x06, 0x06];
18const MAX_ZIP_COMMENT_LEN: u64 = u16::MAX as u64;
19const EOCD_LEN: u64 = 22;
20const ZIP64_EOCD_LOCATOR_LEN: u64 = 20;
21const MIN_ZIP64_EOCD_LEN: u64 = 56;
22const ZIP_TAIL_LEN: u64 =
23    MAX_ZIP_COMMENT_LEN + EOCD_LEN + ZIP64_EOCD_LOCATOR_LEN + MIN_ZIP64_EOCD_LEN;
24
25/// A single part within the OPC package.
26#[derive(Debug, Clone)]
27pub struct PackagePart {
28    /// Part name (URI), e.g. "/word/document.xml"
29    pub name: String,
30    /// Raw bytes of the part content
31    pub data: Vec<u8>,
32}
33
34/// Resource limits applied while expanding an OPC ZIP archive.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct PackageReadLimits {
37    pub max_entries: usize,
38    pub max_part_uncompressed_bytes: u64,
39    pub max_total_uncompressed_bytes: u64,
40}
41
42impl PackageReadLimits {
43    pub const UNBOUNDED: Self = Self {
44        max_entries: usize::MAX,
45        max_part_uncompressed_bytes: u64::MAX,
46        max_total_uncompressed_bytes: u64::MAX,
47    };
48}
49
50/// An in-memory representation of an OPC package (ZIP archive).
51#[derive(Debug, Clone)]
52pub struct OpcPackage {
53    /// Content types from `[Content_Types].xml`
54    pub content_types: ContentTypes,
55    /// Package-level relationships from `_rels/.rels`
56    pub package_rels: Relationships,
57    /// Part-level relationships keyed by the part they belong to.
58    /// Key is the part name (e.g. "/word/document.xml"),
59    /// value is the parsed relationships.
60    pub part_rels: HashMap<String, Relationships>,
61    /// All parts keyed by their URI (e.g. "/word/document.xml").
62    pub parts: HashMap<String, Vec<u8>>,
63    #[cfg(feature = "digital-signatures")]
64    pub(crate) signature_source: Option<crate::signature::SignatureSource>,
65}
66
67impl OpcPackage {
68    /// Open an OPC package from a file path.
69    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
70        let file = std::fs::File::open(path)?;
71        Self::from_reader(file)
72    }
73
74    /// Open an OPC package from any reader that implements Read + Seek.
75    pub fn from_reader<R: Read + Seek>(reader: R) -> Result<Self> {
76        Self::from_reader_with_limits(reader, PackageReadLimits::UNBOUNDED)
77    }
78
79    /// Open an agile-encrypted OPC package with no archive-expansion limit.
80    #[cfg(feature = "agile-encryption")]
81    pub fn from_encrypted_reader<R: Read + Seek>(reader: R, password: &str) -> Result<Self> {
82        Self::from_encrypted_reader_with_limits(reader, password, PackageReadLimits::UNBOUNDED)
83    }
84
85    /// Authenticate and open an agile-encrypted OPC package with expansion limits.
86    #[cfg(feature = "agile-encryption")]
87    pub fn from_encrypted_reader_with_limits<R: Read + Seek>(
88        reader: R,
89        password: &str,
90        limits: PackageReadLimits,
91    ) -> Result<Self> {
92        let plaintext = crate::encryption::decrypt_package(reader, password, limits)?;
93        Self::from_reader_with_limits(std::io::Cursor::new(plaintext), limits)
94    }
95
96    /// Open an OPC package while bounding archive expansion.
97    pub fn from_reader_with_limits<R: Read + Seek>(
98        mut reader: R,
99        limits: PackageReadLimits,
100    ) -> Result<Self> {
101        if limits.max_entries != usize::MAX {
102            let entry_count = pre_index_entry_count(&mut reader)?;
103            if entry_count > limits.max_entries as u64 {
104                return Err(OpcError::PackageLimitExceeded {
105                    kind: "entry count",
106                    limit: limits.max_entries as u64,
107                });
108            }
109        }
110
111        let mut archive = ZipArchive::new(reader)?;
112        if archive.len() > limits.max_entries {
113            return Err(OpcError::PackageLimitExceeded {
114                kind: "entry count",
115                limit: limits.max_entries as u64,
116            });
117        }
118        let mut raw_parts: HashMap<String, Vec<u8>> = HashMap::new();
119        let mut total_uncompressed_bytes = 0_u64;
120
121        // Read all entries from the ZIP
122        for i in 0..archive.len() {
123            let mut entry = archive.by_index(i)?;
124            if entry.is_dir() {
125                continue;
126            }
127            if entry.size() > limits.max_part_uncompressed_bytes {
128                return Err(OpcError::PackageLimitExceeded {
129                    kind: "part size",
130                    limit: limits.max_part_uncompressed_bytes,
131                });
132            }
133            if total_uncompressed_bytes
134                .checked_add(entry.size())
135                .is_none_or(|total| total > limits.max_total_uncompressed_bytes)
136            {
137                return Err(OpcError::PackageLimitExceeded {
138                    kind: "total uncompressed size",
139                    limit: limits.max_total_uncompressed_bytes,
140                });
141            }
142            let name = normalize_part_name(entry.name());
143            let name = name.strip_prefix('/').unwrap_or(&name).to_string();
144            let mut data = Vec::new();
145            let read_limit = limits
146                .max_part_uncompressed_bytes
147                .min(limits.max_total_uncompressed_bytes - total_uncompressed_bytes);
148            entry
149                .by_ref()
150                .take(read_limit.saturating_add(1))
151                .read_to_end(&mut data)?;
152            let data_len = data.len() as u64;
153            if data_len > limits.max_part_uncompressed_bytes {
154                return Err(OpcError::PackageLimitExceeded {
155                    kind: "part size",
156                    limit: limits.max_part_uncompressed_bytes,
157                });
158            }
159            total_uncompressed_bytes = total_uncompressed_bytes
160                .checked_add(data_len)
161                .filter(|total| *total <= limits.max_total_uncompressed_bytes)
162                .ok_or(OpcError::PackageLimitExceeded {
163                    kind: "total uncompressed size",
164                    limit: limits.max_total_uncompressed_bytes,
165                })?;
166            raw_parts.insert(name, data);
167        }
168
169        // Parse [Content_Types].xml
170        let ct_xml = raw_parts
171            .get("[Content_Types].xml")
172            .ok_or_else(|| OpcError::PartNotFound("[Content_Types].xml".into()))?;
173        let content_types = ContentTypes::from_xml(ct_xml)?;
174
175        // Parse package-level relationships: _rels/.rels
176        let package_rels = if let Some(rels_xml) = raw_parts.get("_rels/.rels") {
177            Relationships::from_xml(rels_xml)?
178        } else {
179            Relationships::new()
180        };
181
182        // Parse all part-level .rels files
183        let mut part_rels = HashMap::new();
184        let rels_entries: Vec<String> = raw_parts
185            .keys()
186            .filter(|k| k.ends_with(".rels") && *k != "_rels/.rels")
187            .cloned()
188            .collect();
189
190        for rels_path in rels_entries {
191            if let Some(xml_data) = raw_parts.get(&rels_path) {
192                let rels = Relationships::from_xml(xml_data)?;
193                // Convert rels path to the part name it belongs to.
194                // e.g. "word/_rels/document.xml.rels" → "/word/document.xml"
195                let part_name = rels_path_to_part_name(&rels_path);
196                part_rels.insert(part_name, rels);
197            }
198        }
199
200        // Build parts map with leading "/" normalized
201        let mut parts = HashMap::new();
202        for (name, data) in &raw_parts {
203            if name == "[Content_Types].xml" || name == "_rels/.rels" || name.ends_with(".rels") {
204                continue;
205            }
206            let normalized = if name.starts_with('/') {
207                name.clone()
208            } else {
209                format!("/{name}")
210            };
211            parts.insert(normalized, data.clone());
212        }
213
214        Ok(OpcPackage {
215            content_types,
216            package_rels,
217            part_rels,
218            parts,
219            #[cfg(feature = "digital-signatures")]
220            signature_source: Some(crate::signature::SignatureSource {
221                content_types_xml: ct_xml.clone(),
222                content_types: ContentTypes::from_xml(ct_xml)?,
223            }),
224        })
225    }
226
227    /// Save the OPC package to a file path.
228    pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
229        let file = std::fs::File::create(path)?;
230        self.write_to(file)
231    }
232
233    /// Write the OPC package to any writer.
234    pub fn write_to<W: Write + Seek>(&self, writer: W) -> Result<()> {
235        let mut zip = ZipWriter::new(writer);
236        let options =
237            SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
238
239        // Write [Content_Types].xml
240        #[cfg(feature = "digital-signatures")]
241        let ct_xml = crate::signature::content_types_bytes(self)?;
242        #[cfg(not(feature = "digital-signatures"))]
243        let ct_xml = self.content_types.to_xml()?;
244        zip.start_file("[Content_Types].xml", options)?;
245        zip.write_all(&ct_xml)?;
246
247        // Write _rels/.rels
248        let pkg_rels_xml = self.package_rels.to_xml()?;
249        zip.start_file("_rels/.rels", options)?;
250        zip.write_all(&pkg_rels_xml)?;
251
252        // Write part-level .rels files. Both loops iterate in sorted order so
253        // that saving the same package twice produces byte-identical output;
254        // a HashMap's order would vary between runs.
255        let mut rels_names: Vec<&String> = self.part_rels.keys().collect();
256        rels_names.sort_unstable();
257        for part_name in rels_names {
258            let rels = &self.part_rels[part_name];
259            let rels_path = part_name_to_rels_path(part_name);
260            let rels_xml = rels.to_xml()?;
261            zip.start_file(&rels_path, options)?;
262            zip.write_all(&rels_xml)?;
263        }
264
265        // Write all parts
266        let mut part_names: Vec<&String> = self.parts.keys().collect();
267        part_names.sort_unstable();
268        for name in part_names {
269            // Strip leading "/" for ZIP entry name
270            let zip_name = name.strip_prefix('/').unwrap_or(name);
271            zip.start_file(zip_name, options)?;
272            zip.write_all(&self.parts[name])?;
273        }
274
275        zip.finish()?;
276        Ok(())
277    }
278
279    /// Append a password-protected CFB envelope around the deterministic OPC ZIP.
280    ///
281    /// The output buffer is unchanged if package serialization, encryption, or
282    /// allocation fails.
283    #[cfg(feature = "agile-encryption")]
284    pub fn write_encrypted_to(&self, output: &mut Vec<u8>, password: &str) -> Result<()> {
285        let mut plaintext = std::io::Cursor::new(Vec::new());
286        self.write_to(&mut plaintext)?;
287        crate::encryption::write_encrypted_package(output, &plaintext.into_inner(), password)
288    }
289
290    /// Get raw bytes of a part by its URI.
291    pub fn get_part(&self, part_name: &str) -> Option<&[u8]> {
292        self.parts.get(part_name).map(|v| v.as_slice())
293    }
294
295    /// Set (or replace) a part's raw bytes.
296    pub fn set_part(&mut self, part_name: &str, data: Vec<u8>) {
297        self.parts.insert(part_name.to_string(), data);
298    }
299
300    /// Verify every digital signature discovered through the OPC relationship graph.
301    ///
302    /// Cryptographic validity authenticates the embedded certificate's key. It does
303    /// not establish that the certificate chains to a trusted root. Certificate
304    /// trust remains caller policy.
305    #[cfg(feature = "digital-signatures")]
306    pub fn verify_signatures(&self) -> Result<Vec<crate::SignatureReport>> {
307        crate::signature::verify_signatures(self)
308    }
309
310    /// Sign a cloned candidate package with the strict RSA-SHA256 profile.
311    ///
312    /// The private key must be PKCS#8 DER and the certificate must be X.509
313    /// DER. The live package changes only after the candidate verifies with
314    /// complete declared coverage. Certificate-chain trust remains caller
315    /// policy.
316    #[cfg(feature = "digital-signatures")]
317    pub fn sign(
318        &mut self,
319        private_key_pkcs8_der: &[u8],
320        certificate_der: &[u8],
321    ) -> Result<crate::SignatureReport> {
322        let mut candidate = self.clone();
323        let report = crate::signature::create_signature(
324            &mut candidate,
325            private_key_pkcs8_der,
326            certificate_der,
327        )?;
328        *self = candidate;
329        Ok(report)
330    }
331
332    /// Get the relationships for a specific part.
333    pub fn get_part_rels(&self, part_name: &str) -> Option<&Relationships> {
334        self.part_rels.get(part_name)
335    }
336
337    /// Get or create the relationships for a specific part.
338    pub fn get_or_create_part_rels(&mut self, part_name: &str) -> &mut Relationships {
339        self.part_rels.entry(part_name.to_string()).or_default()
340    }
341
342    /// Resolve the target URI of a relationship relative to its source part.
343    ///
344    /// `.` and `..` segments are collapsed, so a target such as
345    /// `../media/image1.png` on `/word/charts/chart1.xml` resolves to
346    /// `/media/image1.png` and matches the key it is stored under. Without
347    /// this, parts referenced through a parent directory could never be found.
348    pub fn resolve_rel_target(source_part: &str, rel_target: &str) -> String {
349        let joined = if rel_target.starts_with('/') {
350            rel_target.to_string()
351        } else {
352            // Directory of the source part, including the trailing slash.
353            let dir = match source_part.rfind('/') {
354                Some(pos) => &source_part[..=pos],
355                None => "/",
356            };
357            format!("{dir}{rel_target}")
358        };
359        normalize_part_name(&joined)
360    }
361
362    /// Find the main document part URI by looking at package relationships.
363    pub fn main_document_part(&self) -> Option<String> {
364        self.package_rels
365            .get_by_type(rel_types::DOCUMENT)
366            .map(|rel| {
367                if rel.target.starts_with('/') {
368                    rel.target.clone()
369                } else {
370                    format!("/{}", rel.target)
371                }
372            })
373    }
374
375    /// Create an empty package with the universal OPC content types.
376    pub fn new() -> Self {
377        OpcPackage {
378            content_types: ContentTypes::minimal(),
379            package_rels: Relationships::new(),
380            part_rels: HashMap::new(),
381            parts: HashMap::new(),
382            #[cfg(feature = "digital-signatures")]
383            signature_source: None,
384        }
385    }
386
387    /// Create a package whose office document relationship targets a main part.
388    pub fn with_main_part(part_name: &str, content_type: &str) -> Self {
389        let mut package = Self::new();
390        let part_name = part_name.strip_prefix('/').unwrap_or(part_name);
391        package.package_rels.add(rel_types::DOCUMENT, part_name);
392        package
393            .content_types
394            .add_override(&format!("/{part_name}"), content_type);
395        package
396    }
397}
398
399fn pre_index_entry_count<R: Read + Seek>(reader: &mut R) -> Result<u64> {
400    let file_len = reader.seek(SeekFrom::End(0))?;
401    if file_len < EOCD_LEN {
402        return Err(invalid_zip("end of central directory not found"));
403    }
404
405    let tail_len = file_len.min(ZIP_TAIL_LEN);
406    let tail_start = file_len - tail_len;
407    reader.seek(SeekFrom::Start(tail_start))?;
408    let mut tail = vec![0; tail_len as usize];
409    reader.read_exact(&mut tail)?;
410
411    let last_candidate = tail.len() - EOCD_LEN as usize;
412    let entry_count = (0..=last_candidate)
413        .rev()
414        .filter(|&offset| tail[offset..].starts_with(&EOCD_SIGNATURE))
415        .filter_map(|offset| eocd_entry_count(&tail, tail_start, file_len, offset))
416        .max();
417
418    entry_count.ok_or_else(|| invalid_zip("valid end of central directory not found"))
419}
420
421fn eocd_entry_count(tail: &[u8], tail_start: u64, file_len: u64, offset: usize) -> Option<u64> {
422    let eocd = tail.get(offset..offset.checked_add(EOCD_LEN as usize)?)?;
423    let comment_len = u64::from(read_u16(eocd, 20)?);
424    let absolute_offset = tail_start.checked_add(offset as u64)?;
425    if absolute_offset
426        .checked_add(EOCD_LEN)?
427        .checked_add(comment_len)?
428        != file_len
429    {
430        return None;
431    }
432
433    let disk_number = read_u16(eocd, 4)?;
434    let central_directory_disk = read_u16(eocd, 6)?;
435    if disk_number != 0 || central_directory_disk != 0 {
436        return None;
437    }
438
439    let entries_on_disk = read_u16(eocd, 8)?;
440    let total_entries = read_u16(eocd, 10)?;
441    let central_directory_size = read_u32(eocd, 12)?;
442    let central_directory_offset = read_u32(eocd, 16)?;
443    let uses_zip64 = entries_on_disk == u16::MAX
444        || total_entries == u16::MAX
445        || central_directory_size == u32::MAX
446        || central_directory_offset == u32::MAX;
447
448    if uses_zip64 {
449        return zip64_entry_count(tail, tail_start, absolute_offset);
450    }
451    if entries_on_disk != total_entries
452        || !central_directory_bounds_are_plausible(
453            absolute_offset,
454            u64::from(central_directory_offset),
455            u64::from(central_directory_size),
456        )
457    {
458        return None;
459    }
460
461    Some(u64::from(total_entries))
462}
463
464fn zip64_entry_count(tail: &[u8], tail_start: u64, eocd_offset: u64) -> Option<u64> {
465    let locator_offset = eocd_offset.checked_sub(ZIP64_EOCD_LOCATOR_LEN)?;
466    let locator = tail_slice(tail, tail_start, locator_offset, ZIP64_EOCD_LOCATOR_LEN)?;
467    if !locator.starts_with(&ZIP64_EOCD_LOCATOR_SIGNATURE)
468        || read_u32(locator, 4)? != 0
469        || read_u32(locator, 16)? != 1
470    {
471        return None;
472    }
473
474    let zip64_offset = read_u64(locator, 8)?;
475    let zip64 = tail_slice(tail, tail_start, zip64_offset, MIN_ZIP64_EOCD_LEN)?;
476    if !zip64.starts_with(&ZIP64_EOCD_SIGNATURE) {
477        return None;
478    }
479    let record_size = read_u64(zip64, 4)?;
480    if record_size < 44
481        || zip64_offset.checked_add(12)?.checked_add(record_size)? != locator_offset
482        || read_u32(zip64, 16)? != 0
483        || read_u32(zip64, 20)? != 0
484    {
485        return None;
486    }
487
488    let entries_on_disk = read_u64(zip64, 24)?;
489    let total_entries = read_u64(zip64, 32)?;
490    if entries_on_disk != total_entries
491        || !central_directory_bounds_are_plausible(
492            zip64_offset,
493            read_u64(zip64, 48)?,
494            read_u64(zip64, 40)?,
495        )
496    {
497        return None;
498    }
499
500    Some(total_entries)
501}
502
503fn central_directory_bounds_are_plausible(
504    directory_end: u64,
505    relative_directory_offset: u64,
506    directory_size: u64,
507) -> bool {
508    directory_end
509        .checked_sub(directory_size)
510        .is_some_and(|actual_directory_offset| relative_directory_offset <= actual_directory_offset)
511}
512
513fn tail_slice(tail: &[u8], tail_start: u64, absolute_offset: u64, len: u64) -> Option<&[u8]> {
514    let offset = usize::try_from(absolute_offset.checked_sub(tail_start)?).ok()?;
515    let len = usize::try_from(len).ok()?;
516    tail.get(offset..offset.checked_add(len)?)
517}
518
519fn read_u16(bytes: &[u8], offset: usize) -> Option<u16> {
520    Some(u16::from_le_bytes(
521        bytes.get(offset..offset.checked_add(2)?)?.try_into().ok()?,
522    ))
523}
524
525fn read_u32(bytes: &[u8], offset: usize) -> Option<u32> {
526    Some(u32::from_le_bytes(
527        bytes.get(offset..offset.checked_add(4)?)?.try_into().ok()?,
528    ))
529}
530
531fn read_u64(bytes: &[u8], offset: usize) -> Option<u64> {
532    Some(u64::from_le_bytes(
533        bytes.get(offset..offset.checked_add(8)?)?.try_into().ok()?,
534    ))
535}
536
537fn invalid_zip(detail: &'static str) -> OpcError {
538    OpcError::Zip(zip::result::ZipError::InvalidArchive(detail.into()))
539}
540
541impl Default for OpcPackage {
542    fn default() -> Self {
543        Self::new()
544    }
545}
546
547#[cfg(test)]
548fn docx_package() -> OpcPackage {
549    let mut package = OpcPackage::with_main_part(
550        "word/document.xml",
551        "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml",
552    );
553    package.content_types.add_override(
554        "/word/styles.xml",
555        "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml",
556    );
557    package
558}
559
560/// Collapse `.` and `..` segments in an absolute part name.
561///
562/// A `..` that would escape the package root is dropped, matching how OPC
563/// consumers treat over-long parent traversals.
564fn normalize_part_name(path: &str) -> String {
565    if !path.contains("./") && !path.ends_with("/.") && !path.ends_with("/..") {
566        return path.to_string();
567    }
568
569    let mut segments: Vec<&str> = Vec::new();
570    for segment in path.split('/') {
571        match segment {
572            "" | "." => {}
573            ".." => {
574                segments.pop();
575            }
576            other => segments.push(other),
577        }
578    }
579
580    let mut out = String::with_capacity(path.len());
581    for segment in segments {
582        out.push('/');
583        out.push_str(segment);
584    }
585    if out.is_empty() { "/".to_string() } else { out }
586}
587
588/// Convert a .rels file path to the part name it belongs to.
589/// e.g. "word/_rels/document.xml.rels" → "/word/document.xml"
590fn rels_path_to_part_name(rels_path: &str) -> String {
591    // Strip the ".rels" suffix once (`trim_end_matches` would strip repeats,
592    // turning "a.rels.rels" into "a") and drop only the "_rels" path segment
593    // that directly precedes the file name.
594    let without_suffix = rels_path.strip_suffix(".rels").unwrap_or(rels_path);
595    let path = match without_suffix.rfind("_rels/") {
596        Some(pos) => format!(
597            "{}{}",
598            &without_suffix[..pos],
599            &without_suffix[pos + "_rels/".len()..]
600        ),
601        None => without_suffix.to_string(),
602    };
603    if path.starts_with('/') {
604        path
605    } else {
606        format!("/{path}")
607    }
608}
609
610/// Convert a part name to its .rels file path.
611/// e.g. "/word/document.xml" → "word/_rels/document.xml.rels"
612fn part_name_to_rels_path(part_name: &str) -> String {
613    let name = part_name.strip_prefix('/').unwrap_or(part_name);
614    if let Some(pos) = name.rfind('/') {
615        let dir = &name[..pos];
616        let file = &name[pos + 1..];
617        format!("{dir}/_rels/{file}.rels")
618    } else {
619        format!("_rels/{name}.rels")
620    }
621}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626
627    fn package_zip(entries: &[(&str, &[u8])]) -> std::io::Cursor<Vec<u8>> {
628        package_zip_with_comment(entries, &[])
629    }
630
631    fn package_zip_with_comment(
632        entries: &[(&str, &[u8])],
633        comment: &[u8],
634    ) -> std::io::Cursor<Vec<u8>> {
635        let mut buffer = std::io::Cursor::new(Vec::new());
636        {
637            let mut zip = ZipWriter::new(&mut buffer);
638            let options = SimpleFileOptions::default();
639            for (name, data) in entries {
640                zip.start_file(name, options).unwrap();
641                zip.write_all(data).unwrap();
642            }
643            zip.set_raw_comment(comment.to_vec().into_boxed_slice())
644                .unwrap();
645            zip.finish().unwrap();
646        }
647        buffer.set_position(0);
648        buffer
649    }
650
651    fn fake_eocd(total_entries: u16) -> [u8; EOCD_LEN as usize] {
652        let mut eocd = [0; EOCD_LEN as usize];
653        eocd[..4].copy_from_slice(&EOCD_SIGNATURE);
654        eocd[8..10].copy_from_slice(&total_entries.to_le_bytes());
655        eocd[10..12].copy_from_slice(&total_entries.to_le_bytes());
656        eocd
657    }
658
659    const MINIMAL_CONTENT_TYPES: &[u8] =
660        br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
661<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
662  <Default Extension="xml" ContentType="application/xml"/>
663</Types>"#;
664
665    #[test]
666    fn bounded_reader_rejects_too_many_entries() {
667        let archive = package_zip(&[
668            ("[Content_Types].xml", MINIMAL_CONTENT_TYPES),
669            ("word/document.xml", b"<document/>"),
670        ]);
671        let error = OpcPackage::from_reader_with_limits(
672            archive,
673            PackageReadLimits {
674                max_entries: 1,
675                max_part_uncompressed_bytes: 1_024,
676                max_total_uncompressed_bytes: 2_048,
677            },
678        )
679        .unwrap_err();
680
681        assert!(matches!(
682            error,
683            OpcError::PackageLimitExceeded {
684                kind: "entry count",
685                limit: 1
686            }
687        ));
688    }
689
690    #[test]
691    fn later_eocd_signature_in_comment_cannot_hide_real_entry_count() {
692        let mut archive = package_zip_with_comment(
693            &[
694                ("[Content_Types].xml", MINIMAL_CONTENT_TYPES),
695                ("word/document.xml", b"<document/>"),
696            ],
697            &fake_eocd(1),
698        );
699
700        assert_eq!(pre_index_entry_count(&mut archive).unwrap(), 2);
701    }
702
703    #[test]
704    fn truncated_zip64_metadata_returns_an_error_without_panicking() {
705        let mut bytes = vec![0; 4 + ZIP64_EOCD_LOCATOR_LEN as usize + EOCD_LEN as usize];
706        bytes[..4].copy_from_slice(&ZIP64_EOCD_SIGNATURE);
707
708        let locator_offset = 4;
709        bytes[locator_offset..locator_offset + 4].copy_from_slice(&ZIP64_EOCD_LOCATOR_SIGNATURE);
710        bytes[locator_offset + 8..locator_offset + 16].copy_from_slice(&0_u64.to_le_bytes());
711        bytes[locator_offset + 16..locator_offset + 20].copy_from_slice(&1_u32.to_le_bytes());
712
713        let eocd_offset = locator_offset + ZIP64_EOCD_LOCATOR_LEN as usize;
714        bytes[eocd_offset..eocd_offset + 4].copy_from_slice(&EOCD_SIGNATURE);
715        bytes[eocd_offset + 8..eocd_offset + 12].fill(0xff);
716        bytes[eocd_offset + 12..eocd_offset + 20].fill(0xff);
717
718        let error = pre_index_entry_count(&mut std::io::Cursor::new(bytes)).unwrap_err();
719        assert!(matches!(error, OpcError::Zip(_)));
720    }
721
722    #[test]
723    fn bounded_reader_rejects_oversized_parts_and_totals() {
724        let entries = [
725            ("[Content_Types].xml", MINIMAL_CONTENT_TYPES),
726            ("word/document.xml", b"<document/>".as_slice()),
727        ];
728        let part_error = OpcPackage::from_reader_with_limits(
729            package_zip(&entries),
730            PackageReadLimits {
731                max_entries: 8,
732                max_part_uncompressed_bytes: 16,
733                max_total_uncompressed_bytes: 1_024,
734            },
735        )
736        .unwrap_err();
737        assert!(matches!(
738            part_error,
739            OpcError::PackageLimitExceeded {
740                kind: "part size",
741                limit: 16
742            }
743        ));
744
745        let total_error = OpcPackage::from_reader_with_limits(
746            package_zip(&entries),
747            PackageReadLimits {
748                max_entries: 8,
749                max_part_uncompressed_bytes: 1_024,
750                max_total_uncompressed_bytes: MINIMAL_CONTENT_TYPES.len() as u64,
751            },
752        )
753        .unwrap_err();
754        assert!(matches!(
755            total_error,
756            OpcError::PackageLimitExceeded {
757                kind: "total uncompressed size",
758                ..
759            }
760        ));
761    }
762
763    fn independently_built_pptx() -> std::io::Cursor<Vec<u8>> {
764        const CONTENT_TYPES: &[u8] = br#"<?xml version="1.0" encoding="UTF-8"?>
765<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
766  <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
767  <Default Extension="xml" ContentType="application/xml"/>
768  <Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>
769  <Override PartName="/ppt/slides/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>
770  <Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>
771  <Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/>
772  <Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>
773</Types>"#;
774        const PACKAGE_RELS: &[u8] = br#"<?xml version="1.0" encoding="UTF-8"?>
775<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
776  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/>
777</Relationships>"#;
778        const PRESENTATION_RELS: &[u8] = br#"<?xml version="1.0" encoding="UTF-8"?>
779<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
780  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/>
781  <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/>
782</Relationships>"#;
783        const SLIDE_RELS: &[u8] = br#"<?xml version="1.0" encoding="UTF-8"?>
784<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
785  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
786</Relationships>"#;
787        const SLIDE_LAYOUT_RELS: &[u8] = br#"<?xml version="1.0" encoding="UTF-8"?>
788<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
789  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="../slideMasters/slideMaster1.xml"/>
790</Relationships>"#;
791        const SLIDE_MASTER_RELS: &[u8] = br#"<?xml version="1.0" encoding="UTF-8"?>
792<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
793  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
794  <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/>
795</Relationships>"#;
796        const PRESENTATION: &[u8] = br#"<?xml version="1.0" encoding="UTF-8"?>
797<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
798  <p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst>
799  <p:sldIdLst><p:sldId id="256" r:id="rId2"/></p:sldIdLst>
800  <p:sldSz cx="12192000" cy="6858000"/>
801  <p:notesSz cx="6858000" cy="9144000"/>
802</p:presentation>"#;
803        const SLIDE: &[u8] = br#"<?xml version="1.0" encoding="UTF-8"?>
804<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
805  <p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/></p:spTree></p:cSld>
806  <p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
807</p:sld>"#;
808        const SLIDE_LAYOUT: &[u8] = br#"<?xml version="1.0" encoding="UTF-8"?>
809<p:sldLayout xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
810  <p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/></p:spTree></p:cSld>
811  <p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
812</p:sldLayout>"#;
813        const SLIDE_MASTER: &[u8] = br#"<?xml version="1.0" encoding="UTF-8"?>
814<p:sldMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
815  <p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/></p:spTree></p:cSld>
816  <p:clrMap accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" bg1="lt1" bg2="lt2" folHlink="folHlink" hlink="hlink" tx1="dk1" tx2="dk2"/>
817  <p:sldLayoutIdLst><p:sldLayoutId id="2147483648" r:id="rId1"/></p:sldLayoutIdLst>
818  <p:txStyles><p:titleStyle/><p:bodyStyle/><p:otherStyle/></p:txStyles>
819</p:sldMaster>"#;
820        const THEME: &[u8] = br#"<?xml version="1.0" encoding="UTF-8"?>
821<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Minimal">
822  <a:themeElements>
823    <a:clrScheme name="Minimal">
824      <a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1>
825      <a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1>
826      <a:dk2><a:srgbClr val="1F497D"/></a:dk2>
827      <a:lt2><a:srgbClr val="EEECE1"/></a:lt2>
828      <a:accent1><a:srgbClr val="4F81BD"/></a:accent1>
829      <a:accent2><a:srgbClr val="C0504D"/></a:accent2>
830      <a:accent3><a:srgbClr val="9BBB59"/></a:accent3>
831      <a:accent4><a:srgbClr val="8064A2"/></a:accent4>
832      <a:accent5><a:srgbClr val="4BACC6"/></a:accent5>
833      <a:accent6><a:srgbClr val="F79646"/></a:accent6>
834      <a:hlink><a:srgbClr val="0000FF"/></a:hlink>
835      <a:folHlink><a:srgbClr val="800080"/></a:folHlink>
836    </a:clrScheme>
837    <a:fontScheme name="Minimal">
838      <a:majorFont><a:latin typeface="Arial"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont>
839      <a:minorFont><a:latin typeface="Arial"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont>
840    </a:fontScheme>
841    <a:fmtScheme name="Minimal">
842      <a:fillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:fillStyleLst>
843      <a:lnStyleLst><a:ln w="6350"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/></a:ln><a:ln w="12700"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/></a:ln><a:ln w="19050"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/></a:ln></a:lnStyleLst>
844      <a:effectStyleLst><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle></a:effectStyleLst>
845      <a:bgFillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:bgFillStyleLst>
846    </a:fmtScheme>
847  </a:themeElements>
848</a:theme>"#;
849
850        package_zip(&[
851            ("[Content_Types].xml", CONTENT_TYPES),
852            ("_rels/.rels", PACKAGE_RELS),
853            ("ppt/presentation.xml", PRESENTATION),
854            ("ppt/_rels/presentation.xml.rels", PRESENTATION_RELS),
855            ("ppt/slides/slide1.xml", SLIDE),
856            ("ppt/slides/_rels/slide1.xml.rels", SLIDE_RELS),
857            ("ppt/slideLayouts/slideLayout1.xml", SLIDE_LAYOUT),
858            (
859                "ppt/slideLayouts/_rels/slideLayout1.xml.rels",
860                SLIDE_LAYOUT_RELS,
861            ),
862            ("ppt/slideMasters/slideMaster1.xml", SLIDE_MASTER),
863            (
864                "ppt/slideMasters/_rels/slideMaster1.xml.rels",
865                SLIDE_MASTER_RELS,
866            ),
867            ("ppt/theme/theme1.xml", THEME),
868        ])
869    }
870
871    fn pptx_package() -> OpcPackage {
872        let mut package =
873            OpcPackage::with_main_part("ppt/presentation.xml", crate::content_types::PRESENTATION);
874        package
875            .content_types
876            .add_override("/ppt/slides/slide1.xml", crate::content_types::SLIDE);
877        package.content_types.add_override(
878            "/ppt/slideLayouts/slideLayout1.xml",
879            crate::content_types::SLIDE_LAYOUT,
880        );
881        package.set_part("/ppt/presentation.xml", b"<p:presentation/>".to_vec());
882        package.set_part("/ppt/slides/slide1.xml", b"<p:sld/>".to_vec());
883        package.set_part(
884            "/ppt/slideLayouts/slideLayout1.xml",
885            b"<p:sldLayout/>".to_vec(),
886        );
887        package
888            .get_or_create_part_rels("/ppt/presentation.xml")
889            .add(rel_types::SLIDE, "slides/slide1.xml");
890        package
891            .get_or_create_part_rels("/ppt/slides/slide1.xml")
892            .add(rel_types::SLIDE_LAYOUT, "../slideLayouts/slideLayout1.xml");
893        package
894    }
895
896    #[test]
897    fn with_main_part_resolves_and_round_trips() {
898        let mut package = OpcPackage::with_main_part(
899            "ppt/presentation.xml",
900            "application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml",
901        );
902        package.set_part("/ppt/presentation.xml", b"<p:presentation/>".to_vec());
903
904        assert_eq!(
905            package.main_document_part().as_deref(),
906            Some("/ppt/presentation.xml")
907        );
908        assert_eq!(
909            package
910                .content_types
911                .content_type_for("/ppt/presentation.xml"),
912            Some(
913                "application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"
914            )
915        );
916
917        let mut buffer = std::io::Cursor::new(Vec::new());
918        package.write_to(&mut buffer).unwrap();
919        buffer.set_position(0);
920        let round_tripped = OpcPackage::from_reader(buffer).unwrap();
921
922        assert_eq!(
923            round_tripped.main_document_part().as_deref(),
924            Some("/ppt/presentation.xml")
925        );
926        assert_eq!(
927            round_tripped.get_part("/ppt/presentation.xml"),
928            Some(b"<p:presentation/>".as_slice())
929        );
930    }
931
932    #[test]
933    fn pptx_package_resolves_main_slide_and_layout_parts() {
934        let package = pptx_package();
935        let mut buffer = std::io::Cursor::new(Vec::new());
936        package.write_to(&mut buffer).unwrap();
937        buffer.set_position(0);
938        let reopened = OpcPackage::from_reader(buffer).unwrap();
939
940        let presentation_part = reopened.main_document_part().unwrap();
941        assert_eq!(presentation_part, "/ppt/presentation.xml");
942
943        let slide_target = &reopened
944            .get_part_rels(&presentation_part)
945            .unwrap()
946            .get_by_type(rel_types::SLIDE)
947            .unwrap()
948            .target;
949        let slide_part = OpcPackage::resolve_rel_target(&presentation_part, slide_target);
950        assert_eq!(slide_part, "/ppt/slides/slide1.xml");
951
952        let layout_target = &reopened
953            .get_part_rels(&slide_part)
954            .unwrap()
955            .get_by_type(rel_types::SLIDE_LAYOUT)
956            .unwrap()
957            .target;
958        let layout_part = OpcPackage::resolve_rel_target(&slide_part, layout_target);
959        assert_eq!(layout_part, "/ppt/slideLayouts/slideLayout1.xml");
960
961        assert!(reopened.parts.contains_key(&presentation_part));
962        assert!(reopened.parts.contains_key(&slide_part));
963        assert!(reopened.parts.contains_key(&layout_part));
964    }
965
966    #[test]
967    fn independently_built_pptx_opens_and_resolves_relationships() {
968        let package = OpcPackage::from_reader(independently_built_pptx()).unwrap();
969
970        let presentation_part = package.main_document_part().unwrap();
971        assert_eq!(presentation_part, "/ppt/presentation.xml");
972
973        let presentation_master_target = &package
974            .get_part_rels(&presentation_part)
975            .unwrap()
976            .get_by_type(rel_types::SLIDE_MASTER)
977            .unwrap()
978            .target;
979        let presentation_master_part =
980            OpcPackage::resolve_rel_target(&presentation_part, presentation_master_target);
981        assert_eq!(
982            presentation_master_part,
983            "/ppt/slideMasters/slideMaster1.xml"
984        );
985
986        let slide_target = &package
987            .get_part_rels(&presentation_part)
988            .unwrap()
989            .get_by_type(rel_types::SLIDE)
990            .unwrap()
991            .target;
992        let slide_part = OpcPackage::resolve_rel_target(&presentation_part, slide_target);
993        assert_eq!(slide_part, "/ppt/slides/slide1.xml");
994
995        let layout_target = &package
996            .get_part_rels(&slide_part)
997            .unwrap()
998            .get_by_type(rel_types::SLIDE_LAYOUT)
999            .unwrap()
1000            .target;
1001        let layout_part = OpcPackage::resolve_rel_target(&slide_part, layout_target);
1002        assert_eq!(layout_part, "/ppt/slideLayouts/slideLayout1.xml");
1003
1004        let master_target = &package
1005            .get_part_rels(&layout_part)
1006            .unwrap()
1007            .get_by_type(rel_types::SLIDE_MASTER)
1008            .unwrap()
1009            .target;
1010        let master_part = OpcPackage::resolve_rel_target(&layout_part, master_target);
1011        assert_eq!(master_part, "/ppt/slideMasters/slideMaster1.xml");
1012        assert_eq!(master_part, presentation_master_part);
1013
1014        let master_layout_target = &package
1015            .get_part_rels(&master_part)
1016            .unwrap()
1017            .get_by_type(rel_types::SLIDE_LAYOUT)
1018            .unwrap()
1019            .target;
1020        let master_layout_part = OpcPackage::resolve_rel_target(&master_part, master_layout_target);
1021        assert_eq!(master_layout_part, layout_part);
1022
1023        let theme_target = &package
1024            .get_part_rels(&master_part)
1025            .unwrap()
1026            .get_by_type(rel_types::THEME)
1027            .unwrap()
1028            .target;
1029        let theme_part = OpcPackage::resolve_rel_target(&master_part, theme_target);
1030        assert_eq!(theme_part, "/ppt/theme/theme1.xml");
1031
1032        let master_xml = std::str::from_utf8(package.parts.get(&master_part).unwrap()).unwrap();
1033        let layout_id = master_xml
1034            .split_once("<p:sldLayoutId id=\"")
1035            .unwrap()
1036            .1
1037            .split_once('"')
1038            .unwrap()
1039            .0
1040            .parse::<u64>()
1041            .unwrap();
1042        assert!(layout_id >= 2_147_483_648);
1043
1044        assert!(package.parts.contains_key(&presentation_part));
1045        assert!(package.parts.contains_key(&slide_part));
1046        assert!(package.parts.contains_key(&layout_part));
1047        assert!(package.parts.contains_key(&master_part));
1048        assert!(package.parts.contains_key(&theme_part));
1049    }
1050
1051    #[test]
1052    fn presentation_layout_target_resolves_one_directory_up() {
1053        assert_eq!(
1054            OpcPackage::resolve_rel_target(
1055                "/ppt/slides/slide1.xml",
1056                "../slideLayouts/slideLayout1.xml"
1057            ),
1058            "/ppt/slideLayouts/slideLayout1.xml"
1059        );
1060    }
1061
1062    #[test]
1063    fn rels_path_conversion() {
1064        assert_eq!(
1065            rels_path_to_part_name("word/_rels/document.xml.rels"),
1066            "/word/document.xml"
1067        );
1068        assert_eq!(
1069            part_name_to_rels_path("/word/document.xml"),
1070            "word/_rels/document.xml.rels"
1071        );
1072    }
1073
1074    #[test]
1075    fn resolve_relative_target() {
1076        assert_eq!(
1077            OpcPackage::resolve_rel_target("/word/document.xml", "styles.xml"),
1078            "/word/styles.xml"
1079        );
1080        assert_eq!(
1081            OpcPackage::resolve_rel_target("/word/document.xml", "/word/styles.xml"),
1082            "/word/styles.xml"
1083        );
1084    }
1085
1086    #[test]
1087    fn resolve_target_collapses_parent_segments() {
1088        // Charts and headers routinely reference media through a parent dir.
1089        assert_eq!(
1090            OpcPackage::resolve_rel_target("/word/charts/chart1.xml", "../media/image1.png"),
1091            "/word/media/image1.png"
1092        );
1093        assert_eq!(
1094            OpcPackage::resolve_rel_target("/word/document.xml", "./styles.xml"),
1095            "/word/styles.xml"
1096        );
1097        assert_eq!(
1098            OpcPackage::resolve_rel_target("/word/document.xml", "../../../etc/passwd"),
1099            "/etc/passwd"
1100        );
1101    }
1102
1103    #[test]
1104    fn zip_entry_that_escapes_root_is_clamped_to_root() {
1105        let archive = package_zip(&[
1106            ("[Content_Types].xml", MINIMAL_CONTENT_TYPES),
1107            ("../../etc/passwd", b"not a real password file"),
1108        ]);
1109
1110        let package = OpcPackage::from_reader(archive).unwrap();
1111
1112        assert_eq!(
1113            package.get_part("/etc/passwd"),
1114            Some(b"not a real password file".as_slice())
1115        );
1116        assert!(package.parts.keys().all(|name| !name.contains("..")));
1117    }
1118
1119    #[test]
1120    fn absolute_zip_entry_is_normalized_to_package_root() {
1121        let archive = package_zip(&[
1122            ("/[Content_Types].xml", MINIMAL_CONTENT_TYPES),
1123            ("/absolute/path.xml", b"<absolute/>"),
1124        ]);
1125
1126        let package = OpcPackage::from_reader(archive).unwrap();
1127
1128        assert_eq!(
1129            package.get_part("/absolute/path.xml"),
1130            Some(b"<absolute/>".as_slice())
1131        );
1132        assert_eq!(
1133            package
1134                .parts
1135                .keys()
1136                .filter(|name| name.as_str() == "/absolute/path.xml")
1137                .count(),
1138            1
1139        );
1140    }
1141
1142    #[test]
1143    fn rels_path_suffix_is_stripped_once() {
1144        assert_eq!(
1145            rels_path_to_part_name("word/_rels/document.xml.rels"),
1146            "/word/document.xml"
1147        );
1148        // A part whose own name ends in ".rels" must keep that segment.
1149        assert_eq!(
1150            rels_path_to_part_name("word/_rels/odd.rels.rels"),
1151            "/word/odd.rels"
1152        );
1153    }
1154
1155    #[test]
1156    fn saved_packages_are_byte_identical() {
1157        let mut pkg = docx_package();
1158        for i in 0..40 {
1159            pkg.set_part(&format!("/word/media/image{i}.png"), vec![i as u8]);
1160        }
1161        pkg.get_or_create_part_rels("/word/document.xml")
1162            .add(rel_types::STYLES, "styles.xml");
1163
1164        let write = || {
1165            let mut buf = std::io::Cursor::new(Vec::new());
1166            pkg.write_to(&mut buf).unwrap();
1167            buf.into_inner()
1168        };
1169        assert_eq!(write(), write());
1170    }
1171
1172    #[test]
1173    fn new_docx_package() {
1174        let pkg = docx_package();
1175        assert!(pkg.main_document_part().is_some());
1176        assert_eq!(pkg.main_document_part().unwrap(), "/word/document.xml");
1177    }
1178
1179    #[test]
1180    fn round_trip_package() {
1181        let mut pkg = docx_package();
1182        pkg.set_part("/word/document.xml", b"<document/>".to_vec());
1183
1184        // Write to memory
1185        let mut buf = std::io::Cursor::new(Vec::new());
1186        pkg.write_to(&mut buf).unwrap();
1187
1188        // Read back
1189        buf.set_position(0);
1190        let pkg2 = OpcPackage::from_reader(buf).unwrap();
1191        assert_eq!(
1192            pkg2.get_part("/word/document.xml"),
1193            Some(b"<document/>".as_slice())
1194        );
1195        assert!(pkg2.main_document_part().is_some());
1196    }
1197}