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