PREFIX ?= /usr/local BINDIR ?= $(PREFIX)/bin SBINDIR ?= $(PREFIX)/sbin # Tools that need setuid-root to allow non-root callers (change own password, # GECOS, shell, effective group, or administer a group as a group admin). SETUID_TOOLS = passwd chfn chsh newgrp gpasswd sg newuidmap newgidmap # Root-only tools (no setuid; fail at getuid() check for non-root callers). ROOT_TOOLS = useradd userdel usermod chpasswd chgpasswd newusers \ groupadd groupdel groupmod pwck grpck vipw vigr \ pwconv pwunconv grpconv grpunconv # Tools an ordinary user runs, and which therefore go in bin rather than sbin: # sbin is not on a normal user's PATH, so `passwd` there is `command not # found`. This is where upstream shadow puts them -- verified against the Debian # package, which ships passwd, chage, chfn, chsh and newgrp in /usr/bin and # everything else in /usr/sbin. # login lives in bin, and is root-only without being setuid: getty runs it as # root, and it refuses to run as anyone else. USER_BIN_TOOLS = login # Tools a user runs on their own account that have to read /etc/shadow for # it: expiry reads the caller's line to judge it, chage -l reads it to print # it. Neither writes anything for a caller who is not root -- both check the # real uid -- so the upstream suite ships them setgid shadow, enough to read the # file and no more. SETGID_SHADOW_TOOLS = expiry chage # The group allowed to read /etc/shadow. Debian and Ubuntu have one, `shadow`, # and that is what the tools above are made setgid to. Fedora and Arch have no # such group: /etc/shadow there is readable by root alone, and the upstream tool # that reads it on a user's behalf, chage, is setuid root instead. Where the # group does not exist at install time -- those systems, or a packager's # chroot -- the tools above follow that convention and are installed setuid # root; they check the caller's real uid, so the extra privilege buys a user # nothing beyond reading their own line. Packagers whose target has the group # under another name set SHADOW_GROUP. SHADOW_GROUP ?= shadow ifneq ($(shell getent group '$(SHADOW_GROUP)' >/dev/null 2>&1 && echo yes),) SETGID_INSTALL = install -Dm2755 -g '$(SHADOW_GROUP)' SETGID_LABEL = setgid $(SHADOW_GROUP) (2755) else SETGID_INSTALL = install -Dm4755 SETGID_LABEL = setuid (4755; no group '$(SHADOW_GROUP)' on this system) endif USER_TOOLS = $(SETUID_TOOLS) $(USER_BIN_TOOLS) $(SETGID_SHADOW_TOOLS) ALL_TOOLS = $(SETUID_TOOLS) $(ROOT_TOOLS) $(USER_BIN_TOOLS) $(SETGID_SHADOW_TOOLS) .PHONY: all build build-multicall build-arm64 dist-musl check test test-upstream-compat test-unprivileged test-arm64 verify-release install install-multicall uninstall clean all: build # `pam` is not a default cargo feature, so a plain build produces a `passwd` # that refuses the interactive change path ("PAM support is not compiled in"). # Installed binaries must have it; the PAM headers are already listed as a # build requirement in the README. build: cargo build --release --workspace --bins --exclude uu_shadow \ --features uu_passwd/pam,uu_chfn/pam,uu_chsh/pam,uu_login/pam,uu_expiry/pam build-multicall: cargo build --release --bin shadow-rs --features pam # Static musl archive, published as a release asset next to the glibc one # (dist-workspace.toml runs this target; see issue #224). Built without `pam`: # Linux-PAM dlopen()s its modules, which a static binary cannot do, and # shadow-core refuses the combination at compile time. The archive name carries # the "-static" label and docs/PLATFORM-SUPPORT.md, shipped inside, spells out # what the build does and does not do. MUSL_TARGET = x86_64-unknown-linux-musl MUSL_ARCHIVE = uu_shadow-$(MUSL_TARGET)-static MUSL_DIST_DIR = target/dist-musl dist-musl: rustup target add $(MUSL_TARGET) cargo build --release --locked --bin shadow-rs --target $(MUSL_TARGET) @# A DT_NEEDED entry would mean a shared object slipped in and the archive @# is not the self-contained binary its name promises. @if readelf -d target/$(MUSL_TARGET)/release/shadow-rs | grep -q NEEDED; then \ echo "error: shadow-rs is not statically linked" >&2; exit 1; \ fi rm -rf $(MUSL_DIST_DIR) mkdir -p $(MUSL_DIST_DIR)/$(MUSL_ARCHIVE) cp target/$(MUSL_TARGET)/release/shadow-rs LICENSE README.md CHANGELOG.md \ docs/PLATFORM-SUPPORT.md $(MUSL_DIST_DIR)/$(MUSL_ARCHIVE)/ tar -C $(MUSL_DIST_DIR) --owner=0 --group=0 --numeric-owner \ -czf $(MUSL_DIST_DIR)/$(MUSL_ARCHIVE).tar.gz $(MUSL_ARCHIVE) cd $(MUSL_DIST_DIR) && sha256sum $(MUSL_ARCHIVE).tar.gz > $(MUSL_ARCHIVE).tar.gz.sha256 @echo "Built $(MUSL_DIST_DIR)/$(MUSL_ARCHIVE).tar.gz" # Everything CI gates on, in one place, so the README, CONTRIBUTING, the git # hooks and ci.yml stop each carrying their own copy of the command list. # Run it inside a container: docker compose run --rm debian make check check: cargo fmt --all --check cargo clippy --workspace --all-targets -- -D warnings cargo clippy --workspace --all-targets --features pam -- -D warnings cargo clippy --workspace --all-targets --all-features -- -D warnings $(MAKE) test $(MAKE) test-unprivileged # `install` ships binaries built with pam, so the tests must cover that build # as well as the default one: the feature changes which code paths exist. test: cargo test --workspace cargo test --workspace --features pam ARM64_TARGET = aarch64-unknown-linux-gnu ARM64_BIN = target/$(ARM64_TARGET)/release/shadow-rs # Cross-build for arm64 and check the result actually runs. # # The release publishes an arm64 archive; nothing used to execute one, so a # broken arm64 binary would reach a user before anyone ran it. The toolchain is # installed here rather than baked into the image: it is 32 packages, and every # other job would carry them for a check that is run occasionally. build-arm64: dpkg --add-architecture arm64 apt-get update apt-get install -y --no-install-recommends \ gcc-aarch64-linux-gnu libpam0g-dev:arm64 libcrypt-dev:arm64 qemu-user-static rustup target add $(ARM64_TARGET) CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \ cargo build --release --locked --target $(ARM64_TARGET) --bin shadow-rs --features pam # Runs under qemu user-mode emulation, which is enough to catch what actually # differs across architectures: integer widths, struct layouts, and the crypt # and PAM FFI boundaries. test-arm64: build-arm64 ARM64_BIN=$(ARM64_BIN) bash tests/arm64-smoke.sh # Run the archives of a published release the way a user will, before the # release is announced: make verify-release TAG=0.5.0. Runs on the host, since # it drives docker (debian:12 for the glibc archive, alpine for the static # one, the debian image with qemu for arm64); needs `gh` logged in. verify-release: @test -n "$(TAG)" || { echo "usage: make verify-release TAG=" >&2; exit 2; } bash tests/verify-release.sh $(TAG) # Run the suite as an unprivileged user. # # Every container in docker-compose.yml runs as root, so a test that silently # assumes root passes here and fails in CI, where one job runs as an ordinary # user -- and a test asserting a failure exit code passes for the wrong reason, # because "permission denied" is also a failure. This target is that job, # locally. The binaries are already built, so it only re-runs them. # # SHADOW_TEST_REQUIRE_ROOT is cleared deliberately: it exists to turn a skip # into a failure when the suite *is* running as root, which is the opposite of # what this target does. UNPRIV_USER = shadowtest test-unprivileged: cargo test --workspace --no-run 2>&1 \ | sed -n 's/.*(\(target\/debug\/deps\/[^)]*\))$$/\1/p' > /tmp/test-binaries @id -u $(UNPRIV_USER) >/dev/null 2>&1 || useradd -m $(UNPRIV_USER) @chmod -R a+rX target @fail=0; while read -r bin; do \ printf '== %s\n' "$$bin"; \ su $(UNPRIV_USER) -s /bin/sh -c "SHADOW_TEST_REQUIRE_ROOT= $(CURDIR)/$$bin" \ || fail=1; \ done < /tmp/test-binaries; \ exit $$fail # Compare our output and exit codes against the upstream tools installed # alongside. Needs root and the distribution's shadow package, so it belongs # in a container. test-upstream-compat: bash tests/upstream-compat.sh # Default install: 28 standalone per-tool binaries, with the setuid layout and # the bin/sbin split the upstream package uses. Only $(SETUID_TOOLS) are setuid. install: build @for tool in $(SETUID_TOOLS); do \ install -Dm4755 target/release/$$tool $(DESTDIR)$(BINDIR)/$$tool || exit 1; \ done @for tool in $(USER_BIN_TOOLS); do \ install -Dm0755 target/release/$$tool $(DESTDIR)$(BINDIR)/$$tool || exit 1; \ done @for tool in $(SETGID_SHADOW_TOOLS); do \ $(SETGID_INSTALL) target/release/$$tool $(DESTDIR)$(BINDIR)/$$tool || exit 1; \ done @for tool in $(ROOT_TOOLS); do \ install -Dm0755 target/release/$$tool $(DESTDIR)$(SBINDIR)/$$tool || exit 1; \ done @echo "Installed $(words $(ALL_TOOLS)) standalone binaries" @echo " $(DESTDIR)$(BINDIR)/ setuid (4755): $(SETUID_TOOLS)" @echo " $(DESTDIR)$(BINDIR)/ user (0755): $(USER_BIN_TOOLS)" @echo " $(DESTDIR)$(BINDIR)/ $(SETGID_LABEL): $(SETGID_SHADOW_TOOLS)" @echo " $(DESTDIR)$(SBINDIR)/ root (0755): $(ROOT_TOOLS)" # Opt-in install: single multicall binary with symlinks. Smaller footprint. # The binary is installed setuid-root for $(SETUID_TOOLS); the other applets # drop back to the caller's uid before running, so the privilege model matches # the per-tool layout. Intended for container/embedded use. install-multicall: build-multicall install -Dm4755 target/release/shadow-rs $(DESTDIR)$(SBINDIR)/shadow-rs @install -d $(DESTDIR)$(BINDIR) @for tool in $(USER_TOOLS); do \ ln -sf $(SBINDIR)/shadow-rs $(DESTDIR)$(BINDIR)/$$tool || exit 1; \ done @for tool in $(ROOT_TOOLS); do \ ln -sf shadow-rs $(DESTDIR)$(SBINDIR)/$$tool || exit 1; \ done @echo "Installed multicall shadow-rs to $(DESTDIR)$(SBINDIR)/ with" @echo " $(words $(USER_TOOLS)) symlinks in $(DESTDIR)$(BINDIR)/: $(USER_TOOLS)" @echo " $(words $(ROOT_TOOLS)) symlinks in $(DESTDIR)$(SBINDIR)/: $(ROOT_TOOLS)" uninstall: @for tool in $(ALL_TOOLS); do \ rm -f $(DESTDIR)$(BINDIR)/$$tool $(DESTDIR)$(SBINDIR)/$$tool; \ done rm -f $(DESTDIR)$(BINDIR)/shadow-rs $(DESTDIR)$(SBINDIR)/shadow-rs @echo "Uninstalled shadow-rs from $(DESTDIR)$(BINDIR)/ and $(DESTDIR)$(SBINDIR)/" clean: cargo clean