Skip to main content

whisker_dev_server/hotpatch/
jump_table.rs

1//! Build a `subsecond::JumpTable` from old vs new symbol tables.
2//!
3//! This is the diffing brain of Tier 1: given the original binary's
4//! symbols and the freshly-linked patch dylib's symbols, walk the
5//! ones that exist in both and produce the address-to-address map
6//! that `subsecond::apply_patch` will use to rewrite call sites.
7//!
8//! What we *don't* try to do here:
9//!   - Resolve undefined symbols. Those have address 0 in either
10//!     side; including them would lie to the runtime.
11//!   - Touch data symbols. Hot-patching globals would race the
12//!     program, which is harder than function hot-patching and
13//!     not on the I4g critical path.
14//!   - Touch zero-sized symbols. These are typically PLT stubs and
15//!     compiler-introduced markers; no actual code to swap.
16//!   - Special-case weak symbols. They get a warning so the dev
17//!     loop can surface ambiguity, but the entry is still emitted —
18//!     subsecond will pick whichever the dynamic linker chose.
19
20use std::path::PathBuf;
21
22use object::SymbolKind;
23use subsecond_types::{AddressMap, JumpTable};
24
25use super::symbol_table::SymbolTable;
26
27/// Names of symbols that exist in `old` and were dropped in `new`.
28/// Reported alongside the JumpTable so the dev loop can warn the
29/// user that calls into one of those would crash after a patch.
30#[derive(Debug, Clone, Default, PartialEq, Eq)]
31pub struct DiffReport {
32    /// Symbols only in `old`. A call to one of these post-patch
33    /// would resolve to an address subsecond hasn't relocated.
34    pub removed: Vec<String>,
35    /// Symbols only in `new`. Brand-new functions in the patch.
36    /// Safe to ignore: pre-patch code can't reference them.
37    pub added: Vec<String>,
38    /// Weak symbols included in the map. Subsecond will use
39    /// whichever the linker resolved to; this is just a hint to
40    /// the user that something might shift.
41    pub weak: Vec<String>,
42}
43
44/// Result of [`build_jump_table`]: the `subsecond` payload + a
45/// human-readable diff summary.
46#[derive(Debug, Clone)]
47pub struct PatchPlan {
48    pub table: JumpTable,
49    pub report: DiffReport,
50}
51
52/// Compose a [`JumpTable`] from `old` (the live binary's symbol
53/// table, parsed once and cached) and `new` (the freshly-built
54/// patch dylib's symbol table). `new_lib` is the on-device path
55/// the runtime will `dlopen`; `aslr_reference` and `new_base` are
56/// what subsecond uses to correct for ASLR slide.
57pub fn build_jump_table(
58    old: &SymbolTable,
59    new: &SymbolTable,
60    new_lib: PathBuf,
61    aslr_reference: u64,
62    new_base_address: u64,
63) -> PatchPlan {
64    let mut map = AddressMap::default();
65    let mut report = DiffReport::default();
66
67    for (name, new_sym) in &new.by_name {
68        let old_sym = match old.by_name.get(name) {
69            Some(s) => s,
70            None => {
71                report.added.push(name.clone());
72                continue;
73            }
74        };
75
76        // Skip the things hot-patch can't (or shouldn't) touch.
77        if !is_patchable(old_sym.kind) || !is_patchable(new_sym.kind) {
78            continue;
79        }
80        if old_sym.is_undefined || new_sym.is_undefined {
81            continue;
82        }
83        // Skip zero-sized symbols only when *both* are sized — Mach-O
84        // never populates `size` on its symbol table (it's an ELF
85        // concept), so on macOS every Text symbol comes back with
86        // size 0 and we'd discard everything otherwise. ELF defined
87        // symbols always have non-zero size, so PLT stubs / markers
88        // (size 0 on ELF) still get filtered out there.
89        if old_sym.size == 0 && new_sym.size == 0 && cfg!(target_os = "linux") {
90            continue;
91        }
92
93        if old_sym.is_weak || new_sym.is_weak {
94            report.weak.push(name.clone());
95        }
96
97        map.insert(old_sym.address, new_sym.address);
98    }
99
100    // `removed`: symbols that existed in old but no longer in new.
101    for name in old.by_name.keys() {
102        if !new.by_name.contains_key(name) {
103            report.removed.push(name.clone());
104        }
105    }
106    report.removed.sort();
107    report.added.sort();
108    report.weak.sort();
109
110    PatchPlan {
111        table: JumpTable {
112            lib: new_lib,
113            map,
114            aslr_reference,
115            new_base_address,
116            ifunc_count: 0, // WASM-only; not relevant for native targets
117        },
118        report,
119    }
120}
121
122fn is_patchable(kind: SymbolKind) -> bool {
123    matches!(kind, SymbolKind::Text)
124}
125
126// ============================================================================
127// Tests
128// ============================================================================
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::hotpatch::symbol_table::SymbolInfo;
134    use std::collections::HashMap;
135
136    fn text(addr: u64, size: u64) -> SymbolInfo {
137        SymbolInfo {
138            address: addr,
139            kind: SymbolKind::Text,
140            size,
141            is_undefined: false,
142            is_weak: false,
143        }
144    }
145    fn data(addr: u64, size: u64) -> SymbolInfo {
146        SymbolInfo {
147            address: addr,
148            kind: SymbolKind::Data,
149            size,
150            is_undefined: false,
151            is_weak: false,
152        }
153    }
154    fn weak(addr: u64, size: u64) -> SymbolInfo {
155        SymbolInfo {
156            address: addr,
157            kind: SymbolKind::Text,
158            size,
159            is_undefined: false,
160            is_weak: true,
161        }
162    }
163    fn undef() -> SymbolInfo {
164        SymbolInfo {
165            address: 0,
166            kind: SymbolKind::Text,
167            size: 0,
168            is_undefined: true,
169            is_weak: false,
170        }
171    }
172
173    fn t(entries: Vec<(&str, SymbolInfo)>) -> SymbolTable {
174        let mut by_name = HashMap::new();
175        for (n, s) in entries {
176            by_name.insert(n.to_string(), s);
177        }
178        SymbolTable { by_name }
179    }
180
181    fn lib() -> PathBuf {
182        PathBuf::from("/tmp/patch.dylib")
183    }
184
185    // ----- happy path --------------------------------------------------
186
187    #[test]
188    fn identical_tables_produce_an_identity_like_map() {
189        let same = t(vec![("foo", text(0x1000, 32)), ("bar", text(0x2000, 16))]);
190        let plan = build_jump_table(&same, &same, lib(), 0, 0);
191        assert_eq!(plan.table.map.len(), 2);
192        assert_eq!(plan.table.map.get(&0x1000), Some(&0x1000));
193        assert_eq!(plan.table.map.get(&0x2000), Some(&0x2000));
194        assert!(plan.report.removed.is_empty());
195        assert!(plan.report.added.is_empty());
196    }
197
198    #[test]
199    fn moved_function_records_old_to_new_address() {
200        let old = t(vec![("app", text(0x1000, 100))]);
201        let new = t(vec![("app", text(0x1500, 120))]);
202        let plan = build_jump_table(&old, &new, lib(), 0, 0);
203        assert_eq!(plan.table.map.len(), 1);
204        assert_eq!(plan.table.map.get(&0x1000), Some(&0x1500));
205    }
206
207    #[test]
208    fn aslr_and_base_address_are_propagated_into_the_table() {
209        let same = t(vec![("foo", text(0x1000, 32))]);
210        let plan = build_jump_table(&same, &same, lib(), 0xCAFE_BABE, 0xDEAD_BEEF);
211        assert_eq!(plan.table.aslr_reference, 0xCAFE_BABE);
212        assert_eq!(plan.table.new_base_address, 0xDEAD_BEEF);
213        assert_eq!(plan.table.lib, PathBuf::from("/tmp/patch.dylib"));
214        assert_eq!(plan.table.ifunc_count, 0);
215    }
216
217    // ----- skipped categories ------------------------------------------
218
219    #[test]
220    fn data_symbols_are_skipped() {
221        let old = t(vec![("g", data(0x4000, 8))]);
222        let new = t(vec![("g", data(0x4100, 8))]);
223        assert!(build_jump_table(&old, &new, lib(), 0, 0)
224            .table
225            .map
226            .is_empty());
227    }
228
229    #[test]
230    fn undefined_symbols_are_skipped_either_side() {
231        let old = t(vec![("undef", undef()), ("def", text(0x1000, 16))]);
232        let new = t(vec![("undef", text(0x9000, 16)), ("def", undef())]);
233        let plan = build_jump_table(&old, &new, lib(), 0, 0);
234        // "undef" old=undef → skip; "def" new=undef → skip; map empty.
235        assert!(plan.table.map.is_empty());
236    }
237
238    #[test]
239    #[cfg(target_os = "linux")]
240    fn zero_sized_symbols_are_skipped_on_elf() {
241        // On ELF, defined Text symbols always have non-zero size, so
242        // a size-0 entry is a PLT stub or compiler marker — skip.
243        let old = t(vec![("plt_stub", text(0x1000, 0))]);
244        let new = t(vec![("plt_stub", text(0x1100, 0))]);
245        assert!(build_jump_table(&old, &new, lib(), 0, 0)
246            .table
247            .map
248            .is_empty());
249    }
250
251    #[test]
252    #[cfg(any(target_os = "macos", target_os = "ios"))]
253    fn zero_sized_symbols_are_kept_on_mach_o() {
254        // Mach-O's nlist entries don't carry a size field, so every
255        // Text symbol comes back as size 0; the filter must NOT
256        // throw them away or we'd never patch anything on macOS.
257        let old = t(vec![("foo", text(0x1000, 0))]);
258        let new = t(vec![("foo", text(0x1100, 0))]);
259        let plan = build_jump_table(&old, &new, lib(), 0, 0);
260        assert_eq!(plan.table.map.len(), 1);
261        assert_eq!(plan.table.map.get(&0x1000), Some(&0x1100));
262    }
263
264    // ----- diff report -------------------------------------------------
265
266    #[test]
267    fn added_and_removed_show_up_in_the_report() {
268        let old = t(vec![("kept", text(0x1000, 16)), ("gone", text(0x2000, 16))]);
269        let new = t(vec![
270            ("kept", text(0x1100, 16)),
271            ("brand_new", text(0x3000, 16)),
272        ]);
273        let plan = build_jump_table(&old, &new, lib(), 0, 0);
274        assert_eq!(plan.report.removed, vec!["gone".to_string()]);
275        assert_eq!(plan.report.added, vec!["brand_new".to_string()]);
276        assert_eq!(plan.table.map.len(), 1);
277        assert_eq!(plan.table.map.get(&0x1000), Some(&0x1100));
278    }
279
280    #[test]
281    fn weak_symbol_is_emitted_but_listed_in_report() {
282        let old = t(vec![("maybe", weak(0x1000, 16))]);
283        let new = t(vec![("maybe", weak(0x1100, 16))]);
284        let plan = build_jump_table(&old, &new, lib(), 0, 0);
285        assert_eq!(plan.table.map.len(), 1);
286        assert_eq!(plan.report.weak, vec!["maybe".to_string()]);
287    }
288
289    #[test]
290    fn report_lists_are_sorted_for_stable_diagnostics() {
291        let old = t(vec![
292            ("c", text(0x1, 1)),
293            ("a", text(0x2, 1)),
294            ("b", text(0x3, 1)),
295        ]);
296        let new = t(vec![
297            ("z", text(0x4, 1)),
298            ("y", text(0x5, 1)),
299            ("x", text(0x6, 1)),
300        ]);
301        let plan = build_jump_table(&old, &new, lib(), 0, 0);
302        assert_eq!(plan.report.removed, vec!["a", "b", "c"]);
303        assert_eq!(plan.report.added, vec!["x", "y", "z"]);
304    }
305
306    // ----- both empty --------------------------------------------------
307
308    #[test]
309    fn empty_inputs_produce_empty_outputs() {
310        let plan = build_jump_table(
311            &SymbolTable::default(),
312            &SymbolTable::default(),
313            lib(),
314            0,
315            0,
316        );
317        assert!(plan.table.map.is_empty());
318        assert!(plan.report.added.is_empty());
319        assert!(plan.report.removed.is_empty());
320        assert!(plan.report.weak.is_empty());
321    }
322}