use std::panic::{AssertUnwindSafe, catch_unwind};
#[cfg(any(unix, windows))]
use std::path::Path;
use std::sync::atomic::{AtomicPtr, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use crate::JumpTable;
#[cfg(any(unix, windows))]
use crate::anchor::{
ANCHOR_SYMBOL, aslr_reference, image_range_containing, image_slide_containing, register_base,
register_patch,
};
use crate::hot_fn::reset_fall_throughs;
static APP_JUMP_TABLE: AtomicPtr<JumpTable> = AtomicPtr::new(std::ptr::null_mut());
type Handler = Arc<dyn Fn() + Send + Sync + 'static>;
static HOTRELOAD_HANDLERS: Mutex<Vec<Handler>> = Mutex::new(Vec::new());
#[cfg(any(unix, windows))]
static APPLY_LOCK: Mutex<()> = Mutex::new(());
#[derive(Debug, PartialEq, thiserror::Error)]
pub enum PatchError {
#[error("Failed to load library: {0}")]
Dlopen(String),
#[error("Failed to load library on Android: {0}")]
AndroidMemfd(String),
#[error("no ASLR anchor is set; the jump table cannot be rebased")]
AnchorUnresolved,
#[error("hot patching is available only in builds with debug assertions")]
ReleaseBuild,
#[error("cannot determine the address range of a loaded image")]
ImageRangeUnresolved,
#[error("{} unreported layout mismatch(es); patch refused", .0.len())]
LayoutMismatchPending(Vec<LayoutMismatch>),
#[error("{image} anchor mismatch: table implies offset {got:#x}, image slide is {expected:#x}")]
AnchorMismatch {
image: &'static str,
expected: u64,
got: u64,
},
}
pub fn register_handler(handler: Arc<dyn Fn() + Send + Sync + 'static>) {
HOTRELOAD_HANDLERS
.lock()
.unwrap_or_else(PoisonError::into_inner)
.push(handler);
}
pub unsafe fn get_jump_table() -> Option<&'static JumpTable> {
let ptr = APP_JUMP_TABLE.load(Ordering::Acquire);
unsafe { ptr.as_ref() }
}
fn commit_patch(table: JumpTable) {
APP_JUMP_TABLE.store(Box::into_raw(Box::new(table)), Ordering::Release);
reset_fall_throughs();
run_handlers();
}
fn run_handlers() -> usize {
let handlers = HOTRELOAD_HANDLERS
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clone();
let mut panicked = 0;
for handler in &handlers {
if catch_unwind(AssertUnwindSafe(|| handler())).is_err() {
panicked += 1;
eprintln!(
"frust-hotpatch: a patch handler panicked; the patch stays installed and the \
remaining handlers still run"
);
}
}
panicked
}
#[cfg(any(unix, windows))]
pub unsafe fn load_patch_library(path: &Path) -> Result<libloading::Library, PatchError> {
#[cfg(target_os = "android")]
{
unsafe { crate::android::memfd_dlopen(path) }
}
#[cfg(not(target_os = "android"))]
{
unsafe { libloading::Library::new(path) }.map_err(|err| PatchError::Dlopen(err.to_string()))
}
}
#[cfg(any(unix, windows))]
pub unsafe fn apply_patch(table: JumpTable) -> Result<(), PatchError> {
if !cfg!(debug_assertions) {
return Err(PatchError::ReleaseBuild);
}
unsafe { apply_patch_with_anchor(table, aslr_reference) }.map(|_| ())
}
#[cfg(any(unix, windows))]
#[derive(Debug, PartialEq)]
pub struct ApplyReport {
pub applied: bool,
pub layout_mismatches: Vec<LayoutMismatch>,
pub error: Option<PatchError>,
pub load: std::time::Duration,
pub install: std::time::Duration,
}
#[cfg(any(unix, windows))]
pub fn apply_from_devtools(bytes_path: &Path, mut table: JumpTable) -> ApplyReport {
let refused = |layout_mismatches: Vec<LayoutMismatch>, error: Option<PatchError>| ApplyReport {
applied: false,
layout_mismatches,
error,
load: std::time::Duration::ZERO,
install: std::time::Duration::ZERO,
};
if !cfg!(debug_assertions) {
return refused(Vec::new(), Some(PatchError::ReleaseBuild));
}
table.lib = bytes_path.to_path_buf();
match unsafe { apply_patch_with_anchor(table, aslr_reference) } {
Ok(timings) => ApplyReport {
applied: true,
layout_mismatches: Vec::new(),
error: None,
load: timings.load,
install: timings.install,
},
Err(PatchError::LayoutMismatchPending(records)) => refused(records, None),
Err(error) => refused(Vec::new(), Some(error)),
}
}
#[cfg(any(unix, windows))]
#[derive(Debug, PartialEq)]
struct ApplyTimings {
load: std::time::Duration,
install: std::time::Duration,
}
#[cfg(any(unix, windows))]
unsafe fn apply_patch_with_anchor(
mut table: JumpTable,
anchor: fn() -> usize,
) -> Result<ApplyTimings, PatchError> {
let _serial = APPLY_LOCK.lock().unwrap_or_else(PoisonError::into_inner);
let pending = pending_layout_mismatches();
if !pending.is_empty() {
return Err(PatchError::LayoutMismatchPending(pending));
}
let anchor = anchor();
if anchor == 0 {
return Err(PatchError::AnchorUnresolved);
}
let old_offset = anchor.wrapping_sub(table.aslr_reference as usize);
let (base_start, base_end) =
image_range_containing(anchor).ok_or(PatchError::ImageRangeUnresolved)?;
let base_slide = image_slide_containing(anchor).ok_or(PatchError::ImageRangeUnresolved)?;
if old_offset != base_slide {
return Err(PatchError::AnchorMismatch {
image: "base",
expected: base_slide as u64,
got: old_offset as u64,
});
}
let loading = std::time::Instant::now();
let lib = unsafe { load_patch_library(&table.lib)? };
let load = loading.elapsed();
let installing = std::time::Instant::now();
let lib: &'static libloading::Library = Box::leak(Box::new(lib));
let symbol = unsafe { lib.get::<*const ()>(ANCHOR_SYMBOL.as_bytes()) }.map_err(|err| {
PatchError::Dlopen(format!(
"patch library has no `{ANCHOR_SYMBOL}` anchor: {err}"
))
})?;
let patch_anchor = unsafe { symbol.try_as_raw_ptr() }.ok_or_else(|| {
PatchError::Dlopen(format!("patch library's `{ANCHOR_SYMBOL}` anchor is null"))
})?;
let new_offset = patch_anchor.wrapping_byte_sub(table.new_base_address as usize) as usize;
let (patch_start, patch_end) =
image_range_containing(patch_anchor as usize).ok_or(PatchError::ImageRangeUnresolved)?;
let patch_slide =
image_slide_containing(patch_anchor as usize).ok_or(PatchError::ImageRangeUnresolved)?;
if new_offset != patch_slide {
return Err(PatchError::AnchorMismatch {
image: "patch",
expected: patch_slide as u64,
got: new_offset as u64,
});
}
table.map = table
.map
.iter()
.map(|(old, new)| {
(
(*old as usize).wrapping_add(old_offset) as u64,
(*new as usize).wrapping_add(new_offset) as u64,
)
})
.collect();
register_base(base_start, base_end, old_offset);
register_patch(patch_start, patch_end, new_offset);
commit_patch(table);
Ok(ApplyTimings {
load,
install: installing.elapsed(),
})
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct LayoutMismatch {
pub type_name: String,
pub stored: String,
pub own: String,
}
impl std::fmt::Display for LayoutMismatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}: stored {}, own {}",
self.type_name, self.stored, self.own
)
}
}
static LAYOUT_MISMATCHES: Mutex<Vec<LayoutMismatch>> = Mutex::new(Vec::new());
pub fn report_layout_mismatch(
type_name: &str,
stored: impl std::fmt::Display,
own: impl std::fmt::Display,
) {
let record = LayoutMismatch {
type_name: type_name.to_owned(),
stored: stored.to_string(),
own: own.to_string(),
};
let mut records = LAYOUT_MISMATCHES
.lock()
.unwrap_or_else(PoisonError::into_inner);
if !records.contains(&record) {
records.push(record);
}
}
pub fn pending_layout_mismatches() -> Vec<LayoutMismatch> {
LAYOUT_MISMATCHES
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clone()
}
pub fn mark_layout_mismatches_reported(reported: &[LayoutMismatch]) {
LAYOUT_MISMATCHES
.lock()
.unwrap_or_else(PoisonError::into_inner)
.retain(|record| !reported.contains(record));
}
#[cfg(test)]
pub(crate) mod test_support {
use std::sync::{Mutex, MutexGuard, PoisonError};
use super::{APP_JUMP_TABLE, HOTRELOAD_HANDLERS, LAYOUT_MISMATCHES, Ordering, commit_patch};
use crate::JumpTable;
use crate::hot_fn::reset_fall_throughs;
static SERIAL: Mutex<()> = Mutex::new(());
pub(crate) fn serial() -> MutexGuard<'static, ()> {
SERIAL.lock().unwrap_or_else(PoisonError::into_inner)
}
pub(crate) fn reset() {
APP_JUMP_TABLE.store(std::ptr::null_mut(), Ordering::Release);
HOTRELOAD_HANDLERS
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clear();
reset_fall_throughs();
crate::anchor::reset_for_test();
LAYOUT_MISMATCHES
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clear();
}
pub(crate) fn install(table: JumpTable) {
commit_patch(table);
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::AtomicU32;
use crate::anchor::UNKNOWN_IMAGE;
use super::*;
fn table(entries: &[(u64, u64)]) -> JumpTable {
JumpTable {
lib: "/nonexistent/frust-hotpatch-test.so".into(),
map: entries.iter().copied().collect(),
aslr_reference: (test_anchor() - slide_of_test_anchor()) as u64,
new_base_address: 0x2000,
ifunc_count: 0,
}
}
fn test_anchor_fn() {}
fn test_anchor() -> usize {
test_anchor_fn as *const () as usize
}
fn slide_of_test_anchor() -> usize {
crate::anchor::image_slide_containing(test_anchor()).unwrap_or(0)
}
fn counting_handler() -> (Arc<AtomicU32>, Handler) {
let count = Arc::new(AtomicU32::new(0));
let seen = Arc::clone(&count);
(
count,
Arc::new(move || {
seen.fetch_add(1, Ordering::SeqCst);
}),
)
}
#[test]
fn missing_library_is_a_load_error() {
let err = unsafe { load_patch_library(Path::new("/nonexistent/frust-hotpatch-test.so")) };
assert!(matches!(
err,
Err(PatchError::Dlopen(_) | PatchError::AndroidMemfd(_))
));
}
#[test]
fn handler_runs_once_per_patch() {
let _serial = test_support::serial();
test_support::reset();
let (count, handler) = counting_handler();
register_handler(handler);
test_support::install(table(&[]));
assert_eq!(count.load(Ordering::SeqCst), 1);
test_support::install(table(&[]));
assert_eq!(count.load(Ordering::SeqCst), 2);
test_support::reset();
}
#[test]
fn panicking_handler_does_not_abort_the_patch() {
let _serial = test_support::serial();
test_support::reset();
register_handler(Arc::new(|| panic!("handler failure under test")));
let (count, handler) = counting_handler();
register_handler(handler);
commit_patch(table(&[(1, 2)]));
assert_eq!(count.load(Ordering::SeqCst), 1, "later handlers still run");
let installed = unsafe { get_jump_table() }.map(|t| t.map.get(&1).copied());
assert_eq!(installed, Some(Some(2)), "the patch stays installed");
assert_eq!(run_handlers(), 1);
test_support::reset();
}
#[test]
fn poisoned_handler_lock_is_recovered() {
let _serial = test_support::serial();
test_support::reset();
let _ = std::thread::spawn(|| {
let _held = HOTRELOAD_HANDLERS.lock();
panic!("poisoning the handler lock under test");
})
.join();
assert!(HOTRELOAD_HANDLERS.is_poisoned());
let (count, handler) = counting_handler();
register_handler(handler);
test_support::install(table(&[]));
assert_eq!(count.load(Ordering::SeqCst), 1);
HOTRELOAD_HANDLERS.clear_poison();
test_support::reset();
}
#[cfg(any(unix, windows))]
#[test]
fn poisoned_apply_lock_is_recovered() {
let _serial = test_support::serial();
test_support::reset();
let _ = std::thread::spawn(|| {
let _held = APPLY_LOCK.lock();
panic!("poisoning the apply lock under test");
})
.join();
assert!(APPLY_LOCK.is_poisoned());
let err = unsafe { apply_patch_with_anchor(table(&[(1, 2)]), test_anchor) };
assert!(matches!(
err,
Err(PatchError::Dlopen(_) | PatchError::AndroidMemfd(_))
));
assert!(unsafe { get_jump_table() }.is_none());
APPLY_LOCK.clear_poison();
test_support::reset();
}
#[cfg(any(unix, windows))]
#[test]
fn unresolved_anchor_fails_closed() {
let _serial = test_support::serial();
test_support::reset();
let (count, handler) = counting_handler();
register_handler(handler);
let err = unsafe { apply_patch_with_anchor(table(&[(0x1100, 0x2100)]), || 0) };
assert_eq!(err, Err(PatchError::AnchorUnresolved));
assert!(unsafe { get_jump_table() }.is_none(), "nothing installed");
assert_eq!(count.load(Ordering::SeqCst), 0, "no handler fired");
test_support::reset();
}
#[cfg(any(unix, windows))]
#[test]
#[cfg_attr(
not(debug_assertions),
ignore = "the release profile refuses before the anchor; see release_profile_refuses"
)]
fn apply_refuses_until_an_anchor_is_set_then_proceeds() {
let _serial = test_support::serial();
test_support::reset();
let err = unsafe { apply_patch(table(&[(0x1100, 0x2100)])) };
assert_eq!(err, Err(PatchError::AnchorUnresolved), "no anchor yet");
crate::set_anchor(test_anchor());
let err = unsafe { apply_patch(table(&[(0x1100, 0x2100)])) };
assert!(
matches!(
err,
Err(PatchError::Dlopen(_) | PatchError::AndroidMemfd(_))
),
"with an anchor the call reaches the loader: {err:?}"
);
test_support::reset();
}
#[cfg(any(unix, windows))]
#[test]
#[cfg_attr(
debug_assertions,
ignore = "covers the release profile; run `cargo test --release -p frust-hotpatch`"
)]
fn release_profile_refuses() {
let _serial = test_support::serial();
test_support::reset();
crate::set_anchor(test_anchor());
let err = unsafe { apply_patch(table(&[])) };
assert_eq!(err, Err(PatchError::ReleaseBuild));
let report = apply_from_devtools(Path::new("/nonexistent/x.so"), table(&[]));
assert!(!report.applied);
assert_eq!(report.error, Some(PatchError::ReleaseBuild));
test_support::reset();
}
#[cfg(any(unix, windows))]
#[test]
fn debug_profile_does_not_refuse_with_release_build() {
if !cfg!(debug_assertions) {
return;
}
let _serial = test_support::serial();
test_support::reset();
crate::set_anchor(test_anchor());
let err = unsafe { apply_patch(table(&[])) };
assert_ne!(err, Err(PatchError::ReleaseBuild));
test_support::reset();
}
#[cfg(any(unix, windows))]
fn anchorless_library() -> &'static str {
if cfg!(target_vendor = "apple") {
"/usr/lib/libSystem.B.dylib"
} else if cfg!(windows) {
"kernel32.dll"
} else {
"libc.so.6"
}
}
#[cfg(any(unix, windows))]
#[test]
#[cfg_attr(not(debug_assertions), ignore = "apply_patch refuses in release")]
fn library_without_the_anchor_symbol_is_refused() {
let _serial = test_support::serial();
test_support::reset();
crate::set_anchor(test_anchor());
let mut t = table(&[(0x1100, 0x2100)]);
t.lib = anchorless_library().into();
let err = unsafe { apply_patch(t) };
match err {
Err(PatchError::Dlopen(msg)) => assert!(msg.contains(ANCHOR_SYMBOL), "{msg}"),
other => panic!("expected Dlopen naming the anchor, got {other:?}"),
}
assert!(unsafe { get_jump_table() }.is_none());
test_support::reset();
}
#[cfg(any(unix, windows))]
#[test]
#[cfg_attr(not(debug_assertions), ignore = "apply_patch refuses in release")]
fn real_patch_library_applies_through_the_anchor_symbol() {
let _serial = test_support::serial();
test_support::reset();
let dir = std::env::temp_dir().join(format!("frust-hotpatch-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("create scratch dir");
let src = dir.join("patch.rs");
std::fs::write(
&src,
"#[unsafe(no_mangle)] pub extern \"C\" fn __frust_hotpatch_anchor() {}\n\
#[unsafe(no_mangle)] pub extern \"C\" fn patched() -> u32 { 7 }\n",
)
.expect("write patch source");
let ext = if cfg!(windows) {
"dll"
} else if cfg!(target_vendor = "apple") {
"dylib"
} else {
"so"
};
let lib = dir.join(format!("patch.{ext}"));
let status = std::process::Command::new("rustc")
.args([
"--edition=2024",
"--crate-type=cdylib",
"--crate-name=patch",
"-o",
])
.arg(&lib)
.arg(&src)
.status();
let Ok(status) = status else {
eprintln!("rustc is not spawnable; real-library apply test skipped");
return;
};
assert!(status.success(), "building the patch library failed");
crate::set_anchor(test_anchor());
let base_slide = crate::anchor::image_slide_containing(test_anchor()).expect("base slide");
let good_old = test_anchor().wrapping_sub(base_slide) as u64;
let probe: &'static libloading::Library = Box::leak(Box::new(
unsafe { load_patch_library(&lib) }.expect("map patch"),
));
let symbol = unsafe { probe.get::<*const ()>(ANCHOR_SYMBOL.as_bytes()) }.expect("anchor");
let patch_anchor = unsafe { symbol.try_as_raw_ptr() }.expect("non-null anchor") as usize;
let patch_slide = crate::anchor::image_slide_containing(patch_anchor).expect("patch slide");
let good_new = patch_anchor.wrapping_sub(patch_slide) as u64;
let build = |old: u64, new: u64| {
let mut t = table(&[(0x1100, 0x2100)]);
t.lib = lib.clone();
t.aslr_reference = old;
t.new_base_address = new;
t
};
let wrong_base = unsafe { apply_patch(build(good_old + 0x10, good_new)) };
assert_eq!(
wrong_base,
Err(PatchError::AnchorMismatch {
image: "base",
expected: base_slide as u64,
got: base_slide as u64 - 0x10,
})
);
let wrong_patch = unsafe { apply_patch(build(good_old, good_new + 0x10)) };
assert_eq!(
wrong_patch,
Err(PatchError::AnchorMismatch {
image: "patch",
expected: patch_slide as u64,
got: patch_slide as u64 - 0x10,
})
);
assert!(unsafe { get_jump_table() }.is_none(), "nothing installed");
assert_eq!(
crate::anchor::classify(test_anchor() as u64).0,
UNKNOWN_IMAGE
);
unsafe { apply_patch(build(good_old, good_new)) }.expect("apply through the anchor symbol");
let old_offset = base_slide;
let installed = unsafe { get_jump_table() }.expect("table installed");
assert_eq!(installed.map.len(), 1);
let (key, value) = installed.map.iter().next().expect("one entry");
assert_eq!(*key, (0x1100usize.wrapping_add(old_offset)) as u64);
assert_ne!(
*value, 0x2100,
"the target was rebased onto the loaded image"
);
assert_eq!(crate::anchor::classify(test_anchor() as u64).0, 0);
assert_eq!(crate::anchor::classify(*value).0, 1);
test_support::reset();
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(any(unix, windows))]
#[test]
#[cfg_attr(not(debug_assertions), ignore = "apply_patch refuses in release")]
fn raw_apply_patch_refuses_while_a_mismatch_is_unreported_then_proceeds() {
let _serial = test_support::serial();
test_support::reset();
crate::set_anchor(test_anchor());
report_layout_mismatch("RawState", 4, 8);
let expected = pending_layout_mismatches();
let refused = unsafe { apply_patch(table(&[(1, 2)])) };
assert_eq!(
refused,
Err(PatchError::LayoutMismatchPending(expected.clone()))
);
assert!(unsafe { get_jump_table() }.is_none());
mark_layout_mismatches_reported(&expected);
let proceeded = unsafe { apply_patch(table(&[(1, 2)])) };
assert!(
matches!(
proceeded,
Err(PatchError::Dlopen(_) | PatchError::AndroidMemfd(_))
),
"once reported the raw apply reaches the loader: {proceeded:?}"
);
test_support::reset();
}
#[cfg(any(unix, windows))]
#[test]
#[cfg_attr(not(debug_assertions), ignore = "apply_patch refuses in release")]
fn devtools_apply_refuses_while_a_mismatch_is_unreported() {
let _serial = test_support::serial();
test_support::reset();
crate::set_anchor(test_anchor());
report_layout_mismatch("HomeState", 4, 8);
report_layout_mismatch("HomeState", 4, 8);
let expected = LayoutMismatch {
type_name: "HomeState".into(),
stored: "4".into(),
own: "8".into(),
};
assert_eq!(
pending_layout_mismatches(),
vec![expected.clone()],
"deduplicated"
);
let report = apply_from_devtools(Path::new("/nonexistent/x.so"), table(&[(1, 2)]));
assert!(!report.applied);
assert_eq!(report.layout_mismatches, vec![expected.clone()]);
assert_eq!(report.error, None, "refused for the record, not an error");
assert_eq!(
(report.load, report.install),
(std::time::Duration::ZERO, std::time::Duration::ZERO),
"nothing was loaded or installed, so nothing was timed"
);
assert!(unsafe { get_jump_table() }.is_none());
report_layout_mismatch("Other", 1, 2);
mark_layout_mismatches_reported(&report.layout_mismatches);
assert_eq!(pending_layout_mismatches().len(), 1);
mark_layout_mismatches_reported(&pending_layout_mismatches());
assert!(pending_layout_mismatches().is_empty());
let report = apply_from_devtools(Path::new("/nonexistent/x.so"), table(&[(1, 2)]));
assert!(report.layout_mismatches.is_empty());
assert!(
matches!(
report.error,
Some(PatchError::Dlopen(_) | PatchError::AndroidMemfd(_))
),
"once reported the apply proceeds to the loader: {report:?}"
);
assert_eq!(
LayoutMismatch {
type_name: "T".into(),
stored: "1".into(),
own: "2".into()
}
.to_string(),
"T: stored 1, own 2"
);
test_support::reset();
}
}