xlsxparser 0.10.2

A lightweight, high-performance .xlsx (OOXML) parser library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
//! XML parsing layer: consolidates the `quick-xml` dependency. Secure
//! `Reader` construction, XML error conversion, and helpers shared by every
//! submodule live here; each submodule interprets one OOXML part's
//! structure.

mod relationships;
mod shared_strings;
mod styles;
mod workbook;
mod worksheet;

pub(crate) use relationships::parse_relationships;
pub(crate) use shared_strings::{parse_shared_strings, SharedStringTable};
pub(crate) use styles::parse_styles;
pub(crate) use workbook::parse_workbook_xml;
pub(crate) use worksheet::{parse_worksheet, PendingSharedString, PendingStyle};

use crate::error::Error;
use quick_xml::events::{BytesStart, Event};
use quick_xml::Reader;
use std::io::BufRead;

/// The sole gateway for constructing a `Reader` with XXE mitigations
/// applied.
///
/// `quick-xml` is a non-validating parser that, even in its default
/// configuration, never fetches external entities or an external DTD
/// subset — so classic XXE (local file disclosure, SSRF) cannot occur in the
/// first place. Even so, the requirement to "disable external entity
/// expansion during XML parsing" should not remain an implicit assumption;
/// this function exists to make that setting explicit, and [`read_event`]
/// backs it with an active, verifiable check on every event read.
///
/// Sets `trim_text(false)` so element text is never auto-trimmed — needed to
/// not lose shared strings' `xml:space="preserve"`; whether to actually
/// preserve whitespace is decided per submodule.
pub(crate) fn create_secure_reader<R: BufRead>(inner: R) -> Reader<R> {
    let mut reader = Reader::from_reader(inner);
    reader.config_mut().trim_text(false);
    reader
}

/// The sole gateway for converting `quick_xml::Error` into
/// `crate::error::Error`. A limit-exceeded error from `container::sanitize`'s
/// `BoundedReader` (Zip Bomb protection) propagates up wrapped as an
/// `io::Error` inside `quick_xml::Error::Io`, so this first downcasts to
/// `container::sanitize::LimitExceeded` and, if it matches, returns
/// `Error::ZipBombDetected`. Otherwise it wraps the error as
/// `Error::XmlParse`, type-erased per `error.rs`'s policy of never exposing
/// `quick_xml::Error` directly in the public API.
pub(crate) fn convert_xml_error(path: &str, err: quick_xml::Error) -> Error {
    if let quick_xml::Error::Io(io_err) = &err {
        if let Some(limit) = io_err
            .get_ref()
            .and_then(|e| e.downcast_ref::<crate::container::sanitize::LimitExceeded>())
        {
            return Error::ZipBombDetected {
                limit: limit.limit,
                actual: limit.actual,
            };
        }
    }
    Error::XmlParse {
        path: path.to_string(),
        source: Box::new(err),
    }
}

/// The sole gateway for reading events. Calls `reader.read_event_into(buf)`,
/// converts any error via [`convert_xml_error`], and — if the returned
/// `Event` is `Event::DocType` (a `<!DOCTYPE ...>` declaration) — returns
/// `Error::DoctypeRejected` unconditionally without interpreting its content
/// at all (fail closed).
///
/// None of OOXML's `_rels`/`workbook.xml`/`sharedStrings.xml`/`styles.xml`/
/// `sheetX.xml` parts ever carry a DOCTYPE declaration per spec, so this
/// check never rejects a legitimate `.xlsx`. It acts as an independent layer
/// of defense that keeps working even if quick-xml's non-validating
/// behavior were ever broken by a future version change or a switch to a
/// different parser, by cutting processing off the moment a DOCTYPE
/// declaration's mere presence is detected at the XML syntax level. Every
/// module under `parse/` reads events only through this function, never
/// calling `reader.read_event_into` directly.
pub(crate) fn read_event<'a>(
    reader: &mut Reader<impl BufRead>,
    buf: &'a mut Vec<u8>,
    path: &str,
) -> Result<Event<'a>, Error> {
    let event = reader
        .read_event_into(buf)
        .map_err(|err| convert_xml_error(path, err))?;
    if matches!(event, Event::DocType(_)) {
        return Err(Error::DoctypeRejected {
            path: path.to_string(),
        });
    }
    Ok(event)
}

