Skip to content

Commit 6cf7118

Browse files
committed
Introducing IdnaResult
1 parent 8b70551 commit 6cf7118

File tree

8 files changed

+261
-100
lines changed

8 files changed

+261
-100
lines changed

src/Domain.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ private function domainToAscii(string $domain): string
164164
$option = IntlIdna::IDNA2003_ASCII_OPTIONS;
165165
}
166166

167-
return IntlIdna::toAscii($domain, $option);
167+
return IntlIdna::toAscii($domain, $option)->result();
168168
}
169169

170170
private function domainToUnicode(string $domain): string
@@ -174,7 +174,7 @@ private function domainToUnicode(string $domain): string
174174
$option = IntlIdna::IDNA2003_UNICODE_OPTIONS;
175175
}
176176

177-
return IntlIdna::toUnicode($domain, $option);
177+
return IntlIdna::toUnicode($domain, $option)->result();
178178
}
179179

180180
/**

src/IdnaResult.php

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Pdp;
6+
7+
use function array_filter;
8+
use function array_keys;
9+
use function array_reduce;
10+
use const ARRAY_FILTER_USE_KEY;
11+
12+
final class IdnaResult
13+
{
14+
/**
15+
* IDNA errors.
16+
*
17+
* @see https://unicode-org.github.io/icu-docs/apidoc/dev/icu4j/
18+
*/
19+
public const ERROR_EMPTY_LABEL = 1;
20+
public const ERROR_LABEL_TOO_LONG = 2;
21+
public const ERROR_DOMAIN_NAME_TOO_LONG = 4;
22+
public const ERROR_LEADING_HYPHEN = 8;
23+
public const ERROR_TRAILING_HYPHEN = 0x10;
24+
public const ERROR_HYPHEN_3_4 = 0x20;
25+
public const ERROR_LEADING_COMBINING_MARK = 0x40;
26+
public const ERROR_DISALLOWED = 0x80;
27+
public const ERROR_PUNYCODE = 0x100;
28+
public const ERROR_LABEL_HAS_DOT = 0x200;
29+
public const ERROR_INVALID_ACE_LABEL = 0x400;
30+
public const ERROR_BIDI = 0x800;
31+
public const ERROR_CONTEXTJ = 0x1000;
32+
public const ERROR_CONTEXTO_PUNCTUATION = 0x2000;
33+
public const ERROR_CONTEXTO_DIGITS = 0x4000;
34+
35+
private const ERRORS = [
36+
self::ERROR_EMPTY_LABEL => 'a non-final domain name label (or the whole domain name) is empty',
37+
self::ERROR_LABEL_TOO_LONG => 'a domain name label is longer than 63 bytes',
38+
self::ERROR_DOMAIN_NAME_TOO_LONG => 'a domain name is longer than 255 bytes in its storage form',
39+
self::ERROR_LEADING_HYPHEN => 'a label starts with a hyphen-minus ("-")',
40+
self::ERROR_TRAILING_HYPHEN => 'a label ends with a hyphen-minus ("-")',
41+
self::ERROR_HYPHEN_3_4 => 'a label contains hyphen-minus ("-") in the third and fourth positions',
42+
self::ERROR_LEADING_COMBINING_MARK => 'a label starts with a combining mark',
43+
self::ERROR_DISALLOWED => 'a label or domain name contains disallowed characters',
44+
self::ERROR_PUNYCODE => 'a label starts with "xn--" but does not contain valid Punycode',
45+
self::ERROR_LABEL_HAS_DOT => 'a label contains a dot=full stop',
46+
self::ERROR_INVALID_ACE_LABEL => 'An ACE label does not contain a valid label string',
47+
self::ERROR_BIDI => 'a label does not meet the IDNA BiDi requirements (for right-to-left characters)',
48+
self::ERROR_CONTEXTJ => 'a label does not meet the IDNA CONTEXTJ requirements',
49+
self::ERROR_CONTEXTO_DIGITS => 'a label does not meet the IDNA CONTEXTO requirements for digits',
50+
self::ERROR_CONTEXTO_PUNCTUATION => 'a label does not meet the IDNA CONTEXTO requirements for punctuation characters. Some punctuation characters "Would otherwise have been DISALLOWED" but are allowed in certain contexts',
51+
];
52+
53+
private string $result;
54+
55+
private bool $isTransitionalDifferent;
56+
57+
/**
58+
* @var array<int, string>
59+
*/
60+
private array $errors;
61+
62+
/**
63+
* @param array<int, string> $error
64+
*/
65+
private function __construct(string $result, bool $isTransitionalDifferent, array $error)
66+
{
67+
$this->result = $result;
68+
$this->errors = $error;
69+
$this->isTransitionalDifferent = $isTransitionalDifferent;
70+
}
71+
72+
/**
73+
* @param array{result:string, isTransitionalDifferent:bool, errors:int} $infos
74+
*/
75+
public static function fromIntl(array $infos): self
76+
{
77+
return new self(
78+
$infos['result'],
79+
$infos['isTransitionalDifferent'],
80+
array_filter(
81+
self::ERRORS,
82+
fn (int $errorByte): bool => 0 !== ($errorByte & $infos['errors']),
83+
ARRAY_FILTER_USE_KEY
84+
)
85+
);
86+
}
87+
88+
/**
89+
* @param array{result:string, isTransitionalDifferent:bool, errors:array<int, string>} $properties
90+
*/
91+
public static function __set_state(array $properties): self
92+
{
93+
return new self($properties['result'], $properties['isTransitionalDifferent'], $properties['errors']);
94+
}
95+
96+
public function result(): string
97+
{
98+
return $this->result;
99+
}
100+
101+
public function isTransitionalDifferent(): bool
102+
{
103+
return $this->isTransitionalDifferent;
104+
}
105+
106+
/**
107+
* @return array<int, string>
108+
*/
109+
public function errors(): array
110+
{
111+
return $this->errors;
112+
}
113+
114+
public function error(int $error): ?string
115+
{
116+
return $this->errors[$error] ?? null;
117+
}
118+
119+
/**
120+
* @return array{result:string, isTransitionalDifferent:bool, errors:int}
121+
*/
122+
public function toIntl(): array
123+
{
124+
return [
125+
'result' => $this->result,
126+
'isTransitionalDifferent' => $this->isTransitionalDifferent,
127+
'errors' => array_reduce(
128+
array_keys($this->errors),
129+
fn (int $curry, int $errorByte): int => $curry | $errorByte,
130+
0
131+
),
132+
];
133+
}
134+
}

