Files
shadow/CONTRIBUTING.md
T
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

6.5 KiB

Contributing to shadow-rs

Thanks for wanting to contribute to shadow-rs! This document explains everything you need to know.

Before you start, also check:

Warning

shadow-rs is original code and cannot contain any code from GNU shadow-utils or other GPL-licensed implementations. This means that we cannot accept any changes based on the shadow-maint/shadow source code (GPL-2.0+). To make sure that cannot happen, you must not read or link to the GNU source code. This includes paraphrasing or translating their logic, and feeding it into an LLM for translation.

Safe Reference Sources

When implementing a tool, reference ONLY these sources:

Source License Use
POSIX specification Open standard Behavioral spec
Man pages (man7.org) Documentation Command options, file formats
FreeBSD src BSD-2-Clause Implementation patterns
OpenBSD src ISC Implementation patterns
musl libc MIT pwd/grp/shadow C API
sudo-rs Apache-2.0 / MIT PAM, privilege-dropping

Process: Read the POSIX spec and man page for behavioral requirements, then write an original implementation. Never search for or view the C source.

Getting Started

Requirements

  • Docker and Docker Compose (all builds/tests run in containers)
  • Git

Setup

git clone https://github.com/uutils/shadow
cd shadow
docker compose build
./hooks/install.sh  # install pre-commit and pre-push hooks

The hooks are deliberately small. pre-commit checks formatting and runs clippy; pre-push runs make test once, on Debian, and skips entirely for a push that carries no new commits (a tag, a deletion, a branch the remote already has). Everything else — MSRV, cargo-deny, the static musl archive, the three-distro matrix as root, the GNU output comparison and the end-to-end deployment image — runs in CI on the pull request, in parallel and with retries. A hook that tried to match CI took minutes on every push and was routed around with --no-verify, which protects nothing.

There is a second hook system, .pre-commit-config.yaml, run by pre-commit.ci on the pull request. It does not overlap: whitespace, line endings, large files, and the spell checker. The two are kept apart on purpose — file hygiene needs no container, and the Rust checks need one.

Building and Testing

make check is the single definition of what CI gates on — formatting, clippy with and without pam, and the tests in both configurations. Run it in a container; do not build on the host.

docker compose run --rm debian make check           # everything CI gates on
docker compose run --rm debian make test-gnu-compat # compare against GNU

docker compose run --rm debian cargo build          # build
docker compose run --rm debian cargo test --workspace  # test on Debian
docker compose run --rm alpine cargo test --workspace  # test on Alpine (musl)
docker compose run --rm fedora cargo test --workspace  # test on Fedora (SELinux)

All three must pass. The alpine image tests musl with dynamic linking; the release also ships a static musl archive, built without PAM, which make dist-musl reproduces and CI builds on every pull request. What that build gives up is documented in docs/PLATFORM-SUPPORT.md.

Linting

docker compose run --rm debian cargo clippy --workspace --all-targets -- -D warnings
docker compose run --rm debian cargo fmt --all --check

Design Goals

  • Drop-in replacement: same flags, same exit codes, same output format as GNU shadow-utils. Differences with GNU are treated as bugs.
  • uutils compatible: tools use uucore (UResult<()>, #[uucore::main], show_error!) so they can be merged into the uutils ecosystem.
  • Memory safe: no .unwrap(), no panic!, no std::process::exit in library code.
  • Well-tested: unit tests, proptest, integration tests, fuzz targets.

Our Rust Style

Don't panic!

Never use .unwrap() or panic!. Use unreachable! only with a justifying comment. Return errors via UResult<()>.

Don't exit

Utilities must be embeddable. Return UResult<()> from uumain. The uucore::bin!() macro handles process::exit in the generated main().

unsafe

Denied at the workspace level (unsafe_code = "deny"). Only three FFI boundary modules are exempted: shadow_core::pam (PAM C library), shadow_core::crypt (POSIX crypt(3)) and shadow_core::process (setuid, signal masks, getpwuid_r). Every unsafe block must have a // SAFETY: comment.

str, OsStr & Path

Use OsStr and Path for filesystem paths. Only convert to String/str when you know the data is always valid UTF-8 (usernames, for example).

Comments

Comments explain why, not what. If you need to describe what code does, improve the naming instead.

Commits

  • Small, atomic commits — one logical change per commit
  • Tool-prefixed messages: passwd: fix buffer handling
  • Test commits: tests/passwd: add aging test
  • Do not move code in the same commit as a behavior change

Pull Requests

  • One issue per PR, reference it: Fixes #N
  • Title prefixed with tool name, under 70 characters
  • Branch naming: fix/N-short-description or feat/N-short-description
  • CI must pass (clippy, fmt, tests on all 3 distros)
  • Keep PRs small and self-contained

AI-Assisted Development

AI tools (Copilot, Claude, etc.) are allowed and treated like any other development tool.

  • All code must be understood, reviewed, and tested by human contributors
  • Clean-room rule is absolute — never feed GPL source into an LLM
  • Reference only: POSIX specs, man pages, BSD-licensed implementations
  • If a PR is substantially AI-generated, note it (transparency, not a blocker)
  • Quality is the gate — contributions are judged on correctness and test coverage, not authorship method

Licensing

shadow-rs is distributed under the terms of the MIT License.

Acceptable dependency licenses: MIT, Apache-2.0, ISC, BSD-2-Clause, BSD-3-Clause, CC0-1.0, Unicode-3.0, Zlib, MPL-2.0.

No GPL or LGPL dependencies, ever.