diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 9444048..c4ede25 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -7,11 +7,16 @@ on: jobs: linux_tests: name: PHP on ${{ matrix.php }} - ${{ matrix.stability }} - ${{ matrix.composer-flags }} - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 strategy: matrix: - php: ['8.1', '8.2', '8.3'] + php: ['8.1', '8.2', '8.3', '8.4'] stability: [prefer-lowest, prefer-stable] + include: + - php: '8.4' + flags: "--ignore-platform-req=php" + phpunit-flags: '--no-coverage' + stability: prefer-stable steps: - name: Checkout code uses: actions/checkout@v3 @@ -32,7 +37,7 @@ jobs: id: composer-cache run: | echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - - uses: actions/cache@v3 + - uses: actions/cache@v4 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ matrix.stability }}-${{ matrix.flags }}-${{ hashFiles('**/composer.lock') }} @@ -45,11 +50,16 @@ jobs: - name: Run Unit tests with coverage run: composer phpunit -- ${{ matrix.phpunit-flags }} + if: ${{ matrix.php == '8.3' || matrix.php == '8.2' || matrix.php == '8.1'}} + + - name: Run Unit tests without coverage + run: composer phpunit:min + if: ${{ matrix.php == '8.4'}} - name: Run static analysis run: composer phpstan - if: ${{ matrix.php == '8.2' && matrix.stability == 'prefer-stable'}} + if: ${{ matrix.php == '8.3' && matrix.stability == 'prefer-stable'}} - name: Run Coding style rules run: composer phpcs:fix - if: ${{ matrix.php == '8.2' && matrix.stability == 'prefer-stable'}} + if: ${{ matrix.php == '8.3' && matrix.stability == 'prefer-stable'}} diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index 0b41a31..c17d0c9 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -1,5 +1,7 @@ in(__DIR__.'/src') ; @@ -7,8 +9,9 @@ $config = new PhpCsFixer\Config(); return $config + ->setParallelConfig(ParallelConfigFactory::detect()) ->setRules([ - '@PSR2' => true, + '@PSR12' => true, 'array_syntax' => ['syntax' => 'short'], 'concat_space' => ['spacing' => 'none'], 'global_namespace_import' => [ @@ -16,22 +19,18 @@ 'import_constants' => true, 'import_functions' => true, ], - 'list_syntax' => ['syntax' => 'short'], 'new_with_parentheses' => true, 'no_blank_lines_after_phpdoc' => true, 'no_empty_phpdoc' => true, 'no_empty_comment' => true, 'no_leading_import_slash' => true, - 'no_superfluous_phpdoc_tags' => [ - 'allow_mixed' => true, - 'remove_inheritdoc' => true, - 'allow_unused_params' => false, - ], + 'no_superfluous_phpdoc_tags' => true, 'no_trailing_comma_in_singleline' => true, 'no_unused_imports' => true, + 'nullable_type_declaration_for_default_null_value' => true, 'ordered_imports' => ['imports_order' => ['class', 'function', 'const'], 'sort_algorithm' => 'alpha'], 'phpdoc_add_missing_param_annotation' => ['only_untyped' => true], - 'phpdoc_align' => true, + 'phpdoc_align' => ['align' => 'left'], 'phpdoc_no_empty_return' => true, 'phpdoc_order' => true, 'phpdoc_scalar' => true, @@ -46,5 +45,6 @@ 'trailing_comma_in_multiline' => true, 'trim_array_spaces' => true, 'whitespace_after_comma_in_array' => true, + 'yoda_style' => true, ]) ->setFinder($finder); diff --git a/CHANGELOG.md b/CHANGELOG.md index 965c392..50dc06f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ All Notable changes to `PHP Domain Parser` starting from the **5.x** series will be documented in this file +## [6.4.0] - 2025-04-26 + +### Added + +- `DomainName::withRootLabel`, `DomainName::withoutRootLabel`, `DomainName::isAbsolute` methods to handle absolute domain names. +- `DomainName::when` to allow conditionable when building the domain. + +### Fixed + +- Absolute domain name can now also be resolved by the package see issue [#361](https://github.com/jeremykendall/php-domain-parser/issues/361) prior to this release an exception was thrown. +- Since we no longer support PHP7 type hint and return type are improved. +- Resolving private suffixes that have a wildcarded subdomain suffix [#363](https://github.com/jeremykendall/php-domain-parser/issues/363) by [@wikando-ck](https://github.com/wikando-ck) + (Once downloaded and installed you MUST refresh your cache to enable the fix to work.) + +### Deprecated + +- None + +### Removed + +- None + ## [6.3.0] - 2023-02-25 ### Added @@ -390,6 +412,7 @@ All Notable changes to `PHP Domain Parser` starting from the **5.x** series will - `Pdp\HttpAdapter\HttpAdapterInterface` interface replaced by the `Pdp\HttpClient` interface - `Pdp\HttpAdapter\CurlHttpAdapter` class replaced by the `Pdp\CurlHttpClient` class +[6.4.0]: https://github.com/jeremykendall/php-domain-parser/compare/6.3.0...6.4.0 [6.3.0]: https://github.com/jeremykendall/php-domain-parser/compare/6.2.0...6.3.0 [6.2.0]: https://github.com/jeremykendall/php-domain-parser/compare/6.1.2...6.2.0 [6.1.2]: https://github.com/jeremykendall/php-domain-parser/compare/6.1.1...6.1.2 diff --git a/README.md b/README.md index 68dc1b3..96d3519 100644 --- a/README.md +++ b/README.md @@ -36,10 +36,18 @@ composer require jeremykendall/php-domain-parser:^6.0 You need: -- **PHP >= 7.4** but the latest stable version of PHP is recommended -- the `intl` extension +- **PHP >= 8.1** but the latest stable version of PHP is recommended - a copy of the [Public Suffix List](https://publicsuffix.org/) data and/or a copy of the [IANA Top Level Domain List](https://www.iana.org/domains/root/files). Please refer to the [Managing external data source section](#managing-the-package-external-resources) for more information when using this package in production. +Handling of an IDN host requires the presence of the `intl` extension or +a polyfill for the `intl` IDN functions like the `symfony/polyfill-intl-idn` +otherwise an exception will be thrown when attempting to validate or interact +with such a host. + +> [!WARNING] +> When upgrading to version `6.4` you MUST refresh your local cache for the fix on +> private domain resolution to take effect. + ## Usage > [!WARNING] @@ -224,6 +232,10 @@ supported type to avoid unexpected results. By default, if the input is not a `Pdp\Suffix` instance, the resulting public suffix will be labelled as being unknown. For more information go to the [Public Suffix section](#public-suffix) +> [!NOTE] +> Since version `6.4` Domain resolution is also adapted so that absolute domain +can effectively be resolved. Prior to version `6.4` an exception was thrown. + ### Domain Suffix The domain effective TLD is represented using the `Pdp\Suffix`. Depending on @@ -338,6 +350,26 @@ $newDomain->clear()->labels(); //return [] echo $domain->slice(2)->toString(); //display 'www' ~~~ +Starting with version `6.4` it is possible to specify and handle absolute domain +with the following methods. + +~~~php +resolve(Domain::from2008('www.ExAmpLE.cOM'))->domain(); +$newDomain = $domain->withRootLabel(); + +echo $domain->toString(); //display 'www.example.com' +echo $newDomain->toString(); //display 'www.example.com.www.' +$domain->isAbsolute(); //return false +$newDomain->isAbsolute() //return true + +$domain->value() === $newDomain->withoutRootLabel()->value(); +~~~ + > [!WARNING] > Because of its definition, a domain name can be `null` or a string. diff --git a/composer.json b/composer.json index 25138d2..245e9d2 100644 --- a/composer.json +++ b/composer.json @@ -41,27 +41,28 @@ ], "require": { "php": "^8.1", - "ext-filter": "*", - "ext-intl": "*", - "ext-json": "*" + "ext-filter": "*" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^v3.35.1", - "guzzlehttp/guzzle": "^7.8", - "guzzlehttp/psr7": "^1.6 || ^2.6.1", - "phpstan/phpstan": "^1.10.39", - "phpstan/phpstan-phpunit": "^1.3.15", - "phpstan/phpstan-strict-rules": "^1.5.1", - "phpunit/phpunit": "^10.4.1", - "psr/http-factory": "^1.0.2", - "psr/simple-cache": "^1.0.1", - "symfony/cache": "^v5.0.0 || ^v6.3.5" + "friendsofphp/php-cs-fixer": "^3.65.0", + "guzzlehttp/guzzle": "^7.9.2", + "guzzlehttp/psr7": "^1.6 || ^2.7.0", + "phpstan/phpstan": "^1.12.13", + "phpstan/phpstan-phpunit": "^1.4.2", + "phpstan/phpstan-strict-rules": "^1.6.1", + "phpunit/phpunit": "^10.5.15 || ^11.5.1", + "psr/http-factory": "^1.1.0", + "psr/simple-cache": "^1.0.1 || ^2.0.0", + "symfony/cache": "^v5.0.0 || ^6.4.16", + "symfony/var-dumper": "^v6.4.18 || ^7.2" }, "suggest": { - "psr/http-client-implementation": "To use the storage functionnality which depends on PSR-18", - "psr/http-factory-implementation": "To use the storage functionnality which depends on PSR-17", - "psr/simple-cache-implementation": "To use the storage functionnality which depends on PSR-16", - "league/uri": "To parse URL and validate host" + "psr/http-client-implementation": "To use the storage functionality which depends on PSR-18", + "psr/http-factory-implementation": "To use the storage functionality which depends on PSR-17", + "psr/simple-cache-implementation": "To use the storage functionality which depends on PSR-16", + "league/uri": "To parse and extract the host from an URL using a RFC3986/RFC3987 URI parser", + "rowbot/url": "To parse and extract the host from an URL using a WHATWG URL parser", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "autoload": { "psr-4": { @@ -71,8 +72,9 @@ "scripts": { "phpcs": "PHP_CS_FIXER_IGNORE_ENV=1 php-cs-fixer fix -vvv --diff --dry-run --allow-risky=yes --ansi", "phpcs:fix": "php-cs-fixer fix -vvv --allow-risky=yes --ansi", - "phpstan": "phpstan analyse -l max -c phpstan.neon src --memory-limit=256M --ansi", + "phpstan": "phpstan analyse -c phpstan.neon --ansi --memory-limit=192M", "phpunit": "XDEBUG_MODE=coverage phpunit --coverage-text", + "phpunit:min": "phpunit --no-coverage", "test": [ "@phpunit", "@phpstan", diff --git a/phpstan.neon b/phpstan.neon index caa678b..19ea5e3 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,6 +3,9 @@ includes: - vendor/phpstan/phpstan-phpunit/extension.neon - vendor/phpstan/phpstan-phpunit/rules.neon parameters: + level: max + paths: + - src ignoreErrors: - message: '#has no value type specified in iterable type array.#' path: src/Rules.php @@ -12,4 +15,5 @@ parameters: path: src/Rules.php - message: '#Variable \$line on left side of \?\? always exists and is not nullable.#' path: src/Rules.php + - '#^Parameter \#1 \$callback of function set_error_handler expects#' reportUnmatchedIgnoredErrors: true diff --git a/src/Domain.php b/src/Domain.php index 358ebdc..39fb020 100644 --- a/src/Domain.php +++ b/src/Domain.php @@ -6,6 +6,9 @@ use Iterator; use Stringable; + +use function is_bool; + use const FILTER_FLAG_IPV4; use const FILTER_VALIDATE_IP; @@ -86,7 +89,7 @@ public function label(int $key): ?string /** * @return list */ - public function keys(string $label = null): array + public function keys(?string $label = null): array { return $this->registeredName->keys($label); } @@ -155,8 +158,52 @@ public function clear(): self /** * @throws CannotProcessHost */ - public function slice(int $offset, int $length = null): self + public function slice(int $offset, ?int $length = null): self { return $this->newInstance($this->registeredName->slice($offset, $length)); } + + public function withRootLabel(): self + { + if ('' === $this->label(0)) { + return $this; + } + + return $this->append(''); + } + + public function withoutRootLabel(): self + { + if ('' === $this->label(0)) { + return $this->slice(1); + } + + return $this; + } + + public function isAbsolute(): bool + { + return '' === $this->label(0); + } + + /** + * Apply the callback if the given "condition" is (or resolves to) true. + * + * @param (callable($this): bool)|bool $condition + * @param callable($this): (self|null) $onSuccess + * @param ?callable($this): (self|null) $onFail + * + */ + public function when(callable|bool $condition, callable $onSuccess, ?callable $onFail = null): self + { + if (!is_bool($condition)) { + $condition = $condition($this); + } + + return match (true) { + $condition => $onSuccess($this), + null !== $onFail => $onFail($this), + default => $this, + } ?? $this; + } } diff --git a/src/DomainName.php b/src/DomainName.php index c4a4aaf..fd6e3b9 100644 --- a/src/DomainName.php +++ b/src/DomainName.php @@ -49,7 +49,7 @@ public function labels(): array; * * @return list */ - public function keys(string $label = null): array; + public function keys(?string $label = null): array; /** * The external iterator iterates over the DomainInterface labels @@ -119,5 +119,5 @@ public function clear(): self; * * If $length is null it returns all elements from $offset to the end of the Domain. */ - public function slice(int $offset, int $length = null): self; + public function slice(int $offset, ?int $length = null): self; } diff --git a/src/DomainTest.php b/src/DomainTest.php index 3ea5315..da13679 100644 --- a/src/DomainTest.php +++ b/src/DomainTest.php @@ -6,8 +6,6 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -use stdClass; -use TypeError; final class DomainTest extends TestCase { @@ -168,14 +166,14 @@ public static function toUnicodeProvider(): iterable public function testToAscii( ?string $domain, ?string $expectedDomain, - ?string $expectedAsciiDomain + ?string $expectedIDNDomain ): void { $domain = Domain::fromIDNA2008($domain); self::assertSame($expectedDomain, $domain->value()); /** @var Domain $domainIDN */ $domainIDN = $domain->toAscii(); - self::assertSame($expectedAsciiDomain, $domainIDN->value()); + self::assertSame($expectedIDNDomain, $domainIDN->value()); } /** @@ -290,12 +288,6 @@ public static function withLabelWorksProvider(): iterable ]; } - public function testWithLabelFailsWithTypeError(): void - { - $this->expectException(TypeError::class); - Domain::fromIDNA2008('example.com')->withLabel(1, new stdClass()); /* @phpstan-ignore-line */ - } - public function testWithLabelFailsWithInvalidKey(): void { $this->expectException(SyntaxError::class); diff --git a/src/Host.php b/src/Host.php index 87945e9..6b4de6b 100644 --- a/src/Host.php +++ b/src/Host.php @@ -11,6 +11,11 @@ * @see https://tools.ietf.org/html/rfc1034#section-3.5 * @see https://tools.ietf.org/html/rfc1123#section-2.1 * @see https://tools.ietf.org/html/rfc5890 + * + * @method bool isAbsolute() tells whether the domain is absolute or not. + * @method static withRootLabel() returns an instance with its Root label. (see https://tools.ietf.org/html/rfc3986#section-3.2.2) + * @method static withoutRootLabel() returns an instance without its Root label. (see https://tools.ietf.org/html/rfc3986#section-3.2.2) + * @method static when(callable|bool $condition, callable $onSuccess, ?callable $onFail = null) apply the callback if the given "condition" is (or resolves to) true. */ interface Host extends Countable, JsonSerializable { diff --git a/src/Idna.php b/src/Idna.php index cf33ca0..f09f72a 100644 --- a/src/Idna.php +++ b/src/Idna.php @@ -5,6 +5,7 @@ namespace Pdp; use UnexpectedValueException; + use function defined; use function function_exists; use function idn_to_ascii; @@ -12,6 +13,7 @@ use function preg_match; use function rawurldecode; use function strtolower; + use const INTL_IDNA_VARIANT_UTS46; /** diff --git a/src/IdnaInfo.php b/src/IdnaInfo.php index 5724c6f..7afd6b2 100644 --- a/src/IdnaInfo.php +++ b/src/IdnaInfo.php @@ -5,6 +5,7 @@ namespace Pdp; use function array_filter; + use const ARRAY_FILTER_USE_KEY; /** diff --git a/src/IdnaInfoTest.php b/src/IdnaInfoTest.php index cd2fcde..1ecd93b 100644 --- a/src/IdnaInfoTest.php +++ b/src/IdnaInfoTest.php @@ -5,6 +5,7 @@ namespace Pdp; use PHPUnit\Framework\TestCase; + use function var_export; final class IdnaInfoTest extends TestCase diff --git a/src/PublicSuffixList.php b/src/PublicSuffixList.php index b63b64c..ecbd75a 100644 --- a/src/PublicSuffixList.php +++ b/src/PublicSuffixList.php @@ -9,7 +9,7 @@ interface PublicSuffixList extends DomainNameResolver /** * Returns PSL info for a given domain against the PSL rules for cookie domain detection. * - * @throws SyntaxError if the domain is invalid + * @throws SyntaxError if the domain is invalid * @throws UnableToResolveDomain if the effective TLD can not be resolved */ public function getCookieDomain(Host $host): ResolvedDomainName; @@ -17,7 +17,7 @@ public function getCookieDomain(Host $host): ResolvedDomainName; /** * Returns PSL info for a given domain against the PSL rules for ICANN domain detection. * - * @throws SyntaxError if the domain is invalid + * @throws SyntaxError if the domain is invalid * @throws UnableToResolveDomain if the domain does not contain a ICANN Effective TLD */ public function getICANNDomain(Host $host): ResolvedDomainName; @@ -25,7 +25,7 @@ public function getICANNDomain(Host $host): ResolvedDomainName; /** * Returns PSL info for a given domain against the PSL rules for private domain detection. * - * @throws SyntaxError if the domain is invalid + * @throws SyntaxError if the domain is invalid * @throws UnableToResolveDomain if the domain does not contain a private Effective TLD */ public function getPrivateDomain(Host $host): ResolvedDomainName; diff --git a/src/RegisteredName.php b/src/RegisteredName.php index 4350932..4a08b41 100644 --- a/src/RegisteredName.php +++ b/src/RegisteredName.php @@ -6,6 +6,7 @@ use Iterator; use Stringable; + use function array_count_values; use function array_keys; use function array_reverse; @@ -15,10 +16,12 @@ use function filter_var; use function implode; use function in_array; +use function is_bool; use function ksort; use function preg_match; use function rawurldecode; use function strtolower; + use const FILTER_FLAG_IPV4; use const FILTER_VALIDATE_IP; @@ -190,7 +193,7 @@ public function label(int $key): ?string /** * @return list */ - public function keys(string $label = null): array + public function keys(?string $label = null): array { if (null === $label) { return array_keys($this->labels); @@ -345,7 +348,7 @@ public function clear(): self /** * @throws CannotProcessHost */ - public function slice(int $offset, int $length = null): self + public function slice(int $offset, ?int $length = null): self { $nbLabels = count($this->labels); if ($offset < - $nbLabels || $offset > $nbLabels) { @@ -359,4 +362,48 @@ public function slice(int $offset, int $length = null): self return new self($this->type, [] === $labels ? null : implode('.', array_reverse($labels))); } + + public function withRootLabel(): self + { + if ('' === $this->label(0)) { + return $this; + } + + return $this->append(''); + } + + public function withoutRootLabel(): self + { + if ('' === $this->label(0)) { + return $this->slice(1); + } + + return $this; + } + + public function isAbsolute(): bool + { + return '' === $this->label(0); + } + + /** + * Apply the callback if the given "condition" is (or resolves to) true. + * + * @param (callable($this): bool)|bool $condition + * @param callable($this): (self|null) $onSuccess + * @param ?callable($this): (self|null) $onFail + * + */ + public function when(callable|bool $condition, callable $onSuccess, ?callable $onFail = null): self + { + if (!is_bool($condition)) { + $condition = $condition($this); + } + + return match (true) { + $condition => $onSuccess($this), + null !== $onFail => $onFail($this), + default => $this, + } ?? $this; + } } diff --git a/src/ResolvedDomain.php b/src/ResolvedDomain.php index f4fb87c..d687146 100644 --- a/src/ResolvedDomain.php +++ b/src/ResolvedDomain.php @@ -5,21 +5,27 @@ namespace Pdp; use Stringable; + use function count; +use function is_bool; final class ResolvedDomain implements ResolvedDomainName { + private readonly DomainName $domain; private readonly DomainName $secondLevelDomain; private readonly DomainName $registrableDomain; private readonly DomainName $subDomain; + private readonly EffectiveTopLevelDomain $suffix; /** * @throws CannotProcessHost */ private function __construct( - private readonly DomainName $domain, - private readonly EffectiveTopLevelDomain $suffix + DomainName $domain, + EffectiveTopLevelDomain $suffix ) { + $this->domain = $domain; + $this->suffix = $suffix; [ 'registrableDomain' => $this->registrableDomain, 'secondLevelDomain' => $this->secondLevelDomain, @@ -34,7 +40,7 @@ public static function fromICANN(DomainNameProvider|Host|Stringable|string|int|n { $domain = self::setDomainName($domain); - return new self($domain, Suffix::fromICANN($domain->slice(0, $suffixLength))); + return new self($domain, Suffix::fromICANN($domain->withoutRootLabel()->slice(0, $suffixLength))); } /** @@ -44,7 +50,7 @@ public static function fromPrivate(DomainNameProvider|Host|Stringable|string|int { $domain = self::setDomainName($domain); - return new self($domain, Suffix::fromPrivate($domain->slice(0, $suffixLength))); + return new self($domain, Suffix::fromPrivate($domain->withoutRootLabel()->slice(0, $suffixLength))); } /** @@ -54,7 +60,7 @@ public static function fromIANA(DomainNameProvider|Host|Stringable|string|int|nu { $domain = self::setDomainName($domain); - return new self($domain, Suffix::fromIANA($domain->label(0))); + return new self($domain, Suffix::fromIANA($domain->withoutRootLabel()->label(0))); } /** @@ -118,11 +124,15 @@ private function parse(): array } $length = count($this->suffix); + $offset = 0; + if ($this->domain->isAbsolute()) { + $offset = 1; + } return [ - 'registrableDomain' => $this->domain->slice(0, $length + 1), - 'secondLevelDomain' => $this->domain->slice($length, 1), - 'subDomain' => RegisteredName::fromIDNA2008($this->domain->value())->slice($length + 1), + 'registrableDomain' => $this->domain->slice($offset, $length + 1), + 'secondLevelDomain' => $this->domain->slice($length + $offset, 1), + 'subDomain' => RegisteredName::fromIDNA2008($this->domain->value())->slice($length + 1 + $offset), ]; } @@ -181,6 +191,21 @@ public function toUnicode(): self return new self($this->domain->toUnicode(), $this->suffix->toUnicode()); } + public function isAbsolute(): bool + { + return $this->domain->isAbsolute(); + } + + public function withoutRootLabel(): self + { + return new self($this->domain->withoutRootLabel(), $this->suffix); + } + + public function withRootLabel(): self + { + return new self($this->domain->withRootLabel(), $this->suffix); + } + /** * @throws CannotProcessHost */ @@ -190,8 +215,10 @@ public function withSuffix(DomainNameProvider|Host|Stringable|string|int|null $s $suffix = Suffix::fromUnknown($suffix); } + $domain = $this->domain->withoutRootLabel()->slice(count($this->suffix))->append($suffix); + return new self( - $this->domain->slice(count($this->suffix))->append($suffix), + $domain->when($this->domain->isAbsolute(), fn (DomainName $domainName) => $domain->withRootLabel()), $suffix->normalize($this->domain) ); } @@ -206,11 +233,20 @@ public function withSubDomain(DomainNameProvider|Host|Stringable|string|int|null } $subDomain = RegisteredName::fromIDNA2008($subDomain); + if ($subDomain->isAbsolute()) { + $subDomain = $subDomain->withoutRootLabel(); + if (null === $subDomain->value()) { + throw SyntaxError::dueToMalformedValue($subDomain->withRootLabel()->toString()); + } + } + if ($this->subDomain->value() === $subDomain->value()) { return $this; } - return new self($this->registrableDomain->prepend($subDomain), $this->suffix); + $domain = $this->registrableDomain->prepend($subDomain); + + return new self($domain->when($this->domain->isAbsolute(), fn (DomainName $domainName) => $domain->withRootLabel()), $this->suffix); } /** @@ -223,6 +259,13 @@ public function withSecondLevelDomain(DomainNameProvider|Host|Stringable|string| } $label = RegisteredName::fromIDNA2008($label); + if ($label->isAbsolute()) { + if (2 !== count($label)) { + throw UnableToResolveDomain::dueToInvalidSecondLevelDomain($label); + } + $label = $label->withoutRootLabel(); + } + if (1 !== count($label)) { throw UnableToResolveDomain::dueToInvalidSecondLevelDomain($label); } @@ -234,4 +277,25 @@ public function withSecondLevelDomain(DomainNameProvider|Host|Stringable|string| return new self($newRegistrableDomain->prepend($this->subDomain), $this->suffix); } + + /** + * Apply the callback if the given "condition" is (or resolves to) true. + * + * @param (callable($this): bool)|bool $condition + * @param callable($this): (self|null) $onSuccess + * @param ?callable($this): (self|null) $onFail + * + */ + public function when(callable|bool $condition, callable $onSuccess, ?callable $onFail = null): self + { + if (!is_bool($condition)) { + $condition = $condition($this); + } + + return match (true) { + $condition => $onSuccess($this), + null !== $onFail => $onFail($this), + default => $this, + } ?? $this; + } } diff --git a/src/ResolvedDomainTest.php b/src/ResolvedDomainTest.php index e6f15f2..5f22839 100644 --- a/src/ResolvedDomainTest.php +++ b/src/ResolvedDomainTest.php @@ -6,8 +6,6 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -use TypeError; -use function date_create; final class ResolvedDomainTest extends TestCase { @@ -173,8 +171,8 @@ public function testItCanBeConvertedToAscii( ?string $publicSuffix, ?string $expectedDomain, ?string $expectedSuffix, - ?string $expectedAsciiDomain, - ?string $expectedAsciiSuffix + ?string $expectedIDNDomain, + ?string $expectedIDNSuffix ): void { $domain = ResolvedDomain::fromUnknown(Domain::fromIDNA2003($domain), count(Domain::fromIDNA2003($publicSuffix))); self::assertSame($expectedDomain, $domain->value()); @@ -182,8 +180,8 @@ public function testItCanBeConvertedToAscii( /** @var ResolvedDomain $domainIDN */ $domainIDN = $domain->toAscii(); - self::assertSame($expectedAsciiDomain, $domainIDN->value()); - self::assertSame($expectedAsciiSuffix, $domainIDN->suffix()->value()); + self::assertSame($expectedIDNDomain, $domainIDN->value()); + self::assertSame($expectedIDNSuffix, $domainIDN->suffix()->value()); } /** @@ -236,7 +234,7 @@ public static function toAsciiProvider(): iterable } #[DataProvider('withSubDomainWorksProvider')] - public function testItCanHaveItsSubDomainChanged(ResolvedDomain $domain, DomainName|string|null $subdomain, string $expected = null): void + public function testItCanHaveItsSubDomainChanged(ResolvedDomain $domain, DomainName|string|null $subdomain, ?string $expected = null): void { $result = $domain->withSubDomain($subdomain); @@ -300,13 +298,6 @@ public function testItCanThrowsDuringSubDomainChangesIfTheSubDomainIsTheEmptyStr ResolvedDomain::fromICANN('www.example.com', 1)->withSubDomain(''); } - public function testItCanThrowsDuringSubDomainChangesIfTheSubDomainIsNotStringable(): void - { - $this->expectException(TypeError::class); - - ResolvedDomain::fromICANN('www.example.com', 1)->withSubDomain(date_create()); /* @phpstan-ignore-line */ - } - #[DataProvider('withPublicSuffixWorksProvider')] public function testItCanChangeItsSuffix( ResolvedDomain $domain, @@ -331,6 +322,7 @@ public function testItCanChangeItsSuffix( public static function withPublicSuffixWorksProvider(): iterable { $baseDomain = ResolvedDomain::fromICANN('example.com', 1); + $baseRootDomain = ResolvedDomain::fromICANN('example.com.', 1); return [ 'simple update (1)' => [ @@ -341,6 +333,14 @@ public static function withPublicSuffixWorksProvider(): iterable 'isICANN' => false, 'isPrivate' => false, ], + 'simple update with root-label (1)' => [ + 'domain' => $baseRootDomain, + 'publicSuffix' => 'be', + 'expected' => 'be', + 'isKnown' => false, + 'isICANN' => false, + 'isPrivate' => false, + ], 'simple update (2)' => [ 'domain' => $baseDomain, 'publicSuffix' => Suffix::fromPrivate('github.io'), @@ -349,6 +349,14 @@ public static function withPublicSuffixWorksProvider(): iterable 'isICANN' => false, 'isPrivate' => true, ], + 'simple update with root-label (2)' => [ + 'domain' => $baseRootDomain, + 'publicSuffix' => Suffix::fromPrivate('github.io'), + 'expected' => 'github.io', + 'isKnown' => true, + 'isICANN' => false, + 'isPrivate' => true, + ], 'same public suffix but PSL info is changed' => [ 'domain' => $baseDomain, 'publicSuffix' => Suffix::fromPrivate('com'), @@ -357,6 +365,14 @@ public static function withPublicSuffixWorksProvider(): iterable 'isICANN' => false, 'isPrivate' => true, ], + 'same public suffix but PSL info is changed with root domain' => [ + 'domain' => $baseRootDomain, + 'publicSuffix' => Suffix::fromPrivate('com'), + 'expected' => 'com', + 'isKnown' => true, + 'isICANN' => false, + 'isPrivate' => true, + ], 'same public suffix but PSL info does not changed' => [ 'domain' => $baseDomain, 'publicSuffix' => Suffix::fromICANN('com'), @@ -381,6 +397,14 @@ public static function withPublicSuffixWorksProvider(): iterable 'isICANN' => true, 'isPrivate' => false, ], + 'simple update IDN (2) with root domains' => [ + 'domain' => ResolvedDomain::fromICANN(Domain::fromIDNA2003('www.bébé.be.'), 1), + 'publicSuffix' => Suffix::fromICANN(Domain::fromIDNA2003('xn--p1ai')), + 'expected' => 'рф', + 'isKnown' => true, + 'isICANN' => true, + 'isPrivate' => false, + ], 'adding the public suffix to a single label domain' => [ 'domain' => ResolvedDomain::fromUnknown('localhost'), 'publicSuffix' => 'www', @@ -397,7 +421,7 @@ public static function withPublicSuffixWorksProvider(): iterable 'isICANN' => false, 'isPrivate' => false, ], - 'with custom IDNA domain options' =>[ + 'with custom IDNA domain options' => [ 'domain' => ResolvedDomain::fromICANN('www.bébé.be', 1), 'publicSuffix' => null, 'expected' => null, @@ -509,6 +533,13 @@ public static function withSldWorksProvider(): iterable 'expectedSld' => 'www', 'expectedHost' => 'www.com', ], + [ + 'host' => 'example.com', + 'publicSuffix' => 'com', + 'sld' => 'www.', + 'expectedSld' => 'www', + 'expectedHost' => 'www.com', + ], [ 'host' => 'www.example.com', 'publicSuffix' => 'com', @@ -523,6 +554,13 @@ public static function withSldWorksProvider(): iterable 'expectedSld' => 'hamburger', 'expectedHost' => 'www.hamburger.co.uk', ], + [ + 'host' => 'www.bbc.co.uk', + 'publicSuffix' => 'co.uk', + 'sld' => 'hamburger.', + 'expectedSld' => 'hamburger', + 'expectedHost' => 'www.hamburger.co.uk', + ], ]; } @@ -533,6 +571,20 @@ public function testItCanNotAppendAnEmptySLD(): void ResolvedDomain::fromICANN('private.ulb.ac.be', 2)->withSecondLevelDomain(null); } + public function testItCanNotAppendAnEmptyRootLabelSLD(): void + { + $this->expectException(SyntaxError::class); + + ResolvedDomain::fromICANN('private.ulb.ac.be', 2)->withSecondLevelDomain('.'); + } + + public function testItCanNotAppendAnInvalidRootLabelSLD(): void + { + $this->expectException(UnableToResolveDomain::class); + + ResolvedDomain::fromICANN('private.ulb.ac.be', 2)->withSecondLevelDomain('toto.foo.'); + } + public function testItCanNotAppendASLDToAResolvedDomainWithoutSuffix(): void { $this->expectException(UnableToResolveDomain::class); @@ -547,6 +599,13 @@ public function testItCanNotAppendAnInvalidSLDToAResolvedDomain(): void ResolvedDomain::fromIANA('private.ulb.ac.be')->withSecondLevelDomain('foo.bar'); } + public function testItCanNotAppendAnInvalidSubDomainToAResolvedDomain(): void + { + $this->expectException(SyntaxError::class); + + ResolvedDomain::fromIANA('private.ulb.ac.be')->withSubDomain(''); + } + public function testItReturnsTheInstanceWhenTheSLDIsEqual(): void { $domain = ResolvedDomain::fromICANN('private.ulb.ac.be', 2); @@ -569,4 +628,19 @@ public function testSuffixCanHandleIpLikeValue(): void ResolvedDomain::fromICANN('cloudflare-dns.com.1.1.1.1', 4)->toString() ); } + + public function testWithAndWithoutRootLabelResult(): void + { + $withoutRootLabelResult = ResolvedDomain::fromICANN('cloudflare-dns.com', 1); + $withRootLabelResult = $withoutRootLabelResult->withRootLabel(); + + self::assertTrue($withRootLabelResult->isAbsolute()); + self::assertFalse($withoutRootLabelResult->isAbsolute()); + self::assertEquals($withoutRootLabelResult, $withRootLabelResult->withoutRootLabel()); + self::assertSame($withoutRootLabelResult->suffix()->value(), $withRootLabelResult->suffix()->value()); + self::assertSame($withoutRootLabelResult->subDomain()->value(), $withRootLabelResult->subDomain()->value()); + self::assertSame($withoutRootLabelResult->registrableDomain()->value(), $withRootLabelResult->registrableDomain()->value()); + self::assertSame($withoutRootLabelResult->secondLevelDomain()->value(), $withRootLabelResult->secondLevelDomain()->value()); + self::assertNotSame($withoutRootLabelResult->domain()->value(), $withRootLabelResult->domain()->value()); + } } diff --git a/src/Rules.php b/src/Rules.php index 6c26b49..edee031 100644 --- a/src/Rules.php +++ b/src/Rules.php @@ -7,8 +7,11 @@ use SplFileObject; use SplTempFileObject; use Stringable; + +use function array_key_exists; use function array_pop; use function explode; +use function in_array; use function preg_match; use function substr; @@ -17,7 +20,7 @@ final class Rules implements PublicSuffixList private const ICANN_DOMAINS = 'ICANN_DOMAINS'; private const PRIVATE_DOMAINS = 'PRIVATE_DOMAINS'; private const UNKNOWN_DOMAINS = 'UNKNOWN_DOMAINS'; - + private const DOMAIN_RULE_MARKER = '?'; private const REGEX_PSL_SECTION = ',^// ===(?BEGIN|END) (?ICANN|PRIVATE) DOMAINS===,'; private const PSL_SECTION = [ 'ICANN' => [ @@ -42,7 +45,7 @@ private function __construct(private readonly array $rules) * * @param null|resource $context * - * @throws UnableToLoadResource If the rules can not be loaded from the path + * @throws UnableToLoadResource If the rules can not be loaded from the path * @throws UnableToLoadPublicSuffixList If the rules contain in the resource are invalid */ public static function fromPath(string $path, $context = null): self @@ -101,11 +104,10 @@ private static function getSection(string $section, string $line): string * This method is based heavily on the code found in generateEffectiveTLDs.php * * @see https://github.com/usrflo/registered-domain-libs/blob/master/generateEffectiveTLDs.php - * A copy of the Apache License, Version 2.0, is provided with this - * distribution + * A copy of the Apache License, Version 2.0, is provided with this distribution * - * @param array $list Initially an empty array, this eventually becomes the array representation of a - * Public Suffix List section + * @param array $list Initially an empty array, this eventually becomes the array representation of a + * Public Suffix List section * @param array $ruleParts One line (rule) from the Public Suffix List exploded on '.', or the remaining * portion of that array during recursion * @@ -133,11 +135,16 @@ private static function addRule(array $list, array $ruleParts): array $isDomain = false; } - $list[$rule] = $list[$rule] ?? ($isDomain ? [] : ['!' => '']); + if (isset($list[$rule]) && [] === $list[$rule]) { + $list[$rule] = [self::DOMAIN_RULE_MARKER => '']; + } + + if (!isset($list[$rule])) { + $list[$rule] = $isDomain ? [] : ['!' => '']; + } + if ($isDomain && [] !== $ruleParts) { - /** @var array $tmpList */ - $tmpList = $list[$rule]; - $list[$rule] = self::addRule($tmpList, $ruleParts); + $list[$rule] = self::addRule($list[$rule], $ruleParts); } return $list; @@ -151,10 +158,7 @@ public static function __set_state(array $properties): self return new self($properties['rules']); } - /** - * @param int|DomainNameProvider|Host|string|Stringable|null $host a type that supports instantiating a Domain from. - */ - public function resolve($host): ResolvedDomainName + public function resolve(DomainNameProvider|Host|Stringable|string|int|null $host): ResolvedDomainName { try { return $this->getCookieDomain($host); @@ -165,13 +169,10 @@ public function resolve($host): ResolvedDomainName } } - /** - * @param int|DomainNameProvider|Host|string|Stringable|null $host the domain value - */ - public function getCookieDomain($host): ResolvedDomainName + public function getCookieDomain(DomainNameProvider|Host|Stringable|string|int|null $host): ResolvedDomainName { $domain = $this->validateDomain($host); - [$suffixLength, $section] = $this->resolveSuffix($domain, self::UNKNOWN_DOMAINS); + [$suffixLength, $section] = $this->resolveSuffix($domain->withoutRootLabel(), self::UNKNOWN_DOMAINS); return match (true) { self::ICANN_DOMAINS === $section => ResolvedDomain::fromICANN($domain, $suffixLength), @@ -180,10 +181,7 @@ public function getCookieDomain($host): ResolvedDomainName }; } - /** - * @param int|DomainNameProvider|Host|string|Stringable|null $host a type that supports instantiating a Domain from. - */ - public function getICANNDomain($host): ResolvedDomainName + public function getICANNDomain(DomainNameProvider|Host|Stringable|string|int|null $host): ResolvedDomainName { $domain = $this->validateDomain($host); [$suffixLength, $section] = $this->resolveSuffix($domain, self::ICANN_DOMAINS); @@ -194,10 +192,7 @@ public function getICANNDomain($host): ResolvedDomainName return ResolvedDomain::fromICANN($domain, $suffixLength); } - /** - * @param int|DomainNameProvider|Host|string|Stringable|null $host a type that supports instantiating a Domain from. - */ - public function getPrivateDomain($host): ResolvedDomainName + public function getPrivateDomain(DomainNameProvider|Host|Stringable|string|int|null $host): ResolvedDomainName { $domain = $this->validateDomain($host); [$suffixLength, $section] = $this->resolveSuffix($domain, self::PRIVATE_DOMAINS); @@ -211,10 +206,10 @@ public function getPrivateDomain($host): ResolvedDomainName /** * Assert the domain is valid and is resolvable. * - * @throws SyntaxError If the domain is invalid + * @throws SyntaxError If the domain is invalid * @throws UnableToResolveDomain If the domain can not be resolved */ - private function validateDomain(int|DomainNameProvider|Host|string|Stringable|null $domain): DomainName + private function validateDomain(DomainNameProvider|Host|Stringable|string|int|null $domain): DomainName { if ($domain instanceof DomainNameProvider) { $domain = $domain->domain(); @@ -224,10 +219,6 @@ private function validateDomain(int|DomainNameProvider|Host|string|Stringable|nu $domain = Domain::fromIDNA2008($domain); } - if ('' === $domain->label(0)) { - throw UnableToResolveDomain::dueToUnresolvableDomain($domain); - } - return $domain; } @@ -267,22 +258,33 @@ private function getPublicSuffixLengthFromSection(DomainName $domain, string $se foreach ($domain->toAscii() as $label) { //match exception rule if (isset($rules[$label]['!'])) { - break; + return $labelCount; } //match wildcard rule if (array_key_exists('*', $rules)) { ++$labelCount; - break; + + return $labelCount; } //no match found if (!array_key_exists($label, $rules)) { + if (self::PRIVATE_DOMAINS !== $section) { + return $labelCount; + } + + // Suffix MATCHES default domain + if (array_key_exists(self::DOMAIN_RULE_MARKER, $rules)) { + return $labelCount; + } + // Suffix MUST be fully matched else no suffix is found for private domain - if (self::PRIVATE_DOMAINS === $section && self::hasRemainingRules($rules)) { - $labelCount = 0; + if (self::hasRemainingRules($rules)) { + return 0; } - break; + + return $labelCount; } ++$labelCount; diff --git a/src/RulesTest.php b/src/RulesTest.php index b8e4838..2b0bda9 100644 --- a/src/RulesTest.php +++ b/src/RulesTest.php @@ -6,7 +6,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -use TypeError; + use function array_fill; use function dirname; use function file_get_contents; @@ -27,7 +27,7 @@ public static function setUpBeforeClass(): void public function testCreateFromPath(): void { $context = stream_context_create([ - 'http'=> [ + 'http' => [ 'method' => 'GET', 'header' => "Accept-language: en\r\nCookie: foo=bar\r\n", ], @@ -66,13 +66,6 @@ public function testNullWillReturnNullDomain(): void self::assertFalse($domain->suffix()->isKnown()); } - public function testThrowsTypeErrorOnWrongInput(): void - { - $this->expectException(TypeError::class); - - self::$rules->resolve(date_create()); /* @phpstan-ignore-line */ - } - public function testIsSuffixValidFalse(): void { $domain = self::$rules->resolve('www.example.faketld'); @@ -129,10 +122,10 @@ public function testWithAbsoluteHostInvalid(): void $domain = self::$rules->resolve('private.ulb.ac.be.'); self::assertSame('private.ulb.ac.be.', $domain->value()); - self::assertFalse($domain->suffix()->isKnown()); - self::assertFalse($domain->suffix()->isICANN()); + self::assertTrue($domain->suffix()->isKnown()); + self::assertTrue($domain->suffix()->isICANN()); self::assertFalse($domain->suffix()->isPrivate()); - self::assertNull($domain->suffix()->value()); + self::assertSame('ac.be', $domain->suffix()->value()); } public function testWithICANNDomainInvalid(): void @@ -572,4 +565,39 @@ public function testWithMultiLevelPrivateDomain(): void self::assertTrue($domain->suffix()->isPrivate()); self::assertSame('lt.eu.org', $domain->suffix()->value()); } + + #[DataProvider('privateDomainWithWildcardProvider')] + public function testWithPrivateDomainThatHasWildcardSubdomain(string $inputDomain, string $expectedSuffix): void + { + $domain = self::$rules->getPrivateDomain($inputDomain); + + self::assertSame($expectedSuffix, $domain->suffix()->value()); + self::assertFalse($domain->suffix()->isICANN()); + self::assertTrue($domain->suffix()->isPrivate()); + } + + /** + * @return iterable + */ + public static function privateDomainWithWildcardProvider(): iterable + { + return [ + 'appspot subdomain' => [ + 'inputDomain' => 'test-domain.de.r.appspot.com', + 'expectedSuffix' => 'de.r.appspot.com', + ], + 'appspot root domain' => [ + 'inputDomain' => 'test-domain.appspot.com', + 'expectedSuffix' => 'appspot.com', + ], + 'qcx subdomain' => [ + 'inputDomain' => 'test-domain.de.sys.qcx.io', + 'expectedSuffix' => 'de.sys.qcx.io', + ], + 'qcx root domain' => [ + 'inputDomain' => 'test-domain.qcx.io', + 'expectedSuffix' => 'qcx.io', + ], + ]; + } } diff --git a/src/Storage/PublicSuffixListPsr16Cache.php b/src/Storage/PublicSuffixListPsr16Cache.php index b8753af..676c164 100644 --- a/src/Storage/PublicSuffixListPsr16Cache.php +++ b/src/Storage/PublicSuffixListPsr16Cache.php @@ -11,6 +11,7 @@ use Psr\SimpleCache\CacheInterface; use Stringable; use Throwable; + use function md5; use function strtolower; diff --git a/src/Storage/PublicSuffixListPsr16CacheTest.php b/src/Storage/PublicSuffixListPsr16CacheTest.php index 18b0cf3..686294b 100644 --- a/src/Storage/PublicSuffixListPsr16CacheTest.php +++ b/src/Storage/PublicSuffixListPsr16CacheTest.php @@ -9,9 +9,11 @@ use InvalidArgumentException; use Pdp\Rules; use PHPUnit\Framework\TestCase; +use PHPUnit\Runner\ErrorHandler; use Psr\SimpleCache\CacheException; use Psr\SimpleCache\CacheInterface; use RuntimeException; + use function dirname; final class PublicSuffixListPsr16CacheTest extends TestCase @@ -80,7 +82,7 @@ public function testItReturnsFalseIfItCantStoreAPublicSuffixListInstance(): void public function testItReturnsFalseIfItCantCacheAPublicSuffixListInstance(): void { - $exception = new class('Something went wrong.', 0) extends RuntimeException implements CacheException { + $exception = new class ('Something went wrong.', 0) extends RuntimeException implements CacheException { }; $cache = self::createStub(CacheInterface::class); $cache->method('set')->will(self::throwException($exception)); @@ -93,13 +95,13 @@ public function testItReturnsFalseIfItCantCacheAPublicSuffixListInstance(): void public function testItWillThrowIfItCantCacheAPublicSuffixListInstance(): void { - $exception = new class('Something went wrong.', 0) extends RuntimeException { + $exception = new class ('Something went wrong.', 0) extends RuntimeException { }; $cache = self::createStub(CacheInterface::class); $cache->method('set')->will(self::throwException($exception)); $psl = Rules::fromPath(dirname(__DIR__, 2).'/test_data/public_suffix_list.dat'); - $pslCache = new PublicSuffixListPsr16Cache($cache, 'pdp_', new class() { + $pslCache = new PublicSuffixListPsr16Cache($cache, 'pdp_', new class () { public function __toString(): string { return '1 DAY'; @@ -128,4 +130,38 @@ public function testItWillThrowIfTheTTLIsNotParsable(): void $cache = self::createStub(CacheInterface::class); new PublicSuffixListPsr16Cache($cache, 'pdp_', 'foobar'); } + + protected function restoreExceptionHandler(): void + { + while (true) { + $previousHandler = set_exception_handler(static fn () => null); + restore_exception_handler(); + if (null === $previousHandler) { + break; + } + + restore_exception_handler(); + } + } + + protected function restoreErrorHandler(): void + { + while (true) { + $previousHandler = set_error_handler(static fn (int $errno, string $errstr, ?string $errfile = null, ?int $errline = null) => null); + restore_error_handler(); + $isPhpUnitErrorHandler = ($previousHandler instanceof ErrorHandler); + if (null === $previousHandler || $isPhpUnitErrorHandler) { + break; + } + restore_error_handler(); + } + } + + protected function tearDown(): void + { + parent::tearDown(); + + $this->restoreErrorHandler(); + $this->restoreExceptionHandler(); + } } diff --git a/src/Storage/PublicSuffixListPsr18ClientTest.php b/src/Storage/PublicSuffixListPsr18ClientTest.php index 1117abb..eb4b237 100644 --- a/src/Storage/PublicSuffixListPsr18ClientTest.php +++ b/src/Storage/PublicSuffixListPsr18ClientTest.php @@ -14,6 +14,7 @@ use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; + use function dirname; use function file_get_contents; @@ -21,7 +22,7 @@ final class PublicSuffixListPsr18ClientTest extends TestCase { public function testIsCanReturnAPublicSuffixListInstance(): void { - $client = new class() implements ClientInterface { + $client = new class () implements ClientInterface { public function sendRequest(RequestInterface $request): ResponseInterface { /** @var string $body */ @@ -30,7 +31,7 @@ public function sendRequest(RequestInterface $request): ResponseInterface } }; - $requestFactory = new class() implements RequestFactoryInterface { + $requestFactory = new class () implements RequestFactoryInterface { public function createRequest(string $method, $uri): RequestInterface { return new Request($method, $uri); @@ -45,14 +46,14 @@ public function createRequest(string $method, $uri): RequestInterface public function testItWillThrowIfTheClientCanNotConnectToTheRemoteURI(): void { - $client = new class() implements ClientInterface { + $client = new class () implements ClientInterface { public function sendRequest(RequestInterface $request): ResponseInterface { throw new ConnectException('foobar', $request, null); } }; - $requestFactory = new class() implements RequestFactoryInterface { + $requestFactory = new class () implements RequestFactoryInterface { public function createRequest(string $method, $uri): RequestInterface { return new Request($method, $uri); @@ -68,14 +69,14 @@ public function createRequest(string $method, $uri): RequestInterface public function testItWillThrowIfTheReturnedStatusCodeIsNotOK(): void { - $client = new class() implements ClientInterface { + $client = new class () implements ClientInterface { public function sendRequest(RequestInterface $request): ResponseInterface { return new Response(404); } }; - $requestFactory = new class() implements RequestFactoryInterface { + $requestFactory = new class () implements RequestFactoryInterface { public function createRequest(string $method, $uri): RequestInterface { return new Request($method, $uri); diff --git a/src/Storage/RulesStorageTest.php b/src/Storage/RulesStorageTest.php index 723628e..8606ffe 100644 --- a/src/Storage/RulesStorageTest.php +++ b/src/Storage/RulesStorageTest.php @@ -7,13 +7,14 @@ use Pdp\PublicSuffixList; use Pdp\Rules; use PHPUnit\Framework\TestCase; + use function dirname; final class RulesStorageTest extends TestCase { public function testIsCanReturnAPublicSuffixListInstanceFromCache(): void { - $cache = new class() implements PublicSuffixListCache { + $cache = new class () implements PublicSuffixListCache { public function fetch(string $uri): ?PublicSuffixList { return Rules::fromPath(dirname(__DIR__, 2).'/test_data/public_suffix_list.dat'); @@ -30,7 +31,7 @@ public function forget(string $uri): bool } }; - $client = new class() implements PublicSuffixListClient { + $client = new class () implements PublicSuffixListClient { public function get(string $uri): PublicSuffixList { return Rules::fromPath(dirname(__DIR__, 2).'/test_data/public_suffix_list.dat'); @@ -45,7 +46,7 @@ public function get(string $uri): PublicSuffixList public function testIsCanReturnAPublicSuffixListInstanceFromTheInnerStorage(): void { - $cache = new class() implements PublicSuffixListCache { + $cache = new class () implements PublicSuffixListCache { public function fetch(string $uri): ?PublicSuffixList { return null; @@ -62,7 +63,7 @@ public function forget(string $uri): bool } }; - $client = new class() implements PublicSuffixListClient { + $client = new class () implements PublicSuffixListClient { public function get(string $uri): PublicSuffixList { return Rules::fromPath(dirname(__DIR__, 2).'/test_data/public_suffix_list.dat'); @@ -77,7 +78,7 @@ public function get(string $uri): PublicSuffixList public function testIsCanDeleteAPublicSuffixListInstanceFromTheInnerStorage(): void { - $cache = new class() implements PublicSuffixListCache { + $cache = new class () implements PublicSuffixListCache { public function fetch(string $uri): ?PublicSuffixList { return null; @@ -94,7 +95,7 @@ public function forget(string $uri): bool } }; - $client = new class() implements PublicSuffixListClient { + $client = new class () implements PublicSuffixListClient { public function get(string $uri): PublicSuffixList { return Rules::fromPath(dirname(__DIR__, 2).'/test_data/public_suffix_list.dat'); diff --git a/src/Storage/TimeToLive.php b/src/Storage/TimeToLive.php index 3bbe04a..6d0289f 100644 --- a/src/Storage/TimeToLive.php +++ b/src/Storage/TimeToLive.php @@ -10,7 +10,9 @@ use InvalidArgumentException; use Stringable; use Throwable; + use function filter_var; + use const FILTER_VALIDATE_INT; /** diff --git a/src/Storage/TimeToLiveTest.php b/src/Storage/TimeToLiveTest.php index 1604d3d..43d7bff 100644 --- a/src/Storage/TimeToLiveTest.php +++ b/src/Storage/TimeToLiveTest.php @@ -22,9 +22,7 @@ public function testItDoesNotReturnTheAbsoluteInterval(): void self::assertSame(0, TimeToLive::until($tomorrow)->invert); } - /** - * @dataProvider validDurationString - */ + #[DataProvider('validDurationString')] public function testItCanBeInstantiatedFromDurationInput(string $input, DateInterval $expected): void { $now = new DateTimeImmutable(); @@ -78,7 +76,7 @@ public static function validDurationInt(): iterable ]; yield 'stringable object' => [ - 'input' => new class() { + 'input' => new class () { public function __toString(): string { return '2345'; diff --git a/src/Storage/TopLevelDomainListPsr16Cache.php b/src/Storage/TopLevelDomainListPsr16Cache.php index e2b2c6b..2e76284 100644 --- a/src/Storage/TopLevelDomainListPsr16Cache.php +++ b/src/Storage/TopLevelDomainListPsr16Cache.php @@ -11,6 +11,7 @@ use Psr\SimpleCache\CacheInterface; use Stringable; use Throwable; + use function md5; use function strtolower; diff --git a/src/Storage/TopLevelDomainListPsr16CacheTest.php b/src/Storage/TopLevelDomainListPsr16CacheTest.php index 9d4275b..fc91144 100644 --- a/src/Storage/TopLevelDomainListPsr16CacheTest.php +++ b/src/Storage/TopLevelDomainListPsr16CacheTest.php @@ -9,9 +9,11 @@ use InvalidArgumentException; use Pdp\TopLevelDomains; use PHPUnit\Framework\TestCase; +use PHPUnit\Runner\ErrorHandler; use Psr\SimpleCache\CacheException; use Psr\SimpleCache\CacheInterface; use RuntimeException; + use function dirname; final class TopLevelDomainListPsr16CacheTest extends TestCase @@ -81,7 +83,7 @@ public function testItReturnsFalseIfItCantStoreAPublicSuffixListInstance(): void public function testItReturnsFalseIfItCantCacheATopLevelDomainListInstance(): void { - $exception = new class('Something went wrong.', 0) extends RuntimeException implements CacheException { + $exception = new class ('Something went wrong.', 0) extends RuntimeException implements CacheException { }; $cache = self::createStub(CacheInterface::class); $cache->method('set')->will(self::throwException($exception)); @@ -94,7 +96,7 @@ public function testItReturnsFalseIfItCantCacheATopLevelDomainListInstance(): vo public function testItThrowsIfItCantCacheATopLevelDomainListInstance(): void { - $exception = new class('Something went wrong.', 0) extends RuntimeException { + $exception = new class ('Something went wrong.', 0) extends RuntimeException { }; $cache = self::createStub(CacheInterface::class); $cache->method('set')->will(self::throwException($exception)); @@ -125,4 +127,40 @@ public function testItWillThrowIfTheTTLIsNotParsable(): void $cache = self::createStub(CacheInterface::class); new TopLevelDomainListPsr16Cache($cache, 'pdp_', 'foobar'); } + + + + protected function restoreExceptionHandler(): void + { + while (true) { + $previousHandler = set_exception_handler(static fn () => null); + restore_exception_handler(); + if (null === $previousHandler) { + break; + } + + restore_exception_handler(); + } + } + + protected function restoreErrorHandler(): void + { + while (true) { + $previousHandler = set_error_handler(static fn (int $errno, string $errstr, ?string $errfile = null, ?int $errline = null) => null); + restore_error_handler(); + $isPhpUnitErrorHandler = ($previousHandler instanceof ErrorHandler); + if (null === $previousHandler || $isPhpUnitErrorHandler) { + break; + } + restore_error_handler(); + } + } + + protected function tearDown(): void + { + parent::tearDown(); + + $this->restoreErrorHandler(); + $this->restoreExceptionHandler(); + } } diff --git a/src/Storage/TopLevelDomainListPsr18ClientTest.php b/src/Storage/TopLevelDomainListPsr18ClientTest.php index 9f4b0b6..eb5a5b4 100644 --- a/src/Storage/TopLevelDomainListPsr18ClientTest.php +++ b/src/Storage/TopLevelDomainListPsr18ClientTest.php @@ -14,6 +14,7 @@ use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; + use function dirname; use function file_get_contents; @@ -21,7 +22,7 @@ final class TopLevelDomainListPsr18ClientTest extends TestCase { public function testIsCanReturnARootZoneDatabaseInstance(): void { - $client = new class() implements ClientInterface { + $client = new class () implements ClientInterface { public function sendRequest(RequestInterface $request): ResponseInterface { /** @var string $body */ @@ -31,7 +32,7 @@ public function sendRequest(RequestInterface $request): ResponseInterface } }; - $requestFactory = new class() implements RequestFactoryInterface { + $requestFactory = new class () implements RequestFactoryInterface { public function createRequest(string $method, $uri): RequestInterface { return new Request($method, $uri); @@ -46,14 +47,14 @@ public function createRequest(string $method, $uri): RequestInterface public function testItWillThrowIfTheClientCanNotConnectToTheRemoteURI(): void { - $client = new class() implements ClientInterface { + $client = new class () implements ClientInterface { public function sendRequest(RequestInterface $request): ResponseInterface { throw new ConnectException('foobar', $request, null); } }; - $requestFactory = new class() implements RequestFactoryInterface { + $requestFactory = new class () implements RequestFactoryInterface { public function createRequest(string $method, $uri): RequestInterface { return new Request($method, $uri); @@ -69,14 +70,14 @@ public function createRequest(string $method, $uri): RequestInterface public function testItWillThrowIfTheReturnedStatusCodeIsNotOK(): void { - $client = new class() implements ClientInterface { + $client = new class () implements ClientInterface { public function sendRequest(RequestInterface $request): ResponseInterface { return new Response(404); } }; - $requestFactory = new class() implements RequestFactoryInterface { + $requestFactory = new class () implements RequestFactoryInterface { public function createRequest(string $method, $uri): RequestInterface { return new Request($method, $uri); diff --git a/src/Storage/TopLevelDomainsStorageTest.php b/src/Storage/TopLevelDomainsStorageTest.php index 0e299e9..2da00d1 100644 --- a/src/Storage/TopLevelDomainsStorageTest.php +++ b/src/Storage/TopLevelDomainsStorageTest.php @@ -7,13 +7,14 @@ use Pdp\TopLevelDomainList; use Pdp\TopLevelDomains; use PHPUnit\Framework\TestCase; + use function dirname; final class TopLevelDomainsStorageTest extends TestCase { public function testIsCanReturnARootZoneDatabaseInstanceFromCache(): void { - $cache = new class() implements TopLevelDomainListCache { + $cache = new class () implements TopLevelDomainListCache { public function fetch(string $uri): ?TopLevelDomainList { return TopLevelDomains::fromPath(dirname(__DIR__, 2).'/test_data/tlds-alpha-by-domain.txt'); @@ -30,7 +31,7 @@ public function forget(string $uri): bool } }; - $client = new class() implements TopLevelDomainListClient { + $client = new class () implements TopLevelDomainListClient { public function get(string $uri): TopLevelDomainList { return TopLevelDomains::fromPath(dirname(__DIR__, 2).'/test_data/tlds-alpha-by-domain.txt'); @@ -44,7 +45,7 @@ public function get(string $uri): TopLevelDomainList public function testIsCanReturnARootZoneDatabaseInstanceFromTheInnerStorage(): void { - $cache = new class() implements TopLevelDomainListCache { + $cache = new class () implements TopLevelDomainListCache { public function fetch(string $uri): ?TopLevelDomainList { return null; @@ -61,7 +62,7 @@ public function forget(string $uri): bool } }; - $client = new class() implements TopLevelDomainListClient { + $client = new class () implements TopLevelDomainListClient { public function get(string $uri): TopLevelDomainList { return TopLevelDomains::fromPath(dirname(__DIR__, 2).'/test_data/tlds-alpha-by-domain.txt'); @@ -75,7 +76,7 @@ public function get(string $uri): TopLevelDomainList public function testIsCanDeleteARootZoneDatabaseInstanceFromTheInnerStorage(): void { - $cache = new class() implements TopLevelDomainListCache { + $cache = new class () implements TopLevelDomainListCache { public function fetch(string $uri): ?TopLevelDomainList { return null; @@ -92,7 +93,7 @@ public function forget(string $uri): bool } }; - $client = new class() implements TopLevelDomainListClient { + $client = new class () implements TopLevelDomainListClient { public function get(string $uri): TopLevelDomainList { return TopLevelDomains::fromPath(dirname(__DIR__, 2).'/test_data/tlds-alpha-by-domain.txt'); diff --git a/src/Suffix.php b/src/Suffix.php index da39728..6a4989d 100644 --- a/src/Suffix.php +++ b/src/Suffix.php @@ -5,6 +5,7 @@ namespace Pdp; use Stringable; + use function count; use function in_array; @@ -89,7 +90,7 @@ private static function setDomainName(int|DomainNameProvider|Host|string|Stringa $domain = RegisteredName::fromIDNA2008($domain); } - if ('' === $domain->label(0)) { + if ($domain->isAbsolute()) { throw SyntaxError::dueToInvalidSuffix($domain); } diff --git a/src/SuffixTest.php b/src/SuffixTest.php index b3e6d56..bf47266 100644 --- a/src/SuffixTest.php +++ b/src/SuffixTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; + use function json_encode; final class SuffixTest extends TestCase diff --git a/src/TopLevelDomainList.php b/src/TopLevelDomainList.php index 51e9b0f..1ca3eb5 100644 --- a/src/TopLevelDomainList.php +++ b/src/TopLevelDomainList.php @@ -24,7 +24,7 @@ public function version(): string; */ public function lastUpdated(): DateTimeImmutable; - + public function count(): int; /** @@ -40,7 +40,7 @@ public function getIterator(): Iterator; /** * Returns PSL info for a given domain against the PSL rules for ICANN domain detection. * - * @throws SyntaxError if the domain is invalid + * @throws SyntaxError if the domain is invalid * @throws UnableToResolveDomain if the domain does not contain a IANA Effective TLD */ public function getIANADomain(Host $host): ResolvedDomainName; diff --git a/src/TopLevelDomains.php b/src/TopLevelDomains.php index 8f956cf..98384c2 100644 --- a/src/TopLevelDomains.php +++ b/src/TopLevelDomains.php @@ -9,8 +9,8 @@ use SplFileObject; use SplTempFileObject; use Stringable; + use function count; -use function in_array; use function preg_match; use function trim; @@ -34,7 +34,7 @@ private function __construct( * * @param null|resource $context * - * @throws UnableToLoadResource If the rules can not be loaded from the path + * @throws UnableToLoadResource If the rules can not be loaded from the path * @throws UnableToLoadTopLevelDomainList If the content is invalid or can not be correctly parsed and converted */ public static function fromPath(string $path, $context = null): self @@ -164,14 +164,11 @@ public function getIterator(): Iterator yield from array_keys($this->records); } - /** - * @param int|DomainNameProvider|Host|string|Stringable|null $host a type that supports instantiating a Domain from. - */ - public function resolve($host): ResolvedDomainName + public function resolve(DomainNameProvider|Host|Stringable|string|int|null $host): ResolvedDomainName { try { $domain = $this->validateDomain($host); - if ($this->containsTopLevelDomain($domain)) { + if ($this->containsTopLevelDomain($domain->withoutRootLabel())) { return ResolvedDomain::fromIANA($domain); } return ResolvedDomain::fromUnknown($domain); @@ -185,10 +182,10 @@ public function resolve($host): ResolvedDomainName /** * Assert the domain is valid and is resolvable. * - * @throws SyntaxError If the domain is invalid + * @throws SyntaxError If the domain is invalid * @throws UnableToResolveDomain If the domain can not be resolved */ - private function validateDomain(int|DomainNameProvider|Host|string|Stringable|null $domain): DomainName + private function validateDomain(DomainNameProvider|Host|Stringable|string|int|null $domain): DomainName { if ($domain instanceof DomainNameProvider) { $domain = $domain->domain(); @@ -198,8 +195,7 @@ private function validateDomain(int|DomainNameProvider|Host|string|Stringable|nu $domain = Domain::fromIDNA2008($domain); } - $label = $domain->label(0); - if (in_array($label, [null, ''], true)) { + if (null === $domain->label(0)) { throw UnableToResolveDomain::dueToUnresolvableDomain($domain); } @@ -211,13 +207,10 @@ private function containsTopLevelDomain(DomainName $domain): bool return isset($this->records[$domain->toAscii()->label(0)]); } - /** - * @param int|DomainNameProvider|Host|string|Stringable|null $host a domain in a type that can be converted into a DomainInterface instance - */ - public function getIANADomain($host): ResolvedDomainName + public function getIANADomain(DomainNameProvider|Host|Stringable|string|int|null $host): ResolvedDomainName { $domain = $this->validateDomain($host); - if (!$this->containsTopLevelDomain($domain)) { + if (!$this->containsTopLevelDomain($domain->withoutRootLabel())) { throw UnableToResolveDomain::dueToMissingSuffix($domain, 'IANA'); } diff --git a/src/TopLevelDomainsTest.php b/src/TopLevelDomainsTest.php index ca86e11..e756c6c 100644 --- a/src/TopLevelDomainsTest.php +++ b/src/TopLevelDomainsTest.php @@ -9,7 +9,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Stringable; -use TypeError; + use function dirname; final class TopLevelDomainsTest extends TestCase @@ -24,7 +24,7 @@ public static function setUpBeforeClass(): void public function testCreateFromPath(): void { $context = stream_context_create([ - 'http'=> [ + 'http' => [ 'method' => 'GET', 'header' => "Accept-language: en\r\nCookie: foo=bar\r\n", ], @@ -176,7 +176,7 @@ public static function validDomainProvider(): iterable 'Unicode domain (1)' => ['الاعلى-للاتصالات.قطر'], 'Unicode domain (2)' => ['кто.рф'], 'Unicode domain (3)' => ['Deutsche.Vermögensberatung.vermögensberater'], - 'object with __toString method' => [new class() { + 'object with __toString method' => [new class () { public function __toString(): string { return 'www.இந.இந்தியா'; @@ -186,13 +186,6 @@ public function __toString(): string ]; } - public function testTopLevelDomainThrowsTypeError(): void - { - $this->expectException(TypeError::class); - - self::$topLevelDomains->getIANADomain(new DateTimeImmutable()); /* @phpstan-ignore-line */ - } - public function testTopLevelDomainWithInvalidDomain(): void { $this->expectException(SyntaxError::class); @@ -211,8 +204,8 @@ public function testResolveWithAbsoluteDomainName(): void { $result = self::$topLevelDomains->resolve('example.com.'); self::assertSame('example.com.', $result->value()); - self::assertFalse($result->suffix()->isIANA()); - self::assertNull($result->suffix()->value()); + self::assertTrue($result->suffix()->isIANA()); + self::assertSame('com', $result->suffix()->value()); } public function testTopLevelDomainWithUnResolvableDomain(): void @@ -222,6 +215,13 @@ public function testTopLevelDomainWithUnResolvableDomain(): void self::$topLevelDomains->getIANADomain('localhost'); } + public function testTopLevelDomainWithUnResolvableDomain2(): void + { + $this->expectException(UnableToResolveDomain::class); + + self::$topLevelDomains->getIANADomain('localhost.'); + } + public function testResolveWithUnResolvableDomain(): void { $result = self::$topLevelDomains->resolve('localhost'); @@ -269,7 +269,7 @@ public static function validTldProvider(): iterable 'Unicode TLD (3)' => ['рф'], 'Unicode TLD (4)' => ['இந்தியா'], 'Unicode TLD (5)' => ['vermögensberater'], - 'object with __toString method' => [new class() { + 'object with __toString method' => [new class () { public function __toString(): string { return 'COM'; @@ -296,7 +296,7 @@ public static function invalidTldProvider(): iterable 'invalid IDN to ASCII' => ['XN--TTT'], 'invalid IDN to ASCII with leading dot' => ['.XN--TTT'], 'null' => [null], - 'object with __toString method' => [new class() { + 'object with __toString method' => [new class () { public function __toString(): string { return 'COMMM'; diff --git a/src/UnableToLoadTopLevelDomainList.php b/src/UnableToLoadTopLevelDomainList.php index 94ed824..d2f4e93 100644 --- a/src/UnableToLoadTopLevelDomainList.php +++ b/src/UnableToLoadTopLevelDomainList.php @@ -9,7 +9,7 @@ final class UnableToLoadTopLevelDomainList extends InvalidArgumentException implements CannotProcessHost { - public static function dueToInvalidTopLevelDomain(string $content, Throwable $exception = null): self + public static function dueToInvalidTopLevelDomain(string $content, ?Throwable $exception = null): self { return new self('Invalid Top Level Domain: '.$content, 0, $exception); }