pray-core 1.8.1

Core library for Prayfile, the package manager for the language placed before inference
Documentation
use crate::{PrayError, PrayResult};
use serde::{Deserialize, Serialize};
use std::collections::hash_map::DefaultHasher;
use std::fs;
use std::hash::{Hash, Hasher};
use std::path::Path;

#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
pub struct ClientTrustPolicy {
    #[serde(default)]
    pub default: ClientTrustRule,
    #[serde(default)]
    pub rules: Vec<ClientTrustRule>,
}

#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct ClientTrustRule {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub match_prefix: Option<String>,
    #[serde(default = "default_allow")]
    pub allow: bool,
    #[serde(default)]
    pub require_signed_commit: bool,
    #[serde(default)]
    pub require_signed_packages: bool,
    #[serde(default)]
    pub allowed_signing_keys: Vec<String>,
    #[serde(default)]
    pub allowed_host_keys: Vec<String>,
    #[serde(default)]
    pub allowed_publishers: Vec<String>,
}

impl Default for ClientTrustRule {
    fn default() -> Self {
        Self {
            match_prefix: None,
            allow: true,
            require_signed_commit: false,
            require_signed_packages: false,
            allowed_signing_keys: Vec::new(),
            allowed_host_keys: Vec::new(),
            allowed_publishers: Vec::new(),
        }
    }
}

fn default_allow() -> bool {
    true
}

pub fn trust_policy_path(home: &Path) -> std::path::PathBuf {
    home.join("trust.toml")
}

pub fn load_policy(home: &Path) -> PrayResult<Option<ClientTrustPolicy>> {
    let path = trust_policy_path(home);
    if !path.is_file() {
        return Ok(None);
    }
    let text = fs::read_to_string(&path)?;
    let policy: ClientTrustPolicy = toml::from_str(&text).map_err(|error| PrayError::Parse {
        kind: "client trust policy",
        message: error.to_string(),
    })?;
    Ok(Some(policy))
}

pub fn load_policy_or_default(home: &Path) -> PrayResult<ClientTrustPolicy> {
    Ok(load_policy(home)?.unwrap_or_default())
}

pub fn save_policy(home: &Path, policy: &ClientTrustPolicy) -> PrayResult<()> {
    let path = trust_policy_path(home);
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    let text =
        toml::to_string_pretty(policy).map_err(|error| PrayError::Manifest(error.to_string()))?;
    fs::write(path, text)?;
    Ok(())
}

pub fn best_rule<'a>(policy: &'a ClientTrustPolicy, source_url: &str) -> &'a ClientTrustRule {
    let mut best: Option<&ClientTrustRule> = None;
    let mut best_length = 0usize;
    for rule in &policy.rules {
        let Some(prefix) = rule.match_prefix.as_deref() else {
            continue;
        };
        if source_url.starts_with(prefix) && prefix.len() > best_length {
            best = Some(rule);
            best_length = prefix.len();
        }
    }
    best.unwrap_or(&policy.default)
}

pub fn normalize_key(value: &str) -> String {
    value.trim().to_ascii_uppercase()
}

pub fn source_scope_id(source_url: &str) -> String {
    let mut hasher = DefaultHasher::new();
    source_url.hash(&mut hasher);
    let hash = format!("{:016x}", hasher.finish());
    let mut slug: String = source_url
        .chars()
        .map(|character| {
            if character.is_ascii_alphanumeric() || character == '-' || character == '_' {
                character
            } else {
                '-'
            }
        })
        .collect();
    if slug.len() > 64 {
        slug.truncate(64);
    }
    format!("{slug}-{hash}")
}

