Skip to main content

mise_brew_relocation/
lib.rs

1//! Replace Homebrew's bottling placeholders with real paths — the same work
2//! `brew` does when pouring a bottle (Library/Homebrew/keg_relocate.rb).
3//!
4//! Because we always install at the canonical prefix, placeholder
5//! replacements shrink or stay nearly the same size:
6//!   @@HOMEBREW_PREFIX@@ (19) -> /opt/homebrew (13)
7//!   @@HOMEBREW_CELLAR@@ (19) -> /opt/homebrew/Cellar (20)
8//!
9//! Text files get plain string replacement. For shebang executables with binary
10//! payloads, such as zipapps, only the shebang is replaced so offsets and
11//! checksums in the payload remain intact. Mach-O binaries get in-place C-string
12//! replacement: the new string must fit in the existing string's slot (its
13//! bytes plus any trailing NUL padding, keeping one terminator). Replacements
14//! that shrink always fit; the +1-byte Cellar case fits unless the original
15//! string ended exactly at its slot boundary, which we detect and report as an
16//! error rather than corrupt the binary.
17
18#![cfg(unix)]
19
20mod elf;
21mod macho;
22
23use std::collections::HashSet;
24use std::os::unix::fs::MetadataExt;
25use std::path::{Path, PathBuf};
26
27use eyre::{WrapErr, bail};
28
29use eyre::Result;
30
31struct Replacement {
32    pub placeholder: &'static [u8],
33    pub value: Vec<u8>,
34}
35
36fn standard_replacements(prefix: &Path, repository: &Path) -> Vec<Replacement> {
37    let prefix = prefix.to_string_lossy();
38    let repository = repository.to_string_lossy();
39    let macos = cfg!(target_os = "macos");
40    vec![
41        Replacement {
42            placeholder: b"@@HOMEBREW_PREFIX@@",
43            value: prefix.as_bytes().to_vec(),
44        },
45        Replacement {
46            placeholder: b"@@HOMEBREW_CELLAR@@",
47            value: format!("{prefix}/Cellar").into_bytes(),
48        },
49        Replacement {
50            placeholder: b"@@HOMEBREW_REPOSITORY@@",
51            value: repository.as_bytes().to_vec(),
52        },
53        Replacement {
54            placeholder: b"@@HOMEBREW_LIBRARY@@",
55            value: format!("{repository}/Library").into_bytes(),
56        },
57        Replacement {
58            placeholder: b"@@HOMEBREW_PERL@@",
59            // matches brew: system perl on macOS, brewed perl on Linux
60            value: if macos {
61                b"/usr/bin/perl".to_vec()
62            } else {
63                format!("{prefix}/opt/perl/bin/perl").into_bytes()
64            },
65        },
66        Replacement {
67            placeholder: b"@@HOMEBREW_JAVA@@",
68            value: if macos {
69                format!("{prefix}/opt/openjdk/libexec/openjdk.jdk/Contents/Home").into_bytes()
70            } else {
71                format!("{prefix}/opt/openjdk/libexec").into_bytes()
72            },
73        },
74    ]
75}
76
77#[derive(Debug, Default)]
78pub struct RelocationReport {
79    /// files whose contents were modified
80    pub changed_files: Vec<PathBuf>,
81    /// modified Mach-O binaries that must be re-codesigned
82    pub changed_machos: Vec<PathBuf>,
83}
84
85fn is_macho(content: &[u8]) -> bool {
86    if content.len() < 4 {
87        return false;
88    }
89    matches!(
90        u32::from_be_bytes([content[0], content[1], content[2], content[3]]),
91        0xfeedface | 0xcefaedfe | 0xfeedfacf | 0xcffaedfe | 0xcafebabe | 0xbebafeca
92    )
93}
94
95/// Return the end of a valid shebang interpreter within Homebrew's 1 KiB
96/// inspection window. The line ending is excluded from the returned range.
97fn text_executable_shebang_end(content: &[u8]) -> Option<usize> {
98    let prefix = content.get(..1024.min(content.len()))?;
99    let rest = prefix.strip_prefix(b"#!")?;
100    let line_end = rest
101        .iter()
102        .position(|&b| b == b'\n' || b == b'\r')
103        .unwrap_or(rest.len());
104    let interpreter = &rest[..line_end];
105    (!interpreter.contains(&0) && interpreter.iter().any(|b| !b.is_ascii_whitespace()))
106        .then_some(2 + line_end)
107}
108
109fn contains_any_placeholder(content: &[u8], replacements: &[Replacement]) -> bool {
110    replacements
111        .iter()
112        .any(|r| memmem(content, r.placeholder).is_some())
113}
114
115fn memmem(haystack: &[u8], needle: &[u8]) -> Option<usize> {
116    haystack
117        .windows(needle.len())
118        .position(|window| window == needle)
119}
120
121/// Plain replacement for text files
122fn replace_text(content: &[u8], replacements: &[Replacement]) -> Vec<u8> {
123    let mut out = content.to_vec();
124    for r in replacements {
125        let mut result = Vec::with_capacity(out.len());
126        let mut rest: &[u8] = &out;
127        while let Some(pos) = memmem(rest, r.placeholder) {
128            result.extend_from_slice(&rest[..pos]);
129            result.extend_from_slice(&r.value);
130            rest = &rest[pos + r.placeholder.len()..];
131        }
132        result.extend_from_slice(rest);
133        out = result;
134    }
135    out
136}
137
138/// Replace placeholders only in the shebang preamble of a binary-backed text
139/// executable. ZIP readers permit a variable-length preamble, while keeping
140/// the archive bytes untouched preserves its offsets, sizes, and checksums.
141fn replace_shebang(content: &[u8], shebang_end: usize, replacements: &[Replacement]) -> Vec<u8> {
142    let shebang = replace_text(&content[..shebang_end], replacements);
143    let mut out = Vec::with_capacity(shebang.len() + content.len() - shebang_end);
144    out.extend_from_slice(&shebang);
145    out.extend_from_slice(&content[shebang_end..]);
146    out
147}
148
149/// In-place C-string replacement for binaries. Returns whether anything
150/// changed; errors if a replacement can't fit in its slot.
151fn replace_in_binary(
152    content: &mut [u8],
153    replacements: &[Replacement],
154    path: &Path,
155) -> Result<bool> {
156    let mut changed = false;
157    for r in replacements {
158        let mut search_from = 0;
159        while let Some(rel_pos) = memmem(&content[search_from..], r.placeholder) {
160            let start = search_from + rel_pos;
161            // the C-string containing this placeholder: backtrack is not
162            // needed (placeholders start strings or follow path separators we
163            // keep); find the end at the next NUL
164            let str_end = content[start..]
165                .iter()
166                .position(|&b| b == 0)
167                .map(|p| start + p)
168                .unwrap_or(content.len());
169            // available slot: the string plus the run of NULs after it,
170            // minus one NUL that must remain as terminator
171            let slot_end = content[str_end..]
172                .iter()
173                .position(|&b| b != 0)
174                .map(|p| str_end + p)
175                .unwrap_or(content.len());
176            let old = content[start..str_end].to_vec();
177            let mut new = r.value.clone();
178            new.extend_from_slice(&old[r.placeholder.len()..]);
179            let slot = slot_end.saturating_sub(start);
180            if new.len() + 1 > slot {
181                bail!(
182                    "cannot relocate {}: replacement for {} does not fit ({} > {} bytes)",
183                    path.display(),
184                    String::from_utf8_lossy(r.placeholder),
185                    new.len() + 1,
186                    slot,
187                );
188            }
189            content[start..start + new.len()].copy_from_slice(&new);
190            for b in &mut content[start + new.len()..slot_end] {
191                *b = 0;
192            }
193            changed = true;
194            search_from = start + new.len();
195        }
196    }
197    Ok(changed)
198}
199
200/// Walk a poured keg and replace placeholders. `skip_linkage` leaves binary
201/// linkage untouched while still relocating text files, matching Homebrew's
202/// handling of `:any_skip_relocation` bottles.
203pub fn relocate_keg(
204    keg: &Path,
205    formula_name: &str,
206    skip_linkage: bool,
207    prefix: &Path,
208    repository: &Path,
209) -> Result<RelocationReport> {
210    relocate_keg_with_replacements(
211        keg,
212        formula_name,
213        skip_linkage,
214        prefix,
215        &standard_replacements(prefix, repository),
216    )
217}
218
219fn relocate_keg_with_replacements(
220    keg: &Path,
221    formula_name: &str,
222    skip_linkage: bool,
223    prefix: &Path,
224    replacements: &[Replacement],
225) -> Result<RelocationReport> {
226    let elf_opts = elf::LinkageOpts::for_formula(formula_name, prefix);
227    // brew never patches glibc's own files — rewriting the dynamic linker
228    // breaks it (extend/os/linux/keg_relocate.rb)
229    let patch_elf = formula_name != "glibc" && !formula_name.starts_with("glibc@");
230    let mut report = RelocationReport::default();
231    let mut changed_macho_inodes = HashSet::new();
232    for entry in walkdir::WalkDir::new(keg).follow_links(false) {
233        let entry = entry?;
234        if !entry.file_type().is_file() {
235            continue;
236        }
237        let path = entry.path();
238        let metadata = path.metadata()?;
239        let inode = (metadata.dev(), metadata.ino());
240        // codesign can replace an inode, so every alias needs its own signature
241        if changed_macho_inodes.contains(&inode) {
242            report.changed_machos.push(path.to_path_buf());
243            report.changed_files.push(path.to_path_buf());
244            continue;
245        }
246        let content =
247            std::fs::read(path).wrap_err_with(|| format!("failed read: {}", path.display()))?;
248        if !contains_any_placeholder(&content, replacements) {
249            continue;
250        }
251        let macho = is_macho(&content);
252        let elf = cfg!(target_os = "linux") && elf::is_elf(&content);
253        let shebang_end = text_executable_shebang_end(&content);
254        if skip_linkage && (macho || elf || (content.contains(&0) && shebang_end.is_none())) {
255            continue;
256        }
257        let perms = metadata.permissions();
258        // bottle files are often read-only; lift that while we patch
259        let mut writable = perms.clone();
260        std::os::unix::fs::PermissionsExt::set_mode(
261            &mut writable,
262            std::os::unix::fs::PermissionsExt::mode(&perms) | 0o200,
263        );
264        std::fs::set_permissions(path, writable)?;
265        if macho || (!elf && content.contains(&0) && shebang_end.is_none()) {
266            // Non-ELF files containing NUL bytes are treated as binaries unless
267            // their shebang makes them text executables (for example zipapps).
268            // Binary replacement cannot shift offsets. Mach-O load commands
269            // first: proper rewriting that can grow a command when the
270            // replacement is longer; then the generic in-place pass for
271            // strings in data sections.
272            let mut content = content;
273            let mut changed = macho && macho::patch(&mut content, replacements, path)?;
274            changed |= replace_in_binary(&mut content, replacements, path)?;
275            if changed {
276                std::fs::write(path, &content)
277                    .wrap_err_with(|| format!("failed write: {}", path.display()))?;
278                if macho {
279                    changed_macho_inodes.insert(inode);
280                    report.changed_machos.push(path.to_path_buf());
281                }
282                report.changed_files.push(path.to_path_buf());
283            }
284        } else if elf {
285            // Linux: patch the ELF interpreter and rpath, like brew's
286            // relocate_dynamic_linkage. brew does not rewrite other strings
287            // inside ELF binaries at pour time and neither do we — leftover
288            // placeholder copies in abandoned string tables are unreferenced.
289            if patch_elf {
290                let mut content = content;
291                if elf::patch(&mut content, &elf_opts, path)? {
292                    std::fs::write(path, &content)
293                        .wrap_err_with(|| format!("failed write: {}", path.display()))?;
294                    report.changed_files.push(path.to_path_buf());
295                }
296            }
297        } else {
298            let new_content = if content.contains(&0) {
299                // A valid shebang is the only way a NUL-backed file reaches
300                // this branch. Preserve the opaque binary payload byte-for-byte.
301                replace_shebang(&content, shebang_end.unwrap(), replacements)
302            } else {
303                replace_text(&content, replacements)
304            };
305            if new_content != content {
306                std::fs::write(path, &new_content)
307                    .wrap_err_with(|| format!("failed write: {}", path.display()))?;
308                report.changed_files.push(path.to_path_buf());
309            }
310        }
311        std::fs::set_permissions(path, perms)?;
312    }
313    Ok(report)
314}
315
316#[cfg(test)]
317mod tests;