diff --git a/.gitattributes b/.gitattributes index 30832ef77b1..237fac3f68d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -34,16 +34,34 @@ tests/TestCase export-ignore .editorconfig export-ignore .gitattributes export-ignore .gitignore export-ignore +.git-blame-ignore-revs export-ignore .mailmap export-ignore -.stickler.yml export-ignore Makefile export-ignore phpcs.xml export-ignore phpstan.neon.dist export-ignore phpstan-baseline.neon export-ignore phpunit.xml.dist export-ignore +psalm.xml export-ignore +psalm-baseline.xml export-ignore rector.php export-ignore +tests/composer.lock export-ignore +tests/phpstan.neon export-ignore +tests/phpstan-baseline.neon export-ignore + # Split package files +src/Database/.gitattributes export-ignore +src/Database/phpstan.neon.dist export-ignore +src/Database/tests/ export-ignore +src/Datasource/.gitattributes export-ignore +src/Datasource/phpstan.neon.dist export-ignore +src/Datasource/tests/ export-ignore +src/Http/.gitattributes export-ignore +src/Http/phpstan.neon.dist export-ignore +src/Http/tests/ export-ignore +src/ORM/.gitattributes export-ignore +src/ORM/phpstan.neon.dist export-ignore +src/ORM/tests/ export-ignore src/Validation/.gitattributes export-ignore src/Validation/phpstan.neon.dist export-ignore src/Validation/tests/ export-ignore diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 6b168892c37..a61b2a1656d 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -2,19 +2,7 @@ ## Supported Versions -We support fixing security issues on the following releases: - -| Version | Supported | Security fixes until -| ------- | ------------------ | -------------------- -| 5.0 | :white_check_mark: | The release of 5.2 -| 4.5 | :white_check_mark: | 36 Months after the release of 5.0 (09 Sep 2026) -| 4.4 | :white_check_mark: | 36 Months after the release of 5.0 (09 Sep 2026) -| 4.3 | :white_check_mark: | 36 Months after the release of 5.0 (09 Sep 2026) -| 4.2 | :x: | No longer supported -| 4.1 | :x: | No longer supported -| 4.0 | :x: | No longer supported -| 3.10.x | :x: | No longer supported -| 2.10.x | :x: | No longer supported +The supported version list can be found in [GitHub wiki](https://github.com/cakephp/cakephp/wiki#supported-versions). ## Reporting a Vulnerability @@ -32,3 +20,4 @@ the CakePHP team will take the following actions: * Prepare a post describing the vulnerability, and the possible exploits. * Release new versions of all affected versions. * Prominently feature the problem in the release announcement + diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6647c42863e..78cb4271e62 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,8 +5,12 @@ updates: schedule: interval: weekly open-pull-requests-limit: 10 + cooldown: + default-days: 7 - package-ecosystem: github-actions directory: "/" schedule: interval: weekly open-pull-requests-limit: 10 + cooldown: + default-days: 7 diff --git a/.github/workflows/api-docs.yml b/.github/workflows/api-docs.yml index 6a4e37236dd..495d772f691 100644 --- a/.github/workflows/api-docs.yml +++ b/.github/workflows/api-docs.yml @@ -22,9 +22,11 @@ jobs: private_key: ${{ secrets.CAKEBOT_APP_PRIVATE_KEY }} - name: Trigger API build + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} run: > curl -XPOST - -H 'Authorization: Bearer ${{ steps.app-token.outputs.token }}' + -H "Authorization: Bearer ${APP_TOKEN}" -H 'Accept: application/vnd.github.v3+json' -H 'Content-Type: application/json' https://api.github.com/repos/cakephp/cakephp-api-docs/actions/workflows/deploy_2x.yml/dispatches diff --git a/.github/workflows/cancel.yml b/.github/workflows/cancel.yml deleted file mode 100644 index 263c669071d..00000000000 --- a/.github/workflows/cancel.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Cancel -on: - workflow_run: - workflows: ["CI"] - types: - - requested -permissions: - contents: read - -jobs: - cancel: - permissions: - actions: write # for styfle/cancel-workflow-action to cancel/stop running workflows - runs-on: ubuntu-latest - steps: - - uses: styfle/cancel-workflow-action@0.13.1 - with: - workflow_id: ${{ github.event.workflow.id }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd265c7261f..f68d22c38e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,10 @@ on: - '*' workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + permissions: contents: read # to fetch code (actions/checkout) @@ -110,7 +114,7 @@ jobs: sleep 2 done - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: persist-credentials: false @@ -164,7 +168,7 @@ jobs: - name: Submit code coverage if: matrix.php-version == '8.2' - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: files: coverage.xml,coverage-database.xml,coverage-functions.xml token: ${{ secrets.CODECOV_TOKEN }} @@ -178,7 +182,7 @@ jobs: PHP_VERSION: '8.2' steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: persist-credentials: false @@ -195,7 +199,7 @@ jobs: key: ${{ steps.key-date.outputs.date }} - name: Cache PHP extensions - uses: actions/cache@v5 + uses: actions/cache@v6.1.0 with: path: ${{ steps.php-ext-cache.outputs.dir }} key: ${{ runner.os }}-php-ext-${{ steps.php-ext-cache.outputs.key }} @@ -242,7 +246,7 @@ jobs: PHPSTAN_TESTS: 1 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: persist-credentials: false @@ -260,7 +264,7 @@ jobs: uses: ramsey/composer-install@v4 - name: Cache phive tools - uses: actions/cache@v5 + uses: actions/cache@v6.1.0 with: path: | ~/.phive @@ -283,7 +287,7 @@ jobs: - name: Setup phpcs cache if: always() - uses: actions/cache@v5 + uses: actions/cache@v6.1.0 with: path: ${{ runner.temp }}/phpcs.cache key: ${{ runner.os }}-phpcs-${{ hashFiles('phpcs.xml', 'composer.lock') }} @@ -311,7 +315,7 @@ jobs: - name: Setup rector cache if: always() - uses: actions/cache@v5 + uses: actions/cache@v6.1.0 with: path: ${{ runner.temp }}/rector key: ${{ runner.os }}-rector-${{ hashFiles('rector.php', 'composer.lock') }} @@ -335,7 +339,7 @@ jobs: PHIVE_KEYS: 'CF1A108D0E7AE720,51C67305FFC2E5C0,12CE0F1D262429A5,99BF4D9A33D65E1E' steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: persist-credentials: false @@ -353,7 +357,7 @@ jobs: uses: ramsey/composer-install@v4 - name: Cache phive tools - uses: actions/cache@v5 + uses: actions/cache@v6.1.0 with: path: | ~/.phive diff --git a/.github/workflows/split-packages.yml b/.github/workflows/split-packages.yml index 3d1dea6588f..927da3474d3 100644 --- a/.github/workflows/split-packages.yml +++ b/.github/workflows/split-packages.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 persist-credentials: false @@ -28,7 +28,8 @@ jobs: - name: Push split packages env: + CURRENT_BRANCH: ${{ github.ref_name }} GITHUB_TOKEN: ${{ secrets.GH_SPLIT_PACKAGES_WRITE_TOKEN }} run: | - make CURRENT_BRANCH=${{ github.ref_name }} components + make CURRENT_BRANCH="${CURRENT_BRANCH}" components make clean-components-branches diff --git a/.phive/phars.xml b/.phive/phars.xml index f648a9f744f..a1053000514 100644 --- a/.phive/phars.xml +++ b/.phive/phars.xml @@ -1,5 +1,5 @@ - + diff --git a/Makefile b/Makefile index 6227133dc26..222bece920b 100644 --- a/Makefile +++ b/Makefile @@ -146,7 +146,7 @@ package: clean dist/cakephp-$(DASH_VERSION).zip # Publish app skeleton with dependencies zipballs to Github. publish: guard-VERSION dist/cakephp-$(DASH_VERSION).zip @echo "Creating draft release for $(VERSION). prerelease=$(PRERELEASE)" - curl $(AUTH) -XPOST $(API_HOST)/repos/$(OWNER)/cakephp/releases -d '{"tag_name": "$(VERSION)", "name": "CakePHP $(VERSION) released", "draft": true, "prerelease": $(PRERELEASE)}' > release.json + curl $(AUTH) -XPOST $(API_HOST)/repos/$(OWNER)/cakephp/releases -d '{"tag_name": "$(VERSION)", "name": "CakePHP $(VERSION)", "draft": true, "prerelease": $(PRERELEASE)}' > release.json # Extract id out of response json. php -r '$$f = file_get_contents("./release.json"); $$d = json_decode($$f, true); file_put_contents("./id.txt", $$d["id"]);' @echo "Uploading zip file to github." diff --git a/VERSION.txt b/VERSION.txt index 53a18289616..f91b5944c90 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -16,4 +16,4 @@ // @license https://opensource.org/licenses/mit-license.php MIT License // +--------------------------------------------------------------------------------------------+ // //////////////////////////////////////////////////////////////////////////////////////////////////// -5.3.6 +5.3.7 diff --git a/composer.json b/composer.json index 23989ffa749..e64e685c727 100644 --- a/composer.json +++ b/composer.json @@ -134,7 +134,7 @@ "psalm-baseline": "tools/psalm --set-baseline=psalm-baseline.xml", "lowest": "validate-prefer-lowest", "lowest-setup": "composer update --prefer-lowest --prefer-stable --prefer-dist --no-interaction && cp composer.json composer.backup && composer require --dev dereuromark/composer-prefer-lowest && mv composer.backup composer.json", - "rector-setup": "cp composer.json composer.backup && composer require --dev rector/rector:\"~2.3.1\" && mv composer.backup composer.json", + "rector-setup": "cp composer.json composer.backup && composer require --dev rector/rector:\"~2.4.0\" && mv composer.backup composer.json", "rector-check": "vendor/bin/rector process --dry-run", "rector-fix": "vendor/bin/rector process", "test": "phpunit", diff --git a/phpcs.xml b/phpcs.xml index 71c1b80d43f..2f409ba72bb 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -16,6 +16,17 @@ + + + + + + + + + + tests/test_app/* + diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 6fe203586e1..ab3fafb2b3e 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -54,12 +54,6 @@ parameters: count: 8 path: src/Database/Expression/QueryExpression.php - - - message: '#^Strict comparison using \!\=\= between Cake\\Database\\TypeMap and null will always evaluate to true\.$#' - identifier: notIdentical.alwaysTrue - count: 1 - path: src/Database/Expression/WhenThenExpression.php - - message: '#^Dead catch \- InvalidArgumentException is never thrown in the try block\.$#' identifier: catch.neverThrown diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 13c9f2e5f1f..ad810f56aca 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,10 +1,5 @@ - - - - - _config]]> - - - - - _fields[$field]]]> diff --git a/psalm.xml b/psalm.xml index 53050d23b63..7b40fa17225 100644 --- a/psalm.xml +++ b/psalm.xml @@ -27,10 +27,5 @@ - - - - - diff --git a/rector.php b/rector.php index 4f09b3f229f..4d4c50afc6d 100644 --- a/rector.php +++ b/rector.php @@ -127,4 +127,10 @@ \Rector\Php73\Rector\FuncCall\ArrayKeyFirstLastRector::class, \Rector\Php80\Rector\FuncCall\ClassOnObjectRector::class, \Rector\CodeQuality\Rector\Ternary\SwitchNegatedTernaryRector::class, + + // Newly aggressive in rector 2.4 - keep the bump behavior-neutral: + // adds declare(strict_types=1) to test fixtures/config (out of scope here), + \Rector\TypeDeclaration\Rector\StmtsAwareInterface\SafeDeclareStrictTypesRector::class, + // and rewrites `$x ?: []` in ways that can change behavior on undefined/empty values. + \Rector\DeadCode\Rector\Ternary\RemoveUselessTernaryRector::class, ]); diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index 13f5b256606..f2349c823f9 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -72,8 +72,7 @@ class Cache * An array mapping URL schemes to fully qualified caching engine * class names. * - * @var array - * @phpstan-var array + * @var array */ protected static array $_dsnClassMap = [ 'array' => Engine\ArrayEngine::class, diff --git a/src/Cache/Engine/RedisEngine.php b/src/Cache/Engine/RedisEngine.php index f7d6f06ffdb..d35fccd9bf9 100644 --- a/src/Cache/Engine/RedisEngine.php +++ b/src/Cache/Engine/RedisEngine.php @@ -86,6 +86,12 @@ class RedisEngine extends CacheEngine * - `clearUsesFlushDb` Enable clear() and clearBlocking() to use FLUSHDB. This will be * faster than standard clear()/clearBlocking() but will ignore prefixes and will * cause dataloss if other applications are sharing a redis database. + * - `allowedClasses` Controls the `allowed_classes` option passed to `unserialize()` + * when reading values back. Set to `false` to disallow all object unserialization + * (safest when the cache only stores scalar/array values), or provide an array of + * fully qualified class names to allow only those classes. Useful for hardening + * against PHP object injection when a cache backend is shared across applications. + * Defaults to `true` (allow all) for backwards compatibility. * * @var array */ @@ -108,6 +114,7 @@ class RedisEngine extends CacheEngine 'nodes' => [], 'failover' => null, 'clearUsesFlushDb' => false, + 'allowedClasses' => true, ]; /** @@ -669,6 +676,11 @@ protected function unserialize(string $value): mixed return (int)$value; } + $allowedClasses = $this->getConfig('allowedClasses'); + if ($allowedClasses !== true) { + return unserialize($value, ['allowed_classes' => $allowedClasses]); + } + return unserialize($value); } diff --git a/src/Collection/CollectionInterface.php b/src/Collection/CollectionInterface.php index e28527760e0..4f876f0bad3 100644 --- a/src/Collection/CollectionInterface.php +++ b/src/Collection/CollectionInterface.php @@ -811,8 +811,7 @@ public function insert(string $path, mixed $values): CollectionInterface; * collection as the array keys. Keep in mind that it is valid for iterators * to return the same key for different elements, setting this value to false * can help getting all items if keys are not important in the result. - * @phpstan-return ($keepKeys is true ? array : array) - * @return array|array + * @return ($keepKeys is true ? array : array) */ public function toArray(bool $keepKeys = true): array; diff --git a/src/Collection/Iterator/TreeIterator.php b/src/Collection/Iterator/TreeIterator.php index c4286fbf646..beb961004b3 100644 --- a/src/Collection/Iterator/TreeIterator.php +++ b/src/Collection/Iterator/TreeIterator.php @@ -38,8 +38,7 @@ class TreeIterator extends RecursiveIteratorIterator implements CollectionInterf /** * The iteration mode * - * @var int - * @phpstan-var \RecursiveIteratorIterator::LEAVES_ONLY|\RecursiveIteratorIterator::SELF_FIRST|\RecursiveIteratorIterator::CHILD_FIRST + * @var \RecursiveIteratorIterator::LEAVES_ONLY|\RecursiveIteratorIterator::SELF_FIRST|\RecursiveIteratorIterator::CHILD_FIRST */ protected int $_mode; @@ -47,10 +46,8 @@ class TreeIterator extends RecursiveIteratorIterator implements CollectionInterf * Constructor * * @param \RecursiveIterator $items The iterator to flatten. - * @param int $mode Iterator mode. - * @param int $flags Iterator flags. - * @phpstan-param \RecursiveIteratorIterator::LEAVES_ONLY|\RecursiveIteratorIterator::SELF_FIRST|\RecursiveIteratorIterator::CHILD_FIRST $mode - * @phpstan-param \RecursiveIteratorIterator::LEAVES_ONLY|\RecursiveIteratorIterator::CATCH_GET_CHILD $flags + * @param \RecursiveIteratorIterator::LEAVES_ONLY|\RecursiveIteratorIterator::SELF_FIRST|\RecursiveIteratorIterator::CHILD_FIRST $mode Iterator mode. + * @param \RecursiveIteratorIterator::LEAVES_ONLY|\RecursiveIteratorIterator::CATCH_GET_CHILD $flags Iterator flags. */ public function __construct( RecursiveIterator $items, diff --git a/src/Collection/Iterator/TreePrinter.php b/src/Collection/Iterator/TreePrinter.php index 9bc6e709765..95e73dbcc5a 100644 --- a/src/Collection/Iterator/TreePrinter.php +++ b/src/Collection/Iterator/TreePrinter.php @@ -73,8 +73,7 @@ class TreePrinter extends RecursiveIteratorIterator implements CollectionInterfa * callable returning the key value. * @param string $spacer The string to use for prefixing the values according to * their depth in the tree. - * @param int $mode Iterator mode. - * @phpstan-param \RecursiveIteratorIterator::LEAVES_ONLY|\RecursiveIteratorIterator::SELF_FIRST|\RecursiveIteratorIterator::CHILD_FIRST $mode + * @param \RecursiveIteratorIterator::LEAVES_ONLY|\RecursiveIteratorIterator::SELF_FIRST|\RecursiveIteratorIterator::CHILD_FIRST $mode Iterator mode. */ public function __construct( RecursiveIterator $items, diff --git a/src/Console/Command/HelpCommand.php b/src/Console/Command/HelpCommand.php index 366d09b69a5..639f29fb0c5 100644 --- a/src/Console/Command/HelpCommand.php +++ b/src/Console/Command/HelpCommand.php @@ -434,9 +434,8 @@ protected function outputPaths(ConsoleIo $io): void } /** - * @param array $names Names + * @param non-empty-array $names Names * @return string - * @phpstan-param non-empty-array $names */ protected function getShortestName(array $names): string { diff --git a/src/Console/CommandCollection.php b/src/Console/CommandCollection.php index cc816fba505..0e00b615009 100644 --- a/src/Console/CommandCollection.php +++ b/src/Console/CommandCollection.php @@ -162,8 +162,7 @@ public function get(string $name): CommandInterface|string /** * Implementation of IteratorAggregate. * - * @return \Traversable - * @phpstan-return \Traversable> + * @return \Traversable> */ public function getIterator(): Traversable { diff --git a/src/Console/HelpFormatter.php b/src/Console/HelpFormatter.php index 1be9a12cc9f..65dc3b96607 100644 --- a/src/Console/HelpFormatter.php +++ b/src/Console/HelpFormatter.php @@ -196,12 +196,16 @@ public function xml(bool $string = true): SimpleXMLElement|string $xml->addChild('description', $parser->getDescription()); $options = $xml->addChild('options'); - foreach ($parser->options() as $option) { - $option->xml($options); + if ($options !== null) { + foreach ($parser->options() as $option) { + $option->xml($options); + } } $arguments = $xml->addChild('arguments'); - foreach ($parser->arguments() as $argument) { - $argument->xml($arguments); + if ($arguments !== null) { + foreach ($parser->arguments() as $argument) { + $argument->xml($arguments); + } } $xml->addChild('epilog', $parser->getEpilog()); diff --git a/src/Controller/Component.php b/src/Controller/Component.php index 9cfdf716a95..595aa96b387 100644 --- a/src/Controller/Component.php +++ b/src/Controller/Component.php @@ -46,7 +46,7 @@ * - `afterFilter(EventInterface $event)` * Called after the action is complete and the view has been rendered but * before Controller::afterFilter(). - * - `beforeRedirect(EventInterface $event $url, Response $response)` + * - `beforeRedirect(EventInterface $event, $url, Response $response)` * Called before a redirect is done. Allows you to change the URL that will * be redirected to by returning a Response instance with new URL set using * Response::location(). Redirection can be prevented by stopping the event diff --git a/src/Controller/Component/FormProtectionComponent.php b/src/Controller/Component/FormProtectionComponent.php index 924fbe87acf..100f71a0988 100644 --- a/src/Controller/Component/FormProtectionComponent.php +++ b/src/Controller/Component/FormProtectionComponent.php @@ -33,7 +33,7 @@ * - Existing fields have not been removed from the form. * - Values of hidden inputs have not been changed. * - * @phpstan-property array{validate:bool, unlockedFields:array, unlockedActions:array, validationFailureCallback:?\Closure} $_config + * @property array{validate:bool, unlockedFields:array, unlockedActions:array, validationFailureCallback:?\Closure} $_config */ class FormProtectionComponent extends Component { diff --git a/src/Controller/Controller.php b/src/Controller/Controller.php index 1518729161c..0a8e43aaf3d 100644 --- a/src/Controller/Controller.php +++ b/src/Controller/Controller.php @@ -175,8 +175,7 @@ class Controller implements EventListenerInterface, EventDispatcherInterface /** * Middlewares list. * - * @var array - * @phpstan-var array + * @var array */ protected array $middlewares = []; @@ -522,12 +521,11 @@ public function invokeAction(Closure $action, array $args): void * Register middleware for the controller. * * @param \Psr\Http\Server\MiddlewareInterface|\Closure|string $middleware Middleware. - * @param array $options Valid options: + * @param array{only?: array|string, except?: array|string} $options Valid options: * - `only`: (array|string) Only run the middleware for specified actions. * - `except`: (array|string) Run the middleware for all actions except the specified ones. * @return void * @since 4.3.0 - * @phpstan-param array{only?: array|string, except?: array|string} $options */ public function middleware(MiddlewareInterface|Closure|string $middleware, array $options = []): void { diff --git a/src/Core/Exception/CakeException.php b/src/Core/Exception/CakeException.php index aae98c0e5b6..1028cd666e3 100644 --- a/src/Core/Exception/CakeException.php +++ b/src/Core/Exception/CakeException.php @@ -68,6 +68,9 @@ public function __construct(array|string $message = '', ?int $code = null, ?Thro * Get the passed in attributes * * @return array + * @psalm-taint-escape html Exception attributes are developer-defined metadata (e.g. controller + * name, validation rule names) used only in debug output — log files and CLI console — never + * rendered unescaped into HTML responses. */ public function getAttributes(): array { diff --git a/src/Core/PluginCollection.php b/src/Core/PluginCollection.php index 51dd526f96e..83d5a298b95 100644 --- a/src/Core/PluginCollection.php +++ b/src/Core/PluginCollection.php @@ -248,12 +248,11 @@ public function get(string $name): PluginInterface /** * Create a plugin instance from a name/classname and configuration. * - * @param string $name The plugin name or classname + * @param class-string<\Cake\Core\PluginInterface>|string $name The plugin name or classname * @param array $config Configuration options for the plugin. * @return \Cake\Core\PluginInterface * @throws \Cake\Core\Exception\MissingPluginException When plugin instance could not be created. * @throws \InvalidArgumentException When class name cannot be found or an empty name is provided. - * @phpstan-param class-string<\Cake\Core\PluginInterface>|string $name */ public function create(string $name, array $config = []): PluginInterface { diff --git a/src/Core/StaticConfigTrait.php b/src/Core/StaticConfigTrait.php index 717a6cbfb8f..c9c4408123d 100644 --- a/src/Core/StaticConfigTrait.php +++ b/src/Core/StaticConfigTrait.php @@ -312,9 +312,8 @@ public static function parseDsn(string $dsn): array /** * Updates the DSN class map for this class. * - * @param array $map Additions/edits to the class map to apply. + * @param array $map Additions/edits to the class map to apply. * @return void - * @phpstan-param array $map */ public static function setDsnClassMap(array $map): void { diff --git a/src/Core/functions.php b/src/Core/functions.php index 9285ac0bfc9..c2e3bfdda3a 100644 --- a/src/Core/functions.php +++ b/src/Core/functions.php @@ -145,9 +145,8 @@ function h(mixed $text, bool $double = true, ?string $charset = null): mixed * @param string $name The name you want to plugin split. * @param bool $dotAppend Set to true if you want the plugin to have a '.' appended to it. * @param string|null $plugin Optional default plugin to use if no plugin is found. Defaults to null. - * @return array Array with 2 indexes. 0 => plugin name, 1 => class name. + * @return array{0: string|null, 1: string} Array with 2 indexes. 0 => plugin name, 1 => class name. * @link https://book.cakephp.org/5/en/core-libraries/global-constants-and-functions.html#pluginsplit - * @phpstan-return array{string|null, string} */ function pluginSplit(string $name, bool $dotAppend = false, ?string $plugin = null): array { @@ -157,7 +156,7 @@ function pluginSplit(string $name, bool $dotAppend = false, ?string $plugin = nu $parts[0] .= '.'; } - /** @phpstan-var array{string, string} */ + /** @var array{string, string} */ return $parts; } @@ -248,7 +247,7 @@ function pj(mixed $var): mixed * environment information. * * @param string $key Environment variable name. - * @param string|bool|null $default Specify a default value in case the environment variable is not defined. + * @param string|float|int|bool|null $default Specify a default value in case the environment variable is not defined. * @return string|float|int|bool|null Environment variable setting. * @link https://book.cakephp.org/5/en/core-libraries/global-constants-and-functions.html#env */ diff --git a/src/Core/functions_global.php b/src/Core/functions_global.php index 6b466565bb5..0644ecc15d8 100644 --- a/src/Core/functions_global.php +++ b/src/Core/functions_global.php @@ -82,9 +82,8 @@ function h(mixed $text, bool $double = true, ?string $charset = null): mixed * @param string $name The name you want to plugin split. * @param bool $dotAppend Set to true if you want the plugin to have a '.' appended to it. * @param string|null $plugin Optional default plugin to use if no plugin is found. Defaults to null. - * @return array Array with 2 indexes. 0 => plugin name, 1 => class name. + * @return array{0: string|null, 1: string} Array with 2 indexes. 0 => plugin name, 1 => class name. * @link https://book.cakephp.org/5/en/core-libraries/global-constants-and-functions.html#pluginsplit - * @phpstan-return array{string|null, string} */ function pluginSplit(string $name, bool $dotAppend = false, ?string $plugin = null): array { @@ -155,7 +154,7 @@ function pj(mixed $var): mixed * environment information. * * @param string $key Environment variable name. - * @param string|bool|null $default Specify a default value in case the environment variable is not defined. + * @param string|float|int|bool|null $default Specify a default value in case the environment variable is not defined. * @return string|float|int|bool|null Environment variable setting. * @link https://book.cakephp.org/5/en/core-libraries/global-constants-and-functions.html#env */ diff --git a/src/Database/Connection.php b/src/Database/Connection.php index 119dfb9b66f..34e3ba62fa2 100644 --- a/src/Database/Connection.php +++ b/src/Database/Connection.php @@ -133,8 +133,7 @@ public function __construct(array $config) * Creates read and write drivers. * * @param array $config Connection config - * @return array - * @phpstan-return array{read: \Cake\Database\Driver, write: \Cake\Database\Driver} + * @return array{read: \Cake\Database\Driver, write: \Cake\Database\Driver} */ protected function createDrivers(array $config): array { diff --git a/src/Database/Driver.php b/src/Database/Driver.php index 552136c21b7..eabd43683a7 100644 --- a/src/Database/Driver.php +++ b/src/Database/Driver.php @@ -670,13 +670,11 @@ protected function _updateQueryTranslator(UpdateQuery $query): UpdateQuery /** * Removes aliases from the `WHERE` clause of a query. * - * @param \Cake\Database\Query\UpdateQuery|\Cake\Database\Query\DeleteQuery $query The query to process. - * @return \Cake\Database\Query\UpdateQuery|\Cake\Database\Query\DeleteQuery The modified query. + * @template T of \Cake\Database\Query\UpdateQuery|\Cake\Database\Query\DeleteQuery + * @param T $query The query to process. + * @return T The modified query. * @throws \Cake\Database\Exception\DatabaseException In case the processed query contains any joins, as removing * aliases from the conditions can break references to the joined tables. - * @template T of \Cake\Database\Query\UpdateQuery|\Cake\Database\Query\DeleteQuery - * @phpstan-param T $query - * @phpstan-return T */ protected function _removeAliasesFromConditions(UpdateQuery|DeleteQuery $query): UpdateQuery|DeleteQuery { diff --git a/src/Database/Driver/Postgres.php b/src/Database/Driver/Postgres.php index 78574daea08..a65f164cfa2 100644 --- a/src/Database/Driver/Postgres.php +++ b/src/Database/Driver/Postgres.php @@ -328,9 +328,7 @@ protected function _transformFunctionExpression(FunctionExpression $expression): $expression->setName('JSONB_PATH_QUERY') ->iterateParts(function ($p, $key) { if ($key === 0) { - $p = sprintf('%s::jsonb', $p); - } elseif ($key === 1) { - $p = sprintf("'%s'::jsonpath", $this->quoteIdentifier($p['value'])); + return sprintf('%s::jsonb', $p); } return $p; diff --git a/src/Database/Expression/WindowInterface.php b/src/Database/Expression/WindowInterface.php index f96441e71b4..31016cc0c31 100644 --- a/src/Database/Expression/WindowInterface.php +++ b/src/Database/Expression/WindowInterface.php @@ -133,16 +133,13 @@ public function groups(?int $start, ?int $end = 0); * - `0` - 'CURRENT ROW' * - `null` - 'UNBOUNDED' * - * @param string $type Frame type + * @param self::RANGE|self::ROWS|self::GROUPS $type Frame type * @param \Cake\Database\ExpressionInterface|string|int|null $startOffset Frame start offset - * @param string $startDirection Frame start direction + * @param self::PRECEDING|self::FOLLOWING $startDirection Frame start direction * @param \Cake\Database\ExpressionInterface|string|int|null $endOffset Frame end offset - * @param string $endDirection Frame end direction + * @param self::PRECEDING|self::FOLLOWING $endDirection Frame end direction * @return $this * @throws \InvalidArgumentException WHen offsets are negative. - * @phpstan-param self::RANGE|self::ROWS|self::GROUPS $type - * @phpstan-param self::PRECEDING|self::FOLLOWING $startDirection - * @phpstan-param self::PRECEDING|self::FOLLOWING $endDirection */ public function frame( string $type, diff --git a/src/Database/FunctionsBuilder.php b/src/Database/FunctionsBuilder.php index 2afbc46b53b..515e9a52ad8 100644 --- a/src/Database/FunctionsBuilder.php +++ b/src/Database/FunctionsBuilder.php @@ -23,7 +23,11 @@ /** * Contains methods related to generating FunctionExpression objects * with most commonly used SQL functions. + * * This acts as a factory for FunctionExpression objects. + * + * Supplying user-controlled data to parameters with the `ExpressionInterface|string` + * type is **unsafe**. These parameters are included in the final query without escaping. */ class FunctionsBuilder { @@ -40,7 +44,7 @@ public function rand(): FunctionExpression /** * Returns a AggregateExpression representing a call to SQL SUM function. * - * @param \Cake\Database\ExpressionInterface|string $expression the function argument + * @param \Cake\Database\ExpressionInterface|string $expression the expression for the sum() function. * @param array $types list of types to bind to the arguments * @return \Cake\Database\Expression\AggregateExpression */ @@ -57,7 +61,7 @@ public function sum(ExpressionInterface|string $expression, array $types = []): /** * Returns a AggregateExpression representing a call to SQL AVG function. * - * @param \Cake\Database\ExpressionInterface|string $expression the function argument + * @param \Cake\Database\ExpressionInterface|string $expression the expression for the avg() function. * @param array $types list of types to bind to the arguments * @return \Cake\Database\Expression\AggregateExpression */ @@ -69,7 +73,7 @@ public function avg(ExpressionInterface|string $expression, array $types = []): /** * Returns a AggregateExpression representing a call to SQL MAX function. * - * @param \Cake\Database\ExpressionInterface|string $expression the function argument + * @param \Cake\Database\ExpressionInterface|string $expression the expression for the max() function * @param array $types list of types to bind to the arguments * @return \Cake\Database\Expression\AggregateExpression */ @@ -81,7 +85,7 @@ public function max(ExpressionInterface|string $expression, array $types = []): /** * Returns a AggregateExpression representing a call to SQL MIN function. * - * @param \Cake\Database\ExpressionInterface|string $expression the function argument + * @param \Cake\Database\ExpressionInterface|string $expression the expression for the min() function. * @param array $types list of types to bind to the arguments * @return \Cake\Database\Expression\AggregateExpression */ @@ -93,7 +97,7 @@ public function min(ExpressionInterface|string $expression, array $types = []): /** * Returns a AggregateExpression representing a call to SQL COUNT function. * - * @param \Cake\Database\ExpressionInterface|string $expression the function argument + * @param \Cake\Database\ExpressionInterface|string $expression the expression for the count() function. * @param array $types list of types to bind to the arguments * @return \Cake\Database\Expression\AggregateExpression */ @@ -133,11 +137,12 @@ public function coalesce(array $args, array $types = []): FunctionExpression * is the default type name. Use `setReturnType()` to update it. * * @param \Cake\Database\ExpressionInterface|string $field Field or expression to cast. - * @param string $dataType The SQL data type + * @param string $dataType The SQL data type. Must be a simple alphanumeric string. * @return \Cake\Database\Expression\FunctionExpression */ public function cast(ExpressionInterface|string $field, string $dataType): FunctionExpression { + $this->ensureSimpleString('dataType', $dataType); $expression = new FunctionExpression('CAST', $this->toLiteralParam($field)); return $expression->setConjunction(' AS')->add([$dataType => 'literal']); @@ -159,7 +164,7 @@ public function dateDiff(array $args, array $types = []): FunctionExpression /** * Returns the specified date part from the SQL expression. * - * @param string $part Part of the date to return. + * @param string $part Part of the date to return. Must be a simple alphanumeric string. * @param \Cake\Database\ExpressionInterface|string $expression Expression to obtain the date part from. * @param array $types list of types to bind to the arguments * @return \Cake\Database\Expression\FunctionExpression @@ -175,13 +180,14 @@ public function datePart( /** * Returns the specified date part from the SQL expression. * - * @param string $part Part of the date to return. + * @param string $part Part of the date to return. Must be a simple alphanumeric string. * @param \Cake\Database\ExpressionInterface|string $expression Expression to obtain the date part from. * @param array $types list of types to bind to the arguments * @return \Cake\Database\Expression\FunctionExpression */ public function extract(string $part, ExpressionInterface|string $expression, array $types = []): FunctionExpression { + $this->ensureSimpleString('part', $part); $expression = new FunctionExpression('EXTRACT', $this->toLiteralParam($expression), $types, 'integer'); return $expression->setConjunction(' FROM')->add([$part => 'literal'], [], true); @@ -192,7 +198,7 @@ public function extract(string $part, ExpressionInterface|string $expression, ar * * @param \Cake\Database\ExpressionInterface|string $expression Expression to obtain the date part from. * @param string|int $value Value to be added. Use negative to subtract. - * @param string $unit Unit of the value e.g. hour or day. + * @param string $unit Unit of the value e.g. hour or day. Must be a simple alphanumeric string. * @param array $types list of types to bind to the arguments * @return \Cake\Database\Expression\FunctionExpression */ @@ -205,6 +211,7 @@ public function dateAdd( if (!is_numeric($value)) { $value = 0; } + $this->ensureSimpleString('unit', $unit); $interval = $value . ' ' . $unit; $expression = new FunctionExpression('DATE_ADD', $this->toLiteralParam($expression), $types, 'datetime'); @@ -345,7 +352,7 @@ public function jsonValue( * @param string $name The SQL aggregate function name * @param array $params Array of arguments to be passed to the function. * Can be an associative array with the literal value or identifier: - * `['value' => 'literal']` or `['value' => 'identifier'] + * `['value' => 'literal']` or `['value' => 'identifier']` * @param array $types Array of types that match the names used in `$params`: * `['name' => 'type']` * @param string $return Return type of the entire expression. Defaults to float. @@ -388,4 +395,19 @@ protected function toLiteralParam(ExpressionInterface|string $expression): array return [$expression]; } + + /** + * Ensures that string values are simple ascii values with no whitespace + * + * @param string $parameterName The name of the parameter being checked. + * @param string $value The value to check + * @return void + */ + protected function ensureSimpleString(string $parameterName, string $value): void + { + if (preg_match('/^[a-zA-Z0-9]+$/', $value)) { + return; + } + throw new InvalidArgumentException("Argument `{$parameterName}` must be an alphanumeric string"); + } } diff --git a/src/Database/Schema/Column.php b/src/Database/Schema/Column.php index 3706ee67548..aa6d2ba0e52 100644 --- a/src/Database/Schema/Column.php +++ b/src/Database/Schema/Column.php @@ -409,7 +409,7 @@ public function setComment(?string $comment) /** * Gets the column comment. * - * @return string + * @return string|null */ public function getComment(): ?string { @@ -597,7 +597,7 @@ public function setAttributes(array $attributes) /** * Convert an index into an array that is compatible with the Column constructor. * - * @return array + * @return array{name: ?string, baseType: ?string, type: string, length: ?int, null: ?bool, default: mixed, generated: ?string, unsigned: ?bool, onUpdate: ?string, collate: ?string, precision: ?int, srid: ?int, comment: ?string, autoIncrement: bool, identity: bool, fixed: ?bool, geometryType?: ?string} */ public function toArray(): array { diff --git a/src/Database/Schema/PostgresSchemaDialect.php b/src/Database/Schema/PostgresSchemaDialect.php index 0357240baa4..89831afc3f5 100644 --- a/src/Database/Schema/PostgresSchemaDialect.php +++ b/src/Database/Schema/PostgresSchemaDialect.php @@ -918,10 +918,11 @@ public function indexSql(TableSchema $schema, string $name): string (array)$index->getColumns(), ); $include = ''; - if ($index->getInclude()) { + $includes = $index->getInclude(); + if ($includes) { $included = array_map( $this->_driver->quoteIdentifier(...), - $index->getInclude(), + $includes, ); $include = sprintf(' INCLUDE (%s)', implode(', ', $included)); } diff --git a/src/Database/Schema/TableSchema.php b/src/Database/Schema/TableSchema.php index 49c7b4479c7..7a4b633ae82 100644 --- a/src/Database/Schema/TableSchema.php +++ b/src/Database/Schema/TableSchema.php @@ -435,18 +435,14 @@ public function getColumn(string $name): ?array if (isset(static::$_columnExtras[$attrs['type']])) { $expected += static::$_columnExtras[$attrs['type']]; } - // Remove any attributes that weren't in the allow list. - // This is to provide backwards compatible keys - $remove = array_diff(array_keys($attrs), array_keys($expected)); - foreach ($remove as $key) { - unset($attrs[$key]); - } if (isset($attrs['baseType']) && $attrs['baseType'] === $attrs['type']) { unset($attrs['baseType']); } - return $attrs; + // Remove any attributes that weren't in the allow list. + // This is to provide backwards compatible keys + return array_intersect_key($attrs, $expected); } /** diff --git a/src/Database/Statement/Statement.php b/src/Database/Statement/Statement.php index 9c751784f65..73024e98464 100644 --- a/src/Database/Statement/Statement.php +++ b/src/Database/Statement/Statement.php @@ -101,8 +101,7 @@ public function bindValue(string|int $column, mixed $value, string|int|null $typ * * @param mixed $value The value to cast. * @param \Cake\Database\TypeInterface|string|int $type The type name or type instance to use. - * @return array List containing converted value and internal type. - * @phpstan-return array{0:mixed, 1:int} + * @return array{0:mixed, 1:int} List containing converted value and internal type. */ protected function cast(mixed $value, TypeInterface|string|int $type = 'string'): array { diff --git a/src/Database/TypeFactory.php b/src/Database/TypeFactory.php index 7c97a789f59..6d1efb44499 100644 --- a/src/Database/TypeFactory.php +++ b/src/Database/TypeFactory.php @@ -26,8 +26,7 @@ class TypeFactory * identifier is used as key and a complete namespaced class name as value * representing the class that will do actual type conversions. * - * @var array - * @phpstan-var array> + * @var array> */ protected static array $_types = [ 'biginteger' => Type\IntegerType::class, @@ -118,9 +117,8 @@ public static function set(string $name, TypeInterface $instance): void * Registers a new type identifier and maps it to a fully namespaced classname. * * @param string $type Name of type to map. - * @param string $className The classname to register. + * @param class-string<\Cake\Database\TypeInterface> $className The classname to register. * @return void - * @phpstan-param class-string<\Cake\Database\TypeInterface> $className */ public static function map(string $type, string $className): void { @@ -131,9 +129,8 @@ public static function map(string $type, string $className): void /** * Set type to classname mapping. * - * @param array $map List of types to be mapped. + * @param array> $map List of types to be mapped. * @return void - * @phpstan-param array> $map */ public static function setMap(array $map): void { @@ -168,8 +165,7 @@ public static function getMap(?string $type = null): array|string|null * Get mapped class name for a specific type. * * @param string $type Type name to get mapped class for. - * @return string|null Configured class name for given $type or null if not found. - * @phpstan-return class-string<\Cake\Database\TypeInterface>|null + * @return class-string<\Cake\Database\TypeInterface>|null Configured class name for given $type or null if not found. */ public static function getMapped(string $type): ?string { diff --git a/src/Database/phpstan.neon.dist b/src/Database/phpstan.neon.dist index ca17bf0b54d..573d19e7da3 100644 --- a/src/Database/phpstan.neon.dist +++ b/src/Database/phpstan.neon.dist @@ -9,13 +9,10 @@ parameters: - vendor/ ignoreErrors: - '#Unsafe usage of new static\(\).#' - - - identifier: notIdentical.alwaysTrue - identifier: missingType.iterableValue - - message: '#^Dead catch \- InvalidArgumentException is never thrown in the try block\.$#' identifier: catch.neverThrown - count: 1 path: Type/DateTimeType.php + reportUnmatched: false diff --git a/src/Datasource/ConnectionManager.php b/src/Datasource/ConnectionManager.php index 20e990a270e..114fbb89f1e 100644 --- a/src/Datasource/ConnectionManager.php +++ b/src/Datasource/ConnectionManager.php @@ -51,8 +51,7 @@ class ConnectionManager /** * An array mapping url schemes to fully qualified driver class names * - * @var array - * @phpstan-var array + * @var array */ protected static array $_dsnClassMap = [ 'mysql' => Mysql::class, diff --git a/src/Error/Debugger.php b/src/Error/Debugger.php index b834e1bd0e7..ba73e1df3de 100644 --- a/src/Error/Debugger.php +++ b/src/Error/Debugger.php @@ -62,7 +62,15 @@ class Debugger * @var array */ protected array $_defaultConfig = [ - 'outputMask' => [], + 'outputMask' => [ + 'password' => '********', + 'login' => '********', + 'host' => '********', + 'database' => '********', + 'port' => '********', + 'prefix' => '********', + 'schema' => '********', + ], 'exportFormatter' => null, 'editor' => 'phpstorm', 'editorBasePath' => null, @@ -661,7 +669,7 @@ protected static function export(mixed $var, DebugContext $context): NodeInterfa /** * Export an array type object. Filters out keys used in datasource configuration. * - * The following keys are replaced with ***'s + * By default the following keys are replaced with ***'s * * - password * - login diff --git a/src/Event/Event.php b/src/Event/Event.php index cc1163d458f..b7ed901e419 100644 --- a/src/Event/Event.php +++ b/src/Event/Event.php @@ -78,7 +78,6 @@ class Event implements EventInterface * (usually the object that is generating the event). * @param array $data any value you wish to be transported * with this event to it can be read by listeners. - * @phpstan-param TSubject|null $subject */ public function __construct(string $name, ?object $subject = null, array $data = []) { diff --git a/src/Event/EventDispatcherTrait.php b/src/Event/EventDispatcherTrait.php index 0856b75eddb..c5fbda4c005 100644 --- a/src/Event/EventDispatcherTrait.php +++ b/src/Event/EventDispatcherTrait.php @@ -78,7 +78,6 @@ public function setEventManager(EventManagerInterface $eventManager) * @param TSubject|null $subject The object that this event applies to * ($this by default). * @return \Cake\Event\EventInterface - * @phpstan-ignore missingType.generics */ public function dispatchEvent(string $name, array $data = [], ?object $subject = null): EventInterface // @phpstan-ignore missingType.generics { @@ -86,7 +85,6 @@ public function dispatchEvent(string $name, array $data = [], ?object $subject = /** * @var \Cake\Event\EventInterface $event Coerce for psalm/phpstan - * @phpstan-ignore missingType.generics (TSubject may itself be generic) */ $event = new $this->_eventClass($name, $subject, $data); $this->getEventManager()->dispatch($event); diff --git a/src/Form/Form.php b/src/Form/Form.php index e5270cc495e..519187f5e05 100644 --- a/src/Form/Form.php +++ b/src/Form/Form.php @@ -74,8 +74,7 @@ class Form implements EventListenerInterface, EventDispatcherInterface, Validato /** * Schema class. * - * @var string - * @phpstan-var class-string<\Cake\Form\Schema> + * @var class-string<\Cake\Form\Schema> */ protected string $_schemaClass = Schema::class; diff --git a/src/Form/FormProtector.php b/src/Form/FormProtector.php index b303a3629b9..61b8a122db0 100644 --- a/src/Form/FormProtector.php +++ b/src/Form/FormProtector.php @@ -270,8 +270,7 @@ protected function extractToken(mixed $formData): ?string * Return hash parts for the token generation * * @param array $formData Form data. - * @return array Contains 'fields' and 'unlockedFields' keys. Additional keys allowed. - * @phpstan-return array{fields: array, unlockedFields: array, ...} + * @return array{fields: array, unlockedFields: array, ...} Contains 'fields' and 'unlockedFields' keys. Additional keys allowed. */ protected function extractHashParts(array $formData): array { @@ -380,8 +379,7 @@ protected function sortedUnlockedFields(array $formData): array * * @param string $url Form URL. * @param string $sessionId Session ID. - * @return array The token data. Contains 'fields', 'unlocked', and 'debug' keys. Additional keys allowed. - * @phpstan-return array{fields: string, unlocked: string, debug: string, ...} + * @return array{fields: string, unlocked: string, debug: string, ...} The token data. Contains 'fields', 'unlocked', and 'debug' keys. Additional keys allowed. */ public function buildTokenData(string $url = '', string $sessionId = ''): array { diff --git a/src/Http/Client/Adapter/Curl.php b/src/Http/Client/Adapter/Curl.php index 40aca2afe0a..118b2539292 100644 --- a/src/Http/Client/Adapter/Curl.php +++ b/src/Http/Client/Adapter/Curl.php @@ -128,7 +128,7 @@ public function buildOptions(RequestInterface $request, array $options): array } if (empty($options['ssl_cafile'])) { - $options['ssl_cafile'] = CaBundle::getBundledCaBundlePath(); + $options['ssl_cafile'] = ini_get('curl.cainfo') ?: CaBundle::getBundledCaBundlePath(); } if (!empty($options['ssl_verify_host'])) { // Value of 1 or true is deprecated. Only 2 or 0 should be used now. diff --git a/src/Http/Client/Request.php b/src/Http/Client/Request.php index 16f2b70474e..52abaa3dbb3 100644 --- a/src/Http/Client/Request.php +++ b/src/Http/Client/Request.php @@ -36,10 +36,9 @@ class Request extends Message implements RequestInterface * * Provides backwards compatible defaults for some properties. * - * @phpstan-param array $headers * @param \Psr\Http\Message\UriInterface|string $url The request URL * @param string $method The HTTP method to use. - * @param array $headers The HTTP headers to set. + * @param array $headers The HTTP headers to set. * @param array|string|null $data The request body to use. */ public function __construct( @@ -65,8 +64,7 @@ public function __construct( /** * Add an array of headers to the request. * - * @phpstan-param array $headers - * @param array $headers The headers to add. + * @param array $headers The headers to add. * @return void */ protected function addHeaders(array $headers): void @@ -101,7 +99,7 @@ protected function setContent(array|string $content) $formData = new FormData(); $formData->addMany($content); - /** @phpstan-var array $headers */ + /** @var array $headers */ $headers = ['Content-Type' => $formData->contentType()]; $this->addHeaders($headers); $content = (string)$formData; diff --git a/src/Http/ControllerFactoryInterface.php b/src/Http/ControllerFactoryInterface.php index d7e34fb0bd7..8406b440891 100644 --- a/src/Http/ControllerFactoryInterface.php +++ b/src/Http/ControllerFactoryInterface.php @@ -30,18 +30,16 @@ interface ControllerFactoryInterface * Create a controller for a given request * * @param \Psr\Http\Message\ServerRequestInterface $request The request to build a controller for. - * @return mixed + * @return TController * @throws \Cake\Http\Exception\MissingControllerException - * @phpstan-return TController */ public function create(ServerRequestInterface $request): mixed; /** * Invoke a controller's action and wrapping methods. * - * @param mixed $controller The controller to invoke. + * @param TController $controller The controller to invoke. * @return \Psr\Http\Message\ResponseInterface The response - * @phpstan-param TController $controller */ public function invoke(mixed $controller): ResponseInterface; } diff --git a/src/Http/Response.php b/src/Http/Response.php index 29f84f62443..7fd55100ce0 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -304,8 +304,7 @@ public function withLocation(string $url): static /** * Sets a header. * - * @phpstan-param non-empty-string $header - * @param string $header Header key. + * @param non-empty-string $header Header key. * @param string $value Header value. * @return void */ @@ -319,8 +318,7 @@ protected function _setHeader(string $header, string $value): void /** * Clear header * - * @phpstan-param non-empty-string $header - * @param string $header Header key. + * @param non-empty-string $header Header key. * @return void */ protected function _clearHeader(string $header): void diff --git a/src/Http/ServerRequest.php b/src/Http/ServerRequest.php index 1e29dd3b6d5..8079954f00e 100644 --- a/src/Http/ServerRequest.php +++ b/src/Http/ServerRequest.php @@ -430,8 +430,10 @@ public function getTrustedProxies(): array /** * Returns the referer that referred this request. * - * @param bool $local Attempt to return a local address. - * Local addresses do not contain hostnames. + * @param bool $local When true, return the referer as a host-stripped local path, + * or null if the referer is not on the same origin as this application. + * When false, return the raw referer URL as-is. In both cases null is returned + * when no referer is available. * @return string|null The referring address for this request or null. */ public function referer(bool $local = true): ?string diff --git a/src/Http/UriFactory.php b/src/Http/UriFactory.php index 0ba277139c9..05037457549 100644 --- a/src/Http/UriFactory.php +++ b/src/Http/UriFactory.php @@ -44,8 +44,7 @@ public function createUri(string $uri = ''): UriInterface * * @param array|null $server Array of server data to build the Uri from. * $_SERVER will be used if $server parameter is null. - * @return array - * @phpstan-return array{uri: \Psr\Http\Message\UriInterface, base: string, webroot: string} + * @return array{uri: \Psr\Http\Message\UriInterface, base: string, webroot: string} */ public static function marshalUriAndBaseFromSapi(?array $server = null): array { @@ -117,8 +116,7 @@ protected static function updatePath(string $base, UriInterface $uri): UriInterf * * @param \Psr\Http\Message\UriInterface $uri The Uri instance. * @param array $server The SERVER data to use. - * @return array An array containing the base and webroot paths. - * @phpstan-return array{base: string, webroot: string} + * @return array{base: string, webroot: string} An array containing the base and webroot paths. */ protected static function getBase(UriInterface $uri, array $server): array { diff --git a/src/I18n/Date.php b/src/I18n/Date.php index a1a699059f4..671408e3d56 100644 --- a/src/I18n/Date.php +++ b/src/I18n/Date.php @@ -29,7 +29,7 @@ * * Adds handy methods and locale-aware formatting helpers. * - * @phpstan-immutable + * @immutable */ class Date extends ChronosDate implements JsonSerializable, Stringable { diff --git a/src/I18n/DateTime.php b/src/I18n/DateTime.php index 462bddffba4..aee19dfbd51 100644 --- a/src/I18n/DateTime.php +++ b/src/I18n/DateTime.php @@ -29,7 +29,7 @@ * Extends the built-in DateTime class to provide handy methods and locale-aware * formatting helpers. * - * @phpstan-immutable + * @immutable */ class DateTime extends Chronos implements JsonSerializable, Stringable { diff --git a/src/I18n/RelativeTimeFormatter.php b/src/I18n/RelativeTimeFormatter.php index b202daad0c4..56fd9b96a2b 100644 --- a/src/I18n/RelativeTimeFormatter.php +++ b/src/I18n/RelativeTimeFormatter.php @@ -413,9 +413,8 @@ public function dateAgoInWords(DateTime|Date $date, array $options = []): string * Build the options for relative date formatting. * * @param array $options The options provided by the user. - * @param string $class The class name to use for defaults. + * @param class-string<\Cake\I18n\Date>|class-string<\Cake\I18n\DateTime> $class The class name to use for defaults. * @return array Options with defaults applied. - * @phpstan-param class-string<\Cake\I18n\Date>|class-string<\Cake\I18n\DateTime> $class */ protected function _options(array $options, string $class): array { diff --git a/src/I18n/Time.php b/src/I18n/Time.php index 79def737ae2..8913e2c64b3 100644 --- a/src/I18n/Time.php +++ b/src/I18n/Time.php @@ -28,7 +28,7 @@ * * Adds handy methods and locale-aware formatting helpers. * - * @phpstan-immutable + * @immutable */ class Time extends ChronosTime implements JsonSerializable, Stringable { diff --git a/src/Log/Log.php b/src/Log/Log.php index 0440836c1ac..ffecaf2b54f 100644 --- a/src/Log/Log.php +++ b/src/Log/Log.php @@ -115,8 +115,7 @@ class Log /** * An array mapping url schemes to fully qualified Log engine class names * - * @var array - * @phpstan-var array + * @var array */ protected static array $_dsnClassMap = [ 'console' => Engine\ConsoleLog::class, diff --git a/src/Mailer/AbstractTransport.php b/src/Mailer/AbstractTransport.php index 4964d1c10c6..e72218490cd 100644 --- a/src/Mailer/AbstractTransport.php +++ b/src/Mailer/AbstractTransport.php @@ -37,8 +37,7 @@ abstract class AbstractTransport * Send mail * * @param \Cake\Mailer\Message $message Email message. - * @return array Contains 'headers' and 'message' keys. Additional keys allowed. - * @phpstan-return array{headers: string, message: string, ...} + * @return array{headers: string, message: string, ...} Contains 'headers' and 'message' keys. Additional keys allowed. */ abstract public function send(Message $message): array; diff --git a/src/Mailer/Mailer.php b/src/Mailer/Mailer.php index 3f4b32c6250..efc69281927 100644 --- a/src/Mailer/Mailer.php +++ b/src/Mailer/Mailer.php @@ -153,8 +153,7 @@ class Mailer implements EventListenerInterface /** * Message class name. * - * @var string - * @phpstan-var class-string<\Cake\Mailer\Message> + * @var class-string<\Cake\Mailer\Message> */ protected string $messageClass = Message::class; @@ -187,8 +186,7 @@ class Mailer implements EventListenerInterface /** * Mailer driver class map. * - * @var array - * @phpstan-var array + * @var array */ protected static array $_dsnClassMap = []; @@ -315,8 +313,7 @@ public function setViewVars(array|string $key, mixed $value = null) * If no action is specified then all other method arguments will be ignored. * @param array $args Arguments to pass to the triggered mailer action. * @param array $headers Headers to set. - * @return array Contains 'headers' and 'message' keys. Additional keys allowed. - * @phpstan-return array{headers: string, message: string, ...} + * @return array{headers: string, message: string, ...} Contains 'headers' and 'message' keys. Additional keys allowed. * @throws \Cake\Mailer\Exception\MissingActionException * @throws \BadMethodCallException */ @@ -373,8 +370,7 @@ public function render(string $content = '') * Render content and send email using configured transport. * * @param string $content Content. - * @return array Contains 'headers' and 'message' keys. Additional keys allowed. - * @phpstan-return array{headers: string, message: string, ...} + * @return array{headers: string, message: string, ...} Contains 'headers' and 'message' keys. Additional keys allowed. */ public function deliver(string $content = ''): array { @@ -552,8 +548,7 @@ public function reset() /** * Log the email message delivery. * - * @param array $contents The content with 'headers' and 'message' keys. - * @phpstan-param array{headers: string, message: string, ...} $contents + * @param array{headers: string, message: string, ...} $contents The content with 'headers' and 'message' keys. * @return void */ protected function logDelivery(array $contents): void diff --git a/src/Mailer/Renderer.php b/src/Mailer/Renderer.php index d1d9f411fdf..1459a2afebd 100644 --- a/src/Mailer/Renderer.php +++ b/src/Mailer/Renderer.php @@ -49,10 +49,8 @@ public function __construct() * of the specified content types for the email. * * @param string $content The content. - * @param array $types Content types to render. Valid array values are {@link Message::MESSAGE_HTML}, {@link Message::MESSAGE_TEXT}. - * @return array The rendered content with "html" and/or "text" keys. - * @phpstan-param array<\Cake\Mailer\Message::MESSAGE_HTML|\Cake\Mailer\Message::MESSAGE_TEXT> $types - * @phpstan-return array{html?: string, text?: string} + * @param array<\Cake\Mailer\Message::MESSAGE_HTML|\Cake\Mailer\Message::MESSAGE_TEXT> $types Content types to render. Valid array values are {@link Message::MESSAGE_HTML}, {@link Message::MESSAGE_TEXT}. + * @return array{html?: string, text?: string} The rendered content with "html" and/or "text" keys. */ public function render(string $content, array $types = []): array { diff --git a/src/Mailer/Transport/SmtpTransport.php b/src/Mailer/Transport/SmtpTransport.php index 90f77292c91..175ad78f4cd 100644 --- a/src/Mailer/Transport/SmtpTransport.php +++ b/src/Mailer/Transport/SmtpTransport.php @@ -202,8 +202,7 @@ public function getLastResponse(): array * Send mail * * @param \Cake\Mailer\Message $message Message instance - * @return array Contains 'headers' and 'message' keys. Additional keys allowed. - * @phpstan-return array{headers: string, message: string, ...} + * @return array{headers: string, message: string, ...} Contains 'headers' and 'message' keys. Additional keys allowed. * @throws \Cake\Network\Exception\SocketException */ public function send(Message $message): array @@ -558,16 +557,22 @@ protected function _sendData(Message $message): void { $this->_smtpSend('DATA', '354'); - $headers = $message->getHeadersString([ - 'from', - 'sender', - 'replyTo', - 'readReceipt', - 'to', - 'cc', - 'subject', - 'returnPath', - ]); + $headers = $message->getHeadersString( + [ + 'from', + 'sender', + 'replyTo', + 'readReceipt', + 'to', + 'cc', + 'subject', + 'returnPath', + ], + "\r\n", + function (string $val): string { + return str_replace("\r\n", '', $val); + }, + ); $message = $this->_prepareMessage($message); $this->_smtpSend($headers . "\r\n\r\n" . $message . "\r\n\r\n\r\n."); diff --git a/src/Mailer/TransportFactory.php b/src/Mailer/TransportFactory.php index 4794e899ed8..a35e149f8d7 100644 --- a/src/Mailer/TransportFactory.php +++ b/src/Mailer/TransportFactory.php @@ -36,8 +36,7 @@ class TransportFactory /** * An array mapping url schemes to fully qualified Transport class names * - * @var array - * @phpstan-var array + * @var array */ protected static array $_dsnClassMap = [ 'debug' => Transport\DebugTransport::class, diff --git a/src/ORM/Association/BelongsTo.php b/src/ORM/Association/BelongsTo.php index 908b2914d9e..a504900c476 100644 --- a/src/ORM/Association/BelongsTo.php +++ b/src/ORM/Association/BelongsTo.php @@ -148,7 +148,6 @@ public function saveAssociated(EntityInterface $entity, array $options = []): En $targetEntity->extract((array)$this->getBindingKey()), ); - // @phpstan-ignore function.alreadyNarrowedType (patch method available on EntityInterface) if (method_exists($entity, 'patch')) { $entity = $entity->patch($properties, ['guard' => false]); } else { diff --git a/src/ORM/Association/HasMany.php b/src/ORM/Association/HasMany.php index 3b352b1dbf3..5325d670fb7 100644 --- a/src/ORM/Association/HasMany.php +++ b/src/ORM/Association/HasMany.php @@ -228,7 +228,6 @@ protected function _saveTarget( } if ($foreignKeyReference !== $entity->extract($foreignKey)) { - // @phpstan-ignore function.alreadyNarrowedType (patch method available on EntityInterface) if (method_exists($entity, 'patch')) { $entity->patch($foreignKeyReference, ['guard' => false]); } else { diff --git a/src/ORM/Association/HasOne.php b/src/ORM/Association/HasOne.php index 71d7d4da7b0..51012c7733f 100644 --- a/src/ORM/Association/HasOne.php +++ b/src/ORM/Association/HasOne.php @@ -127,7 +127,6 @@ public function saveAssociated(EntityInterface $entity, array $options = []): En $foreignKeys, $entity->extract((array)$this->getBindingKey()), ); - // @phpstan-ignore function.alreadyNarrowedType (patch method available on EntityInterface) if (method_exists($targetEntity, 'patch')) { $targetEntity = $targetEntity->patch($properties, ['guard' => false]); } else { diff --git a/src/ORM/Association/Loader/SelectLoader.php b/src/ORM/Association/Loader/SelectLoader.php index 866e6ac7fbd..1ca8d0b5b2b 100644 --- a/src/ORM/Association/Loader/SelectLoader.php +++ b/src/ORM/Association/Loader/SelectLoader.php @@ -463,10 +463,15 @@ protected function _subqueryFields(SelectQuery $query): array $order = $query->clause('order'); if ($order) { $columns = $query->clause('select'); - $order->iterateParts(function ($direction, $field) use (&$fields, $columns): void { + // iterateParts() rebuilds the expression from the callback's return value, so each part + // has to be handed back. Returning nothing would strip the ORDER BY from $query itself, + // which is the caller's query, not a clone of it. + $order->iterateParts(function ($direction, $field) use (&$fields, $columns) { if (isset($columns[$field])) { $fields[$field] = $columns[$field]; } + + return $direction; }); } diff --git a/src/ORM/AssociationCollection.php b/src/ORM/AssociationCollection.php index a286d783f6a..05667b364d6 100644 --- a/src/ORM/AssociationCollection.php +++ b/src/ORM/AssociationCollection.php @@ -68,13 +68,11 @@ public function __construct(?LocatorInterface $tableLocator = null) * If the alias added contains a `.` the part preceding the `.` will be dropped. * This makes using plugins simpler as the Plugin.Class syntax is frequently used. * + * @template T of \Cake\ORM\Association * @param string $alias The association alias - * @param \Cake\ORM\Association $association The association to add. - * @return \Cake\ORM\Association The association object being added. + * @param T $association The association to add. + * @return T The association object being added. * @throws \Cake\Core\Exception\CakeException If the alias is already added. - * @template T of \Cake\ORM\Association - * @phpstan-param T $association - * @phpstan-return T */ public function add(string $alias, Association $association): Association { @@ -90,14 +88,12 @@ public function add(string $alias, Association $association): Association /** * Creates and adds the Association object to this collection. * - * @param string $className The name of association class. + * @template T of \Cake\ORM\Association + * @param class-string $className The name of association class. * @param string $associated The alias for the target table. * @param array $options List of options to configure the association definition. - * @return \Cake\ORM\Association + * @return T * @throws \InvalidArgumentException - * @template T of \Cake\ORM\Association - * @phpstan-param class-string $className - * @phpstan-return T */ public function load(string $className, string $associated, array $options = []): Association { diff --git a/src/ORM/Behavior/TranslateBehavior.php b/src/ORM/Behavior/TranslateBehavior.php index c1950963ca5..4d9c2f3d8b0 100644 --- a/src/ORM/Behavior/TranslateBehavior.php +++ b/src/ORM/Behavior/TranslateBehavior.php @@ -73,8 +73,7 @@ class TranslateBehavior extends Behavior implements PropertyMarshalInterface /** * Default strategy class name. * - * @var string - * @phpstan-var class-string<\Cake\ORM\Behavior\Translate\TranslateStrategyInterface> + * @var class-string<\Cake\ORM\Behavior\Translate\TranslateStrategyInterface> */ protected static string $defaultStrategyClass = ShadowTableStrategy::class; @@ -136,10 +135,9 @@ public function initialize(array $config): void /** * Set default strategy class name. * - * @param string $class Class name. + * @param class-string<\Cake\ORM\Behavior\Translate\TranslateStrategyInterface> $class Class name. * @return void * @since 4.0.0 - * @phpstan-param class-string<\Cake\ORM\Behavior\Translate\TranslateStrategyInterface> $class */ public static function setDefaultStrategyClass(string $class): void { @@ -149,9 +147,8 @@ public static function setDefaultStrategyClass(string $class): void /** * Get default strategy class name. * - * @return string + * @return class-string<\Cake\ORM\Behavior\Translate\TranslateStrategyInterface> * @since 4.0.0 - * @phpstan-return class-string<\Cake\ORM\Behavior\Translate\TranslateStrategyInterface> */ public static function getDefaultStrategyClass(): string { diff --git a/src/ORM/Behavior/TreeBehavior.php b/src/ORM/Behavior/TreeBehavior.php index 2a9ffde9bed..f86cfee3366 100644 --- a/src/ORM/Behavior/TreeBehavior.php +++ b/src/ORM/Behavior/TreeBehavior.php @@ -944,7 +944,6 @@ protected function _ensureFields(EntityInterface $entity): void } $fresh = $this->_table->get($entity->get($this->_getPrimaryKey())); - // @phpstan-ignore function.alreadyNarrowedType (patch method available on EntityInterface) if (method_exists($entity, 'patch')) { $entity->patch($fresh->extract($fields), ['guard' => false]); } else { diff --git a/src/ORM/BehaviorRegistry.php b/src/ORM/BehaviorRegistry.php index b32c93fc2ba..4e6e3c95e3b 100644 --- a/src/ORM/BehaviorRegistry.php +++ b/src/ORM/BehaviorRegistry.php @@ -91,8 +91,7 @@ public function setTable(Table $table): void * Resolve a behavior classname. * * @param string $class Partial classname to resolve. - * @return string|null Either the correct classname or null. - * @phpstan-return class-string|null + * @return class-string|null Either the correct classname or null. */ public static function className(string $class): ?string { diff --git a/src/ORM/Marshaller.php b/src/ORM/Marshaller.php index b21f9149aac..7573fc2cfac 100644 --- a/src/ORM/Marshaller.php +++ b/src/ORM/Marshaller.php @@ -250,7 +250,6 @@ public function one(array $data, array $options = []): EntityInterface $entity->set($field, $properties[$field], ['asOriginal' => true]); } } - // @phpstan-ignore function.alreadyNarrowedType (patch method available on EntityInterface) } elseif (method_exists($entity, 'patch')) { $entity->patch($properties, ['asOriginal' => true]); } else { @@ -624,7 +623,6 @@ public function merge(EntityInterface $entity, array $data, array $options = []) $entity->setErrors($errors); if (!isset($options['fields'])) { - // @phpstan-ignore function.alreadyNarrowedType (patch method available on EntityInterface) if (method_exists($entity, 'patch')) { $entity->patch($properties); } else { diff --git a/src/ORM/Table.php b/src/ORM/Table.php index 688f7d4a84a..1ed4860d007 100644 --- a/src/ORM/Table.php +++ b/src/ORM/Table.php @@ -263,8 +263,7 @@ class Table implements RepositoryInterface, EventListenerInterface, EventDispatc /** * The name of the class that represent a single row for this table * - * @var string|null - * @phpstan-var class-string|null + * @var class-string|null */ protected ?string $_entityClass = null; @@ -843,11 +842,9 @@ public function behaviors(): BehaviorRegistry /** * Get a behavior from the registry. * - * @param string $name The behavior alias to get from the registry. - * @return \Cake\ORM\Behavior + * @param TName $name The behavior alias to get from the registry. + * @return (TName is key-of ? TBehaviors[TName] : \Cake\ORM\Behavior) * @template TName of string - * @phpstan-param TName $name The behavior alias to get from the registry. - * @phpstan-return (TName is key-of ? TBehaviors[TName] : \Cake\ORM\Behavior) * @throws \InvalidArgumentException If the behavior does not exist. */ public function getBehavior(string $name): Behavior @@ -2195,7 +2192,6 @@ protected function _insert(EntityInterface $entity, array $data): EntityInterfac if ($statement->rowCount() !== 0) { $success = $entity; - // @phpstan-ignore function.alreadyNarrowedType (patch method available on EntityInterface) if (method_exists($entity, 'patch')) { $entity = $entity->patch($filteredKeys, ['guard' => false]); } else { diff --git a/src/Routing/Asset.php b/src/Routing/Asset.php index a76dcbe5dee..16708c63adf 100644 --- a/src/Routing/Asset.php +++ b/src/Routing/Asset.php @@ -355,8 +355,7 @@ protected static function requestWebroot(): string * It checks if the plugin is loaded, else filename will stay unchanged for filenames containing dot. * * @param string $name The name you want to plugin split. - * @return array Array with 2 indexes. 0 => plugin name, 1 => filename. - * @phpstan-return array{string|null, string} + * @return array{0: string|null, 1: string} Array with 2 indexes. 0 => plugin name, 1 => filename. */ protected static function pluginSplit(string $name): array { diff --git a/src/Routing/Route/Route.php b/src/Routing/Route/Route.php index e513a788e5a..25ace3da42c 100644 --- a/src/Routing/Route/Route.php +++ b/src/Routing/Route/Route.php @@ -22,6 +22,7 @@ use InvalidArgumentException; use Psr\Http\Message\ServerRequestInterface; use UnitEnum; +use function Cake\Routing\urldecodeSegments; /** * A single Route used by the Router to connect requests to @@ -468,8 +469,8 @@ public function parse(string $url, string $method): ?array [$url, $ext] = $this->_parseExtension($url); $urldecode = $this->options['_urldecode'] ?? true; - if ($urldecode) { - $url = urldecode($url); + if ($urldecode && str_contains($url, '%')) { + $url = urldecodeSegments($url); } if (!preg_match($compiledRoute, $url, $route)) { diff --git a/src/Routing/RouteCollection.php b/src/Routing/RouteCollection.php index ecec092a060..a7b30f51809 100644 --- a/src/Routing/RouteCollection.php +++ b/src/Routing/RouteCollection.php @@ -136,15 +136,10 @@ public function add(Route $route, array $options = []): void public function parseRequest(ServerRequestInterface $request): array { $uri = $request->getUri(); + $urlPath = $uri->getPath(); if (str_contains($urlPath, '%')) { - // decode urlencoded segments, but don't decode %2f aka / - $parts = explode('/', $urlPath); - $parts = array_map( - fn(string $part) => str_replace('/', '%2f', urldecode($part)), - $parts, - ); - $urlPath = implode('/', $parts); + $urlPath = urldecodeSegments($urlPath); } if ($urlPath !== '/') { $urlPath = rtrim($urlPath, '/'); @@ -294,10 +289,17 @@ public function match(array $url, array $context): string if ($out) { return $out; } + $message = sprintf( + 'A named route was found for `%s`, but matching failed. Passed parameters: `%s`.', + $name, + (string)json_encode($url), + ); + throw new MissingRouteException([ 'url' => $name, 'context' => $context, - 'message' => "A named route was found for `{$name}`, but matching failed.", + // Escape `%` so the message survives CakeException's vsprintf() pass unchanged. + 'message' => str_replace('%', '%%', $message), ]); } throw new MissingRouteException(['url' => $name, 'context' => $context]); diff --git a/src/Routing/functions.php b/src/Routing/functions.php index 1c340ccf659..23a7f814b90 100644 --- a/src/Routing/functions.php +++ b/src/Routing/functions.php @@ -57,3 +57,20 @@ function url(UriInterface|array|string|null $url = null, bool $full = false): st { return Router::url($url, $full); } + +/** + * urldecode all of the segments in a path, but skip over %2f + * because applications can use %2f in a segment. + * + * @internal + */ +function urldecodeSegments(string $url): string +{ + $parts = explode('/', $url); + $parts = array_map( + fn(string $part) => str_replace('/', '%2f', urldecode($part)), + $parts, + ); + + return implode('/', $parts); +} diff --git a/src/TestSuite/MiddlewareDispatcher.php b/src/TestSuite/MiddlewareDispatcher.php index 4e6f18ab892..51e053921cc 100644 --- a/src/TestSuite/MiddlewareDispatcher.php +++ b/src/TestSuite/MiddlewareDispatcher.php @@ -124,6 +124,7 @@ protected function _createRequest(array $spec): ServerRequest array_merge($_SERVER, ['REQUEST_URI' => $spec['url']]), $spec['environment'], ); + /** @phpstan-ignore offsetAccess.notFound */ if (str_contains($environment['PHP_SELF'], 'phpunit')) { $environment['PHP_SELF'] = '/'; } diff --git a/src/TestSuite/TestCase.php b/src/TestSuite/TestCase.php index 368db63f82b..c68b7852cad 100644 --- a/src/TestSuite/TestCase.php +++ b/src/TestSuite/TestCase.php @@ -562,11 +562,10 @@ public function assertTextEquals(string $expected, string $result, string $messa * Asserts that a string starts with a given prefix, ignoring differences in newlines. * Helpful for doing cross platform tests of blocks of text. * - * @param string $prefix The prefix to check for. + * @param non-empty-string $prefix The prefix to check for. * @param string $string The string to search in. * @param string $message The message to use for failure. * @return void - * @phpstan-param non-empty-string $prefix */ public function assertTextStartsWith(string $prefix, string $string, string $message = ''): void { @@ -580,11 +579,10 @@ public function assertTextStartsWith(string $prefix, string $string, string $mes * Asserts that a string starts not with a given prefix, ignoring differences in newlines. * Helpful for doing cross platform tests of blocks of text. * - * @param string $prefix The prefix to not find. + * @param non-empty-string $prefix The prefix to not find. * @param string $string The string to search. * @param string $message The message to use for failure. * @return void - * @phpstan-param non-empty-string $prefix */ public function assertTextStartsNotWith(string $prefix, string $string, string $message = ''): void { @@ -598,11 +596,10 @@ public function assertTextStartsNotWith(string $prefix, string $string, string $ * Asserts that a string ends with a given prefix, ignoring differences in newlines. * Helpful for doing cross platform tests of blocks of text. * - * @param string $suffix The suffix to find. + * @param non-empty-string $suffix The suffix to find. * @param string $string The string to search. * @param string $message The message to use for failure. * @return void - * @phpstan-param non-empty-string $suffix */ public function assertTextEndsWith(string $suffix, string $string, string $message = ''): void { @@ -616,11 +613,10 @@ public function assertTextEndsWith(string $suffix, string $string, string $messa * Asserts that a string ends not with a given prefix, ignoring differences in newlines. * Helpful for doing cross platform tests of blocks of text. * - * @param string $suffix The suffix to not find. + * @param non-empty-string $suffix The suffix to not find. * @param string $string The string to search. * @param string $message The message to use for failure. * @return void - * @phpstan-param non-empty-string $suffix */ public function assertTextEndsNotWith(string $suffix, string $string, string $message = ''): void { diff --git a/src/TestSuite/TestEmailTransport.php b/src/TestSuite/TestEmailTransport.php index 0b34fd257ce..5ad9a2a6a0a 100644 --- a/src/TestSuite/TestEmailTransport.php +++ b/src/TestSuite/TestEmailTransport.php @@ -38,8 +38,7 @@ class TestEmailTransport extends DebugTransport * Stores email for later assertions * * @param \Cake\Mailer\Message $message Message - * @return array Contains 'headers' and 'message' keys. Additional keys allowed. - * @phpstan-return array{headers: string, message: string, ...} + * @return array{headers: string, message: string, ...} Contains 'headers' and 'message' keys. Additional keys allowed. */ public function send(Message $message): array { diff --git a/src/Utility/Hash.php b/src/Utility/Hash.php index f8abbda50db..5f96e2c3099 100644 --- a/src/Utility/Hash.php +++ b/src/Utility/Hash.php @@ -53,6 +53,9 @@ class Hash * @return mixed The value fetched from the array, or $default if path doesn't exist, is null, * or $data is empty. * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-get + * @psalm-taint-specialize Psalm tracks taint per call site instead of globally, preventing + * false positives where taint from one caller (e.g. request body) bleeds into unrelated + * callers of this generic utility (e.g. Configure::read, getParam). */ public static function get(ArrayAccess|array $data, array|string|int|null $path, mixed $default = null): mixed { @@ -113,10 +116,9 @@ public static function get(ArrayAccess|array $data, array|string|int|null $path, * * @param \ArrayAccess|array $data The data to extract from. * @param string $path The path to extract. - * @return \ArrayAccess|array An array of the extracted values. Returns an empty array + * @return ($path is non-empty-string ? array : \ArrayAccess|array) An array of the extracted values. Returns an empty array * if there are no matches. * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-extract - * @phpstan-return ($path is non-empty-string ? array : \ArrayAccess|array) */ public static function extract(ArrayAccess|array $data, string $path): ArrayAccess|array { @@ -294,9 +296,11 @@ protected static function _matches(ArrayAccess|array $data, string $selector): b * @param T $data The data to insert into. * @param string $path The path to insert at. * @param mixed $values The values to insert. - * @return \ArrayAccess|array The data with $values inserted. - * @phpstan-return (T is array ? array : \ArrayAccess) + * @return (T is array ? array : \ArrayAccess) The data with $values inserted. * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-insert + * @psalm-taint-specialize Psalm tracks taint per call site instead of globally, preventing + * false positives where taint from one caller (e.g. ServerRequest::withData) bleeds into + * unrelated callers of this generic utility (e.g. Configure::write). */ public static function insert(ArrayAccess|array $data, string $path, mixed $values = null): ArrayAccess|array { @@ -349,6 +353,10 @@ public static function insert(ArrayAccess|array $data, string $path, mixed $valu * @param array $path The path to work on. * @param mixed $values The values to insert when doing inserts. * @return \ArrayAccess|array + * @psalm-taint-specialize Psalm tracks taint per call site instead of globally. Without this, + * the ArrayAccess|array $data parameter causes Psalm to dispatch taint through every + * ArrayAccess::offsetSet implementation in the codebase (e.g. Validator) when this method + * is called with tainted request data. */ protected static function _simpleOp( string $op, @@ -398,9 +406,11 @@ protected static function _simpleOp( * @template T of \ArrayAccess|array * @param T $data The data to operate on * @param string $path A path expression to use to remove. - * @return \ArrayAccess|array The modified array. - * @phpstan-return (T is array ? array : \ArrayAccess) + * @return (T is array ? array : \ArrayAccess) The modified array. * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-remove + * @psalm-taint-specialize Psalm tracks taint per call site instead of globally, preventing + * false positives where taint from one caller (e.g. ServerRequest::withoutData) bleeds into + * unrelated callers of this generic utility (e.g. Configure::delete). */ public static function remove(ArrayAccess|array $data, string $path): ArrayAccess|array { @@ -553,12 +563,11 @@ public static function combine( * @param array $data Source array from which to extract the data * @param array $paths An array containing one or more Hash::extract()-style key paths * @param string $format Format string into which values will be inserted, see sprintf() - * @return array|null An array of strings extracted from `$path` and formatted with `$format`, + * @return ($paths is non-empty-array ? array : null) An array of strings extracted from `$path` and formatted with `$format`, * or null if $paths is empty. * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-format * @see sprintf() * @see \Cake\Utility\Hash::extract() - * @phpstan-return ($paths is non-empty-array ? array : null) */ public static function format(array $data, array $paths, string $format): ?array { @@ -731,9 +740,8 @@ public static function flatten(array $data, string $separator = '.'): array * into a multi-dimensional array. So, `['0.Foo.Bar' => 'Far']` becomes * `[['Foo' => ['Bar' => 'Far']]]`. * - * @phpstan-param non-empty-string $separator * @param array $data Flattened array - * @param string $separator The delimiter used + * @param non-empty-string $separator The delimiter used * @return array * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-expand */ @@ -776,6 +784,10 @@ public static function expand(array $data, string $separator = '.'): array * @param mixed $merge Array to merge with. The argument and all trailing arguments will be array cast when merged * @return array Merged array * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-merge + * @psalm-taint-specialize Psalm tracks taint per call site instead of globally, preventing + * false positives where taint from one caller (e.g. ServerRequestFactory merging POST body + * with uploaded files) bleeds into unrelated callers (e.g. InstanceConfigTrait merging + * developer configuration, ServerRequest::addDetector merging detector definitions). */ public static function merge(array $data, mixed $merge): array { @@ -1212,12 +1224,11 @@ public static function normalize(array $data, bool $assoc = true, mixed $default * - `root` The id of the desired top-most result. * * @param array $data The data to nest. - * @param array $options Options. + * @param array{idPath?: string, parentPath?: string, children?: string, root?: string|null} $options Options. * @return array of results, nested * @see \Cake\Utility\Hash::extract() * @throws \InvalidArgumentException When providing invalid data. * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-nest - * @phpstan-param array{idPath?: string, parentPath?: string, children?: string, root?: string|null} $options */ public static function nest(array $data, array $options = []): array { diff --git a/src/Utility/Text.php b/src/Utility/Text.php index ef2349b9eec..57a5099058c 100644 --- a/src/Utility/Text.php +++ b/src/Utility/Text.php @@ -422,10 +422,9 @@ public static function wrapBlock(string $text, array|int $options = []): string /** * Unicode and newline aware version of wordwrap. * - * @phpstan-param non-empty-string $break * @param string $text The text to format. * @param int $width The width to wrap to. Defaults to 72. - * @param string $break The line is broken using the optional break parameter. Defaults to '\n'. + * @param non-empty-string $break The line is broken using the optional break parameter. Defaults to '\n'. * @param bool $cut If the cut is set to true, the string is always wrapped at the specified width. * @return string Formatted text. */ diff --git a/src/Utility/Xml.php b/src/Utility/Xml.php index c49c4d4771f..442a4e01035 100644 --- a/src/Utility/Xml.php +++ b/src/Utility/Xml.php @@ -412,9 +412,8 @@ protected static function _fromArray( /** * Helper to _fromArray(). It will create children of arrays * - * @param array $data Array with information to create children + * @param array{dom: \DOMDocument, node: \DOMNode, key: string, format: string, value?: mixed} $data Array with information to create children * @return void - * @phpstan-param array{dom: \DOMDocument, node: \DOMNode, key: string, format: string, value?: mixed} $data */ protected static function _createChild(array $data): void { diff --git a/src/Validation/RulesProvider.php b/src/Validation/RulesProvider.php index 077b7cc77f0..ef98036185f 100644 --- a/src/Validation/RulesProvider.php +++ b/src/Validation/RulesProvider.php @@ -46,9 +46,8 @@ class RulesProvider /** * Constructor, sets the default class to use for calling methods * - * @param object|string $class the default class to proxy + * @param object|class-string $class the default class to proxy * @throws \ReflectionException - * @phpstan-param object|class-string $class */ public function __construct(object|string $class = Validation::class) { diff --git a/src/Validation/Validator.php b/src/Validation/Validator.php index 81934e83c26..e407236b2ba 100644 --- a/src/Validation/Validator.php +++ b/src/Validation/Validator.php @@ -139,16 +139,14 @@ class Validator implements ArrayAccess, IteratorAggregate, Countable * An associative array of objects or classes containing methods * used for validation * - * @var array - * @phpstan-var array + * @var array */ protected array $_providers = []; /** * An associative array of objects or classes used as a default provider list * - * @var array - * @phpstan-var array + * @var array */ protected static array $_defaultProviders = []; @@ -314,8 +312,7 @@ public function hasField(string $name): bool * when called will receive the full list of providers stored in this validator. * * @param string $name The name under which the provider should be set. - * @param object|string $object Provider object or class name. - * @phpstan-param object|class-string $object + * @param object|class-string $object Provider object or class name. * @return $this */ public function setProvider(string $name, object|string $object) @@ -351,8 +348,7 @@ public static function getDefaultProvider(string $name): object|string|null * Associates an object to a name so it can be used as a default provider. * * @param string $name The name under which the provider should be set. - * @param object|string $object Provider object or class name. - * @phpstan-param object|class-string $object + * @param object|class-string $object Provider object or class name. * @return void */ public static function addDefaultProvider(string $name, object|string $object): void diff --git a/src/View/Helper/FormHelper.php b/src/View/Helper/FormHelper.php index c9bc5c57fa6..0b08a12c9f2 100644 --- a/src/View/Helper/FormHelper.php +++ b/src/View/Helper/FormHelper.php @@ -1401,7 +1401,13 @@ protected function enumOptions(string $enumClass): array $hasLabel = true; } elseif (method_exists($enumClass, 'label')) { $hasLabel = true; - deprecationWarning('5.2.0', 'Enums with the `label()` method must implement the `EnumLabelInterface`.'); + deprecationWarning( + '5.2.0', + sprintf( + 'Enum class `%s` with a `label()` method must implement the `EnumLabelInterface`.', + $enumClass, + ), + ); } else { $hasLabel = false; } diff --git a/src/View/Helper/HtmlHelper.php b/src/View/Helper/HtmlHelper.php index aeed440de05..98d450c4996 100644 --- a/src/View/Helper/HtmlHelper.php +++ b/src/View/Helper/HtmlHelper.php @@ -523,11 +523,10 @@ public function script(array|string $url, array $options = []): ?string /** * Generate the "importmap" script tag. * - * @param array $map Map array. + * @param array{imports?: array, scopes?: array>>, integrity?: array} $map Map array. * @param array $options Same options as `UrlHelper::script()`. * @return string * @since 5.2.0 - * @phpstan-param array{imports?: array, scopes?: array>>, integrity?: array} $map */ public function importmap(array $map, array $options = []): string { diff --git a/src/View/Helper/PaginatorHelper.php b/src/View/Helper/PaginatorHelper.php index ecfa89f4359..16f80a22e45 100644 --- a/src/View/Helper/PaginatorHelper.php +++ b/src/View/Helper/PaginatorHelper.php @@ -755,8 +755,7 @@ public function numbers(array $options = []): string * * @param array $params Params from the numbers() method. * @param array $options Options from the numbers() method. - * @return array An array with the start and end numbers. - * @phpstan-return array{0: int, 1: int} + * @return array{0: int, 1: int} An array with the start and end numbers. */ protected function _getNumbersStartAndEnd(array $params, array $options): array { diff --git a/src/View/View.php b/src/View/View.php index 7010edef7a7..54fd512df4d 100644 --- a/src/View/View.php +++ b/src/View/View.php @@ -285,8 +285,7 @@ class View implements EventDispatcherInterface /** * ViewBlock class. * - * @var string - * @phpstan-var class-string<\Cake\View\ViewBlock> + * @var class-string<\Cake\View\ViewBlock> */ protected string $_viewBlockClass = ViewBlock::class; @@ -650,7 +649,7 @@ public function setLayout(string $name) * or `MyPlugin.template` to use the template element from MyPlugin. If the element * is not found in the plugin, the normal view path cascade will be searched. * @param array $data Array of data to be made available to the rendered view (i.e. the Element) - * @param array $options Array of options. Possible keys are: + * @param array{cache?:array|true, callbacks?:bool, plugin?:string|false, ignoreMissing?:bool} $options Array of options. Possible keys are: * * - `cache` - Can either be `true`, to enable caching using the config in View::$elementCache. Or an array * If an array, the following keys can be used: @@ -666,7 +665,6 @@ public function setLayout(string $name) * @return string Rendered Element * @throws \Cake\View\Exception\MissingElementException When an element is missing and `ignoreMissing` * is false. - * @phpstan-param array{cache?:array|true, callbacks?:bool, plugin?:string|false, ignoreMissing?:bool} $options */ public function element(string $name, array $data = [], array $options = []): string { @@ -1449,8 +1447,7 @@ protected function _checkFilePath(string $file, ?string $plugin): string * * @param string $name The name you want to plugin split. * @param bool $fallback If true uses the plugin set in the current Request when parsed plugin is not loaded - * @return array Array with 2 indexes. 0 => plugin name, 1 => filename. - * @phpstan-return array{string|null, string} + * @return array{0:string|null, 1:string} Array with 2 indexes. 0 => plugin name, 1 => filename. */ public function pluginSplit(string $name, bool $fallback = true): array { @@ -1655,13 +1652,12 @@ protected function _paths(?string $plugin = null, bool $cached = true): array * @param string $name Element name * @param array $data Data * @param array $options Element options - * @return array Element Cache configuration. - * @phpstan-return array{key:string, config:string} + * @return array{key:string, config:string} Element Cache configuration. */ protected function _elementCache(string $name, array $data, array $options): array { if (isset($options['cache']['key'], $options['cache']['config'])) { - /** @phpstan-var array{key:string, config:string} $cache */ + /** @var array{key:string, config:string} $cache */ $cache = $options['cache']; $cache['key'] = 'element_' . $cache['key']; diff --git a/src/View/ViewBuilder.php b/src/View/ViewBuilder.php index adaba7271bc..b6200e84a7f 100644 --- a/src/View/ViewBuilder.php +++ b/src/View/ViewBuilder.php @@ -111,8 +111,7 @@ class ViewBuilder implements JsonSerializable * Can either use plugin notation, a short name * or a fully namespaced classname. * - * @var string|null - * @phpstan-var class-string<\Cake\View\View>|string|null + * @var class-string<\Cake\View\View>|string|null */ protected ?string $_className = null; diff --git a/tests/TestCase/Cache/Engine/RedisClusterEngineTest.php b/tests/TestCase/Cache/Engine/RedisClusterEngineTest.php index 2b557dc7eea..bf79ad7f5a8 100644 --- a/tests/TestCase/Cache/Engine/RedisClusterEngineTest.php +++ b/tests/TestCase/Cache/Engine/RedisClusterEngineTest.php @@ -136,6 +136,7 @@ public function testConfig(): void 'unix_socket' => false, 'clearUsesFlushDb' => false, 'failover' => null, + 'allowedClasses' => true, ]; $this->assertEquals($expecting, $config); } diff --git a/tests/TestCase/Cache/Engine/RedisEngineTest.php b/tests/TestCase/Cache/Engine/RedisEngineTest.php index 86a62f06cd0..1d0b9c5d66e 100644 --- a/tests/TestCase/Cache/Engine/RedisEngineTest.php +++ b/tests/TestCase/Cache/Engine/RedisEngineTest.php @@ -16,12 +16,14 @@ */ namespace Cake\Test\TestCase\Cache\Engine; +use __PHP_Incomplete_Class; use Cake\Cache\Cache; use Cake\Cache\Engine\RedisEngine; use Cake\TestSuite\TestCase; use DateInterval; use Mockery; use Redis; +use stdClass; use function Cake\Core\env; /** @@ -123,6 +125,7 @@ public function testConfig(): void 'nodes' => [], 'clearUsesFlushDb' => false, 'failover' => null, + 'allowedClasses' => true, ]; $this->assertEquals($expecting, $config); } @@ -157,6 +160,7 @@ public function testConfigDsn(): void 'nodes' => [], 'clearUsesFlushDb' => false, 'failover' => null, + 'allowedClasses' => true, ]; $this->assertEquals($expecting, $config); } @@ -198,6 +202,7 @@ public function testConfigDsnSSLContext(): void 'nodes' => [], 'clearUsesFlushDb' => false, 'failover' => null, + 'allowedClasses' => true, ]; $this->assertEquals($expecting, $config); } @@ -539,6 +544,80 @@ public function testReadAndWriteCache(): void Cache::delete('test', 'redis'); } + /** + * Objects are unserialized normally with the default `allowedClasses` of `true`. + */ + public function testAllowedClassesDefaultAllowsObjects(): void + { + $this->_configCache(); + + $data = new stdClass(); + $data->foo = 'bar'; + $this->assertTrue(Cache::write('object', $data, 'redis')); + + $result = Cache::read('object', 'redis'); + $this->assertInstanceOf(stdClass::class, $result); + $this->assertSame('bar', $result->foo); + + Cache::delete('object', 'redis'); + } + + /** + * Setting `allowedClasses` to `false` blocks object unserialization, + * yielding an incomplete class instance instead of the original object. + */ + public function testAllowedClassesFalseBlocksObjects(): void + { + $this->_configCache(['allowedClasses' => false]); + + $data = new stdClass(); + $data->foo = 'bar'; + $this->assertTrue(Cache::write('object', $data, 'redis')); + + $result = Cache::read('object', 'redis'); + $this->assertInstanceOf(__PHP_Incomplete_Class::class, $result); + + Cache::delete('object', 'redis'); + } + + /** + * An array of `allowedClasses` permits only the listed classes; others + * are returned as incomplete class instances. + */ + public function testAllowedClassesWhitelist(): void + { + $this->_configCache(['allowedClasses' => [stdClass::class]]); + + $allowed = new stdClass(); + $allowed->foo = 'bar'; + $this->assertTrue(Cache::write('allowed', $allowed, 'redis')); + $this->assertInstanceOf(stdClass::class, Cache::read('allowed', 'redis')); + + $blocked = new DateInterval('PT1S'); + $this->assertTrue(Cache::write('blocked', $blocked, 'redis')); + $this->assertInstanceOf(__PHP_Incomplete_Class::class, Cache::read('blocked', 'redis')); + + Cache::delete('allowed', 'redis'); + Cache::delete('blocked', 'redis'); + } + + /** + * Integers keep round-tripping correctly regardless of `allowedClasses`. + */ + public function testAllowedClassesPreservesScalars(): void + { + $this->_configCache(['allowedClasses' => false]); + + $this->assertTrue(Cache::write('int', 42, 'redis')); + $this->assertSame(42, Cache::read('int', 'redis')); + + $this->assertTrue(Cache::write('array', ['a' => 1, 'b' => 2], 'redis')); + $this->assertSame(['a' => 1, 'b' => 2], Cache::read('array', 'redis')); + + Cache::delete('int', 'redis'); + Cache::delete('array', 'redis'); + } + /** * Test get with default value */ diff --git a/tests/TestCase/Database/Driver/PostgresTest.php b/tests/TestCase/Database/Driver/PostgresTest.php index 47a2465caed..ad8324a94fb 100644 --- a/tests/TestCase/Database/Driver/PostgresTest.php +++ b/tests/TestCase/Database/Driver/PostgresTest.php @@ -19,6 +19,7 @@ use Cake\Database\Connection; use Cake\Database\Driver\Postgres; use Cake\Database\DriverFeatureEnum; +use Cake\Database\Exception\QueryException; use Cake\Database\Query\SelectQuery; use Cake\Datasource\ConnectionManager; use Cake\TestSuite\TestCase; @@ -270,4 +271,43 @@ public function testQuote(): void $expected = "'O\slash'"; $this->assertEquals($expected, $result); } + + /** + * test jsonValue expression tranformation and parameter binding + * + * @return void + */ + public function testJsonValueParameterBinding(): void + { + $connection = ConnectionManager::get('test'); + $this->skipIf(!$connection->getDriver() instanceof Postgres); + + $stmt = $connection->insert( + 'comments', + ['article_id' => 1, 'user_id' => 1, 'comment' => ['scores' => [25, 36]]], + ['comment' => 'json'], + ); + $this->assertEquals(1, $stmt->rowCount()); + + $query = new SelectQuery($connection); + $query + ->select(['score' => $query->func()->jsonValue('comment', '$.scores[1]')]) + ->from('comments') + ->where(['id' => 1]); + + $result = $query->execute(); + $comment = $result->fetchAll('assoc'); + $result->closeCursor(); + $this->assertSame('36', $comment[0]['score']); + + $query = new SelectQuery($connection); + $query + ->select(['score' => $query->func()->jsonValue('comment', "x')) > '0' OR (SELECT 1) --")]) + ->from('comments') + ->where(['id' => 1]); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('syntax error at end of jsonpath input'); + $query->execute(); + } } diff --git a/tests/TestCase/Database/FunctionsBuilderTest.php b/tests/TestCase/Database/FunctionsBuilderTest.php index 082c74f10ee..3177e172bd8 100644 --- a/tests/TestCase/Database/FunctionsBuilderTest.php +++ b/tests/TestCase/Database/FunctionsBuilderTest.php @@ -21,6 +21,8 @@ use Cake\Database\FunctionsBuilder; use Cake\Database\ValueBinder; use Cake\TestSuite\TestCase; +use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; /** * Tests FunctionsBuilder class @@ -172,6 +174,26 @@ public function testCast(): void $this->assertSame('string', $function->getReturnType()); } + public static function invalidValues(): array + { + return [ + ['words with spaces'], + ["' drop table users --"], + [' word '], + ]; + } + + /** + * Ensure that only alphanumeric values are accepted for types. + */ + #[DataProvider('invalidValues')] + public function testCastInvalidValue(string $type): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('`dataType` must be an alphanumeric string'); + $this->functions->cast('field', $type); + } + /** * Tests generating a NOW(), CURRENT_TIME() and CURRENT_DATE() function */ @@ -209,6 +231,22 @@ public function testExtract(): void $this->assertSame('integer', $function->getReturnType()); } + #[DataProvider('invalidValues')] + public function testExtractInvalidValue(string $part): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('`part` must be an alphanumeric string'); + $this->functions->extract($part, 'created'); + } + + #[DataProvider('invalidValues')] + public function testDatePartInvalidValue(string $part): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('`part` must be an alphanumeric string'); + $this->functions->datePart($part, 'created'); + } + /** * Tests generating a DATE_ADD() function */ @@ -224,6 +262,14 @@ public function testDateAdd(): void $this->assertSame('DATE_ADD(created, INTERVAL -3 day)', $function->sql(new ValueBinder())); } + #[DataProvider('invalidValues')] + public function testDateAddInvalidValue(string $unit): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('`unit` must be an alphanumeric string'); + $this->functions->dateAdd('created', -3, $unit); + } + /** * Tests generating a DAYOFWEEK() function */ diff --git a/tests/TestCase/Error/DebuggerTest.php b/tests/TestCase/Error/DebuggerTest.php index 1887ef5d0de..217db518bd3 100644 --- a/tests/TestCase/Error/DebuggerTest.php +++ b/tests/TestCase/Error/DebuggerTest.php @@ -65,6 +65,9 @@ protected function setUp(): void Log::drop('stderr'); Log::drop('stdout'); Debugger::configInstance('exportFormatter', TextFormatter::class); + // The instances need to be initialized for the tests to pass + Debugger::getInstance(TestDebugger::class); + Debugger::getInstance(Debugger::class); } /** @@ -659,9 +662,14 @@ public function testDebugInfo(): void public function testSetOutputMask(): void { Debugger::setOutputMask(['password' => '[**********]']); - $this->assertEquals(['password' => '[**********]'], Debugger::outputMask()); + $result = Debugger::outputMask(); + $this->assertEquals('[**********]', $result['password']); + $this->assertEquals('********', $result['login']); + $this->assertEquals('********', $result['host']); Debugger::setOutputMask(['serial' => 'XXXXXX']); - $this->assertEquals(['password' => '[**********]', 'serial' => 'XXXXXX'], Debugger::outputMask()); + $result = Debugger::outputMask(); + $this->assertEquals('[**********]', $result['password']); + $this->assertEquals('XXXXXX', $result['serial']); Debugger::setOutputMask([], false); $this->assertSame([], Debugger::outputMask()); } @@ -703,6 +711,71 @@ public function testMaskObject(): void $this->assertSame($expected, preg_replace('/\s+/', '', $result)); } + /** + * Tests that default outputMask keys are masked by default. + */ + public function testDefaultOutputMaskMasksPassword(): void + { + $result = Debugger::exportVar(['password' => 'supersecret']); + $this->assertStringContainsString('********', $result); + $this->assertStringNotContainsString('supersecret', $result); + } + + /** + * Tests that all default outputMask keys are present. + */ + public function testDefaultOutputMaskContainsAllKeys(): void + { + $mask = Debugger::outputMask(); + $this->assertArrayHasKey('password', $mask); + $this->assertArrayHasKey('login', $mask); + $this->assertArrayHasKey('host', $mask); + $this->assertArrayHasKey('database', $mask); + $this->assertArrayHasKey('port', $mask); + $this->assertArrayHasKey('prefix', $mask); + $this->assertArrayHasKey('schema', $mask); + $this->assertSame('********', $mask['password']); + $this->assertSame('********', $mask['login']); + $this->assertSame('********', $mask['host']); + $this->assertSame('********', $mask['database']); + $this->assertSame('********', $mask['port']); + $this->assertSame('********', $mask['prefix']); + $this->assertSame('********', $mask['schema']); + } + + /** + * Tests that custom masks merge with defaults. + */ + public function testCustomMaskMergesWithDefaults(): void + { + Debugger::setOutputMask(['api_key' => 'SECRET']); + $mask = Debugger::outputMask(); + $this->assertSame('SECRET', $mask['api_key']); + $this->assertSame('********', $mask['password']); + $this->assertSame('********', $mask['host']); + } + + /** + * Tests that non-default array keys are not masked. + */ + public function testNonDefaultKeysAreNotMasked(): void + { + $result = Debugger::exportVar(['username' => 'admin', 'email' => 'test@example.com']); + $this->assertStringContainsString('admin', $result); + $this->assertStringContainsString('test@example.com', $result); + } + + /** + * Tests that default mask works on object properties. + */ + public function testDefaultMaskOnObjectProperties(): void + { + $object = new SecurityThing(); + $result = Debugger::exportVar($object); + $this->assertStringContainsString('********', $result); + $this->assertStringNotContainsString('pass1234', $result); + } + /** * test testPrintVar() */ diff --git a/tests/TestCase/Error/Renderer/WebExceptionRendererTest.php b/tests/TestCase/Error/Renderer/WebExceptionRendererTest.php index b41a1c17163..d05228b0301 100644 --- a/tests/TestCase/Error/Renderer/WebExceptionRendererTest.php +++ b/tests/TestCase/Error/Renderer/WebExceptionRendererTest.php @@ -14,7 +14,7 @@ * @since 2.0.0 * @license https://opensource.org/licenses/mit-license.php MIT License */ -namespace Cake\Test\TestCase\Error; +namespace Cake\Test\TestCase\Error\Renderer; use Cake\Controller\Controller; use Cake\Controller\ErrorController; diff --git a/tests/TestCase/Http/Client/Adapter/CurlTest.php b/tests/TestCase/Http/Client/Adapter/CurlTest.php index 8341877bb38..48daf5e3f51 100644 --- a/tests/TestCase/Http/Client/Adapter/CurlTest.php +++ b/tests/TestCase/Http/Client/Adapter/CurlTest.php @@ -43,7 +43,9 @@ protected function setUp(): void $this->skipIf(!function_exists('curl_init'), 'Skipping as ext/curl is not installed.'); $this->curl = new Curl(); - $this->caFile = CaBundle::getBundledCaBundlePath(); + // Mirror the adapter default: an explicit `curl.cainfo` (php.ini) wins, + // otherwise the bundled CA file is used. + $this->caFile = ini_get('curl.cainfo') ?: CaBundle::getBundledCaBundlePath(); } /** @@ -122,6 +124,21 @@ public function testBuildOptionsGet(): void $this->assertSame($expected, $result); } + /** + * An explicitly passed `ssl_cafile` must take precedence over both the + * `curl.cainfo` php.ini setting and the bundled CA file. + */ + public function testBuildOptionsExplicitCafileWins(): void + { + $options = [ + 'ssl_cafile' => '/custom/ca-bundle.pem', + ]; + $request = new Request('http://localhost/things', 'GET'); + $result = $this->curl->buildOptions($request, $options); + + $this->assertSame('/custom/ca-bundle.pem', $result[CURLOPT_CAINFO]); + } + /** * Test converting client options into curl ones. */ diff --git a/tests/TestCase/Http/MimeTypeTest.php b/tests/TestCase/Http/MimeTypeTest.php index 75c18bcf42d..cd8fefdceb9 100644 --- a/tests/TestCase/Http/MimeTypeTest.php +++ b/tests/TestCase/Http/MimeTypeTest.php @@ -14,7 +14,7 @@ * @since 5.2.0 * @license https://opensource.org/licenses/mit-license.php MIT License */ -namespace Cake\Http\Test\TestCase; +namespace Cake\Test\TestCase\Http; use Cake\Http\MimeType; use Cake\TestSuite\TestCase; diff --git a/tests/TestCase/Mailer/Transport/MailTransportTest.php b/tests/TestCase/Mailer/Transport/MailTransportTest.php index 0d1944e3f74..332748517a0 100644 --- a/tests/TestCase/Mailer/Transport/MailTransportTest.php +++ b/tests/TestCase/Mailer/Transport/MailTransportTest.php @@ -114,4 +114,45 @@ public function testSendData(): void $this->assertStringContainsString('Subject: ', $result['headers']); $this->assertStringContainsString('To: ', $result['headers']); } + + /** + * test send strips crlf from headers + */ + public function testSendHeadersStripCrlf(): void + { + $eol = "\r\n"; + $date = date(DATE_RFC2822); + + $message = new Message(); + $message->setFrom('noreply@cakephp.org', 'CakePHP Test'); + $message->setTo('cake@cakephp.org', 'CakePHP'); + $message->setSubject('injected headers'); + $message->setMessageId(''); + + $message->setHeaders([ + 'X-inject' => "line one\r\nline two", + ]); + $message->setBody(['text' => "First Line\nSecond Line"]); + + $data = "From: CakePHP Test {$eol}"; + $data .= 'X-inject: line oneline two' . $eol; + $data .= 'Date: ' . $date . $eol; + $data .= 'Message-ID: ' . $eol; + $data .= "MIME-Version: 1.0{$eol}"; + $data .= "Content-Type: text/plain; charset=UTF-8{$eol}"; + $data .= 'Content-Transfer-Encoding: 8bit'; + + $this->MailTransport->expects($this->once())->method('_mail') + ->with( + 'CakePHP ', + 'injected headers', + implode($eol, ['First Line', 'Second Line', '', '']), + $data, + '-f', + ); + + $result = $this->MailTransport->send($message); + $this->assertStringContainsString('Subject: ', $result['headers']); + $this->assertStringContainsString('To: ', $result['headers']); + } } diff --git a/tests/TestCase/Mailer/Transport/SmtpTransportTest.php b/tests/TestCase/Mailer/Transport/SmtpTransportTest.php index 2cf22ec7b1d..ad0636917e2 100644 --- a/tests/TestCase/Mailer/Transport/SmtpTransportTest.php +++ b/tests/TestCase/Mailer/Transport/SmtpTransportTest.php @@ -600,6 +600,52 @@ public function testSendData(): void $this->SmtpTransport->sendData($message); } + /** + * test send strips crlf from headers + */ + public function testSendHeadersStripCrlf(): void + { + $eol = "\r\n"; + $date = date(DATE_RFC2822); + + $message = new Message(); + $message->setFrom('noreply@cakephp.org', 'CakePHP Test'); + $message->setTo('cake@cakephp.org', 'CakePHP'); + $message->setSubject('injected headers'); + $message->setMessageId(''); + + $message->setHeaders([ + 'X-inject' => "line one\r\nline two", + ]); + $message->setBody(['text' => 'oh no']); + + $data = "From: CakePHP Test {$eol}"; + $data .= 'To: CakePHP ' . $eol; + $data .= 'X-inject: line oneline two' . $eol; + $data .= 'Date: ' . $date . $eol; + $data .= 'Message-ID: ' . $eol; + $data .= 'Subject: injected headers' . $eol; + $data .= "MIME-Version: 1.0{$eol}"; + $data .= "Content-Type: text/plain; charset=UTF-8{$eol}"; + $data .= 'Content-Transfer-Encoding: 8bit' . $eol; + $data .= "\r\n"; + $data .= "oh no\r\n"; + $data .= "\r\n\r\n"; + $data .= "\r\n\r\n.\r\n"; + + $this->socket->shouldReceive('read') + ->andReturn( + "354 OK\r\n", + "250 OK\r\n", + ) + ->twice(); + + $this->socket->shouldReceive('write')->with("DATA\r\n")->once(); + $this->socket->shouldReceive('write')->with($data)->once(); + + $this->SmtpTransport->sendData($message); + } + /** * testQuit method */ diff --git a/tests/TestCase/ORM/Query/QueryRegressionTest.php b/tests/TestCase/ORM/Query/QueryRegressionTest.php index 32381ceab8a..e5fe48d018a 100644 --- a/tests/TestCase/ORM/Query/QueryRegressionTest.php +++ b/tests/TestCase/ORM/Query/QueryRegressionTest.php @@ -23,6 +23,7 @@ use Cake\Datasource\EntityInterface; use Cake\Event\EventInterface; use Cake\I18n\DateTime; +use Cake\ORM\Association; use Cake\ORM\Entity; use Cake\ORM\Query\SelectQuery; use Cake\TestSuite\TestCase; @@ -1849,4 +1850,62 @@ public function testExecutedSubqueryCanBeReused(): void $this->assertEquals('First Article', $result[0]->title); $this->assertNotEmpty($result[0]->user); } + + /** + * The subquery strategy must not strip the ORDER BY from the query it is given. + * + * The loader for the first association would otherwise leave the caller's query without an + * ORDER BY, so every subsequent loader builds its filtering subquery unordered, pages on the + * wrong rows and returns no results at all. + */ + public function testSubqueryStrategyDoesNotMutateOrder(): void + { + $authors = $this->getTableLocator()->get('Authors'); + $authors->hasMany('Articles', ['strategy' => Association::STRATEGY_SUBQUERY]); + + $query = $authors->find() + ->contain(['Articles']) + ->orderBy(['Authors.id' => 'DESC']) + ->limit(2); + + $this->assertCount(1, $query->clause('order')); + $query->all()->toArray(); + $this->assertCount(1, $query->clause('order'), 'The ORDER BY was removed from the query.'); + } + + /** + * Every hasMany in the contain list must be loaded, not just the first one. + * + * Ordering by id DESC with a limit of 2 pages onto authors 4 and 3. An unordered subquery + * would instead filter on authors 1 and 2, so author 4's comment goes missing. + */ + public function testSubqueryStrategyWithMultipleHasMany(): void + { + $authors = $this->getTableLocator()->get('Authors'); + $authors->hasMany('Articles', ['strategy' => Association::STRATEGY_SUBQUERY]); + $authors->hasMany('Comments', [ + 'foreignKey' => 'user_id', + 'strategy' => Association::STRATEGY_SUBQUERY, + ]); + + $results = $this->getTableLocator()->get('Authors')->find() + ->contain(['Articles', 'Comments']) + ->orderBy(['Authors.id' => 'DESC']) + ->limit(2) + ->all() + ->toArray(); + + $this->assertCount(2, $results); + $this->assertSame([4, 3], array_map(fn(EntityInterface $author) => $author->id, $results)); + + // Second hasMany in the contain list: empty before the fix. + $this->assertCount(1, $results[0]->comments); + $this->assertSame(4, $results[0]->comments[0]->user_id); + $this->assertEmpty($results[0]->articles); + + // First hasMany in the contain list: loaded correctly either way. + $this->assertCount(1, $results[1]->articles); + $this->assertSame('Second Article', $results[1]->articles[0]->title); + $this->assertEmpty($results[1]->comments); + } } diff --git a/tests/TestCase/Routing/Route/RouteTest.php b/tests/TestCase/Routing/Route/RouteTest.php index f1fa7f91c64..e12cbd56846 100644 --- a/tests/TestCase/Routing/Route/RouteTest.php +++ b/tests/TestCase/Routing/Route/RouteTest.php @@ -1181,6 +1181,28 @@ public function testParseUrlDecodeElements(): void $this->assertSame('∂∂', $result['slug']); } + /** + * Test that segments are urldecoded into parsed route, but not %2f + * as applications use %2f for encoded data that path prefix matching + * doesn't treat as / + */ + public function testParseUrlDecodeButNot2f(): void + { + $route = new Route( + '/{controller}/{slug}', + ['action' => 'view'], + ); + $route->compile(); + $result = $route->parse('/posts/foo', 'GET'); + $this->assertSame('foo', $result['slug']); + + $result = $route->parse('/posts/foo%20bar', 'GET'); + $this->assertSame('foo bar', $result['slug']); + + $result = $route->parse('/posts%2ffoo', 'GET'); + $this->assertNull($result, 'encoded / should not count as real / '); + } + /** * Test numerically indexed defaults, get appended to pass */ diff --git a/tests/TestCase/Routing/RouteBuilderTest.php b/tests/TestCase/Routing/RouteBuilderTest.php index ba0595854c4..1cf7d360249 100644 --- a/tests/TestCase/Routing/RouteBuilderTest.php +++ b/tests/TestCase/Routing/RouteBuilderTest.php @@ -19,6 +19,7 @@ use BadMethodCallException; use Cake\Core\Exception\MissingPluginException; use Cake\Core\Plugin; +use Cake\Http\Response; use Cake\Http\ServerRequest; use Cake\Routing\Route\InflectedRoute; use Cake\Routing\Route\RedirectRoute; @@ -957,7 +958,7 @@ public function testScopeInheritMiddleware(): void /** * Test using name prefixes. */ - public function testNamePrefixes(): void + public function testScopeNamePrefixes(): void { $routes = new RouteBuilder($this->collection, '/api', [], ['namePrefix' => 'api:']); $routes->scope('/v1', ['version' => 1, '_namePrefix' => 'v1:'], function (RouteBuilder $routes): void { @@ -973,6 +974,41 @@ public function testNamePrefixes(): void $this->assertArrayHasKey('web:pong', $all); } + /** + * Test that if an application has scoped middleware on a path prefix, + * and fallback routes, that URL decoding in Route::parse() would + * decode the %2f that was not accepted in the scope path prefix check. + */ + public function testScopeMiddlewarePathMatch(): void + { + $gate = function () { + return new Response(['body' => 'no', 'status' => 403]); + }; + + $routes = new RouteBuilder($this->collection, '/'); + $routes->registerMiddleware('gate', $gate); + $routes->scope('/admin', function (RouteBuilder $routes): void { + $routes->applyMiddleware('gate'); + $routes->connect('/secret', ['controller' => 'Admin', 'action' => 'secret']); + }); + $routes->fallbacks(DashedRoute::class); + + // No encoded / is a standard request. + $result = $this->collection->parseRequest(new ServerRequest([ + 'url' => '/admin/secret', + ])); + $this->assertEquals(['gate'], $result['_route']->getMiddleware()); + $this->assertEquals('/admin/secret', $result['_route']->template); + + // Encoded / will not match path prefix. It will match /controller though + $result = $this->collection->parseRequest(new ServerRequest([ + 'url' => '/admin%2Fsecret', + ])); + $this->assertEquals('/{controller}', $result['_route']->template); + $this->assertEquals('Admin%2fsecret', $result['controller']); + $this->assertEquals('index', $result['action']); + } + /** * Test adding middleware to the collection. */ diff --git a/tests/TestCase/Routing/RouteCollectionTest.php b/tests/TestCase/Routing/RouteCollectionTest.php index 253af476a70..cb888be70e9 100644 --- a/tests/TestCase/Routing/RouteCollectionTest.php +++ b/tests/TestCase/Routing/RouteCollectionTest.php @@ -666,7 +666,9 @@ public function testMatchNamed(): void public function testMatchNamedError(): void { $this->expectException(MissingRouteException::class); - $this->expectExceptionMessage('A named route was found for `fail`, but matching failed'); + $this->expectExceptionMessage( + 'A named route was found for `fail`, but matching failed. Passed parameters: `{"controller":"Articles"}`.', + ); $context = [ '_base' => '/', '_scheme' => 'http', @@ -675,7 +677,7 @@ public function testMatchNamedError(): void $routes = new RouteBuilder($this->collection, '/b'); $routes->connect('/{lang}/articles', ['controller' => 'Articles'], ['_name' => 'fail']); - $this->collection->match(['_name' => 'fail'], $context); + $this->collection->match(['_name' => 'fail', 'controller' => 'Articles'], $context); } /** diff --git a/tests/TestCase/TestSuite/Fixture/FixtureHelperTest.php b/tests/TestCase/TestSuite/Fixture/FixtureHelperTest.php index 86f53df35a3..ecb6fa84bc1 100644 --- a/tests/TestCase/TestSuite/Fixture/FixtureHelperTest.php +++ b/tests/TestCase/TestSuite/Fixture/FixtureHelperTest.php @@ -14,7 +14,7 @@ * @since 4.3.0 * @license https://opensource.org/licenses/mit-license.php MIT License */ -namespace Cake\Test\TestCase\TestSuite; +namespace Cake\Test\TestCase\TestSuite\Fixture; use Cake\Core\Exception\CakeException; use Cake\Database\Connection; diff --git a/tests/TestCase/TestSuite/Fixture/TransactionStrategyTest.php b/tests/TestCase/TestSuite/Fixture/TransactionStrategyTest.php index 6bf0bad00e2..1cc600dee37 100644 --- a/tests/TestCase/TestSuite/Fixture/TransactionStrategyTest.php +++ b/tests/TestCase/TestSuite/Fixture/TransactionStrategyTest.php @@ -14,7 +14,7 @@ * @since 4.3.0 * @license https://opensource.org/licenses/mit-license.php MIT License */ -namespace Cake\Test\TestCase\TestSuite; +namespace Cake\Test\TestCase\TestSuite\Fixture; use Cake\Datasource\ConnectionManager; use Cake\TestSuite\Fixture\TransactionStrategy; diff --git a/tests/TestCase/TestSuite/Fixture/TruncateStrategyTest.php b/tests/TestCase/TestSuite/Fixture/TruncateStrategyTest.php index d56ddc122cc..057cbaebe13 100644 --- a/tests/TestCase/TestSuite/Fixture/TruncateStrategyTest.php +++ b/tests/TestCase/TestSuite/Fixture/TruncateStrategyTest.php @@ -14,7 +14,7 @@ * @since 4.3.0 * @license https://opensource.org/licenses/mit-license.php MIT License */ -namespace Cake\Test\TestCase\TestSuite; +namespace Cake\Test\TestCase\TestSuite\Fixture; use Cake\Datasource\ConnectionManager; use Cake\TestSuite\Fixture\TruncateStrategy;