zlayer-toolchain 0.14.1

Runtime toolchain provisioning (macOS Homebrew bottle resolver/installer) for ZLayer
Documentation
//! On-disk I/O for the toolchain lockfile (`zlayer-toolchains.lock`).
//!
//! The lockfile shapes ([`ToolchainLockfile`], [`LockedTool`]) are pure serde
//! types in `zlayer-types`; this module adds the TOML load/save, the
//! `(tool, platform, arch)` lookup/upsert, and the sha256 recomputation helper.
//! The provisioning crate only ever *consumes* a lock (verifying downloads
//! against pinned digests) — it never writes one. The writer is the CLI
//! (`zlayer toolchains lock`).
//!
//! A [`ToolchainLockfile`] is stored as TOML with a stable `[[tool]]` ordering
//! (sorted by `(tool, platform, arch)`), so the file is diff-friendly across
//! regenerations.

use std::path::Path;

use sha2::{Digest, Sha256};

use crate::error::{Result, ToolchainError};

pub use zlayer_types::toolchain_lock::{LockedTool, ToolchainLockfile, TOOLCHAIN_LOCK_SCHEMA};

/// Canonical file name for the toolchain lockfile.
pub const LOCKFILE_NAME: &str = "zlayer-toolchains.lock";

/// I/O + query extension methods for the pure-serde [`ToolchainLockfile`].
///
/// Implemented as an extension trait because the type is defined in
/// `zlayer-types` (which must stay free of `tokio`/`toml` I/O), yet the file
/// operations belong here in the provisioning crate. With this trait in scope,
/// `ToolchainLockfile::new()` / `ToolchainLockfile::load(path)` and
/// `lock.save(path)` / `lock.lookup(..)` / `lock.upsert(..)` all read naturally.
pub trait ToolchainLockfileExt: Sized {
    /// A fresh, empty lockfile stamped with the current schema + timestamp.
    #[must_use]
    fn new() -> Self;

    /// Load a lockfile from `path`.
    ///
    /// Returns `Ok(None)` when the file is absent (a cold repo), a loud
    /// [`ToolchainError::CacheError`] on a parse failure or a schema mismatch,
    /// and `Ok(Some(..))` otherwise.
    ///
    /// # Errors
    ///
    /// Returns [`ToolchainError::CacheError`] on a corrupt file or an
    /// unsupported schema, and [`ToolchainError::IoError`] on a read failure
    /// other than "not found".
    fn load(path: &Path) -> Result<Option<Self>>;

    /// Serialize to TOML at `path` with a stable `(tool, platform, arch)` order.
    ///
    /// # Errors
    ///
    /// Returns [`ToolchainError::CacheError`] on a serialization failure and
    /// [`ToolchainError::IoError`] on a write failure.
    fn save(&self, path: &Path) -> Result<()>;

    /// The pinned entry for `(tool, platform, arch)`, if present.
    fn lookup(&self, tool: &str, platform: &str, arch: &str) -> Option<&LockedTool>;

    /// Insert `entry`, replacing any existing pin for its
    /// `(tool, platform, arch)`.
    fn upsert(&mut self, entry: LockedTool);
}

impl ToolchainLockfileExt for ToolchainLockfile {
    fn new() -> Self {
        Self {
            schema: TOOLCHAIN_LOCK_SCHEMA,
            generated_at: chrono::Utc::now().to_rfc3339(),
            tools: Vec::new(),
        }
    }

    fn load(path: &Path) -> Result<Option<Self>> {
        let text = match std::fs::read_to_string(path) {
            Ok(text) => text,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
            Err(e) => return Err(ToolchainError::IoError(e)),
        };
        let lock: Self = toml::from_str(&text).map_err(|e| ToolchainError::CacheError {
            message: format!("failed to parse lockfile {}: {e}", path.display()),
        })?;
        if lock.schema != TOOLCHAIN_LOCK_SCHEMA {
            return Err(ToolchainError::CacheError {
                message: format!(
                    "lockfile {} has schema {} but this build supports schema {TOOLCHAIN_LOCK_SCHEMA}",
                    path.display(),
                    lock.schema
                ),
            });
        }
        Ok(Some(lock))
    }

    fn save(&self, path: &Path) -> Result<()> {
        let mut out = self.clone();
        out.schema = TOOLCHAIN_LOCK_SCHEMA;
        out.generated_at = chrono::Utc::now().to_rfc3339();
        out.tools
            .sort_by(|a, b| (&a.tool, &a.platform, &a.arch).cmp(&(&b.tool, &b.platform, &b.arch)));
        let text = toml::to_string_pretty(&out).map_err(|e| ToolchainError::CacheError {
            message: format!("failed to serialize lockfile: {e}"),
        })?;
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        std::fs::write(path, text)?;
        Ok(())
    }

