14 Commits
Author SHA1 Message Date
Pierre Warnier acc1771bc2 tests: exercise the PAM stack against known permit and deny services (#281)
The only PAM coverage was the deployment image authenticating a real
account. That cannot exercise a *refusal* without relying on a wrong
password being wrong, and it cannot tell a refusal apart from a
conversation that never reached the stack -- which is exactly the failure
that broke password changes earlier this week and took three rounds to
find.

The glibc images now define two throwaway services, one backed by
pam_permit and one by pam_deny, and shadow-core tests the wrapper against
both: the permit stack authenticates and passes account management, the
deny stack refuses with the stack's own answer rather than a conversation
error, and a service with no configuration at all does not authenticate.
The tests skip where the services are absent, which is any machine but
the containers.

CI also gains an all-features clippy pass. The completions feature and
the shadow-core feature gates were compiled by no job, so a lint or a
compile error in them could reach main unnoticed. make check runs it too.
2026-09-04 18:25:33 +02:00
Pierre Warnier 02fefda42c ci: compare against GNU for real, pin the actions by digest, one make check (#275)
* 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.
2026-09-04 16:58:54 +02:00
Pierre Warnier 9cd5eef4ac Install layout, aarch64 archives, pinned images, and a pre-push hook worth keeping (#272)
* hooks: stop reproducing CI in pre-push

The hook ran fmt, clippy and the test suite on Debian, Alpine and Fedora
in series, on every push. That is several minutes for a push that may
carry nothing: pushing the 0.2.2 tag moved one object and still blocked
for over two minutes. The predictable result was that every push in the
last batch of work used --no-verify, and a hook that is always skipped
protects nothing while still costing time whenever someone forgets to
skip it.

It now runs the workspace tests once, on Debian, and returns immediately
for a push that carries no new commits -- a tag, a branch deletion, or a
branch the remote already has. git hands the hook one line per ref on
stdin; the ranges it names are asked for a single commit, and if there
are none there is nothing to test.

Everything the hook dropped is already a required check on the pull
request, running in parallel with retries: fmt, clippy and the tests with
and without pam, MSRV, cargo-deny, the static musl archive, the
three-distro matrix as root, and the end-to-end deployment image.

Closes #231.

* install: put the user-facing tools in bin, not sbin

All fourteen binaries went to $(PREFIX)/sbin, which is not on a normal
user's PATH: passwd was 'command not found' for exactly the people it is
setuid for. GNU shadow splits them, verified against the Debian package
-- passwd, chage, chfn, chsh and newgrp in /usr/bin, the other nine in
/usr/sbin. Both install targets do the same now, and the multicall
symlinks in bin point at the absolute path of the binary in sbin. chage
is in bin for 'chage -l', the one mode a user may run on their own
account.

The deployment suite follows each tool to the directory it belongs in,
and asserts that all five are reachable by name for an unprivileged
user, which is the property the split exists for.

make test also runs the suite with pam now. install ships binaries built
with it, so a run without it covered a build nobody ships.

* docker: pin the base images, drop the unused nextest download

rust:latest, rust:alpine and fedora:latest meant a new toolchain or
distro release landed on main with no pull request and no signal, and
clippy gains lints between Rust releases. They are pinned to what the
images resolve to today -- rust 1.98 on Trixie and Alpine 3.23, Fedora 44
-- and Renovate's Dockerfile manager proposes the bumps, so a toolchain
change arrives as a reviewable PR that CI has run.

Each image also downloaded cargo-nextest from get.nexte.st/latest on
every build: unpinned, unverified, and never invoked -- nothing in the
repository runs 'cargo nextest'. cargo-deny, which is used, keeps its
pinned version and checksum.

* deny: allow exactly the licences the graph uses

The allow list carried ISC, BSD-2-Clause, CC0-1.0 and Zlib, none of which
appears anywhere in the dependency graph. A list wider than the graph
means a crate arriving under a new licence passes silently; the point of
the check is that such an arrival is a decision someone records.

It is now MIT, Apache-2.0, BSD-3-Clause (subtle, for the constant-time
comparison in shadow-core::crypt) and Unicode-3.0 (tinystr and
unicode-ident, via uucore's fluent stack), taken from
'cargo deny --all-features list'.

Two crates carry a copyleft option in a dual licence and are accepted
under the other half; that is now written down rather than left for the
next reader to work out. All four checks pass with the narrower list.

* ci: run the advisory workflow on a schedule only

It also ran on every push touching a manifest or the lockfile, where the
Cargo Deny job in ci.yml had already checked the same advisory database
alongside licences and bans. Two tools reporting the same finding on the
same commit is noise.

What the pull request job cannot do is notice an advisory published
against code nobody has touched, which is the reason to keep a scheduled
run. That is now all this workflow does, plus manual dispatch.

* packaging: remove the RPM spec and debian directory

Neither can build. debian/ has no changelog at all, so dpkg-buildpackage
stops before it starts:

    dpkg-buildpackage: error: cannot open file debian/changelog

and shadow-rs.spec pins version 0.2.0 against a 0.2.2 workspace, points
at the pre-transfer repository URL, and lists a %files layout that
'make install' does not produce -- it names the multicall binary, which
only install-multicall builds, and puts every tool in sbin, which is no
longer where half of them go.

Shipping packaging that claims to work and does not is worse than
shipping none, and this would have produced a package declaring
'Provides: passwd, login' on a system whose real passwd it replaced.
Releases are the archives dist publishes; a distribution packager writes
these against their own policy anyway.

* release: publish an aarch64 archive

The pipeline shipped x86_64 only. arm64 servers and Apple-silicon
containers are common enough that a missing archive is a real gap.

It builds on a native arm64 runner rather than cross-compiling, which is
what makes it straightforward: shadow-core::crypt links crypt(3) and the
pam feature links libpam, so a cross build would need an arm64 sysroot
carrying both, while a native runner installs them from apt like any
other build. dist has picked a native runner for aarch64 since 0.30.

Both runners are pinned to 24.04. dist's defaults are ubuntu-22.04 and
ubuntu-22.04-arm, and 22.04 enters deprecation on 2026-09-17 with the
image unsupported from 2027-04-17; the pin keeps the two archives on the
same generation and off an image that is about to start failing. The
higher runner raises the glibc floor for the dynamically linked
archives, which is what the static musl archive is there for.

release.yml is regenerated by 'dist generate'; 'dist plan' now lists
three archives and resolves ubuntu-24.04-arm for the arm64 job.

Closes #222.
2026-09-04 16:28:57 +02:00
Pierre Warnier 881aad13c7 shadow-rs: drop the setuid privilege for applets that are not meant to have it (#239)
* shadow-rs: drop the setuid privilege for applets that are not meant to have it

The per-tool install marks only passwd, chfn, chsh and newgrp setuid; a
multicall install has to make the one binary setuid, which gave every
applet euid 0. Any local user could run 'shadow-rs pwck -s' or
'grpck -s' and have /etc/passwd, /etc/shadow, /etc/group and /etc/gshadow
rewritten as root. Before an applet outside that set runs, the binary now
calls setuid(getuid()), so the real, effective and saved uid are all the
caller's and the two layouts share one privilege model. It fails closed if
the kernel does not take the privilege away.

Dispatch and --list are driven by one table instead of two hand-kept
lists, and an exit code outside 0..=255 no longer clamps to success.

The deployment test checks that testrunner's pwck -s and grpck -s leave
the files' inode and ownership untouched.

* docs: the multicall binary keeps euid 0 for the four self-service tools only
2026-09-04 10:24:35 +02:00
Pierre Warnier 52c2debe8b release: publish a labelled static musl archive (#224) (#237)
* shadow-core: link libcrypt only where it exists; refuse pam on static musl

musl implements crypt(3) inside libc and ships no libcrypt. Asking for
-lcrypt there made a cross-build for x86_64-unknown-linux-musl fall through
to the host's glibc-built libxcrypt and fail on glibc-only symbols; Alpine
only worked because musl-dev ships an empty libcrypt.a.

Linux-PAM loads its modules with dlopen, which static musl does not have,
and no distribution ships libpam.a. Building the pam feature into a
crt-static musl binary is therefore a configuration error; reject it at
compile time instead of producing a binary whose authentication path can
never work.

The crypt tests stated the musl behaviour wrongly (musl does honour
rounds=; it lacks yescrypt). They now assert the real behaviour per target
instead of skipping.

* release: publish a static musl archive next to the glibc one

dist cannot vary cargo features per target and pam must be off for musl,
so the musl archive is built by 'make dist-musl' and attached by dist as an
extra artifact of the same release. The target enforces the lockfile,
checks the binary has no DT_NEEDED entry and packs it with the licence,
changelog, README and PLATFORM-SUPPORT.md so the trade-off travels with
the binary. The archive name carries a -static suffix so it is never
mistaken for the glibc build.

CI runs the same make target on every pull request, smoke-tests the
archive, runs shadow-core's unit tests against static musl and checks that
the pam feature is rejected there, so a tag is never the first time the
archive is built.

* docs: describe both release archives and what the static musl one gives up

The README now lists the two archives side by side with the one question a
reader has to answer (is PAM, NSS or yescrypt needed on this host?), and
PLATFORM-SUPPORT.md is rewritten around the archive that now exists: how
it is built, which CI job guards it, and the exact effect of each gap.
CONTRIBUTING listed two unsafe-permitted modules; there are three.

* chore: use the canonical uutils/shadow repository URL

The repository is uutils/shadow; the old shadow-rs name is a redirect. The
README badges already used the new name while package metadata, the --help
footer and SECURITY.md still pointed at the old one.
2026-09-04 10:04:38 +02:00
Pierre Warnier 9f197e0e50 chfn, chsh: authenticate the caller before applying a change (#235)
* shadow-core: move PrivDrop out of passwd

The RAII guard that drops the effective UID for the PAM conversation
lived inside uu_passwd, but it is process-level privilege handling that
any setuid tool needs. Move it next to the seteuid wrapper it calls.

Two changes while moving it: the error type becomes io::Result rather
than PasswdError so other tools can use it, and the failure-to-restore
message goes through uucore::show_error! instead of a hardcoded
"passwd:" prefix, so it names whichever tool is running.

* chfn, chsh: authenticate the caller before applying a change

Both tools are installed setuid-root. They correctly restrict a non-root
caller to their own account, checked against the real UID — but they
never verify who is asking. Anyone with access to an unlocked session
could change that user's login shell or GECOS field, and changing a login
shell is a foothold.

GNU prompts for the password here, and distributions already ship the
service definitions we were ignoring: /etc/pam.d/chfn and /etc/pam.d/chsh
both let root through with pam_rootok and fall back to common-auth for
everyone else. CONTRIBUTING treats a difference from GNU as a bug, so
this is one.

Authenticate through PAM for non-root callers, reusing the pattern passwd
already uses: drop to the real UID for the conversation so modules see
the actual caller, then authenticate and check account validity.

Without the pam feature there is no way to verify anyone, so the tools
refuse rather than apply an unauthenticated change. That is a behaviour
change for that build only, and the safe direction for a setuid binary.
It also means every install path must enable the feature — 'make build'
only enabled it for passwd, which would have shipped two setuid tools
that reject all non-root use. Fixed, and the end-to-end assertion now
covers all three tools instead of passwd alone.

CI gained 'cargo test --features pam': the feature changes behaviour
rather than only adding code, so both configurations need testing.
2026-09-04 09:27:08 +02:00
Pierre Warnier 3de8b506ac build: ship binaries with PAM so passwd can change passwords (#220) (#225)
`pam` is not a default cargo feature, so every build path except the e2e
image compiled the interactive change path out of `passwd`. The tool
could report and lock accounts but not change a password:

    $ passwd alice
    shadow-rs: PAM support is not compiled in — cannot change password
    interactively

That covered the release archive, `make install` (the default install of
14 standalone binaries) and `make install-multicall`. Only the e2e image
passed --features pam, and its assertions stop at `passwd -S`, which
works without PAM — hence nobody noticed.

Enable the feature in the release build and in both Makefile targets, and
install libpam0g-dev on the release runner. The PAM headers are already a
documented build requirement.

Add an e2e assertion that the installed passwd is linked against libpam,
so this cannot regress silently again.

Closes #220.
2026-09-03 13:16:46 +02:00
Sylvestre Ledru cb8ce5ebd2 cargo: rename package to uu_shadow. shadow-rs is already used
https://crates.io/crates/uu_grep is a precedent
2026-08-02 21:31:25 +02:00
Pierre Warnier d89b4aaa64 Makefile: drop PROFILE var, fix stale comments
Address Copilot review round 2:

- Remove PROFILE variable and hardcode --release / target/release/.
  Cargo maps "dev" profile to target/debug/ (not target/dev/), so
  PROFILE=dev would silently break install. Nobody installs debug
  builds of setuid-root tools.

- Fix stale comments in Makefile and README that referenced the old
  "chmod on symlink follows through" approach. The current code uses
  install -Dm4755 directly.
2026-04-16 10:43:14 +02:00
Pierre Warnier de1e96c605 Makefile: single cargo build + atomic install -Dm mode
Address review comments on #139:

- build: replace 14-call loop with a single
  `cargo build --workspace --bins --exclude shadow-rs` invocation,
  eliminating per-tool Cargo startup/metadata overhead. Matches the
  uutils/coreutils GNUmakefile pattern.

- install: use `install -Dm4755` / `install -Dm0755` to set mode atomically
  during install instead of post-install chmod. Errors no longer swallowed:
  if install can't set the setuid bit, `make install` now fails loudly.

- install-multicall: same — `install -Dm4755` sets the shadow-rs binary
  setuid in one step.
2026-04-15 14:40:52 +02:00
Pierre Warnier 698eaf7cf3 make install: default to standalone per-tool binaries (#138)
Switch the default install to build and install 14 standalone per-tool
binaries with a least-privilege setuid layout matching GNU shadow-utils.
Only passwd/chfn/chsh/newgrp are setuid-root; the other 10 tools are 0755.

The previous multicall install is preserved as `make install-multicall`
for container/embedded use cases where the ~14x disk savings matter and
the enlarged setuid attack surface is acceptable.

The old layout chmod'd symlinks pointing to the multicall binary, which
(because chmod follows symlinks) marked the underlying ELF setuid-root.
That meant all 14 tools ran with euid=0 when invoked via symlink. Each
tool's internal getuid() check was defense-in-depth, not OS-level least
privilege. The standalone layout gives the latter.

Raised by @oech3 in uutils/coreutils#11828.
2026-04-15 14:19:21 +02:00
Pierre Warnier 5d8ac9587d review: address all 13 Copilot review comments
- Fix cargo build --release --workspace in test scripts
- Document setuid-on-symlinks as intentional in Makefile
- Warn (not abort) on subid allocation failure in useradd
- Propagate subid file read errors instead of unwrap_or_default
- Update SELinux docs to describe best-effort behavior
- Add env_clear() to getfattr subprocess in selinux.rs
- Use hardening::sanitized_env() in nscd.rs instead of local reimpl
- Create testuser in pam-e2e.sh, add cleanup
- Document su-as-root limitation in pam-e2e.sh
- Fix set -e exit code capture in gnu-compat.sh
- Add 5 date validation unit tests in chage
- Wire audit::log_user_event into all 7 tools
2026-03-24 15:42:08 +01:00
Pierre Warnier 8ba8f940fd prod: GNU compat tests, PAM e2e, setuid, SELinux, audit, packaging
Fixes #93 — GNU compatibility test suite (tests/gnu-compat.sh)
Fixes #94 — PAM end-to-end test script (tests/pam-e2e.sh)
Fixes #95 — Setuid permissions in Makefile (chmod 4755)
Fixes #96 — nscd env_clear() on subprocess spawning
Fixes #97 — SELinux file context support (getfattr/chcon/restorecon)
Fixes #98 — Audit logging to syslog/auditd
Fixes #99 — subuid/subgid allocation in useradd
Fixes #100 — Debian packaging (debian/control, debian/rules)
Fixes #101 — Fedora packaging (shadow-rs.spec)
Fixes #103 — Recursive chown on usermod UID change
Fixes #104 — Proper date validation (Feb 31 rejected)

456 tests, zero clippy warnings. Binary: 894KB (release-small).
2026-03-24 15:15:21 +01:00
Pierre Warnier 896a4214b6 infra: uutils compliance — edition 2024, unsafe deny, dead_code deny
Fixes #79 — CODE_OF_CONDUCT.md (Contributor Covenant 2.1)
Fixes #80 — locales/en-US.ftl for all 14 tools
Fixes #81 — rust-version = "1.85.0" in workspace
Fixes #82 — edition 2024 (resolver 3, unsafe extern blocks)
Fixes #83 — GitHub Actions CI (fmt, clippy, test, cargo-deny)
Fixes #84 — .clippy.toml with MSRV and quality thresholds
Fixes #85 — deny.toml tightened (multiple-versions=deny)
Fixes #87 — panic=abort, codegen-units=1 in release profile
Fixes #89 — Makefile with install/uninstall/clean targets
Fixes #90 — workspace lints: cargo group, unused_qualifications

Key policy changes enforced by cargo:
- unsafe_code = "deny" workspace-wide. Only pam.rs and crypt.rs
  have #[allow(unsafe_code)] (C FFI boundaries).
- dead_code = "deny" workspace-wide. No unused code allowed.
- Edition 2024: set_var/remove_var are unsafe, so sanitize_env
  returns a Vec instead of mutating. Signal handler removed
  (was unsafe). localtime_r replaced with pure Rust calendar math.

456 tests, zero clippy warnings.
2026-03-24 14:30:22 +01:00