1use std::path::{Path, PathBuf};
13use std::process::Stdio;
14
15use tokio::process::Command;
16use tracing::{debug, info, trace, warn};
17
18const MIN_BUILDAH_VERSION: &str = "1.28.0";
20
21#[derive(Debug, Clone)]
26pub struct BuildahInstaller {
27 install_dir: PathBuf,
29 min_version: &'static str,
31}
32
33#[derive(Debug, Clone)]
35pub struct BuildahInstallation {
36 pub path: PathBuf,
38 pub version: String,
40}
41
42#[derive(Debug, thiserror::Error)]
44pub enum InstallError {
45 #[error("Buildah not found. {}", install_instructions())]
47 NotFound,
48
49 #[error("Buildah version {found} is below minimum required version {required}")]
51 VersionTooOld {
52 found: String,
54 required: String,
56 },
57
58 #[error("Unsupported platform: {os}/{arch}")]
60 UnsupportedPlatform {
61 os: String,
63 arch: String,
65 },
66
67 #[error("Download failed: {0}")]
69 DownloadFailed(String),
70
71 #[error("IO error: {0}")]
73 Io(#[from] std::io::Error),
74
75 #[error("Failed to parse buildah version output: {0}")]
77 VersionParse(String),
78
79 #[error("Failed to execute buildah: {0}")]
81 ExecutionFailed(String),
82}
83
84impl Default for BuildahInstaller {
85 fn default() -> Self {
86 Self::new()
87 }
88}
89
90impl BuildahInstaller {
91 #[must_use]
96 pub fn new() -> Self {
97 let install_dir = default_install_dir();
98 Self {
99 install_dir,
100 min_version: MIN_BUILDAH_VERSION,
101 }
102 }
103
104 #[must_use]
106 pub fn with_install_dir(dir: PathBuf) -> Self {
107 Self {
108 install_dir: dir,
109 min_version: MIN_BUILDAH_VERSION,
110 }
111 }
112
113 #[must_use]
115 pub fn install_dir(&self) -> &Path {
116 &self.install_dir
117 }
118
119 #[must_use]
121 pub fn min_version(&self) -> &str {
122 self.min_version
123 }
124
125 pub async fn find_existing(&self) -> Option<BuildahInstallation> {
134 let search_paths = get_search_paths(&self.install_dir);
136
137 for path in search_paths {
138 trace!("Checking for buildah at: {}", path.display());
139
140 if path.exists() && path.is_file() {
141 match Self::get_version(&path).await {
142 Ok(version) => {
143 info!("Found buildah at {} (version {})", path.display(), version);
144 return Some(BuildahInstallation { path, version });
145 }
146 Err(e) => {
147 debug!(
148 "Found buildah at {} but couldn't get version: {}",
149 path.display(),
150 e
151 );
152 }
153 }
154 }
155 }
156
157 if let Some(path) = find_in_path("buildah").await {
159 match Self::get_version(&path).await {
160 Ok(version) => {
161 info!(
162 "Found buildah in PATH at {} (version {})",
163 path.display(),
164 version
165 );
166 return Some(BuildahInstallation { path, version });
167 }
168 Err(e) => {
169 debug!(
170 "Found buildah in PATH at {} but couldn't get version: {}",
171 path.display(),
172 e
173 );
174 }
175 }
176 }
177
178 None
179 }
180
181 pub async fn check(&self) -> Result<BuildahInstallation, InstallError> {
189 let installation = self.find_existing().await.ok_or(InstallError::NotFound)?;
190
191 if !version_meets_minimum(&installation.version, self.min_version) {
193 return Err(InstallError::VersionTooOld {
194 found: installation.version,
195 required: self.min_version.to_string(),
196 });
197 }
198
199 Ok(installation)
200 }
201
202 pub async fn get_version(path: &Path) -> Result<String, InstallError> {
211 let output = Command::new(path)
212 .arg("--version")
213 .stdout(Stdio::piped())
214 .stderr(Stdio::piped())
215 .output()
216 .await
217 .map_err(|e| InstallError::ExecutionFailed(e.to_string()))?;
218
219 if !output.status.success() {
220 let stderr = String::from_utf8_lossy(&output.stderr);
221 return Err(InstallError::ExecutionFailed(format!(
222 "buildah --version failed: {}",
223 stderr.trim()
224 )));
225 }
226
227 let stdout = String::from_utf8_lossy(&output.stdout);
228 parse_version(&stdout)
229 }
230
231 pub async fn ensure(&self) -> Result<BuildahInstallation, InstallError> {
240 match self.check().await {
242 Ok(installation) => {
243 info!(
244 "Using buildah {} at {}",
245 installation.version,
246 installation.path.display()
247 );
248 Ok(installation)
249 }
250 Err(InstallError::VersionTooOld { found, required }) => {
251 warn!(
252 "Found buildah {} but minimum required version is {}",
253 found, required
254 );
255 Err(InstallError::VersionTooOld { found, required })
256 }
257 Err(InstallError::NotFound) => {
258 #[cfg(target_os = "macos")]
259 debug!("Buildah not available on macOS; will use native sandbox builder");
260 #[cfg(not(target_os = "macos"))]
261 warn!("Buildah not found on system");
262 Err(InstallError::NotFound)
263 }
264 Err(e) => Err(e),
265 }
266 }
267
268 #[allow(clippy::unused_async)]
277 pub async fn download(&self) -> Result<BuildahInstallation, InstallError> {
278 let (os, arch) = current_platform();
279
280 if !is_platform_supported() {
282 return Err(InstallError::UnsupportedPlatform {
283 os: os.to_string(),
284 arch: arch.to_string(),
285 });
286 }
287
288 Err(InstallError::DownloadFailed(format!(
291 "Automatic download not yet implemented. {}",
292 install_instructions()
293 )))
294 }
295}
296
297#[must_use]
299pub fn current_platform() -> (&'static str, &'static str) {
300 let os = std::env::consts::OS; let arch = std::env::consts::ARCH; (os, arch)
303}
304
305#[must_use]
310pub fn is_platform_supported() -> bool {
311 let (os, arch) = current_platform();
312 matches!((os, arch), ("linux" | "macos", "x86_64" | "aarch64"))
313}
314
315#[must_use]
317pub fn install_instructions() -> String {
318 let (os, _arch) = current_platform();
319
320 match os {
321 "linux" => {
322 if let Some(distro) = detect_linux_distro() {
324 match distro.as_str() {
325 "ubuntu" | "debian" | "pop" | "mint" | "elementary" => {
326 "Install with: sudo apt install buildah".to_string()
327 }
328 "fedora" | "rhel" | "centos" | "rocky" | "alma" => {
329 "Install with: sudo dnf install buildah".to_string()
330 }
331 "arch" | "manjaro" | "endeavouros" => {
332 "Install with: sudo pacman -S buildah".to_string()
333 }
334 "opensuse" | "suse" => "Install with: sudo zypper install buildah".to_string(),
335 "alpine" => "Install with: sudo apk add buildah".to_string(),
336 "gentoo" => "Install with: sudo emerge app-containers/buildah".to_string(),
337 "void" => "Install with: sudo xbps-install buildah".to_string(),
338 _ => "Install buildah using your distribution's package manager.\n\
339 Common commands:\n\
340 - Debian/Ubuntu: sudo apt install buildah\n\
341 - Fedora/RHEL: sudo dnf install buildah\n\
342 - Arch: sudo pacman -S buildah\n\
343 - openSUSE: sudo zypper install buildah"
344 .to_string(),
345 }
346 } else {
347 "Install buildah using your distribution's package manager.\n\
348 Common commands:\n\
349 - Debian/Ubuntu: sudo apt install buildah\n\
350 - Fedora/RHEL: sudo dnf install buildah\n\
351 - Arch: sudo pacman -S buildah\n\
352 - openSUSE: sudo zypper install buildah"
353 .to_string()
354 }
355 }
356 "macos" => "Buildah is not required on macOS -- ZLayer uses the native \
357 sandbox builder instead. If you prefer buildah, install with: \
358 brew install buildah (requires a Linux VM for container operations)."
359 .to_string(),
360 "windows" => "Buildah is not natively supported on Windows.\n\
361 Consider using WSL2 with a Linux distribution and installing buildah there."
362 .to_string(),
363 _ => format!("Buildah is not supported on {os}. Use a Linux system."),
364 }
365}
366
367fn default_install_dir() -> PathBuf {
373 zlayer_paths::ZLayerDirs::system_default().bin()
374}
375
376fn get_search_paths(install_dir: &Path) -> Vec<PathBuf> {
378 let mut paths = vec![
379 install_dir.join("buildah"),
381 zlayer_paths::ZLayerDirs::system_default()
383 .bin()
384 .join("buildah"),
385 PathBuf::from("/usr/local/lib/zlayer/buildah"),
387 PathBuf::from("/usr/bin/buildah"),
389 PathBuf::from("/usr/local/bin/buildah"),
390 PathBuf::from("/bin/buildah"),
391 ];
392
393 let mut seen = std::collections::HashSet::new();
395 paths.retain(|p| seen.insert(p.clone()));
396
397 paths
398}
399
400async fn find_in_path(binary: &str) -> Option<PathBuf> {
404 #[cfg(windows)]
405 {
406 if let Ok(output) = Command::new("where")
407 .arg(binary)
408 .stdout(Stdio::piped())
409 .stderr(Stdio::null())
410 .output()
411 .await
412 {
413 if output.status.success() {
414 let stdout = String::from_utf8_lossy(&output.stdout);
415 if let Some(first) = stdout.lines().next() {
418 let path = first.trim().to_string();
419 if !path.is_empty() {
420 return Some(PathBuf::from(path));
421 }
422 }
423 }
424 }
425 }
426
427 let output = Command::new("which")
428 .arg(binary)
429 .stdout(Stdio::piped())
430 .stderr(Stdio::null())
431 .output()
432 .await
433 .ok()?;
434
435 if output.status.success() {
436 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
437 if !path.is_empty() {
438 return Some(PathBuf::from(path));
439 }
440 }
441
442 let output = Command::new("sh")
444 .args(["-c", &format!("command -v {binary}")])
445 .stdout(Stdio::piped())
446 .stderr(Stdio::null())
447 .output()
448 .await
449 .ok()?;
450
451 if output.status.success() {
452 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
453 if !path.is_empty() {
454 return Some(PathBuf::from(path));
455 }
456 }
457
458 None
459}
460
461fn parse_version(output: &str) -> Result<String, InstallError> {
468 let output = output.trim();
470
471 if let Some(pos) = output.to_lowercase().find("version") {
473 let after_version = &output[pos + "version".len()..].trim_start();
474
475 let version: String = after_version
478 .chars()
479 .take_while(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-')
480 .collect();
481
482 let version = version.trim_end_matches('-');
484
485 if !version.is_empty() && version.contains('.') {
486 return Ok(version.to_string());
487 }
488 }
489
490 Err(InstallError::VersionParse(format!(
491 "Could not parse version from: {output}"
492 )))
493}
494
495fn version_meets_minimum(version: &str, minimum: &str) -> bool {
499 let parse_version = |s: &str| -> Option<Vec<u32>> {
500 let clean = s.split('-').next()?;
502 clean
503 .split('.')
504 .map(|p| p.parse::<u32>().ok())
505 .collect::<Option<Vec<_>>>()
506 };
507
508 let Some(version_parts) = parse_version(version) else {
509 return false;
510 };
511
512 let Some(minimum_parts) = parse_version(minimum) else {
513 return true; };
515
516 for (v, m) in version_parts.iter().zip(minimum_parts.iter()) {
518 match v.cmp(m) {
519 std::cmp::Ordering::Greater => return true,
520 std::cmp::Ordering::Less => return false,
521 std::cmp::Ordering::Equal => {}
522 }
523 }
524
525 version_parts.len() >= minimum_parts.len()
527}
528
529fn detect_linux_distro() -> Option<String> {
531 if let Ok(contents) = std::fs::read_to_string("/etc/os-release") {
533 for line in contents.lines() {
534 if let Some(id) = line.strip_prefix("ID=") {
535 return Some(id.trim_matches('"').to_lowercase());
536 }
537 }
538 }
539
540 if let Ok(contents) = std::fs::read_to_string("/etc/lsb-release") {
542 for line in contents.lines() {
543 if let Some(id) = line.strip_prefix("DISTRIB_ID=") {
544 return Some(id.trim_matches('"').to_lowercase());
545 }
546 }
547 }
548
549 None
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555
556 #[test]
557 fn test_parse_version_full() {
558 let output = "buildah version 1.33.0 (image-spec 1.0.2-dev, runtime-spec 1.0.2-dev)";
559 let version = parse_version(output).unwrap();
560 assert_eq!(version, "1.33.0");
561 }
562
563 #[test]
564 fn test_parse_version_simple() {
565 let output = "buildah version 1.28.0";
566 let version = parse_version(output).unwrap();
567 assert_eq!(version, "1.28.0");
568 }
569
570 #[test]
571 fn test_parse_version_with_newline() {
572 let output = "buildah version 1.33.0\n";
573 let version = parse_version(output).unwrap();
574 assert_eq!(version, "1.33.0");
575 }
576
577 #[test]
578 fn test_parse_version_dev() {
579 let output = "buildah version 1.34.0-dev";
580 let version = parse_version(output).unwrap();
581 assert_eq!(version, "1.34.0-dev");
582 }
583
584 #[test]
585 fn test_parse_version_invalid() {
586 let output = "some random output";
587 assert!(parse_version(output).is_err());
588 }
589
590 #[test]
591 fn test_version_meets_minimum_equal() {
592 assert!(version_meets_minimum("1.28.0", "1.28.0"));
593 }
594
595 #[test]
596 fn test_version_meets_minimum_greater_patch() {
597 assert!(version_meets_minimum("1.28.1", "1.28.0"));
598 }
599
600 #[test]
601 fn test_version_meets_minimum_greater_minor() {
602 assert!(version_meets_minimum("1.29.0", "1.28.0"));
603 }
604
605 #[test]
606 fn test_version_meets_minimum_greater_major() {
607 assert!(version_meets_minimum("2.0.0", "1.28.0"));
608 }
609
610 #[test]
611 fn test_version_meets_minimum_less_patch() {
612 assert!(!version_meets_minimum("1.27.5", "1.28.0"));
613 }
614
615 #[test]
616 fn test_version_meets_minimum_less_minor() {
617 assert!(!version_meets_minimum("1.20.0", "1.28.0"));
618 }
619
620 #[test]
621 fn test_version_meets_minimum_less_major() {
622 assert!(!version_meets_minimum("0.99.0", "1.28.0"));
623 }
624
625 #[test]
626 fn test_version_meets_minimum_with_dev_suffix() {
627 assert!(version_meets_minimum("1.34.0-dev", "1.28.0"));
628 }
629
630 #[test]
631 fn test_current_platform() {
632 let (os, arch) = current_platform();
633 assert!(!os.is_empty());
635 assert!(!arch.is_empty());
636 }
637
638 #[test]
639 fn test_is_platform_supported() {
640 let (os, arch) = current_platform();
642 let supported = is_platform_supported();
643
644 match (os, arch) {
645 ("linux" | "macos", "x86_64" | "aarch64") => assert!(supported),
646 _ => assert!(!supported),
647 }
648 }
649
650 #[test]
651 fn test_install_instructions_not_empty() {
652 let instructions = install_instructions();
653 assert!(!instructions.is_empty());
654 }
655
656 #[test]
657 fn test_default_install_dir() {
658 let dir = default_install_dir();
659 assert!(dir.to_string_lossy().contains("bin") || dir.to_string_lossy().contains("zlayer"));
661 }
662
663 #[test]
664 fn test_get_search_paths_not_empty() {
665 let install_dir = PathBuf::from("/var/lib/zlayer-test");
666 let paths = get_search_paths(&install_dir);
667 assert!(!paths.is_empty());
668 assert!(paths[0].starts_with("/var/lib/zlayer-test"));
670 }
671
672 #[test]
673 fn test_installer_creation() {
674 let installer = BuildahInstaller::new();
675 assert_eq!(installer.min_version(), MIN_BUILDAH_VERSION);
676 }
677
678 #[test]
679 fn test_installer_with_custom_dir() {
680 let custom_dir = PathBuf::from("/custom/path");
681 let installer = BuildahInstaller::with_install_dir(custom_dir.clone());
682 assert_eq!(installer.install_dir(), custom_dir);
683 }
684
685 #[tokio::test]
686 async fn test_find_in_path_nonexistent() {
687 let result = find_in_path("this_binary_should_not_exist_12345").await;
689 assert!(result.is_none());
690 }
691
692 #[tokio::test]
693 #[allow(clippy::await_holding_lock)]
694 async fn test_find_in_path_exists() {
695 #[cfg(unix)]
697 let probe = "sh";
698 #[cfg(windows)]
699 let probe = "cmd.exe";
700
701 let _g = crate::TEST_ENV_LOCK
711 .lock()
712 .unwrap_or_else(std::sync::PoisonError::into_inner);
713
714 let result = find_in_path(probe).await;
715 assert!(
716 result.is_some(),
717 "find_in_path({probe:?}) should find a system binary"
718 );
719 }
720
721 #[tokio::test]
723 #[ignore = "requires buildah to be installed"]
724 async fn test_find_existing_buildah() {
725 let installer = BuildahInstaller::new();
726 let result = installer.find_existing().await;
727 assert!(result.is_some());
728 let installation = result.unwrap();
729 assert!(installation.path.exists());
730 assert!(!installation.version.is_empty());
731 }
732
733 #[tokio::test]
734 #[ignore = "requires buildah to be installed"]
735 async fn test_check_buildah() {
736 let installer = BuildahInstaller::new();
737 let result = installer.check().await;
738 assert!(result.is_ok());
739 }
740
741 #[tokio::test]
742 #[ignore = "requires buildah to be installed"]
743 async fn test_ensure_buildah() {
744 let installer = BuildahInstaller::new();
745 let result = installer.ensure().await;
746 assert!(result.is_ok());
747 }
748
749 #[tokio::test]
750 #[ignore = "requires buildah to be installed"]
751 async fn test_get_version() {
752 let installer = BuildahInstaller::new();
753 if let Some(installation) = installer.find_existing().await {
754 let version = BuildahInstaller::get_version(&installation.path).await;
755 assert!(version.is_ok());
756 let version = version.unwrap();
757 assert!(version.contains('.'));
758 }
759 }
760}
761
762#[cfg(unix)]
768pub mod buildd {
769 use std::env;
770 use std::fs;
771 use std::os::unix::fs::PermissionsExt;
772 use std::path::{Path, PathBuf};
773
774 use crate::error::{BuildError, Result};
775
776 pub const EXPECTED_VERSION: &str = env!("CARGO_PKG_VERSION");
780
781 #[derive(Debug)]
783 pub enum InstallOutcome {
784 AlreadyInstalled {
786 path: PathBuf,
788 version: String,
790 },
791 CopiedFromBundle {
793 source: PathBuf,
795 dest: PathBuf,
797 },
798 Downloaded {
800 url: String,
802 dest: PathBuf,
804 },
805 Skipped {
809 reason: String,
811 },
812 }
813
814 pub fn ensure_buildd_sidecar(install_dir: &Path) -> Result<InstallOutcome> {
833 if env::var_os("ZLAYER_BUILDD_BIN").is_some() {
835 return Ok(InstallOutcome::Skipped {
836 reason: "ZLAYER_BUILDD_BIN set; using that binary instead".into(),
837 });
838 }
839
840 ensure_buildd_sidecar_with_bundle(install_dir, bundled_sidecar_path()?)
841 }
842
843 fn ensure_buildd_sidecar_with_bundle(
856 install_dir: &Path,
857 bundled: Option<PathBuf>,
858 ) -> Result<InstallOutcome> {
859 let dest = install_dir.join("zlayer-buildd");
860
861 if let Some(bundled) = bundled {
864 if dest.exists() && is_executable(&dest) && files_equal(&bundled, &dest)? {
865 return Ok(InstallOutcome::AlreadyInstalled {
866 version: read_version(&dest).unwrap_or_else(|| EXPECTED_VERSION.to_string()),
867 path: dest,
868 });
869 }
870 ensure_parent_dir(&dest)?;
871 fs::copy(&bundled, &dest)?;
872 mark_executable(&dest)?;
873 return Ok(InstallOutcome::CopiedFromBundle {
874 source: bundled,
875 dest,
876 });
877 }
878
879 if dest.exists() && is_executable(&dest) {
882 return Ok(InstallOutcome::AlreadyInstalled {
883 version: read_version(&dest).unwrap_or_else(|| EXPECTED_VERSION.to_string()),
884 path: dest,
885 });
886 }
887
888 Err(BuildError::NotSupported {
898 operation: format!(
899 "zlayer-buildd is not installed at {} and no bundled binary was found \
900 alongside the running zlayer executable. Either run `make release` in \
901 bin/zlayer-buildd/ and copy the result into {}, set ZLAYER_BUILDD_BIN, or \
902 install the release tarball that ships zlayer-buildd alongside zlayer.",
903 dest.display(),
904 install_dir.display(),
905 ),
906 })
907 }
908
909 fn files_equal(a: &Path, b: &Path) -> Result<bool> {
914 use std::io::Read;
915
916 let (ma, mb) = (fs::metadata(a)?, fs::metadata(b)?);
917 if ma.len() != mb.len() {
918 return Ok(false);
919 }
920 let mut fa = std::io::BufReader::new(fs::File::open(a)?);
921 let mut fb = std::io::BufReader::new(fs::File::open(b)?);
922 let mut buf_a = [0u8; 8 * 1024];
923 let mut buf_b = [0u8; 8 * 1024];
924 loop {
925 let na = fa.read(&mut buf_a)?;
926 if na == 0 {
927 return Ok(true);
928 }
929 let mut filled = 0;
932 while filled < na {
933 let nb = fb.read(&mut buf_b[filled..na])?;
934 if nb == 0 {
935 return Ok(false);
936 }
937 filled += nb;
938 }
939 if buf_a[..na] != buf_b[..na] {
940 return Ok(false);
941 }
942 }
943 }
944
945 fn bundled_sidecar_path() -> Result<Option<PathBuf>> {
949 let exe = env::current_exe()?;
950 let Some(dir) = exe.parent() else {
951 return Ok(None);
952 };
953 let candidate = dir.join("zlayer-buildd");
954 if candidate.exists() && is_executable(&candidate) {
955 Ok(Some(candidate))
956 } else {
957 Ok(None)
958 }
959 }
960
961 fn read_version(binary: &Path) -> Option<String> {
962 let output = std::process::Command::new(binary)
963 .arg("--version")
964 .output()
965 .ok()?;
966 if !output.status.success() {
967 return None;
968 }
969 let stdout = String::from_utf8(output.stdout).ok()?;
970 let key = "\"sidecar_version\":\"";
975 let start = stdout.find(key)? + key.len();
976 let rest = &stdout[start..];
977 let end = rest.find('"')?;
978 Some(rest[..end].to_string())
979 }
980
981 fn is_executable(path: &Path) -> bool {
982 match fs::metadata(path) {
983 Ok(md) if md.is_file() => md.permissions().mode() & 0o111 != 0,
984 _ => false,
985 }
986 }
987
988 fn mark_executable(path: &Path) -> Result<()> {
989 let mut perms = fs::metadata(path)?.permissions();
990 let mode = perms.mode();
991 perms.set_mode(mode | 0o755);
992 fs::set_permissions(path, perms)?;
993 Ok(())
994 }
995
996 fn ensure_parent_dir(path: &Path) -> Result<()> {
997 if let Some(parent) = path.parent() {
998 fs::create_dir_all(parent)?;
999 }
1000 Ok(())
1001 }
1002
1003 #[cfg(test)]
1004 #[allow(unsafe_code)]
1005 mod tests {
1006 use super::*;
1007 use crate::TEST_ENV_LOCK;
1008 use std::sync::PoisonError;
1009
1010 #[test]
1011 fn env_override_yields_skipped() {
1012 let _g = TEST_ENV_LOCK.lock().unwrap_or_else(PoisonError::into_inner);
1013 unsafe {
1017 env::set_var("ZLAYER_BUILDD_BIN", "/tmp/whatever");
1018 }
1019 let tmp = tempfile::tempdir().unwrap();
1020 let outcome = ensure_buildd_sidecar(tmp.path()).unwrap();
1021 unsafe {
1023 env::remove_var("ZLAYER_BUILDD_BIN");
1024 }
1025 assert!(matches!(outcome, InstallOutcome::Skipped { .. }));
1026 }
1027
1028 #[test]
1029 fn missing_binary_returns_actionable_error() {
1030 let _g = TEST_ENV_LOCK.lock().unwrap_or_else(PoisonError::into_inner);
1031 unsafe {
1034 env::remove_var("ZLAYER_BUILDD_BIN");
1035 }
1036 let tmp = tempfile::tempdir().unwrap();
1042 let err = ensure_buildd_sidecar(tmp.path());
1043 if let Err(e) = err {
1047 let msg = e.to_string();
1048 assert!(
1049 msg.contains("zlayer-buildd"),
1050 "error did not mention the binary: {msg}"
1051 );
1052 }
1053 }
1054
1055 fn write_exec(path: &Path, content: &[u8]) {
1057 fs::write(path, content).unwrap();
1058 mark_executable(path).unwrap();
1059 }
1060
1061 #[test]
1062 fn rebuilt_bundle_replaces_stale_installed_binary() {
1063 let tmp = tempfile::tempdir().unwrap();
1068 let install_dir = tmp.path().join("install");
1069 fs::create_dir_all(&install_dir).unwrap();
1070 let dest = install_dir.join("zlayer-buildd");
1071 write_exec(&dest, b"STALE buildd binary v1");
1072
1073 let bundle = tmp.path().join("zlayer-buildd-bundle");
1074 write_exec(&bundle, b"FRESH rebuilt buildd binary v2 (different bytes)");
1075
1076 let outcome =
1077 ensure_buildd_sidecar_with_bundle(&install_dir, Some(bundle.clone())).unwrap();
1078 assert!(
1079 matches!(outcome, InstallOutcome::CopiedFromBundle { .. }),
1080 "expected CopiedFromBundle, got {outcome:?}"
1081 );
1082 assert_eq!(
1084 fs::read(&dest).unwrap(),
1085 fs::read(&bundle).unwrap(),
1086 "dest was not refreshed from the bundle"
1087 );
1088 }
1089
1090 #[test]
1091 fn matching_bundle_is_already_installed_no_copy() {
1092 let tmp = tempfile::tempdir().unwrap();
1093 let install_dir = tmp.path().join("install");
1094 fs::create_dir_all(&install_dir).unwrap();
1095 let dest = install_dir.join("zlayer-buildd");
1096 write_exec(&dest, b"identical buildd binary");
1097 let bundle = tmp.path().join("zlayer-buildd-bundle");
1098 write_exec(&bundle, b"identical buildd binary");
1099
1100 let outcome = ensure_buildd_sidecar_with_bundle(&install_dir, Some(bundle)).unwrap();
1101 assert!(
1102 matches!(outcome, InstallOutcome::AlreadyInstalled { .. }),
1103 "content-equal bundle should be a no-op, got {outcome:?}"
1104 );
1105 }
1106
1107 #[test]
1108 fn no_bundle_existing_dest_is_already_installed() {
1109 let tmp = tempfile::tempdir().unwrap();
1110 let install_dir = tmp.path().join("install");
1111 fs::create_dir_all(&install_dir).unwrap();
1112 let dest = install_dir.join("zlayer-buildd");
1113 write_exec(&dest, b"air-gapped installed binary");
1114
1115 let outcome = ensure_buildd_sidecar_with_bundle(&install_dir, None).unwrap();
1116 assert!(
1117 matches!(outcome, InstallOutcome::AlreadyInstalled { .. }),
1118 "air-gapped existing binary should be accepted, got {outcome:?}"
1119 );
1120 }
1121
1122 #[test]
1123 fn no_bundle_no_dest_errors() {
1124 let tmp = tempfile::tempdir().unwrap();
1125 let err = ensure_buildd_sidecar_with_bundle(tmp.path(), None);
1126 assert!(matches!(err, Err(BuildError::NotSupported { .. })));
1127 }
1128
1129 #[test]
1130 fn files_equal_detects_differences() {
1131 let tmp = tempfile::tempdir().unwrap();
1132 let a = tmp.path().join("a");
1133 let b = tmp.path().join("b");
1134 let base = vec![0xABu8; 200 * 1024];
1136 fs::write(&a, &base).unwrap();
1137 fs::write(&b, &base).unwrap();
1138 assert!(files_equal(&a, &b).unwrap());
1139
1140 let mut diff = base.clone();
1142 diff[100 * 1024] = 0xCD;
1143 fs::write(&b, &diff).unwrap();
1144 assert!(!files_equal(&a, &b).unwrap());
1145
1146 fs::write(&b, vec![0xABu8; 100 * 1024]).unwrap();
1148 assert!(!files_equal(&a, &b).unwrap());
1149 }
1150 }
1151}