use std::collections::HashSet;
use std::path::{Path, PathBuf};
use tracing::{debug, info};
use crate::error::{Result, ToolchainError};
pub const TEXT_PLACEHOLDER: &str = "@ZLAYER_TC@";
#[derive(Debug, Clone)]
pub struct ResidueHit {
pub file: PathBuf,
pub sample: String,
}
#[derive(Debug, Clone, Default)]
pub struct RelocationReport {
pub rewritten_machos: Vec<PathBuf>,
pub bundled_dylibs: Vec<PathBuf>,
pub text_files_with_prefix: Vec<PathBuf>,
pub residue: Vec<ResidueHit>,
}
impl RelocationReport {
#[must_use]
pub fn is_fully_relocatable(&self) -> bool {
self.residue.is_empty()
}
}
pub async fn make_relocatable(
dir: &Path,
built_prefix: &Path,
dep_toolchains: &[PathBuf],
) -> Result<RelocationReport> {
let files = collect_regular_files(dir).await?;
let mut texts: Vec<PathBuf> = Vec::new();
let mut machos: Vec<PathBuf> = Vec::new();
let mut other_binaries: Vec<PathBuf> = Vec::new();
for file in files {
match classify_bytes(&read_head(&file, CLASSIFY_SAMPLE_LEN).await?) {
FileClass::MachO => machos.push(file),
FileClass::Text => texts.push(file),
FileClass::OtherBinary => other_binaries.push(file),
}
}
let mut relocator = Relocator {
dir,
built_prefix,
dep_prefixes: dep_toolchains,
lib_dir: dir.join("lib"),
bundled: HashSet::new(),
report: RelocationReport::default(),
};
for file in &machos {
relocator.rewrite_macho(file).await?;
}
let mut report = relocator.report;
let prefix_str = built_prefix.to_string_lossy().into_owned();
for file in &texts {
if text_file_contains(file, prefix_str.as_bytes()).await? {
report.text_files_with_prefix.push(file.clone());
}
}
let bundled = report.bundled_dylibs.clone();
for file in other_binaries
.iter()
.chain(machos.iter())
.chain(bundled.iter())
{
if let Some(hit) = scan_residue(file, prefix_str.as_bytes()).await? {
report.residue.push(hit);
}
}
info!(
dir = %dir.display(),
rewritten = report.rewritten_machos.len(),
bundled = report.bundled_dylibs.len(),
text_with_prefix = report.text_files_with_prefix.len(),
residue = report.residue.len(),
"make_relocatable finished"
);
Ok(report)
}
pub async fn apply_text_placeholders(
dir: &Path,
built_prefix: &Path,
original_root: &Path,
files: &[PathBuf],
) -> Result<()> {
let prefix = built_prefix.to_string_lossy().into_owned();
for file in files {
let rel = file.strip_prefix(original_root).unwrap_or(file);
let target = dir.join(rel);
if placeholder_text_file(&target, prefix.as_bytes()).await? {
debug!(file = %target.display(), "placeholdered text file in publish copy");
}
}
Ok(())
}
pub async fn relocate_pulled(
dir: &Path,
recorded_prefix: &str,
local_prefix: &Path,
relocatable: bool,
) -> Result<()> {
let local = local_prefix.to_string_lossy().into_owned();
if !relocatable && local.len() > recorded_prefix.len() {
return Err(ToolchainError::RegistryError {
message: format!(
"cannot relocate non-relocatable toolchain into '{local}' ({} bytes): \
binary patching is null-padded in place, so the local prefix must be \
no longer than the recorded prefix '{recorded_prefix}' ({} bytes)",
local.len(),
recorded_prefix.len()
),
});
}
let bin_dir = dir.join("bin");
let mut bin_machos: Vec<PathBuf> = Vec::new();
for file in collect_regular_files(dir).await? {
let bytes = tokio::fs::read(&file).await?;
match classify_bytes(&bytes) {
FileClass::Text => {
if let Some(patched) =
replace_bytes(&bytes, TEXT_PLACEHOLDER.as_bytes(), local.as_bytes())
{
write_preserving_mode(&file, &patched).await?;
}
}
class @ (FileClass::MachO | FileClass::OtherBinary) => {
let is_macho = class == FileClass::MachO;
if is_macho && file.parent() == Some(bin_dir.as_path()) {
bin_machos.push(file.clone());
}
if !relocatable {
let mut patched = bytes;
let n = patch_bytes_null_padded(
&mut patched,
recorded_prefix.as_bytes(),
local.as_bytes(),
);
if n > 0 {
debug!(file = %file.display(), occurrences = n, "null-pad patched binary");
write_preserving_mode(&file, &patched).await?;
if is_macho {
codesign_adhoc(&file).await?;
}
}
}
}
}
}
for file in &bin_machos {
if !codesign_verify_ok(file).await {
codesign_adhoc(file).await?;
}
}
Ok(())
}
const CLASSIFY_SAMPLE_LEN: usize = 8192;
const MACHO_MAGICS: [[u8; 4]; 6] = [
[0xfe, 0xed, 0xfa, 0xce], [0xce, 0xfa, 0xed, 0xfe], [0xfe, 0xed, 0xfa, 0xcf], [0xcf, 0xfa, 0xed, 0xfe], [0xca, 0xfe, 0xba, 0xbe], [0xbe, 0xba, 0xfe, 0xca], ];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FileClass {
MachO,
Text,
OtherBinary,
}
fn classify_bytes(bytes: &[u8]) -> FileClass {
if bytes.len() >= 4 && MACHO_MAGICS.iter().any(|magic| bytes[..4] == *magic) {
return FileClass::MachO;
}
let sample = &bytes[..bytes.len().min(CLASSIFY_SAMPLE_LEN)];
if sample.contains(&0) {
return FileClass::OtherBinary;
}
match std::str::from_utf8(sample) {
Ok(_) => FileClass::Text,
Err(e) if e.error_len().is_none() => FileClass::Text,
Err(_) => FileClass::OtherBinary,
}
}
struct Relocator<'a> {
dir: &'a Path,
built_prefix: &'a Path,
dep_prefixes: &'a [PathBuf],
lib_dir: PathBuf,
bundled: HashSet<String>,
report: RelocationReport,
}
struct RewritePlan {
args: Vec<String>,
pending: Vec<PathBuf>,
}
impl Relocator<'_> {
async fn rewrite_macho(&mut self, file: &Path) -> Result<()> {
let plan = self.plan_rewrite(file).await?;
self.bundle_all(plan.pending).await?;
if plan.args.is_empty() {
return Ok(());
}
apply_rewrite(file, &plan.args).await?;
self.report.rewritten_machos.push(file.to_path_buf());
Ok(())
}
async fn plan_rewrite(&mut self, file: &Path) -> Result<RewritePlan> {
let cmds = load_commands_of(file).await?;
let file_dir = file.parent().unwrap_or(self.dir);
let mut args: Vec<String> = Vec::new();
let mut pending: Vec<PathBuf> = Vec::new();
for load in &cmds.loads {
if let Some(new) = self.map_load(file_dir, load, &mut pending) {
args.extend(["-change".into(), load.clone(), new]);
}
}
if let (Some(id), Some(name)) = (&cmds.id, file.file_name()) {
if self.is_prefixed(id) {
let name = name.to_string_lossy();
args.extend(["-id".into(), format!("@loader_path/{name}")]);
}
}
self.plan_rpaths(file_dir, &cmds.rpaths, &mut args);
Ok(RewritePlan { args, pending })
}
fn map_load(
&mut self,
file_dir: &Path,
load: &str,
pending: &mut Vec<PathBuf>,
) -> Option<String> {
let load_path = Path::new(load);
if let Ok(rel) = load_path.strip_prefix(self.built_prefix) {
return Some(loader_path_to(file_dir, &self.dir.join(rel)));
}
if load_path.starts_with(self.dir) {
return Some(loader_path_to(file_dir, load_path));
}
for dep in self.dep_prefixes {
if load_path.starts_with(dep) {
let name = load_path.file_name()?.to_string_lossy().into_owned();
if self.bundled.insert(name.clone()) {
pending.push(load_path.to_path_buf());
}
return Some(loader_path_to(file_dir, &self.lib_dir.join(name)));
}
}
None
}
fn plan_rpaths(&self, file_dir: &Path, rpaths: &[String], args: &mut Vec<String>) {
let mut rewrote_one = false;
for rpath in rpaths {
if !self.is_prefixed(rpath) {
continue;
}
if rewrote_one {
args.extend(["-delete_rpath".into(), rpath.clone()]);
} else {
let new = loader_path_to(file_dir, &self.lib_dir);
args.extend(["-rpath".into(), rpath.clone(), new]);
rewrote_one = true;
}
}
}
fn is_prefixed(&self, path: &str) -> bool {
let p = Path::new(path);
p.starts_with(self.built_prefix)
|| p.starts_with(self.dir)
|| self.dep_prefixes.iter().any(|dep| p.starts_with(dep))
}
async fn bundle_all(&mut self, mut queue: Vec<PathBuf>) -> Result<()> {
while let Some(src) = queue.pop() {
self.bundle_one(&src, &mut queue).await?;
}
Ok(())
}
async fn bundle_one(&mut self, src: &Path, queue: &mut Vec<PathBuf>) -> Result<()> {
let name = src
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.ok_or_else(|| ToolchainError::RegistryError {
message: format!("dep dylib path has no file name: {}", src.display()),
})?;
tokio::fs::create_dir_all(&self.lib_dir).await?;
let dest = self.lib_dir.join(&name);
tokio::fs::copy(src, &dest).await?;
debug!(src = %src.display(), dest = %dest.display(), "bundled dependency dylib");
let cmds = load_commands_of(&dest).await?;
let lib_dir = self.lib_dir.clone();
let mut args: Vec<String> = vec!["-id".into(), format!("@loader_path/{name}")];
for load in &cmds.loads {
if let Some(new) = self.map_load(&lib_dir, load, queue) {
args.extend(["-change".into(), load.clone(), new]);
}
}
self.plan_rpaths(&lib_dir, &cmds.rpaths, &mut args);
apply_rewrite(&dest, &args).await?;
self.report.bundled_dylibs.push(dest);
Ok(())
}
}
#[derive(Debug, Default)]
struct MachLoadCommands {
id: Option<String>,
loads: Vec<String>,
rpaths: Vec<String>,
}
async fn load_commands_of(file: &Path) -> Result<MachLoadCommands> {
let file_str = file.to_string_lossy();
let out = run_host_tool("otool", &["-l", &file_str]).await?;
Ok(parse_load_commands(&out))
}
fn parse_load_commands(otool_l: &str) -> MachLoadCommands {
let mut out = MachLoadCommands::default();
let mut current_cmd = "";
for line in otool_l.lines() {
let trimmed = line.trim_start();
if let Some(rest) = trimmed.strip_prefix("cmd ") {
current_cmd = rest.trim();
} else if let Some(value) = trimmed.strip_prefix("name ") {
let value = strip_offset_suffix(value);
match current_cmd {
"LC_ID_DYLIB" => out.id = Some(value),
"LC_LOAD_DYLIB"
| "LC_LOAD_WEAK_DYLIB"
| "LC_REEXPORT_DYLIB"
| "LC_LAZY_LOAD_DYLIB"
| "LC_LOAD_UPWARD_DYLIB"
if !out.loads.contains(&value) =>
{
out.loads.push(value);
}
_ => {}
}
} else if let Some(value) = trimmed.strip_prefix("path ") {
if current_cmd == "LC_RPATH" {
let value = strip_offset_suffix(value);
if !out.rpaths.contains(&value) {
out.rpaths.push(value);
}
}
}
}
out
}
fn strip_offset_suffix(value: &str) -> String {
let cut = value.rfind(" (offset ").map_or(value, |i| &value[..i]);
cut.trim().to_string()
}
async fn apply_rewrite(file: &Path, args: &[String]) -> Result<()> {
let file_str = file.to_string_lossy();
let mut invocation: Vec<&str> = args.iter().map(String::as_str).collect();
invocation.push(file_str.as_ref());
run_host_tool("install_name_tool", &invocation).await?;
codesign_adhoc(file).await?;
debug!(file = %file.display(), "rewrote Mach-O load commands + re-signed");
Ok(())
}
async fn codesign_adhoc(file: &Path) -> Result<()> {
let file_str = file.to_string_lossy();
run_host_tool("codesign", &["-f", "-s", "-", &file_str]).await?;
Ok(())
}
async fn codesign_verify_ok(file: &Path) -> bool {
tokio::process::Command::new("codesign")
.arg("-v")
.arg(file)
.output()
.await
.is_ok_and(|out| out.status.success())
}
async fn run_host_tool(tool: &str, args: &[&str]) -> Result<String> {
let out = tokio::process::Command::new(tool)
.args(args)
.output()
.await?;
if !out.status.success() {
return Err(ToolchainError::RegistryError {
message: format!(
"`{tool} {}` failed ({}): {}",
args.join(" "),
out.status,
String::from_utf8_lossy(&out.stderr).trim()
),
});
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
fn loader_path_to(from_dir: &Path, target: &Path) -> String {
let rel = relative_hop(from_dir, target);
if rel.as_os_str().is_empty() {
"@loader_path".to_string()
} else {
format!("@loader_path/{}", rel.display())
}
}
fn relative_hop(from_dir: &Path, to: &Path) -> PathBuf {
let from: Vec<_> = from_dir.components().collect();
let to: Vec<_> = to.components().collect();
let common = from
.iter()
.zip(to.iter())
.take_while(|(a, b)| a == b)
.count();
let mut rel = PathBuf::new();
for _ in common..from.len() {
rel.push("..");
}
for component in &to[common..] {
rel.push(component.as_os_str());
}
rel
}
async fn collect_regular_files(root: &Path) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
let mut dirs = vec![root.to_path_buf()];
while let Some(dir) = dirs.pop() {
let mut entries = tokio::fs::read_dir(&dir).await?;
while let Some(entry) = entries.next_entry().await? {
let file_type = entry.file_type().await?;
if file_type.is_dir() {
dirs.push(entry.path());
} else if file_type.is_file() {
files.push(entry.path());
}
}
}
Ok(files)
}
async fn read_head(path: &Path, n: usize) -> Result<Vec<u8>> {
use tokio::io::AsyncReadExt;
let mut file = tokio::fs::File::open(path).await?;
let mut buf = vec![0u8; n];
let mut filled = 0;
loop {
let read = file.read(&mut buf[filled..]).await?;
if read == 0 || filled + read == n {
filled += read;
break;
}
filled += read;
}
buf.truncate(filled);
Ok(buf)
}
async fn placeholder_text_file(file: &Path, prefix: &[u8]) -> Result<bool> {
let bytes = tokio::fs::read(file).await?;
let Some(patched) = replace_bytes(&bytes, prefix, TEXT_PLACEHOLDER.as_bytes()) else {
return Ok(false);
};
write_preserving_mode(file, &patched).await?;
Ok(true)
}
async fn text_file_contains(file: &Path, prefix: &[u8]) -> Result<bool> {
let bytes = tokio::fs::read(file).await?;
Ok(find_subslice(&bytes, prefix).is_some())
}
async fn write_preserving_mode(file: &Path, bytes: &[u8]) -> Result<()> {
let perms = tokio::fs::metadata(file).await?.permissions();
tokio::fs::write(file, bytes).await?;
tokio::fs::set_permissions(file, perms).await?;
Ok(())
}
fn replace_bytes(data: &[u8], old: &[u8], new: &[u8]) -> Option<Vec<u8>> {
if old.is_empty() {
return None;
}
let mut out = Vec::with_capacity(data.len());
let mut i = 0;
let mut found = false;
while i < data.len() {
if data[i..].starts_with(old) {
out.extend_from_slice(new);
i += old.len();
found = true;
} else {
out.push(data[i]);
i += 1;
}
}
found.then_some(out)
}
fn patch_bytes_null_padded(data: &mut [u8], old: &[u8], new: &[u8]) -> usize {
debug_assert!(new.len() <= old.len(), "null-pad patch needs new <= old");
if old.is_empty() {
return 0;
}
let mut count = 0;
let mut i = 0;
while i + old.len() <= data.len() {
if &data[i..i + old.len()] == old {
data[i..i + new.len()].copy_from_slice(new);
data[i + new.len()..i + old.len()].fill(0);
i += old.len();
count += 1;
} else {
i += 1;
}
}
count
}
async fn scan_residue(file: &Path, prefix: &[u8]) -> Result<Option<ResidueHit>> {
let bytes = tokio::fs::read(file).await?;
Ok(find_subslice(&bytes, prefix).map(|at| ResidueHit {
file: file.to_path_buf(),
sample: sample_around(&bytes, at, prefix.len()),
}))
}
fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() || haystack.len() < needle.len() {
return None;
}
haystack.windows(needle.len()).position(|w| w == needle)
}
fn sample_around(bytes: &[u8], at: usize, needle_len: usize) -> String {
let start = at.saturating_sub(40);
let end = (at + needle_len + 40).min(bytes.len());
String::from_utf8_lossy(&bytes[start..end])
.chars()
.map(|c| if c.is_control() { '.' } else { c })
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_macho_magic_variants() {
for magic in MACHO_MAGICS {
let mut bytes = magic.to_vec();
bytes.extend_from_slice(&[0u8; 60]);
assert_eq!(classify_bytes(&bytes), FileClass::MachO, "{magic:02x?}");
}
}
#[test]
fn classify_text_and_binary() {
assert_eq!(
classify_bytes(b"prefix=/opt/tc\nlibdir=${prefix}/lib\n"),
FileClass::Text
);
assert_eq!(
classify_bytes(b"\x01\x02\x00binary"),
FileClass::OtherBinary
);
assert_eq!(
classify_bytes(&[0xff, 0xfe, b'a', b'b']),
FileClass::OtherBinary
);
let mut boundary = vec![b'a'; CLASSIFY_SAMPLE_LEN - 1];
boundary.extend_from_slice("é".as_bytes());
assert!(boundary.len() > CLASSIFY_SAMPLE_LEN);
assert_eq!(classify_bytes(&boundary), FileClass::Text);
}
#[test]
fn loader_path_hops() {
assert_eq!(
loader_path_to(Path::new("/tc/bin"), Path::new("/tc/lib/libgreet.dylib")),
"@loader_path/../lib/libgreet.dylib"
);
assert_eq!(
loader_path_to(Path::new("/tc/lib"), Path::new("/tc/lib/liba.dylib")),
"@loader_path/liba.dylib"
);
assert_eq!(
loader_path_to(Path::new("/tc/lib"), Path::new("/tc/lib")),
"@loader_path"
);
assert_eq!(
loader_path_to(
Path::new("/tc/libexec/git-core"),
Path::new("/tc/lib/libz.dylib")
),
"@loader_path/../../lib/libz.dylib"
);
}
#[test]
fn parse_otool_load_commands() {
let otool = "\
/tc/bin/tool:
Load command 3
cmd LC_ID_DYLIB
cmdsize 56
name /tc/lib/libgreet.dylib (offset 24)
Load command 12
cmd LC_LOAD_DYLIB
cmdsize 56
name /usr/lib/libSystem.B.dylib (offset 24)
Load command 13
cmd LC_LOAD_DYLIB
cmdsize 72
name /dep/tc/lib/libdep.dylib (offset 24)
Load command 14
cmd LC_RPATH
cmdsize 32
path /tc/lib (offset 12)
Load command 15
cmd LC_LOAD_DYLIB
cmdsize 56
name /usr/lib/libSystem.B.dylib (offset 24)
";
let cmds = parse_load_commands(otool);
assert_eq!(cmds.id.as_deref(), Some("/tc/lib/libgreet.dylib"));
assert_eq!(
cmds.loads,
vec![
"/usr/lib/libSystem.B.dylib".to_string(),
"/dep/tc/lib/libdep.dylib".to_string()
]
);
assert_eq!(cmds.rpaths, vec!["/tc/lib".to_string()]);
}
#[tokio::test]
async fn text_placeholder_round_trip_to_new_prefix() {
let tmp = tempfile::tempdir().unwrap();
let tc = tmp.path().join("tc");
let prefix_str = tc.to_string_lossy().into_owned();
let pc = tc.join("lib/pkgconfig/foo.pc");
tokio::fs::create_dir_all(pc.parent().unwrap())
.await
.unwrap();
let original = format!(
"prefix={prefix_str}\nlibdir={prefix_str}/lib\nCflags: -I{prefix_str}/include\n"
);
tokio::fs::write(&pc, &original).await.unwrap();
let readme = tc.join("README");
tokio::fs::write(&readme, "no prefix here\n").await.unwrap();
let report = make_relocatable(&tc, &tc, &[]).await.unwrap();
assert_eq!(report.text_files_with_prefix, vec![pc.clone()]);
assert!(report.is_fully_relocatable());
assert_eq!(
tokio::fs::read_to_string(&pc).await.unwrap(),
original,
"live .pc must still carry the real prefix after make_relocatable"
);
apply_text_placeholders(&tc, &tc, &tc, &report.text_files_with_prefix)
.await
.unwrap();
let placeholdered = tokio::fs::read_to_string(&pc).await.unwrap();
assert!(!placeholdered.contains(&prefix_str));
assert_eq!(placeholdered.matches(TEXT_PLACEHOLDER).count(), 3);
let new_prefix = Path::new("/opt/zlayer/toolchains/foo-1.0-arm64");
relocate_pulled(&tc, &prefix_str, new_prefix, true)
.await
.unwrap();
let restored = tokio::fs::read_to_string(&pc).await.unwrap();
assert!(!restored.contains(TEXT_PLACEHOLDER));
assert_eq!(
restored,
format!(
"prefix={p}\nlibdir={p}/lib\nCflags: -I{p}/include\n",
p = new_prefix.display()
)
);
assert_eq!(
tokio::fs::read_to_string(&readme).await.unwrap(),
"no prefix here\n"
);
}
#[tokio::test]
async fn residue_scan_reports_embedded_prefix() {
let tmp = tempfile::tempdir().unwrap();
let tc = tmp.path().join("tc");
let prefix_str = tc.to_string_lossy().into_owned();
let blob = tc.join("libexec/tool.bin");
tokio::fs::create_dir_all(blob.parent().unwrap())
.await
.unwrap();
let mut bytes = b"\x7fELF\x00\x01\x02".to_vec();
bytes.extend_from_slice(prefix_str.as_bytes());
bytes.extend_from_slice(b"/libexec/helper\x00trailing");
tokio::fs::write(&blob, &bytes).await.unwrap();
let report = make_relocatable(&tc, &tc, &[]).await.unwrap();
assert!(!report.is_fully_relocatable());
assert_eq!(report.residue.len(), 1);
let hit = &report.residue[0];
assert_eq!(hit.file, blob);
assert!(hit.sample.contains("/libexec/helper"), "{}", hit.sample);
assert!(report.text_files_with_prefix.is_empty());
}
#[test]
fn null_padded_patch_pads_and_preserves_length() {
let old = b"/build/prefix/tc";
let new = b"/opt/t";
let mut data = Vec::new();
data.extend_from_slice(b"AA");
data.extend_from_slice(old);
data.extend_from_slice(b"/bin/tool\x00");
data.extend_from_slice(old);
data.extend_from_slice(b"ZZ");
let original_len = data.len();
assert_eq!(patch_bytes_null_padded(&mut data, old, new), 2);
assert_eq!(data.len(), original_len);
let mut expected = Vec::new();
expected.extend_from_slice(b"AA");
expected.extend_from_slice(new);
expected.extend_from_slice(&vec![0u8; old.len() - new.len()]);
expected.extend_from_slice(b"/bin/tool\x00");
expected.extend_from_slice(new);
expected.extend_from_slice(&vec![0u8; old.len() - new.len()]);
expected.extend_from_slice(b"ZZ");
assert_eq!(data, expected);
let mut same = b"x/build/prefix/tcx".to_vec();
assert_eq!(
patch_bytes_null_padded(&mut same, old, b"/A/BUILD/prefix2"),
1
);
assert_eq!(same, b"x/A/BUILD/prefix2x".to_vec());
}
#[tokio::test]
async fn relocate_pulled_patches_binary_null_padded() {
let tmp = tempfile::tempdir().unwrap();
let tc = tmp.path().join("tc");
let blob = tc.join("libexec/tool.bin");
tokio::fs::create_dir_all(blob.parent().unwrap())
.await
.unwrap();
let recorded = "/build/prefix/tc";
let mut bytes = b"\x00\x10\x20".to_vec();
bytes.extend_from_slice(recorded.as_bytes());
bytes.extend_from_slice(b"/bin/tool\x00tail");
tokio::fs::write(&blob, &bytes).await.unwrap();
relocate_pulled(&tc, recorded, Path::new("/opt/t"), false)
.await
.unwrap();
let patched = tokio::fs::read(&blob).await.unwrap();
assert_eq!(patched.len(), bytes.len(), "offsets must be preserved");
let mut expected = b"\x00\x10\x20".to_vec();
expected.extend_from_slice(b"/opt/t");
expected.extend_from_slice(&vec![0u8; recorded.len() - "/opt/t".len()]);
expected.extend_from_slice(b"/bin/tool\x00tail");
assert_eq!(patched, expected);
}
#[tokio::test]
async fn relocate_pulled_rejects_longer_local_prefix() {
let tmp = tempfile::tempdir().unwrap();
let tc = tmp.path().join("tc");
tokio::fs::create_dir_all(&tc).await.unwrap();
let err = relocate_pulled(
&tc,
"/short",
Path::new("/a/much/longer/local/prefix"),
false,
)
.await
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("no longer than the recorded prefix"), "{msg}");
}
async fn cc(args: &[&str]) {
let out = tokio::process::Command::new("cc")
.args(args)
.output()
.await
.expect("spawn cc");
assert!(
out.status.success(),
"cc {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[tokio::test]
#[ignore = "live: compiles a real dylib"]
async fn live_toolchain_survives_move_after_make_relocatable() {
let build_tmp = tempfile::tempdir().unwrap();
let root = build_tmp.path().canonicalize().unwrap();
let tc = root.join("tc");
let lib = tc.join("lib");
let bin = tc.join("bin");
tokio::fs::create_dir_all(&lib).await.unwrap();
tokio::fs::create_dir_all(&bin).await.unwrap();
let greet_c = root.join("greet.c");
tokio::fs::write(
&greet_c,
"#include <stdio.h>\nvoid greet(void) { printf(\"hello-from-greet\\n\"); }\n",
)
.await
.unwrap();
let main_c = root.join("main.c");
tokio::fs::write(
&main_c,
"void greet(void);\nint main(void) { greet(); return 0; }\n",
)
.await
.unwrap();
let dylib = lib.join("libgreet.dylib");
let hello = bin.join("hello");
cc(&[
"-dynamiclib",
greet_c.to_str().unwrap(),
"-o",
dylib.to_str().unwrap(),
"-install_name",
dylib.to_str().unwrap(),
"-Wl,-headerpad_max_install_names",
])
.await;
cc(&[
main_c.to_str().unwrap(),
"-o",
hello.to_str().unwrap(),
"-L",
lib.to_str().unwrap(),
"-lgreet",
"-Wl,-headerpad_max_install_names",
])
.await;
let report = make_relocatable(&tc, &tc, &[]).await.unwrap();
assert!(report.rewritten_machos.contains(&hello), "{report:?}");
assert!(report.rewritten_machos.contains(&dylib), "{report:?}");
assert!(
report.is_fully_relocatable(),
"unexpected residue: {:?}",
report.residue
);
let move_tmp = tempfile::tempdir().unwrap();
let moved = move_tmp.path().canonicalize().unwrap().join("moved-tc");
tokio::fs::rename(&tc, &moved).await.unwrap();
let moved_hello = moved.join("bin/hello");
let moved_dylib = moved.join("lib/libgreet.dylib");
let otool_l = run_host_tool("otool", &["-L", moved_hello.to_str().unwrap()])
.await
.unwrap();
assert!(
otool_l.contains("@loader_path/../lib/libgreet.dylib"),
"otool -L: {otool_l}"
);
let out = tokio::process::Command::new(&moved_hello)
.output()
.await
.expect("run moved consumer");
assert!(out.status.success(), "moved consumer must execute");
assert!(
String::from_utf8_lossy(&out.stdout).contains("hello-from-greet"),
"stdout: {}",
String::from_utf8_lossy(&out.stdout)
);
assert!(codesign_verify_ok(&moved_hello).await, "hello codesign -v");
assert!(codesign_verify_ok(&moved_dylib).await, "dylib codesign -v");
}
}