passwd: implement -S, -l, -u, -d, -e, -n, -x, -w, -i, -P flags

Full drop-in replacement for GNU passwd administrative operations:
- Status display (-S, -Sa) with correct L/NP/P output format
- Lock (-l): prepend ! to shadow password hash
- Unlock (-u): remove leading ! with safety checks
- Delete (-d): set password to empty (passwordless account)
- Expire (-e): set last_change to 0 (force change at next login)
- Aging fields (-n/-x/-w/-i): set min/max/warn/inactive days
- Prefix (-P): operate on files under a prefix directory
- Correct exit codes (0-6) matching GNU passwd spec
- Mutual exclusion between conflicting flags via clap

shadow-core modules implemented:
- lock: file locking with .lock files, stale detection, PID tracking
- login_defs: /etc/login.defs KEY VALUE parser
- nscd: cache invalidation (nscd + sssd)
- sysroot: --prefix path resolver for testable file operations
- shadow: ShadowEntry helper methods (lock/unlock/delete/expire/status)

48 tests passing on Debian/Alpine/Fedora, zero clippy warnings.

Not yet implemented: PAM password change (default operation), --root
(chroot), --keep-tokens, --stdin, --repository.
This commit is contained in:
Pierre Warnier
2026-03-23 12:36:29 +01:00
parent 97130fea20
commit 7cc7c463cb
8 changed files with 1255 additions and 32 deletions
+1
View File
@@ -36,4 +36,5 @@ pub mod selinux;
pub mod atomic;
pub mod lock;
pub mod nscd;
pub mod sysroot;
pub mod uid_alloc;
+231
View File
@@ -2,8 +2,239 @@
//
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
// spell-checker:ignore lockfile
//! File locking for `/etc/passwd`, `/etc/shadow`, etc.
//!
//! Uses `.lock` files (e.g., `/etc/passwd.lock`) with timeout and stale
//! lock detection, matching the convention used by GNU shadow-utils.
//!
//! Lock files are created atomically with `O_CREAT | O_EXCL` and contain
//! the PID of the locking process for stale detection.
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::thread;
use std::time::{Duration, Instant};
use nix::fcntl::{self, OFlag};
use nix::sys::stat::Mode;
use nix::unistd;
use crate::error::ShadowError;
/// Default lock timeout (matches GNU shadow-utils `LOCK_TIMEOUT`).
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15);
/// Retry interval when waiting for a lock.
const RETRY_INTERVAL: Duration = Duration::from_millis(100);
/// A held file lock. The lock is released when this value is dropped.
pub struct FileLock {
lock_path: PathBuf,
released: bool,
}
impl FileLock {
/// Acquire a lock for the given file using the default timeout.
///
/// Creates `{file_path}.lock` atomically. If another process holds the lock,
/// retries until the timeout expires. Stale locks (held by dead processes)
/// are automatically cleaned up.
///
/// # Errors
///
/// Returns `ShadowError::Lock` if the lock cannot be acquired within the timeout.
pub fn acquire(file_path: &Path) -> Result<Self, ShadowError> {
Self::acquire_with_timeout(file_path, DEFAULT_TIMEOUT)
}
/// Acquire a lock with a custom timeout.
///
/// # Errors
///
/// Returns `ShadowError::Lock` if the lock cannot be acquired within the timeout.
pub fn acquire_with_timeout(file_path: &Path, timeout: Duration) -> Result<Self, ShadowError> {
let lock_path = lock_path_for(file_path);
let deadline = Instant::now() + timeout;
loop {
if try_create_lock(&lock_path).is_ok() {
return Ok(Self {
lock_path,
released: false,
});
}
// Lock file exists — check if it's stale.
if is_stale_lock(&lock_path) {
// Remove stale lock and retry immediately.
let _ = fs::remove_file(&lock_path);
continue;
}
if Instant::now() >= deadline {
return Err(ShadowError::Lock(format!(
"cannot acquire lock {}: timed out after {timeout:?}",
lock_path.display()
)));
}
thread::sleep(RETRY_INTERVAL);
}
}
/// Explicitly release the lock.
///
/// # Errors
///
/// Returns `ShadowError::Lock` if the lock file cannot be removed.
pub fn release(mut self) -> Result<(), ShadowError> {
self.released = true;
fs::remove_file(&self.lock_path).map_err(|e| {
ShadowError::Lock(format!(
"cannot release lock {}: {e}",
self.lock_path.display()
))
})
}
}
impl Drop for FileLock {
fn drop(&mut self) {
if !self.released {
let _ = fs::remove_file(&self.lock_path);
}
}
}
/// Compute the lock file path: append `.lock` to the file path.
fn lock_path_for(file_path: &Path) -> PathBuf {
let mut lock = file_path.as_os_str().to_owned();
lock.push(".lock");
PathBuf::from(lock)
}
/// Try to atomically create the lock file. Write our PID into it.
fn try_create_lock(lock_path: &Path) -> Result<(), ShadowError> {
// O_CREAT | O_EXCL ensures atomic creation — fails if the file exists.
let fd = fcntl::open(
lock_path,
OFlag::O_CREAT | OFlag::O_EXCL | OFlag::O_WRONLY,
Mode::from_bits_truncate(0o600),
)
.map_err(|e| ShadowError::Lock(format!("cannot create {}: {e}", lock_path.display())))?;
// Write our PID for stale detection.
// SAFETY: fd is a valid file descriptor we just created.
let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
let pid = unistd::getpid();
let _ = write!(file, "{pid}");
Ok(())
}
/// Check if an existing lock file is stale (held by a dead process).
fn is_stale_lock(lock_path: &Path) -> bool {
let Ok(contents) = fs::read_to_string(lock_path) else {
return false;
};
let Ok(pid) = contents.trim().parse::<i32>() else {
// Cannot parse PID — treat as stale.
return true;
};
if pid <= 0 {
return true;
}
// Signal 0 checks if the process exists without actually sending a signal.
let pid = nix::unistd::Pid::from_raw(pid);
nix::sys::signal::kill(pid, None).is_err()
}
use std::os::unix::io::FromRawFd;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_lock_path_for() {
assert_eq!(
lock_path_for(Path::new("/etc/shadow")),
PathBuf::from("/etc/shadow.lock")
);
}
#[test]
fn test_acquire_and_release() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test_file");
fs::write(&file, "data").unwrap();
let lock = FileLock::acquire(&file).unwrap();
assert!(lock.lock_path.exists());
lock.release().unwrap();
assert!(!dir.path().join("test_file.lock").exists());
}
#[test]
fn test_drop_releases_lock() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test_file");
fs::write(&file, "data").unwrap();
{
let _lock = FileLock::acquire(&file).unwrap();
assert!(dir.path().join("test_file.lock").exists());
}
// Lock should be released by drop.
assert!(!dir.path().join("test_file.lock").exists());
}
#[test]
fn test_double_lock_times_out() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test_file");
fs::write(&file, "data").unwrap();
let _lock1 = FileLock::acquire(&file).unwrap();
// Second lock should time out.
let result = FileLock::acquire_with_timeout(&file, Duration::from_millis(200));
assert!(result.is_err());
}
#[test]
fn test_stale_lock_cleanup() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test_file");
fs::write(&file, "data").unwrap();
// Create a lock file with a PID that doesn't exist.
let lock_path = dir.path().join("test_file.lock");
fs::write(&lock_path, "999999999").unwrap();
// Should succeed because the stale lock is cleaned up.
let lock = FileLock::acquire(&file).unwrap();
lock.release().unwrap();
}
#[test]
fn test_lock_file_contains_pid() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test_file");
fs::write(&file, "data").unwrap();
let lock = FileLock::acquire(&file).unwrap();
let contents = fs::read_to_string(&lock.lock_path).unwrap();
let pid: i32 = contents.trim().parse().unwrap();
assert_eq!(pid, i32::try_from(std::process::id()).unwrap());
lock.release().unwrap();
}
}
+133
View File
@@ -5,3 +5,136 @@
// spell-checker:ignore login_defs
//! Parser for `/etc/login.defs` configuration file.
//!
//! File format: `KEY VALUE` pairs, one per line. Lines starting with `#`
//! are comments. Blank lines are ignored. Keys are case-sensitive.
use std::collections::HashMap;
use std::io::BufRead;
use std::path::Path;
use crate::error::ShadowError;
/// Parsed `/etc/login.defs` configuration.
#[derive(Debug, Clone)]
pub struct LoginDefs {
entries: HashMap<String, String>,
}
impl LoginDefs {
/// Load and parse `/etc/login.defs` from the given path.
///
/// If the file does not exist, returns an empty `LoginDefs` (this is
/// intentional — missing `login.defs` is not an error, defaults apply).
///
/// # Errors
///
/// Returns `ShadowError` on I/O errors other than file-not-found.
pub fn load(path: &Path) -> Result<Self, ShadowError> {
let file = match std::fs::File::open(path) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(Self {
entries: HashMap::new(),
});
}
Err(e) => return Err(ShadowError::IoPath(e, path.to_owned())),
};
let reader = std::io::BufReader::new(file);
let mut entries = HashMap::new();
for line in reader.lines() {
let line = line?;
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
// Split on first whitespace: KEY VALUE
if let Some((key, value)) = trimmed.split_once(|c: char| c.is_whitespace()) {
entries.insert(key.to_string(), value.trim().to_string());
}
}
Ok(Self { entries })
}
/// Get a string value by key.
#[must_use]
pub fn get(&self, key: &str) -> Option<&str> {
self.entries.get(key).map(String::as_str)
}
/// Get a numeric value by key.
#[must_use]
pub fn get_i64(&self, key: &str) -> Option<i64> {
self.entries.get(key).and_then(|v| v.parse().ok())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::path::PathBuf;
fn write_login_defs(dir: &Path, content: &str) -> PathBuf {
let path = dir.join("login.defs");
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(content.as_bytes()).unwrap();
path
}
#[test]
fn test_parse_basic() {
let dir = tempfile::tempdir().unwrap();
let path = write_login_defs(
dir.path(),
"PASS_MAX_DAYS\t99999\nPASS_MIN_DAYS\t0\nPASS_WARN_AGE\t7\n",
);
let defs = LoginDefs::load(&path).unwrap();
assert_eq!(defs.get_i64("PASS_MAX_DAYS"), Some(99999));
assert_eq!(defs.get_i64("PASS_MIN_DAYS"), Some(0));
assert_eq!(defs.get_i64("PASS_WARN_AGE"), Some(7));
}
#[test]
fn test_comments_and_blanks() {
let dir = tempfile::tempdir().unwrap();
let path = write_login_defs(
dir.path(),
"# This is a comment\n\nPASS_MAX_DAYS 99999\n# Another comment\n",
);
let defs = LoginDefs::load(&path).unwrap();
assert_eq!(defs.get_i64("PASS_MAX_DAYS"), Some(99999));
assert_eq!(defs.get("# This is a comment"), None);
}
#[test]
fn test_string_values() {
let dir = tempfile::tempdir().unwrap();
let path = write_login_defs(
dir.path(),
"ENCRYPT_METHOD SHA512\nENV_PATH /bin:/usr/bin\n",
);
let defs = LoginDefs::load(&path).unwrap();
assert_eq!(defs.get("ENCRYPT_METHOD"), Some("SHA512"));
assert_eq!(defs.get("ENV_PATH"), Some("/bin:/usr/bin"));
}
#[test]
fn test_missing_file_returns_empty() {
let defs = LoginDefs::load(Path::new("/nonexistent/login.defs")).unwrap();
assert_eq!(defs.get("PASS_MAX_DAYS"), None);
}
#[test]
fn test_get_i64_invalid_returns_none() {
let dir = tempfile::tempdir().unwrap();
let path = write_login_defs(dir.path(), "ENCRYPT_METHOD SHA512\n");
let defs = LoginDefs::load(&path).unwrap();
assert_eq!(defs.get_i64("ENCRYPT_METHOD"), None);
}
}
+25 -3
View File
@@ -2,9 +2,31 @@
//
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
// spell-checker:ignore nscd
// spell-checker:ignore nscd sssd
//! nscd (Name Service Cache Daemon) cache invalidation.
//! `nscd` (Name Service Cache Daemon) cache invalidation.
//!
//! After modifying `/etc/passwd`, `/etc/shadow`, or `/etc/group`,
//! the nscd cache must be invalidated so lookups reflect the changes.
//! the `nscd` cache must be invalidated so lookups reflect the changes.
//! Also supports `sssd` cache invalidation.
use std::process::Command;
/// Invalidate the `nscd` and `sssd` caches for the given database.
///
/// The `database` should be one of `"passwd"`, `"shadow"`, or `"group"`.
///
/// Silently succeeds if `nscd`/`sssd` is not installed or not running —
/// this matches GNU shadow-utils behavior.
pub fn invalidate_cache(database: &str) {
// nscd -i <database>
let _ = Command::new("nscd").arg("-i").arg(database).status();
// sssd: sss_cache with the appropriate flag
let flag = match database {
"passwd" | "shadow" => "-U",
"group" => "-G",
_ => return,
};
let _ = Command::new("sss_cache").arg(flag).status();
}
+58
View File
@@ -43,6 +43,64 @@ pub struct ShadowEntry {
pub reserved: String,
}
impl ShadowEntry {
/// Whether the account password is locked (password starts with `!`).
#[must_use]
pub fn is_locked(&self) -> bool {
self.passwd.starts_with('!')
}
/// Whether the account has no password (empty password field).
#[must_use]
pub fn has_no_password(&self) -> bool {
self.passwd.is_empty()
}
/// Lock the password by prepending `!`.
pub fn lock(&mut self) {
self.passwd.insert(0, '!');
}
/// Unlock the password by removing the leading `!`.
///
/// Returns `false` if the password is not locked or would become empty
/// after unlocking (GNU passwd refuses this — use `delete` instead).
pub fn unlock(&mut self) -> bool {
if !self.is_locked() {
return false;
}
let after = &self.passwd[1..];
if after.is_empty() || after == "!" {
// Would result in empty or still-locked password.
return false;
}
self.passwd = after.to_string();
true
}
/// Delete the password (set to empty string, making the account passwordless).
pub fn delete_password(&mut self) {
self.passwd = String::new();
}
/// Expire the password (set `last_change` to 0, forcing change at next login).
pub fn expire(&mut self) {
self.last_change = Some(0);
}
/// Password status character for `passwd -S` output.
#[must_use]
pub fn status_char(&self) -> &'static str {
if self.is_locked() {
"L"
} else if self.has_no_password() {
"NP"
} else {
"P"
}
}
}
/// Parse an optional numeric field — empty string becomes `None`.
fn parse_optional_field(field: &str) -> Result<Option<i64>, ShadowError> {
if field.is_empty() {
+102
View File
@@ -0,0 +1,102 @@
// This file is part of the shadow-rs package.
//
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
// spell-checker:ignore chroot sysroot
//! System root path resolver for `--root` and `--prefix` support.
//!
//! `--prefix DIR` prepends DIR to all file paths (no `chroot` syscall).
//! `--root DIR` does an actual `chroot()` — paths are then relative to `/`.
use std::path::{Path, PathBuf};
/// Resolves file paths relative to an optional prefix directory.
#[derive(Debug, Clone)]
pub struct SysRoot {
prefix: PathBuf,
}
impl SysRoot {
/// Create a new `SysRoot` with the given prefix.
///
/// If `prefix` is `None`, paths resolve against the real root `/`.
#[must_use]
pub fn new(prefix: Option<&Path>) -> Self {
Self {
prefix: prefix.unwrap_or_else(|| Path::new("/")).to_owned(),
}
}
/// Resolve a path relative to the prefix.
///
/// Strips leading `/` from `relative` before joining with the prefix.
#[must_use]
pub fn resolve(&self, relative: &str) -> PathBuf {
let stripped = relative.strip_prefix('/').unwrap_or(relative);
self.prefix.join(stripped)
}
/// Path to `/etc/passwd`.
#[must_use]
pub fn passwd_path(&self) -> PathBuf {
self.resolve("/etc/passwd")
}
/// Path to `/etc/shadow`.
#[must_use]
pub fn shadow_path(&self) -> PathBuf {
self.resolve("/etc/shadow")
}
/// Path to `/etc/group`.
#[must_use]
pub fn group_path(&self) -> PathBuf {
self.resolve("/etc/group")
}
/// Path to `/etc/login.defs`.
#[must_use]
pub fn login_defs_path(&self) -> PathBuf {
self.resolve("/etc/login.defs")
}
}
impl Default for SysRoot {
fn default() -> Self {
Self::new(None)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_root() {
let root = SysRoot::default();
assert_eq!(root.shadow_path(), PathBuf::from("/etc/shadow"));
assert_eq!(root.passwd_path(), PathBuf::from("/etc/passwd"));
}
#[test]
fn test_with_prefix() {
let root = SysRoot::new(Some(Path::new("/tmp/test")));
assert_eq!(root.shadow_path(), PathBuf::from("/tmp/test/etc/shadow"));
assert_eq!(root.passwd_path(), PathBuf::from("/tmp/test/etc/passwd"));
assert_eq!(
root.login_defs_path(),
PathBuf::from("/tmp/test/etc/login.defs")
);
}
#[test]
fn test_resolve_strips_leading_slash() {
let root = SysRoot::new(Some(Path::new("/mnt")));
assert_eq!(
root.resolve("/etc/shadow"),
PathBuf::from("/mnt/etc/shadow")
);
assert_eq!(root.resolve("etc/shadow"), PathBuf::from("/mnt/etc/shadow"));
}
}
+6 -1
View File
@@ -16,8 +16,13 @@ path = "src/main.rs"
[dependencies]
clap = { workspace = true }
shadow-core = { workspace = true, features = ["shadow", "pam"] }
libc = { workspace = true }
nix = { workspace = true }
shadow-core = { workspace = true, features = ["shadow", "login-defs"] }
thiserror = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
[lints]
workspace = true
File diff suppressed because it is too large Load Diff