Skip to main content

whisker_dev_server/hotpatch/
link_plan.rs

1//! Build the linker invocation for a hot-patch dylib by editing
2//! the captured fat-build linker call (see I4g-X1
3//! `whisker-linker-shim`) as little as possible.
4//!
5//! ## Why we don't construct linker args from scratch
6//!
7//! cargo + rustc + the platform's clang/gcc driver assemble a
8//! large, fragile argv: sysroot, target triple, `-arch`, NDK
9//! search paths, framework directories, OS-version-min flags,
10//! `-Wl,…` directives, sometimes a custom `-fuse-ld=…`. These
11//! shift across:
12//!
13//!   - macOS major versions (sysroot path, `-platform_version`)
14//!   - Xcode releases (`-isysroot`, framework dirs)
15//!   - Android NDK r24/r25/r26 (CRT layout, libc++.a, libunwind.a)
16//!   - glibc CSU layout (crt1.o vs Scrt1.o, libc_nonshared.a)
17//!   - rustc's choice of linker driver (cc, clang, lld)
18//!
19//! Re-deriving any of these is a long-tail of papercuts. So:
20//! capture the fat-build linker invocation verbatim (X1) and edit
21//! only the parts a hot-patch must change. Same principle as
22//! `build_obj_plan` does for rustc.
23//!
24//! ## What we change
25//!
26//!   1. **Drop object/archive inputs** (`.o`, `.rlib`, `.a`,
27//!      `.so`, `.dylib`). The fat build linked the entire
28//!      workspace; the patch only needs the freshly-rebuilt object.
29//!   2. **Drop `-o <path>`** and the existing `-shared` (we
30//!      re-add both).
31//!   3. **Drop `-undefined <action>`** (the separated macOS form)
32//!      so we can deterministically set `dynamic_lookup`.
33//!   4. **Drop `--version-script=<path>` and `--no-undefined-version`.**
34//!      The fat build's version-script enumerates thousands of
35//!      Rust-mangled symbols (rustc auto-generates one for both
36//!      `dylib` and `cdylib`). Re-applying it to a patch dylib
37//!      that only defines the one changed function makes the
38//!      linker emit `version script assignment ... failed:
39//!      symbol not defined` for every absent symbol — fatal under
40//!      `--no-undefined-version`. The patch dylib's default
41//!      visibility (everything global) is the right behaviour:
42//!      `subsecond::apply_patch` reads the patch's `.dynsym`
43//!      looking for the changed function's mangled name.
44//!   5. **Append**:
45//!       - `-shared`
46//!       - OS-specific "unresolved is fine" directive:
47//!           - macOS: `-Wl,-undefined,dynamic_lookup`
48//!           - Linux/Android: `-Wl,--unresolved-symbols=ignore-all`
49//!       - any caller-supplied **extra objects** (typically the
50//!         `stub.o` produced by
51//!         [`crate::hotpatch::create_undefined_symbol_stub`]). The
52//!         stub defines every host symbol the patch refers to as a
53//!         tiny ARM64 trampoline branching to that symbol's
54//!         *runtime* address, computed from the device's reported
55//!         `subsecond::aslr_reference()`. Linking it in this slot
56//!         means the patch has no `DT_NEEDED` back-edge to the
57//!         host, and no dlopen-time symbol resolution to perform —
58//!         which avoids the Android linker-namespace +
59//!         `RTLD_LOCAL` corner cases the previous "back-edge to
60//!         host dylib" scheme tripped over.
61//!       - the new object path
62//!       - `-o <output>`
63//!
64//! Everything else — `-isysroot`, `-arch`, `-target`, `-L`,
65//! `-l`, `-rpath`, `-Wl,…`, `-F`, `-framework`, `-fuse-ld=…`,
66//! `-mmacosx-version-min=…` — is preserved verbatim.
67//!
68//! The "unresolved is fine" directive is the load-bearing trick
69//! that makes hot-patch dylibs small and fast: every symbol the
70//! patch references but doesn't define (e.g. an unmodified
71//! `whisker::println`) is left as an undefined-symbol marker, and
72//! `subsecond::apply_patch` resolves it against the *original*
73//! binary's symbol table at swap-in time. So the patch dylib
74//! holds only the changed function bodies, not their entire
75//! transitive call graph.
76
77use std::path::{Path, PathBuf};
78
79/// Result of [`build_link_plan`].
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct LinkPlan {
82    /// Final argv to pass to the linker driver (cc / clang / etc.).
83    pub args: Vec<String>,
84    /// The path the linker will write — equal to `output` passed in.
85    /// Surfaced separately so the runner can sanity-check existence
86    /// after the spawn returns.
87    pub output: PathBuf,
88}
89
90/// Which OS-specific "unresolved-is-fine" directive to emit.
91/// Android uses the same lld defaults as Linux for our purposes.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum LinkerOs {
94    /// macOS host or iOS Simulator.
95    Macos,
96    /// Linux host or Android device.
97    Linux,
98    /// Windows host. Currently unsupported — we still strip the
99    /// captured args but won't emit a useful directive (PE/COFF
100    /// hot-patch isn't on the I4g roadmap).
101    Other,
102}
103
104/// Re-shape a captured linker invocation into the link step of a
105/// hot-patch build. See module docs for the rationale.
106///
107/// `extra_objects` are additional `.o` paths to link alongside
108/// `new_object`. The typical caller is `thin_rebuild_obj`, which
109/// passes the `stub.o` produced by
110/// [`crate::hotpatch::create_undefined_symbol_stub`] here — that stub
111/// defines every host symbol the patch references as a tiny ARM64
112/// trampoline branching to the symbol's runtime address. After
113/// linking with the stub, the patch dylib has no `DT_NEEDED`
114/// back-edge to the host and no dlopen-time symbol resolution to
115/// perform. See `docs/hot-reload-plan.md` "Option B" for the design.
116pub fn build_link_plan(
117    captured_linker_args: &[String],
118    new_object: &Path,
119    output: &Path,
120    target_os: LinkerOs,
121    extra_objects: &[std::path::PathBuf],
122    extra_exports: &[&str],
123) -> LinkPlan {
124    let mut args = filter_captured_linker_args(captured_linker_args);
125
126    if !args.iter().any(|a| a == "-shared") {
127        args.push("-shared".into());
128    }
129    match target_os {
130        LinkerOs::Macos => {
131            args.push("-Wl,-undefined,dynamic_lookup".into());
132
133            // Caller-supplied exports for the patch dylib's
134            // `.dynsym`. Empty for test fixtures (their thin obj
135            // has none of the `whisker_*` symbols, so naming them
136            // here would fail the link); populated by
137            // `Patcher::build_patch` with
138            // `_whisker_aslr_anchor` + `_whisker_app_main` +
139            // `_whisker_tick`, which `subsecond::apply_patch`
140            // needs in the patch's `.dynsym` (it does
141            // `dlsym(patch, "whisker_aslr_anchor").unwrap()` and
142            // aborts the host app on None).
143            for sym in extra_exports {
144                args.push(format!("-Wl,-exported_symbol,{sym}"));
145            }
146            // If the captured args target the iOS Simulator (or
147            // device), rustc's fat build resolved the SDK sysroot
148            // implicitly through its own driver — that path doesn't
149            // show up in the captured argv. Re-running clang
150            // directly we need `-isysroot <iphonesimulator-sdk>`
151            // or `-liconv` / `-lSystem` / iOS SDK frameworks fail
152            // to resolve. Detect by looking at `-target ...-simulator`
153            // / `-target ...-ios*` in the captured args, then ask
154            // xcrun for the SDK path.
155            if !args.iter().any(|a| a == "-isysroot") {
156                if let Some(sdk_kind) = detect_apple_sdk(&args) {
157                    if let Some(sdk_path) = xcrun_sdk_path(sdk_kind) {
158                        args.push("-isysroot".into());
159                        args.push(sdk_path);
160                    }
161                }
162            }
163        }
164        LinkerOs::Linux => {
165            // Safety net for any symbol that didn't end up in the
166            // stub object (e.g., synthesised compiler intrinsics that
167            // aren't in the host's symbol table). Stubbed symbols
168            // satisfy the linker from `extra_objects` first; this
169            // flag handles the long tail.
170            args.push("-Wl,--unresolved-symbols=ignore-all".into());
171        }
172        LinkerOs::Other => {}
173    }
174
175    for obj in extra_objects {
176        args.push(obj.to_string_lossy().into());
177    }
178    args.push(new_object.to_string_lossy().into());
179    args.push("-o".into());
180    args.push(output.to_string_lossy().into());
181
182    LinkPlan {
183        args,
184        output: output.to_path_buf(),
185    }
186}
187
188/// Look at the captured `-target …` triple and decide which Apple SDK
189/// to ask `xcrun` for. Returns the `--sdk` argument value
190/// (`"iphonesimulator"`, `"iphoneos"`, `"macosx"`) or `None` if the
191/// captured args don't look like an Apple build.
192fn detect_apple_sdk(args: &[String]) -> Option<&'static str> {
193    let mut iter = args.iter();
194    while let Some(a) = iter.next() {
195        if a != "-target" {
196            continue;
197        }
198        let triple = iter.next()?;
199        return Some(if triple.contains("-simulator") {
200            "iphonesimulator"
201        } else if triple.contains("apple-ios") {
202            "iphoneos"
203        } else if triple.contains("apple-darwin") {
204            "macosx"
205        } else {
206            return None;
207        });
208    }
209    None
210}
211
212/// Run `xcrun --sdk <kind> --show-sdk-path` and return the trimmed
213/// stdout. `None` on any kind of failure — caller falls back to
214/// "no -isysroot" which will work for host-macOS builds where
215/// `/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk` is the
216/// default lookup.
217fn xcrun_sdk_path(kind: &str) -> Option<String> {
218    let out = std::process::Command::new("xcrun")
219        .args(["--sdk", kind, "--show-sdk-path"])
220        .output()
221        .ok()?;
222    if !out.status.success() {
223        return None;
224    }
225    let path = String::from_utf8(out.stdout).ok()?.trim().to_string();
226    if path.is_empty() {
227        None
228    } else {
229        Some(path)
230    }
231}
232
233/// Pick the [`LinkerOs`] that matches the host we're building on.
234/// `cfg!`-based — at runtime we know which compiled-in branch ran.
235/// For cross-target hot-patch (e.g. macOS host → Android device),
236/// callers should pass the target OS explicitly rather than rely on
237/// this convenience.
238pub fn linker_os_for_host() -> LinkerOs {
239    if cfg!(target_os = "macos") || cfg!(target_os = "ios") {
240        LinkerOs::Macos
241    } else if cfg!(target_os = "linux") || cfg!(target_os = "android") {
242        LinkerOs::Linux
243    } else {
244        LinkerOs::Other
245    }
246}
247
248/// Strip the captured args of every flag we want to override
249/// deterministically: object/archive inputs, the existing `-o`,
250/// `-shared`, and the separated `-undefined <action>` pair. Other
251/// flags pass through unmodified.
252fn filter_captured_linker_args(args: &[String]) -> Vec<String> {
253    let mut out = Vec::with_capacity(args.len());
254    let mut i = 0;
255    while i < args.len() {
256        let arg = &args[i];
257
258        // -o <path>: drop both.
259        if arg == "-o" && i + 1 < args.len() {
260            i += 2;
261            continue;
262        }
263        // -shared: re-added later.
264        if arg == "-shared" {
265            i += 1;
266            continue;
267        }
268        // -undefined <action>: re-added later in the macOS branch.
269        if arg == "-undefined" && i + 1 < args.len() {
270            i += 2;
271            continue;
272        }
273        // Bare object / archive input.
274        if is_object_or_archive_input(arg) {
275            i += 1;
276            continue;
277        }
278        // Wholesale -Wl,-undefined,dynamic_lookup (the comma form
279        // we'll re-add). Drop the existing one so we don't end up
280        // with two on Macos.
281        if arg.starts_with("-Wl,-undefined,") {
282            i += 1;
283            continue;
284        }
285        // Wholesale -Wl,--unresolved-symbols= (Linux equivalent).
286        if arg.starts_with("-Wl,--unresolved-symbols=") {
287            i += 1;
288            continue;
289        }
290        // Drop fat-build version-scripts — see module docs §4.
291        // Both the `=` form (what rustc + our cargo_build.rs emit)
292        // and the separated `--version-script <path>` form (defensive;
293        // some clang drivers normalize one to the other).
294        if arg.starts_with("-Wl,--version-script=") || arg.starts_with("--version-script=") {
295            i += 1;
296            continue;
297        }
298        if (arg == "-Wl,--version-script" || arg == "--version-script") && i + 1 < args.len() {
299            i += 2;
300            continue;
301        }
302        // Mach-O equivalent: `-Wl,-exported_symbols_list <path>` (or
303        // the combined `,<path>` form). rustc emits this in the fat
304        // build's linker invocation, naming a temp file that lists
305        // every Rust `pub` symbol the full crate graph wanted to
306        // export. The patch link only links a small subset of that
307        // graph (one `.o` + the bridge `.a` + the stub `.o`), so the
308        // file references symbols our inputs don't define and ld
309        // errors out with `Undefined symbols … <initial-undefines>`.
310        // Drop it from the patch link line.
311        //
312        // We deliberately DO keep per-symbol `-Wl,-exported_symbol,…`
313        // directives (which rustc also emits, one per `#[no_mangle]
314        // pub extern "C"` symbol). Those name symbols the user's
315        // crate actually defines — `whisker_aslr_anchor`,
316        // `whisker_app_main`, `whisker_tick`, the bridge entry
317        // points — and `subsecond::apply_patch` needs at least
318        // `whisker_aslr_anchor` to be in the patch dylib's `.dynsym`
319        // so its dlsym lookup hits. Filtering them out would land
320        // us with a patch dylib that loads fine but panics inside
321        // subsecond's symbol lookup.
322        if arg == "-Wl,-exported_symbols_list" && i + 1 < args.len() {
323            i += 2;
324            continue;
325        }
326        if arg.starts_with("-Wl,-exported_symbols_list,") {
327            i += 1;
328            continue;
329        }
330        // --no-undefined-version turns the "version-script lists a
331        // symbol not defined" warning into a hard error. We dropped
332        // the version-script anyway, but a stray --no-undefined-version
333        // is now meaningless and could become a future foot-gun if a
334        // future capture path reintroduces a version-script.
335        if arg == "-Wl,--no-undefined-version" || arg == "--no-undefined-version" {
336            i += 1;
337            continue;
338        }
339        // `-Wl,-dead_strip` (Mach-O) / `-Wl,--gc-sections` (ELF) =
340        // remove unreferenced symbols from the linked image. Combined
341        // with `-Wl,-undefined,dynamic_lookup` and per-symbol
342        // `-exported_symbol` whitelists, this strips every symbol
343        // that isn't transitively reachable from the whitelist roots.
344        //
345        // Fine for the fat build where `_whisker_app_main` keeps the
346        // whole user crate's tree live, but FATAL for sub-crate
347        // patches (#103): the patch dylib's sub-crate `.o` provides
348        // a definition for symbols the user crate's `.o` references,
349        // but `dynamic_lookup` lets ld64 treat those references as
350        // "will be resolved at runtime" without following the local
351        // edge, so dead_strip drops every sub-crate symbol. The
352        // JumpTable then has zero sub-crate entries and the hot patch
353        // visibly does nothing. Drop both forms.
354        if arg == "-Wl,-dead_strip" || arg == "-Wl,--gc-sections" {
355            i += 1;
356            continue;
357        }
358
359        out.push(arg.clone());
360        i += 1;
361    }
362    out
363}
364
365/// Heuristic: a non-flag arg whose extension is a recognised object
366/// or archive format. We deliberately don't treat `-l<name>` or
367/// `-L<dir>` as object inputs (they're flags, hence start with `-`).
368fn is_object_or_archive_input(arg: &str) -> bool {
369    if arg.starts_with('-') {
370        return false;
371    }
372    let ext = Path::new(arg)
373        .extension()
374        .and_then(|e| e.to_str())
375        .map(str::to_ascii_lowercase);
376    matches!(
377        ext.as_deref(),
378        Some("o" | "rlib" | "a" | "so" | "dylib" | "obj" | "lib"),
379    )
380}
381
382// ============================================================================
383// Tests
384// ============================================================================
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    fn s(v: &[&str]) -> Vec<String> {
391        v.iter().map(|s| s.to_string()).collect()
392    }
393
394    // ----- filter_captured_linker_args ---------------------------------
395
396    #[test]
397    fn filter_drops_object_inputs() {
398        let kept = filter_captured_linker_args(&s(&[
399            "-O3",
400            "/tmp/foo.o",
401            "/tmp/bar.rlib",
402            "/tmp/libstd.a",
403            "-l",
404            "iconv",
405        ]));
406        assert_eq!(kept, s(&["-O3", "-l", "iconv"]));
407    }
408
409    #[test]
410    fn filter_drops_dynamic_libraries_too() {
411        // Captured fat-build linker may have re-linked an existing
412        // .so/.dylib; we drop those for the same reason as static
413        // archives — their symbols come back via dynamic_lookup.
414        let kept =
415            filter_captured_linker_args(&s(&["/tmp/libfoo.so", "/tmp/libbar.dylib", "-shared"]));
416        // -shared also dropped (we re-add later).
417        assert!(kept.is_empty(), "expected empty, got {kept:?}");
418    }
419
420    #[test]
421    fn filter_keeps_search_path_and_link_flags() {
422        let kept = filter_captured_linker_args(&s(&[
423            "-L",
424            "/sdk/lib",
425            "-L/different/dir",
426            "-lcurl",
427            "-l",
428            "z",
429            "-Wl,-rpath,/some/path",
430            "-isysroot",
431            "/Applications/Xcode.app/.../MacOSX.sdk",
432            "-arch",
433            "arm64",
434            "-target",
435            "arm64-apple-macosx14.0.0",
436            "-fuse-ld=lld",
437            "-mmacosx-version-min=11.0",
438        ]));
439        assert_eq!(
440            kept,
441            s(&[
442                "-L",
443                "/sdk/lib",
444                "-L/different/dir",
445                "-lcurl",
446                "-l",
447                "z",
448                "-Wl,-rpath,/some/path",
449                "-isysroot",
450                "/Applications/Xcode.app/.../MacOSX.sdk",
451                "-arch",
452                "arm64",
453                "-target",
454                "arm64-apple-macosx14.0.0",
455                "-fuse-ld=lld",
456                "-mmacosx-version-min=11.0",
457            ]),
458        );
459    }
460
461    #[test]
462    fn filter_drops_existing_output_path() {
463        let kept =
464            filter_captured_linker_args(&s(&["-shared", "-o", "/old/libfoo.dylib", "/tmp/foo.o"]));
465        assert!(kept.is_empty(), "got {kept:?}");
466    }
467
468    #[test]
469    fn filter_drops_fat_build_version_scripts_and_no_undefined_version() {
470        // Mirrors the actual capture from a dylib fat build: rustc
471        // emits an enormous version-script enumerating every Rust
472        // symbol it expects exported, plus --no-undefined-version
473        // to harden the check. The patch link only defines the one
474        // changed function, so re-applying these would fail with
475        // "symbol not defined" for every absent symbol.
476        let kept = filter_captured_linker_args(&s(&[
477            "-Wl,--version-script=/tmp/rustcXX/list",
478            "-Wl,--version-script=/ws/target/.whisker/android-jni-exports.ver",
479            "-Wl,--no-undefined-version",
480            "-Wl,--as-needed",
481            "-arch",
482            "arm64",
483        ]));
484        assert_eq!(kept, s(&["-Wl,--as-needed", "-arch", "arm64"]));
485    }
486
487    #[test]
488    fn filter_drops_separated_version_script_form() {
489        // Some clang drivers split `-Wl,--version-script=/p` into
490        // `--version-script /p` when forwarding to ld. Defensive.
491        let kept =
492            filter_captured_linker_args(&s(&["--version-script", "/tmp/rustcXX/list", "-pie"]));
493        assert_eq!(kept, s(&["-pie"]));
494    }
495
496    #[test]
497    fn filter_drops_existing_undefined_dynamic_lookup() {
498        // Both the separated and the comma-bundled form.
499        let kept = filter_captured_linker_args(&s(&[
500            "-undefined",
501            "dynamic_lookup",
502            "-Wl,-undefined,dynamic_lookup",
503            "-Wl,--unresolved-symbols=ignore-all",
504            "-arch",
505            "arm64",
506        ]));
507        assert_eq!(kept, s(&["-arch", "arm64"]));
508    }
509
510    #[test]
511    fn filter_drops_dead_strip_and_gc_sections() {
512        // `-Wl,-dead_strip` (Mach-O) and `-Wl,--gc-sections` (ELF)
513        // would otherwise drop sub-crate symbols whose only inbound
514        // references are satisfied via `-undefined,dynamic_lookup`.
515        let kept = filter_captured_linker_args(&s(&[
516            "-Wl,-dead_strip",
517            "-Wl,--gc-sections",
518            "-arch",
519            "arm64",
520        ]));
521        assert_eq!(kept, s(&["-arch", "arm64"]));
522    }
523
524    #[test]
525    fn filter_keeps_dash_l_with_path_that_starts_with_l() {
526        // Regression: `-llog` is `-l log` (link library named "log"),
527        // not an object file. starts_with('-') already covers this
528        // but make sure.
529        let kept = filter_captured_linker_args(&s(&["-llog", "-lstdc++"]));
530        assert_eq!(kept, s(&["-llog", "-lstdc++"]));
531    }
532
533    #[test]
534    fn filter_keeps_framework_pairs() {
535        // -framework Foundation must keep the bare "Foundation" arg
536        // (it doesn't end in an object extension).
537        let kept = filter_captured_linker_args(&s(&[
538            "-framework",
539            "Foundation",
540            "-framework",
541            "CoreFoundation",
542        ]));
543        assert_eq!(
544            kept,
545            s(&["-framework", "Foundation", "-framework", "CoreFoundation",]),
546        );
547    }
548
549    // ----- is_object_or_archive_input ----------------------------------
550
551    #[test]
552    fn object_detection_covers_common_extensions() {
553        for path in [
554            "foo.o",
555            "foo.rlib",
556            "foo.a",
557            "foo.so",
558            "foo.dylib",
559            "foo.OBJ",
560            "foo.LIB", // case-insensitive (Windows)
561            "/abs/path/lib.a",
562            "rel/dir/foo.o",
563        ] {
564            assert!(is_object_or_archive_input(path), "{path}");
565        }
566    }
567
568    #[test]
569    fn object_detection_rejects_flags_and_non_object_paths() {
570        for path in [
571            "-shared",
572            "-o",
573            "-Llib",
574            "-llog",
575            "/some/source.rs",
576            "Foundation",
577            "foo.txt",
578            "bar",
579        ] {
580            assert!(!is_object_or_archive_input(path), "{path}");
581        }
582    }
583
584    // ----- build_link_plan: macOS --------------------------------------
585
586    #[test]
587    fn macos_plan_appends_shared_dynamic_lookup_object_and_output() {
588        let plan = build_link_plan(
589            &s(&["-isysroot", "/sdk", "-arch", "arm64"]),
590            Path::new("/o/demo.o"),
591            Path::new("/o/libdemo.dylib"),
592            LinkerOs::Macos,
593            &[],
594            &[],
595        );
596        assert_eq!(
597            plan.args,
598            s(&[
599                "-isysroot",
600                "/sdk",
601                "-arch",
602                "arm64",
603                "-shared",
604                "-Wl,-undefined,dynamic_lookup",
605                "/o/demo.o",
606                "-o",
607                "/o/libdemo.dylib",
608            ]),
609        );
610        assert_eq!(plan.output, Path::new("/o/libdemo.dylib"));
611    }
612
613    #[test]
614    fn macos_plan_does_not_double_shared_when_captured_already_had_it() {
615        let plan = build_link_plan(
616            &s(&["-shared", "-isysroot", "/sdk"]),
617            Path::new("/o/demo.o"),
618            Path::new("/o/libdemo.dylib"),
619            LinkerOs::Macos,
620            &[],
621            &[],
622        );
623        let shared_count = plan.args.iter().filter(|a| *a == "-shared").count();
624        assert_eq!(shared_count, 1, "got args: {:?}", plan.args);
625    }
626
627    #[test]
628    fn macos_plan_replaces_old_object_inputs_with_just_the_new_one() {
629        let plan = build_link_plan(
630            &s(&["/old/a.o", "/old/b.o", "/old/libstd.rlib"]),
631            Path::new("/new/demo.o"),
632            Path::new("/new/libdemo.dylib"),
633            LinkerOs::Macos,
634            &[],
635            &[],
636        );
637        // The output path *itself* is .dylib-shaped, so we walk
638        // by index and skip the arg immediately after `-o`.
639        let mut object_args: Vec<&str> = Vec::new();
640        let mut i = 0;
641        while i < plan.args.len() {
642            if plan.args[i] == "-o" {
643                i += 2;
644                continue;
645            }
646            if is_object_or_archive_input(&plan.args[i]) {
647                object_args.push(&plan.args[i]);
648            }
649            i += 1;
650        }
651        assert_eq!(object_args, vec!["/new/demo.o"]);
652    }
653
654    #[test]
655    fn macos_plan_replaces_old_output_with_new_one() {
656        let plan = build_link_plan(
657            &s(&["-o", "/old/libold.dylib"]),
658            Path::new("/new/demo.o"),
659            Path::new("/new/libnew.dylib"),
660            LinkerOs::Macos,
661            &[],
662            &[],
663        );
664        // Find the position of -o and check the next arg is the new output.
665        let dash_o = plan.args.iter().position(|a| a == "-o").unwrap();
666        assert_eq!(
667            plan.args.get(dash_o + 1).map(String::as_str),
668            Some("/new/libnew.dylib")
669        );
670        assert_eq!(plan.args.iter().filter(|a| *a == "-o").count(), 1);
671    }
672
673    // ----- build_link_plan: Linux / Android ----------------------------
674
675    #[test]
676    fn linux_plan_uses_unresolved_symbols_ignore_all_directive() {
677        let plan = build_link_plan(
678            &s(&["-pie", "-L", "/ndk/lib"]),
679            Path::new("/o/demo.o"),
680            Path::new("/o/libdemo.so"),
681            LinkerOs::Linux,
682            &[],
683            &[],
684        );
685        assert_eq!(
686            plan.args,
687            s(&[
688                "-pie",
689                "-L",
690                "/ndk/lib",
691                "-shared",
692                "-Wl,--unresolved-symbols=ignore-all",
693                "/o/demo.o",
694                "-o",
695                "/o/libdemo.so",
696            ]),
697        );
698    }
699
700    #[test]
701    fn linux_plan_drops_pre_existing_unresolved_directive() {
702        // Same flag captured twice (e.g. fat build already had it
703        // from -C link-arg). Make sure we end up with one.
704        let plan = build_link_plan(
705            &s(&["-Wl,--unresolved-symbols=ignore-all", "-L", "/ndk/lib"]),
706            Path::new("/o/demo.o"),
707            Path::new("/o/libdemo.so"),
708            LinkerOs::Linux,
709            &[],
710            &[],
711        );
712        let count = plan
713            .args
714            .iter()
715            .filter(|a| *a == "-Wl,--unresolved-symbols=ignore-all")
716            .count();
717        assert_eq!(count, 1, "args: {:?}", plan.args);
718    }
719
720    #[test]
721    fn linux_plan_appends_extra_objects_before_new_object() {
722        // Extra objects (typically `stub.o` from
723        // `create_undefined_symbol_stub`) land AFTER
724        // `--unresolved-symbols` and BEFORE the new object so the
725        // linker resolves the patch's references against them first.
726        let stub: std::path::PathBuf = "/o/stub.o".into();
727        let plan = build_link_plan(
728            &s(&["-L", "/ndk/lib"]),
729            Path::new("/o/demo.o"),
730            Path::new("/o/libdemo.so"),
731            LinkerOs::Linux,
732            std::slice::from_ref(&stub),
733            &[],
734        );
735        assert_eq!(
736            plan.args,
737            s(&[
738                "-L",
739                "/ndk/lib",
740                "-shared",
741                "-Wl,--unresolved-symbols=ignore-all",
742                "/o/stub.o",
743                "/o/demo.o",
744                "-o",
745                "/o/libdemo.so",
746            ]),
747        );
748    }
749
750    #[test]
751    fn linux_plan_with_empty_extras_links_only_the_new_object() {
752        let plan = build_link_plan(
753            &s(&["-L", "/ndk/lib"]),
754            Path::new("/o/demo.o"),
755            Path::new("/o/libdemo.so"),
756            LinkerOs::Linux,
757            &[],
758            &[],
759        );
760        // Just `-Wl,--unresolved-symbols=ignore-all` + new object + -o
761        // -shared + the captured arg `-L /ndk/lib`. No DT_NEEDED, no
762        // back-edge to a host dylib.
763        assert!(
764            !plan.args.iter().any(|a| a.contains("--no-as-needed")
765                || a.ends_with(".so") && a != "/o/libdemo.so"),
766            "no host-dylib back-edge expected: {:?}",
767            plan.args,
768        );
769    }
770
771    #[test]
772    fn macos_plan_also_threads_extra_objects() {
773        // Same shape on macOS — stub objects are platform-portable
774        // (we generate Mach-O bytes there).
775        let stub: std::path::PathBuf = "/o/stub.o".into();
776        let plan = build_link_plan(
777            &s(&["-isysroot", "/sdk"]),
778            Path::new("/o/demo.o"),
779            Path::new("/o/libdemo.dylib"),
780            LinkerOs::Macos,
781            std::slice::from_ref(&stub),
782            &[],
783        );
784        assert!(
785            plan.args.iter().any(|a| a == "/o/stub.o"),
786            "stub object should appear on macOS plan: {:?}",
787            plan.args,
788        );
789    }
790
791    // ----- build_link_plan: Other --------------------------------------
792
793    #[test]
794    fn other_os_plan_omits_unresolved_directive() {
795        let plan = build_link_plan(
796            &s(&["-machine:x64"]),
797            Path::new("/o/demo.obj"),
798            Path::new("/o/demo.dll"),
799            LinkerOs::Other,
800            &[],
801            &[],
802        );
803        // No -Wl directive of any kind.
804        assert!(
805            !plan.args.iter().any(|a| a.starts_with("-Wl,")),
806            "args: {:?}",
807            plan.args,
808        );
809        // Still gets -shared, the new object, and -o.
810        assert!(plan.args.contains(&"-shared".into()));
811        assert!(plan.args.iter().any(|a| a.ends_with("demo.obj")));
812    }
813
814    // ----- linker_os_for_host ------------------------------------------
815
816    #[test]
817    fn linker_os_for_host_picks_an_os_consistent_with_cfg() {
818        let os = linker_os_for_host();
819        if cfg!(target_os = "macos") || cfg!(target_os = "ios") {
820            assert_eq!(os, LinkerOs::Macos);
821        } else if cfg!(target_os = "linux") || cfg!(target_os = "android") {
822            assert_eq!(os, LinkerOs::Linux);
823        } else {
824            assert_eq!(os, LinkerOs::Other);
825        }
826    }
827}