diff --git a/README.md b/README.md index 63d9c1b..e2fe689 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,13 @@ A successful `phpvm install` activates the new version automatically — pass `--no-use` to skip. Long installs show live progress (download bar on Windows, build spinner on Linux), suppressed when output is not a terminal. +**Downloads are verified.** Windows checks the PHP zip against the `sha256sum.txt` +published next to it; Linux/macOS check the source tarball against the SHA-256 in +php.net's release metadata, including tarballs served from the local cache. A +mismatch aborts the install and deletes the file. If no checksum is published, or +the host has no `sha256sum`/`shasum`/`openssl`, phpvm warns and continues rather +than blocking an otherwise valid install. Set `PHPVM_SKIP_HASH=1` to opt out. + ### CA bundle (Windows) Windows PHP builds ship without a CA bundle, so HTTPS from PHP fails with @@ -110,10 +117,16 @@ fixes each finding — it never changes anything. phpvm doctor ``` -Checks: active version, whether `php` on PATH resolves to phpvm (catches -XAMPP/Laragon/WAMP shadowing), `extension_dir` vs the active build, and — -per OS — the CA bundle + VC++ runtime (Windows) or openssl + build toolchain -(Linux). Start here when something behaves unexpectedly. +Checks: active version, whether `php` on PATH resolves to phpvm — and whether a +second PHP is sitting behind it (XAMPP/Laragon/WAMP on Windows, a distro or +Homebrew `php` on Linux/macOS) — `extension_dir` vs the active build, and — +per OS — the CA bundle + VC++ runtime (Windows) or openssl, the build toolchain, +and the host OpenSSL version (Linux/macOS). Start here when something behaves +unexpectedly. + +On Linux/macOS the OpenSSL check reports upfront when the host runs OpenSSL 3, +which rules out building PHP 8.0 and older. Versions already installed keep +working; only new builds below 8.1 are refused. ### Auto-Switch with `.phpvmrc` @@ -139,8 +152,8 @@ Restart your terminal after enabling (Linux: or `exec $SHELL`). ### Extension Management ```bash -phpvm ext list # all bundled extensions (ON/OFF) -phpvm ext loaded # currently loaded (php -m) +phpvm ext list # every extension PHP can load, with ON/OFF state +phpvm ext loaded # currently loaded only (php -m) phpvm ext info redis # details about an extension phpvm ext enable mbstring # enable a bundled extension (edits php.ini) phpvm ext disable pdo_sqlite @@ -149,6 +162,11 @@ phpvm ext install mongodb 1.17.0 # specific version phpvm ext install xdebug # Windows: from xdebug.org | Linux: via PECL ``` +`ext list` shows both sides of the picture: extensions PHP has loaded (`ON`) and +extensions it *could* load but nothing has enabled yet (`OFF`). On Linux/macOS +the `OFF` side comes from the `.so` files in `extension_dir`; extensions compiled +into the binary have no file of their own and always appear as `ON`. + ### Laravel quick setup One command enables the extensions a typical Laravel app needs: @@ -245,7 +263,7 @@ sudo apt-get install -y \ |---|---|---| | `PHPVM_DIR` | `~/.phpvm` | phpvm home directory | | `EDITOR` | `nano` | Editor used by `phpvm ini` (Linux) | -| `PHPVM_SKIP_HASH` | _unset_ | When set to `1`, skip SHA-256 verification on Windows installs (use for content-rewriting corporate proxies) | +| `PHPVM_SKIP_HASH` | _unset_ | When set to `1`, skip SHA-256 verification of downloads (use for content-rewriting corporate proxies) | | `PHPVM_NO_UPDATE_CHECK` | _unset_ | When set, skip the daily phpvm update check | --- diff --git a/linux/install.sh b/linux/install.sh index 39a54f5..ce60831 100644 --- a/linux/install.sh +++ b/linux/install.sh @@ -6,7 +6,7 @@ set -e -PHPVM_VERSION="1.13.2" +PHPVM_VERSION="1.14.0" PHPVM_DIR="${PHPVM_DIR:-$HOME/.phpvm}" PHPVM_REPO="https://raw.githubusercontent.com/devhardiyanto/phpvm/main" diff --git a/linux/phpvm.sh b/linux/phpvm.sh index 3ddc03c..9dfe3e6 100644 --- a/linux/phpvm.sh +++ b/linux/phpvm.sh @@ -10,7 +10,7 @@ # phpvm use 8.3.0 # ============================================================================== -PHPVM_VERSION="1.13.2" +PHPVM_VERSION="1.14.0" PHPVM_DIR="${PHPVM_DIR:-$HOME/.phpvm}" PHPVM_VERSIONS="$PHPVM_DIR/versions" PHPVM_CURRENT="$PHPVM_DIR/current" @@ -462,6 +462,84 @@ _phpvm_resolve_remote() { echo "$match" } +# Expected SHA-256 for php-.tar.gz, straight from php.net's release JSON. +# The per-version endpoint returns a "source" array whose entries each carry a +# filename and its sha256; splitting on "{" puts one entry per line so the digest +# next to the .tar.gz filename is the one we pick (never the .xz/.bz2 sibling). +_phpvm_php_sha256() { + local ver="$1" + local api="https://www.php.net/releases/index.php?json&version=$ver" + local json + if command -v curl &>/dev/null; then + json=$(curl -fsSL --max-time 10 "$api" 2>/dev/null) + elif command -v wget &>/dev/null; then + json=$(wget -qO- --timeout=10 "$api" 2>/dev/null) + fi + [[ -z "$json" ]] && return 1 + + local sum + sum=$(printf '%s' "$json" \ + | tr '{' '\n' \ + | grep -F "\"php-$ver.tar.gz\"" \ + | grep -oE '"sha256"[[:space:]]*:[[:space:]]*"[0-9a-f]{64}"' \ + | grep -oE '[0-9a-f]{64}' \ + | head -1) + [[ -n "$sum" ]] || return 1 + echo "$sum" +} + +# Digest a file with whatever the host has. PHP itself is not an option here the +# way it is for the composer/wp-cli phars - this runs *before* any PHP exists. +_phpvm_sha256_file() { + local file="$1" + if command -v sha256sum &>/dev/null; then + sha256sum "$file" 2>/dev/null | awk '{print $1}' + elif command -v shasum &>/dev/null; then + shasum -a 256 "$file" 2>/dev/null | awk '{print $1}' + elif command -v openssl &>/dev/null; then + openssl dgst -sha256 "$file" 2>/dev/null | awk '{print $NF}' + else + return 1 + fi +} + +# Verify a downloaded (or cached) tarball. Mismatch is fatal and takes the file +# with it; an unavailable digest or a host with no hashing tool degrades to a +# warning, matching the Windows fallback so an offline mirror or an EOL release +# that php.net no longer lists cannot brick an otherwise valid install. +_phpvm_verify_tarball() { + local file="$1" ver="$2" + + if [[ -n "${PHPVM_SKIP_HASH:-}" ]]; then + _dim "Skipping SHA-256 verification (PHPVM_SKIP_HASH is set)." + return 0 + fi + + local expected + if ! expected=$(_phpvm_php_sha256 "$ver") || [[ -z "$expected" ]]; then + _warn "No published SHA-256 for PHP $ver - skipping verification." + return 0 + fi + + local actual + if ! actual=$(_phpvm_sha256_file "$file") || [[ -z "$actual" ]]; then + _warn "No sha256sum/shasum/openssl found - skipping verification." + return 0 + fi + + if [[ "$actual" != "$expected" ]]; then + _err "SHA-256 mismatch for $(basename "$file")!" + _dim "expected: $expected" + _dim "actual: $actual" + _dim "Removing the file. Re-run to download it again." + rm -f "$file" + return 1 + fi + + _ok "SHA-256 verified." + return 0 +} + # ============================================================================== # phpvm install # ============================================================================== @@ -537,6 +615,11 @@ phpvm_install() { _dim "Using cached: $cache_file" fi + # Verify after both paths: a poisoned cache would otherwise be trusted + # forever, since a cached tarball never gets re-downloaded. + _step "Verifying SHA-256 ..." + _phpvm_verify_tarball "$cache_file" "$ver" || return 1 + # Extract local src_dir="$PHPVM_CACHE/php-$ver" _step "Extracting ..." @@ -838,30 +921,80 @@ phpvm_doctor() { _dwarn "No active PHP version. Run: phpvm use " fi - # 2. PATH: whichever php resolves first is what runs. - if command -v php &>/dev/null; then - local php_path - php_path=$(command -v php) - case "$php_path" in + # 2. PATH: whichever php resolves first is what runs - but a second PHP + # further down PATH still matters, because it is what comes back the + # moment phpvm's bin drops off (a distro upgrade rewriting the rc, a + # shell that never sourced phpvm.sh). `command -v` only ever reports the + # winner, so walk PATH ourselves. + # + # Split with parameter expansion rather than tr/awk: this is the check + # that tells you PATH is broken, so it must not itself depend on finding + # coreutils there. It also sidesteps zsh, where `for d in $PATH` does not + # split on colons at all. + local php_paths="" rest="$PATH" d + while [[ -n "$rest" ]]; do + d="${rest%%:*}" + if [[ "$d" == "$rest" ]]; then rest=""; else rest="${rest#*:}"; fi + [[ -n "$d" && -x "$d/php" ]] || continue + case ":$php_paths:" in *":$d/php:"*) continue ;; esac # PATH may repeat + php_paths="${php_paths:+$php_paths:}$d/php" + done + + if [[ -z "$php_paths" ]]; then + _dwarn "No 'php' on PATH. Run: phpvm use (and source phpvm.sh in your rc)." + else + local first="${php_paths%%:*}" + case "$first" in "$PHPVM_CURRENT"/*|"$PHPVM_BIN"/*|"$PHPVM_VERSIONS"/*) - _dok "'php' resolves to phpvm: $php_path" ;; + _dok "'php' resolves to phpvm: $first" ;; *) - _dwarn "'php' resolves to a non-phpvm install: $php_path" + _dwarn "'php' resolves to a non-phpvm install: $first" _dim "Ensure $PHPVM_DIR is sourced in your shell rc, then open a new shell." ;; esac - else - _dwarn "No 'php' on PATH. Run: phpvm use (and source phpvm.sh in your rc)." + + local other="" p + rest="$php_paths" + while [[ -n "$rest" ]]; do + p="${rest%%:*}" + if [[ "$p" == "$rest" ]]; then rest=""; else rest="${rest#*:}"; fi + case "$p" in + # `:` rather than an empty body - bash 3.2 on macOS is fussy + # about case arms, which is what broke the first cut of this. + "$PHPVM_CURRENT"/*|"$PHPVM_BIN"/*|"$PHPVM_VERSIONS"/*) : ;; + *) other="$p"; break ;; + esac + done + if [[ -n "$other" && "$other" != "$first" ]]; then + _dwarn "Another PHP on PATH: $other" + _dim "It shadows phpvm whenever phpvm's bin is not first. Remove it or reorder PATH." + fi fi - # 3. extension_dir readable for the active build. + # 3. extension_dir must match what the active build was compiled with. + # Checking the directory merely exists passes an ini left pointing at a + # different version - the exact case fix-ini exists to repair. Go through + # the version's own binary, not PATH: check 2 may have just told us PATH + # resolves somewhere else entirely. if [[ -n "$cur" ]]; then - local ext_dir - ext_dir=$(php -r "echo ini_get('extension_dir');" 2>/dev/null) - if [[ -n "$ext_dir" && -d "$ext_dir" ]]; then - _dok "extension_dir present: $ext_dir" + local doc_php="$PHPVM_VERSIONS/$cur/bin/php" + if [[ ! -x "$doc_php" ]]; then + _dwarn "php binary missing for active version: $doc_php" else - _dwarn "extension_dir not found or unreadable for active version." - _dim "Fix with: phpvm fix-ini" + local ext_dir built_dir + ext_dir=$("$doc_php" -r "echo ini_get('extension_dir');" 2>/dev/null) + built_dir=$("$doc_php" -r "echo PHP_EXTENSION_DIR;" 2>/dev/null) + if [[ -z "$ext_dir" ]]; then + _dwarn "No extension_dir set in the active php.ini." + _dim "Fix with: phpvm fix-ini" + elif [[ "${ext_dir%/}" != "${built_dir%/}" ]]; then + _dwarn "extension_dir mismatch: '$ext_dir' != '$built_dir'" + _dim "Fix with: phpvm fix-ini" + elif [[ ! -d "$ext_dir" ]]; then + _dwarn "extension_dir does not exist: $ext_dir" + _dim "Fix with: phpvm fix-ini" + else + _dok "extension_dir matches active build." + fi fi fi @@ -886,6 +1019,22 @@ phpvm_doctor() { _dim "See: phpvm deps" fi + # 6. Host OpenSSL vs what is still buildable here. `install` already refuses + # PHP <8.1 on OpenSSL 3 (_phpvm_check_openssl_compat), but only once you + # have waited for a download - doctor should say it upfront. + local host_ssl + if host_ssl=$(_phpvm_openssl_version) && [[ -n "$host_ssl" ]]; then + local ssl_major="${host_ssl%%.*}" + if [[ "$ssl_major" =~ ^[0-9]+$ ]] && (( ssl_major >= 3 )); then + _dwarn "OpenSSL $host_ssl - PHP 8.0 and older cannot be built on this host." + _dim "Installed versions keep working; only new builds below 8.1 are refused." + else + _dok "OpenSSL $host_ssl (all supported PHP versions buildable)." + fi + else + _dim " OpenSSL version could not be determined - build compatibility unknown." + fi + echo "" if [[ $warn -eq 0 ]]; then _ok "All checks passed ($ok ok)." @@ -925,14 +1074,65 @@ phpvm_deps() { # EXT COMMANDS # ============================================================================== +# Everything PHP could load, with its current state - the Windows `ext list` +# shape. The ON side has to come from `php -m` rather than from a directory +# listing, because extensions compiled into the binary (pdo, mbstring, ...) have +# no .so to find; the OFF side is the .so files nothing has switched on yet. phpvm_ext_list() { local cur cur=$(_phpvm_current_version) [[ -z "$cur" ]] && { _err "No active PHP version."; return 1; } + local php_bin="$PHPVM_VERSIONS/$cur/bin/php" + [[ ! -x "$php_bin" ]] && { _err "php binary not found: $php_bin"; return 1; } + + local loaded + loaded=$("$php_bin" -m 2>/dev/null | grep -v '^\[' | grep -v '^[[:space:]]*$' \ + | tr '[:upper:]' '[:lower:]' | sort -u) + + local ext_dir available="" + ext_dir=$("$php_bin" -r "echo ini_get('extension_dir');" 2>/dev/null) + if [[ -n "$ext_dir" && -d "$ext_dir" ]]; then + available=$(find "$ext_dir" -maxdepth 1 -name '*.so' 2>/dev/null \ + | while read -r so; do + so=$(basename "$so" .so) + printf '%s\n' "${so#php_}" + done | tr '[:upper:]' '[:lower:]' | sort -u) + fi + + echo "" + echo -e " \033[36mPHP $cur — extensions:\033[0m" + echo "" + + local on=0 off=0 name + while read -r name; do + [[ -z "$name" ]] && continue + if printf '%s\n' "$loaded" | grep -qx -- "$name"; then + printf " \033[32m%-20s ON\033[0m\n" "$name" + on=$((on+1)) + else + printf " \033[90m%-20s OFF (.so available)\033[0m\n" "$name" + off=$((off+1)) + fi + done <<< "$(printf '%s\n%s\n' "$loaded" "$available" | grep -v '^[[:space:]]*$' | sort -u)" + + echo "" + _dim "$on ON, $off OFF. Enable: phpvm ext enable " + echo "" +} + +# `php -m` verbatim - what the runtime actually has loaded, nothing inferred. +phpvm_ext_loaded() { + local cur + cur=$(_phpvm_current_version) + [[ -z "$cur" ]] && { _err "No active PHP version."; return 1; } + + local php_bin="$PHPVM_VERSIONS/$cur/bin/php" + [[ ! -x "$php_bin" ]] && { _err "php binary not found: $php_bin"; return 1; } + echo "" echo -e " \033[36mPHP $cur — loaded extensions:\033[0m" - php -m 2>/dev/null | grep -v '^\[' | sort | while read -r ext; do + "$php_bin" -m 2>/dev/null | grep -v '^\[' | grep -v '^[[:space:]]*$' | sort | while read -r ext; do echo -e " \033[32m$ext\033[0m" done echo "" @@ -1206,7 +1406,7 @@ phpvm_ext() { case "$sub" in list|ls) phpvm_ext_list ;; - loaded) phpvm_ext_list ;; + loaded) phpvm_ext_loaded ;; install) phpvm_ext_install "$name" "$ver" ;; enable) phpvm_ext_enable "$name" ;; disable) phpvm_ext_disable "$name" ;; @@ -1411,7 +1611,8 @@ phpvm_ext_help() { phpvm ext — Extension Manager (Linux) ───────────────────────────────────────────────────────── - phpvm ext list Show loaded extensions + phpvm ext list Available extensions (ON/OFF) + phpvm ext loaded Loaded extensions (php -m) phpvm ext enable Enable via conf.d ini drop-in phpvm ext disable Disable extension phpvm ext install Install via PECL @@ -1471,7 +1672,8 @@ phpvm_help() { phpvm ext laravel full Required + recommended + Redis EXTENSION MANAGEMENT - phpvm ext list Show loaded extensions + phpvm ext list Available extensions (ON/OFF) + phpvm ext loaded Loaded extensions (php -m) phpvm ext enable Enable extension (conf.d drop-in) phpvm ext disable Disable extension phpvm ext install Install via PECL diff --git a/tests/linux/commands.bats b/tests/linux/commands.bats index 1f43f70..0199fab 100644 --- a/tests/linux/commands.bats +++ b/tests/linux/commands.bats @@ -8,12 +8,20 @@ setup() { export PHPVM_CURRENT="$PHPVM_DIR/current" export PHPVM_NO_INIT=1 export PHPVM_NO_UPDATE_CHECK=1 + export _ORIG_PATH="$PATH" mkdir -p "$PHPVM_VERSIONS" # shellcheck disable=SC1091 . "$BATS_TEST_DIRNAME/../../linux/phpvm.sh" } +# The PATH checks below hand doctor a PATH with no /usr/bin in it. bats runs its +# own cleanup (rm) after the test with whatever PATH the test left behind, so +# hand it back or the run dies in teardown with every assertion green. +teardown() { + export PATH="$_ORIG_PATH" +} + # Stand up a fake PHP install at $PHPVM_VERSIONS/$1 with a fake php binary # whose behavior is driven by env vars the test sets. Stubs # _phpvm_current_version directly so the suite works on Windows Git Bash @@ -30,14 +38,17 @@ _fake_php_install() { #!/usr/bin/env bash # Test double for php. Behavior controlled via env: # FAKE_PHP_EXTS space-separated extension names for -m -# FAKE_PHP_EXT_DIR value returned for ini_get('extension_dir') / PHP_EXTENSION_DIR +# FAKE_PHP_EXT_DIR value returned for PHP_EXTENSION_DIR (the compiled-in dir) +# FAKE_PHP_INI_EXT_DIR value returned for ini_get('extension_dir'); defaults +# to FAKE_PHP_EXT_DIR, so the two agree unless a test +# deliberately drives them apart # FAKE_PHP_HASH value returned for hash_file() case "$1" in -m) printf '%s\n' ${FAKE_PHP_EXTS:-Core openssl} ;; -r) case "$2" in *PHP_EXTENSION_DIR*) printf '%s' "${FAKE_PHP_EXT_DIR:-/dev/null/ext}" ;; - *ini_get*extension_dir*) printf '%s' "${FAKE_PHP_EXT_DIR:-/dev/null/ext}" ;; + *ini_get*extension_dir*) printf '%s' "${FAKE_PHP_INI_EXT_DIR-${FAKE_PHP_EXT_DIR:-/dev/null/ext}}" ;; *hash_file*) printf '%s' "${FAKE_PHP_HASH:-deadbeef}" ;; *extension_loaded*openssl*) case " ${FAKE_PHP_EXTS:-openssl} " in *" openssl "*) exit 0 ;; *) exit 1 ;; esac ;; @@ -183,6 +194,119 @@ EOF [[ "$output" == *"php.ini not found"* ]] } +# ---------- phpvm_ext_list / phpvm_ext_loaded ---------- + +# The row for one extension. Asserting on $output as a whole would let a glob +# like *redis*OFF* match "redis ... ON" on one line and the "0 OFF" summary on +# another, which is how a broken ON/OFF marker could slip through green. +# Colour codes butt straight up against the name ("\033[90mredis"), so strip +# them before matching or the leading word boundary never appears. printf for +# the escape rather than \x1b: BSD sed on the macOS runner does not read \x. +_ext_row() { + printf '%s\n' "$output" \ + | sed "s/$(printf '\033')\[[0-9;]*m//g" \ + | grep -E "^[[:space:]]*$1[[:space:]]" +} + +@test "ext list: errors when no active version" { + eval "_phpvm_current_version() { echo ''; }" + run phpvm_ext_list + [ "$status" -ne 0 ] + [[ "$output" == *"No active PHP version"* ]] +} + +@test "ext list: marks loaded extensions ON" { + _fake_php_install 8.3.0 + export FAKE_PHP_EXTS="Core curl mbstring" + export FAKE_PHP_EXT_DIR="$PHPVM_VERSIONS/8.3.0/lib/php/extensions" + run phpvm_ext_list + [ "$status" -eq 0 ] + [[ "$output" == *"curl"*"ON"* ]] + [[ "$output" == *"mbstring"*"ON"* ]] +} + +@test "ext list: marks an available-but-unloaded .so OFF" { + _fake_php_install 8.3.0 + export FAKE_PHP_EXTS="Core curl" + export FAKE_PHP_EXT_DIR="$PHPVM_VERSIONS/8.3.0/lib/php/extensions" + touch "$FAKE_PHP_EXT_DIR/redis.so" "$FAKE_PHP_EXT_DIR/xdebug.so" + run phpvm_ext_list + [ "$status" -eq 0 ] + [[ "$(_ext_row redis)" == *OFF* ]] + [[ "$(_ext_row xdebug)" == *OFF* ]] + # Core and curl are the two loaded ones. + [[ "$output" == *"2 ON, 2 OFF"* ]] +} + +@test "ext list: an extension with a .so that is also loaded counts once, as ON" { + _fake_php_install 8.3.0 + export FAKE_PHP_EXTS="Core redis" + export FAKE_PHP_EXT_DIR="$PHPVM_VERSIONS/8.3.0/lib/php/extensions" + touch "$FAKE_PHP_EXT_DIR/redis.so" + run phpvm_ext_list + [ "$status" -eq 0 ] + [[ "$(_ext_row redis)" == *ON* ]] + [[ "$(_ext_row redis)" != *OFF* ]] + [ "$(_ext_row redis | wc -l)" -eq 1 ] + [[ "$output" == *"2 ON, 0 OFF"* ]] +} + +@test "ext list: keeps statically compiled extensions that have no .so" { + # The whole reason ON comes from `php -m` and not from the directory: pdo + # and mbstring are built into the binary and own no file to find. + _fake_php_install 8.3.0 + export FAKE_PHP_EXTS="Core pdo mbstring" + export FAKE_PHP_EXT_DIR="$PHPVM_VERSIONS/8.3.0/lib/php/extensions" + run phpvm_ext_list + [ "$status" -eq 0 ] + [[ "$output" == *"pdo"* ]] + [[ "$output" == *"mbstring"* ]] + [[ "$output" == *"3 ON, 0 OFF"* ]] +} + +@test "ext list: strips the php_ prefix some .so files carry" { + _fake_php_install 8.3.0 + export FAKE_PHP_EXTS="Core" + export FAKE_PHP_EXT_DIR="$PHPVM_VERSIONS/8.3.0/lib/php/extensions" + touch "$FAKE_PHP_EXT_DIR/php_imagick.so" + run phpvm_ext_list + [ "$status" -eq 0 ] + [[ "$output" == *"imagick"*"OFF"* ]] + [[ "$output" != *"php_imagick"* ]] +} + +@test "ext list: survives an extension_dir that does not exist" { + _fake_php_install 8.3.0 + export FAKE_PHP_EXTS="Core curl" + export FAKE_PHP_EXT_DIR="$BATS_TEST_TMPDIR/nope" + run phpvm_ext_list + [ "$status" -eq 0 ] + [[ "$output" == *"curl"*"ON"* ]] + [[ "$output" == *"2 ON, 0 OFF"* ]] +} + +@test "ext loaded: shows php -m only, without the OFF entries" { + _fake_php_install 8.3.0 + export FAKE_PHP_EXTS="Core curl" + export FAKE_PHP_EXT_DIR="$PHPVM_VERSIONS/8.3.0/lib/php/extensions" + touch "$FAKE_PHP_EXT_DIR/redis.so" + run phpvm_ext_loaded + [ "$status" -eq 0 ] + [[ "$output" == *"curl"* ]] + [[ "$output" != *"redis"* ]] + [[ "$output" != *"OFF"* ]] +} + +@test "dispatch: 'phpvm ext loaded' no longer routes to ext list" { + _fake_php_install 8.3.0 + export FAKE_PHP_EXTS="Core curl" + export FAKE_PHP_EXT_DIR="$PHPVM_VERSIONS/8.3.0/lib/php/extensions" + touch "$FAKE_PHP_EXT_DIR/redis.so" + run phpvm ext loaded + [ "$status" -eq 0 ] + [[ "$output" != *"redis"* ]] +} + # ---------- phpvm_ext_laravel ---------- @test "ext laravel: errors when no active version" { @@ -437,6 +561,11 @@ EOF [[ "$output" != *"8.5.2 8.5.6"* ]] } +# These checks assert on how many PHPs are visible, and CI images ship a distro +# php of their own - so where a test needs to own the answer it hands doctor a +# PATH containing nothing but phpvm. That works because the scan only needs `-x` +# on each candidate, never to run one, and no external tool to split PATH. + # ---------- phpvm_doctor ---------- @test "doctor: warns when no active version" { @@ -456,6 +585,123 @@ EOF run phpvm_doctor [ "$status" -eq 0 ] [[ "$output" == *"Active PHP version: 8.3.0"* ]] - [[ "$output" == *"extension_dir present"* ]] + [[ "$output" == *"extension_dir matches active build"* ]] [[ "$output" == *"openssl extension loaded"* ]] } + +@test "doctor: flags an extension_dir left pointing at another version" { + _fake_php_install 8.3.0 + mkdir -p "$PHPVM_VERSIONS/8.2.0/lib/php/extensions" + export FAKE_PHP_EXT_DIR="$PHPVM_VERSIONS/8.3.0/lib/php/extensions" + # The stale dir exists, so the old `-d` check would have called this healthy. + export FAKE_PHP_INI_EXT_DIR="$PHPVM_VERSIONS/8.2.0/lib/php/extensions" + run phpvm_doctor + [ "$status" -eq 0 ] + [[ "$output" == *"extension_dir mismatch"* ]] + [[ "$output" == *"phpvm fix-ini"* ]] +} + +@test "doctor: warns when extension_dir is unset in php.ini" { + _fake_php_install 8.3.0 + export FAKE_PHP_EXT_DIR="$PHPVM_VERSIONS/8.3.0/lib/php/extensions" + export FAKE_PHP_INI_EXT_DIR="" + run phpvm_doctor + [ "$status" -eq 0 ] + [[ "$output" == *"No extension_dir set"* ]] +} + +@test "doctor: reads the active version's php, not whatever PATH resolves" { + _fake_php_install 8.3.0 + export FAKE_PHP_EXT_DIR="$PHPVM_VERSIONS/8.3.0/lib/php/extensions" + # A foreign php earlier on PATH that would report a bogus extension_dir if + # doctor trusted PATH instead of the version it says is active. + mkdir -p "$BATS_TEST_TMPDIR/usrbin" + cat > "$BATS_TEST_TMPDIR/usrbin/php" <<'EOF' +#!/usr/bin/env bash +case "$1" in + -m) echo "Core" ;; + -r) printf '%s' "/wrong/ext/dir" ;; +esac +EOF + chmod +x "$BATS_TEST_TMPDIR/usrbin/php" + export PATH="$BATS_TEST_TMPDIR/usrbin:$PATH" + run phpvm_doctor + [ "$status" -eq 0 ] + [[ "$output" == *"extension_dir matches active build"* ]] + [[ "$output" != *"/wrong/ext/dir"* ]] +} + +# ---------- phpvm_doctor: PATH shadowing ---------- + +@test "doctor: names a second php further down PATH" { + _fake_php_install 8.3.0 + export FAKE_PHP_EXT_DIR="$PHPVM_VERSIONS/8.3.0/lib/php/extensions" + mkdir -p "$BATS_TEST_TMPDIR/usrbin" + printf '#!/usr/bin/env bash\n' > "$BATS_TEST_TMPDIR/usrbin/php" + chmod +x "$BATS_TEST_TMPDIR/usrbin/php" + # phpvm wins the lookup, but the distro php is still sitting behind it. + export PATH="$PHPVM_VERSIONS/8.3.0/bin:$BATS_TEST_TMPDIR/usrbin" + run phpvm_doctor + [ "$status" -eq 0 ] + [[ "$output" == *"resolves to phpvm"* ]] + [[ "$output" == *"Another PHP on PATH"* ]] + [[ "$output" == *"usrbin/php"* ]] +} + +@test "doctor: stays quiet when phpvm is the only php on PATH" { + _fake_php_install 8.3.0 + export FAKE_PHP_EXT_DIR="$PHPVM_VERSIONS/8.3.0/lib/php/extensions" + export PATH="$PHPVM_VERSIONS/8.3.0/bin" + run phpvm_doctor + [ "$status" -eq 0 ] + [[ "$output" == *"resolves to phpvm"* ]] + [[ "$output" != *"Another PHP on PATH"* ]] +} + +@test "doctor: does not report the winner twice as a shadowing php" { + _fake_php_install 8.3.0 + export FAKE_PHP_EXT_DIR="$PHPVM_VERSIONS/8.3.0/lib/php/extensions" + mkdir -p "$BATS_TEST_TMPDIR/usrbin" + printf '#!/usr/bin/env bash\n' > "$BATS_TEST_TMPDIR/usrbin/php" + chmod +x "$BATS_TEST_TMPDIR/usrbin/php" + # Non-phpvm php first: it is the winner *and* the only foreign entry, so it + # must be reported once as the resolution problem, not again as a shadow. + export PATH="$BATS_TEST_TMPDIR/usrbin:$PHPVM_VERSIONS/8.3.0/bin" + run phpvm_doctor + [ "$status" -eq 0 ] + [[ "$output" == *"resolves to a non-phpvm install"* ]] + [[ "$output" != *"Another PHP on PATH"* ]] +} + +# ---------- phpvm_doctor: host OpenSSL ---------- + +@test "doctor: warns that OpenSSL 3 rules out building PHP 8.0 and older" { + _fake_php_install 8.3.0 + export FAKE_PHP_EXT_DIR="$PHPVM_VERSIONS/8.3.0/lib/php/extensions" + eval "_phpvm_openssl_version() { echo '3.0.2'; }" + run phpvm_doctor + [ "$status" -eq 0 ] + [[ "$output" == *"OpenSSL 3.0.2"* ]] + [[ "$output" == *"8.0 and older cannot be built"* ]] +} + +@test "doctor: calls OpenSSL 1.1 fully buildable" { + _fake_php_install 8.3.0 + export FAKE_PHP_EXT_DIR="$PHPVM_VERSIONS/8.3.0/lib/php/extensions" + eval "_phpvm_openssl_version() { echo '1.1.1'; }" + run phpvm_doctor + [ "$status" -eq 0 ] + [[ "$output" == *"OpenSSL 1.1.1"* ]] + [[ "$output" == *"all supported PHP versions buildable"* ]] +} + +@test "doctor: does not count an undetectable OpenSSL as a warning" { + _fake_php_install 8.3.0 + export FAKE_PHP_EXT_DIR="$PHPVM_VERSIONS/8.3.0/lib/php/extensions" + eval "_phpvm_openssl_version() { return 1; }" + run phpvm_doctor + [ "$status" -eq 0 ] + [[ "$output" == *"build compatibility unknown"* ]] + # Reported as a plain note, never as a [warn] the user is asked to fix. + [[ "$output" != *"cannot be built"* ]] +} diff --git a/tests/linux/verify.bats b/tests/linux/verify.bats new file mode 100644 index 0000000..b657cc8 --- /dev/null +++ b/tests/linux/verify.bats @@ -0,0 +1,148 @@ +#!/usr/bin/env bats +# SHA-256 verification of the PHP source tarball: digest lookup against +# php.net's release JSON, local hashing, and the verify decision itself. +# Run from repo root: bats tests/linux/ + +setup() { + export PHPVM_DIR="$BATS_TEST_TMPDIR/phpvm" + export PHPVM_VERSIONS="$PHPVM_DIR/versions" + export PHPVM_CURRENT="$PHPVM_DIR/current" + export PHPVM_CACHE="$PHPVM_DIR/cache" + export PHPVM_NO_INIT=1 + export PHPVM_NO_UPDATE_CHECK=1 + unset PHPVM_SKIP_HASH + mkdir -p "$PHPVM_VERSIONS" "$PHPVM_CACHE" + + # shellcheck disable=SC1091 + . "$BATS_TEST_DIRNAME/../../linux/phpvm.sh" +} + +GZ_SUM=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +BZ2_SUM=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb +XZ_SUM=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc + +# A php.net release payload shaped like the real one: one "source" array with a +# sibling entry per archive format, all three carrying their own sha256. +_stub_release_json() { + local ver="$1" + local body="{\"date\":\"1 Jan 2024\",\"source\":[\ +{\"filename\":\"php-$ver.tar.gz\",\"name\":\"PHP $ver (tar.gz)\",\"sha256\":\"$GZ_SUM\"},\ +{\"filename\":\"php-$ver.tar.bz2\",\"name\":\"PHP $ver (tar.bz2)\",\"sha256\":\"$BZ2_SUM\"},\ +{\"filename\":\"php-$ver.tar.xz\",\"name\":\"PHP $ver (tar.xz)\",\"sha256\":\"$XZ_SUM\"}],\ +\"museum\":false}" + # A function satisfies `command -v curl`, so the lookup takes the same + # branch it would on a host that really has curl. + eval "curl() { printf '%s' '$body'; }" +} + +# ── digest lookup ───────────────────────────────────────────────────────────── + +@test "php_sha256: picks the tar.gz digest, not its bz2/xz siblings" { + _stub_release_json 8.3.0 + run _phpvm_php_sha256 8.3.0 + [ "$status" -eq 0 ] + [ "$output" = "$GZ_SUM" ] +} + +@test "php_sha256: fails when php.net answers with nothing" { + eval "curl() { printf ''; }" + run _phpvm_php_sha256 8.3.0 + [ "$status" -eq 1 ] + [ -z "$output" ] +} + +@test "php_sha256: fails when the payload carries no sha256 at all" { + eval "curl() { printf '%s' '{\"source\":[{\"filename\":\"php-8.3.0.tar.gz\"}]}'; }" + run _phpvm_php_sha256 8.3.0 + [ "$status" -eq 1 ] +} + +@test "php_sha256: does not hand back a digest belonging to another version" { + # php.net answering for the wrong release must not silently satisfy us. + _stub_release_json 8.3.0 + run _phpvm_php_sha256 8.2.0 + [ "$status" -eq 1 ] +} + +# ── local hashing ───────────────────────────────────────────────────────────── + +@test "sha256_file: digests a file with whatever tool the host has" { + printf 'phpvm' > "$BATS_TEST_TMPDIR/f" + run _phpvm_sha256_file "$BATS_TEST_TMPDIR/f" + [ "$status" -eq 0 ] + # Same string, same digest, whichever of the three tools answered. + [ "${#output}" -eq 64 ] + [[ "$output" =~ ^[0-9a-f]{64}$ ]] +} + +# ── the verify decision ─────────────────────────────────────────────────────── + +_stub_digests() { # expected, actual + eval "_phpvm_php_sha256() { echo '$1'; }" + eval "_phpvm_sha256_file() { echo '$2'; }" +} + +@test "verify: passes and keeps the file when digests agree" { + printf 'tarball' > "$PHPVM_CACHE/php-8.3.0.tar.gz" + _stub_digests "$GZ_SUM" "$GZ_SUM" + run _phpvm_verify_tarball "$PHPVM_CACHE/php-8.3.0.tar.gz" 8.3.0 + [ "$status" -eq 0 ] + [[ "$output" == *"SHA-256 verified"* ]] + [ -f "$PHPVM_CACHE/php-8.3.0.tar.gz" ] +} + +@test "verify: fails and deletes the file when digests disagree" { + printf 'tampered' > "$PHPVM_CACHE/php-8.3.0.tar.gz" + _stub_digests "$GZ_SUM" "$BZ2_SUM" + run _phpvm_verify_tarball "$PHPVM_CACHE/php-8.3.0.tar.gz" 8.3.0 + [ "$status" -eq 1 ] + [[ "$output" == *"SHA-256 mismatch"* ]] + # Left in place it would be reused forever - a cached tarball is never + # re-downloaded. + [ ! -f "$PHPVM_CACHE/php-8.3.0.tar.gz" ] +} + +@test "verify: mismatch prints both digests so the user can see which is which" { + printf 'tampered' > "$PHPVM_CACHE/php-8.3.0.tar.gz" + _stub_digests "$GZ_SUM" "$BZ2_SUM" + run _phpvm_verify_tarball "$PHPVM_CACHE/php-8.3.0.tar.gz" 8.3.0 + [[ "$output" == *"$GZ_SUM"* ]] + [[ "$output" == *"$BZ2_SUM"* ]] +} + +@test "verify: PHPVM_SKIP_HASH skips the check and leaves the file alone" { + printf 'tampered' > "$PHPVM_CACHE/php-8.3.0.tar.gz" + _stub_digests "$GZ_SUM" "$BZ2_SUM" + PHPVM_SKIP_HASH=1 run _phpvm_verify_tarball "$PHPVM_CACHE/php-8.3.0.tar.gz" 8.3.0 + [ "$status" -eq 0 ] + [[ "$output" == *"Skipping SHA-256 verification"* ]] + [ -f "$PHPVM_CACHE/php-8.3.0.tar.gz" ] +} + +@test "verify: warns but proceeds when php.net publishes no digest" { + printf 'tarball' > "$PHPVM_CACHE/php-8.3.0.tar.gz" + eval "_phpvm_php_sha256() { return 1; }" + run _phpvm_verify_tarball "$PHPVM_CACHE/php-8.3.0.tar.gz" 8.3.0 + [ "$status" -eq 0 ] + [[ "$output" == *"No published SHA-256"* ]] + [ -f "$PHPVM_CACHE/php-8.3.0.tar.gz" ] +} + +@test "verify: warns but proceeds when the host has no hashing tool" { + printf 'tarball' > "$PHPVM_CACHE/php-8.3.0.tar.gz" + eval "_phpvm_php_sha256() { echo '$GZ_SUM'; }" + eval "_phpvm_sha256_file() { return 1; }" + run _phpvm_verify_tarball "$PHPVM_CACHE/php-8.3.0.tar.gz" 8.3.0 + [ "$status" -eq 0 ] + [[ "$output" == *"skipping verification"* ]] + [ -f "$PHPVM_CACHE/php-8.3.0.tar.gz" ] +} + +@test "verify: an empty digest from php.net is treated as no digest, not a mismatch" { + printf 'tarball' > "$PHPVM_CACHE/php-8.3.0.tar.gz" + eval "_phpvm_php_sha256() { echo ''; }" + run _phpvm_verify_tarball "$PHPVM_CACHE/php-8.3.0.tar.gz" 8.3.0 + [ "$status" -eq 0 ] + [[ "$output" == *"No published SHA-256"* ]] + [ -f "$PHPVM_CACHE/php-8.3.0.tar.gz" ] +} diff --git a/tests/linux/zsh-smoke.zsh b/tests/linux/zsh-smoke.zsh index ba99306..82a4f4a 100644 --- a/tests/linux/zsh-smoke.zsh +++ b/tests/linux/zsh-smoke.zsh @@ -142,6 +142,96 @@ print "all good" > "$PHPVM_LOG" out=$(_phpvm_show_build_error 2>&1) check_empty "stays quiet when the log holds no error" "$out" +print -r -- "-- tarball verification --" +tarball="$tmp/php-8.3.0.tar.gz" +print "payload" > "$tarball" +_phpvm_php_sha256() { print "aaaa" } +_phpvm_sha256_file() { print "aaaa" } +out=$(_phpvm_verify_tarball "$tarball" 8.3.0 2>&1); st=$? +check "accepts a matching digest" "SHA-256 verified" "$out" +check_status "accepts with status 0" 0 $st + +_phpvm_sha256_file() { print "bbbb" } +out=$(_phpvm_verify_tarball "$tarball" 8.3.0 2>&1); st=$? +check "rejects a mismatching digest" "SHA-256 mismatch" "$out" +check_status "rejects with a non-zero status" 1 $st +if [[ -f "$tarball" ]]; then + print "FAIL - deletes the tarball on mismatch" + fail=1 +else + print "ok - deletes the tarball on mismatch" +fi + +print "payload" > "$tarball" +out=$(PHPVM_SKIP_HASH=1 _phpvm_verify_tarball "$tarball" 8.3.0 2>&1); st=$? +check "honours PHPVM_SKIP_HASH" "Skipping SHA-256 verification" "$out" +check_status "skips with status 0" 0 $st + +_phpvm_php_sha256() { return 1 } +out=$(_phpvm_verify_tarball "$tarball" 8.3.0 2>&1); st=$? +check "degrades to a warning with no published digest" "No published SHA-256" "$out" +check_status "degrades without failing the install" 0 $st + +# The real lookup parses JSON with a pipeline; make sure that pipeline behaves +# under zsh rather than only under bash. +curl() { print -n '{"source":[{"filename":"php-8.3.0.tar.gz","sha256":"'${(l:64::a:)}'"},{"filename":"php-8.3.0.tar.xz","sha256":"'${(l:64::b:)}'"}]}' } +unfunction _phpvm_php_sha256 2>/dev/null +# Re-source to get the real implementation back. NO_INIT so this does not +# re-run the source-time PATH and hook side effects mid-suite. +PHPVM_NO_INIT=1 . ./linux/phpvm.sh >/dev/null 2>&1 +out=$(_phpvm_php_sha256 8.3.0 2>&1) +check "picks the tar.gz digest out of the release JSON" "aaaa" "$out" +if [[ "$out" == *bbbb* ]]; then + print "FAIL - must not return the tar.xz digest" + fail=1 +else + print "ok - does not return the tar.xz digest" +fi +unfunction curl + +print -r -- "-- ext list ON/OFF --" +export PHPVM_VERSIONS="$PHPVM_DIR/versions" +extdir="$PHPVM_VERSIONS/8.3.0/lib/php/extensions" +mkdir -p "$PHPVM_VERSIONS/8.3.0/bin" "$extdir" +cat > "$PHPVM_VERSIONS/8.3.0/bin/php" <<'PHPEOF' +#!/usr/bin/env bash +case "$1" in + -m) printf '%s\n' Core curl ;; + -r) printf '%s' "$FAKE_EXT_DIR" ;; +esac +PHPEOF +chmod +x "$PHPVM_VERSIONS/8.3.0/bin/php" +export FAKE_EXT_DIR="$extdir" +touch "$extdir/redis.so" +_phpvm_current_version() { print "8.3.0" } + +out=$(phpvm_ext_list 2>&1) +check "marks a loaded extension ON" "curl" "$out" +check "marks an unloaded .so OFF" "OFF" "$out" +# The counters live in a `while read` loop fed by a here-string. Were that a +# pipeline, zsh would run it in a subshell and both totals would come back 0. +check "counters survive the read loop" "2 ON, 1 OFF" "$out" + +out=$(phpvm_ext_loaded 2>&1) +check "ext loaded lists php -m" "curl" "$out" +if [[ "$out" == *redis* ]]; then + print "FAIL - ext loaded must not show unloaded .so files" + fail=1 +else + print "ok - ext loaded omits unloaded .so files" +fi + +print -r -- "-- doctor PATH scan --" +# `for d in $PATH` does not split on colons in zsh; the scan has to tr-split. +mkdir -p "$tmp/usrbin" +print '#!/usr/bin/env bash' > "$tmp/usrbin/php" +chmod +x "$tmp/usrbin/php" +export PHPVM_BIN="$PHPVM_DIR/bin" +export PHPVM_CURRENT="$PHPVM_DIR/current" +out=$(PATH="$PHPVM_VERSIONS/8.3.0/bin:$tmp/usrbin:$PATH" phpvm_doctor 2>&1) +check "finds the phpvm php first" "resolves to phpvm" "$out" +check "still names the second php behind it" "Another PHP on PATH" "$out" + rm -rf "$tmp" print "" diff --git a/version.txt b/version.txt index f0df1f7..cd99d38 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.13.2 \ No newline at end of file +1.14.0 \ No newline at end of file diff --git a/windows/install.ps1 b/windows/install.ps1 index 8b5a454..be18836 100644 --- a/windows/install.ps1 +++ b/windows/install.ps1 @@ -8,7 +8,7 @@ Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" -$PHPVM_VERSION = "1.13.2" +$PHPVM_VERSION = "1.14.0" $PHPVM_DIR = if ($env:PHPVM_DIR) { $env:PHPVM_DIR } else { "$env:USERPROFILE\.phpvm" } $PHPVM_BIN = "$PHPVM_DIR\bin" diff --git a/windows/phpvm.ps1 b/windows/phpvm.ps1 index b58a128..22babf6 100644 --- a/windows/phpvm.ps1 +++ b/windows/phpvm.ps1 @@ -23,7 +23,7 @@ Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" # -- Constants ----------------------------------------------------------------- -$PHPVM_VERSION = "1.13.2" +$PHPVM_VERSION = "1.14.0" $PHPVM_DIR = if ($env:PHPVM_DIR) { $env:PHPVM_DIR } else { "$env:USERPROFILE\.phpvm" } $VERSIONS_DIR = "$PHPVM_DIR\versions" $CURRENT_LINK = "$PHPVM_DIR\current" diff --git a/windows/src/00-header.ps1 b/windows/src/00-header.ps1 index f82eb8d..aa2c74c 100644 --- a/windows/src/00-header.ps1 +++ b/windows/src/00-header.ps1 @@ -15,7 +15,7 @@ Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" # -- Constants ----------------------------------------------------------------- -$PHPVM_VERSION = "1.13.2" +$PHPVM_VERSION = "1.14.0" $PHPVM_DIR = if ($env:PHPVM_DIR) { $env:PHPVM_DIR } else { "$env:USERPROFILE\.phpvm" } $VERSIONS_DIR = "$PHPVM_DIR\versions" $CURRENT_LINK = "$PHPVM_DIR\current"