diff --git a/Cargo.lock b/Cargo.lock index aaee6e9176c..827ffc019f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4480,9 +4480,12 @@ name = "uu_who" version = "0.11.0" dependencies = [ "clap", + "dns-lookup", "fluent", "rustix", + "time", "uucore", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 7107cd8a240..58e81a86f01 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -227,7 +227,7 @@ feat_os_unix_musl = [ "feat_require_unix_utmpx", ] # "feat_os_windows" == set of utilities which can be built/run on modern windows platforms -feat_os_windows = ["feat_Tier1", "stdbuf"] +feat_os_windows = ["feat_Tier1", "stdbuf", "who"] ## (secondary platforms) feature sets # "feat_os_unix_gnueabihf" == set of utilities which can be built/run on the "arm-unknown-linux-gnueabihf" target (ARMv6 Linux [hardfloat]) feat_os_unix_gnueabihf = [ @@ -623,7 +623,6 @@ uptime = { optional = true, version = "0.11.0", package = "uu_uptime", path = "s users = { optional = true, version = "0.11.0", package = "uu_users", path = "src/uu/users" } vdir = { optional = true, version = "0.11.0", package = "uu_vdir", path = "src/uu/vdir" } wc = { optional = true, version = "0.11.0", package = "uu_wc", path = "src/uu/wc" } -who = { optional = true, version = "0.11.0", package = "uu_who", path = "src/uu/who" } whoami = { optional = true, version = "0.11.0", package = "uu_whoami", path = "src/uu/whoami" } yes = { optional = true, version = "0.11.0", package = "uu_yes", path = "src/uu/yes" } @@ -665,6 +664,9 @@ rustix = { workspace = true, features = ["param", "use-libc-auxv"] } [target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies] selinux = { workspace = true, optional = true } +[target.'cfg(not(target_os = "openbsd"))'.dependencies] +who = { optional = true, version = "0.11.0", package = "uu_who", path = "src/uu/who" } + # * uutils chcon = { optional = true, version = "0.11.0", package = "uu_chcon", path = "src/uu/chcon" } runcon = { optional = true, version = "0.11.0", package = "uu_runcon", path = "src/uu/runcon" } diff --git a/build.rs b/build.rs index 2b61b84d2e4..c441797d5cf 100644 --- a/build.rs +++ b/build.rs @@ -48,6 +48,10 @@ pub fn main() { "chcon" | "runcon" => { continue; } + #[cfg(target_os = "openbsd")] + "who" => { + continue; + } "default" | "macos" | "unix" | "windows" | "selinux" | "zip" | "clap_complete" | "clap_mangen" | "fluent_syntax" | "openssl" => continue, // common/standard feature names "nightly" | "test_unimplemented" | "expensive_tests" | "test_risky_names" => { diff --git a/src/uu/who/Cargo.toml b/src/uu/who/Cargo.toml index 58b8d32f58f..ae6edb54fb8 100644 --- a/src/uu/who/Cargo.toml +++ b/src/uu/who/Cargo.toml @@ -23,9 +23,21 @@ doctest = false [dependencies] clap = { workspace = true } +fluent = { workspace = true } +time = { workspace = true, features = ["formatting", "local-offset"] } + +[target.'cfg(unix)'.dependencies] rustix = { workspace = true, features = ["fs", "termios"] } uucore = { workspace = true, features = ["utmpx"] } -fluent = { workspace = true } + +[target.'cfg(windows)'.dependencies] +dns-lookup = { workspace = true } +uucore = { workspace = true } +windows-sys = { workspace = true, features = [ + "Win32_Foundation", + "Win32_System_RemoteDesktop", + "Win32_System_Threading", +] } [lints] workspace = true diff --git a/src/uu/who/locales/en-US.ftl b/src/uu/who/locales/en-US.ftl index 2984c2fdcc5..cca15b31852 100644 --- a/src/uu/who/locales/en-US.ftl +++ b/src/uu/who/locales/en-US.ftl @@ -54,6 +54,3 @@ who-heading-exit = EXIT # Error messages who-canonicalize-error = failed to canonicalize { $host } - -# Platform-specific messages -who-unsupported-openbsd = unsupported command on OpenBSD diff --git a/src/uu/who/locales/fr-FR.ftl b/src/uu/who/locales/fr-FR.ftl index 0c853e51ee9..5cfb7c2d233 100644 --- a/src/uu/who/locales/fr-FR.ftl +++ b/src/uu/who/locales/fr-FR.ftl @@ -53,6 +53,3 @@ who-heading-exit = SORTIE # Error messages who-canonicalize-error = échec de canonicalisation de { $host } - -# Platform-specific messages -who-unsupported-openbsd = commande non prise en charge sur OpenBSD diff --git a/src/uu/who/src/platform/mod.rs b/src/uu/who/src/platform/mod.rs index e0e87dca1bf..b6d55761c9b 100644 --- a/src/uu/who/src/platform/mod.rs +++ b/src/uu/who/src/platform/mod.rs @@ -3,12 +3,10 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -#[cfg(not(target_os = "openbsd"))] +#[cfg(unix)] mod unix; -#[cfg(not(target_os = "openbsd"))] -pub use self::unix::*; +#[cfg(unix)] +pub(crate) use unix::*; -#[cfg(target_os = "openbsd")] -mod openbsd; -#[cfg(target_os = "openbsd")] -pub use self::openbsd::*; +#[cfg(windows)] +mod windows; diff --git a/src/uu/who/src/platform/openbsd.rs b/src/uu/who/src/platform/openbsd.rs deleted file mode 100644 index f5871382b11..00000000000 --- a/src/uu/who/src/platform/openbsd.rs +++ /dev/null @@ -1,17 +0,0 @@ -// 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. - -// Specific implementation for OpenBSD: tool unsupported (utmpx not supported) - -use crate::uu_app; - -use uucore::error::UResult; -use uucore::translate; - -pub fn uumain(args: impl uucore::Args) -> UResult<()> { - let _matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; - println!("{}", translate!("who-unsupported-openbsd")); - Ok(()) -} diff --git a/src/uu/who/src/platform/unix.rs b/src/uu/who/src/platform/unix.rs index 9600449908c..a8f5dde3daa 100644 --- a/src/uu/who/src/platform/unix.rs +++ b/src/uu/who/src/platform/unix.rs @@ -5,75 +5,22 @@ // spell-checker:ignore (ToDO) ttyname hostnames runlevel mesg wtmp -use crate::options; -use crate::uu_app; +use crate::{Row, Who, format_idle, format_timestamp}; use uucore::display::Quotable; use uucore::error::{FromIo, UResult}; use uucore::libc::S_IWGRP; use uucore::translate; -use uucore::utmpx::{self, UtmpxRecord, time}; +use uucore::utmpx::{self, UtmpxRecord}; -use std::borrow::Cow; -use std::fmt::Write; -use std::io::{Write as _, stdout}; use std::os::unix::fs::MetadataExt; use std::path::PathBuf; -fn get_long_usage() -> String { +pub(crate) fn get_long_usage() -> String { translate!("who-long-usage", "default_file" => utmpx::DEFAULT_FILE) } -/// Which kinds of accounting record are worth reporting. -#[derive(Default)] -struct Selection { - /// The record left by the last system boot. - boot: bool, - /// The records of processes that have since exited. - exited: bool, - /// The login processes still waiting for someone to sign in. - login_slots: bool, - /// The processes that init spawned. - init_children: bool, - /// The record left by the most recent clock adjustment. - clock_change: bool, - /// The record holding the current runlevel. - runlevel: bool, - /// Ordinary user sessions. - sessions: bool, -} - -impl Selection { - /// True when no selecting option was given at all, including `--users`. - /// Such an invocation falls back to reporting user sessions. - fn is_default(&self) -> bool { - !(self.boot - || self.exited - || self.login_slots - || self.init_children - || self.clock_change - || self.runlevel - || self.sessions) - } -} - -/// Which columns each row carries. -#[derive(Default)] -struct Layout { - /// Prepend a header row naming the columns. - header: bool, - /// The column reporting whether the terminal accepts messages: `+` when it - /// does, `-` when it does not, `?` when the terminal cannot be queried. - write_state: bool, - /// How long the terminal has been quiet. - idle: bool, - /// How the process ended and with what status. - exit: bool, - /// Drop everything but the name, line and time columns. - terse: bool, -} - /// The events that are reported from something other than a live session. #[derive(Clone, Copy)] enum Event { @@ -85,141 +32,6 @@ enum Event { InitChild, Exited, } - -/// One output line, before the columns are padded out. -struct Row<'a> { - user: &'a str, - write_state: char, - line: &'a str, - time: &'a str, - idle: &'a str, - pid: &'a str, - note: &'a str, - exit: &'a str, -} - -impl Default for Row<'_> { - fn default() -> Self { - Self { - user: "", - write_state: ' ', - line: "", - time: "", - idle: "", - pid: "", - note: "", - exit: "", - } - } -} - -pub fn uumain(args: impl uucore::Args) -> UResult<()> { - let matches = - uucore::clap_localization::handle_clap_result(uu_app().after_help(get_long_usage()), args)?; - - let files: Vec = matches - .get_many::(options::FILE) - .map(|v| v.map(ToString::to_string).collect()) - .unwrap_or_default(); - - let all = matches.get_flag(options::ALL); - let flag = |name: &str| all || matches.get_flag(name); - - let mut select = Selection { - boot: flag(options::BOOT), - exited: flag(options::DEAD), - login_slots: flag(options::LOGIN), - init_children: flag(options::PROCESS), - clock_change: flag(options::TIME), - runlevel: flag(options::RUNLEVEL), - sessions: matches.get_flag(options::USERS), - }; - - // With no selecting option the report falls back to user sessions, and the - // narrower row shape that goes with them. - let defaulted = select.is_default(); - select.sessions |= all || defaulted; - - let layout = Layout { - header: matches.get_flag(options::HEADING), - write_state: flag(options::MESG), - // The idle column is only meaningful for records tied to a terminal. - idle: select.exited || select.login_slots || select.runlevel || select.sessions, - exit: select.exited, - terse: !select.exited && defaulted, - }; - - let mut who = Who { - // Resolve each recorded host to its canonical name before printing it. - resolve_hosts: matches.get_flag(options::LOOKUP), - // Print just the login names followed by a total, instead of one row - // per record. Carries no meaning in the `who am i` form. - names_only: matches.get_flag(options::COUNT), - // Report only the session attached to the invoking terminal. - own_terminal_only: matches.get_flag(options::ONLY_HOSTNAME_USER) || files.len() == 2, - select, - layout, - args: files, - }; - - who.exec()?; - Ok(()) -} - -struct Who { - resolve_hosts: bool, - names_only: bool, - own_terminal_only: bool, - select: Selection, - layout: Layout, - args: Vec, -} - -/// Render how long a terminal has been quiet: `hours:minutes`, `.` when under a -/// minute, and the localized `old` past a day or before the given boot time. -fn format_idle<'a>(when: i64, since_boot: i64) -> Cow<'a, str> { - thread_local! { - static NOW: time::OffsetDateTime = time::OffsetDateTime::now_local().unwrap(); - } - NOW.with(|n| { - let now = n.unix_timestamp(); - if since_boot < when && now - 24 * 3600 < when && when <= now { - let quiet_for = now - when; - if quiet_for < 60 { - " . ".into() - } else { - format!("{:02}:{:02}", quiet_for / 3600, (quiet_for % 3600) / 60).into() - } - } else { - translate!("who-idle-old").into() - } - }) -} - -fn format_timestamp(ut: &UtmpxRecord) -> String { - const FORMAT_DESCRIPTION_VERSION: usize = 2; - - let pattern: Vec = if ["LC_ALL", "LC_TIME", "LANG"] - .into_iter() - .find_map(std::env::var_os) - .as_deref() - == Some(std::ffi::OsStr::new("C")) - { - // "%b %e %H:%M" - time::format_description::parse_borrowed::( - "[month repr:short] [day padding:space] [hour]:[minute]", - ) - .unwrap() - } else { - // "%Y-%m-%d %H:%M" - time::format_description::parse_borrowed::( - "[year]-[month]-[day] [hour]:[minute]", - ) - .unwrap() - }; - ut.login_time().format(&pattern).unwrap() -} - fn current_tty() -> String { rustix::termios::ttyname(std::io::stdin(), Vec::with_capacity(16)) .map(|s| s.to_string_lossy().trim_start_matches("/dev/").to_owned()) @@ -227,14 +39,18 @@ fn current_tty() -> String { } impl Who { - fn exec(&mut self) -> UResult<()> { + pub(crate) fn exec(&mut self) -> UResult<()> { let f = if self.args.len() == 1 { self.args[0].as_ref() } else { utmpx::DEFAULT_FILE }; if self.names_only { - return self.emit_names(f); + let users = utmpx::Utmpx::iter_all_records_from(f) + .filter(UtmpxRecord::is_user_process) + .map(|ut| ut.user()) + .collect::>(); + return self.emit_names(&users); } let records = utmpx::Utmpx::iter_all_records_from(f); @@ -261,24 +77,6 @@ impl Who { Ok(()) } - /// The `-q` report: every login name on one line, then the total. - fn emit_names(&self, path: &str) -> UResult<()> { - let users = utmpx::Utmpx::iter_all_records_from(path) - .filter(UtmpxRecord::is_user_process) - .map(|ut| ut.user()) - .collect::>(); - // `println!` panics on a write error; the rest of this file surfaces - // it through `?` instead so the caller can report it and exit - // non-zero, matching GNU (#13388). - writeln!(stdout(), "{}", users.join(" "))?; - writeln!( - stdout(), - "{}", - translate!("who-user-count", "count" => users.len()) - )?; - Ok(()) - } - /// Map a record to the event it stands for, or `None` when that kind was /// not selected. fn event_for(&self, ut: &UtmpxRecord) -> Option { @@ -300,7 +98,7 @@ impl Who { } fn emit_event(&self, ut: &UtmpxRecord, event: Event) -> UResult<()> { - let time = format_timestamp(ut); + let time = format_timestamp(ut.login_time()); let pid = format!("{}", ut.pid()); let note = translate!("who-login-id", "id" => ut.terminal_suffix()); @@ -416,7 +214,7 @@ impl Who { user: &ut.user(), write_state, line: &ut.tty_device(), - time: &format_timestamp(ut), + time: &format_timestamp(ut.login_time()), idle: &idle, pid: &format!("{}", ut.pid()), note: ¬e, @@ -425,46 +223,4 @@ impl Who { Ok(()) } - - fn emit_row(&self, row: &Row) -> UResult<()> { - // Width of "%b %e %H:%M" under LC_ALL=C. - const TIME_WIDTH: usize = 3 + 2 + 2 + 1 + 2; - - let mut buf = String::with_capacity(64); - write!(buf, "{:<8}", row.user).unwrap(); - if self.layout.write_state { - buf.push(' '); - buf.push(row.write_state); - } - write!(buf, " {:<12}", row.line).unwrap(); - write!(buf, " {:10}", row.pid).unwrap(); - } - write!(buf, " {:<8}", row.note).unwrap(); - if self.layout.exit { - write!(buf, " {:<12}", row.exit).unwrap(); - } - writeln!(stdout(), "{}", buf.trim_end())?; - Ok(()) - } - - #[inline] - fn emit_header(&self) -> UResult<()> { - self.emit_row(&Row { - user: &translate!("who-heading-name"), - write_state: ' ', - line: &translate!("who-heading-line"), - time: &translate!("who-heading-time"), - idle: &translate!("who-heading-idle"), - pid: &translate!("who-heading-pid"), - note: &translate!("who-heading-comment"), - exit: &translate!("who-heading-exit"), - })?; - Ok(()) - } } diff --git a/src/uu/who/src/platform/windows.rs b/src/uu/who/src/platform/windows.rs new file mode 100644 index 00000000000..eba314479c5 --- /dev/null +++ b/src/uu/who/src/platform/windows.rs @@ -0,0 +1,272 @@ +// 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 (ToDO) INFOW StationName WinStation + +use crate::{Row, Who, format_idle, format_timestamp}; +use uucore::display::Quotable; +use uucore::error::{UResult, USimpleError}; +use uucore::translate; + +use std::ffi::OsString; +use std::os::windows::ffi::OsStringExt; +use std::ptr; +use windows_sys::Win32::System::RemoteDesktop::{ + ProcessIdToSessionId, WTS_CURRENT_SERVER_HANDLE, WTS_INFO_CLASS, WTS_SESSION_INFOW, + WTSClientName, WTSEnumerateSessionsW, WTSFreeMemory, WTSINFOW, WTSListen, + WTSQuerySessionInformationW, WTSSessionInfo, +}; +use windows_sys::Win32::System::Threading::GetCurrentProcessId; + +/// Owns a buffer allocated by the Windows Terminal Services API. +struct WtsBuffer(*mut T); + +impl Drop for WtsBuffer { + fn drop(&mut self) { + // SAFETY: the pointer was returned by WTS and is freed exactly once. + unsafe { WTSFreeMemory(self.0.cast()) }; + } +} + +struct Session { + id: u32, + line: String, + user: String, + host: String, + logon_time: i64, + last_input_time: i64, +} + +impl Who { + pub(crate) fn exec(&mut self) -> UResult<()> { + if self.names_only { + let users = sessions(!self.all) + .iter() + .filter(|session| !session.user.is_empty()) + .map(|session| session.user.clone()) + .collect::>(); + return self.emit_names(&users); + } + + if self.layout.header { + self.emit_header()?; + } + let current_session = if self.own_terminal_only { + current_session_id() + } else { + None + }; + + for session in sessions(!self.all) { + if self.own_terminal_only && current_session != Some(session.id) { + continue; + } + if self.select.sessions && !session.user.is_empty() { + self.emit_session(&session)?; + } + } + Ok(()) + } +} + +/// Enumerate WTS sessions. Without `all`, omit sessions that cannot contain a +/// login (listeners) so the default report only lists reachable login sessions. +fn sessions(all: bool) -> Vec { + let Some(raw_sessions) = RawSessions::enumerate() else { + return Vec::new(); + }; + + raw_sessions + .items() + .iter() + .filter(|raw| all || raw.State != WTSListen) + .filter_map(|raw| { + let line = if raw.pWinStationName.is_null() { + String::new() + } else { + // SAFETY: WTS provides a NUL-terminated station name; its + // documented maximum is 32 UTF-16 units. + wide_string(unsafe { std::slice::from_raw_parts(raw.pWinStationName, 32) }) + }; + let info = query_session_info(raw.SessionId)?; + let user = wide_string(&info.UserName); + if !all && user.is_empty() { + return None; + } + let host = query_string(raw.SessionId, WTSClientName).unwrap_or_default(); + + Some(Session { + id: raw.SessionId, + line, + user, + host, + logon_time: info.LogonTime, + last_input_time: info.LastInputTime, + }) + }) + .collect() +} + +/// Owns a WTS-allocated session array. +struct RawSessions { + buffer: WtsBuffer, + count: usize, +} + +impl RawSessions { + fn enumerate() -> Option { + let mut buffer = ptr::null_mut(); + let mut count = 0; + // SAFETY: the out-pointers are writable and the server handle is the + // documented sentinel for the local machine. + let result = unsafe { + WTSEnumerateSessionsW( + WTS_CURRENT_SERVER_HANDLE, + 0, + 1, + &raw mut buffer, + &raw mut count, + ) + }; + if result == 0 || buffer.is_null() { + return None; + } + + Some(Self { + buffer: WtsBuffer(buffer), + count: count as usize, + }) + } + + fn items(&self) -> &[WTS_SESSION_INFOW] { + // SAFETY: WTS returned `count` consecutive records in this buffer. + unsafe { std::slice::from_raw_parts(self.buffer.0, self.count) } + } +} + +/// Copy a fixed-size WTS session record out of its WTS-owned buffer. +fn query_session_info(session_id: u32) -> Option { + let mut buffer = ptr::null_mut(); + let mut byte_len = 0; + // SAFETY: the out-pointers are writable and the server handle is the + // documented sentinel for the local machine. + let result = unsafe { + WTSQuerySessionInformationW( + WTS_CURRENT_SERVER_HANDLE, + session_id, + WTSSessionInfo, + &raw mut buffer, + &raw mut byte_len, + ) + }; + if result == 0 || byte_len < size_of::() as u32 { + return None; + } + // WTS allocates WTSINFO on a suitable alignment for its fields. + #[allow(clippy::cast_ptr_alignment)] + let buffer = WtsBuffer(buffer.cast::()); + unsafe { buffer.0.as_ref().copied() } +} + +fn query_string(session_id: u32, info_class: WTS_INFO_CLASS) -> Option { + let mut buffer = ptr::null_mut(); + let mut byte_len = 0; + // SAFETY: the out-pointers are writable and the server handle is the + // documented sentinel for the local machine. + let result = unsafe { + WTSQuerySessionInformationW( + WTS_CURRENT_SERVER_HANDLE, + session_id, + info_class, + &raw mut buffer, + &raw mut byte_len, + ) + }; + if result == 0 || buffer.is_null() { + return None; + } + let buffer = WtsBuffer(buffer.cast::()); + // SAFETY: on success `byte_len` is the size of the returned UTF-16 buffer. + let units = unsafe { std::slice::from_raw_parts(buffer.0, byte_len as usize / 2) }; + Some(wide_string(units.split(|&unit| unit == 0).next()?)) +} + +fn wide_string(units: &[u16]) -> String { + let length = units + .iter() + .position(|&unit| unit == 0) + .unwrap_or(units.len()); + OsString::from_wide(&units[..length]) + .to_string_lossy() + .into_owned() +} + +fn current_session_id() -> Option { + let process_id = unsafe { GetCurrentProcessId() }; + let mut session_id = 0; + // SAFETY: the output pointer is writable and the process ID is read-only. + let result = unsafe { ProcessIdToSessionId(process_id, &raw mut session_id) }; + (result != 0).then_some(session_id) +} + +fn windows_timestamp_to_unix(timestamp: i64) -> i64 { + ((timestamp as i128 - 116_444_736_000_000_000) / 10_000_000) as i64 +} + +fn optional_timestamp(timestamp: i64) -> Option { + (timestamp != 0).then(|| windows_timestamp_to_unix(timestamp)) +} + +impl Who { + fn emit_session(&self, session: &Session) -> UResult<()> { + let host = if self.resolve_hosts { + canonicalize_host(&session.host)? + } else { + session.host.clone() + }; + let note = if host.is_empty() { + host + } else { + format!("({host})") + }; + let idle = match optional_timestamp(session.last_input_time) { + Some(last_touched) => format_idle(last_touched, 0), + None => " ?".into(), + }; + let time = optional_timestamp(session.logon_time) + .and_then(|timestamp| time::OffsetDateTime::from_unix_timestamp(timestamp).ok()) + .map(format_timestamp) + .unwrap_or_default(); + + self.emit_row(&Row { + user: &session.user, + write_state: '?', + line: &session.line, + time: &time, + idle: &idle, + pid: &format!("{}", session.id), + note: ¬e, + exit: "", + }) + } +} + +fn canonicalize_host(host: &str) -> UResult { + if host.is_empty() { + return Ok(String::new()); + } + let Ok(address) = host.parse() else { + return Ok(host.to_owned()); + }; + + dns_lookup::lookup_addr(&address) + .map_err(|_| { + translate!( + "who-canonicalize-error", + "host" => host.split(':').next().unwrap_or(host).quote() + ) + }) + .map_err(|message| USimpleError::new(1, message)) +} diff --git a/src/uu/who/src/who.rs b/src/uu/who/src/who.rs index e82990b7924..282a36e7410 100644 --- a/src/uu/who/src/who.rs +++ b/src/uu/who/src/who.rs @@ -6,6 +6,10 @@ // spell-checker:ignore (ToDO) runlevel mesg use clap::{Arg, ArgAction, Command}; +use std::borrow::Cow; +use std::fmt::Write as _; +use std::io::{Write as _, stdout}; +use uucore::error::UResult; use uucore::format_usage; use uucore::translate; @@ -36,8 +40,254 @@ fn get_runlevel_help() -> String { return translate!("who-help-runlevel-non-linux"); } +/// Which kinds of accounting record are worth reporting. +#[derive(Default)] +pub(crate) struct Selection { + /// The record left by the last system boot. + pub(crate) boot: bool, + /// The records of processes that have since exited. + pub(crate) exited: bool, + /// The login processes still waiting for someone to sign in. + pub(crate) login_slots: bool, + /// The processes that init spawned. + pub(crate) init_children: bool, + /// The record left by the most recent clock adjustment. + pub(crate) clock_change: bool, + /// The record holding the current runlevel. + pub(crate) runlevel: bool, + /// Ordinary user sessions. + pub(crate) sessions: bool, +} + +impl Selection { + /// True when no selecting option was given at all, including `--users`. + /// Such an invocation falls back to reporting user sessions. + fn is_default(&self) -> bool { + !(self.boot + || self.exited + || self.login_slots + || self.init_children + || self.clock_change + || self.runlevel + || self.sessions) + } +} + +/// Which columns each row carries. +#[derive(Default)] +pub(crate) struct Layout { + /// Prepend a header row naming the columns. + pub(crate) header: bool, + /// The column reporting whether the terminal accepts messages: `+` when it + /// does, `-` when it does not, `?` when the terminal cannot be queried. + pub(crate) write_state: bool, + /// How long the terminal has been quiet. + pub(crate) idle: bool, + /// How the process ended and with what status. + pub(crate) exit: bool, + /// Drop everything but the name, line and time columns. + pub(crate) terse: bool, +} + +/// One output line, before the columns are padded out. +pub(crate) struct Row<'a> { + pub(crate) user: &'a str, + pub(crate) write_state: char, + pub(crate) line: &'a str, + pub(crate) time: &'a str, + pub(crate) idle: &'a str, + pub(crate) pid: &'a str, + pub(crate) note: &'a str, + pub(crate) exit: &'a str, +} + +impl Default for Row<'_> { + fn default() -> Self { + Self { + user: "", + write_state: ' ', + line: "", + time: "", + idle: "", + pid: "", + note: "", + exit: "", + } + } +} + +pub struct Who { + #[cfg_attr(not(windows), allow(dead_code))] + pub(crate) all: bool, + pub(crate) resolve_hosts: bool, + pub(crate) names_only: bool, + pub(crate) own_terminal_only: bool, + pub(crate) select: Selection, + pub(crate) layout: Layout, + #[cfg_attr(not(unix), allow(dead_code))] + pub(crate) args: Vec, +} + +impl Who { + pub(crate) fn emit_row(&self, row: &Row) -> UResult<()> { + // Width of "%b %e %H:%M" under LC_ALL=C. + const TIME_WIDTH: usize = 3 + 2 + 2 + 1 + 2; + + let mut buf = String::with_capacity(64); + write!(buf, "{:<8}", row.user).unwrap(); + if self.layout.write_state { + buf.push(' '); + buf.push(row.write_state); + } + write!(buf, " {:<12}", row.line).unwrap(); + write!(buf, " {:10}", row.pid).unwrap(); + } + write!(buf, " {:<8}", row.note).unwrap(); + if self.layout.exit { + write!(buf, " {:<12}", row.exit).unwrap(); + } + writeln!(stdout(), "{}", buf.trim_end())?; + Ok(()) + } + + #[inline] + pub(crate) fn emit_header(&self) -> UResult<()> { + self.emit_row(&Row { + user: &translate!("who-heading-name"), + write_state: ' ', + line: &translate!("who-heading-line"), + time: &translate!("who-heading-time"), + idle: &translate!("who-heading-idle"), + pid: &translate!("who-heading-pid"), + note: &translate!("who-heading-comment"), + exit: &translate!("who-heading-exit"), + })?; + Ok(()) + } + + pub(crate) fn emit_names(&self, users: &[String]) -> UResult<()> { + // `println!` panics on a write error; the rest of this file surfaces + // it through `?` instead so the caller can report it and exit + // non-zero, matching GNU (#13388). + writeln!(stdout(), "{}", users.join(" "))?; + writeln!( + stdout(), + "{}", + translate!("who-user-count", "count" => users.len()) + )?; + Ok(()) + } +} + +/// Render how long a terminal has been quiet: `hours:minutes`, `.` when under a +/// minute, and the localized `old` past a day or before the given boot time. +pub(crate) fn format_idle<'a>(when: i64, since_boot: i64) -> Cow<'a, str> { + thread_local! { + static NOW: time::OffsetDateTime = time::OffsetDateTime::now_local().unwrap(); + } + NOW.with(|n| { + let now = n.unix_timestamp(); + if since_boot < when && now - 24 * 3600 < when && when <= now { + let quiet_for = now - when; + if quiet_for < 60 { + " . ".into() + } else { + format!("{:02}:{:02}", quiet_for / 3600, (quiet_for % 3600) / 60).into() + } + } else { + translate!("who-idle-old").into() + } + }) +} + +pub(crate) fn format_timestamp(login_time: time::OffsetDateTime) -> String { + const FORMAT_DESCRIPTION_VERSION: usize = 2; + + let pattern: Vec = if ["LC_ALL", "LC_TIME", "LANG"] + .into_iter() + .find_map(std::env::var_os) + .as_deref() + == Some(std::ffi::OsStr::new("C")) + { + // "%b %e %H:%M" + time::format_description::parse_borrowed::( + "[month repr:short] [day padding:space] [hour]:[minute]", + ) + .unwrap() + } else { + // "%Y-%m-%d %H:%M" + time::format_description::parse_borrowed::( + "[year]-[month]-[day] [hour]:[minute]", + ) + .unwrap() + }; + login_time.format(&pattern).unwrap() +} + #[uucore::main(no_signals)] -use platform::uumain; +pub fn uumain(args: impl uucore::Args) -> UResult<()> { + #[cfg(unix)] + let app = uu_app().after_help(platform::get_long_usage()); + #[cfg(not(unix))] + let app = uu_app(); + + let matches = uucore::clap_localization::handle_clap_result(app, args)?; + + let files: Vec = matches + .get_many::(options::FILE) + .map(|v| v.map(ToString::to_string).collect()) + .unwrap_or_default(); + + let all = matches.get_flag(options::ALL); + let flag = |name: &str| all || matches.get_flag(name); + + let mut select = Selection { + boot: flag(options::BOOT), + exited: flag(options::DEAD), + login_slots: flag(options::LOGIN), + init_children: flag(options::PROCESS), + clock_change: flag(options::TIME), + runlevel: flag(options::RUNLEVEL), + sessions: matches.get_flag(options::USERS), + }; + + // With no selecting option the report falls back to user sessions, and the + // narrower row shape that goes with them. + let defaulted = select.is_default(); + select.sessions |= all || defaulted; + + let layout = Layout { + header: matches.get_flag(options::HEADING), + write_state: flag(options::MESG), + // The idle column is only meaningful for records tied to a terminal. + idle: select.exited || select.login_slots || select.runlevel || select.sessions, + exit: select.exited, + terse: !select.exited && defaulted, + }; + + let mut who = Who { + all, + // Resolve each recorded host to its canonical name before printing it. + resolve_hosts: matches.get_flag(options::LOOKUP), + // Print just the login names followed by a total, instead of one row + // per record. Carries no meaning in the `who am i` form. + names_only: matches.get_flag(options::COUNT), + // Report only the session attached to the invoking terminal. + own_terminal_only: matches.get_flag(options::ONLY_HOSTNAME_USER) || files.len() == 2, + select, + layout, + args: files, + }; + + who.exec()?; + Ok(()) +} pub fn uu_app() -> Command { #[cfg(not(target_env = "musl"))] diff --git a/tests/by-util/test_who.rs b/tests/by-util/test_who.rs index cf84396e894..7388c450391 100644 --- a/tests/by-util/test_who.rs +++ b/tests/by-util/test_who.rs @@ -5,9 +5,14 @@ // spell-checker:ignore (flags) runlevel mesg +#![cfg(not(target_os = "openbsd"))] + use uutests::new_ucmd; +#[cfg(unix)] use uutests::unwrap_or_return; +#[cfg(unix)] use uutests::util::{TestScenario, expected_result, gnu_cmd_result}; +#[cfg(unix)] use uutests::util_name; #[test] fn test_invalid_arg() {