From aa176f3ce0e5643578a7acc29ff2eb6190bde67a Mon Sep 17 00:00:00 2001 From: mattsu Date: Sat, 27 Jun 2026 16:42:45 +0900 Subject: [PATCH] utmpx: fall back to systemd-logind when utmp is unavailable On Linux, try systemd-logind when the default utmp file is missing or empty. Load libsystemd at runtime so systems without it can still use traditional utmp, and remove the opt-in feature. Read the boot time from /proc/stat instead of the random-seed timestamp. --- .github/workflows/freebsd.yml | 3 +- .github/workflows/openbsd.yml | 3 +- .../workspace.wordlist.txt | 3 + Cargo.lock | 1 + Cargo.toml | 9 +- src/uu/pinky/Cargo.toml | 3 - src/uu/uptime/Cargo.toml | 3 - src/uu/users/Cargo.toml | 3 - src/uu/who/Cargo.toml | 3 - src/uucore/Cargo.toml | 4 +- src/uucore/src/lib/features.rs | 2 +- src/uucore/src/lib/features/systemd_logind.rs | 202 +++++++++++----- src/uucore/src/lib/features/utmpx.rs | 228 ++++++++++++++---- src/uucore/src/lib/lib.rs | 2 +- 14 files changed, 341 insertions(+), 128 deletions(-) diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 95a80c29893..8a4f3c02a37 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -183,9 +183,8 @@ jobs: export RUST_BACKTRACE=1 export CARGO_TERM_COLOR=always if (test -z "\$FAULT"); then cargo nextest run --hide-progress-bar --profile ci --features '${{ matrix.job.features }}' || FAULT=1 ; fi - # There is no systemd-logind on FreeBSD, so test all features except feat_systemd_logind ( https://github.com/rust-lang/cargo/issues/3126#issuecomment-2523441905 ) if (test -z "\$FAULT"); then - UUCORE_FEATURES=\$(cargo metadata --format-version=1 --no-deps | jq -r '.packages[] | select(.name == "uucore") | .features | keys | .[]' | grep -v "feat_systemd_logind" | paste -s -d "," -) + UUCORE_FEATURES=\$(cargo metadata --format-version=1 --no-deps | jq -r '.packages[] | select(.name == "uucore") | .features | keys | .[]' | paste -s -d "," -) cargo nextest run --hide-progress-bar --profile ci --features "\$UUCORE_FEATURES" -p uucore || FAULT=1 fi # Test building with make diff --git a/.github/workflows/openbsd.yml b/.github/workflows/openbsd.yml index 73120728818..c8278288c86 100644 --- a/.github/workflows/openbsd.yml +++ b/.github/workflows/openbsd.yml @@ -215,10 +215,9 @@ jobs: cargo test --features '${{ matrix.job.features }}' || FAULT=1 echo "::endgroup::" fi - # There is no systemd-logind on OpenBSD, so test all features except feat_systemd_logind if (test -z "\$FAULT"); then echo "::group::cargo test (uucore)" - UUCORE_FEATURES=\$(cargo metadata --format-version=1 --no-deps | jq -r '.packages[] | select(.name == "uucore") | .features | keys | .[]' | grep -v "feat_systemd_logind" | paste -s -d "," -) + UUCORE_FEATURES=\$(cargo metadata --format-version=1 --no-deps | jq -r '.packages[] | select(.name == "uucore") | .features | keys | .[]' | paste -s -d "," -) cargo test --features "\$UUCORE_FEATURES" -p uucore || FAULT=1 echo "::endgroup::" fi diff --git a/.vscode/cspell.dictionaries/workspace.wordlist.txt b/.vscode/cspell.dictionaries/workspace.wordlist.txt index 971e99afcdb..f05321d4abf 100644 --- a/.vscode/cspell.dictionaries/workspace.wordlist.txt +++ b/.vscode/cspell.dictionaries/workspace.wordlist.txt @@ -1,6 +1,7 @@ # * cargo cdylib doctest +libloading # * uutils project uutils @@ -186,6 +187,7 @@ execvp fdatasync freeaddrinfo getaddrinfo +dlopen getegid geteuid getgid @@ -201,6 +203,7 @@ inode inodes isatty lchown +libsystemd pathlen setgid setgroups diff --git a/Cargo.lock b/Cargo.lock index 09a89516bd3..6c83ec65fdd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4440,6 +4440,7 @@ dependencies = [ "jiff", "jiff-icu", "libc", + "libloading", "md-5", "memchr", "nix", diff --git a/Cargo.toml b/Cargo.toml index 5a5c06258bc..175a9a5c1f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -84,6 +84,7 @@ jiff = "0.2.18" jiff-icu = "0.2.2" jiff-tzdb = "0.1" libc = "0.2.186" +libloading = "0.8.9" lscolors = { version = "0.21.0", default-features = false, features = [ "gnu_legacy", ] } @@ -454,14 +455,6 @@ feat_diagnostics = ["uucore/diagnostics"] ## Optional feature for stdbuf # "feat_external_libstdbuf" == use an external libstdbuf.so for stdbuf instead of embedding it feat_external_libstdbuf = ["stdbuf/feat_external_libstdbuf"] -# "feat_systemd_logind" == enable feat_systemd_logind support for utmpx replacement -feat_systemd_logind = [ - "pinky/feat_systemd_logind", - "uptime/feat_systemd_logind", - "users/feat_systemd_logind", - "uucore/feat_systemd_logind", - "who/feat_systemd_logind", -] # "feat_selinux" == enable support for SELinux Security Context (by using `--features feat_selinux`) # NOTE: # * The selinux(-sys) crate requires `libselinux` headers and shared library to be accessible in the C toolchain at compile time. diff --git a/src/uu/pinky/Cargo.toml b/src/uu/pinky/Cargo.toml index 73c8470cec1..3b63d67903a 100644 --- a/src/uu/pinky/Cargo.toml +++ b/src/uu/pinky/Cargo.toml @@ -22,9 +22,6 @@ clap = { workspace = true } fluent = { workspace = true } uucore = { workspace = true, features = ["utmpx", "entries"] } -[features] -feat_systemd_logind = ["uucore/feat_systemd_logind"] - [lints] workspace = true diff --git a/src/uu/uptime/Cargo.toml b/src/uu/uptime/Cargo.toml index 37c818dfb9b..f410d2190cf 100644 --- a/src/uu/uptime/Cargo.toml +++ b/src/uu/uptime/Cargo.toml @@ -25,9 +25,6 @@ jiff = { workspace = true } thiserror = { workspace = true } uucore = { workspace = true, features = ["libc", "utmpx", "uptime"] } -[features] -feat_systemd_logind = ["uucore/feat_systemd_logind"] - [lints] workspace = true diff --git a/src/uu/users/Cargo.toml b/src/uu/users/Cargo.toml index ca70debe352..77dda0da61f 100644 --- a/src/uu/users/Cargo.toml +++ b/src/uu/users/Cargo.toml @@ -29,9 +29,6 @@ uucore = { workspace = true, features = [] } [target.'cfg(target_os = "openbsd")'.dependencies] utmp-classic = { workspace = true } -[features] -feat_systemd_logind = ["uucore/feat_systemd_logind"] - [lints] workspace = true diff --git a/src/uu/who/Cargo.toml b/src/uu/who/Cargo.toml index c3c36788106..bf21fed8469 100644 --- a/src/uu/who/Cargo.toml +++ b/src/uu/who/Cargo.toml @@ -24,9 +24,6 @@ fluent = { workspace = true } rustix = { workspace = true, features = ["fs", "termios"] } uucore = { workspace = true, features = ["utmpx"] } -[features] -feat_systemd_logind = ["uucore/feat_systemd_logind"] - [lints] workspace = true diff --git a/src/uucore/Cargo.toml b/src/uucore/Cargo.toml index eb7ce9d5fa0..7b6a79eea76 100644 --- a/src/uucore/Cargo.toml +++ b/src/uucore/Cargo.toml @@ -117,6 +117,7 @@ time = { workspace = true, optional = true, features = [ walkdir = { workspace = true, optional = true } [target.'cfg(target_os = "linux")'.dependencies] +libloading = { workspace = true, optional = true } procfs = { workspace = true, optional = true } [target.'cfg(windows)'.dependencies] @@ -164,7 +165,6 @@ fsext = ["libc", "windows-sys", "bstr", "wide"] fsxattr = ["xattr", "itertools", "libc"] hardware = [] lines = [] -feat_systemd_logind = ["utmpx", "libc"] format = [ "bigdecimal", "extendedbigdecimal", @@ -225,7 +225,7 @@ sum = [ openssl = ["dep:openssl"] update-control = ["parser"] utf8 = [] -utmpx = ["time", "time/macros", "libc", "dns-lookup"] +utmpx = ["time", "time/macros", "libc", "dns-lookup", "dep:libloading"] version-cmp = [] wide = [] tty = [] diff --git a/src/uucore/src/lib/features.rs b/src/uucore/src/lib/features.rs index 83dff9de82d..840d9a2884c 100644 --- a/src/uucore/src/lib/features.rs +++ b/src/uucore/src/lib/features.rs @@ -128,7 +128,7 @@ pub mod selinux; pub mod signals; #[cfg(all(feature = "smack", target_os = "linux"))] pub mod smack; -#[cfg(feature = "feat_systemd_logind")] +#[cfg(all(target_os = "linux", feature = "utmpx"))] pub mod systemd_logind; #[cfg(all( feature = "utmpx", diff --git a/src/uucore/src/lib/features/systemd_logind.rs b/src/uucore/src/lib/features/systemd_logind.rs index 5d3a23e6d10..8340d7c17a7 100644 --- a/src/uucore/src/lib/features/systemd_logind.rs +++ b/src/uucore/src/lib/features/systemd_logind.rs @@ -9,8 +9,9 @@ //! //! This module provides systemd-logind based implementation for reading //! login records as an alternative to traditional utmp/utmpx files. -//! When the systemd-logind feature is enabled and systemd is available, -//! this will be used instead of traditional utmp files. +//! On Linux, this is used when the traditional utmp file is unavailable or +//! empty. If systemd-logind is unavailable, callers fall back to the traditional +//! implementation. use core::ffi::CStr; use std::mem::MaybeUninit; @@ -18,25 +19,80 @@ use std::time::{SystemTime, UNIX_EPOCH}; use crate::error::{UResult, USimpleError}; -/// FFI bindings for libsystemd login and D-Bus functions +/// Dynamically loaded FFI bindings for libsystemd login functions. mod ffi { use core::ffi::{c_char, c_int, c_uint}; + use libloading::Library; + use std::path::Path; + + type SdGetSessions = unsafe extern "C" fn(*mut *mut *mut c_char) -> c_int; + type SdSessionGetUid = unsafe extern "C" fn(*const c_char, *mut c_uint) -> c_int; + type SdSessionGetStartTime = unsafe extern "C" fn(*const c_char, *mut u64) -> c_int; + type SdSessionGetString = unsafe extern "C" fn(*const c_char, *mut *mut c_char) -> c_int; + + pub(super) struct SystemdLoginApi { + // The library must remain loaded while any of these function pointers are used. + _library: Library, + pub(super) sd_get_sessions: SdGetSessions, + pub(super) sd_session_get_uid: SdSessionGetUid, + pub(super) sd_session_get_start_time: SdSessionGetStartTime, + pub(super) sd_session_get_tty: SdSessionGetString, + pub(super) sd_session_get_remote_host: SdSessionGetString, + pub(super) sd_session_get_display: SdSessionGetString, + pub(super) sd_session_get_type: SdSessionGetString, + pub(super) sd_session_get_seat: SdSessionGetString, + } + + impl SystemdLoginApi { + pub(super) fn load() -> Result> { + Self::load_from("libsystemd.so.0") + } + + fn load_from(path: impl AsRef) -> Result> { + // SAFETY: The loaded symbols use the signatures declared by libsystemd's + // stable public API. The Library is retained in Self for at least as long + // as the copied function pointers can be called. + unsafe { + let library = Library::new(path.as_ref())?; + let sd_get_sessions = *library.get::(b"sd_get_sessions\0")?; + let sd_session_get_uid = + *library.get::(b"sd_session_get_uid\0")?; + let sd_session_get_start_time = + *library.get::(b"sd_session_get_start_time\0")?; + let sd_session_get_tty = + *library.get::(b"sd_session_get_tty\0")?; + let sd_session_get_remote_host = + *library.get::(b"sd_session_get_remote_host\0")?; + let sd_session_get_display = + *library.get::(b"sd_session_get_display\0")?; + let sd_session_get_type = + *library.get::(b"sd_session_get_type\0")?; + let sd_session_get_seat = + *library.get::(b"sd_session_get_seat\0")?; + + Ok(Self { + _library: library, + sd_get_sessions, + sd_session_get_uid, + sd_session_get_start_time, + sd_session_get_tty, + sd_session_get_remote_host, + sd_session_get_display, + sd_session_get_type, + sd_session_get_seat, + }) + } + } + } - #[link(name = "systemd")] - unsafe extern "C" { - pub fn sd_get_sessions(sessions: *mut *mut *mut c_char) -> c_int; - pub fn sd_session_get_uid(session: *const c_char, uid: *mut c_uint) -> c_int; - pub fn sd_session_get_start_time(session: *const c_char, usec: *mut u64) -> c_int; - pub fn sd_session_get_tty(session: *const c_char, tty: *mut *mut c_char) -> c_int; - pub fn sd_session_get_remote_host( - session: *const c_char, - remote_host: *mut *mut c_char, - ) -> c_int; - pub fn sd_session_get_display(session: *const c_char, display: *mut *mut c_char) -> c_int; - pub fn sd_session_get_type(session: *const c_char, session_type: *mut *mut c_char) - -> c_int; - pub fn sd_session_get_seat(session: *const c_char, seat: *mut *mut c_char) -> c_int; + #[cfg(test)] + mod tests { + use super::SystemdLoginApi; + #[test] + fn missing_library_is_reported() { + assert!(SystemdLoginApi::load_from("libsystemd-uutils-does-not-exist.so").is_err()); + } } } @@ -46,13 +102,15 @@ mod login { use core::ffi::CStr; use std::ffi::CString; use std::ptr; - use std::time::SystemTime; + use std::time::{SystemTime, UNIX_EPOCH}; /// Get all active sessions - pub fn get_sessions() -> Result, Box> { + pub fn get_sessions( + api: &ffi::SystemdLoginApi, + ) -> Result, Box> { let mut sessions_ptr: *mut *mut core::ffi::c_char = ptr::null_mut(); - let result = unsafe { ffi::sd_get_sessions(&raw mut sessions_ptr) }; + let result = unsafe { (api.sd_get_sessions)(&raw mut sessions_ptr) }; if result < 0 { return Err(format!("sd_get_sessions failed: {result}").into()); @@ -81,11 +139,14 @@ mod login { } /// Get UID for a session - pub fn get_session_uid(session_id: &str) -> Result> { + pub fn get_session_uid( + api: &ffi::SystemdLoginApi, + session_id: &str, + ) -> Result> { let session_cstring = CString::new(session_id)?; let mut uid: core::ffi::c_uint = 0; - let result = unsafe { ffi::sd_session_get_uid(session_cstring.as_ptr(), &raw mut uid) }; + let result = unsafe { (api.sd_session_get_uid)(session_cstring.as_ptr(), &raw mut uid) }; if result < 0 { return Err( @@ -97,12 +158,15 @@ mod login { } /// Get start time for a session (in microseconds since Unix epoch) - pub fn get_session_start_time(session_id: &str) -> Result> { + pub fn get_session_start_time( + api: &ffi::SystemdLoginApi, + session_id: &str, + ) -> Result> { let session_cstring = CString::new(session_id)?; let mut usec: u64 = 0; let result = - unsafe { ffi::sd_session_get_start_time(session_cstring.as_ptr(), &raw mut usec) }; + unsafe { (api.sd_session_get_start_time)(session_cstring.as_ptr(), &raw mut usec) }; if result < 0 { return Err(format!( @@ -115,11 +179,15 @@ mod login { } /// Get TTY for a session - pub fn get_session_tty(session_id: &str) -> Result, Box> { + pub fn get_session_tty( + api: &ffi::SystemdLoginApi, + session_id: &str, + ) -> Result, Box> { let session_cstring = CString::new(session_id)?; let mut tty_ptr: *mut core::ffi::c_char = ptr::null_mut(); - let result = unsafe { ffi::sd_session_get_tty(session_cstring.as_ptr(), &raw mut tty_ptr) }; + let result = + unsafe { (api.sd_session_get_tty)(session_cstring.as_ptr(), &raw mut tty_ptr) }; if result < 0 { return Err( @@ -141,13 +209,15 @@ mod login { /// Get remote host for a session pub fn get_session_remote_host( + api: &ffi::SystemdLoginApi, session_id: &str, ) -> Result, Box> { let session_cstring = CString::new(session_id)?; let mut host_ptr: *mut core::ffi::c_char = ptr::null_mut(); - let result = - unsafe { ffi::sd_session_get_remote_host(session_cstring.as_ptr(), &raw mut host_ptr) }; + let result = unsafe { + (api.sd_session_get_remote_host)(session_cstring.as_ptr(), &raw mut host_ptr) + }; if result < 0 { return Err(format!( @@ -170,13 +240,14 @@ mod login { /// Get display for a session pub fn get_session_display( + api: &ffi::SystemdLoginApi, session_id: &str, ) -> Result, Box> { let session_cstring = CString::new(session_id)?; let mut display_ptr: *mut core::ffi::c_char = ptr::null_mut(); let result = - unsafe { ffi::sd_session_get_display(session_cstring.as_ptr(), &raw mut display_ptr) }; + unsafe { (api.sd_session_get_display)(session_cstring.as_ptr(), &raw mut display_ptr) }; if result < 0 { return Err(format!( @@ -199,13 +270,14 @@ mod login { /// Get type for a session pub fn get_session_type( + api: &ffi::SystemdLoginApi, session_id: &str, ) -> Result, Box> { let session_cstring = CString::new(session_id)?; let mut type_ptr: *mut core::ffi::c_char = ptr::null_mut(); let result = - unsafe { ffi::sd_session_get_type(session_cstring.as_ptr(), &raw mut type_ptr) }; + unsafe { (api.sd_session_get_type)(session_cstring.as_ptr(), &raw mut type_ptr) }; if result < 0 { return Err( @@ -227,13 +299,14 @@ mod login { /// Get seat for a session pub fn get_session_seat( + api: &ffi::SystemdLoginApi, session_id: &str, ) -> Result, Box> { let session_cstring = CString::new(session_id)?; let mut seat_ptr: *mut core::ffi::c_char = ptr::null_mut(); let result = - unsafe { ffi::sd_session_get_seat(session_cstring.as_ptr(), &raw mut seat_ptr) }; + unsafe { (api.sd_session_get_seat)(session_cstring.as_ptr(), &raw mut seat_ptr) }; if result < 0 { return Err( @@ -253,25 +326,20 @@ mod login { Ok(Some(seat_string)) } - /// Get system boot time using systemd random-seed file fallback - /// - /// TODO: This replicates GNU coreutils' fallback behavior for compatibility. - /// GNU coreutils uses the mtime of /var/lib/systemd/random-seed as a heuristic for boot time - /// when utmp is unavailable, rather than querying systemd's authoritative KernelTimestamp. - /// This creates inconsistency: `uptime -s` shows the actual kernel boot time - /// while `who -b` shows ~1 minute later when systemd services start. - /// - /// Ideally, both should use the same source (KernelTimestamp) for semantic consistency. - /// Consider proposing to GNU coreutils to use systemd's KernelTimestamp property instead. - pub fn get_boot_time() -> Result> { - use std::fs; - - let metadata = fs::metadata("/var/lib/systemd/random-seed") - .map_err(|e| format!("Failed to read /var/lib/systemd/random-seed: {e}"))?; + pub(super) fn boot_time_from_proc_stat(contents: &str) -> Option { + contents.lines().find_map(|line| { + line.strip_prefix("btime ") + .and_then(|seconds| seconds.parse::().ok()) + .map(|seconds| UNIX_EPOCH + std::time::Duration::from_secs(seconds)) + }) + } - metadata - .modified() - .map_err(|e| format!("Failed to get modification time: {e}").into()) + /// Get the system boot time using the kernel's `/proc/stat` value. + pub fn get_boot_time() -> Result> { + let proc_stat = std::fs::read_to_string("/proc/stat") + .map_err(|e| format!("Failed to read /proc/stat: {e}"))?; + boot_time_from_proc_stat(&proc_stat) + .ok_or_else(|| "Failed to find btime in /proc/stat".into()) } } @@ -323,6 +391,8 @@ impl SystemdLoginRecord { /// Read login records from systemd-logind using safe wrapper functions /// This matches the approach used by GNU coreutils read_utmp_from_systemd() pub fn read_login_records() -> UResult> { + let api = ffi::SystemdLoginApi::load() + .map_err(|e| USimpleError::new(1, format!("Failed to load libsystemd: {e}")))?; let mut records = Vec::new(); // Add boot time record first @@ -342,7 +412,7 @@ pub fn read_login_records() -> UResult> { } // Get all active sessions using safe wrapper - let mut sessions = login::get_sessions() + let mut sessions = login::get_sessions(&api) .map_err(|e| USimpleError::new(1, format!("Failed to get systemd sessions: {e}")))?; // Sort sessions consistently for reproducible output (reverse for TTY sessions first) @@ -352,7 +422,7 @@ pub fn read_login_records() -> UResult> { // Iterate through all sessions for session_id in sessions { // Get session UID using safe wrapper - let Ok(uid) = login::get_session_uid(&session_id) else { + let Ok(uid) = login::get_session_uid(&api, &session_id) else { continue; }; @@ -391,18 +461,19 @@ pub fn read_login_records() -> UResult> { }; // Get start time using safe wrapper, fallback to epoch if unavailable - let start_time = login::get_session_start_time(&session_id).map_or(UNIX_EPOCH, |usec| { - UNIX_EPOCH + std::time::Duration::from_micros(usec) - }); + let start_time = login::get_session_start_time(&api, &session_id) + .map_or(UNIX_EPOCH, |usec| { + UNIX_EPOCH + std::time::Duration::from_micros(usec) + }); // Get TTY using safe wrapper - let mut tty = login::get_session_tty(&session_id) + let mut tty = login::get_session_tty(&api, &session_id) .ok() .flatten() .unwrap_or_default(); // Get seat using safe wrapper - let mut seat = login::get_session_seat(&session_id) + let mut seat = login::get_session_seat(&api, &session_id) .ok() .flatten() .unwrap_or_default(); @@ -416,19 +487,19 @@ pub fn read_login_records() -> UResult> { } // Get remote host using safe wrapper - let remote_host = login::get_session_remote_host(&session_id) + let remote_host = login::get_session_remote_host(&api, &session_id) .ok() .flatten() .unwrap_or_default(); // Get display using safe wrapper (for GUI sessions) - let display = login::get_session_display(&session_id) + let display = login::get_session_display(&api, &session_id) .ok() .flatten() .unwrap_or_default(); // Get session type using safe wrapper (currently unused but available) - let _session_type = login::get_session_type(&session_id) + let _session_type = login::get_session_type(&api, &session_id) .ok() .flatten() .unwrap_or_default(); @@ -660,6 +731,19 @@ impl Iterator for SystemdUtmpxIter { mod tests { use super::*; + #[test] + fn test_boot_time_from_proc_stat() { + let boot_time = login::boot_time_from_proc_stat( + "cpu 1 2 3 4 5 6 7 8 9 10\nbtime 1234567890\nprocesses 42\n", + ); + + assert_eq!( + boot_time, + Some(UNIX_EPOCH + std::time::Duration::from_secs(1_234_567_890)) + ); + assert!(login::boot_time_from_proc_stat("cpu 1 2 3\n").is_none()); + } + #[test] fn test_empty_iterator() { let mut iter = SystemdUtmpxIter::empty(); diff --git a/src/uucore/src/lib/features/utmpx.rs b/src/uucore/src/lib/features/utmpx.rs index 9471680dd24..10a98117a3f 100644 --- a/src/uucore/src/lib/features/utmpx.rs +++ b/src/uucore/src/lib/features/utmpx.rs @@ -36,12 +36,14 @@ pub extern crate time; use std::ffi::CString; use std::io::Result as IOResult; use std::marker::PhantomData; +#[cfg(target_os = "linux")] +use std::mem::size_of; use std::os::unix::ffi::OsStrExt; use std::path::Path; use std::ptr; use std::sync::{Mutex, MutexGuard}; -#[cfg(feature = "feat_systemd_logind")] +#[cfg(target_os = "linux")] use crate::features::systemd_logind; pub use self::ut::*; @@ -322,19 +324,33 @@ impl Utmpx { /// This will use the default location, or the path [`Utmpx::iter_all_records_from`] /// was most recently called with. /// - /// On systems with systemd-logind feature enabled at compile time, - /// this will use systemd-logind instead of traditional utmp files. + /// On Linux, this will use a populated traditional utmp file when available. + /// If the default utmp file is missing or empty, it will try systemd-logind + /// and fall back to traditional utmp if systemd-logind is unavailable. /// /// Only one instance of [`UtmpxIter`] may be active at a time. This /// function will block as long as one is still active. Beware! pub fn iter_all_records() -> UtmpxIter { - #[cfg(feature = "feat_systemd_logind")] + #[cfg(target_os = "linux")] { - // Use systemd-logind instead of traditional utmp when feature is enabled - UtmpxIter::new_systemd() + // The usability check inspects DEFAULT_FILE, while iteration reads + // libc's process-global path, which is sticky across + // `iter_all_records_from` calls. These can only disagree if a caller + // previously selected a custom path and then calls this function; + // no in-tree caller does that. + if traditional_utmp_is_usable(Path::new(DEFAULT_FILE)) { + let iter = UtmpxIter::new(); + unsafe { + #[cfg_attr(target_env = "musl", allow(deprecated))] + setutxent(); + } + iter + } else { + UtmpxIter::new_systemd_with_fallback_path(None) + } } - #[cfg(not(feature = "feat_systemd_logind"))] + #[cfg(not(target_os = "linux"))] { let iter = UtmpxIter::new(); unsafe { @@ -354,17 +370,18 @@ impl Utmpx { /// /// This function affects subsequent calls to [`Utmpx::iter_all_records`]. /// - /// On systems with systemd-logind feature enabled at compile time, - /// if the path matches the default utmp file, this will use systemd-logind - /// instead of traditional utmp files. + /// On Linux, if the path matches the default utmp file and that file is + /// missing or empty, this will try systemd-logind first and fall back to + /// that file if systemd-logind is unavailable. /// /// The same caveats as for [`Utmpx::iter_all_records`] apply. pub fn iter_all_records_from>(path: P) -> UtmpxIter { - #[cfg(feature = "feat_systemd_logind")] + #[cfg(target_os = "linux")] { - // Use systemd-logind for default utmp file when feature is enabled - if path.as_ref() == Path::new(DEFAULT_FILE) { - return UtmpxIter::new_systemd(); + if path.as_ref() == Path::new(DEFAULT_FILE) + && !traditional_utmp_is_usable(path.as_ref()) + { + return UtmpxIter::new_systemd_with_fallback_path(Some(path.as_ref())); } } @@ -402,6 +419,33 @@ impl Utmpx { // ordinary race conditions are also very much possible. static LOCK: Mutex<()> = Mutex::new(()); +/// Whether the traditional utmp file can serve login records: it must exist, +/// be a regular file holding at least one record, and be readable. +/// +/// A present-but-empty file (e.g. a non-systemd system with no logins yet) is +/// treated as unusable, so each invocation pays one failed dlopen of +/// libsystemd before falling back; this keeps the common systemd case correct +/// at a negligible cost elsewhere. +#[cfg(target_os = "linux")] +fn traditional_utmp_is_usable(path: &Path) -> bool { + #[cfg(target_env = "musl")] + { + let _ = path; + false + } + + #[cfg(not(target_env = "musl"))] + { + std::fs::metadata(path).is_ok_and(|metadata| { + // Stat before opening: opening a non-regular file could block + // (e.g. a FIFO with no writer). + metadata.is_file() + && metadata.len() >= size_of::() as u64 + && std::fs::File::open(path).is_ok() + }) + } +} + /// Iterator of login records pub struct UtmpxIter { #[allow(dead_code)] @@ -409,7 +453,7 @@ pub struct UtmpxIter { /// Ensure UtmpxIter is !Send. Technically redundant because MutexGuard /// is also !Send. phantom: PhantomData>, - #[cfg(feature = "feat_systemd_logind")] + #[cfg(target_os = "linux")] systemd_iter: Option, } @@ -422,37 +466,137 @@ impl UtmpxIter { Self { guard, phantom: PhantomData, - #[cfg(feature = "feat_systemd_logind")] + #[cfg(target_os = "linux")] systemd_iter: None, } } - #[cfg(feature = "feat_systemd_logind")] - fn new_systemd() -> Self { + #[cfg(target_os = "linux")] + fn new_systemd_with_fallback_path(fallback_path: Option<&Path>) -> Self { + Self::new_systemd_with_fallback_path_using( + fallback_path, + systemd_logind::SystemdUtmpxIter::new, + ) + } + + #[cfg(target_os = "linux")] + fn new_systemd_with_fallback_path_using( + fallback_path: Option<&Path>, + new_systemd_iter: F, + ) -> Self + where + F: FnOnce() -> Result, + { // PoisonErrors can safely be ignored let guard = LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - let systemd_iter = match systemd_logind::SystemdUtmpxIter::new() { - Ok(iter) => iter, - Err(_) => { - // Like GNU coreutils: graceful degradation, not fallback to traditional utmp - // Return empty iterator rather than falling back (GNU coreutils also returns 0 when /var/run/utmp is not present, so we don't need to propagate the error here) - systemd_logind::SystemdUtmpxIter::empty() + if let Ok(iter) = new_systemd_iter() { + Self { + guard, + phantom: PhantomData, + systemd_iter: Some(iter), + } + } else { + // Fall back to traditional utmp when systemd-logind is unavailable. + // Callers only reach this constructor after determining that the + // traditional file is missing or empty, so re-reading it yields no + // records — matching GNU coreutils, which also prints nothing when + // /var/run/utmp is absent. + unsafe { + if let Some(path) = fallback_path { + let path = CString::new(path.as_os_str().as_bytes()).unwrap(); + #[cfg_attr(target_env = "musl", allow(deprecated))] + utmpxname(path.as_ptr()); + } + #[cfg_attr(target_env = "musl", allow(deprecated))] + setutxent(); + } + Self { + guard, + phantom: PhantomData, + systemd_iter: None, } + } + } +} + +#[cfg(all(test, target_os = "linux", not(target_env = "musl")))] +mod tests { + use super::*; + use std::fs::File; + use std::io::Write; + use std::mem::zeroed; + + fn write_utmpx_record(path: &Path, user: &str) { + // SAFETY: A zero-filled utmpx value is valid, and the initialized value is + // written as bytes without outliving it. + let mut record: utmpx = unsafe { zeroed() }; + record.ut_type = USER_PROCESS; + for (destination, source) in record.ut_user.iter_mut().zip(user.bytes()) { + *destination = source as _; + } + + // SAFETY: `record` remains alive for the duration of `write_all`, and the + // byte slice covers exactly its initialized object representation. + let bytes = unsafe { + std::slice::from_raw_parts((&raw const record).cast::(), size_of::()) }; - Self { - guard, - phantom: PhantomData, - systemd_iter: Some(systemd_iter), + File::create(path).unwrap().write_all(bytes).unwrap(); + } + + #[test] + fn systemd_failure_uses_explicit_fallback_path() { + let directory = tempfile::tempdir().unwrap(); + let custom_path = directory.path().join("custom-utmp"); + let fallback_path = directory.path().join("fallback-utmp"); + write_utmpx_record(&custom_path, "custom"); + write_utmpx_record(&fallback_path, "fallback"); + + let custom_record = Utmpx::iter_all_records_from(&custom_path) + .next() + .expect("custom utmp record"); + assert_eq!(custom_record.user(), "custom"); + + // LOCK is released here with libc's global path still set to + // `custom_path`, so a parallel test iterating utmp records would + // observe it (none does today). Keeping the first iterator alive + // instead would deadlock: the constructor below reacquires the + // non-reentrant LOCK. + let mut fallback_iter = + UtmpxIter::new_systemd_with_fallback_path_using(Some(&fallback_path), || { + Err::(()) + }); + let fallback_record = fallback_iter.next().expect("fallback utmp record"); + assert_eq!(fallback_record.user(), "fallback"); + + // Restore libc's process-global utmp path for other tests. This must + // happen before dropping `fallback_iter`: the iterator still holds + // LOCK, so no other thread can observe the intermediate path. + let default_path = CString::new(DEFAULT_FILE).unwrap(); + unsafe { + utmpxname(default_path.as_ptr()); } + drop(fallback_iter); + } + + #[test] + fn traditional_utmp_requires_at_least_one_record() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("utmp"); + + File::create(&path).unwrap(); + assert!(!traditional_utmp_is_usable(&path)); + + write_utmpx_record(&path, "user"); + assert!(traditional_utmp_is_usable(&path)); } } /// Wrapper type that can hold either traditional utmpx records or systemd records pub enum UtmpxRecord { Traditional(Box), - #[cfg(feature = "feat_systemd_logind")] + #[cfg(target_os = "linux")] Systemd(systemd_logind::SystemdUtmpxCompat), } @@ -461,7 +605,7 @@ impl UtmpxRecord { pub fn record_type(&self) -> i16 { match self { Self::Traditional(utmpx) => utmpx.record_type(), - #[cfg(feature = "feat_systemd_logind")] + #[cfg(target_os = "linux")] Self::Systemd(systemd) => systemd.record_type(), } } @@ -470,7 +614,7 @@ impl UtmpxRecord { pub fn pid(&self) -> i32 { match self { Self::Traditional(utmpx) => utmpx.pid(), - #[cfg(feature = "feat_systemd_logind")] + #[cfg(target_os = "linux")] Self::Systemd(systemd) => systemd.pid(), } } @@ -479,7 +623,7 @@ impl UtmpxRecord { pub fn terminal_suffix(&self) -> String { match self { Self::Traditional(utmpx) => utmpx.terminal_suffix(), - #[cfg(feature = "feat_systemd_logind")] + #[cfg(target_os = "linux")] Self::Systemd(systemd) => systemd.terminal_suffix(), } } @@ -488,7 +632,7 @@ impl UtmpxRecord { pub fn user(&self) -> String { match self { Self::Traditional(utmpx) => utmpx.user(), - #[cfg(feature = "feat_systemd_logind")] + #[cfg(target_os = "linux")] Self::Systemd(systemd) => systemd.user(), } } @@ -497,7 +641,7 @@ impl UtmpxRecord { pub fn host(&self) -> String { match self { Self::Traditional(utmpx) => utmpx.host(), - #[cfg(feature = "feat_systemd_logind")] + #[cfg(target_os = "linux")] Self::Systemd(systemd) => systemd.host(), } } @@ -506,7 +650,7 @@ impl UtmpxRecord { pub fn tty_device(&self) -> String { match self { Self::Traditional(utmpx) => utmpx.tty_device(), - #[cfg(feature = "feat_systemd_logind")] + #[cfg(target_os = "linux")] Self::Systemd(systemd) => systemd.tty_device(), } } @@ -515,7 +659,7 @@ impl UtmpxRecord { pub fn login_time(&self) -> time::OffsetDateTime { match self { Self::Traditional(utmpx) => utmpx.login_time(), - #[cfg(feature = "feat_systemd_logind")] + #[cfg(target_os = "linux")] Self::Systemd(systemd) => systemd.login_time(), } } @@ -526,7 +670,7 @@ impl UtmpxRecord { pub fn exit_status(&self) -> (i16, i16) { match self { Self::Traditional(utmpx) => utmpx.exit_status(), - #[cfg(feature = "feat_systemd_logind")] + #[cfg(target_os = "linux")] Self::Systemd(systemd) => systemd.exit_status(), } } @@ -535,7 +679,7 @@ impl UtmpxRecord { pub fn is_user_process(&self) -> bool { match self { Self::Traditional(utmpx) => utmpx.is_user_process(), - #[cfg(feature = "feat_systemd_logind")] + #[cfg(target_os = "linux")] Self::Systemd(systemd) => systemd.is_user_process(), } } @@ -544,7 +688,7 @@ impl UtmpxRecord { pub fn canon_host(&self) -> IOResult { match self { Self::Traditional(utmpx) => utmpx.canon_host(), - #[cfg(feature = "feat_systemd_logind")] + #[cfg(target_os = "linux")] Self::Systemd(systemd) => Ok(systemd.canon_host()), } } @@ -553,10 +697,12 @@ impl UtmpxRecord { impl Iterator for UtmpxIter { type Item = UtmpxRecord; fn next(&mut self) -> Option { - #[cfg(feature = "feat_systemd_logind")] + #[cfg(target_os = "linux")] { if let Some(ref mut systemd_iter) = self.systemd_iter { - // We have a systemd iterator - use it exclusively (never fall back to traditional utmp) + // Once a systemd iterator was successfully created, use it exclusively. + // If systemd initialization failed, `systemd_iter` is None and we use + // traditional utmp below. return systemd_iter.next().map(UtmpxRecord::Systemd); } } diff --git a/src/uucore/src/lib/lib.rs b/src/uucore/src/lib/lib.rs index 903450bb77b..6292a8b28a4 100644 --- a/src/uucore/src/lib/lib.rs +++ b/src/uucore/src/lib/lib.rs @@ -79,7 +79,7 @@ pub use crate::features::ranges; pub use crate::features::ringbuffer; #[cfg(feature = "sum")] pub use crate::features::sum; -#[cfg(feature = "feat_systemd_logind")] +#[cfg(all(target_os = "linux", feature = "utmpx"))] pub use crate::features::systemd_logind; #[cfg(feature = "time")] pub use crate::features::time;