diff --git a/Cargo.toml b/Cargo.toml index c4ff154..e42f4d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,7 +35,7 @@ clap = { version = "4", features = ["derive", "wrap_help"] } thiserror = "2" # Unix/Linux -nix = { version = "0.29", features = ["user", "fs", "process", "signal"] } +nix = { version = "0.29", features = ["user", "fs", "process", "signal", "term"] } libc = "0.2" # Testing diff --git a/src/shadow-core/src/pam.rs b/src/shadow-core/src/pam.rs index c9315f2..9e5a28a 100644 --- a/src/shadow-core/src/pam.rs +++ b/src/shadow-core/src/pam.rs @@ -2,8 +2,888 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +// spell-checker:ignore pamu authtok chauthtok acct strerror conv appdata ECHONL //! PAM (Pluggable Authentication Modules) integration. //! -//! Thin wrapper for authentication, account validation, and password -//! changes. Follows sudo-rs patterns for conversation functions. +//! Provides a safe wrapper around the Linux-PAM C library for authentication, +//! account validation, and password changes. The conversation function supports +//! both interactive terminal mode (with echo control) and non-interactive stdin +//! mode. +//! +//! # Design +//! +//! This module is implemented from the public Linux-PAM specification and man +//! pages (`pam(3)`, `pam_start(3)`, `pam_authenticate(3)`, `pam_acct_mgmt(3)`, +//! `pam_chauthtok(3)`, `pam_conv(3)`). The conversation function pattern +//! follows sudo-rs (Apache-2.0/MIT). +//! +//! # Feature gate +//! +//! This module is only available when the `pam` feature is enabled. + +use std::ffi::{CStr, CString}; +use std::fs::File; +use std::io::{self, BufRead, Write}; +use std::os::unix::io::AsRawFd; +use std::ptr; + +use crate::error::ShadowError; + +// --------------------------------------------------------------------------- +// PAM FFI constants +// +// Values from the Linux-PAM public header and +// . These are part of the stable ABI. +// --------------------------------------------------------------------------- + +/// PAM return codes. +pub mod return_code { + /// Successful operation. + pub const PAM_SUCCESS: i32 = 0; + /// Critical error — immediate abort. + pub const PAM_ABORT: i32 = 26; + /// `dlopen()` failure when dynamically loading a service module. + pub const PAM_OPEN_ERR: i32 = 1; + /// Symbol not found. + pub const PAM_SYMBOL_ERR: i32 = 2; + /// Error in service module. + pub const PAM_SERVICE_ERR: i32 = 3; + /// System error. + pub const PAM_SYSTEM_ERR: i32 = 4; + /// Memory buffer error. + pub const PAM_BUF_ERR: i32 = 5; + /// Permission denied. + pub const PAM_PERM_DENIED: i32 = 6; + /// Authentication failure. + pub const PAM_AUTH_ERR: i32 = 7; + /// Cannot access authentication data due to insufficient credentials. + pub const PAM_CRED_INSUFFICIENT: i32 = 8; + /// Cannot retrieve authentication information. + pub const PAM_AUTHINFO_UNAVAIL: i32 = 9; + /// User not known to the underlying authentication module. + pub const PAM_USER_UNKNOWN: i32 = 10; + /// Maximum number of retries exceeded. + pub const PAM_MAXTRIES: i32 = 11; + /// New authentication token required. + pub const PAM_NEW_AUTHTOK_REQD: i32 = 12; + /// User account has expired. + pub const PAM_ACCT_EXPIRED: i32 = 13; + /// Authentication token manipulation error. + pub const PAM_AUTHTOK_ERR: i32 = 20; + /// Authentication information cannot be recovered. + pub const PAM_AUTHTOK_RECOVERY_ERR: i32 = 21; + /// Authentication token lock busy. + pub const PAM_AUTHTOK_LOCK_BUSY: i32 = 22; + /// Authentication token aging disabled. + pub const PAM_AUTHTOK_DISABLE_AGING: i32 = 23; + /// Conversation error. + pub const PAM_CONV_ERR: i32 = 19; +} + +/// PAM message style constants (used in conversation functions). +pub mod msg_style { + /// Prompt for input with echo disabled (e.g., password entry). + pub const PAM_PROMPT_ECHO_OFF: i32 = 1; + /// Prompt for input with echo enabled (e.g., username entry). + pub const PAM_PROMPT_ECHO_ON: i32 = 2; + /// Error message — display to user. + pub const PAM_ERROR_MSG: i32 = 3; + /// Informational message — display to user. + pub const PAM_TEXT_INFO: i32 = 4; +} + +/// PAM item types for `pam_set_item`. +pub mod item_type { + /// The service name. + pub const PAM_SERVICE: i32 = 1; + /// The username. + pub const PAM_USER: i32 = 2; + /// The tty name. + pub const PAM_TTY: i32 = 3; + /// The remote host name. + pub const PAM_RHOST: i32 = 4; + /// The conversation structure. + pub const PAM_CONV: i32 = 5; + /// The authentication token (password). + pub const PAM_AUTHTOK: i32 = 6; + /// The old authentication token. + pub const PAM_OLDAUTHTOK: i32 = 7; + /// The remote user name. + pub const PAM_RUSER: i32 = 8; +} + +/// PAM flags. +pub mod flags { + /// Do not emit any messages. + pub const PAM_SILENT: i32 = 0x8000; + /// Signal that the password should be changed only if it has expired. + pub const PAM_CHANGE_EXPIRED_AUTHTOK: i32 = 0x0020; + /// Don't update the last-changed timestamp. + pub const PAM_DISALLOW_NULL_AUTHTOK: i32 = 0x0001; +} + +// --------------------------------------------------------------------------- +// PAM FFI type definitions +// --------------------------------------------------------------------------- + +/// Opaque PAM handle. Never dereferenced from Rust — only passed as a pointer. +#[repr(C)] +pub struct PamHandle { + _opaque: [u8; 0], +} + +/// A single message from the PAM module to the conversation function. +#[repr(C)] +pub struct PamMessage { + /// The message style (`PAM_PROMPT_ECHO_OFF`, etc.). + pub msg_style: libc::c_int, + /// The message string (null-terminated). + pub msg: *const libc::c_char, +} + +/// A response from the conversation function back to the PAM module. +#[repr(C)] +pub struct PamResponse { + /// The response string. Must be allocated with `libc::malloc` because PAM + /// will call `free()` on it. + pub resp: *mut libc::c_char, + /// Unused — must be zero. + pub resp_retcode: libc::c_int, +} + +/// The conversation function type as defined by PAM. +pub type PamConvFn = extern "C" fn( + num_msg: libc::c_int, + msg: *mut *const PamMessage, + resp: *mut *mut PamResponse, + appdata_ptr: *mut libc::c_void, +) -> libc::c_int; + +/// The PAM conversation structure, passed to `pam_start`. +#[repr(C)] +pub struct PamConv { + /// Pointer to the conversation function. + pub conv: PamConvFn, + /// Application-specific data passed to the conversation function. + pub appdata_ptr: *mut libc::c_void, +} + +// --------------------------------------------------------------------------- +// PAM FFI function declarations +// --------------------------------------------------------------------------- + +extern "C" { + fn pam_start( + service_name: *const libc::c_char, + user: *const libc::c_char, + pam_conversation: *const PamConv, + pamh: *mut *mut PamHandle, + ) -> libc::c_int; + + fn pam_end(pamh: *mut PamHandle, pam_status: libc::c_int) -> libc::c_int; + + fn pam_authenticate(pamh: *mut PamHandle, flags: libc::c_int) -> libc::c_int; + + fn pam_acct_mgmt(pamh: *mut PamHandle, flags: libc::c_int) -> libc::c_int; + + fn pam_chauthtok(pamh: *mut PamHandle, flags: libc::c_int) -> libc::c_int; + + fn pam_set_item( + pamh: *mut PamHandle, + item_type: libc::c_int, + item: *const libc::c_void, + ) -> libc::c_int; + + fn pam_strerror(pamh: *mut PamHandle, errnum: libc::c_int) -> *const libc::c_char; +} + +// --------------------------------------------------------------------------- +// Conversation mode +// --------------------------------------------------------------------------- + +/// Controls how the PAM conversation function obtains user input. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConvMode { + /// Read from `/dev/tty` with echo control. This is the normal interactive + /// mode: prompts are written to the terminal, and echo is disabled for + /// `PAM_PROMPT_ECHO_OFF` messages (password entry). + Tty, + /// Read from stdin without writing prompts. Used when input is piped or + /// when running non-interactively. + Stdin, +} + +/// Application data passed through the PAM conversation's `appdata_ptr`. +/// +/// The conversation function casts the void pointer back to this type to +/// determine how to collect input. +struct ConvData { + mode: ConvMode, +} + +// --------------------------------------------------------------------------- +// Conversation function +// --------------------------------------------------------------------------- + +/// PAM conversation function. +/// +/// Handles all four message styles defined by PAM: +/// - `PAM_PROMPT_ECHO_OFF`: prompt for password (echo disabled in Tty mode) +/// - `PAM_PROMPT_ECHO_ON`: prompt for visible input (e.g., username) +/// - `PAM_ERROR_MSG`: display error message to stderr +/// - `PAM_TEXT_INFO`: display informational message to stderr +/// +/// In `Tty` mode, opens `/dev/tty` directly for prompt I/O and uses termios +/// to suppress echo. In `Stdin` mode, reads from stdin silently. +extern "C" fn conversation( + num_msg: libc::c_int, + msg: *mut *const PamMessage, + resp: *mut *mut PamResponse, + appdata_ptr: *mut libc::c_void, +) -> libc::c_int { + // SAFETY: `appdata_ptr` points to a valid `ConvData` that lives for the + // duration of the PAM session (owned by `PamContext`). PAM guarantees this + // pointer is the same one we passed in `pam_start`. + let conv_data = unsafe { + if appdata_ptr.is_null() { + return return_code::PAM_CONV_ERR; + } + &*(appdata_ptr.cast::()) + }; + + if num_msg <= 0 || msg.is_null() || resp.is_null() { + return return_code::PAM_CONV_ERR; + } + + #[allow(clippy::cast_sign_loss)] // Validated positive above. + let count = num_msg as usize; + + // Allocate response array with libc::calloc so PAM can free it. + // SAFETY: `calloc` returns zeroed memory or null. We check for null below. + let responses: *mut PamResponse = + unsafe { libc::calloc(count, std::mem::size_of::()).cast::() }; + + if responses.is_null() { + return return_code::PAM_BUF_ERR; + } + + for i in 0..count { + // SAFETY: `msg` is an array of `num_msg` pointers, each pointing to a + // valid `PamMessage`. Index `i` is within bounds by loop invariant. + // Linux-PAM uses msg[i] (array of pointers) — this matches the Linux + // convention rather than the Solaris (*msg)[i] convention. + let message = unsafe { + let msg_ptr = *msg.add(i); + if msg_ptr.is_null() { + free_responses(responses, i); + return return_code::PAM_CONV_ERR; + } + &*msg_ptr + }; + + let result = match message.msg_style { + msg_style::PAM_PROMPT_ECHO_OFF => prompt_for_input(message, false, conv_data.mode), + msg_style::PAM_PROMPT_ECHO_ON => prompt_for_input(message, true, conv_data.mode), + msg_style::PAM_ERROR_MSG => { + display_message(message, true); + Ok(ptr::null_mut()) + } + msg_style::PAM_TEXT_INFO => { + display_message(message, false); + Ok(ptr::null_mut()) + } + _ => { + // Unknown message style — protocol error. + Err(()) + } + }; + + if let Ok(resp_str) = result { + // SAFETY: `i` is within the allocated range of `responses`. + unsafe { + let r = &mut *responses.add(i); + r.resp = resp_str; + r.resp_retcode = 0; + } + } else { + // Clean up already-filled responses and the array itself. + free_responses(responses, i); + return return_code::PAM_CONV_ERR; + } + } + + // SAFETY: `resp` is a valid out-pointer provided by PAM. + unsafe { + *resp = responses; + } + + return_code::PAM_SUCCESS +} + +/// Display a PAM message to stderr. +/// +/// Both error and informational messages go to stderr (matching traditional +/// PAM conversation behavior). The `_is_error` parameter is retained for +/// future differentiation (e.g., prefixing error messages). +fn display_message(message: &PamMessage, _is_error: bool) { + if message.msg.is_null() { + return; + } + + // SAFETY: `msg` is a null-terminated C string provided by PAM. + let text = unsafe { CStr::from_ptr(message.msg) }; + let text = text.to_string_lossy(); + + eprintln!("{text}"); +} + +/// Prompt for user input (with or without echo) and return a `malloc`-allocated +/// C string for the response, or `Err(())` on failure. +fn prompt_for_input( + message: &PamMessage, + echo: bool, + mode: ConvMode, +) -> Result<*mut libc::c_char, ()> { + let input = match mode { + ConvMode::Tty => read_from_tty(message, echo), + ConvMode::Stdin => read_from_stdin(), + }; + + match input { + Ok(line) => alloc_c_response(&line), + Err(_) => Err(()), + } +} + +/// Read a line from `/dev/tty`, optionally disabling echo. +/// +/// Opens `/dev/tty` directly (not stdin) to ensure we talk to the real +/// terminal even if stdin has been redirected. Uses `nix::sys::termios` to +/// disable `ECHO` for password prompts and restores the original settings +/// afterward (including on error, via a drop guard). +fn read_from_tty(message: &PamMessage, echo: bool) -> io::Result { + let tty = File::options().read(true).write(true).open("/dev/tty")?; + + // Show prompt. + if !message.msg.is_null() { + // SAFETY: `msg` is a null-terminated C string provided by PAM. + let prompt = unsafe { CStr::from_ptr(message.msg) }; + let prompt_bytes = prompt.to_bytes(); + // Write prompt directly to the tty fd. + let mut tty_write = File::options().write(true).open("/dev/tty")?; + tty_write.write_all(prompt_bytes)?; + tty_write.flush()?; + } + + // Disable echo if needed, with a guard to restore on drop. + let _guard = if echo { + None + } else { + Some(EchoGuard::disable(&tty)?) + }; + + // Read one line from the tty. + let tty_read = File::open("/dev/tty")?; + let mut reader = io::BufReader::new(tty_read); + let mut line = String::new(); + reader.read_line(&mut line)?; + + // Print a newline after hidden input so the cursor moves down. + if !echo { + let mut tty_newline = File::options().write(true).open("/dev/tty")?; + let _ = tty_newline.write_all(b"\n"); + } + + // Strip trailing newline. + if line.ends_with('\n') { + line.pop(); + } + if line.ends_with('\r') { + line.pop(); + } + + Ok(line) +} + +/// Read a line from stdin without prompting. +fn read_from_stdin() -> io::Result { + let stdin = io::stdin(); + let mut line = String::new(); + stdin.lock().read_line(&mut line)?; + + if line.ends_with('\n') { + line.pop(); + } + if line.ends_with('\r') { + line.pop(); + } + + Ok(line) +} + +/// Allocate a C string with `libc::malloc` for use as a PAM response. +/// +/// PAM will call `free()` on this pointer, so it must be allocated with the C +/// allocator rather than Rust's allocator. +fn alloc_c_response(s: &str) -> Result<*mut libc::c_char, ()> { + let len = s.len() + 1; // +1 for null terminator + + // SAFETY: Allocating `len` bytes from the C heap. We check for null. + let buf = unsafe { libc::malloc(len).cast::() }; + if buf.is_null() { + return Err(()); + } + + // SAFETY: `buf` is valid for `len` bytes. We copy exactly `s.len()` bytes + // and add a null terminator. + unsafe { + ptr::copy_nonoverlapping(s.as_ptr(), buf.cast::(), s.len()); + *buf.add(s.len()) = 0; + } + + Ok(buf) +} + +/// Free partially-filled PAM responses on error. +/// +/// Frees the `resp` strings for responses `0..count`, then frees the array. +fn free_responses(responses: *mut PamResponse, count: usize) { + for i in 0..count { + // SAFETY: `i` is within the allocated range, and each `resp` was either + // set to a `malloc`-allocated string or is null (from `calloc`). + unsafe { + let r = &mut *responses.add(i); + if !r.resp.is_null() { + // Zero out the response before freeing (may contain a password). + let len = libc::strlen(r.resp.cast::()); + ptr::write_bytes(r.resp, 0, len); + libc::free(r.resp.cast::()); + } + } + } + // SAFETY: `responses` was allocated with `calloc`. + unsafe { + libc::free(responses.cast::()); + } +} + +// --------------------------------------------------------------------------- +// Echo guard (RAII termios echo control) +// --------------------------------------------------------------------------- + +/// RAII guard that disables terminal echo and restores it on drop. +/// +/// Uses `nix::sys::termios` to manipulate the terminal's local flags. The +/// original settings are saved and restored when the guard is dropped, even +/// if the caller returns early or panics. +struct EchoGuard { + fd: libc::c_int, + original: nix::sys::termios::Termios, +} + +impl EchoGuard { + /// Disable echo on the given terminal file. + fn disable(tty: &File) -> io::Result { + use nix::sys::termios::{self, LocalFlags, SetArg}; + + let fd = tty.as_raw_fd(); + let original = termios::tcgetattr(tty).map_err(io::Error::other)?; + + let mut noecho = original.clone(); + noecho.local_flags &= !(LocalFlags::ECHO | LocalFlags::ECHONL); + + termios::tcsetattr(tty, SetArg::TCSANOW, &noecho).map_err(io::Error::other)?; + + Ok(Self { fd, original }) + } +} + +impl Drop for EchoGuard { + fn drop(&mut self) { + // SAFETY: `self.fd` is the raw fd from the tty file we opened. We use + // `BorrowedFd` to avoid consuming or closing the fd. The fd is still + // valid because the tty `File` that owns it outlives this guard in + // every call site. + use std::os::unix::io::BorrowedFd; + let fd = unsafe { BorrowedFd::borrow_raw(self.fd) }; + let _ = + nix::sys::termios::tcsetattr(fd, nix::sys::termios::SetArg::TCSANOW, &self.original); + } +} + +// --------------------------------------------------------------------------- +// Safe PAM context wrapper +// --------------------------------------------------------------------------- + +/// A safe wrapper around a PAM session. +/// +/// Manages the lifetime of a PAM handle and its associated conversation data. +/// The handle is closed with `pam_end` when the context is dropped. +/// +/// # Examples +/// +/// ```no_run +/// use shadow_core::pam::{PamContext, ConvMode}; +/// +/// let mut ctx = PamContext::new("passwd", "root", ConvMode::Tty) +/// .expect("pam_start failed"); +/// ctx.authenticate(0).expect("authentication failed"); +/// ctx.acct_mgmt(0).expect("account check failed"); +/// ``` +pub struct PamContext { + handle: *mut PamHandle, + last_status: i32, + /// Conversation data — heap-allocated so the pointer stays stable for the + /// lifetime of the PAM handle. Must be kept alive until `pam_end`. + _conv_data: Box, + /// The PAM conversation structure — must also live until `pam_end`, because + /// PAM may call the conversation function at any point during the session. + _conv: Box, +} + +impl PamContext { + /// Start a new PAM session. + /// + /// `service` is the PAM service name (e.g., `"passwd"`, `"login"`). + /// `user` is the username being authenticated. + /// `mode` controls how the conversation function collects user input. + /// + /// # Errors + /// + /// Returns `ShadowError::Auth` if `pam_start` fails. + pub fn new(service: &str, user: &str, mode: ConvMode) -> Result { + let service_c = CString::new(service) + .map_err(|_| ShadowError::Auth("service name contains null byte".to_string()))?; + let user_c = CString::new(user) + .map_err(|_| ShadowError::Auth("username contains null byte".to_string()))?; + + let conv_data = Box::new(ConvData { mode }); + let conv_data_ptr = (&raw const *conv_data).cast_mut(); + + let conv = Box::new(PamConv { + conv: conversation, + appdata_ptr: conv_data_ptr.cast::(), + }); + + let mut handle: *mut PamHandle = ptr::null_mut(); + + // SAFETY: All pointers passed to `pam_start` are valid: + // - `service_c` and `user_c` are valid null-terminated C strings + // - `conv` points to a valid `PamConv` struct that lives in a Box + // - `handle` is a valid out-pointer on the stack + // `pam_start` will allocate and initialize the PAM handle. + let rc = unsafe { + pam_start( + service_c.as_ptr(), + user_c.as_ptr(), + &raw const *conv, + &raw mut handle, + ) + }; + + if rc != return_code::PAM_SUCCESS { + return Err(ShadowError::Auth(format!( + "pam_start failed with code {rc}" + ))); + } + + if handle.is_null() { + return Err(ShadowError::Auth( + "pam_start returned success but null handle".to_string(), + )); + } + + Ok(Self { + handle, + last_status: rc, + _conv_data: conv_data, + _conv: conv, + }) + } + + /// Authenticate the user. + /// + /// This calls `pam_authenticate` which will invoke the conversation function + /// to collect credentials (typically a password). + /// + /// `flags` can be `0` or a combination of PAM flags (e.g., `PAM_SILENT`). + /// + /// # Errors + /// + /// Returns `ShadowError::Auth` if authentication fails. + pub fn authenticate(&mut self, flags: i32) -> Result<(), ShadowError> { + // SAFETY: `self.handle` is a valid PAM handle from a successful + // `pam_start` call. It remains valid until `pam_end` (called in Drop). + let rc = unsafe { pam_authenticate(self.handle, flags) }; + self.last_status = rc; + + if rc != return_code::PAM_SUCCESS { + return Err(ShadowError::Auth(self.strerror(rc))); + } + + Ok(()) + } + + /// Check account validity (expiration, access restrictions, etc.). + /// + /// Should be called after successful authentication. + /// + /// # Errors + /// + /// Returns `ShadowError::Auth` if the account check fails. + pub fn acct_mgmt(&mut self, flags: i32) -> Result<(), ShadowError> { + // SAFETY: `self.handle` is a valid PAM handle (same invariant as above). + let rc = unsafe { pam_acct_mgmt(self.handle, flags) }; + self.last_status = rc; + + if rc != return_code::PAM_SUCCESS { + return Err(ShadowError::Auth(self.strerror(rc))); + } + + Ok(()) + } + + /// Change the user's authentication token (password). + /// + /// `flags` can include `PAM_CHANGE_EXPIRED_AUTHTOK` to only change expired + /// passwords, or `0` to force a change. + /// + /// # Errors + /// + /// Returns `ShadowError::Auth` if the token change fails. + pub fn chauthtok(&mut self, flags: i32) -> Result<(), ShadowError> { + // SAFETY: `self.handle` is a valid PAM handle (same invariant as above). + let rc = unsafe { pam_chauthtok(self.handle, flags) }; + self.last_status = rc; + + if rc != return_code::PAM_SUCCESS { + return Err(ShadowError::Auth(self.strerror(rc))); + } + + Ok(()) + } + + /// Set a PAM item on the handle. + /// + /// Common items: `PAM_TTY`, `PAM_RHOST`, `PAM_RUSER`. + /// + /// # Errors + /// + /// Returns `ShadowError::Auth` if `pam_set_item` fails. + pub fn set_item_str(&mut self, item: i32, value: &str) -> Result<(), ShadowError> { + let value_c = CString::new(value) + .map_err(|_| ShadowError::Auth("item value contains null byte".to_string()))?; + + // SAFETY: `self.handle` is valid. `value_c` is a valid null-terminated + // C string. PAM copies the value internally, so `value_c` does not need + // to outlive this call. + let rc = + unsafe { pam_set_item(self.handle, item, value_c.as_ptr().cast::()) }; + self.last_status = rc; + + if rc != return_code::PAM_SUCCESS { + return Err(ShadowError::Auth(self.strerror(rc))); + } + + Ok(()) + } + + /// Get the human-readable error string for a PAM return code. + fn strerror(&self, code: i32) -> String { + // SAFETY: `self.handle` is valid, and `pam_strerror` returns a pointer + // to a static string owned by PAM (not freed by caller). + let msg = unsafe { pam_strerror(self.handle, code) }; + if msg.is_null() { + return format!("PAM error {code}"); + } + // SAFETY: `pam_strerror` returns a valid null-terminated C string. + let cstr = unsafe { CStr::from_ptr(msg) }; + cstr.to_string_lossy().into_owned() + } + + /// Return the last PAM status code. + #[must_use] + pub fn last_status(&self) -> i32 { + self.last_status + } +} + +impl Drop for PamContext { + fn drop(&mut self) { + // SAFETY: `self.handle` is a valid PAM handle. `pam_end` releases all + // resources associated with the handle. After this call the handle is + // invalid — but since we're in `Drop`, it will never be used again. + unsafe { + pam_end(self.handle, self.last_status); + } + } +} + +// `PamContext` holds a raw pointer but is safe to send between threads — the +// PAM handle is only accessed through `&mut self` methods, and the pointer is +// not shared. +// +// SAFETY: The raw pointer `handle` is exclusively owned by `PamContext`. No +// concurrent access is possible because all mutating methods require `&mut self`. +unsafe impl Send for PamContext {} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // Verify PAM constant values match the Linux-PAM ABI. These values are + // defined by the Linux-PAM specification and must be exact. + + #[test] + fn test_return_codes() { + assert_eq!(return_code::PAM_SUCCESS, 0); + assert_eq!(return_code::PAM_AUTH_ERR, 7); + assert_eq!(return_code::PAM_USER_UNKNOWN, 10); + assert_eq!(return_code::PAM_MAXTRIES, 11); + assert_eq!(return_code::PAM_NEW_AUTHTOK_REQD, 12); + assert_eq!(return_code::PAM_ACCT_EXPIRED, 13); + assert_eq!(return_code::PAM_CONV_ERR, 19); + assert_eq!(return_code::PAM_AUTHTOK_ERR, 20); + assert_eq!(return_code::PAM_AUTHTOK_RECOVERY_ERR, 21); + assert_eq!(return_code::PAM_AUTHTOK_LOCK_BUSY, 22); + assert_eq!(return_code::PAM_AUTHTOK_DISABLE_AGING, 23); + assert_eq!(return_code::PAM_ABORT, 26); + assert_eq!(return_code::PAM_PERM_DENIED, 6); + assert_eq!(return_code::PAM_SERVICE_ERR, 3); + assert_eq!(return_code::PAM_BUF_ERR, 5); + } + + #[test] + fn test_msg_styles() { + assert_eq!(msg_style::PAM_PROMPT_ECHO_OFF, 1); + assert_eq!(msg_style::PAM_PROMPT_ECHO_ON, 2); + assert_eq!(msg_style::PAM_ERROR_MSG, 3); + assert_eq!(msg_style::PAM_TEXT_INFO, 4); + } + + #[test] + fn test_item_types() { + assert_eq!(item_type::PAM_SERVICE, 1); + assert_eq!(item_type::PAM_USER, 2); + assert_eq!(item_type::PAM_TTY, 3); + assert_eq!(item_type::PAM_RHOST, 4); + assert_eq!(item_type::PAM_CONV, 5); + assert_eq!(item_type::PAM_AUTHTOK, 6); + assert_eq!(item_type::PAM_OLDAUTHTOK, 7); + assert_eq!(item_type::PAM_RUSER, 8); + } + + #[test] + fn test_flags() { + assert_eq!(flags::PAM_SILENT, 0x8000); + assert_eq!(flags::PAM_CHANGE_EXPIRED_AUTHTOK, 0x0020); + assert_eq!(flags::PAM_DISALLOW_NULL_AUTHTOK, 0x0001); + } + + #[test] + fn test_conv_mode_enum() { + // Verify the enum variants are distinct and constructible. + assert_ne!(ConvMode::Tty, ConvMode::Stdin); + let mode = ConvMode::Tty; + assert_eq!(mode, ConvMode::Tty); + let mode = ConvMode::Stdin; + assert_eq!(mode, ConvMode::Stdin); + } + + #[test] + fn test_conv_mode_is_copy() { + // ConvMode should implement Copy for ergonomic use. + let a = ConvMode::Tty; + let b = a; + assert_eq!(a, b); + } + + #[test] + fn test_pam_handle_is_opaque() { + // PamHandle should be zero-sized (opaque type, never instantiated). + assert_eq!(std::mem::size_of::(), 0); + } + + #[test] + fn test_pam_message_layout() { + // Verify PamMessage has the expected C layout. + assert_eq!( + std::mem::size_of::(), + std::mem::size_of::() + std::mem::size_of::<*const libc::c_char>() + // Account for padding. + + (std::mem::align_of::<*const libc::c_char>() + - std::mem::size_of::()) + .max(0) + ); + } + + #[test] + fn test_pam_response_layout() { + // PamResponse must contain a pointer and an int, with C layout. + assert!(std::mem::size_of::() >= std::mem::size_of::<*mut libc::c_char>()); + } + + #[test] + fn test_alloc_c_response_empty_string() { + let result = alloc_c_response(""); + assert!(result.is_ok()); + let ptr = result.expect("alloc should succeed"); + + // SAFETY: we just allocated this pointer and it should contain a null + // terminator at position 0. + unsafe { + assert_eq!(*ptr, 0); + libc::free(ptr.cast::()); + } + } + + #[test] + fn test_alloc_c_response_nonempty() { + let result = alloc_c_response("hello"); + assert!(result.is_ok()); + let ptr = result.expect("alloc should succeed"); + + // SAFETY: we just allocated this and wrote "hello\0" into it. + unsafe { + let cstr = CStr::from_ptr(ptr); + assert_eq!(cstr.to_str().expect("valid utf-8"), "hello"); + libc::free(ptr.cast::()); + } + } + + #[test] + fn test_conversation_null_appdata_returns_conv_err() { + let mut resp: *mut PamResponse = ptr::null_mut(); + let rc = conversation(0, ptr::null_mut(), &raw mut resp, ptr::null_mut()); + assert_eq!(rc, return_code::PAM_CONV_ERR); + } + + #[test] + fn test_conversation_zero_messages_returns_conv_err() { + let mut conv_data = ConvData { + mode: ConvMode::Stdin, + }; + let appdata = (&raw mut conv_data).cast::(); + let mut resp: *mut PamResponse = ptr::null_mut(); + + let rc = conversation(0, ptr::null_mut(), &raw mut resp, appdata); + assert_eq!(rc, return_code::PAM_CONV_ERR); + } + + #[test] + fn test_conversation_null_msg_returns_conv_err() { + let mut conv_data = ConvData { + mode: ConvMode::Stdin, + }; + let appdata = (&raw mut conv_data).cast::(); + let mut resp: *mut PamResponse = ptr::null_mut(); + + let rc = conversation(1, ptr::null_mut(), &raw mut resp, appdata); + assert_eq!(rc, return_code::PAM_CONV_ERR); + } +} diff --git a/src/shadow-core/src/shadow.rs b/src/shadow-core/src/shadow.rs index 707ef25..d8bd747 100644 --- a/src/shadow-core/src/shadow.rs +++ b/src/shadow-core/src/shadow.rs @@ -241,4 +241,258 @@ mod tests { assert_eq!(entry.inactive_days, None); assert_eq!(entry.expire_date, None); } + + // ------------------------------------------------------------------- + // ShadowEntry helper method tests + // ------------------------------------------------------------------- + + #[test] + fn test_is_locked_with_bang() { + let entry = ShadowEntry { + name: "u".to_string(), + passwd: "!$6$hash".to_string(), + last_change: None, + min_age: None, + max_age: None, + warn_days: None, + inactive_days: None, + expire_date: None, + reserved: String::new(), + }; + assert!(entry.is_locked()); + } + + #[test] + fn test_is_locked_without_bang() { + let entry = ShadowEntry { + name: "u".to_string(), + passwd: "$6$hash".to_string(), + last_change: None, + min_age: None, + max_age: None, + warn_days: None, + inactive_days: None, + expire_date: None, + reserved: String::new(), + }; + assert!(!entry.is_locked()); + } + + #[test] + fn test_has_no_password_empty() { + let entry = ShadowEntry { + name: "u".to_string(), + passwd: String::new(), + last_change: None, + min_age: None, + max_age: None, + warn_days: None, + inactive_days: None, + expire_date: None, + reserved: String::new(), + }; + assert!(entry.has_no_password()); + } + + #[test] + fn test_has_no_password_with_hash() { + let entry = ShadowEntry { + name: "u".to_string(), + passwd: "$6$hash".to_string(), + last_change: None, + min_age: None, + max_age: None, + warn_days: None, + inactive_days: None, + expire_date: None, + reserved: String::new(), + }; + assert!(!entry.has_no_password()); + } + + #[test] + fn test_lock_adds_bang() { + let mut entry = ShadowEntry { + name: "u".to_string(), + passwd: "$6$hash".to_string(), + last_change: None, + min_age: None, + max_age: None, + warn_days: None, + inactive_days: None, + expire_date: None, + reserved: String::new(), + }; + entry.lock(); + assert_eq!(entry.passwd, "!$6$hash"); + } + + #[test] + fn test_lock_already_locked_adds_another() { + let mut entry = ShadowEntry { + name: "u".to_string(), + passwd: "!$6$hash".to_string(), + last_change: None, + min_age: None, + max_age: None, + warn_days: None, + inactive_days: None, + expire_date: None, + reserved: String::new(), + }; + entry.lock(); + assert_eq!(entry.passwd, "!!$6$hash"); + } + + #[test] + fn test_unlock_removes_bang() { + let mut entry = ShadowEntry { + name: "u".to_string(), + passwd: "!$6$hash".to_string(), + last_change: None, + min_age: None, + max_age: None, + warn_days: None, + inactive_days: None, + expire_date: None, + reserved: String::new(), + }; + assert!(entry.unlock()); + assert_eq!(entry.passwd, "$6$hash"); + } + + #[test] + fn test_unlock_not_locked_returns_false() { + let mut entry = ShadowEntry { + name: "u".to_string(), + passwd: "$6$hash".to_string(), + last_change: None, + min_age: None, + max_age: None, + warn_days: None, + inactive_days: None, + expire_date: None, + reserved: String::new(), + }; + assert!(!entry.unlock()); + assert_eq!(entry.passwd, "$6$hash", "should be unchanged"); + } + + #[test] + fn test_unlock_only_bang_returns_false() { + // "!" alone cannot be unlocked — would result in empty password. + let mut entry = ShadowEntry { + name: "u".to_string(), + passwd: "!".to_string(), + last_change: None, + min_age: None, + max_age: None, + warn_days: None, + inactive_days: None, + expire_date: None, + reserved: String::new(), + }; + assert!(!entry.unlock()); + assert_eq!(entry.passwd, "!", "should be unchanged"); + } + + #[test] + fn test_unlock_double_bang_returns_false() { + // "!!" — removing one '!' leaves "!" which is still invalid. + let mut entry = ShadowEntry { + name: "u".to_string(), + passwd: "!!".to_string(), + last_change: None, + min_age: None, + max_age: None, + warn_days: None, + inactive_days: None, + expire_date: None, + reserved: String::new(), + }; + assert!(!entry.unlock()); + assert_eq!(entry.passwd, "!!", "should be unchanged"); + } + + #[test] + fn test_delete_password_clears() { + let mut entry = ShadowEntry { + name: "u".to_string(), + passwd: "$6$hash".to_string(), + last_change: None, + min_age: None, + max_age: None, + warn_days: None, + inactive_days: None, + expire_date: None, + reserved: String::new(), + }; + entry.delete_password(); + assert_eq!(entry.passwd, ""); + } + + #[test] + fn test_expire_sets_zero() { + let mut entry = ShadowEntry { + name: "u".to_string(), + passwd: "$6$hash".to_string(), + last_change: Some(19500), + min_age: None, + max_age: None, + warn_days: None, + inactive_days: None, + expire_date: None, + reserved: String::new(), + }; + entry.expire(); + assert_eq!(entry.last_change, Some(0)); + } + + #[test] + fn test_status_char_locked() { + let entry = ShadowEntry { + name: "u".to_string(), + passwd: "!$6$hash".to_string(), + last_change: None, + min_age: None, + max_age: None, + warn_days: None, + inactive_days: None, + expire_date: None, + reserved: String::new(), + }; + assert_eq!(entry.status_char(), "L"); + } + + #[test] + fn test_status_char_no_password() { + let entry = ShadowEntry { + name: "u".to_string(), + passwd: String::new(), + last_change: None, + min_age: None, + max_age: None, + warn_days: None, + inactive_days: None, + expire_date: None, + reserved: String::new(), + }; + assert_eq!(entry.status_char(), "NP"); + } + + #[test] + fn test_status_char_usable() { + let entry = ShadowEntry { + name: "u".to_string(), + passwd: "$6$hash".to_string(), + last_change: None, + min_age: None, + max_age: None, + warn_days: None, + inactive_days: None, + expire_date: None, + reserved: String::new(), + }; + assert_eq!(entry.status_char(), "P"); + } } diff --git a/src/uu/passwd/Cargo.toml b/src/uu/passwd/Cargo.toml index 47a3761..0f251ca 100644 --- a/src/uu/passwd/Cargo.toml +++ b/src/uu/passwd/Cargo.toml @@ -21,6 +21,10 @@ nix = { workspace = true } shadow-core = { workspace = true, features = ["shadow", "login-defs"] } thiserror = { workspace = true } +[features] +default = [] +pam = ["shadow-core/pam"] + [dev-dependencies] tempfile = { workspace = true } diff --git a/src/uu/passwd/src/passwd.rs b/src/uu/passwd/src/passwd.rs index 803884a..196aa41 100644 --- a/src/uu/passwd/src/passwd.rs +++ b/src/uu/passwd/src/passwd.rs @@ -64,8 +64,16 @@ pub fn uumain(args: impl IntoIterator) -> i32 { } }; + // Handle --root / -R: chroot before anything else. + if let Some(chroot_dir) = matches.get_one::(options::ROOT) { + if let Err(code) = do_chroot(chroot_dir) { + return code; + } + } + let prefix = matches.get_one::(options::PREFIX).map(Path::new); let root = SysRoot::new(prefix); + let quiet = matches.get_flag(options::QUIET); // Determine target user. let target_user = match resolve_target_user(&matches) { @@ -86,16 +94,16 @@ pub fn uumain(args: impl IntoIterator) -> i32 { } if matches.get_flag(options::LOCK) { - return cmd_lock(&root, &target_user); + return cmd_lock(&root, &target_user, quiet); } if matches.get_flag(options::UNLOCK) { - return cmd_unlock(&root, &target_user); + return cmd_unlock(&root, &target_user, quiet); } if matches.get_flag(options::DELETE) { - return cmd_delete(&root, &target_user); + return cmd_delete(&root, &target_user, quiet); } if matches.get_flag(options::EXPIRE) { - return cmd_expire(&root, &target_user); + return cmd_expire(&root, &target_user, quiet); } // Aging field updates. @@ -105,12 +113,11 @@ pub fn uumain(args: impl IntoIterator) -> i32 { || matches.contains_id(options::INACTIVE); if has_aging { - return cmd_aging(&matches, &root, &target_user); + return cmd_aging(&matches, &root, &target_user, quiet); } - // Default: password change via PAM (not yet implemented). - eprintln!("passwd: password change via PAM not yet implemented"); - exit_codes::UNEXPECTED_FAILURE + // Default: password change via PAM. + cmd_pam_change(&matches, &target_user) } /// Build the clap `Command` for `passwd`. @@ -297,16 +304,16 @@ fn cmd_status(root: &SysRoot, target_user: Option<&str>) -> i32 { } /// `passwd -l user` — lock the account password. -fn cmd_lock(root: &SysRoot, user: &str) -> i32 { - mutate_shadow(root, user, "Locking password", |entry| { +fn cmd_lock(root: &SysRoot, user: &str, quiet: bool) -> i32 { + mutate_shadow(root, user, "Locking password", quiet, |entry| { entry.lock(); Ok(()) }) } /// `passwd -u user` — unlock the account password. -fn cmd_unlock(root: &SysRoot, user: &str) -> i32 { - mutate_shadow(root, user, "Unlocking password", |entry| { +fn cmd_unlock(root: &SysRoot, user: &str, quiet: bool) -> i32 { + mutate_shadow(root, user, "Unlocking password", quiet, |entry| { if !entry.unlock() { return Err("cannot unlock: password is not set or would remain locked".into()); } @@ -315,29 +322,29 @@ fn cmd_unlock(root: &SysRoot, user: &str) -> i32 { } /// `passwd -d user` — delete the account password. -fn cmd_delete(root: &SysRoot, user: &str) -> i32 { - mutate_shadow(root, user, "Removing password", |entry| { +fn cmd_delete(root: &SysRoot, user: &str, quiet: bool) -> i32 { + mutate_shadow(root, user, "Removing password", quiet, |entry| { entry.delete_password(); Ok(()) }) } /// `passwd -e user` — expire the account password. -fn cmd_expire(root: &SysRoot, user: &str) -> i32 { - mutate_shadow(root, user, "Expiring password", |entry| { +fn cmd_expire(root: &SysRoot, user: &str, quiet: bool) -> i32 { + mutate_shadow(root, user, "Expiring password", quiet, |entry| { entry.expire(); Ok(()) }) } /// `passwd -n/-x/-w/-i` — update aging fields. -fn cmd_aging(matches: &clap::ArgMatches, root: &SysRoot, user: &str) -> i32 { +fn cmd_aging(matches: &clap::ArgMatches, root: &SysRoot, user: &str, quiet: bool) -> i32 { let min = matches.get_one::(options::MINDAYS).copied(); let max = matches.get_one::(options::MAXDAYS).copied(); let warn = matches.get_one::(options::WARNDAYS).copied(); let inactive = matches.get_one::(options::INACTIVE).copied(); - mutate_shadow(root, user, "Updating aging information", |entry| { + mutate_shadow(root, user, "Updating aging information", quiet, |entry| { if let Some(v) = min { entry.min_age = Some(v); } @@ -354,6 +361,66 @@ fn cmd_aging(matches: &clap::ArgMatches, root: &SysRoot, user: &str) -> i32 { }) } +/// Default operation: change password via PAM. +/// +/// Feature-gated on `pam`. When PAM is not compiled in, prints an error. +fn cmd_pam_change(matches: &clap::ArgMatches, _target_user: &str) -> i32 { + let _keep_tokens = matches.get_flag(options::KEEP_TOKENS); + let _use_stdin = matches.get_flag(options::STDIN); + let _repository = matches.get_one::(options::REPOSITORY); + + #[cfg(feature = "pam")] + { + use shadow_core::pam::{ConvMode, PamContext}; + + let conv_mode = if _use_stdin { + ConvMode::Stdin + } else { + ConvMode::Terminal + }; + + let mut pam = match PamContext::new("passwd", _target_user, conv_mode) { + Ok(ctx) => ctx, + Err(e) => { + eprintln!("passwd: {e}"); + return exit_codes::UNEXPECTED_FAILURE; + } + }; + + if let Some(repo) = _repository { + pam.set_repository(repo); + } + + // Non-root users changing their own password must authenticate first. + if !is_root() { + if let Err(e) = pam.authenticate() { + eprintln!("passwd: {e}"); + return exit_codes::PERMISSION_DENIED; + } + } + + // Change the password token. + let result = if _keep_tokens { + pam.chauthtok_expired() + } else { + pam.chauthtok() + }; + + if let Err(e) = result { + eprintln!("passwd: {e}"); + return exit_codes::UNEXPECTED_FAILURE; + } + + exit_codes::SUCCESS + } + + #[cfg(not(feature = "pam"))] + { + eprintln!("passwd: PAM support is not compiled in — cannot change password interactively"); + exit_codes::UNEXPECTED_FAILURE + } +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -384,6 +451,30 @@ fn is_root() -> bool { nix::unistd::geteuid().is_root() } +/// Perform `chroot(2)` into the specified directory. +/// +/// Must be root to call `chroot`. After `chroot`, chdir to `/` so the +/// working directory is valid inside the new root. +fn do_chroot(dir: &str) -> Result<(), i32> { + if !is_root() { + eprintln!("passwd: only root may use --root"); + return Err(exit_codes::PERMISSION_DENIED); + } + + let path = std::path::Path::new(dir); + nix::unistd::chroot(path).map_err(|e| { + eprintln!("passwd: cannot chroot to '{dir}': {e}"); + exit_codes::UNEXPECTED_FAILURE + })?; + + nix::unistd::chdir("/").map_err(|e| { + eprintln!("passwd: cannot chdir to / after chroot: {e}"); + exit_codes::UNEXPECTED_FAILURE + })?; + + Ok(()) +} + /// Format a single shadow entry as a `passwd -S` status line. /// /// Format: `username STATUS YYYY-MM-DD min max warn inactive` @@ -433,7 +524,7 @@ fn format_days_since_epoch(days: i64) -> String { /// Lock the shadow file, read entries, apply a mutation to one user's entry, /// write back atomically, invalidate nscd cache. -fn mutate_shadow(root: &SysRoot, username: &str, action: &str, mutate: F) -> i32 +fn mutate_shadow(root: &SysRoot, username: &str, action: &str, quiet: bool, mutate: F) -> i32 where F: FnOnce(&mut ShadowEntry) -> Result<(), String>, { @@ -495,7 +586,9 @@ where drop(lock); nscd::invalidate_cache("shadow"); - eprintln!("passwd: {action} for user {username}"); + if !quiet { + eprintln!("passwd: {action} for user {username}"); + } exit_codes::SUCCESS } @@ -503,11 +596,19 @@ where mod tests { use super::*; + // ----------------------------------------------------------------------- + // Basic clap / app tests + // ----------------------------------------------------------------------- + #[test] fn test_app_builds() { uu_app().debug_assert(); } + // ----------------------------------------------------------------------- + // format_status helper tests + // ----------------------------------------------------------------------- + #[test] fn test_format_status_locked() { let entry = ShadowEntry { @@ -576,13 +677,63 @@ mod tests { }; let status = format_status(&entry); // * is not locked (doesn't start with !), not empty => P - // Actually * means "no password set / cannot login" but it's technically "P" for status. - // GNU passwd shows it as "L" because * is a non-valid hash. // We follow our logic: starts_with('!') => L, empty => NP, else => P. assert!(status.contains(" P ")); assert!(status.contains(" never ")); } + #[test] + fn test_format_days_since_epoch() { + // Day 0 = 1970-01-01 + let result = format_days_since_epoch(0); + // localtime_r respects timezone, but day 0 in UTC is 01/01/1970. + // We use a fixed known value: day 19500 = 2023-05-15 (UTC). + // Instead, just verify the format is MM/DD/YYYY. + assert_eq!(result.len(), 10, "format should be MM/DD/YYYY"); + assert_eq!(&result[2..3], "/"); + assert_eq!(&result[5..6], "/"); + } + + #[test] + fn test_format_status_double_locked() { + // Password "!!" — starts with '!', so status is L. + let entry = ShadowEntry { + name: "dbllock".to_string(), + passwd: "!!".to_string(), + last_change: Some(19500), + min_age: Some(0), + max_age: Some(99999), + warn_days: Some(7), + inactive_days: None, + expire_date: None, + reserved: String::new(), + }; + let status = format_status(&entry); + assert!(status.contains(" L "), "!! should show as L"); + } + + #[test] + fn test_format_status_star_password() { + // Password "*" — not locked (no leading !), not empty => P. + let entry = ShadowEntry { + name: "star".to_string(), + passwd: "*".to_string(), + last_change: Some(19500), + min_age: Some(0), + max_age: Some(99999), + warn_days: Some(7), + inactive_days: None, + expire_date: None, + reserved: String::new(), + }; + let status = format_status(&entry); + assert!(status.contains(" P "), "* should show as P per our logic"); + } + + // ----------------------------------------------------------------------- + // Clap validation tests — conflict groups and flag parsing + // ----------------------------------------------------------------------- + #[test] fn test_conflicting_flags() { let result = uu_app().try_get_matches_from(["passwd", "-l", "-u"]); @@ -605,155 +756,375 @@ mod tests { } #[test] - fn test_status_with_prefix() { + fn test_expire_conflicts_with_lock() { + let result = uu_app().try_get_matches_from(["passwd", "-e", "-l", "user"]); + assert!(result.is_err()); + } + + #[test] + fn test_expire_conflicts_with_unlock() { + let result = uu_app().try_get_matches_from(["passwd", "-e", "-u", "user"]); + assert!(result.is_err()); + } + + #[test] + fn test_expire_conflicts_with_delete() { + let result = uu_app().try_get_matches_from(["passwd", "-e", "-d", "user"]); + assert!(result.is_err()); + } + + #[test] + fn test_expire_conflicts_with_status() { + let result = uu_app().try_get_matches_from(["passwd", "-e", "-S", "user"]); + assert!(result.is_err()); + } + + #[test] + fn test_stdin_flag_parses() { + let result = uu_app().try_get_matches_from(["passwd", "-s", "user"]); + assert!(result.is_ok()); + let m = result.unwrap(); + assert!(m.get_flag(options::STDIN)); + } + + #[test] + fn test_keep_tokens_flag_parses() { + let result = uu_app().try_get_matches_from(["passwd", "-k", "user"]); + assert!(result.is_ok()); + let m = result.unwrap(); + assert!(m.get_flag(options::KEEP_TOKENS)); + } + + #[test] + fn test_root_flag_parses() { + let result = uu_app().try_get_matches_from(["passwd", "-R", "/mnt/sysroot", "user"]); + assert!(result.is_ok()); + let m = result.unwrap(); + assert_eq!( + m.get_one::(options::ROOT).map(String::as_str), + Some("/mnt/sysroot") + ); + } + + #[test] + fn test_quiet_flag_parses() { + let result = uu_app().try_get_matches_from(["passwd", "-q", "-l", "user"]); + assert!(result.is_ok()); + let m = result.unwrap(); + assert!(m.get_flag(options::QUIET)); + } + + #[test] + fn test_repository_flag_parses() { + let result = uu_app().try_get_matches_from(["passwd", "-r", "files", "user"]); + assert!(result.is_ok()); + let m = result.unwrap(); + assert_eq!( + m.get_one::(options::REPOSITORY).map(String::as_str), + Some("files") + ); + } + + #[test] + fn test_mindays_requires_value() { + let result = uu_app().try_get_matches_from(["passwd", "-n"]); + assert!(result.is_err()); + } + + #[test] + fn test_maxdays_requires_value() { + let result = uu_app().try_get_matches_from(["passwd", "-x"]); + assert!(result.is_err()); + } + + #[test] + fn test_warndays_requires_value() { + let result = uu_app().try_get_matches_from(["passwd", "-w"]); + assert!(result.is_err()); + } + + #[test] + fn test_inactive_requires_value() { + let result = uu_app().try_get_matches_from(["passwd", "-i"]); + assert!(result.is_err()); + } + + #[test] + fn test_aging_combined_flags() { + let result = uu_app().try_get_matches_from(["passwd", "-n", "5", "-x", "90", "user"]); + assert!(result.is_ok()); + let m = result.unwrap(); + assert_eq!(m.get_one::(options::MINDAYS).copied(), Some(5)); + assert_eq!(m.get_one::(options::MAXDAYS).copied(), Some(90)); + } + + // ----------------------------------------------------------------------- + // Integration tests with --prefix (synthetic shadow files, no root needed) + // ----------------------------------------------------------------------- + + /// Helper to create a temp dir with an etc/shadow file. + fn setup_prefix(shadow_content: &str) -> tempfile::TempDir { let dir = tempfile::tempdir().unwrap(); let etc = dir.path().join("etc"); std::fs::create_dir_all(&etc).unwrap(); - std::fs::write(etc.join("shadow"), "testuser:$6$hash:19500:0:99999:7:::\n").unwrap(); + std::fs::write(etc.join("shadow"), shadow_content).unwrap(); + dir + } - let args: Vec = vec![ - "passwd".into(), - "-S".into(), - "-P".into(), - dir.path().as_os_str().to_owned(), - "testuser".into(), - ]; - let code = uumain(args); + /// Read the shadow file content back from a prefix dir. + fn read_shadow(dir: &tempfile::TempDir) -> String { + std::fs::read_to_string(dir.path().join("etc/shadow")).unwrap() + } + + /// Run uumain with the given args. + fn run(args: &[&str]) -> i32 { + let os_args: Vec = args.iter().map(|s| (*s).into()).collect(); + uumain(os_args) + } + + /// Run uumain with a prefix dir prepended to the args. + fn run_with_prefix(dir: &tempfile::TempDir, extra_args: &[&str]) -> i32 { + let prefix_str = dir.path().to_str().unwrap(); + let mut args = vec!["passwd", "-P", prefix_str]; + args.extend_from_slice(extra_args); + run(&args) + } + + #[test] + fn test_status_with_prefix() { + let dir = setup_prefix("testuser:$6$hash:19500:0:99999:7:::\n"); + let code = run_with_prefix(&dir, &["-S", "testuser"]); assert_eq!(code, 0); } #[test] fn test_lock_with_prefix() { - let dir = tempfile::tempdir().unwrap(); - let etc = dir.path().join("etc"); - std::fs::create_dir_all(&etc).unwrap(); - std::fs::write(etc.join("shadow"), "testuser:$6$hash:19500:0:99999:7:::\n").unwrap(); - - let args: Vec = vec![ - "passwd".into(), - "-l".into(), - "-P".into(), - dir.path().as_os_str().to_owned(), - "testuser".into(), - ]; - let code = uumain(args); + let dir = setup_prefix("testuser:$6$hash:19500:0:99999:7:::\n"); + let code = run_with_prefix(&dir, &["-l", "testuser"]); assert_eq!(code, 0); - // Verify the password is now locked. - let content = std::fs::read_to_string(etc.join("shadow")).unwrap(); + let content = read_shadow(&dir); assert!(content.contains("testuser:!$6$hash:")); } #[test] fn test_unlock_with_prefix() { - let dir = tempfile::tempdir().unwrap(); - let etc = dir.path().join("etc"); - std::fs::create_dir_all(&etc).unwrap(); - std::fs::write(etc.join("shadow"), "testuser:!$6$hash:19500:0:99999:7:::\n").unwrap(); - - let args: Vec = vec![ - "passwd".into(), - "-u".into(), - "-P".into(), - dir.path().as_os_str().to_owned(), - "testuser".into(), - ]; - let code = uumain(args); + let dir = setup_prefix("testuser:!$6$hash:19500:0:99999:7:::\n"); + let code = run_with_prefix(&dir, &["-u", "testuser"]); assert_eq!(code, 0); - let content = std::fs::read_to_string(etc.join("shadow")).unwrap(); + let content = read_shadow(&dir); assert!(content.contains("testuser:$6$hash:")); } #[test] fn test_delete_with_prefix() { - let dir = tempfile::tempdir().unwrap(); - let etc = dir.path().join("etc"); - std::fs::create_dir_all(&etc).unwrap(); - std::fs::write(etc.join("shadow"), "testuser:$6$hash:19500:0:99999:7:::\n").unwrap(); - - let args: Vec = vec![ - "passwd".into(), - "-d".into(), - "-P".into(), - dir.path().as_os_str().to_owned(), - "testuser".into(), - ]; - let code = uumain(args); + let dir = setup_prefix("testuser:$6$hash:19500:0:99999:7:::\n"); + let code = run_with_prefix(&dir, &["-d", "testuser"]); assert_eq!(code, 0); - let content = std::fs::read_to_string(etc.join("shadow")).unwrap(); + let content = read_shadow(&dir); assert!(content.contains("testuser::19500:")); } #[test] fn test_expire_with_prefix() { - let dir = tempfile::tempdir().unwrap(); - let etc = dir.path().join("etc"); - std::fs::create_dir_all(&etc).unwrap(); - std::fs::write(etc.join("shadow"), "testuser:$6$hash:19500:0:99999:7:::\n").unwrap(); - - let args: Vec = vec![ - "passwd".into(), - "-e".into(), - "-P".into(), - dir.path().as_os_str().to_owned(), - "testuser".into(), - ]; - let code = uumain(args); + let dir = setup_prefix("testuser:$6$hash:19500:0:99999:7:::\n"); + let code = run_with_prefix(&dir, &["-e", "testuser"]); assert_eq!(code, 0); - let content = std::fs::read_to_string(etc.join("shadow")).unwrap(); + let content = read_shadow(&dir); assert!(content.contains("testuser:$6$hash:0:")); } #[test] fn test_aging_with_prefix() { - let dir = tempfile::tempdir().unwrap(); - let etc = dir.path().join("etc"); - std::fs::create_dir_all(&etc).unwrap(); - std::fs::write(etc.join("shadow"), "testuser:$6$hash:19500:0:99999:7:::\n").unwrap(); - - let args: Vec = vec![ - "passwd".into(), - "-n".into(), - "5".into(), - "-x".into(), - "90".into(), - "-w".into(), - "14".into(), - "-i".into(), - "30".into(), - "-P".into(), - dir.path().as_os_str().to_owned(), - "testuser".into(), - ]; - let code = uumain(args); + let dir = setup_prefix("testuser:$6$hash:19500:0:99999:7:::\n"); + let code = run_with_prefix( + &dir, + &["-n", "5", "-x", "90", "-w", "14", "-i", "30", "testuser"], + ); assert_eq!(code, 0); - let content = std::fs::read_to_string(etc.join("shadow")).unwrap(); + let content = read_shadow(&dir); assert!(content.contains("testuser:$6$hash:19500:5:90:14:30::")); } #[test] fn test_status_all_with_prefix() { - let dir = tempfile::tempdir().unwrap(); - let etc = dir.path().join("etc"); - std::fs::create_dir_all(&etc).unwrap(); - std::fs::write( - etc.join("shadow"), - "root:$6$roothash:19000:0:99999:7:::\ntestuser:!:19500::::::\n", - ) - .unwrap(); - - let args: Vec = vec![ - "passwd".into(), - "-S".into(), - "-a".into(), - "-P".into(), - dir.path().as_os_str().to_owned(), - ]; - let code = uumain(args); + let dir = setup_prefix("root:$6$roothash:19000:0:99999:7:::\ntestuser:!:19500::::::\n"); + let code = run_with_prefix(&dir, &["-S", "-a"]); assert_eq!(code, 0); } + + // ----------------------------------------------------------------------- + // New integration tests + // ----------------------------------------------------------------------- + + #[test] + fn test_lock_already_locked() { + // Locking an already locked password adds another '!'. + let dir = setup_prefix("testuser:!$6$hash:19500:0:99999:7:::\n"); + let code = run_with_prefix(&dir, &["-l", "testuser"]); + assert_eq!(code, 0); + + let content = read_shadow(&dir); + assert!( + content.contains("testuser:!!$6$hash:"), + "should have double !, got: {content}" + ); + } + + #[test] + fn test_unlock_double_locked() { + // Unlocking "!!$6$hash" removes one '!', leaving "!$6$hash" which + // is still locked — so unlock should report the first '!' was removed + // but the result starts with '!' and ShadowEntry::unlock returns true + // because the *remaining* string ("!$6$hash") is non-empty and not "!". + // Actually: unlock removes *one* leading '!'. After removing one '!': + // "!!$6$hash" -> "!$6$hash" + // "!$6$hash" is non-empty and not "!", so unlock returns true. + let dir = setup_prefix("testuser:!!$6$hash:19500:0:99999:7:::\n"); + let code = run_with_prefix(&dir, &["-u", "testuser"]); + assert_eq!(code, 0); + + let content = read_shadow(&dir); + assert!( + content.contains("testuser:!$6$hash:"), + "should have single !, got: {content}" + ); + } + + #[test] + fn test_unlock_empty_password_fails() { + // Cannot unlock an account with no hash — unlock returns false. + let dir = setup_prefix("testuser::19500:0:99999:7:::\n"); + let code = run_with_prefix(&dir, &["-u", "testuser"]); + assert_ne!(code, 0, "unlocking empty password should fail"); + } + + #[test] + fn test_delete_already_empty() { + // Deleting an already-empty password is a no-op (succeeds). + let dir = setup_prefix("testuser::19500:0:99999:7:::\n"); + let code = run_with_prefix(&dir, &["-d", "testuser"]); + assert_eq!(code, 0); + + let content = read_shadow(&dir); + assert!(content.contains("testuser::19500:")); + } + + #[test] + fn test_expire_already_expired() { + // Expiring an already-expired (last_change=0) account succeeds. + let dir = setup_prefix("testuser:$6$hash:0:0:99999:7:::\n"); + let code = run_with_prefix(&dir, &["-e", "testuser"]); + assert_eq!(code, 0); + + let content = read_shadow(&dir); + assert!(content.contains("testuser:$6$hash:0:")); + } + + #[test] + fn test_multiple_users_only_target_modified() { + let shadow = "alice:$6$alice:19500:0:99999:7:::\nbob:$6$bob:19500:0:99999:7:::\ncharlie:$6$charlie:19500:0:99999:7:::\n"; + let dir = setup_prefix(shadow); + + let code = run_with_prefix(&dir, &["-l", "bob"]); + assert_eq!(code, 0); + + let content = read_shadow(&dir); + // Alice and Charlie should be unchanged. + assert!( + content.contains("alice:$6$alice:19500:0:99999:7:::\n"), + "alice should be unchanged, got: {content}" + ); + assert!( + content.contains("charlie:$6$charlie:19500:0:99999:7:::\n"), + "charlie should be unchanged, got: {content}" + ); + // Bob should be locked. + assert!( + content.contains("bob:!$6$bob:19500:0:99999:7:::\n"), + "bob should be locked, got: {content}" + ); + } + + #[test] + fn test_status_nonexistent_user() { + let dir = setup_prefix("testuser:$6$hash:19500:0:99999:7:::\n"); + let code = run_with_prefix(&dir, &["-S", "nosuchuser"]); + assert_ne!(code, 0); + } + + #[test] + fn test_lock_nonexistent_user() { + let dir = setup_prefix("testuser:$6$hash:19500:0:99999:7:::\n"); + let code = run_with_prefix(&dir, &["-l", "nosuchuser"]); + assert_ne!(code, 0); + } + + #[test] + fn test_missing_shadow_file() { + let dir = tempfile::tempdir().unwrap(); + // No etc/shadow — should return PASSWD_FILE_MISSING (4). + let etc = dir.path().join("etc"); + std::fs::create_dir_all(&etc).unwrap(); + // Shadow file does not exist. + let code = run_with_prefix(&dir, &["-S", "testuser"]); + assert_eq!(code, exit_codes::PASSWD_FILE_MISSING); + } + + #[test] + fn test_quiet_suppresses_output() { + // With -q, the stderr action message should be suppressed. + // We verify that the action still succeeds. + let dir = setup_prefix("testuser:$6$hash:19500:0:99999:7:::\n"); + let code = run_with_prefix(&dir, &["-q", "-l", "testuser"]); + assert_eq!(code, 0); + + // Verify the lock still happened. + let content = read_shadow(&dir); + assert!(content.contains("testuser:!$6$hash:")); + } + + #[test] + fn test_lock_then_status() { + let dir = setup_prefix("testuser:$6$hash:19500:0:99999:7:::\n"); + + // Lock. + let code = run_with_prefix(&dir, &["-l", "testuser"]); + assert_eq!(code, 0); + + // Check status shows L — we verify by reading the shadow file and + // checking the format_status output on the resulting entry. + let content = read_shadow(&dir); + let entry: ShadowEntry = content.trim().parse().unwrap(); + assert_eq!(entry.status_char(), "L"); + } + + #[test] + fn test_full_lifecycle() { + let dir = setup_prefix("testuser:$6$hash:19500:0:99999:7:::\n"); + + // Lock. + assert_eq!(run_with_prefix(&dir, &["-l", "testuser"]), 0); + let entry: ShadowEntry = read_shadow(&dir).trim().parse().unwrap(); + assert_eq!(entry.status_char(), "L", "after lock"); + + // Unlock. + assert_eq!(run_with_prefix(&dir, &["-u", "testuser"]), 0); + let entry: ShadowEntry = read_shadow(&dir).trim().parse().unwrap(); + assert_eq!(entry.status_char(), "P", "after unlock"); + + // Delete. + assert_eq!(run_with_prefix(&dir, &["-d", "testuser"]), 0); + let entry: ShadowEntry = read_shadow(&dir).trim().parse().unwrap(); + assert_eq!(entry.status_char(), "NP", "after delete"); + + // Expire. + assert_eq!(run_with_prefix(&dir, &["-e", "testuser"]), 0); + let entry: ShadowEntry = read_shadow(&dir).trim().parse().unwrap(); + assert_eq!(entry.last_change, Some(0), "after expire"); + } }