1use std::path::{Path, PathBuf};
7use std::process::Stdio;
8
9use tokio::process::Command;
10use tracing::{debug, info, trace, warn};
11
12const MIN_BUILDAH_VERSION: &str = "1.28.0";
14
15#[derive(Debug, Clone)]
20pub struct BuildahInstaller {
21 install_dir: PathBuf,
23 min_version: &'static str,
25}
26
27#[derive(Debug, Clone)]
29pub struct BuildahInstallation {
30 pub path: PathBuf,
32 pub version: String,
34}
35
36#[derive(Debug, thiserror::Error)]
38pub enum InstallError {
39 #[error("Buildah not found. {}", install_instructions())]
41 NotFound,
42
43 #[error("Buildah version {found} is below minimum required version {required}")]
45 VersionTooOld {
46 found: String,
48 required: String,
50 },
51
52 #[error("Unsupported platform: {os}/{arch}")]
54 UnsupportedPlatform {
55 os: String,
57 arch: String,
59 },
60
61 #[error("Download failed: {0}")]
63 DownloadFailed(String),
64
65 #[error("IO error: {0}")]
67 Io(#[from] std::io::Error),
68
69 #[error("Failed to parse buildah version output: {0}")]
71 VersionParse(String),
72
73 #[error("Failed to execute buildah: {0}")]
75 ExecutionFailed(String),
76}
77
78impl Default for BuildahInstaller {
79 fn default() -> Self {
80 Self::new()
81 }
82}
83
84impl BuildahInstaller {
85 #[must_use]
90 pub fn new() -> Self {
91 let install_dir = default_install_dir();
92 Self {
93 install_dir,
94 min_version: MIN_BUILDAH_VERSION,
95 }
96 }
97
98 #[must_use]
100 pub fn with_install_dir(dir: PathBuf) -> Self {
101 Self {
102 install_dir: dir,
103 min_version: MIN_BUILDAH_VERSION,
104 }
105 }
106
107 #[must_use]
109 pub fn install_dir(&self) -> &Path {
110 &self.install_dir
111 }
112
113 #[must_use]
115 pub fn min_version(&self) -> &str {
116 self.min_version
117 }
118
119 pub async fn find_existing(&self) -> Option<BuildahInstallation> {
128 let search_paths = get_search_paths(&self.install_dir);
130
131 for path in search_paths {
132 trace!("Checking for buildah at: {}", path.display());
133
134 if path.exists() && path.is_file() {
135 match Self::get_version(&path).await {
136 Ok(version) => {
137 info!("Found buildah at {} (version {})", path.display(), version);
138 return Some(BuildahInstallation { path, version });
139 }
140 Err(e) => {
141 debug!(
142 "Found buildah at {} but couldn't get version: {}",
143 path.display(),
144 e
145 );
146 }
147 }
148 }
149 }
150
151 if let Some(path) = find_in_path("buildah").await {
153 match Self::get_version(&path).await {
154 Ok(version) => {
155 info!(
156 "Found buildah in PATH at {} (version {})",
157 path.display(),
158 version
159 );
160 return Some(BuildahInstallation { path, version });
161 }
162 Err(e) => {
163 debug!(
164 "Found buildah in PATH at {} but couldn't get version: {}",
165 path.display(),
166 e
167 );
168 }
169 }
170 }
171
172 None
173 }
174
175 pub async fn check(&self) -> Result<BuildahInstallation, InstallError> {
183 let installation = self.find_existing().await.ok_or(InstallError::NotFound)?;
184
185 if !version_meets_minimum(&installation.version, self.min_version) {
187 return Err(InstallError::VersionTooOld {
188 found: installation.version,
189 required: self.min_version.to_string(),
190 });
191 }
192
193 Ok(installation)
194 }
195
196 pub async fn get_version(path: &Path) -> Result<String, InstallError> {
205 let output = Command::new(path)
206 .arg("--version")
207 .stdout(Stdio::piped())
208 .stderr(Stdio::piped())
209 .output()
210 .await
211 .map_err(|e| InstallError::ExecutionFailed(e.to_string()))?;
212
213 if !output.status.success() {
214 let stderr = String::from_utf8_lossy(&output.stderr);
215 return Err(InstallError::ExecutionFailed(format!(
216 "buildah --version failed: {}",
217 stderr.trim()
218 )));
219 }
220
221 let stdout = String::from_utf8_lossy(&output.stdout);
222 parse_version(&stdout)
223 }
224
225 pub async fn ensure(&self) -> Result<BuildahInstallation, InstallError> {
234 match self.check().await {
236 Ok(installation) => {
237 info!(
238 "Using buildah {} at {}",
239 installation.version,
240 installation.path.display()
241 );
242 Ok(installation)
243 }
244 Err(InstallError::VersionTooOld { found, required }) => {
245 warn!(
246 "Found buildah {} but minimum required version is {}",
247 found, required
248 );
249 Err(InstallError::VersionTooOld { found, required })
250 }
251 Err(InstallError::NotFound) => {
252 #[cfg(target_os = "macos")]
253 debug!("Buildah not available on macOS; will use native sandbox builder");
254 #[cfg(not(target_os = "macos"))]
255 warn!("Buildah not found on system");
256 Err(InstallError::NotFound)
257 }
258 Err(e) => Err(e),
259 }
260 }
261
262 #[allow(clippy::unused_async)]
271 pub async fn download(&self) -> Result<BuildahInstallation, InstallError> {
272 let (os, arch) = current_platform();
273
274 if !is_platform_supported() {
276 return Err(InstallError::UnsupportedPlatform {
277 os: os.to_string(),
278 arch: arch.to_string(),
279 });
280 }
281
282 Err(InstallError::DownloadFailed(format!(
285 "Automatic download not yet implemented. {}",
286 install_instructions()
287 )))
288 }
289}
290
291#[must_use]
293pub fn current_platform() -> (&'static str, &'static str) {
294 let os = std::env::consts::OS; let arch = std::env::consts::ARCH; (os, arch)
297}
298
299#[must_use]
304pub fn is_platform_supported() -> bool {
305 let (os, arch) = current_platform();
306 matches!((os, arch), ("linux" | "macos", "x86_64" | "aarch64"))
307}
308
309#[must_use]
311pub fn install_instructions() -> String {
312 let (os, _arch) = current_platform();
313
314 match os {
315 "linux" => {
316 if let Some(distro) = detect_linux_distro() {
318 match distro.as_str() {
319 "ubuntu" | "debian" | "pop" | "mint" | "elementary" => {
320 "Install with: sudo apt install buildah".to_string()
321 }
322 "fedora" | "rhel" | "centos" | "rocky" | "alma" => {
323 "Install with: sudo dnf install buildah".to_string()
324 }
325 "arch" | "manjaro" | "endeavouros" => {
326 "Install with: sudo pacman -S buildah".to_string()
327 }
328 "opensuse" | "suse" => "Install with: sudo zypper install buildah".to_string(),
329 "alpine" => "Install with: sudo apk add buildah".to_string(),
330 "gentoo" => "Install with: sudo emerge app-containers/buildah".to_string(),
331 "void" => "Install with: sudo xbps-install buildah".to_string(),
332 _ => "Install buildah using your distribution's package manager.\n\
333 Common commands:\n\
334 - Debian/Ubuntu: sudo apt install buildah\n\
335 - Fedora/RHEL: sudo dnf install buildah\n\
336 - Arch: sudo pacman -S buildah\n\
337 - openSUSE: sudo zypper install buildah"
338 .to_string(),
339 }
340 } else {
341 "Install buildah using your distribution's package manager.\n\
342 Common commands:\n\
343 - Debian/Ubuntu: sudo apt install buildah\n\
344 - Fedora/RHEL: sudo dnf install buildah\n\
345 - Arch: sudo pacman -S buildah\n\
346 - openSUSE: sudo zypper install buildah"
347 .to_string()
348 }
349 }
350 "macos" => "Buildah is not required on macOS -- ZLayer uses the native \
351 sandbox builder instead. If you prefer buildah, install with: \
352 brew install buildah (requires a Linux VM for container operations)."
353 .to_string(),
354 "windows" => "Buildah is not natively supported on Windows.\n\
355 Consider using WSL2 with a Linux distribution and installing buildah there."
356 .to_string(),
357 _ => format!("Buildah is not supported on {os}. Use a Linux system."),
358 }
359}
360
361fn default_install_dir() -> PathBuf {
367 zlayer_paths::ZLayerDirs::system_default().bin()
368}
369
370fn get_search_paths(install_dir: &Path) -> Vec<PathBuf> {
372 let mut paths = vec![
373 install_dir.join("buildah"),
375 zlayer_paths::ZLayerDirs::system_default()
377 .bin()
378 .join("buildah"),
379 PathBuf::from("/usr/local/lib/zlayer/buildah"),
381 PathBuf::from("/usr/bin/buildah"),
383 PathBuf::from("/usr/local/bin/buildah"),
384 PathBuf::from("/bin/buildah"),
385 ];
386
387 let mut seen = std::collections::HashSet::new();
389 paths.retain(|p| seen.insert(p.clone()));
390
391 paths
392}
393
394async fn find_in_path(binary: &str) -> Option<PathBuf> {
396 let output = Command::new("which")
397 .arg(binary)
398 .stdout(Stdio::piped())
399 .stderr(Stdio::null())
400 .output()
401 .await
402 .ok()?;
403
404 if output.status.success() {
405 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
406 if !path.is_empty() {
407 return Some(PathBuf::from(path));
408 }
409 }
410
411 let output = Command::new("sh")
413 .args(["-c", &format!("command -v {binary}")])
414 .stdout(Stdio::piped())
415 .stderr(Stdio::null())
416 .output()
417 .await
418 .ok()?;
419
420 if output.status.success() {
421 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
422 if !path.is_empty() {
423 return Some(PathBuf::from(path));
424 }
425 }
426
427 None
428}
429
430fn parse_version(output: &str) -> Result<String, InstallError> {
437 let output = output.trim();
439
440 if let Some(pos) = output.to_lowercase().find("version") {
442 let after_version = &output[pos + "version".len()..].trim_start();
443
444 let version: String = after_version
447 .chars()
448 .take_while(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-')
449 .collect();
450
451 let version = version.trim_end_matches('-');
453
454 if !version.is_empty() && version.contains('.') {
455 return Ok(version.to_string());
456 }
457 }
458
459 Err(InstallError::VersionParse(format!(
460 "Could not parse version from: {output}"
461 )))
462}
463
464fn version_meets_minimum(version: &str, minimum: &str) -> bool {
468 let parse_version = |s: &str| -> Option<Vec<u32>> {
469 let clean = s.split('-').next()?;
471 clean
472 .split('.')
473 .map(|p| p.parse::<u32>().ok())
474 .collect::<Option<Vec<_>>>()
475 };
476
477 let Some(version_parts) = parse_version(version) else {
478 return false;
479 };
480
481 let Some(minimum_parts) = parse_version(minimum) else {
482 return true; };
484
485 for (v, m) in version_parts.iter().zip(minimum_parts.iter()) {
487 match v.cmp(m) {
488 std::cmp::Ordering::Greater => return true,
489 std::cmp::Ordering::Less => return false,
490 std::cmp::Ordering::Equal => {}
491 }
492 }
493
494 version_parts.len() >= minimum_parts.len()
496}
497
498fn detect_linux_distro() -> Option<String> {
500 if let Ok(contents) = std::fs::read_to_string("/etc/os-release") {
502 for line in contents.lines() {
503 if let Some(id) = line.strip_prefix("ID=") {
504 return Some(id.trim_matches('"').to_lowercase());
505 }
506 }
507 }
508
509 if let Ok(contents) = std::fs::read_to_string("/etc/lsb-release") {
511 for line in contents.lines() {
512 if let Some(id) = line.strip_prefix("DISTRIB_ID=") {
513 return Some(id.trim_matches('"').to_lowercase());
514 }
515 }
516 }
517
518 None
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524
525 #[test]
526 fn test_parse_version_full() {
527 let output = "buildah version 1.33.0 (image-spec 1.0.2-dev, runtime-spec 1.0.2-dev)";
528 let version = parse_version(output).unwrap();
529 assert_eq!(version, "1.33.0");
530 }
531
532 #[test]
533 fn test_parse_version_simple() {
534 let output = "buildah version 1.28.0";
535 let version = parse_version(output).unwrap();
536 assert_eq!(version, "1.28.0");
537 }
538
539 #[test]
540 fn test_parse_version_with_newline() {
541 let output = "buildah version 1.33.0\n";
542 let version = parse_version(output).unwrap();
543 assert_eq!(version, "1.33.0");
544 }
545
546 #[test]
547 fn test_parse_version_dev() {
548 let output = "buildah version 1.34.0-dev";
549 let version = parse_version(output).unwrap();
550 assert_eq!(version, "1.34.0-dev");
551 }
552
553 #[test]
554 fn test_parse_version_invalid() {
555 let output = "some random output";
556 assert!(parse_version(output).is_err());
557 }
558
559 #[test]
560 fn test_version_meets_minimum_equal() {
561 assert!(version_meets_minimum("1.28.0", "1.28.0"));
562 }
563
564 #[test]
565 fn test_version_meets_minimum_greater_patch() {
566 assert!(version_meets_minimum("1.28.1", "1.28.0"));
567 }
568
569 #[test]
570 fn test_version_meets_minimum_greater_minor() {
571 assert!(version_meets_minimum("1.29.0", "1.28.0"));
572 }
573
574 #[test]
575 fn test_version_meets_minimum_greater_major() {
576 assert!(version_meets_minimum("2.0.0", "1.28.0"));
577 }
578
579 #[test]
580 fn test_version_meets_minimum_less_patch() {
581 assert!(!version_meets_minimum("1.27.5", "1.28.0"));
582 }
583
584 #[test]
585 fn test_version_meets_minimum_less_minor() {
586 assert!(!version_meets_minimum("1.20.0", "1.28.0"));
587 }
588
589 #[test]
590 fn test_version_meets_minimum_less_major() {
591 assert!(!version_meets_minimum("0.99.0", "1.28.0"));
592 }
593
594 #[test]
595 fn test_version_meets_minimum_with_dev_suffix() {
596 assert!(version_meets_minimum("1.34.0-dev", "1.28.0"));
597 }
598
599 #[test]
600 fn test_current_platform() {
601 let (os, arch) = current_platform();
602 assert!(!os.is_empty());
604 assert!(!arch.is_empty());
605 }
606
607 #[test]
608 fn test_is_platform_supported() {
609 let (os, arch) = current_platform();
611 let supported = is_platform_supported();
612
613 match (os, arch) {
614 ("linux" | "macos", "x86_64" | "aarch64") => assert!(supported),
615 _ => assert!(!supported),
616 }
617 }
618
619 #[test]
620 fn test_install_instructions_not_empty() {
621 let instructions = install_instructions();
622 assert!(!instructions.is_empty());
623 }
624
625 #[test]
626 fn test_default_install_dir() {
627 let dir = default_install_dir();
628 assert!(dir.to_string_lossy().contains("bin") || dir.to_string_lossy().contains("zlayer"));
630 }
631
632 #[test]
633 fn test_get_search_paths_not_empty() {
634 let install_dir = PathBuf::from("/tmp/zlayer-test");
635 let paths = get_search_paths(&install_dir);
636 assert!(!paths.is_empty());
637 assert!(paths[0].starts_with("/tmp/zlayer-test"));
639 }
640
641 #[test]
642 fn test_installer_creation() {
643 let installer = BuildahInstaller::new();
644 assert_eq!(installer.min_version(), MIN_BUILDAH_VERSION);
645 }
646
647 #[test]
648 fn test_installer_with_custom_dir() {
649 let custom_dir = PathBuf::from("/custom/path");
650 let installer = BuildahInstaller::with_install_dir(custom_dir.clone());
651 assert_eq!(installer.install_dir(), custom_dir);
652 }
653
654 #[tokio::test]
655 async fn test_find_in_path_nonexistent() {
656 let result = find_in_path("this_binary_should_not_exist_12345").await;
658 assert!(result.is_none());
659 }
660
661 #[tokio::test]
662 async fn test_find_in_path_exists() {
663 let result = find_in_path("sh").await;
665 assert!(result.is_some());
666 }
667
668 #[tokio::test]
670 #[ignore = "requires buildah to be installed"]
671 async fn test_find_existing_buildah() {
672 let installer = BuildahInstaller::new();
673 let result = installer.find_existing().await;
674 assert!(result.is_some());
675 let installation = result.unwrap();
676 assert!(installation.path.exists());
677 assert!(!installation.version.is_empty());
678 }
679
680 #[tokio::test]
681 #[ignore = "requires buildah to be installed"]
682 async fn test_check_buildah() {
683 let installer = BuildahInstaller::new();
684 let result = installer.check().await;
685 assert!(result.is_ok());
686 }
687
688 #[tokio::test]
689 #[ignore = "requires buildah to be installed"]
690 async fn test_ensure_buildah() {
691 let installer = BuildahInstaller::new();
692 let result = installer.ensure().await;
693 assert!(result.is_ok());
694 }
695
696 #[tokio::test]
697 #[ignore = "requires buildah to be installed"]
698 async fn test_get_version() {
699 let installer = BuildahInstaller::new();
700 if let Some(installation) = installer.find_existing().await {
701 let version = BuildahInstaller::get_version(&installation.path).await;
702 assert!(version.is_ok());
703 let version = version.unwrap();
704 assert!(version.contains('.'));
705 }
706 }
707}