pray_core/client_trust/
import_registry.rs1use 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}