From 613636c7bbbd499c55d002475d7c41ad5077395c Mon Sep 17 00:00:00 2001 From: Pierre Warnier Date: Sun, 6 Sep 2026 12:10:06 +0200 Subject: [PATCH] tests: cross-build for arm64 and actually run it The release has published an arm64 archive since 0.3.0 and nothing had ever executed one. CI builds it on a native arm64 runner and ships it, so a binary that was broken on that architecture would reach a user before anyone ran it; the checks so far went as far as the ELF header and the linked libraries. make test-arm64 cross-compiles the multicall binary and runs it under qemu user-mode emulation against a prefixed tree: useradd with a home and a supplementary group, chpasswd producing a SHA-512 hash, chage computing its dates, gpasswd adding a member, usermod renaming across files, userdel -r, and pwck reading back what the others wrote. Eighteen assertions, all passing on the current tree. Emulation is enough for what actually differs across architectures -- integer widths, struct layouts, and the crypt and PAM FFI boundaries -- and it needs no arm64 machine and no binfmt handler on the host, which rootless Docker cannot register anyway. The cross toolchain is installed by the target rather than baked into the image: it is 32 packages, and every other job would carry them for a check that runs occasionally. Also confirmed the published 0.4.0 arm64 archive runs, by the same route. --- CONTRIBUTING.md | 1 + Makefile | 26 ++++++++- tests/arm64-smoke.sh | 135 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 161 insertions(+), 1 deletion(-) create mode 100755 tests/arm64-smoke.sh 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 ]