diff --git a/composer.json b/composer.json index 1a60556..87936f0 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,8 @@ "type": "library", "license": "MIT", "require": { - "php": ">=8.2" + "php": ">=8.2", + "utopia-php/validators": "0.2.*" }, "require-dev": { "laravel/pint": "^1.18", @@ -30,4 +31,3 @@ "minimum-stability": "stable", "prefer-stable": true } - diff --git a/composer.lock b/composer.lock index bb92df9..8663d44 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,49 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2ea83540769daf9a5da7ac560f807ae3", - "packages": [], + "content-hash": "a986dbfae76439382aef9763aa893dd0", + "packages": [ + { + "name": "utopia-php/validators", + "version": "0.2.6", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/validators.git", + "reference": "f1ac4f08d6876daf117a1d9af53a4c7b752c2c0b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/validators/zipball/f1ac4f08d6876daf117a1d9af53a4c7b752c2c0b", + "reference": "f1ac4f08d6876daf117a1d9af53a4c7b752c2c0b", + "shasum": "" + }, + "require": { + "php": ">=8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A lightweight collection of reusable validators for Utopia projects", + "keywords": [ + "php", + "utopia", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/utopia-php/validators/issues", + "source": "https://github.com/utopia-php/validators/tree/0.2.6" + }, + "time": "2026-06-10T14:45:13+00:00" + } + ], "packages-dev": [ { "name": "laravel/pint", @@ -136,16 +177,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.6.2", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "3a454ca033b9e06b63282ce19562e892747449bb" + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/3a454ca033b9e06b63282ce19562e892747449bb", - "reference": "3a454ca033b9e06b63282ce19562e892747449bb", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", "shasum": "" }, "require": { @@ -188,9 +229,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.2" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" }, - "time": "2025-10-21T19:32:17+00:00" + "time": "2025-12-06T11:56:16+00:00" }, { "name": "phar-io/manifest", @@ -365,35 +406,35 @@ }, { "name": "phpunit/php-code-coverage", - "version": "11.0.11", + "version": "11.0.12", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4" + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4", - "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^5.4.0", + "nikic/php-parser": "^5.7.0", "php": ">=8.2", "phpunit/php-file-iterator": "^5.1.0", "phpunit/php-text-template": "^4.0.1", "sebastian/code-unit-reverse-lookup": "^4.0.1", "sebastian/complexity": "^4.0.1", - "sebastian/environment": "^7.2.0", + "sebastian/environment": "^7.2.1", "sebastian/lines-of-code": "^3.0.1", "sebastian/version": "^5.0.2", - "theseer/tokenizer": "^1.2.3" + "theseer/tokenizer": "^1.3.1" }, "require-dev": { - "phpunit/phpunit": "^11.5.2" + "phpunit/phpunit": "^11.5.46" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -431,7 +472,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.11" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" }, "funding": [ { @@ -451,32 +492,32 @@ "type": "tidelift" } ], - "time": "2025-08-27T14:37:49+00:00" + "time": "2025-12-24T07:01:01+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "5.1.0", + "version": "5.1.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6" + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6", - "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "5.1-dev" } }, "autoload": { @@ -504,15 +545,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.0" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" } ], - "time": "2024-08-27T05:02:59+00:00" + "time": "2026-02-02T13:52:54+00:00" }, { "name": "phpunit/php-invoker", @@ -700,16 +753,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.44", + "version": "11.5.55", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "c346885c95423eda3f65d85a194aaa24873cda82" + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c346885c95423eda3f65d85a194aaa24873cda82", - "reference": "c346885c95423eda3f65d85a194aaa24873cda82", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", "shasum": "" }, "require": { @@ -723,19 +776,20 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.2", - "phpunit/php-code-coverage": "^11.0.11", - "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", "phpunit/php-invoker": "^5.0.1", "phpunit/php-text-template": "^4.0.1", "phpunit/php-timer": "^7.0.1", "sebastian/cli-parser": "^3.0.2", "sebastian/code-unit": "^3.0.3", - "sebastian/comparator": "^6.3.2", + "sebastian/comparator": "^6.3.3", "sebastian/diff": "^6.0.2", "sebastian/environment": "^7.2.1", "sebastian/exporter": "^6.3.2", "sebastian/global-state": "^7.0.2", "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", "sebastian/type": "^5.1.3", "sebastian/version": "^5.0.2", "staabm/side-effects-detector": "^1.0.5" @@ -781,7 +835,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.44" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" }, "funding": [ { @@ -805,7 +859,7 @@ "type": "tidelift" } ], - "time": "2025-11-13T07:17:35+00:00" + "time": "2026-02-18T12:37:06+00:00" }, { "name": "sebastian/cli-parser", @@ -979,16 +1033,16 @@ }, { "name": "sebastian/comparator", - "version": "6.3.2", + "version": "6.3.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8" + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/85c77556683e6eee4323e4c5468641ca0237e2e8", - "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", "shasum": "" }, "require": { @@ -1047,7 +1101,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.2" + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" }, "funding": [ { @@ -1067,7 +1121,7 @@ "type": "tidelift" } ], - "time": "2025-08-10T08:07:46+00:00" + "time": "2026-01-24T09:26:40+00:00" }, { "name": "sebastian/complexity", @@ -1905,5 +1959,5 @@ "php": ">=8.2" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/src/Validator/Conditions.php b/src/Validator/Conditions.php new file mode 100644 index 0000000..f27526b --- /dev/null +++ b/src/Validator/Conditions.php @@ -0,0 +1,120 @@ +isValidCondition($condition, $count)) { + return false; + } + } + + return true; + } + + public function getType(): string + { + return self::TYPE_ARRAY; + } + + /** + * @param array|string $payload + */ + private function isValidCondition(array|string $payload, int &$count): bool + { + if (\is_string($payload)) { + if ($this->maxPayloadLength > 0 && \strlen($payload) > $this->maxPayloadLength) { + return false; + } + + try { + $payload = Condition::decode($payload)->toArray(); + } catch (\Throwable) { + return false; + } + } + + $count++; + + if ($this->maxConditions > 0 && $count > $this->maxConditions) { + return false; + } + + if ($this->maxPayloadLength > 0 && !$this->isWithinPayloadLimit($payload)) { + return false; + } + + $method = $payload['method'] ?? ''; + $values = $payload['values'] ?? []; + + if (\in_array($method, [Condition::TYPE_AND, Condition::TYPE_OR], true)) { + if (!\is_array($values) || \count($values) === 0) { + return false; + } + + foreach ($values as $value) { + if (!\is_array($value) || !$this->isValidCondition($value, $count)) { + return false; + } + } + } + + try { + Condition::fromArray($payload); + } catch (\Throwable) { + return false; + } + + return true; + } + + /** + * @param array $payload + */ + private function isWithinPayloadLimit(array $payload): bool + { + try { + $encoded = \json_encode($payload, JSON_THROW_ON_ERROR); + } catch (\Throwable) { + return false; + } + + return \strlen($encoded) <= $this->maxPayloadLength; + } +} diff --git a/tests/Validator/ConditionsTest.php b/tests/Validator/ConditionsTest.php new file mode 100644 index 0000000..6acaeb6 --- /dev/null +++ b/tests/Validator/ConditionsTest.php @@ -0,0 +1,144 @@ +assertTrue($validator->isArray()); + $this->assertSame(Conditions::TYPE_ARRAY, $validator->getType()); + } + + public function testRejectsEmptyConditionList(): void + { + $validator = new Conditions(); + + $this->assertFalse($validator->isValid([])); + } + + public function testAcceptsConditionArrays(): void + { + $validator = new Conditions(); + + $this->assertTrue($validator->isValid([ + Condition::equal('ip', ['198.51.100.5'])->toArray(), + Condition::startsWith('path', '/v1')->toArray(), + ])); + } + + public function testAcceptsEncodedConditionStrings(): void + { + $validator = new Conditions(); + + $this->assertTrue($validator->isValid([ + Condition::startsWith('path', '/v1')->encode(), + ])); + } + + public function testRejectsInvalidConditionStrings(): void + { + $validator = new Conditions(); + + $this->assertFalse($validator->isValid([ + '{"method":', + ])); + } + + public function testRejectsInvalidConditionArray(): void + { + $validator = new Conditions(); + + $this->assertFalse($validator->isValid([ + [ + 'method' => 'unknown', + 'attribute' => 'ip', + 'values' => ['1.2.3.4'], + ], + ])); + } + + public function testRejectsMixedConditionTypes(): void + { + $validator = new Conditions(); + + $this->assertFalse($validator->isValid([ + Condition::startsWith('path', '/v1')->toArray(), + null, + ])); + + $this->assertFalse($validator->isValid([ + 123, + ])); + } + + public function testRejectsTooManyConditions(): void + { + $validator = new Conditions(maxConditions: 1); + + $this->assertFalse($validator->isValid([ + Condition::equal('ip', ['198.51.100.5'])->toArray(), + Condition::startsWith('path', '/v1')->toArray(), + ])); + } + + public function testRejectsTooManyNestedConditions(): void + { + $validator = new Conditions(maxConditions: 2); + + $this->assertFalse($validator->isValid([ + Condition::and([ + Condition::equal('ip', ['198.51.100.5']), + Condition::startsWith('path', '/v1'), + ])->toArray(), + ])); + } + + public function testRejectsTooManyNestedEncodedConditions(): void + { + $validator = new Conditions(maxConditions: 2); + + $this->assertFalse($validator->isValid([ + Condition::and([ + Condition::equal('ip', ['198.51.100.5']), + Condition::startsWith('path', '/v1'), + ])->encode(), + ])); + } + + public function testRejectsLongConditionArrays(): void + { + $validator = new Conditions(maxPayloadLength: 64); + + $this->assertFalse($validator->isValid([ + Condition::equal('ip', [\str_repeat('1', 128)])->toArray(), + ])); + } + + public function testRejectsLongConditionStrings(): void + { + $validator = new Conditions(maxPayloadLength: 64); + + $this->assertFalse($validator->isValid([ + Condition::equal('ip', [\str_repeat('1', 128)])->encode(), + ])); + } + + public function testRejectsEmptyLogicalConditions(): void + { + $validator = new Conditions(); + + $this->assertFalse($validator->isValid([ + [ + 'method' => Condition::TYPE_AND, + 'values' => [], + ], + ])); + } +}