Skip to main content

lfsx_server/auth/
namespaces.rs

1use 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                // Dropped rather than widened, because an entry nobody can read as
32                // org/repo must never become the whole organisation. Said out loud
33                // because the direction it fails in is open: a typo leaves the
34                // objects served to anyone the forge grants pull, and the boot line
35                // below stays quiet when nothing parsed at all.
36                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}