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}