diff --git a/Cargo.lock b/Cargo.lock index 032c8e77e..baa3b400b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -156,6 +156,15 @@ dependencies = [ "compare", ] +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bindgen" version = "0.69.4" @@ -423,6 +432,7 @@ checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" name = "coreutils" version = "0.0.26" dependencies = [ + "bincode", "chrono", "clap", "clap_complete", @@ -444,6 +454,8 @@ dependencies = [ "rlimit", "rstest", "selinux", + "serde", + "serde-big-array", "sha1", "tempfile", "textwrap", @@ -2085,18 +2097,27 @@ checksum = "e25dfac463d778e353db5be2449d1cce89bd6fd23c9f1ea21310ce6e5a1b29c4" [[package]] name = "serde" -version = "1.0.193" +version = "1.0.202" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25dd9975e68d0cb5aa1120c288333fc98731bd1dd12f561e468ea4728c042b89" +checksum = "226b61a0d411b2ba5ff6d7f73a476ac4f8bb900373459cd00fab8512828ba395" dependencies = [ "serde_derive", ] [[package]] -name = "serde_derive" -version = "1.0.193" +name = "serde-big-array" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43576ca501357b9b071ac53cdc7da8ef0cbd9493d8df094cd821777ea6e894d3" +checksum = "11fc7cc2c76d73e0f27ee52abbd64eec84d46f370c88371120433196934e4b7f" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_derive" +version = "1.0.202" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6048858004bcff69094cd972ed40a32500f153bd3be9f716b2eed2e8217c4838" dependencies = [ "proc-macro2", "quote", @@ -3360,6 +3381,7 @@ version = "0.0.26" dependencies = [ "chrono", "clap", + "thiserror", "uucore", ] diff --git a/Cargo.toml b/Cargo.toml index 316c7d5ca..d73ba684e 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 fundu gethostid kqueue libselinux mangen memmap procfs uuhelp +# spell-checker:ignore (libs) bigdecimal datetime serde bincode fundu gethostid kqueue libselinux mangen memmap procfs uuhelp [package] name = "coreutils" @@ -491,10 +491,11 @@ tempfile = { workspace = true } time = { workspace = true, features = ["local-offset"] } unindent = "0.2.3" uucore = { workspace = true, features = [ - "entries", "mode", + "entries", "process", "signals", + "utmpx", ] } walkdir = { workspace = true } hex-literal = "0.4.1" @@ -509,6 +510,14 @@ rlimit = "0.10.1" rand_pcg = "0.3.1" xattr = { workspace = true } +# Specifically used in test_uptime::test_uptime_with_file_containing_valid_boot_time_utmpx_record +# to deserialize a utmpx struct into a binary file +[target.'cfg(all(target_family= "unix",not(target_os = "macos")))'.dev-dependencies] +serde = { version = "1.0.202", features = ["derive"] } +bincode = { version = "1.3.3" } +serde-big-array = "0.5.1" + + [build-dependencies] phf_codegen = { workspace = true } diff --git a/src/uu/uptime/Cargo.toml b/src/uu/uptime/Cargo.toml index 447728433..665611b96 100644 --- a/src/uu/uptime/Cargo.toml +++ b/src/uu/uptime/Cargo.toml @@ -18,6 +18,7 @@ path = "src/uptime.rs" chrono = { workspace = true } clap = { workspace = true } uucore = { workspace = true, features = ["libc", "utmpx"] } +thiserror = { workspace = true } [[bin]] name = "uptime" diff --git a/src/uu/uptime/src/platform/unix.rs b/src/uu/uptime/src/platform/unix.rs index df3e5e653..14c5c77b2 100644 --- a/src/uu/uptime/src/platform/unix.rs +++ b/src/uu/uptime/src/platform/unix.rs @@ -3,12 +3,20 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (ToDO) getloadavg upsecs updays nusers loadavg boottime uphours upmins +// spell-checker:ignore getloadavg behaviour loadavg uptime upsecs updays upmins uphours boottime nusers utmpxname use crate::options; use crate::uu_app; - use chrono::{Local, TimeZone, Utc}; +use clap::ArgMatches; +use std::ffi::OsString; +use std::fs; +use std::io; +use std::os::unix::fs::FileTypeExt; +use thiserror::Error; +use uucore::error::set_exit_code; +use uucore::error::UError; +use uucore::show_error; use uucore::libc::time_t; @@ -16,36 +24,145 @@ use uucore::error::{UResult, USimpleError}; #[cfg(unix)] use uucore::libc::getloadavg; - #[cfg(windows)] extern "C" { fn GetTickCount() -> uucore::libc::uint32_t; } +#[derive(Debug, Error)] +pub enum UptimeError { + // io::Error wrapper + #[error("couldn't get boot time: {0}")] + IoErr(#[from] io::Error), + + #[error("couldn't get boot time: Is a directory")] + TargetIsDir, + + #[error("couldn't get boot time: Illegal seek")] + TargetIsFifo, + #[error("extra operand '{0}'")] + ExtraOperandError(String), +} +impl UError for UptimeError { + fn code(&self) -> i32 { + 1 + } +} pub fn uumain(args: impl uucore::Args) -> UResult<()> { let matches = uu_app().try_get_matches_from(args)?; + let argument = matches.get_many::(options::PATH); - let (boot_time, user_count) = process_utmpx(); - let uptime = get_uptime(boot_time); - if uptime < 0 { - Err(USimpleError::new(1, "could not retrieve system uptime")) - } else { - if matches.get_flag(options::SINCE) { - let initial_date = Local - .timestamp_opt(Utc::now().timestamp() - uptime, 0) - .unwrap(); - println!("{}", initial_date.format("%Y-%m-%d %H:%M:%S")); + // Switches to default uptime behaviour if there is no argument + if argument.is_none() { + return default_uptime(&matches); + } + let mut arg_iter = argument.unwrap(); + + let file_path = arg_iter.next().unwrap(); + if let Some(path) = arg_iter.next() { + // Uptime doesn't attempt to calculate boot time if there is extra arguments. + // Its a fatal error + show_error!( + "{}", + UptimeError::ExtraOperandError(path.to_owned().into_string().unwrap()) + ); + set_exit_code(1); + return Ok(()); + } + uptime_with_file(file_path) +} + +#[cfg(unix)] +fn uptime_with_file(file_path: &OsString) -> UResult<()> { + // Uptime will print loadavg and time to stderr unless we encounter an extra operand. + let mut non_fatal_error = false; + + // process_utmpx_from_file() doesn't detect or report failures, we check if the path is valid + // before proceeding with more operations. + let md_res = fs::metadata(file_path); + if let Ok(md) = md_res { + if md.is_dir() { + show_error!("{}", UptimeError::TargetIsDir); + non_fatal_error = true; + set_exit_code(1); + } + if md.file_type().is_fifo() { + show_error!("{}", UptimeError::TargetIsFifo); + non_fatal_error = true; + set_exit_code(1); + } + } else if let Err(e) = md_res { + non_fatal_error = true; + set_exit_code(1); + show_error!("{}", UptimeError::IoErr(e)); + } + // utmpxname() returns an -1 , when filename doesn't end with 'x' or its too long. + // Reference: `` + + #[cfg(target_os = "macos")] + { + use std::os::unix::ffi::OsStrExt; + let bytes = file_path.as_os_str().as_bytes(); + + if bytes[bytes.len() - 1] != b'x' { + show_error!("couldn't get boot time"); + print_time(); + print!("up ???? days ??:??,"); + print_nusers(0); + print_loadavg(); + set_exit_code(1); return Ok(()); } - - print_time(); - let upsecs = uptime; - print_uptime(upsecs); - print_nusers(user_count); - print_loadavg(); - - Ok(()) } + + if non_fatal_error { + print_time(); + print!("up ???? days ??:??,"); + print_nusers(0); + print_loadavg(); + return Ok(()); + } + + print_time(); + let (boot_time, user_count) = process_utmpx_from_file(file_path); + if let Some(time) = boot_time { + let upsecs = get_uptime_from_boot_time(time); + print_uptime(upsecs); + } else { + show_error!("couldn't get boot time"); + set_exit_code(1); + + print!("up ???? days ??:??,"); + } + + print_nusers(user_count); + print_loadavg(); + + Ok(()) +} + +/// Default uptime behaviour i.e. when no file argument is given. +fn default_uptime(matches: &ArgMatches) -> UResult<()> { + let (boot_time, user_count) = process_utmpx(); + let uptime = get_uptime(boot_time); + if matches.get_flag(options::SINCE) { + let initial_date = Local + .timestamp_opt(Utc::now().timestamp() - uptime, 0) + .unwrap(); + println!("{}", initial_date.format("%Y-%m-%d %H:%M:%S")); + return Ok(()); + } + + if uptime < 0 { + return Err(USimpleError::new(1, "could not retrieve system uptime")); + } + print_time(); + let upsecs = uptime; + print_uptime(upsecs); + print_nusers(user_count); + print_loadavg(); + + Ok(()) } #[cfg(unix)] @@ -97,6 +214,28 @@ fn process_utmpx() -> (Option, usize) { (boot_time, nusers) } +#[cfg(unix)] +fn process_utmpx_from_file(file: &OsString) -> (Option, usize) { + use uucore::utmpx::*; + + let mut nusers = 0; + let mut boot_time = None; + + for line in Utmpx::iter_all_records_from(file) { + match line.record_type() { + USER_PROCESS => nusers += 1, + BOOT_TIME => { + let dt = line.login_time(); + if dt.unix_timestamp() > 0 { + boot_time = Some(dt.unix_timestamp() as time_t); + } + } + _ => continue, + } + } + (boot_time, nusers) +} + #[cfg(windows)] fn process_utmpx() -> (Option, usize) { (None, 0) // TODO: change 0 to number of users @@ -104,16 +243,25 @@ fn process_utmpx() -> (Option, usize) { fn print_nusers(nusers: usize) { match nusers.cmp(&1) { + std::cmp::Ordering::Less => print!(" 0 users, "), std::cmp::Ordering::Equal => print!("1 user, "), std::cmp::Ordering::Greater => print!("{nusers} users, "), - _ => {} }; } fn print_time() { let local_time = Local::now().time(); - print!(" {} ", local_time.format("%H:%M:%S")); + print!(" {} ", local_time.format("%H:%M:%S")); +} + +fn get_uptime_from_boot_time(boot_time: time_t) -> i64 { + let now = Local::now().timestamp(); + #[cfg(target_pointer_width = "64")] + let boottime: i64 = boot_time; + #[cfg(not(target_pointer_width = "64"))] + let boottime: i64 = boot_time.into(); + now - boottime } #[cfg(unix)] @@ -154,8 +302,8 @@ fn print_uptime(upsecs: i64) { match updays.cmp(&1) { std::cmp::Ordering::Equal => print!("up {updays:1} day, {uphours:2}:{upmins:02}, "), std::cmp::Ordering::Greater => { - print!("up {updays:1} days, {uphours:2}:{upmins:02}, "); + print!("up {updays:1} days {uphours:2}:{upmins:02}, "); } - _ => print!("up {uphours:2}:{upmins:02}, "), + _ => print!("up {uphours:2}:{upmins:02}, "), }; } diff --git a/src/uu/uptime/src/uptime.rs b/src/uu/uptime/src/uptime.rs index 196ae60ba..4e9acd699 100644 --- a/src/uu/uptime/src/uptime.rs +++ b/src/uu/uptime/src/uptime.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -use clap::{crate_version, Arg, ArgAction, Command}; +use clap::{builder::ValueParser, crate_version, Arg, ArgAction, Command, ValueHint}; use uucore::{format_usage, help_about, help_usage}; @@ -13,6 +13,7 @@ const ABOUT: &str = help_about!("uptime.md"); const USAGE: &str = help_usage!("uptime.md"); pub mod options { pub static SINCE: &str = "since"; + pub static PATH: &str = "path"; } #[uucore::main] @@ -31,4 +32,11 @@ pub fn uu_app() -> Command { .help("system up since") .action(ArgAction::SetTrue), ) + .arg( + Arg::new(options::PATH) + .help("file to search boot time from") + .action(ArgAction::Append) + .value_parser(ValueParser::os_string()) + .value_hint(ValueHint::AnyPath), + ) } diff --git a/tests/by-util/test_uptime.rs b/tests/by-util/test_uptime.rs index 89e5567fb..382d5f2ef 100644 --- a/tests/by-util/test_uptime.rs +++ b/tests/by-util/test_uptime.rs @@ -2,8 +2,22 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +// +// spell-checker:ignore bincode serde utmp runlevel testusr testx + use crate::common::util::TestScenario; + +#[cfg(not(target_os = "macos"))] +use bincode::serialize; use regex::Regex; +#[cfg(not(target_os = "macos"))] +use serde::Serialize; +#[cfg(not(target_os = "macos"))] +use serde_big_array::BigArray; +#[cfg(not(target_os = "macos"))] +use std::fs::File; +#[cfg(not(target_os = "macos"))] +use std::{io::Write, path::PathBuf}; #[test] fn test_invalid_arg() { @@ -22,6 +36,225 @@ fn test_uptime() { // Don't check for users as it doesn't show in some CI } +/// Checks for files without utmpx records for which boot time cannot be calculated +#[test] +#[cfg(not(any(target_os = "openbsd", target_os = "freebsd")))] +// Disabled for freebsd, since it doesn't use the utmpxname() sys call to change the default utmpx +// file that is accessed using getutxent() +fn test_uptime_for_file_without_utmpx_records() { + let (at, mut ucmd) = at_and_ucmd!(); + at.write("file1", "hello"); + + ucmd.arg(at.plus_as_string("file1")) + .fails() + .stderr_contains("uptime: couldn't get boot time") + .stdout_contains("up ???? days ??:??") + .stdout_contains("load average"); +} + +/// Checks whether uptime displays the correct stderr msg when its called with a fifo +#[test] +#[cfg(all(unix, feature = "cp"))] +fn test_uptime_with_fifo() { + // This test can go on forever in the CI in some cases, might need aborting + // Sometimes writing to the pipe is broken + let ts = TestScenario::new(util_name!()); + + let at = &ts.fixtures; + at.mkfifo("fifo1"); + + at.write("a", "hello"); + // Creating a child process to write to the fifo + let mut child = ts + .ccmd("cp") + .arg(at.plus_as_string("a")) + .arg(at.plus_as_string("fifo1")) + .run_no_wait(); + + ts.ucmd() + .arg("fifo1") + .fails() + .stderr_contains("uptime: couldn't get boot time: Illegal seek") + .stdout_contains("up ???? days ??:??") + .stdout_contains("load average"); + + child.kill(); +} + +#[test] +#[cfg(not(any(target_os = "openbsd", target_os = "freebsd")))] +fn test_uptime_with_non_existent_file() { + // Disabled for freebsd, since it doesn't use the utmpxname() sys call to change the default utmpx + // file that is accessed using getutxent() + let ts = TestScenario::new(util_name!()); + + ts.ucmd() + .arg("file1") + .fails() + .stderr_contains("uptime: couldn't get boot time: No such file or directory") + .stdout_contains("up ???? days ??:??"); +} + +// TODO create a similar test for macos +// This will pass +#[test] +#[cfg(not(any(target_os = "openbsd", target_os = "macos")))] +fn test_uptime_with_file_containing_valid_boot_time_utmpx_record() { + // This test will pass for freebsd but we currently don't support changing the utmpx file for + // freebsd. + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + // Regex matches for "up 00::00" ,"up 12 days 00::00", the time can be any valid time and + // the days can be more than 1 digit or not there. This will match even if the amount of whitespace is + // wrong between the days and the time. + + let re = Regex::new(r"up [(\d){1,} days]*\d{1,2}:\d\d").unwrap(); + utmp(&at.plus("testx")); + ts.ucmd() + .arg("testx") + .succeeds() + .stdout_matches(&re) + .stdout_contains("load average"); + + // Helper function to create byte sequences + fn slice_32(slice: &[u8]) -> [i8; 32] { + let mut arr: [i8; 32] = [0; 32]; + + for (i, val) in slice.iter().enumerate() { + arr[i] = *val as i8; + } + arr + } + // Creates a file utmp records of three different types including a valid BOOT_TIME entry + fn utmp(path: &PathBuf) { + // Definitions of our utmpx structs + const BOOT_TIME: i32 = 2; + const RUN_LVL: i32 = 1; + const USER_PROCESS: i32 = 7; + #[derive(Serialize)] + #[repr(C)] + pub struct TimeVal { + pub tv_sec: i32, + pub tv_usec: i32, + } + + #[derive(Serialize)] + #[repr(C)] + pub struct ExitStatus { + e_termination: i16, + e_exit: i16, + } + #[derive(Serialize)] + #[repr(C, align(4))] + pub struct Utmp { + pub ut_type: i32, + pub ut_pid: i32, + pub ut_line: [i8; 32], + pub ut_id: [i8; 4], + + pub ut_user: [i8; 32], + #[serde(with = "BigArray")] + pub ut_host: [i8; 256], + pub ut_exit: ExitStatus, + pub ut_session: i32, + pub ut_tv: TimeVal, + + pub ut_addr_v6: [i32; 4], + glibc_reserved: [i8; 20], + } + + let utmp = Utmp { + ut_type: BOOT_TIME, + ut_pid: 0, + ut_line: slice_32("~".as_bytes()), + ut_id: [126, 126, 0, 0], + ut_user: slice_32("reboot".as_bytes()), + ut_host: [0; 256], + ut_exit: ExitStatus { + e_termination: 0, + e_exit: 0, + }, + ut_session: 0, + ut_tv: TimeVal { + tv_sec: 1716371201, + tv_usec: 290913, + }, + ut_addr_v6: [127, 0, 0, 1], + glibc_reserved: [0; 20], + }; + let utmp1 = Utmp { + ut_type: RUN_LVL, + ut_pid: std::process::id() as i32, + ut_line: slice_32("~".as_bytes()), + ut_id: [126, 126, 0, 0], + ut_user: slice_32("runlevel".as_bytes()), + ut_host: [0; 256], + ut_exit: ExitStatus { + e_termination: 0, + e_exit: 0, + }, + ut_session: 0, + ut_tv: TimeVal { + tv_sec: 1716371209, + tv_usec: 162250, + }, + ut_addr_v6: [0, 0, 0, 0], + glibc_reserved: [0; 20], + }; + let utmp2 = Utmp { + ut_type: USER_PROCESS, + ut_pid: std::process::id() as i32, + ut_line: slice_32(":1".as_bytes()), + ut_id: [126, 126, 0, 0], + ut_user: slice_32("testusr".as_bytes()), + ut_host: [0; 256], + ut_exit: ExitStatus { + e_termination: 0, + e_exit: 0, + }, + ut_session: 0, + ut_tv: TimeVal { + tv_sec: 1716371283, + tv_usec: 858764, + }, + ut_addr_v6: [0, 0, 0, 0], + glibc_reserved: [0; 20], + }; + + let mut buf = serialize(&utmp).unwrap(); + buf.append(&mut serialize(&utmp1).unwrap()); + buf.append(&mut serialize(&utmp2).unwrap()); + let mut f = File::create(path).unwrap(); + f.write_all(&buf).unwrap(); + } +} + +#[test] +#[cfg(not(target_os = "openbsd"))] +fn test_uptime_with_extra_argument() { + let ts = TestScenario::new(util_name!()); + + ts.ucmd() + .arg("a") + .arg("b") + .fails() + .stderr_contains("extra operand 'b'"); +} +/// Checks whether uptime displays the correct stderr msg when its called with a directory +#[test] +#[cfg(not(target_os = "openbsd"))] +fn test_uptime_with_dir() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + at.mkdir("dir1"); + + ts.ucmd() + .arg("dir1") + .fails() + .stderr_contains("uptime: couldn't get boot time: Is a directory") + .stdout_contains("up ???? days ??:??"); +} + #[test] #[cfg(not(target_os = "openbsd"))] fn test_uptime_since() {