diff --git a/Cargo.lock b/Cargo.lock index 5a558db..52f5ff8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1481,6 +1481,7 @@ dependencies = [ "either", "interpreter", "lexer", + "libc", "memchr", "parser", "thiserror", diff --git a/Cargo.toml b/Cargo.toml index 82eb224..f8a5bd6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,13 @@ license.workspace = true name = "awk" path = "src/main.rs" +[[bin]] +name = "pwcat" +path = "src/bin/pwcat.rs" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + [workspace.package] version = "0.1.0" license = "MIT OR Apache-2.0" diff --git a/parser/src/tests/ast_gen.rs b/parser/src/tests/ast_gen.rs index 39c5db0..16fbdd1 100644 --- a/parser/src/tests/ast_gen.rs +++ b/parser/src/tests/ast_gen.rs @@ -306,11 +306,11 @@ fn materialize_atom<'a>(atom: &GenAtom, arena: &'a Bump) -> Atom<'a> { } } -fn materialize_var(index: u8, arena: &Bump) -> Variable { +fn materialize_var(index: u8, arena: &Bump) -> Variable<'_> { Variable::User(ident(arena, index)) } -fn ident(arena: &Bump, index: u8) -> Identifier { +fn ident(arena: &Bump, index: u8) -> Identifier<'_> { let literal = match index % 4 { 0 => "a", 1 => "b", @@ -327,6 +327,6 @@ fn text_slice<'a>(arena: &'a Bump, content: &str) -> lexer::Slice<'a> { arena.alloc_str(content).as_bytes().into() } -fn regex_slice(arena: &Bump, index: u8) -> lexer::Slice { +fn regex_slice(arena: &Bump, index: u8) -> lexer::Slice<'_> { text_slice(arena, &format!("p{index}")) } diff --git a/src/bin/pwcat.rs b/src/bin/pwcat.rs new file mode 100644 index 0000000..9d8e839 --- /dev/null +++ b/src/bin/pwcat.rs @@ -0,0 +1,102 @@ +// This file is part of the uutils awk package. +// +// For the full copyright and license information, please view the LICENSE +// files that was distributed with this source code. + +//! Dump the password database in `/etc/passwd` format for gawk library routines. +//! +//! Behavior matches the `pwcat` helper described in the GNU Awk User's Guide: +//! +//! +//! Uses `getpwent()`/`endpwent()` so NSS sources beyond local files (LDAP, SSSD, +//! systemd-userdb, etc.) are included, as required by routines such as +//! `passwd.awk`. + +use std::{ + io::{self, Write}, + process::ExitCode, +}; + +fn main() -> ExitCode { + #[cfg(unix)] + { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(err) if err.kind() == io::ErrorKind::BrokenPipe => ExitCode::SUCCESS, + Err(err) => { + let _ = writeln!(io::stderr(), "pwcat: {err}"); + ExitCode::FAILURE + } + } + } + #[cfg(not(unix))] + { + let _ = writeln!(io::stderr(), "pwcat: not supported on this platform"); + ExitCode::FAILURE + } +} + +#[cfg(unix)] +fn run() -> io::Result<()> { + struct EndPwent; + + impl Drop for EndPwent { + fn drop(&mut self) { + // SAFETY: pairs with `setpwent` below; `pwcat` is single-threaded. + unsafe { + libc::endpwent(); + } + } + } + + let mut out = io::stdout().lock(); + + // SAFETY: `getpwent`/`setpwent`/`endpwent` share process-global state and are + // not thread-safe. `pwcat` is a single-threaded helper, so exclusive use is OK. + unsafe { + libc::setpwent(); + } + let _end = EndPwent; + + loop { + // SAFETY: see note above; pointer is only used while non-null and before + // the next `getpwent`/`endpwent` call. + let passwd = unsafe { libc::getpwent() }; + if passwd.is_null() { + break; + } + // SAFETY: `getpwent` returned a non-null pointer to a valid `passwd`. + let passwd = unsafe { &*passwd }; + + write_field(&mut out, passwd.pw_name)?; + out.write_all(b":")?; + write_field_or_star(&mut out, passwd.pw_passwd)?; + write!(out, ":{}:{}:", passwd.pw_uid, passwd.pw_gid)?; + write_field(&mut out, passwd.pw_gecos)?; + out.write_all(b":")?; + write_field(&mut out, passwd.pw_dir)?; + out.write_all(b":")?; + write_field(&mut out, passwd.pw_shell)?; + out.write_all(b"\n")?; + } + + Ok(()) +} + +#[cfg(unix)] +fn write_field(out: &mut impl Write, ptr: *const libc::c_char) -> io::Result<()> { + if ptr.is_null() { + return Ok(()); + } + // SAFETY: caller guarantees a NUL-terminated C string when non-null. + let bytes = unsafe { std::ffi::CStr::from_ptr(ptr) }.to_bytes(); + out.write_all(bytes) +} + +#[cfg(unix)] +fn write_field_or_star(out: &mut impl Write, ptr: *const libc::c_char) -> io::Result<()> { + if ptr.is_null() { + return out.write_all(b"*"); + } + write_field(out, ptr) +} diff --git a/tests/by-util/test_pwcat.rs b/tests/by-util/test_pwcat.rs new file mode 100644 index 0000000..f97a414 --- /dev/null +++ b/tests/by-util/test_pwcat.rs @@ -0,0 +1,89 @@ +// This file is part of the uutils awk package. +// +// For the full copyright and license information, please view the LICENSE +// files that was distributed with this source code. + +use std::process::{Command, Stdio}; + +use crate::{TESTS_BINARY, ucmd}; + +#[cfg_attr( + not(target_os = "linux"), + ignore = "pwcat tests require Linux NSS via getent" +)] +#[test] +fn pwcat_outputs_passwd_database_format() { + let result = ucmd().succeeds(); + let stdout = result.stdout_str(); + + assert!( + !stdout.is_empty(), + "pwcat produced no output; password database may be unavailable in this environment" + ); + + for line in stdout.lines().filter(|line| !line.is_empty()) { + let fields: Vec<&str> = line.split(':').collect(); + assert_eq!( + fields.len(), + 7, + "expected 7 colon-separated fields in line: {line}" + ); + assert!( + fields[2].chars().all(|ch| ch.is_ascii_digit()), + "expected numeric uid in line: {line}" + ); + assert!( + fields[3].chars().all(|ch| ch.is_ascii_digit()), + "expected numeric gid in line: {line}" + ); + } +} + +// Regression test for gawk compatibility: pwcat must match the password database +// format consumed by gawk library routines (see passwd.awk). +#[cfg_attr( + not(target_os = "linux"), + ignore = "pwcat tests require Linux NSS via getent" +)] +#[test] +fn pwcat_matches_getent_passwd() { + let getent = Command::new("getent") + .arg("passwd") + .output() + .expect("failed to spawn getent; install it or skip this host explicitly"); + assert!( + getent.status.success(), + "getent passwd is required for this test (exit {:?}): {}", + getent.status.code(), + String::from_utf8_lossy(&getent.stderr) + ); + + let pwcat = ucmd().succeeds(); + assert_eq!( + getent.stdout.as_slice(), + pwcat.stdout(), + "pwcat output should match getent passwd" + ); +} + +#[cfg_attr( + not(target_os = "linux"), + ignore = "pwcat tests require Linux NSS via getent" +)] +#[test] +fn pwcat_ignores_broken_pipe() { + let mut child = Command::new(TESTS_BINARY) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn pwcat"); + + // Close the read end immediately so the next write hits EPIPE/BrokenPipe. + drop(child.stdout.take()); + + let status = child.wait().expect("failed to wait for pwcat"); + assert!( + status.success(), + "pwcat should treat broken pipe as success, got {status}" + ); +} diff --git a/tests/pwcat.rs b/tests/pwcat.rs new file mode 100644 index 0000000..0acb9d7 --- /dev/null +++ b/tests/pwcat.rs @@ -0,0 +1,27 @@ +// This file is part of the uutils awk package. +// +// For the full copyright and license information, please view the LICENSE +// files that was distributed with this source code. + +use std::env; + +use uutests::util::TestScenario; + +pub const TESTS_BINARY: &str = env!("CARGO_BIN_EXE_pwcat"); + +#[ctor::ctor(unsafe)] +fn init() { + unsafe { + env::set_var("UUTESTS_BINARY_PATH", TESTS_BINARY); + env::remove_var("UUTESTS_UTIL_NAME"); + env::set_var("UUTESTS_UTIL_NAME", ""); + env::set_var("UUTILS_MULTICALL", "0"); + } +} + +fn ucmd() -> uutests::util::UCommand { + TestScenario::new("pwcat").cmd(TESTS_BINARY) +} + +#[path = "by-util/test_pwcat.rs"] +mod test_pwcat;