/// Reads attribute `name` from `start`. Returns
/// `Error::MissingRequiredElement` if it is absent, or wraps a malformed
/// attribute (invalid UTF-8, a stray `&` not part of a valid entity, etc.)
/// as `Error::XmlParse`.
pub(crate) fn required_attr(
    start: &BytesStart<'_>,
    path: &str,
    name: &'static str,
) -> Result<String, Error> {
    for attr in start.attributes() {
        let attr = attr.map_err(|err| Error::XmlParse {
            path: path.to_string(),
            source: Box::new(err),
        })?;
        if attr.key.as_ref() == name.as_bytes() {
            let value = attr
                .normalized_value(quick_xml::XmlVersion::Implicit1_0)
                .map_err(|err| Error::XmlParse {
                    path: path.to_string(),
                    source: Box::new(err),
                })?;
            return Ok(value.into_owned());
        }
    }
    Err(Error::MissingRequiredElement {
        path: path.to_string(),
        name,
    })
}

/// Like [`required_attr`], but returns `Ok(None)` rather than an error when
/// `name` is absent (used for optional attributes such as `state`/
/// `TargetMode`).
pub(crate) fn optional_attr(
    start: &BytesStart<'_>,
    path: &str,
    name: &str,
) -> Result<Option<String>, Error> {
    for attr in start.attributes() {
        let attr = attr.map_err(|err| Error::XmlParse {
            path: path.to_string(),
            source: Box::new(err),
        })?;
        if attr.key.as_ref() == name.as_bytes() {
            let value = attr
                .normalized_value(quick_xml::XmlVersion::Implicit1_0)
                .map_err(|err| Error::XmlParse {
                    path: path.to_string(),
                    source: Box::new(err),
                })?;
            return Ok(Some(value.into_owned()));
        }
    }
    Ok(None)
}

/// Resolves an `Event::GeneralRef` (a `&#x...;`/`&#...;` character
/// reference, or one of the 5 predefined XML entities — the only entities
/// that can legally occur without a DTD, which `read_event` already
/// rejects) and appends the resolved text to `text`. Shared by
/// [`concat_rich_text`] and `parse/worksheet.rs`'s leaf-element text reader,
/// both of which need to reconstruct entity-bearing text content.
pub(crate) fn push_general_ref(
    text: &mut String,
    r: &quick_xml::events::BytesRef<'_>,
    path: &str,
) -> Result<(), Error> {
    match r.resolve_char_ref().map_err(|err| Error::XmlParse {
        path: path.to_string(),
        source: Box::new(err),
    })? {
        Some(ch) => text.push(ch),
        None => {
            let decoded = r.decode().map_err(|err| Error::XmlParse {
                path: path.to_string(),
                source: Box::new(err),
            })?;
            let resolved =
                quick_xml::escape::resolve_predefined_entity(&decoded).ok_or_else(|| {
                    Error::XmlParse {
                        path: path.to_string(),
                        source: format!("unknown XML entity reference: &{decoded};").into(),
                    }
                })?;
            text.push_str(resolved);
        }
    }
    Ok(())
}

