nex-pkg 0.18.0

Package manager UX for nix-darwin + homebrew
Documentation
use std::fmt;
use std::path::{Path, PathBuf};

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

pub const MACHINE_PROFILE_FILE: &str = "machine-profile.toml";
pub const MACHINE_PROFILE_SCHEMA_V1: &str = "io.styrene.nex.machine-profile.v1";

#[derive(Debug, Clone, Deserialize)]
pub struct MachineProfileDocument {
    pub machine_profile: MachineProfile,
    #[serde(default)]
    pub dependencies: Vec<MachineProfileDependency>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct MachineProfile {
    pub schema: String,
    pub id: String,
    pub slug: String,
    pub name: String,
    pub version: String,
    pub description: Option<String>,
    pub license: Option<String>,
    pub min_nex: String,
    pub defaults: MachineProfileDefaults,
    pub safety: MachineProfileSafety,
    pub secrets: Option<MachineProfileSecrets>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct MachineProfileDefaults {
    pub mode: MachineProfileMode,
    pub target: MachineProfileTarget,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum MachineProfileMode {
    PlanOnly,
    ImageBuild,
    VmBuild,
    Provision,
}

impl fmt::Display for MachineProfileMode {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let value = match self {
            Self::PlanOnly => "plan-only",
            Self::ImageBuild => "image-build",
            Self::VmBuild => "vm-build",
            Self::Provision => "provision",
        };
        f.write_str(value)
    }
}

#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum MachineProfileTarget {
    NixDevshell,
    OciImage,
    Vm,
    PhysicalMachine,
}

impl fmt::Display for MachineProfileTarget {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let value = match self {
            Self::NixDevshell => "nix-devshell",
            Self::OciImage => "oci-image",
            Self::Vm => "vm",
            Self::PhysicalMachine => "physical-machine",
        };
        f.write_str(value)
    }
}

#[derive(Debug, Clone, Deserialize)]
pub struct MachineProfileSafety {
    pub default_destructive: bool,
    pub requires_confirmation: bool,
    pub requires_target_attestation: bool,
    pub allowed_targets: Vec<MachineProfileTarget>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct MachineProfileSecrets {
    #[serde(default)]
    pub required: Vec<String>,
    #[serde(default)]
    pub optional: Vec<String>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct MachineProfileDependency {
    pub kind: MachineProfileDependencyKind,
    pub id: String,
    pub version: String,
    pub required: bool,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum MachineProfileDependencyKind {
    ForgeTemplate,
}

impl fmt::Display for MachineProfileDependencyKind {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::ForgeTemplate => f.write_str("forge-template"),
        }
    }
}

impl MachineProfileDocument {
    pub fn from_path(path: &Path) -> Result<Self> {
        let manifest_path = resolve_manifest_path(path)?;
        let content = std::fs::read_to_string(&manifest_path)
            .with_context(|| format!("reading {}", manifest_path.display()))?;
        Self::from_str(&content).with_context(|| format!("parsing {}", manifest_path.display()))
    }

    pub fn from_str(content: &str) -> Result<Self> {
        let document: Self = toml::from_str(content).context("invalid machine profile TOML")?;
        document.validate()?;
        Ok(document)
    }

