diff --git a/Cargo.lock b/Cargo.lock index 906787fb..62f7d6ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -380,9 +380,6 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] [[package]] name = "diff" @@ -898,9 +895,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" @@ -1411,12 +1408,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" dependencies = [ "deranged", - "itoa", "libc", "num-conv", "num_threads", @@ -1428,15 +1424,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" dependencies = [ "num-conv", "time-core", @@ -1566,6 +1562,7 @@ dependencies = [ "uu_setpgid", "uu_setsid", "uu_uuidgen", + "uu_wall", "uucore 0.2.2", "uuhelp_parser", "uuid", @@ -1793,6 +1790,17 @@ dependencies = [ "windows", ] +[[package]] +name = "uu_wall" +version = "0.0.1" +dependencies = [ + "chrono", + "clap", + "nix 0.31.3", + "thiserror", + "uucore 0.2.2", +] + [[package]] name = "uucore" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index f9129a8c..d6644f45 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,7 @@ feat_common_core = [ "setpgid", "setsid", "uuidgen", + "wall", ] [workspace.dependencies] @@ -121,6 +122,7 @@ rev = { optional = true, version = "0.0.1", package = "uu_rev", path = "src/uu/r setpgid = { optional = true, version = "0.0.1", package = "uu_setpgid", path = "src/uu/setpgid" } setsid = { optional = true, version = "0.0.1", package = "uu_setsid", path ="src/uu/setsid" } uuidgen = { optional = true, version = "0.0.1", package = "uu_uuidgen", path ="src/uu/uuidgen" } +wall = { optional = true, version = "0.0.1", package = "uu_wall", path = "src/uu/wall" } [dev-dependencies] ctor = "1.0.0" diff --git a/src/uu/wall/Cargo.toml b/src/uu/wall/Cargo.toml new file mode 100644 index 00000000..242135f2 --- /dev/null +++ b/src/uu/wall/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "uu_wall" +version = "0.0.1" +description = "wall ~ write a message to all users" + +edition = "2021" + +[lib] +path = "src/wall.rs" + +[[bin]] +name = "wall" +path = "src/main.rs" + +[dependencies] +chrono = { workspace = true } +clap.workspace = true +nix = { workspace = true, features = ["feature", "fs", "hostname", "term"] } +thiserror.workspace = true +uucore = { workspace = true, features = ["utmpx"] } diff --git a/src/uu/wall/src/main.rs b/src/uu/wall/src/main.rs new file mode 100644 index 00000000..c57b1120 --- /dev/null +++ b/src/uu/wall/src/main.rs @@ -0,0 +1,6 @@ +// 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. + +uucore::bin!(uu_wall); diff --git a/src/uu/wall/src/wall.rs b/src/uu/wall/src/wall.rs new file mode 100644 index 00000000..29de578a --- /dev/null +++ b/src/uu/wall/src/wall.rs @@ -0,0 +1,283 @@ +// 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. + +#[cfg(not(unix))] +use clap::ArgMatches; +use clap::builder::ValueParser; +use clap::parser::ValuesRef; +use clap::{Arg, ArgAction, Command}; +#[cfg(unix)] +use nix::unistd; +use std::env; +use std::ffi::OsString; +use std::io; +use std::io::prelude::*; +#[cfg(unix)] +use std::os::fd::AsFd; +use std::string::FromUtf8Error; +use thiserror::Error; + +#[cfg(unix)] +use uucore::{error::USimpleError, utmpx::Utmpx}; +use uucore::{ + error::{UError, UResult}, + format_usage, translate, +}; + +const STRING: &str = "string"; +const OPT_GROUP: &str = "group"; +#[cfg(target_os = "linux")] +const OPT_NOBANNER: &str = "nobanner"; +#[cfg(target_os = "linux")] +const OPT_TIMEOUT: &str = "timeout"; + +#[derive(Error, Debug)] +enum WallError { + #[error("wall: cannot read stdin")] + Stdin(#[from] io::Error), + #[error("wall: encoding error")] + VecToString(#[from] FromUtf8Error), + #[error("wall: osstring conversion failed")] + ToStringError, +} + +impl UError for WallError { + fn code(&self) -> i32 { + 1 + } +} + +#[cfg(target_family = "unix")] +#[uucore::main(no_signals)] +pub fn uumain(args: impl uucore::Args) -> UResult<()> { + let matches = uucore::clap_localization::handle_clap_result(uu_app(), args) + .map_err(|e| USimpleError::new(1, e.to_string()))?; // Clap would have return 101 + // Might be considered wrong for --help and --version + let message = get_message(matches.get_many(STRING).unwrap_or_default())?; + let users = find_logged_users(); + write_to_terminals(message, users)?; + Ok(()) +} + +#[cfg(not(target_family = "unix"))] +#[uucore::main(no_signals)] +pub fn uumain(args: impl uucore::Args) -> UResult<()> { + let _matches: ArgMatches = uu_app().try_get_matches_from(args)?; + Err(uucore::error::USimpleError::new( + 1, + "`wall` is available only on Unix platforms.", + )) +} + +#[cfg(target_os = "linux")] +pub fn uu_app() -> Command { + Command::new("wall") + .version(uucore::crate_version!()) + .about(translate!("wall.md")) + .infer_long_args(true) + .override_usage(format_usage(&translate!("wall.md"))) + .arg( + Arg::new(OPT_GROUP) // TODO(FEAT): Implement -g/--groups to target specific + // users inside a group + .short('g') + .long(OPT_GROUP) + .value_name("GROUP") + .help("Send restrict to only users in the group(s)") + .num_args(1) + .required(false) + .action(ArgAction::Append) // User can target more than one group + .value_parser(clap::value_parser!(String)), + ) + .arg( + Arg::new(OPT_NOBANNER) // TODO(FEAT): Implement -n/--nobanner to remove broadcasting + // intro message + .short('n') + .long(OPT_NOBANNER) + .required(false) + .action(ArgAction::SetTrue) + .help("Suppress the intro branner of the broadcast"), + ) + .arg( + Arg::new(OPT_TIMEOUT) // TODO(FEAT): Implement -t --timeout to stop trying to print + // after passed a delay + .short('t') + .long(OPT_TIMEOUT) + .required(false) + .value_name("SECONDS") + .help("Abandon after t seconds the write attempt to the terminals") + .num_args(1), + ) + .arg( + Arg::new(STRING) + .action(ArgAction::Append) + .value_parser(ValueParser::os_string()), + ) +} + +#[cfg(not(target_os = "linux"))] +pub fn uu_app() -> Command { + Command::new("wall") + .version(uucore::crate_version!()) + .about(translate!("wall.md")) + .infer_long_args(true) + .override_usage(format_usage(&translate!("wall.md"))) + .arg( + Arg::new(OPT_GROUP) // TODO(FEAT): Implement -g/--groups to target specific + // users inside a group + .short('g') + .long(OPT_GROUP) + .value_name("GROUP") + .help("Send restrict to only users in the group(s)") + .num_args(1) + .required(false) + .action(ArgAction::Append) // User can target more than one group + .value_parser(clap::value_parser!(String)), + ) + .arg( + Arg::new(STRING) + .action(ArgAction::Append) + .value_parser(ValueParser::os_string()), + ) +} + +#[cfg(target_family = "unix")] +fn get_message(args: ValuesRef) -> Result { + if args.len() == 0 { + read_from_stdin() + } else if args.len() == 1 { + match read_from_file(args.clone().next().unwrap()) { + Ok(str) => Ok(str), + Err(_e) => { + #[cfg(target_os = "linux")] + return concatenate_message(args); + #[cfg(not(target_os = "linux"))] + return Err(_e); + } + } + } else { + concatenate_message(args) + } +} + +#[cfg(target_family = "unix")] +fn read_from_stdin() -> Result { + let mut buffer = Vec::new(); + io::stdin().read_to_end(&mut buffer)?; + let res = String::from_utf8(buffer)?; + Ok(res) +} + +#[cfg(target_family = "unix")] +fn read_from_file(file: &OsString) -> Result { + let mut buffer = Vec::new(); + let mut file = std::fs::File::open(file)?; + file.read_to_end(&mut buffer)?; + let res = String::from_utf8(buffer)?; + Ok(res) +} + +#[cfg(target_family = "unix")] +fn concatenate_message(args: ValuesRef) -> Result { + let mut res = String::new(); + for arg in args { + res.push_str(arg.to_str().ok_or(WallError::ToStringError)?); + res.push(' '); + } + res.pop(); + Ok(res) +} + +#[cfg(target_family = "unix")] +fn find_logged_users() -> Vec { + let mut res = Vec::::new(); + for ut in Utmpx::iter_all_records() { + if ut.is_user_process() { + let mut tty_path = OsString::from("/dev/"); + tty_path.push(OsString::from(&ut.tty_device().clone())); + res.push(tty_path); + } + } + res +} + +#[cfg(target_family = "unix")] +fn wall_intro_message() -> String { + let user = "USER"; + let biding = unistd::gethostname().unwrap_or_else(|_| "".into()); + let hostname = biding.to_string_lossy(); + + let user = env::var_os(user).unwrap_or_default(); + // Fetch the TTY of the process calling wall (requires OS-specific calls or a wrapper function) + let tty = &get_sender(); + + let datetime = get_hour_and_date(); + #[cfg(target_os = "macos")] + return format!( + "\r\nBroadcast message from {}@{} ({tty}) at ({datetime} \r\n\r\n", + user.to_string_lossy(), + hostname + ); + #[cfg(target_os = "linux")] + return format!( + "\r\nBroadcast message from {}@{} ({tty}) ({datetime}) \r\n\r\n", + user.to_string_lossy(), + hostname + ); +} + +#[cfg(target_family = "unix")] +fn write_to_terminals(message: String, users: Vec) -> UResult<()> { + #[cfg(target_os = "linux")] + let mut formatted_message = message.replace('\n', "\r\n\n"); + #[cfg(target_os = "linux")] + formatted_message.push_str("\r\n\n"); + + #[cfg(not(target_os = "linux"))] + let formatted_message = message.replace('\n', "\r\n\n"); + + let transmission = format!("{}{}", wall_intro_message(), formatted_message); + for user in users { + let mut file = match std::fs::OpenOptions::new().write(true).open(user) { + Ok(f) => f, + Err(_) => continue, + }; + if !unistd::isatty(&file).unwrap_or(false) { + continue; + } + write!(file, "{transmission}").map_err(|e| { + eprintln!("wall-error: terminal write:, {e}",); + WallError::Stdin(e) + })?; + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn get_hour_and_date() -> String { + chrono::Local::now().format("%a %b %e %H:%M %Y").to_string() +} + +#[cfg(target_os = "macos")] +fn get_hour_and_date() -> String { + chrono::Local::now().format("%a %b %e %H:%M %Z").to_string() +} + +#[cfg(target_os = "macos")] +fn get_sender() -> String { + unistd::ttyname(std::io::stdin().as_fd()) + .unwrap_or_else(|_| "".into()) + .to_string_lossy() + .to_string() +} + +#[cfg(target_os = "linux")] +fn get_sender() -> String { + unistd::ttyname(std::io::stdin().as_fd()) + .unwrap_or_else(|_| "".into()) + .to_string_lossy() + .strip_prefix("/dev/") // Wall doesn't print /dev/ after tty name, but might not be the way it does it + .unwrap_or("") + .to_string() +} diff --git a/src/uu/wall/wall.md b/src/uu/wall/wall.md new file mode 100644 index 00000000..1a100295 --- /dev/null +++ b/src/uu/wall/wall.md @@ -0,0 +1 @@ +wall - write message to all users diff --git a/tests/by-util/test_lslocks.rs b/tests/by-util/test_lslocks.rs index a73d3d5f..f94a25aa 100644 --- a/tests/by-util/test_lslocks.rs +++ b/tests/by-util/test_lslocks.rs @@ -17,6 +17,8 @@ fn test_column_headers() { assert_eq!( cols, - ["COMMAND", "PID", "TYPE", "SIZE", "MODE", "M", "START", "END", "PATH"] + [ + "COMMAND", "PID", "TYPE", "SIZE", "MODE", "M", "START", "END", "PATH" + ] ); } diff --git a/tests/by-util/test_setsid.rs b/tests/by-util/test_setsid.rs index 002873f6..6d96492a 100644 --- a/tests/by-util/test_setsid.rs +++ b/tests/by-util/test_setsid.rs @@ -6,8 +6,8 @@ #[cfg(target_family = "unix")] mod unix { use uutests::new_ucmd; - use uutests::util::get_tests_binary; use uutests::util::UCommand; + use uutests::util::get_tests_binary; #[test] fn test_invalid_arg() { diff --git a/tests/by-util/test_wall.rs b/tests/by-util/test_wall.rs new file mode 100644 index 00000000..94b1a613 --- /dev/null +++ b/tests/by-util/test_wall.rs @@ -0,0 +1,35 @@ +// This file is part of the uutils util-linux package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use uutests::new_ucmd; + +#[cfg(target_family = "unix")] +#[test] +fn test_invalid_arg() { + new_ucmd!().arg("--definitely-invalid").fails().code_is(1); +} + +#[cfg(not(target_family = "unix"))] +#[test] +fn unsupported_feature() { + new_ucmd!().arg("Cargo.toml").fails().code_is(1); +} + +#[cfg(target_os = "linux")] +#[test] +fn test_invalid_file() { + new_ucmd!().arg("not_existing_file.not_existing_extension"); // Should print non-file name as broadcast +} + +#[cfg(target_os = "macos")] +#[test] +fn test_invalid_file() { + new_ucmd!() + .arg("not_existing_file.not_existing_extension") + .fails() + .code_is(1); // On macOS, file not existing is an error +} + +// wall does not print the content of the file in the stdout, it sends it to the tty(s). diff --git a/tests/tests.rs b/tests/tests.rs index 09ffc245..d9bafecf 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -94,3 +94,7 @@ mod test_mcookie; #[cfg(feature = "uuidgen")] #[path = "by-util/test_uuidgen.rs"] mod test_uuidgen; + +#[cfg(feature = "wall")] +#[path = "by-util/test_wall.rs"] +mod test_wall;