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 destination exists and the caller did not ask to replace it. A refusal, not an I/O
39 /// failure: the fix is the caller's, so it must not read as a disk problem.
40 #[error("the output file already exists")]
41 OutputExists,
42
43 /// The input exceeds the configured size limit and was not read.
44 ///
45 /// This is a refusal, not a failure: an unbounded read of an attacker-supplied file is a
46 /// memory-exhaustion vector (`docs/ARCHITECTURE.md` §5.1), so strypt declines rather than
47 /// trying and dying.
48 #[error("input is larger than the configured limit of {limit} bytes")]
49 InputTooLarge {
50 /// The limit that was exceeded, in bytes.
51 limit: u64,
52 /// The input's actual size, where the source could report it.
53 actual: Option<u64>,
54 },
55
56 /// The content was recognised, but no handler for it exists in this release.
57 ///
58 /// Reported explicitly and never silently passed through — a silent pass-through is the
59 /// failure mode in `docs/THREAT_MODEL.md` §5.4, where the user publishes a file the tool
60 /// implied it had cleaned.
61 #[error("{format} is not supported in this release")]
62 UnsupportedFormat {
63 /// What the content was identified as.
64 format: UnsupportedKind,
65 },
66
67 /// The content matched no format strypt recognises at all.
68 #[error("the content does not match any format strypt recognises")]
69 UnrecognisedFormat,
70
71 /// The file claims to be `format` but violates its structure.
72 ///
73 // The offset is formatted by hand because `{offset:?}` on an `Option` renders "None" or
74 // "Some(42)" — debug syntax shown to someone deciding whether to publish a document. When
75 // the position is unknown, saying nothing is better than saying "None".
76 #[error("malformed {format}{}: {detail}", .offset.map_or_else(String::new, |o| format!(" at byte offset {o}")))]
77 Malformed {
78 /// The format whose rules were broken.
79 format: Format,
80 /// Where the parser gave up, when the position is known.
81 offset: Option<u64>,
82 /// Which structural rule was violated. Never contains a metadata value.
83 detail: MalformedDetail,
84 },
85
86 /// A hostile or pathological file hit one of the parser's resource ceilings.
87 ///
88 /// For memory-safe Rust this is the realistic residual attack class, not memory
89 /// corruption (`docs/THREAT_MODEL.md` §5.1), so it gets its own variant rather than being
90 /// folded into [`StryptError::Malformed`].
91 #[error("{format} parsing exceeded the {limit} limit")]
92 LimitExceeded {
93 /// The format being parsed when the ceiling was hit.
94 format: Format,
95 /// Which ceiling.
96 limit: ResourceLimit,
97 },
98
99 /// The handler produced output, but re-inspecting that output still found metadata.
100 ///
101 /// The output is discarded. This is the verification pass in `docs/ARCHITECTURE.md` §1
102 /// doing its job: it converts a silent handler bug into a loud, safe failure, which is
103 /// the whole reason it exists.
104 #[error("verification failed: {residual} metadata item(s) survived stripping")]
105 VerificationFailed {
106 /// The format that was being stripped.
107 format: Format,
108 /// How many items the re-inspection still found. Counts only — never the values.
109 residual: usize,
110 },
111}
112
113/// What an I/O operation was attempting when it failed.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115#[non_exhaustive]
116pub enum IoAction {
117 /// Opening or reading the input file.
118 ReadingInput,
119 /// Determining the input's size before reading it.
120 MeasuringInput,
121 /// Creating the temporary file that output is written to.
122 CreatingTemporary,
123 /// Writing sanitised bytes.
124 WritingOutput,
125 /// Flushing and synchronising output to disk.
126 SyncingOutput,
127 /// Renaming the temporary file over the destination.
128 ReplacingDestination,
129 /// Setting permissions on the output.
130 SettingPermissions,
131 /// Removing a temporary file after a failure.
132 CleaningUp,
133}
134
135impl std::fmt::Display for IoAction {
136 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137 let s = match self {
138 Self::ReadingInput => "reading the input",
139 Self::MeasuringInput => "measuring the input",
140 Self::CreatingTemporary => "creating a temporary file",
141 Self::WritingOutput => "writing output",
142 Self::SyncingOutput => "syncing output to disk",
143 Self::ReplacingDestination => "replacing the destination file",
144 Self::SettingPermissions => "setting output permissions",
145 Self::CleaningUp => "removing a temporary file",
146 };
147 f.write_str(s)
148 }
149}
150
151/// A format strypt can identify but cannot yet process.
152///
153/// Naming it is worth the small amount of detection code: "this is an `OpenXML` document,
154/// which arrives in Phase 2" is actionable, where "unrecognised" sends the user away
155/// believing their file is exotic when it is merely out of scope.
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157#[non_exhaustive]
158pub enum UnsupportedKind {
159 /// A format strypt identifies and has a place for, but whose handler has not landed yet.
160 ///
161 /// Distinct from the Phase 2 formats below, because the advice differs: "not in this
162 /// release" versus "not in this phase of the project".
163 NotYetImplemented(crate::detect::Format),
164 /// A ZIP container, which may be an Office document, an ODF document, or an archive.
165 ZipContainer,
166 /// A macro-enabled Office document — `.docm`, `.xlsm`, `.pptm`.
167 ///
168 /// Refused rather than handled, and named separately from [`Self::ZipContainer`] because
169 /// the advice differs. This is not "a later phase will get to it": the document carries a
170 /// `vbaProject.bin`, which is an OLE compound file with its own directory and its own
171 /// metadata streams that strypt cannot read. Reporting the document clean while a container
172 /// inside it went unexamined is the failure in `docs/THREAT_MODEL.md` §5.4 (ADR-0029).
173 MacroEnabledOffice,
174 /// An `OpenDocument` package of a type this release does not handle — a drawing, a formula,
175 /// a chart, a database, or any of the `-template` variants.
176 ///
177 /// Named separately from [`Self::ZipContainer`] because the two say different things to a
178 /// user: this one means the file was understood and declined, where the generic refusal
179 /// sounds like it was not recognised at all. `docs/ROADMAP.md` Phase 2 group 2 is `.odt`,
180 /// `.ods`, and `.odp`, and widening that needs a superseding ADR (ADR-0027).
181 OtherOpenDocument,
182 /// `BigTIFF`: the same byte-order marks as TIFF but magic number 43, with eight-byte
183 /// offsets throughout. Refused by name rather than parsed as the TIFF it is not, because a
184 /// parser reading its directories as ordinary TIFF ones produces confident nonsense
185 /// (ADR-0033).
186 BigTiff,
187 /// An ISO base-media file whose brands name nothing this release handles.
188 ///
189 /// Still HEIF and AVIF, progressive MP4 and M4A all have handlers, so what reaches this variant
190 /// is a container declaring some other brand entirely — or a motion HEIF, which carries a
191 /// still-image brand alongside a sequence one.
192 IsoBaseMedia,
193 /// A fragmented MP4 — a `moof`, `mfra`, `mvex`, `styp` or `sidx` box, or a DASH/CMAF brand.
194 ///
195 /// Refused rather than edited. `tfhd`'s `base_data_offset` and every `tfra` entry are absolute
196 /// file offsets again, spread across fragments this handler does not read, so the relocation
197 /// table the MP4 handler is built on cannot be constructed for them (ADR-0042). A fragmented
198 /// file edited as though it were progressive still opens and plays nothing.
199 FragmentedMp4,
200 /// An MP4 or M4A under Common Encryption or `FairPlay` — a `pssh` box, an `encv`/`enca`/`drms`
201 /// sample entry, or the `M4P ` brand.
202 ///
203 /// Refused on the reasoning behind [`Self::MacroEnabledOffice`]: the samples are ciphertext no
204 /// rule here matches, so reporting the file clean would mean reporting on a file nobody
205 /// examined (`docs/THREAT_MODEL.md` §5.4, ADR-0042).
206 ProtectedMedia,
207 /// A `QuickTime` movie — a `.mov`, declaring the `qt ` brand.
208 ///
209 /// The same box grammar as MP4 and a different vocabulary on top of it. Phase 2's fourth group
210 /// is MP4 and M4A (ADR-0037); widening it is a superseding ADR rather than a judgement call.
211 QuickTimeMovie,
212 /// A 3GPP or 3GPP2 file — the `3gp`/`3g2` brand family.
213 ///
214 /// Named rather than left as [`Self::IsoBaseMedia`], because it is a format a user has a name
215 /// for. Out of ADR-0037's scope for the reason [`Self::QuickTimeMovie`] is.
216 ThirdGenerationPartnership,
217 /// A HEIF or AVIF that carries a motion sequence as well as, or instead of, a still image.
218 ///
219 /// An Apple Live Photo is the common case: an ordinary-looking `.HEIC` with a video track
220 /// beside the picture. Named separately from [`Self::IsoBaseMedia`] because the user's file
221 /// *is* a photograph as far as they are concerned, and "this is an MP4" would be baffling.
222 ///
223 /// Refused rather than partly cleaned. `docs/ROADMAP.md` puts video containers in Phase 2's
224 /// fourth group; stripping the still and discarding the track would change what the file is
225 /// (`docs/PRD.md` §8.1) and would delete a track carrying its own metadata that nobody
226 /// parsed (ADR-0034).
227 MotionHeif,
228 /// An Ogg carrying Theora video.
229 ///
230 /// Video is Phase 2's fifth tranche at the earliest (ADR-0037), and a Theora stream carries
231 /// its own comment header this handler has not read.
232 OggTheora,
233 /// An Ogg whose codec strypt has no mapping for — Speex, Skeleton, or something unrecognised.
234 ///
235 /// The container is the same; what is in the packets is not, and the metadata lives in the
236 /// packets (ADR-0041).
237 OtherOggCodec,
238 /// An Ogg carrying more than one logical bitstream: a multiplexed or a chained file.
239 ///
240 /// Refused rather than partly cleaned. A second stream is a second mapping with a second
241 /// comment header, and cleaning one while copying the other through is the failure in
242 /// `docs/THREAT_MODEL.md` §5.4 (ADR-0041 decision 6).
243 MultiplexedOgg,
244 /// MPEG audio that is not Layer III — an `.mp1` or `.mp2`.
245 ///
246 /// The same frame grammar as MP3 and a different format, so it is named rather than stripped
247 /// as one. Phase 2's scope is MP3 (ADR-0027), and the layer field is what says which of the
248 /// three a file is (ADR-0040).
249 MpegAudioNotLayerThree,
250 /// A RIFF container that is neither WebP nor WAV, such as AVI.
251 OtherRiff,
252 /// An RF64 or BW64 file: a WAV whose payload exceeds what a 32-bit RIFF size can express.
253 ///
254 /// A different container spelling, not a large WAV. The real sizes live in a `ds64` chunk and
255 /// the RIFF size field is a `-1` placeholder, so a handler that treated it as WAV would walk
256 /// the wrong extent. Named rather than left unrecognised (ADR-0039).
257 Rf64,
258 /// A WAV whose audio is a `wavl` wave list rather than a single `data` chunk.
259 ///
260 /// The one WAV shape where removing a chunk could move something: `cue ` offsets index into
261 /// the wave list's data section, which is exactly what ADR-0034 found in HEIF. Refused rather
262 /// than edited, because getting it wrong yields a file that still plays the wrong bytes
263 /// (ADR-0039).
264 WaveList,
265 /// An XML document that is not an SVG this release claims.
266 ///
267 /// Also where an SVG with a *prefixed* root element — `<svg:svg>`, which very old Inkscape
268 /// releases wrote — lands. The handler removes prefixed elements on an allow-list (ADR-0035),
269 /// so claiming that document would mean removing it; refusing is fail-closed.
270 Xml,
271 /// A gzip stream, which in this project's world is usually a `.svgz`.
272 ///
273 /// Refused rather than handled. Inflating it would put a decompressor on the input path and a
274 /// compressor on the output path for no metadata gain, and the user can decompress it
275 /// themselves in one command (ADR-0035). Named rather than left unrecognised, because
276 /// "unrecognised" is untrue for a common spelling of a format strypt does handle.
277 Gzip,
278 /// An SVG carrying a script, an event-handler attribute, or a `foreignObject`.
279 ///
280 /// Refused rather than partly cleaned, on the same reasoning as [`Self::MacroEnabledOffice`]:
281 /// the document contains executable code strypt has no parser for, which is free to hold a
282 /// name, a path, a credential, or a base64 copy of anything at all. Removing it would change
283 /// what the file does (`docs/PRD.md` §8.1); keeping it would mean reporting success on a file
284 /// that runs unexamined code the moment a reader opens it, which is
285 /// `docs/THREAT_MODEL.md` §5.4. mat2 re-renders SVG and is the better recommendation for a
286 /// user who needs the script gone (ADR-0035).
287 ScriptedSvg,
288 /// A JPEG XL carrying a top-level box strypt does not recognise.
289 ///
290 /// Boxes reach the output from an allow-list, so an unrecognised one is refused rather than
291 /// copied through: an unknown top-level box in a format that keeps its metadata in top-level
292 /// boxes is more likely to be metadata than not (ADR-0036).
293 UnknownJxlBox,
294}
295
296impl std::fmt::Display for UnsupportedKind {
297 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
298 // Written as a total match rather than an early return plus `unreachable!()`: this
299 // crate has no panic paths, and "obviously unreachable" is how they get introduced.
300 let s = match self {
301 Self::NotYetImplemented(format) => {
302 return write!(f, "{format}, whose handler has not landed yet");
303 }
304 Self::ZipContainer => "a ZIP container (Office, OpenDocument, or archive)",
305 Self::MacroEnabledOffice => {
306 "a macro-enabled Office document, whose embedded VBA project strypt cannot read"
307 }
308 Self::OtherOpenDocument => {
309 "an OpenDocument type strypt does not handle yet (a drawing, formula, chart, or template)"
310 }
311 Self::BigTiff => "BigTIFF",
312 Self::IsoBaseMedia => {
313 "an ISO base-media file whose brands name no format strypt handles"
314 }
315 Self::FragmentedMp4 => {
316 "a fragmented MP4, whose sample offsets strypt will not relocate"
317 }
318 Self::ProtectedMedia => {
319 "an encrypted MP4 or M4A (Common Encryption or FairPlay), whose samples strypt \
320 cannot examine"
321 }
322 Self::QuickTimeMovie => "a QuickTime movie (.mov), which is a different vocabulary",
323 Self::ThirdGenerationPartnership => "a 3GPP or 3GPP2 file",
324 Self::MotionHeif => "a motion HEIF or AVIF (an Apple Live Photo, for instance)",
325 Self::OggTheora => "an Ogg carrying Theora video",
326 Self::OtherOggCodec => {
327 "an Ogg carrying a codec strypt has no mapping for (Speex or Skeleton, for instance)"
328 }
329 Self::MultiplexedOgg => {
330 "an Ogg carrying more than one logical bitstream, which strypt will not partly clean"
331 }
332 Self::MpegAudioNotLayerThree => {
333 "MPEG audio that is not Layer III (an .mp1 or .mp2), which is a different format"
334 }
335 Self::OtherRiff => "a RIFF container other than WebP or WAV",
336 Self::Rf64 => {
337 "an RF64 or BW64 file, which is a different container from WAV and not a large one"
338 }
339 Self::WaveList => "a WAV whose audio is a wave list rather than a single data chunk",
340 Self::Xml => "an XML document that is not an SVG strypt can process",
341 Self::Gzip => "a gzip-compressed file, most likely a .svgz; decompress it first",
342 Self::UnknownJxlBox => "a JPEG XL carrying a top-level box strypt does not recognise",
343 Self::ScriptedSvg => {
344 "an SVG containing a script, an event handler, or a foreignObject, \
345 which strypt will not partly clean"
346 }
347 };
348 f.write_str(s)
349 }
350}
351
352/// Which structural rule a malformed file broke.
353///
354/// Deliberately coarse. The purpose is to let a user tell "this file is truncated" from
355/// "this file is not really the format it claims", not to provide a parser trace.
356#[derive(Debug, Clone, Copy, PartialEq, Eq)]
357#[non_exhaustive]
358pub enum MalformedDetail {
359 /// The file ends in the middle of a structure that declared more bytes.
360 Truncated,
361 /// A length or offset field points outside the file.
362 LengthOutOfRange,
363 /// A required structural marker is missing.
364 MissingMarker,
365 /// A structural marker appeared where it is not permitted.
366 UnexpectedMarker,
367 /// The cross-reference or index structure is unusable.
368 BrokenIndex,
369 /// The file's objects reference each other in a cycle.
370 CyclicReference,
371 /// The file uses a feature strypt will not process, such as encryption.
372 UnsupportedFeature,
373 /// The rewritten file did not read back as the document that was written.
374 ///
375 /// Distinct from every other variant here because it describes a failure found in
376 /// strypt's *output* rather than in the input: the document parsed, scrubbed, and
377 /// serialised, and the result then did not round-trip. The input is still what caused it
378 /// — a structure lenient enough to parse but not to reproduce — so it belongs with the
379 /// malformed refusals rather than being reported as an internal error, which would tell
380 /// the user to file a bug about a file that is genuinely broken.
381 NotRoundTrippable,
382 /// A third-party parser panicked on this file and the panic was contained.
383 ///
384 /// Reported as malformed input rather than as an internal error because that is what it
385 /// means for the user: the file was not processed and nothing was written. It is a
386 /// distinct variant rather than being folded into `BrokenIndex` because a panic in a
387 /// dependency is a defect worth being able to find in the wild, not an ordinary refusal
388 /// (`crate::panic_guard`).
389 DependencyPanic,
390}
391
392impl std::fmt::Display for MalformedDetail {
393 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
394 let s = match self {
395 Self::Truncated => "the file ends mid-structure",
396 Self::LengthOutOfRange => "a declared length or offset falls outside the file",
397 Self::MissingMarker => "a required structural marker is missing",
398 Self::UnexpectedMarker => "a structural marker appeared where it is not allowed",
399 Self::BrokenIndex => "the cross-reference structure is unusable",
400 Self::CyclicReference => "objects reference each other in a cycle",
401 Self::UnsupportedFeature => "the file uses a feature strypt will not process",
402 Self::NotRoundTrippable => {
403 "the file cannot be rewritten faithfully, so nothing was written"
404 }
405 Self::DependencyPanic => "the parser failed on this file and it was not processed",
406 };
407 f.write_str(s)
408 }
409}
410
411/// Which parser ceiling a file hit.
412#[derive(Debug, Clone, Copy, PartialEq, Eq)]
413#[non_exhaustive]
414pub enum ResourceLimit {
415 /// Nesting depth. Guards against stack exhaustion, which aborts the process and so
416 /// cannot be recovered from after the fact.
417 Depth,
418 /// Number of objects, segments, or chunks.
419 ItemCount,
420 /// Total bytes a single structure may expand to.
421 ExpandedSize,
422}
423
424impl std::fmt::Display for ResourceLimit {
425 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
426 let s = match self {
427 Self::Depth => "nesting depth",
428 Self::ItemCount => "item count",
429 Self::ExpandedSize => "expanded size",
430 };
431 f.write_str(s)
432 }
433}
434
435/// The result type used throughout `strypt-core`.
436pub type Result<T> = std::result::Result<T, StryptError>;
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441
442 #[test]
443 fn error_messages_carry_structure_not_values() {
444 // The guard from docs/THREAT_MODEL.md §5.5: a rendered error is durable, so it may
445 // report counts and offsets but never the metadata itself.
446 let e = StryptError::VerificationFailed {
447 format: Format::Pdf,
448 residual: 3,
449 };
450 let rendered = e.to_string();
451 assert!(rendered.contains('3'));
452 assert!(!rendered.contains("Author"));
453 }
454
455 #[test]
456 fn unsupported_is_distinguishable_from_unrecognised() {
457 // A front-end must be able to tell "Phase 2 will handle this" from "no idea what
458 // this is" — they warrant different advice and different exit codes.
459 let known = StryptError::UnsupportedFormat {
460 format: UnsupportedKind::ZipContainer,
461 };
462 assert!(matches!(known, StryptError::UnsupportedFormat { .. }));
463 assert!(matches!(
464 StryptError::UnrecognisedFormat,
465 StryptError::UnrecognisedFormat
466 ));
467 }
468}