Skip to main content

pray_core/client_trust/
policy.rs

1use 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}