From 46e6c28e19ffff0f7ed4db80faa1c0e9b454827d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 13 Jul 2026 10:21:22 +0200 Subject: [PATCH 1/3] feat(url): support private-use URI scheme redirect URIs (RFC 8252) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in `allowPrivateUseSchemes` flag to the URL validator so it accepts authority-less private-use URI scheme redirect URIs per RFC 8252 §7.1 (reverse-DNS form), e.g. "com.example.app:/oauth". These are valid URIs but PHP's FILTER_VALIDATE_URL requires an authority component and rejects them, blocking OAuth Dynamic Client Registration for native clients (e.g. Raycast). The new flag is the trailing constructor argument so existing positional call sites are unaffected, and defaults to false to preserve behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/validators/src/Validator/URL.php | 77 ++++++++++++++++++- .../validators/tests/Validator/URLTest.php | 48 ++++++++++++ 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/packages/validators/src/Validator/URL.php b/packages/validators/src/Validator/URL.php index 6be43de99..e14ba5ae4 100644 --- a/packages/validators/src/Validator/URL.php +++ b/packages/validators/src/Validator/URL.php @@ -13,7 +13,7 @@ */ class URL extends Validator { - public function __construct(protected array $allowedSchemes = [], protected bool $allowEmpty = false, protected bool $allowFragments = true) {} + public function __construct(protected array $allowedSchemes = [], protected bool $allowEmpty = false, protected bool $allowFragments = true, protected bool $allowPrivateUseSchemes = false) {} /** * Get Description @@ -53,7 +53,11 @@ public function isValid($value): bool } if (filter_var($value, FILTER_VALIDATE_URL) === false) { - return false; + // FILTER_VALIDATE_URL rejects authority-less private-use URI schemes + // (e.g. "com.example.app:/oauth", RFC 8252 §7.1). Optionally accept those. + if (!($this->allowPrivateUseSchemes && $this->isPrivateUseSchemeURI($value))) { + return false; + } } if ($this->allowedSchemes !== [] && !\in_array(parse_url($value, PHP_URL_SCHEME), $this->allowedSchemes)) { @@ -67,6 +71,75 @@ public function isValid($value): bool return true; } + /** + * Is private-use URI scheme + * + * Returns true when $value is an authority-less private-use URI scheme + * redirect URI as defined by RFC 8252 §7.1, e.g. "com.example.app:/oauth". + * + * @param mixed $value + */ + private function isPrivateUseSchemeURI($value): bool + { + if (!\is_string($value)) { + return false; + } + + $colonPos = \strpos($value, ':'); + if ($colonPos === false) { + return false; + } + + $scheme = \substr($value, 0, $colonPos); + $remainder = \substr($value, $colonPos + 1); + + if (!$this->isPrivateUseScheme($scheme)) { + return false; + } + + // Reject the "scheme://…" authority form; that is handled by filter_var. + if (\str_starts_with($remainder, '//')) { + return false; + } + + // Validate the authority-less remainder (a path, optionally with a query + // and/or fragment) by reusing PHP's URL validation with a placeholder + // authority, since filter_var alone rejects the authority-less form. + $normalized = \str_starts_with($remainder, '/') + ? 'https://localhost' . $remainder + : 'https://localhost/' . $remainder; + + return filter_var($normalized, FILTER_VALIDATE_URL) !== false; + } + + /** + * Is private-use scheme + * + * Returns true when $scheme is a valid RFC 3986 scheme that is also a + * reverse-DNS / private-use scheme (contains a dot), per RFC 8252 §7.1. + * The dot requirement also excludes standard dotless schemes (http, ftp, …). + * parse_url does not enforce the scheme grammar, so validate it explicitly. + */ + private function isPrivateUseScheme(string $scheme): bool + { + // RFC 3986: scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) + if ($scheme === '' || !\ctype_alpha($scheme[0])) { + return false; + } + + if (!\str_contains($scheme, '.')) { + return false; + } + + foreach (\str_split($scheme) as $char) { + if (!\ctype_alnum($char) && !\in_array($char, ['+', '-', '.'], true)) { + return false; + } + } + + return true; + } + /** * Is array * diff --git a/packages/validators/tests/Validator/URLTest.php b/packages/validators/tests/Validator/URLTest.php index c76d8bebf..43038b650 100644 --- a/packages/validators/tests/Validator/URLTest.php +++ b/packages/validators/tests/Validator/URLTest.php @@ -87,4 +87,52 @@ public function testDisallowFragmentsAllowedSchemes(): void $this->assertFalse($urlWithoutFragments->isValid('https://example.com/callback#fragment')); $this->assertFalse($urlWithoutFragments->isValid('gopher://www.example.com')); } + + public function testAllowPrivateUseSchemes(): void + { + // Default: private-use schemes are rejected (backward compatibility). + $default = new URL(); + $this->assertFalse($default->isValid('com.raycast-x:/oauth')); + $this->assertFalse($default->isValid('com.example.app:/oauth2redirect/example-provider')); + + $url = new URL(allowPrivateUseSchemes: true); + + // Happy path — RFC 8252 §7.1 private-use URI scheme redirect URIs. + $this->assertTrue($url->isValid('com.raycast-x:/oauth')); + $this->assertTrue($url->isValid('com.example.app:/oauth2redirect/example-provider')); + $this->assertTrue($url->isValid('com.raycast-x:/oauth?state=abc')); // query allowed + $this->assertTrue($url->isValid('com.raycast-x:oauth')); // path-rootless (opaque) form + + // Standard hierarchical URLs still validate through the normal path. + $this->assertTrue($url->isValid('https://example.com/callback')); + $this->assertTrue($url->isValid('http://127.0.0.1:8080/callback')); // loopback redirect + + // Edge-case failures. + $this->assertFalse($url->isValid('http:/example.com')); // dotless standard scheme, no authority — still invalid + $this->assertFalse($url->isValid('1com.raycast:/oauth')); // scheme must not start with a digit (RFC 3986) + $this->assertFalse($url->isValid('com raycast:/oauth')); // space in scheme + $this->assertFalse($url->isValid(':/oauth')); // missing scheme + $this->assertFalse($url->isValid('/oauth')); // no scheme at all + $this->assertFalse($url->isValid('comraycast:/oauth')); // no dot -> not treated as reverse-DNS private-use scheme + $this->assertFalse($url->isValid('not a url')); + $this->assertFalse($url->isValid('')); // allowEmpty not set + } + + public function testAllowPrivateUseSchemesWithConstraints(): void + { + // Fragments forbidden must also apply to private-use schemes (RFC 6749 §3.1.2). + $noFragments = new URL(allowFragments: false, allowPrivateUseSchemes: true); + $this->assertTrue($noFragments->isValid('com.raycast-x:/oauth')); + $this->assertFalse($noFragments->isValid('com.raycast-x:/oauth#frag')); + + // allowEmpty composes as before. + $allowEmpty = new URL(allowEmpty: true, allowPrivateUseSchemes: true); + $this->assertTrue($allowEmpty->isValid('')); + $this->assertTrue($allowEmpty->isValid('com.raycast-x:/oauth')); + + // allowedSchemes still gates private-use schemes. + $scoped = new URL(['com.raycast-x'], allowPrivateUseSchemes: true); + $this->assertTrue($scoped->isValid('com.raycast-x:/oauth')); + $this->assertFalse($scoped->isValid('com.evil-app:/oauth')); + } } From 5296e0705bae163de720bf3f8425ee2e86ea32fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 13 Jul 2026 11:09:07 +0200 Subject: [PATCH 2/3] Use new URI class --- .github/workflows/tests.yml | 8 ++- packages/validators/composer.json | 2 +- packages/validators/composer.lock | 4 +- packages/validators/src/Validator/URL.php | 59 +++-------------------- 4 files changed, 17 insertions(+), 56 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e8296640e..666aea46a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -95,10 +95,16 @@ jobs: run: | EXTENSIONS=$(jq -r '[(.require, ."require-dev", .suggest | select(. != null)) | keys[] | select(startswith("ext-")) | ltrimstr("ext-")] | unique | join(", ")' packages/${{ matrix.package }}/composer.json) echo "extensions=$EXTENSIONS" >> "$GITHUB_OUTPUT" + # Test each package on the higher of its declared PHP floor and the + # baseline 8.4, so a package that requires a newer PHP (e.g. >=8.5) + # is tested there without dropping siblings below the baseline. + FLOOR=$(jq -r '.require.php // ""' packages/${{ matrix.package }}/composer.json | grep -oE '[0-9]+\.[0-9]+' | head -1) + PHP=$(printf '%s\n8.4\n' "$FLOOR" | grep -E '^[0-9]+\.[0-9]+$' | sort -V | tail -1) + echo "php=$PHP" >> "$GITHUB_OUTPUT" - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # ratchet:shivammathur/setup-php@v2 with: - php-version: '8.4' + php-version: ${{ steps.meta.outputs.php }} extensions: ${{ steps.meta.outputs.extensions }} coverage: none diff --git a/packages/validators/composer.json b/packages/validators/composer.json index 3861591d8..e7d5987f7 100644 --- a/packages/validators/composer.json +++ b/packages/validators/composer.json @@ -18,6 +18,6 @@ "test": "phpunit --configuration phpunit.xml" }, "require": { - "php": ">=8.0" + "php": ">=8.5" } } diff --git a/packages/validators/composer.lock b/packages/validators/composer.lock index 7f0aee05b..70137e8f0 100644 --- a/packages/validators/composer.lock +++ b/packages/validators/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a9d11df709b90d3b6ea8cbf4f3822315", + "content-hash": "08588a2ff165a8793847060da05149da", "packages": [], "packages-dev": [], "aliases": [], @@ -13,7 +13,7 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=8.0" + "php": ">=8.5" }, "platform-dev": {}, "plugin-api-version": "2.9.0" diff --git a/packages/validators/src/Validator/URL.php b/packages/validators/src/Validator/URL.php index e14ba5ae4..c94691bca 100644 --- a/packages/validators/src/Validator/URL.php +++ b/packages/validators/src/Validator/URL.php @@ -73,10 +73,8 @@ public function isValid($value): bool /** * Is private-use URI scheme - * * Returns true when $value is an authority-less private-use URI scheme - * redirect URI as defined by RFC 8252 §7.1, e.g. "com.example.app:/oauth". - * + * redirect URI as defined by RFC 8252 §7.1, e.g. "com.example.app:/oauth" * @param mixed $value */ private function isPrivateUseSchemeURI($value): bool @@ -85,59 +83,16 @@ private function isPrivateUseSchemeURI($value): bool return false; } - $colonPos = \strpos($value, ':'); - if ($colonPos === false) { - return false; - } - - $scheme = \substr($value, 0, $colonPos); - $remainder = \substr($value, $colonPos + 1); - - if (!$this->isPrivateUseScheme($scheme)) { - return false; - } - - // Reject the "scheme://…" authority form; that is handled by filter_var. - if (\str_starts_with($remainder, '//')) { - return false; - } - - // Validate the authority-less remainder (a path, optionally with a query - // and/or fragment) by reusing PHP's URL validation with a placeholder - // authority, since filter_var alone rejects the authority-less form. - $normalized = \str_starts_with($remainder, '/') - ? 'https://localhost' . $remainder - : 'https://localhost/' . $remainder; - - return filter_var($normalized, FILTER_VALIDATE_URL) !== false; - } - - /** - * Is private-use scheme - * - * Returns true when $scheme is a valid RFC 3986 scheme that is also a - * reverse-DNS / private-use scheme (contains a dot), per RFC 8252 §7.1. - * The dot requirement also excludes standard dotless schemes (http, ftp, …). - * parse_url does not enforce the scheme grammar, so validate it explicitly. - */ - private function isPrivateUseScheme(string $scheme): bool - { - // RFC 3986: scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) - if ($scheme === '' || !\ctype_alpha($scheme[0])) { - return false; - } - - if (!\str_contains($scheme, '.')) { + $uri = \Uri\Rfc3986\Uri::parse($value); + if ($uri === null) { return false; } - foreach (\str_split($scheme) as $char) { - if (!\ctype_alnum($char) && !\in_array($char, ['+', '-', '.'], true)) { - return false; - } - } + $scheme = $uri->getScheme(); - return true; + return $scheme !== null + && \str_contains($scheme, '.') + && $uri->getHost() === null; } /** From d5638caa831752cdf15ae7ca7c0dafa330e60575 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 13 Jul 2026 11:17:22 +0200 Subject: [PATCH 3/3] chore(validators): conform package to PHP 8.5 rector/pint rule sets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumping the package to `php: >=8.5` makes the shared rector config's `withPhpSets()` auto-detect 8.5 as the target, activating the 8.1→8.5 rule sets. Applying them (plus pint) modernizes the whole package so the `check` gate passes: `declare(strict_types=1)`, null-to-string arg casts, `array_all`, arrow-fn return types, `#[\Override]`, and parenthesis-less `new X()->method()`. Behavior is unchanged — full suite (135 tests, 878 assertions) stays green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../validators/src/Validator/Contains.php | 6 +- packages/validators/src/Validator/Domain.php | 6 +- .../validators/src/Validator/Globstar.php | 18 +- packages/validators/src/Validator/Host.php | 4 +- packages/validators/src/Validator/Range.php | 4 + packages/validators/src/Validator/URL.php | 20 +- .../validators/src/Validator/WhiteList.php | 6 +- .../tests/Validator/GlobstarTest.php | 188 +++++++++--------- 8 files changed, 129 insertions(+), 123 deletions(-) diff --git a/packages/validators/src/Validator/Contains.php b/packages/validators/src/Validator/Contains.php index c3c21d203..1dc758106 100644 --- a/packages/validators/src/Validator/Contains.php +++ b/packages/validators/src/Validator/Contains.php @@ -1,5 +1,7 @@ patterns as $pattern) { - $pattern = $this->strict ? $pattern : mb_strtolower($pattern, 'UTF-8'); + $pattern = $this->strict ? $pattern : mb_strtolower((string) $pattern, 'UTF-8'); - if (str_contains($value, $pattern)) { + if (str_contains($value, (string) $pattern)) { return true; } } diff --git a/packages/validators/src/Validator/Domain.php b/packages/validators/src/Validator/Domain.php index a3f8108a3..dc2a435ff 100644 --- a/packages/validators/src/Validator/Domain.php +++ b/packages/validators/src/Validator/Domain.php @@ -1,5 +1,7 @@ patterns as $p) { - if (!str_starts_with($p, '!')) { + if (!str_starts_with((string) $p, '!')) { $hasInclusions = true; break; } @@ -58,23 +58,17 @@ public function isValid($value): bool // Pure-exclusion mode: default to valid; any matching exclusion invalidates. if (!$hasInclusions) { - foreach ($this->patterns as $pattern) { - if ($this->match($value, substr($pattern, 1))) { - return false; - } - } - - return true; + return array_all($this->patterns, fn($pattern): bool => !$this->match($value, substr((string) $pattern, 1))); } // Inclusion mode. // // Step 1 — literal (no *, ?, [) inclusion patterns always win: // if any specific inclusion matches, the value is valid regardless of later exclusions. - $isWildcard = fn($p): bool => str_contains($p, '*') || str_contains($p, '?') || str_contains($p, '['); + $isWildcard = fn($p): bool => str_contains((string) $p, '*') || str_contains((string) $p, '?') || str_contains((string) $p, '['); foreach ($this->patterns as $pattern) { - if (!str_starts_with($pattern, '!') && !$isWildcard($pattern) && $this->match($value, $pattern)) { + if (!str_starts_with((string) $pattern, '!') && !$isWildcard($pattern) && $this->match($value, $pattern)) { return true; } } @@ -85,8 +79,8 @@ public function isValid($value): bool // Literal inclusions already handled above; skip them here. $state = false; foreach ($this->patterns as $pattern) { - if (str_starts_with($pattern, '!')) { - if ($this->match($value, substr($pattern, 1))) { + if (str_starts_with((string) $pattern, '!')) { + if ($this->match($value, substr((string) $pattern, 1))) { $state = false; } } elseif ($isWildcard($pattern)) { diff --git a/packages/validators/src/Validator/Host.php b/packages/validators/src/Validator/Host.php index 81e0a94ca..5e474137c 100755 --- a/packages/validators/src/Validator/Host.php +++ b/packages/validators/src/Validator/Host.php @@ -1,5 +1,7 @@ whitelist); - return $hostnameValidator->isValid(parse_url($value, PHP_URL_HOST)); + return $hostnameValidator->isValid(parse_url((string) $value, PHP_URL_HOST)); } /** diff --git a/packages/validators/src/Validator/Range.php b/packages/validators/src/Validator/Range.php index f6f1a2a7e..346d6e823 100755 --- a/packages/validators/src/Validator/Range.php +++ b/packages/validators/src/Validator/Range.php @@ -44,6 +44,7 @@ public function getFormat(): string * * Returns validator description */ + #[\Override] public function getDescription(): string { return 'Value must be a valid range between ' . number_format($this->min) . ' and ' . number_format($this->max); @@ -54,6 +55,7 @@ public function getDescription(): string * * Function will return true if object is array. */ + #[\Override] public function isArray(): bool { return false; @@ -64,6 +66,7 @@ public function isArray(): bool * * Returns validator type. */ + #[\Override] public function getType(): string { return $this->format; @@ -76,6 +79,7 @@ public function getType(): string * Not strict, considers any valid integer to be a valid float * Considers infinity to be a valid integer */ + #[\Override] public function isValid(mixed $value): bool { if (!parent::isValid($value)) { diff --git a/packages/validators/src/Validator/URL.php b/packages/validators/src/Validator/URL.php index c94691bca..b04452129 100644 --- a/packages/validators/src/Validator/URL.php +++ b/packages/validators/src/Validator/URL.php @@ -1,5 +1,7 @@ allowPrivateUseSchemes && $this->isPrivateUseSchemeURI($value))) { - return false; - } + // FILTER_VALIDATE_URL rejects authority-less private-use URI schemes + // (e.g. "com.example.app:/oauth", RFC 8252 §7.1). Optionally accept those. + if (filter_var($value, FILTER_VALIDATE_URL) === false && (!$this->allowPrivateUseSchemes || !$this->isPrivateUseSchemeURI($value))) { + return false; } - if ($this->allowedSchemes !== [] && !\in_array(parse_url($value, PHP_URL_SCHEME), $this->allowedSchemes)) { + if ($this->allowedSchemes !== [] && !\in_array(parse_url((string) $value, PHP_URL_SCHEME), $this->allowedSchemes)) { return false; } - if (!$this->allowFragments && parse_url($value, PHP_URL_FRAGMENT) !== null) { + if (!$this->allowFragments && parse_url((string) $value, PHP_URL_FRAGMENT) !== null) { return false; } @@ -84,14 +84,14 @@ private function isPrivateUseSchemeURI($value): bool } $uri = \Uri\Rfc3986\Uri::parse($value); - if ($uri === null) { + if (!$uri instanceof \Uri\Rfc3986\Uri) { return false; } $scheme = $uri->getScheme(); return $scheme !== null - && \str_contains($scheme, '.') + && str_contains($scheme, '.') && $uri->getHost() === null; } diff --git a/packages/validators/src/Validator/WhiteList.php b/packages/validators/src/Validator/WhiteList.php index 5efd65d02..4333c609a 100755 --- a/packages/validators/src/Validator/WhiteList.php +++ b/packages/validators/src/Validator/WhiteList.php @@ -1,5 +1,7 @@ strict) { foreach ($this->list as $key => &$value) { - $this->list[$key] = strtolower($value); + $this->list[$key] = strtolower((string) $value); } } } @@ -77,7 +79,7 @@ public function isValid(mixed $value): bool return false; } - $value = ($this->strict) ? $value : strtolower($value); + $value = ($this->strict) ? $value : strtolower((string) $value); return \in_array($value, $this->list, $this->strict); } } diff --git a/packages/validators/tests/Validator/GlobstarTest.php b/packages/validators/tests/Validator/GlobstarTest.php index efb612d80..ef39e9b5a 100644 --- a/packages/validators/tests/Validator/GlobstarTest.php +++ b/packages/validators/tests/Validator/GlobstarTest.php @@ -54,9 +54,9 @@ public function testSingleWildcardInclusion(): void public function testSingleWildcardWithDirectoryPrefix(): void { // baz/*.txt — * matches within a single directory segment - $this->assertTrue((new Globstar(['baz/*.txt']))->isValid('baz/file.txt')); - $this->assertFalse((new Globstar(['baz/*.txt']))->isValid('a/baz/file.txt')); // prefix must match literally - $this->assertFalse((new Globstar(['baz/*.txt']))->isValid('baz/file.log')); + $this->assertTrue(new Globstar(['baz/*.txt'])->isValid('baz/file.txt')); + $this->assertFalse(new Globstar(['baz/*.txt'])->isValid('a/baz/file.txt')); // prefix must match literally + $this->assertFalse(new Globstar(['baz/*.txt'])->isValid('baz/file.log')); } public function testWildcardWithDash(): void @@ -99,10 +99,10 @@ public function testQuestionMarkMixedWithStar(): void public function testQuestionMarkSuffix(): void { // qux? — question mark matches exactly one character as suffix - $this->assertTrue((new Globstar(['qux?']))->isValid('qux1')); - $this->assertTrue((new Globstar(['qux?']))->isValid('quxa')); - $this->assertFalse((new Globstar(['qux?']))->isValid('qux')); // requires exactly one char - $this->assertFalse((new Globstar(['qux?']))->isValid('qux12')); // does not match two chars + $this->assertTrue(new Globstar(['qux?'])->isValid('qux1')); + $this->assertTrue(new Globstar(['qux?'])->isValid('quxa')); + $this->assertFalse(new Globstar(['qux?'])->isValid('qux')); // requires exactly one char + $this->assertFalse(new Globstar(['qux?'])->isValid('qux12')); // does not match two chars } public function testDoubleWildcardAtEnd(): void @@ -329,55 +329,55 @@ public function testDirPrefixDoubleStarExtPattern(): void public function testDoubleWildcardAtStartAlternateFilename(): void { // **/temp.txt — different filename from existing **/file.txt tests - $this->assertTrue((new Globstar(['**/temp.txt']))->isValid('temp.txt')); - $this->assertTrue((new Globstar(['**/temp.txt']))->isValid('a/temp.txt')); - $this->assertTrue((new Globstar(['**/temp.txt']))->isValid('a/b/temp.txt')); + $this->assertTrue(new Globstar(['**/temp.txt'])->isValid('temp.txt')); + $this->assertTrue(new Globstar(['**/temp.txt'])->isValid('a/temp.txt')); + $this->assertTrue(new Globstar(['**/temp.txt'])->isValid('a/b/temp.txt')); } public function testDoubleWildcardAtEndNoExtension(): void { // src/** without extension filter - $this->assertTrue((new Globstar(['src/**']))->isValid('src/file.txt')); - $this->assertTrue((new Globstar(['src/**']))->isValid('src/a/file.txt')); + $this->assertTrue(new Globstar(['src/**'])->isValid('src/file.txt')); + $this->assertTrue(new Globstar(['src/**'])->isValid('src/a/file.txt')); } public function testDoubleWildcardLogExtension(): void { // src/**/*.log - $this->assertTrue((new Globstar(['src/**/*.log']))->isValid('src/error.log')); - $this->assertTrue((new Globstar(['src/**/*.log']))->isValid('src/a/debug.log')); + $this->assertTrue(new Globstar(['src/**/*.log'])->isValid('src/error.log')); + $this->assertTrue(new Globstar(['src/**/*.log'])->isValid('src/a/debug.log')); } public function testDoubleWildcardMiddleWithTwoSegmentTail(): void { // a/**/b/c — tail is two segments (b/c) - $this->assertTrue((new Globstar(['a/**/b/c']))->isValid('a/b/c')); // zero intermediate - $this->assertTrue((new Globstar(['a/**/b/c']))->isValid('a/x/b/c')); // one intermediate - $this->assertTrue((new Globstar(['a/**/b/c']))->isValid('a/x/y/b/c')); // two intermediate - $this->assertFalse((new Globstar(['a/**/b/c']))->isValid('a/b/d')); // wrong tail + $this->assertTrue(new Globstar(['a/**/b/c'])->isValid('a/b/c')); // zero intermediate + $this->assertTrue(new Globstar(['a/**/b/c'])->isValid('a/x/b/c')); // one intermediate + $this->assertTrue(new Globstar(['a/**/b/c'])->isValid('a/x/y/b/c')); // two intermediate + $this->assertFalse(new Globstar(['a/**/b/c'])->isValid('a/b/d')); // wrong tail } public function testDoubleWildcardBothSides(): void { // **/d/e/** — globstar on both sides of a literal segment pair - $this->assertTrue((new Globstar(['**/d/e/**']))->isValid('d/e/file.txt')); - $this->assertTrue((new Globstar(['**/d/e/**']))->isValid('x/d/e/file.txt')); - $this->assertTrue((new Globstar(['**/d/e/**']))->isValid('x/y/d/e/z/file.txt')); - $this->assertFalse((new Globstar(['**/d/e/**']))->isValid('d/f/file.txt')); + $this->assertTrue(new Globstar(['**/d/e/**'])->isValid('d/e/file.txt')); + $this->assertTrue(new Globstar(['**/d/e/**'])->isValid('x/d/e/file.txt')); + $this->assertTrue(new Globstar(['**/d/e/**'])->isValid('x/y/d/e/z/file.txt')); + $this->assertFalse(new Globstar(['**/d/e/**'])->isValid('d/f/file.txt')); } public function testDoubleWildcardWithJsExtension(): void { // src/foo/**/*.js - $this->assertTrue((new Globstar(['src/foo/**/*.js']))->isValid('src/foo/app/file.js')); - $this->assertTrue((new Globstar(['src/foo/**/*.js']))->isValid('src/foo/file.js')); - $this->assertFalse((new Globstar(['src/foo/**/*.js']))->isValid('src/bar/app/file.js')); + $this->assertTrue(new Globstar(['src/foo/**/*.js'])->isValid('src/foo/app/file.js')); + $this->assertTrue(new Globstar(['src/foo/**/*.js'])->isValid('src/foo/file.js')); + $this->assertFalse(new Globstar(['src/foo/**/*.js'])->isValid('src/bar/app/file.js')); } public function testDoubleWildcardDeepNesting(): void { // Very deep path with ** in the middle - $this->assertTrue((new Globstar(['deep/**/logs/*.log']))->isValid('deep/level1/level2/level3/level4/level5/level6/level7/logs/app.log')); + $this->assertTrue(new Globstar(['deep/**/logs/*.log'])->isValid('deep/level1/level2/level3/level4/level5/level6/level7/logs/app.log')); } // ------------------------------------------------------------------------- @@ -569,141 +569,141 @@ public function testRejectsNonStringValues(): void public function testLiteralsExact(): void { - $this->assertTrue((new Globstar(['file.txt']))->isValid('file.txt')); - $this->assertFalse((new Globstar(['file.txt']))->isValid('file.txt.bak')); + $this->assertTrue(new Globstar(['file.txt'])->isValid('file.txt')); + $this->assertFalse(new Globstar(['file.txt'])->isValid('file.txt.bak')); } public function testSingleAsteriskDoesNotCrossSlash(): void { - $this->assertTrue((new Globstar(['*.txt']))->isValid('file.txt')); - $this->assertTrue((new Globstar(['*.txt']))->isValid('another.txt')); - $this->assertFalse((new Globstar(['*.txt']))->isValid('file.txt.bak')); - $this->assertFalse((new Globstar(['*.txt']))->isValid('dir/file.txt')); // * does not cross / + $this->assertTrue(new Globstar(['*.txt'])->isValid('file.txt')); + $this->assertTrue(new Globstar(['*.txt'])->isValid('another.txt')); + $this->assertFalse(new Globstar(['*.txt'])->isValid('file.txt.bak')); + $this->assertFalse(new Globstar(['*.txt'])->isValid('dir/file.txt')); // * does not cross / } public function testQuestionMarkSingleChar(): void { - $this->assertTrue((new Globstar(['file.?xt']))->isValid('file.txt')); - $this->assertTrue((new Globstar(['file.?xt']))->isValid('file.dxt')); - $this->assertFalse((new Globstar(['file.?xt']))->isValid('file.xtt')); + $this->assertTrue(new Globstar(['file.?xt'])->isValid('file.txt')); + $this->assertTrue(new Globstar(['file.?xt'])->isValid('file.dxt')); + $this->assertFalse(new Globstar(['file.?xt'])->isValid('file.xtt')); } public function testDoubleStarPrefixFileMatch(): void { - $this->assertTrue((new Globstar(['**/file.txt']))->isValid('file.txt')); - $this->assertTrue((new Globstar(['**/file.txt']))->isValid('dir/file.txt')); - $this->assertTrue((new Globstar(['**/file.txt']))->isValid('dir/subdir/file.txt')); - $this->assertFalse((new Globstar(['**/file.txt']))->isValid('file.txt.bak')); + $this->assertTrue(new Globstar(['**/file.txt'])->isValid('file.txt')); + $this->assertTrue(new Globstar(['**/file.txt'])->isValid('dir/file.txt')); + $this->assertTrue(new Globstar(['**/file.txt'])->isValid('dir/subdir/file.txt')); + $this->assertFalse(new Globstar(['**/file.txt'])->isValid('file.txt.bak')); } public function testDoubleStarMiddleFileMatch(): void { - $this->assertTrue((new Globstar(['src/**/file.txt']))->isValid('src/file.txt')); - $this->assertTrue((new Globstar(['src/**/file.txt']))->isValid('src/dir/file.txt')); - $this->assertTrue((new Globstar(['src/**/file.txt']))->isValid('src/dir/subdir/file.txt')); - $this->assertFalse((new Globstar(['src/**/file.txt']))->isValid('other/file.txt')); + $this->assertTrue(new Globstar(['src/**/file.txt'])->isValid('src/file.txt')); + $this->assertTrue(new Globstar(['src/**/file.txt'])->isValid('src/dir/file.txt')); + $this->assertTrue(new Globstar(['src/**/file.txt'])->isValid('src/dir/subdir/file.txt')); + $this->assertFalse(new Globstar(['src/**/file.txt'])->isValid('other/file.txt')); } public function testEscapedAsteriskIsLiteral(): void { - $this->assertTrue((new Globstar(['file\*.txt']))->isValid('file*.txt')); - $this->assertFalse((new Globstar(['file\*.txt']))->isValid('fileX.txt')); + $this->assertTrue(new Globstar(['file\*.txt'])->isValid('file*.txt')); + $this->assertFalse(new Globstar(['file\*.txt'])->isValid('fileX.txt')); } public function testEscapedHashIsNotComment(): void { // \# must be treated as a literal # character, not a comment marker - $this->assertTrue((new Globstar(['\#not_a_comment.txt']))->isValid('#not_a_comment.txt')); + $this->assertTrue(new Globstar(['\#not_a_comment.txt'])->isValid('#not_a_comment.txt')); } public function testEscapedQuestionMarkIsLiteral(): void { // \? must match literal ? rather than any single character - $this->assertTrue((new Globstar(['file\?.txt']))->isValid('file?.txt')); + $this->assertTrue(new Globstar(['file\?.txt'])->isValid('file?.txt')); } public function testBasicCharacterClasses(): void { - $this->assertTrue((new Globstar(['[a]bc.txt']))->isValid('abc.txt')); - $this->assertFalse((new Globstar(['[a]bc.txt']))->isValid('bbc.txt')); - $this->assertTrue((new Globstar(['[a-z]est.txt']))->isValid('test.txt')); - $this->assertFalse((new Globstar(['[a-z]est.txt']))->isValid('Test.txt')); - $this->assertTrue((new Globstar(['[A-Z]est.txt']))->isValid('Test.txt')); - $this->assertFalse((new Globstar(['[A-Z]est.txt']))->isValid('test.txt')); - $this->assertTrue((new Globstar(['file[0-9].log']))->isValid('file5.log')); - $this->assertFalse((new Globstar(['file[0-9].log']))->isValid('fileA.log')); - $this->assertTrue((new Globstar(['[a-zA-Z]file.txt']))->isValid('afile.txt')); - $this->assertTrue((new Globstar(['[a-zA-Z]file.txt']))->isValid('Afile.txt')); - $this->assertFalse((new Globstar(['[a-zA-Z]file.txt']))->isValid('1file.txt')); + $this->assertTrue(new Globstar(['[a]bc.txt'])->isValid('abc.txt')); + $this->assertFalse(new Globstar(['[a]bc.txt'])->isValid('bbc.txt')); + $this->assertTrue(new Globstar(['[a-z]est.txt'])->isValid('test.txt')); + $this->assertFalse(new Globstar(['[a-z]est.txt'])->isValid('Test.txt')); + $this->assertTrue(new Globstar(['[A-Z]est.txt'])->isValid('Test.txt')); + $this->assertFalse(new Globstar(['[A-Z]est.txt'])->isValid('test.txt')); + $this->assertTrue(new Globstar(['file[0-9].log'])->isValid('file5.log')); + $this->assertFalse(new Globstar(['file[0-9].log'])->isValid('fileA.log')); + $this->assertTrue(new Globstar(['[a-zA-Z]file.txt'])->isValid('afile.txt')); + $this->assertTrue(new Globstar(['[a-zA-Z]file.txt'])->isValid('Afile.txt')); + $this->assertFalse(new Globstar(['[a-zA-Z]file.txt'])->isValid('1file.txt')); } public function testNegatedCharacterClasses(): void { - $this->assertTrue((new Globstar(['[!a-z]file.txt']))->isValid('Afile.txt')); - $this->assertFalse((new Globstar(['[!a-z]file.txt']))->isValid('afile.txt')); - $this->assertTrue((new Globstar(['^[^a-z]file.txt']))->isValid('^1file.txt')); - $this->assertFalse((new Globstar(['[^a-z]file.txt']))->isValid('afile.txt')); - $this->assertTrue((new Globstar(['[!a-z0-9]file.txt']))->isValid('#file.txt')); - $this->assertFalse((new Globstar(['[!a-z0-9]file.txt']))->isValid('afile.txt')); - $this->assertFalse((new Globstar(['[!a-z0-9]file.txt']))->isValid('5file.txt')); + $this->assertTrue(new Globstar(['[!a-z]file.txt'])->isValid('Afile.txt')); + $this->assertFalse(new Globstar(['[!a-z]file.txt'])->isValid('afile.txt')); + $this->assertTrue(new Globstar(['^[^a-z]file.txt'])->isValid('^1file.txt')); + $this->assertFalse(new Globstar(['[^a-z]file.txt'])->isValid('afile.txt')); + $this->assertTrue(new Globstar(['[!a-z0-9]file.txt'])->isValid('#file.txt')); + $this->assertFalse(new Globstar(['[!a-z0-9]file.txt'])->isValid('afile.txt')); + $this->assertFalse(new Globstar(['[!a-z0-9]file.txt'])->isValid('5file.txt')); } public function testCaretNegatedCharacterClass(): void { - $this->assertTrue((new Globstar(['[^a-z]file.txt']))->isValid('1file.txt')); - $this->assertFalse((new Globstar(['[^a-z]file.txt']))->isValid('afile.txt')); + $this->assertTrue(new Globstar(['[^a-z]file.txt'])->isValid('1file.txt')); + $this->assertFalse(new Globstar(['[^a-z]file.txt'])->isValid('afile.txt')); } public function testSpecialCharsInsideCharacterClasses(): void { - $this->assertTrue((new Globstar(['file[.+]name.txt']))->isValid('file.name.txt')); - $this->assertTrue((new Globstar(['file[.+]name.txt']))->isValid('file+name.txt')); - $this->assertFalse((new Globstar(['file[.+]name.txt']))->isValid('filename.txt')); - $this->assertTrue((new Globstar(['[_!@#]special.txt']))->isValid('_special.txt')); - $this->assertTrue((new Globstar(['[_!@#]special.txt']))->isValid('@special.txt')); - $this->assertFalse((new Globstar(['[_!@#]special.txt']))->isValid('xspecial.txt')); - $this->assertTrue((new Globstar(['[-abc]dash.txt']))->isValid('-dash.txt')); - $this->assertTrue((new Globstar(['[-abc]dash.txt']))->isValid('adash.txt')); - $this->assertTrue((new Globstar(['[abc-]dash.txt']))->isValid('-dash.txt')); + $this->assertTrue(new Globstar(['file[.+]name.txt'])->isValid('file.name.txt')); + $this->assertTrue(new Globstar(['file[.+]name.txt'])->isValid('file+name.txt')); + $this->assertFalse(new Globstar(['file[.+]name.txt'])->isValid('filename.txt')); + $this->assertTrue(new Globstar(['[_!@#]special.txt'])->isValid('_special.txt')); + $this->assertTrue(new Globstar(['[_!@#]special.txt'])->isValid('@special.txt')); + $this->assertFalse(new Globstar(['[_!@#]special.txt'])->isValid('xspecial.txt')); + $this->assertTrue(new Globstar(['[-abc]dash.txt'])->isValid('-dash.txt')); + $this->assertTrue(new Globstar(['[-abc]dash.txt'])->isValid('adash.txt')); + $this->assertTrue(new Globstar(['[abc-]dash.txt'])->isValid('-dash.txt')); } public function testCharacterClassCombinedWithGlobstar(): void { - $this->assertTrue((new Globstar(['[a-z]*.txt']))->isValid('abc.txt')); - $this->assertFalse((new Globstar(['[a-z]*.txt']))->isValid('Abc.txt')); - $this->assertTrue((new Globstar(['**/[a-z]*.txt']))->isValid('dir/abc.txt')); - $this->assertTrue((new Globstar(['**/[a-z]*.txt']))->isValid('dir/subdir/abc.txt')); - $this->assertFalse((new Globstar(['**/[a-z]*.txt']))->isValid('dir/Abc.txt')); - $this->assertTrue((new Globstar(['[a-z][0-9]*.txt']))->isValid('a1file.txt')); - $this->assertFalse((new Globstar(['[a-z][0-9]*.txt']))->isValid('ab.txt')); - $this->assertFalse((new Globstar(['[a-z][0-9]*.txt']))->isValid('A1file.txt')); + $this->assertTrue(new Globstar(['[a-z]*.txt'])->isValid('abc.txt')); + $this->assertFalse(new Globstar(['[a-z]*.txt'])->isValid('Abc.txt')); + $this->assertTrue(new Globstar(['**/[a-z]*.txt'])->isValid('dir/abc.txt')); + $this->assertTrue(new Globstar(['**/[a-z]*.txt'])->isValid('dir/subdir/abc.txt')); + $this->assertFalse(new Globstar(['**/[a-z]*.txt'])->isValid('dir/Abc.txt')); + $this->assertTrue(new Globstar(['[a-z][0-9]*.txt'])->isValid('a1file.txt')); + $this->assertFalse(new Globstar(['[a-z][0-9]*.txt'])->isValid('ab.txt')); + $this->assertFalse(new Globstar(['[a-z][0-9]*.txt'])->isValid('A1file.txt')); } public function testEdgeCaseEmptyCharacterClass(): void { // Empty character class [] matches nothing - $this->assertFalse((new Globstar(['[]file.txt']))->isValid('file.txt')); + $this->assertFalse(new Globstar(['[]file.txt'])->isValid('file.txt')); } public function testEdgeCaseUnclosedBracket(): void { // Unclosed bracket treated as literal - $this->assertTrue((new Globstar(['[abc']))->isValid('[abc')); - $this->assertFalse((new Globstar(['[abc']))->isValid('abc')); + $this->assertTrue(new Globstar(['[abc'])->isValid('[abc')); + $this->assertFalse(new Globstar(['[abc'])->isValid('abc')); } public function testEdgeCaseExclamationOnlyClass(): void { // [!] — exclamation as sole content; behaviour is implementation-defined // but the implementation treats it as a literal class containing '!' - $this->assertTrue((new Globstar(['[!]file.txt']))->isValid('!file.txt')); + $this->assertTrue(new Globstar(['[!]file.txt'])->isValid('!file.txt')); } public function testEdgeCaseCaretOnlyClass(): void { // [^] — caret as sole content; behaviour is implementation-defined // but the implementation treats it as a literal class containing '^' - $this->assertTrue((new Globstar(['[^]file.txt']))->isValid('^file.txt')); + $this->assertTrue(new Globstar(['[^]file.txt'])->isValid('^file.txt')); } // ------------------------------------------------------------------------- @@ -718,10 +718,10 @@ public function testComplexNegationChainPattern(): void // - README-private*.md is re-included by the third pattern $patterns = ['*.md', '!README*.md', 'README-private*.md']; - $this->assertTrue((new Globstar($patterns))->isValid('documentation.md')); // included, not excluded - $this->assertFalse((new Globstar($patterns))->isValid('README.md')); // excluded by !README*.md - $this->assertFalse((new Globstar($patterns))->isValid('README-public.md')); // excluded by !README*.md - $this->assertTrue((new Globstar($patterns))->isValid('README-private.md')); // re-included by README-private*.md - $this->assertTrue((new Globstar($patterns))->isValid('README-private-draft.md')); // re-included by README-private*.md + $this->assertTrue(new Globstar($patterns)->isValid('documentation.md')); // included, not excluded + $this->assertFalse(new Globstar($patterns)->isValid('README.md')); // excluded by !README*.md + $this->assertFalse(new Globstar($patterns)->isValid('README-public.md')); // excluded by !README*.md + $this->assertTrue(new Globstar($patterns)->isValid('README-private.md')); // re-included by README-private*.md + $this->assertTrue(new Globstar($patterns)->isValid('README-private-draft.md')); // re-included by README-private*.md } }