Skip to main content

sbox/
shell.rs

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(default_shell().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    Ok(default_shell().to_string())
91}
92
93/// Platform-specific fallback shell when `SHELL` / profile config is not set.
94fn default_shell() -> &'static str {
95    #[cfg(windows)]
96    {
97        "cmd.exe"
98    }
99    #[cfg(not(windows))]
100    {
101        "/bin/sh"
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use indexmap::IndexMap;
108
109    use super::resolve_shell;
110    use crate::cli::{Cli, Commands, ShellCommand};
111    use crate::config::model::{Config, ExecutionMode, ProfileConfig};
112
113    fn base_cli() -> Cli {
114        Cli {
115            config: None,
116            workspace: None,
117            backend: None,
118            image: None,
119            profile: None,
120            mode: None,
121            strict_security: false,
122            verbose: 0,
123            quiet: false,
124            command: Commands::Shell(ShellCommand::default()),
125        }
126    }
127
128    fn base_config() -> Config {
129        let mut profiles = IndexMap::new();
130        profiles.insert(
131            "default".to_string(),
132            ProfileConfig {
133                mode: ExecutionMode::Sandbox,
134                image: None,
135                network: Some("off".to_string()),
136                writable: Some(true),
137                require_pinned_image: None,
138                require_lockfile: None,
139                role: None,
140                lockfile_files: Vec::new(),
141                pre_run: Vec::new(),
142                network_allow: Vec::new(),
143                ports: Vec::new(),
144                capabilities: None,
145                no_new_privileges: Some(true),
146                read_only_rootfs: None,
147                reuse_container: None,
148                shell: Some("/bin/bash".to_string()),
149                writable_paths: None,
150            },
151        );
152
153        Config {
154            version: 1,
155            runtime: None,
156            workspace: None,
157            identity: None,
158            image: None,
159            environment: None,
160            mounts: Vec::new(),
161            caches: Vec::new(),
162            secrets: Vec::new(),
163            profiles,
164            dispatch: IndexMap::new(),
165
166            package_manager: None,
167        }
168    }
169
170    #[test]
171    fn shell_flag_overrides_profile_shell() {
172        let cli = base_cli();
173        let config = base_config();
174        let command = ShellCommand {
175            shell: Some("/bin/zsh".to_string()),
176        };
177
178        let shell =
179            resolve_shell(&cli, &command, &config, "default").expect("shell should resolve");
180        assert_eq!(shell, "/bin/zsh");
181    }
182
183    #[test]
184    fn profile_shell_is_used_when_flag_is_absent() {
185        let cli = base_cli();
186        let config = base_config();
187
188        let shell = resolve_shell(&cli, &ShellCommand::default(), &config, "default")
189            .expect("shell should resolve");
190        assert_eq!(shell, "/bin/bash");
191    }
192}