Skip to content

Conversation

@Thavarshan
Copy link
Owner

Purpose

Release v1.9.0 with critical production fixes for error handling, state management, and security hardening.

This release addresses multiple issues discovered during production use:

  • Error propagation failures causing silent failures in install_php and use_php_version
  • Premature state writes in switch_to_system_php before operations complete
  • Asymmetric package handling in apt install/uninstall
  • Missing directory validation for alias operations
  • Symlink creation without target verification
  • Path traversal vulnerabilities in alias handling

Approach

Error Propagation

  • Implemented safe pattern: capture phpvm_resolve_version output and check exit status before proceeding
  • Functions now fail fast with proper error messages instead of silently continuing

State Management

  • switch_to_system_php now only writes active_version=system after brew link succeeds
  • update_current_symlink now uses command -v php to get actual resolved path

Package Symmetry

  • apt uninstall now removes php{version}-cli, php{version}-common, and php{version}-fpm symmetrically with install

Alias Directory

  • Added explicit directory creation check with clear error messaging on failure

Symlink Robustness

  • Added target existence verification before symlink creation with warning on missing targets

Security Hardening

  • Moved phpvm_validate_alias_name() validation before any file path operations in phpvm_alias()
  • Added validation in phpvm_resolve_version() before accessing alias files
  • Added 4 new security tests for path traversal protection

Open Questions and Pre-Merge TODOs

  • Shellcheck passes with 0 errors/warnings
  • shfmt formatting applied consistently
  • All 51 BATS tests pass
  • Path traversal vulnerabilities addressed
  • CHANGELOG updated for v1.9.0

Learning

Research

  • Reviewed bash best practices for error propagation patterns (capture output + check $?)
  • Analyzed path traversal attack vectors in shell scripts
  • Studied symlink race condition mitigations

References

Copilot AI review requested due to automatic review settings January 30, 2026 10:05
@Thavarshan Thavarshan added bug Something isn't working documentation Improvements or additions to documentation duplicate This issue or pull request already exists enhancement New feature or request labels Jan 30, 2026
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bda71b5383

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

phpvm.sh Outdated
Comment on lines 2743 to 2747
# Validate version format BEFORE using it in path check (security)
# This prevents path traversal attacks with malicious version strings
if ! validate_php_version "$version"; then
phpvm_err "Invalid PHP version format: $version"
return "$PHPVM_EXIT_INVALID_ARG"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Permit alias targets like latest/stable

Because phpvm_alias now calls validate_php_version before phpvm_resolve_version, non-numeric keywords such as latest/stable (which phpvm_resolve_version explicitly supports and are documented as version arguments) are rejected with “Invalid PHP version format.” This makes commands like phpvm alias default latest or phpvm alias prod stable fail even though they previously resolved to a concrete version; consider resolving first and then validating the resolved value, or allow these keywords explicitly before the validation.

Useful? React with 👍 / 👎.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces v1.9.0 of phpvm with critical production fixes addressing error handling, state management, and security vulnerabilities discovered during production use. The release focuses on making the tool more robust and secure through proper error propagation, atomic state management, path traversal protection, and package manager abstraction.

Changes:

  • Fixed critical error propagation failures in phpvm_resolve_version that caused silent failures in install and use commands
  • Implemented atomic state management ensuring active_version files are only written after operations succeed
  • Added comprehensive path traversal protection for alias operations with input validation before file access
  • Introduced package manager abstraction layer (DRY) reducing code duplication across brew/apt/dnf/yum/pacman
  • Enhanced lock mechanism with stale lock detection and proper trap restoration for shell integration
  • Added 4 new security tests for path traversal protection, bringing total test count to 51 passing tests

Reviewed changes

Copilot reviewed 6 out of 8 changed files in this pull request and generated 12 comments.

