diff --git a/senbei-cli/tests/panic_hook.rs b/senbei-cli/tests/panic_hook.rs new file mode 100644 index 0000000..963f669 --- /dev/null +++ b/senbei-cli/tests/panic_hook.rs @@ -0,0 +1,68 @@ +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Barrier}; + +#[test] +fn folder_runs_preserve_the_host_panic_hook() { + const CHILD: &str = "SENBEI_PANIC_HOOK_TEST_CHILD"; + if std::env::var_os(CHILD).is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "folder_runs_preserve_the_host_panic_hook", + "--nocapture", + ]) + .env(CHILD, "1") + .output() + .unwrap(); + assert!( + output.status.success(), + "child failed: {}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + + static CALLS: AtomicUsize = AtomicUsize::new(0); + std::panic::set_hook(Box::new(|_| { + CALLS.fetch_add(1, Ordering::SeqCst); + })); + let root = tempfile::tempdir().unwrap(); + // Exercise repeated job lifetimes both before and after engine capture. + for _ in 0..2 { + senbei_io::job::run_folder_opts(root.path(), None, 2, false, true, false).unwrap(); + assert!(senbei_engine::catch_unwind_quiet(|| panic!("captured")).is_err()); + assert!(std::panic::catch_unwind(|| panic!("host")).is_err()); + } + assert_eq!(CALLS.load(Ordering::SeqCst), 2); + + let barrier = Arc::new(Barrier::new(2)); + let worker_barrier = barrier.clone(); + let worker = std::thread::spawn(move || { + senbei_engine::catch_unwind_quiet(|| { + worker_barrier.wait(); + worker_barrier.wait(); + panic!("captured in another thread"); + }) + .unwrap_err(); + }); + barrier.wait(); + // A quiet capture in another thread must not silence unrelated panics. + assert!(std::panic::catch_unwind(|| panic!("unrelated host panic")).is_err()); + barrier.wait(); + worker.join().unwrap(); + assert_eq!(CALLS.load(Ordering::SeqCst), 3); + + std::thread::scope(|scope| { + for _ in 0..4 { + let path = root.path(); + scope.spawn(move || { + senbei_io::job::run_folder_opts(path, None, 2, false, true, false).unwrap(); + assert!( + senbei_engine::catch_unwind_quiet(|| panic!("after concurrent job")).is_err() + ); + }); + } + }); + assert_eq!(CALLS.load(Ordering::SeqCst), 3); +} diff --git a/senbei-engine/src/lib.rs b/senbei-engine/src/lib.rs index bad56f2..60c7fd6 100644 --- a/senbei-engine/src/lib.rs +++ b/senbei-engine/src/lib.rs @@ -1,8 +1,11 @@ //! Platform-specific unpacking engines. pub mod android; +mod panic_capture; pub mod windows; +pub use panic_capture::catch_unwind_quiet; + pub use windows::{ Detected, IntegrityReport, Kind, UnpackError, check_integrity, detect, unpack_auto, unpack_auto_v, unpack_dll, unpack_dll_v, unpack_exe, unpack_exe_v, diff --git a/senbei-engine/src/panic_capture.rs b/senbei-engine/src/panic_capture.rs new file mode 100644 index 0000000..73e763d --- /dev/null +++ b/senbei-engine/src/panic_capture.rs @@ -0,0 +1,182 @@ +//! Thread-scoped panic capture shared by the engines and native orchestration. + +use std::cell::RefCell; +use std::sync::{Arc, Mutex}; + +#[derive(Clone)] +pub(crate) struct PanicCapture(Arc>>); + +#[derive(Clone)] +pub(crate) struct PanicDetails { + pub message: String, + pub file: String, + pub line: u32, + pub column: u32, +} + +thread_local! { + static ACTIVE_PANIC_CAPTURE: RefCell> = const { RefCell::new(None) }; +} + +struct PanicCaptureGuard(Option); + +impl Drop for PanicCaptureGuard { + fn drop(&mut self) { + ACTIVE_PANIC_CAPTURE.with(|slot| { + slot.replace(self.0.take()); + }); + } +} + +impl PanicCapture { + pub(crate) fn new() -> Self { + Self(Arc::new(Mutex::new(None))) + } + + fn record(&self, info: &std::panic::PanicHookInfo<'_>) { + let location = info.location(); + let details = PanicDetails { + message: panic_message(info.payload()), + file: location + .map(|value| value.file().to_owned()) + .unwrap_or_else(|| "".to_owned()), + line: location.map_or(0, std::panic::Location::line), + column: location.map_or(0, std::panic::Location::column), + }; + let mut captured = self + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if captured.is_none() { + *captured = Some(details); + } + } + + pub(crate) fn details(&self, payload: &(dyn std::any::Any + Send)) -> PanicDetails { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + .unwrap_or_else(|| PanicDetails { + message: panic_message(payload), + file: "".to_owned(), + line: 0, + column: 0, + }) + } + + pub(crate) fn merge_from(&self, other: &Self) { + let details = other + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + let Some(details) = details else { return }; + let mut captured = self + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if captured.is_none() { + *captured = Some(details); + } + } +} + +fn panic_message(payload: &(dyn std::any::Any + Send)) -> String { + if let Some(message) = payload.downcast_ref::<&str>() { + (*message).to_owned() + } else if let Some(message) = payload.downcast_ref::() { + message.clone() + } else { + "non-string panic payload".to_owned() + } +} + +pub(crate) fn install_hook() { + static INSTALL: std::sync::Once = std::sync::Once::new(); + INSTALL.call_once(|| { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let capture = ACTIVE_PANIC_CAPTURE + .try_with(|slot| slot.try_borrow().ok().and_then(|slot| slot.clone())) + .ok() + .flatten(); + if let Some(capture) = capture { + capture.record(info); + // WebAssembly unwinding is unavailable: retain the browser's + // diagnostic hook before the unrecoverable trap. + #[cfg(target_arch = "wasm32")] + previous(info); + } else { + previous(info); + } + })); + }); +} + +pub(crate) fn current_panic_capture() -> Option { + ACTIVE_PANIC_CAPTURE.with(|slot| slot.borrow().clone()) +} + +pub(crate) fn with_panic_capture(capture: Option, f: impl FnOnce() -> R) -> R { + let previous = ACTIVE_PANIC_CAPTURE.with(|slot| slot.replace(capture)); + let _guard = PanicCaptureGuard(previous); + f() +} + +/// Catch a native per-file panic without printing the process panic hook. +/// +/// Suppression applies only to this thread and scope. The process hook is +/// installed once, chains to the host hook outside captures, and is never +/// replaced or restored by individual jobs. Nested engine captures and their +/// worker-thread diagnostics continue to work independently. +/// +/// Allocation aborts and WebAssembly traps cannot be caught. +pub fn catch_unwind_quiet(f: impl FnOnce() -> R) -> std::thread::Result { + install_hook(); + with_panic_capture(Some(PanicCapture::new()), || { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nested_captures_restore_the_outer_scope() { + install_hook(); + let outer = PanicCapture::new(); + with_panic_capture(Some(outer.clone()), || { + assert!(catch_unwind_quiet(|| panic!("inner panic")).is_err()); + let payload = std::panic::catch_unwind(|| panic!("outer panic")).unwrap_err(); + assert_eq!(outer.details(payload.as_ref()).message, "outer panic"); + }); + assert!(current_panic_capture().is_none()); + } + + #[test] + fn captures_are_thread_local_and_unwind_safe() { + install_hook(); + let workers: Vec<_> = (0..8) + .map(|index| { + std::thread::spawn(move || { + let capture = PanicCapture::new(); + let payload = with_panic_capture(Some(capture.clone()), || { + std::panic::catch_unwind(|| panic!("worker {index}")) + }) + .unwrap_err(); + assert!(current_panic_capture().is_none()); + let details = capture.details(payload.as_ref()); + assert_eq!(details.message, format!("worker {index}")); + assert!(details.file.ends_with("panic_capture.rs")); + assert!(details.line > 0); + assert!(details.column > 0); + }) + }) + .collect(); + for worker in workers { + worker.join().unwrap(); + } + } +} diff --git a/senbei-engine/src/windows/mod.rs b/senbei-engine/src/windows/mod.rs index 33789cf..e8f4c6f 100644 --- a/senbei-engine/src/windows/mod.rs +++ b/senbei-engine/src/windows/mod.rs @@ -8,8 +8,8 @@ mod layout; pub(crate) mod parallel; use senbei_crypto::primitives; -use std::cell::RefCell; -use std::sync::{Arc, Mutex}; + +pub(crate) use crate::panic_capture::{PanicCapture, current_panic_capture, with_panic_capture}; pub use crate::thread_cap; pub use dll::{unpack_dll, unpack_dll_v}; @@ -26,130 +26,6 @@ pub use integrity::{ /// Real protected binaries are far below this. pub(crate) const MAX_IMAGE_SIZE: u64 = senbei_crypto::MAX_IMAGE_SIZE; -#[derive(Clone)] -pub(crate) struct PanicCapture(Arc>>); - -#[derive(Clone)] -struct PanicDetails { - message: String, - file: String, - line: u32, - column: u32, -} - -thread_local! { - static ACTIVE_PANIC_CAPTURE: RefCell> = const { RefCell::new(None) }; -} - -struct PanicCaptureGuard(Option); - -impl Drop for PanicCaptureGuard { - fn drop(&mut self) { - ACTIVE_PANIC_CAPTURE.with(|slot| { - slot.replace(self.0.take()); - }); - } -} - -impl PanicCapture { - fn new() -> Self { - Self(Arc::new(Mutex::new(None))) - } - - fn record(&self, info: &std::panic::PanicHookInfo<'_>) { - let location = info.location(); - let details = PanicDetails { - message: panic_message(info.payload()), - file: location - .map(|value| value.file().to_owned()) - .unwrap_or_else(|| "".to_owned()), - line: location.map_or(0, std::panic::Location::line), - column: location.map_or(0, std::panic::Location::column), - }; - let mut captured = self - .0 - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if captured.is_none() { - *captured = Some(details); - } - } - - fn into_error(self, payload: &(dyn std::any::Any + Send)) -> UnpackError { - let details = self - .0 - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone() - .unwrap_or_else(|| PanicDetails { - message: panic_message(payload), - file: "".to_owned(), - line: 0, - column: 0, - }); - UnpackError::InternalPanic { - message: details.message, - file: details.file, - line: details.line, - column: details.column, - } - } - - fn merge_from(&self, other: &Self) { - let details = other - .0 - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone(); - let Some(details) = details else { return }; - let mut captured = self - .0 - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if captured.is_none() { - *captured = Some(details); - } - } -} - -fn panic_message(payload: &(dyn std::any::Any + Send)) -> String { - if let Some(message) = payload.downcast_ref::<&str>() { - (*message).to_owned() - } else if let Some(message) = payload.downcast_ref::() { - message.clone() - } else { - "non-string panic payload".to_owned() - } -} - -fn install_panic_capture_hook() { - static INSTALL: std::sync::Once = std::sync::Once::new(); - INSTALL.call_once(|| { - let previous = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |info| { - let capture = ACTIVE_PANIC_CAPTURE - .try_with(|slot| slot.borrow().clone()) - .ok() - .flatten(); - if let Some(capture) = capture { - capture.record(info); - } else { - previous(info); - } - })); - }); -} - -pub(crate) fn current_panic_capture() -> Option { - ACTIVE_PANIC_CAPTURE.with(|slot| slot.borrow().clone()) -} - -pub(crate) fn with_panic_capture(capture: Option, f: impl FnOnce() -> R) -> R { - let previous = ACTIVE_PANIC_CAPTURE.with(|slot| slot.replace(capture)); - let _guard = PanicCaptureGuard(previous); - f() -} - /// Run an unpack pipeline, converting any internal panic into a clean /// [`UnpackError::InternalPanic`] so the public API stays panic-free on any input /// (truncated/garbled files chase offsets out of bounds). The panic location and @@ -161,14 +37,22 @@ pub(crate) fn catch_unpack(f: F) -> Result, UnpackError> where F: FnOnce() -> Result, UnpackError>, { - install_panic_capture_hook(); + crate::panic_capture::install_hook(); let capture = PanicCapture::new(); let r = with_panic_capture(Some(capture.clone()), || { std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) }); match r { Ok(result) => result, - Err(payload) => Err(capture.into_error(payload.as_ref())), + Err(payload) => { + let details = capture.details(payload.as_ref()); + Err(UnpackError::InternalPanic { + message: details.message, + file: details.file, + line: details.line, + column: details.column, + }) + }, } } diff --git a/senbei-io/src/job.rs b/senbei-io/src/job.rs index 99e4611..87a3dd2 100644 --- a/senbei-io/src/job.rs +++ b/senbei-io/src/job.rs @@ -165,10 +165,6 @@ pub fn run_folder_opts( ..Summary::default() }; - // Silence the default panic hook's stderr spew during per-file processing. - let default_hook = std::panic::take_hook(); - std::panic::set_hook(Box::new(|_| {})); // suppress "thread panicked" messages - for input in candidates { let rel = rel_in_tree(root, input); let dest = out_root.join(out_name(&rel)); @@ -176,9 +172,9 @@ pub fn run_folder_opts( // Wrap in catch_unwind so a single bad file never aborts the folder run. let input_owned = input.clone(); let dest_owned = dest.clone(); - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let result = senbei_engine::catch_unwind_quiet(|| { unpack_one_v(&input_owned, &dest_owned, verbose_steps) - })); + }); match result { Ok(Ok((kind, report))) => { @@ -246,14 +242,14 @@ pub fn run_folder_opts( } let input_owned = input.clone(); let dest_owned = dest.clone(); - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let result = senbei_engine::catch_unwind_quiet(|| { crate::android::restore_so_file_with_method_index_module( &input_owned, &dest_owned, verbose_steps, &mut android_method_index_module, ) - })); + }); match result { Ok(Ok(embedded)) => { s.unpacked += 1; @@ -325,7 +321,7 @@ pub fn run_folder_opts( let rel_owned = rel.clone().into_owned(); let out_root_owned = out_root.clone(); let mut seen_taken = std::mem::take(&mut android_seen); - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let result = senbei_engine::catch_unwind_quiet(|| { let outcomes = crate::android::restore_package_with_method_index_module( &package_owned, &rel_owned, @@ -335,7 +331,7 @@ pub fn run_folder_opts( &mut android_method_index_module, ); (outcomes, seen_taken) - })); + }); match result { Ok((Ok(outcomes), seen_back)) => { android_seen = seen_back; @@ -377,14 +373,14 @@ pub fn run_folder_opts( let dest = out_root.join(out_name(&rel)); let meta_owned = meta.clone(); let dest_owned = dest.clone(); - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let result = senbei_engine::catch_unwind_quiet(|| { deobfuscate_metadata_to_with_module( &meta_owned, &dest_owned, verbose_steps, android_method_index_module.as_deref(), ) - })); + }); match result { Ok(Ok(report)) if report.remapped > 0 => { s.metadata += 1; @@ -453,9 +449,6 @@ pub fn run_folder_opts( } } - // Restore the original panic hook. - std::panic::set_hook(default_hook); - bar.finish_and_clear(); s.duration_ms = t0.elapsed().as_millis(); if let Some(log) = &log {