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

168 lines
8.5 KiB
Markdown

# 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
```shell
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.