use crate::apk; use crate::config::Config; use crate::graph::PackageGraph; use crate::log; use crate::patches; use crate::phase::{PhaseCommand, PhaseEnv, SourceDir, collect_phase_commands}; use crate::recipe::{OutputPackage, PackageKind, Recipe, RecipeSet}; use crate::source; use anyhow::{Context, Result, anyhow, bail}; use sha2::{Digest, Sha256}; use std::cell::{Cell, RefCell}; use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; const C_SOURCE: &str = "/source"; const C_BUILD: &str = "/builddir"; const C_DEST: &str = "/dest"; const C_SYSROOT: &str = "/sysroot"; const HOST_PREFIX: &str = "/usr/local"; const TARGET_PREFIX: &str = "/usr"; #[derive(Debug)] pub struct Builder { repo: PathBuf, config: Config, skip_checks: bool, preflight_done: Cell, built_recipes: RefCell>, } impl Builder { pub fn new(repo: PathBuf, config: Config, skip_checks: bool) -> Self { Self { repo, config, skip_checks, preflight_done: Cell::new(false), built_recipes: RefCell::default(), } } pub fn build( &self, recipes: &RecipeSet, graph: &PackageGraph, package: Option<&str>, rebuild: bool, ) -> Result<()> { self.preflight_container()?; let (label, order) = match package { Some(p) => (p.to_owned(), graph.build_order(p)?), None => ("".to_owned(), graph.build_order_all()?), }; log::step( "plan", &format!("{label}: {} package(s) [{}]", order.len(), order.join(", ")), ); for output_name in order { let output = graph.output(&output_name)?; let recipe = recipes.recipe_for_package(&output_name)?; self.ensure_source(recipe)?; // Only force a rebuild of the explicitly requested package; // dependencies stay cached when their manifest is up to date. // With no package (build all), every package honours --rebuild. let force = rebuild && package.map_or(true, |p| p == output_name); match output.kind { PackageKind::Host => self.build_host(recipe, output, force)?, PackageKind::Target => self.build_target(recipe, output, force)?, } } self.repo_index()?; Ok(()) } pub fn fetch(&self, recipes: &RecipeSet, package: &str) -> Result<()> { let recipe = recipes.recipe_by_user_ref(package)?; log::step("fetch", &recipe.key()); source::fetch_sources(recipe, &self.source_cache_dir())?; Ok(()) } pub fn shell(&self, recipes: &RecipeSet, graph: &PackageGraph, package: &str) -> Result<()> { graph.output(package)?; let recipe = recipes.recipe_for_package(package)?; self.preflight_container()?; let source = self.unpacked_source_dir(recipe); let build = self.build_dir(recipe); fs::create_dir_all(&source)?; fs::create_dir_all(&build)?; let status = Command::new(&self.config.container_runtime) .arg("run") .arg("--rm") .arg("-it") .arg("-v") .arg(format!("{}:{C_SOURCE}:ro", source.display())) .arg("-v") .arg(format!("{}:{C_BUILD}", build.display())) .arg("-w") .arg(C_BUILD) .arg(&self.config.container_image) .arg("/bin/sh") .status()?; if !status.success() { bail!("container shell exited with {status}"); } Ok(()) } pub fn repo_index(&self) -> Result<()> { self.preflight_container()?; let repo = self.pkgs_dir(); fs::create_dir_all(&repo)?; // Nothing to index yet — leave it alone so callers don't trip on a // failing `*.apk` glob. let has_apks = fs::read_dir(&repo)?.any(|e| { e.ok() .and_then(|e| e.path().extension().map(|x| x == "apk")) .unwrap_or(false) }); if !has_apks { return Ok(()); } log::step( "index", &format!("signing repository at {}", repo.display()), ); let key = self.abs_config_path(&self.config.signing_key); let pubkey = self.abs_config_path(&self.config.signing_pubkey); if !key.exists() || !pubkey.exists() { bail!("signing key is not configured or missing; run `distro init-key` first"); } let index_name = "APKINDEX.tar.gz"; let status = Command::new(&self.config.container_runtime) .arg("run") .arg("--rm") .arg("-v") .arg(format!("{}:/repo", repo.display())) .arg("-v") .arg(format!("{}:/keys/distro.rsa:ro", key.display())) .arg("-v") .arg(format!( "{}:/etc/apk/keys/distro.rsa.pub:ro", pubkey.display() )) .arg(&self.config.container_image) .arg("/bin/sh") .arg("-lc") .arg(format!( "cd /repo && apk --sign-key /keys/distro.rsa mkndx -o {index_name} *.apk" )) .status() .context("failed to run repository index command")?; if !status.success() { bail!("repository index command failed with {status}"); } Ok(()) } pub fn init_key(&self) -> Result<()> { let key = self.abs_config_path(&self.config.signing_key); let pubkey = self.abs_config_path(&self.config.signing_pubkey); if key.exists() && pubkey.exists() { log::skip("init-key", "signing key already present"); return Ok(()); } log::step("init-key", &format!("generating {}", key.display())); let key_dir = key .parent() .ok_or_else(|| anyhow!("invalid signing key path"))?; let key_name = key .file_name() .ok_or_else(|| anyhow!("invalid signing key path"))? .to_string_lossy(); fs::create_dir_all(key_dir)?; self.preflight_container()?; let status = Command::new(&self.config.container_runtime) .arg("run") .arg("--rm") .arg("-v") .arg(format!("{}:/keys", key_dir.display())) .arg(&self.config.container_image) .arg("/bin/sh") .arg("-lc") .arg(format!( "openssl genrsa -out /keys/{key_name} 4096 && \ openssl rsa -in /keys/{key_name} -pubout -out /keys/{key_name}.pub" )) .status() .context("failed to run key generation command")?; if !status.success() { bail!("key generation failed with {status}"); } Ok(()) } pub fn rootfs(&self, root: &Path, packages: &[String]) -> Result<()> { self.preflight_container()?; let pubkey = self.abs_config_path(&self.config.signing_pubkey); if !pubkey.exists() { bail!("rootfs requires a configured public signing key; run `distro init-key` first"); } fs::create_dir_all(root)?; log::step( "rootfs", &format!( "{} -> {} [{}]", packages.join(", "), root.display(), root.display() ), ); let status = Command::new(&self.config.container_runtime) .arg("run") .arg("--rm") .arg("-v") .arg(format!("{}:/rootfs", root.display())) .arg("-v") .arg(format!("{}:/repo:ro", self.pkgs_root().display())) .arg("-v") .arg(format!( "{}:/etc/apk/keys/distro.rsa.pub:ro", pubkey.display() )) .arg(&self.config.container_image) .arg("apk") .arg("--root") .arg("/rootfs") .arg("--keys-dir") .arg("/etc/apk/keys") .arg("--initdb") .arg("--repository") .arg("/repo") .arg("add") .args(packages) .status() .context("failed to run rootfs apk command")?; if !status.success() { bail!("rootfs creation failed with {status}"); } Ok(()) } // --- per-recipe build steps ------------------------------------------- fn build_host(&self, recipe: &Recipe, output: &OutputPackage, rebuild: bool) -> Result<()> { let manifest = self.manifest_path(&output.key()); let hash = self.manifest_hash(recipe)?; if !rebuild && fs::read_to_string(&manifest).ok().as_deref() == Some(hash.as_str()) { log::skip("up-to-date", &output.key()); return Ok(()); } log::step( "build", &format!("{} {}-r{}", output.key(), recipe.version, recipe.revision), ); let build_dir = self.host_build_dir(recipe); let dest_dir = self.host_pkg_dir(recipe); let source_dir = self.unpacked_source_dir(recipe); // A rebuild starts from a clean build dir; sources stay shared. if rebuild { Self::recreate(&build_dir)?; } else { fs::create_dir_all(&build_dir)?; } Self::recreate(&dest_dir)?; fs::create_dir_all(&build_dir)?; let env = PhaseEnv { source_dir: source_dir_env(recipe), build_dir: C_BUILD, dest_dir: C_DEST, prefix: HOST_PREFIX, sysroot: C_SYSROOT, }; self.run_recipe_phases( recipe, output, &env, &source_dir, &build_dir, &dest_dir, None, )?; fs::create_dir_all(manifest.parent().unwrap())?; fs::write(manifest, hash)?; Ok(()) } fn build_target(&self, recipe: &Recipe, output: &OutputPackage, rebuild: bool) -> Result<()> { let manifest = self.manifest_path(&output.key()); let hash = self.manifest_hash(recipe)?; if !rebuild && fs::read_to_string(&manifest).ok().as_deref() == Some(hash.as_str()) { log::skip("up-to-date", &output.key()); return Ok(()); } log::step( "build", &format!("{} {}-r{}", output.key(), recipe.version, recipe.revision), ); let build_dir = self.build_dir(recipe); let source_dir = self.unpacked_source_dir(recipe); // A rebuild starts from a clean build dir; sources stay shared. if rebuild { Self::recreate(&build_dir)?; } else { fs::create_dir_all(&build_dir)?; } let dest_tmp = tempfile::tempdir_in(&build_dir).context("failed to create ephemeral destdir")?; let dest_dir = dest_tmp.path(); let sysroot = self.materialize_sysroot(recipe)?; let env = PhaseEnv { source_dir: source_dir_env(recipe), build_dir: C_BUILD, dest_dir: C_DEST, prefix: TARGET_PREFIX, sysroot: C_SYSROOT, }; self.run_recipe_phases( recipe, output, &env, &source_dir, &build_dir, dest_dir, sysroot.as_ref().map(|s| s.path()), )?; self.apk_mkpkg(output, dest_dir)?; fs::create_dir_all(manifest.parent().unwrap())?; fs::write(manifest, hash)?; Ok(()) } fn run_recipe_phases( &self, recipe: &Recipe, output: &OutputPackage, env: &PhaseEnv<'_>, source_dir: &Path, build_dir: &Path, dest_dir: &Path, target_sysroot: Option<&Path>, ) -> Result<()> { let host_sandbox = self.materialize_host_sandbox(recipe)?; let recipe_already_built = self.built_recipes.borrow().contains(&recipe.key()); // configure/build/check run at most once per recipe per invocation. let mut shared_phases: Vec<(&str, bool)> = Vec::new(); if !recipe_already_built { if let Some(p) = recipe.configure_fn.as_deref() { shared_phases.push((p, false)); } if let Some(p) = recipe.build_fn.as_deref() { shared_phases.push((p, false)); } if !self.skip_checks { if let Some(p) = recipe.check_fn.as_deref() { shared_phases.push((p, false)); } } } // install runs per output (each output has its own destdir/install_fn). let install_phase = (output.install_fn.as_str(), true); let mut commands: Vec = Vec::new(); for (phase, takes_pkg) in shared_phases.iter().chain(std::iter::once(&install_phase)) { let pkg = if *takes_pkg { Some((output.name.as_str(), C_DEST)) } else { None }; let owner = if *takes_pkg { output.key() } else { recipe.key() }; log::info("phase", &format!("{phase} {owner}")); commands.extend(collect_phase_commands( &recipe.path, &self.repo, &self.config, phase, env, pkg, )?); } if commands.is_empty() { self.built_recipes.borrow_mut().insert(recipe.key()); return Ok(()); } self.run_in_container( source_dir, build_dir, dest_dir, host_sandbox.as_ref().map(|s| s.path()), target_sysroot, &commands, )?; self.built_recipes.borrow_mut().insert(recipe.key()); Ok(()) } fn run_in_container( &self, source_dir: &Path, build_dir: &Path, dest_dir: &Path, host_sandbox: Option<&Path>, target_sysroot: Option<&Path>, commands: &[PhaseCommand], ) -> Result<()> { fs::create_dir_all(dest_dir)?; let mut process = Command::new(&self.config.container_runtime); process .arg("run") .arg("--rm") .arg("-v") .arg(format!("{}:{C_SOURCE}:ro", source_dir.display())) .arg("-v") .arg(format!("{}:{C_BUILD}", build_dir.display())) .arg("-v") .arg(format!("{}:{C_DEST}", dest_dir.display())) .arg("-w") .arg(C_BUILD); if let Some(sandbox) = host_sandbox { // Host packages are configured with --prefix=/usr/local, so we // bind the assembled tree exactly there (matching Jinx). This // means rpaths, --print-search-dirs, pkg-config lookups, etc. // all keep working with no extra environment fiddling. process .arg("-v") .arg(format!("{}:{HOST_PREFIX}:ro", sandbox.display())); } if let Some(sysroot) = target_sysroot { process .arg("-v") .arg(format!("{}:{C_SYSROOT}:ro", sysroot.display())) .arg("-e") .arg(format!("PKG_CONFIG_SYSROOT_DIR={C_SYSROOT}")) .arg("-e") .arg(format!("SYSROOT={C_SYSROOT}")); } process .arg(&self.config.container_image) .arg("/bin/sh") .arg("-c") .arg(build_phase_script(commands)); let status = process .status() .context("failed to start container for phase commands")?; if !status.success() { bail!("phase commands failed with {status}"); } Ok(()) } fn apk_mkpkg(&self, output: &OutputPackage, dest_dir: &Path) -> Result<()> { let repo = self.pkgs_dir(); fs::create_dir_all(&repo)?; let signing_key = self.abs_config_path(&self.config.signing_key); if !signing_key.exists() { bail!("package signing key is missing; run `distro init-key` first"); } log::step("package", &format!("{} -> {}", output.name, repo.display())); let plan = apk::mkpkg_plan(&self.config, output, Path::new(C_DEST), Path::new("/out")); let status = Command::new(&self.config.container_runtime) .arg("run") .arg("--rm") .arg("-v") .arg(format!("{}:{C_DEST}:ro", dest_dir.display())) .arg("-v") .arg(format!("{}:/out", repo.display())) .arg("-v") .arg(format!("{}:/keys/distro.rsa:ro", signing_key.display())) .arg(&self.config.container_image) .arg("apk") .arg("--sign-key") .arg("/keys/distro.rsa") .args(plan.args) .status() .context("failed to run apk mkpkg command")?; if !status.success() { bail!("apk mkpkg failed with {status}"); } Ok(()) } // --- source preparation (Jinx-style persistent sources//) ------ fn ensure_source(&self, recipe: &Recipe) -> Result<()> { let cached = source::fetch_sources(recipe, &self.source_cache_dir())?; let unpacked = self.unpacked_source_dir(recipe); let version_stamp = self.source_stamp(recipe, "version"); let want_version = format!("{}-r{}", recipe.version, recipe.revision); let need_extract = fs::read_to_string(&version_stamp).ok().as_deref() != Some(&want_version); if need_extract { log::info("unpack", &recipe.key()); if unpacked.exists() { fs::remove_dir_all(&unpacked)?; } fs::create_dir_all(&unpacked)?; for (src, tarball) in recipe.sources.iter().zip(cached.iter()) { let dest = if src.name.is_empty() { unpacked.clone() } else { unpacked.join(&src.name) }; self.extract_tarball(tarball, &dest, src.strip_components)?; } fs::write(&version_stamp, &want_version)?; let patched = self.source_stamp(recipe, "patched"); let _ = fs::remove_file(&patched); } let patched_stamp = self.source_stamp(recipe, "patched"); if !patched_stamp.exists() { self.apply_patches(recipe, &unpacked)?; fs::write(&patched_stamp, "")?; } Ok(()) } fn extract_tarball(&self, tarball: &Path, dest: &Path, strip_components: u32) -> Result<()> { fs::create_dir_all(dest)?; log::info( "extract", &format!("{} -> {}", tarball.display(), dest.display()), ); let strip = if strip_components > 0 { format!("--strip-components={strip_components}") } else { String::new() }; let status = Command::new(&self.config.container_runtime) .arg("run") .arg("--rm") .arg("-v") .arg(format!("{}:/in.tar:ro", tarball.display())) .arg("-v") .arg(format!("{}:/out", dest.display())) .arg(&self.config.container_image) .arg("/bin/sh") .arg("-c") .arg(format!("tar -xf /in.tar -C /out {strip}")) .status() .context("failed to unpack source archive")?; if !status.success() { bail!("source unpack failed with {status}"); } Ok(()) } fn apply_patches(&self, recipe: &Recipe, source_dir: &Path) -> Result<()> { let patches = patches::discover(&recipe.dir)?; if patches.is_empty() { return Ok(()); } if recipe.sources.iter().any(|s| !s.name.is_empty()) { bail!( "recipe `{}` has patches/ but uses multi-source layout; \ apply patches yourself via ctx.run in the configure phase", recipe.id ); } for patch in patches { log::info( "patch", &format!( "{} <- {}", recipe.id, patch .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_default(), ), ); let status = Command::new(&self.config.container_runtime) .arg("run") .arg("--rm") .arg("-v") .arg(format!("{}:/source", source_dir.display())) .arg("-v") .arg(format!("{}:/patch.diff:ro", patch.display())) .arg(&self.config.container_image) .arg("patch") .arg("-d") .arg("/source") .arg("-p1") .arg("-i") .arg("/patch.diff") .status() .context("failed to apply patch")?; if !status.success() { bail!("patch {} failed with {status}", patch.display()); } } Ok(()) } // --- host sandbox / target sysroot materialization -------------------- /// Assemble all `host_deps` into a single ephemeral tree that we can mount /// at `/usr/local` inside the container. Following Jinx, we hard-link /// rather than byte-copy so this is essentially free. The returned /// `TempDir` cleans up when dropped. fn materialize_host_sandbox(&self, recipe: &Recipe) -> Result> { if recipe.host_deps.is_empty() { return Ok(None); } fs::create_dir_all(self.repo.join("build"))?; let sandbox = tempfile::Builder::new() .prefix(&format!("host-sandbox-{}-", recipe.id)) .tempdir_in(self.repo.join("build")) .context("failed to create host sandbox tempdir")?; log::info( "host-sandbox", &format!("{} <- [{}]", recipe.id, recipe.host_deps.join(", ")), ); for dep in &recipe.host_deps { let source = self.host_pkg_dir_by_id(dep).join("usr/local"); if !source.exists() { bail!( "host dependency `{dep}` has not been built at {}", source.display() ); } hardlink_tree(&source, sandbox.path())?; } Ok(Some(sandbox)) } fn materialize_sysroot(&self, recipe: &Recipe) -> Result> { let mut deps: Vec = recipe .build_deps .iter() .chain(recipe.deps.iter()) .cloned() .collect(); deps.sort(); deps.dedup(); if deps.is_empty() { return Ok(None); } // The local repo index must be present and current so apk can resolve // and verify the just-built target packages. self.repo_index()?; let pubkey = self.abs_config_path(&self.config.signing_pubkey); if !pubkey.exists() { bail!("target dependency sysroot requires a configured public signing key"); } fs::create_dir_all(self.repo.join("build"))?; let sysroot = tempfile::Builder::new() .prefix(&format!("sysroot-{}-", recipe.id)) .tempdir_in(self.repo.join("build")) .context("failed to create sysroot tempdir")?; log::info( "sysroot", &format!("{} <- [{}]", recipe.id, deps.join(", ")), ); let status = Command::new(&self.config.container_runtime) .arg("run") .arg("--rm") .arg("-v") .arg(format!("{}:/sysroot", sysroot.path().display())) .arg("-v") .arg(format!("{}:/repo:ro", self.pkgs_root().display())) .arg("-v") .arg(format!( "{}:/etc/apk/keys/distro.rsa.pub:ro", pubkey.display() )) .arg(&self.config.container_image) .arg("apk") .arg("--root") .arg("/sysroot") .arg("--keys-dir") .arg("/etc/apk/keys") .arg("--initdb") .arg("--repository") .arg("/repo") .arg("add") .args(&deps) .status() .context("failed to install target dependency sysroot")?; if !status.success() { bail!("target dependency sysroot install failed with {status}"); } Ok(Some(sysroot)) } // --- preflight & container image -------------------------------------- fn preflight_container(&self) -> Result<()> { if self.preflight_done.get() { return Ok(()); } let status = Command::new(&self.config.container_runtime) .arg("--version") .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .with_context(|| { format!( "{} is required but was not found", self.config.container_runtime ) })?; if !status.success() { bail!( "{} preflight failed with {status}", self.config.container_runtime ); } let dockerfile = self.abs_config_path(&self.config.container_dockerfile); self.ensure_container_image(&dockerfile)?; self.preflight_done.set(true); Ok(()) } fn ensure_container_image(&self, dockerfile: &Path) -> Result<()> { if !dockerfile.exists() { bail!( "configured container Dockerfile does not exist: {}", dockerfile.display() ); } let hash = self.container_build_hash(dockerfile)?; let stamp = self.repo.join("build/container-image.hash"); if fs::read_to_string(&stamp).ok().as_deref() == Some(hash.as_str()) && self.container_image_exists()? { return Ok(()); } log::step( "image", &format!( "building {} from {}", self.config.container_image, dockerfile.display() ), ); let status = Command::new(&self.config.container_runtime) .arg("build") .arg("-f") .arg(dockerfile) .arg("-t") .arg(&self.config.container_image) .arg(&self.repo) .status() .with_context(|| { format!( "failed to build container image `{}` from {}", self.config.container_image, dockerfile.display() ) })?; if !status.success() { bail!( "container image build failed for `{}` with {status}", self.config.container_image ); } fs::create_dir_all(stamp.parent().unwrap())?; fs::write(stamp, hash)?; Ok(()) } fn container_image_exists(&self) -> Result { let status = Command::new(&self.config.container_runtime) .arg("image") .arg("exists") .arg(&self.config.container_image) .status() .with_context(|| { format!( "failed to inspect container image `{}`", self.config.container_image ) })?; Ok(status.success()) } fn container_build_hash(&self, dockerfile: &Path) -> Result { let mut hasher = Sha256::new(); hasher.update(fs::read(dockerfile)?); hasher.update(self.config.container_image.as_bytes()); Ok(hex::encode(hasher.finalize())) } fn manifest_hash(&self, recipe: &Recipe) -> Result { let mut hasher = Sha256::new(); hasher.update(self.config.target_arch.as_bytes()); hasher.update(recipe.version.as_bytes()); hasher.update(recipe.revision.to_le_bytes()); for source in &recipe.sources { hasher.update(source.url.as_bytes()); hasher.update(source.sha256.as_bytes()); } for patch in patches::discover(&recipe.dir)? { hasher.update(patch.display().to_string().as_bytes()); hasher.update(fs::read(patch)?); } Ok(hex::encode(hasher.finalize())) } // --- path helpers ----------------------------------------------------- fn source_cache_dir(&self) -> PathBuf { self.repo.join("build/cache/sources") } fn unpacked_source_dir(&self, recipe: &Recipe) -> PathBuf { self.repo.join("build/sources").join(recipe.slug()) } fn source_stamp(&self, recipe: &Recipe, kind: &str) -> PathBuf { self.repo .join("build/sources") .join(format!("{}.{kind}", recipe.slug())) } fn build_dir(&self, recipe: &Recipe) -> PathBuf { self.repo.join("build/builds").join(&recipe.id) } fn host_build_dir(&self, recipe: &Recipe) -> PathBuf { self.repo.join("build/host-builds").join(&recipe.id) } fn host_pkg_dir(&self, recipe: &Recipe) -> PathBuf { self.repo.join("build/host-pkgs").join(&recipe.id) } fn host_pkg_dir_by_id(&self, host_recipe_id: &str) -> PathBuf { self.repo.join("build/host-pkgs").join(host_recipe_id) } /// Root of the target package repo. apk treats this as the repo root and /// expects `//APKINDEX.tar.gz` underneath. fn pkgs_root(&self) -> PathBuf { self.repo.join("build/pkgs") } /// Arch-specific package directory: where .apk files and the index live. fn pkgs_dir(&self) -> PathBuf { self.pkgs_root().join(&self.config.target_arch) } fn manifest_path(&self, output_key: &str) -> PathBuf { // Output keys may contain `:` (e.g. `host:gcc`); the manifest file // name uses the filesystem-safe slug form instead. let safe = output_key.replace(':', "-"); self.repo .join("build/manifests") .join(format!("{safe}.hash")) } fn abs_config_path(&self, path: &Path) -> PathBuf { if path.is_absolute() { path.to_path_buf() } else { self.repo.join(path) } } fn recreate(path: &Path) -> Result<()> { if path.exists() { fs::remove_dir_all(path)?; } fs::create_dir_all(path)?; Ok(()) } } fn build_phase_script(commands: &[PhaseCommand]) -> String { let mut parts: Vec = Vec::with_capacity(commands.len() + 1); parts.push("set -e".to_owned()); for cmd in commands { let mut tokens: Vec = Vec::with_capacity(cmd.env.len() + cmd.argv.len()); for (k, v) in &cmd.env { let value = shell_escape::unix::escape(v.as_str().into()).into_owned(); tokens.push(format!("{k}={value}")); } for arg in &cmd.argv { tokens.push(shell_escape::unix::escape(arg.as_str().into()).into_owned()); } parts.push(tokens.join(" ")); } parts.join("\n") } /// Expose `/source` as a string for single-source recipes, or a struct of /// `/source/` paths for multi-source recipes. fn source_dir_env(recipe: &Recipe) -> SourceDir { if recipe.sources.iter().all(|s| s.name.is_empty()) { SourceDir::Single(C_SOURCE.to_owned()) } else { let map = recipe .sources .iter() .map(|s| (s.name.clone(), format!("{C_SOURCE}/{}", s.name))) .collect(); SourceDir::Many(map) } } /// Mirror `src` into `dst` using hard links for regular files (and preserving /// symlinks). This matches Jinx's `cp -Pplr`: no bytes are copied, just inode /// references, so assembling a host-deps sandbox is essentially free. fn hardlink_tree(src: &Path, dst: &Path) -> Result<()> { fs::create_dir_all(dst)?; for entry in walkdir::WalkDir::new(src) { let entry = entry?; let relative = entry.path().strip_prefix(src)?; if relative.as_os_str().is_empty() { continue; } let target = dst.join(relative); let file_type = entry.file_type(); if file_type.is_dir() { fs::create_dir_all(&target)?; } else if file_type.is_symlink() { if let Some(parent) = target.parent() { fs::create_dir_all(parent)?; } let link_target = fs::read_link(entry.path())?; // Replace any pre-existing entry (multiple host deps may ship the // same symlink path). let _ = fs::remove_file(&target); std::os::unix::fs::symlink(&link_target, &target)?; } else if file_type.is_file() { if let Some(parent) = target.parent() { fs::create_dir_all(parent)?; } if target.exists() { continue; } fs::hard_link(entry.path(), &target).with_context(|| { format!( "failed to hard-link {} -> {}", entry.path().display(), target.display() ) })?; } } Ok(()) }