Skip to main content

dev_prune/
pathenv.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Making the managed binaries reachable from a fresh shell, and undoing it.
5//
6// pip in a virtualenv, `npx`, `uv tool run` — every one of those puts the binary
7// somewhere that stops existing, or stops being on PATH, the moment the environment
8// closes. The managed pair under `<config>/bin` already outlives them (the scheduler
9// and the git hooks are registered against it for exactly that reason); this module
10// closes the last gap by making the *user's own shell* find that copy too.
11//
12// On Windows that means one entry in the user PATH (`HKCU\Environment`), read and
13// written as raw registry data. The obvious .NET call —
14// `[Environment]::GetEnvironmentVariable('Path','User')` — hands back the *expanded*
15// value, and writing that back bakes `%USERPROFILE%`-style entries into literal paths
16// for good; going through the registry API with `DoNotExpandEnvironmentNames`, and
17// preserving the value's `REG_EXPAND_SZ`/`REG_SZ` kind, leaves every entry exactly as
18// its owner spelled it.
19// Everywhere else it means symlinks in `~/.local/bin`, the XDG-conventional user
20// executable directory — no shell profile is ever edited, because a profile has no
21// safe "remove exactly what I added" operation and an uninstall that leaves edits
22// behind is worse than an install that asks the user to add one line.
23
24use std::path::Path;
25
26use crate::output;
27use crate::setup::Outcome;
28
29/// Whether one PATH entry names the same directory as another.
30///
31/// Windows treats `C:\x\bin` and `C:\x\bin\` as the same entry and compares without
32/// case; Unix does neither. Trailing-separator trimming is safe on both.
33pub(crate) fn entries_equal(a: &str, b: &str) -> bool {
34    let a = a.trim().trim_end_matches(['\\', '/']);
35    let b = b.trim().trim_end_matches(['\\', '/']);
36    if cfg!(windows) {
37        a.eq_ignore_ascii_case(b)
38    } else {
39        a == b
40    }
41}
42
43/// Whether `path_value` (a `;`- or `:`-joined PATH string) already contains `dir`.
44fn path_value_contains(path_value: &str, dir: &str) -> bool {
45    let sep = if cfg!(windows) { ';' } else { ':' };
46    path_value.split(sep).any(|entry| entries_equal(entry, dir))
47}
48
49/// `path_value` with every entry naming `dir` removed, or `None` when nothing matched.
50///
51/// Empty entries are dropped too — on Windows an empty PATH entry means "search the
52/// current directory", which nobody wants and which a naive join could introduce.
53// Only the Windows uninstall path rewrites a PATH string; on Unix removal is deleting
54// symlinks. The function still compiles (and is unit-tested) on both.
55#[cfg_attr(unix, allow(dead_code))]
56fn path_value_without(path_value: &str, dir: &str) -> Option<String> {
57    let sep = if cfg!(windows) { ";" } else { ":" };
58    if !path_value_contains(path_value, dir) {
59        return None;
60    }
61    Some(
62        path_value
63            .split(sep)
64            .filter(|entry| !entry.trim().is_empty() && !entries_equal(entry, dir))
65            .collect::<Vec<_>>()
66            .join(sep),
67    )
68}
69
70#[cfg(windows)]
71mod imp {
72    use super::*;
73
74    use windows_sys::Win32::Foundation::{ERROR_FILE_NOT_FOUND, ERROR_SUCCESS};
75    use windows_sys::Win32::System::Registry::{
76        HKEY, HKEY_CURRENT_USER, KEY_READ, KEY_SET_VALUE, REG_EXPAND_SZ, REG_SZ, REG_VALUE_TYPE,
77        RegCloseKey, RegOpenKeyExW, RegQueryValueExW, RegSetValueExW,
78    };
79    use windows_sys::Win32::UI::WindowsAndMessaging::{
80        HWND_BROADCAST, SMTO_ABORTIFHUNG, SendMessageTimeoutW, WM_SETTINGCHANGE,
81    };
82
83    // This module talks to the registry directly rather than driving `powershell.exe`.
84    // The script it used to run was passed as `-EncodedCommand` base64 and reached
85    // `SendMessageTimeout` through `Add-Type`/`DllImport` — an encoded PowerShell
86    // command that compiles code at runtime to P/Invoke into user32 is, feature for
87    // feature, what commodity loaders do, and every behavioural scanner scores it that
88    // way. Sophos quarantined the binary on that profile before it could run once.
89    // Nothing here needs an interpreter: these are three `advapi32` calls and one
90    // `user32` broadcast.
91
92    /// A NUL-terminated UTF-16 string, as every `*W` entry point wants one.
93    fn wide(s: &str) -> Vec<u16> {
94        s.encode_utf16().chain(std::iter::once(0)).collect()
95    }
96
97    /// An open registry key that closes itself.
98    struct Key(HKEY);
99
100    impl Drop for Key {
101        fn drop(&mut self) {
102            // SAFETY: the handle came from a successful `RegOpenKeyExW`, and a `Key` is
103            // only ever built from one, so this closes a live handle exactly once.
104            unsafe { RegCloseKey(self.0) };
105        }
106    }
107
108    /// Open `HKCU\Environment`, the key holding the *user* PATH.
109    fn open_environment(access: u32) -> Option<Key> {
110        let subkey = wide("Environment");
111        let mut hkey: HKEY = std::ptr::null_mut();
112        // SAFETY: `subkey` is NUL-terminated and outlives the call, and `hkey` is a
113        // valid out-pointer.
114        let rc = unsafe { RegOpenKeyExW(HKEY_CURRENT_USER, subkey.as_ptr(), 0, access, &mut hkey) };
115        // Built only on success: an unopened handle must not reach `Key`, whose `Drop`
116        // would close it.
117        if rc == ERROR_SUCCESS {
118            Some(Key(hkey))
119        } else {
120            None
121        }
122    }
123
124    /// Read `Path` from an open key, as `(value, kind)`.
125    ///
126    /// `RegQueryValueExW` hands back the value *raw*. The .NET call this replaced —
127    /// `[Environment]::GetEnvironmentVariable('Path','User')` — expands it first, and
128    /// writing that back bakes `%USERPROFILE%`-style entries into literal paths for
129    /// good; reading through the registry API leaves every entry as its owner spelled
130    /// it. A missing `Path` value is an empty PATH, not a failure: a profile that never
131    /// had one is a normal state.
132    fn query_path(key: &Key) -> Option<(String, REG_VALUE_TYPE)> {
133        let name = wide("Path");
134        let mut kind: REG_VALUE_TYPE = 0;
135        let mut len: u32 = 0;
136        // SAFETY: a null data pointer with a zero length asks for the size only.
137        let rc = unsafe {
138            RegQueryValueExW(
139                key.0,
140                name.as_ptr(),
141                std::ptr::null(),
142                &mut kind,
143                std::ptr::null_mut(),
144                &mut len,
145            )
146        };
147        if rc == ERROR_FILE_NOT_FOUND {
148            return Some((String::new(), REG_EXPAND_SZ));
149        }
150        if rc != ERROR_SUCCESS {
151            return None;
152        }
153
154        let mut buf = vec![0u8; len as usize];
155        // SAFETY: `buf` holds exactly the `len` bytes the sizing call asked for, and
156        // `len` is updated in place with the count actually written.
157        let rc = unsafe {
158            RegQueryValueExW(
159                key.0,
160                name.as_ptr(),
161                std::ptr::null(),
162                &mut kind,
163                buf.as_mut_ptr(),
164                &mut len,
165            )
166        };
167        if rc != ERROR_SUCCESS {
168            return None;
169        }
170        buf.truncate(len as usize);
171        Some((decode_utf16_value(&buf), kind))
172    }
173
174    /// Decode a `REG_SZ`/`REG_EXPAND_SZ` payload.
175    ///
176    /// The registry stores UTF-16 and promises neither a terminator nor only one, so
177    /// the value ends at the first NUL if there is one and at the end of the buffer if
178    /// there is not. A trailing odd byte cannot begin a code unit and is dropped.
179    fn decode_utf16_value(buf: &[u8]) -> String {
180        let units: Vec<u16> = buf
181            .as_chunks::<2>()
182            .0
183            .iter()
184            .map(|pair| u16::from_le_bytes(*pair))
185            .take_while(|&unit| unit != 0)
186            .collect();
187        String::from_utf16_lossy(&units)
188    }
189
190    fn read_user_path() -> Option<String> {
191        let key = open_environment(KEY_READ)?;
192        query_path(&key).map(|(value, _)| value)
193    }
194
195    /// Persist a new user PATH, keeping the registry value's kind — flattening
196    /// `REG_EXPAND_SZ` to `REG_SZ` would stop every `%VAR%` entry expanding — and
197    /// broadcasting `WM_SETTINGCHANGE` so Explorer and new shells pick it up, which the
198    /// registry write does not do on its own.
199    fn write_user_path(value: &str) -> bool {
200        let Some(key) = open_environment(KEY_READ | KEY_SET_VALUE) else {
201            return false;
202        };
203        // Anything not already a plain string is written back as expandable: that is
204        // what Windows itself creates `Path` as, and it is the safe way to guess.
205        let kind = match query_path(&key) {
206            Some((_, REG_SZ)) => REG_SZ,
207            _ => REG_EXPAND_SZ,
208        };
209
210        let data = wide(value);
211        let bytes = std::mem::size_of_val(data.as_slice()) as u32;
212        let name = wide("Path");
213        // SAFETY: `data` is NUL-terminated UTF-16 and `bytes` is its exact length in
214        // bytes, terminator included, which is what `RegSetValueExW` wants for a string.
215        let rc = unsafe {
216            RegSetValueExW(
217                key.0,
218                name.as_ptr(),
219                0,
220                kind,
221                data.as_ptr().cast::<u8>(),
222                bytes,
223            )
224        };
225        if rc != ERROR_SUCCESS {
226            return false;
227        }
228
229        let environment = wide("Environment");
230        let mut delivered: usize = 0;
231        // SAFETY: `environment` is NUL-terminated and outlives the call. The PATH is
232        // already written by this point, so a failed broadcast costs a stale Explorer
233        // environment until the next sign-in, never the edit itself; `SMTO_ABORTIFHUNG`
234        // keeps one wedged top-level window from stalling setup behind it.
235        unsafe {
236            SendMessageTimeoutW(
237                HWND_BROADCAST,
238                WM_SETTINGCHANGE,
239                0,
240                environment.as_ptr() as isize,
241                SMTO_ABORTIFHUNG,
242                5000,
243                &mut delivered,
244            )
245        };
246        true
247    }
248
249    pub fn ensure_reachable(bin_dir: &Path) -> Outcome {
250        let dir = bin_dir.display().to_string();
251        let Some(current) = read_user_path() else {
252            return Outcome::Failed("could not read the user PATH".to_string());
253        };
254        if path_value_contains(&current, &dir) {
255            return Outcome::AlreadyPresent;
256        }
257        let new_value = if current.trim().is_empty() {
258            dir.clone()
259        } else {
260            format!("{};{}", current.trim_end_matches(';'), dir)
261        };
262        if write_user_path(&new_value) {
263            output::print_notice(&format!(
264                "`{}` was added to your user PATH — terminals opened from now on will find `devp`.",
265                output::clean_path(bin_dir)
266            ));
267            Outcome::Installed
268        } else {
269            Outcome::Failed("could not write the user PATH".to_string())
270        }
271    }
272
273    /// Read-only: whether `bin_dir` is on the persisted user PATH.
274    pub fn is_reachable(bin_dir: &Path) -> bool {
275        read_user_path()
276            .is_some_and(|current| path_value_contains(&current, &bin_dir.display().to_string()))
277    }
278
279    /// Take the managed directory back out of the user PATH. `Ok(true)` when an entry
280    /// was actually removed.
281    pub fn remove_reachability(bin_dir: &Path) -> anyhow::Result<bool> {
282        let dir = bin_dir.display().to_string();
283        let Some(current) = read_user_path() else {
284            anyhow::bail!("could not read the user PATH");
285        };
286        let Some(new_value) = path_value_without(&current, &dir) else {
287            return Ok(false);
288        };
289        if write_user_path(&new_value) {
290            Ok(true)
291        } else {
292            anyhow::bail!("could not write the user PATH")
293        }
294    }
295
296    #[cfg(test)]
297    mod tests {
298        use super::*;
299
300        fn utf16(s: &str) -> Vec<u8> {
301            s.encode_utf16().flat_map(u16::to_le_bytes).collect()
302        }
303
304        #[test]
305        fn a_registry_string_ends_at_its_first_terminator_if_it_has_one() {
306            // No terminator at all: the whole buffer is the value.
307            assert_eq!(decode_utf16_value(&utf16(r"C:\a;C:\b")), r"C:\a;C:\b");
308
309            // One terminator, and the doubled form Windows sometimes stores.
310            let mut one = utf16(r"C:\a");
311            one.extend_from_slice(&[0, 0]);
312            assert_eq!(decode_utf16_value(&one), r"C:\a");
313            let mut two = utf16(r"C:\a");
314            two.extend_from_slice(&[0, 0, 0, 0]);
315            assert_eq!(decode_utf16_value(&two), r"C:\a");
316
317            // Anything past the terminator is not part of the value.
318            let mut trailing = utf16(r"C:\a");
319            trailing.extend_from_slice(&[0, 0]);
320            trailing.extend_from_slice(&utf16("junk"));
321            assert_eq!(decode_utf16_value(&trailing), r"C:\a");
322
323            // A dangling odd byte cannot begin a code unit.
324            let mut odd = utf16(r"C:\a");
325            odd.push(b'x');
326            assert_eq!(decode_utf16_value(&odd), r"C:\a");
327
328            assert_eq!(decode_utf16_value(&[]), "");
329        }
330
331        /// A `%VAR%` entry has to survive the round trip unexpanded: expanding it and
332        /// writing it back is what bakes one user's home directory into another's PATH.
333        #[test]
334        fn an_unexpanded_entry_is_read_back_verbatim() {
335            let raw = r"%USERPROFILE%\bin;C:\Windows\System32";
336            assert_eq!(decode_utf16_value(&utf16(raw)), raw);
337        }
338
339        /// Non-ASCII is why this is decoded as UTF-16 rather than taken as bytes: a
340        /// console codepage would have mangled it, which is what the old PowerShell
341        /// reader had to work around.
342        #[test]
343        fn a_non_ascii_entry_survives_decoding() {
344            let raw = r"C:\Users\Müller\bin;C:\Users\日本\bin";
345            assert_eq!(decode_utf16_value(&utf16(raw)), raw);
346        }
347
348        /// The registry plumbing end to end: open, size, read, decode. Read-only, so it
349        /// is safe on a real machine — it asserts nothing about that machine's own PATH,
350        /// only that reading it succeeds.
351        #[test]
352        fn the_user_path_can_be_read_from_the_registry() {
353            assert!(read_user_path().is_some(), "could not read the user PATH");
354        }
355    }
356}
357
358#[cfg(unix)]
359mod imp {
360    use super::*;
361    use std::fs;
362
363    fn local_bin() -> Option<std::path::PathBuf> {
364        Some(dirs::home_dir()?.join(".local").join("bin"))
365    }
366
367    pub fn ensure_reachable(bin_dir: &Path) -> Outcome {
368        let Some(local_bin) = local_bin() else {
369            return Outcome::Skipped("could not determine the home directory".to_string());
370        };
371        if fs::create_dir_all(&local_bin).is_err() {
372            return Outcome::Failed(format!(
373                "could not create {}",
374                output::clean_path(&local_bin)
375            ));
376        }
377
378        let mut created_any = false;
379        for name in ["dev-prune", "devp"] {
380            let link = local_bin.join(name);
381            let target = bin_dir.join(name);
382            match fs::read_link(&link) {
383                Ok(existing) if existing == target => continue,
384                Ok(existing) if existing.starts_with(bin_dir) => {
385                    // Our own link, pointing at a name that moved. Repoint it.
386                    let _ = fs::remove_file(&link);
387                }
388                Ok(_) => continue, // someone else's link — leave it, it resolves
389                Err(_) if link.exists() => continue, // a real file the user put there
390                Err(_) => {}
391            }
392            if std::os::unix::fs::symlink(&target, &link).is_ok() {
393                created_any = true;
394            }
395        }
396
397        let on_path = std::env::var("PATH")
398            .map(|p| path_value_contains(&p, &local_bin.display().to_string()))
399            .unwrap_or(false);
400        if !on_path {
401            return Outcome::Skipped(format!(
402                "linked into `{}`, which is not on your PATH — add it in your shell profile",
403                output::clean_path(&local_bin)
404            ));
405        }
406        if created_any {
407            Outcome::Installed
408        } else {
409            Outcome::AlreadyPresent
410        }
411    }
412
413    /// Read-only: whether the `~/.local/bin` links exist and point into `bin_dir`.
414    pub fn is_reachable(bin_dir: &Path) -> bool {
415        local_bin().is_some_and(|local_bin| {
416            fs::read_link(local_bin.join("devp")).is_ok_and(|target| target.starts_with(bin_dir))
417        })
418    }
419
420    /// Remove the `~/.local/bin` links, but only the ones that point into `bin_dir` —
421    /// a binary the user placed there themselves is theirs.
422    pub fn remove_reachability(bin_dir: &Path) -> anyhow::Result<bool> {
423        let Some(local_bin) = local_bin() else {
424            return Ok(false);
425        };
426        let mut removed_any = false;
427        for name in ["dev-prune", "devp"] {
428            let link = local_bin.join(name);
429            if let Ok(target) = fs::read_link(&link)
430                && target.starts_with(bin_dir)
431            {
432                fs::remove_file(&link)?;
433                removed_any = true;
434            }
435        }
436        Ok(removed_any)
437    }
438}
439
440pub use imp::{ensure_reachable, is_reachable, remove_reachability};
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    #[test]
447    fn a_path_entry_matches_with_and_without_a_trailing_separator() {
448        if cfg!(windows) {
449            assert!(path_value_contains(r"C:\a;C:\x\bin\;C:\b", r"C:\x\bin"));
450            assert!(path_value_contains(r"c:\X\BIN", r"C:\x\bin"));
451            assert!(!path_value_contains(r"C:\x\binx", r"C:\x\bin"));
452        } else {
453            assert!(path_value_contains("/a:/x/bin/:/b", "/x/bin"));
454            assert!(!path_value_contains("/x/BIN", "/x/bin"));
455            assert!(!path_value_contains("/x/binx", "/x/bin"));
456        }
457    }
458
459    #[test]
460    fn removal_strips_the_entry_and_reports_no_change_when_absent() {
461        if cfg!(windows) {
462            assert_eq!(
463                path_value_without(r"C:\a;C:\x\bin;C:\b", r"C:\x\bin"),
464                Some(r"C:\a;C:\b".to_string())
465            );
466            assert_eq!(path_value_without(r"C:\a;C:\b", r"C:\x\bin"), None);
467            // Empty entries mean "search the current directory" on Windows; a removal
468            // must never leave one behind.
469            assert_eq!(
470                path_value_without(r"C:\a;;C:\x\bin", r"C:\x\bin"),
471                Some(r"C:\a".to_string())
472            );
473        } else {
474            assert_eq!(
475                path_value_without("/a:/x/bin:/b", "/x/bin"),
476                Some("/a:/b".to_string())
477            );
478            assert_eq!(path_value_without("/a:/b", "/x/bin"), None);
479        }
480    }
481}