* ci: pin the actions by digest A tag is a moving pointer. Whoever controls the action's repository can repoint v7 at any commit, and every workflow in the project runs it on the next push with write access to the checkout. A digest cannot be repointed. The tag stays in a trailing comment so the version is still readable, and Renovate's pinGitHubActionDigests preset is enabled so the digests are kept current the same way every other dependency is -- as a pull request that CI has run, rather than silently. * tests: make the GNU comparison real, and run it in CI The suite compared full output for exactly two commands and, for the other twelve, only that both tools exit 0 on --help. It ran nowhere. It now compares what is actually a contract. 'passwd -S' and 'chage -l' are parsed by monitoring and provisioning scripts, so their output is compared byte for byte across eleven aging configurations -- including the cases this project got wrong until recently: a last change of 0, the threshold above which a maximum age reads never, and cleared fields. Exit codes are compared on ten error paths. --help output is deliberately not compared: ours comes from clap and is meant to differ. One difference is deliberate and now asserted as such, so it cannot be mistaken for a regression the day it changes by accident. 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 decides when an account stops working. We refuse it; chage(1) says so. 38 comparisons, all as expected, and a CI job runs them. 'make check' is now the single definition of what CI gates on -- fmt, clippy with and without pam, and the tests in both configurations. The pre-push hook and CONTRIBUTING refer to it instead of each carrying their own copy of the command list, and the two hook systems are documented as the non-overlapping things they are.
A memory-safe reimplementation of the Linux
shadow-utils in
Rust. shadow-utils (useradd, passwd,
groupadd, etc.) is the suite of setuid-root tools that manages user accounts,
passwords, and groups on every Linux system.
Why
shadow-utils runs as root or setuid-root on every Linux system. It parses
user-supplied input, writes to /etc/passwd, /etc/shadow, /etc/group, and
has had recent CVEs (CVE-2023-4641: password leak in memory, CVE-2024-56433:
subuid collision enabling account takeover). Before this project there was
no Rust reimplementation — not in uutils, not in Prossimo/Trifecta, not on
crates.io.
sudo-rs proved the model: an independent Rust rewrite of a privilege-boundary tool can go from zero to default-in-Ubuntu in under 3 years. This project follows that playbook.
Goals
- Drop-in replacement: same flags, same exit codes, same output format as GNU shadow-utils. Differences are treated as bugs.
- uutils compatible: built on
uucorewith the standarduumain()/uu_app()API contract. Designed to merge into the uutils ecosystem. - Memory safe: eliminate entire classes of vulnerabilities (buffer overflows,
use-after-free, uninitialized memory) that affect the C original. Passwords
zeroed in memory via
zeroize. - Well-tested: unit tests, property-based tests (
proptest), integration tests, fuzz targets for all parsers. Tested on Debian, Alpine (musl), and Fedora (SELinux). - Hardened: Landlock filesystem sandboxing, signal blocking during critical sections, core dump suppression, environment sanitization, privilege drop during PAM.
- Auditable: small dependency tree,
cargo-denylicense and advisory checks, no GPL dependencies.
Status
| Tool | Status |
|---|---|
passwd |
All 16 flags implemented. Drop-in for GNU passwd. PAM password change, Landlock sandboxing, --root, --quiet, --stdin. Output bit-for-bit identical with GNU. |
pwck |
All checks implemented. Drop-in for GNU pwck. Bit-for-bit identical output. |
useradd |
Implemented. UID/GID allocation, home dir + skel, shadow entry, group creation. |
userdel |
Implemented. Remove from all system files, optional home/mail cleanup. |
usermod |
Implemented. Modify all properties, group membership, lock/unlock, set pre-hashed password. |
chpasswd |
Implemented. Batch password change from stdin. |
chage |
Implemented. Password aging management, -l list mode. |
groupadd |
Implemented. Auto GID allocation, system groups, force mode. |
groupdel |
Implemented. Primary group usage check. |
groupmod |
Implemented. GID change, rename, password. |
grpck |
Implemented. Group/gshadow integrity verification. |
chfn |
Implemented. GECOS sub-field modification. |
chsh |
Implemented. Shell change with /etc/shells validation. |
newgrp |
Implemented. Effective group change with crypt verification. |
Building
Requirements
- Rust (stable toolchain)
- Linux (PAM headers, SELinux headers optional)
- Docker + Docker Compose (for testing)
Build
git clone https://github.com/uutils/shadow
cd shadow
docker compose build debian
docker compose run --rm debian cargo build --release
Install
Default install: 14 standalone per-tool binaries with least-privilege setuid
layout matching GNU shadow-utils. Only passwd, chfn, chsh, newgrp are
installed setuid-root; the other 10 are plain 0755.
sudo make install PREFIX=/usr/local
Alternative: single multicall binary with symlinks. Smaller footprint (~14×
disk savings). The binary is installed setuid-root so that passwd, chfn,
chsh and newgrp can serve unprivileged callers; every other applet drops
back to the caller's uid before it runs, so the privilege model is the same
as the per-tool layout. Intended for container/embedded use cases.
sudo make install-multicall PREFIX=/usr/local
From a release archive
Each release publishes two
archives, each with a .sha256 alongside. They contain the same shadow-rs
multicall binary but are not interchangeable:
| Archive | libc | Linking | Use it for |
|---|---|---|---|
uu_shadow-x86_64-unknown-linux-gnu.tar.gz |
glibc | dynamic | Any regular distribution. This is the full build. |
uu_shadow-x86_64-unknown-linux-musl-static.tar.gz |
musl | static | Minimal containers and embedded images: local /etc/passwd, no directory service, no PAM stack. |
The static archive has no runtime dependencies, and pays for that with three capabilities a static binary cannot have — none of them cosmetic for account tools:
- No PAM.
passwdcannot change a password interactively, andchfn/chshare root-only (they authenticate the caller through PAM and fail closed without it). - No NSS. Users from LDAP, SSSD, Active Directory or systemd-userdb are invisible to the five tools that look up the calling user.
- No yescrypt (
$y$). The default password hash on Debian 12+ and Ubuntu 24.04 can be neither verified nor produced.
If any of those matter on the host, use the glibc archive. docs/PLATFORM-SUPPORT.md explains each gap, what still works, and how the archive is built.
Either archive ships a plain binary — nothing is installed, symlinked, or made
setuid by extracting it. To deploy it the same way make install-multicall
would:
tar xzf uu_shadow-x86_64-unknown-linux-gnu.tar.gz # or the -musl-static one
sudo install -o root -g root -m 4755 \
uu_shadow-*/shadow-rs /usr/local/bin/shadow-rs
for tool in passwd chfn chsh newgrp chage chpasswd groupadd groupdel \
groupmod grpck pwck useradd userdel usermod; do
sudo ln -sf shadow-rs "/usr/local/bin/$tool"
done
Mode 4755 is what the four self-service applets need; the others give the
privilege up before running. Run shadow-rs --list to see the applets a
given build contains, and sha256sum -c uu_shadow-*.tar.gz.sha256 to verify
a download.
Test
All builds and tests run inside Docker containers to isolate from the host system. Three distros are tested to catch libc and PAM differences:
docker compose run --rm debian cargo test --workspace # Debian Trixie (glibc)
docker compose run --rm alpine cargo test --workspace # Alpine (musl libc)
docker compose run --rm fedora cargo test --workspace # Fedora (SELinux enforcing)
Lint
docker compose run --rm debian cargo clippy --workspace --all-targets -- -D warnings
docker compose run --rm debian cargo fmt --all --check
Architecture
Cargo workspace monorepo built on uucore:
src/bin/shadow-rs.rs multicall binary (dispatches by argv[0])
|
src/uu/{tool}/ individual tool crates (passwd, useradd, ...)
|
┌────┴────┐
uucore shadow-core shared infrastructure + domain library
Tools use uucore for the standard uutils API (UResult, #[uucore::main],
show_error!) and shadow-core for domain-specific functionality.
shadow-core provides:
- File parsers for
/etc/passwd,/etc/shadow,/etc/group,/etc/gshadow,/etc/login.defs,/etc/subuid,/etc/subgid - Atomic file writes (lock, write tmp, fsync, rename, unlock, invalidate nscd) that keep the file's mode, owner, group and SELinux label
- PAM integration (feature-gated)
- Username/groupname and field validation (no
:or control characters can reach a record) - UID/GID allocation
Each tool crate exports uumain() and uu_app(), following
uutils conventions exactly so a future
merge is frictionless.
Docker Test Matrix
| Target | Base | libc | PAM | SELinux |
|---|---|---|---|---|
debian |
rust:latest (Trixie) |
glibc | Linux-PAM | headers |
alpine |
rust:alpine |
musl | Linux-PAM | none |
fedora |
fedora:latest |
glibc | Linux-PAM | enforcing |
The alpine image tests musl with dynamic linking, PAM included. The static
musl release archive is a different build — no PAM, no NSS, no yescrypt — and
has its own CI job that builds it and runs shadow-core's unit tests against
static musl on every pull request. See
docs/PLATFORM-SUPPORT.md.
Credits
Security patterns from OpenBSD
(ISC license). PAM integration patterns from
sudo-rs (Apache-2.0/MIT).
uutils infrastructure via uucore (MIT).
Contributing
See CONTRIBUTING.md for guidelines.
Important: uutils/shadow is developed under a strict GPL clean-room policy. Do not read, reference, or feed into an LLM any code from shadow-maint/shadow (GPL-2.0+). Reference only: POSIX specs, man pages, BSD-licensed implementations (FreeBSD, OpenBSD, musl), and sudo-rs.
License
uutils/shadow is licensed under the MIT License.
GNU shadow-utils is licensed under the GPL 2.0 or later.