diff --git a/README.md b/README.md index ef08332..3debd47 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,24 @@ make phar Run `make help` for the complete local development surface. +The PHP maintenance standalone builder acquires SPC through the fixed upstream +release identities in [spc-artifact.py](scripts/ci/spc-artifact.py). It verifies +the compressed archive and extracted binary for all four recorded platforms, +and publishes caches atomically without replacing an existing file. Linux +execution requires Python 3, sealed memory descriptors and bubblewrap at +`/usr/bin/bwrap` with working unprivileged user namespaces. One sealed copy +supplies every download, build and combine call through a private read-only +mount, including the embedded PHP loader's self-reads. These controls run in +the existing Python parity test lane. The namespace helper must be owned by +root without setuid, setgid or file capabilities. The SPC process receives +only `PATH`, `HOME`, `TMPDIR`, `SOURCE_DATE_EPOCH` and fixed `LC_ALL=C`. + +The legacy macOS standalone builder refuses execution. Use the supported native +Rust CLI or a qualified Linux legacy build. Native Rust CLI macOS releases are +unaffected. This binds the SPC component only. PHP, downloaded libraries, build +caches and each host platform still require their applicable toolchain +qualification. + Standalone release builds use the reviewed [dependency source policy](scripts/ci/standalone-dependencies.json). The workflow verifies the OpenSSL archive before compilation and checks the produced runtime after both cold builds and cache restores. Each release includes diff --git a/rust/parity/test_legacy_spc.py b/rust/parity/test_legacy_spc.py new file mode 100644 index 0000000..a128f29 --- /dev/null +++ b/rust/parity/test_legacy_spc.py @@ -0,0 +1,15 @@ +"""Run legacy builder trust controls in the existing host-Python parity lane. + +The qualified PHP test images intentionally do not supply host Python. Keep +these Python/descriptor controls in the lane that already owns that runtime. +""" +import importlib.util +from pathlib import Path + + +def load_tests(loader, _tests, _pattern): + source = Path(__file__).resolve().parents[2] / 'tests/test_spc_artifact.py' + spec = importlib.util.spec_from_file_location('legacy_spc_cases', source) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return loader.loadTestsFromModule(module) diff --git a/scripts/build.sh b/scripts/build.sh index 47ec37c..195d474 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -45,17 +45,6 @@ ensure_tools() { bash "$ROOT/scripts/ci/ensure-box.sh" "$BOX_VERSION" "$TOOLS_DIR/box" } -ensure_spc() { - local platform - platform="$(detect_platform)" - if [[ ! -x "$TOOLS_DIR/spc" ]]; then - echo ">> Downloading spc (static-php-cli) for ${platform}" - curl --proto '=https' --proto-redir '=https' -fsSL -o "$TOOLS_DIR/spc" \ - "https://dl.static-php.dev/static-php-cli/spc-bin/nightly/spc-${platform}" - chmod +x "$TOOLS_DIR/spc" - fi -} - install_composer_deps() { echo ">> Installing production Composer dependencies" composer install --no-dev --prefer-dist --no-interaction --optimize-autoloader @@ -98,29 +87,6 @@ normalize_mtimes() { touch -h -d "@$epoch" "$ROOT/src/GeneratedBuildInfo.php" 2>/dev/null || true } -spc_download_with_retry() { - if [[ ! "$SPC_DOWNLOAD_OUTER_ATTEMPTS" =~ ^[0-9]+$ ]] || [[ "$SPC_DOWNLOAD_OUTER_ATTEMPTS" -lt 1 ]]; then - echo "SPC_DOWNLOAD_OUTER_ATTEMPTS must be a positive integer" >&2 - exit 1 - fi - - local attempt delay - for ((attempt = 1; attempt <= SPC_DOWNLOAD_OUTER_ATTEMPTS; attempt++)); do - if ./spc download "$@"; then - return 0 - fi - - if [[ "$attempt" -lt "$SPC_DOWNLOAD_OUTER_ATTEMPTS" ]]; then - delay=$((attempt * 30)) - echo ">> spc dependency download attempt ${attempt}/${SPC_DOWNLOAD_OUTER_ATTEMPTS} failed; retrying in ${delay}s" >&2 - sleep "$delay" - fi - done - - echo "spc dependency download failed after ${SPC_DOWNLOAD_OUTER_ATTEMPTS} attempts" >&2 - return 1 -} - build_phar() { ensure_tools ensure_source_date_epoch @@ -134,23 +100,21 @@ build_phar() { } build_binary() { - build_phar - ensure_spc - local php_version="${PHP_VERSION:-8.4}" - local platform + local platform php_version out_name platform="$(detect_platform)" - local out_name="dw-${platform}" - - pushd "$TOOLS_DIR" >/dev/null - echo ">> Downloading PHP ${php_version} source + extension deps" - spc_download_with_retry --with-php="$php_version" --for-extensions="$SPC_EXTENSIONS" \ - --prefer-pre-built --without-suggestions --retry="$SPC_DOWNLOAD_RETRY" - echo ">> Compiling phpmicro with required extensions" - ./spc build "$SPC_EXTENSIONS" --build-micro - echo ">> Embedding PHAR into micro SAPI" - ./spc micro:combine "$PHAR_OUT" --output="$BUILD_DIR/$out_name" - popd >/dev/null - + if [[ "$platform" = macos-* ]]; then + echo 'Secure legacy macOS SPC execution is unavailable; use the supported native Rust CLI or a qualified Linux legacy build.' >&2 + return 1 + fi + # Refuse unsupported kernel execution before building any PHAR or fetching + # SPC. Acquisition remains available for every pinned platform. + bash "$ROOT/scripts/ci/ensure-spc.sh" "$platform" "$TOOLS_DIR/spc" check + build_phar + php_version="${PHP_VERSION:-8.4}" + out_name="dw-${platform}" + bash "$ROOT/scripts/ci/ensure-spc.sh" "$platform" "$TOOLS_DIR/spc" run \ + "$php_version" "$SPC_EXTENSIONS" "$SPC_DOWNLOAD_RETRY" "$SPC_DOWNLOAD_OUTER_ATTEMPTS" \ + "$PHAR_OUT" "$BUILD_DIR/$out_name" "$TOOLS_DIR" chmod +x "$BUILD_DIR/$out_name" echo ">> Standalone binary: $BUILD_DIR/$out_name" } diff --git a/scripts/ci/ensure-spc.sh b/scripts/ci/ensure-spc.sh new file mode 100755 index 0000000..79c231c --- /dev/null +++ b/scripts/ci/ensure-spc.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Acquire pinned SPC data or run the complete Linux pipeline through one holder. +set -euo pipefail +umask 077 +export LC_ALL=C +unset TAR_OPTIONS GZIP GZIP_OPT + +platform="${1:?Usage: ensure-spc.sh PLATFORM ABSOLUTE_CACHE [acquire|check|run ARGS...]}" +destination="${2:?Usage: ensure-spc.sh PLATFORM ABSOLUTE_CACHE [acquire|check|run ARGS...]}" +role="${3:-acquire}" +command -v python3 >/dev/null 2>&1 || { echo 'SPC requires Python 3 for descriptor-bound execution.' >&2; exit 1; } +source_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +# Capture and check exactly the code passed to isolated Python, with no reopen. +spc_code="$(cat "$source_dir/spc-artifact.py")"$'\n' +expected_code_sha=2bc2245ae63dfac589aecb609cc3e260b442d50fe5e1e0f5f59181eb12d3c28d +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum | awk '{print $1}'; else shasum -a 256 | awk '{print $1}'; fi +} +[[ "$(printf '%s' "$spc_code" | sha256_of)" = "$expected_code_sha" ]] || { echo 'SPC controller source differs.' >&2; exit 1; } +spc_python() { python3 -I -S -B -c "$spc_code" "$@"; } +read -r version asset_id archive_sha archive_size expected_sha expected_size release_url < <(spc_python metadata "$platform") + +refuse() { echo "SPC acquisition refused: $1" >&2; exit 1; } +case "$role" in + check) spc_python check "$platform"; exit 0 ;; + acquire) [[ $# -le 3 ]] || refuse 'unexpected acquisition arguments' ;; + run) spc_python check "$platform" ;; + *) refuse 'unknown operation' ;; +esac +[[ "$destination" = /* ]] || refuse 'destination must be absolute' +[[ ! -L "$destination" ]] || refuse 'cached artifact must not be a symlink' +directory="$(dirname "$destination")" +mkdir -p "$directory" +[[ "$(cd "$directory" && pwd -P)" = "$directory" ]] || refuse 'cache directory must be canonical' + +verify() { + local file="$1" digest="$2" size="$3" + [[ -f "$file" && ! -L "$file" && -O "$file" ]] || refuse 'artifact must be a regular file' + [[ -n "$(find "$file" -prune -type f -links 1)" ]] || refuse 'artifact must have one link' + [[ "$(wc -c < "$file")" -eq "$size" ]] || refuse 'artifact size differs from the reviewed asset' + [[ "$(sha256_of < "$file")" = "$digest" ]] || refuse 'artifact digest differs from the reviewed asset' +} + +acquire() { + if [[ -e "$destination" ]]; then verify "$destination" "$expected_sha" "$expected_size"; return; fi + held="$(mktemp -d "$directory/.spc-download.XXXXXX")" + trap 'rm -rf -- "$held"' EXIT + trap 'exit 1' HUP INT TERM + local url="$release_url" status attempt + for attempt in 1 2 3 4; do + [[ ! "$url" =~ [[:space:][:cntrl:]] && "$url" != *'#'* ]] || refuse 'invalid redirect URL' + case "$url" in + "$release_url"|https://release-assets.githubusercontent.com/*|https://objects.githubusercontent.com/*) ;; + *) refuse 'redirect host or scheme is outside the reviewed set' ;; + esac + # Only the downloader child inherits this file-size limit. Build + # outputs and dependency sources can legitimately exceed the archive. + status="$(ulimit -f 24576; exec curl --disable --proto '=https' --tlsv1.2 --silent --show-error --fail \ + --max-time 45 --connect-timeout 10 --max-filesize "$archive_size" \ + --dump-header "$held/headers" --output "$held/artifact" --write-out '%{http_code}' "$url")" || refuse 'download failed' + case "$status" in + 200) + verify "$held/artifact" "$archive_sha" "$archive_size" + [[ "$(tar -tzf "$held/artifact")" = spc ]] || refuse 'archive member differs' + tar -xzOf "$held/artifact" spc > "$held/spc" || refuse 'archive extraction failed' + verify "$held/spc" "$expected_sha" "$expected_size" + spc_python publish "$platform" "$held/spc" "$destination" + printf 'SPC %s %s acquired: asset=%s sha256=%s bytes=%s\n' "$version" "$platform" "$asset_id" "$expected_sha" "$expected_size" + return ;; + 301|302|303|307|308) + url="$(awk 'tolower($0) ~ /^location:/ { sub(/^[^:]*:[ \t]*/, ""); sub(/\r$/, ""); value=$0; count++ } + END { if (count != 1) exit 1; print value }' "$held/headers")" || refuse 'redirect requires one Location header' ;; + *) refuse 'unexpected HTTP status' ;; + esac + done + refuse 'redirect limit exceeded' +} +acquire +if [[ "$role" = run ]]; then + shift 3 + spc_python run "$platform" "$destination" "$@" +fi diff --git a/scripts/ci/spc-artifact.py b/scripts/ci/spc-artifact.py new file mode 100644 index 0000000..9ccbfb3 --- /dev/null +++ b/scripts/ci/spc-artifact.py @@ -0,0 +1,295 @@ +"""Pinned SPC data acquisition and Linux immutable execution for the legacy builder.""" +import argparse +import fcntl +import hashlib +import os +from pathlib import Path +import platform +import secrets +import stat +import subprocess +import sys +import time + +VERSION = "2.8.5" +BWRAP = "/usr/bin/bwrap" +# asset id, archive sha/size, sole-member sha/size +ARTIFACTS = { + "linux-x86_64": (399146238, "523ba4279c54c7a377156c0dd3a36adf92ee64b01e9a7f5e9e2ec084b8e458e5", 4170453, + "d1e8815bbf6b1c98324d3a0c93af3a40b9ba1e4a1af4021921d19d14b244afcd", 4378067), + "linux-aarch64": (399146236, "675a3840dcdc4ed041fe20eaa54310ce019a9984c1c03951df9ec66df5795213", 4301050, + "e82524fa737790c8189eb8211a53298e63ccd81b8b87f776c9fa45180d62c43b", 4526270), + "macos-x86_64": (399146531, "e8b798048f62ca4960764196543b60ae703f7174aa418824cf542aeec1d2cd6a", 5024260, + "c5f1ec6285fc259e4a081909183f1f458ffdfbf4ab05b097fc082bd21130d43c", 9232412), + "macos-aarch64": (399146400, "acf2f25d56d0cbf8e65aa82e5054fef555f7be7c5c38046c6e0819f266d83225", 4916396, + "f21cd8e5032a27000369470909fbe724fa81d7b41e1de35fa9288e6115a557e1", 9222300), +} + + +class Refused(RuntimeError): + pass + + +def execution_environment(): + # The compiler's selected tools and reproducibility clock are explicit. + # Do not forward loader, credential or language-startup environment hooks. + result = {key: os.environ[key] for key in ("PATH", "HOME", "TMPDIR", "SOURCE_DATE_EPOCH") if key in os.environ} + result["LC_ALL"] = "C" + return result + + +def identity(value): + return (value.st_dev, value.st_ino, value.st_mode, value.st_uid, value.st_gid, + value.st_nlink, value.st_size, value.st_mtime_ns, value.st_ctime_ns) + + +class TrustedBwrap: + """Bind the root-controlled namespace helper for this invocation.""" + def __init__(self): + self.fd = None + try: + self.check_parents() + self.fd = os.open(BWRAP, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC) + value = os.fstat(self.fd) + if (not stat.S_ISREG(value.st_mode) or value.st_uid != 0 or value.st_nlink != 1 + or value.st_mode & (0o022 | stat.S_ISUID | stat.S_ISGID) + or not value.st_mode & 0o111 or not 0 < value.st_size <= 4 * 1024 * 1024): + raise Refused("bubblewrap must be an unprivileged root-controlled regular executable") + if "security.capability" in os.listxattr(self.fd): + raise Refused("bubblewrap file capabilities are unsupported") + self.expected = identity(value) + self.digest = hashlib.sha256(os.pread(self.fd, value.st_size + 1, 0)).hexdigest() + self.recheck() + except BaseException: + self.close() + raise + + @staticmethod + def check_parents(): + for parent in Path(BWRAP).parents: + value = parent.lstat() + if not stat.S_ISDIR(value.st_mode) or value.st_uid != 0 or value.st_mode & 0o022: + raise Refused("bubblewrap parents must be root-controlled directories without links") + + def recheck(self): + self.check_parents() + if (identity(os.fstat(self.fd)) != self.expected or identity(Path(BWRAP).lstat()) != self.expected + or hashlib.sha256(os.pread(self.fd, self.expected[6] + 1, 0)).hexdigest() != self.digest): + raise Refused("bubblewrap identity changed") + + def close(self): + if self.fd is not None: + os.close(self.fd) + self.fd = None + + +def read_verified(path, digest, size): + """Capture only the exact owned, no-follow, single-link regular inode.""" + path = Path(path) + if not path.is_absolute() or path.resolve() != path: + raise Refused("artifact path must be absolute and canonical") + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC) + try: + before = os.fstat(fd) + if (not stat.S_ISREG(before.st_mode) or before.st_uid != os.getuid() or before.st_nlink != 1 + or before.st_mode & 0o022 or before.st_size != size): + raise Refused("artifact inode owner/type/link/mode/size differs") + data = os.pread(fd, size + 1, 0) + if len(data) != size or hashlib.sha256(data).hexdigest() != digest: + raise Refused("artifact descriptor digest differs") + if identity(os.fstat(fd)) != identity(before) or identity(path.lstat()) != identity(before): + raise Refused("artifact identity changed during capture") + return fd, data + except BaseException: + os.close(fd) + raise + + +def publish(data, destination): + """One atomic no-clobber link publishes complete inert bytes, never mv-overwrite.""" + destination = Path(destination) + parent = destination.parent + if not destination.is_absolute() or parent.resolve() != parent: + raise Refused("publication parent must be canonical") + directory = os.open(parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC) + temporary = ".spc-publish-" + secrets.token_hex(16) + descriptor = None + try: + folder = os.fstat(directory) + if folder.st_uid != os.getuid() or folder.st_mode & 0o022: + raise Refused("publication directory must be owner-controlled") + descriptor = os.open(temporary, os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC, + 0o600, dir_fd=directory) + with os.fdopen(os.dup(descriptor), "wb") as output: + output.write(data) + output.flush() + os.fsync(output.fileno()) + # The cache is data. Only the sealed execution descriptor gets x bits. + os.fchmod(descriptor, 0o400) + expected = os.fstat(descriptor) + os.link(temporary, destination.name, src_dir_fd=directory, dst_dir_fd=directory, follow_symlinks=False) + linked = os.open(destination.name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC, + dir_fd=directory) + try: + current = os.fstat(linked) + if (current.st_dev, current.st_ino) != (expected.st_dev, expected.st_ino): + raise Refused("publication linked a different inode") + if os.pread(linked, len(data) + 1, 0) != data: + raise Refused("published descriptor bytes differ") + finally: + os.close(linked) + if (parent.lstat().st_dev, parent.lstat().st_ino) != (folder.st_dev, folder.st_ino): + raise Refused("publication parent changed") + finally: + if descriptor is not None: + try: + named = os.stat(temporary, dir_fd=directory, follow_symlinks=False) + held = os.fstat(descriptor) + if (named.st_dev, named.st_ino) == (held.st_dev, held.st_ino): + os.unlink(temporary, dir_fd=directory) + except FileNotFoundError: + pass + os.close(descriptor) + os.close(directory) + + +def can_execute(artifact_platform): + if sys.platform == "darwin": + raise Refused("secure legacy macOS SPC execution is unavailable; use the supported native Rust CLI or a qualified Linux legacy build") + if sys.platform != "linux" or not hasattr(os, "memfd_create"): + raise Refused("SPC execution requires Linux sealed-memory file descriptors") + if not os.access(BWRAP, os.X_OK): + raise Refused("SPC execution requires bubblewrap at /usr/bin/bwrap with unprivileged user namespaces") + machine = {"AMD64": "x86_64", "arm64": "aarch64"}.get(platform.machine(), platform.machine()) + if artifact_platform != "linux-" + machine: + raise Refused("SPC platform does not match the executing Linux architecture") + helper = TrustedBwrap() + try: + check = subprocess.run(namespace_command() + ["/bin/true"], env=execution_environment(), capture_output=True, text=True) + helper.recheck() + if check.returncode: + raise Refused("SPC requires working bubblewrap unprivileged user namespaces: " + check.stderr.strip()) + finally: + helper.close() + + +def namespace_command(descriptor=None): + command = [BWRAP, "--die-with-parent", "--new-session", "--unshare-user", "--unshare-pid", + "--disable-userns", "--cap-drop", "ALL", "--bind", "/", "/", "--proc", "/proc", + "--dev", "/dev", "--dir", "/dev/shm"] + if descriptor is not None: + command += ["--perms", "0500", "--ro-bind-data", str(descriptor), "/dev/shm/cli-spc"] + return command + ["--remount-ro", "/dev", "--remount-ro", "/proc", "--"] + + +class HeldExecutable: + def __init__(self, path, digest, size): + self.source_fd, data = read_verified(path, digest, size) + self.fd = None + self.helper = None + try: + self.helper = TrustedBwrap() + self.environment = execution_environment() + self.fd = os.memfd_create("cli-spc", os.MFD_CLOEXEC | os.MFD_ALLOW_SEALING) + with os.fdopen(os.dup(self.fd), "wb") as output: + output.write(data) + os.fchmod(self.fd, 0o500) + self.seals = fcntl.F_SEAL_WRITE | fcntl.F_SEAL_GROW | fcntl.F_SEAL_SHRINK | fcntl.F_SEAL_SEAL + fcntl.fcntl(self.fd, fcntl.F_ADD_SEALS, self.seals) + self.digest, self.size = digest, size + self.expected = identity(os.fstat(self.fd))[:7] + self.recheck() + except BaseException: + self.close() + raise + + def recheck(self): + self.helper.recheck() + if fcntl.fcntl(self.fd, fcntl.F_GET_SEALS) & self.seals != self.seals: + raise Refused("SPC execution descriptor is not sealed") + if identity(os.fstat(self.fd))[:7] != self.expected or hashlib.sha256(os.pread(self.fd, self.size + 1, 0)).hexdigest() != self.digest: + raise Refused("SPC execution descriptor bytes changed") + + def run(self, arguments, *, cwd): + self.recheck() + # Micro canonicalizes AT_EXECFN and reopens that pathname, so direct + # anonymous-FD execution fails. Bubblewrap copies only the held sealed + # bytes into a read-only private mount. Its parent is also read-only, + # capabilities are dropped and nested user namespaces are disabled. + # This binds SPC itself; the build still uses its host toolchain. + os.lseek(self.fd, 0, os.SEEK_SET) + result = subprocess.run(namespace_command(self.fd) + ["/dev/shm/cli-spc", *arguments], + pass_fds=(self.fd,), cwd=cwd, env=self.environment) + self.recheck() + return result.returncode + + def close(self): + if self.helper is not None: + self.helper.close() + self.helper = None + for name in ("fd", "source_fd"): + value = getattr(self, name, None) + if value is not None: + os.close(value) + setattr(self, name, None) + + +def pipeline(holder, php, extensions, retry, attempts, phar, output, directory): + for attempt in range(1, attempts + 1): + code = holder.run(["download", "--with-php=" + php, "--for-extensions=" + extensions, + "--prefer-pre-built", "--without-suggestions", "--retry=" + str(retry)], cwd=directory) + if code == 0: + break + if attempt == attempts: + raise Refused("spc dependency download failed after " + str(attempts) + " attempts") + delay = attempt * 30 + print(f">> spc dependency download attempt {attempt}/{attempts} failed; retrying in {delay}s", file=sys.stderr) + time.sleep(delay) + code = holder.run(["build", extensions, "--build-micro"], cwd=directory) + if code: + raise Refused("spc build failed with exit " + str(code)) + code = holder.run(["micro:combine", phar, "--output=" + output], cwd=directory) + if code: + raise Refused("spc micro:combine failed with exit " + str(code)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("role", choices=("metadata", "publish", "check", "run")) + parser.add_argument("platform", choices=tuple(ARTIFACTS)) + parser.add_argument("arguments", nargs="*") + args = parser.parse_args() + asset, archive_sha, archive_size, digest, size = ARTIFACTS[args.platform] + if args.role == "metadata": + url = f"https://github.com/crazywhalecc/static-php-cli/releases/download/{VERSION}/spc-{args.platform}.tar.gz" + print(VERSION, asset, archive_sha, archive_size, digest, size, url) + elif args.role == "check": + can_execute(args.platform) + elif args.role == "publish": + if len(args.arguments) != 2: + raise Refused("publish requires staged member and cache destination") + descriptor, data = read_verified(args.arguments[0], digest, size) + try: + publish(data, args.arguments[1]) + finally: + os.close(descriptor) + else: + can_execute(args.platform) + if len(args.arguments) != 8: + raise Refused("run requires cache/php/extensions/retry/attempts/phar/output/directory") + cache, php, extensions, retry, attempts, phar, output, directory = args.arguments + if not retry.isascii() or not retry.isdecimal() or not attempts.isascii() or not attempts.isdecimal() or int(attempts) < 1: + raise Refused("SPC retries must be nonnegative and attempts positive integers") + holder = HeldExecutable(cache, digest, size) + try: + pipeline(holder, php, extensions, int(retry), int(attempts), phar, output, directory) + finally: + holder.close() + + +if __name__ == "__main__": + try: + main() + except (Refused, OSError) as error: + print("SPC refused: " + str(error), file=sys.stderr) + raise SystemExit(1) diff --git a/tests/ReleaseInstallerContractTest.php b/tests/ReleaseInstallerContractTest.php index 1f02e46..6869b6c 100644 --- a/tests/ReleaseInstallerContractTest.php +++ b/tests/ReleaseInstallerContractTest.php @@ -699,8 +699,9 @@ public function test_release_pipeline_pins_source_date_epoch(): void self::assertStringContainsString('normalize_mtimes', $buildScript); self::assertStringContainsString('SPC_DOWNLOAD_RETRY="${SPC_DOWNLOAD_RETRY:-5}"', $buildScript); self::assertStringContainsString('SPC_DOWNLOAD_OUTER_ATTEMPTS="${SPC_DOWNLOAD_OUTER_ATTEMPTS:-4}"', $buildScript); - self::assertStringContainsString('spc_download_with_retry', $buildScript); - self::assertStringContainsString('--prefer-pre-built --without-suggestions --retry="$SPC_DOWNLOAD_RETRY"', $buildScript); + self::assertStringContainsString('"$TOOLS_DIR/spc" run', $buildScript); + $spcRunner = self::readRepoFile('scripts/ci/spc-artifact.py'); + self::assertStringContainsString('"--prefer-pre-built", "--without-suggestions", "--retry="', $spcRunner); self::assertStringContainsString('SOURCE_DATE_EPOCH', $generator); } diff --git a/tests/fixtures/unverified-spc-cache.sh b/tests/fixtures/unverified-spc-cache.sh new file mode 100644 index 0000000..2026840 --- /dev/null +++ b/tests/fixtures/unverified-spc-cache.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# SPC cached-tool branch from scripts/build.sh at b962dca9; marker only. +set -euo pipefail +TOOLS_DIR="$(dirname "${1:?cached SPC path required}")" +ensure_spc() { + if [[ ! -x "$TOOLS_DIR/spc" ]]; then + curl --proto '=https' --proto-redir '=https' -fsSL -o "$TOOLS_DIR/spc" \ + https://dl.static-php.dev/static-php-cli/spc-bin/nightly/spc-linux-x86_64 + chmod +x "$TOOLS_DIR/spc" + fi +} +ensure_spc +"$TOOLS_DIR/spc" --version diff --git a/tests/test_spc_artifact.py b/tests/test_spc_artifact.py new file mode 100644 index 0000000..9fc5712 --- /dev/null +++ b/tests/test_spc_artifact.py @@ -0,0 +1,312 @@ +"""Offline counterexamples for SPC identity, immutable calls and publication.""" +import hashlib +import importlib.util +import os +from pathlib import Path +import shutil +import shlex +import stat +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +SOURCE = Path(__file__).resolve().parents[1] / "scripts/ci/spc-artifact.py" +spec = importlib.util.spec_from_file_location("spc_artifact", SOURCE) +spc = importlib.util.module_from_spec(spec) +spec.loader.exec_module(spc) + + +class ArtifactTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name).resolve() + self.root.chmod(0o700) + self.cache = self.root / "spc" + self.bytes = Path("/bin/sh").resolve().read_bytes() + self.cache.write_bytes(self.bytes) + self.cache.chmod(0o500) + self.sha = hashlib.sha256(self.bytes).hexdigest() + + def holder(self): + if not hasattr(os, "memfd_create"): + self.skipTest("Linux immutable descriptor required") + value = spc.HeldExecutable(self.cache, self.sha, len(self.bytes)) + self.addCleanup(value.close) + return value + + def test_each_predecessor_call_executes_substitute_while_holder_never_does(self): + for selected in ("download", "build", "micro:combine"): + with self.subTest(selected=selected): + self.cache.chmod(0o600) + self.cache.write_bytes(self.bytes) + self.cache.chmod(0o500) + holder = self.holder() + marker = self.root / "marker" + for command in ("download", "build", "micro:combine"): + if command == selected: + self.cache.unlink() + self.cache.write_text("#!/bin/sh\nprintf substituted > " + str(marker) + "\n") + self.cache.chmod(0o700) + subprocess.run([str(self.cache)], check=True) + self.assertTrue(marker.exists()) + marker.unlink() + self.assertEqual(holder.run(["-c", 'printf "%s" "$1"', "fixture", command], cwd=self.root), 0) + self.assertFalse(marker.exists()) + holder.recheck() + self.assertEqual(os.pread(holder.fd, len(self.bytes) + 1, 0), self.bytes) + + def test_capture_rejects_replacement_before_first_call(self): + self.cache.chmod(0o600) + self.cache.write_bytes(b"different") + with self.assertRaises(spc.Refused): + self.holder() + + def test_sealed_descriptor_refuses_in_place_mutation(self): + holder = self.holder() + with self.assertRaises(OSError): + os.pwrite(holder.fd, b"X", 0) + with self.assertRaises(OSError): + os.ftruncate(holder.fd, 0) + holder.recheck() + + def test_child_cannot_replace_or_write_private_execution_mount(self): + holder = self.holder() + script = ''' +if (printf substituted > /dev/shm/cli-spc) 2>/dev/null; then exit 91; fi +if chmod 700 /dev/shm/cli-spc 2>/dev/null; then exit 92; fi +if rm /dev/shm/cli-spc 2>/dev/null; then exit 93; fi +if unshare -Ur true 2>/dev/null; then exit 94; fi +if mount -o remount,rw /dev 2>/dev/null; then exit 95; fi +if [ "$(awk '/^CapEff:/{print $2}' /proc/self/status)" != 0000000000000000 ]; then exit 96; fi +''' + self.assertEqual(holder.run(['-c', script], cwd=self.root), 0) + holder.recheck() + + def test_bubblewrap_rejects_a_user_controlled_helper_or_link(self): + path = self.root / 'bwrap' + path.write_bytes(Path(spc.BWRAP).read_bytes()) + path.chmod(0o700) + with patch.object(spc, 'BWRAP', str(path)): + with self.assertRaises(spc.Refused): + spc.TrustedBwrap() + path.unlink() + path.symlink_to(spc.BWRAP) + with patch.object(spc, 'BWRAP', str(path)): + with self.assertRaises(spc.Refused): + spc.TrustedBwrap() + + def test_execution_environment_drops_startup_and_credential_hooks(self): + with patch.dict(os.environ, {'LD_PRELOAD':'untrusted', 'PYTHONPATH':'untrusted', 'COMPOSER_AUTH':'marker', + 'SOURCE_DATE_EPOCH':'123'}, clear=True): + self.assertEqual(spc.execution_environment(), {'LC_ALL':'C','SOURCE_DATE_EPOCH':'123'}) + + def test_source_inode_can_change_without_changing_the_execution_image(self): + holder = self.holder() + self.cache.chmod(0o600) + self.cache.write_bytes(b"changed original inode") + self.assertEqual(holder.run(["-c", "exit 0"], cwd=self.root), 0) + holder.recheck() + + def test_symlink_and_hardlink_sources_refuse(self): + original = self.root / "original" + self.cache.rename(original) + self.cache.symlink_to(original) + with self.assertRaises(spc.Refused): + self.holder() + self.cache.unlink() + os.link(original, self.cache) + with self.assertRaises(spc.Refused): + self.holder() + + def test_atomic_publication_preserves_an_appeared_destination(self): + destination = self.root / "new-cache" + link = os.link + def appeared(*args, **kwargs): + destination.write_bytes(b"foreign") + return link(*args, **kwargs) + with patch.object(spc.os, "link", appeared): + with self.assertRaises(FileExistsError): + spc.publish(b"verified", destination) + self.assertEqual(destination.read_bytes(), b"foreign") + self.assertEqual(list(self.root.glob('.spc-publish-*')), []) + # The predecessor's check then replace overwrote that appeared inode. + staged = self.root / "old-staged" + staged.write_bytes(b"verified") + os.replace(staged, destination) + self.assertEqual(destination.read_bytes(), b"verified") + + def test_publication_preserves_existing_symlink_and_directory(self): + target = self.root / "target" + target.write_bytes(b"foreign") + destination = self.root / "destination" + destination.symlink_to(target) + with self.assertRaises(FileExistsError): + spc.publish(b"verified", destination) + self.assertTrue(destination.is_symlink()) + self.assertEqual(target.read_bytes(), b"foreign") + destination.unlink() + destination.mkdir() + with self.assertRaises(FileExistsError): + spc.publish(b"verified", destination) + self.assertEqual(list(destination.iterdir()), []) + + def test_publication_is_complete_inert_data(self): + destination = self.root / "new-cache" + spc.publish(b"verified", destination) + self.assertEqual(destination.read_bytes(), b"verified") + self.assertEqual(stat.S_IMODE(destination.stat().st_mode), 0o400) + self.assertEqual(destination.stat().st_nlink, 1) + self.assertEqual(list(self.root.glob('.spc-publish-*')), []) + + def test_pipeline_uses_one_holder_for_retries_build_and_combine(self): + class Holder: + def __init__(self):self.calls = [] + def run(self, arguments, *, cwd): + self.calls.append((arguments, cwd)) + return 1 if len(self.calls) == 1 else 0 + holder = Holder() + with patch.object(spc.time, 'sleep') as sleep: + spc.pipeline(holder, '8.4', 'curl,phar', 5, 4, '/input.phar', '/output', self.root) + self.assertEqual([row[0][0] for row in holder.calls], ['download', 'download', 'build', 'micro:combine']) + self.assertEqual(holder.calls[0][0], ['download', '--with-php=8.4', '--for-extensions=curl,phar', '--prefer-pre-built', '--without-suggestions', '--retry=5']) + self.assertEqual(holder.calls[2][0], ['build', 'curl,phar', '--build-micro']) + self.assertEqual(holder.calls[3][0], ['micro:combine', '/input.phar', '--output=/output']) + sleep.assert_called_once_with(30) + + def test_download_failure_never_starts_build_or_combine(self): + class Holder: + def __init__(self):self.calls = [] + def run(self, arguments, *, cwd):self.calls.append(arguments);return 1 + holder = Holder() + with self.assertRaises(spc.Refused): + spc.pipeline(holder, '8.4', 'curl', 0, 1, '/input', '/output', self.root) + self.assertEqual([row[0] for row in holder.calls], ['download']) + + def test_macos_refusal_is_explicit_and_all_acquisition_identities_remain(self): + with patch.object(spc.sys, 'platform', 'darwin'): + with self.assertRaisesRegex(spc.Refused, 'native Rust CLI or a qualified Linux legacy build'): + spc.can_execute('macos-aarch64') + self.assertEqual(set(spc.ARTIFACTS), {'linux-x86_64', 'linux-aarch64', 'macos-x86_64', 'macos-aarch64'}) + + +class AcquisitionTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name).resolve() + (self.root / 'bin').mkdir(mode=0o700) + mock = self.root / 'bin/curl' + mock.write_text("""#!/usr/bin/env bash +set -euo pipefail +while [[ $# -gt 0 ]]; do + case "$1" in + --dump-header) headers="$2"; shift 2 ;; + --output) output="$2"; shift 2 ;; + *) url="$1"; shift ;; + esac +done +printf '%s\\n' "$url" >> "$SPC_TEST_ROOT/calls" +printf 'untrusted artifact' > "$output" +case "$SPC_TEST_CASE" in + outside) location=https://127.0.0.1/private ;; + insecure) location=http://release-assets.githubusercontent.com/asset ;; + userinfo) location=https://release-assets.githubusercontent.com@127.0.0.1/private ;; + loop) location=https://release-assets.githubusercontent.com/loop ;; + oversized-headers) head -c 26000000 /dev/zero > "$headers" ;; + duplicate) + printf 'HTTP/1.1 302 Found\\r\\nLocation: https://release-assets.githubusercontent.com/a\\r\\nLocation: https://release-assets.githubusercontent.com/b\\r\\n\\r\\n' > "$headers" + printf 302; exit 0 ;; + allowed-corrupt) + if [[ "$url" == https://release-assets.githubusercontent.com/* ]]; then + printf 'HTTP/1.1 200 OK\\r\\n\\r\\n' > "$headers"; printf 200; exit 0 + fi + location=https://release-assets.githubusercontent.com/asset ;; + *) exit 90 ;; +esac +printf 'HTTP/1.1 302 Found\\r\\nLocation: %s\\r\\n\\r\\n' "$location" > "$headers" +printf 302 +""") + mock.chmod(0o700) + + def acquire(self, scenario='outside', artifact_platform='linux-x86_64'): + return subprocess.run(['bash', str(SOURCE.with_name('ensure-spc.sh')), artifact_platform, str(self.root / 'spc')], + env={'PATH':str(self.root / 'bin') + os.pathsep + os.defpath, 'HOME':str(self.root), + 'SPC_TEST_ROOT':str(self.root), 'SPC_TEST_CASE':scenario}, + capture_output=True, text=True, timeout=15) + + def refuses_redirect(self, scenario, count, reason): + result = self.acquire(scenario) + self.assertNotEqual(result.returncode, 0) + self.assertIn(reason, result.stderr) + self.assertEqual(len((self.root / 'calls').read_text().splitlines()), count) + self.assertFalse((self.root / 'spc').exists()) + self.assertEqual(list(self.root.glob('.spc-download.*')), []) + + def test_outside_host(self):self.refuses_redirect('outside', 1, 'outside the reviewed set') + def test_insecure_scheme(self):self.refuses_redirect('insecure', 1, 'outside the reviewed set') + def test_userinfo_authority(self):self.refuses_redirect('userinfo', 1, 'outside the reviewed set') + def test_duplicate_location(self):self.refuses_redirect('duplicate', 1, 'one Location header') + def test_redirect_loop(self):self.refuses_redirect('loop', 4, 'redirect limit') + def test_allowed_host_corrupt_body(self):self.refuses_redirect('allowed-corrupt', 2, 'artifact size') + def test_oversized_headers(self):self.refuses_redirect('oversized-headers', 1, 'download failed') + + def test_unknown_platform_before_network(self): + self.assertNotEqual(self.acquire(artifact_platform='unknown').returncode, 0) + self.assertFalse((self.root / 'calls').exists()) + + def test_symlink_cache_before_network(self): + target = self.root / 'other';target.write_bytes(b'unchanged') + (self.root / 'spc').symlink_to(target) + self.assertNotEqual(self.acquire().returncode, 0) + self.assertEqual(target.read_bytes(), b'unchanged') + self.assertFalse((self.root / 'calls').exists()) + + def test_hardlink_cache_before_network(self): + target = self.root / 'other';target.write_bytes(b'unchanged') + os.link(target, self.root / 'spc') + self.assertNotEqual(self.acquire().returncode, 0) + self.assertEqual(target.read_bytes(), b'unchanged') + self.assertFalse((self.root / 'calls').exists()) + + def test_same_size_tampered_cache_before_network(self): + (self.root / 'spc').write_bytes(b'x' * spc.ARTIFACTS['linux-x86_64'][4]) + result = self.acquire() + self.assertNotEqual(result.returncode, 0) + self.assertIn('artifact digest', result.stderr) + self.assertFalse((self.root / 'calls').exists()) + + def test_poisoned_cache_predecessor_executes_but_acquisition_refuses(self): + marker = self.root / 'marker' + path = self.root / 'spc' + path.write_text('#!/bin/sh\nprintf marker > ' + shlex.quote(str(marker)) + '\n') + path.chmod(0o700) + subprocess.run(['bash', str(SOURCE.parents[2] / 'tests/fixtures/unverified-spc-cache.sh'), str(path)], check=True) + self.assertTrue(marker.exists());marker.unlink() + self.assertNotEqual(self.acquire().returncode, 0) + self.assertFalse(marker.exists()) + + def test_macos_build_refuses_before_acquisition_or_phar_work(self): + uname = self.root / 'bin/uname' + uname.write_text('#!/bin/sh\ncase "$1" in -s) echo Darwin ;; -m) echo arm64 ;; esac\n') + uname.chmod(0o700) + result = subprocess.run(['bash', str(SOURCE.parents[1] / 'build.sh'), 'binary'], + env={'PATH':str(self.root / 'bin') + os.pathsep + os.defpath, 'HOME':str(self.root), + 'SPC_TEST_ROOT':str(self.root), 'SPC_TEST_CASE':'outside'}, + capture_output=True, text=True, timeout=5) + self.assertNotEqual(result.returncode, 0) + self.assertIn('native Rust CLI or a qualified Linux legacy build', result.stderr) + self.assertFalse((self.root / 'calls').exists()) + + def test_build_uses_only_one_complete_held_pipeline(self): + source = (SOURCE.parents[1] / 'build.sh').read_text() + self.assertIn('"$TOOLS_DIR/spc" check', source) + self.assertIn('"$TOOLS_DIR/spc" run', source) + self.assertNotIn('./spc ', source) + self.assertNotIn('spc-bin/nightly', source) + + +if __name__ == '__main__': + unittest.main()