Skip to main content

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}