From 85a29824517c812e63e11a939254f60b660450b0 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 19:32:41 +0000 Subject: [PATCH 1/2] Verify immutable SPC release archives and cached binaries before use --- README.md | 7 ++ scripts/build.sh | 7 +- scripts/ci/ensure-spc.sh | 115 +++++++++++++++++ tests/SpcArtifactTest.php | 168 +++++++++++++++++++++++++ tests/fixtures/unverified-spc-cache.sh | 13 ++ 5 files changed, 304 insertions(+), 6 deletions(-) create mode 100755 scripts/ci/ensure-spc.sh create mode 100644 tests/SpcArtifactTest.php create mode 100644 tests/fixtures/unverified-spc-cache.sh diff --git a/README.md b/README.md index ef08332..d6ad058 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,13 @@ 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 [ensure-spc.sh](scripts/ci/ensure-spc.sh). It verifies both +the compressed archive and extracted binary, and rechecks existing caches before +use. Unknown platforms and altered caches fail before SPC executes. 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/scripts/build.sh b/scripts/build.sh index 47ec37c..24b787f 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -48,12 +48,7 @@ ensure_tools() { 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 + bash "$ROOT/scripts/ci/ensure-spc.sh" "$platform" "$TOOLS_DIR/spc" } install_composer_deps() { diff --git a/scripts/ci/ensure-spc.sh b/scripts/ci/ensure-spc.sh new file mode 100755 index 0000000..bbdb7a4 --- /dev/null +++ b/scripts/ci/ensure-spc.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# Immutable upstream SPC v2 release archives. These are acquisition identities, +# not qualification of the PHP/dependency inputs downloaded by SPC itself. +set -euo pipefail +umask 077 +export LC_ALL=C +# Archive/config environment must not supply extraction commands or curl URLs. +unset TAR_OPTIONS GZIP GZIP_OPT + +platform="${1:?Usage: ensure-spc.sh PLATFORM ABSOLUTE_DESTINATION}" +destination="${2:?Usage: ensure-spc.sh PLATFORM ABSOLUTE_DESTINATION}" +version=2.8.5 +case "$platform" in + linux-x86_64) + asset_id=399146238 + archive_sha=523ba4279c54c7a377156c0dd3a36adf92ee64b01e9a7f5e9e2ec084b8e458e5 + archive_size=4170453 + expected_sha=d1e8815bbf6b1c98324d3a0c93af3a40b9ba1e4a1af4021921d19d14b244afcd + expected_size=4378067 ;; + linux-aarch64) + asset_id=399146236 + archive_sha=675a3840dcdc4ed041fe20eaa54310ce019a9984c1c03951df9ec66df5795213 + archive_size=4301050 + expected_sha=e82524fa737790c8189eb8211a53298e63ccd81b8b87f776c9fa45180d62c43b + expected_size=4526270 ;; + macos-x86_64) + asset_id=399146531 + archive_sha=e8b798048f62ca4960764196543b60ae703f7174aa418824cf542aeec1d2cd6a + archive_size=5024260 + expected_sha=c5f1ec6285fc259e4a081909183f1f458ffdfbf4ab05b097fc082bd21130d43c + expected_size=9232412 ;; + macos-aarch64) + asset_id=399146400 + archive_sha=acf2f25d56d0cbf8e65aa82e5054fef555f7be7c5c38046c6e0819f266d83225 + archive_size=4916396 + expected_sha=f21cd8e5032a27000369470909fbe724fa81d7b41e1de35fa9288e6115a557e1 + expected_size=9222300 ;; + *) echo 'SPC acquisition refused: platform has no reviewed artifact identity' >&2; exit 1 ;; +esac + +refuse() { + echo "SPC acquisition refused: $1" >&2 + exit 1 +} + +[[ "$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" actual + [[ -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' + if command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "$file" | awk '{print $1}')" + else + actual="$(shasum -a 256 "$file" | awk '{print $1}')" + fi + [[ "$actual" = "$digest" ]] || refuse 'artifact digest differs from the reviewed asset' +} + +if [[ -e "$destination" ]]; then + verify "$destination" "$expected_sha" "$expected_size" + chmod 0755 "$destination" + exit 0 +fi + +held="$(mktemp -d "$directory/.spc-download.XXXXXX")" +trap 'rm -rf -- "$held"' EXIT +trap 'exit 1' HUP INT TERM +# Also bound header-file growth; this child-only ceiling exceeds the asset. +ulimit -f 24576 +release_url="https://github.com/crazywhalecc/static-php-cli/releases/download/$version/spc-$platform.tar.gz" +url="$release_url" +for attempt in 1 2 3 4; do + # Check every destination before contacting it. Never use curl -L here. + [[ ! "$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 + status="$(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" + # Each exact reviewed archive has one regular member named spc. + # Never extract paths to disk or trust an unverified archive. + [[ "$(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" + chmod 0755 "$held/spc" + [[ ! -e "$destination" && ! -L "$destination" ]] || refuse 'cache appeared during download' + mv -- "$held/spc" "$destination" + printf 'SPC %s %s verified: asset=%s sha256=%s bytes=%s\n' "$version" "$platform" "$asset_id" "$expected_sha" "$expected_size" + exit 0 + ;; + 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' diff --git a/tests/SpcArtifactTest.php b/tests/SpcArtifactTest.php new file mode 100644 index 0000000..f6c2960 --- /dev/null +++ b/tests/SpcArtifactTest.php @@ -0,0 +1,168 @@ +directory = sys_get_temp_dir().'/cli-spc-'.bin2hex(random_bytes(8)); + mkdir($this->directory.'/bin', 0700, true); + file_put_contents($this->directory.'/bin/curl', <<<'BASH' +#!/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 +BASH); + chmod($this->directory.'/bin/curl', 0755); + } + + protected function tearDown(): void + { + if (! isset($this->directory)) { + return; + } + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($this->directory, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($files as $file) { + $file->isDir() && ! $file->isLink() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($this->directory); + } + + private function runAcquisition(string $case = 'outside', string $platform = 'linux-x86_64'): Process + { + $process = new Process(['bash', dirname(__DIR__).'/scripts/ci/ensure-spc.sh', $platform, $this->directory.'/spc'], null, [ + 'PATH' => $this->directory.'/bin:'.getenv('PATH'), + 'SPC_TEST_ROOT' => $this->directory, + 'SPC_TEST_CASE' => $case, + ]); + $process->setTimeout(10); + $process->run(); + + return $process; + } + + public function test_build_checks_the_spc_artifact_before_using_it(): void + { + $source = file_get_contents(dirname(__DIR__).'/scripts/build.sh'); + self::assertStringContainsString('bash "$ROOT/scripts/ci/ensure-spc.sh" "$platform" "$TOOLS_DIR/spc"', $source); + self::assertLessThan(strpos($source, 'spc_download_with_retry --with-php='), strrpos($source, ' ensure_spc')); + self::assertStringNotContainsString('spc-bin/nightly', $source); + } + + public static function redirects(): array + { + return [ + 'outside host' => ['outside', 1, 'outside the reviewed set'], + 'insecure scheme' => ['insecure', 1, 'outside the reviewed set'], + 'userinfo authority' => ['userinfo', 1, 'outside the reviewed set'], + 'duplicate location' => ['duplicate', 1, 'one Location header'], + 'redirect loop' => ['loop', 4, 'redirect limit'], + 'allowed host with corrupt body' => ['allowed-corrupt', 2, 'artifact size'], + 'oversized headers' => ['oversized-headers', 1, 'download failed'], + ]; + } + + #[DataProvider('redirects')] + public function test_redirect_and_body_failures_never_publish_an_executable(string $case, int $calls, string $reason): void + { + $process = $this->runAcquisition($case); + self::assertFalse($process->isSuccessful()); + self::assertStringContainsString($reason, $process->getErrorOutput()); + self::assertCount($calls, file($this->directory.'/calls', FILE_IGNORE_NEW_LINES)); + self::assertFileDoesNotExist($this->directory.'/spc'); + self::assertSame([], glob($this->directory.'/.spc-download.*')); + } + + public function test_unknown_platform_refuses_before_network_access(): void + { + self::assertFalse($this->runAcquisition(platform: 'unknown')->isSuccessful()); + self::assertFileDoesNotExist($this->directory.'/calls'); + } + + public function test_symlink_cache_refuses_without_following_or_downloading(): void + { + file_put_contents($this->directory.'/other', 'unchanged'); + symlink($this->directory.'/other', $this->directory.'/spc'); + self::assertFalse($this->runAcquisition()->isSuccessful()); + self::assertSame('unchanged', file_get_contents($this->directory.'/other')); + self::assertFileDoesNotExist($this->directory.'/calls'); + } + + public function test_hardlink_cache_refuses_without_changing_the_other_link(): void + { + file_put_contents($this->directory.'/original', 'unchanged'); + link($this->directory.'/original', $this->directory.'/spc'); + self::assertFalse($this->runAcquisition()->isSuccessful()); + self::assertSame('unchanged', file_get_contents($this->directory.'/original')); + self::assertFileDoesNotExist($this->directory.'/calls'); + } + + public function test_same_size_tampered_cache_refuses_before_execution_or_network(): void + { + file_put_contents($this->directory.'/spc', str_repeat('x', 4378067)); + chmod($this->directory.'/spc', 0755); + $process = $this->runAcquisition(); + self::assertFalse($process->isSuccessful()); + self::assertStringContainsString('artifact digest', $process->getErrorOutput()); + self::assertFileDoesNotExist($this->directory.'/calls'); + } + + public function test_predecessor_executes_a_poisoned_cache_and_current_acquisition_refuses_it(): void + { + $marker = $this->directory.'/executed'; + file_put_contents($this->directory.'/spc', "#!/bin/sh\nprintf executed > ".escapeshellarg($marker)."\n"); + chmod($this->directory.'/spc', 0755); + $old = new Process(['bash', __DIR__.'/fixtures/unverified-spc-cache.sh', $this->directory.'/spc']); + $old->mustRun(); + self::assertFileExists($marker); + unlink($marker); + + $helper = dirname(__DIR__).'/scripts/ci/ensure-spc.sh'; + $current = new Process(['bash', '-c', 'bash "$1" linux-x86_64 "$2" && "$2" --version', + 'spc-control', $helper, $this->directory.'/spc']); + $current->run(); + self::assertFalse($current->isSuccessful()); + self::assertFileDoesNotExist($marker); + } +} 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 From b6d384de3d02442e46206452f935cf5e35781cd6 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 21:05:59 +0000 Subject: [PATCH 2/2] Bind legacy SPC execution to sealed bytes and private read-only mounts --- README.md | 21 +- rust/parity/test_legacy_spc.py | 15 ++ scripts/build.sh | 59 ++--- scripts/ci/ensure-spc.sh | 155 +++++------- scripts/ci/spc-artifact.py | 295 +++++++++++++++++++++++ tests/ReleaseInstallerContractTest.php | 5 +- tests/SpcArtifactTest.php | 168 ------------- tests/test_spc_artifact.py | 312 +++++++++++++++++++++++++ 8 files changed, 716 insertions(+), 314 deletions(-) create mode 100644 rust/parity/test_legacy_spc.py create mode 100644 scripts/ci/spc-artifact.py delete mode 100644 tests/SpcArtifactTest.php create mode 100644 tests/test_spc_artifact.py diff --git a/README.md b/README.md index d6ad058..3debd47 100644 --- a/README.md +++ b/README.md @@ -138,11 +138,22 @@ 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 [ensure-spc.sh](scripts/ci/ensure-spc.sh). It verifies both -the compressed archive and extracted binary, and rechecks existing caches before -use. Unknown platforms and altered caches fail before SPC executes. This binds -the SPC component only; PHP, downloaded libraries, build caches and each host -platform still require their applicable toolchain qualification. +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 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 24b787f..195d474 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -45,12 +45,6 @@ ensure_tools() { bash "$ROOT/scripts/ci/ensure-box.sh" "$BOX_VERSION" "$TOOLS_DIR/box" } -ensure_spc() { - local platform - platform="$(detect_platform)" - bash "$ROOT/scripts/ci/ensure-spc.sh" "$platform" "$TOOLS_DIR/spc" -} - install_composer_deps() { echo ">> Installing production Composer dependencies" composer install --no-dev --prefer-dist --no-interaction --optimize-autoloader @@ -93,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 @@ -129,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 index bbdb7a4..79c231c 100755 --- a/scripts/ci/ensure-spc.sh +++ b/scripts/ci/ensure-spc.sh @@ -1,48 +1,32 @@ #!/usr/bin/env bash -# Immutable upstream SPC v2 release archives. These are acquisition identities, -# not qualification of the PHP/dependency inputs downloaded by SPC itself. +# Acquire pinned SPC data or run the complete Linux pipeline through one holder. set -euo pipefail umask 077 export LC_ALL=C -# Archive/config environment must not supply extraction commands or curl URLs. unset TAR_OPTIONS GZIP GZIP_OPT -platform="${1:?Usage: ensure-spc.sh PLATFORM ABSOLUTE_DESTINATION}" -destination="${2:?Usage: ensure-spc.sh PLATFORM ABSOLUTE_DESTINATION}" -version=2.8.5 -case "$platform" in - linux-x86_64) - asset_id=399146238 - archive_sha=523ba4279c54c7a377156c0dd3a36adf92ee64b01e9a7f5e9e2ec084b8e458e5 - archive_size=4170453 - expected_sha=d1e8815bbf6b1c98324d3a0c93af3a40b9ba1e4a1af4021921d19d14b244afcd - expected_size=4378067 ;; - linux-aarch64) - asset_id=399146236 - archive_sha=675a3840dcdc4ed041fe20eaa54310ce019a9984c1c03951df9ec66df5795213 - archive_size=4301050 - expected_sha=e82524fa737790c8189eb8211a53298e63ccd81b8b87f776c9fa45180d62c43b - expected_size=4526270 ;; - macos-x86_64) - asset_id=399146531 - archive_sha=e8b798048f62ca4960764196543b60ae703f7174aa418824cf542aeec1d2cd6a - archive_size=5024260 - expected_sha=c5f1ec6285fc259e4a081909183f1f458ffdfbf4ab05b097fc082bd21130d43c - expected_size=9232412 ;; - macos-aarch64) - asset_id=399146400 - archive_sha=acf2f25d56d0cbf8e65aa82e5054fef555f7be7c5c38046c6e0819f266d83225 - archive_size=4916396 - expected_sha=f21cd8e5032a27000369470909fbe724fa81d7b41e1de35fa9288e6115a557e1 - expected_size=9222300 ;; - *) echo 'SPC acquisition refused: platform has no reviewed artifact identity' >&2; exit 1 ;; -esac - -refuse() { - echo "SPC acquisition refused: $1" >&2 - exit 1 +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")" @@ -50,66 +34,49 @@ mkdir -p "$directory" [[ "$(cd "$directory" && pwd -P)" = "$directory" ]] || refuse 'cache directory must be canonical' verify() { - local file="$1" digest="$2" size="$3" actual + 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' - if command -v sha256sum >/dev/null 2>&1; then - actual="$(sha256sum "$file" | awk '{print $1}')" - else - actual="$(shasum -a 256 "$file" | awk '{print $1}')" - fi - [[ "$actual" = "$digest" ]] || refuse 'artifact digest differs from the reviewed asset' + [[ "$(sha256_of < "$file")" = "$digest" ]] || refuse 'artifact digest differs from the reviewed asset' } -if [[ -e "$destination" ]]; then - verify "$destination" "$expected_sha" "$expected_size" - chmod 0755 "$destination" - exit 0 +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 - -held="$(mktemp -d "$directory/.spc-download.XXXXXX")" -trap 'rm -rf -- "$held"' EXIT -trap 'exit 1' HUP INT TERM -# Also bound header-file growth; this child-only ceiling exceeds the asset. -ulimit -f 24576 -release_url="https://github.com/crazywhalecc/static-php-cli/releases/download/$version/spc-$platform.tar.gz" -url="$release_url" -for attempt in 1 2 3 4; do - # Check every destination before contacting it. Never use curl -L here. - [[ ! "$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 - status="$(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" - # Each exact reviewed archive has one regular member named spc. - # Never extract paths to disk or trust an unverified archive. - [[ "$(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" - chmod 0755 "$held/spc" - [[ ! -e "$destination" && ! -L "$destination" ]] || refuse 'cache appeared during download' - mv -- "$held/spc" "$destination" - printf 'SPC %s %s verified: asset=%s sha256=%s bytes=%s\n' "$version" "$platform" "$asset_id" "$expected_sha" "$expected_size" - exit 0 - ;; - 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' 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/SpcArtifactTest.php b/tests/SpcArtifactTest.php deleted file mode 100644 index f6c2960..0000000 --- a/tests/SpcArtifactTest.php +++ /dev/null @@ -1,168 +0,0 @@ -directory = sys_get_temp_dir().'/cli-spc-'.bin2hex(random_bytes(8)); - mkdir($this->directory.'/bin', 0700, true); - file_put_contents($this->directory.'/bin/curl', <<<'BASH' -#!/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 -BASH); - chmod($this->directory.'/bin/curl', 0755); - } - - protected function tearDown(): void - { - if (! isset($this->directory)) { - return; - } - $files = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($this->directory, \FilesystemIterator::SKIP_DOTS), - \RecursiveIteratorIterator::CHILD_FIRST, - ); - foreach ($files as $file) { - $file->isDir() && ! $file->isLink() ? rmdir($file->getPathname()) : unlink($file->getPathname()); - } - rmdir($this->directory); - } - - private function runAcquisition(string $case = 'outside', string $platform = 'linux-x86_64'): Process - { - $process = new Process(['bash', dirname(__DIR__).'/scripts/ci/ensure-spc.sh', $platform, $this->directory.'/spc'], null, [ - 'PATH' => $this->directory.'/bin:'.getenv('PATH'), - 'SPC_TEST_ROOT' => $this->directory, - 'SPC_TEST_CASE' => $case, - ]); - $process->setTimeout(10); - $process->run(); - - return $process; - } - - public function test_build_checks_the_spc_artifact_before_using_it(): void - { - $source = file_get_contents(dirname(__DIR__).'/scripts/build.sh'); - self::assertStringContainsString('bash "$ROOT/scripts/ci/ensure-spc.sh" "$platform" "$TOOLS_DIR/spc"', $source); - self::assertLessThan(strpos($source, 'spc_download_with_retry --with-php='), strrpos($source, ' ensure_spc')); - self::assertStringNotContainsString('spc-bin/nightly', $source); - } - - public static function redirects(): array - { - return [ - 'outside host' => ['outside', 1, 'outside the reviewed set'], - 'insecure scheme' => ['insecure', 1, 'outside the reviewed set'], - 'userinfo authority' => ['userinfo', 1, 'outside the reviewed set'], - 'duplicate location' => ['duplicate', 1, 'one Location header'], - 'redirect loop' => ['loop', 4, 'redirect limit'], - 'allowed host with corrupt body' => ['allowed-corrupt', 2, 'artifact size'], - 'oversized headers' => ['oversized-headers', 1, 'download failed'], - ]; - } - - #[DataProvider('redirects')] - public function test_redirect_and_body_failures_never_publish_an_executable(string $case, int $calls, string $reason): void - { - $process = $this->runAcquisition($case); - self::assertFalse($process->isSuccessful()); - self::assertStringContainsString($reason, $process->getErrorOutput()); - self::assertCount($calls, file($this->directory.'/calls', FILE_IGNORE_NEW_LINES)); - self::assertFileDoesNotExist($this->directory.'/spc'); - self::assertSame([], glob($this->directory.'/.spc-download.*')); - } - - public function test_unknown_platform_refuses_before_network_access(): void - { - self::assertFalse($this->runAcquisition(platform: 'unknown')->isSuccessful()); - self::assertFileDoesNotExist($this->directory.'/calls'); - } - - public function test_symlink_cache_refuses_without_following_or_downloading(): void - { - file_put_contents($this->directory.'/other', 'unchanged'); - symlink($this->directory.'/other', $this->directory.'/spc'); - self::assertFalse($this->runAcquisition()->isSuccessful()); - self::assertSame('unchanged', file_get_contents($this->directory.'/other')); - self::assertFileDoesNotExist($this->directory.'/calls'); - } - - public function test_hardlink_cache_refuses_without_changing_the_other_link(): void - { - file_put_contents($this->directory.'/original', 'unchanged'); - link($this->directory.'/original', $this->directory.'/spc'); - self::assertFalse($this->runAcquisition()->isSuccessful()); - self::assertSame('unchanged', file_get_contents($this->directory.'/original')); - self::assertFileDoesNotExist($this->directory.'/calls'); - } - - public function test_same_size_tampered_cache_refuses_before_execution_or_network(): void - { - file_put_contents($this->directory.'/spc', str_repeat('x', 4378067)); - chmod($this->directory.'/spc', 0755); - $process = $this->runAcquisition(); - self::assertFalse($process->isSuccessful()); - self::assertStringContainsString('artifact digest', $process->getErrorOutput()); - self::assertFileDoesNotExist($this->directory.'/calls'); - } - - public function test_predecessor_executes_a_poisoned_cache_and_current_acquisition_refuses_it(): void - { - $marker = $this->directory.'/executed'; - file_put_contents($this->directory.'/spc', "#!/bin/sh\nprintf executed > ".escapeshellarg($marker)."\n"); - chmod($this->directory.'/spc', 0755); - $old = new Process(['bash', __DIR__.'/fixtures/unverified-spc-cache.sh', $this->directory.'/spc']); - $old->mustRun(); - self::assertFileExists($marker); - unlink($marker); - - $helper = dirname(__DIR__).'/scripts/ci/ensure-spc.sh'; - $current = new Process(['bash', '-c', 'bash "$1" linux-x86_64 "$2" && "$2" --version', - 'spc-control', $helper, $this->directory.'/spc']); - $current->run(); - self::assertFalse($current->isSuccessful()); - self::assertFileDoesNotExist($marker); - } -} 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()