src/IdnaResultTest.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Pdp;
6+
7+
use PHPUnit\Framework\TestCase;
8+
use function var_export;
9+
10+
final class IdnaResultTest extends TestCase
11+
{
12+
public function testDomainInternalPhpMethod(): void
13+
{
14+
$infos = ['result' => 'foo.bar', 'isTransitionalDifferent' => false, 'errors' => 0];
15+
$result = IdnaResult::fromIntl($infos);
16+
$generateResult = eval('return '.var_export($result, true).';');
17+
18+
self::assertEquals($result, $generateResult);
19+
self::assertSame($infos, $generateResult->toIntl());
20+
}
21+
22+
public function testItCanBeInstantiatedFromArray(): void
23+
{
24+
$infos = ['result' => '', 'isTransitionalDifferent' => false, 'errors' => 0];
25+
$result = IdnaResult::fromIntl($infos);
26+
27+
self::assertSame('', $result->result());
28+
self::assertFalse($result->isTransitionalDifferent());
29+
self::assertCount(0, $result->errors());
30+
self::assertNull($result->error(IdnaResult::ERROR_BIDI));
31+
}
32+
33+
public function testInvalidSyntaxAfterIDNConversion(): void
34+
{
35+
try {
36+
IntlIdna::toAscii('%00.com', IntlIdna::IDNA2008_ASCII);
37+
} catch (SyntaxError $exception) {
38+
$result = $exception->fetchIdnaResult();
39+
self::assertInstanceOf(IdnaResult::class, $result);
40+
self::assertCount(1, $result->errors());
41+
self::assertNotEmpty($result->error(IdnaResult::ERROR_DISALLOWED));
42+
}
43+
}
44+
}

src/IntlIdna.php

Lines changed: 31 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -4,153 +4,92 @@
44

55
namespace Pdp;
66

