diff --git a/Cargo.lock b/Cargo.lock index 4a5bffc9..5db53a16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1288,7 +1288,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ "fastrand", - "getrandom 0.3.2", + "getrandom 0.4.1", "once_cell", "rustix", "windows-sys 0.60.2", @@ -1426,9 +1426,9 @@ checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" [[package]] name = "unicode-width" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "unicode-xid" @@ -1485,6 +1485,7 @@ dependencies = [ "uu_setpgid", "uu_setsid", "uu_uuidgen", + "uu_wall", "uucore 0.2.2", "uuid", "uutests", @@ -1701,6 +1702,17 @@ dependencies = [ "windows", ] +[[package]] +name = "uu_wall" +version = "0.0.1" +dependencies = [ + "chrono", + "clap", + "libc", + "unicode-width", + "uucore 0.2.2", +] + [[package]] name = "uucore" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 56d2898f..21db372c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ feat_common_core = [ "setpgid", "setsid", "uuidgen", + "wall", ] [workspace.dependencies] @@ -75,6 +76,7 @@ sysinfo = "0.38" tempfile = "3.9.0" textwrap = { version = "0.16.0", features = ["terminal_size"] } thiserror = "2.0" +unicode-width = { version = "0.2.2", default-features = false } uucore = "0.2.2" uuid = { version = "1.16.0", features = ["rng-rand"] } uutests = "0.6.0" @@ -86,10 +88,10 @@ clap = { workspace = true } clap_complete = { workspace = true } clap_mangen = { workspace = true } dns-lookup = { workspace = true } -parse_datetime = {workspace = true} +parse_datetime = { workspace = true } phf = { workspace = true } serde = { workspace = true } -serde_json = { workspace = true } +serde_json = { workspace = true } textwrap = { workspace = true } uucore = { workspace = true } @@ -113,13 +115,16 @@ nologin = { optional = true, version = "0.0.1", package = "uu_nologin", path = " renice = { optional = true, version = "0.0.1", package = "uu_renice", path = "src/uu/renice" } rev = { optional = true, version = "0.0.1", package = "uu_rev", path = "src/uu/rev" } 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" } +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 = "0.6.0" # dmesg test require fixed-boot-time feature turned on. -dmesg = { version = "0.0.1", package = "uu_dmesg", path = "src/uu/dmesg", features = ["fixed-boot-time"] } +dmesg = { version = "0.0.1", package = "uu_dmesg", path = "src/uu/dmesg", features = [ + "fixed-boot-time", +] } libc = { workspace = true } pretty_assertions = "1" rand = { workspace = true } diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 00000000..e69de29b diff --git a/src/uu/wall/Cargo.toml b/src/uu/wall/Cargo.toml new file mode 100644 index 00000000..178a4eb5 --- /dev/null +++ b/src/uu/wall/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "uu_wall" +version = "0.0.1" +edition = "2021" +description = "wall ~ Write a mesage to all users." + +[lib] +path = "src/wall.rs" + +[[bin]] +name = "wall" +path = "src/main.rs" + +[dependencies] +uucore = { workspace = true, features = ["process", "entries"] } +clap = { workspace = true } +libc = { workspace = true } +chrono = { workspace = true } +unicode-width = { workspace = true } diff --git a/src/uu/wall/src/main.rs b/src/uu/wall/src/main.rs new file mode 100644 index 00000000..304f2b55 --- /dev/null +++ b/src/uu/wall/src/main.rs @@ -0,0 +1 @@ +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..1949e40b --- /dev/null +++ b/src/uu/wall/src/wall.rs @@ -0,0 +1,313 @@ +use clap::{crate_version, Arg, ArgAction, Command}; +use uucore::{error::UResult, format_usage, help_about, help_usage}; + +#[cfg(unix)] +use uucore::{ + entries::{Group, Locate}, + error::USimpleError, + process, +}; + +const ABOUT: &str = help_about!("wall.md"); +const USAGE: &str = help_usage!("wall.md"); + +#[cfg(unix)] +mod unix { + use super::process; + + use uucore::entries::{uid2usr, Locate, Passwd}; + use uucore::utmpx::Utmpx; + + use std::{ + ffi::CStr, + fs::OpenOptions, + io::{BufRead, BufReader, Read, Write}, + sync::{mpsc, Arc}, + time::Duration, + }; + use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + + const TERM_WIDTH: usize = 79; + const BLANK: &str = unsafe { str::from_utf8_unchecked(&[b' '; TERM_WIDTH]) }; + fn blank(s: &mut String) { + *s += BLANK; + *s += "\r\n"; + } + + // go through user entries and print to each tty once. + // if group is specified, only print to memebers of the group. + pub fn wall( + input: R, + group: Option, + timeout: Option<&u64>, + print_banner: bool, + ) { + let msg = makemsg(input, print_banner); + let mut seen_ttys = Vec::with_capacity(16); + for record in Utmpx::iter_all_records() { + if !record.is_user_process() { + continue; + } + + // make sure device is valid + let tty = record.tty_device(); + if tty.is_empty() || tty.starts_with(':') { + continue; + } + + // check group membership + if let Some(gid) = group { + match Passwd::locate(record.user().as_str()) { + Ok(pw) if pw.gid == gid || pw.belongs_to().contains(&gid) => {} + _ => continue, + } + } + + // output message to device + if !seen_ttys.contains(&tty) { + if let Err(e) = ttymsg(&tty, msg.clone(), timeout) { + eprintln!("warn ({tty:?}): {e}"); + } + seen_ttys.push(tty); + } + } + } + + // Create the banner and sanitise input + fn makemsg(input: R, print_banner: bool) -> Arc { + let mut buf = String::with_capacity(256); + if print_banner { + let hostname = unsafe { + let mut buf = [0; 256]; + let ret = libc::gethostname(buf.as_mut_ptr(), buf.len()); + if ret == 0 { + CStr::from_ptr(buf.as_ptr()).to_string_lossy().into_owned() + } else { + "unknown".to_string() + } + }; + + let user = uid2usr(process::getuid()).unwrap_or("".to_string()); + + let tty = unsafe { + let tty_ptr = libc::ttyname(libc::STDOUT_FILENO); + if tty_ptr.is_null() { + "somewhere".to_string() + } else { + let s = CStr::from_ptr(tty_ptr).to_string_lossy(); + s.strip_prefix("/dev/").unwrap_or(&s).to_string() + } + }; + + let date = chrono::Local::now().format("%a %b %e %T %Y"); + let banner = format!("Broadcast message from {user}@{hostname} ({tty}) ({date}):"); + + blank(&mut buf); + buf += &banner; + buf.extend(std::iter::repeat_n( + ' ', + TERM_WIDTH.saturating_sub(banner.width()), + )); + buf += "\x07\x07\r\n"; + } + + // we put a blank box around our input + blank(&mut buf); + let mut reader = BufReader::new(input).lines(); + while let Some(Ok(line)) = reader.next() { + buf += &sanitise_line(&line); + } + blank(&mut buf); + + Arc::new(buf) + } + + // this function does two things: + // - wraps lines by TERM_WIDTH + // - escapes control characters + fn sanitise_line(line: &str) -> String { + use std::fmt::Write; + + let mut buf = String::with_capacity(line.len()); + let mut col = 0; + + for ch in line.chars() { + // sanitise character + match ch { + '\x07' => buf.push(ch), + '\t' => { + buf.push(ch); + col += 7 - (col % 8); + } + _ if ch.is_ascii_control() => { + buf.push('^'); + buf.push((ch as u8 ^ 0x40) as char); + col += 2; + } + _ if (0x80_u8..=0x9F).contains(&(ch as u8)) => { + let _ = write!(buf, "\\x{:02X}", ch as u8); + col += 4; + } + _ if ch.is_control() => { + let _ = write!(buf, "\\u{:04X}", ch as u32); + col += 6; + } + _ => { + buf.push(ch); + col += ch.width().unwrap_or_default(); + } + } + + // wrap line + if col >= TERM_WIDTH { + buf += "\r\n"; + col = 0; + } + } + + // fill rest of line with spaces + buf.extend(std::iter::repeat_n(' ', TERM_WIDTH.saturating_sub(col))); + buf + "\r\n" + } + + // Write to the tty device + fn ttymsg(tty: &str, msg: Arc, timeout: Option<&u64>) -> Result<(), &'static str> { + let (tx, rx) = mpsc::channel(); + let device = String::from("/dev/") + tty; + + // spawn thread to write to device + std::thread::spawn(move || { + let r = match OpenOptions::new().write(true).open(&device) { + Ok(mut f) => f.write_all(msg.as_bytes()).map_err(|_| "write failed"), + Err(_) => Err("open failed"), + }; + let _ = tx.send(r); + }); + + // wait with timeout if specified, otherwise block + if let Some(&t) = timeout { + rx.recv_timeout(Duration::from_secs(t)) + .map_err(|_| "write timeout")? + } else { + rx.recv().map_err(|_| "channel closed")? + } + } +} + +#[must_use] +pub fn uu_app() -> Command { + Command::new(uucore::util_name()) + .version(crate_version!()) + .about(ABOUT) + .override_usage(format_usage(USAGE)) + .infer_long_args(true) + .arg( + Arg::new("input") + .value_name(" | ") + .help("file to read or literal message") + .num_args(1..) + .index(1), + ) + .arg( + Arg::new("group") + .short('g') + .long("group") + .help("only send mesage to group"), + ) + .arg( + Arg::new("nobanner") + .short('n') + .long("nobanner") + .action(ArgAction::SetTrue) + .help("do not print banner, works only for root"), + ) + .arg( + Arg::new("timeout") + .short('t') + .long("timeout") + .value_parser(clap::value_parser!(u64)) + .help("write timeout in seconds"), + ) +} + +#[cfg(not(unix))] +#[uucore::main] +pub fn uumain(_args: impl uucore::Args) -> UResult<()> { + Err(uucore::error::USimpleError::new( + 1, + "`wall` is available only on Unix.", + )) +} + +#[cfg(unix)] +#[uucore::main] +pub fn uumain(args: impl uucore::Args) -> UResult<()> { + use std::fs::File; + use std::path::Path; + + let args = uu_app().try_get_matches_from_mut(args)?; + + // clap will reject non-integer values, so we just need to reject 0 + let timeout = args.get_one::("timeout"); + if timeout == Some(&0) { + return Err(USimpleError::new(1, "invalid timeout argument: 0")); + } + + // get nobanner flag and check if user is root + let flag = args.get_flag("nobanner"); + let print_banner = if flag && process::geteuid() != 0 { + eprintln!("wall: --nobanner is available only for root"); + true + } else { + !flag + }; + + // if group exists, map to corresponding gid + let group = args + .get_one::("group") + .map(|g| { + Group::locate(g.as_str()) + .map(|g| g.gid) + .map_err(|_| USimpleError::new(1, format!("{g}: unknown group"))) + }) + .transpose()?; + + // If we have a single input arg and it exists on disk, treat as a file. + // If either is false, assume it is a literal string. + // If no input given, use stdin. + if let Some(v) = args.get_many::("input") { + let vals: Vec<&str> = v.map(String::as_str).collect(); + + let fname = vals + .first() + .expect("clap guarantees at least 1 value for input"); + + let p = Path::new(fname); + if vals.len() == 1 && p.exists() { + // When we are not root, but suid or sgid, refuse to read files + // (e.g. device files) that the user may not have access to. + // After all, our invoker can easily do "wall < file" instead of "wall file". + let uid = process::getuid(); + if uid > 0 && (uid != process::geteuid() || process::getgid() != process::getegid()) { + return Err(USimpleError::new( + 1, + format!("will not read {fname} - use stdin"), + )); + } + + let Ok(f) = File::open(p) else { + return Err(USimpleError::new(1, format!("cannot open {fname}"))); + }; + + unix::wall(f, group, timeout, print_banner); + } else { + let mut s = vals.as_slice().join(" "); + s.push('\n'); + unix::wall(s.as_bytes(), group, timeout, print_banner); + } + } else { + unix::wall(std::io::stdin(), group, timeout, print_banner); + } + + Ok(()) +} diff --git a/src/uu/wall/wall.md b/src/uu/wall/wall.md new file mode 100644 index 00000000..37481667 --- /dev/null +++ b/src/uu/wall/wall.md @@ -0,0 +1,7 @@ +# wall + +``` +wall [options] [ | ] +``` + +Write a mesage to all users. diff --git a/tests/by-util/test_wall.rs b/tests/by-util/test_wall.rs new file mode 100644 index 00000000..bcf9c9af --- /dev/null +++ b/tests/by-util/test_wall.rs @@ -0,0 +1,54 @@ +#[cfg(unix)] +mod tests { + use uutests::new_ucmd; + + #[test] + fn test_invalid_arg() { + new_ucmd!().arg("--definitely-invalid").fails().code_is(1); + } + + #[test] + fn test_fails_on_invalid_group() { + new_ucmd!() + .arg("-g") + .arg("fooblywoobly") // assuming this group doesnt exist + .fails() + .code_is(1) + .stderr_is("wall: fooblywoobly: unknown group\n"); + } + + #[test] + fn test_fails_on_invalid_gid() { + new_ucmd!() + .arg("-g") + .arg("99999") // assuming this group doesnt exist + .fails() + .code_is(1) + .stderr_is("wall: 99999: unknown group\n"); + } + + #[test] + fn test_warns_on_nobanner() { + new_ucmd!() + .arg("-n") + .arg("some text to wall") + .succeeds() + .code_is(0) + .stderr_is("wall: --nobanner is available only for root\n"); + } + + #[test] + fn test_fails_on_invalid_timeout() { + new_ucmd!() + .arg("-t") + .arg("0") + .fails() + .code_is(1) + .stderr_is("wall: invalid timeout argument: 0\n"); + } + + #[test] + fn test_succeeds_no_stdout() { + new_ucmd!().pipe_in("pipe me").succeeds().stdout_is(""); + } +} diff --git a/tests/tests.rs b/tests/tests.rs index 8f94a09a..069ce4f8 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -90,3 +90,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;