Skip to main content

zlayer_toolchain/
relocate.rs

1//! Toolchain relocatability engine: published toolchains must install at ANY
2//! prefix.
3//!
4//! Artifacts bundle the ACTUAL toolchain per-tool with no path coupling. On
5//! macOS the enemy is absolute paths baked into Mach-O load commands; the
6//! mechanism is `@loader_path` — baked into the load commands themselves and
7//! resolved by dyld at image-load time (NOT a `DYLD_*` environment variable,
8//! so it survives sandboxed execution).
9//!
10//! # Rewrite scheme (one consistent scheme, no `@rpath` juggling)
11//!
12//! Every rewrite is a **direct `@loader_path`-relative path**:
13//!
14//! - A consumer's `LC_LOAD_DYLIB` pointing at a dylib inside the toolchain is
15//!   rewritten to `@loader_path/<relative hop>` computed from the consumer's
16//!   own directory to the dylib's real location (e.g. a `bin/` tool loading
17//!   `lib/libfoo.dylib` becomes `@loader_path/../lib/libfoo.dylib`).
18//! - A load command pointing into a **dependency** toolchain causes that
19//!   dylib (and its transitive non-system dylib closure) to be **copied into
20//!   `<dir>/lib/`**, and the load command is rewritten to the
21//!   `@loader_path`-relative hop to the bundled copy.
22//! - `LC_ID_DYLIB` of any toolchain/bundled dylib becomes
23//!   `@loader_path/<name>` (the ID only matters for future static linking;
24//!   consumers carry direct paths).
25//! - `LC_RPATH` entries pointing at the built prefix or a dependency prefix
26//!   are rewritten to the `@loader_path`-relative hop to `<dir>/lib` (extra
27//!   duplicates are deleted) so no absolute-prefix bytes survive in the load
28//!   commands region.
29//!
30//! Builds inject `LDFLAGS=-Wl,-headerpad_max_install_names`, so
31//! `install_name_tool` always has header room; every modified Mach-O is
32//! ad-hoc re-signed (`codesign -f -s -`), which arm64 requires.
33//!
34//! # Publisher / consumer contract
35//!
36//! [`make_relocatable`] runs on the publisher: it rewrites Mach-O load
37//! commands **in the live tree** (safe — `@loader_path` resolves at any prefix)
38//! and REPORTS, without modifying, the text files that carry the built prefix
39//! ([`RelocationReport::text_files_with_prefix`]) plus any absolute-prefix bytes
40//! remaining in binaries ([`RelocationReport::residue`] — embedded data strings
41//! such as compiled-in `libexec` paths). The live tree deliberately keeps REAL
42//! absolute prefixes in its text files (`.pc`, cmake configs, scripts) so a
43//! same-machine dependent build — which references this toolchain by its fixed
44//! on-disk path — still resolves it; placeholdering happens ONLY when packing
45//! the published artifact, via [`apply_text_placeholders`] on a COPY.
46//! [`relocate_pulled`] runs on the consumer: it restores text placeholders to
47//! the local prefix and, for artifacts the publisher could NOT prove fully
48//! relocatable, null-pad patches the recorded prefix inside binaries (only
49//! valid when the local prefix is no longer than the recorded one).
50//!
51//! Host tools used (`otool`, `install_name_tool`, `codesign`) operate ONLY on
52//! files inside the toolchain dir — the same class of host-tool use as
53//! `tar`/`cp`. No network, no writes outside the toolchain dir. Failures
54//! surface as [`ToolchainError::RegistryError`] (relocation is part of the
55//! toolchain-artifact publish/pull pipeline).
56
57use std::collections::HashSet;
58use std::path::{Path, PathBuf};
59
60use tracing::{debug, info};
61
62use crate::error::{Result, ToolchainError};
63
64/// Placeholder written into text files in place of the absolute built prefix.
65///
66/// [`make_relocatable`] substitutes it in; [`relocate_pulled`] substitutes it
67/// back out for the consumer's local prefix.
68pub const TEXT_PLACEHOLDER: &str = "@ZLAYER_TC@";
69
70/// One absolute-prefix byte sequence found in a binary after all Mach-O load
71/// commands were rewritten — i.e. an embedded data string (compiled-in
72/// `libexec` path, baked `--prefix`, …) that load-command rewriting cannot
73/// reach.
74#[derive(Debug, Clone)]
75pub struct ResidueHit {
76    /// The binary containing the residual absolute prefix.
77    pub file: PathBuf,
78    /// A printable ±40-byte sample around the first occurrence (control
79    /// bytes rendered as `.`).
80    pub sample: String,
81}
82
83/// What [`make_relocatable`] did to a toolchain, and whether the result is
84/// provably prefix-independent.
85#[derive(Debug, Clone, Default)]
86pub struct RelocationReport {
87    /// Mach-O files whose load commands were rewritten (and re-signed).
88    pub rewritten_machos: Vec<PathBuf>,
89    /// Dependency dylibs copied into `<dir>/lib` (transitive closure; each
90    /// copy is itself rewritten + re-signed).
91    pub bundled_dylibs: Vec<PathBuf>,
92    /// Text files that CONTAIN the built prefix and therefore need
93    /// placeholdering when the toolchain is packed for publish. These are
94    /// left UNTOUCHED in the live tree (same-machine dependent builds read
95    /// them by their real absolute path); [`apply_text_placeholders`]
96    /// rewrites them to [`TEXT_PLACEHOLDER`] on a publish COPY only.
97    pub text_files_with_prefix: Vec<PathBuf>,
98    /// Absolute-prefix bytes remaining in binaries after the rewrite pass.
99    pub residue: Vec<ResidueHit>,
100}
101
102impl RelocationReport {
103    /// `true` when no absolute-prefix bytes remain in any binary — the
104    /// artifact installs at any prefix with text-substitution alone.
105    #[must_use]
106    pub fn is_fully_relocatable(&self) -> bool {
107        self.residue.is_empty()
108    }
109}
110
111/// Make `dir` (a built toolchain at absolute prefix `built_prefix`)
112/// self-contained and relocatable.
113///
114/// Walks `dir` (regular files only; symlinks are skipped), classifies each
115/// file (Mach-O by magic bytes / text / other-binary), then:
116///
117/// 1. rewrites every Mach-O load command that points at `built_prefix`, at
118///    `dir` itself, or into one of `dep_toolchains` to the direct
119///    `@loader_path`-relative form (bundling dependency dylibs into
120///    `<dir>/lib` first — see the module docs for the exact scheme), ad-hoc
121///    re-signing each modified file;
122/// 2. RECORDS (does NOT modify) text files that contain `built_prefix` in
123///    [`RelocationReport::text_files_with_prefix`] — the live tree keeps its
124///    real absolute paths so same-machine dependent builds resolve
125///    `.pc`/cmake/scripts correctly; placeholdering happens only on a publish
126///    copy via [`apply_text_placeholders`];
127/// 3. scans all binaries for remaining `built_prefix` bytes and reports them
128///    as [`RelocationReport::residue`].
129///
130/// No network access; no writes outside `dir`. Only Mach-O load commands and
131/// bundled dylibs are written; text files are left byte-identical.
132///
133/// # Errors
134///
135/// Propagates I/O failures and returns [`ToolchainError::RegistryError`] when
136/// `otool`, `install_name_tool`, or `codesign` fails on a file (stderr is
137/// included in the message).
138pub async fn make_relocatable(
139    dir: &Path,
140    built_prefix: &Path,
141    dep_toolchains: &[PathBuf],
142) -> Result<RelocationReport> {
143    let files = collect_regular_files(dir).await?;
144    let mut texts: Vec<PathBuf> = Vec::new();
145    let mut machos: Vec<PathBuf> = Vec::new();
146    let mut other_binaries: Vec<PathBuf> = Vec::new();
147    for file in files {
148        match classify_bytes(&read_head(&file, CLASSIFY_SAMPLE_LEN).await?) {
149            FileClass::MachO => machos.push(file),
150            FileClass::Text => texts.push(file),
151            FileClass::OtherBinary => other_binaries.push(file),
152        }
153    }
154
155    let mut relocator = Relocator {
156        dir,
157        built_prefix,
158        dep_prefixes: dep_toolchains,
159        lib_dir: dir.join("lib"),
160        bundled: HashSet::new(),
161        report: RelocationReport::default(),
162    };
163    for file in &machos {
164        relocator.rewrite_macho(file).await?;
165    }
166    let mut report = relocator.report;
167
168    // Text files: RECORD (do not modify) which ones carry the built prefix.
169    // The live tree must keep real absolute paths so same-machine dependent
170    // builds read `.pc`/cmake/scripts correctly; placeholdering is applied to
171    // a publish copy only (see `apply_text_placeholders`).
172    let prefix_str = built_prefix.to_string_lossy().into_owned();
173    for file in &texts {
174        if text_file_contains(file, prefix_str.as_bytes()).await? {
175            report.text_files_with_prefix.push(file.clone());
176        }
177    }
178
179    // Residue scan: other-binaries AND every Mach-O (original + bundled
180    // copies). Load commands were rewritten, so any hit is an embedded data
181    // string.
182    let bundled = report.bundled_dylibs.clone();
183    for file in other_binaries
184        .iter()
185        .chain(machos.iter())
186        .chain(bundled.iter())
187    {
188        if let Some(hit) = scan_residue(file, prefix_str.as_bytes()).await? {
189            report.residue.push(hit);
190        }
191    }
192    info!(
193        dir = %dir.display(),
194        rewritten = report.rewritten_machos.len(),
195        bundled = report.bundled_dylibs.len(),
196        text_with_prefix = report.text_files_with_prefix.len(),
197        residue = report.residue.len(),
198        "make_relocatable finished"
199    );
200    Ok(report)
201}
202
203/// Placeholder the given text `files` (relative to `dir`) IN `dir`, replacing
204/// every `built_prefix` occurrence with [`TEXT_PLACEHOLDER`] (mode-preserving).
205///
206/// This is applied to a **publish copy** of a toolchain, never the live tree —
207/// the artifact carries the placeholder, and [`relocate_pulled`] restores it to
208/// the consumer's prefix. `files` are the paths [`make_relocatable`] recorded in
209/// [`RelocationReport::text_files_with_prefix`], each rebased onto `dir` (so the
210/// same relative entries in the copy are rewritten).
211///
212/// # Errors
213///
214/// Propagates I/O failures reading or writing a file.
215pub async fn apply_text_placeholders(
216    dir: &Path,
217    built_prefix: &Path,
218    original_root: &Path,
219    files: &[PathBuf],
220) -> Result<()> {
221    let prefix = built_prefix.to_string_lossy().into_owned();
222    for file in files {
223        // Rebase each recorded (original-root) path onto the copy `dir`.
224        let rel = file.strip_prefix(original_root).unwrap_or(file);
225        let target = dir.join(rel);
226        if placeholder_text_file(&target, prefix.as_bytes()).await? {
227            debug!(file = %target.display(), "placeholdered text file in publish copy");
228        }
229    }
230    Ok(())
231}
232
233/// Rewrite a pulled toolchain from placeholder / recorded-prefix form to
234/// `local_prefix`.
235///
236/// - Text files: every [`TEXT_PLACEHOLDER`] occurrence becomes
237///   `local_prefix` (mode-preserving).
238/// - `relocatable == true` (publisher proved residue-free): text rewrite is
239///   all that is needed.
240/// - `relocatable == false`: binaries are in-place **null-pad patched** —
241///   each `recorded_prefix` byte occurrence is overwritten with
242///   `local_prefix` bytes followed by NULs, keeping every offset intact.
243///   Only valid when `local_prefix` is no longer (in bytes) than
244///   `recorded_prefix`; a longer local prefix is a hard error. Patched
245///   Mach-O files are ad-hoc re-signed.
246/// - Finally every Mach-O in `<dir>/bin` is verified with `codesign -v` and
247///   ad-hoc re-signed when invalid.
248///
249/// # Errors
250///
251/// Returns [`ToolchainError::RegistryError`] when a non-relocatable artifact
252/// is installed at a prefix longer than the recorded one, or when `codesign`
253/// fails; propagates I/O failures.
254pub async fn relocate_pulled(
255    dir: &Path,
256    recorded_prefix: &str,
257    local_prefix: &Path,
258    relocatable: bool,
259) -> Result<()> {
260    let local = local_prefix.to_string_lossy().into_owned();
261    if !relocatable && local.len() > recorded_prefix.len() {
262        return Err(ToolchainError::RegistryError {
263            message: format!(
264                "cannot relocate non-relocatable toolchain into '{local}' ({} bytes): \
265                 binary patching is null-padded in place, so the local prefix must be \
266                 no longer than the recorded prefix '{recorded_prefix}' ({} bytes)",
267                local.len(),
268                recorded_prefix.len()
269            ),
270        });
271    }
272
273    let bin_dir = dir.join("bin");
274    let mut bin_machos: Vec<PathBuf> = Vec::new();
275    for file in collect_regular_files(dir).await? {
276        let bytes = tokio::fs::read(&file).await?;
277        match classify_bytes(&bytes) {
278            FileClass::Text => {
279                if let Some(patched) =
280                    replace_bytes(&bytes, TEXT_PLACEHOLDER.as_bytes(), local.as_bytes())
281                {
282                    write_preserving_mode(&file, &patched).await?;
283                }
284            }
285            class @ (FileClass::MachO | FileClass::OtherBinary) => {
286                let is_macho = class == FileClass::MachO;
287                if is_macho && file.parent() == Some(bin_dir.as_path()) {
288                    bin_machos.push(file.clone());
289                }
290                if !relocatable {
291                    let mut patched = bytes;
292                    let n = patch_bytes_null_padded(
293                        &mut patched,
294                        recorded_prefix.as_bytes(),
295                        local.as_bytes(),
296                    );
297                    if n > 0 {
298                        debug!(file = %file.display(), occurrences = n, "null-pad patched binary");
299                        write_preserving_mode(&file, &patched).await?;
300                        if is_macho {
301                            codesign_adhoc(&file).await?;
302                        }
303                    }
304                }
305            }
306        }
307    }
308
309    // Ad-hoc re-sign check: a pulled binary whose signature no longer
310    // verifies (e.g. invalidated in transit or by patching) gets re-signed.
311    for file in &bin_machos {
312        if !codesign_verify_ok(file).await {
313            codesign_adhoc(file).await?;
314        }
315    }
316    Ok(())
317}
318
319// ---------------------------------------------------------------------------
320// Classification
321// ---------------------------------------------------------------------------
322
323/// Bytes sampled from the head of a file for text/binary classification.
324const CLASSIFY_SAMPLE_LEN: usize = 8192;
325
326/// The six Mach-O magic byte sequences: thin 32/64-bit and fat headers, each
327/// in both byte orders (`0xfeedface`, `0xfeedfacf`, `0xcafebabe` and their
328/// swaps).
329const MACHO_MAGICS: [[u8; 4]; 6] = [
330    [0xfe, 0xed, 0xfa, 0xce], // MH_MAGIC
331    [0xce, 0xfa, 0xed, 0xfe], // MH_CIGAM
332    [0xfe, 0xed, 0xfa, 0xcf], // MH_MAGIC_64
333    [0xcf, 0xfa, 0xed, 0xfe], // MH_CIGAM_64
334    [0xca, 0xfe, 0xba, 0xbe], // FAT_MAGIC
335    [0xbe, 0xba, 0xfe, 0xca], // FAT_CIGAM
336];
337
338/// Coarse file class driving which relocation pass applies.
339#[derive(Debug, Clone, Copy, PartialEq, Eq)]
340enum FileClass {
341    /// Mach-O image (thin or fat) — load-command rewriting applies.
342    MachO,
343    /// Text (valid UTF-8, no NUL in the first 8 KiB) — placeholder
344    /// substitution applies.
345    Text,
346    /// Any other binary — byte-scan / null-pad patching applies.
347    OtherBinary,
348}
349
350/// Classify file content: Mach-O by magic bytes, then text (valid UTF-8 with
351/// no NUL byte in the first 8 KiB — a multi-byte sequence truncated exactly
352/// at the sample boundary still counts as text), else other-binary.
353fn classify_bytes(bytes: &[u8]) -> FileClass {
354    if bytes.len() >= 4 && MACHO_MAGICS.iter().any(|magic| bytes[..4] == *magic) {
355        return FileClass::MachO;
356    }
357    let sample = &bytes[..bytes.len().min(CLASSIFY_SAMPLE_LEN)];
358    if sample.contains(&0) {
359        return FileClass::OtherBinary;
360    }
361    match std::str::from_utf8(sample) {
362        Ok(_) => FileClass::Text,
363        // `error_len() == None` means the sample ends mid-sequence — the cut
364        // is ours, not the file's.
365        Err(e) if e.error_len().is_none() => FileClass::Text,
366        Err(_) => FileClass::OtherBinary,
367    }
368}
369
370// ---------------------------------------------------------------------------
371// Mach-O load-command rewriting
372// ---------------------------------------------------------------------------
373
374/// State threaded through the Mach-O rewrite pass of [`make_relocatable`].
375struct Relocator<'a> {
376    /// The toolchain dir being made relocatable.
377    dir: &'a Path,
378    /// The absolute prefix the toolchain was built at (usually equals `dir`).
379    built_prefix: &'a Path,
380    /// Dependency toolchain prefixes whose dylibs get bundled.
381    dep_prefixes: &'a [PathBuf],
382    /// `<dir>/lib` — where dependency dylibs are bundled.
383    lib_dir: PathBuf,
384    /// File names already bundled into `lib_dir` (dedupe + cycle guard).
385    bundled: HashSet<String>,
386    /// Accumulated report (rewrites + bundles; text/residue filled later).
387    report: RelocationReport,
388}
389
390/// The `install_name_tool` argument list for one file plus the dependency
391/// dylibs discovered while planning it.
392struct RewritePlan {
393    /// `-change old new` / `-id x` / `-rpath old new` / `-delete_rpath old`
394    /// argument groups (file path appended at apply time).
395    args: Vec<String>,
396    /// Dependency dylib source paths that must be bundled into `lib/`.
397    pending: Vec<PathBuf>,
398}
399
400impl Relocator<'_> {
401    /// Rewrite one Mach-O inside the toolchain: bundle any dependency dylibs
402    /// it needs, then apply its load-command rewrites + ad-hoc re-sign.
403    async fn rewrite_macho(&mut self, file: &Path) -> Result<()> {
404        let plan = self.plan_rewrite(file).await?;
405        self.bundle_all(plan.pending).await?;
406        if plan.args.is_empty() {
407            return Ok(());
408        }
409        apply_rewrite(file, &plan.args).await?;
410        self.report.rewritten_machos.push(file.to_path_buf());
411        Ok(())
412    }
413
414    /// Build the rewrite plan for `file` from its parsed load commands.
415    async fn plan_rewrite(&mut self, file: &Path) -> Result<RewritePlan> {
416        let cmds = load_commands_of(file).await?;
417        let file_dir = file.parent().unwrap_or(self.dir);
418        let mut args: Vec<String> = Vec::new();
419        let mut pending: Vec<PathBuf> = Vec::new();
420        for load in &cmds.loads {
421            if let Some(new) = self.map_load(file_dir, load, &mut pending) {
422                args.extend(["-change".into(), load.clone(), new]);
423            }
424        }
425        if let (Some(id), Some(name)) = (&cmds.id, file.file_name()) {
426            if self.is_prefixed(id) {
427                let name = name.to_string_lossy();
428                args.extend(["-id".into(), format!("@loader_path/{name}")]);
429            }
430        }
431        self.plan_rpaths(file_dir, &cmds.rpaths, &mut args);
432        Ok(RewritePlan { args, pending })
433    }
434
435    /// Map one dylib load path to its `@loader_path`-relative replacement
436    /// (`None` = system / already-relative path, leave untouched). A load
437    /// into a dependency prefix enqueues that dylib for bundling.
438    fn map_load(
439        &mut self,
440        file_dir: &Path,
441        load: &str,
442        pending: &mut Vec<PathBuf>,
443    ) -> Option<String> {
444        let load_path = Path::new(load);
445        // Built at `built_prefix`, now living at `dir`: map through the
446        // prefix onto the actual location.
447        if let Ok(rel) = load_path.strip_prefix(self.built_prefix) {
448            return Some(loader_path_to(file_dir, &self.dir.join(rel)));
449        }
450        if load_path.starts_with(self.dir) {
451            return Some(loader_path_to(file_dir, load_path));
452        }
453        for dep in self.dep_prefixes {
454            if load_path.starts_with(dep) {
455                let name = load_path.file_name()?.to_string_lossy().into_owned();
456                if self.bundled.insert(name.clone()) {
457                    pending.push(load_path.to_path_buf());
458                }
459                return Some(loader_path_to(file_dir, &self.lib_dir.join(name)));
460            }
461        }
462        None
463    }
464
465    /// Rewrite `LC_RPATH` entries under any of our prefixes to the
466    /// `@loader_path`-relative hop to `<dir>/lib`; extra matches are deleted
467    /// so `install_name_tool` never sees a duplicate rpath value.
468    fn plan_rpaths(&self, file_dir: &Path, rpaths: &[String], args: &mut Vec<String>) {
469        let mut rewrote_one = false;
470        for rpath in rpaths {
471            if !self.is_prefixed(rpath) {
472                continue;
473            }
474            if rewrote_one {
475                args.extend(["-delete_rpath".into(), rpath.clone()]);
476            } else {
477                let new = loader_path_to(file_dir, &self.lib_dir);
478                args.extend(["-rpath".into(), rpath.clone(), new]);
479                rewrote_one = true;
480            }
481        }
482    }
483
484    /// Does `path` point into the built prefix, the toolchain dir itself, or
485    /// a dependency toolchain?
486    fn is_prefixed(&self, path: &str) -> bool {
487        let p = Path::new(path);
488        p.starts_with(self.built_prefix)
489            || p.starts_with(self.dir)
490            || self.dep_prefixes.iter().any(|dep| p.starts_with(dep))
491    }
492
493    /// Drain the bundle queue: copy each dependency dylib into `<dir>/lib`,
494    /// rewriting + re-signing each copy. Copies can enqueue further deps
495    /// (transitive closure); `bundled` dedupes, so this terminates.
496    async fn bundle_all(&mut self, mut queue: Vec<PathBuf>) -> Result<()> {
497        while let Some(src) = queue.pop() {
498            self.bundle_one(&src, &mut queue).await?;
499        }
500        Ok(())
501    }
502
503    /// Copy one dependency dylib into `<dir>/lib` (following symlinks), set
504    /// its `LC_ID_DYLIB` to `@loader_path/<name>`, rewrite its own dep loads
505    /// (enqueueing new transitive deps), rewrite/delete its matching rpaths,
506    /// and ad-hoc re-sign it.
507    async fn bundle_one(&mut self, src: &Path, queue: &mut Vec<PathBuf>) -> Result<()> {
508        let name = src
509            .file_name()
510            .map(|n| n.to_string_lossy().into_owned())
511            .ok_or_else(|| ToolchainError::RegistryError {
512                message: format!("dep dylib path has no file name: {}", src.display()),
513            })?;
514        tokio::fs::create_dir_all(&self.lib_dir).await?;
515        let dest = self.lib_dir.join(&name);
516        tokio::fs::copy(src, &dest).await?;
517        debug!(src = %src.display(), dest = %dest.display(), "bundled dependency dylib");
518
519        let cmds = load_commands_of(&dest).await?;
520        let lib_dir = self.lib_dir.clone();
521        let mut args: Vec<String> = vec!["-id".into(), format!("@loader_path/{name}")];
522        for load in &cmds.loads {
523            if let Some(new) = self.map_load(&lib_dir, load, queue) {
524                args.extend(["-change".into(), load.clone(), new]);
525            }
526        }
527        self.plan_rpaths(&lib_dir, &cmds.rpaths, &mut args);
528        apply_rewrite(&dest, &args).await?;
529        self.report.bundled_dylibs.push(dest);
530        Ok(())
531    }
532}
533
534/// The relocation-relevant load commands of one Mach-O (deduped across fat
535/// slices).
536#[derive(Debug, Default)]
537struct MachLoadCommands {
538    /// `LC_ID_DYLIB` install name (dylibs only).
539    id: Option<String>,
540    /// `LC_LOAD_DYLIB` (+ weak/reexport/lazy/upward variants) paths.
541    loads: Vec<String>,
542    /// `LC_RPATH` search paths.
543    rpaths: Vec<String>,
544}
545
546/// Run `otool -l` on `file` and parse its dylib/rpath load commands.
547async fn load_commands_of(file: &Path) -> Result<MachLoadCommands> {
548    let file_str = file.to_string_lossy();
549    let out = run_host_tool("otool", &["-l", &file_str]).await?;
550    Ok(parse_load_commands(&out))
551}
552
553/// Parse `otool -l` output into [`MachLoadCommands`].
554fn parse_load_commands(otool_l: &str) -> MachLoadCommands {
555    let mut out = MachLoadCommands::default();
556    let mut current_cmd = "";
557    for line in otool_l.lines() {
558        let trimmed = line.trim_start();
559        if let Some(rest) = trimmed.strip_prefix("cmd ") {
560            current_cmd = rest.trim();
561        } else if let Some(value) = trimmed.strip_prefix("name ") {
562            let value = strip_offset_suffix(value);
563            match current_cmd {
564                "LC_ID_DYLIB" => out.id = Some(value),
565                "LC_LOAD_DYLIB"
566                | "LC_LOAD_WEAK_DYLIB"
567                | "LC_REEXPORT_DYLIB"
568                | "LC_LAZY_LOAD_DYLIB"
569                | "LC_LOAD_UPWARD_DYLIB"
570                    if !out.loads.contains(&value) =>
571                {
572                    out.loads.push(value);
573                }
574                _ => {}
575            }
576        } else if let Some(value) = trimmed.strip_prefix("path ") {
577            if current_cmd == "LC_RPATH" {
578                let value = strip_offset_suffix(value);
579                if !out.rpaths.contains(&value) {
580                    out.rpaths.push(value);
581                }
582            }
583        }
584    }
585    out
586}
587
588/// Drop the trailing ` (offset N)` annotation `otool -l` appends to load
589/// command string values.
590fn strip_offset_suffix(value: &str) -> String {
591    let cut = value.rfind(" (offset ").map_or(value, |i| &value[..i]);
592    cut.trim().to_string()
593}
594
595/// Apply an `install_name_tool` argument list to `file`, then ad-hoc re-sign
596/// it (modification invalidates the signature; arm64 requires a valid one).
597async fn apply_rewrite(file: &Path, args: &[String]) -> Result<()> {
598    let file_str = file.to_string_lossy();
599    let mut invocation: Vec<&str> = args.iter().map(String::as_str).collect();
600    invocation.push(file_str.as_ref());
601    run_host_tool("install_name_tool", &invocation).await?;
602    codesign_adhoc(file).await?;
603    debug!(file = %file.display(), "rewrote Mach-O load commands + re-signed");
604    Ok(())
605}
606
607/// Ad-hoc re-sign a Mach-O in place (`codesign -f -s -`).
608async fn codesign_adhoc(file: &Path) -> Result<()> {
609    let file_str = file.to_string_lossy();
610    run_host_tool("codesign", &["-f", "-s", "-", &file_str]).await?;
611    Ok(())
612}
613
614/// Does `codesign -v` accept this file's signature?
615async fn codesign_verify_ok(file: &Path) -> bool {
616    tokio::process::Command::new("codesign")
617        .arg("-v")
618        .arg(file)
619        .output()
620        .await
621        .is_ok_and(|out| out.status.success())
622}
623
624/// Run a host tool, returning stdout on success and a
625/// [`ToolchainError::RegistryError`] carrying stderr on failure.
626async fn run_host_tool(tool: &str, args: &[&str]) -> Result<String> {
627    let out = tokio::process::Command::new(tool)
628        .args(args)
629        .output()
630        .await?;
631    if !out.status.success() {
632        return Err(ToolchainError::RegistryError {
633            message: format!(
634                "`{tool} {}` failed ({}): {}",
635                args.join(" "),
636                out.status,
637                String::from_utf8_lossy(&out.stderr).trim()
638            ),
639        });
640    }
641    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
642}
643
644// ---------------------------------------------------------------------------
645// Relative-path computation
646// ---------------------------------------------------------------------------
647
648/// `@loader_path/<relative hop>` from the directory containing a Mach-O to a
649/// target path inside the toolchain (bare `@loader_path` when `target` IS
650/// `from_dir` — used for rpaths on files already in `lib/`).
651fn loader_path_to(from_dir: &Path, target: &Path) -> String {
652    let rel = relative_hop(from_dir, target);
653    if rel.as_os_str().is_empty() {
654        "@loader_path".to_string()
655    } else {
656        format!("@loader_path/{}", rel.display())
657    }
658}
659
660/// Pure relative path from `from_dir` to `to` (both absolute): `..` per
661/// unshared `from_dir` component, then the unshared tail of `to`.
662fn relative_hop(from_dir: &Path, to: &Path) -> PathBuf {
663    let from: Vec<_> = from_dir.components().collect();
664    let to: Vec<_> = to.components().collect();
665    let common = from
666        .iter()
667        .zip(to.iter())
668        .take_while(|(a, b)| a == b)
669        .count();
670    let mut rel = PathBuf::new();
671    for _ in common..from.len() {
672        rel.push("..");
673    }
674    for component in &to[common..] {
675        rel.push(component.as_os_str());
676    }
677    rel
678}
679
680// ---------------------------------------------------------------------------
681// File walking + byte surgery
682// ---------------------------------------------------------------------------
683
684/// All regular files under `root`, recursively. Symlinks (file or dir) are
685/// skipped — relocation operates on real bytes only.
686async fn collect_regular_files(root: &Path) -> Result<Vec<PathBuf>> {
687    let mut files = Vec::new();
688    let mut dirs = vec![root.to_path_buf()];
689    while let Some(dir) = dirs.pop() {
690        let mut entries = tokio::fs::read_dir(&dir).await?;
691        while let Some(entry) = entries.next_entry().await? {
692            let file_type = entry.file_type().await?;
693            if file_type.is_dir() {
694                dirs.push(entry.path());
695            } else if file_type.is_file() {
696                files.push(entry.path());
697            }
698        }
699    }
700    Ok(files)
701}
702
703/// Read up to `n` bytes from the head of `path`.
704async fn read_head(path: &Path, n: usize) -> Result<Vec<u8>> {
705    use tokio::io::AsyncReadExt;
706    let mut file = tokio::fs::File::open(path).await?;
707    let mut buf = vec![0u8; n];
708    let mut filled = 0;
709    loop {
710        let read = file.read(&mut buf[filled..]).await?;
711        if read == 0 || filled + read == n {
712            filled += read;
713            break;
714        }
715        filled += read;
716    }
717    buf.truncate(filled);
718    Ok(buf)
719}
720
721/// Replace `built_prefix` bytes in a text file with [`TEXT_PLACEHOLDER`],
722/// preserving the file mode. Returns whether anything was replaced.
723async fn placeholder_text_file(file: &Path, prefix: &[u8]) -> Result<bool> {
724    let bytes = tokio::fs::read(file).await?;
725    let Some(patched) = replace_bytes(&bytes, prefix, TEXT_PLACEHOLDER.as_bytes()) else {
726        return Ok(false);
727    };
728    write_preserving_mode(file, &patched).await?;
729    Ok(true)
730}
731
732/// Read-only: does `file` contain the `prefix` byte sequence anywhere?
733/// Used by [`make_relocatable`] to record (without modifying) which text files
734/// carry the built prefix.
735async fn text_file_contains(file: &Path, prefix: &[u8]) -> Result<bool> {
736    let bytes = tokio::fs::read(file).await?;
737    Ok(find_subslice(&bytes, prefix).is_some())
738}
739
740/// Overwrite `file` with `bytes`, restoring its original permissions.
741async fn write_preserving_mode(file: &Path, bytes: &[u8]) -> Result<()> {
742    let perms = tokio::fs::metadata(file).await?.permissions();
743    tokio::fs::write(file, bytes).await?;
744    tokio::fs::set_permissions(file, perms).await?;
745    Ok(())
746}
747
748/// Length-changing byte replacement: every `old` occurrence becomes `new`.
749/// `None` when `old` never occurs (callers skip the write).
750fn replace_bytes(data: &[u8], old: &[u8], new: &[u8]) -> Option<Vec<u8>> {
751    if old.is_empty() {
752        return None;
753    }
754    let mut out = Vec::with_capacity(data.len());
755    let mut i = 0;
756    let mut found = false;
757    while i < data.len() {
758        if data[i..].starts_with(old) {
759            out.extend_from_slice(new);
760            i += old.len();
761            found = true;
762        } else {
763            out.push(data[i]);
764            i += 1;
765        }
766    }
767    found.then_some(out)
768}
769
770/// In-place null-padded byte patch: each `old` occurrence is overwritten
771/// with `new` followed by NULs up to `old`'s length, so every file offset is
772/// preserved. Requires `new.len() <= old.len()` (callers enforce). Returns
773/// the occurrence count.
774fn patch_bytes_null_padded(data: &mut [u8], old: &[u8], new: &[u8]) -> usize {
775    debug_assert!(new.len() <= old.len(), "null-pad patch needs new <= old");
776    if old.is_empty() {
777        return 0;
778    }
779    let mut count = 0;
780    let mut i = 0;
781    while i + old.len() <= data.len() {
782        if &data[i..i + old.len()] == old {
783            data[i..i + new.len()].copy_from_slice(new);
784            data[i + new.len()..i + old.len()].fill(0);
785            i += old.len();
786            count += 1;
787        } else {
788            i += 1;
789        }
790    }
791    count
792}
793
794/// Scan one binary for `prefix` bytes; `Some(hit)` carries a printable
795/// ±40-byte sample around the first occurrence.
796async fn scan_residue(file: &Path, prefix: &[u8]) -> Result<Option<ResidueHit>> {
797    let bytes = tokio::fs::read(file).await?;
798    Ok(find_subslice(&bytes, prefix).map(|at| ResidueHit {
799        file: file.to_path_buf(),
800        sample: sample_around(&bytes, at, prefix.len()),
801    }))
802}
803
804/// First index of `needle` in `haystack`.
805fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
806    if needle.is_empty() || haystack.len() < needle.len() {
807        return None;
808    }
809    haystack.windows(needle.len()).position(|w| w == needle)
810}
811
812/// Lossy printable sample of the bytes ±40 around a residue hit (control
813/// bytes rendered as `.`).
814fn sample_around(bytes: &[u8], at: usize, needle_len: usize) -> String {
815    let start = at.saturating_sub(40);
816    let end = (at + needle_len + 40).min(bytes.len());
817    String::from_utf8_lossy(&bytes[start..end])
818        .chars()
819        .map(|c| if c.is_control() { '.' } else { c })
820        .collect()
821}
822
823// ---------------------------------------------------------------------------
824// Tests
825// ---------------------------------------------------------------------------
826
827#[cfg(test)]
828mod tests {
829    use super::*;
830
831    // -- classification ----------------------------------------------------
832
833    /// Every Mach-O magic variant (thin 32/64 + fat, both byte orders)
834    /// classifies as Mach-O.
835    #[test]
836    fn classify_macho_magic_variants() {
837        for magic in MACHO_MAGICS {
838            let mut bytes = magic.to_vec();
839            bytes.extend_from_slice(&[0u8; 60]);
840            assert_eq!(classify_bytes(&bytes), FileClass::MachO, "{magic:02x?}");
841        }
842    }
843
844    /// UTF-8 without NULs is text; NUL bytes or invalid UTF-8 are binary;
845    /// a multi-byte char truncated exactly at the 8 KiB sample boundary is
846    /// still text.
847    #[test]
848    fn classify_text_and_binary() {
849        assert_eq!(
850            classify_bytes(b"prefix=/opt/tc\nlibdir=${prefix}/lib\n"),
851            FileClass::Text
852        );
853        assert_eq!(
854            classify_bytes(b"\x01\x02\x00binary"),
855            FileClass::OtherBinary
856        );
857        assert_eq!(
858            classify_bytes(&[0xff, 0xfe, b'a', b'b']),
859            FileClass::OtherBinary
860        );
861
862        // 8191 ASCII bytes + a 2-byte char whose first byte lands at sample
863        // index 8191: the sample cuts mid-sequence -> text.
864        let mut boundary = vec![b'a'; CLASSIFY_SAMPLE_LEN - 1];
865        boundary.extend_from_slice("é".as_bytes());
866        assert!(boundary.len() > CLASSIFY_SAMPLE_LEN);
867        assert_eq!(classify_bytes(&boundary), FileClass::Text);
868    }
869
870    // -- relative hops -------------------------------------------------------
871
872    /// The direct `@loader_path` scheme: bin -> lib hops through `..`, lib
873    /// siblings are bare names, and a dir referencing itself is bare
874    /// `@loader_path`.
875    #[test]
876    fn loader_path_hops() {
877        assert_eq!(
878            loader_path_to(Path::new("/tc/bin"), Path::new("/tc/lib/libgreet.dylib")),
879            "@loader_path/../lib/libgreet.dylib"
880        );
881        assert_eq!(
882            loader_path_to(Path::new("/tc/lib"), Path::new("/tc/lib/liba.dylib")),
883            "@loader_path/liba.dylib"
884        );
885        assert_eq!(
886            loader_path_to(Path::new("/tc/lib"), Path::new("/tc/lib")),
887            "@loader_path"
888        );
889        assert_eq!(
890            loader_path_to(
891                Path::new("/tc/libexec/git-core"),
892                Path::new("/tc/lib/libz.dylib")
893            ),
894            "@loader_path/../../lib/libz.dylib"
895        );
896    }
897
898    // -- otool parsing -------------------------------------------------------
899
900    /// `otool -l` sections parse into id/loads/rpaths with offsets stripped
901    /// and fat-slice duplicates deduped.
902    #[test]
903    fn parse_otool_load_commands() {
904        let otool = "\
905/tc/bin/tool:
906Load command 3
907          cmd LC_ID_DYLIB
908      cmdsize 56
909         name /tc/lib/libgreet.dylib (offset 24)
910Load command 12
911          cmd LC_LOAD_DYLIB
912      cmdsize 56
913         name /usr/lib/libSystem.B.dylib (offset 24)
914Load command 13
915          cmd LC_LOAD_DYLIB
916      cmdsize 72
917         name /dep/tc/lib/libdep.dylib (offset 24)
918Load command 14
919          cmd LC_RPATH
920      cmdsize 32
921         path /tc/lib (offset 12)
922Load command 15
923          cmd LC_LOAD_DYLIB
924      cmdsize 56
925         name /usr/lib/libSystem.B.dylib (offset 24)
926";
927        let cmds = parse_load_commands(otool);
928        assert_eq!(cmds.id.as_deref(), Some("/tc/lib/libgreet.dylib"));
929        assert_eq!(
930            cmds.loads,
931            vec![
932                "/usr/lib/libSystem.B.dylib".to_string(),
933                "/dep/tc/lib/libdep.dylib".to_string()
934            ]
935        );
936        assert_eq!(cmds.rpaths, vec!["/tc/lib".to_string()]);
937    }
938
939    // -- text placeholder round trip -----------------------------------------
940
941    /// `make_relocatable` RECORDS a `.pc` fixture without touching the live
942    /// tree; `apply_text_placeholders` (publish copy) writes the placeholder;
943    /// `relocate_pulled` restores it at a DIFFERENT prefix.
944    #[tokio::test]
945    async fn text_placeholder_round_trip_to_new_prefix() {
946        let tmp = tempfile::tempdir().unwrap();
947        let tc = tmp.path().join("tc");
948        let prefix_str = tc.to_string_lossy().into_owned();
949        let pc = tc.join("lib/pkgconfig/foo.pc");
950        tokio::fs::create_dir_all(pc.parent().unwrap())
951            .await
952            .unwrap();
953        let original = format!(
954            "prefix={prefix_str}\nlibdir={prefix_str}/lib\nCflags: -I{prefix_str}/include\n"
955        );
956        tokio::fs::write(&pc, &original).await.unwrap();
957        // A text file with no prefix occurrence must be left alone.
958        let readme = tc.join("README");
959        tokio::fs::write(&readme, "no prefix here\n").await.unwrap();
960
961        let report = make_relocatable(&tc, &tc, &[]).await.unwrap();
962        // Recorded, NOT placeholdered — the live tree keeps its real prefix so
963        // same-machine dependents read the .pc correctly.
964        assert_eq!(report.text_files_with_prefix, vec![pc.clone()]);
965        assert!(report.is_fully_relocatable());
966        assert_eq!(
967            tokio::fs::read_to_string(&pc).await.unwrap(),
968            original,
969            "live .pc must still carry the real prefix after make_relocatable"
970        );
971
972        // Publish would placeholder a COPY; simulate in place for the round-trip.
973        apply_text_placeholders(&tc, &tc, &tc, &report.text_files_with_prefix)
974            .await
975            .unwrap();
976        let placeholdered = tokio::fs::read_to_string(&pc).await.unwrap();
977        assert!(!placeholdered.contains(&prefix_str));
978        assert_eq!(placeholdered.matches(TEXT_PLACEHOLDER).count(), 3);
979
980        let new_prefix = Path::new("/opt/zlayer/toolchains/foo-1.0-arm64");
981        relocate_pulled(&tc, &prefix_str, new_prefix, true)
982            .await
983            .unwrap();
984        let restored = tokio::fs::read_to_string(&pc).await.unwrap();
985        assert!(!restored.contains(TEXT_PLACEHOLDER));
986        assert_eq!(
987            restored,
988            format!(
989                "prefix={p}\nlibdir={p}/lib\nCflags: -I{p}/include\n",
990                p = new_prefix.display()
991            )
992        );
993        assert_eq!(
994            tokio::fs::read_to_string(&readme).await.unwrap(),
995            "no prefix here\n"
996        );
997    }
998
999    // -- residue scan ----------------------------------------------------------
1000
1001    /// An embedded absolute path in a non-Mach-O binary is reported as
1002    /// residue with a printable sample.
1003    #[tokio::test]
1004    async fn residue_scan_reports_embedded_prefix() {
1005        let tmp = tempfile::tempdir().unwrap();
1006        let tc = tmp.path().join("tc");
1007        let prefix_str = tc.to_string_lossy().into_owned();
1008        let blob = tc.join("libexec/tool.bin");
1009        tokio::fs::create_dir_all(blob.parent().unwrap())
1010            .await
1011            .unwrap();
1012        let mut bytes = b"\x7fELF\x00\x01\x02".to_vec();
1013        bytes.extend_from_slice(prefix_str.as_bytes());
1014        bytes.extend_from_slice(b"/libexec/helper\x00trailing");
1015        tokio::fs::write(&blob, &bytes).await.unwrap();
1016
1017        let report = make_relocatable(&tc, &tc, &[]).await.unwrap();
1018        assert!(!report.is_fully_relocatable());
1019        assert_eq!(report.residue.len(), 1);
1020        let hit = &report.residue[0];
1021        assert_eq!(hit.file, blob);
1022        assert!(hit.sample.contains("/libexec/helper"), "{}", hit.sample);
1023        assert!(report.text_files_with_prefix.is_empty());
1024    }
1025
1026    // -- null-padded patching ----------------------------------------------------
1027
1028    /// Shorter replacement pads with NULs keeping length; equal length needs
1029    /// no padding; every occurrence is patched.
1030    #[test]
1031    fn null_padded_patch_pads_and_preserves_length() {
1032        let old = b"/build/prefix/tc";
1033        let new = b"/opt/t";
1034        let mut data = Vec::new();
1035        data.extend_from_slice(b"AA");
1036        data.extend_from_slice(old);
1037        data.extend_from_slice(b"/bin/tool\x00");
1038        data.extend_from_slice(old);
1039        data.extend_from_slice(b"ZZ");
1040        let original_len = data.len();
1041
1042        assert_eq!(patch_bytes_null_padded(&mut data, old, new), 2);
1043        assert_eq!(data.len(), original_len);
1044        let mut expected = Vec::new();
1045        expected.extend_from_slice(b"AA");
1046        expected.extend_from_slice(new);
1047        expected.extend_from_slice(&vec![0u8; old.len() - new.len()]);
1048        expected.extend_from_slice(b"/bin/tool\x00");
1049        expected.extend_from_slice(new);
1050        expected.extend_from_slice(&vec![0u8; old.len() - new.len()]);
1051        expected.extend_from_slice(b"ZZ");
1052        assert_eq!(data, expected);
1053
1054        // Equal length: byte-for-byte swap, no padding.
1055        let mut same = b"x/build/prefix/tcx".to_vec();
1056        assert_eq!(
1057            patch_bytes_null_padded(&mut same, old, b"/A/BUILD/prefix2"),
1058            1
1059        );
1060        assert_eq!(same, b"x/A/BUILD/prefix2x".to_vec());
1061    }
1062
1063    /// `relocate_pulled` on a non-relocatable artifact patches binaries
1064    /// null-padded in place (length unchanged) without touching text logic.
1065    #[tokio::test]
1066    async fn relocate_pulled_patches_binary_null_padded() {
1067        let tmp = tempfile::tempdir().unwrap();
1068        let tc = tmp.path().join("tc");
1069        let blob = tc.join("libexec/tool.bin");
1070        tokio::fs::create_dir_all(blob.parent().unwrap())
1071            .await
1072            .unwrap();
1073        let recorded = "/build/prefix/tc";
1074        let mut bytes = b"\x00\x10\x20".to_vec();
1075        bytes.extend_from_slice(recorded.as_bytes());
1076        bytes.extend_from_slice(b"/bin/tool\x00tail");
1077        tokio::fs::write(&blob, &bytes).await.unwrap();
1078
1079        relocate_pulled(&tc, recorded, Path::new("/opt/t"), false)
1080            .await
1081            .unwrap();
1082
1083        let patched = tokio::fs::read(&blob).await.unwrap();
1084        assert_eq!(patched.len(), bytes.len(), "offsets must be preserved");
1085        let mut expected = b"\x00\x10\x20".to_vec();
1086        expected.extend_from_slice(b"/opt/t");
1087        expected.extend_from_slice(&vec![0u8; recorded.len() - "/opt/t".len()]);
1088        expected.extend_from_slice(b"/bin/tool\x00tail");
1089        assert_eq!(patched, expected);
1090    }
1091
1092    /// A local prefix LONGER than the recorded one cannot be null-pad
1093    /// patched: clear hard error.
1094    #[tokio::test]
1095    async fn relocate_pulled_rejects_longer_local_prefix() {
1096        let tmp = tempfile::tempdir().unwrap();
1097        let tc = tmp.path().join("tc");
1098        tokio::fs::create_dir_all(&tc).await.unwrap();
1099
1100        let err = relocate_pulled(
1101            &tc,
1102            "/short",
1103            Path::new("/a/much/longer/local/prefix"),
1104            false,
1105        )
1106        .await
1107        .unwrap_err();
1108        let msg = err.to_string();
1109        assert!(msg.contains("no longer than the recorded prefix"), "{msg}");
1110    }
1111
1112    // -- live: real dylib + move -----------------------------------------------
1113
1114    /// Compile `cc` args, panicking with stderr on failure.
1115    async fn cc(args: &[&str]) {
1116        let out = tokio::process::Command::new("cc")
1117            .args(args)
1118            .output()
1119            .await
1120            .expect("spawn cc");
1121        assert!(
1122            out.status.success(),
1123            "cc {args:?} failed: {}",
1124            String::from_utf8_lossy(&out.stderr)
1125        );
1126    }
1127
1128    /// THE POINT of this module: build a real dylib + consumer at an
1129    /// absolute tempdir prefix (with `-headerpad_max_install_names`), make
1130    /// it relocatable, MOVE the whole toolchain dir, and prove the consumer
1131    /// still executes — with `otool -L` showing `@loader_path` and
1132    /// `codesign -v` passing.
1133    #[tokio::test]
1134    #[ignore = "live: compiles a real dylib"]
1135    async fn live_toolchain_survives_move_after_make_relocatable() {
1136        let build_tmp = tempfile::tempdir().unwrap();
1137        // Canonicalize: /var/folders symlinks to /private/var on macOS and
1138        // the linker records whatever string we pass it.
1139        let root = build_tmp.path().canonicalize().unwrap();
1140        let tc = root.join("tc");
1141        let lib = tc.join("lib");
1142        let bin = tc.join("bin");
1143        tokio::fs::create_dir_all(&lib).await.unwrap();
1144        tokio::fs::create_dir_all(&bin).await.unwrap();
1145
1146        let greet_c = root.join("greet.c");
1147        tokio::fs::write(
1148            &greet_c,
1149            "#include <stdio.h>\nvoid greet(void) { printf(\"hello-from-greet\\n\"); }\n",
1150        )
1151        .await
1152        .unwrap();
1153        let main_c = root.join("main.c");
1154        tokio::fs::write(
1155            &main_c,
1156            "void greet(void);\nint main(void) { greet(); return 0; }\n",
1157        )
1158        .await
1159        .unwrap();
1160
1161        let dylib = lib.join("libgreet.dylib");
1162        let hello = bin.join("hello");
1163        cc(&[
1164            "-dynamiclib",
1165            greet_c.to_str().unwrap(),
1166            "-o",
1167            dylib.to_str().unwrap(),
1168            "-install_name",
1169            dylib.to_str().unwrap(),
1170            "-Wl,-headerpad_max_install_names",
1171        ])
1172        .await;
1173        cc(&[
1174            main_c.to_str().unwrap(),
1175            "-o",
1176            hello.to_str().unwrap(),
1177            "-L",
1178            lib.to_str().unwrap(),
1179            "-lgreet",
1180            "-Wl,-headerpad_max_install_names",
1181        ])
1182        .await;
1183
1184        let report = make_relocatable(&tc, &tc, &[]).await.unwrap();
1185        assert!(report.rewritten_machos.contains(&hello), "{report:?}");
1186        assert!(report.rewritten_machos.contains(&dylib), "{report:?}");
1187        assert!(
1188            report.is_fully_relocatable(),
1189            "unexpected residue: {:?}",
1190            report.residue
1191        );
1192
1193        // MOVE the whole toolchain to a different absolute path.
1194        let move_tmp = tempfile::tempdir().unwrap();
1195        let moved = move_tmp.path().canonicalize().unwrap().join("moved-tc");
1196        tokio::fs::rename(&tc, &moved).await.unwrap();
1197        let moved_hello = moved.join("bin/hello");
1198        let moved_dylib = moved.join("lib/libgreet.dylib");
1199
1200        let otool_l = run_host_tool("otool", &["-L", moved_hello.to_str().unwrap()])
1201            .await
1202            .unwrap();
1203        assert!(
1204            otool_l.contains("@loader_path/../lib/libgreet.dylib"),
1205            "otool -L: {otool_l}"
1206        );
1207
1208        let out = tokio::process::Command::new(&moved_hello)
1209            .output()
1210            .await
1211            .expect("run moved consumer");
1212        assert!(out.status.success(), "moved consumer must execute");
1213        assert!(
1214            String::from_utf8_lossy(&out.stdout).contains("hello-from-greet"),
1215            "stdout: {}",
1216            String::from_utf8_lossy(&out.stdout)
1217        );
1218
1219        assert!(codesign_verify_ok(&moved_hello).await, "hello codesign -v");
1220        assert!(codesign_verify_ok(&moved_dylib).await, "dylib codesign -v");
1221    }
1222}