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}