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