Skip to main content

running_process_platform_internal/platform_linux/
host.rs

1//! Linux host facts, directories, user identity, resources, and autostart.
2
3use std::ffi::OsString;
4use std::io;
5use std::path::Path;
6
7/// A privileged system identity this process may be running as.
8///
9/// The variants name the *answer*, not the mechanism: what a caller does with
10/// "this process is the machine's system account" does not change with how the
11/// host was asked. `None` from [`current_process_privilege`] means an ordinary
12/// user, which is the only case most callers care to distinguish.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum PrivilegedIdentity {
15    /// Unix effective UID 0.
16    UnixRoot,
17    /// Windows LocalSystem account (`S-1-5-18`).
18    WindowsLocalSystem,
19}
20
21impl std::fmt::Display for PrivilegedIdentity {
22    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23        match self {
24            Self::UnixRoot => f.write_str("root (effective uid 0)"),
25            Self::WindowsLocalSystem => f.write_str("Windows LocalSystem (S-1-5-18)"),
26        }
27    }
28}
29
30/// The privileged identity this process is running as, if any.
31pub fn current_process_privilege() -> io::Result<Option<PrivilegedIdentity>> {
32    Ok(privilege_from_effective_uid(unsafe { libc::geteuid() }))
33}
34
35/// Root is effective uid 0, and an ordinary uid is not root.
36///
37/// Kept as a pure function so the rule stays testable without being able to
38/// change this process's identity.
39fn privilege_from_effective_uid(euid: libc::uid_t) -> Option<PrivilegedIdentity> {
40    (euid == 0).then_some(PrivilegedIdentity::UnixRoot)
41}
42
43
44/// A stable identity for this user on this machine.
45///
46/// The uid alone is not enough -- two machines both have a uid 1000 -- so it
47/// is paired with a machine-scoped id. Callers hash this; they do not parse it.
48pub fn user_machine_identity() -> io::Result<String> {
49    let uid = unsafe { libc::getuid() };
50    let machine_id = crate::platform::host::machine_id_from(&MACHINE_ID_PATHS, BOOT_ID_PATH)?;
51    Ok(format!("{uid}:{machine_id}"))
52}
53
54// ---------------------------------------------------------------------------
55// Host identity facts
56// ---------------------------------------------------------------------------
57
58/// This machine's name as the host reports it.
59pub fn hostname() -> Option<String> {
60    let mut buf = [0_u8; 256];
61    // SAFETY: `buf` is writable for its full length, which is what is passed
62    // as the bound. The kernel NUL-terminates within it on success.
63    let ok = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) };
64    if ok != 0 {
65        return None;
66    }
67    let nul = buf.iter().position(|b| *b == 0).unwrap_or(buf.len());
68    let name = String::from_utf8_lossy(&buf[..nul]).into_owned();
69    (!name.is_empty()).then_some(name)
70}
71
72/// The filesystem device this path lives on.
73pub fn filesystem_device_id(path: &Path) -> Option<u64> {
74    use std::os::unix::fs::MetadataExt;
75
76    std::fs::metadata(path).ok().map(|meta| meta.dev())
77}
78
79/// A durable per-machine identifier that survives reboots.
80///
81/// `/etc/machine-id` is the systemd-era answer and `/var/lib/dbus/machine-id`
82/// the older one; a host with neither falls back to a boot-scoped id, which is
83/// weaker but still never shared between machines.
84///
85/// Deliberately *not* [`crate::platform::host::machine_id_from`], which treats
86/// an unreadable machine-id file as a hard error. That strictness exists to
87/// stop one user's processes deriving two different identities and each
88/// believing it is the singleton. A fact reported to a caller that is only
89/// comparing machines has the opposite preference: keep looking, and answer
90/// with the best id available.
91pub fn machine_id() -> Option<String> {
92    MACHINE_ID_PATHS
93        .iter()
94        .find_map(|path| read_trimmed(path))
95        .or_else(|| read_trimmed(BOOT_ID_PATH).map(|id| format!("boot:{id}")))
96}
97
98/// An identifier that changes on every boot of this machine.
99pub fn boot_id() -> Option<String> {
100    read_trimmed(BOOT_ID_PATH)
101}
102
103/// The mount and PID namespaces this process is in.
104///
105/// Two processes on the same machine in the same boot can still be in
106/// different containers, and then they share neither a filesystem view nor a
107/// PID space. That is a real identity difference, so it is reported as one.
108pub fn namespace_id() -> Option<String> {
109    let mnt = read_link_lossy("/proc/self/ns/mnt").unwrap_or_else(|| "mntns:unknown".to_string());
110    let pid = read_link_lossy("/proc/self/ns/pid").unwrap_or_else(|| "pidns:unknown".to_string());
111    Some(format!("{mnt}:{pid}"))
112}
113
114const MACHINE_ID_PATHS: [&str; 2] = ["/etc/machine-id", "/var/lib/dbus/machine-id"];
115const BOOT_ID_PATH: &str = "/proc/sys/kernel/random/boot_id";
116
117fn read_trimmed(path: &str) -> Option<String> {
118    std::fs::read_to_string(path)
119        .ok()
120        .map(|s| s.trim().to_string())
121        .filter(|s| !s.is_empty())
122}
123
124fn read_link_lossy(path: &str) -> Option<String> {
125    std::fs::read_link(path)
126        .ok()
127        .map(|p| p.to_string_lossy().into_owned())
128}
129
130// ---------------------------------------------------------------------------
131// Login environment
132// ---------------------------------------------------------------------------
133
134/// The logged-in user's environment, as a fresh login would see it.
135///
136/// Unix has no API that reconstructs a login environment, so this is rebuilt
137/// from the user's identity rather than copied from this process: `getpwuid_r`
138/// supplies `USER`/`LOGNAME`/`HOME`/`SHELL`, `PATH` gets this host's login
139/// default, and the session-describing variables are carried over.
140///
141/// A user with no resolvable passwd entry -- a uid absent from NSS -- has no
142/// identity to rebuild from, so the current process environment is returned
143/// instead. That is a worse answer than a real login environment and a much
144/// better one than nothing.
145pub fn login_environment() -> io::Result<Vec<(OsString, OsString)>> {
146    Ok(passwd_login_environment().unwrap_or_else(|| std::env::vars_os().collect()))
147}
148
149/// Unix environment variable names compare byte for byte.
150pub fn environment_keys_are_case_insensitive() -> bool {
151    false
152}
153
154/// Build the login environment from the passwd entry, or `None` when there is
155/// no entry to build it from.
156fn passwd_login_environment() -> Option<Vec<(OsString, OsString)>> {
157    use std::ffi::CStr;
158    use std::os::unix::ffi::OsStringExt;
159
160    // SAFETY: an all-zero `passwd` is a valid one; `getpwuid_r` fills it.
161    let mut passwd: libc::passwd = unsafe { std::mem::zeroed() };
162    let mut result: *mut libc::passwd = std::ptr::null_mut();
163    // sysconf(_SC_GETPW_R_SIZE_MAX) is allowed to return -1 ("no limit");
164    // 1 KiB covers real-world passwd entries and getpwuid_r reports ERANGE
165    // if it does not, in which case we grow and retry.
166    let mut buf = vec![0u8; 1024];
167    loop {
168        // SAFETY: `passwd`, `buf`, and `result` are all live and writable for
169        // the sizes handed over, and `buf.len()` is the buffer's real length.
170        let rc = unsafe {
171            libc::getpwuid_r(
172                libc::getuid(),
173                &mut passwd,
174                buf.as_mut_ptr().cast(),
175                buf.len(),
176                &mut result,
177            )
178        };
179        if rc == libc::ERANGE && buf.len() < 1 << 20 {
180            buf.resize(buf.len() * 2, 0);
181            continue;
182        }
183        if rc != 0 || result.is_null() {
184            return None;
185        }
186        break;
187    }
188
189    let field = |ptr: *const libc::c_char| -> Option<OsString> {
190        if ptr.is_null() {
191            return None;
192        }
193        // SAFETY: a non-null passwd field points at a NUL-terminated string
194        // inside `buf`, which outlives this read.
195        let bytes = unsafe { CStr::from_ptr(ptr) }.to_bytes();
196        (!bytes.is_empty()).then(|| OsString::from_vec(bytes.to_vec()))
197    };
198    let name = field(passwd.pw_name)?;
199    let home = field(passwd.pw_dir)?;
200
201    let mut env: Vec<(OsString, OsString)> = vec![
202        (OsString::from("USER"), name.clone()),
203        (OsString::from("LOGNAME"), name),
204        (OsString::from("HOME"), home),
205        (OsString::from("PATH"), OsString::from(LOGIN_DEFAULT_PATH)),
206    ];
207    if let Some(shell) = field(passwd.pw_shell) {
208        env.push((OsString::from("SHELL"), shell));
209    }
210    env.extend(carried_session_variables());
211    Some(env)
212}
213
214/// Variables that describe the login *session* rather than this process.
215///
216/// Locale, timezone, and the per-user runtime/tmp dirs are set by the login
217/// session (PAM/logind), not by `getpwuid_r` or by profile scripts, so a
218/// reconstructed baseline can only obtain them by carrying them over. Children
219/// then keep rendering text and resolving paths the way the user does.
220///
221/// `XDG_RUNTIME_DIR` and `TMPDIR` are the runtime-dir variables the broker's
222/// own endpoint placement keys on. Dropping `XDG_RUNTIME_DIR` made a daemon
223/// fall back to `/tmp` while its session-resident clients dialled
224/// `$XDG_RUNTIME_DIR/…` -- every request then missed the socket
225/// (zackees/soldr#2442).
226fn carried_session_variables() -> Vec<(OsString, OsString)> {
227    std::env::vars_os()
228        .filter(|(key, _)| describes_the_login_session(key))
229        .collect()
230}
231
232fn describes_the_login_session(key: &OsString) -> bool {
233    key == "LANG"
234        || key == "TZ"
235        || key == "TMPDIR"
236        || key == "XDG_RUNTIME_DIR"
237        || key.to_str().is_some_and(|k| k.starts_with("LC_"))
238}
239
240/// The `PATH` a fresh login starts from: the customary `login(1)` default.
241const LOGIN_DEFAULT_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
242
243/// The login environment in the double-NUL-terminated UTF-16 block form.
244///
245/// This shape exists because `CreateProcessW` consumes it on Windows. It is
246/// provided on every host so the facade has one signature rather than one per
247/// host, and because the encoding is the same data either way -- a caller
248/// driving a Windows API through a cross-platform code path should not have to
249/// choose between a `cfg` and a hand-rolled encoder.
250pub fn login_environment_block() -> io::Result<Vec<u16>> {
251    Ok(encode_environment_block(&login_environment()?))
252}
253
254/// Encode `key=value` pairs as one double-NUL-terminated UTF-16 block.
255///
256/// Unix environment strings are bytes, not UTF-16, so a name or value that is
257/// not valid UTF-8 is encoded lossily. That is a real narrowing and it is the
258/// block format's, not this function's: the format has no way to carry a byte
259/// that is not a character.
260fn encode_environment_block(entries: &[(OsString, OsString)]) -> Vec<u16> {
261    let mut block = Vec::new();
262    for (key, value) in entries {
263        let entry = format!("{}={}", key.to_string_lossy(), value.to_string_lossy());
264        block.extend(entry.encode_utf16());
265        block.push(0);
266    }
267    // An empty environment is still a block: a lone terminator, never zero
268    // bytes, so a consumer reading the shape finds the end where it expects to.
269    block.push(0);
270    block
271}
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn root_detection_uses_effective_uid_zero() {
278        assert_eq!(
279            privilege_from_effective_uid(0),
280            Some(PrivilegedIdentity::UnixRoot)
281        );
282        assert_eq!(privilege_from_effective_uid(1000), None);
283    }
284
285    /// The three string facts either answer or say they cannot; an empty
286    /// string is never a valid answer, because a caller comparing two hosts
287    /// would read two empties as a match.
288    #[test]
289    fn host_identity_facts_are_never_empty_strings() {
290        let facts = [hostname(), machine_id(), boot_id(), namespace_id()];
291        for value in facts.into_iter().flatten() {
292            assert!(!value.is_empty(), "a reported fact must carry a value");
293        }
294    }
295
296    /// This host has a name and a machine id, whatever they turn out to be.
297    #[test]
298    fn this_host_reports_a_name_and_a_machine_id() {
299        assert!(hostname().is_some(), "a running host has a name");
300        assert!(machine_id().is_some(), "a running host has a machine id");
301    }
302
303    /// The device id is a property of the volume, so a path and its parent
304    /// answer alike, and a directory that exists always answers.
305    #[test]
306    fn filesystem_device_id_answers_for_an_existing_directory() {
307        let cwd = std::env::current_dir().expect("cwd");
308        let dev = filesystem_device_id(&cwd).expect("an existing directory has a device");
309        assert_eq!(filesystem_device_id(&cwd), Some(dev), "stable across reads");
310    }
311
312    /// A path that does not exist has no device to report. Unlike the volume
313    /// probe on Windows, there is nothing to walk up to here: the caller asked
314    /// about a path, and the honest answer is that the host does not know.
315    #[test]
316    fn filesystem_device_id_declines_a_missing_path() {
317        let missing = std::env::temp_dir().join(format!(
318            "rp-host-absent-{}-{:?}",
319            std::process::id(),
320            std::thread::current().id()
321        ));
322        assert_eq!(filesystem_device_id(&missing), None);
323    }
324
325    /// The reconstructed login environment carries the identity the passwd
326    /// entry supplies, and a `PATH` to start from.
327    #[test]
328    fn login_environment_contains_identity_and_default_path() {
329        let env = login_environment().unwrap();
330        let get = |name: &str| {
331            env.iter()
332                .find(|(key, _)| key == name)
333                .map(|(_, value)| value.clone())
334        };
335        let user = get("USER").expect("baseline must contain USER");
336        assert!(!user.is_empty());
337        assert_eq!(get("LOGNAME").as_ref(), Some(&user));
338        assert!(!get("HOME").expect("baseline must contain HOME").is_empty());
339        assert!(!get("PATH").expect("baseline must contain PATH").is_empty());
340    }
341
342    /// A variable that exists only in this process must not survive into the
343    /// login baseline -- carrying everything is what `Inherit` is for.
344    #[test]
345    fn login_environment_does_not_leak_arbitrary_process_vars() {
346        std::env::set_var("RUNNING_PROCESS_BASELINE_CANARY", "1");
347        let env = passwd_login_environment().expect("test user must have a passwd entry");
348        std::env::remove_var("RUNNING_PROCESS_BASELINE_CANARY");
349        assert!(
350            !env.iter()
351                .any(|(key, _)| key == "RUNNING_PROCESS_BASELINE_CANARY"),
352            "process-local variables must not leak into the login baseline"
353        );
354    }
355
356    /// The broker keys its socket path on `XDG_RUNTIME_DIR`. A baseline that
357    /// drops it makes a daemon bind under `/tmp` while its session-resident
358    /// clients dial `$XDG_RUNTIME_DIR/…`, stranding every request
359    /// (zackees/soldr#2442).
360    #[test]
361    fn login_environment_carries_xdg_runtime_dir() {
362        std::env::set_var("XDG_RUNTIME_DIR", "/run/user/4242");
363        let env = passwd_login_environment().expect("test user must have a passwd entry");
364        let carried = env
365            .iter()
366            .find(|(key, _)| key == "XDG_RUNTIME_DIR")
367            .map(|(_, value)| value.clone());
368        std::env::remove_var("XDG_RUNTIME_DIR");
369        assert_eq!(
370            carried.as_deref(),
371            Some(std::ffi::OsStr::new("/run/user/4242")),
372            "login baseline must carry XDG_RUNTIME_DIR when the session sets it"
373        );
374    }
375
376    /// The carry rule is what separates a session variable from a process one,
377    /// so it is asserted directly rather than only through a live environment.
378    #[test]
379    fn only_session_describing_variables_are_carried() {
380        for carried in ["LANG", "TZ", "TMPDIR", "XDG_RUNTIME_DIR", "LC_ALL", "LC_TIME"] {
381            assert!(
382                describes_the_login_session(&OsString::from(carried)),
383                "{carried} describes the login session"
384            );
385        }
386        for dropped in ["PWD", "OLDPWD", "SSH_AUTH_SOCK", "LCD_BRIGHTNESS", "L"] {
387            assert!(
388                !describes_the_login_session(&OsString::from(dropped)),
389                "{dropped} belongs to this process, not the session"
390            );
391        }
392    }
393
394    /// The block always ends where a consumer looks for the end, including
395    /// when there is nothing in it.
396    #[test]
397    fn an_encoded_block_is_double_nul_terminated() {
398        let live = login_environment_block().expect("this host has a login environment");
399        assert!(live.len() >= 2);
400        assert_eq!(&live[live.len() - 2..], &[0, 0]);
401
402        let empty = encode_environment_block(&[]);
403        assert_eq!(empty, vec![0]);
404    }
405
406    /// Every variable survives the encoding, in order.
407    #[test]
408    fn an_encoded_block_carries_every_entry_in_order() {
409        let block = encode_environment_block(&[
410            (OsString::from("FIRST"), OsString::from("one")),
411            (OsString::from("SECOND"), OsString::from("two")),
412        ]);
413        let text = String::from_utf16_lossy(&block);
414        let entries: Vec<&str> = text.split('').filter(|s| !s.is_empty()).collect();
415        assert_eq!(entries, vec!["FIRST=one", "SECOND=two"]);
416    }
417}