lfsx_server/auth/
restricted.rs1use crate::namespace::Namespace;
2
3#[derive(Debug, Clone, PartialEq, Eq)]
4enum Repo {
5 Any,
6 Prefix(String),
7 Exact(String),
8}
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11struct Pattern {
12 org: String,
13 repo: Repo,
14}
15
16#[derive(Debug, Clone, Default, PartialEq, Eq)]
17pub struct Restricted(Vec<Pattern>);
18
19impl Restricted {
20 pub fn parse(value: Option<&str>) -> Self {
21 let mut patterns = Vec::new();
22
23 for entry in value.unwrap_or_default().split(',') {
24 let entry = entry.trim();
25 if entry.is_empty() {
26 continue;
27 }
28
29 match Pattern::parse(entry) {
30 Some(pattern) => patterns.push(pattern),
31 None => tracing::warn!(
37 entry,
38 "LFSX_RESTRICTED entry is not org/repo and was ignored, so that \
39 repository keeps the permissions the forge gives it"
40 ),
41 }
42 }
43
44 Self(patterns)
45 }
46
47 pub fn is_empty(&self) -> bool {
48 self.0.is_empty()
49 }
50
51 pub fn covers(&self, ns: &Namespace) -> bool {
52 self.0.iter().any(|pattern| pattern.covers(ns))
53 }
54}
55
56impl Pattern {
57 fn parse(entry: &str) -> Option<Self> {
58 let (org, repo) = entry.split_once('/')?;
59 if org.is_empty() || repo.is_empty() {
60 return None;
61 }
62
63 let repo = match repo.strip_suffix('*') {
64 Some("") => Repo::Any,
65 Some(prefix) => Repo::Prefix(prefix.to_lowercase()),
66 None => Repo::Exact(repo.to_lowercase()),
67 };
68
69 Some(Self {
70 org: org.to_lowercase(),
71 repo,
72 })
73 }
74
75 fn covers(&self, ns: &Namespace) -> bool {
76 if !ns.org().eq_ignore_ascii_case(&self.org) {
77 return false;
78 }
79
80 match &self.repo {
81 Repo::Any => true,
82 Repo::Prefix(prefix) => ns.repo().to_lowercase().starts_with(prefix),
83 Repo::Exact(repo) => ns.repo().eq_ignore_ascii_case(repo),
84 }
85 }
86}
87
88#[cfg(test)]
89mod tests {
90 use super::*;
91
92 fn ns(org: &str, repo: &str) -> Namespace {
93 Namespace::new(org, repo).unwrap()
94 }
95
96 #[test]
97 fn nothing_configured_covers_nothing() {
98 let restricted = Restricted::parse(None);
99
100 assert!(restricted.is_empty());
101 assert!(!restricted.covers(&ns("acme", "assets")));
102 }
103
104 #[test]
105 fn an_exact_entry_covers_only_that_repository() {
106 let restricted = Restricted::parse(Some("acme/assets"));
107
108 assert!(restricted.covers(&ns("acme", "assets")));
109 assert!(!restricted.covers(&ns("acme", "assets-public")));
110 assert!(!restricted.covers(&ns("other", "assets")));
111 }
112
113 #[test]
114 fn a_trailing_star_covers_the_prefix_it_names() {
115 let restricted = Restricted::parse(Some("acme/game-*"));
116
117 assert!(restricted.covers(&ns("acme", "game-art")));
118 assert!(restricted.covers(&ns("acme", "game-")));
119 assert!(!restricted.covers(&ns("acme", "game")));
120 assert!(!restricted.covers(&ns("acme", "tools")));
121 }
122
123 #[test]
124 fn a_bare_star_covers_the_whole_organisation() {
125 let restricted = Restricted::parse(Some("acme/*"));
126
127 assert!(restricted.covers(&ns("acme", "anything")));
128 assert!(!restricted.covers(&ns("acmecorp", "anything")));
129 }
130
131 #[test]
132 fn entries_are_matched_without_regard_to_case() {
133 let restricted = Restricted::parse(Some("ACME/Assets,acme/Game-*"));
134
135 assert!(restricted.covers(&ns("acme", "assets")));
136 assert!(restricted.covers(&ns("Acme", "ASSETS")));
137 assert!(restricted.covers(&ns("acme", "GAME-art")));
138 }
139
140 #[test]
141 fn several_entries_are_read_and_whitespace_around_them_is_not() {
142 let restricted = Restricted::parse(Some(" acme/assets , acme/game-* "));
143
144 assert!(restricted.covers(&ns("acme", "assets")));
145 assert!(restricted.covers(&ns("acme", "game-art")));
146 }
147
148 #[test]
149 fn an_entry_that_names_no_repository_is_dropped_rather_than_widened() {
150 let restricted = Restricted::parse(Some("acme,,/assets,acme/,acme/assets"));
151
152 assert_eq!(restricted.0.len(), 1);
153 assert!(restricted.covers(&ns("acme", "assets")));
154 assert!(!restricted.covers(&ns("acme", "anything")));
155 }
156}