Merge pull request #116 from shadow-utils-rs/feat/114-usermod-password

usermod: add -p/--password flag (#114)
This commit is contained in:
Pierre Warnier
2026-04-03 14:01:11 +02:00
committed by GitHub
4 changed files with 158 additions and 31 deletions
+9
View File
@@ -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<Option<i64>, ShadowError> {
if field.is_empty() {
+58 -25
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,25 @@ 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);
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::<i64>().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::<i64>().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')
+87
View File
@@ -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");
}
+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: