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    "bundle",
16    // runtimes — prevent post-install artifacts from running on the bare host
17    "node", "python3", "python", "go", "ruby",
18];
19
20pub fn execute(command: &ShimCommand) -> Result<ExitCode, SboxError> {
21    if command.verify {
22        return execute_verify(command);
23    }
24
25    let shim_dir = resolve_shim_dir(command)?;
26
27    if !command.dry_run {
28        fs::create_dir_all(&shim_dir).map_err(|source| SboxError::InitWrite {
29            path: shim_dir.clone(),
30            source,
31        })?;
32    }
33
34    let mut created = 0usize;
35    let mut skipped = 0usize;
36
37    for &name in SHIM_TARGETS {
38        let dest = shim_file_path(&shim_dir, name);
39
40        if dest.exists() && !command.force && !command.dry_run {
41            println!(
42                "skip   {} (already exists; use --force to overwrite)",
43                dest.display()
44            );
45            skipped += 1;
46            continue;
47        }
48
49        let real_binary = find_real_binary(name, &shim_dir);
50        let script = render_shim(name, real_binary.as_deref());
51
52        if command.dry_run {
53            match &real_binary {
54                Some(p) => println!("would create {} -> {}", dest.display(), p.display()),
55                None => println!("would create {} (real binary not found)", dest.display()),
56            }
57            created += 1;
58            continue;
59        }
60
61        fs::write(&dest, &script).map_err(|source| SboxError::InitWrite {
62            path: dest.clone(),
63            source,
64        })?;
65
66        set_executable(&dest).map_err(|source| SboxError::InitWrite {
67            path: dest.clone(),
68            source,
69        })?;
70
71        match &real_binary {
72            Some(p) => println!("created {} -> {}", dest.display(), p.display()),
73            None => println!(
74                "created {} (real binary not found at shim time)",
75                dest.display()
76            ),
77        }
78        created += 1;
79    }
80
81    if !command.dry_run {
82        println!();
83        if created > 0 {
84            println!(
85                "Add {} to your PATH before the real package manager binaries:",
86                shim_dir.display()
87            );
88            println!();
89            #[cfg(windows)]
90            println!("  set PATH={};%PATH%", shim_dir.display());
91            #[cfg(not(windows))]
92            println!("  export PATH=\"{}:$PATH\"", shim_dir.display());
93            println!();
94            #[cfg(not(windows))]
95            println!("Then restart your shell or run: source ~/.bashrc");
96            #[cfg(windows)]
97            println!("Then restart your terminal.");
98        }
99        if skipped > 0 {
100            println!("({skipped} skipped — use --force to overwrite)");
101        }
102    }
103
104    Ok(ExitCode::SUCCESS)
105}
106
107/// Return the full path for a shim file, including platform-specific extension.
108fn shim_file_path(dir: &Path, name: &str) -> PathBuf {
109    #[cfg(windows)]
110    {
111        dir.join(format!("{name}.cmd"))
112    }
113    #[cfg(not(windows))]
114    {
115        dir.join(name)
116    }
117}
118
119/// For each shim target, check whether:
120/// 1. A shim file exists in the shim dir.
121/// 2. The shim dir appears in PATH before the directory that contains the real binary.
122///
123/// Returns (ok_count, problem_count). Prints a line per target.
124pub fn verify_shims(shim_dir: &Path) -> (usize, usize) {
125    let mut ok = 0usize;
126    let mut problems = 0usize;
127
128    let path_os = std::env::var_os("PATH").unwrap_or_default();
129    let path_dirs: Vec<std::path::PathBuf> = std::env::split_paths(&path_os).collect();
130
131    // Index of the shim dir in PATH, if present.
132    let shim_pos = path_dirs.iter().position(|d| d == shim_dir);
133
134    for &name in SHIM_TARGETS {
135        let shim_file = shim_file_path(shim_dir, name);
136
137        if !shim_file.exists() {
138            println!("missing  {name:<12}  shim not found at {}", shim_file.display());
139            problems += 1;
140            continue;
141        }
142
143        // Find the real binary position in PATH (skip the shim dir itself).
144        let real_pos = path_dirs.iter().enumerate().find_map(|(i, dir)| {
145            if dir == shim_dir {
146                return None;
147            }
148            #[cfg(windows)]
149            {
150                for ext in &[".exe", ".cmd", ".bat"] {
151                    if dir.join(format!("{name}{ext}")).is_file() {
152                        return Some(i);
153                    }
154                }
155                None
156            }
157            #[cfg(not(windows))]
158            {
159                let candidate = dir.join(name);
160                if is_executable_file(&candidate) {
161                    Some(i)
162                } else {
163                    None
164                }
165            }
166        });
167
168        match (shim_pos, real_pos) {
169            (Some(sp), Some(rp)) if sp < rp => {
170                println!("ok       {name:<12}  shim is active (PATH position {sp} < {rp})");
171                ok += 1;
172            }
173            (Some(_sp), Some(rp)) => {
174                println!(
175                    "shadowed {name:<12}  real binary at PATH position {rp} comes before shim dir; \
176                     move {} earlier in PATH",
177                    shim_dir.display()
178                );
179                problems += 1;
180            }
181            (None, _) => {
182                println!(
183                    "inactive {name:<12}  shim exists but {} is not in PATH",
184                    shim_dir.display()
185                );
186                problems += 1;
187            }
188            (Some(_), None) => {
189                println!("ok       {name:<12}  shim active (no real binary found elsewhere in PATH)");
190                ok += 1;
191            }
192        }
193    }
194
195    (ok, problems)
196}
197
198fn execute_verify(command: &ShimCommand) -> Result<ExitCode, SboxError> {
199    let shim_dir = resolve_shim_dir(command)?;
200    println!("shim dir: {}\n", shim_dir.display());
201
202    let (ok, problems) = verify_shims(&shim_dir);
203
204    println!();
205    println!("{ok} ok, {problems} problem(s)");
206
207    if problems > 0 {
208        println!(
209            "\nRun `sbox shim` to (re)create missing shims, then ensure {} is first in PATH.",
210            shim_dir.display()
211        );
212        Ok(ExitCode::FAILURE)
213    } else {
214        Ok(ExitCode::SUCCESS)
215    }
216}
217
218fn resolve_shim_dir(command: &ShimCommand) -> Result<PathBuf, SboxError> {
219    if let Some(dir) = &command.dir {
220        let abs = if dir.is_absolute() {
221            dir.clone()
222        } else {
223            std::env::current_dir()
224                .map_err(|source| SboxError::CurrentDirectory { source })?
225                .join(dir)
226        };
227        return Ok(abs);
228    }
229
230    // Default: ~/.local/bin  (Unix) or %USERPROFILE%\.local\bin  (Windows)
231    if let Some(home) = crate::platform::home_dir() {
232        return Ok(home.join(".local").join("bin"));
233    }
234
235    // Last resort: use current directory
236    std::env::current_dir().map_err(|source| SboxError::CurrentDirectory { source })
237}
238
239/// Search PATH for `name`, skipping `exclude_dir` to avoid resolving the shim itself.
240fn find_real_binary(name: &str, exclude_dir: &Path) -> Option<PathBuf> {
241    let path_os = std::env::var_os("PATH")?;
242    for dir in std::env::split_paths(&path_os) {
243        if dir == exclude_dir {
244            continue;
245        }
246        // On Windows also search for name.exe / name.cmd / name.bat
247        #[cfg(windows)]
248        {
249            for ext in &["", ".exe", ".cmd", ".bat"] {
250                let candidate = dir.join(format!("{name}{ext}"));
251                if candidate.is_file() {
252                    return Some(candidate);
253                }
254            }
255        }
256        #[cfg(not(windows))]
257        {
258            let candidate = dir.join(name);
259            if is_executable_file(&candidate) {
260                return Some(candidate);
261            }
262        }
263    }
264    None
265}
266
267#[cfg(not(windows))]
268fn is_executable_file(path: &Path) -> bool {
269    use std::os::unix::fs::PermissionsExt;
270    path.metadata()
271        .map(|m| m.is_file() && (m.permissions().mode() & 0o111) != 0)
272        .unwrap_or(false)
273}
274
275/// Make a file executable. On Unix sets the rwxr-xr-x permission bits.
276/// On Windows files are executable by virtue of being writable; this is a no-op.
277fn set_executable(path: &Path) -> std::io::Result<()> {
278    #[cfg(unix)]
279    {
280        use std::os::unix::fs::PermissionsExt;
281        let mut perms = fs::metadata(path)?.permissions();
282        perms.set_mode(0o755);
283        fs::set_permissions(path, perms)?;
284    }
285    #[cfg(windows)]
286    {
287        let _ = path; // Windows: executable by extension (.cmd), nothing to set
288    }
289    Ok(())
290}
291
292/// Render a shim script for the given package manager name.
293///
294/// - Unix: POSIX `/bin/sh` script that walks up the directory tree looking for `sbox.yaml`.
295/// - Windows: `.cmd` batch script with equivalent walk-up logic.
296fn render_shim(name: &str, real_binary: Option<&Path>) -> String {
297    #[cfg(not(windows))]
298    return render_shim_posix(name, real_binary);
299
300    #[cfg(windows)]
301    return render_shim_cmd(name, real_binary);
302}
303
304/// POSIX shell shim (Unix / macOS / Linux).
305#[cfg(not(windows))]
306fn render_shim_posix(name: &str, real_binary: Option<&Path>) -> String {
307    let fallback = match real_binary {
308        Some(path) => format!(
309            "printf 'sbox: no sbox.yaml found — running {name} unsandboxed\\n' >&2\nexec {path} \"$@\"",
310            path = path.display()
311        ),
312        None => format!(
313            "printf 'sbox shim: {name}: real binary not found; reinstall or run `sbox shim` again\\n' >&2\nexit 127"
314        ),
315    };
316
317    // Note: the ${_sbox_d%/*} shell parameter expansion is written literally here.
318    // It strips the last path component, walking up the directory tree.
319    format!(
320        "#!/bin/sh\n\
321         # sbox shim: {name}\n\
322         # Generated by `sbox shim`. Re-run `sbox shim --dir DIR` to regenerate.\n\
323         _sbox_d=\"$PWD\"\n\
324         while true; do\n\
325         \x20 if [ -f \"$_sbox_d/sbox.yaml\" ]; then\n\
326         \x20   exec sbox run -- {name} \"$@\"\n\
327         \x20 fi\n\
328         \x20 [ \"$_sbox_d\" = \"/\" ] && break\n\
329         \x20 _sbox_d=\"${{_sbox_d%/*}}\"\n\
330         \x20 [ -z \"$_sbox_d\" ] && _sbox_d=\"/\"\n\
331         done\n\
332         {fallback}\n"
333    )
334}
335
336/// Windows CMD (.cmd) shim.
337#[cfg(windows)]
338fn render_shim_cmd(name: &str, real_binary: Option<&Path>) -> String {
339    let fallback = match real_binary {
340        Some(path) => format!(
341            "echo sbox: no sbox.yaml found -- running {name} unsandboxed 1>&2\r\n\"{path}\" %*\r\nexit /b %ERRORLEVEL%",
342            path = path.display()
343        ),
344        None => format!(
345            "echo sbox shim: {name}: real binary not found; reinstall or run `sbox shim` again 1>&2\r\nexit /b 127"
346        ),
347    };
348
349    format!(
350        "@echo off\r\n\
351         :: sbox shim: {name}\r\n\
352         :: Generated by `sbox shim`. Re-run `sbox shim --dir DIR` to regenerate.\r\n\
353         setlocal enabledelayedexpansion\r\n\
354         set \"_sbox_d=%CD%\"\r\n\
355         :_sbox_walk_{name}\r\n\
356         if exist \"%_sbox_d%\\sbox.yaml\" (\r\n\
357         \x20   sbox run -- {name} %*\r\n\
358         \x20   exit /b %ERRORLEVEL%\r\n\
359         )\r\n\
360         for %%P in (\"%_sbox_d%\\..\") do set \"_sbox_parent=%%~fP\"\r\n\
361         if \"!_sbox_parent!\"==\"!_sbox_d!\" goto _sbox_fallback_{name}\r\n\
362         set \"_sbox_d=!_sbox_parent!\"\r\n\
363         goto _sbox_walk_{name}\r\n\
364         :_sbox_fallback_{name}\r\n\
365         {fallback}\r\n"
366    )
367}
368
369
370#[cfg(test)]
371mod tests {
372    use super::render_shim;
373
374    #[test]
375    fn shim_contains_sbox_run_delegation() {
376        let script = render_shim("npm", Some(std::path::Path::new("/usr/bin/npm")));
377        assert!(script.contains("sbox run -- npm"));
378        assert!(script.contains("sbox.yaml"));
379    }
380
381    #[test]
382    fn shim_fallback_when_real_binary_missing() {
383        let script = render_shim("npm", None);
384        assert!(script.contains("real binary not found"));
385        #[cfg(not(windows))]
386        assert!(script.contains("exit 127"));
387        #[cfg(windows)]
388        assert!(script.contains("exit /b 127"));
389    }
390
391    #[test]
392    fn shim_walks_to_root() {
393        let script = render_shim("uv", Some(std::path::Path::new("/usr/local/bin/uv")));
394        #[cfg(not(windows))]
395        {
396            assert!(script.contains("_sbox_d%/*"));
397            assert!(script.contains("[ \"$_sbox_d\" = \"/\" ] && break"));
398        }
399        #[cfg(windows)]
400        {
401            assert!(script.contains("_sbox_parent"));
402            assert!(script.contains("goto _sbox_walk_uv"));
403        }
404    }
405
406    /// Verify the generated .cmd script contains all the structural elements needed
407    /// to walk up the directory tree and fall back to the real binary.
408    #[cfg(windows)]
409    #[test]
410    fn cmd_shim_structure() {
411        let script =
412            render_shim("npm", Some(std::path::Path::new(r"C:\Program Files\nodejs\npm.cmd")));
413
414        // Must be a batch file
415        assert!(script.contains("@echo off"), "must suppress echo");
416
417        // Must check for sbox.yaml and delegate
418        assert!(script.contains("sbox.yaml"), "must check for sbox.yaml");
419        assert!(script.contains("sbox run -- npm %*"), "must delegate to sbox run");
420
421        // Walk-up logic: parent dir extraction and loop label
422        assert!(
423            script.contains("goto _sbox_walk_npm"),
424            "must have a labelled walk loop"
425        );
426        assert!(
427            script.contains("_sbox_parent"),
428            "must compute parent directory"
429        );
430
431        // Fallback to the real binary when no sbox.yaml is found
432        assert!(
433            script.contains(r"C:\Program Files\nodejs\npm.cmd"),
434            "must reference real binary path"
435        );
436    }
437
438    #[cfg(windows)]
439    #[test]
440    fn cmd_shim_fallback_when_no_real_binary() {
441        let script = render_shim("uv", None);
442        assert!(script.contains("real binary not found"));
443        assert!(script.contains("exit /b 127"));
444    }
445}