Skip to main content

whisker_dev_server/hotpatch/
stub_object.rs

1//! Build a "stub" object file that defines every symbol the patch
2//! references but doesn't itself supply. Each defined-here-only-for-
3//! the-patch symbol resolves to a tiny assembly trampoline that
4//! branches to the corresponding *runtime* address in the live host
5//! process.
6//!
7//! This is the load-bearing piece of the Option B / Dioxus-style
8//! patch-resolution scheme:
9//!
10//! - The dev server already knows every symbol's *static* address in
11//!   the host `.so` (parsed once into [`HotpatchModuleCache`]).
12//! - The device tells us, on its `hello` handshake, its
13//!   `subsecond::aslr_reference()` — the *runtime* address of
14//!   `whisker_aslr_anchor` in the loaded host process (Whisker's
15//!   subsecond fork anchors on this unique symbol rather than
16//!   `main`; see `crates/whisker-subsecond/src/lib.rs`).
17//! - `aslr_offset = aslr_reference - host_static_anchor_addr` is
18//!   the ASLR slide between the recorded `.so` and the live
19//!   process.
20//! - For each symbol the patch needs, we compute `runtime_addr =
21//!   host_static_addr + aslr_offset` and write a stub that jumps
22//!   straight there.
23//!
24//! After linking the patch with this stub object, the patch has *no*
25//! `DT_NEEDED` back-edge to the host and no dlopen-time symbol
26//! resolution to perform: every call from the patch into the host
27//! lands at the correct address by construction. This sidesteps the
28//! Android linker-namespace + `RTLD_LOCAL` problems that the prior
29//! "back-edge to host dylib" scheme tripped over.
30//!
31//! Mirrors `dioxus-cli-0.7.9::build::patch::create_undefined_symbol_stub`.
32//! Differences:
33//!
34//! - We don't support Windows (`__imp_` prefix handling and PE32+
35//!   stubs are skipped — Whisker targets Android + iOS-sim + the
36//!   macOS / Linux host).
37//! - We only emit Text stubs; Data symbol stubs are deferred (none of
38//!   our hot-patches reference data symbols in the host so far; the
39//!   tests in B-4 confirm this).
40
41use anyhow::{bail, Context, Result};
42use object::write::{Object, StandardSection, Symbol, SymbolSection};
43use object::{
44    Architecture, BinaryFormat, Endianness, Object as _, ObjectSymbol, SymbolFlags, SymbolKind,
45    SymbolScope,
46};
47use std::collections::HashSet;
48use std::path::Path;
49
50use crate::hotpatch::cache::HotpatchModuleCache;
51use crate::hotpatch::LinkerOs;
52
53/// Build a stub `.o` (bytes ready to write to disk) that satisfies
54/// every undefined symbol in `patch_obj` whose name is also present
55/// in `cache.symbols` as a defined symbol.
56///
57/// `aslr_reference` is the runtime address of
58/// `whisker_aslr_anchor` on the device
59/// (`subsecond::aslr_reference()`'s return value). The cache's
60/// `aslr_reference` field, populated in
61/// [`HotpatchModuleCache::from_path`], stores
62/// `whisker_aslr_anchor`'s *static* address in the host `.so`. The
63/// difference is the ASLR slide.
64pub fn create_undefined_symbol_stub(
65    cache: &HotpatchModuleCache,
66    patch_obj: &Path,
67    target_os: LinkerOs,
68    aslr_reference: u64,
69) -> Result<Vec<u8>> {
70    let needed = compute_needed_symbols(patch_obj)?;
71    build_stub_for_needed(&needed, cache, target_os, aslr_reference)
72}
73
74/// Parse `patch_obj` and return the sorted list of symbol names the
75/// patch refers to but doesn't itself define. Sorted (Vec, not Set)
76/// so callers can hash the result deterministically for caching.
77pub fn compute_needed_symbols(patch_obj: &Path) -> Result<Vec<String>> {
78    compute_needed_symbols_multi(std::slice::from_ref(&patch_obj))
79}
80
81/// Multi-input variant of [`compute_needed_symbols`]. Take the union
82/// of every input's undefined set minus the union of every input's
83/// defined set — i.e. symbols still unresolved after the inputs
84/// satisfy each other's references.
85///
86/// Used by sub-crate hot-patches (#103) where the patch dylib is
87/// linked from both the sub-crate's `.o` and the user crate's `.o`
88/// (the user's `.o` carries the anchor symbols). A name undefined
89/// in one but defined in the other should NOT end up in the stub.
90pub fn compute_needed_symbols_multi(patch_objs: &[&Path]) -> Result<Vec<String>> {
91    let mut undefined: HashSet<String> = HashSet::new();
92    let mut defined: HashSet<String> = HashSet::new();
93    for patch_obj in patch_objs {
94        let bytes = std::fs::read(patch_obj)
95            .with_context(|| format!("read patch obj {}", patch_obj.display()))?;
96        let file = object::File::parse(&*bytes).context("parse patch obj")?;
97        for sym in file.symbols() {
98            let Ok(name) = sym.name() else {
99                continue;
100            };
101            if name.is_empty() {
102                continue;
103            }
104            if sym.is_undefined() {
105                undefined.insert(name.to_string());
106            } else {
107                defined.insert(name.to_string());
108            }
109        }
110    }
111    let mut needed: Vec<String> = undefined.difference(&defined).cloned().collect();
112    needed.sort();
113    Ok(needed)
114}
115
116/// Build the stub object bytes for a precomputed `needed` list.
117/// Split out so callers (the Patcher's in-session cache) can hash
118/// `needed` against a key and skip the rebuild when the UND set
119/// hasn't changed.
120pub fn build_stub_for_needed(
121    needed: &[String],
122    cache: &HotpatchModuleCache,
123    target_os: LinkerOs,
124    aslr_reference: u64,
125) -> Result<Vec<u8>> {
126    let host_static_anchor = cache.aslr_reference;
127    if host_static_anchor == 0 {
128        bail!(
129            "host cache has no `whisker_aslr_anchor` symbol address \
130             (aslr_reference=0); ensure the `#[whisker::main]` macro \
131             emitted the synthetic anchor and that the cache parsed it"
132        );
133    }
134    if aslr_reference < host_static_anchor {
135        bail!(
136            "device-reported aslr_reference {:#x} is below host's static \
137             whisker_aslr_anchor address {:#x} — would underflow when \
138             computing the ASLR slide. Is the device running a stale \
139             build of the host .so?",
140            aslr_reference,
141            host_static_anchor,
142        );
143    }
144    let aslr_offset = aslr_reference - host_static_anchor;
145
146    let (bin_fmt, endian) = match target_os {
147        LinkerOs::Linux => (BinaryFormat::Elf, Endianness::Little),
148        LinkerOs::Macos => (BinaryFormat::MachO, Endianness::Little),
149        LinkerOs::Other => bail!(
150            "stub object generation: unsupported target_os {:?}",
151            target_os
152        ),
153    };
154    let mut obj = Object::new(bin_fmt, Architecture::Aarch64, endian);
155
156    let text = obj.section_id(StandardSection::Text);
157
158    for name in needed {
159        // Trim `__imp_` (a Windows-only convention) so the lookup
160        // works for ELF/Mach-O even if a Rust toolchain change starts
161        // emitting it on those platforms too. Currently a no-op for
162        // our supported targets.
163        let lookup_name = name.trim_start_matches("__imp_");
164        let Some(sym) = cache.symbols.by_name.get(lookup_name) else {
165            continue;
166        };
167        if sym.is_undefined || sym.address == 0 {
168            continue;
169        }
170        let abs_addr = sym.address + aslr_offset;
171
172        // Only Text (= code) symbols get stubs right now. Data
173        // symbols would need a different shape (pointer-sized Data
174        // entry in `.data` rather than executable trampoline), and
175        // we haven't seen the patch reference any host *data* in
176        // practice.
177        if !matches!(sym.kind, SymbolKind::Text) {
178            continue;
179        }
180
181        let code = arm64_jump_stub(abs_addr);
182        let off = obj.append_section_data(text, &code, 4);
183        // **Weak**, not strong: the captured linker args bring in
184        // archives like `libunwind.a` and `libwhisker_bridge_static.a`
185        // that already define some of these symbols. If we emit
186        // strong definitions the linker errors with "duplicate
187        // symbol"; weak ones lose to the strong defs but still
188        // satisfy the long tail (`core::fmt::*`, `alloc::*`, every
189        // `pub fn` in the user crate) that nothing else provides.
190        obj.add_symbol(Symbol {
191            name: name.as_bytes().to_vec(),
192            value: off,
193            size: code.len() as u64,
194            scope: SymbolScope::Linkage,
195            kind: SymbolKind::Text,
196            weak: true,
197            section: SymbolSection::Section(text),
198            flags: SymbolFlags::None,
199        });
200    }
201
202    obj.write().context("serialize stub object")
203}
204
205/// ARM64 assembly that loads a 64-bit absolute address into `X16`
206/// (the platform's intra-procedure-call scratch register) and
207/// branches to it.
208///
209/// ```text
210/// MOVZ X16, #imm0,  LSL #0    ; bits  0..15
211/// MOVK X16, #imm1,  LSL #16   ; bits 16..31
212/// MOVK X16, #imm2,  LSL #32   ; bits 32..47
213/// MOVK X16, #imm3,  LSL #48   ; bits 48..63
214/// BR   X16
215/// ```
216///
217/// 5 × 4 = 20 bytes. Encoded little-endian; the constants below come
218/// straight from the ARM64 instruction reference and match the
219/// Dioxus implementation.
220fn arm64_jump_stub(addr: u64) -> Vec<u8> {
221    let mut code = Vec::with_capacity(20);
222    let imm0 = (addr & 0xFFFF) as u32;
223    code.extend_from_slice(&(0xD280_0010_u32 | (imm0 << 5)).to_le_bytes());
224    let imm1 = ((addr >> 16) & 0xFFFF) as u32;
225    code.extend_from_slice(&(0xF2A0_0010_u32 | (imm1 << 5)).to_le_bytes());
226    let imm2 = ((addr >> 32) & 0xFFFF) as u32;
227    code.extend_from_slice(&(0xF2C0_0010_u32 | (imm2 << 5)).to_le_bytes());
228    let imm3 = ((addr >> 48) & 0xFFFF) as u32;
229    code.extend_from_slice(&(0xF2E0_0010_u32 | (imm3 << 5)).to_le_bytes());
230    // BR X16 = 0xD61F_0200, little-endian → `00 02 1F D6`
231    code.extend_from_slice(&[0x00, 0x02, 0x1F, 0xD6]);
232    code
233}
234
235// ============================================================================
236// Tests
237// ============================================================================
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn arm64_stub_encodes_address_zero_as_clear_zero_movs() {
245        // addr = 0 → all four immediate fields are 0. MOVZ#0 and the
246        // three MOVKs all share Rd=X16 and base encoding. Verify the
247        // bytes round-trip to the documented base instructions.
248        let code = arm64_jump_stub(0);
249        assert_eq!(code.len(), 20);
250        assert_eq!(&code[0..4], &0xD280_0010_u32.to_le_bytes()); // MOVZ X16, #0
251        assert_eq!(&code[4..8], &0xF2A0_0010_u32.to_le_bytes());
252        assert_eq!(&code[8..12], &0xF2C0_0010_u32.to_le_bytes());
253        assert_eq!(&code[12..16], &0xF2E0_0010_u32.to_le_bytes());
254        assert_eq!(&code[16..20], &[0x00, 0x02, 0x1F, 0xD6]); // BR X16
255    }
256
257    #[test]
258    fn arm64_stub_encodes_a_canonical_aarch64_userspace_address() {
259        // 0x7B40_91FF_2C00 is a plausible Android arm64 user-space
260        // address. Slice it into four 16-bit chunks and verify each
261        // lands in the right MOV instruction.
262        let addr = 0x7B40_91FF_2C00_u64;
263        let code = arm64_jump_stub(addr);
264        // imm0 = 0x2C00
265        let imm0 = (addr & 0xFFFF) as u32;
266        assert_eq!(&code[0..4], &(0xD280_0010_u32 | (imm0 << 5)).to_le_bytes(),);
267        // imm3 = 0x7B40 — top word lands in the LSL#48 MOVK
268        let imm3 = ((addr >> 48) & 0xFFFF) as u32;
269        assert_eq!(
270            &code[12..16],
271            &(0xF2E0_0010_u32 | (imm3 << 5)).to_le_bytes(),
272        );
273    }
274}