nex-pkg 0.25.3

Package manager UX for nix-darwin + homebrew
Documentation
use std::fs::File;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::Command;

use anyhow::{bail, Context, Result};

use crate::armory_lock::{self, LockedPackage, PackageLock};

#[derive(Debug, Clone)]
pub struct StoreRecord {
    pub package_ref: String,
    pub path: PathBuf,
    pub verified: bool,
}

pub fn materialize_lock() -> Result<Vec<StoreRecord>> {
    let mut lock = armory_lock::read_package_lock()?;
    let records = materialize_package_lock(&mut lock)?;
    armory_lock::write_package_lock(&lock)?;
    if let Some(activation_lock) = armory_lock::activation_lock_for_package_lock(&lock)? {
        armory_lock::write_activation_lock(&activation_lock)?;
    }
    Ok(records)
}

pub fn materialize_package_lock(lock: &mut PackageLock) -> Result<Vec<StoreRecord>> {
    enforce_trust_policy(lock)?;
    let mut records = Vec::new();
    for package in &mut lock.packages {
        let record = materialize_package(package)?;
        package.path = Some(record.path.display().to_string());
        package.verified = record.verified;
        package.installed_at = Some(current_timestamp());
        records.push(record);
    }
    Ok(records)
}

fn enforce_trust_policy(lock: &PackageLock) -> Result<()> {
    // Phase 3 has digest verification but no signature/attestation contract yet.
    // Signed registries therefore fail closed instead of pretending digest-only
    // verification satisfies a signed-trust policy.
    for registry in &lock.registries {
        if registry
            .trust
            .as_deref()
            .is_some_and(|trust| trust.eq_ignore_ascii_case("signed"))
        {
            bail!(
                "registry {} requires signed packages, but signature verification is not implemented yet",
                registry.name
            );
        }
    }
    Ok(())
}

fn materialize_package(package: &LockedPackage) -> Result<StoreRecord> {
    let Some(oci_ref) = &package.oci_ref else {
        bail!(
            "package {} has no ociRef; cannot materialize in store phase",
            package.package_ref
        );
    };
    let Some(digest) = &package.digest else {
        bail!(
            "package {} has no digest; refusing unverified materialization",
            package.package_ref
        );
    };

    let path = match package.path.as_deref() {
        Some(path) => PathBuf::from(path),
        None => store_path_for_digest(digest)?,
    };
    if path.exists() {
        let actual = compute_path_sha256(&path)?;
        if actual != *digest {
            bail!(
                "digest mismatch for existing store path {}: lock declares {}, computed {}",
                path.display(),
                digest,
                actual
            );
        }
        validate_package(package, &path)?;
        return Ok(StoreRecord {
            package_ref: package.package_ref.clone(),
            path,
            verified: true,
        });
    }

    std::fs::create_dir_all(&path)?;
    fetch_oci(oci_ref, digest, &path)?;
    let actual = compute_path_sha256(&path)?;
    if actual != *digest {
        let _ = std::fs::remove_dir_all(&path);
        bail!(
            "digest mismatch for {}: registry declared {}, fetched {}",
            package.package_ref,
            digest,
            actual
        );
    }
    validate_package(package, &path)?;

    Ok(StoreRecord {
        package_ref: package.package_ref.clone(),
        path,
        verified: true,
    })
}

fn fetch_oci(oci_ref: &str, digest: &str, output: &Path) -> Result<()> {
    let status = Command::new("oras")
        .args(["pull", oci_ref, "--output"])
        .arg(output)
        .status()
        .context("oras is required for Armory OCI materialization; install it with `brew install oras`, `nix profile install nixpkgs#oras`, or your system package manager")?;
    if !status.success() {
        bail!("oras pull failed for {oci_ref}");
    }

    if !digest.starts_with("sha256:") {
        bail!("unsupported digest format {digest}; expected sha256:<hex>");
    }
    Ok(())
}

fn validate_package(package: &LockedPackage, path: &Path) -> Result<()> {
    let kind = package
        .package_ref
        .split_once('/')
        .map(|(kind, _)| kind)
        .unwrap_or_default();
    match kind {
        "machine-profile" | "materialization-payload" => {
            let report = crate::artifact::check_artifact_dir(path);
            if !report.ok {
                bail!(
                    "artifact validation failed for {} at {}",
                    package.package_ref,
                    path.display()
                );
            }
        }
        "forge-template" => bail!(
            "forge-template package validation is not supported yet for {}; refusing to mark installed",
            package.package_ref
        ),
        _ => {}
    }
    Ok(())
}

fn compute_path_sha256(path: &Path) -> Result<String> {
    if path.is_file() {
        return compute_file_sha256(path);
    }
    if path.is_dir() {
        let mut files = Vec::new();
        collect_files(path, path, &mut files)?;
        let mut content = String::new();
        for (relative, digest) in files {
            content.push_str(&digest);
            content.push_str("  ");
            content.push_str(&relative);
            content.push('\n');
        }
        return Ok(format!("sha256:{}", sha256_hex(content.as_bytes())));
    }
    bail!(
        "cannot compute digest for non-file/non-directory path {}",
        path.display()
    )
}

fn collect_files(root: &Path, path: &Path, files: &mut Vec<(String, String)>) -> Result<()> {
    let mut entries = std::fs::read_dir(path)
        .with_context(|| format!("reading {}", path.display()))?
        .collect::<std::result::Result<Vec<_>, _>>()?;
    entries.sort_by_key(|entry| entry.path());
    for entry in entries {
        let path = entry.path();
        let metadata = entry.metadata()?;
        if metadata.is_dir() {
            collect_files(root, &path, files)?;
        } else if metadata.is_file() {
            let relative = path
                .strip_prefix(root)?
                .to_string_lossy()
                .replace('\\', "/");
            files.push((relative, compute_file_sha256(&path)?));
        }
    }
    Ok(())
}

