Skip to main content

stella_docx_kernel/projection/
archive.rs

1use miniz_oxide::inflate::{TINFLStatus, decompress_to_vec_with_limit};
2use rawzip::{CompressionMethod, ZipArchive, ZipSliceArchive, crc32};
3
4use crate::ProjectionError;
5use crate::projection::relationships::{
6    document_relationships_path, main_document_path, review_part_paths,
7};
8use crate::projection::review::{ReviewFactLimits, ReviewFactUnknownReason};
9
10const DOCUMENT_XML_PATH: &[u8] = b"word/document.xml";
11const STYLES_XML_PATH: &[u8] = b"word/styles.xml";
12const ROOT_RELATIONSHIPS_PATH: &[u8] = b"_rels/.rels";
13const MAXIMUM_ROOT_RELATIONSHIPS_BYTES: usize = 1024 * 1024;
14const MAXIMUM_DOCUMENT_RELATIONSHIPS_BYTES: usize = 1024 * 1024;
15
16#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17pub struct DocxLimits {
18    pub maximum_archive_bytes: usize,
19    pub maximum_document_xml_bytes: usize,
20    pub maximum_styles_xml_bytes: usize,
21    pub maximum_entries: u64,
22    pub maximum_compression_ratio: u64,
23    pub maximum_paragraphs: usize,
24    pub maximum_structural_facts: usize,
25}
26
27impl Default for DocxLimits {
28    fn default() -> Self {
29        Self {
30            maximum_archive_bytes: 64 * 1024 * 1024,
31            maximum_document_xml_bytes: 32 * 1024 * 1024,
32            maximum_styles_xml_bytes: 8 * 1024 * 1024,
33            maximum_entries: 4096,
34            maximum_compression_ratio: 200,
35            maximum_paragraphs: 250_000,
36            maximum_structural_facts: 1_000_000,
37        }
38    }
39}
40
41#[derive(Clone, Copy)]
42struct XmlEntry {
43    wayfinder: rawzip::ZipArchiveEntryWayfinder,
44    method: CompressionMethod,
45    compressed_size: u64,
46    uncompressed_size: u64,
47    crc32: u32,
48    encrypted: bool,
49}
50
51struct PackageEntry {
52    path: Vec<u8>,
53    xml: XmlEntry,
54}
55
56struct EntryExtractionErrors {
57    invalid_entry: ProjectionError,
58    too_large: ProjectionError,
59    integrity: ProjectionError,
60}
61
62impl EntryExtractionErrors {
63    const DOCUMENT_XML: Self = Self {
64        invalid_entry: ProjectionError::InvalidDocumentXmlEntry,
65        too_large: ProjectionError::DocumentXmlTooLarge,
66        integrity: ProjectionError::DocumentXmlIntegrity,
67    };
68    const PACKAGE_RELATIONSHIPS: Self = Self {
69        invalid_entry: ProjectionError::InvalidPackageRelationships,
70        too_large: ProjectionError::PackageRelationshipsTooLarge,
71        integrity: ProjectionError::InvalidPackageRelationships,
72    };
73    const STYLES_XML: Self = Self {
74        invalid_entry: ProjectionError::InvalidStylesXmlEntry,
75        too_large: ProjectionError::StylesXmlTooLarge,
76        integrity: ProjectionError::StylesXmlIntegrity,
77    };
78}
79
80#[derive(Clone, Debug, Eq, PartialEq)]
81pub struct DocumentParts {
82    pub document_xml: Vec<u8>,
83    pub styles_xml: Option<Vec<u8>>,
84}
85
86pub(super) struct ProjectionPackageParts {
87    pub document: Vec<u8>,
88    pub styles: Option<Vec<u8>>,
89    pub comments: Result<Option<Vec<u8>>, ReviewFactUnknownReason>,
90    pub comments_extended: Result<Option<Vec<u8>>, ReviewFactUnknownReason>,
91}
92
93struct RequiredPackageParts {
94    document_path: Vec<u8>,
95    document: Vec<u8>,
96    styles: Option<Vec<u8>>,
97}
98
99struct ReviewPackageParts {
100    comments: Result<Option<Vec<u8>>, ReviewFactUnknownReason>,
101    comments_extended: Result<Option<Vec<u8>>, ReviewFactUnknownReason>,
102}
103
104/// Extracts the relationship-resolved main document part from a bounded DOCX package.
105///
106/// # Errors
107///
108/// Returns [`ProjectionError`] when the package is invalid, unsupported, or
109/// exceeds the supplied resource limits.
110pub fn extract_document_xml(bytes: &[u8], limits: DocxLimits) -> Result<Vec<u8>, ProjectionError> {
111    extract_document_parts(bytes, limits).map(|parts| parts.document_xml)
112}
113
114/// Extracts the main document and its conventional paragraph-style part from one
115/// bounded scan of the ZIP directory.
116///
117/// `styles_xml: None` is not evidence that a package has no effective styles:
118/// relationship targets may use another path. Callers therefore retain an
119/// explicit unknown state for style-derived structural facts when it is absent.
120///
121/// # Errors
122///
123/// Returns [`ProjectionError`] when the package is invalid, unsupported, or
124/// exceeds the supplied resource limits.
125pub fn extract_document_parts(
126    bytes: &[u8],
127    limits: DocxLimits,
128) -> Result<DocumentParts, ProjectionError> {
129    extract_required_parts(bytes, limits)
130}
131
132pub(super) fn extract_projection_parts(
133    bytes: &[u8],
134    limits: DocxLimits,
135    review_limits: ReviewFactLimits,
136) -> Result<ProjectionPackageParts, ProjectionError> {
137    if bytes.len() > limits.maximum_archive_bytes {
138        return Err(ProjectionError::ArchiveTooLarge);
139    }
140    let archive = ZipArchive::from_slice(bytes).map_err(|_| ProjectionError::InvalidArchive)?;
141    let package_entries = scan_package_entries(&archive, limits)?;
142    let required = extract_required_parts_from_archive(&archive, &package_entries, limits)?;
143    let review = extract_review_parts(
144        &archive,
145        &package_entries,
146        &required.document_path,
147        review_limits,
148        limits,
149    );
150    Ok(ProjectionPackageParts {
151        document: required.document,
152        styles: required.styles,
153        comments: review.comments,
154        comments_extended: review.comments_extended,
155    })
156}
157
158fn extract_required_parts(
159    bytes: &[u8],
160    limits: DocxLimits,
161) -> Result<DocumentParts, ProjectionError> {
162    if bytes.len() > limits.maximum_archive_bytes {
163        return Err(ProjectionError::ArchiveTooLarge);
164    }
165    let archive = ZipArchive::from_slice(bytes).map_err(|_| ProjectionError::InvalidArchive)?;
166    let package_entries = scan_package_entries(&archive, limits)?;
167
168    let required = extract_required_parts_from_archive(&archive, &package_entries, limits)?;
169    Ok(DocumentParts {
170        document_xml: required.document,
171        styles_xml: required.styles,
172    })
173}
174
175fn extract_required_parts_from_archive(
176    archive: &ZipSliceArchive<&[u8]>,
177    package_entries: &[PackageEntry],
178    limits: DocxLimits,
179) -> Result<RequiredPackageParts, ProjectionError> {
180    let document_path = resolve_document_path(archive, package_entries, limits)?;
181    let document = unique_entry(
182        package_entries,
183        &document_path,
184        ProjectionError::DuplicateDocumentXml,
185    )?
186    .ok_or(ProjectionError::MissingDocumentXml)?;
187    validate_selected_entry(
188        document,
189        limits.maximum_document_xml_bytes,
190        ProjectionError::DocumentXmlTooLarge,
191        ProjectionError::EncryptedDocumentXml,
192        limits,
193    )?;
194    let document_xml = extract_entry(
195        archive,
196        document,
197        &document_path,
198        limits.maximum_document_xml_bytes,
199        EntryExtractionErrors::DOCUMENT_XML,
200    )?;
201    let styles_xml = unique_entry(
202        package_entries,
203        STYLES_XML_PATH,
204        ProjectionError::DuplicateStylesXml,
205    )?
206    .map(|styles| {
207        validate_selected_entry(
208            styles,
209            limits.maximum_styles_xml_bytes,
210            ProjectionError::StylesXmlTooLarge,
211            ProjectionError::InvalidStylesXmlEntry,
212            limits,
213        )?;
214        extract_entry(
215            archive,
216            styles,
217            STYLES_XML_PATH,
218            limits.maximum_styles_xml_bytes,
219            EntryExtractionErrors::STYLES_XML,
220        )
221    })
222    .transpose()?;
223    Ok(RequiredPackageParts {
224        document_path,
225        document: document_xml,
226        styles: styles_xml,
227    })
228}
229
230fn extract_review_parts(
231    archive: &ZipSliceArchive<&[u8]>,
232    entries: &[PackageEntry],
233    document_path: &[u8],
234    review_limits: ReviewFactLimits,
235    limits: DocxLimits,
236) -> ReviewPackageParts {
237    let Ok(relationships_path) = document_relationships_path(document_path) else {
238        return ReviewPackageParts {
239            comments: Err(ReviewFactUnknownReason::InvalidComments),
240            comments_extended: Err(ReviewFactUnknownReason::InvalidCommentsExtended),
241        };
242    };
243    let relationships = match extract_optional_review_entry(
244        archive,
245        entries,
246        &relationships_path,
247        MAXIMUM_DOCUMENT_RELATIONSHIPS_BYTES,
248        ReviewFactUnknownReason::InvalidComments,
249        limits,
250    ) {
251        Ok(Some(xml)) => xml,
252        Ok(None) => {
253            return ReviewPackageParts {
254                comments: Ok(None),
255                comments_extended: Ok(None),
256            };
257        }
258        Err(ReviewFactUnknownReason::ResourceLimit) => {
259            return ReviewPackageParts {
260                comments: Err(ReviewFactUnknownReason::ResourceLimit),
261                comments_extended: Err(ReviewFactUnknownReason::ResourceLimit),
262            };
263        }
264        Err(_) => {
265            return ReviewPackageParts {
266                comments: Err(ReviewFactUnknownReason::InvalidComments),
267                comments_extended: Err(ReviewFactUnknownReason::InvalidCommentsExtended),
268            };
269        }
270    };
271    let Ok(paths) = review_part_paths(&relationships, document_path) else {
272        return ReviewPackageParts {
273            comments: Err(ReviewFactUnknownReason::InvalidComments),
274            comments_extended: Err(ReviewFactUnknownReason::InvalidCommentsExtended),
275        };
276    };
277    let comments = paths.comments.map_or(Ok(None), |path| {
278        extract_optional_review_entry(
279            archive,
280            entries,
281            &path,
282            review_limits.maximum_comments_xml_bytes,
283            ReviewFactUnknownReason::InvalidComments,
284            limits,
285        )
286        .and_then(|value| {
287            value
288                .ok_or(ReviewFactUnknownReason::InvalidComments)
289                .map(Some)
290        })
291    });
292    let comments_extended = paths.comments_extended.map_or(Ok(None), |path| {
293        extract_optional_review_entry(
294            archive,
295            entries,
296            &path,
297            review_limits.maximum_comments_extended_xml_bytes,
298            ReviewFactUnknownReason::InvalidCommentsExtended,
299            limits,
300        )
301        .and_then(|value| {
302            value
303                .ok_or(ReviewFactUnknownReason::InvalidCommentsExtended)
304                .map(Some)
305        })
306    });
307    ReviewPackageParts {
308        comments,
309        comments_extended,
310    }
311}
312
313fn extract_optional_review_entry(
314    archive: &ZipSliceArchive<&[u8]>,
315    entries: &[PackageEntry],
316    path: &[u8],
317    maximum_bytes: usize,
318    invalid: ReviewFactUnknownReason,
319    limits: DocxLimits,
320) -> Result<Option<Vec<u8>>, ReviewFactUnknownReason> {
321    let selected =
322        unique_entry(entries, path, ProjectionError::InvalidArchive).map_err(|_| invalid)?;
323    let Some(selected) = selected else {
324        return Ok(None);
325    };
326    let validation = validate_selected_entry(
327        selected,
328        maximum_bytes,
329        ProjectionError::DocumentXmlTooLarge,
330        ProjectionError::InvalidDocumentXmlEntry,
331        limits,
332    );
333    validation.map_err(|error| match error {
334        ProjectionError::DocumentXmlTooLarge | ProjectionError::SuspiciousCompressionRatio => {
335            ReviewFactUnknownReason::ResourceLimit
336        }
337        _ => invalid,
338    })?;
339    let extracted = extract_entry(
340        archive,
341        selected,
342        path,
343        maximum_bytes,
344        EntryExtractionErrors::DOCUMENT_XML,
345    );
346    extracted.map(Some).map_err(|error| match error {
347        ProjectionError::DocumentXmlTooLarge | ProjectionError::SuspiciousCompressionRatio => {
348            ReviewFactUnknownReason::ResourceLimit
349        }
350        _ => invalid,
351    })
352}
353
354fn scan_package_entries(
355    archive: &ZipSliceArchive<&[u8]>,
356    limits: DocxLimits,
357) -> Result<Vec<PackageEntry>, ProjectionError> {
358    if archive.entries_hint() > limits.maximum_entries {
359        return Err(ProjectionError::TooManyArchiveEntries);
360    }
361
362    let mut package_entries = Vec::new();
363    let mut entries = archive.entries();
364    let mut entry_count = 0_u64;
365    while let Some(entry) = entries
366        .next_entry()
367        .map_err(|_| ProjectionError::InvalidArchive)?
368    {
369        entry_count = entry_count
370            .checked_add(1)
371            .ok_or(ProjectionError::TooManyArchiveEntries)?;
372        if entry_count > limits.maximum_entries {
373            return Err(ProjectionError::TooManyArchiveEntries);
374        }
375        package_entries.push(PackageEntry {
376            path: entry.file_path().as_ref().to_vec(),
377            xml: XmlEntry {
378                wayfinder: entry.wayfinder(),
379                method: entry.compression_method(),
380                compressed_size: entry.compressed_size_hint(),
381                uncompressed_size: entry.uncompressed_size_hint(),
382                crc32: entry.crc32(),
383                encrypted: entry.flags().is_encrypted(),
384            },
385        });
386    }
387    Ok(package_entries)
388}
389
390fn resolve_document_path(
391    archive: &ZipSliceArchive<&[u8]>,
392    package_entries: &[PackageEntry],
393    limits: DocxLimits,
394) -> Result<Vec<u8>, ProjectionError> {
395    if let Some(relationships) = unique_entry(
396        package_entries,
397        ROOT_RELATIONSHIPS_PATH,
398        ProjectionError::InvalidPackageRelationships,
399    )? {
400        validate_selected_entry(
401            relationships,
402            MAXIMUM_ROOT_RELATIONSHIPS_BYTES,
403            ProjectionError::PackageRelationshipsTooLarge,
404            ProjectionError::InvalidPackageRelationships,
405            limits,
406        )?;
407        let relationships_xml = extract_entry(
408            archive,
409            relationships,
410            ROOT_RELATIONSHIPS_PATH,
411            MAXIMUM_ROOT_RELATIONSHIPS_BYTES,
412            EntryExtractionErrors::PACKAGE_RELATIONSHIPS,
413        )?;
414        main_document_path(&relationships_xml)
415    } else {
416        Ok(DOCUMENT_XML_PATH.to_vec())
417    }
418}
419
420fn unique_entry(
421    entries: &[PackageEntry],
422    path: &[u8],
423    duplicate: ProjectionError,
424) -> Result<Option<XmlEntry>, ProjectionError> {
425    let mut matches = entries
426        .iter()
427        .filter(|entry| entry.path == path)
428        .map(|entry| entry.xml);
429    let first = matches.next();
430    if matches.next().is_some() {
431        return Err(duplicate);
432    }
433    Ok(first)
434}
435
436fn validate_selected_entry(
437    entry: XmlEntry,
438    maximum_bytes: usize,
439    too_large: ProjectionError,
440    encrypted: ProjectionError,
441    limits: DocxLimits,
442) -> Result<(), ProjectionError> {
443    if entry.encrypted {
444        return Err(encrypted);
445    }
446    if entry.method != CompressionMethod::STORE && entry.method != CompressionMethod::DEFLATE {
447        return Err(ProjectionError::UnsupportedCompression(
448            entry.method.as_u16(),
449        ));
450    }
451    validate_declared_sizes(
452        entry.compressed_size,
453        entry.uncompressed_size,
454        maximum_bytes,
455        too_large,
456        limits,
457    )
458}
459
460fn extract_entry(
461    archive: &ZipSliceArchive<&[u8]>,
462    entry: XmlEntry,
463    expected_path: &[u8],
464    maximum_bytes: usize,
465    errors: EntryExtractionErrors,
466) -> Result<Vec<u8>, ProjectionError> {
467    let local = archive
468        .get_entry(entry.wayfinder)
469        .map_err(|_| errors.invalid_entry.clone())?;
470    let local_header = local.local_header();
471    if local_header.compression_method() != entry.method
472        || local_header.file_path().as_ref() != expected_path
473        || local_header.flags().is_encrypted()
474    {
475        return Err(errors.invalid_entry);
476    }
477    let output = match entry.method {
478        CompressionMethod::STORE => local.data().to_vec(),
479        CompressionMethod::DEFLATE => decompress_to_vec_with_limit(local.data(), maximum_bytes)
480            .map_err(|error| {
481                if error.status == TINFLStatus::HasMoreOutput {
482                    errors.too_large.clone()
483                } else {
484                    errors.invalid_entry.clone()
485                }
486            })?,
487        _ => {
488            return Err(ProjectionError::UnsupportedCompression(
489                entry.method.as_u16(),
490            ));
491        }
492    };
493    let expected_size = usize::try_from(entry.uncompressed_size).map_err(|_| errors.too_large)?;
494    if output.len() != expected_size || crc32(&output) != entry.crc32 {
495        return Err(errors.integrity);
496    }
497    if u64::try_from(local.data().len()).ok() != Some(entry.compressed_size) {
498        return Err(errors.integrity);
499    }
500    Ok(output)
501}
502
503fn validate_declared_sizes(
504    compressed_size: u64,
505    uncompressed_size: u64,
506    maximum_bytes: usize,
507    too_large: ProjectionError,
508    limits: DocxLimits,
509) -> Result<(), ProjectionError> {
510    let uncompressed_size_as_usize =
511        usize::try_from(uncompressed_size).map_err(|_| too_large.clone())?;
512    if uncompressed_size_as_usize > maximum_bytes {
513        return Err(too_large);
514    }
515    if uncompressed_size == 0 {
516        return Ok(());
517    }
518    if compressed_size == 0
519        || uncompressed_size > compressed_size.saturating_mul(limits.maximum_compression_ratio)
520    {
521        return Err(ProjectionError::SuspiciousCompressionRatio);
522    }
523    Ok(())
524}