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/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 6be43de99..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; } - 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; } 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; + } + + $uri = \Uri\Rfc3986\Uri::parse($value); + if (!$uri instanceof \Uri\Rfc3986\Uri) { + return false; + } + + $scheme = $uri->getScheme(); + + return $scheme !== null + && str_contains($scheme, '.') + && $uri->getHost() === null; + } + /** * Is array * 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 } } 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')); + } }