Skip to main content

corundum_framework_package/
cli_path.rs

1//! The app's command line on `PATH`.
2//!
3//! Windows puts the package's `current` directory on the user `Path` from the install hook
4//! and takes it off from the uninstall hook. macOS links `/usr/local/bin/<command>` to the
5//! binary in the `.app`, with administrator rights asked through `osascript`; Linux links
6//! `~/.local/bin/<command>` to the AppImage. Both are made and removed when the user asks.
7//! An update keeps the binary's path, so nothing is made again after one.
8
9use std::ffi::OsStr;
10use std::path::{Path, PathBuf};
11
12use crate::error::Error;
13
14#[cfg(unix)]
15mod unix;
16#[cfg(windows)]
17mod windows;
18
19/// The command line a package puts on `PATH`.
20#[derive(Clone, Copy, Debug)]
21pub struct CliSpec {
22    /// The name typed in a terminal: `sapphire-journal`. It need not be the cargo binary's
23    /// name. On macOS and Linux the command links to the running executable, whatever
24    /// `vpk --mainExe` named it; on Windows it is the console shim's file name without
25    /// `.exe`, and the shim sits beside the GUI executable in `current\`.
26    pub command: &'static str,
27}
28
29/// Whether the command line is on `PATH`.
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum CliStatus {
32    /// On `PATH`, running this package.
33    Installed,
34    /// Not on `PATH`.
35    Missing,
36    /// A link whose target is gone: the `.app` or the AppImage was deleted or moved.
37    Broken,
38    /// Another program has the name, left alone: a file that is not a link (a
39    /// command-line-only install of the same app), or a link to another file (Homebrew's,
40    /// pipx's).
41    Taken,
42    /// Outside a package.
43    Unsupported,
44}
45
46/// Whether the command line is on `PATH`.
47pub fn status(spec: &CliSpec) -> CliStatus {
48    #[cfg(windows)]
49    return windows::status(spec);
50    #[cfg(unix)]
51    return unix::status(spec);
52}
53
54/// Puts the command line on `PATH`. Never replaces another program's: a file that is not a
55/// link, or a link to another file ([`CliStatus::Taken`]).
56pub fn install(spec: &CliSpec) -> Result<(), Error> {
57    #[cfg(windows)]
58    return windows::install(spec);
59    #[cfg(unix)]
60    return unix::install(spec);
61}
62
63/// Takes the command line off `PATH`. Nothing to take off is not an error.
64pub fn uninstall(spec: &CliSpec) -> Result<(), Error> {
65    #[cfg(windows)]
66    return windows::uninstall(spec);
67    #[cfg(unix)]
68    return unix::uninstall(spec);
69}
70
71/// Whether `dir` is on this process's `PATH`: Linux's `~/.local/bin` is not on every
72/// distribution's.
73pub fn on_path(dir: &Path) -> bool {
74    on_path_in(dir, &std::env::var_os("PATH").unwrap_or_default())
75}
76
77/// Whether `dir` is on the `PATH` a terminal gets: the one the user's login shell sets up,
78/// or `None` when the shell does not answer within [`LOGIN_PATH_WAIT`]. A window started from
79/// the desktop's menu often has a shorter `PATH` than a terminal, since shells add
80/// `~/.local/bin` in their own start files, so [`on_path`] would warn about a directory every
81/// terminal has; and a busy machine can take seconds to start a shell, so a missing answer
82/// is not a "no". It blocks while the shell starts: call it off the UI thread.
83#[cfg(unix)]
84pub fn on_login_path(dir: &Path) -> Option<bool> {
85    login_path().map(|path_var| on_path_in(dir, &path_var))
86}
87
88/// How long [`on_login_path`] waits for the shell: an interactive shell's start files can
89/// take several seconds on a machine busy with its disk.
90#[cfg(unix)]
91pub const LOGIN_PATH_WAIT: std::time::Duration = std::time::Duration::from_secs(20);
92
93/// What a terminal's `PATH` would be: asked of `$SHELL` as a login, interactive shell, which
94/// reads the start files a terminal does. Its output is searched for a marker, since start
95/// files may print greetings of their own.
96#[cfg(unix)]
97fn login_path() -> Option<std::ffi::OsString> {
98    use std::io::Read;
99    use std::process::{Command, Stdio};
100    use std::time::{Duration, Instant};
101
102    let shell = std::env::var_os("SHELL").unwrap_or_else(|| "/bin/sh".into());
103    let mut child = Command::new(shell)
104        .args(["-l", "-i", "-c", r#"printf '\n%s%s\n' "$1" "$PATH""#, "sh"])
105        .arg(LOGIN_PATH_MARKER)
106        .stdin(Stdio::null())
107        .stdout(Stdio::piped())
108        .stderr(Stdio::null())
109        .spawn()
110        .ok()?;
111    let deadline = Instant::now() + LOGIN_PATH_WAIT;
112    loop {
113        match child.try_wait() {
114            Ok(Some(_)) => break,
115            Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(20)),
116            _ => {
117                let _ = child.kill();
118                let _ = child.wait();
119                return None;
120            }
121        }
122    }
123    let mut out = Vec::new();
124    child.stdout.take()?.read_to_end(&mut out).ok()?;
125    parse_login_path(&out)
126}
127
128/// Put before `$PATH` in the shell's output, so a greeting from a start file is not taken
129/// for it.
130#[cfg(unix)]
131const LOGIN_PATH_MARKER: &str = "corundum-login-path:";
132
133/// The `PATH` after the last marker in a login shell's output.
134#[cfg(unix)]
135fn parse_login_path(out: &[u8]) -> Option<std::ffi::OsString> {
136    use std::os::unix::ffi::OsStringExt;
137
138    let marker = LOGIN_PATH_MARKER.as_bytes();
139    let start = out
140        .windows(marker.len())
141        .rposition(|window| window == marker)?
142        + marker.len();
143    let line = out[start..].split(|&b| b == b'\n').next()?;
144    (!line.is_empty()).then(|| std::ffi::OsString::from_vec(line.to_vec()))
145}
146
147/// [`on_path`] for a given `PATH` value.
148pub(crate) fn on_path_in(dir: &Path, path_var: &OsStr) -> bool {
149    let dir = clean(dir);
150    std::env::split_paths(path_var)
151        .filter(|entry| !entry.as_os_str().is_empty())
152        .any(|entry| clean(&entry) == dir)
153}
154
155/// `path` as it names a directory: its canonical form, or, when it does not exist, its
156/// components without `.` and a trailing separator.
157fn clean(path: &Path) -> PathBuf {
158    path.canonicalize()
159        .unwrap_or_else(|_| path.components().collect())
160}
161
162/// The user `Path` value with `dir` added at the end, or `None` when it is already there.
163/// The other entries are kept as they are, a trailing `;` too.
164#[cfg_attr(not(windows), allow(dead_code))]
165pub(crate) fn path_with(value: &str, dir: &str) -> Option<String> {
166    if value.split(';').any(|entry| same_entry(entry, dir)) {
167        return None;
168    }
169    Some(if value.is_empty() {
170        dir.to_owned()
171    } else if let Some(rest) = value.strip_suffix(';') {
172        format!("{rest};{dir};")
173    } else {
174        format!("{value};{dir}")
175    })
176}
177
178/// The user `Path` value with every entry naming `dir` removed, or `None` when there is
179/// none. The other entries are kept as they are, empty ones too.
180#[cfg_attr(not(windows), allow(dead_code))]
181pub(crate) fn path_without(value: &str, dir: &str) -> Option<String> {
182    let entries: Vec<&str> = value.split(';').collect();
183    let kept: Vec<&str> = entries
184        .iter()
185        .copied()
186        .filter(|entry| !same_entry(entry, dir))
187        .collect();
188    (kept.len() != entries.len()).then(|| kept.join(";"))
189}
190
191/// Whether a `Path` entry names `dir`: Windows paths ignore case, and an entry may be
192/// quoted or end with a `\`. An empty entry names nothing.
193fn same_entry(entry: &str, dir: &str) -> bool {
194    let norm = |s: &str| {
195        let s = s
196            .strip_prefix('"')
197            .and_then(|s| s.strip_suffix('"'))
198            .unwrap_or(s);
199        s.strip_suffix('\\').unwrap_or(s).to_lowercase()
200    };
201    !entry.is_empty() && norm(entry) == norm(dir)
202}
203
204#[cfg(test)]
205mod tests {
206    use std::ffi::OsString;
207
208    use super::*;
209
210    const DIR: &str = "C:\\x\\current";
211
212    #[cfg(unix)]
213    #[test]
214    fn the_login_path_is_read_after_the_marker() {
215        let out = b"Welcome back!\ncorundum-login-path: in a greeting\n\ncorundum-login-path:/home/u/.local/bin:/usr/bin\n";
216        assert_eq!(
217            parse_login_path(out),
218            Some(OsString::from("/home/u/.local/bin:/usr/bin"))
219        );
220        assert_eq!(parse_login_path(b"no marker here\n"), None);
221        assert_eq!(parse_login_path(b"\ncorundum-login-path:\n"), None);
222    }
223
224    #[cfg(unix)]
225    #[test]
226    fn the_login_shell_is_asked_for_its_path() {
227        // This test process's shell sees at least the directories its own `PATH` has.
228        let dir = tempfile::tempdir().unwrap();
229        assert_ne!(on_login_path(dir.path()), Some(true));
230        assert_ne!(on_login_path(Path::new("/usr/bin")), Some(false));
231    }
232
233    #[test]
234    fn a_dir_is_added_at_the_end_of_a_path_without_it() {
235        let cases: &[(&str, Option<&str>)] = &[
236            ("C:\\a;C:\\b", Some("C:\\a;C:\\b;C:\\x\\current")),
237            ("", Some("C:\\x\\current")),
238            ("C:\\a;", Some("C:\\a;C:\\x\\current;")),
239            ("C:\\a;;C:\\b", Some("C:\\a;;C:\\b;C:\\x\\current")),
240            ("C:\\a;C:\\X\\CURRENT\\", None),
241            ("\"C:\\x\\current\";C:\\b", None),
242            ("C:\\x\\current", None),
243        ];
244        for (value, want) in cases {
245            assert_eq!(path_with(value, DIR).as_deref(), *want, "{value:?}");
246        }
247    }
248
249    #[test]
250    fn a_dir_is_removed_from_a_path_keeping_the_other_entries() {
251        let cases: &[(&str, Option<&str>)] = &[
252            (
253                "%USERPROFILE%\\bin;C:\\x\\current;C:\\b",
254                Some("%USERPROFILE%\\bin;C:\\b"),
255            ),
256            ("C:\\a;C:\\x\\current;", Some("C:\\a;")),
257            ("C:\\a;;C:\\X\\Current\\;C:\\b", Some("C:\\a;;C:\\b")),
258            ("\"C:\\x\\current\"", Some("")),
259            ("C:\\x\\current;C:\\a;C:\\x\\current", Some("C:\\a")),
260            ("C:\\a;C:\\b", None),
261            ("", None),
262            ("C:\\x\\current2", None),
263        ];
264        for (value, want) in cases {
265            assert_eq!(path_without(value, DIR).as_deref(), *want, "{value:?}");
266        }
267    }
268
269    #[test]
270    fn a_dir_is_on_a_path_naming_it_with_or_without_a_trailing_separator() {
271        let tmp = tempfile::tempdir().unwrap();
272        let dir = tmp.path().join("bin");
273        std::fs::create_dir(&dir).unwrap();
274        let other = tmp.path().join("other");
275        let sep = std::path::MAIN_SEPARATOR;
276        let path_of = |entries: &[PathBuf]| -> OsString { std::env::join_paths(entries).unwrap() };
277
278        assert!(on_path_in(&dir, &path_of(&[other.clone(), dir.clone()])));
279        let trailing = PathBuf::from(format!("{}{sep}", dir.display()));
280        assert!(on_path_in(&dir, &path_of(&[trailing])));
281        let dotted = tmp.path().join(".").join("bin");
282        assert!(on_path_in(&dir, &path_of(&[dotted])));
283        assert!(!on_path_in(&dir, &path_of(&[other])));
284        assert!(!on_path_in(&dir, OsStr::new("")));
285    }
286}