Skip to main content

sbox/config/
validate.rs

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                // Strip the leading **/ glob prefix before checking for absolute paths
92                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
285/// Collect non-fatal warnings for the config.
286/// Returns a Vec so callers can test the warnings without capturing stderr.
287pub fn collect_config_warnings(config: &Config) -> Vec<String> {
288    let mut warnings = Vec::new();
289
290    // Warn on :latest image references.
291    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    // Warn when Docker is used without rootless mode — files written inside the container
313    // (node_modules, .venv, target/, etc.) will be owned by root on the host, requiring
314    // sudo or a privileged docker run to delete them.
315    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    // Warn when an install profile uses network: on without network_allow.
333    // This gives postinstall scripts unrestricted internet access.
334    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    // Warn when credential-looking secrets are not restricted from install profiles.
348    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
372/// Print collected warnings to stderr. Called from load_config after validate_config succeeds.
373pub 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}