Skip to content

Commit 87c268a

Browse files
committed
Don’t run binaries that just happen to be in PATH
We only want our own managed crates.io installed binaries thanks.
1 parent c60966f commit 87c268a

9 files changed

Lines changed: 90 additions & 207 deletions

File tree

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,6 @@ You can override the default by setting:
9393
for already-installed binaries.
9494

9595
2. **Binary lookup is restricted to**:
96-
- Binaries already on your `PATH` (via `which`)
9796
- The `cargox` install directory only
9897

9998
3. **Environment isolation**: When installing packages, `cargox` removes all

TESTING.md

Lines changed: 6 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -50,29 +50,15 @@ Verifies that when no override is set, we use platform-appropriate XDG directori
5050

5151
### 3. Directory Isolation Tests
5252

53-
These tests ensure we don't check or use standard Cargo/system directories:
53+
Directory isolation is enforced by refusing to execute binaries outside the sandboxed install directory (see the Execution Guard tests below).
5454

55-
#### `candidate_bin_dirs_only_checks_cargox_dir`
56-
**Critical sandboxing test**: Verifies that:
57-
- Only 2 directories are checked (our install dir and install dir/bin)
58-
- No standard directories like `~/.cargo/bin` are included
59-
- All checked directories contain "cargox" in the path
55+
### 4. Execution Guard Tests
6056

61-
#### `candidate_bin_dirs_ignores_cargo_env_vars`
62-
**Critical sandboxing test**: Verifies that even when Cargo-related environment variables are set, they are completely ignored:
63-
- `CARGO_INSTALL_ROOT` - ignored
64-
- `CARGO_HOME` - ignored
65-
- `BINSTALL_INSTALL_PATH` - ignored
57+
#### `allows_binaries_inside_install_dir`
58+
Unit test in `executor.rs` that ensures binaries located inside the sandboxed install directory are allowed to run.
6659

67-
This ensures complete isolation from the user's Cargo configuration.
68-
69-
### 4. Binary Resolution Tests
70-
71-
#### `resolve_binary_path_checks_which_first`
72-
Verifies that we check `PATH` first via the `which` crate, allowing users to override with binaries already in their PATH.
73-
74-
#### `resolve_binary_path_falls_back_to_cargox_dir`
75-
Verifies that after checking PATH, we fall back to our sandboxed directory.
60+
#### `rejects_binaries_outside_install_dir`
61+
Unit test in `executor.rs` that ensures we refuse to execute binaries that live outside the sandboxed install directory, preventing delegation to system-wide paths.
7662

7763
### 5. Environment Sanitization Tests
7864

@@ -88,11 +74,6 @@ The function removes these variables before calling `cargo install` or `cargo-bi
8874
- `RUSTUP_HOME`
8975
- `RUSTUP_TOOLCHAIN`
9076

91-
### 6. Override Tests
92-
93-
#### `cargox_install_dir_override_is_respected`
94-
Verifies that users can override the install directory with `CARGOX_INSTALL_DIR` and that the override is properly respected.
95-
9677
## Running Tests
9778

9879
```bash

src/cli.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ use clap::Parser;
33
use std::env;
44
use std::ffi::OsString;
55

6-
/// Run Cargo binaries on demand
6+
/// Run Cargo binaries on demand, installing them via `cargo-binstall` when missing.
77
#[derive(Parser, Debug)]
8-
#[command(name = "cargox", author, version, about = "Run Cargo binaries on demand", long_about = None, arg_required_else_help = true)]
8+
#[command(name = "cargox", author, version, about, long_about = None, arg_required_else_help = true)]
99
pub struct Cli {
1010
/// Crate to run, optionally suffixed with `@version`
1111
#[arg(value_name = "crate[@version]")]

src/executor.rs

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1-
use anyhow::{Context, Result};
1+
use crate::paths::get_install_dir;
2+
use anyhow::{Context, Result, anyhow};
23
use std::ffi::OsString;
34
use std::path::Path;
45
use std::process::{Command, ExitStatus};
56

67
pub fn execute_binary(binary_path: &Path, args: &[OsString]) -> Result<ExitStatus> {
8+
ensure_within_install_dir(binary_path)?;
9+
710
let mut cmd = Command::new(binary_path);
811
cmd.args(args);
912

@@ -13,3 +16,59 @@ pub fn execute_binary(binary_path: &Path, args: &[OsString]) -> Result<ExitStatu
1316

1417
Ok(status)
1518
}
19+
20+
fn ensure_within_install_dir(binary_path: &Path) -> Result<()> {
21+
let install_dir = get_install_dir()?;
22+
ensure_binary_within_dir(binary_path, &install_dir)
23+
}
24+
25+
fn ensure_binary_within_dir(binary_path: &Path, install_dir: &Path) -> Result<()> {
26+
let install_dir = install_dir
27+
.canonicalize()
28+
.with_context(|| format!("failed to canonicalize {}", install_dir.display()))?;
29+
let binary = binary_path.canonicalize().with_context(|| {
30+
format!(
31+
"failed to canonicalize binary path {}",
32+
binary_path.display()
33+
)
34+
})?;
35+
36+
if !binary.starts_with(&install_dir) {
37+
return Err(anyhow!(
38+
"refusing to execute binary outside install dir: {}",
39+
binary.display()
40+
));
41+
}
42+
43+
Ok(())
44+
}
45+
46+
#[cfg(test)]
47+
mod tests {
48+
use super::*;
49+
use std::fs;
50+
use tempfile::tempdir;
51+
52+
#[test]
53+
fn allows_binaries_inside_install_dir() {
54+
let temp = tempdir().unwrap();
55+
let bin_dir = temp.path().join("bin");
56+
fs::create_dir_all(&bin_dir).unwrap();
57+
let binary_path = bin_dir.join("tool");
58+
fs::write(&binary_path, b"#!/bin/sh\n").unwrap();
59+
60+
let result = ensure_binary_within_dir(&binary_path, temp.path());
61+
assert!(result.is_ok());
62+
}
63+
64+
#[test]
65+
fn rejects_binaries_outside_install_dir() {
66+
let temp = tempdir().unwrap();
67+
let outside = tempdir().unwrap();
68+
let binary_path = outside.path().join("tool");
69+
fs::write(&binary_path, b"#!/bin/sh\n").unwrap();
70+
71+
let result = ensure_binary_within_dir(&binary_path, temp.path());
72+
assert!(result.is_err());
73+
}
74+
}

src/main.rs

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ mod installer;
44
mod paths;
55
mod registry;
66
mod target;
7+
#[cfg(test)]
8+
mod test_support;
79
mod versions;
810

911
use std::path::PathBuf;
@@ -15,14 +17,12 @@ use semver::{Version, VersionReq};
1517
use cli::Cli;
1618
use executor::execute_binary;
1719
use installer::ensure_installed;
18-
use paths::resolve_binary_path;
1920
use registry::{fetch_highest_matching_version, fetch_latest_version};
2021
use target::{Target, VersionSpec, parse_spec};
2122
use versions::{find_installed_version, latest_installed, versioned_binary_path};
2223

2324
enum RunPlan {
2425
UseInstalled { path: PathBuf },
25-
UseSystem { path: PathBuf },
2626
InstallAndRun { version: Version },
2727
}
2828

@@ -71,10 +71,6 @@ fn resolve_unspecified(target: &Target, cli: &Cli) -> Result<RunPlan> {
7171
path: installed.path,
7272
});
7373
}
74-
75-
if let Ok(path) = resolve_binary_path(&target.binary) {
76-
return Ok(RunPlan::UseSystem { path });
77-
}
7874
}
7975

8076
let version = fetch_latest_version(&target.crate_name)?;
@@ -116,7 +112,6 @@ fn resolve_requirement(target: &Target, cli: &Cli, requirement: &VersionReq) ->
116112
fn execute_plan(plan: &RunPlan, target: &Target, cli: &Cli) -> Result<ExitStatus> {
117113
match plan {
118114
RunPlan::UseInstalled { path } => execute_binary(path, &cli.args),
119-
RunPlan::UseSystem { path } => execute_binary(path, &cli.args),
120115
RunPlan::InstallAndRun { version } => {
121116
ensure_installed(target, cli, version)?;
122117
let binary_path = versioned_binary_path(&target.binary, version)?;

src/paths.rs

Lines changed: 4 additions & 159 deletions
Original file line numberDiff line numberDiff line change
@@ -33,40 +33,6 @@ pub fn get_install_dir() -> Result<PathBuf> {
3333
Err(anyhow!("unable to determine install directory"))
3434
}
3535

36-
pub fn resolve_binary_path(name: &str) -> Result<PathBuf> {
37-
if let Ok(path) = which::which(name) {
38-
return Ok(path);
39-
}
40-
41-
for dir in candidate_bin_dirs() {
42-
let candidate = dir.join(name);
43-
if candidate.exists() {
44-
return Ok(candidate);
45-
}
46-
#[cfg(windows)]
47-
{
48-
let exe_candidate = candidate.with_extension("exe");
49-
if exe_candidate.exists() {
50-
return Ok(exe_candidate);
51-
}
52-
}
53-
}
54-
55-
Err(anyhow!("cannot find binary path"))
56-
}
57-
58-
fn candidate_bin_dirs() -> Vec<PathBuf> {
59-
let mut dirs = Vec::new();
60-
61-
// Only check our sandboxed install directory
62-
if let Ok(install_dir) = get_install_dir() {
63-
dirs.push(install_dir.join("bin"));
64-
dirs.push(install_dir);
65-
}
66-
67-
dirs
68-
}
69-
7036
fn home_dir() -> Option<PathBuf> {
7137
env::var_os("HOME")
7238
.or_else(|| env::var_os("USERPROFILE"))
@@ -76,9 +42,11 @@ fn home_dir() -> Option<PathBuf> {
7642
#[cfg(test)]
7743
mod tests {
7844
use super::*;
45+
use crate::test_support::env_lock;
7946

8047
#[test]
8148
fn get_install_dir_respects_cargox_install_dir() {
49+
let _guard = env_lock().lock().unwrap();
8250
let temp = tempfile::tempdir().unwrap();
8351
let custom_path = temp.path().to_path_buf();
8452

@@ -95,6 +63,7 @@ mod tests {
9563

9664
#[test]
9765
fn get_install_dir_ignores_cargo_install_root() {
66+
let _guard = env_lock().lock().unwrap();
9867
let temp = tempfile::tempdir().unwrap();
9968
let cargo_root = temp.path().join("cargo_root");
10069

@@ -114,6 +83,7 @@ mod tests {
11483

11584
#[test]
11685
fn get_install_dir_uses_xdg_directories() {
86+
let _guard = env_lock().lock().unwrap();
11787
// Clear any override env vars
11888
unsafe {
11989
env::remove_var("CARGOX_INSTALL_DIR");
@@ -134,129 +104,4 @@ mod tests {
134104
#[cfg(target_os = "windows")]
135105
assert!(result.to_string_lossy().contains("AppData"));
136106
}
137-
138-
#[test]
139-
fn candidate_bin_dirs_only_checks_cargox_dir() {
140-
// Clear any override env vars
141-
unsafe {
142-
env::remove_var("CARGOX_INSTALL_DIR");
143-
}
144-
145-
let dirs = candidate_bin_dirs();
146-
147-
// Should only have 2 entries: install_dir/bin and install_dir
148-
assert_eq!(dirs.len(), 2);
149-
150-
// Both should contain "cargox"
151-
for dir in &dirs {
152-
assert!(dir.to_string_lossy().contains("cargox"));
153-
}
154-
155-
// Should NOT contain any standard cargo directories
156-
let dir_strings: Vec<String> = dirs
157-
.iter()
158-
.map(|d| d.to_string_lossy().to_string())
159-
.collect();
160-
161-
for dir_str in &dir_strings {
162-
assert!(
163-
!dir_str.contains("/.cargo/bin"),
164-
"Should not check ~/.cargo/bin, found: {dir_str}"
165-
);
166-
assert!(
167-
!dir_str.contains("\\.cargo\\bin"),
168-
"Should not check ~/.cargo/bin, found: {dir_str}"
169-
);
170-
}
171-
}
172-
173-
#[test]
174-
fn candidate_bin_dirs_ignores_cargo_env_vars() {
175-
let temp = tempfile::tempdir().unwrap();
176-
177-
// Set various cargo-related env vars
178-
unsafe {
179-
env::set_var("CARGO_INSTALL_ROOT", temp.path().join("cargo_root"));
180-
env::set_var("CARGO_HOME", temp.path().join("cargo_home"));
181-
env::set_var("BINSTALL_INSTALL_PATH", temp.path().join("binstall"));
182-
}
183-
184-
let dirs = candidate_bin_dirs();
185-
186-
// Clean up
187-
unsafe {
188-
env::remove_var("CARGO_INSTALL_ROOT");
189-
env::remove_var("CARGO_HOME");
190-
env::remove_var("BINSTALL_INSTALL_PATH");
191-
}
192-
193-
// None of the candidate dirs should match the env var paths
194-
let dir_strings: Vec<String> = dirs
195-
.iter()
196-
.map(|d| d.to_string_lossy().to_string())
197-
.collect();
198-
199-
for dir_str in &dir_strings {
200-
assert!(
201-
!dir_str.contains("cargo_root"),
202-
"Should ignore CARGO_INSTALL_ROOT, found: {dir_str}"
203-
);
204-
assert!(
205-
!dir_str.contains("cargo_home"),
206-
"Should ignore CARGO_HOME, found: {dir_str}"
207-
);
208-
assert!(
209-
!dir_str.contains("binstall"),
210-
"Should ignore BINSTALL_INSTALL_PATH, found: {dir_str}"
211-
);
212-
}
213-
}
214-
215-
#[test]
216-
fn resolve_binary_path_checks_which_first() {
217-
// This test verifies that we check PATH first via which
218-
// We use a binary that's definitely on PATH (like "ls" on Unix or "cmd" on Windows)
219-
#[cfg(unix)]
220-
let result = resolve_binary_path("ls");
221-
222-
#[cfg(windows)]
223-
let result = resolve_binary_path("cmd");
224-
225-
// Should find the system binary via which
226-
assert!(result.is_ok());
227-
}
228-
229-
#[test]
230-
fn resolve_binary_path_falls_back_to_cargox_dir() {
231-
// Clear any override env vars
232-
unsafe {
233-
env::remove_var("CARGOX_INSTALL_DIR");
234-
}
235-
236-
// Try to find a binary that doesn't exist
237-
let result = resolve_binary_path("this_binary_definitely_does_not_exist_12345");
238-
239-
// Should fail because it's not in PATH and not in our install dir
240-
assert!(result.is_err());
241-
}
242-
243-
#[test]
244-
fn cargox_install_dir_override_is_respected() {
245-
let temp = tempfile::tempdir().unwrap();
246-
let custom_dir = temp.path().join("my_custom_cargox");
247-
248-
unsafe {
249-
env::set_var("CARGOX_INSTALL_DIR", &custom_dir);
250-
}
251-
252-
let dirs = candidate_bin_dirs();
253-
254-
unsafe {
255-
env::remove_var("CARGOX_INSTALL_DIR");
256-
}
257-
258-
// Should use the custom directory
259-
assert!(dirs.iter().any(|d| d == &custom_dir.join("bin")));
260-
assert!(dirs.iter().any(|d| d == &custom_dir));
261-
}
262107
}

src/test_support.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
use std::sync::{Mutex, OnceLock};
2+
3+
pub fn env_lock() -> &'static Mutex<()> {
4+
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
5+
LOCK.get_or_init(|| Mutex::new(()))
6+
}

0 commit comments

Comments
 (0)