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 } 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 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}