greentic_component/
capabilities.rs

1use once_cell::sync::Lazy;
2use regex::Regex;
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
6pub struct Capabilities {
7    #[serde(default)]
8    pub http: Option<HttpCaps>,
9    #[serde(default)]
10    pub secrets: Option<SecretsCaps>,
11    #[serde(default)]
12    pub kv: Option<KvCaps>,
13    #[serde(default)]
14    pub fs: Option<FsCaps>,
15    #[serde(default)]
16    pub net: Option<NetCaps>,
17    #[serde(default)]
18    pub tools: Option<ToolsCaps>,
19}
20
21impl Capabilities {
22    pub fn is_empty(&self) -> bool {
23        self.http.is_none()
24            && self.secrets.is_none()
25            && self.kv.is_none()
26            && self.fs.is_none()
27            && self.net.is_none()
28            && self.tools.is_none()
29    }
30
31    pub fn validate(&self) -> Result<(), CapabilityError> {
32        if let Some(http) = &self.http {
33            http.validate("capabilities.http")?;
34        }
35        if let Some(secrets) = &self.secrets {
36            secrets.validate("capabilities.secrets")?;
37        }
38        if let Some(kv) = &self.kv {
39            kv.validate("capabilities.kv")?;
40        }
41        if let Some(fs) = &self.fs {
42            fs.validate("capabilities.fs")?;
43        }
44        if let Some(net) = &self.net {
45            net.validate("capabilities.net")?;
46        }
47        if let Some(tools) = &self.tools {
48            tools.validate("capabilities.tools")?;
49        }
50        Ok(())
51    }
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
55pub struct HttpCaps {
56    pub domains: Vec<String>,
57    #[serde(default)]
58    pub allow_insecure: bool,
59}
60
61impl HttpCaps {
62    fn validate(&self, path: &str) -> Result<(), CapabilityError> {
63        if self.domains.is_empty() {
64            return Err(CapabilityError::invalid(
65                "http",
66                format!("{path}.domains"),
67                "domains cannot be empty",
68            ));
69        }
70        for domain in &self.domains {
71            if !DOMAIN_RE.is_match(domain) {
72                return Err(CapabilityError::invalid(
73                    "http",
74                    format!("{path}.domains[{domain}]"),
75                    "domain must be alphanumeric with dots/dashes",
76                ));
77            }
78        }
79        Ok(())
80    }
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
84pub struct SecretsCaps {
85    pub scopes: Vec<String>,
86}
87
88impl SecretsCaps {
89    fn validate(&self, path: &str) -> Result<(), CapabilityError> {
90        if self.scopes.is_empty() {
91            return Err(CapabilityError::invalid(
92                "secrets",
93                format!("{path}.scopes"),
94                "at least one scope is required",
95            ));
96        }
97        for scope in &self.scopes {
98            if scope.trim().is_empty() {
99                return Err(CapabilityError::invalid(
100                    "secrets",
101                    format!("{path}.scopes"),
102                    "scopes cannot be blank",
103                ));
104            }
105        }
106        Ok(())
107    }
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
111pub struct KvCaps {
112    pub buckets: Vec<String>,
113    #[serde(default = "default_true")]
114    pub read: bool,
115    #[serde(default)]
116    pub write: bool,
117}
118
119impl KvCaps {
120    fn validate(&self, path: &str) -> Result<(), CapabilityError> {
121        if self.buckets.is_empty() {
122            return Err(CapabilityError::invalid(
123                "kv",
124                format!("{path}.buckets"),
125                "at least one bucket is required",
126            ));
127        }
128        for bucket in &self.buckets {
129            if !BUCKET_RE.is_match(bucket) {
130                return Err(CapabilityError::invalid(
131                    "kv",
132                    format!("{path}.buckets[{bucket}]"),
133                    "bucket names must be lowercase alphanumeric or dash",
134                ));
135            }
136        }
137        Ok(())
138    }
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
142pub struct FsCaps {
143    pub paths: Vec<String>,
144    #[serde(default = "default_true")]
145    pub read_only: bool,
146}
147
148impl FsCaps {
149    fn validate(&self, path: &str) -> Result<(), CapabilityError> {
150        if self.paths.is_empty() {
151            return Err(CapabilityError::invalid(
152                "fs",
153                format!("{path}.paths"),
154                "at least one path is required",
155            ));
156        }
157        for p in &self.paths {
158            if p.trim().is_empty() {
159                return Err(CapabilityError::invalid(
160                    "fs",
161                    format!("{path}.paths"),
162                    "paths cannot be blank",
163                ));
164            }
165        }
166        Ok(())
167    }
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
171pub struct NetCaps {
172    #[serde(default)]
173    pub hosts: Vec<String>,
174    #[serde(default = "default_true")]
175    pub allow_tcp: bool,
176    #[serde(default)]
177    pub allow_udp: bool,
178}
179
180impl NetCaps {
181    fn validate(&self, path: &str) -> Result<(), CapabilityError> {
182        for host in &self.hosts {
183            if host.trim().is_empty() {
184                return Err(CapabilityError::invalid(
185                    "net",
186                    format!("{path}.hosts"),
187                    "hosts cannot be blank",
188                ));
189            }
190        }
191        Ok(())
192    }
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
196pub struct ToolsCaps {
197    #[serde(default)]
198    pub allow: Vec<String>,
199}
200
201impl ToolsCaps {
202    fn validate(&self, path: &str) -> Result<(), CapabilityError> {
203        for tool in &self.allow {
204            if tool.trim().is_empty() {
205                return Err(CapabilityError::invalid(
206                    "tools",
207                    format!("{path}.allow"),
208                    "tool names cannot be blank",
209                ));
210            }
211        }
212        Ok(())
213    }
214}
215
216fn default_true() -> bool {
217    true
218}
219
220static DOMAIN_RE: Lazy<Regex> = Lazy::new(|| {
221    Regex::new(r"^[A-Za-z0-9.-]+$").expect("http domain regex compile should never fail")
222});
223
224static BUCKET_RE: Lazy<Regex> =
225    Lazy::new(|| Regex::new(r"^[a-z0-9-]+$").expect("bucket regex compile should never fail"));
226
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228pub enum CapabilityErrorKind {
229    Invalid,
230    Denied,
231}
232
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct CapabilityError {
235    pub capability: &'static str,
236    pub path: String,
237    pub kind: CapabilityErrorKind,
238    pub message: String,
239}
240
241impl CapabilityError {
242    pub fn invalid(
243        capability: &'static str,
244        path: impl Into<String>,
245        message: impl Into<String>,
246    ) -> Self {
247        Self {
248            capability,
249            path: path.into(),
250            kind: CapabilityErrorKind::Invalid,
251            message: message.into(),
252        }
253    }
254
255    pub fn denied(
256        capability: &'static str,
257        path: impl Into<String>,
258        message: impl Into<String>,
259    ) -> Self {
260        Self {
261            capability,
262            path: path.into(),
263            kind: CapabilityErrorKind::Denied,
264            message: message.into(),
265        }
266    }
267}
268
269impl std::fmt::Display for CapabilityError {
270    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
271        write!(
272            f,
273            "{:?} capability `{}` at `{}`: {}",
274            self.kind, self.capability, self.path, self.message
275        )
276    }
277}
278
279impl std::error::Error for CapabilityError {}