Files
shadow/docs/PLATFORM-SUPPORT.md
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

8.5 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 inside an ubuntu:22.04 container, so they run on any system with glibc 2.34 or newer — Debian 12, Ubuntu 22.04, RHEL 9 and anything later. The runner itself is 24.04, because 22.04 runners are being retired; building on it directly raised the floor to 2.39, since Rust's std links pidfd_spawnp when the build host has it. Older systems than that 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.99-trixie glibc Linux-PAM headers
alpine rust:1.99-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").
  • chfn and chsh become 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.
  • login has no way to authenticate at all, so the applet prints PAM support is not compiled in and exits 1. A static image that needs a getty-driven login must use the glibc archive. utmp and wtmp recording is also a no-op under musl, which has stub pututxline and updwtmpx; who(1) and last(1) see nothing there.
  • expiry still reports, and -c still refuses an expired account; the forced change it performs on an expired password goes through PAM, so the static build says so and points at passwd(1) instead.

Unaffected: passwd -S/-l/-u/-d/-e/-n/-x/-w/-i; newgrp, sg and gpasswd, which authenticate against the group password through crypt(3) rather than PAM -- so a group administrator can still use them; chage, newuidmap and newgidmap, which never authenticate through PAM; and the seventeen root-only tools, useradd through grpunconv, which reach the account files directly.

2. No NSS

shadow_core resolves the calling user through getpwuid_r, used by passwd, chfn, chsh, chage, newgrp, sg, gpasswd, expiry, newuidmap and newgidmap; login resolves the name typed at the prompt through getpwnam.

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 eleven 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, sg and gpasswd fail against a $y$ group password, gpasswd cannot set one on a host configured for yescrypt, and -c YESCRYPT is rejected by chpasswd, chgpasswd and newusers. 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::crypt asks for -lcrypt. musl implements crypt() inside libc and ships no libcrypt; Rust's self-contained musl sysroot has no libcrypt.a either, 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 because musl-dev ships an empty libcrypt.a that absorbs the flag.)
  • pam guard. shadow_core::pam emits compile_error! when built for musl with crt-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.