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("/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}