Skip to main content

stella_docx_kernel/projection/
archive.rs

1use miniz_oxide::inflate::decompress_to_vec_with_limit;
2use rawzip::{CompressionMethod, ZipArchive, ZipSliceArchive, crc32};
3
4use crate::ProjectionError;
5use crate::projection::relationships::main_document_path;
6
7const DOCUMENT_XML_PATH: &[u8] = b"word/document.xml";
8const STYLES_XML_PATH: &[u8] = b"word/styles.xml";
9const ROOT_RELATIONSHIPS_PATH: &[u8] = b"_rels/.rels";
10const MAXIMUM_ROOT_RELATIONSHIPS_BYTES: usize = 1024 * 1024;
11
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub struct DocxLimits {
14    pub maximum_archive_bytes: usize,
15    pub maximum_document_xml_bytes: usize,
16    pub maximum_styles_xml_bytes: usize,
17    pub maximum_entries: u64,
18    pub maximum_compression_ratio: u64,
19    pub maximum_paragraphs: usize,
20    pub maximum_structural_facts: usize,
21}
22
23impl Default for DocxLimits {
24    fn default() -> Self {
25        Self {
26            maximum_archive_bytes: 64 * 1024 * 1024,
27            maximum_document_xml_bytes: 32 * 1024 * 1024,
28            maximum_styles_xml_bytes: 8 * 1024 * 1024,
29            maximum_entries: 4096,
30            maximum_compression_ratio: 200,
31            maximum_paragraphs: 250_000,
32            maximum_structural_facts: 1_000_000,
33        }
34    }
35}
36
37#[derive(Clone, Copy)]
38struct XmlEntry {
39    wayfinder: rawzip::ZipArchiveEntryWayfinder,
40    method: CompressionMethod,
41    compressed_size: u64,
42    uncompressed_size: u64,
43    crc32: u32,
44    encrypted: bool,
45}
46
47struct PackageEntry {
48    path: Vec<u8>,
49    xml: XmlEntry,
50}
51
52struct EntryExtractionErrors {
53    invalid_entry: ProjectionError,
54    too_large: ProjectionError,
55    integrity: ProjectionError,
56}
57
58impl EntryExtractionErrors {
59    const DOCUMENT_XML: Self = Self {
60        invalid_entry: ProjectionError::InvalidDocumentXmlEntry,
61        too_large: ProjectionError::DocumentXmlTooLarge,
62        integrity: ProjectionError::DocumentXmlIntegrity,
63    };
64    const PACKAGE_RELATIONSHIPS: Self = Self {
65        invalid_entry: ProjectionError::InvalidPackageRelationships,
66        too_large: ProjectionError::PackageRelationshipsTooLarge,
67        integrity: ProjectionError::InvalidPackageRelationships,
68    };
69    const STYLES_XML: Self = Self {
70        invalid_entry: ProjectionError::InvalidStylesXmlEntry,
71        too_large: ProjectionError::StylesXmlTooLarge,
72        integrity: ProjectionError::StylesXmlIntegrity,
73    };
74}
75
76#[derive(Clone, Debug, Eq, PartialEq)]
77pub struct DocumentParts {
78    pub document_xml: Vec<u8>,
79    pub styles_xml: Option<Vec<u8>>,
80}
81
82/// Extracts the relationship-resolved main document part from a bounded DOCX package.
83///
84/// # Errors
85///
86/// Returns [`ProjectionError`] when the package is invalid, unsupported, or
87/// exceeds the supplied resource limits.
88pub fn extract_document_xml(bytes: &[u8], limits: DocxLimits) -> Result<Vec<u8>, ProjectionError> {
89    extract_document_parts(bytes, limits).map(|parts| parts.document_xml)
90}
91
92/// Extracts the main document and its conventional paragraph-style part from one
93/// bounded scan of the ZIP directory.
94///
95/// `styles_xml: None` is not evidence that a package has no effective styles:
96/// relationship targets may use another path. Callers therefore retain an
97/// explicit unknown state for style-derived structural facts when it is absent.
98///
99/// # Errors
100///
101/// Returns [`ProjectionError`] when the package is invalid, unsupported, or
102/// exceeds the supplied resource limits.
103pub fn extract_document_parts(
104    bytes: &[u8],
105    limits: DocxLimits,
106) -> Result<DocumentParts, ProjectionError> {
107    if bytes.len() > limits.maximum_archive_bytes {
108        return Err(ProjectionError::ArchiveTooLarge);
109    }
110    let archive = ZipArchive::from_slice(bytes).map_err(|_| ProjectionError::InvalidArchive)?;
111    let package_entries = scan_package_entries(&archive, limits)?;
112
113    let document_path = resolve_document_path(&archive, &package_entries, limits)?;
114    let document = unique_entry(
115        &package_entries,
116        &document_path,
117        ProjectionError::DuplicateDocumentXml,
118    )?
119    .ok_or(ProjectionError::MissingDocumentXml)?;
120    validate_selected_entry(
121        document,
122        limits.maximum_document_xml_bytes,
123        ProjectionError::DocumentXmlTooLarge,
124        ProjectionError::EncryptedDocumentXml,
125        limits,
126    )?;
127    let document_xml = extract_entry(
128        &archive,
129        document,
130        &document_path,
131        limits.maximum_document_xml_bytes,
132        EntryExtractionErrors::DOCUMENT_XML,
133    )?;
134    let styles_xml = unique_entry(
135        &package_entries,
136        STYLES_XML_PATH,
137        ProjectionError::DuplicateStylesXml,
138    )?
139    .map(|styles| {
140        validate_selected_entry(
141            styles,
142            limits.maximum_styles_xml_bytes,
143            ProjectionError::StylesXmlTooLarge,
144            ProjectionError::InvalidStylesXmlEntry,
145            limits,
146        )?;
147        extract_entry(
148            &archive,
149            styles,
150            STYLES_XML_PATH,
151            limits.maximum_styles_xml_bytes,
152            EntryExtractionErrors::STYLES_XML,
153        )
154    })
155    .transpose()?;
156    Ok(DocumentParts {
157        document_xml,
158        styles_xml,
159    })
160}
161
162fn scan_package_entries(
163    archive: &ZipSliceArchive<&[u8]>,
164    limits: DocxLimits,
165) -> Result<Vec<PackageEntry>, ProjectionError> {
166    if archive.entries_hint() > limits.maximum_entries {
167        return Err(ProjectionError::TooManyArchiveEntries);
168    }
169
170    let mut package_entries = Vec::new();
171    let mut entries = archive.entries();
172    let mut entry_count = 0_u64;
173    while let Some(entry) = entries
174        .next_entry()
175        .map_err(|_| ProjectionError::InvalidArchive)?
176    {
177        entry_count = entry_count
178            .checked_add(1)
179            .ok_or(ProjectionError::TooManyArchiveEntries)?;
180        if entry_count > limits.maximum_entries {
181            return Err(ProjectionError::TooManyArchiveEntries);
182        }
183        package_entries.push(PackageEntry {
184            path: entry.file_path().as_ref().to_vec(),
185            xml: XmlEntry {
186                wayfinder: entry.wayfinder(),
187                method: entry.compression_method(),
188                compressed_size: entry.compressed_size_hint(),
189                uncompressed_size: entry.uncompressed_size_hint(),
190                crc32: entry.crc32(),
191                encrypted: entry.flags().is_encrypted(),
192            },
193        });
194    }
195    Ok(package_entries)
196}
197
198fn resolve_document_path(
199    archive: &ZipSliceArchive<&[u8]>,
200    package_entries: &[PackageEntry],
201    limits: DocxLimits,
202) -> Result<Vec<u8>, ProjectionError> {
203    if let Some(relationships) = unique_entry(
204        package_entries,
205        ROOT_RELATIONSHIPS_PATH,
206        ProjectionError::InvalidPackageRelationships,
207    )? {
208        validate_selected_entry(
209            relationships,
210            MAXIMUM_ROOT_RELATIONSHIPS_BYTES,
211            ProjectionError::PackageRelationshipsTooLarge,
212            ProjectionError::InvalidPackageRelationships,
213            limits,
214        )?;
215        let relationships_xml = extract_entry(
216            archive,
217            relationships,
218            ROOT_RELATIONSHIPS_PATH,
219            MAXIMUM_ROOT_RELATIONSHIPS_BYTES,
220            EntryExtractionErrors::PACKAGE_RELATIONSHIPS,
221        )?;
222        main_document_path(&relationships_xml)
223    } else {
224        Ok(DOCUMENT_XML_PATH.to_vec())
225    }
226}
227
228fn unique_entry(
229    entries: &[PackageEntry],
230    path: &[u8],
231    duplicate: ProjectionError,
232) -> Result<Option<XmlEntry>, ProjectionError> {
233    let mut matches = entries
234        .iter()
235        .filter(|entry| entry.path == path)
236        .map(|entry| entry.xml);
237    let first = matches.next();
238    if matches.next().is_some() {
239        return Err(duplicate);
240    }
241    Ok(first)
242}
243
244fn validate_selected_entry(
245    entry: XmlEntry,
246    maximum_bytes: usize,
247    too_large: ProjectionError,
248    encrypted: ProjectionError,
249    limits: DocxLimits,
250) -> Result<(), ProjectionError> {
251    if entry.encrypted {
252        return Err(encrypted);
253    }
254    if entry.method != CompressionMethod::STORE && entry.method != CompressionMethod::DEFLATE {
255        return Err(ProjectionError::UnsupportedCompression(
256            entry.method.as_u16(),
257        ));
258    }
259    validate_declared_sizes(
260        entry.compressed_size,
261        entry.uncompressed_size,
262        maximum_bytes,
263        too_large,
264        limits,
265    )
266}
267
268fn extract_entry(
269    archive: &ZipSliceArchive<&[u8]>,
270    entry: XmlEntry,
271    expected_path: &[u8],
272    maximum_bytes: usize,
273    errors: EntryExtractionErrors,
274) -> Result<Vec<u8>, ProjectionError> {
275    let local = archive
276        .get_entry(entry.wayfinder)
277        .map_err(|_| errors.invalid_entry.clone())?;
278    let local_header = local.local_header();
279    if local_header.compression_method() != entry.method
280        || local_header.file_path().as_ref() != expected_path
281        || local_header.flags().is_encrypted()
282    {
283        return Err(errors.invalid_entry);
284    }
285    let output = match entry.method {
286        CompressionMethod::STORE => local.data().to_vec(),
287        CompressionMethod::DEFLATE => decompress_to_vec_with_limit(local.data(), maximum_bytes)
288            .map_err(|_| errors.invalid_entry.clone())?,
289        _ => {
290            return Err(ProjectionError::UnsupportedCompression(
291                entry.method.as_u16(),
292            ));
293        }
294    };
295    let expected_size = usize::try_from(entry.uncompressed_size).map_err(|_| errors.too_large)?;
296    if output.len() != expected_size || crc32(&output) != entry.crc32 {
297        return Err(errors.integrity);
298    }
299    if u64::try_from(local.data().len()).ok() != Some(entry.compressed_size) {
300        return Err(errors.integrity);
301    }
302    Ok(output)
303}
304
305fn validate_declared_sizes(
306    compressed_size: u64,
307    uncompressed_size: u64,
308    maximum_bytes: usize,
309    too_large: ProjectionError,
310    limits: DocxLimits,
311) -> Result<(), ProjectionError> {
312    let uncompressed_size_as_usize =
313        usize::try_from(uncompressed_size).map_err(|_| too_large.clone())?;
314    if uncompressed_size_as_usize > maximum_bytes {
315        return Err(too_large);
316    }
317    if uncompressed_size == 0 {
318        return Ok(());
319    }
320    if compressed_size == 0
321        || uncompressed_size > compressed_size.saturating_mul(limits.maximum_compression_ratio)
322    {
323        return Err(ProjectionError::SuspiciousCompressionRatio);
324    }
325    Ok(())
326}