Skip to main content

strypt_core/
panic_guard.rs

1//! Containment for panics raised inside third-party parsers.
2//!
3//! # Why this exists
4//!
5//! ADR-0006 requires strypt's own parsing code never to panic: malformed input is *expected*
6//! input, and failures are typed `Result` values. That rule cannot be extended to a dependency
7//! by wishing. `lopdf` is a third-party PDF parser handling attacker-controlled bytes
8//! (ADR-0018), and `docs/THREAT_MODEL.md` §5.1 names a panic inside it as one of the realistic
9//! residual risks that `forbid(unsafe_code)` does not address.
10//!
11//! That risk stopped being theoretical: a sustained fuzz run reached an integer overflow in
12//! `lopdf` 0.44.0's cross-reference parser (`parser/mod.rs:516`, `start + index` where `start`
13//! comes from the file). Because `Cargo.toml` deliberately enables `overflow-checks` in release
14//! so that an overflow aborts rather than wrapping into a nonsensical offset, the shipped
15//! binary panicked — a user handed a hostile PDF got a stack trace and exit code 101 instead of
16//! "this file could not be processed".
17//!
18//! # What this does, and what it does not
19//!
20//! `guard` runs a closure and converts an unwinding panic into a typed error, so a dependency's
21//! panic reaches the user as an ordinary refusal. **This is containment, not a fix.** The
22//! defect stays in the dependency and is reported upstream; this only stops it reaching the
23//! user as a crash.
24//!
25//! Three limits, stated because a guard that is trusted beyond its reach is worse than none:
26//!
27//! 1. **It requires unwinding panics.** Built with `panic = "abort"` the process dies before
28//!    any of this runs. strypt does not set `panic = "abort"`, and this is a reason not to.
29//! 2. **It cannot catch what does not unwind** — a stack overflow from deep recursion, an
30//!    abort, or a SIGSEGV. Bounded recursion (`ParseLimits`) is the control for the first.
31//! 3. **It says nothing about correctness.** A dependency that panicked may equally return a
32//!    wrong answer without panicking, which no guard detects. Fail-closed refusal on panic is a
33//!    floor, not a guarantee.
34//!
35//! # Why the panic message is suppressed
36//!
37//! The default panic hook prints to stderr. A panic message from a parser can quote the bytes
38//! it was parsing, and those bytes are the user's document — the metadata they are trying to
39//! destroy. CLAUDE.md §3.8 forbids printing metadata values, so a guarded panic must not print
40//! the default message. The hook is installed once and delegates to the previous hook whenever
41//! the guard is not active, so unguarded panics elsewhere still report normally.
42
43use std::cell::Cell;
44use std::panic::{self, AssertUnwindSafe};
45use std::sync::Once;
46
47thread_local! {
48    /// True while a guarded call is on this thread's stack.
49    static GUARDED: Cell<bool> = const { Cell::new(false) };
50}
51
52static HOOK: Once = Once::new();
53
54/// Install a panic hook that stays silent for guarded calls and delegates otherwise.
55///
56/// Installed at most once per process. The flag is thread-local, so a guarded call on one
57/// thread never silences a genuine panic on another.
58fn install_hook() {
59    HOOK.call_once(|| {
60        let previous = panic::take_hook();
61        panic::set_hook(Box::new(move |info| {
62            if GUARDED.with(Cell::get) {
63                // Deliberately silent: the caller turns this into a typed error, and the
64                // message may contain fragments of the user's document.
65                return;
66            }
67            previous(info);
68        }));
69    });
70}
71
72/// Run `f`, converting an unwinding panic into `on_panic()`.
73///
74/// # Errors
75///
76/// Returns whatever `f` returns on the ordinary path. If `f` panics and the panic unwinds,
77/// returns `on_panic()` instead — so a caller can distinguish "this file was refused" from
78/// "the parser fell over", which are different facts about the same input.
79///
80/// `AssertUnwindSafe` is used because the closure operates on values owned by the caller and
81/// nothing observable is shared across the boundary: on the panic path the partially-built
82/// value is dropped and an error is returned, so no caller can observe a half-updated state.
83pub fn guard<T, E, F, P>(f: F, on_panic: P) -> Result<T, E>
84where
85    F: FnOnce() -> Result<T, E>,
86    P: FnOnce() -> E,
87{
88    install_hook();
89
90    let previous = GUARDED.replace(true);
91    let result = panic::catch_unwind(AssertUnwindSafe(f));
92    GUARDED.set(previous);
93
94    match result {
95        Ok(value) => value,
96        Err(_) => Err(on_panic()),
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    #![allow(clippy::panic, clippy::unwrap_used)]
103
104    use super::guard;
105
106    #[derive(Debug, PartialEq)]
107    struct Refused;
108
109    #[test]
110    fn a_panic_becomes_an_error() {
111        let out: Result<u8, Refused> = guard(|| panic!("dependency exploded"), || Refused);
112        assert_eq!(out, Err(Refused));
113    }
114
115    #[test]
116    fn an_arithmetic_overflow_is_caught_like_any_other_panic() {
117        // The shape of the real finding: lopdf's `start + index` with overflow-checks on.
118        let out: Result<usize, Refused> = guard(
119            || {
120                // `black_box` because the compiler rejects a literal `usize::MAX + 1` outright
121                // via the arithmetic_overflow lint. The real overflow comes from parsed input
122                // the compiler cannot see, so hiding the value reproduces the real shape.
123                let start = std::hint::black_box(usize::MAX);
124                Ok(start + 1)
125            },
126            || Refused,
127        );
128        assert_eq!(out, Err(Refused));
129    }
130
131    #[test]
132    fn a_success_passes_through_untouched() {
133        let out: Result<u8, Refused> = guard(|| Ok(7), || Refused);
134        assert_eq!(out, Ok(7));
135    }
136
137    #[test]
138    fn an_ordinary_error_is_not_turned_into_a_panic_error() {
139        // A guard that flattened every failure into "it panicked" would erase the distinction
140        // between a refusal and a crash, which is the distinction the caller acts on.
141        #[derive(Debug, PartialEq)]
142        enum E {
143            Normal,
144            Panicked,
145        }
146        let out: Result<u8, E> = guard(|| Err(E::Normal), || E::Panicked);
147        assert_eq!(out, Err(E::Normal));
148    }
149
150    #[test]
151    fn the_guard_flag_is_cleared_afterwards() {
152        // If the flag leaked, a later genuine panic on this thread would be silenced — the
153        // guard would be suppressing exactly the reports it must not hide.
154        let _: Result<u8, Refused> = guard(|| panic!("boom"), || Refused);
155        super::GUARDED.with(|g| assert!(!g.get(), "guard flag leaked past the guarded call"));
156    }
157}