diff --git a/Cargo.lock b/Cargo.lock index 0f364dfe..d5ee3833 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" @@ -1400,12 +1397,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", @@ -1417,15 +1413,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", @@ -1555,6 +1551,7 @@ dependencies = [ "uu_setpgid", "uu_setsid", "uu_uuidgen", + "uu_wall", "uucore 0.2.2", "uuhelp_parser", "uuid", @@ -1782,6 +1779,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 a35f33c5..ba1f4757 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..0bfab36a --- /dev/null +++ b/src/uu/wall/src/wall.rs @@ -0,0 +1,290 @@ +// 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. + +use chrono; +use clap::builder::ValueParser; +use clap::parser::ValuesRef; +use clap::{Arg, ArgAction, Command}; +use nix::unistd; +use std::env; +use std::ffi::OsString; +use std::io; +use std::io::prelude::*; +use std::os::fd::AsFd; +use std::string::FromUtf8Error; +use thiserror::Error; + +use uucore::error::{UError, UResult}; +use uucore::format_usage; +use uucore::utmpx::Utmpx; + +use uucore::translate; +const STRING: &str = "string"; +const OPT_GROUP: &str = "group"; +const OPT_NOBANNER: &str = "nobanner"; +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 + } +} + +#[uucore::main(no_signals)] +pub fn uumain(args: impl uucore::Args) -> UResult<()> { + let args = args.skip(1).peekable(); + let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; + let message = get_message(matches.get_many(STRING).unwrap_or_default())?; + let users = find_logged_users(); + write_to_terminals(message, users)?; + Ok(()) +} + +pub fn uu_app() -> Command { + Command::new("wall") + .version(uucore::crate_version!()) + .about(translate!("wall-about")) + .override_usage(format_usage(&translate!("pwd-usage"))) + .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) + .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) + .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) + .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()), + ) +} + +fn get_message(args: ValuesRef) -> Result { + if args.len() == 0 { + read_from_stdin() + } else if args.len() == 1 { + read_from_file(args.into_iter().next().unwrap()) + } else { + concatenate_message(args) + } +} + +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) +} + +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) +} + +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) +} + +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 +} + +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(); + format!( + "\r\nBroadcast message from {}@{} ({tty}) at ({datetime}) \r\n\r\n", + user.to_string_lossy(), + hostname + ) +} + +fn write_to_terminals(message: String, users: Vec) -> UResult<()> { + let format_message = message.replace("\n", "\r\n\n"); + let transmission = wall_intro_message() + &format_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(()) +} + +fn get_hour_and_date() -> String { + chrono::Local::now().format("%a %b %e %H:%M %Z").to_string() +} + +fn get_sender() -> String { + unistd::ttyname(std::io::stdin().as_fd()) + .unwrap_or_else(|_| "".into()) + .to_string_lossy() + .to_string() +} + +#[cfg(test)] +mod tests { + + use crate::{OPT_GROUP, STRING}; + use crate::{find_logged_users, get_message, uu_app, write_to_terminals}; + use std::ffi::OsString; + use std::process::{Command, Output}; + + #[test] + fn test_basic_clap_implementation() { + let group = String::from("staff"); + let file = String::from("LICENSE"); + let command = vec!["wall", "-g", &group, &file]; + let matches = uucore::clap_localization::handle_clap_result(uu_app(), command) + .expect("Error outside of test perimeter"); + assert!(matches.get_one::(OPT_GROUP).unwrap() == &group); + assert!( + matches + .get_one::(STRING) + .unwrap() + .clone() + .into_string() + .unwrap() + == file + ); + } + + #[test] + fn test_get_message_on_file() { + let file = String::from("Cargo.toml"); + + // wall does not print the content of the file in the stdout, it sends it to the tty(s) + // Hence the use of cat to check if the get_message function can extract correctly the + // file + let mut command = Command::new("cat"); + command.arg(&file); + let output: Output = command.output().expect("Failed to start 'cat' command"); + assert!( + output.status.success(), + "'cat' command exit with failure status" + ); + let command_output = + String::from_utf8(output.stdout).expect("Failed to convert 'cat'output"); + + let command = vec!["wall", &file]; + let matches = uucore::clap_localization::handle_clap_result(uu_app(), command) + .expect("External error"); + let pos_arg = matches.get_many(STRING).unwrap_or_default(); + let function_output = get_message(pos_arg).unwrap(); + assert_eq!(function_output, command_output); + } + + #[test] + fn test_get_message_on_stdin() { + // for the moment test against cat is not implemented + let command = vec!["wall"]; + let matches = uucore::clap_localization::handle_clap_result(uu_app(), command) + .expect("External error"); + let pos_arg = matches.get_many(STRING).unwrap_or_default(); + let function_output = get_message(pos_arg).unwrap(); + assert_eq!(function_output, "Hello !\n"); + } + + #[test] + fn test_arguments_as_message() { + let command = vec!["wall", "Hello", "World", "!"]; + let matches = uucore::clap_localization::handle_clap_result(uu_app(), command) + .expect("External error"); + let pos_arg = matches.get_many(STRING).unwrap_or_default(); + let function_output = get_message(pos_arg).unwrap(); + assert_eq!(function_output, "Hello World !"); + } + + #[test] + fn test_found_connected_users() { + let users = find_logged_users(); + assert_eq!( + users, + vec!( + OsString::from("/dev/tty2"), + OsString::from("/dev/pts/1"), + OsString::from("/dev/pts/2") + ) + ); + } + + #[test] + fn test_print_to_terminals() { + let users = find_logged_users(); + let _ = write_to_terminals(String::from("hello world!"), users); + let _ = write_to_terminals( + String::from("hello world!"), + vec![OsString::from("/dev/tty1")], + ); + } + + #[test] + fn test_get_sender() { + let sender = crate::get_sender(); + assert_eq!(sender, "pts/0"); + } +}