Skip to main content

lfsx_server/auth/
restricted.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 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                // 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                    "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}