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::Result;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum Operation {
10    Read,
11    Write,
12}
13
14#[derive(Debug, Clone)]
15pub struct Access {
16    read: Rule,
17    write: Rule,
18    repositories_dir: PathBuf,
19}
20
21#[derive(Debug, Clone)]
22enum Rule {
23    None,
24    All,
25    Identities(Vec<[u8; 16]>),
26}
27
28impl Access {
29    pub fn new(read: &[String], write: &[String], repositories_dir: PathBuf) -> Result<Self> {
30        Ok(Self {
31            read: Rule::parse(read)?,
32            write: Rule::parse(write)?,
33            repositories_dir,
34        })
35    }
36
37    pub fn allows(
38        &self,
39        op: Operation,
40        repository: &str,
41        identity: Option<&[u8; 16]>,
42    ) -> Result<bool> {
43        validate_repo_name(repository)?;
44        if self.repo_allowed(op, repository, identity)? {
45            return Ok(true);
46        }
47        Ok(match op {
48            Operation::Read => self.read.allows(identity),
49            Operation::Write => self.write.allows(identity),
50        })
51    }
52
53    fn repo_allowed(
54        &self,
55        op: Operation,
56        repository: &str,
57        identity: Option<&[u8; 16]>,
58    ) -> Result<bool> {
59        for path in self.allowed_files(repository) {
60            if !path.exists() {
61                continue;
62            }
63            let rules = parse_allowed_file(&fs::read_to_string(path)?)?;
64            let Some(rule) = rules.get(match op {
65                Operation::Read => "read",
66                Operation::Write => "write",
67            }) else {
68                continue;
69            };
70            if rule.allows(identity) {
71                return Ok(true);
72            }
73        }
74        Ok(false)
75    }
76
77    fn allowed_files(&self, repository: &str) -> Vec<PathBuf> {
78        let repo = self.repositories_dir.join(repository);
79        let mut out = vec![repo.join(".allowed")];
80        if let Some(group) = repository.split('/').next() {
81            out.push(self.repositories_dir.join(group).join("group.allowed"));
82        }
83        out
84    }
85}
86
87impl Rule {
88    fn parse(values: &[String]) -> Result<Self> {
89        if values.iter().any(|v| v.eq_ignore_ascii_case("all")) {
90            return Ok(Rule::All);
91        }
92        let identities: Vec<[u8; 16]> = values
93            .iter()
94            .filter(|v| !v.eq_ignore_ascii_case("none"))
95            .map(|v| parse_hex_16(v))
96            .collect::<Result<_>>()?;
97        if identities.is_empty() {
98            Ok(Rule::None)
99        } else {
100            Ok(Rule::Identities(identities))
101        }
102    }
103
104    fn allows(&self, identity: Option<&[u8; 16]>) -> bool {
105        match self {
106            Rule::None => false,
107            Rule::All => true,
108            Rule::Identities(allowed) => identity.is_some_and(|id| allowed.iter().any(|v| v == id)),
109        }
110    }
111}
112
113fn parse_allowed_file(input: &str) -> Result<HashMap<String, Rule>> {
114    let mut values: HashMap<String, Vec<String>> = HashMap::new();
115    for raw in input.lines() {
116        let line = raw.split('#').next().unwrap_or("").trim();
117        if line.is_empty() {
118            continue;
119        }
120        let (key, value) = line
121            .split_once('=')
122            .or_else(|| line.split_once(':'))
123            .unwrap_or(("read", line));
124        values
125            .entry(key.trim().to_ascii_lowercase())
126            .or_default()
127            .extend(value.split(',').map(|v| v.trim().to_string()));
128    }
129    values
130        .into_iter()
131        .map(|(key, values)| Ok((key, Rule::parse(&values)?)))
132        .collect()
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn global_rules_allow_all_or_none() {
141        let access = Access::new(&["all".into()], &["none".into()], PathBuf::from(".")).unwrap();
142        assert!(access.allows(Operation::Read, "group/repo", None).unwrap());
143        assert!(!access.allows(Operation::Write, "group/repo", None).unwrap());
144    }
145
146    #[test]
147    fn repo_allowed_file_can_grant_write() {
148        let tmp = tempfile::tempdir().unwrap();
149        let repo = tmp.path().join("group/repo");
150        fs::create_dir_all(&repo).unwrap();
151        fs::write(repo.join(".allowed"), "write = all\n").unwrap();
152        let access = Access::new(&["none".into()], &["none".into()], tmp.path().into()).unwrap();
153        assert!(access.allows(Operation::Write, "group/repo", None).unwrap());
154    }
155
156    #[test]
157    fn invalid_repository_names_are_rejected_before_acl_files() {
158        let access = Access::new(&["all".into()], &["all".into()], PathBuf::from(".")).unwrap();
159        assert!(access.allows(Operation::Read, "../repo", None).is_err());
160    }
161}