diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..31af517 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,20 @@ +name: "CodeQL" + +on: [pull_request] + +jobs: + lint: + name: CodeQL + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Run CodeQL + env: + COMPOSER_ROOT_VERSION: dev-main + run: | + docker run --rm -v $PWD:/app -w /app -e COMPOSER_ROOT_VERSION=$COMPOSER_ROOT_VERSION composer:2.8 sh -c \ + "git config --global --add safe.directory /app && composer install --profile --ignore-platform-reqs && composer check" + diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..5f1247e --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,20 @@ +name: "Linter" + +on: [pull_request] + +jobs: + lint: + name: Linter + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Run Linter + env: + COMPOSER_ROOT_VERSION: dev-main + run: | + docker run --rm -v $PWD:/app -w /app -e COMPOSER_ROOT_VERSION=$COMPOSER_ROOT_VERSION composer:2.8 sh -c \ + "git config --global --add safe.directory /app && composer install --profile --ignore-platform-reqs && composer lint" + diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..4699c2d --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,33 @@ +name: "Tests" + +on: [pull_request] + +jobs: + tests: + name: PHP ${{ matrix.php }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.2', '8.3', '8.4', '8.5'] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + extensions: mbstring + + - name: Validate composer.json and composer.lock + run: composer validate --strict + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Run unit tests + run: composer test + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..794c2e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +vendor/ +/.phpunit.cache +var/ + diff --git a/README.md b/README.md index 1f0a85f..33fbcbf 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,111 @@ -# waf -Lite & fast micro PHP WAF rules management that is easy to learn. +# Utopia WAF + +Lite & fast micro PHP Web Application Firewall (WAF) rules management library that is **easy to use** and fits naturally inside the [Utopia](https://github.com/utopia-php) ecosystem. + +The library ships with: + +- A `Condition` builder that mirrors the API of [`Utopia\Database\Query`](https://github.com/utopia-php/database/blob/main/src/Database/Query.php), including JSON parsing helpers and logical operators. +- Action specific rule classes (`Bypass`, `Deny`, `Challenge`, `RateLimit`, `Redirect`). +- A dependency-free `Firewall` orchestrator that evaluates rules against any set of request attributes. + +## Installation + +```bash +composer require utopia-php/waf +``` + +## Usage + +```php +setAttribute('requestIP', '127.0.0.1'); +$firewall->setAttribute('requestMethod', 'GET'); +$firewall->setAttribute('requestPath', '/index'); +$firewall->setAttribute('headers', [ + 'X-Country' => 'US', +]); + +$firewall->addRule(new Deny([ + Condition::equal('ip', ['127.0.0.1']), + Condition::notEqual('path', '/status'), +])); + +$firewall->addRule(new Bypass([ + Condition::equal('country', ['US']), + Condition::equal('method', ['GET']), +])); + +$firewall->addRule(new Challenge([ + Condition::startsWith('path', '/admin'), +], Challenge::TYPE_CAPTCHA)); + +$firewall->addRule(new RateLimit([ + Condition::equal('method', ['POST']), +], limit: 100, interval: 3600)); + +$firewall->addRule(new Redirect([ + Condition::startsWith('path', '/legacy'), +], location: '/new-home', statusCode: 301)); + +var_dump($firewall->verify()); // bool(true|false) + +if ($rule = $firewall->getLastMatchedRule()) { + echo 'Matched action: ' . $rule->getAction(); +} +``` + +### Building Conditions + +Conditions can be created fluently or by parsing JSON definitions: + +```php +$condition = Condition::and([ + Condition::equal('ip', ['10.0.0.1']), + Condition::notEqual('path', '/health'), +]); + +$json = $condition->toString(); +$parsed = Condition::parse($json); +``` + +Available operators mirror the database query builder: `equal`, `notEqual`, `lessThan`, `greaterThan`, `contains`, `between`, `startsWith`, `endsWith`, `isNull`, `and`, `or`, and more. + +### Rate Limiting + +`RateLimit` rules only store the metadata required for external throttling (`limit` + `interval`). Once a rate limit rule matches, the firewall returns `true` and exposes the matched rule via `getLastMatchedRule()` so you can call any third-party rate limiter with the provided metadata. + +```php +$firewall->addRule(new RateLimit([ + Condition::equal('ip', ['203.0.113.12']), +], limit: 500, interval: 60)); + +if ($firewall->verify()) { + $matched = $firewall->getLastMatchedRule(); + if ($matched instanceof RateLimit) { + // Invoke your preferred rate limiter here using $matched->getLimit() and $matched->getInterval() + } +} +``` + +### Testing Locally + +```bash +composer install +composer test +``` + +## License + +MIT diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..1a60556 --- /dev/null +++ b/composer.json @@ -0,0 +1,33 @@ +{ + "name": "utopia-php/waf", + "description": "Lite and extensible Web Application Firewall rules management library for the Utopia PHP ecosystem.", + "type": "library", + "license": "MIT", + "require": { + "php": ">=8.2" + }, + "require-dev": { + "laravel/pint": "^1.18", + "phpstan/phpstan": "^1.11", + "phpunit/phpunit": "^11.0" + }, + "autoload": { + "psr-4": { + "Utopia\\WAF\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Utopia\\WAF\\Tests\\": "tests/" + } + }, + "scripts": { + "check": "vendor/bin/phpstan analyse -c phpstan.neon --memory-limit 512M", + "lint": "vendor/bin/pint --test", + "format": "vendor/bin/pint", + "test": "vendor/bin/phpunit --configuration phpunit.xml" + }, + "minimum-stability": "stable", + "prefer-stable": true +} + diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..bb92df9 --- /dev/null +++ b/composer.lock @@ -0,0 +1,1909 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "2ea83540769daf9a5da7ac560f807ae3", + "packages": [], + "packages-dev": [ + { + "name": "laravel/pint", + "version": "v1.26.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "69dcca060ecb15e4b564af63d1f642c81a241d6f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/69dcca060ecb15e4b564af63d1f642c81a241d6f", + "reference": "69dcca060ecb15e4b564af63d1f642c81a241d6f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.90.0", + "illuminate/view": "^12.40.1", + "larastan/larastan": "^3.8.0", + "laravel-zero/framework": "^12.0.4", + "mockery/mockery": "^1.6.12", + "nunomaduro/termwind": "^2.3.3", + "pestphp/pest": "^3.8.4" + }, + "bin": [ + "builds/pint" + ], + "type": "project", + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/", + "Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An opinionated code formatter for PHP.", + "homepage": "https://laravel.com", + "keywords": [ + "dev", + "format", + "formatter", + "lint", + "linter", + "php" + ], + "support": { + "issues": "https://github.com/laravel/pint/issues", + "source": "https://github.com/laravel/pint" + }, + "time": "2025-11-25T21:15:52+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.6.2", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "3a454ca033b9e06b63282ce19562e892747449bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/3a454ca033b9e06b63282ce19562e892747449bb", + "reference": "3a454ca033b9e06b63282ce19562e892747449bb", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.2" + }, + "time": "2025-10-21T19:32:17+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "1.12.32", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/2770dcdf5078d0b0d53f94317e06affe88419aa8", + "reference": "2770dcdf5078d0b0d53f94317e06affe88419aa8", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2025-09-30T10:16:31+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.11", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4", + "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.4.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/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.2" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "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" + }, + "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-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-08-27T14:37:49+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6", + "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "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" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-27T05:02:59+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.44", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "c346885c95423eda3f65d85a194aaa24873cda82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c346885c95423eda3f65d85a194aaa24873cda82", + "reference": "c346885c95423eda3f65d85a194aaa24873cda82", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "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-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/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/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "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" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "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/phpunit", + "type": "tidelift" + } + ], + "time": "2025-11-13T07:17:35+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/85c77556683e6eee4323e4c5468641ca0237e2e8", + "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "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" + }, + "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/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2025-08-10T08:07:46+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.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/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + }, + "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/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:12:51+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + }, + "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/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + }, + "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/sebastian/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:55:48+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": ">=8.2" + }, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..44afaee --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,7 @@ +parameters: + level: 6 + paths: + - src + - tests + tmpDir: var/phpstan + diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..5580fa4 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,15 @@ + + + + + tests + + + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..65b49a8 --- /dev/null +++ b/pint.json @@ -0,0 +1,8 @@ +{ + "preset": "psr12", + "rules": { + "no_unused_imports": true, + "single_quote": true + } +} + diff --git a/src/Condition.php b/src/Condition.php new file mode 100644 index 0000000..db28855 --- /dev/null +++ b/src/Condition.php @@ -0,0 +1,572 @@ + + */ + private const TYPES = [ + self::TYPE_EQUAL, + self::TYPE_NOT_EQUAL, + self::TYPE_LESS_THAN, + self::TYPE_LESS_THAN_EQUAL, + self::TYPE_GREATER_THAN, + self::TYPE_GREATER_THAN_EQUAL, + self::TYPE_BETWEEN, + self::TYPE_NOT_BETWEEN, + self::TYPE_CONTAINS, + self::TYPE_NOT_CONTAINS, + self::TYPE_STARTS_WITH, + self::TYPE_NOT_STARTS_WITH, + self::TYPE_ENDS_WITH, + self::TYPE_NOT_ENDS_WITH, + self::TYPE_IS_NULL, + self::TYPE_IS_NOT_NULL, + self::TYPE_AND, + self::TYPE_OR, + ]; + + private string $method; + + private string $attribute; + + /** + * @var array + */ + private array $values; + + /** + * @param array $values + */ + public function __construct(string $method, string $attribute = '', array $values = []) + { + if (!self::isMethod($method)) { + throw new ConditionException("Unsupported condition method: {$method}"); + } + + $this->method = $method; + $this->attribute = $attribute; + $this->values = $this->normalizeValues($values); + } + + public function __clone(): void + { + foreach ($this->values as $index => $value) { + if ($value instanceof self) { + $this->values[$index] = clone $value; + } + } + } + + public function getMethod(): string + { + return $this->method; + } + + public function getAttribute(): string + { + return $this->attribute; + } + + /** + * @return array + */ + public function getValues(): array + { + return $this->values; + } + + public function isLogical(): bool + { + return \in_array($this->method, self::LOGICAL_TYPES, true); + } + + public static function isMethod(string $value): bool + { + return \in_array($value, self::TYPES, true); + } + + /** + * Parses a JSON encoded condition. + */ + public static function parse(string $payload): self + { + try { + $decoded = \json_decode($payload, true, flags: JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + throw new ConditionException('Invalid condition payload: ' . $exception->getMessage()); + } + + if (!\is_array($decoded)) { + throw new ConditionException('Invalid condition payload. Expecting array definition.'); + } + + return self::fromArray($decoded); + } + + /** + * @param array $payload + */ + public static function fromArray(array $payload): self + { + $method = $payload['method'] ?? ''; + $attribute = $payload['attribute'] ?? ''; + $values = $payload['values'] ?? []; + + if (!\is_string($method)) { + throw new ConditionException('Invalid condition method definition.'); + } + + if (!\is_string($attribute)) { + throw new ConditionException('Invalid condition attribute definition.'); + } + + if (!\is_array($values)) { + throw new ConditionException('Invalid condition values definition.'); + } + + if (\in_array($method, self::LOGICAL_TYPES, true)) { + $values = array_map( + static function (mixed $value): self { + if (!\is_array($value)) { + throw new ConditionException('Invalid nested condition definition.'); + } + + return self::fromArray($value); + }, + $values + ); + } + + return new self($method, $attribute, $values); + } + + /** + * @param array> $conditions + * @return array + */ + public static function fromArrays(array $conditions): array + { + return array_map(static fn (array $condition): self => self::fromArray($condition), $conditions); + } + + /** + * @return array{ + * method: string, + * attribute?: string, + * values: array + * } + */ + public function toArray(): array + { + $result = ['method' => $this->method]; + + if ($this->attribute !== '') { + $result['attribute'] = $this->attribute; + } + + if ($this->isLogical()) { + $result['values'] = array_map( + static fn (self $condition): array => $condition->toArray(), + $this->values + ); + } else { + $result['values'] = $this->values; + } + + return $result; + } + + /** + * Encode condition as JSON string. + * + * @throws ConditionException + */ + public function toString(): string + { + try { + return \json_encode($this->toArray(), flags: JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + throw new ConditionException('Unable to encode condition: ' . $exception->getMessage()); + } + } + + /** + * @param array $values + */ + public static function equal(string $attribute, array $values): self + { + return new self(self::TYPE_EQUAL, $attribute, $values); + } + + public static function notEqual(string $attribute, string|int|float|bool $value): self + { + return new self(self::TYPE_NOT_EQUAL, $attribute, [$value]); + } + + public static function lessThan(string $attribute, string|int|float $value): self + { + return new self(self::TYPE_LESS_THAN, $attribute, [$value]); + } + + public static function lessThanEqual(string $attribute, string|int|float $value): self + { + return new self(self::TYPE_LESS_THAN_EQUAL, $attribute, [$value]); + } + + public static function greaterThan(string $attribute, string|int|float $value): self + { + return new self(self::TYPE_GREATER_THAN, $attribute, [$value]); + } + + public static function greaterThanEqual(string $attribute, string|int|float $value): self + { + return new self(self::TYPE_GREATER_THAN_EQUAL, $attribute, [$value]); + } + + /** + * @param array $values + */ + public static function contains(string $attribute, array $values): self + { + return new self(self::TYPE_CONTAINS, $attribute, $values); + } + + /** + * @param array $values + */ + public static function notContains(string $attribute, array $values): self + { + return new self(self::TYPE_NOT_CONTAINS, $attribute, $values); + } + + public static function between(string $attribute, string|int|float $start, string|int|float $end): self + { + return new self(self::TYPE_BETWEEN, $attribute, [$start, $end]); + } + + public static function notBetween(string $attribute, string|int|float $start, string|int|float $end): self + { + return new self(self::TYPE_NOT_BETWEEN, $attribute, [$start, $end]); + } + + public static function startsWith(string $attribute, string $value): self + { + return new self(self::TYPE_STARTS_WITH, $attribute, [$value]); + } + + public static function notStartsWith(string $attribute, string $value): self + { + return new self(self::TYPE_NOT_STARTS_WITH, $attribute, [$value]); + } + + public static function endsWith(string $attribute, string $value): self + { + return new self(self::TYPE_ENDS_WITH, $attribute, [$value]); + } + + public static function notEndsWith(string $attribute, string $value): self + { + return new self(self::TYPE_NOT_ENDS_WITH, $attribute, [$value]); + } + + public static function isNull(string $attribute): self + { + return new self(self::TYPE_IS_NULL, $attribute); + } + + public static function isNotNull(string $attribute): self + { + return new self(self::TYPE_IS_NOT_NULL, $attribute); + } + + /** + * @param array $conditions + */ + public static function and(array $conditions): self + { + return new self(self::TYPE_AND, '', $conditions); + } + + /** + * @param array $conditions + */ + public static function or(array $conditions): self + { + return new self(self::TYPE_OR, '', $conditions); + } + + /** + * Evaluate the condition against resolved attributes. + * + * @param array $attributes + */ + public function matches(array $attributes): bool + { + if ($this->isLogical()) { + return $this->matchesLogical($attributes); + } + + $value = $this->resolveValue($attributes); + + return match ($this->method) { + self::TYPE_EQUAL => $this->matchesEqual($value), + self::TYPE_NOT_EQUAL => !$this->matchesEqual($value), + self::TYPE_LESS_THAN => $this->matchesRelational($value, $this->values[0] ?? null, static fn (int $result): bool => $result < 0), + self::TYPE_LESS_THAN_EQUAL => $this->matchesRelational($value, $this->values[0] ?? null, static fn (int $result): bool => $result <= 0), + self::TYPE_GREATER_THAN => $this->matchesRelational($value, $this->values[0] ?? null, static fn (int $result): bool => $result > 0), + self::TYPE_GREATER_THAN_EQUAL => $this->matchesRelational($value, $this->values[0] ?? null, static fn (int $result): bool => $result >= 0), + self::TYPE_CONTAINS => $this->matchesContains($value, $this->values), + self::TYPE_NOT_CONTAINS => !$this->matchesContains($value, $this->values), + self::TYPE_BETWEEN => $this->matchesRange($value, true), + self::TYPE_NOT_BETWEEN => !$this->matchesRange($value, true), + self::TYPE_STARTS_WITH => $this->matchesPrefix($value), + self::TYPE_NOT_STARTS_WITH => !$this->matchesPrefix($value), + self::TYPE_ENDS_WITH => $this->matchesSuffix($value), + self::TYPE_NOT_ENDS_WITH => !$this->matchesSuffix($value), + self::TYPE_IS_NULL => $value === null, + self::TYPE_IS_NOT_NULL => $value !== null, + default => false, + }; + } + + /** + * @param array $values + * @return array + */ + private function normalizeValues(array $values): array + { + if (!$this->isLogical()) { + return $values; + } + + return array_map(static function (mixed $value): self { + if ($value instanceof self) { + return $value; + } + + if (!\is_array($value)) { + throw new ConditionException('Logical conditions require nested condition definitions.'); + } + + return self::fromArray($value); + }, $values); + } + + /** + * @param array $attributes + */ + private function matchesLogical(array $attributes): bool + { + if ($this->method === self::TYPE_AND) { + foreach ($this->values as $condition) { + if (!$condition->matches($attributes)) { + return false; + } + } + + return true; + } + + foreach ($this->values as $condition) { + if ($condition->matches($attributes)) { + return true; + } + } + + return false; + } + + private function matchesEqual(mixed $value): bool + { + foreach ($this->values as $expected) { + if ($expected === $value) { + return true; + } + } + + return false; + } + + /** + * @param array $needles + */ + private function matchesContains(mixed $value, array $needles): bool + { + if (\is_array($value)) { + return \count(array_intersect($value, $needles)) > 0; + } + + if (\is_string($value)) { + foreach ($needles as $needle) { + if (\is_string($needle) && $needle !== '' && str_contains($value, $needle)) { + return true; + } + } + } + + return false; + } + + private function matchesRange(mixed $value, bool $inclusive): bool + { + if (\count($this->values) < 2) { + return false; + } + + [$start, $end] = $this->values; + + if ($value === null || $start === null || $end === null) { + return false; + } + + $startComparison = $this->compare($value, $start); + $endComparison = $this->compare($value, $end); + + if ($startComparison === null || $endComparison === null) { + return false; + } + + return $inclusive + ? $startComparison >= 0 && $endComparison <= 0 + : $startComparison > 0 && $endComparison < 0; + } + + private function matchesPrefix(mixed $value): bool + { + $prefix = $this->values[0] ?? null; + + if (!\is_string($value) || !\is_string($prefix)) { + return false; + } + + return str_starts_with($value, $prefix); + } + + private function matchesSuffix(mixed $value): bool + { + $suffix = $this->values[0] ?? null; + + if (!\is_string($value) || !\is_string($suffix)) { + return false; + } + + return str_ends_with($value, $suffix); + } + + /** + * @param array $attributes + */ + private function resolveValue(array $attributes): mixed + { + if ($this->attribute === '') { + return null; + } + + if (array_key_exists($this->attribute, $attributes)) { + return $attributes[$this->attribute]; + } + + if (str_contains($this->attribute, '.')) { + $segments = explode('.', $this->attribute); + $current = $attributes; + + foreach ($segments as $segment) { + if (\is_array($current) && array_key_exists($segment, $current)) { + $current = $current[$segment]; + continue; + } + + return null; + } + + return $current; + } + + return $attributes[$this->attribute] ?? null; + } + + private function compare(mixed $left, mixed $right): ?int + { + if ($left === null && $right === null) { + return 0; + } + + if ($left === null || $right === null) { + return null; + } + + if (\is_numeric($left) && \is_numeric($right)) { + return $left <=> $right; + } + + if (\is_string($left) && \is_string($right)) { + return $left <=> $right; + } + + if (\is_bool($left) && \is_bool($right)) { + return $left <=> $right; + } + + return null; + } + + /** + * @param callable(int):bool $verdict + */ + private function matchesRelational(mixed $value, mixed $reference, callable $verdict): bool + { + if ($value === null || $reference === null) { + return false; + } + + $result = $this->compare($value, $reference); + + if ($result === null) { + return false; + } + + return $verdict($result); + } +} diff --git a/src/Exception/Condition.php b/src/Exception/Condition.php new file mode 100644 index 0000000..a31b76d --- /dev/null +++ b/src/Exception/Condition.php @@ -0,0 +1,7 @@ + + */ + private array $attributes = []; + + /** + * @var array + */ + private array $rules = []; + + private ?Rule $lastMatchedRule = null; + + public function setAttribute(string $name, mixed $value): self + { + foreach ($this->attributeAliases($name) as $key) { + $this->attributes[$key] = $value; + } + + return $this; + } + + /** + * @param array $attributes + */ + public function setAttributes(array $attributes): self + { + foreach ($attributes as $name => $value) { + $this->setAttribute($name, $value); + } + + return $this; + } + + public function getAttribute(string $name, mixed $default = null): mixed + { + return $this->attributes[$name] ?? $default; + } + + public function addRule(Rule $rule): self + { + $this->rules[] = $rule; + + return $this; + } + + /** + * @param array $rules + */ + public function setRules(array $rules): self + { + $this->rules = $rules; + + return $this; + } + + /** + * @return array + */ + public function getRules(): array + { + return $this->rules; + } + + public function clearRules(): self + { + $this->rules = []; + + return $this; + } + + public function getLastMatchedRule(): ?Rule + { + return $this->lastMatchedRule; + } + + /** + * Evaluate the registered rules and return true when the request should be allowed. + */ + public function verify(): bool + { + $this->lastMatchedRule = null; + + foreach ($this->rules as $rule) { + if (!$rule->matches($this->attributes)) { + continue; + } + + $this->lastMatchedRule = $rule; + + return $this->applyRule($rule); + } + + return false; + } + + private function applyRule(Rule $rule): bool + { + return match ($rule->getAction()) { + Rule::ACTION_BYPASS => true, + Rule::ACTION_DENY => false, + Rule::ACTION_CHALLENGE => false, + Rule::ACTION_RATE_LIMIT => true, + Rule::ACTION_REDIRECT => false, + default => false, + }; + } + + /** + * @return array + */ + private function attributeAliases(string $name): array + { + $aliases = [$name]; + + $normalized = $this->normalizeRequestKey($name); + if ($normalized !== $name) { + $aliases[] = $normalized; + } + + $lower = strtolower($normalized); + if (!\in_array($lower, $aliases, true)) { + $aliases[] = $lower; + } + + return array_unique($aliases); + } + + private function normalizeRequestKey(string $name): string + { + if (stripos($name, 'request') === 0) { + $withoutPrefix = substr($name, 7); + if ($withoutPrefix !== false && $withoutPrefix !== '') { + return lcfirst($withoutPrefix); + } + } + + return $name; + } +} diff --git a/src/Rule.php b/src/Rule.php new file mode 100644 index 0000000..7df6f2b --- /dev/null +++ b/src/Rule.php @@ -0,0 +1,67 @@ + + */ + protected array $conditions = []; + + /** + * @param array> $conditions + */ + public function __construct(array $conditions = []) + { + $this->conditions = array_map( + static function (Condition|array $condition): Condition { + if ($condition instanceof Condition) { + return clone $condition; + } + + return Condition::fromArray($condition); + }, + $conditions + ); + } + + abstract public function getAction(): string; + + /** + * @return array + */ + public function getConditions(): array + { + return $this->conditions; + } + + public function addCondition(Condition $condition): self + { + $this->conditions[] = $condition; + + return $this; + } + + /** + * Evaluate rule conditions against provided attributes. + * + * @param array $attributes + */ + public function matches(array $attributes): bool + { + foreach ($this->conditions as $condition) { + if (!$condition->matches($attributes)) { + return false; + } + } + + return true; + } +} diff --git a/src/Rules/Bypass.php b/src/Rules/Bypass.php new file mode 100644 index 0000000..97f1da3 --- /dev/null +++ b/src/Rules/Bypass.php @@ -0,0 +1,13 @@ +> $conditions + */ + public function __construct(array $conditions = [], string $type = self::TYPE_CAPTCHA) + { + parent::__construct($conditions); + if (!in_array($type, [self::TYPE_CAPTCHA, self::TYPE_CUSTOM], true)) { + throw new \InvalidArgumentException('Invalid challenge type: ' . $type); + } + $this->type = $type; + } + + public function getAction(): string + { + return self::ACTION_CHALLENGE; + } + + public function getType(): string + { + return $this->type; + } +} diff --git a/src/Rules/Deny.php b/src/Rules/Deny.php new file mode 100644 index 0000000..7b2da01 --- /dev/null +++ b/src/Rules/Deny.php @@ -0,0 +1,13 @@ +> $conditions + */ + public function __construct( + array $conditions = [], + int $limit = 100, + int $interval = 3600 + ) { + parent::__construct($conditions); + if ($limit < 1 || $interval < 1) { + throw new \InvalidArgumentException('Limit and interval must be at least 1'); + } + $this->limit = max(1, $limit); + $this->interval = max(1, $interval); + } + + public function getAction(): string + { + return self::ACTION_RATE_LIMIT; + } + + public function getLimit(): int + { + return $this->limit; + } + + public function getInterval(): int + { + return $this->interval; + } +} diff --git a/src/Rules/Redirect.php b/src/Rules/Redirect.php new file mode 100644 index 0000000..697359a --- /dev/null +++ b/src/Rules/Redirect.php @@ -0,0 +1,37 @@ +> $conditions + */ + public function __construct(array $conditions = [], string $location = '/', int $statusCode = 302) + { + parent::__construct($conditions); + $this->location = $location; + $this->statusCode = $statusCode; + } + + public function getAction(): string + { + return self::ACTION_REDIRECT; + } + + public function getLocation(): string + { + return $this->location; + } + + public function getStatusCode(): int + { + return $this->statusCode; + } +} diff --git a/tests/ConditionTest.php b/tests/ConditionTest.php new file mode 100644 index 0000000..7930a62 --- /dev/null +++ b/tests/ConditionTest.php @@ -0,0 +1,190 @@ +assertTrue($equal->matches(['ip' => '127.0.0.1'])); + $this->assertFalse($equal->matches(['ip' => '1.1.1.1'])); + + $this->assertTrue($notEqual->matches(['method' => 'GET'])); + $this->assertFalse($notEqual->matches(['method' => 'POST'])); + } + + public function testComparisonOperators(): void + { + $lessThan = Condition::lessThan('count', 10); + $lessThanEqual = Condition::lessThanEqual('count', 10); + $greaterThan = Condition::greaterThan('count', 5); + $greaterThanEqual = Condition::greaterThanEqual('count', 5); + + $this->assertTrue($lessThan->matches(['count' => 9])); + $this->assertFalse($lessThan->matches(['count' => 10])); + + $this->assertTrue($lessThanEqual->matches(['count' => 10])); + $this->assertTrue($greaterThan->matches(['count' => 6])); + $this->assertFalse($greaterThan->matches(['count' => 5])); + $this->assertTrue($greaterThanEqual->matches(['count' => 5])); + } + + public function testContainsOperators(): void + { + $stringContains = Condition::contains('path', ['admin', 'dashboard']); + $arrayContains = Condition::contains('tags', ['security']); + $notContains = Condition::notContains('path', ['forbidden']); + + $this->assertTrue($stringContains->matches(['path' => '/admin/users'])); + $this->assertFalse($stringContains->matches(['path' => '/public'])); + + $this->assertTrue($arrayContains->matches(['tags' => ['security', 'waf']])); + $this->assertFalse($arrayContains->matches(['tags' => ['network']])); + + $this->assertTrue($notContains->matches(['path' => '/allowed'])); + $this->assertFalse($notContains->matches(['path' => '/forbidden'])); + } + + public function testRangeOperators(): void + { + $between = Condition::between('latency', 100, 200); + $notBetween = Condition::notBetween('latency', 100, 200); + + $this->assertTrue($between->matches(['latency' => 150])); + $this->assertTrue($notBetween->matches(['latency' => 50])); + $this->assertFalse($notBetween->matches(['latency' => 150])); + } + + public function testRelationalOperatorsWithNullValues(): void + { + $lessThan = Condition::lessThan('count', 5); + $greaterThan = Condition::greaterThan('count', 5); + $between = Condition::between('count', 1, 10); + + $this->assertFalse($lessThan->matches(['count' => null])); + $this->assertFalse($lessThan->matches([])); + + $this->assertFalse($greaterThan->matches(['count' => null])); + $this->assertFalse($greaterThan->matches([])); + + $this->assertFalse($between->matches(['count' => null])); + $this->assertFalse($between->matches([])); + } + + public function testStartsAndEndsOperators(): void + { + $startsWith = Condition::startsWith('path', '/api'); + $notStartsWith = Condition::notStartsWith('path', '/admin'); + $endsWith = Condition::endsWith('path', '.json'); + $notEndsWith = Condition::notEndsWith('path', '.php'); + + $this->assertTrue($startsWith->matches(['path' => '/api/v1'])); + $this->assertFalse($startsWith->matches(['path' => '/web'])); + + $this->assertTrue($notStartsWith->matches(['path' => '/public'])); + $this->assertFalse($notStartsWith->matches(['path' => '/admin'])); + + $this->assertTrue($endsWith->matches(['path' => '/status.json'])); + $this->assertFalse($endsWith->matches(['path' => '/status.xml'])); + + $this->assertTrue($notEndsWith->matches(['path' => '/status'])); + $this->assertFalse($notEndsWith->matches(['path' => '/index.php'])); + } + + public function testNullOperatorsAndAttributeResolution(): void + { + $isNull = Condition::isNull('payload.signature'); + $isNotNull = Condition::isNotNull('payload.signature'); + + $attributes = [ + 'payload' => [ + 'signature' => 'abc', + ], + ]; + + $this->assertFalse($isNull->matches($attributes)); + $this->assertTrue($isNull->matches(['payload' => []])); + + $this->assertTrue($isNotNull->matches($attributes)); + $this->assertFalse($isNotNull->matches(['payload' => []])); + } + + public function testLogicalOperatorsNested(): void + { + $nested = Condition::and([ + Condition::equal('method', ['POST']), + Condition::or([ + Condition::equal('path', ['/admin']), + Condition::startsWith('path', '/internal'), + ]), + Condition::notContains('headers.user-agent', ['bot']), + ]); + + $this->assertTrue($nested->matches([ + 'method' => 'POST', + 'path' => '/internal/tools', + 'headers' => [ + 'user-agent' => 'Mozilla', + ], + ])); + + $this->assertFalse($nested->matches([ + 'method' => 'POST', + 'path' => '/public', + 'headers' => [ + 'user-agent' => 'Mozilla', + ], + ])); + + $this->assertFalse($nested->matches([ + 'method' => 'POST', + 'path' => '/internal/ops', + 'headers' => [ + 'user-agent' => 'bot', + ], + ])); + } + + public function testConditionSerializationRoundTrip(): void + { + $condition = Condition::and([ + Condition::equal('ip', ['127.0.0.1']), + Condition::or([ + Condition::startsWith('path', '/api'), + Condition::endsWith('path', '.json'), + ]), + ]); + + $json = $condition->toString(); + $parsed = Condition::parse($json); + + $this->assertTrue($parsed->matches(['ip' => '127.0.0.1', 'path' => '/api/users'])); + $this->assertTrue($parsed->matches(['ip' => '127.0.0.1', 'path' => '/status.json'])); + $this->assertFalse($parsed->matches(['ip' => '127.0.0.1', 'path' => '/web'])); + } + + public function testInvalidMethodThrowsException(): void + { + $this->expectException(ConditionException::class); + + Condition::fromArray([ + 'method' => 'unknown', + 'attribute' => 'ip', + 'values' => [], + ]); + } + + public function testParseRejectsInvalidJson(): void + { + $this->expectException(ConditionException::class); + + Condition::parse('{"method":'); + } +} diff --git a/tests/FirewallTest.php b/tests/FirewallTest.php new file mode 100644 index 0000000..d5a394c --- /dev/null +++ b/tests/FirewallTest.php @@ -0,0 +1,66 @@ +setAttribute('requestIP', '127.0.0.1'); + $firewall->setAttribute('requestPath', '/index'); + + $deny = new Deny([ + Condition::equal('ip', ['127.0.0.1']), + Condition::notEqual('path', '/health'), + ]); + + $bypass = new Bypass([ + Condition::equal('ip', ['127.0.0.1']), + ]); + + $firewall->addRule($deny); + $firewall->addRule($bypass); + + $this->assertFalse($firewall->verify(), 'Deny should be executed first'); + + $firewall->clearRules(); + $firewall->addRule($bypass); + $firewall->addRule($deny); + + $this->assertTrue($firewall->verify(), 'Bypass should pass when it is the first matching rule'); + } + + public function testRateLimitMetadata(): void + { + $firewall = new Firewall(); + $firewall->setAttributes([ + 'requestIP' => '192.168.1.10', + 'requestPath' => '/api', + ]); + + $rateLimit = new RateLimit([ + Condition::equal('ip', ['192.168.1.10']), + ], limit: 2, interval: 60); + + $firewall->addRule($rateLimit); + + $this->assertTrue($firewall->verify()); + $matched = $firewall->getLastMatchedRule(); + + $this->assertInstanceOf(RateLimit::class, $matched); + if (!$matched instanceof RateLimit) { + return; + } + + $this->assertSame(2, $matched->getLimit()); + $this->assertSame(60, $matched->getInterval()); + } +} diff --git a/tests/RulesTest.php b/tests/RulesTest.php new file mode 100644 index 0000000..4b848d5 --- /dev/null +++ b/tests/RulesTest.php @@ -0,0 +1,62 @@ +assertTrue($rule->matches(['ip' => '127.0.0.1'])); + $this->assertSame('bypass', $rule->getAction()); + } + + public function testDenyRule(): void + { + $rule = new Deny([ + Condition::equal('method', ['POST']), + ]); + + $this->assertTrue($rule->matches(['method' => 'POST'])); + $this->assertSame('deny', $rule->getAction()); + } + + public function testChallengeRuleTypeDefaults(): void + { + $defaultRule = new Challenge(); + $customRule = new Challenge([], Challenge::TYPE_CUSTOM); + + $this->assertSame('challenge', $defaultRule->getAction()); + $this->assertSame(Challenge::TYPE_CAPTCHA, $defaultRule->getType()); + $this->assertSame(Challenge::TYPE_CUSTOM, $customRule->getType()); + } + + public function testRateLimitMetadata(): void + { + $rule = new RateLimit([], limit: 10, interval: 600); + + $this->assertSame('rateLimit', $rule->getAction()); + $this->assertSame(10, $rule->getLimit()); + $this->assertSame(600, $rule->getInterval()); + } + + public function testRedirectRule(): void + { + $rule = new Redirect([], location: '/new', statusCode: 301); + + $this->assertSame('redirect', $rule->getAction()); + $this->assertSame('/new', $rule->getLocation()); + $this->assertSame(301, $rule->getStatusCode()); + } +}