pray-core 1.9.2

Core library for Prayfile, the package manager for the language placed before inference
Documentation
use std::path::{Path, PathBuf};

use crate::registry::fetch_optional_distribution_bytes;
use crate::ssh_client::{is_pray_ssh_url, parse_pray_ssh_url, with_pray_ssh_session};
use crate::ssh_identity::normalize_identity;
use crate::ssh_publishers::{read_ssh_publishers, SshPublisherConfig};
use crate::{PrayError, PrayResult};

use super::policy::{
    append_missing_host_keys, append_missing_publishers, load_policy_or_default,
    mutable_rule_for_match_prefix, save_policy,
};
use super::ssh_host::fetch_host_key_fingerprints;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportRegistryResult {
    pub publishers_added: usize,
    pub host_keys_added: usize,
}

pub fn import_registry_trust(
    home: &Path,
    source_url: &str,
    match_prefix: Option<&str>,
    include_host_key: bool,
) -> PrayResult<ImportRegistryResult> {
    let prefix = match_prefix.unwrap_or(source_url);
    let Some(config) = fetch_ssh_publishers(source_url)? else {
        return Err(PrayError::Unsupported(format!(
            "no v1/ssh_publishers.json found for {source_url}"
        )));
    };
    let publisher_fingerprints = publisher_fingerprints(&config);
    if publisher_fingerprints.is_empty() {
        return Err(PrayError::Unsupported(format!(
            "v1/ssh_publishers.json for {source_url} lists no publisher fingerprints"
        )));
    }

    let mut host_keys = Vec::new();
    if include_host_key && is_pray_ssh_url(source_url) {
        let target = parse_pray_ssh_url(source_url)?;
        if target.host != "stdio-host" {
            host_keys = fetch_host_key_fingerprints(&target.host, target.port)?;
        }
    }

    let mut policy = load_policy_or_default(home)?;
    let rule = mutable_rule_for_match_prefix(&mut policy, prefix);
    let publishers_added = append_missing_publishers(rule, &publisher_fingerprints);
    let host_keys_added = append_missing_host_keys(rule, &host_keys);
    save_policy(home, &policy)?;

    Ok(ImportRegistryResult {
        publishers_added,
        host_keys_added,
    })
}

fn publisher_fingerprints(config: &SshPublisherConfig) -> Vec<String> {
    config
        .publishers
        .iter()
        .map(|entry| normalize_identity(&entry.fingerprint))
        .filter(|fingerprint| !fingerprint.is_empty())
        .collect()
}

pub fn fetch_ssh_publishers(source_url: &str) -> PrayResult<Option<SshPublisherConfig>> {
    if let Some(root) = local_distribution_root(source_url) {
        return read_ssh_publishers(&root);
    }
    if is_pray_ssh_url(source_url) {
        return with_pray_ssh_session(source_url, |session| {
            use serde_json::json;
            match session.call_bytes("artifact.get", json!({ "path": "v1/ssh_publishers.json" })) {
                Ok(bytes) => {
                    let config: SshPublisherConfig =
                        serde_json::from_slice(&bytes).map_err(|error| PrayError::Parse {
                            kind: "ssh publishers",
                            message: error.to_string(),
                        })?;
                    Ok(Some(config))
                }
                Err(PrayError::Resolution(message))
                    if message.contains("404") || message.contains("not found") =>
                {
                    Ok(None)
                }
                Err(error) => Err(error),
            }
        });
    }
    if source_url.starts_with("http://") || source_url.starts_with("https://") {
        let Some(bytes) = fetch_optional_distribution_bytes(source_url, "v1/ssh_publishers.json")?
        else {
            return Ok(None);
        };
        let config: SshPublisherConfig =
            serde_json::from_slice(&bytes).map_err(|error| PrayError::Parse {
                kind: "ssh publishers",
                message: error.to_string(),
            })?;
        return Ok(Some(config));
    }

    Err(PrayError::Unsupported(format!(
        "unsupported registry source for import: {source_url}"
    )))
}

fn local_distribution_root(source_url: &str) -> Option<PathBuf> {
    let path = if let Some(path) = source_url.strip_prefix("file://") {
        PathBuf::from(path)
    } else {
        PathBuf::from(source_url)
    };
    if path.is_dir() {
        Some(path)
    } else {
        None
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ssh_publishers::SshPublisherEntry;
    use std::fs;

    #[test]
    fn publisher_fingerprints_normalize_entries() {
        let config = SshPublisherConfig {
            publishers: vec![SshPublisherEntry {
                fingerprint: "sha256:abc".to_string(),
                id: "team-ci".to_string(),
                push: true,
            }],
        };
        assert_eq!(
            publisher_fingerprints(&config),
            vec!["SHA256:ABC".to_string()]
        );
    }

    #[test]
    fn import_registry_reads_local_publishers_file() {
        let home =
            std::env::temp_dir().join(format!("pray-import-registry-home-{}", std::process::id()));
        let root =
            std::env::temp_dir().join(format!("pray-import-registry-root-{}", std::process::id()));
        let _ = fs::remove_dir_all(&home);
        let _ = fs::remove_dir_all(&root);
        fs::create_dir_all(root.join("v1")).expect("v1");
        fs::write(
            root.join("v1/ssh_publishers.json"),
            r#"{"publishers":[{"fingerprint":"SHA256:deadbeef","id":"team-ci","push":true}]}"#,
        )
        .expect("publishers");

        let result = import_registry_trust(&home, root.to_str().expect("utf8"), None, false)
            .expect("import");
        assert_eq!(result.publishers_added, 1);
        assert_eq!(result.host_keys_added, 0);

        let policy = super::super::policy::load_policy(&home)
            .expect("load")
            .expect("policy");
        let rule = policy
            .rules
            .iter()
            .find(|rule| rule.match_prefix.as_deref() == Some(root.to_str().expect("utf8")))
            .expect("rule");
        assert_eq!(rule.allowed_publishers, vec!["SHA256:DEADBEEF".to_string()]);

        let _ = fs::remove_dir_all(&home);
        let _ = fs::remove_dir_all(&root);
    }
}