Skip to main content

sbox/
plan.rs

1use std::fmt::Write as _;
2use std::process::ExitCode;
3
4use crate::cli::{Cli, PlanCommand};
5use crate::config::{LoadOptions, load_config};
6use crate::error::SboxError;
7use crate::resolve::{
8    CwdMapping, EnvVarSource, ExecutionPlan, ModeSource, ProfileSource, ResolutionTarget,
9    ResolvedImageSource, resolve_execution_plan,
10};
11
12pub fn execute(cli: &Cli, command: &PlanCommand) -> Result<ExitCode, SboxError> {
13    let loaded = load_config(&LoadOptions {
14        workspace: cli.workspace.clone(),
15        config: cli.config.clone(),
16    })?;
17
18    let (target, effective_command): (ResolutionTarget<'_>, Vec<String>) =
19        if command.command.is_empty() {
20            let profile =
21                cli.profile
22                    .as_deref()
23                    .ok_or_else(|| SboxError::ProfileResolutionFailed {
24                        command: "<none>".to_string(),
25                    })?;
26            (
27                ResolutionTarget::Exec { profile },
28                vec!["<profile-inspection>".to_string()],
29            )
30        } else {
31            (ResolutionTarget::Plan, command.command.clone())
32        };
33
34    let plan = resolve_execution_plan(cli, &loaded, target, &effective_command)?;
35    let strict_security = crate::exec::strict_security_enabled(cli, &loaded.config);
36
37    print!(
38        "{}",
39        render_plan(
40            &loaded.config_path,
41            &plan,
42            strict_security,
43            command.show_command,
44            command.command.is_empty(),
45        )
46    );
47
48    // Append an inline security scan when the user passes --audit.
49    // Gated behind a flag so `sbox plan` stays fast and deterministic by default.
50    // Non-blocking: a missing audit tool or tool failure never causes `sbox plan` to exit non-zero.
51    if command.audit && !command.command.is_empty() {
52        let pm_name = loaded
53            .config
54            .package_manager
55            .as_ref()
56            .map(|pm| pm.name.as_str())
57            .unwrap_or_else(|| crate::audit::detect_pm_from_workspace(&loaded.workspace_root));
58
59        let result = crate::audit::run_inline(pm_name, &loaded.workspace_root);
60        print!("{}", render_inline_audit(&result));
61    }
62
63    Ok(ExitCode::SUCCESS)
64}
65
66pub(crate) fn render_plan(
67    config_path: &std::path::Path,
68    plan: &ExecutionPlan,
69    strict_security: bool,
70    show_command: bool,
71    profile_inspection: bool,
72) -> String {
73    let mut output = String::new();
74    writeln!(output, "sbox plan").ok();
75    writeln!(output, "phase: 2").ok();
76    writeln!(output, "config: {}", config_path.display()).ok();
77    writeln!(output).ok();
78
79    if profile_inspection {
80        writeln!(output, "command: <profile inspection — no command given>").ok();
81    } else {
82        writeln!(output, "command: {}", plan.command_string).ok();
83        writeln!(output, "argv:").ok();
84        for arg in &plan.command {
85            writeln!(output, "  - {arg}").ok();
86        }
87    }
88    writeln!(output).ok();
89
90    if profile_inspection {
91        writeln!(output, "audit: <not applicable for profile inspection>").ok();
92        writeln!(output).ok();
93    } else {
94        writeln!(output, "audit:").ok();
95        writeln!(output, "  install_style: {}", plan.audit.install_style).ok();
96        writeln!(output, "  strict_security: {}", strict_security).ok();
97        writeln!(
98            output,
99            "  trusted_image_required: {}",
100            crate::exec::trusted_image_required(plan, strict_security)
101        )
102        .ok();
103        writeln!(
104            output,
105            "  sensitive_pass_through: {}",
106            if plan.audit.sensitive_pass_through_vars.is_empty() {
107                "<none>".to_string()
108            } else {
109                plan.audit.sensitive_pass_through_vars.join(", ")
110            }
111        )
112        .ok();
113        writeln!(
114            output,
115            "  lockfile: {}",
116            describe_lockfile_audit(&plan.audit.lockfile)
117        )
118        .ok();
119        writeln!(
120            output,
121            "  pre_run: {}",
122            describe_pre_run(&plan.audit.pre_run)
123        )
124        .ok();
125        writeln!(output).ok();
126    } // end audit block
127
128    writeln!(output, "resolution:").ok();
129    writeln!(output, "  profile: {}", plan.profile_name).ok();
130    writeln!(
131        output,
132        "  profile source: {}",
133        describe_profile_source(&plan.profile_source)
134    )
135    .ok();
136    writeln!(output, "  mode: {}", describe_execution_mode(&plan.mode)).ok();
137    writeln!(
138        output,
139        "  mode source: {}",
140        describe_mode_source(&plan.mode_source)
141    )
142    .ok();
143    writeln!(output).ok();
144
145    writeln!(output, "runtime:").ok();
146    writeln!(output, "  backend: {}", describe_backend(&plan.backend)).ok();
147    writeln!(output, "  image: {}", plan.image.description).ok();
148    writeln!(
149        output,
150        "  image_trust: {}",
151        describe_image_trust(plan.image.trust)
152    )
153    .ok();
154    writeln!(
155        output,
156        "  verify_signature: {}",
157        if plan.image.verify_signature {
158            "requested"
159        } else {
160            "not requested"
161        }
162    )
163    .ok();
164    writeln!(output, "  user mapping: {}", describe_user(&plan.user)).ok();
165    writeln!(output).ok();
166
167    writeln!(output, "workspace:").ok();
168    writeln!(output, "  root: {}", plan.workspace.root.display()).ok();
169    writeln!(
170        output,
171        "  invocation cwd: {}",
172        plan.workspace.invocation_dir.display()
173    )
174    .ok();
175    writeln!(
176        output,
177        "  effective host dir: {}",
178        plan.workspace.effective_host_dir.display()
179    )
180    .ok();
181    writeln!(output, "  mount: {}", plan.workspace.mount).ok();
182    writeln!(output, "  sandbox cwd: {}", plan.workspace.sandbox_cwd).ok();
183    writeln!(
184        output,
185        "  cwd mapping: {}",
186        describe_cwd_mapping(&plan.workspace.cwd_mapping)
187    )
188    .ok();
189    writeln!(output).ok();
190
191    writeln!(output, "policy:").ok();
192    writeln!(output, "  network: {}", plan.policy.network).ok();
193    writeln!(
194        output,
195        "  network_allow: {}",
196        describe_network_allow(
197            &plan.policy.network_allow,
198            &plan.policy.network_allow_patterns
199        )
200    )
201    .ok();
202    writeln!(output, "  writable: {}", plan.policy.writable).ok();
203    writeln!(
204        output,
205        "  no_new_privileges: {}",
206        plan.policy.no_new_privileges
207    )
208    .ok();
209    writeln!(
210        output,
211        "  read_only_rootfs: {}",
212        plan.policy.read_only_rootfs
213    )
214    .ok();
215    writeln!(output, "  reuse_container: {}", plan.policy.reuse_container).ok();
216    writeln!(
217        output,
218        "  reusable_session: {}",
219        plan.policy
220            .reusable_session_name
221            .as_deref()
222            .unwrap_or("<none>")
223    )
224    .ok();
225    writeln!(
226        output,
227        "  cap_drop: {}",
228        if plan.policy.cap_drop.is_empty() {
229            "<none>".to_string()
230        } else {
231            plan.policy.cap_drop.join(", ")
232        }
233    )
234    .ok();
235    writeln!(
236        output,
237        "  cap_add: {}",
238        if plan.policy.cap_add.is_empty() {
239            "<none>".to_string()
240        } else {
241            plan.policy.cap_add.join(", ")
242        }
243    )
244    .ok();
245    writeln!(
246        output,
247        "  ports: {}",
248        if plan.policy.ports.is_empty() {
249            "<none>".to_string()
250        } else {
251            plan.policy.ports.join(", ")
252        }
253    )
254    .ok();
255    writeln!(
256        output,
257        "  pull_policy: {}",
258        plan.policy.pull_policy.as_deref().unwrap_or("<default>")
259    )
260    .ok();
261    writeln!(output).ok();
262
263    writeln!(output, "environment:").ok();
264    if plan.environment.variables.is_empty() {
265        writeln!(output, "  selected: <none>").ok();
266    } else {
267        for variable in &plan.environment.variables {
268            writeln!(
269                output,
270                "  - {}={} ({})",
271                variable.name,
272                variable.value,
273                describe_env_source(&variable.source)
274            )
275            .ok();
276        }
277    }
278    writeln!(
279        output,
280        "  denied: {}",
281        if plan.environment.denied.is_empty() {
282            "<none>".to_string()
283        } else {
284            plan.environment.denied.join(", ")
285        }
286    )
287    .ok();
288    writeln!(output).ok();
289
290    writeln!(output, "mounts:").ok();
291    for mount in &plan.mounts {
292        if mount.kind == "mask" {
293            writeln!(output, "  - mask {} (credential masked)", mount.target).ok();
294            continue;
295        }
296        let source = mount
297            .source
298            .as_ref()
299            .map(|path| path.display().to_string())
300            .unwrap_or_else(|| "<none>".to_string());
301        let label = if mount.is_workspace {
302            "workspace"
303        } else {
304            "extra"
305        };
306        writeln!(
307            output,
308            "  - {} {} -> {} ({}, {})",
309            mount.kind,
310            source,
311            mount.target,
312            if mount.read_only { "ro" } else { "rw" },
313            label
314        )
315        .ok();
316    }
317    writeln!(output).ok();
318
319    writeln!(output, "caches:").ok();
320    if plan.caches.is_empty() {
321        writeln!(output, "  <none>").ok();
322    } else {
323        for cache in &plan.caches {
324            writeln!(
325                output,
326                "  - {} -> {} ({}, source: {})",
327                cache.name,
328                cache.target,
329                if cache.read_only { "ro" } else { "rw" },
330                cache.source.as_deref().unwrap_or("<default>")
331            )
332            .ok();
333        }
334    }
335    writeln!(output).ok();
336
337    writeln!(output, "secrets:").ok();
338    if plan.secrets.is_empty() {
339        writeln!(output, "  <none>").ok();
340    } else {
341        for secret in &plan.secrets {
342            writeln!(
343                output,
344                "  - {} -> {} (source: {})",
345                secret.name, secret.target, secret.source
346            )
347            .ok();
348        }
349    }
350
351    if show_command && let Some(podman_args) = render_podman_command(plan) {
352        writeln!(output).ok();
353        writeln!(output, "backend command:").ok();
354        writeln!(output, "  {podman_args}").ok();
355    }
356
357    output
358}
359
360fn render_inline_audit(result: &crate::audit::InlineAuditResult) -> String {
361    use crate::audit::InlineAuditStatus;
362
363    let mut out = String::new();
364    writeln!(out).ok();
365    writeln!(out, "security-scan:").ok();
366    writeln!(out, "  tool: {}", result.tool).ok();
367
368    let status_label = match result.status {
369        InlineAuditStatus::Clean => "clean",
370        InlineAuditStatus::Findings => "VULNERABILITIES FOUND",
371        InlineAuditStatus::ToolNotFound => "tool not installed",
372        InlineAuditStatus::Error => "error",
373    };
374    writeln!(out, "  status: {status_label}").ok();
375
376    if !result.output.trim().is_empty() {
377        writeln!(out, "  output: |").ok();
378        for line in result.output.trim_end().lines() {
379            writeln!(out, "    {line}").ok();
380        }
381    }
382
383    out
384}
385
386fn render_podman_command(plan: &ExecutionPlan) -> Option<String> {
387    if !matches!(plan.mode, crate::config::model::ExecutionMode::Sandbox) {
388        return None;
389    }
390    if !matches!(plan.backend, crate::config::BackendKind::Podman) {
391        return None;
392    }
393
394    let image = match &plan.image.source {
395        ResolvedImageSource::Reference(r) => r.clone(),
396        ResolvedImageSource::Build { tag, .. } => tag.clone(),
397    };
398
399    match crate::backend::podman::build_run_args(plan, &image) {
400        Ok(args) => {
401            let escaped: Vec<String> = std::iter::once("podman".to_string())
402                .chain(args.into_iter().map(|arg| {
403                    if arg.contains(' ') || arg.contains(',') {
404                        format!("'{arg}'")
405                    } else {
406                        arg
407                    }
408                }))
409                .collect();
410            Some(escaped.join(" "))
411        }
412        Err(_) => None,
413    }
414}
415
416fn describe_profile_source(source: &ProfileSource) -> String {
417    match source {
418        ProfileSource::CliOverride => "cli override".to_string(),
419        ProfileSource::ExecSubcommand => "exec subcommand".to_string(),
420        ProfileSource::Dispatch { rule_name, pattern } => {
421            // pm:<name>:<kind> — generated by package_manager: elaboration
422            if let Some(rest) = rule_name.strip_prefix("pm:") {
423                let parts: Vec<&str> = rest.splitn(2, ':').collect();
424                if parts.len() == 2 {
425                    return format!(
426                        "package_manager preset `{}` ({}) via pattern `{}`",
427                        parts[0], parts[1], pattern
428                    );
429                }
430            }
431            format!("dispatch rule `{rule_name}` via pattern `{pattern}`")
432        }
433        ProfileSource::DefaultProfile => "default profile".to_string(),
434        ProfileSource::ImplementationDefault => "implementation default".to_string(),
435    }
436}
437
438fn describe_mode_source(source: &ModeSource) -> &'static str {
439    match source {
440        ModeSource::CliOverride => "cli override",
441        ModeSource::Profile => "profile",
442    }
443}
444
445fn describe_backend(backend: &crate::config::BackendKind) -> &'static str {
446    match backend {
447        crate::config::BackendKind::Podman => "podman",
448        crate::config::BackendKind::Docker => "docker",
449    }
450}
451
452fn describe_image_trust(trust: crate::resolve::ImageTrust) -> &'static str {
453    match trust {
454        crate::resolve::ImageTrust::PinnedDigest => "pinned-digest",
455        crate::resolve::ImageTrust::MutableReference => "mutable-reference",
456        crate::resolve::ImageTrust::LocalBuild => "local-build",
457    }
458}
459
460fn describe_lockfile_audit(audit: &crate::resolve::LockfileAudit) -> String {
461    if !audit.applicable {
462        return "not-applicable".to_string();
463    }
464
465    if audit.present {
466        let requirement = if audit.required {
467            "required"
468        } else {
469            "advisory"
470        };
471        format!(
472            "{requirement}, present ({})",
473            audit.expected_files.join(" or ")
474        )
475    } else {
476        let requirement = if audit.required {
477            "required"
478        } else {
479            "advisory"
480        };
481        format!(
482            "{requirement}, missing ({})",
483            audit.expected_files.join(" or ")
484        )
485    }
486}
487
488fn describe_network_allow(resolved: &[(String, String)], patterns: &[String]) -> String {
489    if resolved.is_empty() && patterns.is_empty() {
490        return "<none>".to_string();
491    }
492    let mut parts: Vec<String> = Vec::new();
493    if !resolved.is_empty() {
494        let hosts: Vec<String> = {
495            let mut seen = Vec::new();
496            for (host, _) in resolved {
497                if !seen.contains(host) {
498                    seen.push(host.clone());
499                }
500            }
501            seen
502        };
503        parts.push(format!("[resolved] {}", hosts.join(", ")));
504    }
505    if !patterns.is_empty() {
506        parts.push(format!("[patterns] {}", patterns.join(", ")));
507    }
508    parts.join("; ")
509}
510
511fn describe_pre_run(pre_run: &[Vec<String>]) -> String {
512    if pre_run.is_empty() {
513        return "<none>".to_string();
514    }
515    pre_run
516        .iter()
517        .map(|argv| argv.join(" "))
518        .collect::<Vec<_>>()
519        .join(", ")
520}
521
522fn describe_execution_mode(mode: &crate::config::model::ExecutionMode) -> &'static str {
523    match mode {
524        crate::config::model::ExecutionMode::Host => "host",
525        crate::config::model::ExecutionMode::Sandbox => "sandbox",
526    }
527}
528
529fn describe_cwd_mapping(mapping: &CwdMapping) -> &'static str {
530    match mapping {
531        CwdMapping::InvocationMapped => "mapped from invocation cwd",
532        CwdMapping::WorkspaceRootFallback => "workspace root fallback",
533    }
534}
535
536fn describe_env_source(source: &EnvVarSource) -> &'static str {
537    match source {
538        EnvVarSource::PassThrough => "pass-through",
539        EnvVarSource::Set => "set",
540    }
541}
542
543fn describe_user(user: &crate::resolve::ResolvedUser) -> String {
544    match user {
545        crate::resolve::ResolvedUser::Default => "default".to_string(),
546        crate::resolve::ResolvedUser::KeepId => "keep-id".to_string(),
547        crate::resolve::ResolvedUser::Explicit { uid, gid } => format!("{uid}:{gid}"),
548    }
549}