Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions rust/parity/test_legacy_spc.py
Original file line number Diff line number Diff line change
@@ -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)
64 changes: 14 additions & 50 deletions scripts/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"
}
Expand Down
82 changes: 82 additions & 0 deletions scripts/ci/ensure-spc.sh
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading