Skip to main content

rns_git/
acl.rs

1use std::collections::HashMap;
2use std::fs;
3use std::path::PathBuf;
4
5use crate::util::{parse_hex_16, validate_repo_name};
6use crate::{Error, Result};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum Operation {
10    Read,
11    Write,
12    Create,
13    Stats,
14    Release,
15    Interact,
16    Admin,
17}
18
19#[derive(Debug, Clone)]
20pub struct Access {
21    read: Rule,
22    write: Rule,
23    create: Rule,
24    stats: Rule,
25    release: Rule,
26    interact: Rule,
27    admin: Rule,
28    repositories_dir: PathBuf,
29}
30
31#[derive(Debug, Clone)]
32enum Rule {
33    None,
34    All,
35    Identities(Vec<[u8; 16]>),
36}
37
38impl Access {
39    pub fn new(
40        read: &[String],
41        write: &[String],
42        create: &[String],
43        stats: &[String],
44        release: &[String],
45        interact: &[String],
46        admin: &[String],
47        repositories_dir: PathBuf,
48    ) -> Result<Self> {
49        Ok(Self {
50            read: Rule::parse(read)?,
51            write: Rule::parse(write)?,
52            create: Rule::parse(create)?,
53            stats: Rule::parse(stats)?,
54            release: Rule::parse(release)?,
55            interact: Rule::parse(interact)?,
56            admin: Rule::parse(admin)?,
57            repositories_dir,
58        })
59    }
60
61    pub fn allows(
62        &self,
63        op: Operation,
64        repository: &str,
65        identity: Option<&[u8; 16]>,
66    ) -> Result<bool> {
67        validate_repo_name(repository)?;
68        if op != Operation::Admin
69            && (self.repo_allowed(Operation::Admin, repository, identity)?
70                || self.admin.allows(identity))
71        {
72            return Ok(true);
73        }
74        if self.repo_allowed(op, repository, identity)? {
75            return Ok(true);
76        }
77        Ok(match op {
78            Operation::Read => self.read.allows(identity),
79            Operation::Write => self.write.allows(identity),
80            Operation::Create => self.create.allows(identity),
81            Operation::Stats => self.stats.allows(identity),
82            Operation::Release => self.release.allows(identity),
83            Operation::Interact => self.interact.allows(identity),
84            Operation::Admin => self.admin.allows(identity),
85        })
86    }
87
88    fn repo_allowed(
89        &self,
90        op: Operation,
91        repository: &str,
92        identity: Option<&[u8; 16]>,
93    ) -> Result<bool> {
94        for path in self.allowed_files(repository) {
95            if !path.exists() {
96                continue;
97            }
98            let rules = parse_allowed_file(&fs::read_to_string(path)?)?;
99            let Some(rule) = rules.get(operation_key(op)) else {
100                continue;
101            };
102            if rule.allows(identity) {
103                return Ok(true);
104            }
105        }
106        Ok(false)
107    }
108
109    fn allowed_files(&self, repository: &str) -> Vec<PathBuf> {
110        let repo = self.repositories_dir.join(repository);
111        let mut out = vec![repo.join(".allowed")];
112        if let Some(group) = repository.split('/').next() {
113            out.push(self.repositories_dir.join(group).join("group.allowed"));
114        }
115        out
116    }
117}
118
119pub(crate) fn allowed_input_allows(
120    input: &str,
121    op: Operation,
122    identity: Option<&[u8; 16]>,
123) -> Result<bool> {
124    Ok(parse_allowed_file(input)?
125        .get(operation_key(op))
126        .is_some_and(|rule| rule.allows(identity)))
127}
128
129pub(crate) fn validate_allowed_input(input: &str) -> Result<()> {
130    parse_allowed_file(input).map(|_| ())
131}
132
133fn operation_key(op: Operation) -> &'static str {
134    match op {
135        Operation::Read => "read",
136        Operation::Write => "write",
137        Operation::Create => "create",
138        Operation::Stats => "stats",
139        Operation::Release => "release",
140        Operation::Interact => "interact",
141        Operation::Admin => "admin",
142    }
143}
144
145impl Rule {
146    fn parse(values: &[String]) -> Result<Self> {
147        if values.iter().any(|v| v.eq_ignore_ascii_case("all")) {
148            return Ok(Rule::All);
149        }
150        let identities: Vec<[u8; 16]> = values
151            .iter()
152            .filter(|v| !v.eq_ignore_ascii_case("none"))
153            .map(|v| parse_hex_16(v))
154            .collect::<Result<_>>()?;
155        if identities.is_empty() {
156            Ok(Rule::None)
157        } else {
158            Ok(Rule::Identities(identities))
159        }
160    }
161
162    fn allows(&self, identity: Option<&[u8; 16]>) -> bool {
163        match self {
164            Rule::None => false,
165            Rule::All => true,
166            Rule::Identities(allowed) => identity.is_some_and(|id| allowed.iter().any(|v| v == id)),
167        }
168    }
169}
170
171fn parse_allowed_file(input: &str) -> Result<HashMap<String, Rule>> {
172    let mut values: HashMap<String, Vec<String>> = HashMap::new();
173    for raw in input.lines() {
174        let line = raw.split('#').next().unwrap_or("").trim();
175        if line.is_empty() {
176            continue;
177        }
178        let (key, value) = line
179            .split_once('=')
180            .or_else(|| line.split_once(':'))
181            .unwrap_or(("read", line));
182        let raw_key = key.trim().to_ascii_lowercase();
183        let key = match raw_key.as_str() {
184            "r" | "read" => "read",
185            "w" | "write" => "write",
186            "rw" | "readwrite" => "readwrite",
187            "c" | "create" => "create",
188            "s" | "stats" => "stats",
189            "rel" | "release" => "release",
190            "i" | "interact" => "interact",
191            "adm" | "admin" => "admin",
192            _ => return Err(Error::msg(format!("invalid permission \"{raw_key}\""))),
193        };
194        let targets = value.split(',').map(|v| v.trim().to_string());
195        if key == "readwrite" {
196            values
197                .entry("read".into())
198                .or_default()
199                .extend(targets.clone());
200            values.entry("write".into()).or_default().extend(targets);
201        } else {
202            values.entry(key.into()).or_default().extend(targets);
203        }
204    }
205    values
206        .into_iter()
207        .map(|(key, values)| Ok((key, Rule::parse(&values)?)))
208        .collect()
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn global_rules_allow_all_or_none() {
217        let access = Access::new(
218            &["all".into()],
219            &["none".into()],
220            &["none".into()],
221            &["all".into()],
222            &["none".into()],
223            &["none".into()],
224            &["none".into()],
225            PathBuf::from("."),
226        )
227        .unwrap();
228        assert!(access.allows(Operation::Read, "group/repo", None).unwrap());
229        assert!(!access.allows(Operation::Write, "group/repo", None).unwrap());
230        assert!(!access
231            .allows(Operation::Create, "group/repo", None)
232            .unwrap());
233        assert!(access.allows(Operation::Stats, "group/repo", None).unwrap());
234        assert!(!access
235            .allows(Operation::Release, "group/repo", None)
236            .unwrap());
237        assert!(!access
238            .allows(Operation::Interact, "group/repo", None)
239            .unwrap());
240        assert!(!access.allows(Operation::Admin, "group/repo", None).unwrap());
241    }
242
243    #[test]
244    fn repo_allowed_file_can_grant_write() {
245        let tmp = tempfile::tempdir().unwrap();
246        let repo = tmp.path().join("group/repo");
247        fs::create_dir_all(&repo).unwrap();
248        fs::write(repo.join(".allowed"), "write = all\n").unwrap();
249        let access = Access::new(
250            &["none".into()],
251            &["none".into()],
252            &["none".into()],
253            &["none".into()],
254            &["none".into()],
255            &["none".into()],
256            &["none".into()],
257            tmp.path().into(),
258        )
259        .unwrap();
260        assert!(access.allows(Operation::Write, "group/repo", None).unwrap());
261    }
262
263    #[test]
264    fn repo_allowed_file_can_grant_stats_with_long_or_short_key() {
265        let tmp = tempfile::tempdir().unwrap();
266        let repo = tmp.path().join("group/repo");
267        fs::create_dir_all(&repo).unwrap();
268        fs::write(repo.join(".allowed"), "stats = all\n").unwrap();
269        let access = Access::new(
270            &["none".into()],
271            &["none".into()],
272            &["none".into()],
273            &["none".into()],
274            &["none".into()],
275            &["none".into()],
276            &["none".into()],
277            tmp.path().into(),
278        )
279        .unwrap();
280        assert!(access.allows(Operation::Stats, "group/repo", None).unwrap());
281
282        fs::write(repo.join(".allowed"), "s = all\n").unwrap();
283        assert!(access.allows(Operation::Stats, "group/repo", None).unwrap());
284    }
285
286    #[test]
287    fn repo_allowed_file_can_grant_create_with_long_or_short_key() {
288        let tmp = tempfile::tempdir().unwrap();
289        let repo = tmp.path().join("group/repo");
290        fs::create_dir_all(&repo).unwrap();
291        fs::write(repo.join(".allowed"), "create = all\n").unwrap();
292        let access = Access::new(
293            &["none".into()],
294            &["none".into()],
295            &["none".into()],
296            &["none".into()],
297            &["none".into()],
298            &["none".into()],
299            &["none".into()],
300            tmp.path().into(),
301        )
302        .unwrap();
303        assert!(access
304            .allows(Operation::Create, "group/repo", None)
305            .unwrap());
306
307        fs::write(repo.join(".allowed"), "c = all\n").unwrap();
308        assert!(access
309            .allows(Operation::Create, "group/repo", None)
310            .unwrap());
311    }
312
313    #[test]
314    fn group_allowed_file_can_grant_create() {
315        let tmp = tempfile::tempdir().unwrap();
316        let group = tmp.path().join("group");
317        fs::create_dir_all(&group).unwrap();
318        fs::write(group.join("group.allowed"), "create = all\n").unwrap();
319        let access = Access::new(
320            &["none".into()],
321            &["none".into()],
322            &["none".into()],
323            &["none".into()],
324            &["none".into()],
325            &["none".into()],
326            &["none".into()],
327            tmp.path().into(),
328        )
329        .unwrap();
330        assert!(access
331            .allows(Operation::Create, "group/repo", None)
332            .unwrap());
333    }
334
335    #[test]
336    fn group_allowed_file_can_grant_stats() {
337        let tmp = tempfile::tempdir().unwrap();
338        let group = tmp.path().join("group");
339        fs::create_dir_all(&group).unwrap();
340        fs::write(group.join("group.allowed"), "s = all\n").unwrap();
341        let access = Access::new(
342            &["none".into()],
343            &["none".into()],
344            &["none".into()],
345            &["none".into()],
346            &["none".into()],
347            &["none".into()],
348            &["none".into()],
349            tmp.path().into(),
350        )
351        .unwrap();
352        assert!(access.allows(Operation::Stats, "group/repo", None).unwrap());
353    }
354
355    #[test]
356    fn repo_allowed_file_can_grant_release_with_long_or_short_key() {
357        let tmp = tempfile::tempdir().unwrap();
358        let repo = tmp.path().join("group/repo");
359        fs::create_dir_all(&repo).unwrap();
360        fs::write(repo.join(".allowed"), "release = all\n").unwrap();
361        let access = Access::new(
362            &["none".into()],
363            &["none".into()],
364            &["none".into()],
365            &["none".into()],
366            &["none".into()],
367            &["none".into()],
368            &["none".into()],
369            tmp.path().into(),
370        )
371        .unwrap();
372        assert!(access
373            .allows(Operation::Release, "group/repo", None)
374            .unwrap());
375
376        fs::write(repo.join(".allowed"), "rel = all\n").unwrap();
377        assert!(access
378            .allows(Operation::Release, "group/repo", None)
379            .unwrap());
380    }
381
382    #[test]
383    fn group_allowed_file_can_grant_release() {
384        let tmp = tempfile::tempdir().unwrap();
385        let group = tmp.path().join("group");
386        fs::create_dir_all(&group).unwrap();
387        fs::write(group.join("group.allowed"), "rel = all\n").unwrap();
388        let access = Access::new(
389            &["none".into()],
390            &["none".into()],
391            &["none".into()],
392            &["none".into()],
393            &["none".into()],
394            &["none".into()],
395            &["none".into()],
396            tmp.path().into(),
397        )
398        .unwrap();
399        assert!(access
400            .allows(Operation::Release, "group/repo", None)
401            .unwrap());
402    }
403
404    #[test]
405    fn repo_allowed_file_can_grant_interact_and_admin_with_long_or_short_key() {
406        let tmp = tempfile::tempdir().unwrap();
407        let repo = tmp.path().join("group/repo");
408        fs::create_dir_all(&repo).unwrap();
409        fs::write(repo.join(".allowed"), "interact = all\nadmin = all\n").unwrap();
410        let access = Access::new(
411            &["none".into()],
412            &["none".into()],
413            &["none".into()],
414            &["none".into()],
415            &["none".into()],
416            &["none".into()],
417            &["none".into()],
418            tmp.path().into(),
419        )
420        .unwrap();
421        assert!(access
422            .allows(Operation::Interact, "group/repo", None)
423            .unwrap());
424        assert!(access.allows(Operation::Admin, "group/repo", None).unwrap());
425
426        fs::write(repo.join(".allowed"), "i = all\nadm = all\n").unwrap();
427        assert!(access
428            .allows(Operation::Interact, "group/repo", None)
429            .unwrap());
430        assert!(access.allows(Operation::Admin, "group/repo", None).unwrap());
431    }
432
433    #[test]
434    fn group_allowed_file_can_grant_interact_and_admin() {
435        let tmp = tempfile::tempdir().unwrap();
436        let group = tmp.path().join("group");
437        fs::create_dir_all(&group).unwrap();
438        fs::write(group.join("group.allowed"), "i = all\nadm = all\n").unwrap();
439        let access = Access::new(
440            &["none".into()],
441            &["none".into()],
442            &["none".into()],
443            &["none".into()],
444            &["none".into()],
445            &["none".into()],
446            &["none".into()],
447            tmp.path().into(),
448        )
449        .unwrap();
450        assert!(access
451            .allows(Operation::Interact, "group/repo", None)
452            .unwrap());
453        assert!(access.allows(Operation::Admin, "group/repo", None).unwrap());
454    }
455
456    #[test]
457    fn admin_identity_satisfies_repository_permission_checks() {
458        let tmp = tempfile::tempdir().unwrap();
459        let admin = [0xA5; 16];
460        let other = [0x5A; 16];
461        let repo = tmp.path().join("group/repo");
462        fs::create_dir_all(&repo).unwrap();
463        fs::write(
464            repo.join(".allowed"),
465            "admin = a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5\n",
466        )
467        .unwrap();
468        let access = Access::new(
469            &["none".into()],
470            &["none".into()],
471            &["none".into()],
472            &["none".into()],
473            &["none".into()],
474            &["none".into()],
475            &["none".into()],
476            tmp.path().into(),
477        )
478        .unwrap();
479
480        for op in [
481            Operation::Read,
482            Operation::Write,
483            Operation::Create,
484            Operation::Stats,
485            Operation::Release,
486            Operation::Interact,
487        ] {
488            assert!(access.allows(op, "group/repo", Some(&admin)).unwrap());
489            assert!(!access.allows(op, "group/repo", Some(&other)).unwrap());
490        }
491    }
492
493    #[test]
494    fn invalid_repository_names_are_rejected_before_acl_files() {
495        let access = Access::new(
496            &["all".into()],
497            &["all".into()],
498            &["all".into()],
499            &["all".into()],
500            &["all".into()],
501            &["all".into()],
502            &["all".into()],
503            PathBuf::from("."),
504        )
505        .unwrap();
506        assert!(access.allows(Operation::Read, "../repo", None).is_err());
507        assert!(access.allows(Operation::Create, "../repo", None).is_err());
508        assert!(access.allows(Operation::Stats, "../repo", None).is_err());
509        assert!(access.allows(Operation::Release, "../repo", None).is_err());
510        assert!(access.allows(Operation::Interact, "../repo", None).is_err());
511        assert!(access.allows(Operation::Admin, "../repo", None).is_err());
512    }
513}