1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
//! Invariant: `resolve_binary` rejects non-executable files found in PATH.
//!
//! Mutates `PATH`; serialized against other env-mutating tests via
//! `ENV_MUTEX` and restored via [`EnvVarGuard`].
use std::sync::{Mutex, PoisonError};
use envseal::error::Error;
use envseal::policy::resolve_binary;
static ENV_MUTEX: Mutex<()> = Mutex::new(());
struct EnvVarGuard {
key: &'static str,
previous: Option<std::ffi::OsString>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
let previous = std::env::var_os(key);
std::env::set_var(key, value);
Self { key, previous }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match self.previous.take() {
Some(v) => std::env::set_var(self.key, v),
None => std::env::remove_var(self.key),
}
}
}
#[test]
fn resolve_binary_rejects_non_executable() {
let _env_lock = ENV_MUTEX.lock().unwrap_or_else(PoisonError::into_inner);
// `tempfile::tempdir()` puts files under the OS tmp dir, which on
// some kernels is mode 1777 (world-writable). Use the crate manifest
// directory as the base — never o+w — to stay stable across sandboxes.
let base = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let tmp = tempfile::Builder::new()
.prefix("path-resolve-")
.tempdir_in(base)
.expect("create temp dir for PATH test");
let path = tmp.path().join("fake-tool");
std::fs::write(&path, "#!/bin/sh\necho hi\n").expect("write test command candidate");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644))
.expect("make candidate non-executable");
}
let _path_guard = EnvVarGuard::set("PATH", tmp.path().display().to_string());
let err = resolve_binary("fake-tool")
.expect_err("non-executable files in PATH must never be resolved as runnable");
// The security invariant we're pinning: a non-executable file in
// PATH must NEVER be resolved as runnable. Two acceptable failure
// shapes prove that property:
// - "not executable" — resolve_binary found the candidate, saw
// the missing X bit, and refused.
// - "binary not found in PATH" — resolve_binary's PATH walker
// filtered the candidate up-front (Linux walkers that pre-
// filter on the X bit do this; Windows does it because the
// candidate has no `.exe`/`.bat`/etc extension).
// Both close the attack surface; the test must accept either on
// every platform. (Earlier this file required the first error
// shape on Unix, which broke on Linux walkers that pre-filter.)
assert!(
matches!(err, Error::BinaryResolution(ref msg)
if msg.contains("not executable") || msg.contains("binary not found in PATH")),
"expected non-executable / not-found rejection, got: {err:?}"
);
}