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}