diff --git a/.gitattributes b/.gitattributes index b1d0822c10d5..05fc1f38cde9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -26,7 +26,7 @@ structarmed.php export-ignore phpmetrics.json export-ignore phpstan-baseline.php export-ignore phpstan-bootstrap.php export-ignore -phpstan.neon.dist export-ignore +phpstan.dist.neon export-ignore phpunit.dist.xml export-ignore psalm-baseline.xml export-ignore psalm.xml export-ignore diff --git a/.github/workflows/deploy-apidocs.yml b/.github/workflows/deploy-apidocs.yml index 6bcaf710b7f0..3d24470ed846 100644 --- a/.github/workflows/deploy-apidocs.yml +++ b/.github/workflows/deploy-apidocs.yml @@ -30,13 +30,13 @@ jobs: git config --global user.name "${GITHUB_ACTOR}" - name: Checkout source - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: path: source persist-credentials: false - name: Checkout target - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: codeigniter4/api token: ${{ secrets.ACCESS_TOKEN }} @@ -44,7 +44,7 @@ jobs: persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: '8.2' tools: phive diff --git a/.github/workflows/deploy-distributables.yml b/.github/workflows/deploy-distributables.yml index 388829840852..221383aafd57 100644 --- a/.github/workflows/deploy-distributables.yml +++ b/.github/workflows/deploy-distributables.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 # fetch all tags persist-credentials: false @@ -50,13 +50,13 @@ jobs: git config --global user.name "${GITHUB_ACTOR}" - name: Checkout source - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: path: source persist-credentials: false - name: Checkout target - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: codeigniter4/framework token: ${{ secrets.ACCESS_TOKEN }} @@ -104,13 +104,13 @@ jobs: git config --global user.name "${GITHUB_ACTOR}" - name: Checkout source - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: path: source persist-credentials: false - name: Checkout target - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: codeigniter4/appstarter token: ${{ secrets.ACCESS_TOKEN }} @@ -158,13 +158,13 @@ jobs: git config --global user.name "${GITHUB_ACTOR}" - name: Checkout source - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: path: source persist-credentials: false - name: Checkout target - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: codeigniter4/userguide token: ${{ secrets.ACCESS_TOKEN }} @@ -172,7 +172,7 @@ jobs: persist-credentials: false - name: Setup Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: '3.12' diff --git a/.github/workflows/deploy-userguide-latest.yml b/.github/workflows/deploy-userguide-latest.yml index 7be8a04ba246..9bbd7a5bb7ba 100644 --- a/.github/workflows/deploy-userguide-latest.yml +++ b/.github/workflows/deploy-userguide-latest.yml @@ -25,18 +25,18 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: '8.2' coverage: none - name: Setup Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: '3.12' diff --git a/.github/workflows/label-add-conflict-all-pr.yml b/.github/workflows/label-add-conflict-all-pr.yml index a6f5ba276cbe..774012da1f32 100644 --- a/.github/workflows/label-add-conflict-all-pr.yml +++ b/.github/workflows/label-add-conflict-all-pr.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Get PR List id: PR-list diff --git a/.github/workflows/label-pr.yml b/.github/workflows/label-pr.yml index 3fbe8a7a4489..2e2659c6963a 100644 --- a/.github/workflows/label-pr.yml +++ b/.github/workflows/label-pr.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false diff --git a/.github/workflows/label-signing.yml b/.github/workflows/label-signing.yml index 00cda536ef05..80c321c5b117 100644 --- a/.github/workflows/label-signing.yml +++ b/.github/workflows/label-signing.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Check signed commits in PR uses: 1Password/check-signed-commits-action@ed2885f3ed2577a4f5d3c3fe895432a557d23d52 # v1.2.0 diff --git a/.github/workflows/reusable-coveralls.yml b/.github/workflows/reusable-coveralls.yml index 385fda85aba6..e47cceb1771d 100644 --- a/.github/workflows/reusable-coveralls.yml +++ b/.github/workflows/reusable-coveralls.yml @@ -17,12 +17,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: ${{ inputs.php-version }} tools: composer @@ -43,7 +43,7 @@ jobs: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + 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.*') }} @@ -52,7 +52,7 @@ jobs: ${{ github.job }}- - name: Cache PHPUnit's static analysis cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: build/.phpunit.cache/code-coverage key: phpunit-code-coverage-${{ hashFiles('**/phpunit.*') }} diff --git a/.github/workflows/reusable-phpunit-test.yml b/.github/workflows/reusable-phpunit-test.yml index 2a4ed98cd9b9..43d520458bf0 100644 --- a/.github/workflows/reusable-phpunit-test.yml +++ b/.github/workflows/reusable-phpunit-test.yml @@ -170,12 +170,12 @@ jobs: sudo apt-get install -y imagemagick libmagickwand-dev ghostscript poppler-data libjbig2dec0:amd64 libopenjp2-7:amd64 - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: ${{ inputs.php-version }} tools: composer @@ -192,7 +192,7 @@ jobs: echo "ARTIFACT_NAME=${{ inputs.job-id || github.job }}-php-${{ inputs.php-version }}-db-${{ inputs.db-platform || 'none' }}${{ inputs.mysql-version || '' }}" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + 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.*') }} @@ -203,7 +203,7 @@ jobs: - name: Cache PHPUnit's static analysis cache if: ${{ inputs.enable-artifact-upload }} - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: build/.phpunit.cache/code-coverage key: phpunit-code-coverage-${{ hashFiles('**/phpunit.*') }} diff --git a/.github/workflows/reusable-serviceless-phpunit-test.yml b/.github/workflows/reusable-serviceless-phpunit-test.yml index fc0faa4cae41..7858913cf531 100644 --- a/.github/workflows/reusable-serviceless-phpunit-test.yml +++ b/.github/workflows/reusable-serviceless-phpunit-test.yml @@ -66,13 +66,13 @@ jobs: sudo apt-get install -y imagemagick libmagickwand-dev ghostscript poppler-data libjbig2dec0:amd64 libopenjp2-7:amd64 - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false fetch-depth: 0 - name: Setup PHP - uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: ${{ inputs.php-version }} tools: composer @@ -89,7 +89,7 @@ jobs: echo "ARTIFACT_NAME=${{ inputs.job-id || github.job }}-php-${{ inputs.php-version }}" >> $GITHUB_OUTPUT - name: Cache Composer dependencies - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + 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.*') }} @@ -99,7 +99,7 @@ jobs: - name: Cache PHPUnit's static analysis cache if: ${{ inputs.enable-artifact-upload }} - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: build/.phpunit.cache/code-coverage key: phpunit-code-coverage-${{ hashFiles('**/phpunit.*') }} diff --git a/.github/workflows/test-autoreview.yml b/.github/workflows/test-autoreview.yml index 77394c0517eb..fd7c83248b2e 100644 --- a/.github/workflows/test-autoreview.yml +++ b/.github/workflows/test-autoreview.yml @@ -36,10 +36,10 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup PHP - uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: '8.2' diff --git a/.github/workflows/test-coding-standards.yml b/.github/workflows/test-coding-standards.yml index f4853751f79e..125dbd5b3311 100644 --- a/.github/workflows/test-coding-standards.yml +++ b/.github/workflows/test-coding-standards.yml @@ -35,10 +35,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup PHP - uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: ${{ matrix.php-version }} extensions: tokenizer @@ -49,7 +49,7 @@ jobs: run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + 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') }} diff --git a/.github/workflows/test-file-permissions.yml b/.github/workflows/test-file-permissions.yml index 8c61248d215f..7116f4620869 100644 --- a/.github/workflows/test-file-permissions.yml +++ b/.github/workflows/test-file-permissions.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Detect unnecessary execution permissions run: php utils/check_permission_x.php diff --git a/.github/workflows/test-phpstan.yml b/.github/workflows/test-phpstan.yml index 0801ce4d2741..586314917200 100644 --- a/.github/workflows/test-phpstan.yml +++ b/.github/workflows/test-phpstan.yml @@ -13,7 +13,7 @@ on: - 'tests/**.php' - 'utils/**.php' - composer.json - - phpstan.neon.dist + - phpstan.dist.neon - phpstan-baseline.php - '.github/workflows/test-phpstan.yml' @@ -27,7 +27,7 @@ on: - 'tests/**.php' - 'utils/**.php' - composer.json - - phpstan.neon.dist + - phpstan.dist.neon - phpstan-baseline.php - '.github/workflows/test-phpstan.yml' @@ -46,10 +46,10 @@ jobs: fail-fast: false steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup PHP - uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: '8.2' extensions: intl @@ -66,7 +66,7 @@ jobs: run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -76,7 +76,7 @@ jobs: run: mkdir -p build/phpstan - name: Cache PHPStan result cache directory - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: build/phpstan key: ${{ runner.os }}-phpstan-${{ github.sha }} diff --git a/.github/workflows/test-psalm.yml b/.github/workflows/test-psalm.yml index abd8b1b58073..43931032b9e4 100644 --- a/.github/workflows/test-psalm.yml +++ b/.github/workflows/test-psalm.yml @@ -36,12 +36,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: ${{ matrix.php-version }} extensions: intl, json, mbstring, xml, mysqli, oci8, pgsql, sqlsrv, sqlite3 @@ -54,7 +54,7 @@ jobs: run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer dependencies - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + 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') }} @@ -64,7 +64,7 @@ jobs: run: mkdir -p build/psalm - name: Cache Psalm results - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: build/psalm key: ${{ runner.os }}-psalm-${{ github.sha }} diff --git a/.github/workflows/test-random-execution.yml b/.github/workflows/test-random-execution.yml index 7774419e4a38..4f29559b2e93 100644 --- a/.github/workflows/test-random-execution.yml +++ b/.github/workflows/test-random-execution.yml @@ -168,13 +168,13 @@ jobs: -Q "CREATE DATABASE test COLLATE Latin1_General_100_CS_AS_SC_UTF8" - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false fetch-depth: 0 - name: Setup PHP ${{ matrix.php-version }} - uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: ${{ matrix.php-version }} extensions: gd, curl, iconv, json, mbstring, openssl, sodium @@ -186,7 +186,7 @@ jobs: run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer dependencies - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} key: PHP_${{ matrix.php-version }}-${{ hashFiles('**/composer.*') }} diff --git a/.github/workflows/test-rector.yml b/.github/workflows/test-rector.yml index b8bef42af1dc..4f52dfe53681 100644 --- a/.github/workflows/test-rector.yml +++ b/.github/workflows/test-rector.yml @@ -53,10 +53,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup PHP - uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: ${{ matrix.php-version }} extensions: intl @@ -72,7 +72,7 @@ jobs: run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -82,7 +82,7 @@ jobs: run: composer update --ansi --no-interaction ${{ matrix.composer-option }} - name: Rector Cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: /tmp/rector key: ${{ runner.os }}-rector-${{ github.run_id }} diff --git a/.github/workflows/test-scss.yml b/.github/workflows/test-scss.yml index 919fae137812..3fcce79de33c 100644 --- a/.github/workflows/test-scss.yml +++ b/.github/workflows/test-scss.yml @@ -33,7 +33,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 diff --git a/.github/workflows/test-structarmed.yml b/.github/workflows/test-structarmed.yml index 030028f3a16c..296b4d22870a 100644 --- a/.github/workflows/test-structarmed.yml +++ b/.github/workflows/test-structarmed.yml @@ -51,10 +51,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup PHP - uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # 2.37.1 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: ${{ matrix.php-version }} extensions: intl @@ -68,7 +68,7 @@ jobs: run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.composer-cache.outputs.COMPOSER_CACHE_FILES_DIR }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -78,7 +78,7 @@ jobs: run: composer update --ansi --no-interaction - name: Structarmed Cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: /tmp/structarmed key: ${{ runner.os }}-structarmed-${{ github.run_id }} diff --git a/.github/workflows/test-userguide.yml b/.github/workflows/test-userguide.yml index 310eff3277f9..1dd382cd069b 100644 --- a/.github/workflows/test-userguide.yml +++ b/.github/workflows/test-userguide.yml @@ -24,10 +24,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: '3.12' diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f8cbd59deb0..82d3974606b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,81 @@ # Changelog +## [v4.7.4](https://github.com/codeigniter4/CodeIgniter4/tree/v4.7.4) (2026-07-07) +[Full Changelog](https://github.com/codeigniter4/CodeIgniter4/compare/v4.7.3...v4.7.4) + +### Security + +* **IncomingRequest:** *HTTPS detection via client-supplied headers* was fixed. + ``IncomingRequest::isSecure()`` now trusts the ``X-Forwarded-Proto`` and + ``Front-End-Https`` headers only when the request comes from a trusted proxy + configured in ``Config\App::$proxyIPs``. + See the `Security advisory GHSA-7wmf-pw8j-mc78 `_ + for more information. + +* **Query Builder:** Fixed a SQL injection vulnerability in ``deleteBatch()``. + When ``deleteBatch()`` was used together with ``where()`` conditions, the + bound values from the WHERE clause were substituted into the generated SQL + with their escape flag ignored, so they were never escaped or quoted. The + WHERE binds are now escaped in the same way as a regular ``delete()``. + + See the `Security advisory GHSA-c9w5-rwh3-7pm9 `_ + for more information. + +* **UploadedFile:** ``UploadedFile::move()`` now sanitizes the client-provided + filename when called without a second argument. Previously, the unsanitized + client filename was used as the default, allowing path traversal sequences + (e.g. ``../../public/shell.php``) to write the uploaded file outside the + intended directory. A name explicitly passed as the second argument is not + sanitized and remains the caller's responsibility. + See the `Security advisory GHSA-hhmc-q9hp-r662 `_ + for more information. + +* **Validation:** The ``is_image`` and ``mime_in`` file upload validation rules + now also verify non-empty client filename extensions. Previously, these rules + classified an upload solely by its content-derived MIME type, so a file with a + dangerous client extension (for example, a ``.php`` file prepended with image + magic bytes) could pass validation while keeping its original extension on + disk. + See the `Security advisory GHSA-mmj4-63m4-r6h5 `_ + for more information. + + The ``is_image`` rule now rejects uploads when a non-empty client filename + extension is not an image extension. The ``mime_in`` rule now rejects uploads + when a non-empty client filename extension does not match the detected file + content, matching the same extension/content agreement used by ``ext_in``. + Files uploaded without any extension (such as JavaScript ``Blob`` uploads) + are still accepted, since the rules validate the file content. + +### Fixed Bugs + +* fix: prevent updateBatch with existing where conditions by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/10236 +* fix: detect Safari version from Version token by @memleakd in https://github.com/codeigniter4/CodeIgniter4/pull/10251 +* fix: nested transformer request scope leakage by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/10281 +* fix(model): pass $recursive parameter to parent in objectToRawArray by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10258 +* fix: preserve enclosing stream filter when using `MockInputOutput` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10307 +* fix: skip already-translated keys in `spark lang:find` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10308 +* fix(Filters): check both keys and values in `InvalidChars` arrays by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10303 +* fix: use dynamic lockMaxRetries limit in RedisHandler by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10295 +* fix: preserve sub-namespace when generating Entity from Model (i.e., `--return entity`) by @xgrind in https://github.com/codeigniter4/CodeIgniter4/pull/10232 +* fix: `env()` TypeError for non-string `$_SERVER` values + `esc()` fixes by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10305 +* fix: unify casing in BaseService::injectMock by @ThomasMeschke in https://github.com/codeigniter4/CodeIgniter4/pull/10316 +* fix: normalize SodiumHandler params and padding failures by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10321 +* fix(Validation): correct required_without logic and prevent array key warnings by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10328 +* fix: handle null ini values in phpini check by @Will-thom in https://github.com/codeigniter4/CodeIgniter4/pull/10233 +* fix: preserve zero values in XML export by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10367 +* fix: support array indexes in `getPostGet()` and `getGetPost()` by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/10362 + +### Refactoring + +* refactor: narrow `Image` original dimension types to `int` by @michalsn in https://github.com/codeigniter4/CodeIgniter4/pull/10326 +* refactor(HTTP): optimize file path parsing in `download()` method by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10330 +* refactor(database): optimize groupGetType by caching it inside BaseBuilder loops by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10340 +* refactor: bump to phpstan-codeigniter v2.1 by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10312 +* refactor: replace type `mixed` with more specific types by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10345 +* refactor: optimize `prepQuotedPrintable()` with hash lookup and int-length tracking by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10344 +* refactor: add precise `array` phpdocs for `CLI` by @paulbalandan in https://github.com/codeigniter4/CodeIgniter4/pull/10354 +* refactor(database): optimize `_processForeignKeys()` string allocations and loop invariants by @gr8man in https://github.com/codeigniter4/CodeIgniter4/pull/10351 + ## [v4.7.3](https://github.com/codeigniter4/CodeIgniter4/tree/v4.7.3) (2026-05-22) [Full Changelog](https://github.com/codeigniter4/CodeIgniter4/compare/v4.7.2...v4.7.3) diff --git a/composer.json b/composer.json index 64183685f899..8e0990839d9f 100644 --- a/composer.json +++ b/composer.json @@ -17,19 +17,19 @@ "psr/log": "^3.0" }, "require-dev": { - "boundwize/structarmed": "0.6.15", - "codeigniter/phpstan-codeigniter": "^1.5", + "boundwize/structarmed": "0.14.10", + "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.1.55", + "phpstan/phpstan": "^2.2.1", "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.4.4", + "rector/rector": "2.5.4", "shipmonk/phpstan-baseline-per-identifier": "^2.0" }, "replace": { diff --git a/phpdoc.dist.xml b/phpdoc.dist.xml index 33101eeaa704..4ec68a4bfaea 100644 --- a/phpdoc.dist.xml +++ b/phpdoc.dist.xml @@ -10,7 +10,7 @@ api/build/ api/cache/ - + system diff --git a/phpstan-bootstrap.php b/phpstan-bootstrap.php index 0f45bf0cb559..ef64b6c23a52 100644 --- a/phpstan-bootstrap.php +++ b/phpstan-bootstrap.php @@ -1,5 +1,9 @@ withPHPStanConfigs([ - __DIR__ . '/phpstan.neon.dist', + __DIR__ . '/phpstan.dist.neon', __DIR__ . '/vendor/codeigniter/phpstan-codeigniter/extension.neon', __DIR__ . '/vendor/phpstan/phpstan-strict-rules/rules.neon', __DIR__ . '/vendor/shipmonk/phpstan-baseline-per-identifier/extension.neon', @@ -96,17 +96,10 @@ RemoveUnusedPrivateMethodRector::class => [ // private method called via getPrivateMethodInvoker - __DIR__ . '/tests/system/Test/ReflectionHelperTest.php', __DIR__ . '/tests/_support/Test/TestForReflectionHelper.php', ], RemoveUnusedConstructorParamRector::class => [ - // there are deprecated parameters - __DIR__ . '/system/Debug/Exceptions.php', - // @TODO remove if deprecated $httpVerb is removed - __DIR__ . '/system/Router/AutoRouterImproved.php', - // @TODO remove if deprecated $config is removed - __DIR__ . '/system/HTTP/Request.php', __DIR__ . '/system/HTTP/Response.php', ], @@ -115,11 +108,6 @@ __DIR__ . '/tests/system/Debug/ToolbarTest.php', ], - // check on constant compare - UnwrapFutureCompatibleIfPhpVersionRector::class => [ - __DIR__ . '/system/Autoloader/Autoloader.php', - ], - UnderscoreToCamelCaseVariableNameRector::class => [ // session handlers have the gc() method with underscored parameter `$max_lifetime` __DIR__ . '/system/Session/Handlers', @@ -130,10 +118,7 @@ __DIR__ . '/app', __DIR__ . '/system/CodeIgniter.php', __DIR__ . '/system/Config/BaseConfig.php', - __DIR__ . '/system/Commands/Generators/Views', - __DIR__ . '/system/Pager/Views', __DIR__ . '/system/Test/ControllerTestTrait.php', - __DIR__ . '/system/Validation/Views', __DIR__ . '/system/View/Parser.php', __DIR__ . '/tests/system/Debug/ExceptionsTest.php', ], @@ -149,15 +134,11 @@ __DIR__ . '/system/Filters/Filters.php', __DIR__ . '/system/HTTP/CURLRequest.php', __DIR__ . '/system/HTTP/DownloadResponse.php', - __DIR__ . '/system/HTTP/IncomingRequest.php', __DIR__ . '/system/Security/Security.php', __DIR__ . '/system/Session/Session.php', ], ReturnNeverTypeRector::class => [ - __DIR__ . '/system/Cache/Handlers/BaseHandler.php', - __DIR__ . '/system/Cache/Handlers/MemcachedHandler.php', - __DIR__ . '/system/Cache/Handlers/WincacheHandler.php', __DIR__ . '/system/CodeIgniter.php', __DIR__ . '/system/Database/MySQLi/Utils.php', __DIR__ . '/system/Database/OCI8/Utils.php', @@ -166,8 +147,6 @@ __DIR__ . '/system/Database/SQLite3/Utils.php', __DIR__ . '/system/HTTP/DownloadResponse.php', __DIR__ . '/system/HTTP/SiteURI.php', - __DIR__ . '/system/Helpers/kint_helper.php', - __DIR__ . '/tests/_support/Autoloader/FatalLocator.php', ], // Unnecessary (string) is inserted @@ -197,12 +176,12 @@ __DIR__ . '/tests/system/Models', ], - StaticCallOnNonStaticToInstanceCallRector::class => [ - __DIR__ . '/tests/_support/Config/Services.php', - ], + // to be applied in separate PRs to ease review + NegatedAndsToPositiveOrsRector::class, + RemoveDuplicatedReturnSelfDocblockRector::class, ]) // auto import fully qualified class names - ->withImportNames(removeUnusedImports: true) + ->withImportNames() ->withRules([ DeclareStrictTypesRector::class, UnderscoreToCamelCaseVariableNameRector::class, @@ -231,4 +210,5 @@ ->withConfiguredRule(RenameConstantRector::class, [ 'FILTER_DEFAULT' => 'FILTER_UNSAFE_RAW', ]) - ->withCodeQualityLevel(61); + ->withCodeQualityLevel(61) + ->reportUnusedSkips(); diff --git a/structarmed.php b/structarmed.php index 69bfaf1d197f..e1027f578ce2 100644 --- a/structarmed.php +++ b/structarmed.php @@ -101,7 +101,7 @@ 'Pager' => ['URI', 'View'], 'Publisher' => ['Files', 'URI'], // +API = API + its allowed layers; +Controller = Controller + its allowed layers - 'RESTful' => ['API', 'Controller', 'Database', 'Format', 'HTTP', 'Model', 'Pager', 'URI', 'Validation'], + 'RESTful' => ['+API', '+Controller'], 'Router' => ['HTTP', 'I18n'], 'Security' => ['Cookie', 'HTTP', 'I18n', 'Session'], 'Session' => ['Cookie', 'Database', 'HTTP', 'I18n'], diff --git a/system/API/BaseTransformer.php b/system/API/BaseTransformer.php index 540d443d1cdc..4badb8267139 100644 --- a/system/API/BaseTransformer.php +++ b/system/API/BaseTransformer.php @@ -54,6 +54,11 @@ */ abstract class BaseTransformer implements TransformerInterface { + /** + * Nesting depth of the transformation currently in progress (0 = root). + */ + private static int $depth = 0; + /** * @var list|null */ @@ -69,17 +74,24 @@ abstract class BaseTransformer implements TransformerInterface public function __construct( private ?IncomingRequest $request = null, ) { + // An explicitly provided request is always honored - its scope is + // intentional. Only the implicit global fallback is suppressed for + // nested transformers, which is the actual leak vector. + $explicitRequest = $request instanceof IncomingRequest; + $this->request = $request ?? request(); - $fields = $this->request->getGet('fields'); - $this->fields = is_string($fields) - ? array_map(trim(...), explode(',', $fields)) - : $fields; + if ($explicitRequest || self::$depth === 0) { + $fields = $this->request->getGet('fields'); + $this->fields = is_string($fields) + ? array_map(trim(...), explode(',', $fields)) + : $fields; - $includes = $this->request->getGet('include'); - $this->includes = is_string($includes) - ? array_map(trim(...), explode(',', $includes)) - : $includes; + $includes = $this->request->getGet('include'); + $this->includes = is_string($includes) + ? array_map(trim(...), explode(',', $includes)) + : $includes; + } } /** @@ -207,13 +219,19 @@ private function insertIncludes(array $data): array } } - foreach ($this->includes as $include) { - $method = 'include' . ucfirst($include); - if (method_exists($this, $method)) { - $data[$include] = $this->{$method}(); - } else { - throw ApiException::forMissingInclude($include); + self::$depth++; + + try { + foreach ($this->includes as $include) { + $method = 'include' . ucfirst($include); + if (method_exists($this, $method)) { + $data[$include] = $this->{$method}(); + } else { + throw ApiException::forMissingInclude($include); + } } + } finally { + self::$depth--; } return $data; diff --git a/system/BaseModel.php b/system/BaseModel.php index d40a662724d9..6f52c02a48c6 100644 --- a/system/BaseModel.php +++ b/system/BaseModel.php @@ -49,7 +49,7 @@ * - process various callbacks * - allow intermingling calls to the db connection * - * @phpstan-type row_array array + * @phpstan-type row_array array * @phpstan-type event_data_beforeinsert array{data: row_array} * @phpstan-type event_data_afterinsert array{id: int|string, data: row_array, result: bool} * @phpstan-type event_data_beforefind array{id?: int|string, method: string, singleton: bool, limit?: int, offset?: int} diff --git a/system/Boot.php b/system/Boot.php index d3b25895093b..a1b8c9695004 100644 --- a/system/Boot.php +++ b/system/Boot.php @@ -426,9 +426,11 @@ protected static function initializeConsole(): Console { $console = new Console(); + $args = $_SERVER['argv'] ?? []; // @phpstan-ignore codeigniter.superglobalsOffsetAccess (reads live $_SERVER, not the snapshot service) + // Show basic information before we do anything else. - if (is_int($suppress = array_search('--no-header', $_SERVER['argv'], true))) { - unset($_SERVER['argv'][$suppress]); + if (is_int($suppress = array_search('--no-header', $args, true))) { + unset($args[$suppress]); $suppress = true; } diff --git a/system/CLI/CLI.php b/system/CLI/CLI.php index 9cdbbbada0b5..11f49bbc0c45 100644 --- a/system/CLI/CLI.php +++ b/system/CLI/CLI.php @@ -214,9 +214,9 @@ public static function input(?string $prefix = null): string * // Do not provide options but requires a valid email * $email = CLI::prompt('What is your email?', null, 'required|valid_email'); * - * @param string $field Output "field" question - * @param list|string $options String to a default value, array to a list of options (the first option will be the default value) - * @param array|string|null $validation Validation rules + * @param string $field Output "field" question + * @param list|string $options String to a default value, array to a list of options (the first option will be the default value) + * @param list|string|null $validation Validation rules * * @return string The user input */ @@ -273,10 +273,10 @@ public static function prompt(string $field, $options = null, $validation = null /** * prompt(), but based on the option's key * - * @param array|string $text Output "field" text or an one or two value array where the first value is the text before listing the options - * and the second value the text before asking to select one option. Provide empty string to omit - * @param array $options A list of options (array(key => description)), the first option will be the default value - * @param array|string|null $validation Validation rules + * @param list|string $text Output "field" text or an one or two value array where the first value is the text before listing the options + * and the second value the text before asking to select one option. Provide empty string to omit + * @param array $options A list of options (array(key => description)), the first option will be the default value + * @param list|string|null $validation Validation rules * * @return string The selected key of $options */ @@ -302,11 +302,11 @@ public static function promptByKey($text, array $options, $validation = null): s /** * This method is the same as promptByKey(), but this method supports multiple keys, separated by commas. * - * @param string $text Output "field" text or an one or two value array where the first value is the text before listing the options - * and the second value the text before asking to select one option. Provide empty string to omit - * @param array $options A list of options (array(key => description)), the first option will be the default value + * @param string $text Output "field" text or an one or two value array where the first value is the text before listing the options + * and the second value the text before asking to select one option. Provide empty string to omit + * @param array $options A list of options (array(key => description)), the first option will be the default value * - * @return array The selected key(s) and value(s) of $options + * @return array The selected key(s) and value(s) of $options */ public static function promptByMultipleKeys(string $text, array $options): array { @@ -375,6 +375,8 @@ public static function promptByMultipleKeys(string $text, array $options): array /** * Validation for $options in promptByKey() and promptByMultipleKeys(). Return an error if $options is an empty array. + * + * @param array $options */ private static function isZeroOptions(array $options): void { @@ -385,6 +387,8 @@ private static function isZeroOptions(array $options): void /** * Print each key and value one by one + * + * @param array $options */ private static function printKeysAndValues(array $options): void { @@ -404,9 +408,9 @@ private static function printKeysAndValues(array $options): void /** * Validate one prompt "field" at a time * - * @param string $field Prompt "field" output - * @param string $value Input value - * @param array|string $rules Validation rules + * @param string $field Prompt "field" output + * @param string $value Input value + * @param list|string $rules Validation rules */ protected static function validate(string $field, string $value, $rules): bool { @@ -707,7 +711,7 @@ public static function streamSupports(string $function, $resource): bool public static function hasColorSupport($resource): bool { // Follow https://no-color.org/ - if (isset($_SERVER['NO_COLOR']) || getenv('NO_COLOR') !== false) { + if (isset($_SERVER['NO_COLOR']) || getenv('NO_COLOR') !== false) { // @phpstan-ignore codeigniter.superglobalsOffsetAccess (reads live $_SERVER, not the snapshot service) return false; } @@ -718,7 +722,7 @@ public static function hasColorSupport($resource): bool if (is_windows()) { // @codeCoverageIgnoreStart return static::streamSupports('sapi_windows_vt100_support', $resource) - || isset($_SERVER['ANSICON']) + || isset($_SERVER['ANSICON']) // @phpstan-ignore codeigniter.superglobalsOffsetAccess (reads live $_SERVER, not the snapshot service) || getenv('ANSICON') !== false || getenv('ConEmuANSI') === 'ON' || getenv('TERM') === 'xterm'; @@ -887,7 +891,7 @@ public static function wrap(?string $string = null, int $max = 0, int $padLeft = */ protected static function parseCommandLine() { - $args = $_SERVER['argv'] ?? []; + $args = $_SERVER['argv'] ?? []; // @phpstan-ignore codeigniter.superglobalsOffsetAccess (reads live $_SERVER, not the snapshot service) array_shift($args); // scrap invoking program $optionValue = false; @@ -1012,7 +1016,7 @@ public static function getOptionString(bool $useLongOpts = false, bool $trim = f continue; } - if (mb_strpos($value, ' ') !== false) { + if (str_contains($value, ' ')) { $out .= "\"{$value}\" "; } elseif ($value !== null) { $out .= "{$value} "; @@ -1025,8 +1029,8 @@ public static function getOptionString(bool $useLongOpts = false, bool $trim = f /** * Returns a well formatted table * - * @param array $tbody List of rows - * @param array $thead List of columns + * @param list> $tbody List of rows + * @param list $thead List of columns * * @return void */ diff --git a/system/CLI/Console.php b/system/CLI/Console.php index 89415e265134..b24562e52d46 100644 --- a/system/CLI/Console.php +++ b/system/CLI/Console.php @@ -75,6 +75,8 @@ public function showHeader(bool $suppress = false) * unshift it as argument instead. * * @param array $params + * + * @return array */ private function parseParamsForHelpOption(array $params): array { diff --git a/system/CodeIgniter.php b/system/CodeIgniter.php index 66fe9d0b4983..b806fe00a740 100644 --- a/system/CodeIgniter.php +++ b/system/CodeIgniter.php @@ -55,7 +55,7 @@ class CodeIgniter /** * The current version of CodeIgniter Framework */ - public const CI_VERSION = '4.7.3'; + public const CI_VERSION = '4.7.4'; /** * App startup time. diff --git a/system/Commands/Encryption/GenerateKey.php b/system/Commands/Encryption/GenerateKey.php index 3726360fa8ad..148fd264dc33 100644 --- a/system/Commands/Encryption/GenerateKey.php +++ b/system/Commands/Encryption/GenerateKey.php @@ -101,7 +101,8 @@ public function run(array $params) // force DotEnv to reload the new env vars putenv('encryption.key'); - unset($_ENV['encryption.key'], $_SERVER['encryption.key']); + unset($_ENV['encryption.key']); + service('superglobals')->unsetServer('encryption.key'); $dotenv = new DotEnv((new Paths())->envDirectory ?? ROOTPATH); // @phpstan-ignore nullCoalesce.property $dotenv->load(); diff --git a/system/Commands/Generators/ModelGenerator.php b/system/Commands/Generators/ModelGenerator.php index 5450bda79b4c..30b7422238fd 100644 --- a/system/Commands/Generators/ModelGenerator.php +++ b/system/Commands/Generators/ModelGenerator.php @@ -114,18 +114,23 @@ protected function prepare(string $class): string } if ($return === 'entity') { - $return = str_replace('Models', 'Entities', $class); + // Build the fully-qualified entity class from the model class so + // that the generated Entity keeps any sub-namespaces (eg. Admin). + $entityClass = str_replace('Models', 'Entities', $class); - if (preg_match('/^(\S+)Model$/i', $return, $match) === 1) { - $return = $match[1]; + if (preg_match('/^(\S+)Model$/i', $entityClass, $match) === 1) { + $entityClass = $match[1]; if ($this->getOption('suffix')) { - $return .= 'Entity'; + $entityClass .= 'Entity'; } } - $return = '\\' . trim($return, '\\') . '::class'; - $this->call('make:entity', array_merge([$baseClass], $this->params)); + // Call the entity generator with the fully-qualified class name so + // it ends up under the correct sub-namespace/folder (eg. Admin). + $this->call('make:entity', array_merge([trim($entityClass, '\\')], $this->params)); + + $return = '\\' . trim($entityClass, '\\') . '::class'; } else { $return = "'{$return}'"; } diff --git a/system/Commands/Translation/LocalizationFinder.php b/system/Commands/Translation/LocalizationFinder.php index e6557b74d1c4..ca7a1fb20230 100644 --- a/system/Commands/Translation/LocalizationFinder.php +++ b/system/Commands/Translation/LocalizationFinder.php @@ -133,13 +133,17 @@ private function process(string $currentDir, string $currentLocale): void $languageStoredKeys = require $languageFilePath; } - $languageDiff = ArrayHelper::recursiveDiff($foundLanguageKeys[$langFileName], $languageStoredKeys); + // Keys already resolvable from any namespace's language file (framework, packages, or app) + // are not new and must not be re-reported or written. + $resolvedKeys = $this->findResolvedTranslations($langFileName, $currentLocale); + + $languageDiff = ArrayHelper::recursiveDiff($foundLanguageKeys[$langFileName], $resolvedKeys); $countNewKeys += ArrayHelper::recursiveCount($languageDiff); if ($this->showNew) { $tableRows = array_merge($this->arrayToTableRows($langFileName, $languageDiff), $tableRows); } else { - $newLanguageKeys = array_replace_recursive($foundLanguageKeys[$langFileName], $languageStoredKeys); + $newLanguageKeys = array_replace_recursive($languageDiff, $languageStoredKeys); if ($languageDiff !== []) { if (file_put_contents($languageFilePath, $this->templateFile($newLanguageKeys)) === false) { @@ -177,6 +181,35 @@ private function process(string $currentDir, string $currentLocale): void } } + /** + * Loads the translations already resolvable for the given file and locale + * from every registered namespace (framework, packages, and app). + * + * @return array + */ + private function findResolvedTranslations(string $langFileName, string $currentLocale): array + { + $translations = []; + + foreach (service('locator')->search("Language/{$currentLocale}/{$langFileName}.php", 'php', false) as $file) { + if (! is_file($file)) { + continue; + } + + $keys = require $file; + + if (is_array($keys)) { + $translations[] = $keys; + } + } + + if ($translations === []) { + return []; + } + + return array_replace_recursive(...$translations); + } + /** * @param SplFileInfo|string $file * diff --git a/system/Common.php b/system/Common.php index ea0c476423a0..fc8abb394f06 100644 --- a/system/Common.php +++ b/system/Common.php @@ -185,13 +185,21 @@ function command(string $command) $params[$arg] = $value; } + $bufferLevel = ob_get_level(); + try { ob_start(); service('commands')->run($command, $params); + if (ob_get_level() <= $bufferLevel) { + return false; + } + return ob_get_contents(); } finally { - ob_end_clean(); + while (ob_get_level() > $bufferLevel) { + ob_end_clean(); + } } } } @@ -403,19 +411,25 @@ function db_connect($db = null, bool $getShared = true) * retrieving values set from the .env file for * use in config files. * - * @param array|bool|float|int|object|string|null $default + * @param mixed $default * - * @return array|bool|float|int|object|string|null + * @return mixed */ function env(string $key, $default = null) { - $value = $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key); + $value = $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key); // @phpstan-ignore codeigniter.superglobalsOffsetAccess (reads live $_SERVER, not the snapshot service) // Not found? Return the default value if ($value === false) { return $default; } + // Non-string values (e.g. $_SERVER['argc'] is int, $_SERVER['argv'] is array in CLI) + // must be returned as-is to avoid TypeError from strtolower(). + if (! is_string($value)) { + return $value; + } + // Handle any boolean values return match (strtolower($value)) { 'true' => true, @@ -459,8 +473,10 @@ function esc($data, string $context = 'html', ?string $encoding = null) if (is_array($data)) { foreach ($data as &$value) { - $value = esc($value, $context); + $value = esc($value, $context, $encoding); } + + return $data; } if (is_string($data)) { @@ -470,16 +486,14 @@ function esc($data, string $context = 'html', ?string $encoding = null) $method = $context === 'attr' ? 'escapeHtmlAttr' : 'escape' . ucfirst($context); - static $escaper; - if (! $escaper) { - $escaper = new Escaper($encoding); - } + static $escapers = []; + $cacheKey = strtolower($encoding ?? 'utf-8'); - if ($encoding !== null && $escaper->getEncoding() !== $encoding) { - $escaper = new Escaper($encoding); + if (! isset($escapers[$cacheKey])) { + $escapers[$cacheKey] = new Escaper($encoding); } - $data = $escaper->{$method}($data); + $data = $escapers[$cacheKey]->{$method}($data); } return $data; @@ -691,7 +705,7 @@ function is_cli(): bool // PHP_SAPI could be 'cgi-fcgi', 'fpm-fcgi'. // See https://github.com/codeigniter4/CodeIgniter4/pull/5393 - return ! isset($_SERVER['REMOTE_ADDR']) && ! isset($_SERVER['REQUEST_METHOD']); + return ! isset($_SERVER['REMOTE_ADDR']) && ! isset($_SERVER['REQUEST_METHOD']); // @phpstan-ignore codeigniter.superglobalsOffsetAccess (reads live $_SERVER, not the snapshot service), codeigniter.superglobalsOffsetAccess (reads live $_SERVER, not the snapshot service) } } @@ -1074,7 +1088,7 @@ function session(?string $val = null) * - $timer = service('timer') * - $timer = \CodeIgniter\Config\Services::timer(); * - * @param array|bool|float|int|object|string|null ...$params + * @param mixed ...$params */ function service(string $name, ...$params): ?object { @@ -1090,7 +1104,7 @@ function service(string $name, ...$params): ?object /** * Always returns a new instance of the class. * - * @param array|bool|float|int|object|string|null ...$params + * @param mixed ...$params */ function single_service(string $name, ...$params): ?object { diff --git a/system/Config/AutoloadConfig.php b/system/Config/AutoloadConfig.php index b6d7f72bdd54..f35df4ebe740 100644 --- a/system/Config/AutoloadConfig.php +++ b/system/Config/AutoloadConfig.php @@ -142,7 +142,8 @@ class AutoloadConfig */ public function __construct() { - if (isset($_SERVER['CI_ENVIRONMENT']) && $_SERVER['CI_ENVIRONMENT'] === 'testing') { + // @phpstan-ignore codeigniter.superglobalsOffsetAccess (service() is not yet available) + if (($_SERVER['CI_ENVIRONMENT'] ?? null) === 'testing') { $this->psr4['Tests\Support'] = SUPPORTPATH; $this->classmap['CodeIgniter\Log\TestLogger'] = SYSTEMPATH . 'Test/TestLogger.php'; $this->classmap['CIDatabaseTestCase'] = SYSTEMPATH . 'Test/CIDatabaseTestCase.php'; diff --git a/system/Config/BaseConfig.php b/system/Config/BaseConfig.php index 42da45cdb52f..40782a243b96 100644 --- a/system/Config/BaseConfig.php +++ b/system/Config/BaseConfig.php @@ -166,7 +166,7 @@ protected function parseEncryptionKey(string $key): string /** * Initialization an environment-specific configuration setting * - * @param array|bool|float|int|string|null $property + * @param array|bool|float|int|string|null $property * * @return void */ @@ -221,10 +221,10 @@ protected function getEnvValue(string $property, string $prefix, string $shortPr return $_ENV["{$shortPrefix}_{$underscoreProperty}"]; case array_key_exists("{$shortPrefix}.{$property}", $_SERVER): - return $_SERVER["{$shortPrefix}.{$property}"]; + return $_SERVER["{$shortPrefix}.{$property}"]; // @phpstan-ignore codeigniter.superglobalsOffsetAccess (reads live $_SERVER, not the snapshot service) case array_key_exists("{$shortPrefix}_{$underscoreProperty}", $_SERVER): - return $_SERVER["{$shortPrefix}_{$underscoreProperty}"]; + return $_SERVER["{$shortPrefix}_{$underscoreProperty}"]; // @phpstan-ignore codeigniter.superglobalsOffsetAccess (reads live $_SERVER, not the snapshot service) case array_key_exists("{$prefix}.{$property}", $_ENV): return $_ENV["{$prefix}.{$property}"]; @@ -233,10 +233,10 @@ protected function getEnvValue(string $property, string $prefix, string $shortPr return $_ENV["{$prefix}_{$underscoreProperty}"]; case array_key_exists("{$prefix}.{$property}", $_SERVER): - return $_SERVER["{$prefix}.{$property}"]; + return $_SERVER["{$prefix}.{$property}"]; // @phpstan-ignore codeigniter.superglobalsOffsetAccess (reads live $_SERVER, not the snapshot service) case array_key_exists("{$prefix}_{$underscoreProperty}", $_SERVER): - return $_SERVER["{$prefix}_{$underscoreProperty}"]; + return $_SERVER["{$prefix}_{$underscoreProperty}"]; // @phpstan-ignore codeigniter.superglobalsOffsetAccess (reads live $_SERVER, not the snapshot service) default: $value = getenv("{$shortPrefix}.{$property}"); diff --git a/system/Config/BaseService.php b/system/Config/BaseService.php index 483da962d5ad..64b5266da041 100644 --- a/system/Config/BaseService.php +++ b/system/Config/BaseService.php @@ -244,7 +244,7 @@ public static function override(string $key, object $value): void * * $key must be a name matching a service. * - * @param array|bool|float|int|object|string|null ...$params + * @param mixed ...$params * * @return object */ @@ -445,8 +445,9 @@ public static function resetSingle(string $name) */ public static function injectMock(string $name, $mock) { - static::$instances[$name] = $mock; - static::$mocks[strtolower($name)] = $mock; + $name = strtolower($name); + static::$instances[$name] = $mock; + static::$mocks[$name] = $mock; } /** diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index d891b3d61954..f1a9428c13eb 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -764,14 +764,18 @@ protected function whereHaving(string $qbKey, $key, $value = null, string $type $keyValue = $key; } + if ($keyValue === []) { + return $this; + } + // If the escape value was not set will base it on the global setting if (! is_bool($escape)) { $escape = $this->db->protectIdentifiers; } - foreach ($keyValue as $k => $v) { - $prefix = empty($this->{$qbKey}) ? $this->groupGetType('') : $this->groupGetType($type); + $prefix = empty($this->{$qbKey}) ? $this->groupGetType('') : $this->groupGetType($type); + foreach ($keyValue as $k => $v) { if ($rawSqlOnly) { $k = ''; $op = ''; @@ -830,6 +834,8 @@ protected function whereHaving(string $qbKey, $key, $value = null, string $type 'escape' => $escape, ]; } + + $prefix = $type; } return $this; @@ -1152,13 +1158,17 @@ protected function _like($field, string $match = '', string $type = 'AND ', stri $keyValue = is_array($field) ? $field : [$field => $match]; + if ($keyValue === []) { + return $this; + } + + $prefix = $this->{$clause} === [] ? $this->groupGetType('') : $this->groupGetType($type); + foreach ($keyValue as $k => $v) { if ($insensitiveSearch) { $v = mb_strtolower($v, 'UTF-8'); } - $prefix = empty($this->{$clause}) ? $this->groupGetType('') : $this->groupGetType($type); - if ($side === 'none') { $bind = $this->setBind($k, $v, $escape); } elseif ($side === 'before') { @@ -1180,6 +1190,8 @@ protected function _like($field, string $match = '', string $type = 'AND ', stri 'condition' => $likeStatement, 'escape' => $escape, ]; + + $prefix = $type; } return $this; @@ -2589,6 +2601,13 @@ protected function validateUpdate(): bool */ public function updateBatch($set = null, $constraints = null, int $batchSize = 100) { + if ($this->QBWhere !== []) { + throw new DatabaseException( + 'updateBatch() cannot be safely combined with existing Query Builder WHERE conditions. ' + . 'Use updateBatch($data, $constraints), onConstraint(), or include all required constraint fields in the batch data.', + ); + } + $this->onConstraint($constraints); if (isset($this->QBOptions['setQueryAsData'])) { @@ -2667,7 +2686,7 @@ protected function _updateBatch(string $table, array $keys, array $values): stri $sql .= 'WHERE ' . implode( ' AND ', array_map( - static fn ($key, $value) => ( + static fn ($key, $value): RawSql|string => ( ($value instanceof RawSql && is_string($key)) ? $table . '.' . $key . ' = ' . $value @@ -2798,7 +2817,7 @@ public function getCompiledDelete(bool $reset = true): string /** * Compiles a delete string and runs the query * - * @param array|RawSql|string $where + * @param array|RawSql|string $where * * @return bool|string Returns a SQL string if in test mode. * @@ -2917,7 +2936,7 @@ protected function _deleteBatch(string $table, array $keys, array $values): stri $sql .= 'ON ' . implode( ' AND ', array_map( - static fn ($key, $value) => ( + static fn ($key, $value): RawSql|string => ( $value instanceof RawSql ? $value : ( @@ -2932,11 +2951,7 @@ protected function _deleteBatch(string $table, array $keys, array $values): stri ); // convert binds in where - foreach ($this->QBWhere as $key => $where) { - foreach ($this->binds as $field => $bind) { - $this->QBWhere[$key]['condition'] = str_replace(':' . $field . ':', $bind[0], $where['condition']); - } - } + $this->convertWhereBindsForBatch(); $sql .= ' ' . $this->compileWhereHaving('QBWhere'); @@ -2962,6 +2977,35 @@ protected function _deleteBatch(string $table, array $keys, array $values): stri return str_replace('{:_table_:}', $data, $sql); } + /** + * Escapes and substitutes the WHERE binds into the QBWhere conditions + * for batch delete queries. + * + * 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. + * + * @used-by _deleteBatch() + */ + protected function convertWhereBindsForBatch(): void + { + $replacers = []; + + foreach ($this->binds as $field => $bind) { + $escapedValue = $bind[1] ? $this->db->escape($bind[0]) : $bind[0]; + + if (is_array($bind[0])) { + $escapedValue = '(' . implode(',', $escapedValue) . ')'; + } + + $replacers[':' . $field . ':'] = (string) $escapedValue; + } + + foreach ($this->QBWhere as $key => $where) { + $this->QBWhere[$key]['condition'] = strtr($where['condition'], $replacers); + } + } + /** * Increments a numeric column by the specified value. * @@ -3056,7 +3100,7 @@ protected function trackAliases($table) * Generates a query string based on which functions were used. * Should not be called directly. * - * @param mixed $selectOverride + * @param false|string $selectOverride */ protected function compileSelect($selectOverride = false): string { diff --git a/system/Database/BaseConnection.php b/system/Database/BaseConnection.php index 273e4c60c4d5..c0cb7174dd52 100644 --- a/system/Database/BaseConnection.php +++ b/system/Database/BaseConnection.php @@ -781,7 +781,7 @@ abstract protected function execute(string $sql); * Should automatically handle different connections for read/write * queries if needed. * - * @param array|string|null $binds + * @param array|string|null $binds * * @return BaseResult|bool|Query * @@ -1533,7 +1533,7 @@ abstract public function affectedRows(): int; * Escapes data based on type. * Sets boolean and null types * - * @param array|bool|float|int|object|string|null $str + * @param mixed $str * * @return ($str is array ? array : float|int|string) */ @@ -2059,7 +2059,7 @@ protected function _enableForeignKeyChecks() /** * Accessor for properties if they exist. * - * @return array|bool|float|int|object|resource|string|null + * @return mixed */ public function __get(string $key) { diff --git a/system/Database/BaseResult.php b/system/Database/BaseResult.php index c0bdc2aa1025..5bede241a59d 100644 --- a/system/Database/BaseResult.php +++ b/system/Database/BaseResult.php @@ -312,7 +312,7 @@ public function getCustomRowObject(int $n, string $className) $this->currentRow = $n; } - return $this->customResultObject[$className][$this->currentRow]; + return $this->customResultObject[$className][$this->currentRow] ?? null; } /** @@ -333,7 +333,7 @@ public function getRowArray(int $n = 0) $this->currentRow = $n; } - return $result[$this->currentRow]; + return $result[$this->currentRow] ?? null; } /** @@ -350,11 +350,11 @@ public function getRowObject(int $n = 0) return null; } - if ($n !== $this->customResultObject && isset($result[$n])) { + if ($n !== $this->currentRow && isset($result[$n])) { $this->currentRow = $n; } - return $result[$this->currentRow]; + return $result[$this->currentRow] ?? null; } /** @@ -440,7 +440,7 @@ public function getPreviousRow(string $type = 'object') $this->currentRow--; } - return $result[$this->currentRow]; + return $result[$this->currentRow] ?? null; } /** diff --git a/system/Database/BaseUtils.php b/system/Database/BaseUtils.php index a9310e1d4e32..938c28364444 100644 --- a/system/Database/BaseUtils.php +++ b/system/Database/BaseUtils.php @@ -125,7 +125,7 @@ public function optimizeTable(string $tableName) /** * Optimize Database * - * @return mixed + * @return array|bool * * @throws DatabaseException */ @@ -170,7 +170,7 @@ public function optimizeDatabase() /** * Repair Table * - * @return mixed + * @return array|bool * * @throws DatabaseException */ @@ -250,7 +250,7 @@ public function getXMLFromResult(ResultInterface $query, array $params = []): st $xml .= $tab . '<' . $element . '>' . $newline; foreach ($row as $key => $val) { - $val = empty($val) ? '' : xml_convert((string) $val); + $val = ($val === null || $val === '') ? '' : xml_convert((string) $val); $xml .= $tab . $tab . '<' . $key . '>' . $val . '' . $newline; } diff --git a/system/Database/ConnectionInterface.php b/system/Database/ConnectionInterface.php index 97c8fa0bbcba..15fd63e1c7aa 100644 --- a/system/Database/ConnectionInterface.php +++ b/system/Database/ConnectionInterface.php @@ -100,7 +100,7 @@ public function getVersion(): string; * Should automatically handle different connections for read/write * queries if needed. * - * @param array|string|null $binds + * @param array|string|null $binds * * @return BaseResult|bool|Query */ @@ -137,7 +137,7 @@ public function getLastQuery(); * Escapes data based on type. * Sets boolean and null types. * - * @param array|bool|float|int|object|string|null $str + * @param mixed $str * * @return ($str is array ? array : float|int|string) */ @@ -149,7 +149,7 @@ public function escape($str); * * @param array ...$params * - * @return array|bool|float|int|object|resource|string|null + * @return mixed */ public function callFunction(string $functionName, ...$params); diff --git a/system/Database/Forge.php b/system/Database/Forge.php index f6eb617fd389..9291683acb5f 100644 --- a/system/Database/Forge.php +++ b/system/Database/Forge.php @@ -1209,6 +1209,10 @@ protected function _processIndexes(string $table, bool $asQuery = false): array */ protected function _processForeignKeys(string $table, bool $asQuery = false): array { + if ($this->foreignKeys === []) { + return ['']; + } + $errorNames = []; foreach ($this->foreignKeys as $fkeyInfo) { @@ -1225,40 +1229,43 @@ protected function _processForeignKeys(string $table, bool $asQuery = false): ar throw new DatabaseException(lang('Database.fieldNotExists', $errorNames)); } - $sqls = ['']; - - foreach ($this->foreignKeys as $index => $fkey) { - if ($asQuery === false) { - $index = 0; - } else { - $sqls[$index] = ''; - } + $sqls = ['']; + $isOci8 = $this->db->DBDriver === 'OCI8'; + $dbPrefix = $this->db->DBPrefix; + $fkSuffix = $isOci8 ? '_fk' : '_foreign'; - $nameIndex = $fkey['fkName'] !== '' ? - $fkey['fkName'] : - $table . '_' . implode('_', $fkey['field']) . ($this->db->DBDriver === 'OCI8' ? '_fk' : '_foreign'); + $prefixSql = $asQuery + ? 'ALTER TABLE ' . $this->db->escapeIdentifiers($dbPrefix . $table) . ' ADD ' + : ",\n\t"; - $nameIndexFilled = $this->db->escapeIdentifiers($nameIndex); - $foreignKeyFilled = implode(', ', $this->db->escapeIdentifiers($fkey['field'])); - $referenceTableFilled = $this->db->escapeIdentifiers($this->db->DBPrefix . $fkey['referenceTable']); - $referenceFieldFilled = implode(', ', $this->db->escapeIdentifiers($fkey['referenceField'])); + foreach ($this->foreignKeys as $index => $fkey) { + $idx = $asQuery ? $index : 0; if ($asQuery) { - $sqls[$index] .= 'ALTER TABLE ' . $this->db->escapeIdentifiers($this->db->DBPrefix . $table) . ' ADD '; - } else { - $sqls[$index] .= ",\n\t"; + $sqls[$idx] = ''; } - $formatSql = 'CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s(%s)'; - $sqls[$index] .= sprintf($formatSql, $nameIndexFilled, $foreignKeyFilled, $referenceTableFilled, $referenceFieldFilled); + $nameIndex = $fkey['fkName'] !== '' + ? $fkey['fkName'] + : $table . '_' . implode('_', $fkey['field']) . $fkSuffix; + + $sql = $prefixSql . sprintf( + 'CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s(%s)', + $this->db->escapeIdentifiers($nameIndex), + implode(', ', $this->db->escapeIdentifiers($fkey['field'])), + $this->db->escapeIdentifiers($dbPrefix . $fkey['referenceTable']), + implode(', ', $this->db->escapeIdentifiers($fkey['referenceField'])), + ); if ($fkey['onDelete'] !== false && in_array($fkey['onDelete'], $this->fkAllowActions, true)) { - $sqls[$index] .= ' ON DELETE ' . $fkey['onDelete']; + $sql .= ' ON DELETE ' . $fkey['onDelete']; } - if ($this->db->DBDriver !== 'OCI8' && $fkey['onUpdate'] !== false && in_array($fkey['onUpdate'], $this->fkAllowActions, true)) { - $sqls[$index] .= ' ON UPDATE ' . $fkey['onUpdate']; + if (! $isOci8 && $fkey['onUpdate'] !== false && in_array($fkey['onUpdate'], $this->fkAllowActions, true)) { + $sql .= ' ON UPDATE ' . $fkey['onUpdate']; } + + $sqls[$idx] .= $sql; } return $sqls; diff --git a/system/Database/MySQLi/Builder.php b/system/Database/MySQLi/Builder.php index a9e248fc4584..bba90421e9e4 100644 --- a/system/Database/MySQLi/Builder.php +++ b/system/Database/MySQLi/Builder.php @@ -92,7 +92,7 @@ protected function _updateBatch(string $table, array $keys, array $values): stri $sql .= 'ON ' . implode( ' AND ', array_map( - static fn ($key, $value) => ( + static fn ($key, $value): RawSql|string => ( ($value instanceof RawSql && is_string($key)) ? $table . '.' . $key . ' = ' . $value diff --git a/system/Database/OCI8/Builder.php b/system/Database/OCI8/Builder.php index 0bbf4283d056..ff5b1289c34a 100644 --- a/system/Database/OCI8/Builder.php +++ b/system/Database/OCI8/Builder.php @@ -150,7 +150,7 @@ protected function _truncate(string $table): string /** * Compiles a delete string and runs the query * - * @param mixed $where + * @param array|RawSql|string $where * * @return bool|string * @@ -248,7 +248,7 @@ protected function _updateBatch(string $table, array $keys, array $values): stri $sql .= 'ON (' . implode( ' AND ', array_map( - static fn ($key, $value) => ( + static fn ($key, $value): RawSql|string => ( ($value instanceof RawSql && is_string($key)) ? $table . '.' . $key . ' = ' . $value @@ -353,7 +353,7 @@ protected function _upsertBatch(string $table, array $keys, array $values): stri $sql .= implode( ' AND ', array_map( - static fn ($key, $value) => ( + static fn ($key, $value): RawSql|string => ( ($value instanceof RawSql && is_string($key)) ? $table . '.' . $key . ' = ' . $value @@ -442,7 +442,7 @@ protected function _deleteBatch(string $table, array $keys, array $values): stri $sql .= 'WHERE ' . implode( ' AND ', array_map( - static fn ($key, $value) => ( + static fn ($key, $value): RawSql|string => ( $value instanceof RawSql ? $value : ( @@ -457,11 +457,7 @@ protected function _deleteBatch(string $table, array $keys, array $values): stri ); // convert binds in where - foreach ($this->QBWhere as $key => $where) { - foreach ($this->binds as $field => $bind) { - $this->QBWhere[$key]['condition'] = str_replace(':' . $field . ':', $bind[0], $where['condition']); - } - } + $this->convertWhereBindsForBatch(); $sql .= ' ' . str_replace( 'WHERE ', diff --git a/system/Database/Postgre/Builder.php b/system/Database/Postgre/Builder.php index 502a5278482f..c3a673558ec8 100644 --- a/system/Database/Postgre/Builder.php +++ b/system/Database/Postgre/Builder.php @@ -225,7 +225,7 @@ protected function _insertBatch(string $table, array $keys, array $values): stri /** * Compiles a delete string and runs the query * - * @param mixed $where + * @param array|RawSql|string $where * * @return bool|string * @@ -597,11 +597,7 @@ static function ($key, $value) use ($table, $alias, $that): RawSql|string { ); // convert binds in where - foreach ($this->QBWhere as $key => $where) { - foreach ($this->binds as $field => $bind) { - $this->QBWhere[$key]['condition'] = str_replace(':' . $field . ':', $bind[0], $where['condition']); - } - } + $this->convertWhereBindsForBatch(); $sql .= ' ' . str_replace( 'WHERE ', diff --git a/system/Database/Postgre/Connection.php b/system/Database/Postgre/Connection.php index 37c1d678fcc0..4c3358a4b470 100644 --- a/system/Database/Postgre/Connection.php +++ b/system/Database/Postgre/Connection.php @@ -241,7 +241,7 @@ public function affectedRows(): int * * Escapes data based on type * - * @param array|bool|float|int|object|string|null $str + * @param mixed $str * * @return ($str is array ? array : float|int|string) */ diff --git a/system/Database/SQLSRV/Builder.php b/system/Database/SQLSRV/Builder.php index 67f2f26c33f9..cd7e7968fe72 100644 --- a/system/Database/SQLSRV/Builder.php +++ b/system/Database/SQLSRV/Builder.php @@ -538,7 +538,7 @@ protected function _delete(string $table): string /** * Compiles a delete string and runs the query * - * @param mixed $where + * @param array|RawSql|string $where * * @return bool|string * @@ -578,7 +578,7 @@ public function delete($where = '', ?int $limit = null, bool $resetData = true) * * Generates a query string based on which functions were used. * - * @param bool $selectOverride + * @param false|string $selectOverride */ protected function compileSelect($selectOverride = false): string { @@ -752,7 +752,7 @@ protected function _upsertBatch(string $table, array $keys, array $values): stri $sql .= implode( ' AND ', array_map( - static fn ($key, $value) => ( + static fn ($key, $value): RawSql|string => ( ($value instanceof RawSql && is_string($key)) ? $fullTableName . '.' . $key . ' = ' . $value diff --git a/system/Database/SQLSRV/Forge.php b/system/Database/SQLSRV/Forge.php index 0622de5e8e81..64a4ffaf6d35 100644 --- a/system/Database/SQLSRV/Forge.php +++ b/system/Database/SQLSRV/Forge.php @@ -321,7 +321,7 @@ protected function _alterTable(string $alterType, string $table, $processedField /** * Drop index for table * - * @return mixed + * @return false|resource */ protected function _dropIndex(string $table, object $indexData) { diff --git a/system/Debug/Toolbar.php b/system/Debug/Toolbar.php index fdd0b0f3f54c..f5885aa0555c 100644 --- a/system/Debug/Toolbar.php +++ b/system/Debug/Toolbar.php @@ -479,7 +479,7 @@ public function respond(): void // Otherwise, if it includes ?debugbar_time, then // we should return the entire debugbar. - if ($request->getGet('debugbar_time')) { + if ($request->getGet('debugbar_time') !== null) { helper('security'); // Negotiate the content-type to format the output diff --git a/system/Email/Email.php b/system/Email/Email.php index b886d07fdc72..4b3e94dfb38b 100644 --- a/system/Email/Email.php +++ b/system/Email/Email.php @@ -197,7 +197,7 @@ class Email * * @see http://www.ietf.org/rfc/rfc822.txt * - * @var "\r\n"|"n" + * @var "\n"|"\r\n" */ public $CRLF = "\r\n"; @@ -1335,89 +1335,6 @@ protected function appendAttachments(&$body, $boundary, $multipart = null) */ protected function prepQuotedPrintable($str) { - // ASCII code numbers for "safe" characters that can always be - // used literally, without encoding, as described in RFC 2049. - // http://www.ietf.org/rfc/rfc2049.txt - static $asciiSafeChars = [ - // ' ( ) + , - . / : = ? - 39, - 40, - 41, - 43, - 44, - 45, - 46, - 47, - 58, - 61, - 63, - // numbers - 48, - 49, - 50, - 51, - 52, - 53, - 54, - 55, - 56, - 57, - // upper-case letters - 65, - 66, - 67, - 68, - 69, - 70, - 71, - 72, - 73, - 74, - 75, - 76, - 77, - 78, - 79, - 80, - 81, - 82, - 83, - 84, - 85, - 86, - 87, - 88, - 89, - 90, - // lower-case letters - 97, - 98, - 99, - 100, - 101, - 102, - 103, - 104, - 105, - 106, - 107, - 108, - 109, - 110, - 111, - 112, - 113, - 114, - 115, - 116, - 117, - 118, - 119, - 120, - 121, - 122, - ]; - // We are intentionally wrapping so mail servers will encode characters // properly and MUAs will behave, so {unwrap} must go! $str = str_replace(['{unwrap}', '{/unwrap}'], '', $str); @@ -1438,46 +1355,55 @@ protected function prepQuotedPrintable($str) $str = str_replace(["\r\n", "\r"], "\n", $str); } - $escape = '='; + static $asciiSafeChars; + if ($asciiSafeChars === null) { + $safeChars = [39, 40, 41, 43, 44, 45, 46, 47, 58, 61, 63]; + $safeChars = array_merge($safeChars, range(48, 57), range(65, 90), range(97, 122)); + $asciiSafeChars = array_fill_keys($safeChars, true); + } + $output = ''; foreach (explode("\n", $str) as $line) { - $length = static::strlen($line); - $temp = ''; + $length = static::strlen($line); + $temp = ''; + $tempLen = 0; // Loop through each character in the line to add soft-wrap // characters at the end of a line " =\r\n" and add the newly // processed line(s) to the output (see comment on $crlf class property) for ($i = 0; $i < $length; $i++) { - // Grab the next character - $char = $line[$i]; - $ascii = ord($char); + $char = $line[$i]; + $ascii = ord($char); + $charLen = 1; // Convert spaces and tabs but only if it's the end of the line if ($ascii === 32 || $ascii === 9) { if ($i === ($length - 1)) { - $char = $escape . sprintf('%02s', dechex($ascii)); + $char = sprintf('=%02X', $ascii); + $charLen = 3; } } // DO NOT move this below the $ascii_safe_chars line! // // = (equals) signs are allowed by RFC2049, but must be encoded // as they are the encoding delimiter! - elseif ($ascii === 61) { - $char = $escape . strtoupper(sprintf('%02s', dechex($ascii))); // =3D - } elseif (! in_array($ascii, $asciiSafeChars, true)) { - $char = $escape . strtoupper(sprintf('%02s', dechex($ascii))); + elseif ($ascii === 61 || ! isset($asciiSafeChars[$ascii])) { + $char = sprintf('=%02X', $ascii); + $charLen = 3; } // If we're at the character limit, add the line to the output, // reset our temp variable, and keep on chuggin' - if ((static::strlen($temp) + static::strlen($char)) >= 76) { - $output .= $temp . $escape . $this->CRLF; - $temp = ''; + if (($tempLen + $charLen) >= 76) { + $output .= $temp . '=' . $this->CRLF; + $temp = ''; + $tempLen = 0; } // Add the character to our temporary line $temp .= $char; + $tempLen += $charLen; } // Add our completed line to the output diff --git a/system/Encryption/Handlers/BaseHandler.php b/system/Encryption/Handlers/BaseHandler.php index 365ed961d323..c324a465dbb8 100644 --- a/system/Encryption/Handlers/BaseHandler.php +++ b/system/Encryption/Handlers/BaseHandler.php @@ -55,7 +55,7 @@ protected static function substr($str, $start, $length = null) * * @param string $key Property name * - * @return array|bool|int|string|null + * @return array|bool|int|string|null */ public function __get($key) { diff --git a/system/Encryption/Handlers/SodiumHandler.php b/system/Encryption/Handlers/SodiumHandler.php index 248436c28799..55bf14893251 100644 --- a/system/Encryption/Handlers/SodiumHandler.php +++ b/system/Encryption/Handlers/SodiumHandler.php @@ -15,6 +15,7 @@ use CodeIgniter\Encryption\Exceptions\EncryptionException; use SensitiveParameter; +use SodiumException; /** * SodiumHandler uses libsodium in encryption. @@ -43,34 +44,31 @@ class SodiumHandler extends BaseHandler */ public function encrypt(#[SensitiveParameter] $data, #[SensitiveParameter] $params = null) { - // Allow key override - $key = $params !== null - ? (is_array($params) && isset($params['key']) ? $params['key'] : $params) - : $this->key; - - // Allow blockSize override - $blockSize = (is_array($params) && isset($params['blockSize'])) - ? $params['blockSize'] - : $this->blockSize; + $key = $this->key; + $blockSize = $this->blockSize; + + if ($params !== null) { + if (is_array($params)) { + $key = $params['key'] ?? $key; + $blockSize = $params['blockSize'] ?? $blockSize; + } else { + $key = $params; + } + } - if (empty($key)) { + if (empty($key) || strlen((string) $key) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) { throw EncryptionException::forNeedsStarterKey(); } - // create a nonce for this operation - $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); // 24 bytes - - // add padding before we encrypt the data if ($blockSize <= 0) { throw EncryptionException::forEncryptionFailed(); } - $data = sodium_pad($data, $blockSize); + $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + $data = sodium_pad($data, $blockSize); - // encrypt message and combine with nonce $ciphertext = $nonce . sodium_crypto_secretbox($data, $nonce, $key); - // cleanup buffers sodium_memzero($data); sodium_memzero($key); @@ -82,45 +80,41 @@ public function encrypt(#[SensitiveParameter] $data, #[SensitiveParameter] $para */ public function decrypt($data, #[SensitiveParameter] $params = null) { - // Allow key override - $key = $params !== null - ? (is_array($params) && isset($params['key']) ? $params['key'] : $params) - : $this->key; - - // Allow blockSize override - $blockSize = (is_array($params) && isset($params['blockSize'])) - ? $params['blockSize'] - : $this->blockSize; + $key = $this->key; + $blockSize = $this->blockSize; + + if ($params !== null) { + if (is_array($params)) { + $key = $params['key'] ?? $key; + $blockSize = $params['blockSize'] ?? $blockSize; + } else { + $key = $params; + } + } - if (empty($key)) { + if (empty($key) || strlen((string) $key) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) { throw EncryptionException::forNeedsStarterKey(); } if (mb_strlen($data, '8bit') < (SODIUM_CRYPTO_SECRETBOX_NONCEBYTES + SODIUM_CRYPTO_SECRETBOX_MACBYTES)) { - // message was truncated throw EncryptionException::forAuthenticationFailed(); } - // Extract info from encrypted data $nonce = self::substr($data, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); $ciphertext = self::substr($data, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); - // decrypt data $data = sodium_crypto_secretbox_open($ciphertext, $nonce, $key); - if ($data === false) { - // message was tampered in transit - throw EncryptionException::forAuthenticationFailed(); // @codeCoverageIgnore + if ($data === false || $blockSize <= 0) { + throw EncryptionException::forAuthenticationFailed(); } - // remove extra padding during encryption - if ($blockSize <= 0) { + try { + $data = sodium_unpad($data, $blockSize); + } catch (SodiumException) { throw EncryptionException::forAuthenticationFailed(); } - $data = sodium_unpad($data, $blockSize); - - // cleanup buffers sodium_memzero($ciphertext); sodium_memzero($key); diff --git a/system/Encryption/KeyRotationDecorator.php b/system/Encryption/KeyRotationDecorator.php index 8778b7ce800b..9e4b6ef0002b 100644 --- a/system/Encryption/KeyRotationDecorator.php +++ b/system/Encryption/KeyRotationDecorator.php @@ -86,7 +86,7 @@ public function decrypt($data, #[SensitiveParameter] $params = null) /** * Delegate property access to the inner handler. * - * @return array|bool|int|string|null + * @return array|bool|int|string|null */ public function __get(string $key) { diff --git a/system/Entity/Cast/CastInterface.php b/system/Entity/Cast/CastInterface.php index 8b727fb0117f..9f5552826859 100644 --- a/system/Entity/Cast/CastInterface.php +++ b/system/Entity/Cast/CastInterface.php @@ -23,20 +23,20 @@ interface CastInterface /** * Takes a raw value from Entity, returns its value for PHP. * - * @param array|bool|float|int|object|string|null $value Data - * @param array $params Additional param + * @param mixed $value Data + * @param array $params Additional param * - * @return array|bool|float|int|object|string|null + * @return mixed */ public static function get($value, array $params = []); /** * Takes a PHP value, returns its raw value for Entity. * - * @param array|bool|float|int|object|string|null $value Data - * @param array $params Additional param + * @param mixed $value Data + * @param array $params Additional param * - * @return array|bool|float|int|object|string|null + * @return mixed */ public static function set($value, array $params = []); } diff --git a/system/Entity/Entity.php b/system/Entity/Entity.php index e3733bd0fc02..95261f607458 100644 --- a/system/Entity/Entity.php +++ b/system/Entity/Entity.php @@ -157,7 +157,7 @@ public function __construct(?array $data = null) * properties, using any `setCamelCasedProperty()` methods * that may or may not exist. * - * @param array|bool|float|int|object|string|null> $data + * @param array $data * * @return $this */ @@ -556,7 +556,7 @@ protected function mutateDate($value) * @param string $attribute Attribute name * @param string $method Allowed to "get" and "set" * - * @return array|bool|float|int|object|string|null + * @return mixed * * @throws CastException */ @@ -631,7 +631,7 @@ public function cast(?bool $cast = null) * $this->my_property = $p; * $this->setMyProperty() = $p; * - * @param array|bool|float|int|object|string|null $value + * @param mixed $value * * @return void * @@ -682,7 +682,7 @@ public function __set(string $key, $value = null) * $p = $this->my_property * $p = $this->getMyProperty() * - * @return array|bool|float|int|object|string|null + * @return mixed * * @throws Exception */ diff --git a/system/Filters/Filters.php b/system/Filters/Filters.php index 5ec5fc0fa074..bf5419eab560 100644 --- a/system/Filters/Filters.php +++ b/system/Filters/Filters.php @@ -375,7 +375,7 @@ public function getRequiredFilters(string $position = 'before'): array { // For backward compatibility. For users who do not update Config\Filters. if (! isset($this->config->required[$position])) { - $baseConfig = config(BaseFiltersConfig::class); // @phpstan-ignore-line + $baseConfig = config(BaseFiltersConfig::class); $filters = $baseConfig->required[$position]; $aliases = $baseConfig->aliases; } else { diff --git a/system/Filters/InvalidChars.php b/system/Filters/InvalidChars.php index 1cd775153c0e..85e71c5d4f59 100644 --- a/system/Filters/InvalidChars.php +++ b/system/Filters/InvalidChars.php @@ -87,11 +87,18 @@ public function after(RequestInterface $request, ResponseInterface $response, $a * @param array|string $value * * @return array|string + * + * @throws SecurityException */ protected function checkEncoding($value) { if (is_array($value)) { - array_map($this->checkEncoding(...), $value); + foreach ($value as $key => $item) { + if (is_string($key)) { + $this->checkEncoding($key); + } + $this->checkEncoding($item); + } return $value; } @@ -113,7 +120,12 @@ protected function checkEncoding($value) protected function checkControl($value) { if (is_array($value)) { - array_map($this->checkControl(...), $value); + foreach ($value as $key => $item) { + if (is_string($key)) { + $this->checkControl($key); + } + $this->checkControl($item); + } return $value; } diff --git a/system/HTTP/CLIRequest.php b/system/HTTP/CLIRequest.php index bde4e1b8e3f8..453272a4f59a 100644 --- a/system/HTTP/CLIRequest.php +++ b/system/HTTP/CLIRequest.php @@ -164,7 +164,7 @@ public function getOptionString(bool $useLongOpts = false): string continue; } - if (mb_strpos($value, ' ') !== false) { + if (str_contains($value, ' ')) { $out .= '"' . $value . '" '; } else { $out .= "{$value} "; diff --git a/system/HTTP/CURLRequest.php b/system/HTTP/CURLRequest.php index 02aadfa8f97b..ac05f9ae903b 100644 --- a/system/HTTP/CURLRequest.php +++ b/system/HTTP/CURLRequest.php @@ -289,7 +289,7 @@ public function setForm(array $params, bool $multipart = false) /** * Set JSON data to be sent. * - * @param array|bool|float|int|object|string|null $data + * @param mixed $data * * @return $this */ diff --git a/system/HTTP/Files/UploadedFile.php b/system/HTTP/Files/UploadedFile.php index b43923026021..95a393616dff 100644 --- a/system/HTTP/Files/UploadedFile.php +++ b/system/HTTP/Files/UploadedFile.php @@ -123,7 +123,8 @@ public function __construct(string $path, string $originalName, ?string $mimeTyp * @see http://php.net/move_uploaded_file * * @param string $targetPath Path to which to move the uploaded file. - * @param string|null $name the name to rename the file to. + * @param string|null $name The name to rename the file to. When null, the client-provided name is used and sanitized. + * A caller-supplied name is NOT sanitized. * @param bool $overwrite State for indicating whether to overwrite the previously generated file with the same * name or not. * @@ -142,7 +143,10 @@ public function move(string $targetPath, ?string $name = null, bool $overwrite = throw HTTPException::forInvalidFile(); } - $name ??= $this->getName(); + if ($name === null) { + helper('security'); + $name = sanitize_filename($this->getName()); + } $destination = $overwrite ? $targetPath . $name : $this->getDestination($targetPath . $name); try { diff --git a/system/HTTP/IncomingRequest.php b/system/HTTP/IncomingRequest.php index f9a57a4098ba..3a43d6dec050 100644 --- a/system/HTTP/IncomingRequest.php +++ b/system/HTTP/IncomingRequest.php @@ -272,6 +272,10 @@ public function isSecure(): bool return true; } + if (! $this->isFromTrustedProxy()) { + return false; + } + if ($this->hasHeader('X-Forwarded-Proto') && $this->header('X-Forwarded-Proto')->getValue() === 'https') { return true; } @@ -363,7 +367,7 @@ public function getDefaultLocale(): string * @param int|null $filter Filter constant * @param array|int|null $flags * - * @return array|bool|float|int|stdClass|string|null + * @return array|bool|float|int|stdClass|string|null */ public function getVar($index = null, $filter = null, $flags = null) { @@ -390,7 +394,7 @@ public function getVar($index = null, $filter = null, $flags = null) * * @see http://php.net/manual/en/function.json-decode.php * - * @return array|bool|float|int|stdClass|null + * @return array|bool|float|int|stdClass|null * * @throws HTTPException When the body is invalid as JSON. */ @@ -417,7 +421,7 @@ public function getJSON(bool $assoc = false, int $depth = 512, int $options = 0) * @param int|null $filter Filter Constant * @param array|int|null $flags Option * - * @return array|bool|float|int|stdClass|string|null + * @return array|bool|float|int|stdClass|string|null */ public function getJsonVar($index = null, bool $assoc = false, ?int $filter = null, $flags = null) { @@ -508,7 +512,7 @@ public function getRawInput() * @param int|null $filter Filter Constant * @param array|int|null $flags Option * - * @return array|bool|float|int|object|string|null + * @return mixed */ public function getRawInputVar($index = null, ?int $filter = null, $flags = null) { @@ -562,7 +566,7 @@ public function getRawInputVar($index = null, ?int $filter = null, $flags = null * @param int|null $filter A filter name to apply. * @param array|int|null $flags * - * @return array|bool|float|int|object|string|null + * @return mixed */ public function getGet($index = null, $filter = null, $flags = null) { @@ -576,7 +580,7 @@ public function getGet($index = null, $filter = null, $flags = null) * @param int|null $filter A filter name to apply * @param array|int|null $flags * - * @return array|bool|float|int|object|string|null + * @return mixed */ public function getPost($index = null, $filter = null, $flags = null) { @@ -590,7 +594,7 @@ public function getPost($index = null, $filter = null, $flags = null) * @param int|null $filter A filter name to apply * @param array|int|null $flags * - * @return array|bool|float|int|object|string|null + * @return mixed */ public function getPostGet($index = null, $filter = null, $flags = null) { @@ -598,6 +602,16 @@ public function getPostGet($index = null, $filter = null, $flags = null) return array_merge($this->getGet($index, $filter, $flags), $this->getPost($index, $filter, $flags)); } + if (is_array($index)) { + $output = []; + + foreach ($index as $key) { + $output[$key] = $this->getPostGet($key, $filter, $flags); + } + + return $output; + } + // Use $_POST directly here, since filter_has_var only // checks the initial POST data, not anything that might // have been added since. @@ -613,7 +627,7 @@ public function getPostGet($index = null, $filter = null, $flags = null) * @param int|null $filter A filter name to apply * @param array|int|null $flags * - * @return array|bool|float|int|object|string|null + * @return mixed */ public function getGetPost($index = null, $filter = null, $flags = null) { @@ -621,6 +635,16 @@ public function getGetPost($index = null, $filter = null, $flags = null) return array_merge($this->getPost($index, $filter, $flags), $this->getGet($index, $filter, $flags)); } + if (is_array($index)) { + $output = []; + + foreach ($index as $key) { + $output[$key] = $this->getGetPost($key, $filter, $flags); + } + + return $output; + } + // Use $_GET directly here, since filter_has_var only // checks the initial GET data, not anything that might // have been added since. @@ -636,7 +660,7 @@ public function getGetPost($index = null, $filter = null, $flags = null) * @param int|null $filter A filter name to be applied * @param array|int|null $flags * - * @return array|bool|float|int|object|string|null + * @return mixed */ public function getCookie($index = null, $filter = null, $flags = null) { diff --git a/system/HTTP/RequestTrait.php b/system/HTTP/RequestTrait.php index 6adfe3794935..9011f4148eba 100644 --- a/system/HTTP/RequestTrait.php +++ b/system/HTTP/RequestTrait.php @@ -87,67 +87,7 @@ public function getIPAddress(): string // @TODO Extract all this IP address logic to another class. foreach ($proxyIPs as $proxyIP => $header) { - // Check if we have an IP address or a subnet - if (! str_contains($proxyIP, '/')) { - // An IP address (and not a subnet) is specified. - // We can compare right away. - if ($proxyIP === $this->ipAddress) { - $spoof = $this->getClientIP($header); - - if ($spoof !== null) { - $this->ipAddress = $spoof; - break; - } - } - - continue; - } - - // We have a subnet ... now the heavy lifting begins - if (! isset($separator)) { - $separator = $ipValidator($this->ipAddress, 'ipv6') ? ':' : '.'; - } - - // If the proxy entry doesn't match the IP protocol - skip it - if (! str_contains($proxyIP, $separator)) { - continue; - } - - // Convert the REMOTE_ADDR IP address to binary, if needed - if (! isset($ip, $sprintf)) { - if ($separator === ':') { - // Make sure we're having the "full" IPv6 format - $ip = explode(':', str_replace('::', str_repeat(':', 9 - substr_count($this->ipAddress, ':')), $this->ipAddress)); - - for ($j = 0; $j < 8; $j++) { - $ip[$j] = intval($ip[$j], 16); - } - - $sprintf = '%016b%016b%016b%016b%016b%016b%016b%016b'; - } else { - $ip = explode('.', $this->ipAddress); - $sprintf = '%08b%08b%08b%08b'; - } - - $ip = vsprintf($sprintf, $ip); - } - - // Split the netmask length off the network address - sscanf($proxyIP, '%[^/]/%d', $netaddr, $masklen); - - // Again, an IPv6 address is most likely in a compressed form - if ($separator === ':') { - $netaddr = explode(':', str_replace('::', str_repeat(':', 9 - substr_count($netaddr, ':')), $netaddr)); - - for ($i = 0; $i < 8; $i++) { - $netaddr[$i] = intval($netaddr[$i], 16); - } - } else { - $netaddr = explode('.', $netaddr); - } - - // Convert to binary and finally compare - if (strncmp($ip, vsprintf($sprintf, $netaddr), $masklen) === 0) { + if ($this->checkIPAgainstProxy($this->ipAddress, (string) $proxyIP)) { $spoof = $this->getClientIP($header); if ($spoof !== null) { @@ -192,6 +132,92 @@ private function getClientIP(string $header): ?string return $spoof; } + /** + * Checks if the request comes from one of the trusted proxies + * configured in Config\App::$proxyIPs. + */ + protected function isFromTrustedProxy(): bool + { + $proxyIPs = $this->config->proxyIPs; + + if (! is_array($proxyIPs) || $proxyIPs === []) { + return false; + } + + $remoteAddr = $this->getServer('REMOTE_ADDR'); + + if (! is_string($remoteAddr) || $remoteAddr === '') { + return false; + } + + foreach (array_keys($proxyIPs) as $proxyIP) { + if ($this->checkIPAgainstProxy($remoteAddr, (string) $proxyIP)) { + return true; + } + } + + return false; + } + + /** + * Checks if the given IP address matches the trusted proxy entry, + * which may be a single IP address or a subnet in CIDR notation. + * Supports both IPv4 and IPv6. + */ + private function checkIPAgainstProxy(string $ip, string $proxyIP): bool + { + $maskLength = null; + + if (str_contains($proxyIP, '/')) { + [$proxyIP, $mask] = explode('/', $proxyIP, 2); + + if ($mask === '' || ! ctype_digit($mask)) { + return false; + } + + $maskLength = (int) $mask; + } + + $binaryIP = inet_pton($ip); + $binaryProxy = inet_pton($proxyIP); + + if ($binaryIP === false || $binaryProxy === false) { + return false; + } + + // If the proxy entry doesn't match the IP protocol - no match + if (strlen($binaryIP) !== strlen($binaryProxy)) { + return false; + } + + if ($maskLength === null) { + return $binaryIP === $binaryProxy; + } + + if ($maskLength > strlen($binaryIP) * 8) { + return false; + } + + if ($maskLength === 0) { + return true; + } + + $fullBytes = intdiv($maskLength, 8); + $remainingBits = $maskLength % 8; + + if ($fullBytes > 0 && strncmp($binaryIP, $binaryProxy, $fullBytes) !== 0) { + return false; + } + + if ($remainingBits > 0) { + $bitmask = 0xFF & (0xFF << (8 - $remainingBits)); + + return (ord($binaryIP[$fullBytes]) & $bitmask) === (ord($binaryProxy[$fullBytes]) & $bitmask); + } + + return true; + } + /** * Fetch an item from the $_SERVER array. * @@ -257,7 +283,7 @@ public function setGlobal(string $name, $value) * @param int|null $filter Filter constant * @param array|int|null $flags Options * - * @return array|bool|float|int|object|string|null + * @return mixed */ public function fetchGlobal(string $name, $index = null, ?int $filter = null, $flags = null) { diff --git a/system/HTTP/ResponseTrait.php b/system/HTTP/ResponseTrait.php index 3063e44116fb..265a62fb0a1f 100644 --- a/system/HTTP/ResponseTrait.php +++ b/system/HTTP/ResponseTrait.php @@ -733,20 +733,15 @@ public function download(string $filename = '', $data = '', bool $setMime = fals return null; } - $filepath = ''; if ($data === null) { - $filepath = $filename; - $filename = explode('/', str_replace(DIRECTORY_SEPARATOR, '/', $filename)); - $filename = end($filename); + $response = new DownloadResponse(basename($filename), $setMime); + $response->setFilePath($filename); + + return $response; } $response = new DownloadResponse($filename, $setMime); - - if ($filepath !== '') { - $response->setFilePath($filepath); - } elseif ($data !== null) { - $response->setBinary($data); - } + $response->setBinary($data); return $response; } diff --git a/system/HTTP/UserAgent.php b/system/HTTP/UserAgent.php index ba9e544ae2f8..fe7679e59384 100644 --- a/system/HTTP/UserAgent.php +++ b/system/HTTP/UserAgent.php @@ -317,8 +317,15 @@ protected function setBrowser(): bool if (is_array($this->config->browsers) && $this->config->browsers !== []) { foreach ($this->config->browsers as $key => $val) { if (preg_match('|' . $key . '.*?([0-9\.]+)|i', $this->agent, $match)) { + $version = $match[1]; + + // Safari's browser version is reported in the Version token. + if ($val === 'Safari' && preg_match('|Version/([0-9\.]+).*?Safari|i', $this->agent, $safariMatch)) { + $version = $safariMatch[1]; + } + $this->isBrowser = true; - $this->version = $match[1]; + $this->version = $version; $this->browser = $val; $this->setMobile(); diff --git a/system/Helpers/Array/ArrayHelper.php b/system/Helpers/Array/ArrayHelper.php index 6741dc4bbb0e..1e7d6904796c 100644 --- a/system/Helpers/Array/ArrayHelper.php +++ b/system/Helpers/Array/ArrayHelper.php @@ -35,7 +35,7 @@ final class ArrayHelper * * @param string $index The index as dot array syntax. * - * @return array|bool|int|object|string|null + * @return mixed */ public static function dotSearch(string $index, array $array) { @@ -68,7 +68,7 @@ private static function convertToArray(string $index): array * * @used-by dotSearch() * - * @return array|bool|float|int|object|string|null + * @return mixed */ private static function arraySearchDot(array $indexes, array $array) { diff --git a/system/Helpers/array_helper.php b/system/Helpers/array_helper.php index 837a612e4cef..4d5d12488d78 100644 --- a/system/Helpers/array_helper.php +++ b/system/Helpers/array_helper.php @@ -20,7 +20,7 @@ * Searches an array through dot syntax. Supports * wildcard searches, like foo.*.bar * - * @return array|bool|int|object|string|null + * @return mixed */ function dot_array_search(string $index, array $array) { @@ -34,7 +34,7 @@ function dot_array_search(string $index, array $array) * * @param int|string $key * - * @return array|bool|float|int|object|string|null + * @return mixed */ function array_deep_search($key, array $array) { @@ -43,8 +43,12 @@ function array_deep_search($key, array $array) } foreach ($array as $value) { - if (is_array($value) && ($result = array_deep_search($key, $value))) { - return $result; + if (is_array($value)) { + $result = array_deep_search($key, $value); + + if ($result !== null) { + return $result; + } } } diff --git a/system/I18n/TimeTrait.php b/system/I18n/TimeTrait.php index 9daa1f9b7848..04499668f5ed 100644 --- a/system/I18n/TimeTrait.php +++ b/system/I18n/TimeTrait.php @@ -1211,7 +1211,7 @@ public function __toString(): string * * @param string $name * - * @return array|bool|DateTimeInterface|DateTimeZone|int|IntlCalendar|self|string|null + * @return array|bool|DateTimeInterface|DateTimeZone|int|IntlCalendar|self|string|null */ public function __get($name) { diff --git a/system/Images/Image.php b/system/Images/Image.php index 55754e339535..0634405c6ec0 100644 --- a/system/Images/Image.php +++ b/system/Images/Image.php @@ -26,14 +26,14 @@ class Image extends File /** * The original image width in pixels. * - * @var float|int + * @var int */ public $origWidth; /** * The original image height in pixels. * - * @var float|int + * @var int */ public $origHeight; diff --git a/system/Model.php b/system/Model.php index f4ddd75276d3..0d3c55ee0568 100644 --- a/system/Model.php +++ b/system/Model.php @@ -711,15 +711,10 @@ public function update($id = null, $row = null): bool return parent::update($id, $row); } - protected function objectToRawArray($object, bool $onlyChanged = true, bool $recursive = false): array - { - return parent::objectToRawArray($object, $onlyChanged); - } - /** * Provides/instantiates the builder/db connection and model's table/primary key names and return type. * - * @return array|BaseBuilder|bool|float|int|object|string|null + * @return mixed */ public function __get(string $name) { @@ -746,7 +741,7 @@ public function __isset(string $name): bool * Provides direct access to method in the builder (if available) * and the database connection. * - * @return $this|array|BaseBuilder|bool|float|int|object|string|null + * @return mixed */ public function __call(string $name, array $params) { diff --git a/system/Security/CheckPhpIni.php b/system/Security/CheckPhpIni.php index 949c1d10aa48..d1ab3dfccda0 100644 --- a/system/Security/CheckPhpIni.php +++ b/system/Security/CheckPhpIni.php @@ -182,8 +182,8 @@ private static function checkIni(?string $argument = null): array foreach ($items as $key => $values) { $hasKeyInIni = array_key_exists($key, $ini); $output[$key] = [ - 'global' => $hasKeyInIni ? $ini[$key]['global_value'] : 'disabled', - 'current' => $hasKeyInIni ? $ini[$key]['local_value'] : 'disabled', + 'global' => $hasKeyInIni ? (string) ($ini[$key]['global_value'] ?? '') : 'disabled', + 'current' => $hasKeyInIni ? (string) ($ini[$key]['local_value'] ?? '') : 'disabled', 'recommended' => $values['recommended'] ?? '', 'remark' => $values['remark'] ?? '', ]; diff --git a/system/Session/Handlers/RedisHandler.php b/system/Session/Handlers/RedisHandler.php index 4c8f78ba3894..54f628c4789b 100644 --- a/system/Session/Handlers/RedisHandler.php +++ b/system/Session/Handlers/RedisHandler.php @@ -98,8 +98,8 @@ public function __construct(SessionConfig $config, string $ipAddress) $this->keyPrefix .= $this->ipAddress . ':'; } - $this->lockRetryInterval = $config->lockWait ?? $this->lockRetryInterval; - $this->lockMaxRetries = $config->lockAttempts ?? $this->lockMaxRetries; + $this->lockRetryInterval = $config->lockRetryInterval ?? $this->lockRetryInterval; // @phpstan-ignore nullCoalesce.property + $this->lockMaxRetries = $config->lockMaxRetries ?? $this->lockMaxRetries; // @phpstan-ignore nullCoalesce.property } protected function setSavePath(): void @@ -386,10 +386,10 @@ protected function lockSession(string $sessionID): bool break; } while (++$attempt < $this->lockMaxRetries); - if ($attempt === 300) { + if ($attempt === $this->lockMaxRetries) { $this->logger->error( 'Session: Unable to obtain lock for ' . $this->keyPrefix . $sessionID - . ' after 300 attempts, aborting.', + . ' after ' . $this->lockMaxRetries . ' attempts, aborting.', ); return false; diff --git a/system/Session/Session.php b/system/Session/Session.php index 1c3824928775..1998a0ea8596 100644 --- a/system/Session/Session.php +++ b/system/Session/Session.php @@ -110,11 +110,10 @@ public function start() $this->setSaveHandler(); // Sanitize the cookie, because apparently PHP doesn't do that for userspace handlers - if ( - isset($_COOKIE[$this->config->cookieName]) - && (! is_string($_COOKIE[$this->config->cookieName]) || preg_match('#\A' . $this->sidRegexp . '\z#', $_COOKIE[$this->config->cookieName]) !== 1) - ) { - unset($_COOKIE[$this->config->cookieName]); + $cookie = service('superglobals')->cookie($this->config->cookieName); + + if (isset($cookie) && (! is_string($cookie) || preg_match('#\A' . $this->sidRegexp . '\z#', $cookie) !== 1)) { + service('superglobals')->unsetCookie($this->config->cookieName); } $this->startSession(); @@ -132,7 +131,7 @@ public function start() } // Another work-around ... PHP doesn't seem to send the session cookie // unless it is being currently created or regenerated - elseif (isset($_COOKIE[$this->config->cookieName]) && $_COOKIE[$this->config->cookieName] === session_id()) { + elseif (isset($cookie) && $cookie === session_id()) { $this->setCookie(); } diff --git a/system/Test/DOMParser.php b/system/Test/DOMParser.php index d8521edfbc86..52765a4d656b 100644 --- a/system/Test/DOMParser.php +++ b/system/Test/DOMParser.php @@ -112,7 +112,7 @@ public function see(?string $search = null, ?string $element = null): bool if ($element === null) { $content = $this->dom->saveHTML($this->dom->documentElement); - return mb_strpos($content, $search) !== false; + return str_contains($content, $search); } $result = $this->doXPath($search, $element); diff --git a/system/Test/FeatureTestTrait.php b/system/Test/FeatureTestTrait.php index a271191a2271..087fc0a59d90 100644 --- a/system/Test/FeatureTestTrait.php +++ b/system/Test/FeatureTestTrait.php @@ -350,9 +350,9 @@ protected function setupRequest(string $method, ?string $path = null): IncomingR $request->setProtocolVersion('1.1'); if ($config->forceGlobalSecureRequests) { - $_SERVER['HTTPS'] = 'test'; - $server = $request->getServer(); - $server['HTTPS'] = 'test'; + service('superglobals')->setServer('HTTPS', 'test'); + $server = $request->getServer(); + $server['HTTPS'] = 'test'; $request->setGlobal('server', $server); } diff --git a/system/Test/Filters/CITestStreamFilter.php b/system/Test/Filters/CITestStreamFilter.php index 538c0741ba8d..fa91aa69ef55 100644 --- a/system/Test/Filters/CITestStreamFilter.php +++ b/system/Test/Filters/CITestStreamFilter.php @@ -92,6 +92,22 @@ public static function removeOutputFilter(): void self::removeFilter(self::$out); } + /** + * Whether an output filter is currently attached to STDOUT. + */ + public static function hasOutputFilter(): bool + { + return self::$out !== null; + } + + /** + * Whether an error filter is currently attached to STDERR. + */ + public static function hasErrorFilter(): bool + { + return self::$err !== null; + } + /** * @param resource|null $stream * diff --git a/system/Test/Mock/MockConnection.php b/system/Test/Mock/MockConnection.php index d14160595333..7f6aa5240e27 100644 --- a/system/Test/Mock/MockConnection.php +++ b/system/Test/Mock/MockConnection.php @@ -70,7 +70,7 @@ public function shouldReturn(string $method, $return) * Should automatically handle different connections for read/write * queries if needed. * - * @param mixed $binds + * @param array|string|null $binds * * @return BaseResult|bool|Query * diff --git a/system/Test/Mock/MockInputOutput.php b/system/Test/Mock/MockInputOutput.php index 6aa4779fb742..9bf8a4f54810 100644 --- a/system/Test/Mock/MockInputOutput.php +++ b/system/Test/Mock/MockInputOutput.php @@ -35,6 +35,16 @@ final class MockInputOutput extends InputOutput */ private array $outputs = []; + /** + * Snapshot of the shared CITestStreamFilter state captured before this + * object attaches its own filters, restored once it is done. Lets a test + * combine MockInputOutput with an enclosing StreamFilterTrait without the + * latter's filters being torn down. + * + * @var array{output: bool, error: bool, buffer: string}|null + */ + private ?array $priorFilterState = null; + /** * Sets user inputs. * @@ -88,6 +98,12 @@ public function getOutputs(): array private function addStreamFilters(): void { + $this->priorFilterState = [ + 'output' => CITestStreamFilter::hasOutputFilter(), + 'error' => CITestStreamFilter::hasErrorFilter(), + 'buffer' => CITestStreamFilter::$buffer, + ]; + CITestStreamFilter::registration(); CITestStreamFilter::addOutputFilter(); CITestStreamFilter::addErrorFilter(); @@ -97,6 +113,22 @@ private function removeStreamFilters(): void { CITestStreamFilter::removeOutputFilter(); CITestStreamFilter::removeErrorFilter(); + + if ($this->priorFilterState === null) { + return; + } + + CITestStreamFilter::$buffer = $this->priorFilterState['buffer']; + + if ($this->priorFilterState['output']) { + CITestStreamFilter::addOutputFilter(); + } + + if ($this->priorFilterState['error']) { + CITestStreamFilter::addErrorFilter(); + } + + $this->priorFilterState = null; } public function input(?string $prefix = null): string diff --git a/system/Test/Mock/MockSecurity.php b/system/Test/Mock/MockSecurity.php index 89d1eab0a701..072dc548dbe6 100644 --- a/system/Test/Mock/MockSecurity.php +++ b/system/Test/Mock/MockSecurity.php @@ -19,7 +19,7 @@ class MockSecurity extends Security { protected function doSendCookie(): void { - $_COOKIE['csrf_cookie_name'] = $this->hash; + service('superglobals')->setCookie('csrf_cookie_name', $this->hash); } protected function randomize(string $hash): string diff --git a/system/Traits/ConditionalTrait.php b/system/Traits/ConditionalTrait.php index 6835de6c3a4f..b24fbdcd1b0c 100644 --- a/system/Traits/ConditionalTrait.php +++ b/system/Traits/ConditionalTrait.php @@ -23,7 +23,6 @@ trait ConditionalTrait * @param TWhen $condition * @param callable(self, TWhen): mixed $callback * @param (callable(self): mixed)|null $defaultCallback - * @param mixed $condition * * @return $this */ @@ -46,7 +45,6 @@ public function when($condition, callable $callback, ?callable $defaultCallback * @param TWhenNot $condition * @param callable(self, TWhenNot): mixed $callback * @param (callable(self): mixed)|null $defaultCallback - * @param mixed $condition * * @return $this */ diff --git a/system/Validation/Rules.php b/system/Validation/Rules.php index e3e256b099b4..7617b283f099 100644 --- a/system/Validation/Rules.php +++ b/system/Validation/Rules.php @@ -316,7 +316,7 @@ public function not_in_list($value, string $list): bool } /** - * @param array|bool|float|int|object|string|null $str + * @param mixed $str */ public function required($str = null): bool { @@ -429,15 +429,22 @@ public function required_without( $fieldData = dot_array_search($otherField, $data); $fieldSplitArray = explode('.', $field); - $fieldKey = $fieldSplitArray[1]; + $fieldKey = $fieldSplitArray[1] ?? null; if (is_array($fieldData)) { - return ! empty(dot_array_search($otherField, $data)[$fieldKey]); + if (empty($fieldData[$fieldKey])) { + return false; + } + + continue; } - $nowField = str_replace('*', $fieldKey, $otherField); + + $nowField = str_replace('*', (string) $fieldKey, $otherField); $nowFieldVaule = dot_array_search($nowField, $data); - return null !== $nowFieldVaule; + if ($nowFieldVaule === null) { + return false; + } } } @@ -447,10 +454,10 @@ public function required_without( /** * The field exists in $data. * - * @param array|bool|float|int|object|string|null $value The field value. - * @param string|null $param The rule's parameter. - * @param array $data The data to be validated. - * @param string|null $field The field name. + * @param mixed $value The field value. + * @param string|null $param The rule's parameter. + * @param array $data The data to be validated. + * @param string|null $field The field name. */ public function field_exists( $value = null, diff --git a/system/Validation/StrictRules/CreditCardRules.php b/system/Validation/StrictRules/CreditCardRules.php index 6512568317ed..fd28e4454095 100644 --- a/system/Validation/StrictRules/CreditCardRules.php +++ b/system/Validation/StrictRules/CreditCardRules.php @@ -42,7 +42,7 @@ public function __construct() * 'cc_num' => 'valid_cc_number[visa]' * ]; * - * @param array|bool|float|int|object|string|null $ccNumber + * @param mixed $ccNumber */ public function valid_cc_number($ccNumber, string $type): bool { diff --git a/system/Validation/StrictRules/FileRules.php b/system/Validation/StrictRules/FileRules.php index b6d4fdb673fd..d381f4f47301 100644 --- a/system/Validation/StrictRules/FileRules.php +++ b/system/Validation/StrictRules/FileRules.php @@ -15,6 +15,7 @@ use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\HTTP\CLIRequest; +use CodeIgniter\HTTP\Files\UploadedFile; use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\RequestInterface; use Config\Mimes; @@ -150,6 +151,10 @@ public function is_image(?string $blank, string $params): bool if (mb_strpos($type, 'image') !== 0) { return false; } + + if ($this->hasInvalidImageClientExtension($file)) { + return false; + } } return true; @@ -182,6 +187,10 @@ public function mime_in(?string $blank, string $params): bool if (! in_array($file->getMimeType(), $params, true)) { return false; } + + if ($this->hasMismatchedClientExtension($file)) { + return false; + } } return true; @@ -321,4 +330,34 @@ public function min_dims(?string $blank, string $params): bool return true; } + + /** + * Reject filenames with non-empty extensions that are not image types. + */ + private function hasInvalidImageClientExtension(UploadedFile $file): bool + { + $clientExtension = trim(strtolower($file->getClientExtension()), '. '); + + if ($clientExtension === '') { + return false; + } + + $type = Mimes::guessTypeFromExtension($clientExtension) ?? ''; + + return mb_strpos($type, 'image') !== 0; + } + + /** + * Reject filenames with non-empty extensions that do not match the detected content. + */ + private function hasMismatchedClientExtension(UploadedFile $file): bool + { + $clientExtension = trim(strtolower($file->getClientExtension()), '. '); + + if ($clientExtension === '') { + return false; + } + + return $file->guessExtension() !== $clientExtension; + } } diff --git a/system/Validation/StrictRules/FormatRules.php b/system/Validation/StrictRules/FormatRules.php index 0363f4be4afc..8557a6de070d 100644 --- a/system/Validation/StrictRules/FormatRules.php +++ b/system/Validation/StrictRules/FormatRules.php @@ -32,7 +32,7 @@ public function __construct() /** * Alpha * - * @param array|bool|float|int|object|string|null $str + * @param mixed $str */ public function alpha($str = null): bool { @@ -46,7 +46,7 @@ public function alpha($str = null): bool /** * Alpha with spaces. * - * @param array|bool|float|int|object|string|null $value Value. + * @param mixed $value Value. * * @return bool True if alpha with spaces, else false. */ @@ -62,7 +62,7 @@ public function alpha_space($value = null): bool /** * Alphanumeric with underscores and dashes * - * @param array|bool|float|int|object|string|null $str + * @param mixed $str */ public function alpha_dash($str = null): bool { @@ -84,7 +84,7 @@ public function alpha_dash($str = null): bool * _ underscore, + plus, = equals, | vertical bar, : colon, . period * ~ ! # $ % & * - _ + = | : . * - * @param array|bool|float|int|object|string|null $str + * @param mixed $str * * @return bool */ @@ -104,7 +104,7 @@ public function alpha_numeric_punct($str) /** * Alphanumeric * - * @param array|bool|float|int|object|string|null $str + * @param mixed $str */ public function alpha_numeric($str = null): bool { @@ -122,7 +122,7 @@ public function alpha_numeric($str = null): bool /** * Alphanumeric w/ spaces * - * @param array|bool|float|int|object|string|null $str + * @param mixed $str */ public function alpha_numeric_space($str = null): bool { @@ -140,7 +140,7 @@ public function alpha_numeric_space($str = null): bool /** * Any type of string * - * @param array|bool|float|int|object|string|null $str + * @param mixed $str */ public function string($str = null): bool { @@ -150,7 +150,7 @@ public function string($str = null): bool /** * Decimal number * - * @param array|bool|float|int|object|string|null $str + * @param mixed $str */ public function decimal($str = null): bool { @@ -168,7 +168,7 @@ public function decimal($str = null): bool /** * String of hexidecimal characters * - * @param array|bool|float|int|object|string|null $str + * @param mixed $str */ public function hex($str = null): bool { @@ -186,7 +186,7 @@ public function hex($str = null): bool /** * Integer * - * @param array|bool|float|int|object|string|null $str + * @param mixed $str */ public function integer($str = null): bool { @@ -204,7 +204,7 @@ public function integer($str = null): bool /** * Is a Natural number (0,1,2,3, etc.) * - * @param array|bool|float|int|object|string|null $str + * @param mixed $str */ public function is_natural($str = null): bool { @@ -222,7 +222,7 @@ public function is_natural($str = null): bool /** * Is a Natural number, but not a zero (1,2,3, etc.) * - * @param array|bool|float|int|object|string|null $str + * @param mixed $str */ public function is_natural_no_zero($str = null): bool { @@ -240,7 +240,7 @@ public function is_natural_no_zero($str = null): bool /** * Numeric * - * @param array|bool|float|int|object|string|null $str + * @param mixed $str */ public function numeric($str = null): bool { @@ -258,7 +258,7 @@ public function numeric($str = null): bool /** * Compares value against a regular expression pattern. * - * @param array|bool|float|int|object|string|null $str + * @param mixed $str */ public function regex_match($str, string $pattern): bool { @@ -275,7 +275,7 @@ public function regex_match($str, string $pattern): bool * * @see http://php.net/manual/en/datetimezone.listidentifiers.php * - * @param array|bool|float|int|object|string|null $str + * @param mixed $str */ public function timezone($str = null): bool { @@ -292,7 +292,7 @@ public function timezone($str = null): bool * Tests a string for characters outside of the Base64 alphabet * as defined by RFC 2045 http://www.faqs.org/rfcs/rfc2045 * - * @param array|bool|float|int|object|string|null $str + * @param mixed $str */ public function valid_base64($str = null): bool { @@ -306,7 +306,7 @@ public function valid_base64($str = null): bool /** * Valid JSON * - * @param array|bool|float|int|object|string|null $str + * @param mixed $str */ public function valid_json($str = null): bool { @@ -320,7 +320,7 @@ public function valid_json($str = null): bool /** * Checks for a correctly formatted email address * - * @param array|bool|float|int|object|string|null $str + * @param mixed $str */ public function valid_email($str = null): bool { @@ -337,7 +337,7 @@ public function valid_email($str = null): bool * Example: * valid_emails[one@example.com,two@example.com] * - * @param array|bool|float|int|object|string|null $str + * @param mixed $str */ public function valid_emails($str = null): bool { @@ -351,8 +351,8 @@ public function valid_emails($str = null): bool /** * Validate an IP address (human readable format or binary string - inet_pton) * - * @param array|bool|float|int|object|string|null $ip - * @param string|null $which IP protocol: 'ipv4' or 'ipv6' + * @param mixed $ip + * @param string|null $which IP protocol: 'ipv4' or 'ipv6' */ public function valid_ip($ip = null, ?string $which = null): bool { @@ -369,7 +369,7 @@ public function valid_ip($ip = null, ?string $which = null): bool * Warning: this rule will pass basic strings like * "banana"; use valid_url_strict for a stricter rule. * - * @param array|bool|float|int|object|string|null $str + * @param mixed $str */ public function valid_url($str = null): bool { @@ -383,8 +383,8 @@ public function valid_url($str = null): bool /** * Checks a URL to ensure it's formed correctly. * - * @param array|bool|float|int|object|string|null $str - * @param string|null $validSchemes comma separated list of allowed schemes + * @param mixed $str + * @param string|null $validSchemes comma separated list of allowed schemes */ public function valid_url_strict($str = null, ?string $validSchemes = null): bool { @@ -398,7 +398,7 @@ public function valid_url_strict($str = null, ?string $validSchemes = null): boo /** * Checks for a valid date and matches a given date format * - * @param array|bool|float|int|object|string|null $str + * @param mixed $str */ public function valid_date($str = null, ?string $format = null): bool { diff --git a/system/Validation/StrictRules/Rules.php b/system/Validation/StrictRules/Rules.php index 0a03e6461dba..6d8dce14588b 100644 --- a/system/Validation/StrictRules/Rules.php +++ b/system/Validation/StrictRules/Rules.php @@ -33,8 +33,8 @@ public function __construct() /** * The value does not match another field in $data. * - * @param array|bool|float|int|object|string|null $str - * @param array $data Other field/value pairs + * @param mixed $str + * @param array $data Other field/value pairs */ public function differs( $str, @@ -65,7 +65,7 @@ public function differs( /** * Equals the static value provided. * - * @param array|bool|float|int|object|string|null $str + * @param mixed $str */ public function equals($str, string $val): bool { @@ -76,7 +76,7 @@ public function equals($str, string $val): bool * Returns true if $str is $val characters long. * $val = "5" (one) | "5,8,12" (multiple values) * - * @param array|bool|float|int|object|string|null $str + * @param mixed $str */ public function exact_length($str, string $val): bool { @@ -94,7 +94,7 @@ public function exact_length($str, string $val): bool /** * Greater than * - * @param array|bool|float|int|object|string|null $str expects int|string + * @param mixed $str expects int|string */ public function greater_than($str, string $min): bool { @@ -112,7 +112,7 @@ public function greater_than($str, string $min): bool /** * Equal to or Greater than * - * @param array|bool|float|int|object|string|null $str expects int|string + * @param mixed $str expects int|string */ public function greater_than_equal_to($str, string $min): bool { @@ -137,7 +137,7 @@ public function greater_than_equal_to($str, string $min): bool * is_not_unique[table.field,where_field,where_value] * is_not_unique[menu.id,active,1] * - * @param array|bool|float|int|object|string|null $str + * @param mixed $str */ public function is_not_unique($str, string $field, array $data): bool { @@ -151,7 +151,7 @@ public function is_not_unique($str, string $field, array $data): bool /** * Value should be within an array of values * - * @param array|bool|float|int|object|string|null $value + * @param mixed $value */ public function in_list($value, string $list): bool { @@ -176,7 +176,7 @@ public function in_list($value, string $list): bool * is_unique[table.field,ignore_field,ignore_value] * is_unique[users.email,id,5] * - * @param array|bool|float|int|object|string|null $str + * @param mixed $str */ public function is_unique($str, string $field, array $data): bool { @@ -190,7 +190,7 @@ public function is_unique($str, string $field, array $data): bool /** * Less than * - * @param array|bool|float|int|object|string|null $str expects int|string + * @param mixed $str expects int|string */ public function less_than($str, string $max): bool { @@ -208,7 +208,7 @@ public function less_than($str, string $max): bool /** * Equal to or Less than * - * @param array|bool|float|int|object|string|null $str expects int|string + * @param mixed $str expects int|string */ public function less_than_equal_to($str, string $max): bool { @@ -226,8 +226,8 @@ public function less_than_equal_to($str, string $max): bool /** * Matches the value of another field in $data. * - * @param array|bool|float|int|object|string|null $str - * @param array $data Other field/value pairs + * @param mixed $str + * @param array $data Other field/value pairs */ public function matches( $str, @@ -258,7 +258,7 @@ public function matches( /** * Returns true if $str is $val or fewer characters in length. * - * @param array|bool|float|int|object|string|null $str + * @param mixed $str */ public function max_length($str, string $val): bool { @@ -276,7 +276,7 @@ public function max_length($str, string $val): bool /** * Returns true if $str is at least $val length. * - * @param array|bool|float|int|object|string|null $str + * @param mixed $str */ public function min_length($str, string $val): bool { @@ -294,7 +294,7 @@ public function min_length($str, string $val): bool /** * Does not equal the static value provided. * - * @param array|bool|float|int|object|string|null $str + * @param mixed $str */ public function not_equals($str, string $val): bool { @@ -304,7 +304,7 @@ public function not_equals($str, string $val): bool /** * Value should not be within an array of values. * - * @param array|bool|float|int|object|string|null $value + * @param mixed $value */ public function not_in_list($value, string $list): bool { @@ -324,7 +324,7 @@ public function not_in_list($value, string $list): bool } /** - * @param array|bool|float|int|object|string|null $str + * @param mixed $str */ public function required($str = null): bool { @@ -339,9 +339,9 @@ public function required($str = null): bool * * required_with[password] * - * @param array|bool|float|int|object|string|null $str - * @param string|null $fields List of fields that we should check if present - * @param array $data Complete list of fields from the form + * @param mixed $str + * @param string|null $fields List of fields that we should check if present + * @param array $data Complete list of fields from the form */ public function required_with($str = null, ?string $fields = null, array $data = []): bool { @@ -356,9 +356,9 @@ public function required_with($str = null, ?string $fields = null, array $data = * * required_without[id,email] * - * @param array|bool|float|int|object|string|null $str - * @param string|null $otherFields The param fields of required_without[]. - * @param string|null $field This rule param fields aren't present, this field is required. + * @param mixed $str + * @param string|null $otherFields The param fields of required_without[]. + * @param string|null $field This rule param fields aren't present, this field is required. */ public function required_without( $str = null, @@ -373,10 +373,10 @@ public function required_without( /** * The field exists in $data. * - * @param array|bool|float|int|object|string|null $value The field value. - * @param string|null $param The rule's parameter. - * @param array $data The data to be validated. - * @param string|null $field The field name. + * @param mixed $value The field value. + * @param string|null $param The rule's parameter. + * @param array $data The data to be validated. + * @param string|null $field The field name. */ public function field_exists( $value = null, diff --git a/system/Validation/Validation.php b/system/Validation/Validation.php index 6ca3233f746f..38210fbdb705 100644 --- a/system/Validation/Validation.php +++ b/system/Validation/Validation.php @@ -242,10 +242,10 @@ private static function getRegex(string $field): string * Runs the validation process, returning true or false determining whether * validation was successful or not. * - * @param array|bool|float|int|object|string|null $value The data to validate. - * @param array|string $rules The validation rules. - * @param list $errors The custom error message. - * @param string|null $dbGroup The database group to use. + * @param mixed $value The data to validate. + * @param array|string $rules The validation rules. + * @param list $errors The custom error message. + * @param string|null $dbGroup The database group to use. */ public function check($value, $rules, array $errors = [], $dbGroup = null): bool { @@ -1000,8 +1000,7 @@ protected function splitRules(string $rules): array * Entry point: allocates a single accumulator and delegates to the * recursive collector, so no intermediate arrays are built or unpacked. * - * @param list $segments - * @param array|mixed $current + * @param list $segments * * @return list */ @@ -1019,10 +1018,9 @@ private function walkForAllPossiblePaths(array $segments, mixed $current, string * paths where the key is genuinely absent are recorded - intermediate * missing segments are silently skipped so `*` never appears in a result. * - * @param list $segments - * @param int<0, max> $segmentCount - * @param array|mixed $current - * @param list $result + * @param list $segments + * @param int<0, max> $segmentCount + * @param list $result */ private function collectMissingPaths( array $segments, diff --git a/system/Validation/ValidationInterface.php b/system/Validation/ValidationInterface.php index 997bfb2bc0a4..836516d561b8 100644 --- a/system/Validation/ValidationInterface.php +++ b/system/Validation/ValidationInterface.php @@ -35,10 +35,10 @@ public function run(?array $data = null, ?string $group = null, $dbGroup = null) * Check; runs the validation process, returning true or false * determining whether or not validation was successful. * - * @param array|bool|float|int|object|string|null $value Value to validate. - * @param array|string $rules - * @param list $errors - * @param string|null $dbGroup The database group to use. + * @param mixed $value Value to validate. + * @param array|string $rules + * @param list $errors + * @param string|null $dbGroup The database group to use. * * @return bool True if valid, else false. */ diff --git a/system/View/Parser.php b/system/View/Parser.php index 9a2d7d9d83b1..5de115f8236b 100644 --- a/system/View/Parser.php +++ b/system/View/Parser.php @@ -748,7 +748,7 @@ public function removePlugin(string $alias) * Converts an object to an array, respecting any * toArray() methods on an object. * - * @param array|bool|float|int|object|string|null $value + * @param mixed $value * * @return array|bool|float|int|string|null */ diff --git a/system/util_bootstrap.php b/system/util_bootstrap.php index 5423954deb59..135208a78ea9 100644 --- a/system/util_bootstrap.php +++ b/system/util_bootstrap.php @@ -23,14 +23,13 @@ * DEFINE ENVIRONMENT * --------------------------------------------------------------- * - * As this bootstrap file is primarily used by internal scripts - * across the framework and other CodeIgniter projects, we need - * to make sure it recognizes that we're in development. + * The environment defaults to development. A caller may set + * $_SERVER['CI_ENVIRONMENT'] before including this file to override it. */ -$_SERVER['CI_ENVIRONMENT'] = 'development'; -define('ENVIRONMENT', 'development'); -defined('CI_DEBUG') || define('CI_DEBUG', true); +$_SERVER['CI_ENVIRONMENT'] ??= 'development'; +defined('ENVIRONMENT') || define('ENVIRONMENT', $_SERVER['CI_ENVIRONMENT']); +defined('CI_DEBUG') || define('CI_DEBUG', true); /* * --------------------------------------------------------------- diff --git a/tests/_support/API/ChildTransformer.php b/tests/_support/API/ChildTransformer.php new file mode 100644 index 000000000000..a226dad97608 --- /dev/null +++ b/tests/_support/API/ChildTransformer.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\API; + +use CodeIgniter\API\BaseTransformer; + +/** + * Nested transformer used to verify that the root request's scope + * (fields/includes) does not leak into related resources. + */ +class ChildTransformer extends BaseTransformer +{ + public function toArray(mixed $resource): array + { + return [ + 'child_id' => $resource['id'] ?? null, + 'status' => 'transformed', + ]; + } +} diff --git a/tests/_support/API/ParentTransformer.php b/tests/_support/API/ParentTransformer.php new file mode 100644 index 000000000000..803b4dbb353f --- /dev/null +++ b/tests/_support/API/ParentTransformer.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\API; + +use CodeIgniter\API\BaseTransformer; + +/** + * Root transformer used to verify that nested transformers do not inherit + * the root request's `fields`/`include` query state. + */ +class ParentTransformer extends BaseTransformer +{ + public function toArray(mixed $resource): array + { + return [ + 'parent_id' => $resource['id'] ?? null, + ]; + } + + /** + * Includes a single related child resource. + * + * @return array + */ + protected function includeChildren(): array + { + return (new ChildTransformer())->transform(['id' => 99]); + } + + /** + * Includes a collection of related child resources. + * + * @return list> + */ + protected function includeChildrenCollection(): array + { + return (new ChildTransformer())->transformMany([ + ['id' => 77], + ['id' => 88], + ]); + } + + /** + * Includes a single child while explicitly forwarding the request, opting + * the child into the request-derived scope even though it is nested. + * + * @return array + */ + protected function includeExplicitChild(): array + { + $childRequest = clone request(); + $childRequest->setGlobal('get', ['fields' => 'child_id']); + + return (new ChildTransformer($childRequest))->transform(['id' => 99]); + } +} diff --git a/tests/system/API/TransformerTest.php b/tests/system/API/TransformerTest.php index 8c6f50857d23..11f37f0fd859 100644 --- a/tests/system/API/TransformerTest.php +++ b/tests/system/API/TransformerTest.php @@ -22,6 +22,8 @@ use Config\Services; use PHPUnit\Framework\Attributes\Group; use stdClass; +use Tests\Support\API\ChildTransformer; +use Tests\Support\API\ParentTransformer; /** * @internal @@ -33,12 +35,14 @@ protected function setUp(): void { parent::setUp(); + Services::resetSingle('request'); Services::superglobals()->setGetArray([]); } protected function tearDown(): void { Services::superglobals()->setGetArray([]); + Services::resetSingle('request'); parent::tearDown(); } @@ -641,4 +645,160 @@ protected function includePosts(): array $this->assertArrayHasKey('posts', $result); $this->assertSame([['id' => 1, 'title' => 'Post 1']], $result['posts']); } + + public function testNestedTransformerDoesNotInheritIncludeState(): void + { + // The child transformer has no includeChildren() method. If the root + // request's `include=children` leaked into it, transforming the child + // would raise an ApiException for the missing include method. + $request = $this->createMockRequest('include=children'); + Services::injectMock('request', $request); + + $transformer = new ParentTransformer($request); + + $result = $transformer->transform(['id' => 1]); + + $this->assertSame([ + 'parent_id' => 1, + 'children' => ['child_id' => 99, 'status' => 'transformed'], + ], $result); + } + + public function testNestedTransformerDoesNotInheritFieldFilter(): void + { + // `fields=parent_id` applies to the root only. If it leaked into the + // child, array_intersect_key would strip every child field, leaving []. + $request = $this->createMockRequest('include=children&fields=parent_id'); + Services::injectMock('request', $request); + + $transformer = new ParentTransformer($request); + + $result = $transformer->transform(['id' => 1]); + + $this->assertSame([ + 'parent_id' => 1, + 'children' => ['child_id' => 99, 'status' => 'transformed'], + ], $result); + } + + public function testNestedCollectionTransformerDoesNotInheritState(): void + { + $request = $this->createMockRequest('include=childrenCollection&fields=parent_id'); + Services::injectMock('request', $request); + + $transformer = new ParentTransformer($request); + + $result = $transformer->transform(['id' => 1]); + + $this->assertSame([ + 'parent_id' => 1, + 'childrenCollection' => [ + ['child_id' => 77, 'status' => 'transformed'], + ['child_id' => 88, 'status' => 'transformed'], + ], + ], $result); + } + + public function testBareNestedInstantiationDoesNotInheritState(): void + { + // Reproduces the exact leak vector: a child created with a bare + // `new ChildTransformer()` (no request passed) inside an include + // method must not pick up the root request's scope from request(). + $request = $this->createMockRequest('include=children&fields=parent_id'); + Services::injectMock('request', $request); + + $root = new ParentTransformer(); + + $result = $root->transform(['id' => 5]); + + $this->assertSame([ + 'parent_id' => 5, + 'children' => ['child_id' => 99, 'status' => 'transformed'], + ], $result); + } + + public function testNestedTransformerHonorsExplicitRequest(): void + { + // A child created with an explicitly passed request must honor that + // request's scope even while nested - the isolation only suppresses + // the implicit global fallback, not deliberate developer intent. + $request = $this->createMockRequest('include=explicitChild&fields=child_id,parent_id'); + Services::injectMock('request', $request); + + $transformer = new ParentTransformer($request); + + $result = $transformer->transform(['id' => 1]); + + $this->assertSame([ + 'parent_id' => 1, + 'explicitChild' => ['child_id' => 99], + ], $result); + } + + public function testRootScopeStillAppliesAfterNesting(): void + { + // Sanity check that the root transformer keeps applying its own scope + // while nested children are isolated. + $request = $this->createMockRequest('include=children&fields=parent_id'); + Services::injectMock('request', $request); + + $transformer = new ParentTransformer($request); + + $result = $transformer->transform(['id' => 1, 'secret' => 'hidden']); + + // Root keeps only parent_id (plus the include key), the child is intact. + $this->assertArrayHasKey('parent_id', $result); + $this->assertArrayNotHasKey('secret', $result); + $this->assertSame(['child_id' => 99, 'status' => 'transformed'], $result['children']); + } + + public function testDepthIsRestoredAfterIncludeThrows(): void + { + $request = $this->createMockRequest('include=nonexistent'); + Services::injectMock('request', $request); + + $throwingRoot = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + }; + + try { + $throwingRoot->transform(['id' => 1, 'name' => 'Test']); + $this->fail('Expected ApiException was not thrown.'); + } catch (ApiException) { + // expected + } + + // The nesting depth must be balanced after the exception, so a fresh + // root transformer still applies the request scope (depth back to 0). + $fieldsRequest = $this->createMockRequest('fields=id'); + Services::injectMock('request', $fieldsRequest); + + $nextRoot = new class ($fieldsRequest) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + }; + + $result = $nextRoot->transform(['id' => 1, 'name' => 'Test']); + + $this->assertSame(['id' => 1], $result); + } + + public function testBareNestedTransformerStillUsedByChildTransformerDirectly(): void + { + // When ChildTransformer is itself the root (no parent), it must apply + // request scope as usual - the isolation only affects nesting. + $request = $this->createMockRequest('fields=child_id'); + Services::injectMock('request', $request); + + $transformer = new ChildTransformer($request); + + $result = $transformer->transform(['id' => 42]); + + $this->assertSame(['child_id' => 42], $result); + } } diff --git a/tests/system/Commands/Generators/ModelGeneratorTest.php b/tests/system/Commands/Generators/ModelGeneratorTest.php index 520bbbdc7a3b..8dab8b007f19 100644 --- a/tests/system/Commands/Generators/ModelGeneratorTest.php +++ b/tests/system/Commands/Generators/ModelGeneratorTest.php @@ -134,6 +134,33 @@ public function testGenerateModelWithOptionSuffix(): void rmdir(dirname($entity)); } + public function testGenerateModelWithSubNamespaceAndReturnEntity(): void + { + command('make:model admin/class --return entity'); + + $model = APPPATH . 'Models/Admin/Class.php'; + $entity = APPPATH . 'Entities/Admin/Class.php'; + + $this->assertFileExists($model); + $this->assertFileExists($entity); + + if (is_file($model)) { + unlink($model); + } + $modelDir = dirname($model); + if (is_dir($modelDir)) { + rmdir($modelDir); + } + + if (is_file($entity)) { + unlink($entity); + } + $entityDir = dirname($entity); + if (is_dir($entityDir)) { + rmdir($entityDir); + } + } + /** * @see https://github.com/codeigniter4/CodeIgniter4/issues/5050 */ diff --git a/tests/system/Commands/Translation/LocalizationFinderTest.php b/tests/system/Commands/Translation/LocalizationFinderTest.php index 3baa82290f1f..bfbd00d8bf83 100644 --- a/tests/system/Commands/Translation/LocalizationFinderTest.php +++ b/tests/system/Commands/Translation/LocalizationFinderTest.php @@ -30,6 +30,7 @@ final class LocalizationFinderTest extends CIUnitTestCase private static string $locale; private static string $languageTestPath; private string $originalLocale; + private ?string $tempSourceDir = null; /** * @var list @@ -57,6 +58,7 @@ protected function tearDown(): void parent::tearDown(); $this->clearGeneratedFiles(); + $this->clearSourceFixture(); Locale::setDefault($this->originalLocale); config(App::class)->supportedLocales = $this->originalSupportedLocales; } @@ -131,6 +133,39 @@ public function testShowBadTranslation(): void $this->assertStringContainsString($this->getActualTableWithBadKeys(), $this->getStreamFilterBuffer()); } + public function testShowNewSkipsKeysAlreadyTranslatedByFramework(): void + { + $this->makeLocaleDirectory(); + $this->makeSourceFixture( + 'ResolvedKeys', + "getStreamFilterBuffer(); + $this->assertStringNotContainsString('Errors.pageNotFound', $buffer); + $this->assertStringContainsString('Errors.thisKeyIsBrandNew', $buffer); + } + + public function testWriteSkipsKeysAlreadyTranslatedByFramework(): void + { + $this->makeLocaleDirectory(); + $this->makeSourceFixture( + 'ResolvedKeys', + "assertFileExists($generatedFile); + + $generatedKeys = require $generatedFile; + $this->assertArrayHasKey('thisKeyIsBrandNew', $generatedKeys); + $this->assertArrayNotHasKey('pageNotFound', $generatedKeys); + } + private function getActualTranslationOneKeys(): array { return [ @@ -261,6 +296,32 @@ private function makeLocaleDirectory(): void @mkdir(self::$languageTestPath . self::$locale, 0777, true); } + private function makeSourceFixture(string $dirName, string $contents): void + { + $this->tempSourceDir = SUPPORTPATH . 'Services' . DIRECTORY_SEPARATOR . $dirName; + @mkdir($this->tempSourceDir, 0777, true); + file_put_contents($this->tempSourceDir . DIRECTORY_SEPARATOR . 'Source.php', $contents); + } + + private function clearSourceFixture(): void + { + if ($this->tempSourceDir === null) { + return; + } + + $sourceFile = $this->tempSourceDir . DIRECTORY_SEPARATOR . 'Source.php'; + + if (is_file($sourceFile)) { + unlink($sourceFile); + } + + if (is_dir($this->tempSourceDir)) { + rmdir($this->tempSourceDir); + } + + $this->tempSourceDir = null; + } + private function clearGeneratedFiles(): void { if (is_file(self::$languageTestPath . self::$locale . '/TranslationOne.php')) { @@ -275,6 +336,10 @@ private function clearGeneratedFiles(): void unlink(self::$languageTestPath . self::$locale . '/Translation-Four.php'); } + if (is_file(self::$languageTestPath . self::$locale . '/Errors.php')) { + unlink(self::$languageTestPath . self::$locale . '/Errors.php'); + } + if (is_dir(self::$languageTestPath . '/test_locale_incorrect')) { rmdir(self::$languageTestPath . '/test_locale_incorrect'); } diff --git a/tests/system/CommonFunctionsSendTest.php b/tests/system/CommonFunctionsSendTest.php index e1f3f9dff961..1cf2b7917459 100644 --- a/tests/system/CommonFunctionsSendTest.php +++ b/tests/system/CommonFunctionsSendTest.php @@ -30,7 +30,8 @@ protected function setUp(): void { parent::setUp(); - unset($_ENV['foo'], $_SERVER['foo']); + unset($_ENV['foo']); + service('superglobals')->unsetServer('foo'); } /** @@ -57,9 +58,7 @@ public function testRedirectResponseCookiesSent(): void // send it ob_start(); $response->send(); - if (ob_get_level() > 0) { - ob_end_clean(); - } + ob_end_clean(); // and what actually got sent? $this->assertHeaderEmitted('Set-Cookie: foo=onething;'); diff --git a/tests/system/CommonFunctionsTest.php b/tests/system/CommonFunctionsTest.php index 7f79b07381fd..83750c220bc4 100644 --- a/tests/system/CommonFunctionsTest.php +++ b/tests/system/CommonFunctionsTest.php @@ -42,6 +42,7 @@ use Config\Services; use Config\Session as SessionConfig; use Exception; +use InvalidArgumentException; use Kint; use PHPUnit\Framework\Attributes\BackupGlobals; use PHPUnit\Framework\Attributes\DataProvider; @@ -64,7 +65,8 @@ final class CommonFunctionsTest extends CIUnitTestCase protected function setUp(): void { - unset($_ENV['foo'], $_SERVER['foo']); + unset($_ENV['foo']); + service('superglobals')->unsetServer('foo'); $this->resetServices(); parent::setUp(); @@ -131,6 +133,42 @@ public function testEnvBooleans(): void $this->assertNull(env('p4')); } + #[DataProvider('provideEnvReturnsCorrectTypesWithoutTypeError')] + public function testEnvReturnsCorrectTypesWithoutTypeError(string $source, mixed $value): void + { + $key = 'ci_test_var'; + + if ($source === 'SERVER' || $source === 'BOTH') { + service('superglobals')->setServer($key, $value); + } + + if ($source === 'ENV' || $source === 'BOTH') { + $_ENV[$key] = $value; + } + + $this->assertSame($value, env($key)); + } + + /** + * @return iterable + */ + public static function provideEnvReturnsCorrectTypesWithoutTypeError(): iterable + { + yield 'integer from SERVER' => ['SERVER', 2]; + + yield 'array from SERVER' => ['SERVER', ['spark', 'migrate']]; + + yield 'int 1 is not true' => ['SERVER', 1]; + + yield 'int 0 is not false' => ['SERVER', 0]; + + yield 'float from SERVER' => ['SERVER', 3.14]; + + yield 'integer from ENV' => ['ENV', 42]; + + yield 'CLI simulation BOTH' => ['BOTH', 3]; + } + private function createRouteCollection(): RouteCollection { return new RouteCollection(Services::locator(), new Modules(), new Routing()); @@ -276,6 +314,22 @@ public function testEscapeRecursiveArrayRaw(): void $this->assertSame($data, esc($data, 'raw')); } + public function testEscapeArrayPropagatesEncoding(): void + { + $this->expectException(InvalidArgumentException::class); + // If encoding is not propagated, it would not instantiate the Escaper with the invalid encoding and wouldn't throw. + esc(['test'], 'html', 'invalid-encoding'); + } + + public function testEscapeWithChangingArrayEncoding(): void + { + $data = [hex2bin('E9')]; + + $this->assertSame(['é'], esc($data, 'attr', 'iso-8859-1')); + $this->assertSame(['й'], esc($data, 'attr', 'windows-1251')); + $this->assertSame(['é'], esc($data, 'attr', 'iso-8859-1')); + } + #[PreserveGlobalState(false)] #[RunInSeparateProcess] #[WithoutErrorHandler] diff --git a/tests/system/CommonSingleServiceTest.php b/tests/system/CommonSingleServiceTest.php index e423d1feaee0..829f0f6e2d96 100644 --- a/tests/system/CommonSingleServiceTest.php +++ b/tests/system/CommonSingleServiceTest.php @@ -34,8 +34,8 @@ public function testSingleServiceWithNoParamsSupplied(string $service): void { Services::injectMock('security', new MockSecurity(new SecurityConfig())); - $service1 = single_service($service); // @phpstan-ignore codeigniter.unknownServiceMethod - $service2 = single_service($service); // @phpstan-ignore codeigniter.unknownServiceMethod + $service1 = single_service($service); + $service2 = single_service($service); $this->assertNotNull($service1); @@ -62,8 +62,8 @@ public function testSingleServiceWithAtLeastOneParamSupplied(string $service): v $params[] = $method->getNumberOfParameters() === 1 ? true : $method->getParameters()[0]->getDefaultValue(); - $service1 = single_service($service, ...$params); // @phpstan-ignore codeigniter.unknownServiceMethod - $service2 = single_service($service, ...$params); // @phpstan-ignore codeigniter.unknownServiceMethod + $service1 = single_service($service, ...$params); + $service2 = single_service($service, ...$params); $this->assertNotNull($service1); diff --git a/tests/system/Config/BaseConfigTest.php b/tests/system/Config/BaseConfigTest.php index 1d34ce30535a..24d259feef26 100644 --- a/tests/system/Config/BaseConfigTest.php +++ b/tests/system/Config/BaseConfigTest.php @@ -125,10 +125,7 @@ public function testUseDefaultValueTypeStringValue(): void #[RunInSeparateProcess] public function testServerValues(): void { - $_SERVER = [ - 'simpleconfig.shortie' => 123, - 'SimpleConfig.longie' => 456, - ]; + service('superglobals')->setServerArray(['simpleconfig.shortie' => 123, 'SimpleConfig.longie' => 456]); $dotenv = new DotEnv($this->fixturesFolder, '.env'); $dotenv->load(); $config = new SimpleConfig(); diff --git a/tests/system/Config/DotEnvTest.php b/tests/system/Config/DotEnvTest.php index 505f4bedcbcb..c789169b1b87 100644 --- a/tests/system/Config/DotEnvTest.php +++ b/tests/system/Config/DotEnvTest.php @@ -93,7 +93,8 @@ public static function provideLoadsVars(): iterable public function testLoadsHex2Bin(): void { putenv('encryption.key'); - unset($_ENV['encryption.key'], $_SERVER['encryption.key']); // @phpstan-ignore codeigniter.superglobalAccess + unset($_ENV['encryption.key']); + service('superglobals')->unsetServer('encryption.key'); $dotenv = new DotEnv($this->fixturesFolder, 'encryption.env'); $dotenv->load(); @@ -108,7 +109,8 @@ public function testLoadsHex2Bin(): void public function testLoadsBase64(): void { putenv('encryption.key'); - unset($_ENV['encryption.key'], $_SERVER['encryption.key']); // @phpstan-ignore codeigniter.superglobalAccess + unset($_ENV['encryption.key']); + service('superglobals')->unsetServer('encryption.key'); $dotenv = new DotEnv($this->fixturesFolder, 'base64encryption.env'); $dotenv->load(); @@ -167,10 +169,10 @@ public function testLoadsServerGlobals(): void $dotenv = new DotEnv($this->fixturesFolder, '.env'); $dotenv->load(); - $this->assertSame('bar', $_SERVER['FOO']); - $this->assertSame('baz', $_SERVER['BAR']); - $this->assertSame('with spaces', $_SERVER['SPACED']); - $this->assertSame('', $_SERVER['NULL']); + $this->assertSame('bar', $_SERVER['FOO']); // @phpstan-ignore codeigniter.superglobalsOffsetAccess (checks the live superglobal, not the snapshot service) + $this->assertSame('baz', $_SERVER['BAR']); // @phpstan-ignore codeigniter.superglobalsOffsetAccess (checks the live superglobal, not the snapshot service) + $this->assertSame('with spaces', $_SERVER['SPACED']); // @phpstan-ignore codeigniter.superglobalsOffsetAccess (checks the live superglobal, not the snapshot service) + $this->assertSame('', $_SERVER['NULL']); // @phpstan-ignore codeigniter.superglobalsOffsetAccess (checks the live superglobal, not the snapshot service) } public function testNamespacedVariables(): void @@ -178,7 +180,7 @@ public function testNamespacedVariables(): void $dotenv = new DotEnv($this->fixturesFolder, '.env'); $dotenv->load(); - $this->assertSame('complex', $_SERVER['SimpleConfig_simple_name']); + $this->assertSame('complex', $_SERVER['SimpleConfig_simple_name']); // @phpstan-ignore codeigniter.superglobalsOffsetAccess (checks the live superglobal, not the snapshot service) } public function testLoadsGetServerVar(): void diff --git a/tests/system/Config/ServicesTest.php b/tests/system/Config/ServicesTest.php index 7eef9818a727..86228dfadd3f 100644 --- a/tests/system/Config/ServicesTest.php +++ b/tests/system/Config/ServicesTest.php @@ -78,7 +78,7 @@ protected function setUp(): void protected function tearDown(): void { - $_SERVER = $this->original; + service('superglobals')->setServerArray($this->original); $this->resetServices(); } diff --git a/tests/system/ControllerTest.php b/tests/system/ControllerTest.php index 113b02c14deb..2ca26f9aa15c 100644 --- a/tests/system/ControllerTest.php +++ b/tests/system/ControllerTest.php @@ -74,7 +74,7 @@ public function testConstructor(): void public function testConstructorHTTPS(): void { $original = $_SERVER; - $_SERVER = ['HTTPS' => 'on']; + service('superglobals')->setServerArray(['HTTPS' => 'on']); // make sure we can instantiate one try { @@ -86,7 +86,7 @@ public function testConstructorHTTPS(): void } $this->assertInstanceOf(Controller::class, $this->controller); - $_SERVER = $original; // restore so code coverage doesn't break + service('superglobals')->setServerArray($original); // restore so code coverage doesn't break } public function testCachePageSetsTtl(): void diff --git a/tests/system/Database/BaseResultTest.php b/tests/system/Database/BaseResultTest.php new file mode 100644 index 000000000000..901faee6a565 --- /dev/null +++ b/tests/system/Database/BaseResultTest.php @@ -0,0 +1,332 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database; + +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\Group; +use stdClass; + +/** + * @internal + */ +#[Group('Others')] +final class BaseResultTest extends CIUnitTestCase +{ + /** + * Create a minimal concrete implementation of BaseResult for testing. + * + * @param list> $resultArray Result set as arrays. + * @param list $resultObject Result set as objects. + * + * @return BaseResult + */ + private function createResultDouble(array $resultArray, array $resultObject): BaseResult + { + return new + /** + * @extends BaseResult + */ + class ($resultArray, $resultObject) extends BaseResult { + /** + * @param list> $resultArray Result set as arrays. + * @param list $resultObject Result set as objects. + */ + public function __construct(array $resultArray, array $resultObject) + { + $this->resultArray = $resultArray; + $this->resultObject = $resultObject; + $this->currentRow = 0; + + $connId = null; + $resultId = null; + parent::__construct($connId, $resultId); + } + + public function getFieldCount(): int + { + return 0; + } + + /** + * @return list + */ + public function getFieldNames(): array + { + return []; + } + + /** + * @return list + */ + public function getFieldData(): array + { + return []; + } + + public function freeResult(): void + { + } + + public function dataSeek(int $n = 0): bool + { + return true; + } + + /** + * @return false|list>|null + */ + protected function fetchAssoc(): array|bool|null + { + return false; + } + + protected function fetchObject(string $className = stdClass::class) + { + return false; + } + }; + } + + // -------------------------------------------------------------------- + // getRowArray() + // -------------------------------------------------------------------- + + public function testGetRowArrayReturnsRow(): void + { + $result = $this->createResultDouble( + [ + ['id' => 1, 'name' => 'John'], + ['id' => 2, 'name' => 'Jane'], + ], + [], + ); + + $this->assertSame(['id' => 1, 'name' => 'John'], $result->getRowArray(0)); + $this->assertSame(['id' => 2, 'name' => 'Jane'], $result->getRowArray(1)); + } + + public function testGetRowArrayReturnsNullForEmptyResult(): void + { + $result = $this->createResultDouble([], []); + + $this->assertNull($result->getRowArray(0)); + } + + public function testGetRowArrayReturnsFirstRowByDefault(): void + { + $result = $this->createResultDouble( + [ + ['id' => 1, 'name' => 'John'], + ['id' => 2, 'name' => 'Jane'], + ], + [], + ); + + $this->assertSame(['id' => 1, 'name' => 'John'], $result->getRowArray()); + } + + // -------------------------------------------------------------------- + // getRowObject() + // -------------------------------------------------------------------- + + public function testGetRowObjectReturnsObject(): void + { + $row1 = new stdClass(); + $row1->id = 1; + $row1->name = 'John'; + $row2 = new stdClass(); + $row2->id = 2; + $row2->name = 'Jane'; + + $result = $this->createResultDouble([], [$row1, $row2]); + + $this->assertSame($row1, $result->getRowObject(0)); + $this->assertSame($row2, $result->getRowObject(1)); + } + + public function testGetRowObjectReturnsNullForEmptyResult(): void + { + $result = $this->createResultDouble([], []); + + $this->assertNull($result->getRowObject(0)); + } + + public function testGetRowObjectReturnsFirstRowByDefault(): void + { + $row1 = new stdClass(); + $row1->id = 1; + $row1->name = 'John'; + + $result = $this->createResultDouble([], [$row1]); + + $this->assertSame($row1, $result->getRowObject()); + } + + public function testGetRowObjectAndGetRowArrayShareCurrentRow(): void + { + $row1 = new stdClass(); + $row1->id = 1; + $row1->name = 'John'; + $row2 = new stdClass(); + $row2->id = 2; + $row2->name = 'Jane'; + + $result = $this->createResultDouble( + [ + ['id' => 1, 'name' => 'John'], + ['id' => 2, 'name' => 'Jane'], + ], + [$row1, $row2], + ); + + // getRowObject(1) should advance currentRow to 1 (same as getRowArray would) + $result->getRowObject(1); + $this->assertSame(['id' => 2, 'name' => 'Jane'], $result->getRowArray(1)); + } + + public function testGetRowObjectUsesCurrentRowLikeGetRowArray(): void + { + $row1 = new stdClass(); + $row1->id = 1; + $row1->name = 'John'; + $row2 = new stdClass(); + $row2->id = 2; + $row2->name = 'Jane'; + + $result = $this->createResultDouble( + [ + ['id' => 1, 'name' => 'John'], + ['id' => 2, 'name' => 'Jane'], + ], + [$row1, $row2], + ); + + // Both methods should advance currentRow consistently + $result->getRowObject(1); + $result->getRowArray(); + $this->assertSame($row1, $result->getRowObject()); + } + + // -------------------------------------------------------------------- + // getRow() — convenience wrapper + // -------------------------------------------------------------------- + + public function testGetRowWithInvalidIndexReturnsFirstRow(): void + { + $result = $this->createResultDouble( + [['id' => 1, 'name' => 'John']], + [], + ); + + $this->assertSame(['id' => 1, 'name' => 'John'], $result->getRow(999, 'array')); + } + + public function testGetRowObjectWithInvalidIndexReturnsFirstRow(): void + { + $row1 = new stdClass(); + $row1->id = 1; + $row1->name = 'John'; + + $result = $this->createResultDouble([], [$row1]); + + $this->assertSame($row1, $result->getRow(999, 'object')); + } + + public function testGetRowNullForColumnNameNotFound(): void + { + $result = $this->createResultDouble( + [['id' => 1, 'name' => 'John']], + [], + ); + + $this->assertNull($result->getRow('nonexistent', 'array')); + } + + // -------------------------------------------------------------------- + // Custom Result Object + // -------------------------------------------------------------------- + + public function testGetCustomRowObjectWithInvalidIndexReturnsFirstRow(): void + { + $row = new stdClass(); + $row->id = 1; + $row->name = 'John'; + + $result = $this->createResultDouble([], []); + $result->customResultObject[stdClass::class] = [$row]; + + $this->assertSame($row, $result->getCustomRowObject(999, stdClass::class)); + } + + // -------------------------------------------------------------------- + // Fallback Tests (Null return on invalid currentRow) + // -------------------------------------------------------------------- + + public function testGetRowArrayReturnsNullWhenCurrentRowIsInvalid(): void + { + $result = $this->createResultDouble( + [['id' => 1, 'name' => 'John']], + [], + ); + + $result->currentRow = 999; + + $this->assertNull($result->getRowArray(999)); + } + + public function testGetRowObjectReturnsNullWhenCurrentRowIsInvalid(): void + { + $row1 = new stdClass(); + $row1->id = 1; + $row1->name = 'John'; + + $result = $this->createResultDouble( + [], + [$row1], + ); + + $result->currentRow = 999; + + $this->assertNull($result->getRowObject(999)); + } + + public function testGetCustomRowObjectReturnsNullWhenCurrentRowIsInvalid(): void + { + $row1 = new stdClass(); + $row1->id = 1; + $row1->name = 'John'; + + $result = $this->createResultDouble([], []); + $result->customResultObject[stdClass::class] = [$row1]; + + $result->currentRow = 999; + + $this->assertNotInstanceOf(stdClass::class, $result->getCustomRowObject(999, stdClass::class)); + } + + public function testGetPreviousRowReturnsNullWhenCurrentRowIsInvalid(): void + { + $result = $this->createResultDouble( + [ + ['id' => 1], + ['id' => 2], + ], + [], + ); + + $result->currentRow = -1; + + $this->assertNull($result->getPreviousRow('array')); + } +} diff --git a/tests/system/Database/Builder/DeleteTest.php b/tests/system/Database/Builder/DeleteTest.php index 53b66eed805b..200eb9ca5b4a 100644 --- a/tests/system/Database/Builder/DeleteTest.php +++ b/tests/system/Database/Builder/DeleteTest.php @@ -13,8 +13,10 @@ namespace CodeIgniter\Database\Builder; +use CodeIgniter\Database\BaseBuilder; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockConnection; +use CodeIgniter\Test\Mock\MockQuery; use PHPUnit\Framework\Attributes\Group; /** @@ -90,4 +92,81 @@ public function testGetCompiledDeleteWithLimit(): void EOL; $this->assertSame($expectedSQL, $sql); } + + public function testDeleteBatchEscapesWhereBinds(): void + { + $db = new MockConnection([]); + $builder = new BaseBuilder('jobs', $db); + + $data = [ + ['id' => 1, 'name' => 'Derek J'], + ['id' => 2, 'name' => 'Ahmadinejad'], + ]; + + // A user-controlled value that would break out of the query if it + // were substituted into the SQL unescaped. + $malicious = "anything' OR '1'='1"; + + $db->shouldReturn('execute', new class () {}); + $builder->setData($data, null, 'data') + ->onConstraint(['id' => 'id']) + ->where('jobs.name', $malicious) + ->deleteBatch(); + + $query = $db->getLastQuery(); + $this->assertInstanceOf(MockQuery::class, $query); + + // The bound value must be escaped and quoted as a single string literal: + // it is wrapped in single quotes and the inner quotes are doubled, so it + // cannot break out of the literal and inject SQL. + $this->assertStringContainsString("'anything'' OR ''1''", $query->getQuery()); + $this->assertStringNotContainsString("= anything' OR '1'='1", $query->getQuery()); + } + + public function testDeleteBatchEscapesMultipleWhereBinds(): 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', "anything' OR '1'='1") + ->where('jobs.id', 1) + ->deleteBatch(); + + $query = $db->getLastQuery(); + $this->assertInstanceOf(MockQuery::class, $query); + + $this->assertStringContainsString('"jobs"."name" = \'anything\'\' OR \'\'1\'\' = \'\'1\'', $query->getQuery()); + $this->assertStringContainsString('"jobs"."id" = 1', $query->getQuery()); + } + + public function testDeleteBatchEscapesWhereInBinds(): 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']) + ->whereIn('jobs.name', ["anything' OR '1'='1", 'Ahmadinejad']) + ->deleteBatch(); + + $query = $db->getLastQuery(); + $this->assertInstanceOf(MockQuery::class, $query); + + $this->assertStringContainsString("IN ('anything'' OR ''1''=''1','Ahmadinejad')", $query->getQuery()); + $this->assertStringNotContainsString("IN anything' OR '1'='1", $query->getQuery()); + } } diff --git a/tests/system/Database/Builder/LikeTest.php b/tests/system/Database/Builder/LikeTest.php index d2a534b5bf27..f9d7cd91f65e 100644 --- a/tests/system/Database/Builder/LikeTest.php +++ b/tests/system/Database/Builder/LikeTest.php @@ -17,6 +17,7 @@ use CodeIgniter\Database\RawSql; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockConnection; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; /** @@ -231,4 +232,114 @@ public function testDBPrefixAndCoulmnWithTablename(): void $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); $this->assertSame($expectedBinds, $builder->getBinds()); } + + public function testLikeMultipleFields(): void + { + $builder = new BaseBuilder('job', $this->db); + + $builder->like([ + 'name' => 'veloper', + 'title' => 'dev', + ]); + + $expectedSQL = "SELECT * FROM \"job\" WHERE \"name\" LIKE '%veloper%' ESCAPE '!' AND \"title\" LIKE '%dev%' ESCAPE '!'"; + $expectedBinds = [ + 'name' => [ + '%veloper%', + true, + ], + 'title' => [ + '%dev%', + true, + ], + ]; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + + public function testLikeMultipleCallsWithRawSqlAndString(): void + { + $builder = new BaseBuilder('users', $this->db); + + $sql = "concat(users.name, ' ', users.surname)"; + $rawSql = new RawSql($sql); + + $builder->like($rawSql, 'value')->like('name', 'veloper'); + + $expectedSQL = "SELECT * FROM \"users\" WHERE {$sql} LIKE '%value%' ESCAPE '!' AND \"name\" LIKE '%veloper%' ESCAPE '!'"; + $expectedBinds = [ + $rawSql->getBindingKey() => [ + '%value%', + true, + ], + 'name' => [ + '%veloper%', + true, + ], + ]; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + + public function testOrLikeMultipleFields(): void + { + $builder = new BaseBuilder('job', $this->db); + + $builder->orLike([ + 'name' => 'veloper', + 'title' => 'dev', + ]); + + $expectedSQL = "SELECT * FROM \"job\" WHERE \"name\" LIKE '%veloper%' ESCAPE '!' OR \"title\" LIKE '%dev%' ESCAPE '!'"; + $expectedBinds = [ + 'name' => [ + '%veloper%', + true, + ], + 'title' => [ + '%dev%', + true, + ], + ]; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + + #[DataProvider('provideLikeMethodsWithEmptyArray')] + public function testLikeMethodsWithEmptyArray(string $method): void + { + $builder = new BaseBuilder('job', $this->db); + + $builder->groupStart() + ->{$method}([]) + ->where('id', 1) + ->groupEnd(); + + $expectedSQL = 'SELECT * FROM "job" WHERE ( "id" = 1 )'; + $expectedBinds = [ + 'id' => [ + 1, + true, + ], + ]; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + + /** + * @return array + */ + public static function provideLikeMethodsWithEmptyArray(): iterable + { + return [ + 'like' => ['like'], + 'orLike' => ['orLike'], + 'notLike' => ['notLike'], + 'orNotLike' => ['orNotLike'], + ]; + } } diff --git a/tests/system/Database/Builder/UpdateTest.php b/tests/system/Database/Builder/UpdateTest.php index eb1cfc7d9c92..5bb93ad2a598 100644 --- a/tests/system/Database/Builder/UpdateTest.php +++ b/tests/system/Database/Builder/UpdateTest.php @@ -299,6 +299,24 @@ public function testUpdateBatchThrowsExceptionWithNoData(): void $builder->updateBatch(null, 'id'); } + public function testUpdateBatchThrowsExceptionWithWhere(): void + { + $builder = new BaseBuilder('jobs', $this->db); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage( + 'updateBatch() cannot be safely combined with existing Query Builder WHERE conditions. ' + . 'Use updateBatch($data, $constraints), onConstraint(), or include all required constraint fields in the batch data.', + ); + + $builder->where('description', 'old')->updateBatch([ + [ + 'id' => 2, + 'name' => 'Comedian', + ], + ], 'id'); + } + public function testUpdateBatchThrowsExceptionWithNoID(): void { $builder = new BaseBuilder('jobs', $this->db); diff --git a/tests/system/Database/Live/DbUtilsTest.php b/tests/system/Database/Live/DbUtilsTest.php index e20a32d8bed2..c90c8c6333a4 100644 --- a/tests/system/Database/Live/DbUtilsTest.php +++ b/tests/system/Database/Live/DbUtilsTest.php @@ -205,4 +205,24 @@ public function testUtilsXMLFromResult(): void $this->assertSame($expected, $actual); } + + public function testUtilsXMLFromResultWithZero(): void + { + $this->db->table('job')->insert([ + 'name' => '0', + 'description' => 'Testing zero value', + ]); + $data = $this->db->table('job')->where('name', '0')->get(); + + $util = (new Database())->loadUtils($this->db); + + $data = $util->getXMLFromResult($data); + + $expected = '50Testing zero value'; + + $actual = preg_replace('#\R+#', '', $data); + $actual = preg_replace('/[ ]{2,}|[\t]/', '', $actual); + + $this->assertSame($expected, $actual); + } } diff --git a/tests/system/Database/Live/DeleteTest.php b/tests/system/Database/Live/DeleteTest.php index 896ab12c1d39..651dff251a7f 100644 --- a/tests/system/Database/Live/DeleteTest.php +++ b/tests/system/Database/Live/DeleteTest.php @@ -100,6 +100,25 @@ public function testDeleteBatch(): void $this->dontSeeInDatabase('user', ['email' => 'ahmadinejad@world.com', 'name' => 'Ahmadinejad']); } + public function testDeleteBatchPreventsSQLInjectionInWhere(): void + { + if ($this->db->DBDriver === 'SQLite3') { + $this->markTestSkipped('SQLite3 driver does not support WHERE for batch deletes.'); + } + + $data = [ + ['userid' => 1, 'username' => 'Derek J'], + ]; + + $this->db->table('user') + ->setData($data, null, 'data') + ->onConstraint(['id' => 'userid']) + ->where('user.name', "nobody' OR 1=1 --") + ->deleteBatch(); + + $this->seeInDatabase('user', ['email' => 'derek@world.com', 'name' => 'Derek Jones']); + } + public function testDeleteBatchConstraintsDate(): void { $table = 'type_test'; diff --git a/tests/system/Debug/ExceptionsTest.php b/tests/system/Debug/ExceptionsTest.php index 415b6772c509..919c1ea85180 100644 --- a/tests/system/Debug/ExceptionsTest.php +++ b/tests/system/Debug/ExceptionsTest.php @@ -37,14 +37,14 @@ public static function setUpBeforeClass(): void { parent::setUpBeforeClass(); - unset($_SERVER['CODEIGNITER_SCREAM_DEPRECATIONS']); + service('superglobals')->unsetServer('CODEIGNITER_SCREAM_DEPRECATIONS'); } public static function tearDownAfterClass(): void { parent::tearDownAfterClass(); - $_SERVER['CODEIGNITER_SCREAM_DEPRECATIONS'] = '1'; + service('superglobals')->setServer('CODEIGNITER_SCREAM_DEPRECATIONS', '1'); } protected function setUp(): void diff --git a/tests/system/Email/EmailTest.php b/tests/system/Email/EmailTest.php index ca42613505e4..9ca5c5a51a4a 100644 --- a/tests/system/Email/EmailTest.php +++ b/tests/system/Email/EmailTest.php @@ -302,4 +302,111 @@ public function testGetHostnameFallsBackToGethostnameFunction(): void $this->assertSame(gethostname(), $getHostname()); } + + #[DataProvider('providePrepQuotedPrintableWithLfCrlf')] + public function testPrepQuotedPrintableWithLfCrlf(string $input, string $expected): void + { + $email = new Email(); + $email->CRLF = "\n"; + $prepQP = self::getPrivateMethodInvoker($email, 'prepQuotedPrintable'); + + $this->assertSame($expected, $prepQP($input)); + } + + /** + * @return iterable + */ + public static function providePrepQuotedPrintableWithLfCrlf(): iterable + { + return [ + 'empty string' => ['', ''], + 'safe ascii only' => ['hello world', 'hello world'], + 'safe chars only' => ['abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789(),-./:?', 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789(),-./:?'], + 'unsafe char encoded' => ["a\x01b", 'a=01b'], + 'trailing space encoded' => ["hello \nworld", "hello=20\nworld"], + 'trailing tab encoded' => ["hello\t\nworld", "hello=09\nworld"], + 'equals sign encoded as =3D' => ['a=b', 'a=3Db'], + 'multiple spaces reduced' => ['a b', 'a b'], + 'null bytes removed' => ["a\x00b", 'ab'], + 'unwrap tags removed' => ['{unwrap}secret{/unwrap}', 'secret'], + 'single line' => ['test', 'test'], + 'two lines' => ["line1\nline2", "line1\nline2"], + 'three lines trailing empty' => ["line1\nline2\n", "line1\nline2\n"], + ]; + } + + public function testPrepQuotedPrintableWithCrlfNative(): void + { + $email = new Email(); + $email->CRLF = "\r\n"; + $prepQP = self::getPrivateMethodInvoker($email, 'prepQuotedPrintable'); + + $result = $prepQP('test'); + + $this->assertSame(quoted_printable_encode('test'), $result); + } + + public function testPrepQuotedPrintableSoftLineBreak(): void + { + $email = new Email(); + $email->CRLF = "\n"; + $prepQP = self::getPrivateMethodInvoker($email, 'prepQuotedPrintable'); + + // 76 'a' chars fit in one line; add 2 more 'b' chars and they soft-wrap + // After reduction: no trailing spaces, just safe chars + $input = str_repeat('a', 76) . 'bb'; + $result = $prepQP($input); + + $this->assertStringContainsString("=\n", $result, 'Soft line break must be present'); + $this->assertStringNotContainsString("\r\n", $result, 'Custom CRLF must not contain \\r'); + } + + public function testPrepQuotedPrintableSoftBreakAfterEncodedChar(): void + { + $email = new Email(); + $email->CRLF = "\n"; + $prepQP = self::getPrivateMethodInvoker($email, 'prepQuotedPrintable'); + + // 74 safe chars + 1 encoded (=3D = 3 bytes) = 77 → must break before encoded + $input = str_repeat('a', 74) . '='; + $result = $prepQP($input); + + $this->assertSame(str_repeat('a', 74) . "=\n=3D", $result); + } + + public function testPrepQuotedPrintableHardLineBreakNoInternalSpaceReduction(): void + { + $email = new Email(); + $email->CRLF = "\n"; + $prepQP = self::getPrivateMethodInvoker($email, 'prepQuotedPrintable'); + + // Spaces not at end of line must be left as-is + $this->assertSame('a b', $prepQP('a b')); + } + + public function testPrepQuotedPrintableMixedContent(): void + { + $email = new Email(); + $email->CRLF = "\n"; + $prepQP = self::getPrivateMethodInvoker($email, 'prepQuotedPrintable'); + + $input = "Hello, World!\nline ends with tab\t\n=special chars: \x01\x02"; + $result = $prepQP($input); + + $this->assertStringContainsString('Hello, World=21', $result); + $this->assertStringContainsString('=09', $result); + $this->assertStringContainsString('=3D', $result); + $this->assertStringContainsString('=01', $result); + $this->assertStringContainsString('=02', $result); + } + + public function testPrepQuotedPrintableUnwrapRemovesTagsOnly(): void + { + $email = new Email(); + $email->CRLF = "\n"; + $prepQP = self::getPrivateMethodInvoker($email, 'prepQuotedPrintable'); + + $this->assertSame('keep =7Bbraces=7D', $prepQP('keep {braces}')); + $this->assertSame('keep (parentheses)', $prepQP('keep (parentheses)')); + } } diff --git a/tests/system/Encryption/Handlers/SodiumHandlerTest.php b/tests/system/Encryption/Handlers/SodiumHandlerTest.php index 4e963762b997..09b1784e4a12 100644 --- a/tests/system/Encryption/Handlers/SodiumHandlerTest.php +++ b/tests/system/Encryption/Handlers/SodiumHandlerTest.php @@ -151,4 +151,57 @@ public function testInternalKeyNotModifiedByParams(): void $this->assertSame($message, $encrypter->decrypt($encoded, ['key' => $differentKey])); } + + public function testNullKeyOverrideFallsBackToInstanceKey(): void + { + /** @var SodiumHandler $encrypter */ + $encrypter = $this->encryption->initialize($this->config); + + $ciphertext = $encrypter->encrypt('message', ['key' => null]); + + $this->assertSame('message', $encrypter->decrypt($ciphertext, ['key' => null])); + } + + public function testInvalidKeyLengthThrowsEncryptionException(): void + { + $this->expectException(EncryptionException::class); + /** @var SodiumHandler $encrypter */ + $encrypter = $this->encryption->initialize($this->config); + + $encrypter->encrypt('message', str_repeat('a', 31)); + } + + public function testMismatchedBlockSizeThrowsEncryptionException(): void + { + $this->expectException(EncryptionException::class); + /** @var SodiumHandler $encrypter */ + $encrypter = $this->encryption->initialize($this->config); + + $ciphertext = $encrypter->encrypt('message', ['blockSize' => 16]); + + $encrypter->decrypt($ciphertext, ['blockSize' => 32]); + } + + public function testDecryptTamperedMessageThrowsException(): void + { + $this->expectException(EncryptionException::class); + $encrypter = $this->encryption->initialize($this->config); + + $ciphertext = $encrypter->encrypt('message'); + + $ciphertext[0] = $ciphertext[0] === 'a' ? 'b' : 'a'; + + $encrypter->decrypt($ciphertext); + } + + public function testOverrideKeyAsStringWorks(): void + { + $encrypter = $this->encryption->initialize($this->config); + $newKey = sodium_crypto_secretbox_keygen(); + + $ciphertext = $encrypter->encrypt('message', $newKey); + $decrypted = $encrypter->decrypt($ciphertext, $newKey); + + $this->assertSame('message', $decrypted); + } } diff --git a/tests/system/Entity/EntityTest.php b/tests/system/Entity/EntityTest.php index 52d1de2f5a8b..5233a28deeaf 100644 --- a/tests/system/Entity/EntityTest.php +++ b/tests/system/Entity/EntityTest.php @@ -906,7 +906,7 @@ public function testCastEnumNullable(): void $entity->status = null; - $this->assertNull($entity->status); + $this->assertNotInstanceOf(StatusEnum::class, $entity->status); $entity->status = 'pending'; @@ -1573,7 +1573,7 @@ public function testDataCasterInitEmptyCasts(): void $getDataCaster = $this->getPrivateMethodInvoker($entity, 'dataCaster'); - $this->assertNull($getDataCaster()); + $this->assertNotInstanceOf(DataCaster::class, $getDataCaster()); $this->assertNull($this->getPrivateProperty($entity, 'dataCaster')); $this->assertSame('12345', $entity->first); diff --git a/tests/system/Filters/FiltersTest.php b/tests/system/Filters/FiltersTest.php index cdf57c8b9211..b632df217043 100644 --- a/tests/system/Filters/FiltersTest.php +++ b/tests/system/Filters/FiltersTest.php @@ -68,7 +68,7 @@ protected function setUp(): void ]; service('autoloader')->addNamespace($defaults); - $_SERVER = []; + service('superglobals')->setServerArray([]); Services::injectMock('superglobals', new Superglobals()); $this->response = service('response'); diff --git a/tests/system/Filters/InvalidCharsTest.php b/tests/system/Filters/InvalidCharsTest.php index 8aa935b14383..4cf112b6da91 100644 --- a/tests/system/Filters/InvalidCharsTest.php +++ b/tests/system/Filters/InvalidCharsTest.php @@ -103,13 +103,51 @@ public function testBeforeInvalidUTF8StringCausesException(): void $this->invalidChars->before($this->request); } - public function testBeforeInvalidControlCharCausesException(): void + public function testBeforeInvalidUTF8StringInArrayKeyCausesException(): void + { + $this->expectException(SecurityException::class); + $this->expectExceptionMessage('Invalid UTF-8 characters in post:'); + + $sjisString = mb_convert_encoding('SJISの文字列です。', 'SJIS'); + service('superglobals')->setPost('val', [ + $sjisString => 'valid string', + ]); + + $this->invalidChars->before($this->request); + } + + #[DataProvider('provideBeforeInvalidControlCharCausesException')] + public function testBeforeInvalidControlCharCausesException(string $invalidString): void + { + $this->expectException(SecurityException::class); + $this->expectExceptionMessage('Invalid Control characters in cookie:'); + + service('superglobals')->setCookie('val', $invalidString); + + $this->invalidChars->before($this->request); + } + + /** + * @return iterable> + */ + public static function provideBeforeInvalidControlCharCausesException(): iterable + { + yield 'null byte' => ["String with null char \0"]; + + yield 'backspace' => ["String with backspace \x08"]; + + yield 'escape' => ["String with escape \x1b"]; + } + + public function testBeforeInvalidControlCharInArrayKeyCausesException(): void { $this->expectException(SecurityException::class); $this->expectExceptionMessage('Invalid Control characters in cookie:'); $stringWithNullChar = "String contains null char and line break.\0\n"; - service('superglobals')->setCookie('val', $stringWithNullChar); + service('superglobals')->setCookie('val', [ + $stringWithNullChar => 'valid string', + ]); $this->invalidChars->before($this->request); } diff --git a/tests/system/HTTP/DownloadResponseTest.php b/tests/system/HTTP/DownloadResponseTest.php index d9c5e30fe207..d7cfcf937612 100644 --- a/tests/system/HTTP/DownloadResponseTest.php +++ b/tests/system/HTTP/DownloadResponseTest.php @@ -377,9 +377,7 @@ public function testRealOutput(): void // send it ob_start(); $response->send(); - if (ob_get_level() > 0) { - ob_end_clean(); - } + ob_end_clean(); // and what actually got sent? $this->assertHeaderEmitted('Content-Length: ' . filesize(__FILE__)); diff --git a/tests/system/HTTP/Files/FileMovingTest.php b/tests/system/HTTP/Files/FileMovingTest.php index 45a9fd179837..3284c33d0f26 100644 --- a/tests/system/HTTP/Files/FileMovingTest.php +++ b/tests/system/HTTP/Files/FileMovingTest.php @@ -100,6 +100,38 @@ public function testMove(): void $this->assertTrue($this->root->hasChild('destination/' . $finalFilename . '_1.txt')); } + public function testMoveSanitizesClientNameByDefault(): void + { + service('superglobals')->setFilesArray([ + 'userfile' => [ + 'name' => '../../public/shell.php', + 'type' => 'text/plain', + 'size' => 124, + 'tmp_name' => '/tmp/fileA.txt', + 'error' => 0, + ], + ]); + + $collection = new FileCollection(); + + $this->assertTrue($collection->hasFile('userfile')); + + $destination = $this->destination; + if (! is_dir($destination)) { + mkdir($destination, 0777, true); + } + + $file = $collection->getFile('userfile'); + $this->assertInstanceOf(UploadedFile::class, $file); + + // No second argument: the client-provided name must be sanitized. + $file->move($destination); + + $this->assertSame('publicshell.php', $file->getName()); + $this->assertTrue($this->root->hasChild('destination/publicshell.php')); + $this->assertFalse($this->root->hasChild('public/shell.php')); + } + public function testMoveOverwriting(): void { $finalFilename = 'file_with_delimiters_underscore'; diff --git a/tests/system/HTTP/IncomingRequestTest.php b/tests/system/HTTP/IncomingRequestTest.php index 509cf69643d2..2634e31a4b84 100644 --- a/tests/system/HTTP/IncomingRequestTest.php +++ b/tests/system/HTTP/IncomingRequestTest.php @@ -45,7 +45,13 @@ protected function setUp(): void { parent::setUp(); - $_POST = $_GET = $_SERVER = $_REQUEST = $_ENV = $_COOKIE = $_SESSION = []; + $_ENV = $_SESSION = []; + service('superglobals') + ->setPostArray([]) + ->setGetArray([]) + ->setServerArray([]) + ->setRequestArray([]) + ->setCookieArray([]); Services::injectMock('superglobals', new Superglobals()); $config = new App(); @@ -95,6 +101,34 @@ public function testCanGrabPostBeforeGet(): void $this->assertSame('3', $this->request->getGetPost('TEST')); } + public function testCanGrabMultiplePostAndGetVars(): void + { + service('superglobals') + ->setPostArray([ + 'post' => 'post value', + 'shared' => 'post shared value', + ]) + ->setGetArray([ + 'get' => 'get value', + 'shared' => 'get shared value', + ]); + + $index = ['post', 'get', 'shared', 'missing']; + + $this->assertSame([ + 'post' => 'post value', + 'get' => 'get value', + 'shared' => 'post shared value', + 'missing' => null, + ], $this->request->getPostGet($index)); + $this->assertSame([ + 'post' => 'post value', + 'get' => 'get value', + 'shared' => 'get shared value', + 'missing' => null, + ], $this->request->getGetPost($index)); + } + public function testNoOldInput(): void { $this->assertNull($this->request->getOldInput('name')); @@ -756,16 +790,86 @@ public function testIsSecure(): void $this->assertTrue($this->request->isSecure()); } - public function testIsSecureFrontEnd(): void - { - $this->request->appendHeader('Front-End-Https', 'on'); - $this->assertTrue($this->request->isSecure()); + /** + * @param array $proxyIPs + */ + #[DataProvider('provideIsSecureWithForwardedHeaders')] + public function testIsSecureWithForwardedHeaders( + string $header, + string $value, + string $remoteAddr, + array $proxyIPs, + bool $expected, + ): void { + service('superglobals')->setServer('REMOTE_ADDR', $remoteAddr); + + $config = new App(); + $config->proxyIPs = $proxyIPs; + + $request = $this->createRequest($config); + $request->appendHeader($header, $value); + + $this->assertSame($expected, $request->isSecure()); } - public function testIsSecureForwarded(): void + /** + * @return iterable, bool}> + */ + public static function provideIsSecureWithForwardedHeaders(): iterable { - $this->request->appendHeader('X-Forwarded-Proto', 'https'); - $this->assertTrue($this->request->isSecure()); + yield from [ + '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 no trusted proxies' => [ + 'X-Forwarded-Proto', 'https', '10.0.1.200', [], false, + ], + 'X-Forwarded-Proto untrusted proxy IP' => [ + 'X-Forwarded-Proto', 'https', '10.0.1.201', ['10.0.1.200' => 'X-Forwarded-For'], false, + ], + 'X-Forwarded-Proto trusted subnet' => [ + 'X-Forwarded-Proto', 'https', '192.168.5.21', ['192.168.5.0/24' => 'X-Forwarded-For'], true, + ], + 'X-Forwarded-Proto out of trusted subnet' => [ + 'X-Forwarded-Proto', 'https', '192.168.6.21', ['192.168.5.0/24' => 'X-Forwarded-For'], false, + ], + 'X-Forwarded-Proto trusted IPv6 subnet' => [ + 'X-Forwarded-Proto', 'https', '2001:db8::5', ['2001:db8::/32' => 'X-Forwarded-For'], true, + ], + 'X-Forwarded-Proto out of trusted IPv6 subnet' => [ + 'X-Forwarded-Proto', 'https', '2001:db9::5', ['2001:db8::/32' => 'X-Forwarded-For'], false, + ], + 'X-Forwarded-Proto trusted proxy but http' => [ + 'X-Forwarded-Proto', 'http', '10.0.1.200', ['10.0.1.200' => 'X-Forwarded-For'], false, + ], + 'Front-End-Https trusted proxy IP' => [ + 'Front-End-Https', 'on', '10.0.1.200', ['10.0.1.200' => 'X-Forwarded-For'], true, + ], + 'Front-End-Https no trusted proxies' => [ + 'Front-End-Https', 'on', '10.0.1.200', [], false, + ], + 'Front-End-Https trusted proxy but off' => [ + 'Front-End-Https', 'off', '10.0.1.200', ['10.0.1.200' => 'X-Forwarded-For'], false, + ], + 'invalid proxy IP string' => [ + 'X-Forwarded-Proto', 'https', '10.0.1.200', ['not an ip' => 'X-Forwarded-For'], false, + ], + 'invalid proxy CIDR mask' => [ + 'X-Forwarded-Proto', 'https', '192.168.5.21', ['192.168.5.0/foo' => 'X-Forwarded-For'], false, + ], + 'empty proxy CIDR mask' => [ + 'X-Forwarded-Proto', 'https', '192.168.5.21', ['192.168.5.0/' => 'X-Forwarded-For'], false, + ], + 'negative proxy CIDR mask' => [ + 'X-Forwarded-Proto', 'https', '192.168.5.21', ['192.168.5.0/-1' => 'X-Forwarded-For'], false, + ], + 'out of range IPv4 proxy CIDR mask' => [ + 'X-Forwarded-Proto', 'https', '192.168.5.21', ['192.168.5.0/33' => 'X-Forwarded-For'], false, + ], + 'out of range IPv6 proxy CIDR mask' => [ + 'X-Forwarded-Proto', 'https', '2001:db8::5', ['2001:db8::/129' => 'X-Forwarded-For'], false, + ], + ]; } public function testUserAgent(): void diff --git a/tests/system/HTTP/RequestTest.php b/tests/system/HTTP/RequestTest.php index 0ccaf8083f4f..eb15506e6fdf 100644 --- a/tests/system/HTTP/RequestTest.php +++ b/tests/system/HTTP/RequestTest.php @@ -19,6 +19,7 @@ use CodeIgniter\Test\CIUnitTestCase; use Config\App; use PHPUnit\Framework\Attributes\BackupGlobals; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; /** @@ -567,95 +568,126 @@ public function testGetIPAddressNormal(): void $this->assertSame($expected, $this->request->getIPAddress()); } - public function testGetIPAddressThruProxy(): void - { - $expected = '123.123.123.123'; + /** + * @param array $proxyIPs + */ + #[DataProvider('provideGetIPAddressThruProxy')] + public function testGetIPAddressThruProxy( + string $expected, + string $remoteAddr, + array $proxyIPs, + string $forwardedFor, + ): void { service('superglobals') - ->setServer('REMOTE_ADDR', '10.0.1.200') - ->setServer('HTTP_X_FORWARDED_FOR', $expected); + ->setServer('REMOTE_ADDR', $remoteAddr) + ->setServer('HTTP_X_FORWARDED_FOR', $forwardedFor); $config = new App(); - $config->proxyIPs = [ - '10.0.1.200' => 'X-Forwarded-For', - '192.168.5.0/24' => 'X-Forwarded-For', - ]; + $config->proxyIPs = $proxyIPs; Factories::injectMock('config', App::class, $config); $this->request = new Request(); $this->request->populateHeaders(); - // we should see the original forwarded address $this->assertSame($expected, $this->request->getIPAddress()); } - public function testGetIPAddressThruProxyInvalid(): void - { - $expected = '123.456.23.123'; - service('superglobals') - ->setServer('REMOTE_ADDR', '10.0.1.200') - ->setServer('HTTP_X_FORWARDED_FOR', $expected); - $config = new App(); - $config->proxyIPs = [ - '10.0.1.200' => 'X-Forwarded-For', - '192.168.5.0/24' => 'X-Forwarded-For', - ]; - - $this->request = new Request($config); - $this->request->populateHeaders(); - - // spoofed address invalid - $this->assertSame('10.0.1.200', $this->request->getIPAddress()); - } - - public function testGetIPAddressThruProxyNotWhitelisted(): void + /** + * @return iterable, string}> + */ + public static function provideGetIPAddressThruProxy(): iterable { - $expected = '123.456.23.123'; - service('superglobals') - ->setServer('REMOTE_ADDR', '10.10.1.200') - ->setServer('HTTP_X_FORWARDED_FOR', $expected); - - $config = new App(); - $config->proxyIPs = [ - '10.0.1.200' => 'X-Forwarded-For', - '192.168.5.0/24' => 'X-Forwarded-For', + yield from [ + 'trusted proxy IP' => [ + '123.123.123.123', + '10.0.1.200', + ['10.0.1.200' => 'X-Forwarded-For', '192.168.5.0/24' => 'X-Forwarded-For'], + '123.123.123.123', + ], + 'invalid spoofed address' => [ + '10.0.1.200', + '10.0.1.200', + ['10.0.1.200' => 'X-Forwarded-For', '192.168.5.0/24' => 'X-Forwarded-For'], + '123.456.23.123', + ], + 'not whitelisted proxy IP' => [ + '10.10.1.200', + '10.10.1.200', + ['10.0.1.200' => 'X-Forwarded-For', '192.168.5.0/24' => 'X-Forwarded-For'], + '123.456.23.123', + ], + 'trusted subnet' => [ + '123.123.123.123', + '192.168.5.21', + ['192.168.5.0/24' => 'X-Forwarded-For'], + '123.123.123.123', + ], + 'out of trusted subnet' => [ + '192.168.5.21', + '192.168.5.21', + ['192.168.5.0/28' => 'X-Forwarded-For'], + '123.123.123.123', + ], + 'trusted IPv6 proxy IP' => [ + '123.123.123.123', + '2001:db8::2:1', + ['2001:db8::2:1' => 'X-Forwarded-For'], + '123.123.123.123', + ], + 'trusted IPv6 subnet' => [ + '123.123.123.123', + '2001:db8::2:1', + ['2001:db8::/32' => 'X-Forwarded-For'], + '123.123.123.123', + ], + 'out of trusted IPv6 subnet' => [ + '2001:db9::2:1', + '2001:db9::2:1', + ['2001:db8::/32' => 'X-Forwarded-For'], + '123.123.123.123', + ], + 'proxy entry IP version mismatch' => [ + '192.168.5.21', + '192.168.5.21', + ['2001:db8::/32' => 'X-Forwarded-For'], + '123.123.123.123', + ], + 'invalid proxy IP string' => [ + '10.0.1.200', + '10.0.1.200', + ['not an ip' => 'X-Forwarded-For'], + '123.123.123.123', + ], + 'invalid proxy CIDR mask' => [ + '192.168.5.21', + '192.168.5.21', + ['192.168.5.0/foo' => 'X-Forwarded-For'], + '123.123.123.123', + ], + 'empty proxy CIDR mask' => [ + '192.168.5.21', + '192.168.5.21', + ['192.168.5.0/' => 'X-Forwarded-For'], + '123.123.123.123', + ], + 'negative proxy CIDR mask' => [ + '192.168.5.21', + '192.168.5.21', + ['192.168.5.0/-1' => 'X-Forwarded-For'], + '123.123.123.123', + ], + 'out of range IPv4 proxy CIDR mask' => [ + '192.168.5.21', + '192.168.5.21', + ['192.168.5.0/33' => 'X-Forwarded-For'], + '123.123.123.123', + ], + 'out of range IPv6 proxy CIDR mask' => [ + '2001:db8::2:1', + '2001:db8::2:1', + ['2001:db8::/129' => 'X-Forwarded-For'], + '123.123.123.123', + ], ]; - $this->request = new Request($config); - $this->request->populateHeaders(); - - // spoofed address invalid - $this->assertSame('10.10.1.200', $this->request->getIPAddress()); - } - - public function testGetIPAddressThruProxySubnet(): void - { - $expected = '123.123.123.123'; - service('superglobals') - ->setServer('REMOTE_ADDR', '192.168.5.21') - ->setServer('HTTP_X_FORWARDED_FOR', $expected); - - $config = new App(); - $config->proxyIPs = ['192.168.5.0/24' => 'X-Forwarded-For']; - Factories::injectMock('config', App::class, $config); - $this->request = new Request(); - $this->request->populateHeaders(); - - // we should see the original forwarded address - $this->assertSame($expected, $this->request->getIPAddress()); - } - - public function testGetIPAddressThruProxyOutofSubnet(): void - { - $expected = '123.123.123.123'; - service('superglobals') - ->setServer('REMOTE_ADDR', '192.168.5.21') - ->setServer('HTTP_X_FORWARDED_FOR', $expected); - - $config = new App(); - $config->proxyIPs = ['192.168.5.0/28' => 'X-Forwarded-For']; - $this->request = new Request($config); - $this->request->populateHeaders(); - - // we should see the original forwarded address - $this->assertSame('192.168.5.21', $this->request->getIPAddress()); } // FIXME getIPAddress should have more testing, to 100% code coverage diff --git a/tests/system/HTTP/ResponseSendTest.php b/tests/system/HTTP/ResponseSendTest.php index 211d806ba487..93076b9a3756 100644 --- a/tests/system/HTTP/ResponseSendTest.php +++ b/tests/system/HTTP/ResponseSendTest.php @@ -67,9 +67,7 @@ public function testHeadersMissingDate(): void // send it ob_start(); $response->send(); - if (ob_get_level() > 0) { - ob_end_clean(); - } + ob_end_clean(); // and what actually got sent? $this->assertHeaderEmitted('Date:'); @@ -102,9 +100,7 @@ public function testHeadersWithCSP(): void // send it ob_start(); $response->send(); - if (ob_get_level() > 0) { - ob_end_clean(); - } + ob_end_clean(); // and what actually got sent?; test both ways $this->assertHeaderEmitted('Content-Security-Policy:'); @@ -140,9 +136,7 @@ public function testRedirectResponseCookies(): void // send it ob_start(); $response->send(); - if (ob_get_level() > 0) { - ob_end_clean(); - } + ob_end_clean(); // and what actually got sent? $this->assertHeaderEmitted('Set-Cookie: foo=bar;'); @@ -213,9 +207,7 @@ public function testHeaderOverride(): void header('Access-Control-Expose-Headers: Content-Encoding'); header('Allow: GET, POST'); $response->send(); - if (ob_get_level() > 0) { - ob_end_clean(); - } + ob_end_clean(); // single header $this->assertHeaderEmitted('Vary: Accept-Encoding'); diff --git a/tests/system/HTTP/ResponseTest.php b/tests/system/HTTP/ResponseTest.php index 38c349117cbb..17d1b4191134 100644 --- a/tests/system/HTTP/ResponseTest.php +++ b/tests/system/HTTP/ResponseTest.php @@ -494,6 +494,41 @@ public function testGetDownloadResponseByFilePath(): void $this->assertSame(file_get_contents(__FILE__), $actualOutput); } + public function testGetDownloadResponseByExtremeFilePath(): void + { + $response = new Response(new App()); + + $tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'ci4_test_dir_' . bin2hex(random_bytes(8)); + $this->assertTrue(mkdir($tempDir)); + $extremeName = 'my_extreme_file_!@#$%.txt'; + $extremePath = $tempDir . DIRECTORY_SEPARATOR . $extremeName; + + try { + file_put_contents($extremePath, 'extreme data'); + + $actual = $response->download($extremePath, null); + + $this->assertInstanceOf(DownloadResponse::class, $actual); + $actual->buildHeaders(); + + $expectedFilename = $extremeName; + $this->assertSame( + 'attachment; filename="' . addslashes($expectedFilename) . '"; filename*=UTF-8\'\'' . rawurlencode($expectedFilename), + $actual->getHeaderLine('Content-Disposition'), + ); + + ob_start(); + $actual->sendBody(); + $actualOutput = ob_get_contents(); + ob_end_clean(); + + $this->assertSame('extreme data', $actualOutput); + } finally { + @unlink($extremePath); + @rmdir($tempDir); + } + } + public function testVagueDownload(): void { $response = new Response(new App()); diff --git a/tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php b/tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php index 2a0e485775d4..b864e493bb5f 100644 --- a/tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php +++ b/tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php @@ -32,7 +32,7 @@ protected function setUp(): void { parent::setUp(); - $_GET = $_SERVER = []; + service('superglobals')->setGetArray([])->setServerArray([]); Services::injectMock('superglobals', new Superglobals()); } @@ -251,7 +251,7 @@ public function testQueryStringWithQueryString(): void $expected = 'ci/woot'; $this->assertSame($expected, $factory->detectRoutePath('QUERY_STRING')); - $this->assertSame('code=good', $_SERVER['QUERY_STRING']); + $this->assertSame('code=good', $_SERVER['QUERY_STRING']); // @phpstan-ignore codeigniter.superglobalsOffsetAccess (checks the live superglobal, not the snapshot service) $this->assertSame(['code' => 'good'], $_GET); } diff --git a/tests/system/HTTP/UserAgentTest.php b/tests/system/HTTP/UserAgentTest.php index c43c46354e87..b19e0c62dee5 100644 --- a/tests/system/HTTP/UserAgentTest.php +++ b/tests/system/HTTP/UserAgentTest.php @@ -74,10 +74,38 @@ public function testBrowserInfo(): void { $this->assertSame('Mac OS X', $this->agent->getPlatform()); $this->assertSame('Safari', $this->agent->getBrowser()); - $this->assertSame('533.20.27', $this->agent->getVersion()); + $this->assertSame('5.0.4', $this->agent->getVersion()); $this->assertSame('', $this->agent->getRobot()); } + public function testParseModernSafariUsesVersionToken(): void + { + $this->agent->parse('Mozilla/5.0 (Macintosh; Intel Mac OS X 13_2) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15'); + + $this->assertSame('Mac OS X', $this->agent->getPlatform()); + $this->assertSame('Safari', $this->agent->getBrowser()); + $this->assertSame('16.3', $this->agent->getVersion()); + } + + public function testParseMobileSafariUsesVersionToken(): void + { + $this->agent->parse($this->_mobile_ua); + + $this->assertSame('iOS', $this->agent->getPlatform()); + $this->assertSame('Safari', $this->agent->getBrowser()); + $this->assertSame('4.0.5', $this->agent->getVersion()); + $this->assertSame('Apple iPhone', $this->agent->getMobile()); + } + + public function testParseChromeWithSafariTokenUsesChromeVersion(): void + { + $this->agent->parse('Mozilla/5.0 (Macintosh; Intel Mac OS X 13_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36'); + + $this->assertSame('Mac OS X', $this->agent->getPlatform()); + $this->assertSame('Chrome', $this->agent->getBrowser()); + $this->assertSame('110.0.0.0', $this->agent->getVersion()); + } + public function testParse(): void { $newAgent = 'Mozilla/5.0 (Android; Mobile; rv:13.0) Gecko/13.0 Firefox/13.0'; diff --git a/tests/system/Helpers/CookieHelperTest.php b/tests/system/Helpers/CookieHelperTest.php index abbc291ceb38..9b79e8d61dbc 100644 --- a/tests/system/Helpers/CookieHelperTest.php +++ b/tests/system/Helpers/CookieHelperTest.php @@ -41,7 +41,7 @@ final class CookieHelperTest extends CIUnitTestCase protected function setUp(): void { - $_COOKIE = []; + service('superglobals')->setCookieArray([]); parent::setUp(); diff --git a/tests/system/Helpers/FormHelperTest.php b/tests/system/Helpers/FormHelperTest.php index 04c35458cd01..73cbe2430a5f 100644 --- a/tests/system/Helpers/FormHelperTest.php +++ b/tests/system/Helpers/FormHelperTest.php @@ -41,7 +41,7 @@ protected function setUp(): void parent::setUp(); - $_POST = $_GET = []; + service('superglobals')->setPostArray([])->setGetArray([]); CodeIgniterServices::injectMock('superglobals', new Superglobals()); diff --git a/tests/system/Helpers/URLHelper/SiteUrlCliTest.php b/tests/system/Helpers/URLHelper/SiteUrlCliTest.php index e24f55f44605..ad99f46a8b2a 100644 --- a/tests/system/Helpers/URLHelper/SiteUrlCliTest.php +++ b/tests/system/Helpers/URLHelper/SiteUrlCliTest.php @@ -48,7 +48,7 @@ protected function tearDown(): void { parent::tearDown(); - $_SERVER = []; + service('superglobals')->setServerArray([]); } private function createRequest(?App $config = null): void diff --git a/tests/system/Helpers/URLHelper/SiteUrlTest.php b/tests/system/Helpers/URLHelper/SiteUrlTest.php index 791515a09fab..c8a07758e3bd 100644 --- a/tests/system/Helpers/URLHelper/SiteUrlTest.php +++ b/tests/system/Helpers/URLHelper/SiteUrlTest.php @@ -52,7 +52,7 @@ protected function tearDown(): void { parent::tearDown(); - $_SERVER = []; + service('superglobals')->setServerArray([]); } private function createRequest(?App $config = null, $body = null, ?string $path = null): void diff --git a/tests/system/Models/DataConverterModelTest.php b/tests/system/Models/DataConverterModelTest.php index 7d647790d62b..f26f03d999b9 100644 --- a/tests/system/Models/DataConverterModelTest.php +++ b/tests/system/Models/DataConverterModelTest.php @@ -20,6 +20,7 @@ use Tests\Support\Models\UserCastsTimestampModel; /** + * @property-read UserCastsTimestampModel $model * @internal */ #[Group('DatabaseLive')] @@ -36,7 +37,7 @@ public function testFindAsArray(): void $user = $this->model->find($id); - $this->assertIsInt($user['id']); // @phpstan-ignore offsetAccess.notFound + $this->assertIsInt($user['id']); $this->assertInstanceOf(Time::class, $user['created_at']); $this->assertSame('John Smith', $user['name']); // `name` is cast by custom CastBase64 handler. @@ -128,9 +129,9 @@ public function testFindAllAsArray(): void $users = $this->model->findAll(); - $this->assertIsInt($users[0]['id']); // @phpstan-ignore offsetAccess.notFound + $this->assertIsInt($users[0]['id']); $this->assertInstanceOf(Time::class, $users[0]['created_at']); - $this->assertIsInt($users[1]['id']); // @phpstan-ignore offsetAccess.notFound + $this->assertIsInt($users[1]['id']); $this->assertInstanceOf(Time::class, $users[1]['created_at']); } @@ -208,7 +209,7 @@ public function testFirstAsArray(): void $user = $this->model->first(); - $this->assertIsInt($user['id']); // @phpstan-ignore offsetAccess.notFound + $this->assertIsInt($user['id']); $this->assertInstanceOf(Time::class, $user['created_at']); } @@ -265,7 +266,7 @@ public function testInsertArray(): void $user = $this->model->find($id); - $this->assertSame(['joe@example.com'], $user['email']); // @phpstan-ignore offsetAccess.notFound + $this->assertSame(['joe@example.com'], $user['email']); } public function testInsertObject(): void @@ -281,7 +282,7 @@ public function testInsertObject(): void $user = $this->model->find($id); - $this->assertSame(['joe@example.com'], $user['email']); // @phpstan-ignore offsetAccess.notFound + $this->assertSame(['joe@example.com'], $user['email']); } public function testUpdateArray(): void @@ -290,14 +291,14 @@ public function testUpdateArray(): void $user = $this->model->find($id); $user['email'][] = 'private@example.org'; - $this->model->update($user['id'], $user); // @phpstan-ignore offsetAccess.notFound + $this->model->update($user['id'], $user); $user = $this->model->find($id); $this->assertSame([ 'john@example.com', 'private@example.org', - ], $user['email']); // @phpstan-ignore offsetAccess.notFound + ], $user['email']); } public function testUpdateObject(): void @@ -313,7 +314,7 @@ public function testUpdateObject(): void $this->assertSame([ 'john@example.com', 'private@example.org', - ], $user['email']); // @phpstan-ignore offsetAccess.notFound + ], $user['email']); } public function testUpdateCustomObject(): void @@ -335,8 +336,7 @@ public function testUpdateCustomObject(): void public function testUpdateEntity(): void { - $id = $this->prepareOneRecord(); - /** @var User $user */ + $id = $this->prepareOneRecord(); $user = $this->model->asObject(User::class)->find($id); $email = $user->email; @@ -365,7 +365,7 @@ public function testSaveArray(): void $this->assertSame([ 'john@example.com', 'private@example.org', - ], $user['email']); // @phpstan-ignore offsetAccess.notFound + ], $user['email']); } public function testSaveObject(): void @@ -381,7 +381,7 @@ public function testSaveObject(): void $this->assertSame([ 'john@example.com', 'private@example.org', - ], $user['email']); // @phpstan-ignore offsetAccess.notFound + ], $user['email']); } public function testSaveCustomObject(): void @@ -403,8 +403,7 @@ public function testSaveCustomObject(): void public function testSaveEntity(): void { - $id = $this->prepareOneRecord(); - /** @var User $user */ + $id = $this->prepareOneRecord(); $user = $this->model->asObject(User::class)->find($id); $email = $user->email; diff --git a/tests/system/Models/FindModelTest.php b/tests/system/Models/FindModelTest.php index 71a085dc3950..b5efc8a64f64 100644 --- a/tests/system/Models/FindModelTest.php +++ b/tests/system/Models/FindModelTest.php @@ -226,27 +226,27 @@ public function testFirstRespectsSoftDeletes($aggregate, $groupBy): void ->where('id', 1) ->update(['deleted_at' => date('Y-m-d H:i:s')]); - $this->createModel(UserModel::class); + $model = $this->createModel(UserModel::class); if ($aggregate) { $ANSISQLDriverNames = ['OCI8']; if (in_array($this->db->DBDriver, $ANSISQLDriverNames, true)) { - $this->model->select('SUM("id") as "id"'); + $model->select('SUM("id") as "id"'); } else { - $this->model->select('SUM(id) as id'); + $model->select('SUM(id) as id'); } } if ($groupBy) { if (! $aggregate) { - $this->model->select('id'); + $model->select('id'); } - $this->model->groupBy('id'); + $model->groupBy('id'); } - $user = $this->model->first(); + $user = $model->first(); if (! $aggregate || $groupBy) { $count = is_object($user) ? 1 : 0; @@ -256,7 +256,7 @@ public function testFirstRespectsSoftDeletes($aggregate, $groupBy): void $this->assertSame(9, (int) $user->id); } - $user = $this->model->withDeleted()->select('id')->first(); + $user = $model->withDeleted()->select('id')->first(); $this->assertSame(1, (int) $user->id); } @@ -267,29 +267,29 @@ public function testFirstRespectsSoftDeletes($aggregate, $groupBy): void #[DataProvider('provideAggregateAndGroupBy')] public function testFirstRecoverTempUseSoftDeletes($aggregate, $groupBy): void { - $this->createModel(UserModel::class); - $this->model->delete(1); + $model = $this->createModel(UserModel::class); + $model->delete(1); if ($aggregate) { $ANSISQLDriverNames = ['OCI8']; if (in_array($this->db->DBDriver, $ANSISQLDriverNames, true)) { - $this->model->select('SUM("id") as "id"'); + $model->select('SUM("id") as "id"'); } else { - $this->model->select('SUM(id) as id'); + $model->select('SUM(id) as id'); } } else { - $this->model->select('id'); + $model->select('id'); } if ($groupBy) { - $this->model->groupBy('id'); + $model->groupBy('id'); } - $user = $this->model->withDeleted()->first(); + $user = $model->withDeleted()->first(); $this->assertSame(1, (int) $user->id); - $user2 = $this->model->select('id')->first(); + $user2 = $model->select('id')->first(); $this->assertSame(2, (int) $user2->id); } diff --git a/tests/system/Models/InsertModelTest.php b/tests/system/Models/InsertModelTest.php index 74a2ffaa89fa..ccaa8e9cf4ac 100644 --- a/tests/system/Models/InsertModelTest.php +++ b/tests/system/Models/InsertModelTest.php @@ -119,12 +119,12 @@ public function testInsertBatchSetsIntTimestamps(): void ], ]; - $this->createModel(JobModel::class); + $model = $this->createModel(JobModel::class); - $this->setPrivateProperty($this->model, 'useTimestamps', true); - $this->assertSame(2, $this->model->insertBatch($jobData)); + $this->setPrivateProperty($model, 'useTimestamps', true); + $this->assertSame(2, $model->insertBatch($jobData)); - $result = $this->model->where('name', 'Philosopher')->first(); + $result = $model->where('name', 'Philosopher')->first(); $this->assertCloseEnough(time(), (int) $result->created_at); } @@ -143,12 +143,12 @@ public function testInsertBatchSetsDatetimeTimestamps(): void ], ]; - $this->createModel(UserModel::class); + $model = $this->createModel(UserModel::class); - $this->setPrivateProperty($this->model, 'useTimestamps', true); - $this->assertSame(2, $this->model->insertBatch($userData)); + $this->setPrivateProperty($model, 'useTimestamps', true); + $this->assertSame(2, $model->insertBatch($userData)); - $result = $this->model->where('name', 'Lou')->first(); + $result = $model->where('name', 'Lou')->first(); $this->assertCloseEnough(time(), strtotime($result->created_at)); } @@ -335,15 +335,13 @@ public function testInsertWithSetAndEscape(): void 'name' => 'Scott', ]; - $this->createModel(UserModel::class); - - $this->setPrivateProperty($this->model, 'useTimestamps', true); - - $this->model->set('country', '1+1', false)->set('email', '2+2')->insert($userData); + $model = $this->createModel(UserModel::class); + $this->setPrivateProperty($model, 'useTimestamps', true); - $this->assertGreaterThan(0, $this->model->getInsertID()); - $result = $this->model->where('name', 'Scott')->where('country', '2')->where('email', '2+2')->first(); + $model->set('country', '1+1', false)->set('email', '2+2')->insert($userData); + $this->assertGreaterThan(0, $model->getInsertID()); + $result = $model->where('name', 'Scott')->where('country', '2')->where('email', '2+2')->first(); $this->assertNotNull($result->created_at); } @@ -389,7 +387,7 @@ public function testInsertBatchWithCasts(): void ]; $this->createModel(UserCastsTimestampModel::class); - $numRows = $this->model->insertBatch($userData); // @phpstan-ignore argument.type + $numRows = $this->model->insertBatch($userData); $this->assertSame(2, $numRows); diff --git a/tests/system/Models/MiscellaneousModelTest.php b/tests/system/Models/MiscellaneousModelTest.php index e95a709e61bd..ae6d98621819 100644 --- a/tests/system/Models/MiscellaneousModelTest.php +++ b/tests/system/Models/MiscellaneousModelTest.php @@ -99,7 +99,8 @@ public function testChunkEmptyTable(): void public function testCanCreateAndSaveEntityClasses(): void { - $entity = $this->createModel(EntityModel::class)->where('name', 'Developer')->first(); + $model = $this->createModel(EntityModel::class); + $entity = $model->where('name', 'Developer')->first(); $this->assertInstanceOf(SimpleEntity::class, $entity); $this->assertSame('Developer', $entity->name); @@ -110,9 +111,9 @@ public function testCanCreateAndSaveEntityClasses(): void $entity->name = 'Senior Developer'; $entity->created_at = $time; - $this->assertTrue($this->model->save($entity)); + $this->assertTrue($model->save($entity)); - $result = $this->model->where('name', 'Senior Developer')->first(); + $result = $model->where('name', 'Senior Developer')->first(); $this->assertSame( Time::createFromTimestamp($time)->toDateTimeString(), $result->created_at->toDateTimeString(), diff --git a/tests/system/Models/ObjectToRawArrayModelTest.php b/tests/system/Models/ObjectToRawArrayModelTest.php new file mode 100644 index 000000000000..8ee7460c9d52 --- /dev/null +++ b/tests/system/Models/ObjectToRawArrayModelTest.php @@ -0,0 +1,129 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Models; + +use CodeIgniter\Entity\Entity; +use CodeIgniter\Model; +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class ObjectToRawArrayModelTest extends CIUnitTestCase +{ + private function createModel(): Model + { + return new class () extends Model { + public function __construct() + { + // Skip DB connection — we only test objectToRawArray + } + + protected $table = 'test'; + protected $allowedFields = ['name', 'nested', 'entity']; + protected $returnType = 'array'; + protected $useSoftDeletes = false; + }; + } + + /** + * Call protected objectToRawArray via reflection. + * + * @return array + */ + private function callObjectToRawArray(Model $model, object $object, bool $onlyChanged, bool $recursive): array + { + $method = self::getPrivateMethodInvoker($model, 'objectToRawArray'); + + return $method($object, $onlyChanged, $recursive); + } + + public function testObjectToRawArrayPassesRecursiveTrue(): void + { + $model = $this->createModel(); + + $inner = new class () extends Entity { + protected $attributes = ['name' => 'inner']; + protected $original = ['name' => 'inner']; + }; + + $outer = new class () extends Entity { + protected $attributes = ['name' => 'outer', 'nested' => null]; + protected $original = ['name' => 'outer', 'nested' => null]; + }; + $outer->nested = $inner; + + $result = $this->callObjectToRawArray($model, $outer, false, true); + + $this->assertArrayHasKey('name', $result); + $this->assertSame('outer', $result['name']); + $this->assertArrayHasKey('nested', $result); + $this->assertIsArray($result['nested']); + $this->assertSame(['name' => 'inner'], $result['nested']); + } + + public function testObjectToRawArrayPassesRecursiveFalse(): void + { + $model = $this->createModel(); + + $inner = new class () extends Entity { + protected $attributes = ['name' => 'inner']; + protected $original = ['name' => 'inner']; + }; + + $outer = new class () extends Entity { + protected $attributes = ['name' => 'outer', 'nested' => null]; + protected $original = ['name' => 'outer', 'nested' => null]; + }; + $outer->nested = $inner; + + $result = $this->callObjectToRawArray($model, $outer, false, false); + + $this->assertArrayHasKey('name', $result); + $this->assertSame('outer', $result['name']); + $this->assertArrayHasKey('nested', $result); + // With recursive=false, nested Entity should remain as object + $this->assertInstanceOf(Entity::class, $result['nested']); + } + + public function testObjectToRawArrayNonEntity(): void + { + $model = $this->createModel(); + + $obj = new class () { + public string $name = 'test'; + public string $value = '123'; + }; + + $result = $this->callObjectToRawArray($model, $obj, false, false); + + $this->assertSame(['name' => 'test', 'value' => '123'], $result); + } + + public function testObjectToRawArrayOnlyChanged(): void + { + $model = $this->createModel(); + $entity = new class () extends Entity { + protected $attributes = ['name' => 'original', 'value' => 'keep']; + protected $original = ['name' => 'original', 'value' => 'keep']; + }; + $entity->name = 'modified'; + + $result = $this->callObjectToRawArray($model, $entity, true, false); + + $this->assertSame(['name' => 'modified'], $result); + } +} diff --git a/tests/system/Models/SaveModelTest.php b/tests/system/Models/SaveModelTest.php index be77d958ff7f..246bd8e6142c 100644 --- a/tests/system/Models/SaveModelTest.php +++ b/tests/system/Models/SaveModelTest.php @@ -167,9 +167,9 @@ public function testSelectAndEntitiesSaveOnlyChangedValues(): void 'created_at' => time(), ]); - $this->createModel(EntityModel::class); + $model = $this->createModel(EntityModel::class); - $job = $this->model->select('id, name')->where('name', 'Rocket Scientist')->first(); + $job = $model->select('id, name')->where('name', 'Rocket Scientist')->first(); $this->assertNull($job->description); $this->assertSame('Rocket Scientist', $job->name); @@ -181,7 +181,7 @@ public function testSelectAndEntitiesSaveOnlyChangedValues(): void 'name' => 'Rocket Scientist', ]); - $job = $this->model->select('id, name, description')->where('name', 'Rocket Scientist')->first(); + $job = $model->select('id, name, description')->where('name', 'Rocket Scientist')->first(); $this->assertSame('Some guitar description', $job->description); } diff --git a/tests/system/Models/TimestampModelTest.php b/tests/system/Models/TimestampModelTest.php index 9af7cd3b9547..52989cff754a 100644 --- a/tests/system/Models/TimestampModelTest.php +++ b/tests/system/Models/TimestampModelTest.php @@ -19,6 +19,7 @@ use Tests\Support\Models\UserTimestampModel; /** + * @property-read UserTimestampModel $model * @internal */ #[Group('DatabaseLive')] @@ -98,7 +99,7 @@ public function testDoNotAllowDatesInsertArrayWithoutDatesSetsTimestamp(): void $expected .= '.000'; } - $this->assertSame($expected, $user['created_at']); // @phpstan-ignore offsetAccess.notFound + $this->assertSame($expected, $user['created_at']); $this->assertSame($expected, $user['updated_at']); } @@ -123,7 +124,7 @@ public function testDoNotAllowDatesInsertArrayWithDatesSetsTimestamp(): void $expected .= '.000'; } - $this->assertSame($expected, $user['created_at']); // @phpstan-ignore offsetAccess.notFound + $this->assertSame($expected, $user['created_at']); $this->assertSame($expected, $user['updated_at']); } @@ -143,7 +144,7 @@ public function testDoNotAllowDatesUpdateArrayUpdatesUpdatedAt(): void $user = $this->model->find($id); $user['country'] = 'CA'; - $this->model->update($user['id'], $user); // @phpstan-ignore offsetAccess.notFound + $this->model->update($user['id'], $user); $user = $this->model->find($id); @@ -153,7 +154,7 @@ public function testDoNotAllowDatesUpdateArrayUpdatesUpdatedAt(): void $expected .= '.000'; } - $this->assertSame($expected, $user['created_at']); // @phpstan-ignore offsetAccess.notFound + $this->assertSame($expected, $user['created_at']); $this->assertSame($expected, $user['updated_at']); } @@ -170,14 +171,13 @@ public function testDoNotAllowDatesUpdateEntityUpdatesUpdatedAt(): void ]; $id = $this->doNotAllowDatesPrepareOneRecord($data); $this->setPrivateProperty($this->model, 'returnType', User::class); - $this->setPrivateProperty($this->model, 'tempReturnType', User::class); - $user = $this->model->find($id); + $user = $this->model->asObject(User::class)->find($id); $user->country = 'CA'; $this->model->update($user->id, $user); - $user = $this->model->find($id); + $user = $this->model->asObject(User::class)->find($id); $this->assertSame('2023-11-25 12:00:00', (string) $user->created_at); $this->assertSame('2023-11-25 12:00:00', (string) $user->updated_at); @@ -208,7 +208,7 @@ public function testAllowDatesInsertArrayWithoutDatesSetsTimestamp(): void $expected .= '.000'; } - $this->assertSame($expected, $user['created_at']); // @phpstan-ignore offsetAccess.notFound + $this->assertSame($expected, $user['created_at']); $this->assertSame($expected, $user['updated_at']); } @@ -237,7 +237,7 @@ public function testAllowDatesInsertArrayWithDatesSetsTimestamp(): void $expected .= '.000'; } - $this->assertSame($expected, $user['created_at']); // @phpstan-ignore offsetAccess.notFound + $this->assertSame($expected, $user['created_at']); $this->assertSame($expected, $user['updated_at']); } @@ -261,7 +261,7 @@ public function testAllowDatesUpdateArrayUpdatesUpdatedAt(): void $user = $this->model->find($id); $user['country'] = 'CA'; - $this->model->update($user['id'], $user); // @phpstan-ignore offsetAccess.notFound + $this->model->update($user['id'], $user); $user = $this->model->find($id); @@ -271,7 +271,7 @@ public function testAllowDatesUpdateArrayUpdatesUpdatedAt(): void $expected .= '.000'; } - $this->assertSame($expected, $user['created_at']); // @phpstan-ignore offsetAccess.notFound + $this->assertSame($expected, $user['created_at']); $this->assertSame($expected, $user['updated_at']); } @@ -292,14 +292,13 @@ public function testAllowDatesUpdateEntityUpdatesUpdatedAt(): void ]; $id = $this->allowDatesPrepareOneRecord($data); $this->setPrivateProperty($this->model, 'returnType', User::class); - $this->setPrivateProperty($this->model, 'tempReturnType', User::class); - $user = $this->model->find($id); + $user = $this->model->asObject(User::class)->find($id); $user->country = 'CA'; $this->model->update($user->id, $user); - $user = $this->model->find($id); + $user = $this->model->asObject(User::class)->find($id); $this->assertSame('2000-01-01 12:00:00', (string) $user->created_at); // The Entity has `updated_at` value, but it will be discarded because of onlyChanged. diff --git a/tests/system/Models/UpdateModelTest.php b/tests/system/Models/UpdateModelTest.php index ed2ebf7a9a93..23279dc5b968 100644 --- a/tests/system/Models/UpdateModelTest.php +++ b/tests/system/Models/UpdateModelTest.php @@ -422,7 +422,6 @@ public function testUpdateSetObject(): void /** @var int|string $id */ $id = $this->model->insert($object); - /** @var stdClass $object */ $object = $this->model->find($id); $object->name = 'John Smith'; @@ -457,30 +456,27 @@ public function testUpdateSetEntity(): void public function testUpdateEntityWithPrimaryKeyCast(): void { - if ( - in_array($this->db->DBDriver, ['OCI8', 'Postgre', 'SQLSRV', 'SQLite3'], true) - ) { + if (in_array($this->db->DBDriver, ['OCI8', 'Postgre', 'SQLSRV', 'SQLite3'], true)) { $this->markTestSkipped($this->db->DBDriver . ' does not work with binary data as string data.'); } $this->createUuidTable(); - $this->createModel(UUIDPkeyModel::class); + $model = $this->createModel(UUIDPkeyModel::class); $entity = new UUID(); $entity->id = '550e8400-e29b-41d4-a716-446655440000'; $entity->value = 'test1'; - $id = $this->model->insert($entity); - $entity = $this->model->find($id); + $id = $model->insert($entity); + $entity = $model->find($id); $entity->value = 'id'; - $result = $this->model->save($entity); + $result = $model->save($entity); $this->assertTrue($result); - $entity = $this->model->find($id); - + $entity = $model->find($id); $this->assertSame('id', $entity->value); } @@ -716,7 +712,7 @@ public function testUpdateBatchWithCasts(): void ], ]; - $numRows = $this->model->updateBatch($updateData, 'id'); // @phpstan-ignore argument.type + $numRows = $this->model->updateBatch($updateData, 'id'); $this->assertSame(2, $numRows); $this->seeInDatabase('user', ['email' => json_encode($updateData[0]['email'])]); diff --git a/tests/system/Models/WhenWhenNotModelTest.php b/tests/system/Models/WhenWhenNotModelTest.php index 31015aa88bb1..aa9edffe9efe 100644 --- a/tests/system/Models/WhenWhenNotModelTest.php +++ b/tests/system/Models/WhenWhenNotModelTest.php @@ -40,9 +40,10 @@ public function testWhenWithTrueCondition(): void ]; $filter = 'foobar'; - $this->createModel(SecondaryModel::class)->insertBatch($secondaryData); + $model = $this->createModel(SecondaryModel::class); + $model->insertBatch($secondaryData); - $result = $this->model->when($filter, static function ($query, $filter): void { + $result = $model->when($filter, static function ($query, $filter): void { $query->where('value', $filter); })->find(); @@ -69,9 +70,10 @@ public function testWhenWithFalseConditionAndDefaultCallback(): void ]; $filter = ''; - $this->createModel(SecondaryModel::class)->insertBatch($secondaryData); + $model = $this->createModel(SecondaryModel::class); + $model->insertBatch($secondaryData); - $result = $this->model->when($filter, static function ($query, $filter): void { + $result = $model->when($filter, static function ($query, $filter): void { $query->where('value', $filter); }, static function ($query): void { $query->where('value', 'foobar'); @@ -100,9 +102,10 @@ public function testWhenNotWithFalseCondition(): void ]; $filter = ''; - $this->createModel(SecondaryModel::class)->insertBatch($secondaryData); + $model = $this->createModel(SecondaryModel::class); + $model->insertBatch($secondaryData); - $result = $this->model->whenNot($filter, static function ($query, $filter): void { + $result = $model->whenNot($filter, static function ($query, $filter): void { $query->where('value !=', 'foobar'); })->find(); @@ -129,9 +132,10 @@ public function testWhenNotWithTrueConditionAndDefaultCallback(): void ]; $filter = 'foobar'; - $this->createModel(SecondaryModel::class)->insertBatch($secondaryData); + $model = $this->createModel(SecondaryModel::class); + $model->insertBatch($secondaryData); - $result = $this->model->whenNot($filter, static function ($query, $filter): void { + $result = $model->whenNot($filter, static function ($query, $filter): void { $query->where('value !=', 'foobar'); }, static function ($query): void { $query->where('value', 'foobar'); diff --git a/tests/system/Security/CheckPhpIniTest.php b/tests/system/Security/CheckPhpIniTest.php index 939f69e4191a..272a06298240 100644 --- a/tests/system/Security/CheckPhpIniTest.php +++ b/tests/system/Security/CheckPhpIniTest.php @@ -17,6 +17,14 @@ use CodeIgniter\Test\StreamFilterTrait; use PHPUnit\Framework\Attributes\Group; +/** + * @return array>|false + */ +function ini_get_all(?string $extension = null, bool $details = true): array|false +{ + return CheckPhpIniTest::$iniGetAllReturn ?? \ini_get_all($extension, $details); +} + /** * @internal */ @@ -25,6 +33,18 @@ final class CheckPhpIniTest extends CIUnitTestCase { use StreamFilterTrait; + /** + * @var array>|null + */ + public static ?array $iniGetAllReturn = null; + + protected function tearDown(): void + { + parent::tearDown(); + + self::$iniGetAllReturn = null; + } + public function testCheckIni(): void { $output = self::getPrivateMethodInvoker(CheckPhpIni::class, 'checkIni')(); @@ -51,6 +71,26 @@ public function testCheckIniOpcache(): void $this->assertSame($expected, $output['opcache.save_comments']); } + public function testCheckIniCastsNullIniValuesToString(): void + { + self::$iniGetAllReturn = [ + 'default_charset' => [ + 'global_value' => null, + 'local_value' => null, + ], + ]; + + $output = self::getPrivateMethodInvoker(CheckPhpIni::class, 'checkIni')(); + + $expected = [ + 'global' => '', + 'current' => '', + 'recommended' => 'UTF-8', + 'remark' => '', + ]; + $this->assertSame($expected, $output['default_charset']); + } + public function testRunCli(): void { CheckPhpIni::run(true); diff --git a/tests/system/Session/Handlers/Database/RedisHandlerTest.php b/tests/system/Session/Handlers/Database/RedisHandlerTest.php index 2e7821446fe3..637132669aec 100644 --- a/tests/system/Session/Handlers/Database/RedisHandlerTest.php +++ b/tests/system/Session/Handlers/Database/RedisHandlerTest.php @@ -15,6 +15,8 @@ use CodeIgniter\Session\Handlers\RedisHandler; use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\TestLogger; +use Config\Logger as LoggerConfig; use Config\Session as SessionConfig; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; @@ -54,7 +56,10 @@ protected function getInstance($options = []): RedisHandler $sessionConfig->{$key} = $value; } - return new RedisHandler($sessionConfig, $this->userIpAddress); + $handler = new RedisHandler($sessionConfig, $this->userIpAddress); + $handler->setLogger(new TestLogger(new LoggerConfig())); + + return $handler; } protected function tearDown(): void @@ -359,4 +364,26 @@ public function testResetPersistentConnections(): void $handler1->close(); $handler2->close(); } + + public function testLockMaxRetries(): void + { + $options = [ + 'lockRetryInterval' => 10_000, // 10ms + 'lockMaxRetries' => 3, + ]; + + $handler1 = $this->getInstance($options); + $handler1->open($this->sessionSavePath, $this->sessionName); + $handler1->read('lock_test_session'); // Acquires lock + + $handler2 = $this->getInstance($options); + $handler2->open($this->sessionSavePath, $this->sessionName); + + // Before the fix, this would incorrectly return true (since $attempt === 3 !== 300). + // With the fix, it should return false after 3 attempts. + $this->assertFalse($handler2->read('lock_test_session')); + + $handler1->close(); + $handler2->close(); + } } diff --git a/tests/system/SuperglobalsTest.php b/tests/system/SuperglobalsTest.php index b7a2af0edcd7..de1fed832f3a 100644 --- a/tests/system/SuperglobalsTest.php +++ b/tests/system/SuperglobalsTest.php @@ -40,7 +40,7 @@ public function testServerGetSet(): void $this->superglobals->setServer('TEST_KEY', 'test_value'); $this->assertSame('test_value', $this->superglobals->server('TEST_KEY')); - $this->assertSame('test_value', $_SERVER['TEST_KEY']); + $this->assertSame('test_value', $_SERVER['TEST_KEY']); // @phpstan-ignore codeigniter.superglobalsOffsetAccess (checks the live superglobal, not the snapshot service) } public function testServerGetReturnsNullForNonExistent(): void @@ -108,7 +108,7 @@ public function testGetGetSet(): void $this->superglobals->setGet('test', 'value1'); $this->assertSame('value1', $this->superglobals->get('test')); - $this->assertSame('value1', $_GET['test']); + $this->assertSame('value1', $_GET['test']); // @phpstan-ignore codeigniter.superglobalsOffsetAccess (checks the live superglobal, not the snapshot service) } public function testGetReturnsNullForNonExistent(): void @@ -158,7 +158,7 @@ public function testPostGetSet(): void $this->superglobals->setPost('test', 'value1'); $this->assertSame('value1', $this->superglobals->post('test')); - $this->assertSame('value1', $_POST['test']); + $this->assertSame('value1', $_POST['test']); // @phpstan-ignore codeigniter.superglobalsOffsetAccess (checks the live superglobal, not the snapshot service) } public function testPostReturnsNullForNonExistent(): void @@ -208,7 +208,7 @@ public function testCookieGetSet(): void $this->superglobals->setCookie('session', 'abc123'); $this->assertSame('abc123', $this->superglobals->cookie('session')); - $this->assertSame('abc123', $_COOKIE['session']); + $this->assertSame('abc123', $_COOKIE['session']); // @phpstan-ignore codeigniter.superglobalsOffsetAccess (checks the live superglobal, not the snapshot service) } public function testCookieReturnsNullForNonExistent(): void @@ -258,7 +258,7 @@ public function testRequestGetSet(): void $this->superglobals->setRequest('test', 'value1'); $this->assertSame('value1', $this->superglobals->request('test')); - $this->assertSame('value1', $_REQUEST['test']); + $this->assertSame('value1', $_REQUEST['test']); // @phpstan-ignore codeigniter.superglobalsOffsetAccess (checks the live superglobal, not the snapshot service) } public function testRequestReturnsNullForNonExistent(): void @@ -475,11 +475,11 @@ public function testConstructorSynchronizesWithPhpSuperglobals(): void new Superglobals($server, $get, $post, $cookie, $files, $request); // Verify PHP superglobals are synchronized - $this->assertSame('server_val', $_SERVER['CUSTOM_SERVER']); - $this->assertSame('get_val', $_GET['custom_get']); - $this->assertSame('post_val', $_POST['custom_post']); - $this->assertSame('cookie_val', $_COOKIE['custom_cookie']); - $this->assertSame('request_val', $_REQUEST['custom_request']); + $this->assertSame('server_val', $_SERVER['CUSTOM_SERVER']); // @phpstan-ignore codeigniter.superglobalsOffsetAccess (checks the live superglobal, not the snapshot service) + $this->assertSame('get_val', $_GET['custom_get']); // @phpstan-ignore codeigniter.superglobalsOffsetAccess (checks the live superglobal, not the snapshot service) + $this->assertSame('post_val', $_POST['custom_post']); // @phpstan-ignore codeigniter.superglobalsOffsetAccess (checks the live superglobal, not the snapshot service) + $this->assertSame('cookie_val', $_COOKIE['custom_cookie']); // @phpstan-ignore codeigniter.superglobalsOffsetAccess (checks the live superglobal, not the snapshot service) + $this->assertSame('request_val', $_REQUEST['custom_request']); // @phpstan-ignore codeigniter.superglobalsOffsetAccess (checks the live superglobal, not the snapshot service) $this->assertSame($files, $_FILES); } diff --git a/tests/system/Test/BootstrapFCPATHTest.php b/tests/system/Test/BootstrapFCPATHTest.php index 2af9b87b560d..6b876006c3d1 100644 --- a/tests/system/Test/BootstrapFCPATHTest.php +++ b/tests/system/Test/BootstrapFCPATHTest.php @@ -100,7 +100,7 @@ private function fileContents(): string return $fileContents . 'echo FCPATH;' . PHP_EOL; } - private function readOutput(string $file): false|string + private function readOutput(string $file): string { ob_start(); system('php -f ' . $file); diff --git a/tests/system/Test/FeatureTestTraitTest.php b/tests/system/Test/FeatureTestTraitTest.php index f36767f7f971..79a00104a079 100644 --- a/tests/system/Test/FeatureTestTraitTest.php +++ b/tests/system/Test/FeatureTestTraitTest.php @@ -412,7 +412,7 @@ public function testCallGetWithParams(): void [ 'GET', 'home', - static fn () => json_encode(service('request')->getGet()), + static fn (): false|string => json_encode(service('request')->getGet()), ], ]); $data = [ @@ -439,7 +439,7 @@ public function testCallGetWithParamsAndREQUEST(): void [ 'GET', 'home', - static fn () => json_encode(service('request')->fetchGlobal('request')), + static fn (): false|string => json_encode(service('request')->fetchGlobal('request')), ], ]); $data = [ @@ -466,7 +466,7 @@ public function testCallPostWithParams(): void [ 'POST', 'home', - static fn () => json_encode(service('request')->getPost()), + static fn (): false|string => json_encode(service('request')->getPost()), ], ]); $data = [ @@ -493,7 +493,7 @@ public function testCallPostWithParamsAndREQUEST(): void [ 'POST', 'home', - static fn () => json_encode(service('request')->fetchGlobal('request')), + static fn (): false|string => json_encode(service('request')->fetchGlobal('request')), ], ]); $data = [ @@ -544,7 +544,7 @@ public function testCallPutWithJsonRequestAndREQUEST(): void [ 'PUT', 'home', - static fn () => json_encode(service('request')->fetchGlobal('request')), + static fn (): false|string => json_encode(service('request')->fetchGlobal('request')), ], ]); $data = [ diff --git a/tests/system/Test/Mock/MockInputOutputTest.php b/tests/system/Test/Mock/MockInputOutputTest.php new file mode 100644 index 000000000000..9ac7aefb547a --- /dev/null +++ b/tests/system/Test/Mock/MockInputOutputTest.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Test\Mock; + +use CodeIgniter\CLI\CLI; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\StreamFilterTrait; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class MockInputOutputTest extends CIUnitTestCase +{ + use StreamFilterTrait; + + protected function tearDown(): void + { + parent::tearDown(); + + CLI::resetInputOutput(); + } + + public function testFwriteThroughMockPreservesEnclosingStreamFilter(): void + { + CLI::write('before mock'); + $this->assertStringContainsString('before mock', $this->getStreamFilterBuffer()); + + $io = new MockInputOutput(); + CLI::setInputOutput($io); + CLI::write('through mock'); + CLI::resetInputOutput(); + + // The mock captured its own write into its own buffer... + $this->assertStringContainsString('through mock', $io->getOutput()); + // ...and left the enclosing StreamFilterTrait buffer untouched. + $this->assertStringNotContainsString('through mock', $this->getStreamFilterBuffer()); + + // The enclosing filter is still attached, so later writes are captured. + CLI::write('after mock'); + $this->assertStringContainsString('before mock', $this->getStreamFilterBuffer()); + $this->assertStringContainsString('after mock', $this->getStreamFilterBuffer()); + } + + public function testInputThroughMockPreservesEnclosingStreamFilter(): void + { + $io = new MockInputOutput(); + $io->setInputs(['y']); + CLI::setInputOutput($io); + CLI::prompt('Continue?', ['y', 'n']); + CLI::resetInputOutput(); + + CLI::write('after prompt'); + $this->assertStringContainsString('after prompt', $this->getStreamFilterBuffer()); + } +} diff --git a/tests/system/Test/TestCaseEmissionsTest.php b/tests/system/Test/TestCaseEmissionsTest.php index 8ebcdcc28d92..224121f60012 100644 --- a/tests/system/Test/TestCaseEmissionsTest.php +++ b/tests/system/Test/TestCaseEmissionsTest.php @@ -62,9 +62,7 @@ public function testHeadersEmitted(): void // send it ob_start(); $response->send(); - if (ob_get_level() > 0) { - ob_end_clean(); - } + ob_end_clean(); // and what actually got sent?; test both ways $this->assertHeaderEmitted('Set-Cookie: foo=bar;'); @@ -90,9 +88,7 @@ public function testHeadersNotEmitted(): void // send it ob_start(); $response->send(); // what really was sent - if (ob_get_level() > 0) { - ob_end_clean(); - } + ob_end_clean(); $this->assertHeaderNotEmitted('Set-Cookie: pop=corn', true); } diff --git a/tests/system/Validation/StrictRules/FileRulesTest.php b/tests/system/Validation/StrictRules/FileRulesTest.php index f7b5543b6f9f..5c58787b5f0b 100644 --- a/tests/system/Validation/StrictRules/FileRulesTest.php +++ b/tests/system/Validation/StrictRules/FileRulesTest.php @@ -266,6 +266,91 @@ public function testIsImage(): void $this->assertTrue($this->validation->run([])); } + public function testIsImageFailsForMismatchedClientExtension(): void + { + $payload = $this->createGifPayload(); + + try { + $this->setUploadedAvatar($payload, 'shell.php'); + + $this->validation->setRules(['avatar' => 'is_image[avatar]']); + $this->assertFalse($this->validation->run([])); + } finally { + unlink($payload); + } + } + + public function testIsImageFailsForNonImageClientExtension(): void + { + $payload = $this->createGifPayload(); + + try { + $this->setUploadedAvatar($payload, 'document.pdf'); + + $this->validation->setRules(['avatar' => 'is_image[avatar]']); + $this->assertFalse($this->validation->run([])); + } finally { + unlink($payload); + } + } + + public function testIsImageAllowsImageClientExtensionThatDoesNotMatchContent(): void + { + $payload = $this->createGifPayload(); + + try { + $this->setUploadedAvatar($payload, 'my-avatar.jpg'); + + $this->validation->setRules(['avatar' => 'is_image[avatar]']); + $this->assertTrue($this->validation->run([])); + } finally { + unlink($payload); + } + } + + public function testIsImageAllowsExtensionlessClientFilename(): void + { + $payload = $this->createGifPayload(); + + try { + $this->setUploadedAvatar($payload, 'blob'); + + $this->validation->setRules(['avatar' => 'is_image[avatar]']); + $this->assertTrue($this->validation->run([])); + } finally { + unlink($payload); + } + } + + public function testIsImageAllowsSvg(): void + { + $payload = $this->createSvgPayload(); + + try { + $this->setUploadedAvatar($payload, 'my-avatar.svg', 'image/svg+xml'); + + $this->validation->setRules(['avatar' => 'is_image[avatar]']); + $this->assertTrue($this->validation->run([])); + } finally { + unlink($payload); + } + } + + public function testIsImageFailsForNonImageContent(): void + { + $payload = $this->createPhpPayload(); + + try { + // An image extension is not enough; the file content must be an image too. + $this->setUploadedAvatar($payload, 'fake.gif'); + + $this->validation->setRules(['avatar' => 'is_image[avatar]']); + $this->assertFalse($this->validation->run([])); + } finally { + unlink($payload); + } + } + public function testIsntImage(): void { $_FILES['stuff'] = [ @@ -294,6 +379,62 @@ public function testMimeTypeOk(): void $this->assertTrue($this->validation->run([])); } + public function testMimeTypeFailsForMismatchedClientExtension(): void + { + $payload = $this->createGifPayload(); + + try { + $this->setUploadedAvatar($payload, 'shell.php'); + + $this->validation->setRules(['avatar' => 'mime_in[avatar,image/gif]']); + $this->assertFalse($this->validation->run([])); + } finally { + unlink($payload); + } + } + + public function testMimeTypeFailsForIncompatibleClientExtension(): void + { + $payload = $this->createGifPayload(); + + try { + $this->setUploadedAvatar($payload, 'document.pdf'); + + $this->validation->setRules(['avatar' => 'mime_in[avatar,image/gif]']); + $this->assertFalse($this->validation->run([])); + } finally { + unlink($payload); + } + } + + public function testMimeTypeFailsForAllowedClientExtensionThatDoesNotMatchContent(): void + { + $payload = $this->createGifPayload(); + + try { + $this->setUploadedAvatar($payload, 'my-avatar.jpg'); + + $this->validation->setRules(['avatar' => 'mime_in[avatar,image/gif,image/jpeg]']); + $this->assertFalse($this->validation->run([])); + } finally { + unlink($payload); + } + } + + public function testMimeTypeAllowsExtensionlessClientFilename(): void + { + $payload = $this->createGifPayload(); + + try { + $this->setUploadedAvatar($payload, 'blob'); + + $this->validation->setRules(['avatar' => 'mime_in[avatar,image/gif]']); + $this->assertTrue($this->validation->run([])); + } finally { + unlink($payload); + } + } + public function testMimeTypeNotOk(): void { $this->validation->setRules([ @@ -397,14 +538,34 @@ private function createGifPayload(): string return $payload; } - private function setUploadedAvatar(string $payload, string $name): void + private function createPhpPayload(): string + { + $payload = tempnam(sys_get_temp_dir(), 'ci4-upload-poc-'); + $this->assertIsString($payload); + + file_put_contents($payload, "\n"); + + return $payload; + } + + private function createSvgPayload(): string + { + $payload = tempnam(sys_get_temp_dir(), 'ci4-upload-poc-'); + $this->assertIsString($payload); + + file_put_contents($payload, ''); + + return $payload; + } + + private function setUploadedAvatar(string $payload, string $name, string $clientMimeType = 'image/gif'): void { service('superglobals')->setFilesArray([ 'avatar' => [ 'tmp_name' => $payload, 'name' => $name, 'size' => filesize($payload), - 'type' => 'image/gif', + 'type' => $clientMimeType, 'error' => UPLOAD_ERR_OK, ], ]); diff --git a/tests/system/Validation/ValidationTest.php b/tests/system/Validation/ValidationTest.php index 7e0f4411f079..7cf70deaeb27 100644 --- a/tests/system/Validation/ValidationTest.php +++ b/tests/system/Validation/ValidationTest.php @@ -1854,6 +1854,51 @@ public function testRequireWithoutWithAsterisk(): void ); } + /** + * Test that `required_without` checks all fields in dot-notation when there are multiple fields. + */ + public function testRequireWithoutMultipleWithAsterisk(): void + { + $data = [ + 'a' => [ + ['b' => 1, 'c' => 2, 'd' => ''], + ['b' => 1, 'c' => '', 'd' => ''], + ], + ]; + + $this->validation->setRules([ + 'a.*.d' => 'required_without[a.*.b,a.*.c]', + ])->run($data); + + $this->assertSame( + 'The a.*.d field is required when a.*.b,a.*.c is not present.', + $this->validation->getError('a.1.d'), + ); + } + + /** + * Test that `required_without` handles a non-asterisk field checked against an asterisk field + * without throwing undefined array key warnings for `$fieldSplitArray[1]`. + */ + public function testRequireWithoutAsteriskOnNonAsteriskField(): void + { + $data = [ + 'foo' => '', + 'a' => [ + ['b' => ''], + ], + ]; + + $this->validation->setRules([ + 'foo' => 'required_without[a.*.b]', + ])->run($data); + + $this->assertSame( + 'The foo field is required when a.*.b is not present.', + $this->validation->getError('foo'), + ); + } + /** * @see https://github.com/codeigniter4/CodeIgniter4/issues/8128 */ diff --git a/user_guide_src/source/changelogs/index.rst b/user_guide_src/source/changelogs/index.rst index b47fe1a7ffff..ca8f8c2cb715 100644 --- a/user_guide_src/source/changelogs/index.rst +++ b/user_guide_src/source/changelogs/index.rst @@ -12,6 +12,7 @@ See all the changes. .. toctree:: :titlesonly: + v4.7.4 v4.7.3 v4.7.2 v4.7.1 diff --git a/user_guide_src/source/changelogs/v4.7.4.rst b/user_guide_src/source/changelogs/v4.7.4.rst new file mode 100644 index 000000000000..48c90add3232 --- /dev/null +++ b/user_guide_src/source/changelogs/v4.7.4.rst @@ -0,0 +1,82 @@ +############# +Version 4.7.4 +############# + +Release Date: July 7, 2026 + +**4.7.4 release of CodeIgniter4** + +.. contents:: + :local: + :depth: 3 + +******** +SECURITY +******** + +- **IncomingRequest:** *HTTPS detection via client-supplied headers* was fixed. + ``IncomingRequest::isSecure()`` now trusts the ``X-Forwarded-Proto`` and + ``Front-End-Https`` headers only when the request comes from a trusted proxy + configured in ``Config\App::$proxyIPs``. + See the `Security advisory GHSA-7wmf-pw8j-mc78 `_ + for more information. + +- **Query Builder:** Fixed a SQL injection vulnerability in ``deleteBatch()``. + When ``deleteBatch()`` was used together with ``where()`` conditions, the + bound values from the WHERE clause were substituted into the generated SQL + with their escape flag ignored, so they were never escaped or quoted. The + WHERE binds are now escaped in the same way as a regular ``delete()``. + + See the `Security advisory GHSA-c9w5-rwh3-7pm9 `_ + for more information. + +- **UploadedFile:** ``UploadedFile::move()`` now sanitizes the client-provided + filename when called without a second argument. Previously, the unsanitized + client filename was used as the default, allowing path traversal sequences + (e.g. ``../../public/shell.php``) to write the uploaded file outside the + intended directory. A name explicitly passed as the second argument is not + sanitized and remains the caller's responsibility. + See the `Security advisory GHSA-hhmc-q9hp-r662 `_ + for more information. + +- **Validation:** The ``is_image`` and ``mime_in`` file upload validation rules + now also verify non-empty client filename extensions. Previously, these rules + classified an upload solely by its content-derived MIME type, so a file with a + dangerous client extension (for example, a ``.php`` file prepended with image + magic bytes) could pass validation while keeping its original extension on + disk. + See the `Security advisory GHSA-mmj4-63m4-r6h5 `_ + for more information. + + The ``is_image`` rule now rejects uploads when a non-empty client filename + extension is not an image extension. The ``mime_in`` rule now rejects uploads + when a non-empty client filename extension does not match the detected file + content, matching the same extension/content agreement used by ``ext_in``. + Files uploaded without any extension (such as JavaScript ``Blob`` uploads) + are still accepted, since the rules validate the file content. + +********** +Bugs Fixed +********** + +- **API:** Fixed a bug in Transformers where the root request's ``fields`` and ``include`` query parameters leaked into nested transformers created inside ``include*()`` methods, causing incorrect field filtering, unexpected includes, or infinite recursion. +- **Commands:** Fixed a bug where ``make:model --return entity`` did not preserve sub-namespaces when generating the related Entity class. +- **Common:** Fixed a bug in ``env()`` where a ``TypeError`` could be thrown when non-string values were passed. +- **Common:** Fixed ``esc()`` to propagate encoding correctly and prevent reference leaks. +- **Commands:** Fixed a bug where ``spark lang:find`` treated translation keys already provided by the framework or another namespace (such as ``Errors.*`` in ``system/Language``) as new, listing them under ``--show-new`` and writing untranslated placeholders into ``app/Language`` that overrode the existing translations. +- **Config:** Fixed a bug where ``BaseService::injectMock`` did not apply ``strtolower`` consistently, causing inconsistent Service and Mock registration and resolution. +- **Database:** Fixed a bug where ``updateBatch()`` could be called after Query Builder ``where()`` conditions, even though it's not supported. In this situation, now the ``DatabaseException`` is thrown. +- **Database:** Fixed a bug in ``BaseUtils::getXMLFromResult()`` where database values of ``0`` or ``'0'`` were treated as empty and omitted from the generated XML export. +- **Encryption:** Fixed bugs in ``SodiumHandler`` where runtime ``blockSize`` overrides without ``key`` were handled incorrectly, invalid key lengths could leak native Sodium errors, and mismatched decrypt ``blockSize`` values could throw ``SodiumException`` instead of ``EncryptionException``. +- **Filters:** Fixed a bug in ``InvalidChars`` filter where invalid UTF-8 or control characters in array keys were not checked. +- **HTTP:** Fixed a bug where ``IncomingRequest::getPostGet()`` and ``IncomingRequest::getGetPost()`` threw a ``TypeError`` when passed an array of indexes. +- **HTTP:** Fixed a bug where the User Agent library reported Safari's WebKit version instead of the browser version from the ``Version`` token. +- **Model:** Fixed a bug in ``Model::objectToRawArray()`` where the ``$recursive`` parameter was ignored. +- **Security:** Fixed a bug where ``CheckPhpIni`` could raise a type error when ``ini_get_all()`` returned ``null`` for a configured directive value. +- **Session:** Fixed a bug in ``RedisHandler`` where the configured ``$lockMaxRetries`` and ``$lockRetryInterval`` values were not respected when acquiring session locks. +- **Testing:** Fixed a bug where using ``MockInputOutput`` within a test that also uses ``StreamFilterTrait`` tore down the trait's stream filters, so CLI output produced after the ``MockInputOutput`` interaction (such as in ``tearDown()``) was no longer captured and leaked to the console. +- **Validation:** Fixed bugs in the ``required_without`` rule logic where using array dot notation caused early exits ignoring subsequent fields and triggered an ``Undefined array key`` warning for missing keys. + +See the repo's +`CHANGELOG.md `_ +for a complete list of bugs fixed. diff --git a/user_guide_src/source/conf.py b/user_guide_src/source/conf.py index d7f994c7a943..820b3acd54a7 100644 --- a/user_guide_src/source/conf.py +++ b/user_guide_src/source/conf.py @@ -26,7 +26,7 @@ version = '4.7' # The full version, including alpha/beta/rc tags. -release = '4.7.3' +release = '4.7.4' # -- General configuration --------------------------------------------------- diff --git a/user_guide_src/source/database/query_builder.rst b/user_guide_src/source/database/query_builder.rst index 480b8c18d92e..b0ca8aa1fb5c 100644 --- a/user_guide_src/source/database/query_builder.rst +++ b/user_guide_src/source/database/query_builder.rst @@ -1230,7 +1230,8 @@ Generates a batch **DELETE** statement based on a set of data. This method may be especially useful when deleting data in a table with a composite primary key. -.. note:: SQLite3 does not support the use of ``where()``. +.. note:: The SQLite3 driver does not support additional Query Builder WHERE + conditions for ``deleteBatch()``. Delete from a Query ^^^^^^^^^^^^^^^^^^^ diff --git a/user_guide_src/source/incoming/incomingrequest.rst b/user_guide_src/source/incoming/incomingrequest.rst index a96353dadfba..99c5762129d6 100644 --- a/user_guide_src/source/incoming/incomingrequest.rst +++ b/user_guide_src/source/incoming/incomingrequest.rst @@ -76,6 +76,17 @@ You can also check if the request was made through and HTTPS connection with the .. literalinclude:: incomingrequest/006.php +.. important:: The ``X-Forwarded-Proto`` and ``Front-End-Https`` headers are taken + into account only when the request comes from a trusted proxy configured in + ``Config\App::$proxyIPs``. Otherwise, they are client-supplied and could be + spoofed. + +.. note:: On dual-stack servers, a proxy's IPv4 address may be reported as an + IPv4-mapped IPv6 address (e.g., ``::ffff:192.168.5.21``), which does not + match an IPv4 entry such as ``192.168.5.0/24``. In that case, add the + IPv4-mapped form to ``Config\App::$proxyIPs``, e.g., + ``::ffff:192.168.5.21`` or ``::ffff:192.168.5.0/120``. + Retrieving Input **************** @@ -351,6 +362,10 @@ The methods provided by the parent classes that are available are: :returns: True if the request is an HTTPS request, otherwise false. :rtype: bool + .. important:: The ``X-Forwarded-Proto`` and ``Front-End-Https`` headers + are taken into account only when the request comes from a trusted + proxy configured in ``Config\App::$proxyIPs``. + .. php:method:: getVar([$index = null[, $filter = null[, $flags = null]]]) :param string $index: The name of the variable/key to look for. @@ -369,7 +384,7 @@ The methods provided by the parent classes that are available are: .. php:method:: getGet([$index = null[, $filter = null[, $flags = null]]]) - :param string $index: The name of the variable/key to look for. + :param array|string $index: The name of the variable/key to look for, or an array of names. :param int $filter: The type of filter to apply. A list of filters can be found in `Types of filters `__. :param int $flags: Flags to apply. A list of flags can be found in @@ -408,7 +423,7 @@ The methods provided by the parent classes that are available are: .. php:method:: getPost([$index = null[, $filter = null[, $flags = null]]]) - :param string $index: The name of the variable/key to look for. + :param array|string $index: The name of the variable/key to look for, or an array of names. :param int $filter: The type of filter to apply. A list of filters can be found `here `__. :param int $flags: Flags to apply. A list of flags can be found @@ -420,7 +435,7 @@ The methods provided by the parent classes that are available are: .. php:method:: getPostGet([$index = null[, $filter = null[, $flags = null]]]) - :param string $index: The name of the variable/key to look for. + :param array|string $index: The name of the variable/key to look for, or an array of names. :param int $filter: The type of filter to apply. A list of filters can be found in `Types of filters `__. :param int $flags: Flags to apply. A list of flags can be found in @@ -435,12 +450,15 @@ The methods provided by the parent classes that are available are: .. literalinclude:: incomingrequest/032.php + If an array of indexes is specified, the method returns an associative array and applies + the POST-then-GET lookup order to each index. + If no index is specified, it will return both POST and GET streams combined. Although POST data will be preferred in case of name conflict. .. php:method:: getGetPost([$index = null[, $filter = null[, $flags = null]]]) - :param string $index: The name of the variable/key to look for. + :param array|string $index: The name of the variable/key to look for, or an array of names. :param int $filter: The type of filter to apply. A list of filters can be found in `Types of filters `__. :param int $flags: Flags to apply. A list of flags can be found in @@ -455,6 +473,9 @@ The methods provided by the parent classes that are available are: .. literalinclude:: incomingrequest/033.php + If an array of indexes is specified, the method returns an associative array and applies + the GET-then-POST lookup order to each index. + If no index is specified, it will return both GET and POST streams combined. Although GET data will be preferred in case of name conflict. @@ -519,4 +540,3 @@ The methods provided by the parent classes that are available are: .. note:: Prior to v4.4.0, this was the safest method to determine the "current URI", since ``IncomingRequest::$uri`` might not be aware of the complete App configuration for base URLs. - diff --git a/user_guide_src/source/installation/upgrade_412.rst b/user_guide_src/source/installation/upgrade_412.rst index 1fec935f7ec6..3d3f0aa4051a 100644 --- a/user_guide_src/source/installation/upgrade_412.rst +++ b/user_guide_src/source/installation/upgrade_412.rst @@ -143,9 +143,6 @@ many will be simple comments or formatting that have no effect on the runtime: * ``composer.json`` * ``contributing/guidelines.rst`` * ``env`` -* ``phpstan.neon.dist`` -* ``phpunit.xml.dist`` * ``public/.htaccess`` * ``public/index.php`` -* ``rector.php`` * ``spark`` diff --git a/user_guide_src/source/installation/upgrade_474.rst b/user_guide_src/source/installation/upgrade_474.rst new file mode 100644 index 000000000000..dd236de98eac --- /dev/null +++ b/user_guide_src/source/installation/upgrade_474.rst @@ -0,0 +1,74 @@ +############################# +Upgrading from 4.7.3 to 4.7.4 +############################# + +Please refer to the upgrade instructions corresponding to your installation method. + +- :ref:`Composer Installation App Starter Upgrading ` +- :ref:`Composer Installation Adding CodeIgniter4 to an Existing Project Upgrading ` +- :ref:`Manual Installation Upgrading ` + +.. contents:: + :local: + :depth: 2 + +**************** +Breaking Changes +**************** + +HTTPS Detection Behind Proxies +============================== + +For security reasons, ``IncomingRequest::isSecure()`` no longer trusts the +``X-Forwarded-Proto`` and ``Front-End-Https`` headers unless the request comes +from a trusted proxy. See the +`Security advisory GHSA-7wmf-pw8j-mc78 `_ +for more information. + +If your application runs behind a reverse proxy or load balancer that +terminates TLS, you must register the proxy in ``Config\App::$proxyIPs``, e.g.:: + + public array $proxyIPs = [ + '10.0.1.200' => 'X-Forwarded-For', + '192.168.5.0/24' => 'X-Forwarded-For', + ]; + +Otherwise, ``isSecure()``, ``force_https()``, and +``Config\App::$forceGlobalSecureRequests`` will treat such requests as +non-HTTPS, which may result in redirect loops. + +.. note:: On dual-stack servers, a proxy's IPv4 address may be reported as an + IPv4-mapped IPv6 address (e.g., ``::ffff:192.168.5.21``), which does not + match an IPv4 entry such as ``192.168.5.0/24``. In that case, add the + IPv4-mapped form to ``Config\App::$proxyIPs``, e.g., + ``::ffff:192.168.5.21`` or ``::ffff:192.168.5.0/120``. + +************* +Project Files +************* + +Some files in the **project space** (root, app, public, writable) received updates. Due to +these files being outside of the **system** scope they will not be changed without your intervention. + +.. note:: There are some third-party CodeIgniter modules available to assist + with merging changes to the project space: + `Explore on Packagist `_. + +Content Changes +=============== + +The following files received significant changes (including deprecations or visual adjustments) +and it is recommended that you merge the updated versions with your application: + +Config +------ + +- No config files were changed in this release. + +All Changes +=========== + +This is a list of all files in the **project space** that received changes; +many will be simple comments or formatting that have no effect on the runtime: + +- No project files were changed in this release. diff --git a/user_guide_src/source/installation/upgrading.rst b/user_guide_src/source/installation/upgrading.rst index 64dd632b1d1d..dc9199da84ca 100644 --- a/user_guide_src/source/installation/upgrading.rst +++ b/user_guide_src/source/installation/upgrading.rst @@ -22,6 +22,7 @@ Alternatively, replace it with a new file and add your previous lines. backward_compatibility_notes + upgrade_474 upgrade_473 upgrade_472 upgrade_471 diff --git a/user_guide_src/source/libraries/images.rst b/user_guide_src/source/libraries/images.rst index 8f039c9561ec..2bffd2b9a122 100644 --- a/user_guide_src/source/libraries/images.rst +++ b/user_guide_src/source/libraries/images.rst @@ -42,6 +42,14 @@ The available Handlers are as follows: On Windows, the ImageMagick handler requires **absolute file paths** when loading images (for example, using ``WRITEPATH`` or ``FCPATH``). +.. warning:: + Do not let user input directly decide the image source path, storage + directory, or filename. This includes values passed to methods like + ``save()``, ``copy()`` and others, and any path or filename used to store + processed images. Use directories controlled by your application, and + generate filenames yourself or sanitize them with + :php:func:`sanitize_filename`. + ******************* Processing an Image ******************* @@ -91,6 +99,10 @@ only applies to JPEG and WebP images, will be ignored otherwise: .. note:: The parameter ``$quality`` for WebP can be used since v4.4.0. +.. note:: When using ImageMagick, the target path passed to ``save()`` determines the output format by its extension. + ImageMagick may also interpret explicit format prefixes like ``jpg:filename.png``. If the target path is based on user + input, validate it against the formats your application allows, including any format prefix or extension. + .. literalinclude:: images/005.php .. note:: Higher quality will result in larger file sizes. See also https://www.php.net/manual/en/function.imagejpeg.php @@ -153,8 +165,7 @@ The ``convert()`` method changes the library's internal indicator for the desire .. literalinclude:: images/009.php -.. note:: ImageMagick already saves files in the type - indicated by their extension, ignoring ``$imageType``. +.. note:: ImageMagick already saves files in the type indicated by their extension, ignoring ``$imageType``. Fitting Images ============== diff --git a/user_guide_src/source/libraries/official_packages.rst b/user_guide_src/source/libraries/official_packages.rst index ccadc81599bc..b13f3c5d6930 100644 --- a/user_guide_src/source/libraries/official_packages.rst +++ b/user_guide_src/source/libraries/official_packages.rst @@ -41,18 +41,18 @@ while defaulting to the config files when not custom value has been stored. This an application to ship with the default config values, but adapt as the project grows or moves servers, without having to touch the code. -************ -Tasks (BETA) -************ +***** +Tasks +***** `CodeIgniter Tasks `_ is a simple task scheduler for CodeIgniter 4. It allows you to schedule tasks to run at specific times, or on a recurring basis. It is designed to be simple to use, but flexible enough to handle most use cases. -************ -Queue (BETA) -************ +***** +Queue +***** `CodeIgniter Queue `_ is a simple queue system for CodeIgniter 4. It allows you to queue up tasks to be run later. diff --git a/user_guide_src/source/libraries/uploaded_files.rst b/user_guide_src/source/libraries/uploaded_files.rst index 7cc3084d306f..eadc1d8ef2e9 100644 --- a/user_guide_src/source/libraries/uploaded_files.rst +++ b/user_guide_src/source/libraries/uploaded_files.rst @@ -339,7 +339,7 @@ the file to as the first parameter: .. literalinclude:: uploaded_files/016.php -By default, the original filename was used. +By default, the client-provided filename is sanitized and used. with New Filename ----------------- diff --git a/user_guide_src/source/outgoing/api_transformers.rst b/user_guide_src/source/outgoing/api_transformers.rst index e462b588c159..38ce1de1fc14 100644 --- a/user_guide_src/source/outgoing/api_transformers.rst +++ b/user_guide_src/source/outgoing/api_transformers.rst @@ -72,7 +72,7 @@ The ``make:transformer`` command supports several options: .. code-block:: console - php spark make:transformer User --namespace="MyCompany\\API" + php spark make:transformer User --namespace MyCompany\API **--force** Forces overwriting an existing file: @@ -174,6 +174,14 @@ The response would include: ] } +.. note:: The ``fields`` and ``include`` query parameters describe the **root** resource only. + Transformers instantiated inside an ``include*()`` method (such as ``PostTransformer`` above) + are treated as nested resources: when created **without an explicit request** they do **not** + inherit the root request's ``fields``/``include`` state. This prevents the parent's query + parameters from leaking into related resources, which could otherwise cause incorrect field + filtering or unexpected/recursive includes. If you deliberately pass a request to a nested + transformer, that request's scope is honored. + Restricting Available Includes =============================== @@ -262,6 +270,13 @@ Class Reference Initializes the transformer and extracts the ``fields`` and ``include`` query parameters from the request. + .. note:: When no request is explicitly passed, the global request's ``fields`` and ``include`` + are read **only for the root transformer**. A transformer constructed during a nested + transformation (i.e. inside an ``include*()`` method) without an explicit request still uses + the global request object, but does not read ``fields``/``include`` from it, to avoid leaking + the root's scope. Passing an explicit request always applies that request's scope, regardless + of nesting. + .. php:method:: toArray(mixed $resource) :param mixed $resource: The resource being transformed (Entity, array, object, or null) diff --git a/user_guide_src/source/outgoing/localization.rst b/user_guide_src/source/outgoing/localization.rst index 4d9facce4bfa..c73cf9449d00 100644 --- a/user_guide_src/source/outgoing/localization.rst +++ b/user_guide_src/source/outgoing/localization.rst @@ -317,6 +317,10 @@ After the operation, you need to translate the language keys yourself. The command is able to recognize nested keys normally ``File.array.nested.text``. Previously saved keys do not change. +.. note:: Keys that ``lang()`` can already resolve (for example ``Errors.*`` + from **system/Language**) are treated as already translated, so they are + neither listed by ``--show-new`` nor written into **app/Language**. + .. code-block:: console php spark lang:find diff --git a/utils/phpstan-baseline/argument.type.neon b/utils/phpstan-baseline/argument.type.neon index 9697915545a3..bbd0685fcf76 100644 --- a/utils/phpstan-baseline/argument.type.neon +++ b/utils/phpstan-baseline/argument.type.neon @@ -1,4 +1,4 @@ -# total 78 errors +# total 73 errors parameters: ignoreErrors: @@ -177,21 +177,6 @@ parameters: count: 1 path: ../../tests/system/Log/Handlers/ChromeLoggerHandlerTest.php - - - message: '#^Parameter \#1 \$row of method CodeIgniter\\BaseModel\:\:save\(\) expects array\\|object, array\\> given\.$#' - count: 1 - path: ../../tests/system/Models/DataConverterModelTest.php - - - - message: '#^Parameter \#1 \$row of method CodeIgniter\\Model\:\:insert\(\) expects array\\|object\|null, array\\|string\> given\.$#' - count: 3 - path: ../../tests/system/Models/DataConverterModelTest.php - - - - message: '#^Parameter \#2 \$row of method CodeIgniter\\Model\:\:update\(\) expects array\\|object\|null, array\\> given\.$#' - count: 1 - path: ../../tests/system/Models/DataConverterModelTest.php - - message: '#^Parameter \#1 \$format of method CodeIgniter\\RESTful\\ResourceController\:\:setFormat\(\) expects ''json''\|''xml'', ''Nonsense'' given\.$#' count: 1 diff --git a/utils/phpstan-baseline/codeigniter.getReassignArray.neon b/utils/phpstan-baseline/codeigniter.getReassignArray.neon deleted file mode 100644 index f663c67ee50e..000000000000 --- a/utils/phpstan-baseline/codeigniter.getReassignArray.neon +++ /dev/null @@ -1,18 +0,0 @@ -# total 3 errors - -parameters: - ignoreErrors: - - - message: '#^Re\-assigning arrays to \$_GET directly is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/IncomingRequestTest.php - - - - message: '#^Re\-assigning arrays to \$_GET directly is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php - - - - message: '#^Re\-assigning arrays to \$_GET directly is discouraged\.$#' - count: 1 - path: ../../tests/system/Helpers/FormHelperTest.php diff --git a/utils/phpstan-baseline/codeigniter.superglobalAccess.neon b/utils/phpstan-baseline/codeigniter.superglobalAccess.neon deleted file mode 100644 index 68f9316f8ee1..000000000000 --- a/utils/phpstan-baseline/codeigniter.superglobalAccess.neon +++ /dev/null @@ -1,123 +0,0 @@ -# total 29 errors - -parameters: - ignoreErrors: - - - message: '#^Accessing offset ''ANSICON'' directly on \$_SERVER is discouraged\.$#' - count: 1 - path: ../../system/CLI/CLI.php - - - - message: '#^Accessing offset ''NO_COLOR'' directly on \$_SERVER is discouraged\.$#' - count: 1 - path: ../../system/CLI/CLI.php - - - - message: '#^Accessing offset ''encryption\.key'' directly on \$_SERVER is discouraged\.$#' - count: 1 - path: ../../system/Commands/Encryption/GenerateKey.php - - - - message: '#^Accessing offset ''REMOTE_ADDR'' directly on \$_SERVER is discouraged\.$#' - count: 1 - path: ../../system/Common.php - - - - message: '#^Accessing offset ''REQUEST_METHOD'' directly on \$_SERVER is discouraged\.$#' - count: 1 - path: ../../system/Common.php - - - - message: '#^Accessing offset string directly on \$_SERVER is discouraged\.$#' - count: 1 - path: ../../system/Common.php - - - - message: '#^Accessing offset ''CI_ENVIRONMENT'' directly on \$_SERVER is discouraged\.$#' - count: 2 - path: ../../system/Config/AutoloadConfig.php - - - - message: '#^Accessing offset non\-falsy\-string directly on \$_SERVER is discouraged\.$#' - count: 4 - path: ../../system/Config/BaseConfig.php - - - - message: '#^Accessing offset string directly on \$_SERVER is discouraged\.$#' - count: 2 - path: ../../system/Config/DotEnv.php - - - - message: '#^Accessing offset string directly on \$_GET is discouraged\.$#' - count: 1 - path: ../../system/Superglobals.php - - - - message: '#^Accessing offset string directly on \$_SERVER is discouraged\.$#' - count: 1 - path: ../../system/Superglobals.php - - - - message: '#^Accessing offset ''foo'' directly on \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/CommonFunctionsSendTest.php - - - - message: '#^Accessing offset ''foo'' directly on \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/CommonFunctionsTest.php - - - - message: '#^Accessing offset ''BAR'' directly on \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/Config/DotEnvTest.php - - - - message: '#^Accessing offset ''FOO'' directly on \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/Config/DotEnvTest.php - - - - message: '#^Accessing offset ''NULL'' directly on \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/Config/DotEnvTest.php - - - - message: '#^Accessing offset ''SPACED'' directly on \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/Config/DotEnvTest.php - - - - message: '#^Accessing offset ''SimpleConfig_simple_name'' directly on \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/Config/DotEnvTest.php - - - - message: '#^Accessing offset ''CODEIGNITER_SCREAM_DEPRECATIONS'' directly on \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/Debug/ExceptionsTest.php - - - - message: '#^Accessing offset ''QUERY_STRING'' directly on \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php - - - - message: '#^Accessing offset ''CUSTOM_SERVER'' directly on \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/SuperglobalsTest.php - - - - message: '#^Accessing offset ''TEST_KEY'' directly on \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/SuperglobalsTest.php - - - - message: '#^Accessing offset ''custom_get'' directly on \$_GET is discouraged\.$#' - count: 1 - path: ../../tests/system/SuperglobalsTest.php - - - - message: '#^Accessing offset ''test'' directly on \$_GET is discouraged\.$#' - count: 1 - path: ../../tests/system/SuperglobalsTest.php diff --git a/utils/phpstan-baseline/codeigniter.superglobalAccessAssign.neon b/utils/phpstan-baseline/codeigniter.superglobalAccessAssign.neon deleted file mode 100644 index ab0d015d5677..000000000000 --- a/utils/phpstan-baseline/codeigniter.superglobalAccessAssign.neon +++ /dev/null @@ -1,28 +0,0 @@ -# total 5 errors - -parameters: - ignoreErrors: - - - message: '#^Assigning string directly on offset string of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../system/Config/DotEnv.php - - - - message: '#^Assigning ''1'' directly on offset ''CODEIGNITER_SCREAM_DEPRECATIONS'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/Debug/ExceptionsTest.php - - - - message: '#^Assigning ''test'' directly on offset ''HTTPS'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/HomeTest.php - - - - message: '#^Assigning ''test'' directly on offset ''HTTPS'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/Test/FeatureTestAutoRoutingImprovedTest.php - - - - message: '#^Assigning ''test'' directly on offset ''HTTPS'' of \$_SERVER is discouraged\.$#' - count: 1 - path: ../../tests/system/Test/FeatureTestTraitTest.php diff --git a/utils/phpstan-baseline/empty.notAllowed.neon b/utils/phpstan-baseline/empty.notAllowed.neon index 0f92e89f912e..1d8d1796b5c2 100644 --- a/utils/phpstan-baseline/empty.notAllowed.neon +++ b/utils/phpstan-baseline/empty.notAllowed.neon @@ -1,4 +1,4 @@ -# total 214 errors +# total 213 errors parameters: ignoreErrors: @@ -34,7 +34,7 @@ parameters: - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - count: 28 + count: 27 path: ../../system/Database/BaseBuilder.php - @@ -54,7 +54,7 @@ parameters: - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - count: 4 + count: 3 path: ../../system/Database/BaseUtils.php - diff --git a/utils/phpstan-baseline/function.alreadyNarrowedType.neon b/utils/phpstan-baseline/function.alreadyNarrowedType.neon deleted file mode 100644 index 3ca000aeecb5..000000000000 --- a/utils/phpstan-baseline/function.alreadyNarrowedType.neon +++ /dev/null @@ -1,13 +0,0 @@ -# total 2 errors - -parameters: - ignoreErrors: - - - message: '#^Call to function property_exists\(\) with \$this\(class@anonymous/tests/system/API/ResponseTraitTest\.php\:198\) and ''stringAsHtml'' will always evaluate to true\.$#' - count: 1 - path: ../../tests/system/API/ResponseTraitTest.php - - - - message: '#^Call to function property_exists\(\) with \$this\(class@anonymous/tests/system/API/ResponseTraitTest\.php\:318\) and ''stringAsHtml'' will always evaluate to true\.$#' - count: 1 - path: ../../tests/system/API/ResponseTraitTest.php diff --git a/utils/phpstan-baseline/function.impossibleType.neon b/utils/phpstan-baseline/function.impossibleType.neon deleted file mode 100644 index f13d9a4010c2..000000000000 --- a/utils/phpstan-baseline/function.impossibleType.neon +++ /dev/null @@ -1,18 +0,0 @@ -# total 3 errors - -parameters: - ignoreErrors: - - - message: '#^Call to function property_exists\(\) with \$this\(CodeIgniter\\Debug\\ExceptionHandler\) and ''stringAsHtml'' will always evaluate to false\.$#' - count: 1 - path: ../../system/Debug/ExceptionHandler.php - - - - message: '#^Call to function property_exists\(\) with \$this\(class@anonymous/tests/system/API/ResponseTraitTest\.php\:140\) and ''stringAsHtml'' will always evaluate to false\.$#' - count: 1 - path: ../../tests/system/API/ResponseTraitTest.php - - - - message: '#^Call to function property_exists\(\) with \$this\(class@anonymous/tests/system/API/ResponseTraitTest\.php\:638\) and ''stringAsHtml'' will always evaluate to false\.$#' - count: 1 - path: ../../tests/system/API/ResponseTraitTest.php diff --git a/utils/phpstan-baseline/function.resultUnused.neon b/utils/phpstan-baseline/function.resultUnused.neon new file mode 100644 index 000000000000..7f8539d49820 --- /dev/null +++ b/utils/phpstan-baseline/function.resultUnused.neon @@ -0,0 +1,13 @@ +# total 2 errors + +parameters: + ignoreErrors: + - + message: '#^Call to function d\(\) on a separate line has no effect\.$#' + count: 1 + path: ../../system/Commands/Utilities/ConfigCheck.php + + - + message: '#^Call to function d\(\) on a separate line has no effect\.$#' + count: 1 + path: ../../tests/system/CommonFunctionsTest.php diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index 815fa2376cc0..bf2719972413 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,17 +1,13 @@ -# total 2015 errors +# total 1827 errors includes: - argument.type.neon - arguments.count.neon - assign.propertyType.neon - - codeigniter.getReassignArray.neon - codeigniter.modelArgumentType.neon - - codeigniter.superglobalAccess.neon - - codeigniter.superglobalAccessAssign.neon - deadCode.unreachable.neon - empty.notAllowed.neon - - function.alreadyNarrowedType.neon - - function.impossibleType.neon + - function.resultUnused.neon - method.alreadyNarrowedType.neon - method.childParameterType.neon - method.childReturnType.neon diff --git a/utils/phpstan-baseline/method.childParameterType.neon b/utils/phpstan-baseline/method.childParameterType.neon index d5b6014b15a6..21752412ee34 100644 --- a/utils/phpstan-baseline/method.childParameterType.neon +++ b/utils/phpstan-baseline/method.childParameterType.neon @@ -1,4 +1,4 @@ -# total 12 errors +# total 11 errors parameters: ignoreErrors: @@ -18,27 +18,22 @@ parameters: path: ../../system/Database/BaseResult.php - - message: '#^Parameter \#1 \$selectOverride \(bool\) of method CodeIgniter\\Database\\SQLSRV\\Builder\:\:compileSelect\(\) should be contravariant with parameter \$selectOverride \(mixed\) of method CodeIgniter\\Database\\BaseBuilder\:\:compileSelect\(\)$#' - count: 1 - path: ../../system/Database/SQLSRV/Builder.php - - - - message: '#^Parameter \#1 \$value \(bool\|int\|string\) of method CodeIgniter\\Entity\\Cast\\IntBoolCast\:\:set\(\) should be contravariant with parameter \$value \(array\|bool\|float\|int\|object\|string\|null\) of method CodeIgniter\\Entity\\Cast\\BaseCast\:\:set\(\)$#' + message: '#^Parameter \#1 \$value \(bool\|int\|string\) of method CodeIgniter\\Entity\\Cast\\IntBoolCast\:\:set\(\) should be contravariant with parameter \$value \(mixed\) of method CodeIgniter\\Entity\\Cast\\BaseCast\:\:set\(\)$#' count: 1 path: ../../system/Entity/Cast/IntBoolCast.php - - message: '#^Parameter \#1 \$value \(bool\|int\|string\) of method CodeIgniter\\Entity\\Cast\\IntBoolCast\:\:set\(\) should be contravariant with parameter \$value \(array\|bool\|float\|int\|object\|string\|null\) of method CodeIgniter\\Entity\\Cast\\CastInterface\:\:set\(\)$#' + message: '#^Parameter \#1 \$value \(bool\|int\|string\) of method CodeIgniter\\Entity\\Cast\\IntBoolCast\:\:set\(\) should be contravariant with parameter \$value \(mixed\) of method CodeIgniter\\Entity\\Cast\\CastInterface\:\:set\(\)$#' count: 1 path: ../../system/Entity/Cast/IntBoolCast.php - - message: '#^Parameter \#1 \$value \(int\) of method CodeIgniter\\Entity\\Cast\\IntBoolCast\:\:get\(\) should be contravariant with parameter \$value \(array\|bool\|float\|int\|object\|string\|null\) of method CodeIgniter\\Entity\\Cast\\BaseCast\:\:get\(\)$#' + message: '#^Parameter \#1 \$value \(int\) of method CodeIgniter\\Entity\\Cast\\IntBoolCast\:\:get\(\) should be contravariant with parameter \$value \(mixed\) of method CodeIgniter\\Entity\\Cast\\BaseCast\:\:get\(\)$#' count: 1 path: ../../system/Entity/Cast/IntBoolCast.php - - message: '#^Parameter \#1 \$value \(int\) of method CodeIgniter\\Entity\\Cast\\IntBoolCast\:\:get\(\) should be contravariant with parameter \$value \(array\|bool\|float\|int\|object\|string\|null\) of method CodeIgniter\\Entity\\Cast\\CastInterface\:\:get\(\)$#' + message: '#^Parameter \#1 \$value \(int\) of method CodeIgniter\\Entity\\Cast\\IntBoolCast\:\:get\(\) should be contravariant with parameter \$value \(mixed\) of method CodeIgniter\\Entity\\Cast\\CastInterface\:\:get\(\)$#' count: 1 path: ../../system/Entity/Cast/IntBoolCast.php diff --git a/utils/phpstan-baseline/method.notFound.neon b/utils/phpstan-baseline/method.notFound.neon index 34144944c976..8ced93d9ad27 100644 --- a/utils/phpstan-baseline/method.notFound.neon +++ b/utils/phpstan-baseline/method.notFound.neon @@ -69,7 +69,7 @@ parameters: - message: '#^Call to an undefined method CodeIgniter\\HTTP\\Request\:\:getGetPost\(\)\.$#' - count: 5 + count: 6 path: ../../tests/system/HTTP/IncomingRequestTest.php - @@ -89,7 +89,7 @@ parameters: - message: '#^Call to an undefined method CodeIgniter\\HTTP\\Request\:\:getPostGet\(\)\.$#' - count: 5 + count: 6 path: ../../tests/system/HTTP/IncomingRequestTest.php - @@ -114,7 +114,7 @@ parameters: - message: '#^Call to an undefined method CodeIgniter\\HTTP\\Request\:\:isSecure\(\)\.$#' - count: 3 + count: 1 path: ../../tests/system/HTTP/IncomingRequestTest.php - diff --git a/utils/phpstan-baseline/missingType.iterableValue.neon b/utils/phpstan-baseline/missingType.iterableValue.neon index 571912cb7ad4..1627bbb4a52a 100644 --- a/utils/phpstan-baseline/missingType.iterableValue.neon +++ b/utils/phpstan-baseline/missingType.iterableValue.neon @@ -1,67 +1,7 @@ -# total 1257 errors +# total 1141 errors parameters: ignoreErrors: - - - message: '#^Method CodeIgniter\\CLI\\CLI\:\:isZeroOptions\(\) has parameter \$options with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/CLI/CLI.php - - - - message: '#^Method CodeIgniter\\CLI\\CLI\:\:printKeysAndValues\(\) has parameter \$options with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/CLI/CLI.php - - - - message: '#^Method CodeIgniter\\CLI\\CLI\:\:promptByKey\(\) has parameter \$options with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/CLI/CLI.php - - - - message: '#^Method CodeIgniter\\CLI\\CLI\:\:promptByKey\(\) has parameter \$text with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/CLI/CLI.php - - - - message: '#^Method CodeIgniter\\CLI\\CLI\:\:promptByKey\(\) has parameter \$validation with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/CLI/CLI.php - - - - message: '#^Method CodeIgniter\\CLI\\CLI\:\:promptByMultipleKeys\(\) has parameter \$options with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/CLI/CLI.php - - - - message: '#^Method CodeIgniter\\CLI\\CLI\:\:promptByMultipleKeys\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/CLI/CLI.php - - - - message: '#^Method CodeIgniter\\CLI\\CLI\:\:prompt\(\) has parameter \$validation with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/CLI/CLI.php - - - - message: '#^Method CodeIgniter\\CLI\\CLI\:\:table\(\) has parameter \$tbody with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/CLI/CLI.php - - - - message: '#^Method CodeIgniter\\CLI\\CLI\:\:table\(\) has parameter \$thead with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/CLI/CLI.php - - - - message: '#^Method CodeIgniter\\CLI\\CLI\:\:validate\(\) has parameter \$rules with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/CLI/CLI.php - - - - message: '#^Method CodeIgniter\\CLI\\Console\:\:parseParamsForHelpOption\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/CLI/Console.php - - message: '#^Method CodeIgniter\\CodeIgniter\:\:getPerformanceStats\(\) return type has no value type specified in iterable type array\.$#' count: 1 @@ -172,16 +112,6 @@ parameters: count: 1 path: ../../system/Common.php - - - message: '#^Function service\(\) has parameter \$params with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Common.php - - - - message: '#^Function single_service\(\) has parameter \$params with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Common.php - - message: '#^Function stringify_attributes\(\) has parameter \$attributes with no value type specified in iterable type array\.$#' count: 1 @@ -207,11 +137,6 @@ parameters: count: 1 path: ../../system/Config/BaseConfig.php - - - message: '#^Method CodeIgniter\\Config\\BaseConfig\:\:initEnvValue\(\) has parameter \$property with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Config/BaseConfig.php - - message: '#^Property CodeIgniter\\Config\\BaseConfig\:\:\$registrars type has no value type specified in iterable type array\.$#' count: 1 @@ -232,11 +157,6 @@ parameters: count: 1 path: ../../system/Config/BaseService.php - - - message: '#^Method CodeIgniter\\Config\\BaseService\:\:getSharedInstance\(\) has parameter \$params with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Config/BaseService.php - - message: '#^Property CodeIgniter\\Config\\BaseService\:\:\$services type has no value type specified in iterable type array\.$#' count: 1 @@ -447,11 +367,6 @@ parameters: count: 1 path: ../../system/Database/BaseBuilder.php - - - message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:delete\(\) has parameter \$where with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Database/BaseBuilder.php - - message: '#^Method CodeIgniter\\Database\\BaseBuilder\:\:fieldsFromQuery\(\) return type has no value type specified in iterable type array\.$#' count: 1 @@ -787,11 +702,6 @@ parameters: count: 1 path: ../../system/Database/BaseConnection.php - - - message: '#^Method CodeIgniter\\Database\\BaseConnection\:\:__get\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Database/BaseConnection.php - - message: '#^Method CodeIgniter\\Database\\BaseConnection\:\:callFunction\(\) has parameter \$params with no value type specified in iterable type array\.$#' count: 1 @@ -807,11 +717,6 @@ parameters: count: 1 path: ../../system/Database/BaseConnection.php - - - message: '#^Method CodeIgniter\\Database\\BaseConnection\:\:escape\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Database/BaseConnection.php - - message: '#^Method CodeIgniter\\Database\\BaseConnection\:\:escape\(\) return type has no value type specified in iterable type array\.$#' count: 1 @@ -837,11 +742,6 @@ parameters: count: 1 path: ../../system/Database/BaseConnection.php - - - message: '#^Method CodeIgniter\\Database\\BaseConnection\:\:query\(\) has parameter \$binds with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Database/BaseConnection.php - - message: '#^Method CodeIgniter\\Database\\BaseConnection\:\:setAliasedTables\(\) has parameter \$aliases with no value type specified in iterable type array\.$#' count: 1 @@ -1032,26 +932,11 @@ parameters: count: 1 path: ../../system/Database/ConnectionInterface.php - - - message: '#^Method CodeIgniter\\Database\\ConnectionInterface\:\:callFunction\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Database/ConnectionInterface.php - - - - message: '#^Method CodeIgniter\\Database\\ConnectionInterface\:\:escape\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Database/ConnectionInterface.php - - message: '#^Method CodeIgniter\\Database\\ConnectionInterface\:\:escape\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../system/Database/ConnectionInterface.php - - - message: '#^Method CodeIgniter\\Database\\ConnectionInterface\:\:query\(\) has parameter \$binds with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Database/ConnectionInterface.php - - message: '#^Method CodeIgniter\\Database\\ConnectionInterface\:\:table\(\) has parameter \$tableName with no value type specified in iterable type array\.$#' count: 1 @@ -1417,11 +1302,6 @@ parameters: count: 1 path: ../../system/Database/Postgre/Builder.php - - - message: '#^Method CodeIgniter\\Database\\Postgre\\Connection\:\:escape\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Database/Postgre/Connection.php - - message: '#^Method CodeIgniter\\Database\\Postgre\\Connection\:\:escape\(\) return type has no value type specified in iterable type array\.$#' count: 1 @@ -2132,11 +2012,6 @@ parameters: count: 1 path: ../../system/Encryption/Encryption.php - - - message: '#^Method CodeIgniter\\Encryption\\Handlers\\BaseHandler\:\:__get\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Encryption/Handlers/BaseHandler.php - - message: '#^Method CodeIgniter\\Encryption\\Handlers\\OpenSSLHandler\:\:decrypt\(\) has parameter \$params with no value type specified in iterable type array\.$#' count: 1 @@ -2167,11 +2042,6 @@ parameters: count: 1 path: ../../system/Encryption/Handlers/SodiumHandler.php - - - message: '#^Method CodeIgniter\\Encryption\\KeyRotationDecorator\:\:__get\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Encryption/KeyRotationDecorator.php - - message: '#^Method CodeIgniter\\Encryption\\KeyRotationDecorator\:\:decrypt\(\) has parameter \$params with no value type specified in iterable type array\.$#' count: 1 @@ -2182,146 +2052,16 @@ parameters: count: 1 path: ../../system/Encryption/KeyRotationDecorator.php - - - message: '#^Method CodeIgniter\\Entity\\Cast\\ArrayCast\:\:get\(\) has parameter \$value with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/ArrayCast.php - - message: '#^Method CodeIgniter\\Entity\\Cast\\ArrayCast\:\:get\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../system/Entity/Cast/ArrayCast.php - - - message: '#^Method CodeIgniter\\Entity\\Cast\\ArrayCast\:\:set\(\) has parameter \$value with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/ArrayCast.php - - - - message: '#^Method CodeIgniter\\Entity\\Cast\\BaseCast\:\:get\(\) has parameter \$value with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/BaseCast.php - - - - message: '#^Method CodeIgniter\\Entity\\Cast\\BaseCast\:\:get\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/BaseCast.php - - - - message: '#^Method CodeIgniter\\Entity\\Cast\\BaseCast\:\:set\(\) has parameter \$value with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/BaseCast.php - - - - message: '#^Method CodeIgniter\\Entity\\Cast\\BaseCast\:\:set\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/BaseCast.php - - - - message: '#^Method CodeIgniter\\Entity\\Cast\\BooleanCast\:\:get\(\) has parameter \$value with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/BooleanCast.php - - - - message: '#^Method CodeIgniter\\Entity\\Cast\\CSVCast\:\:get\(\) has parameter \$value with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/CSVCast.php - - message: '#^Method CodeIgniter\\Entity\\Cast\\CSVCast\:\:get\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../system/Entity/Cast/CSVCast.php - - - message: '#^Method CodeIgniter\\Entity\\Cast\\CSVCast\:\:set\(\) has parameter \$value with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/CSVCast.php - - - - message: '#^Method CodeIgniter\\Entity\\Cast\\CastInterface\:\:get\(\) has parameter \$value with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/CastInterface.php - - - - message: '#^Method CodeIgniter\\Entity\\Cast\\CastInterface\:\:get\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/CastInterface.php - - - - message: '#^Method CodeIgniter\\Entity\\Cast\\CastInterface\:\:set\(\) has parameter \$value with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/CastInterface.php - - - - message: '#^Method CodeIgniter\\Entity\\Cast\\CastInterface\:\:set\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/CastInterface.php - - - - message: '#^Method CodeIgniter\\Entity\\Cast\\DatetimeCast\:\:get\(\) has parameter \$value with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/DatetimeCast.php - - - - message: '#^Method CodeIgniter\\Entity\\Cast\\EnumCast\:\:get\(\) has parameter \$value with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/EnumCast.php - - - - message: '#^Method CodeIgniter\\Entity\\Cast\\EnumCast\:\:set\(\) has parameter \$value with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/EnumCast.php - - - - message: '#^Method CodeIgniter\\Entity\\Cast\\FloatCast\:\:get\(\) has parameter \$value with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/FloatCast.php - - - - message: '#^Method CodeIgniter\\Entity\\Cast\\IntegerCast\:\:get\(\) has parameter \$value with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/IntegerCast.php - - - - message: '#^Method CodeIgniter\\Entity\\Cast\\JsonCast\:\:get\(\) has parameter \$value with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/JsonCast.php - - - - message: '#^Method CodeIgniter\\Entity\\Cast\\JsonCast\:\:get\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/JsonCast.php - - - - message: '#^Method CodeIgniter\\Entity\\Cast\\JsonCast\:\:set\(\) has parameter \$value with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/JsonCast.php - - - - message: '#^Method CodeIgniter\\Entity\\Cast\\ObjectCast\:\:get\(\) has parameter \$value with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/ObjectCast.php - - - - message: '#^Method CodeIgniter\\Entity\\Cast\\StringCast\:\:get\(\) has parameter \$value with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/StringCast.php - - - - message: '#^Method CodeIgniter\\Entity\\Cast\\TimestampCast\:\:get\(\) has parameter \$value with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/TimestampCast.php - - - - message: '#^Method CodeIgniter\\Entity\\Cast\\TimestampCast\:\:get\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/TimestampCast.php - - - - message: '#^Method CodeIgniter\\Entity\\Cast\\URICast\:\:get\(\) has parameter \$value with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Entity/Cast/URICast.php - - message: '#^Method CodeIgniter\\Exceptions\\PageNotFoundException\:\:lang\(\) has parameter \$args with no value type specified in iterable type array\.$#' count: 1 @@ -2607,11 +2347,6 @@ parameters: count: 1 path: ../../system/HTTP/CURLRequest.php - - - message: '#^Method CodeIgniter\\HTTP\\CURLRequest\:\:setJSON\(\) has parameter \$data with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/HTTP/CURLRequest.php - - message: '#^Method CodeIgniter\\HTTP\\CURLRequest\:\:setResponseHeaders\(\) has parameter \$headers with no value type specified in iterable type array\.$#' count: 1 @@ -2682,11 +2417,6 @@ parameters: count: 1 path: ../../system/HTTP/IncomingRequest.php - - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getCookie\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/HTTP/IncomingRequest.php - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getFileMultiple\(\) return type has no value type specified in iterable type array\.$#' count: 1 @@ -2707,11 +2437,6 @@ parameters: count: 1 path: ../../system/HTTP/IncomingRequest.php - - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getGetPost\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/HTTP/IncomingRequest.php - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getGet\(\) has parameter \$flags with no value type specified in iterable type array\.$#' count: 1 @@ -2722,16 +2447,6 @@ parameters: count: 1 path: ../../system/HTTP/IncomingRequest.php - - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getGet\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/HTTP/IncomingRequest.php - - - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getJSON\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/HTTP/IncomingRequest.php - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getJsonVar\(\) has parameter \$flags with no value type specified in iterable type array\.$#' count: 1 @@ -2742,11 +2457,6 @@ parameters: count: 1 path: ../../system/HTTP/IncomingRequest.php - - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getJsonVar\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/HTTP/IncomingRequest.php - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getOldInput\(\) return type has no value type specified in iterable type array\.$#' count: 1 @@ -2762,11 +2472,6 @@ parameters: count: 1 path: ../../system/HTTP/IncomingRequest.php - - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getPostGet\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/HTTP/IncomingRequest.php - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getPost\(\) has parameter \$flags with no value type specified in iterable type array\.$#' count: 1 @@ -2777,11 +2482,6 @@ parameters: count: 1 path: ../../system/HTTP/IncomingRequest.php - - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getPost\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/HTTP/IncomingRequest.php - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getRawInputVar\(\) has parameter \$flags with no value type specified in iterable type array\.$#' count: 1 @@ -2792,11 +2492,6 @@ parameters: count: 1 path: ../../system/HTTP/IncomingRequest.php - - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getRawInputVar\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/HTTP/IncomingRequest.php - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getRawInput\(\) return type has no value type specified in iterable type array\.$#' count: 1 @@ -2812,11 +2507,6 @@ parameters: count: 1 path: ../../system/HTTP/IncomingRequest.php - - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:getVar\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/HTTP/IncomingRequest.php - - message: '#^Method CodeIgniter\\HTTP\\IncomingRequest\:\:negotiate\(\) has parameter \$supported with no value type specified in iterable type array\.$#' count: 1 @@ -2952,11 +2642,6 @@ parameters: count: 1 path: ../../system/HTTP/Request.php - - - message: '#^Method CodeIgniter\\HTTP\\Request\:\:fetchGlobal\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/HTTP/Request.php - - message: '#^Method CodeIgniter\\HTTP\\Request\:\:getEnv\(\) has parameter \$flags with no value type specified in iterable type array\.$#' count: 1 @@ -3112,11 +2797,6 @@ parameters: count: 1 path: ../../system/Helpers/Array/ArrayHelper.php - - - message: '#^Method CodeIgniter\\Helpers\\Array\\ArrayHelper\:\:arraySearchDot\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Helpers/Array/ArrayHelper.php - - message: '#^Method CodeIgniter\\Helpers\\Array\\ArrayHelper\:\:dotKeyExists\(\) has parameter \$array with no value type specified in iterable type array\.$#' count: 1 @@ -3127,11 +2807,6 @@ parameters: count: 1 path: ../../system/Helpers/Array/ArrayHelper.php - - - message: '#^Method CodeIgniter\\Helpers\\Array\\ArrayHelper\:\:dotSearch\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Helpers/Array/ArrayHelper.php - - message: '#^Method CodeIgniter\\Helpers\\Array\\ArrayHelper\:\:groupBy\(\) has parameter \$array with no value type specified in iterable type array\.$#' count: 1 @@ -3172,11 +2847,6 @@ parameters: count: 1 path: ../../system/Helpers/array_helper.php - - - message: '#^Function array_deep_search\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Helpers/array_helper.php - - message: '#^Function array_flatten_with_dots\(\) has parameter \$array with no value type specified in iterable type iterable\.$#' count: 1 @@ -3217,11 +2887,6 @@ parameters: count: 1 path: ../../system/Helpers/array_helper.php - - - message: '#^Function dot_array_search\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Helpers/array_helper.php - - message: '#^Function directory_map\(\) return type has no value type specified in iterable type array\.$#' count: 1 @@ -3587,16 +3252,6 @@ parameters: count: 1 path: ../../system/HotReloader/IteratorFilter.php - - - message: '#^Method CodeIgniter\\I18n\\Time\:\:__get\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/I18n/Time.php - - - - message: '#^Method CodeIgniter\\I18n\\TimeLegacy\:\:__get\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/I18n/TimeLegacy.php - - message: '#^Method CodeIgniter\\Images\\Handlers\\BaseHandler\:\:__call\(\) has parameter \$args with no value type specified in iterable type array\.$#' count: 1 @@ -4167,11 +3822,6 @@ parameters: count: 1 path: ../../system/Validation/Rules.php - - - message: '#^Method CodeIgniter\\Validation\\Rules\:\:field_exists\(\) has parameter \$value with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/Rules.php - - message: '#^Method CodeIgniter\\Validation\\Rules\:\:is_not_unique\(\) has parameter \$data with no value type specified in iterable type array\.$#' count: 1 @@ -4187,11 +3837,6 @@ parameters: count: 1 path: ../../system/Validation/Rules.php - - - message: '#^Method CodeIgniter\\Validation\\Rules\:\:required\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/Rules.php - - message: '#^Method CodeIgniter\\Validation\\Rules\:\:required_with\(\) has parameter \$data with no value type specified in iterable type array\.$#' count: 1 @@ -4202,266 +3847,46 @@ parameters: count: 1 path: ../../system/Validation/Rules.php - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\CreditCardRules\:\:valid_cc_number\(\) has parameter \$ccNumber with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/CreditCardRules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\FormatRules\:\:alpha\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/FormatRules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\FormatRules\:\:alpha_dash\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/FormatRules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\FormatRules\:\:alpha_numeric\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/FormatRules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\FormatRules\:\:alpha_numeric_punct\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/FormatRules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\FormatRules\:\:alpha_numeric_space\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/FormatRules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\FormatRules\:\:alpha_space\(\) has parameter \$value with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/FormatRules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\FormatRules\:\:decimal\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/FormatRules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\FormatRules\:\:hex\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/FormatRules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\FormatRules\:\:integer\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/FormatRules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\FormatRules\:\:is_natural\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/FormatRules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\FormatRules\:\:is_natural_no_zero\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/FormatRules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\FormatRules\:\:numeric\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/FormatRules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\FormatRules\:\:regex_match\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/FormatRules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\FormatRules\:\:string\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/FormatRules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\FormatRules\:\:timezone\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/FormatRules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\FormatRules\:\:valid_base64\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/FormatRules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\FormatRules\:\:valid_date\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/FormatRules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\FormatRules\:\:valid_email\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/FormatRules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\FormatRules\:\:valid_emails\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/FormatRules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\FormatRules\:\:valid_ip\(\) has parameter \$ip with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/FormatRules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\FormatRules\:\:valid_json\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/FormatRules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\FormatRules\:\:valid_url\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/FormatRules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\FormatRules\:\:valid_url_strict\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/FormatRules.php - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\Rules\:\:differs\(\) has parameter \$data with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Validation/StrictRules/Rules.php - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\Rules\:\:differs\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/Rules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\Rules\:\:equals\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/Rules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\Rules\:\:exact_length\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/Rules.php - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\Rules\:\:field_exists\(\) has parameter \$data with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Validation/StrictRules/Rules.php - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\Rules\:\:field_exists\(\) has parameter \$value with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/Rules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\Rules\:\:greater_than\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/Rules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\Rules\:\:greater_than_equal_to\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/Rules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\Rules\:\:in_list\(\) has parameter \$value with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/Rules.php - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\Rules\:\:is_not_unique\(\) has parameter \$data with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Validation/StrictRules/Rules.php - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\Rules\:\:is_not_unique\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/Rules.php - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\Rules\:\:is_unique\(\) has parameter \$data with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Validation/StrictRules/Rules.php - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\Rules\:\:is_unique\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/Rules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\Rules\:\:less_than\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/Rules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\Rules\:\:less_than_equal_to\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/Rules.php - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\Rules\:\:matches\(\) has parameter \$data with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Validation/StrictRules/Rules.php - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\Rules\:\:matches\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/Rules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\Rules\:\:max_length\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/Rules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\Rules\:\:min_length\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/Rules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\Rules\:\:not_equals\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/Rules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\Rules\:\:not_in_list\(\) has parameter \$value with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/Rules.php - - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\Rules\:\:required\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/Rules.php - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\Rules\:\:required_with\(\) has parameter \$data with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Validation/StrictRules/Rules.php - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\Rules\:\:required_with\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/Rules.php - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\Rules\:\:required_without\(\) has parameter \$data with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Validation/StrictRules/Rules.php - - - message: '#^Method CodeIgniter\\Validation\\StrictRules\\Rules\:\:required_without\(\) has parameter \$str with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/StrictRules/Rules.php - - message: '#^Method CodeIgniter\\Validation\\Validation\:\:check\(\) has parameter \$rules with no value type specified in iterable type array\.$#' count: 1 path: ../../system/Validation/Validation.php - - - message: '#^Method CodeIgniter\\Validation\\Validation\:\:check\(\) has parameter \$value with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/Validation.php - - message: '#^Method CodeIgniter\\Validation\\Validation\:\:fillPlaceholders\(\) has parameter \$data with no value type specified in iterable type array\.$#' count: 1 @@ -4627,11 +4052,6 @@ parameters: count: 1 path: ../../system/Validation/ValidationInterface.php - - - message: '#^Method CodeIgniter\\Validation\\ValidationInterface\:\:check\(\) has parameter \$value with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Validation/ValidationInterface.php - - message: '#^Method CodeIgniter\\Validation\\ValidationInterface\:\:getRules\(\) return type has no value type specified in iterable type array\.$#' count: 1 diff --git a/utils/phpstan-baseline/property.nonObject.neon b/utils/phpstan-baseline/property.nonObject.neon index 33d6299465f8..7268e376d119 100644 --- a/utils/phpstan-baseline/property.nonObject.neon +++ b/utils/phpstan-baseline/property.nonObject.neon @@ -1,4 +1,4 @@ -# total 36 errors +# total 12 errors parameters: ignoreErrors: @@ -27,61 +27,6 @@ parameters: count: 1 path: ../../tests/system/Database/Live/GetTest.php - - - message: '#^Cannot access property \$id on array\.$#' - count: 2 - path: ../../tests/system/Models/FindModelTest.php - - - - message: '#^Cannot access property \$created_at on array\.$#' - count: 3 - path: ../../tests/system/Models/InsertModelTest.php - - - - message: '#^Cannot access property \$created_at on array\.$#' - count: 1 - path: ../../tests/system/Models/MiscellaneousModelTest.php - - - - message: '#^Cannot access property \$description on array\.$#' - count: 1 - path: ../../tests/system/Models/SaveModelTest.php - - - - message: '#^Cannot access property \$country on array\.$#' - count: 2 - path: ../../tests/system/Models/TimestampModelTest.php - - - - message: '#^Cannot access property \$created_at on array\.$#' - count: 2 - path: ../../tests/system/Models/TimestampModelTest.php - - - - message: '#^Cannot access property \$id on array\.$#' - count: 2 - path: ../../tests/system/Models/TimestampModelTest.php - - - - message: '#^Cannot access property \$updated_at on array\.$#' - count: 2 - path: ../../tests/system/Models/TimestampModelTest.php - - - - message: '#^Cannot access property \$value on list\\.$#' - count: 1 - path: ../../tests/system/Models/UpdateModelTest.php - - - - message: '#^Cannot access property \$key on array\.$#' - count: 7 - path: ../../tests/system/Models/WhenWhenNotModelTest.php - - - - message: '#^Cannot access property \$value on array\.$#' - count: 1 - path: ../../tests/system/Models/WhenWhenNotModelTest.php - - message: '#^Cannot access property \$created_at on array\|object\.$#' count: 1 diff --git a/utils/phpstan-baseline/property.notFound.neon b/utils/phpstan-baseline/property.notFound.neon index 9e6fc64adb15..b64acebff15b 100644 --- a/utils/phpstan-baseline/property.notFound.neon +++ b/utils/phpstan-baseline/property.notFound.neon @@ -1,4 +1,4 @@ -# total 49 errors +# total 47 errors parameters: ignoreErrors: @@ -17,16 +17,6 @@ parameters: count: 14 path: ../../system/Database/SQLSRV/Forge.php - - - message: '#^Access to an undefined property Config\\Session\:\:\$lockAttempts\.$#' - count: 1 - path: ../../system/Session/Handlers/RedisHandler.php - - - - message: '#^Access to an undefined property Config\\Session\:\:\$lockWait\.$#' - count: 1 - path: ../../system/Session/Handlers/RedisHandler.php - - message: '#^Access to an undefined property CodeIgniter\\Database\\BaseConnection\:\:\$mysqli\.$#' count: 1 diff --git a/utils/phpstan-baseline/return.type.neon b/utils/phpstan-baseline/return.type.neon index b181bcabebd9..cd3da83ccce5 100644 --- a/utils/phpstan-baseline/return.type.neon +++ b/utils/phpstan-baseline/return.type.neon @@ -1,4 +1,4 @@ -# total 2 errors +# total 3 errors parameters: ignoreErrors: @@ -7,6 +7,11 @@ parameters: count: 1 path: ../../system/Database/BaseBuilder.php + - + message: '#^Method CodeIgniter\\Entity\\Cast\\DatetimeCast\:\:get\(\) should return CodeIgniter\\I18n\\Time but returns mixed\.$#' + count: 1 + path: ../../system/Entity/Cast/DatetimeCast.php + - message: '#^Method CodeIgniter\\Router\\Router\:\:getRouteAttributes\(\) should return array\{class\: list\, method\: list\\} but returns array\{class\: list\, method\: list\\}\.$#' count: 1