Show a summary per file
File Description
phpvm.sh Major refactoring with 500+ line additions: error propagation fixes, state management improvements, security hardening (alias validation), package manager abstraction layer, lock mechanism, helper function consolidation (command_exists, ensure_phpvm_dirs, brew_link_php_unversioned, etc.)
tests/03_error_handling.bats Added 4 security tests validating path traversal protection in alias name validation and phpvm alias command
README.md Updated test command reference from deprecated phpvm test to bats tests/
CONTRIBUTING.md Removed outdated built-in test documentation, updated to reference BATS tests only
CLAUDE.md Updated development documentation to reflect BATS-only testing approach and new testing commands
CHANGELOG.md Comprehensive v1.9.0 release notes documenting 17 bug fixes, 11 improvements, 2 additions, and 5 internal changes
.gitignore Added notes files to ignore list (notes.md, FEATURE_CHECKLIST.md, IMPLEMENTATION_GUIDE.md)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +876 to +881
# Symmetrical with install: remove cli+common+fpm packages
run_with_sudo apt-get remove -y \
"php${normalized_version}-cli" \
"php${normalized_version}-common" \
"php${normalized_version}-fpm" 2> /dev/null ||
run_with_sudo apt-get remove -y "$package_name"
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The apt uninstall logic tries to remove three packages but falls back to removing just the main package if that fails. However, this could leave orphaned packages (common/fpm) if only the cli package removal succeeds. The 2> /dev/null suppresses errors, and the || fallback means we can't distinguish between "all three failed" vs "some succeeded". Consider checking each package individually or using apt-get remove without error suppression to provide better feedback about what was actually removed.

Suggested change
# Symmetrical with install: remove cli+common+fpm packages
run_with_sudo apt-get remove -y \
"php${normalized_version}-cli" \
"php${normalized_version}-common" \
"php${normalized_version}-fpm" 2> /dev/null ||
run_with_sudo apt-get remove -y "$package_name"
# Symmetrical with install: remove cli+common+fpm packages explicitly
local remove_failed=false
for pkg in \
"php${normalized_version}-cli" \
"php${normalized_version}-common" \
"php${normalized_version}-fpm"; do
if ! run_with_sudo apt-get remove -y "$pkg"; then
remove_failed=true
fi
done
# Also attempt to remove the aggregate package name if any component failed
if [ "$remove_failed" = true ]; then
run_with_sudo apt-get remove -y "$package_name"
fi

Copilot uses AI. Check for mistakes.

# Check if lock is stale (process no longer exists)
if [ -f "$lockdir/pid" ]; then
lock_pid=$(cat "$lockdir/pid" 2> /dev/null || echo "")
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lock mechanism uses cat to read PID file without sanitization. If the PID file contains unexpected content (e.g., newlines, spaces, malicious shell metacharacters), the kill -0 test could behave unexpectedly. Consider using a safer approach with explicit field extraction or validation of PID format before use.

Suggested change
lock_pid=$(cat "$lockdir/pid" 2> /dev/null || echo "")
# Safely read PID from file and validate it is numeric
if ! IFS= read -r lock_pid 2> /dev/null < "$lockdir/pid"; then
lock_pid=""
fi
# Ensure PID consists only of digits; otherwise ignore it
if ! [[ "$lock_pid" =~ ^[0-9]+$ ]]; then
lock_pid=""
fi

Copilot uses AI. Check for mistakes.
# Returns: 0 if valid, 1 if invalid
is_valid_version_format() {
local version="$1"
echo "$version" | command grep -qE '^[0-9]+\.[0-9]+(\.[0-9]+)?$'
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function is_valid_version_format still uses echo instead of printf for piping to grep. This is inconsistent with the security improvements made elsewhere in the code (lines 635, 648, 688) where printf was specifically adopted to avoid echo interpretation of strings like -n, -e, or backslash escapes. While version strings are unlikely to contain these patterns, consistency is important for maintainability and defense-in-depth.

Suggested change
echo "$version" | command grep -qE '^[0-9]+\.[0-9]+(\.[0-9]+)?$'
printf '%s\n' "$version" | command grep -qE '^[0-9]+\.[0-9]+(\.[0-9]+)?$'

Copilot uses AI. Check for mistakes.
if echo "$version" | command grep -qE '^[0-9]+\.[0-9]+(\.[0-9]+)?$'; then
return "$PHPVM_EXIT_SUCCESS"
if is_valid_version_format "$version"; then
echo "$version" | command awk -F. '{print $1 "." $2}'
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The awk script uses echo piping instead of direct command substitution which adds unnecessary overhead. More importantly, this function still uses echo piping where printf would be safer and more consistent with the codebase's security improvements. Consider replacing with printf '%s\n' "$version" | command awk -F. '...'.

Suggested change
echo "$version" | command awk -F. '{print $1 "." $2}'
printf '%s\n' "$version" | command awk -F. '{print $1 "." $2}'

Copilot uses AI. Check for mistakes.
install_php_yum "$normalized_version" || return $?
;;
pacman)
install_php_pacman "$normalized_version" "$package_name" || return $?
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function signature shows install_php_pacman takes two parameters (version and package_name), but the other install functions (brew, apt, dnf, yum) only take version. This inconsistency in the API design makes the abstraction layer less uniform. Consider either making all install functions accept package_name or having install_php_pacman derive package_name internally like the others do.

