1//! Builds a Dioxus-subsecond patch `.so` + `subsecond_types::JumpTable` from 2//! objects buck2 already rebuilt, and links it against a *running* base 3//! binary by address rather than by name. See `../README.md` for the whole 4//! pipeline and `../CLAUDE.md` for what not to change without re-measuring. 5//! 6//! `create_undefined_symbol_stub` and the jump-table construction below port 7//! the logic (not the code verbatim - no `walrus`/wasm/Windows/macOS 8//! branches, no `target_lexicon`, Linux/x86_64 ELF only) of 9//! `create_undefined_symbol_stub` and `create_native_jump_table` in 10//! `dioxus-cli`'s `packages/cli/src/build/patch.rs`, at tag `v0.7.10` 11//! (commit `57d6794ad60b949e5bd8aa282f6f8c3dc97a365e`, 12//! <https://github.com/DioxusLabs/dioxus>). That crate, and `subsecond-types` 13//! (whose `JumpTable`/`AddressMap` types this file uses unmodified), are 14//! dual-licensed MIT OR Apache-2.0; see `LICENSE-MIT`/`LICENSE-APACHE` in the 15//! Dioxus repository. Copyright the Dioxus contributors. 16 17use std::collections::{HashMap, HashSet}; 18use std::io::{Cursor, Read}; 19use std::path::{Path, PathBuf}; 20use std::process::Command; 21use std::{env, fs, process}; 22 23use object::write::{SectionId, StandardSection, Symbol, SymbolId, SymbolSection}; 24use object::{Endianness, Object, ObjectSection, ObjectSymbol, SymbolFlags, SymbolKind, SymbolScope}; 25use subsecond_types::{AddressMap, JumpTable}; 26 27struct Args { 28 base: PathBuf, 29 objects: Vec<PathBuf>, 30 aslr_reference: u64, 31 out_dir: PathBuf, 32} 33 34fn usage() -> ! { 35 eprintln!( 36 "usage: hotpatch --base <exe> --objects <.o/.rlib>... --aslr-reference <addr> --out-dir <dir>" 37 ); 38 process::exit(2); 39} 40 41fn parse_args() -> Args { 42 let mut base = None; 43 let mut objects = Vec::new(); 44 let mut aslr_reference = None; 45 let mut out_dir = None; 46 47 let mut args = env::args().skip(1).peekable(); 48 while let Some(arg) = args.next() { 49 match arg.as_str() { 50 "--base" => base = Some(PathBuf::from(args.next().unwrap_or_else(|| usage()))), 51 "--aslr-reference" => { 52 let raw = args.next().unwrap_or_else(|| usage()); 53 aslr_reference = Some(parse_u64(&raw)); 54 } 55 "--out-dir" => out_dir = Some(PathBuf::from(args.next().unwrap_or_else(|| usage()))), 56 "--objects" => { 57 while let Some(next) = args.peek() { 58 if next.starts_with("--") { 59 break; 60 } 61 objects.push(PathBuf::from(args.next().unwrap())); 62 } 63 } 64 _ => usage(), 65 } 66 } 67 68 if objects.is_empty() { 69 eprintln!("hotpatch: --objects needs at least one path"); 70 usage(); 71 } 72 73 Args { 74 base: base.unwrap_or_else(|| usage()), 75 objects, 76 aslr_reference: aslr_reference.unwrap_or_else(|| usage()), 77 out_dir: out_dir.unwrap_or_else(|| usage()), 78 } 79} 80 81fn parse_u64(s: &str) -> u64 { 82 if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) { 83 u64::from_str_radix(hex, 16).unwrap_or_else(|e| { 84 eprintln!("hotpatch: bad --aslr-reference {s:?}: {e}"); 85 process::exit(2); 86 }) 87 } else { 88 s.parse().unwrap_or_else(|e| { 89 eprintln!("hotpatch: bad --aslr-reference {s:?}: {e}"); 90 process::exit(2); 91 }) 92 } 93} 94 95/// One entry from the base binary's own (already-linked) symbol table - 96/// `patch.rs`'s `CachedSymbol`, minus the fields only the wasm/macOS/Windows 97/// branches need. 98struct CachedSymbol { 99 address: u64, 100 /// On ELF, a `SymbolKind::Tls` symbol's `address` is its OFFSET into the 101 /// TLS template (`.tdata`/`.tbss`), not a virtual address, and `size` is 102 /// how many of its init bytes to copy out of `.tdata` - both needed only 103 /// by the `Tls` branch of `create_undefined_symbol_stub`. 104 size: u64, 105 kind: SymbolKind, 106 is_undefined: bool, 107 is_weak: bool, 108 flags: SymbolFlags<SectionId, SymbolId>, 109} 110 111/// `object::read`'s `SymbolFlags` is generic over section/symbol INDEX types 112/// that only a few file-format variants actually carry a value in; ELF's 113/// `Elf { st_info, st_other }` doesn't, so it converts freely into 114/// `object::write`'s differently-indexed `SymbolFlags` by re-matching, same 115/// as upstream `patch.rs` does at its cache-build site. 116fn build_symbol_cache(bytes: &[u8]) -> HashMap<String, CachedSymbol> { 117 let obj = object::read::File::parse(bytes).expect("parsing base binary as an object file"); 118 obj.symbols() 119 .filter_map(|s| { 120 let flags = match s.flags() { 121 SymbolFlags::None => SymbolFlags::None, 122 SymbolFlags::Elf { st_info, st_other } => SymbolFlags::Elf { st_info, st_other }, 123 _ => SymbolFlags::None, 124 }; 125 Some(( 126 s.name().ok()?.to_string(), 127 CachedSymbol { 128 address: s.address(), 129 size: s.size(), 130 kind: s.kind(), 131 is_undefined: s.is_undefined(), 132 is_weak: s.is_weak(), 133 flags, 134 }, 135 )) 136 }) 137 .collect() 138} 139 140/// The base binary's `.tdata` section: the init bytes every `SymbolKind::Tls` 141/// symbol's offset/size (`CachedSymbol::address`/`size`) is a slice into. 142/// Absent when the binary has no explicitly-initialized thread-locals (only 143/// `.tbss`, zero-initialized) - every TLS symbol then falls through to the 144/// zero-init branch in `create_undefined_symbol_stub`, which is correct: a 145/// `.tbss`-only variable's initial value IS all zero bytes. 146fn tdata(bytes: &[u8]) -> Vec<u8> { 147 let obj = object::read::File::parse(bytes).expect("parsing base binary as an object file"); 148 obj.section_by_name(".tdata").and_then(|s| s.data().ok()).unwrap_or(&[]).to_vec() 149} 150 151/// Walks every provided `.o`/`.rlib`/`.a`, collecting the union of undefined 152/// and (globally) defined symbol names across all of them - `patch.rs`'s 153/// `collect_stub_symbols_from_path`/`_bytes`. A `.rlib`/`.a` also has its 154/// `.o` members extracted into `extract_dir` and appended to `link_objects`, 155/// so a patch that spans more than the tip crate's own fresh objects still 156/// gets those members linked into the final `.so` (not exercised by 157/// `apps/hotdemo`, which only ever passes plain `.o` files, but the CLI 158/// contract names `.rlib` explicitly for when a dependency crate is part of 159/// the patch too). 160fn collect_symbols( 161 paths: &[PathBuf], 162 extract_dir: &Path, 163) -> (HashSet<String>, HashSet<String>, Vec<PathBuf>) { 164 let mut undefined = HashSet::new(); 165 let mut defined = HashSet::new(); 166 let mut link_objects = Vec::new(); 167 168 for path in paths { 169 let bytes = fs::read(path).unwrap_or_else(|e| { 170 eprintln!("hotpatch: reading {}: {e}", path.display()); 171 process::exit(1); 172 }); 173 174 let is_archive = matches!( 175 path.extension().and_then(|e| e.to_str()), 176 Some("rlib" | "a") 177 ); 178 179 if !is_archive { 180 collect_symbols_from_bytes(&bytes, path, &mut undefined, &mut defined); 181 link_objects.push(path.clone()); 182 continue; 183 } 184 185 let stem = path 186 .file_stem() 187 .map(|s| s.to_string_lossy().into_owned()) 188 .unwrap_or_else(|| "member".to_string()); 189 let mut archive = ar::Archive::new(Cursor::new(bytes)); 190 let mut member_index = 0usize; 191 while let Some(entry) = archive.next_entry() { 192 let mut entry = entry.unwrap_or_else(|e| { 193 eprintln!("hotpatch: reading archive member of {}: {e}", path.display()); 194 process::exit(1); 195 }); 196 let name = std::str::from_utf8(entry.header().identifier()) 197 .unwrap_or_default() 198 .to_string(); 199 if name.ends_with(".rmeta") || !(name.ends_with(".o") || name.ends_with(".obj")) { 200 continue; 201 } 202 203 let mut member_bytes = Vec::with_capacity(entry.header().size() as usize); 204 entry 205 .read_to_end(&mut member_bytes) 206 .unwrap_or_else(|e| panic!("reading archive member {name}: {e}")); 207 collect_symbols_from_bytes(&member_bytes, path, &mut undefined, &mut defined); 208 209 member_index += 1; 210 let out_path = extract_dir.join(format!("{stem}.{member_index}.o")); 211 fs::write(&out_path, &member_bytes) 212 .unwrap_or_else(|e| panic!("writing extracted member {}: {e}", out_path.display())); 213 link_objects.push(out_path); 214 } 215 } 216 217 (undefined, defined, link_objects) 218} 219 220fn collect_symbols_from_bytes( 221 bytes: &[u8], 222 path: &Path, 223 undefined: &mut HashSet<String>, 224 defined: &mut HashSet<String>, 225) { 226 let file = object::read::File::parse(bytes) 227 .unwrap_or_else(|e| panic!("parsing {} as an object file: {e}", path.display())); 228 for symbol in file.symbols() { 229 let Ok(name) = symbol.name() else { continue }; 230 if symbol.is_undefined() { 231 undefined.insert(name.to_string()); 232 } else if symbol.is_global() { 233 defined.insert(name.to_string()); 234 } 235 } 236} 237 238/// `patch.rs`'s `create_undefined_symbol_stub`, Linux/x86_64 only: for every 239/// name left undefined after linking the new objects together, if the BASE 240/// binary defines it, emit a tiny stub that jumps straight to that symbol's 241/// address in the *running* process (`abs_addr = sym.address + aslr_offset`, 242/// where `aslr_offset` is the caller-supplied slide between the base 243/// binary's on-disk `main` and its live one). A name absent from the base 244/// binary too is left genuinely undefined in the stub - `-nodefaultlibs` 245/// doesn't forbid that; the dynamic linker resolves it against the running 246/// process's already-loaded shared libraries at `dlopen` time (libc and 247/// friends), the same as it would for any other `.so`. 248fn create_undefined_symbol_stub( 249 cache: &HashMap<String, CachedSymbol>, 250 tdata: &[u8], 251 undefined_symbols: &[String], 252 aslr_offset: i64, 253) -> Vec<u8> { 254 let mut obj = object::write::Object::new( 255 object::BinaryFormat::Elf, 256 object::Architecture::X86_64, 257 Endianness::Little, 258 ); 259 let text_section = obj.section_id(StandardSection::Text); 260 261 for name in undefined_symbols { 262 let Some(sym) = cache.get(name.as_str()) else { 263 continue; 264 }; 265 if sym.is_undefined { 266 continue; 267 } 268 269 let abs_addr = (sym.address as i64 + aslr_offset) as u64; 270 271 match sym.kind { 272 SymbolKind::Text => { 273 // jmp [rip+0]; <8-byte absolute address> - patch.rs:1083-1091. 274 let mut code = vec![0xFF, 0x25, 0x00, 0x00, 0x00, 0x00]; 275 code.extend_from_slice(&abs_addr.to_le_bytes()); 276 let offset = obj.append_section_data(text_section, &code, 8); 277 obj.add_symbol(Symbol { 278 name: name.as_bytes().to_vec(), 279 value: offset, 280 size: code.len() as u64, 281 scope: SymbolScope::Linkage, 282 kind: SymbolKind::Text, 283 weak: false, 284 section: SymbolSection::Section(text_section), 285 flags: SymbolFlags::None, 286 }); 287 } 288 SymbolKind::Tls => { 289 // patch.rs:1163-1226's ELF branch: `sym.address` is already the 290 // OFFSET into the TLS template (not a virtual address, unlike 291 // every other symbol kind here), so no `aslr_offset` applies. 292 // The new symbol gets its OWN fresh TLS slot in the patch, 293 // seeded with the base binary's init bytes - each patch gets 294 // its own copy, which is why a thread-local resets on a patch 295 // (research/subsecond-patch-build.md §5, same limitation as 296 // apps/hotdemo's `TICKS` static resetting on the tip crate's 297 // own recompile). 298 let start = sym.address as usize; 299 let end = start + sym.size as usize; 300 let init = tdata.get(start..end).map(<[u8]>::to_vec).unwrap_or_else(|| { 301 // Beyond .tdata's bounds: a `.tbss` (zero-initialized) 302 // thread-local, whose initial value genuinely is zero. 303 vec![0u8; sym.size as usize] 304 }); 305 let align = sym.size.max(1).min(8).next_power_of_two(); 306 let tls_section = obj.section_id(StandardSection::Tls); 307 let sym_id = obj.add_symbol(Symbol { 308 name: name.as_bytes().to_vec(), 309 value: 0, 310 size: 0, 311 scope: SymbolScope::Linkage, 312 kind: SymbolKind::Tls, 313 weak: sym.is_weak, 314 section: SymbolSection::Undefined, 315 flags: SymbolFlags::None, 316 }); 317 obj.add_symbol_data(sym_id, tls_section, &init, align); 318 } 319 _ => { 320 // Data, and anything Unknown (patch.rs treats Unknown as 321 // Data too - some platforms report statics that way). 322 let kind = match sym.kind { 323 SymbolKind::Unknown => SymbolKind::Data, 324 k => k, 325 }; 326 obj.add_symbol(Symbol { 327 name: name.as_bytes().to_vec(), 328 value: abs_addr, 329 size: 0, 330 scope: SymbolScope::Linkage, 331 kind, 332 weak: sym.is_weak, 333 section: SymbolSection::Absolute, 334 flags: sym.flags, 335 }); 336 } 337 } 338 } 339 340 obj.write().expect("writing the stub object") 341} 342 343/// `cc -shared` with the same flags `dx`'s thin link uses on the Gnu flavor 344/// (`link.rs:429-436`): a shared object, no default libs (everything not in 345/// the stub or the new objects resolves dynamically at `dlopen` time against 346/// the running process's own already-loaded libraries). 347fn link_patch(objects: &[PathBuf], stub: &Path, out: &Path) { 348 let cc = env::var("CC").unwrap_or_else(|_| "cc".to_string()); 349 let status = Command::new(&cc) 350 .arg("-shared") 351 .arg("-Wl,--eh-frame-hdr") 352 .arg("-Wl,-z,noexecstack") 353 .arg("-Wl,-z,relro,-z,now") 354 .arg("-nodefaultlibs") 355 .arg("-Wl,-Bdynamic") 356 .args(objects) 357 .arg(stub) 358 .arg("-o") 359 .arg(out) 360 .status() 361 .unwrap_or_else(|e| panic!("running the linker ({cc}): {e}")); 362 363 if !status.success() { 364 eprintln!("hotpatch: link failed ({status})"); 365 process::exit(1); 366 } 367} 368 369/// `patch.rs`'s `create_native_jump_table`: every symbol name common to the 370/// base binary's cached table and the freshly-linked patch's own symbol 371/// table gets an entry (old on-disk address -> new on-disk address); 372/// `apply_patch` (running in-process, `subsecond/src/lib.rs:498-547`) rebases 373/// both sides against the live addresses it can observe at apply time, so 374/// this tool never needs to compute a live address for the NEW library. 375fn build_jump_table( 376 cache: &HashMap<String, CachedSymbol>, 377 patch_path: &Path, 378 patch_bytes: &[u8], 379) -> JumpTable { 380 let obj2 = object::read::File::parse(patch_bytes).expect("parsing the freshly linked patch"); 381 let symbol_map = obj2.symbol_map(); 382 let new_name_to_addr: HashMap<&str, u64> = symbol_map 383 .symbols() 384 .iter() 385 .map(|s| (s.name(), s.address())) 386 .collect(); 387 388 let mut map = AddressMap::default(); 389 for (new_name, new_addr) in &new_name_to_addr { 390 if let Some(old) = cache.get(*new_name) { 391 map.insert(old.address, *new_addr); 392 } 393 } 394 395 let new_base_address = *new_name_to_addr.get("main").unwrap_or_else(|| { 396 eprintln!("hotpatch: the patch has no 'main' symbol - built with debuginfo?"); 397 process::exit(1); 398 }); 399 let aslr_reference = cache 400 .get("main") 401 .unwrap_or_else(|| { 402 eprintln!("hotpatch: the base binary has no 'main' symbol"); 403 process::exit(1); 404 }) 405 .address; 406 407 JumpTable { 408 lib: patch_path 409 .canonicalize() 410 .unwrap_or_else(|e| panic!("canonicalizing {}: {e}", patch_path.display())), 411 map, 412 aslr_reference, 413 new_base_address, 414 ifunc_count: 0, 415 } 416} 417 418fn next_patch_number(out_dir: &Path) -> u32 { 419 let mut n = 0; 420 if let Ok(entries) = fs::read_dir(out_dir) { 421 for entry in entries.flatten() { 422 let name = entry.file_name(); 423 let name = name.to_string_lossy(); 424 if let Some(k) = name 425 .strip_prefix("patch-") 426 .and_then(|r| r.strip_suffix(".so")) 427 .and_then(|r| r.parse::<u32>().ok()) 428 { 429 n = n.max(k); 430 } 431 } 432 } 433 n + 1 434} 435 436fn main() { 437 // CLI hygiene (code-rust.md): a piped `| head` shouldn't panic this 438 // process. 439 unsafe { 440 libc::signal(libc::SIGPIPE, libc::SIG_DFL); 441 } 442 443 let args = parse_args(); 444 fs::create_dir_all(&args.out_dir) 445 .unwrap_or_else(|e| panic!("creating {}: {e}", args.out_dir.display())); 446 let extract_dir = args.out_dir.join("objs"); 447 fs::create_dir_all(&extract_dir) 448 .unwrap_or_else(|e| panic!("creating {}: {e}", extract_dir.display())); 449 450 let base_bytes = fs::read(&args.base) 451 .unwrap_or_else(|e| panic!("reading base exe {}: {e}", args.base.display())); 452 let cache = build_symbol_cache(&base_bytes); 453 let tdata = tdata(&base_bytes); 454 let base_main = cache 455 .get("main") 456 .unwrap_or_else(|| { 457 eprintln!( 458 "hotpatch: {} has no 'main' symbol - built with -Cdebuginfo=1 and \ 459 -Wl,--export-dynamic-symbol,main?", 460 args.base.display() 461 ); 462 process::exit(1); 463 }) 464 .address; 465 let aslr_offset = args.aslr_reference as i64 - base_main as i64; 466 467 let (undefined, defined, link_objects) = collect_symbols(&args.objects, &extract_dir); 468 let undefined_symbols: Vec<String> = undefined.difference(&defined).cloned().collect(); 469 470 let stub_bytes = create_undefined_symbol_stub(&cache, &tdata, &undefined_symbols, aslr_offset); 471 472 let n = next_patch_number(&args.out_dir); 473 let stub_path = args.out_dir.join(format!("patch-{n}.stub.o")); 474 fs::write(&stub_path, &stub_bytes) 475 .unwrap_or_else(|e| panic!("writing {}: {e}", stub_path.display())); 476 477 let so_path = args.out_dir.join(format!("patch-{n}.so")); 478 link_patch(&link_objects, &stub_path, &so_path); 479 480 let patch_bytes = fs::read(&so_path) 481 .unwrap_or_else(|e| panic!("reading freshly linked {}: {e}", so_path.display())); 482 let table = build_jump_table(&cache, &so_path, &patch_bytes); 483 484 let json_path = args.out_dir.join(format!("patch-{n}.json")); 485 let json = serde_json::to_vec_pretty(&table).expect("serializing the jump table"); 486 fs::write(&json_path, &json) 487 .unwrap_or_else(|e| panic!("writing {}: {e}", json_path.display())); 488 489 println!( 490 "hotpatch: wrote {} and {} ({} undefined symbol(s) stubbed, {} address(es) mapped)", 491 so_path.display(), 492 json_path.display(), 493 undefined_symbols.len(), 494 table.map.len() 495 ); 496}