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