40 Commits
Author SHA1 Message Date
renovate[bot]andPierre Warnier 64ac0752f0 chore(deps): update rust docker tag to v1.99 (#327)
* chore(deps): update rust docker tag to v1.99

* docs: the test matrix table follows the Rust image bump

Renovate bumps the three Dockerfiles; the table in PLATFORM-SUPPORT.md
names the same images and was still at 1.98.

* tests: show the value when asserting that something is empty

Rust 1.99's clippy adds assert_is_empty: a bare assert!(x.is_empty())
says only that the check failed, not what x held. Twenty-two test
assertions across the workspace were of that form; each now carries
the value in its message, which is what the lint is after and reads
better than its own suggestion of comparing against `[] as [T; 0]`.
Test code only.

The CI clippy job runs on the runner's toolchain, which has not reached
1.99 yet, so the pull request was green while `make check` in the
rebuilt image was not.

* tests: the expiry output is already a string

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Pierre Warnier <pierre@warnier.net>
2026-10-05 11:04:09 +02:00
Pierre Warnier b516b87be8 docs: the upstream suite is not GNU, and is mostly BSD-licensed (#322)
* docs: the upstream suite is not GNU, and is mostly BSD-licensed

The README called the original shadow-utils a GNU project and GPL-2.0+,
and CONTRIBUTING grounded the clean-room policy on that. Neither holds,
as #320 points out: shadow-maint/shadow is not a GNU project, and its
tree is mostly BSD-3-Clause with GPL-2.0-or-later files among it --
su.c, vipw.c, contributed scripts, some translated manuals. Checked
against Debian's copyright file for the package and Fedora's licence
tag, BSD-3-Clause AND GPL-2.0-or-later, not against the source.

The policy stands, on the accurate ground: this is an independent MIT
implementation that copies from neither part. Copying from the BSD part
would carry attribution terms this project does not take on; from the
GPL part, a derivative work; sorting files by licence would mean reading
them. So the rule is unchanged: do not read that tree.

Every "GNU" that meant upstream is now "upstream" or "the distribution's
package": README, CONTRIBUTING, Makefile, the man pages' section heading,
the e2e image and suite, test comments. make test-gnu-compat is make
test-upstream-compat and its script is renamed with it. The 2026-05-04
audit record gets an erratum at the top and is otherwise left as it was
run.

* docs: the last upstream-as-GNU mentions in the compat suite and the tool table

* tests: say upstream, not GNU, in the test comments and skip messages too

* tests: one more

* docs: keep the README's clean-room line as it was, reworded around it
2026-09-21 15:52:51 +02:00
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
Pierre Warnier aa987bb390 newuidmap, newgidmap: set the id maps of a user namespace (#298)
The setuid helpers that write /proc/<pid>/uid_map and gid_map: what Podman
and rootless Docker call for every container they start. The kernel lets an
unprivileged process map exactly one id into a namespace it created, its
own; everything a container needs beyond that takes the privilege these
carry, and they hand it out only within the ranges /etc/subuid and
/etc/subgid grant the caller. Fedora ships them in shadow-utils; with these
two, every tool the two reference distributions ship from the suite exists
here.

What is checked before anything is written was established by running the
GNU helpers against a real user namespace, never by reading them: the
operands must be a target and plain-decimal triples, a zero count is the
overflow message; every outside range must sit inside one granted range --
an entry by login name or by numeric uid -- or be the caller's own id mapped
once; root is exempt from neither, as newuidmap(1) says; and the target
process must belong to the caller by real uid, account uid and the owner of
/proc/<pid>, with all three named when they disagree. Overlapping ranges are
refused by name; the kernel would refuse them with a bare EINVAL, so the
message costs nothing and changes no accepted outcome.

The map file is opened through a descriptor held on /proc/<pid>, so a pid
recycled between the check and the write cannot redirect it, and the
`fd:N` form takes such a descriptor from a caller that did its own checks.
It is re-opened through /proc/self/fd rather than adopted, which keeps the
tool free of unsafe.

The tests do the real thing. A test user creates a namespace with unshare
and the helper runs as that user, unprivileged, against it: the own-id
mapping lands and the kernel's uid_map reads it back, the second write is
refused by the kernel and reported, the fd form works, and every refusal is
checked against the same live target. That runs on glibc and musl alike.

The Debian image gains the uidmap package so tests/gnu-compat.sh can hold
the helpers against GNU's. They take no --help -- any bad command line is
the usage text and exit 1 -- so the comparison is on the refusals the two
share rather than on help.

Verified: make check clean with the unprivileged run; 912 tests on alpine
and 934 on fedora with PAM; 393 e2e assertions against a real install, the
kernel-visible writes included; 57 GNU comparisons; 34 arm64 assertions
under emulation.
2026-09-07 15:46:36 +02:00
Pierre Warnier 4ac1bdfe32 login: begin a session on the system (#297)
* shadow-core: take /etc/.pwd.lock before the per-file lock

FileLock took the per-file `.lock` first and `/etc/.pwd.lock` second. Two
tools working on different files at once could then meet in the classic
shape: one holding passwd.lock and waiting for .pwd.lock, the other holding
shadow.lock and waiting for passwd.lock's holder to let go of .pwd.lock.
Neither can move; both sit out the fifteen-second timeout; both fail -- on a
system that was merely busy.

Sampling the lock files every 250 ms while the whole suite ran showed it
plainly: a chpasswd held shadow.lock for 28 s and a useradd held passwd,
group and gshadow for 14 s, each waiting on the other, and seven tests
failed with "timed out" on one lock or the other. Three runs after the
change: no failures, and the suite back from 35-50 s to 17 s.

.pwd.lock now comes first, the order lckpwdf(3) and the GNU tools use and
the only one that cannot deadlock: whoever holds it is the only process
asking for per-file locks, so it never waits for anyone who is waiting for
it. If the per-file lock then cannot be taken, the .pwd.lock reference is
given back, so nothing is held on failure.

* useradd: lock the shadow file with the others, and write it first

useradd wrote /etc/passwd and the group files in one transaction, then
opened /etc/shadow on its own afterwards -- under a comment claiming the
first locks were still held, which they were not: commit_all releases them.
A shadow lock that timed out at that point left the account half made: a
passwd line with no shadow line, which a second useradd then reported as
already existing. That is exactly what turned up when the login tests put
the suite under load.

The shadow file is now locked with passwd, group and gshadow before
anything is written, and written first, for the reason pwconv writes shadow
first: a failure between the writes must never leave a passwd line whose
hash is nowhere. The old write_shadow_entry helper stays for its unit test
only.

A regression test puts a directory where the shadow file should be and
checks that useradd fails with /etc/passwd untouched. Before this change the
passwd line was written and the failure came after.

* login: begin a session on the system

login is the twenty-fifth tool and the one uutils/login was archived
towards. It is what getty runs on a terminal: it prompts for a name and,
through the `login` PAM service, a password; refuses after LOGIN_RETRIES
failures or LOGIN_TIMEOUT seconds; opens the PAM session and takes its
environment; records the login in utmp and wtmp; hands the terminal to the
user; runs the login shell as the user; and when the shell exits, closes the
session and records the logout.

Which login to match was a question. Debian 13 and Ubuntu 25.10 take login
from util-linux, but every release in service -- Ubuntu 20.04, 22.04 and
24.04, Debian 12 -- ships shadow's; the two were run side by side on real
pseudo-terminals and agree on everything that matters. This implements the
option set they share (-p, -f, -h, name) plus util-linux's -H and --help,
which conflict with nothing. There is no -h for help: -h has been the
remote host since telnetd. -r, the rlogin protocol, is refused. util-linux's
vhangup is not reproduced; shadow's login, which the installed base runs,
does not do it either.

A wrong password and an unknown name get the same words, after the same
delay. -f skips authentication and with it the auth stack, pam_nologin
included, as both tools do. A missing home falls back to / with a notice
when DEFAULT_HOME allows. The shell runs as a child so the session can be
closed afterwards; it gets a clean signal mask, login keeps its own, and
groups, gid and uid are dropped in that order in one pre_exec.

shadow-core grew what login needed and nothing had: pam setcred,
open_session, close_session, user and environment; utmp/wtmp recording;
getpwnam through NSS; a getty-style spawn_with_controlling_tty; spawn_as_user;
and a watchdog. Without the pam feature the applet says PAM is not compiled
in and exits: a login that cannot authenticate is not a login, and the
static musl archive is documented accordingly.

The tests drive the real binary on a pseudo-terminal the way getty would,
answering each prompt once it appears: autologin, the environment with and
without -p, a wrong then a right password, an unknown user, the retry
limit, the missing home, and the terminal handover. Writing them under the
full parallel suite is what exposed the two fixes committed before this
one.

Verified: make check clean with the unprivileged run; 892 tests on alpine
and 882 on fedora; 368 e2e assertions against a real install, including the
password path on a pty; 52 GNU comparisons; 32 arm64 assertions under
emulation.

* docker: give the Fedora image the login PAM service

Fedora ships /etc/pam.d/login in util-linux, which the test image did not
install. With no service file PAM falls back to `other`, which on Fedora
denies everything: `login -f` failed its account check and the password
path was refused before any prompt, so every login test failed there and
only there.

util-linux is now installed, which is what any real Fedora has. The login
tests then run through Fedora's own stack -- pam_selinux, pam_loginuid,
pam_namespace -- and pass, which makes it the second PAM configuration the
session code is exercised against rather than a gap.
2026-09-07 15:45:29 +02:00
Pierre Warnier ea2c85cf6c pwconv, pwunconv, grpconv, grpunconv: convert to and from shadow files (#295)
* pwconv, pwunconv, grpconv, grpunconv: convert to and from shadow files

These four complete the set both Debian and Fedora ship: every tool the two
distributions agree on now exists here. pwconv moves password hashes out of
the world-readable /etc/passwd into /etc/shadow, creating it if need be, and
reconciles the two; pwunconv merges them back and removes /etc/shadow;
grpconv and grpunconv do the same for the group files. They are one engine
with a selector, and the other three crates are the selector.

The order of writes is the substance of the change. pwconv writes shadow
before passwd: the hashes are copied into the shadow file and only then
replaced by `x`, so a failure between the two writes leaves them where they
were rather than nowhere. pwunconv writes passwd before it removes shadow:
for a moment the hashes exist twice, which is recoverable, where the other
order would have a moment with none. The removal happens while the lock on
the shadow file is still held, so no other tool can slip in between the last
read of the file and its disappearance.

What pwconv does was established by running the GNU tool:

  - A hash found in passwd beside an existing shadow line is the newer of the
    two -- it got there through a tool or an edit that did not know about
    shadow -- so it replaces the shadow hash and the change is dated; the
    aging is kept.
  - A passwd line with no shadow line gets one, dated today, with the aging
    from PASS_MIN_DAYS, PASS_MAX_DAYS and PASS_WARN_AGE. The hash field is
    copied as it stands, an `x` included: an `x` in the hash field matches no
    password, which is the honest state of an account whose hash was lost.
  - A shadow line for an account no longer in passwd is dropped.
  - A consistent system is left byte for byte alone.

A shadow file this run creates is made 0640 root:shadow, the layout the
distributions ship, with the `shadow` group looked up in the tree being
converted rather than through the name service: under --prefix or --root the
host's shadow group says nothing about the target's. Where the tree has no
such group the file stays 0600 root, the atomic writer's default.

The e2e suite uses the other tools as witnesses rather than only reading
files back: after pwunconv and pwconv the hash is byte for byte what it was,
passwd -S still reports the account as having a password, and after
grpunconv and grpconv sg still admits a non-member with the group password
and still refuses a wrong one.

Verified: 878 tests on debian, alpine and fedora; make check clean including
the unprivileged run; 356 e2e assertions against a real install; 51 GNU
comparisons; 31 arm64 assertions under emulation, the round trip included.

* tests: hand children their stdin as a file, not a pipe

The integration binary died on two CI runners with SIGPIPE: "write on a
pipe with no one to read". Not a failing test -- the test runner itself was
killed, part way through, and every test after it never ran.

Two things combine. The batch tools exit as soon as an input line is bad,
so a child fed "staff\n" has closed its stdin within a millisecond of
starting. And the `uumain` that `#[uucore::main]` generates resets SIGPIPE
to its default disposition every time it runs, in-process, for GNU pipeline
compatibility; several tests call `uumain` in-process -- deliberately, where
the tool would exec -- so once any of them has run, the whole test binary
has SIGPIPE at default. From then on a stdin write that lands after the
child has gone is not an EPIPE the helper could handle but a signal that
ends the process. The window is a few microseconds wide, which is why it
showed up on loaded CI runners and never locally.

The helpers now write the input to a file in the prefix tree and hand the
child that file as its stdin. A file has no reader to lose, so the class is
gone regardless of what the signal disposition is at the time. Applied to
chpasswd's helper too, which had the same shape and the same latent hazard.

Fifteen runs of the integration binary at sixteen threads after the change:
no failures. The race could not be reproduced on demand before it, so that
number is not a before-and-after; the diagnosis rests on uucore's source and
on the two CI logs.
2026-09-07 15:43:22 +02:00
Pierre Warnier 8575f629a8 vipw, vigr: edit the account files under the suite's lock (#294)
vipw and vigr are the nineteenth and twentieth tools, and the fourth and
fifth of the nine that both Debian and Fedora ship and we did not. They
hand /etc/passwd, /etc/shadow, /etc/group or /etc/gshadow to the
administrator's editor under the same lock every other tool takes, and
install the result atomically. vigr is vipw with the group file as its
default -- the GNU suite ships it as a symlink -- so it is a crate that calls
uu_vipw::run with a different default and nothing else.

The reason the tool exists is the lock, so that is what the e2e suite proves:
with the editor open on /etc/passwd, a concurrent useradd waits rather than
failing or racing, and both changes land.

Two deliberate departures from GNU, both established by running its binary:

  - The edited file is parsed before it is installed. GNU installs whatever
    the editor saved: a line with no colons went in, and so did
    `evil::0:0:::/bin/sh`, a UID 0 account with an empty password field.
    Here a file the rest of the suite could not read back is refused. The
    check is structural -- pwck and grpck still judge the contents -- and
    when it refuses, the edit is kept beside the file rather than discarded.
    An administrator whose ten minutes of work vanished would use a raw
    editor next time, which is the outcome this tool exists to prevent.

  - Changes are detected by content, not by timestamp. GNU compares
    modification times in whole seconds, so an edit saved within the same
    second as the copy was made is silently thrown away. The first probe of
    the GNU tool hit exactly that: every scripted edit came back
    "unchanged" until the script was slowed down.

The working copy sits beside the target as passwd.edit, created under a
zeroed umask with the original's mode and ownership, so a copy of
/etc/shadow is never more readable than /etc/shadow. A stale copy from a
crashed run is replaced, and the editor is handed the live contents.

vipw blocks SIGINT and friends for the whole edit so a Ctrl-C aimed at the
editor cannot kill it holding the lock. That mask would be inherited by the
editor, which then could not be interrupted at all, so
shadow_core::process::spawn_with_signals_unblocked starts the child with a
clean mask -- one pre_exec in the module where unsafe is allowed.

Verified: 852 tests on debian, alpine and fedora; make check clean including
the unprivileged run; 319 e2e assertions against a real install; 47 GNU
comparisons; 24 arm64 assertions under emulation.
2026-09-07 15:42:20 +02:00
Pierre Warnier 5db310fc88 newusers: create or update accounts in batch (#293)
newusers is the eighteenth tool and the third of the nine that both Debian
and Fedora ship and we did not. One input line of seven fields produces a
whole account: the passwd record, the hashed shadow record, a group, and the
home directory with the skeleton copied in.

Every line is parsed, every name validated, every group resolved and every
password hashed before anything is written. A batch with one bad line leaves
the system exactly as it was, including for the good lines above it -- the
failure mode is "nothing happened", not "the first two hundred accounts
exist and the rest do not". That is the whole reason to reach for this tool
over a shell loop around useradd.

Home directory creation moved to shadow_core::home rather than being copied.
useradd's version forces the umask so the requested mode is exact, and hands
over ownership through a descriptor opened O_NOFOLLOW so nobody who can write
the parent can swap in a symlink between the mkdir and the chown. A second
copy of that is a second chance to get it wrong, and the mistake would be
silent. useradd now calls the shared one and its own tests still pass.

Three deliberate divergences, all established by running the GNU tool:

  - An empty password field is refused before anything is written. Hashing
    "" gives a valid hash that a bare Enter matches. GNU passes the empty
    field to PAM, which refuses it *after* creating the account, leaving a
    half-made account behind -- verified.
  - A pw_gid naming a group that does not exist is refused. GNU falls back to
    the user's own ID and creates no group, so the account is left pointing
    at a GID that is not there, which grpck then reports.
  - A missing parent directory is created, as useradd -b does. GNU's newusers
    fails there, which makes it disagree with GNU's own useradd on the same
    path.

--badname is implemented rather than stubbed: it drops the portability rules
on the login name while keeping the checks that stop a name from corrupting
the file or being read as an option, which is what makes the flag safe to
offer at all. It is what lets a domain-qualified name from a directory join
through.

The e2e suite re-hashes the stored field with its own salt and compares, so a
tool that wrote a well-formed hash of the wrong string would fail; the
negative control, the same check against a different password, is asserted to
fail. An earlier version of that assertion ended in `|| true` and was
therefore always green -- worth saying, because it is the failure mode these
checks exist to catch.

Verified: 828 tests on debian, alpine and fedora; make check clean, including
the unprivileged run; 297 e2e assertions against a real install; 45 GNU
comparisons; 22 arm64 assertions under emulation.
2026-09-07 15:40:59 +02:00
Pierre Warnier f37939d9fc chgpasswd: set group passwords in batch (#292)
* chgpasswd: set group passwords in batch

chgpasswd is chpasswd's counterpart for groups: it reads group:password
lines from stdin and applies them. It is the seventeenth tool and the second
of the nine that both Debian and Fedora ship and we did not.

It follows chpasswd's rule, which is what makes a batch tool safe to run
unattended: every line is parsed, every group resolved and every password
hashed before any file is written, so a batch naming one group that does not
exist changes nothing rather than applying the lines above it and stopping.
The two account files are then written in one transaction.

Where the hash goes was established by running the GNU tool, not by reading
it. With /etc/gshadow present the hash belongs there and /etc/group keeps the
`x` placeholder -- /etc/group is world-readable. Without it the hash goes
into /etc/group, and no gshadow file is created: conjuring one up would
change how every other tool on the host reads group passwords.

Three deliberate divergences, all refusals:

  - `-c NONE`, which GNU honours by storing the password as clear text in
    /etc/gshadow. A readable group password is worth no more than none at
    all, and `-e` already writes a field verbatim when that is what is
    wanted. The message says so, or an operator would just try `-c MD5`.
  - `-m` and `-c MD5`/`-c DES`, as chpasswd already refuses them.
  - An empty password in plaintext mode: hashing "" yields a valid hash that
    a bare Enter matches, which is a group anyone can enter rather than a
    group with no password. `-e ''` remains the way to clear the field.

A field containing a colon is refused, since it would split the gshadow line
and corrupt the file. The GNU tool refuses it too and likewise leaves the
file untouched, so this is agreement, not divergence.

The e2e suite checks the tools against each other rather than only against
the files: the password chgpasswd sets is the one sg then accepts from a
non-member, and a wrong one is refused. A hash written in a format nothing
can verify would pass a file-shape assertion and fail this one.

Verified: 798 tests on debian, alpine and fedora; make check clean; 273 e2e
assertions against a real install; 44 GNU comparisons.

* tests: run the suite as an unprivileged user too

Sixteen of the chgpasswd tests assumed root. They passed locally because
every container in docker-compose.yml runs as root, and failed in the one CI
job that does not.

Worse than the two that failed outright were the ones that passed for the
wrong reason: a test asserting exit 1 got exit 1 from "permission denied"
rather than from the condition it meant to check, so it was green while
testing nothing. Only the two expecting exit 0 gave the game away.

The guard itself is what chpasswd already does -- skip_unless_root() on every
test that writes an account file. The two that only exercise argument parsing
keep running everywhere, since clap answers before the root check.

The gap that let this through was local, so the fix is too: `make
test-unprivileged` re-runs the already-built test binaries as an ordinary
user, and `make check` now does it after the root run. Removing one guard
again makes it fail with "Permission denied", which is how it was verified.

SHADOW_TEST_REQUIRE_ROOT is cleared for that run on purpose: it exists to
turn a skip into a failure when the suite *is* root, which is the opposite
case.
2026-09-07 15:40:15 +02:00
Pierre Warnier 651ed5c62b sg: run a command with a different primary group (#291)
sg is the sixteenth tool, and the first of the nine that both Debian and
Fedora ship and we did not. It runs a single command with a different
primary group: newgrp for one command instead of for a whole shell.

sg and newgrp are not merely similar. They decide who may enter a group by
the same rules and enter it the same way, and the distributions ship sg as a
symlink to the setuid newgrp binary -- one implementation, dispatched on
argv[0]. So the shared half moves into shadow_core::group_switch and both
tools call it, which is what keeps them from drifting apart. The multicall
binary already dispatches on argv[0], so it needs only the new applet.

sg execs the command rather than forking and waiting for it. The shell then
is the process, so the command's exit status reaches the caller unaltered:
`sg staff -c 'exit 7'` exits 7 and a command that cannot be run exits 127,
without sg reconstructing a wait status. This matches what the GNU tool does
and costs a process.

Writing the shared module turned up two things wrong in newgrp:

  - It refused a group with no password without prompting, and prompted when
    the group had one. Whether a prompt appeared therefore told any caller
    which groups have passwords set, which is a list of the ones worth
    attacking. Both tools now prompt either way and refuse afterwards.

  - It rebuilt the supplementary group list with initgroups() alone, which
    builds it from the member lists in /etc/group -- where a primary group
    never appears. `newgrp staff` therefore dropped the group the caller
    started in, costing them access to their own files until they left the
    shell. The original group is added back, which needs setgroups(2) and so
    has to happen before the setuid privilege is given up.

Both were established by running the GNU tools, not by reading them.

sg is setuid for the reason newgrp is: a caller who is not a member has to
be checked against the group password in /etc/gshadow, which is root-only.
Removing sg from the setuid list fails six assertions in the e2e suite --
including for a plain member, since initgroups() itself needs the privilege
-- and nothing else in the suite covers it.

Verified: 763 tests on debian, alpine and fedora; 255 e2e assertions against
a real setuid install, covering a member, a non-member with the group
password, a wrong password, a passwordless group and exit-status
propagation; 43 GNU comparisons; 19 arm64 assertions under emulation.
2026-09-07 15:39:24 +02:00
Jocelyn Le SageandPierre Warnier 8361bf512b gpasswd: added implementation of gpasswd (#282)
* 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>
2026-09-06 11:03:53 +02:00
Pierre Warnier eb940c0b46 --root is a real chroot in every tool, not a path prefix in eight of them (#277)
* --root is a real chroot in every tool, not a path prefix in eight of them

Five tools called chroot(2) for --root. The other eight -- groupadd,
groupdel, groupmod, grpck, pwck, useradd, userdel, usermod -- folded the
argument into the same resolver --prefix uses, so it only prepended the
directory to the files the tool itself opened.

The difference shows whenever an absolute path is read out of a record
rather than being one of the tool's own files:

    useradd -R /mnt/sysroot -m alice

created the home on the *host*, at /home/alice, because the path came
back from login.defs as /home/alice and nothing prefixed it. The skeleton
was copied from the host's /etc/skel. userdel -r -R had the mirror-image
problem and resolved the home to delete against /.

All thirteen now chroot, through one helper in shadow-core rather than
five copies of it, and refuse --root for anyone but root: a setuid-root
binary pointed at a tree of the caller's choosing would read and write
account files that caller controls.

pwck and grpck have no --prefix, matching GNU, so their tests move to a
spawned process: --root cannot be exercised in-process at all now, since
a chroot in the test binary takes every later test into the tree with it,
where /tmp does not exist. That is how this was caught. The other tools'
in-process tests use -P, which is what they meant.

Verified in the deployment container against a minimal target tree: the
account, its home and the skeleton all land inside it, groupadd/groupmod/
groupdel operate on the tree's group file, pwck and grpck read the tree,
userdel -r removes the home from the tree, and the host is untouched
throughout.

Closes #270.

* pwck: name the shadow file in the bad-entry test

The test passed only the passwd file, so the shadow path defaulted to the
host's /etc/shadow. Unprivileged, that cannot be read, and the test
reported 'cannot open' (exit 3) instead of the bad entry it is about
(exit 2). It passed in the containers, which run as root, and failed on
the host runner.

It was --root that hid this: as a path prefix it pointed the default at
the fixture. --root is a chroot now, so the fixture has to name both
files.

* tests: guard the pwck tests that chroot

They spawn 'pwck --root', and chroot(2) needs root: the tool refuses the
option for anyone else. The containers run as root so they passed there,
and the host runner failed them.
2026-09-04 17:27:13 +02:00
Pierre Warnier cf7ecb4a0f tests: one binary, spawn the real tool, and assert on what it prints (#273)
* tests: one binary, spawn the real tool, assert on what it prints

Fourteen [[test]] targets each linked all fourteen tool crates and
carried its own copy of the helpers through a #[path] include, so the
suite was compiled and linked fourteen times over. It is one target now,
the layout the rest of uutils uses.

More importantly the tests called uumain in-process and asserted on an
exit code and nothing else. Not one assertion in the suite looked at a
byte of output, so 'bit-for-bit identical to GNU' was never checked. A
shared process also let harden_process, setuid, Landlock and umask
changes leak from one test into the next.

common now offers a spawned-binary helper with assertions on the exit
code and both streams, and the tests use it. Three findings came out of
that within the hour:

  - chage exited 3 for an invalid date where GNU exits 2; 3 is for an
    unexpected failure, not for an argument the caller got wrong.
  - passwd -l was not idempotent. Eight concurrent locks left eight
    markers, and since passwd -u refuses to unlock an account that would
    stay locked, an account locked twice could not be unlocked with the
    tool at all. GNU leaves a single marker however many times it is
    asked; two unit tests that encoded the old behaviour are rewritten
    to the verified one.
  - pwck's duplicate-UID test accepted either 0 or 2, so it asserted
    nothing. Two accounts may deliberately share a UID (useradd -o), and
    GNU reports nothing; a duplicate *name* is the fault, and that is now
    what is checked, message included.

test_chage and test_chpasswd never invoked their tool at all -- they set
a field through shadow-core and read it back, testing the parser twice.
Both now drive the binary through --prefix, which those tools have since
gained, and cover the aging-field writes, the -l wording and thresholds,
the batch rules, ENCRYPT_METHOD and the flag pairs the man page forbids.

The concurrency test raced four threads through uumain against a lock
keyed on the process id, which same-process threads never contend for,
and discarded all eight return codes. It spawns eight processes now and
checks every one.

New: the multicall binary is tested for the property one table now
guarantees -- every applet listed is reachable, and the help text is
identical through the argument and the symlink.

* passwd: make -l idempotent

Locking prepended '!' unconditionally, so locking an account twice left
'!!'. One unlock then removed one marker and left the account locked --
and passwd -u refuses to unlock an account that would stay locked, so
the tool could not undo its own second lock.

GNU shadow 4.17 leaves a single marker however many times it is asked:
three locks give one '!', which one unlock removes. Unlocking a field
that already carries two markers still removes exactly one, which is
also what GNU does; that case is reachable only from a file some other
tool wrote.

Found by running eight concurrent passwd -l processes against one
account and looking at the result, which is a thing the suite could not
do while it called uumain in-process. Two unit tests asserted the old
behaviour and now assert the verified one, and the deployment suite
locks twice and checks the marker did not double.

* ci: make root-only skips visible, and test root together with pam

Roughly three quarters of the integration suite is root-only, and a
skipped test is reported ok, so a run that exercised almost nothing
looked exactly like a run that exercised everything.

SHADOW_TEST_REQUIRE_ROOT turns a skip into a failure. The Docker images
set it, since they run as root: 27 useradd tests that reported ok off-root
fail loudly there now if the suite ever stops running as root.

The matrix also runs with pam. The feature changes which code paths
exist -- chfn and chsh refuse every non-root call without it, and passwd
loses its interactive path -- so root and PAM together had never been
exercised by any job.

* fuzz: assert a real invariant, add a subid target, build in CI

The targets asserted only that parsing does not panic. For a record file
the property that matters is that a parsed entry survives being written
back: if Display can emit a line that parses differently, a tool
rewriting the file silently changes an account. Each target now checks
that the rendered line carries exactly the separators its format has --
one more would shift every following field on the next read, which is
how an injected account appears -- and that parsing it again renders
identically. /etc/subuid had no target at all.

The crate is a separate workspace nothing else builds, so it had rotted:
its manifest asked shadow-core for features that no longer exist. A
stable 'cargo check --bins' in CI catches that without nightly.

The corpus was both gitignored and tracked. It is kept: 667 accumulated
inputs are worth carrying between runs, and the main suite now replays
them through the same two properties on every pull request, so a
regression a fuzzer once found is caught on stable without a fuzzing
session.

* chage: an invalid date is a bad argument, not an unexpected failure

'chage -E 2025-02-29' exited 3, which chage(1) uses for something going
wrong that the caller did not ask for. A date the caller typed wrongly is
exit 2, which is what GNU shadow 4.17 answers -- 'chage -d -5 u' prints
"invalid date '-5'" and exits 2.

Found by the integration tests once they ran the real binary and looked
at its exit code.

* tests: guard the chage tests that need root, and let an empty flag mean off

The chage tests drive the tool against a --prefix tree, but 'chage -l' on
an account other than the caller's, and every modification, are refused
for a non-root caller before any file is opened. The host runner is not
root, so those tests failed there.

SHADOW_TEST_REQUIRE_ROOT also treated an empty value as set, so
'-e SHADOW_TEST_REQUIRE_ROOT=' turned the requirement mysteriously on
rather than off. An empty value now counts as unset, which is the
conventional reading.

* ci: do not ask Alpine for a pam build

Alpine's Rust image targets musl with crt-static, where shadow-core
refuses the pam feature at compile time on purpose: Linux-PAM loads its
modules with dlopen(3), which a static binary cannot do, so a build that
compiled would produce an authentication path that never works.

The new 'with pam' matrix step ran on all three distros and so hit that
guard. It runs on the glibc images now. The static musl job already
asserts that requesting pam there is rejected, and Alpine is in the
matrix for musl's libc behaviour, not for PAM.
2026-09-04 16:46:07 +02:00
Pierre Warnier 6442faf970 chage/passwd/chpasswd/newgrp: aging bounds, input rules and newgrp - (#269)
* chage, passwd: one date module, GNU's -l output, and exit 1 for an unknown login

The Hinnant calendar algorithms were written out three times. They now
live in shadow-core::date, which also gained the inverse direction and
the two display forms, with the arithmetic checked.

That matters here: 'chage -l' adds lastchg + max + inactive, three values
read from a file anyone who can write /etc/shadow chooses. Plain addition
wraps in release builds and panics in debug ones. The sum is now bounded
and an unrepresentable date shows as 'never', which is what GNU displays
for a field it cannot make sense of.

Two -l rules were wrong, both verified against GNU shadow 4.17 in the
Debian image:

  - A last change of 0 is passwd -e's 'must change at next login' marker,
    not a date. GNU prints 'password must be changed' on all three
    password lines; we printed 1970 dates for the two derived ones.
  - Expiry is disabled at a maximum age of 10000 days, not 99999. -M 9999
    shows a date, -M 10000 shows never.

Both tools also exited the wrong code for an unknown login: chage 15,
which chage(1) reserves for 'can't find the shadow password file', and
passwd 3, which is for an unexpected failure. GNU exits 1 for both.

chage gains --prefix, so its behaviour can be exercised without touching
the live files. The display tests, which asserted only that a string
contained '1970' and that printing did not panic, now assert the exact
output line by line.

* chpasswd: honour ENCRYPT_METHOD, reject the flag pairs the man page forbids

The default hashing scheme was hard-coded SHA-512 while ENCRYPT_METHOD in
login.defs is how a distribution picks one for the whole system. On
Debian, which sets YESCRYPT, every password set through this tool was
weaker than the one the same user would get from passwd. -c still
overrides, and an unreadable or unusable configuration falls back to
SHA-512 rather than refusing to set a password.

Two flag combinations chpasswd(8) documents as errors were accepted and
quietly did nothing: -s without -c (a rounds count with no scheme to
apply it to) and -e with -c (a scheme for input that is already hashed).
Both are now usage errors, and usage errors exit 2 as the man page says.

The batch is also resolved before it is applied: an unknown login in the
middle of a list used to leave the first half written. Membership is
looked up through an index rather than a scan per pair, which took the
critical section -- held with signals blocked -- out of quadratic time.

Also drops the setuid(0) before the write, which raised the real uid and
made caller_is_root() answer true for the rest of the process, and owns
each input line in a zeroizing buffer: every one of them holds a
password.

Adds --prefix.

* newgrp: implement '-', and stop making every shell a login shell

newgrp(1) is 'newgrp [-] [group]'. The dash was not implemented, so
'newgrp - docker' looked for a group called '-' and reported it missing.

Worse, every invocation execd a login shell (argv[0] = -sh) whether or
not the dash was given. newgrp(1) says the current environment and
working directory are kept without it, so the profile files were being
re-read on each 'newgrp docker', in an environment they had already been
applied to.

Now: without '-', a non-login shell inheriting the environment; with '-',
a login shell, chdir to the home directory, and an environment built
explicitly (HOME, SHELL, USER, LOGNAME, PATH, plus TERM and the locale
variables a real login inherits from the terminal). That needs execve,
added to shadow-core::process, because std::env::set_var is unsafe and
process-global in edition 2024.

Membership and password sources were checked against GNU shadow 4.17
rather than changed on assumption, with four groups covering the cases:
membership comes from /etc/group and the password from /etc/gshadow, and
a user listed only in gshadow's member list is not admitted. That is what
this already did, so it stays.

The duplicated EchoGuard is gone; the prompt goes through the shared
terminal helper, which blocks interrupts for the read. The env
sanitization call that computed a value and dropped it is gone too, with
a note on why newgrp deliberately does not sanitize.

The parse tests now exercise the operand grammar instead of asserting
that clap stores a string.

* shadow-core: scrub the crypt(3) password copy, identify the caller to PAM

crypt(3) takes C strings, so a copy of the password has to exist.
CString::new made that copy on the heap and freed it without zeroing:
the caller's Zeroizing<String> scrubs its own buffer and never learns
about this one. The copy is now owned in a zeroizing buffer whose
capacity is reserved exactly, since a reallocation would leave a first
copy behind unzeroed.

The PAM response cleanup zeroed with ptr::write_bytes immediately before
free(). That is a dead store, and the optimiser is entitled to delete it,
leaving the password in the heap. Volatile writes and a compiler fence
cannot be elided.

PAM_TTY and PAM_RUSER were never set, so pam_unix and pam_faillock logged
tty=? with no requesting user, which makes their records useless for
tracing a failed authentication and stops pam_faillock's per-tty handling
from working at all. Both are now set when the handle is created, best
effort: a tool with no controlling terminal must still be able to
authenticate.

* docs: rewrite the chage, chpasswd and newgrp man pages; cover the rules end to end

None of the three explained the rules that actually decide what happens:
what a last change of 0 means in the chage output and where the 'never'
threshold sits, that chpasswd takes its scheme from ENCRYPT_METHOD and
which flag pairs are errors, or what newgrp's dash does and where
membership and the group password are each read from. All three now carry
value restrictions, configuration, exit status, files and worked
examples.

The deployment suite gains thirty-five assertions against the real
binaries: the three 'password must be changed' lines, a date at -M 9999
and 'never' at -M 10000, -1 clearing a field while -5 is refused, exit 1
for an unknown login from both chage and passwd, chpasswd following
ENCRYPT_METHOD through two changes and -c overriding it, the two usage
errors, --prefix on both tools leaving the live files untouched, and
newgrp keeping the environment without '-' and reinitializing it with.
2026-09-04 14:20:46 +02:00
Pierre Warnier b6c318d1d5 chfn/chsh: interactive mode, and fix a setuid path oracle (#268)
* shadow-core: add a shared terminal helper

Reading a line and reading a password were open-coded in pam.rs, and
every prompt outside a PAM conversation had no helper at all.

tty.rs holds all of it: prompt_line, read_password and read_stdin_line,
with the EchoGuard that restores the terminal. Ctrl-C at a password
prompt used to leave echo disabled -- the signal terminates the process
without unwinding, so the guard's destructor never ran. The read now
blocks SIGINT, SIGQUIT and SIGTSTP for its duration, which is what
readpassphrase(3) does, and restores the mask on the way out.

The prompt prefers /dev/tty and falls back to stderr and stdin when
there is no controlling terminal (ENXIO/ENODEV), the case that broke
password changes under su -c.

* chfn: prompt for fields, and check values before asking for a password

chfn(1) with no field option prompts for each field with its current
value; ours refused with 'no flags specified'. It now prompts, and an
empty answer keeps the field.

Three fixes come with it:

  - CHFN_RESTRICT is enforced on the values, not on which options were
    passed. Interactive input went through unrestricted, and a field the
    caller may not change is now not prompted for at all.
  - Values are validated before the PAM conversation, so a rejected
    value no longer costs the caller a password prompt.
  - mutate_passwd no longer calls setuid(0). euid 0 is all the lock and
    the atomic write need; raising the real uid made caller_is_root(),
    which is deliberately real-uid based, answer true for everyone.

The five sub-fields are now one Changes value with the collect,
validate, permission and apply steps on it, which is what let uumain
shrink rather than grow.

* chsh: prompt for the shell, and stop leaking path existence

Three changes to the self-service path.

chsh(1) with no -s prompts for the new shell with the current one in
brackets; ours refused with 'no shell specified'. Pressing ENTER, or
repeating the current value, is now a successful no-op.

An empty shell is accepted. passwd(5) gives the field no meaning of its
own and login falls back to /bin/sh, so -s '' selects the system default
rather than naming a program that does not exist.

The order of the two checks on a shell now matters and is documented.
chsh is setuid-root, so Path::exists answers with root's view of the
filesystem: testing existence first made the tool an oracle for paths
the caller cannot stat, one probe at a time --

    chsh -s /root/.ssh/id_ed25519

answering 'is not listed in /etc/shells' for a file that exists and
'does not exist' for one that does not. For a non-root caller the
membership test now runs first and discloses nothing, /etc/shells being
world-readable. Root still bypasses the list but not existence.

Validation also moved ahead of the PAM conversation, so a refused shell
no longer costs a password prompt, and mutate_passwd no longer calls
setuid(0): euid 0 is enough, and raising the real uid made
caller_is_root() answer true for every caller.

* docs: rewrite the chfn and chsh man pages; cover self-service end to end

The man pages documented neither interactive mode, nor CHFN_RESTRICT,
nor the restricted-account rule, nor what an empty shell field means,
and chfn's still said a field option was mandatory. Both now carry
permissions, value restrictions, configuration, files and worked
examples, and chsh explains why the two shell checks run in the order
they do.

The deployment suite gains eighteen assertions against the real setuid
binaries: interactive chfn and chsh through PAM, an empty answer as a
no-op, CHFN_RESTRICT enforced on interactive input, the empty shell
field, and the oracle itself -- an existing and a missing path under
/root must give a non-root caller the same answer, with no password
prompt before it.
2026-09-04 13:53:30 +02:00
Pierre Warnier 5bb637da38 pwck, grpck: report what the man pages say they report (#267)
grpck -q is documented as 'report errors only', but every error print was
gated on !quiet along with the warnings, so -q silenced the one thing it
is supposed to keep. Errors are now always shown.

A gshadow file that could not be parsed was turned into an empty list and
a warning, and the group/gshadow consistency check then ran only when that
list was non-empty -- so a malformed or empty gshadow beside a populated
group file reported nothing at all. The check is driven by the file
existing, and a parse failure is an error. grpck also verifies what its
man page calls 'a valid list of members and administrators': a name in
either list that no account carries is now reported.

pwck resolved a relative home or shell against its own working directory,
so it reported on whatever happened to be there; a relative path is itself
the fault and is reported as one. The exit code for a sort that cannot be
written was defined and never emitted; it is now used. The home and shell
checks move into their own function, which is also what keeps the entry
loop readable.
2026-09-04 13:18:15 +02:00
Pierre Warnier 90e5043fd3 group tools: close the groupadd race and add the missing options (#266)
groupadd read /etc/group before taking its lock, then checked the name and
allocated a GID from that snapshot. Two concurrent 'groupadd -r' runs could
therefore pick the same GID, and two 'groupadd www' could both conclude the
name was free. The lock is now taken first and the file read under it.

Four documented options were a usage error: groupadd -U and groupmod -U/-a
manage a group's member list, and groupdel -f removes a group even when it
is a user's primary group and succeeds when the group is already gone.

groupmod -p was a silent no-op on a system without /etc/gshadow; the
password now goes into the group file, which is where such a system keeps
it. groupdel left the deleted group in /etc/gshadow whenever it was the
only entry there -- the write was skipped for an empty result -- and could
not delete the last group in /etc/group at all, because the atomic writer
refuses a zero-length file. Both now unlink the file instead, an absent
file meaning the same as an empty one.
2026-09-04 13:10:35 +02:00
Pierre Warnier a1ca5d448e usermod: fix the group handling and report the documented exit codes (#265)
-g accepted only a numeric GID and wrote it through unchecked, so
'usermod -g 12345' left the account with a primary group that does not
exist. It now takes a name or a GID and requires the group to exist,
exiting 6 as usermod(8) documents.

-G '' split into one empty name and was rejected as a group that does not
exist; the man page defines it as removing every supplementary membership.
It does that now, -G accepts GIDs as well as names, and -a requires -G.

-l combined with -G added the *old* login to the groups, after the rename
had already happened -- a member naming no account. The new login is used.

/etc/gshadow was never updated by -G or -l, which is precisely what grpck
reports as 'members differ'. Both now keep it in step with /etc/group, and
it is only written when something actually changed.

-L prepended a second '!' to an already-locked password, which one -U
could not undo; it is now idempotent. -U refuses to leave an account with
no password instead of silently doing nothing. Group failures report 6 and
10 rather than the catch-all 1.
2026-09-04 13:02:56 +02:00
Pierre Warnier d48d19c749 useradd: honour /etc/default/useradd, add -b and -P, close the home chown race (#264)
* shadow-core: read and write /etc/default/useradd

useradd's own defaults live in /etc/default/useradd as KEY=VALUE, a
different format from login.defs and a different set of keys (GROUP, HOME,
INACTIVE, EXPIRE, SHELL, SKEL, CREATE_MAIL_SPOOL). Nothing read it, so a
site that set SHELL there had it ignored. A missing file reads as no
values, and the writer replaces the file atomically.

* useradd: honour /etc/default/useradd, add -b and -P, close the home chown race

useradd -D with a value printed the defaults and exited 0 without saving
anything, and nothing ever read /etc/default/useradd, so a site's
configured SHELL, SKEL or HOME base was silently ignored. -D now persists
what it is given, the file takes precedence over login.defs for the keys
it holds, and a new account uses those values.

-b/--base-dir and -P/--prefix are added, both documented in useradd(8) and
both previously a usage error. A UID of 4294967295 is rejected: that is
(uid_t)-1, the 'no change' sentinel of chown and setresuid.

The new home directory is chowned through a descriptor opened with
O_NOFOLLOW instead of by path. Between the mkdir and the chown, anyone
able to write the parent -- a home under /tmp, or a shared base directory
-- could replace the directory with a symlink and be handed its target.
2026-09-04 12:56:26 +02:00
Pierre Warnier 968c2d8bf2 useradd: follow useradd(8) for system accounts, groups, ID allocation and home creation (#263)
* shadow-core: match shadow-utils on name rules and ID allocation

Two behaviours verified against shadow-utils rather than assumed:

Names. 'useradd Alice' and 'useradd MACHINE$' both succeed there, so
rejecting upper case and a trailing dollar meant refusing accounts that
exist on real systems -- the dollar is how Samba names machine accounts.
Both are accepted now; the length cap and the rest of the character rules
are unchanged.

Allocation. Creating a, then b, deleting a, then creating c gives c the ID
after b's, not a's. We handed out the lowest free ID, so a new account
inherited a deleted one's ID and with it any files left behind. Allocation
now starts past the highest ID in use within the range and falls back to
the first gap only once the top of the range is taken.

* useradd: follow useradd(8) for system accounts, groups and home creation

System accounts (-r), all three verified against shadow-utils: no home
directory even when CREATE_HOME is yes, no aging information in
/etc/shadow (it writes 'svc:!:20700::::::'), and no subordinate UID/GID
ranges.

Groups: -g and -G now require the group to exist, named or numbered, and
report exit 6 when it does not. A numeric GID naming no group was
accepted, leaving the account with a primary group that does not exist --
id(1) then prints a bare number and new files get an unnamed group. -G
also refused the numeric form the man page allows.

Home directories: HOME_MODE from login.defs is honoured instead of a
hardcoded 0700, and a missing parent of the home is created (0755,
root-owned) instead of failing with ENOENT.
2026-09-04 12:47:31 +02:00
Pierre Warnier 2bfeba0b50 shadow-core: stop deleting comments and NIS compat lines when rewriting account files (#262)
* shadow-core: keep the lines that are not entries

A record file holds more than entries: comments, blank lines, and the NIS
compatibility lines nsswitch.conf(5) documents for compat mode (+user,
+@netgroup, -user, a bare +::::::). The parsers dropped all of them, so
the first tool to rewrite a file deleted every comment in it, and the
compat lines did not parse at all -- on a host using compat, every tool
failed on the first line it could not read.

records::read_with_layout returns the entries plus a Layout holding those
lines, and write_with_layout puts them back. Each raw line is anchored to
the name of the entry it preceded rather than to a line number, so it
stays with that entry when the file is reordered and survives entries
being added or removed elsewhere; a line whose entry disappears is written
at the end rather than lost. The entry-only readers now skip raw lines
instead of failing on them.

* tools: preserve comments and compat lines when rewriting account files

Every read-then-write path switches to the layout-preserving reader and
writer, so useradd, usermod, userdel, groupadd, groupmod, groupdel, chfn,
chsh, passwd, chage and chpasswd all leave the rest of the file alone.

pwck and grpck additionally re-read under the lock before sorting: they
were sorting entries read before it, so a concurrent change could be
overwritten, and the sort now carries each comment along with the entry it
described.
2026-09-04 12:36:05 +02:00
Pierre Warnier 3f7f070ab6 chage, passwd: bound the aging fields and let -1 clear them (#260)
The four day-count fields accepted any negative value and wrote it
through, so 'chage -M -5' left a policy no reader can make sense of. Only
-1, meaning 'unset', may be negative now; anything lower exits 2 for chage
and 6 for passwd, the codes their man pages give for an invalid argument
to an option. The check runs before any file is opened.

passwd could not even be given -1: the four options lacked
allow_hyphen_values, so 'passwd -x -1' -- which passwd(1) documents as 'no
maximum' -- was rejected by the parser. They accept it now, and -1 clears
the field instead of being stored literally, matching what chage writes.
2026-09-04 11:58:01 +02:00
Pierre Warnier eee6b34be0 usermod: reject a rename onto an existing login, accept dated -e (#257)
* shadow-core: one calendar implementation for the expiry fields

useradd, usermod and chage each carried their own date arithmetic and
their own idea of what -e accepts. Move the Gregorian conversion and the
expiry parser into shadow_core::date, with the parser accepting the
YYYY-MM-DD form the man pages document, an empty value or -1 for 'no
expiry', and a bare integer already in days since the epoch. Impossible
dates are rejected rather than normalised.

useradd now calls it and drops its copies of days_since_epoch,
is_leap_year and days_in_month; their tests move to the new module.

* usermod: reject a rename onto an existing login, accept dated -e

usermod -l never checked that the new name was free, so 'usermod -l root
alice' left two entries named root in /etc/passwd and in /etc/shadow. It
now exits 9, the code usermod(8) documents for a login already in use.

-e only accepted days since the epoch, so 'usermod -e 2030-01-01' -- the
form the man page documents and the form Ansible and shell scripts pass
-- failed. It now goes through shadow_core::date, and is parsed before
the passwd file is written so an invalid date no longer leaves the other
fields already committed.
2026-09-04 11:42:38 +02:00
Pierre Warnier 75d70d3e38 pwck, grpck: never rewrite the files when a line failed to parse (#255)
-s sorts and rewrites the entries that parsed. When a line did not parse
-- exactly the case these tools exist to report -- it was left out of that
set, so `pwck -s`/`grpck -s` silently dropped every invalid line and every
comment while returning exit 2 as if nothing had changed. They now report
the errors and exit 2 without touching the files.

-r and -s are rejected together, which the man pages state cannot be
combined.
2026-09-04 11:32:40 +02:00
Pierre Warnier 3a246b4e23 userdel: don't destroy homes it shouldn't, clean up the private group and subids (#254)
Aligns userdel with userdel(8) on the data-destroying edges:

- -r refused to consider ownership or sharing: it would remove a home the
  account merely pointed at (a root-owned /srv/data) or one shared by
  another account. It now refuses a home not owned by the user, or shared
  with another still-present account, unless -f -- which the man page ties
  to exactly these cases.
- the user's private group (the USERGROUPS_ENAB group named after the
  login) was never removed, so useradd/userdel cycles accumulated zombie
  groups and drifted UID/GID pairs. It is now removed when it has no other
  members, kept when it does or when it is another user's primary group
  (unless -f).
- /etc/subuid and /etc/subgid rows for the user were left behind, so a
  later user of the same name silently inherited the ranges; they are now
  removed (the file is unlinked if it becomes empty, which the atomic
  writer's zero-length guard would otherwise forbid).
- a missing user exited 1; userdel(8) says 6. With -f the absence is
  tolerated and the remaining cleanup still runs.

-f is documented for what it now does instead of 'no effect'.
2026-09-04 11:26:14 +02:00