7-
use UnexpectedValueException;
87
use function idn_to_ascii;
98
use function idn_to_utf8;
10-
use function implode;
119
use function preg_match;
1210
use function rawurldecode;
1311
use function strpos;
1412
use function strtolower;
1513
use const IDNA_CHECK_BIDI;
1614
use const IDNA_CHECK_CONTEXTJ;
1715
use const IDNA_DEFAULT;
18-
use const IDNA_ERROR_BIDI;
19-
use const IDNA_ERROR_CONTEXTJ;
20-
use const IDNA_ERROR_DISALLOWED;
21-
use const IDNA_ERROR_DOMAIN_NAME_TOO_LONG;
22-
use const IDNA_ERROR_EMPTY_LABEL;
23-
use const IDNA_ERROR_HYPHEN_3_4;
24-
use const IDNA_ERROR_INVALID_ACE_LABEL;
25-
use const IDNA_ERROR_LABEL_HAS_DOT;
26-
use const IDNA_ERROR_LABEL_TOO_LONG;
27-
use const IDNA_ERROR_LEADING_COMBINING_MARK;
28-
use const IDNA_ERROR_LEADING_HYPHEN;
29-
use const IDNA_ERROR_PUNYCODE;
30-
use const IDNA_ERROR_TRAILING_HYPHEN;
3116
use const IDNA_NONTRANSITIONAL_TO_ASCII;
3217
use const IDNA_NONTRANSITIONAL_TO_UNICODE;
3318
use const IDNA_USE_STD3_RULES;
3419
use const INTL_IDNA_VARIANT_UTS46;
3520

