Skip to main content

strypt_core/
error.rs

1//! Typed errors.
2//!
3//! Callers must be able to distinguish "this format is not supported" from "this file is
4//! corrupt" from "the disk is full", because those three demand different actions from the
5//! user and different exit codes from the CLI. That is why `strypt-core` uses `thiserror`
6//! and never `anyhow` (ADR-0008): a boxed, stringly-typed error erases exactly the
7//! distinction the front-ends need to make.
8//!
9//! # These messages must never contain metadata values
10//!
11//! An error string is durable — it lands in terminal scrollback, in a shell's history file,
12//! in a bug report pasted into a public issue tracker. A message that helpfully quoted the
13//! GPS coordinate it failed to parse would be a durable copy of the secret the user was
14//! trying to destroy (`docs/THREAT_MODEL.md` §5.5). Errors here name *fields, offsets, and
15//! counts*. They never name values.
16
17use crate::detect::Format;
18
19/// Everything that can go wrong in `strypt-core`.
20///
21/// Non-exhaustive: new variants are additive and must not break front-ends that match on it.
22#[derive(Debug, thiserror::Error)]
23#[non_exhaustive]
24pub enum StryptError {
25    /// The underlying I/O operation failed.
26    ///
27    /// `action` says what was being attempted, so a front-end can render "could not read the
28    /// input" rather than a bare `ENOENT`.
29    #[error("i/o failure while {action}")]
30    Io {
31        /// What the operation was trying to do.
32        action: IoAction,
33        /// The operating system's error.
34        #[source]
35        source: std::io::Error,
36    },
37
38    /// The input exceeds the configured size limit and was not read.
39    ///
40    /// This is a refusal, not a failure: an unbounded read of an attacker-supplied file is a
41    /// memory-exhaustion vector (`docs/ARCHITECTURE.md` §5.1), so strypt declines rather than
42    /// trying and dying.
43    #[error("input is larger than the configured limit of {limit} bytes")]
44    InputTooLarge {
45        /// The limit that was exceeded, in bytes.
46        limit: u64,
47        /// The input's actual size, where the source could report it.
48        actual: Option<u64>,
49    },
50
51    /// The content was recognised, but no handler for it exists in this release.
52    ///
53    /// Reported explicitly and never silently passed through — a silent pass-through is the
54    /// failure mode in `docs/THREAT_MODEL.md` §5.4, where the user publishes a file the tool
55    /// implied it had cleaned.
56    #[error("{format} is not supported in this release")]
57    UnsupportedFormat {
58        /// What the content was identified as.
59        format: UnsupportedKind,
60    },
61
62    /// The content matched no format strypt recognises at all.
63    #[error("the content does not match any format strypt recognises")]
64    UnrecognisedFormat,
65
66    /// The file claims to be `format` but violates its structure.
67    ///
68    // The offset is formatted by hand because `{offset:?}` on an `Option` renders "None" or
69    // "Some(42)" — debug syntax shown to someone deciding whether to publish a document. When
70    // the position is unknown, saying nothing is better than saying "None".
71    #[error("malformed {format}{}: {detail}", .offset.map_or_else(String::new, |o| format!(" at byte offset {o}")))]
72    Malformed {
73        /// The format whose rules were broken.
74        format: Format,
75        /// Where the parser gave up, when the position is known.
76        offset: Option<u64>,
77        /// Which structural rule was violated. Never contains a metadata value.
78        detail: MalformedDetail,
79    },
80
81    /// A hostile or pathological file hit one of the parser's resource ceilings.
82    ///
83    /// For memory-safe Rust this is the realistic residual attack class, not memory
84    /// corruption (`docs/THREAT_MODEL.md` §5.1), so it gets its own variant rather than being
85    /// folded into [`StryptError::Malformed`].
86    #[error("{format} parsing exceeded the {limit} limit")]
87    LimitExceeded {
88        /// The format being parsed when the ceiling was hit.
89        format: Format,
90        /// Which ceiling.
91        limit: ResourceLimit,
92    },
93
94    /// The handler produced output, but re-inspecting that output still found metadata.
95    ///
96    /// The output is discarded. This is the verification pass in `docs/ARCHITECTURE.md` §1
97    /// doing its job: it converts a silent handler bug into a loud, safe failure, which is
98    /// the whole reason it exists.
99    #[error("verification failed: {residual} metadata item(s) survived stripping")]
100    VerificationFailed {
101        /// The format that was being stripped.
102        format: Format,
103        /// How many items the re-inspection still found. Counts only — never the values.
104        residual: usize,
105    },
106}
107
108/// What an I/O operation was attempting when it failed.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110#[non_exhaustive]
111pub enum IoAction {
112    /// Opening or reading the input file.
113    ReadingInput,
114    /// Determining the input's size before reading it.
115    MeasuringInput,
116    /// Creating the temporary file that output is written to.
117    CreatingTemporary,
118    /// Writing sanitised bytes.
119    WritingOutput,
120    /// Flushing and synchronising output to disk.
121    SyncingOutput,
122    /// Renaming the temporary file over the destination.
123    ReplacingDestination,
124    /// Setting permissions on the output.
125    SettingPermissions,
126    /// Removing a temporary file after a failure.
127    CleaningUp,
128}
129
130impl std::fmt::Display for IoAction {
131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        let s = match self {
133            Self::ReadingInput => "reading the input",
134            Self::MeasuringInput => "measuring the input",
135            Self::CreatingTemporary => "creating a temporary file",
136            Self::WritingOutput => "writing output",
137            Self::SyncingOutput => "syncing output to disk",
138            Self::ReplacingDestination => "replacing the destination file",
139            Self::SettingPermissions => "setting output permissions",
140            Self::CleaningUp => "removing a temporary file",
141        };
142        f.write_str(s)
143    }
144}
145
146/// A format strypt can identify but cannot yet process.
147///
148/// Naming it is worth the small amount of detection code: "this is an `OpenXML` document,
149/// which arrives in Phase 2" is actionable, where "unrecognised" sends the user away
150/// believing their file is exotic when it is merely out of scope.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152#[non_exhaustive]
153pub enum UnsupportedKind {
154    /// A format strypt identifies and has a place for, but whose handler has not landed yet.
155    ///
156    /// Distinct from the Phase 2 formats below, because the advice differs: "not in this
157    /// release" versus "not in this phase of the project".
158    NotYetImplemented(crate::detect::Format),
159    /// A ZIP container, which may be an Office document, an ODF document, or an archive.
160    ZipContainer,
161    /// GIF.
162    Gif,
163    /// TIFF.
164    Tiff,
165    /// An ISO base-media file: MP4, M4A, HEIF, AVIF.
166    IsoBaseMedia,
167    /// An MP3 audio file.
168    Mp3,
169    /// An Ogg container.
170    Ogg,
171    /// A FLAC audio file.
172    Flac,
173    /// A RIFF container that is not WebP, such as WAV or AVI.
174    OtherRiff,
175    /// SVG or another XML document.
176    Xml,
177}
178
179impl std::fmt::Display for UnsupportedKind {
180    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
181        // Written as a total match rather than an early return plus `unreachable!()`: this
182        // crate has no panic paths, and "obviously unreachable" is how they get introduced.
183        let s = match self {
184            Self::NotYetImplemented(format) => {
185                return write!(f, "{format}, whose handler has not landed yet");
186            }
187            Self::ZipContainer => "a ZIP container (Office, OpenDocument, or archive)",
188            Self::Gif => "GIF",
189            Self::Tiff => "TIFF",
190            Self::IsoBaseMedia => "an ISO base-media file (MP4, HEIF, or AVIF)",
191            Self::Mp3 => "MP3",
192            Self::Ogg => "Ogg",
193            Self::Flac => "FLAC",
194            Self::OtherRiff => "a RIFF container other than WebP",
195            Self::Xml => "an XML document (possibly SVG)",
196        };
197        f.write_str(s)
198    }
199}
200
201/// Which structural rule a malformed file broke.
202///
203/// Deliberately coarse. The purpose is to let a user tell "this file is truncated" from
204/// "this file is not really the format it claims", not to provide a parser trace.
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206#[non_exhaustive]
207pub enum MalformedDetail {
208    /// The file ends in the middle of a structure that declared more bytes.
209    Truncated,
210    /// A length or offset field points outside the file.
211    LengthOutOfRange,
212    /// A required structural marker is missing.
213    MissingMarker,
214    /// A structural marker appeared where it is not permitted.
215    UnexpectedMarker,
216    /// The cross-reference or index structure is unusable.
217    BrokenIndex,
218    /// The file's objects reference each other in a cycle.
219    CyclicReference,
220    /// The file uses a feature strypt will not process, such as encryption.
221    UnsupportedFeature,
222    /// A third-party parser panicked on this file and the panic was contained.
223    ///
224    /// Reported as malformed input rather than as an internal error because that is what it
225    /// means for the user: the file was not processed and nothing was written. It is a
226    /// distinct variant rather than being folded into `BrokenIndex` because a panic in a
227    /// dependency is a defect worth being able to find in the wild, not an ordinary refusal
228    /// (`crate::panic_guard`).
229    DependencyPanic,
230}
231
232impl std::fmt::Display for MalformedDetail {
233    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234        let s = match self {
235            Self::Truncated => "the file ends mid-structure",
236            Self::LengthOutOfRange => "a declared length or offset falls outside the file",
237            Self::MissingMarker => "a required structural marker is missing",
238            Self::UnexpectedMarker => "a structural marker appeared where it is not allowed",
239            Self::BrokenIndex => "the cross-reference structure is unusable",
240            Self::CyclicReference => "objects reference each other in a cycle",
241            Self::UnsupportedFeature => "the file uses a feature strypt will not process",
242            Self::DependencyPanic => "the parser failed on this file and it was not processed",
243        };
244        f.write_str(s)
245    }
246}
247
248/// Which parser ceiling a file hit.
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250#[non_exhaustive]
251pub enum ResourceLimit {
252    /// Nesting depth. Guards against stack exhaustion, which aborts the process and so
253    /// cannot be recovered from after the fact.
254    Depth,
255    /// Number of objects, segments, or chunks.
256    ItemCount,
257    /// Total bytes a single structure may expand to.
258    ExpandedSize,
259}
260
261impl std::fmt::Display for ResourceLimit {
262    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
263        let s = match self {
264            Self::Depth => "nesting depth",
265            Self::ItemCount => "item count",
266            Self::ExpandedSize => "expanded size",
267        };
268        f.write_str(s)
269    }
270}
271
272/// The result type used throughout `strypt-core`.
273pub type Result<T> = std::result::Result<T, StryptError>;
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn error_messages_carry_structure_not_values() {
281        // The guard from docs/THREAT_MODEL.md §5.5: a rendered error is durable, so it may
282        // report counts and offsets but never the metadata itself.
283        let e = StryptError::VerificationFailed {
284            format: Format::Pdf,
285            residual: 3,
286        };
287        let rendered = e.to_string();
288        assert!(rendered.contains('3'));
289        assert!(!rendered.contains("Author"));
290    }
291
292    #[test]
293    fn unsupported_is_distinguishable_from_unrecognised() {
294        // A front-end must be able to tell "Phase 2 will handle this" from "no idea what
295        // this is" — they warrant different advice and different exit codes.
296        let known = StryptError::UnsupportedFormat {
297            format: UnsupportedKind::ZipContainer,
298        };
299        assert!(matches!(known, StryptError::UnsupportedFormat { .. }));
300        assert!(matches!(
301            StryptError::UnrecognisedFormat,
302            StryptError::UnrecognisedFormat
303        ));
304    }
305}