pray_core/client_trust/
policy.rs1use crate::{PrayError, PrayResult};
2use serde::{Deserialize, Serialize};
3use std::collections::hash_map::DefaultHasher;
4use std::fs;
5use std::hash::{Hash, Hasher};
6use std::path::Path;
7
8#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
9pub struct ClientTrustPolicy {
10 #[serde(default)]
11 pub default: ClientTrustRule,
12 #[serde(default)]
13 pub rules: Vec<ClientTrustRule>,
14}
15
16#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
17pub struct ClientTrustRule {
18 #[serde(default, skip_serializing_if = "Option::is_none")]
19 pub match_prefix: Option<String>,
20 #[serde(default = "default_allow")]
21 pub allow: bool,
22 #[serde(default)]
23 pub require_signed_commit: bool,
24 #[serde(default)]
25 pub require_signed_packages: bool,
26 #[serde(default)]
27 pub allowed_signing_keys: Vec<String>,
28 #[serde(default)]
29 pub allowed_host_keys: Vec<String>,
30 #[serde(default)]
31 pub allowed_publishers: Vec<String>,
32}
33
34impl Default for ClientTrustRule {
35 fn default() -> Self {
36 Self {
37 match_prefix: None,
38 allow: true,
39 require_signed_commit: false,
40 require_signed_packages: false,
41 allowed_signing_keys: Vec::new(),
42 allowed_host_keys: Vec::new(),
43 allowed_publishers: Vec::new(),
44 }
45 }
46}
47
48fn default_allow() -> bool {
49 true
50}
51
52pub fn trust_policy_path(home: &Path) -> std::path::PathBuf {
53 home.join("trust.toml")
54}
55
56pub fn load_policy(home: &Path) -> PrayResult<Option<ClientTrustPolicy>> {
57 let path = trust_policy_path(home);
58 if !path.is_file() {
59 return Ok(None);
60 }
61 let text = fs::read_to_string(&path)?;
62 let policy: ClientTrustPolicy = toml::from_str(&text).map_err(|error| PrayError::Parse {
63 kind: "client trust policy",
64 message: error.to_string(),
65 })?;
66 Ok(Some(policy))
67}
68
69pub fn load_policy_or_default(home: &Path) -> PrayResult<ClientTrustPolicy> {
70 Ok(load_policy(home)?.unwrap_or_default())
71}
72
73pub fn save_policy(home: &Path, policy: &ClientTrustPolicy) -> PrayResult<()> {
74 let path = trust_policy_path(home);
75 if let Some(parent) = path.parent() {
76 fs::create_dir_all(parent)?;
77 }
78 let text =
79 toml::to_string_pretty(policy).map_err(|error| PrayError::Manifest(error.to_string()))?;
80 fs::write(path, text)?;
81 Ok(())
82}
83
84pub fn best_rule<'a>(policy: &'a ClientTrustPolicy, source_url: &str) -> &'a ClientTrustRule {
85 let mut best: Option<&ClientTrustRule> = None;
86 let mut best_length = 0usize;
87 for rule in &policy.rules {
88 let Some(prefix) = rule.match_prefix.as_deref() else {
89 continue;
90 };
91 if source_url.starts_with(prefix) && prefix.len() > best_length {
92 best = Some(rule);
93 best_length = prefix.len();
94 }
95 }
96 best.unwrap_or(&policy.default)
97}
98
99pub fn normalize_key(value: &str) -> String {
100 value.trim().to_ascii_uppercase()
101}
102
103pub fn source_scope_id(source_url: &str) -> String {
104 let mut hasher = DefaultHasher::new();
105 source_url.hash(&mut hasher);
106 let hash = format!("{:016x}", hasher.finish());
107 let mut slug: String = source_url
108 .chars()
109 .map(|character| {
110 if character.is_ascii_alphanumeric() || character == '-' || character == '_' {
111 character
112 } else {
113 '-'
114 }
115 })
116 .collect();
117 if slug.len() > 64 {
118 slug.truncate(64);
119 }
120 format!("{slug}-{hash}")
121}
122
123pub fn mutable_rule_for_match_prefix<'a>(
124 policy: &'a mut ClientTrustPolicy,
125 match_prefix: &str,
126) -> &'a mut ClientTrustRule {
127 if let Some(index) = policy
128 .rules
129 .iter()
130 .position(|rule| rule.match_prefix.as_deref() == Some(match_prefix))
131 {
132 return &mut policy.rules[index];
133 }
134 policy.rules.push(ClientTrustRule {
135 match_prefix: Some(match_prefix.to_string()),
136 ..ClientTrustRule::default()
137 });
138 policy.rules.last_mut().expect("rule just pushed")
139}
140
141pub fn append_missing_publishers(rule: &mut ClientTrustRule, keys: &[String]) -> usize {
142 append_missing_identity_list(&mut rule.allowed_publishers, keys)
143}
144
145pub fn append_missing_host_keys(rule: &mut ClientTrustRule, keys: &[String]) -> usize {
146 append_missing_identity_list(&mut rule.allowed_host_keys, keys)
147}
148
149fn append_missing_identity_list(target: &mut Vec<String>, keys: &[String]) -> usize {
150 let mut added = 0usize;
151 for key in keys {
152 let normalized = normalize_key(key);
153 if normalized.is_empty() {
154 continue;
155 }
156 if target
157 .iter()
158 .any(|existing| normalize_key(existing) == normalized)
159 {
160 continue;
161 }
162 target.push(normalized);
163 added += 1;
164 }
165 added
166}
167
168pub fn append_missing_keys(rule: &mut ClientTrustRule, keys: &[String]) -> usize {
169 append_missing_identity_list(&mut rule.allowed_signing_keys, keys)
170}
171
172pub fn keys_missing_for_trust_scope(
173 home: &Path,
174 source_url: &str,
175 keys: &[String],
176 global_scope: bool,
177) -> PrayResult<Vec<String>> {
178 let policy = load_policy_or_default(home)?;
179 let rule = if global_scope {
180 &policy.default
181 } else {
182 best_rule(&policy, source_url)
183 };
184 let mut missing = Vec::new();
185 for key in keys {
186 let normalized = normalize_key(key);
187 if normalized.is_empty() {
188 continue;
189 }
190 if rule
191 .allowed_signing_keys
192 .iter()
193 .any(|existing| normalize_key(existing) == normalized)
194 {
195 continue;
196 }
197 missing.push(normalized);
198 }
199 Ok(missing)
200}
201
202pub fn format_rule_block(scope: &str, rule: &ClientTrustRule) -> String {
203 let mut out = format!("{scope}\n");
204 out.push_str(&format!(" allow: {}\n", rule.allow));
205 out.push_str(&format!(
206 " require_signed_commit: {}\n",
207 rule.require_signed_commit
208 ));
209 out.push_str(&format!(
210 " require_signed_packages: {}\n",
211 rule.require_signed_packages
212 ));
213 if rule.allowed_signing_keys.is_empty() {
214 out.push_str(" allowed_signing_keys: []\n");
215 } else {
216 out.push_str(" allowed_signing_keys:\n");
217 for key in &rule.allowed_signing_keys {
218 out.push_str(&format!(" - {key}\n"));
219 }
220 }
221 if rule.allowed_host_keys.is_empty() {
222 out.push_str(" allowed_host_keys: []\n");
223 } else {
224 out.push_str(" allowed_host_keys:\n");
225 for key in &rule.allowed_host_keys {
226 out.push_str(&format!(" - {key}\n"));
227 }
228 }
229 if rule.allowed_publishers.is_empty() {
230 out.push_str(" allowed_publishers: []\n");
231 } else {
232 out.push_str(" allowed_publishers:\n");
233 for key in &rule.allowed_publishers {
234 out.push_str(&format!(" - {key}\n"));
235 }
236 }
237 out
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 #[test]
245 fn longest_match_prefix_wins() {
246 let policy = ClientTrustPolicy {
247 default: ClientTrustRule::default(),
248 rules: vec![
249 ClientTrustRule {
250 match_prefix: Some("https://github.com/org/".into()),
251 require_signed_commit: true,
252 ..ClientTrustRule::default()
253 },
254 ClientTrustRule {
255 match_prefix: Some("https://github.com/org/repo".into()),
256 allow: false,
257 ..ClientTrustRule::default()
258 },
259 ],
260 };
261 let rule = best_rule(&policy, "https://github.com/org/repo.git");
262 assert!(!rule.allow);
263 }
264}