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}