Skip to main content

cargo_zigbuild/macos/
install_name_tool.rs

1//! A minimal implementation of `install_name_tool` for cross-compilation.
2//!
3//! Supports modifying Mach-O load commands:
4//! - `-id name`: Change LC_ID_DYLIB
5//! - `-change old new`: Change LC_LOAD_DYLIB / LC_LOAD_WEAK_DYLIB / etc.
6//! - `-add_rpath new`: Add LC_RPATH
7//! - `-delete_rpath old`: Delete LC_RPATH
8//! - `-rpath old new`: Change LC_RPATH
9//!
10//! Based on the approach from [arwen-macho](https://github.com/nichmor/arwen).
11//!
12//! TODO: Replace this custom implementation with the `arwen-macho` crate
13//! once it's published to crates.io.
14
15use std::ffi::{CStr, OsString};
16use std::path::Path;
17
18use anyhow::{Context, Result, bail};
19use goblin::container;
20use goblin::mach::fat;
21use goblin::mach::header::{Header, SIZEOF_HEADER_32, SIZEOF_HEADER_64};
22use goblin::mach::load_command::{
23    CommandVariant, DylibCommand, LC_RPATH, LoadCommand, RpathCommand, SIZEOF_RPATH_COMMAND,
24};
25use goblin::mach::{MachO, MultiArch, parse_magic_and_ctx, peek};
26use scroll::Pwrite;
27
28/// Parsed command-line arguments for install_name_tool
29#[derive(Debug, Default)]
30struct Args {
31    id: Option<String>,
32    changes: Vec<(String, String)>,
33    rpaths: Vec<(String, String)>,
34    add_rpaths: Vec<String>,
35    delete_rpaths: Vec<String>,
36    input: Option<String>,
37}
38
39fn parse_args(args: &[String]) -> Result<Args> {
40    let mut parsed = Args::default();
41    let mut i = 0;
42    while i < args.len() {
43        match args[i].as_str() {
44            "-id" => {
45                if i + 1 >= args.len() {
46                    bail!("-id requires an argument");
47                }
48                parsed.id = Some(args[i + 1].clone());
49                i += 2;
50            }
51            "-change" => {
52                if i + 2 >= args.len() {
53                    bail!("-change requires two arguments");
54                }
55                parsed
56                    .changes
57                    .push((args[i + 1].clone(), args[i + 2].clone()));
58                i += 3;
59            }
60            "-rpath" => {
61                if i + 2 >= args.len() {
62                    bail!("-rpath requires two arguments");
63                }
64                parsed
65                    .rpaths
66                    .push((args[i + 1].clone(), args[i + 2].clone()));
67                i += 3;
68            }
69            "-add_rpath" => {
70                if i + 1 >= args.len() {
71                    bail!("-add_rpath requires an argument");
72                }
73                parsed.add_rpaths.push(args[i + 1].clone());
74                i += 2;
75            }
76            "-delete_rpath" => {
77                if i + 1 >= args.len() {
78                    bail!("-delete_rpath requires an argument");
79                }
80                parsed.delete_rpaths.push(args[i + 1].clone());
81                i += 2;
82            }
83            arg if arg.starts_with('-') => {
84                bail!("unknown option: {arg}");
85            }
86            _ => {
87                if parsed.input.is_some() {
88                    bail!("multiple input files not supported");
89                }
90                parsed.input = Some(args[i].clone());
91                i += 1;
92            }
93        }
94    }
95    if parsed.input.is_none() {
96        bail!("no input file specified");
97    }
98    Ok(parsed)
99}
100
101// -- Header helpers --
102
103fn header_size(ctx: container::Ctx) -> usize {
104    if ctx.container.is_big() {
105        SIZEOF_HEADER_64
106    } else {
107        SIZEOF_HEADER_32
108    }
109}
110
111/// Align size to pointer width (4 for 32-bit, 8 for 64-bit)
112fn align_to_ctx(size: usize, ctx: container::Ctx) -> usize {
113    if ctx.container.is_big() {
114        size.next_multiple_of(8)
115    } else {
116        size.next_multiple_of(4)
117    }
118}
119
120// -- Load command manipulation --
121
122/// Remove a load command from the buffer and update the header.
123fn remove_load_command(
124    buffer: &mut Vec<u8>,
125    header: &mut Header,
126    ctx: container::Ctx,
127    cmd_offset: usize,
128    cmdsize: usize,
129) -> Result<()> {
130    buffer.drain(cmd_offset..cmd_offset + cmdsize);
131
132    header.ncmds -= 1;
133    header.sizeofcmds -= cmdsize as u32;
134
135    // Insert zero padding after remaining load commands to keep file size stable
136    let padding_offset = header_size(ctx) + header.sizeofcmds as usize;
137    let zeroes = vec![0u8; cmdsize];
138    let tail = buffer.split_off(padding_offset);
139    buffer.extend(&zeroes);
140    buffer.extend(tail);
141
142    buffer.pwrite_with(*header, 0, ctx)?;
143    Ok(())
144}
145
146/// Insert a new load command at the given offset and update the header.
147fn insert_load_command(
148    buffer: &mut Vec<u8>,
149    header: &mut Header,
150    ctx: container::Ctx,
151    offset: usize,
152    cmd_data: &[u8],
153) -> Result<()> {
154    let new_cmd_size = cmd_data.len() as u32;
155
156    header.ncmds += 1;
157    header.sizeofcmds += new_cmd_size;
158
159    // Insert the new command bytes
160    let tail = buffer.split_off(offset);
161    buffer.extend_from_slice(cmd_data);
162    buffer.extend(tail);
163
164    // Drain surplus padding to keep file size stable
165    let drain_start = header_size(ctx) + header.sizeofcmds as usize;
166    let drain_end = drain_start + new_cmd_size as usize;
167    if drain_end <= buffer.len() {
168        buffer.drain(drain_start..drain_end);
169    }
170
171    buffer.pwrite_with(*header, 0, ctx)?;
172    Ok(())
173}
174
175/// Build a serialized LC_RPATH command
176fn build_rpath_command(path: &str, ctx: container::Ctx) -> Result<(RpathCommand, Vec<u8>)> {
177    let c_str = format!("{path}\0");
178    let c_str = CStr::from_bytes_with_nul(c_str.as_bytes())?;
179    let str_size = (c_str.count_bytes() + 1).next_multiple_of(4);
180    let cmdsize = align_to_ctx(SIZEOF_RPATH_COMMAND + str_size, ctx);
181
182    let rpath_cmd = RpathCommand {
183        cmd: LC_RPATH,
184        cmdsize: cmdsize as u32,
185        path: SIZEOF_RPATH_COMMAND as u32,
186    };
187
188    let mut buf = vec![0u8; cmdsize];
189    buf.pwrite(rpath_cmd, 0)?;
190    buf.pwrite(c_str, SIZEOF_RPATH_COMMAND)?;
191    Ok((rpath_cmd, buf))
192}
193
194/// Build a serialized DylibCommand (for LC_ID_DYLIB, LC_LOAD_DYLIB, etc.)
195fn build_dylib_command(
196    name: &str,
197    old_cmd: &DylibCommand,
198    ctx: container::Ctx,
199) -> Result<(DylibCommand, Vec<u8>)> {
200    let c_str = format!("{name}\0");
201    let c_str = CStr::from_bytes_with_nul(c_str.as_bytes())?;
202    let str_size = (c_str.count_bytes() + 1).next_multiple_of(4);
203    // DylibCommand header: cmd(4) + cmdsize(4) + name_offset(4) + timestamp(4) + current_version(4) + compat_version(4) = 24
204    let dylib_header_size: usize = 24;
205    let cmdsize = align_to_ctx(dylib_header_size + str_size, ctx);
206
207    let new_cmd = DylibCommand {
208        cmd: old_cmd.cmd,
209        cmdsize: cmdsize as u32,
210        dylib: goblin::mach::load_command::Dylib {
211            name: dylib_header_size as u32,
212            timestamp: old_cmd.dylib.timestamp,
213            current_version: old_cmd.dylib.current_version,
214            compatibility_version: old_cmd.dylib.compatibility_version,
215        },
216    };
217
218    let mut buf = vec![0u8; cmdsize];
219    buf.pwrite(new_cmd, 0)?;
220    buf.pwrite(c_str, dylib_header_size)?;
221    Ok((new_cmd, buf))
222}
223
224// -- Command finders --
225
226/// Read the string name from a dylib load command in the raw data
227fn read_dylib_name<'a>(data: &'a [u8], lc: &LoadCommand, dylib_cmd: &DylibCommand) -> &'a str {
228    let name_offset = lc.offset + dylib_cmd.dylib.name as usize;
229    let cmd_end = lc.offset + dylib_cmd.cmdsize as usize;
230    let name_end = data[name_offset..cmd_end]
231        .iter()
232        .position(|&b| b == 0)
233        .map(|p| name_offset + p)
234        .unwrap_or(cmd_end);
235    std::str::from_utf8(&data[name_offset..name_end]).unwrap_or("")
236}
237
238/// Read the string path from an rpath load command in the raw data
239fn read_rpath_path<'a>(data: &'a [u8], lc: &LoadCommand, rpath_cmd: &RpathCommand) -> &'a str {
240    let path_offset = lc.offset + rpath_cmd.path as usize;
241    let cmd_end = lc.offset + rpath_cmd.cmdsize as usize;
242    let path_end = data[path_offset..cmd_end]
243        .iter()
244        .position(|&b| b == 0)
245        .map(|p| path_offset + p)
246        .unwrap_or(cmd_end);
247    std::str::from_utf8(&data[path_offset..path_end]).unwrap_or("")
248}
249
250// -- Single Mach-O processing --
251
252/// Process a single Mach-O binary. The buffer must start at the Mach-O header (offset 0).
253fn process_single_macho(data: &mut Vec<u8>, args: &Args) -> Result<()> {
254    let macho = MachO::parse(data, 0).context("failed to parse Mach-O")?;
255    let (_, maybe_ctx) = parse_magic_and_ctx(data, 0)?;
256    let ctx = maybe_ctx.context("could not determine endianness")?;
257    let mut header = macho.header;
258
259    // -id: change LC_ID_DYLIB
260    if let Some(ref new_id) = args.id {
261        let mut found = false;
262        for lc in &macho.load_commands {
263            if let CommandVariant::IdDylib(ref dylib_cmd) = lc.command {
264                let cmdsize = lc.command.cmdsize();
265                let (_, new_cmd_buf) = build_dylib_command(new_id, dylib_cmd, ctx)?;
266                remove_load_command(data, &mut header, ctx, lc.offset, cmdsize)?;
267                insert_load_command(data, &mut header, ctx, lc.offset, &new_cmd_buf)?;
268                found = true;
269                break;
270            }
271        }
272        if !found {
273            bail!("no LC_ID_DYLIB found in binary");
274        }
275    }
276
277    // After modifying the binary, we need to re-parse to get updated offsets.
278    // For -change, -rpath, -delete_rpath, -add_rpath we re-parse each time.
279
280    // -change: change dylib load names
281    for (old_name, new_name) in &args.changes {
282        let macho = MachO::parse(data, 0).context("failed to re-parse Mach-O")?;
283        let (_, maybe_ctx) = parse_magic_and_ctx(data, 0)?;
284        let ctx = maybe_ctx.context("could not determine endianness")?;
285        let mut header = macho.header;
286
287        let mut found = false;
288        for lc in &macho.load_commands {
289            let dylib_cmd = match &lc.command {
290                CommandVariant::LoadDylib(cmd)
291                | CommandVariant::LoadWeakDylib(cmd)
292                | CommandVariant::ReexportDylib(cmd)
293                | CommandVariant::LazyLoadDylib(cmd)
294                | CommandVariant::LoadUpwardDylib(cmd) => cmd,
295                _ => continue,
296            };
297            let name = read_dylib_name(data, lc, dylib_cmd);
298            if name == old_name.as_str() {
299                let cmdsize = lc.command.cmdsize();
300                let (_, new_cmd_buf) = build_dylib_command(new_name, dylib_cmd, ctx)?;
301                remove_load_command(data, &mut header, ctx, lc.offset, cmdsize)?;
302                insert_load_command(data, &mut header, ctx, lc.offset, &new_cmd_buf)?;
303                found = true;
304                break;
305            }
306        }
307        if !found {
308            bail!("no LC_LOAD_DYLIB with name '{old_name}' found");
309        }
310    }
311
312    // -rpath: change rpath
313    for (old_rpath, new_rpath) in &args.rpaths {
314        let macho = MachO::parse(data, 0).context("failed to re-parse Mach-O")?;
315        let (_, maybe_ctx) = parse_magic_and_ctx(data, 0)?;
316        let ctx = maybe_ctx.context("could not determine endianness")?;
317        let mut header = macho.header;
318
319        let mut found = false;
320        for lc in &macho.load_commands {
321            if let CommandVariant::Rpath(ref rpath_cmd) = lc.command {
322                let path = read_rpath_path(data, lc, rpath_cmd);
323                if path == old_rpath.as_str() {
324                    let cmdsize = lc.command.cmdsize();
325                    let (_, new_cmd_buf) = build_rpath_command(new_rpath, ctx)?;
326                    remove_load_command(data, &mut header, ctx, lc.offset, cmdsize)?;
327                    insert_load_command(data, &mut header, ctx, lc.offset, &new_cmd_buf)?;
328                    found = true;
329                    break;
330                }
331            }
332        }
333        if !found {
334            bail!("no LC_RPATH with path '{old_rpath}' found");
335        }
336    }
337
338    // -delete_rpath
339    for del_rpath in &args.delete_rpaths {
340        let macho = MachO::parse(data, 0).context("failed to re-parse Mach-O")?;
341        let (_, maybe_ctx) = parse_magic_and_ctx(data, 0)?;
342        let ctx = maybe_ctx.context("could not determine endianness")?;
343        let mut header = macho.header;
344
345        let mut found = false;
346        for lc in &macho.load_commands {
347            if let CommandVariant::Rpath(ref rpath_cmd) = lc.command {
348                let path = read_rpath_path(data, lc, rpath_cmd);
349                if path == del_rpath.as_str() {
350                    let cmdsize = lc.command.cmdsize();
351                    remove_load_command(data, &mut header, ctx, lc.offset, cmdsize)?;
352                    found = true;
353                    break;
354                }
355            }
356        }
357        if !found {
358            bail!("no LC_RPATH with path '{del_rpath}' found");
359        }
360    }
361
362    // -add_rpath
363    for new_rpath in &args.add_rpaths {
364        let macho = MachO::parse(data, 0).context("failed to re-parse Mach-O")?;
365        let (_, maybe_ctx) = parse_magic_and_ctx(data, 0)?;
366        let ctx = maybe_ctx.context("could not determine endianness")?;
367        let mut header = macho.header;
368
369        let insert_offset = header_size(ctx) + header.sizeofcmds as usize;
370        let (_, new_cmd_buf) = build_rpath_command(new_rpath, ctx)?;
371        insert_load_command(data, &mut header, ctx, insert_offset, &new_cmd_buf)?;
372    }
373
374    Ok(())
375}
376
377// -- Top-level file processing --
378
379fn process_file(path: &Path, args: &Args) -> Result<()> {
380    let mut data =
381        fs_err::read(path).with_context(|| format!("failed to read '{}'", path.display()))?;
382
383    let magic = peek(&data, 0)?;
384
385    match magic {
386        fat::FAT_MAGIC => {
387            let multi = MultiArch::new(&data)?;
388            let arches: Vec<_> = multi.iter_arches().collect::<std::result::Result<_, _>>()?;
389
390            // Process each arch slice independently, then splice it back.
391            // Process from last to first so that offset changes don't affect earlier slices.
392            for arch in arches.iter().rev() {
393                let offset = arch.offset as usize;
394                let size = arch.size as usize;
395                let mut slice = data[offset..offset + size].to_vec();
396                process_single_macho(&mut slice, args)?;
397                data.splice(offset..offset + size, slice);
398            }
399        }
400        _ => {
401            // Single Mach-O (or will fail inside process_single_macho)
402            process_single_macho(&mut data, args)?;
403        }
404    }
405
406    fs_err::write(path, &data).with_context(|| format!("failed to write '{}'", path.display()))?;
407
408    Ok(())
409}
410
411/// Execute install_name_tool with the given arguments
412pub fn execute(args: impl IntoIterator<Item = impl Into<OsString>>) -> Result<()> {
413    let args: Vec<String> = args
414        .into_iter()
415        .map(|a| a.into().to_string_lossy().into_owned())
416        .collect();
417    let parsed = parse_args(&args)?;
418    let input = parsed.input.as_ref().unwrap();
419    process_file(Path::new(input), &parsed)
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use std::path::PathBuf;
426
427    fn fixtures_dir() -> PathBuf {
428        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
429    }
430
431    /// Copy a fixture to a temp file for modification
432    fn copy_fixture(name: &str) -> tempfile::NamedTempFile {
433        let src = fixtures_dir().join(name);
434        let mut tmp = tempfile::NamedTempFile::new().unwrap();
435        std::io::copy(&mut fs_err::File::open(src).unwrap(), &mut tmp).unwrap();
436        tmp
437    }
438
439    /// Read the LC_ID_DYLIB name from a single Mach-O slice
440    fn read_id(data: &[u8]) -> Option<String> {
441        let macho = MachO::parse(data, 0).unwrap();
442        macho.name.map(|s| s.to_string())
443    }
444
445    /// Read all rpaths from a single Mach-O slice
446    fn read_rpaths(data: &[u8]) -> Vec<String> {
447        let macho = MachO::parse(data, 0).unwrap();
448        macho.rpaths.iter().map(|s| s.to_string()).collect()
449    }
450
451    /// For fat binaries, get the slices as (offset, size) pairs
452    fn fat_slices(data: &[u8]) -> Vec<(usize, usize)> {
453        let multi = MultiArch::new(data).unwrap();
454        multi
455            .iter_arches()
456            .map(|a| {
457                let a = a.unwrap();
458                (a.offset as usize, a.size as usize)
459            })
460            .collect()
461    }
462
463    // -- Tests for single-arch (aarch64) --
464
465    #[test]
466    fn test_change_id_aarch64() {
467        let tmp = copy_fixture("test_aarch64.dylib");
468        execute(["-id", "/new/lib/test.dylib", tmp.path().to_str().unwrap()]).unwrap();
469
470        let data = fs_err::read(tmp.path()).unwrap();
471        assert_eq!(read_id(&data).as_deref(), Some("/new/lib/test.dylib"));
472    }
473
474    #[test]
475    fn test_change_rpath_aarch64() {
476        let tmp = copy_fixture("test_aarch64.dylib");
477        execute([
478            "-rpath",
479            "/old/rpath",
480            "/new/rpath",
481            tmp.path().to_str().unwrap(),
482        ])
483        .unwrap();
484
485        let data = fs_err::read(tmp.path()).unwrap();
486        let rpaths = read_rpaths(&data);
487        assert_eq!(rpaths, vec!["/new/rpath"]);
488    }
489
490    #[test]
491    fn test_delete_rpath_aarch64() {
492        let tmp = copy_fixture("test_aarch64.dylib");
493        execute(["-delete_rpath", "/old/rpath", tmp.path().to_str().unwrap()]).unwrap();
494
495        let data = fs_err::read(tmp.path()).unwrap();
496        let rpaths = read_rpaths(&data);
497        assert!(rpaths.is_empty());
498    }
499
500    #[test]
501    fn test_add_rpath_aarch64() {
502        let tmp = copy_fixture("test_aarch64.dylib");
503        execute(["-add_rpath", "/added/rpath", tmp.path().to_str().unwrap()]).unwrap();
504
505        let data = fs_err::read(tmp.path()).unwrap();
506        let rpaths = read_rpaths(&data);
507        assert!(rpaths.contains(&"/old/rpath".to_string()));
508        assert!(rpaths.contains(&"/added/rpath".to_string()));
509    }
510
511    // -- Tests for single-arch (x86_64) --
512
513    #[test]
514    fn test_change_id_x86_64() {
515        let tmp = copy_fixture("test_x86_64.dylib");
516        execute(["-id", "/new/lib/test.dylib", tmp.path().to_str().unwrap()]).unwrap();
517
518        let data = fs_err::read(tmp.path()).unwrap();
519        assert_eq!(read_id(&data).as_deref(), Some("/new/lib/test.dylib"));
520    }
521
522    #[test]
523    fn test_change_rpath_x86_64() {
524        let tmp = copy_fixture("test_x86_64.dylib");
525        execute([
526            "-rpath",
527            "/old/rpath",
528            "/new/rpath",
529            tmp.path().to_str().unwrap(),
530        ])
531        .unwrap();
532
533        let data = fs_err::read(tmp.path()).unwrap();
534        let rpaths = read_rpaths(&data);
535        assert_eq!(rpaths, vec!["/new/rpath"]);
536    }
537
538    // -- Tests for fat (universal2) binary --
539
540    #[test]
541    fn test_change_id_universal2() {
542        let tmp = copy_fixture("test_universal2.dylib");
543        execute(["-id", "/new/lib/test.dylib", tmp.path().to_str().unwrap()]).unwrap();
544
545        let data = fs_err::read(tmp.path()).unwrap();
546        for (offset, size) in fat_slices(&data) {
547            let slice = &data[offset..offset + size];
548            assert_eq!(read_id(slice).as_deref(), Some("/new/lib/test.dylib"));
549        }
550    }
551
552    #[test]
553    fn test_change_rpath_universal2() {
554        let tmp = copy_fixture("test_universal2.dylib");
555        execute([
556            "-rpath",
557            "/old/rpath",
558            "/new/rpath",
559            tmp.path().to_str().unwrap(),
560        ])
561        .unwrap();
562
563        let data = fs_err::read(tmp.path()).unwrap();
564        for (offset, size) in fat_slices(&data) {
565            let slice = &data[offset..offset + size];
566            assert_eq!(read_rpaths(slice), vec!["/new/rpath"]);
567        }
568    }
569
570    #[test]
571    fn test_delete_rpath_universal2() {
572        let tmp = copy_fixture("test_universal2.dylib");
573        execute(["-delete_rpath", "/old/rpath", tmp.path().to_str().unwrap()]).unwrap();
574
575        let data = fs_err::read(tmp.path()).unwrap();
576        for (offset, size) in fat_slices(&data) {
577            let slice = &data[offset..offset + size];
578            assert!(read_rpaths(slice).is_empty());
579        }
580    }
581
582    #[test]
583    fn test_add_rpath_universal2() {
584        let tmp = copy_fixture("test_universal2.dylib");
585        execute(["-add_rpath", "/added/rpath", tmp.path().to_str().unwrap()]).unwrap();
586
587        let data = fs_err::read(tmp.path()).unwrap();
588        for (offset, size) in fat_slices(&data) {
589            let slice = &data[offset..offset + size];
590            let rpaths = read_rpaths(slice);
591            assert!(rpaths.contains(&"/old/rpath".to_string()));
592            assert!(rpaths.contains(&"/added/rpath".to_string()));
593        }
594    }
595
596    // -- Combined operations --
597
598    #[test]
599    fn test_multiple_operations_aarch64() {
600        let tmp = copy_fixture("test_aarch64.dylib");
601        // Change id and rpath in separate calls (like real usage)
602        execute(["-id", "/new/id.dylib", tmp.path().to_str().unwrap()]).unwrap();
603        execute([
604            "-rpath",
605            "/old/rpath",
606            "/replaced/rpath",
607            tmp.path().to_str().unwrap(),
608        ])
609        .unwrap();
610        execute(["-add_rpath", "/extra/rpath", tmp.path().to_str().unwrap()]).unwrap();
611
612        let data = fs_err::read(tmp.path()).unwrap();
613        assert_eq!(read_id(&data).as_deref(), Some("/new/id.dylib"));
614        let rpaths = read_rpaths(&data);
615        assert!(rpaths.contains(&"/replaced/rpath".to_string()));
616        assert!(rpaths.contains(&"/extra/rpath".to_string()));
617        assert!(!rpaths.contains(&"/old/rpath".to_string()));
618    }
619
620    // -- Error cases --
621
622    #[test]
623    fn test_delete_nonexistent_rpath_fails() {
624        let tmp = copy_fixture("test_aarch64.dylib");
625        let result = execute([
626            "-delete_rpath",
627            "/nonexistent",
628            tmp.path().to_str().unwrap(),
629        ]);
630        assert!(result.is_err());
631    }
632
633    #[test]
634    fn test_change_nonexistent_rpath_fails() {
635        let tmp = copy_fixture("test_aarch64.dylib");
636        let result = execute([
637            "-rpath",
638            "/nonexistent",
639            "/new",
640            tmp.path().to_str().unwrap(),
641        ]);
642        assert!(result.is_err());
643    }
644}