/// Shared helper that extracts text-only content from the rich-text run
/// structure under `<si>` (shared strings) or `<is>` (inline strings) — a
/// sequence of `<r><t>...</t></r>` runs, or a single bare `<t>...</t>`.
/// `<t>` elements nested under `<rPr>` (per-run formatting) or `<rPh>`
/// (phonetic hints) are excluded from concatenation, since only their
/// *sibling* `<t>` (the run's actual text) contributes to the value.
///
/// Called with the reader positioned just after the opening `<si>`/`<is>`
/// tag; consumes events up to and including the matching closing tag.
pub(crate) fn concat_rich_text<R: BufRead>(
    reader: &mut Reader<R>,
    path: &str,
) -> Result<String, Error> {
    let mut text = String::new();
    let mut buf = Vec::new();
    // Depth of exclusion zones (`<rPr>`/`<rPh>`) the cursor is currently
    // inside; `<t>` is only appended to `text` while this is zero.
    let mut skip_depth: u32 = 0;

    loop {
        match read_event(reader, &mut buf, path)? {
            Event::Start(e) if e.local_name().as_ref() == b"rPr" => skip_depth += 1,
            Event::Start(e) if e.local_name().as_ref() == b"rPh" => skip_depth += 1,
            Event::End(e) if e.local_name().as_ref() == b"rPr" => skip_depth -= 1,
            Event::End(e) if e.local_name().as_ref() == b"rPh" => skip_depth -= 1,
            Event::Text(e) if skip_depth == 0 => {
                // Plain text content only: quick-xml 0.41 tokenizes any
                // `&...;` reference within content as a separate
                // `Event::GeneralRef`, never leaving escaped syntax inside
                // `Event::Text` for `.decode()` to unescape.
                let decoded = e.decode().map_err(|err| Error::XmlParse {
                    path: path.to_string(),
                    source: Box::new(err),
                })?;
                text.push_str(&decoded);
            }
            // `&#x...;`/`&#...;` (character references) or `&amp;`/`&lt;`/etc.
            // (the 5 predefined XML entities — the only ones that can occur
            // without a DTD, which `read_event` already rejects outright).
            Event::GeneralRef(e) if skip_depth == 0 => push_general_ref(&mut text, &e, path)?,
            Event::End(e)
                if e.local_name().as_ref() == b"si" || e.local_name().as_ref() == b"is" =>
            {
                break;
            }
            Event::Eof => {
                return Err(Error::MissingRequiredElement {
                    path: path.to_string(),
                    name: "si/is closing tag",
                })
            }
            _ => {}
        }
        buf.clear();
    }

    Ok(text)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::container::sanitize::BoundedReader;
    use std::io::{self, Read};

    #[test]
    fn create_secure_reader_disables_text_trimming() {
        let xml = b"<root>  padded  </root>".as_slice();
        let mut reader = create_secure_reader(xml);
        let mut buf = Vec::new();

        // Advance past `<root>`.
        assert!(matches!(
            reader.read_event_into(&mut buf).unwrap(),
            Event::Start(_)
        ));
        buf.clear();
        match reader.read_event_into(&mut buf).unwrap() {
            Event::Text(t) => assert_eq!(t.decode().unwrap(), "  padded  "),
            other => panic!("expected Text event, got {other:?}"),
        }
    }

    #[test]
    fn convert_xml_error_maps_limit_exceeded_to_zip_bomb_detected() {
        let mut cumulative = 0u64;
        // `read_to_end` reads this single-chunk `&[u8]` source in one call,
        // so `actual` ends up as the full length read (11), not `limit + 1`
        // in general — keep `data` at exactly `limit + 1` bytes so the
        // expected `actual` stays simple and deterministic.
        let data = [0u8; 11];
        let mut bounded = BoundedReader::new(&data[..], 10, &mut cumulative, 1000);
        let mut out = Vec::new();
        let io_err = bounded.read_to_end(&mut out).unwrap_err();
        let quick_xml_err = quick_xml::Error::Io(std::sync::Arc::new(io_err));

        let err = convert_xml_error("xl/worksheets/sheet1.xml", quick_xml_err);
        match err {
            Error::ZipBombDetected { limit, actual } => {
                assert_eq!(limit, 10);
                assert_eq!(actual, 11);
            }
            other => panic!("expected ZipBombDetected, got {other:?}"),
        }
    }

    #[test]
    fn convert_xml_error_falls_back_to_xml_parse() {
        let io_err = io::Error::other("plain io failure");
        let quick_xml_err = quick_xml::Error::Io(std::sync::Arc::new(io_err));

        let err = convert_xml_error("xl/worksheets/sheet1.xml", quick_xml_err);
        match err {
            Error::XmlParse { path, .. } => assert_eq!(path, "xl/worksheets/sheet1.xml"),
            other => panic!("expected XmlParse, got {other:?}"),
        }
    }

    #[test]
    fn required_attr_returns_value_when_present() {
        let xml = br#"<c r="A1" t="s"></c>"#.as_slice();
        let mut reader = create_secure_reader(xml);
        let mut buf = Vec::new();
        let event = reader.read_event_into(&mut buf).unwrap();
        let Event::Start(start) = event else {
            panic!("expected Start event");
        };

        assert_eq!(required_attr(&start, "sheet1.xml", "r").unwrap(), "A1");
        assert_eq!(required_attr(&start, "sheet1.xml", "t").unwrap(), "s");
    }

    #[test]
    fn required_attr_errors_when_absent() {
        let xml = br#"<c r="A1"></c>"#.as_slice();
        let mut reader = create_secure_reader(xml);
        let mut buf = Vec::new();
        let event = reader.read_event_into(&mut buf).unwrap();
        let Event::Start(start) = event else {
            panic!("expected Start event");
        };

        let err = required_attr(&start, "sheet1.xml", "t").unwrap_err();
        assert!(matches!(
            err,
            Error::MissingRequiredElement { name: "t", .. }
        ));
    }

    fn parse_si_body(xml: &[u8]) -> String {
        let mut reader = create_secure_reader(xml);
        let mut buf = Vec::new();
        // Advance past the opening `<si>`/`<is>` tag.
        loop {
            match reader.read_event_into(&mut buf).unwrap() {
                Event::Start(e)
                    if e.local_name().as_ref() == b"si" || e.local_name().as_ref() == b"is" =>
                {
                    break
                }
                Event::Eof => panic!("no <si>/<is> start tag found"),
                _ => {}
            }
            buf.clear();
        }
        buf.clear();
        concat_rich_text(&mut reader, "xl/sharedStrings.xml").unwrap()
    }

    #[test]
    fn concat_rich_text_single_bare_t() {
        let xml = b"<si><t>hello</t></si>";
        assert_eq!(parse_si_body(xml), "hello");
    }

    #[test]
    fn concat_rich_text_multiple_runs() {
        let xml = b"<si><r><t>hello </t></r><r><t>world</t></r></si>";
        assert_eq!(parse_si_body(xml), "hello world");
    }

    #[test]
    fn concat_rich_text_excludes_rpr_and_rph() {
        let xml = b"<si><r><rPr><b/></rPr><t>bold</t></r><rPh><t>phonetic</t></rPh></si>";
        assert_eq!(parse_si_body(xml), "bold");
    }

    #[test]
    fn read_event_rejects_doctype_and_stops_reading() {
        let xml =
            br#"<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]><root/>"#.as_slice();
        let mut reader = create_secure_reader(xml);
        let mut buf = Vec::new();

        let err = read_event(&mut reader, &mut buf, "xl/sharedStrings.xml").unwrap_err();
        assert!(matches!(err, Error::DoctypeRejected { .. }));
    }

    #[test]
    fn read_event_passes_through_legitimate_xml_without_false_positives() {
        let xml = br#"<?xml version="1.0"?><sst><si><t>ok</t></si></sst>"#.as_slice();
        let mut reader = create_secure_reader(xml);
        let mut buf = Vec::new();

        loop {
            if read_event(&mut reader, &mut buf, "xl/sharedStrings.xml").unwrap() == Event::Eof {
                break;
            }
            buf.clear();
        }
    }

    #[test]
    fn read_event_converts_syntax_error_via_convert_xml_error() {
        let xml = b"<root><unclosed></root>".as_slice();
        let mut reader = create_secure_reader(xml);
        let mut buf = Vec::new();

        let mut last_err = None;
        loop {
            buf.clear();
            match read_event(&mut reader, &mut buf, "sheet1.xml") {
                Ok(Event::Eof) => break,
                Ok(_) => continue,
                Err(err) => {
                    last_err = Some(err);
                    break;
                }
            }
        }
        assert!(matches!(last_err, Some(Error::XmlParse { .. })));
    }

    #[test]
    fn read_event_converts_bounded_reader_limit_via_convert_xml_error() {
        let mut cumulative = 0u64;
        let data = b"<root>this is too much data</root>".to_vec();
        let bounded = BoundedReader::new(&data[..], 5, &mut cumulative, 1000);
        let mut reader = create_secure_reader(io::BufReader::new(bounded));
        let mut buf = Vec::new();

        let err = loop {
            buf.clear();
            match read_event(&mut reader, &mut buf, "sheet1.xml") {
                Ok(Event::Eof) => panic!("expected an error before EOF"),
                Ok(_) => continue,
                Err(err) => break err,
            }
        };
        assert!(matches!(err, Error::ZipBombDetected { limit: 5, .. }));
    }
}