1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
10pub enum SeccompMode {
11 #[default]
13 Default,
14 Unconfined,
16 Custom(String),
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct SecurityConfig {
23 pub seccomp: SeccompMode,
25 pub no_new_privileges: bool,
27 pub cap_add: Vec<String>,
29 pub cap_drop: Vec<String>,
31 pub privileged: bool,
33}
34
35impl Default for SecurityConfig {
36 fn default() -> Self {
37 Self {
38 seccomp: SeccompMode::Default,
39 no_new_privileges: true, cap_add: vec![],
41 cap_drop: vec![],
42 privileged: false,
43 }
44 }
45}
46
47impl SecurityConfig {
48 pub fn validate(&self) -> Result<(), String> {
53 if let SeccompMode::Custom(path) = &self.seccomp {
54 return Err(format!(
55 "custom seccomp profile '{}' is not supported; \
56 use seccomp=default or seccomp=unconfined",
57 path
58 ));
59 }
60 Ok(())
61 }
62
63 pub fn from_options(
76 security_opt: &[String],
77 cap_add: &[String],
78 cap_drop: &[String],
79 privileged: bool,
80 ) -> Self {
81 if privileged {
82 return Self {
83 seccomp: SeccompMode::Unconfined,
84 no_new_privileges: false,
85 cap_add: vec!["ALL".to_string()],
86 cap_drop: vec![],
87 privileged: true,
88 };
89 }
90
91 let mut config = Self {
92 cap_add: cap_add.to_vec(),
93 cap_drop: cap_drop.to_vec(),
94 ..Self::default()
95 };
96
97 for opt in security_opt {
98 let opt = opt.trim();
99 if opt.eq_ignore_ascii_case("no-new-privileges") {
100 config.no_new_privileges = true;
101 continue;
102 }
103
104 let Some((key, value)) = opt.split_once('=') else {
105 continue;
106 };
107 let key = key.trim();
108 let value = value.trim();
109 if key.eq_ignore_ascii_case("seccomp") {
110 config.seccomp = if value.eq_ignore_ascii_case("default") {
111 SeccompMode::Default
112 } else if value.eq_ignore_ascii_case("unconfined") {
113 SeccompMode::Unconfined
114 } else {
115 SeccompMode::Custom(value.to_string())
116 };
117 } else if key.eq_ignore_ascii_case("no-new-privileges") {
118 if value.eq_ignore_ascii_case("true") {
119 config.no_new_privileges = true;
120 } else if value.eq_ignore_ascii_case("false") {
121 config.no_new_privileges = false;
122 }
123 }
124 }
125
126 config
127 }
128
129 pub fn to_env_vars(&self) -> Vec<(String, String)> {
133 let mut env = Vec::new();
134
135 let seccomp_value = match &self.seccomp {
137 SeccompMode::Default => "default".to_string(),
138 SeccompMode::Unconfined => "unconfined".to_string(),
139 SeccompMode::Custom(path) => format!("custom:{}", path),
140 };
141 env.push(("A3S_SEC_SECCOMP".to_string(), seccomp_value));
142
143 env.push((
145 "A3S_SEC_NO_NEW_PRIVS".to_string(),
146 if self.no_new_privileges { "1" } else { "0" }.to_string(),
147 ));
148
149 if self.privileged {
151 env.push(("A3S_SEC_PRIVILEGED".to_string(), "1".to_string()));
152 }
153
154 if !self.cap_add.is_empty() {
156 env.push(("A3S_SEC_CAP_ADD".to_string(), self.cap_add.join(",")));
157 }
158 if !self.cap_drop.is_empty() {
159 env.push(("A3S_SEC_CAP_DROP".to_string(), self.cap_drop.join(",")));
160 }
161
162 env
163 }
164
165 pub fn from_env_vars() -> Self {
167 let mut config = Self::default();
168
169 if let Ok(val) = std::env::var("A3S_SEC_PRIVILEGED") {
170 if val == "1" {
171 return Self {
172 seccomp: SeccompMode::Unconfined,
173 no_new_privileges: false,
174 cap_add: vec!["ALL".to_string()],
175 cap_drop: vec![],
176 privileged: true,
177 };
178 }
179 }
180
181 if let Ok(val) = std::env::var("A3S_SEC_SECCOMP") {
182 config.seccomp = if val == "unconfined" {
183 SeccompMode::Unconfined
184 } else if val == "default" {
185 SeccompMode::Default
186 } else if let Some(path) = val.strip_prefix("custom:") {
187 SeccompMode::Custom(path.to_string())
188 } else {
189 SeccompMode::Default
190 };
191 }
192
193 if let Ok(val) = std::env::var("A3S_SEC_NO_NEW_PRIVS") {
194 config.no_new_privileges = val == "1";
195 }
196
197 if let Ok(val) = std::env::var("A3S_SEC_CAP_ADD") {
198 config.cap_add = val.split(',').map(|s| s.trim().to_string()).collect();
199 }
200
201 if let Ok(val) = std::env::var("A3S_SEC_CAP_DROP") {
202 config.cap_drop = val.split(',').map(|s| s.trim().to_string()).collect();
203 }
204
205 config
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 #[test]
214 fn test_security_config_default() {
215 let config = SecurityConfig::default();
216 assert_eq!(config.seccomp, SeccompMode::Default);
217 assert!(config.no_new_privileges);
218 assert!(config.cap_add.is_empty());
219 assert!(config.cap_drop.is_empty());
220 assert!(!config.privileged);
221 }
222
223 #[test]
224 fn test_seccomp_mode_default() {
225 assert_eq!(SeccompMode::default(), SeccompMode::Default);
226 }
227
228 #[test]
229 fn test_from_options_empty() {
230 let config = SecurityConfig::from_options(&[], &[], &[], false);
231 assert_eq!(config.seccomp, SeccompMode::Default);
232 assert!(config.no_new_privileges);
233 assert!(!config.privileged);
234 }
235
236 #[test]
237 fn test_from_options_seccomp_unconfined() {
238 let opts = vec!["seccomp=unconfined".to_string()];
239 let config = SecurityConfig::from_options(&opts, &[], &[], false);
240 assert_eq!(config.seccomp, SeccompMode::Unconfined);
241 }
242
243 #[test]
244 fn test_from_options_explicit_seccomp_default() {
245 let opts = vec![" SECCOMP=DEFAULT ".to_string()];
246 let config = SecurityConfig::from_options(&opts, &[], &[], false);
247 assert_eq!(config.seccomp, SeccompMode::Default);
248 }
249
250 #[test]
251 fn test_from_options_seccomp_custom() {
252 let opts = vec!["seccomp=/path/to/profile.json".to_string()];
253 let config = SecurityConfig::from_options(&opts, &[], &[], false);
254 assert_eq!(
255 config.seccomp,
256 SeccompMode::Custom("/path/to/profile.json".to_string())
257 );
258 }
259
260 #[test]
261 fn test_from_options_no_new_privileges() {
262 let opts = vec!["no-new-privileges".to_string()];
263 let config = SecurityConfig::from_options(&opts, &[], &[], false);
264 assert!(config.no_new_privileges);
265 }
266
267 #[test]
268 fn test_from_options_no_new_privileges_false() {
269 let opts = vec!["no-new-privileges=false".to_string()];
270 let config = SecurityConfig::from_options(&opts, &[], &[], false);
271 assert!(!config.no_new_privileges);
272 }
273
274 #[test]
275 fn test_from_options_privileged() {
276 let config = SecurityConfig::from_options(&[], &[], &[], true);
277 assert!(config.privileged);
278 assert_eq!(config.seccomp, SeccompMode::Unconfined);
279 assert!(!config.no_new_privileges);
280 assert_eq!(config.cap_add, vec!["ALL"]);
281 }
282
283 #[test]
284 fn test_from_options_capabilities() {
285 let cap_add = vec!["NET_ADMIN".to_string(), "SYS_PTRACE".to_string()];
286 let cap_drop = vec!["NET_RAW".to_string()];
287 let config = SecurityConfig::from_options(&[], &cap_add, &cap_drop, false);
288 assert_eq!(config.cap_add, vec!["NET_ADMIN", "SYS_PTRACE"]);
289 assert_eq!(config.cap_drop, vec!["NET_RAW"]);
290 }
291
292 #[test]
293 fn test_to_env_vars_default() {
294 let config = SecurityConfig::default();
295 let env = config.to_env_vars();
296 assert!(env.contains(&("A3S_SEC_SECCOMP".to_string(), "default".to_string())));
297 assert!(env.contains(&("A3S_SEC_NO_NEW_PRIVS".to_string(), "1".to_string())));
298 assert!(!env.iter().any(|(k, _)| k == "A3S_SEC_CAP_ADD"));
300 assert!(!env.iter().any(|(k, _)| k == "A3S_SEC_CAP_DROP"));
301 assert!(!env.iter().any(|(k, _)| k == "A3S_SEC_PRIVILEGED"));
302 }
303
304 #[test]
305 fn test_to_env_vars_privileged() {
306 let config = SecurityConfig::from_options(&[], &[], &[], true);
307 let env = config.to_env_vars();
308 assert!(env.contains(&("A3S_SEC_SECCOMP".to_string(), "unconfined".to_string())));
309 assert!(env.contains(&("A3S_SEC_NO_NEW_PRIVS".to_string(), "0".to_string())));
310 assert!(env.contains(&("A3S_SEC_PRIVILEGED".to_string(), "1".to_string())));
311 assert!(env.contains(&("A3S_SEC_CAP_ADD".to_string(), "ALL".to_string())));
312 }
313
314 #[test]
315 fn test_to_env_vars_with_caps() {
316 let cap_add = vec!["NET_ADMIN".to_string()];
317 let cap_drop = vec!["ALL".to_string()];
318 let config = SecurityConfig::from_options(&[], &cap_add, &cap_drop, false);
319 let env = config.to_env_vars();
320 assert!(env.contains(&("A3S_SEC_CAP_ADD".to_string(), "NET_ADMIN".to_string())));
321 assert!(env.contains(&("A3S_SEC_CAP_DROP".to_string(), "ALL".to_string())));
322 }
323
324 #[test]
325 fn test_env_vars_roundtrip() {
326 let original = SecurityConfig::from_options(
327 &["seccomp=unconfined".to_string()],
328 &["NET_ADMIN".to_string(), "SYS_PTRACE".to_string()],
329 &["NET_RAW".to_string()],
330 false,
331 );
332 let env = original.to_env_vars();
333
334 for (key, value) in &env {
336 std::env::set_var(key, value);
337 }
338
339 let parsed = SecurityConfig::from_env_vars();
340 assert_eq!(parsed.seccomp, SeccompMode::Unconfined);
341 assert!(parsed.no_new_privileges); assert_eq!(parsed.cap_add, vec!["NET_ADMIN", "SYS_PTRACE"]);
343 assert_eq!(parsed.cap_drop, vec!["NET_RAW"]);
344 assert!(!parsed.privileged);
345
346 for (key, _) in &env {
348 std::env::remove_var(key);
349 }
350 }
351
352 #[test]
353 fn test_security_config_serde_roundtrip() {
354 let config = SecurityConfig {
355 seccomp: SeccompMode::Custom("/my/profile.json".to_string()),
356 no_new_privileges: false,
357 cap_add: vec!["NET_ADMIN".to_string()],
358 cap_drop: vec!["ALL".to_string()],
359 privileged: false,
360 };
361 let json = serde_json::to_string(&config).unwrap();
362 let parsed: SecurityConfig = serde_json::from_str(&json).unwrap();
363 assert_eq!(
364 parsed.seccomp,
365 SeccompMode::Custom("/my/profile.json".to_string())
366 );
367 assert!(!parsed.no_new_privileges);
368 assert_eq!(parsed.cap_add, vec!["NET_ADMIN"]);
369 assert_eq!(parsed.cap_drop, vec!["ALL"]);
370 }
371
372 #[test]
375 fn test_validate_default_ok() {
376 let config = SecurityConfig::default();
377 assert!(config.validate().is_ok());
378 }
379
380 #[test]
381 fn test_validate_unconfined_ok() {
382 let config =
383 SecurityConfig::from_options(&["seccomp=unconfined".to_string()], &[], &[], false);
384 assert!(config.validate().is_ok());
385 }
386
387 #[test]
388 fn test_validate_custom_rejected() {
389 let config = SecurityConfig::from_options(
390 &["seccomp=/path/to/profile.json".to_string()],
391 &[],
392 &[],
393 false,
394 );
395 let err = config.validate().unwrap_err();
396 assert!(err.contains("custom seccomp profile"));
397 assert!(err.contains("not supported"));
398 }
399
400 #[test]
401 fn test_validate_privileged_ok() {
402 let config = SecurityConfig::from_options(&[], &[], &[], true);
403 assert!(config.validate().is_ok());
404 }
405}