diff --git a/.github/workflows/composer-update-tests.yml b/.github/workflows/composer-update-tests.yml index 9be6ba8..7724786 100644 --- a/.github/workflows/composer-update-tests.yml +++ b/.github/workflows/composer-update-tests.yml @@ -21,24 +21,12 @@ jobs: steps: - uses: actions/checkout@v4 - # jq, git and bash are preinstalled on ubuntu-latest runners. - - name: Run shell tests - run: | - shopt -s nullglob - tests=(composer-update/tests/*.test.sh) - if [ ${#tests[@]} -eq 0 ]; then - echo "No tests found" >&2 - exit 1 - fi - fail=0 - for t in "${tests[@]}"; do - echo "::group::$t" - if bash "$t"; then - echo "ok: $t" - else - echo "::error::$t failed" - fail=1 - fi - echo "::endgroup::" - done - exit $fail + # The PHP helper tests run the real helpers and bootstrap composer/semver + # into a temp vendor dir. jq, git and bash are preinstalled on the runner. + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + tools: composer + + - name: Run tests + run: chmod +x composer-update/tests/run.sh && composer-update/tests/run.sh diff --git a/composer-update/scripts/compute-min-safe-constraints.php b/composer-update/scripts/compute-min-safe-constraints.php index 562923b..4f5488c 100644 --- a/composer-update/scripts/compute-min-safe-constraints.php +++ b/composer-update/scripts/compute-min-safe-constraints.php @@ -113,4 +113,7 @@ } } -echo json_encode($result, JSON_UNESCAPED_SLASHES); +// Cast to object so an empty result encodes as `{}`, not `[]`. composer-update +// string-indexes this map with `jq '.[$pkg]'`, which errors on a JSON array — +// so an empty array would break the consumer under `set -eo pipefail`. +echo json_encode((object) $result, JSON_UNESCAPED_SLASHES); diff --git a/composer-update/scripts/lib.sh b/composer-update/scripts/lib.sh new file mode 100755 index 0000000..e993463 --- /dev/null +++ b/composer-update/scripts/lib.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# +# Helper functions for composer-update/scripts/update.sh. +# +# Sourced by update.sh (and by the unit tests under ../tests). These functions +# are pure-ish: they take args and read a few tempfiles that update.sh writes +# before calling them: +# /tmp/composer-update-constraints.json (build_pkg_arg, build_widen_arg) +# /tmp/composer-update-direct.txt (find_direct_ancestors, expand_args_for) +# /tmp/composer-update-reverse.txt (find_direct_ancestors) +# /tmp/composer-update-vulns.json (is_still_vulnerable) +# and $GITHUB_ACTION_PATH for the PHP helpers. + +# Build the composer arg for a single package: just the name if no +# constraint is configured for it, or `name:constraint` (e.g. +# `vendor/pkg:~1.2.3`) when the caller supplied a tight bound. +# Tilde at offset >0 inside a single word isn't subject to shell +# tilde expansion, so the colon-form is safe to interpolate. +# Trailing newline matters: expand_args_for() concatenates this +# with find_direct_ancestors() and pipes the result to `while read`; +# without it, a direct-dep package produces an unterminated line +# that `read` discards and the arg ends up empty. (#27) +build_pkg_arg() { + local pkg="$1" + local c + c=$(jq -r --arg p "$pkg" '.[$p] // ""' /tmp/composer-update-constraints.json) + if [ -n "$c" ]; then + printf '%s:%s\n' "$pkg" "$c" + else + printf '%s\n' "$pkg" + fi +} + +# Build the composer arg for the WIDEN step (composer require), which +# REPLACES the project's constraint rather than tightening within it. +# Here the package gets a caret `^min_safe` (e.g. `^27.1.2` = +# `>=27.1.2,<28.0.0`) instead of the patch-pinned tight `~min_safe`. +# Why: `composer require` is reached only when the in-constraint +# update already failed — i.e. the fix lives OUTSIDE composer.json's +# current constraint (e.g. project pins `^26.0` but the patched +# release is 27.x). A tight `~27.1.2` would (a) pin to a single patch +# line that often doesn't exist (Yoast went 27.1.1 → 27.2, no 27.1.2) +# and (b) defeat the whole point of widening. The caret spans the +# safe-version's whole major, so composer can land on the real fix +# (27.6) while still not crossing into the next major. Packages with +# no min_safe (no vulns_json) fall back to a bare name — unconstrained +# widening, as before. (#29) +build_widen_arg() { + local pkg="$1" + local c + c=$(jq -r --arg p "$pkg" '.[$p] // ""' /tmp/composer-update-constraints.json) + if [[ "$c" =~ ^~([0-9.]+)$ ]]; then + printf '%s:^%s' "$pkg" "${BASH_REMATCH[1]}" + else + printf '%s' "$pkg" + fi +} + +# Find direct-dep ancestor(s) of a package by BFS through the reverse +# map. Returns one ancestor per line. If the package is itself a +# direct dep, returns just that name. (#22, #26) +find_direct_ancestors() { + local target="$1" + local seen="|" + local queue="$target" + local result="" + while [ -n "$queue" ]; do + local current + current=$(echo "$queue" | head -1) + queue=$(echo "$queue" | tail -n +2) + case "$seen" in *"|$current|"*) continue ;; esac + seen="$seen$current|" + if grep -qFx "$current" /tmp/composer-update-direct.txt; then + result="$result $current" + continue + fi + local parents + parents=$(awk -v t="$current" '$1 == t {print $2}' /tmp/composer-update-reverse.txt) + if [ -n "$parents" ]; then + queue=$(printf '%s\n%s' "$queue" "$parents") + fi + done + echo "$result" | tr ' ' '\n' | grep -v '^$' | sort -u || true +} + +# Derive a "same-major, not-affected" loose constraint from the tight +# `~X.Y.Z` form: `>=X.Y.Z, <(X+1).0.0`. Used as a fallback when the +# tight retry matches no published version (e.g. CVE upper bound +# `<=6.6.3` heuristic'd to `~6.6.4`, but WordPress never tagged +# 6.6.4 — the next release is 6.7.0). Lets composer pick the next +# safe version within composer.json's existing constraint rather +# than falling through to widening. (#28) +loosen_constraint() { + local tight="$1" + if [[ "$tight" =~ ^~([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + local major="${BASH_REMATCH[1]}" + local minor="${BASH_REMATCH[2]}" + local patch="${BASH_REMATCH[3]}" + printf '>=%s.%s.%s,<%s.0.0' "$major" "$minor" "$patch" "$((major + 1))" + fi +} + +# Safety net for the loose retry: confirm the package's new locked +# version is OUT of every range in its `affected` field before +# accepting the update. composer audit's advisory database normally +# blocks vulnerable versions, but `audit.block-insecure` defaults +# vary by project and we'd rather over-revert than ship a "fix" +# that doesn't fix anything. (#28) +is_still_vulnerable() { + local pkg="$1" + local version="$2" + if [ ! -s /tmp/composer-update-vulns.json ] || [ -z "$version" ]; then + echo no + return + fi + php "$GITHUB_ACTION_PATH/scripts/is-still-vulnerable.php" \ + /tmp/composer-update-vulns.json "$pkg" "$version" +} + +# Expand a single flagged package into the args we pass to composer: +# `name[:constraint]` for the package itself, plus the names of its +# direct-dep ancestor(s) when the flagged package is a transitive. +# +# Why: `composer update -W X` updates X and X's dependencies (downward), +# but NOT X's reverse-deps. For metapackages like roots/wordpress that +# pin roots/wordpress-no-content at self.version, the parent is locked +# at the same version as the transitive and won't move unless we list +# it explicitly. Without this expansion, updating wordpress-no-content +# within a tight ~constraint fails with "roots/wordpress is locked and +# not requested" and falls through to widening — which then rewrites +# composer.json unnecessarily. +# +# Ancestors get no constraint suffix: we only want them eligible for +# movement within their existing composer.json constraint. (#26) +expand_args_for() { + local pkg="$1" + build_pkg_arg "$pkg" + if ! grep -qFx "$pkg" /tmp/composer-update-direct.txt; then + find_direct_ancestors "$pkg" + fi +} + +# Read a package's locked version from a given composer.lock file. +get_lock_version() { + jq -r --arg p "$1" ' + ((.packages // []) + (."packages-dev" // [])) + | map(select(.name == $p)) | first | .version // empty + ' "$2" +} diff --git a/composer-update/scripts/update.sh b/composer-update/scripts/update.sh index 76d91bb..44c1e87 100755 --- a/composer-update/scripts/update.sh +++ b/composer-update/scripts/update.sh @@ -6,6 +6,10 @@ # GITHUB_ACTION_PATH and GITHUB_OUTPUT. Mirrors the composite shell options. set -eo pipefail +# Shared helper functions (build_pkg_arg, build_widen_arg, find_direct_ancestors, +# loosen_constraint, is_still_vulnerable, expand_args_for, get_lock_version). +source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + # Snapshot composer.lock to a tempfile BEFORE any composer ops run, # so we have an untouched copy for the before/after version diff. cp composer.lock /tmp/composer-update-before.lock @@ -29,50 +33,6 @@ if [ -n "${VULNS_JSON:-}" ]; then fi fi -# Build the composer arg for a single package: just the name if no -# constraint is configured for it, or `name:constraint` (e.g. -# `vendor/pkg:~1.2.3`) when the caller supplied a tight bound. -# Tilde at offset >0 inside a single word isn't subject to shell -# tilde expansion, so the colon-form is safe to interpolate. -# Trailing newline matters: expand_args_for() concatenates this -# with find_direct_ancestors() and pipes the result to `while read`; -# without it, a direct-dep package produces an unterminated line -# that `read` discards and the arg ends up empty. -build_pkg_arg() { - local pkg="$1" - local c - c=$(jq -r --arg p "$pkg" '.[$p] // ""' /tmp/composer-update-constraints.json) - if [ -n "$c" ]; then - printf '%s:%s\n' "$pkg" "$c" - else - printf '%s\n' "$pkg" - fi -} - -# Build the composer arg for the WIDEN step (composer require), which -# REPLACES the project's constraint rather than tightening within it. -# Here the package gets a caret `^min_safe` (e.g. `^27.1.2` = -# `>=27.1.2,<28.0.0`) instead of the patch-pinned tight `~min_safe`. -# Why: `composer require` is reached only when the in-constraint -# update already failed — i.e. the fix lives OUTSIDE composer.json's -# current constraint (e.g. project pins `^26.0` but the patched -# release is 27.x). A tight `~27.1.2` would (a) pin to a single patch -# line that often doesn't exist (Yoast went 27.1.1 → 27.2, no 27.1.2) -# and (b) defeat the whole point of widening. The caret spans the -# safe-version's whole major, so composer can land on the real fix -# (27.6) while still not crossing into the next major. Packages with -# no min_safe (no vulns_json) fall back to a bare name — unconstrained -# widening, as before. -build_widen_arg() { - local pkg="$1" - local c - c=$(jq -r --arg p "$pkg" '.[$p] // ""' /tmp/composer-update-constraints.json) - if [[ "$c" =~ ^~([0-9.]+)$ ]]; then - printf '%s:^%s' "$pkg" "${BASH_REMATCH[1]}" - else - printf '%s' "$pkg" - fi -} # Precompute the project's direct-dependency set + the lock's reverse- # dependency map, used by: @@ -89,89 +49,6 @@ jq -r ' "\($child) \($p.name)" ' composer.lock > /tmp/composer-update-reverse.txt -# Find direct-dep ancestor(s) of a package by BFS through the reverse -# map. Returns one ancestor per line. If the package is itself a -# direct dep, returns just that name. -find_direct_ancestors() { - local target="$1" - local seen="|" - local queue="$target" - local result="" - while [ -n "$queue" ]; do - local current - current=$(echo "$queue" | head -1) - queue=$(echo "$queue" | tail -n +2) - case "$seen" in *"|$current|"*) continue ;; esac - seen="$seen$current|" - if grep -qFx "$current" /tmp/composer-update-direct.txt; then - result="$result $current" - continue - fi - local parents - parents=$(awk -v t="$current" '$1 == t {print $2}' /tmp/composer-update-reverse.txt) - if [ -n "$parents" ]; then - queue=$(printf '%s\n%s' "$queue" "$parents") - fi - done - echo "$result" | tr ' ' '\n' | grep -v '^$' | sort -u || true -} - -# Derive a "same-major, not-affected" loose constraint from the tight -# `~X.Y.Z` form: `>=X.Y.Z, <(X+1).0.0`. Used as a fallback when the -# tight retry matches no published version (e.g. CVE upper bound -# `<=6.6.3` heuristic'd to `~6.6.4`, but WordPress never tagged -# 6.6.4 — the next release is 6.7.0). Lets composer pick the next -# safe version within composer.json's existing constraint rather -# than falling through to widening. -loosen_constraint() { - local tight="$1" - if [[ "$tight" =~ ^~([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then - local major="${BASH_REMATCH[1]}" - local minor="${BASH_REMATCH[2]}" - local patch="${BASH_REMATCH[3]}" - printf '>=%s.%s.%s,<%s.0.0' "$major" "$minor" "$patch" "$((major + 1))" - fi -} - -# Safety net for the loose retry: confirm the package's new locked -# version is OUT of every range in its `affected` field before -# accepting the update. composer audit's advisory database normally -# blocks vulnerable versions, but `audit.block-insecure` defaults -# vary by project and we'd rather over-revert than ship a "fix" -# that doesn't fix anything. -is_still_vulnerable() { - local pkg="$1" - local version="$2" - if [ ! -s /tmp/composer-update-vulns.json ] || [ -z "$version" ]; then - echo no - return - fi - php "$GITHUB_ACTION_PATH/scripts/is-still-vulnerable.php" \ - /tmp/composer-update-vulns.json "$pkg" "$version" -} - -# Expand a single flagged package into the args we pass to composer: -# `name[:constraint]` for the package itself, plus the names of its -# direct-dep ancestor(s) when the flagged package is a transitive. -# -# Why: `composer update -W X` updates X and X's dependencies (downward), -# but NOT X's reverse-deps. For metapackages like roots/wordpress that -# pin roots/wordpress-no-content at self.version, the parent is locked -# at the same version as the transitive and won't move unless we list -# it explicitly. Without this expansion, updating wordpress-no-content -# within a tight ~constraint fails with "roots/wordpress is locked and -# not requested" and falls through to widening — which then rewrites -# composer.json unnecessarily. -# -# Ancestors get no constraint suffix: we only want them eligible for -# movement within their existing composer.json constraint. -expand_args_for() { - local pkg="$1" - build_pkg_arg "$pkg" - if ! grep -qFx "$pkg" /tmp/composer-update-direct.txt; then - find_direct_ancestors "$pkg" - fi -} # Step 1: Try composer update (stays within existing constraints). # `-W` (--with-all-dependencies) lets composer also update packages that @@ -207,12 +84,6 @@ fi # per-package: for any package whose locked version didn't move, # retry the update on just that one so a single unfixable package # can't poison the rest. -get_lock_version() { - jq -r --arg p "$1" ' - ((.packages // []) + (."packages-dev" // [])) - | map(select(.name == $p)) | first | .version // empty - ' "$2" -} UNHANDLED="" for PACKAGE in $PACKAGES; do diff --git a/composer-update/tests/compute-min-safe-constraints.test.sh b/composer-update/tests/compute-min-safe-constraints.test.sh new file mode 100755 index 0000000..bc6fcb7 --- /dev/null +++ b/composer-update/tests/compute-min-safe-constraints.test.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# +# Unit tests for scripts/compute-min-safe-constraints.php +# +# Verifies the per-package "minimum-safe" constraint derivation: pick the +# affected range that contains the locked version, then turn its upper bound +# into a tight ~X.Y.Z (capping at the safe minor). Covers exclusive vs +# inclusive bounds, missing version components, multi-range selection, and the +# no-entry fall-throughs. +set -uo pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +WORK="$(mktmp)" +ensure_semver_vendor "$WORK" +cd "$WORK" + +# run_compute -> prints the helper's JSON output +run_compute() { + printf '%s' "$1" > vulns.json + printf '%s' "$2" > composer.lock + php "$SCRIPTS_DIR/compute-min-safe-constraints.php" vulns.json composer.lock +} + +lock() { # lock + printf '{"packages":[{"name":"%s","version":"%s"}],"packages-dev":[]}' "$1" "$2" +} + +echo "== exclusive upper bound ~X.Y.Z ==" +out=$(run_compute '[{"package":"vendor/a","affected":">=7.0.0,<7.4.12"}]' "$(lock vendor/a v7.4.8)") +assert_eq "$(echo "$out" | jq -r '."vendor/a"')" "~7.4.12" "<7.4.12 -> ~7.4.12" + +echo "== inclusive upper bound <=X.Y.Z => ~X.Y.(Z+1) ==" +out=$(run_compute '[{"package":"vendor/a","affected":"<=2.0.21"}]' "$(lock vendor/a 2.0.10)") +assert_eq "$(echo "$out" | jq -r '."vendor/a"')" "~2.0.22" "<=2.0.21 -> ~2.0.22" + +echo "== missing patch ~X.Y.0 (not ~X.Y.) ==" +out=$(run_compute '[{"package":"vendor/a","affected":">=10.5,<10.6"}]' "$(lock vendor/a 10.5.3)") +assert_eq "$(echo "$out" | jq -r '."vendor/a"')" "~10.6.0" "<10.6 -> ~10.6.0" + +echo "== missing minor+patch ~X.0.0 ==" +out=$(run_compute '[{"package":"vendor/a","affected":"<8"}]' "$(lock vendor/a 7.4.0)") +assert_eq "$(echo "$out" | jq -r '."vendor/a"')" "~8.0.0" "<8 -> ~8.0.0" + +echo "== multi-range '|': pick the range containing the locked version ==" +# Locked 6.4.30 sits in the second range; its bound (<6.4.40) drives the result. +multi='[{"package":"vendor/a","affected":">=7.0.0,<7.4.12|>=6.4.0,<6.4.40"}]' +out=$(run_compute "$multi" "$(lock vendor/a v6.4.30)") +assert_eq "$(echo "$out" | jq -r '."vendor/a"')" "~6.4.40" "selects range matching locked version" + +echo "== package not present in lock => no entry ==" +out=$(run_compute '[{"package":"vendor/missing","affected":"<2.0.0"}]' "$(lock vendor/other 1.0.0)") +assert_eq "$(echo "$out" | jq -r 'has("vendor/missing")')" "false" "unlocked package omitted" + +echo "== locked version not in any affected range => no entry ==" +out=$(run_compute '[{"package":"vendor/a","affected":"<7.0.0"}]' "$(lock vendor/a v7.4.8)") +assert_eq "$(echo "$out" | jq -r 'has("vendor/a")')" "false" "already-safe version omitted" + +echo "== no parseable upper bound (only lower) => no entry ==" +out=$(run_compute '[{"package":"vendor/a","affected":">=1.0.0"}]' "$(lock vendor/a 1.5.0)") +assert_eq "$(echo "$out" | jq -r 'has("vendor/a")')" "false" "no upper bound omitted" + +echo "== multiple packages in one run ==" +twolock='{"packages":[{"name":"vendor/a","version":"7.4.8"},{"name":"vendor/b","version":"1.2.0"}],"packages-dev":[]}' +out=$(run_compute '[{"package":"vendor/a","affected":"<7.4.12"},{"package":"vendor/b","affected":"<=1.2.3"}]' "$twolock") +assert_eq "$(echo "$out" | jq -r '."vendor/a"')" "~7.4.12" "pkg a" +assert_eq "$(echo "$out" | jq -r '."vendor/b"')" "~1.2.4" "pkg b (inclusive bump)" + +finish diff --git a/composer-update/tests/is-still-vulnerable.test.sh b/composer-update/tests/is-still-vulnerable.test.sh new file mode 100755 index 0000000..794d920 --- /dev/null +++ b/composer-update/tests/is-still-vulnerable.test.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# +# Unit tests for scripts/is-still-vulnerable.php (guard added in #28) +# +# Prints `yes` if a version is inside any of the package's affected ranges, +# else `no`. Used by the loose-constraint retry to avoid accepting a "fix" +# that's still vulnerable. Must fail safe (`no`) on junk input. +set -uo pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +WORK="$(mktmp)" +ensure_semver_vendor "$WORK" +cd "$WORK" + +check() { # check + printf '%s' "$1" > vulns.json + php "$SCRIPTS_DIR/is-still-vulnerable.php" vulns.json "$2" "$3" +} + +V='[{"package":"vendor/a","affected":">=6.0.0,<6.6.4"}]' + +assert_eq "$(check "$V" vendor/a 6.6.0)" "yes" "version inside affected range -> yes" +assert_eq "$(check "$V" vendor/a 6.6.4)" "no" "version at safe boundary -> no" +assert_eq "$(check "$V" vendor/a 7.0.0)" "no" "version above range -> no" + +echo "== multi-range '|' ==" +M='[{"package":"vendor/a","affected":">=5.0,<5.4|>=6.0,<6.6.4"}]' +assert_eq "$(check "$M" vendor/a 6.5.0)" "yes" "matches second range -> yes" +assert_eq "$(check "$M" vendor/a 5.9.0)" "no" "between ranges -> no" + +echo "== package not in vulns -> no ==" +assert_eq "$(check "$V" vendor/other 1.0.0)" "no" "unknown package -> no" + +echo "== empty affected -> no ==" +assert_eq "$(check '[{"package":"vendor/a","affected":""}]' vendor/a 1.0.0)" "no" "empty affected skipped -> no" + +echo "== unparseable range ignored, others still checked ==" +U='[{"package":"vendor/a","affected":"garbage|>=6.0,<6.6.4"}]' +assert_eq "$(check "$U" vendor/a 6.5.0)" "yes" "bad range skipped, good range matches -> yes" + +echo "== invalid vulns JSON fails safe -> no ==" +assert_eq "$(check 'not json' vendor/a 1.0.0)" "no" "junk input -> no" + +finish diff --git a/composer-update/tests/lib-functions.test.sh b/composer-update/tests/lib-functions.test.sh new file mode 100755 index 0000000..31c79de --- /dev/null +++ b/composer-update/tests/lib-functions.test.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# +# Unit tests for scripts/lib.sh — the pure-ish helper functions. +# +# Covers the edge cases fixed in: +# #27 build_pkg_arg trailing newline +# #29 build_widen_arg caret (^min_safe) widening +# #28 loosen_constraint same-major loose range +# #22/#26 find_direct_ancestors BFS + expand_args_for ancestor inclusion +set -uo pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +source "$(dirname "${BASH_SOURCE[0]}")/../scripts/lib.sh" + +CON=/tmp/composer-update-constraints.json +DIRECT=/tmp/composer-update-direct.txt +REVERSE=/tmp/composer-update-reverse.txt + +echo "== build_pkg_arg (#27) ==" +echo '{"vendor/a":"~1.2.3"}' > "$CON" +assert_eq "$(build_pkg_arg vendor/a)" "vendor/a:~1.2.3" "name:constraint when constrained" +assert_eq "$(build_pkg_arg vendor/b)" "vendor/b" "bare name when unconstrained" +# #27: a bare (direct-dep) arg MUST be newline-terminated or expand_args_for's +# `while read` drops it. command-subst strips the newline, so count lines. +assert_eq "$(build_pkg_arg vendor/b | wc -l | tr -d ' ')" "1" "bare arg is newline-terminated" +assert_eq "$(build_pkg_arg vendor/a | wc -l | tr -d ' ')" "1" "constrained arg is newline-terminated" + +echo "== build_widen_arg (#29) ==" +echo '{"vendor/a":"~27.1.2"}' > "$CON" +assert_eq "$(build_widen_arg vendor/a)" "vendor/a:^27.1.2" "tight ~X.Y.Z widens to caret ^X.Y.Z" +assert_eq "$(build_widen_arg vendor/b)" "vendor/b" "no constraint -> bare name (unconstrained widen)" +echo '{"vendor/a":">=1.0,<2.0"}' > "$CON" +assert_eq "$(build_widen_arg vendor/a)" "vendor/a" "non-tilde constraint -> bare name" + +echo "== loosen_constraint (#28) ==" +assert_eq "$(loosen_constraint '~6.6.4')" ">=6.6.4,<7.0.0" "~6.6.4 -> same-major loose range" +assert_eq "$(loosen_constraint '~10.5.3')" ">=10.5.3,<11.0.0" "~10.5.3 -> >=10.5.3,<11.0.0" +assert_eq "$(loosen_constraint '~6.6')" "" "2-part tilde -> empty (needs X.Y.Z)" +assert_eq "$(loosen_constraint '^1.0.0')" "" "caret -> empty" + +echo "== find_direct_ancestors (#22, #26) ==" +printf 'roots/wordpress\nvendor/x\n' > "$DIRECT" +printf 'roots/wordpress-no-content roots/wordpress\n' > "$REVERSE" +assert_eq "$(find_direct_ancestors roots/wordpress)" "roots/wordpress" "a direct dep returns itself" +assert_eq "$(find_direct_ancestors roots/wordpress-no-content)" "roots/wordpress" "transitive BFS up to direct ancestor" +# Multi-level BFS: a -> b -> c, only c is direct. +printf 'c\n' > "$DIRECT" +printf 'a b\nb c\n' > "$REVERSE" +assert_eq "$(find_direct_ancestors a)" "c" "multi-level BFS resolves to nearest direct ancestor" +assert_eq "$(find_direct_ancestors unknown)" "" "no ancestor -> empty (no error under pipefail)" + +echo "== expand_args_for (#26) ==" +echo '{"roots/wordpress-no-content":"~6.8.5"}' > "$CON" +printf 'roots/wordpress\n' > "$DIRECT" +printf 'roots/wordpress-no-content roots/wordpress\n' > "$REVERSE" +# Transitive: emits its own (constrained) arg AND the direct-dep ancestor. +out=$(expand_args_for roots/wordpress-no-content) +assert_contains "$out" "roots/wordpress-no-content:~6.8.5" "transitive's own constrained arg" +assert_contains "$out" "roots/wordpress" "plus the direct-dep ancestor" +# Direct dep: just its own arg, no ancestor line. +echo '{"roots/wordpress":"~6.8.5"}' > "$CON" +assert_eq "$(expand_args_for roots/wordpress)" "roots/wordpress:~6.8.5" "direct dep -> only its own arg" + +echo "== get_lock_version ==" +printf '{"packages":[{"name":"vendor/a","version":"v1.2.3"}],"packages-dev":[{"name":"dev/b","version":"2.0.0"}]}' > /tmp/_lock.json +assert_eq "$(get_lock_version vendor/a /tmp/_lock.json)" "v1.2.3" "reads version from packages" +assert_eq "$(get_lock_version dev/b /tmp/_lock.json)" "2.0.0" "reads version from packages-dev" +assert_eq "$(get_lock_version vendor/missing /tmp/_lock.json)" "" "absent package -> empty" +rm -f /tmp/_lock.json + +finish diff --git a/composer-update/tests/lib.sh b/composer-update/tests/lib.sh new file mode 100755 index 0000000..919d698 --- /dev/null +++ b/composer-update/tests/lib.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# +# Shared helpers for composer-update tests. Source this from a *.test.sh file: +# +# source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +# +# Provides: assert_eq, assert_contains, assert_not_contains, assert_exit, +# pass/fail bookkeeping (call `finish` at the end), a temp-dir factory, a +# fake-composer builder, and a composer/semver bootstrap for the PHP helpers. + +ACTION_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SCRIPTS_DIR="$ACTION_DIR/scripts" + +_tests=0 +_fails=0 + +_red() { printf '\033[31m%s\033[0m\n' "$1"; } +_green() { printf '\033[32m%s\033[0m\n' "$1"; } + +assert_eq() { + local actual="$1" expected="$2" msg="${3:-}" + _tests=$((_tests + 1)) + if [ "$actual" = "$expected" ]; then + _green " ok: ${msg:-equal}" + else + _fails=$((_fails + 1)) + _red " FAIL: ${msg:-equal}" + printf ' expected: %q\n actual: %q\n' "$expected" "$actual" + fi +} + +assert_contains() { + local haystack="$1" needle="$2" msg="${3:-}" + _tests=$((_tests + 1)) + if printf '%s' "$haystack" | grep -qF -- "$needle"; then + _green " ok: ${msg:-contains '$needle'}" + else + _fails=$((_fails + 1)) + _red " FAIL: ${msg:-contains '$needle'}" + printf ' needle: %q\n in: %q\n' "$needle" "$haystack" + fi +} + +assert_not_contains() { + local haystack="$1" needle="$2" msg="${3:-}" + _tests=$((_tests + 1)) + if printf '%s' "$haystack" | grep -qF -- "$needle"; then + _fails=$((_fails + 1)) + _red " FAIL: ${msg:-does not contain '$needle'}" + printf ' unexpectedly found: %q\n' "$needle" + else + _green " ok: ${msg:-does not contain '$needle'}" + fi +} + +# assert_exit — runs cmd, compares its exit code. +assert_exit() { + local expected="$1"; shift + local actual=0 + "$@" >/dev/null 2>&1 || actual=$? + _tests=$((_tests + 1)) + if [ "$actual" = "$expected" ]; then + _green " ok: exit $expected" + else + _fails=$((_fails + 1)) + _red " FAIL: expected exit $expected, got $actual ($*)" + fi +} + +_tmpdirs=() +_cleanup_tmpdirs() { local d; for d in "${_tmpdirs[@]:-}"; do [ -n "$d" ] && rm -rf "$d"; done; } +trap _cleanup_tmpdirs EXIT + +finish() { + echo "------------------------------------------------------------" + if [ "$_fails" -eq 0 ]; then + _green "PASS — $_tests assertion(s)" + exit 0 + fi + _red "FAIL — $_fails of $_tests assertion(s) failed" + exit 1 +} + +# mktmp — create a temp dir, registered for cleanup at exit. (Must NOT set its +# own EXIT trap: callers use `d=$(mktmp)`, and a trap set inside that +# command-substitution subshell would fire — deleting the dir — the moment the +# subshell returns, before the caller can use it.) +mktmp() { + local d + d="$(mktemp -d)" + _tmpdirs+=("$d") + echo "$d" +} + +# write_fake_composer — drop an executable `composer` shim into +# bin-dir whose body is the given bash. The shim receives composer's args. +write_fake_composer() { + local dir="$1" body="$2" + mkdir -p "$dir" + { + echo '#!/usr/bin/env bash' + echo "$body" + } > "$dir/composer" + chmod +x "$dir/composer" +} + +# new_project — create a +# git-initialised temp project with the given manifest/lock and a fake `composer` +# shim on PATH, cd into it, and export the env update.sh expects. Echoes nothing; +# leaves you in the project dir with GH_OUTPUT pointing at ./github_output. +new_project() { + local manifest="$1" lock="$2" composer_body="$3" + local dir + dir="$(mktmp)" + cd "$dir" + git init -q + git config user.email test@example.com + git config user.name test + printf '%s' "$manifest" > composer.json + printf '%s' "$lock" > composer.lock + git add -A && git commit -qm init + write_fake_composer "$dir/bin" "$composer_body" + export PATH="$dir/bin:$PATH" + export GITHUB_ACTION_PATH="$SCRIPTS_DIR" + export GITHUB_OUTPUT="$dir/github_output" + : > "$GITHUB_OUTPUT" +} + +# run_update [VULNS_JSON] — run the real update.sh with PACKAGES already exported, +# capturing combined output into $RUN_LOG and its exit code into $RUN_EXIT. +run_update() { + export VULNS_JSON="${1:-}" + RUN_LOG=$(bash "$SCRIPTS_DIR/update.sh" 2>&1); RUN_EXIT=$? + export RUN_LOG RUN_EXIT +} + +# json_constraint — echo the require/require-dev constraint for a package +# from the CWD composer.json (empty if absent). +json_constraint() { + jq -r --arg p "$1" '(.require // {})[$p] // (.["require-dev"] // {})[$p] // ""' composer.json +} + +# ensure_semver_vendor — make `$dir/vendor/autoload.php` provide +# composer/semver (what the PHP helpers require via getcwd()/vendor). Cached in +# a shared location so we only hit the network once per test run. +ensure_semver_vendor() { + local target="$1" + local cache="${TMPDIR:-/tmp}/composer-update-test-semver" + if [ ! -f "$cache/vendor/autoload.php" ]; then + mkdir -p "$cache" + ( cd "$cache" && composer require composer/semver --no-interaction --quiet >/dev/null 2>&1 ) + fi + mkdir -p "$target" + cp -R "$cache/vendor" "$target/vendor" +} diff --git a/composer-update/tests/run.sh b/composer-update/tests/run.sh new file mode 100755 index 0000000..ea7c51f --- /dev/null +++ b/composer-update/tests/run.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# +# Run every composer-update/tests/*.test.sh and report a summary. +# Requires: bash, git, jq, php, composer (the PHP helper tests bootstrap +# composer/semver into a temp vendor dir). +# +# Usage: composer-update/tests/run.sh +set -uo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")" + +pass=0 +fail=0 +for t in *.test.sh; do + echo "==================================================================" + echo "RUN $t" + echo "==================================================================" + if bash "$t"; then + pass=$((pass + 1)) + else + fail=$((fail + 1)) + fi + echo +done + +echo "==================================================================" +echo "SUMMARY: $pass file(s) passed, $fail failed" +echo "==================================================================" +[ "$fail" -eq 0 ] diff --git a/composer-update/tests/update-orchestration.test.sh b/composer-update/tests/update-orchestration.test.sh new file mode 100755 index 0000000..c578279 --- /dev/null +++ b/composer-update/tests/update-orchestration.test.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# +# Integration tests for scripts/update.sh — drives the real script end-to-end +# with a fake `composer` (no network) and asserts the Step 1 / Step 2 guards. +# +# Covers: +# #24 revert a `composer require` outcome that DOWNGRADES the package +# #21 revert a `composer require` that resolves to a dev-* constraint +# #23 per-package retry: one unfixable package doesn't widen the rest +# #20 no-widen honored when given as a JSON array (not just a map) +set -uo pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +source "$SCRIPTS_DIR/lib.sh" # get_lock_version for assertions + +# A fake-composer helper that bumps a package's locked version via jq. Paste +# into a fake-composer body as needed. +JQ_BUMP='bump(){ tmp=$(mktemp); jq --arg n "$1" --arg v "$2" "(.packages[]|select(.name==\$n).version)=\$v" composer.lock > "$tmp" && mv "$tmp" composer.lock; }' + +echo "==================================================================" +echo "#24 — composer require that downgrades is reverted" +echo "==================================================================" +new_project \ + '{"require":{"vendor/wp":"^6.6"}}' \ + '{"packages":[{"name":"vendor/wp","version":"6.6.4","require":{}}],"packages-dev":[]}' \ + ' +args="$*" +case "$args" in + update*) : ;; # fix is outside ^6.6 — nothing moves within constraints + require*vendor/wp*) + # Unconstrained require walks DOWN to 5.9.3 and rewrites the constraint. + tmp=$(mktemp); jq "(.packages[]|select(.name==\"vendor/wp\").version)=\"5.9.3\"" composer.lock > "$tmp" && mv "$tmp" composer.lock + tmp=$(mktemp); jq ".require[\"vendor/wp\"]=\"^5.9\"" composer.json > "$tmp" && mv "$tmp" composer.json + ;; +esac +exit 0' +export PACKAGES="vendor/wp" +run_update "" +assert_eq "$(json_constraint vendor/wp)" "^6.6" "composer.json constraint reverted (not ^5.9)" +assert_eq "$(get_lock_version vendor/wp composer.lock)" "6.6.4" "lock version reverted (not 5.9.3)" +assert_contains "$RUN_LOG" "downgrade 6.6.4" "logged the downgrade revert" +assert_contains "$(cat "$GITHUB_OUTPUT")" "changed=false" "no PR (nothing safely updatable)" + +echo "==================================================================" +echo "#21 — composer require that resolves to dev-* is reverted" +echo "==================================================================" +new_project \ + '{"require":{"vendor/plugin":"^1.0"}}' \ + '{"packages":[{"name":"vendor/plugin","version":"1.1.1","require":{}}],"packages-dev":[]}' \ + ' +args="$*" +case "$args" in + update*) : ;; + require*vendor/plugin*) + tmp=$(mktemp); jq "(.packages[]|select(.name==\"vendor/plugin\").version)=\"dev-trunk\"" composer.lock > "$tmp" && mv "$tmp" composer.lock + tmp=$(mktemp); jq ".require[\"vendor/plugin\"]=\"dev-trunk\"" composer.json > "$tmp" && mv "$tmp" composer.json + ;; +esac +exit 0' +export PACKAGES="vendor/plugin" +run_update "" +assert_eq "$(json_constraint vendor/plugin)" "^1.0" "dev-* constraint reverted" +assert_contains "$RUN_LOG" "resolved to dev branch constraint" "logged the dev-* revert" +assert_contains "$(cat "$GITHUB_OUTPUT")" "changed=false" "no PR" + +echo "==================================================================" +echo "#23 — one unfixable package doesn't force-widen the fixable ones" +echo "==================================================================" +# Bulk update (both pkgs) fails transactionally; per-package retry moves +# vendor/good within ^1.0; vendor/bad is unfixable (and pinned no-widen). +new_project \ + '{"require":{"vendor/good":"^1.0","vendor/bad":"^1.0"},"extra":{"vuln-scan":{"no-widen":{"vendor/bad":"pinned"}}}}' \ + '{"packages":[{"name":"vendor/good","version":"1.0.0","require":{}},{"name":"vendor/bad","version":"1.0.0","require":{}}],"packages-dev":[]}' \ + ' +args="$*" +if [[ "$args" == update*vendor/good*vendor/bad* || "$args" == update*vendor/bad*vendor/good* ]]; then + exit 1 # transactional batch rolls back because vendor/bad is unsatisfiable +elif [[ "$args" == update*vendor/good* ]]; then + tmp=$(mktemp); jq "(.packages[]|select(.name==\"vendor/good\").version)=\"1.0.5\"" composer.lock > "$tmp" && mv "$tmp" composer.lock +fi +exit 0' +export PACKAGES="vendor/good vendor/bad" +run_update "" +assert_eq "$(get_lock_version vendor/good composer.lock)" "1.0.5" "good moved within constraint via per-package retry" +assert_eq "$(json_constraint vendor/good)" "^1.0" "good NOT widened (constraint untouched)" +assert_eq "$(get_lock_version vendor/bad composer.lock)" "1.0.0" "bad unchanged" +assert_contains "$RUN_LOG" "skip vendor/bad — listed in extra.vuln-scan.no-widen" "bad skipped via no-widen" +assert_contains "$(cat "$GITHUB_OUTPUT")" "changed=true" "PR raised for the package that did update" + +echo "==================================================================" +echo "#20 — no-widen honored as a JSON array (not only a map)" +echo "==================================================================" +new_project \ + '{"require":{"vendor/safe":"^1.0","vendor/x":"1.0.0"},"extra":{"vuln-scan":{"no-widen":["vendor/x"]}}}' \ + '{"packages":[{"name":"vendor/safe","version":"1.0.0","require":{}},{"name":"vendor/x","version":"1.0.0","require":{}}],"packages-dev":[]}' \ + ' +args="$*" +case "$args" in + update*vendor/safe*) + tmp=$(mktemp); jq "(.packages[]|select(.name==\"vendor/safe\").version)=\"1.0.5\"" composer.lock > "$tmp" && mv "$tmp" composer.lock + ;; +esac +exit 0' +export PACKAGES="vendor/safe vendor/x" +run_update "" +assert_eq "$(get_lock_version vendor/safe composer.lock)" "1.0.5" "safe package updated" +assert_eq "$(json_constraint vendor/x)" "1.0.0" "no-widen (array form) left x pinned" +assert_contains "$RUN_LOG" "skip vendor/x — listed in extra.vuln-scan.no-widen" "array-form no-widen honored" + +finish