3621
final class IntlIdna
3722
{
38-
public const IDNA2008_ASCII_OPTIONS = IDNA_NONTRANSITIONAL_TO_ASCII
23+
public const IDNA2008_ASCII = IDNA_NONTRANSITIONAL_TO_ASCII
3924
| IDNA_CHECK_BIDI
4025
| IDNA_USE_STD3_RULES
4126
| IDNA_CHECK_CONTEXTJ;
4227

43-
public const IDNA2008_UNICODE_OPTIONS = IDNA_NONTRANSITIONAL_TO_UNICODE
28+
public const IDNA2008_UNICODE = IDNA_NONTRANSITIONAL_TO_UNICODE
4429
| IDNA_CHECK_BIDI
4530
| IDNA_USE_STD3_RULES
4631
| IDNA_CHECK_CONTEXTJ;
4732

48-
public const IDNA2003_ASCII_OPTIONS = IDNA_DEFAULT;
49-
public const IDNA2003_UNICODE_OPTIONS = IDNA_DEFAULT;
50-
51-
/**
52-
* IDNA errors.
53-
*
54-
* @see http://icu-project.org/apiref/icu4j/com/ibm/icu/text/IDNA.Error.html
55-
*/
56-
private const IDNA_ERRORS = [
57-
IDNA_ERROR_EMPTY_LABEL => 'a non-final domain name label (or the whole domain name) is empty',
58-
IDNA_ERROR_LABEL_TOO_LONG => 'a domain name label is longer than 63 bytes',
59-
IDNA_ERROR_DOMAIN_NAME_TOO_LONG => 'a domain name is longer than 255 bytes in its storage form',
60-
IDNA_ERROR_LEADING_HYPHEN => 'a label starts with a hyphen-minus ("-")',
61-
IDNA_ERROR_TRAILING_HYPHEN => 'a label ends with a hyphen-minus ("-")',
62-
IDNA_ERROR_HYPHEN_3_4 => 'a label contains hyphen-minus ("-") in the third and fourth positions',
63-
IDNA_ERROR_LEADING_COMBINING_MARK => 'a label starts with a combining mark',
64-
IDNA_ERROR_DISALLOWED => 'a label or domain name contains disallowed characters',
65-
IDNA_ERROR_PUNYCODE => 'a label starts with "xn--" but does not contain valid Punycode',
66-
IDNA_ERROR_LABEL_HAS_DOT => 'a label contains a dot=full stop',
67-
IDNA_ERROR_INVALID_ACE_LABEL => 'An ACE label does not contain a valid label string',
68-
IDNA_ERROR_BIDI => 'a label does not meet the IDNA BiDi requirements (for right-to-left characters)',
69-
IDNA_ERROR_CONTEXTJ => 'a label does not meet the IDNA CONTEXTJ requirements',
70-
];
33+
public const IDNA2003_ASCII = IDNA_DEFAULT;
34+
public const IDNA2003_UNICODE = IDNA_DEFAULT;
7135

7236
private const REGEXP_IDNA_PATTERN = '/[^\x20-\x7f]/';
7337

74-
/**
75-
* Get and format IDN conversion error message.
76-
*/
77-
private static function getIDNAErrors(int $errorByte): string
78-
{
79-
$res = [];
80-
foreach (self::IDNA_ERRORS as $error => $reason) {
81-
if ($error === ($errorByte & $error)) {
82-
$res[] = $reason;
83-
}
84-
}
85-
86-
return [] === $res ? 'Unknown IDNA conversion error.' : implode(', ', $res).'.';
87-
}
88-
8938
/**
9039
* Converts the input to its IDNA ASCII form.
9140
*
9241
* This method returns the string converted to IDN ASCII form
9342
*
9443
* @throws SyntaxError if the string can not be converted to ASCII using IDN UTS46 algorithm
9544
*/
96-
public static function toAscii(string $domain, int $option): string
45+
public static function toAscii(string $domain, int $option): IdnaResult
9746
{
9847
$domain = rawurldecode($domain);
9948
if (1 !== preg_match(self::REGEXP_IDNA_PATTERN, $domain)) {
100-
return strtolower($domain);
101-
}
102-
103-
$output = idn_to_ascii($domain, $option, INTL_IDNA_VARIANT_UTS46, $infos);
104-
if ([] === $infos) {
105-
throw SyntaxError::dueToIDNAError($domain);
106-
}
107-
108-
if (0 !== $infos['errors']) {
109-
throw SyntaxError::dueToIDNAError($domain, self::getIDNAErrors($infos['errors']));
49+
return IdnaResult::fromIntl([
50+
'result' => strtolower($domain),
51+
'isTransitionalDifferent' => false,
52+
'errors' => 0,
53+
]);
11054
}
11155

112-
// @codeCoverageIgnoreStart
113-
if (false === $output) {
114-
throw new UnexpectedValueException('The Intl extension is misconfigured for '.PHP_OS.', please correct this issue before proceeding.');
115-
}
116-
// @codeCoverageIgnoreEnd
117-
118-
if (false === strpos($output, '%')) {
119-
return $output;
120-
}
56+
idn_to_ascii($domain, $option, INTL_IDNA_VARIANT_UTS46, $infos);
12157

122-
throw SyntaxError::dueToInvalidCharacters($domain);
58+
return self::createIdnaResult($domain, $infos);
12359
}
12460

12561
/**
12662
* Converts the input to its IDNA UNICODE form.
12763
*
12864
* This method returns the string converted to IDN UNICODE form
12965
*
130-
* @throws SyntaxError if the string can not be converted to UNICODE using IDN UTS46 algorithm
131-
* @throws UnexpectedValueException if the intl extension is misconfigured
66+
* @throws SyntaxError if the string can not be converted to UNICODE using IDN UTS46 algorithm
13267
*/
133-
public static function toUnicode(string $domain, int $option): string
68+
public static function toUnicode(string $domain, int $option): IdnaResult
13469
{
13570
if (false === strpos($domain, 'xn--')) {
136-
return $domain;
71+
return IdnaResult::fromIntl([
72+
'result' => $domain,
73+
'isTransitionalDifferent' => false,
74+
'errors' => 0,
75+
]);
13776
}
13877

139-
$output = idn_to_utf8($domain, $option, INTL_IDNA_VARIANT_UTS46, $info);
140-
if ([] === $info) {
141-
throw SyntaxError::dueToIDNAError($domain);
142-
}
78+
idn_to_utf8($domain, $option, INTL_IDNA_VARIANT_UTS46, $infos);
14379

144-
if (0 !== $info['errors']) {
145-
throw SyntaxError::dueToIDNAError($domain, self::getIDNAErrors($info['errors']));
146-
}
80+
return self::createIdnaResult($domain, $infos);
81+
}
14782

148-
// @codeCoverageIgnoreStart
149-
if (false === $output) {
150-
throw new UnexpectedValueException('The Intl extension for '.PHP_OS.' is misconfigured. Please correct this issue before proceeding.');
83+
/**
84+
* @param array{result:string, isTransitionalDifferent:bool, errors:int} $infos
85+
*/
86+
private static function createIdnaResult(string $domain, array $infos): IdnaResult
87+
{
88+
$result = IdnaResult::fromIntl($infos);
89+
if ([] !== $result->errors()) {
90+
throw SyntaxError::dueToIDNAError($domain, $result);
15191
}
152-
// @codeCoverageIgnoreEnd
15392

154-
return $output;
93+
return $result;
15594
}
15695
}

src/Rules.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ private static function addRule(array $list, array $ruleParts): array
162162
try {
163163
/** @var string $line */
164164
$line = array_pop($ruleParts);
165-
$rule = IntlIdna::toAscii($line, IntlIdna::IDNA2008_ASCII_OPTIONS);
165+
$rule = IntlIdna::toAscii($line, IntlIdna::IDNA2008_ASCII_OPTIONS)->result();
166166
} catch (CannotProcessHost $exception) {
167167
throw UnableToLoadPublicSuffixList::dueToInvalidRule($line ?? null, $exception);
168168
}

0 commit comments

Comments
 (0)