html_to_markdown_rs/
safety.rs

1//! Helpers to keep binding entrypoints panic-safe.
2//!
3//! Binding layers (PyO3, NAPI-RS, ext-php-rs, WASM, FFI) must not allow Rust
4//! panics to unwind into foreign runtimes. `guard_panic` wraps conversion calls,
5//! converts panics into `ConversionError::Panic`, and preserves the original
6//! error handling path for the caller.
7
8use std::any::Any;
9use std::panic::{self, AssertUnwindSafe, UnwindSafe};
10
11use crate::error::{ConversionError, Result};
12
13/// Run a fallible operation while preventing panics from unwinding across FFI.
14///
15/// Panics are captured and surfaced as `ConversionError::Panic` so bindings can
16/// translate them into language-native errors instead of aborting.
17pub fn guard_panic<F, T>(f: F) -> Result<T>
18where
19    F: FnOnce() -> Result<T>,
20    F: UnwindSafe,
21{
22    match panic::catch_unwind(AssertUnwindSafe(f)) {
23        Ok(result) => result,
24        Err(payload) => Err(ConversionError::Panic(panic_message(payload))),
25    }
26}
27
28fn panic_message(payload: Box<dyn Any + Send>) -> String {
29    if let Some(msg) = payload.downcast_ref::<&str>() {
30        (*msg).to_string()
31    } else if let Some(msg) = payload.downcast_ref::<String>() {
32        msg.clone()
33    } else {
34        "unexpected panic without message".to_string()
35    }
36}
37
38#[cfg(test)]
39mod tests {
40    use super::*;
41
42    #[test]
43    fn guard_panic_converts_panic_to_error() {
44        let err = guard_panic::<_, ()>(|| -> Result<()> {
45            panic!("boom");
46        })
47        .unwrap_err();
48
49        match err {
50            ConversionError::Panic(message) => assert_eq!(message, "boom"),
51            other => panic!("expected panic error, got {:?}", other),
52        }
53    }
54
55    #[test]
56    fn guard_panic_forwards_ok() {
57        let value = guard_panic(|| Ok::<_, ConversionError>(42)).unwrap();
58        assert_eq!(value, 42);
59    }
60}