1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::ExitCode;
4
5use crate::cli::ShimCommand;
6use crate::error::SboxError;
7
8const SHIM_TARGETS: &[&str] = &[
13 "npm", "npx", "pnpm", "yarn", "bun", "uv", "pip", "pip3", "poetry", "cargo", "composer",
15 "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
102fn 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 if let Some(home) = crate::platform::home_dir() {
128 return Ok(home.join(".local").join("bin"));
129 }
130
131 std::env::current_dir().map_err(|source| SboxError::CurrentDirectory { source })
133}
134
135fn 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 #[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
171fn 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; }
185 Ok(())
186}
187
188fn 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#[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 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#[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 #[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 assert!(script.contains("@echo off"), "must suppress echo");
312
313 assert!(script.contains("sbox.yaml"), "must check for sbox.yaml");
315 assert!(script.contains("sbox run -- npm %*"), "must delegate to sbox run");
316
317 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 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}