capulus 0.4.3

Shared support helpers for komputation CLI tools.
Documentation
use std::path::Path;

use semver::Version;
use serde::{Deserialize, Serialize};

#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case", tag = "kind", deny_unknown_fields)]
pub enum CargoRegistry {
    CratesIo,
    Private {
        name: String,
        index: String,
        token: String,
        ca_pem: String,
    },
}

impl std::fmt::Debug for CargoRegistry {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::CratesIo => formatter.write_str("CratesIo"),
            Self::Private { name, index, .. } => formatter
                .debug_struct("Private")
                .field("name", name)
                .field("index", index)
                .field("token", &"[REDACTED]")
                .field("ca_pem", &"[REDACTED]")
                .finish(),
        }
    }
}

impl CargoRegistry {
    pub fn private(
        name: impl Into<String>,
        index: impl Into<String>,
        token: impl Into<String>,
        ca_pem: impl Into<String>,
    ) -> Result<Self, ReleaseValidationError> {
        let registry = Self::Private {
            name: name.into(),
            index: index.into(),
            token: token.into(),
            ca_pem: ca_pem.into(),
        };
        registry.validate()?;
        Ok(registry)
    }

    pub fn validate(&self) -> Result<(), ReleaseValidationError> {
        let Self::Private {
            name,
            index,
            token,
            ca_pem,
        } = self
        else {
            return Ok(());
        };
        if name.is_empty()
            || !name.bytes().all(|byte| {
                byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_')
            })
        {
            return Err(ReleaseValidationError::RegistryName);
        }
        let url = index.strip_prefix("sparse+").unwrap_or(index);
        if !url.starts_with("https://") || url.bytes().any(|byte| byte.is_ascii_control()) {
            return Err(ReleaseValidationError::RegistryIndex);
        }
        if token.trim().is_empty() || token.len() > 16 * 1024 || token.bytes().any(|b| b == 0) {
            return Err(ReleaseValidationError::RegistryToken);
        }
        if ca_pem.len() > 1024 * 1024
            || !ca_pem.contains("-----BEGIN CERTIFICATE-----")
            || !ca_pem.contains("-----END CERTIFICATE-----")
        {
            return Err(ReleaseValidationError::RegistryCa);
        }
        Ok(())
    }

    pub(crate) fn cargo_registry_name(&self) -> Option<&str> {
        match self {
            Self::CratesIo => None,
            Self::Private { name, .. } => Some(name),
        }
    }

    pub(crate) fn configuration(
        &self,
        ca_path: &Path,
    ) -> Result<CargoConfiguration<'_>, toml::ser::Error> {
        let mut config = toml::Table::from_iter([(
            "net".to_string(),
            toml::Value::Table(toml::Table::from_iter([(
                "retry".to_string(),
                toml::Value::Integer(2),
            )])),
        )]);
        let Self::Private {
            name,
            index,
            token,
            ca_pem,
        } = self
        else {
            return Ok(CargoConfiguration {
                config: toml::to_string(&config)?,
                credentials: None,
                ca_pem: None,
            });
        };
        config.insert(
            "registries".to_string(),
            toml::Value::Table(toml::Table::from_iter([(
                name.clone(),
                toml::Value::Table(toml::Table::from_iter([
                    ("index".to_string(), toml::Value::String(index.clone())),
                    (
                        "credential-provider".to_string(),
                        toml::Value::String("cargo:token".to_string()),
                    ),
                ])),
            )])),
        );
        config.insert(
            "registry".to_string(),
            toml::Value::Table(toml::Table::from_iter([(
                "global-credential-providers".to_string(),
                toml::Value::Array(vec![toml::Value::String("cargo:token".to_string())]),
            )])),
        );
        config.insert(
            "http".to_string(),
            toml::Value::Table(toml::Table::from_iter([(
                "cainfo".to_string(),
                toml::Value::String(ca_path.display().to_string()),
            )])),
        );
        Ok(CargoConfiguration {
            config: toml::to_string(&config)?,
            credentials: Some(toml::to_string(&toml::Table::from_iter([(
                "registries".to_string(),
                toml::Value::Table(toml::Table::from_iter([(
                    name.clone(),
                    toml::Value::Table(toml::Table::from_iter([(
                        "token".to_string(),
                        toml::Value::String(token.clone()),
                    )])),
                )])),
            )]))?),
            ca_pem: Some(ca_pem),
        })
    }
}

pub(crate) struct CargoConfiguration<'a> {
    pub config: String,
    pub credentials: Option<String>,
    pub ca_pem: Option<&'a str>,
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ResolvedRelease {
    pub version: Version,
    pub registry: CargoRegistry,
}

impl ResolvedRelease {
    pub fn validate(&self) -> Result<(), ReleaseValidationError> {
        if !self.version.pre.is_empty() || !self.version.build.is_empty() {
            return Err(ReleaseValidationError::StableVersion);
        }
        self.registry.validate()
    }
}

#[derive(Debug, thiserror::Error)]
pub enum ReleaseValidationError {
    #[error("private Cargo registry name is invalid")]
    RegistryName,
    #[error("private Cargo registry index must use HTTPS")]
    RegistryIndex,
    #[error("private Cargo registry token is missing or invalid")]
    RegistryToken,
    #[error("private Cargo registry CA bundle is missing or invalid")]
    RegistryCa,
    #[error("managed releases must use a stable version without build metadata")]
    StableVersion,
}