passwd: add PAM integration, --root, --quiet, comprehensive tests

shadow-core/pam: complete PAM FFI bindings (feature-gated):
- pam_start/pam_end/pam_authenticate/pam_acct_mgmt/pam_chauthtok
- Conversation function with terminal (echo disable) and stdin modes
- Safe PamContext wrapper with RAII cleanup
- All unsafe blocks have SAFETY comments

passwd tool — all 17 flags now implemented:
- Default password change via PAM (authenticate + chauthtok)
- --keep-tokens (-k): PAM_CHANGE_EXPIRED_AUTHTOK flag
- --stdin (-s): read password from stdin instead of /dev/tty
- --repository (-r): passed through to PAM
- --root (-R): chroot into directory before operations
- --quiet (-q): suppress action messages on stderr

shadow-core/shadow: 15 new ShadowEntry helper tests:
- is_locked, has_no_password, lock, unlock edge cases
  (double lock, unlock-only-bang, unlock-double-bang)
- delete_password, expire, status_char

passwd tool: 29 new tests (43 total in tool, 92 workspace-wide):
- Clap validation (all conflict groups, flag parsing, value requirements)
- Integration tests with --prefix (lock/unlock/delete/expire lifecycle,
  multi-user isolation, nonexistent user, missing file, quiet mode)
- Full lifecycle test: lock→L→unlock→P→delete→NP→expire

All 92 tests pass on Debian/Alpine/Fedora, zero clippy warnings.
This commit is contained in:
Pierre Warnier
2026-03-23 13:13:54 +01:00
parent 202acc16c9
commit 915f0ac5fb
5 changed files with 1639 additions and 130 deletions
+1 -1
View File
@@ -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
File diff suppressed because it is too large Load Diff
+254
View File
@@ -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");
}
}
+4
View File
@@ -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 }
File diff suppressed because it is too large Load Diff