Skip to content

Commit 1b45eb4

Browse files
authored
Merge pull request #7 from domainvalidity/refactor/improve-type-safety
refactor: Replace mixed types with precise recursive type definitions
2 parents 28a2a0d + 20b3e63 commit 1b45eb4

6 files changed

Lines changed: 136 additions & 49 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
strategy:
1818
fail-fast: false
1919
matrix:
20-
php-versions: ['8.1', '8.2', '8.3', '8.4']
20+
php-versions: ['8.2', '8.3', '8.4']
2121

2222
steps:
2323
- uses: actions/checkout@v3

composer.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@
1010
}
1111
],
1212
"require": {
13-
"php": "^8.1.0"
13+
"php": "^8.2.0"
1414
},
1515
"require-dev": {
16-
"pestphp/pest": "^2.31",
16+
"pestphp/pest": "^3.0",
1717
"phpstan/phpstan": "^1.10",
18-
"phpunit/phpunit": "^10.5",
18+
"phpunit/phpunit": "^11.5",
1919
"squizlabs/php_codesniffer": "^3.8",
2020
"symfony/var-dumper": "^7.0"
2121
},

phpstan.neon

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,6 @@ parameters:
22
level: 8
33
paths:
44
- src
5-
- tests
5+
- tests
6+
ignoreErrors:
7+
- '#no value type specified in iterable type array#'

src/Factory.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ class Factory
1313
*/
1414
public static function make(string $dotDatContent): Validator
1515
{
16+
/** @var array<'icann'|'private'|string, array<string, true|array<string, true|array<string, true|array<string, true|array>>>>> $publicSuffixList */
1617
$publicSuffixList = PublicSuffixListParser::parse($dotDatContent);
1718

1819
return new Validator($publicSuffixList);

src/Parse/PublicSuffixListParser.php

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
use function preg_match;
99
use function remove_comments;
1010
use function remove_empty_lines;
11-
use function strval;
1211
use function trim;
1312

1413
class PublicSuffixListParser
@@ -32,28 +31,74 @@ protected static function getSection(string $pattern, string $publicSuffixList):
3231
}
3332

3433
/**
35-
* @return array<string, array<int, string>>
34+
* Build a hierarchical nested map from domain lines.
35+
* Each domain is split by '.', reversed, and stored in nested arrays.
36+
* Uses '__end__' marker to indicate complete domain entries.
37+
*
38+
* @param array<string> $lines
39+
* @return array<string, true|array<string, true|array<string, true|array<string, true|array>>>>
40+
*/
41+
private static function buildHierarchicalMap(array $lines): array
42+
{
43+
$map = [];
44+
45+
foreach ($lines as $line) {
46+
$line = trim($line);
47+
if (empty($line)) {
48+
continue;
49+
}
50+
51+
// Split domain by '.' and reverse (e.g., 'com.mx' -> ['mx', 'com'])
52+
$parts = array_reverse(explode('.', $line));
53+
54+
// Navigate/create nested structure
55+
/** @var array<string, true|array<string, true|array<string, true|array<string, true|array>>>> $current */
56+
$current = &$map;
57+
foreach ($parts as $part) {
58+
if (!isset($current[$part])) {
59+
$current[$part] = [];
60+
}
61+
/** @var array<string, true|array<string, true|array<string, true|array<string, true|array>>>> $current */
62+
$current = &$current[$part];
63+
}
64+
65+
// Mark this as a complete domain entry
66+
$current['__end__'] = true;
67+
}
68+
69+
return $map;
70+
}
71+
72+
/**
73+
* @return array<'icann'|'private',
74+
* array<string, true|array<string, true|array<string, true|array<string, true|array>>>>>
3675
*/
3776
public static function parse(string $publicSuffixListString): array
3877
{
39-
$icannSection = strval(self::getSection(self::ICANN_DELIMITER_PATTERN, $publicSuffixListString));
78+
$icannSection = self::getSection(self::ICANN_DELIMITER_PATTERN, $publicSuffixListString);
4079

41-
$privateSection = strval(self::getSection(self::PRIVATE_DELIMITER_PATTERN, $publicSuffixListString));
80+
$privateSection = self::getSection(self::PRIVATE_DELIMITER_PATTERN, $publicSuffixListString);
4281

43-
$icann = remove_empty_lines(
44-
strval(remove_comments($icannSection))
45-
);
82+
$icann = remove_empty_lines(
83+
(string) remove_comments($icannSection ?? '')
84+
);
4685

47-
$private = remove_empty_lines(
48-
strval(remove_comments($privateSection))
49-
);
86+
$private = remove_empty_lines(
87+
(string) remove_comments($privateSection ?? '')
88+
);
5089

51-
$icannLines = explode("\n", strval($icann));
52-
$privateLines = explode("\n", strval($private));
90+
$icannLines = explode("\n", $icann ?? '');
91+
$privateLines = explode("\n", $private ?? '');
5392

5493
unset($icannLines[0]);
5594
unset($privateLines[0]);
5695

57-
return ['icann' => array_values($icannLines), 'private' => array_values($privateLines)];
96+
$icannLines = array_values($icannLines);
97+
$privateLines = array_values($privateLines);
98+
99+
return [
100+
'icann' => self::buildHierarchicalMap($icannLines),
101+
'private' => self::buildHierarchicalMap($privateLines),
102+
];
58103
}
59104
}

src/Validator.php

Lines changed: 70 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
class Validator
88
{
99
/**
10-
* @param array<string, array<int, string>> $publicSuffixList
10+
* @param array<'icann'|'private'|string,array<string,true|array<string,true|array<string,
11+
* true|array<string,true|array>>>>> $publicSuffixList
12+
* @phpstan-ignore-next-line
1113
*/
1214
public function __construct(
1315
protected array $publicSuffixList,
@@ -18,7 +20,10 @@ public function validate(string $host): Host
1820
{
1921
$host = new Host($host);
2022

21-
$tld = $this->getTld(explode('.', strval($host->toString())), 'icann');
23+
$tld = $this->findTldInHierarchy(
24+
explode('.', (string) $host->toString()),
25+
$this->publicSuffixList['icann']
26+
);
2227

2328
if ($tld !== null) {
2429
$host->isPrivate(
@@ -32,47 +37,81 @@ public function validate(string $host): Host
3237
}
3338

3439
/**
35-
* @param array<string> $parts
40+
* Find TLD in hierarchical structure using iterative lookup.
41+
* Traverses the hierarchy from right to left, checking each suffix.
42+
* Returns the longest matching domain suffix.
43+
*
44+
* @param array<string> $parts Domain parts (e.g., ['www', 'adro', 'com', 'mx'])
45+
* @param array<string, true|array<string, true|array<string, true|array<string, true|array>>>> $section
46+
* The hierarchical section
3647
*/
37-
protected function getTld(array $parts, string $section, bool $partialFound = false, ?string $tld = null): ?string
48+
protected function findTldInHierarchy(array $parts, array $section): ?string
3849
{
39-
$current = end($parts) . ($tld ? ".{$tld}" : '');
40-
unset($parts[count($parts) - 1]);
41-
42-
foreach ($this->publicSuffixList[$section] as $item) {
43-
if ($current === $item) {
44-
return $this->getTld(
45-
parts: $parts,
46-
section: $section,
47-
partialFound: true,
48-
tld: $current,
49-
);
50-
}
50+
if (empty($parts)) {
51+
return null;
5152
}
5253

53-
if ($partialFound === false) {
54-
$current = end($parts) . '.' . $current;
55-
foreach ($this->publicSuffixList[$section] as $item) {
56-
if ($current === $item) {
57-
return $this->getTld(
58-
parts: $parts,
59-
section: $section,
60-
partialFound: true,
61-
tld: $current,
62-
);
63-
}
54+
$longestMatch = null;
55+
$reversed = array_reverse($parts);
56+
$current = &$section;
57+
$depth = 0;
58+
59+
// Traverse from rightmost (top-level domain) leftward
60+
foreach ($reversed as $index => $part) {
61+
if (!isset($current[$part])) {
62+
// No match at this level, stop traversing
63+
break;
64+
}
65+
66+
$current = &$current[$part];
67+
$depth++;
68+
69+
// If this level is marked as a complete domain, record it
70+
if (isset($current['__end__'])) {
71+
// Build the matched suffix by taking the rightmost 'depth' parts in original order
72+
$suffix_parts = array_slice($reversed, 0, $depth);
73+
$longestMatch = implode('.', array_reverse($suffix_parts));
6474
}
6575
}
6676

67-
return $partialFound ? strval($tld) : null;
77+
return $longestMatch;
6878
}
6979

80+
/**
81+
* Check if host is in private domains list.
82+
* Uses hierarchical lookup similar to getTld with wildcard support.
83+
*/
7084
protected function checkIfIsPrivate(string $host): bool
7185
{
72-
foreach ($this->publicSuffixList['private'] as $item) {
73-
if (strpos($host, trim($item, '*')) !== false) {
74-
return true;
86+
$parts = explode('.', $host);
87+
$reversed = array_reverse($parts);
88+
/** @var array<string, true|array<string, true|array<string, true|array<string, true|array>>>> $section */
89+
$section = $this->publicSuffixList['private'];
90+
91+
$current = &$section;
92+
93+
// Traverse hierarchy from rightmost part
94+
foreach ($reversed as $part) {
95+
// Check for exact match
96+
if (isset($current[$part])) {
97+
if (isset($current[$part]['__end__'])) {
98+
return true;
99+
}
100+
$current = &$current[$part];
101+
continue;
75102
}
103+
104+
// Check for wildcard match
105+
if (isset($current['*'])) {
106+
if (isset($current['*']['__end__'])) {
107+
return true;
108+
}
109+
$current = &$current['*'];
110+
continue;
111+
}
112+
113+
// No match found at this level
114+
return false;
76115
}
77116

78117
return false;

0 commit comments

Comments
 (0)