Skip to main content

zlayer_builder/buildah/
install.rs

1//! Buildah installation and discovery
2//!
3//! This module provides functionality to find existing buildah installations
4//! or provide helpful error messages for installing buildah on various platforms.
5//!
6//! Sidecar (`zlayer-buildd`) installation lives in the [`buildd`] submodule
7//! at the bottom of this file. The buildah-sidecar plan (Option B) ships a
8//! Go binary alongside `zlayer` and the operator typically copies it into
9//! `${ZLAYER_DATA_DIR}/bin/zlayer-buildd`; auto-install fetches from
10//! release artifacts when that's possible.
11
12use std::path::{Path, PathBuf};
13use std::process::Stdio;
14
15use tokio::process::Command;
16use tracing::{debug, info, trace, warn};
17
18/// Minimum required buildah version
19const MIN_BUILDAH_VERSION: &str = "1.28.0";
20
21/// Buildah installation manager
22///
23/// Handles finding existing buildah installations and providing
24/// installation guidance when buildah is not found.
25#[derive(Debug, Clone)]
26pub struct BuildahInstaller {
27    /// Where to store downloaded buildah binary (for future use)
28    install_dir: PathBuf,
29    /// Minimum required buildah version
30    min_version: &'static str,
31}
32
33/// Information about a discovered buildah installation
34#[derive(Debug, Clone)]
35pub struct BuildahInstallation {
36    /// Path to buildah binary
37    pub path: PathBuf,
38    /// Installed version
39    pub version: String,
40}
41
42/// Errors that can occur during buildah installation/discovery
43#[derive(Debug, thiserror::Error)]
44pub enum InstallError {
45    /// Buildah was not found on the system
46    #[error("Buildah not found. {}", install_instructions())]
47    NotFound,
48
49    /// Buildah version is too old
50    #[error("Buildah version {found} is below minimum required version {required}")]
51    VersionTooOld {
52        /// The version that was found
53        found: String,
54        /// The minimum required version
55        required: String,
56    },
57
58    /// Platform is not supported
59    #[error("Unsupported platform: {os}/{arch}")]
60    UnsupportedPlatform {
61        /// Operating system
62        os: String,
63        /// CPU architecture
64        arch: String,
65    },
66
67    /// Download failed (for future binary download support)
68    #[error("Download failed: {0}")]
69    DownloadFailed(String),
70
71    /// IO error during installation
72    #[error("IO error: {0}")]
73    Io(#[from] std::io::Error),
74
75    /// Failed to parse version output
76    #[error("Failed to parse buildah version output: {0}")]
77    VersionParse(String),
78
79    /// Failed to execute buildah
80    #[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    /// Create installer with default paths
92    ///
93    /// User install directory: `~/.zlayer/bin/`
94    /// System install directory: `/usr/local/lib/zlayer/`
95    #[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    /// Create with custom install directory
105    #[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    /// Get the install directory
114    #[must_use]
115    pub fn install_dir(&self) -> &Path {
116        &self.install_dir
117    }
118
119    /// Get the minimum required version
120    #[must_use]
121    pub fn min_version(&self) -> &str {
122        self.min_version
123    }
124
125    /// Find existing buildah installation
126    ///
127    /// Searches for buildah in the following locations (in order):
128    /// 1. PATH environment variable
129    /// 2. `~/.zlayer/bin/buildah`
130    /// 3. `/usr/local/lib/zlayer/buildah`
131    /// 4. `/usr/bin/buildah`
132    /// 5. `/usr/local/bin/buildah`
133    pub async fn find_existing(&self) -> Option<BuildahInstallation> {
134        // Search paths in priority order
135        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        // Also try using `which` to find buildah in PATH
158        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    /// Check if buildah is installed and meets version requirements
182    ///
183    /// Returns the installation if found and valid, otherwise returns an error.
184    ///
185    /// # Errors
186    ///
187    /// Returns an error if buildah is not found or if the version is below the minimum.
188    pub async fn check(&self) -> Result<BuildahInstallation, InstallError> {
189        let installation = self.find_existing().await.ok_or(InstallError::NotFound)?;
190
191        // Check version meets minimum requirements
192        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    /// Get buildah version from a binary
203    ///
204    /// Runs `buildah --version` and parses the output.
205    /// Expected format: "buildah version 1.33.0 (image-spec 1.0.2-dev, runtime-spec 1.0.2-dev)"
206    ///
207    /// # Errors
208    ///
209    /// Returns an error if the binary cannot be executed or the version output cannot be parsed.
210    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    /// Ensure buildah is available (find existing or return helpful error)
232    ///
233    /// This is the primary entry point for ensuring buildah is available.
234    /// If buildah is not found, it returns an error with installation instructions.
235    ///
236    /// # Errors
237    ///
238    /// Returns an error if buildah is not found or the version is insufficient.
239    pub async fn ensure(&self) -> Result<BuildahInstallation, InstallError> {
240        // First try to find existing installation
241        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    /// Download buildah binary for current platform
269    ///
270    /// Currently returns an error with installation instructions.
271    /// Future versions may download static binaries from GitHub releases.
272    ///
273    /// # Errors
274    ///
275    /// Returns an error if the platform is unsupported or automatic download is unavailable.
276    #[allow(clippy::unused_async)]
277    pub async fn download(&self) -> Result<BuildahInstallation, InstallError> {
278        let (os, arch) = current_platform();
279
280        // Check if platform is supported
281        if !is_platform_supported() {
282            return Err(InstallError::UnsupportedPlatform {
283                os: os.to_string(),
284                arch: arch.to_string(),
285            });
286        }
287
288        // For now, return helpful error with installation instructions
289        // Future: Download static binary from GitHub releases
290        Err(InstallError::DownloadFailed(format!(
291            "Automatic download not yet implemented. {}",
292            install_instructions()
293        )))
294    }
295}
296
297/// Get the current platform (OS and architecture)
298#[must_use]
299pub fn current_platform() -> (&'static str, &'static str) {
300    let os = std::env::consts::OS; // "linux", "macos", "windows"
301    let arch = std::env::consts::ARCH; // "x86_64", "aarch64"
302    (os, arch)
303}
304
305/// Check if the current platform is supported for buildah
306///
307/// Buildah is primarily a Linux tool, though it can work on macOS
308/// through virtualization.
309#[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/// Get installation instructions for the current platform
316#[must_use]
317pub fn install_instructions() -> String {
318    let (os, _arch) = current_platform();
319
320    match os {
321        "linux" => {
322            // Try to detect the Linux distribution
323            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
367// ============================================================================
368// Internal Helper Functions
369// ============================================================================
370
371/// Get the default install directory for `ZLayer` binaries
372fn default_install_dir() -> PathBuf {
373    zlayer_paths::ZLayerDirs::system_default().bin()
374}
375
376/// Get all paths to search for buildah
377fn get_search_paths(install_dir: &Path) -> Vec<PathBuf> {
378    let mut paths = vec![
379        // Custom install directory
380        install_dir.join("buildah"),
381        // User ZLayer directory
382        zlayer_paths::ZLayerDirs::system_default()
383            .bin()
384            .join("buildah"),
385        // System ZLayer directory
386        PathBuf::from("/usr/local/lib/zlayer/buildah"),
387        // Standard system paths
388        PathBuf::from("/usr/bin/buildah"),
389        PathBuf::from("/usr/local/bin/buildah"),
390        PathBuf::from("/bin/buildah"),
391    ];
392
393    // Deduplicate while preserving order
394    let mut seen = std::collections::HashSet::new();
395    paths.retain(|p| seen.insert(p.clone()));
396
397    paths
398}
399
400/// Find a binary on PATH. Uses platform-native lookup:
401/// - Windows: `where.exe` (always available in System32).
402/// - Unix: `which`, then `sh -c "command -v"` as a fallback.
403async 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                // `where.exe` prints one path per line — first hit is the
416                // one that would run.
417                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    // Also try `command -v` as a fallback (works in more shells)
443    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
461/// Parse version from buildah --version output
462///
463/// Expected formats:
464/// - "buildah version 1.33.0 (image-spec 1.0.2-dev, runtime-spec 1.0.2-dev)"
465/// - "buildah version 1.33.0"
466/// - "buildah version 1.34.0-dev"
467fn parse_version(output: &str) -> Result<String, InstallError> {
468    // Look for "buildah version X.Y.Z" pattern
469    let output = output.trim();
470
471    // Try to extract version after "version"
472    if let Some(pos) = output.to_lowercase().find("version") {
473        let after_version = &output[pos + "version".len()..].trim_start();
474
475        // Extract the version number (digits, dots, and optional suffix like -dev, -rc1)
476        // Version format: X.Y.Z or X.Y.Z-suffix
477        let version: String = after_version
478            .chars()
479            .take_while(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-')
480            .collect();
481
482        // Clean up: trim trailing hyphens if any
483        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
495/// Check if a version string meets the minimum requirement
496///
497/// Uses simple semantic version comparison.
498fn version_meets_minimum(version: &str, minimum: &str) -> bool {
499    let parse_version = |s: &str| -> Option<Vec<u32>> {
500        // Strip any trailing non-numeric parts (like "-dev")
501        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; // If we can't parse minimum, assume it's met
514    };
515
516    // Compare version parts
517    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    // If all compared parts are equal, check if version has at least as many parts
526    version_parts.len() >= minimum_parts.len()
527}
528
529/// Detect the Linux distribution
530fn detect_linux_distro() -> Option<String> {
531    // Try /etc/os-release first (most modern distros)
532    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    // Try /etc/lsb-release as fallback
541    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        // Just verify it returns non-empty strings
634        assert!(!os.is_empty());
635        assert!(!arch.is_empty());
636    }
637
638    #[test]
639    fn test_is_platform_supported() {
640        // This test will pass on Linux and macOS x86_64/aarch64
641        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        // Should end with bin
660        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        // First path should be in our custom install dir
669        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        // This binary should not exist
688        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        // Probe for a binary guaranteed to exist on each platform.
696        #[cfg(unix)]
697        let probe = "sh";
698        #[cfg(windows)]
699        let probe = "cmd.exe";
700
701        // Other tests in this crate mutate `PATH` (e.g. the discover
702        // module's missing-binary test). We must hold the shared env lock
703        // for the full duration of `find_in_path` — the spawned `which` /
704        // `where.exe` subprocess inherits PATH at spawn time, and any
705        // concurrent test that flips PATH would break this probe.
706        //
707        // Tokio's test runtime is single-threaded by default, so holding
708        // a sync `MutexGuard` across the inner await cannot deadlock
709        // worker threads (we're the only task).
710        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    // Integration test - only runs if buildah is installed
722    #[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/// Sidecar (`zlayer-buildd`) install helpers.
763///
764/// Unix-only: the sidecar binary is a Linux executable and the install
765/// path relies on Unix file-mode bits. On non-Unix targets the module is
766/// compiled away entirely.
767#[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    /// Compile-time constant: the `zlayer-buildd` version the host
777    /// expects. Bound to the same crate version as `zlayer` itself so the
778    /// daemon and sidecar are released in lockstep.
779    pub const EXPECTED_VERSION: &str = env!("CARGO_PKG_VERSION");
780
781    /// Outcome of an [`ensure_buildd_sidecar`] call.
782    #[derive(Debug)]
783    pub enum InstallOutcome {
784        /// Sidecar was already present at the canonical location.
785        AlreadyInstalled {
786            /// Path to the installed binary.
787            path: PathBuf,
788            /// Version reported by the binary's `--version` output.
789            version: String,
790        },
791        /// Sidecar was copied from a release tarball next to `zlayer`.
792        CopiedFromBundle {
793            /// Source path the binary was copied from.
794            source: PathBuf,
795            /// Destination path the binary was copied to.
796            dest: PathBuf,
797        },
798        /// Sidecar was fetched from a release artifact URL.
799        Downloaded {
800            /// URL the binary was fetched from.
801            url: String,
802            /// Destination path the binary was written to.
803            dest: PathBuf,
804        },
805        /// Sidecar was deliberately not installed (env override or
806        /// development setup). The caller is expected to ensure the
807        /// binary is reachable some other way.
808        Skipped {
809            /// Human-readable reason the install was skipped.
810            reason: String,
811        },
812    }
813
814    /// Ensure `zlayer-buildd` is installed at the canonical location.
815    ///
816    /// Resolution order:
817    ///   1. `ZLAYER_BUILDD_BIN` set → skipped (development override).
818    ///   2. Canonical path already populated and the binary's `--version`
819    ///      matches [`EXPECTED_VERSION`] → already installed.
820    ///   3. A `zlayer-buildd` binary lives next to the currently-running
821    ///      `zlayer` binary (e.g. release tarball with both binaries in
822    ///      one dir) → copy it.
823    ///   4. Fetch from the release artifact URL.
824    ///
825    /// # Errors
826    ///
827    /// Returns [`BuildError::NotSupported`] when none of the resolution
828    /// steps succeed (no env override, no canonical install, no bundled
829    /// binary alongside `zlayer`). The HTTP-download path is intentionally
830    /// not implemented in this module — the air-gapped bundled path
831    /// covers production.
832    pub fn ensure_buildd_sidecar(install_dir: &Path) -> Result<InstallOutcome> {
833        // 1) Env override.
834        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    /// Install/refresh decision with the bundled-sidecar source resolved by the
844    /// caller. Split out from [`ensure_buildd_sidecar`] so it can be unit-tested
845    /// without depending on `env::current_exe()` (which `bundled_sidecar_path`
846    /// uses to locate the binary next to the running `zlayer`).
847    ///
848    /// The bundled sidecar is the source of truth: when it exists we install it
849    /// whenever the destination is missing or its CONTENT differs. The previous
850    /// implementation short-circuited on `version == EXPECTED_VERSION`, but
851    /// `EXPECTED_VERSION` is the static workspace version (`0.0.0-dev`), so an
852    /// installed copy always matched and a freshly rebuilt sidecar was never
853    /// re-copied — the daemon kept running stale buildd code. Comparing content
854    /// makes a rebuild always take effect.
855    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        // A bundled sidecar is authoritative: install it whenever dest is
862        // absent or content-different.
863        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        // No bundle to compare against (air-gapped: the installed copy is the
880        // only one). Accept an existing executable as already installed.
881        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        // Nothing installed and nothing bundled.
889        //
890        // We deliberately do NOT implement HTTP fetch in this module. The
891        // pattern in this codebase (per the existing buildah-install logic
892        // above and the keg-provisioner flow) is to delegate network
893        // fetches to `zlayer_registry::client` or `reqwest` callers and
894        // surface a clear error otherwise. For the air-gapped bundled path to
895        // suffice in 99% of cases, returning an actionable error here is
896        // appropriate.
897        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    /// Byte-for-byte equality of two files. Compares length first (cheap reject
910    /// for the common "rebuilt, so different" case), then streams both files in
911    /// fixed-size chunks so we never hold a ~30-50 MB binary fully in memory.
912    /// Dependency-free (no hashing crate).
913    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            // Fill buf_b with exactly `na` bytes to compare against (lengths are
930            // already known equal, so a short read here means a truncated file).
931            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    /// Look for a `zlayer-buildd` binary in the same directory as the
946    /// currently-running `zlayer` executable. This is the release-tarball
947    /// pattern.
948    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        // The Go side emits a single JSON line like:
971        //   {"buildah_version":"...","go_version":"...","sidecar_version":"..."}
972        // We look for the sidecar_version value via a tolerant substring
973        // scan to avoid pulling in a JSON dep for this single use.
974        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            // SAFETY: tests in this module serialize env mutations via
1014            // `ENV_LOCK`, so no other thread observes the inconsistent
1015            // intermediate state.
1016            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            // SAFETY: see above.
1022            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            // SAFETY: tests in this module serialize env mutations via
1032            // `ENV_LOCK`.
1033            unsafe {
1034                env::remove_var("ZLAYER_BUILDD_BIN");
1035            }
1036            // Make sure no zlayer-buildd is next to our test binary.
1037            // (env::current_exe() in tests points at the test runner,
1038            // which won't have a sibling zlayer-buildd unless the local
1039            // dev box happens to have one — we just check the error path
1040            // when both bundled lookup and dest are absent.)
1041            let tmp = tempfile::tempdir().unwrap();
1042            let err = ensure_buildd_sidecar(tmp.path());
1043            // Allow either "AlreadyInstalled" or the error if a stale
1044            // binary somehow got copied next to the test runner; the
1045            // primary assertion is that the function doesn't panic.
1046            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        /// Write `content` to `path` and mark it executable.
1056        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            // The bug repro: an installed sidecar and a rebuilt bundle report
1064            // the SAME version (`0.0.0-dev`), so the old version-equality check
1065            // returned AlreadyInstalled and left the stale binary in place.
1066            // Content-based comparison must re-copy.
1067            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            // The stale dest is now byte-identical to the rebuilt bundle.
1083            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            // Larger than the 64 KiB chunk to exercise the streaming loop.
1135            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            // Same length, one differing byte deep in the second chunk.
1141            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            // Different length.
1147            fs::write(&b, vec![0xABu8; 100 * 1024]).unwrap();
1148            assert!(!files_equal(&a, &b).unwrap());
1149        }
1150    }
1151}