    pub fn validate(&self) -> Result<()> {
        let profile = &self.machine_profile;
        require_non_empty("machine_profile.schema", &profile.schema)?;
        require_non_empty("machine_profile.id", &profile.id)?;
        require_non_empty("machine_profile.slug", &profile.slug)?;
        require_non_empty("machine_profile.name", &profile.name)?;
        require_non_empty("machine_profile.version", &profile.version)?;
        require_non_empty("machine_profile.min_nex", &profile.min_nex)?;

        if profile.schema != MACHINE_PROFILE_SCHEMA_V1 {
            bail!(
                "unsupported machine profile schema '{}'; expected '{}'",
                profile.schema,
                MACHINE_PROFILE_SCHEMA_V1
            );
        }

        if !profile
            .safety
            .allowed_targets
            .contains(&profile.defaults.target)
        {
            bail!(
                "default target '{}' is not listed in machine_profile.safety.allowed_targets",
                profile.defaults.target
            );
        }

        if profile.safety.default_destructive && !profile.safety.requires_confirmation {
            bail!("destructive machine profiles must require confirmation");
        }

        if profile.defaults.target == MachineProfileTarget::PhysicalMachine
            && !profile.safety.requires_target_attestation
        {
            bail!("physical-machine targets require target attestation");
        }

        if let Some(secrets) = &profile.secrets {
            for secret in secrets.required.iter().chain(secrets.optional.iter()) {
                validate_secret_name(secret)?;
            }
        }

        for dependency in &self.dependencies {
            require_non_empty("dependencies.id", &dependency.id)?;
            require_non_empty("dependencies.version", &dependency.version)?;
            let _ = dependency.kind.to_string();
            let _ = dependency.required;
        }

        Ok(())
    }
}

pub fn resolve_manifest_path(path: &Path) -> Result<PathBuf> {
    if path.is_dir() {
        Ok(path.join(MACHINE_PROFILE_FILE))
    } else {
        Ok(path.to_path_buf())
    }
}

fn require_non_empty(field: &str, value: &str) -> Result<()> {
    if value.trim().is_empty() {
        bail!("{field} is required");
    }
    Ok(())
}

fn validate_secret_name(secret: &str) -> Result<()> {
    require_non_empty("secret name", secret)?;
    if secret.contains('=') || secret.contains(':') || secret.contains('/') || secret.contains('\\') {
        bail!("secret '{}' must be a name, not a value or path", secret);
    }
    if !secret
        .chars()
        .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
    {
        bail!(
            "secret '{}' must use uppercase environment-name syntax [A-Z0-9_]",
            secret
        );
    }
    Ok(())
}

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

    fn valid_manifest() -> &'static str {
        r#"
[machine_profile]
schema = "io.styrene.nex.machine-profile.v1"
id = "io.styrene.nex.machine-profile.example"
slug = "example"
name = "Example Machine Profile"
version = "1.0.0"
description = "Reusable machine materialization policy."
license = "MIT"
min_nex = "0.18.0"

[machine_profile.defaults]
mode = "plan-only"
target = "oci-image"

[machine_profile.safety]
default_destructive = false
requires_confirmation = true
requires_target_attestation = true
allowed_targets = ["nix-devshell", "oci-image", "vm", "physical-machine"]

[machine_profile.secrets]
required = ["GITHUB_TOKEN"]
optional = ["AWS_PROFILE"]

[[dependencies]]
kind = "forge-template"
id = "nixos-workstation"
version = ">=1.0.0"
required = true
"#
    }

    #[test]
    fn valid_machine_profile_passes() {
        MachineProfileDocument::from_str(valid_manifest()).expect("valid manifest");
    }

    #[test]
    fn rejects_wrong_schema() {
        let manifest = valid_manifest().replace(MACHINE_PROFILE_SCHEMA_V1, "wrong");
        let error = MachineProfileDocument::from_str(&manifest).expect_err("schema rejected");
        assert!(error.to_string().contains("unsupported machine profile schema"));
    }

    #[test]
    fn rejects_secret_values() {
        let manifest = valid_manifest().replace("GITHUB_TOKEN", "GITHUB_TOKEN=secret");
        let error = MachineProfileDocument::from_str(&manifest).expect_err("secret value rejected");
        assert!(error.to_string().contains("must be a name"));
    }

    #[test]
    fn rejects_physical_machine_without_attestation() {
        let manifest = valid_manifest()
            .replace("target = \"oci-image\"", "target = \"physical-machine\"")
            .replace("requires_target_attestation = true", "requires_target_attestation = false");
        let error = MachineProfileDocument::from_str(&manifest).expect_err("attestation required");
        let message = format!("{error:#}");
        assert!(message.contains("physical-machine targets require target attestation"));
    }
}