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 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 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 const ROOT_COMMANDS: &[&str] = &[
246 "apt-get", "apt ", "apk ", "yum ", "dnf ", "pacman ", "zypper ",
247 ];
248
249 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 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
580const 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
650fn 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}