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 : /// IPv6 address format validator (RFC 4291 §2.2).
16 : ///
17 : /// Validates IPv6 address strings without using `dart:io`, which is unavailable
18 : /// in some Dart environments (e.g. the browser). The implementation uses a
19 : /// group-counting approach rather than a single monolithic regex, making the
20 : /// logic more readable and the edge-case handling explicit.
21 : ///
22 : /// Supported forms:
23 : /// - Full eight-group form: `2001:db8:85a3:0:0:8a2e:370:7334`
24 : /// - All compressed (`::`) positions: `::1`, `1::`, `1::2`, `::`
25 : /// - IPv4-mapped tail: `::ffff:192.168.1.1`
26 : ///
27 : /// This is a best-effort structural check, not a full RFC 4291 parser.
28 : library;
29 :
30 : /// Pattern matching a single hex group (1–4 hex digits), case-insensitive.
31 3 : final RegExp _hexGroup = RegExp(r'^[0-9a-fA-F]{1,4}$');
32 :
33 : /// Pattern matching a valid IPv4 dotted-quad with no leading zeros in any
34 : /// octet, per the per-octet alternation that rejects values > 255.
35 3 : final RegExp _ipv4Dotted = RegExp(
36 : r'^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)'
37 : r'\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)'
38 : r'\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)'
39 : r'\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)$',
40 : );
41 :
42 : /// Validates IPv6 address strings (RFC 4291 §2.2).
43 : ///
44 : /// Both compressed and uncompressed forms are accepted. Group matching is
45 : /// case-insensitive (e.g. `2001:DB8::1` is valid). The empty string and
46 : /// strings with trailing/leading whitespace are rejected.
47 : ///
48 : /// Example:
49 : /// ```dart
50 : /// Ipv6.isValid('::1'); // true (loopback)
51 : /// Ipv6.isValid('::'); // true (all-zeros)
52 : /// Ipv6.isValid('::ffff:1.2.3.4'); // true (IPv4-mapped)
53 : /// Ipv6.isValid('gggg::1'); // false (invalid hex digit)
54 : /// Ipv6.isValid('1::2::3'); // false (two :: groups)
55 : /// ```
56 : class Ipv6 {
57 0 : Ipv6._();
58 :
59 : /// Returns `true` if [value] is a syntactically valid IPv6 address string.
60 1 : static bool isValid(String value) {
61 1 : if (value.isEmpty) return false;
62 :
63 : // At most one '::' is allowed in any IPv6 address.
64 2 : final dcCount = '::'.allMatches(value).length;
65 1 : if (dcCount > 1) return false;
66 :
67 1 : final hasDoubleColon = dcCount == 1;
68 :
69 : // Detect an IPv4-mapped tail (e.g. ::ffff:1.2.3.4).
70 : // The IPv4 portion appears after the last ':' when the string contains a
71 : // dotted-quad pattern.
72 1 : final lastColon = value.lastIndexOf(':');
73 1 : if (lastColon >= 0) {
74 2 : final tail = value.substring(lastColon + 1);
75 1 : if (tail.contains('.')) {
76 : // The tail looks like an IPv4 address — validate as IPv4-mapped.
77 1 : return _validateIpv4Mapped(value, tail, hasDoubleColon);
78 : }
79 : }
80 :
81 : // Pure hex form (no IPv4 tail).
82 : if (hasDoubleColon) {
83 1 : return _validateCompressed(value);
84 : } else {
85 1 : return _validateFull(value);
86 : }
87 : }
88 :
89 : /// Validates a full (uncompressed) eight-group IPv6 address.
90 : ///
91 : /// Expects exactly eight colon-separated hex groups.
92 1 : static bool _validateFull(String value) {
93 1 : final groups = value.split(':');
94 2 : if (groups.length != 8) return false;
95 3 : return groups.every(_hexGroup.hasMatch);
96 : }
97 :
98 : /// Validates a compressed IPv6 address that contains exactly one `::`.
99 : ///
100 : /// The `::` expands to fill the remaining groups so the total reaches eight.
101 : /// Each side of `::` may have zero to six explicit hex groups.
102 1 : static bool _validateCompressed(String value) {
103 1 : final sides = value.split('::');
104 : // split('::') on a valid compressed address always produces exactly 2
105 : // parts (even if one or both are empty strings).
106 2 : if (sides.length != 2) return false;
107 :
108 5 : final left = sides[0].isEmpty ? <String>[] : sides[0].split(':');
109 5 : final right = sides[1].isEmpty ? <String>[] : sides[1].split(':');
110 :
111 : // The total explicit groups must leave at least one slot for :: to expand,
112 : // so at most 6 groups across both sides (8 total − 2 from ::).
113 : // Exception: '::' alone has 0 explicit groups, which is fine (all zeros).
114 4 : if (left.length + right.length > 6) return false;
115 :
116 5 : return [...left, ...right].every(_hexGroup.hasMatch);
117 : }
118 :
119 : /// Validates an IPv6 address that has an IPv4 dotted-quad tail.
120 : ///
121 : /// Legal forms under RFC 4291 §2.2 rule 3:
122 : /// ```
123 : /// x:x:x:x:x:x:d.d.d.d
124 : /// ```
125 : /// where there are exactly six hex groups before the IPv4 part, or a
126 : /// `::` compressed form where the hex groups plus the IPv4 tail account for
127 : /// the full 128 bits (IPv4 counts as two 16-bit groups).
128 : ///
129 : /// [tail] is the portion after the last `:` (the IPv4 address string).
130 1 : static bool _validateIpv4Mapped(
131 : String value,
132 : String tail,
133 : bool hasDoubleColon,
134 : ) {
135 : // The IPv4 dotted-quad must itself be valid.
136 2 : if (!_ipv4Dotted.hasMatch(tail)) return false;
137 :
138 : // Strip the IPv4 tail (plus the preceding colon) to get the hex prefix.
139 3 : final prefixWithColon = value.substring(0, value.lastIndexOf(':') + 1);
140 : // prefixWithColon now ends in ':', e.g. "::ffff:" or "::".
141 :
142 : if (hasDoubleColon) {
143 : // Split on '::'.
144 1 : final dcIndex = prefixWithColon.indexOf('::');
145 1 : final leftStr = prefixWithColon.substring(0, dcIndex);
146 : // The portion after '::' ends in ':', strip the trailing colon.
147 2 : final rightStr = prefixWithColon.substring(dcIndex + 2);
148 1 : final rightClean = rightStr.endsWith(':')
149 3 : ? rightStr.substring(0, rightStr.length - 1)
150 : : rightStr;
151 :
152 3 : final left = leftStr.isEmpty ? <String>[] : leftStr.split(':');
153 3 : final right = rightClean.isEmpty ? <String>[] : rightClean.split(':');
154 :
155 : // The IPv4 tail counts as 2 hex groups, so at most 4 explicit hex
156 : // groups are allowed across both sides of '::'.
157 4 : if (left.length + right.length > 4) return false;
158 :
159 1 : return [
160 : ...left,
161 1 : ...right,
162 5 : ].every((g) => g.isEmpty || _hexGroup.hasMatch(g));
163 : } else {
164 : // No '::'. The prefix must be exactly 6 hex groups separated by ':'.
165 : // prefixWithColon ends with ':', so split gives a trailing empty string.
166 : final groups = prefixWithColon
167 1 : .split(':')
168 3 : .where((s) => s.isNotEmpty)
169 1 : .toList();
170 2 : if (groups.length != 6) return false;
171 3 : return groups.every(_hexGroup.hasMatch);
172 : }
173 : }
174 : }
|