Skip to main content

sbox/
shim.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::ExitCode;
4
5use crate::cli::ShimCommand;
6use crate::error::SboxError;
7
8/// Package managers and runtimes that sbox knows how to intercept.
9/// Install-time tools (npm, pip, ...) catch supply-chain attacks at the source.
10/// Runtime tools (node, python3, go, ...) close the post-install artifact gap:
11/// code planted in node_modules/.bin during install can't run unsandboxed if `node` is shimmed.
12const SHIM_TARGETS: &[&str] = &[
13    // package managers / installers
14    "npm", "npx", "pnpm", "yarn", "bun", "uv", "pip", "pip3", "poetry", "cargo", "composer",
15    // runtimes — prevent post-install artifacts from running on the bare host
16    "node", "python3", "python", "go",
17];
18
19pub fn execute(command: &ShimCommand) -> Result<ExitCode, SboxError> {
20    let shim_dir = resolve_shim_dir(command)?;
21
22    if !command.dry_run {
23        fs::create_dir_all(&shim_dir).map_err(|source| SboxError::InitWrite {
24            path: shim_dir.clone(),
25            source,
26        })?;
27    }
28
29    let mut created = 0usize;
30    let mut skipped = 0usize;
31
32    for &name in SHIM_TARGETS {
33        let dest = shim_file_path(&shim_dir, name);
34
35        if dest.exists() && !command.force && !command.dry_run {
36            println!(
37                "skip   {} (already exists; use --force to overwrite)",
38                dest.display()
39            );
40            skipped += 1;
41            continue;
42        }
43
44        let real_binary = find_real_binary(name, &shim_dir);
45        let script = render_shim(name, real_binary.as_deref());
46
47        if command.dry_run {
48            match &real_binary {
49                Some(p) => println!("would create {} -> {}", dest.display(), p.display()),
50                None => println!("would create {} (real binary not found)", dest.display()),
51            }
52            created += 1;
53            continue;
54        }
55
56        fs::write(&dest, &script).map_err(|source| SboxError::InitWrite {
57            path: dest.clone(),
58            source,
59        })?;
60
61        set_executable(&dest).map_err(|source| SboxError::InitWrite {
62            path: dest.clone(),
63            source,
64        })?;
65
66        match &real_binary {
67            Some(p) => println!("created {} -> {}", dest.display(), p.display()),
68            None => println!(
69                "created {} (real binary not found at shim time)",
70                dest.display()
71            ),
72        }
73        created += 1;
74    }
75
76    if !command.dry_run {
77        println!();
78        if created > 0 {
79            println!(
80                "Add {} to your PATH before the real package manager binaries:",
81                shim_dir.display()
82            );
83            println!();
84            #[cfg(windows)]
85            println!("  set PATH={};%PATH%", shim_dir.display());
86            #[cfg(not(windows))]
87            println!("  export PATH=\"{}:$PATH\"", shim_dir.display());
88            println!();
89            #[cfg(not(windows))]
90            println!("Then restart your shell or run: source ~/.bashrc");
91            #[cfg(windows)]
92            println!("Then restart your terminal.");
93        }
94        if skipped > 0 {
95            println!("({skipped} skipped — use --force to overwrite)");
96        }
97    }
98
99    Ok(ExitCode::SUCCESS)
100}
101
102/// Return the full path for a shim file, including platform-specific extension.
103fn shim_file_path(dir: &Path, name: &str) -> PathBuf {
104    #[cfg(windows)]
105    {
106        dir.join(format!("{name}.cmd"))
107    }
108    #[cfg(not(windows))]
109    {
110        dir.join(name)
111    }
112}
113
114fn resolve_shim_dir(command: &ShimCommand) -> Result<PathBuf, SboxError> {
115    if let Some(dir) = &command.dir {
116        let abs = if dir.is_absolute() {
117            dir.clone()
118        } else {
119            std::env::current_dir()
120                .map_err(|source| SboxError::CurrentDirectory { source })?
121                .join(dir)
122        };
123        return Ok(abs);
124    }
125
126    // Default: ~/.local/bin  (Unix) or %USERPROFILE%\.local\bin  (Windows)
127    if let Some(home) = crate::platform::home_dir() {
128        return Ok(home.join(".local").join("bin"));
129    }
130
131    // Last resort: use current directory
132    std::env::current_dir().map_err(|source| SboxError::CurrentDirectory { source })
133}
134
135/// Search PATH for `name`, skipping `exclude_dir` to avoid resolving the shim itself.
136fn find_real_binary(name: &str, exclude_dir: &Path) -> Option<PathBuf> {
137    let path_os = std::env::var_os("PATH")?;
138    for dir in std::env::split_paths(&path_os) {
139        if dir == exclude_dir {
140            continue;
141        }
142        // On Windows also search for name.exe / name.cmd / name.bat
143        #[cfg(windows)]
144        {
145            for ext in &["", ".exe", ".cmd", ".bat"] {
146                let candidate = dir.join(format!("{name}{ext}"));
147                if candidate.is_file() {
148                    return Some(candidate);
149                }
150            }
151        }
152        #[cfg(not(windows))]
153        {
154            let candidate = dir.join(name);
155            if is_executable_file(&candidate) {
156                return Some(candidate);
157            }
158        }
159    }
160    None
161}
162
163#[cfg(not(windows))]
164fn is_executable_file(path: &Path) -> bool {
165    use std::os::unix::fs::PermissionsExt;
166    path.metadata()
167        .map(|m| m.is_file() && (m.permissions().mode() & 0o111) != 0)
168        .unwrap_or(false)
169}
170
171/// Make a file executable. On Unix sets the rwxr-xr-x permission bits.
172/// On Windows files are executable by virtue of being writable; this is a no-op.
173fn set_executable(path: &Path) -> std::io::Result<()> {
174    #[cfg(unix)]
175    {
176        use std::os::unix::fs::PermissionsExt;
177        let mut perms = fs::metadata(path)?.permissions();
178        perms.set_mode(0o755);
179        fs::set_permissions(path, perms)?;
180    }
181    #[cfg(windows)]
182    {
183        let _ = path; // Windows: executable by extension (.cmd), nothing to set
184    }
185    Ok(())
186}
187
188/// Render a shim script for the given package manager name.
189///
190/// - Unix: POSIX `/bin/sh` script that walks up the directory tree looking for `sbox.yaml`.
191/// - Windows: `.cmd` batch script with equivalent walk-up logic.
192fn render_shim(name: &str, real_binary: Option<&Path>) -> String {
193    #[cfg(not(windows))]
194    return render_shim_posix(name, real_binary);
195
196    #[cfg(windows)]
197    return render_shim_cmd(name, real_binary);
198}
199
200/// POSIX shell shim (Unix / macOS / Linux).
201#[cfg(not(windows))]
202fn render_shim_posix(name: &str, real_binary: Option<&Path>) -> String {
203    let fallback = match real_binary {
204        Some(path) => format!(
205            "printf 'sbox: no sbox.yaml found — running {name} unsandboxed\\n' >&2\nexec {path} \"$@\"",
206            path = path.display()
207        ),
208        None => format!(
209            "printf 'sbox shim: {name}: real binary not found; reinstall or run `sbox shim` again\\n' >&2\nexit 127"
210        ),
211    };
212
213    // Note: the ${_sbox_d%/*} shell parameter expansion is written literally here.
214    // It strips the last path component, walking up the directory tree.
215    format!(
216        "#!/bin/sh\n\
217         # sbox shim: {name}\n\
218         # Generated by `sbox shim`. Re-run `sbox shim --dir DIR` to regenerate.\n\
219         _sbox_d=\"$PWD\"\n\
220         while true; do\n\
221         \x20 if [ -f \"$_sbox_d/sbox.yaml\" ]; then\n\
222         \x20   exec sbox run -- {name} \"$@\"\n\
223         \x20 fi\n\
224         \x20 [ \"$_sbox_d\" = \"/\" ] && break\n\
225         \x20 _sbox_d=\"${{_sbox_d%/*}}\"\n\
226         \x20 [ -z \"$_sbox_d\" ] && _sbox_d=\"/\"\n\
227         done\n\
228         {fallback}\n"
229    )
230}
231
232/// Windows CMD (.cmd) shim.
233#[cfg(windows)]
234fn render_shim_cmd(name: &str, real_binary: Option<&Path>) -> String {
235    let fallback = match real_binary {
236        Some(path) => format!(
237            "echo sbox: no sbox.yaml found -- running {name} unsandboxed 1>&2\r\n\"{path}\" %*\r\nexit /b %ERRORLEVEL%",
238            path = path.display()
239        ),
240        None => format!(
241            "echo sbox shim: {name}: real binary not found; reinstall or run `sbox shim` again 1>&2\r\nexit /b 127"
242        ),
243    };
244
245    format!(
246        "@echo off\r\n\
247         :: sbox shim: {name}\r\n\
248         :: Generated by `sbox shim`. Re-run `sbox shim --dir DIR` to regenerate.\r\n\
249         setlocal enabledelayedexpansion\r\n\
250         set \"_sbox_d=%CD%\"\r\n\
251         :_sbox_walk_{name}\r\n\
252         if exist \"%_sbox_d%\\sbox.yaml\" (\r\n\
253         \x20   sbox run -- {name} %*\r\n\
254         \x20   exit /b %ERRORLEVEL%\r\n\
255         )\r\n\
256         for %%P in (\"%_sbox_d%\\..\") do set \"_sbox_parent=%%~fP\"\r\n\
257         if \"!_sbox_parent!\"==\"!_sbox_d!\" goto _sbox_fallback_{name}\r\n\
258         set \"_sbox_d=!_sbox_parent!\"\r\n\
259         goto _sbox_walk_{name}\r\n\
260         :_sbox_fallback_{name}\r\n\
261         {fallback}\r\n"
262    )
263}
264
265
266#[cfg(test)]
267mod tests {
268    use super::render_shim;
269
270    #[test]
271    fn shim_contains_sbox_run_delegation() {
272        let script = render_shim("npm", Some(std::path::Path::new("/usr/bin/npm")));
273        assert!(script.contains("sbox run -- npm"));
274        assert!(script.contains("sbox.yaml"));
275    }
276
277    #[test]
278    fn shim_fallback_when_real_binary_missing() {
279        let script = render_shim("npm", None);
280        assert!(script.contains("real binary not found"));
281        #[cfg(not(windows))]
282        assert!(script.contains("exit 127"));
283        #[cfg(windows)]
284        assert!(script.contains("exit /b 127"));
285    }
286
287    #[test]
288    fn shim_walks_to_root() {
289        let script = render_shim("uv", Some(std::path::Path::new("/usr/local/bin/uv")));
290        #[cfg(not(windows))]
291        {
292            assert!(script.contains("_sbox_d%/*"));
293            assert!(script.contains("[ \"$_sbox_d\" = \"/\" ] && break"));
294        }
295        #[cfg(windows)]
296        {
297            assert!(script.contains("_sbox_parent"));
298            assert!(script.contains("goto _sbox_walk_uv"));
299        }
300    }
301
302    /// Verify the generated .cmd script contains all the structural elements needed
303    /// to walk up the directory tree and fall back to the real binary.
304    #[cfg(windows)]
305    #[test]
306    fn cmd_shim_structure() {
307        let script =
308            render_shim("npm", Some(std::path::Path::new(r"C:\Program Files\nodejs\npm.cmd")));
309
310        // Must be a batch file
311        assert!(script.contains("@echo off"), "must suppress echo");
312
313        // Must check for sbox.yaml and delegate
314        assert!(script.contains("sbox.yaml"), "must check for sbox.yaml");
315        assert!(script.contains("sbox run -- npm %*"), "must delegate to sbox run");
316
317        // Walk-up logic: parent dir extraction and loop label
318        assert!(
319            script.contains("goto _sbox_walk_npm"),
320            "must have a labelled walk loop"
321        );
322        assert!(
323            script.contains("_sbox_parent"),
324            "must compute parent directory"
325        );
326
327        // Fallback to the real binary when no sbox.yaml is found
328        assert!(
329            script.contains(r"C:\Program Files\nodejs\npm.cmd"),
330            "must reference real binary path"
331        );
332    }
333
334    #[cfg(windows)]
335    #[test]
336    fn cmd_shim_fallback_when_no_real_binary() {
337        let script = render_shim("uv", None);
338        assert!(script.contains("real binary not found"));
339        assert!(script.contains("exit /b 127"));
340    }
341}