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