use std::path::PathBuf;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("required relationship part not found: {0}")]
MissingRelationshipPart(String),
#[error("dangling relationship reference: r:id={r_id}")]
DanglingRelationship { r_id: String },
#[error("zip bomb detected: uncompressed size {actual} bytes exceeds limit {limit} bytes")]
ZipBombDetected { limit: u64, actual: u64 },
#[error("path traversal detected in zip entry: {entry_name}")]
ZipSlipDetected { entry_name: String },
#[error("not a valid .xlsx package: {0}")]
InvalidPackage(String),
#[error("XML parse error in {path}: {source}")]
XmlParse {
path: String,
#[source]
source: Box<dyn std::error::Error + Send + Sync + 'static>,
},
#[error("missing required element/attribute `{name}` in {path}")]
MissingRequiredElement { path: String, name: &'static str },
#[error("DOCTYPE declaration rejected in {path} (XXE defense)")]
DoctypeRejected { path: String },
#[error("invalid cell reference: {0:?}")]
InvalidCellRef(String),
#[error("shared string index {index} out of bounds (table len={len})")]
SharedStringIndexOutOfBounds { index: usize, len: usize },
#[error("invalid style id: {0}")]
InvalidStyleId(u32),
#[error("invalid merged cell range {start}:{end}: {reason}")]
InvalidMergedRange {
start: String,
end: String,
reason: String,
},
#[error("too many merged cell ranges in one sheet: {count} exceeds limit {limit}")]
TooManyMergedRanges { count: usize, limit: usize },
#[error("JSON serialization error: {source}")]
JsonSerialize {
#[source]
source: Box<dyn std::error::Error + Send + Sync + 'static>,
},
#[error("I/O error{}: {source}", io_path_suffix(path))]
Io {
path: Option<PathBuf>,
#[source]
source: std::io::Error,
},
}
fn io_path_suffix(path: &Option<PathBuf>) -> String {
match path {
Some(p) => format!(" (path: {})", p.display()),
None => String::new(),
}
}
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");
}
}