diff --git a/.github/workflows/phpunit-invariants.yml b/.github/workflows/phpunit-invariants.yml new file mode 100644 index 0000000000000..f4d48a16379cc --- /dev/null +++ b/.github/workflows/phpunit-invariants.yml @@ -0,0 +1,241 @@ +# Runs the PHPUnit test suite with the gameshark PHP extension checking the invariants in tests/invariants/specs/. +# +# The invariants only report: a violation never fails this workflow. Their report is shown in the run's summary and +# uploaded as an artifact. The workflow fails when the extension cannot be built or loaded, when the self-tests of the +# invariant support code fail, or when the test suite itself fails. See tests/invariants/README.md. +# +# Besides pushes and pull requests, adding the `invariant-tests` label to a pull request runs it again. +name: PHPUnit Invariants + +on: + push: + branches: + - trunk + paths: + - 'src/**' + - 'tests/phpunit/**' + - 'tests/invariants/**' + - 'phpunit.xml.dist' + - 'composer.json' + - 'package.json' + - 'package-lock.json' + - 'docker-compose.yml' + - 'tools/local-env/**' + - '.github/workflows/phpunit-invariants.yml' + pull_request: + types: [ 'opened', 'synchronize', 'reopened', 'labeled' ] + branches: + - trunk + paths: + - 'src/**' + - 'tests/phpunit/**' + - 'tests/invariants/**' + - 'phpunit.xml.dist' + - 'composer.json' + - 'package.json' + - 'package-lock.json' + - 'docker-compose.yml' + - 'tools/local-env/**' + - '.github/workflows/phpunit-invariants.yml' + workflow_dispatch: + +# Cancels earlier runs for the same pull request. Adding any other label starts a run of its own, which is skipped, so +# that it does not cancel a run in progress. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.sha }}${{ github.event.action == 'labeled' && github.event.label.name != 'invariant-tests' && format( '-{0}', github.run_id ) || '' }} + cancel-in-progress: true + +# Disable permissions for all available scopes by default. +# Any needed permissions should be configured at the job level. +permissions: {} + +jobs: + prepare-gutenberg: + name: Prepare Gutenberg Assets + uses: ./.github/workflows/reusable-prepare-gutenberg.yml + permissions: + contents: read + if: ${{ github.event.action != 'labeled' || github.event.label.name == 'invariant-tests' }} + + # Runs the test suite with the invariants, on the latest PHP only. + # + # Performs the following steps: + # - Checks out the repository, and the gameshark extension and the MySQL parser at the commits in + # tests/invariants/DEPENDENCIES. + # - Sets up Node.js, PHP and Composer, and builds WordPress. + # - Starts the Docker environment and installs WordPress. + # - Builds the extension inside the PHP container and enables it there. + # - Runs the self-tests of the invariant support code. + # - Runs the test suite with the invariants. + # - Adds the invariant report to the run's summary, and uploads it. + invariants: + name: PHP 8.5 with MySQL 8.4 + runs-on: ubuntu-24.04 + needs: [ prepare-gutenberg ] + permissions: + contents: read + # The suite runs several times slower with the invariants. + timeout-minutes: 180 + env: + LOCAL_PHP: '8.5-fpm' + LOCAL_PHP_XDEBUG: false + LOCAL_DB_TYPE: 'mysql' + LOCAL_DB_VERSION: '8.4' + LOCAL_PHP_MEMCACHED: false + LOCAL_WP_TESTS_DOMAIN: 'example.org' + PUPPETEER_SKIP_DOWNLOAD: true + steps: + - name: Configure environment variables + run: | + echo "PHP_FPM_UID=$(id -u)" >> "$GITHUB_ENV" + echo "PHP_FPM_GID=$(id -g)" >> "$GITHUB_ENV" + + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} + persist-credentials: false + + - name: Read the commits of the dependencies + id: dependencies + run: | + echo "gameshark=$( sed -n 's/^gameshark=//p' tests/invariants/DEPENDENCIES )" >> "$GITHUB_OUTPUT" + echo "sqlite-database-integration=$( sed -n 's/^sqlite-database-integration=//p' tests/invariants/DEPENDENCIES )" >> "$GITHUB_OUTPUT" + + # Both are checked out inside the repository, so that they are mounted in the PHP container under /var/www/.invariants. + - name: Checkout the gameshark extension + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: dmsnell/php-ext-gameshark-extension + ref: ${{ steps.dependencies.outputs.gameshark }} + path: .invariants/gameshark + show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} + persist-credentials: false + + # The MySQL parser, in packages/mysql-parser, is the oracle for the structure of queries. + - name: Checkout the MySQL parser + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: WordPress/sqlite-database-integration + ref: ${{ steps.dependencies.outputs.sqlite-database-integration }} + path: .invariants/sqlite-database-integration + sparse-checkout: packages/mysql-parser/src + show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} + persist-credentials: false + + - name: Download prepared Gutenberg build + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: gutenberg-build + path: gutenberg + digest-mismatch: error + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: '.nvmrc' + package-manager-cache: false + + - name: Set up npm + uses: ./.github/actions/setup-npm + + - name: Set up PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: '8.5' + coverage: none + + - name: Install Composer dependencies + uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + custom-cache-suffix: $(/bin/date -u --date='last Mon' "+%F") + + - name: Install npm dependencies + run: npm ci + + - name: Build WordPress + timeout-minutes: 5 + run: npm run build:dev + env: + GUTENBERG_EXPECTED_SHA: ${{ needs.prepare-gutenberg.outputs.gutenberg-sha }} + + - name: Start Docker environment + run: | + npm run env:pull + npm run env:start + + - name: Install WordPress + run: npm run env:install + + # Rust and the extension's build are kept between runs, for the same extension commit and PHP image. + - name: Get the PHP image + id: php-image + run: echo "id=$( docker image inspect --format '{{.Id}}' "wordpressdevelop/php:${LOCAL_PHP}" )" >> "$GITHUB_OUTPUT" + + - name: Cache the gameshark build + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: | + .invariants/cargo + .invariants/rustup + .invariants/gameshark/rust/target + key: gameshark-${{ steps.dependencies.outputs.gameshark }}-${{ steps.php-image.outputs.id }} + + - name: Build and enable the gameshark extension + run: docker compose exec -T -u 0 php bash /var/www/tests/invariants/bin/install-gameshark.sh + + - name: Run the self-tests of the invariant support code + run: npm run test:php:invariants:self-tests + + - name: Run the canaries, which break each security invariant on purpose + run: npm run test:php:invariants:canaries + + - name: Run PHPUnit tests with the invariants + run: npm run test:php:invariants -- --verbose + env: + # Also count the types every WordPress function is given and returns, for the types report. + WP_INVARIANTS_TYPES: 1 + + # The AJAX tests are a group of their own, which the main run leaves out, as phpunit.xml.dist does. + - name: Run the AJAX tests with the invariants + if: ${{ success() || failure() }} + run: npm run test:php:invariants -- --verbose --group ajax --log-junit tests/phpunit/build/logs/invariants-ajax-junit.xml + env: + WP_INVARIANTS_REPORT_NAME: invariants-ajax + + - name: Show the test which was running + if: ${{ failure() || cancelled() }} + run: | + cat tests/phpunit/build/logs/invariants-current-test.txt 2> /dev/null || echo 'No test was running.' + cat tests/phpunit/build/logs/invariants-ajax-current-test.txt 2> /dev/null || true + + - name: Add the invariant report to the summary + if: ${{ always() }} + run: | + if [[ -f tests/phpunit/build/logs/invariants.md ]]; then + cat tests/phpunit/build/logs/invariants.md >> "$GITHUB_STEP_SUMMARY" + else + echo 'No invariant report was written.' >> "$GITHUB_STEP_SUMMARY" + fi + # The types report itself is too large for the summary, which links to it. + if [[ -f tests/phpunit/build/logs/invariants-types-summary.md ]]; then + cat tests/phpunit/build/logs/invariants-types-summary.md >> "$GITHUB_STEP_SUMMARY" + fi + if [[ -f tests/phpunit/build/logs/invariants-ajax.md ]]; then + echo '# AJAX tests' >> "$GITHUB_STEP_SUMMARY" + cat tests/phpunit/build/logs/invariants-ajax.md >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload the invariant report + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: invariant-report + path: | + tests/phpunit/build/logs/invariants*.json + tests/phpunit/build/logs/invariants*.md + tests/phpunit/build/logs/invariants*-junit.xml + if-no-files-found: warn + + - name: Ensure version-controlled files are not modified or deleted + run: git diff --exit-code diff --git a/.gitignore b/.gitignore index 94798d0a72702..c68600a3a5da5 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,8 @@ wp-tests-config.php /build /gutenberg /tests/phpunit/build +# The gameshark PHP extension, the MySQL parser, and their builds, for tests/invariants. +/.invariants /wp-cli.local.yml /phpstan.neon /*.tsbuildinfo diff --git a/package.json b/package.json index e209ae46d2058..0174f27dce08e 100644 --- a/package.json +++ b/package.json @@ -150,8 +150,12 @@ "env:composer": "node ./tools/local-env/scripts/docker.js run -T --rm php composer", "env:logs": "node ./tools/local-env/scripts/docker.js logs", "env:pull": "node ./tools/local-env/scripts/docker.js pull", + "env:gameshark": "node ./tools/local-env/scripts/docker.js exec -u 0 php bash /var/www/tests/invariants/bin/install-gameshark.sh", "test:performance": "wp-scripts test-playwright --config tests/performance/playwright.config.js", "test:php": "node ./tools/local-env/scripts/docker.js run --rm php ./vendor/bin/phpunit", + "test:php:invariants": "node ./tools/local-env/scripts/docker.js exec -e GAMESHARK_INVARIANTS=1 -e WP_INVARIANTS_SKIP -e WP_INVARIANTS_TYPES -e WP_INVARIANTS_REPORT_NAME -e GAMESHARK_INVARIANTS_FILE=/var/www/tests/invariants/gameshark.php php ./vendor/bin/phpunit -c tests/invariants/phpunit.xml.dist", + "test:php:invariants:canaries": "node ./tools/local-env/scripts/docker.js exec -e GAMESHARK_INVARIANTS=1 -e WP_INVARIANTS_SKIP -e WP_INVARIANTS_CANARIES=1 -e GAMESHARK_INVARIANTS_FILE=/var/www/tests/invariants/gameshark.php php ./vendor/bin/phpunit -c tests/invariants/canaries/phpunit.xml.dist", + "test:php:invariants:self-tests": "node ./tools/local-env/scripts/docker.js exec -e GAMESHARK_INVARIANTS=1 -e WP_INVARIANTS_SKIP -e GAMESHARK_INVARIANTS_FILE=/var/www/tests/invariants/self-tests/gameshark.php php ./vendor/bin/phpunit -c tests/invariants/self-tests/phpunit.xml.dist", "test:coverage": "npm run test:php -- --coverage-html ./coverage/html/ --coverage-php ./coverage/php/report.php --coverage-text=./coverage/text/report.txt", "test:e2e": "wp-scripts test-playwright --config tests/e2e/playwright.config.js", "test:visual": "wp-scripts test-playwright --config tests/visual-regression/playwright.config.js", diff --git a/phpunit.xml.dist b/phpunit.xml.dist index c5f7e98a395c7..44e3957699ddf 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -12,6 +12,7 @@ convertNoticesToExceptions="true" convertDeprecationsToExceptions="true" > + diff --git a/tests/invariants/DEPENDENCIES b/tests/invariants/DEPENDENCIES new file mode 100644 index 0000000000000..1be640f7233d1 --- /dev/null +++ b/tests/invariants/DEPENDENCIES @@ -0,0 +1,2 @@ +gameshark=8bd24c72901ddd3a0ef95614da00d26fa311497c +sqlite-database-integration=f05c1f9eecf13ff2f387c4be8acf1ae3befaa537 diff --git a/tests/invariants/README.md b/tests/invariants/README.md new file mode 100644 index 0000000000000..1b01b9096c08f --- /dev/null +++ b/tests/invariants/README.md @@ -0,0 +1,291 @@ +# Invariants + +Invariants are properties of WordPress which should hold every time some code runs, such as "a post ID given to `get_post()` is never negative", or "a query from `$wpdb->prepare()` reaches MySQL with its structure unchanged". + +They are checked while the whole PHPUnit test suite runs, by hooks on WordPress functions and methods. The [gameshark](https://github.com/dmsnell/php-ext-gameshark-extension) PHP extension calls those hooks just before or just after each call to their target. The existing tests do not change: they only cause the calls which the invariants look at. + +Where it helps, invariants compare what WordPress produces with how an independent parser reads it: PHP's browser-grade HTML parser `Dom\HTMLDocument`, its URL parser `Uri\WhatWg\Url`, its XML parser `Dom\XMLDocument`, and a MySQL parser generated from MySQL's own grammar. These oracles are why the suite needs PHP 8.5. + +Violations are reported only; they never fail a test. The [`PHPUnit Invariants`](../../.github/workflows/phpunit-invariants.yml) workflow runs the suite with the invariants on PHP 8.5, and shows the report in the run's summary. It runs for pull requests and pushes to trunk, and again when the `invariant-tests` label is added to a pull request. + +## Files + +- `specs/`: the invariants, in one file for each area of WordPress, such as `post.php` or `wpdb.php`. +- `gameshark.php`: the file the extension loads at the start of every PHP process, before PHPUnit and WordPress. It returns the hooks of every invariant in `specs/`. +- `bootstrap.php` and `phpunit.xml.dist`: run the WordPress test suite with the invariants. The test suites and groups are the same as in `phpunit.xml.dist` at the root of the repository. +- `includes/`: the support code. `WP_Invariant` defines invariants, `WP_Invariant_Call` is what their checks receive, and `WP_Invariant_SQL_Lexer` compares the structure of queries and of `wpdb::prepare()` templates. `WP_Invariant_Request` finds what the request sent, and `WP_Invariant_Security` reads addresses, serialized data and files as an attacker would use them. +- `includes/oracles/`: the independent parsers. `WP_Invariant_Browser` reads HTML, URLs and XML as browsers and XML parsers do; `WP_Invariant_MySQL` parses queries with the [MySQL parser](https://github.com/WordPress/sqlite-database-integration/tree/trunk/packages/mysql-parser) from `WordPress/sqlite-database-integration`; `WP_Invariant_HTML_API` finds where the syntax of HTML is with WordPress's own tag processor. +- `self-tests/`: tests of the support code, with fixtures standing in for WordPress. They need the extension, but not WordPress or a database. +- `canaries/`: tests which break each security invariant on purpose, with WordPress loaded, and check that it is reported. Each has a twin which does the same safely, and checks that nothing is. +- `bin/install-gameshark.sh`: builds the extension in the PHP container of the local Docker environment. +- `DEPENDENCIES`: the commits of the extension and of `sqlite-database-integration`, for the MySQL parser. Both are checked out in `.invariants/`, which git ignores. + +## Running locally + +With the [local Docker environment](../../README.md) started and WordPress installed (`npm run env:start` and `npm run env:install`): + +```sh +# Check out the extension and the MySQL parser once, at the pinned commits. +git clone https://github.com/dmsnell/php-ext-gameshark-extension.git .invariants/gameshark +git -C .invariants/gameshark checkout "$( sed -n 's/^gameshark=//p' tests/invariants/DEPENDENCIES )" +git clone https://github.com/WordPress/sqlite-database-integration.git .invariants/sqlite-database-integration +git -C .invariants/sqlite-database-integration checkout "$( sed -n 's/^sqlite-database-integration=//p' tests/invariants/DEPENDENCIES )" + +# Build the extension and enable it in the PHP container. Rust and the build are kept in .invariants/. +# Run this again whenever the container is recreated. +npm run env:gameshark + +npm run test:php:invariants:self-tests +npm run test:php:invariants +npm run test:php:invariants -- --group post +``` + +The report is written to `tests/phpunit/build/logs/invariants.md` and `invariants.json`. Set `WP_INVARIANTS_REPORT_DIR` to write it somewhere else. + +To leave out spec files, set `WP_INVARIANTS_SKIP` to their names, without `.php`: `WP_INVARIANTS_SKIP=pcre,xml,filesystem,kses,output,secrets,uploads,archives,serialize,random npm run test:php:invariants` runs without the invariants which hook built-in PHP functions, which are the slowest. + +The AJAX tests are a group which the main run leaves out, as `phpunit.xml.dist` does. Run them on their own, with a report of their own: `WP_INVARIANTS_REPORT_NAME=invariants-ajax npm run test:php:invariants -- --group ajax`. The security invariants which look at request handlers, in `specs/request.php`, mostly find their handlers there. + +`npm run test:php:invariants:canaries` runs the canaries. They need a database, as the suite does. To load the MySQL parser from somewhere else, set `WP_INVARIANTS_MYSQL_PARSER` to its `packages/mysql-parser/src` directory. + +To stop at a violation with its full stack, set `WP_INVARIANTS_THROW=1`. A check which throws then breaks the code under test, so only use it to investigate one invariant. + +Outside Docker, PHP 8.5 or newer which is not thread-safe can run the suite: build it with `scripts/build-unix.sh` from its repository, then run PHPUnit with `-d extension=/path/to/gameshark.so` and the environment variables `GAMESHARK_INVARIANTS=1` and `GAMESHARK_INVARIANTS_FILE=/absolute/path/to/tests/invariants/gameshark.php`. The self-tests need no database, so they run this way anywhere. Tests which run in a separate process start PHP again without `-d` options, so for them, enable the extension in an ini file instead, in a directory given by `PHP_INI_SCAN_DIR`. + +## Writing an invariant + +Add it to the file in `specs/` for its area of WordPress, or start a new file. A spec file returns a list of invariants: + +```php +return array( + WP_Invariant::define( 'positive-id' ) + ->describe( 'A numeric post ID given to get_post() is a whole number above 0, or 0 for the current post.' ) + ->before( + 'get_post', + static function ( WP_Invariant_Call $call ) { + $post = $call->arg( 'post' ); + if ( is_numeric( $post ) ) { + $call->expect( (int) $post >= 0, 'get_post() received the post ID %s.', $post ); + } + } + ), +); +``` + +Its id is the file's path in `specs/` and its name: `post/positive-id`. + +An invariant has: + +- a **description** of what must hold, which is shown with its violations; +- any number of **hooks**: `->before( $target, $check )` runs before each call to the target, and `->after( $target, $check )` after each call returns. `->before_each( $targets, $check )` and `->after_each()` add the same check to several targets. A target is a function, such as `get_post`, or a method, such as `wpdb::prepare`. Private methods, methods inherited by subclasses, and built-in PHP functions and methods work too; +- optionally, a **state** shared by its hooks: `->state( $initial, 'test' )` starts it again before each test, while `'run'` keeps it for the whole run. + +A check is `function ( WP_Invariant_Call $call, &$state )`. It records a violation with `$call->expect( $condition, $format, ...$values )`, and can read globals such as `$wpdb` or `$wp_filter` like any other code. + +`WP_Invariant_Call` gives: + +| Method | | +| --- | --- | +| `arg( $name )` | An argument by its parameter name, or the parameter's default when it was not passed. | +| `args()` | The arguments in order. | +| `self()` | The object of an instance method, or null. | +| `return_value()` | What the call returned, in an after check. | +| `before()` | What the paired before check returned for this same call, in an after check. | +| `caller()` | The file and line the target was called from. | +| `caller_args()` | The arguments of the function which called the target. It takes a backtrace with arguments, so only call it once a check has found something to report. | +| `depth()` | How deep the call is. A call the target makes is one deeper. | +| `stack()` | The frames of the call stack in `src/`, innermost first. | +| `target()`, `when()` | The target, and `before` or `after`. | + +### Inputs and results + +An after check sees both the arguments and the return value, so it can relate them: + +```php +WP_Invariant::define( 'returns-requested-post' ) + ->describe( 'get_post() given a post ID returns the post with that ID, or null.' ) + ->after( + 'get_post', + static function ( WP_Invariant_Call $call ) { + $result = $call->return_value(); + if ( is_numeric( $call->arg( 'post' ) ) && $result instanceof WP_Post ) { + $call->expect( (int) $call->arg( 'post' ) === $result->ID, 'get_post( %s ) returned the post %d.', $call->arg( 'post' ), $result->ID ); + } + } + ), +``` + +The arguments an after check sees are as they were when the call started, even if the function changed its parameters. Objects are not copied, though: an object argument, or `self()`, shows the changes the call made. To compare with an object as it was before the call, pair the after check with a before check. + +### Before and after one call + +When an invariant has a before check and an after check on the same target, what the before check returns is given to the after check for the same call, as `$call->before()`. This works through recursion, and when a call inside throws: + +```php +WP_Invariant::define( 'query-is-counted' ) + ->describe( 'A successful wpdb::query() adds at least 1 to $wpdb->num_queries.' ) + ->before( + 'wpdb::query', + static function ( WP_Invariant_Call $call ) { + return $call->self()->num_queries; + } + ) + ->after( + 'wpdb::query', + static function ( WP_Invariant_Call $call ) { + if ( false !== $call->return_value() ) { + $call->expect( $call->self()->num_queries > $call->before(), 'num_queries went from %d to %d.', $call->before(), $call->self()->num_queries ); + } + } + ), +``` + +After checks do not run for a call which throws. + +### Tables of calls + +To check one call against others, keep a table of calls in the invariant's state, or in an object shared by several invariants, in any spec files: `WP_Invariant::shared( 'wpdb/prepared' )` returns a plain object, which is emptied before each test unless it is given the scope `'run'`. In `specs/wpdb.php`, `prepared-structure` records every query that `wpdb::prepare()` returns, and `prepared-reaches-mysql` looks for them in the queries sent to MySQL. + +An invariant which finds places in WordPress, rather than calls, such as `pcre/matches-no-html-syntax`, keeps the places it has reported in a `'run'` state, and reports each place once. + +`WP_Invariant::strings_in( $value )` returns the strings in a value and its nested arrays, which helps with arguments such as `$postarr`. It does not look inside objects. + +### Other helpers + +| | | +| --- | --- | +| `WP_Invariant::property( $instance, $declaring, $name )` | A private or protected property, read with reflection, which runs none of the object's code. `$declaring` is the class which declares it. `specs/html.php` reads where the tag processor's current tag is. | +| `WP_Invariant::methods_in( $directory, $pattern )` | The `Class::method` names declared in a directory's PHP files whose names match a pattern, read from their tokens without loading them, as targets for a spec file. `specs/rest.php` hooks every core REST permission check this way. | +| `WP_Invariant::has_callbacks( $hook )` | Whether a filter or action has callbacks, read from `$wp_filter` without running anything. Checks use it to stay quiet when a test filters what they check. | +| `WP_Invariant_Request::values( $characters )` | The values the request sent, which hold any of some characters: the strings in `$_GET`, `$_POST`, `$_COOKIE` and `$_REQUEST`, the URI, query string and headers, and the parameters of the REST requests being dispatched, as sent and unslashed, of at least 4 bytes. `WP_Invariant_Request::occurrences()` finds where one appears. | +| `WP_Invariant::frame_mark( $function )` and `WP_Invariant::running( $mark, $function )` | A scope for the run of a call, such as an AJAX handler's `do_action()`, without an after hook, which would not run when the call throws. `specs/request.php` and `specs/archives.php` keep stacks of them. | +| `WP_Invariant::resolve_path()`, `path_inside()`, `path_resolves_inside()` and `directory_forms()` | Paths with `..` and symbolic links resolved, and whether they are inside some directories. | +| `WP_Invariant::functions_calling( $directories, $callee )` and `WP_Invariant::cached()` | The functions whose own code calls a function, read from tokens, as targets; and a cache of such a result, kept while the source has not changed, for the processes of separate-process tests. | + +### Asking an oracle + +An oracle reads the same thing WordPress produced, independently. Compare with it rather than with WordPress's own functions: + +| | | +| --- | --- | +| `WP_Invariant_Browser::elements( $html )` | The elements a browser builds from HTML, as the contents of a BODY element, including those inside TEMPLATE elements. | +| `WP_Invariant_Browser::tree( $html )` | How a browser serializes HTML after parsing it: two pieces of HTML with the same tree give the same string. | +| `WP_Invariant_Browser::attribute_value( $value )` | The value a browser reads from `href="…"`, or null when the markup does not stay one attribute. | +| `WP_Invariant_Browser::url_scheme( $url )` | The scheme a browser gives a URL, or null when it is relative or cannot be parsed. | +| `WP_Invariant_Browser::url_host( $url, $base )` | The host a browser goes to for a URL, such as a Location header, resolved against a base. | +| `WP_Invariant_Browser::start_tag( $tag )` | The name and attribute values a browser reads from one start tag, parsed inside a TEMPLATE, where tags such as TD are kept. | +| `WP_Invariant_Browser::textarea_value( $html )` | The value a browser gives ``, or null when the markup does not stay one TEXTAREA. | +| `WP_Invariant_Browser::nodes( $html )` | The comments, text and elements a browser builds directly in a BODY element. | +| `WP_Invariant_Browser::xml_child_elements( $xml )` | How many elements XML content adds, or null when it is not well-formed. | +| `WP_Invariant_Browser::xml_well_formed( $xml )` | Whether an XML document is well-formed. | +| `WP_Invariant_HTML_API::markup( $html )` | The tags, comments and DOCTYPE declarations in HTML, counted by kind, as WordPress's HTML API reads them: not what only looks like markup inside a SCRIPT or TEXTAREA element, nor a tag the end of the HTML cuts off. | +| `WP_Invariant_HTML_API::boundaries( $html )` | Where tags, tag names, attributes, attribute values, comments and DOCTYPE declarations start in HTML, by byte offset, as the HTML API reads them. | +| `WP_Invariant_MySQL::parse_tree( $sql )` | The shape of a query's parse tree without its values, or null when MySQL would reject it. | +| `WP_Invariant_MySQL::table_names( $sql )` | The tables a query reads or changes, without aliases or common table expressions, or null when MySQL would reject it. | + +Most spec files use them. `WP_Invariant_HTML_API` uses WordPress's tag processor, which follows the tokenizer of the HTML standard and, unlike `Dom\HTMLDocument`, gives byte offsets. It is independent of the regular expressions and string functions it checks, such as `wpautop()`, but not of the HTML API, and it is only there once WordPress has loaded the HTML API: before that, it returns null. A check which compares WordPress with itself, such as `blocks/serialized-blocks-parse-back`, which parses what `serialize_blocks()` returns with WordPress's own block parser, is a round trip, not an oracle: it finds where the two disagree, but not a mistake they share. + +### Calls from tests + +By default, a violation is not reported when the target was called directly from test code, in `tests/`. Tests deliberately give public functions invalid input, and the question an invariant answers is whether WordPress itself breaks it. The check runs either way; only the report depends on who called. + +Invariants about what a function returns hold whoever calls it. Add `->include_test_callers()` to those, so that they report calls from tests too. + +### Checks must not change anything + +A check runs inside the code under test. If it changes anything, the suite does something different from what it would do without the invariants, and tests can fail for no reason of their own. Read what the hooks recorded, or ask an oracle, instead of calling WordPress again: + +- calling a function which runs a filter, such as `map_meta_cap()`, changes how many times the filter runs, and tests count that; +- a lookup such as `get_userdata()` can run a query, and tests count queries; +- `wp_cache_get()` changes the cache's statistics; +- `WP_HTML_Tag_Processor::has_class()` applies pending class changes; +- `wp_unslash()` changes the objects it is given; +- `wpdb::prepare()` can call `_doing_it_wrong()`, which fails tests that do not expect it; +- any PCRE function resets `preg_last_error()`. + +For example, `specs/capabilities.php` records what `map_meta_cap()` returned, by `$call->depth()`, so that the check on `WP_User::has_cap()` finds the capabilities that call mapped to, without mapping them again. + +### Hooks on built-in functions + +Once any built-in PHP function has a hook, the extension looks at every call to a built-in function, which slows down the whole suite. Keep invariants on built-in functions in their own spec files, such as `specs/pcre.php`, `specs/xml.php`, `specs/filesystem.php`, `specs/kses.php`, `specs/output.php`, `specs/secrets.php`, `specs/uploads.php`, `specs/archives.php`, `specs/serialize.php` and `specs/random.php`, so that `WP_INVARIANTS_SKIP` can leave them out. + +### Keep checks cheap + +A hook on a function which runs often, such as `get_post`, runs millions of times in the suite. Return early from a check when it does not apply. Calls a check makes itself are not hooked. + +### Mistakes + +- A spec file which fails to load is a fatal error in every PHP process, and the PHPUnit bootstrap refuses to run the suite. +- A check which throws is reported as a hook error, and the code under test carries on. +- A target which is never called, which is what a misspelled target looks like, is listed in the report under "Hooks which never ran". + +## Security invariants + +Some invariants follow what the request sent, what happened earlier in the request, or real secrets, which static analysis and unit tests miss: + +| Invariant | What it catches | +| --- | --- | +| `output/request-values-stay-text` | Reflected XSS: a request value which holds HTML syntax where WordPress printed it, as the HTML API reads the whole output, including the HTML in JSON responses. | +| `wpdb/request-values-stay-literals` | SQL injection without `wpdb::prepare()`: a request value outside a string literal in a query. It needs MySQL. | +| `http/validated-urls-stay-external` | SSRF: a URL `wp_http_validate_url()` accepts whose host, as a WHATWG URL parser reads it, differs from the one it checked, or is internal in any IPv4 or IPv6 form. | +| `secrets/never-sent` | Secret disclosure: a salt, password hash, reset or request key, session token, auth cookie or application password in a REST or JSON response, printed HTML, a `wp_die()` message, a header or the error log. | +| `request/writes-check-a-nonce` | CSRF: a function which changes state, run in an AJAX, admin-post or admin action handler before a nonce check passed there. | +| `request/writes-check-a-capability` | Missing authorization: the same, before a capability check passed, and REST callbacks which run as another user than their permission check. | +| `uploads/nothing-executable` | Malicious uploads: a file in the uploads directory whose name or contents could run as code, such as `photo.php.jpg`. | +| `archives/extract-inside-destination` | Zip slip: a write outside the directory `unzip_file()` or `copy_dir()` was given. | +| `serialize/no-foreign-objects` | PHP object injection: serialized objects of classes with magic methods which this run did not serialize, or request values, given to `unserialize()`. | +| `kses/stored-html-is-filtered` | Stored XSS: post, comment, term or user description HTML stored unfiltered while kses filters that field. | +| `random/secrets-use-csprng` | Predictable secrets: `mt_rand()` and the like inside functions which make secrets. | +| `sanitize/idempotent` | Sanitizers which change their own output, which lets a value pass a check made between two sanitizations. | +| `multisite/blog-restored` | Data written to the wrong site: a function which returns while still switched to another site. It runs only in multisite runs, with `WP_MULTISITE=1`. | +| `mail/no-header-injection` | Email header injection through addresses, subjects or headers with line breaks. | + +The canaries check that each of these is reported, except `http/validated-urls-stay-external`, `sanitize/idempotent` and `multisite/blog-restored`, which only WordPress itself could break. + +## Types report + +With `WP_INVARIANTS_TYPES=1`, as in CI, the run also counts the types of the arguments every WordPress function and method is given, and of the values it returns. The result is a second report, `invariants-types.md` and `invariants-types.json`, beside the invariant report, for adding type annotations and correcting them: + +``` +wp_kses() src/wp-includes/kses.php:979 + calls: 242 (154 core, 88 tests) + $content @param string + 242: string (154 core, 88 tests) + $allowed_html @param array[]|string + 92: false (92 core) + 37: 'post' (34 core, 3 tests) + ... +``` + +- **What it covers:** every named function and method declared in `src/wp-includes` and `src/wp-admin`, private or not. Private ones are marked, as are protected and private methods, magic methods, static methods, deprecated functions and generators. The bundled third-party libraries which `tests/phpstan/base.neon` lists are left out, as are closures and the methods of traits and interfaces. `WP_Invariant::functions_in()` finds them from the source, and functions which were never called are listed at the end. +- **How it counts them:** two catch-all hooks of the extension, with the target `*`, run before and after every call to a user-space function in those directories. Each type is counted separately for calls from WordPress and for calls made directly from tests, which deliberately pass odd types. +- **Documented types:** the native type, the PHPDoc `@param` or `@return` type, and the PHPStan `@phpstan-param` or `@phpstan-return` type are shown as written, next to what was counted. The report does not decide whether they agree; the JSON keeps them in separate fields, for a script to compare. +- **How types are written:** + - `true` and `false` are shown apart until both are seen, then as `bool`. + - Up to 4 distinct ints are shown as literals, such as `0` or `-1`. A fifth distinct int makes them all `int`. Floats are always `float`. + - Up to 10 distinct strings shorter than 32 bytes are shown as literals, such as `'post'`. Another distinct string, or a longer one, makes them all `string`. + - Once ints and strings are no longer literals, they are counted by their properties, from the first one seen, such as `int [zero]`, `string [html slash]` or `string [non-empty]`: + - `zero` and `non-zero` ints. + - `empty` strings, and `non-empty` ones, which is shown only when none of the other tags is, since each implies it. + - `numeric`: it is a numeric string, as `is_numeric()` reads it, such as `'42'`, `'-1.5'`, `'1e3'` or `' 7'`, but not `'0x1A'`. + - `html`: the Tag Processor finds a token in it other than text. A tag cut off by the end of the string is text. + - `ref`: it holds an HTML character reference which decodes in text, such as `&` or a legacy `&`. + - `url`: `Uri\WhatWg\Url::parse()` accepts it without a base, so it is an absolute URL. That includes `mailto:` and `a:b`, and Windows paths such as `C:\x`, whose drive letter is a scheme. + - `slash`: `wp_slash()` would change it. `unslash`: `wp_unslash()` would change it, which implies `slash`. + - `path`: it is probably a file path, but nothing is there. `file`: it is probably a file path, and a file or directory is there, at the time of the call. A path is an absolute one whose first segment is a directory at `/`, such as `/var/www/…`, so not `/wp-admin/`; a Windows path starting with a drive letter; or a relative one starting with `./` or `../`. It has no NUL, newline, `<` or `://`. + - `-utf8`: it is not valid UTF-8. Strings are assumed to be valid otherwise. + - Literals have no tags, and strings and ints inside arrays are not tagged. + - An array is `array{}` when empty, `list<…>` with the types of its first 16 elements, or a record such as `array{post_type: string, paged?: int}` of its first-level keys, where `?` marks a key some arrays did not have. Past 64 keys, it is `array`. + - A stdClass object is a record of its properties, like an array, and other objects are their class. + - `(not passed)` counts calls which left out an optional parameter, and `(extra)` counts arguments beyond the declared parameters of a function without a variadic one. +- **Separate processes:** tests which run in a separate process save what they counted when they finish, and the report adds it in. This needs the extension enabled in an ini file, as in CI. + +Return values can be counted fewer times than calls: none is counted for a call which throws, or for a generator, whose caller receives a Generator. The cost is about twice the run time of the groups measured locally. + +## Limits + +- Tests which run in a separate process run the hooks too, but what their invariants record is not reported. The types report does include what they count. +- The extension tracks at most 128 nested calls with after checks at once; deeper calls get no after checks. +- A call to an inaccessible or undefined method, which PHP routes to `__call()` or `__callStatic()`, is hooked as that method. A generator is hooked once, when it is called, and gets no after checks. +- The suite needs PHP 8.5 or newer, for the oracles. The extension does not support thread-safe builds or Windows. +- `WP_Invariant_MySQL` parses MySQL 8.4, as CI runs it. Syntax only MariaDB accepts would be reported as invalid. diff --git a/tests/invariants/bin/install-gameshark.sh b/tests/invariants/bin/install-gameshark.sh new file mode 100755 index 0000000000000..25ea98def1d34 --- /dev/null +++ b/tests/invariants/bin/install-gameshark.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# +# Builds the gameshark PHP extension and enables it for every PHP process in this container. +# +# Run it as root inside the PHP container of the local Docker environment, with `npm run env:gameshark`, or: +# +# docker compose exec -u 0 php bash /var/www/tests/invariants/bin/install-gameshark.sh +# +# It builds the extension from .invariants/gameshark, which must be a checkout of +# https://github.com/dmsnell/php-ext-gameshark-extension at the commit named in tests/invariants/DEPENDENCIES. +# Rust and the build are kept in .invariants/ so that later runs reuse them. +# +# The extension is only enabled until the container is recreated, as by `npm run env:restart`. +set -euo pipefail + +root="$( cd "$( dirname "${BASH_SOURCE[0]}" )/../../.." && pwd )" +source_dir="$root/.invariants/gameshark" +export CARGO_HOME="$root/.invariants/cargo" +export RUSTUP_HOME="$root/.invariants/rustup" +export PATH="$CARGO_HOME/bin:$PATH" + +if [[ ! -f "$source_dir/config.m4" ]]; then + echo "There is no gameshark source in $source_dir. Check it out with:" >&2 + echo " git clone https://github.com/dmsnell/php-ext-gameshark-extension.git .invariants/gameshark" >&2 + echo " git -C .invariants/gameshark checkout \"\$( sed -n 's/^gameshark=//p' tests/invariants/DEPENDENCIES )\"" >&2 + exit 1 +fi + +if ! command -v cargo > /dev/null; then + echo 'Installing Rust...' + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --no-modify-path +fi + +cd "$source_dir" +RUN_TESTS=0 PHP_CONFIG="$( command -v php-config )" scripts/build-unix.sh + +install -m 0644 modules/gameshark.so "$( php-config --extension-dir )/gameshark.so" +# The report lists the hooks on built-in functions, so the extension's warning about them is turned off. It has to be +# an INI setting: the extension's default for it is an INI value, which beats GAMESHARK_INVARIANTS_WARN_BUILTINS. +# PHP may use all the memory the container has: what the invariants and the types report keep grows with the suite, +# and WordPress only lifts the limit once it loads, after the invariants file and in no separate process before that. +printf 'extension=gameshark.so\ngameshark.invariants_warn_builtins=0\nmemory_limit=-1\n' > "${PHP_INI_DIR:-/usr/local/etc/php}/conf.d/zz-gameshark.ini" +php --ri gameshark +echo "PHP memory_limit: $( php -r 'echo ini_get( "memory_limit" );' )" +echo "Container memory limit: $( cat /sys/fs/cgroup/memory.max 2> /dev/null || cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2> /dev/null || echo unknown )" +echo "Memory: $( grep -E '^(MemTotal|MemAvailable)' /proc/meminfo 2> /dev/null | tr -s ' ' | paste -sd ',' - )" + +# Whoever runs the tests, and the CI cache, can read and replace what was built here as root. +chmod -R a+rwX "$root/.invariants" diff --git a/tests/invariants/bootstrap.php b/tests/invariants/bootstrap.php new file mode 100644 index 0000000000000..0f7e1d48d2aa6 --- /dev/null +++ b/tests/invariants/bootstrap.php @@ -0,0 +1,14 @@ +' . ( $escape ? esc_html( $search ) : $search ) . '

'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- The canary. +} + +/** + * Looks up posts by the title the request sent, escaped or not. + * + * @param bool $escape Whether to escape it. + */ +function wp_invariants_canary_query_title( $escape ) { + global $wpdb; + + $title = isset( $_GET['t'] ) ? wp_unslash( $_GET['t'] ) : ''; // phpcs:ignore WordPress.Security + $title = $escape ? esc_sql( $title ) : $title; + $wpdb->get_results( "SELECT ID FROM {$wpdb->posts} WHERE post_title = '{$title}'" ); // phpcs:ignore WordPress.DB +} + +/** + * Handles an AJAX request by changing an option, after checking a nonce and a capability or not. + * + * @param bool $check Whether to check them. + */ +function wp_invariants_canary_change_option( $check ) { + if ( $check && ( ! check_ajax_referer( 'canary', 'nonce', false ) || ! current_user_can( 'manage_options' ) ) ) { + return; + } + + update_option( 'wp_invariants_canary', 'changed' ); +} + +/** + * Writes a file into the uploads directory. + * + * @param string $name File name. + * @param string $contents Contents. + * @return string The path written. + */ +function wp_invariants_canary_write_upload( $name, $contents ) { + $path = wp_upload_dir()['basedir'] . '/' . $name; + file_put_contents( $path, $contents ); // phpcs:ignore WordPress.WP.AlternativeFunctions + return $path; +} + +/** + * Writes a file while an archive is extracted, as a filter of pre_unzip_file can. + * + * @param string $path Path to write. + * @return true To stop the extraction there. + */ +function wp_invariants_canary_write_during_unzip( $path ) { + file_put_contents( $path, 'x' ); // phpcs:ignore WordPress.WP.AlternativeFunctions + return true; +} + +/** + * Unserializes data. + * + * @param string $data Serialized data. + * @return mixed + */ +function wp_invariants_canary_unserialize( $data ) { + return unserialize( $data ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions -- The canary. +} + +/** + * Stores post content straight into the database, past the kses filters. + * + * @param string $content Post content. + */ +function wp_invariants_canary_store_content( $content ) { + global $wpdb; + + $wpdb->insert( + $wpdb->posts, + array( + 'post_content' => $content, + 'post_title' => 'canary', + 'post_type' => 'post', + ) + ); +} + +/** + * Picks a random number, as a filter of random_password could. + * + * @param bool $secure Whether to use a secure generator. + * @param string $password The password, which is returned as it is. + * @return string + */ +function wp_invariants_canary_random( $secure, $password ) { + $secure ? random_int( 0, 9 ) : mt_rand( 0, 9 ); // phpcs:ignore WordPress.WP.AlternativeFunctions + return $password; +} + +/** + * Sends an email with a subject. + * + * @param string $subject Subject. + */ +function wp_invariants_canary_mail( $subject ) { + wp_mail( 'someone@example.org', $subject, 'Hello.' ); +} + +/** + * Returns a user's password hash in a REST response, or not. + * + * @param bool $leak Whether to leak it. + * @return array + */ +function wp_invariants_canary_rest_user( $leak ) { + $user = get_userdata( (int) get_option( 'wp_invariants_canary_user' ) ); + return $leak ? array( 'hash' => $user->user_pass ) : array( 'id' => $user->ID ); +} + +/** + * Changes an option from a public REST route. + * + * @return array + */ +function wp_invariants_canary_rest_change() { + update_option( 'wp_invariants_canary', 'changed' ); + return array( 'changed' => true ); +} + +/** + * Filters markup with kses after escaping it, or filters text which arrived with escaped markup in it. + * + * @param bool $escape Whether to escape markup first, which turns it into text. + * @return string + */ +function wp_invariants_canary_kses_escaped( $escape ) { + $content = $escape ? esc_html( '

Hello

' ) : 'Use <p> for paragraphs.'; + return wp_kses( $content, 'post' ); +} + +/** + * Matches a tag in HTML with a regular expression. + * + * @return int + */ +function wp_invariants_canary_regex_html() { + return preg_match( '~

~', '

Hello

', $matches ); +} diff --git a/tests/invariants/canaries/fixtures/class-wp-invariants-canary-gadget.php b/tests/invariants/canaries/fixtures/class-wp-invariants-canary-gadget.php new file mode 100644 index 0000000000000..879fd9817f300 --- /dev/null +++ b/tests/invariants/canaries/fixtures/class-wp-invariants-canary-gadget.php @@ -0,0 +1,18 @@ + + + + + + tests + + + + + + + + + invariants-canaries + + + + diff --git a/tests/invariants/canaries/tests/test-canaries.php b/tests/invariants/canaries/tests/test-canaries.php new file mode 100644 index 0000000000000..8b745790a8967 --- /dev/null +++ b/tests/invariants/canaries/tests/test-canaries.php @@ -0,0 +1,295 @@ +markTestSkipped( 'The canaries need WP_INVARIANTS_CANARIES=1, so that their fixtures count as code under test.' ); + } + } + + public function tear_down() { + $_GET = array(); + $_REQUEST = array(); + parent::tear_down(); + } + + /** + * Asserts how often an invariant was violated in this test. + * + * @param string $id Invariant id, such as `output/request-values-stay-text`. + * @param bool $violated Whether it must have been violated. + */ + private function assertViolated( $id, $violated = true ) { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid -- Named as PHPUnit's assertions are. + if ( ! isset( WP_Invariant::all()[ $id ] ) ) { + $this->markTestSkipped( "{$id} is not loaded, as with WP_INVARIANTS_SKIP." ); + } + + $violations = WP_Invariant_Violations::in_test( $id ); + if ( $violated ) { + $this->assertNotEmpty( $violations, "{$id} was not reported." ); + } else { + $this->assertSame( array(), array_column( $violations, 'message' ), "{$id} was reported." ); + } + } + + /** + * @dataProvider data_safely + * + * @param bool $safely Whether to do it safely. + */ + public function test_output_request_values_stay_text( $safely ) { + $_GET['s'] = ''; + ob_start(); + wp_invariants_canary_print_search( $safely ); + ob_get_clean(); + + $this->assertViolated( 'output/request-values-stay-text', ! $safely ); + } + + /** + * @dataProvider data_safely + * + * @param bool $safely Whether to do it safely. + */ + public function test_wpdb_request_values_stay_literals( $safely ) { + if ( ! method_exists( $GLOBALS['wpdb'], '_do_query' ) || defined( 'DB_ENGINE' ) && 'sqlite' === DB_ENGINE ) { + $this->markTestSkipped( 'This invariant needs MySQL.' ); + } + + $_GET['t'] = "x' OR '1'='1"; + wp_invariants_canary_query_title( $safely ); + + $this->assertViolated( 'wpdb/request-values-stay-literals', ! $safely ); + } + + /** + * @dataProvider data_safely + * + * @param bool $safely Whether to do it safely. + */ + public function test_request_writes_check_a_nonce_and_a_capability( $safely ) { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + $_REQUEST['nonce'] = wp_create_nonce( 'canary' ); + add_action( + 'wp_ajax_wp_invariants_canary', + static function () use ( $safely ) { + wp_invariants_canary_change_option( $safely ); + } + ); + + do_action( 'wp_ajax_wp_invariants_canary' ); + + $this->assertViolated( 'request/writes-check-a-nonce', ! $safely ); + $this->assertViolated( 'request/writes-check-a-capability', ! $safely ); + } + + public function test_request_rest_writes_check_a_capability() { + $this->add_route( + '/change', + array( + 'methods' => 'POST', + 'callback' => 'wp_invariants_canary_rest_change', + 'permission_callback' => '__return_true', + ) + ); + + rest_get_server()->dispatch( new WP_REST_Request( 'POST', '/wp-invariants-canary/v1/change' ) ); + + $this->assertViolated( 'request/writes-check-a-capability' ); + } + + /** + * @dataProvider data_safely + * + * @param bool $safely Whether to do it safely. + */ + public function test_uploads_nothing_executable( $safely ) { + $path = wp_invariants_canary_write_upload( $safely ? 'canary.jpg' : 'canary.php.jpg', 'GIF89a' ); + unlink( $path ); + + $this->assertViolated( 'uploads/nothing-executable', ! $safely ); + } + + /** + * @dataProvider data_safely + * + * @param bool $safely Whether to do it safely. + */ + public function test_archives_extract_inside_destination( $safely ) { + require_once ABSPATH . 'wp-admin/includes/file.php'; + WP_Filesystem(); + + $to = get_temp_dir() . uniqid( 'wp-invariants-canary-' ) . '/'; + $path = $safely ? "{$to}inside.txt" : get_temp_dir() . uniqid( 'wp-invariants-canary-outside-' ) . '.txt'; + mkdir( $to ); + add_filter( + 'pre_unzip_file', + static function () use ( $path ) { + return wp_invariants_canary_write_during_unzip( $path ); + } + ); + + unzip_file( DIR_TESTDATA . '/filesystem/archive-comment.zip', $to ); + unlink( $path ); + $GLOBALS['wp_filesystem']->delete( $to, true ); + + $this->assertViolated( 'archives/extract-inside-destination', ! $safely ); + } + + /** + * @dataProvider data_safely + * + * @param bool $safely Whether to do it safely. + */ + public function test_serialize_no_foreign_objects( $safely ) { + $data = $safely ? serialize( new WP_Invariants_Canary_Gadget() ) : 'O:27:"WP_Invariants_Canary_Gadget":0:{}'; // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions + wp_invariants_canary_unserialize( $data ); + + $this->assertViolated( 'serialize/no-foreign-objects', ! $safely ); + } + + /** + * @dataProvider data_safely + * + * @param bool $safely Whether to do it safely. + */ + public function test_kses_stored_html_is_filtered( $safely ) { + wp_set_current_user( 0 ); + kses_init_filters(); + + $content = '

Hello

'; + if ( $safely ) { + wp_insert_post( + array( + 'post_content' => $content, + 'post_title' => 'canary', + ) + ); + } else { + wp_invariants_canary_store_content( $content ); + } + + $this->assertViolated( 'kses/stored-html-is-filtered', ! $safely ); + } + + /** + * @dataProvider data_safely + * + * @param bool $safely Whether to do it safely. + */ + public function test_random_secrets_use_csprng( $safely ) { + add_filter( + 'random_password', + static function ( $password ) use ( $safely ) { + return wp_invariants_canary_random( $safely, $password ); + } + ); + + wp_generate_password(); + + $this->assertViolated( 'random/secrets-use-csprng', ! $safely ); + } + + /** + * @dataProvider data_safely + * + * @param bool $safely Whether to do it safely. + */ + public function test_mail_no_header_injection( $safely ) { + reset_phpmailer_instance(); + wp_invariants_canary_mail( $safely ? 'Hello' : "Hello\r\nBcc: victim@example.org" ); + + $this->assertViolated( 'mail/no-header-injection', ! $safely ); + } + + /** + * @dataProvider data_safely + * + * @param bool $safely Whether to do it safely. + */ + public function test_secrets_never_sent( $safely ) { + update_option( 'wp_invariants_canary_user', self::factory()->user->create() ); + $this->add_route( + '/user', + array( + 'methods' => 'GET', + 'callback' => static function () use ( $safely ) { + return wp_invariants_canary_rest_user( ! $safely ); + }, + 'permission_callback' => '__return_true', + ) + ); + + rest_get_server()->dispatch( new WP_REST_Request( 'GET', '/wp-invariants-canary/v1/user' ) ); + + $this->assertViolated( 'secrets/never-sent', ! $safely ); + } + + /** + * @dataProvider data_safely + * + * @param bool $safely Whether to do it safely. + */ + public function test_kses_input_not_escaped( $safely ) { + wp_invariants_canary_kses_escaped( ! $safely ); + + $this->assertViolated( 'kses/input-not-escaped', ! $safely ); + } + + public function test_pcre_matches_no_html_syntax() { + wp_invariants_canary_regex_html(); + + $this->assertViolated( 'pcre/matches-no-html-syntax' ); + } + + public function test_pcre_matches_no_html_syntax_leaves_out_known_parsers() { + wpautop( "

Hello

\n\n
World
" ); + wptexturize( '

"Quoted" text

' ); + wp_html_split( '

Hello

' ); + + $this->assertViolated( 'pcre/matches-no-html-syntax', false ); + } + + /** + * Registers a REST route of the canaries, as REST routes are registered, on rest_api_init. + * + * @param string $route Route, such as `/user`. + * @param array $args Its arguments. + */ + private function add_route( $route, array $args ) { + add_action( + 'rest_api_init', + static function () use ( $route, $args ) { + register_rest_route( 'wp-invariants-canary/v1', $route, $args ); + } + ); + $GLOBALS['wp_rest_server'] = null; + } + + /** + * @return array[] + */ + public static function data_safely() { + return array( + 'unsafely' => array( false ), + 'safely' => array( true ), + ); + } +} diff --git a/tests/invariants/gameshark.php b/tests/invariants/gameshark.php new file mode 100644 index 0000000000000..440c31dbe44f7 --- /dev/null +++ b/tests/invariants/gameshark.php @@ -0,0 +1,83 @@ + array( "{$root}/tests/phpunit", __DIR__ . '/canaries/tests', __DIR__ . '/includes' ), + 'core' => array( "{$root}/src", "{$root}/build", __DIR__ . '/canaries/fixtures' ), + ) + ); + unset( $root ); +} + +$specs = WP_Invariant::load_specs( __DIR__ . '/specs' ); + +if ( getenv( 'WP_INVARIANTS_TYPES' ) ) { + $root = dirname( __DIR__, 2 ); + + // Bundled third-party libraries, which are not WordPress functions: those listed as such in tests/phpstan/base.neon. + $bundled = array( + 'src/wp-admin/includes/class-ftp-pure.php', + 'src/wp-admin/includes/class-ftp-sockets.php', + 'src/wp-admin/includes/class-ftp.php', + 'src/wp-admin/includes/class-pclzip.php', + 'src/wp-includes/ID3', + 'src/wp-includes/IXR', + 'src/wp-includes/PHPMailer', + 'src/wp-includes/Requests', + 'src/wp-includes/SimplePie', + 'src/wp-includes/Text', + 'src/wp-includes/atomlib.php', + 'src/wp-includes/class-IXR.php', + 'src/wp-includes/class-avif-info.php', + 'src/wp-includes/class-json.php', + 'src/wp-includes/class-phpass.php', + 'src/wp-includes/class-pop3.php', + 'src/wp-includes/class-requests.php', + 'src/wp-includes/class-simplepie.php', + 'src/wp-includes/class-snoopy.php', + 'src/wp-includes/php-ai-client', + 'src/wp-includes/pomo', + 'src/wp-includes/rss.php', + 'src/wp-includes/sodium_compat', + ); + + $directories = array( "{$root}/src/wp-includes", "{$root}/src/wp-admin" ); + $targets = WP_Invariant_Types::targets( + $directories, + array_map( + static function ( $path ) use ( $root ) { + return "{$root}/{$path}"; + }, + $bundled + ), + sys_get_temp_dir() . '/wp-invariants-types-' . md5( $root ) . '.cache' + ); + $specs = array_merge( $specs, WP_Invariant_Types::specs( $targets, $directories, array( "{$root}/tests" ) ) ); + register_shutdown_function( array( 'WP_Invariant_Types', 'save_for_parent' ) ); + + unset( $root, $bundled, $directories, $targets ); +} + +return $specs; diff --git a/tests/invariants/includes/class-wp-invariant-call.php b/tests/invariants/includes/class-wp-invariant-call.php new file mode 100644 index 0000000000000..2317827a59d5b --- /dev/null +++ b/tests/invariants/includes/class-wp-invariant-call.php @@ -0,0 +1,437 @@ + list of parameters. + * + * @var array[] + */ + private static $parameters = array(); + + /** + * Whether each lower-case method target is static. + * + * @var bool[] + */ + private static $static_methods = array(); + + /** + * The invariant whose check this is. + * + * @var WP_Invariant + */ + private $invariant; + + /** + * `pre` or `post`. + * + * @var string + */ + private $when; + + /** + * Function or method, as the invariant names it. + * + * @var string + */ + private $target; + + /** + * The object for an instance method, or null. + * + * @var object|null + */ + private $self = null; + + /** + * Arguments, in order. + * + * @var array + */ + private $args; + + /** + * What the call returned, in an after check. + * + * @var mixed + */ + private $return_value = null; + + /** + * What the paired before check returned. + * + * @var mixed + */ + private $before = null; + + /** + * Whether a paired before check ran for this call. + * + * @var bool + */ + private $has_before = false; + + /** + * Frames of the call stack, from the target's caller outwards, found when first needed. + * + * @var array[]|null + */ + private $frames = null; + + /** + * Reads what the gameshark extension passed to a hook. + * + * @param WP_Invariant $invariant The invariant whose check this is. + * @param string $when `pre` or `post`. + * @param string $target Function or method. + * @param array $params What the extension passed to the hook. + */ + public function __construct( WP_Invariant $invariant, $when, $target, array $params ) { + $this->invariant = $invariant; + $this->when = $when; + $this->target = $target; + + $instance = false !== strpos( $target, '::' ) && ! self::is_static_method( $target ); + if ( $instance ) { + $this->self = array_shift( $params ); + } + + if ( 'post' === $when ) { + $this->return_value = $params[0]; + $this->args = $params[1]; + } else { + $this->args = $params; + } + } + + /** + * Returns the function or method, as the invariant names it, such as `wpdb::prepare`. + * + * @return string + */ + public function target() { + return $this->target; + } + + /** + * Returns `before` or `after`. + * + * @return string + */ + public function when() { + return 'pre' === $this->when ? 'before' : 'after'; + } + + /** + * Returns the object an instance method was called on, or null. + * + * @return object|null + */ + public function self() { + return $this->self; + } + + /** + * Returns the arguments in order, as they were when the call started. + * + * @return array + */ + public function args() { + return $this->args; + } + + /** + * Returns an argument by its parameter name, or the parameter's default when it was not passed. + * + * A variadic parameter gives the list of arguments it collected. + * + * @param string $name Parameter name, without `$`. + * @return mixed + */ + public function arg( $name ) { + foreach ( self::parameters( $this->target ) as $position => $parameter ) { + if ( $parameter['name'] !== $name ) { + continue; + } + + if ( $parameter['variadic'] ) { + return array_slice( $this->args, $position ); + } + if ( array_key_exists( $position, $this->args ) ) { + return $this->args[ $position ]; + } + return $parameter['default']; + } + + throw new InvalidArgumentException( sprintf( '%s() has no parameter named $%s.', $this->target, $name ) ); + } + + /** + * Returns what the call returned. Only after checks have one. + * + * @return mixed + */ + public function return_value() { + if ( 'post' !== $this->when ) { + throw new LogicException( 'A before check has no return value yet; use an after check.' ); + } + + return $this->return_value; + } + + /** + * Returns what the paired before check returned for this same call. + * + * @return mixed + */ + public function before() { + if ( ! $this->has_before ) { + throw new LogicException( sprintf( 'This invariant has no before check on %s paired with this after check.', $this->target ) ); + } + + return $this->before; + } + + /** + * Returns where the target was called from. + * + * @return array { + * @type string|null $file Path of the calling file, or null when PHP called the target itself. + * @type int|null $line Line in that file. + * } + */ + public function caller() { + $frames = $this->frames(); + return array( + 'file' => isset( $frames[0]['file'] ) ? $frames[0]['file'] : null, + 'line' => isset( $frames[0]['line'] ) ? $frames[0]['line'] : null, + ); + } + + /** + * Returns the function or method whose own code made the call. + * + * @return string|null As `name` or `Class::name`, or null when the call was made outside any function. + */ + public function caller_function() { + $frames = $this->frames(); + return isset( $frames[1] ) ? self::frame_function( $frames[1] ) : null; + } + + /** + * Returns how deep the call is: the number of frames from the target's own frame outward. + * + * A call the target made is one deeper. Invariants use this to tell which call, + * recorded in a table, a target made itself. It does not depend on how deep the + * invariant's own code is. + * + * @return int + */ + public function depth() { + return count( $this->frames() ); + } + + /** + * Returns the frames of the call stack in the code under test, innermost first. + * + * @param int $limit Most frames to return. + * @return string[] Each one as `function() at file:line`, the function holding that line, with the file relative to the repository. + */ + public function stack( $limit = 8 ) { + $stack = array(); + $frames = $this->frames(); + foreach ( $frames as $index => $frame ) { + if ( ! isset( $frame['file'] ) || ! WP_Invariant::is_core_file( $frame['file'] ) ) { + continue; + } + + // A frame's file and line are where its function was called from, which is in the next frame's function. + $function = isset( $frames[ $index + 1 ] ) ? self::frame_function( $frames[ $index + 1 ] ) . '()' : 'main'; + $stack[] = sprintf( '%s at %s:%d', $function, WP_Invariant::relative_path( $frame['file'] ), $frame['line'] ); + if ( count( $stack ) >= $limit ) { + break; + } + } + + return $stack; + } + + /** + * Records a violation of the invariant when a condition is false. + * + * @param bool $condition What must be true. + * @param string $format Message, with `%s` and similar for the values. + * @param mixed ...$values Values for the message. Values which are not scalars are shown as short PHP. + * @return bool The condition. + */ + public function expect( $condition, $format, ...$values ) { + if ( ! $condition ) { + WP_Invariant_Violations::record( $this->invariant, vsprintf( $format, array_map( array( __CLASS__, 'describe_value' ), $values ) ), $this ); + } + + return (bool) $condition; + } + + /** + * Gives an after check what the paired before check returned. + * + * @internal + * + * @param mixed $value What the before check returned. + */ + public function set_before( $value ) { + $this->before = $value; + $this->has_before = true; + } + + /** + * Shows a value briefly in a message. + * + * @param mixed $value Any value. + * @return string|int|float + */ + public static function describe_value( $value ) { + if ( is_string( $value ) ) { + return strlen( $value ) > 200 ? substr( $value, 0, 200 ) . '…' : $value; + } + if ( is_int( $value ) || is_float( $value ) ) { + return $value; + } + if ( is_object( $value ) ) { + return get_class( $value ) . ( isset( $value->ID ) ? '#' . $value->ID : '' ); + } + + $text = var_export( $value, true ); + return strlen( $text ) > 200 ? substr( $text, 0, 200 ) . '…' : $text; + } + + /** + * Returns the frames from the target's caller outwards. + * + * @return array[] + */ + private function frames() { + if ( null !== $this->frames ) { + return $this->frames; + } + + $frames = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS ); + $index = $this->target_frame( $frames ); + $this->frames = null === $index ? array() : array_slice( $frames, $index ); + return $this->frames; + } + + /** + * Returns the arguments the target's caller was given, as they are now: its parameters may have changed since. + * + * This takes a backtrace with arguments, so only call it when a check has found something to report. + * + * @return array Empty when the target was called from outside a function. + */ + public function caller_args() { + $frames = debug_backtrace(); + $index = $this->target_frame( $frames ); + return null !== $index && isset( $frames[ $index + 1 ]['args'] ) ? $frames[ $index + 1 ]['args'] : array(); + } + + /** + * Finds the target's own frame in a backtrace: it holds the file and line the target was called from. + * + * @param array[] $frames Frames from debug_backtrace(). + * @return int|null + */ + private function target_frame( array $frames ) { + $parts = explode( '::', $this->target ); + $method = array_pop( $parts ); + $class = array_pop( $parts ); + + foreach ( $frames as $index => $frame ) { + if ( ! isset( $frame['function'] ) || 0 !== strcasecmp( $frame['function'], $method ) ) { + continue; + } + if ( null !== $class && ( ! isset( $frame['class'] ) || ( 0 !== strcasecmp( $frame['class'], $class ) && ! is_subclass_of( $frame['class'], $class ) ) ) ) { + continue; + } + + return $index; + } + + return null; + } + + /** + * Names the function a frame is in. + * + * @param array $frame A frame from debug_backtrace(). + * @return string + */ + private static function frame_function( array $frame ) { + return ( isset( $frame['class'] ) ? $frame['class'] . '::' : '' ) . $frame['function']; + } + + /** + * Whether a method target is static, found by reflection the first time it is called. + * + * @param string $target `Class::method`. + * @return bool + */ + private static function is_static_method( $target ) { + $key = strtolower( $target ); + if ( ! isset( self::$static_methods[ $key ] ) ) { + list( $class, $method ) = explode( '::', $target ); + self::$static_methods[ $key ] = ( new ReflectionMethod( $class, $method ) )->isStatic(); + } + + return self::$static_methods[ $key ]; + } + + /** + * Returns a target's parameters, found by reflection the first time they are needed. + * + * @param string $target Function or method. + * @return array[] Each with `name`, `variadic` and `default`. + */ + private static function parameters( $target ) { + $key = strtolower( $target ); + if ( isset( self::$parameters[ $key ] ) ) { + return self::$parameters[ $key ]; + } + + if ( false !== strpos( $target, '::' ) ) { + list( $class, $method ) = explode( '::', $target ); + $reflection = new ReflectionMethod( $class, $method ); + } else { + $reflection = new ReflectionFunction( $target ); + } + $parameters = array(); + foreach ( $reflection->getParameters() as $parameter ) { + $parameters[] = array( + 'name' => $parameter->getName(), + 'variadic' => $parameter->isVariadic(), + 'default' => $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null, + ); + } + + self::$parameters[ $key ] = $parameters; + return $parameters; + } +} diff --git a/tests/invariants/includes/class-wp-invariant-phpunit-extension.php b/tests/invariants/includes/class-wp-invariant-phpunit-extension.php new file mode 100644 index 0000000000000..54a844d259bec --- /dev/null +++ b/tests/invariants/includes/class-wp-invariant-phpunit-extension.php @@ -0,0 +1,165 @@ + + * + * invariants + * + * + * + * The report is written to tests/phpunit/build/logs/, or to the directory in the + * environment variable WP_INVARIANTS_REPORT_DIR. The environment variable + * WP_INVARIANTS_REPORT_NAME changes its base name, for a second run, such as of + * the AJAX tests. + * + * Running with invariants is several times slower than without, so it also + * reports how far the run has got: every 1,000 tests, and every test which takes + * longer than 30 seconds, on STDERR. The test which is running is kept in + * `-current-test.txt`, so that a run which is stopped can tell where. + */ +final class WP_Invariant_PHPUnit_Extension implements BeforeTestHook, AfterTestHook, AfterLastTestHook { + + /** + * Tests which take longer than this many seconds are reported. + */ + const SLOW_SECONDS = 30; + + /** + * Progress is reported after every this many tests. + */ + const PROGRESS_EVERY = 1000; + + /** + * Base name of the report files. + * + * @var string + */ + private $report_name; + + /** + * Directory for the report files. + * + * @var string + */ + private $directory; + + /** + * When the first test started, from hrtime(). + * + * @var int|null + */ + private $started_at = null; + + /** + * Tests which have finished. + * + * @var int + */ + private $finished = 0; + + /** + * Constructor. + * + * @param string $report_name Base name of the report files, unless WP_INVARIANTS_REPORT_NAME is set. + */ + public function __construct( $report_name = 'invariants' ) { + $name = getenv( 'WP_INVARIANTS_REPORT_NAME' ); + $this->report_name = $name ? $name : $report_name; + $this->directory = getenv( 'WP_INVARIANTS_REPORT_DIR' ); + if ( ! $this->directory ) { + $this->directory = dirname( __DIR__, 2 ) . '/phpunit/build/logs'; + } + + if ( array() !== WP_Invariant_Types::hooked() ) { + WP_Invariant_Types::collect_from_processes( $this->processes_directory() ); + } + } + + /** + * Starts a test. + * + * @param string $test Name of the test. + */ + public function executeBeforeTest( string $test ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid -- PHPUnit interface. + if ( null === $this->started_at ) { + $this->started_at = hrtime( true ); + if ( ! is_dir( $this->directory ) ) { + mkdir( $this->directory, 0777, true ); + } + } + + file_put_contents( "{$this->directory}/{$this->report_name}-current-test.txt", sprintf( "%s after %s: %s\n", gmdate( 'H:i:s' ), $this->elapsed(), $test ) ); + WP_Invariant::start_test( $test ); + } + + /** + * Reports slow tests, and progress. + * + * @param string $test Name of the test. + * @param float $time How long it took, in seconds. + */ + public function executeAfterTest( string $test, float $time ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid -- PHPUnit interface. + ++$this->finished; + if ( $time >= self::SLOW_SECONDS ) { + fwrite( STDERR, sprintf( "\n[invariants] Slow test, %.0f s: %s\n", $time, $test ) ); + } + if ( 0 === $this->finished % self::PROGRESS_EVERY ) { + fwrite( STDERR, sprintf( "\n[invariants] %s tests after %s\n", number_format( $this->finished ), $this->elapsed() ) ); + } + } + + /** + * Writes the report. + */ + public function executeAfterLastTest(): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid -- PHPUnit interface. + WP_Invariant::start_test( '(after the last test)' ); + + WP_Invariant_Report::write( $this->directory, $this->report_name ); + @unlink( "{$this->directory}/{$this->report_name}-current-test.txt" ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- It may not exist. + fwrite( STDOUT, sprintf( "\nInvariant report, after %s tests in %s: %s/%s.md\n", number_format( $this->finished ), $this->elapsed(), $this->directory, $this->report_name ) ); + + if ( array() !== WP_Invariant_Types::hooked() ) { + $processes = glob( $this->processes_directory() . '/*.serialized' ); + WP_Invariant_Types_Report::write( $this->directory, "{$this->report_name}-types", WP_Invariant_Types::hooked(), WP_Invariant_Types::profile(), $processes ? $processes : array() ); + // They exist only when a separate process saved what it collected, and the parent only while no other run uses it. + @rmdir( $this->processes_directory() ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + @rmdir( dirname( $this->processes_directory() ) ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + fwrite( STDOUT, sprintf( "Types report: %s/%s-types.md\n", $this->directory, $this->report_name ) ); + } + } + + /** + * Returns the directory where tests in separate processes leave the types they collected. + * + * It is this run's own, so that what an earlier run which was stopped left is not merged. + * + * @return string + */ + private function processes_directory() { + return "{$this->directory}/{$this->report_name}-types-processes/" . getmypid(); + } + + /** + * Returns how long the run has taken, such as `12m 05s`. + * + * @return string + */ + private function elapsed() { + $seconds = null === $this->started_at ? 0 : intdiv( hrtime( true ) - $this->started_at, 1000000000 ); + return sprintf( '%dm %02ds', intdiv( $seconds, 60 ), $seconds % 60 ); + } +} diff --git a/tests/invariants/includes/class-wp-invariant-report.php b/tests/invariants/includes/class-wp-invariant-report.php new file mode 100644 index 0000000000000..2badbed070661 --- /dev/null +++ b/tests/invariants/includes/class-wp-invariant-report.php @@ -0,0 +1,184 @@ +.json` and `.md` into a directory. + * + * @param string $directory Directory for the reports. + * @param string $name Base name of the report files. + * @return string The Markdown report. + */ + public static function write( $directory, $name ) { + if ( ! is_dir( $directory ) && ! mkdir( $directory, 0777, true ) && ! is_dir( $directory ) ) { + throw new RuntimeException( sprintf( 'Cannot create the report directory %s.', $directory ) ); + } + + $data = self::data(); + $markdown = self::markdown( $data ); + file_put_contents( "{$directory}/{$name}.json", json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE ) . "\n" ); + file_put_contents( "{$directory}/{$name}.md", $markdown ); + + return $markdown; + } + + /** + * Gathers the invariants, what they found, and the extension's status. + * + * @return array + */ + private static function data() { + $status = function_exists( 'gameshark_invariants_status' ) ? gameshark_invariants_status() : array(); + $executions = array(); + foreach ( isset( $status['specs'] ) ? $status['specs'] : array() as $spec ) { + $executions[ $spec['id'] ] = $spec; + } + + $invariants = array(); + $recorded = WP_Invariant_Violations::all(); + foreach ( WP_Invariant::all() as $id => $invariant ) { + $hooks = array(); + foreach ( $invariant->hooks() as $index => $hook ) { + $spec = isset( $executions[ "{$id}#{$index}" ] ) ? $executions[ "{$id}#{$index}" ] : array(); + $hooks[] = array( + 'when' => 'pre' === $hook['when'] ? 'before' : 'after', + 'target' => $hook['target'], + 'matched' => ! empty( $spec['matched'] ), + 'executions' => isset( $spec['executions'] ) ? $spec['executions'] : 0, + ); + } + + $found = isset( $recorded[ $id ] ) ? $recorded[ $id ] : null; + $invariants[ $id ] = array( + 'description' => $invariant->description(), + 'hooks' => $hooks, + 'violations' => $found ? $found['violations'] : 0, + 'hook_errors' => $found ? $found['hook_errors'] : 0, + 'tests' => $found ? $found['tests'] : array(), + 'examples' => $found ? $found['examples'] : array(), + ); + } + + return array( + 'invariants' => $invariants, + 'extension' => array_diff_key( $status, array( 'specs' => true ) ), + ); + } + + /** + * Formats the report as Markdown. + * + * @param array $data What data() gathered. + * @return string + */ + private static function markdown( array $data ) { + $violated = array(); + $never_ran = array(); + $violations = 0; + $tests = array(); + foreach ( $data['invariants'] as $id => $invariant ) { + if ( $invariant['violations'] + $invariant['hook_errors'] > 0 ) { + $violated[ $id ] = $invariant; + $violations += $invariant['violations'] + $invariant['hook_errors']; + $tests += $invariant['tests']; + } + foreach ( $invariant['hooks'] as $hook ) { + if ( ! $hook['matched'] ) { + $never_ran[] = sprintf( '- `%s`: %s `%s` was never called.', $id, $hook['when'], $hook['target'] ); + } + } + } + uasort( + $violated, + static function ( $a, $b ) { + return ( $b['violations'] + $b['hook_errors'] ) <=> ( $a['violations'] + $a['hook_errors'] ); + } + ); + + $out = "## Invariants\n\n"; + $out .= sprintf( + "%s of %d invariants were violated, %s times in %s tests. This report does not fail the job.\n\n", + count( $violated ), + count( $data['invariants'] ), + number_format( $violations ), + number_format( count( $tests ) ) + ); + + if ( array() !== $violated ) { + $out .= "| Invariant | Violations | Hook errors | Tests | First example |\n| --- | ---: | ---: | ---: | --- |\n"; + foreach ( $violated as $id => $invariant ) { + $first = $invariant['examples'][0]; + $out .= sprintf( + "| `%s` | %s | %s | %s | %s
in `%s` |\n", + $id, + number_format( $invariant['violations'] ), + number_format( $invariant['hook_errors'] ), + number_format( count( $invariant['tests'] ) ), + self::cell( $first['message'] ), + self::cell( $first['test'] ) + ); + } + + $out .= "\n
Examples\n\n"; + foreach ( $violated as $id => $invariant ) { + $out .= sprintf( "### `%s`\n\n%s\n\n", $id, $invariant['description'] ); + foreach ( array_slice( $invariant['examples'], 0, self::SHOWN_EXAMPLES ) as $example ) { + $out .= sprintf( "- %s%s
%s `%s`, in `%s`\n", 'hook-error' === $example['kind'] ? '**Hook error:** ' : '', self::one_line( $example['message'] ), ucfirst( $example['when'] ), $example['target'], $example['test'] ); + foreach ( $example['stack'] as $frame ) { + $out .= " - `{$frame}`\n"; + } + } + $out .= "\n"; + } + $out .= "
\n\n"; + } + + if ( array() !== $never_ran ) { + $out .= "### Hooks which never ran\n\nTheir target was never called while the suite ran. A misspelled target looks like this.\n\n" . implode( "\n", $never_ran ) . "\n\n"; + } + + $out .= "
How many times each hook ran\n\n| Invariant | Hook | Runs |\n| --- | --- | ---: |\n"; + foreach ( $data['invariants'] as $id => $invariant ) { + foreach ( $invariant['hooks'] as $hook ) { + $out .= sprintf( "| `%s` | %s `%s` | %s |\n", $id, $hook['when'], $hook['target'], number_format( $hook['executions'] ) ); + } + } + $out .= "\n
\n"; + + return $out; + } + + /** + * Makes text safe for a Markdown table cell. + * + * @param string $text Text. + * @return string + */ + private static function cell( $text ) { + return str_replace( '|', '\\|', self::one_line( $text ) ); + } + + /** + * Puts text on one line. + * + * @param string $text Text. + * @return string + */ + private static function one_line( $text ) { + return preg_replace( '/\s*[\r\n]+\s*/', ' ', $text ); + } +} diff --git a/tests/invariants/includes/class-wp-invariant-request.php b/tests/invariants/includes/class-wp-invariant-request.php new file mode 100644 index 0000000000000..93624d735ea3a --- /dev/null +++ b/tests/invariants/includes/class-wp-invariant-request.php @@ -0,0 +1,154 @@ + null, + 'values' => array(), + ); + + /** + * Returns the values the request sent which hold any of some characters. + * + * @param string $characters Characters, as for strpbrk(), such as `<"'`. Empty for every value. + * @return string[] Distinct values. + */ + public static function values( $characters = '' ) { + $values = self::all_values(); + if ( '' === $characters ) { + return $values; + } + + return array_values( + array_filter( + $values, + static function ( $value ) use ( $characters ) { + return false !== strpbrk( $value, $characters ); + } + ) + ); + } + + /** + * Returns the byte offsets where a value appears in a string. + * + * @param string $haystack String to look in. + * @param string $needle Value to look for. + * @param int $limit Most offsets to return. + * @return int[] + */ + public static function occurrences( $haystack, $needle, $limit = 20 ) { + $offsets = array(); + $at = strpos( $haystack, $needle ); + while ( false !== $at && count( $offsets ) < $limit ) { + $offsets[] = $at; + $at = strpos( $haystack, $needle, $at + 1 ); + } + + return $offsets; + } + + /** + * Notes that a REST request is being dispatched, so that its parameters are values the request sent. + * + * @param array $params Its parameters. + */ + public static function start_rest_request( array $params ) { + self::$rest[] = $params; + } + + /** + * Notes that the innermost REST request has been dispatched. + */ + public static function end_rest_request() { + array_pop( self::$rest ); + } + + /** + * Forgets the REST requests being dispatched, as a test starts. + */ + public static function reset() { + self::$rest = array(); + } + + /** + * Returns every value the request sent, from a cache while the request has not changed. + * + * @return string[] + */ + private static function all_values() { + $sources = array( $_GET, $_POST, $_COOKIE, $_REQUEST, self::$rest ); // phpcs:ignore WordPress.Security.NonceVerification -- Only read, to follow the values. + foreach ( array( 'REQUEST_URI', 'QUERY_STRING' ) as $key ) { + if ( isset( $_SERVER[ $key ] ) ) { + $sources[] = $_SERVER[ $key ]; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Only read, to follow the values. + } + } + foreach ( $_SERVER as $key => $value ) { + if ( 0 === strncmp( $key, 'HTTP_', 5 ) ) { + $sources[] = $value; + } + } + + $key = md5( serialize( $sources ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Only to compare. + if ( $key === self::$cache['key'] ) { + return self::$cache['values']; + } + + $values = array(); + foreach ( $sources as $source ) { + foreach ( WP_Invariant::strings_in( $source, self::MAX_VALUES ) as $value ) { + foreach ( array( $value, stripslashes( $value ) ) as $form ) { + if ( strlen( $form ) >= self::MIN_LENGTH && count( $values ) < self::MAX_VALUES ) { + $values[ $form ] = true; + } + } + } + } + + self::$cache = array( + 'key' => $key, + 'values' => array_map( 'strval', array_keys( $values ) ), + ); + + return self::$cache['values']; + } +} diff --git a/tests/invariants/includes/class-wp-invariant-security.php b/tests/invariants/includes/class-wp-invariant-security.php new file mode 100644 index 0000000000000..1a472e6f15874 --- /dev/null +++ b/tests/invariants/includes/class-wp-invariant-security.php @@ -0,0 +1,223 @@ + true, + 'php3' => true, + 'php4' => true, + 'php5' => true, + 'php6' => true, + 'php7' => true, + 'php8' => true, + 'phtml' => true, + 'pht' => true, + 'phar' => true, + 'phps' => true, + 'shtml' => true, + 'cgi' => true, + 'pl' => true, + ); + + /** + * Names of files which configure a web server or PHP for the directory they are in. + */ + const CONFIGURATION_FILES = array( + '.htaccess' => true, + '.user.ini' => true, + 'web.config' => true, + ); + + /** + * Methods which unserialize() can lead to, through which serialized objects can run code. + */ + const GADGET_METHODS = array( '__wakeup', '__unserialize', '__destruct', '__toString', '__call', '__get' ); + + /** + * Returns what kind of internal address an IP address is, or null when it is a public one. + * + * @param string $ip IPv4 or IPv6 address, without brackets. + * @return string|null Such as `loopback`, `private` or `link-local, the cloud metadata service`. + */ + public static function internal_address( $ip ) { + $bytes = @inet_pton( $ip ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- It warns on what is not an address. + if ( false === $bytes ) { + return null; + } + + if ( 16 === strlen( $bytes ) ) { + $mapped = substr( $bytes, 0, 12 ); + if ( str_repeat( "\0", 10 ) . "\xff\xff" === $mapped || "\x00\x64\xff\x9b" . str_repeat( "\0", 8 ) === $mapped ) { + // An IPv4-mapped address, or the NAT64 prefix 64:ff9b::/96, reaches the IPv4 address in its last four bytes. + $bytes = substr( $bytes, 12 ); + } elseif ( str_repeat( "\0", 15 ) . "\x01" === $bytes ) { + return 'loopback'; + } elseif ( str_repeat( "\0", 16 ) === $bytes ) { + return 'unspecified'; + } elseif ( 0xfc === ( ord( $bytes[0] ) & 0xfe ) ) { + return 'unique local'; + } elseif ( 0xfe === ord( $bytes[0] ) && 0x80 === ( ord( $bytes[1] ) & 0xc0 ) ) { + return 'link-local'; + } else { + return null; + } + } + + $a = ord( $bytes[0] ); + $b = ord( $bytes[1] ); + if ( 0 === $a ) { + return 'unspecified'; + } + if ( 127 === $a ) { + return 'loopback'; + } + if ( 10 === $a || ( 172 === $a && $b >= 16 && $b <= 31 ) || ( 192 === $a && 168 === $b ) ) { + return 'private'; + } + if ( 169 === $a && 254 === $b ) { + return "\xa9\xfe\xa9\xfe" === $bytes ? 'link-local, the cloud metadata service' : 'link-local'; + } + if ( 100 === $a && $b >= 64 && $b <= 127 ) { + return 'carrier-grade NAT'; + } + + return null; + } + + /** + * Returns the names of the classes of the objects in serialized data. + * + * It reads `O::""` and `C::""` wherever they + * appear outside serialized strings. + * + * @param string $data Serialized data. + * @return string[] Distinct class names. + */ + public static function serialized_classes( $data ) { + $classes = array(); + $length = strlen( $data ); + $at = 0; + while ( $at < $length ) { + $type = $data[ $at ]; + if ( 's' === $type && ':' === ( $data[ $at + 1 ] ?? '' ) ) { + // Skip a string, whose contents can look like anything: s::"…";. + $digits = strspn( $data, '0123456789', $at + 2 ); + $at += 2 + $digits + 2 + (int) substr( $data, $at + 2, $digits ) + 2; + continue; + } + if ( ( 'O' === $type || 'C' === $type ) && ':' === ( $data[ $at + 1 ] ?? '' ) ) { + $digits = strspn( $data, '0123456789', $at + 2 ); + $name = substr( $data, $at + 2 + $digits + 2, (int) substr( $data, $at + 2, $digits ) ); + if ( '' !== $name ) { + $classes[ $name ] = true; + } + } + ++$at; + } + + return array_keys( $classes ); + } + + /** + * Returns the classes in serialized data through which unserialize() could run code. + * + * Those are classes which are loaded, which unserialize() would create, and + * which have any of GADGET_METHODS. A class which has not been loaded is not + * loaded to find out. + * + * @param string $data Serialized data. + * @param bool|array $allowed_classes The `allowed_classes` option given to unserialize(), true when none was. + * @return string[] + */ + public static function gadget_classes( $data, $allowed_classes = true ) { + if ( false === $allowed_classes || ( false === strpos( $data, 'O:' ) && false === strpos( $data, 'C:' ) ) ) { + return array(); + } + + $allowed = is_array( $allowed_classes ) ? array_change_key_case( array_flip( $allowed_classes ) ) : null; + $gadgets = array(); + foreach ( self::serialized_classes( $data ) as $class ) { + if ( ( null !== $allowed && ! isset( $allowed[ strtolower( $class ) ] ) ) || ! class_exists( $class, false ) ) { + continue; + } + foreach ( self::GADGET_METHODS as $method ) { + if ( method_exists( $class, $method ) ) { + $gadgets[] = $class; + break; + } + } + } + + return $gadgets; + } + + /** + * Returns why a file name could run as code on a web server, or null when it could not. + * + * Apache runs a file by any of its extensions, so `photo.php.jpg` counts as PHP. + * + * @param string $name A file name, or a path. + * @return string|null Such as `the extension .php` or `.htaccess, which configures the server`. + */ + public static function executable_name( $name ) { + $name = strtolower( basename( str_replace( '\\', '/', $name ) ) ); + if ( isset( self::CONFIGURATION_FILES[ $name ] ) ) { + return "{$name}, which configures the server"; + } + + $extensions = explode( '.', $name ); + array_shift( $extensions ); + foreach ( $extensions as $extension ) { + $extension = rtrim( $extension, " \t" ); + if ( isset( self::EXECUTABLE_EXTENSIONS[ $extension ] ) ) { + return "the extension .{$extension}"; + } + } + + return null; + } + + /** + * Returns why the contents of a file are PHP code, or null when they are not. + * + * @param string $contents Contents of a file. + * @return string|null Such as `it starts with 'empty', + WP_Invariant_Types::STRING_NUMERIC => 'numeric', + WP_Invariant_Types::STRING_HTML => 'html', + WP_Invariant_Types::STRING_REFERENCE => 'ref', + WP_Invariant_Types::STRING_URL => 'url', + WP_Invariant_Types::STRING_FILE => 'file', + WP_Invariant_Types::STRING_PATH => 'path', + WP_Invariant_Types::STRING_UNSLASH => 'unslash', + WP_Invariant_Types::STRING_SLASH => 'slash', + WP_Invariant_Types::STRING_INVALID_UTF8 => '-utf8', + ); + + /** + * Properties of strings which imply another, which is then not shown. + * + * Every property but `empty` implies `non-empty`, which is shown only when a + * non-empty string has no other. + */ + const STRING_IMPLIES = array( + WP_Invariant_Types::STRING_UNSLASH => WP_Invariant_Types::STRING_SLASH, + WP_Invariant_Types::STRING_FILE => WP_Invariant_Types::STRING_PATH, + ); + + /** + * Writes the report, after merging in what separate processes collected. + * + * @param string $directory Directory for the report files. + * @param string $name Base name of the report files, such as `invariants-types`. + * @param array[] $targets The functions and methods which were hooked, from WP_Invariant::functions_in(). + * @param array[] $profile What was collected in this process, from WP_Invariant_Types::profile(). + * @param string[] $processes Files of what separate processes collected, which are removed once read. + * @return string A short summary in Markdown. + */ + public static function write( $directory, $name, array $targets, array $profile, array $processes = array() ) { + foreach ( $processes as $file ) { + $other = unserialize( file_get_contents( $file ), array( 'allowed_classes' => false ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- What WP_Invariant_Types wrote. + if ( is_array( $other ) ) { + $profile = WP_Invariant_Types::merge_profiles( $profile, $other ); + } + unlink( $file ); + } + + $data = self::data( $targets, $profile, count( $processes ) ); + if ( ! is_dir( $directory ) ) { + mkdir( $directory, 0777, true ); + } + file_put_contents( "{$directory}/{$name}.json", json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE ) . "\n" ); + file_put_contents( "{$directory}/{$name}.md", self::markdown( $data ) ); + + $summary = self::summary( $data, $name ); + file_put_contents( "{$directory}/{$name}-summary.md", $summary ); + return $summary; + } + + /** + * Gathers what each target was given and returned, with its documented types. + * + * @param array[] $targets The hooked functions and methods. + * @param array[] $profile What was collected, by target. + * @param int $processes How many separate processes were merged in. + * @return array + */ + public static function data( array $targets, array $profile, $processes = 0 ) { + $functions = array(); + $never = array(); + $calls = 0; + foreach ( $targets as $target ) { + $name = $target['target']; + if ( ! isset( $profile[ $name ] ) || 0 === array_sum( $profile[ $name ]['calls'] ) ) { + $never[] = $name . '()'; + continue; + } + + $functions[ $name ] = self::describe( $target, $profile[ $name ] ); + $calls += $functions[ $name ]['calls']['core'] + $functions[ $name ]['calls']['tests']; + } + + uksort( $functions, array( __CLASS__, 'compare_targets' ) ); + usort( $never, array( __CLASS__, 'compare_targets' ) ); + + return array( + 'summary' => array( + 'targets' => count( $targets ), + 'called' => count( $functions ), + 'never_called' => count( $never ), + 'calls' => $calls, + 'processes' => $processes, + ), + 'functions' => $functions, + 'never_called' => $never, + ); + } + + /** + * Describes one function or method: where it is, how it is marked, and what it was given and returned. + * + * @param array $target From WP_Invariant::functions_in(). + * @param array $profile What was collected for it. + * @return array + */ + private static function describe( array $target, array $profile ) { + $reflection = self::reflect( $target ); + $documented = self::doc_types( $reflection ? (string) $reflection->getDocComment() : '' ); + $parameters = $reflection ? $reflection->getParameters() : array(); + $declared = count( $parameters ); + $variadic = $declared > 0 && $parameters[ $declared - 1 ]->isVariadic(); + + // Arguments past the declared parameters are counted together: as the variadic parameter, or as extra arguments. + $slots = array(); + foreach ( $profile['slots'] as $position => $slot ) { + if ( 'return' === $position ) { + continue; + } + if ( ! $reflection ) { + $index = $position; + } else { + $index = $variadic ? min( $position, $declared - 1 ) : min( $position, $declared ); + } + if ( ! isset( $slots[ $index ] ) ) { + $slots[ $index ] = $slot; + } else { + $slots[ $index ] = WP_Invariant_Types::merge_slots( $slots[ $index ], $slot ); + } + } + + $described = array(); + foreach ( $parameters as $index => $parameter ) { + $rows = isset( $slots[ $index ] ) ? self::rows( $slots[ $index ] ) : array(); + if ( ! $parameter->isVariadic() ) { + // A parameter was not passed when the call had no more arguments than its position. + $missing = array( 0, 0 ); + foreach ( $profile['arguments'] as $count => $counts ) { + if ( $count <= $index ) { + $missing = array( $missing[0] + $counts[0], $missing[1] + $counts[1] ); + } + } + if ( $missing[0] + $missing[1] > 0 ) { + $rows[] = self::row( '(not passed)', $missing ); + } + } + + $name = $parameter->getName(); + $described[] = array( + 'name' => ( $parameter->isVariadic() ? '...$' : '$' ) . $name, + 'native' => $parameter->hasType() ? (string) $parameter->getType() : null, + 'phpdoc' => isset( $documented['param'][ $name ] ) ? $documented['param'][ $name ] : null, + 'phpstan' => isset( $documented['phpstan-param'][ $name ] ) ? $documented['phpstan-param'][ $name ] : null, + 'rows' => self::sort_rows( $rows ), + ); + } + + // Without reflection, arguments are only known by position. + foreach ( $slots as $index => $slot ) { + if ( $index >= $declared && ! ( $variadic && $index === $declared - 1 ) ) { + $described[] = array( + 'name' => $reflection ? '(extra)' : "#{$index}", + 'native' => null, + 'phpdoc' => null, + 'phpstan' => null, + 'rows' => self::sort_rows( self::rows( $slot ) ), + ); + } + } + + $markers = array(); + if ( $target['private'] ) { + $markers[] = 'private'; + } + if ( 'public' !== $target['visibility'] ) { + $markers[] = "{$target['visibility']} method"; + } + foreach ( array( 'static', 'magic', 'deprecated' ) as $marker ) { + if ( $target[ $marker ] ) { + $markers[] = $marker; + } + } + // The extension gives a generator no post hooks, so its return values are not counted. + if ( $reflection && $reflection->isGenerator() ) { + $markers[] = 'generator'; + } + + return array( + 'file' => WP_Invariant::relative_path( $reflection ? $reflection->getFileName() : $target['file'] ), + 'line' => $reflection ? $reflection->getStartLine() : $target['line'], + 'markers' => $markers, + 'calls' => array( + 'core' => $profile['calls'][0], + 'tests' => $profile['calls'][1], + ), + 'parameters' => $described, + 'return' => array( + 'native' => $reflection && $reflection->hasReturnType() ? (string) $reflection->getReturnType() : null, + 'phpdoc' => isset( $documented['return'] ) ? $documented['return'] : null, + 'phpstan' => isset( $documented['phpstan-return'] ) ? $documented['phpstan-return'] : null, + 'rows' => isset( $profile['slots']['return'] ) ? self::sort_rows( self::rows( $profile['slots']['return'] ) ) : array(), + ), + ); + } + + /** + * Returns the reflection of a function or method, if it is loaded. + * + * @param array $target From WP_Invariant::functions_in(). + * @return ReflectionFunctionAbstract|null + */ + private static function reflect( array $target ) { + if ( null === $target['class'] ) { + return function_exists( $target['function'] ) ? new ReflectionFunction( $target['function'] ) : null; + } + + $loaded = class_exists( $target['class'], false ) || interface_exists( $target['class'], false ) || trait_exists( $target['class'], false ) || enum_exists( $target['class'], false ); + return $loaded && method_exists( $target['class'], $target['function'] ) ? new ReflectionMethod( $target['class'], $target['function'] ) : null; + } + + /** + * Returns the types a doc comment declares, as written. + * + * @param string $doc Doc comment. + * @return array With `param` and `phpstan-param`, by parameter name without `$`, and `return` and `phpstan-return`. + */ + public static function doc_types( $doc ) { + $types = array( + 'param' => array(), + 'phpstan-param' => array(), + ); + if ( '' === $doc ) { + return $types; + } + + // Without the comment's delimiters and the asterisks which start its lines. + $text = preg_replace( '~^\s*/\*\*|\*/\s*$~', '', $doc ); + $text = preg_replace( '~^[ \t]*\*[ \t]?~m', '', $text ); + + preg_match_all( '~@(param|phpstan-param|return|phpstan-return)[ \t]+~', $text, $tags, PREG_SET_ORDER | PREG_OFFSET_CAPTURE ); + foreach ( $tags as $tag ) { + $kind = $tag[1][0]; + $at = $tag[0][1] + strlen( $tag[0][0] ); + $type = self::read_type( $text, $at ); + if ( '' === $type ) { + continue; + } + + if ( 'return' === $kind || 'phpstan-return' === $kind ) { + if ( ! isset( $types[ $kind ] ) ) { + $types[ $kind ] = $type; + } + continue; + } + + if ( preg_match( '~\G\s*&?(?:\.\.\.)?\$(\w+)~', $text, $match, 0, $at ) && ! isset( $types[ $kind ][ $match[1] ] ) ) { + $types[ $kind ][ $match[1] ] = $type; + } + } + + return $types; + } + + /** + * Reads a type from a doc comment, which can span lines inside brackets, such as `array{a: int}`. + * + * @param string $text Doc comment text. + * @param int $at Where the type starts; set to where it ends. + * @return string + */ + private static function read_type( $text, &$at ) { + $depth = 0; + $type = ''; + $length = strlen( $text ); + for ( ; $at < $length; $at++ ) { + $char = $text[ $at ]; + if ( false !== strpos( '<{([', $char ) ) { + ++$depth; + } elseif ( false !== strpos( '>})]', $char ) && $depth > 0 && '-' !== substr( $type, -1 ) ) { + --$depth; + } elseif ( 0 === $depth && ctype_space( $char ) ) { + // A callable's return type follows its colon, as in `callable(): void`. + if ( ':' !== substr( $type, -1 ) ) { + break; + } + continue; + } + $type .= $char; + } + + return preg_replace( '~\s+~', ' ', trim( $type ) ); + } + + /** + * Returns the counted types of a slot as rows: the type, and how often it came from WordPress and from tests. + * + * @param array $slot From WP_Invariant_Types. + * @return array[] + */ + public static function rows( array $slot ) { + $rows = array(); + $types = $slot['types']; + if ( isset( $types['true'], $types['false'] ) ) { + $rows[] = self::row( 'bool', array( $types['true'][0] + $types['false'][0], $types['true'][1] + $types['false'][1] ) ); + unset( $types['true'], $types['false'] ); + } + + foreach ( $types as $type => $counts ) { + if ( 'array{...}' === $type || 'stdClass{...}' === $type ) { + $kind = substr( $type, 0, -5 ); + $type = self::record_type( $kind, $slot['records'][ $kind ] ); + } + $rows[] = self::row( $type, $counts ); + } + + $ints = $slot['ints']; + if ( $ints['widened'] ) { + foreach ( $ints['properties'] as $properties => $counts ) { + $rows[] = self::row( 'int', $counts, self::tags( 'int', $properties ) ); + } + } else { + foreach ( $ints['values'] as $value => $counts ) { + $rows[] = self::row( (string) $value, $counts ); + } + } + + $strings = $slot['strings']; + if ( $strings['widened'] ) { + foreach ( $strings['properties'] as $properties => $counts ) { + $rows[] = self::row( 'string', $counts, self::tags( 'string', $properties ) ); + } + } else { + foreach ( $strings['values'] as $value => $counts ) { + $rows[] = self::row( var_export( (string) $value, true ), $counts ); + } + } + + return $rows; + } + + /** + * Returns the tags for properties of ints or strings, leaving out those which others imply. + * + * @param string $kind `int` or `string`. + * @param int $properties Bits, from WP_Invariant_Types. + * @return string[] Such as `array( 'html', 'slash' )`, `array( 'non-empty' )` or `array( 'zero' )`. + */ + public static function tags( $kind, $properties ) { + if ( 'int' === $kind ) { + return array( ( $properties & WP_Invariant_Types::INT_ZERO ) ? 'zero' : 'non-zero' ); + } + + foreach ( self::STRING_IMPLIES as $property => $implied ) { + if ( $properties & $property ) { + $properties &= ~$implied; + } + } + + $tags = array(); + foreach ( self::STRING_TAGS as $property => $tag ) { + if ( $properties & $property ) { + $tags[] = $tag; + } + } + + return array() === $tags ? array( 'non-empty' ) : $tags; + } + + /** + * Formats a record of first-level keys as a shape, such as `array{post_type: string, paged?: int}`. + * + * A key is optional, with `?`, when some values in the record did not have it. + * + * @param string $kind `array` or `stdClass`. + * @param array $record From WP_Invariant_Types. + * @return string + */ + public static function record_type( $kind, array $record ) { + $fields = array(); + foreach ( $record['keys'] as $key => $entry ) { + $key = is_int( $key ) || preg_match( '~^[A-Za-z_][A-Za-z0-9_]*$~', $key ) ? $key : var_export( (string) $key, true ); + $optional = $entry['present'] < $record['count'] ? '?' : ''; + $fields[] = "{$key}{$optional}: " . WP_Invariant_Types::join_types( array_keys( $entry['types'] ) ); + } + + return $kind . '{' . implode( ', ', $fields ) . '}'; + } + + /** + * Makes a row. + * + * @param string $type Type. + * @param int[] $counts From WordPress, and from tests. + * @param string[] $properties Tags for the properties of the values, from tags(), if any. + * @return array + */ + private static function row( $type, array $counts, array $properties = array() ) { + $row = array( + 'type' => array() === $properties ? $type : $type . ' [' . implode( ' ', $properties ) . ']', + 'core' => $counts[0], + 'tests' => $counts[1], + ); + if ( array() !== $properties ) { + $row['properties'] = $properties; + } + + return $row; + } + + /** + * Sorts rows by how often they were seen, most often first. + * + * @param array[] $rows Rows. + * @return array[] + */ + private static function sort_rows( array $rows ) { + usort( + $rows, + static function ( $a, $b ) { + $difference = ( $b['core'] + $b['tests'] ) - ( $a['core'] + $a['tests'] ); + return 0 !== $difference ? $difference : strcmp( $a['type'], $b['type'] ); + } + ); + return $rows; + } + + /** + * Orders functions before methods, then by name, ignoring case. + * + * @param string $a Target, perhaps followed by `()`. + * @param string $b Target, perhaps followed by `()`. + * @return int + */ + private static function compare_targets( $a, $b ) { + $a_method = false !== strpos( $a, '::' ); + $b_method = false !== strpos( $b, '::' ); + if ( $a_method !== $b_method ) { + return $a_method ? 1 : -1; + } + + return strcasecmp( $a, $b ); + } + + /** + * Formats the report as Markdown, in the layout of a type listing. + * + * @param array $data What data() gathered. + * @return string + */ + public static function markdown( array $data ) { + $summary = $data['summary']; + $lines = array( + '# Argument and return types', + '', + sprintf( + '%s of %s WordPress functions and methods were called, %s times in all. Each type shows how often it came from WordPress, then from tests, next to the types the function declares. Ints are shown as literals until there are more than %d distinct ones. Strings are shown as literals until there are more than %d distinct ones, or one of %d bytes or more. `true` and `false` are shown apart until both are seen.', + number_format( $summary['called'] ), + number_format( $summary['targets'] ), + number_format( $summary['calls'] ), + WP_Invariant_Types::INT_LITERALS, + WP_Invariant_Types::STRING_LITERALS, + WP_Invariant_Types::STRING_LENGTH + ), + '', + 'Once they are no longer literals, ints and strings are counted by their properties:', + '', + '- `zero` and `non-zero` ints.', + '- `empty` and `non-empty` strings. `non-empty` is shown only for a string with none of the properties below, each of which implies it.', + '- `numeric`: it is a numeric string, as `is_numeric()` reads it, such as `\'42\'` or `\'-1.5\'`.', + '- `html`: the HTML API finds markup in it, which is anything but text.', + '- `ref`: it holds an HTML character reference, such as `&`.', + '- `url`: it parses as an absolute WHATWG URL.', + '- `path`: it is probably a file path, but nothing is there. `file`: it is probably a file path, and a file or directory is there.', + '- `slash`: `wp_slash()` would change it. `unslash`: `wp_unslash()` would change it, and so would `wp_slash()`.', + '- `-utf8`: it is not valid UTF-8.', + ); + + // Functions come first, then the methods of each class, each under a heading. + $heading = false; + $block = array(); + foreach ( $data['functions'] as $target => $function ) { + $parts = explode( '::', $target ); + $group = count( $parts ) > 1 ? $parts[0] : null; + if ( $group !== $heading ) { + $lines = array_merge( $lines, self::code_block( $block ) ); + $block = array(); + $lines[] = ''; + $lines[] = null === $group ? '## Functions' : "## {$group}"; + $heading = $group; + } + + $block = array_merge( $block, self::function_lines( $target, $function ) ); + } + $lines = array_merge( $lines, self::code_block( $block ) ); + + if ( array() !== $data['never_called'] ) { + $lines[] = ''; + $lines[] = '## Never called'; + $lines[] = ''; + $lines[] = sprintf( '%s functions and methods were not called by the tests.', number_format( count( $data['never_called'] ) ) ); + $lines = array_merge( $lines, self::code_block( $data['never_called'] ) ); + } + + return implode( "\n", $lines ) . "\n"; + } + + /** + * Formats one function or method as lines of the listing. + * + * @param string $target Function or method. + * @param array $described From describe(). + * @return string[] + */ + private static function function_lines( $target, array $described ) { + $markers = array() === $described['markers'] ? '' : ' [' . implode( ', ', $described['markers'] ) . ']'; + $lines = array( + "{$target}() {$described['file']}:{$described['line']}{$markers}", + ' calls: ' . self::counts( $described['calls']['core'], $described['calls']['tests'] ), + ); + + foreach ( $described['parameters'] as $parameter ) { + $lines = array_merge( $lines, self::slot_lines( $parameter['name'], $parameter, '@param' ) ); + } + if ( array() !== $described['return']['rows'] || null !== $described['return']['phpdoc'] ) { + $lines = array_merge( $lines, self::slot_lines( 'return', $described['return'], '@return' ) ); + } + $lines[] = ''; + + return $lines; + } + + /** + * Formats one parameter or return value: its documented types, then a line for each type seen. + * + * @param string $label Parameter name, or `return`. + * @param array $slot With `native`, `phpdoc`, `phpstan` and `rows`. + * @param string $phpdoc `@param` or `@return`. + * @return string[] + */ + private static function slot_lines( $label, array $slot, $phpdoc ) { + $documented = array(); + if ( null !== $slot['native'] ) { + $documented[] = "native {$slot['native']}"; + } + if ( null !== $slot['phpdoc'] ) { + $documented[] = "{$phpdoc} {$slot['phpdoc']}"; + } + if ( null !== $slot['phpstan'] ) { + $documented[] = '@phpstan-' . substr( $phpdoc, 1 ) . " {$slot['phpstan']}"; + } + + $lines = array( rtrim( ' ' . str_pad( $label, 30 ) . implode( ' ', $documented ) ) ); + foreach ( $slot['rows'] as $row ) { + $lines[] = sprintf( '%12s: %s (%s)', number_format( $row['core'] + $row['tests'] ), str_pad( $row['type'], 22 ), self::counts( $row['core'], $row['tests'], false ) ); + } + + return $lines; + } + + /** + * Formats counts from WordPress and from tests. + * + * @param int $core From WordPress. + * @param int $tests From tests. + * @param bool $total Whether to start with the total. + * @return string + */ + private static function counts( $core, $tests, $total = true ) { + $parts = array(); + if ( $core > 0 || 0 === $tests ) { + $parts[] = number_format( $core ) . ' core'; + } + if ( $tests > 0 ) { + $parts[] = number_format( $tests ) . ' tests'; + } + + return $total ? sprintf( '%s (%s)', number_format( $core + $tests ), implode( ', ', $parts ) ) : implode( ', ', $parts ); + } + + /** + * Wraps lines in a code block, or returns nothing for no lines. + * + * @param string[] $lines Lines. + * @return string[] + */ + private static function code_block( array $lines ) { + if ( array() === $lines ) { + return array(); + } + + while ( '' === end( $lines ) ) { + array_pop( $lines ); + } + return array_merge( array( '', '```' ), $lines, array( '```' ) ); + } + + /** + * Returns a short summary of the report, for the summary of a CI run. + * + * @param array $data What data() gathered. + * @param string $name Base name of the report files. + * @return string + */ + private static function summary( array $data, $name ) { + $summary = $data['summary']; + return implode( + "\n", + array( + '## Argument and return types', + '', + sprintf( + '%s of %s WordPress functions and methods were called, %s times in all%s. %s were never called. The full report is `%s.md` and `%s.json`, in the `invariant-report` artifact.', + number_format( $summary['called'] ), + number_format( $summary['targets'] ), + number_format( $summary['calls'] ), + $summary['processes'] ? sprintf( ', including %s separate processes', number_format( $summary['processes'] ) ) : '', + number_format( $summary['never_called'] ), + $name, + $name + ), + ) + ) . "\n"; + } +} diff --git a/tests/invariants/includes/class-wp-invariant-types.php b/tests/invariants/includes/class-wp-invariant-types.php new file mode 100644 index 0000000000000..1224383b4f905 --- /dev/null +++ b/tests/invariants/includes/class-wp-invariant-types.php @@ -0,0 +1,944 @@ +` of the types of its first + * LIST_SAMPLE elements, or a record of its keys and the types of their values. + * Arrays with more than RECORD_KEYS elements, or a slot whose records have had + * more than RECORD_KEYS distinct keys, are plain `array`. + * - A stdClass object is a record of its properties, like an array. Other objects + * are their class. + * + * @see WP_Invariant_Types_Report + */ +final class WP_Invariant_Types { + + /** + * Most distinct ints a slot counts as literals: the fifth makes them `int`. + */ + const INT_LITERALS = 4; + + /** + * Most distinct strings a slot counts as literals. + */ + const STRING_LITERALS = 10; + + /** + * Strings this long, or longer, are not literals. + */ + const STRING_LENGTH = 32; + + /** + * Most keys an array or stdClass record has, and most distinct keys a slot's records have. + */ + const RECORD_KEYS = 64; + + /** + * Most distinct types a record's field has before it is `mixed`. + */ + const FIELD_TYPES = 8; + + /** + * Elements of a list whose types are counted. + */ + const LIST_SAMPLE = 16; + + /** + * Properties of a string, as bits. See string_properties(). + */ + const STRING_EMPTY = 1; + const STRING_HTML = 2; + const STRING_REFERENCE = 4; + const STRING_SLASH = 8; + const STRING_UNSLASH = 16; + const STRING_URL = 32; + const STRING_PATH = 64; + const STRING_FILE = 128; + const STRING_INVALID_UTF8 = 256; + const STRING_NUMERIC = 512; + + /** + * Property of an int, as a bit: it is zero. + */ + const INT_ZERO = 1; + + /** + * The special schemes of WHATWG URLs, whose URLs have no opaque path. + */ + const SPECIAL_SCHEMES = array( + 'ftp' => true, + 'file' => true, + 'http' => true, + 'https' => true, + 'ws' => true, + 'wss' => true, + ); + + /** + * Strings this long, or shorter, have their properties remembered. + */ + const REMEMBERED_LENGTH = 256; + + /** + * Most strings whose properties are remembered at once. + */ + const REMEMBERED_STRINGS = 8192; + + /** + * Index of the counts of calls from WordPress, and from tests. + */ + const CORE = 0; + const TEST = 1; + + /** + * What was collected, by target: `calls`, `arguments` (count of arguments => counts) and `slots`. + * + * @var array[] + */ + private static $profile = array(); + + /** + * The functions and methods which are hooked, from WP_Invariant::functions_in(). + * + * @var array[] + */ + private static $targets = array(); + + /** + * Directories which hold test code, each ending in a slash. + * + * @var string[] + */ + private static $test_directories = array(); + + /** + * Whether each calling file holds test code. + * + * @var bool[] + */ + private static $test_files = array(); + + /** + * The properties of strings classified recently, by string. + * + * @var int[] + */ + private static $remembered = array(); + + /** + * Whether each first segment of an absolute path is a directory at the root of the file system. + * + * @var bool[] + */ + private static $root_directories = array(); + + /** + * Returns the functions and methods to profile, from a cache when the files have not changed. + * + * @param string[] $directories Directories to profile. + * @param string[] $exclude Directories and files inside them to leave out. + * @param string $cache File to cache them in. + * @return array[] As WP_Invariant::functions_in() returns them. + */ + public static function targets( array $directories, array $exclude, $cache ) { + $key = md5( serialize( array( $exclude, WP_Invariant::source_key( $directories ) ) ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Only to compare. + + $cached = is_readable( $cache ) ? unserialize( file_get_contents( $cache ), array( 'allowed_classes' => false ) ) : null; // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- A cache this class wrote. + if ( is_array( $cached ) && isset( $cached['key'], $cached['targets'] ) && $cached['key'] === $key ) { + return $cached['targets']; + } + + $targets = WP_Invariant::functions_in( $directories, $exclude ); + if ( is_dir( dirname( $cache ) ) || mkdir( dirname( $cache ), 0777, true ) ) { + $cached = array( + 'key' => $key, + 'targets' => $targets, + ); + file_put_contents( $cache, serialize( $cached ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- A cache for this class. + } + + return $targets; + } + + /** + * Returns gameshark hook specs which count the types of the arguments and return values of the targets. + * + * They are two catch-all specs, with the target `*`, which the extension runs + * for every user-space function declared in the given directories, and which + * count the calls to the targets among them. + * + * @param array[] $targets As WP_Invariant::functions_in() returns them. + * @param string[] $directories Directories the targets were found in. + * @param string[] $test_directories Directories which hold test code. + * @return array[] Gameshark specs, with ids starting `types/`. + */ + public static function specs( array $targets, array $directories, array $test_directories ) { + self::$test_directories = array_map( + static function ( $directory ) { + $real = realpath( $directory ); + return rtrim( false === $real ? $directory : $real, '/' ) . '/'; + }, + $test_directories + ); + + // A function declared in two files, as some fallbacks are, is still one target. The extension names it as declared. + foreach ( $targets as $target ) { + if ( ! isset( self::$targets[ strtolower( $target['target'] ) ] ) ) { + self::$targets[ strtolower( $target['target'] ) ] = $target; + } + } + + // PHP may know a file by the path it was included with, or by its real path. + $files = array(); + foreach ( $directories as $directory ) { + $files[] = rtrim( $directory, '/' ) . '/'; + $real = realpath( $directory ); + if ( false !== $real ) { + $files[] = rtrim( $real, '/' ) . '/'; + } + } + $files = array_values( array_unique( $files ) ); + + return array( + array( + 'id' => 'types/arguments', + 'target' => '*', + 'when' => 'pre', + 'files' => $files, + 'hook' => static function ( $callee, array $args ) { + $key = strtolower( $callee ); + if ( isset( self::$targets[ $key ] ) ) { + self::count_arguments( self::$targets[ $key ]['target'], $args, self::caller_kind() ); + } + }, + ), + array( + 'id' => 'types/returns', + 'target' => '*', + 'when' => 'post', + 'files' => $files, + 'hook' => static function ( $callee, $return_value ) { + $key = strtolower( $callee ); + if ( isset( self::$targets[ $key ] ) ) { + self::count_value( self::$targets[ $key ]['target'], 'return', $return_value, self::caller_kind() ); + } + }, + ), + ); + } + + /** + * Returns whether the hooked call came from test code or not. + * + * The call came from the first frame with a file at or after the target's own + * frame: when PHP called the target, as with call_user_func(), the frame which + * called that function. + * + * @internal + * + * @return int self::TEST or self::CORE. + */ + public static function caller_kind() { + // This method, the hook's closure, then the target. + $frames = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 5 ); + $count = count( $frames ); + for ( $i = 2; $i < $count; $i++ ) { + if ( isset( $frames[ $i ]['file'] ) ) { + $file = $frames[ $i ]['file']; + if ( ! isset( self::$test_files[ $file ] ) ) { + self::$test_files[ $file ] = false; + foreach ( self::$test_directories as $directory ) { + if ( 0 === strpos( $file, $directory ) ) { + self::$test_files[ $file ] = true; + } + } + } + + return self::$test_files[ $file ] ? self::TEST : self::CORE; + } + } + + return self::CORE; + } + + /** + * Counts a call and the types of its arguments. + * + * @internal + * + * @param string $target The hooked function or method. + * @param array $arguments The arguments, in order. + * @param int $kind self::CORE or self::TEST. + */ + public static function count_arguments( $target, array $arguments, $kind ) { + if ( ! isset( self::$profile[ $target ] ) ) { + self::$profile[ $target ] = array( + 'calls' => array( 0, 0 ), + 'arguments' => array(), + 'slots' => array(), + ); + } + + $profile = &self::$profile[ $target ]; + ++$profile['calls'][ $kind ]; + + $count = count( $arguments ); + if ( ! isset( $profile['arguments'][ $count ] ) ) { + $profile['arguments'][ $count ] = array( 0, 0 ); + } + ++$profile['arguments'][ $count ][ $kind ]; + + foreach ( array_values( $arguments ) as $position => $argument ) { + self::count_value( $target, $position, $argument, $kind ); + } + } + + /** + * Counts the type of one value: an argument, or a return value. + * + * @internal + * + * @param string $target The hooked function or method. + * @param int|string $slot The argument's position, or `return`. + * @param mixed $value The value. + * @param int $kind self::CORE or self::TEST. + */ + public static function count_value( $target, $slot, $value, $kind ) { + if ( ! isset( self::$profile[ $target ] ) ) { + self::$profile[ $target ] = array( + 'calls' => array( 0, 0 ), + 'arguments' => array(), + 'slots' => array(), + ); + } + if ( ! isset( self::$profile[ $target ]['slots'][ $slot ] ) ) { + self::$profile[ $target ]['slots'][ $slot ] = self::empty_slot(); + } + + self::add_to_slot( self::$profile[ $target ]['slots'][ $slot ], $value, $kind ); + } + + /** + * Returns everything collected, by target. + * + * @return array[] + */ + public static function profile() { + return self::$profile; + } + + /** + * Returns the functions and methods which are hooked. + * + * @return array[] As WP_Invariant::functions_in() returns them. + */ + public static function hooked() { + return array_values( self::$targets ); + } + + /** + * Tells processes which PHPUnit starts for tests in separate processes where to leave what they collect. + * + * Child processes inherit the environment, and save_for_parent() reads it. + * + * @param string $directory Directory for what they collect. + */ + public static function collect_from_processes( $directory ) { + putenv( 'WP_INVARIANTS_TYPES_PARENT=' . getmypid() ); + putenv( "WP_INVARIANTS_TYPES_PROCESSES={$directory}" ); + } + + /** + * Saves what a separate process collected, for the PHPUnit process which started it to merge. + * + * It runs at shutdown, and does nothing in the PHPUnit process itself. + * + * @internal + */ + public static function save_for_parent() { + $parent = getenv( 'WP_INVARIANTS_TYPES_PARENT' ); + $directory = getenv( 'WP_INVARIANTS_TYPES_PROCESSES' ); + if ( ! $parent || ! $directory || getmypid() === (int) $parent || array() === self::$profile ) { + return; + } + + if ( is_dir( $directory ) || mkdir( $directory, 0777, true ) ) { + file_put_contents( "{$directory}/" . getmypid() . '.serialized', serialize( self::$profile ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- For WP_Invariant_Types_Report to read. + } + } + + /** + * Sets what was collected, for the self-tests. + * + * @param array[] $profile By target. + */ + public static function set_profile( array $profile ) { + self::$profile = $profile; + } + + /** + * Returns a slot which has counted nothing yet. + * + * @return array + */ + public static function empty_slot() { + return array( + 'types' => array(), + 'ints' => self::empty_literals(), + 'strings' => self::empty_literals(), + 'records' => array(), + ); + } + + /** + * Returns literals which have counted nothing yet. + * + * @return array `widened`, whether they are no longer counted apart; `total`, the counts of all + * values; `values`, the counts of each value until widened; and `properties`, the + * counts of all values by their properties, as bits. + */ + private static function empty_literals() { + return array( + 'widened' => false, + 'total' => array( 0, 0 ), + 'values' => array(), + 'properties' => array(), + ); + } + + /** + * Counts a value in a slot. + * + * @param array $slot The slot, from empty_slot(). + * @param mixed $value The value. + * @param int $kind self::CORE or self::TEST. + */ + public static function add_to_slot( array &$slot, $value, $kind ) { + if ( is_int( $value ) ) { + self::add_literal( $slot['ints'], $value, $kind, self::INT_LITERALS, true, 0 === $value ? self::INT_ZERO : 0 ); + return; + } + if ( is_string( $value ) ) { + self::add_literal( $slot['strings'], $value, $kind, self::STRING_LITERALS, strlen( $value ) < self::STRING_LENGTH, self::string_properties( $value ) ); + return; + } + + $record = null; + if ( is_array( $value ) ) { + $count = count( $value ); + if ( 0 === $count ) { + $type = 'array{}'; + } elseif ( array_is_list( $value ) ) { + $type = self::list_type( $value ); + } elseif ( $count > self::RECORD_KEYS ) { + $type = 'array'; + } else { + $type = 'array'; + $record = $value; + } + } elseif ( $value instanceof stdClass ) { + $type = 'stdClass'; + $record = get_object_vars( $value ); + if ( count( $record ) > self::RECORD_KEYS ) { + $record = null; + } + } else { + $type = self::shallow_type( $value ); + } + + if ( null !== $record && self::add_to_record( $slot, $type, $record ) ) { + $type = "{$type}{...}"; + } + + if ( ! isset( $slot['types'][ $type ] ) ) { + $slot['types'][ $type ] = array( 0, 0 ); + } + ++$slot['types'][ $type ][ $kind ]; + } + + /** + * Counts an int or string in a slot's literals of its type. + * + * @param array $literals The slot's literals, from empty_literals(). + * @param int|string $value The value. + * @param int $kind self::CORE or self::TEST. + * @param int $limit Most distinct values to count apart. + * @param bool $fits Whether the value may be a literal at all. + * @param int $properties The value's properties, as bits. + */ + private static function add_literal( array &$literals, $value, $kind, $limit, $fits, $properties ) { + ++$literals['total'][ $kind ]; + if ( ! isset( $literals['properties'][ $properties ] ) ) { + $literals['properties'][ $properties ] = array( 0, 0 ); + } + ++$literals['properties'][ $properties ][ $kind ]; + if ( $literals['widened'] ) { + return; + } + + if ( isset( $literals['values'][ $value ] ) ) { + ++$literals['values'][ $value ][ $kind ]; + } elseif ( $fits && count( $literals['values'] ) < $limit ) { + $literals['values'][ $value ] = array( 0, 0 ); + $literals['values'][ $value ][ $kind ] = 1; + } else { + $literals['widened'] = true; + $literals['values'] = array(); + } + } + + /** + * Returns the properties of a string, as bits. + * + * - STRING_EMPTY: it is empty, and has no other properties. + * - STRING_HTML: the HTML API's Tag Processor finds a token in it other than + * text. A tag which the end of the string cuts off is text. + * - STRING_REFERENCE: it holds a character reference which decodes in text, + * such as `&` or a legacy `&`. + * - STRING_SLASH: wp_slash() would change it, as it holds a quote, a backslash or NUL. + * - STRING_UNSLASH: wp_unslash() would change it, as it holds a backslash. + * - STRING_URL: it parses as an absolute WHATWG URL. + * - STRING_PATH: it is probably a file path: an absolute path whose first + * segment is a directory at the root of the file system, a Windows path + * starting with a drive letter, or a relative one starting with `./` or + * `../`, without a NUL, a newline, `<` or `://`. + * - STRING_FILE: it is probably a file path, and a file or directory exists there. + * - STRING_INVALID_UTF8: it is not valid UTF-8. + * - STRING_NUMERIC: it is a numeric string, as is_numeric() reads it, such as + * `'42'`, `'-1.5'`, `'1e3'` or `' 7'`. + * + * It uses no PCRE function, which would change what preg_last_error() returns + * to the WordPress code being called. + * + * @param string $value A string. + * @return int Bits. + */ + public static function string_properties( $value ) { + if ( '' === $value ) { + return self::STRING_EMPTY; + } + + $length = strlen( $value ); + if ( $length <= self::REMEMBERED_LENGTH && isset( self::$remembered[ $value ] ) ) { + return self::with_file( $value, self::$remembered[ $value ] ); + } + + $properties = is_numeric( $value ) ? self::STRING_NUMERIC : 0; + + // Every token other than text starts with `<`. + if ( false !== strpos( $value, '<' ) && class_exists( 'WP_HTML_Tag_Processor', false ) ) { + $processor = new WP_HTML_Tag_Processor( $value ); + while ( $processor->next_token() ) { + if ( '#text' !== $processor->get_token_type() ) { + $properties |= self::STRING_HTML; + break; + } + } + } + + $at = strpos( $value, '&' ); + if ( false !== $at && class_exists( 'WP_HTML_Decoder', false ) && isset( $GLOBALS['html5_named_character_references'] ) ) { + while ( false !== $at ) { + if ( null !== WP_HTML_Decoder::read_character_reference( 'data', $value, $at ) ) { + $properties |= self::STRING_REFERENCE; + break; + } + $at = strpos( $value, '&', $at + 1 ); + } + } + + if ( false !== strpbrk( $value, "'\"\\\0" ) ) { + $properties |= self::STRING_SLASH; + if ( false !== strpos( $value, '\\' ) ) { + $properties |= self::STRING_UNSLASH; + } + } + + if ( self::parses_as_url( $value ) ) { + $properties |= self::STRING_URL; + } + + if ( self::looks_like_path( $value, $length ) ) { + $properties |= self::STRING_PATH; + } + + if ( function_exists( 'mb_check_encoding' ) ? ! mb_check_encoding( $value, 'UTF-8' ) : ( function_exists( 'wp_is_valid_utf8' ) && ! wp_is_valid_utf8( $value ) ) ) { + $properties |= self::STRING_INVALID_UTF8; + } + + if ( $length <= self::REMEMBERED_LENGTH ) { + if ( count( self::$remembered ) >= self::REMEMBERED_STRINGS ) { + self::$remembered = array(); + } + self::$remembered[ $value ] = $properties; + } + + return self::with_file( $value, $properties ); + } + + /** + * Adds STRING_FILE to the properties of a path when something exists there now. + * + * It is not remembered, as tests create and remove files. + * + * @param string $value A string. + * @param int $properties Its other properties, as bits. + * @return int Bits. + */ + private static function with_file( $value, $properties ) { + if ( ( $properties & self::STRING_PATH ) && @file_exists( $value ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Only to look; WordPress's own calls report their warnings. + $properties |= self::STRING_FILE; + } + + return $properties; + } + + /** + * Returns whether a string parses as an absolute WHATWG URL. + * + * Only a string which starts with a scheme and `:`, after what the parser + * removes from it, is given to the parser. A URL with a scheme which is not + * special and an opaque path, such as `mailto:a@example.org`, always parses, + * so it is not given to the parser either: PHP 8.5's parser keeps the memory + * of each such URL until the request ends. + * + * @param string $value A non-empty string. + * @return bool + */ + private static function parses_as_url( $value ) { + if ( ! class_exists( 'Uri\WhatWg\Url', false ) ) { + return false; + } + + $url = ltrim( $value, "\x00..\x20" ); + if ( false !== strpbrk( $url, "\t\n\r" ) ) { + $url = str_replace( array( "\t", "\n", "\r" ), '', $url ); + } + + $colon = strpos( $url, ':' ); + if ( false === $colon || 0 === $colon || ! ctype_alpha( $url[0] ) ) { + return false; + } + if ( strspn( $url, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+-.', 0, $colon ) !== $colon ) { + return false; + } + + $scheme = strtolower( substr( $url, 0, $colon ) ); + if ( ! isset( self::SPECIAL_SCHEMES[ $scheme ] ) && ( ! isset( $url[ $colon + 1 ] ) || '/' !== $url[ $colon + 1 ] ) ) { + return true; + } + + return null !== Uri\WhatWg\Url::parse( $value ); + } + + /** + * Returns whether a string is probably a file path, as string_properties() describes. + * + * @param string $value A non-empty string. + * @param int $length Its length. + * @return bool + */ + private static function looks_like_path( $value, $length ) { + if ( $length > PHP_MAXPATHLEN || false !== strpbrk( $value, "\0\n\r<" ) || false !== strpos( $value, '://' ) ) { + return false; + } + + if ( 0 === strpos( $value, './' ) || 0 === strpos( $value, '../' ) ) { + return true; + } + + if ( $length >= 3 && ':' === $value[1] && ( '\\' === $value[2] || '/' === $value[2] ) && ctype_alpha( $value[0] ) ) { + return true; + } + + // An absolute path, but not a URL path such as `/wp-admin/`, or a network path. + if ( '/' !== $value[0] || ( $length > 1 && '/' === $value[1] ) ) { + return false; + } + + $segment = substr( $value, 1, strcspn( $value, '/', 1 ) ); + if ( '' === $segment || '.' === $segment || '..' === $segment ) { + return false; + } + if ( ! isset( self::$root_directories[ $segment ] ) ) { + self::$root_directories[ $segment ] = @is_dir( "/{$segment}" ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Only to look. + } + + return self::$root_directories[ $segment ]; + } + + /** + * Counts the keys of an array or stdClass in a slot's record of them. + * + * Once the slot's records have had more than RECORD_KEYS distinct keys, the + * record is dropped, and the values are counted as plain arrays or objects. + * + * @param array $slot The slot. + * @param string $type `array` or `stdClass`. + * @param array $fields The value's keys and values. + * @return bool Whether the value was counted in the record. + */ + private static function add_to_record( array &$slot, $type, array $fields ) { + if ( ! isset( $slot['records'][ $type ] ) ) { + $slot['records'][ $type ] = array( + 'widened' => false, + 'count' => 0, + 'keys' => array(), + ); + } + + $record = &$slot['records'][ $type ]; + if ( $record['widened'] ) { + return false; + } + if ( count( $record['keys'] + $fields ) > self::RECORD_KEYS ) { + $record['widened'] = true; + $record['keys'] = array(); + + // What the record counted is counted as plain values from now on. + $recorded = isset( $slot['types'][ "{$type}{...}" ] ) ? $slot['types'][ "{$type}{...}" ] : array( 0, 0 ); + unset( $slot['types'][ "{$type}{...}" ] ); + $slot['types'][ $type ] = isset( $slot['types'][ $type ] ) ? array( $slot['types'][ $type ][0] + $recorded[0], $slot['types'][ $type ][1] + $recorded[1] ) : $recorded; + return false; + } + + ++$record['count']; + foreach ( $fields as $key => $field ) { + if ( ! isset( $record['keys'][ $key ] ) ) { + $record['keys'][ $key ] = array( + 'present' => 0, + 'types' => array(), + ); + } + + $entry = &$record['keys'][ $key ]; + ++$entry['present']; + if ( isset( $entry['types']['mixed'] ) ) { + continue; + } + + $field_type = self::shallow_type( $field ); + $entry['types'][ $field_type ] = isset( $entry['types'][ $field_type ] ) ? $entry['types'][ $field_type ] + 1 : 1; + if ( count( $entry['types'] ) > self::FIELD_TYPES ) { + $entry['types'] = array( 'mixed' => array_sum( $entry['types'] ) ); + } + } + + return true; + } + + /** + * Returns the type of a list, from the types of its first elements. + * + * @param array $elements A list with at least one element. + * @return string Such as `list` or `list`. + */ + private static function list_type( array $elements ) { + $types = array(); + foreach ( array_slice( $elements, 0, self::LIST_SAMPLE ) as $element ) { + $types[ self::shallow_type( $element ) ] = true; + } + $types = self::join_types( array_keys( $types ) ); + + return "list<{$types}>"; + } + + /** + * Returns the type of a value without looking inside it, except for whether an array is empty or a list. + * + * @param mixed $value Any value. + * @return string + */ + public static function shallow_type( $value ) { + if ( null === $value ) { + return 'null'; + } + if ( true === $value ) { + return 'true'; + } + if ( false === $value ) { + return 'false'; + } + if ( is_int( $value ) ) { + return 'int'; + } + if ( is_float( $value ) ) { + return 'float'; + } + if ( is_string( $value ) ) { + return 'string'; + } + if ( is_array( $value ) ) { + if ( array() === $value ) { + return 'array{}'; + } + return array_is_list( $value ) ? 'list' : 'array'; + } + if ( is_object( $value ) ) { + return get_class( $value ); + } + + return 'resource'; + } + + /** + * Joins types into a union, with `true` and `false` as `bool`. + * + * @param string[] $types Types. + * @return string + */ + public static function join_types( array $types ) { + if ( in_array( 'true', $types, true ) && in_array( 'false', $types, true ) ) { + $types = array_values( array_diff( $types, array( 'true', 'false' ) ) ); + array_unshift( $types, 'bool' ); + } + + return implode( '|', $types ); + } + + /** + * Merges what another process collected into what this one collected, by the same rules. + * + * @param array[] $into By target. + * @param array[] $other By target. + * @return array[] By target. + */ + public static function merge_profiles( array $into, array $other ) { + foreach ( $other as $target => $profile ) { + if ( ! isset( $into[ $target ] ) ) { + $into[ $target ] = $profile; + continue; + } + + $merged = &$into[ $target ]; + $merged['calls'] = self::add_counts( $merged['calls'], $profile['calls'] ); + foreach ( $profile['arguments'] as $count => $counts ) { + $merged['arguments'][ $count ] = isset( $merged['arguments'][ $count ] ) ? self::add_counts( $merged['arguments'][ $count ], $counts ) : $counts; + } + foreach ( $profile['slots'] as $slot => $counted ) { + $merged['slots'][ $slot ] = isset( $merged['slots'][ $slot ] ) ? self::merge_slots( $merged['slots'][ $slot ], $counted ) : $counted; + } + unset( $merged ); + } + + return $into; + } + + /** + * Merges two slots, by the rules which counted them. + * + * @param array $into A slot. + * @param array $other Another slot. + * @return array + */ + public static function merge_slots( array $into, array $other ) { + foreach ( $other['types'] as $type => $counts ) { + $into['types'][ $type ] = isset( $into['types'][ $type ] ) ? self::add_counts( $into['types'][ $type ], $counts ) : $counts; + } + + $into['ints'] = self::merge_literals( $into['ints'], $other['ints'], self::INT_LITERALS ); + $into['strings'] = self::merge_literals( $into['strings'], $other['strings'], self::STRING_LITERALS ); + + foreach ( $other['records'] as $type => $record ) { + if ( ! isset( $into['records'][ $type ] ) ) { + $into['records'][ $type ] = $record; + continue; + } + + $merged = &$into['records'][ $type ]; + if ( ! $merged['widened'] && ! $record['widened'] ) { + $merged['count'] += $record['count']; + foreach ( $record['keys'] as $key => $entry ) { + if ( ! isset( $merged['keys'][ $key ] ) ) { + $merged['keys'][ $key ] = $entry; + continue; + } + $merged['keys'][ $key ]['present'] += $entry['present']; + foreach ( $entry['types'] as $field_type => $count ) { + $merged['keys'][ $key ]['types'][ $field_type ] = ( isset( $merged['keys'][ $key ]['types'][ $field_type ] ) ? $merged['keys'][ $key ]['types'][ $field_type ] : 0 ) + $count; + } + if ( isset( $merged['keys'][ $key ]['types']['mixed'] ) || count( $merged['keys'][ $key ]['types'] ) > self::FIELD_TYPES ) { + $merged['keys'][ $key ]['types'] = array( 'mixed' => array_sum( $merged['keys'][ $key ]['types'] ) ); + } + } + } + + if ( $record['widened'] || count( $merged['keys'] ) > self::RECORD_KEYS ) { + $merged['widened'] = true; + } + if ( $merged['widened'] ) { + $merged['keys'] = array(); + + // The records of both are counted as plain values. + $recorded = isset( $into['types'][ "{$type}{...}" ] ) ? $into['types'][ "{$type}{...}" ] : array( 0, 0 ); + unset( $into['types'][ "{$type}{...}" ] ); + $into['types'][ $type ] = isset( $into['types'][ $type ] ) ? self::add_counts( $into['types'][ $type ], $recorded ) : $recorded; + } + unset( $merged ); + } + + return $into; + } + + /** + * Merges two slots' literals of one type, widening them past the limit. + * + * @param array $into Literals, from empty_literals(). + * @param array $other Other literals of the same type. + * @param int $limit Most distinct values to count apart. + * @return array + */ + private static function merge_literals( array $into, array $other, $limit ) { + $into['total'] = self::add_counts( $into['total'], $other['total'] ); + foreach ( $other['properties'] as $properties => $counts ) { + $into['properties'][ $properties ] = isset( $into['properties'][ $properties ] ) ? self::add_counts( $into['properties'][ $properties ], $counts ) : $counts; + } + if ( ! $into['widened'] && ! $other['widened'] ) { + foreach ( $other['values'] as $value => $counts ) { + $into['values'][ $value ] = isset( $into['values'][ $value ] ) ? self::add_counts( $into['values'][ $value ], $counts ) : $counts; + } + } + if ( $other['widened'] || count( $into['values'] ) > $limit ) { + $into['widened'] = true; + $into['values'] = array(); + } + + return $into; + } + + /** + * Adds two pairs of counts, from WordPress and from tests. + * + * @param int[] $a Counts. + * @param int[] $b Counts. + * @return int[] + */ + private static function add_counts( array $a, array $b ) { + return array( $a[0] + $b[0], $a[1] + $b[1] ); + } +} diff --git a/tests/invariants/includes/class-wp-invariant-violations.php b/tests/invariants/includes/class-wp-invariant-violations.php new file mode 100644 index 0000000000000..3dba9e3ec68d0 --- /dev/null +++ b/tests/invariants/includes/class-wp-invariant-violations.php @@ -0,0 +1,164 @@ + count) and `examples`. + * + * @var array[] + */ + private static $by_invariant = array(); + + /** + * Every violation in the running test, for the self-tests. + * + * @var array[] + */ + private static $in_test = array(); + + /** + * Attributes the violations which follow to a test. + * + * @param string $test Name of the test. + */ + public static function start_test( $test ) { + self::$test = $test; + self::$in_test = array(); + } + + /** + * Records a violation. + * + * @param WP_Invariant $invariant The invariant which was violated. + * @param string $message What was wrong. + * @param WP_Invariant_Call $call The call it was found on. + */ + public static function record( WP_Invariant $invariant, $message, WP_Invariant_Call $call ) { + if ( $invariant->ignores( $call ) ) { + return; + } + + self::add( $invariant->id(), 'violation', $message, $call->target(), $call->when(), $call->stack() ); + } + + /** + * Records a check which threw instead of finishing. + * + * @param WP_Invariant $invariant The invariant whose check threw. + * @param array $hook The hook, with `when` and `target`. + * @param WP_Invariant_Call|null $call The call, or null when it could not be read. + * @param Throwable $error What it threw. + */ + public static function record_hook_error( WP_Invariant $invariant, array $hook, $call, Throwable $error ) { + if ( $call && $invariant->ignores( $call ) ) { + return; + } + + $message = sprintf( 'The check threw %s: %s (%s:%d)', get_class( $error ), $error->getMessage(), WP_Invariant::relative_path( $error->getFile() ), $error->getLine() ); + self::add( $invariant->id(), 'hook-error', $message, $hook['target'], 'pre' === $hook['when'] ? 'before' : 'after', $call ? $call->stack() : array() ); + } + + /** + * Returns everything recorded, by invariant id. + * + * @return array[] + */ + public static function all() { + return self::$by_invariant; + } + + /** + * Returns the violations recorded in the running test, for the self-tests. + * + * @param string|null $id Only those of this invariant. + * @return array[] Each with `invariant`, `kind`, `message`, `target`, `when`, `test` and `stack`. + */ + public static function in_test( $id = null ) { + if ( null === $id ) { + return self::$in_test; + } + + return array_values( + array_filter( + self::$in_test, + static function ( $violation ) use ( $id ) { + return $violation['invariant'] === $id; + } + ) + ); + } + + /** + * Counts a violation, and keeps it when there are few examples of its invariant yet. + * + * @param string $id Invariant id. + * @param string $kind `violation` or `hook-error`. + * @param string $message What was wrong. + * @param string $target The hooked function or method. + * @param string $when `before` or `after`. + * @param string[] $stack Frames in the code under test. + */ + private static function add( $id, $kind, $message, $target, $when, array $stack ) { + if ( ! isset( self::$by_invariant[ $id ] ) ) { + self::$by_invariant[ $id ] = array( + 'violations' => 0, + 'hook_errors' => 0, + 'tests' => array(), + 'examples' => array(), + ); + } + + $entry = &self::$by_invariant[ $id ]; + if ( 'hook-error' === $kind ) { + ++$entry['hook_errors']; + } else { + ++$entry['violations']; + } + $entry['tests'][ self::$test ] = ( isset( $entry['tests'][ self::$test ] ) ? $entry['tests'][ self::$test ] : 0 ) + 1; + + $violation = array( + 'invariant' => $id, + 'kind' => $kind, + 'message' => $message, + 'target' => $target, + 'when' => $when, + 'test' => self::$test, + 'stack' => $stack, + ); + if ( count( $entry['examples'] ) < self::EXAMPLES ) { + $entry['examples'][] = $violation; + } + if ( count( self::$in_test ) < self::IN_TEST ) { + self::$in_test[] = $violation; + } + } +} diff --git a/tests/invariants/includes/class-wp-invariant.php b/tests/invariants/includes/class-wp-invariant.php new file mode 100644 index 0000000000000..e70d5625d9bab --- /dev/null +++ b/tests/invariants/includes/class-wp-invariant.php @@ -0,0 +1,1298 @@ +before()`. + * + * Example: + * + * WP_Invariant::define( 'query-is-counted' ) + * ->describe( 'A successful wpdb::query() adds to $wpdb->num_queries.' ) + * ->before( 'wpdb::query', static function ( WP_Invariant_Call $call ) { + * return $call->self()->num_queries; + * } ) + * ->after( 'wpdb::query', static function ( WP_Invariant_Call $call ) { + * // ...compare $call->self()->num_queries with $call->before(). + * } ); + * + * @see tests/invariants/README.md + */ +final class WP_Invariant { + + /** + * Full id, set when the spec file is loaded: the file's path under specs/, then the name. + * + * @var string + */ + private $id = ''; + + /** + * Name given to define(), unique within its spec file. + * + * @var string + */ + private $name; + + /** + * What must hold, in a sentence. + * + * @var string + */ + private $description = ''; + + /** + * Hooks, each with `when` (`pre` or `post`), `target` and `check`. + * + * @var array[] + */ + private $hooks = array(); + + /** + * Builds the starting state, or null when the invariant keeps none. + * + * @var callable|null + */ + private $state_initializer = null; + + /** + * When the state is reset: `test` before each test, or `run` never. + * + * @var string + */ + private $state_scope = 'test'; + + /** + * The state shared by this invariant's hooks. + * + * @var mixed + */ + private $state = null; + + /** + * Whether the state has been built since it was last reset. + * + * @var bool + */ + private $has_state = false; + + /** + * Whether calls made directly from test code are checked too. + * + * @var bool + */ + private $include_test_callers = false; + + /** + * For each paired target, the results of `before()` checks which wait for their `after()` check. + * + * Each entry is `array( depth, value )`, where depth is the depth of the call stack. + * + * @var array[] + */ + private $pending = array(); + + /** + * Every loaded invariant, by id. + * + * @var WP_Invariant[] + */ + private static $invariants = array(); + + /** + * Shared objects by name, each with its scope. + * + * @var array[] + */ + private static $shared = array(); + + /** + * Directories which hold test code, and which hold the code under test. + * + * @var array + */ + private static $paths = null; + + /** + * Properties property() has read: `Class::name` => ReflectionProperty. + * + * @var ReflectionProperty[] + */ + private static $properties = array(); + + /** + * Starts defining an invariant. + * + * @param string $name Name, unique within its spec file, such as `positive-id`. + * @return WP_Invariant + */ + public static function define( $name ) { + if ( ! is_string( $name ) || ! preg_match( '/^[a-z0-9][a-z0-9-]*$/', $name ) ) { + throw new InvalidArgumentException( 'An invariant name is lower-case letters, digits and hyphens, such as "positive-id".' ); + } + + $invariant = new self(); + $invariant->name = $name; + return $invariant; + } + + /** + * Says what must hold. This is shown with every violation. + * + * @param string $description One sentence. + * @return WP_Invariant + */ + public function describe( $description ) { + $this->description = $description; + return $this; + } + + /** + * Adds a check which runs just before each call to a function or method. + * + * The check receives a WP_Invariant_Call and, by reference, the invariant's + * state. When the invariant also has an `after()` hook on the same target, the + * check's return value is given to that hook as `$call->before()`. + * + * @param string $target Function or method, such as `get_post` or `wpdb::prepare`. + * @param callable $check `function ( WP_Invariant_Call $call, &$state )`. + * @return WP_Invariant + */ + public function before( $target, $check ) { + return $this->add_hook( 'pre', $target, $check ); + } + + /** + * Adds a check which runs just after each call to a function or method returns. + * + * It does not run when the call throws. The check sees the call's arguments as + * they were when the call started, and its return value. + * + * @param string $target Function or method, such as `get_post` or `wpdb::prepare`. + * @param callable $check `function ( WP_Invariant_Call $call, &$state )`. + * @return WP_Invariant + */ + public function after( $target, $check ) { + return $this->add_hook( 'post', $target, $check ); + } + + /** + * Adds the same before check to several functions or methods. + * + * @param string[] $targets Functions or methods. + * @param callable $check `function ( WP_Invariant_Call $call, &$state )`. + * @return WP_Invariant + */ + public function before_each( array $targets, $check ) { + foreach ( $targets as $target ) { + $this->before( $target, $check ); + } + return $this; + } + + /** + * Adds the same after check to several functions or methods. + * + * @param string[] $targets Functions or methods. + * @param callable $check `function ( WP_Invariant_Call $call, &$state )`. + * @return WP_Invariant + */ + public function after_each( array $targets, $check ) { + foreach ( $targets as $target ) { + $this->after( $target, $check ); + } + return $this; + } + + /** + * Gives the invariant's hooks a state to share. + * + * @param callable $initial Returns the starting state. + * @param string $scope `test` to start again before each test, or `run` to keep it for the whole run. + * @return WP_Invariant + */ + public function state( $initial, $scope = 'test' ) { + self::check_scope( $scope ); + $this->state_initializer = $initial; + $this->state_scope = $scope; + return $this; + } + + /** + * Reports violations on calls made directly from test code too. + * + * By default a violation on a call whose caller is in test code is not + * reported, because tests deliberately give invalid input to public functions. + * The default answers whether WordPress itself ever breaks the invariant. + * Invariants about what a function returns hold whoever calls it, and use this. + * + * The check runs either way; only reporting depends on the caller, so that + * the stack is only looked at when there is something to report. + * + * @return WP_Invariant + */ + public function include_test_callers() { + $this->include_test_callers = true; + return $this; + } + + /** + * Returns an object shared by any number of invariants, in any spec files. + * + * It is a plain object: give it whatever properties are needed. Its properties + * are removed before each test, or kept for the whole run. + * + * @param string $name Name, such as `wpdb/prepared`. + * @param string $scope `test` to empty it before each test, or `run` to keep it. + * @return stdClass + */ + public static function shared( $name, $scope = 'test' ) { + self::check_scope( $scope ); + if ( ! isset( self::$shared[ $name ] ) ) { + self::$shared[ $name ] = array( + 'object' => new stdClass(), + 'scope' => $scope, + ); + } elseif ( self::$shared[ $name ]['scope'] !== $scope ) { + throw new InvalidArgumentException( sprintf( 'The shared object "%s" is used with both the "test" and "run" scopes.', $name ) ); + } + + return self::$shared[ $name ]['object']; + } + + /** + * Returns the strings in a value: the value itself, or the strings in its arrays, at any depth. + * + * Objects are left out, because reading their properties can run code, such + * as WP_Post::__get(), which would change what the suite does. + * + * @param mixed $value Any value. + * @param int $limit Most strings to return. + * @return string[] + */ + public static function strings_in( $value, $limit = 200 ) { + if ( is_string( $value ) ) { + return array( $value ); + } + + $strings = array(); + if ( is_array( $value ) ) { + array_walk_recursive( + $value, + static function ( $item ) use ( &$strings, $limit ) { + if ( is_string( $item ) && count( $strings ) < $limit ) { + $strings[] = $item; + } + } + ); + } + + return $strings; + } + + /** + * Returns a property of an object, even a private or protected one. + * + * It reads the property with reflection, which runs no code of the object's, + * not even __get(). + * + * @param object $instance Object. + * @param string $declaring The class which declares the property: a private property belongs to its class, not to subclasses. + * @param string $name Property. + * @return mixed + */ + public static function property( $instance, $declaring, $name ) { + $key = "{$declaring}::{$name}"; + if ( ! isset( self::$properties[ $key ] ) ) { + self::$properties[ $key ] = new ReflectionProperty( $declaring, $name ); + } + + return self::$properties[ $key ]->getValue( $instance ); + } + + /** + * Returns the methods declared in the PHP files of a directory whose names match a pattern. + * + * The files are read as tokens, never included, so this works before WordPress + * loads, as spec files do. + * + * @param string $directory Directory, searched recursively. + * @param string $pattern Regular expression for method names, such as `/_permissions_check$/`. + * @return string[] Each as `Class::method`, sorted. + */ + public static function methods_in( $directory, $pattern ) { + $methods = array(); + foreach ( new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $directory, FilesystemIterator::SKIP_DOTS ) ) as $file ) { + if ( 'php' !== $file->getExtension() ) { + continue; + } + + $tokens = token_get_all( file_get_contents( $file->getPathname() ) ); + $class = null; + $depth = 0; + $body = null; + foreach ( $tokens as $index => $token ) { + if ( '{' === $token || ( is_array( $token ) && in_array( $token[0], array( T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES ), true ) ) ) { + ++$depth; + continue; + } + if ( '}' === $token ) { + --$depth; + if ( $depth === $body ) { + $class = null; + $body = null; + } + continue; + } + if ( ! is_array( $token ) ) { + continue; + } + + $next = self::next_token( $tokens, $index ); + if ( in_array( $token[0], array( T_CLASS, T_TRAIT, T_INTERFACE ), true ) && null === $class && is_array( $next ) && T_STRING === $next[0] ) { + // Not `Foo::class`, nor `new class`, which are followed by other tokens. + $class = $next[1]; + $body = $depth; + } elseif ( T_FUNCTION === $token[0] && null !== $class && $depth === $body + 1 && is_array( $next ) && T_STRING === $next[0] && preg_match( $pattern, $next[1] ) ) { + $methods[] = "{$class}::{$next[1]}"; + } + } + } + + sort( $methods ); + return $methods; + } + + /** + * Returns every named function and method declared in the PHP files of some directories. + * + * The files are read as tokens, never included, so this works before WordPress + * loads, as spec files do. Functions declared inside other blocks, such as + * `if ( ! function_exists() )`, are included. Closures, abstract methods, + * and the methods of interfaces and traits are not: they are never the target + * of a call, which for a trait method is the class which uses it. + * + * @param string[] $directories Directories, searched recursively. + * @param string[] $exclude Directories and files inside them to leave out. + * @return array[] Each with `target` (`name` or `Class::name`), `class`, `function`, + * `file`, `line`, `static`, `visibility` (`public`, `protected` or + * `private`), `magic`, `private` (named with `_`, or documented + * `@access private`, on itself or its class) and `deprecated`. + */ + public static function functions_in( array $directories, array $exclude = array() ) { + $exclude = array_map( + static function ( $path ) { + return rtrim( $path, '/' ); + }, + $exclude + ); + + $files = array(); + foreach ( $directories as $directory ) { + if ( ! is_dir( $directory ) ) { + continue; + } + foreach ( new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $directory, FilesystemIterator::SKIP_DOTS ) ) as $file ) { + $path = $file->getPathname(); + if ( 'php' !== $file->getExtension() ) { + continue; + } + foreach ( $exclude as $excluded ) { + if ( $path === $excluded || 0 === strpos( $path, $excluded . '/' ) ) { + continue 2; + } + } + $files[] = $path; + } + } + sort( $files ); + + $functions = array(); + foreach ( $files as $file ) { + foreach ( self::functions_in_file( $file ) as $function ) { + $functions[] = $function; + } + } + + return $functions; + } + + /** + * Returns the named functions and methods declared in one PHP file. + * + * @see WP_Invariant::functions_in() + * + * @param string $file PHP file. + * @return array[] + */ + private static function functions_in_file( $file ) { + $tokens = token_get_all( file_get_contents( $file ) ); + $functions = array(); + $depth = 0; + $class = null; + $body = null; + $doc = ''; + $modifiers = array(); + + foreach ( $tokens as $index => $token ) { + // A doc comment and modifiers belong to the declaration which follows them. + if ( '{' === $token || ';' === $token || '}' === $token ) { + $doc = ''; + $modifiers = array(); + } + + if ( '{' === $token || ( is_array( $token ) && in_array( $token[0], array( T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES ), true ) ) ) { + ++$depth; + continue; + } + if ( '}' === $token ) { + --$depth; + if ( $depth === $body ) { + $class = null; + $body = null; + } + continue; + } + if ( ! is_array( $token ) ) { + continue; + } + + switch ( $token[0] ) { + case T_DOC_COMMENT: + $doc = $token[1]; + continue 2; + + case T_PUBLIC: + case T_PROTECTED: + case T_PRIVATE: + case T_STATIC: + case T_ABSTRACT: + $modifiers[ $token[0] ] = true; + continue 2; + } + + $next = self::next_token( $tokens, $index ); + if ( in_array( $token[0], array( T_CLASS, T_TRAIT, T_INTERFACE, T_ENUM ), true ) && null === $class && is_array( $next ) && T_STRING === $next[0] ) { + // Not `Foo::class`, nor `new class`, which are followed by other tokens. + $class = array( + 'name' => $next[1], + 'methods' => T_CLASS === $token[0] || T_ENUM === $token[0], + 'private' => false !== stripos( $doc, '@access private' ), + ); + $body = $depth; + continue; + } + if ( T_FUNCTION !== $token[0] ) { + continue; + } + + // A function which returns by reference has `&` before its name, a token of its own since PHP 8.1. + $by_reference = '&' === $next || ( is_array( $next ) && T_AMPERSAND_NOT_FOLLOWED_BY_VAR_OR_VARARG === $next[0] ); + $name_index = $by_reference ? self::next_token_index( $tokens, self::next_token_index( $tokens, $index ) ) : self::next_token_index( $tokens, $index ); + $name = null === $name_index ? null : $tokens[ $name_index ]; + if ( ! is_array( $name ) || T_STRING !== $name[0] ) { + continue; + } + + $method = null !== $class && $depth === $body + 1; + if ( $method && ( ! $class['methods'] || isset( $modifiers[ T_ABSTRACT ] ) ) ) { + continue; + } + + $visibility = 'public'; + if ( $method && isset( $modifiers[ T_PROTECTED ] ) ) { + $visibility = 'protected'; + } elseif ( $method && isset( $modifiers[ T_PRIVATE ] ) ) { + $visibility = 'private'; + } + + $functions[] = array( + 'target' => $method ? "{$class['name']}::{$name[1]}" : $name[1], + 'class' => $method ? $class['name'] : null, + 'function' => $name[1], + 'file' => $file, + 'line' => $token[2], + 'static' => $method && isset( $modifiers[ T_STATIC ] ), + 'visibility' => $visibility, + 'magic' => $method && 0 === strpos( $name[1], '__' ), + 'private' => ( ! $method && '_' === $name[1][0] ) || false !== stripos( $doc, '@access private' ) || ( $method && $class['private'] ), + 'deprecated' => false !== stripos( $doc, '@deprecated' ), + ); + } + + return $functions; + } + + /** + * Returns an absolute path with its . and .. segments resolved, as the file system would resolve them without symbolic links. + * + * A relative path is read from the working directory. + * + * @param string $path A non-empty path. + * @return string + */ + public static function resolve_path( $path ) { + if ( '/' !== $path[0] ) { + $path = getcwd() . '/' . $path; + } + + $segments = array(); + foreach ( explode( '/', $path ) as $segment ) { + if ( '..' === $segment ) { + array_pop( $segments ); + } elseif ( '' !== $segment && '.' !== $segment ) { + $segments[] = $segment; + } + } + + return '/' . implode( '/', $segments ); + } + + /** + * Whether a resolved path is one of some directories, or inside one. + * + * @param string $path A path from resolve_path(), or a real path. + * @param string[] $directories Directories, each ending in a slash. + * @return bool + */ + public static function path_inside( $path, array $directories ) { + foreach ( $directories as $directory ) { + if ( 0 === strpos( rtrim( $path, '/' ) . '/', $directory ) ) { + return true; + } + } + + return false; + } + + /** + * Whether a path is inside some directories, as it resolves or through symbolic links. + * + * A path which does not exist yet is looked at through its nearest directory which does. + * + * @param string $path A non-empty path. + * @param string[] $directories Directories, as they resolve and as their real paths, each ending in a slash. + * @return bool + */ + public static function path_resolves_inside( $path, array $directories ) { + $resolved = self::resolve_path( $path ); + if ( self::path_inside( $resolved, $directories ) ) { + return true; + } + + $existing = $resolved; + $rest = ''; + while ( '/' !== $existing && ! file_exists( $existing ) ) { + $rest = '/' . basename( $existing ) . $rest; + $existing = dirname( $existing ); + } + $real = realpath( $existing ); + + return false !== $real && self::path_inside( rtrim( $real, '/' ) . $rest, $directories ); + } + + /** + * Returns a directory as it resolves and as its real path, each ending in a slash. + * + * @param string $directory A non-empty path. + * @return string[] + */ + public static function directory_forms( $directory ) { + $forms = array( rtrim( self::resolve_path( $directory ), '/' ) . '/' ); + $real = realpath( $directory ); + if ( false !== $real ) { + $forms[] = rtrim( $real, '/' ) . '/'; + } + + return array_values( array_unique( $forms ) ); + } + + /** + * Returns a mark for the innermost running call to a function, which running() can check later. + * + * A hook can open a scope for a call this way without an after hook, which + * would not run when the call throws, as AJAX handlers do through wp_die() in + * tests. + * + * @param string $name Function or method, such as `do_action` or `WP_REST_Server::respond_to_request`. + * @return int|null Position of its frame, counted from the outermost, or null when it is not running. + */ + public static function frame_mark( $name ) { + $frames = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS ); + $count = count( $frames ); + foreach ( $frames as $index => $frame ) { + if ( self::frame_name( $frame ) === $name ) { + return $count - $index; + } + } + + return null; + } + + /** + * Whether the call frame_mark() marked is still running. + * + * The call is still running when a frame of the same function is at the same + * position from the outermost frame. A later call to the same function at + * exactly the same depth, after the first one threw, looks the same. + * + * @param int $mark From frame_mark(). + * @param string $name The same function. + * @return bool + */ + public static function running( $mark, $name ) { + $frames = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS ); + $index = count( $frames ) - $mark; + + return $index >= 0 && isset( $frames[ $index ] ) && self::frame_name( $frames[ $index ] ) === $name; + } + + /** + * Returns the names of the functions and methods running, innermost first. + * + * @return string[] Such as `wp_generate_password` or `WP_Session_Tokens::create`. + */ + public static function running_functions() { + return array_map( array( __CLASS__, 'frame_name' ), debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS ) ); + } + + /** + * Returns the function or method of a frame, as `name` or `Class::name`. + * + * @param array $frame A frame from debug_backtrace(). + * @return string + */ + private static function frame_name( array $frame ) { + return isset( $frame['class'] ) ? $frame['class'] . '::' . $frame['function'] : $frame['function']; + } + + /** + * Returns a key which changes whenever a PHP file in some directories is added, removed or changed. + * + * It is computed once per process for each set of directories. + * + * @param string[] $directories Directories, searched recursively. + * @return string + */ + public static function source_key( array $directories ) { + static $keys = array(); + + $id = implode( "\0", $directories ); + if ( ! isset( $keys[ $id ] ) ) { + $parts = $directories; + foreach ( $directories as $directory ) { + if ( ! is_dir( $directory ) ) { + continue; + } + foreach ( new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $directory, FilesystemIterator::SKIP_DOTS ) ) as $file ) { + if ( 'php' === $file->getExtension() ) { + $parts[] = $file->getPathname() . ':' . $file->getMTime(); + } + } + } + $keys[ $id ] = md5( implode( "\n", $parts ) ); + } + + return $keys[ $id ]; + } + + /** + * Returns a value computed from the PHP files in some directories, from a cache in the temporary directory when they have not changed. + * + * Processes which PHPUnit starts for tests in separate processes reuse it. + * + * @param string $name Name of the cache, such as `switch-to-blog-callers`. + * @param string[] $directories Directories the value is computed from. + * @param callable $compute Computes the value, which serialize() can store. + * @return mixed + */ + public static function cached( $name, array $directories, $compute ) { + $key = self::source_key( $directories ); + $cache = sys_get_temp_dir() . "/wp-invariants-{$name}-" . md5( implode( "\0", $directories ) ) . '.cache'; + + $cached = is_readable( $cache ) ? unserialize( file_get_contents( $cache ), array( 'allowed_classes' => false ) ) : null; // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- A cache this class wrote. + if ( is_array( $cached ) && isset( $cached['key'] ) && $cached['key'] === $key && array_key_exists( 'value', $cached ) ) { + return $cached['value']; + } + + $value = $compute(); + $cached = array( + 'key' => $key, + 'value' => $value, + ); + file_put_contents( $cache, serialize( $cached ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- A cache for this class. + + return $value; + } + + /** + * Returns the named functions and methods, in the PHP files of some directories, whose own code calls a function. + * + * A call inside a closure belongs to the closure, not to the function around it. + * The files are read as tokens, never included. + * + * @param string[] $directories Directories, searched recursively. + * @param string $callee Name of the function called, such as `switch_to_blog`. + * @return string[] Targets, such as `wpmu_delete_blog` or `WP_Site_Query::get_sites`. + */ + public static function functions_calling( array $directories, $callee ) { + $callers = array(); + foreach ( $directories as $directory ) { + if ( ! is_dir( $directory ) ) { + continue; + } + foreach ( new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $directory, FilesystemIterator::SKIP_DOTS ) ) as $file ) { + if ( 'php' !== $file->getExtension() ) { + continue; + } + $code = file_get_contents( $file->getPathname() ); + if ( false === stripos( $code, $callee ) ) { + continue; + } + foreach ( self::functions_calling_in( $code, $callee ) as $caller ) { + $callers[ $caller ] = true; + } + } + } + + $callers = array_keys( $callers ); + sort( $callers ); + return $callers; + } + + /** + * Returns the named functions and methods in some PHP code whose own code calls a function. + * + * @see WP_Invariant::functions_calling() + * + * @param string $code PHP code. + * @param string $callee Name of the function called. + * @return string[] + */ + private static function functions_calling_in( $code, $callee ) { + $tokens = token_get_all( $code ); + $depth = 0; + $class = null; + $body = null; + $scopes = array(); // Each open function: its name, or null for a closure, and the depth of its body. + $pending = false; // The name of a function whose body has not opened yet, or null for a closure. + $callers = array(); + + foreach ( $tokens as $index => $token ) { + if ( '{' === $token || ( is_array( $token ) && in_array( $token[0], array( T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES ), true ) ) ) { + ++$depth; + if ( false !== $pending ) { + $scopes[] = array( $pending, $depth ); + $pending = false; + } + continue; + } + if ( '}' === $token ) { + if ( array() !== $scopes && end( $scopes )[1] === $depth ) { + array_pop( $scopes ); + } + --$depth; + if ( $depth === $body ) { + $class = null; + $body = null; + } + continue; + } + if ( ';' === $token && false !== $pending ) { + // An abstract or interface method has no body. + $pending = false; + continue; + } + if ( ! is_array( $token ) ) { + continue; + } + + $next = self::next_token( $tokens, $index ); + if ( in_array( $token[0], array( T_CLASS, T_TRAIT, T_INTERFACE, T_ENUM ), true ) && null === $class && is_array( $next ) && T_STRING === $next[0] ) { + $class = $next[1]; + $body = $depth; + continue; + } + + if ( T_FUNCTION === $token[0] || T_FN === $token[0] ) { + $by_reference = '&' === $next || ( is_array( $next ) && T_AMPERSAND_NOT_FOLLOWED_BY_VAR_OR_VARARG === $next[0] ); + $name_index = $by_reference ? self::next_token_index( $tokens, self::next_token_index( $tokens, $index ) ) : self::next_token_index( $tokens, $index ); + $name = null === $name_index ? null : $tokens[ $name_index ]; + if ( T_FUNCTION === $token[0] && is_array( $name ) && T_STRING === $name[0] ) { + $pending = null !== $class && $depth === $body + 1 ? "{$class}::{$name[1]}" : $name[1]; + } elseif ( T_FUNCTION === $token[0] ) { + $pending = null; + } + continue; + } + + if ( T_STRING !== $token[0] || 0 !== strcasecmp( $token[1], $callee ) || '(' !== $next || array() === $scopes ) { + continue; + } + + // Not a method, a declaration or a constant of that name. + $previous = null; + for ( $back = $index - 1; $back >= 0; $back-- ) { + if ( ! is_array( $tokens[ $back ] ) || ! in_array( $tokens[ $back ][0], array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) { + $previous = $tokens[ $back ]; + break; + } + } + if ( is_array( $previous ) && in_array( $previous[0], array( T_OBJECT_OPERATOR, T_NULLSAFE_OBJECT_OPERATOR, T_DOUBLE_COLON, T_FUNCTION, T_NEW ), true ) ) { + continue; + } + + $scope = end( $scopes ); + if ( null !== $scope[0] ) { + $callers[ $scope[0] ] = true; + } + } + + return array_keys( $callers ); + } + + /** + * Whether a filter or action has callbacks. + * + * It reads $wp_filter directly, and runs no hook. + * + * @param string $hook Filter or action. + * @return bool + */ + public static function has_callbacks( $hook ) { + return isset( $GLOBALS['wp_filter'][ $hook ] ) && $GLOBALS['wp_filter'][ $hook ]->has_filters(); + } + + /** + * Sets which directories hold test code, and which hold the code under test. + * + * @param array $paths { + * @type string[] $test Directories of test code. Calls from them are not checked by default. + * @type string[] $core Directories of the code under test. Only these frames are kept in stacks. + * } + */ + public static function configure_paths( array $paths ) { + self::$paths = array( + 'test' => array_map( array( __CLASS__, 'directory' ), $paths['test'] ), + 'core' => array_map( array( __CLASS__, 'directory' ), $paths['core'] ), + ); + } + + /** + * Loads every spec file in a directory, and returns the specs for the gameshark extension. + * + * Each file returns a list of invariants. The id of each is the file's path + * under the directory, without `.php`, then its name: `wpdb/prepared-structure`. + * + * The environment variable WP_INVARIANTS_SKIP leaves files out: a comma-separated + * list of their paths under the directory, without `.php`, such as `pcre,xml`. + * + * @param string $directory Directory of spec files. + * @return array[] One gameshark spec for each hook: `id`, `target`, `when` and `hook`. + */ + public static function load_specs( $directory ) { + $directory = rtrim( $directory, '/' ); + $files = array(); + foreach ( new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $directory, FilesystemIterator::SKIP_DOTS ) ) as $file ) { + if ( 'php' === $file->getExtension() ) { + $files[] = $file->getPathname(); + } + } + sort( $files ); + + $skip = array_filter( array_map( 'trim', explode( ',', (string) getenv( 'WP_INVARIANTS_SKIP' ) ) ) ); + $specs = array(); + foreach ( $files as $file ) { + $prefix = substr( $file, strlen( $directory ) + 1, -4 ); + if ( in_array( $prefix, $skip, true ) ) { + continue; + } + + $invariants = self::require_spec_file( $file ); + if ( ! is_array( $invariants ) ) { + throw new UnexpectedValueException( sprintf( '%s must return a list of invariants.', $file ) ); + } + + foreach ( $invariants as $invariant ) { + if ( ! $invariant instanceof self ) { + throw new UnexpectedValueException( sprintf( '%s must return only invariants made with WP_Invariant::define().', $file ) ); + } + + $invariant->id = $prefix . '/' . $invariant->name; + if ( isset( self::$invariants[ $invariant->id ] ) ) { + throw new UnexpectedValueException( sprintf( 'Two invariants in %s are named "%s".', $file, $invariant->name ) ); + } + if ( '' === $invariant->description ) { + throw new UnexpectedValueException( sprintf( 'The invariant %s needs a description: ->describe( \'...\' ).', $invariant->id ) ); + } + if ( array() === $invariant->hooks ) { + throw new UnexpectedValueException( sprintf( 'The invariant %s has no hooks: add ->before() or ->after().', $invariant->id ) ); + } + + self::$invariants[ $invariant->id ] = $invariant; + foreach ( $invariant->hooks as $index => $hook ) { + $specs[] = array( + 'id' => $invariant->id . '#' . $index, + 'target' => $hook['target'], + 'when' => $hook['when'], + 'hook' => $invariant->gameshark_hook( $index ), + ); + } + } + } + + return $specs; + } + + /** + * Starts a test: resets test-scoped state and shared objects, and attributes violations to the test. + * + * @param string $test Name of the test, as PHPUnit gives it. + */ + public static function start_test( $test ) { + WP_Invariant_Violations::start_test( $test ); + WP_Invariant_Request::reset(); + + foreach ( self::$invariants as $invariant ) { + // A call which is still running when a test starts is not paired across tests. + $invariant->pending = array(); + if ( 'test' === $invariant->state_scope ) { + $invariant->state = null; + $invariant->has_state = false; + } + } + + foreach ( self::$shared as $shared ) { + if ( 'test' === $shared['scope'] ) { + foreach ( array_keys( get_object_vars( $shared['object'] ) ) as $property ) { + unset( $shared['object']->$property ); + } + } + } + } + + /** + * Returns every loaded invariant, by id. + * + * @return WP_Invariant[] + */ + public static function all() { + return self::$invariants; + } + + /** + * Returns the invariant's full id, such as `wpdb/prepared-structure`. + * + * @return string + */ + public function id() { + return $this->id; + } + + /** + * Returns what must hold. + * + * @return string + */ + public function description() { + return $this->description; + } + + /** + * Returns the invariant's hooks, each with `when` and `target`. + * + * @return array[] + */ + public function hooks() { + return array_map( + static function ( $hook ) { + return array( + 'when' => $hook['when'], + 'target' => $hook['target'], + ); + }, + $this->hooks + ); + } + + /** + * Whether a violation on a call is left out of the report, because test code made the call. + * + * @param WP_Invariant_Call $call The call. + * @return bool + */ + public function ignores( WP_Invariant_Call $call ) { + return ! $this->include_test_callers && self::is_test_file( $call->caller()['file'] ); + } + + /** + * Returns the invariant's current state, for the self-tests. + * + * @return mixed + */ + public function current_state() { + return $this->state; + } + + /** + * Whether a file holds test code. + * + * @param string|null $file Path of a file. + * @return bool + */ + public static function is_test_file( $file ) { + return self::path_in( $file, self::paths()['test'] ); + } + + /** + * Whether a file holds code under test. + * + * @param string|null $file Path of a file. + * @return bool + */ + public static function is_core_file( $file ) { + return self::path_in( $file, self::paths()['core'] ); + } + + /** + * Returns a path relative to the repository, for reports. + * + * @param string $file Path of a file. + * @return string + */ + public static function relative_path( $file ) { + $root = self::directory( dirname( __DIR__, 3 ) ); + return 0 === strpos( $file, $root ) ? substr( $file, strlen( $root ) ) : $file; + } + + /** + * Runs one of the invariant's hooks for a call. The gameshark extension calls this through gameshark_hook(). + * + * Nothing it throws reaches the code under test: a check which fails with an + * exception is recorded as a hook error. Set the environment variable + * WP_INVARIANTS_THROW to rethrow instead, for debugging. + * + * @internal + * + * @param int $index Which hook. + * @param array $params What the extension passed to the hook. + */ + public function run( $index, array $params ) { + $hook = $this->hooks[ $index ]; + $call = null; + + try { + $call = new WP_Invariant_Call( $this, $hook['when'], $hook['target'], $params ); + $paired = $hook['paired']; + + if ( $paired ) { + // The hooks for one call run at the same depth; deeper entries were left by calls which threw. + $depth = count( debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS ) ); + $key = $hook['key']; + if ( 'post' === $hook['when'] ) { + while ( ! empty( $this->pending[ $key ] ) && end( $this->pending[ $key ] )[0] > $depth ) { + array_pop( $this->pending[ $key ] ); + } + if ( ! empty( $this->pending[ $key ] ) && end( $this->pending[ $key ] )[0] === $depth ) { + $call->set_before( array_pop( $this->pending[ $key ] )[1] ); + } + } + } + + if ( ! $this->has_state && null !== $this->state_initializer ) { + $this->state = call_user_func( $this->state_initializer ); + $this->has_state = true; + } + + $check = $hook['check']; + $result = $check( $call, $this->state ); + + if ( $paired && 'pre' === $hook['when'] ) { + $this->pending[ $key ][] = array( $depth, $result ); + } + } catch ( Throwable $error ) { + if ( getenv( 'WP_INVARIANTS_THROW' ) ) { + throw $error; + } + + WP_Invariant_Violations::record_hook_error( $this, $hook, $call, $error ); + } + } + + /** + * Returns the callable given to the gameshark extension for one hook. + * + * @param int $index Which hook. + * @return Closure + */ + private function gameshark_hook( $index ) { + $invariant = $this; + return static function ( ...$params ) use ( $invariant, $index ) { + $invariant->run( $index, $params ); + }; + } + + /** + * Adds a hook. + * + * @param string $when `pre` or `post`. + * @param string $target Function or method. + * @param callable $check The check. + * @return WP_Invariant + */ + private function add_hook( $when, $target, $check ) { + if ( ! is_string( $target ) || ! preg_match( '/^\\\\?[A-Za-z_][A-Za-z0-9_\\\\]*(::[A-Za-z_][A-Za-z0-9_]*)?$/', $target ) ) { + throw new InvalidArgumentException( sprintf( 'The invariant "%s" has a target which is not a function or Class::method name.', $this->name ) ); + } + if ( ! is_callable( $check ) ) { + throw new InvalidArgumentException( sprintf( 'The invariant "%s" has a check for %s which is not callable.', $this->name, $target ) ); + } + + $target = ltrim( $target, '\\' ); + $key = strtolower( $target ); + $this->hooks[] = array( + 'when' => $when, + 'target' => $target, + 'check' => $check, + 'key' => $key, + 'paired' => false, + ); + + // A before and an after hook on the same target are paired; work that out once, not on every call. + $whens = array(); + foreach ( $this->hooks as $hook ) { + if ( $hook['key'] === $key ) { + $whens[ $hook['when'] ] = true; + } + } + if ( isset( $whens['pre'], $whens['post'] ) ) { + foreach ( $this->hooks as $index => $hook ) { + if ( $hook['key'] === $key ) { + $this->hooks[ $index ]['paired'] = true; + } + } + } + + return $this; + } + + /** + * Requires a spec file in its own scope. + * + * @param string $file Path of the spec file. + * @return mixed What the file returns. + */ + private static function require_spec_file( $file ) { + return require $file; + } + + /** + * Rejects a scope other than `test` or `run`. + * + * @param string $scope Scope. + */ + private static function check_scope( $scope ) { + if ( 'test' !== $scope && 'run' !== $scope ) { + throw new InvalidArgumentException( 'A scope is "test" or "run".' ); + } + } + + /** + * Returns the configured directories, defaulting to the WordPress test suite. + * + * @return array + */ + private static function paths() { + if ( null === self::$paths ) { + $root = dirname( __DIR__, 3 ); + self::configure_paths( + array( + 'test' => array( $root . '/tests' ), + 'core' => array( $root . '/src', $root . '/build' ), + ) + ); + } + + return self::$paths; + } + + /** + * Whether a file is in one of the directories. + * + * @param string|null $file Path of a file. + * @param string[] $directories Directories, each ending in a slash. + * @return bool + */ + private static function path_in( $file, array $directories ) { + if ( ! is_string( $file ) ) { + return false; + } + + foreach ( $directories as $directory ) { + if ( 0 === strpos( $file, $directory ) ) { + return true; + } + } + + return false; + } + + /** + * Returns the next token which is not whitespace or a comment. + * + * @param array $tokens Tokens from token_get_all(). + * @param int $index Position to look after. + * @return array|string|null + */ + private static function next_token( array $tokens, $index ) { + $next = self::next_token_index( $tokens, $index ); + return null === $next ? null : $tokens[ $next ]; + } + + /** + * Returns where the next token is which is not whitespace or a comment. + * + * @param array $tokens Tokens from token_get_all(). + * @param int|null $index Position to look after. + * @return int|null + */ + private static function next_token_index( array $tokens, $index ) { + if ( null === $index ) { + return null; + } + + $count = count( $tokens ); + for ( $i = $index + 1; $i < $count; $i++ ) { + if ( ! is_array( $tokens[ $i ] ) || ! in_array( $tokens[ $i ][0], array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) { + return $i; + } + } + + return null; + } + + /** + * Normalizes a directory to its real path, ending in a slash. + * + * @param string $directory Directory. + * @return string + */ + private static function directory( $directory ) { + $real = realpath( $directory ); + return rtrim( false === $real ? $directory : $real, '/' ) . '/'; + } +} diff --git a/tests/invariants/includes/load.php b/tests/invariants/includes/load.php new file mode 100644 index 0000000000000..0f854cccb0d97 --- /dev/null +++ b/tests/invariants/includes/load.php @@ -0,0 +1,58 @@ +', LIBXML_NOERROR )->body; + $body->innerHTML = $html; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A property of the Dom API. + return $body; + } + + /** + * Returns every element a browser would build from HTML, as the contents of a BODY element. + * + * Elements inside TEMPLATE elements are included: a browser does not render + * them, but script can clone them into the page. + * + * @param string $html HTML. + * @return Dom\Element[] + */ + public static function elements( $html ) { + $elements = array(); + foreach ( self::body( $html )->querySelectorAll( '*' ) as $element ) { + $elements[] = $element; + if ( 'template' === self::tag_name( $element ) ) { + $elements = array_merge( $elements, self::elements( $element->innerHTML ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A property of the Dom API. + } + } + + return $elements; + } + + /** + * Returns how a browser would serialize HTML after parsing it as the contents of a BODY element. + * + * Two pieces of HTML which serialize the same build the same tree. + * + * @param string $html HTML. + * @return string + */ + public static function tree( $html ) { + return self::body( $html )->innerHTML; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A property of the Dom API. + } + + /** + * Returns the lower-case name of an element, such as `a` or `foreignobject`. + * + * @param Dom\Element $element Element. + * @return string + */ + public static function tag_name( $element ) { + return strtolower( $element->localName ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A property of the Dom API. + } + + /** + * Returns how a browser reads the value of an attribute, given its markup between double quotes. + * + * @param string $value Attribute value, as it appears in HTML. + * @return string|null The value the browser sees, or null when the markup does not give one attribute. + */ + public static function attribute_value( $value ) { + $elements = self::elements( '' ); + if ( 1 !== count( $elements ) || 1 !== count( $elements[0]->attributes ) ) { + return null; + } + + return $elements[0]->getAttribute( 'href' ); + } + + /** + * Returns the scheme a browser would give a URL, or null when it is relative or cannot be parsed. + * + * A relative URL, such as `/path` or `//host/path`, takes the scheme of the page it is on. + * + * @param string $url URL, as a browser would read it from an attribute. + * @return string|null Lower-case scheme. + */ + public static function url_scheme( $url ) { + $parsed = Uri\WhatWg\Url::parse( $url ); + return null === $parsed ? null : $parsed->getScheme(); + } + + /** + * Returns the host a browser would go to for a URL, such as one in a Location header, or null when it cannot be parsed. + * + * @param string $url URL. + * @param string $base Absolute URL a relative one is resolved against. + * @return string|null Host, in ASCII and lower case. Empty for a URL without one, such as `mailto:a@example.org`. + */ + public static function url_host( $url, $base ) { + $parsed = Uri\WhatWg\Url::parse( $url, Uri\WhatWg\Url::parse( $base ) ); + return null === $parsed ? null : (string) $parsed->getAsciiHost(); + } + + /** + * Returns how a browser reads one start tag: its name, and the value of each attribute. + * + * The tag is parsed inside a TEMPLATE element, where a browser keeps tags it + * would move or drop elsewhere, such as TD, and the template's contents are + * read back from their serialization, which writes every attribute as + * `name="value"` with `&`, `"`, `<`, `>` and U+00A0 escaped. + * + * @param string $tag Markup of one start tag, such as ``. + * @return array|null `name`, lower case, and `attributes`, lower-case name => value, as the browser keeps them: the first of duplicates. Null when the browser builds no element from it. + */ + public static function start_tag( $tag ) { + $document = Dom\HTMLDocument::createFromString( '', LIBXML_NOERROR ); + $template = $document->body->firstElementChild; + if ( null === $template || 'template' !== self::tag_name( $template ) ) { + return null; + } + + $html = $template->innerHTML; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A property of the Dom API. + if ( ! preg_match( '~^<([^\s/>]+)((?:\s+[^\s="/>]+(?:="[^"]*")?)*)>~', $html, $match ) ) { + return null; + } + + preg_match_all( '~\s+([^\s="/>]+)(?:="([^"]*)")?~', $match[2], $attributes, PREG_SET_ORDER ); + $values = array(); + foreach ( $attributes as $attribute ) { + $name = strtolower( $attribute[1] ); + if ( ! isset( $values[ $name ] ) ) { + $values[ $name ] = html_entity_decode( isset( $attribute[2] ) ? $attribute[2] : '', ENT_QUOTES | ENT_HTML5, 'UTF-8' ); + } + } + + return array( + 'name' => strtolower( $match[1] ), + 'attributes' => $values, + ); + } + + /** + * Returns the value a browser gives a TEXTAREA, or null when the markup does not stay one TEXTAREA. + * + * @param string $html What goes between ``. + * @return string|null + */ + public static function textarea_value( $html ) { + $body = self::body( '' ); + if ( 1 !== $body->childNodes->length || ! $body->firstChild instanceof Dom\Element || 'textarea' !== self::tag_name( $body->firstChild ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Properties of the Dom API. + return null; + } + + return $body->firstChild->textContent; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Properties of the Dom API. + } + + /** + * Returns the nodes a browser builds directly in a BODY element from HTML. + * + * @param string $html HTML. + * @return string[][] Each as `array( type, text )`: `comment` and its data, `text` and its text, or `element` and its lower-case name. + */ + public static function nodes( $html ) { + $nodes = array(); + foreach ( self::body( $html )->childNodes as $node ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A property of the Dom API. + if ( $node instanceof Dom\Comment ) { + $nodes[] = array( 'comment', $node->data ); + } elseif ( $node instanceof Dom\Element ) { + $nodes[] = array( 'element', self::tag_name( $node ) ); + } else { + $nodes[] = array( 'text', $node->textContent ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A property of the Dom API. + } + } + + return $nodes; + } + + /** + * Returns whether a string is a well-formed XML document. + * + * @param string $xml XML. + * @return bool + */ + public static function xml_well_formed( $xml ) { + try { + Dom\XMLDocument::createFromString( $xml, LIBXML_NOERROR | LIBXML_NONET ); + return true; + } catch ( Exception $e ) { + return false; + } + } + + /** + * Returns how many child elements XML content would have inside an element, or null when it is not well-formed. + * + * @param string $xml XML content. + * @return int|null + */ + public static function xml_child_elements( $xml ) { + try { + return Dom\XMLDocument::createFromString( '' . $xml . '', LIBXML_NOERROR )->documentElement->childElementCount; + } catch ( Exception $e ) { + return null; + } + } +} diff --git a/tests/invariants/includes/oracles/class-wp-invariant-html-api.php b/tests/invariants/includes/oracles/class-wp-invariant-html-api.php new file mode 100644 index 0000000000000..793622c0a7f86 --- /dev/null +++ b/tests/invariants/includes/oracles/class-wp-invariant-html-api.php @@ -0,0 +1,163 @@ +next_token() ) { + switch ( $processor->get_token_type() ) { + case '#tag': + $kind = ( $processor->is_tag_closer() ? 'get_tag() ); + break; + + case '#comment': + case '#funky-comment': + $kind = ' + + + + ../phpunit/tests + ../phpunit/tests/rest-api/rest-autosaves-controller.php + + + + ../phpunit/tests/rest-api/rest-autosaves-controller.php + + + + + ajax + ms-files + ms-required + external-http + html-api-web-platform-tests + + + + + + + + + + + + invariants + + + + diff --git a/tests/invariants/self-tests/bootstrap.php b/tests/invariants/self-tests/bootstrap.php new file mode 100644 index 0000000000000..90286677cfb6a --- /dev/null +++ b/tests/invariants/self-tests/bootstrap.php @@ -0,0 +1,19 @@ +switch_to_blog( 2 ); +} diff --git a/tests/invariants/self-tests/fixtures/class-wp-invariants-fixture-properties.php b/tests/invariants/self-tests/fixtures/class-wp-invariants-fixture-properties.php new file mode 100644 index 0000000000000..3de9e8bf70003 --- /dev/null +++ b/tests/invariants/self-tests/fixtures/class-wp-invariants-fixture-properties.php @@ -0,0 +1,45 @@ +magic_reads; + return null; + } +} diff --git a/tests/invariants/self-tests/fixtures/class-wp-invariants-fixture.php b/tests/invariants/self-tests/fixtures/class-wp-invariants-fixture.php new file mode 100644 index 0000000000000..02e688510fd15 --- /dev/null +++ b/tests/invariants/self-tests/fixtures/class-wp-invariants-fixture.php @@ -0,0 +1,41 @@ +total += $n; + return $this->total; + } + + /** + * Doubles a number. + * + * @param int $n Number. + * @return int + */ + public static function twice( $n ) { + return 2 * $n; + } +} diff --git a/tests/invariants/self-tests/fixtures/core.php b/tests/invariants/self-tests/fixtures/core.php new file mode 100644 index 0000000000000..0afbbdc51b927 --- /dev/null +++ b/tests/invariants/self-tests/fixtures/core.php @@ -0,0 +1,114 @@ + 0 ) { + wp_invariants_fixture_recursive( $n - 1 ); + } + return $n; +} + +/** + * Calls itself down to 0, where it throws; each level above catches it and returns. + * + * @param int $n Depth. + * @return int + */ +function wp_invariants_fixture_throwing( $n ) { + if ( 0 === $n ) { + throw new RuntimeException( 'The innermost call throws.' ); + } + + try { + wp_invariants_fixture_throwing( $n - 1 ); + } catch ( RuntimeException $e ) { + unset( $e ); + } + return $n; +} + +/** + * Does nothing; the invariants count its calls. + */ +function wp_invariants_fixture_counter() { +} + +/** + * Does nothing; an invariant's check on it throws. + * + * @param mixed $value Anything. + * @return mixed The value. + */ +function wp_invariants_fixture_error( $value ) { + return $value; +} + +/** + * Calls wp_invariants_fixture_inner(), one call deeper. + * + * @return int + */ +function wp_invariants_fixture_outer() { + return wp_invariants_fixture_inner(); +} + +/** + * Does nothing; it is called by wp_invariants_fixture_outer(). + * + * @return int + */ +function wp_invariants_fixture_inner() { + return 1; +} + +/** + * Takes one declared argument and any number of others, and has a local variable. + * + * PHP keeps the arguments beyond those a function declares apart from the + * declared ones, after its local variables. + * + * @param mixed $first First argument. + * @param mixed ...$rest Other arguments. + * @return int How many other arguments there were. + */ +function wp_invariants_fixture_variadic( $first, ...$rest ) { + $local = array( $first, $rest ); + return count( $local[1] ); +} diff --git a/tests/invariants/self-tests/fixtures/methods/class-wp-invariants-fixture-unloaded.php b/tests/invariants/self-tests/fixtures/methods/class-wp-invariants-fixture-unloaded.php new file mode 100644 index 0000000000000..f4269f53af6db --- /dev/null +++ b/tests/invariants/self-tests/fixtures/methods/class-wp-invariants-fixture-unloaded.php @@ -0,0 +1,45 @@ +describe( 'Loaded by the self-tests of WP_INVARIANTS_SKIP.' ) + ->before( + 'wp_invariants_fixture_counter', + static function () {} + ), +); diff --git a/tests/invariants/self-tests/fixtures/skip-specs/skipped.php b/tests/invariants/self-tests/fixtures/skip-specs/skipped.php new file mode 100644 index 0000000000000..48984160b4e4a --- /dev/null +++ b/tests/invariants/self-tests/fixtures/skip-specs/skipped.php @@ -0,0 +1,9 @@ + array( __DIR__ . '/tests' ), + 'core' => array( __DIR__ . '/fixtures' ), + ) +); + +// The functions in fixtures/types/ are hooked for the types report, as WordPress's functions are with WP_INVARIANTS_TYPES=1. +return array_merge( + WP_Invariant::load_specs( __DIR__ . '/specs' ), + WP_Invariant_Types::specs( WP_Invariant::functions_in( array( __DIR__ . '/fixtures/types' ) ), array( __DIR__ . '/fixtures/types' ), array( __DIR__ . '/tests' ) ) +); diff --git a/tests/invariants/self-tests/phpunit.xml.dist b/tests/invariants/self-tests/phpunit.xml.dist new file mode 100644 index 0000000000000..67e71d2ab8db6 --- /dev/null +++ b/tests/invariants/self-tests/phpunit.xml.dist @@ -0,0 +1,32 @@ + + + + + + tests + + + + + + invariants-self-tests + + + + diff --git a/tests/invariants/self-tests/specs/fixtures.php b/tests/invariants/self-tests/specs/fixtures.php new file mode 100644 index 0000000000000..aa45896b54109 --- /dev/null +++ b/tests/invariants/self-tests/specs/fixtures.php @@ -0,0 +1,183 @@ +describe( 'wp_invariants_fixture_positive() is given a number above 0.' ) + ->before( + 'wp_invariants_fixture_positive', + static function ( WP_Invariant_Call $call ) { + $call->expect( $call->arg( 'n' ) > 0, 'It was given %s.', $call->arg( 'n' ) ); + } + ), + + WP_Invariant::define( 'positive-from-anywhere' ) + ->describe( 'wp_invariants_fixture_positive() is given a number above 0, even by test code.' ) + ->include_test_callers() + ->before( + 'wp_invariants_fixture_positive', + static function ( WP_Invariant_Call $call ) { + $call->expect( $call->arg( 'n' ) > 0, 'It was given %s.', $call->arg( 'n' ) ); + } + ), + + WP_Invariant::define( 'label-is-default' ) + ->describe( 'wp_invariants_fixture_positive() is called with its default label.' ) + ->before( + 'wp_invariants_fixture_positive', + static function ( WP_Invariant_Call $call ) { + $call->expect( 'default' === $call->arg( 'label' ), 'Its label was %s.', $call->arg( 'label' ) ); + } + ), + + WP_Invariant::define( 'entry-values' ) + ->describe( 'An after check sees the arguments as they were when the call started.' ) + ->after( + 'wp_invariants_fixture_positive', + static function ( WP_Invariant_Call $call ) { + $call->expect( $call->arg( 'n' ) === $call->return_value(), 'It was given %s and returned %s.', $call->arg( 'n' ), $call->return_value() ); + } + ), + + WP_Invariant::define( 'paired-recursion' ) + ->describe( 'Each after check gets the result of the before check of its own call, through recursion.' ) + ->before( + 'wp_invariants_fixture_recursive', + static function ( WP_Invariant_Call $call ) { + return $call->arg( 'n' ); + } + ) + ->after( + 'wp_invariants_fixture_recursive', + static function ( WP_Invariant_Call $call ) { + $call->expect( $call->before() === $call->arg( 'n' ), 'The call with %s got the before result %s.', $call->arg( 'n' ), $call->before() ); + } + ), + + WP_Invariant::define( 'paired-throw' ) + ->describe( 'Each after check gets the result of the before check of its own call, when a call inside it threw.' ) + ->before( + 'wp_invariants_fixture_throwing', + static function ( WP_Invariant_Call $call ) { + return $call->arg( 'n' ); + } + ) + ->after( + 'wp_invariants_fixture_throwing', + static function ( WP_Invariant_Call $call ) { + $call->expect( $call->before() === $call->arg( 'n' ), 'The call with %s got the before result %s.', $call->arg( 'n' ), $call->before() ); + } + ), + + WP_Invariant::define( 'method-self' ) + ->describe( 'An instance method check gets the object, with its state after the call.' ) + ->after( + 'WP_Invariants_Fixture::add', + static function ( WP_Invariant_Call $call ) { + $self = $call->self(); + $call->expect( $self instanceof WP_Invariants_Fixture && $self->total === $call->return_value(), 'The object was %s and the call returned %s.', $self, $call->return_value() ); + } + ), + + WP_Invariant::define( 'static-method' ) + ->describe( 'A static method check gets no object, and the arguments in order.' ) + ->before( + 'WP_Invariants_Fixture::twice', + static function ( WP_Invariant_Call $call ) { + $call->expect( null === $call->self() && array( 7 ) === $call->args(), 'It got the object %s and the arguments %s.', $call->self(), $call->args() ); + } + ), + + WP_Invariant::define( 'counted-per-test' ) + ->describe( 'Counts calls to wp_invariants_fixture_counter() in each test, in its own state and in a shared object.' ) + ->state( + static function () { + return array( 'calls' => 0 ); + } + ) + ->before( + 'wp_invariants_fixture_counter', + static function ( WP_Invariant_Call $call, array &$state ) { + ++$state['calls']; + + $shared = WP_Invariant::shared( 'self-test/counter' ); + $shared->calls = isset( $shared->calls ) ? $shared->calls + 1 : 1; + } + ), + + WP_Invariant::define( 'counted-per-run' ) + ->describe( 'Counts calls to wp_invariants_fixture_counter() over the whole run.' ) + ->state( + static function () { + return array( 'calls' => 0 ); + }, + 'run' + ) + ->before( + 'wp_invariants_fixture_counter', + static function ( WP_Invariant_Call $call, array &$state ) { + ++$state['calls']; + } + ), + + WP_Invariant::define( 'depth' ) + ->describe( 'A call a target makes is one deeper than the target, and a check on each target runs once per call.' ) + ->after_each( + array( 'wp_invariants_fixture_inner', 'wp_invariants_fixture_outer' ), + static function ( WP_Invariant_Call $call ) { + $depths = WP_Invariant::shared( 'self-test/depths' ); + $depths->seen[ $call->target() ] = $call->depth(); + } + ), + + WP_Invariant::define( 'caller-args' ) + ->describe( 'A check can see the arguments of the function which called its target.' ) + ->include_test_callers() + ->before( + 'wp_invariants_fixture_positive', + static function ( WP_Invariant_Call $call ) { + WP_Invariant::shared( 'self-test/caller-args' )->seen = $call->caller_args(); + } + ), + + WP_Invariant::define( 'pcre-error-survives' ) + ->describe( 'A check on a PCRE function sees the error of the call it is checking.' ) + ->include_test_callers() + ->after( + 'preg_match', + static function ( WP_Invariant_Call $call ) { + $error = preg_last_error(); + + WP_Invariant::shared( 'self-test/pcre' )->errors[] = array( $call->arg( 'pattern' ), $error ); + } + ), + + WP_Invariant::define( 'extra-arguments' ) + ->describe( 'Checks see every argument of a variadic function, before and after the call. The gameshark extension at a7f9b79 passed undefined values instead of the extra ones.' ) + ->before( + 'wp_invariants_fixture_variadic', + static function ( WP_Invariant_Call $call ) { + $call->expect( array( 1, 'two', array( 3 ) ) === $call->args() && array( 'two', array( 3 ) ) === $call->arg( 'rest' ), 'A before check got %s.', $call->args() ); + } + ) + ->after( + 'wp_invariants_fixture_variadic', + static function ( WP_Invariant_Call $call ) { + $call->expect( array( 1, 'two', array( 3 ) ) === $call->args() && 2 === $call->return_value(), 'An after check got %s.', $call->args() ); + } + ), + + WP_Invariant::define( 'check-throws' ) + ->describe( 'A check which throws is recorded as a hook error.' ) + ->before( + 'wp_invariants_fixture_error', + static function () { + throw new RuntimeException( 'This check always throws.' ); + } + ), +); diff --git a/tests/invariants/self-tests/tests/test-helpers.php b/tests/invariants/self-tests/tests/test-helpers.php new file mode 100644 index 0000000000000..e70f10101f223 --- /dev/null +++ b/tests/invariants/self-tests/tests/test-helpers.php @@ -0,0 +1,82 @@ +title = 'hidden'; + + $this->assertSame( array( 'a' ), WP_Invariant::strings_in( 'a' ) ); + $this->assertSame( array( 'a', 'b', 'c' ), WP_Invariant::strings_in( array( 'a', array( 'b', 3, array( 'c' ) ), $object ) ) ); + $this->assertSame( array(), WP_Invariant::strings_in( $object ) ); + $this->assertCount( 2, WP_Invariant::strings_in( array( 'a', 'b', 'c' ), 2 ) ); + } + + public function test_depth_is_one_more_for_a_call_a_target_makes() { + wp_invariants_fixture_call( 'wp_invariants_fixture_outer' ); + + $seen = WP_Invariant::shared( 'self-test/depths' )->seen; + $this->assertSame( $seen['wp_invariants_fixture_outer'] + 1, $seen['wp_invariants_fixture_inner'] ); + } + + public function test_caller_args_are_the_arguments_of_the_function_which_called_the_target() { + wp_invariants_fixture_call( 'wp_invariants_fixture_positive', 5, 'label' ); + + $this->assertSame( array( 'wp_invariants_fixture_positive', 5, 'label' ), WP_Invariant::shared( 'self-test/caller-args' )->seen ); + } + + public function test_check_on_a_pcre_function_sees_its_error() { + wp_invariants_fixture_call( 'preg_match', '/(a+)+$/', str_repeat( 'a', 30000 ) . 'b' ); + wp_invariants_fixture_call( 'preg_match', '/a/', 'a' ); + + // PHPUnit calls preg_match() too; only these two calls matter. + $errors = array_values( + array_filter( + WP_Invariant::shared( 'self-test/pcre' )->errors, + static function ( $error ) { + return in_array( $error[0], array( '/(a+)+$/', '/a/' ), true ); + } + ) + ); + $this->assertSame( array( array( '/(a+)+$/', PREG_BACKTRACK_LIMIT_ERROR ), array( '/a/', PREG_NO_ERROR ) ), $errors ); + } + + public function test_property_reads_private_and_protected_properties_without_running_code() { + $fixture = new WP_Invariants_Fixture_Properties(); + + $this->assertSame( 'private', WP_Invariant::property( $fixture, 'WP_Invariants_Fixture_Properties', 'hidden' ) ); + $this->assertSame( 'protected', WP_Invariant::property( $fixture, 'WP_Invariants_Fixture_Properties', 'kept' ) ); + $this->assertSame( 0, $fixture->magic_reads ); + } + + public function test_methods_in_lists_methods_declared_in_files_without_loading_them() { + $this->assertSame( + array( 'WP_Invariants_Fixture_Unloaded::get_item_permissions_check', 'WP_Invariants_Fixture_Unloaded::get_items_permissions_check' ), + WP_Invariant::methods_in( dirname( __DIR__ ) . '/fixtures/methods', '/_permissions_check$/' ) + ); + $this->assertFalse( class_exists( 'WP_Invariants_Fixture_Unloaded', false ) ); + } + + public function test_skipped_spec_files_are_not_loaded() { + $previous = getenv( 'WP_INVARIANTS_SKIP' ); + putenv( 'WP_INVARIANTS_SKIP=skipped' ); + try { + $specs = WP_Invariant::load_specs( dirname( __DIR__ ) . '/fixtures/skip-specs' ); + } finally { + putenv( false === $previous ? 'WP_INVARIANTS_SKIP' : "WP_INVARIANTS_SKIP={$previous}" ); + } + + $this->assertSame( array( 'kept/kept#0' ), array_column( $specs, 'id' ) ); + } +} diff --git a/tests/invariants/self-tests/tests/test-hooks.php b/tests/invariants/self-tests/tests/test-hooks.php new file mode 100644 index 0000000000000..db96828a95c38 --- /dev/null +++ b/tests/invariants/self-tests/tests/test-hooks.php @@ -0,0 +1,161 @@ +assertArrayHasKey( 'fixtures/positive', WP_Invariant::all() ); + $this->assertArrayHasKey( 'fixtures/paired-recursion', WP_Invariant::all() ); + } + + public function test_violation_from_the_code_under_test_is_recorded() { + wp_invariants_fixture_call( 'wp_invariants_fixture_positive', -1 ); + + $violations = WP_Invariant_Violations::in_test( 'fixtures/positive' ); + $this->assertCount( 1, $violations ); + $this->assertSame( 'It was given -1.', $violations[0]['message'] ); + $this->assertSame( 'before', $violations[0]['when'] ); + $this->assertSame( 'wp_invariants_fixture_positive', $violations[0]['target'] ); + $this->assertSame( __METHOD__, $violations[0]['test'] ); + $this->assertStringStartsWith( 'wp_invariants_fixture_call() at tests/invariants/self-tests/fixtures/core.php:', $violations[0]['stack'][0] ); + } + + public function test_call_from_test_code_is_only_checked_when_asked() { + wp_invariants_fixture_positive( -1 ); + + $this->assertSame( array(), WP_Invariant_Violations::in_test( 'fixtures/positive' ) ); + $this->assertCount( 1, WP_Invariant_Violations::in_test( 'fixtures/positive-from-anywhere' ) ); + } + + public function test_nothing_is_recorded_when_invariants_hold() { + wp_invariants_fixture_call( 'wp_invariants_fixture_positive', 5 ); + + $this->assertSame( array(), WP_Invariant_Violations::in_test() ); + $this->assertGreaterThan( 0, $this->runs( 'fixtures/positive#0' ) ); + } + + public function test_arg_gives_the_default_of_an_argument_which_was_not_passed() { + wp_invariants_fixture_call( 'wp_invariants_fixture_positive', 5 ); + $this->assertSame( array(), WP_Invariant_Violations::in_test( 'fixtures/label-is-default' ) ); + + wp_invariants_fixture_call( 'wp_invariants_fixture_positive', 5, 'other' ); + $violations = WP_Invariant_Violations::in_test( 'fixtures/label-is-default' ); + $this->assertCount( 1, $violations ); + $this->assertSame( 'Its label was other.', $violations[0]['message'] ); + } + + public function test_after_check_sees_arguments_as_they_were_when_the_call_started() { + // The fixture changes its parameter before it returns. + wp_invariants_fixture_call( 'wp_invariants_fixture_positive', 5 ); + + $this->assertSame( array(), WP_Invariant_Violations::in_test( 'fixtures/entry-values' ) ); + $this->assertGreaterThan( 0, $this->runs( 'fixtures/entry-values#0' ) ); + } + + public function test_paired_checks_match_through_recursion() { + $before = $this->runs( 'fixtures/paired-recursion#1' ); + wp_invariants_fixture_call( 'wp_invariants_fixture_recursive', 3 ); + + $this->assertSame( array(), WP_Invariant_Violations::in_test() ); + $this->assertSame( $before + 4, $this->runs( 'fixtures/paired-recursion#1' ) ); + } + + public function test_paired_checks_match_when_a_call_inside_throws() { + $before = $this->runs( 'fixtures/paired-throw#1' ); + wp_invariants_fixture_call( 'wp_invariants_fixture_throwing', 3 ); + + $this->assertSame( array(), WP_Invariant_Violations::in_test() ); + // The innermost call threw, so only the 3 calls around it returned. + $this->assertSame( $before + 3, $this->runs( 'fixtures/paired-throw#1' ) ); + } + + public function test_instance_method_check_gets_the_object() { + $fixture = new WP_Invariants_Fixture(); + wp_invariants_fixture_call( array( $fixture, 'add' ), 4 ); + + $this->assertSame( array(), WP_Invariant_Violations::in_test() ); + $this->assertGreaterThan( 0, $this->runs( 'fixtures/method-self#0' ) ); + } + + public function test_static_method_check_gets_no_object() { + wp_invariants_fixture_call( array( 'WP_Invariants_Fixture', 'twice' ), 7 ); + + $this->assertSame( array(), WP_Invariant_Violations::in_test() ); + $this->assertGreaterThan( 0, $this->runs( 'fixtures/static-method#0' ) ); + } + + /** + * @return int Calls counted over the run so far. + */ + public function test_state_counts_the_calls_in_a_test() { + wp_invariants_fixture_call( 'wp_invariants_fixture_counter' ); + wp_invariants_fixture_call( 'wp_invariants_fixture_counter' ); + + $this->assertSame( array( 'calls' => 2 ), WP_Invariant::all()['fixtures/counted-per-test']->current_state() ); + $this->assertSame( 2, WP_Invariant::shared( 'self-test/counter' )->calls ); + + $run = WP_Invariant::all()['fixtures/counted-per-run']->current_state(); + $this->assertGreaterThanOrEqual( 2, $run['calls'] ); + return $run['calls']; + } + + /** + * @depends test_state_counts_the_calls_in_a_test + * + * @param int $run_calls Calls counted over the run by the previous test. + */ + public function test_state_starts_again_in_each_test_unless_kept_for_the_run( $run_calls ) { + $this->assertNull( WP_Invariant::all()['fixtures/counted-per-test']->current_state() ); + $this->assertFalse( isset( WP_Invariant::shared( 'self-test/counter' )->calls ) ); + + wp_invariants_fixture_call( 'wp_invariants_fixture_counter' ); + + $this->assertSame( array( 'calls' => 1 ), WP_Invariant::all()['fixtures/counted-per-test']->current_state() ); + $this->assertSame( 1, WP_Invariant::shared( 'self-test/counter' )->calls ); + $this->assertSame( array( 'calls' => $run_calls + 1 ), WP_Invariant::all()['fixtures/counted-per-run']->current_state() ); + } + + public function test_checks_see_every_argument_of_a_variadic_function() { + wp_invariants_fixture_call( 'wp_invariants_fixture_variadic', 1, 'two', array( 3 ) ); + + $this->assertSame( array(), WP_Invariant_Violations::in_test() ); + $this->assertGreaterThan( 0, $this->runs( 'fixtures/extra-arguments#1' ) ); + } + + public function test_check_which_throws_is_recorded_without_breaking_the_call() { + $this->assertSame( 'kept', wp_invariants_fixture_call( 'wp_invariants_fixture_error', 'kept' ) ); + + $errors = WP_Invariant_Violations::in_test( 'fixtures/check-throws' ); + $this->assertCount( 1, $errors ); + $this->assertSame( 'hook-error', $errors[0]['kind'] ); + $this->assertStringContainsString( 'RuntimeException: This check always throws.', $errors[0]['message'] ); + } + + /** + * Returns how many times a hook has run, as the gameshark extension counts. + * + * @param string $spec_id Id of the hook, such as `fixtures/positive#0`. + * @return int + */ + private function runs( $spec_id ) { + foreach ( gameshark_invariants_status()['specs'] as $spec ) { + if ( $spec['id'] === $spec_id ) { + return $spec['executions']; + } + } + + $this->fail( "There is no hook {$spec_id}." ); + } +} diff --git a/tests/invariants/self-tests/tests/test-oracles.php b/tests/invariants/self-tests/tests/test-oracles.php new file mode 100644 index 0000000000000..30b5d96bfc569 --- /dev/null +++ b/tests/invariants/self-tests/tests/test-oracles.php @@ -0,0 +1,209 @@ +assertSame( 'javascript', WP_Invariant_Browser::url_scheme( WP_Invariant_Browser::attribute_value( ' java script:alert(1)' ) ) ); + $this->assertSame( 'javascript', WP_Invariant_Browser::url_scheme( 'JaVaScRiPt:alert(1)' ) ); + $this->assertSame( 'https', WP_Invariant_Browser::url_scheme( 'https://example.org/' ) ); + $this->assertNull( WP_Invariant_Browser::url_scheme( '/relative' ) ); + $this->assertNull( WP_Invariant_Browser::url_scheme( '//example.org/' ) ); + } + + public function test_browser_tells_whether_a_value_stays_in_its_attribute() { + $this->assertSame( 'a" onclick="x', WP_Invariant_Browser::attribute_value( 'a" onclick="x' ) ); + $this->assertNull( WP_Invariant_Browser::attribute_value( 'a" onclick="x' ) ); + } + + public function test_browser_finds_elements_in_templates_and_implied_elements() { + $names = array_map( array( 'WP_Invariant_Browser', 'tag_name' ), WP_Invariant_Browser::elements( '
a' ) ); + + $this->assertSame( array( 'template', 'img', 'table', 'tbody', 'tr', 'td' ), $names ); + } + + public function test_browser_trees_are_equal_when_a_browser_builds_the_same_tree() { + $this->assertSame( WP_Invariant_Browser::tree( '

a

b' ), WP_Invariant_Browser::tree( '

a

b

' ) ); + $this->assertNotSame( WP_Invariant_Browser::tree( '

a

b' ), WP_Invariant_Browser::tree( '

ab

' ) ); + } + + public function test_xml_parser_counts_elements_and_rejects_malformed_xml() { + $this->assertSame( 0, WP_Invariant_Browser::xml_child_elements( 'a & b ]]>' ) ); + $this->assertSame( 1, WP_Invariant_Browser::xml_child_elements( 'a ' ) ); + $this->assertNull( WP_Invariant_Browser::xml_child_elements( 'a & b' ) ); + $this->assertNull( WP_Invariant_Browser::xml_child_elements( 'a   b' ) ); + } + + public function test_mysql_parse_trees_leave_out_values_but_not_structure() { + $tree = WP_Invariant_MySQL::parse_tree( "SELECT ID FROM wp_posts WHERE post_name = 'x' AND post_parent = 0" ); + + $this->assertNotNull( $tree ); + $this->assertSame( $tree, WP_Invariant_MySQL::parse_tree( "SELECT ID FROM wp_posts WHERE post_name = 'it\\'s' AND post_parent = -5" ) ); + $this->assertSame( $tree, WP_Invariant_MySQL::parse_tree( "SELECT ID FROM wp_posts WHERE post_name = '' AND post_parent = 18446744073709551615" ) ); + $this->assertNotSame( $tree, WP_Invariant_MySQL::parse_tree( "SELECT ID FROM wp_posts WHERE post_name = 'x' OR 1=1 -- ' AND post_parent = 0" ) ); + $this->assertNull( WP_Invariant_MySQL::parse_tree( 'SELECT ID FROM wp_posts WHERE' ) ); + } + + public function test_browser_reads_a_start_tag_where_it_would_move_or_drop_it_elsewhere() { + $this->assertSame( + array( + 'name' => 'td', + 'attributes' => array( 'title' => "a\u{2209}b&" ), + ), + WP_Invariant_Browser::start_tag( '
' ) + ); + $this->assertSame( + array( + 'name' => 'svg', + 'attributes' => array( + 'viewbox' => '0 0 1 1', + 'xlink:href' => 'x', + ), + ), + WP_Invariant_Browser::start_tag( '' ) + ); + $this->assertSame( array( 'disabled' => '' ), WP_Invariant_Browser::start_tag( '' ) ); + } + + public function test_browser_reads_a_textarea_value_without_its_leading_line_break() { + $this->assertSame( 'a&b ', WP_Invariant_Browser::textarea_value( "\na&b " ) ); + $this->assertNull( WP_Invariant_Browser::textarea_value( 'a' ) ); + } + + public function test_browser_lists_the_nodes_of_a_fragment() { + $escaped = '{"a":"' . str_repeat( chr( 92 ) . 'u002d', 2 ) . chr( 92 ) . 'u003e"}'; + + $this->assertSame( array( array( 'comment', " wp:x {$escaped} /" ) ), WP_Invariant_Browser::nodes( "" ) ); + $this->assertSame( array( array( 'comment', ' wp:x {"a":"' ), array( 'text', '"} /-->' ) ), WP_Invariant_Browser::nodes( '"} /-->' ) ); + $this->assertSame( array( array( 'text', 'a' ), array( 'element', 'b' ) ), WP_Invariant_Browser::nodes( 'a' ) ); + } + + /** + * @dataProvider data_redirect_hosts + * + * @param string $location Location header. + * @param string $host Host a browser goes to. + */ + public function test_browser_finds_the_host_a_location_goes_to( $location, $host ) { + $this->assertSame( $host, WP_Invariant_Browser::url_host( $location, 'http://site.invalid/wp-admin/' ) ); + } + + public function data_redirect_hosts() { + return array( + 'relative path' => array( 'edit.php', 'site.invalid' ), + 'absolute path' => array( '/wp-admin/', 'site.invalid' ), + 'protocol-relative' => array( '//evil.example/', 'evil.example' ), + 'backslash after a slash' => array( '/\\evil.example/', 'evil.example' ), + 'backslashes' => array( '\\\\evil.example', 'evil.example' ), + 'backslash after the scheme' => array( 'https:/\\evil.example', 'evil.example' ), + 'leading space' => array( ' //evil.example', 'evil.example' ), + 'user info' => array( 'http://site.invalid@evil.example/', 'evil.example' ), + 'upper case' => array( 'HTTPS://EVIL.Example/', 'evil.example' ), + 'no host' => array( 'mailto:a@example.org', '' ), + ); + } + + public function test_xml_parser_tells_whether_a_document_is_well_formed() { + $this->assertTrue( WP_Invariant_Browser::xml_well_formed( '&' ) ); + $this->assertFalse( WP_Invariant_Browser::xml_well_formed( '&' ) ); + $this->assertFalse( WP_Invariant_Browser::xml_well_formed( '' ) ); + } + + /** + * @dataProvider data_table_names + * + * @param string $sql Query. + * @param array|null $tables Tables it names. + */ + public function test_mysql_finds_the_tables_a_query_names( $sql, $tables ) { + $this->assertSame( $tables, WP_Invariant_MySQL::table_names( $sql ) ); + } + + public function data_table_names() { + return array( + 'join, alias and subquery' => array( 'SELECT p.* FROM `wp_posts` p JOIN wp_postmeta AS m ON m.post_id = p.ID WHERE p.ID IN (SELECT object_id FROM wp_term_relationships)', array( array( null, 'wp_posts' ), array( null, 'wp_postmeta' ), array( null, 'wp_term_relationships' ) ) ), + 'insert' => array( 'INSERT INTO wp_options (option_name) VALUES (1)', array( array( null, 'wp_options' ) ) ), + 'describe' => array( 'DESCRIBE wp_posts;', array( array( null, 'wp_posts' ) ) ), + 'database' => array( 'SELECT * FROM information_schema.tables', array( array( 'information_schema', 'tables' ) ) ), + 'aliases of a delete' => array( 'DELETE a, b FROM wp_a a JOIN wp_b b', array( array( null, 'wp_a' ), array( null, 'wp_b' ) ) ), + 'common table expression' => array( 'WITH x AS (SELECT 1) SELECT * FROM x JOIN wp_posts', array( array( null, 'wp_posts' ) ) ), + 'pattern of SHOW TABLES' => array( "SHOW TABLES LIKE 'wp_x'", array() ), + 'no table' => array( 'SELECT FOUND_ROWS()', array() ), + 'not valid MySQL' => array( 'SELECT * FROM', null ), + ); + } + + public function test_html_api_counts_markup_but_not_what_only_looks_like_markup() { + self::load_html_api(); + + $this->assertSame( + array( + ' 1, + ' 2, + '

x assertSame( array( array( 0, 2 ), array( 18, 20 ), array( 52, 54 ) ), WP_Invariant_HTML_API::texts( 'a c f ' ) ); + } + + public function test_html_api_finds_where_html_syntax_starts() { + self::load_html_api(); + + $this->assertSame( + array( + 0 => 'tag', + 1 => 'tag name', + 3 => 'attribute', + 10 => 'attribute value', + 13 => 'attribute', + 16 => 'attribute value', + 18 => 'attribute', + 25 => 'attribute', + 34 => 'tag', + 36 => 'tag name', + 38 => 'comment', + 69 => 'DOCTYPE', + ), + WP_Invariant_HTML_API::boundaries( '

Name ' ) + ); + $this->assertSame( array(), WP_Invariant_HTML_API::boundaries( 'if ( a < b && c > d ) { echo "' ) ); + } + + public function test_fill_template_puts_a_harmless_value_in_each_placeholder() { + $this->assertSame( + "SELECT * FROM `i` WHERE a = 'x' AND b = 'x' AND c = 0 AND d = 0 AND e LIKE '%%%s%%' AND f = x AND g = 100%", + WP_Invariant_SQL_Lexer::fill_template( "SELECT * FROM %i WHERE a = %s AND b = '%s' AND c = %d AND d = %f AND e LIKE '%%%s%%' AND f = %1\$s AND g = 100%%" ) + ); + } +} diff --git a/tests/invariants/self-tests/tests/test-prepare.php b/tests/invariants/self-tests/tests/test-prepare.php new file mode 100644 index 0000000000000..a3c2fffe602d4 --- /dev/null +++ b/tests/invariants/self-tests/tests/test-prepare.php @@ -0,0 +1,72 @@ +newInstanceWithoutConstructor(); + } + + /** + * @dataProvider data_templates + * + * @param string $template A template for wpdb::prepare(). + * @param array $args Arguments for it. + * @param bool $same Whether the prepared query has the template's structure. + */ + public function test_prepared_query_has_the_structure_of_its_template( $template, array $args, $same ) { + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The template is what is being tested. + $query = self::$wpdb->remove_placeholder_escape( self::$wpdb->prepare( $template, ...$args ) ); + + $this->assertSame( + $same, + WP_Invariant_SQL_Lexer::same_structure( + WP_Invariant_SQL_Lexer::structure( WP_Invariant_SQL_Lexer::tokens( $template, true ) ), + WP_Invariant_SQL_Lexer::structure( WP_Invariant_SQL_Lexer::tokens( $query ) ) + ), + "wpdb::prepare() returned: {$query}" + ); + } + + public function data_templates() { + $tricky = "it's 50% \\ done\" -- /*"; + + return array( + 'strings and numbers' => array( 'SELECT ID FROM wp_posts WHERE post_name = %s AND post_parent = %d LIMIT %d', array( $tricky, 5, -1 ), true ), + 'already quoted %s' => array( "SELECT * FROM wp_options WHERE option_name = '%s' OR option_name = \"%s\"", array( $tricky, $tricky ), true ), + 'identifier' => array( 'SELECT %i FROM %i WHERE %i = %s', array( 'ID', 'wp_posts', 'post_status', 'publish' ), true ), + 'like with literal percents' => array( "SELECT * FROM wp_posts WHERE post_title LIKE '%%%s%%'", array( '50%' ), true ), + 'float' => array( 'SELECT * FROM t WHERE a > %f AND b < %F', array( 1.5, -2.25 ), true ), + 'numbered placeholders, quoted' => array( "SELECT * FROM t WHERE a = '%1\$s' AND b = '%2\$s' AND c = '%1\$s'", array( $tricky, 'x' ), true ), + 'numbered, one word, unquoted' => array( 'SELECT * FROM %1$s WHERE ID = %2$d', array( 'wp_posts', 3 ), true ), + 'IN list' => array( 'SELECT * FROM t WHERE ID IN (%d, %d, %d)', array( 1, 2, 3 ), true ), + 'unrecognized percent' => array( 'SELECT * FROM t WHERE a = %s AND b = 100% AND c = %d', array( 'x', 4 ), true ), + 'numbered, injected, unquoted' => array( 'SELECT * FROM t WHERE ID = %1$s', array( '1 OR 1=1' ), false ), + 'formatted, injected, unquoted' => array( 'SELECT * FROM t WHERE ID = %5s', array( '1 OR 1=1' ), false ), + ); + } +} diff --git a/tests/invariants/self-tests/tests/test-security.php b/tests/invariants/self-tests/tests/test-security.php new file mode 100644 index 0000000000000..b50cc179241ee --- /dev/null +++ b/tests/invariants/self-tests/tests/test-security.php @@ -0,0 +1,235 @@ +saved = array( $_GET, $_POST, $_COOKIE, $_REQUEST, $_SERVER ); + WP_Invariant_Request::reset(); + } + + public function tearDown(): void { + list( $_GET, $_POST, $_COOKIE, $_REQUEST, $_SERVER ) = $this->saved; + WP_Invariant_Request::reset(); + } + + /** + * @dataProvider data_internal_addresses + * + * @param string $ip Address. + * @param string|null $expected Kind, or null for a public address. + */ + public function test_internal_address_knows_every_form_of_an_internal_address( $ip, $expected ) { + $this->assertSame( $expected, WP_Invariant_Security::internal_address( $ip ) ); + } + + /** + * @return array[] + */ + public static function data_internal_addresses() { + return array( + 'IPv4 loopback' => array( '127.0.0.1', 'loopback' ), + 'IPv4 loopback range' => array( '127.255.0.9', 'loopback' ), + 'IPv6 loopback' => array( '::1', 'loopback' ), + 'IPv4-mapped loopback' => array( '::ffff:127.0.0.1', 'loopback' ), + 'private 10/8' => array( '10.1.2.3', 'private' ), + 'private 172.16/12' => array( '172.31.255.255', 'private' ), + 'public 172.32' => array( '172.32.0.1', null ), + 'private 192.168/16' => array( '192.168.0.1', 'private' ), + 'metadata service' => array( '169.254.169.254', 'link-local, the cloud metadata service' ), + 'metadata through NAT64' => array( '64:ff9b::a9fe:a9fe', 'link-local, the cloud metadata service' ), + 'carrier-grade NAT' => array( '100.64.0.1', 'carrier-grade NAT' ), + 'unspecified' => array( '0.0.0.0', 'unspecified' ), + 'IPv6 unspecified' => array( '::', 'unspecified' ), + 'IPv6 unique local' => array( 'fd12::1', 'unique local' ), + 'IPv6 link-local' => array( 'fe80::1', 'link-local' ), + 'public IPv4' => array( '93.184.216.34', null ), + 'public IPv6' => array( '2606:4700::1', null ), + 'not an address' => array( 'example.org', null ), + ); + } + + public function test_serialized_classes_reads_objects_but_not_strings_which_look_like_them() { + $data = serialize( + array( + new ArrayObject( array( 1 ) ), + 'O:8:"stdClass":0:{}', + (object) array( 'inner' => new SplObjectStorage() ), + ) + ); + + $this->assertSame( array( 'ArrayObject', 'stdClass', 'SplObjectStorage' ), WP_Invariant_Security::serialized_classes( $data ) ); + } + + public function test_gadget_classes_are_loaded_classes_with_magic_methods_which_unserialize_would_create() { + $data = serialize( array( new ArrayObject(), new stdClass(), new WP_Invariants_Fixture() ) ); + + $this->assertSame( array( 'ArrayObject' ), WP_Invariant_Security::gadget_classes( $data ), 'ArrayObject has __unserialize(); stdClass and the fixture have no magic methods.' ); + $this->assertSame( array(), WP_Invariant_Security::gadget_classes( $data, false ), 'allowed_classes false creates no objects.' ); + $this->assertSame( array(), WP_Invariant_Security::gadget_classes( $data, array( 'stdClass' ) ), 'Classes left out of allowed_classes are not created.' ); + $this->assertSame( array(), WP_Invariant_Security::gadget_classes( 'O:22:"Not_Loaded_Gadget_Class":0:{}' ), 'A class which is not loaded is not loaded to find out.' ); + } + + /** + * @dataProvider data_executable_names + * + * @param string $name File name. + * @param bool $executable Whether it could run as code. + */ + public function test_executable_name_finds_code_by_any_extension( $name, $executable ) { + $why = WP_Invariant_Security::executable_name( $name ); + $this->assertSame( $executable, is_string( $why ) ); + } + + /** + * @return array[] + */ + public static function data_executable_names() { + return array( + 'PHP' => array( 'shell.php', true ), + 'PHP among the extensions' => array( 'photo.php.jpg', true ), + 'upper case' => array( 'x.PHTML', true ), + 'trailing space' => array( 'shell.php ', true ), + 'PHAR' => array( 'x.phar', true ), + 'a path' => array( '/uploads/2024/01/a.php5', true ), + '.htaccess' => array( '.htaccess', true ), + '.user.ini in a directory' => array( 'dir/.user.ini', true ), + 'a name which is just php' => array( 'php.jpg', false ), + 'an image' => array( 'photo.jpg', false ), + 'php inside an extension' => array( 'archive.phpx', false ), + ); + } + + public function test_executable_contents_finds_php_and_phar_archives() { + $this->assertNotNull( WP_Invariant_Security::executable_contents( "\xEF\xBB\xBF assertNotNull( WP_Invariant_Security::executable_contents( '' ) ); + $this->assertNotNull( WP_Invariant_Security::executable_contents( "GIF89a\0\0" ) ); + $this->assertNull( WP_Invariant_Security::executable_contents( "GIF89a\0\0" ) ); + $this->assertNull( WP_Invariant_Security::executable_contents( '' ) ); + } + + public function test_is_write_mode() { + $this->assertFalse( WP_Invariant_Security::is_write_mode( 'rb' ) ); + foreach ( array( 'w', 'wb', 'a', 'x', 'c+', 'r+' ) as $mode ) { + $this->assertTrue( WP_Invariant_Security::is_write_mode( $mode ), $mode ); + } + } + + public function test_request_values_are_long_enough_strings_as_sent_and_unslashed() { + $_GET = array( + 'q' => 'it\\\'s ', + 'n' => '1', + 'list' => array( 'nested "value"' ), + ); + $_POST = array(); + $_COOKIE = array(); + $_REQUEST = array(); + $_SERVER = array( + 'REQUEST_URI' => '/?q=x', + 'HTTP_USER_AGENT' => 'Agent "quoted"', + 'SCRIPT_NAME' => '/not/a/request/value.php', + ); + + $values = WP_Invariant_Request::values(); + $this->assertContains( 'it\\\'s ', $values ); + $this->assertContains( 'it\'s ', $values, 'The superglobals are slashed, so values are followed unslashed too.' ); + $this->assertContains( 'nested "value"', $values ); + $this->assertContains( 'Agent "quoted"', $values ); + $this->assertContains( '/?q=x', $values ); + $this->assertNotContains( '1', $values, 'Values shorter than 4 bytes are not followed.' ); + $this->assertNotContains( '/not/a/request/value.php', $values ); + + $this->assertSame( array( 'it\\\'s ', 'it\'s ' ), WP_Invariant_Request::values( '<' ) ); + } + + public function test_request_values_follow_changes_and_rest_requests() { + $_GET = array( 'first' => 'first value' ); + $this->assertContains( 'first value', WP_Invariant_Request::values() ); + + $_GET = array( 'second' => 'second value' ); + $this->assertContains( 'second value', WP_Invariant_Request::values() ); + $this->assertNotContains( 'first value', WP_Invariant_Request::values() ); + + WP_Invariant_Request::start_rest_request( array( 'title' => 'a REST title' ) ); + $this->assertContains( 'a REST title', WP_Invariant_Request::values() ); + WP_Invariant_Request::end_rest_request(); + $this->assertNotContains( 'a REST title', WP_Invariant_Request::values() ); + } + + public function test_occurrences() { + $this->assertSame( array( 0, 4, 8 ), WP_Invariant_Request::occurrences( 'abc abc abc', 'abc' ) ); + $this->assertSame( array( 0, 4 ), WP_Invariant_Request::occurrences( 'abc abc abc', 'abc', 2 ) ); + $this->assertSame( array(), WP_Invariant_Request::occurrences( 'abc', 'x' ) ); + } + + public function test_paths_resolve_inside_directories_through_dot_dot_and_symbolic_links() { + $directory = sys_get_temp_dir() . '/wp-invariants-paths-' . getmypid(); + mkdir( "{$directory}/inside", 0777, true ); + symlink( "{$directory}/inside", "{$directory}/link" ); + + $inside = WP_Invariant::directory_forms( "{$directory}/inside" ); + $this->assertTrue( WP_Invariant::path_resolves_inside( "{$directory}/inside/a/new/file.txt", $inside ) ); + $this->assertTrue( WP_Invariant::path_resolves_inside( "{$directory}/link/file.txt", $inside ), 'Through a symbolic link.' ); + $this->assertFalse( WP_Invariant::path_resolves_inside( "{$directory}/inside/../file.txt", $inside ), 'A .. climbs out.' ); + $this->assertFalse( WP_Invariant::path_resolves_inside( "{$directory}/inside-not/file.txt", $inside ), 'A directory whose name starts the same is another directory.' ); + $this->assertTrue( WP_Invariant::path_resolves_inside( "{$directory}/inside", $inside ), 'The directory itself.' ); + + unlink( "{$directory}/link" ); + rmdir( "{$directory}/inside" ); + rmdir( $directory ); + } + + public function test_functions_calling_finds_the_functions_whose_own_code_calls_a_function() { + $this->assertSame( + array( 'WP_Invariants_Calls_Fixture::run', 'wp_invariants_calls_switches' ), + WP_Invariant::functions_calling( array( dirname( __DIR__ ) . '/fixtures/calls' ), 'switch_to_blog' ) + ); + } + + public function test_a_frame_mark_tells_whether_its_call_is_still_running() { + $mark = $this->mark_inside(); + $this->assertIsInt( $mark ); + $this->assertFalse( WP_Invariant::running( $mark, __CLASS__ . '::mark_inside' ), 'The call returned.' ); + + $this->assertTrue( $this->check_inside(), 'The call is running.' ); + $this->assertNull( WP_Invariant::frame_mark( 'no_such_function' ) ); + } + + /** + * Returns a mark for itself. + * + * @return int|null + */ + private function mark_inside() { + return WP_Invariant::frame_mark( __METHOD__ ); + } + + /** + * Returns whether a mark it makes for itself is running, from a call inside it. + * + * @return bool + */ + private function check_inside() { + $mark = WP_Invariant::frame_mark( __METHOD__ ); + return ( static function () use ( $mark ) { + return WP_Invariant::running( $mark, __CLASS__ . '::check_inside' ); + } )(); + } +} diff --git a/tests/invariants/self-tests/tests/test-sql-lexer.php b/tests/invariants/self-tests/tests/test-sql-lexer.php new file mode 100644 index 0000000000000..7c6029b5c9277 --- /dev/null +++ b/tests/invariants/self-tests/tests/test-sql-lexer.php @@ -0,0 +1,153 @@ +assertSame( + array( + array( 'word', 'SELECT' ), + array( 'punct', '*' ), + array( 'word', 'FROM' ), + array( 'ident', '`wp_posts`' ), + array( 'word', 'WHERE' ), + array( 'word', 'ID' ), + array( 'punct', '=' ), + array( 'number', '5' ), + array( 'word', 'AND' ), + array( 'word', 'post_title' ), + array( 'punct', '=' ), + array( 'string', "'it''s'" ), + array( 'comment', '-- note' ), + array( 'word', 'LIMIT' ), + array( 'number', '1.5e3' ), + ), + $this->types_and_texts( $tokens ) + ); + } + + public function test_backslash_escapes_stay_inside_strings() { + $tokens = WP_Invariant_SQL_Lexer::tokens( "a = 'O\\'Reilly' OR b = \"x\\\"y\"" ); + + $this->assertSame( array( 'a', '=', 'VALUE', 'OR', 'b', '=', 'VALUE' ), WP_Invariant_SQL_Lexer::structure( $tokens ) ); + } + + public function test_comments_are_tokens() { + $tokens = WP_Invariant_SQL_Lexer::tokens( "SELECT 1 # one\n/* two */ --three" ); + + $this->assertSame( + array( + array( 'word', 'SELECT' ), + array( 'number', '1' ), + array( 'comment', '# one' ), + array( 'comment', '/* two */' ), + array( 'punct', '-' ), + array( 'punct', '-' ), + array( 'word', 'three' ), + ), + $this->types_and_texts( $tokens ), + '"--" only starts a comment when whitespace follows it.' + ); + } + + public function test_template_placeholders_become_values_identifiers_or_raw_text() { + $template = "SELECT * FROM %i WHERE a = %s AND b = '%s' AND c = %d AND d = %f AND e LIKE '%%%s%%' AND f = %1\$s AND g LIKE %%%s AND h = 100%%"; + + $this->assertSame( + array( 'SELECT', '*', 'FROM', 'IDENT', 'WHERE', 'a', '=', 'VALUE', 'AND', 'b', '=', 'VALUE', 'AND', 'c', '=', 'VALUE', 'AND', 'd', '=', 'VALUE', 'AND', 'e', 'LIKE', 'VALUE', 'AND', 'f', '=', 'RAW', 'AND', 'g', 'LIKE', '%', 'RAW', 'AND', 'h', '=', 'VALUE', '%' ), + WP_Invariant_SQL_Lexer::structure( WP_Invariant_SQL_Lexer::tokens( $template, true ) ) + ); + } + + /** + * @dataProvider data_prepared_queries + * + * @param string $template What was given to wpdb::prepare(). + * @param string $query What it returned. + * @param bool $same Whether they have the same structure. + */ + public function test_compares_the_structure_of_a_template_and_a_query( $template, $query, $same ) { + $this->assertSame( + $same, + WP_Invariant_SQL_Lexer::same_structure( + WP_Invariant_SQL_Lexer::structure( WP_Invariant_SQL_Lexer::tokens( $template, true ) ), + WP_Invariant_SQL_Lexer::structure( WP_Invariant_SQL_Lexer::tokens( $query ) ) + ) + ); + } + + public function data_prepared_queries() { + return array( + 'string value' => array( 'SELECT * FROM t WHERE a = %s', "SELECT * FROM t WHERE a = 'x'", true ), + 'escaped quote in value' => array( 'SELECT * FROM t WHERE a = %s', "SELECT * FROM t WHERE a = 'x\\' OR 1=1 -- '", true ), + 'negative number' => array( 'SELECT * FROM t LIMIT %d', 'SELECT * FROM t LIMIT -5', true ), + 'subtraction stays an operator' => array( 'SELECT a - %d FROM t', 'SELECT a - 5 FROM t', true ), + 'identifier' => array( 'SELECT * FROM %i', 'SELECT * FROM `wp_posts`', true ), + 'raw placeholder, one word' => array( 'SELECT * FROM %1$s', 'SELECT * FROM wp_posts', true ), + 'raw placeholder, injected SQL' => array( 'SELECT * FROM t WHERE a = %1$s', 'SELECT * FROM t WHERE a = 1 OR 1=1', false ), + 'unescaped quote breaks out' => array( 'SELECT * FROM t WHERE a = %s', "SELECT * FROM t WHERE a = 'x' OR '1'='1'", false ), + 'comment added' => array( 'SELECT * FROM t WHERE a = %d', 'SELECT * FROM t WHERE a = 1 -- ', false ), + 'percent in a LIKE value' => array( "SELECT * FROM t WHERE a LIKE '%%%s%%'", "SELECT * FROM t WHERE a LIKE '%50\\%%'", true ), + 'literal percent outside quotes' => array( 'SELECT a % %d FROM t', 'SELECT a % 3 FROM t', true ), + ); + } + + /** + * @dataProvider data_appearances + * + * @param string $sql Query sent to MySQL. + * @param string $query Prepared query. + * @param bool[] $appearances Expected appearances. + */ + public function test_finds_where_a_prepared_query_appears_in_another( $sql, $query, array $appearances ) { + $this->assertSame( + $appearances, + WP_Invariant_SQL_Lexer::appearances( $sql, WP_Invariant_SQL_Lexer::tokens( $sql ), $query, WP_Invariant_SQL_Lexer::tokens( $query ) ) + ); + } + + public function data_appearances() { + $prefix = 'SELECT term_taxonomy_id FROM wptests_term_relationships WHERE object_id = 4194558 AND term_taxonomy_id = 1'; + + return array( + 'the whole query' => array( $prefix, $prefix, array( true ) ), + 'inside a larger query' => array( "SELECT * FROM t WHERE ID IN ({$prefix}) LIMIT 1", $prefix, array( true ) ), + 'twice' => array( "{$prefix} UNION {$prefix}", $prefix, array( true, true ) ), + 'a prefix of a longer number' => array( "{$prefix}1", $prefix, array() ), + 'a suffix of a longer name' => array( 'SELECT * FROM t WHERE meta_post_id = 5', 'post_id = 5', array() ), + 'the text of a string' => array( "INSERT INTO t VALUES ('{$prefix}')", $prefix, array() ), + 'the text of a comment' => array( "SELECT 1 /* {$prefix} */", $prefix, array() ), + 'once intact, once in a number' => array( "{$prefix}1 UNION {$prefix}", $prefix, array( true ) ), + 'a string glued to a quote' => array( "SELECT * FROM t WHERE a = 'x'' OR 1 = 1 -- '", "SELECT * FROM t WHERE a = 'x'", array( false ) ), + 'opening a comment' => array( 'SELECT * FROM t WHERE a = 1 /*/ AND b = 2 */', 'SELECT * FROM t WHERE a = 1 /*/', array( false ) ), + 'starting inside a string' => array( "SELECT 'a, b' FROM t", "b' FROM t", array( false ) ), + ); + } + + /** + * Returns the type and text of each token. + * + * @param array[] $tokens Tokens. + * @return array[] + */ + private function types_and_texts( array $tokens ) { + return array_map( + static function ( $token ) { + return array( $token['type'], $token['text'] ); + }, + $tokens + ); + } +} diff --git a/tests/invariants/self-tests/tests/test-types.php b/tests/invariants/self-tests/tests/test-types.php new file mode 100644 index 0000000000000..3432871eb94e1 --- /dev/null +++ b/tests/invariants/self-tests/tests/test-types.php @@ -0,0 +1,503 @@ +saved = WP_Invariant_Types::profile(); + WP_Invariant_Types::set_profile( array() ); + } + + public function tearDown(): void { + WP_Invariant_Types::set_profile( $this->saved ); + } + + public function test_functions_in_finds_and_marks_every_function_and_method() { + $found = array(); + foreach ( WP_Invariant::functions_in( array( dirname( __DIR__ ) . '/fixtures/types' ) ) as $function ) { + $markers = array( $function['visibility'] ); + foreach ( array( 'static', 'magic', 'private', 'deprecated' ) as $marker ) { + if ( $function[ $marker ] ) { + $markers[] = $marker; + } + } + $found[ $function['target'] ] = implode( ' ', $markers ); + } + ksort( $found ); + + $expected = array( + 'wp_invariants_types_fixture' => 'public', + 'wp_invariants_types_fixture_caller' => 'public', + 'wp_invariants_types_fixture_extra' => 'public', + 'wp_invariants_types_fixture_throws' => 'public', + '_wp_invariants_types_fixture_private' => 'public private', + 'wp_invariants_types_fixture_documented_private' => 'public private deprecated', + 'wp_invariants_types_fixture_fallback' => 'public', + 'wp_invariants_types_fixture_reference' => 'public', + 'WP_Invariants_Types_Fixture::instance' => 'public', + 'WP_Invariants_Types_Fixture::make' => 'public static', + 'WP_Invariants_Types_Fixture::hidden' => 'protected', + 'WP_Invariants_Types_Fixture::secret' => 'private', + 'WP_Invariants_Types_Fixture::__get' => 'public magic', + 'WP_Invariants_Types_Private_Fixture::run' => 'public private', + ); + ksort( $expected ); + $this->assertSame( $expected, $found ); + } + + public function test_booleans_are_true_or_false_until_both_are_seen() { + $slot = $this->slot( true, true ); + $this->assertTypes( array( 'true' => 2 ), $slot ); + + WP_Invariant_Types::add_to_slot( $slot, false, WP_Invariant_Types::CORE ); + $this->assertTypes( array( 'bool' => 3 ), $slot ); + } + + public function test_ints_are_literals_until_five_distinct_ones_are_seen() { + $values = array( 0, -1, 0, 7, 42 ); + $this->assertTypes( + array( + '0' => 2, + '-1' => 1, + '7' => 1, + '42' => 1, + ), + $this->slot( ...$values ), + 'Four distinct ints are literals.' + ); + + $values[] = 3; + $this->assertTypes( + array( + 'int [zero]' => 2, + 'int [non-zero]' => 4, + ), + $this->slot( ...$values ), + 'A fifth distinct int makes them all int, counted by whether they are zero.' + ); + + $slot = $this->slot( ...$values ); + WP_Invariant_Types::add_to_slot( $slot, 0, WP_Invariant_Types::CORE ); + $this->assertTypes( + array( + 'int [zero]' => 3, + 'int [non-zero]' => 4, + ), + $slot, + 'Once widened, ints seen before are int too.' + ); + } + + public function test_floats_are_never_literals() { + $this->assertTypes( + array( + 'float' => 3, + '1' => 1, + ), + $this->slot( 1.0, 1.5, 1.0, 1 ) + ); + } + + public function test_strings_are_literals_until_there_are_too_many() { + $values = array( 'post', 'page', 'post' ); + $this->assertTypes( + array( + "'post'" => 2, + "'page'" => 1, + ), + $this->slot( ...$values ) + ); + + for ( $i = 0; $i < WP_Invariant_Types::STRING_LITERALS; $i++ ) { + $values[] = "value-{$i}"; + } + $this->assertTypes( array( 'string [non-empty]' => 13 ), $this->slot( ...$values ) ); + } + + public function test_strings_are_literals_until_one_is_too_long() { + $this->assertTypes( array( 'string [non-empty]' => 2 ), $this->slot( 'short', str_repeat( 'x', WP_Invariant_Types::STRING_LENGTH ) ) ); + $this->assertTypes( array( "'" . str_repeat( 'x', 31 ) . "'" => 1 ), $this->slot( str_repeat( 'x', 31 ) ) ); + } + + /** + * @dataProvider data_string_properties + * + * @param string $value A string. + * @param string[] $expected Its tags. + */ + public function test_strings_are_tagged_by_their_most_specific_properties( $value, array $expected ) { + self::load_html_api(); + + $this->assertSame( $expected, WP_Invariant_Types_Report::tags( 'string', WP_Invariant_Types::string_properties( $value ) ) ); + } + + /** + * @return array[] + */ + public static function data_string_properties() { + return array( + 'empty' => array( '', array( 'empty' ) ), + 'plain' => array( 'post', array( 'non-empty' ) ), + 'an integer' => array( '42', array( 'numeric' ) ), + 'a decimal' => array( '-1.5', array( 'numeric' ) ), + 'an exponent' => array( '1e3', array( 'numeric' ) ), + 'a number with a space' => array( ' 7', array( 'numeric' ) ), + 'hexadecimal' => array( '0x1A', array( 'non-empty' ) ), + 'a number and letters' => array( '12abc', array( 'non-empty' ) ), + 'a tag' => array( '

x

', array( 'html' ) ), + 'a comment' => array( 'a c', array( 'html' ) ), + 'a less-than sign' => array( 'a < b', array( 'non-empty' ) ), + 'a tag cut off' => array( 'a array( 'x & y', array( 'ref' ) ), + 'a legacy reference' => array( 'x & y', array( 'ref' ) ), + 'an ampersand' => array( 'AT&T', array( 'non-empty' ) ), + 'a quote' => array( "it's", array( 'slash' ) ), + 'a backslash' => array( 'a\\b', array( 'unslash' ) ), + 'a URL' => array( 'https://example.org/', array( 'url' ) ), + 'a URL without an authority' => array( 'mailto:a@example.org', array( 'url' ) ), + 'a URL with a newline' => array( "java\nscript:x", array( 'url' ) ), + 'a URL with a bad host' => array( 'https://[nope/', array( 'non-empty' ) ), + 'a URL path' => array( '/wp-admin/', array( 'non-empty' ) ), + 'a file' => array( __FILE__, array( 'file' ) ), + 'a path to nothing' => array( __DIR__ . '/missing.php', array( 'path' ) ), + 'a relative path' => array( './missing.php', array( 'path' ) ), + 'a Windows path, and a URL' => array( 'C:\\missing.php', array( 'url', 'path', 'unslash' ) ), + 'a stream wrapper' => array( 'phar://' . __FILE__, array( 'url' ) ), + 'invalid UTF-8' => array( "\xFF", array( '-utf8' ) ), + 'markup with a quote' => array( "

it's

", array( 'html', 'slash' ) ), + 'markup with every property' => array( "
&\\\xFF", array( 'html', 'ref', 'unslash', '-utf8' ) ), + ); + } + + public function test_a_path_is_looked_at_on_every_call() { + $file = tempnam( sys_get_temp_dir(), 'types' ); + $this->assertSame( array( 'file' ), WP_Invariant_Types_Report::tags( 'string', WP_Invariant_Types::string_properties( $file ) ) ); + + unlink( $file ); + $this->assertSame( array( 'path' ), WP_Invariant_Types_Report::tags( 'string', WP_Invariant_Types::string_properties( $file ) ), 'Whether a file exists is not remembered.' ); + } + + public function test_urls_with_opaque_paths_do_not_use_up_memory() { + $before = memory_get_usage(); + for ( $i = 0; $i < 5000; $i++ ) { + $this->assertSame( WP_Invariant_Types::STRING_URL, WP_Invariant_Types::string_properties( "mailto:{$i}" . str_repeat( 'x', WP_Invariant_Types::REMEMBERED_LENGTH ) ) ); + } + + $this->assertLessThan( 200000, memory_get_usage() - $before, 'PHP 8.5 keeps the memory of each URL with an opaque path which it parses.' ); + } + + public function test_classifying_strings_leaves_the_pcre_error_alone() { + self::load_html_api(); + + $limit = ini_get( 'pcre.backtrack_limit' ); + ini_set( 'pcre.backtrack_limit', '1' ); + preg_match( '/(?:\D+|<\d+>)*[!?]/', 'foobar foobar foobar' ); + ini_set( 'pcre.backtrack_limit', $limit ); + $this->assertSame( PREG_BACKTRACK_LIMIT_ERROR, preg_last_error() ); + + WP_Invariant_Types::string_properties( '

it's at https://example.org/

' . uniqid() ); + $this->assertSame( PREG_BACKTRACK_LIMIT_ERROR, preg_last_error() ); + preg_match( '/^/', '' ); + } + + public function test_widened_strings_are_counted_by_their_properties_from_the_first() { + self::load_html_api(); + + $values = array( '', '', '', 'it\'s', 'post', '42' ); + for ( $i = 0; $i < WP_Invariant_Types::STRING_LITERALS; $i++ ) { + $values[] = "value-{$i}"; + } + + $this->assertTypes( + array( + 'string [empty]' => 2, + 'string [html]' => 1, + 'string [slash]' => 1, + 'string [numeric]' => 1, + 'string [non-empty]' => 11, + ), + $this->slot( ...$values ) + ); + } + + public function test_scalars_objects_and_resources_are_their_types() { + $this->assertTypes( + array( + 'null' => 1, + '1' => 1, + 'float' => 1, + 'Closure' => 1, + 'WP_Invariants_Types_Fixture' => 1, + 'resource' => 1, + ), + $this->slot( null, 1, 1.5, static function () {}, new WP_Invariants_Types_Fixture(), STDERR ) + ); + } + + public function test_arrays_are_empty_lists_or_records() { + $this->assertTypes( + array( + 'array{a: int, b?: string|null}' => 3, + 'array{}' => 1, + 'list' => 1, + 'list' => 1, + ), + $this->slot( + array(), + array( 1, 'two' ), + array( new WP_Invariants_Types_Fixture() ), + array( + 'a' => 1, + 'b' => 'x', + ), + array( + 'a' => 2, + 'b' => null, + ), + array( 'a' => 3 ) + ) + ); + } + + public function test_records_widen_to_array_past_the_key_limit() { + $slot = $this->slot( array( 'a' => 1 ) ); + $wide = array(); + for ( $i = 0; $i < WP_Invariant_Types::RECORD_KEYS; $i++ ) { + $wide[ "key{$i}" ] = $i; + } + + WP_Invariant_Types::add_to_slot( $slot, $wide, WP_Invariant_Types::CORE ); + $this->assertTypes( array( 'array' => 2 ), $slot ); + + WP_Invariant_Types::add_to_slot( $slot, array( 'b' => 1 ), WP_Invariant_Types::CORE ); + $this->assertTypes( array( 'array' => 3 ), $slot, 'Once widened, later records are plain arrays too.' ); + } + + public function test_a_field_with_too_many_types_is_mixed() { + $values = array( null, 1, 1.5, 'x', array(), array( 1 ), array( 'a' => 1 ), new stdClass(), static function () {} ); + $slot = WP_Invariant_Types::empty_slot(); + foreach ( $values as $value ) { + WP_Invariant_Types::add_to_slot( $slot, array( 'field' => $value ), WP_Invariant_Types::CORE ); + } + + $this->assertTypes( array( 'array{field: mixed}' => 9 ), $slot ); + } + + public function test_stdclass_is_a_record_of_its_properties() { + $this->assertTypes( + array( 'stdClass{ID: int, name?: string}' => 2 ), + $this->slot( + (object) array( 'ID' => 1 ), + (object) array( + 'ID' => 2, + 'name' => 'x', + ) + ) + ); + } + + public function test_merging_follows_the_same_widening_rules() { + $first = $this->slot( 'a', 'b', 'c', 'd', 'e', 'f', array( 'x' => 1 ), true, 1, 2 ); + $second = $this->slot( 'g', 'h', 'i', 'j', 'k', 'a', array( 'y' => 'z' ), false, 3, 4, 5 ); + + $this->assertTypes( + array( + 'string [non-empty]' => 12, + 'array{x?: int, y?: string}' => 2, + 'bool' => 2, + 'int [non-zero]' => 5, + ), + WP_Invariant_Types::merge_slots( $first, $second ) + ); + + $this->assertTypes( + array( + 'int [zero]' => 2, + 'int [non-zero]' => 4, + ), + WP_Invariant_Types::merge_slots( $this->slot( 0, 1, 2 ), $this->slot( 0, 3, 4 ) ), + 'Properties are counted across processes, and their literals widen together.' + ); + + $this->assertTypes( + array( + '1' => 2, + '2' => 1, + '3' => 1, + ), + WP_Invariant_Types::merge_slots( $this->slot( 1, 2 ), $this->slot( 3, 1 ) ), + 'Ints stay literals while there are no more than four distinct ones.' + ); + } + + public function test_calls_are_counted_from_core_and_from_tests() { + wp_invariants_types_fixture( 'from a test', array( 'x' => 1 ) ); + wp_invariants_types_fixture_caller( 5 ); + wp_invariants_types_fixture_caller( 'a', array(), 'extra', 'more' ); + + $profile = WP_Invariant_Types::profile()['wp_invariants_types_fixture']; + $this->assertSame( array( 2, 1 ), $profile['calls'], 'Two calls from the fixture, which stands in for WordPress, and one from this test.' ); + $arguments = $profile['arguments']; + ksort( $arguments ); + $this->assertSame( + array( + 1 => array( 1, 0 ), + 2 => array( 0, 1 ), + 4 => array( 1, 0 ), + ), + $arguments + ); + $this->assertSame( array( 5 => array( 1, 0 ) ), $profile['slots'][0]['ints']['values'] ); + $this->assertSame( + array( + 'from a test' => array( 0, 1 ), + 'a' => array( 1, 0 ), + ), + $profile['slots'][0]['strings']['values'] + ); + $this->assertArrayHasKey( 3, $profile['slots'], 'Every variadic argument is counted.' ); + $this->assertSame( array( 'from a test', 5, 'a' ), array_keys( $profile['slots']['return']['strings']['values'] ), 'A numeric string literal is an integer key, which the report turns back into a string.' ); + } + + public function test_methods_count_their_arguments_without_the_object() { + ( new WP_Invariants_Types_Fixture() )->instance( 1.5 ); + WP_Invariants_Types_Fixture::make( 'name' ); + + $profile = WP_Invariant_Types::profile(); + $this->assertSame( array( 'float' => array( 0, 1 ) ), $profile['WP_Invariants_Types_Fixture::instance']['slots'][0]['types'] ); + $this->assertSame( array( 'float' => array( 0, 1 ) ), $profile['WP_Invariants_Types_Fixture::instance']['slots']['return']['types'] ); + $this->assertSame( array( 'name' => array( 0, 1 ) ), $profile['WP_Invariants_Types_Fixture::make']['slots'][0]['strings']['values'] ); + $this->assertSame( array( 'WP_Invariants_Types_Fixture' => array( 0, 1 ) ), $profile['WP_Invariants_Types_Fixture::make']['slots']['return']['types'] ); + } + + public function test_a_call_which_throws_has_its_arguments_counted_but_no_return_value() { + try { + wp_invariants_types_fixture_throws( 'reason' ); + } catch ( RuntimeException $e ) { + // Expected. + unset( $e ); + } + + $profile = WP_Invariant_Types::profile()['wp_invariants_types_fixture_throws']; + $this->assertSame( array( 0, 1 ), $profile['calls'] ); + $this->assertArrayNotHasKey( 'return', $profile['slots'] ); + } + + public function test_doc_types_reads_phpdoc_and_phpstan_types() { + $types = WP_Invariant_Types_Report::doc_types( ( new ReflectionFunction( 'wp_invariants_types_fixture' ) )->getDocComment() ); + + $this->assertSame( + array( + 'param' => array( + 'value' => 'string|int', + 'options' => 'array', + 'rest' => 'mixed', + ), + 'phpstan-param' => array( + 'value' => 'non-empty-string|int', + 'options' => 'array{ x?: int, }', + ), + 'return' => 'string', + 'phpstan-return' => 'non-empty-string', + ), + $types + ); + } + + public function test_report_lists_types_next_to_documented_types() { + wp_invariants_types_fixture( 'post' ); + wp_invariants_types_fixture_caller( 'post', array( 'x' => 1 ) ); + wp_invariants_types_fixture_caller( null ); + wp_invariants_types_fixture_extra( 1, 'more' ); + + $data = WP_Invariant_Types_Report::data( WP_Invariant_Types::hooked(), WP_Invariant_Types::profile() ); + $text = WP_Invariant_Types_Report::markdown( $data ); + + $this->assertStringContainsString( 'wp_invariants_types_fixture() tests/invariants/self-tests/fixtures/types/functions.php:27', $text ); + $this->assertStringContainsString( " \$value @param string|int @phpstan-param non-empty-string|int\n 2: 'post' (1 core, 1 tests)\n 1: null (1 core)", $text ); + $this->assertStringContainsString( " \$options @param array @phpstan-param array{ x?: int, }\n 2: (not passed) (1 core, 1 tests)\n 1: array{x: int} (1 core)", $text ); + $this->assertStringContainsString( " (extra)\n 1: 'more' (1 tests)", $text ); + $this->assertStringContainsString( '_wp_invariants_types_fixture_private()', $text ); + $this->assertContains( 'WP_Invariants_Types_Fixture::secret()', $data['never_called'] ); + $this->assertSame( + array( + 'core' => 2, + 'tests' => 1, + ), + $data['functions']['wp_invariants_types_fixture']['calls'] + ); + } + + /** + * Loads the HTML API, which WordPress would have loaded, for WP_Invariant_Types::string_properties(). + */ + private static function load_html_api() { + $directory = dirname( __DIR__, 4 ) . '/src/wp-includes'; + require_once "{$directory}/class-wp-token-map.php"; + require_once "{$directory}/html-api/html5-named-character-references.php"; + foreach ( array( 'class-wp-html-attribute-token.php', 'class-wp-html-span.php', 'class-wp-html-text-replacement.php', 'class-wp-html-decoder.php', 'class-wp-html-doctype-info.php', 'class-wp-html-tag-processor.php' ) as $file ) { + require_once "{$directory}/html-api/{$file}"; + } + } + + /** + * Counts values in a new slot, as calls from WordPress. + * + * @param mixed ...$values Values. + * @return array The slot. + */ + private function slot( ...$values ) { + $slot = WP_Invariant_Types::empty_slot(); + foreach ( $values as $value ) { + WP_Invariant_Types::add_to_slot( $slot, $value, WP_Invariant_Types::CORE ); + } + return $slot; + } + + /** + * Returns a slot's rows as type => total count, by type. + * + * @param array $slot A slot. + * @return int[] + */ + private function types( array $slot ) { + $types = array(); + foreach ( WP_Invariant_Types_Report::rows( $slot ) as $row ) { + $types[ $row['type'] ] = $row['core'] + $row['tests']; + } + ksort( $types ); + return $types; + } + + /** + * Asserts the types a slot counted, in any order. + * + * @param int[] $expected Type => total count. + * @param array $slot A slot. + * @param string $message Message on failure. + */ + private function assertTypes( array $expected, array $slot, $message = '' ) { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid -- Named as PHPUnit's assertions are. + ksort( $expected ); + $this->assertSame( $expected, $this->types( $slot ), $message ); + } +} diff --git a/tests/invariants/specs/archives.php b/tests/invariants/specs/archives.php new file mode 100644 index 0000000000000..1c58917b02a86 --- /dev/null +++ b/tests/invariants/specs/archives.php @@ -0,0 +1,103 @@ + array( 0 ), + 'file_put_contents' => array( 0 ), + 'fopen' => array( 0 ), + 'touch' => array( 0 ), + 'copy' => array( 1 ), + 'rename' => array( 1 ), + 'ZipArchive::extractTo' => array( 0 ), + 'WP_Filesystem_Direct::put_contents' => array( 0 ), + 'WP_Filesystem_Direct::mkdir' => array( 0 ), + 'WP_Filesystem_Direct::copy' => array( 1 ), + 'WP_Filesystem_Direct::move' => array( 1 ), +); + +return array( + WP_Invariant::define( 'extract-inside-destination' ) + ->describe( 'While unzip_file() or copy_dir() runs, every file and directory it writes is inside the directory it was given to write to, with .. and symbolic links resolved: no archive entry such as ../../wp-config.php, or absolute name, takes a write outside it. Each place in WordPress is reported once for each path.' ) + ->state( + static function () { + return array(); + }, + 'run' + ) + ->before_each( + array( 'unzip_file', 'copy_dir' ), + static function ( WP_Invariant_Call $call ) use ( $destinations ) { + $to = $call->arg( 'to' ); + if ( ! is_string( $to ) || '' === $to ) { + return; + } + if ( ! isset( $destinations->stack ) ) { + $destinations->stack = array(); + } + $destinations->stack[] = array( + 'function' => $call->target(), + 'mark' => WP_Invariant::frame_mark( $call->target() ), + 'to' => $to, + 'directories' => WP_Invariant::directory_forms( $to ), + ); + } + ) + ->before_each( + array_keys( $writes ), + static function ( WP_Invariant_Call $call, array &$reported ) use ( $destinations, $writes ) { + if ( empty( $destinations->stack ) ) { + return; + } + + // The innermost extraction or copy which is still running. + $destination = null; + while ( array() !== $destinations->stack ) { + $destination = end( $destinations->stack ); + if ( WP_Invariant::running( $destination['mark'], $destination['function'] ) ) { + break; + } + array_pop( $destinations->stack ); + $destination = null; + } + if ( null === $destination ) { + return; + } + + $args = $call->args(); + if ( 'fopen' === $call->target() && ! WP_Invariant_Security::is_write_mode( isset( $args[1] ) ? (string) $args[1] : 'r' ) ) { + return; + } + + foreach ( $writes[ $call->target() ] as $position ) { + $path = isset( $args[ $position ] ) ? $args[ $position ] : null; + if ( ! is_string( $path ) || '' === $path || false !== strpos( $path, "\0" ) || false !== strpos( $path, '://' ) ) { + continue; + } + if ( WP_Invariant::path_resolves_inside( $path, $destination['directories'] ) ) { + continue; + } + + $caller = $call->caller(); + $site = $caller['file'] . ':' . $caller['line'] . ' ' . $path; + if ( isset( $reported[ $site ] ) ) { + continue; + } + $reported[ $site ] = true; + + $call->expect( false, '%s() wrote %s, which is outside %s, while %s() ran.', $call->target(), $path, $destination['to'], $destination['function'] ); + } + } + ), +); diff --git a/tests/invariants/specs/auth.php b/tests/invariants/specs/auth.php new file mode 100644 index 0000000000000..de50e5d179304 --- /dev/null +++ b/tests/invariants/specs/auth.php @@ -0,0 +1,144 @@ +describe( 'When wp_validate_auth_cookie() returns a user ID, it is the ID of the user it looked up by the cookie\'s username.' ) + ->include_test_callers() + ->after( + 'get_user_by', + static function ( WP_Invariant_Call $call ) use ( $logins ) { + if ( 'login' === $call->arg( 'field' ) ) { + $logins->found[ $call->depth() ] = array( $call->arg( 'value' ), $call->return_value() ); + } + } + ) + ->before( + 'wp_validate_auth_cookie', + static function ( WP_Invariant_Call $call ) use ( $logins ) { + unset( $logins->found[ $call->depth() + 1 ] ); + } + ) + ->after( + 'wp_validate_auth_cookie', + static function ( WP_Invariant_Call $call ) use ( $logins ) { + $user_id = $call->return_value(); + if ( false === $user_id ) { + return; + } + + $depth = $call->depth() + 1; + $found = isset( $logins->found[ $depth ] ) ? $logins->found[ $depth ] : null; + unset( $logins->found[ $depth ] ); + if ( ! $call->expect( is_int( $user_id ) && $user_id > 0 && null !== $found, 'wp_validate_auth_cookie() returned %s without looking up a user.', $user_id ) ) { + return; + } + + list( $login, $user ) = $found; + $cookie = wp_parse_auth_cookie( $call->arg( 'cookie' ), $call->arg( 'scheme' ) ); + $call->expect( + is_object( $user ) && $user->ID === $user_id && is_array( $cookie ) && $cookie['username'] === $login, + 'wp_validate_auth_cookie() returned the user %d, but looked up %s for the cookie of %s.', + $user_id, + $login, + is_array( $cookie ) ? $cookie['username'] : $cookie + ); + } + ), + + WP_Invariant::define( 'authenticate-result' ) + ->describe( 'wp_authenticate() returns a WP_User with an ID above 0, or a WP_Error: never null or false.' ) + ->include_test_callers() + ->after( + 'wp_authenticate', + static function ( WP_Invariant_Call $call ) { + $user = $call->return_value(); + $call->expect( + ( $user instanceof WP_User && $user->ID > 0 ) || $user instanceof WP_Error, + 'wp_authenticate() returned %s.', + $user + ); + } + ), + + WP_Invariant::define( 'password-hash-not-plaintext' ) + ->describe( 'wp_hash_password() returns a hash starting with $, which does not contain the password, or * for a password over 4,096 bytes.' ) + ->include_test_callers() + ->after( + 'wp_hash_password', + static function ( WP_Invariant_Call $call ) { + $password = (string) $call->arg( 'password' ); + $hash = $call->return_value(); + if ( '*' === $hash ) { + $call->expect( strlen( $password ) > 4096, 'wp_hash_password() returned * for a password of %d bytes.', strlen( $password ) ); + return; + } + + // Only the start of the hash is reported, and never the password. + $call->expect( + is_string( $hash ) && '' !== $hash && '$' === $hash[0] && ( strlen( $password ) < 8 || false === strpos( $hash, $password ) ), + 'wp_hash_password() returned %s… for a password of %d bytes, which is not a hash of it.', + is_string( $hash ) ? substr( $hash, 0, 7 ) : $hash, + strlen( $password ) + ); + } + ), + + WP_Invariant::define( 'nonce-result' ) + ->describe( 'wp_verify_nonce() returns false, 1 or 2.' ) + ->include_test_callers() + ->after( + 'wp_verify_nonce', + static function ( WP_Invariant_Call $call ) { + $call->expect( in_array( $call->return_value(), array( false, 1, 2 ), true ), 'wp_verify_nonce() returned %s.', $call->return_value() ); + } + ), + + WP_Invariant::define( 'check-password-accepts-own-hash' ) + ->describe( 'wp_check_password() accepts a password with the hash wp_hash_password() made from it, unless a check_password filter decides otherwise.' ) + ->include_test_callers() + ->state( + static function () { + return array(); + }, + 'test' + ) + ->after( + 'wp_hash_password', + static function ( WP_Invariant_Call $call, array &$hashes ) { + $hash = $call->return_value(); + if ( is_string( $hash ) && '*' !== $hash ) { + $hashes[ $hash ] = (string) $call->arg( 'password' ); + if ( count( $hashes ) > 64 ) { + unset( $hashes[ array_key_first( $hashes ) ] ); + } + } + } + ) + ->after( + 'wp_check_password', + static function ( WP_Invariant_Call $call, array &$hashes ) { + $hash = $call->arg( 'hash' ); + if ( ! is_string( $hash ) || ! isset( $hashes[ $hash ] ) || $hashes[ $hash ] !== $call->arg( 'password' ) || WP_Invariant::has_callbacks( 'check_password' ) || ! empty( $GLOBALS['wp_hasher'] ) ) { + return; + } + + // The report never includes the password. + $password = $hashes[ $hash ]; + $call->expect( + true === $call->return_value(), + 'wp_check_password() rejected a password of %d bytes%s with the hash wp_hash_password() made from it.', + strlen( $password ), + trim( $password ) === $password ? '' : ', with whitespace at its start or end,' + ); + } + ), +); diff --git a/tests/invariants/specs/blocks.php b/tests/invariants/specs/blocks.php new file mode 100644 index 0000000000000..691d22ca4c610 --- /dev/null +++ b/tests/invariants/specs/blocks.php @@ -0,0 +1,90 @@ +describe( 'What serialize_block_attributes() returns stays in the HTML comment of a block delimiter, as a browser reads it, and decodes back to the attributes.' ) + ->include_test_callers() + ->after( + 'serialize_block_attributes', + static function ( WP_Invariant_Call $call ) { + $json = $call->return_value(); + if ( ! is_string( $json ) ) { + return; + } + + $attributes = $call->arg( 'block_attributes' ); + $comment = " wp:x {$json} /"; + $nodes = WP_Invariant_Browser::nodes( "" ); + if ( ! $call->expect( array( array( 'comment', $comment ) ) === $nodes, 'serialize_block_attributes() returned %s, which a browser does not read as one comment.', $json ) ) { + return; + } + + // Compared as JSON gives them back, so that numbers and empty arrays read the same. + $expected = json_encode( $attributes ); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- Independent of wp_json_encode(). + if ( false === $expected ) { + // Such as invalid UTF-8, which wp_json_encode() repairs first. + return; + } + $call->expect( json_decode( $json, true ) === json_decode( $expected, true ), 'serialize_block_attributes() returned %s, which decodes to different attributes.', $json ); + } + ), + + WP_Invariant::define( 'serialized-blocks-parse-back' ) + ->describe( 'Parsed again, what serialize_blocks() returns gives the same named blocks, in the same order and nesting, with the same attributes.' ) + ->include_test_callers() + ->after( + 'serialize_blocks', + static function ( WP_Invariant_Call $call ) use ( $named_blocks, $well_formed ) { + $html = $call->return_value(); + $blocks = $call->arg( 'blocks' ); + if ( ! is_string( $html ) || ! is_array( $blocks ) || ! $well_formed( $blocks ) ) { + return; + } + + // The parser itself, not parse_blocks(), which runs a filter. + $parsed = ( new WP_Block_Parser() )->parse( $html ); + $call->expect( $named_blocks( $parsed ) === $named_blocks( $blocks ), 'serialize_blocks() returned %s, which parses to different blocks.', $html ); + } + ), +); diff --git a/tests/invariants/specs/capabilities.php b/tests/invariants/specs/capabilities.php new file mode 100644 index 0000000000000..e20ca05edd47a --- /dev/null +++ b/tests/invariants/specs/capabilities.php @@ -0,0 +1,76 @@ +depth() + 1; + return isset( $mapped->caps[ $depth ] ) ? $mapped->caps[ $depth ] : null; +}; + +return array( + WP_Invariant::define( 'well-typed' ) + ->describe( 'map_meta_cap() returns an array of non-empty strings, and WP_User::has_cap() returns a bool.' ) + ->include_test_callers() + ->after( + 'map_meta_cap', + static function ( WP_Invariant_Call $call ) use ( $mapped ) { + $caps = $call->return_value(); + $mapped->caps[ $call->depth() ] = $caps; + + $valid = is_array( $caps ); + foreach ( $valid ? $caps : array() as $cap ) { + $valid = $valid && is_string( $cap ) && '' !== $cap; + } + $call->expect( $valid, 'map_meta_cap( %s ) returned %s.', $call->arg( 'cap' ), $caps ); + } + ) + ->after( + 'WP_User::has_cap', + static function ( WP_Invariant_Call $call ) { + $call->expect( is_bool( $call->return_value() ), 'WP_User::has_cap( %s ) returned %s.', $call->arg( 'cap' ), $call->return_value() ); + } + ), + + WP_Invariant::define( 'do-not-allow-never-granted' ) + ->describe( 'WP_User::has_cap() never grants a capability which map_meta_cap() mapped to do_not_allow.' ) + ->include_test_callers() + ->after( + 'WP_User::has_cap', + static function ( WP_Invariant_Call $call ) use ( $mapped_in ) { + if ( true !== $call->return_value() ) { + return; + } + + $caps = $mapped_in( $call ); + $call->expect( ! is_array( $caps ) || ! in_array( 'do_not_allow', $caps, true ), 'WP_User::has_cap( %s ) granted it to the user %d, though it maps to do_not_allow.', $call->arg( 'cap' ), $call->self()->ID ); + } + ), + + WP_Invariant::define( 'logged-out-can-only-exist' ) + ->describe( 'For a user who is not logged in, WP_User::has_cap() is only true when the only capability required is exist.' ) + ->include_test_callers() + ->after( + 'WP_User::has_cap', + static function ( WP_Invariant_Call $call ) use ( $mapped_in ) { + if ( true !== $call->return_value() || 0 !== $call->self()->ID ) { + return; + } + + $caps = $mapped_in( $call ); + $call->expect( ! is_array( $caps ) || array() === array_diff( $caps, array( 'exist' ) ), 'WP_User::has_cap( %s ) granted it to a user who is not logged in, though it needs %s.', $call->arg( 'cap' ), is_array( $caps ) ? implode( ', ', $caps ) : $caps ); + } + ), +); diff --git a/tests/invariants/specs/escaping.php b/tests/invariants/specs/escaping.php new file mode 100644 index 0000000000000..ab00b6946a8d6 --- /dev/null +++ b/tests/invariants/specs/escaping.php @@ -0,0 +1,138 @@ +describe( 'Read as a browser reads an href, what esc_url() returns stays in its attribute, and is relative, unparseable, or has a scheme in its allowed protocols.' ) + ->include_test_callers() + ->after( + 'esc_url', + static function ( WP_Invariant_Call $call ) { + $url = $call->return_value(); + if ( ! is_string( $url ) || '' === $url || ( false === strpos( $url, ':' ) && false === strpos( $url, '&' ) ) ) { + return; + } + + // In the display context the URL is written into HTML, and a browser decodes it first. + if ( 'display' === $call->arg( '_context' ) ) { + $url = WP_Invariant_Browser::attribute_value( $url ); + if ( ! $call->expect( null !== $url, 'esc_url() returned %s, which breaks out of an attribute.', $call->return_value() ) ) { + return; + } + } + + $scheme = WP_Invariant_Browser::url_scheme( $url ); + if ( null === $scheme ) { + return; + } + + $protocols = $call->arg( 'protocols' ); + if ( empty( $protocols ) ) { + $protocols = wp_allowed_protocols(); + } + $call->expect( in_array( $scheme, array_map( 'strtolower', $protocols ), true ), 'esc_url( %s ) returned a %s URL: %s', $call->arg( 'url' ), $scheme, $call->return_value() ); + } + ), + + WP_Invariant::define( 'esc-attr-safe' ) + ->describe( 'What esc_attr() returns cannot break out of an attribute value, in double or single quotes, as a browser reads it.' ) + ->include_test_callers() + ->after( + 'esc_attr', + static function ( WP_Invariant_Call $call ) { + $text = $call->return_value(); + if ( ! is_string( $text ) || false === strpbrk( $text, "\"'<>" ) ) { + return; + } + + foreach ( array( '
', "
" ) as $markup ) { + $elements = WP_Invariant_Browser::elements( $markup ); + if ( ! $call->expect( 1 === count( $elements ) && 1 === count( $elements[0]->attributes ), 'esc_attr( %s ) returned %s, which breaks out of an attribute.', $call->arg( 'text' ), $text ) ) { + return; + } + } + } + ), + + WP_Invariant::define( 'esc-xml-well-formed' ) + ->describe( 'What esc_xml() returns is well-formed XML text: inside an element, it is parsed without error and adds no elements.' ) + ->include_test_callers() + ->after( + 'esc_xml', + static function ( WP_Invariant_Call $call ) { + $text = $call->return_value(); + if ( ! is_string( $text ) || false === strpbrk( $text, '<&' ) ) { + return; + } + + $children = WP_Invariant_Browser::xml_child_elements( $text ); + $call->expect( 0 === $children, 'esc_xml( %s ) returned %s, which is %s.', $call->arg( 'text' ), $text, null === $children ? 'not well-formed' : 'markup' ); + } + ), + + WP_Invariant::define( 'esc-js-stays-in-string' ) + ->describe( 'What esc_js() returns stays in a single-quoted JavaScript string inside an inline event handler, as a browser reads it: the attribute stays one attribute, and the string has no unescaped quote or line break.' ) + ->include_test_callers() + ->after( + 'esc_js', + static function ( WP_Invariant_Call $call ) { + $escaped = $call->return_value(); + if ( ! is_string( $escaped ) || '' === $escaped ) { + return; + } + + $tag = WP_Invariant_Browser::start_tag( '' ); + if ( ! $call->expect( null !== $tag && array( 'onclick' ) === array_keys( $tag['attributes'] ), 'esc_js( %s ) returned %s, which breaks out of an attribute.', $call->arg( 'text' ), $escaped ) ) { + return; + } + + // What the script sees between the quotes, with its escapes taken out. + $script = $tag['attributes']['onclick']; + $string = preg_replace( '/\\\\./s', '', substr( $script, 3, -2 ) ); + $call->expect( + "f('" === substr( $script, 0, 3 ) && "')" === substr( $script, -2 ) && false === strpbrk( $string, "'\r\n" ), + 'esc_js( %s ) returned %s, which a browser runs as %s.', + $call->arg( 'text' ), + $escaped, + $script + ); + } + ), + + WP_Invariant::define( 'esc-textarea-reads-back' ) + ->describe( 'What esc_textarea() returns, inside a TEXTAREA, stays one TEXTAREA, which a browser gives the original text as its value, apart from a leading line break, which browsers drop.' ) + ->include_test_callers() + ->after( + 'esc_textarea', + static function ( WP_Invariant_Call $call ) { + $escaped = $call->return_value(); + $text = $call->arg( 'text' ); + if ( ! is_string( $escaped ) || ! is_scalar( $text ) ) { + return; + } + + $text = (string) $text; + if ( '' === $escaped && '' !== $text ) { + $call->expect( false, 'esc_textarea() returned an empty string for %d bytes of text, which are %s.', strlen( $text ), mb_check_encoding( $text, 'UTF-8' ) ? 'valid UTF-8' : 'not valid UTF-8' ); + return; + } + if ( ! mb_check_encoding( $text, 'UTF-8' ) ) { + // In another character set, which the browser here does not read. + return; + } + + // As a browser reads text: line breaks become LF, and NUL the replacement character. + $expected = str_replace( array( "\r\n", "\r", "\0" ), array( "\n", "\n", "\u{FFFD}" ), $text ); + if ( "\n" === substr( $expected, 0, 1 ) ) { + $expected = substr( $expected, 1 ); + } + $value = WP_Invariant_Browser::textarea_value( $escaped ); + $call->expect( $value === $expected, 'esc_textarea( %s ) returned %s, which a browser reads as %s.', $text, $escaped, $value ); + } + ), +); diff --git a/tests/invariants/specs/files.php b/tests/invariants/specs/files.php new file mode 100644 index 0000000000000..74d923e658fb5 --- /dev/null +++ b/tests/invariants/specs/files.php @@ -0,0 +1,55 @@ +describe( 'What sanitize_file_name() and wp_unique_filename() return is a name, not a path: it has no slash, backslash or NUL, and is not . or ..' ) + ->include_test_callers() + ->after_each( + array( 'sanitize_file_name', 'wp_unique_filename' ), + static function ( WP_Invariant_Call $call ) { + $name = $call->return_value(); + if ( is_string( $name ) ) { + $call->expect( false === strpbrk( $name, "/\\\0" ) && '.' !== $name && '..' !== $name, '%s( %s ) returned %s.', $call->target(), $call->arg( 'filename' ), $name ); + } + } + ), + + WP_Invariant::define( 'validated-paths-stay-inside' ) + ->describe( 'A path validate_file() accepts, by returning 0, stays inside the directory it is read from: no .. in it climbs above where it starts. Callers append it to a directory, such as WP_PLUGIN_DIR, so a path starting with a slash starts there too.' ) + ->include_test_callers() + ->after( + 'validate_file', + static function ( WP_Invariant_Call $call ) { + $file = $call->arg( 'file' ); + if ( 0 !== $call->return_value() || ! is_scalar( $file ) ) { + return; + } + + // As validate_file() reads it, with backslashes as slashes. A stream wrapper, such as php://memory, is not in a directory. + $path = str_replace( '\\', '/', (string) $file ); + if ( false !== strpos( $path, '://' ) ) { + return; + } + + $depth = 0; + foreach ( explode( '/', $path ) as $segment ) { + if ( '..' === $segment ) { + --$depth; + } elseif ( '' !== $segment && '.' !== $segment ) { + ++$depth; + } + if ( $depth < 0 ) { + break; + } + } + + $call->expect( $depth >= 0, 'validate_file( %s ) accepted a path which climbs above the directory it is read from.', $file ); + } + ), +); diff --git a/tests/invariants/specs/filesystem.php b/tests/invariants/specs/filesystem.php new file mode 100644 index 0000000000000..6190a463953af --- /dev/null +++ b/tests/invariants/specs/filesystem.php @@ -0,0 +1,144 @@ + array( 0 ), + 'is_file' => array( 0 ), + 'is_dir' => array( 0 ), + 'is_link' => array( 0 ), + 'is_readable' => array( 0 ), + 'is_writable' => array( 0 ), + 'is_writeable' => array( 0 ), + 'filesize' => array( 0 ), + 'filemtime' => array( 0 ), + 'fileperms' => array( 0 ), + 'stat' => array( 0 ), + 'fopen' => array( 0 ), + 'file_get_contents' => array( 0 ), + 'file_put_contents' => array( 0 ), + 'file' => array( 0 ), + 'readfile' => array( 0 ), + 'md5_file' => array( 0 ), + 'sha1_file' => array( 0 ), + 'hash_file' => array( 1 ), + 'getimagesize' => array( 0 ), + 'unlink' => array( 0 ), + 'mkdir' => array( 0 ), + 'rmdir' => array( 0 ), + 'touch' => array( 0 ), + 'chmod' => array( 0 ), + 'opendir' => array( 0 ), + 'scandir' => array( 0 ), + 'glob' => array( 0 ), + 'tempnam' => array( 0 ), + 'copy' => array( 0, 1 ), + 'rename' => array( 0, 1 ), + 'move_uploaded_file' => array( 0, 1 ), +); + +/* + * The directories WordPress runs in, as the configuration names them, each as it + * resolves and as its real path, ending in a slash. Those are where WordPress is + * installed, its content, plugin, language and theme directories, temporary + * directories, and the test suite's own files, which tests give to WordPress. + */ +$roots = static function () { + static $known = array(); + + $directories = array( sys_get_temp_dir(), '/tmp', (string) ini_get( 'upload_tmp_dir' ), dirname( __DIR__, 2 ) ); + foreach ( array( 'ABSPATH', 'WP_CONTENT_DIR', 'WP_PLUGIN_DIR', 'WPMU_PLUGIN_DIR', 'WP_LANG_DIR', 'WP_TEMP_DIR' ) as $constant ) { + if ( defined( $constant ) ) { + $directories[] = (string) constant( $constant ); + } + } + if ( ! empty( $GLOBALS['wp_theme_directories'] ) ) { + $directories = array_merge( $directories, array_filter( (array) $GLOBALS['wp_theme_directories'], 'is_string' ) ); + } + + $key = implode( "\0", $directories ); + if ( ! isset( $known[ $key ] ) ) { + $roots = array(); + foreach ( array_filter( $directories ) as $directory ) { + $roots = array_merge( $roots, WP_Invariant::directory_forms( $directory ) ); + } + $known = array( $key => array_values( array_unique( $roots ) ) ); + } + + return $known[ $key ]; +}; + +// Devices PHP code reads for random bytes, or writes to throw output away. +$devices = array( + '/dev/null' => true, + '/dev/random' => true, + '/dev/urandom' => true, +); + +return array( + WP_Invariant::define( 'paths-stay-inside' ) + ->describe( 'WordPress gives the file system no path outside the directories it runs in: where it is installed, its content, plugin, language and theme directories, and temporary directories, apart from /dev/null and the random devices. A .. in a path never takes it out of them. Each place in WordPress is reported once for each path.' ) + ->state( + static function () { + return array(); + }, + 'run' + ) + ->before_each( + array_keys( $functions ), + static function ( WP_Invariant_Call $call, array &$reported ) use ( $functions, $roots, $devices ) { + $args = $call->args(); + foreach ( $functions[ $call->target() ] as $position ) { + $path = isset( $args[ $position ] ) ? $args[ $position ] : null; + if ( ! is_string( $path ) || '' === $path || false !== strpos( $path, "\0" ) ) { + continue; + } + + // Other stream wrappers, such as php://memory, are not paths in a directory. + $scheme = strpos( $path, '://' ); + if ( false !== $scheme && 0 === strncasecmp( $path, 'file://', 7 ) ) { + $path = substr( $path, 7 ); + } elseif ( false !== $scheme ) { + continue; + } + + $resolved = WP_Invariant::resolve_path( $path ); + if ( isset( $devices[ $resolved ] ) || WP_Invariant::path_inside( $resolved, $roots() ) ) { + continue; + } + + // A symbolic link, such as /tmp on macOS, can lead inside. + $real = realpath( $resolved ); + $real = false === $real ? realpath( dirname( $resolved ) ) : $real; + if ( false !== $real && WP_Invariant::path_inside( $real, $roots() ) ) { + continue; + } + + $caller = $call->caller(); + $site = $caller['file'] . ':' . $caller['line'] . ' ' . $resolved; + if ( isset( $reported[ $site ] ) || ! WP_Invariant::is_core_file( $caller['file'] ) ) { + continue; + } + + $reported[ $site ] = true; + $stack = $call->stack( 1 ); + $call->expect( + false, + '%s() in %s was given %s%s, outside the directories WordPress runs in.', + $call->target(), + $stack ? $stack[0] : $caller['file'] . ':' . $caller['line'], + $path, + $resolved === $path ? '' : ", which is {$resolved}" + ); + } + } + ), +); diff --git a/tests/invariants/specs/formatting.php b/tests/invariants/specs/formatting.php new file mode 100644 index 0000000000000..4ac53a2be0635 --- /dev/null +++ b/tests/invariants/specs/formatting.php @@ -0,0 +1,103 @@ + 0 && '[' === $text[ $at - 1 ] ) { + continue; + } + + $closing = isset( $text[ $at + 1 ] ) && '/' === $text[ $at + 1 ]; + $starts = $at + ( $closing ? 2 : 1 ); + $name = substr( $text, $starts, strspn( $text, $name_characters, $starts ) ); + if ( '' !== $name && isset( $GLOBALS['shortcode_tags'][ $name ] ) ) { + $kind = ( $closing ? '[/' : '[' ) . $name; + $counts[ $kind ] = ( isset( $counts[ $kind ] ) ? $counts[ $kind ] : 0 ) + 1; + } + } + + return $counts; +}; + +// The markup wpautop() adds, as it is meant to. +$paragraphs = array( + ' true, + ' true, + ' true, +); + +return array( + WP_Invariant::define( 'esc-html-no-tags' ) + ->describe( 'What esc_html() returns contains no < or >, so it cannot open or close a tag.' ) + ->after( + 'esc_html', + static function ( WP_Invariant_Call $call ) { + $result = $call->return_value(); + $call->expect( false === strpbrk( (string) $result, '<>' ), 'esc_html( %s ) returned %s.', $call->arg( 'text' ), $result ); + } + ), + + WP_Invariant::define( 'strip-all-tags-has-no-elements' ) + ->describe( 'A browser builds no elements from what wp_strip_all_tags() returns, read as the contents of a BODY element.' ) + ->include_test_callers() + ->after( + 'wp_strip_all_tags', + static function ( WP_Invariant_Call $call ) { + $text = $call->return_value(); + if ( ! is_string( $text ) || false === strpos( $text, '<' ) ) { + return; + } + + $elements = WP_Invariant_Browser::elements( $text ); + $call->expect( array() === $elements, 'wp_strip_all_tags( %s ) returned %s, in which a browser finds the element %s.', $call->arg( 'text' ), $text, array() === $elements ? '' : WP_Invariant_Browser::tag_name( $elements[0] ) ); + } + ), + + WP_Invariant::define( 'no-new-markup' ) + ->describe( 'wp_kses(), wpautop() and wptexturize() create no markup. As the HTML API reads them, what they return has no more of any start tag, end tag, comment or DOCTYPE, or of any registered shortcode, than what they were given, apart from the P and BR tags wpautop() adds.' ) + ->include_test_callers() + ->after_each( + array( 'wp_kses', 'wpautop', 'wptexturize' ), + static function ( WP_Invariant_Call $call ) use ( $shortcodes, $paragraphs ) { + $args = $call->args(); + $input = isset( $args[0] ) ? $args[0] : null; + $output = $call->return_value(); + if ( ! is_string( $input ) || ! is_string( $output ) || $input === $output || false === strpbrk( $output, '<[' ) ) { + return; + } + + $before = WP_Invariant_HTML_API::markup( $input ); + $after = WP_Invariant_HTML_API::markup( $output ); + if ( null === $before ) { + return; + } + + $before += $shortcodes( $input ); + $after += $shortcodes( $output ); + foreach ( $after as $kind => $count ) { + $had = isset( $before[ $kind ] ) ? $before[ $kind ] : 0; + if ( $count > $had && ( 'wpautop' !== $call->target() || ! isset( $paragraphs[ $kind ] ) ) ) { + $call->expect( false, '%s() created markup: it was given %d of %s and returned %d, from %s to %s', $call->target(), $had, $kind, $count, $input, $output ); + return; + } + } + } + ), +); diff --git a/tests/invariants/specs/html.php b/tests/invariants/specs/html.php new file mode 100644 index 0000000000000..4473e65801815 --- /dev/null +++ b/tests/invariants/specs/html.php @@ -0,0 +1,97 @@ +describe( 'When WP_HTML_Processor::normalize() returns HTML, a browser builds the same tree from it as from the original HTML, both parsed as the contents of a BODY element.' ) + ->include_test_callers() + ->after( + 'WP_HTML_Processor::normalize', + static function ( WP_Invariant_Call $call ) { + $normalized = $call->return_value(); + if ( ! is_string( $normalized ) ) { + return; + } + + $html = $call->arg( 'html' ); + $expected = WP_Invariant_Browser::tree( $html ); + $actual = WP_Invariant_Browser::tree( $normalized ); + $call->expect( $expected === $actual, 'WP_HTML_Processor::normalize( %s ) returned %s, which a browser reads as %s instead of %s.', $html, $normalized, $actual, $expected ); + } + ), + + WP_Invariant::define( 'get-attribute-agrees-with-browser' ) + ->describe( 'What WP_HTML_Tag_Processor::get_attribute() returns for a start tag in the document is what a browser reads from that tag: the value of a string, an empty value for true, and no attribute for null.' ) + ->include_test_callers() + ->after( + 'WP_HTML_Tag_Processor::get_attribute', + static function ( WP_Invariant_Call $call ) { + $processor = $call->self(); + $name = $call->arg( 'name' ); + $value = $call->return_value(); + $class = 'WP_HTML_Tag_Processor'; + + // Pending changes are not in the document yet, and asking for `class` applies them. + if ( + ! is_string( $name ) || 'class' === strtolower( $name ) || + WP_HTML_Tag_Processor::STATE_MATCHED_TAG !== WP_Invariant::property( $processor, $class, 'parser_state' ) || + WP_Invariant::property( $processor, $class, 'is_closing_tag' ) || + array() !== WP_Invariant::property( $processor, $class, 'lexical_updates' ) || + array() !== WP_Invariant::property( $processor, $class, 'classname_updates' ) + ) { + return; + } + + $tag = substr( WP_Invariant::property( $processor, $class, 'html' ), WP_Invariant::property( $processor, $class, 'token_starts_at' ), WP_Invariant::property( $processor, $class, 'token_length' ) ); + $browser = WP_Invariant_Browser::start_tag( $tag ); + + // The browser changes some elements into others, such as IMAGE into IMG, or ignores them, such as BODY. + if ( null === $browser || ! preg_match( '~^<([^\s/>]+)~', $tag, $match ) || strtolower( $match[1] ) !== $browser['name'] ) { + return; + } + + $name = strtolower( $name ); + $expected = isset( $browser['attributes'][ $name ] ) ? $browser['attributes'][ $name ] : null; + $actual = true === $value ? '' : $value; + $call->expect( $actual === $expected, 'WP_HTML_Tag_Processor::get_attribute( %s ) returned %s for %s, which a browser reads as %s.', $name, $value, $tag, $expected ); + } + ), + + WP_Invariant::define( 'decoder-agrees-with-browser' ) + ->describe( 'WP_HTML_Decoder decodes character references as a browser does: in text as in the contents of a TEXTAREA, and in attribute values as in a double-quoted attribute.' ) + ->include_test_callers() + ->after( + 'WP_HTML_Decoder::decode_text_node', + static function ( WP_Invariant_Call $call ) { + $text = $call->arg( 'text' ); + + // A browser changes line breaks and NUL, which the decoder leaves to the tag processor. + if ( ! is_string( $text ) || false === strpos( $text, '&' ) || false !== strpbrk( $text, "\r\0" ) || false !== stripos( $text, 'expect( $call->return_value() === $expected, 'WP_HTML_Decoder::decode_text_node( %s ) returned %s, which a browser reads as %s.', $text, $call->return_value(), $expected ); + } + ) + ->after( + 'WP_HTML_Decoder::decode_attribute', + static function ( WP_Invariant_Call $call ) { + $text = $call->arg( 'text' ); + if ( ! is_string( $text ) || false === strpos( $text, '&' ) || false !== strpbrk( $text, "\"\r\0" ) ) { + return; + } + + $tag = WP_Invariant_Browser::start_tag( '' ); + $expected = null === $tag ? null : $tag['attributes']['title']; + $call->expect( $call->return_value() === $expected, 'WP_HTML_Decoder::decode_attribute( %s ) returned %s, which a browser reads as %s.', $text, $call->return_value(), $expected ); + } + ), +); diff --git a/tests/invariants/specs/http.php b/tests/invariants/specs/http.php new file mode 100644 index 0000000000000..d89e3cbff0cb5 --- /dev/null +++ b/tests/invariants/specs/http.php @@ -0,0 +1,61 @@ +describe( 'When wp_http_validate_url() accepts a URL, an HTTP client goes to the host it checked, as a WHATWG URL parser reads the URL, and that host is not a loopback, private, link-local, carrier-grade NAT or unspecified address, in any IPv4 or IPv6 form, unless it is the site\'s own host or the http_request_host_is_external filter allows it.' ) + ->include_test_callers() + ->after( + 'wp_http_validate_url', + static function ( WP_Invariant_Call $call ) use ( $ascii ) { + $url = $call->return_value(); + if ( ! is_string( $url ) || '' === $url ) { + return; + } + + $client = Uri\WhatWg\Url::parse( $url ); + if ( null === $client ) { + return; + } + $host = trim( strtolower( (string) $client->getAsciiHost() ), '[].' ); + + // What wp_http_validate_url() checked. + $parsed = parse_url( $url ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- What wp_http_validate_url() uses. + $checked = is_array( $parsed ) && isset( $parsed['host'] ) ? trim( strtolower( $parsed['host'] ), '.' ) : ''; + if ( strlen( $checked ) !== strspn( $checked, $ascii ) ) { + // A client goes to an international domain name by its ASCII form. + $checked = function_exists( 'idn_to_ascii' ) ? idn_to_ascii( $checked ) : false; + if ( false === $checked ) { + return; + } + } + + // wp_http_validate_url() resolves a host such as 0x7f.1 with gethostbyname(), which reads IPv4 addresses as a WHATWG URL parser does. + $same = $host === $checked || ( filter_var( $host, FILTER_VALIDATE_IP ) && '' !== $checked && gethostbyname( $checked ) === $host ); + if ( ! $call->expect( $same, 'wp_http_validate_url( %s ) checked the host %s, but an HTTP client would go to %s.', $call->arg( 'url' ), $checked, $host ) ) { + return; + } + + $internal = filter_var( $host, FILTER_VALIDATE_IP ) ? WP_Invariant_Security::internal_address( $host ) : null; + if ( null === $internal ) { + return; + } + + $home = parse_url( (string) get_option( 'home' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- What wp_http_validate_url() uses. + if ( is_array( $home ) && isset( $home['host'] ) && strtolower( $home['host'] ) === $checked ) { + return; + } + + /** This filter is documented in wp-includes/http.php */ + $call->expect( (bool) apply_filters( 'http_request_host_is_external', false, $host, $url ), 'wp_http_validate_url( %s ) accepted a URL whose host %s is a %s address.', $call->arg( 'url' ), $host, $internal ); + } + ), +); diff --git a/tests/invariants/specs/json.php b/tests/invariants/specs/json.php new file mode 100644 index 0000000000000..02b4822b3c16b --- /dev/null +++ b/tests/invariants/specs/json.php @@ -0,0 +1,18 @@ +describe( 'wp_json_encode() returns a string. When it returns false, a REST response or a script\'s data is quietly empty.' ) + ->after( + 'wp_json_encode', + static function ( WP_Invariant_Call $call ) { + $call->expect( is_string( $call->return_value() ), 'wp_json_encode() could not encode a value of type %s: %s', gettype( $call->arg( 'value' ) ), json_last_error_msg() ); + } + ), +); diff --git a/tests/invariants/specs/kses.php b/tests/invariants/specs/kses.php new file mode 100644 index 0000000000000..136154a72e4c0 --- /dev/null +++ b/tests/invariants/specs/kses.php @@ -0,0 +1,357 @@ + true, + 'head' => true, + 'body' => true, + 'tbody' => true, + 'colgroup' => true, +); + +/* + * Where HTML next has an escaped tag or comment: a character reference for `<`, such as + * `<`, `<` or `<`, before a letter, `/` and a letter, or `!--`. It is + * found without PCRE, which would reset preg_last_error() for the code under test. + */ +$escaped_tag = static function ( $html, $from ) { + for ( $at = strpos( $html, '&', $from ); false !== $at; $at = strpos( $html, '&', $at + 1 ) ) { + $end = null; + if ( '<' === substr( $html, $at, 4 ) || '<' === substr( $html, $at, 4 ) ) { + $end = $at + 4; + } elseif ( '&#' === substr( $html, $at, 2 ) ) { + $hex = isset( $html[ $at + 2 ] ) && ( 'x' === $html[ $at + 2 ] || 'X' === $html[ $at + 2 ] ); + $digits = $at + ( $hex ? 3 : 2 ); + $digits = $digits + strspn( $html, '0', $digits ); + if ( strtolower( substr( $html, $digits, 2 ) ) !== ( $hex ? '3c' : '60' ) ) { + continue; + } + + // Without a semicolon, the reference takes every digit which follows, as in `ϋ`. + $end = $digits + 2; + if ( isset( $html[ $end ] ) && ';' === $html[ $end ] ) { + ++$end; + } elseif ( strspn( $html, $hex ? '0123456789abcdefABCDEF' : '0123456789', $end, 1 ) ) { + continue; + } + } + + $next = null === $end ? '' : substr( $html, $end, 3 ); + if ( '' !== $next && ( ctype_alpha( $next[0] ) || ( '/' === $next[0] && isset( $next[1] ) && ctype_alpha( $next[1] ) ) || '!--' === $next ) ) { + return array( $at, substr( $html, $at, $end - $at ) . $next ); + } + } + + return null; +}; + +/* + * Returns what makes HTML unsafe which kses would filter out of it: an element + * or comment it removes, an event handler attribute, or a URL with a scheme it + * does not allow. Returns null when kses would only change what is harmless. + */ +$unsafe_html = static function ( $html, $filtered ) { + $before = WP_Invariant_HTML_API::markup( $html ); + $after = WP_Invariant_HTML_API::markup( $filtered ); + if ( null === $before || null === $after ) { + return null; + } + foreach ( $before as $kind => $count ) { + if ( $count > ( isset( $after[ $kind ] ) ? $after[ $kind ] : 0 ) ) { + return "it has {$kind} markup, which kses removes"; + } + } + + $uri_attributes = wp_kses_uri_attributes(); + $protocols = wp_allowed_protocols(); + $processor = new WP_HTML_Tag_Processor( $html ); + while ( $processor->next_tag() ) { + $handlers = $processor->get_attribute_names_with_prefix( 'on' ); + if ( ! empty( $handlers ) ) { + return sprintf( 'its %s tag has the event handler %s', $processor->get_tag(), $handlers[0] ); + } + foreach ( $uri_attributes as $attribute ) { + $url = $processor->get_attribute( $attribute ); + if ( is_string( $url ) && wp_kses_bad_protocol( $url, $protocols ) !== $url ) { + return sprintf( 'its %s tag has the URL %s, with a scheme kses does not allow', $processor->get_tag(), $url ); + } + } + } + + return null; +}; + +/* + * Whether test code called an API which stores what it is given without + * filtering it, by design, such as wp_insert_comment(), which wp_new_comment() + * calls once it has filtered the comment. + */ +$raw_from_tests = static function () { + foreach ( debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS ) as $frame ) { + if ( 'wp_insert_comment' === $frame['function'] && ! isset( $frame['class'] ) ) { + return isset( $frame['file'] ) && WP_Invariant::is_test_file( $frame['file'] ); + } + } + + return false; +}; + + +// What HTML escaping functions returned in the running test with escaped markup in it: output => function. +$escaped = WP_Invariant::shared( 'kses/escaped' ); + +// Most outputs kept; past that, the oldest are forgotten. +$most_escaped = 200; + +// PHP's and WordPress's functions which escape HTML. +$escapers = array( + 'htmlspecialchars' => true, + 'htmlentities' => true, + 'esc_html' => true, + 'esc_attr' => true, + 'esc_textarea' => true, + 'esc_xml' => true, + '_wp_specialchars' => true, +); + +return array( + WP_Invariant::define( 'input-not-escaped' ) + ->describe( 'wp_kses() is given HTML, not HTML which WordPress escaped first: the text of its content has no escaped tag or comment, such as <p or </p, which an HTML escaping function returned, whether PHP\'s htmlspecialchars() or htmlentities(), or WordPress\'s esc_html(), esc_attr(), esc_textarea(), esc_xml() or _wp_specialchars(). Markup escaped before it is filtered shows up as text. Escaped markup which content arrived with, as a user may type it, is left out, as are attribute values and the contents of elements such as TEXTAREA, which are escaped as they should be.' ) + ->after_each( + array_keys( $escapers ), + static function ( WP_Invariant_Call $call ) use ( $escaped, $escaped_tag, $most_escaped ) { + $output = $call->return_value(); + if ( ! is_string( $output ) || false === strpos( $output, '&' ) || null === $escaped_tag( $output, 0 ) ) { + return; + } + + if ( ! isset( $escaped->outputs ) ) { + $escaped->outputs = array(); + } + unset( $escaped->outputs[ $output ] ); + $escaped->outputs[ $output ] = $call->target(); + if ( count( $escaped->outputs ) > $most_escaped ) { + unset( $escaped->outputs[ array_key_first( $escaped->outputs ) ] ); + } + } + ) + ->before( + 'wp_kses', + static function ( WP_Invariant_Call $call ) use ( $escaped, $escaped_tag ) { + $content = $call->arg( 'content' ); + if ( empty( $escaped->outputs ) || ! is_string( $content ) || false === strpos( $content, '&' ) ) { + return; + } + + $texts = null; + for ( $found = $escaped_tag( $content, 0 ); null !== $found; $found = $escaped_tag( $content, $found[0] + 1 ) ) { + // Only escaped markup an escaping function returned counts, wherever it appears in the content. + $escaper = null; + foreach ( $escaped->outputs as $output => $function ) { + $output = (string) $output; + foreach ( WP_Invariant_Request::occurrences( $content, $output ) as $at ) { + if ( $found[0] >= $at && $found[0] < $at + strlen( $output ) ) { + $escaper = $function; + break 2; + } + } + } + if ( null === $escaper ) { + continue; + } + + $texts = null === $texts ? WP_Invariant_HTML_API::texts( $content ) : $texts; + foreach ( (array) $texts as $text ) { + if ( $found[0] >= $text[0] && $found[0] < $text[1] ) { + $call->expect( false, 'wp_kses() was given the escaped markup %s, which %s() escaped, at byte %d of %s', $found[1], $escaper, $found[0], substr( $content, max( 0, $found[0] - 40 ), 120 ) ); + return; + } + } + } + } + ), + + WP_Invariant::define( 'allowed-markup-only' ) + ->describe( 'Read as a browser reads it, what wp_kses() returns has only elements its allowed HTML allows, no event handler attributes unless allowed, and no URL with a scheme outside its allowed protocols.' ) + ->include_test_callers() + ->after( + 'wp_kses_allowed_html', + static function ( WP_Invariant_Call $call ) use ( $kses ) { + $context = $call->arg( 'context' ); + if ( is_string( $context ) ) { + $kses->allowed[ $context ] = $call->return_value(); + } + } + ) + ->after( + 'wp_kses_uri_attributes', + static function ( WP_Invariant_Call $call ) use ( $kses ) { + $kses->uri_attributes = array_fill_keys( array_map( 'strtolower', (array) $call->return_value() ), true ); + } + ) + ->after( + 'wp_kses', + static function ( WP_Invariant_Call $call ) use ( $kses, $implied ) { + $output = $call->return_value(); + if ( ! is_string( $output ) || false === strpos( $output, '<' ) ) { + return; + } + + // A context name is looked up as kses looked it up, without running its filter again. + $allowed = $call->arg( 'allowed_html' ); + if ( is_string( $allowed ) ) { + $allowed = isset( $kses->allowed[ $allowed ] ) ? $kses->allowed[ $allowed ] : null; + } + if ( is_array( $allowed ) ) { + $allowed = array_change_key_case( $allowed, CASE_LOWER ); + } + + $protocols = $call->arg( 'allowed_protocols' ); + if ( empty( $protocols ) ) { + $protocols = wp_allowed_protocols(); + } + $protocols = array_map( 'strtolower', $protocols ); + + foreach ( WP_Invariant_Browser::elements( $output ) as $element ) { + $tag = WP_Invariant_Browser::tag_name( $element ); + if ( isset( $implied[ $tag ] ) ) { + continue; + } + + if ( is_array( $allowed ) && ! $call->expect( isset( $allowed[ $tag ] ), 'wp_kses() let through a %s element: %s', $tag, $output ) ) { + return; + } + + $allowed_attributes = is_array( $allowed ) && is_array( $allowed[ $tag ] ) ? array_change_key_case( $allowed[ $tag ], CASE_LOWER ) : array(); + foreach ( $element->attributes as $attribute ) { + $name = strtolower( $attribute->name ); + if ( 0 === strpos( $name, 'on' ) && empty( $allowed_attributes[ $name ] ) ) { + $call->expect( false, 'wp_kses() let through the event handler %s on a %s element: %s', $name, $tag, $output ); + return; + } + + if ( isset( $kses->uri_attributes[ $name ] ) ) { + $scheme = WP_Invariant_Browser::url_scheme( $attribute->value ); + if ( null !== $scheme && ! $call->expect( in_array( $scheme, $protocols, true ), 'wp_kses() let through a %s URL in %s: %s', $scheme, $name, $output ) ) { + return; + } + } + } + } + } + ), + + WP_Invariant::define( 'idempotent-when-reapplied' ) + ->describe( 'Given something it returned earlier in the test, wp_kses() returns it unchanged, when its allowed HTML and protocols are the same.' ) + ->include_test_callers() + ->state( + static function () { + return array(); + }, + 'test' + ) + ->after( + 'wp_kses', + static function ( WP_Invariant_Call $call, array &$outputs ) { + $input = $call->arg( 'content' ); + $output = $call->return_value(); + if ( ! is_string( $input ) || ! is_string( $output ) ) { + return; + } + + $allowed = $call->arg( 'allowed_html' ); + $key = md5( serialize( array( is_string( $allowed ) ? $allowed : (array) $allowed, $call->arg( 'allowed_protocols' ) ) ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Only to compare arguments. + if ( isset( $outputs[ $key ][ $input ] ) ) { + $call->expect( $output === $input, 'wp_kses() changed what it had returned before, %s, to %s.', $input, $output ); + } + + unset( $outputs[ $key ][ $output ] ); + $outputs[ $key ][ $output ] = true; + if ( count( $outputs[ $key ] ) > 256 ) { + unset( $outputs[ $key ][ array_key_first( $outputs[ $key ] ) ] ); + } + } + ), + + WP_Invariant::define( 'stored-html-is-filtered' ) + ->describe( 'While WordPress filters a field with kses, which it does for users without unfiltered_html, what it stores in that field is already safe by kses: wp_kses() would remove no element or comment from it, and it has no event handler attribute and no URL with a scheme kses does not allow. Attributes WordPress adds after kses, such as rel on links in comments, do not count, and neither do comments tests store with wp_insert_comment(), which stores what it is given. That holds for the content, excerpt and title of posts, the content of comments, the descriptions of terms and the description of users, however they reach the database. Each path through WordPress is reported once for each field.' ) + ->state( + static function () { + return array(); + }, + 'run' + ) + ->before_each( + array( 'wpdb::insert', 'wpdb::update' ), + static function ( WP_Invariant_Call $call, array &$reported ) use ( $unsafe_html, $raw_from_tests ) { + $wpdb = $call->self(); + $table = $call->arg( 'table' ); + $data = $call->arg( 'data' ); + if ( ! is_array( $data ) || ! is_string( $table ) ) { + return; + } + + // Fields kses filters as they are saved: field => the filter which saves it. + $fields = array(); + if ( $table === $wpdb->posts ) { + $fields = array( + 'post_content' => 'content_save_pre', + 'post_excerpt' => 'excerpt_save_pre', + 'post_title' => 'title_save_pre', + ); + } elseif ( $table === $wpdb->comments ) { + $fields = array( 'comment_content' => 'pre_comment_content' ); + } elseif ( $table === $wpdb->term_taxonomy ) { + $fields = array( 'description' => 'pre_term_description' ); + } elseif ( $table === $wpdb->usermeta ) { + $where = 'wpdb::update' === $call->target() ? $call->arg( 'where' ) : $data; + if ( is_array( $where ) && isset( $where['meta_key'] ) && 'description' === $where['meta_key'] ) { + $fields = array( 'meta_value' => 'pre_user_description' ); + } + } + + foreach ( $fields as $field => $filter ) { + if ( ! isset( $data[ $field ] ) || ! is_string( $data[ $field ] ) || false === strpbrk( $data[ $field ], '<>&' ) ) { + continue; + } + + // wp_filter_kses() filters for the filter it runs in, and wp_filter_post_kses() as post content. + if ( false !== has_filter( $filter, 'wp_filter_post_kses' ) ) { + $context = 'post'; + } elseif ( false !== has_filter( $filter, 'wp_filter_kses' ) ) { + $context = $filter; + } else { + continue; + } + + $value = $data[ $field ]; + $filtered = wp_kses( $value, $context ); + $unsafe = $filtered === $value ? null : $unsafe_html( $value, $filtered ); + if ( null === $unsafe || $raw_from_tests() ) { + continue; + } + + $path = implode( ' < ', $call->stack( 4 ) ); + if ( isset( $reported[ "{$table}.{$field} {$path}" ] ) ) { + continue; + } + $reported[ "{$table}.{$field} {$path}" ] = true; + + $call->expect( false, '%s stored HTML in %s.%s which kses would filter, while the %s filter applies kses: %s. It stored %s', $call->target(), $table, $field, $filter, $unsafe, $value ); + } + } + ), +); diff --git a/tests/invariants/specs/mail.php b/tests/invariants/specs/mail.php new file mode 100644 index 0000000000000..49899ed0aeaf3 --- /dev/null +++ b/tests/invariants/specs/mail.php @@ -0,0 +1,65 @@ +describe( 'What wp_mail() is given can add no header of its own: no address or subject has a line break, no header given as an array element has one, and no value the request sent with a line break in it appears in the headers. Each place in WordPress is reported once.' ) + ->state( + static function () { + return array(); + }, + 'run' + ) + ->before( + 'wp_mail', + static function ( WP_Invariant_Call $call, array &$reported ) use ( $breaks ) { + $caller = $call->caller(); + $site = $caller['file'] . ':' . $caller['line']; + if ( isset( $reported[ $site ] ) ) { + return; + } + + $problems = array(); + foreach ( WP_Invariant::strings_in( $call->arg( 'to' ) ) as $address ) { + if ( $breaks( $address ) ) { + $problems[] = sprintf( 'the address %s has a line break', var_export( $address, true ) ); + } + } + if ( $breaks( $call->arg( 'subject' ) ) ) { + $problems[] = sprintf( 'the subject %s has a line break', var_export( $call->arg( 'subject' ), true ) ); + } + + $headers = $call->arg( 'headers' ); + if ( is_array( $headers ) ) { + foreach ( WP_Invariant::strings_in( $headers ) as $header ) { + if ( $breaks( rtrim( $header, "\r\n" ) ) ) { + $problems[] = sprintf( 'the header %s has a line break', var_export( $header, true ) ); + } + } + $headers = implode( "\n", WP_Invariant::strings_in( $headers ) ); + } + if ( is_string( $headers ) && '' !== $headers ) { + foreach ( WP_Invariant_Request::values( "\r\n" ) as $value ) { + if ( false !== strpos( $headers, $value ) ) { + $problems[] = sprintf( 'the headers hold the request value %s, which has a line break', var_export( $value, true ) ); + } + } + } + + if ( array() !== $problems ) { + $reported[ $site ] = true; + $call->expect( false, 'wp_mail() was given headers it did not mean to send: %s.', implode( '; ', $problems ) ); + } + } + ), +); diff --git a/tests/invariants/specs/multisite.php b/tests/invariants/specs/multisite.php new file mode 100644 index 0000000000000..b6b16f1374645 --- /dev/null +++ b/tests/invariants/specs/multisite.php @@ -0,0 +1,55 @@ +describe( 'A WordPress function which calls switch_to_blog() has called restore_current_blog() as often by the time it returns, so that what runs after it works on the site it expects, not another site of the network.' ) + ->before_each( + $switchers, + static function () { + return isset( $GLOBALS['_wp_switched_stack'] ) ? count( $GLOBALS['_wp_switched_stack'] ) : 0; + } + ) + ->after_each( + $switchers, + static function ( WP_Invariant_Call $call ) { + $depth = isset( $GLOBALS['_wp_switched_stack'] ) ? count( $GLOBALS['_wp_switched_stack'] ) : 0; + $call->expect( $depth === $call->before(), '%s() returned with %d more switch_to_blog() calls than restore_current_blog() calls; the current site is now %d.', $call->target(), $depth - $call->before(), get_current_blog_id() ); + } + ), +); diff --git a/tests/invariants/specs/option.php b/tests/invariants/specs/option.php new file mode 100644 index 0000000000000..2ad716f8e47a9 --- /dev/null +++ b/tests/invariants/specs/option.php @@ -0,0 +1,46 @@ +describe( 'The option name given to get_option() is a string which is not empty after trim().' ) + ->before( + 'get_option', + static function ( WP_Invariant_Call $call ) { + $option = $call->arg( 'option' ); + $call->expect( is_string( $option ) && '' !== trim( $option ), 'get_option() received the option name %s.', var_export( $option, true ) ); + } + ), + + WP_Invariant::define( 'names-fit-columns' ) + ->describe( 'Names fit the database columns they are stored in, in characters: option names in option_name, 191; transient names, which are stored with the prefix _transient_timeout_, 172, and site transient names, with _site_transient_timeout_, 167; and meta keys in meta_key, 255. MySQL would quietly cut a longer name short.' ) + ->before_each( + array( 'add_option', 'update_option', 'set_transient', 'set_site_transient', 'add_metadata', 'update_metadata' ), + static function ( WP_Invariant_Call $call ) { + $limits = array( + 'add_option' => array( 'option', 191 ), + 'update_option' => array( 'option', 191 ), + 'set_transient' => array( 'transient', 172 ), + 'set_site_transient' => array( 'transient', 167 ), + 'add_metadata' => array( 'meta_key', 255 ), + 'update_metadata' => array( 'meta_key', 255 ), + ); + + list( $parameter, $limit ) = $limits[ $call->target() ]; + $name = $call->arg( $parameter ); + if ( ! is_string( $name ) ) { + return; + } + + // Options are trimmed, and meta keys unslashed, before they are stored. + $name = 'meta_key' === $parameter ? stripslashes( $name ) : trim( $name ); + $length = mb_strlen( $name, 'UTF-8' ); + $call->expect( $length <= $limit, '%s() was given a name of %d characters, which is more than %d: %s', $call->target(), $length, $limit, $name ); + } + ), +); diff --git a/tests/invariants/specs/output.php b/tests/invariants/specs/output.php new file mode 100644 index 0000000000000..fcf5679b97e28 --- /dev/null +++ b/tests/invariants/specs/output.php @@ -0,0 +1,106 @@ + $kind ) { + if ( $offset < $at || $offset >= $at + $length ) { + continue; + } + // A value may be text, or fill an attribute value; it may not start markup of its own. + if ( $offset === $at && 'attribute value' === $kind ) { + continue; + } + + $reported[ $value ] = true; + $call->expect( + false, + 'The request value %s appears in printed HTML, where a %s starts %s it: %s', + $value, + $kind, + $offset === $at ? 'with' : 'inside', + substr( $html, max( 0, $at - 40 ), $length + 80 ) + ); + continue 3; + } + } + } +}; + +return array( + WP_Invariant::define( 'request-values-stay-text' ) + ->describe( 'A value the request sent, where it appears in HTML WordPress printed, is text or a whole attribute value: as the HTML API reads the whole output, no tag, comment, attribute or attribute value starts inside it, and it starts no tag, comment or attribute. The HTML in the strings of a JSON response counts too. Requests from users with unfiltered_html, who may send HTML, are left out. Values shorter than 4 bytes, or without <, >, quotes, = or whitespace, are not followed. Each value is reported once.' ) + ->include_test_callers() + ->state( + static function () { + return array(); + }, + 'run' + ) + ->after_each( + array( 'ob_get_clean', 'ob_get_contents' ), + static function ( WP_Invariant_Call $call, array &$reported ) use ( $check ) { + $output = $call->return_value(); + if ( ! is_string( $output ) || false === strpos( $output, '<' ) ) { + return; + } + + // A user with unfiltered_html may send HTML, and see it printed. + if ( function_exists( 'current_user_can' ) && did_action( 'set_current_user' ) && current_user_can( 'unfiltered_html' ) ) { + return; + } + + $values = WP_Invariant_Request::values( "<>\"'= \t\n\f\r" ); + if ( array() === $values ) { + return; + } + + // A JSON response, as AJAX handlers send, holds its HTML in strings. + $documents = array( $output ); + $first = substr( ltrim( $output ), 0, 1 ); + if ( '{' === $first || '[' === $first ) { + $decoded = json_decode( $output, true ); + if ( null !== $decoded ) { + $documents = array_filter( + WP_Invariant::strings_in( $decoded ), + static function ( $text ) { + return false !== strpos( $text, '<' ); + } + ); + } + } + + foreach ( $documents as $html ) { + $check( $call, $reported, $html, $values ); + } + } + ), +); diff --git a/tests/invariants/specs/pcre.php b/tests/invariants/specs/pcre.php new file mode 100644 index 0000000000000..bfebe6badbbd8 --- /dev/null +++ b/tests/invariants/specs/pcre.php @@ -0,0 +1,194 @@ += 0 ? array_slice( $matches, 0, $limit ) : $matches as $match ) { + foreach ( $match as $group => $capture ) { + $at = $capture[1]; + if ( $at < 0 || ! isset( $boundaries[ $at ] ) ) { + continue; + } + + /* + * The syntax is the `<` which starts a tag or comment, or what comes just before a tag name, + * an attribute or its value, such as `= 2 && '<' === $subject[ $at - 2 ] ) { + $syntax[] = $at - 2; + } + foreach ( $syntax as $byte ) { + $neutral[ $byte ] = "\x01"; + } + + $still = array(); + if ( preg_match_all( $pattern, $neutral, $neutral_matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE, $offset ) ) { + foreach ( $neutral_matches as $neutral_match ) { + foreach ( $neutral_match as $neutral_capture ) { + $still[ $neutral_capture[1] ] = true; + } + } + } + if ( ! isset( $still[ $at ] ) ) { + return array( $group, $at, $kind ); + } + } + } + + return null; +}; + + +// Functions known to parse HTML with regular expressions. +$known_parsers = array( + 'wpautop' => true, + 'wptexturize' => true, + 'wp_html_split' => true, +); + +return array( + WP_Invariant::define( 'no-silent-failure' ) + ->describe( 'No PCRE function fails: none runs out of backtracking or recursion, or is given invalid UTF-8 for a /u pattern. Those failures return null or false, which callers rarely check.' ) + ->include_test_callers() + ->after_each( + array( 'preg_match', 'preg_match_all', 'preg_replace', 'preg_replace_callback', 'preg_split' ), + static function ( WP_Invariant_Call $call ) { + // Read it first: nothing before this line calls a PCRE function. + $error = preg_last_error(); + if ( PREG_NO_ERROR === $error ) { + return; + } + + $subject = $call->arg( 'subject' ); + $call->expect( + false, + '%s() failed: %s. The pattern was %s, and the subject %s.', + $call->target(), + preg_last_error_msg(), + $call->arg( 'pattern' ), + is_string( $subject ) ? strlen( $subject ) . ' bytes' : 'not a string' + ); + } + ), + + WP_Invariant::define( 'matches-no-html-syntax' ) + ->describe( 'Regular expressions in WordPress do not parse HTML: no match or group which a PCRE function returns starts where the HTML API finds a tag, a tag name, an attribute, an attribute value, a comment or a DOCTYPE, unless the pattern would start there without that syntax, as `^.` or `\w+` would. So no replacement or split starts there either. The PCRE calls wpautop(), wptexturize() and wp_html_split() make themselves are left out: they are known to parse HTML this way. Each place in WordPress is reported once.' ) + ->state( + static function () { + return array(); + }, + 'run' + ) + ->after_each( + array( 'preg_match', 'preg_match_all', 'preg_replace', 'preg_replace_callback', 'preg_replace_callback_array', 'preg_split' ), + static function ( WP_Invariant_Call $call, array &$reported ) use ( $on_syntax, $known_parsers ) { + // A call which failed found nothing, and the error must stay for its caller to read. + if ( PREG_NO_ERROR !== preg_last_error() ) { + return; + } + + // Without $matches, preg_match() returns no match, only whether there is one. + $target = $call->target(); + if ( ( 'preg_match' === $target || 'preg_match_all' === $target ) && count( $call->args() ) < 3 ) { + return; + } + + $subject = $call->arg( 'subject' ); + $subjects = array_slice( array_filter( (array) $subject, 'is_string' ), 0, 8 ); + $html = false; + foreach ( $subjects as $text ) { + $html = $html || false !== strpos( $text, '<' ); + } + if ( ! $html ) { + return; + } + + $caller = $call->caller(); + $site = $caller['file'] . ':' . $caller['line']; + if ( isset( $reported[ $site ] ) || ! WP_Invariant::is_core_file( $caller['file'] ) ) { + return; + } + + // Functions known to parse HTML with regular expressions, whose own calls are not reported. + if ( isset( $known_parsers[ (string) $call->caller_function() ] ) ) { + return; + } + + $replaces = in_array( $target, array( 'preg_replace', 'preg_replace_callback', 'preg_replace_callback_array' ), true ); + $offset = in_array( $target, array( 'preg_match', 'preg_match_all' ), true ) ? (int) $call->arg( 'offset' ) : 0; + $limit = 'preg_match' === $target ? 1 : -1; + if ( $replaces ) { + $limit = (int) $call->arg( 'limit' ) > 0 ? (int) $call->arg( 'limit' ) : -1; + } elseif ( 'preg_split' === $target ) { + $limit = (int) $call->arg( 'limit' ) > 0 ? (int) $call->arg( 'limit' ) - 1 : -1; + } + + $patterns = 'preg_replace_callback_array' === $target ? array_keys( (array) $call->arg( 'pattern' ) ) : array_values( (array) $call->arg( 'pattern' ) ); + $replacements = 'preg_replace' === $target ? $call->arg( 'replacement' ) : null; + $replacements = is_array( $replacements ) ? array_values( $replacements ) : $replacements; + $found = null; + foreach ( $subjects as $text ) { + foreach ( $patterns as $index => $pattern ) { + $found = $on_syntax( $pattern, $text, $offset, $limit ); + // Later patterns see what earlier ones replaced, which only preg_replace() can work out again without running a callback. + if ( null !== $found || 'preg_replace' !== $target || ! is_string( $text ) ) { + break; + } + $replacement = is_array( $replacements ) ? ( isset( $replacements[ $index ] ) ? $replacements[ $index ] : '' ) : $replacements; + $text = preg_replace( $pattern, $replacement, $text, $limit ); + } + if ( null !== $found ) { + break; + } + } + + // Running the patterns again must leave no error for the code under test to find. + if ( PREG_NO_ERROR !== preg_last_error() ) { + preg_match( '/^/', '' ); + } + if ( null === $found ) { + return; + } + + list( $group, $at, $kind ) = $found; + $reported[ $site ] = true; + $stack = $call->stack( 1 ); + $call->expect( + false, + '%s() in %s matched HTML syntax with %s: %s starts at the %s at byte %d of %s', + $target, + $stack ? $stack[0] : $site, + $pattern, + 0 === $group ? 'the match' : "group {$group}", + $kind, + $at, + substr( $text, max( 0, $at - 40 ), 120 ) + ); + } + ), +); diff --git a/tests/invariants/specs/post.php b/tests/invariants/specs/post.php new file mode 100644 index 0000000000000..92cf2709e8d69 --- /dev/null +++ b/tests/invariants/specs/post.php @@ -0,0 +1,43 @@ +describe( 'A numeric post ID given to get_post() is a whole number above 0, or 0 for the current post.' ) + ->before( + 'get_post', + static function ( WP_Invariant_Call $call ) { + $post = $call->arg( 'post' ); + if ( ! is_numeric( $post ) ) { + return; + } + + $id = (int) $post; + $call->expect( + $id >= 0 && ( is_int( $post ) || (string) $id === (string) $post ), + 'get_post() received the post ID %s.', + var_export( $post, true ) + ); + } + ), + + WP_Invariant::define( 'returns-requested-post' ) + ->describe( 'get_post() given a post ID returns the post with that ID, or null.' ) + ->after( + 'get_post', + static function ( WP_Invariant_Call $call ) { + $post = $call->arg( 'post' ); + $result = $call->return_value(); + if ( ! is_numeric( $post ) || (int) $post <= 0 || ! is_object( $result ) || ! isset( $result->ID ) ) { + return; + } + + $call->expect( (int) $post === (int) $result->ID, 'get_post( %s ) returned the post %d.', var_export( $post, true ), $result->ID ); + } + ), +); diff --git a/tests/invariants/specs/query.php b/tests/invariants/specs/query.php new file mode 100644 index 0000000000000..16ebe5054feb0 --- /dev/null +++ b/tests/invariants/specs/query.php @@ -0,0 +1,38 @@ +describe( 'After WP_Query::get_posts(), post_count is the number of posts, and for a paged query which counts its matches, max_num_pages is found_posts over posts_per_page, rounded up.' ) + ->include_test_callers() + ->after( + 'WP_Query::get_posts', + static function ( WP_Invariant_Call $call ) { + $query = $call->self(); + if ( ! is_array( $query->posts ) ) { + return; + } + + if ( ! $call->expect( count( $query->posts ) === $query->post_count, 'WP_Query found %d posts, but its post_count is %d.', count( $query->posts ), $query->post_count ) ) { + return; + } + + // A singular query is not paged, and filters and plugins may count posts their own way. + $per_page = isset( $query->query_vars['posts_per_page'] ) ? (int) $query->query_vars['posts_per_page'] : 0; + if ( + array() === $query->posts || $per_page < 1 || $query->is_singular || ! empty( $query->query_vars['no_found_rows'] ) || ! empty( $query->query_vars['nopaging'] ) || + WP_Invariant::has_callbacks( 'found_posts' ) || WP_Invariant::has_callbacks( 'found_posts_query' ) || WP_Invariant::has_callbacks( 'posts_pre_query' ) + ) { + return; + } + + $expected = (int) ceil( $query->found_posts / $per_page ); + $call->expect( $expected === $query->max_num_pages, 'WP_Query found %d posts, %d per page, but its max_num_pages is %d.', $query->found_posts, $per_page, $query->max_num_pages ); + } + ), +); diff --git a/tests/invariants/specs/random.php b/tests/invariants/specs/random.php new file mode 100644 index 0000000000000..b2ef07724abe5 --- /dev/null +++ b/tests/invariants/specs/random.php @@ -0,0 +1,57 @@ + true, + 'get_password_reset_key' => true, + 'wp_generate_user_request_key' => true, + 'wp_generate_auth_cookie' => true, + 'wp_salt' => true, + 'wp_hash_password' => true, + 'WP_Session_Tokens::create' => true, + 'WP_Application_Passwords::create_new_application_password' => true, +); + +return array( + WP_Invariant::define( 'secrets-use-csprng' ) + ->describe( 'No function which makes a secret, such as wp_generate_password(), get_password_reset_key(), WP_Session_Tokens::create() or wp_salt(), uses a random number generator an attacker can predict: mt_rand(), rand(), uniqid(), lcg_value(), str_shuffle(), array_rand() or shuffle(). Each place in WordPress is reported once.' ) + ->state( + static function () { + return array(); + }, + 'run' + ) + ->before_each( + array( 'mt_rand', 'rand', 'uniqid', 'lcg_value', 'str_shuffle', 'array_rand', 'shuffle' ), + static function ( WP_Invariant_Call $call, array &$reported ) use ( $secret_makers ) { + $maker = null; + foreach ( WP_Invariant::running_functions() as $function ) { + if ( isset( $secret_makers[ $function ] ) ) { + $maker = $function; + break; + } + } + if ( null === $maker ) { + return; + } + + $caller = $call->caller(); + $site = $caller['file'] . ':' . $caller['line']; + if ( isset( $reported[ $site ] ) ) { + return; + } + $reported[ $site ] = true; + + $call->expect( false, '%s() ran while %s() made a secret: its output can be predicted.', $call->target(), $maker ); + } + ), +); diff --git a/tests/invariants/specs/redirect.php b/tests/invariants/specs/redirect.php new file mode 100644 index 0000000000000..d57c1ddef7c1a --- /dev/null +++ b/tests/invariants/specs/redirect.php @@ -0,0 +1,42 @@ +describe( 'When wp_validate_redirect() lets a location through, a browser goes to the host it checked: the host parse_url() finds, or for a location without a host, the site itself.' ) + ->include_test_callers() + ->after( + 'wp_validate_redirect', + static function ( WP_Invariant_Call $call ) { + $location = $call->return_value(); + if ( ! is_string( $location ) || '' === $location || $location === $call->arg( 'fallback_url' ) ) { + return; + } + + // wp_validate_redirect() checks the host parse_url() finds, against the hosts it allows. + $parsed = parse_url( $location ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- What wp_validate_redirect() uses. + $checked = is_array( $parsed ) && isset( $parsed['host'] ) ? strtolower( $parsed['host'] ) : 'site.invalid'; + + // A browser goes to an international domain name by its ASCII form. + if ( preg_match( '/[^\x20-\x7e]/', $checked ) ) { + $checked = function_exists( 'idn_to_ascii' ) ? idn_to_ascii( $checked ) : false; + if ( false === $checked ) { + return; + } + } + + // Any host on the site stands in for it: a location without a host stays on it. + $host = WP_Invariant_Browser::url_host( $location, 'http://site.invalid/wp-admin/' ); + if ( null === $host ) { + return; + } + + $call->expect( $host === $checked, 'wp_validate_redirect( %s ) let %s through, checking the host %s, but a browser would go to %s.', $call->arg( 'location' ), $location, $checked, $host ); + } + ), +); diff --git a/tests/invariants/specs/request.php b/tests/invariants/specs/request.php new file mode 100644 index 0000000000000..99b814d4d5a62 --- /dev/null +++ b/tests/invariants/specs/request.php @@ -0,0 +1,246 @@ + prefix. +$handlers = array( + 'AJAX' => 'wp_ajax_', + 'admin-post' => 'admin_post_', + 'admin action' => 'admin_action_', +); + +// Handlers which are meant to be public, and change nothing a forged request could abuse. +$public = array( + 'wp_ajax_nopriv_heartbeat' => true, + 'wp_ajax_nopriv_generate-password' => true, +); + +// Functions which change state, and the argument which names what they change, if any. +$writes = array( + 'wp_insert_post' => null, + 'wp_update_post' => null, + 'wp_delete_post' => null, + 'wp_trash_post' => null, + 'add_option' => 'option', + 'update_option' => 'option', + 'delete_option' => 'option', + 'wp_insert_user' => null, + 'wp_update_user' => null, + 'wp_delete_user' => null, + 'wp_set_password' => null, + 'wp_set_object_terms' => null, + 'wp_delete_comment' => null, + 'wp_set_comment_status' => null, + 'activate_plugin' => null, + 'switch_theme' => null, + 'update_user_meta' => 'meta_key', + 'update_post_meta' => 'meta_key', +); + +// Options WordPress keeps for itself, which any request may change. +$housekeeping = static function ( $name ) { + return ! is_string( $name ) || 0 === strpos( $name, '_transient_' ) || 0 === strpos( $name, '_site_transient_' ) || in_array( $name, array( 'cron', 'rewrite_rules' ), true ); +}; + +/* + * Returns the innermost request scope which is still running, after dropping + * those which ended, such as by throwing from wp_die(). + */ +$current = static function () use ( $scopes ) { + if ( empty( $scopes->stack ) ) { + return null; + } + + while ( array() !== $scopes->stack ) { + $scope = end( $scopes->stack ); + if ( WP_Invariant::running( $scope['mark'], $scope['function'] ) ) { + return $scope; + } + array_pop( $scopes->stack ); + } + + return null; +}; + +// Marks the innermost running scope as having passed a check. +$passed = static function ( $check ) use ( $scopes, $current ) { + if ( null !== $current() ) { + $scopes->stack[ count( $scopes->stack ) - 1 ][ $check ] = true; + } +}; + +/* + * Returns the scope a write happens in, when it is one the invariant looks at: + * not a write WordPress makes for itself. + */ +$write_scope = static function ( WP_Invariant_Call $call ) use ( $scopes, $writes, $housekeeping, $current ) { + if ( empty( $scopes->stack ) ) { + return null; + } + + if ( 'option' === $writes[ $call->target() ] && $housekeeping( $call->arg( 'option' ) ) ) { + return null; + } + + $scope = $current(); + if ( null === $scope ) { + return null; + } + + // A write inside another, such as add_option() in update_option(), is the outer one's; wp_salt() saves salts it makes on first use. + $running = array_count_values( WP_Invariant::running_functions() ); + foreach ( array_keys( $writes ) as $write ) { + if ( isset( $running[ $write ] ) && ( $write !== $call->target() || $running[ $write ] > 1 ) ) { + return null; + } + } + if ( isset( $running['wp_salt'] ) ) { + return null; + } + + return $scope; +}; + +$run_state = static function () { + return array(); +}; + +return array( + WP_Invariant::define( 'writes-check-a-nonce' ) + ->describe( 'Inside an AJAX, admin-post or admin action handler, functions which change posts, options, users, terms, comments, plugins or themes run only after a nonce check passed in that handler: wp_verify_nonce(), check_ajax_referer() or check_admin_referer(). Public handlers, such as heartbeat, transients, cron, rewrite rules and salts, and writes inside other writes are left out. Each handler is reported once for each function.' ) + ->state( $run_state, 'run' ) + ->before( + 'do_action', + static function ( WP_Invariant_Call $call ) use ( $scopes, $handlers ) { + $hook = $call->arg( 'hook_name' ); + // Most actions are not handlers; only those starting with w or a can be. + if ( ! is_string( $hook ) || '' === $hook || ( 'w' !== $hook[0] && 'a' !== $hook[0] ) ) { + return; + } + + foreach ( $handlers as $kind => $prefix ) { + if ( 0 === strncmp( $hook, $prefix, strlen( $prefix ) ) ) { + if ( ! isset( $scopes->stack ) ) { + $scopes->stack = array(); + } + $scopes->stack[] = array( + 'kind' => $kind, + 'name' => $hook, + 'function' => 'do_action', + 'mark' => WP_Invariant::frame_mark( 'do_action' ), + 'nonce' => false, + 'capability' => false, + 'user' => function_exists( 'get_current_user_id' ) ? get_current_user_id() : 0, + ); + return; + } + } + } + ) + ->after_each( + array( 'wp_verify_nonce', 'check_ajax_referer', 'check_admin_referer' ), + static function ( WP_Invariant_Call $call ) use ( $passed ) { + if ( $call->return_value() ) { + $passed( 'nonce' ); + } + } + ) + ->before_each( + array_keys( $writes ), + static function ( WP_Invariant_Call $call, array &$reported ) use ( $write_scope, $public ) { + $scope = $write_scope( $call ); + if ( null === $scope || 'REST' === $scope['kind'] || $scope['nonce'] || isset( $public[ $scope['name'] ] ) ) { + return; + } + + $key = "{$scope['name']} {$call->target()}"; + if ( isset( $reported[ $key ] ) ) { + return; + } + $reported[ $key ] = true; + $call->expect( false, '%s() ran in the %s handler %s before any nonce check passed.', $call->target(), $scope['kind'], $scope['name'] ); + } + ), + + WP_Invariant::define( 'writes-check-a-capability' ) + ->describe( 'Inside an AJAX, admin-post or admin action handler, or a REST callback, functions which change posts, options, users, terms, comments, plugins or themes run only after a capability check for the current user passed in it: WP_User::has_cap() returned true for a capability other than exist, as current_user_can() and the REST route\'s permission callback do. Users may change their own user meta. In a REST callback, the current user is the one the permission check ran for. Public handlers, such as heartbeat, transients, cron, rewrite rules and salts, and writes inside other writes are left out. Each handler or route is reported once for each function.' ) + ->state( $run_state, 'run' ) + ->before( + 'WP_REST_Server::dispatch', + static function ( WP_Invariant_Call $call ) { + // The parameters of a REST request are values the request sent, for every invariant which follows them. + $request = $call->arg( 'request' ); + WP_Invariant_Request::start_rest_request( $request instanceof WP_REST_Request ? $request->get_params() : array() ); + } + ) + ->after( + 'WP_REST_Server::dispatch', + static function () { + WP_Invariant_Request::end_rest_request(); + } + ) + ->before( + 'WP_REST_Server::respond_to_request', + static function ( WP_Invariant_Call $call ) use ( $scopes ) { + $route = $call->arg( 'route' ); + if ( ! isset( $scopes->stack ) ) { + $scopes->stack = array(); + } + $scopes->stack[] = array( + 'kind' => 'REST', + 'name' => is_string( $route ) ? $route : '?', + 'function' => 'WP_REST_Server::respond_to_request', + 'mark' => WP_Invariant::frame_mark( 'WP_REST_Server::respond_to_request' ), + 'nonce' => false, + 'capability' => false, + 'user' => get_current_user_id(), + ); + } + ) + ->after( + 'WP_User::has_cap', + static function ( WP_Invariant_Call $call ) use ( $scopes, $passed ) { + if ( ! empty( $scopes->stack ) && true === $call->return_value() && 'exist' !== $call->arg( 'cap' ) ) { + $passed( 'capability' ); + } + } + ) + ->before_each( + array_keys( $writes ), + static function ( WP_Invariant_Call $call, array &$reported ) use ( $write_scope, $public ) { + $scope = $write_scope( $call ); + if ( null === $scope || isset( $public[ $scope['name'] ] ) ) { + return; + } + + // A user may change their own settings, such as which boxes are closed, without any capability. + if ( 'update_user_meta' === $call->target() && (int) $call->arg( 'user_id' ) === get_current_user_id() ) { + return; + } + + if ( 'REST' === $scope['kind'] && get_current_user_id() !== $scope['user'] ) { + $call->expect( false, '%s() ran in the REST callback for %s as user %d, but its permission check ran for user %d.', $call->target(), $scope['name'], get_current_user_id(), $scope['user'] ); + return; + } + + $key = "{$scope['name']} {$call->target()}"; + if ( $scope['capability'] || isset( $reported[ $key ] ) ) { + return; + } + $reported[ $key ] = true; + $call->expect( false, '%s() ran in the %s %s %s before any capability check passed.', $call->target(), $scope['kind'], 'REST' === $scope['kind'] ? 'callback for' : 'handler', $scope['name'] ); + } + ), +); diff --git a/tests/invariants/specs/rest.php b/tests/invariants/specs/rest.php new file mode 100644 index 0000000000000..74c2696287db0 --- /dev/null +++ b/tests/invariants/specs/rest.php @@ -0,0 +1,23 @@ +describe( 'A permission check of a core REST controller returns true, false or a WP_Error. WP_REST_Server::respond_to_request() treats any other value but null as allowed, even 0, an empty string or an empty array.' ) + ->include_test_callers() + ->after_each( + $permission_checks, + static function ( WP_Invariant_Call $call ) { + $result = $call->return_value(); + $call->expect( is_bool( $result ) || $result instanceof WP_Error, '%s() returned %s.', $call->target(), is_scalar( $result ) ? var_export( $result, true ) : gettype( $result ) ); + } + ), +); diff --git a/tests/invariants/specs/sanitize.php b/tests/invariants/specs/sanitize.php new file mode 100644 index 0000000000000..54a940d5c2039 --- /dev/null +++ b/tests/invariants/specs/sanitize.php @@ -0,0 +1,92 @@ + array( 'sanitize_key', array(), 'abcdefghijklmnopqrstuvwxyz0123456789_-' ), + 'sanitize_html_class' => array( 'sanitize_html_class', array(), 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-' ), + 'sanitize_text_field' => array( 'sanitize_text_field', array(), null ), + 'sanitize_textarea_field' => array( 'sanitize_textarea_field', array(), null ), + 'sanitize_title' => array( 'sanitize_title', array( 'sanitize_title_with_dashes' ), null ), + 'sanitize_email' => array( 'sanitize_email', array(), null ), + 'sanitize_user' => array( 'sanitize_user', array(), null ), + 'sanitize_file_name' => array( 'sanitize_file_name', array(), null ), +); + +// The characters sanitize_user() keeps in strict mode. +$strict_user = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 _.-@'; + +// Whether a filter has only the callbacks WordPress adds to it, so that sanitizing again is up to WordPress alone. +$only_core_callbacks = static function ( $hook, array $core ) { + if ( ! WP_Invariant::has_callbacks( $hook ) ) { + return true; + } + foreach ( $GLOBALS['wp_filter'][ $hook ]->callbacks as $callbacks ) { + foreach ( $callbacks as $callback ) { + if ( ! is_string( $callback['function'] ) || ! in_array( $callback['function'], $core, true ) ) { + return false; + } + } + } + + return true; +}; + +return array( + WP_Invariant::define( 'idempotent' ) + ->describe( 'Sanitizing what a sanitizing function returned changes nothing: sanitize_key(), sanitize_html_class(), sanitize_text_field(), sanitize_textarea_field(), sanitize_title(), sanitize_email(), sanitize_user() and sanitize_file_name() return what they are given when they are given what they returned, with the same other arguments. Otherwise a check made between two sanitizations can be passed with a value which changes after it. What sanitize_key(), sanitize_html_class() and sanitize_user() in strict mode return holds only the characters they keep. Calls are left out while a plugin or test filters the function\'s result.' ) + ->include_test_callers() + ->state( + static function () { + return array(); + }, + 'run' + ) + ->after_each( + array_keys( $sanitizers ), + static function ( WP_Invariant_Call $call, array &$reported ) use ( $sanitizers, $strict_user, $only_core_callbacks ) { + $result = $call->return_value(); + if ( ! is_string( $result ) ) { + return; + } + + $function = $call->target(); + list( $hook, $core, $alphabet ) = $sanitizers[ $function ]; + if ( ! $only_core_callbacks( $hook, $core ) ) { + return; + } + + $args = $call->args(); + if ( 'sanitize_user' === $function && ! empty( $args[1] ) ) { + $alphabet = $strict_user; + } + + if ( null !== $alphabet && strspn( $result, $alphabet ) !== strlen( $result ) && ! isset( $reported[ "{$function} alphabet" ] ) ) { + $reported[ "{$function} alphabet" ] = true; + $call->expect( false, '%s( %s ) returned %s, with characters it does not keep.', $function, isset( $args[0] ) ? $args[0] : null, $result ); + } + + // Sanitizing again runs PCRE functions; leave preg_last_error() as the call left it. + $pcre_error = preg_last_error(); + $args[0] = $result; + $again = call_user_func_array( $function, $args ); + if ( PREG_NO_ERROR === $pcre_error && PREG_NO_ERROR !== preg_last_error() ) { + preg_match( '/^/', '' ); + } + + if ( $again === $result || isset( $reported[ "{$function} " . md5( $result ) ] ) ) { + return; + } + $reported[ "{$function} " . md5( $result ) ] = true; + $call->expect( false, '%s() returned %s, which it sanitizes again to %s.', $function, $result, $again ); + } + ), +); diff --git a/tests/invariants/specs/secrets.php b/tests/invariants/specs/secrets.php new file mode 100644 index 0000000000000..69b4f0448add4 --- /dev/null +++ b/tests/invariants/specs/secrets.php @@ -0,0 +1,196 @@ + what it is. +$made = WP_Invariant::shared( 'secrets/made' ); +$salts = WP_Invariant::shared( 'secrets/salts', 'run' ); + +// Secrets this short could appear by chance. +$min_length = 16; + +$remember = static function ( $holder, $secret, $label ) use ( $min_length ) { + if ( is_string( $secret ) && strlen( $secret ) >= $min_length ) { + if ( ! isset( $holder->values ) ) { + $holder->values = array(); + } + $holder->values[ $secret ] = $label; + } +}; + +/* + * Returns the password hashes in some text, by their formats: phpass ($P$, $H$), + * WordPress's bcrypt ($wp$2y$), bcrypt ($2y$, $2b$, $2a$) and Argon2 ($argon2i$, + * $argon2id$), with at least 30 characters of the hash alphabet after the marker. + */ +$hashes_in = static function ( $text ) { + $hashes = array(); + foreach ( array( '$P$', '$H$', '$wp$2', '$2y$', '$2b$', '$2a$', '$argon2' ) as $marker ) { + $at = strpos( $text, $marker ); + while ( false !== $at && count( $hashes ) < 10 ) { + $length = strspn( $text, './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz$=,', $at ); + if ( $length >= 30 ) { + $hashes[] = substr( $text, $at, $length ); + } + $at = strpos( $text, $marker, $at + max( 1, $length ) ); + } + } + + return array_values( array_unique( $hashes ) ); +}; + +/* + * Checks what WordPress sends back for secrets, and reports each one found, + * unless the request itself sent it, as a user sends their own key back. + */ +$check = static function ( WP_Invariant_Call $call, array &$reported, $text, $sink, array $allowed = array() ) use ( $made, $salts, $hashes_in ) { + if ( ! is_string( $text ) || strlen( $text ) < 16 ) { + return; + } + + $found = array(); + foreach ( array( $made, $salts ) as $holder ) { + foreach ( isset( $holder->values ) ? $holder->values : array() as $secret => $label ) { + $secret = (string) $secret; + if ( ! isset( $allowed[ $label ] ) && false !== strpos( $text, $secret ) ) { + $found[ $secret ] = $label; + } + } + } + foreach ( $hashes_in( $text ) as $hash ) { + $found[ $hash ] = 'a password hash'; + } + if ( array() === $found ) { + return; + } + + $sent = array_flip( WP_Invariant_Request::values() ); + foreach ( $found as $secret => $label ) { + $key = "{$label} {$sink}"; + if ( isset( $sent[ $secret ] ) || isset( $reported[ $key ] ) ) { + continue; + } + + $reported[ $key ] = true; + $at = strpos( $text, (string) $secret ); + $call->expect( false, '%s sent %s: %s', $sink, $label, substr( $text, max( 0, $at - 60 ), 160 ) ); + } +}; + +$encode = static function ( $data ) { + $json = function_exists( 'wp_json_encode' ) ? wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PARTIAL_OUTPUT_ON_ERROR ) : false; + return is_string( $json ) ? $json : ''; +}; + +$run_state = static function () { + return array(); +}; + +return array( + WP_Invariant::define( 'never-sent' ) + ->describe( 'No secret WordPress makes is sent back: no salt, password hash, password reset key, user request key, session token, auth cookie or application password appears in a REST response, a JSON response, printed HTML, a wp_die() message, a header or the error log, unless the request sent it itself. A new application password may appear in the REST response which creates it. Strings shaped like password hashes count wherever they came from. Each kind of secret is reported once for each place.' ) + ->include_test_callers() + ->state( $run_state, 'run' ) + ->after( + 'wp_salt', + static function ( WP_Invariant_Call $call ) use ( $salts, $remember ) { + $remember( $salts, $call->return_value(), 'a salt' ); + } + ) + ->after( + 'wp_hash_password', + static function ( WP_Invariant_Call $call ) use ( $made, $remember ) { + $remember( $made, $call->return_value(), 'a password hash' ); + } + ) + ->after( + 'get_password_reset_key', + static function ( WP_Invariant_Call $call ) use ( $made, $remember ) { + $remember( $made, $call->return_value(), 'a password reset key' ); + } + ) + ->after( + 'wp_generate_user_request_key', + static function ( WP_Invariant_Call $call ) use ( $made, $remember ) { + $remember( $made, $call->return_value(), 'a user request key' ); + } + ) + ->after( + 'WP_Session_Tokens::create', + static function ( WP_Invariant_Call $call ) use ( $made, $remember ) { + $remember( $made, $call->return_value(), 'a session token' ); + } + ) + ->after( + 'wp_generate_auth_cookie', + static function ( WP_Invariant_Call $call ) use ( $made, $remember ) { + $remember( $made, $call->return_value(), 'an auth cookie' ); + } + ) + ->after( + 'WP_Application_Passwords::create_new_application_password', + static function ( WP_Invariant_Call $call ) use ( $made, $remember ) { + $created = $call->return_value(); + if ( is_array( $created ) && isset( $created[0], $created[1] ) ) { + $remember( $made, $created[0], 'an application password' ); + $remember( $made, is_array( $created[1] ) && isset( $created[1]['password'] ) ? $created[1]['password'] : null, 'a password hash' ); + } + } + ) + ->after( + 'WP_REST_Server::dispatch', + static function ( WP_Invariant_Call $call, array &$reported ) use ( $check, $encode ) { + $response = $call->return_value(); + $request = $call->arg( 'request' ); + if ( ! $response instanceof WP_REST_Response || ! $request instanceof WP_REST_Request ) { + return; + } + + $route = $request->get_route(); + $allowed = false !== strpos( $route, '/application-passwords' ) && 'POST' === $request->get_method() ? array( 'an application password' => true ) : array(); + $check( $call, $reported, $encode( $response->get_data() ), sprintf( 'The REST response to %s %s', $request->get_method(), $route ), $allowed ); + } + ) + ->before( + 'wp_send_json', + static function ( WP_Invariant_Call $call, array &$reported ) use ( $check, $encode ) { + $check( $call, $reported, $encode( $call->arg( 'response' ) ), 'wp_send_json()' ); + } + ) + ->before( + 'wp_die', + static function ( WP_Invariant_Call $call, array &$reported ) use ( $check ) { + $message = $call->arg( 'message' ); + if ( $message instanceof WP_Error ) { + $message = implode( "\n", $message->get_error_messages() ); + } + $check( $call, $reported, is_string( $message ) ? $message : '', 'wp_die()' ); + } + ) + ->before( + 'error_log', + static function ( WP_Invariant_Call $call, array &$reported ) use ( $check ) { + $check( $call, $reported, $call->arg( 'message' ), 'error_log()' ); + } + ) + ->before( + 'header', + static function ( WP_Invariant_Call $call, array &$reported ) use ( $check ) { + $check( $call, $reported, $call->arg( 'header' ), 'header()' ); + } + ) + ->after_each( + array( 'ob_get_clean', 'ob_get_contents' ), + static function ( WP_Invariant_Call $call, array &$reported ) use ( $check ) { + $check( $call, $reported, $call->return_value(), 'Printed output' ); + } + ), +); diff --git a/tests/invariants/specs/serialize.php b/tests/invariants/specs/serialize.php new file mode 100644 index 0000000000000..f7501548b6bf7 --- /dev/null +++ b/tests/invariants/specs/serialize.php @@ -0,0 +1,93 @@ +describe( 'unserialize() is never given serialized objects of a loaded class with __wakeup(), __unserialize(), __destruct(), __toString(), __call() or __get(), through which it could run code, unless serialize() made that exact data in this run or allowed_classes leaves the class out. Neither unserialize() nor maybe_unserialize() is ever given a value the request sent. Each place in WordPress is reported once for each class.' ) + ->state( + static function () { + return array(); + }, + 'run' + ) + ->after( + 'serialize', + static function ( WP_Invariant_Call $call ) use ( $produced, $most_produced ) { + $data = $call->return_value(); + if ( is_string( $data ) && ( false !== strpos( $data, 'O:' ) || false !== strpos( $data, 'C:' ) ) ) { + if ( ! isset( $produced->hashes ) || count( $produced->hashes ) >= $most_produced ) { + $produced->hashes = array(); + } + $produced->hashes[ md5( $data ) ] = true; + } + } + ) + ->before( + 'unserialize', + static function ( WP_Invariant_Call $call, array &$reported ) use ( $produced, $from_request ) { + $data = $call->arg( 'data' ); + if ( ! is_string( $data ) ) { + return; + } + + $caller = $call->caller(); + $site = $caller['file'] . ':' . $caller['line']; + if ( $from_request( $data ) && ! isset( $reported[ "{$site} request" ] ) ) { + $reported[ "{$site} request" ] = true; + $call->expect( false, 'unserialize() was given a value the request sent: %s', $data ); + } + + $options = $call->arg( 'options' ); + $allowed = is_array( $options ) && array_key_exists( 'allowed_classes', $options ) ? $options['allowed_classes'] : true; + $gadgets = WP_Invariant_Security::gadget_classes( $data, $allowed ); + if ( array() === $gadgets || isset( $produced->hashes[ md5( $data ) ] ) ) { + return; + } + + foreach ( $gadgets as $class ) { + if ( isset( $reported[ "{$site} {$class}" ] ) ) { + continue; + } + $reported[ "{$site} {$class}" ] = true; + $call->expect( false, 'unserialize() was given an object of %s, which this run did not serialize: %s', $class, $data ); + } + } + ) + ->before( + 'maybe_unserialize', + static function ( WP_Invariant_Call $call, array &$reported ) use ( $from_request ) { + $data = $call->arg( 'data' ); + $caller = $call->caller(); + $site = $caller['file'] . ':' . $caller['line']; + if ( $from_request( $data ) && is_serialized( $data ) && ! isset( $reported[ "{$site} request" ] ) ) { + $reported[ "{$site} request" ] = true; + $call->expect( false, 'maybe_unserialize() was given a value the request sent: %s', $data ); + } + } + ), +); diff --git a/tests/invariants/specs/slashing.php b/tests/invariants/specs/slashing.php new file mode 100644 index 0000000000000..5521cc54e03ed --- /dev/null +++ b/tests/invariants/specs/slashing.php @@ -0,0 +1,100 @@ +strings ) ) { + $table->strings = array(); + } + unset( $table->strings[ $string ] ); + $table->strings[ $string ] = true; + if ( count( $table->strings ) > 512 ) { + unset( $table->strings[ array_key_first( $table->strings ) ] ); + } + } +}; + +// Returns the first string in a call's arguments which is in a table, or null. +$find = static function ( stdClass $table, WP_Invariant_Call $call ) { + if ( empty( $table->strings ) ) { + return null; + } + + foreach ( $call->args() as $arg ) { + foreach ( WP_Invariant::strings_in( $arg ) as $string ) { + if ( isset( $table->strings[ $string ] ) ) { + return $string; + } + } + } + + return null; +}; + +return array( + WP_Invariant::define( 'slashed-input-not-unslashed' ) + ->describe( 'A function which removes slashes from its input never receives a string which wp_unslash() just returned with a backslash in it: removing slashes again would lose that backslash.' ) + ->after( + 'wp_unslash', + static function ( WP_Invariant_Call $call ) use ( $unslashed, $remember ) { + $remember( $unslashed, $call->return_value() ); + } + ) + ->before_each( + array( 'wp_insert_post', 'wp_update_post', 'wp_insert_comment', 'wp_update_comment', 'wp_insert_term', 'wp_update_term', 'add_metadata', 'update_metadata' ), + static function ( WP_Invariant_Call $call ) use ( $unslashed, $find ) { + $string = $find( $unslashed, $call ); + $call->expect( null === $string, '%s() expects slashed data, but received %s, which wp_unslash() had just returned.', $call->target(), $string ); + } + ), + + WP_Invariant::define( 'unslashed-input-not-slashed' ) + ->describe( 'A function which stores its input as given never receives a string which wp_slash() just returned with added backslashes: they would be stored too.' ) + ->after( + 'wp_slash', + static function ( WP_Invariant_Call $call ) use ( $slashed, $remember ) { + $remember( $slashed, $call->return_value() ); + } + ) + ->before_each( + array( 'update_option', 'add_option', 'set_transient', 'update_network_option', 'add_network_option' ), + static function ( WP_Invariant_Call $call ) use ( $slashed, $find ) { + $string = $find( $slashed, $call ); + $call->expect( null === $string, '%s() stores data as given, but received %s, which wp_slash() had just returned.', $call->target(), $string ); + } + ), + + WP_Invariant::define( 'wpdb-receives-unslashed' ) + ->describe( 'wpdb takes raw values, which it escapes itself: no value given to wpdb::insert(), update(), replace(), delete() or prepare() is a string which wp_slash() just returned with added backslashes, which would be stored.' ) + // unslashed-input-not-slashed records what wp_slash() returns. + ->before_each( + array( 'wpdb::insert', 'wpdb::update', 'wpdb::replace', 'wpdb::delete', 'wpdb::prepare' ), + static function ( WP_Invariant_Call $call ) use ( $slashed, $find ) { + $string = $find( $slashed, $call ); + $call->expect( null === $string, '%s() takes raw values, but received %s, which wp_slash() had just returned.', $call->target(), $string ); + } + ), +); diff --git a/tests/invariants/specs/types.php b/tests/invariants/specs/types.php new file mode 100644 index 0000000000000..7508a776d6b8b --- /dev/null +++ b/tests/invariants/specs/types.php @@ -0,0 +1,53 @@ +describe( 'Functions which look up or change an object by its ID are never given a WP_Error, which makes them quietly return null or false.' ) + ->before_each( + array( 'get_post', 'get_post_field', 'get_userdata', 'get_term', 'get_comment', 'get_permalink', 'get_post_meta', 'update_post_meta', 'wp_update_post', 'wp_delete_post', 'user_can' ), + static function ( WP_Invariant_Call $call ) { + foreach ( $call->args() as $position => $arg ) { + if ( $arg instanceof WP_Error ) { + $call->expect( false, '%s() was given a WP_Error (%s) as argument %d.', $call->target(), $arg->get_error_code(), $position + 1 ); + return; + } + } + } + ), + + WP_Invariant::define( 'no-false-ids' ) + ->describe( 'get_post() and get_comment() are never given false or an empty string, which they quietly read as the current global post or comment, unless their caller was given it and passes it on. Pass null for the current one.' ) + ->before_each( + array( 'get_post', 'get_comment' ), + static function ( WP_Invariant_Call $call ) { + $args = $call->args(); + $id = isset( $args[0] ) ? $args[0] : null; + if ( false !== $id && '' !== $id ) { + return; + } + + // A value the caller was given came from further out, such as a test of falsy input. + $call->expect( in_array( $id, $call->caller_args(), true ), '%s() was given %s, which means the current global object.', $call->target(), var_export( $id, true ) ); + } + ), + + WP_Invariant::define( 'metadata-object-id' ) + ->describe( 'The object ID given to the metadata API is a whole number, 0 or more, as an int or a string of digits. absint() would quietly turn -5 into the object 5, and 1.5 into 1.' ) + ->before_each( + array( 'add_metadata', 'update_metadata', 'get_metadata_raw', 'metadata_exists', 'delete_metadata' ), + static function ( WP_Invariant_Call $call ) { + if ( 'delete_metadata' === $call->target() && $call->arg( 'delete_all' ) ) { + return; + } + + $id = $call->arg( 'object_id' ); + $call->expect( ( is_int( $id ) && $id >= 0 ) || ( is_string( $id ) && ctype_digit( $id ) ), '%s() was given the object ID %s.', $call->target(), is_scalar( $id ) || null === $id ? var_export( $id, true ) : gettype( $id ) ); + } + ), +); diff --git a/tests/invariants/specs/uploads.php b/tests/invariants/specs/uploads.php new file mode 100644 index 0000000000000..abba191463fd8 --- /dev/null +++ b/tests/invariants/specs/uploads.php @@ -0,0 +1,70 @@ + array( 1, null ), + 'copy' => array( 1, null ), + 'rename' => array( 1, null ), + 'file_put_contents' => array( 0, 1 ), + 'fopen' => array( 0, null ), + 'WP_Filesystem_Direct::put_contents' => array( 0, 1 ), + 'WP_Filesystem_Direct::copy' => array( 1, null ), + 'WP_Filesystem_Direct::move' => array( 1, null ), +); + +return array( + WP_Invariant::define( 'nothing-executable' ) + ->describe( 'Nothing WordPress writes into the uploads directory could run as code on a web server: no file has a PHP, PHAR, CGI, Perl or server-side include extension among its extensions, as in photo.php.jpg, none is a .htaccess, .user.ini or web.config file, and no contents written start with state( + static function () { + return array(); + }, + 'run' + ) + ->before_each( + array_keys( $functions ), + static function ( WP_Invariant_Call $call, array &$reported ) use ( $functions ) { + $args = $call->args(); + list( $path_at, $contents_at ) = $functions[ $call->target() ]; + + $path = isset( $args[ $path_at ] ) ? $args[ $path_at ] : null; + if ( ! is_string( $path ) || '' === $path || false !== strpos( $path, "\0" ) || false !== strpos( $path, '://' ) ) { + return; + } + if ( 'fopen' === $call->target() && ! WP_Invariant_Security::is_write_mode( isset( $args[1] ) ? (string) $args[1] : 'r' ) ) { + return; + } + + $why = WP_Invariant_Security::executable_name( $path ); + if ( null === $why && null !== $contents_at && isset( $args[ $contents_at ] ) && is_string( $args[ $contents_at ] ) ) { + $why = WP_Invariant_Security::executable_contents( $args[ $contents_at ] ); + } + if ( null === $why || ! function_exists( 'wp_upload_dir' ) ) { + return; + } + + $uploads = wp_upload_dir( null, false ); + if ( empty( $uploads['basedir'] ) || ! WP_Invariant::path_resolves_inside( $path, WP_Invariant::directory_forms( $uploads['basedir'] ) ) ) { + return; + } + + $caller = $call->caller(); + $site = $caller['file'] . ':' . $caller['line'] . ' ' . $path; + if ( isset( $reported[ $site ] ) ) { + return; + } + $reported[ $site ] = true; + + $call->expect( false, '%s() wrote %s into the uploads directory, which could run as code: %s.', $call->target(), $path, $why ); + } + ), +); diff --git a/tests/invariants/specs/wpdb.php b/tests/invariants/specs/wpdb.php new file mode 100644 index 0000000000000..433d63eadf97d --- /dev/null +++ b/tests/invariants/specs/wpdb.php @@ -0,0 +1,240 @@ +describe( 'What wpdb::prepare() returns has the structure of its query template: the values it fills in add no SQL of their own.' ) + ->after( + 'wpdb::prepare', + static function ( WP_Invariant_Call $call ) use ( $prepared ) { + $result = $call->return_value(); + if ( ! is_string( $result ) || '' === $result ) { + return; + } + + // The query as MySQL will receive it: the query filter removes the escapes of literal percent signs. + $query = $call->self()->remove_placeholder_escape( $result ); + $template = $call->arg( 'query' ); + $tokens = WP_Invariant_SQL_Lexer::tokens( $query ); + $call->expect( + WP_Invariant_SQL_Lexer::same_structure( + WP_Invariant_SQL_Lexer::structure( WP_Invariant_SQL_Lexer::tokens( $template, true ) ), + WP_Invariant_SQL_Lexer::structure( $tokens ) + ), + 'wpdb::prepare( %s ) returned %s.', + $template, + $query + ); + + // Keep the latest 256 for prepared-reaches-mysql, oldest first. + if ( ! isset( $prepared->queries ) ) { + $prepared->queries = array(); + } + unset( $prepared->queries[ $query ] ); + $prepared->queries[ $query ] = $tokens; + if ( count( $prepared->queries ) > 256 ) { + unset( $prepared->queries[ array_key_first( $prepared->queries ) ] ); + } + } + ), + + WP_Invariant::define( 'prepared-reaches-mysql' ) + ->describe( 'A query returned by wpdb::prepare() reaches MySQL intact: where it appears in a query sent to MySQL, it lexes the same there, and does not break into or out of a string, quoted identifier or comment. Text which only matches part of a number or a name, such as `= 1` in `= 11`, is another query.' ) + ->before( + 'wpdb::_do_query', + static function ( WP_Invariant_Call $call ) use ( $prepared ) { + if ( empty( $prepared->queries ) ) { + return; + } + + $sql = $call->arg( 'query' ); + $tokens = null; + foreach ( $prepared->queries as $query => $query_tokens ) { + if ( array() === $query_tokens || false === strpos( $sql, $query ) ) { + continue; + } + + if ( null === $tokens ) { + $tokens = WP_Invariant_SQL_Lexer::tokens( $sql ); + } + + $appearances = WP_Invariant_SQL_Lexer::appearances( $sql, $tokens, $query, $query_tokens ); + if ( array() !== $appearances ) { + $call->expect( ! in_array( false, $appearances, true ), 'The prepared query %s does not lex the same inside %s.', $query, $sql ); + } + } + } + ), + + WP_Invariant::define( 'prepared-parse-tree' ) + ->describe( 'Parsed with MySQL\'s grammar, what wpdb::prepare() returns has the same parse tree as its template filled with harmless values: the values it fills in change no SQL. Templates which are not whole statements are left to prepared-structure.' ) + ->state( + static function () { + return array( 'templates' => array() ); + }, + 'run' + ) + ->after( + 'wpdb::prepare', + static function ( WP_Invariant_Call $call, array &$state ) { + $result = $call->return_value(); + $template = $call->arg( 'query' ); + if ( ! is_string( $result ) || '' === $result || ! is_string( $template ) ) { + return; + } + + // The same templates are prepared over and over: keep the parse trees of the latest 2,000. + if ( ! array_key_exists( $template, $state['templates'] ) ) { + $state['templates'][ $template ] = WP_Invariant_MySQL::parse_tree( WP_Invariant_SQL_Lexer::fill_template( $template ) ); + if ( count( $state['templates'] ) > 2000 ) { + unset( $state['templates'][ array_key_first( $state['templates'] ) ] ); + } + } + $expected = $state['templates'][ $template ]; + if ( null === $expected ) { + return; + } + + $query = $call->self()->remove_placeholder_escape( $result ); + $call->expect( WP_Invariant_MySQL::parse_tree( $query ) === $expected, 'wpdb::prepare( %s ) returned %s, which MySQL parses differently.', $template, $query ); + } + ), + + WP_Invariant::define( 'queries-are-valid-mysql' ) + ->describe( 'Every query sent to MySQL is valid MySQL 8.4, as MySQL\'s grammar parses it.' ) + ->include_test_callers() + ->before( + 'wpdb::_do_query', + static function ( WP_Invariant_Call $call ) { + $query = $call->arg( 'query' ); + $call->expect( ! is_string( $query ) || null !== WP_Invariant_MySQL::parse_tree( $query ), 'MySQL would reject this query as invalid: %s', $query ); + } + ), + + WP_Invariant::define( 'query-is-counted' ) + ->describe( 'A successful wpdb::query() adds at least 1 to $wpdb->num_queries. It can add more when it runs queries of its own.' ) + ->before( + 'wpdb::query', + static function ( WP_Invariant_Call $call ) { + return $call->self()->num_queries; + } + ) + ->after( + 'wpdb::query', + static function ( WP_Invariant_Call $call ) { + if ( false === $call->return_value() ) { + return; + } + + $call->expect( + $call->self()->num_queries > $call->before(), + 'wpdb::query() succeeded, but num_queries went from %d to %d.', + $call->before(), + $call->self()->num_queries + ); + } + ), + + WP_Invariant::define( 'tables-are-prefixed' ) + ->describe( 'Every table a query sent to MySQL names, as MySQL\'s grammar parses it, starts with $wpdb->prefix or $wpdb->base_prefix, or is in information_schema, or is CUSTOM_USER_TABLE or CUSTOM_USER_META_TABLE.' ) + ->before( + 'wpdb::_do_query', + static function ( WP_Invariant_Call $call ) { + $query = $call->arg( 'query' ); + if ( ! is_string( $query ) ) { + return; + } + + // Parsed once for this and queries-are-valid-mysql; a query MySQL would reject is left to that. + $tables = WP_Invariant_MySQL::table_names( $query ); + if ( empty( $tables ) ) { + return; + } + + $wpdb = $call->self(); + // An empty prefix, which WordPress allows, is the start of every name. + $prefixes = array_unique( array( strtolower( (string) $wpdb->prefix ), strtolower( (string) $wpdb->base_prefix ) ) ); + $custom = array(); + foreach ( array( 'CUSTOM_USER_TABLE', 'CUSTOM_USER_META_TABLE' ) as $constant ) { + if ( defined( $constant ) ) { + $custom[] = strtolower( constant( $constant ) ); + } + } + + foreach ( $tables as list( $database, $table ) ) { + $table = strtolower( $table ); + if ( 'information_schema' === strtolower( (string) $database ) || in_array( $table, $custom, true ) ) { + continue; + } + + $prefixed = false; + foreach ( $prefixes as $prefix ) { + $prefixed = $prefixed || 0 === strpos( $table, $prefix ); + } + if ( ! $call->expect( $prefixed, 'The query names the table %s, without the prefix %s: %s', null === $database ? $table : "{$database}.{$table}", implode( ' or ', $prefixes ), $query ) ) { + return; + } + } + } + ), + + WP_Invariant::define( 'request-values-stay-literals' ) + ->describe( 'A value the request sent, where it appears in a query sent to MySQL, is inside one string literal or quoted identifier there: it adds no SQL of its own, whether or not the query went through wpdb::prepare(). Values shorter than 4 bytes, or without a quote, backslash or backtick, are not followed. Each value is reported once.' ) + ->include_test_callers() + ->state( + static function () { + return array(); + }, + 'run' + ) + ->before( + 'wpdb::_do_query', + static function ( WP_Invariant_Call $call, array &$reported ) { + $sql = $call->arg( 'query' ); + if ( ! is_string( $sql ) ) { + return; + } + + $tokens = null; + foreach ( WP_Invariant_Request::values( "'\"\\`" ) as $value ) { + if ( isset( $reported[ $value ] ) ) { + continue; + } + + $length = strlen( $value ); + foreach ( WP_Invariant_Request::occurrences( $sql, $value ) as $at ) { + if ( null === $tokens ) { + $tokens = WP_Invariant_SQL_Lexer::tokens( $sql ); + } + + $inside = false; + foreach ( $tokens as $token ) { + if ( $token['start'] <= $at && $at + $length <= $token['end'] ) { + // Strictly inside the quotes, not including them. + $inside = ( 'string' === $token['type'] || 'ident' === $token['type'] ) && $token['start'] < $at && $at + $length < $token['end']; + break; + } + } + + if ( ! $inside ) { + $reported[ $value ] = true; + $call->expect( false, 'The request value %s appears in a query outside a string literal: %s', $value, $sql ); + continue 2; + } + } + } + } + ), +); diff --git a/tests/invariants/specs/xml-output.php b/tests/invariants/specs/xml-output.php new file mode 100644 index 0000000000000..2cfbd1e8bf615 --- /dev/null +++ b/tests/invariants/specs/xml-output.php @@ -0,0 +1,34 @@ +describe( 'The XML documents and fragments WordPress generates for sitemaps, oEmbed and XML-RPC are well-formed, as a strict XML parser reads them.' ) + ->include_test_callers() + ->after_each( + array( 'WP_Sitemaps_Renderer::get_sitemap_xml', 'WP_Sitemaps_Renderer::get_sitemap_index_xml', '_oembed_create_xml', 'IXR_Request::getXml' ), + static function ( WP_Invariant_Call $call ) { + $xml = $call->return_value(); + if ( is_string( $xml ) ) { + $call->expect( WP_Invariant_Browser::xml_well_formed( $xml ), '%s() returned XML which is not well-formed: %s', $call->target(), $xml ); + } + } + ) + ->after_each( + array( 'IXR_Error::getXml', 'IXR_Value::getXml', 'IXR_Date::getXml', 'IXR_Base64::getXml' ), + static function ( WP_Invariant_Call $call ) { + $xml = $call->return_value(); + if ( is_string( $xml ) ) { + $call->expect( null !== WP_Invariant_Browser::xml_child_elements( $xml ), '%s() returned XML which is not well-formed: %s', $call->target(), $xml ); + } + } + ), +); diff --git a/tests/invariants/specs/xml.php b/tests/invariants/specs/xml.php new file mode 100644 index 0000000000000..ba26675d0f69f --- /dev/null +++ b/tests/invariants/specs/xml.php @@ -0,0 +1,32 @@ +describe( 'XML is never parsed with options which load external entities or DTDs (LIBXML_NOENT, LIBXML_DTDLOAD, LIBXML_DTDATTR), which would allow XML external entity attacks.' ) + ->before_each( + array( 'DOMDocument::loadXML', 'DOMDocument::load', 'DOMDocument::loadHTML', 'simplexml_load_string', 'simplexml_load_file', 'Dom\\XMLDocument::createFromString', 'Dom\\XMLDocument::createFromFile' ), + static function ( WP_Invariant_Call $call ) { + $options = (int) $call->arg( 'options' ); + $call->expect( 0 === ( $options & ( LIBXML_NOENT | LIBXML_DTDLOAD | LIBXML_DTDATTR ) ), '%s() was given the options %d, which load entities or DTDs.', $call->target(), $options ); + } + ), + + WP_Invariant::define( 'no-entity-declarations' ) + ->describe( 'XML given to xml_parse() declares no entities, which the expat parser would expand.' ) + ->before( + 'xml_parse', + static function ( WP_Invariant_Call $call ) { + $data = $call->arg( 'data' ); + $call->expect( ! is_string( $data ) || false === stripos( $data, '