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 "bundle",
16 "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
107fn 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
119pub 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 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 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 if let Some(home) = crate::platform::home_dir() {
232 return Ok(home.join(".local").join("bin"));
233 }
234
235 std::env::current_dir().map_err(|source| SboxError::CurrentDirectory { source })
237}
238
239fn 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 #[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
275fn 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; }
289 Ok(())
290}
291
292fn 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#[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 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#[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 #[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 assert!(script.contains("@echo off"), "must suppress echo");
416
417 assert!(script.contains("sbox.yaml"), "must check for sbox.yaml");
419 assert!(script.contains("sbox run -- npm %*"), "must delegate to sbox run");
420
421 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 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}