diff --git a/.gitattributes b/.gitattributes index 02a23de3322d..0ec474bf866d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -24,7 +24,6 @@ utils/ export-ignore .php-cs-fixer.user-guide.php export-ignore structarmed-baseline.php export-ignore structarmed.php export-ignore -phpmetrics.json export-ignore phpstan-baseline.php export-ignore phpstan-bootstrap.php export-ignore phpstan.dist.neon export-ignore diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4ad38ee3eadf..cd4aff6cff0a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -23,8 +23,15 @@ Prioritize actionable findings involving: ## Review behavior - Determine the PR base branch and apply the compatibility policy from - `AGENTS.md`. If the base branch cannot be determined, assume `develop` - and state that assumption. + `AGENTS.md`. When it is not visible, read it from the title prefix and + the labels as `AGENTS.md` describes, and otherwise assume `develop` and + state that assumption. +- Before reporting a compatibility break, confirm the symbol exists in the + latest tagged release. A symbol introduced on the base branch since that + release is unreleased and may change freely, as `AGENTS.md` describes. +- Do not report a PHPStan suppression or baseline entry covered by the + exceptions in `AGENTS.md`, or a missing reset of state that + `CIUnitTestCase::tearDown()` already restores. - Do not report formatting-only issues already enforced by PHP-CS-Fixer. - Every finding should identify a concrete failure scenario and the affected code. diff --git a/.github/instructions/tests.instructions.md b/.github/instructions/tests.instructions.md index af2d9a96ba96..63722474b723 100644 --- a/.github/instructions/tests.instructions.md +++ b/.github/instructions/tests.instructions.md @@ -14,6 +14,8 @@ applyTo: "tests/**/*.php" Avoid testing private implementation details. - Restore services, factories, environment variables, superglobals, handlers, locale, time-related state, and other global state modified by a test. + Check `CIUnitTestCase::tearDown()` first: a test need not repeat the + resets it already performs. - Tests must not depend on execution order or state left by another test. - Do not replace a meaningful assertion with a weaker assertion to make a test pass. diff --git a/.github/workflows/deploy-apidocs.yml b/.github/workflows/deploy-apidocs.yml index 8eb59179a946..d34fce914406 100644 --- a/.github/workflows/deploy-apidocs.yml +++ b/.github/workflows/deploy-apidocs.yml @@ -44,7 +44,7 @@ jobs: persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 with: php-version: '8.2' tools: phive diff --git a/.github/workflows/deploy-distributables.yml b/.github/workflows/deploy-distributables.yml index 1eeb86f772cb..38423b4e5229 100644 --- a/.github/workflows/deploy-distributables.yml +++ b/.github/workflows/deploy-distributables.yml @@ -172,7 +172,7 @@ jobs: persist-credentials: false - name: Setup Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: python-version: '3.12' diff --git a/.github/workflows/deploy-userguide-latest.yml b/.github/workflows/deploy-userguide-latest.yml index c943254325d6..65b1fe77b0be 100644 --- a/.github/workflows/deploy-userguide-latest.yml +++ b/.github/workflows/deploy-userguide-latest.yml @@ -30,13 +30,13 @@ jobs: persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 with: php-version: '8.2' coverage: none - name: Setup Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: python-version: '3.12' @@ -59,7 +59,7 @@ jobs: # Create an artifact of the html output - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: HTML Documentation path: user_guide_src/build/html/ diff --git a/.github/workflows/reusable-coveralls.yml b/.github/workflows/reusable-coveralls.yml index cf9c02b22e11..687b2a919071 100644 --- a/.github/workflows/reusable-coveralls.yml +++ b/.github/workflows/reusable-coveralls.yml @@ -22,14 +22,14 @@ jobs: persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 with: php-version: ${{ inputs.php-version }} tools: composer coverage: xdebug - name: Download coverage files - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: path: build/cov @@ -46,10 +46,10 @@ jobs: uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} - key: ${{ github.job }}-php-${{ inputs.php-version }}-${{ hashFiles('**/composer.*') }} + key: composer-php-${{ inputs.php-version }}-${{ hashFiles('**/composer.json') }} restore-keys: | - ${{ github.job }}-php-${{ inputs.php-version }}- - ${{ github.job }}- + composer-php-${{ inputs.php-version }}- + composer- - name: Cache PHPUnit's static analysis cache uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 diff --git a/.github/workflows/reusable-phpunit-test.yml b/.github/workflows/reusable-phpunit-test.yml index b216a71493bb..6c5497b67294 100644 --- a/.github/workflows/reusable-phpunit-test.yml +++ b/.github/workflows/reusable-phpunit-test.yml @@ -175,7 +175,7 @@ jobs: persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 with: php-version: ${{ inputs.php-version }} tools: composer @@ -195,11 +195,10 @@ jobs: uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.setup-env.outputs.COMPOSER_CACHE_FILES_DIR }} - key: ${{ inputs.job-id || github.job }}-php-${{ inputs.php-version }}-db-${{ inputs.db-platform || 'none' }}-${{ hashFiles('**/composer.*') }} + key: composer-php-${{ inputs.php-version }}-${{ hashFiles('**/composer.json') }} restore-keys: | - ${{ inputs.job-id || github.job }}-php-${{ inputs.php-version }}-db-${{ inputs.db-platform || 'none' }}- - ${{ inputs.job-id || github.job }}-php-${{ inputs.php-version }}- - ${{ inputs.job-id || github.job }}- + composer-php-${{ inputs.php-version }}- + composer- - name: Cache PHPUnit's static analysis cache if: ${{ inputs.enable-artifact-upload }} @@ -233,7 +232,7 @@ jobs: - name: Upload coverage results as artifact if: ${{ inputs.enable-artifact-upload }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ steps.setup-env.outputs.ARTIFACT_NAME }} path: build/cov/coverage-${{ steps.setup-env.outputs.ARTIFACT_NAME }}.cov diff --git a/.github/workflows/reusable-serviceless-phpunit-test.yml b/.github/workflows/reusable-serviceless-phpunit-test.yml index 8420d2b8c978..60186d6eaa84 100644 --- a/.github/workflows/reusable-serviceless-phpunit-test.yml +++ b/.github/workflows/reusable-serviceless-phpunit-test.yml @@ -72,7 +72,7 @@ jobs: fetch-depth: 0 - name: Setup PHP - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 with: php-version: ${{ inputs.php-version }} tools: composer @@ -92,10 +92,10 @@ jobs: uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.setup-env.outputs.COMPOSER_CACHE_FILES_DIR }} - key: ${{ inputs.job-id || github.job }}-php-${{ inputs.php-version }}-${{ hashFiles('**/composer.*') }} + key: composer-php-${{ inputs.php-version }}-${{ hashFiles('**/composer.json') }} restore-keys: | - ${{ inputs.job-id || github.job }}-php-${{ inputs.php-version }}- - ${{ inputs.job-id || github.job }}- + composer-php-${{ inputs.php-version }}- + composer- - name: Cache PHPUnit's static analysis cache if: ${{ inputs.enable-artifact-upload }} @@ -127,7 +127,7 @@ jobs: - name: Upload coverage results as artifact if: ${{ inputs.enable-artifact-upload }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ steps.setup-env.outputs.ARTIFACT_NAME }} path: build/cov/coverage-${{ steps.setup-env.outputs.ARTIFACT_NAME }}.cov diff --git a/.github/workflows/run-carson.yml b/.github/workflows/run-carson.yml index a0a160b0930d..a9f88eb7f530 100644 --- a/.github/workflows/run-carson.yml +++ b/.github/workflows/run-carson.yml @@ -32,7 +32,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Run Carson - uses: NexusPHP/carson@cb35604ab330c2e44dba673fcef712c78e75f858 # v1.7.0 + uses: NexusPHP/carson@53c45cf25e2303bc503209f8b5dd9107f27b2e8f # v1.8.0 with: app_id: ${{ secrets.CARSON_APP_ID }} private_key: ${{ secrets.CARSON_PRIVATE_KEY }} diff --git a/.github/workflows/test-autoreview.yml b/.github/workflows/test-autoreview.yml index f06e02619966..26b50e049208 100644 --- a/.github/workflows/test-autoreview.yml +++ b/.github/workflows/test-autoreview.yml @@ -30,79 +30,3 @@ jobs: php-version: '8.2' job-id: auto-review-tests group-name: AutoReview - - composer-normalize-tests: - name: Check normalized composer.json - runs-on: ubuntu-24.04 - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Setup PHP - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 - with: - php-version: '8.2' - - - name: Install dependencies - run: composer update - - - name: Run on root composer.json - id: normalize-root-composer - if: ${{ always() }} - run: | - echo "ROOT_COMPOSER_RESULT<> $GITHUB_OUTPUT - echo "$(composer normalize)" >> $GITHUB_OUTPUT - echo "RCR" >> $GITHUB_OUTPUT - - - name: Run on framework composer.json - id: normalize-framework-composer - if: ${{ always() }} - working-directory: admin/framework - run: | - echo "FRAMEWORK_COMPOSER_RESULT<> $GITHUB_OUTPUT - echo "$(composer normalize)" >> $GITHUB_OUTPUT - echo "FCR" >> $GITHUB_OUTPUT - - - name: Run on starter composer.json - id: normalize-starter-composer - if: ${{ always() }} - working-directory: admin/starter - run: | - echo "STARTER_COMPOSER_RESULT<> $GITHUB_OUTPUT - echo "$(composer normalize)" >> $GITHUB_OUTPUT - echo "SCR" >> $GITHUB_OUTPUT - - - name: Analyse normalization results - run: | - if [[ '${{ steps.normalize-root-composer.conclusion }}' == 'failure' ]]; then - echo 'Normalization of root composer.json encountered a problem.'; - echo 'Please run it locally: `composer normalize`'; - exit 1; - fi - - if [[ ${{ contains(steps.normalize-root-composer.outputs.ROOT_COMPOSER_RESULT, 'Successfully normalized') }} == true ]]; then - echo 'Root composer.json is not yet normalized.'; - exit 1; - fi - - if [[ '${{ steps.normalize-framework-composer.conclusion }}' == 'failure' ]]; then - echo 'Normalization of framework composer.json encountered a problem.'; - echo 'Please run it locally: `composer normalize -d admin/framework`'; - exit 1; - fi - - if [[ ${{ contains(steps.normalize-framework-composer.outputs.FRAMEWORK_COMPOSER_RESULT, 'Successfully normalized') }} == true ]]; then - echo 'Framework composer.json is not yet normalized.'; - exit 1; - fi - - if [[ '${{ steps.normalize-starter-composer.conclusion }}' == 'failure' ]]; then - echo 'Normalization of starter composer.json encountered a problem.'; - echo 'Please run it locally: `composer normalize -d admin/starter`'; - exit 1; - fi - - if [[ ${{ contains(steps.normalize-starter-composer.outputs.STARTER_COMPOSER_RESULT, 'Successfully normalized') }} == true ]]; then - echo 'Starter composer.json is not yet normalized.'; - exit 1; - fi diff --git a/.github/workflows/test-coding-standards.yml b/.github/workflows/test-coding-standards.yml index b02059733b13..274cc5311e2a 100644 --- a/.github/workflows/test-coding-standards.yml +++ b/.github/workflows/test-coding-standards.yml @@ -38,7 +38,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup PHP - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 with: php-version: ${{ matrix.php-version }} extensions: tokenizer @@ -52,10 +52,10 @@ jobs: uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} - key: ${{ runner.os }}-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }} + key: composer-php-${{ matrix.php-version }}-${{ hashFiles('**/composer.json') }} restore-keys: | - ${{ runner.os }}-${{ matrix.php-version }}- - ${{ runner.os }}- + composer-php-${{ matrix.php-version }}- + composer- - name: Install dependencies run: composer update --ansi --no-interaction ${{ matrix.composer-option }} diff --git a/.github/workflows/test-composer-normalize.yml b/.github/workflows/test-composer-normalize.yml new file mode 100644 index 000000000000..164c2424976c --- /dev/null +++ b/.github/workflows/test-composer-normalize.yml @@ -0,0 +1,54 @@ +name: Composer Normalize + +on: + pull_request: + paths: + - '**/composer.json' + - '.github/workflows/test-composer-normalize.yml' + push: + paths: + - '**/composer.json' + - '.github/workflows/test-composer-normalize.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + composer-normalize: + name: Check normalized composer.json + runs-on: ubuntu-24.04 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup PHP + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.2' + coverage: none + + - name: Install dependencies + run: composer update --ansi --no-interaction --working-dir=utils + + - name: Run on root composer.json + if: ${{ !cancelled() }} + run: composer normalize --ansi --dry-run --working-dir=utils "$GITHUB_WORKSPACE/composer.json" + + - name: Run on framework composer.json + if: ${{ !cancelled() }} + run: composer normalize --ansi --dry-run --working-dir=utils "$GITHUB_WORKSPACE/admin/framework/composer.json" + + - name: Run on starter composer.json + if: ${{ !cancelled() }} + run: composer normalize --ansi --dry-run --working-dir=utils "$GITHUB_WORKSPACE/admin/starter/composer.json" + + - name: Run on utils composer.json + if: ${{ !cancelled() }} + run: composer normalize --ansi --dry-run --working-dir=utils diff --git a/.github/workflows/test-phpstan.yml b/.github/workflows/test-phpstan.yml index cc246f871808..d16ab6d98a27 100644 --- a/.github/workflows/test-phpstan.yml +++ b/.github/workflows/test-phpstan.yml @@ -49,7 +49,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup PHP - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 with: php-version: '8.2' extensions: intl @@ -69,8 +69,10 @@ jobs: uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ runner.os }}-composer- + key: composer-php-8.2-${{ hashFiles('**/composer.json') }} + restore-keys: | + composer-php-8.2- + composer- - name: Create PHPStan result cache directory run: mkdir -p build/phpstan @@ -79,8 +81,8 @@ jobs: uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: build/phpstan - key: ${{ runner.os }}-phpstan-${{ github.sha }} - restore-keys: ${{ runner.os }}-phpstan- + key: phpstan-${{ hashFiles('**/*.php', '**/*.neon', 'composer.json') }} + restore-keys: phpstan- - name: Install dependencies run: composer update --ansi --no-interaction diff --git a/.github/workflows/test-psalm.yml b/.github/workflows/test-psalm.yml index 3f385ce858fb..23f2ea365eab 100644 --- a/.github/workflows/test-psalm.yml +++ b/.github/workflows/test-psalm.yml @@ -41,7 +41,7 @@ jobs: persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 with: php-version: ${{ matrix.php-version }} extensions: intl, json, mbstring, xml, mysqli, oci8, pgsql, sqlsrv, sqlite3 @@ -57,8 +57,10 @@ jobs: uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}-${{ hashFiles('**/composer.lock') }} - restore-keys: ${{ runner.os }}-composer- + key: composer-php-${{ matrix.php-version }}-${{ hashFiles('**/composer.json') }} + restore-keys: | + composer-php-${{ matrix.php-version }}- + composer- - name: Create Psalm cache directory run: mkdir -p build/psalm @@ -67,8 +69,8 @@ jobs: uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: build/psalm - key: ${{ runner.os }}-psalm-${{ github.sha }} - restore-keys: ${{ runner.os }}-psalm- + key: psalm-${{ hashFiles('**/*.php', 'psalm*.xml', 'composer.json') }} + restore-keys: psalm- - name: Install dependencies run: | diff --git a/.github/workflows/test-random-execution.yml b/.github/workflows/test-random-execution.yml index e90de6bb031d..a98d50cec3ad 100644 --- a/.github/workflows/test-random-execution.yml +++ b/.github/workflows/test-random-execution.yml @@ -174,7 +174,7 @@ jobs: fetch-depth: 0 - name: Setup PHP ${{ matrix.php-version }} - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 with: php-version: ${{ matrix.php-version }} extensions: gd, curl, iconv, json, mbstring, openssl, sodium @@ -189,9 +189,10 @@ jobs: uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} - key: PHP_${{ matrix.php-version }}-${{ hashFiles('**/composer.*') }} + key: composer-php-${{ matrix.php-version }}-${{ hashFiles('**/composer.json') }} restore-keys: | - PHP_${{ matrix.php-version }}- + composer-php-${{ matrix.php-version }}- + composer- - name: Install project dependencies run: | @@ -230,7 +231,7 @@ jobs: - name: Upload random-test artifacts on failure if: ${{ always() && failure() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: random-tests-${{ matrix.db-platform }}-php${{ matrix.php-version }} path: build/random-tests/ diff --git a/.github/workflows/test-rector.yml b/.github/workflows/test-rector.yml index 2fa9b74c0e02..4e603df49e44 100644 --- a/.github/workflows/test-rector.yml +++ b/.github/workflows/test-rector.yml @@ -56,7 +56,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup PHP - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 with: php-version: ${{ matrix.php-version }} extensions: intl @@ -75,20 +75,22 @@ jobs: uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ runner.os }}-composer- - - - name: Install dependencies - run: composer update --ansi --no-interaction ${{ matrix.composer-option }} + key: composer-php-${{ matrix.php-version }}-${{ hashFiles('**/composer.json') }} + restore-keys: | + composer-php-${{ matrix.php-version }}- + composer- - name: Rector Cache uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: /tmp/rector - key: ${{ runner.os }}-rector-${{ github.run_id }} - restore-keys: ${{ runner.os }}-rector- + key: rector-${{ hashFiles('**/*.php', 'composer.json') }} + restore-keys: rector- - run: mkdir -p /tmp/rector + - name: Install dependencies + run: composer update --ansi --no-interaction ${{ matrix.composer-option }} + - name: Run static analysis run: vendor/bin/rector process --dry-run --no-progress-bar diff --git a/.github/workflows/test-scss.yml b/.github/workflows/test-scss.yml index 0000e700a422..57157785d600 100644 --- a/.github/workflows/test-scss.yml +++ b/.github/workflows/test-scss.yml @@ -36,7 +36,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '24' diff --git a/.github/workflows/test-structarmed.yml b/.github/workflows/test-structarmed.yml index 62a2dbc7d1cb..b064b8213d07 100644 --- a/.github/workflows/test-structarmed.yml +++ b/.github/workflows/test-structarmed.yml @@ -54,7 +54,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup PHP - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 with: php-version: ${{ matrix.php-version }} extensions: intl @@ -72,20 +72,22 @@ jobs: uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ runner.os }}-composer- - - - name: Install dependencies - run: composer update --ansi --no-interaction + key: composer-php-${{ matrix.php-version }}-${{ hashFiles('**/composer.json') }} + restore-keys: | + composer-php-${{ matrix.php-version }}- + composer- - name: Structarmed Cache uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: /tmp/structarmed - key: ${{ runner.os }}-structarmed-${{ github.run_id }} - restore-keys: ${{ runner.os }}-structarmed- + key: structarmed-${{ hashFiles('**/*.php', 'composer.json') }} + restore-keys: structarmed- - run: mkdir -p /tmp/structarmed + - name: Install dependencies + run: composer update --ansi --no-interaction + - name: Run static analysis run: vendor/bin/structarmed analyze diff --git a/.github/workflows/test-userguide.yml b/.github/workflows/test-userguide.yml index 88f316888f02..b7ef79e0170d 100644 --- a/.github/workflows/test-userguide.yml +++ b/.github/workflows/test-userguide.yml @@ -27,7 +27,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: python-version: '3.12' diff --git a/.github/workflows/verify-release.yml b/.github/workflows/verify-release.yml index 0488b76a1a70..842c2407a4c5 100644 --- a/.github/workflows/verify-release.yml +++ b/.github/workflows/verify-release.yml @@ -71,12 +71,12 @@ jobs: steps: - name: Setup PHP - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 with: php-version: '8.2' tools: composer extensions: intl, gd - coverage: none + coverage: xdebug - name: Wait for Packagist to serve the new framework version run: | diff --git a/AGENTS.md b/AGENTS.md index 208568025619..675c9f83e33e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,8 +19,13 @@ existing implementation before introducing a new abstraction or convention. ## Target branch and compatibility policy Compatibility is judged against the pull request's base branch, not the -contributor's source or feature branch. When the base branch is unknown, -apply the stricter `develop` policy. +contributor's source or feature branch. When the base branch is not stated, +take the cues from the title prefix and the labels. A `feat:` prefix and the +`enhancement`, `new feature`, `breaking change`, or `4.x` labels point to the +next minor branch. A `fix:`, `refactor:`, or `docs:` prefix and the `bug` or +`refactor` labels can go either way, since the next minor branch also takes +fixes to its own unreleased code, so look at what the change touches. When no +cue settles it, apply the stricter `develop` policy. ### Base branch `develop` @@ -50,6 +55,16 @@ complete the documented deprecation lifecycle. - Every accepted break needs tests for the new behavior, a minor-version changelog entry, and migration instructions in the upgrading guide. +### Unreleased API + +A symbol first added on the base branch after its latest tagged release is +unreleased. Nothing outside the repository can depend on it yet, so changing, +renaming, or removing it is not a compatibility break: it needs no +deprecation, no upgrading-guide entry, and no changelog line beyond the one +describing the final shape. Check the version tags or the changelog of the +unreleased version before reporting a break on such a symbol. The +compatibility policy applies to what the latest tagged release ships. + ### All base branches - Production code must run on PHP 8.2. Do not require a newer PHP version, @@ -83,7 +98,15 @@ complete the documented deprecation lifecycle. - Add PHPDoc only when it contributes information that native types cannot express. Do not duplicate a parent or interface docblock. - Never suppress a static-analysis error or update a baseline to make a - check pass. + check pass. The accepted exceptions are: + - a false positive, when the usage is judged correct; + - a suppression needed to assert a runtime behavior in a test; + - a call to a function mocked in `system/Test/Mock/MockCommon.php`, such + as `is_cli(false)`, which PHPStan reports as `arguments.count`. + + Regenerate the baseline with `composer phpstan:baseline`, never by editing + the `.neon` files, and add a test file that calls `is_cli()` with an + argument to the `RemoveExtraParametersRector` skip list in `rector.php`. ## FrankenPHP Worker Mode diff --git a/CHANGELOG.md b/CHANGELOG.md index ddac0375df83..366b82d095f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,80 @@ # Changelog +## [v4.7.6](https://github.com/codeigniter4/CodeIgniter4/tree/v4.7.6) (2026-11-01) +[Full Changelog](https://github.com/codeigniter4/CodeIgniter4/compare/v4.7.5...v4.7.6) + +### Security + +* **Cache:** ``FileHandler::deleteMatching()`` now matches the pattern against cache + file names only. Previously, a pattern such as ``../*`` could delete files outside + the cache directory. Patterns can no longer match files in subdirectories of the + cache directory, so those files are no longer deleted. Special characters in the + cache path, such as ``[``, are no longer interpreted as part of the pattern. + See the `Security advisory GHSA-9hf2-g3w9-pfmp `_ + for more information. See :ref:`upgrade-476-cache-delete-matching` for migration + instructions. +* **Model:** Fixed a mass assignment vulnerability in ``replace()``. + The method now discards fields not listed in ``$allowedFields`` when field + protection is enabled, while retaining a supplied primary key. Input emptied + by filtering, or reduced to only its primary key, now throws a ``DataException``. + Previously, applications passing untrusted input to ``replace()`` could write + protected columns. See :doc:`Upgrading from 4.7.5 to 4.7.6 ` + for migration instructions. + See the `Security advisory GHSA-jv74-fx4h-m63x `_ + for more information. +* **Security Helper:** Fixed a path traversal vulnerability in ``sanitize_filename()``. + Backslashes were only removed by a final ``stripslashes()`` call after filtering. + When ``$relativePath`` was ``true``, input such as ``.\./`` became ``../`` on all + platforms. In both modes, repeated backslashes could survive as Windows path + separators, and ``\0`` could become a NUL byte. + Backslashes are now removed during filtering, and ``stripslashes()`` is no longer + called. Forward slashes are still preserved when ``$relativePath`` is ``true``. + The same applies to ``Security::sanitizeFilename()``. + See :doc:`Upgrading from 4.7.5 to 4.7.6 ` and the + `Security advisory GHSA-9j59-qcxw-q7qm `_ + for more information. +* **Database:** Fixed a SQL injection vulnerability in named query bindings. + When a query combined named binds (``:name:``) with a value that was already + escaped and rendered into the SQL (for example a subquery passed to ``where()``, + ``union()``, ``fromSubquery()`` or ``selectSubquery()``, a custom ``where()`` + string built with ``$db->escape()``, the ``WHERE`` binds of ``deleteBatch()``, + or a manually escaped fragment in ``$db->query()``), a placeholder inside that + value was replaced with the bind of the same name, which could break out of the + string literal. Placeholders are now ignored inside quoted strings, quoted + identifiers and SQL comments, and the ``WHERE`` binds of ``deleteBatch()`` are + substituted only after the batch data has been rendered into the query. + Plain ``where()``, ``like()``, ``insert()`` and ``update()`` calls were not affected. + See the `Security advisory GHSA-25rf-99hr-7pmr `_ + and :ref:`upgrade-476-named-binds` for more information. + +### Fixed Bugs + +* fix: preserve JSON request body when CSRF token is not in it by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10620 +* fix: sort generator imports in templates with CRLF line endings by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10622 +* fix: handle invalid values and empty tag names in XMLFormatter by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10626 +* fix: detect X-Forwarded-Proto case-insensitively in isSecure() by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10627 +* fix: refresh REQUEST after URI GET normalization by @rahul05ranjan in https://github.com/codeigniter4/CodeIgniter4/pull/10606 +* fix: widen accepted values in `set_value()`'s `$default` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10641 +* fix: fix array handling in ContentSecurityPolicy::setDefaultSrc() and add addDefaultSrc() by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10635 +* fix: reject missing keys in `CLI::promptByMultipleKeys()` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10654 +* fix: normalize string values and keys in `ContentSecurityPolicy::addOption()` by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10636 +* fix(HTTP): read request body when `post_max_size` is unlimited by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10646 +* fix: apply `ContentSecurityPolicy::reportOnly()` to config sources after construction by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10663 +* fix: stop prompts from asking forever when the input ends by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10658 +* fix: ignore media ranges without a subtype in `Negotiate::media()` by @wleeaf in https://github.com/codeigniter4/CodeIgniter4/pull/10660 +* fix: clear transaction state when reconnect() replaces the socket by @KevinBlakeLopez in https://github.com/codeigniter4/CodeIgniter4/pull/10650 +* fix: do not let wildcards select values excluded with q=0 in `Negotiate` by @wleeaf in https://github.com/codeigniter4/CodeIgniter4/pull/10661 +* fix: keep writes transactional after MySQL deadlock 1213 by @KevinBlakeLopez in https://github.com/codeigniter4/CodeIgniter4/pull/10651 +* fix(Cache): isolate the factories cache files used by the tests by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10647 +* fix: restore the timezone, locale and string format when unserializing `Time` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10682 +* fix: preserve subclass types in route group callbacks by @maniaba in https://github.com/codeigniter4/CodeIgniter4/pull/10644 +* fix(HTTP): remove invalid `CURLOPT_HTTPHEADER` string assignment in `CURLRequest` by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10695 + +### Refactoring + +* refactor: remove `@see` test annotations in source by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10624 +* refactor: fix `null` checks that are always `false` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10670 + ## [v4.7.5](https://github.com/codeigniter4/CodeIgniter4/tree/v4.7.5) (2026-10-09) [Full Changelog](https://github.com/codeigniter4/CodeIgniter4/compare/v4.7.4...v4.7.5) diff --git a/admin/README.md b/admin/README.md index a4048bf59152..aa81a25da794 100644 --- a/admin/README.md +++ b/admin/README.md @@ -54,6 +54,9 @@ are used as part of that process: version references in the framework source, the user guide, and the distribution build script. Usage: `php admin/prepare-release.php 4.x.x` +- **create-release.php** publishes the GitHub release for a new version, + crediting every PR author since the previous release except bots. + Usage: `php admin/create-release.php 4.x.x [--previous=v4.x.w] [--dry-run]` - **create-new-changelog.php** creates the changelog and upgrade guide stubs for the next version and adds them to their index files. Usage: `php admin/create-new-changelog.php ` diff --git a/admin/RELEASE.md b/admin/RELEASE.md index 75f2a0fbf35a..8be9798b3ae8 100644 --- a/admin/RELEASE.md +++ b/admin/RELEASE.md @@ -150,22 +150,15 @@ existing content. * Title: `4.x.x Ready code` * Description: blank * [ ] Merge the PR and wait for all tests. -* [ ] Create a new Release: - * Choose a tag: `v4.x.x` (Create new tag: v4.x.x on publish) - * Target: `master` - * Title: `CodeIgniter 4.x.x` - * Description: - ``` - CodeIgniter 4.x.x release. - - See the changelog: https://github.com/codeigniter4/CodeIgniter4/blob/develop/CHANGELOG.md - - ## New Contributors - * - - **Full Changelog**: https://github.com/codeigniter4/CodeIgniter4/compare/v4.x.w...v4.x.x - ``` - Click the "Generate release notes" button, and get the "New Contributors". +* [ ] Create the new Release: + * Run `php admin/create-release.php 4.x.x --dry-run` and check the output. + * Run `php admin/create-release.php 4.x.x` to publish it. + * The above command does the following: + * Fetch GitHub's auto-generated release notes for `master` since the latest + release (override with `--previous=v4.x.w`) + * Credit every PR author except bots, marking first-time contributors + * Publish the release `CodeIgniter 4.x.x` with the tag `v4.x.x` on `master`, + linking to **CHANGELOG.md** and the full comparison * [ ] Watch the "[Verify Release](https://github.com/codeigniter4/CodeIgniter4/actions/workflows/verify-release.yml)" workflow and make sure it passes. It does the following: * Wait for the "[Deploy Distributable Repos](https://github.com/codeigniter4/CodeIgniter4/actions/workflows/deploy-distributables.yml)" diff --git a/admin/create-release.php b/admin/create-release.php new file mode 100644 index 000000000000..efb08d208404 --- /dev/null +++ b/admin/create-release.php @@ -0,0 +1,148 @@ + [--previous=] [--dry-run] + */ +chdir(__DIR__ . '/..'); + +$args = array_slice($argv, 1); +$options = array_values(array_filter($args, static fn (string $arg): bool => str_starts_with($arg, '--'))); +$params = array_values(array_diff($args, $options)); + +if (count($params) !== 1 || preg_match('/\Av?(\d+\.\d+\.\d+)\z/', $params[0], $matches) !== 1) { + echo sprintf("Usage: php %s [--previous=] [--dry-run]\n", $argv[0]); + echo sprintf("E.g.,: php %s 4.7.6\n", $argv[0]); + + exit(1); +} + +$version = $matches[1]; +$tag = sprintf('v%s', $version); +$dryRun = in_array('--dry-run', $options, true); +$repo = 'codeigniter4/CodeIgniter4'; + +exec(sprintf('gh release view %s --repo %s 2>&1', escapeshellarg($tag), $repo), $output, $exitCode); + +if ($exitCode === 0) { + echo sprintf("The release %s already exists.\n", $tag); + + exit(1); +} + +$previousTag = null; + +foreach ($options as $option) { + if (str_starts_with($option, '--previous=')) { + $previousTag = sprintf('v%s', ltrim(substr($option, strlen('--previous=')), 'v')); + } +} + +if ($previousTag === null) { + $output = []; + exec(sprintf('gh release view --repo %s --json tagName --jq .tagName 2>&1', $repo), $output, $exitCode); + + if ($exitCode !== 0) { + echo "Failed to fetch the latest release from GitHub:\n"; + echo implode("\n", $output) . "\n"; + + exit(1); + } + + $previousTag = trim(implode('', $output)); +} + +$output = []; +exec(sprintf( + 'gh api repos/%s/releases/generate-notes -f tag_name=%s -f target_commitish=master -f previous_tag_name=%s 2>&1', + $repo, + escapeshellarg($tag), + escapeshellarg($previousTag), +), $output, $exitCode); + +if ($exitCode !== 0) { + echo "Failed to fetch the release notes from GitHub:\n"; + echo implode("\n", $output) . "\n"; + + exit(1); +} + +$notes = json_decode(implode("\n", $output), true)['body'] ?? ''; + +$contributors = []; +$firstPulls = []; +$fullChangelog = null; + +foreach (preg_split('/\R/', $notes) as $line) { + if (preg_match('~ by @(\S+) in https://github\.com/\S+/pull/\d+$~', $line, $matches) === 1) { + $contributors[$matches[1]] = true; + + continue; + } + + if (preg_match('~^\* @(\S+) made their first contribution in (\S+)$~', $line, $matches) === 1) { + $firstPulls[$matches[1]] = $matches[2]; + + continue; + } + + if (str_starts_with($line, '**Full Changelog**: ')) { + $fullChangelog = $line; + } +} + +if ($fullChangelog === null) { + echo "Could not find the Full Changelog link in the release notes.\n"; + + exit(1); +} + +$contributors = array_filter( + array_keys($contributors), + static fn (string $login): bool => ! str_ends_with($login, '[bot]'), +); +natcasesort($contributors); + +$credits = array_map( + static fn (string $login): string => isset($firstPulls[$login]) + ? sprintf('* @%s made their first contribution in %s', $login, $firstPulls[$login]) + : sprintf('* @%s', $login), + $contributors, +); + +$body = sprintf("CodeIgniter %s release.\n\n", $version); +$body .= sprintf("See the changelog: https://github.com/%s/blob/develop/CHANGELOG.md\n\n", $repo); + +if ($credits !== []) { + $body .= sprintf("## Contributors\n\n%s\n\n", implode("\n", $credits)); +} + +$body .= $fullChangelog . "\n"; + +if ($dryRun) { + echo $body; + + exit(0); +} + +$notesFile = tempnam(sys_get_temp_dir(), 'ci4-release-'); +file_put_contents($notesFile, $body); + +passthru(sprintf( + 'gh release create %s --repo %s --target master --title %s --notes-file %s', + escapeshellarg($tag), + $repo, + escapeshellarg(sprintf('CodeIgniter %s', $version)), + escapeshellarg($notesFile), +), $exitCode); + +unlink($notesFile); + +exit($exitCode); diff --git a/admin/generate-changelog.php b/admin/generate-changelog.php index f6578cb1372f..521d2744669e 100644 --- a/admin/generate-changelog.php +++ b/admin/generate-changelog.php @@ -140,7 +140,13 @@ exit(0); } -$updated = preg_replace('/\A# Changelog\n\n/', "# Changelog\n\n{$entry}\n", $changelog, 1, $count); +$updated = preg_replace_callback( + '/\A# Changelog\n\n/', + static fn (): string => "# Changelog\n\n{$entry}\n", + $changelog, + 1, + $count, +); if ($count !== 1) { echo 'Could not find the "# Changelog" header in CHANGELOG.md.' . "\n"; diff --git a/admin/update-upgrade-guide.php b/admin/update-upgrade-guide.php index c80f0a75b635..19d42bed64d9 100644 --- a/admin/update-upgrade-guide.php +++ b/admin/update-upgrade-guide.php @@ -79,10 +79,23 @@ function bullets(array $items): string // Paths that are not part of the project space. Keep in sync with the // distribution repos: `tests/` is not used there, see "admin/starter/tests". $excludes = [ - ':!.github/', ':!admin/', ':!changelogs/', ':!contributing/', - ':!system/', ':!tests/', ':!user_guide_src/', ':!utils/', - ':!*.json', ':!*.xml', ':!*.dist', ':!rector.php', ':!structarmed.php', - ':!phpstan*', ':!psalm*', ':!.php-cs-fixer.*', ':!LICENSE', ':!CHANGELOG.md', + ':!.github/', + ':!admin/', + ':!changelogs/', + ':!contributing/', + ':!system/', + ':!tests/', + ':!user_guide_src/', + ':!utils/', + ':!.*', + ':!*.md', + ':!*.json', + ':!*.xml', + ':!phpstan*', + ':!psalm*', + ':!rector.php', + ':!structarmed*', + ':!LICENSE', ]; $command = sprintf( diff --git a/composer.json b/composer.json index 9aa9641b8e6d..f47e5c8ba355 100644 --- a/composer.json +++ b/composer.json @@ -17,19 +17,19 @@ "psr/log": "^3.0" }, "require-dev": { - "boundwize/structarmed": "^0.18", + "boundwize/structarmed": "^0.20", "codeigniter/phpstan-codeigniter": "^2.1", "fakerphp/faker": "^1.24", "kint-php/kint": "^6.1", "mikey179/vfsstream": "^1.6.12", "nexusphp/tachycardia": "^2.0", "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "^2.2.15", + "phpstan/phpstan": "^2.3.4", "phpstan/phpstan-strict-rules": "^2.0", "phpunit/phpcov": "^9.0.2 || ^10.0", "phpunit/phpunit": "^10.5.16 || ^11.2", "predis/predis": "^3.0", - "rector/rector": "2.7.0", + "rector/rector": "2.8.3", "shipmonk/phpstan-baseline-per-identifier": "^2.0" }, "replace": { @@ -114,7 +114,6 @@ "utils/vendor/bin/php-cs-fixer fix --ansi --verbose --diff --config=.php-cs-fixer.tests.php", "utils/vendor/bin/php-cs-fixer fix --ansi --verbose --diff" ], - "metrics": "utils/vendor/bin/phpmetrics --config=phpmetrics.json", "phpstan:baseline": [ "phpstan analyse --ansi --generate-baseline=utils/phpstan-baseline/loader.neon", "split-phpstan-baseline utils/phpstan-baseline/loader.neon" @@ -128,7 +127,6 @@ "analyze": "Run static analysis", "cs": "Check the coding style", "cs-fix": "Fix the coding style", - "metrics": "Run PhpMetrics", "phpstan:baseline": "Run PHPStan then dump all errors to baseline", "phpstan:check": "Run PHPStan with support for identifiers", "test": "Run unit tests" diff --git a/phpdoc.dist.xml b/phpdoc.dist.xml index 0f25b06d5f66..1cb9e6c2c4b3 100644 --- a/phpdoc.dist.xml +++ b/phpdoc.dist.xml @@ -10,7 +10,7 @@ api/build/ api/cache/ - + system diff --git a/phpmetrics.json b/phpmetrics.json deleted file mode 100644 index 3ff72a669193..000000000000 --- a/phpmetrics.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "includes": ["system"], - "excludes": ["ThirdParty"], - "report": { - "html": "build/phpmetrics/" - } -} diff --git a/rector.php b/rector.php index 2698aec9f098..5e8f63feab59 100644 --- a/rector.php +++ b/rector.php @@ -39,7 +39,6 @@ use Rector\TypeDeclaration\Rector\Closure\ClosureReturnTypeRector; use Rector\TypeDeclaration\Rector\Function_\AddFunctionVoidReturnTypeWhereNoReturnRector; use Rector\TypeDeclaration\Rector\Property\TypedPropertyFromAssignsRector; -use Rector\TypeDeclaration\Rector\StmtsAwareInterface\DeclareStrictTypesRector; use Rector\TypeDeclaration\Rector\StmtsAwareInterface\SafeDeclareStrictTypesRector; use Utils\Rector\PassStrictParameterToFunctionParameterRector; use Utils\Rector\RemoveErrorSuppressInTryCatchStmtsRector; @@ -110,15 +109,6 @@ __DIR__ . '/tests/_support/Entity/CustomUser.php', ], - DeclareStrictTypesRector::class => [ - __DIR__ . '/app', - __DIR__ . '/system/CodeIgniter.php', - __DIR__ . '/system/Config/BaseConfig.php', - __DIR__ . '/system/Test/ControllerTestTrait.php', - __DIR__ . '/system/View/Parser.php', - __DIR__ . '/tests/system/Debug/ExceptionsTest.php', - ], - // use mt_rand instead of random_int on purpose on non-cryptographically random RandomFunctionRector::class, @@ -172,7 +162,6 @@ // auto import fully qualified class names ->withImportNames() ->withRules([ - DeclareStrictTypesRector::class, UnderscoreToCamelCaseVariableNameRector::class, PassStrictParameterToFunctionParameterRector::class, RemoveErrorSuppressInTryCatchStmtsRector::class, diff --git a/system/Autoloader/Autoloader.php b/system/Autoloader/Autoloader.php index 92ff2026bb19..3d7068aa2a82 100644 --- a/system/Autoloader/Autoloader.php +++ b/system/Autoloader/Autoloader.php @@ -60,8 +60,6 @@ * // register the autoloader * $loader->register(); * ``` - * - * @see \CodeIgniter\Autoloader\AutoloaderTest */ class Autoloader { diff --git a/system/Autoloader/FileLocator.php b/system/Autoloader/FileLocator.php index 50015b5b7341..845f4353f20a 100644 --- a/system/Autoloader/FileLocator.php +++ b/system/Autoloader/FileLocator.php @@ -16,8 +16,6 @@ /** * Allows loading non-class files in a namespaced manner. * Works with Helpers, Views, etc. - * - * @see \CodeIgniter\Autoloader\FileLocatorTest */ class FileLocator implements FileLocatorInterface { diff --git a/system/Autoloader/FileLocatorCached.php b/system/Autoloader/FileLocatorCached.php index ee1bb1577433..0690a6af7b66 100644 --- a/system/Autoloader/FileLocatorCached.php +++ b/system/Autoloader/FileLocatorCached.php @@ -18,8 +18,6 @@ /** * FileLocator with Cache - * - * @see \CodeIgniter\Autoloader\FileLocatorCachedTest */ final class FileLocatorCached implements FileLocatorInterface { diff --git a/system/BaseModel.php b/system/BaseModel.php index ad79f6f42ec6..8195bbb7a36f 100644 --- a/system/BaseModel.php +++ b/system/BaseModel.php @@ -129,7 +129,7 @@ abstract class BaseModel /** * Determines whether the model should protect field names during - * mass assignment operations such as $this->insert(), $this->update(). + * mass assignment operations such as $this->insert(), $this->update(), $this->replace(). * * When set to `true`, only the fields explicitly defined in the `$allowedFields` * property will be allowed for mass assignment. This helps prevent @@ -142,7 +142,7 @@ abstract class BaseModel /** * An array of field names that are allowed - * to be set by the user in inserts/updates. + * to be set by the user in inserts/updates/replaces. * * @var list */ @@ -1265,6 +1265,8 @@ public function onlyDeleted() * @param bool $returnSQL `true` means SQL is returned, `false` will execute the query. * * @return BaseResult|false|Query|string + * + * @throws DataException */ public function replace(?array $row = null, bool $returnSQL = false) { @@ -1273,7 +1275,14 @@ public function replace(?array $row = null, bool $returnSQL = false) return false; } - $row = (array) $row; + $row = (array) $row; + $hasData = $row !== []; + $row = $this->doProtectFieldsForReplace($row); + + if ($hasData && $row === []) { + throw DataException::forEmptyDataset('replace'); + } + $row = $this->setCreatedField($row, $this->setDate()); $row = $this->setUpdatedField($row, $this->setDate()); @@ -1414,6 +1423,20 @@ protected function doProtectFieldsForInsert(array $row): array return $this->doProtectFields($row); } + /** + * Protects replacement data before generated timestamps are added. + * + * @param row_array $row + * + * @return row_array + * + * @throws DataException + */ + protected function doProtectFieldsForReplace(array $row): array + { + return $this->doProtectFields($row); + } + /** * Sets the timestamp or current timestamp if null value is passed. * diff --git a/system/CLI/CLI.php b/system/CLI/CLI.php index 314b6a934ee1..d04a4e5d1f2f 100644 --- a/system/CLI/CLI.php +++ b/system/CLI/CLI.php @@ -27,8 +27,6 @@ * Some of the code in this class is Windows-specific, and not * possible to test using travis-ci. It has been phpunit-annotated * to prevent messing up code coverage. - * - * @see \CodeIgniter\CLI\CLITest */ class CLI { @@ -263,6 +261,10 @@ public static function prompt(string $field, $options = null, $validation = null if ($validation !== []) { while (! static::validate('"' . trim($field) . '"', $input, $validation)) { + if (static::$io->isAtEnd()) { + throw CLIException::forInputEnded(); + } + $input = static::prompt($field, $options, $validation); } } @@ -312,61 +314,42 @@ public static function promptByMultipleKeys(string $text, array $options): array { CLI::isZeroOptions($options); - $extraOutputDefault = static::color('0', 'green'); - $opts = $options; - unset($opts[0]); + $default = (string) array_key_first($options); + $others = $options; + unset($others[$default]); - if ($opts === []) { - $extraOutput = $extraOutputDefault; - } else { - $optsKey = array_keys($opts); - $extraOutput = '[' . $extraOutputDefault . ', ' . implode(', ', $optsKey) . ']'; - $extraOutput = 'You can specify multiple values separated by commas.' . PHP_EOL . $extraOutput; + $field = static::color($default, 'green'); + + if ($others !== []) { + $field = sprintf( + 'You can specify multiple values separated by commas.%s[%s, %s]', + PHP_EOL, + $field, + implode(', ', array_keys($others)), + ); } CLI::write($text); CLI::printKeysAndValues($options); CLI::newLine(); - $input = static::prompt($extraOutput); - $input = ($input === '') ? '0' : $input; // 0 is default - - // validation while (true) { - $pattern = preg_match_all('/^\d+(,\d+)*$/', trim($input)); - - // separate input by comma and convert all to an int[] - $inputToArray = array_map(static fn ($value): int => (int) $value, explode(',', $input)); - // find max from key of $options - $maxOptions = array_key_last($options); - // find max from input - $maxInput = max($inputToArray); - - // return the prompt again if $input contain(s) non-numeric character, except a comma. - // And if max from $options less than max from input, - // it means user tried to access null value in $options - if ($pattern < 1 || $maxOptions < $maxInput) { - static::error('Please select correctly.'); - CLI::newLine(); - - $input = static::prompt($extraOutput); - $input = ($input === '') ? '0' : $input; - } else { - break; + $input = static::prompt($field); + $input = $input === '' ? $default : $input; + $keys = array_map(intval(...), explode(',', $input)); + + if (preg_match('/^\d+(,\d+)*$/', $input) === 1 && array_diff($keys, array_keys($options)) === []) { + return array_intersect_key($options, array_flip($keys)); } - } - $input = []; + static::error('Please select correctly.'); - foreach ($options as $key => $description) { - foreach ($inputToArray as $inputKey) { - if ($key === $inputKey) { - $input[$key] = $description; - } + if (static::$io->isAtEnd()) { + throw CLIException::forInputEnded(); } - } - return $input; + CLI::newLine(); + } } // -------------------------------------------------------------------- diff --git a/system/CLI/Console.php b/system/CLI/Console.php index 82c31d60f7c5..001b2bc3cf87 100644 --- a/system/CLI/Console.php +++ b/system/CLI/Console.php @@ -20,8 +20,6 @@ /** * Console - * - * @see \CodeIgniter\CLI\ConsoleTest */ class Console { diff --git a/system/CLI/Exceptions/CLIException.php b/system/CLI/Exceptions/CLIException.php index 304b2ea4f444..cccd96b7f9b6 100644 --- a/system/CLI/Exceptions/CLIException.php +++ b/system/CLI/Exceptions/CLIException.php @@ -16,9 +16,6 @@ use CodeIgniter\Exceptions\DebugTraceableTrait; use CodeIgniter\Exceptions\RuntimeException; -/** - * CLIException - */ class CLIException extends RuntimeException { use DebugTraceableTrait; @@ -33,4 +30,12 @@ public static function forInvalidColor(string $type, string $color) { return new static(lang('CLI.invalidColor', [$type, $color])); } + + /** + * Thrown when a prompt must ask again but the input has ended. + */ + public static function forInputEnded(): static + { + return new static(lang('CLI.inputEnded')); + } } diff --git a/system/CLI/GeneratorTrait.php b/system/CLI/GeneratorTrait.php index 45e72c7bda71..c4b439cdc563 100644 --- a/system/CLI/GeneratorTrait.php +++ b/system/CLI/GeneratorTrait.php @@ -399,15 +399,16 @@ protected function buildContent(string $class): string if ( $this->sortImports && preg_match( - '/(?P(?:^use [^;]+;$\n?)+)/m', + '/(?P(?:^use [^;]+;\r?$\n?)+)/m', $template, $match, ) ) { - $imports = explode("\n", trim($match['imports'])); + $eol = str_contains($match['imports'], "\r\n") ? "\r\n" : "\n"; + $imports = explode("\n", str_replace("\r\n", "\n", trim($match['imports']))); sort($imports); - return str_replace(trim($match['imports']), implode("\n", $imports), $template); + return str_replace(trim($match['imports']), implode($eol, $imports), $template); } return $template; diff --git a/system/CLI/InputOutput.php b/system/CLI/InputOutput.php index 893b98a24595..30cd67adc0d4 100644 --- a/system/CLI/InputOutput.php +++ b/system/CLI/InputOutput.php @@ -23,6 +23,8 @@ class InputOutput */ private readonly bool $readlineSupport; + private bool $atEnd = false; + public function __construct() { // Readline is an extension for PHP that makes interactivity with PHP @@ -51,7 +53,8 @@ public function input(?string $prefix = null): string self::fwrite(STDOUT, $prefix ?? ''); } - $input = readline($prompt); + $input = readline($prompt); + $this->atEnd = $input === false && (! defined('STDIN') || ! stream_isatty(STDIN)); return $input === false ? '' : $input; // @codeCoverageIgnoreEnd @@ -60,7 +63,9 @@ public function input(?string $prefix = null): string // self:: skips MockInputOutput's fwrite override, whose filter bookkeeping must not nest inside input(). self::fwrite(STDOUT, $prefix ?? ''); - $input = fgets(fopen('php://stdin', 'rb')); + $stdin = fopen('php://stdin', 'rb'); + $input = fgets($stdin); + $this->atEnd = $input === false && ! stream_isatty($stdin); if ($input === false) { return ''; @@ -69,6 +74,16 @@ public function input(?string $prefix = null): string return $input; } + /** + * Whether the last `input()` found the end of an input other than a terminal, + * so that no later read can bring an answer. At a terminal, `Ctrl+D` ends only + * the current read, and `input()` returns an empty string as for an empty line. + */ + public function isAtEnd(): bool + { + return $this->atEnd; + } + /** * While the library is intended for use on CLI commands, * commands can be called from controllers and elsewhere diff --git a/system/Cache/CacheFactory.php b/system/Cache/CacheFactory.php index b0347ffa2a93..d161c23f3987 100644 --- a/system/Cache/CacheFactory.php +++ b/system/Cache/CacheFactory.php @@ -20,8 +20,6 @@ /** * A factory for loading the desired - * - * @see \CodeIgniter\Cache\CacheFactoryTest */ class CacheFactory { diff --git a/system/Cache/FactoriesCache/FileVarExportHandler.php b/system/Cache/FactoriesCache/FileVarExportHandler.php index 241399a12035..d1363707667d 100644 --- a/system/Cache/FactoriesCache/FileVarExportHandler.php +++ b/system/Cache/FactoriesCache/FileVarExportHandler.php @@ -13,9 +13,18 @@ namespace CodeIgniter\Cache\FactoriesCache; -final class FileVarExportHandler +final readonly class FileVarExportHandler { - private string $path = WRITEPATH . 'cache'; + private string $path; + + /** + * @param string|null $path The directory where the cache files are stored. + * Defaults to the "writable/cache" directory. + */ + public function __construct(?string $path = null) + { + $this->path = rtrim($path ?? WRITEPATH . 'cache', '/'); + } public function save(string $key, mixed $val): void { diff --git a/system/Cache/Handlers/ApcuHandler.php b/system/Cache/Handlers/ApcuHandler.php index ef0f51c50dc7..ef82d68afe03 100644 --- a/system/Cache/Handlers/ApcuHandler.php +++ b/system/Cache/Handlers/ApcuHandler.php @@ -20,8 +20,6 @@ /** * APCu cache handler - * - * @see \CodeIgniter\Cache\Handlers\ApcuHandlerTest */ class ApcuHandler extends BaseHandler { diff --git a/system/Cache/Handlers/BaseHandler.php b/system/Cache/Handlers/BaseHandler.php index 7cf048c6a92b..9f8c22a1964d 100644 --- a/system/Cache/Handlers/BaseHandler.php +++ b/system/Cache/Handlers/BaseHandler.php @@ -20,8 +20,6 @@ /** * Base class for cache handling - * - * @see \CodeIgniter\Cache\Handlers\BaseHandlerTest */ abstract class BaseHandler implements CacheInterface { diff --git a/system/Cache/Handlers/DummyHandler.php b/system/Cache/Handlers/DummyHandler.php index a30475965d6f..0a4d1d3aad57 100644 --- a/system/Cache/Handlers/DummyHandler.php +++ b/system/Cache/Handlers/DummyHandler.php @@ -17,8 +17,6 @@ /** * Dummy cache handler - * - * @see \CodeIgniter\Cache\Handlers\DummyHandlerTest */ class DummyHandler extends BaseHandler { diff --git a/system/Cache/Handlers/FileHandler.php b/system/Cache/Handlers/FileHandler.php index adc31c8f447c..9c00998c1f85 100644 --- a/system/Cache/Handlers/FileHandler.php +++ b/system/Cache/Handlers/FileHandler.php @@ -16,12 +16,12 @@ use CodeIgniter\Cache\Exceptions\CacheException; use CodeIgniter\I18n\Time; use Config\Cache; +use FilesystemIterator; use Throwable; +use UnexpectedValueException; /** * File system cache handler - * - * @see \CodeIgniter\Cache\Handlers\FileHandlerTest */ class FileHandler extends BaseHandler { @@ -119,10 +119,25 @@ public function delete(string $key): bool public function deleteMatching(string $pattern): int { + // The pattern is matched against file names only and never becomes part + // of a path, so it cannot reach files outside the cache directory. + // Passing the cache path to glob() would also interpret special + // characters in the path (e.g. "[") as part of the pattern. + try { + $iterator = new FilesystemIterator($this->path, FilesystemIterator::SKIP_DOTS); + } catch (UnexpectedValueException) { + return 0; + } + $deleted = 0; - foreach (glob($this->path . $pattern, GLOB_NOSORT) as $filename) { - if (is_file($filename) && @unlink($filename)) { + foreach ($iterator as $file) { + // FNM_PERIOD keeps glob() semantics: wildcards don't match a leading dot. + if (! fnmatch($pattern, $file->getFilename(), FNM_PERIOD)) { + continue; + } + + if ($file->isFile() && @unlink($file->getPathname())) { $deleted++; } } diff --git a/system/Cache/Handlers/MemcachedHandler.php b/system/Cache/Handlers/MemcachedHandler.php index 3633dc8e40da..7a4316ba5468 100644 --- a/system/Cache/Handlers/MemcachedHandler.php +++ b/system/Cache/Handlers/MemcachedHandler.php @@ -23,8 +23,6 @@ /** * Mamcached cache handler - * - * @see \CodeIgniter\Cache\Handlers\MemcachedHandlerTest */ class MemcachedHandler extends BaseHandler { diff --git a/system/Cache/Handlers/PredisHandler.php b/system/Cache/Handlers/PredisHandler.php index c868f34550e9..79e7ae98f916 100644 --- a/system/Cache/Handlers/PredisHandler.php +++ b/system/Cache/Handlers/PredisHandler.php @@ -23,8 +23,6 @@ /** * Predis cache handler - * - * @see \CodeIgniter\Cache\Handlers\PredisHandlerTest */ class PredisHandler extends BaseHandler { diff --git a/system/Cache/Handlers/RedisHandler.php b/system/Cache/Handlers/RedisHandler.php index 05cae32da440..ccbb29bcd032 100644 --- a/system/Cache/Handlers/RedisHandler.php +++ b/system/Cache/Handlers/RedisHandler.php @@ -21,8 +21,6 @@ /** * Redis cache handler - * - * @see \CodeIgniter\Cache\Handlers\RedisHandlerTest */ class RedisHandler extends BaseHandler { diff --git a/system/Cache/ResponseCache.php b/system/Cache/ResponseCache.php index 7f0ea9e1da94..c056c3a2e454 100644 --- a/system/Cache/ResponseCache.php +++ b/system/Cache/ResponseCache.php @@ -22,8 +22,6 @@ /** * Web Page Caching - * - * @see \CodeIgniter\Cache\ResponseCacheTest */ final class ResponseCache { diff --git a/system/CodeIgniter.php b/system/CodeIgniter.php index c3725f2388ad..03b4fca3b05c 100644 --- a/system/CodeIgniter.php +++ b/system/CodeIgniter.php @@ -47,15 +47,13 @@ * This class is the core of the framework, and will analyse the * request, route it to a controller, and send back the response. * Of course, there are variations to that flow, but this is the brains. - * - * @see \CodeIgniter\CodeIgniterTest */ class CodeIgniter { /** * The current version of CodeIgniter Framework */ - public const CI_VERSION = '4.7.5'; + public const CI_VERSION = '4.7.6'; /** * App startup time. diff --git a/system/Commands/Database/MigrateStatus.php b/system/Commands/Database/MigrateStatus.php index 7307305307c1..e7f841808fe3 100644 --- a/system/Commands/Database/MigrateStatus.php +++ b/system/Commands/Database/MigrateStatus.php @@ -18,8 +18,6 @@ /** * Displays a list of all migrations and whether they've been run or not. - * - * @see \CodeIgniter\Commands\Database\MigrateStatusTest */ class MigrateStatus extends BaseCommand { diff --git a/system/Commands/Database/ShowTableInfo.php b/system/Commands/Database/ShowTableInfo.php index d05159b1b10a..f071076fcc58 100644 --- a/system/Commands/Database/ShowTableInfo.php +++ b/system/Commands/Database/ShowTableInfo.php @@ -22,8 +22,6 @@ /** * Get table data if it exists in the database. - * - * @see \CodeIgniter\Commands\Database\ShowTableInfoTest */ class ShowTableInfo extends BaseCommand { diff --git a/system/Commands/Translation/LocalizationFinder.php b/system/Commands/Translation/LocalizationFinder.php index 8fed993a6ecc..6bd44d9eaa3b 100644 --- a/system/Commands/Translation/LocalizationFinder.php +++ b/system/Commands/Translation/LocalizationFinder.php @@ -22,9 +22,6 @@ use RecursiveIteratorIterator; use SplFileInfo; -/** - * @see \CodeIgniter\Commands\Translation\LocalizationFinderTest - */ class LocalizationFinder extends BaseCommand { protected $group = 'Translation'; diff --git a/system/Commands/Translation/LocalizationSync.php b/system/Commands/Translation/LocalizationSync.php index f54df45c364c..9e505db01619 100644 --- a/system/Commands/Translation/LocalizationSync.php +++ b/system/Commands/Translation/LocalizationSync.php @@ -24,9 +24,6 @@ use RecursiveIteratorIterator; use SplFileInfo; -/** - * @see \CodeIgniter\Commands\Translation\LocalizationSyncTest - */ class LocalizationSync extends BaseCommand { protected $group = 'Translation'; diff --git a/system/Commands/Utilities/ConfigCheck.php b/system/Commands/Utilities/ConfigCheck.php index 4583c67dae87..a99daa19c2b4 100644 --- a/system/Commands/Utilities/ConfigCheck.php +++ b/system/Commands/Utilities/ConfigCheck.php @@ -22,8 +22,6 @@ /** * Check the Config values. - * - * @see \CodeIgniter\Commands\Utilities\ConfigCheckTest */ final class ConfigCheck extends BaseCommand { diff --git a/system/Commands/Utilities/Namespaces.php b/system/Commands/Utilities/Namespaces.php index 51bc045759ff..08e68d4f9891 100644 --- a/system/Commands/Utilities/Namespaces.php +++ b/system/Commands/Utilities/Namespaces.php @@ -21,8 +21,6 @@ * Lists namespaces set in Config\Autoload with their * full server path. Helps you to verify that you have * the namespaces setup correctly. - * - * @see \CodeIgniter\Commands\Utilities\NamespacesTest */ class Namespaces extends BaseCommand { diff --git a/system/Commands/Utilities/Routes/AutoRouteCollector.php b/system/Commands/Utilities/Routes/AutoRouteCollector.php index 3c8eb44279c7..8c812108aa3d 100644 --- a/system/Commands/Utilities/Routes/AutoRouteCollector.php +++ b/system/Commands/Utilities/Routes/AutoRouteCollector.php @@ -15,8 +15,6 @@ /** * Collects data for auto route listing. - * - * @see \CodeIgniter\Commands\Utilities\Routes\AutoRouteCollectorTest */ final readonly class AutoRouteCollector { diff --git a/system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollector.php b/system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollector.php index a19bd5f24188..3e28f7a4ebec 100644 --- a/system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollector.php +++ b/system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollector.php @@ -18,8 +18,6 @@ /** * Collects data for Auto Routing Improved. - * - * @see \CodeIgniter\Commands\Utilities\Routes\AutoRouterImproved\AutoRouteCollectorTest */ final readonly class AutoRouteCollector { diff --git a/system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReader.php b/system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReader.php index 0863388b8af5..d81ba0e8aeb0 100644 --- a/system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReader.php +++ b/system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReader.php @@ -19,8 +19,6 @@ /** * Reads a controller and returns a list of auto route listing. - * - * @see \CodeIgniter\Commands\Utilities\Routes\AutoRouterImproved\ControllerMethodReaderTest */ final readonly class ControllerMethodReader { diff --git a/system/Commands/Utilities/Routes/ControllerFinder.php b/system/Commands/Utilities/Routes/ControllerFinder.php index 103a7846ab52..1d91cd3458bb 100644 --- a/system/Commands/Utilities/Routes/ControllerFinder.php +++ b/system/Commands/Utilities/Routes/ControllerFinder.php @@ -17,8 +17,6 @@ /** * Finds all controllers in a namespace for auto route listing. - * - * @see \CodeIgniter\Commands\Utilities\Routes\ControllerFinderTest */ final readonly class ControllerFinder { diff --git a/system/Commands/Utilities/Routes/ControllerMethodReader.php b/system/Commands/Utilities/Routes/ControllerMethodReader.php index aa5cddaf48d1..79f293a41679 100644 --- a/system/Commands/Utilities/Routes/ControllerMethodReader.php +++ b/system/Commands/Utilities/Routes/ControllerMethodReader.php @@ -18,8 +18,6 @@ /** * Reads a controller and returns a list of auto route listing. - * - * @see \CodeIgniter\Commands\Utilities\Routes\ControllerMethodReaderTest */ final readonly class ControllerMethodReader { diff --git a/system/Commands/Utilities/Routes/FilterCollector.php b/system/Commands/Utilities/Routes/FilterCollector.php index ec1219debe1a..ea2ed2d7ddde 100644 --- a/system/Commands/Utilities/Routes/FilterCollector.php +++ b/system/Commands/Utilities/Routes/FilterCollector.php @@ -21,8 +21,6 @@ /** * Collects filters for a route. - * - * @see \CodeIgniter\Commands\Utilities\Routes\FilterCollectorTest */ final readonly class FilterCollector { diff --git a/system/Commands/Utilities/Routes/FilterFinder.php b/system/Commands/Utilities/Routes/FilterFinder.php index 6477e1d13300..89aad6584745 100644 --- a/system/Commands/Utilities/Routes/FilterFinder.php +++ b/system/Commands/Utilities/Routes/FilterFinder.php @@ -22,8 +22,6 @@ /** * Finds filters. - * - * @see \CodeIgniter\Commands\Utilities\Routes\FilterFinderTest */ final readonly class FilterFinder { diff --git a/system/Commands/Utilities/Routes/SampleURIGenerator.php b/system/Commands/Utilities/Routes/SampleURIGenerator.php index a0e5f09bc2cb..1d6eba31f416 100644 --- a/system/Commands/Utilities/Routes/SampleURIGenerator.php +++ b/system/Commands/Utilities/Routes/SampleURIGenerator.php @@ -18,8 +18,6 @@ /** * Generate a sample URI path from route key regex. - * - * @see \CodeIgniter\Commands\Utilities\Routes\SampleURIGeneratorTest */ final class SampleURIGenerator { diff --git a/system/Config/BaseConfig.php b/system/Config/BaseConfig.php index 208797ce6d1c..7b7d43c27fdf 100644 --- a/system/Config/BaseConfig.php +++ b/system/Config/BaseConfig.php @@ -29,7 +29,6 @@ * These can be set within the .env file. * * @phpstan-consistent-constructor - * @see \CodeIgniter\Config\BaseConfigTest */ class BaseConfig { diff --git a/system/Config/DotEnv.php b/system/Config/DotEnv.php index a2f15e7c4850..ef02b555f9d0 100644 --- a/system/Config/DotEnv.php +++ b/system/Config/DotEnv.php @@ -17,8 +17,6 @@ /** * Environment-specific configuration - * - * @see \CodeIgniter\Config\DotEnvTest */ class DotEnv { diff --git a/system/Config/Factories.php b/system/Config/Factories.php index 1adbafc48e7b..5831862be8f6 100644 --- a/system/Config/Factories.php +++ b/system/Config/Factories.php @@ -29,7 +29,6 @@ * @method static object|null cells(string $alias, array $options = []) * @method static BaseConfig|null config(...$arguments) * @method static Model|null models(string $alias, array $options = [], ?ConnectionInterface &$conn = null) - * @see \CodeIgniter\Config\FactoriesTest */ final class Factories { diff --git a/system/Config/Services.php b/system/Config/Services.php index f5a11ac19a80..9c30ee4677c4 100644 --- a/system/Config/Services.php +++ b/system/Config/Services.php @@ -108,7 +108,6 @@ * * @see http://blog.ircmaxell.com/2015/11/simple-easy-risk-and-change.html * @see http://www.infoq.com/presentations/Simple-Made-Easy - * @see \CodeIgniter\Config\ServicesTest */ class Services extends BaseService { diff --git a/system/Controller.php b/system/Controller.php index 2fdb5d5a59a6..6a2f645ab951 100644 --- a/system/Controller.php +++ b/system/Controller.php @@ -24,9 +24,6 @@ use Config\Validation; use Psr\Log\LoggerInterface; -/** - * @see \CodeIgniter\ControllerTest - */ class Controller { /** diff --git a/system/Cookie/Cookie.php b/system/Cookie/Cookie.php index da95f18805d4..9c253530311f 100644 --- a/system/Cookie/Cookie.php +++ b/system/Cookie/Cookie.php @@ -40,7 +40,6 @@ * ``` * * @template-implements ArrayAccess - * @see \CodeIgniter\Cookie\CookieTest */ class Cookie implements ArrayAccess, CloneableCookieInterface { diff --git a/system/Cookie/CookieStore.php b/system/Cookie/CookieStore.php index ece407e98d80..aaea3d1f0301 100644 --- a/system/Cookie/CookieStore.php +++ b/system/Cookie/CookieStore.php @@ -23,7 +23,6 @@ * The CookieStore object represents an immutable collection of `Cookie` value objects. * * @implements IteratorAggregate - * @see \CodeIgniter\Cookie\CookieStoreTest */ class CookieStore implements Countable, IteratorAggregate { diff --git a/system/DataConverter/DataConverter.php b/system/DataConverter/DataConverter.php index a1b5db4f0ef9..123e1eefe72b 100644 --- a/system/DataConverter/DataConverter.php +++ b/system/DataConverter/DataConverter.php @@ -21,8 +21,6 @@ * PHP data <==> DataSource data converter * * @template TEntity of object - * - * @see \CodeIgniter\DataConverter\DataConverterTest */ final readonly class DataConverter { diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index fe2c1c371816..08a9ef7dc35b 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -2959,9 +2959,6 @@ protected function _deleteBatch(string $table, array $keys, array $values): stri ), ); - // convert binds in where - $this->convertWhereBindsForBatch(); - $sql .= ' ' . $this->compileWhereHaving('QBWhere'); $this->QBOptions['sql'] = trim($sql); @@ -2983,35 +2980,50 @@ protected function _deleteBatch(string $table, array $keys, array $values): stri ) . "\n"; } - return str_replace('{:_table_:}', $data, $sql); + return $this->compileBatchBinds(str_replace('{:_table_:}', $data, $sql)); } /** - * Escapes and substitutes the WHERE binds into the QBWhere conditions - * for batch delete queries. + * Escapes and substitutes the binds into a compiled batch query. * - * The bound values respect their escape flag and are escaped the same way - * as a regular query (see Query::matchNamedBinds()), instead of being - * injected into the SQL as raw, unescaped values. + * This must run after the batch data has been rendered into the SQL, so + * that a bound value containing `{:_table_:}` is never mixed with the + * batch data. The bound values are substituted by Query, the same way as + * in a regular query (see Query::matchNamedBinds()), so they respect their + * escape flag and placeholders inside the already escaped batch data are + * left untouched. * * @used-by _deleteBatch() */ - protected function convertWhereBindsForBatch(): void + protected function compileBatchBinds(string $sql): string { - $replacers = []; + if ($this->binds === []) { + return $sql; + } - foreach ($this->binds as $field => $bind) { - $escapedValue = $bind[1] ? $this->db->escape($bind[0]) : $bind[0]; + return (new Query($this->db)) + ->setQuery($sql, $this->binds, false) + ->getQuery(); + } - if (is_array($bind[0])) { - $escapedValue = '(' . implode(',', $escapedValue) . ')'; - } + /** + * Escapes and substitutes the WHERE binds into the QBWhere conditions + * for batch delete queries. + * + * @deprecated 4.7.6 No longer used. The binds are substituted into the + * compiled batch query by compileBatchBinds() instead. + */ + protected function convertWhereBindsForBatch(): void + { + foreach ($this->QBWhere as $key => $where) { + $condition = $where['condition']; - $replacers[':' . $field . ':'] = (string) $escapedValue; - } + $sql = (new Query($this->db)) + ->setQuery((string) $condition, $this->binds, false) + ->getQuery(); - foreach ($this->QBWhere as $key => $where) { - $this->QBWhere[$key]['condition'] = strtr($where['condition'], $replacers); + // Keep RawSql conditions as RawSql, as they are compiled differently. + $this->QBWhere[$key]['condition'] = $condition instanceof RawSql ? $condition->with($sql) : $sql; } } diff --git a/system/Database/BaseConnection.php b/system/Database/BaseConnection.php index 1db7618e2971..d025453f74ba 100644 --- a/system/Database/BaseConnection.php +++ b/system/Database/BaseConnection.php @@ -59,7 +59,6 @@ * @template TResult * * @implements ConnectionInterface - * @see \CodeIgniter\Database\BaseConnectionTest */ abstract class BaseConnection implements ConnectionInterface { @@ -626,16 +625,40 @@ public function close() * Keep or establish the connection if no queries have been sent for * a length of time exceeding the server's idle timeout. * + * A replaced socket is not in the transaction that was open on the old + * one. The server has already rolled that work back. Clear the depth and + * mark the transaction failed before opening the new connection, so a + * failed reconnect cannot still report success. + * * @return void */ public function reconnect() { if ($this->ping() === false) { + $lostTransaction = $this->transDepth > 0; + $this->close(); + + if ($lostTransaction) { + $this->abandonLostTransaction(); + } + $this->initialize(); } } + /** + * The socket that owned the transaction is already closed. + * + * Drivers that keep transaction state in PHP, rather than only on the + * server, must override this. + */ + protected function abandonLostTransaction(): void + { + $this->transDepth = 0; + $this->transStatus = false; + } + /** * Platform dependent way method for closing the connection. * diff --git a/system/Database/Config.php b/system/Database/Config.php index 250af22acd62..79b8bcaa6447 100644 --- a/system/Database/Config.php +++ b/system/Database/Config.php @@ -17,9 +17,6 @@ use CodeIgniter\Exceptions\InvalidArgumentException; use Config\Database as DbConfig; -/** - * @see \CodeIgniter\Database\ConfigTest - */ class Config extends BaseConfig { /** diff --git a/system/Database/MySQLi/Connection.php b/system/Database/MySQLi/Connection.php index 10e56e944dd4..8d216e6ed142 100644 --- a/system/Database/MySQLi/Connection.php +++ b/system/Database/MySQLi/Connection.php @@ -91,6 +91,16 @@ class Connection extends BaseConnection */ public $foundRows = false; + /** + * InnoDB errno 1213 ended this transaction. + */ + private bool $transServerRolledBack = false; + + /** + * The next statement must BEGIN before it runs. + */ + private bool $transNeedsServerRestart = false; + /** * Connect to the database. * @@ -291,6 +301,18 @@ protected function execute(string $sql) } try { + // 1213 ended the server transaction. BEGIN again so this statement + // does not autocommit. A failed BEGIN stays pending. 1205 is unchanged: + // innodb_rollback_on_timeout decides that case. + if ($this->connID instanceof mysqli && $this->serverTransactionRestartPending()) { + // Exception reporting off: a failed BEGIN returns false. + if (! $this->connID->begin_transaction()) { + return false; + } + + $this->transNeedsServerRestart = false; + } + return $this->connID->query($this->prepQuery($sql), $this->resultMode); } catch (mysqli_sql_exception $e) { log_message('error', "{message}\nin {exFile} on line {exLine}.\n{trace}", [ @@ -300,6 +322,10 @@ protected function execute(string $sql) 'trace' => render_backtrace($e->getTrace()), ]); + if ($e->getCode() === 1213 && $this->transDepth > 0) { + $this->markServerRolledBackTransaction(); + } + if ($this->DBDebug) { throw new DatabaseException($e->getMessage(), $e->getCode(), $e); } @@ -574,16 +600,53 @@ public function insertID(): int protected function _transBegin(): bool { - return $this->connID->begin_transaction(); + if (! $this->connID->begin_transaction()) { + return false; + } + + $this->clearDeadlockTransactionState(); + + return true; } protected function _transCommit(): bool { + // mysqli::commit() returns true after 1213. End the transaction + // and report failure instead. + if ($this->transServerRolledBack) { + $this->transRollback(); + + return false; + } + return $this->connID->commit(); } protected function _transRollback(): bool { - return $this->connID->rollback(); + if (! $this->connID->rollback()) { + return false; + } + + $this->clearDeadlockTransactionState(); + + return true; + } + + private function markServerRolledBackTransaction(): void + { + $this->transServerRolledBack = true; + $this->transNeedsServerRestart = true; + } + + private function serverTransactionRestartPending(): bool + { + return $this->transNeedsServerRestart && $this->transDepth > 0; + } + + private function clearDeadlockTransactionState(): void + { + $this->transServerRolledBack = false; + $this->transNeedsServerRestart = false; } } diff --git a/system/Database/OCI8/Builder.php b/system/Database/OCI8/Builder.php index abdaf10ae4a1..66800836abd2 100644 --- a/system/Database/OCI8/Builder.php +++ b/system/Database/OCI8/Builder.php @@ -459,9 +459,6 @@ protected function _deleteBatch(string $table, array $keys, array $values): stri ), ); - // convert binds in where - $this->convertWhereBindsForBatch(); - $sql .= ' ' . str_replace( 'WHERE ', 'AND ', @@ -494,7 +491,7 @@ protected function _deleteBatch(string $table, array $keys, array $values): stri ) . " FROM DUAL\n"; } - return str_replace('{:_table_:}', $data, $sql); + return $this->compileBatchBinds(str_replace('{:_table_:}', $data, $sql)); } /** diff --git a/system/Database/OCI8/Connection.php b/system/Database/OCI8/Connection.php index 0d700ef095cf..4167eeefa9fd 100644 --- a/system/Database/OCI8/Connection.php +++ b/system/Database/OCI8/Connection.php @@ -722,6 +722,17 @@ protected function _transRollback(): bool return oci_rollback($this->connID); } + /** + * Restore OCI_COMMIT_ON_SUCCESS. close() does not, and + * OCI_NO_AUTO_COMMIT would leave later writes uncommitted. + */ + protected function abandonLostTransaction(): void + { + $this->commitMode = OCI_COMMIT_ON_SUCCESS; + + parent::abandonLostTransaction(); + } + /** * Returns the name of the current database being used. */ diff --git a/system/Database/Postgre/Builder.php b/system/Database/Postgre/Builder.php index 5367ae2a0fc3..103172fad498 100644 --- a/system/Database/Postgre/Builder.php +++ b/system/Database/Postgre/Builder.php @@ -640,9 +640,6 @@ static function ($key, $value) use ($table, $alias, $that): RawSql|string { ), ); - // convert binds in where - $this->convertWhereBindsForBatch(); - $sql .= ' ' . str_replace( 'WHERE ', 'AND ', @@ -674,6 +671,6 @@ static function ($key, $value) use ($table, $alias, $that): RawSql|string { ) . "\n"; } - return str_replace('{:_table_:}', $data, $sql); + return $this->compileBatchBinds(str_replace('{:_table_:}', $data, $sql)); } } diff --git a/system/Database/Query.php b/system/Database/Query.php index 41d21c93330d..4abc177d9532 100644 --- a/system/Database/Query.php +++ b/system/Database/Query.php @@ -13,6 +13,11 @@ namespace CodeIgniter\Database; +use CodeIgniter\Database\MySQLi\Connection as MySQLiConnection; +use CodeIgniter\Database\OCI8\Connection as OCI8Connection; +use CodeIgniter\Database\Postgre\Connection as PostgreConnection; +use CodeIgniter\Database\SQLite3\Connection as SQLite3Connection; +use CodeIgniter\Database\SQLSRV\Connection as SQLSRVConnection; use Stringable; /** @@ -284,10 +289,263 @@ protected function matchNamedBinds(string $sql, array $binds): string $escapedValue = '(' . implode(',', $escapedValue) . ')'; } - $replacers[":{$placeholder}:"] = $escapedValue; + $replacers[":{$placeholder}:"] = (string) $escapedValue; } - return strtr($sql, $replacers); + return $this->replaceNamedPlaceholders($sql, $replacers); + } + + /** + * Works like strtr(), but leaves quoted strings, quoted identifiers + * and comments untouched, so that values already rendered into the SQL + * (escaped values, compiled subqueries) are never treated as placeholders. + * + * @param array $replacers + */ + private function replaceNamedPlaceholders(string $sql, array $replacers): string + { + if (strpbrk($sql, '\'"`#[$') === false && ! str_contains($sql, '--') && ! str_contains($sql, '/*')) { + return strtr($sql, $replacers); + } + + $maxLength = max(array_map(strlen(...), array_keys($replacers))); + $isMySQL = $this->db instanceof MySQLiConnection; + $isSQLSRV = $this->db instanceof SQLSRVConnection; + $isPostgre = $this->db instanceof PostgreConnection; + $isSQLite = $this->db instanceof SQLite3Connection; + $isOCI8 = $this->db instanceof OCI8Connection; + + // Backslash escapes matter only when there is a backslash in the SQL. + $backslashEscapes = str_contains($sql, '\\') + && $this->db instanceof BaseConnection + && $this->db->escapeString('\\') !== '\\'; + + // Multibyte characters matter only when there is a non-ASCII byte in the SQL. + $multibyte = preg_match('/[\x80-\xff]/', $sql) === 1 + ? $this->getMultibyteCharPattern($isMySQL, $isPostgre) + : null; + + $stop = ':\'"`-/#[$' . ($multibyte === null ? '' : implode('', array_map(chr(...), range(0x80, 0xFF)))); + $multibyteEnd = -1; + $length = strlen($sql); + $result = ''; + $offset = 0; + + while ($offset < $length) { + $position = $offset + strcspn($sql, $stop, $offset); + $result .= substr($sql, $offset, $position - $offset); + + if ($position >= $length) { + break; + } + + $char = $sql[$position]; + $next = $sql[$position + 1] ?? ''; + $end = $position + 1; + + if ($multibyte !== null && ord($char) >= 0x80) { + $end = $position + $this->getCharLength($sql, $position, $multibyte); + $multibyteEnd = $end; + } elseif ($char === ':') { + $placeholder = $this->findPlaceholderAt($sql, $position, $replacers, $maxLength); + + if ($placeholder !== null) { + $result .= $replacers[$placeholder]; + $offset = $position + strlen($placeholder); + + continue; + } + } elseif ( + $char === '\'' && $isOCI8 + && preg_match('/(?', q'(...)' or q'X...X' + $close = strpos($sql, strtr($sql[$position + 1], '[{<(', ']}>)') . '\'', $position + 2); + $end = $close === false ? $length : $close + 2; + } elseif ($char === '\'') { + // PostgreSQL E'...' strings always use backslash escapes. + $isEscapeString = $isPostgre + && $position > 0 + && $multibyteEnd !== $position + && ($sql[$position - 1] === 'E' || $sql[$position - 1] === 'e') + && ($position === 1 || preg_match('/[\w$\x80-\xff]/', $sql[$position - 2]) !== 1); + + $end = $this->findQuotedEnd($sql, $position, '\'', $backslashEscapes || $isEscapeString, $multibyte); + } elseif ($char === '"') { + // Only MySQL uses "..." for strings; elsewhere it quotes + // identifiers, in which a backslash is never an escape. + $end = $this->findQuotedEnd($sql, $position, '"', $backslashEscapes && $isMySQL, $multibyte); + } elseif ($char === '`') { + $end = $this->findQuotedEnd($sql, $position, '`', false); + } elseif ($char === '[' && ($isSQLSRV || $isSQLite)) { + $end = $this->findQuotedEnd($sql, $position, ']', false); + } elseif ( + $char === '$' && $isPostgre + && ($position === 0 || preg_match('/[\w$\x80-\xff]/', $sql[$position - 1]) !== 1) + && preg_match('/\G\$(?:[A-Za-z_\x80-\xff][\w\x80-\xff]*)?\$/', $sql, $match, 0, $position) === 1 + ) { + // PostgreSQL dollar-quoted strings: $$...$$ or $tag$...$tag$ + $close = strpos($sql, $match[0], $position + strlen($match[0])); + $end = $close === false ? $length : $close + strlen($match[0]); + } elseif ( + ($char === '-' && $next === '-' && (! $isMySQL || ord($sql[$position + 2] ?? ' ') <= 32)) + || ($char === '#' && $isMySQL) + ) { + // MySQL requires whitespace after "--" and also supports "#" comments. + $newLine = strpos($sql, "\n", $position); + $end = $newLine === false ? $length : $newLine; + } elseif ($char === '/' && $next === '*' && (! $isMySQL || ($sql[$position + 2] ?? '') !== '!')) { + // MySQL executes /*! ... */ comments, so they are not skipped. + // PostgreSQL and SQL Server allow nested block comments. + $end = $this->findCommentEnd($sql, $position, $isPostgre || $isSQLSRV); + } + + $result .= substr($sql, $position, $end - $position); + $offset = $end; + } + + return $result; + } + + /** + * Returns the longest placeholder that starts at the given position, + * matching the strtr() behavior. + * + * @param array $replacers + */ + private function findPlaceholderAt(string $sql, int $position, array $replacers, int $maxLength): ?string + { + $candidates = []; + $colon = $position; + + while (($colon = strpos($sql, ':', $colon + 1)) !== false && $colon - $position < $maxLength) { + $candidates[] = substr($sql, $position, $colon - $position + 1); + } + + foreach (array_reverse($candidates) as $candidate) { + if (array_key_exists($candidate, $replacers)) { + return $candidate; + } + } + + return null; + } + + /** + * Returns a pattern matching one multibyte character for connection + * charsets in which a trailing byte may be an ASCII byte, such as + * a backslash, or null when the charset does not need it. + */ + private function getMultibyteCharPattern(bool $isMySQL, bool $isPostgre): ?string + { + if (! $this->db instanceof BaseConnection) { + return null; + } + + // Same rules as my_ismbchar() in MySQL: invalid sequences are single bytes. + if ($isMySQL) { + return match (strtolower($this->db->charset)) { + 'big5' => '/\G[\xa1-\xf9][\x40-\x7e\xa1-\xfe]/', + 'gbk' => '/\G[\x81-\xfe][\x40-\x7e\x80-\xfe]/', + 'gb18030' => '/\G[\x81-\xfe](?:[\x30-\x39][\x81-\xfe][\x30-\x39]|[\x40-\x7e\x80-\xfe])/', + 'cp932', 'sjis' => '/\G[\x81-\x9f\xe0-\xfc][\x40-\x7e\x80-\xfc]/', + default => null, + }; + } + + // Same rules as pg_encoding_mblen() in libpq; PostgreSQL ignores case + // and non-alphanumeric characters in encoding names. + if ($isPostgre) { + return match (strtolower((string) preg_replace('/[^A-Za-z0-9]/', '', $this->db->charset))) { + 'big5', 'win950', 'windows950', 'gbk', 'win936', 'windows936' => '/\G[\x80-\xff][\x00-\xff]/', + 'gb18030' => '/\G[\x80-\xff](?:[\x30-\x39][\x00-\xff]{2}|[\x00-\xff])/', + 'mskanji', 'shiftjis', 'shiftjis2004', 'sjis', 'win932', 'windows932' => '/\G[\x80-\xa0\xe0-\xff][\x00-\xff]/', + default => null, + }; + } + + return null; + } + + /** + * Returns the length in bytes of the character at the given position. + */ + private function getCharLength(string $sql, int $position, string $multibyte): int + { + return preg_match($multibyte, $sql, $match, 0, $position) === 1 ? strlen($match[0]) : 1; + } + + /** + * Returns the position just after the end of the block comment + * that starts at the given position. + */ + private function findCommentEnd(string $sql, int $position, bool $nested): int + { + if (! $nested) { + $close = strpos($sql, '*/', $position + 2); + + return $close === false ? strlen($sql) : $close + 2; + } + + $depth = 0; + $offset = $position; + + while (preg_match('~/\*|\*/~', $sql, $match, PREG_OFFSET_CAPTURE, $offset) === 1) { + $offset = $match[0][1] + 2; + $depth += $match[0][0] === '/*' ? 1 : -1; + + if ($depth === 0) { + return $offset; + } + } + + return strlen($sql); + } + + /** + * Returns the position just after the closing quote of the quoted + * string or identifier that starts at the given position. + */ + private function findQuotedEnd(string $sql, int $position, string $quote, bool $backslashEscapes, ?string $multibyte = null): int + { + $stop = $backslashEscapes ? $quote . '\\' : $quote; + $length = strlen($sql); + $offset = $position + 1; + + if ($multibyte !== null) { + $stop .= implode('', array_map(chr(...), range(0x80, 0xFF))); + } + + while ($offset < $length) { + $offset += strcspn($sql, $stop, $offset); + + if ($offset >= $length) { + break; + } + + if ($multibyte !== null && ord($sql[$offset]) >= 0x80) { + $offset += $this->getCharLength($sql, $offset, $multibyte); + + continue; + } + + if ($sql[$offset] === '\\') { + $offset += 2; + + continue; + } + + if (($sql[$offset + 1] ?? '') === $quote) { + $offset += 2; + + continue; + } + + return $offset + 1; + } + + return $length; } /** diff --git a/system/Database/RawSql.php b/system/Database/RawSql.php index cb16131fe4c3..ef4acf39c3ea 100644 --- a/system/Database/RawSql.php +++ b/system/Database/RawSql.php @@ -15,9 +15,6 @@ use Stringable; -/** - * @see \CodeIgniter\Database\RawSqlTest - */ class RawSql implements Stringable { /** diff --git a/system/Database/TableName.php b/system/Database/TableName.php index a34ac57aa1d2..230dd845a67c 100644 --- a/system/Database/TableName.php +++ b/system/Database/TableName.php @@ -17,8 +17,6 @@ * Represents a table name in SQL. * * @interal - * - * @see \CodeIgniter\Database\TableNameTest */ class TableName { diff --git a/system/Debug/ExceptionHandler.php b/system/Debug/ExceptionHandler.php index 805b5691c079..e884052f5a87 100644 --- a/system/Debug/ExceptionHandler.php +++ b/system/Debug/ExceptionHandler.php @@ -24,9 +24,6 @@ use Config\Paths; use Throwable; -/** - * @see \CodeIgniter\Debug\ExceptionHandlerTest - */ final class ExceptionHandler extends BaseExceptionHandler implements ExceptionHandlerInterface { use ResponseTrait; diff --git a/system/Debug/Exceptions.php b/system/Debug/Exceptions.php index 74092b627963..4fc5feddd035 100644 --- a/system/Debug/Exceptions.php +++ b/system/Debug/Exceptions.php @@ -28,8 +28,6 @@ /** * Exceptions manager - * - * @see \CodeIgniter\Debug\ExceptionsTest */ class Exceptions { diff --git a/system/Debug/Timer.php b/system/Debug/Timer.php index 4c383a743413..53f050c2091d 100644 --- a/system/Debug/Timer.php +++ b/system/Debug/Timer.php @@ -20,8 +20,6 @@ * * Provides a simple way to measure the amount of time * that elapses between two points. - * - * @see \CodeIgniter\Debug\TimerTest */ class Timer { diff --git a/system/Debug/Toolbar/Collectors/Database.php b/system/Debug/Toolbar/Collectors/Database.php index 6ab1094c2918..9f7af83957e0 100644 --- a/system/Debug/Toolbar/Collectors/Database.php +++ b/system/Debug/Toolbar/Collectors/Database.php @@ -20,8 +20,6 @@ /** * Collector for the Database tab of the Debug Toolbar. - * - * @see \CodeIgniter\Debug\Toolbar\Collectors\DatabaseTest */ class Database extends BaseCollector { diff --git a/system/Debug/Toolbar/Collectors/History.php b/system/Debug/Toolbar/Collectors/History.php index a0575feba9e8..d306810f202c 100644 --- a/system/Debug/Toolbar/Collectors/History.php +++ b/system/Debug/Toolbar/Collectors/History.php @@ -17,8 +17,6 @@ /** * History collector - * - * @see \CodeIgniter\Debug\Toolbar\Collectors\HistoryTest */ class History extends BaseCollector { diff --git a/system/Email/Email.php b/system/Email/Email.php index 9304734fd179..3236514a0cf6 100644 --- a/system/Email/Email.php +++ b/system/Email/Email.php @@ -22,8 +22,6 @@ * CodeIgniter Email Class * * Permits email to be sent using Mail, Sendmail, or SMTP. - * - * @see \CodeIgniter\Email\EmailTest */ class Email { diff --git a/system/Encryption/Encryption.php b/system/Encryption/Encryption.php index d24a6754efab..32518574239f 100644 --- a/system/Encryption/Encryption.php +++ b/system/Encryption/Encryption.php @@ -27,8 +27,6 @@ * @property-read string $driver * @property-read list $drivers * @property-read string $key - * - * @see \CodeIgniter\Encryption\EncryptionTest */ class Encryption { diff --git a/system/Encryption/Handlers/OpenSSLHandler.php b/system/Encryption/Handlers/OpenSSLHandler.php index d649a66bf060..0700b8071312 100644 --- a/system/Encryption/Handlers/OpenSSLHandler.php +++ b/system/Encryption/Handlers/OpenSSLHandler.php @@ -21,8 +21,6 @@ * * @property-read string $cipher * @property-read string $key - * - * @see \CodeIgniter\Encryption\Handlers\OpenSSLHandlerTest */ class OpenSSLHandler extends BaseHandler { diff --git a/system/Encryption/Handlers/SodiumHandler.php b/system/Encryption/Handlers/SodiumHandler.php index 0fa1bd235247..b518a322c152 100644 --- a/system/Encryption/Handlers/SodiumHandler.php +++ b/system/Encryption/Handlers/SodiumHandler.php @@ -24,7 +24,6 @@ * @property-read string|null $key * * @see https://github.com/jedisct1/libsodium/issues/392 - * @see \CodeIgniter\Encryption\Handlers\SodiumHandlerTest */ class SodiumHandler extends BaseHandler { diff --git a/system/Entity/Entity.php b/system/Entity/Entity.php index a67c44b6fd1c..30c9e37fa1a8 100644 --- a/system/Entity/Entity.php +++ b/system/Entity/Entity.php @@ -38,8 +38,6 @@ /** * Entity encapsulation, for use with CodeIgniter\Model - * - * @see \CodeIgniter\Entity\EntityTest */ class Entity implements JsonSerializable { diff --git a/system/Events/Events.php b/system/Events/Events.php index 3cad30995f94..f50d13eb288c 100644 --- a/system/Events/Events.php +++ b/system/Events/Events.php @@ -17,8 +17,6 @@ /** * Events - * - * @see \CodeIgniter\Events\EventsTest */ class Events { diff --git a/system/Files/File.php b/system/Files/File.php index 2cf394355185..dda73875a997 100644 --- a/system/Files/File.php +++ b/system/Files/File.php @@ -22,8 +22,6 @@ /** * Wrapper for PHP's built-in SplFileInfo, with goodies. - * - * @see \CodeIgniter\Files\FileTest */ class File extends SplFileInfo { diff --git a/system/Files/FileCollection.php b/system/Files/FileCollection.php index b69cfb1d266b..ea6632d9be1f 100644 --- a/system/Files/FileCollection.php +++ b/system/Files/FileCollection.php @@ -27,7 +27,6 @@ * filtering, and ordering them. * * @template-implements IteratorAggregate - * @see \CodeIgniter\Files\FileCollectionTest */ class FileCollection implements Countable, IteratorAggregate { diff --git a/system/Filters/CSRF.php b/system/Filters/CSRF.php index ea9a9939f2de..994e6eb738c6 100644 --- a/system/Filters/CSRF.php +++ b/system/Filters/CSRF.php @@ -26,7 +26,6 @@ * This filter is not intended to be used from the command line. * * @codeCoverageIgnore - * @see \CodeIgniter\Filters\CSRFTest */ class CSRF implements FilterInterface { diff --git a/system/Filters/Cors.php b/system/Filters/Cors.php index c070d72925e5..34e40c482daa 100644 --- a/system/Filters/Cors.php +++ b/system/Filters/Cors.php @@ -18,9 +18,6 @@ use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; -/** - * @see \CodeIgniter\Filters\CorsTest - */ class Cors implements FilterInterface { private ?CorsService $cors = null; diff --git a/system/Filters/DebugToolbar.php b/system/Filters/DebugToolbar.php index 7199b85f9d62..5f5ba05ab14e 100644 --- a/system/Filters/DebugToolbar.php +++ b/system/Filters/DebugToolbar.php @@ -18,8 +18,6 @@ /** * Debug toolbar filter - * - * @see \CodeIgniter\Filters\DebugToolbarTest */ class DebugToolbar implements FilterInterface { diff --git a/system/Filters/Filters.php b/system/Filters/Filters.php index 36725b8198d3..2a02ea7c08e2 100644 --- a/system/Filters/Filters.php +++ b/system/Filters/Filters.php @@ -23,8 +23,6 @@ /** * Filters - * - * @see \CodeIgniter\Filters\FiltersTest */ class Filters { diff --git a/system/Filters/Honeypot.php b/system/Filters/Honeypot.php index 4b24691ef905..fe6491d7f6ab 100644 --- a/system/Filters/Honeypot.php +++ b/system/Filters/Honeypot.php @@ -20,8 +20,6 @@ /** * Honeypot filter - * - * @see \CodeIgniter\Filters\HoneypotTest */ class Honeypot implements FilterInterface { diff --git a/system/Filters/InvalidChars.php b/system/Filters/InvalidChars.php index 5d714b0fdf11..6d8a3a2a5cee 100644 --- a/system/Filters/InvalidChars.php +++ b/system/Filters/InvalidChars.php @@ -25,8 +25,6 @@ * invalid characters: * - invalid UTF-8 characters * - control characters except line break and tab code - * - * @see \CodeIgniter\Filters\InvalidCharsTest */ class InvalidChars implements FilterInterface { diff --git a/system/Filters/SecureHeaders.php b/system/Filters/SecureHeaders.php index 42e5842384db..15a69313bb53 100644 --- a/system/Filters/SecureHeaders.php +++ b/system/Filters/SecureHeaders.php @@ -18,8 +18,6 @@ /** * Add Common Security Headers - * - * @see \CodeIgniter\Filters\SecureHeadersTest */ class SecureHeaders implements FilterInterface { diff --git a/system/Format/Exceptions/FormatException.php b/system/Format/Exceptions/FormatException.php index cde333b1292c..7c7e2168c69a 100644 --- a/system/Format/Exceptions/FormatException.php +++ b/system/Format/Exceptions/FormatException.php @@ -57,6 +57,17 @@ public static function forInvalidMime(string $mime) return new static(lang('Format.invalidMime', [$mime])); } + /** + * Thrown in XMLFormatter when a value cannot be converted + * to a string. + * + * @return static + */ + public static function forInvalidXMLValue(string $type) + { + return new static(lang('Format.invalidXMLValue', [$type])); + } + /** * Thrown on XMLFormatter when the `simplexml` extension * is not installed. diff --git a/system/Format/Format.php b/system/Format/Format.php index 6100b1a586a8..ec7d104bce8c 100644 --- a/system/Format/Format.php +++ b/system/Format/Format.php @@ -18,8 +18,6 @@ /** * The Format class is a convenient place to create Formatters. - * - * @see \CodeIgniter\Format\FormatTest */ class Format { diff --git a/system/Format/JSONFormatter.php b/system/Format/JSONFormatter.php index fb882a0e4eac..9f21e0fb8f1d 100644 --- a/system/Format/JSONFormatter.php +++ b/system/Format/JSONFormatter.php @@ -18,8 +18,6 @@ /** * JSON data formatter - * - * @see \CodeIgniter\Format\JSONFormatterTest */ class JSONFormatter implements FormatterInterface { diff --git a/system/Format/XMLFormatter.php b/system/Format/XMLFormatter.php index 0f058e8a7ff7..3a2e8a6f20a9 100644 --- a/system/Format/XMLFormatter.php +++ b/system/Format/XMLFormatter.php @@ -16,11 +16,10 @@ use CodeIgniter\Format\Exceptions\FormatException; use Config\Format; use SimpleXMLElement; +use Stringable; /** * XML data formatter - * - * @see \CodeIgniter\Format\XMLFormatterTest */ class XMLFormatter implements FormatterInterface { @@ -70,7 +69,11 @@ protected function arrayToXML(array $data, &$output) $subnode = $output->addChild("{$key}"); $this->arrayToXML($value, $subnode); } else { - $output->addChild("{$key}", htmlspecialchars("{$value}")); + if (is_object($value) && ! $value instanceof Stringable) { + throw FormatException::forInvalidXMLValue($value::class); + } + + $output->addChild("{$key}", htmlspecialchars((string) $value)); } } } @@ -100,6 +103,8 @@ protected function normalizeXMLTag($key) $key = preg_replace("/[^{$validName}-]+/u", '', $key); $key = preg_replace("/^[^{$startChar}]+/u", 'item$0', $key); - return preg_replace('/^(xml).*/iu', 'item$0', $key); // XML is a reserved starting word + $key = preg_replace('/^(xml).*/iu', 'item$0', $key); // XML is a reserved starting word + + return $key === '' ? 'item' : $key; } } diff --git a/system/HTTP/CLIRequest.php b/system/HTTP/CLIRequest.php index 55c8b797b094..e6f00c90c2a9 100644 --- a/system/HTTP/CLIRequest.php +++ b/system/HTTP/CLIRequest.php @@ -27,8 +27,6 @@ * originally made available under. * * http://fuelphp.com - * - * @see \CodeIgniter\HTTP\CLIRequestTest */ class CLIRequest extends Request { diff --git a/system/HTTP/CURLRequest.php b/system/HTTP/CURLRequest.php index 9c2184ac84c1..7b07c10bec71 100644 --- a/system/HTTP/CURLRequest.php +++ b/system/HTTP/CURLRequest.php @@ -22,8 +22,6 @@ /** * A lightweight HTTP client for sending synchronous HTTP requests via cURL. - * - * @see \CodeIgniter\HTTP\CURLRequestTest */ class CURLRequest extends OutgoingRequest { @@ -815,8 +813,7 @@ private function applyResponseOptions(array $curlOptions, array $config): array if ($accept !== '') { $curlOptions[CURLOPT_ENCODING] = $accept; } else { - $curlOptions[CURLOPT_ENCODING] = ''; - $curlOptions[CURLOPT_HTTPHEADER] = 'Accept-Encoding'; + $curlOptions[CURLOPT_ENCODING] = ''; } } diff --git a/system/HTTP/ContentSecurityPolicy.php b/system/HTTP/ContentSecurityPolicy.php index acea6b2b5ad6..1c40ada588cc 100644 --- a/system/HTTP/ContentSecurityPolicy.php +++ b/system/HTTP/ContentSecurityPolicy.php @@ -25,7 +25,6 @@ * @see http://www.html5rocks.com/en/tutorials/security/content-security-policy/ * @see http://content-security-policy.com/ * @see https://www.owasp.org/index.php/Content_Security_Policy - * @see \CodeIgniter\HTTP\ContentSecurityPolicyTest */ class ContentSecurityPolicy { @@ -67,7 +66,7 @@ class ContentSecurityPolicy /** * The `base-uri` directive restricts the URLs that can be used to specify the document base URL. * - * @var array|string|null + * @var array|string|null */ protected $baseURI = []; @@ -75,35 +74,35 @@ class ContentSecurityPolicy * The `child-src` directive governs the creation of nested browsing contexts as well * as Worker execution contexts. * - * @var array|string + * @var array|string */ protected $childSrc = []; /** * The `connect-src` directive restricts which URLs the protected resource can load using script interfaces. * - * @var array|string + * @var array|string */ protected $connectSrc = []; /** * The `default-src` directive sets a default source list for a number of directives. * - * @var array|string|null + * @var array|string|null */ protected $defaultSrc = []; /** * The `font-src` directive restricts from where the protected resource can load fonts. * - * @var array|string + * @var array|string */ protected $fontSrc = []; /** * The `form-action` directive restricts which URLs can be used as the action of HTML form elements. * - * @var array|string + * @var array|string */ protected $formAction = []; @@ -112,21 +111,21 @@ class ContentSecurityPolicy * the resource using a `frame`, `iframe`, `object`, `embed` or `applet` element, * or equivalent functionality in non-HTML resources. * - * @var array|string + * @var array|string */ protected $frameAncestors = []; /** * The `frame-src` directive restricts the URLs which may be loaded into child navigables. * - * @var array|string + * @var array|string */ protected $frameSrc = []; /** * The `img-src` directive restricts from where the protected resource can load images. * - * @var array|string + * @var array|string */ protected $imageSrc = []; @@ -134,14 +133,14 @@ class ContentSecurityPolicy * The `media-src` directive restricts from where the protected resource can load video, * audio, and associated text tracks. * - * @var array|string + * @var array|string */ protected $mediaSrc = []; /** * The `object-src` directive restricts from where the protected resource can load plugins. * - * @var array|string + * @var array|string */ protected $objectSrc = []; @@ -149,28 +148,28 @@ class ContentSecurityPolicy * The `plugin-types` directive restricts the set of plugins that can be invoked by the * protected resource by limiting the types of resources that can be embedded. * - * @var array|string + * @var array|string */ protected $pluginTypes = []; /** * The `script-src` directive restricts which scripts the protected resource can execute. * - * @var array|string + * @var array|string */ protected $scriptSrc = []; /** * The `style-src` directive restricts which styles the user may applies to the protected resource. * - * @var array|string + * @var array|string */ protected $styleSrc = []; /** * The `sandbox` directive specifies an HTML sandbox policy that the user agent applies to the protected resource. * - * @var array|string + * @var array|string */ protected $sandbox = []; @@ -194,14 +193,14 @@ class ContentSecurityPolicy /** * The `manifest-src` directive restricts the URLs from which application manifests may be loaded. * - * @var array|string + * @var array|string */ protected $manifestSrc = []; /** * The `script-src-elem` directive applies to all script requests and script blocks. * - * @var array|string + * @var array|string */ protected array|string $scriptSrcElem = []; @@ -209,7 +208,7 @@ class ContentSecurityPolicy * The `script-src-attr` directive applies to event handlers and, if present, * it will override the `script-src` directive for relevant checks. * - * @var array|string + * @var array|string */ protected array|string $scriptSrcAttr = []; @@ -217,14 +216,14 @@ class ContentSecurityPolicy * The `style-src-elem` directive governs the behaviour of styles except * for styles defined in inline attributes. * - * @var array|string + * @var array|string */ protected array|string $styleSrcElem = []; /** * The `style-src-attr` directive governs the behaviour of style attributes. * - * @var array|string + * @var array|string */ protected array|string $styleSrcAttr = []; @@ -232,7 +231,7 @@ class ContentSecurityPolicy * The `worker-src` directive restricts the URLs which may be loaded as a `Worker`, * `SharedWorker`, or `ServiceWorker`. * - * @var array|string + * @var array|string */ protected array|string $workerSrc = []; @@ -366,8 +365,8 @@ public function __construct(ContentSecurityPolicyConfig $config) && array_is_list($value) ) { // Config sets these directives as `list|string` - // but we need them as `array` internally. - $this->{$setting} = array_combine($value, array_fill(0, count($value), $this->reportOnly)); + // but we need them as `array` internally. + $this->{$setting} = array_fill_keys($value, null); continue; } @@ -376,11 +375,11 @@ public function __construct(ContentSecurityPolicyConfig $config) } if (! is_array($this->styleSrc)) { - $this->styleSrc = [$this->styleSrc => $this->reportOnly]; + $this->styleSrc = [$this->styleSrc => null]; } if (! is_array($this->scriptSrc)) { - $this->scriptSrc = [$this->scriptSrc => $this->reportOnly]; + $this->scriptSrc = [$this->scriptSrc => null]; } } @@ -522,12 +521,30 @@ public function addConnectSrc($uri, ?bool $explicitReporting = null) * @see http://www.w3.org/TR/CSP/#directive-default-src * * @param list|string $uri + */ + public function addDefaultSrc(array|string $uri, ?bool $explicitReporting = null): static + { + $this->addOption($uri, 'defaultSrc', $explicitReporting ?? $this->reportOnly); + + return $this; + } + + /** + * Sets the `default-src` directive, replacing any existing sources. + * + * `default-src` is the URI that is used for many of the settings when + * no other source has been set. + * + * @see http://www.w3.org/TR/CSP/#directive-default-src + * + * @param list|string $uri * * @return $this */ public function setDefaultSrc($uri, ?bool $explicitReporting = null) { - $this->defaultSrc = [(string) $uri => $explicitReporting ?? $this->reportOnly]; + $this->defaultSrc = []; + $this->addDefaultSrc($uri, $explicitReporting); return $this; } @@ -868,6 +885,28 @@ public function addReportingEndpoints(array $endpoint): static return $this; } + public function clearDirective(string $directive): void + { + if (! array_key_exists($directive, $this->directives)) { + return; + } + + if ($directive === 'report-uri') { + $this->reportURI = null; + + return; + } + + if ($directive === 'report-to') { + $this->reportURI = null; + $this->reportTo = null; + + return; + } + + $this->{$this->directives[$directive]} = []; + } + /** * DRY method to add an string or array to a class property. * @@ -879,13 +918,15 @@ protected function addOption($options, string $target, ?bool $explicitReporting { // Ensure we have an array to work with... if (is_string($this->{$target})) { - $this->{$target} = [$this->{$target} => $this->reportOnly]; + $this->{$target} = $this->parseSourceList($this->{$target}); + } elseif (! is_array($this->{$target})) { + $this->{$target} = []; } $options = is_array($options) ? $options : [$options]; foreach ($options as $option) { - $this->{$target}[$option] = $explicitReporting ?? $this->reportOnly; + $this->{$target}[trim((string) $option)] = $explicitReporting ?? $this->reportOnly; } } @@ -1006,21 +1047,28 @@ protected function buildHeaders(ResponseInterface $response) * Adds a directive and its options to the appropriate header. The $values * array might have options that are geared toward either the regular or the * reportOnly header, since it's viable to have both simultaneously. + * An option flagged `null` follows the current `reportOnly()` setting. * - * @param array|string $values + * @param array|string $values * * @return void */ protected function addToHeader(string $name, $values = null) { + if ($values === null) { + return; + } + if (is_string($values)) { - $values = [$values => $this->reportOnly]; + $values = $this->parseSourceList($values); } $sources = []; $reportSources = []; foreach ($values as $value => $reportOnly) { + $value = (string) $value; + if ( in_array($value, $this->validSources, true) || str_starts_with($value, 'nonce-') @@ -1031,7 +1079,7 @@ protected function addToHeader(string $name, $values = null) $value = "'{$value}'"; } - if ($reportOnly) { + if ($reportOnly ?? $this->reportOnly) { $reportSources[] = $value; } else { $sources[] = $value; @@ -1039,33 +1087,35 @@ protected function addToHeader(string $name, $values = null) } if ($sources !== []) { + if (count($sources) > 1) { + $sources = array_values(array_filter($sources, static fn ($s): bool => $s !== '')); + } + $this->tempHeaders[$name] = implode(' ', $sources); } if ($reportSources !== []) { + if (count($reportSources) > 1) { + $reportSources = array_values(array_filter($reportSources, static fn ($s): bool => $s !== '')); + } + $this->reportOnlyHeaders[$name] = implode(' ', $reportSources); } } - public function clearDirective(string $directive): void + /** + * Converts a string directive value into an array of options. + * + * @return array + */ + private function parseSourceList(string $values): array { - if (! array_key_exists($directive, $this->directives)) { - return; - } - - if ($directive === 'report-uri') { - $this->reportURI = null; + $valueStr = trim($values); - return; + if ($valueStr === '') { + return ['' => null]; } - if ($directive === 'report-to') { - $this->reportURI = null; - $this->reportTo = null; - - return; - } - - $this->{$this->directives[$directive]} = []; + return array_fill_keys(preg_split('/\s+/', $valueStr), null); } } diff --git a/system/HTTP/Cors.php b/system/HTTP/Cors.php index 270b344abb8b..e1da450b8270 100644 --- a/system/HTTP/Cors.php +++ b/system/HTTP/Cors.php @@ -18,8 +18,6 @@ /** * Cross-Origin Resource Sharing (CORS) - * - * @see \CodeIgniter\HTTP\CorsTest */ class Cors { diff --git a/system/HTTP/DownloadResponse.php b/system/HTTP/DownloadResponse.php index b7ce79b8309a..fa7dc3dddf20 100644 --- a/system/HTTP/DownloadResponse.php +++ b/system/HTTP/DownloadResponse.php @@ -20,8 +20,6 @@ /** * HTTP response when a download is requested. - * - * @see \CodeIgniter\HTTP\DownloadResponseTest */ class DownloadResponse extends Response { diff --git a/system/HTTP/Files/FileCollection.php b/system/HTTP/Files/FileCollection.php index c1c0582f6ff5..934cf9ca237e 100644 --- a/system/HTTP/Files/FileCollection.php +++ b/system/HTTP/Files/FileCollection.php @@ -20,8 +20,6 @@ * Class FileCollection * * Provides easy access to uploaded files for a request. - * - * @see \CodeIgniter\HTTP\Files\FileCollectionTest */ class FileCollection { diff --git a/system/HTTP/Header.php b/system/HTTP/Header.php index 54f162eb7caa..845307c84bca 100644 --- a/system/HTTP/Header.php +++ b/system/HTTP/Header.php @@ -20,8 +20,6 @@ * Class Header * * Represents a single HTTP header. - * - * @see \CodeIgniter\HTTP\HeaderTest */ class Header implements Stringable { diff --git a/system/HTTP/IncomingRequest.php b/system/HTTP/IncomingRequest.php index cc66e35f4ddc..eb77b9277875 100644 --- a/system/HTTP/IncomingRequest.php +++ b/system/HTTP/IncomingRequest.php @@ -44,8 +44,6 @@ * - Query string arguments (generally via $_GET, or as parsed via parse_str()) * - Upload files, if any (as represented by $_FILES) * - Deserialized body binds (generally from $_POST) - * - * @see \CodeIgniter\HTTP\IncomingRequestTest */ class IncomingRequest extends Request { @@ -142,7 +140,7 @@ public function __construct($config, ?URI $uri = null, $body = 'php://input', ?U // php://input is not available with enctype="multipart/form-data". // See https://www.php.net/manual/en/wrappers.php.php#wrappers.php.input && ! str_contains($this->getHeaderLine('Content-Type'), 'multipart/form-data') - && (int) $this->getHeaderLine('Content-Length') <= $this->getPostMaxSize() + && (int) $this->getHeaderLine('Content-Length') <= $this->getPostMaxSize((string) ini_get('post_max_size')) ) { // Get our body from php://input $body = file_get_contents('php://input'); @@ -169,16 +167,16 @@ public function __construct($config, ?URI $uri = null, $body = 'php://input', ?U $this->detectLocale($config); } - private function getPostMaxSize(): int + /** + * Converts the `post_max_size` ini value to bytes. + * + * A value of 0 or less means unlimited. + */ + private function getPostMaxSize(string $postMaxSize): int { - $postMaxSize = ini_get('post_max_size'); + $bytes = ini_parse_quantity($postMaxSize); - return match (strtoupper(substr($postMaxSize, -1))) { - 'G' => (int) str_replace('G', '', $postMaxSize) * 1024 ** 3, - 'M' => (int) str_replace('M', '', $postMaxSize) * 1024 ** 2, - 'K' => (int) str_replace('K', '', $postMaxSize) * 1024, - default => (int) $postMaxSize, - }; + return $bytes <= 0 ? PHP_INT_MAX : $bytes; } /** @@ -276,8 +274,18 @@ public function isSecure(): bool return false; } - if ($this->hasHeader('X-Forwarded-Proto') && $this->header('X-Forwarded-Proto')->getValue() === 'https') { - return true; + if ($this->hasHeader('X-Forwarded-Proto')) { + $proto = $this->header('X-Forwarded-Proto')->getValue(); + + // A single value may be a string or a one-element array. Multiple + // values are ambiguous and cannot be trusted, so they are ignored. + if (is_array($proto)) { + $proto = count($proto) === 1 ? reset($proto) : null; + } + + if (is_string($proto) && strtolower(trim($proto)) === 'https') { + return true; + } } if (! $this->hasHeader('Front-End-Https')) { diff --git a/system/HTTP/Message.php b/system/HTTP/Message.php index 7c5114f71f63..22e0ec67a8e5 100644 --- a/system/HTTP/Message.php +++ b/system/HTTP/Message.php @@ -17,8 +17,6 @@ /** * An HTTP message - * - * @see \CodeIgniter\HTTP\MessageTest */ class Message implements MessageInterface { diff --git a/system/HTTP/Negotiate.php b/system/HTTP/Negotiate.php index d482d086e4c9..78ff111c86c0 100644 --- a/system/HTTP/Negotiate.php +++ b/system/HTTP/Negotiate.php @@ -24,7 +24,6 @@ * server wants. * * @see http://tools.ietf.org/html/rfc7231#section-5.3 - * @see \CodeIgniter\HTTP\NegotiateTest */ class Negotiate { @@ -185,13 +184,24 @@ protected function getBestMatch( } // if acceptable value is "anything", return the first available + // that the header does not exclude if ($accept['value'] === '*' || $accept['value'] === '*/*') { - return $supported[0]; + foreach ($supported as $available) { + if (! $this->isExcluded($acceptable, $available, $enforceTypes, $matchLocales)) { + return $available; + } + } + + continue; } - // If an acceptable value is supported, return it + // If an acceptable value is supported, return it, unless the header + // excludes it with a more specific value, such as "text/html;q=0" + // for "text/*" foreach ($supported as $available) { - if ($this->match($accept, $available, $enforceTypes, $matchLocales)) { + if ($this->match($accept, $available, $enforceTypes, $matchLocales) + && ! $this->isExcluded($acceptable, $available, $enforceTypes, $matchLocales) + ) { return $available; } } @@ -234,8 +244,15 @@ protected function getBestLocaleMatch(array $supportedLocales, ?string $header): } // if acceptable value is "anything", return the first available + // that the header does not exclude if ($accept['value'] === '*') { - return $supportedLocales[0]; + foreach ($supportedLocales as $locale) { + if (! $this->isExcluded($acceptable, $locale, false, true)) { + return $locale; + } + } + + continue; } // look for exact match @@ -249,13 +266,17 @@ protected function getBestLocaleMatch(array $supportedLocales, ?string $header): foreach ($fallbackLocales as $fallbackLocale) { // look for exact match - if (in_array($fallbackLocale, $supportedLocales, true)) { + if (in_array($fallbackLocale, $supportedLocales, true) + && ! $this->isExcluded($acceptable, $fallbackLocale, false, true) + ) { return $fallbackLocale; } // look for regional locale match foreach ($supportedLocales as $locale) { - if (str_starts_with($locale, $fallbackLocale . '-')) { + if (str_starts_with($locale, $fallbackLocale . '-') + && ! $this->isExcluded($acceptable, $locale, false, true) + ) { return $locale; } } @@ -264,6 +285,106 @@ protected function getBestLocaleMatch(array $supportedLocales, ?string $header): return $supportedLocales[0]; } + /** + * Checks whether the header excludes a supported value, that is, whether + * the most specific value in the header that matches it has q=0. + * + * @param list}> $acceptable + * @param bool $enforceTypes If TRUE, the values are media types. + * @param bool $matchLocales If TRUE, the values are language tags. + */ + private function isExcluded(array $acceptable, string $supported, bool $enforceTypes, bool $matchLocales): bool + { + $supported = $this->parseHeader($supported)[0]; + $specificity = -1; + $excluded = false; + + // The header is sorted by quality, so of two equally specific values, + // the one with the higher quality is used. + foreach ($acceptable as $accept) { + $acceptSpecificity = $this->getMatchSpecificity($accept, $supported, $enforceTypes, $matchLocales); + + if ($acceptSpecificity > $specificity) { + $specificity = $acceptSpecificity; + $excluded = $accept['q'] === 0.0; + } + } + + return $excluded; + } + + /** + * Returns how specifically a header value matches a supported value, or -1 + * if it does not match it. A wildcard is the least specific. + * + * A media range matches the media types of its type and subtype that have + * its parameters. "text/html" is more specific than "text/*", which is more + * specific than a full wildcard, and a range is more specific than one of + * the same kind with fewer parameters, so "text/html;level=1" is more + * specific than "text/html", and "text/*;charset=utf-8" than "text/*" + * (RFC 9110, Section 12.5.1). Types, subtypes, parameter names and charset + * values are compared ignoring case (RFC 9110, Section 8.3). + * A language range matches the tags that equal it or start with it followed + * by "-", ignoring case, and is more specific with more subtags + * (RFC 4647, Section 3.3.1). Other values must be equal, ignoring case. + * + * @param array{value: string, q: float, params: array} $accept + * @param array{value: string, q: float, params: array} $supported + */ + private function getMatchSpecificity(array $accept, array $supported, bool $enforceTypes, bool $matchLocales): int + { + if ($enforceTypes) { + $range = strtolower($accept['value']); + [$aType, $aSubType] = explode('/', $range, 2) + ['', '']; + [$sType, $sSubType] = explode('/', strtolower($supported['value']), 2) + ['', '']; + + if ($range === '*' || $range === '*/*') { + $level = 0; + } elseif ($aType === $sType && $aSubType === '*') { + $level = 1; + } elseif ($aType === $sType && $aSubType === $sSubType) { + $level = 2; + } else { + return -1; + } + + $aParams = array_change_key_case($accept['params']); + $sParams = array_change_key_case($supported['params']); + + if (isset($aParams['charset'], $sParams['charset'])) { + $aParams['charset'] = strtolower($aParams['charset']); + $sParams['charset'] = strtolower($sParams['charset']); + } + + foreach ($aParams as $label => $value) { + if (($sParams[$label] ?? null) !== $value) { + return -1; + } + } + + // A range has at most as many parameters as the value it matches, + // so it is more specific than any range of a lower level. + return $level * (count($sParams) + 1) + count($aParams); + } + + if ($accept['value'] === '*' || $accept['value'] === '*/*') { + return 0; + } + + if ($matchLocales) { + $aTag = strtolower($accept['value']); + $sTag = strtolower($supported['value']); + + if ($aTag !== $sTag && ! str_starts_with($sTag, $aTag . '-')) { + return -1; + } + + return substr_count($aTag, '-') + 1; + } + + return strcasecmp($accept['value'], $supported['value']) === 0 ? 1 : -1; + } + /** * Parses an Accept* header into it's multiple values. * @@ -410,6 +531,11 @@ protected function matchParameters(array $acceptable, array $supported): bool */ public function matchTypes(array $acceptable, array $supported): bool { + // A value without a subtype, such as a malformed "json", is not a media type. + if (! str_contains($acceptable['value'], '/') || ! str_contains($supported['value'], '/')) { + return false; + } + // PHPDocumentor v2 cannot parse yet the shorter list syntax, // causing no API generation for the file. [$aType, $aSubType] = explode('/', $acceptable['value']); diff --git a/system/HTTP/OutgoingRequest.php b/system/HTTP/OutgoingRequest.php index 1534bdcb8308..74d089a03cda 100644 --- a/system/HTTP/OutgoingRequest.php +++ b/system/HTTP/OutgoingRequest.php @@ -15,8 +15,6 @@ /** * Representation of an outgoing, client-side request. - * - * @see \CodeIgniter\HTTP\OutgoingRequestTest */ class OutgoingRequest extends Message implements OutgoingRequestInterface { diff --git a/system/HTTP/RedirectResponse.php b/system/HTTP/RedirectResponse.php index ed464244a63d..61c760ee1cc9 100644 --- a/system/HTTP/RedirectResponse.php +++ b/system/HTTP/RedirectResponse.php @@ -18,8 +18,6 @@ /** * Handle a redirect response - * - * @see \CodeIgniter\HTTP\RedirectResponseTest */ class RedirectResponse extends Response { diff --git a/system/HTTP/Request.php b/system/HTTP/Request.php index 63b5f0a8dbfd..73859dd843ae 100644 --- a/system/HTTP/Request.php +++ b/system/HTTP/Request.php @@ -17,8 +17,6 @@ /** * Representation of an incoming, server-side HTTP request. - * - * @see \CodeIgniter\HTTP\RequestTest */ class Request extends OutgoingRequest implements RequestInterface { diff --git a/system/HTTP/Response.php b/system/HTTP/Response.php index 2ac2ecb7e9d2..c8107a5507ed 100644 --- a/system/HTTP/Response.php +++ b/system/HTTP/Response.php @@ -29,8 +29,6 @@ * - Status code and reason phrase * - Headers * - Message body - * - * @see \CodeIgniter\HTTP\ResponseTest */ class Response extends Message implements ResponseInterface { diff --git a/system/HTTP/SiteURI.php b/system/HTTP/SiteURI.php index 403269008e56..7deb85fd5613 100644 --- a/system/HTTP/SiteURI.php +++ b/system/HTTP/SiteURI.php @@ -20,8 +20,6 @@ /** * URI for the application site - * - * @see \CodeIgniter\HTTP\SiteURITest */ class SiteURI extends URI { diff --git a/system/HTTP/SiteURIFactory.php b/system/HTTP/SiteURIFactory.php index 11dccce6c540..3533b8383c6a 100644 --- a/system/HTTP/SiteURIFactory.php +++ b/system/HTTP/SiteURIFactory.php @@ -20,9 +20,7 @@ /** * Creates SiteURI using superglobals. * - * This class also updates superglobal $_SERVER and $_GET. - * - * @see \CodeIgniter\HTTP\SiteURIFactoryTest + * This class also updates superglobal $_REQUEST, $_SERVER, and $_GET. */ final readonly class SiteURIFactory { @@ -33,7 +31,7 @@ public function __construct(private App $appConfig, private Superglobals $superg /** * Create the current URI object from superglobals. * - * This method updates superglobal $_SERVER and $_GET. + * This method updates superglobals $_SERVER, $_GET, and $_REQUEST. */ public function createFromGlobals(): SiteURI { @@ -105,7 +103,7 @@ public function detectRoutePath(string $protocol = ''): string * Will parse the REQUEST_URI and automatically detect the URI from it, * fixing the query string if necessary. * - * This method updates superglobal $_SERVER and $_GET. + * This method updates superglobals $_SERVER, $_GET, and $_REQUEST. * * @return string The route path (before normalization). */ @@ -169,9 +167,11 @@ private function parseRequestURI(): string $this->superglobals->setServer('QUERY_STRING', $query); } - // Update our global GET for values likely to have been changed + // Refresh only the request values derived from superglobals after GET changes. + $previousRequestData = $this->superglobals->getRequestData(); parse_str($this->superglobals->server('QUERY_STRING'), $get); $this->superglobals->setGetArray($get); + $this->refreshRequestAfterGetChange($previousRequestData); return URI::removeDotSegments($path); } @@ -179,7 +179,7 @@ private function parseRequestURI(): string /** * Will parse QUERY_STRING and automatically detect the URI from it. * - * This method updates superglobal $_SERVER and $_GET. + * This method updates superglobals $_SERVER, $_GET, and $_REQUEST. * * @return string The route path (before normalization). */ @@ -201,9 +201,11 @@ private function parseQueryString(): string $path = $query; } - // Update our global GET for values likely to have been changed + // Refresh only the request values derived from superglobals after GET changes. + $previousRequestData = $this->superglobals->getRequestData(); parse_str($this->superglobals->server('QUERY_STRING'), $get); $this->superglobals->setGetArray($get); + $this->refreshRequestAfterGetChange($previousRequestData); return URI::removeDotSegments($path); } @@ -222,6 +224,38 @@ private function createURIFromRoutePath(string $routePath): SiteURI return new SiteURI($this->appConfig, $relativePath, $this->getHost()); } + /** + * Update REQUEST once during URI parsing, leaving application-defined values alone. + * + * @param array $previousRequestData + */ + private function refreshRequestAfterGetChange(array $previousRequestData): void + { + $request = $this->superglobals->getRequestArray(); + $currentData = $this->superglobals->getRequestData(); + + foreach ($previousRequestData as $key => $value) { + // A different value was supplied directly in REQUEST; preserve it. + if (! array_key_exists($key, $request) || $request[$key] !== $value) { + continue; + } + + if (array_key_exists($key, $currentData)) { + $request[$key] = $currentData[$key]; + } else { + unset($request[$key]); + } + } + + foreach ($currentData as $key => $value) { + if (! array_key_exists($key, $previousRequestData) && ! array_key_exists($key, $request)) { + $request[$key] = $value; + } + } + + $this->superglobals->setRequestArray($request); + } + /** * @return string|null The current hostname. Returns null if no valid host. */ diff --git a/system/HTTP/URI.php b/system/HTTP/URI.php index 4b8b324dc80e..e7d6cc369210 100644 --- a/system/HTTP/URI.php +++ b/system/HTTP/URI.php @@ -22,8 +22,6 @@ /** * Abstraction for a uniform resource identifier (URI). - * - * @see \CodeIgniter\HTTP\URITest */ class URI implements Stringable { diff --git a/system/HTTP/UserAgent.php b/system/HTTP/UserAgent.php index fe7679e59384..b89fb63b70f0 100644 --- a/system/HTTP/UserAgent.php +++ b/system/HTTP/UserAgent.php @@ -18,8 +18,6 @@ /** * Abstraction for an HTTP user agent - * - * @see \CodeIgniter\HTTP\UserAgentTest */ class UserAgent implements Stringable { diff --git a/system/Helpers/Array/ArrayHelper.php b/system/Helpers/Array/ArrayHelper.php index d370e1f1112f..b48efbfc4802 100644 --- a/system/Helpers/Array/ArrayHelper.php +++ b/system/Helpers/Array/ArrayHelper.php @@ -20,10 +20,6 @@ * * If there are any methods that should be provided, make them * public APIs via helper functions. - * - * @see \CodeIgniter\Helpers\Array\ArrayHelperDotKeyExistsTest - * @see \CodeIgniter\Helpers\Array\ArrayHelperRecursiveDiffTest - * @see \CodeIgniter\Helpers\Array\ArrayHelperSortValuesByNaturalTest */ final class ArrayHelper { diff --git a/system/Helpers/form_helper.php b/system/Helpers/form_helper.php index dee1f001365b..b030b2f06009 100644 --- a/system/Helpers/form_helper.php +++ b/system/Helpers/form_helper.php @@ -11,6 +11,7 @@ * the LICENSE file that was distributed with this source code. */ +use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\Validation\Exceptions\ValidationException; use Config\App; use Config\Validation; @@ -547,27 +548,25 @@ function form_close(string $extra = ''): string if (! function_exists('set_value')) { /** - * Form Value - * * Grabs a value from the POST array for the specified field so you can * re-populate an input field or textarea * - * @param string $field Field name - * @param list|string $default Default value - * @param bool $htmlEscape Whether to escape HTML special characters or not + * @param string $field Field name + * @param list|scalar|Stringable|null $default Default value + * @param bool $htmlEscape Whether to escape HTML special characters or not * * @return list|string */ function set_value(string $field, $default = '', bool $htmlEscape = true): array|string { + /** @var IncomingRequest $request */ $request = service('request'); - // Try any old input data we may have first - $value = $request->getOldInput($field); - - $value ??= $request->getPost($field) ?? $default; + $value = $request->getOldInput($field) + ?? $request->getPost($field) + ?? (is_array($default) ? $default : (string) $default); - return ($htmlEscape) ? esc($value) : $value; + return $htmlEscape ? esc($value) : $value; } } diff --git a/system/Helpers/security_helper.php b/system/Helpers/security_helper.php index d73fd6219b12..2a89f6838498 100644 --- a/system/Helpers/security_helper.php +++ b/system/Helpers/security_helper.php @@ -24,6 +24,7 @@ * If it is acceptable for the user input to include relative paths, * e.g. file/in/some/approved/folder.txt, you can set the second optional * parameter, $relativePath to TRUE. + * Only forward slashes are preserved, backslashes are always removed. * * @param string $filename Input file name * @param bool $relativePath Whether to preserve paths @@ -32,6 +33,7 @@ function sanitize_filename(string $filename, bool $relativePath = false): string { // List of sanitized filename strings $bad = [ + '\\', '../', '', @@ -77,7 +79,7 @@ function sanitize_filename(string $filename, bool $relativePath = false): string $filename = str_replace($bad, '', $filename); } while ($old !== $filename); - return stripslashes($filename); + return $filename; } } diff --git a/system/Honeypot/Honeypot.php b/system/Honeypot/Honeypot.php index 4a40a8ec232a..a26131d69b73 100644 --- a/system/Honeypot/Honeypot.php +++ b/system/Honeypot/Honeypot.php @@ -21,8 +21,6 @@ /** * class Honeypot - * - * @see \CodeIgniter\Honeypot\HoneypotTest */ class Honeypot { diff --git a/system/HotReloader/DirectoryHasher.php b/system/HotReloader/DirectoryHasher.php index ab9ab7a017a4..6bc1e6b50390 100644 --- a/system/HotReloader/DirectoryHasher.php +++ b/system/HotReloader/DirectoryHasher.php @@ -21,7 +21,6 @@ /** * @internal - * @see \CodeIgniter\HotReloader\DirectoryHasherTest */ final class DirectoryHasher { diff --git a/system/I18n/Time.php b/system/I18n/Time.php index cc254dba03ea..c5f9ffc3e557 100644 --- a/system/I18n/Time.php +++ b/system/I18n/Time.php @@ -41,8 +41,6 @@ * @property-read string $year * * @phpstan-consistent-constructor - * - * @see \CodeIgniter\I18n\TimeTest */ class Time extends DateTimeImmutable implements Stringable { diff --git a/system/I18n/TimeDifference.php b/system/I18n/TimeDifference.php index b51baf252472..10f31d436c2b 100644 --- a/system/I18n/TimeDifference.php +++ b/system/I18n/TimeDifference.php @@ -24,8 +24,6 @@ * @property-read int $seconds * @property-read float|int $weeks * @property-read float|int $years - * - * @see \CodeIgniter\I18n\TimeDifferenceTest */ class TimeDifference { diff --git a/system/I18n/TimeLegacy.php b/system/I18n/TimeLegacy.php index 119fc3f5d050..3bf92b1d624a 100644 --- a/system/I18n/TimeLegacy.php +++ b/system/I18n/TimeLegacy.php @@ -44,7 +44,6 @@ * @phpstan-consistent-constructor * * @deprecated Use Time instead. - * @see \CodeIgniter\I18n\TimeLegacyTest */ class TimeLegacy extends DateTime { diff --git a/system/I18n/TimeTrait.php b/system/I18n/TimeTrait.php index 16454c5f57b4..242ce12570e2 100644 --- a/system/I18n/TimeTrait.php +++ b/system/I18n/TimeTrait.php @@ -1237,8 +1237,6 @@ public function __isset($name): bool } /** - * This is called when we unserialize the Time object. - * * @param array $data */ public function __unserialize(array $data): void @@ -1247,6 +1245,16 @@ public function __unserialize(array $data): void $timezone = $data['timezone'] ?? null; assert(is_string($date) && is_string($timezone)); - parent::__construct($date, new DateTimeZone($timezone)); + $locale = $data["\0*\0locale"] ?? null; + $toStringFormat = $data["\0*\0toStringFormat"] ?? null; + + $this->timezone = new DateTimeZone($timezone); + $this->locale = is_string($locale) ? $locale : Locale::getDefault(); + + if (is_string($toStringFormat)) { + $this->toStringFormat = $toStringFormat; + } + + parent::__construct($date, $this->timezone); } } diff --git a/system/Images/Handlers/ImageMagickHandler.php b/system/Images/Handlers/ImageMagickHandler.php index 3215c84f1ab3..4a92ddf7900b 100644 --- a/system/Images/Handlers/ImageMagickHandler.php +++ b/system/Images/Handlers/ImageMagickHandler.php @@ -318,13 +318,10 @@ public function save(?string $target = null, int $quality = 90): bool } $this->ensureResource(); - $this->resource->setImageCompressionQuality($quality); - if ($target !== null) { - $extension = pathinfo($target, PATHINFO_EXTENSION); - $this->resource->setImageFormat($extension); - } + $extension = pathinfo($target, PATHINFO_EXTENSION); + $this->resource->setImageFormat($extension); try { $result = $this->resource->writeImage($target); diff --git a/system/Images/Image.php b/system/Images/Image.php index e634b914016a..9a396241c158 100644 --- a/system/Images/Image.php +++ b/system/Images/Image.php @@ -18,8 +18,6 @@ /** * Encapsulation of an Image file - * - * @see \CodeIgniter\Images\ImageTest */ class Image extends File { @@ -75,7 +73,7 @@ public function copy(string $targetPath, ?string $targetName = null, int $perms $targetName ??= $this->getFilename(); - if ($targetName === null || $targetName === '') { + if ($targetName === '') { throw ImageException::forInvalidFile($targetName); } diff --git a/system/Language/Language.php b/system/Language/Language.php index 1964c91aebd9..49cfd85b9966 100644 --- a/system/Language/Language.php +++ b/system/Language/Language.php @@ -22,8 +22,6 @@ * Locale-based, built on top of PHP internationalization. * * @phpstan-type LoadedStrings array|string>|string|list> - * - * @see \CodeIgniter\Language\LanguageTest */ class Language { diff --git a/system/Language/en/CLI.php b/system/Language/en/CLI.php index 822186dba926..c2155c0f08bd 100644 --- a/system/Language/en/CLI.php +++ b/system/Language/en/CLI.php @@ -53,6 +53,7 @@ 'helpDescription' => 'Description:', 'helpOptions' => 'Options:', 'helpUsage' => 'Usage:', + 'inputEnded' => 'The input ended before a valid answer was given.', 'invalidColor' => 'Invalid "{0}" color: "{1}".', 'namespaceNotDefined' => 'Namespace "{0}" is not defined.', 'signals' => [ diff --git a/system/Language/en/Format.php b/system/Language/en/Format.php index 7d792e2b6128..1f217798afe3 100644 --- a/system/Language/en/Format.php +++ b/system/Language/en/Format.php @@ -16,5 +16,6 @@ 'invalidFormatter' => '"{0}" is not a valid Formatter class.', 'invalidJSON' => 'Failed to parse JSON string. Error: {0}', 'invalidMime' => 'No Formatter defined for mime type: "{0}".', + 'invalidXMLValue' => 'Cannot convert the value of type "{0}" to a string for XML output.', 'missingExtension' => 'The SimpleXML extension is required to format XML.', ]; diff --git a/system/Log/Handlers/ChromeLoggerHandler.php b/system/Log/Handlers/ChromeLoggerHandler.php index ce8e97a12750..df3666111c9d 100644 --- a/system/Log/Handlers/ChromeLoggerHandler.php +++ b/system/Log/Handlers/ChromeLoggerHandler.php @@ -20,7 +20,6 @@ * Requires the ChromeLogger extension installed in your browser. * * @see https://craig.is/writing/chrome-logger - * @see \CodeIgniter\Log\Handlers\ChromeLoggerHandlerTest */ class ChromeLoggerHandler extends BaseHandler { diff --git a/system/Log/Handlers/ErrorlogHandler.php b/system/Log/Handlers/ErrorlogHandler.php index a7e820419fee..bf05a8e95596 100644 --- a/system/Log/Handlers/ErrorlogHandler.php +++ b/system/Log/Handlers/ErrorlogHandler.php @@ -17,8 +17,6 @@ /** * Log handler that writes to PHP's `error_log()` - * - * @see \CodeIgniter\Log\Handlers\ErrorlogHandlerTest */ class ErrorlogHandler extends BaseHandler { diff --git a/system/Log/Logger.php b/system/Log/Logger.php index 6b61837fa22b..02ecf1e97951 100644 --- a/system/Log/Logger.php +++ b/system/Log/Logger.php @@ -32,8 +32,6 @@ * The context array can contain arbitrary data, the only assumption that * can be made by implementors is that if an Exception instance is given * to produce a stack trace, it MUST be in a key named "exception". - * - * @see \CodeIgniter\Log\LoggerTest */ class Logger implements LoggerInterface { diff --git a/system/Model.php b/system/Model.php index c73cdba448c7..55c35895fd67 100644 --- a/system/Model.php +++ b/system/Model.php @@ -695,6 +695,22 @@ protected function doProtectFieldsForInsert(array $row): array return $row; } + protected function doProtectFieldsForReplace(array $row): array + { + $primaryKey = array_intersect_key($row, [$this->primaryKey => true]); + $protectedRow = $this->doProtectFields($row); + + // Reject data reduced to an identifier, but preserve explicit identifier-only calls. + if ($row !== $primaryKey && array_diff_key($protectedRow, $primaryKey) === []) { + throw DataException::forEmptyDataset('replace'); + } + + $protectedRow += $primaryKey; + + // PostgreSQL's replace() uses the first field to match an existing row. + return array_replace(array_intersect_key($row, $protectedRow), $protectedRow); + } + public function update($id = null, $row = null): bool { if (isset($this->tempData['data'])) { diff --git a/system/Pager/Pager.php b/system/Pager/Pager.php index 4537f1d43db3..6a17a07aeee6 100644 --- a/system/Pager/Pager.php +++ b/system/Pager/Pager.php @@ -26,8 +26,6 @@ * pagination links and reading the current url's query variable, "page" * to determine the current page. This class can support multiple * paginations on a single page. - * - * @see \CodeIgniter\Pager\PagerTest */ class Pager implements PagerInterface { diff --git a/system/Pager/PagerRenderer.php b/system/Pager/PagerRenderer.php index 70959c820ea4..e69b56deebf7 100644 --- a/system/Pager/PagerRenderer.php +++ b/system/Pager/PagerRenderer.php @@ -21,8 +21,6 @@ * This class is passed to the view that describes the pagination, * and is used to get the link information and provide utility * methods needed to work with pagination. - * - * @see \CodeIgniter\Pager\PagerRendererTest */ class PagerRenderer { diff --git a/system/Publisher/ContentReplacer.php b/system/Publisher/ContentReplacer.php index b263b1a18d7f..d61aac34bbbc 100644 --- a/system/Publisher/ContentReplacer.php +++ b/system/Publisher/ContentReplacer.php @@ -17,8 +17,6 @@ /** * Replace Text Content - * - * @see \CodeIgniter\Publisher\ContentReplacerTest */ class ContentReplacer { diff --git a/system/RESTful/ResourceController.php b/system/RESTful/ResourceController.php index ed72df212b07..549ec10ea940 100644 --- a/system/RESTful/ResourceController.php +++ b/system/RESTful/ResourceController.php @@ -18,8 +18,6 @@ /** * An extendable controller to provide a RESTful API for a resource. - * - * @see \CodeIgniter\RESTful\ResourceControllerTest */ class ResourceController extends BaseResource { diff --git a/system/RESTful/ResourcePresenter.php b/system/RESTful/ResourcePresenter.php index 00bd54ed035a..890f087d8d1b 100644 --- a/system/RESTful/ResourcePresenter.php +++ b/system/RESTful/ResourcePresenter.php @@ -17,8 +17,6 @@ /** * An extendable controller to help provide a UI for a resource. - * - * @see \CodeIgniter\RESTful\ResourcePresenterTest */ class ResourcePresenter extends BaseResource { diff --git a/system/Router/AutoRouterImproved.php b/system/Router/AutoRouterImproved.php index c7dbf04efec5..15b6cbb07719 100644 --- a/system/Router/AutoRouterImproved.php +++ b/system/Router/AutoRouterImproved.php @@ -21,8 +21,6 @@ /** * New Secure Router for Auto-Routing - * - * @see \CodeIgniter\Router\AutoRouterImprovedTest */ final class AutoRouterImproved implements AutoRouterInterface { diff --git a/system/Router/DefinedRouteCollector.php b/system/Router/DefinedRouteCollector.php index c66e25c03349..7efd5f1ddf0d 100644 --- a/system/Router/DefinedRouteCollector.php +++ b/system/Router/DefinedRouteCollector.php @@ -18,8 +18,6 @@ /** * Collect all defined routes for display. - * - * @see \CodeIgniter\Router\DefinedRouteCollectorTest */ final readonly class DefinedRouteCollector { diff --git a/system/Router/RouteCollection.php b/system/Router/RouteCollection.php index c08c01530b79..181229cba6dd 100644 --- a/system/Router/RouteCollection.php +++ b/system/Router/RouteCollection.php @@ -25,7 +25,6 @@ /** * @todo Implement nested resource routing (See CakePHP) - * @see \CodeIgniter\Router\RouteCollectionTest */ class RouteCollection implements RouteCollectionInterface { @@ -657,8 +656,8 @@ public function getRedirectCode(string $routeKey): int * $route->resource('users'); * }); * - * @param string $name The name to group/prefix the routes with. - * @param array|(callable(self): void) ...$params + * @param string $name The name to group/prefix the routes with. + * @param array|(callable(static): void) ...$params * * @return void */ diff --git a/system/Router/Router.php b/system/Router/Router.php index 6e5a4630832b..f0f9555ad1ac 100644 --- a/system/Router/Router.php +++ b/system/Router/Router.php @@ -32,8 +32,6 @@ /** * Request router. - * - * @see \CodeIgniter\Router\RouterTest */ class Router implements RouterInterface { diff --git a/system/Security/CheckPhpIni.php b/system/Security/CheckPhpIni.php index d1ab3dfccda0..04bcb3e900d0 100644 --- a/system/Security/CheckPhpIni.php +++ b/system/Security/CheckPhpIni.php @@ -20,7 +20,6 @@ * Checks php.ini settings in production environment. * * @used-by \CodeIgniter\Commands\Utilities\PhpIniCheck - * @see \CodeIgniter\Security\CheckPhpIniTest */ class CheckPhpIni { diff --git a/system/Security/Security.php b/system/Security/Security.php index 4ac0de3f8ff8..44e96be29016 100644 --- a/system/Security/Security.php +++ b/system/Security/Security.php @@ -33,8 +33,6 @@ * * Provides methods that help protect your site against * Cross-Site Request Forgery attacks. - * - * @see \CodeIgniter\Security\SecurityTest */ class Security implements SecurityInterface { @@ -295,23 +293,24 @@ private function removeTokenInRequest(IncomingRequest $request): void try { $json = json_decode($body, flags: JSON_THROW_ON_ERROR); } catch (JsonException) { - $json = null; - } + // The body is not valid JSON - check for form-encoded data. + parse_str($body, $result); - if (is_object($json)) { - if (property_exists($json, $tokenName)) { - unset($json->{$tokenName}); - $request->setBody(json_encode($json)); + if (! array_key_exists($tokenName, $result)) { + return; } + unset($result[$tokenName]); + $request->setBody(http_build_query($result)); + return; } - // If the token is found in form-encoded data, we can safely remove it. - parse_str($body, $result); - - unset($result[$tokenName]); - $request->setBody(http_build_query($result)); + // Valid JSON never falls through to form-encoded processing. + if (is_object($json) && property_exists($json, $tokenName)) { + unset($json->{$tokenName}); + $request->setBody(json_encode($json)); + } } private function getPostedToken(IncomingRequest $request): ?string diff --git a/system/Session/Handlers/Database/MySQLiHandler.php b/system/Session/Handlers/Database/MySQLiHandler.php index a15f17bc904f..453ebb1f3cc9 100644 --- a/system/Session/Handlers/Database/MySQLiHandler.php +++ b/system/Session/Handlers/Database/MySQLiHandler.php @@ -17,8 +17,6 @@ /** * Session handler for MySQLi - * - * @see \CodeIgniter\Session\Handlers\Database\MySQLiHandlerTest */ class MySQLiHandler extends DatabaseHandler { diff --git a/system/Session/Handlers/Database/PostgreHandler.php b/system/Session/Handlers/Database/PostgreHandler.php index 1471d44d1860..d8c6d3c8b794 100644 --- a/system/Session/Handlers/Database/PostgreHandler.php +++ b/system/Session/Handlers/Database/PostgreHandler.php @@ -18,8 +18,6 @@ /** * Session handler for Postgre - * - * @see \CodeIgniter\Session\Handlers\Database\PostgreHandlerTest */ class PostgreHandler extends DatabaseHandler { diff --git a/system/Session/Session.php b/system/Session/Session.php index 65b1e87558d8..1cb47c2ee260 100644 --- a/system/Session/Session.php +++ b/system/Session/Session.php @@ -27,8 +27,6 @@ * variables in `Сonfig\Session`. * * @property string $session_id - * - * @see \CodeIgniter\Session\SessionTest */ class Session implements SessionInterface { diff --git a/system/Superglobals.php b/system/Superglobals.php index ac0ea289bd77..7049d035785f 100644 --- a/system/Superglobals.php +++ b/system/Superglobals.php @@ -38,7 +38,6 @@ * @phpstan-type request_items array|string * * @internal - * @see \CodeIgniter\SuperglobalsTest */ final class Superglobals { @@ -387,6 +386,51 @@ public function setRequestArray(array $array): self return $this; } + /** + * Returns the merged $_GET, $_POST, and $_COOKIE data according to the + * `request_order` (or `variables_order`) ini setting, without mutating + * $_REQUEST. + * + * PHP populates $_REQUEST only once at the start of the request. When + * $_GET is modified later (e.g. by SiteURIFactory), $_REQUEST becomes + * stale. This method returns the current merged values so callers can + * read up-to-date request data without relying on the stale $_REQUEST. + * + * @param string|null $requestOrder Overrides the `request_order` ini + * setting. Useful for testing, since the + * ini setting cannot be changed at runtime. + * + * @return array + */ + public function getRequestData(?string $requestOrder = null): array + { + $requestOrder ??= (string) ini_get('request_order'); + + if ($requestOrder === '') { + $requestOrder = (string) ini_get('variables_order'); + } + + if ($requestOrder === '') { + $requestOrder = 'GP'; + } + + $request = []; + + foreach (array_unique(str_split(strtoupper($requestOrder))) as $type) { + match ($type) { + // array_replace_recursive() matches PHP's own $_REQUEST merge + // (php_autoglobal_merge): numeric keys are preserved and + // array values are merged recursively. + 'G' => $request = array_replace_recursive($request, $this->get), + 'P' => $request = array_replace_recursive($request, $this->post), + 'C' => $request = array_replace_recursive($request, $this->cookie), + default => null, + }; + } + + return $request; + } + /** * Get all $_FILES values. * diff --git a/system/Test/DOMParser.php b/system/Test/DOMParser.php index 0afddfa4425e..ad7d483ab22d 100644 --- a/system/Test/DOMParser.php +++ b/system/Test/DOMParser.php @@ -21,8 +21,6 @@ /** * Load a response into a DOMDocument for testing assertions based on that - * - * @see \CodeIgniter\Test\DOMParserTest */ class DOMParser { diff --git a/system/Test/Fabricator.php b/system/Test/Fabricator.php index c0b925d3cf66..93e37f12c9fc 100644 --- a/system/Test/Fabricator.php +++ b/system/Test/Fabricator.php @@ -29,8 +29,6 @@ * * Bridge class for using Faker to create example data based on * model specifications. - * - * @see \CodeIgniter\Test\FabricatorTest */ class Fabricator { diff --git a/system/Test/Mock/MockInputOutput.php b/system/Test/Mock/MockInputOutput.php index 9bf8a4f54810..6f6b49674d5e 100644 --- a/system/Test/Mock/MockInputOutput.php +++ b/system/Test/Mock/MockInputOutput.php @@ -160,6 +160,14 @@ public function input(?string $prefix = null): string return $input; } + /** + * Whether the scripted answers have run out, so that a scripted empty answer before the last stays an empty line. + */ + public function isAtEnd(): bool + { + return $this->inputs === []; + } + public function fwrite($handle, string $string): void { $this->addStreamFilters(); diff --git a/system/Test/PhpStreamWrapper.php b/system/Test/PhpStreamWrapper.php index 927821e8b4dd..65930a66cd81 100644 --- a/system/Test/PhpStreamWrapper.php +++ b/system/Test/PhpStreamWrapper.php @@ -60,6 +60,14 @@ public function stream_open(): bool return true; } + /** + * Returns `false`, since the stream has no descriptor. + */ + public function stream_cast(): false + { + return false; + } + /** * @return string */ diff --git a/system/Test/TestLogger.php b/system/Test/TestLogger.php index bbd0839e93b2..a13657748e53 100644 --- a/system/Test/TestLogger.php +++ b/system/Test/TestLogger.php @@ -16,9 +16,6 @@ use CodeIgniter\Log\Logger; use Stringable; -/** - * @see \CodeIgniter\Test\TestLoggerTest - */ class TestLogger extends Logger { /** diff --git a/system/Test/TestResponse.php b/system/Test/TestResponse.php index 580f938d77af..6a3a38ac88d5 100644 --- a/system/Test/TestResponse.php +++ b/system/Test/TestResponse.php @@ -25,8 +25,6 @@ * for test results. * * @mixin DOMParser - * - * @see \CodeIgniter\Test\TestResponseTest */ class TestResponse { diff --git a/system/Typography/Typography.php b/system/Typography/Typography.php index 66a13a0b3f06..0d30edc66aa7 100644 --- a/system/Typography/Typography.php +++ b/system/Typography/Typography.php @@ -17,8 +17,6 @@ /** * Typography Class - * - * @see \CodeIgniter\Typography\TypographyTest */ class Typography { diff --git a/system/Validation/CreditCardRules.php b/system/Validation/CreditCardRules.php index 0bc20f082123..e270a6ba9f78 100644 --- a/system/Validation/CreditCardRules.php +++ b/system/Validation/CreditCardRules.php @@ -19,7 +19,6 @@ * Provides validation methods for common credit-card inputs. * * @see http://en.wikipedia.org/wiki/Credit_card_number - * @see \CodeIgniter\Validation\CreditCardRulesTest */ class CreditCardRules { diff --git a/system/Validation/DotArrayFilter.php b/system/Validation/DotArrayFilter.php index 8e059c3661ae..bc2eecc497a6 100644 --- a/system/Validation/DotArrayFilter.php +++ b/system/Validation/DotArrayFilter.php @@ -13,9 +13,6 @@ namespace CodeIgniter\Validation; -/** - * @see \CodeIgniter\Validation\DotArrayFilterTest - */ final class DotArrayFilter { /** diff --git a/system/Validation/FileRules.php b/system/Validation/FileRules.php index b65b4da63085..fe8de6a1d5bf 100644 --- a/system/Validation/FileRules.php +++ b/system/Validation/FileRules.php @@ -17,8 +17,6 @@ /** * File validation rules - * - * @see \CodeIgniter\Validation\FileRulesTest */ class FileRules extends StrictFileRules { diff --git a/system/Validation/FormatRules.php b/system/Validation/FormatRules.php index e28ef97706f3..11f11909f0bf 100644 --- a/system/Validation/FormatRules.php +++ b/system/Validation/FormatRules.php @@ -17,8 +17,6 @@ /** * Format validation Rules. - * - * @see \CodeIgniter\Validation\FormatRulesTest */ class FormatRules { diff --git a/system/Validation/Rules.php b/system/Validation/Rules.php index c48465b327e0..ef9832f7ccd0 100644 --- a/system/Validation/Rules.php +++ b/system/Validation/Rules.php @@ -20,8 +20,6 @@ /** * Validation Rules. - * - * @see \CodeIgniter\Validation\RulesTest */ class Rules { diff --git a/system/Validation/StrictRules/CreditCardRules.php b/system/Validation/StrictRules/CreditCardRules.php index fd28e4454095..feded5cbe447 100644 --- a/system/Validation/StrictRules/CreditCardRules.php +++ b/system/Validation/StrictRules/CreditCardRules.php @@ -21,7 +21,6 @@ * Provides validation methods for common credit-card inputs. * * @see http://en.wikipedia.org/wiki/Credit_card_number - * @see \CodeIgniter\Validation\StrictRules\CreditCardRulesTest */ class CreditCardRules { diff --git a/system/Validation/StrictRules/FileRules.php b/system/Validation/StrictRules/FileRules.php index f84275432d54..2911042b15bf 100644 --- a/system/Validation/StrictRules/FileRules.php +++ b/system/Validation/StrictRules/FileRules.php @@ -22,8 +22,6 @@ /** * File validation rules - * - * @see \CodeIgniter\Validation\StrictRules\FileRulesTest */ class FileRules { diff --git a/system/Validation/StrictRules/FormatRules.php b/system/Validation/StrictRules/FormatRules.php index 8557a6de070d..44ada03899a7 100644 --- a/system/Validation/StrictRules/FormatRules.php +++ b/system/Validation/StrictRules/FormatRules.php @@ -17,8 +17,6 @@ /** * Format validation Rules. - * - * @see \CodeIgniter\Validation\StrictRules\FormatRulesTest */ class FormatRules { diff --git a/system/Validation/StrictRules/Rules.php b/system/Validation/StrictRules/Rules.php index cca4fdf494be..f5c31ca16c3e 100644 --- a/system/Validation/StrictRules/Rules.php +++ b/system/Validation/StrictRules/Rules.php @@ -18,8 +18,6 @@ /** * Validation Rules. - * - * @see \CodeIgniter\Validation\StrictRules\RulesTest */ class Rules { diff --git a/system/Validation/Validation.php b/system/Validation/Validation.php index b7c702aee6bc..18c7f913f2c7 100644 --- a/system/Validation/Validation.php +++ b/system/Validation/Validation.php @@ -25,8 +25,6 @@ /** * Validator - * - * @see \CodeIgniter\Validation\ValidationTest */ class Validation implements ValidationInterface { diff --git a/system/View/Cell.php b/system/View/Cell.php index 7b0a15853b2f..855ce7dd5c58 100644 --- a/system/View/Cell.php +++ b/system/View/Cell.php @@ -44,8 +44,6 @@ * class Class { * function method(array $params=null) * } - * - * @see \CodeIgniter\View\CellTest */ class Cell { diff --git a/system/View/Parser.php b/system/View/Parser.php index 8aaa1c723493..21b3fe47b6e3 100644 --- a/system/View/Parser.php +++ b/system/View/Parser.php @@ -20,8 +20,6 @@ /** * Class for parsing pseudo-vars - * - * @see \CodeIgniter\View\ParserTest */ class Parser extends View { diff --git a/system/View/Table.php b/system/View/Table.php index 5d4958088b2d..12de27a831cd 100644 --- a/system/View/Table.php +++ b/system/View/Table.php @@ -19,8 +19,6 @@ * HTML Table Generating Class * * Lets you create tables manually or from database result objects, or arrays. - * - * @see \CodeIgniter\View\TableTest */ class Table { diff --git a/system/View/View.php b/system/View/View.php index ea12ec45bb76..6cdef5689c4a 100644 --- a/system/View/View.php +++ b/system/View/View.php @@ -24,8 +24,6 @@ /** * Class View - * - * @see \CodeIgniter\View\ViewTest */ class View implements RendererInterface { diff --git a/tests/_support/Cache/IsolatedCacheTrait.php b/tests/_support/Cache/IsolatedCacheTrait.php new file mode 100644 index 000000000000..19ee60f55ae9 --- /dev/null +++ b/tests/_support/Cache/IsolatedCacheTrait.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Cache; + +use CodeIgniter\Cache\FactoriesCache\FileVarExportHandler; + +/** + * Gives the test class its own directory for the files created + * by the FileVarExportHandler. + * + * The handler stores its files in the shared "writable/cache" directory by + * default. Since the random test execution runs the components in parallel, + * one component may delete the cache file that another component is + * asserting on. A dedicated directory per test class avoids that. + */ +trait IsolatedCacheTrait +{ + /** + * The directory used to store the cache files of the test class. + */ + private static string $isolatedCachePath; + + public static function setUpBeforeClassIsolatedCache(): void + { + helper('filesystem'); + + self::$isolatedCachePath = WRITEPATH . 'cache_test_' . str_replace('\\', '_', static::class); + + // Remove the files left over by a previous run. + if (is_dir(self::$isolatedCachePath)) { + delete_files(self::$isolatedCachePath, true); + @rmdir(self::$isolatedCachePath); + } + } + + public static function tearDownAfterClassIsolatedCache(): void + { + if (! isset(self::$isolatedCachePath) || ! is_dir(self::$isolatedCachePath)) { + return; + } + + delete_files(self::$isolatedCachePath, true); + @rmdir(self::$isolatedCachePath); + } + + /** + * Returns a handler that stores its files in the isolated directory. + */ + protected function getIsolatedCacheHandler(): FileVarExportHandler + { + return new FileVarExportHandler(self::$isolatedCachePath); + } + + /** + * Returns the path of the isolated cache directory. + */ + protected static function getIsolatedCachePath(): string + { + return self::$isolatedCachePath; + } +} diff --git a/tests/_support/Commands/LanguageCommand.php b/tests/_support/Commands/LanguageCommand.php index 17fba1881c94..c8a48325ae41 100644 --- a/tests/_support/Commands/LanguageCommand.php +++ b/tests/_support/Commands/LanguageCommand.php @@ -27,6 +27,7 @@ class LanguageCommand extends BaseCommand protected $options = [ '--lang' => 'The language folder to save the file.', '--sort' => 'Turn on/off the sortImports flag.', + '--crlf' => 'Use CRLF line endings in the template.', ]; public function run(array $params): void @@ -46,6 +47,8 @@ public function run(array $params): void protected function prepare(string $class): string { - return file_get_contents(__DIR__ . '/Foobar.php') ?: ''; + $template = file_get_contents(__DIR__ . '/Foobar.php') ?: ''; + + return $this->getOption('crlf') === true ? str_replace("\n", "\r\n", $template) : $template; } } diff --git a/tests/_support/Router/RouteCollectionSubclass.php b/tests/_support/Router/RouteCollectionSubclass.php new file mode 100644 index 000000000000..c2623111046f --- /dev/null +++ b/tests/_support/Router/RouteCollectionSubclass.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Router; + +use CodeIgniter\Router\RouteCollection; + +final class RouteCollectionSubclass extends RouteCollection +{ +} diff --git a/tests/system/AutoReview/UpdateUpgradeGuideTest.php b/tests/system/AutoReview/UpdateUpgradeGuideTest.php index f982ac375dfa..bc7e2fc07cd3 100644 --- a/tests/system/AutoReview/UpdateUpgradeGuideTest.php +++ b/tests/system/AutoReview/UpdateUpgradeGuideTest.php @@ -75,7 +75,7 @@ public function testErrorWhenUpgradeGuideIsMissing(): void public function testDryRunPrintsSectionsWithoutWriting(): void { - $this->assertFileExists($this->upgradePath); + $this->skipIfUpgradeGuideIsMissing(); exec(sprintf('php ./admin/update-upgrade-guide.php %s --dry-run 2>&1', $this->nextVersion), $output, $exitCode); $outputString = implode("\n", $output); @@ -88,7 +88,7 @@ public function testDryRunPrintsSectionsWithoutWriting(): void public function testUpdatesUpgradeGuide(): void { - $this->assertFileExists($this->upgradePath); + $this->skipIfUpgradeGuideIsMissing(); if (trim((string) exec("git status --porcelain -- {$this->upgradePath}")) !== '') { $this->markTestSkipped('You have uncommitted changes to the upgrade guide that will be erased by this test.'); @@ -110,4 +110,14 @@ public function testUpdatesUpgradeGuide(): void exec("git restore -- {$this->upgradePath}"); } } + + private function skipIfUpgradeGuideIsMissing(): void + { + if (! is_file($this->upgradePath)) { + $this->markTestSkipped(sprintf( + '%s does not exist yet. It is created by "php admin/create-new-changelog.php" after a release.', + $this->upgradePath, + )); + } + } } diff --git a/tests/system/Autoloader/FileLocatorCachedTest.php b/tests/system/Autoloader/FileLocatorCachedTest.php index 9700f88bdb33..0a5f3a3c586d 100644 --- a/tests/system/Autoloader/FileLocatorCachedTest.php +++ b/tests/system/Autoloader/FileLocatorCachedTest.php @@ -17,6 +17,7 @@ use Config\Autoload; use Config\Modules; use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Cache\IsolatedCacheTrait; /** * @internal @@ -24,18 +25,22 @@ #[Group('Others')] final class FileLocatorCachedTest extends FileLocatorTest { + use IsolatedCacheTrait; + private FileVarExportHandler $handler; + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + + self::setUpBeforeClassIsolatedCache(); + } + public static function tearDownAfterClass(): void { - parent::tearDownAfterClass(); + self::tearDownAfterClassIsolatedCache(); - // Delete cache file. - $autoloader = new Autoloader(); - $handler = new FileVarExportHandler(); - $fileLocator = new FileLocator($autoloader); - $locator = new FileLocatorCached($fileLocator, $handler); - $locator->deleteCache(); + parent::tearDownAfterClass(); } protected function setUp(): void @@ -61,7 +66,7 @@ protected function setUp(): void 'Acme\Sample' => TESTPATH . '_support/does/not/exists', ]); - $this->handler = new FileVarExportHandler(); + $this->handler = $this->getIsolatedCacheHandler(); $fileLocator = new FileLocator($autoloader); $this->locator = new FileLocatorCached($fileLocator, $this->handler); } @@ -70,6 +75,10 @@ protected function tearDown(): void { $this->locator->__destruct(); + // Prevent the destructor from writing the cache file + // when the test object is destroyed. + $this->locator->deleteCache(); + parent::tearDown(); } diff --git a/tests/system/CLI/CLITest.php b/tests/system/CLI/CLITest.php index 9c697b25afb9..de58b6cede9b 100644 --- a/tests/system/CLI/CLITest.php +++ b/tests/system/CLI/CLITest.php @@ -13,6 +13,7 @@ namespace CodeIgniter\CLI; +use CodeIgniter\CLI\Exceptions\CLIException; use CodeIgniter\Config\Services; use CodeIgniter\Exceptions\RuntimeException; use CodeIgniter\Superglobals; @@ -405,6 +406,104 @@ public function testPromptByMultipleKeysInputZero(): void $this->assertSame($expected, $output); } + public function testPromptByMultipleKeysAsksAgainForAMissingKeyBelowTheLast(): void + { + $io = new MockInputOutput(); + $io->setInputs(['1', '2']); + CLI::setInputOutput($io); + + $output = CLI::promptByMultipleKeys('Select your hobbies:', [0 => 'Playing game', 2 => 'Badminton']); + + CLI::resetInputOutput(); + + $this->assertSame([2 => 'Badminton'], $output); + $this->assertSame(1, substr_count($io->getOutput(), 'Please select correctly.')); + } + + public function testPromptByMultipleKeysAcceptsAKeyAboveTheLast(): void + { + $io = new MockInputOutput(); + $io->setInputs(['5']); + CLI::setInputOutput($io); + + $output = CLI::promptByMultipleKeys('Select your hobbies:', [0 => 'Playing game', 5 => 'Sleep', 2 => 'Badminton']); + + CLI::resetInputOutput(); + + $this->assertSame([5 => 'Sleep'], $output); + $this->assertStringNotContainsString('Please select correctly.', $io->getOutput()); + } + + public function testPromptByMultipleKeysDefaultsToTheFirstKey(): void + { + $io = new MockInputOutput(); + $io->setInputs(['']); + CLI::setInputOutput($io); + + $output = CLI::promptByMultipleKeys('Select your hobbies:', [2 => 'Badminton', 5 => 'Sleep']); + + CLI::resetInputOutput(); + + $this->assertSame([2 => 'Badminton'], $output); + $this->assertStringContainsString('[2, 5] : ', preg_replace('/\e\[[^m]+m/', '', $io->getOutput()) ?? ''); + $this->assertStringNotContainsString('Please select correctly.', $io->getOutput()); + } + + public function testInputReportsTheEndOfTheInput(): void + { + $io = new InputOutput(); + + PhpStreamWrapper::register(); + PhpStreamWrapper::setContent('red'); + $io->input(); + $this->assertFalse($io->isAtEnd()); + + PhpStreamWrapper::setContent(''); + $io->input(); + $this->assertTrue($io->isAtEnd()); + + PhpStreamWrapper::restore(); + } + + /** + * @param list $inputs + * @param callable(): mixed $prompt + */ + #[DataProvider('providePromptsStopAskingWhenTheInputEnds')] + public function testPromptsStopAskingWhenTheInputEnds(array $inputs, callable $prompt): void + { + $io = new MockInputOutput(); + $io->setInputs($inputs); + CLI::setInputOutput($io); + + $this->expectException(CLIException::class); + $this->expectExceptionMessage('The input ended before a valid answer was given.'); + + $prompt(); + } + + /** + * @return iterable, callable(): mixed}> + */ + public static function providePromptsStopAskingWhenTheInputEnds(): iterable + { + yield 'prompt' => [[''], static fn (): string => CLI::prompt('Name', null, 'required')]; + + yield 'promptByKey' => [['c'], static fn (): string => CLI::promptByKey('Pick:', ['a' => 'Apple', 'b' => 'Banana'], 'in_list[b]')]; + + yield 'promptByMultipleKeys' => [['9'], static fn (): array => CLI::promptByMultipleKeys('Pick:', ['a' => 'Apple', 'b' => 'Banana'])]; + } + + public function testMockInputOutputKeepsAnEmptyAnswerBeforeTheLastAsAnEmptyLine(): void + { + $io = new MockInputOutput(); + $io->setInputs(['', 'red']); + CLI::setInputOutput($io); + + $this->assertSame('red', CLI::prompt('Color', null, 'required')); + $this->assertTrue($io->isAtEnd()); + } + public function testNewLine(): void { $this->expectOutputString(''); diff --git a/tests/system/Cache/FactoriesCacheFileVarExportHandlerTest.php b/tests/system/Cache/FactoriesCacheFileVarExportHandlerTest.php index e09816dba2fa..c7a9f14e9d94 100644 --- a/tests/system/Cache/FactoriesCacheFileVarExportHandlerTest.php +++ b/tests/system/Cache/FactoriesCacheFileVarExportHandlerTest.php @@ -15,6 +15,7 @@ use CodeIgniter\Cache\FactoriesCache\FileVarExportHandler; use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Cache\IsolatedCacheTrait; /** * @internal @@ -22,27 +23,42 @@ #[Group('Others')] final class FactoriesCacheFileVarExportHandlerTest extends AbstractFactoriesCacheHandlerTestCase { + use IsolatedCacheTrait; + public static function setUpBeforeClass(): void { parent::setUpBeforeClass(); - helper('filesystem'); + self::setUpBeforeClassIsolatedCache(); + } + + public static function tearDownAfterClass(): void + { + self::tearDownAfterClassIsolatedCache(); + + parent::tearDownAfterClass(); } protected function createFactoriesCache(): void { - $this->handler = new FileVarExportHandler(); + $this->handler = $this->getIsolatedCacheHandler(); $this->cache = new FactoriesCache($this->handler); } + public function testUsesTheWritableCacheDirectoryByDefault(): void + { + $handler = new FileVarExportHandler(); + + $this->assertSame(WRITEPATH . 'cache', $this->getPrivateProperty($handler, 'path')); + } + public function testSaveCreatesDirectoryAndFileWithCorrectPermissions(): void { $dir = WRITEPATH . 'cache_test_dir_' . uniqid('', true); $oldUmask = umask(0000); try { - $handler = new FileVarExportHandler(); - $this->setPrivateProperty($handler, 'path', $dir); + $handler = new FileVarExportHandler($dir); $handler->save('test_key', ['data']); diff --git a/tests/system/Cache/Handlers/FileHandlerTest.php b/tests/system/Cache/Handlers/FileHandlerTest.php index ee26783ad992..310b5a7ebce7 100644 --- a/tests/system/Cache/Handlers/FileHandlerTest.php +++ b/tests/system/Cache/Handlers/FileHandlerTest.php @@ -210,6 +210,132 @@ public function testDeleteMatchingSuffix(): void $this->handler->clean(); } + /** + * @param string $pattern "{victim}" is replaced with the name of a file in the parent of the cache directory + */ + #[DataProvider('provideDeleteMatchingDoesNotDeleteFilesOutsideCacheDirectory')] + public function testDeleteMatchingDoesNotDeleteFilesOutsideCacheDirectory(string $pattern): void + { + $parent = realpath(dirname($this->config->file['storePath'])); + $this->assertNotFalse($parent); + + $victim = tempnam($parent, 'deleteMatchingVictim'); + $this->assertNotFalse($victim); + + try { + // tempnam() falls back to the system temp directory on failure. + $this->assertSame($parent, realpath(dirname($victim))); + + $this->assertTrue($this->handler->save(self::$key1, 'value')); + + $pattern = str_replace('{victim}', basename($victim), $pattern); + + $this->assertSame(0, $this->handler->deleteMatching($pattern)); + $this->assertFileExists($victim); + $this->assertSame('value', $this->handler->get(self::$key1)); + } finally { + if (is_file($victim)) { + unlink($victim); + } + } + } + + /** + * @return iterable + */ + public static function provideDeleteMatchingDoesNotDeleteFilesOutsideCacheDirectory(): iterable + { + yield 'parent directory' => ['../{victim}']; + + yield 'parent directory with wildcard' => ['../{victim}*']; + + yield 'parent directory with backslash' => ['..\\{victim}']; + + yield 'parent directory only' => ['..']; + } + + public function testDeleteMatchingDoesNotDeleteFilesInSubdirectories(): void + { + $directoryName = 'dir_' . bin2hex(random_bytes(8)); + $subdirectory = $this->config->file['storePath'] . '/' . $directoryName . '/'; + + $this->assertTrue(mkdir($subdirectory)); + + try { + $this->assertNotFalse(file_put_contents($subdirectory . 'key_1', 'data')); + + $this->assertSame(0, $this->handler->deleteMatching($directoryName . '/key_*')); + $this->assertFileExists($subdirectory . 'key_1'); + } finally { + if (is_file($subdirectory . 'key_1')) { + unlink($subdirectory . 'key_1'); + } + + rmdir($subdirectory); + } + } + + #[RequiresOperatingSystem('Linux|Darwin')] + public function testDeleteMatchingSupportsEscapedWildcards(): void + { + $this->assertTrue($this->handler->save('key*1', 'value')); + $this->assertTrue($this->handler->save('key_1', 'value')); + + $this->assertSame(1, $this->handler->deleteMatching('key\*1')); + $this->assertNull($this->handler->get('key*1')); + $this->assertSame('value', $this->handler->get('key_1')); + } + + public function testDeleteMatchingTreatsCachePathLiterally(): void + { + // As a glob pattern, "cache[1]" would match the sibling "cache1" instead of itself. + $cachePath = $this->config->file['storePath'] . '/cache[1]/'; + $siblingPath = $this->config->file['storePath'] . '/cache1/'; + + $this->assertTrue(mkdir($cachePath)); + $this->assertTrue(mkdir($siblingPath)); + + try { + $this->assertNotFalse(file_put_contents($siblingPath . 'key_1', 'data')); + + $config = new Cache(); + $config->file['storePath'] = $cachePath; + $handler = CacheFactory::getHandler($config, 'file'); + + $this->assertTrue($handler->save('key_1', 'value')); + + $this->assertSame(1, $handler->deleteMatching('*')); + $this->assertNull($handler->get('key_1')); + $this->assertFileExists($siblingPath . 'key_1'); + } finally { + foreach ([$cachePath, $siblingPath] as $path) { + if (is_file($path . 'key_1')) { + unlink($path . 'key_1'); + } + + rmdir($path); + } + } + } + + public function testDeleteMatchingWildcardDoesNotMatchHiddenFiles(): void + { + $hidden = $this->config->file['storePath'] . '/.hidden'; + + $this->assertNotFalse(file_put_contents($hidden, 'data')); + + try { + $this->assertTrue($this->handler->save(self::$key1, 'value')); + + $this->assertSame(1, $this->handler->deleteMatching('*')); + $this->assertFileExists($hidden); + } finally { + if (is_file($hidden)) { + unlink($hidden); + } + } + } + public function testIncrement(): void { $this->handler->save(self::$key1, 1); @@ -294,13 +420,12 @@ public function testIsSupported(): void public function testSaveMode(int $int, string $string): void { // Initialize mode - $config = new Cache(); - $config->file['mode'] = $int; + $this->config->file['mode'] = $int; - $this->handler = CacheFactory::getHandler($config, 'file'); + $this->handler = CacheFactory::getHandler($this->config, 'file'); $this->handler->save(self::$key1, 'value'); - $file = $config->file['storePath'] . DIRECTORY_SEPARATOR . self::$key1; + $file = $this->config->file['storePath'] . DIRECTORY_SEPARATOR . self::$key1; $mode = octal_permissions(fileperms($file)); $this->assertSame($string, $mode); diff --git a/tests/system/Commands/Cache/ClearCacheTest.php b/tests/system/Commands/Cache/ClearCacheTest.php index 6674fff5a2c8..ca93f215bc13 100644 --- a/tests/system/Commands/Cache/ClearCacheTest.php +++ b/tests/system/Commands/Cache/ClearCacheTest.php @@ -16,7 +16,6 @@ use CodeIgniter\Autoloader\FileLocator; use CodeIgniter\Autoloader\FileLocatorCached; use CodeIgniter\Cache\CacheFactory; -use CodeIgniter\Cache\FactoriesCache\FileVarExportHandler; use CodeIgniter\Cache\Handlers\FileHandler; use CodeIgniter\CLI\CLI; use CodeIgniter\Config\Factories; @@ -25,6 +24,7 @@ use Config\Cache; use Config\Services; use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Cache\IsolatedCacheTrait; /** * @internal @@ -32,10 +32,25 @@ #[Group('Others')] final class ClearCacheTest extends CIUnitTestCase { + use IsolatedCacheTrait; use StreamFilterTrait; private Cache $config; + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + + self::setUpBeforeClassIsolatedCache(); + } + + public static function tearDownAfterClass(): void + { + self::tearDownAfterClassIsolatedCache(); + + parent::tearDownAfterClass(); + } + protected function setUp(): void { parent::setUp(); @@ -98,7 +113,7 @@ public function testClearCacheWorks(): void public function testClearCacheDiscardsSharedLocatorCache(): void { - $handler = new FileVarExportHandler(); + $handler = $this->getIsolatedCacheHandler(); $handler->delete('FileLocatorCache'); $locator = new FileLocatorCached(new FileLocator(service('autoloader')), $handler); @@ -117,6 +132,10 @@ public function testClearCacheDiscardsSharedLocatorCache(): void $this->assertArrayHasKey('Config/Cache', $cached['search']); $handler->delete('FileLocatorCache'); + + // Prevent the destructor from writing the cache file + // when the locator is destroyed. + $locator->deleteCache(); } public function testClearCacheFails(): void diff --git a/tests/system/Commands/ConfigurableSortImportsTest.php b/tests/system/Commands/ConfigurableSortImportsTest.php index 4f54390d7ffe..a86cd66a420a 100644 --- a/tests/system/Commands/ConfigurableSortImportsTest.php +++ b/tests/system/Commands/ConfigurableSortImportsTest.php @@ -55,6 +55,25 @@ public function testEnabledSortImportsWillDisruptLanguageFilePublish(): void } } + public function testSortImportsKeepsCrlfLineEndings(): void + { + command('publish:language --lang fr --crlf'); + + $file = APPPATH . 'Language/fr/Foobar.php'; + $this->assertFileExists($file); + $this->assertStringContainsString( + "\r\n\r\nuse CodeIgniter\\CLI\\CLI;\r\nuse Config\\App;\r\n\r\n", + (string) file_get_contents($file), + ); + if (is_file($file)) { + unlink($file); + } + $dir = dirname($file); + if (is_dir($dir)) { + rmdir($dir); + } + } + public function testDisabledSortImportsWillNotAffectLanguageFilesPublish(): void { command('publish:language --lang ar --sort off'); diff --git a/tests/system/Commands/Utilities/OptimizeTest.php b/tests/system/Commands/Utilities/OptimizeTest.php index ccaf9403c613..667859494bcf 100644 --- a/tests/system/Commands/Utilities/OptimizeTest.php +++ b/tests/system/Commands/Utilities/OptimizeTest.php @@ -22,6 +22,7 @@ use CodeIgniter\Test\StreamFilterTrait; use Config\Services; use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Cache\IsolatedCacheTrait; /** * @internal @@ -29,16 +30,31 @@ #[Group('Others')] final class OptimizeTest extends CIUnitTestCase { + use IsolatedCacheTrait; use ReflectionHelper; use StreamFilterTrait; private FileVarExportHandler $handler; + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + + self::setUpBeforeClassIsolatedCache(); + } + + public static function tearDownAfterClass(): void + { + self::tearDownAfterClassIsolatedCache(); + + parent::tearDownAfterClass(); + } + protected function setUp(): void { parent::setUp(); - $this->handler = new FileVarExportHandler(); + $this->handler = $this->getIsolatedCacheHandler(); $this->handler->delete('FileLocatorCache'); } @@ -74,15 +90,52 @@ public function testClearCacheDiscardsSharedLocatorCache(): void $this->assertArrayNotHasKey('Config/App', $cached['search']); $this->assertArrayHasKey('Config/Cache', $cached['search']); $this->assertStringContainsString('Removed FileLocatorCache.', $this->getStreamFilterBuffer()); + + // Prevent the destructor from writing the cache file + // when the locator is destroyed. + $locator->deleteCache(); } public function testClearCacheDeletesTheFileWithoutSharedLocatorCache(): void { - $this->handler->save('FileLocatorCache', ['search' => []]); - - ($this->getClearCache())(); - - $this->assertFalse($this->handler->get('FileLocatorCache')); - $this->assertStringContainsString('Removed FileLocatorCache.', $this->getStreamFilterBuffer()); + $writePath = rtrim(sys_get_temp_dir(), '\\/') . DIRECTORY_SEPARATOR . 'ci_test_optimize_' . uniqid('', true) . DIRECTORY_SEPARATOR; + mkdir($writePath . 'cache', 0755, true); + + $escapedWritePath = var_export($writePath, true); + $escapedBootstrap = var_export(SYSTEMPATH . 'Test/bootstrap.php', true); + + try { + $code = <<save('FileLocatorCache', ['search' => []]); + + \$clearCache = ReflectionHelper::getPrivateMethodInvoker( + new Optimize(service('logger'), service('commands')), + 'clearCache' + ); + \$clearCache(); + + if (\$handler->get('FileLocatorCache') !== false) { + exit(1); + } + PHP; + + $cmd = sprintf('%s -r %s 2>&1', escapeshellarg(PHP_BINARY), escapeshellarg($code)); + exec($cmd, $output, $status); + + $this->assertSame(0, $status, implode(PHP_EOL, $output)); + $this->assertStringContainsString('Removed FileLocatorCache.', implode(PHP_EOL, $output)); + } finally { + helper('filesystem'); + delete_files($writePath, true); + @rmdir($writePath); + } } } diff --git a/tests/system/Database/BaseQueryTest.php b/tests/system/Database/BaseQueryTest.php index b50e41b7ece8..ddb268a125ab 100644 --- a/tests/system/Database/BaseQueryTest.php +++ b/tests/system/Database/BaseQueryTest.php @@ -13,6 +13,10 @@ namespace CodeIgniter\Database; +use CodeIgniter\Database\MySQLi\Connection as MySQLiConnection; +use CodeIgniter\Database\Postgre\Connection as PostgreConnection; +use CodeIgniter\Database\SQLite3\Connection as SQLite3Connection; +use CodeIgniter\Database\SQLSRV\Connection as SQLSRVConnection; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockConnection; use PHPUnit\Framework\Attributes\DataProvider; @@ -333,6 +337,373 @@ public function testNamedBindsDontGetReplacedAgain(): void $this->assertSame($expected, $query->getQuery()); } + public function testNamedBindsAreNotReplacedInsideEscapedValues(): void + { + $query = new Query($this->db); + + $query->setQuery( + 'SELECT * FROM users WHERE name = ' . $this->db->escape(':status:') . ' AND status = :status:', + ['status' => ' OR is_admin = 1 OR '], + ); + + $expected = "SELECT * FROM users WHERE name = ':status:' AND status = ' OR is_admin = 1 OR '"; + + $this->assertSame($expected, $query->getQuery()); + } + + public function testNamedBindsAreNotReplacedAfterEscapedValueEndingWithBackslash(): void + { + $query = new Query($this->db); + + $query->setQuery( + 'SELECT * FROM users WHERE a = ' . $this->db->escape('x\\') . ' AND name = ' . $this->db->escape(':status:') . ' AND status = :status:', + ['status' => ' OR is_admin = 1 OR '], + ); + + $expected = "SELECT * FROM users WHERE a = 'x\\' AND name = ':status:' AND status = ' OR is_admin = 1 OR '"; + + $this->assertSame($expected, $query->getQuery()); + } + + public function testNamedBindsAreNotReplacedInsideBackslashEscapedValues(): void + { + $db = new class ([]) extends MockConnection { + protected function _escapeString(string $str): string + { + return addslashes($str); + } + }; + + $query = new Query($db); + + $query->setQuery( + 'SELECT * FROM users WHERE name = ' . $db->escape("it's :status:") . ' AND status = :status:', + ['status' => ' OR is_admin = 1 OR '], + ); + + $expected = "SELECT * FROM users WHERE name = 'it\\'s :status:' AND status = ' OR is_admin = 1 OR '"; + + $this->assertSame($expected, $query->getQuery()); + } + + public function testNamedBindsAreNotReplacedInsidePostgreEscapeStrings(): void + { + // Emulates standard_conforming_strings = on, where only E'...' + // strings use backslash escapes. + $db = new class ([]) extends PostgreConnection { + protected function _escapeString(string $str): string + { + return $str; + } + }; + + $query = new Query($db); + + $query->setQuery( + "SELECT * FROM users WHERE name = E'it\\'s :status:' AND status = :status:", + ['status' => [5, false]], + false, + ); + + $expected = "SELECT * FROM users WHERE name = E'it\\'s :status:' AND status = 5"; + + $this->assertSame($expected, $query->getQuery()); + } + + public function testNamedBindsEscapeStringsAreOnlyRecognizedOnPostgre(): void + { + $query = new Query($this->db); + + // Outside PostgreSQL, E'...' is a regular string, so the backslash + // does not escape the quote and the string ends after "it\". + $query->setQuery( + "SELECT * FROM users WHERE name = E'it\\' AND status = :status:", + ['status' => 5], + ); + + $expected = "SELECT * FROM users WHERE name = E'it\\' AND status = 5"; + + $this->assertSame($expected, $query->getQuery()); + } + + public function testNamedBindsAreNotReplacedInsideMySQLBackslashEscapedDoubleQuotedStrings(): void + { + $db = $this->createBackslashEscapingMySQLiConnection('utf8mb4'); + + $query = new Query($db); + + $query->setQuery( + 'SELECT * FROM users WHERE name = "it\\"s :status:" AND status = :status:', + ['status' => ' OR 1=1 OR '], + ); + + $expected = "SELECT * FROM users WHERE name = \"it\\\"s :status:\" AND status = ' OR 1=1 OR '"; + + $this->assertSame($expected, $query->getQuery()); + } + + public function testNamedBindsWithPostgreQuotedIdentifierEndingWithBackslash(): void + { + // Emulates standard_conforming_strings = off, where a backslash + // escapes inside '...' strings but never inside "..." identifiers. + $db = new class ([]) extends PostgreConnection { + protected function _escapeString(string $str): string + { + return addcslashes($str, "\\'"); + } + }; + + $query = new Query($db); + + $query->setQuery( + 'SELECT "a\\" FROM users WHERE name = \':id:\' AND id = :id:', + ['id' => [1, false]], + false, + ); + + $this->assertSame('SELECT "a\\" FROM users WHERE name = \':id:\' AND id = 1', $query->getQuery()); + } + + public function testNamedBindsAreNotReplacedInsideStringLiterals(): void + { + $query = new Query($this->db); + + $query->setQuery("SELECT * FROM users WHERE name LIKE '%:name:%' OR name = :name:", ['name' => 'Geoffrey']); + + $expected = "SELECT * FROM users WHERE name LIKE '%:name:%' OR name = 'Geoffrey'"; + + $this->assertSame($expected, $query->getQuery()); + } + + public function testNamedBindsWithQuotesInPlaceholder(): void + { + $query = new Query($this->db); + + $query->setQuery( + "SELECT * FROM users WHERE CONCAT(first, ' ', last) = :CONCAT(first, ' ', last):", + ["CONCAT(first, ' ', last)" => 'John Doe'], + ); + + $expected = "SELECT * FROM users WHERE CONCAT(first, ' ', last) = 'John Doe'"; + + $this->assertSame($expected, $query->getQuery()); + } + + public function testNamedBindsWithQuotesInIdentifiersAndComments(): void + { + $query = new Query($this->db); + + $query->setQuery( + "SELECT \"it's\", `it's` /* it's :id: */ FROM users -- it's :id:\nWHERE id = :id:", + ['id' => 1], + ); + + $expected = "SELECT \"it's\", `it's` /* it's :id: */ FROM users -- it's :id:\nWHERE id = 1"; + + $this->assertSame($expected, $query->getQuery()); + } + + public function testNamedBindsWithDoubleColonAndStringLiteral(): void + { + $query = new Query($this->db); + + $query->setQuery( + "SELECT id::text, 'a:b:c' FROM users WHERE created_at::date = :date: AND x::int = :x::int:", + ['date' => '2026-01-01', 'x::int' => 5], + ); + + $expected = "SELECT id::text, 'a:b:c' FROM users WHERE created_at::date = '2026-01-01' AND x::int = 5"; + + $this->assertSame($expected, $query->getQuery()); + } + + public function testNamedBindsWithQuotesInSQLSRVBracketIdentifiers(): void + { + $query = new Query(new SQLSRVConnection([])); + + $query->setQuery("SELECT [it's] FROM users WHERE id = :id: AND name = ':id:'", ['id' => 1]); + + $expected = "SELECT [it's] FROM users WHERE id = 1 AND name = ':id:'"; + + $this->assertSame($expected, $query->getQuery()); + } + + public function testNamedBindsAreNotReplacedInsideSQLSRVBracketIdentifiers(): void + { + $query = new Query(new SQLSRVConnection([])); + + $query->setQuery('SELECT [:id:] FROM users WHERE id = :id:', ['id' => 1]); + + $this->assertSame('SELECT [:id:] FROM users WHERE id = 1', $query->getQuery()); + } + + public function testNamedBindsWithSQLiteBracketIdentifiers(): void + { + $query = new Query(new SQLite3Connection([])); + + $query->setQuery("SELECT [it's], [:id:] FROM users WHERE id = :id: AND name = ':id:'", ['id' => 1]); + + $expected = "SELECT [it's], [:id:] FROM users WHERE id = 1 AND name = ':id:'"; + + $this->assertSame($expected, $query->getQuery()); + } + + public function testNamedBindsWithPostgreDollarQuotedStrings(): void + { + $query = new Query(new PostgreConnection([])); + + $query->setQuery( + "SELECT \$\$it's :id:\$\$ AS a, \$tag\$it's :id:\$tag\$ AS b, :id: AS id", + ['id' => [1, false]], + false, + ); + + $expected = "SELECT \$\$it's :id:\$\$ AS a, \$tag\$it's :id:\$tag\$ AS b, 1 AS id"; + + $this->assertSame($expected, $query->getQuery()); + } + + public function testNamedBindsAreNotReplacedInsideNestedComments(): void + { + foreach ([new PostgreConnection([]), new SQLSRVConnection([])] as $db) { + $query = new Query($db); + + $query->setQuery( + "SELECT 1 /* outer /* it's */ :id: */ FROM users WHERE id = :id: /*!50000 :id: */", + ['id' => [1, false]], + false, + ); + + $expected = "SELECT 1 /* outer /* it's */ :id: */ FROM users WHERE id = 1 /*!50000 :id: */"; + + $this->assertSame($expected, $query->getQuery(), $db::class); + } + } + + #[DataProvider('provideNamedBindsWithMySQLMultibyteCharsets')] + public function testNamedBindsWithMySQLMultibyteCharsets(string $charset, string $char): void + { + $db = $this->createBackslashEscapingMySQLiConnection($charset); + + $query = new Query($db); + + // mysqli::real_escape_string() is charset-aware and does not escape + // the trailing 0x5C byte of a multibyte character. + $query->setQuery( + "SELECT * FROM users WHERE a = 'x{$char}' AND name = ':status:' AND status = :status:", + ['status' => ' OR 1=1 OR '], + ); + + $expected = "SELECT * FROM users WHERE a = 'x{$char}' AND name = ':status:' AND status = ' OR 1=1 OR '"; + + $this->assertSame($expected, $query->getQuery()); + } + + /** + * @return iterable + */ + public static function provideNamedBindsWithMySQLMultibyteCharsets(): iterable + { + yield 'big5' => ['big5', "\xb3\x5c"]; + + yield 'cp932' => ['cp932', "\x95\x5c"]; + + yield 'gb18030' => ['gb18030', "\x81\x5c"]; + + yield 'gbk' => ['gbk', "\xbf\x5c"]; + + yield 'sjis' => ['SJIS', "\x95\x5c"]; + } + + public function testNamedBindsWithMySQLInvalidMultibyteSequence(): void + { + $db = $this->createBackslashEscapingMySQLiConnection('gbk'); + + $query = new Query($db); + + // 0xBF is a GBK lead byte, but a quote is not a valid trailing byte, + // so MySQL ends the string literal at that quote. + $query->setQuery( + "SELECT * FROM users WHERE a = 'x\xbf' AND name = ':status:' AND status = :status:", + ['status' => ' OR 1=1 OR '], + ); + + $expected = "SELECT * FROM users WHERE a = 'x\xbf' AND name = ':status:' AND status = ' OR 1=1 OR '"; + + $this->assertSame($expected, $query->getQuery()); + } + + public function testNamedBindsWithMySQLUtf8DoesNotSkipMultibyteSequences(): void + { + $db = $this->createBackslashEscapingMySQLiConnection('utf8mb4'); + + $query = new Query($db); + + $query->setQuery("SELECT * FROM users WHERE a = 'x\xbf\\' AND b = ':id:' AND id = :id:", ['id' => 1]); + + $expected = "SELECT * FROM users WHERE a = 'x\xbf\\' AND b = '1' AND id = :id:"; + + $this->assertSame($expected, $query->getQuery()); + } + + #[DataProvider('provideNamedBindsWithPostgreMultibyteCharsets')] + public function testNamedBindsWithPostgreMultibyteCharsets(string $charset, string $char, string $suffix): void + { + $db = new class (['charset' => $charset]) extends PostgreConnection { + protected function _escapeString(string $str): string + { + return $str; + } + }; + + $query = new Query($db); + + // pg_escape_literal() creates E'...' strings for values with a backslash. + $query->setQuery( + "SELECT * FROM users WHERE a = E'\\\\x{$char}{$suffix}' AND name = ':status:' AND status = :status:", + ['status' => [5, false]], + false, + ); + + $expected = "SELECT * FROM users WHERE a = E'\\\\x{$char}{$suffix}' AND name = ':status:' AND status = 5"; + + $this->assertSame($expected, $query->getQuery()); + } + + /** + * @return iterable + */ + public static function provideNamedBindsWithPostgreMultibyteCharsets(): iterable + { + yield 'BIG5' => ['BIG5', "\xb3\x5c", '']; + + yield 'GB18030' => ['GB18030', "\x81\x5c", '']; + + yield 'GBK' => ['GBK', "\xbf\x5c", '']; + + yield 'Windows936' => ['Windows936', "\xbf\x5c", '']; + + yield 'SJIS' => ['SJIS', "\x95\x5c", '']; + + yield 'Shift_JIS_2004' => ['Shift_JIS_2004', "\x95\x5c", '']; + + yield 'SJIS half-width katakana' => ['SJIS', "\xb1", '\\\\']; + } + + public function testNamedBindsWithMySQLComments(): void + { + $query = new Query(new MySQLiConnection([])); + + $query->setQuery( + "SELECT 10--2, ':id:' FROM users # it's\nWHERE id = :id: -- it's\nAND x = :x: /* /* it's */ /*!50000 AND y = :x: */", + ['id' => 1, 'x' => 2], + ); + + $expected = "SELECT 10--2, ':id:' FROM users # it's\nWHERE id = 1 -- it's\nAND x = 2 /* /* it's */ /*!50000 AND y = 2 */"; + + $this->assertSame($expected, $query->getQuery()); + } + /** * @see https://github.com/codeigniter4/CodeIgniter4/issues/1705 */ @@ -607,4 +978,14 @@ public static function provideHighlightQueryKeywords(): iterable ], ]; } + + private function createBackslashEscapingMySQLiConnection(string $charset): MySQLiConnection + { + return new class (['charset' => $charset]) extends MySQLiConnection { + protected function _escapeString(string $str): string + { + return addcslashes($str, "\\'"); + } + }; + } } diff --git a/tests/system/Database/Builder/DeleteTest.php b/tests/system/Database/Builder/DeleteTest.php index 0866cac724a4..a44cd50eb5a2 100644 --- a/tests/system/Database/Builder/DeleteTest.php +++ b/tests/system/Database/Builder/DeleteTest.php @@ -15,6 +15,7 @@ use CodeIgniter\Database\BaseBuilder; use CodeIgniter\Database\Postgre\Builder as PostgreBuilder; +use CodeIgniter\Database\RawSql; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockConnection; use CodeIgniter\Test\Mock\MockQuery; @@ -124,6 +125,107 @@ public function testDeleteBatchEscapesWhereBinds(): void $this->assertStringNotContainsString("= anything' OR '1'='1", $query->getQuery()); } + public function testDeleteBatchDoesNotReplaceNamedBindsInsideEscapedValue(): void + { + $db = new MockConnection([]); + $builder = new BaseBuilder('jobs', $db); + + $data = [ + ['id' => 1, 'name' => 'Derek J'], + ['id' => 2, 'name' => 'Ahmadinejad'], + ]; + + $db->shouldReturn('execute', new class () {}); + $builder->setData($data, null, 'data') + ->onConstraint(['id' => 'id']) + ->where('jobs.name = ' . $db->escape(':jobs.status:')) + ->where('jobs.status', ' OR is_admin OR ') + ->deleteBatch(); + + $query = $db->getLastQuery(); + $this->assertInstanceOf(MockQuery::class, $query); + + $this->assertStringContainsString('"jobs"."name" = \':jobs.status:\'', $query->getQuery()); + $this->assertStringContainsString('"jobs"."status" = \' OR is_admin OR \'', $query->getQuery()); + } + + public function testDeleteBatchWithRawSqlWhere(): void + { + $db = new MockConnection([]); + $builder = new BaseBuilder('jobs', $db); + + $db->shouldReturn('execute', new class () {}); + $builder->setData([['id' => 1, 'name' => 'Derek J']], null, 'data') + ->onConstraint(['id' => 'id']) + ->where(new RawSql('jobs.name IS NOT NULL AND jobs.id <> :jobs.id:')) + ->where('jobs.id', 1) + ->deleteBatch(); + + $query = $db->getLastQuery(); + $this->assertInstanceOf(MockQuery::class, $query); + + $this->assertStringContainsString("WHERE jobs.name IS NOT NULL AND jobs.id <> 1\nAND \"jobs\".\"id\" = 1", $query->getQuery()); + } + + public function testDeleteBatchDoesNotReplaceTablePlaceholderInsideWhereBind(): void + { + $db = new MockConnection([]); + $builder = new BaseBuilder('jobs', $db); + + $db->shouldReturn('execute', new class () {}); + $builder->setData([['id' => 1, 'name' => ' OR 1=1 -- ']], null, 'data') + ->onConstraint(['id' => 'id']) + ->where('jobs.status', 'x{:_table_:}y') + ->deleteBatch(); + + $query = $db->getLastQuery(); + $this->assertInstanceOf(MockQuery::class, $query); + + $this->assertStringContainsString('"jobs"."status" = \'x{:_table_:}y\'', $query->getQuery()); + $this->assertStringNotContainsString("'xSELECT 1", $query->getQuery()); + } + + public function testDeleteBatchDoesNotReplaceNamedBindsInsideBatchData(): void + { + $db = new MockConnection([]); + $builder = new BaseBuilder('jobs', $db); + + $db->shouldReturn('execute', new class () {}); + $builder->setData([['id' => 1, 'name' => ':jobs.status:']], null, 'data') + ->onConstraint(['id' => 'id']) + ->where('jobs.status', ' OR 1=1 -- ') + ->deleteBatch(); + + $query = $db->getLastQuery(); + $this->assertInstanceOf(MockQuery::class, $query); + + $this->assertStringContainsString("SELECT 1 \"id\", ':jobs.status:' \"name\"", $query->getQuery()); + $this->assertStringContainsString('"jobs"."status" = \' OR 1=1 -- \'', $query->getQuery()); + } + + public function testDeleteBatchPostgreDoesNotReplaceTablePlaceholderInsideWhereBind(): void + { + $db = new class (['DBDriver' => 'Postgre']) extends MockConnection { + protected function _fieldData(string $table): array + { + return [ + (object) ['name' => 'id', 'type' => 'integer', 'max_length' => 32], + (object) ['name' => 'name', 'type' => 'text', 'max_length' => null], + ]; + } + }; + + $sql = (new PostgreBuilder('jobs', $db))->testMode() + ->setData([['id' => 1, 'name' => ' OR 1=1 -- ']], null, 'data') + ->onConstraint(['id' => 'id']) + ->where('jobs.status', 'x{:_table_:}y') + ->deleteBatch(); + + $this->assertStringContainsString("SELECT 1 \"id\", CAST(' OR 1=1 -- ' AS TEXT) \"name\"", $sql[0]); + $this->assertStringContainsString('AND "jobs"."status" = \'x{:_table_:}y\'', $sql[0]); + $this->assertStringNotContainsString("'xSELECT 1", $sql[0]); + } + public function testDeleteBatchEscapesMultipleWhereBinds(): void { $db = new MockConnection([]); @@ -144,7 +246,9 @@ public function testDeleteBatchEscapesMultipleWhereBinds(): void $query = $db->getLastQuery(); $this->assertInstanceOf(MockQuery::class, $query); - $this->assertStringContainsString('"jobs"."name" = \'anything\'\' OR \'\'1\'\' = \'\'1\'', $query->getQuery()); + // The bound value is substituted after the operators are formatted, + // so the value itself is never rewritten (`'1'='1` keeps no spaces). + $this->assertStringContainsString('"jobs"."name" = \'anything\'\' OR \'\'1\'\'=\'\'1\'', $query->getQuery()); $this->assertStringContainsString('"jobs"."id" = 1', $query->getQuery()); } diff --git a/tests/system/Database/Builder/UnionTest.php b/tests/system/Database/Builder/UnionTest.php index c6d01273e2e5..9c1cf2f5741f 100644 --- a/tests/system/Database/Builder/UnionTest.php +++ b/tests/system/Database/Builder/UnionTest.php @@ -84,6 +84,15 @@ public function testUnionSQLSRV(): void $this->assertSame($expected, $this->buildSelect($builder)); } + public function testUnionDoesNotReplaceNamedBindsInsideSubqueryValues(): void + { + $expected = 'SELECT * FROM (SELECT * FROM "test" WHERE "status" = \' OR 1=1 OR \') "uwrp0" UNION SELECT * FROM (SELECT * FROM "test" WHERE "name" = \':status:\') "uwrp1"'; + $builder = $this->db->table('test')->where('status', ' OR 1=1 OR '); + + $builder->union($this->db->table('test')->where('name', ':status:')); + $this->assertSame($expected, $this->buildSelect($builder)); + } + protected function buildSelect(BaseBuilder $builder): string { return str_replace("\n", ' ', $builder->getCompiledSelect()); diff --git a/tests/system/Database/Builder/WhereTest.php b/tests/system/Database/Builder/WhereTest.php index 65baa097a832..1aee974100f4 100644 --- a/tests/system/Database/Builder/WhereTest.php +++ b/tests/system/Database/Builder/WhereTest.php @@ -54,6 +54,16 @@ public function testSimpleWhere(): void $this->assertSame($expectedBinds, $builder->getBinds()); } + public function testWhereCustomStringDoesNotReplaceNamedBindsInsideEscapedValue(): void + { + $builder = $this->db->table('users'); + + $expectedSQL = 'SELECT * FROM "users" WHERE "name" = \':status:\' AND "status" = \' OR 1=1 OR \''; + + $builder->where('name = ' . $this->db->escape(':status:'))->where('status', ' OR 1=1 OR '); + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + } + public function testWhereNoEscape(): void { $builder = $this->db->table('users'); diff --git a/tests/system/Database/Live/DeleteTest.php b/tests/system/Database/Live/DeleteTest.php index 8ef937fb00f5..808764a16269 100644 --- a/tests/system/Database/Live/DeleteTest.php +++ b/tests/system/Database/Live/DeleteTest.php @@ -119,6 +119,25 @@ public function testDeleteBatchPreventsSQLInjectionInWhere(): void $this->seeInDatabase('user', ['email' => 'derek@world.com', 'name' => 'Derek Jones']); } + public function testDeleteBatchDoesNotReplaceTablePlaceholderInsideWhereBind(): void + { + if ($this->db->DBDriver === 'SQLite3') { + $this->markTestSkipped('SQLite3 driver does not support WHERE for batch deletes.'); + } + + $data = [ + ['userid' => 1, 'username' => ' OR 1=1 -- '], + ]; + + $this->db->table('user') + ->setData($data, null, 'data') + ->onConstraint(['id' => 'userid']) + ->where('user.name', 'x{:_table_:}y') + ->deleteBatch(); + + $this->seeInDatabase('user', ['email' => 'derek@world.com', 'name' => 'Derek Jones']); + } + public function testDeleteBatchWithMixedConstraintValueTypesInTextColumn(): void { if ($this->db->DBDriver === 'SQLSRV') { diff --git a/tests/system/Database/Live/GetVersionTest.php b/tests/system/Database/Live/GetVersionTest.php index cb4e5c1f5386..723d6b2d0c9c 100644 --- a/tests/system/Database/Live/GetVersionTest.php +++ b/tests/system/Database/Live/GetVersionTest.php @@ -39,6 +39,6 @@ public function testGetVersion(): void $version = $this->db->getVersion(); - $this->assertMatchesRegularExpression('/\A\d+(\.\d+)*\z/', $version); + $this->assertMatchesRegularExpression('/\A\d+(\.\d+)*(-\S+)?\z/', $version); } } diff --git a/tests/system/Database/Live/MySQLi/DeadlockTransactionTest.php b/tests/system/Database/Live/MySQLi/DeadlockTransactionTest.php new file mode 100644 index 000000000000..433c982a6403 --- /dev/null +++ b/tests/system/Database/Live/MySQLi/DeadlockTransactionTest.php @@ -0,0 +1,304 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Live\MySQLi; + +use CodeIgniter\Database\BaseConnection; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\DatabaseTestTrait; +use Config\Database; +use mysqli; +use mysqli_driver; +use PHPUnit\Framework\Attributes\Group; +use Throwable; + +/** + * InnoDB errno 1213 rolls the victim transaction back. These tests lock two + * rows in opposite orders so the CodeIgniter connection is that victim. + * + * Errno 1205 is a different outcome and is not a passing result here. + * + * @internal + */ +#[Group('DatabaseLive')] +final class DeadlockTransactionTest extends CIUnitTestCase +{ + use DatabaseTestTrait; + + protected $migrate = false; + private ?BaseConnection $waiter = null; + private ?mysqli $held = null; + private ?string $probeTable = null; + + protected function setUp(): void + { + $config = config('Database'); + + if ($config->tests['DBDriver'] !== 'MySQLi') { + $this->markTestSkipped('Only MySQLi can complete this test.'); + } + + $this->db = Database::connect($this->DBGroup, false); + + parent::setUp(); + } + + protected function tearDown(): void + { + $this->releaseDeadlock(); + + parent::tearDown(); + } + + public function testFollowingWriteDoesNotAutocommitAndTransCommitReturnsFalse(): void + { + $this->assertDeadlockVictimDoesNotPublishTheNextWrite(false); + } + + public function testFollowingWriteDoesNotAutocommitAndTransCompleteRollsItBack(): void + { + $this->assertDeadlockVictimDoesNotPublishTheNextWrite(true); + } + + public function testFalseTransCommitEndsTheTransactionSoARetryCommits(): void + { + $table = $this->db->escapeIdentifier($this->db->prefixTable('deadlock_probe')); + $this->probeTable = $table; + $this->forceDeadlock($table); + + $this->db->query('INSERT INTO ' . $table . " (id, note) VALUES (102, 'after')"); + + $firstCommit = $this->db->transCommit(); + $depthAfter = $this->db->transDepth; + + // A caller that retries after the false commit must get a real transaction, + // not a nested one inside the restarted server transaction. + $this->db->transBegin(); + $this->db->query('INSERT INTO ' . $table . " (id, note) VALUES (103, 'retry')"); + $retryCommit = $this->db->transCommit(); + + $this->assertSame( + ['first_commit' => false, 'depth_after' => 0, 'retry_commit' => true, 'visible' => [103]], + [ + 'first_commit' => $firstCommit, + 'depth_after' => $depthAfter, + 'retry_commit' => $retryCommit, + 'visible' => $this->committedIds($table), + ], + ); + } + + public function testLostConnectionAfterDeadlockDoesNotEscapeAsDriverException(): void + { + $table = $this->db->escapeIdentifier($this->db->prefixTable('deadlock_probe')); + $this->probeTable = $table; + $this->forceDeadlock($table); + $this->killConnection($this->db); + + // The restart BEGIN fails on the dead socket. That must surface through the + // normal query error path, not as a raw mysqli_sql_exception. + $insert = $this->db->query('INSERT INTO ' . $table . " (id, note) VALUES (102, 'after')"); + $this->db->reconnect(); + + $this->assertSame( + ['insert' => false, 'complete' => false], + ['insert' => $insert, 'complete' => $this->db->transComplete()], + ); + } + + public function testRestartThatCannotBeginDoesNotRunTheStatement(): void + { + $table = $this->db->escapeIdentifier($this->db->prefixTable('deadlock_probe')); + $this->probeTable = $table; + $this->forceDeadlock($table); + + // An active XA transaction makes MySQL refuse START TRANSACTION on a live + // connection. With mysqli exception reporting off, begin_transaction() + // then returns false instead of throwing. + $mysqli = $this->db->getConnection(); + $previous = (new mysqli_driver())->report_mode; + $mysqli->query("XA START 'deadlock_probe'"); + mysqli_report(MYSQLI_REPORT_OFF); + + try { + $insert = $this->db->query('INSERT INTO ' . $table . " (id, note) VALUES (102, 'after')"); + $inserted = (int) $mysqli->query('SELECT COUNT(*) FROM ' . $table . ' WHERE id = 102')->fetch_row()[0]; + } finally { + $mysqli->query("XA END 'deadlock_probe'"); + $mysqli->query("XA ROLLBACK 'deadlock_probe'"); + mysqli_report($previous); + } + + $this->assertSame(['insert' => false, 'inserted' => 0], ['insert' => $insert, 'inserted' => $inserted]); + } + + private function killConnection(BaseConnection $db): void + { + $threadId = $db->getConnection()->thread_id; + $observer = Database::connect($this->DBGroup, false); + $observer->initialize(); + $observer->query('KILL ' . $threadId); + + for ($i = 0; $i < 50; $i++) { + $alive = $observer->query('SELECT COUNT(*) AS n FROM information_schema.processlist WHERE id = ' . $threadId)->getRow()->n; + + if ((int) $alive === 0) { + break; + } + + usleep(20000); + } + + $observer->close(); + } + + private function assertDeadlockVictimDoesNotPublishTheNextWrite(bool $useComplete): void + { + $table = $this->db->escapeIdentifier($this->db->prefixTable('deadlock_probe')); + $this->probeTable = $table; + $this->forceDeadlock($table); + + $this->assertSame(1213, (int) $this->db->error()['code']); + $this->assertSame(1, $this->db->transDepth); + + $this->db->query('INSERT INTO ' . $table . " (id, note) VALUES (102, 'after')"); + + $visibleBefore = $this->committedIds($table); + $finished = $useComplete ? $this->db->transComplete() : $this->db->transCommit(); + $visibleAfter = $this->committedIds($table); + + $this->assertSame( + ['visible_before' => [], 'finished' => false, 'visible_after' => []], + [ + 'visible_before' => $visibleBefore, + 'finished' => $finished, + 'visible_after' => $visibleAfter, + ], + ); + } + + /** + * Make $this->db the InnoDB deadlock victim. The victim row is id 101. + */ + private function forceDeadlock(string $table): void + { + for ($attempt = 1; $attempt <= 3; $attempt++) { + $this->releaseDeadlock(); + $this->resetProbe($table); + + $this->waiter = Database::connect($this->DBGroup, false); + $this->waiter->initialize(); + $this->waiter->query('SET SESSION innodb_lock_wait_timeout = 3'); + $this->waiter->transStart(); + + for ($i = 0; $i < 20; $i++) { + $this->waiter->query('UPDATE ' . $table . " SET note = 'w{$i}' WHERE id = 1"); + } + + $held = $this->waiter->getConnection(); + $this->assertInstanceOf(mysqli::class, $held); + $this->held = $held; + + // The victim has to lock row 2 before the waiter asks for it. + // Sending that request first lets the waiter take row 2, and the + // victim then waits out innodb_lock_wait_timeout (errno 1205). + $this->db->query('SET SESSION innodb_lock_wait_timeout = 3'); + $this->db->transException(false); + $this->db->transStart(); + $this->db->query('INSERT INTO ' . $table . " (id, note) VALUES (101, 'victim')"); + $this->db->query('UPDATE ' . $table . " SET note = 'held' WHERE id = 2"); + + $this->held->query('UPDATE ' . $table . " SET note = 'waiting' WHERE id = 2", MYSQLI_ASYNC); + usleep(400000); + + $this->db->query('UPDATE ' . $table . " SET note = 'closing' WHERE id = 1"); + + if ((int) $this->db->error()['code'] === 1213) { + return; + } + } + + $this->fail('Precondition: InnoDB did not report errno 1213 on the CodeIgniter connection.'); + } + + private function resetProbe(string $table): void + { + // releaseDeadlock() clears this marker after it drops the table. + // Record the name again before recreating it, including when setup fails. + $this->probeTable = $table; + + if ($this->db->transDepth > 0) { + $this->db->transRollback(); + } + + $this->db->query('DROP TABLE IF EXISTS ' . $table); + $this->db->query( + 'CREATE TABLE ' . $table . ' (' + . 'id INT NOT NULL, ' + . 'note VARCHAR(32) NOT NULL, ' + . 'PRIMARY KEY (id)' + . ') ENGINE=InnoDB', + ); + $this->db->query('INSERT INTO ' . $table . " (id, note) VALUES (1, 'one'), (2, 'two')"); + } + + /** + * @return list + */ + private function committedIds(string $table): array + { + $observer = Database::connect($this->DBGroup, false); + $observer->initialize(); + $rows = $observer->query('SELECT id FROM ' . $table . ' WHERE id > 100 ORDER BY id')->getResultArray(); + $observer->close(); + + $ids = []; + + foreach ($rows as $row) { + $ids[] = (int) $row['id']; + } + + return $ids; + } + + private function releaseDeadlock(): void + { + if ($this->held instanceof mysqli) { + try { + $this->held->reap_async_query(); + } catch (Throwable) { + // The waiting statement already finished, or the socket is closed. + } + + $this->held = null; + } + + if ($this->waiter instanceof BaseConnection) { + if ($this->waiter->transDepth > 0) { + $this->waiter->transRollback(); + } + + $this->waiter->close(); + $this->waiter = null; + } + + if ($this->db instanceof BaseConnection && $this->db->transDepth > 0) { + $this->db->transRollback(); + } + + if ($this->probeTable !== null && $this->db instanceof BaseConnection) { + $this->db->query('DROP TABLE IF EXISTS ' . $this->probeTable); + $this->probeTable = null; + } + } +} diff --git a/tests/system/Database/Live/NamedBindsTest.php b/tests/system/Database/Live/NamedBindsTest.php new file mode 100644 index 000000000000..8645f614074b --- /dev/null +++ b/tests/system/Database/Live/NamedBindsTest.php @@ -0,0 +1,114 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Live; + +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\DatabaseTestTrait; +use Config\Database; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('DatabaseLive')] +final class NamedBindsTest extends CIUnitTestCase +{ + use DatabaseTestTrait; + + protected $migrate = false; + + public function testNamedBindsAreNotReplacedInsideEscapedValues(): void + { + $sql = 'SELECT ' . $this->db->escape(':id:') . ' AS lit, :id: AS id'; + + if ($this->db->DBDriver === 'OCI8') { + $sql = 'SELECT ' . $this->db->escape(':id:') . ' AS "lit", :id: AS "id" FROM DUAL'; + } + + $row = $this->db->query($sql, ['id' => 5])->getRowArray(); + + $this->assertSame(':id:', $row['lit']); + $this->assertSame(5, (int) $row['id']); + } + + public function testNamedBindsWithDialectSpecificQuotingAndComments(): void + { + [$sql, $expected] = match ($this->db->DBDriver) { + 'MySQLi' => [ + "SELECT 10--2 AS diff, ':id:' AS lit, :id: AS id # it's", + ['diff' => '12', 'lit' => ':id:', 'id' => '5'], + ], + 'Postgre' => [ + "SELECT \$\$it's :id:\$\$ AS lit, 1 /* a /* it's */ :id: */ AS one, :id: AS id", + ['lit' => "it's :id:", 'one' => '1', 'id' => '5'], + ], + 'SQLSRV' => [ + "SELECT 1 AS [it's], 1 /* a /* it's */ :id: */ AS one, ':id:' AS lit, :id: AS id", + ["it's" => '1', 'one' => '1', 'lit' => ':id:', 'id' => '5'], + ], + 'SQLite3' => [ + "SELECT 1 AS [it's], ':id:' AS lit, :id: AS id", + ["it's" => '1', 'lit' => ':id:', 'id' => '5'], + ], + 'OCI8' => [ + "SELECT q'[it's :id:]' AS \"lit\", nq'{:id:}' AS \"lit2\", :id: AS \"id\" FROM DUAL", + ['lit' => "it's :id:", 'lit2' => ':id:', 'id' => '5'], + ], + default => $this->markTestSkipped('Not supported for ' . $this->db->DBDriver . '.'), + }; + + $row = $this->db->query($sql, ['id' => 5])->getRowArray(); + + $this->assertSame($expected, array_map(strval(...), $row)); + } + + public function testNamedBindsWithMultibyteConnectionCharset(): void + { + // 0xBF5C is a single GBK character whose trailing byte is a backslash. + [$charset, $value] = match ($this->db->DBDriver) { + 'MySQLi' => ['gbk', "x\xbf\x5c"], + 'Postgre' => ['GBK', "a\\\xbf\x5c"], // the backslash makes pg_escape_literal() create E'...' + default => $this->markTestSkipped('Not needed for ' . $this->db->DBDriver . '.'), + }; + + $config = config(Database::class)->tests; + $config['charset'] = $charset; + + if ($this->db->DBDriver === 'Postgre') { + // pg_connect() reuses an open connection with the same DSN, so make + // the DSN unique to avoid changing or closing the shared connection. + $config['DSN'] = sprintf( + "host=%s port=%s user=%s password='%s' dbname=%s application_name=named_binds_test", + $config['hostname'], + $config['port'] !== '' ? $config['port'] : 5432, + $config['username'], + $config['password'], + $config['database'], + ); + } + + $db = Database::connect($config, false); + + try { + $row = $db->query( + 'SELECT ' . $db->escape($value) . ' AS a, ' . $db->escape(':id:') . ' AS lit, :id: AS id', + ['id' => 5], + )->getRowArray(); + } finally { + $db->close(); + } + + $this->assertSame(['a' => $value, 'lit' => ':id:', 'id' => '5'], array_map(strval(...), $row)); + } +} diff --git a/tests/system/Database/Live/ReconnectDuringTransactionTest.php b/tests/system/Database/Live/ReconnectDuringTransactionTest.php new file mode 100644 index 000000000000..280d68d6f034 --- /dev/null +++ b/tests/system/Database/Live/ReconnectDuringTransactionTest.php @@ -0,0 +1,132 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Live; + +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\DatabaseTestTrait; +use Config\Database; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('DatabaseLive')] +final class ReconnectDuringTransactionTest extends CIUnitTestCase +{ + use DatabaseTestTrait; + + protected $migrate = false; + + protected function setUp(): void + { + // Dedicated connection so a forced wait_timeout cannot drop the shared one. + $this->db = Database::connect($this->DBGroup, false); + + parent::setUp(); + } + + public function testReconnectInsideTransactionDoesNotReportSuccess(): void + { + if ($this->db->DBDriver !== 'MySQLi') { + $this->markTestSkipped('Requires the MySQLi driver.'); + } + + $table = $this->db->escapeIdentifier($this->db->prefixTable('reconnect_probe')); + $this->db->query('DROP TABLE IF EXISTS ' . $table); + $this->db->query( + 'CREATE TABLE ' . $table . ' (' + . 'id INT NOT NULL AUTO_INCREMENT, ' + . 'note VARCHAR(32) NOT NULL, ' + . 'PRIMARY KEY (id)' + . ') ENGINE=InnoDB', + ); + + $observer = Database::connect($this->DBGroup, false); + $observer->initialize(); + + try { + $this->db->transStart(); + $this->db->table('reconnect_probe')->insert(['note' => 'before-timeout']); + $this->assertSame(1, $this->db->transDepth); + $this->assertTrue($this->db->transStatus()); + + $this->db->query('SET SESSION wait_timeout = 1'); + sleep(3); + + $this->assertFalse($this->db->ping(), 'Precondition: the server should have closed the idle socket.'); + + $this->db->reconnect(); + + $depthAfterReconnect = $this->db->transDepth; + $statusAfterReconnect = $this->db->transStatus(); + $completed = $this->db->transComplete(); + $rows = (int) $observer->table('reconnect_probe')->countAllResults(); + + $this->assertSame( + ['depth' => 0, 'status' => false, 'completed' => false, 'rows' => 0], + [ + 'depth' => $depthAfterReconnect, + 'status' => $statusAfterReconnect, + 'completed' => $completed, + 'rows' => $rows, + ], + ); + } finally { + if ($this->db->transDepth > 0) { + $this->db->transRollback(); + } + + $this->db->query('DROP TABLE IF EXISTS ' . $table); + $observer->close(); + $this->db->close(); + } + } + + public function testReconnectKeepsAnOpenTransactionWhenTheSocketIsAlive(): void + { + if ($this->db->DBDriver !== 'MySQLi') { + $this->markTestSkipped('Requires the MySQLi driver.'); + } + + $table = $this->db->escapeIdentifier($this->db->prefixTable('reconnect_probe')); + $this->db->query('DROP TABLE IF EXISTS ' . $table); + $this->db->query( + 'CREATE TABLE ' . $table . ' (' + . 'id INT NOT NULL AUTO_INCREMENT, ' + . 'note VARCHAR(32) NOT NULL, ' + . 'PRIMARY KEY (id)' + . ') ENGINE=InnoDB', + ); + + try { + $this->db->transStart(); + $this->db->table('reconnect_probe')->insert(['note' => 'still-open']); + $this->assertTrue($this->db->ping()); + + $this->db->reconnect(); + + $this->assertSame(1, $this->db->transDepth); + $this->assertTrue($this->db->transStatus()); + $this->assertTrue($this->db->transComplete()); + $this->assertSame(1, $this->db->table('reconnect_probe')->countAllResults()); + } finally { + if ($this->db->transDepth > 0) { + $this->db->transRollback(); + } + + $this->db->query('DROP TABLE IF EXISTS ' . $table); + $this->db->close(); + } + } +} diff --git a/tests/system/Database/Migrations/MigrationRunnerTest.php b/tests/system/Database/Migrations/MigrationRunnerTest.php index 79ad1cf39330..fdf5a56d2037 100644 --- a/tests/system/Database/Migrations/MigrationRunnerTest.php +++ b/tests/system/Database/Migrations/MigrationRunnerTest.php @@ -472,6 +472,8 @@ public function testMigrationUsesSameConnectionAsMigrationRunner(): void $runner->latest(); $tables = $database->listTables(); + $database->close(); + $this->assertCount(2, $tables); $this->assertSame('migrations', $tables[0]); $this->assertSame('foo', $tables[1]); diff --git a/tests/system/Database/ReconnectLostTransactionTest.php b/tests/system/Database/ReconnectLostTransactionTest.php new file mode 100644 index 000000000000..eca053ef5b41 --- /dev/null +++ b/tests/system/Database/ReconnectLostTransactionTest.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database; + +use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\MySQLi\Connection as MySQLiConnection; +use CodeIgniter\Database\OCI8\Connection as OCI8Connection; +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class ReconnectLostTransactionTest extends CIUnitTestCase +{ + public function testReconnectClearsTransactionStateWhenInitializeFails(): void + { + $db = new class (['DBDriver' => 'MySQLi']) extends MySQLiConnection { + public function initialize(): never + { + throw new DatabaseException('database unavailable'); + } + }; + + $this->setPrivateProperty($db, 'transDepth', 1); + + try { + $db->reconnect(); + $this->fail('initialize() should have thrown.'); + } catch (DatabaseException $exception) { + $this->assertSame('database unavailable', $exception->getMessage()); + } + + $this->assertSame(0, $this->getPrivateProperty($db, 'transDepth')); + $this->assertFalse($db->transStatus()); + } + + public function testReconnectRestoresOci8CommitModeWhenTransactionIsLost(): void + { + if (! defined('OCI_COMMIT_ON_SUCCESS')) { + define('OCI_COMMIT_ON_SUCCESS', 32); + } + + if (! defined('OCI_NO_AUTO_COMMIT')) { + define('OCI_NO_AUTO_COMMIT', 0); + } + + $db = new class (['DBDriver' => 'OCI8']) extends OCI8Connection { + public function initialize(): void + { + } + }; + + $db->commitMode = OCI_NO_AUTO_COMMIT; + $this->setPrivateProperty($db, 'transDepth', 1); + + $db->reconnect(); + + $this->assertSame(OCI_COMMIT_ON_SUCCESS, $db->commitMode); + $this->assertSame(0, $this->getPrivateProperty($db, 'transDepth')); + $this->assertFalse($db->transStatus()); + } +} diff --git a/tests/system/Format/XMLFormatterTest.php b/tests/system/Format/XMLFormatterTest.php index 107418a5d68c..f86aa95d1feb 100644 --- a/tests/system/Format/XMLFormatterTest.php +++ b/tests/system/Format/XMLFormatterTest.php @@ -13,10 +13,13 @@ namespace CodeIgniter\Format; +use CodeIgniter\Format\Exceptions\FormatException; use CodeIgniter\Test\CIUnitTestCase; use DOMDocument; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; +use stdClass; +use Stringable; /** * @internal @@ -152,6 +155,51 @@ public static function provideValidatingInvalidTags(): iterable ]; } + public function testEmptyTagNameAfterSanitization(): void + { + $data = [ + '!@#' => 'value', + ]; + + $expected = <<<'EOH' + + value + + EOH; + + $this->assertSame($expected, $this->xmlFormatter->format($data)); + } + + public function testStringableObjectValue(): void + { + $value = new class () implements Stringable { + public function __toString(): string + { + return 'bar'; + } + }; + + $data = [ + 'foo' => $value, + ]; + + $expected = <<<'EOH' + + bar + + EOH; + + $this->assertSame($expected, $this->xmlFormatter->format($data)); + } + + public function testNonStringableObjectValueThrows(): void + { + $this->expectException(FormatException::class); + $this->expectExceptionMessage('Cannot convert the value of type "stdClass" to a string for XML output.'); + + $this->xmlFormatter->format(['foo' => new stdClass()]); + } + public function testDeepNestedArrayToXml(): void { $data = [ diff --git a/tests/system/HTTP/CURLRequestTest.php b/tests/system/HTTP/CURLRequestTest.php index f0260ff839c9..e903593bf6fc 100644 --- a/tests/system/HTTP/CURLRequestTest.php +++ b/tests/system/HTTP/CURLRequestTest.php @@ -673,7 +673,6 @@ public function testDecodeContent(): void public function testDecodeContentWithoutAccept(): void { - // $this->request->setHeader('Accept-Encoding', 'cobol'); $this->request->request('get', 'http://example.com', [ 'decode_content' => true, ]); @@ -682,8 +681,11 @@ public function testDecodeContentWithoutAccept(): void $this->assertArrayHasKey(CURLOPT_ENCODING, $options); $this->assertSame('', $options[CURLOPT_ENCODING]); - $this->assertArrayHasKey(CURLOPT_HTTPHEADER, $options); - $this->assertSame('Accept-Encoding', $options[CURLOPT_HTTPHEADER]); + $this->assertArrayNotHasKey(CURLOPT_HTTPHEADER, $options); + + // Verify that curl_setopt_array accepts the options without TypeError + $handle = curl_init(); + $this->assertTrue(curl_setopt_array($handle, $options)); } public function testAllowRedirectsOptionFalse(): void @@ -1629,7 +1631,7 @@ public function testSetCURLOptions(): void // Test Response Options (decode_content / http_errors) $options12 = $invoker([], ['decode_content' => true]); $this->assertSame('', $options12[CURLOPT_ENCODING]); - $this->assertSame('Accept-Encoding', $options12[CURLOPT_HTTPHEADER]); + $this->assertArrayNotHasKey(CURLOPT_HTTPHEADER, $options12); // Test Protocol/Misc/Client Options $options13 = $invoker([], [ diff --git a/tests/system/HTTP/ContentSecurityPolicyTest.php b/tests/system/HTTP/ContentSecurityPolicyTest.php index 780cb99f31ea..75a5cceac55b 100644 --- a/tests/system/HTTP/ContentSecurityPolicyTest.php +++ b/tests/system/HTTP/ContentSecurityPolicyTest.php @@ -114,6 +114,139 @@ public function testReportOnly(): void $this->assertHeaderNotEmitted('Reporting-Endpoints:'); } + public function testReportOnlyAfterConstructionAppliesToConfigSources(): void + { + $config = new CSPConfig(); + $config->reportOnly = false; + $config->scriptSrc = ['self', 'cdn.example.com']; + $config->styleSrc = 'self'; + $config->formAction = 'self'; + + $csp = new ContentSecurityPolicy($config); + $csp->reportOnly(true); + + $response = new Response(new App()); + $response->pretend(true); + $response->setBody('Hello'); + $csp->finalize($response); + + $this->assertFalse($response->hasHeader('Content-Security-Policy')); + + $directives = $this->getCspDirectives($response->getHeaderLine('Content-Security-Policy-Report-Only')); + $this->assertContains("script-src 'self' cdn.example.com", $directives); + $this->assertContains("style-src 'self'", $directives); + $this->assertContains("form-action 'self'", $directives); + } + + public function testReportOnlyDisabledAfterConstructionEnforcesConfigSources(): void + { + $config = new CSPConfig(); + $config->reportOnly = true; + $config->scriptSrc = ['self', 'cdn.example.com']; + + $csp = new ContentSecurityPolicy($config); + $csp->reportOnly(false); + + $response = new Response(new App()); + $response->pretend(true); + $response->setBody('Hello'); + $csp->finalize($response); + + $this->assertFalse($response->hasHeader('Content-Security-Policy-Report-Only')); + $this->assertContains( + "script-src 'self' cdn.example.com", + $this->getCspDirectives($response->getHeaderLine('Content-Security-Policy')), + ); + } + + public function testReportOnlyFromControllerSendsOnlyReportOnlyHeader(): void + { + $config = new CSPConfig(); + $config->reportOnly = false; + $config->upgradeInsecureRequests = true; + $config->defaultSrc = null; + $config->scriptSrc = ['self', 'assets.example.com', 'cdn.example.com']; + $config->scriptSrcElem = ['self', 'assets.example.com', 'cdn.example.com']; + $config->scriptSrcAttr = 'self'; + $config->styleSrc = ['self', 'assets.example.com']; + $config->styleSrcElem = ['self', 'assets.example.com']; + $config->styleSrcAttr = 'self'; + $config->imageSrc = ['self', 'data:', 'images.example.com']; + $config->baseURI = null; + $config->childSrc = ['self']; + $config->connectSrc = ['self', 'assets.example.com']; + $config->fontSrc = ['self', 'assets.example.com']; + $config->formAction = 'self'; + $config->frameAncestors = 'self'; + $config->frameSrc = ['self', 'player.example.com']; + $config->mediaSrc = null; + $config->objectSrc = 'self'; + $config->manifestSrc = null; + $config->workerSrc = []; + $config->pluginTypes = null; + $config->sandbox = null; + $config->autoNonce = false; + + Services::injectMock('csp', new ContentSecurityPolicy($config)); + + $response = new Response(config(App::class)); + $response->pretend(true); + $response->getCSP()->reportOnly(true); + $response->setBody('Hello'); + + ob_start(); + $response->send(); + ob_end_clean(); + + $this->assertFalse($response->hasHeader('Content-Security-Policy')); + $this->assertSame( + [ + "base-uri 'self'", + "child-src 'self'", + "connect-src 'self' assets.example.com", + "default-src 'self'", + "font-src 'self' assets.example.com", + "form-action 'self'", + "frame-ancestors 'self'", + "frame-src 'self' player.example.com", + "img-src 'self' data: images.example.com", + "object-src 'self'", + "script-src 'self' assets.example.com cdn.example.com", + "style-src 'self' assets.example.com", + "script-src-elem 'self' assets.example.com cdn.example.com", + "script-src-attr 'self'", + "style-src-elem 'self' assets.example.com", + "style-src-attr 'self'", + ], + $this->getCspDirectives($response->getHeaderLine('Content-Security-Policy-Report-Only')), + ); + } + + public function testReportOnlyKeepsFlagOfSourcesAddedBeforeIt(): void + { + $config = new CSPConfig(); + $config->reportOnly = false; + $config->scriptSrc = ['self']; + + $csp = new ContentSecurityPolicy($config); + $csp->addScriptSrc('cdn.example.com'); + $csp->reportOnly(true); + + $response = new Response(new App()); + $response->pretend(true); + $response->setBody('Hello'); + $csp->finalize($response); + + $this->assertContains( + 'script-src cdn.example.com', + $this->getCspDirectives($response->getHeaderLine('Content-Security-Policy')), + ); + $this->assertContains( + "script-src 'self'", + $this->getCspDirectives($response->getHeaderLine('Content-Security-Policy-Report-Only')), + ); + } + #[PreserveGlobalState(false)] #[RunInSeparateProcess] public function testExistingCSPHeaderStringIsPreserved(): void @@ -617,6 +750,50 @@ public function testDefaultSrc(): void $this->assertContains('default-src iffy.com', $this->getCspDirectives($header)); } + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testDefaultSrcRich(): void + { + $this->csp->reportOnly(false); + $this->csp->setDefaultSrc(['self', 'maybe.com', 'iffy.com']); + $this->assertTrue($this->work()); + + $header = $this->getHeaderEmitted('Content-Security-Policy'); + $this->assertIsString($header); + $this->assertContains("default-src 'self' maybe.com iffy.com", $this->getCspDirectives($header)); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testAddDefaultSrc(): void + { + $this->csp->addDefaultSrc('maybe.com'); + $this->csp->addDefaultSrc('iffy.com', true); + $this->assertTrue($this->work()); + + $header = $this->getHeaderEmitted('Content-Security-Policy-Report-Only'); + $this->assertIsString($header); + $this->assertContains('default-src iffy.com', $this->getCspDirectives($header)); + + $header = $this->getHeaderEmitted('Content-Security-Policy'); + $this->assertIsString($header); + $this->assertContains('default-src maybe.com', $this->getCspDirectives($header)); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testAddDefaultSrcAppends(): void + { + $this->csp->reportOnly(false); + $this->csp->setDefaultSrc('maybe.com'); + $this->csp->addDefaultSrc('iffy.com'); + $this->assertTrue($this->work()); + + $header = $this->getHeaderEmitted('Content-Security-Policy'); + $this->assertIsString($header); + $this->assertContains('default-src maybe.com iffy.com', $this->getCspDirectives($header)); + } + #[PreserveGlobalState(false)] #[RunInSeparateProcess] public function testReportURI(): void @@ -756,6 +933,237 @@ public function testSandboxFlags(): void $this->assertContains('sandbox allow-popups allow-top-navigation', $this->getCspDirectives($header)); } + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testSandboxEmptyFlagThenFlags(): void + { + $this->csp->reportOnly(false); + $this->csp->addSandbox(''); + $this->csp->addSandbox('allow-scripts'); + $this->assertTrue($this->work()); + + $header = $this->getHeaderEmitted('Content-Security-Policy'); + $this->assertIsString($header); + $this->assertContains('sandbox allow-scripts', $this->getCspDirectives($header)); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testAddOptionWithSpaceSeparatedString(): void + { + $config = new CSPConfig(); + $config->baseURI = 'self example.com'; + + $csp = new ContentSecurityPolicy($config); + + $response = new Response(config(App::class)); + $response->pretend(true); + + $csp->addBaseURI('another.com'); + $csp->finalize($response); + + $directives = $this->getCspDirectives($response->getHeaderLine('Content-Security-Policy')); + $this->assertContains("base-uri 'self' example.com another.com", $directives); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testAddOptionWithEmptyStringTarget(): void + { + $config = new CSPConfig(); + $config->baseURI = ''; + + $csp = new ContentSecurityPolicy($config); + + $response = new Response(config(App::class)); + $response->pretend(true); + + $csp->addBaseURI('example.com'); + $csp->finalize($response); + + $directives = $this->getCspDirectives($response->getHeaderLine('Content-Security-Policy')); + $this->assertContains('base-uri example.com', $directives); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testAddOptionNumericString(): void + { + $this->csp->addBaseURI('0'); + $this->assertTrue($this->work()); + + $header = $this->getHeaderEmitted('Content-Security-Policy'); + $this->assertIsString($header); + $this->assertContains('base-uri 0', $this->getCspDirectives($header)); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testConfiguredBareSandboxWithAddSandboxNoop(): void + { + $config = new CSPConfig(); + $config->sandbox = ''; + + $csp = new ContentSecurityPolicy($config); + + $response = new Response(config(App::class)); + $response->pretend(true); + + $csp->addSandbox([]); + $csp->finalize($response); + + $directives = $this->getCspDirectives($response->getHeaderLine('Content-Security-Policy')); + $this->assertContains('sandbox', $directives); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testAddFrameAncestorEmptyString(): void + { + $this->csp->addFrameAncestor(''); + $this->assertTrue($this->work()); + + $header = $this->getHeaderEmitted('Content-Security-Policy'); + $this->assertIsString($header); + $this->assertContains('frame-ancestors', $this->getCspDirectives($header)); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testSandboxMixedModeEnforcedAndReportOnly(): void + { + $this->csp->addSandbox('', false); + $this->csp->addSandbox('allow-scripts', true); + $this->assertTrue($this->work()); + + $enforced = $this->getHeaderEmitted('Content-Security-Policy'); + $this->assertIsString($enforced); + $this->assertContains('sandbox', $this->getCspDirectives($enforced)); + + $reportOnly = $this->getHeaderEmitted('Content-Security-Policy-Report-Only'); + $this->assertIsString($reportOnly); + $this->assertContains('sandbox allow-scripts', $this->getCspDirectives($reportOnly)); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testOptionMovedBetweenReportingModesPreservesEmptyMarker(): void + { + $this->csp->addSandbox('', false); + $this->csp->addSandbox('allow-scripts', false); + $this->csp->addSandbox('allow-scripts', true); + + $this->csp->addFrameAncestor('', false); + $this->csp->addFrameAncestor('https://example.com', false); + $this->csp->addFrameAncestor('https://example.com', true); + + $this->assertTrue($this->work()); + + $enforced = $this->getHeaderEmitted('Content-Security-Policy'); + $this->assertIsString($enforced); + $enforcedDirectives = $this->getCspDirectives($enforced); + $this->assertContains('sandbox', $enforcedDirectives); + $this->assertContains('frame-ancestors', $enforcedDirectives); + $this->assertNotContains('sandbox allow-scripts', $enforcedDirectives); + $this->assertNotContains('frame-ancestors https://example.com', $enforcedDirectives); + + $reportOnly = $this->getHeaderEmitted('Content-Security-Policy-Report-Only'); + $this->assertIsString($reportOnly); + $reportOnlyDirectives = $this->getCspDirectives($reportOnly); + $this->assertContains('sandbox allow-scripts', $reportOnlyDirectives); + $this->assertContains('frame-ancestors https://example.com', $reportOnlyDirectives); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testConfiguredEmptyDirectivesEmitted(): void + { + $config = new CSPConfig(); + $config->sandbox = ''; + $config->frameAncestors = ''; + $config->formAction = ''; + $config->fontSrc = ''; + $config->connectSrc = ''; + + $csp = new ContentSecurityPolicy($config); + + $response = new Response(config(App::class)); + $response->pretend(true); + + $csp->finalize($response); + + $directives = $this->getCspDirectives($response->getHeaderLine('Content-Security-Policy')); + $this->assertContains('sandbox', $directives); + $this->assertContains('frame-ancestors', $directives); + $this->assertContains('form-action', $directives); + $this->assertContains('font-src', $directives); + $this->assertContains('connect-src', $directives); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testConfiguredEmptyFormActionWithAddFormActionNoop(): void + { + $config = new CSPConfig(); + $config->formAction = ''; + + $csp = new ContentSecurityPolicy($config); + + $response = new Response(config(App::class)); + $response->pretend(true); + + $csp->addFormAction([]); + $csp->finalize($response); + + $directives = $this->getCspDirectives($response->getHeaderLine('Content-Security-Policy')); + $this->assertContains('form-action', $directives); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testClearDirectiveThenAddEmptySource(): void + { + $this->csp->clearDirective('form-action'); + $this->csp->addFormAction(''); + $this->csp->clearDirective('connect-src'); + $this->csp->addConnectSrc(''); + $this->assertTrue($this->work()); + + $header = $this->getHeaderEmitted('Content-Security-Policy'); + $this->assertIsString($header); + $directives = $this->getCspDirectives($header); + $this->assertContains('form-action', $directives); + $this->assertContains('connect-src', $directives); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testAddEmptySourceToFontSrc(): void + { + $this->csp->addFontSrc(''); + $this->assertTrue($this->work()); + + $header = $this->getHeaderEmitted('Content-Security-Policy'); + $this->assertIsString($header); + $directives = $this->getCspDirectives($header); + $this->assertContains('font-src', $directives); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testRedundantEmptySourceFilteredWhenOtherSourcesPresent(): void + { + // connectSrc defaults to 'self' + $this->csp->addConnectSrc(''); + $this->assertTrue($this->work()); + + $header = $this->getHeaderEmitted('Content-Security-Policy'); + $this->assertIsString($header); + $directives = $this->getCspDirectives($header); + $this->assertContains("connect-src 'self'", $directives); + $this->assertNotContains('connect-src', $directives); + } + #[PreserveGlobalState(false)] #[RunInSeparateProcess] public function testUpgradeInsecureRequests(): void diff --git a/tests/system/HTTP/IncomingRequestTest.php b/tests/system/HTTP/IncomingRequestTest.php index 464b46e2ce2c..cc0aee29ab2b 100644 --- a/tests/system/HTTP/IncomingRequestTest.php +++ b/tests/system/HTTP/IncomingRequestTest.php @@ -828,6 +828,12 @@ public static function provideIsSecureWithForwardedHeaders(): iterable 'X-Forwarded-Proto trusted proxy IP' => [ 'X-Forwarded-Proto', 'https', '10.0.1.200', ['10.0.1.200' => 'X-Forwarded-For'], true, ], + 'X-Forwarded-Proto trusted proxy IP uppercase' => [ + 'X-Forwarded-Proto', 'HTTPS', '10.0.1.200', ['10.0.1.200' => 'X-Forwarded-For'], true, + ], + 'X-Forwarded-Proto trusted proxy IP with surrounding whitespace' => [ + 'X-Forwarded-Proto', ' https ', '10.0.1.200', ['10.0.1.200' => 'X-Forwarded-For'], true, + ], 'X-Forwarded-Proto no trusted proxies' => [ 'X-Forwarded-Proto', 'https', '10.0.1.200', [], false, ], @@ -879,6 +885,32 @@ public static function provideIsSecureWithForwardedHeaders(): iterable ]; } + public function testIsSecureWithForwardedProtoSingleElementArray(): void + { + service('superglobals')->setServer('REMOTE_ADDR', '10.0.1.200'); + + $config = new App(); + $config->proxyIPs = ['10.0.1.200' => 'X-Forwarded-For']; + + $request = $this->createRequest($config); + $request->setHeader('X-Forwarded-Proto', ['https']); + + $this->assertTrue($request->isSecure()); + } + + public function testIsSecureWithForwardedProtoMultipleValuesIsNotSecure(): void + { + service('superglobals')->setServer('REMOTE_ADDR', '10.0.1.200'); + + $config = new App(); + $config->proxyIPs = ['10.0.1.200' => 'X-Forwarded-For']; + + $request = $this->createRequest($config); + $request->setHeader('X-Forwarded-Proto', ['https', 'http']); + + $this->assertFalse($request->isSecure()); + } + public function testUserAgent(): void { service('superglobals')->setServer('HTTP_USER_AGENT', 'Mozilla'); @@ -1049,6 +1081,46 @@ public function testSetPath(): void $this->assertSame('foobar', $request->getPath()); } + #[DataProvider('provideGetPostMaxSize')] + public function testGetPostMaxSize(string $value, int $expected): void + { + $getPostMaxSize = $this->getPrivateMethodInvoker($this->request, 'getPostMaxSize'); + + $this->assertSame($expected, $getPostMaxSize($value)); + } + + /** + * @return iterable + */ + public static function provideGetPostMaxSize(): iterable + { + yield 'zero means unlimited' => ['0', PHP_INT_MAX]; + + yield 'zero with suffix means unlimited' => ['0K', PHP_INT_MAX]; + + yield 'negative means unlimited' => ['-1', PHP_INT_MAX]; + + yield 'gigabytes' => ['1G', 1024 ** 3]; + + yield 'lowercased gigabytes' => ['1g', 1024 ** 3]; + + yield 'megabytes' => ['8M', 8 * 1024 ** 2]; + + yield 'lowercased megabytes' => ['8m', 8 * 1024 ** 2]; + + yield 'kilobytes' => ['512K', 512 * 1024]; + + yield 'lowercased kilobytes' => ['512k', 512 * 1024]; + + yield 'hexadecimal' => ['0x10M', 16 * 1024 ** 2]; + + yield 'octal' => ['010M', 8 * 1024 ** 2]; + + yield 'binary' => ['0b10M', 2 * 1024 ** 2]; + + yield 'bytes' => ['12345', 12345]; + } + public function testGetIPAddressNormal(): void { $expected = '123.123.123.123'; diff --git a/tests/system/HTTP/NegotiateTest.php b/tests/system/HTTP/NegotiateTest.php index 083846c8a1a8..dea9169d7960 100644 --- a/tests/system/HTTP/NegotiateTest.php +++ b/tests/system/HTTP/NegotiateTest.php @@ -189,6 +189,125 @@ public function testBestMatchLowQuality(): void $this->assertSame('apple/mac', $this->negotiate->media(['popcorn/cheddar', 'apple/mac'], false)); } + public function testWildcardsSkipExcludedValues(): void + { + $this->request->setHeader('Accept', 'application/json;q=0, */*'); + $this->assertSame('text/html', $this->negotiate->media(['application/json', 'text/html'], true)); + + $this->request->setHeader('Accept', 'text/*, text/html;q=0'); + $this->assertSame('text/plain', $this->negotiate->media(['text/html', 'text/plain'], true)); + + $this->request->setHeader('Accept', '*/*, text/html;q=0'); + $this->assertSame('', $this->negotiate->media(['text/html'], true)); + + $this->request->setHeader('Accept-Charset', 'utf-8;q=0, *'); + $this->assertSame('iso-8859-1', $this->negotiate->charset(['utf-8', 'iso-8859-1'])); + + $this->request->setHeader('Accept-Encoding', 'gzip;q=0, *'); + $this->assertSame('br', $this->negotiate->encoding(['gzip', 'br'])); + + $this->request->setHeader('Accept-Language', 'en;q=0, *'); + $this->assertSame('fr', $this->negotiate->language(['en', 'fr'])); + + config(Feature::class)->strictLocaleNegotiation = true; + + $this->assertSame('de', $this->negotiate->language(['en', 'de'])); + } + + public function testMediaRangesExcludeMatchingValues(): void + { + $this->request->setHeader('Accept', 'text/*;q=0, */*'); + $this->assertSame('application/json', $this->negotiate->media(['text/html', 'application/json'], true)); + + $this->request->setHeader('Accept', 'text/html;q=0, */*'); + $this->assertSame('text/plain', $this->negotiate->media(['text/html;charset=utf-8', 'text/plain'], true)); + + $this->request->setHeader('Accept', 'text/html;q=0, text/*'); + $this->assertSame('text/css', $this->negotiate->media(['text/html;charset=utf-8', 'text/css'], true)); + + // a range only excludes the values that have its parameters + $this->request->setHeader('Accept', 'text/html;level=1;q=0, */*'); + $this->assertSame('text/html', $this->negotiate->media(['text/html;level=1', 'text/html'], true)); + } + + public function testRangesExcludeValuesIgnoringCase(): void + { + $this->request->setHeader('Accept', 'text/*;q=0, TEXT/HTML;q=0.5, */*;q=0.1'); + $this->assertSame('text/html', $this->negotiate->media(['text/html'], true)); + + $this->request->setHeader('Accept', 'TEXT/*;q=0, */*'); + $this->assertSame('application/json', $this->negotiate->media(['text/html', 'application/json'], true)); + + $this->request->setHeader('Accept', 'text/html;CHARSET="UTF-8";q=0, */*'); + $this->assertSame('text/plain', $this->negotiate->media(['text/html;charset=utf-8', 'text/plain'], true)); + + $this->request->setHeader('Accept-Charset', 'UTF-8;q=0, *'); + $this->assertSame('iso-8859-1', $this->negotiate->charset(['utf-8', 'iso-8859-1'])); + + $this->request->setHeader('Accept-Encoding', 'GZIP;q=0, *'); + $this->assertSame('br', $this->negotiate->encoding(['gzip', 'br'])); + } + + public function testMediaRangeParametersAddSpecificity(): void + { + $this->request->setHeader('Accept', 'text/*;charset=utf-8;q=0, text/*'); + $this->assertSame( + 'text/plain;charset=iso-8859-1', + $this->negotiate->media(['text/html;charset=utf-8', 'text/plain;charset=iso-8859-1'], true), + ); + + $this->request->setHeader('Accept', '*/*;charset=utf-8;q=0, */*'); + $this->assertSame('application/json', $this->negotiate->media(['text/html;charset=utf-8', 'application/json'], true)); + + // a type/subtype range is more specific than a type/* range with more parameters + $this->request->setHeader('Accept', 'text/html;q=0, text/*;charset=utf-8;level=1'); + $this->assertSame( + 'text/plain;charset=utf-8;level=1', + $this->negotiate->media(['text/html;charset=utf-8;level=1', 'text/plain;charset=utf-8;level=1'], true), + ); + } + + public function testMoreSpecificRangesTakePrecedence(): void + { + $this->request->setHeader('Accept', 'text/html, text/*;q=0'); + $this->assertSame('text/html', $this->negotiate->media(['text/plain', 'text/html'], true)); + $this->assertSame('', $this->negotiate->media(['text/plain'], true)); + + $this->request->setHeader('Accept', 'text/html;q=0, text/html;level=1;q=0.5, */*'); + $this->assertSame('text/html;level=1', $this->negotiate->media(['text/html', 'text/html;level=1'], true)); + + $this->request->setHeader('Accept-Language', 'en;q=0, en-GB;q=0.5, *'); + $this->assertSame('en-GB', $this->negotiate->language(['en-US', 'en-GB', 'fr'])); + + config(Feature::class)->strictLocaleNegotiation = true; + + $this->assertSame('en-GB', $this->negotiate->language(['en-US', 'en-GB'])); + } + + public function testLanguageRangesExcludeRegionalLocales(): void + { + $this->request->setHeader('Accept-Language', 'en;q=0, *'); + $this->assertSame('fr', $this->negotiate->language(['en-US', 'fr'])); + + // language ranges are case-insensitive and end on a subtag boundary + $this->request->setHeader('Accept-Language', 'zh;q=0, *'); + $this->assertSame('zha', $this->negotiate->language(['ZH-Hant', 'zha'])); + + $this->request->setHeader('Accept-Language', 'en-US, en-GB;q=0'); + $this->assertSame('en-AU', $this->negotiate->language(['en-GB', 'en-AU'])); + + config(Feature::class)->strictLocaleNegotiation = true; + + $this->request->setHeader('Accept-Language', 'en;q=0, *'); + $this->assertSame('fr', $this->negotiate->language(['en-US', 'fr'])); + + $this->request->setHeader('Accept-Language', 'zh;q=0, *'); + $this->assertSame('zha', $this->negotiate->language(['ZH-Hant', 'zha'])); + + $this->request->setHeader('Accept-Language', 'en-US, en-GB;q=0'); + $this->assertSame('en-AU', $this->negotiate->language(['en-GB', 'en-AU'])); + } + public function testBestMatchOnlyLowQuality(): void { $this->request->setHeader('Accept', 'popcorn/cheddar;q=0'); @@ -197,6 +316,15 @@ public function testBestMatchOnlyLowQuality(): void $this->assertSame('popcorn/cheddar', $this->negotiate->media(['popcorn/cheddar', 'apple/mac'], false)); } + public function testBestMatchIgnoresMediaRangesWithoutSubtype(): void + { + $this->request->setHeader('Accept', 'json, application/json'); + $this->assertSame('application/json', $this->negotiate->media(['text/html', 'application/json'], true)); + + $this->request->setHeader('Accept', ', text/html'); + $this->assertSame('text/html', $this->negotiate->media(['application/json', 'text/html'], true)); + } + public function testParameterMatching(): void { $this->request->setHeader('Accept', 'popcorn/cheddar;a=0;b=1'); diff --git a/tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php b/tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php index 4bc29fbecc4d..a61f19a2fefe 100644 --- a/tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php +++ b/tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php @@ -258,6 +258,99 @@ public function testQueryStringWithQueryString(): void $this->assertSame(['code' => 'good'], $_GET); } + public function testQueryStringRefreshesRequestWithoutOverwritingCustomValues(): void + { + if (! $this->effectiveRequestOrderIncludesGet()) { + $this->markTestSkipped('The effective request order does not include GET.'); + } + + $superglobals = new Superglobals( + [ + 'REQUEST_URI' => '/index.php?/ci/woot?code=good', + 'QUERY_STRING' => '/ci/woot?code=good', + 'SCRIPT_NAME' => '/index.php', + ], + ['/ci/woot?code' => 'good', 'override' => 'from get'], + ['posted' => 'post'], + [], + [], + [ + '/ci/woot?code' => 'good', + 'override' => 'custom', + 'posted' => 'post', + 'unrelated' => 'keep', + ], + ); + Services::injectMock('superglobals', $superglobals); + + $factory = new SiteURIFactory(new App(), $superglobals); + + $this->assertSame('ci/woot', $factory->detectRoutePath('QUERY_STRING')); + $this->assertSame([ + 'override' => 'custom', + 'posted' => 'post', + 'unrelated' => 'keep', + 'code' => 'good', + ], $superglobals->getRequestArray()); + $this->assertSame($superglobals->getRequestArray(), $_REQUEST); + + $superglobals->setGet('code', 'later'); + + $this->assertSame('good', $superglobals->request('code')); + } + + public function testRequestURIRefreshesRequestForValidation(): void + { + if (! $this->effectiveRequestOrderIncludesGet()) { + $this->markTestSkipped('The effective request order does not include GET.'); + } + + $superglobals = new Superglobals( + [ + 'REQUEST_URI' => '/index.php/woot?code=good', + 'SCRIPT_NAME' => '/index.php', + ], + ['code' => 'stale'], + [], + [], + [], + ['code' => 'stale', 'unrelated' => 'keep'], + ); + Services::injectMock('superglobals', $superglobals); + + $config = new App(); + $factory = new SiteURIFactory($config, $superglobals); + + $this->assertSame('woot', $factory->detectRoutePath('REQUEST_URI')); + $this->assertSame(['code' => 'good', 'unrelated' => 'keep'], $superglobals->getRequestArray()); + + $request = new IncomingRequest($config, new SiteURI($config), null, new UserAgent()); + + $this->assertSame('good', $request->getVar('code')); + $this->assertTrue(service('validation')->withRequest($request)->setRules(['code' => 'in_list[good]'])->run()); + } + + /** + * Whether GET is part of the effective request order on this host. + * + * Mirrors Superglobals::getRequestData(): uses `request_order`, falls + * back to `variables_order`, then to PHP's default GP. + */ + private function effectiveRequestOrderIncludesGet(): bool + { + $order = (string) ini_get('request_order'); + + if ($order === '') { + $order = (string) ini_get('variables_order'); + } + + if ($order === '') { + $order = 'GP'; + } + + return str_contains(strtoupper($order), 'G'); + } + public function testQueryStringEmpty(): void { // /index.php? diff --git a/tests/system/Helpers/FormHelperTest.php b/tests/system/Helpers/FormHelperTest.php index c24d34b04a3e..7ed8c8668123 100644 --- a/tests/system/Helpers/FormHelperTest.php +++ b/tests/system/Helpers/FormHelperTest.php @@ -22,10 +22,12 @@ use Config\Filters; use Config\Services; use PHPUnit\Framework\Attributes\BackupGlobals; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\PreserveGlobalState; use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\Attributes\WithoutErrorHandler; +use Stringable; /** * @internal @@ -867,6 +869,38 @@ public function testSetValue(): void $this->assertSame('baz', set_value('foo', 'baz')); } + #[DataProvider('provideSetValueCastsNonArrayDefaultToString')] + public function testSetValueCastsNonArrayDefaultToString(mixed $default, string $expected): void + { + $this->assertSame($expected, set_value('foo', $default)); + } + + /** + * @return iterable + */ + public static function provideSetValueCastsNonArrayDefaultToString(): iterable + { + $stringable = self::createStub(Stringable::class); + $stringable->method('__toString')->willReturn(''); + + yield 'int' => [1, '1']; + + yield 'float' => [1.5, '1.5']; + + yield 'true' => [true, '1']; + + yield 'false' => [false, '']; + + yield 'null' => [null, '']; + + yield 'stringable' => [$stringable, '<bar>']; + } + + public function testSetValueKeepsArrayDefault(): void + { + $this->assertSame(['a', 'b'], set_value('foo', ['a', 'b'])); + } + public function testSetSelect(): void { $_SESSION['_ci_old_input']['post']['foo'] = 'bar'; diff --git a/tests/system/Helpers/SecurityHelperTest.php b/tests/system/Helpers/SecurityHelperTest.php index ba0c01849458..383d74b4fe8e 100644 --- a/tests/system/Helpers/SecurityHelperTest.php +++ b/tests/system/Helpers/SecurityHelperTest.php @@ -16,6 +16,7 @@ use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockSecurity; use Config\Security as SecurityConfig; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use Tests\Support\Config\Services; @@ -45,6 +46,161 @@ public function testSanitizeFilenameStripsExtras(): void $this->assertSame('foo ', sanitize_filename($filename)); } + #[DataProvider('provideSanitizeFilenamePaths')] + public function testSanitizeFilenamePaths(string $filename, bool $relativePath, string $expected): void + { + $this->assertSame($expected, sanitize_filename($filename, $relativePath)); + } + + /** + * @return iterable + */ + public static function provideSanitizeFilenamePaths(): iterable + { + $backslash = chr(92); + $doubleBackslash = str_repeat($backslash, 2); + $tripleBackslash = str_repeat($backslash, 3); + $fourBackslashes = str_repeat($backslash, 4); + $eightBackslashes = str_repeat($backslash, 8); + + return [ + 'empty filename' => ['', false, ''], + 'escaped NUL in filename' => ['a' . $backslash . '0b', false, 'a0b'], + 'escaped NUL in relative path' => ['a' . $backslash . '0b', true, 'a0b'], + 'literal NUL in filename' => ['a' . chr(0) . 'b', false, 'ab'], + 'literal NUL in relative path' => ['a' . chr(0) . 'b', true, 'ab'], + 'strip forward path' => [ + 'approved/folder/file.txt', + false, + 'approvedfolderfile.txt', + ], + 'preserve forward relative path' => [ + 'approved/folder/file.txt', + true, + 'approved/folder/file.txt', + ], + 'strip forward traversal' => ['../../public/file.txt', false, 'publicfile.txt'], + 'relative forward traversal' => ['../../public/file.txt', true, 'public/file.txt'], + 'forward traversal exposed by filters' => ['.<.>/public/file.txt', true, 'public/file.txt'], + 'forward traversal split by backslash' => ['.' . $backslash . './file.txt', true, 'file.txt'], + 'backslash traversal split by backslash' => [ + '.' . $backslash . '.' . $doubleBackslash . 'file.txt', + true, + '..file.txt', + ], + 'mixed separators' => [ + '..' . $doubleBackslash . '../public' . $doubleBackslash . 'file.txt', + true, + '..publicfile.txt', + ], + 'backslashes exposed by filters' => [ + 'folder' . $backslash . '<' . $backslash . 'file.txt', + true, + 'folderfile.txt', + ], + 'filename traversal with single backslash' => [ + '..' . $backslash . '..' . $backslash . 'public' . $backslash . 'file.txt', + false, + '....publicfile.txt', + ], + 'filename path with single backslash' => [ + 'folder' . $backslash . 'file.txt', + false, + 'folderfile.txt', + ], + 'relative traversal with single backslash' => [ + '..' . $backslash . '..' . $backslash . 'public' . $backslash . 'file.txt', + true, + '....publicfile.txt', + ], + 'relative path with single backslash' => [ + 'folder' . $backslash . 'file.txt', + true, + 'folderfile.txt', + ], + 'filename traversal with double backslashes' => [ + '..' . $doubleBackslash . '..' . $doubleBackslash . 'public' . $doubleBackslash . 'file.txt', + false, + '....publicfile.txt', + ], + 'filename path with double backslashes' => [ + 'folder' . $doubleBackslash . 'file.txt', + false, + 'folderfile.txt', + ], + 'relative traversal with double backslashes' => [ + '..' . $doubleBackslash . '..' . $doubleBackslash . 'public' . $doubleBackslash . 'file.txt', + true, + '....publicfile.txt', + ], + 'relative path with double backslashes' => [ + 'folder' . $doubleBackslash . 'file.txt', + true, + 'folderfile.txt', + ], + 'filename traversal with triple backslashes' => [ + '..' . $tripleBackslash . '..' . $tripleBackslash . 'public' . $tripleBackslash . 'file.txt', + false, + '....publicfile.txt', + ], + 'filename path with triple backslashes' => [ + 'folder' . $tripleBackslash . 'file.txt', + false, + 'folderfile.txt', + ], + 'relative traversal with triple backslashes' => [ + '..' . $tripleBackslash . '..' . $tripleBackslash . 'public' . $tripleBackslash . 'file.txt', + true, + '....publicfile.txt', + ], + 'relative path with triple backslashes' => [ + 'folder' . $tripleBackslash . 'file.txt', + true, + 'folderfile.txt', + ], + 'filename traversal with four backslashes' => [ + '..' . $fourBackslashes . '..' . $fourBackslashes . 'public' . $fourBackslashes . 'file.txt', + false, + '....publicfile.txt', + ], + 'filename path with four backslashes' => [ + 'folder' . $fourBackslashes . 'file.txt', + false, + 'folderfile.txt', + ], + 'relative traversal with four backslashes' => [ + '..' . $fourBackslashes . '..' . $fourBackslashes . 'public' . $fourBackslashes . 'file.txt', + true, + '....publicfile.txt', + ], + 'relative path with four backslashes' => [ + 'folder' . $fourBackslashes . 'file.txt', + true, + 'folderfile.txt', + ], + 'filename traversal with eight backslashes' => [ + '..' . $eightBackslashes . '..' . $eightBackslashes . 'public' . $eightBackslashes . 'file.txt', + false, + '....publicfile.txt', + ], + 'filename path with eight backslashes' => [ + 'folder' . $eightBackslashes . 'file.txt', + false, + 'folderfile.txt', + ], + 'relative traversal with eight backslashes' => [ + '..' . $eightBackslashes . '..' . $eightBackslashes . 'public' . $eightBackslashes . 'file.txt', + true, + '....publicfile.txt', + ], + 'relative path with eight backslashes' => [ + 'folder' . $eightBackslashes . 'file.txt', + true, + 'folderfile.txt', + ], + ]; + } + public function testStripImageTags(): void { $this->assertSame('http://example.com/spacer.gif', strip_image_tags('http://example.com/spacer.gif')); diff --git a/tests/system/I18n/TimeLegacyTest.php b/tests/system/I18n/TimeLegacyTest.php index ec6cdb042640..d3098e6e8814 100644 --- a/tests/system/I18n/TimeLegacyTest.php +++ b/tests/system/I18n/TimeLegacyTest.php @@ -1147,6 +1147,15 @@ public function testUnserializeTimeObject(): void $this->assertNotSame($time1, $time2); } + public function testUnserializeRestoresTimezoneAndLocale(): void + { + $time = unserialize(serialize(TimeLegacy::parse('2024-03-01 10:00:00', 'Europe/Paris', 'fr_FR'))); + + $this->assertSame('Europe/Paris', $time->getTimezoneName()); + $this->assertSame('mars', $time->toLocalizedString('MMMM')); + $this->assertSame(1, $time->difference(TimeLegacy::parse('2024-03-02 10:00:00', 'Europe/Paris'))->getDays()); + } + public function testSetTestNowWithFaLocale(): void { Locale::setDefault('fa'); diff --git a/tests/system/I18n/TimeTest.php b/tests/system/I18n/TimeTest.php index 3368e5802d54..de032a86f03b 100644 --- a/tests/system/I18n/TimeTest.php +++ b/tests/system/I18n/TimeTest.php @@ -1358,6 +1358,29 @@ public function testUnserializeTimeObject(): void $this->assertNotSame($time1, $time2); } + public function testUnserializeRestoresTimezoneAndLocale(): void + { + $time = unserialize(serialize(Time::parse('2024-03-01 10:00:00', 'Europe/Paris', 'fr_FR'))); + + $this->assertSame('Europe/Paris', $time->getTimezoneName()); + $this->assertSame('mars', $time->toLocalizedString('MMMM')); + $this->assertSame(1, $time->difference(Time::parse('2024-03-02 10:00:00', 'Europe/Paris'))->getDays()); + } + + public function testUnserializeFallsBackWithoutTraitProperties(): void + { + $payload = sprintf( + 'O:%d:"%s":3:{s:4:"date";s:26:"2024-03-01 10:00:00.000000";s:13:"timezone_type";i:3;s:8:"timezone";s:12:"Europe/Paris";}', + strlen(Time::class), + Time::class, + ); + $time = unserialize($payload); + + $this->assertSame('Europe/Paris', $time->getTimezoneName()); + $this->assertSame('March', $time->toLocalizedString('MMMM')); + $this->assertSame('2024-03-01 10:00:00', (string) $time); + } + public function testSetTestNowWithTimeZone(): void { Time::setTestNow('2017/03/10 12:00', 'Asia/Tokyo'); diff --git a/tests/system/Models/ReplaceModelTest.php b/tests/system/Models/ReplaceModelTest.php index 776fcf7647fb..6be2fc6e51a9 100644 --- a/tests/system/Models/ReplaceModelTest.php +++ b/tests/system/Models/ReplaceModelTest.php @@ -13,8 +13,13 @@ namespace CodeIgniter\Models; +use CodeIgniter\Database\Exceptions\DataException; +use CodeIgniter\I18n\Time; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Models\JobModel; use Tests\Support\Models\UserModel; +use Tests\Support\Models\WithoutAutoIncrementModel; /** * @internal @@ -22,6 +27,13 @@ #[Group('DatabaseLive')] final class ReplaceModelTest extends LiveModelTestCase { + protected function tearDown(): void + { + parent::tearDown(); + + Time::setTestNow(); + } + public function testReplaceRespectsUseTimestamps(): void { $this->createModel(UserModel::class); @@ -47,4 +59,305 @@ public function testReplaceRespectsUseTimestamps(): void $this->assertStringContainsString('created_at', (string) $sql); $this->assertStringContainsString('updated_at', (string) $sql); } + + public function testReplaceProtectsFieldsAndPreservesPrimaryKey(): void + { + $model = $this->createModel(JobModel::class); + $id = $model->insert(['name' => 'Original', 'description' => null]); + + $model->setAllowedFields(['name']); + + $this->assertNotFalse($model->replace([ + 'id' => $id, + 'name' => 'Replacement', + 'description' => 'Protected value', + ])); + + $job = $model->find($id); + + $this->assertNotNull($job); + $this->assertSame('Replacement', $job->name); + $this->assertNull($job->description); + $this->dontSeeInDatabase('job', ['name' => 'Original']); + } + + public function testReplaceProtectsFieldsWhenReturningSQL(): void + { + $model = $this->createModel(JobModel::class)->setAllowedFields(['name']); + + $sql = $model->replace([ + 'id' => 1, + 'name' => 'Replacement', + 'description' => 'Protected value', + ], true); + + $this->assertIsString($sql); + $this->assertStringNotContainsString('description', $sql); + + if ($this->db->DBDriver !== 'SQLSRV') { + $this->seeInDatabase('job', ['id' => 1, 'name' => 'Developer']); + } + } + + public function testReplaceWithProtectionDisabled(): void + { + $model = $this->createModel(JobModel::class)->setAllowedFields([])->protect(false); + + $this->assertNotFalse($model->replace([ + 'id' => 1, + 'name' => 'Replacement', + 'description' => 'Explicitly allowed', + ])); + + $this->seeInDatabase('job', [ + 'id' => 1, + 'name' => 'Replacement', + 'description' => 'Explicitly allowed', + ]); + } + + public function testReplaceWithoutAllowedFields(): void + { + $model = $this->createModel(JobModel::class)->setAllowedFields([]); + + $this->expectException(DataException::class); + $this->expectExceptionMessage('Allowed fields must be specified for model: "Tests\\Support\\Models\\JobModel"'); + + $model->replace(['id' => 1, 'name' => 'Replacement']); + } + + public function testReplacePreservesCustomPrimaryKey(): void + { + if ($this->db->DBDriver === 'SQLSRV') { + $this->markTestSkipped('SQLSRV replace() requires an identity column.'); + } + + $model = $this->createModel(WithoutAutoIncrementModel::class); + + $this->assertNotFalse($model->replace([ + 'key' => 'key', + 'value' => 'Replacement', + 'not_allowed' => 'Protected value', + ])); + + $this->seeInDatabase('without_auto_increment', ['key' => 'key', 'value' => 'Replacement']); + } + + #[DataProvider('provideReplacePreservesFieldOrder')] + public function testReplacePreservesFieldOrder(bool $primaryKeyFirst): void + { + $model = new class ($this->db) extends UserModel { + protected function doReplace(?array $row = null, bool $returnSQL = false): string + { + return implode(',', array_keys((array) $row)); + } + }; + + $data = $primaryKeyFirst + ? ['id' => 1, 'name' => 'Replacement'] + : ['name' => 'Replacement', 'id' => 1]; + $data['not_allowed'] = 'Protected value'; + + $this->assertSame( + $primaryKeyFirst ? 'id,name' : 'name,id', + $model->replace($data, true), + ); + } + + /** + * @return iterable + */ + public static function provideReplacePreservesFieldOrder(): iterable + { + yield 'primary key first' => [true]; + + yield 'primary key last' => [false]; + } + + public function testReplaceUsesCustomFieldProtection(): void + { + $model = new class ($this->db) extends JobModel { + protected function doProtectFields(array $row): array + { + $row = parent::doProtectFields($row); + + if (isset($row['name'])) { + $row['name'] = strtoupper($row['name']); + } + + return $row; + } + }; + + $this->assertNotFalse($model->replace(['id' => 1, 'name' => 'Replacement'])); + $this->seeInDatabase('job', ['id' => 1, 'name' => 'REPLACEMENT']); + } + + public function testReplaceFiltersInputTimestampsBeforeGeneratingTimestamps(): void + { + Time::setTestNow('2023-11-25 12:00:00'); + + $model = $this->createModel(JobModel::class); + $this->setPrivateProperty($model, 'useTimestamps', true); + + $this->assertNotFalse($model->replace([ + 'id' => 1, + 'name' => 'Replacement', + 'created_at' => 1, + 'updated_at' => 2, + ])); + + $this->seeInDatabase('job', [ + 'id' => 1, + 'created_at' => Time::now()->getTimestamp(), + 'updated_at' => Time::now()->getTimestamp(), + ]); + } + + public function testReplaceValidatesBeforeProtectingFields(): void + { + $model = $this->createModel(JobModel::class)->setAllowedFields(['name']); + $model->setValidationRules(['description' => 'in_list[Valid]']); + + $this->assertFalse($model->replace([ + 'id' => 1, + 'name' => 'Replacement', + 'description' => 'Invalid', + ])); + $this->assertArrayHasKey('description', $model->errors()); + $this->seeInDatabase('job', ['id' => 1, 'name' => 'Developer']); + } + + /** + * @param array{}|null $data + */ + #[DataProvider('provideReplaceWithEmptyDataUsesBuilderData')] + public function testReplaceWithEmptyDataUsesBuilderData(?array $data): void + { + $model = $this->createModel(JobModel::class); + $model->builder()->set(['id' => 1, 'name' => 'Replacement']); + + $this->assertNotFalse($model->replace($data)); + $this->seeInDatabase('job', ['id' => 1, 'name' => 'Replacement']); + } + + /** + * @return iterable + */ + public static function provideReplaceWithEmptyDataUsesBuilderData(): iterable + { + yield 'null' => [null]; + + yield 'empty array' => [[]]; + } + + public function testReplaceWithEmptyDataCanGenerateTimestamps(): void + { + if ($this->db->DBDriver === 'SQLSRV') { + $this->markTestSkipped('SQLSRV replace() deletes by key fields and rejects data without them.'); + } + + $model = $this->createModel(JobModel::class); + $this->setPrivateProperty($model, 'useTimestamps', true); + + $sql = $model->replace([], true); + + $this->assertIsString($sql); + $this->assertStringContainsString('created_at', $sql); + $this->assertStringContainsString('updated_at', $sql); + } + + public function testReplaceRejectsEmptyDataFromCustomProtectionBeforeTimestamps(): void + { + $model = new class ($this->db) extends JobModel { + protected function doProtectFieldsForReplace(array $row): array + { + return $this->doProtectFields($row); + } + }; + $model->setAllowedFields(['name']); + $this->setPrivateProperty($model, 'useTimestamps', true); + + $this->expectException(DataException::class); + $this->expectExceptionMessage('There is no data to replace.'); + + $model->replace(['description' => 'Protected value'], true); + } + + #[DataProvider('provideReplaceWithOnlyProtectedFieldsHasNoData')] + public function testReplaceWithOnlyProtectedFieldsHasNoData(bool $useTimestamps, bool $returnSQL): void + { + $model = $this->createModel(JobModel::class)->setAllowedFields(['name']); + $this->setPrivateProperty($model, 'useTimestamps', $useTimestamps); + + $this->expectException(DataException::class); + $this->expectExceptionMessage('There is no data to replace.'); + + $model->replace(['description' => 'Protected value'], $returnSQL); + } + + /** + * @return iterable + */ + public static function provideReplaceWithOnlyProtectedFieldsHasNoData(): iterable + { + yield 'without timestamps, execute' => [false, false]; + + yield 'with timestamps, execute' => [true, false]; + + yield 'without timestamps, return SQL' => [false, true]; + + yield 'with timestamps, return SQL' => [true, true]; + } + + #[DataProvider('provideReplaceWithOnlyPrimaryKeyAfterProtectionHasNoData')] + public function testReplaceWithOnlyPrimaryKeyAfterProtectionHasNoData(bool $useTimestamps, bool $allowPrimaryKey): void + { + $model = $this->createModel(JobModel::class); + $model->setAllowedFields($allowPrimaryKey ? ['id', 'name'] : ['name']); + $this->setPrivateProperty($model, 'useTimestamps', $useTimestamps); + + $this->expectException(DataException::class); + $this->expectExceptionMessage('There is no data to replace.'); + + $model->replace(['id' => 1, 'description' => 'Protected value'], true); + } + + /** + * @return iterable + */ + public static function provideReplaceWithOnlyPrimaryKeyAfterProtectionHasNoData(): iterable + { + yield 'without timestamps, primary key protected' => [false, false]; + + yield 'with timestamps, primary key protected' => [true, false]; + + yield 'without timestamps, primary key allowed' => [false, true]; + + yield 'with timestamps, primary key allowed' => [true, true]; + } + + public function testReplaceWithExplicitPrimaryKeyOnlyStillReturnsSQL(): void + { + $model = $this->createModel(JobModel::class); + + $this->assertIsString($model->replace(['id' => 1], true)); + } + + public function testReplaceAfterRejectedProtectedData(): void + { + $model = $this->createModel(JobModel::class)->setAllowedFields(['name']); + $this->setPrivateProperty($model, 'useTimestamps', true); + + try { + $model->replace(['id' => 1, 'description' => 'Protected value']); + $this->fail('Expected an exception for an empty dataset after field protection.'); + } catch (DataException $e) { + $this->assertSame('There is no data to replace.', $e->getMessage()); + } + + $this->seeInDatabase('job', ['id' => 1, 'name' => 'Developer']); + $this->assertNotFalse($model->replace(['id' => 1, 'name' => 'Replacement'])); + $this->seeInDatabase('job', ['id' => 1, 'name' => 'Replacement']); + } } diff --git a/tests/system/Router/RouteCollectionTest.php b/tests/system/Router/RouteCollectionTest.php index 9ceec705cf6b..d5a5dab8a9a3 100644 --- a/tests/system/Router/RouteCollectionTest.php +++ b/tests/system/Router/RouteCollectionTest.php @@ -27,6 +27,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use Tests\Support\Controllers\Hello; +use Tests\Support\Router\RouteCollectionSubclass; /** * @internal @@ -314,6 +315,38 @@ static function ($routes): void { $this->assertSame($expected, $routes->getRoutes()); } + public function testGroupingPassesSubclassToNestedCallbacks(): void + { + $modules = new Modules(); + $modules->enabled = false; + $config = new Routing(); + $config->defaultNamespace = '\\'; + $routes = new RouteCollectionSubclass(service('locator'), $modules, $config); + $routes->setHTTPVerb(Method::GET); + + $routes->group('admin', ['namespace' => 'Admin', 'filter' => ['csrf']], function (RouteCollectionSubclass $group) use ($routes): void { + $this->assertSame($routes, $group); + + $group->group('profile', ['filter' => ['honeypot']], function (RouteCollectionSubclass $nested) use ($routes): void { + $this->assertSame($routes, $nested); + $nested->get('show', 'Profile::show'); + }); + + $group->get('dashboard', 'Dashboard::index'); + }); + + $routes->get('home', 'Home::index'); + + $this->assertSame([ + 'admin/profile/show' => '\\Admin\\Profile::show', + 'admin/dashboard' => '\\Admin\\Dashboard::index', + 'home' => '\\Home::index', + ], $routes->getRoutes(Method::GET)); + $this->assertSame(['csrf', 'honeypot'], $routes->getFiltersForRoute('admin/profile/show')); + $this->assertSame(['csrf'], $routes->getFiltersForRoute('admin/dashboard')); + $this->assertFalse($routes->isFiltered('home')); + } + public function testGroupGetsSanitized(): void { $routes = $this->getCollector(); diff --git a/tests/system/Security/SecurityTest.php b/tests/system/Security/SecurityTest.php index db80d91cbd92..f4eb8b88a830 100644 --- a/tests/system/Security/SecurityTest.php +++ b/tests/system/Security/SecurityTest.php @@ -237,6 +237,41 @@ public function testCsrfVerifyHeaderWithJsonBodyStripsTokenFromBody(): void $this->assertSame('{"foo":"bar"}', $request->getBody()); } + #[DataProvider('provideCsrfVerifyPreservesNonObjectJsonBody')] + public function testCsrfVerifyPreservesNonObjectJsonBody(string $body): void + { + service('superglobals') + ->setServer('REQUEST_METHOD', 'POST') + ->setCookie('csrf_cookie_name', self::CORRECT_CSRF_HASH); + + $security = $this->createMockSecurity(); + $request = $this->createIncomingRequest(); + + $request->setHeader('X-CSRF-TOKEN', self::CORRECT_CSRF_HASH); + $request->setBody($body); + + $this->assertInstanceOf(Security::class, $security->verify($request)); + $this->assertSame($body, $request->getBody()); + } + + /** + * @return iterable + */ + public static function provideCsrfVerifyPreservesNonObjectJsonBody(): iterable + { + yield 'JSON array' => ['[{"foo":"bar"}]']; + + yield 'JSON array of scalars' => ['[1, 2, 3]']; + + yield 'JSON number' => ['123']; + + yield 'JSON boolean' => ['true']; + + yield 'JSON null' => ['null']; + + yield 'JSON string' => ['"foo"']; + } + public function testCsrfVerifyPutBodyThrowsExceptionOnNoMatch(): void { service('superglobals') @@ -282,6 +317,16 @@ public function testSanitizeFilename(): void $this->assertSame('foo', $security->sanitizeFilename($filename)); } + public function testSanitizeFilenameBackslashTraversal(): void + { + $security = $this->createMockSecurity(); + $separator = str_repeat(chr(92), 2); + $filename = '..' . $separator . '..' . $separator . 'public' . $separator . 'file.txt'; + + $this->assertSame('....publicfile.txt', $security->sanitizeFilename($filename)); + $this->assertSame('file.txt', $security->sanitizeFilename('.' . chr(92) . './file.txt', true)); + } + public function testRegenerateWithFalseSecurityRegenerateProperty(): void { service('superglobals') diff --git a/tests/system/SuperglobalsTest.php b/tests/system/SuperglobalsTest.php index e4b8b23b2b28..2aa24f5b64c5 100644 --- a/tests/system/SuperglobalsTest.php +++ b/tests/system/SuperglobalsTest.php @@ -302,6 +302,106 @@ public function testRequestSetArray(): void $this->assertSame($data, $_REQUEST); } + public function testGetRequestDataMergesGetAndPost(): void + { + $this->superglobals->setGetArray(['get_key' => 'get_value']); + $this->superglobals->setPostArray(['post_key' => 'post_value']); + + $data = $this->superglobals->getRequestData('GP'); + + $this->assertSame('get_value', $data['get_key']); + $this->assertSame('post_value', $data['post_key']); + } + + public function testGetRequestDataReflectsGetChanges(): void + { + $this->superglobals->setGetArray(['key' => 'old']); + + $this->assertSame('old', $this->superglobals->getRequestData('G')['key']); + + // Simulate SiteURIFactory updating $_GET after the request started. + $this->superglobals->setGetArray(['key' => 'new']); + + $this->assertSame('new', $this->superglobals->getRequestData('G')['key']); + } + + public function testGetRequestDataMergesCookie(): void + { + $this->superglobals->setGetArray(['get_key' => 'get_value']); + $this->superglobals->setPostArray(['post_key' => 'post_value']); + $this->superglobals->setCookieArray(['cookie_key' => 'cookie_value']); + + $data = $this->superglobals->getRequestData('GPC'); + + $this->assertSame('get_value', $data['get_key']); + $this->assertSame('post_value', $data['post_key']); + $this->assertSame('cookie_value', $data['cookie_key']); + } + + public function testGetRequestDataRespectsOrder(): void + { + $this->superglobals->setGetArray(['shared' => 'get']); + $this->superglobals->setPostArray(['shared' => 'post']); + $this->superglobals->setCookieArray(['shared' => 'cookie']); + + // Later sources overwrite earlier ones, matching PHP's request_order. + $this->assertSame('post', $this->superglobals->getRequestData('GP')['shared']); + $this->assertSame('cookie', $this->superglobals->getRequestData('GPC')['shared']); + $this->assertSame('get', $this->superglobals->getRequestData('PG')['shared']); + } + + public function testGetRequestDataEmptyOrderFallsBackToVariablesOrder(): void + { + $this->superglobals->setGetArray(['get' => 'value']); + $this->superglobals->setPostArray(['post' => 'value']); + $this->superglobals->setCookieArray(['cookie' => 'value']); + + $this->assertSame( + $this->superglobals->getRequestData((string) ini_get('variables_order')), + $this->superglobals->getRequestData(''), + ); + } + + public function testGetRequestDataNormalizesOrderAndIgnoresDuplicates(): void + { + $this->superglobals->setGetArray(['shared' => 'get']); + $this->superglobals->setPostArray(['shared' => 'post']); + $this->superglobals->setCookieArray(['shared' => 'cookie']); + + $this->assertSame('post', $this->superglobals->getRequestData('gp')['shared']); + $this->assertSame('post', $this->superglobals->getRequestData('GPG')['shared']); + $this->assertSame('cookie', $this->superglobals->getRequestData('gPcGpC')['shared']); + } + + public function testGetRequestDataIgnoresUnknownOrderTypes(): void + { + $this->superglobals->setGetArray(['get_key' => 'get_value']); + + $data = $this->superglobals->getRequestData('GX'); + + $this->assertSame(['get_key' => 'get_value'], $data); + } + + public function testGetRequestDataPreservesNumericKeys(): void + { + parse_str('100=foo', $get); + $this->superglobals->setGetArray($get); + + $data = $this->superglobals->getRequestData('G'); + + $this->assertSame([100 => 'foo'], $data); + } + + public function testGetRequestDataMergesRecursively(): void + { + $this->superglobals->setGetArray(['a' => ['x' => 'get']]); + $this->superglobals->setPostArray(['a' => ['y' => 'post']]); + + $data = $this->superglobals->getRequestData('GP'); + + $this->assertSame(['a' => ['x' => 'get', 'y' => 'post']], $data); + } + // $_FILES tests public function testFilesGetArray(): void { diff --git a/user_guide_src/source/_static/js/version_switcher.js b/user_guide_src/source/_static/js/version_switcher.js index a201bb4ddc7a..d33c33ea1509 100644 --- a/user_guide_src/source/_static/js/version_switcher.js +++ b/user_guide_src/source/_static/js/version_switcher.js @@ -5,7 +5,7 @@ * scaffolding above the search box in the left sidebar, containing a