Line data Source code
1 : // Copyright 2026 The Authors
2 : //
3 : // Licensed under the Apache License, Version 2.0 (the "License");
4 : // you may not use this file except in compliance with the License.
5 : // You may obtain a copy of the License at
6 : //
7 : // https://www.apache.org/licenses/LICENSE-2.0
8 : //
9 : // Unless required by applicable law or agreed to in writing, software
10 : // distributed under the License is distributed on an "AS IS" BASIS,
11 : // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 : // See the License for the specific language governing permissions and
13 : // limitations under the License.
14 :
15 : /// Best-effort IDN hostname validator (RFC 5890 §2.3.2.3).
16 : ///
17 : /// **Important:** This is a pragmatic approximation, not a full IDNA 2008 /
18 : /// Punycode conformance check. Full IDNA 2008 conformance requires Punycode
19 : /// encoding and Unicode normalization that are not available in a pure-Dart
20 : /// context without external dependencies. This validator accepts:
21 : ///
22 : /// - Any ASCII hostname valid under RFC 1123 (see `hostname` format).
23 : /// - Labels that contain Unicode letters or digits (Unicode categories L and N)
24 : /// in addition to ASCII alphanumerics and hyphens, with the same structural
25 : /// rules (no leading/trailing hyphen, label ≤ 63 chars, total ≤ 253 chars).
26 : ///
27 : /// This catches clearly invalid inputs (empty labels, leading/trailing hyphens,
28 : /// oversized labels, oversized total) without requiring Punycode processing.
29 : library;
30 :
31 : /// A single ASCII hostname label per RFC 1123.
32 : ///
33 : /// A label must:
34 : /// - Start and end with a letter or digit (ASCII).
35 : /// - Contain only letters, digits, or hyphens.
36 : /// - Be between 1 and 63 characters.
37 : ///
38 : /// Single-character labels (a letter or digit alone) are also valid.
39 3 : final RegExp _asciiLabel = RegExp(
40 : r'^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$',
41 : );
42 :
43 : /// Validates IDN hostnames with a best-effort Unicode label check.
44 : ///
45 : /// This is a pragmatic best-effort validator (not full IDNA 2008 / Punycode
46 : /// conformance). It accepts ASCII hostnames and hostnames whose labels contain
47 : /// Unicode letters or digits while still enforcing the RFC 1123 structural
48 : /// rules (label length, total length, no leading/trailing hyphen).
49 : ///
50 : /// Example:
51 : /// ```dart
52 : /// IdnHostname.isValid('example.com'); // true
53 : /// IdnHostname.isValid('münchen.de'); // true (best-effort: unicode label)
54 : /// IdnHostname.isValid('-bad.com'); // false (leading hyphen)
55 : /// IdnHostname.isValid('a' * 64 + '.com'); // false (label too long)
56 : /// ```
57 : class IdnHostname {
58 0 : IdnHostname._();
59 :
60 : /// Returns `true` if [value] is a valid IDN hostname (best-effort check).
61 : ///
62 : /// Both ASCII labels and Unicode labels are accepted. The total hostname
63 : /// length must be ≤ 253 characters. Each label must be ≤ 63 characters.
64 : /// Labels must not start or end with a hyphen.
65 1 : static bool isValid(String value) {
66 1 : if (value.isEmpty) return false;
67 :
68 : // Trailing dots are rejected (RFC 1123 host names, not DNS zone-file FQDNs).
69 1 : if (value.endsWith('.')) return false;
70 :
71 : // Total length must not exceed 253 characters.
72 2 : if (value.length > 253) return false;
73 :
74 1 : final labels = value.split('.');
75 :
76 2 : for (final label in labels) {
77 1 : if (label.isEmpty) return false;
78 2 : if (label.length > 63) return false;
79 :
80 : // Try the ASCII label pattern first.
81 2 : if (_asciiLabel.hasMatch(label)) continue;
82 :
83 : // For labels containing non-ASCII characters, apply structural rules:
84 : // must not start or end with a hyphen, and every character must be a
85 : // Unicode letter (L), Unicode digit (N), ASCII alphanumeric, or hyphen.
86 : // NOTE: Dart's RegExp does not support \p{L}/\p{N} Unicode properties
87 : // natively. We check by rejecting controls and known-invalid characters
88 : // rather than by allow-listing, then enforce the structural hyphen rule.
89 1 : if (!_isValidUnicodeLabel(label)) return false;
90 : }
91 :
92 : return true;
93 : }
94 :
95 : /// Validates a single label that may contain Unicode characters.
96 : ///
97 : /// Rules enforced:
98 : /// - Must not start or end with `-`.
99 : /// - Must not be empty (enforced by the caller).
100 : /// - Must not contain characters that are ASCII control characters, spaces,
101 : /// or the explicitly disallowed set (`@`, `[`, `]`, etc.).
102 : /// - Must contain at least one non-hyphen character.
103 : ///
104 : /// This is a best-effort check. Full IDNA 2008 validation would require
105 : /// Punycode encoding and Unicode normalization (NFKC).
106 1 : static bool _isValidUnicodeLabel(String label) {
107 : // Must not start or end with a hyphen.
108 2 : if (label.startsWith('-') || label.endsWith('-')) return false;
109 :
110 : // Pattern of characters that are explicitly disallowed in any label
111 : // character position, regardless of Unicode category.
112 : // This covers ASCII control characters, space, and label-separating /
113 : // URI-reserved punctuation.
114 1 : final disallowed = RegExp(
115 : r'[\x00-\x1F\x7F !@#$%^&*()=+\[\]{};:",<>/?\\|`~]',
116 : );
117 1 : if (disallowed.hasMatch(label)) return false;
118 :
119 : // The label must contain at least one non-hyphen character.
120 2 : if (label.replaceAll('-', '').isEmpty) return false;
121 :
122 : return true;
123 : }
124 : }
|