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..d2727c530a0 --- /dev/null +++ b/src/uucore/src/lib/features/systemd_logind.rs @@ -0,0 +1,777 @@ +// 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 testuser GETPW sysconf + +//! 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::mem::MaybeUninit; +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 = 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(); + 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 mut passwd = MaybeUninit::::uninit(); + + // 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( + uid, + 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() + } else { + format!("{}", uid) // fallback to UID if username not found + } + }; + + // 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() { + String::new() + } else { + remote_host + }; + + // 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 + + // 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, // 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(), + )); + + let tty_formatted = if tty.starts_with("tty") { + format!("*{}", tty) + } else { + tty.clone() + }; + 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, + host, + )); + } + } + + 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, + }) + } + + /// 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() { + 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() + } +} + +#[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"); + } +} diff --git a/src/uucore/src/lib/features/utmpx.rs b/src/uucore/src/lib/features/utmpx.rs index 1dba4e16e16..3b84a17d388 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() == Path::new(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,144 @@ 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 = 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: Some(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 { + // 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(); @@ -362,9 +524,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")]