xlsxparser 0.10.1

A lightweight, high-performance .xlsx (OOXML) parser library
Documentation
//! Crate-wide error type. See `docs/design/error.en.md` for the design
//! rationale (in particular, why external-crate errors are type-erased as
//! `Box<dyn std::error::Error + Send + Sync + 'static>` instead of being
//! held as a concrete type).

use std::path::PathBuf;

/// Crate-wide Result alias.
pub type Result<T> = std::result::Result<T, Error>;

/// The common error type used throughout the library. Every module's failure
/// modes, including `parse_workbook`'s `Result::Err`, are consolidated into
/// this type. Marked `#[non_exhaustive]` so future variants can be added
/// without a breaking change.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
    // --- Phase 1: relationship resolution ---
    /// A required relationship part, e.g. `xl/_rels/workbook.xml.rels`, is
    /// missing from the ZIP.
    #[error("required relationship part not found: {0}")]
    MissingRelationshipPart(String),

    /// The `r:id` referenced by a `<sheet r:id="...">` element in
    /// `workbook.xml` does not exist in the rels part, or the target file the
    /// rels part points to does not exist in the ZIP.
    #[error("dangling relationship reference: r:id={r_id}")]
    DanglingRelationship { r_id: String },

    // --- Phase 2: sanitization ---
    /// The total uncompressed size exceeded the configured limit (Zip Bomb
    /// protection, requirements spec section 2).
    #[error("zip bomb detected: uncompressed size {actual} bytes exceeds limit {limit} bytes")]
    ZipBombDetected { limit: u64, actual: u64 },

    /// A ZIP entry name contains a path traversal sequence that would escape
    /// the extraction directory (Zip Slip protection).
    #[error("path traversal detected in zip entry: {entry_name}")]
    ZipSlipDetected { entry_name: String },

    /// The ZIP archive itself is corrupt, or a required part of the .xlsx
    /// (OPC) package — e.g. `[Content_Types].xml` or `xl/workbook.xml` — is
    /// missing.
    #[error("not a valid .xlsx package: {0}")]
    InvalidPackage(String),

    // --- Phase 3: stream parsing ---
    /// The XML is syntactically invalid (wraps the underlying XML parser
    /// error). `source` does not hold the concrete parser error type (e.g.
    /// `quick_xml::Error`) directly; it is type-erased as `Box<dyn Error>` so
    /// the parser crate `parse/` uses never becomes a public dependency.
    #[error("XML parse error in {path}: {source}")]
    XmlParse {
        path: String,
        #[source]
        source: Box<dyn std::error::Error + Send + Sync + 'static>,
    },

    /// A required element or attribute is missing from the XML (e.g. a `<c>`
    /// element without an `r` attribute).
    #[error("missing required element/attribute `{name}` in {path}")]
    MissingRequiredElement { path: String, name: &'static str },

    /// A `<!DOCTYPE ...>` declaration was detected and rejected unconditionally
    /// (XXE mitigation; generated by `parse/mod.rs`'s `read_event`). None of
    /// OOXML's `_rels`/`workbook.xml`/`sharedStrings.xml`/`styles.xml`/
    /// `sheetX.xml` parts ever carry a DOCTYPE declaration per spec, so this
    /// variant never arises for a legitimate `.xlsx` (security review
    /// Finding 1).
    #[error("DOCTYPE declaration rejected in {path} (XXE defense)")]
    DoctypeRejected { path: String },

    // --- Phase 4: analysis and deferred resolution ---
    /// An A1-style cell reference string is invalid (syntax error, numeric
    /// overflow, empty string, etc. — returned by `CellRef::from_a1`).
    #[error("invalid cell reference: {0:?}")]
    InvalidCellRef(String),

    /// A shared string index (referenced via `t="s"`) falls outside the
    /// bounds of the shared string table.
    #[error("shared string index {index} out of bounds (table len={len})")]
    SharedStringIndexOutOfBounds { index: usize, len: usize },

    /// A style ID (index into `cellXfs`) that does not exist was referenced.
    #[error("invalid style id: {0}")]
    InvalidStyleId(u32),

    /// A merged cell range is invalid (overlaps another merged range, or its
    /// start/end coordinates are inverted).
    #[error("invalid merged cell range {start}:{end}: {reason}")]
    InvalidMergedRange {
        start: String,
        end: String,
        reason: String,
    },

    /// The number of `<mergeCell>` entries in a single sheet exceeded
    /// `resolve::merge::MAX_MERGE_REGIONS`. `resolve::merge::resolve`'s
    /// overlap check is O(N^2) in the number of regions, so N itself — not
    /// just the byte size of the XML that declares it, which the Zip Bomb
    /// cap already bounds — must be bounded independently (security review
    /// `docs/security/code-review.md` Finding 1).
    #[error("too many merged cell ranges in one sheet: {count} exceeds limit {limit}")]
    TooManyMergedRanges { count: usize, limit: usize },

    // --- Phase 5: JSON generation ---
    /// JSON serialization failed (wraps the error `serde_json` returns).
    /// `source` is type-erased for the same reason as `XmlParse::source`. In
    /// practice `json.rs` always falls back on non-finite floats before ever
    /// handing them to `serde_json`, so no failure is expected to originate
    /// from a value's content; this variant mainly serves as the propagation
    /// path for I/O errors from the `Write` implementation.
    #[error("JSON serialization error: {source}")]
    JsonSerialize {
        #[source]
        source: Box<dyn std::error::Error + Send + Sync + 'static>,
    },

    // --- Common across all phases ---
    /// An I/O error (e.g. the target file cannot be opened or read). `path`
    /// is `Option` because inputs that don't go through a file path (e.g. an
    /// in-memory buffer such as `Cursor<Vec<u8>>`) have no path to report.
    /// When present, it is appended to the `Display` message so the
    /// offending file can be identified from the error text alone.
    #[error("I/O error{}: {source}", io_path_suffix(path))]
    Io {
        path: Option<PathBuf>,
        #[source]
        source: std::io::Error,
    },
}

