* 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.
7.3 KiB
Platform support
What shadow-rs builds and runs on, which archives a release publishes, and exactly where one build behaves differently from another.
The distinction that matters most is the C library. Every release publishes
glibc archives for two architectures and one static musl archive, and the two
kinds are not interchangeable: the glibc archives are the full build; the
static musl archive is a self-contained binary for containers and embedded
images that gives up three capabilities, each stated below. Its name carries a
-static suffix so the two are never confused.
Release archives
| Archive | Architecture | libc | Linking | pam feature |
Use it for |
|---|---|---|---|---|---|
uu_shadow-x86_64-unknown-linux-gnu.tar.gz |
x86-64 | glibc | dynamic (libpam, libcrypt, libc) | on | Any regular distribution install. The full build. |
uu_shadow-aarch64-unknown-linux-gnu.tar.gz |
arm64 | glibc | dynamic (libpam, libcrypt, libc) | on | The same build for arm64 servers and Apple-silicon containers. |
uu_shadow-x86_64-unknown-linux-musl-static.tar.gz |
x86-64 | musl | static, no runtime dependencies | off | Minimal containers and embedded images with a local /etc/passwd, no directory service and no PAM stack. |
Every archive ships a .sha256 file in sha256sum -c format and contains the
shadow-rs multicall binary with LICENSE, README.md and CHANGELOG.md;
the musl archive also carries this document, so the trade-off travels with the
binary.
The glibc archives are dist's regular target builds with features = ["pam"].
arm64 is built on a native arm64 runner, not cross-compiled, 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.
The musl archive is produced differently. dist cannot vary features per target
and pam must be off for musl (see below), so it is built by make dist-musl
and attached as an extra artifact of the same release. Anyone can reproduce it
with that one command.
Both glibc archives are built on Ubuntu 24.04 images, so they carry that image's glibc floor. A system older than it should use the static musl archive, which has no libc dependency at all.
Test matrix
CI runs the full suite on three images:
| Image | Base | libc | PAM | SELinux |
|---|---|---|---|---|
debian |
rust:1.98-trixie |
glibc | Linux-PAM | headers |
alpine |
rust:1.98-alpine3.23 |
musl (dynamic) | Linux-PAM | none |
fedora |
fedora:44 |
glibc | Linux-PAM | enforcing |
The base images are pinned. An unpinned tag lands a new toolchain on main
with no pull request to run it against, and clippy gains lints between Rust
releases; Renovate proposes the bumps instead.
The alpine image links musl dynamically and therefore does have PAM; it
tests musl's libc behaviour (no NSS, no yescrypt), not the static archive. The
static archive has its own job, Static musl archive, which on every pull
request runs make dist-musl, smoke-tests the result, runs shadow-core's
unit tests against static musl, and checks that requesting pam there is
rejected at compile time. A tag is never the first time the archive is built.
What the static musl build gives up
A static musl binary builds and runs; the whole test suite passes on musl. What it cannot do is inherent to static linking and to musl, not to shadow-rs, and for account-management tools none of it is cosmetic.
1. No PAM
Linux-PAM loads its modules with dlopen(3). A fully static binary cannot do
that: musl's static dlopen is a stub that returns "Dynamic loading not
supported", and no distribution ships a libpam.a to link against in the
first place. shadow-core refuses the pam feature on static musl with a
compile-time error, so the build cannot be misconfigured into a binary whose
authentication path never works.
Effect, and this is the heaviest of the three gaps:
passwd's interactive password change is unavailable and says so ("PAM support is not compiled in").chfnandchshbecome root-only. They authenticate the caller through PAM before applying a change, and a setuid-root tool must fail closed rather than apply an unverified one, so without PAM they refuse every non-root invocation outright.
Unaffected: passwd -S/-l/-u/-d/-e/-n/-x/-w/-i, newgrp (which authenticates
against the group password through crypt(3), not PAM), and the other eleven
tools, which are root-only anyway and reach /etc/shadow directly.
2. No NSS
shadow_core::process resolves the calling user through getpwuid_r, used by
passwd, chfn, chsh, chage and newgrp.
glibc answers such lookups through its NSS module system, so it sees users from
LDAP, SSSD, Active Directory or systemd-userdb. musl has no NSS module system
and reads /etc/passwd directly. On a directory-joined host those five tools
do not see network users at all.
3. No yescrypt ($y$)
musl's crypt(3) implements DES, MD5, SHA-256, SHA-512 (including rounds=)
and bcrypt, byte-identical to libxcrypt for all of them. It does not implement
yescrypt.
This is not a marginal format: Debian 12+ and Ubuntu 24.04 use yescrypt as
the default password hash. A musl build can neither verify nor produce $y$
hashes, so newgrp fails against a $y$ group password and chpasswd -c YESCRYPT is rejected. The prefix guard in shadow_core::crypt reports the
unsupported method explicitly; it never falls back to a weaker hash silently.
The default method is SHA-512, which is unaffected.
Where the static build is the right choice
Minimal containers and embedded images: a local /etc/passwd, no directory
service, no PAM stack. There none of the three gaps costs anything, and a
single binary with no runtime dependencies is worth having. Anywhere one of
them matters, use the glibc archive.
Building for musl
make dist-musl
The target adds the x86_64-unknown-linux-musl toolchain component, builds the
multicall binary in release mode with the lockfile enforced and without pam,
verifies that the binary has no DT_NEEDED entry, and writes the archive and
its checksum to target/dist-musl/.
Two details in the code make that work:
- crypt(3) linkage. On glibc systems crypt(3) lives in libcrypt (libxcrypt
on Debian, Fedora and derivatives), so
shadow_core::cryptasks for-lcrypt. musl implementscrypt()inside libc and ships no libcrypt; Rust's self-contained musl sysroot has nolibcrypt.aeither, so the request would fall through to the host's glibc-built libxcrypt and fail on glibc-only symbols. The attribute is therefore#[cfg_attr(not(target_env = "musl"), link(name = "crypt"))]. (Alpine builds never hit this becausemusl-devships an emptylibcrypt.athat absorbs the flag.) pamguard.shadow_core::pamemitscompile_error!when built for musl withcrt-static, the default for the musl target. Dynamic musl, as in the Alpine test image, is unaffected and links libpam normally.
libxcrypt itself is LGPL-2.1+ and cannot be vendored to fill the yescrypt
gap: shadow-rs takes no GPL or LGPL dependencies. Pure-Rust alternatives exist
(sha-crypt, yescrypt, both MIT/Apache-2.0) and match libxcrypt output, but
the yescrypt crate is self-declared unaudited, which rules it out of a
setuid-root path for now.