lfsx_server/auth/
namespaces.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 Namespaces(Vec<Pattern>);
18
19impl Namespaces {
20 pub fn parse(variable: &str, 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 variable,
39 "an entry is not org/repo and was ignored, and is never widened to \
40 the whole organisation"
41 ),
42 }
43 }
44
45 Self(patterns)
46 }
47
48 pub fn from_entries(entries: &[String]) -> Result<Self, Vec<String>> {
49 let mut patterns = Vec::new();
50 let mut unreadable = Vec::new();
51
52 for entry in entries.iter().map(|entry| entry.trim()) {
53 if entry.is_empty() {
54 continue;
55 }
56 match Pattern::parse(entry) {
57 Some(pattern) => patterns.push(pattern),
58 None => unreadable.push(entry.to_owned()),
59 }
60 }
61
62 if unreadable.is_empty() {
63 Ok(Self(patterns))
64 } else {
65 Err(unreadable)
66 }
67 }
68
69 pub fn is_empty(&self) -> bool {
70 self.0.is_empty()
71 }
72
73 pub fn entries(&self) -> Vec<String> {
74 self.0
75 .iter()
76 .map(|pattern| match &pattern.repo {
77 Repo::Any => format!("{}/*", pattern.org),
78 Repo::Prefix(prefix) => format!("{}/{prefix}*", pattern.org),
79 Repo::Exact(repo) => format!("{}/{repo}", pattern.org),
80 })
81 .collect()
82 }
83
84 pub fn covers(&self, ns: &Namespace) -> bool {
85 self.0.iter().any(|pattern| pattern.covers(ns))
86 }
87}
88
89impl Pattern {
90 fn parse(entry: &str) -> Option<Self> {
91 let (org, repo) = entry.split_once('/')?;
92 if org.is_empty() || repo.is_empty() {
93 return None;
94 }
95
96 let repo = match repo.strip_suffix('*') {
97 Some("") => Repo::Any,
98 Some(prefix) => Repo::Prefix(prefix.to_lowercase()),
99 None => Repo::Exact(repo.to_lowercase()),
100 };
101
102 Some(Self {
103 org: org.to_lowercase(),
104 repo,
105 })
106 }
107
108 fn covers(&self, ns: &Namespace) -> bool {
109 if !ns.org().eq_ignore_ascii_case(&self.org) {
110 return false;
111 }
112
113 match &self.repo {
114 Repo::Any => true,
115 Repo::Prefix(prefix) => ns.repo().to_lowercase().starts_with(prefix),
116 Repo::Exact(repo) => ns.repo().eq_ignore_ascii_case(repo),
117 }
118 }
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124
125 fn ns(org: &str, repo: &str) -> Namespace {
126 Namespace::new(org, repo).unwrap()
127 }
128
129 #[test]
130 fn nothing_configured_covers_nothing() {
131 let restricted = Namespaces::parse("LFSX_RESTRICTED", None);
132
133 assert!(restricted.is_empty());
134 assert!(!restricted.covers(&ns("acme", "assets")));
135 }
136
137 #[test]
138 fn an_exact_entry_covers_only_that_repository() {
139 let restricted = Namespaces::parse("LFSX_RESTRICTED", Some("acme/assets"));
140
141 assert!(restricted.covers(&ns("acme", "assets")));
142 assert!(!restricted.covers(&ns("acme", "assets-public")));
143 assert!(!restricted.covers(&ns("other", "assets")));
144 }
145
146 #[test]
147 fn a_trailing_star_covers_the_prefix_it_names() {
148 let restricted = Namespaces::parse("LFSX_RESTRICTED", Some("acme/game-*"));
149
150 assert!(restricted.covers(&ns("acme", "game-art")));
151 assert!(restricted.covers(&ns("acme", "game-")));
152 assert!(!restricted.covers(&ns("acme", "game")));
153 assert!(!restricted.covers(&ns("acme", "tools")));
154 }
155
156 #[test]
157 fn a_bare_star_covers_the_whole_organisation() {
158 let restricted = Namespaces::parse("LFSX_RESTRICTED", Some("acme/*"));
159
160 assert!(restricted.covers(&ns("acme", "anything")));
161 assert!(!restricted.covers(&ns("acmecorp", "anything")));
162 }
163
164 #[test]
165 fn entries_are_matched_without_regard_to_case() {
166 let restricted = Namespaces::parse("LFSX_RESTRICTED", Some("ACME/Assets,acme/Game-*"));
167
168 assert!(restricted.covers(&ns("acme", "assets")));
169 assert!(restricted.covers(&ns("Acme", "ASSETS")));
170 assert!(restricted.covers(&ns("acme", "GAME-art")));
171 }
172
173 #[test]
174 fn several_entries_are_read_and_whitespace_around_them_is_not() {
175 let restricted = Namespaces::parse("LFSX_RESTRICTED", Some(" acme/assets , acme/game-* "));
176
177 assert!(restricted.covers(&ns("acme", "assets")));
178 assert!(restricted.covers(&ns("acme", "game-art")));
179 }
180
181 #[test]
182 fn an_entry_that_names_no_repository_is_dropped_rather_than_widened() {
183 let restricted =
184 Namespaces::parse("LFSX_RESTRICTED", Some("acme,,/assets,acme/,acme/assets"));
185
186 assert_eq!(restricted.0.len(), 1);
187 assert!(restricted.covers(&ns("acme", "assets")));
188 assert!(!restricted.covers(&ns("acme", "anything")));
189 }
190}