Skip to main content

pray_core/client_trust/
import_registry.rs

1use std::path::{Path, PathBuf};
2
3use crate::registry::fetch_optional_distribution_bytes;
4use crate::ssh_client::{is_pray_ssh_url, parse_pray_ssh_url, with_pray_ssh_session};
5use crate::ssh_identity::normalize_identity;
6use crate::ssh_publishers::{read_ssh_publishers, SshPublisherConfig};
7use crate::{PrayError, PrayResult};
8
9use super::policy::{
10    append_missing_host_keys, append_missing_publishers, load_policy_or_default,
11    mutable_rule_for_match_prefix, save_policy,
12};
13use super::ssh_host::fetch_host_key_fingerprints;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct ImportRegistryResult {
17    pub publishers_added: usize,
18    pub host_keys_added: usize,
19}
20
21pub fn import_registry_trust(
22    home: &Path,
23    source_url: &str,
24    match_prefix: Option<&str>,
25    include_host_key: bool,
26) -> PrayResult<ImportRegistryResult> {
27    let prefix = match_prefix.unwrap_or(source_url);
28    let Some(config) = fetch_ssh_publishers(source_url)? else {
29        return Err(PrayError::Unsupported(format!(
30            "no v1/ssh_publishers.json found for {source_url}"
31        )));
32    };
33    let publisher_fingerprints = publisher_fingerprints(&config);
34    if publisher_fingerprints.is_empty() {
35        return Err(PrayError::Unsupported(format!(
36            "v1/ssh_publishers.json for {source_url} lists no publisher fingerprints"
37        )));
38    }
39
40    let mut host_keys = Vec::new();
41    if include_host_key && is_pray_ssh_url(source_url) {
42        let target = parse_pray_ssh_url(source_url)?;
43        if target.host != "stdio-host" {
44            host_keys = fetch_host_key_fingerprints(&target.host, target.port)?;
45        }
46    }
47
48    let mut policy = load_policy_or_default(home)?;
49    let rule = mutable_rule_for_match_prefix(&mut policy, prefix);
50    let publishers_added = append_missing_publishers(rule, &publisher_fingerprints);
51    let host_keys_added = append_missing_host_keys(rule, &host_keys);
52    save_policy(home, &policy)?;
53
54    Ok(ImportRegistryResult {
55        publishers_added,
56        host_keys_added,
57    })
58}
59
60fn publisher_fingerprints(config: &SshPublisherConfig) -> Vec<String> {
61    config
62        .publishers
63        .iter()
64        .map(|entry| normalize_identity(&entry.fingerprint))
65        .filter(|fingerprint| !fingerprint.is_empty())
66        .collect()
67}
68
69pub fn fetch_ssh_publishers(source_url: &str) -> PrayResult<Option<SshPublisherConfig>> {
70    if let Some(root) = local_distribution_root(source_url) {
71        return read_ssh_publishers(&root);
72    }
73    if is_pray_ssh_url(source_url) {
74        return with_pray_ssh_session(source_url, |session| {
75            use serde_json::json;
76            match session.call_bytes("artifact.get", json!({ "path": "v1/ssh_publishers.json" })) {
77                Ok(bytes) => {
78                    let config: SshPublisherConfig =
79                        serde_json::from_slice(&bytes).map_err(|error| PrayError::Parse {
80                            kind: "ssh publishers",
81                            message: error.to_string(),
82                        })?;
83                    Ok(Some(config))
84                }
85                Err(PrayError::Resolution(message))
86                    if message.contains("404") || message.contains("not found") =>
87                {
88                    Ok(None)
89                }
90                Err(error) => Err(error),
91            }
92        });
93    }
94    if source_url.starts_with("http://") || source_url.starts_with("https://") {
95        let Some(bytes) = fetch_optional_distribution_bytes(source_url, "v1/ssh_publishers.json")?
96        else {
97            return Ok(None);
98        };
99        let config: SshPublisherConfig =
100            serde_json::from_slice(&bytes).map_err(|error| PrayError::Parse {
101                kind: "ssh publishers",
102                message: error.to_string(),
103            })?;
104        return Ok(Some(config));
105    }
106
107    Err(PrayError::Unsupported(format!(
108        "unsupported registry source for import: {source_url}"
109    )))
110}
111
112fn local_distribution_root(source_url: &str) -> Option<PathBuf> {
113    let path = if let Some(path) = source_url.strip_prefix("file://") {
114        PathBuf::from(path)
115    } else {
116        PathBuf::from(source_url)
117    };
118    if path.is_dir() {
119        Some(path)
120    } else {
121        None
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::ssh_publishers::SshPublisherEntry;
129    use std::fs;
130
131    #[test]
132    fn publisher_fingerprints_normalize_entries() {
133        let config = SshPublisherConfig {
134            publishers: vec![SshPublisherEntry {
135                fingerprint: "sha256:abc".to_string(),
136                id: "team-ci".to_string(),
137                push: true,
138            }],
139        };
140        assert_eq!(
141            publisher_fingerprints(&config),
142            vec!["SHA256:ABC".to_string()]
143        );
144    }
145
146    #[test]
147    fn import_registry_reads_local_publishers_file() {
148        let home =
149            std::env::temp_dir().join(format!("pray-import-registry-home-{}", std::process::id()));
150        let root =
151            std::env::temp_dir().join(format!("pray-import-registry-root-{}", std::process::id()));
152        let _ = fs::remove_dir_all(&home);
153        let _ = fs::remove_dir_all(&root);
154        fs::create_dir_all(root.join("v1")).expect("v1");
155        fs::write(
156            root.join("v1/ssh_publishers.json"),
157            r#"{"publishers":[{"fingerprint":"SHA256:deadbeef","id":"team-ci","push":true}]}"#,
158        )
159        .expect("publishers");
160
161        let result = import_registry_trust(&home, root.to_str().expect("utf8"), None, false)
162            .expect("import");
163        assert_eq!(result.publishers_added, 1);
164        assert_eq!(result.host_keys_added, 0);
165
166        let policy = super::super::policy::load_policy(&home)
167            .expect("load")
168            .expect("policy");
169        let rule = policy
170            .rules
171            .iter()
172            .find(|rule| rule.match_prefix.as_deref() == Some(root.to_str().expect("utf8")))
173            .expect("rule");
174        assert_eq!(rule.allowed_publishers, vec!["SHA256:DEADBEEF".to_string()]);
175
176        let _ = fs::remove_dir_all(&home);
177        let _ = fs::remove_dir_all(&root);
178    }
179}