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