Skip to main content

sbox/
shim.rs

1use std::fs;
2use std::os::unix::fs::PermissionsExt;
3use std::path::{Path, PathBuf};
4use std::process::ExitCode;
5
6use crate::cli::ShimCommand;
7use crate::error::SboxError;
8
9/// Package managers and runtimes that sbox knows how to intercept.
10/// Install-time tools (npm, pip, ...) catch supply-chain attacks at the source.
11/// Runtime tools (node, python3, go, ...) close the post-install artifact gap:
12/// code planted in node_modules/.bin during install can't run unsandboxed if `node` is shimmed.
13const SHIM_TARGETS: &[&str] = &[
14    // package managers / installers
15    "npm", "npx", "pnpm", "yarn", "bun", "uv", "pip", "pip3", "poetry", "cargo", "composer",
16    // runtimes — prevent post-install artifacts from running on the bare host
17    "node", "python3", "python", "go",
18];
19
20pub fn execute(command: &ShimCommand) -> Result<ExitCode, SboxError> {
21    let shim_dir = resolve_shim_dir(command)?;
22
23    if !command.dry_run {
24        fs::create_dir_all(&shim_dir).map_err(|source| SboxError::InitWrite {
25            path: shim_dir.clone(),
26            source,
27        })?;
28    }
29
30    let mut created = 0usize;
31    let mut skipped = 0usize;
32
33    for &name in SHIM_TARGETS {
34        let dest = shim_dir.join(name);
35
36        if dest.exists() && !command.force && !command.dry_run {
37            println!(
38                "skip   {} (already exists; use --force to overwrite)",
39                dest.display()
40            );
41            skipped += 1;
42            continue;
43        }
44
45        let real_binary = find_real_binary(name, &shim_dir);
46        let script = render_shim(name, real_binary.as_deref());
47
48        if command.dry_run {
49            match &real_binary {
50                Some(p) => println!("would create {} -> {}", dest.display(), p.display()),
51                None => println!("would create {} (real binary not found)", dest.display()),
52            }
53            created += 1;
54            continue;
55        }
56
57        fs::write(&dest, &script).map_err(|source| SboxError::InitWrite {
58            path: dest.clone(),
59            source,
60        })?;
61
62        let mut perms = fs::metadata(&dest)
63            .map_err(|source| SboxError::InitWrite {
64                path: dest.clone(),
65                source,
66            })?
67            .permissions();
68        perms.set_mode(0o755);
69        fs::set_permissions(&dest, perms).map_err(|source| SboxError::InitWrite {
70            path: dest.clone(),
71            source,
72        })?;
73
74        match &real_binary {
75            Some(p) => println!("created {} -> {}", dest.display(), p.display()),
76            None => println!(
77                "created {} (real binary not found at shim time)",
78                dest.display()
79            ),
80        }
81        created += 1;
82    }
83
84    if !command.dry_run {
85        println!();
86        if created > 0 {
87            println!(
88                "Add {} to your PATH before the real package manager binaries:",
89                shim_dir.display()
90            );
91            println!();
92            println!("  export PATH=\"{}:$PATH\"", shim_dir.display());
93            println!();
94            println!("Then restart your shell or run: source ~/.bashrc");
95        }
96        if skipped > 0 {
97            println!("({skipped} skipped — use --force to overwrite)");
98        }
99    }
100
101    Ok(ExitCode::SUCCESS)
102}
103
104fn resolve_shim_dir(command: &ShimCommand) -> Result<PathBuf, SboxError> {
105    if let Some(dir) = &command.dir {
106        let abs = if dir.is_absolute() {
107            dir.clone()
108        } else {
109            std::env::current_dir()
110                .map_err(|source| SboxError::CurrentDirectory { source })?
111                .join(dir)
112        };
113        return Ok(abs);
114    }
115
116    // Default: ~/.local/bin
117    if let Some(home) = std::env::var_os("HOME") {
118        return Ok(PathBuf::from(home).join(".local/bin"));
119    }
120
121    // Last resort: use current directory
122    std::env::current_dir().map_err(|source| SboxError::CurrentDirectory { source })
123}
124
125/// Search PATH for `name`, skipping `exclude_dir` to avoid resolving the shim itself.
126fn find_real_binary(name: &str, exclude_dir: &Path) -> Option<PathBuf> {
127    let path_os = std::env::var_os("PATH")?;
128    for dir in std::env::split_paths(&path_os) {
129        if dir == exclude_dir {
130            continue;
131        }
132        let candidate = dir.join(name);
133        if is_executable_file(&candidate) {
134            return Some(candidate);
135        }
136    }
137    None
138}
139
140fn is_executable_file(path: &Path) -> bool {
141    path.metadata()
142        .map(|m| m.is_file() && (m.permissions().mode() & 0o111) != 0)
143        .unwrap_or(false)
144}
145
146/// Render a POSIX shell shim script for the given package manager.
147///
148/// The script walks up the directory tree looking for `sbox.yaml`. When found it
149/// delegates to `sbox run -- <name> "$@"`. Otherwise it falls through to the real
150/// binary (hardcoded at shim-generation time to avoid PATH loops).
151fn render_shim(name: &str, real_binary: Option<&Path>) -> String {
152    let fallback = match real_binary {
153        Some(path) => format!(
154            "printf 'sbox: no sbox.yaml found — running {name} unsandboxed\\n' >&2\nexec {path} \"$@\"",
155            path = path.display()
156        ),
157        None => format!(
158            "printf 'sbox shim: {name}: real binary not found; reinstall or run `sbox shim` again\\n' >&2\nexit 127"
159        ),
160    };
161
162    // Note: the ${_sbox_d%/*} shell parameter expansion is written literally here.
163    // It strips the last path component, walking up the directory tree.
164    format!(
165        "#!/bin/sh\n\
166         # sbox shim: {name}\n\
167         # Generated by `sbox shim`. Re-run `sbox shim --dir DIR` to regenerate.\n\
168         _sbox_d=\"$PWD\"\n\
169         while true; do\n\
170         \x20 if [ -f \"$_sbox_d/sbox.yaml\" ]; then\n\
171         \x20   exec sbox run -- {name} \"$@\"\n\
172         \x20 fi\n\
173         \x20 [ \"$_sbox_d\" = \"/\" ] && break\n\
174         \x20 _sbox_d=\"${{_sbox_d%/*}}\"\n\
175         \x20 [ -z \"$_sbox_d\" ] && _sbox_d=\"/\"\n\
176         done\n\
177         {fallback}\n"
178    )
179}
180
181#[cfg(test)]
182mod tests {
183    use super::render_shim;
184
185    #[test]
186    fn shim_contains_sbox_run_delegation() {
187        let script = render_shim("npm", Some(std::path::Path::new("/usr/bin/npm")));
188        assert!(script.contains("exec sbox run -- npm \"$@\""));
189        assert!(script.contains("sbox.yaml"));
190        assert!(script.contains("exec /usr/bin/npm \"$@\""));
191        assert!(script.contains("running npm unsandboxed"));
192    }
193
194    #[test]
195    fn shim_fallback_when_real_binary_missing() {
196        let script = render_shim("npm", None);
197        assert!(script.contains("real binary not found"));
198        assert!(script.contains("exit 127"));
199    }
200
201    #[test]
202    fn shim_walks_to_root() {
203        let script = render_shim("uv", Some(std::path::Path::new("/usr/local/bin/uv")));
204        // The parent-dir stripping logic
205        assert!(script.contains("_sbox_d%/*"));
206        // Terminates at root
207        assert!(script.contains("[ \"$_sbox_d\" = \"/\" ] && break"));
208    }
209}