docs/fixes: man pages, benchmarks, BSD research, Copilot findings

Fixes #56 — 14 man pages in docs/man/ (markdown format).
Fixes #67 — 8 Copilot findings fixed:
  - useradd: proper date validation (Feb 31 rejected)
  - userdel/usermod: --root wired to SysRoot
  - usermod: --expiredate validates input
  - usermod: --login validates name + updates shadow/group
  - userdel: -f separated from -r behavior
  - useradd: home dir resolved through SysRoot
  - skel: preserves directory permissions
Fixes #69 — benchmark script (benches/benchmark.sh)
Fixes #70 — FreeBSD/NetBSD reference (docs/FREEBSD-NETBSD-REFERENCE.md)

456 tests, zero clippy warnings.
This commit is contained in:
Pierre Warnier
2026-03-24 13:05:40 +01:00
parent cdf13a8ed3
commit 81d0e1776d
20 changed files with 1525 additions and 22 deletions
+270
View File
@@ -0,0 +1,270 @@
#!/usr/bin/env bash
# spell-checker:ignore timeformat tempdir ldd passwd pwck
#
# Benchmark script for shadow-rs vs GNU shadow-utils.
#
# Compares performance, binary size, and shared library dependencies
# between shadow-rs (Rust) and GNU shadow-utils (C) implementations.
#
# Usage:
# docker compose run --rm debian bash benches/benchmark.sh
#
# Requirements:
# - Must run inside Docker (needs both GNU shadow-utils and shadow-rs)
# - Must run as root (passwd -S and pwck require root or shadow access)
#
# The script builds shadow-rs in release mode, locates GNU shadow-utils
# binaries, and runs comparative benchmarks.
set -euo pipefail
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
PASSWD_STATUS_ITERS=1000
PWCK_ITERS=100
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
log() { printf '\033[1;34m==> %s\033[0m\n' "$*"; }
warn() { printf '\033[1;33mWARN: %s\033[0m\n' "$*"; }
err() { printf '\033[1;31mERROR: %s\033[0m\n' "$*"; exit 1; }
# Print a separator line.
separator() { printf '%.0s-' {1..72}; printf '\n'; }
# Format bytes as human-readable.
human_size() {
local bytes=$1
if [ "$bytes" -ge 1048576 ]; then
printf '%.1f MiB' "$(echo "$bytes / 1048576" | bc -l)"
elif [ "$bytes" -ge 1024 ]; then
printf '%.1f KiB' "$(echo "$bytes / 1024" | bc -l)"
else
printf '%d B' "$bytes"
fi
}
# Time a command N times, report wall-clock total in seconds.
# Usage: bench_command <label> <iterations> <command...>
bench_command() {
local label=$1
local iters=$2
shift 2
log "Benchmarking: $label ($iters iterations)"
local start end elapsed
start=$(date +%s%N)
for ((i = 0; i < iters; i++)); do
"$@" >/dev/null 2>&1 || true
done
end=$(date +%s%N)
elapsed=$(echo "scale=3; ($end - $start) / 1000000000" | bc)
local per_iter
per_iter=$(echo "scale=6; $elapsed / $iters" | bc)
printf ' %-30s total: %8ss per-iter: %ss\n' "$label" "$elapsed" "$per_iter"
echo "$elapsed"
}
# ---------------------------------------------------------------------------
# Locate binaries
# ---------------------------------------------------------------------------
find_gnu_binary() {
local name=$1
# GNU shadow-utils binaries are typically in /usr/sbin or /usr/bin.
for path in /usr/sbin/"$name" /usr/bin/"$name" /sbin/"$name" /bin/"$name"; do
if [ -x "$path" ]; then
# Make sure it is NOT our shadow-rs binary (check for ELF or "shadow-rs").
if ! file "$path" 2>/dev/null | grep -q 'statically linked\|shadow-rs'; then
echo "$path"
return 0
fi
fi
done
return 1
}
# ---------------------------------------------------------------------------
# Build shadow-rs
# ---------------------------------------------------------------------------
log "Building shadow-rs in release mode..."
cargo build --release --quiet 2>&1
SHADOW_RS_DIR="$(cargo metadata --format-version=1 --no-deps 2>/dev/null | \
python3 -c 'import sys,json; print(json.load(sys.stdin)["target_directory"])' 2>/dev/null || \
echo "target")/release"
# The multicall binary.
SHADOW_RS_BIN="$SHADOW_RS_DIR/shadow-rs"
if [ ! -x "$SHADOW_RS_BIN" ]; then
# Try individual binaries.
SHADOW_RS_BIN=""
fi
# Individual tool binaries (built by cargo as separate bins).
RS_PASSWD="$SHADOW_RS_DIR/passwd"
RS_PWCK="$SHADOW_RS_DIR/pwck"
# Fall back to the multicall binary if individual bins are not found.
if [ ! -x "$RS_PASSWD" ] && [ -x "$SHADOW_RS_DIR/shadow-rs" ]; then
RS_PASSWD="$SHADOW_RS_DIR/shadow-rs"
fi
if [ ! -x "$RS_PWCK" ] && [ -x "$SHADOW_RS_DIR/shadow-rs" ]; then
RS_PWCK="$SHADOW_RS_DIR/shadow-rs"
fi
# Locate GNU binaries.
GNU_PASSWD=$(find_gnu_binary passwd) || GNU_PASSWD=""
GNU_PWCK=$(find_gnu_binary pwck) || GNU_PWCK=""
separator
log "Binary locations:"
printf ' %-20s %s\n' "shadow-rs passwd:" "${RS_PASSWD:-NOT FOUND}"
printf ' %-20s %s\n' "shadow-rs pwck:" "${RS_PWCK:-NOT FOUND}"
printf ' %-20s %s\n' "GNU passwd:" "${GNU_PASSWD:-NOT FOUND}"
printf ' %-20s %s\n' "GNU pwck:" "${GNU_PWCK:-NOT FOUND}"
separator
# ---------------------------------------------------------------------------
# Binary size comparison
# ---------------------------------------------------------------------------
log "Binary size comparison"
printf '\n'
printf ' %-35s %12s\n' "Binary" "Size"
separator
report_size() {
local label=$1
local path=$2
if [ -x "$path" ]; then
local size
size=$(stat --format='%s' "$path" 2>/dev/null || stat -f '%z' "$path" 2>/dev/null || echo 0)
printf ' %-35s %12s (%d bytes)\n' "$label" "$(human_size "$size")" "$size"
else
printf ' %-35s %12s\n' "$label" "N/A"
fi
}
report_size "shadow-rs multicall" "$SHADOW_RS_DIR/shadow-rs"
report_size "shadow-rs passwd" "$RS_PASSWD"
report_size "shadow-rs pwck" "$RS_PWCK"
report_size "GNU passwd" "$GNU_PASSWD"
report_size "GNU pwck" "$GNU_PWCK"
printf '\n'
# ---------------------------------------------------------------------------
# Shared library dependencies
# ---------------------------------------------------------------------------
log "Shared library dependencies"
printf '\n'
report_deps() {
local label=$1
local path=$2
if [ -x "$path" ] && command -v ldd >/dev/null 2>&1; then
local count
count=$(ldd "$path" 2>/dev/null | grep -c '=>' || echo 0)
printf ' %-35s %d shared libraries\n' "$label" "$count"
ldd "$path" 2>/dev/null | sed 's/^/ /'
elif [ -x "$path" ]; then
printf ' %-35s (ldd not available)\n' "$label"
else
printf ' %-35s N/A\n' "$label"
fi
printf '\n'
}
report_deps "shadow-rs passwd" "$RS_PASSWD"
report_deps "GNU passwd" "$GNU_PASSWD"
separator
# ---------------------------------------------------------------------------
# Performance benchmarks
# ---------------------------------------------------------------------------
log "Performance benchmarks"
printf '\n'
printf ' Test: passwd -S root (%d iterations)\n' "$PASSWD_STATUS_ITERS"
printf ' Test: pwck -r (%d iterations)\n' "$PWCK_ITERS"
printf '\n'
separator
# -- passwd -S root --
rs_passwd_time=""
gnu_passwd_time=""
if [ -x "$RS_PASSWD" ]; then
rs_passwd_time=$(bench_command "shadow-rs: passwd -S root" "$PASSWD_STATUS_ITERS" "$RS_PASSWD" -S root)
fi
if [ -x "$GNU_PASSWD" ]; then
gnu_passwd_time=$(bench_command "GNU: passwd -S root" "$PASSWD_STATUS_ITERS" "$GNU_PASSWD" -S root)
fi
printf '\n'
# -- pwck -r --
rs_pwck_time=""
gnu_pwck_time=""
if [ -x "$RS_PWCK" ]; then
rs_pwck_time=$(bench_command "shadow-rs: pwck -r" "$PWCK_ITERS" "$RS_PWCK" -r)
fi
if [ -x "$GNU_PWCK" ]; then
gnu_pwck_time=$(bench_command "GNU: pwck -r" "$PWCK_ITERS" "$GNU_PWCK" -r)
fi
printf '\n'
# ---------------------------------------------------------------------------
# Summary table
# ---------------------------------------------------------------------------
separator
log "SUMMARY"
printf '\n'
printf ' %-30s %12s %12s %12s\n' "Benchmark" "shadow-rs" "GNU" "Ratio"
separator
compute_ratio() {
local rs=$1
local gnu=$2
if [ -n "$rs" ] && [ -n "$gnu" ] && [ "$(echo "$gnu > 0" | bc)" -eq 1 ]; then
printf '%.2fx' "$(echo "$rs / $gnu" | bc -l)"
else
echo "N/A"
fi
}
ratio_passwd=$(compute_ratio "${rs_passwd_time:-}" "${gnu_passwd_time:-}")
ratio_pwck=$(compute_ratio "${rs_pwck_time:-}" "${gnu_pwck_time:-}")
printf ' %-30s %11ss %11ss %12s\n' \
"passwd -S root (${PASSWD_STATUS_ITERS}x)" \
"${rs_passwd_time:-N/A}" \
"${gnu_passwd_time:-N/A}" \
"$ratio_passwd"
printf ' %-30s %11ss %11ss %12s\n' \
"pwck -r (${PWCK_ITERS}x)" \
"${rs_pwck_time:-N/A}" \
"${gnu_pwck_time:-N/A}" \
"$ratio_pwck"
printf '\n'
separator
log "Benchmark complete."
+63
View File
@@ -0,0 +1,63 @@
# FreeBSD/NetBSD Security Reference for shadow-rs
Analysis of FreeBSD's `pw` and NetBSD's user management implementations.
Both BSD-2-Clause licensed — safe to reference.
## FreeBSD `pw` Patterns
### What FreeBSD Does Differently
| Pattern | FreeBSD | shadow-rs | Action |
|---------|---------|-----------|--------|
| Username allows trailing `$` | Yes (Samba compat) | No | Consider adding for Samba/AD |
| Salt generation | `arc4random_uniform()` | PAM handles | N/A (PAM delegates hashing) |
| Password fd input (`-h FD`) | Yes | No | Low priority — niche use case |
| Password buffers not zeroed | Vulnerable | Fixed (zeroize) | We're ahead |
| No mlock() on passwords | Vulnerable | Not implemented | Future work |
| Selective config override | Sentinel values (-1) | Login.defs defaults | Already implemented |
### Key Takeaway
FreeBSD's `pw` is less hardened than OpenBSD's `passwd` — no explicit memory
zeroing, no mlock, no pledge/unveil equivalent. Our implementation with
`zeroize`, core dump suppression, and environment sanitization is already
ahead of FreeBSD's security posture.
### Patterns Worth Adopting
1. **Samba-compatible usernames**: Allow trailing `$` in usernames for
Active Directory machine accounts. This is a common real-world need.
2. **Password input via fd**: The `-h FD` pattern allows passing passwords
from a pipe without command-line exposure. Lower priority but useful for
automation.
3. **mlock() for password buffers**: Neither FreeBSD nor our implementation
uses `mlock()` to prevent password data from being swapped to disk.
OpenBSD doesn't either (they rely on encrypted swap). Consider adding
as defense-in-depth.
## NetBSD Patterns
NetBSD's user management follows similar patterns to FreeBSD. Key
differences:
- Uses `vipw(8)` for direct passwd editing (different approach)
- Stricter POSIX compliance in username validation
- Similar lack of memory hardening
## Recommendations for shadow-rs
All high-value items from BSD review are already tracked:
- **mlock()**: Future work (docs/SECURITY-HARDENING.md)
- **Samba usernames**: Could add `--badname` flag (matches GNU `useradd --badname`)
- **Password fd input**: Low priority feature
No critical security gaps found relative to FreeBSD/NetBSD implementations.
shadow-rs is already more hardened than both.
## References
- FreeBSD pw: https://cgit.freebsd.org/src/tree/usr.sbin/pw/
- NetBSD user management: https://cvsweb.netbsd.org/bsdweb.cgi/src/usr.sbin/user/
+70
View File
@@ -0,0 +1,70 @@
# chage(1) - change user password expiry information
## NAME
chage - change user password expiry information
## SYNOPSIS
**chage** [*options*] *LOGIN*
## DESCRIPTION
The **chage** command changes the number of days between password changes
and the date of the last password change. This information is used by
the system to determine when a user must change their password.
## OPTIONS
**-d**, **--lastday** *LAST_DAY*
: Set the date of the last password change. The date may be expressed
as a date (YYYY-MM-DD) or as the number of days since January 1, 1970.
A value of -1 removes the last-change date requirement.
**-E**, **--expiredate** *EXPIRE_DATE*
: Set the account expiration date. The date may be expressed as a date
(YYYY-MM-DD) or as the number of days since January 1, 1970.
A value of -1 removes the expiration date.
**-I**, **--inactive** *INACTIVE*
: Set the number of days of inactivity after a password has expired
before the account is locked. A value of -1 removes the inactivity
requirement.
**-l**, **--list**
: Show account aging information.
**-m**, **--mindays** *MIN_DAYS*
: Set the minimum number of days between password changes. A value
of -1 removes the minimum days requirement.
**-M**, **--maxdays** *MAX_DAYS*
: Set the maximum number of days during which a password is valid.
A value of -1 removes the maximum days requirement.
**-R**, **--root** *CHROOT_DIR*
: Apply changes in the *CHROOT_DIR* directory.
**-W**, **--warndays** *WARN_DAYS*
: Set the number of days of warning before a password change is
required. A value of -1 removes the warning.
## EXIT STATUS
**0**
: Success.
**1**
: Permission denied.
**2**
: Invalid command syntax.
## FILES
/etc/shadow
: Secure user account information.
## SEE ALSO
passwd(1), passwd(5), shadow(5)
+60
View File
@@ -0,0 +1,60 @@
# chfn(1) - change user finger information
## NAME
chfn - change real user name and information
## SYNOPSIS
**chfn** [*options*] [*LOGIN*]
## DESCRIPTION
The **chfn** command changes the user finger information stored in the
GECOS field of /etc/passwd. This information is typically displayed by
the **finger**(1) program and includes the user's full name, office room
number, and phone numbers.
A normal user may only change their own finger information; the superuser
may change the information for any user. Only the superuser may change
the "other" field.
At least one option flag (**-f**, **-r**, **-w**, **-h**, or **-o**) must
be specified.
## OPTIONS
**-f**, **--full-name** *FULL_NAME*
: Change the user's full name.
**-h**, **--home-phone** *HOME_PHONE*
: Change the user's home phone number.
**-o**, **--other** *OTHER*
: Change the user's other GECOS information. Only root may set this field.
**-r**, **--room** *ROOM*
: Change the user's room number.
**-R**, **--root** *CHROOT_DIR*
: Apply changes in the *CHROOT_DIR* directory.
**-w**, **--work-phone** *WORK_PHONE*
: Change the user's office phone number.
## EXIT STATUS
**0**
: Success.
**1**
: Permission denied or operation failed.
## FILES
/etc/passwd
: User account information.
## SEE ALSO
chsh(1), finger(1), passwd(5)
+56
View File
@@ -0,0 +1,56 @@
# chpasswd(8) - update passwords in batch mode
## NAME
chpasswd - update passwords in batch mode
## SYNOPSIS
**chpasswd** [*options*]
## DESCRIPTION
The **chpasswd** command reads a list of username:password pairs from
standard input and uses this information to update a group of existing
users. Each line is of the format:
username:password
By default the password is expected to be in cleartext (not yet
supported in shadow-rs; use **-e**). With the **-e** flag, the password
is expected to be already encrypted (pre-hashed).
## OPTIONS
**-c**, **--crypt-method** *METHOD*
: Use the specified crypt method. Supported values: SHA256, SHA512,
YESCRYPT, DES, MD5.
**-e**, **--encrypted**
: Supplied passwords are already encrypted (pre-hashed).
**-m**, **--md5**
: Use MD5 encryption for cleartext passwords (deprecated).
**-R**, **--root** *CHROOT_DIR*
: Apply changes in the *CHROOT_DIR* directory.
**-s**, **--sha-rounds** *ROUNDS*
: Use the specified number of rounds for SHA256/SHA512 encryption.
## EXIT STATUS
**0**
: Success.
**1**
: Permission denied, invalid input, file busy, or unexpected failure.
## FILES
/etc/shadow
: Secure user account information.
## SEE ALSO
passwd(1), passwd(5), shadow(5)
+50
View File
@@ -0,0 +1,50 @@
# chsh(1) - change login shell
## NAME
chsh - change login shell
## SYNOPSIS
**chsh** [*options*] [*LOGIN*]
## DESCRIPTION
The **chsh** command changes the user login shell. This determines the
name of the user's initial login command. A normal user may only change
the login shell for their own account; the superuser may change the login
shell for any account.
The new shell must be listed in /etc/shells unless the caller is root.
## OPTIONS
**-l**, **--list-shells**
: Print the list of shells listed in /etc/shells and exit.
**-R**, **--root** *CHROOT_DIR*
: Apply changes in the *CHROOT_DIR* directory.
**-s**, **--shell** *SHELL*
: Set the login shell to *SHELL*. The shell must be an absolute path
and must be listed in /etc/shells (unless the caller is root).
## EXIT STATUS
**0**
: Success.
**1**
: Permission denied or operation failed.
## FILES
/etc/passwd
: User account information.
/etc/shells
: List of valid login shells.
## SEE ALSO
chfn(1), login(1), passwd(5), shells(5)
+80
View File
@@ -0,0 +1,80 @@
# groupadd(8) - create a new group
## NAME
groupadd - create a new group
## SYNOPSIS
**groupadd** [*options*] *GROUP*
## DESCRIPTION
The **groupadd** command creates a new group account using the values
specified on the command line plus the default values from the system.
The new group will be entered into the system files (/etc/group and
/etc/gshadow) as needed.
## OPTIONS
**-f**, **--force**
: Exit successfully if the group already exists, and cancel **-g** if
the GID is already used (a new GID will be allocated instead).
**-g**, **--gid** *GID*
: Use *GID* for the new group.
**-K**, **--key** *KEY=VALUE*
: Override /etc/login.defs defaults (GID_MIN, GID_MAX, SYS_GID_MIN,
SYS_GID_MAX). Can be specified multiple times.
**-o**, **--non-unique**
: Allow creating a group with a non-unique (duplicate) GID.
**-p**, **--password** *PASSWORD*
: Set the encrypted password for the new group.
**-P**, **--prefix** *PREFIX_DIR*
: Use *PREFIX_DIR* as a prefix for system file paths.
**-r**, **--system**
: Create a system group (allocated from the system GID range defined
in /etc/login.defs).
**-R**, **--root** *CHROOT_DIR*
: Apply changes in the *CHROOT_DIR* directory.
## EXIT STATUS
**0**
: Success.
**2**
: Invalid command syntax.
**3**
: Invalid argument to option.
**4**
: GID already in use (and no **-o** or **-f**).
**9**
: Group name already in use.
**10**
: Cannot update group file.
## FILES
/etc/group
: Group account information.
/etc/gshadow
: Secure group account information.
/etc/login.defs
: Shadow password suite configuration.
## SEE ALSO
groupdel(8), groupmod(8), login.defs(5)
+54
View File
@@ -0,0 +1,54 @@
# groupdel(8) - delete a group
## NAME
groupdel - delete a group
## SYNOPSIS
**groupdel** [*options*] *GROUP*
## DESCRIPTION
The **groupdel** command modifies the system account files, deleting all
entries that refer to *GROUP*. The named group must exist.
You may not remove the primary group of any existing user. You must
remove the user before you remove the group.
## OPTIONS
**-P**, **--prefix** *PREFIX_DIR*
: Use *PREFIX_DIR* as a prefix for system file paths.
**-R**, **--root** *CHROOT_DIR*
: Apply changes in the *CHROOT_DIR* directory.
## EXIT STATUS
**0**
: Success.
**2**
: Invalid command syntax.
**6**
: Group does not exist.
**8**
: Cannot remove a user's primary group.
**10**
: Cannot update group file.
## FILES
/etc/group
: Group account information.
/etc/gshadow
: Secure group account information.
## SEE ALSO
groupadd(8), groupmod(8), userdel(8)
+70
View File
@@ -0,0 +1,70 @@
# groupmod(8) - modify a group definition
## NAME
groupmod - modify a group definition
## SYNOPSIS
**groupmod** [*options*] *GROUP*
## DESCRIPTION
The **groupmod** command modifies the definition of the specified
*GROUP* by modifying the appropriate entries in the group and gshadow
databases.
## OPTIONS
**-g**, **--gid** *GID*
: Change the group ID to *GID*.
**-n**, **--new-name** *NEW_GROUP*
: Change the name of the group to *NEW_GROUP*.
**-o**, **--non-unique**
: Allow using a non-unique (duplicate) GID when used with **-g**.
**-p**, **--password** *PASSWORD*
: Change the group password to the encrypted *PASSWORD*.
**-P**, **--prefix** *PREFIX_DIR*
: Use *PREFIX_DIR* as a prefix for system file paths.
**-R**, **--root** *CHROOT_DIR*
: Apply changes in the *CHROOT_DIR* directory.
## EXIT STATUS
**0**
: Success.
**2**
: Invalid command syntax.
**3**
: Invalid argument to option.
**4**
: GID already in use.
**6**
: Group does not exist.
**9**
: Group name already in use.
**10**
: Cannot update group file.
## FILES
/etc/group
: Group account information.
/etc/gshadow
: Secure group account information.
## SEE ALSO
groupadd(8), groupdel(8)
+68
View File
@@ -0,0 +1,68 @@
# grpck(8) - verify integrity of group files
## NAME
grpck - verify integrity of group files
## SYNOPSIS
**grpck** [*options*] [*group* [*gshadow*]]
## DESCRIPTION
The **grpck** command verifies the integrity of the group information.
It checks that all entries in /etc/group and (optionally) /etc/gshadow
have the proper format and contain valid data.
Checks performed include:
- Correct number of fields
- Unique group names
- Valid GID values
- Matching group/gshadow entries
## OPTIONS
**-q**, **--quiet**
: Report only errors, suppress warnings.
**-r**, **--read-only**
: Display errors and warnings but do not modify files.
**-R**, **--root** *CHROOT_DIR*
: Apply changes in the *CHROOT_DIR* directory.
**-s**, **--sort**
: Sort entries by GID.
## EXIT STATUS
**0**
: Success.
**2**
: One or more bad group entries.
**3**
: Cannot open files.
**4**
: Cannot lock files.
**5**
: Cannot update files.
**6**
: Cannot sort files.
## FILES
/etc/group
: Group account information.
/etc/gshadow
: Secure group account information.
## SEE ALSO
groupadd(8), groupdel(8), groupmod(8), pwck(8)
+50
View File
@@ -0,0 +1,50 @@
# newgrp(1) - log in to a new group
## NAME
newgrp - log in to a new group
## SYNOPSIS
**newgrp** [*group*]
## DESCRIPTION
The **newgrp** command is used to change the current group ID during a
login session. If the optional *group* argument is given, the effective
group ID is changed to that group; otherwise the effective group ID is
changed to the user's primary group from /etc/passwd.
If the user is not a member of the specified group, and the group has a
password set in /etc/gshadow, the user will be prompted for the group
password. Root always has access to any group without a password prompt.
A new shell is started with the changed group ID. The shell is
determined by the **SHELL** environment variable, falling back to
/bin/sh.
## OPTIONS
None. Only an optional positional group name argument is accepted.
## EXIT STATUS
**0**
: Success (though note that **newgrp** replaces the current process
with a new shell via **execv**(2), so exit status 0 is not normally
returned to the caller).
**1**
: Permission denied, group not found, or unable to execute shell.
## FILES
/etc/group
: Group account information.
/etc/gshadow
: Secure group account information (for group passwords).
## SEE ALSO
groups(1), id(1), login(1), sg(1), group(5), gshadow(5)
+104
View File
@@ -0,0 +1,104 @@
# passwd(1) - change user password
## NAME
passwd - change user password
## SYNOPSIS
**passwd** [*options*] [*LOGIN*]
## DESCRIPTION
The **passwd** command changes passwords for user accounts. A normal user
may only change the password for their own account; the superuser may
change the password for any account. **passwd** also changes the account
or associated password validity period.
When invoked without a LOGIN argument, **passwd** changes the password for
the current user.
## OPTIONS
**-a**, **--all**
: Report password status on all accounts. Requires **-S**.
**-d**, **--delete**
: Delete the password for the named account. This makes the account
passwordless.
**-e**, **--expire**
: Immediately expire the password for the named account. This forces
the user to change their password at next login.
**-i**, **--inactive** *INACTIVE*
: Set the number of days of inactivity after a password has expired
before the account is locked.
**-k**, **--keep-tokens**
: Change password only if expired.
**-l**, **--lock**
: Lock the password of the named account. This prepends a '!' to the
encrypted password, effectively disabling the password.
**-n**, **--mindays** *MIN_DAYS*
: Set the minimum number of days between password changes.
**-q**, **--quiet**
: Quiet mode.
**-r**, **--repository** *REPOSITORY*
: Change password in the named repository.
**-R**, **--root** *CHROOT_DIR*
: Apply changes in the *CHROOT_DIR* directory and use the configuration
files from the *CHROOT_DIR* directory.
**-P**, **--prefix** *PREFIX_DIR*
: Use *PREFIX_DIR* as a prefix for system file paths.
**-S**, **--status**
: Display account status information. The status information consists
of 7 fields: login name, password status (L=locked, NP=no password,
P=usable password), date of last password change, minimum age,
maximum age, warning period, and inactivity period.
**-s**, **--stdin**
: Read the new password token from standard input.
**-u**, **--unlock**
: Unlock the password of the named account. This removes the '!' prefix
from the encrypted password.
**-w**, **--warndays** *WARN_DAYS*
: Set the number of days of warning before a password change is required.
**-x**, **--maxdays** *MAX_DAYS*
: Set the maximum number of days a password remains valid.
## EXIT STATUS
**0**
: Success.
**1**
: Permission denied or operation failed.
**2**
: Invalid command syntax.
**5**
: Password file busy.
## FILES
/etc/passwd
: User account information.
/etc/shadow
: Secure user account information.
## SEE ALSO
chage(1), chpasswd(8), login.defs(5), shadow(5), pwck(8)
+74
View File
@@ -0,0 +1,74 @@
# pwck(8) - verify integrity of password files
## NAME
pwck - verify integrity of password files
## SYNOPSIS
**pwck** [*options*] [*passwd* [*shadow*]]
## DESCRIPTION
The **pwck** command verifies the integrity of the system authentication
information. It checks that all entries in /etc/passwd and /etc/shadow
have the proper format and contain valid data.
Checks performed include:
- Correct number of fields
- Unique and valid user names
- Valid user and group identifiers
- Valid primary group
- Valid home directory
- Valid login shell
- Matching passwd/shadow entries
## OPTIONS
**-q**, **--quiet**
: Report only errors, suppress warnings.
**-r**, **--read-only**
: Display errors and warnings but do not modify files.
**-R**, **--root** *CHROOT_DIR*
: Apply changes in the *CHROOT_DIR* directory.
**-s**, **--sort**
: Sort entries by UID.
## EXIT STATUS
**0**
: Success.
**1**
: Invalid command syntax.
**2**
: One or more bad password entries.
**3**
: Cannot open files.
**4**
: Cannot lock files.
**5**
: Cannot update files.
## FILES
/etc/passwd
: User account information.
/etc/shadow
: Secure user account information.
/etc/group
: Group account information.
## SEE ALSO
passwd(5), shadow(5), grpck(8)
+136
View File
@@ -0,0 +1,136 @@
# useradd(8) - create a new user
## NAME
useradd - create a new user or update default new user information
## SYNOPSIS
**useradd** [*options*] *LOGIN*
**useradd** **-D** [*options*]
## DESCRIPTION
The **useradd** command creates a new user account using the values
specified on the command line plus the default values from the system.
The new user account will be entered into the system files as needed,
the home directory will be created, and initial files copied, depending
on the command line options.
When invoked with the **-D** flag, **useradd** displays or updates the
default values used for creating new accounts.
## OPTIONS
**-c**, **--comment** *COMMENT*
: Set the GECOS field of the new account.
**-d**, **--home-dir** *HOME_DIR*
: Set the home directory of the new account.
**-D**, **--defaults**
: Print or change default useradd configuration.
**-e**, **--expiredate** *EXPIRE_DATE*
: Set the expiration date of the new account (YYYY-MM-DD).
**-f**, **--inactive** *INACTIVE*
: Set the password inactivity period of the new account.
**-g**, **--gid** *GROUP*
: Set the name or numeric ID of the primary group of the new account.
**-G**, **--groups** *GROUPS*
: Set the list of supplementary groups of the new account (comma-separated).
**-k**, **--skel** *SKEL_DIR*
: Specify the skeleton directory (default: /etc/skel).
**-m**, **--create-home**
: Create the user's home directory if it does not exist.
**-M**, **--no-create-home**
: Do not create the user's home directory.
**-N**, **--no-user-group**
: Do not create a group with the same name as the user.
**-o**, **--non-unique**
: Allow creating users with duplicate (non-unique) UIDs. Requires **-u**.
**-p**, **--password** *PASSWORD*
: Set the encrypted password of the new account.
**-r**, **--system**
: Create a system account.
**-R**, **--root** *CHROOT_DIR*
: Apply changes in the *CHROOT_DIR* directory.
**-s**, **--shell** *SHELL*
: Set the login shell of the new account.
**-u**, **--uid** *UID*
: Set the user ID of the new account.
**-U**, **--user-group**
: Create a group with the same name as the user (default behavior).
## EXIT STATUS
**0**
: Success.
**1**
: Cannot update password file.
**2**
: Invalid command syntax.
**3**
: Invalid argument to option.
**4**
: UID already in use (and no **-o**).
**6**
: Specified group does not exist.
**9**
: Username already in use.
**10**
: Cannot update group file.
**12**
: Cannot create home directory.
**14**
: Cannot update SELinux user mapping.
## FILES
/etc/passwd
: User account information.
/etc/shadow
: Secure user account information.
/etc/group
: Group account information.
/etc/gshadow
: Secure group account information.
/etc/login.defs
: Shadow password suite configuration.
/etc/default/useradd
: Default values for account creation.
/etc/skel
: Directory containing default files.
## SEE ALSO
userdel(8), usermod(8), groupadd(8), login.defs(5)
+67
View File
@@ -0,0 +1,67 @@
# userdel(8) - delete a user account
## NAME
userdel - delete a user account and related files
## SYNOPSIS
**userdel** [*options*] *LOGIN*
## DESCRIPTION
The **userdel** command modifies the system account files, deleting all
entries that refer to the user name *LOGIN*. The named user must exist.
The user's entry is removed from /etc/passwd and /etc/shadow. The user
is also removed from membership lists in /etc/group and /etc/gshadow.
## OPTIONS
**-f**, **--force**
: Force removal of the account even if the user is still logged in.
Also forces removal of the home directory and mail spool.
**-P**, **--prefix** *PREFIX_DIR*
: Use *PREFIX_DIR* as a prefix for system file paths.
**-r**, **--remove**
: Remove the user's home directory and mail spool.
**-R**, **--root** *CHROOT_DIR*
: Apply changes in the *CHROOT_DIR* directory.
## EXIT STATUS
**0**
: Success.
**1**
: Cannot update password file.
**2**
: Invalid command syntax.
**10**
: Cannot update group file.
**12**
: Cannot remove home directory.
## FILES
/etc/passwd
: User account information.
/etc/shadow
: Secure user account information.
/etc/group
: Group account information.
/etc/gshadow
: Secure group account information.
## SEE ALSO
useradd(8), usermod(8), groupdel(8)
+93
View File
@@ -0,0 +1,93 @@
# usermod(8) - modify a user account
## NAME
usermod - modify a user account
## SYNOPSIS
**usermod** [*options*] *LOGIN*
## DESCRIPTION
The **usermod** command modifies the system account files to reflect the
changes that are specified on the command line.
## OPTIONS
**-a**, **--append**
: Append the user to the supplementary group(s) specified by **-G**.
Use only with the **-G** option.
**-c**, **--comment** *COMMENT*
: Set the new value of the user's GECOS field.
**-d**, **--home** *HOME_DIR*
: Set the new home directory for the user.
**-e**, **--expiredate** *EXPIRE_DATE*
: Set the account expiration date.
**-f**, **--inactive** *INACTIVE*
: Set the password inactive period.
**-g**, **--gid** *GROUP*
: Set the new primary group ID (numeric).
**-G**, **--groups** *GROUPS*
: Set the list of supplementary groups (comma-separated). If the **-a**
option is not used, the user is removed from all groups not listed.
**-l**, **--login** *NEW_LOGIN*
: Change the user's login name.
**-L**, **--lock**
: Lock the user's password by prepending a '!' to the shadow password.
**-P**, **--prefix** *PREFIX_DIR*
: Use *PREFIX_DIR* as a prefix for system file paths.
**-R**, **--root** *CHROOT_DIR*
: Apply changes in the *CHROOT_DIR* directory.
**-s**, **--shell** *SHELL*
: Set the new login shell.
**-u**, **--uid** *UID*
: Set the new numeric user ID.
**-U**, **--unlock**
: Unlock the user's password by removing the '!' prefix from the
shadow password.
## EXIT STATUS
**0**
: Success.
**1**
: Cannot update password file.
**2**
: Invalid command syntax.
**4**
: UID already in use.
**6**
: User does not exist.
## FILES
/etc/passwd
: User account information.
/etc/shadow
: Secure user account information.
/etc/group
: Group account information.
## SEE ALSO
useradd(8), userdel(8), groupmod(8), passwd(1)
+6
View File
@@ -44,6 +44,12 @@ fn copy_dir_recursive(src: &Path, dst: &Path, uid: u32, gid: u32) -> Result<(),
if file_type.is_dir() {
std::fs::create_dir_all(&dst_path)
.map_err(|e| ShadowError::IoPath(e, dst_path.clone()))?;
// Preserve the source directory's permissions.
let src_perms = std::fs::metadata(&src_path)
.map_err(|e| ShadowError::IoPath(e, src_path.clone()))?
.permissions();
std::fs::set_permissions(&dst_path, src_perms)
.map_err(|e| ShadowError::IoPath(e, dst_path.clone()))?;
copy_dir_recursive(&src_path, &dst_path, uid, gid)?;
} else if file_type.is_symlink() {
let target = std::fs::read_link(&src_path)
+97 -14
View File
@@ -203,12 +203,19 @@ fn parse_expire_date(s: &str) -> Result<Option<i64>, UseraddError> {
UseraddError::BadArgument(format!("invalid date '{s}' (expected YYYY-MM-DD)"))
})?;
if !(1..=12).contains(&month) || !(1..=31).contains(&day) || year < 1970 {
if !(1..=12).contains(&month) || year < 1970 {
return Err(UseraddError::BadArgument(format!(
"invalid date '{s}' (expected YYYY-MM-DD with valid ranges)"
)));
}
let max_day = days_in_month(year, month);
if !(1..=max_day).contains(&day) {
return Err(UseraddError::BadArgument(format!(
"invalid date '{s}' (day {day} out of range for month {month})"
)));
}
// Convert to days since epoch using a simple calendar calculation.
// This is sufficient for the date ranges used by shadow-utils.
let days = days_since_epoch(year, month, day);
@@ -229,6 +236,28 @@ fn days_since_epoch(year: i64, month: i64, day: i64) -> i64 {
era * 146_097 + doe - 719_468
}
/// Whether `year` is a leap year in the Gregorian calendar.
fn is_leap_year(year: i64) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
/// Number of days in a given month (1-indexed) for `year`.
fn days_in_month(year: i64, month: i64) -> i64 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 => {
if is_leap_year(year) {
29
} else {
28
}
}
// Month range is already validated before calling this function.
_ => 0,
}
}
/// Current date as days since epoch.
#[allow(clippy::cast_possible_wrap)]
fn today_days_since_epoch() -> i64 {
@@ -574,7 +603,9 @@ fn do_useradd(opts: &UseraddOptions) -> UResult<()> {
// Step 15: Create home directory and copy skel.
if opts.create_home {
create_home_directory(&home_dir, &opts.skel_dir, uid, gid)?;
let resolved_home = opts.root.resolve(&home_dir);
let resolved_skel = opts.root.resolve(&opts.skel_dir);
create_home_directory(&resolved_home, &resolved_skel, uid, gid)?;
}
// Step 16: Invalidate nscd caches.
@@ -809,22 +840,23 @@ fn add_to_supplementary_groups(
// ---------------------------------------------------------------------------
/// Create the home directory and copy skeleton files.
fn create_home_directory(home_dir: &str, skel_dir: &str, uid: u32, gid: u32) -> UResult<()> {
let home_path = Path::new(home_dir);
///
/// Paths must already be resolved through `SysRoot` by the caller.
fn create_home_directory(home_path: &Path, skel_path: &Path, uid: u32, gid: u32) -> UResult<()> {
// Use create_dir (not create_dir_all) to avoid TOCTOU between exists() and mkdir().
match std::fs::create_dir(home_path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
uucore::show_warning!(
"home directory '{}' already exists -- not copying from skel directory",
home_dir
home_path.display()
);
return Ok(());
}
Err(e) => {
return Err(UseraddError::CannotCreateHome(format!(
"cannot create directory '{home_dir}': {e}"
"cannot create directory '{}': {e}",
home_path.display()
))
.into());
}
@@ -832,19 +864,26 @@ fn create_home_directory(home_dir: &str, skel_dir: &str, uid: u32, gid: u32) ->
// Set permissions to 0700 (home directories should be private by default).
std::fs::set_permissions(home_path, std::fs::Permissions::from_mode(0o700)).map_err(|e| {
UseraddError::CannotCreateHome(format!("cannot set permissions on '{home_dir}': {e}"))
UseraddError::CannotCreateHome(format!(
"cannot set permissions on '{}': {e}",
home_path.display()
))
})?;
// Set ownership.
std::os::unix::fs::chown(home_path, Some(uid), Some(gid)).map_err(|e| {
UseraddError::CannotCreateHome(format!("cannot set ownership on '{home_dir}': {e}"))
UseraddError::CannotCreateHome(format!(
"cannot set ownership on '{}': {e}",
home_path.display()
))
})?;
// Copy skeleton directory contents.
let skel_path = Path::new(skel_dir);
skel::copy_skel(skel_path, home_path, uid, gid).map_err(|e| {
UseraddError::CannotCreateHome(format!(
"cannot copy skel '{skel_dir}' to '{home_dir}': {e}"
"cannot copy skel '{}' to '{}': {e}",
skel_path.display(),
home_path.display()
))
})?;
@@ -1250,6 +1289,48 @@ mod tests {
assert!(parse_expire_date("1969-12-31").is_err());
}
#[test]
fn test_parse_expire_date_feb_31() {
assert!(parse_expire_date("2025-02-31").is_err());
}
#[test]
fn test_parse_expire_date_feb_29_non_leap() {
assert!(parse_expire_date("2025-02-29").is_err());
}
#[test]
fn test_parse_expire_date_feb_29_leap() {
assert!(parse_expire_date("2024-02-29").is_ok());
}
#[test]
fn test_parse_expire_date_apr_31() {
assert!(parse_expire_date("2025-04-31").is_err());
}
#[test]
fn test_parse_expire_date_apr_30() {
assert!(parse_expire_date("2025-04-30").is_ok());
}
#[test]
fn test_is_leap_year() {
assert!(is_leap_year(2000));
assert!(is_leap_year(2024));
assert!(!is_leap_year(1900));
assert!(!is_leap_year(2023));
}
#[test]
fn test_days_in_month_values() {
assert_eq!(days_in_month(2025, 1), 31);
assert_eq!(days_in_month(2025, 2), 28);
assert_eq!(days_in_month(2024, 2), 29);
assert_eq!(days_in_month(2025, 4), 30);
assert_eq!(days_in_month(2025, 12), 31);
}
#[test]
fn test_days_since_epoch_known_dates() {
// 1970-01-01 = day 0
@@ -1605,12 +1686,14 @@ mod tests {
let home = dir.path().join("home/testuser");
let skel = dir.path().join("skel");
// Parent of home must exist (create_dir is intentionally used, not create_dir_all).
fs::create_dir_all(dir.path().join("home")).expect("create home parent");
// Create skeleton directory with a file.
fs::create_dir_all(&skel).expect("create skel");
fs::write(skel.join(".bashrc"), "# bashrc\n").expect("write bashrc");
create_home_directory(&home.to_string_lossy(), &skel.to_string_lossy(), 1000, 1000)
.expect("create home");
create_home_directory(&home, &skel, 1000, 1000).expect("create home");
assert!(home.exists());
assert!(home.join(".bashrc").exists());
@@ -1632,7 +1715,7 @@ mod tests {
fs::create_dir_all(&home).expect("create home");
// Should succeed with a warning, not copy skel.
create_home_directory(&home.to_string_lossy(), "/nonexistent/skel", 1000, 1000)
create_home_directory(&home, Path::new("/nonexistent/skel"), 1000, 1000)
.expect("should succeed for existing home");
}
+5 -2
View File
@@ -85,8 +85,11 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
let login = matches
.get_one::<String>(options::LOGIN)
.expect("LOGIN is required");
let remove_home = matches.get_flag(options::REMOVE) || matches.get_flag(options::FORCE);
let prefix = matches.get_one::<String>(options::PREFIX).map(Path::new);
let remove_home = matches.get_flag(options::REMOVE);
let prefix = matches
.get_one::<String>(options::PREFIX)
.or_else(|| matches.get_one::<String>(options::ROOT))
.map(Path::new);
let root = SysRoot::new(prefix);
// Must be root.
+52 -6
View File
@@ -19,7 +19,7 @@ use shadow_core::lock::FileLock;
use shadow_core::passwd::{self};
use shadow_core::shadow::{self};
use shadow_core::sysroot::SysRoot;
use shadow_core::{atomic, nscd};
use shadow_core::{atomic, nscd, validate};
mod options {
pub const COMMENT: &str = "comment";
@@ -88,7 +88,10 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
let login = matches
.get_one::<String>(options::USER)
.expect("USER is required");
let prefix = matches.get_one::<String>(options::PREFIX).map(Path::new);
let prefix = matches
.get_one::<String>(options::PREFIX)
.or_else(|| matches.get_one::<String>(options::ROOT))
.map(Path::new);
let root = SysRoot::new(prefix);
if !nix::unistd::getuid().is_root() {
@@ -134,8 +137,11 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
if let Some(&gid) = matches.get_one::<u32>(options::GID) {
entries[idx].gid = gid;
}
if let Some(new_login) = matches.get_one::<String>(options::LOGIN) {
entries[idx].name.clone_from(new_login);
let new_login = matches.get_one::<String>(options::LOGIN);
if let Some(new_name) = new_login {
validate::validate_username(new_name)
.map_err(|e| UsermodError::CantUpdate(format!("invalid login name: {e}")))?;
entries[idx].name.clone_from(new_name);
}
let new_uid = entries[idx].uid;
@@ -160,7 +166,10 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
let expire = matches.get_one::<String>(options::EXPIREDATE);
let inactive = matches.get_one::<i64>(options::INACTIVE);
if shadow_path.exists() && (do_lock || do_unlock || expire.is_some() || inactive.is_some()) {
let login_changing = new_login.is_some();
if shadow_path.exists()
&& (do_lock || do_unlock || expire.is_some() || inactive.is_some() || login_changing)
{
let slock = FileLock::acquire(&shadow_path)
.map_err(|e| UsermodError::CantUpdate(format!("cannot lock shadow: {e}")))?;
@@ -175,11 +184,22 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
s.unlock();
}
if let Some(exp) = expire {
s.expire_date = if exp == "-1" { None } else { exp.parse().ok() };
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);
}
}
atomic::atomic_write(&shadow_path, |f| shadow::write_shadow(&se, f))
@@ -187,6 +207,32 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
drop(slock);
}
// Rename user in group membership lists when --login changes the name.
if let Some(new_name) = new_login {
let group_path = root.group_path();
if group_path.exists() {
let glock = FileLock::acquire(&group_path)
.map_err(|e| UsermodError::CantUpdate(format!("cannot lock group: {e}")))?;
let mut ge = group::read_group_file(&group_path)
.map_err(|e| UsermodError::CantUpdate(format!("{e}")))?;
let mut changed = false;
for g in &mut ge {
if let Some(m) = g.members.iter_mut().find(|m| **m == *login) {
m.clone_from(new_name);
changed = true;
}
}
if changed {
atomic::atomic_write(&group_path, |f| group::write_group(&ge, f))
.map_err(|e| UsermodError::CantUpdate(format!("{e}")))?;
}
drop(glock);
}
}
// Group modifications.
if let Some(groups_str) = matches.get_one::<String>(options::GROUPS) {
let group_path = root.group_path();