void_core/collab/manifest/
policy.rs1use std::time::{SystemTime, UNIX_EPOCH};
7
8use crate::support::pathspec::Pathspec;
9
10use super::keys::SigningPubKey;
11use super::types::Manifest;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum AuthResult {
20 Allowed,
22 Delegated {
24 from: SigningPubKey,
26 },
27 Denied,
29}
30
31impl AuthResult {
32 pub fn is_allowed(&self) -> bool {
34 matches!(self, AuthResult::Allowed | AuthResult::Delegated { .. })
35 }
36}
37
38pub fn check_write_access(
54 manifest: &Manifest,
55 signer: &SigningPubKey,
56 ref_path: &str,
57) -> AuthResult {
58 if manifest.is_owner_or_identity(signer) {
61 return AuthResult::Allowed;
62 }
63
64 for (pattern, allowed_signers) in &manifest.write_policy {
66 if matches_ref_pattern(pattern, ref_path) && allowed_signers.contains(signer) {
67 return AuthResult::Allowed;
68 }
69 }
70
71 let now = current_timestamp();
73 for delegation in &manifest.delegations {
74 if &delegation.to != signer {
75 continue;
76 }
77
78 if !delegation.is_valid_at(now) {
79 continue;
80 }
81
82 if matches_ref_pattern(&delegation.scope, ref_path) {
83 let delegator_access = check_delegator_access(manifest, &delegation.from, ref_path);
85 if delegator_access {
86 return AuthResult::Delegated {
87 from: delegation.from.clone(),
88 };
89 }
90 }
91 }
92
93 AuthResult::Denied
94}
95
96fn check_delegator_access(manifest: &Manifest, delegator: &SigningPubKey, ref_path: &str) -> bool {
100 if manifest.is_owner_or_identity(delegator) {
102 return true;
103 }
104
105 for (pattern, allowed_signers) in &manifest.write_policy {
107 if matches_ref_pattern(pattern, ref_path) && allowed_signers.contains(delegator) {
108 return true;
109 }
110 }
111
112 false
113}
114
115fn matches_ref_pattern(pattern: &str, ref_path: &str) -> bool {
119 if pattern == ref_path {
121 return true;
122 }
123
124 match Pathspec::new(&[pattern]) {
126 Ok(ps) => ps.matches(ref_path),
127 Err(_) => false,
128 }
129}
130
131pub fn default_contributor_namespace(signer: &SigningPubKey) -> String {
142 let prefix = &signer.to_hex()[..16]; format!("refs/heads/contrib/{}/", prefix)
144}
145
146pub fn check_contributor_namespace_access(
150 _manifest: &Manifest,
151 signer: &SigningPubKey,
152 ref_path: &str,
153) -> bool {
154 let namespace = default_contributor_namespace(signer);
155
156 if ref_path.starts_with(&namespace) {
158 return true;
161 }
162
163 false
164}
165
166fn current_timestamp() -> u64 {
168 SystemTime::now()
169 .duration_since(UNIX_EPOCH)
170 .map(|d| d.as_secs())
171 .unwrap_or(0)
172}
173
174#[cfg(test)]
179mod tests {
180 use super::*;
181 use crate::collab::manifest::types::Delegation;
182
183 fn test_signing_key(val: u8) -> SigningPubKey {
184 SigningPubKey::from_bytes([val; 32])
185 }
186
187 fn test_manifest_with_policy() -> Manifest {
188 let owner = test_signing_key(0xaa);
189 let alice = test_signing_key(0xbb);
190
191 let mut manifest = Manifest::new(owner, None);
192
193 manifest
195 .write_policy
196 .insert("refs/heads/main".to_string(), vec![alice.clone()]);
197
198 manifest
200 .write_policy
201 .insert("refs/heads/feature/*".to_string(), vec![alice]);
202
203 manifest
204 }
205
206 #[test]
207 fn owner_always_allowed() {
208 let owner = test_signing_key(0xaa);
209 let manifest = Manifest::new(owner, None);
210
211 let result = check_write_access(&manifest, &owner, "refs/heads/main");
212 assert_eq!(result, AuthResult::Allowed);
213
214 let result = check_write_access(&manifest, &owner, "refs/heads/anything");
215 assert_eq!(result, AuthResult::Allowed);
216 }
217
218 #[test]
219 fn non_contributor_denied() {
220 let manifest = test_manifest_with_policy();
221 let stranger = test_signing_key(0xcc);
222
223 let result = check_write_access(&manifest, &stranger, "refs/heads/main");
224 assert_eq!(result, AuthResult::Denied);
225 }
226
227 #[test]
228 fn direct_policy_match() {
229 let manifest = test_manifest_with_policy();
230 let alice = test_signing_key(0xbb);
231
232 let result = check_write_access(&manifest, &alice, "refs/heads/main");
234 assert_eq!(result, AuthResult::Allowed);
235 }
236
237 #[test]
238 fn glob_policy_match() {
239 let manifest = test_manifest_with_policy();
240 let alice = test_signing_key(0xbb);
241
242 let result = check_write_access(&manifest, &alice, "refs/heads/feature/new-thing");
244 assert_eq!(result, AuthResult::Allowed);
245
246 let result = check_write_access(&manifest, &alice, "refs/heads/develop");
248 assert_eq!(result, AuthResult::Denied);
249 }
250
251 #[test]
252 fn delegation_grants_access() {
253 let owner = test_signing_key(0xaa);
254 let bob = test_signing_key(0xcc);
255
256 let mut manifest = Manifest::new(owner, None);
257
258 manifest.delegations.push(Delegation {
260 from: owner.clone(),
261 to: bob.clone(),
262 scope: "refs/heads/main".to_string(),
263 granted_at: 0,
264 expires_at: None, signature: vec![0; 64],
266 });
267
268 let result = check_write_access(&manifest, &bob, "refs/heads/main");
269 assert!(matches!(result, AuthResult::Delegated { .. }));
270
271 let result = check_write_access(&manifest, &bob, "refs/heads/develop");
273 assert_eq!(result, AuthResult::Denied);
274 }
275
276 #[test]
277 fn expired_delegation_denied() {
278 let owner = test_signing_key(0xaa);
279 let bob = test_signing_key(0xcc);
280
281 let mut manifest = Manifest::new(owner, None);
282
283 manifest.delegations.push(Delegation {
285 from: owner,
286 to: bob.clone(),
287 scope: "refs/heads/main".to_string(),
288 granted_at: 0,
289 expires_at: Some(1), signature: vec![0; 64],
291 });
292
293 let result = check_write_access(&manifest, &bob, "refs/heads/main");
294 assert_eq!(result, AuthResult::Denied);
295 }
296
297 #[test]
298 fn matches_ref_pattern_exact() {
299 assert!(matches_ref_pattern("refs/heads/main", "refs/heads/main"));
300 assert!(!matches_ref_pattern("refs/heads/main", "refs/heads/develop"));
301 }
302
303 #[test]
304 fn matches_ref_pattern_glob() {
305 assert!(matches_ref_pattern(
306 "refs/heads/feature/*",
307 "refs/heads/feature/foo"
308 ));
309 assert!(matches_ref_pattern(
310 "refs/heads/feature/*",
311 "refs/heads/feature/bar"
312 ));
313 assert!(!matches_ref_pattern(
314 "refs/heads/feature/*",
315 "refs/heads/main"
316 ));
317 }
318
319 #[test]
320 fn matches_ref_pattern_recursive_glob() {
321 assert!(matches_ref_pattern(
322 "refs/heads/**",
323 "refs/heads/feature/deep/nested"
324 ));
325 }
326
327 #[test]
328 fn default_contributor_namespace_format() {
329 let signer = test_signing_key(0xab);
330 let ns = default_contributor_namespace(&signer);
331
332 assert!(ns.starts_with("refs/heads/contrib/"));
333 assert!(ns.ends_with("/"));
334 assert!(ns.contains("abababab")); }
336
337 #[test]
338 fn contributor_namespace_access() {
339 let signer = test_signing_key(0xab);
340 let manifest = Manifest::new(test_signing_key(0xaa), None);
341
342 let ns = default_contributor_namespace(&signer);
343 let ref_in_ns = format!("{}feature/test", ns);
344
345 assert!(check_contributor_namespace_access(
346 &manifest,
347 &signer,
348 &ref_in_ns
349 ));
350 assert!(!check_contributor_namespace_access(
351 &manifest,
352 &signer,
353 "refs/heads/main"
354 ));
355 }
356
357 #[test]
358 fn auth_result_is_allowed() {
359 assert!(AuthResult::Allowed.is_allowed());
360 assert!(AuthResult::Delegated {
361 from: test_signing_key(0xaa)
362 }
363 .is_allowed());
364 assert!(!AuthResult::Denied.is_allowed());
365 }
366}