BEGIN|END) (?ICANN|PRIVATE) DOMAINS===,'; private const PSL_SECTION = [ 'ICANN' => [ 'BEGIN' => self::ICANN_DOMAINS, 'END' => '', ], 'PRIVATE' => [ 'BEGIN' => self::PRIVATE_DOMAINS, 'END' => '', ], ]; /** * @param array{ICANN_DOMAINS: array, PRIVATE_DOMAINS: array} $rules */ private function __construct(private readonly array $rules) { } /** * Returns a new instance from a file path. * * @param null|resource $context * * @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 { return self::fromString(Stream::getContentAsString($path, $context)); } /** * Returns a new instance from a string. * * @throws UnableToLoadPublicSuffixList If the rules contain in the resource are invalid */ public static function fromString(Stringable|string $content): self { return new self(self::parse((string) $content)); } /** * Convert the Public Suffix List into an associative, multidimensional array. * * @return array{ICANN_DOMAINS: array, PRIVATE_DOMAINS: array} */ private static function parse(string $content): array { $rules = [self::ICANN_DOMAINS => [], self::PRIVATE_DOMAINS => []]; $section = ''; $file = new SplTempFileObject(); $file->fwrite($content); $file->setFlags(SplFileObject::DROP_NEW_LINE | SplFileObject::READ_AHEAD | SplFileObject::SKIP_EMPTY); /** @var string $line */ foreach ($file as $line) { $section = self::getSection($section, $line); if (in_array($section, [self::PRIVATE_DOMAINS, self::ICANN_DOMAINS], true) && !str_contains($line, '//')) { $rules[$section] = self::addRule($rules[$section], explode('.', $line)); } } return $rules; } /** * Returns the section type for a given line. */ private static function getSection(string $section, string $line): string { if (1 === preg_match(self::REGEX_PSL_SECTION, $line, $matches)) { return self::PSL_SECTION[$matches['type']][$matches['point']]; } return $section; } /** * Recursive method to build the array representation of the Public Suffix List. * * 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 * * @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 * * @throws UnableToLoadPublicSuffixList if the domain name can not be converted using IDN to ASCII algorithm * * @return array */ private static function addRule(array $list, array $ruleParts): array { // Adheres to canonicalization rule from the "Formal Algorithm" section // of https://publicsuffix.org/list/ // "The domain and all rules must be canonicalized in the normal way // for hostnames - lower-case, Punycode (RFC 3492)." try { /** @var string $line */ $line = array_pop($ruleParts); $rule = Idna::toAscii($line, Idna::IDNA2008_ASCII)->result(); } catch (CannotProcessHost $exception) { throw UnableToLoadPublicSuffixList::dueToInvalidRule($line ?? null, $exception); } $isDomain = true; if (str_starts_with($rule, '!')) { $rule = substr($rule, 1); $isDomain = false; } if (isset($list[$rule]) && [] === $list[$rule]) { $list[$rule] = [self::DOMAIN_RULE_MARKER => '']; } if (!isset($list[$rule])) { $list[$rule] = $isDomain ? [] : ['!' => '']; } if ($isDomain && [] !== $ruleParts) { $list[$rule] = self::addRule($list[$rule], $ruleParts); } return $list; } /** * @param array{rules:array{ICANN_DOMAINS: array, PRIVATE_DOMAINS: array}} $properties */ public static function __set_state(array $properties): self { return new self($properties['rules']); } public function resolve(DomainNameProvider|Host|Stringable|string|int|null $host): ResolvedDomainName { try { return $this->getCookieDomain($host); } catch (UnableToResolveDomain $exception) { return ResolvedDomain::fromUnknown($exception->domain()); } catch (SyntaxError) { return ResolvedDomain::fromUnknown(Domain::fromIDNA2008(null)); } } public function getCookieDomain(DomainNameProvider|Host|Stringable|string|int|null $host): ResolvedDomainName { $domain = $this->validateDomain($host); [$suffixLength, $section] = $this->resolveSuffix($domain->withoutRootLabel(), self::UNKNOWN_DOMAINS); return match (true) { self::ICANN_DOMAINS === $section => ResolvedDomain::fromICANN($domain, $suffixLength), self::PRIVATE_DOMAINS === $section => ResolvedDomain::fromPrivate($domain, $suffixLength), default => ResolvedDomain::fromUnknown($domain, $suffixLength), }; } public function getICANNDomain(DomainNameProvider|Host|Stringable|string|int|null $host): ResolvedDomainName { $domain = $this->validateDomain($host); [$suffixLength, $section] = $this->resolveSuffix($domain, self::ICANN_DOMAINS); if (self::ICANN_DOMAINS !== $section) { throw UnableToResolveDomain::dueToMissingSuffix($domain, 'ICANN'); } return ResolvedDomain::fromICANN($domain, $suffixLength); } public function getPrivateDomain(DomainNameProvider|Host|Stringable|string|int|null $host): ResolvedDomainName { $domain = $this->validateDomain($host); [$suffixLength, $section] = $this->resolveSuffix($domain, self::PRIVATE_DOMAINS); if (self::PRIVATE_DOMAINS !== $section) { throw UnableToResolveDomain::dueToMissingSuffix($domain, 'private'); } return ResolvedDomain::fromPrivate($domain, $suffixLength); } /** * Assert the domain is valid and is resolvable. * * @throws SyntaxError If the domain is invalid * @throws UnableToResolveDomain If the domain can not be resolved */ private function validateDomain(DomainNameProvider|Host|Stringable|string|int|null $domain): DomainName { if ($domain instanceof DomainNameProvider) { $domain = $domain->domain(); } if (!$domain instanceof DomainName) { $domain = Domain::fromIDNA2008($domain); } return $domain; } /** * Returns the length and the section of the resolved effective top level domain. * * @param Rules::UNKNOWN_DOMAINS|Rules::ICANN_DOMAINS|Rules::PRIVATE_DOMAINS $section * * @return array{0: int, 1:Rules::UNKNOWN_DOMAINS|Rules::ICANN_DOMAINS|Rules::PRIVATE_DOMAINS} */ private function resolveSuffix(DomainName $domain, string $section): array { $icannSuffixLength = $this->getPublicSuffixLengthFromSection($domain, self::ICANN_DOMAINS); if (1 > $icannSuffixLength) { return [1, self::UNKNOWN_DOMAINS]; } if (self::ICANN_DOMAINS === $section) { return [$icannSuffixLength, self::ICANN_DOMAINS]; } $privateSuffixLength = $this->getPublicSuffixLengthFromSection($domain, self::PRIVATE_DOMAINS); if ($privateSuffixLength > $icannSuffixLength) { return [$privateSuffixLength, self::PRIVATE_DOMAINS]; } return [$icannSuffixLength, self::ICANN_DOMAINS]; } /** * Returns the public suffix label count for a domain name according to a PSL section. */ private function getPublicSuffixLengthFromSection(DomainName $domain, string $section): int { $rules = $this->rules[$section]; $labelCount = 0; foreach ($domain->toAscii() as $label) { //match exception rule if (isset($rules[$label]['!'])) { return $labelCount; } //match wildcard rule if (array_key_exists('*', $rules)) { ++$labelCount; 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::hasRemainingRules($rules)) { return 0; } return $labelCount; } ++$labelCount; /** @var array $rules */ $rules = $rules[$label]; } return $labelCount; } private static function hasRemainingRules(array $rules): bool { foreach ($rules as $rule) { if ([] !== $rule) { return true; } } return false; } }