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 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}