Skip to main content

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}