varve_core/shadow.rs
1//! PATH shadowing (REQ-SHADOW-001) — does the name actually reach our binary?
2//!
3//! varve's headline claim, in the README, is `varve which synth # which binary
4//! runs here`. It was not checking. With a distro-packaged tool, a
5//! `cargo install`ed one, or a stale shim directory earlier in PATH, `which`
6//! printed the store path, `verify` reported the layer perfect, and the shell
7//! ran something else. Each answer was individually correct and the composite
8//! was false (varve#66).
9//!
10//! The layer really is intact in that situation — so this is not a signature
11//! problem and no amount of re-verification finds it. The gap is between what
12//! is SIGNED and what will actually EXECUTE, which is the gap varve exists to
13//! close.
14//!
15//! Resolution here follows the same rules a shell uses: PATH order,
16//! left-to-right, first executable regular file wins. Builtins, aliases and
17//! shell functions are deliberately out of scope — varve cannot see another
18//! process's shell state, and pretending otherwise would produce a check that
19//! is wrong in a new direction.
20
21use std::ffi::OsStr;
22use std::path::{Path, PathBuf};
23
24/// What PATH does with a tool name, relative to the path varve dispatches.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum Shadowing {
27 /// PATH resolves this name to the binary varve dispatches. The claim holds.
28 Agrees,
29 /// PATH resolves it to something else — the shell runs `found`, not `ours`.
30 Shadowed { found: PathBuf },
31 /// The name is not on PATH at all. NOT shadowing: no shims installed yet
32 /// is a different condition from shims overridden, and treating them alike
33 /// would cry wolf on every fresh install.
34 NotOnPath,
35}
36
37impl Shadowing {
38 pub fn is_shadowed(&self) -> bool {
39 matches!(self, Shadowing::Shadowed { .. })
40 }
41}
42
43/// Resolve `name` against a PATH value, as a shell would.
44///
45/// Split out from the environment so it is testable without mutating the
46/// process's own PATH — a test that sets `std::env::set_var("PATH", …)` races
47/// every other test in the binary.
48pub fn resolve_in(path_var: Option<&OsStr>, name: &str) -> Option<PathBuf> {
49 // A name containing a separator is a path, not a PATH lookup — a shell
50 // does not search for `./rivet`, and neither do we.
51 if name.is_empty() || name.contains('/') || name.contains('\\') {
52 return None;
53 }
54 let path_var = path_var?;
55 for dir in std::env::split_paths(path_var) {
56 // POSIX: an empty PATH element means the current directory. Honour it
57 // so the answer matches the shell's, however unwise the setting is.
58 let dir = if dir.as_os_str().is_empty() {
59 PathBuf::from(".")
60 } else {
61 dir
62 };
63 let candidate = dir.join(name);
64 if is_executable_file(&candidate) {
65 return Some(candidate);
66 }
67 }
68 None
69}
70
71#[cfg(unix)]
72fn is_executable_file(p: &Path) -> bool {
73 use std::os::unix::fs::PermissionsExt;
74 match std::fs::metadata(p) {
75 // `metadata` follows symlinks, which is what we want: a shim is a
76 // symlink and a shell will happily execute through it.
77 Ok(m) => m.is_file() && m.permissions().mode() & 0o111 != 0,
78 Err(_) => false,
79 }
80}
81
82#[cfg(not(unix))]
83fn is_executable_file(p: &Path) -> bool {
84 // On Windows the executable bit does not exist; presence of the file under
85 // one of the PATHEXT-ish names is the practical test, and varve installs
86 // copies rather than symlinks there.
87 p.is_file()
88}
89
90/// Compare what varve dispatches against what PATH would run.
91///
92/// `dispatcher` is varve's OWN executable. This is not an optimisation — it is
93/// the whole correctness of the check. A varve shim is a symlink to the varve
94/// binary, not to the pinned tool: running `rivet` executes varve, which reads
95/// argv[0] and dispatches the pinned binary (the argv[0] mechanism that
96/// replaced the old shell scripts in v0.19.0). So a correctly installed shim
97/// canonicalises to varve, NOT to the tool, and a naive path comparison flags
98/// every properly configured machine as shadowed.
99///
100/// An earlier draft did exactly that, and its unit test passed because the
101/// test symlinked to the STORE — testing a mechanism varve does not use. The
102/// end-to-end run caught it.
103pub fn check(
104 path_var: Option<&OsStr>,
105 name: &str,
106 ours: &Path,
107 dispatcher: Option<&Path>,
108) -> Shadowing {
109 let Some(found) = resolve_in(path_var, name) else {
110 return Shadowing::NotOnPath;
111 };
112 let real = found.canonicalize().unwrap_or_else(|_| found.clone());
113
114 // The pinned binary reached directly.
115 if let Ok(b) = ours.canonicalize()
116 && real == b
117 {
118 return Shadowing::Agrees;
119 }
120 // …or reached through our own shim, which is the supported route.
121 if let Some(d) = dispatcher
122 && let Ok(d) = d.canonicalize()
123 && real == d
124 {
125 return Shadowing::Agrees;
126 }
127 Shadowing::Shadowed { found }
128}
129
130/// The user-facing report, carrying its fix (clause 4).
131pub fn describe(name: &str, ours: &Path, found: &Path) -> String {
132 format!(
133 "`{name}` on your PATH is {found}, not the pinned {ours}.\n\
134 Your shell will run the first one; varve dispatches the second, so \
135 `varve which` and `varve run` disagree with what you get by typing \
136 `{name}`.\n\
137 Fix: run `varve shim install` and put the shim directory FIRST on \
138 PATH (`. \"$VARVE_ROOT/env\"`, default ~/.varve/env), or remove the \
139 earlier entry.",
140 found = found.display(),
141 ours = ours.display(),
142 )
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148 use std::ffi::OsString;
149
150 /// A directory holding an executable of the given name.
151 fn bin_dir(name: &str, body: &str) -> tempfile::TempDir {
152 let tmp = tempfile::tempdir().unwrap();
153 let p = tmp.path().join(name);
154 std::fs::write(&p, body).unwrap();
155 #[cfg(unix)]
156 {
157 use std::os::unix::fs::PermissionsExt;
158 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
159 }
160 tmp
161 }
162
163 fn path_of(dirs: &[&Path]) -> OsString {
164 std::env::join_paths(dirs.iter().map(|d| d.to_path_buf())).unwrap()
165 }
166
167 // rivet: verifies REQ-SHADOW-001
168 #[test]
169 fn a_different_binary_earlier_on_path_is_reported_as_shadowing() {
170 // THE reported bug (varve#66). `varve which rivet` printed the store
171 // path while the shell ran something else, and `verify` called the
172 // layer perfect — which it was. Nothing looked wrong anywhere.
173 let ours_dir = bin_dir("rivet", "#!/bin/sh\necho pinned\n");
174 let other = bin_dir("rivet", "#!/bin/sh\necho WRONG\n");
175 let ours = ours_dir.path().join("rivet");
176
177 let path = path_of(&[other.path(), ours_dir.path()]);
178 let verdict = check(Some(&path), "rivet", &ours, None);
179 match &verdict {
180 Shadowing::Shadowed { found } => {
181 assert_eq!(found, &other.path().join("rivet"), "names the winner");
182 }
183 other => panic!("expected Shadowed, got {other:?}"),
184 }
185 assert!(verdict.is_shadowed());
186
187 // Clause 4: the report must be actionable.
188 let msg = describe("rivet", &ours, &other.path().join("rivet"));
189 assert!(msg.contains("rivet"), "names the tool: {msg}");
190 assert!(msg.contains("varve shim install"), "carries its fix: {msg}");
191 assert!(
192 msg.contains("FIRST on PATH"),
193 "says WHERE the shim must go, not merely to install it: {msg}"
194 );
195 }
196
197 // rivet: verifies REQ-SHADOW-001
198 #[test]
199 fn our_own_binary_first_on_path_agrees() {
200 // The correct configuration must not be reported as a conflict, or the
201 // check is noise and gets switched off.
202 let ours_dir = bin_dir("rivet", "#!/bin/sh\n");
203 let other = bin_dir("rivet", "#!/bin/sh\n");
204 let ours = ours_dir.path().join("rivet");
205 let path = path_of(&[ours_dir.path(), other.path()]);
206 assert_eq!(check(Some(&path), "rivet", &ours, None), Shadowing::Agrees);
207 }
208
209 // rivet: verifies REQ-SHADOW-001
210 #[test]
211 fn a_real_shim_symlinked_to_varve_itself_agrees() {
212 // The shim mechanism as it ACTUALLY ships: `rivet` is a symlink to the
213 // varve binary, and varve reads argv[0] to decide what to dispatch. It
214 // therefore canonicalises to varve, never to the tool.
215 //
216 // An earlier version of this test symlinked to the STORE and passed,
217 // testing a mechanism varve does not use; the correctly-configured
218 // machine then failed end to end with `verify` reporting every single
219 // tool as shadowed. Flagging the right configuration is worse than not
220 // checking, because it trains people to ignore the check.
221 #[cfg(unix)]
222 {
223 let store = bin_dir("rivet", "#!/bin/sh\n");
224 let varve_dir = bin_dir("varve", "#!/bin/sh\n");
225 let dispatcher = varve_dir.path().join("varve");
226 let shims = tempfile::tempdir().unwrap();
227 std::os::unix::fs::symlink(&dispatcher, shims.path().join("rivet")).unwrap();
228
229 let ours = store.path().join("rivet");
230 let path = path_of(&[shims.path()]);
231 assert_eq!(
232 check(Some(&path), "rivet", &ours, Some(&dispatcher)),
233 Shadowing::Agrees,
234 "a shim is a symlink to VARVE, not to the tool — it must agree"
235 );
236 // …and without knowing the dispatcher, the same layout looks like
237 // shadowing, which is precisely the bug that shipped.
238 assert!(
239 check(Some(&path), "rivet", &ours, None).is_shadowed(),
240 "this asserts WHY the dispatcher argument exists"
241 );
242 }
243 }
244
245 // rivet: verifies REQ-SHADOW-001
246 #[test]
247 fn a_tool_absent_from_path_is_not_shadowed() {
248 // Clause 5. Shims not installed is a DIFFERENT condition from shims
249 // overridden. Reporting the first as the second would make every fresh
250 // install look compromised.
251 let ours_dir = bin_dir("rivet", "#!/bin/sh\n");
252 let empty = tempfile::tempdir().unwrap();
253 let path = path_of(&[empty.path()]);
254 assert_eq!(
255 check(Some(&path), "rivet", &ours_dir.path().join("rivet"), None),
256 Shadowing::NotOnPath
257 );
258 // …and with no PATH at all.
259 assert_eq!(
260 check(None, "rivet", &ours_dir.path().join("rivet"), None),
261 Shadowing::NotOnPath
262 );
263 }
264
265 // rivet: verifies REQ-SHADOW-001
266 #[test]
267 fn resolution_follows_path_order_and_the_executable_bit() {
268 // Clause 1: same rules as a shell. A non-executable file of the right
269 // name must not win — a shell skips it, and a check that stops there
270 // would report a phantom conflict against a README or a directory.
271 let first = tempfile::tempdir().unwrap();
272 std::fs::write(first.path().join("rivet"), "not executable").unwrap();
273 std::fs::create_dir(first.path().join("also")).unwrap();
274 let second = bin_dir("rivet", "#!/bin/sh\n");
275
276 let path = path_of(&[first.path(), second.path()]);
277 assert_eq!(
278 resolve_in(Some(&path), "rivet"),
279 Some(second.path().join("rivet")),
280 "a non-executable file of the same name is skipped, as a shell skips it"
281 );
282
283 // A directory named like the tool is not the tool either.
284 let dir_named = tempfile::tempdir().unwrap();
285 std::fs::create_dir(dir_named.path().join("rivet")).unwrap();
286 let path2 = path_of(&[dir_named.path(), second.path()]);
287 assert_eq!(
288 resolve_in(Some(&path2), "rivet"),
289 Some(second.path().join("rivet"))
290 );
291 }
292
293 // rivet: verifies REQ-SHADOW-001
294 #[test]
295 fn a_name_with_a_separator_is_not_a_path_lookup() {
296 // A shell does not search PATH for `./rivet` or `bin/rivet`, and
297 // neither may we — otherwise a relative name would be resolved against
298 // every PATH entry and could match something unrelated.
299 let d = bin_dir("rivet", "#!/bin/sh\n");
300 let path = path_of(&[d.path()]);
301 assert_eq!(resolve_in(Some(&path), "./rivet"), None);
302 assert_eq!(resolve_in(Some(&path), "bin/rivet"), None);
303 assert_eq!(resolve_in(Some(&path), ""), None);
304 }
305}