whisker_dev_server/hotpatch/patcher.rs
1//! `Patcher` — the integrator. Turns a [`crate::Change`] into a
2//! [`subsecond_types::JumpTable`] (wrapped in [`PatchPlan`]) by
3//! stitching together the pieces from I4g-1 through I4g-X2:
4//!
5//! - captured rustc args + linker args from the fat build
6//! (`wrapper`, `whisker-rustc-shim`, `whisker-linker-shim`)
7//! - rustc `--emit=obj` + own linker invoke (`thin_build`,
8//! `link_plan`, `runner::thin_rebuild_obj`)
9//! - parse the resulting patch dylib (`symbol_table`)
10//! - diff against the cached original (`HotpatchModuleCache` +
11//! `build_jump_table`)
12//!
13//! Two constructors:
14//!
15//! - [`Patcher::new`] takes already-loaded state. Tests use this
16//! to build the captured maps and the original-binary cache by
17//! hand, so they never need to actually run a real fat build.
18//! - [`Patcher::initialize`] is the production path: spawn a fat
19//! build with both shims active, load both captures, parse the
20//! original binary, then call `new`.
21
22use anyhow::{Context, Result};
23use std::collections::HashMap;
24use std::path::{Path, PathBuf};
25use std::sync::Mutex;
26
27use super::{
28 build_jump_table, build_link_plan, load_captured_args, load_captured_linker_args,
29 parse_symbol_table, run_link_plan, run_obj_plan, thin_build, validate_environment,
30 CapturedLinkerInvocation, CapturedRustcInvocation, HotpatchModuleCache, LinkerOs, PatchPlan,
31};
32
33/// Single-slot in-session cache of the stub `.o` we synthesize for the
34/// patch dylib. Most edits only change a function *body*; the set of
35/// undefined symbols the resulting `.o` references doesn't move, and
36/// the device's `aslr_reference` is fixed for the session — so the
37/// stub bytes are identical to the previous patch's. Reusing them
38/// saves the per-patch object-builder pass.
39struct StubCache {
40 /// FNV-1a hash of the sorted `needed` symbol list. Cheap, and
41 /// good enough for an "is this the same set?" check — collisions
42 /// would just mean rebuilding the stub once.
43 needed_hash: u64,
44 aslr_reference: u64,
45 target_os: LinkerOs,
46 bytes: Vec<u8>,
47}
48
49pub struct Patcher {
50 package: String,
51 rustc_path: PathBuf,
52 linker_path: PathBuf,
53 cwd: PathBuf,
54 patch_out_dir: PathBuf,
55 target_os: LinkerOs,
56 original_cache: HotpatchModuleCache,
57 captured_rustc_args: HashMap<String, CapturedRustcInvocation>,
58 captured_linker_args: HashMap<String, CapturedLinkerInvocation>,
59 stub_cache: Mutex<Option<StubCache>>,
60}
61
62impl Patcher {
63 /// Direct constructor. Tests use this to inject hand-built
64 /// state (so they don't have to run a real `cargo build` or
65 /// touch the workspace).
66 #[allow(clippy::too_many_arguments)]
67 pub fn new(
68 package: String,
69 rustc_path: PathBuf,
70 linker_path: PathBuf,
71 cwd: PathBuf,
72 patch_out_dir: PathBuf,
73 target_os: LinkerOs,
74 original_cache: HotpatchModuleCache,
75 captured_rustc_args: HashMap<String, CapturedRustcInvocation>,
76 captured_linker_args: HashMap<String, CapturedLinkerInvocation>,
77 ) -> Self {
78 Self {
79 package,
80 rustc_path,
81 linker_path,
82 cwd,
83 patch_out_dir,
84 target_os,
85 original_cache,
86 captured_rustc_args,
87 captured_linker_args,
88 stub_cache: Mutex::new(None),
89 }
90 }
91
92 /// Production setup. **Fat build already done** — the dev loop
93 /// runs it through Builder::with_capture, so this constructor
94 /// only needs to read the resulting caches and parse the
95 /// original binary. Splitting the build out lets the dev loop
96 /// reuse its existing initial-build phase rather than spawning
97 /// cargo a second time.
98 ///
99 /// `original_binary` is the file the device actually loaded —
100 /// for Android that's `lib<crate>.so` extracted from the APK or
101 /// found under the Gradle-built jniLibs tree.
102 #[allow(clippy::too_many_arguments)]
103 pub fn initialize(
104 workspace_root: &Path,
105 package: String,
106 rustc_cache_dir: &Path,
107 linker_cache_dir: &Path,
108 real_linker: &Path,
109 original_binary: &Path,
110 target_os: LinkerOs,
111 target_triple: Option<&str>,
112 ) -> Result<Self> {
113 let captured_rustc_args = load_captured_args(rustc_cache_dir, target_triple)
114 .with_context(|| format!("load rustc cache {}", rustc_cache_dir.display()))?;
115 let captured_linker_args = load_captured_linker_args(linker_cache_dir)
116 .with_context(|| format!("load linker cache {}", linker_cache_dir.display()))?;
117 let original_cache = HotpatchModuleCache::from_path(original_binary)
118 .with_context(|| format!("parse original binary {}", original_binary.display()))?;
119 let patch_out_dir = workspace_root.join("target/.whisker/patches");
120 let rustc_path = current_rustc();
121 Ok(Self::new(
122 package,
123 rustc_path,
124 real_linker.to_path_buf(),
125 workspace_root.to_path_buf(),
126 patch_out_dir,
127 target_os,
128 original_cache,
129 captured_rustc_args,
130 captured_linker_args,
131 ))
132 }
133
134 /// Build a single hot-patch from a change. Returns the diff
135 /// alongside the JumpTable so the dev loop can log warnings
136 /// (added / removed / weak symbols).
137 ///
138 /// `aslr_reference` is the runtime address of `main` reported by
139 /// the connected device through the `hello` WebSocket handshake.
140 /// We compute the ASLR slide as
141 /// `aslr_reference - cache.aslr_reference` and bake the result
142 /// into a small stub object that resolves every host symbol the
143 /// patch references — see `stub_object` for the rationale. Pass
144 /// `0` for cases where no device has connected yet (the patch
145 /// will still build but won't dispatch correctly at runtime; the
146 /// caller should refrain from sending it in that state).
147 ///
148 /// `crate_key` is the **rustc-form** name of the crate that owns
149 /// the change. `None` defaults to the user crate. Sub-crate
150 /// patches (#103) pass the changed crate's name so the thin
151 /// build picks up that crate's captured rustc args (a fresh `.o`
152 /// of the changed sub-crate), then links it into a patch dylib
153 /// using the user crate's linker invocation as template. The
154 /// original user dylib already exports the sub-crate's symbols
155 /// (rustc linked its rlib in fat-build time); subsecond's
156 /// JumpTable redirects them onto the patch dylib's new bodies.
157 pub async fn build_patch(
158 &self,
159 aslr_reference: u64,
160 crate_key: Option<&str>,
161 ) -> Result<PatchPlan> {
162 let user_key = self.package.replace('-', "_");
163 let crate_key = crate_key
164 .map(str::to_owned)
165 .unwrap_or_else(|| user_key.clone());
166 let captured_rustc = self.captured_rustc_args.get(&crate_key).with_context(|| {
167 format!(
168 "no captured rustc invocation for crate `{crate_key}`; \
169 was the fat build run?",
170 )
171 })?;
172 // When patching a sub-crate, also compile the user crate
173 // alongside it. The user crate carries the
174 // `whisker_aslr_anchor` / `whisker_app_main` / `whisker_tick`
175 // symbols (emitted by `#[whisker::main]`) — without them,
176 // `subsecond::apply_patch`'s `dlsym(patch, "whisker_aslr_anchor")`
177 // returns NULL and the runtime computes a junk slide that
178 // SIGBUSes on the next call into a patched function. Linking
179 // the user crate's `.o` puts those symbols into the patch
180 // dylib's `.dynsym` so subsecond's math works.
181 let user_rustc = if crate_key != user_key {
182 Some(self.captured_rustc_args.get(&user_key).with_context(|| {
183 format!(
184 "sub-crate patch ({crate_key}) needs user crate `{user_key}` in the link \
185 (for the `whisker_aslr_anchor` symbol), but no captured rustc invocation \
186 is available for it",
187 )
188 })?)
189 } else {
190 None
191 };
192
193 // Linker capture is keyed by output basename. The fat build's
194 // crate-type is whatever cargo chose (typically `cdylib` for
195 // a Whisker user crate, sometimes `bin` + dylib for examples).
196 // Try the most-likely names in order.
197 let captured_linker = self.lookup_captured_linker().with_context(|| {
198 format!(
199 "no captured linker invocation for `{}`; was the fat build run with linker capture?",
200 self.package,
201 )
202 })?;
203
204 validate_environment(captured_rustc, &self.rustc_path)
205 .context("environment validation before thin rebuild")?;
206
207 // Stage 1: rustc the changed crate to a `.o` file.
208 let obj_plan = thin_build::build_obj_plan(captured_rustc, &self.patch_out_dir);
209 let object = run_obj_plan(&obj_plan, &self.rustc_path, &self.cwd)
210 .await
211 .context("rustc --emit=obj for thin patch")?;
212
213 // Stage 1b: for sub-crate patches, also rebuild the user
214 // crate's `.o` so the patch dylib carries the anchor symbols
215 // subsecond needs (see Stage 1's `user_rustc` doc above).
216 // Skipped on user-crate patches because `object` already
217 // covers it.
218 let user_object_for_link: Option<PathBuf> = if let Some(user_rustc) = user_rustc {
219 let user_obj_plan = thin_build::build_obj_plan(user_rustc, &self.patch_out_dir);
220 let obj = run_obj_plan(&user_obj_plan, &self.rustc_path, &self.cwd)
221 .await
222 .context("rustc --emit=obj for user crate (sub-crate patch anchor source)")?;
223 Some(obj)
224 } else {
225 None
226 };
227
228 // Stage 2: synthesize a stub `.o` that maps every host symbol
229 // the patch refers to onto its live runtime address. The stub
230 // path lives next to the rebuilt object so cleanup is "delete
231 // the patch_out_dir" and we don't have to track it separately.
232 //
233 // `aslr_reference == 0` is the "no device reported its base
234 // yet" / test-fixture path. In that case the host's
235 // `dynamic_lookup` (macOS) or `--unresolved-symbols=ignore-all`
236 // (Linux) satisfies the patch's references against the
237 // already-loaded test process — same as before Option B. Real
238 // device dispatch always goes through the stub branch since
239 // `lib.rs::run` skips Tier 1 entirely when no aslr_reference
240 // has been reported.
241 let extras: Vec<PathBuf> = if aslr_reference == 0 {
242 // Test-fixture path: even without a stub we still need to
243 // pass the user crate's `.o` for sub-crate patches so
244 // the anchor symbol exists.
245 user_object_for_link.iter().cloned().collect()
246 } else {
247 let stub_path = self.patch_out_dir.join("aslr-stub.o");
248 // Feed BOTH input objects into the UND-symbol scan when
249 // building the stub. The sub-crate `.o` references
250 // whisker / kit symbols; the user crate `.o` references
251 // sub-crate symbols + framework symbols. Missing the
252 // latter set would leave UNDs unresolved at link time.
253 let stub_bytes = self
254 .stub_bytes_for_objects(&object, user_object_for_link.as_deref(), aslr_reference)
255 .context("synthesize stub object")?;
256 std::fs::write(&stub_path, &stub_bytes)
257 .with_context(|| format!("write stub object to {}", stub_path.display()))?;
258 let mut e = vec![stub_path];
259 // Pull in the user crate's `.o` alongside the sub-crate's
260 // (no-op for user-crate patches where this is None).
261 if let Some(uo) = user_object_for_link.as_ref() {
262 e.push(uo.clone());
263 }
264 // Belt-and-suspenders on Linux/Android: the stub is
265 // Text-only and emits weak symbols, so non-Text host
266 // refs (thread-locals, static OnceCells like
267 // `whisker_runtime::signal::ARENA`, `__data_start` style
268 // markers) and any Text whose name didn't survive
269 // `.llvm.X` ThinLTO normalization still need a fallback.
270 // Linking the host `.so` here adds a `DT_NEEDED` entry,
271 // so the Android dynamic linker fills them in at
272 // `dlopen` time — but only when the stub couldn't (the
273 // weak Text stubs lose to strong host defs, which is
274 // what we want for `_Unwind_Resume`, `whisker_bridge_*`,
275 // etc.).
276 if matches!(self.target_os, LinkerOs::Linux) {
277 e.push(self.original_cache.lib.clone());
278 }
279 e
280 };
281
282 // Stage 3: link the `.o` (+ optional stub `.o`) into a patch dylib.
283 let output_dylib = self.expected_patch_path();
284 // Required exports for the patch dylib's `.dynsym`. See
285 // `build_link_plan` doc for the `whisker_aslr_anchor`
286 // rationale (subsecond panics on `dlsym(patch, ...).unwrap()`
287 // if it's missing). Only meaningful on Mach-O; the Linux
288 // / Android branch of build_link_plan ignores
289 // `extra_exports`.
290 let extra_exports: &[&str] = match self.target_os {
291 LinkerOs::Macos => &["_whisker_aslr_anchor", "_whisker_app_main", "_whisker_tick"],
292 _ => &[],
293 };
294 let link_plan = build_link_plan(
295 &captured_linker.args,
296 &object,
297 &output_dylib,
298 self.target_os,
299 &extras,
300 extra_exports,
301 );
302 let new_dylib = run_link_plan(&link_plan, &self.linker_path, &self.cwd)
303 .await
304 .context("link patch dylib (object + stub)")?;
305
306 let new_symbols = parse_symbol_table(&new_dylib)
307 .with_context(|| format!("parse {}", new_dylib.display()))?;
308 let new_base_address = read_image_base(&new_dylib)?;
309
310 Ok(build_jump_table(
311 &self.original_cache.symbols,
312 &new_symbols,
313 new_dylib,
314 self.original_cache.aslr_reference,
315 new_base_address,
316 ))
317 }
318
319 /// Return the stub object bytes for the link inputs +
320 /// `aslr_reference`. Reuses an in-session cached copy when the
321 /// patch's UND symbol set matches the previous build's and
322 /// `aslr_reference` / `target_os` are unchanged — the common case
323 /// when an edit only touches a function body.
324 ///
325 /// Pass `extra` for sub-crate patches (#103): the union of UND
326 /// symbols across both objects, minus their union of defined,
327 /// gives the right "still unresolved" set.
328 fn stub_bytes_for_objects(
329 &self,
330 object: &Path,
331 extra: Option<&Path>,
332 aslr_reference: u64,
333 ) -> Result<Vec<u8>> {
334 let mut paths: Vec<&Path> = vec![object];
335 if let Some(p) = extra {
336 paths.push(p);
337 }
338 let needed =
339 super::compute_needed_symbols_multi(&paths).context("compute_needed_symbols_multi")?;
340 let needed_hash = hash_needed(&needed);
341 if let Ok(guard) = self.stub_cache.lock() {
342 if let Some(cached) = guard.as_ref() {
343 if cached.needed_hash == needed_hash
344 && cached.aslr_reference == aslr_reference
345 && cached.target_os == self.target_os
346 {
347 return Ok(cached.bytes.clone());
348 }
349 }
350 }
351 let bytes = super::build_stub_for_needed(
352 &needed,
353 &self.original_cache,
354 self.target_os,
355 aslr_reference,
356 )
357 .context("build_stub_for_needed")?;
358 if let Ok(mut guard) = self.stub_cache.lock() {
359 *guard = Some(StubCache {
360 needed_hash,
361 aslr_reference,
362 target_os: self.target_os,
363 bytes: bytes.clone(),
364 });
365 }
366 Ok(bytes)
367 }
368
369 /// Where this Patcher would put the next patch dylib —
370 /// `<patch_out_dir>/lib<crate>.{so,dylib,dll}`. The filename is
371 /// chosen for the *target* OS (e.g. Android's `.so` even when the
372 /// dev session runs on macOS) so the on-device runtime can
373 /// recognise it.
374 pub fn expected_patch_path(&self) -> PathBuf {
375 self.patch_out_dir.join(thin_build::library_filename_for_os(
376 &self.package,
377 self.target_os,
378 ))
379 }
380
381 /// Resolve the captured linker invocation that produced this
382 /// crate's library. The key is the basename of the captured
383 /// `-o`; for a typical cargo build the file is something like
384 /// `lib<crate>-<hash>.dylib`, so we match by the `lib<crate>`
385 /// prefix and the right extension. If multiple match (e.g.
386 /// rebuilds across cargo cache states), the most-recent
387 /// timestamp wins.
388 fn lookup_captured_linker(&self) -> Option<&CapturedLinkerInvocation> {
389 let stem_lib = format!("lib{}", self.package.replace('-', "_"));
390 let stem_bin = self.package.replace('-', "_");
391 let exts: &[&str] = match self.target_os {
392 LinkerOs::Macos => &[".dylib"],
393 LinkerOs::Linux => &[".so"],
394 LinkerOs::Other => &[".dll"],
395 };
396 let mut best: Option<&CapturedLinkerInvocation> = None;
397 for inv in self.captured_linker_args.values() {
398 let Some(out) = inv.output.as_deref() else {
399 continue;
400 };
401 let Some(name) = Path::new(out).file_name().and_then(|n| n.to_str()) else {
402 continue;
403 };
404 let matches_ext = exts.iter().any(|ext| name.ends_with(ext));
405 if !matches_ext {
406 continue;
407 }
408 // `lib<crate>` (Unix shared) or `<crate>` (Windows DLL or
409 // Apple bin output) — both are valid stems for the user
410 // crate's link output.
411 let matches_stem = name.starts_with(&stem_lib) || name.starts_with(&stem_bin);
412 if !matches_stem {
413 continue;
414 }
415 best = match best {
416 Some(prev) if prev.timestamp_micros >= inv.timestamp_micros => Some(prev),
417 _ => Some(inv),
418 };
419 }
420 best
421 }
422}
423
424/// Current rustc (matches cargo's default resolution): `RUSTC` env
425/// wins, otherwise `rustc` on PATH.
426fn current_rustc() -> PathBuf {
427 PathBuf::from(std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into()))
428}
429
430/// Hash a sorted symbol-name list into a u64 for the stub cache key.
431/// FNV-1a — small, fast, and we only need "did this set change?"
432/// granularity (a hash collision just means we rebuild the stub
433/// once, no correctness impact).
434fn hash_needed(needed: &[String]) -> u64 {
435 const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
436 const FNV_PRIME: u64 = 0x0000_0100_0000_01B3;
437 let mut h = FNV_OFFSET;
438 for name in needed {
439 for b in name.as_bytes() {
440 h ^= *b as u64;
441 h = h.wrapping_mul(FNV_PRIME);
442 }
443 // Separator so ["ab","c"] and ["a","bc"] hash differently.
444 h ^= 0xff;
445 h = h.wrapping_mul(FNV_PRIME);
446 }
447 h
448}
449
450/// Return the static virtual address of `whisker_aslr_anchor` in
451/// `path` (Mach-O's underscore-prefixed `_whisker_aslr_anchor` also
452/// accepted). This goes into `JumpTable::new_base_address`; our
453/// vendored subsecond's `apply_patch` then computes
454///
455/// ```ignore
456/// new_offset = dlsym(patch, "whisker_aslr_anchor") // runtime
457/// - table.new_base_address // static
458/// = patch image base.
459/// ```
460///
461/// Using `relative_address_base()` here (always 0 for an ELF PIE
462/// dylib) sent `new_offset = patch_runtime_anchor`, leaving the
463/// JumpTable's values shifted by the runtime address of the anchor
464/// rather than by the image base — every patched function would land
465/// somewhere meaningless. Symmetric to the host-side anchor lookup
466/// in [`crate::hotpatch::cache::HotpatchModuleCache::from_path`].
467fn read_image_base(path: &Path) -> Result<u64> {
468 let table = parse_symbol_table(path).with_context(|| format!("parse {}", path.display()))?;
469 // Same fallback semantics as the host cache: 0 when the anchor
470 // symbol is absent. Lets test fixtures that don't carry
471 // `#[whisker::main]` still build a patch plan; only the runtime
472 // `apply_patch` math gets skewed.
473 Ok(table
474 .by_name
475 .get("whisker_aslr_anchor")
476 .or_else(|| table.by_name.get("_whisker_aslr_anchor"))
477 .map(|s| s.address)
478 .unwrap_or(0))
479}
480
481// ============================================================================
482// Tests
483// ============================================================================
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488 use crate::hotpatch::SymbolTable;
489
490 fn empty_cache() -> HotpatchModuleCache {
491 HotpatchModuleCache {
492 lib: PathBuf::from("/orig.dylib"),
493 symbols: SymbolTable::default(),
494 aslr_reference: 0x1_0000_0000,
495 }
496 }
497
498 fn linker_inv(output: &str, ts: u128) -> CapturedLinkerInvocation {
499 CapturedLinkerInvocation {
500 output: Some(output.into()),
501 args: vec!["-shared".into()],
502 timestamp_micros: ts,
503 }
504 }
505
506 #[test]
507 fn new_holds_onto_its_inputs() {
508 let p = Patcher::new(
509 "demo".into(),
510 PathBuf::from("/usr/local/bin/rustc"),
511 PathBuf::from("/usr/bin/clang"),
512 PathBuf::from("/tmp/cwd"),
513 PathBuf::from("/tmp/patches"),
514 LinkerOs::Macos,
515 empty_cache(),
516 HashMap::new(),
517 HashMap::new(),
518 );
519 assert_eq!(p.package, "demo");
520 assert_eq!(
521 p.expected_patch_path(),
522 PathBuf::from("/tmp/patches")
523 .join(thin_build::library_filename_for_os("demo", LinkerOs::Macos,)),
524 );
525 }
526
527 // ----- lookup_captured_linker --------------------------------------
528
529 fn patcher_with_linker_map(
530 target_os: LinkerOs,
531 package: &str,
532 linker: HashMap<String, CapturedLinkerInvocation>,
533 ) -> Patcher {
534 Patcher::new(
535 package.into(),
536 "/rustc".into(),
537 "/cc".into(),
538 "/cwd".into(),
539 "/patches".into(),
540 target_os,
541 empty_cache(),
542 HashMap::new(),
543 linker,
544 )
545 }
546
547 #[test]
548 fn lookup_finds_macos_dylib_with_lib_prefix() {
549 let mut m = HashMap::new();
550 m.insert(
551 "libdemo-abc123.dylib".into(),
552 linker_inv("/cargo/target/debug/deps/libdemo-abc123.dylib", 100),
553 );
554 let p = patcher_with_linker_map(LinkerOs::Macos, "demo", m);
555 let inv = p.lookup_captured_linker().expect("found");
556 assert_eq!(inv.timestamp_micros, 100);
557 }
558
559 #[test]
560 fn lookup_finds_linux_so_with_underscored_crate_name() {
561 let mut m = HashMap::new();
562 m.insert(
563 "libhello_world.so".into(),
564 linker_inv("/cargo/target/debug/deps/libhello_world.so", 50),
565 );
566 let p = patcher_with_linker_map(LinkerOs::Linux, "hello-world", m);
567 let inv = p.lookup_captured_linker().expect("found");
568 assert_eq!(inv.timestamp_micros, 50);
569 }
570
571 #[test]
572 fn lookup_returns_most_recent_when_multiple_match() {
573 let mut m = HashMap::new();
574 m.insert(
575 "libdemo.dylib".into(),
576 linker_inv("/path/libdemo.dylib", 100),
577 );
578 m.insert(
579 "libdemo-abc.dylib".into(),
580 linker_inv("/path/libdemo-abc.dylib", 200),
581 );
582 let p = patcher_with_linker_map(LinkerOs::Macos, "demo", m);
583 let inv = p.lookup_captured_linker().expect("found");
584 assert_eq!(inv.timestamp_micros, 200);
585 }
586
587 #[test]
588 fn lookup_returns_none_when_no_extension_matches() {
589 let mut m = HashMap::new();
590 m.insert("libdemo.so".into(), linker_inv("/path/libdemo.so", 100));
591 // Looking for macOS .dylib in a map of .so → no match.
592 let p = patcher_with_linker_map(LinkerOs::Macos, "demo", m);
593 assert!(p.lookup_captured_linker().is_none());
594 }
595
596 #[test]
597 fn lookup_returns_none_when_crate_name_doesnt_match() {
598 let mut m = HashMap::new();
599 m.insert(
600 "libother.dylib".into(),
601 linker_inv("/path/libother.dylib", 100),
602 );
603 let p = patcher_with_linker_map(LinkerOs::Macos, "demo", m);
604 assert!(p.lookup_captured_linker().is_none());
605 }
606
607 #[tokio::test]
608 async fn build_patch_errors_when_captured_rustc_args_missing() {
609 let p = Patcher::new(
610 "package-not-in-cache".into(),
611 "/rustc".into(),
612 "/cc".into(),
613 "/cwd".into(),
614 "/patches".into(),
615 LinkerOs::Macos,
616 empty_cache(),
617 HashMap::new(), // empty rustc map
618 HashMap::new(),
619 );
620 // aslr_reference value is irrelevant for this error path —
621 // build_patch bails before touching it.
622 let err = p.build_patch(0, None).await.unwrap_err();
623 let msg = format!("{err:#}");
624 assert!(msg.contains("no captured rustc invocation"), "{msg}");
625 }
626
627 #[tokio::test]
628 async fn build_patch_errors_when_explicit_crate_key_missing() {
629 let p = Patcher::new(
630 "demo".into(),
631 "/rustc".into(),
632 "/cc".into(),
633 "/cwd".into(),
634 "/patches".into(),
635 LinkerOs::Macos,
636 empty_cache(),
637 HashMap::new(),
638 HashMap::new(),
639 );
640 let err = p.build_patch(0, Some("not_a_crate")).await.unwrap_err();
641 let msg = format!("{err:#}");
642 assert!(msg.contains("not_a_crate"), "{msg}");
643 }
644}