Skip to main content

mkit_server/policy/
namespace.rs

1use std::collections::BTreeSet;
2
3use mkit_core::repo_identity::Namespace;
4
5/// Namespaces served for writes (SPEC-TRANSPORT-CONNECT §7.5).
6/// A denial cannot be overridden by an authorizer (SPEC-SERVER §6.2).
7#[derive(Debug, Clone, PartialEq, Eq)]
8#[non_exhaustive]
9pub enum NamespacePolicy {
10    /// Only these owner namespaces may receive writes. The default is empty.
11    Allowlist(BTreeSet<Namespace>),
12    /// Every self-certifying namespace may receive writes. D27 requires
13    /// non-default admission unless the operator explicitly accepts the risk.
14    Any {
15        /// Permit default admission despite new keys resetting namespace quotas.
16        unsafe_without_admission: bool,
17    },
18}
19
20impl Default for NamespacePolicy {
21    fn default() -> Self {
22        Self::Allowlist(BTreeSet::new())
23    }
24}
25
26/// Parse a namespace allowlist file (the native `--namespace-allowlist`
27/// file and the Worker `NAMESPACE_ALLOWLIST` var): namespaces separated by
28/// newlines or commas, each in its canonical form (`ed25519-<64 hex>` or
29/// `0x<40 hex>`). `#` starts a comment that runs to the end of its line;
30/// blank entries are ignored. The file is security configuration, not a
31/// secret.
32///
33/// # Errors
34/// A message naming the 1-based line of the first malformed or duplicate
35/// entry, and "namespace allowlist contains no namespaces" when nothing
36/// parses.
37pub fn parse_namespace_allowlist(text: &str) -> Result<BTreeSet<Namespace>, String> {
38    let mut namespaces = BTreeSet::new();
39    for (line, raw) in text.lines().enumerate() {
40        let uncommented = raw.split('#').next().unwrap_or_default();
41        for entry in uncommented.split(',') {
42            let entry = entry.trim();
43            if entry.is_empty() {
44                continue;
45            }
46            let namespace =
47                Namespace::parse(entry).map_err(|e| format!("line {}: {e}", line + 1))?;
48            if !namespaces.insert(namespace) {
49                return Err(format!("line {}: duplicate namespace {entry}", line + 1));
50            }
51        }
52    }
53    if namespaces.is_empty() {
54        return Err("namespace allowlist contains no namespaces".to_owned());
55    }
56    Ok(namespaces)
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62
63    fn ns(byte: u8) -> String {
64        format!("ed25519-{}", "a".repeat(62) + &format!("{byte:02x}"))
65    }
66
67    #[test]
68    fn parses_newlines_commas_comments_and_blanks() {
69        let text = format!(
70            "# deployment owners\n\n  {}\n{},{}  # the third one\n",
71            ns(1),
72            ns(2),
73            ns(3)
74        );
75        let set = parse_namespace_allowlist(&text).unwrap();
76        assert_eq!(set.len(), 3);
77        for byte in 1..=3 {
78            assert!(set.contains(&Namespace::parse(&ns(byte)).unwrap()));
79        }
80    }
81
82    #[test]
83    fn tolerates_crlf_and_trailing_commas() {
84        let set = parse_namespace_allowlist(&format!("{},\r\n{}\r\n", ns(1), ns(2))).unwrap();
85        assert_eq!(set.len(), 2);
86    }
87
88    #[test]
89    fn accepts_0x_namespaces() {
90        let address = format!("0x{}", "b".repeat(40));
91        let set = parse_namespace_allowlist(&format!("{},{address}", ns(1))).unwrap();
92        assert!(set.contains(&Namespace::Address([0xbb; 20])));
93    }
94
95    #[test]
96    fn refuses_uppercase_bare_and_garbage() {
97        for bad in [
98            format!("ed25519-{}", "A".repeat(64)),
99            "default".to_owned(),
100            format!("0X{}", "b".repeat(40)),
101            "not a namespace".to_owned(),
102            format!("ed25519-{}/repo", "a".repeat(64)),
103        ] {
104            let err = parse_namespace_allowlist(&format!("{}\n{bad}", ns(1))).unwrap_err();
105            assert!(err.starts_with("line 2:"), "{bad}: {err}");
106        }
107    }
108
109    #[test]
110    fn refuses_duplicates() {
111        let err = parse_namespace_allowlist(&format!("{0},{0}", ns(1))).unwrap_err();
112        assert_eq!(err, format!("line 1: duplicate namespace {}", ns(1)));
113    }
114
115    #[test]
116    fn refuses_an_empty_allowlist() {
117        for text in ["", "\n", "# only a comment\n", "  , \n"] {
118            assert_eq!(
119                parse_namespace_allowlist(text).unwrap_err(),
120                "namespace allowlist contains no namespaces"
121            );
122        }
123    }
124}