From 21a597fd7941882622212282725c7dbb2060c706 Mon Sep 17 00:00:00 2001 From: Irfan Hardiyanto <52022757+devhardiyanto@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:48:33 +0700 Subject: [PATCH 1/4] feat(linux): close three parity gaps against the Windows implementation An audit of both implementations found the command surface all but identical - `cacert` (Windows) and `deps` (Linux) are the only differences, and both are by design. Three gaps were not by design. All three live in the same file, so they land together. 1. The PHP source tarball was never verified. It was downloaded from php.net and handed straight to the build, then cached - so a corrupt or substituted archive would be trusted on every later install too, since a cached tarball is never re-downloaded. Composer (SHA-384) and wp-cli (SHA-512) were already verified here; only PHP itself was not. The digest now comes from php.net's per-version release JSON and is checked after both the download and the cache path. A mismatch aborts and deletes the file. A missing digest, or a host with no sha256sum/shasum/openssl, degrades to a warning rather than blocking an otherwise valid install - the same fallback Windows takes. PHPVM_SKIP_HASH opts out. 2. `ext list` and `ext loaded` both ran `php -m`, so there was no way to see an extension that is available but not enabled, and one of the two verbs was dead weight. `ext list` now reports ON/OFF over the union of loaded extensions and the .so files in extension_dir; `ext loaded` is `php -m` alone. ON has to come from `php -m` rather than a directory listing, because extensions compiled into the binary own no .so. 3. `doctor` was shallower than its Windows counterpart in three ways. It only looked at the first `php` on PATH, so a distro or Homebrew PHP waiting behind phpvm went unreported - the very situation the check exists for. It checked that extension_dir merely existed, which passes an ini left pointing at another installed version, the exact case fix-ini repairs. And it read through PATH, which check 2 may have just said resolves elsewhere; it now goes through the active version's own binary. It also reports the host OpenSSL version upfront, so the OpenSSL 3 vs PHP < 8.1 limitation surfaces before a download rather than after one. bats 82 -> 112, zsh smoke 20 -> 40 checks. devhardiyanto --- README.md | 32 +++-- linux/phpvm.sh | 232 ++++++++++++++++++++++++++++++---- tests/linux/commands.bats | 253 +++++++++++++++++++++++++++++++++++++- tests/linux/verify.bats | 148 ++++++++++++++++++++++ tests/linux/zsh-smoke.zsh | 90 ++++++++++++++ 5 files changed, 724 insertions(+), 31 deletions(-) create mode 100644 tests/linux/verify.bats 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/phpvm.sh b/linux/phpvm.sh index 3ddc03c..1807068 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,68 @@ 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. Splitting via tr keeps this working in + # zsh, where `for d in $PATH` does not split on colons. + local php_paths + php_paths=$(printf '%s' "$PATH" | tr ':' '\n' | while read -r d; do + [[ -n "$d" && -x "$d/php" ]] && printf '%s\n' "$d/php" + done | awk '!seen[$0]++') + + if [[ -z "$php_paths" ]]; then + _dwarn "No 'php' on PATH. Run: phpvm use (and source phpvm.sh in your rc)." + else + local first + first=$(printf '%s\n' "$php_paths" | head -1) + 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 + other=$(printf '%s\n' "$php_paths" | while read -r p; do + case "$p" in + "$PHPVM_CURRENT"/*|"$PHPVM_BIN"/*|"$PHPVM_VERSIONS"/*) ;; + *) printf '%s\n' "$p" ;; + esac + done | head -1) + 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 +1007,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 +1062,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 +1394,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 +1599,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 +1660,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..ab766fe 100644 --- a/tests/linux/commands.bats +++ b/tests/linux/commands.bats @@ -30,14 +30,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 +186,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 +553,20 @@ EOF [[ "$output" != *"8.5.2 8.5.6"* ]] } +# PATH with every directory that ships a php removed. The doctor PATH checks +# assert on how many PHPs are visible, and CI images (and dev machines) may well +# have a distro php of their own - dropping them keeps the assertions about the +# PATH the test built, not the one the host happened to bring. +_path_without_php() { + local out="" d + while read -r d; do + [[ -z "$d" ]] && continue + [[ -x "$d/php" ]] && continue + out="${out:+$out:}$d" + done <<< "$(printf '%s' "$PATH" | tr ':' '\n')" + printf '%s' "$out" +} + # ---------- phpvm_doctor ---------- @test "doctor: warns when no active version" { @@ -456,6 +586,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:$(_path_without_php)" + 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:$(_path_without_php)" + 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:$(_path_without_php)" + 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 "" From 4d266564618576f1a0c123429d86698043c0999a Mon Sep 17 00:00:00 2001 From: Irfan Hardiyanto <52022757+devhardiyanto@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:48:37 +0700 Subject: [PATCH 2/4] chore: bump version to 1.14.0 devhardiyanto --- linux/install.sh | 2 +- version.txt | 2 +- windows/install.ps1 | 2 +- windows/phpvm.ps1 | 2 +- windows/src/00-header.ps1 | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) 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/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" From a6897025392622a1c71ba50d9bb4555c7217a2f3 Mon Sep 17 00:00:00 2001 From: Irfan Hardiyanto <52022757+devhardiyanto@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:02:00 +0700 Subject: [PATCH 3/4] fix(linux): keep the doctor PATH scan parseable on bash 3.2 macOS ships bash 3.2, whose parser chokes on a `case` nested inside a command substitution - the scan could not even be sourced there, so phpvm.sh was broken outright on macOS, not merely degraded. Split PATH with parameter expansion instead. That drops the `$( ... case ... )` construct, and with it the dependency on tr and awk: the check that tells you PATH is broken should not need to find coreutils on that same PATH. It also keeps working under zsh, where `for d in $PATH` does not split on colons. The PATH tests can now hand doctor a PATH holding nothing but phpvm, so they stop depending on whether the runner ships a php of its own - which is what made them pass locally and fail on ubuntu. devhardiyanto --- linux/phpvm.sh | 38 +++++++++++++++++++++++++------------- tests/linux/commands.bats | 23 +++++++---------------- 2 files changed, 32 insertions(+), 29 deletions(-) diff --git a/linux/phpvm.sh b/linux/phpvm.sh index 1807068..9dfe3e6 100644 --- a/linux/phpvm.sh +++ b/linux/phpvm.sh @@ -925,18 +925,25 @@ phpvm_doctor() { # 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. Splitting via tr keeps this working in - # zsh, where `for d in $PATH` does not split on colons. - local php_paths - php_paths=$(printf '%s' "$PATH" | tr ':' '\n' | while read -r d; do - [[ -n "$d" && -x "$d/php" ]] && printf '%s\n' "$d/php" - done | awk '!seen[$0]++') + # 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 - first=$(printf '%s\n' "$php_paths" | head -1) + local first="${php_paths%%:*}" case "$first" in "$PHPVM_CURRENT"/*|"$PHPVM_BIN"/*|"$PHPVM_VERSIONS"/*) _dok "'php' resolves to phpvm: $first" ;; @@ -945,13 +952,18 @@ phpvm_doctor() { _dim "Ensure $PHPVM_DIR is sourced in your shell rc, then open a new shell." ;; esac - local other - other=$(printf '%s\n' "$php_paths" | while read -r p; do + local other="" p + rest="$php_paths" + while [[ -n "$rest" ]]; do + p="${rest%%:*}" + if [[ "$p" == "$rest" ]]; then rest=""; else rest="${rest#*:}"; fi case "$p" in - "$PHPVM_CURRENT"/*|"$PHPVM_BIN"/*|"$PHPVM_VERSIONS"/*) ;; - *) printf '%s\n' "$p" ;; + # `:` 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 | head -1) + 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." diff --git a/tests/linux/commands.bats b/tests/linux/commands.bats index ab766fe..1a8ae59 100644 --- a/tests/linux/commands.bats +++ b/tests/linux/commands.bats @@ -553,19 +553,10 @@ _ext_row() { [[ "$output" != *"8.5.2 8.5.6"* ]] } -# PATH with every directory that ships a php removed. The doctor PATH checks -# assert on how many PHPs are visible, and CI images (and dev machines) may well -# have a distro php of their own - dropping them keeps the assertions about the -# PATH the test built, not the one the host happened to bring. -_path_without_php() { - local out="" d - while read -r d; do - [[ -z "$d" ]] && continue - [[ -x "$d/php" ]] && continue - out="${out:+$out:}$d" - done <<< "$(printf '%s' "$PATH" | tr ':' '\n')" - printf '%s' "$out" -} +# 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 ---------- @@ -641,7 +632,7 @@ EOF 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:$(_path_without_php)" + export PATH="$PHPVM_VERSIONS/8.3.0/bin:$BATS_TEST_TMPDIR/usrbin" run phpvm_doctor [ "$status" -eq 0 ] [[ "$output" == *"resolves to phpvm"* ]] @@ -652,7 +643,7 @@ EOF @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:$(_path_without_php)" + export PATH="$PHPVM_VERSIONS/8.3.0/bin" run phpvm_doctor [ "$status" -eq 0 ] [[ "$output" == *"resolves to phpvm"* ]] @@ -667,7 +658,7 @@ EOF 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:$(_path_without_php)" + 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"* ]] From 455566dd507c01a54947e4078d5165e7edb5798d Mon Sep 17 00:00:00 2001 From: Irfan Hardiyanto <52022757+devhardiyanto@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:06:49 +0700 Subject: [PATCH 4/4] test(linux): restore PATH after the doctor shadowing tests Those tests hand doctor a PATH holding nothing but phpvm, and bats runs its own cleanup with whatever PATH the test left behind - so `rm` went missing and the job exited 1 with all 112 assertions green. devhardiyanto --- tests/linux/commands.bats | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/linux/commands.bats b/tests/linux/commands.bats index 1a8ae59..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