pub fn mutable_rule_for_match_prefix<'a>(
    policy: &'a mut ClientTrustPolicy,
    match_prefix: &str,
) -> &'a mut ClientTrustRule {
    if let Some(index) = policy
        .rules
        .iter()
        .position(|rule| rule.match_prefix.as_deref() == Some(match_prefix))
    {
        return &mut policy.rules[index];
    }
    policy.rules.push(ClientTrustRule {
        match_prefix: Some(match_prefix.to_string()),
        ..ClientTrustRule::default()
    });
    policy.rules.last_mut().expect("rule just pushed")
}

pub fn append_missing_publishers(rule: &mut ClientTrustRule, keys: &[String]) -> usize {
    append_missing_identity_list(&mut rule.allowed_publishers, keys)
}

pub fn append_missing_host_keys(rule: &mut ClientTrustRule, keys: &[String]) -> usize {
    append_missing_identity_list(&mut rule.allowed_host_keys, keys)
}

fn append_missing_identity_list(target: &mut Vec<String>, keys: &[String]) -> usize {
    let mut added = 0usize;
    for key in keys {
        let normalized = normalize_key(key);
        if normalized.is_empty() {
            continue;
        }
        if target
            .iter()
            .any(|existing| normalize_key(existing) == normalized)
        {
            continue;
        }
        target.push(normalized);
        added += 1;
    }
    added
}

pub fn append_missing_keys(rule: &mut ClientTrustRule, keys: &[String]) -> usize {
    append_missing_identity_list(&mut rule.allowed_signing_keys, keys)
}

pub fn keys_missing_for_trust_scope(
    home: &Path,
    source_url: &str,
    keys: &[String],
    global_scope: bool,
) -> PrayResult<Vec<String>> {
    let policy = load_policy_or_default(home)?;
    let rule = if global_scope {
        &policy.default
    } else {
        best_rule(&policy, source_url)
    };
    let mut missing = Vec::new();
    for key in keys {
        let normalized = normalize_key(key);
        if normalized.is_empty() {
            continue;
        }
        if rule
            .allowed_signing_keys
            .iter()
            .any(|existing| normalize_key(existing) == normalized)
        {
            continue;
        }
        missing.push(normalized);
    }
    Ok(missing)
}

pub fn format_rule_block(scope: &str, rule: &ClientTrustRule) -> String {
    let mut out = format!("{scope}\n");
    out.push_str(&format!("  allow: {}\n", rule.allow));
    out.push_str(&format!(
        "  require_signed_commit: {}\n",
        rule.require_signed_commit
    ));
    out.push_str(&format!(
        "  require_signed_packages: {}\n",
        rule.require_signed_packages
    ));
    if rule.allowed_signing_keys.is_empty() {
        out.push_str("  allowed_signing_keys: []\n");
    } else {
        out.push_str("  allowed_signing_keys:\n");
        for key in &rule.allowed_signing_keys {
            out.push_str(&format!("    - {key}\n"));
        }
    }
    if rule.allowed_host_keys.is_empty() {
        out.push_str("  allowed_host_keys: []\n");
    } else {
        out.push_str("  allowed_host_keys:\n");
        for key in &rule.allowed_host_keys {
            out.push_str(&format!("    - {key}\n"));
        }
    }
    if rule.allowed_publishers.is_empty() {
        out.push_str("  allowed_publishers: []\n");
    } else {
        out.push_str("  allowed_publishers:\n");
        for key in &rule.allowed_publishers {
            out.push_str(&format!("    - {key}\n"));
        }
    }
    out
}

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

    #[test]
    fn longest_match_prefix_wins() {
        let policy = ClientTrustPolicy {
            default: ClientTrustRule::default(),
            rules: vec![
                ClientTrustRule {
                    match_prefix: Some("https://github.com/org/".into()),
                    require_signed_commit: true,
                    ..ClientTrustRule::default()
                },
                ClientTrustRule {
                    match_prefix: Some("https://github.com/org/repo".into()),
                    allow: false,
                    ..ClientTrustRule::default()
                },
            ],
        };
        let rule = best_rule(&policy, "https://github.com/org/repo.git");
        assert!(!rule.allow);
    }
}