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}