usermod: add -p/--password flag for pre-hashed passwords (#114)

Ansible's user module calls `usermod -p <hash>` to set passwords.
This was the most significant gap for Ansible interop.

Implementation:
- Add PASSWORD option constant and clap argument (-p/--password)
- Write hash directly to shadow entry, update last_change to today
- Follows the same lock → read → mutate → atomic_write pattern

Tests:
- test_set_password: basic -p flag with hash verification
- test_set_password_long_flag: --password long form
- test_set_password_preserves_other_fields: all 9 shadow fields intact

Also updated e2e Ansible playbook to use native `user: password:`
parameter instead of the chpasswd -e workaround.

Fixes #114
This commit is contained in:
Pierre Warnier
2026-04-03 13:48:11 +02:00
parent d8e71819fe
commit 24347e9913
3 changed files with 116 additions and 7 deletions
+27 -1
View File
@@ -35,6 +35,7 @@ mod options {
pub const LOGIN: &str = "login";
pub const SHELL: &str = "shell";
pub const UID: &str = "uid";
pub const PASSWORD: &str = "password";
pub const ROOT: &str = "root";
pub const PREFIX: &str = "prefix";
pub const USER: &str = "USER";
@@ -168,10 +169,16 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
let do_unlock = matches.get_flag(options::UNLOCK);
let expire = matches.get_one::<String>(options::EXPIREDATE);
let inactive = matches.get_one::<i64>(options::INACTIVE);
let new_password = matches.get_one::<String>(options::PASSWORD);
let login_changing = new_login.is_some();
if shadow_path.exists()
&& (do_lock || do_unlock || expire.is_some() || inactive.is_some() || login_changing)
&& (do_lock
|| do_unlock
|| expire.is_some()
|| inactive.is_some()
|| new_password.is_some()
|| login_changing)
{
let slock = FileLock::acquire(&shadow_path)
.map_err(|e| UsermodError::CantUpdate(format!("cannot lock shadow: {e}")))?;
@@ -200,6 +207,10 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
if let Some(&i) = inactive {
s.inactive_days = if i < 0 { None } else { Some(i) };
}
if let Some(pw) = new_password {
s.passwd.clone_from(pw);
s.last_change = Some(days_since_epoch());
}
if let Some(new_name) = new_login {
s.name.clone_from(new_name);
}
@@ -287,6 +298,14 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
Ok(())
}
fn days_since_epoch() -> i64 {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
.unwrap_or(0);
now / 86400
}
/// Recursively chown all files and directories under `path` that are owned by
/// `old_uid` to `new_uid`. Files owned by other users are left untouched.
///
@@ -408,6 +427,13 @@ pub fn uu_app() -> Command {
.value_name("NEW_LOGIN")
.help("New login name"),
)
.arg(
Arg::new(options::PASSWORD)
.short('p')
.long("password")
.value_name("PASSWORD")
.help("New encrypted password (crypt(3) hash)"),
)
.arg(
Arg::new(options::SHELL)
.short('s')
+85
View File
@@ -369,3 +369,88 @@ fn test_uid_collision_fails() {
let code = run_with_prefix(&dir, &["-u", "1001", "bob"]);
assert_eq!(code, 4, "UID collision should exit 4");
}
#[test]
fn test_set_password() {
if skip_unless_root() {
return;
}
let dir = setup_prefix(
"testuser:x:1000:1000:Test:/home/testuser:/bin/bash\n",
"testuser:*:19500:0:99999:7:::\n",
"testuser:x:1000:\n",
);
let hash = "$6$rounds=5000$saltsalt$hashvalue";
let code = run_with_prefix(&dir, &["-p", hash, "testuser"]);
assert_eq!(code, 0, "setting password should succeed");
let content = read_shadow(&dir);
assert!(
content.contains(&format!("testuser:{hash}:")),
"shadow should contain the new hash, got: {content}"
);
// last_change should be updated (not 19500 anymore)
assert!(
!content.contains(":19500:"),
"last_change should be updated from 19500, got: {content}"
);
}
#[test]
fn test_set_password_long_flag() {
if skip_unless_root() {
return;
}
let dir = setup_prefix(
"testuser:x:1000:1000:Test:/home/testuser:/bin/bash\n",
"testuser:!:19500:0:99999:7:::\n",
"testuser:x:1000:\n",
);
let hash = "$6$newsalt$newhash";
let code = run_with_prefix(&dir, &["--password", hash, "testuser"]);
assert_eq!(code, 0, "--password long flag should succeed");
let content = read_shadow(&dir);
assert!(
content.contains(&format!("testuser:{hash}:")),
"shadow should contain the new hash, got: {content}"
);
}
#[test]
fn test_set_password_preserves_other_fields() {
if skip_unless_root() {
return;
}
let dir = setup_prefix(
"testuser:x:1000:1000:Test:/home/testuser:/bin/bash\n",
"testuser:$6$old:19500:1:90:14:30:20000:\n",
"testuser:x:1000:\n",
);
let hash = "$6$new$newhash";
let code = run_with_prefix(&dir, &["-p", hash, "testuser"]);
assert_eq!(code, 0);
let content = read_shadow(&dir);
// min_age=1, max_age=90, warn=14, inactive=30, expire=20000 should be preserved
let line = content
.lines()
.find(|l| l.starts_with("testuser:"))
.unwrap();
let fields: Vec<&str> = line.split(':').collect();
assert_eq!(fields[0], "testuser");
assert_eq!(fields[1], hash);
// fields[2] = last_change (updated, not 19500)
assert_ne!(fields[2], "19500", "last_change should be updated");
assert_eq!(fields[3], "1", "min_age should be preserved");
assert_eq!(fields[4], "90", "max_age should be preserved");
assert_eq!(fields[5], "14", "warn_days should be preserved");
assert_eq!(fields[6], "30", "inactive_days should be preserved");
assert_eq!(fields[7], "20000", "expire_date should be preserved");
}
+4 -6
View File
@@ -64,12 +64,10 @@
# ── Modify ──────────────────────────────────────────────────────
# Set password via chpasswd -e (usermod -p not yet implemented)
- name: Set user password via chpasswd
ansible.builtin.shell: |
echo "{{ test_user }}:$(openssl passwd -6 '{{ test_password }}')" | chpasswd -e
changed_when: true
no_log: true
- name: Set user password
ansible.builtin.user:
name: "{{ test_user }}"
password: "{{ test_password | password_hash('sha512') }}"
- name: Modify user shell
ansible.builtin.user: