Skip to main content

frust_hotpatch/
patch.rs

1// Ported from subsecond 0.7.10 (DioxusLabs, MIT OR Apache-2.0),
2// https://github.com/DioxusLabs/dioxus/tree/main/packages/subsecond.
3//
4//! The load side: install a patch library, rebase its jump table onto this process, publish it to
5//! every [`HotFn`](crate::HotFn), and notify registered handlers.
6
7use std::panic::{AssertUnwindSafe, catch_unwind};
8#[cfg(any(unix, windows))]
9use std::path::Path;
10use std::sync::atomic::{AtomicPtr, Ordering};
11use std::sync::{Arc, Mutex, PoisonError};
12
13use crate::JumpTable;
14#[cfg(any(unix, windows))]
15use crate::anchor::{
16    ANCHOR_SYMBOL, aslr_reference, image_range_containing, image_slide_containing, register_base,
17    register_patch,
18};
19use crate::hot_fn::reset_fall_throughs;
20
21// The installed table, a leaked `Box`, published with a Release store and read with an Acquire
22// load (one `ldar` on aarch64, a plain load on x86). Patches are never freed, so a reader can never
23// observe a dangling pointer; a reader racing a swap sees the previous table for one more call.
24static APP_JUMP_TABLE: AtomicPtr<JumpTable> = AtomicPtr::new(std::ptr::null_mut());
25
26type Handler = Arc<dyn Fn() + Send + Sync + 'static>;
27static HOTRELOAD_HANDLERS: Mutex<Vec<Handler>> = Mutex::new(Vec::new());
28
29// Serialises `apply_patch` end to end: load, rebase, publish, handlers. Nothing is published before
30// a step that can panic, so a lock poisoned by a panicking loader guards no torn state and is
31// recovered.
32#[cfg(any(unix, windows))]
33static APPLY_LOCK: Mutex<()> = Mutex::new(());
34
35/// Why a patch could not be applied. On every error nothing is installed and no handler runs.
36#[derive(Debug, PartialEq, thiserror::Error)]
37pub enum PatchError {
38    /// The patch library failed to load, or lacks the `__frust_hotpatch_anchor` symbol. A `String` rather than
39    /// the loader's error type keeps `libloading` out of the public API.
40    #[error("Failed to load library: {0}")]
41    Dlopen(String),
42
43    /// The Android memfd loader failed, most likely on a permissions issue.
44    #[error("Failed to load library on Android: {0}")]
45    AndroidMemfd(String),
46
47    /// No anchor is set ([`set_anchor`](crate::set_anchor) was never called with a non-zero
48    /// address), so the table's keys cannot be rebased onto this process. Checked before the
49    /// library is loaded.
50    #[error("no ASLR anchor is set; the jump table cannot be rebased")]
51    AnchorUnresolved,
52
53    /// This is a release-profile build: patches are applied only under `debug_assertions`.
54    #[error("hot patching is available only in builds with debug assertions")]
55    ReleaseBuild,
56
57    /// The platform could not say which loaded image holds an anchor, so missed keys could not be
58    /// attributed to an image. Nothing is installed.
59    #[error("cannot determine the address range of a loaded image")]
60    ImageRangeUnresolved,
61
62    /// Layout-mismatch records are unreported (see [`report_layout_mismatch`]): no patch is
63    /// applied over a known mismatch. Carries the unreported records; checked under the apply lock
64    /// before anything is loaded, and cleared by [`mark_layout_mismatches_reported`].
65    #[error("{} unreported layout mismatch(es); patch refused", .0.len())]
66    LayoutMismatchPending(Vec<LayoutMismatch>),
67
68    /// The table's anchor address for `image` (`"base"` or `"patch"`) is not that image's
69    /// `__frust_hotpatch_anchor` link-time address: the offset it implies (`got`) differs from
70    /// the image's real slide (`expected`). A table anchored on another symbol (dx 0.7.10 anchors
71    /// on `main`) is refused rather than rebased by a wrong constant. The `"base"` check runs
72    /// before the library is loaded; for `"patch"` the library is already mapped and leaked
73    /// (never unloaded), as with [`PatchError::ImageRangeUnresolved`] after the load. Nothing is
74    /// registered or installed.
75    #[error("{image} anchor mismatch: table implies offset {got:#x}, image slide is {expected:#x}")]
76    AnchorMismatch {
77        /// Which image's anchor disagrees: `"base"` or `"patch"`.
78        image: &'static str,
79        /// The image's real slide, from the platform loader.
80        expected: u64,
81        /// The offset the table's anchor address implies.
82        got: u64,
83    },
84}
85
86/// Register `handler` to run right after each patch library is loaded and its jump table installed.
87///
88/// It runs on the thread that called [`apply_patch`], while `apply_patch` still holds its lock: a
89/// handler must not apply a patch itself. A panicking handler is caught and logged; the patch stays
90/// installed and the remaining handlers still run.
91pub fn register_handler(handler: Arc<dyn Fn() + Send + Sync + 'static>) {
92    HOTRELOAD_HANDLERS
93        .lock()
94        .unwrap_or_else(PoisonError::into_inner)
95        .push(handler);
96}
97
98/// The installed jump table, or `None` before the first patch.
99///
100/// # Safety
101///
102/// The reference points into a leaked allocation and is valid today for `'static`, but a future
103/// version may reclaim tables between patches: use it within one call, never across a patch.
104pub unsafe fn get_jump_table() -> Option<&'static JumpTable> {
105    let ptr = APP_JUMP_TABLE.load(Ordering::Acquire);
106    // SAFETY: a non-null pointer was produced by `Box::into_raw` in `commit_patch` and is never
107    // freed, so it is valid for the rest of the process. This Acquire load pairs with that
108    // Release store, so every write that built the table (the `HashMap` allocation and entries)
109    // happens-before this read, and the table is never written after publication.
110    unsafe { ptr.as_ref() }
111}
112
113/// Publish `table`, reset the fall-through diagnostics, and run every handler.
114fn commit_patch(table: JumpTable) {
115    // Release: pairs with `get_jump_table`'s Acquire load (see the SAFETY note there).
116    APP_JUMP_TABLE.store(Box::into_raw(Box::new(table)), Ordering::Release);
117    reset_fall_throughs();
118    run_handlers();
119}
120
121/// Run every registered handler once, each under `catch_unwind`; returns how many panicked.
122fn run_handlers() -> usize {
123    // Snapshot first: a handler may itself register another handler.
124    let handlers = HOTRELOAD_HANDLERS
125        .lock()
126        .unwrap_or_else(PoisonError::into_inner)
127        .clone();
128    let mut panicked = 0;
129    for handler in &handlers {
130        if catch_unwind(AssertUnwindSafe(|| handler())).is_err() {
131            panicked += 1;
132            eprintln!(
133                "frust-hotpatch: a patch handler panicked; the patch stays installed and the \
134                 remaining handlers still run"
135            );
136        }
137    }
138    panicked
139}
140
141/// Load `path` with the platform loader [`apply_patch`] uses: `dlopen`/`LoadLibrary` through
142/// `libloading` on desktop, the memfd + `android_dlopen_ext` path on Android (which lets a library
143/// outside the app's native-library directory load on a non-rooted device).
144///
145/// # Safety
146///
147/// Loading a library runs its initialisers, which can do anything: `path` must be a library built
148/// to be loaded into this process. The returned handle must never be dropped while code from it
149/// may still run.
150#[cfg(any(unix, windows))]
151pub unsafe fn load_patch_library(path: &Path) -> Result<libloading::Library, PatchError> {
152    #[cfg(target_os = "android")]
153    {
154        // SAFETY: forwarded verbatim; the caller upholds this function's contract.
155        unsafe { crate::android::memfd_dlopen(path) }
156    }
157
158    #[cfg(not(target_os = "android"))]
159    {
160        // SAFETY: forwarded verbatim; the caller upholds this function's contract.
161        unsafe { libloading::Library::new(path) }.map_err(|err| PatchError::Dlopen(err.to_string()))
162    }
163}
164
165/// Apply a patch: load `table.lib`, rebase `table`'s addresses onto this process and the loaded
166/// library, install it, then run every [`register_handler`] handler.
167///
168/// Calls are serialised end to end: a concurrent call waits for the running one to finish,
169/// handlers included. Fails closed, before loading anything: a release-profile build
170/// ([`PatchError::ReleaseBuild`]), no anchor set with [`set_anchor`](crate::set_anchor)
171/// ([`PatchError::AnchorUnresolved`]), an unlocatable base image
172/// ([`PatchError::ImageRangeUnresolved`]), or any unreported layout-mismatch record
173/// ([`PatchError::LayoutMismatchPending`], checked under the lock, so every apply entry refuses
174/// the same way until the host calls [`mark_layout_mismatches_reported`]). The patch library must export
175/// [`ANCHOR_SYMBOL`](crate::ANCHOR_SYMBOL), else [`PatchError::Dlopen`]. On any error nothing is
176/// installed and no handler runs.
177///
178/// A table whose anchor addresses are not those of [`ANCHOR_SYMBOL`](crate::ANCHOR_SYMBOL) is
179/// refused with [`PatchError::AnchorMismatch`]: the offset implied by `aslr_reference` must equal
180/// the running image's slide, and the one implied by `new_base_address` the patch image's slide.
181/// The base check runs before the library is loaded; a patch-side refusal leaves the library
182/// mapped and leaked.
183///
184/// # Safety
185///
186/// This detours live functions to code from another binary. A malformed or mismatched table (one
187/// not built against this exact executable) makes calls jump to arbitrary addresses with the wrong
188/// signatures. Only apply tables produced by the patch builder for this running build.
189///
190/// Two anchor preconditions hold the rebase honest. Both are checked at run time
191/// ([`PatchError::AnchorMismatch`]) but stated here because a table is trusted input:
192/// `table.aslr_reference` must be the link-time address of `__frust_hotpatch_anchor` in the
193/// running base image, and `table.new_base_address` its link-time address in the patch library.
194/// A table anchored on any other symbol (for example `main`, as dx 0.7.10 builds it) is not valid.
195///
196/// A well-formed table built against this executable is not enough on its own. Entries are matched
197/// by symbol name, and a name does not encode layout, so the caller must also ensure that every
198/// mapped function's argument, return and capture types keep their layout between the running
199/// image and the patch: every type whose values cross images keeps its layout. Otherwise the
200/// patched function reads and writes live values through the new layout: the measured case is a
201/// component's `build` taking `(&C, &mut C::State)` after a field grew `C::State` from 4 to 8
202/// bytes (the hot-patch spike's RESULTS.md, row D2). Closures dispatched through
203/// [`HotFn::current`](crate::HotFn::current) carry their captures as an argument, so a changed
204/// capture is the same hazard. Checking this is the patch builder's job (its L3 gate, the spike's
205/// PORT.md section 2(c)); this function cannot see layouts.
206///
207/// Refuses with [`PatchError::LayoutMismatchPending`] while any layout-mismatch record is
208/// unreported ([`report_layout_mismatch`]); the caller must report the records to the host and call
209/// [`mark_layout_mismatches_reported`] before a patch can apply.
210///
211/// It loads a library and allocates, so it must not run where the process is stopped (e.g. in a
212/// signal handler), nor from a patch handler (it would wait on its own lock).
213#[cfg(any(unix, windows))]
214pub unsafe fn apply_patch(table: JumpTable) -> Result<(), PatchError> {
215    if !cfg!(debug_assertions) {
216        return Err(PatchError::ReleaseBuild);
217    }
218    // SAFETY: forwarded verbatim; the caller upholds this function's contract.
219    unsafe { apply_patch_with_anchor(table, aslr_reference) }.map(|_| ())
220}
221
222/// What [`apply_from_devtools`] did: whether the patch was applied, and why not when it was not.
223#[cfg(any(unix, windows))]
224#[derive(Debug, PartialEq)]
225pub struct ApplyReport {
226    /// The patch is installed and its handlers have run.
227    pub applied: bool,
228    /// The unreported layout-mismatch records that made the call refuse (empty otherwise). The
229    /// caller carries them in its answer and then marks them with
230    /// [`mark_layout_mismatches_reported`].
231    pub layout_mismatches: Vec<LayoutMismatch>,
232    /// Why applying failed, when it did (and no mismatch record was the reason).
233    pub error: Option<PatchError>,
234    /// Wall time spent loading the library ([`load_patch_library`]: on Android the read, the
235    /// memfd copy and `android_dlopen_ext`); zero when nothing was loaded. Part of the app's
236    /// permanent `apply timings` line, the instrument for patch-latency regressions.
237    pub load: std::time::Duration,
238    /// Wall time from the loaded library to the published table: the anchor checks, the rebase
239    /// of every entry, the install and the handlers. Zero when nothing was installed.
240    pub install: std::time::Duration,
241}
242
243/// The safe devtools entry: apply the patch library at `bytes_path` with `table`.
244///
245/// The one `unsafe` call of the devtools path lives here so the shell crates stay `unsafe`-free.
246/// `bytes_path` must be a file the app itself wrote from bytes received on the authenticated
247/// devtools connection, never a path taken from the wire: this function overwrites `table.lib`
248/// with it and trusts it. That, and an authenticated peer that is the patch builder for this very
249/// build, are the preconditions the devtools layer owes [`apply_patch`]'s safety contract.
250///
251/// Refuses, loading nothing and answering `applied: false` with the records, while any
252/// layout-mismatch record is unreported (see [`report_layout_mismatch`]): [`apply_patch`] itself
253/// refuses with [`PatchError::LayoutMismatchPending`] under its lock, and this entry maps that to
254/// the report. A
255/// release-profile build refuses the same way, with [`PatchError::ReleaseBuild`].
256#[cfg(any(unix, windows))]
257pub fn apply_from_devtools(bytes_path: &Path, mut table: JumpTable) -> ApplyReport {
258    let refused = |layout_mismatches: Vec<LayoutMismatch>, error: Option<PatchError>| ApplyReport {
259        applied: false,
260        layout_mismatches,
261        error,
262        load: std::time::Duration::ZERO,
263        install: std::time::Duration::ZERO,
264    };
265    if !cfg!(debug_assertions) {
266        return refused(Vec::new(), Some(PatchError::ReleaseBuild));
267    }
268    table.lib = bytes_path.to_path_buf();
269    // SAFETY: this entry's documented contract: `bytes_path` was written by the app from bytes
270    // received on the authenticated devtools connection, and the table comes from the same
271    // authenticated builder for this running build, which is what `apply_patch` requires (whose
272    // release-build refusal is checked above). The builder's L3 gate owns the layout
273    // precondition.
274    match unsafe { apply_patch_with_anchor(table, aslr_reference) } {
275        Ok(timings) => ApplyReport {
276            applied: true,
277            layout_mismatches: Vec::new(),
278            error: None,
279            load: timings.load,
280            install: timings.install,
281        },
282        Err(PatchError::LayoutMismatchPending(records)) => refused(records, None),
283        Err(error) => refused(Vec::new(), Some(error)),
284    }
285}
286
287/// Where one successful apply spent its time (see [`ApplyReport::load`]).
288#[cfg(any(unix, windows))]
289#[derive(Debug, PartialEq)]
290struct ApplyTimings {
291    load: std::time::Duration,
292    install: std::time::Duration,
293}
294
295/// [`apply_patch`] with the executable's anchor lookup injected, so a test can force it unresolved.
296///
297/// # Safety
298///
299/// As [`apply_patch`].
300#[cfg(any(unix, windows))]
301unsafe fn apply_patch_with_anchor(
302    mut table: JumpTable,
303    anchor: fn() -> usize,
304) -> Result<ApplyTimings, PatchError> {
305    let _serial = APPLY_LOCK.lock().unwrap_or_else(PoisonError::into_inner);
306
307    // Under the lock and before any load: a mismatch reported while this call waited is seen, and
308    // none can slip between this check and the install.
309    let pending = pending_layout_mismatches();
310    if !pending.is_empty() {
311        return Err(PatchError::LayoutMismatchPending(pending));
312    }
313
314    // The app's anchor symbol anchors both images: the running binary's slide and the patch's load
315    // address. The patch builder exports it from every patch library. Rebasing against an
316    // unresolved (0) anchor would key every entry on a wrong address, so refuse before loading
317    // anything.
318    let anchor = anchor();
319    if anchor == 0 {
320        return Err(PatchError::AnchorUnresolved);
321    }
322    let old_offset = anchor.wrapping_sub(table.aslr_reference as usize);
323    let (base_start, base_end) =
324        image_range_containing(anchor).ok_or(PatchError::ImageRangeUnresolved)?;
325    // The table's anchor must be this image's anchor symbol, so the offset it implies is exactly
326    // the image's slide. Checked before the load: a wrong table costs nothing.
327    let base_slide = image_slide_containing(anchor).ok_or(PatchError::ImageRangeUnresolved)?;
328    if old_offset != base_slide {
329        return Err(PatchError::AnchorMismatch {
330            image: "base",
331            expected: base_slide as u64,
332            got: old_offset as u64,
333        });
334    }
335
336    let loading = std::time::Instant::now();
337    // SAFETY: the caller guarantees `table.lib` is the patch library built for this process.
338    let lib = unsafe { load_patch_library(&table.lib)? };
339    let load = loading.elapsed();
340    let installing = std::time::Instant::now();
341    // Never unload a patch: its code stays reachable through the table, and dropping a handle has
342    // been seen to run teardown code that crashes the process.
343    let lib: &'static libloading::Library = Box::leak(Box::new(lib));
344
345    // SAFETY: the anchor is only read as an address, never called through this symbol.
346    let symbol = unsafe { lib.get::<*const ()>(ANCHOR_SYMBOL.as_bytes()) }.map_err(|err| {
347        PatchError::Dlopen(format!(
348            "patch library has no `{ANCHOR_SYMBOL}` anchor: {err}"
349        ))
350    })?;
351    // SAFETY: the symbol came from `lib`, which is leaked and so outlives the raw pointer; the
352    // pointer is only used for address arithmetic.
353    let patch_anchor = unsafe { symbol.try_as_raw_ptr() }.ok_or_else(|| {
354        PatchError::Dlopen(format!("patch library's `{ANCHOR_SYMBOL}` anchor is null"))
355    })?;
356    let new_offset = patch_anchor.wrapping_byte_sub(table.new_base_address as usize) as usize;
357    let (patch_start, patch_end) =
358        image_range_containing(patch_anchor as usize).ok_or(PatchError::ImageRangeUnresolved)?;
359    // The library is already mapped and leaked here; nothing is registered or installed.
360    let patch_slide =
361        image_slide_containing(patch_anchor as usize).ok_or(PatchError::ImageRangeUnresolved)?;
362    if new_offset != patch_slide {
363        return Err(PatchError::AnchorMismatch {
364            image: "patch",
365            expected: patch_slide as u64,
366            got: new_offset as u64,
367        });
368    }
369
370    table.map = table
371        .map
372        .iter()
373        .map(|(old, new)| {
374            (
375                (*old as usize).wrapping_add(old_offset) as u64,
376                (*new as usize).wrapping_add(new_offset) as u64,
377            )
378        })
379        .collect();
380
381    // Registered before the table is published, so a miss right after publication resolves.
382    register_base(base_start, base_end, old_offset);
383    register_patch(patch_start, patch_end, new_offset);
384    commit_patch(table);
385    Ok(ApplyTimings {
386        load,
387        install: installing.elapsed(),
388    })
389}
390
391/// A layout disagreement found at run time: a value built by one image was reached by another
392/// whose own layout for `type_name` differs.
393#[derive(Clone, Debug, PartialEq, Eq, Hash)]
394pub struct LayoutMismatch {
395    /// The mismatched type's name.
396    pub type_name: String,
397    /// The layout recorded by the creating image, as text (e.g. its size).
398    pub stored: String,
399    /// The layout the reporting image computed for itself.
400    pub own: String,
401}
402
403impl std::fmt::Display for LayoutMismatch {
404    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
405        write!(
406            f,
407            "{}: stored {}, own {}",
408            self.type_name, self.stored, self.own
409        )
410    }
411}
412
413static LAYOUT_MISMATCHES: Mutex<Vec<LayoutMismatch>> = Mutex::new(Vec::new());
414
415/// Record that `type_name` has layout `stored` in the image that created a value and `own` in the
416/// image now handling it. The record is kept until it is reported to the host: it is returned by
417/// [`pending_layout_mismatches`] and cleared by [`mark_layout_mismatches_reported`]. Identical
418/// records collapse into one. While any record is unreported, every apply entry
419/// ([`apply_patch`], [`apply_from_devtools`]) refuses.
420///
421/// Callable from any thread, including from a `Drop`; it never panics.
422pub fn report_layout_mismatch(
423    type_name: &str,
424    stored: impl std::fmt::Display,
425    own: impl std::fmt::Display,
426) {
427    let record = LayoutMismatch {
428        type_name: type_name.to_owned(),
429        stored: stored.to_string(),
430        own: own.to_string(),
431    };
432    let mut records = LAYOUT_MISMATCHES
433        .lock()
434        .unwrap_or_else(PoisonError::into_inner);
435    if !records.contains(&record) {
436        records.push(record);
437    }
438}
439
440/// The layout-mismatch records not yet reported, oldest first (for `hotpatch_info`).
441pub fn pending_layout_mismatches() -> Vec<LayoutMismatch> {
442    LAYOUT_MISMATCHES
443        .lock()
444        .unwrap_or_else(PoisonError::into_inner)
445        .clone()
446}
447
448/// Mark `reported` as carried by a `PatchOutcome` or `hotpatch_info` answer. Only the records
449/// given are removed, so one reported after the caller read the list stays pending.
450pub fn mark_layout_mismatches_reported(reported: &[LayoutMismatch]) {
451    LAYOUT_MISMATCHES
452        .lock()
453        .unwrap_or_else(PoisonError::into_inner)
454        .retain(|record| !reported.contains(record));
455}
456
457/// Test-only control over the process-global table and handlers.
458#[cfg(test)]
459pub(crate) mod test_support {
460    use std::sync::{Mutex, MutexGuard, PoisonError};
461
462    use super::{APP_JUMP_TABLE, HOTRELOAD_HANDLERS, LAYOUT_MISMATCHES, Ordering, commit_patch};
463    use crate::JumpTable;
464    use crate::hot_fn::reset_fall_throughs;
465
466    static SERIAL: Mutex<()> = Mutex::new(());
467
468    /// Hold for a test's whole body: the table, handlers and miss count are process-global.
469    pub(crate) fn serial() -> MutexGuard<'static, ()> {
470        SERIAL.lock().unwrap_or_else(PoisonError::into_inner)
471    }
472
473    /// Uninstall the table (leaking it, as patches always are), drop every handler, zero misses.
474    pub(crate) fn reset() {
475        APP_JUMP_TABLE.store(std::ptr::null_mut(), Ordering::Release);
476        HOTRELOAD_HANDLERS
477            .lock()
478            .unwrap_or_else(PoisonError::into_inner)
479            .clear();
480        reset_fall_throughs();
481        crate::anchor::reset_for_test();
482        LAYOUT_MISMATCHES
483            .lock()
484            .unwrap_or_else(PoisonError::into_inner)
485            .clear();
486    }
487
488    /// Publish `table` verbatim (no library load, no rebase) and run the handlers.
489    pub(crate) fn install(table: JumpTable) {
490        commit_patch(table);
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use std::sync::atomic::AtomicU32;
497
498    use crate::anchor::UNKNOWN_IMAGE;
499
500    use super::*;
501
502    fn table(entries: &[(u64, u64)]) -> JumpTable {
503        JumpTable {
504            lib: "/nonexistent/frust-hotpatch-test.so".into(),
505            map: entries.iter().copied().collect(),
506            // The test anchor's link-time address in this test binary, as a correct table has it.
507            aslr_reference: (test_anchor() - slide_of_test_anchor()) as u64,
508            new_base_address: 0x2000,
509            ifunc_count: 0,
510        }
511    }
512
513    /// A real code address in this test binary, standing in for the app's registered anchor.
514    fn test_anchor_fn() {}
515
516    fn test_anchor() -> usize {
517        test_anchor_fn as *const () as usize
518    }
519
520    fn slide_of_test_anchor() -> usize {
521        crate::anchor::image_slide_containing(test_anchor()).unwrap_or(0)
522    }
523
524    fn counting_handler() -> (Arc<AtomicU32>, Handler) {
525        let count = Arc::new(AtomicU32::new(0));
526        let seen = Arc::clone(&count);
527        (
528            count,
529            Arc::new(move || {
530                seen.fetch_add(1, Ordering::SeqCst);
531            }),
532        )
533    }
534
535    #[test]
536    fn missing_library_is_a_load_error() {
537        // SAFETY: the path does not exist, so nothing is loaded.
538        let err = unsafe { load_patch_library(Path::new("/nonexistent/frust-hotpatch-test.so")) };
539        assert!(matches!(
540            err,
541            Err(PatchError::Dlopen(_) | PatchError::AndroidMemfd(_))
542        ));
543    }
544
545    #[test]
546    fn handler_runs_once_per_patch() {
547        let _serial = test_support::serial();
548        test_support::reset();
549        let (count, handler) = counting_handler();
550        register_handler(handler);
551
552        test_support::install(table(&[]));
553        assert_eq!(count.load(Ordering::SeqCst), 1);
554        test_support::install(table(&[]));
555        assert_eq!(count.load(Ordering::SeqCst), 2);
556
557        test_support::reset();
558    }
559
560    #[test]
561    fn panicking_handler_does_not_abort_the_patch() {
562        let _serial = test_support::serial();
563        test_support::reset();
564        register_handler(Arc::new(|| panic!("handler failure under test")));
565        let (count, handler) = counting_handler();
566        register_handler(handler);
567
568        commit_patch(table(&[(1, 2)]));
569        assert_eq!(count.load(Ordering::SeqCst), 1, "later handlers still run");
570        // SAFETY: the reference is used only within this test, under the serial lock.
571        let installed = unsafe { get_jump_table() }.map(|t| t.map.get(&1).copied());
572        assert_eq!(installed, Some(Some(2)), "the patch stays installed");
573
574        // `run_handlers` reports the panic it caught.
575        assert_eq!(run_handlers(), 1);
576        test_support::reset();
577    }
578
579    #[test]
580    fn poisoned_handler_lock_is_recovered() {
581        let _serial = test_support::serial();
582        test_support::reset();
583        let _ = std::thread::spawn(|| {
584            let _held = HOTRELOAD_HANDLERS.lock();
585            panic!("poisoning the handler lock under test");
586        })
587        .join();
588        assert!(HOTRELOAD_HANDLERS.is_poisoned());
589
590        let (count, handler) = counting_handler();
591        register_handler(handler);
592        test_support::install(table(&[]));
593        assert_eq!(count.load(Ordering::SeqCst), 1);
594
595        HOTRELOAD_HANDLERS.clear_poison();
596        test_support::reset();
597    }
598
599    #[cfg(any(unix, windows))]
600    #[test]
601    fn poisoned_apply_lock_is_recovered() {
602        let _serial = test_support::serial();
603        test_support::reset();
604        let _ = std::thread::spawn(|| {
605            let _held = APPLY_LOCK.lock();
606            panic!("poisoning the apply lock under test");
607        })
608        .join();
609        assert!(APPLY_LOCK.is_poisoned());
610
611        // The poisoned lock is taken, not panicked on: the call reaches the loader and reports it.
612        // SAFETY: the library does not exist, so nothing is loaded or installed.
613        let err = unsafe { apply_patch_with_anchor(table(&[(1, 2)]), test_anchor) };
614        assert!(matches!(
615            err,
616            Err(PatchError::Dlopen(_) | PatchError::AndroidMemfd(_))
617        ));
618        // SAFETY: only checked for presence.
619        assert!(unsafe { get_jump_table() }.is_none());
620
621        APPLY_LOCK.clear_poison();
622        test_support::reset();
623    }
624
625    #[cfg(any(unix, windows))]
626    #[test]
627    fn unresolved_anchor_fails_closed() {
628        let _serial = test_support::serial();
629        test_support::reset();
630        let (count, handler) = counting_handler();
631        register_handler(handler);
632
633        // The library path does not exist: an `AnchorUnresolved` (not a load error) proves the
634        // anchor is checked before anything is loaded.
635        // SAFETY: nothing is loaded or installed when the anchor is unresolved.
636        let err = unsafe { apply_patch_with_anchor(table(&[(0x1100, 0x2100)]), || 0) };
637        assert_eq!(err, Err(PatchError::AnchorUnresolved));
638        // SAFETY: only checked for presence.
639        assert!(unsafe { get_jump_table() }.is_none(), "nothing installed");
640        assert_eq!(count.load(Ordering::SeqCst), 0, "no handler fired");
641
642        test_support::reset();
643    }
644
645    #[cfg(any(unix, windows))]
646    #[test]
647    #[cfg_attr(
648        not(debug_assertions),
649        ignore = "the release profile refuses before the anchor; see release_profile_refuses"
650    )]
651    fn apply_refuses_until_an_anchor_is_set_then_proceeds() {
652        let _serial = test_support::serial();
653        test_support::reset();
654
655        // SAFETY: the library does not exist, so nothing is loaded or installed.
656        let err = unsafe { apply_patch(table(&[(0x1100, 0x2100)])) };
657        assert_eq!(err, Err(PatchError::AnchorUnresolved), "no anchor yet");
658
659        crate::set_anchor(test_anchor());
660        // SAFETY: as above.
661        let err = unsafe { apply_patch(table(&[(0x1100, 0x2100)])) };
662        assert!(
663            matches!(
664                err,
665                Err(PatchError::Dlopen(_) | PatchError::AndroidMemfd(_))
666            ),
667            "with an anchor the call reaches the loader: {err:?}"
668        );
669        test_support::reset();
670    }
671
672    #[cfg(any(unix, windows))]
673    #[test]
674    #[cfg_attr(
675        debug_assertions,
676        ignore = "covers the release profile; run `cargo test --release -p frust-hotpatch`"
677    )]
678    fn release_profile_refuses() {
679        let _serial = test_support::serial();
680        test_support::reset();
681        crate::set_anchor(test_anchor());
682        // SAFETY: a release build refuses before touching the table.
683        let err = unsafe { apply_patch(table(&[])) };
684        assert_eq!(err, Err(PatchError::ReleaseBuild));
685        let report = apply_from_devtools(Path::new("/nonexistent/x.so"), table(&[]));
686        assert!(!report.applied);
687        assert_eq!(report.error, Some(PatchError::ReleaseBuild));
688        test_support::reset();
689    }
690
691    #[cfg(any(unix, windows))]
692    #[test]
693    fn debug_profile_does_not_refuse_with_release_build() {
694        if !cfg!(debug_assertions) {
695            return;
696        }
697        let _serial = test_support::serial();
698        test_support::reset();
699        crate::set_anchor(test_anchor());
700        // SAFETY: the library does not exist, so nothing is loaded.
701        let err = unsafe { apply_patch(table(&[])) };
702        assert_ne!(err, Err(PatchError::ReleaseBuild));
703        test_support::reset();
704    }
705
706    /// A system library that certainly loads and certainly lacks the anchor symbol.
707    #[cfg(any(unix, windows))]
708    fn anchorless_library() -> &'static str {
709        if cfg!(target_vendor = "apple") {
710            "/usr/lib/libSystem.B.dylib"
711        } else if cfg!(windows) {
712            "kernel32.dll"
713        } else {
714            "libc.so.6"
715        }
716    }
717
718    #[cfg(any(unix, windows))]
719    #[test]
720    #[cfg_attr(not(debug_assertions), ignore = "apply_patch refuses in release")]
721    fn library_without_the_anchor_symbol_is_refused() {
722        let _serial = test_support::serial();
723        test_support::reset();
724        crate::set_anchor(test_anchor());
725        let mut t = table(&[(0x1100, 0x2100)]);
726        t.lib = anchorless_library().into();
727        // SAFETY: a system library is loaded; its initialisers are the platform's own.
728        let err = unsafe { apply_patch(t) };
729        match err {
730            Err(PatchError::Dlopen(msg)) => assert!(msg.contains(ANCHOR_SYMBOL), "{msg}"),
731            other => panic!("expected Dlopen naming the anchor, got {other:?}"),
732        }
733        // SAFETY: only checked for presence.
734        assert!(unsafe { get_jump_table() }.is_none());
735        test_support::reset();
736    }
737
738    /// Build a real cdylib exporting the anchor symbol, so the first end-to-end apply with a
739    /// non-`main` anchor runs here. Skipped (loudly) only when `rustc` cannot be spawned.
740    #[cfg(any(unix, windows))]
741    #[test]
742    #[cfg_attr(not(debug_assertions), ignore = "apply_patch refuses in release")]
743    fn real_patch_library_applies_through_the_anchor_symbol() {
744        let _serial = test_support::serial();
745        test_support::reset();
746
747        let dir = std::env::temp_dir().join(format!("frust-hotpatch-test-{}", std::process::id()));
748        std::fs::create_dir_all(&dir).expect("create scratch dir");
749        let src = dir.join("patch.rs");
750        std::fs::write(
751            &src,
752            "#[unsafe(no_mangle)] pub extern \"C\" fn __frust_hotpatch_anchor() {}\n\
753             #[unsafe(no_mangle)] pub extern \"C\" fn patched() -> u32 { 7 }\n",
754        )
755        .expect("write patch source");
756        let ext = if cfg!(windows) {
757            "dll"
758        } else if cfg!(target_vendor = "apple") {
759            "dylib"
760        } else {
761            "so"
762        };
763        let lib = dir.join(format!("patch.{ext}"));
764        let status = std::process::Command::new("rustc")
765            .args([
766                "--edition=2024",
767                "--crate-type=cdylib",
768                "--crate-name=patch",
769                "-o",
770            ])
771            .arg(&lib)
772            .arg(&src)
773            .status();
774        let Ok(status) = status else {
775            eprintln!("rustc is not spawnable; real-library apply test skipped");
776            return;
777        };
778        assert!(status.success(), "building the patch library failed");
779
780        crate::set_anchor(test_anchor());
781        // The link-time anchor addresses a correct table carries: runtime address minus slide,
782        // the patch's read by mapping the library (the loader returns the same handle later).
783        let base_slide = crate::anchor::image_slide_containing(test_anchor()).expect("base slide");
784        let good_old = test_anchor().wrapping_sub(base_slide) as u64;
785        // SAFETY: the library was just built by this test and has no initialisers; the handle is
786        // leaked, as `apply_patch` leaks its own.
787        let probe: &'static libloading::Library = Box::leak(Box::new(
788            unsafe { load_patch_library(&lib) }.expect("map patch"),
789        ));
790        // SAFETY: the symbol is only read as an address.
791        let symbol = unsafe { probe.get::<*const ()>(ANCHOR_SYMBOL.as_bytes()) }.expect("anchor");
792        // SAFETY: the library is leaked, so the pointer is only used for address arithmetic.
793        let patch_anchor = unsafe { symbol.try_as_raw_ptr() }.expect("non-null anchor") as usize;
794        let patch_slide = crate::anchor::image_slide_containing(patch_anchor).expect("patch slide");
795        let good_new = patch_anchor.wrapping_sub(patch_slide) as u64;
796        let build = |old: u64, new: u64| {
797            let mut t = table(&[(0x1100, 0x2100)]);
798            t.lib = lib.clone();
799            t.aslr_reference = old;
800            t.new_base_address = new;
801            t
802        };
803
804        // A table anchored elsewhere (dx anchors on `main`) is refused before anything installs.
805        // SAFETY: the library was just built by this test and has no initialisers.
806        let wrong_base = unsafe { apply_patch(build(good_old + 0x10, good_new)) };
807        assert_eq!(
808            wrong_base,
809            Err(PatchError::AnchorMismatch {
810                image: "base",
811                expected: base_slide as u64,
812                got: base_slide as u64 - 0x10,
813            })
814        );
815        // SAFETY: as above.
816        let wrong_patch = unsafe { apply_patch(build(good_old, good_new + 0x10)) };
817        assert_eq!(
818            wrong_patch,
819            Err(PatchError::AnchorMismatch {
820                image: "patch",
821                expected: patch_slide as u64,
822                got: patch_slide as u64 - 0x10,
823            })
824        );
825        // SAFETY: only checked for presence.
826        assert!(unsafe { get_jump_table() }.is_none(), "nothing installed");
827        assert_eq!(
828            crate::anchor::classify(test_anchor() as u64).0,
829            UNKNOWN_IMAGE
830        );
831
832        // SAFETY: as above.
833        unsafe { apply_patch(build(good_old, good_new)) }.expect("apply through the anchor symbol");
834
835        let old_offset = base_slide;
836        // SAFETY: used within this test under the serial lock.
837        let installed = unsafe { get_jump_table() }.expect("table installed");
838        assert_eq!(installed.map.len(), 1);
839        let (key, value) = installed.map.iter().next().expect("one entry");
840        assert_eq!(*key, (0x1100usize.wrapping_add(old_offset)) as u64);
841        assert_ne!(
842            *value, 0x2100,
843            "the target was rebased onto the loaded image"
844        );
845        // The patch is image 1, the executable image 0.
846        assert_eq!(crate::anchor::classify(test_anchor() as u64).0, 0);
847        assert_eq!(crate::anchor::classify(*value).0, 1);
848
849        test_support::reset();
850        let _ = std::fs::remove_dir_all(&dir);
851    }
852
853    #[cfg(any(unix, windows))]
854    #[test]
855    #[cfg_attr(not(debug_assertions), ignore = "apply_patch refuses in release")]
856    fn raw_apply_patch_refuses_while_a_mismatch_is_unreported_then_proceeds() {
857        let _serial = test_support::serial();
858        test_support::reset();
859        crate::set_anchor(test_anchor());
860
861        report_layout_mismatch("RawState", 4, 8);
862        let expected = pending_layout_mismatches();
863        // SAFETY: the library path does not exist, so nothing can be loaded.
864        let refused = unsafe { apply_patch(table(&[(1, 2)])) };
865        assert_eq!(
866            refused,
867            Err(PatchError::LayoutMismatchPending(expected.clone()))
868        );
869        // SAFETY: only checked for presence.
870        assert!(unsafe { get_jump_table() }.is_none());
871
872        mark_layout_mismatches_reported(&expected);
873        // SAFETY: as above; the loader is reached and fails on the missing file.
874        let proceeded = unsafe { apply_patch(table(&[(1, 2)])) };
875        assert!(
876            matches!(
877                proceeded,
878                Err(PatchError::Dlopen(_) | PatchError::AndroidMemfd(_))
879            ),
880            "once reported the raw apply reaches the loader: {proceeded:?}"
881        );
882        test_support::reset();
883    }
884
885    #[cfg(any(unix, windows))]
886    #[test]
887    #[cfg_attr(not(debug_assertions), ignore = "apply_patch refuses in release")]
888    fn devtools_apply_refuses_while_a_mismatch_is_unreported() {
889        let _serial = test_support::serial();
890        test_support::reset();
891        crate::set_anchor(test_anchor());
892
893        report_layout_mismatch("HomeState", 4, 8);
894        report_layout_mismatch("HomeState", 4, 8);
895        let expected = LayoutMismatch {
896            type_name: "HomeState".into(),
897            stored: "4".into(),
898            own: "8".into(),
899        };
900        assert_eq!(
901            pending_layout_mismatches(),
902            vec![expected.clone()],
903            "deduplicated"
904        );
905
906        // The library path does not exist: `Dlopen` would mean the load was attempted.
907        let report = apply_from_devtools(Path::new("/nonexistent/x.so"), table(&[(1, 2)]));
908        assert!(!report.applied);
909        assert_eq!(report.layout_mismatches, vec![expected.clone()]);
910        assert_eq!(report.error, None, "refused for the record, not an error");
911        assert_eq!(
912            (report.load, report.install),
913            (std::time::Duration::ZERO, std::time::Duration::ZERO),
914            "nothing was loaded or installed, so nothing was timed"
915        );
916        // SAFETY: only checked for presence.
917        assert!(unsafe { get_jump_table() }.is_none());
918
919        // A record reported after the list was read stays pending.
920        report_layout_mismatch("Other", 1, 2);
921        mark_layout_mismatches_reported(&report.layout_mismatches);
922        assert_eq!(pending_layout_mismatches().len(), 1);
923        mark_layout_mismatches_reported(&pending_layout_mismatches());
924        assert!(pending_layout_mismatches().is_empty());
925
926        let report = apply_from_devtools(Path::new("/nonexistent/x.so"), table(&[(1, 2)]));
927        assert!(report.layout_mismatches.is_empty());
928        assert!(
929            matches!(
930                report.error,
931                Some(PatchError::Dlopen(_) | PatchError::AndroidMemfd(_))
932            ),
933            "once reported the apply proceeds to the loader: {report:?}"
934        );
935        assert_eq!(
936            LayoutMismatch {
937                type_name: "T".into(),
938                stored: "1".into(),
939                own: "2".into()
940            }
941            .to_string(),
942            "T: stored 1, own 2"
943        );
944        test_support::reset();
945    }
946}