363 lines
9.7 KiB
PHP
363 lines
9.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Pdp;
|
|
|
|
use Iterator;
|
|
use Stringable;
|
|
use function array_count_values;
|
|
use function array_keys;
|
|
use function array_reverse;
|
|
use function array_slice;
|
|
use function count;
|
|
use function explode;
|
|
use function filter_var;
|
|
use function implode;
|
|
use function in_array;
|
|
use function ksort;
|
|
use function preg_match;
|
|
use function rawurldecode;
|
|
use function strtolower;
|
|
use const FILTER_FLAG_IPV4;
|
|
use const FILTER_VALIDATE_IP;
|
|
|
|
final class RegisteredName implements DomainName
|
|
{
|
|
private const IDNA_2003 = 'IDNA_2003';
|
|
private const IDNA_2008 = 'IDNA_2008';
|
|
private const REGEXP_IDN_PATTERN = '/[^\x20-\x7f]/';
|
|
// Note that unreserved is purposely missing . as it is used to separate labels.
|
|
private const REGEXP_REGISTERED_NAME = '/(?(DEFINE)
|
|
(?<unreserved>[a-z0-9_~\-])
|
|
(?<sub_delims>[!$&\'()*+,;=])
|
|
(?<encoded>%[A-F0-9]{2})
|
|
(?<reg_name>(?:(?&unreserved)|(?&sub_delims)|(?&encoded)){1,63})
|
|
)
|
|
^(?:(?®_name)\.){0,126}(?®_name)\.?$/ix';
|
|
private const REGEXP_URI_DELIMITERS = '/[:\/?#\[\]@ ]/';
|
|
|
|
/** @var list<string> */
|
|
private readonly array $labels;
|
|
private readonly ?string $domain;
|
|
|
|
/**
|
|
* @throws CannotProcessHost
|
|
*/
|
|
private function __construct(private readonly string $type, DomainNameProvider|Host|Stringable|string|int|null $domain)
|
|
{
|
|
$this->domain = $this->parseDomain($domain);
|
|
$this->labels = null === $this->domain ? [] : array_reverse(explode('.', $this->domain));
|
|
}
|
|
|
|
/**
|
|
* @param array{domain:string|null, type:string} $properties
|
|
*
|
|
* @throws CannotProcessHost
|
|
*/
|
|
public static function __set_state(array $properties): self
|
|
{
|
|
return new self($properties['type'], $properties['domain']);
|
|
}
|
|
|
|
/**
|
|
* @throws CannotProcessHost
|
|
*/
|
|
public static function fromIDNA2003(DomainNameProvider|Host|Stringable|string|int|null $domain): self
|
|
{
|
|
return new self(self::IDNA_2003, $domain);
|
|
}
|
|
|
|
/**
|
|
* @throws CannotProcessHost
|
|
*/
|
|
public static function fromIDNA2008(DomainNameProvider|Host|Stringable|string|int|null $domain): self
|
|
{
|
|
return new self(self::IDNA_2008, $domain);
|
|
}
|
|
|
|
/**
|
|
* @throws CannotProcessHost
|
|
*/
|
|
private function parseDomain(DomainNameProvider|Host|Stringable|string|int|null $domain): ?string
|
|
{
|
|
return $this->parseValue(match (true) {
|
|
$domain instanceof DomainNameProvider => $domain->domain()->value(),
|
|
$domain instanceof Host => $domain->toUnicode()->value(),
|
|
default => $domain,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Parse and format the domain to ensure it is valid.
|
|
* Returns an array containing the formatted domain name labels
|
|
* and the domain transitional information.
|
|
*
|
|
* For example: parse('wWw.uLb.Ac.be') should return ['www.ulb.ac.be', ['be', 'ac', 'ulb', 'www']];.
|
|
*
|
|
* @throws SyntaxError If the host is not a domain
|
|
* @throws SyntaxError If the domain is not a host
|
|
*/
|
|
private function parseValue(Stringable|string|int|null $domain): ?string
|
|
{
|
|
if (null === $domain) {
|
|
return null;
|
|
}
|
|
|
|
if ($domain instanceof Stringable) {
|
|
$domain = (string) $domain;
|
|
}
|
|
|
|
$domain = (string) $domain;
|
|
if ('' === $domain) {
|
|
return '';
|
|
}
|
|
|
|
$res = filter_var($domain, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
|
|
if (false !== $res) {
|
|
return $res;
|
|
}
|
|
|
|
$formattedDomain = rawurldecode($domain);
|
|
return match (true) {
|
|
1 === preg_match(self::REGEXP_REGISTERED_NAME, $formattedDomain) => strtolower($formattedDomain),
|
|
// a domain name can not contain URI delimiters or space
|
|
1 === preg_match(self::REGEXP_URI_DELIMITERS, $formattedDomain) => throw SyntaxError::dueToInvalidCharacters($domain),
|
|
// if the domain name does not contain UTF-8 chars then it is malformed
|
|
1 !== preg_match(self::REGEXP_IDN_PATTERN, $formattedDomain) => throw SyntaxError::dueToMalformedValue($domain),
|
|
default => $this->domainToUnicode($this->domainToAscii($formattedDomain)),
|
|
};
|
|
}
|
|
|
|
private function domainToAscii(string $domain): string
|
|
{
|
|
return Idna::toAscii(
|
|
$domain,
|
|
self::IDNA_2003 === $this->type ? Idna::IDNA2003_ASCII : Idna::IDNA2008_ASCII
|
|
)->result();
|
|
}
|
|
|
|
private function domainToUnicode(string $domain): string
|
|
{
|
|
return Idna::toUnicode(
|
|
$domain,
|
|
self::IDNA_2003 === $this->type ? Idna::IDNA2003_UNICODE : Idna::IDNA2008_UNICODE
|
|
)->result();
|
|
}
|
|
|
|
/**
|
|
* @return Iterator<string>
|
|
*/
|
|
public function getIterator(): Iterator
|
|
{
|
|
yield from $this->labels;
|
|
}
|
|
|
|
public function isAscii(): bool
|
|
{
|
|
return null === $this->domain || 1 !== preg_match(self::REGEXP_IDN_PATTERN, $this->domain);
|
|
}
|
|
|
|
public function jsonSerialize(): ?string
|
|
{
|
|
return $this->domain;
|
|
}
|
|
|
|
public function count(): int
|
|
{
|
|
return count($this->labels);
|
|
}
|
|
|
|
public function value(): ?string
|
|
{
|
|
return $this->domain;
|
|
}
|
|
|
|
public function toString(): string
|
|
{
|
|
return (string) $this->domain;
|
|
}
|
|
|
|
public function label(int $key): ?string
|
|
{
|
|
if ($key < 0) {
|
|
$key += count($this->labels);
|
|
}
|
|
|
|
return $this->labels[$key] ?? null;
|
|
}
|
|
|
|
/**
|
|
* @return list<int>
|
|
*/
|
|
public function keys(?string $label = null): array
|
|
{
|
|
if (null === $label) {
|
|
return array_keys($this->labels);
|
|
}
|
|
|
|
return array_keys($this->labels, $label, true);
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
public function labels(): array
|
|
{
|
|
return $this->labels;
|
|
}
|
|
|
|
public function toAscii(): self
|
|
{
|
|
if (null === $this->domain) {
|
|
return $this;
|
|
}
|
|
|
|
$domain = $this->domainToAscii($this->domain);
|
|
if ($domain === $this->domain) {
|
|
return $this;
|
|
}
|
|
|
|
return new self($this->type, $domain);
|
|
}
|
|
|
|
public function toUnicode(): self
|
|
{
|
|
if (null === $this->domain) {
|
|
return $this;
|
|
}
|
|
|
|
$domain = $this->domainToUnicode($this->domain);
|
|
if ($domain === $this->domain) {
|
|
return $this;
|
|
}
|
|
|
|
return new self($this->type, $domain);
|
|
}
|
|
|
|
/**
|
|
* Filter a subdomain to update the domain part.
|
|
*/
|
|
private function normalize(DomainNameProvider|Host|Stringable|string|int|null $domain): ?string
|
|
{
|
|
if ($domain instanceof DomainNameProvider) {
|
|
$domain = $domain->domain();
|
|
}
|
|
|
|
if ($domain instanceof Host) {
|
|
$domain = $domain->value();
|
|
}
|
|
|
|
if (null === $domain) {
|
|
return null;
|
|
}
|
|
|
|
$domain = (string) $domain;
|
|
|
|
return match (true) {
|
|
null === $this->domain => $domain,
|
|
$this->isAscii() => $this->domainToAscii($domain),
|
|
default => $this->domainToUnicode($domain),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @throws CannotProcessHost
|
|
*/
|
|
public function prepend(DomainNameProvider|Host|Stringable|string|int|null $label): self
|
|
{
|
|
return $this->withLabel(count($this->labels), $label);
|
|
}
|
|
|
|
/**
|
|
* @throws CannotProcessHost
|
|
*/
|
|
public function append(DomainNameProvider|Host|Stringable|string|int|null $label): self
|
|
{
|
|
return $this->withLabel(- count($this->labels) - 1, $label);
|
|
}
|
|
|
|
public function withLabel(int $key, DomainNameProvider|Host|Stringable|string|int|null $label): self
|
|
{
|
|
$nbLabels = count($this->labels);
|
|
if ($key < - $nbLabels - 1 || $key > $nbLabels) {
|
|
throw SyntaxError::dueToInvalidLabelKey($this, $key);
|
|
}
|
|
|
|
if (0 > $key) {
|
|
$key = $nbLabels + $key;
|
|
}
|
|
|
|
$label = $this->normalize($label);
|
|
|
|
if (($this->labels[$key] ?? null) === $label) {
|
|
return $this;
|
|
}
|
|
|
|
$labels = $this->labels;
|
|
$labels[$key] = $label;
|
|
ksort($labels);
|
|
|
|
return new self($this->type, implode('.', array_reverse($labels)));
|
|
}
|
|
|
|
public function withoutLabel(int ...$keys): self
|
|
{
|
|
$nbLabels = count($this->labels);
|
|
foreach ($keys as &$offset) {
|
|
if (- $nbLabels > $offset || $nbLabels - 1 < $offset) {
|
|
throw SyntaxError::dueToInvalidLabelKey($this, $offset);
|
|
}
|
|
|
|
if (0 > $offset) {
|
|
$offset += $nbLabels;
|
|
}
|
|
}
|
|
unset($offset);
|
|
|
|
$deletedKeys = array_keys(array_count_values($keys));
|
|
$labels = [];
|
|
foreach ($this->labels as $offset => $label) {
|
|
if (!in_array($offset, $deletedKeys, true)) {
|
|
$labels[] = $label;
|
|
}
|
|
}
|
|
|
|
if ($labels === $this->labels) {
|
|
return $this;
|
|
}
|
|
|
|
return new self($this->type, [] === $labels ? null : implode('.', array_reverse($labels)));
|
|
}
|
|
|
|
/**
|
|
* @throws CannotProcessHost
|
|
*/
|
|
public function clear(): self
|
|
{
|
|
if (null === $this->domain) {
|
|
return $this;
|
|
}
|
|
|
|
return new self($this->type, null);
|
|
}
|
|
|
|
/**
|
|
* @throws CannotProcessHost
|
|
*/
|
|
public function slice(int $offset, ?int $length = null): self
|
|
{
|
|
$nbLabels = count($this->labels);
|
|
if ($offset < - $nbLabels || $offset > $nbLabels) {
|
|
throw SyntaxError::dueToInvalidLabelKey($this, $offset);
|
|
}
|
|
|
|
$labels = array_slice($this->labels, $offset, $length, true);
|
|
if ($labels === $this->labels) {
|
|
return $this;
|
|
}
|
|
|
|
return new self($this->type, [] === $labels ? null : implode('.', array_reverse($labels)));
|
|
}
|
|
}
|