diff --git a/src/shadow-core/src/shadow.rs b/src/shadow-core/src/shadow.rs index 776021a..4db51b3 100644 --- a/src/shadow-core/src/shadow.rs +++ b/src/shadow-core/src/shadow.rs @@ -106,6 +106,15 @@ impl ShadowEntry { } } +/// Current date as days since Unix epoch, for `last_change` updates. +pub fn days_since_epoch() -> i64 { + let secs = 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); + secs / 86400 +} + /// Parse an optional numeric field — empty string becomes `None`. fn parse_optional_field(field: &str) -> Result, ShadowError> { if field.is_empty() { diff --git a/src/uu/usermod/src/usermod.rs b/src/uu/usermod/src/usermod.rs index ccff0d8..a58bdc1 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,25 @@ 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); + + if let Some(pw) = new_password + && pw.contains([':', '\n', '\r']) + { + return Err(UsermodError::CantUpdate( + "invalid password hash: must not contain ':', '\\n', or '\\r'".into(), + ) + .into()); + } 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}")))?; @@ -179,30 +195,40 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let mut se = shadow::read_shadow_file(&shadow_path) .map_err(|e| UsermodError::CantUpdate(format!("{e}")))?; - if let Some(s) = se.iter_mut().find(|e| e.name == *login) { - if do_lock { - s.lock(); - } - if do_unlock { - s.unlock(); - } - if let Some(exp) = expire { - s.expire_date = if exp == "-1" || exp.is_empty() { - None - } else { - Some(exp.parse::().map_err(|_| { - UsermodError::CantUpdate(format!( - "invalid expire date '{exp}' (expected days since epoch)" - )) - })?) - }; - } - if let Some(&i) = inactive { - s.inactive_days = if i < 0 { None } else { Some(i) }; - } - if let Some(new_name) = new_login { - s.name.clone_from(new_name); - } + let Some(s) = se.iter_mut().find(|e| e.name == *login) else { + drop(slock); + return Err(UsermodError::CantUpdate(format!( + "user '{login}' not found in shadow file" + )) + .into()); + }; + + if do_lock { + s.lock(); + } + if do_unlock { + s.unlock(); + } + if let Some(exp) = expire { + s.expire_date = if exp == "-1" || exp.is_empty() { + None + } else { + Some(exp.parse::().map_err(|_| { + UsermodError::CantUpdate(format!( + "invalid expire date '{exp}' (expected days since epoch)" + )) + })?) + }; + } + 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(shadow::days_since_epoch()); + } + if let Some(new_name) = new_login { + s.name.clone_from(new_name); } atomic::atomic_write(&shadow_path, |f| shadow::write_shadow(&se, f)) @@ -408,6 +434,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..02b7cad 100644 --- a/tests/by-util/test_usermod.rs +++ b/tests/by-util/test_usermod.rs @@ -369,3 +369,90 @@ 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"); + assert_eq!(fields.len(), 9, "shadow entry should have exactly 9 fields"); + assert_eq!(fields[8], "", "reserved field should be empty"); +} 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: