Skip to main content

sbox/
doctor.rs

1use std::path::Path;
2use std::process::{Command, ExitCode, Stdio};
3
4use crate::cli::{Cli, CliBackendKind, DoctorCommand};
5use crate::config::{LoadOptions, load_config};
6use crate::resolve::{ResolutionTarget, ResolvedImageSource, resolve_execution_plan};
7
8pub fn execute(cli: &Cli, command: &DoctorCommand) -> Result<ExitCode, crate::error::SboxError> {
9    let mut checks = Vec::new();
10
11    let loaded = match load_config(&LoadOptions {
12        workspace: cli.workspace.clone(),
13        config: cli.config.clone(),
14    }) {
15        Ok(loaded) => {
16            checks.push(CheckResult::pass(
17                "config",
18                format!("loaded {}", loaded.config_path.display()),
19            ));
20            Some(loaded)
21        }
22        Err(error) => {
23            checks.push(CheckResult::fail("config", error.to_string()));
24            None
25        }
26    };
27
28    let backend = resolve_backend(cli, loaded.as_ref());
29    if let Some(loaded) = loaded.as_ref() {
30        checks.extend(risky_config_warnings(&loaded.config));
31        checks.extend(workspace_state_warnings(loaded));
32        checks.extend(credential_exposure_warnings(loaded));
33    }
34    match backend {
35        Backend::Podman => run_podman_checks(cli, loaded.as_ref(), &mut checks),
36        Backend::Docker => run_docker_checks(loaded.as_ref(), &mut checks),
37    }
38    checks.extend(shim_health_checks());
39
40    print_report(&checks);
41    Ok(determine_exit_code(&checks, command.strict))
42}
43
44#[derive(Debug, Clone, Copy)]
45enum Backend {
46    Podman,
47    Docker,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51enum CheckLevel {
52    Pass,
53    Warn,
54    Fail,
55}
56
57#[derive(Debug, Clone)]
58struct CheckResult {
59    name: &'static str,
60    level: CheckLevel,
61    detail: String,
62}
63
64impl CheckResult {
65    fn pass(name: &'static str, detail: String) -> Self {
66        Self {
67            name,
68            level: CheckLevel::Pass,
69            detail,
70        }
71    }
72
73    fn warn(name: &'static str, detail: String) -> Self {
74        Self {
75            name,
76            level: CheckLevel::Warn,
77            detail,
78        }
79    }
80
81    fn fail(name: &'static str, detail: String) -> Self {
82        Self {
83            name,
84            level: CheckLevel::Fail,
85            detail,
86        }
87    }
88}
89
90fn resolve_backend(cli: &Cli, loaded: Option<&crate::config::LoadedConfig>) -> Backend {
91    match cli.backend {
92        Some(CliBackendKind::Docker) => Backend::Docker,
93        Some(CliBackendKind::Podman) => Backend::Podman,
94        None => match loaded
95            .and_then(|loaded| loaded.config.runtime.as_ref())
96            .and_then(|runtime| runtime.backend.as_ref())
97        {
98            Some(crate::config::BackendKind::Docker) => Backend::Docker,
99            Some(crate::config::BackendKind::Podman) => Backend::Podman,
100            None => {
101                // Auto-detect: prefer podman, fall back to docker.
102                if crate::resolve::which_on_path("podman") {
103                    Backend::Podman
104                } else {
105                    Backend::Docker
106                }
107            }
108        },
109    }
110}
111
112fn run_podman_checks(
113    cli: &Cli,
114    loaded: Option<&crate::config::LoadedConfig>,
115    checks: &mut Vec<CheckResult>,
116) {
117    let installed = run_capture(Command::new("podman").arg("--version"));
118    if let Err(detail) = installed {
119        checks.push(CheckResult::fail("backend", detail));
120        return;
121    }
122    checks.push(CheckResult::pass(
123        "backend",
124        "podman is installed".to_string(),
125    ));
126
127    let podman_rootless = run_capture(Command::new("podman").args([
128        "info",
129        "--format",
130        "{{.Host.Security.Rootless}}",
131    ]));
132
133    let configured_rootless = loaded
134        .and_then(|l| l.config.runtime.as_ref())
135        .and_then(|rt| rt.rootless);
136
137    match &podman_rootless {
138        Ok(output) if output.trim() == "true" => {
139            checks.push(CheckResult::pass(
140                "rootless",
141                "rootless mode is active".to_string(),
142            ));
143            if configured_rootless == Some(false) {
144                checks.push(CheckResult::warn(
145                    "rootless-config",
146                    "config sets `runtime.rootless: false` but Podman is running in rootless mode"
147                        .to_string(),
148                ));
149            }
150        }
151        Ok(output) => {
152            checks.push(CheckResult::fail(
153                "rootless",
154                format!("podman reported rootless={}", output.trim()),
155            ));
156            if configured_rootless != Some(false) {
157                checks.push(CheckResult::warn(
158                    "rootless-config",
159                    "Podman is not running in rootless mode; set `runtime.rootless: false` in sbox.yaml to suppress --userns keep-id".to_string(),
160                ));
161            }
162        }
163        Err(detail) => checks.push(CheckResult::fail("rootless", detail.clone())),
164    }
165
166    if let Some(loaded) = loaded {
167        checks.push(signature_verification_check(&loaded.config));
168        match mount_check_request(cli, loaded) {
169            Ok(request) => match run_status(podman_mount_probe(&request)) {
170                Ok(()) => checks.push(CheckResult::pass(
171                    "workspace-mount",
172                    format!(
173                        "workspace mounted at {} with cwd {}",
174                        request.workspace_mount, request.sandbox_cwd
175                    ),
176                )),
177                Err(detail) => checks.push(CheckResult::fail("workspace-mount", detail)),
178            },
179            Err(detail) => checks.push(CheckResult::warn("workspace-mount", detail)),
180        }
181    }
182}
183
184fn run_docker_checks(loaded: Option<&crate::config::LoadedConfig>, checks: &mut Vec<CheckResult>) {
185    match run_capture(Command::new("docker").arg("--version")) {
186        Err(detail) => {
187            checks.push(CheckResult::fail("backend", detail));
188            return;
189        }
190        Ok(version) => checks.push(CheckResult::pass(
191            "backend",
192            format!("docker is installed: {}", version.trim()),
193        )),
194    }
195
196    match run_capture(Command::new("docker").args(["info", "--format", "{{.ServerVersion}}"])) {
197        Ok(version) => checks.push(CheckResult::pass(
198            "daemon",
199            format!("docker daemon is running (server {})", version.trim()),
200        )),
201        Err(detail) => {
202            checks.push(CheckResult::fail(
203                "daemon",
204                format!("docker daemon is not reachable: {detail}"),
205            ));
206            return;
207        }
208    }
209
210    // Check for rootless mode via SecurityOptions
211    match run_capture(Command::new("docker").args(["info", "--format", "{{.SecurityOptions}}"])) {
212        Ok(options) if options.contains("rootless") => {
213            checks.push(CheckResult::pass(
214                "rootless",
215                "rootless mode is active".to_string(),
216            ));
217        }
218        Ok(_) => {
219            checks.push(CheckResult::warn(
220                "rootless",
221                "Docker is not running in rootless mode — container processes have true root \
222                 inside the container (stronger isolation requires rootless mode).\n  \
223                 Note: sbox automatically injects --user UID:GID so bind-mounted files are \
224                 owned by you, not root.\n  \
225                 Fix: install rootless Docker (https://docs.docker.com/engine/security/rootless/) \
226                 then set `rootless: true` under `runtime:` in sbox.yaml to suppress this warning."
227                    .to_string(),
228            ));
229        }
230        Err(detail) => checks.push(CheckResult::warn(
231            "rootless",
232            format!("could not check rootless status: {detail}"),
233        )),
234    }
235
236    if let Some(loaded) = loaded {
237        checks.extend(root_command_dispatch_warnings(&loaded.config));
238    }
239}
240
241fn root_command_dispatch_warnings(
242    config: &crate::config::model::Config,
243) -> Vec<CheckResult> {
244    // Commands that typically require running as root inside the container.
245    const ROOT_COMMANDS: &[&str] = &[
246        "apt-get", "apt ", "apk ", "yum ", "dnf ", "pacman ", "zypper ",
247    ];
248
249    // If identity explicitly sets uid:0, the user already opted in — no warning needed.
250    let explicit_root = config
251        .identity
252        .as_ref()
253        .and_then(|id| id.uid)
254        .is_some_and(|uid| uid == 0);
255    if explicit_root {
256        return vec![];
257    }
258
259    let matching_patterns: Vec<String> = config
260        .dispatch
261        .values()
262        .flat_map(|rule| rule.patterns.iter())
263        .filter(|pattern| {
264            ROOT_COMMANDS
265                .iter()
266                .any(|cmd| pattern.starts_with(cmd) || pattern.contains(cmd))
267        })
268        .cloned()
269        .collect();
270
271    if matching_patterns.is_empty() {
272        return vec![];
273    }
274
275    vec![CheckResult::warn(
276        "root-commands",
277        format!(
278            "dispatch patterns route commands that may require root inside the container \
279             ({}). sbox injects `--user UID:GID` for Docker — if the container must run \
280             as root, add `identity: {{ uid: 0, gid: 0 }}` to sbox.yaml.",
281            matching_patterns.join(", ")
282        ),
283    )]
284}
285
286fn shim_health_checks() -> Vec<CheckResult> {
287    // Resolve the default shim dir the same way `sbox shim` does.
288    let shim_dir = match crate::platform::home_dir() {
289        Some(home) => home.join(".local").join("bin"),
290        None => return vec![CheckResult::warn(
291            "shims",
292            "cannot determine home directory — skipping shim check".to_string(),
293        )],
294    };
295
296    if !shim_dir.exists() {
297        return vec![CheckResult::warn(
298            "shims",
299            format!(
300                "shim directory {} does not exist — run `sbox shim` to create shims",
301                shim_dir.display()
302            ),
303        )];
304    }
305
306    let (ok, problems) = crate::shim::verify_shims(&shim_dir);
307    let total = ok + problems;
308
309    if problems == 0 {
310        vec![CheckResult::pass(
311            "shims",
312            format!("{ok}/{total} shims active and correctly ordered in PATH"),
313        )]
314    } else {
315        vec![CheckResult::warn(
316            "shims",
317            format!(
318                "{problems}/{total} shim(s) missing or shadowed — run `sbox shim --verify` for details",
319            ),
320        )]
321    }
322}
323
324fn signature_verification_check(config: &crate::config::model::Config) -> CheckResult {
325    let requested = config
326        .image
327        .as_ref()
328        .and_then(|image| image.verify_signature)
329        .unwrap_or(false);
330
331    match crate::backend::podman::inspect_signature_verification_support() {
332        Ok(crate::backend::podman::SignatureVerificationSupport::Available { policy }) => {
333            if requested {
334                CheckResult::pass(
335                    "signature-verify",
336                    format!("requested and supported via {}", policy.display()),
337                )
338            } else {
339                CheckResult::pass(
340                    "signature-verify",
341                    format!(
342                        "available via {} (not requested by config)",
343                        policy.display()
344                    ),
345                )
346            }
347        }
348        Ok(crate::backend::podman::SignatureVerificationSupport::Unavailable {
349            policy,
350            reason,
351        }) => {
352            let detail = match policy {
353                Some(policy) => format!("{reason} ({})", policy.display()),
354                None => reason,
355            };
356            if requested {
357                CheckResult::fail("signature-verify", detail)
358            } else {
359                CheckResult::warn(
360                    "signature-verify",
361                    format!("not currently usable: {detail}"),
362                )
363            }
364        }
365        Err(error) => {
366            if requested {
367                CheckResult::fail("signature-verify", error.to_string())
368            } else {
369                CheckResult::warn("signature-verify", error.to_string())
370            }
371        }
372    }
373}
374
375struct MountCheckRequest {
376    image: String,
377    workspace_root: String,
378    workspace_mount: String,
379    sandbox_cwd: String,
380    userns_keep_id: bool,
381}
382
383fn mount_check_request(
384    cli: &Cli,
385    loaded: &crate::config::LoadedConfig,
386) -> Result<MountCheckRequest, String> {
387    let plan = resolve_execution_plan(
388        cli,
389        loaded,
390        ResolutionTarget::Plan,
391        &["__doctor__".to_string()],
392    )
393    .map_err(|error| format!("unable to resolve workspace mount test: {error}"))?;
394
395    let image = match &plan.image.source {
396        ResolvedImageSource::Reference(reference) => reference.clone(),
397        ResolvedImageSource::Build { tag, .. } => tag.clone(),
398    };
399
400    Ok(MountCheckRequest {
401        image,
402        workspace_root: plan.workspace.root.display().to_string(),
403        workspace_mount: plan.workspace.mount,
404        sandbox_cwd: plan.workspace.sandbox_cwd,
405        userns_keep_id: matches!(plan.user, crate::resolve::ResolvedUser::KeepId),
406    })
407}
408
409fn podman_mount_probe(request: &MountCheckRequest) -> Command {
410    let mut command = Command::new("podman");
411    command.arg("run");
412    command.arg("--rm");
413    if request.userns_keep_id {
414        command.args(["--userns", "keep-id"]);
415    }
416    command.args([
417        "--mount",
418        &format!(
419            "type=bind,src={},target={},relabel=private,readonly=false",
420            request.workspace_root, request.workspace_mount
421        ),
422        "--workdir",
423        &request.sandbox_cwd,
424        "--entrypoint",
425        "/bin/sh",
426        &request.image,
427        "-lc",
428        "pwd >/dev/null && test -r .",
429    ]);
430    command.stdin(Stdio::null());
431    command.stdout(Stdio::null());
432    command.stderr(Stdio::piped());
433    command
434}
435
436fn print_report(checks: &[CheckResult]) {
437    println!("sbox doctor");
438    for check in checks {
439        println!(
440            "{} {:<16} {}",
441            level_label(check.level),
442            check.name,
443            check.detail
444        );
445    }
446}
447
448fn risky_config_warnings(config: &crate::config::model::Config) -> Vec<CheckResult> {
449    let mut checks = Vec::new();
450    let sensitive_envs: Vec<String> = config
451        .environment
452        .as_ref()
453        .map(|environment| {
454            environment
455                .pass_through
456                .iter()
457                .filter(|name| looks_like_sensitive_env(name))
458                .cloned()
459                .collect()
460        })
461        .unwrap_or_default();
462
463    if !sensitive_envs.is_empty() {
464        checks.push(CheckResult::warn(
465            "env-policy",
466            format!(
467                "sensitive host variables are passed through: {}",
468                sensitive_envs.join(", ")
469            ),
470        ));
471    }
472
473    if !sensitive_envs.is_empty() {
474        let risky_profiles: Vec<String> = config
475            .profiles
476            .iter()
477            .filter(|(_, profile)| {
478                matches!(profile.mode, crate::config::model::ExecutionMode::Sandbox)
479                    && profile.network.as_deref().unwrap_or("off") != "off"
480            })
481            .map(|(name, _)| name.clone())
482            .collect();
483
484        if !risky_profiles.is_empty() {
485            checks.push(CheckResult::warn(
486                "install-policy",
487                format!(
488                    "network-enabled sandbox profiles can see sensitive pass-through vars: {}",
489                    risky_profiles.join(", ")
490                ),
491            ));
492        }
493    }
494
495    let risky_mounts: Vec<String> = config
496        .mounts
497        .iter()
498        .filter_map(|mount| mount.source.as_deref())
499        .filter(|source| looks_like_sensitive_mount(source))
500        .map(|source| source.display().to_string())
501        .collect();
502
503    if !risky_mounts.is_empty() {
504        checks.push(CheckResult::warn(
505            "mount-policy",
506            format!(
507                "sensitive host paths are mounted explicitly: {}",
508                risky_mounts.join(", ")
509            ),
510        ));
511    }
512
513    checks
514}
515
516fn workspace_state_warnings(loaded: &crate::config::LoadedConfig) -> Vec<CheckResult> {
517    let mut checks = Vec::new();
518    let risky_artifacts = scan_workspace_artifacts(&loaded.workspace_root);
519
520    if !risky_artifacts.is_empty() {
521        checks.push(CheckResult::warn(
522            "workspace-state",
523            format!(
524                "host dependency artifacts exist in the workspace: {}",
525                risky_artifacts.join(", ")
526            ),
527        ));
528    }
529
530    checks
531}
532
533fn scan_workspace_artifacts(workspace_root: &Path) -> Vec<String> {
534    const RISKY_NAMES: &[&str] = &[
535        ".venv",
536        "node_modules",
537        ".npmrc",
538        ".yarnrc",
539        ".yarnrc.yml",
540        ".pnpm-store",
541    ];
542    const MAX_DEPTH: usize = 3;
543
544    let mut findings = Vec::new();
545    let mut stack = vec![(workspace_root.to_path_buf(), 0usize)];
546
547    while let Some((dir, depth)) = stack.pop() {
548        let entries = match std::fs::read_dir(&dir) {
549            Ok(entries) => entries,
550            Err(_) => continue,
551        };
552
553        for entry in entries.flatten() {
554            let path = entry.path();
555            let name = entry.file_name();
556            let name = name.to_string_lossy();
557
558            if RISKY_NAMES.iter().any(|candidate| *candidate == name)
559                && let Ok(relative) = path.strip_prefix(workspace_root)
560            {
561                findings.push(relative.display().to_string());
562            }
563
564            if depth < MAX_DEPTH
565                && entry
566                    .file_type()
567                    .map(|file_type| file_type.is_dir())
568                    .unwrap_or(false)
569                && !name.starts_with('.')
570            {
571                stack.push((path, depth + 1));
572            }
573        }
574    }
575
576    findings.sort();
577    findings
578}
579
580/// Credential file patterns that are worth warning about if found unmasked in the workspace.
581const CREDENTIAL_PATTERNS: &[&str] = &[
582    ".env",
583    ".env.local",
584    ".env.production",
585    ".env.development",
586    ".env.test",
587    ".env.staging",
588    "*.pem",
589    "*.key",
590    "*.p12",
591    "*.pfx",
592    ".npmrc",
593    ".netrc",
594    ".pypirc",
595    "secrets.yaml",
596    "secrets.yml",
597    "secrets.json",
598    "credentials.json",
599    "credentials.yaml",
600    "credentials.yml",
601];
602
603fn credential_exposure_warnings(loaded: &crate::config::LoadedConfig) -> Vec<CheckResult> {
604    let exclude_paths = loaded
605        .config
606        .workspace
607        .as_ref()
608        .map(|ws| ws.exclude_paths.as_slice())
609        .unwrap_or(&[]);
610
611    let mut unmasked: Vec<String> = Vec::new();
612
613    for pattern in CREDENTIAL_PATTERNS {
614        let mut found = Vec::new();
615        collect_credential_files(
616            &loaded.workspace_root,
617            &loaded.workspace_root,
618            pattern,
619            &mut found,
620        );
621        for host_path in found {
622            if let Ok(rel) = host_path.strip_prefix(&loaded.workspace_root) {
623                let rel_str = rel.to_string_lossy();
624                let is_covered = exclude_paths
625                    .iter()
626                    .any(|ep| crate::resolve::exclude_pattern_matches(&rel_str, ep));
627                if !is_covered {
628                    unmasked.push(rel_str.to_string());
629                }
630            }
631        }
632    }
633
634    unmasked.sort();
635    unmasked.dedup();
636
637    if unmasked.is_empty() {
638        return vec![];
639    }
640
641    vec![CheckResult::warn(
642        "credential-exposure",
643        format!(
644            "credential files found in workspace not covered by exclude_paths: {}",
645            unmasked.join(", ")
646        ),
647    )]
648}
649
650/// Recursive file walk for credential pattern matching — mirrors the resolve.rs implementation
651/// but is standalone so doctor does not depend on resolve internals beyond the pub(crate) helpers.
652fn collect_credential_files(
653    workspace_root: &std::path::Path,
654    dir: &std::path::Path,
655    pattern: &str,
656    out: &mut Vec<std::path::PathBuf>,
657) {
658    let Ok(entries) = std::fs::read_dir(dir) else {
659        return;
660    };
661    for entry in entries.flatten() {
662        let file_type = match entry.file_type() {
663            Ok(ft) => ft,
664            Err(_) => continue,
665        };
666        if file_type.is_symlink() {
667            continue;
668        }
669        let path = entry.path();
670        if file_type.is_dir() {
671            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
672            if matches!(name, ".git" | "node_modules" | "target" | ".venv") {
673                continue;
674            }
675            collect_credential_files(workspace_root, &path, pattern, out);
676        } else if file_type.is_file()
677            && let Ok(rel) = path.strip_prefix(workspace_root)
678        {
679            let rel_str = rel.to_string_lossy();
680            if crate::resolve::exclude_pattern_matches(&rel_str, pattern) {
681                out.push(path);
682            }
683        }
684    }
685}
686
687fn looks_like_sensitive_env(name: &str) -> bool {
688    const EXACT: &[&str] = &[
689        "SSH_AUTH_SOCK",
690        "GITHUB_TOKEN",
691        "GH_TOKEN",
692        "NPM_TOKEN",
693        "NODE_AUTH_TOKEN",
694        "PYPI_TOKEN",
695        "DOCKER_CONFIG",
696        "KUBECONFIG",
697        "GOOGLE_APPLICATION_CREDENTIALS",
698        "AZURE_CLIENT_SECRET",
699        "AWS_SESSION_TOKEN",
700        "AWS_SECRET_ACCESS_KEY",
701        "AWS_ACCESS_KEY_ID",
702    ];
703    const PREFIXES: &[&str] = &["AWS_", "GCP_", "GOOGLE_", "AZURE_", "CI_JOB_", "CLOUDSDK_"];
704
705    EXACT.contains(&name) || PREFIXES.iter().any(|prefix| name.starts_with(prefix))
706}
707
708fn looks_like_sensitive_mount(source: &Path) -> bool {
709    let source_string = source.to_string_lossy();
710    if source_string == "~" || source_string.starts_with("~/") {
711        return true;
712    }
713
714    if !source.is_absolute() {
715        return false;
716    }
717
718    const EXACT_PATHS: &[&str] = &[
719        "/var/run/docker.sock",
720        "/run/docker.sock",
721        "/var/run/podman/podman.sock",
722        "/run/podman/podman.sock",
723        "/home",
724        "/root",
725        "/Users",
726    ];
727    if EXACT_PATHS
728        .iter()
729        .any(|candidate| source == Path::new(candidate))
730    {
731        return true;
732    }
733
734    if let Some(home) = crate::platform::home_dir() {
735        if source == home {
736            return true;
737        }
738
739        for suffix in [
740            ".ssh",
741            ".aws",
742            ".kube",
743            ".config/gcloud",
744            ".gnupg",
745            ".git-credentials",
746            ".npmrc",
747            ".pypirc",
748            ".netrc",
749        ] {
750            if source == home.join(suffix) {
751                return true;
752            }
753        }
754    }
755
756    false
757}
758
759
760fn level_label(level: CheckLevel) -> &'static str {
761    match level {
762        CheckLevel::Pass => "PASS",
763        CheckLevel::Warn => "WARN",
764        CheckLevel::Fail => "FAIL",
765    }
766}
767
768fn determine_exit_code(checks: &[CheckResult], strict: bool) -> ExitCode {
769    if checks.iter().any(|check| check.level == CheckLevel::Fail) {
770        return ExitCode::from(10);
771    }
772
773    if strict && checks.iter().any(|check| check.level == CheckLevel::Warn) {
774        return ExitCode::from(1);
775    }
776
777    ExitCode::SUCCESS
778}
779
780fn run_capture(command: &mut Command) -> Result<String, String> {
781    let output = command.output().map_err(|source| source.to_string())?;
782    if output.status.success() {
783        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
784    } else {
785        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
786        if stderr.is_empty() {
787            Err(format!(
788                "command exited with status {}",
789                output.status.code().unwrap_or(1)
790            ))
791        } else {
792            Err(stderr)
793        }
794    }
795}
796
797fn run_status(mut command: Command) -> Result<(), String> {
798    let output = command.output().map_err(|source| source.to_string())?;
799    if output.status.success() {
800        Ok(())
801    } else {
802        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
803        if stderr.is_empty() {
804            Err(format!(
805                "command exited with status {}",
806                output.status.code().unwrap_or(1)
807            ))
808        } else {
809            Err(stderr)
810        }
811    }
812}
813
814#[cfg(test)]
815mod tests {
816    use super::{
817        CheckLevel, CheckResult, credential_exposure_warnings, determine_exit_code,
818        risky_config_warnings, root_command_dispatch_warnings, scan_workspace_artifacts,
819        workspace_state_warnings,
820    };
821    use crate::config::LoadedConfig;
822    use crate::config::model::{
823        Config, DispatchRule, EnvironmentConfig, MountConfig, MountType, WorkspaceConfig,
824    };
825    use std::collections::BTreeMap;
826    use std::path::PathBuf;
827    use std::process::ExitCode;
828
829    #[test]
830    fn doctor_returns_success_when_all_checks_pass() {
831        let checks = vec![CheckResult::pass("config", "ok".into())];
832        assert_eq!(determine_exit_code(&checks, false), ExitCode::SUCCESS);
833    }
834
835    #[test]
836    fn doctor_returns_strict_warning_exit_code() {
837        let checks = vec![CheckResult {
838            name: "backend",
839            level: CheckLevel::Warn,
840            detail: "warn".into(),
841        }];
842        assert_eq!(determine_exit_code(&checks, true), ExitCode::from(1));
843    }
844
845    #[test]
846    fn doctor_returns_failure_exit_code_when_any_check_fails() {
847        let checks = vec![CheckResult::fail("backend", "missing".into())];
848        assert_eq!(determine_exit_code(&checks, false), ExitCode::from(10));
849    }
850
851    #[test]
852    fn doctor_warns_on_sensitive_pass_through_envs() {
853        let config = Config {
854            version: 1,
855            runtime: None,
856            workspace: None,
857            identity: None,
858            image: None,
859            environment: Some(EnvironmentConfig {
860                pass_through: vec!["AWS_SECRET_ACCESS_KEY".into()],
861                set: BTreeMap::new(),
862                deny: Vec::new(),
863            }),
864            mounts: Vec::new(),
865            caches: Vec::new(),
866            secrets: Vec::new(),
867            profiles: Default::default(),
868            dispatch: Default::default(),
869
870            package_manager: None,
871        };
872
873        let warnings = risky_config_warnings(&config);
874        assert!(
875            warnings
876                .iter()
877                .any(|warning| warning.name == "env-policy" && warning.level == CheckLevel::Warn)
878        );
879    }
880
881    #[test]
882    fn doctor_warns_on_sensitive_mounts() {
883        let config = Config {
884            version: 1,
885            runtime: None,
886            workspace: None,
887            identity: None,
888            image: None,
889            environment: None,
890            mounts: vec![MountConfig {
891                source: Some(PathBuf::from("/var/run/docker.sock")),
892                target: Some("/run/docker.sock".into()),
893                mount_type: MountType::Bind,
894                read_only: Some(true),
895                create: None,
896            }],
897            caches: Vec::new(),
898            secrets: Vec::new(),
899            profiles: Default::default(),
900            dispatch: Default::default(),
901
902            package_manager: None,
903        };
904
905        let warnings = risky_config_warnings(&config);
906        assert!(
907            warnings
908                .iter()
909                .any(|warning| warning.name == "mount-policy" && warning.level == CheckLevel::Warn)
910        );
911    }
912
913    #[test]
914    fn doctor_warns_when_sensitive_envs_meet_network_enabled_profiles() {
915        let mut profiles = indexmap::IndexMap::new();
916        profiles.insert(
917            "install".to_string(),
918            crate::config::model::ProfileConfig {
919                mode: crate::config::model::ExecutionMode::Sandbox,
920                image: None,
921                network: Some("on".into()),
922                writable: Some(true),
923                require_pinned_image: None,
924                require_lockfile: None,
925                role: None,
926                lockfile_files: Vec::new(),
927                pre_run: Vec::new(),
928                network_allow: Vec::new(),
929                ports: Vec::new(),
930                capabilities: None,
931                no_new_privileges: Some(true),
932                read_only_rootfs: None,
933                reuse_container: None,
934                shell: None,
935
936                writable_paths: None,
937            },
938        );
939        let config = Config {
940            version: 1,
941            runtime: None,
942            workspace: None,
943            identity: None,
944            image: None,
945            environment: Some(EnvironmentConfig {
946                pass_through: vec!["NPM_TOKEN".into()],
947                set: BTreeMap::new(),
948                deny: Vec::new(),
949            }),
950            mounts: Vec::new(),
951            caches: Vec::new(),
952            secrets: Vec::new(),
953            profiles,
954            dispatch: Default::default(),
955
956            package_manager: None,
957        };
958
959        let warnings = risky_config_warnings(&config);
960        assert!(
961            warnings
962                .iter()
963                .any(|warning| warning.name == "install-policy")
964        );
965    }
966
967    #[test]
968    fn doctor_warns_on_workspace_dependency_artifacts() {
969        let unique = format!(
970            "sbox-doctor-workspace-{}",
971            std::time::SystemTime::now()
972                .duration_since(std::time::UNIX_EPOCH)
973                .expect("time should move forward")
974                .as_nanos()
975        );
976        let root = std::env::temp_dir().join(unique);
977        std::fs::create_dir_all(root.join(".venv")).expect("fixture workspace should exist");
978        std::fs::create_dir_all(root.join("examples/demo/node_modules"))
979            .expect("nested dependency artifact should exist");
980
981        let loaded = LoadedConfig {
982            invocation_dir: root.clone(),
983            workspace_root: root.clone(),
984            config_path: root.join("sbox.yaml"),
985            config: Config {
986                version: 1,
987                runtime: None,
988                workspace: None,
989                identity: None,
990                image: None,
991                environment: None,
992                mounts: Vec::new(),
993                caches: Vec::new(),
994                secrets: Vec::new(),
995                profiles: Default::default(),
996                dispatch: Default::default(),
997
998                package_manager: None,
999            },
1000        };
1001
1002        let warnings = workspace_state_warnings(&loaded);
1003        assert!(
1004            warnings
1005                .iter()
1006                .any(|warning| warning.name == "workspace-state")
1007        );
1008        assert!(warnings[0].detail.contains(".venv"));
1009        assert!(warnings[0].detail.contains("examples/demo/node_modules"));
1010
1011        std::fs::remove_dir_all(root).expect("fixture workspace should be removed");
1012    }
1013
1014    #[test]
1015    fn workspace_scan_finds_nested_dependency_artifacts() {
1016        let unique = format!(
1017            "sbox-doctor-scan-{}",
1018            std::time::SystemTime::now()
1019                .duration_since(std::time::UNIX_EPOCH)
1020                .expect("time should move forward")
1021                .as_nanos()
1022        );
1023        let root = std::env::temp_dir().join(unique);
1024        std::fs::create_dir_all(root.join("examples/npm-smoke/node_modules"))
1025            .expect("nested node_modules should exist");
1026
1027        let findings = scan_workspace_artifacts(&root);
1028        assert!(
1029            findings
1030                .iter()
1031                .any(|path| path == "examples/npm-smoke/node_modules")
1032        );
1033
1034        std::fs::remove_dir_all(root).expect("fixture workspace should be removed");
1035    }
1036
1037    #[test]
1038    fn doctor_warning_exit_code_stays_nonfatal_when_signature_verification_is_not_requested() {
1039        let checks = vec![CheckResult::warn(
1040            "signature-verify",
1041            "not currently usable: skopeo is not installed".into(),
1042        )];
1043        assert_eq!(determine_exit_code(&checks, false), ExitCode::SUCCESS);
1044    }
1045
1046    fn make_loaded_with_workspace(root: PathBuf, exclude_paths: Vec<String>) -> LoadedConfig {
1047        LoadedConfig {
1048            invocation_dir: root.clone(),
1049            workspace_root: root.clone(),
1050            config_path: root.join("sbox.yaml"),
1051            config: Config {
1052                version: 1,
1053                runtime: None,
1054                workspace: Some(WorkspaceConfig {
1055                    root: Some(root.clone()),
1056                    mount: Some("/workspace".to_string()),
1057                    writable: Some(true),
1058                    writable_paths: Vec::new(),
1059                    exclude_paths,
1060                }),
1061                identity: None,
1062                image: None,
1063                environment: None,
1064                mounts: Vec::new(),
1065                caches: Vec::new(),
1066                secrets: Vec::new(),
1067                profiles: Default::default(),
1068                dispatch: Default::default(),
1069
1070                package_manager: None,
1071            },
1072        }
1073    }
1074
1075    #[test]
1076    fn doctor_warns_when_env_file_not_covered_by_exclude_paths() {
1077        let root = tempfile::tempdir().unwrap();
1078        std::fs::write(root.path().join(".env"), "SECRET=hunter2").unwrap();
1079
1080        let loaded = make_loaded_with_workspace(root.path().to_path_buf(), vec![]);
1081        let warnings = credential_exposure_warnings(&loaded);
1082
1083        assert!(
1084            warnings.iter().any(|w| w.name == "credential-exposure"),
1085            "expected credential-exposure warning"
1086        );
1087        assert!(warnings[0].detail.contains(".env"));
1088    }
1089
1090    #[test]
1091    fn doctor_no_warning_when_env_file_covered_by_exclude_paths() {
1092        let root = tempfile::tempdir().unwrap();
1093        std::fs::write(root.path().join(".env"), "SECRET=hunter2").unwrap();
1094
1095        let loaded =
1096            make_loaded_with_workspace(root.path().to_path_buf(), vec![".env".to_string()]);
1097        let warnings = credential_exposure_warnings(&loaded);
1098
1099        assert!(
1100            warnings.iter().all(|w| w.name != "credential-exposure"),
1101            "no warning when .env is covered"
1102        );
1103    }
1104
1105    #[test]
1106    fn doctor_warns_for_pem_file_not_covered() {
1107        let root = tempfile::tempdir().unwrap();
1108        std::fs::write(root.path().join("server.pem"), "CERT").unwrap();
1109
1110        let loaded = make_loaded_with_workspace(root.path().to_path_buf(), vec![]);
1111        let warnings = credential_exposure_warnings(&loaded);
1112
1113        assert!(warnings.iter().any(|w| w.name == "credential-exposure"));
1114        assert!(warnings[0].detail.contains("server.pem"));
1115    }
1116
1117    #[test]
1118    fn doctor_no_warning_when_workspace_has_no_credential_files() {
1119        let root = tempfile::tempdir().unwrap();
1120        std::fs::write(root.path().join("main.rs"), "fn main() {}").unwrap();
1121
1122        let loaded = make_loaded_with_workspace(root.path().to_path_buf(), vec![]);
1123        let warnings = credential_exposure_warnings(&loaded);
1124
1125        assert!(warnings.iter().all(|w| w.name != "credential-exposure"));
1126    }
1127
1128    fn make_config_with_dispatch(patterns: Vec<&str>) -> Config {
1129        let mut dispatch = indexmap::IndexMap::new();
1130        dispatch.insert(
1131            "system".to_string(),
1132            DispatchRule {
1133                patterns: patterns.into_iter().map(String::from).collect(),
1134                profile: "root".to_string(),
1135            },
1136        );
1137        Config {
1138            version: 1,
1139            runtime: None,
1140            workspace: None,
1141            identity: None,
1142            image: None,
1143            environment: None,
1144            mounts: Vec::new(),
1145            caches: Vec::new(),
1146            secrets: Vec::new(),
1147            profiles: Default::default(),
1148            dispatch,
1149            package_manager: None,
1150        }
1151    }
1152
1153    #[test]
1154    fn root_command_dispatch_warns_for_apt_get_pattern() {
1155        let config = make_config_with_dispatch(vec!["apt-get install *"]);
1156        let warnings = root_command_dispatch_warnings(&config);
1157        assert!(
1158            warnings.iter().any(|w| w.name == "root-commands" && w.level == CheckLevel::Warn),
1159            "expected root-commands warning for apt-get pattern"
1160        );
1161        assert!(warnings[0].detail.contains("apt-get install *"));
1162    }
1163
1164    #[test]
1165    fn root_command_dispatch_warns_for_multiple_root_patterns() {
1166        let config = make_config_with_dispatch(vec!["apk add *", "yum install *"]);
1167        let warnings = root_command_dispatch_warnings(&config);
1168        assert!(
1169            warnings.iter().any(|w| w.name == "root-commands"),
1170            "expected root-commands warning"
1171        );
1172        let detail = &warnings[0].detail;
1173        assert!(detail.contains("apk add *") || detail.contains("yum install *"));
1174    }
1175
1176    #[test]
1177    fn root_command_dispatch_no_warning_for_safe_patterns() {
1178        let config = make_config_with_dispatch(vec!["npm install", "cargo build"]);
1179        let warnings = root_command_dispatch_warnings(&config);
1180        assert!(
1181            warnings.iter().all(|w| w.name != "root-commands"),
1182            "no warning for safe package manager patterns"
1183        );
1184    }
1185
1186    #[test]
1187    fn root_command_dispatch_no_warning_when_identity_uid_zero() {
1188        use crate::config::model::IdentityConfig;
1189        let mut config = make_config_with_dispatch(vec!["apt-get install *"]);
1190        config.identity = Some(IdentityConfig {
1191            map_user: None,
1192            uid: Some(0),
1193            gid: Some(0),
1194        });
1195        let warnings = root_command_dispatch_warnings(&config);
1196        assert!(
1197            warnings.iter().all(|w| w.name != "root-commands"),
1198            "no warning when uid:0 is explicitly set — user opted in to root"
1199        );
1200    }
1201}