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