Skip to main content

rightkit_child_process/
login_shell.rs

1//! The user's login-shell PATH and selected environment values, for an app launched from the
2//! desktop (Finder, the Dock, a launch agent) that inherits only the session's minimal
3//! environment. Unix only: on Windows every function returns nothing and runs nothing.
4//!
5//! The shell is `$SHELL` when it is an absolute, existing file, else `/bin/zsh`, else `/bin/sh`.
6//! It runs as `shell -lic <script>` (login and interactive, so the profile files are read) under
7//! [`run`](crate::run) with stdin closed, a hard deadline (the whole tree is killed at the
8//! deadline, so a profile that waits for a terminal cannot hang the caller) and a capped output.
9//! A timeout, a failure to start, truncated output, or a missing marker returns nothing.
10//!
11//! Environment values are secrets. They are returned to the caller only, never printed, logged
12//! or placed on a command line (the names are checked to be plain identifiers before they go
13//! into the script).
14
15use std::time::Duration;
16
17#[cfg(unix)]
18use crate::{run, RunOptions};
19
20#[cfg(unix)]
21const PATH_BEGIN: &str = "__RIGHTKIT_PATH_BEGIN__";
22#[cfg(unix)]
23const PATH_END: &str = "__RIGHTKIT_PATH_END__";
24#[cfg(unix)]
25const ENV_BEGIN: &str = "__RIGHTKIT_ENV_BEGIN__";
26#[cfg(unix)]
27const ENV_END: &str = "__RIGHTKIT_ENV_END__";
28/// Most bytes read from the shell's output; more than this is treated as incomplete.
29#[cfg(unix)]
30const OUTPUT_CAP: usize = 256 * 1024;
31
32/// The login shell's `PATH`, or `None` when it cannot be read within `timeout`.
33pub fn path(timeout: Duration) -> Option<String> {
34    #[cfg(unix)]
35    {
36        let script =
37            format!("printf '%s' '{PATH_BEGIN}'; printf '%s' \"$PATH\"; printf '%s' '{PATH_END}'");
38        let output = run(shell(), ["-lic", script.as_str()], &capped(timeout)).ok()?;
39        if output.timed_out || output.stdout.truncated {
40            return None;
41        }
42        let text = String::from_utf8_lossy(&output.stdout.bytes);
43        let (_, rest) = text.split_once(PATH_BEGIN)?;
44        let (value, _) = rest.split_once(PATH_END)?;
45        (!value.is_empty()).then(|| value.to_string())
46    }
47    #[cfg(not(unix))]
48    {
49        let _ = timeout;
50        None
51    }
52}
53
54/// The login shell's values of the named environment variables, as `(name, value)` pairs for the
55/// names that are set and non-empty there. Names that are not plain identifiers are skipped, and
56/// nothing is returned when the shell cannot answer within `timeout`. The values are secrets:
57/// the caller must not log them.
58pub fn env_values(names: &[String], timeout: Duration) -> Vec<(String, String)> {
59    #[cfg(unix)]
60    {
61        let wanted: Vec<&str> = names
62            .iter()
63            .map(String::as_str)
64            .filter(|name| is_identifier(name))
65            .collect();
66        if wanted.is_empty() {
67            return Vec::new();
68        }
69        let mut script = format!("printf '%s' '{ENV_BEGIN}'");
70        for name in &wanted {
71            // Each value is followed by a NUL, so an empty value keeps its place in the list.
72            script.push_str(&format!("; printf '%s\\0' \"${{{name}-}}\""));
73        }
74        script.push_str(&format!("; printf '%s' '{ENV_END}'"));
75        let Ok(output) = run(shell(), ["-lic", script.as_str()], &capped(timeout)) else {
76            return Vec::new();
77        };
78        if output.timed_out || output.stdout.truncated {
79            return Vec::new();
80        }
81        let text = String::from_utf8_lossy(&output.stdout.bytes);
82        let Some((_, rest)) = text.split_once(ENV_BEGIN) else {
83            return Vec::new();
84        };
85        let Some((body, _)) = rest.split_once(ENV_END) else {
86            return Vec::new();
87        };
88        body.split('\0')
89            .zip(wanted)
90            .filter(|(value, _)| !value.is_empty())
91            .map(|(value, name)| (name.to_string(), value.to_string()))
92            .collect()
93    }
94    #[cfg(not(unix))]
95    {
96        let _ = (names, timeout);
97        Vec::new()
98    }
99}
100
101#[cfg(unix)]
102fn capped(timeout: Duration) -> RunOptions {
103    RunOptions::new(timeout)
104        .stdout_cap(OUTPUT_CAP)
105        .stderr_cap(0)
106        .no_window(true)
107}
108
109/// `$SHELL` when it is an absolute path to an existing file with no control characters, else
110/// `/bin/zsh`, else `/bin/sh`.
111#[cfg(unix)]
112fn shell() -> std::path::PathBuf {
113    use std::path::PathBuf;
114    let from_env = std::env::var_os("SHELL")
115        .map(PathBuf::from)
116        .filter(|shell| {
117            shell.is_absolute()
118                && shell.is_file()
119                && shell
120                    .to_str()
121                    .is_some_and(|text| !text.chars().any(char::is_control))
122        });
123    from_env.unwrap_or_else(|| {
124        ["/bin/zsh", "/bin/sh"]
125            .iter()
126            .map(PathBuf::from)
127            .find(|candidate| candidate.is_file())
128            .unwrap_or_else(|| PathBuf::from("/bin/sh"))
129    })
130}
131
132#[cfg(unix)]
133fn is_identifier(name: &str) -> bool {
134    let mut chars = name.chars();
135    chars
136        .next()
137        .is_some_and(|first| first.is_ascii_alphabetic() || first == '_')
138        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
139}
140
141#[cfg(all(test, unix))]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn only_plain_identifiers_reach_the_script() {
147        assert!(is_identifier("PATH"));
148        assert!(is_identifier("_X1"));
149        assert!(!is_identifier("1X"));
150        assert!(!is_identifier("A;rm"));
151        assert!(!is_identifier("A B"));
152        assert!(!is_identifier(""));
153    }
154
155    #[test]
156    fn a_nonexistent_shell_falls_back_to_a_real_one() {
157        let shell = shell();
158        assert!(shell.is_absolute());
159        assert!(shell.is_file());
160    }
161
162    #[test]
163    fn env_values_skips_invalid_names_and_returns_nothing_for_none_valid() {
164        assert!(env_values(&["not valid".to_string()], Duration::from_secs(1)).is_empty());
165    }
166}