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