1use std::collections::BTreeSet;
2use std::path::Path;
3
4use crate::error::SboxError;
5
6use super::model::{Config, ExecutionMode, ImageConfig, MountType};
7
8pub fn validate_config(config: &Config) -> Result<(), SboxError> {
9 let mut errors = Vec::new();
10 let mut mount_targets = BTreeSet::new();
11 let mut cache_targets = BTreeSet::new();
12 let mut secret_names = BTreeSet::new();
13
14 if config.version != 1 {
15 errors.push(format!(
16 "unsupported config version `{}`; expected `1`",
17 config.version
18 ));
19 }
20
21 match &config.runtime {
22 Some(runtime) => {
23 if runtime.rootless == Some(false)
24 && let Some(identity) = &config.identity
25 && identity.map_user == Some(true)
26 {
27 errors.push(
28 "`identity.map_user: true` conflicts with `runtime.rootless: false`; \
29 --userns keep-id is only valid in rootless Podman"
30 .to_string(),
31 );
32 }
33
34 if runtime.require_pinned_image == Some(true) {
35 let global_digest_set = config
36 .image
37 .as_ref()
38 .and_then(|i| i.digest.as_ref())
39 .is_some();
40 if !global_digest_set {
41 let all_sandbox_profiles_have_digest = config
42 .profiles
43 .values()
44 .filter(|p| matches!(p.mode, ExecutionMode::Sandbox))
45 .all(|p| p.image.as_ref().and_then(|i| i.digest.as_ref()).is_some());
46 if !all_sandbox_profiles_have_digest {
47 errors.push(
48 "`runtime.require_pinned_image: true` requires `image.digest` to be set (or all sandbox profiles to have per-profile image digests)".to_string()
49 );
50 }
51 }
52 }
53 }
54 None => errors.push("`runtime` section is required".to_string()),
55 }
56
57 match &config.workspace {
58 Some(workspace) => {
59 if workspace.mount.as_deref().is_none_or(str::is_empty) {
60 errors.push("`workspace.mount` is required".to_string());
61 } else if let Some(mount) = workspace.mount.as_deref()
62 && !is_absolute_target(mount)
63 {
64 errors.push(format!(
65 "`workspace.mount` must be an absolute path: `{mount}`"
66 ));
67 }
68
69 for path in &workspace.writable_paths {
70 if path.trim().is_empty() {
71 errors.push("`workspace.writable_paths` entries must not be empty".to_string());
72 continue;
73 }
74 let p = std::path::Path::new(path);
75 if p.is_absolute() {
76 errors.push(format!(
77 "`workspace.writable_paths` entry must be a relative path: `{path}`"
78 ));
79 } else if p.components().any(|c| c == std::path::Component::ParentDir) {
80 errors.push(format!(
81 "`workspace.writable_paths` entry must not contain `..`: `{path}`"
82 ));
83 }
84 }
85
86 for pattern in &workspace.exclude_paths {
87 if pattern.trim().is_empty() {
88 errors.push("`workspace.exclude_paths` entries must not be empty".to_string());
89 continue;
90 }
91 let effective = pattern.trim_start_matches("**/");
93 if std::path::Path::new(effective).is_absolute() {
94 errors.push(format!(
95 "`workspace.exclude_paths` entry must be a relative pattern: `{pattern}`"
96 ));
97 }
98 }
99 }
100 None => errors.push("`workspace` section is required".to_string()),
101 }
102
103 match &config.image {
104 Some(image) => validate_image(image, &mut errors),
105 None => errors.push("`image` section is required".to_string()),
106 }
107
108 if let Some(environment) = &config.environment {
109 for name in &environment.pass_through {
110 if environment.deny.iter().any(|denied| denied == name) {
111 errors.push(format!(
112 "environment variable `{name}` cannot appear in both `pass_through` and `deny`"
113 ));
114 }
115 }
116 }
117
118 if config.profiles.is_empty() {
119 errors.push("at least one profile must be defined".to_string());
120 }
121
122 let mut cache_names = BTreeSet::new();
123 for cache in &config.caches {
124 if cache.name.trim().is_empty() {
125 errors.push("cache names must not be empty".to_string());
126 }
127 if cache.target.trim().is_empty() {
128 errors.push(format!("cache `{}` must define `target`", cache.name));
129 } else {
130 if !is_absolute_target(&cache.target) {
131 errors.push(format!(
132 "cache `{}` target must be absolute: `{}`",
133 cache.name, cache.target
134 ));
135 }
136 if !cache_targets.insert(cache.target.clone()) {
137 errors.push(format!("duplicate cache target `{}`", cache.target));
138 }
139 }
140 if !cache_names.insert(cache.name.clone()) {
141 errors.push(format!("duplicate cache name `{}`", cache.name));
142 }
143 }
144
145 for (name, mount) in config.mounts.iter().enumerate() {
146 let mount_label = format!("mount #{}", name + 1);
147
148 if mount.target.as_deref().is_none_or(str::is_empty) {
149 errors.push(format!("{mount_label} must define `target`"));
150 } else if let Some(target) = mount.target.as_deref() {
151 if !is_absolute_target(target) {
152 errors.push(format!("{mount_label} target must be absolute: `{target}`"));
153 }
154 if !mount_targets.insert(target.to_string()) {
155 errors.push(format!("{mount_label} reuses mount target `{target}`"));
156 }
157 }
158
159 if matches!(mount.mount_type, MountType::Bind) && mount.source.is_none() {
160 errors.push(format!(
161 "{mount_label} with `type: bind` must define `source`"
162 ));
163 }
164
165 if matches!(mount.mount_type, MountType::Tmpfs) && mount.source.is_some() {
166 errors.push(format!(
167 "{mount_label} with `type: tmpfs` must not define `source`"
168 ));
169 }
170
171 if let Some(source) = mount.source.as_deref()
172 && let Some(message) = validate_mount_source_safety(source)
173 {
174 errors.push(format!("{mount_label} {message}"));
175 }
176 }
177
178 for secret in &config.secrets {
179 if secret.name.trim().is_empty() {
180 errors.push("secret names must not be empty".to_string());
181 }
182 if secret.source.trim().is_empty() || secret.target.trim().is_empty() {
183 errors.push(format!(
184 "secret `{}` must define non-empty `source` and `target`",
185 secret.name
186 ));
187 }
188 if !secret_names.insert(secret.name.clone()) {
189 errors.push(format!("duplicate secret name `{}`", secret.name));
190 }
191 if !secret.target.trim().is_empty() && !is_absolute_target(&secret.target) {
192 errors.push(format!(
193 "secret `{}` target must be absolute: `{}`",
194 secret.name, secret.target
195 ));
196 }
197
198 for profile in &secret.when_profiles {
199 if !config.profiles.contains_key(profile) {
200 errors.push(format!(
201 "secret `{}` references unknown profile `{profile}`",
202 secret.name
203 ));
204 }
205 }
206 }
207
208 for (name, profile) in &config.profiles {
209 if let Some(image) = &profile.image {
210 validate_image(image, &mut errors);
211 }
212
213 if matches!(profile.mode, ExecutionMode::Host) && !profile.ports.is_empty() {
214 errors.push(format!(
215 "profile `{name}` cannot expose ports in `host` mode"
216 ));
217 }
218
219 if let Some(crate::config::model::CapabilitiesSpec::Keyword(keyword)) =
220 &profile.capabilities
221 && keyword != "drop-all"
222 {
223 errors.push(format!(
224 "profile `{name}` has unknown capabilities keyword `{keyword}`; \
225 use `drop-all`, a list `[CAP_NAME, ...]`, or a structured form `{{ drop: [...], add: [...] }}`"
226 ));
227 }
228
229 for domain in &profile.network_allow {
230 if domain.trim().is_empty() {
231 errors.push(format!(
232 "profile `{name}` has an empty entry in `network_allow`"
233 ));
234 }
235 }
236
237 if !profile.network_allow.is_empty() && profile.network.as_deref() == Some("off") {
238 errors.push(format!(
239 "profile `{name}` sets `network_allow` but `network: off` — allow-listing has no effect when network is disabled"
240 ));
241 }
242
243 if profile.require_pinned_image == Some(true) {
244 let has_digest = profile
245 .image
246 .as_ref()
247 .and_then(|i| i.digest.as_ref())
248 .is_some()
249 || config
250 .image
251 .as_ref()
252 .and_then(|i| i.digest.as_ref())
253 .is_some();
254 if !has_digest {
255 errors.push(format!(
256 "profile `{name}` sets `require_pinned_image: true` but no image digest is configured (set `image.digest` globally or in the profile's image override)"
257 ));
258 }
259 }
260 }
261
262 for (name, rule) in &config.dispatch {
263 if rule.patterns.is_empty() {
264 errors.push(format!(
265 "dispatch rule `{name}` must define at least one pattern"
266 ));
267 }
268 if !config.profiles.contains_key(&rule.profile) {
269 errors.push(format!(
270 "dispatch rule `{name}` references unknown profile `{}`",
271 rule.profile
272 ));
273 }
274 }
275
276 if errors.is_empty() {
277 Ok(())
278 } else {
279 Err(SboxError::ConfigValidation {
280 message: errors.join("\n"),
281 })
282 }
283}
284
285pub fn collect_config_warnings(config: &Config) -> Vec<String> {
288 let mut warnings = Vec::new();
289
290 let check_ref = |reference: &str, warnings: &mut Vec<String>| {
292 if reference.ends_with(":latest") {
293 warnings.push(format!(
294 "image `{reference}` uses `:latest` — consider pinning to a specific version \
295 or digest for reproducibility and supply-chain safety"
296 ));
297 }
298 };
299 if let Some(image) = &config.image
300 && let Some(r) = &image.reference
301 {
302 check_ref(r, &mut warnings);
303 }
304 for (_name, profile) in &config.profiles {
305 if let Some(image) = &profile.image
306 && let Some(r) = &image.reference
307 {
308 check_ref(r, &mut warnings);
309 }
310 }
311
312 if let Some(runtime) = &config.runtime
316 && matches!(
317 runtime.backend,
318 Some(crate::config::model::BackendKind::Docker)
319 )
320 && runtime.rootless != Some(true)
321 {
322 warnings.push(
323 "backend is `docker` without `rootless: true` — files written inside the \
324 container (e.g. node_modules, .venv) will be owned by root on the host. \
325 To clean them up: \
326 `docker run --rm -v $PWD:/w <image> chown -R $(id -u):$(id -g) /w` \
327 or enable rootless Docker and set `rootless: true`."
328 .to_string(),
329 );
330 }
331
332 for (name, profile) in &config.profiles {
335 if profile.role == Some(crate::config::model::ProfileRole::Install)
336 && profile.network.as_deref() == Some("on")
337 && profile.network_allow.is_empty()
338 {
339 warnings.push(format!(
340 "install profile `{name}` uses `network: on` without `network_allow` — \
341 postinstall scripts have unrestricted internet access. \
342 Add `network_allow` to restrict outbound connections to registry hostnames only."
343 ));
344 }
345 }
346
347 let has_install_profile = config
349 .profiles
350 .values()
351 .any(|p| p.role == Some(crate::config::model::ProfileRole::Install));
352
353 if has_install_profile {
354 for secret in &config.secrets {
355 if looks_like_credential(&secret.source)
356 && secret.deny_roles.is_empty()
357 && secret.when_profiles.is_empty()
358 {
359 warnings.push(format!(
360 "secret `{}` (source: {}) is not restricted from install profiles — \
361 postinstall scripts can read it. \
362 Add `deny_roles: [install]` to block it from install-phase containers.",
363 secret.name, secret.source
364 ));
365 }
366 }
367 }
368
369 warnings
370}
371
372pub fn emit_config_warnings(config: &Config) {
374 for warning in collect_config_warnings(config) {
375 eprintln!("sbox warning: {warning}");
376 }
377}
378
379fn looks_like_credential(path: &str) -> bool {
380 const PATTERNS: &[&str] = &[
381 "npmrc",
382 "netrc",
383 "pypirc",
384 "token",
385 "secret",
386 "credential",
387 "id_rsa",
388 "id_ed25519",
389 "id_ecdsa",
390 "id_dsa",
391 ".aws/",
392 ".ssh/",
393 "auth.json",
394 ];
395 let lower = path.to_lowercase();
396 PATTERNS.iter().any(|p| lower.contains(p))
397}
398
399fn validate_image(image: &ImageConfig, errors: &mut Vec<String>) {
400 let source_count =
401 image.reference.iter().count() + image.build.iter().count() + image.preset.iter().count();
402
403 match source_count {
404 0 => errors
405 .push("`image` must define exactly one of `ref`, `build`, or `preset`".to_string()),
406 1 => {}
407 _ => errors.push(
408 "`image.ref`, `image.build`, and `image.preset` are mutually exclusive".to_string(),
409 ),
410 }
411
412 if let Some(digest) = image.digest.as_deref() {
413 if !digest.starts_with("sha256:") {
414 errors.push(format!(
415 "`image.digest` must start with `sha256:`: `{digest}`"
416 ));
417 }
418
419 if image.build.is_some() {
420 errors.push("`image.digest` cannot be used with `image.build`".to_string());
421 }
422 }
423}
424
425fn is_absolute_target(target: &str) -> bool {
426 Path::new(target).is_absolute()
427}
428
429fn validate_mount_source_safety(source: &Path) -> Option<String> {
430 let source_string = source.to_string_lossy();
431
432 if source_string == "~" || source_string.starts_with("~/") {
433 return Some(format!(
434 "must not mount home-directory paths implicitly: `{}`",
435 source.display()
436 ));
437 }
438
439 let source = if source.is_absolute() {
440 source.to_path_buf()
441 } else {
442 return None;
443 };
444
445 if is_home_root(&source) {
446 return Some(format!(
447 "must not mount full home-directory roots: `{}`",
448 source.display()
449 ));
450 }
451
452 if is_sensitive_host_path(&source) {
453 return Some(format!(
454 "must not mount sensitive host credential or socket paths: `{}`",
455 source.display()
456 ));
457 }
458
459 None
460}
461
462fn is_home_root(path: &Path) -> bool {
463 if let Some(home) = crate::platform::home_dir()
464 && path == home
465 {
466 return true;
467 }
468
469 matches!(
470 path,
471 p if p == Path::new("/home")
472 || p == Path::new("/root")
473 || p == Path::new("/Users")
474 )
475}
476
477fn is_sensitive_host_path(path: &Path) -> bool {
478 const EXACT_PATHS: &[&str] = &[
479 "/var/run/docker.sock",
480 "/run/docker.sock",
481 "/var/run/podman/podman.sock",
482 "/run/podman/podman.sock",
483 ];
484 const PREFIX_PATHS: &[&str] = &[
485 ".ssh",
486 ".aws",
487 ".kube",
488 ".config/gcloud",
489 ".gnupg",
490 ".docker",
491 ];
492 const FILE_PATHS: &[&str] = &[
493 ".git-credentials",
494 ".npmrc",
495 ".pypirc",
496 ".netrc",
497 ".docker/config.json",
498 ];
499
500 if EXACT_PATHS
501 .iter()
502 .any(|candidate| path == Path::new(candidate))
503 {
504 return true;
505 }
506
507 if let Some(home) = crate::platform::home_dir() {
508 if PREFIX_PATHS
509 .iter()
510 .map(|suffix| home.join(suffix))
511 .any(|candidate| path == candidate)
512 {
513 return true;
514 }
515 if FILE_PATHS
516 .iter()
517 .map(|suffix| home.join(suffix))
518 .any(|candidate| path == candidate)
519 {
520 return true;
521 }
522 }
523
524 false
525}
526
527
528#[cfg(test)]
529mod tests {
530 use indexmap::IndexMap;
531
532 use super::validate_config;
533 use crate::config::model::{
534 BackendKind, Config, DispatchRule, EnvironmentConfig, ExecutionMode, ImageConfig,
535 MountConfig, MountType, ProfileConfig, RuntimeConfig, WorkspaceConfig,
536 };
537 use std::collections::BTreeMap;
538 use std::path::PathBuf;
539
540 fn base_config() -> Config {
541 let mut profiles = IndexMap::new();
542 profiles.insert(
543 "default".to_string(),
544 ProfileConfig {
545 mode: ExecutionMode::Sandbox,
546 image: None,
547 network: Some("off".into()),
548 writable: Some(true),
549 require_pinned_image: None,
550 require_lockfile: None,
551 role: None,
552 lockfile_files: Vec::new(),
553 pre_run: Vec::new(),
554 network_allow: Vec::new(),
555 ports: Vec::new(),
556 capabilities: None,
557 no_new_privileges: Some(true),
558 read_only_rootfs: None,
559 reuse_container: None,
560 shell: None,
561
562 writable_paths: None,
563 },
564 );
565
566 Config {
567 version: 1,
568 runtime: Some(RuntimeConfig {
569 backend: Some(BackendKind::Podman),
570 rootless: Some(true),
571 reuse_container: Some(false),
572 container_name: None,
573 pull_policy: None,
574 strict_security: None,
575 require_pinned_image: None,
576 }),
577 workspace: Some(WorkspaceConfig {
578 root: Some(PathBuf::from(".")),
579 mount: Some("/workspace".into()),
580 writable: Some(true),
581 writable_paths: Vec::new(),
582 exclude_paths: Vec::new(),
583 }),
584 identity: None,
585 image: Some(ImageConfig {
586 reference: Some("python:3.13-slim".into()),
587 build: None,
588 preset: None,
589 digest: None,
590 verify_signature: None,
591 pull_policy: None,
592 tag: None,
593 }),
594 environment: Some(EnvironmentConfig {
595 pass_through: Vec::new(),
596 set: BTreeMap::new(),
597 deny: Vec::new(),
598 }),
599 mounts: Vec::new(),
600 caches: Vec::new(),
601 secrets: Vec::new(),
602 profiles,
603 dispatch: IndexMap::<String, DispatchRule>::new(),
604 package_manager: None,
605 }
606 }
607
608 #[test]
609 fn rejects_overlapping_pass_through_and_deny_variables() {
610 let mut config = base_config();
611 config.environment = Some(EnvironmentConfig {
612 pass_through: vec!["SSH_AUTH_SOCK".into()],
613 set: BTreeMap::new(),
614 deny: vec!["SSH_AUTH_SOCK".into()],
615 });
616
617 let error = validate_config(&config).expect_err("validation should fail");
618 assert!(
619 error
620 .to_string()
621 .contains("cannot appear in both `pass_through` and `deny`")
622 );
623 }
624
625 #[test]
626 fn rejects_dangerous_docker_socket_mounts() {
627 let mut config = base_config();
628 config.mounts.push(MountConfig {
629 source: Some(PathBuf::from("/var/run/docker.sock")),
630 target: Some("/run/docker.sock".into()),
631 mount_type: MountType::Bind,
632 read_only: Some(true),
633 create: None,
634 });
635
636 let error = validate_config(&config).expect_err("validation should fail");
637 assert!(
638 error
639 .to_string()
640 .contains("must not mount sensitive host credential or socket paths")
641 );
642 }
643
644 #[test]
645 fn rejects_docker_config_dir_mount() {
646 let home = std::env::var("HOME").expect("HOME must be set");
647 let mut config = base_config();
648 config.mounts.push(MountConfig {
649 source: Some(PathBuf::from(format!("{home}/.docker"))),
650 target: Some("/run/docker-config".into()),
651 mount_type: MountType::Bind,
652 read_only: Some(true),
653 create: None,
654 });
655
656 let error = validate_config(&config).expect_err("validation should fail");
657 assert!(
658 error
659 .to_string()
660 .contains("must not mount sensitive host credential or socket paths")
661 );
662 }
663
664 #[test]
665 fn rejects_docker_config_json_mount() {
666 let home = std::env::var("HOME").expect("HOME must be set");
667 let mut config = base_config();
668 config.mounts.push(MountConfig {
669 source: Some(PathBuf::from(format!("{home}/.docker/config.json"))),
670 target: Some("/run/docker-config.json".into()),
671 mount_type: MountType::Bind,
672 read_only: Some(true),
673 create: None,
674 });
675
676 let error = validate_config(&config).expect_err("validation should fail");
677 assert!(
678 error
679 .to_string()
680 .contains("must not mount sensitive host credential or socket paths")
681 );
682 }
683}