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