    fn lookup(&self, tool: &str, platform: &str, arch: &str) -> Option<&LockedTool> {
        self.tools
            .iter()
            .find(|t| t.tool == tool && t.platform == platform && t.arch == arch)
    }

    fn upsert(&mut self, entry: LockedTool) {
        if let Some(slot) = self
            .tools
            .iter_mut()
            .find(|t| t.tool == entry.tool && t.platform == entry.platform && t.arch == entry.arch)
        {
            *slot = entry;
        } else {
            self.tools.push(entry);
        }
    }
}

/// Compute the sha256 (bare lowercase hex) of the file at `path`, streaming it in
/// chunks so a large artifact never has to be fully buffered.
///
/// # Errors
///
/// Returns [`ToolchainError::IoError`] if the file cannot be read.
pub fn compute_sha256(path: &Path) -> Result<String> {
    use std::io::Read;
    let mut file = std::fs::File::open(path)?;
    let mut hasher = Sha256::new();
    let mut buf = [0u8; 8 * 1024];
    loop {
        let n = file.read(&mut buf)?;
        if n == 0 {
            break;
        }
        hasher.update(&buf[..n]);
    }
    Ok(hex::encode(hasher.finalize()))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn tool(name: &str, platform: &str, arch: &str, version: &str) -> LockedTool {
        LockedTool {
            tool: name.to_string(),
            platform: platform.to_string(),
            arch: arch.to_string(),
            version: version.to_string(),
            url: format!("https://example/{name}-{version}"),
            sha256: "abc123".to_string(),
            resolved_at: "2026-07-01T00:00:00Z".to_string(),
        }
    }

    #[test]
    fn round_trips_through_disk() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join(LOCKFILE_NAME);

        let mut lock = ToolchainLockfile::new();
        lock.upsert(tool("git", "macos", "arm64", "2.55.0"));
        lock.upsert(tool("node@lts", "macos", "arm64", "22.1.0"));
        lock.save(&path).unwrap();

        let read = ToolchainLockfile::load(&path).unwrap().unwrap();
        assert_eq!(read.schema, TOOLCHAIN_LOCK_SCHEMA);
        assert_eq!(read.tools.len(), 2);
        assert_eq!(
            read.lookup("git", "macos", "arm64")
                .map(|t| t.version.as_str()),
            Some("2.55.0")
        );
        assert!(read.lookup("git", "macos", "x86_64").is_none());
    }

    #[test]
    fn absent_file_loads_none() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join(LOCKFILE_NAME);
        assert!(ToolchainLockfile::load(&path).unwrap().is_none());
    }

    #[test]
    fn schema_mismatch_is_a_loud_error() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join(LOCKFILE_NAME);
        std::fs::write(&path, "schema = 999\ngenerated_at = \"x\"\n").unwrap();
        let err = ToolchainLockfile::load(&path).unwrap_err();
        assert!(matches!(err, ToolchainError::CacheError { .. }));
    }

    #[test]
    fn corrupt_toml_is_a_loud_error() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join(LOCKFILE_NAME);
        std::fs::write(&path, "not = = valid toml").unwrap();
        assert!(matches!(
            ToolchainLockfile::load(&path).unwrap_err(),
            ToolchainError::CacheError { .. }
        ));
    }

    #[test]
    fn upsert_replaces_matching_key() {
        let mut lock = ToolchainLockfile::new();
        lock.upsert(tool("git", "macos", "arm64", "2.55.0"));
        lock.upsert(tool("git", "macos", "arm64", "2.56.0"));
        assert_eq!(lock.tools.len(), 1);
        assert_eq!(
            lock.lookup("git", "macos", "arm64")
                .map(|t| t.version.as_str()),
            Some("2.56.0")
        );
        // A different arch is a distinct entry.
        lock.upsert(tool("git", "macos", "x86_64", "2.56.0"));
        assert_eq!(lock.tools.len(), 2);
    }

    #[test]
    fn save_sorts_tools_by_key() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join(LOCKFILE_NAME);
        let mut lock = ToolchainLockfile::new();
        lock.upsert(tool("zig", "macos", "arm64", "0.13.0"));
        lock.upsert(tool("git", "macos", "x86_64", "2.55.0"));
        lock.upsert(tool("git", "macos", "arm64", "2.55.0"));
        lock.save(&path).unwrap();
        let read = ToolchainLockfile::load(&path).unwrap().unwrap();
        let keys: Vec<_> = read
            .tools
            .iter()
            .map(|t| (t.tool.as_str(), t.platform.as_str(), t.arch.as_str()))
            .collect();
        assert_eq!(
            keys,
            vec![
                ("git", "macos", "arm64"),
                ("git", "macos", "x86_64"),
                ("zig", "macos", "arm64"),
            ]
        );
    }

    #[test]
    fn compute_sha256_matches_known_vector() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("f.bin");
        std::fs::write(&path, b"hello world").unwrap();
        assert_eq!(
            compute_sha256(&path).unwrap(),
            "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
        );
    }
}