Files
shadow/tests/e2e/deploy-test.sh
T
Pierre Warnier 7e28940874 expiry, and usermod's subordinate-id options (#299)
* usermod: add and remove subordinate id ranges

GNU usermod has -v/--add-subuids, -V/--del-subuids, -w/--add-subgids and
-W/--del-subgids; ours had none of them, which came to light while writing
the tests for newuidmap. The range helpers live in shadow_core::subid so
whatever next touches /etc/subuid reads the same file the same way.

The behaviour was established by running the GNU tool: a range the user
already holds is not added twice; a removal trims an entry it overlaps,
splits one it cuts through the middle of, deletes one it covers, and ignores
a range never held; nothing checks other users' entries, since an
administrator may share a range on purpose; and an invalid range --
reversed, not two numbers, above the 32-bit ids the kernel maps -- is exit 3
with the GNU wording, before any file is written. Entries are keyed by the
login name given on the command line and are not renamed by -l, as GNU's
are not: a range is a grant to a name.

-V is GNU's letter for the deletion, so clap's own -V for --version had to
go; --version stays.

* expiry: check and enforce password expiration

expiry is the twenty-eighth tool and the last the Scope table lists as in
scope. It judges the caller's own /etc/shadow line as login would: silent
when all is well, a warning inside warn_days, a forced change through PAM
when the password has expired, a refusal when the account has. Shell
profiles run it so a session that did not come through login -- a display
manager, an ssh key -- still meets the aging policy.

The rules are in shadow_core::shadow::Aging, one function over the aging
fields with checked arithmetic, since every input is read from a file anyone
who can write /etc/shadow chooses. Its order is login's: account expiry
first, then a last change of 0 or an aged-out password, then the warning
window, with a maximum age of 10000 or more meaning never as chage -l prints
it. A locked password is a policy of its own and is not reported.

The GNU tool is setgid shadow -- enough to read the file, no more -- and the
change goes through PAM as the caller. The per-tool install ships it the
same way, a new install class. The single setuid binary keeps euid 0 for it,
having nothing narrower to offer. Under --prefix it reports and never
changes anything, since a PAM change would act on this system's account of
the same name; that is also what lets the required-change path be asserted
in the integration tests without a prompt, while the e2e suite drives the
prompt itself on a real user under a pty and checks the hash changed.

groupmems is not coming. Debian 13 and Fedora do not ship it, and on Ubuntu
24.04 every action fails against PAM's default for want of a
/etc/pam.d/groupmems: a tool one distribution ships and none can use. The
Scope table says so.

Verified: make check clean with the unprivileged run; 935 tests on alpine
and 950 on fedora with PAM; 410 e2e assertions against a real install; 59
GNU comparisons; 35 arm64 assertions under emulation.

* shadow-core: stop asking nscd to invalidate a shadow cache it never had

Every passwd, chage and chpasswd ended with `nscd -i shadow`, and on a host
running nscd that printed "nscd: 'shadow' is not a known database" on the
user's terminal each time. nscd caches passwd, group, hosts, services and
netgroup; the passwd entries it holds carry the `x` placeholder, so a
password change leaves nothing of nscd's stale. Only the databases nscd
knows are passed to it now. sss_cache still gets -U for a shadow change,
which is right: sssd does cache what a password change affects.
2026-09-07 15:47:43 +02:00

1484 lines
62 KiB
Bash
Executable File

