Skip to main content

openbim_bcf/
error.rs

1//! Errors that stop a BCF archive from being read at all.
2//!
3//! The dividing line: a [`BcfError`] means the caller gets **nothing**. Every
4//! deviation that still leaves a usable document is a
5//! [`Diagnostic`][crate::Diagnostic] instead. Given a corpus where 21 of 44
6//! real archives lack `bcf.version` and none carry `project.bcfp`, putting
7//! spec deviations in this type would make the crate useless.
8
9use std::fmt;
10use std::path::PathBuf;
11
12/// A failure that prevents reading a BCF archive.
13#[derive(Debug)]
14#[non_exhaustive]
15pub enum BcfError {
16    /// The file could not be opened or read.
17    Io {
18        /// The path involved, when one is known.
19        path: Option<PathBuf>,
20        /// The underlying I/O failure.
21        source: std::io::Error,
22    },
23    /// The bytes are not a readable ZIP container.
24    NotAnArchive {
25        /// What the ZIP reader reported.
26        detail: String,
27    },
28    /// The archive contains no markup document, so there is no BCF content.
29    ///
30    /// Distinguished from an archive with unreadable topics: that yields
31    /// diagnostics and whatever parsed, while this means the container held
32    /// nothing recognisable as BCF at all.
33    NoTopics,
34    /// A declared limit in [`Limits`][crate::Limits] was exceeded.
35    ///
36    /// BCF archives arrive from untrusted parties, so decompression is bounded
37    /// rather than trusted. This is refusal, not corruption.
38    LimitExceeded {
39        /// Which limit tripped.
40        limit: &'static str,
41        /// The bound that was set.
42        allowed: u64,
43        /// What the archive asked for, when it is known up front.
44        requested: u64,
45    },
46    /// An archive entry escapes the extraction root, or is otherwise unsafe.
47    ///
48    /// Kept fatal on purpose: a traversal path is not a tolerable quirk, it is
49    /// an attempt to write outside the archive.
50    UnsafeEntry {
51        /// The offending entry name, verbatim.
52        name: String,
53    },
54}
55
56impl fmt::Display for BcfError {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        match self {
59            BcfError::Io { path: Some(p), .. } => {
60                write!(f, "cannot read BCF archive {}", p.display())
61            }
62            BcfError::Io { path: None, .. } => f.write_str("cannot read BCF archive"),
63            BcfError::NotAnArchive { detail } => {
64                write!(f, "not a readable BCF (ZIP) archive: {detail}")
65            }
66            BcfError::NoTopics => f.write_str("archive contains no BCF markup document"),
67            BcfError::LimitExceeded {
68                limit,
69                allowed,
70                requested,
71            } => write!(
72                f,
73                "BCF archive exceeds the {limit} limit ({requested} > {allowed})"
74            ),
75            BcfError::UnsafeEntry { name } => {
76                write!(f, "BCF archive entry escapes the archive root: {name:?}")
77            }
78        }
79    }
80}
81
82impl std::error::Error for BcfError {
83    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
84        match self {
85            BcfError::Io { source, .. } => Some(source),
86            _ => None,
87        }
88    }
89}
90
91impl From<std::io::Error> for BcfError {
92    fn from(source: std::io::Error) -> Self {
93        BcfError::Io { path: None, source }
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use std::error::Error as _;
101
102    #[test]
103    fn io_errors_keep_their_source_and_path() {
104        let err = BcfError::Io {
105            path: Some(PathBuf::from("/tmp/x.bcfzip")),
106            source: std::io::Error::new(std::io::ErrorKind::NotFound, "missing"),
107        };
108        assert!(err.to_string().contains("/tmp/x.bcfzip"));
109        assert!(err.source().is_some());
110    }
111
112    #[test]
113    fn limit_messages_name_the_limit_and_both_numbers() {
114        let msg = BcfError::LimitExceeded {
115            limit: "uncompressed size",
116            allowed: 10,
117            requested: 99,
118        }
119        .to_string();
120        assert!(msg.contains("uncompressed size"), "{msg}");
121        assert!(msg.contains("99") && msg.contains("10"), "{msg}");
122    }
123
124    #[test]
125    fn unsafe_entries_are_reported_verbatim() {
126        let msg = BcfError::UnsafeEntry {
127            name: "../../etc/passwd".into(),
128        }
129        .to_string();
130        assert!(msg.contains("../../etc/passwd"), "{msg}");
131    }
132
133    #[test]
134    fn no_topics_is_distinct_from_not_an_archive() {
135        assert_ne!(
136            BcfError::NoTopics.to_string(),
137            BcfError::NotAnArchive { detail: "x".into() }.to_string()
138        );
139    }
140}