fn compute_file_sha256(path: &Path) -> Result<String> {
    let mut file = File::open(path).with_context(|| format!("opening {}", path.display()))?;
    let mut bytes = Vec::new();
    file.read_to_end(&mut bytes)?;
    Ok(format!("sha256:{}", sha256_hex(&bytes)))
}

fn sha256_hex(bytes: &[u8]) -> String {
    use sha2::{Digest, Sha256};
    let digest = Sha256::digest(bytes);
    hex::encode(digest)
}

fn current_timestamp() -> String {
    match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
        Ok(duration) => duration.as_secs().to_string(),
        Err(_) => "0".to_string(),
    }
}

pub fn store_path_for_digest(digest: &str) -> Result<PathBuf> {
    let Some(hex) = digest.strip_prefix("sha256:") else {
        bail!("unsupported digest format {digest}; expected sha256:<hex>");
    };
    if hex.is_empty() || !hex.chars().all(|ch| ch.is_ascii_hexdigit()) {
        bail!("invalid sha256 digest {digest}");
    }
    Ok(store_dir()?.join(format!("sha256-{hex}")))
}

fn store_dir() -> Result<PathBuf> {
    let home = std::env::var_os("HOME").context("HOME is not set")?;
    Ok(PathBuf::from(home).join(".local/share/nex/store"))
}

#[cfg(test)]
mod tests {
    use super::{compute_path_sha256, materialize_package_lock, store_path_for_digest};
    use crate::armory_lock::{
        LockedPackage, LockedRegistry, LockedRoot, PackageLock, PACKAGE_LOCK_SCHEMA,
    };

    #[test]
    fn computes_content_addressed_store_path() {
        let path = store_path_for_digest("sha256:abcdef").expect("store path");
        assert!(path.ends_with(".local/share/nex/store/sha256-abcdef"));
    }

    #[test]
    fn rejects_non_sha256_digest() {
        let error = store_path_for_digest("sha512:abcdef").expect_err("digest rejected");
        assert!(format!("{error:#}").contains("unsupported digest"));
    }

    #[test]
    fn computes_file_sha256_digest() {
        let dir = tempfile::tempdir().expect("temp dir");
        let file = dir.path().join("payload.txt");
        std::fs::write(&file, b"abc").expect("write payload");
        assert_eq!(
            compute_path_sha256(&file).expect("digest"),
            "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
        );
    }

    #[test]
    fn computes_directory_digest_from_sorted_file_digests() {
        let dir = tempfile::tempdir().expect("temp dir");
        std::fs::write(dir.path().join("b.txt"), b"b").expect("write b");
        std::fs::write(dir.path().join("a.txt"), b"a").expect("write a");
        let digest = compute_path_sha256(dir.path()).expect("digest");
        assert!(digest.starts_with("sha256:"));
    }

    #[test]
    fn materialization_updates_lock_entries() {
        let dir = tempfile::tempdir().expect("temp dir");
        let digest = compute_path_sha256(dir.path()).expect("digest");
        let mut lock = PackageLock {
            schema: PACKAGE_LOCK_SCHEMA.to_string(),
            registries: vec![LockedRegistry {
                name: "test".to_string(),
                url: "https://example.test/index.json".to_string(),
                trust: None,
            }],
            roots: vec![LockedRoot {
                package_ref: "profile/root".to_string(),
            }],
            packages: vec![LockedPackage {
                package_ref: "profile/root".to_string(),
                version: Some("1.0.0".to_string()),
                registry: "test".to_string(),
                oci_ref: Some("oci://example/root".to_string()),
                digest: Some(digest),
                dependencies: Vec::new(),
                path: Some(dir.path().display().to_string()),
                verified: false,
                installed_at: None,
            }],
        };

        let records = materialize_package_lock(&mut lock).expect("materialize existing path");

        assert_eq!(records.len(), 1);
        assert!(lock.packages[0].verified);
        assert!(lock.packages[0].installed_at.is_some());
    }

    #[test]
    fn signed_registry_fails_closed_until_signature_verification_exists() {
        let mut lock = PackageLock {
            schema: PACKAGE_LOCK_SCHEMA.to_string(),
            registries: vec![LockedRegistry {
                name: "signed".to_string(),
                url: "https://example.test/index.json".to_string(),
                trust: Some("signed".to_string()),
            }],
            roots: vec![LockedRoot {
                package_ref: "profile/root".to_string(),
            }],
            packages: Vec::new(),
        };

        let error = materialize_package_lock(&mut lock).expect_err("signed registry rejected");

        assert!(format!("{error:#}").contains("signature verification is not implemented"));
    }

    #[test]
    fn forge_template_validation_fails_closed() {
        let dir = tempfile::tempdir().expect("temp dir");
        let package = LockedPackage {
            package_ref: "forge-template/base".to_string(),
            version: Some("1.0.0".to_string()),
            registry: "test".to_string(),
            oci_ref: Some("oci://example/base".to_string()),
            digest: Some(compute_path_sha256(dir.path()).expect("digest")),
            dependencies: Vec::new(),
            path: Some(dir.path().display().to_string()),
            verified: false,
            installed_at: None,
        };

        let error = super::validate_package(&package, dir.path()).expect_err("unsupported");

        assert!(format!("{error:#}").contains("forge-template package validation is not supported"));
    }
}