mirror of
https://github.com/uutils/shadow.git
synced 2026-10-16 02:48:14 -07:00
* gpasswd: added implementation of gpasswd
* gpasswd: rebase onto main and port to the current APIs
Rebases jlesage's implementation onto a main that moved a long way under
it, and carries it onto the interfaces that landed since the branch was
cut. The tool's behaviour is unchanged and still matches GNU on every
path probed.
What the rebase had to resolve:
- shadow-rs.spec and debian/ were revived by the merge; both were
deleted because neither could build.
- The standalone dispatch() and hand-written --list predate the single
applets() table, which now drives both. gpasswd joins the table.
- The five shadow-core parser features it asked for no longer exist.
- The fourteen [[test]] targets are one; test_gpasswd joins it.
- README, Makefile and PLATFORM-SUPPORT described the pre-#239
privilege model, in which every applet ran with euid 0.
What changed beyond the rebase:
- gpasswd keeps its privilege in the multicall build. It is setuid in
GNU and the branch marks it setuid for the per-tool install, but
SETUID_APPLETS still listed four names, so the multicall build would
have dropped privilege and no group administrator could have used it
-- silently.
- Comments in /etc/group survive. It read entries only and wrote them
back, which erases every comment, blank line and NIS compatibility
line in the file. The locked-file transaction keeps them, and commits
group and gshadow together so a bad value cannot leave the pair
disagreeing.
- The duplicated EchoGuard and password reader are gone, replaced by
shadow_core::tty, which blocks interrupts for the read so Ctrl-C at
the prompt cannot leave the terminal with echo off.
- The print macros are replaced. They panic when the stream is closed,
which a setuid-root tool must not do part way through a change.
These were the only five in the repository and no lint caught them,
so clippy::print_stdout and print_stderr are now denied
workspace-wide, with the test crate exempt.
The GNU comparison suite gains gpasswd: the unknown-group, unknown-user
and non-member exit codes, all three of which match.
CONTRIBUTING's licence list also said ISC, BSD-2-Clause, CC0-1.0, Zlib
and MPL-2.0 were acceptable, which stopped being true when deny.toml was
narrowed to the graph. It now says what deny.toml enforces.
---------
Co-authored-by: Pierre Warnier <pierre@warnier.net>
242 lines
9.0 KiB
Bash
Executable File
242 lines
9.0 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# GNU compatibility suite for shadow-rs.
|
|
#
|
|
# Runs our tool and the GNU tool on the same input and compares what they
|
|
# produce. This is the only check that compares against GNU directly; the Rust
|
|
# suite asserts against expectations written down by hand, which can drift from
|
|
# what GNU actually does.
|
|
#
|
|
# It compares two different things, deliberately:
|
|
#
|
|
# * **Output**, where the format is a contract other software parses --
|
|
# `passwd -S` and `chage -l`. A difference here breaks scripts.
|
|
# * **Exit codes**, for the error paths. The messages are ours, but the codes
|
|
# are part of the interface.
|
|
#
|
|
# `--help` output is *not* compared: ours comes from clap and is intentionally
|
|
# different. Only that both accept the flag.
|
|
#
|
|
# Usage: docker compose run --rm debian bash tests/gnu-compat.sh
|
|
# Requires: root, and the GNU shadow package installed alongside our build.
|
|
|
|
set -uo pipefail
|
|
|
|
PASS=0
|
|
FAIL=0
|
|
|
|
RED='\033[0;31m'
|
|
GREEN='\033[0;32m'
|
|
NC='\033[0m'
|
|
|
|
pass() {
|
|
printf " ${GREEN}PASS${NC}: %s\n" "$1"
|
|
PASS=$((PASS + 1))
|
|
}
|
|
|
|
fail() {
|
|
printf " ${RED}FAIL${NC}: %s\n" "$1"
|
|
FAIL=$((FAIL + 1))
|
|
}
|
|
|
|
# Compare stdout+stderr of the two commands, byte for byte.
|
|
compare_output() {
|
|
local name="$1" ours="$2" gnu="$3"
|
|
local our_out gnu_out
|
|
our_out=$(eval "$ours" 2>&1)
|
|
gnu_out=$(eval "$gnu" 2>&1)
|
|
if [ "$our_out" = "$gnu_out" ]; then
|
|
pass "$name"
|
|
else
|
|
fail "$name"
|
|
diff <(printf '%s\n' "$gnu_out") <(printf '%s\n' "$our_out") \
|
|
| sed 's/^/ /' | head -20
|
|
fi
|
|
}
|
|
|
|
# Assert a difference we mean to have: our code, GNU's code, and why.
|
|
#
|
|
# A deliberate divergence still has to be watched. Left out of the suite it
|
|
# would be indistinguishable from a regression the day it changed by accident.
|
|
expect_difference() {
|
|
local name="$1" ours="$2" gnu="$3" want_our="$4" want_gnu="$5" why="$6"
|
|
local our_rc=0 gnu_rc=0
|
|
eval "$ours" >/dev/null 2>&1 || our_rc=$?
|
|
eval "$gnu" >/dev/null 2>&1 || gnu_rc=$?
|
|
if [ "$our_rc" = "$want_our" ] && [ "$gnu_rc" = "$want_gnu" ]; then
|
|
pass "$name (ours=$our_rc, GNU=$gnu_rc, by design: $why)"
|
|
else
|
|
fail "$name: expected ours=$want_our GNU=$want_gnu, got ours=$our_rc GNU=$gnu_rc"
|
|
fi
|
|
}
|
|
|
|
# Compare only the exit codes.
|
|
compare_exit() {
|
|
local name="$1" ours="$2" gnu="$3"
|
|
local our_rc=0 gnu_rc=0
|
|
eval "$ours" >/dev/null 2>&1 || our_rc=$?
|
|
eval "$gnu" >/dev/null 2>&1 || gnu_rc=$?
|
|
if [ "$our_rc" = "$gnu_rc" ]; then
|
|
pass "$name (exit $our_rc)"
|
|
else
|
|
fail "$name (ours=$our_rc, GNU=$gnu_rc)"
|
|
fi
|
|
}
|
|
|
|
# ── Setup ───────────────────────────────────────────────────────────
|
|
|
|
if [ "$(id -u)" -ne 0 ]; then
|
|
echo "error: this suite needs root; the GNU tools refuse otherwise" >&2
|
|
exit 1
|
|
fi
|
|
|
|
for gnu in /usr/bin/passwd /usr/bin/chage /usr/sbin/pwck /usr/sbin/grpck; do
|
|
if [ ! -x "$gnu" ]; then
|
|
echo "error: $gnu is missing; install the GNU shadow package" >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
cargo build --release --workspace --bins --exclude uu_shadow >/dev/null 2>&1 || {
|
|
echo "error: build failed" >&2
|
|
exit 1
|
|
}
|
|
RS=./target/release
|
|
|
|
# A throwaway account with known aging fields, so both tools describe exactly
|
|
# the same thing and the comparison is not at the mercy of whatever the image
|
|
# happens to contain.
|
|
PROBE=gnucompat_probe
|
|
/usr/sbin/userdel -r "$PROBE" >/dev/null 2>&1
|
|
/usr/sbin/useradd -M "$PROBE" >/dev/null 2>&1 || {
|
|
echo "error: cannot create the probe account" >&2
|
|
exit 1
|
|
}
|
|
# A throwaway group for the gpasswd comparisons.
|
|
PROBE_GRP=gnucompat_probegrp
|
|
/usr/sbin/groupdel "$PROBE_GRP" >/dev/null 2>&1
|
|
/usr/sbin/groupadd "$PROBE_GRP" >/dev/null 2>&1 || {
|
|
echo "error: cannot create the probe group" >&2
|
|
exit 1
|
|
}
|
|
cleanup() {
|
|
/usr/sbin/userdel -r "$PROBE" >/dev/null 2>&1
|
|
/usr/sbin/groupdel "$PROBE_GRP" >/dev/null 2>&1
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
# ── passwd -S ───────────────────────────────────────────────────────
|
|
#
|
|
# `passwd -S` is parsed by monitoring and provisioning scripts, so its seven
|
|
# fields and their formatting are a contract.
|
|
|
|
echo "=== passwd -S ==="
|
|
for args in \
|
|
"-d 2026-01-01 -m 0 -M 99999 -W 7" \
|
|
"-d 0 -m 5 -M 90 -W 14 -I 30" \
|
|
"-d -1 -m -1 -M -1 -W -1 -I -1" \
|
|
"-d 2026-01-01 -M 10000" \
|
|
"-d 2026-01-01 -M 9999 -E 2027-06-15"; do
|
|
# shellcheck disable=SC2086
|
|
/usr/bin/chage $args "$PROBE" >/dev/null 2>&1
|
|
compare_output "passwd -S after chage $args" \
|
|
"$RS/passwd -S $PROBE" "/usr/bin/passwd -S $PROBE"
|
|
done
|
|
|
|
compare_output "passwd -S root" "$RS/passwd -S root" "/usr/bin/passwd -S root"
|
|
|
|
# ── chage -l ────────────────────────────────────────────────────────
|
|
#
|
|
# The label column, the "password must be changed" wording and the threshold
|
|
# above which a maximum age reads "never" all have to match.
|
|
|
|
echo "=== chage -l ==="
|
|
for args in \
|
|
"-d 2026-01-01 -m 0 -M 99999 -W 7 -I -1 -E -1" \
|
|
"-d 0 -m 5 -M 90 -W 14 -I 30 -E 1970-01-01" \
|
|
"-d 2026-01-01 -M 9999 -I -1 -E -1" \
|
|
"-d 2026-01-01 -M 10000 -I -1 -E -1" \
|
|
"-d 2026-01-01 -M 90 -I 30 -E 2027-06-15" \
|
|
"-d -1 -m -1 -M -1 -W -1 -I -1 -E -1"; do
|
|
# shellcheck disable=SC2086
|
|
/usr/bin/chage $args "$PROBE" >/dev/null 2>&1
|
|
compare_output "chage -l after chage $args" \
|
|
"$RS/chage -l $PROBE" "/usr/bin/chage -l $PROBE"
|
|
done
|
|
|
|
# ── Exit codes on the error paths ───────────────────────────────────
|
|
#
|
|
# The messages are ours; the codes are the interface.
|
|
|
|
echo "=== exit codes ==="
|
|
compare_exit "passwd -S on an unknown login" \
|
|
"$RS/passwd -S no_such_user_9f3a" "/usr/bin/passwd -S no_such_user_9f3a"
|
|
compare_exit "chage -l on an unknown login" \
|
|
"$RS/chage -l no_such_user_9f3a" "/usr/bin/chage -l no_such_user_9f3a"
|
|
compare_exit "chage -M with a negative day count" \
|
|
"$RS/chage -M -5 $PROBE" "/usr/bin/chage -M -5 $PROBE"
|
|
|
|
compare_exit "chage -l combined with a field option" \
|
|
"$RS/chage -l -M 90 $PROBE" "/usr/bin/chage -l -M 90 $PROBE"
|
|
compare_exit "groupadd with no group name" \
|
|
"$RS/groupadd" "/usr/sbin/groupadd"
|
|
compare_exit "groupdel on an unknown group" \
|
|
"$RS/groupdel no_such_group_9f3a" "/usr/sbin/groupdel no_such_group_9f3a"
|
|
compare_exit "userdel on an unknown login" \
|
|
"$RS/userdel no_such_user_9f3a" "/usr/sbin/userdel no_such_user_9f3a"
|
|
compare_exit "gpasswd on an unknown group" \
|
|
"$RS/gpasswd no_such_group_9f3a </dev/null" "/usr/bin/gpasswd no_such_group_9f3a </dev/null"
|
|
compare_exit "gpasswd -a for an unknown user" \
|
|
"$RS/gpasswd -a no_such_user_9f3a $PROBE_GRP" "/usr/bin/gpasswd -a no_such_user_9f3a $PROBE_GRP"
|
|
compare_exit "gpasswd -d for a non-member" \
|
|
"$RS/gpasswd -d $PROBE $PROBE_GRP" "/usr/bin/gpasswd -d $PROBE $PROBE_GRP"
|
|
compare_exit "chpasswd -s without -c" \
|
|
"echo x:y | $RS/chpasswd -s 5000" "echo x:y | /usr/sbin/chpasswd -s 5000"
|
|
compare_exit "chpasswd -e with -c" \
|
|
"echo x:y | $RS/chpasswd -e -c SHA512" "echo x:y | /usr/sbin/chpasswd -e -c SHA512"
|
|
|
|
# ── Deliberate differences ──────────────────────────────────────────
|
|
|
|
echo "=== deliberate differences ==="
|
|
# GNU parses dates leniently and rolls them over: `chage -d 2025-02-29` stores
|
|
# 1 March, and `-d 2025-13-01` stores 1 January 2026. An administrator who
|
|
# typed one of those made a mistake, and silently storing a different date than
|
|
# the one they wrote is a poor answer for a field that governs when an account
|
|
# stops working. We refuse it instead; chage(1) in docs/man says so.
|
|
for date in 2025-02-29 2025-04-31 2025-13-01; do
|
|
expect_difference "chage -d $date" \
|
|
"$RS/chage -d $date $PROBE" "/usr/bin/chage -d $date $PROBE" \
|
|
2 0 "an impossible date is refused, not rolled over"
|
|
done
|
|
|
|
# Both must accept --help; the text itself is ours.
|
|
echo "=== --help is accepted ==="
|
|
for pair in \
|
|
"passwd:/usr/bin/passwd" \
|
|
"chage:/usr/bin/chage" \
|
|
"chfn:/usr/bin/chfn" \
|
|
"chsh:/usr/bin/chsh" \
|
|
"newgrp:/usr/bin/newgrp" \
|
|
"useradd:/usr/sbin/useradd" \
|
|
"userdel:/usr/sbin/userdel" \
|
|
"usermod:/usr/sbin/usermod" \
|
|
"groupadd:/usr/sbin/groupadd" \
|
|
"groupdel:/usr/sbin/groupdel" \
|
|
"groupmod:/usr/sbin/groupmod" \
|
|
"chpasswd:/usr/sbin/chpasswd" \
|
|
"gpasswd:/usr/bin/gpasswd" \
|
|
"pwck:/usr/sbin/pwck" \
|
|
"grpck:/usr/sbin/grpck"; do
|
|
tool="${pair%%:*}"
|
|
gnu="${pair#*:}"
|
|
compare_exit "$tool --help" "$RS/$tool --help" "$gnu --help"
|
|
done
|
|
|
|
# ── Results ─────────────────────────────────────────────────────────
|
|
|
|
|
|
echo ""
|
|
echo "=== Results ==="
|
|
printf " ${GREEN}PASS: %d${NC}\n" "$PASS"
|
|
printf " ${RED}FAIL: %d${NC}\n" "$FAIL"
|
|
[ "$FAIL" -eq 0 ]
|