1use std::collections::HashSet;
58use std::path::{Path, PathBuf};
59
60use tracing::{debug, info};
61
62use crate::error::{Result, ToolchainError};
63
64pub const TEXT_PLACEHOLDER: &str = "@ZLAYER_TC@";
69
70#[derive(Debug, Clone)]
75pub struct ResidueHit {
76 pub file: PathBuf,
78 pub sample: String,
81}
82
83#[derive(Debug, Clone, Default)]
86pub struct RelocationReport {
87 pub rewritten_machos: Vec<PathBuf>,
89 pub bundled_dylibs: Vec<PathBuf>,
92 pub text_files_with_prefix: Vec<PathBuf>,
98 pub residue: Vec<ResidueHit>,
100}
101
102impl RelocationReport {
103 #[must_use]
106 pub fn is_fully_relocatable(&self) -> bool {
107 self.residue.is_empty()
108 }
109}
110
111pub 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 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 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
203pub 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 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
233pub 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 for file in &bin_machos {
312 if !codesign_verify_ok(file).await {
313 codesign_adhoc(file).await?;
314 }
315 }
316 Ok(())
317}
318
319const CLASSIFY_SAMPLE_LEN: usize = 8192;
325
326const MACHO_MAGICS: [[u8; 4]; 6] = [
330 [0xfe, 0xed, 0xfa, 0xce], [0xce, 0xfa, 0xed, 0xfe], [0xfe, 0xed, 0xfa, 0xcf], [0xcf, 0xfa, 0xed, 0xfe], [0xca, 0xfe, 0xba, 0xbe], [0xbe, 0xba, 0xfe, 0xca], ];
337
338#[derive(Debug, Clone, Copy, PartialEq, Eq)]
340enum FileClass {
341 MachO,
343 Text,
346 OtherBinary,
348}
349
350fn 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 Err(e) if e.error_len().is_none() => FileClass::Text,
366 Err(_) => FileClass::OtherBinary,
367 }
368}
369
370struct Relocator<'a> {
376 dir: &'a Path,
378 built_prefix: &'a Path,
380 dep_prefixes: &'a [PathBuf],
382 lib_dir: PathBuf,
384 bundled: HashSet<String>,
386 report: RelocationReport,
388}
389
390struct RewritePlan {
393 args: Vec<String>,
396 pending: Vec<PathBuf>,
398}
399
400impl Relocator<'_> {
401 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 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 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 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 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 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 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 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#[derive(Debug, Default)]
537struct MachLoadCommands {
538 id: Option<String>,
540 loads: Vec<String>,
542 rpaths: Vec<String>,
544}
545
546async 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
553fn 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
588fn 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
595async 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
607async 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
614async 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
624async 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
644fn 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
660fn 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
680async 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
703async 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
721async 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
732async 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
740async 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
748fn 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
770fn 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
794async 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
804fn 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
812fn 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#[cfg(test)]
828mod tests {
829 use super::*;
830
831 #[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 #[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 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 #[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 #[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 #[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 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 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 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 #[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 #[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 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 #[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 #[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 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 #[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 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 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}