/// Formats `Error::Io`'s optional `path` as a `Display`-message suffix
/// (empty when `None`).
fn io_path_suffix(path: &Option<PathBuf>) -> String {
    match path {
        Some(p) => format!(" (path: {})", p.display()),
        None => String::new(),
    }
}

/// Converts a path-less I/O error (e.g. from an in-memory buffer) via `?`.
/// Errors with a known path should still be constructed explicitly as
/// `Error::Io { path: Some(..), source }` so the path is reported.
impl From<std::io::Error> for Error {
    fn from(source: std::io::Error) -> Self {
        Error::Io { path: None, source }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::error::Error as _;
    use std::io;

    fn boxed_source(message: &str) -> Box<dyn std::error::Error + Send + Sync + 'static> {
        #[derive(Debug)]
        struct StubError(String);

        impl std::fmt::Display for StubError {
            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
                write!(f, "{}", self.0)
            }
        }

        impl std::error::Error for StubError {}

        Box::new(StubError(message.to_string()))
    }

    #[test]
    fn display_messages() {
        assert_eq!(
            Error::MissingRelationshipPart("xl/_rels/workbook.xml.rels".into()).to_string(),
            "required relationship part not found: xl/_rels/workbook.xml.rels"
        );

        assert_eq!(
            Error::DanglingRelationship {
                r_id: "rId1".into()
            }
            .to_string(),
            "dangling relationship reference: r:id=rId1"
        );

        assert_eq!(
            Error::ZipBombDetected {
                limit: 100,
                actual: 200
            }
            .to_string(),
            "zip bomb detected: uncompressed size 200 bytes exceeds limit 100 bytes"
        );

        assert_eq!(
            Error::ZipSlipDetected {
                entry_name: "../evil".into()
            }
            .to_string(),
            "path traversal detected in zip entry: ../evil"
        );

        assert_eq!(
            Error::InvalidPackage("missing [Content_Types].xml".into()).to_string(),
            "not a valid .xlsx package: missing [Content_Types].xml"
        );

        assert_eq!(
            Error::XmlParse {
                path: "xl/worksheets/sheet1.xml".into(),
                source: boxed_source("unexpected eof"),
            }
            .to_string(),
            "XML parse error in xl/worksheets/sheet1.xml: unexpected eof"
        );

        assert_eq!(
            Error::MissingRequiredElement {
                path: "xl/worksheets/sheet1.xml".into(),
                name: "r",
            }
            .to_string(),
            "missing required element/attribute `r` in xl/worksheets/sheet1.xml"
        );

        assert_eq!(
            Error::DoctypeRejected {
                path: "xl/sharedStrings.xml".into()
            }
            .to_string(),
            "DOCTYPE declaration rejected in xl/sharedStrings.xml (XXE defense)"
        );

        assert_eq!(
            Error::InvalidCellRef("A0".into()).to_string(),
            "invalid cell reference: \"A0\""
        );

        assert_eq!(
            Error::SharedStringIndexOutOfBounds { index: 5, len: 3 }.to_string(),
            "shared string index 5 out of bounds (table len=3)"
        );

        assert_eq!(
            Error::InvalidStyleId(42).to_string(),
            "invalid style id: 42"
        );

        assert_eq!(
            Error::InvalidMergedRange {
                start: "A1".into(),
                end: "B2".into(),
                reason: "overlaps existing range".into(),
            }
            .to_string(),
            "invalid merged cell range A1:B2: overlaps existing range"
        );

        assert_eq!(
            Error::TooManyMergedRanges {
                count: 20_001,
                limit: 20_000,
            }
            .to_string(),
            "too many merged cell ranges in one sheet: 20001 exceeds limit 20000"
        );

        assert_eq!(
            Error::JsonSerialize {
                source: boxed_source("trailing comma"),
            }
            .to_string(),
            "JSON serialization error: trailing comma"
        );

        assert_eq!(
            Error::Io {
                path: Some(PathBuf::from("book.xlsx")),
                source: io::Error::new(io::ErrorKind::NotFound, "no such file"),
            }
            .to_string(),
            "I/O error (path: book.xlsx): no such file"
        );

        assert_eq!(
            Error::Io {
                path: None,
                source: io::Error::new(io::ErrorKind::NotFound, "no such file"),
            }
            .to_string(),
            "I/O error: no such file"
        );
    }

    #[test]
    fn io_error_converts_via_from_with_no_path() {
        let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "denied");
        let err: Error = io_err.into();
        match &err {
            Error::Io { path, source } => {
                assert_eq!(*path, None);
                assert_eq!(source.to_string(), "denied");
            }
            other => panic!("expected Error::Io, got {other:?}"),
        }
        assert_eq!(err.to_string(), "I/O error: denied");
    }

    #[test]
    fn from_io_error_propagates_via_question_mark() {
        fn fallible() -> Result<()> {
            Err(io::Error::other("boom"))?;
            Ok(())
        }

        let err = fallible().unwrap_err();
        assert!(matches!(err, Error::Io { path: None, .. }));
    }

    #[test]
    fn source_chain_is_preserved() {
        let xml_err = Error::XmlParse {
            path: "xl/worksheets/sheet1.xml".into(),
            source: boxed_source("unexpected eof"),
        };
        assert_eq!(xml_err.source().unwrap().to_string(), "unexpected eof");

        let json_err = Error::JsonSerialize {
            source: boxed_source("trailing comma"),
        };
        assert_eq!(json_err.source().unwrap().to_string(), "trailing comma");

        let io_err = Error::Io {
            path: None,
            source: io::Error::new(io::ErrorKind::PermissionDenied, "denied"),
        };
        assert_eq!(io_err.source().unwrap().to_string(), "denied");
    }
}