From b48eac246d58c9446ddf3a2ea369a191b1e7dea7 Mon Sep 17 00:00:00 2001 From: Etienne Cordonnier Date: Tue, 12 Aug 2025 21:32:53 +0200 Subject: [PATCH 01/14] add support for systemd-logind Add support for systemd-logind for systems where `/var/run/utmp` does not exist any more (e.g. Ubuntu 25.04). Here is some context explaining the switch from utmp to systemd-logind: https://www.thkukuk.de/blog/Y2038_glibc_utmp_64bit/ Fixes https://github.com/uutils/coreutils/issues/8376 Signed-off-by: Etienne Cordonnier --- .github/workflows/CICD.yml | 8 +- .github/workflows/code-quality.yml | 6 +- .github/workflows/freebsd.yml | 10 +- Cargo.toml | 10 +- src/uu/pinky/Cargo.toml | 5 + src/uu/pinky/src/platform/unix.rs | 6 +- src/uu/uptime/Cargo.toml | 5 + src/uu/users/Cargo.toml | 5 + src/uu/users/src/users.rs | 2 +- src/uu/who/Cargo.toml | 6 + src/uu/who/src/platform/unix.rs | 24 +- src/uucore/Cargo.toml | 3 +- src/uucore/src/lib/features.rs | 4 +- src/uucore/src/lib/features/systemd_logind.rs | 632 ++++++++++++++++++ src/uucore/src/lib/features/utmpx.rs | 177 ++++- src/uucore/src/lib/lib.rs | 4 +- 16 files changed, 866 insertions(+), 41 deletions(-) create mode 100644 src/uucore/src/lib/features/systemd_logind.rs diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 8bdbc6c3f10..deb77c6b8ee 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -5,7 +5,7 @@ name: CICD # spell-checker:ignore (jargon) SHAs deps dequote softprops subshell toolchain fuzzers dedupe devel profdata # spell-checker:ignore (people) Peltoche rivy dtolnay Anson dawidd # spell-checker:ignore (shell/tools) binutils choco clippy dmake dpkg esac fakeroot fdesc fdescfs gmake grcov halium lcov libclang libfuse libssl limactl mkdir nextest nocross pacman popd printf pushd redoxer rsync rustc rustfmt rustup shopt sccache utmpdump xargs -# spell-checker:ignore (misc) aarch alnum armhf bindir busytest coreutils defconfig DESTDIR gecos getenforce gnueabihf issuecomment maint manpages msys multisize noconfirm nofeatures nullglob onexitbegin onexitend pell runtest Swatinem tempfile testsuite toybox uutils +# spell-checker:ignore (misc) aarch alnum armhf bindir busytest coreutils defconfig DESTDIR gecos getenforce gnueabihf issuecomment maint manpages msys multisize noconfirm nofeatures nullglob onexitbegin onexitend pell runtest Swatinem tempfile testsuite toybox uutils libsystemd env: PROJECT_NAME: coreutils @@ -425,7 +425,7 @@ jobs: run: | ## Install dependencies sudo apt-get update - sudo apt-get install jq libselinux1-dev + sudo apt-get install jq libselinux1-dev libsystemd-dev - name: "`make install`" shell: bash run: | @@ -714,9 +714,9 @@ jobs: esac case '${{ matrix.job.os }}' in ubuntu-*) - # selinux headers needed to build tests + # selinux and systemd headers needed to build tests sudo apt-get -y update - sudo apt-get -y install libselinux1-dev + sudo apt-get -y install libselinux1-dev libsystemd-dev # pinky is a tool to show logged-in users from utmp, and gecos fields from /etc/passwd. # In GitHub Action *nix VMs, no accounts log in, even the "runner" account that runs the commands, and "system boot" entry is missing. # The account also has empty gecos fields. diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index b06ad4e6056..f1bfab33f0f 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -1,7 +1,7 @@ name: Code Quality # spell-checker:ignore (people) reactivecircus Swatinem dtolnay juliangruber pell taplo -# spell-checker:ignore (misc) TERMUX noaudio pkill swiftshader esac sccache pcoreutils shopt subshell dequote +# spell-checker:ignore (misc) TERMUX noaudio pkill swiftshader esac sccache pcoreutils shopt subshell dequote libsystemd on: pull_request: @@ -110,8 +110,8 @@ jobs: ## Install/setup prerequisites case '${{ matrix.job.os }}' in ubuntu-*) - # selinux headers needed to enable all features - sudo apt-get -y install libselinux1-dev + # selinux and systemd headers needed to enable all features + sudo apt-get -y install libselinux1-dev libsystemd-dev ;; esac - name: "`cargo clippy` lint testing" diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 5ab13392cb2..c550cefdcc2 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -1,6 +1,6 @@ name: FreeBSD -# spell-checker:ignore sshfs usesh vmactions taiki Swatinem esac fdescfs fdesc sccache nextest copyback +# spell-checker:ignore sshfs usesh vmactions taiki Swatinem esac fdescfs fdesc sccache nextest copyback logind env: # * style job configuration @@ -140,7 +140,7 @@ jobs: usesh: true sync: rsync copyback: false - prepare: pkg install -y curl gmake sudo + prepare: pkg install -y curl gmake sudo jq run: | ## Prepare, build, and test # implementation modelled after ref: @@ -194,7 +194,11 @@ 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 - if (test -z "\$FAULT"); then cargo nextest run --hide-progress-bar --profile ci --all-features -p uucore || 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 -p uucore | jq -r '.packages[] | select(.name == "uucore") | .features | keys | .[]' | grep -v "feat_systemd_logind" | paste -s -d "," -) + cargo nextest run --hide-progress-bar --profile ci --features "\$UUCORE_FEATURES" -p uucore || FAULT=1 + fi # Test building with make if (test -z "\$FAULT"); then make PROFILE=ci || FAULT=1 ; fi # Clean to avoid to rsync back the files diff --git a/Cargo.toml b/Cargo.toml index db8329aa539..bf939b398af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ # coreutils (uutils) # * see the repository LICENSE, README, and CONTRIBUTING files for more information -# spell-checker:ignore (libs) bigdecimal datetime serde bincode gethostid kqueue libselinux mangen memmap uuhelp startswith constness expl unnested +# spell-checker:ignore (libs) bigdecimal datetime serde bincode gethostid kqueue libselinux mangen memmap uuhelp startswith constness expl unnested logind [package] name = "coreutils" @@ -38,6 +38,14 @@ uudoc = ["zip", "dep:uuhelp_parser"] ## 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_acl" == enable support for ACLs (access control lists; by using`--features feat_acl`) # NOTE: # * On linux, the posix-acl/acl-sys crate requires `libacl` 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 1a22ca5171c..acb0eb20793 100644 --- a/src/uu/pinky/Cargo.toml +++ b/src/uu/pinky/Cargo.toml @@ -1,3 +1,5 @@ +# spell-checker:ignore logind + [package] name = "uu_pinky" description = "pinky ~ (uutils) display user information" @@ -14,6 +16,9 @@ readme.workspace = true [lints] workspace = true +[features] +feat_systemd_logind = ["uucore/feat_systemd_logind"] + [lib] path = "src/pinky.rs" diff --git a/src/uu/pinky/src/platform/unix.rs b/src/uu/pinky/src/platform/unix.rs index 3581cedbab3..5832dbbf099 100644 --- a/src/uu/pinky/src/platform/unix.rs +++ b/src/uu/pinky/src/platform/unix.rs @@ -14,7 +14,7 @@ use uucore::entries::{Locate, Passwd}; use uucore::error::{FromIo, UResult}; use uucore::libc::S_IWGRP; use uucore::translate; -use uucore::utmpx::{self, Utmpx, time}; +use uucore::utmpx::{self, Utmpx, UtmpxRecord, time}; use std::io::BufReader; use std::io::prelude::*; @@ -137,7 +137,7 @@ fn idle_string(when: i64) -> String { }) } -fn time_string(ut: &Utmpx) -> String { +fn time_string(ut: &UtmpxRecord) -> String { // "%b %e %H:%M" let time_format: Vec = time::format_description::parse("[month repr:short] [day padding:space] [hour]:[minute]") @@ -158,7 +158,7 @@ fn gecos_to_fullname(pw: &Passwd) -> Option { } impl Pinky { - fn print_entry(&self, ut: &Utmpx) -> std::io::Result<()> { + fn print_entry(&self, ut: &UtmpxRecord) -> std::io::Result<()> { let mut pts_path = PathBuf::from("/dev"); pts_path.push(ut.tty_device().as_str()); diff --git a/src/uu/uptime/Cargo.toml b/src/uu/uptime/Cargo.toml index ef962b9edd7..e584fbb7d32 100644 --- a/src/uu/uptime/Cargo.toml +++ b/src/uu/uptime/Cargo.toml @@ -1,3 +1,5 @@ +# spell-checker:ignore logind + [package] name = "uu_uptime" description = "uptime ~ (uutils) display dynamic system information" @@ -14,6 +16,9 @@ readme.workspace = true [lints] workspace = true +[features] +feat_systemd_logind = ["uucore/feat_systemd_logind"] + [lib] path = "src/uptime.rs" diff --git a/src/uu/users/Cargo.toml b/src/uu/users/Cargo.toml index 1164aceedd9..fa311e74ac4 100644 --- a/src/uu/users/Cargo.toml +++ b/src/uu/users/Cargo.toml @@ -1,3 +1,5 @@ +# spell-checker:ignore logind + [package] name = "uu_users" description = "users ~ (uutils) display names of currently logged-in users" @@ -14,6 +16,9 @@ readme.workspace = true [lints] workspace = true +[features] +feat_systemd_logind = ["uucore/feat_systemd_logind"] + [lib] path = "src/users.rs" diff --git a/src/uu/users/src/users.rs b/src/uu/users/src/users.rs index ca63e527eae..bd93e340287 100644 --- a/src/uu/users/src/users.rs +++ b/src/uu/users/src/users.rs @@ -69,7 +69,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let filename = maybe_file.unwrap_or(utmpx::DEFAULT_FILE.as_ref()); users = Utmpx::iter_all_records_from(filename) - .filter(Utmpx::is_user_process) + .filter(|ut| ut.is_user_process()) .map(|ut| ut.user()) .collect::>(); }; diff --git a/src/uu/who/Cargo.toml b/src/uu/who/Cargo.toml index bb6ec82bd48..fdb5dd41dfc 100644 --- a/src/uu/who/Cargo.toml +++ b/src/uu/who/Cargo.toml @@ -1,3 +1,6 @@ +# spell-checker:ignore logind + + [package] name = "uu_who" description = "who ~ (uutils) display information about currently logged-in users" @@ -14,6 +17,9 @@ readme.workspace = true [lints] workspace = true +[features] +feat_systemd_logind = ["uucore/feat_systemd_logind"] + [lib] path = "src/who.rs" diff --git a/src/uu/who/src/platform/unix.rs b/src/uu/who/src/platform/unix.rs index 7f7f5dc91d5..d06f199b132 100644 --- a/src/uu/who/src/platform/unix.rs +++ b/src/uu/who/src/platform/unix.rs @@ -14,7 +14,7 @@ use uucore::libc::{S_IWGRP, STDIN_FILENO, ttyname}; use uucore::translate; use uucore::LocalizedCommand; -use uucore::utmpx::{self, Utmpx, time}; +use uucore::utmpx::{self, UtmpxRecord, time}; use std::borrow::Cow; use std::ffi::CStr; @@ -162,7 +162,7 @@ fn idle_string<'a>(when: i64, boottime: i64) -> Cow<'a, str> { }) } -fn time_string(ut: &Utmpx) -> String { +fn time_string(ut: &UtmpxRecord) -> String { // "%b %e %H:%M" let time_format: Vec = time::format_description::parse("[month repr:short] [day padding:space] [hour]:[minute]") @@ -202,14 +202,14 @@ impl Who { utmpx::DEFAULT_FILE }; if self.short_list { - let users = Utmpx::iter_all_records_from(f) - .filter(Utmpx::is_user_process) + let users = utmpx::Utmpx::iter_all_records_from(f) + .filter(|ut| ut.is_user_process()) .map(|ut| ut.user()) .collect::>(); println!("{}", users.join(" ")); println!("{}", translate!("who-user-count", "count" => users.len())); } else { - let records = Utmpx::iter_all_records_from(f); + let records = utmpx::Utmpx::iter_all_records_from(f); if self.include_heading { self.print_heading(); @@ -248,7 +248,7 @@ impl Who { } #[inline] - fn print_runlevel(&self, ut: &Utmpx) { + fn print_runlevel(&self, ut: &UtmpxRecord) { let last = (ut.pid() / 256) as u8 as char; let curr = (ut.pid() % 256) as u8 as char; let runlevel_line = translate!("who-runlevel", "level" => curr); @@ -268,7 +268,7 @@ impl Who { } #[inline] - fn print_clockchange(&self, ut: &Utmpx) { + fn print_clockchange(&self, ut: &UtmpxRecord) { self.print_line( "", ' ', @@ -282,7 +282,7 @@ impl Who { } #[inline] - fn print_login(&self, ut: &Utmpx) { + fn print_login(&self, ut: &UtmpxRecord) { let comment = translate!("who-login-id", "id" => ut.terminal_suffix()); let pidstr = format!("{}", ut.pid()); self.print_line( @@ -298,7 +298,7 @@ impl Who { } #[inline] - fn print_deadprocs(&self, ut: &Utmpx) { + fn print_deadprocs(&self, ut: &UtmpxRecord) { let comment = translate!("who-login-id", "id" => ut.terminal_suffix()); let pidstr = format!("{}", ut.pid()); let e = ut.exit_status(); @@ -316,7 +316,7 @@ impl Who { } #[inline] - fn print_initspawn(&self, ut: &Utmpx) { + fn print_initspawn(&self, ut: &UtmpxRecord) { let comment = translate!("who-login-id", "id" => ut.terminal_suffix()); let pidstr = format!("{}", ut.pid()); self.print_line( @@ -332,7 +332,7 @@ impl Who { } #[inline] - fn print_boottime(&self, ut: &Utmpx) { + fn print_boottime(&self, ut: &UtmpxRecord) { self.print_line( "", ' ', @@ -345,7 +345,7 @@ impl Who { ); } - fn print_user(&self, ut: &Utmpx) -> UResult<()> { + fn print_user(&self, ut: &UtmpxRecord) -> UResult<()> { let mut p = PathBuf::from("/dev"); p.push(ut.tty_device().as_str()); let mesg; diff --git a/src/uucore/Cargo.toml b/src/uucore/Cargo.toml index a316d1f0abf..2cefa701b98 100644 --- a/src/uucore/Cargo.toml +++ b/src/uucore/Cargo.toml @@ -1,4 +1,4 @@ -# spell-checker:ignore (features) bigdecimal zerocopy extendedbigdecimal tzdb zoneinfo +# spell-checker:ignore (features) bigdecimal zerocopy extendedbigdecimal tzdb zoneinfo logind [package] name = "uucore" @@ -113,6 +113,7 @@ fs = ["dunce", "libc", "winapi-util", "windows-sys"] fsext = ["libc", "windows-sys"] fsxattr = ["xattr"] lines = [] +feat_systemd_logind = ["utmpx", "libc"] format = [ "bigdecimal", "extendedbigdecimal", diff --git a/src/uucore/src/lib/features.rs b/src/uucore/src/lib/features.rs index d06fbed545a..4f7f7b8093a 100644 --- a/src/uucore/src/lib/features.rs +++ b/src/uucore/src/lib/features.rs @@ -4,7 +4,7 @@ // file that was distributed with this source code. // features ~ feature-gated modules (core/bundler file) // -// spell-checker:ignore (features) extendedbigdecimal +// spell-checker:ignore (features) extendedbigdecimal logind #[cfg(feature = "backup-control")] pub mod backup_control; @@ -74,6 +74,8 @@ pub mod fsxattr; pub mod selinux; #[cfg(all(unix, not(target_os = "fuchsia"), feature = "signals"))] pub mod signals; +#[cfg(feature = "feat_systemd_logind")] +pub mod systemd_logind; #[cfg(all( unix, not(target_os = "android"), diff --git a/src/uucore/src/lib/features/systemd_logind.rs b/src/uucore/src/lib/features/systemd_logind.rs new file mode 100644 index 00000000000..c79a3fb99ad --- /dev/null +++ b/src/uucore/src/lib/features/systemd_logind.rs @@ -0,0 +1,632 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. +// +// spell-checker:ignore logind libsystemd btime unref RAII + +//! Systemd-logind support for reading login records +//! +//! 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. + +use std::ffi::CStr; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::error::{UResult, USimpleError}; +use crate::utmpx; + +/// FFI bindings for libsystemd login and D-Bus functions +mod ffi { + use std::ffi::c_char; + use std::os::raw::{c_int, c_uint}; + + #[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; + + } +} + +/// Safe wrapper functions for libsystemd FFI calls +mod login { + use super::ffi; + use std::ffi::{CStr, CString}; + use std::ptr; + use std::time::SystemTime; + + /// Get all active sessions + pub fn get_sessions() -> Result, Box> { + let mut sessions_ptr: *mut *mut i8 = ptr::null_mut(); + + let result = unsafe { ffi::sd_get_sessions(&mut sessions_ptr) }; + + if result < 0 { + return Err(format!("sd_get_sessions failed: {}", result).into()); + } + + let mut sessions = Vec::new(); + if !sessions_ptr.is_null() { + let mut i = 0; + loop { + let session_ptr = unsafe { *sessions_ptr.add(i) }; + if session_ptr.is_null() { + break; + } + + let session_cstr = unsafe { CStr::from_ptr(session_ptr) }; + sessions.push(session_cstr.to_string_lossy().into_owned()); + + unsafe { libc::free(session_ptr as *mut libc::c_void) }; + i += 1; + } + + unsafe { libc::free(sessions_ptr as *mut libc::c_void) }; + } + + Ok(sessions) + } + + /// Get UID for a session + pub fn get_session_uid(session_id: &str) -> Result> { + let session_cstring = CString::new(session_id)?; + let mut uid: std::os::raw::c_uint = 0; + + let result = unsafe { ffi::sd_session_get_uid(session_cstring.as_ptr(), &mut uid) }; + + if result < 0 { + return Err(format!( + "sd_session_get_uid failed for session '{}': {}", + session_id, result + ) + .into()); + } + + Ok(uid) + } + + /// Get start time for a session (in microseconds since Unix epoch) + pub fn get_session_start_time(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(), &mut usec) }; + + if result < 0 { + return Err(format!( + "sd_session_get_start_time failed for session '{}': {}", + session_id, result + ) + .into()); + } + + Ok(usec) + } + + /// Get TTY for a session + pub fn get_session_tty(session_id: &str) -> Result, Box> { + let session_cstring = CString::new(session_id)?; + let mut tty_ptr: *mut i8 = ptr::null_mut(); + + let result = unsafe { ffi::sd_session_get_tty(session_cstring.as_ptr(), &mut tty_ptr) }; + + if result < 0 { + return Err(format!( + "sd_session_get_tty failed for session '{}': {}", + session_id, result + ) + .into()); + } + + if tty_ptr.is_null() { + return Ok(None); + } + + let tty_cstr = unsafe { CStr::from_ptr(tty_ptr) }; + let tty_string = tty_cstr.to_string_lossy().into_owned(); + + unsafe { libc::free(tty_ptr as *mut libc::c_void) }; + + Ok(Some(tty_string)) + } + + /// Get remote host for a session + pub fn get_session_remote_host( + session_id: &str, + ) -> Result, Box> { + let session_cstring = CString::new(session_id)?; + let mut host_ptr: *mut i8 = ptr::null_mut(); + + let result = + unsafe { ffi::sd_session_get_remote_host(session_cstring.as_ptr(), &mut host_ptr) }; + + if result < 0 { + return Err(format!( + "sd_session_get_remote_host failed for session '{}': {}", + session_id, result + ) + .into()); + } + + if host_ptr.is_null() { + return Ok(None); + } + + let host_cstr = unsafe { CStr::from_ptr(host_ptr) }; + let host_string = host_cstr.to_string_lossy().into_owned(); + + unsafe { libc::free(host_ptr as *mut libc::c_void) }; + + Ok(Some(host_string)) + } + + /// Get display for a session + pub fn get_session_display( + session_id: &str, + ) -> Result, Box> { + let session_cstring = CString::new(session_id)?; + let mut display_ptr: *mut i8 = ptr::null_mut(); + + let result = + unsafe { ffi::sd_session_get_display(session_cstring.as_ptr(), &mut display_ptr) }; + + if result < 0 { + return Err(format!( + "sd_session_get_display failed for session '{}': {}", + session_id, result + ) + .into()); + } + + if display_ptr.is_null() { + return Ok(None); + } + + let display_cstr = unsafe { CStr::from_ptr(display_ptr) }; + let display_string = display_cstr.to_string_lossy().into_owned(); + + unsafe { libc::free(display_ptr as *mut libc::c_void) }; + + Ok(Some(display_string)) + } + + /// Get type for a session + pub fn get_session_type( + session_id: &str, + ) -> Result, Box> { + let session_cstring = CString::new(session_id)?; + let mut type_ptr: *mut i8 = ptr::null_mut(); + + let result = unsafe { ffi::sd_session_get_type(session_cstring.as_ptr(), &mut type_ptr) }; + + if result < 0 { + return Err(format!( + "sd_session_get_type failed for session '{}': {}", + session_id, result + ) + .into()); + } + + if type_ptr.is_null() { + return Ok(None); + } + + let type_cstr = unsafe { CStr::from_ptr(type_ptr) }; + let type_string = type_cstr.to_string_lossy().into_owned(); + + unsafe { libc::free(type_ptr as *mut libc::c_void) }; + + Ok(Some(type_string)) + } + + /// Get seat for a session + pub fn get_session_seat( + session_id: &str, + ) -> Result, Box> { + let session_cstring = CString::new(session_id)?; + let mut seat_ptr: *mut i8 = ptr::null_mut(); + + let result = unsafe { ffi::sd_session_get_seat(session_cstring.as_ptr(), &mut seat_ptr) }; + + if result < 0 { + return Err(format!( + "sd_session_get_seat failed for session '{}': {}", + session_id, result + ) + .into()); + } + + if seat_ptr.is_null() { + return Ok(None); + } + + let seat_cstr = unsafe { CStr::from_ptr(seat_ptr) }; + let seat_string = seat_cstr.to_string_lossy().into_owned(); + + unsafe { libc::free(seat_ptr as *mut libc::c_void) }; + + 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))?; + + metadata + .modified() + .map_err(|e| format!("Failed to get modification time: {}", e).into()) + } +} + +/// Login record compatible with utmpx structure +#[derive(Debug, Clone)] +pub struct SystemdLoginRecord { + pub user: String, + pub session_id: String, + pub seat_or_tty: String, + pub raw_device: String, + pub host: String, + pub login_time: SystemTime, + pub pid: u32, + pub session_leader_pid: u32, + pub record_type: SystemdRecordType, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum SystemdRecordType { + UserProcess = 7, // USER_PROCESS + LoginProcess = 6, // LOGIN_PROCESS + BootTime = 2, // BOOT_TIME +} + +impl SystemdLoginRecord { + /// Check if this is a user process record + pub fn is_user_process(&self) -> bool { + !self.user.is_empty() && self.record_type == SystemdRecordType::UserProcess + } + + /// Get login time as time::OffsetDateTime compatible with utmpx + pub fn login_time_offset(&self) -> utmpx::time::OffsetDateTime { + let duration = self + .login_time + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + let ts_nanos: i128 = (duration.as_nanos()).try_into().unwrap_or(0); + let local_offset = utmpx::time::OffsetDateTime::now_local() + .map_or_else(|_| utmpx::time::UtcOffset::UTC, |v| v.offset()); + utmpx::time::OffsetDateTime::from_unix_timestamp_nanos(ts_nanos) + .unwrap_or_else(|_| { + utmpx::time::OffsetDateTime::now_local() + .unwrap_or_else(|_| utmpx::time::OffsetDateTime::now_utc()) + }) + .to_offset(local_offset) + } +} + +/// 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 mut records = Vec::new(); + + // Add boot time record first + if let Ok(boot_time) = login::get_boot_time() { + let boot_record = SystemdLoginRecord { + user: "reboot".to_string(), + session_id: "boot".to_string(), + seat_or_tty: "~".to_string(), // Traditional boot time indicator + raw_device: String::new(), + host: String::new(), + login_time: boot_time, + pid: 0, + session_leader_pid: 0, + record_type: SystemdRecordType::BootTime, + }; + records.push(boot_record); + } + + // Get all active sessions using safe wrapper + let mut sessions = match login::get_sessions() { + Ok(sessions) => sessions, + Err(e) => { + return Err(USimpleError::new( + 1, + format!("Failed to get systemd sessions: {}", e), + )); + } + }; + + // Sort sessions consistently for reproducible output (reverse for TTY sessions first) + sessions.sort(); + sessions.reverse(); + + // Iterate through all sessions + for session_id in sessions { + // Get session UID using safe wrapper + let uid = match login::get_session_uid(&session_id) { + Ok(uid) => uid, + Err(_) => continue, + }; + + // Get username from UID + let user = unsafe { + let passwd = libc::getpwuid(uid); + if passwd.is_null() { + format!("{}", uid) // fallback to UID if username not found + } else { + CStr::from_ptr((*passwd).pw_name) + .to_string_lossy() + .into_owned() + } + }; + + // Get start time using safe wrapper + let start_time = login::get_session_start_time(&session_id) + .map(|usec| UNIX_EPOCH + std::time::Duration::from_micros(usec)) + .unwrap_or(UNIX_EPOCH); // fallback to epoch if unavailable + + // Get TTY using safe wrapper + let mut tty = login::get_session_tty(&session_id) + .ok() + .flatten() + .unwrap_or_default(); + + // Get seat using safe wrapper + let mut seat = login::get_session_seat(&session_id) + .ok() + .flatten() + .unwrap_or_default(); + + // Strip any existing prefixes from systemd values (if any) + if tty.starts_with('?') { + tty = tty[1..].to_string(); + } + if seat.starts_with('?') { + seat = seat[1..].to_string(); + } + + // Get remote host using safe wrapper + let remote_host = login::get_session_remote_host(&session_id) + .ok() + .flatten() + .unwrap_or_default(); + + // Get display using safe wrapper (for GUI sessions) + let display = login::get_session_display(&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) + .ok() + .flatten() + .unwrap_or_default(); + + // Determine host (use remote_host if available) + let host = if !remote_host.is_empty() { + remote_host + } else { + String::new() + }; + + // Skip sessions that have neither TTY nor seat (e.g., manager sessions) + if tty.is_empty() && seat.is_empty() && display.is_empty() { + continue; + } + + // A single session can be associated with both a TTY and a seat. + // GNU `who` and `pinky` create separate records for each. + // We replicate that behavior here. + // Order: seat first, then TTY to match expected output + + // Create a record for the seat if it's not empty. + // The seat is prefixed with '?' to match GNU's output. + if !seat.is_empty() { + let seat_formatted = format!("?{}", seat); + records.push(SystemdLoginRecord { + user: user.clone(), + session_id: session_id.clone(), + seat_or_tty: seat_formatted, + raw_device: seat.clone(), // Store raw seat name for device access + host: host.clone(), + login_time: start_time, + pid: 0, + session_leader_pid: 0, + record_type: SystemdRecordType::UserProcess, + }); + } + + if !tty.is_empty() { + let tty_formatted = if tty.starts_with("tty") { + format!("*{}", tty) + } else { + tty.clone() + }; + + records.push(SystemdLoginRecord { + user: user.clone(), + session_id: session_id.clone(), + seat_or_tty: tty_formatted, + raw_device: tty.clone(), // Store raw TTY for device access + host: host.clone(), + login_time: start_time, + pid: 0, // systemd doesn't directly provide session leader PID in this context + session_leader_pid: 0, + record_type: SystemdRecordType::UserProcess, + }); + } + + // If only display session, create a fallback record + if tty.is_empty() && seat.is_empty() && !display.is_empty() { + records.push(SystemdLoginRecord { + user, + session_id: session_id.clone(), + seat_or_tty: display, + raw_device: String::new(), // No raw device for display sessions + host, + login_time: start_time, + pid: 0, + session_leader_pid: 0, + record_type: SystemdRecordType::UserProcess, + }); + } + } + + Ok(records) +} + +/// Wrapper to provide utmpx-compatible interface for a single record +pub struct SystemdUtmpxCompat { + record: SystemdLoginRecord, +} + +impl SystemdUtmpxCompat { + /// Create new instance from a SystemdLoginRecord + pub fn new(record: SystemdLoginRecord) -> Self { + SystemdUtmpxCompat { record } + } + + /// A.K.A. ut.ut_type + pub fn record_type(&self) -> i16 { + self.record.record_type as i16 + } + + /// A.K.A. ut.ut_pid + pub fn pid(&self) -> i32 { + self.record.pid as i32 + } + + /// A.K.A. ut.ut_id + pub fn terminal_suffix(&self) -> String { + // Extract last part of session ID or use session ID + self.record.session_id.clone() + } + + /// A.K.A. ut.ut_user + pub fn user(&self) -> String { + self.record.user.clone() + } + + /// A.K.A. ut.ut_host + pub fn host(&self) -> String { + self.record.host.clone() + } + + /// A.K.A. ut.ut_line + pub fn tty_device(&self) -> String { + // Return raw device name for device access if available, otherwise formatted seat_or_tty + if !self.record.raw_device.is_empty() { + self.record.raw_device.clone() + } else { + self.record.seat_or_tty.clone() + } + } + + /// Login time + pub fn login_time(&self) -> utmpx::time::OffsetDateTime { + self.record.login_time_offset() + } + + /// Exit status (not available from systemd) + pub fn exit_status(&self) -> (i16, i16) { + (0, 0) // Not available from systemd + } + + /// Check if this is a user process record + pub fn is_user_process(&self) -> bool { + self.record.is_user_process() + } + + /// Canonical host name + pub fn canon_host(&self) -> std::io::Result { + // Simple implementation - just return the host as-is + // Could be enhanced with DNS lookup like the original + Ok(self.record.host.clone()) + } +} + +/// Container for reading multiple systemd records +pub struct SystemdUtmpxIter { + records: Vec, + current_index: usize, +} + +impl SystemdUtmpxIter { + /// Create new instance and read records from systemd-logind + pub fn new() -> UResult { + let records = read_login_records()?; + Ok(SystemdUtmpxIter { + records, + current_index: 0, + }) + } + + /// Get next record (similar to getutxent) + pub fn next_record(&mut self) -> Option { + if self.current_index >= self.records.len() { + return None; + } + + let record = self.records[self.current_index].clone(); + self.current_index += 1; + + Some(SystemdUtmpxCompat::new(record)) + } + + /// Get all records at once + pub fn get_all_records(&self) -> Vec { + self.records + .iter() + .cloned() + .map(SystemdUtmpxCompat::new) + .collect() + } + + /// Reset iterator to beginning + pub fn reset(&mut self) { + self.current_index = 0; + } + + /// Get number of records + pub fn len(&self) -> usize { + self.records.len() + } + + /// Check if empty + pub fn is_empty(&self) -> bool { + self.records.is_empty() + } +} + +impl Iterator for SystemdUtmpxIter { + type Item = SystemdUtmpxCompat; + + fn next(&mut self) -> Option { + self.next_record() + } +} diff --git a/src/uucore/src/lib/features/utmpx.rs b/src/uucore/src/lib/features/utmpx.rs index 1dba4e16e16..46913facca9 100644 --- a/src/uucore/src/lib/features/utmpx.rs +++ b/src/uucore/src/lib/features/utmpx.rs @@ -3,6 +3,8 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. // +// spell-checker:ignore logind + //! Aims to provide platform-independent methods to obtain login records //! //! **ONLY** support linux, macos and freebsd for the time being @@ -39,6 +41,9 @@ use std::path::Path; use std::ptr; use std::sync::{Mutex, MutexGuard}; +#[cfg(feature = "feat_systemd_logind")] +use crate::features::systemd_logind; + pub use self::ut::*; // See the FAQ at https://wiki.musl-libc.org/faq#Q:-Why-is-the-utmp/wtmp-functionality-only-implemented-as-stubs? @@ -278,18 +283,30 @@ 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. + /// /// 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 { - let iter = UtmpxIter::new(); - unsafe { - // This can technically fail, and it would be nice to detect that, - // but it doesn't return anything so we'd have to do nasty things - // with errno. - #[cfg_attr(target_env = "musl", allow(deprecated))] - setutxent(); + #[cfg(feature = "feat_systemd_logind")] + { + // Use systemd-logind instead of traditional utmp when feature is enabled + UtmpxIter::new_systemd() + } + + #[cfg(not(feature = "feat_systemd_logind"))] + { + let iter = UtmpxIter::new(); + unsafe { + // This can technically fail, and it would be nice to detect that, + // but it doesn't return anything so we'd have to do nasty things + // with errno. + #[cfg_attr(target_env = "musl", allow(deprecated))] + setutxent(); + } + iter } - iter } /// Iterate through all the utmp records from a specific file. @@ -298,8 +315,20 @@ 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. + /// /// The same caveats as for [`Utmpx::iter_all_records`] apply. pub fn iter_all_records_from>(path: P) -> UtmpxIter { + #[cfg(feature = "feat_systemd_logind")] + { + // Use systemd-logind for default utmp file when feature is enabled + if path.as_ref().to_str() == Some(DEFAULT_FILE) { + return UtmpxIter::new_systemd(); + } + } + let iter = UtmpxIter::new(); let path = CString::new(path.as_ref().as_os_str().as_bytes()).unwrap(); unsafe { @@ -336,6 +365,8 @@ pub struct UtmpxIter { /// Ensure UtmpxIter is !Send. Technically redundant because MutexGuard /// is also !Send. phantom: PhantomData>, + #[cfg(feature = "feat_systemd_logind")] + systemd_iter: Option, } impl UtmpxIter { @@ -345,13 +376,137 @@ impl UtmpxIter { Self { guard, phantom: PhantomData, + #[cfg(feature = "feat_systemd_logind")] + systemd_iter: None, + } + } + + #[cfg(feature = "feat_systemd_logind")] + fn new_systemd() -> Self { + // PoisonErrors can safely be ignored + let guard = LOCK.lock().unwrap_or_else(|err| err.into_inner()); + let systemd_iter = systemd_logind::SystemdUtmpxIter::new().ok(); + Self { + guard, + phantom: PhantomData, + systemd_iter, + } + } +} + +/// Wrapper type that can hold either traditional utmpx records or systemd records +pub enum UtmpxRecord { + Traditional(Box), + #[cfg(feature = "feat_systemd_logind")] + Systemd(systemd_logind::SystemdUtmpxCompat), +} + +impl UtmpxRecord { + /// A.K.A. ut.ut_type + pub fn record_type(&self) -> i16 { + match self { + UtmpxRecord::Traditional(utmpx) => utmpx.record_type(), + #[cfg(feature = "feat_systemd_logind")] + UtmpxRecord::Systemd(systemd) => systemd.record_type(), + } + } + + /// A.K.A. ut.ut_pid + pub fn pid(&self) -> i32 { + match self { + UtmpxRecord::Traditional(utmpx) => utmpx.pid(), + #[cfg(feature = "feat_systemd_logind")] + UtmpxRecord::Systemd(systemd) => systemd.pid(), + } + } + + /// A.K.A. ut.ut_id + pub fn terminal_suffix(&self) -> String { + match self { + UtmpxRecord::Traditional(utmpx) => utmpx.terminal_suffix(), + #[cfg(feature = "feat_systemd_logind")] + UtmpxRecord::Systemd(systemd) => systemd.terminal_suffix(), + } + } + + /// A.K.A. ut.ut_user + pub fn user(&self) -> String { + match self { + UtmpxRecord::Traditional(utmpx) => utmpx.user(), + #[cfg(feature = "feat_systemd_logind")] + UtmpxRecord::Systemd(systemd) => systemd.user(), + } + } + + /// A.K.A. ut.ut_host + pub fn host(&self) -> String { + match self { + UtmpxRecord::Traditional(utmpx) => utmpx.host(), + #[cfg(feature = "feat_systemd_logind")] + UtmpxRecord::Systemd(systemd) => systemd.host(), + } + } + + /// A.K.A. ut.ut_line + pub fn tty_device(&self) -> String { + match self { + UtmpxRecord::Traditional(utmpx) => utmpx.tty_device(), + #[cfg(feature = "feat_systemd_logind")] + UtmpxRecord::Systemd(systemd) => systemd.tty_device(), + } + } + + /// A.K.A. ut.ut_tv + pub fn login_time(&self) -> time::OffsetDateTime { + match self { + UtmpxRecord::Traditional(utmpx) => utmpx.login_time(), + #[cfg(feature = "feat_systemd_logind")] + UtmpxRecord::Systemd(systemd) => systemd.login_time(), + } + } + + /// A.K.A. ut.ut_exit + /// + /// Return (e_termination, e_exit) + pub fn exit_status(&self) -> (i16, i16) { + match self { + UtmpxRecord::Traditional(utmpx) => utmpx.exit_status(), + #[cfg(feature = "feat_systemd_logind")] + UtmpxRecord::Systemd(systemd) => systemd.exit_status(), + } + } + + /// check if the record is a user process + pub fn is_user_process(&self) -> bool { + match self { + UtmpxRecord::Traditional(utmpx) => utmpx.is_user_process(), + #[cfg(feature = "feat_systemd_logind")] + UtmpxRecord::Systemd(systemd) => systemd.is_user_process(), + } + } + + /// Canonicalize host name using DNS + pub fn canon_host(&self) -> IOResult { + match self { + UtmpxRecord::Traditional(utmpx) => utmpx.canon_host(), + #[cfg(feature = "feat_systemd_logind")] + UtmpxRecord::Systemd(systemd) => systemd.canon_host(), } } } impl Iterator for UtmpxIter { - type Item = Utmpx; + type Item = UtmpxRecord; fn next(&mut self) -> Option { + #[cfg(feature = "feat_systemd_logind")] + { + if let Some(ref mut systemd_iter) = self.systemd_iter { + if let Some(systemd_record) = systemd_iter.next() { + return Some(UtmpxRecord::Systemd(systemd_record)); + } + } + } + unsafe { #[cfg_attr(target_env = "musl", allow(deprecated))] let res = getutxent(); @@ -362,9 +517,9 @@ impl Iterator for UtmpxIter { // call to getutxent(), so we have to read it now. // All the strings live inline in the struct as arrays, which // makes things easier. - Some(Utmpx { + Some(UtmpxRecord::Traditional(Box::new(Utmpx { inner: ptr::read(res as *const _), - }) + }))) } } } diff --git a/src/uucore/src/lib/lib.rs b/src/uucore/src/lib/lib.rs index af0e039d063..7b16baff9ce 100644 --- a/src/uucore/src/lib/lib.rs +++ b/src/uucore/src/lib/lib.rs @@ -5,7 +5,7 @@ //! library ~ (core/bundler file) // #![deny(missing_docs)] //TODO: enable this // -// spell-checker:ignore sigaction SIGBUS SIGSEGV extendedbigdecimal myutil +// spell-checker:ignore sigaction SIGBUS SIGSEGV extendedbigdecimal myutil logind // * feature-gated external crates (re-shared as public internal modules) #[cfg(feature = "libc")] @@ -67,6 +67,8 @@ pub use crate::features::ranges; pub use crate::features::ringbuffer; #[cfg(feature = "sum")] pub use crate::features::sum; +#[cfg(feature = "feat_systemd_logind")] +pub use crate::features::systemd_logind; #[cfg(feature = "time")] pub use crate::features::time; #[cfg(feature = "update-control")] From 90e4adaba440c0ba1a170a6f7624066bc69f62a1 Mon Sep 17 00:00:00 2001 From: Etienne Cordonnier Date: Wed, 20 Aug 2025 23:32:11 +0200 Subject: [PATCH 02/14] fix duplicated boot record When feature feat_systemd_logind is enabled and utmp is present, two boot records were printed. Signed-off-by: Etienne Cordonnier --- src/uucore/src/lib/features/utmpx.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/uucore/src/lib/features/utmpx.rs b/src/uucore/src/lib/features/utmpx.rs index 46913facca9..974a82661ba 100644 --- a/src/uucore/src/lib/features/utmpx.rs +++ b/src/uucore/src/lib/features/utmpx.rs @@ -503,6 +503,9 @@ impl Iterator for UtmpxIter { if let Some(ref mut systemd_iter) = self.systemd_iter { if let Some(systemd_record) = systemd_iter.next() { return Some(UtmpxRecord::Systemd(systemd_record)); + } else { + // When systemd iterator is exhausted, don't fall back to traditional utmp + return None; } } } From 7c9c77450952f850febfc78b9209afae875049e7 Mon Sep 17 00:00:00 2001 From: Etienne Date: Thu, 21 Aug 2025 20:54:12 +0200 Subject: [PATCH 03/14] improve error forwarding Co-authored-by: Daniel Hofstetter --- src/uucore/src/lib/features/systemd_logind.rs | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/uucore/src/lib/features/systemd_logind.rs b/src/uucore/src/lib/features/systemd_logind.rs index c79a3fb99ad..5742bf1cb48 100644 --- a/src/uucore/src/lib/features/systemd_logind.rs +++ b/src/uucore/src/lib/features/systemd_logind.rs @@ -350,15 +350,8 @@ pub fn read_login_records() -> UResult> { } // Get all active sessions using safe wrapper - let mut sessions = match login::get_sessions() { - Ok(sessions) => sessions, - Err(e) => { - return Err(USimpleError::new( - 1, - format!("Failed to get systemd sessions: {}", e), - )); - } - }; + let mut sessions = login::get_sessions() + .map_err(|e| USimpleError::new(1, format!("Failed to get systemd sessions: {e}")))?; // Sort sessions consistently for reproducible output (reverse for TTY sessions first) sessions.sort(); From 524e230471c9bede35b12962ef138312f41a416a Mon Sep 17 00:00:00 2001 From: Etienne Date: Thu, 21 Aug 2025 20:58:31 +0200 Subject: [PATCH 04/14] remove unnecessary negation in if/else block Co-authored-by: Daniel Hofstetter --- src/uucore/src/lib/features/systemd_logind.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/uucore/src/lib/features/systemd_logind.rs b/src/uucore/src/lib/features/systemd_logind.rs index 5742bf1cb48..a2d925cadb7 100644 --- a/src/uucore/src/lib/features/systemd_logind.rs +++ b/src/uucore/src/lib/features/systemd_logind.rs @@ -421,10 +421,10 @@ pub fn read_login_records() -> UResult> { .unwrap_or_default(); // Determine host (use remote_host if available) - let host = if !remote_host.is_empty() { - remote_host - } else { + let host = if remote_host.is_empty() { String::new() + } else { + remote_host }; // Skip sessions that have neither TTY nor seat (e.g., manager sessions) From 76d55cf89530fe3d6444f91828a81cfe651a2f35 Mon Sep 17 00:00:00 2001 From: Etienne Cordonnier Date: Wed, 20 Aug 2025 23:57:21 +0200 Subject: [PATCH 05/14] use getpwuid_r() instead of getpwuid() getpwuid() is not thread-safe. Signed-off-by: Etienne Cordonnier --- src/uucore/src/lib/features/systemd_logind.rs | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/uucore/src/lib/features/systemd_logind.rs b/src/uucore/src/lib/features/systemd_logind.rs index a2d925cadb7..2216afaa98f 100644 --- a/src/uucore/src/lib/features/systemd_logind.rs +++ b/src/uucore/src/lib/features/systemd_logind.rs @@ -367,13 +367,24 @@ pub fn read_login_records() -> UResult> { // Get username from UID let user = unsafe { - let passwd = libc::getpwuid(uid); - if passwd.is_null() { - format!("{}", uid) // fallback to UID if username not found - } else { - CStr::from_ptr((*passwd).pw_name) + let mut passwd: libc::passwd = std::mem::zeroed(); + let mut buf = vec![0u8; 1024]; + let mut result: *mut libc::passwd = std::ptr::null_mut(); + + let ret = libc::getpwuid_r( + uid, + &mut passwd, + buf.as_mut_ptr() as *mut libc::c_char, + buf.len(), + &mut result, + ); + + if ret == 0 && !result.is_null() { + CStr::from_ptr(passwd.pw_name) .to_string_lossy() .into_owned() + } else { + format!("{}", uid) // fallback to UID if username not found } }; From 29aa34b6333072041132975aa00b14fd3bc8696a Mon Sep 17 00:00:00 2001 From: Etienne Cordonnier Date: Thu, 21 Aug 2025 20:42:43 +0200 Subject: [PATCH 06/14] improve error handling Signed-off-by: Etienne Cordonnier --- src/uucore/src/lib/features/systemd_logind.rs | 8 ++++++++ src/uucore/src/lib/features/utmpx.rs | 20 +++++++++++-------- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/uucore/src/lib/features/systemd_logind.rs b/src/uucore/src/lib/features/systemd_logind.rs index 2216afaa98f..1b69632d1ca 100644 --- a/src/uucore/src/lib/features/systemd_logind.rs +++ b/src/uucore/src/lib/features/systemd_logind.rs @@ -590,6 +590,14 @@ impl SystemdUtmpxIter { }) } + /// Create empty iterator (for when systemd initialization fails) + pub fn empty() -> Self { + SystemdUtmpxIter { + records: Vec::new(), + current_index: 0, + } + } + /// Get next record (similar to getutxent) pub fn next_record(&mut self) -> Option { if self.current_index >= self.records.len() { diff --git a/src/uucore/src/lib/features/utmpx.rs b/src/uucore/src/lib/features/utmpx.rs index 974a82661ba..d301504c35b 100644 --- a/src/uucore/src/lib/features/utmpx.rs +++ b/src/uucore/src/lib/features/utmpx.rs @@ -385,11 +385,18 @@ impl UtmpxIter { fn new_systemd() -> Self { // PoisonErrors can safely be ignored let guard = LOCK.lock().unwrap_or_else(|err| err.into_inner()); - let systemd_iter = systemd_logind::SystemdUtmpxIter::new().ok(); + 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() + } + }; Self { guard, phantom: PhantomData, - systemd_iter, + systemd_iter: Some(systemd_iter), } } } @@ -501,15 +508,12 @@ impl Iterator for UtmpxIter { #[cfg(feature = "feat_systemd_logind")] { if let Some(ref mut systemd_iter) = self.systemd_iter { - if let Some(systemd_record) = systemd_iter.next() { - return Some(UtmpxRecord::Systemd(systemd_record)); - } else { - // When systemd iterator is exhausted, don't fall back to traditional utmp - return None; - } + // We have a systemd iterator - use it exclusively (never fall back to traditional utmp) + return systemd_iter.next().map(UtmpxRecord::Systemd); } } + // Traditional utmp path unsafe { #[cfg_attr(target_env = "musl", allow(deprecated))] let res = getutxent(); From 21aab014a862cada63f1fb21ce9bc4074283d140 Mon Sep 17 00:00:00 2001 From: Etienne Cordonnier Date: Thu, 21 Aug 2025 20:46:47 +0200 Subject: [PATCH 07/14] use Path instead of string Signed-off-by: Etienne Cordonnier --- src/uucore/src/lib/features/utmpx.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/uucore/src/lib/features/utmpx.rs b/src/uucore/src/lib/features/utmpx.rs index d301504c35b..3b84a17d388 100644 --- a/src/uucore/src/lib/features/utmpx.rs +++ b/src/uucore/src/lib/features/utmpx.rs @@ -324,7 +324,7 @@ impl Utmpx { #[cfg(feature = "feat_systemd_logind")] { // Use systemd-logind for default utmp file when feature is enabled - if path.as_ref().to_str() == Some(DEFAULT_FILE) { + if path.as_ref() == Path::new(DEFAULT_FILE) { return UtmpxIter::new_systemd(); } } From d63813a35847ab858a963b81c9c32b3154315012 Mon Sep 17 00:00:00 2001 From: Etienne Cordonnier Date: Thu, 21 Aug 2025 22:04:54 +0200 Subject: [PATCH 08/14] use closure to reduce the number of clones() calls Signed-off-by: Etienne Cordonnier --- src/uucore/src/lib/features/systemd_logind.rs | 88 +++++++++++-------- 1 file changed, 49 insertions(+), 39 deletions(-) diff --git a/src/uucore/src/lib/features/systemd_logind.rs b/src/uucore/src/lib/features/systemd_logind.rs index 1b69632d1ca..8c56f24ae83 100644 --- a/src/uucore/src/lib/features/systemd_logind.rs +++ b/src/uucore/src/lib/features/systemd_logind.rs @@ -448,56 +448,66 @@ pub fn read_login_records() -> UResult> { // We replicate that behavior here. // Order: seat first, then TTY to match expected output - // Create a record for the seat if it's not empty. - // The seat is prefixed with '?' to match GNU's output. - if !seat.is_empty() { - let seat_formatted = format!("?{}", seat); - records.push(SystemdLoginRecord { - user: user.clone(), - session_id: session_id.clone(), - seat_or_tty: seat_formatted, - raw_device: seat.clone(), // Store raw seat name for device access - host: host.clone(), + // Helper closure to create a record + let create_record = |seat_or_tty: String, + raw_device: String, + user: String, + session_id: String, + host: String| { + SystemdLoginRecord { + user, + session_id, + seat_or_tty, + raw_device, + host, login_time: start_time, - pid: 0, + pid: 0, // systemd doesn't directly provide session leader PID in this context session_leader_pid: 0, record_type: SystemdRecordType::UserProcess, - }); - } + } + }; + + // Create records based on available seat/tty/display + if !seat.is_empty() && !tty.is_empty() { + // Both seat and tty - need 2 records, clone for first. + // The seat is prefixed with '?' to match GNU's output. + let seat_formatted = format!("?{}", seat); + records.push(create_record( + seat_formatted, + seat, + user.clone(), + session_id.clone(), + host.clone(), + )); - if !tty.is_empty() { let tty_formatted = if tty.starts_with("tty") { format!("*{}", tty) } else { tty.clone() }; - - records.push(SystemdLoginRecord { - user: user.clone(), - session_id: session_id.clone(), - seat_or_tty: tty_formatted, - raw_device: tty.clone(), // Store raw TTY for device access - host: host.clone(), - login_time: start_time, - pid: 0, // systemd doesn't directly provide session leader PID in this context - session_leader_pid: 0, - record_type: SystemdRecordType::UserProcess, - }); - } - - // If only display session, create a fallback record - if tty.is_empty() && seat.is_empty() && !display.is_empty() { - records.push(SystemdLoginRecord { + records.push(create_record(tty_formatted, tty, user, session_id, host)); // Move for second (and last) record + } else if !seat.is_empty() { + // Only seat + let seat_formatted = format!("?{}", seat); + records.push(create_record(seat_formatted, seat, user, session_id, host)); + } else if !tty.is_empty() { + // Only tty + let tty_formatted = if tty.starts_with("tty") { + format!("*{}", tty) + } else { + tty.clone() + }; + records.push(create_record(tty_formatted, tty, user, session_id, host)); + } else if !display.is_empty() { + // Only display + // No raw device for display sessions + records.push(create_record( + display, + String::new(), user, - session_id: session_id.clone(), - seat_or_tty: display, - raw_device: String::new(), // No raw device for display sessions + session_id, host, - login_time: start_time, - pid: 0, - session_leader_pid: 0, - record_type: SystemdRecordType::UserProcess, - }); + )); } } From 4ed58c9a611c14af36e56bfd3b68eacd4b2dfbfe Mon Sep 17 00:00:00 2001 From: Etienne Cordonnier Date: Fri, 22 Aug 2025 10:56:41 +0200 Subject: [PATCH 09/14] use MaybeUninit instead of mem::zeroed() Signed-off-by: Etienne Cordonnier --- src/uucore/src/lib/features/systemd_logind.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/uucore/src/lib/features/systemd_logind.rs b/src/uucore/src/lib/features/systemd_logind.rs index 8c56f24ae83..63d13badae3 100644 --- a/src/uucore/src/lib/features/systemd_logind.rs +++ b/src/uucore/src/lib/features/systemd_logind.rs @@ -13,6 +13,7 @@ //! this will be used instead of traditional utmp files. use std::ffi::CStr; +use std::mem::MaybeUninit; use std::time::{SystemTime, UNIX_EPOCH}; use crate::error::{UResult, USimpleError}; @@ -367,19 +368,20 @@ pub fn read_login_records() -> UResult> { // Get username from UID let user = unsafe { - let mut passwd: libc::passwd = std::mem::zeroed(); + let mut passwd = MaybeUninit::::uninit(); let mut buf = vec![0u8; 1024]; let mut result: *mut libc::passwd = std::ptr::null_mut(); let ret = libc::getpwuid_r( uid, - &mut passwd, + passwd.as_mut_ptr(), buf.as_mut_ptr() as *mut libc::c_char, buf.len(), &mut result, ); if ret == 0 && !result.is_null() { + let passwd = passwd.assume_init(); CStr::from_ptr(passwd.pw_name) .to_string_lossy() .into_owned() From 90c41d99462d6428ce6fa63d422fe1b1644d9b68 Mon Sep 17 00:00:00 2001 From: Etienne Cordonnier Date: Fri, 22 Aug 2025 11:43:44 +0200 Subject: [PATCH 10/14] systemd-logind: add some unit-tests Signed-off-by: Etienne Cordonnier --- src/uucore/src/lib/features/systemd_logind.rs | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/src/uucore/src/lib/features/systemd_logind.rs b/src/uucore/src/lib/features/systemd_logind.rs index 63d13badae3..85acafc9795 100644 --- a/src/uucore/src/lib/features/systemd_logind.rs +++ b/src/uucore/src/lib/features/systemd_logind.rs @@ -654,3 +654,114 @@ impl Iterator for SystemdUtmpxIter { self.next_record() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_empty_iterator() { + let mut iter = SystemdUtmpxIter::empty(); + + assert_eq!(iter.len(), 0); + assert!(iter.is_empty()); + assert!(iter.next().is_none()); + assert!(iter.next_record().is_none()); + } + + #[test] + fn test_iterator_with_mock_data() { + // Create iterator with mock records + let mock_records = vec![ + SystemdLoginRecord { + session_id: "session1".to_string(), + user: "user1".to_string(), + seat_or_tty: "tty1".to_string(), + raw_device: "tty1".to_string(), + host: "host1".to_string(), + login_time: std::time::UNIX_EPOCH, + pid: 1234, + session_leader_pid: 1234, + record_type: SystemdRecordType::UserProcess, + }, + SystemdLoginRecord { + session_id: "session2".to_string(), + user: "user2".to_string(), + seat_or_tty: "pts/0".to_string(), + raw_device: "pts/0".to_string(), + host: "host2".to_string(), + login_time: std::time::UNIX_EPOCH, + pid: 5678, + session_leader_pid: 5678, + record_type: SystemdRecordType::UserProcess, + }, + ]; + + let mut iter = SystemdUtmpxIter { + records: mock_records, + current_index: 0, + }; + + assert_eq!(iter.len(), 2); + assert!(!iter.is_empty()); + + // Test iterator behavior + let first = iter.next(); + assert!(first.is_some()); + + let second = iter.next(); + assert!(second.is_some()); + + let third = iter.next(); + assert!(third.is_none()); + + // Iterator should be exhausted + assert!(iter.next().is_none()); + } + + #[test] + fn test_get_all_records() { + let mock_records = vec![SystemdLoginRecord { + session_id: "session1".to_string(), + user: "user1".to_string(), + seat_or_tty: "tty1".to_string(), + raw_device: "tty1".to_string(), + host: "host1".to_string(), + login_time: std::time::UNIX_EPOCH, + pid: 1234, + session_leader_pid: 1234, + record_type: SystemdRecordType::UserProcess, + }]; + + let iter = SystemdUtmpxIter { + records: mock_records, + current_index: 0, + }; + + let all_records = iter.get_all_records(); + assert_eq!(all_records.len(), 1); + } + + #[test] + fn test_systemd_record_conversion() { + // Test that SystemdLoginRecord converts correctly to SystemdUtmpxCompat + let record = SystemdLoginRecord { + session_id: "c1".to_string(), + user: "testuser".to_string(), + seat_or_tty: "seat0".to_string(), + raw_device: "seat0".to_string(), + host: "localhost".to_string(), + login_time: std::time::UNIX_EPOCH + std::time::Duration::from_secs(1000), + pid: 9999, + session_leader_pid: 9999, + record_type: SystemdRecordType::UserProcess, + }; + + let compat = SystemdUtmpxCompat::new(record); + + // Test the actual conversion logic + assert_eq!(compat.user(), "testuser"); + assert_eq!(compat.tty_device().as_str(), "seat0"); + assert_eq!(compat.host(), "localhost"); + } +} From ed15bf98575d6820d346d2234281d8934adef0c7 Mon Sep 17 00:00:00 2001 From: Etienne Cordonnier Date: Fri, 22 Aug 2025 12:49:51 +0200 Subject: [PATCH 11/14] use sysconf(_SC_GETPW_R_SIZE_MAX) instead of 1024 Signed-off-by: Etienne Cordonnier --- src/uucore/src/lib/features/systemd_logind.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/uucore/src/lib/features/systemd_logind.rs b/src/uucore/src/lib/features/systemd_logind.rs index 85acafc9795..f43a176935f 100644 --- a/src/uucore/src/lib/features/systemd_logind.rs +++ b/src/uucore/src/lib/features/systemd_logind.rs @@ -369,7 +369,17 @@ pub fn read_login_records() -> UResult> { // Get username from UID let user = unsafe { let mut passwd = MaybeUninit::::uninit(); - let mut buf = vec![0u8; 1024]; + + // Get recommended buffer size, fall back if indeterminate + let buf_size = { + let size = libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX); + if size == -1 { + 16384 // Value was indeterminate, use fallback from getpwuid_r man page + } else { + size as usize + } + }; + let mut buf = vec![0u8; buf_size]; let mut result: *mut libc::passwd = std::ptr::null_mut(); let ret = libc::getpwuid_r( From a0911d513f74676c602b83bdd5faf28699348dee Mon Sep 17 00:00:00 2001 From: Etienne Cordonnier Date: Fri, 22 Aug 2025 12:55:22 +0200 Subject: [PATCH 12/14] fix cspell Signed-off-by: Etienne Cordonnier --- src/uucore/src/lib/features/systemd_logind.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/uucore/src/lib/features/systemd_logind.rs b/src/uucore/src/lib/features/systemd_logind.rs index f43a176935f..d2727c530a0 100644 --- a/src/uucore/src/lib/features/systemd_logind.rs +++ b/src/uucore/src/lib/features/systemd_logind.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. // -// spell-checker:ignore logind libsystemd btime unref RAII +// spell-checker:ignore logind libsystemd btime unref RAII testuser GETPW sysconf //! Systemd-logind support for reading login records //! From 16626d4653c5fa66b120f705c289d954ebd17637 Mon Sep 17 00:00:00 2001 From: Etienne Cordonnier Date: Fri, 22 Aug 2025 13:24:02 +0200 Subject: [PATCH 13/14] add auto-enablement of feat_systemd_logind in GNUmakefile Signed-off-by: Etienne Cordonnier --- GNUmakefile | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/GNUmakefile b/GNUmakefile index 20dc731d3b0..1374c95e51d 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -1,4 +1,4 @@ -# spell-checker:ignore (misc) testsuite runtest findstring (targets) busytest toybox distclean pkgs nextest ; (vars/env) BINDIR BUILDDIR CARGOFLAGS DESTDIR DOCSDIR INSTALLDIR INSTALLEES MULTICALL DATAROOTDIR TESTDIR manpages +# spell-checker:ignore (misc) testsuite runtest findstring (targets) busytest toybox distclean pkgs nextest logind ; (vars/env) BINDIR BUILDDIR CARGOFLAGS DESTDIR DOCSDIR INSTALLDIR INSTALLEES MULTICALL DATAROOTDIR TESTDIR manpages # Config options PROFILE ?= debug @@ -292,6 +292,14 @@ TEST_SPEC_FEATURE := selinux BUILD_SPEC_FEATURE := selinux endif +# Auto-enable systemd-logind support on systemd systems to prevent utmp compatibility issues +# (systemd 256.7+ removes utmp support, so feat_systemd_logind is required for who/pinky/users/uptime) +ifeq ($(OS),Linux) +ifneq ($(wildcard /run/systemd/system),) + BUILD_SPEC_FEATURE += feat_systemd_logind +endif +endif + define TEST_BUSYBOX test_busybox_$(1): -(cd $(BUSYBOX_SRC)/testsuite && bindir=$(BUILDDIR) ./runtest $(RUNTEST_ARGS) $(1)) From e26cbfbc74f10054b4469642a472ca33c34085f2 Mon Sep 17 00:00:00 2001 From: Etienne Cordonnier Date: Fri, 22 Aug 2025 13:45:27 +0200 Subject: [PATCH 14/14] Revert "add auto-enablement of feat_systemd_logind in GNUmakefile" This breaks "Tests/BusyBox test suite" in CI. This reverts commit 16626d4653c5fa66b120f705c289d954ebd17637. --- GNUmakefile | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/GNUmakefile b/GNUmakefile index 1374c95e51d..20dc731d3b0 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -1,4 +1,4 @@ -# spell-checker:ignore (misc) testsuite runtest findstring (targets) busytest toybox distclean pkgs nextest logind ; (vars/env) BINDIR BUILDDIR CARGOFLAGS DESTDIR DOCSDIR INSTALLDIR INSTALLEES MULTICALL DATAROOTDIR TESTDIR manpages +# spell-checker:ignore (misc) testsuite runtest findstring (targets) busytest toybox distclean pkgs nextest ; (vars/env) BINDIR BUILDDIR CARGOFLAGS DESTDIR DOCSDIR INSTALLDIR INSTALLEES MULTICALL DATAROOTDIR TESTDIR manpages # Config options PROFILE ?= debug @@ -292,14 +292,6 @@ TEST_SPEC_FEATURE := selinux BUILD_SPEC_FEATURE := selinux endif -# Auto-enable systemd-logind support on systemd systems to prevent utmp compatibility issues -# (systemd 256.7+ removes utmp support, so feat_systemd_logind is required for who/pinky/users/uptime) -ifeq ($(OS),Linux) -ifneq ($(wildcard /run/systemd/system),) - BUILD_SPEC_FEATURE += feat_systemd_logind -endif -endif - define TEST_BUSYBOX test_busybox_$(1): -(cd $(BUSYBOX_SRC)/testsuite && bindir=$(BUILDDIR) ./runtest $(RUNTEST_ARGS) $(1))