1use std::process::ExitCode;
2
3use crate::cli::{Cli, ShellCommand};
4use crate::config::{LoadOptions, load_config};
5use crate::error::SboxError;
6use crate::resolve::{ResolutionTarget, resolve_execution_plan};
7
8pub fn execute(cli: &Cli, command: &ShellCommand) -> Result<ExitCode, SboxError> {
9 let loaded = load_config(&LoadOptions {
10 workspace: cli.workspace.clone(),
11 config: cli.config.clone(),
12 })?;
13
14 let initial_shell = resolve_initial_shell(command)?;
15 let initial_plan = resolve_execution_plan(
16 cli,
17 &loaded,
18 ResolutionTarget::Shell,
19 std::slice::from_ref(&initial_shell),
20 )?;
21 let shell = resolve_shell(cli, command, &loaded.config, &initial_plan.profile_name)?;
22 let plan = if shell == initial_shell {
23 initial_plan
24 } else {
25 resolve_execution_plan(cli, &loaded, ResolutionTarget::Shell, &[shell])?
26 };
27 crate::exec::validate_execution_safety(
28 &plan,
29 crate::exec::strict_security_enabled(cli, &loaded.config),
30 )?;
31
32 match plan.mode {
33 crate::config::model::ExecutionMode::Host => crate::exec::execute_host(&plan),
34 crate::config::model::ExecutionMode::Sandbox => match plan.backend {
35 crate::config::BackendKind::Podman => {
36 crate::backend::podman::execute_interactive(&plan)
37 }
38 crate::config::BackendKind::Docker => {
39 crate::backend::docker::execute_interactive(&plan)
40 }
41 },
42 }
43}
44
45fn resolve_shell(
46 cli: &Cli,
47 command: &ShellCommand,
48 config: &crate::config::model::Config,
49 active_profile: &str,
50) -> Result<String, SboxError> {
51 if let Some(shell) = &command.shell {
52 return Ok(shell.clone());
53 }
54
55 if let Some(profile) = config.profiles.get(active_profile)
56 && let Some(shell) = &profile.shell
57 {
58 return Ok(shell.clone());
59 }
60
61 if let Some(profile_name) = &cli.profile
62 && let Some(profile) = config.profiles.get(profile_name)
63 && let Some(shell) = &profile.shell
64 {
65 return Ok(shell.clone());
66 }
67
68 if let Some(shell) = std::env::var_os("SHELL") {
69 let shell = shell.to_string_lossy().trim().to_string();
70 if !shell.is_empty() {
71 return Ok(shell);
72 }
73 }
74
75 Ok("/bin/sh".to_string())
76}
77
78fn resolve_initial_shell(command: &ShellCommand) -> Result<String, SboxError> {
79 if let Some(shell) = &command.shell {
80 return Ok(shell.clone());
81 }
82
83 if let Some(shell) = std::env::var_os("SHELL") {
84 let shell = shell.to_string_lossy().trim().to_string();
85 if !shell.is_empty() {
86 return Ok(shell);
87 }
88 }
89
90 let fallback = "/bin/sh".to_string();
91 if fallback.is_empty() {
92 Err(SboxError::NoShellResolved)
93 } else {
94 Ok(fallback)
95 }
96}
97
98#[cfg(test)]
99mod tests {
100 use indexmap::IndexMap;
101
102 use super::resolve_shell;
103 use crate::cli::{Cli, Commands, ShellCommand};
104 use crate::config::model::{Config, ExecutionMode, ProfileConfig};
105
106 fn base_cli() -> Cli {
107 Cli {
108 config: None,
109 workspace: None,
110 backend: None,
111 image: None,
112 profile: None,
113 mode: None,
114 strict_security: false,
115 verbose: 0,
116 quiet: false,
117 command: Commands::Shell(ShellCommand::default()),
118 }
119 }
120
121 fn base_config() -> Config {
122 let mut profiles = IndexMap::new();
123 profiles.insert(
124 "default".to_string(),
125 ProfileConfig {
126 mode: ExecutionMode::Sandbox,
127 image: None,
128 network: Some("off".to_string()),
129 writable: Some(true),
130 require_pinned_image: None,
131 require_lockfile: None,
132 role: None,
133 lockfile_files: Vec::new(),
134 pre_run: Vec::new(),
135 network_allow: Vec::new(),
136 ports: Vec::new(),
137 capabilities: None,
138 no_new_privileges: Some(true),
139 read_only_rootfs: None,
140 reuse_container: None,
141 shell: Some("/bin/bash".to_string()),
142 writable_paths: None,
143 },
144 );
145
146 Config {
147 version: 1,
148 runtime: None,
149 workspace: None,
150 identity: None,
151 image: None,
152 environment: None,
153 mounts: Vec::new(),
154 caches: Vec::new(),
155 secrets: Vec::new(),
156 profiles,
157 dispatch: IndexMap::new(),
158
159 package_manager: None,
160 }
161 }
162
163 #[test]
164 fn shell_flag_overrides_profile_shell() {
165 let cli = base_cli();
166 let config = base_config();
167 let command = ShellCommand {
168 shell: Some("/bin/zsh".to_string()),
169 };
170
171 let shell =
172 resolve_shell(&cli, &command, &config, "default").expect("shell should resolve");
173 assert_eq!(shell, "/bin/zsh");
174 }
175
176 #[test]
177 fn profile_shell_is_used_when_flag_is_absent() {
178 let cli = base_cli();
179 let config = base_config();
180
181 let shell = resolve_shell(&cli, &ShellCommand::default(), &config, "default")
182 .expect("shell should resolve");
183 assert_eq!(shell, "/bin/bash");
184 }
185}