From 24347e99133b7eda06f5a1622a99cd42e6ca242a Mon Sep 17 00:00:00 2001 From: Pierre Warnier Date: Fri, 3 Apr 2026 13:48:11 +0200 Subject: [PATCH] usermod: add -p/--password flag for pre-hashed passwords (#114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ansible's user module calls `usermod -p ` 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 --- src/uu/usermod/src/usermod.rs | 28 +++++++++++- tests/by-util/test_usermod.rs | 85 +++++++++++++++++++++++++++++++++++ tests/e2e/ansible-test.yml | 10 ++--- 3 files changed, 116 insertions(+), 7 deletions(-) diff --git a/src/uu/usermod/src/usermod.rs b/src/uu/usermod/src/usermod.rs index ccff0d8..8fec016 100644 --- a/src/uu/usermod/src/usermod.rs +++ b/src/uu/usermod/src/usermod.rs @@ -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::(options::EXPIREDATE); let inactive = matches.get_one::(options::INACTIVE); + let new_password = matches.get_one::(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') diff --git a/tests/by-util/test_usermod.rs b/tests/by-util/test_usermod.rs index d011955..c66d4a3 100644 --- a/tests/by-util/test_usermod.rs +++ b/tests/by-util/test_usermod.rs @@ -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"); +} diff --git a/tests/e2e/ansible-test.yml b/tests/e2e/ansible-test.yml index e8573dd..c89dd58 100644 --- a/tests/e2e/ansible-test.yml +++ b/tests/e2e/ansible-test.yml @@ -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: