diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b04bcfe..11e86fb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,6 +74,7 @@ container; do not build on the host. ```shell 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 make test-arm64 # cross-build arm64 and run it docker compose run --rm debian cargo build # build docker compose run --rm debian cargo test --workspace # test on Debian diff --git a/Makefile b/Makefile index 30d4e69..57ef3a1 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ USER_TOOLS = $(SETUID_TOOLS) chage ALL_TOOLS = $(SETUID_TOOLS) $(ROOT_TOOLS) chage -.PHONY: all build build-multicall dist-musl check test test-gnu-compat install install-multicall uninstall clean +.PHONY: all build build-multicall build-arm64 dist-musl check test test-gnu-compat test-arm64 install install-multicall uninstall clean all: build @@ -78,6 +78,30 @@ 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 + # Compare our output and exit codes against the GNU tools installed alongside. # Needs root and the GNU shadow package, so it belongs in a container. test-gnu-compat: diff --git a/tests/arm64-smoke.sh b/tests/arm64-smoke.sh new file mode 100755 index 0000000..33fde08 --- /dev/null +++ b/tests/arm64-smoke.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# Run the aarch64 build and check it actually works. +# +# The release publishes an arm64 archive, and until this existed nothing ever +# executed one: CI builds it on a native arm64 runner and then ships it, so a +# broken arm64 binary would reach a user before anyone ran it. The x86-64 +# archive is exercised by the whole test suite; this is the arm64 equivalent, +# reduced to the operations that touch every account file. +# +# The binary is cross-compiled and run under qemu's user-mode emulation with +# the cross toolchain's arm64 libraries as the loader path. That is enough to +# catch what actually breaks across architectures -- a wrong integer width, a +# struct layout assumption, an endianness slip in the crypt or PAM FFI -- none +# of which need a real arm64 machine to show up. +# +# Usage: docker compose run --rm debian make test-arm64 + +set -uo pipefail + +PASS=0 +FAIL=0 +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' + +ok() { printf " ${GREEN}PASS${NC}: %s\n" "$1"; PASS=$((PASS + 1)); } +bad() { printf " ${RED}FAIL${NC}: %s\n" "$1"; FAIL=$((FAIL + 1)); } + +check() { + local name="$1" + shift + if "$@" >/dev/null 2>&1; then ok "$name"; else bad "$name"; fi +} + +# Assert that a file matches an extended regular expression. +contains() { + local name="$1" file="$2" pattern="$3" + if grep -qE "$pattern" "$file"; then + ok "$name" + else + bad "$name" + sed 's/^/ /' "$file" | head -5 + fi +} + +BIN="${ARM64_BIN:?ARM64_BIN must point at the cross-built binary}" +SYSROOT="${ARM64_SYSROOT:-/usr/aarch64-linux-gnu}" +QEMU=(qemu-aarch64-static -L "$SYSROOT") + +if [ ! -x "$BIN" ]; then + echo "error: $BIN is missing; run 'make build-arm64' first" >&2 + exit 1 +fi + +echo "=== the binary is arm64 and runs ===" +if file -b "$BIN" | grep -q 'ARM aarch64'; then + ok "cross-compiled for ARM aarch64" +else + bad "not an ARM aarch64 binary: $(file -b "$BIN")" + exit 1 +fi + +version=$("${QEMU[@]}" "$BIN" --version 2>&1) +if [ -n "$version" ]; then ok "runs: $version"; else bad "would not run"; exit 1; fi + +applets=$("${QEMU[@]}" "$BIN" --list 2>/dev/null | tail -n +2 | wc -l) +if [ "$applets" -ge 15 ]; then + ok "carries $applets applets" +else + bad "expected at least 15 applets, found $applets" +fi + +# ── A prefix tree, so nothing here touches the container's own accounts ── + +T=$(mktemp -d) +trap 'rm -rf "$T"' EXIT +mkdir -p "$T/etc" "$T/home" +printf 'root:x:0:0:root:/root:/bin/sh\n' >"$T/etc/passwd" +printf 'root:!:19000:0:99999:7:::\n' >"$T/etc/shadow" +printf 'root:x:0:\nstaff:x:2000:\n' >"$T/etc/group" +printf 'root:!::\nstaff:!::\n' >"$T/etc/gshadow" +printf 'UID_MIN 1000\nGID_MIN 1000\nENCRYPT_METHOD SHA512\n' >"$T/etc/login.defs" +: >"$T/etc/subuid" +: >"$T/etc/subgid" + +echo "=== account operations ===" +check "useradd -m -G staff" "${QEMU[@]}" "$BIN" useradd -P "$T" -m -G staff alice +contains "the account is in passwd" "$T/etc/passwd" '^alice:x:1000:' +contains "and in shadow, with aging from login.defs" "$T/etc/shadow" '^alice:!:[0-9]+:0:99999:7:' +contains "and in the supplementary group" "$T/etc/group" '^staff:x:2000:alice' +check "the home directory was created" test -d "$T/home/alice" + +# crypt(3) is FFI, so it is the most likely thing to differ across +# architectures. A SHA-512 hash is 106 characters and starts with $6$. +echo 'alice:a long passphrase' | "${QEMU[@]}" "$BIN" chpasswd -P "$T" >/dev/null 2>&1 +contains "chpasswd wrote a SHA-512 hash" "$T/etc/shadow" '^alice:\$6\$[./A-Za-z0-9]{8,}\$[./A-Za-z0-9]{86}:' + +check "chage sets the aging fields" "${QEMU[@]}" "$BIN" chage -P "$T" -d 2026-01-01 -M 90 alice +aging=$("${QEMU[@]}" "$BIN" chage -P "$T" -l alice 2>/dev/null) +# The date arithmetic is pure integer maths and a good width canary. +if printf '%s' "$aging" | grep -q 'Jan 01, 2026' && printf '%s' "$aging" | grep -q 'Apr 01, 2026'; then + ok "chage -l computes the dates correctly" +else + bad "chage -l gave unexpected dates" + printf '%s\n' "$aging" | sed 's/^/ /' | head -3 +fi + +check "gpasswd adds a member" "${QEMU[@]}" "$BIN" gpasswd -P "$T" -a alice staff +contains "gshadow carries the member" "$T/etc/gshadow" '^staff:[^:]*:[^:]*:alice' + +check "usermod renames the account" "${QEMU[@]}" "$BIN" usermod -P "$T" -l ada alice +contains "the rename reached group" "$T/etc/group" '^staff:x:2000:ada' + +check "userdel -r removes it" "${QEMU[@]}" "$BIN" userdel -P "$T" -r ada +if grep -q '^ada:' "$T/etc/passwd"; then + bad "the account is still in passwd" +else + ok "the account is gone from passwd" +fi + +# pwck exits 2 for warnings, which a synthetic tree produces (no real shells), +# so anything up to 2 means it read and checked the files rather than failing. +"${QEMU[@]}" "$BIN" pwck -r "$T/etc/passwd" "$T/etc/shadow" >/dev/null 2>&1 +rc=$? +if [ "$rc" -le 2 ]; then + ok "pwck reads the files it just wrote (exit $rc)" +else + bad "pwck exited $rc" +fi + +echo "" +echo "=== Results ===" +printf " ${GREEN}PASS: %d${NC}\n" "$PASS" +printf " ${RED}FAIL: %d${NC}\n" "$FAIL" +[ "$FAIL" -eq 0 ]