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 {}