Skip to main content

nautilus_plugin/
panic.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Catch-unwind wrapper used by every plug-in `extern "C"` thunk.
17//!
18//! Unwinding across an FFI boundary is undefined behavior, so every host-bound
19//! call from a plug-in must be wrapped to convert a panic into a returned
20//! [`PluginError`] with code [`PluginErrorCode::Panic`].
21
22use std::panic::{AssertUnwindSafe, catch_unwind};
23
24use crate::boundary::{PluginError, PluginErrorCode, PluginResult};
25
26/// Wraps a closure in `catch_unwind` and maps a panic to a `PluginError`.
27///
28/// Macro-generated thunks call this so plug-in panics surface as errors instead
29/// of unwinding through the FFI.
30pub fn guard<T>(f: impl FnOnce() -> Result<T, PluginError>) -> PluginResult<T> {
31    let result = catch_unwind(AssertUnwindSafe(f));
32    match result {
33        Ok(Ok(t)) => PluginResult::Ok(t),
34        Ok(Err(e)) => PluginResult::Err(e),
35        Err(payload) => {
36            let message = panic_message(payload.as_ref());
37            drop_payload(payload);
38            PluginResult::Err(PluginError::new(PluginErrorCode::Panic, message))
39        }
40    }
41}
42
43/// Runs a closure under `catch_unwind` for thunks whose return type cannot
44/// carry a `PluginError` (e.g. `extern "C" fn(...) -> u64`).
45///
46/// On panic, logs the message and aborts the process. Aborting is the only
47/// sound option once a panic reaches this point: returning a sentinel would
48/// silently corrupt downstream computation, and unwinding across the FFI
49/// boundary is undefined behavior.
50pub fn guard_infallible<T>(thunk_name: &str, f: impl FnOnce() -> T) -> T {
51    match catch_unwind(AssertUnwindSafe(f)) {
52        Ok(t) => t,
53        Err(payload) => {
54            let msg = panic_message(payload.as_ref());
55            drop_payload(payload);
56            log::error!(
57                target: "nautilus_plugin",
58                "plug-in panicked in `{thunk_name}` thunk; aborting process: {msg}",
59            );
60            std::process::abort();
61        }
62    }
63}
64
65/// Runs a closure under `catch_unwind` for thunks that return a raw pointer
66/// where null already signals failure (`create`, `clone_handle`).
67///
68/// On panic, logs the message and returns null so the host can surface a
69/// recoverable error instead of the process aborting.
70pub fn guard_or_null<T>(thunk_name: &str, f: impl FnOnce() -> *mut T) -> *mut T {
71    match catch_unwind(AssertUnwindSafe(f)) {
72        Ok(ptr) => ptr,
73        Err(payload) => {
74            let msg = panic_message(payload.as_ref());
75            drop_payload(payload);
76            // A panicking logger must not unwind out of the thunk; the
77            // null-return contract holds even when reporting fails.
78            catch_unwind(AssertUnwindSafe(|| {
79                log::error!(
80                    target: "nautilus_plugin",
81                    "plug-in panicked in `{thunk_name}` thunk; returning null: {msg}",
82                );
83            }))
84            .unwrap_or_else(drop_payload);
85            std::ptr::null_mut()
86        }
87    }
88}
89
90/// Runs a destructor closure under `catch_unwind` for `drop_handle` thunks.
91///
92/// On panic, logs the message and returns normally, leaking whatever the
93/// destructor failed to release. A leaked value is recoverable; unwinding
94/// across the FFI boundary or aborting the process is not.
95pub fn guard_drop(thunk_name: &str, f: impl FnOnce()) {
96    if let Err(payload) = catch_unwind(AssertUnwindSafe(f)) {
97        let msg = panic_message(payload.as_ref());
98        drop_payload(payload);
99        // A panicking logger must not unwind out of the thunk; the
100        // swallow-and-leak contract holds even when reporting fails.
101        catch_unwind(AssertUnwindSafe(|| {
102            log::error!(
103                target: "nautilus_plugin",
104                "plug-in panicked in `{thunk_name}` thunk; value leaked: {msg}",
105            );
106        }))
107        .unwrap_or_else(drop_payload);
108    }
109}
110
111/// Drops a panic payload while suppressing any unwind from its `Drop` impl.
112///
113/// `std::panic::catch_unwind` catches the original panic, but if the payload
114/// itself panics on drop the second panic unwinds the caller. For an
115/// `extern "C"` thunk that is undefined behavior. Wrapping the drop in
116/// another `catch_unwind` keeps the surface around the FFI boundary
117/// unwind-free even with adversarial payloads (e.g. `panic_any(T)` where
118/// `T: Drop` panics). If disposal panics, its new payload is deliberately leaked.
119pub fn drop_payload(payload: Box<dyn std::any::Any + Send>) {
120    if let Err(nested) = catch_unwind(AssertUnwindSafe(move || drop(payload))) {
121        // Its destructor is also untrusted; attempting another drop can unwind again
122        #[allow(
123            clippy::mem_forget,
124            reason = "the replacement payload can also panic on drop"
125        )]
126        std::mem::forget(nested);
127    }
128}
129
130pub(crate) fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
131    if let Some(s) = payload.downcast_ref::<&'static str>() {
132        (*s).to_string()
133    } else if let Some(s) = payload.downcast_ref::<String>() {
134        s.clone()
135    } else {
136        "plug-in panicked with non-string payload".to_string()
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use std::sync::atomic::{AtomicUsize, Ordering};
143
144    use rstest::rstest;
145
146    use super::*;
147
148    #[rstest]
149    fn returns_ok_on_success() {
150        let r = guard(|| Ok::<u32, PluginError>(7));
151        assert_eq!(r.into_result().unwrap(), 7);
152    }
153
154    #[rstest]
155    fn returns_err_on_returned_error() {
156        let r = guard(|| Err::<u32, _>(PluginError::generic("boom")));
157        let e = r.into_result().unwrap_err();
158        assert_eq!(e.code, PluginErrorCode::Generic);
159        assert_eq!(e.message_string(), "boom");
160    }
161
162    #[rstest]
163    fn returns_err_on_string_panic() {
164        let r = guard(|| -> Result<u32, PluginError> { panic!("oops") });
165        let e = r.into_result().unwrap_err();
166        assert_eq!(e.code, PluginErrorCode::Panic);
167        assert!(e.message_string().contains("oops"));
168    }
169
170    #[rstest]
171    fn returns_err_on_non_string_panic() {
172        let r = guard(|| -> Result<u32, PluginError> {
173            std::panic::panic_any(42_u32);
174        });
175        let e = r.into_result().unwrap_err();
176        assert_eq!(e.code, PluginErrorCode::Panic);
177        assert!(e.message_string().contains("non-string"));
178    }
179
180    #[rstest]
181    fn guard_infallible_returns_inner_on_success() {
182        let v = guard_infallible("test", || 42u64);
183        assert_eq!(v, 42);
184    }
185
186    #[rstest]
187    fn guard_or_null_returns_inner_on_success() {
188        let boxed = Box::into_raw(Box::new(7u32));
189        let v = guard_or_null("test", || boxed);
190        assert_eq!(v, boxed);
191        // SAFETY: pointer originates from Box::into_raw above.
192        unsafe { drop(Box::from_raw(boxed)) };
193    }
194
195    #[rstest]
196    fn guard_or_null_returns_null_on_panic() {
197        let v: *mut u32 = guard_or_null("test", || panic!("create panic"));
198        assert!(v.is_null());
199    }
200
201    #[rstest]
202    fn guard_drop_runs_inner_on_success() {
203        let mut ran = false;
204        guard_drop("test", || ran = true);
205        assert!(ran);
206    }
207
208    #[rstest]
209    fn guard_drop_swallows_panic() {
210        guard_drop("test", || panic!("drop panic"));
211    }
212
213    #[rstest]
214    fn drop_payload_swallows_panicking_drop() {
215        // `drop_payload` runs the payload Drop inside an inner `catch_unwind`
216        // so a panicking Drop does not propagate out of the function. This
217        // test asserts the call returns normally even when the payload
218        // panics on drop.
219        use std::{
220            any::Any,
221            sync::atomic::{AtomicUsize, Ordering},
222        };
223
224        static DROPS_OBSERVED: AtomicUsize = AtomicUsize::new(0);
225        struct Bomb;
226        impl Drop for Bomb {
227            fn drop(&mut self) {
228                DROPS_OBSERVED.fetch_add(1, Ordering::SeqCst);
229                panic!("drop panic");
230            }
231        }
232        DROPS_OBSERVED.store(0, Ordering::SeqCst);
233
234        let payload: Box<dyn Any + Send> = Box::new(Bomb);
235        drop_payload(payload);
236        assert_eq!(DROPS_OBSERVED.load(Ordering::SeqCst), 1);
237    }
238
239    #[rstest]
240    fn guard_survives_panic_any_with_panicking_drop() {
241        // Regression: a panic payload whose Drop also panics must not unwind
242        // past `catch_unwind`. `drop_payload` wraps the payload drop in a
243        // second `catch_unwind`; without it the second panic aborts the host
244        // or causes UB in the `extern "C"` thunk.
245        static DROPS_OBSERVED: AtomicUsize = AtomicUsize::new(0);
246        struct Bomb;
247        impl Drop for Bomb {
248            fn drop(&mut self) {
249                DROPS_OBSERVED.fetch_add(1, Ordering::SeqCst);
250                panic!("drop panic");
251            }
252        }
253
254        DROPS_OBSERVED.store(0, Ordering::SeqCst);
255        let r = guard(|| -> Result<u32, PluginError> {
256            std::panic::panic_any(Bomb);
257        });
258        let e = r.into_result().unwrap_err();
259        assert_eq!(e.code, PluginErrorCode::Panic);
260
261        // Drop ran inside the inner catch_unwind; observed exactly once
262        assert_eq!(DROPS_OBSERVED.load(Ordering::SeqCst), 1);
263    }
264
265    #[rstest]
266    fn guard_contains_successive_panicking_payload_destructors() {
267        use std::sync::Arc;
268
269        struct Payload {
270            drops: Arc<AtomicUsize>,
271        }
272
273        impl Drop for Payload {
274            fn drop(&mut self) {
275                self.drops.fetch_add(1, Ordering::SeqCst);
276                std::panic::panic_any(Self {
277                    drops: Arc::clone(&self.drops),
278                });
279            }
280        }
281
282        let drops = Arc::new(AtomicUsize::new(0));
283        let result = catch_unwind(AssertUnwindSafe(|| {
284            guard(|| -> Result<(), PluginError> {
285                std::panic::panic_any(Payload {
286                    drops: Arc::clone(&drops),
287                });
288            })
289        }));
290        let result = match result {
291            Ok(result) => Some(result.into_result().unwrap_err()),
292            Err(payload) => {
293                // Disposing of this escaped payload would panic again in the failing test
294                #[allow(
295                    clippy::mem_forget,
296                    reason = "preserve the assertion failure without another panic"
297                )]
298                std::mem::forget(payload);
299                None
300            }
301        };
302
303        assert_eq!(drops.load(Ordering::SeqCst), 1);
304        let error = result.expect("panic cleanup must not unwind out of the guard");
305        assert_eq!(error.code, PluginErrorCode::Panic);
306        assert_eq!(
307            error.message_string(),
308            "plug-in panicked with non-string payload"
309        );
310    }
311
312    #[rstest]
313    fn guards_contain_panicking_logger_payloads() {
314        const CHILD: &str = "NAUTILUS_TEST_PANIC_LOGGER_CHILD";
315        static DROPS: AtomicUsize = AtomicUsize::new(0);
316        struct Payload;
317        impl Drop for Payload {
318            fn drop(&mut self) {
319                DROPS.fetch_add(1, Ordering::SeqCst);
320                std::panic::panic_any(Self);
321            }
322        }
323        struct Logger;
324        impl log::Log for Logger {
325            fn enabled(&self, _: &log::Metadata<'_>) -> bool {
326                true
327            }
328            fn log(&self, _: &log::Record<'_>) {
329                std::panic::panic_any(Payload);
330            }
331            fn flush(&self) {}
332        }
333        static LOGGER: Logger = Logger;
334        extern "C" fn exercise() {
335            let pointer = guard_or_null::<u8>("logger", || panic!("constructor panic"));
336            assert!(pointer.is_null());
337            guard_drop("logger", || panic!("destructor panic"));
338        }
339
340        if std::env::var_os(CHILD).is_none() {
341            let current = std::thread::current();
342            let name = current.name().expect("the test harness names its thread");
343            let output = std::process::Command::new(std::env::current_exe().unwrap())
344                .args(["--exact", name])
345                .env(CHILD, "1")
346                .output()
347                .unwrap();
348            assert!(
349                output.status.success(),
350                "{}",
351                String::from_utf8_lossy(&output.stderr)
352            );
353            return;
354        }
355
356        log::set_logger(&LOGGER).unwrap();
357        log::set_max_level(log::LevelFilter::Error);
358
359        exercise();
360
361        assert_eq!(DROPS.load(Ordering::SeqCst), 2);
362    }
363}