#!/usr/bin/env bash
# shadow-rs end-to-end deployment tests
#
# Runs ~100 assertions validating that shadow-rs works as a drop-in
# replacement for GNU shadow-utils when installed system-wide.
#
# Usage:
# docker compose run --rm e2e # run all tests
# docker compose run --rm e2e bash # debug interactively
#
# Requires: root (for user/group management), expect, nscd, ansible-core
set -uo pipefail
# ── Test framework ──────────────────────────────────────────────────
PASS=0
FAIL=0
SECTION=""
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
section() {
SECTION="$1"
echo -e "\n${BLUE}── $1 ──${NC}"
}
assert_ok() {
local desc="$1"
shift
if "$@" >/dev/null 2>&1; then
echo -e " ${GREEN}✓${NC} $desc"
PASS=$((PASS + 1))
else
echo -e " ${RED}✗${NC} $desc (command: $1)"
FAIL=$((FAIL + 1))
fi
}
assert_fail() {
local desc="$1"
shift
if "$@" >/dev/null 2>&1; then
echo -e " ${RED}✗${NC} $desc (expected failure, got success)"
FAIL=$((FAIL + 1))
else
echo -e " ${GREEN}✓${NC} $desc"
PASS=$((PASS + 1))
fi
}
assert_contains() {
local desc="$1"
local pattern="$2"
shift 2
local output
if output=$("$@" 2>&1) && echo "$output" | grep -q "$pattern"; then
echo -e " ${GREEN}✓${NC} $desc"
PASS=$((PASS + 1))
else
echo -e " ${RED}✗${NC} $desc (pattern '$pattern' not found in output)"
FAIL=$((FAIL + 1))
fi
}
assert_file_contains() {
local desc="$1"
local file="$2"
local pattern="$3"
if grep -q "$pattern" "$file" 2>/dev/null; then
echo -e " ${GREEN}✓${NC} $desc"
PASS=$((PASS + 1))
else
echo -e " ${RED}✗${NC} $desc (pattern '$pattern' not in $file)"
FAIL=$((FAIL + 1))
fi
}
assert_file_not_contains() {
local desc="$1"
local file="$2"
local pattern="$3"
if grep -q "$pattern" "$file" 2>/dev/null; then
echo -e " ${RED}✗${NC} $desc (pattern '$pattern' found in $file but shouldn't be)"
FAIL=$((FAIL + 1))
else
echo -e " ${GREEN}✓${NC} $desc"
PASS=$((PASS + 1))
fi
}
# Helper: hash a plaintext password for chpasswd -e
hash_password() {
openssl passwd -6 "$1"
}
# ── TOOLS list ──────────────────────────────────────────────────────
TOOLS="passwd pwck useradd userdel usermod chpasswd chgpasswd newusers chage groupadd groupdel groupmod gpasswd grpck chfn chsh newgrp sg vipw vigr pwconv pwunconv grpconv grpunconv login newuidmap newgidmap expiry"
SETUID_TOOLS="passwd chfn chsh newgrp gpasswd sg newuidmap newgidmap"
# The tools an unprivileged user runs are installed in bin, the rest in sbin,
# which is the split the GNU package uses: sbin is not on a normal user's
# PATH, so `passwd` there would be "command not found".
USER_TOOLS="$SETUID_TOOLS chage login expiry"
BINDIR="/usr/sbin"
USER_BINDIR="/usr/bin"
# Where a given tool's symlink was installed.
tool_path() {
case " $USER_TOOLS " in
*" $1 "*) echo "/usr/bin/$1" ;;
*) echo "/usr/sbin/$1" ;;
esac
}
# ── Preflight ───────────────────────────────────────────────────────
preflight() {
section "Preflight checks"
assert_ok "shadow-rs binary exists" test -x "$BINDIR/shadow-rs"
assert_ok "shadow-rs --list succeeds" "$BINDIR/shadow-rs" --list
for tool in $TOOLS; do
local path
path=$(tool_path "$tool")
assert_ok "symlink exists: $path" test -L "$path"
assert_ok "symlink $tool resolves to shadow-rs" \
bash -c "readlink -f '$path' | grep -q 'shadow-rs'"
done
# An unprivileged user must be able to run their own tools by name.
for tool in $USER_TOOLS; do
assert_ok "$tool is on an unprivileged user's PATH" \
su -s /bin/bash testrunner -c "command -v $tool >/dev/null"
done
}
# ── Symlink dispatch ────────────────────────────────────────────────
test_symlink_dispatch() {
section "Symlink dispatch (argv[0])"
for tool in $TOOLS; do
assert_ok "$tool --help via symlink" "$(tool_path "$tool")" --help
done
}
# ── Multicall dispatch ──────────────────────────────────────────────
test_multicall_dispatch() {
section "Multicall dispatch (shadow-rs <tool>)"
for tool in $TOOLS; do
assert_ok "shadow-rs $tool --help" "$BINDIR/shadow-rs" "$tool" --help
done
}
# ── Setuid ──────────────────────────────────────────────────────────
test_setuid() {
section "Setuid bits"
# Check setuid on the target binary (symlinks always show 777)
local target_perms
target_perms=$(stat -L -c '%a' "$BINDIR/shadow-rs" 2>/dev/null || echo "0")
if [[ "$target_perms" == "4755" ]]; then
echo -e " ${GREEN}✓${NC} shadow-rs binary has setuid bit (4755)"
PASS=$((PASS + 1))
else
echo -e " ${RED}✗${NC} shadow-rs binary expected 4755, got $target_perms"
FAIL=$((FAIL + 1))
fi
# Verify each setuid tool symlink resolves to the setuid binary
for tool in $SETUID_TOOLS; do
local resolved_perms
resolved_perms=$(stat -L -c '%a' "$(tool_path "$tool")" 2>/dev/null || echo "0")
if [[ "$resolved_perms" == "4755" ]]; then
echo -e " ${GREEN}✓${NC} $tool resolves to setuid binary (4755)"
PASS=$((PASS + 1))
else
echo -e " ${RED}✗${NC} $tool expected 4755 (via symlink), got $resolved_perms"
FAIL=$((FAIL + 1))
fi
done
# passwd must be built with the `pam` cargo feature, otherwise the
# interactive change path is compiled out and the tool can only report and
# lock accounts — it cannot actually change a password.
# chfn and chsh authenticate the caller through PAM before applying a
# change, so a build without the feature refuses every non-root use.
for tool in passwd chfn chsh; do
assert_ok "$tool is linked against PAM" \
sh -c "ldd $(tool_path "$tool") | grep -q libpam"
done
# Non-root user should be able to run passwd -S on themselves
assert_ok "testrunner can run passwd -S" \
su -s /bin/bash testrunner -c "$USER_BINDIR/passwd -S testrunner"
# Non-root user should NOT be able to change another user's password
assert_fail "testrunner cannot passwd root" \
su -s /bin/bash testrunner -c "echo 'root:hacked' | $BINDIR/chpasswd -e"
# The binary is setuid for the four self-service tools only; every other
# applet must run with the caller's own privileges. An unprivileged
# `pwck -s` or `grpck -s` must neither rewrite a file nor change its owner.
local passwd_before group_before
passwd_before=$(stat -c '%i %U:%G' /etc/passwd)
group_before=$(stat -c '%i %U:%G' /etc/group)
assert_fail "testrunner cannot run shadow-rs pwck -s" \
su -s /bin/bash testrunner -c "$BINDIR/shadow-rs pwck -s"
assert_fail "testrunner cannot run pwck -s through the symlink" \
su -s /bin/bash testrunner -c "$BINDIR/pwck -s"
assert_fail "testrunner cannot run grpck -s" \
su -s /bin/bash testrunner -c "$BINDIR/grpck -s"
assert_ok "/etc/passwd untouched by unprivileged pwck -s" \
test "$(stat -c '%i %U:%G' /etc/passwd)" = "$passwd_before"
assert_ok "/etc/group untouched by unprivileged grpck -s" \
test "$(stat -c '%i %U:%G' /etc/group)" = "$group_before"
assert_ok "/etc/shadow is root:shadow 0640" \
test "$(stat -c '%U:%G %a' /etc/shadow)" = "root:shadow 640"
}
# ── User lifecycle ──────────────────────────────────────────────────
test_user_lifecycle() {
section "User lifecycle (useradd → chpasswd → usermod → pwck → userdel)"
# Clean up from any previous failed run
userdel -r lifecycle_user 2>/dev/null || true
groupdel lifecycle_grp 2>/dev/null || true
# Create user
assert_ok "useradd -m -s /bin/bash lifecycle_user" \
useradd -m -s /bin/bash lifecycle_user
assert_file_contains "lifecycle_user in /etc/passwd" \
/etc/passwd "^lifecycle_user:"
assert_file_contains "lifecycle_user in /etc/shadow" \
/etc/shadow "^lifecycle_user:"
assert_ok "home directory created" test -d /home/lifecycle_user
# Set password via chpasswd -e (pre-hashed)
local hashed
hashed=$(hash_password "TestPass123")
assert_ok "chpasswd -e sets password" \
bash -c "echo 'lifecycle_user:$hashed' | chpasswd -e"
# Verify shadow entry has a hash (not ! or *)
assert_file_not_contains "shadow has real hash (not locked)" \
/etc/shadow '^lifecycle_user:[!*]:'
# Modify user
assert_ok "usermod -c 'Lifecycle Test' lifecycle_user" \
usermod -c "Lifecycle Test" lifecycle_user
assert_file_contains "GECOS updated" \
/etc/passwd "lifecycle_user:.*:Lifecycle Test:"
assert_ok "usermod -s /bin/sh lifecycle_user" \
usermod -s /bin/sh lifecycle_user
assert_file_contains "shell updated to /bin/sh" \
/etc/passwd "lifecycle_user:.*:/bin/sh$"
# Add supplementary group
groupadd lifecycle_grp 2>/dev/null || true
assert_ok "usermod -aG lifecycle_grp lifecycle_user" \
usermod -aG lifecycle_grp lifecycle_user
assert_contains "user in supplementary group" "lifecycle_user" \
grep "^lifecycle_grp:" /etc/group
# Consistency check (exit 2 = warnings about system accounts, acceptable)
assert_ok "pwck -r passes" bash -c 'rc=$(pwck -r >/dev/null 2>&1; echo $?); [ "$rc" -le 2 ]'
# Lock and unlock password
assert_ok "passwd -l lifecycle_user" passwd -l lifecycle_user
assert_file_contains "password locked (! prefix)" \
/etc/shadow '^lifecycle_user:!'
# Every rewrite must leave the file as the distribution installed it;
# unix_chkpwd and friends are sgid shadow and rely on it.
assert_ok "/etc/shadow is still root:shadow 0640 after passwd -l" \
test "$(stat -c '%U:%G %a' /etc/shadow)" = "root:shadow 640"
# Locking twice must leave one marker: a second would need a second
# unlock, and `passwd -u` refuses to unlock an account that would stay
# locked, so the tool could not undo its own second lock.
assert_ok "passwd -l lifecycle_user again" passwd -l lifecycle_user
assert_ok "a second lock adds no second marker" \
bash -c "! grep -q '^lifecycle_user:!!' /etc/shadow"
assert_ok "passwd -u lifecycle_user" passwd -u lifecycle_user
assert_file_not_contains "password unlocked (no ! prefix)" \
/etc/shadow '^lifecycle_user:!'
# Delete user
assert_ok "userdel -r lifecycle_user" userdel -r lifecycle_user
assert_file_not_contains "user removed from /etc/passwd" \
/etc/passwd "^lifecycle_user:"
assert_file_not_contains "user removed from /etc/shadow" \
/etc/shadow "^lifecycle_user:"
# Clean up group
groupdel lifecycle_grp 2>/dev/null || true
}
# ── Group lifecycle ─────────────────────────────────────────────────
test_group_lifecycle() {
section "Group lifecycle (groupadd → gpasswd → groupmod → groupdel → grpck)"
# Clean up from any previous failed run
groupdel lifecycle_testgrp 2>/dev/null || true
groupdel lifecycle_renamed 2>/dev/null || true
# Create group
assert_ok "groupadd lifecycle_testgrp" groupadd lifecycle_testgrp
assert_file_contains "group in /etc/group" \
/etc/group "^lifecycle_testgrp:"
assert_ok "gpasswd -a root lifecycle_testgrp" \
gpasswd -a root lifecycle_testgrp
assert_file_contains "gpasswd added root to group" \
/etc/group "^lifecycle_testgrp:.*root"
# Modify group name
assert_ok "groupmod -n lifecycle_renamed lifecycle_testgrp" \
groupmod -n lifecycle_renamed lifecycle_testgrp
assert_file_contains "renamed group in /etc/group" \
/etc/group "^lifecycle_renamed:"
assert_file_not_contains "old name gone from /etc/group" \
/etc/group "^lifecycle_testgrp:"
# Consistency check
assert_ok "grpck -r passes" grpck -r
# Delete group
assert_ok "groupdel lifecycle_renamed" groupdel lifecycle_renamed
assert_file_not_contains "group removed from /etc/group" \
/etc/group "^lifecycle_renamed:"
}
# ── Individual tool tests ───────────────────────────────────────────
test_individual_tools() {
section "Individual tool tests"
# Set up test user for tool-specific tests
userdel -r tooltest_user 2>/dev/null || true
assert_ok "useradd -m -s /bin/bash tooltest_user" \
useradd -m -s /bin/bash tooltest_user
local hashed
hashed=$(hash_password "ToolPass123")
assert_ok "chpasswd -e sets tooltest_user password" \
bash -c "echo 'tooltest_user:$hashed' | chpasswd -e"
# chage: set and query password aging
assert_ok "chage -l tooltest_user" chage -l tooltest_user
assert_ok "chage -M 90 tooltest_user" chage -M 90 tooltest_user
assert_contains "max days is 90" "90" chage -l tooltest_user
# chfn: change GECOS
assert_ok "chfn -f 'Tool Test User' tooltest_user" \
chfn -f "Tool Test User" tooltest_user
assert_file_contains "GECOS updated by chfn" \
/etc/passwd "tooltest_user:.*:Tool Test User"
# chsh: change shell
assert_ok "chsh -s /bin/sh tooltest_user" chsh -s /bin/sh tooltest_user
assert_file_contains "shell changed by chsh" \
/etc/passwd "tooltest_user:.*:/bin/sh$"
# chpasswd -e: batch password change with pre-hashed
local newhash
newhash=$(hash_password "NewPass456")
assert_ok "chpasswd -e batch mode" \
bash -c "echo 'tooltest_user:$newhash' | chpasswd -e"
# passwd -S: status
assert_ok "passwd -S tooltest_user" passwd -S tooltest_user
# pwck/grpck: read-only checks (exit 2 = warnings, acceptable)
assert_ok "pwck -r" bash -c 'rc=$(pwck -r >/dev/null 2>&1; echo $?); [ "$rc" -le 2 ]'
assert_ok "grpck -r" bash -c 'rc=$(grpck -r >/dev/null 2>&1; echo $?); [ "$rc" -le 2 ]'
# Clean up
userdel -r tooltest_user 2>/dev/null || true
}
# ── PAM authentication ──────────────────────────────────────────────
# Authenticate as $1 with password $2 through `su` run by testrunner. Root's
# own `su` never asks for a password (pam_rootok), so an unprivileged caller is
# the only way to make PAM actually verify one. Exit 0 = authenticated.
su_as_testrunner() {
expect -c "
set timeout 15
log_user 0
spawn su -s /bin/bash testrunner -c {su -s /bin/bash -c id $1}
expect {
-nocase {password:} { send \"$2\r\"; exp_continue }
{uid=} { exit 0 }
-nocase {failure} { exit 1 }
eof { exit 2 }
timeout { exit 3 }
}
"
}
# $1 changes their own password from $2 to $3 with our passwd, as themselves.
# Runs under `su -c`, which has no controlling terminal, so the prompts arrive
# on stdin/stderr — the path the conversation function must handle.
change_own_password() {
expect -c "
set timeout 20
log_user 0
spawn su -s /bin/bash $1 -c $USER_BINDIR/passwd
expect {
-nocase -re {current.*password:|\\(current\\).*:} { send \"$2\r\"; exp_continue }
-nocase -re {new.*password:} { send \"$3\r\"; exp_continue }
-nocase -re {(retype|again).*:} { send \"$3\r\"; exp_continue }
-nocase {failure} { exit 10 }
eof { catch wait result; exit [lindex \$result 3] }
timeout { exit 99 }
}
"
}
# root sets $1's password to $2 with our passwd (no current password asked).
set_password_as_root() {
expect -c "
set timeout 20
log_user 0
spawn $USER_BINDIR/passwd $1
expect {
-nocase -re {new.*password:} { send \"$2\r\"; exp_continue }
-nocase -re {(retype|again).*:} { send \"$2\r\"; exp_continue }
eof { catch wait result; exit [lindex \$result 3] }
timeout { exit 99 }
}
"
}
test_pam_auth() {
section "PAM authentication"
# Create PAM test user with pre-hashed password
userdel -r pamtest_user 2>/dev/null || true
assert_ok "useradd -m -s /bin/bash pamtest_user" \
useradd -m -s /bin/bash pamtest_user
local hashed
hashed=$(hash_password "PamPass789")
assert_ok "chpasswd -e sets pamtest_user password" \
bash -c "echo 'pamtest_user:$hashed' | chpasswd -e"
assert_ok "known password authenticates through PAM" \
su_as_testrunner pamtest_user PamPass789
assert_fail "wrong password is rejected" \
su_as_testrunner pamtest_user WrongPass000
# The reason passwd is setuid: a user changes their own password. This is
# pam_unix end to end — authenticate with the current password through
# the unprivileged helper, then rewrite /etc/shadow with the privilege the
# setuid bit provides.
assert_ok "pamtest_user changes own password with passwd" \
change_own_password pamtest_user PamPass789 NewPass456
assert_ok "new password authenticates" \
su_as_testrunner pamtest_user NewPass456
assert_fail "old password no longer authenticates" \
su_as_testrunner pamtest_user PamPass789
assert_ok "/etc/shadow is still root:shadow 0640 after a PAM change" \
test "$(stat -c '%U:%G %a' /etc/shadow)" = "root:shadow 640"
assert_ok "root sets pamtest_user's password with passwd" \
set_password_as_root pamtest_user RootSet321
assert_ok "root-set password authenticates" \
su_as_testrunner pamtest_user RootSet321
# Clean up
userdel -r pamtest_user 2>/dev/null || true
}
# ── Self-service tools: chfn and chsh ───────────────────────────────
# $1 runs chsh interactively as themselves, answering the shell prompt with $2
# and any password prompt with $3. Exits with chsh's own status.
chsh_interactive() {
expect -c "
set timeout 20
log_user 0
spawn su -s /bin/bash $1 -c $USER_BINDIR/chsh
expect {
-nocase -re {login shell.*:} { send \"$2\r\"; exp_continue }
-nocase -re {password:} { send \"$3\r\"; exp_continue }
eof { catch wait result; exit [lindex \$result 3] }
timeout { exit 99 }
}
"
}
# $1 runs chfn interactively as themselves, answering the room prompt with $2,
# every other field with ENTER, and any password prompt with $3.
chfn_interactive() {
expect -c "
set timeout 20
log_user 0
spawn su -s /bin/bash $1 -c $USER_BINDIR/chfn
expect {
-nocase -re {room number.*:} { send \"$2\r\"; exp_continue }
-nocase -re {(full name|work phone|home phone).*:} { send \"\r\"; exp_continue }
-nocase -re {password:} { send \"$3\r\"; exp_continue }
eof { catch wait result; exit [lindex \$result 3] }
timeout { exit 99 }
}
"
}
# $1 asks chsh for shell $2 and must be refused *before* any password prompt:
# the value is checked first, so a rejected shell never costs a password.
# Exit 0 only if chsh failed and never prompted. $3 is the expected message.
chsh_refused_without_prompt() {
expect -c "
set timeout 20
log_user 0
spawn su -s /bin/bash $1 -c {$USER_BINDIR/chsh -s $2}
expect {
-nocase -re {password:} { exit 20 }
-nocase {$3} { exp_continue }
eof { catch wait result;
if {[lindex \$result 3] == 0} { exit 21 } else { exit 0 } }
timeout { exit 99 }
}
"
}
# Same, but exit 0 only if the message does NOT appear.
chsh_message_absent() {
expect -c "
set timeout 20
log_user 0
spawn su -s /bin/bash $1 -c {$USER_BINDIR/chsh -s $2}
expect {
-nocase {$3} { exit 22 }
eof { exit 0 }
timeout { exit 99 }
}
"
}
test_self_service() {
section "Self-service tools (chfn, chsh)"
userdel -r selftest_user 2>/dev/null || true
assert_ok "useradd -m -s /bin/bash selftest_user" \
useradd -m -s /bin/bash selftest_user
local hashed
hashed=$(hash_password "SelfPass123")
assert_ok "chpasswd -e sets selftest_user password" \
bash -c "echo 'selftest_user:$hashed' | chpasswd -e"
grep -q '^/bin/sh$' /etc/shells 2>/dev/null || echo /bin/sh >>/etc/shells
grep -q '^/bin/bash$' /etc/shells 2>/dev/null || echo /bin/bash >>/etc/shells
# A user changes their own shell with no -s: chsh prompts, PAM verifies.
assert_ok "selftest_user changes own shell interactively" \
chsh_interactive selftest_user /bin/sh SelfPass123
assert_file_contains "interactive chsh wrote the new shell" \
/etc/passwd "selftest_user:.*:/bin/sh$"
# Pressing ENTER keeps the current value and is not an error.
assert_ok "empty answer keeps the current shell" \
chsh_interactive selftest_user "" SelfPass123
assert_file_contains "shell unchanged after an empty answer" \
/etc/passwd "selftest_user:.*:/bin/sh$"
# chsh is setuid-root, so its existence check sees what root sees. A
# non-root caller must get the same answer for an unlisted path whether or
# not it exists, or the tool becomes a filesystem oracle.
local secret=/root/.chsh-oracle-probe
: >"$secret"
chmod 600 "$secret"
assert_ok "unlisted existing path under /root is refused as unlisted" \
chsh_refused_without_prompt selftest_user "$secret" "is not listed"
assert_ok "unlisted missing path under /root gives the same answer" \
chsh_refused_without_prompt selftest_user /root/.chsh-absent "is not listed"
assert_ok "existence of a /root path is never disclosed" \
chsh_message_absent selftest_user "$secret" "does not exist"
rm -f "$secret"
# An empty shell field is the system default, not a missing program.
assert_ok "root may set the empty shell" chsh -s "" selftest_user
assert_file_contains "empty shell field written" \
/etc/passwd "^selftest_user:.*:/home/selftest_user:$"
assert_ok "chsh -s /bin/bash restores a shell" chsh -s /bin/bash selftest_user
# chfn with no field option prompts for each field the caller may change.
assert_ok "CHFN_RESTRICT=rwh in login.defs" \
bash -c "sed -i '/^CHFN_RESTRICT/d' /etc/login.defs; echo 'CHFN_RESTRICT rwh' >>/etc/login.defs"
assert_ok "selftest_user changes own room interactively" \
chfn_interactive selftest_user B-217 SelfPass123
assert_file_contains "interactive chfn wrote the room number" \
/etc/passwd "selftest_user:.*:,B-217,"
# 'f' is absent from CHFN_RESTRICT, so the full name is not the caller's
# to change — and the refusal must arrive without a password prompt.
assert_fail "selftest_user may not change the full name under CHFN_RESTRICT=rwh" \
su -s /bin/bash selftest_user -c "$USER_BINDIR/chfn -f Nope </dev/null"
assert_ok "root may still change the full name" \
chfn -f "Self Test User" selftest_user
assert_file_contains "root-set full name written" \
/etc/passwd "selftest_user:.*:Self Test User,"
userdel -r selftest_user 2>/dev/null || true
}
# ── Aging fields, batch input and newgrp ────────────────────────────
# Print the value column of one `chage -l` line, e.g. aging_field 2 alice
# for "Password expires".
aging_field() {
chage -l "$2" | sed -n "$1p" | sed 's/.*: //'
}
test_aging_and_input() {
section "Aging fields, batch input, newgrp"
userdel -r aging_user 2>/dev/null || true
assert_ok "useradd -m aging_user" useradd -m aging_user
# A last-change day of 0 is `passwd -e`'s marker, not a date, and it makes
# both derived dates meaningless too.
assert_ok "chage -d 0 aging_user" chage -d 0 aging_user
assert_ok "last change reports 'password must be changed'" \
test "$(aging_field 1 aging_user)" = "password must be changed"
assert_ok "password expiry reports 'password must be changed'" \
test "$(aging_field 2 aging_user)" = "password must be changed"
assert_ok "password inactive reports 'password must be changed'" \
test "$(aging_field 3 aging_user)" = "password must be changed"
# The threshold above which a maximum age means "no expiry" is 10000.
assert_ok "chage -d 2026-01-01 -M 9999 aging_user" \
chage -d 2026-01-01 -M 9999 aging_user
assert_ok "max 9999 gives a real expiry date" \
test "$(aging_field 2 aging_user)" = "May 18, 2053"
assert_ok "chage -M 10000 aging_user" chage -M 10000 aging_user
assert_ok "max 10000 means never" \
test "$(aging_field 2 aging_user)" = "never"
# -1 clears a field; anything else negative is a usage error.
assert_ok "chage -M -1 clears the maximum" chage -M -1 aging_user
assert_ok "cleared maximum reads back as -1" \
test "$(aging_field 6 aging_user)" = "-1"
assert_fail "chage -M -5 is refused" chage -M -5 aging_user
assert_fail "chage -d -5 is refused" chage -d -5 aging_user
# An unknown login is an ordinary failure (1), not "no shadow file" (15).
assert_ok "chage -l on an unknown login exits 1" \
bash -c 'chage -l no-such-user-4b2 >/dev/null 2>&1; [ "$?" -eq 1 ]'
assert_ok "passwd -S on an unknown login exits 1" \
bash -c 'passwd -S no-such-user-4b2 >/dev/null 2>&1; [ "$?" -eq 1 ]'
# chpasswd takes its default hashing scheme from login.defs, as the rest
# of the system does; hard-coding one wrote weaker hashes than configured.
assert_ok "ENCRYPT_METHOD YESCRYPT in login.defs" \
bash -c "sed -i '/^ENCRYPT_METHOD/d' /etc/login.defs; echo 'ENCRYPT_METHOD YESCRYPT' >>/etc/login.defs"
assert_ok "chpasswd hashes aging_user" \
bash -c "echo 'aging_user:BatchPass123' | chpasswd"
assert_file_contains "chpasswd used yescrypt as configured" \
/etc/shadow 'aging_user:\$y\$'
assert_ok "ENCRYPT_METHOD SHA512 in login.defs" \
bash -c "sed -i 's/^ENCRYPT_METHOD.*/ENCRYPT_METHOD SHA512/' /etc/login.defs"
assert_ok "chpasswd hashes aging_user again" \
bash -c "echo 'aging_user:BatchPass456' | chpasswd"
assert_file_contains "chpasswd followed the changed configuration" \
/etc/shadow 'aging_user:\$6\$'
assert_ok "-c overrides the configured default" \
bash -c "echo 'aging_user:BatchPass789' | chpasswd -c SHA256"
assert_file_contains "explicit -c wins" /etc/shadow 'aging_user:\$5\$'
# Flag combinations chpasswd(8) documents as errors.
assert_ok "-s without -c is a usage error (exit 2)" \
bash -c 'echo "aging_user:x" | chpasswd -s 5000 >/dev/null 2>&1; [ "$?" -eq 2 ]'
assert_ok "-e with -c is a usage error (exit 2)" \
bash -c 'echo "aging_user:x" | chpasswd -e -c SHA512 >/dev/null 2>&1; [ "$?" -eq 2 ]'
# --prefix on chage and chpasswd, so their behaviour is testable without
# touching the live files.
local pfx
pfx=$(mktemp -d)
mkdir -p "$pfx/etc"
printf 'pfxuser:x:4000:4000::/home/pfxuser:/bin/sh\n' >"$pfx/etc/passwd"
printf 'pfxuser:!:19000:0:99999:7:::\n' >"$pfx/etc/shadow"
assert_ok "chage --prefix reads the prefixed shadow file" \
chage -P "$pfx" -l pfxuser
assert_ok "chage --prefix writes the prefixed shadow file" \
chage -P "$pfx" -M 42 pfxuser
assert_file_contains "prefixed maximum age written" \
"$pfx/etc/shadow" 'pfxuser:!:19000:0:42:'
assert_ok "chpasswd --prefix writes the prefixed shadow file" \
bash -c "echo 'pfxuser:PrefixPass1' | chpasswd -P '$pfx'"
assert_ok "the live shadow file was untouched by --prefix" \
bash -c "! grep -q '^pfxuser:' /etc/shadow"
rm -rf "$pfx"
# newgrp: without '-' the environment and working directory survive; with
# '-' the shell is a login shell in a reinitialized environment.
groupadd -f newgrp_grp >/dev/null 2>&1
usermod -aG newgrp_grp aging_user
assert_ok "newgrp switches the primary group" \
bash -c "su -s /bin/bash aging_user -c 'echo id -gn | newgrp newgrp_grp' | grep -qx newgrp_grp"
assert_ok "newgrp keeps the caller's environment" \
bash -c "su -s /bin/bash aging_user -c 'MARKER=kept; export MARKER; echo \"echo \\\$MARKER\" | newgrp newgrp_grp' | grep -qx kept"
assert_ok "newgrp - reinitializes the environment" \
bash -c "su -s /bin/bash aging_user -c 'MARKER=kept; export MARKER; echo \"echo m=\\\$MARKER\" | newgrp - newgrp_grp' | grep -qx 'm='"
assert_ok "newgrp - still switches the group" \
bash -c "su -s /bin/bash aging_user -c 'echo id -gn | newgrp - newgrp_grp' | grep -qx newgrp_grp"
assert_fail "newgrp rejects a misplaced dash" \
su -s /bin/bash aging_user -c "newgrp newgrp_grp - </dev/null"
groupdel newgrp_grp 2>/dev/null || true
userdel -r aging_user 2>/dev/null || true
}
# ── Audit logging ───────────────────────────────────────────────────
test_audit_logging() {
section "Audit logging"
# The tools write their records straight to /dev/log rather than forking
# `logger` for each event, so the test needs a daemon listening there.
rsyslogd -n >/dev/null 2>&1 &
local rsyslog_pid=$!
sleep 2
if [ ! -S /dev/log ]; then
echo -e " ${YELLOW}⊘${NC} no /dev/log socket, skipping"
kill "$rsyslog_pid" 2>/dev/null || true
return
fi
userdel -r audit_user 2>/dev/null || true
assert_ok "useradd -m audit_user" useradd -m audit_user
assert_ok "userdel -r audit_user" userdel -r audit_user
sleep 1
local log=/var/log/auth.log
[ -f "$log" ] || log=/var/log/syslog
assert_ok "syslog received the account creation" \
bash -c "grep -q 'op=ADD_USER.*acct=\"audit_user\".*res=success' $log"
assert_ok "syslog received the account deletion" \
bash -c "grep -q 'op=DEL_USER.*acct=\"audit_user\"' $log"
assert_ok "the record is tagged shadow-rs" \
bash -c "grep -q 'shadow-rs\[[0-9]*\]:.*op=ADD_USER' $log"
kill "$rsyslog_pid" 2>/dev/null || true
}
# ── --root is a real chroot ─────────────────────────────────────────
test_root_option() {
section "--root is a real chroot"
# A minimal target tree. The point of --root is that absolute paths read
# out of the records resolve inside it too, so the home directory must be
# created here and not on the host.
local tree
tree=$(mktemp -d)
mkdir -p "$tree/etc" "$tree/home" "$tree/etc/skel"
printf 'root:x:0:0:root:/root:/bin/sh\n' >"$tree/etc/passwd"
printf 'root:!:19000:0:99999:7:::\n' >"$tree/etc/shadow"
printf 'root:x:0:\n' >"$tree/etc/group"
printf 'root:!::\n' >"$tree/etc/gshadow"
printf 'UID_MIN 1000\nGID_MIN 1000\n' >"$tree/etc/login.defs"
echo 'from the tree skeleton' >"$tree/etc/skel/.profile"
assert_ok "useradd -R creates the account in the tree" \
useradd -R "$tree" -m rooted_user
assert_file_contains "the record is in the tree" \
"$tree/etc/passwd" '^rooted_user:'
assert_ok "the home is in the tree, not on the host" \
test -d "$tree/home/rooted_user"
assert_ok "the skeleton came from inside the tree" \
bash -c "grep -q 'from the tree skeleton' '$tree/home/rooted_user/.profile'"
assert_ok "the host was not touched" \
bash -c "! test -e /home/rooted_user && ! grep -q '^rooted_user:' /etc/passwd"
assert_ok "usermod -R edits the account in the tree" \
usermod -R "$tree" -c 'Rooted User' rooted_user
assert_file_contains "usermod wrote inside the tree" \
"$tree/etc/passwd" 'rooted_user:.*Rooted User'
assert_ok "groupadd -R adds a group in the tree" groupadd -R "$tree" rooted_grp
assert_file_contains "the group is in the tree" "$tree/etc/group" '^rooted_grp:'
assert_ok "groupmod -R renames it" groupmod -R "$tree" -n rooted_grp2 rooted_grp
assert_ok "groupdel -R removes it" groupdel -R "$tree" rooted_grp2
assert_ok "the group is gone from the tree" \
bash -c "! grep -q '^rooted_grp' '$tree/etc/group'"
assert_ok "no group leaked onto the host" \
bash -c "! grep -q '^rooted_grp' /etc/group"
# pwck and grpck have no --prefix, matching GNU, so --root is the only way
# to point them at another tree.
assert_ok "grpck -r reads the tree" grpck -r -R "$tree"
assert_ok "pwck -r reads the tree" \
bash -c "rc=\$(pwck -r -R '$tree' >/dev/null 2>&1; echo \$?); [ \"\$rc\" -le 2 ]"
assert_ok "userdel -r -R removes the account and its home in the tree" \
userdel -R "$tree" -r rooted_user
assert_ok "the home is gone from the tree" \
bash -c "! test -e '$tree/home/rooted_user'"
rm -rf "$tree"
}
# ── gpasswd as a group administrator ────────────────────────────────
test_gpasswd_group_admin() {
section "gpasswd as a group administrator"
# The reason gpasswd is setuid: someone who is not root, but is named an
# administrator of one group, manages that group's membership. Nothing
# else in this suite exercises a non-root caller of gpasswd, and the
# multicall binary drops privilege for every applet not on its setuid
# list -- so leaving gpasswd off that list fails exactly here, and
# nowhere else.
userdel -r gp_admin 2>/dev/null || true
userdel -r gp_member 2>/dev/null || true
groupdel gp_team 2>/dev/null || true
assert_ok "useradd -m gp_admin" useradd -m gp_admin
assert_ok "useradd -m gp_member" useradd -m gp_member
assert_ok "groupadd gp_team" groupadd gp_team
assert_ok "gpasswd -A gp_admin gp_team" gpasswd -A gp_admin gp_team
assert_file_contains "gp_admin is recorded as an administrator" \
/etc/gshadow '^gp_team:[^:]*:gp_admin:'
assert_ok "an administrator adds a member without being root" \
su -s /bin/bash gp_admin -c "gpasswd -a gp_member gp_team"
assert_file_contains "the member is in /etc/group" \
/etc/group '^gp_team:.*:gp_member'
assert_file_contains "and in /etc/gshadow" \
/etc/gshadow '^gp_team:.*:gp_member'
assert_fail "a plain member may not change the membership" \
su -s /bin/bash gp_member -c "gpasswd -a gp_admin gp_team"
assert_fail "a plain member may not appoint administrators" \
su -s /bin/bash gp_member -c "gpasswd -A gp_member gp_team"
assert_fail "a plain member may not use --root" \
su -s /bin/bash gp_member -c "gpasswd --root / -a gp_member gp_team"
# The refusals must change nothing.
assert_file_contains "the administrator list is unchanged" \
/etc/gshadow '^gp_team:[^:]*:gp_admin:'
groupdel gp_team 2>/dev/null || true
userdel -r gp_admin 2>/dev/null || true
userdel -r gp_member 2>/dev/null || true
}
# ── usermod subordinate ids, and expiry ────────────────────────────
test_subid_and_expiry() {
section "usermod -v/-V/-w/-W, and expiry"
userdel -r ex_user 2>/dev/null || true
assert_ok "useradd -m ex_user" useradd -m -s /bin/sh ex_user
assert_ok "set a password" bash -c "printf 'ex_user:oldpw\n' | chpasswd"
assert_ok "usermod -v grants a subuid range" usermod -v 500000-500999 ex_user
assert_file_contains "the range is in /etc/subuid" /etc/subuid '^ex_user:500000:1000$'
assert_ok "usermod -V of half of it splits the entry" usermod -V 500000-500499 ex_user
assert_file_contains "the remainder is what is left" /etc/subuid '^ex_user:500500:500$'
assert_fail "an invalid range is refused" usermod -v 9-1 ex_user
assert_ok "expiry -c is silent on a healthy account" \
bash -c "out=\$(su -s /bin/sh ex_user -c 'expiry -c'); test -z \"\$out\""
chage -M 30 -d "$(date -d '-27 days' +%Y-%m-%d)" ex_user
assert_contains "expiry warns inside the warning window" "Your password will expire in 3 days." \
su -s /bin/sh ex_user -c 'expiry -c'
if command -v python3 >/dev/null 2>&1; then
chage -M 1 -d 2000-01-01 ex_user
cat > /tmp/drive_expiry.py <<'PYDRV'
import os, pty, select, sys, time
pid, fd = pty.fork()
if pid == 0:
os.environ["TERM"] = "dumb"; os.execvp("su", ["su", "-s", "/bin/sh", "ex_user", "-c", "expiry -f; echo RC=$?"])
buf = b""; t0 = time.time(); i = 0
steps = [("urrent password:", "oldpw"), ("ew password:", "N3w-Passw0rd-e2e"), ("etype new password:", "N3w-Passw0rd-e2e")]
while time.time() - t0 < 20 and b"RC=" not in buf:
r, _, _ = select.select([fd], [], [], 0.2)
if r:
try: buf += os.read(fd, 4096)
except OSError: break
if i < len(steps) and steps[i][0].encode() in buf:
os.write(fd, steps[i][1].encode() + b"\n"); buf = buf.replace(steps[i][0].encode(), b"", 1); i += 1
try: os.kill(pid, 9)
except ProcessLookupError: pass
os.waitpid(pid, 0)
sys.stdout.write(buf.decode(errors="replace"))
PYDRV
before=$(grep '^ex_user:' /etc/shadow | cut -d: -f2)
assert_contains "expiry -f on an expired password forces the change" "RC=0" python3 /tmp/drive_expiry.py
assert_ok "and the hash changed" \
bash -c "test \"\$(grep '^ex_user:' /etc/shadow | cut -d: -f2)\" != \"$before\""
rm -f /tmp/drive_expiry.py
fi
chage -E 2000-01-01 ex_user
assert_fail "expiry refuses an expired account" su -s /bin/sh ex_user -c 'expiry -c'
chage -E -1 ex_user
userdel -r ex_user 2>/dev/null || true
sed -i '/^ex_user:/d' /etc/subuid /etc/subgid
}
# ── newuidmap / newgidmap: id maps for a user namespace ────────────
test_idmap() {
section "newuidmap / newgidmap — id maps for a user namespace"
userdel -r im_user 2>/dev/null || true
assert_ok "useradd -M im_user" useradd -M im_user
printf 'im_user:200000:65536\n' >> /etc/subuid
printf 'im_user:200000:65536\n' >> /etc/subgid
assert_fail "no operands is a usage error" newuidmap
assert_contains "an operand count that is not triples is reported" "ranges:" \
bash -c "newuidmap 1 0 200000 2>&1; true"
assert_contains "a zero count is the overflow message" "subuid overflow detected." \
bash -c "newuidmap 1 0 200000 0 2>&1; true"
assert_contains "no such process" "Could not open proc directory" \
bash -c "newuidmap 4194304 0 0 1 2>&1; true"
if ! su -s /bin/sh im_user -c 'unshare -U true' 2>/dev/null; then
echo -e " ${YELLOW}⊘${NC} user namespaces not available here, skipping the live checks"
userdel im_user 2>/dev/null || true
return
fi
# A namespace owned by im_user, kept alive while the checks run.
su -s /bin/sh im_user -c 'unshare -U sh -c "echo \$\$ > /tmp/im_pid; sleep 30"' &
sleep 1; pid=$(cat /tmp/im_pid)
uid=$(id -u im_user); gid=$(id -g im_user)
assert_contains "a range outside the grant is refused by name" "not allowed" \
su -s /bin/sh im_user -c "newuidmap $pid 0 300000 10 2>&1; true"
assert_contains "overlapping ranges are refused by name" "overlap" \
su -s /bin/sh im_user -c "newuidmap $pid 0 200000 10 5 200100 10 2>&1; true"
assert_contains "someone else's process is refused" "owned by a different user" \
su -s /bin/sh im_user -c "newuidmap 1 0 $uid 1 2>&1; true"
assert_ok "the owner maps their own uid once" \
su -s /bin/sh im_user -c "newuidmap $pid 0 $uid 1"
assert_ok "and the kernel shows it" \
bash -c "test \"\$(tr -s ' ' < /proc/$pid/uid_map | sed 's/^ //')\" = '0 $uid 1'"
assert_fail "a second write is refused" \
su -s /bin/sh im_user -c "newuidmap $pid 0 $uid 1"
echo deny > /proc/$pid/setgroups
assert_ok "the owner maps their own gid once" \
su -s /bin/sh im_user -c "newgidmap $pid 0 $gid 1"
assert_ok "and the kernel shows that too" \
bash -c "test \"\$(tr -s ' ' < /proc/$pid/gid_map | sed 's/^ //')\" = '0 $gid 1'"
kill $pid 2>/dev/null; wait 2>/dev/null
sed -i '/^im_user:/d' /etc/subuid /etc/subgid
userdel im_user 2>/dev/null || true
}
# ── login: a session on a terminal ─────────────────────────────────
test_login() {
section "login — a session on a terminal"
if ! command -v python3 >/dev/null 2>&1; then
echo -e " ${YELLOW}⊘${NC} python3 not available, skipping the pty-driven checks"
return
fi
userdel -r lg_e2e 2>/dev/null || true
assert_ok "useradd -m lg_e2e" useradd -m -s /bin/sh lg_e2e
assert_ok "set a password" bash -c "printf 'lg_e2e:e2epw\n' | chpasswd"
# Drive login the way getty would: on a fresh pty, as session leader,
# answering each prompt only once it has appeared.
cat > /tmp/drive_login.py <<'PYDRV'
import os, pty, select, sys, time
def run(argv, steps, timeout=40):
pid, fd = pty.fork()
if pid == 0:
os.environ["TERM"] = "dumb"; os.execvp(argv[0], argv)
buf = b""; i = 0; t0 = time.time(); seen = b""
while time.time() - t0 < timeout:
r, _, _ = select.select([fd], [], [], 0.2)
if r:
try:
chunk = os.read(fd, 4096)
except OSError:
break
buf += chunk; seen += chunk
if i < len(steps) and steps[i][0].encode() in buf:
os.write(fd, steps[i][1].encode() + b"\n"); buf = b""; i += 1
if i == len(steps) and steps and steps[-1][1].endswith("exit"):
done, _ = os.waitpid(pid, os.WNOHANG)
if done:
break
try:
os.kill(pid, 9)
except ProcessLookupError:
pass
try:
os.waitpid(pid, 0)
except ChildProcessError:
pass
sys.stdout.write(seen.decode(errors="replace"))
case = sys.argv[1]
if case == "autologin":
run(["login", "-f", "lg_e2e"], [("$ ", "printf 'O''UT %s %s %s\\n' \"$(id -un)\" \"$HOME\" \"$0\"; exit")])
elif case == "password":
run(["login", "-H"], [("login: ", "lg_e2e"), ("assword:", "wrong"), ("login: ", "lg_e2e"), ("assword:", "e2epw"), ("$ ", "printf 'O''UT %s\\n' \"$(id -un)\"; exit")])
elif case == "unknown":
run(["login", "-H"], [("login: ", "nobody_here_e2e"), ("assword:", "x")], timeout=12)
PYDRV
assert_contains "autologin gives a login shell as the user, in the home" \
"OUT lg_e2e /home/lg_e2e -sh" python3 /tmp/drive_login.py autologin
assert_contains "a wrong password is refused and the right one accepted" \
"OUT lg_e2e" python3 /tmp/drive_login.py password
assert_contains "an unknown user gets the same answer as a wrong password" \
"Login incorrect" python3 /tmp/drive_login.py unknown
assert_fail "login refuses to run as a normal user" \
su -s /bin/bash lg_e2e -c "login -f root"
assert_fail "login refuses without a terminal" bash -c "login -f root </dev/null"
userdel -r lg_e2e 2>/dev/null || true
rm -f /tmp/drive_login.py
}
# ── pwconv family: shadow round trips ──────────────────────────────
test_pwconv_family() {
section "pwconv / pwunconv / grpconv / grpunconv — shadow round trips"
userdel -r pc_alice 2>/dev/null || true
userdel -r pc_outsider 2>/dev/null || true
groupdel pc_team 2>/dev/null || true
assert_ok "useradd -m pc_alice" useradd -m pc_alice
assert_ok "useradd -m pc_outsider" useradd -m pc_outsider
assert_ok "groupadd pc_team" groupadd pc_team
assert_ok "set a group password" bash -c "printf 'pc_team:teampw\n' | chgpasswd"
assert_ok "set a user password" bash -c "printf 'pc_alice:userpw\n' | chpasswd"
grep '^pc_alice:' /etc/shadow | cut -d: -f2 > /tmp/pc_hash.before
cp -p /etc/shadow /tmp/pc_shadow.before
assert_ok "pwunconv" pwunconv
assert_ok "the shadow file is gone" bash -c "! test -e /etc/shadow"
assert_ok "the hash moved into /etc/passwd" \
bash -c "grep '^pc_alice:' /etc/passwd | cut -d: -f2 | diff -q - /tmp/pc_hash.before"
assert_ok "pwconv" pwconv
assert_file_contains "/etc/passwd holds x again" /etc/passwd '^pc_alice:x:'
assert_ok "the hash is back in /etc/shadow, byte for byte" \
bash -c "grep '^pc_alice:' /etc/shadow | cut -d: -f2 | diff -q - /tmp/pc_hash.before"
assert_ok "the recreated shadow file is 0640 root:shadow" \
bash -c "test \"\$(stat -c '%a %U %G' /etc/shadow)\" = '640 root shadow'"
# The cross-tool proof: passwd still reads the account's state, and the
# aging that pwconv set from login.defs is what chage reports.
assert_contains "passwd -S still sees a set password" " P " passwd -S pc_alice
assert_ok "pwconv on a consistent system changes nothing" \
bash -c "cp -p /etc/shadow /tmp/pc_s1 && pwconv && cmp -s /etc/shadow /tmp/pc_s1"
# And the group side, with sg as the witness: a non-member must still be
# able to enter the group with the password after the round trip.
assert_ok "grpunconv" grpunconv
assert_ok "the gshadow file is gone" bash -c "! test -e /etc/gshadow"
assert_ok "grpconv" grpconv
assert_ok "the recreated gshadow file is 0640 root:shadow" \
bash -c "test \"\$(stat -c '%a %U %G' /etc/gshadow)\" = '640 root shadow'"
assert_contains "sg accepts the group password after the round trip" "pc_team" \
bash -c "echo teampw | su -s /bin/bash pc_outsider -c \"sg pc_team -c 'id -gn'\""
assert_fail "and still refuses a wrong one" \
bash -c "echo wrong | su -s /bin/bash pc_outsider -c \"sg pc_team -c 'id -gn'\""
assert_fail "an operand is a usage error" pwconv extra
userdel -r pc_alice 2>/dev/null || true
userdel -r pc_outsider 2>/dev/null || true
groupdel pc_team 2>/dev/null || true
rm -f /tmp/pc_hash.before /tmp/pc_shadow.before /tmp/pc_s1
}
# ── vipw / vigr: hand edits under the lock ─────────────────────────
test_vipw() {
section "vipw / vigr — hand edits under the lock"
userdel -r vp_alice 2>/dev/null || true
userdel -r vp_racer 2>/dev/null || true
groupdel vp_team 2>/dev/null || true
rm -f /etc/passwd.edit /etc/group.edit
# An "editor" that appends a line, and one that sleeps so a race can be
# arranged. Both come back within the same second as the copy was made,
# which the GNU tool would misread as "unchanged".
cat > /usr/local/bin/ed-append <<'E'
#!/bin/sh
printf '%s\n' "$APPEND" >> "$1"
E
cat > /usr/local/bin/ed-slow <<'E'
#!/bin/sh
sleep 3
printf '%s\n' "$APPEND" >> "$1"
E
chmod +x /usr/local/bin/ed-append /usr/local/bin/ed-slow
assert_ok "vipw installs an edit" \
env APPEND='vp_alice:x:5601:5601::/home/vp_alice:/bin/sh' EDITOR=ed-append vipw -q
assert_file_contains "the line is in /etc/passwd" /etc/passwd '^vp_alice:x:5601:'
assert_ok "the mode of /etc/passwd is intact" \
bash -c "test \"\$(stat -c %a /etc/passwd)\" = 644"
assert_ok "no working copy is left behind" bash -c "! test -e /etc/passwd.edit"
assert_ok "vigr installs an edit" \
env APPEND='vp_team:x:5602:vp_alice' EDITOR=ed-append vigr -q
assert_file_contains "the group is in /etc/group" /etc/group '^vp_team:x:5602:vp_alice'
assert_ok "the mode of /etc/shadow survives vipw -s" \
bash -c "EDITOR=true vipw -s -q && test \"\$(stat -c %a /etc/shadow)\" = 640"
# The reason the tool exists: while the editor is open, another tool that
# wants the same file waits, and both changes land.
APPEND='vp_racer_placeholder:x:5603:5603:::' EDITOR=ed-slow vipw -q &
sleep 1
assert_ok "useradd waits for the lock rather than failing" useradd -M vp_racer
wait
assert_file_contains "the hand edit landed" /etc/passwd '^vp_racer_placeholder:'
assert_file_contains "and so did the concurrent useradd" /etc/passwd '^vp_racer:'
# A result the suite cannot parse is refused and the edit kept.
assert_fail "an unparseable result is refused" \
env APPEND='this has no colons' EDITOR=ed-append vipw -q
assert_file_not_contains "the live file is untouched" /etc/passwd 'no colons'
assert_ok "the edit is kept beside the file" \
bash -c "grep -q 'no colons' /etc/passwd.edit"
rm -f /etc/passwd.edit
assert_fail "an editor that fails installs nothing" \
env EDITOR=false vipw -q
userdel -r vp_racer 2>/dev/null || true
userdel vp_racer_placeholder 2>/dev/null || true
userdel -r vp_alice 2>/dev/null || true
groupdel vp_team 2>/dev/null || true
rm -f /usr/local/bin/ed-append /usr/local/bin/ed-slow
}
# ── newusers: batch account creation ───────────────────────────────
test_newusers() {
section "newusers — batch account creation"
for u in nu_alice nu_bob nu_svc; do userdel -r $u 2>/dev/null || true; done
groupdel nu_shared 2>/dev/null || true
assert_ok "groupadd nu_shared" groupadd nu_shared
assert_ok "a batch of three accounts applies" \
bash -c "printf 'nu_alice:pw1:::Alice:/home/nu_alice:/bin/bash\nnu_bob:pw2::nu_shared:Bob:/home/nu_bob:/bin/sh\nnu_svc:pw3:::Service::/usr/sbin/nologin\n' | newusers"
assert_file_contains "nu_alice is in passwd" /etc/passwd '^nu_alice:x:'
assert_file_contains "nu_alice has a hash in shadow" /etc/shadow '^nu_alice:\$'
assert_file_contains "nu_alice got a group of her own" /etc/group '^nu_alice:'
assert_ok "nu_alice has a home" test -d /home/nu_alice
assert_ok "the skeleton reached it" bash -c "ls -A /home/nu_alice | grep -q ."
assert_ok "the home belongs to her" \
bash -c "test \"\$(stat -c %U /home/nu_alice)\" = nu_alice"
assert_ok "and is private" \
bash -c "test \"\$(stat -c %a /home/nu_alice)\" = 700"
# A named group in the gid field is used rather than a new one invented.
assert_ok "nu_bob landed in the named group" \
bash -c "test \"\$(id -gn nu_bob)\" = nu_shared"
# An empty home field means no directory at all.
assert_ok "nu_svc has no home directory" bash -c "! test -e /home/nu_svc"
# The default scheme is the host's, from login.defs, not a hard-coded one.
assert_file_contains "the default scheme follows login.defs (yescrypt here)" \
/etc/shadow '^nu_alice:\$y\$'
# And the hash has to actually encode the password supplied, not merely
# look like a hash. Re-hash the plaintext with the salt taken from the
# stored field and compare -- a tool that wrote a well-formed hash of the
# wrong string passes every shape check above and fails this one. Done with
# -c SHA512 because openssl can recompute that one.
userdel -r nu_check 2>/dev/null || true
assert_ok "an account hashed with SHA512" \
bash -c "printf 'nu_check:checkpw:::::\n' | newusers -c SHA512"
assert_ok "the stored hash really encodes the supplied password" \
bash -c 'stored=$(grep "^nu_check:" /etc/shadow | cut -d: -f2); \
salt=$(printf "%s" "$stored" | cut -d"$" -f3); \
test "$stored" = "$(openssl passwd -6 -salt "$salt" checkpw)"'
assert_fail "and does not encode a different one" \
bash -c 'stored=$(grep "^nu_check:" /etc/shadow | cut -d: -f2); \
salt=$(printf "%s" "$stored" | cut -d"$" -f3); \
test "$stored" = "$(openssl passwd -6 -salt "$salt" wrongpw)"'
userdel -r nu_check 2>/dev/null || true
# All or nothing.
for u in nu_x nu_y; do userdel -r $u 2>/dev/null || true; done
assert_fail "a batch with a bad line fails" \
bash -c "printf 'nu_x:pw:::::\nbadline\n' | newusers"
assert_ok "and created nothing" bash -c "! grep -q '^nu_x:' /etc/passwd"
assert_fail "an empty password is refused" \
bash -c "printf 'nu_y::::::\n' | newusers"
assert_ok "leaving no account behind" bash -c "! grep -q '^nu_y:' /etc/passwd"
assert_ok "empty input succeeds having done nothing" \
bash -c "newusers < /dev/null"
for u in nu_alice nu_bob nu_svc; do userdel -r $u 2>/dev/null || true; done
groupdel nu_shared 2>/dev/null || true
}
# ── chgpasswd: group passwords in batch ────────────────────────────
test_chgpasswd() {
section "chgpasswd — group passwords in batch"
groupdel cg_one 2>/dev/null || true
groupdel cg_two 2>/dev/null || true
assert_ok "groupadd cg_one" groupadd cg_one
assert_ok "groupadd cg_two" groupadd cg_two
assert_ok "a batch of two group passwords applies" \
bash -c "printf 'cg_one:first\ncg_two:second\n' | chgpasswd"
assert_file_contains "cg_one got a hash in gshadow" \
/etc/gshadow '^cg_one:\$'
assert_file_contains "cg_two got a hash in gshadow" \
/etc/gshadow '^cg_two:\$'
assert_file_contains "/etc/group keeps the placeholder" \
/etc/group '^cg_one:x:'
# The password has to be the one a non-member is actually checked against,
# or the tool has written something decorative.
userdel -r cg_outsider 2>/dev/null || true
assert_ok "useradd -m cg_outsider" useradd -m cg_outsider
assert_contains "sg accepts the password chgpasswd set" "cg_one" \
bash -c "echo first | su -s /bin/bash cg_outsider -c \"sg cg_one -c 'id -gn'\""
assert_fail "and refuses a wrong one" \
bash -c "echo wrong | su -s /bin/bash cg_outsider -c \"sg cg_one -c 'id -gn'\""
# All or nothing: one unknown group and the whole batch is discarded.
# The line is compared through a file: a hash is full of dollar signs, and
# carrying one through a shell variable expands them away.
grep '^cg_two:' /etc/gshadow > /tmp/cg_two.before
assert_fail "a batch naming an unknown group fails" \
bash -c "printf 'cg_two:changed\nnosuchgroup:x\n' | chgpasswd"
assert_ok "and changed nothing" \
bash -c "grep '^cg_two:' /etc/gshadow | diff -q - /tmp/cg_two.before"
assert_ok "-e stores a field verbatim" \
bash -c "printf 'cg_one:\$6\$salt\$hash\n' | chgpasswd -e"
assert_file_contains "the verbatim field is in gshadow" \
/etc/gshadow '^cg_one:\$6\$salt\$hash:'
assert_ok "empty input succeeds having done nothing" \
bash -c "chgpasswd < /dev/null"
userdel -r cg_outsider 2>/dev/null || true
groupdel cg_one 2>/dev/null || true
groupdel cg_two 2>/dev/null || true
}
# ── sg: running one command in another group ───────────────────────
test_sg_group_switch() {
section "sg — running one command in another group"
# sg is setuid for the same reason newgrp is: a caller who is not a member
# of the target group has to be checked against the group password in
# /etc/gshadow, which is root-only. Nothing else in this suite runs sg as
# an unprivileged user, so dropping sg from the setuid list fails here and
# nowhere else.
userdel -r sg_member 2>/dev/null || true
userdel -r sg_outsider 2>/dev/null || true
groupdel sg_team 2>/dev/null || true
groupdel sg_locked 2>/dev/null || true
assert_ok "useradd -m sg_member" useradd -m sg_member
assert_ok "useradd -m sg_outsider" useradd -m sg_outsider
assert_ok "groupadd sg_team" groupadd sg_team
assert_ok "groupadd sg_locked" groupadd sg_locked
assert_ok "gpasswd -a sg_member sg_team" gpasswd -a sg_member sg_team
assert_contains "a member runs a command in the group" "sg_team" \
su -s /bin/bash sg_member -c "sg sg_team -c 'id -gn'"
assert_contains "-c is optional" "sg_team" \
su -s /bin/bash sg_member -c "sg sg_team 'id -gn'"
# sg changes the group, not the user: anything else would make it su.
assert_contains "the caller keeps their own identity" "sg_member" \
su -s /bin/bash sg_member -c "sg sg_team -c 'id -un'"
# The group the caller started in must still be reachable afterwards, or
# the switch would cost them access to their own files.
assert_contains "the original primary group survives the switch" "sg_member" \
su -s /bin/bash sg_member -c "sg sg_team -c 'id -Gn'"
# sg execs the command, so its status is the caller's status.
assert_ok "the command's exit status reaches the caller" \
bash -c "su -s /bin/bash sg_member -c \"sg sg_team -c 'exit 7'\"; test \$? -eq 7"
# The setuid path: a non-member authenticating against the group password.
printf 'teampw\nteampw\n' | gpasswd sg_team >/dev/null 2>&1
assert_contains "a non-member with the group password gets in" "sg_team" \
bash -c "echo teampw | su -s /bin/bash sg_outsider -c \"sg sg_team -c 'id -gn'\""
assert_fail "a wrong group password is refused" \
bash -c "echo wrong | su -s /bin/bash sg_outsider -c \"sg sg_team -c 'id -gn'\""
# A group with no password admits nobody who is not already a member.
assert_fail "a non-member is refused a group with no password" \
bash -c "echo anything | su -s /bin/bash sg_outsider -c \"sg sg_locked -c 'id -gn'\""
assert_fail "an unknown group is refused" \
su -s /bin/bash sg_member -c "sg no_such_group_at_all -c true"
groupdel sg_team 2>/dev/null || true
groupdel sg_locked 2>/dev/null || true
userdel -r sg_member 2>/dev/null || true
userdel -r sg_outsider 2>/dev/null || true
}
# ── nscd cache invalidation ────────────────────────────────────────
test_nscd() {
section "nscd cache invalidation"
# Start nscd (needs /var/run/nscd directory and /var/db/nscd)
mkdir -p /var/run/nscd /var/db/nscd 2>/dev/null || true
nscd -d >/dev/null 2>&1 &
sleep 2
if ! pgrep -x nscd >/dev/null 2>&1; then
echo -e " ${YELLOW}⊘${NC} nscd could not be started, skipping"
PASS=$((PASS + 1))
return
fi
assert_ok "nscd is running" pgrep -x nscd
# Create user and verify getent picks it up
userdel nscd_user 2>/dev/null || true
assert_ok "useradd -m nscd_user" useradd -m nscd_user
assert_contains "getent finds new user" "nscd_user" \
getent passwd nscd_user
# Delete user and verify getent no longer finds it
assert_ok "userdel -r nscd_user" userdel -r nscd_user
sleep 1
assert_fail "getent no longer finds deleted user" \
getent passwd nscd_user
# Stop nscd
killall nscd 2>/dev/null || true
}
# ── Landlock sandboxing ─────────────────────────────────────────────
test_landlock() {
section "Landlock sandboxing"
# Check if kernel supports Landlock
if [ -f /sys/kernel/security/landlock/abi_version ]; then
local abi_version
abi_version=$(cat /sys/kernel/security/landlock/abi_version)
echo -e " ${GREEN}✓${NC} Landlock ABI version: $abi_version"
PASS=$((PASS + 1))
# passwd should work under Landlock restriction
userdel -r landlock_user 2>/dev/null || true
assert_ok "useradd -m landlock_user" useradd -m landlock_user
assert_ok "passwd -S works under Landlock" \
passwd -S landlock_user
# The mutation path spawns nscd/logger and rewrites the file inside
# the sandbox; both have to be allowed by the rule set.
assert_ok "passwd -l works under Landlock" passwd -l landlock_user
assert_file_contains "lock applied under Landlock" \
/etc/shadow '^landlock_user:!'
assert_ok "passwd -u works under Landlock" passwd -u landlock_user
assert_ok "passwd -x 90 works under Landlock" passwd -x 90 landlock_user
assert_ok "unprivileged passwd -S works under Landlock" \
su -s /bin/bash testrunner -c "passwd -S testrunner"
userdel -r landlock_user 2>/dev/null || true
else
echo -e " ${YELLOW}⊘${NC} Landlock not available (kernel too old), skipping"
PASS=$((PASS + 1))
fi
}
# ── Ansible integration ────────────────────────────────────────────
test_ansible() {
section "Ansible integration"
if command -v ansible-playbook >/dev/null 2>&1; then
# Clean up from any previous failed run
userdel -r ansibleuser 2>/dev/null || true
groupdel ansiblegroup 2>/dev/null || true
assert_ok "ansible-playbook runs successfully" \
ansible-playbook -c local -i "localhost," /tests/e2e/ansible-test.yml
else
echo -e " ${YELLOW}⊘${NC} ansible-playbook not found, skipping"
PASS=$((PASS + 1))
fi
}
# ── Main ────────────────────────────────────────────────────────────
main() {
echo -e "${BLUE}shadow-rs end-to-end deployment tests${NC}"
echo "Running as: $(whoami) ($(id -u))"
echo "Binary: $BINDIR/shadow-rs"
echo ""
if [ "$(id -u)" -ne 0 ]; then
echo -e "${RED}ERROR: must run as root${NC}"
exit 1
fi
preflight
test_symlink_dispatch
test_multicall_dispatch
test_setuid
test_user_lifecycle
test_group_lifecycle
test_individual_tools
test_pam_auth
test_self_service
test_gpasswd_group_admin
test_sg_group_switch
test_chgpasswd
test_newusers
test_vipw
test_pwconv_family
test_login
test_idmap
test_subid_and_expiry
test_aging_and_input
test_audit_logging
test_root_option
test_nscd
test_landlock
test_ansible
echo ""
echo -e "${BLUE}── Results ──${NC}"
echo -e " ${GREEN}Passed: $PASS${NC}"
if [ "$FAIL" -gt 0 ]; then
echo -e " ${RED}Failed: $FAIL${NC}"
echo ""
echo -e "${RED}SOME TESTS FAILED${NC}"
exit 1
else
echo -e " ${RED}Failed: 0${NC}"
echo ""
echo -e "${GREEN}ALL TESTS PASSED${NC}"
exit 0
fi
}
main "$@"