1use serde::{Deserialize, Serialize};
28use std::collections::{BTreeMap, BTreeSet};
29use std::fmt;
30
31pub const PLACEHOLDER_PREFIX: &str = "SMOL_PLACEHOLDER_";
36
37pub const GUEST_CA_DIR: &str = "/run/smol/credentials";
39pub const GUEST_CA_FILE: &str = "ca.pem";
41pub const GUEST_CA_BUNDLE: &str = "/run/smolvm/ca-bundle.pem";
45
46pub const GUEST_TRUST_ENV: &[(&str, &str)] = &[
48 ("SSL_CERT_FILE", GUEST_CA_BUNDLE),
49 ("CURL_CA_BUNDLE", GUEST_CA_BUNDLE),
50 ("REQUESTS_CA_BUNDLE", GUEST_CA_BUNDLE),
51 ("GIT_SSL_CAINFO", GUEST_CA_BUNDLE),
52 ("NODE_EXTRA_CA_CERTS", "/run/smol/credentials/ca.pem"),
53 ("DENO_CERT", "/run/smol/credentials/ca.pem"),
54];
55
56pub const DEFAULT_METHODS: &[&str] = &["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
58
59const SUPPORTED_METHODS: &[&str] = DEFAULT_METHODS;
60
61#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(deny_unknown_fields)]
64pub struct InjectionLocation {
65 #[serde(default = "yes")]
68 pub header: bool,
69}
70
71fn yes() -> bool {
72 true
73}
74
75impl Default for InjectionLocation {
76 fn default() -> Self {
77 Self { header: true }
78 }
79}
80
81#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct CredentialBinding {
85 pub name: String,
88 pub environment_variable: String,
90 pub allowed_hosts: Vec<String>,
93 #[serde(default)]
95 pub injection_location: InjectionLocation,
96 #[serde(default = "default_methods")]
99 pub methods: Vec<String>,
100}
101
102fn default_methods() -> Vec<String> {
103 DEFAULT_METHODS.iter().map(|m| m.to_string()).collect()
104}
105
106impl CredentialBinding {
107 pub fn allows_host(&self, host: &str) -> bool {
109 let host = host.trim_end_matches('.');
110 self.allowed_hosts
111 .iter()
112 .any(|allowed| allowed.eq_ignore_ascii_case(host))
113 }
114
115 pub fn allows_method(&self, method: &str) -> bool {
117 self.methods.iter().any(|m| m.eq_ignore_ascii_case(method))
118 }
119}
120
121#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(deny_unknown_fields, default)]
124pub struct CredentialPolicy {
125 pub credentials: Vec<CredentialBinding>,
127}
128
129#[derive(Clone, Debug, PartialEq, Eq)]
131pub struct PolicyError(pub String);
132
133impl fmt::Display for PolicyError {
134 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135 f.write_str(&self.0)
136 }
137}
138
139impl std::error::Error for PolicyError {}
140
141fn refuse(message: String) -> Result<(), PolicyError> {
142 Err(PolicyError(message))
143}
144
145impl CredentialPolicy {
146 pub fn is_empty(&self) -> bool {
148 self.credentials.is_empty()
149 }
150
151 pub fn validate(&self, network_allowed_hosts: Option<&[String]>) -> Result<(), PolicyError> {
156 let mut names = BTreeSet::new();
157 let mut env_vars = BTreeSet::new();
158 for binding in &self.credentials {
159 let name = &binding.name;
160 let env_var = &binding.environment_variable;
161 if !valid_binding_name(name) {
162 return refuse(format!(
163 "credential name {name:?} is not a valid binding name"
164 ));
165 }
166 if !valid_env_name(env_var) {
167 return refuse(format!(
168 "credential environment variable {env_var:?} is not a valid name"
169 ));
170 }
171 if !names.insert(name.as_str()) {
172 return refuse(format!("credential {name:?} is declared twice"));
173 }
174 if !env_vars.insert(env_var.as_str()) {
175 return refuse(format!(
176 "environment variable {env_var:?} is bound to more than one credential"
177 ));
178 }
179 if binding.allowed_hosts.is_empty() {
180 return refuse(format!(
181 "credential {name:?} lists no allowed_hosts; a credential is never sent anywhere by default"
182 ));
183 }
184 for host in &binding.allowed_hosts {
185 if !valid_exact_host(host) {
186 return refuse(format!(
187 "credential {name:?} host {host:?} must be an exact lowercase DNS name (no wildcard, scheme, port or IP)"
188 ));
189 }
190 if network_allowed_hosts
191 .is_some_and(|allowed| !covered_by_allow_list(host, allowed))
192 {
193 return refuse(format!(
194 "credential {name:?} host {host:?} is not reachable under the machine's network allow_hosts"
195 ));
196 }
197 }
198 if binding.methods.is_empty() {
199 return refuse(format!("credential {name:?} allows no HTTP methods"));
200 }
201 if let Some(method) = binding.methods.iter().find(|method| {
202 !SUPPORTED_METHODS
203 .iter()
204 .any(|m| m.eq_ignore_ascii_case(method))
205 }) {
206 return refuse(format!(
207 "credential {name:?} method {method:?} is not supported"
208 ));
209 }
210 if !binding.injection_location.header {
211 return refuse(format!("credential {name:?} enables no injection location"));
212 }
213 }
214 Ok(())
215 }
216
217 pub fn bindings_for_host<'a>(
219 &'a self,
220 host: &'a str,
221 ) -> impl Iterator<Item = &'a CredentialBinding> + 'a {
222 self.credentials.iter().filter(move |b| b.allows_host(host))
223 }
224
225 pub fn binding(&self, name: &str) -> Option<&CredentialBinding> {
227 self.credentials.iter().find(|b| b.name == name)
228 }
229
230 pub fn guest_env<'a>(
234 &'a self,
235 placeholders: &'a BTreeMap<String, String>,
236 ) -> impl Iterator<Item = (String, String)> + 'a {
237 self.credentials.iter().filter_map(move |b| {
238 placeholders
239 .get(&b.name)
240 .map(|p| (b.environment_variable.clone(), p.clone()))
241 })
242 }
243}
244
245pub fn covered_by_allow_list(host: &str, allowed: &[String]) -> bool {
248 let host = host.trim_end_matches('.').to_ascii_lowercase();
249 allowed.iter().any(|pattern| {
250 let pattern = pattern.trim_end_matches('.').to_ascii_lowercase();
251 host == pattern || host.ends_with(&format!(".{pattern}"))
252 })
253}
254
255fn valid_binding_name(name: &str) -> bool {
256 !name.is_empty()
257 && name.len() <= 64
258 && name
259 .bytes()
260 .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'_' | b'-' | b'.'))
261}
262
263fn valid_env_name(name: &str) -> bool {
264 let mut bytes = name.bytes();
265 bytes
266 .next()
267 .is_some_and(|c| c.is_ascii_alphabetic() || c == b'_')
268 && bytes.all(|c| c.is_ascii_alphanumeric() || c == b'_')
269}
270
271fn valid_exact_host(host: &str) -> bool {
272 !host.is_empty()
273 && host.len() <= 253
274 && host.parse::<std::net::IpAddr>().is_err()
275 && !host.contains(['/', ':', '*', '@', '?', '#'])
276 && host.split('.').all(|label| {
277 !label.is_empty()
278 && label.len() <= 63
279 && !label.starts_with('-')
280 && !label.ends_with('-')
281 && label
282 .bytes()
283 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-')
284 })
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 fn notion() -> CredentialBinding {
292 CredentialBinding {
293 name: "notion".into(),
294 environment_variable: "NOTION_API_KEY".into(),
295 allowed_hosts: vec!["api.notion.com".into()],
296 injection_location: InjectionLocation::default(),
297 methods: default_methods(),
298 }
299 }
300
301 #[test]
302 fn parses_the_harness_shape() {
303 let policy: CredentialPolicy = serde_json::from_str(
304 r#"{"credentials":[{"name":"notion","environment_variable":"NOTION_API_KEY",
305 "allowed_hosts":["api.notion.com"],"injection_location":{"header":true}}]}"#,
306 )
307 .unwrap();
308 assert_eq!(policy.credentials, vec![notion()]);
309 policy.validate(None).unwrap();
310 }
311
312 #[test]
313 fn credential_hosts_must_sit_inside_the_network_allow_list() {
314 let policy = CredentialPolicy {
315 credentials: vec![notion()],
316 };
317 policy.validate(Some(&["notion.com".to_string()])).unwrap();
318 policy
319 .validate(Some(&["api.notion.com".to_string()]))
320 .unwrap();
321 let err = policy
322 .validate(Some(&["api.github.com".to_string()]))
323 .unwrap_err();
324 assert!(err.0.contains("network allow_hosts"), "{err}");
325 }
326
327 #[test]
328 fn rejects_empty_wildcard_and_ip_hosts() {
329 for host in [
330 "",
331 "*.notion.com",
332 "10.0.0.1",
333 "API.notion.com",
334 "notion.com:443",
335 ] {
336 let mut b = notion();
337 b.allowed_hosts = vec![host.to_string()];
338 let policy = CredentialPolicy {
339 credentials: vec![b],
340 };
341 assert!(policy.validate(None).is_err(), "{host:?} accepted");
342 }
343 let mut b = notion();
344 b.allowed_hosts.clear();
345 let policy = CredentialPolicy {
346 credentials: vec![b],
347 };
348 assert!(policy
349 .validate(None)
350 .unwrap_err()
351 .0
352 .contains("no allowed_hosts"));
353 }
354
355 #[test]
356 fn rejects_duplicates_and_bad_names() {
357 let policy = CredentialPolicy {
358 credentials: vec![notion(), notion()],
359 };
360 assert!(policy
361 .validate(None)
362 .unwrap_err()
363 .0
364 .contains("declared twice"));
365 let mut other = notion();
366 other.name = "notion-2".into();
367 let policy = CredentialPolicy {
368 credentials: vec![notion(), other],
369 };
370 assert!(policy
371 .validate(None)
372 .unwrap_err()
373 .0
374 .contains("more than one credential"));
375 let mut bad = notion();
376 bad.environment_variable = "1BAD".into();
377 let policy = CredentialPolicy {
378 credentials: vec![bad],
379 };
380 assert!(policy
381 .validate(None)
382 .unwrap_err()
383 .0
384 .contains("not a valid name"));
385 }
386
387 #[test]
388 fn guest_env_maps_placeholders_onto_bound_variables() {
389 let mut github = notion();
390 github.name = "github".into();
391 github.environment_variable = "GITHUB_TOKEN".into();
392 github.allowed_hosts = vec!["api.github.com".into()];
393 let policy = CredentialPolicy {
394 credentials: vec![notion(), github],
395 };
396 let placeholders: BTreeMap<String, String> = [
397 (
398 "notion".to_string(),
399 "SMOL_PLACEHOLDER_NOTION_1".to_string(),
400 ),
401 (
402 "github".to_string(),
403 "SMOL_PLACEHOLDER_GITHUB_2".to_string(),
404 ),
405 ]
406 .into_iter()
407 .collect();
408 let env: Vec<_> = policy.guest_env(&placeholders).collect();
409 assert_eq!(
410 env,
411 vec![
412 (
413 "NOTION_API_KEY".to_string(),
414 "SMOL_PLACEHOLDER_NOTION_1".to_string()
415 ),
416 (
417 "GITHUB_TOKEN".to_string(),
418 "SMOL_PLACEHOLDER_GITHUB_2".to_string()
419 ),
420 ]
421 );
422 assert!(policy
423 .bindings_for_host("api.github.com")
424 .all(|b| b.name == "github"));
425 }
426}