Copilot uses AI. Check for mistakes.
if command -v auto_switch_php_version > /dev/null 2>&1; then
auto_switch_php_version 2> /dev/null || true
# Initialize environment when sourced (do not exit on failure)
phpvm_init_if_needed || true
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When sourced, the script calls phpvm_init_if_needed || true to suppress errors. However, this means that if directory creation or system detection fails, the script continues silently without properly initialized state. Commands run later might fail with confusing errors because PHPVM_DIR or PKG_MANAGER are not set. Consider storing the initialization failure state and having commands check it, or at minimum, emit a warning when initialization fails during sourcing.

Copilot uses AI. Check for mistakes.
Comment on lines +418 to +419
if [ -n "$saved_exit_trap" ]; then
eval "$saved_exit_trap"
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The eval usage here is potentially dangerous. While the trap output from trap -p is generally safe, using eval on dynamically stored values could introduce security risks if trap contents are somehow manipulated. Consider using a safer approach without eval, such as reconstructing traps programmatically or validating trap strings before evaluation.

Copilot uses AI. Check for mistakes.
@@ -1445,7 +2265,8 @@ phpvm_list_aliases() {
alias_name=$(basename "$alias_file")
alias_target=$(command cat "$alias_file" 2> /dev/null | command tr -d '[:space:]')
if [ -n "$pattern" ]; then
if echo "$alias_name" | command grep -qi "$pattern"; then
# Use fixed-string literal match to avoid regex interpretation
if echo "$alias_name" | command grep -Fqi "$pattern"; then
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line 2269 uses command grep -Fqi (fixed-string case-insensitive match) but the comment on line 2268 says "fixed-string literal match". The -F flag means fixed string (literal), which is correct, but the -i flag for case-insensitive matching may not be appropriate for exact alias matching. If alias names are case-sensitive (as implied by the validation regex ^[a-zA-Z0-9_-]+$), then this grep should not use -i. Consider whether case-insensitive pattern filtering is the intended behavior.

Suggested change
if echo "$alias_name" | command grep -Fqi "$pattern"; then
if echo "$alias_name" | command grep -Fq "$pattern"; then

Copilot uses AI. Check for mistakes.
Comment on lines +1758 to +1780
if [ -n "$MACOS_MAJOR" ] && [ "$MACOS_MAJOR" -ge 12 ]; then
if [ -d "$HOMEBREW_PREFIX/Cellar/php" ]; then
phpvm_debug "Linking Homebrew php formula as system default (macOS $MACOS_VERSION)..."
brew_link_php_unversioned || {
phpvm_err "Failed to link Homebrew php formula."
return "$PHPVM_EXIT_ERROR"
}
# Only set state AFTER successful switch
set_active_version "system" || return "$PHPVM_EXIT_FILE_ERROR"
update_current_symlink || true
phpvm_echo "Switched to system PHP (Homebrew default)."
return "$PHPVM_EXIT_SUCCESS"
fi

if [ "$version" = "system" ]; then
# Special case for switching to system PHP
phpvm_atomic_write "$PHPVM_ACTIVE_VERSION_FILE" "system" || {
phpvm_err "Failed to write active version file"
return 1
}
# Remove the current symlink since we're using system PHP
rm -f "$PHPVM_CURRENT_SYMLINK"

# Handle different macOS versions
# Apple removed PHP from macOS starting with macOS Monterey 12.0
if [ -n "$MACOS_MAJOR" ] && [ "$MACOS_MAJOR" -ge 12 ]; then
# macOS 12+ doesn't have built-in PHP, use Homebrew
if [ -d "$HOMEBREW_PREFIX/Cellar/php" ]; then
phpvm_debug "Linking Homebrew php formula as system default (macOS $MACOS_VERSION)..."
brew link php --force --overwrite > /dev/null 2>&1 || {
phpvm_err "Failed to link Homebrew php formula."
return 1
}
phpvm_echo "Switched to system PHP (Homebrew default)."
return 0
else
phpvm_warn "No system PHP available on macOS $MACOS_VERSION. Installing Homebrew PHP..."
if brew install php > /dev/null 2>&1; then
phpvm_echo "Installed and switched to system PHP (Homebrew)."
return 0
else
phpvm_err "Failed to install system PHP via Homebrew."
return 1
fi
fi
else
# macOS 11 and earlier may have built-in PHP
if [ -x "/usr/bin/php" ]; then
phpvm_echo "Switched to system PHP (built-in macOS PHP)."
return 0
elif [ -d "$HOMEBREW_PREFIX/Cellar/php" ]; then
phpvm_debug "Linking Homebrew php formula as system default..."
brew link php --force --overwrite > /dev/null 2>&1 || {
phpvm_err "Failed to link Homebrew php formula."
return 1
}
phpvm_echo "Switched to system PHP (Homebrew default)."
return 0
elif command -v php > /dev/null 2>&1; then
phpvm_echo "Switched to system PHP."
return 0
else
phpvm_echo "Switched to system PHP."
phpvm_warn "No system PHP found. You may need to install PHP with 'brew install php' or switch to a specific version."
return 0
fi
phpvm_warn "No system PHP available on macOS $MACOS_VERSION. Installing Homebrew PHP..."
if brew install php > /dev/null 2>&1; then
set_active_version "system" || return "$PHPVM_EXIT_FILE_ERROR"
update_current_symlink || true
phpvm_echo "Installed and switched to system PHP (Homebrew)."
return "$PHPVM_EXIT_SUCCESS"
fi
phpvm_err "Failed to install system PHP via Homebrew."
return "$PHPVM_EXIT_ERROR"
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function returns $PHPVM_EXIT_SUCCESS on line 1769 after successfully linking, but then continues to execute lines 1772-1780 which will also execute if the condition on line 1759 is true but the directory check on line 1759 fails. This creates a logic flow issue where success messages could be followed by failure handling. The return statement on line 1769 should prevent execution of subsequent code blocks, but the structure is confusing. Consider restructuring with else-if blocks for clarity.

Copilot uses AI. Check for mistakes.
Comment on lines +3003 to +3006
if command -v find_phpvmrc > /dev/null 2>&1 && find_phpvmrc > /dev/null 2>&1; then
if command -v auto_switch_php_version > /dev/null 2>&1; then
auto_switch_php_version 2> /dev/null || true
fi
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The auto-switch logic checks command -v find_phpvmrc and command -v auto_switch_php_version even though these functions are defined in the same file that was just sourced. These checks should never fail unless the script is corrupted. This defensive programming is good, but consider whether it's necessary given that PHPVM_FUNCTIONS_LOADED is already set to true on line 2990. If you keep the checks, consider adding error messages if they fail to help debug script corruption issues.

Suggested change
if command -v find_phpvmrc > /dev/null 2>&1 && find_phpvmrc > /dev/null 2>&1; then
if command -v auto_switch_php_version > /dev/null 2>&1; then
auto_switch_php_version 2> /dev/null || true
fi
if command -v find_phpvmrc > /dev/null 2>&1; then
if find_phpvmrc > /dev/null 2>&1; then
if command -v auto_switch_php_version > /dev/null 2>&1; then
auto_switch_php_version 2> /dev/null || true
else
phpvm_err "auto_switch_php_version function not found - phpvm.sh may be corrupted"
fi
fi
else
phpvm_err "find_phpvmrc function not found - phpvm.sh may be corrupted"

Copilot uses AI. Check for mistakes.
@Thavarshan Thavarshan merged commit 28bc64c into main Jan 30, 2026
11 checks passed
@Thavarshan Thavarshan deleted the refactor/package-manager-abstraction branch January 30, 2026 10:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working documentation Improvements or additions to documentation duplicate This issue or pull request already exists enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants