zlayer_toolchain/
brew_emulate.rs1use std::path::{Path, PathBuf};
41
42use tracing::{info, warn};
43
44use crate::error::{Result, ToolchainError};
45use crate::manifest::{KegManifest, KegSource};
46use crate::source_build::SourceSpec;
47
48const HOMEBREW_REPO_URL: &str = "https://github.com/Homebrew/brew";
50
51fn arch_token() -> &'static str {
53 match std::env::consts::ARCH {
54 "aarch64" => "arm64",
55 other => other,
56 }
57}
58
59pub async fn ensure_via_brew(
75 formula: &str,
76 spec: &SourceSpec,
77 cache_dir: &Path,
78) -> Result<PathBuf> {
79 let keg = cache_dir.join(format!("{formula}-{}-{}", spec.version, arch_token()));
80 let ready_marker = keg.join(".ready");
81 if tokio::fs::try_exists(&ready_marker).await.unwrap_or(false) {
82 return Ok(keg);
83 }
84
85 let _ = tokio::fs::remove_dir_all(&keg).await;
87 tokio::fs::create_dir_all(&keg).await?;
88
89 let brew_prefix = keg.join("brew");
90 provision_brew_at_prefix(&brew_prefix).await?;
91
92 let brew_cache = cache_dir.join(".brew-cache");
94 tokio::fs::create_dir_all(&brew_cache).await?;
95
96 run_brew_install(formula, &brew_prefix, &brew_cache).await?;
97
98 let mut path_dirs = Vec::new();
108 let opt_bin = brew_prefix.join("opt").join(formula).join("bin");
109 if tokio::fs::try_exists(&opt_bin).await.unwrap_or(false) {
110 path_dirs.push(opt_bin.display().to_string());
111 } else {
112 let prefix_bin = brew_prefix.join("bin");
113 if tokio::fs::try_exists(&prefix_bin).await.unwrap_or(false) {
114 path_dirs.push(prefix_bin.display().to_string());
115 }
116 }
117 if path_dirs.is_empty() {
118 return Err(ToolchainError::RegistryError {
119 message: format!(
120 "brew-emulate install of {formula} produced no bin dir under {}",
121 brew_prefix.display()
122 ),
123 });
124 }
125
126 if let Some(offending) = scan_for_homebrew_placeholder(&opt_bin).await {
131 return Err(ToolchainError::RegistryError {
132 message: format!(
133 "brew-emulate {formula}: binary {} still carries an @@HOMEBREW@@ load \
134 command (bottle poured instead of built from source?)",
135 offending.display()
136 ),
137 });
138 }
139
140 for slim in [".git", "Library", "docs", "completions", "manpages"] {
144 let _ = tokio::fs::remove_dir_all(brew_prefix.join(slim)).await;
145 }
146 let _ = tokio::fs::remove_file(brew_prefix.join("bin").join("brew")).await;
150
151 let manifest = KegManifest {
152 tool: formula.to_string(),
153 version: spec.version.clone(),
154 arch: arch_token().to_string(),
155 platform: "macos".to_string(),
156 path_dirs,
157 env: std::collections::HashMap::new(),
158 source: KegSource::SourceBuild {
159 url: spec.tarball_url.clone(),
160 sha256: spec.sha256.clone(),
161 },
162 build_deps: spec.build_dependencies.clone(),
163 provisioned_at: chrono::Utc::now().to_rfc3339(),
164 };
165 manifest.write_to_keg(&keg).await?;
166 tokio::fs::write(&ready_marker, b"").await?;
167
168 info!(formula, keg = %keg.display(), "brew-emulate fallback produced a self-contained keg");
169 Ok(keg)
170}
171
172async fn provision_brew_at_prefix(brew_prefix: &Path) -> Result<()> {
179 if tokio::fs::try_exists(brew_prefix.join("bin/brew"))
180 .await
181 .unwrap_or(false)
182 {
183 return Ok(());
184 }
185 if let Some(parent) = brew_prefix.parent() {
186 tokio::fs::create_dir_all(parent).await?;
187 }
188
189 let clone = tokio::process::Command::new("git")
191 .arg("clone")
192 .arg("--depth=1")
193 .arg(HOMEBREW_REPO_URL)
194 .arg(brew_prefix)
195 .output()
196 .await;
197 if let Ok(out) = clone {
198 if out.status.success()
199 && tokio::fs::try_exists(brew_prefix.join("bin/brew"))
200 .await
201 .unwrap_or(false)
202 {
203 return Ok(());
204 }
205 warn!(
206 "git clone of Homebrew failed ({}); falling back to source tarball",
207 String::from_utf8_lossy(&out.stderr).trim()
208 );
209 }
210
211 let tarball = "https://github.com/Homebrew/brew/archive/refs/heads/master.tar.gz";
213 let bytes = reqwest::get(tarball)
214 .await
215 .map_err(|e| ToolchainError::RegistryError {
216 message: format!("failed to download Homebrew tarball: {e}"),
217 })?
218 .bytes()
219 .await
220 .map_err(|e| ToolchainError::RegistryError {
221 message: format!("failed to read Homebrew tarball bytes: {e}"),
222 })?;
223 let tmp = brew_prefix.with_extension("tar.gz");
224 tokio::fs::write(&tmp, &bytes).await?;
225 tokio::fs::create_dir_all(brew_prefix).await?;
226 let untar = tokio::process::Command::new("tar")
227 .arg("xf")
228 .arg(&tmp)
229 .args(["--strip-components", "1", "-C"])
230 .arg(brew_prefix)
231 .output()
232 .await?;
233 let _ = tokio::fs::remove_file(&tmp).await;
234 if !untar.status.success() {
235 return Err(ToolchainError::RegistryError {
236 message: format!(
237 "failed to extract Homebrew tarball: {}",
238 String::from_utf8_lossy(&untar.stderr)
239 ),
240 });
241 }
242 if !tokio::fs::try_exists(brew_prefix.join("bin/brew"))
243 .await
244 .unwrap_or(false)
245 {
246 return Err(ToolchainError::RegistryError {
247 message: format!(
248 "Homebrew checkout at {} has no bin/brew",
249 brew_prefix.display()
250 ),
251 });
252 }
253 Ok(())
254}
255
256async fn run_brew_install(formula: &str, brew_prefix: &Path, brew_cache: &Path) -> Result<()> {
260 let brew_bin = brew_prefix.join("bin/brew");
261 let prefix_str = brew_prefix.display().to_string();
262
263 let host_path = std::env::var("PATH").unwrap_or_default();
265 let mut path_parts = vec![brew_prefix.join("bin").display().to_string()];
266 if !host_path.is_empty() {
267 path_parts.push(host_path);
268 }
269 path_parts.push("/usr/bin:/bin:/usr/sbin:/sbin".to_string());
270
271 info!(formula, prefix = %prefix_str, "running brew install --build-from-source");
272 let out = tokio::process::Command::new(&brew_bin)
273 .arg("install")
274 .arg("--build-from-source")
275 .arg(formula)
276 .env("PATH", path_parts.join(":"))
277 .env("HOMEBREW_PREFIX", &prefix_str)
278 .env("HOMEBREW_REPOSITORY", &prefix_str)
279 .env("HOMEBREW_CELLAR", brew_prefix.join("Cellar"))
280 .env("HOMEBREW_CACHE", brew_cache)
281 .env("HOMEBREW_NO_AUTO_UPDATE", "1")
282 .env("HOMEBREW_NO_ANALYTICS", "1")
283 .env("HOMEBREW_NO_ENV_HINTS", "1")
284 .env("HOMEBREW_NO_INSTALL_CLEANUP", "1")
285 .output()
286 .await?;
287
288 if !out.status.success() {
289 let tail = String::from_utf8_lossy(&out.stderr)
290 .lines()
291 .rev()
292 .take(30)
293 .collect::<Vec<_>>()
294 .into_iter()
295 .rev()
296 .collect::<Vec<_>>()
297 .join("\n");
298 return Err(ToolchainError::RegistryError {
299 message: format!("brew install --build-from-source {formula} failed:\n{tail}"),
300 });
301 }
302 Ok(())
303}
304
305async fn scan_for_homebrew_placeholder(dir: &Path) -> Option<PathBuf> {
309 let mut stack = vec![dir.to_path_buf()];
310 while let Some(d) = stack.pop() {
311 let mut entries = tokio::fs::read_dir(&d).await.ok()?;
312 while let Ok(Some(entry)) = entries.next_entry().await {
313 let path = entry.path();
314 let ft = entry.file_type().await.ok()?;
315 if ft.is_dir() {
316 stack.push(path);
317 } else if ft.is_file() {
318 if let Ok(bytes) = tokio::fs::read(&path).await {
319 if contains_subslice(&bytes, b"@@HOMEBREW") {
320 return Some(path);
321 }
322 }
323 }
324 }
325 }
326 None
327}
328
329fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool {
331 if needle.is_empty() || haystack.len() < needle.len() {
332 return false;
333 }
334 haystack.windows(needle.len()).any(|w| w == needle)
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340
341 #[test]
342 fn placeholder_scan_helper_matches() {
343 assert!(contains_subslice(b"abc@@HOMEBREW@@/lib", b"@@HOMEBREW"));
344 assert!(!contains_subslice(
345 b"/usr/lib/libSystem.dylib",
346 b"@@HOMEBREW"
347 ));
348 assert!(!contains_subslice(b"", b"@@HOMEBREW"));
349 }
350
351 #[tokio::test]
352 async fn placeholder_scan_finds_offender_and_clean_is_none() {
353 let tmp = tempfile::tempdir().unwrap();
354 let sub = tmp.path().join("bin");
355 tokio::fs::create_dir_all(&sub).await.unwrap();
356 tokio::fs::write(sub.join("clean"), b"/usr/lib/libSystem.B.dylib")
357 .await
358 .unwrap();
359 assert!(scan_for_homebrew_placeholder(tmp.path()).await.is_none());
360
361 tokio::fs::write(
362 sub.join("dirty"),
363 b"@@HOMEBREW_PREFIX@@/opt/x/lib/libx.dylib",
364 )
365 .await
366 .unwrap();
367 let hit = scan_for_homebrew_placeholder(tmp.path()).await;
368 assert!(hit.is_some());
369 assert!(hit.unwrap().ends_with("dirty"));
370 }
371
372 #[tokio::test]
373 async fn ensure_via_brew_short_circuits_on_ready_keg() {
374 let tmp = tempfile::tempdir().unwrap();
375 let spec = SourceSpec {
376 version: "1.2.3".to_string(),
377 tarball_url: "https://example/x.tar.gz".to_string(),
378 sha256: String::new(),
379 dependencies: vec![],
380 build_dependencies: vec![],
381 macos_provided: vec![],
382 };
383 let keg = tmp.path().join(format!("demo-1.2.3-{}", arch_token()));
384 tokio::fs::create_dir_all(&keg).await.unwrap();
385 tokio::fs::write(keg.join(".ready"), b"").await.unwrap();
386
387 let got = ensure_via_brew("demo", &spec, tmp.path()).await.unwrap();
388 assert_eq!(got, keg, "a ready keg short-circuits without invoking brew");
389 }
390}