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 : import 'dart:collection' show UnmodifiableMapView;
15 :
16 : import 'package:intl/intl.dart';
17 : import 'package:uuid/validation.dart';
18 :
19 : import 'digit_string.dart';
20 : import 'doi.dart';
21 : import 'duration.dart';
22 : import 'email.dart';
23 : import 'hex.dart';
24 : import 'idn_hostname.dart';
25 : import 'isbn.dart';
26 : import 'ipv6.dart';
27 : import 'lang.dart';
28 : import 'roman.dart';
29 : import 'urn.dart';
30 :
31 : export 'digit_string.dart';
32 : export 'doi.dart';
33 : export 'duration.dart';
34 : export 'email.dart';
35 : export 'hex.dart';
36 : export 'idn_hostname.dart';
37 : export 'isbn.dart';
38 : export 'ipv6.dart';
39 : export 'lang.dart';
40 : export 'roman.dart';
41 : export 'urn.dart';
42 :
43 : /// An immutable descriptor for a single named string-format validator.
44 : ///
45 : /// [name] identifies the format (e.g. `'email'`, `'uri'`, `'uuid'`).
46 : /// [description] is a human-readable explanation of the format.
47 : /// [function] returns `true` if a string value satisfies the format.
48 : class StringValidator {
49 : final String name;
50 : final String description;
51 : final bool Function(String) function;
52 :
53 10 : StringValidator(this.name, this.description, this.function);
54 : }
55 :
56 : /// Provides a registry of named string-format validators.
57 : ///
58 : /// Implementations must ensure [supportedValidators] returns an unmodifiable
59 : /// view — callers may enumerate it but must not mutate it.
60 : abstract class StringValidatorService {
61 : /// All validators indexed by format name.
62 : ///
63 : /// The returned map is unmodifiable; calling any mutating method throws
64 : /// [UnsupportedError].
65 : UnmodifiableMapView<String, StringValidator> get supportedValidators;
66 :
67 : /// Returns the [StringValidator] for [name], or `null` if unrecognised.
68 : StringValidator? getValidator(String name);
69 : }
70 :
71 : /// The built-in registry of JSON Schema and project-specific format validators.
72 : class StringFormatValidator implements StringValidatorService {
73 2 : @override
74 : UnmodifiableMapView<String, StringValidator> get supportedValidators =>
75 4 : UnmodifiableMapView(_supportedValidators);
76 :
77 10 : @override
78 20 : StringValidator? getValidator(String name) => _supportedValidators[name];
79 :
80 : final _supportedValidators = {
81 : 'uri': StringValidator(
82 : 'uri',
83 : 'A string instance is valid against this attribute if it is a valid URI,'
84 : ' according to RFC 3986. Note: a valid URN (urn: scheme) is also a'
85 : ' valid URI, so this validator accepts both http URLs and URNs.',
86 : // Uri.tryParse is intentionally lenient: it accepts all registered URI
87 : // schemes, including urn:. A URN is a valid URI per RFC 3986.
88 4 : (value) => Uri.tryParse(value) != null ? true : false,
89 : ),
90 : 'urn': StringValidator(
91 : 'urn',
92 : 'A string instance is valid against this attribute if it is a valid URN,'
93 : ' according to RFC 8141',
94 : // Urn.tryParse strictly validates URN syntax (urn:<nid>:<nss>).
95 : // Plain http/https URLs are not URNs and are rejected.
96 4 : (value) => Urn.tryParse(value) != null ? true : false,
97 : ),
98 : 'duration': StringValidator(
99 : 'duration',
100 : 'A string instance is valid against this attribute if it is a valid '
101 : 'representation according to RFC3339',
102 : Iso8601Duration.isValid,
103 : ),
104 : 'email': StringValidator(
105 : 'email',
106 : 'A string instance is valid against this attribute if it is a valid '
107 : 'representation of a pragmatic subset of RFC 5322',
108 : Email.isValid,
109 : ),
110 : 'lang': StringValidator(
111 : 'lang',
112 : 'A string instance is valid against this attribute if it is a valid '
113 : 'language tag as defined in RFC 5646',
114 : LanguageTag.isValid,
115 : ),
116 : 'date-time': StringValidator(
117 : 'date-time',
118 : 'A string instance is valid against this attribute if it is a valid '
119 : 'representation according to the "date-time" ABNF rule in ISO8601',
120 2 : (value) => DateTime.tryParse(value) != null ? true : false,
121 : ),
122 : 'date': StringValidator(
123 : 'date',
124 : 'A string instance is valid against this attribute if it is a valid '
125 : 'representation according to the "date" ABNF rule in RFC3339',
126 : isValidDate,
127 : ),
128 : 'time': StringValidator(
129 : 'time',
130 : 'A string instance is valid against this attribute if it is a valid '
131 : 'representation according to the "time" ABNF rule in RFC3339',
132 3 : (value) => DateFormat('HH:mm:ss.SSS').tryParseStrict(value) != null,
133 : ),
134 : 'uuid': StringValidator(
135 : 'uuid',
136 : 'A string instance is valid against this attribute if it is a valid '
137 : 'string representation of a UUID, according to RFC9562',
138 2 : (value) => UuidValidation.isValidUUID(fromString: value, noDashes: false),
139 : ),
140 : 'regex': StringValidator(
141 : 'regex',
142 : 'A regular expression, which SHOULD be valid '
143 : 'according to the ECMA-262 [ecma262] regular expression dialect.',
144 : isValidRegex,
145 : ),
146 : 'hex-string': StringValidator(
147 : 'hex-string',
148 : 'The string represents a valid '
149 : 'series of characters for a hexadecimal number',
150 : HexString.isValid,
151 : ),
152 : 'digit-string': StringValidator(
153 : 'digit-string',
154 : 'The string represents a valid '
155 : 'series of characters for a decimal number',
156 : DigitString.isValid,
157 : ),
158 : 'roman-numeral': StringValidator(
159 : 'roman-numeral',
160 : 'The string represents a valid '
161 : 'series of characters for a Roman numeral',
162 : RomanNumerals.isValid,
163 : ),
164 : 'isbn-13': StringValidator(
165 : 'isbn-13',
166 : 'The string represents a valid'
167 : ' International Standard Book Number (ISBN) if it meets the '
168 : 'required check digit calculation',
169 : Isbn13.isValid,
170 : ),
171 : 'doi': StringValidator(
172 : 'doi',
173 : 'The string represents a valid DOI',
174 : DOI.isValid,
175 : ),
176 : 'ipv4': StringValidator(
177 : 'ipv4',
178 : 'A string instance is valid against this attribute if it is a valid '
179 : 'IPv4 address according to RFC 2673 §3.2: four decimal octets '
180 : '0–255 separated by dots. Leading zeros in any octet are rejected '
181 : 'to avoid ambiguous octal interpretation.',
182 : // Per-octet alternation rejects leading zeros and values > 255 in a
183 : // single pass without post-processing.
184 2 : (value) => RegExp(
185 : r'^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)'
186 : r'\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)'
187 : r'\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)'
188 : r'\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)$',
189 1 : ).hasMatch(value),
190 : ),
191 : 'ipv6': StringValidator(
192 : 'ipv6',
193 : 'A string instance is valid against this attribute if it is a valid '
194 : 'IPv6 address according to RFC 4291 §2.2. Both full eight-group '
195 : 'and compressed (::) forms are accepted, including IPv4-mapped '
196 : 'addresses. Validation uses a pure-Dart approach (no dart:io) '
197 : 'so that the validator works in browser environments.',
198 : Ipv6.isValid,
199 : ),
200 : 'hostname': StringValidator(
201 : 'hostname',
202 : 'A string instance is valid against this attribute if it is a valid '
203 : 'Internet hostname per RFC 1123 §2.1. Labels are composed of '
204 : 'ASCII letters, digits, and hyphens; must not start or end with '
205 : 'a hyphen; must be 1–63 characters each; and the total length '
206 : 'must not exceed 253 characters. Trailing dots are rejected. '
207 : 'Matching is case-insensitive.',
208 : // Labels: start/end with alphanum, interior may include hyphens.
209 : // Single-char labels (one alphanumeric) are valid.
210 1 : (value) {
211 4 : if (value.isEmpty || value.endsWith('.') || value.length > 253) {
212 : return false;
213 : }
214 1 : final labelPattern = RegExp(
215 : r'^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$',
216 : );
217 : return value
218 1 : .split('.')
219 4 : .every((label) => label.isNotEmpty && labelPattern.hasMatch(label));
220 : },
221 : ),
222 : 'idn-hostname': StringValidator(
223 : 'idn-hostname',
224 : 'A string instance is valid against this attribute if it is a valid '
225 : 'internationalized hostname per RFC 5890. This is a best-effort '
226 : 'check: ASCII hostnames are validated per RFC 1123; labels '
227 : 'containing Unicode characters are accepted if they satisfy the '
228 : 'structural rules (no leading/trailing hyphen, label ≤ 63 chars, '
229 : 'total ≤ 253 chars). Full IDNA 2008 / Punycode conformance is not '
230 : 'enforced; that requires Punycode processing unavailable in '
231 : 'pure Dart without external dependencies.',
232 : IdnHostname.isValid,
233 : ),
234 : 'uri-reference': StringValidator(
235 : 'uri-reference',
236 : 'A string instance is valid against this attribute if it is a valid '
237 : 'URI reference per RFC 3986 §4.1 — either an absolute URI or a '
238 : 'relative reference. The check uses a structural approach: '
239 : 'Uri.tryParse must succeed AND the string must not contain '
240 : 'characters that are illegal in both forms (unescaped spaces, '
241 : 'ASCII control characters, or unencoded angle brackets).',
242 1 : (value) {
243 : // Reject strings containing unescaped spaces, ASCII control
244 : // characters (0x00–0x1F, 0x7F), or literal angle brackets.
245 : // These are illegal in both absolute URIs and relative references
246 : // per RFC 3986, regardless of how permissive Uri.tryParse is.
247 2 : if (RegExp(r'[\x00-\x1F\x7F <>\[\]\\^`{|}]').hasMatch(value)) {
248 : return false;
249 : }
250 1 : return Uri.tryParse(value) != null;
251 : },
252 : ),
253 : 'json-pointer': StringValidator(
254 : 'json-pointer',
255 : 'A string instance is valid against this attribute if it is a valid '
256 : 'JSON Pointer per RFC 6901. A JSON Pointer is either an empty '
257 : 'string (pointing to the root document) or a sequence of '
258 : 'reference tokens each prefixed by /. Within a token, ~ must '
259 : 'only appear as ~0 (representing ~) or ~1 (representing /).',
260 : // Regex: empty string OR one-or-more /token sequences where each
261 : // token contains any character except ~ (which must be ~0 or ~1).
262 3 : (value) => RegExp(r'^(/([^~]|~[01])*)*$').hasMatch(value),
263 : ),
264 : 'relative-json-pointer': StringValidator(
265 : 'relative-json-pointer',
266 : 'A string instance is valid against this attribute if it is a valid '
267 : 'Relative JSON Pointer per the IETF draft (bhutton). A Relative '
268 : 'JSON Pointer begins with a non-negative integer (no leading zeros '
269 : 'unless the value is 0) followed by either # (referring to the '
270 : 'key/index of the referenced location) or a JSON Pointer '
271 : '(including the empty string).',
272 : // Regex breakdown:
273 : // (0|[1-9][0-9]*) — non-negative integer, no leading zeros
274 : // (#|(/([^~]|~[01])*)*) — either '#' or a JSON Pointer
275 1 : (value) =>
276 2 : RegExp(r'^(0|[1-9][0-9]*)(#|(/([^~]|~[01])*)*)$').hasMatch(value),
277 : ),
278 : };
279 : }
280 :
281 0 : bool isValidRegex(String value) {
282 : try {
283 0 : RegExp(value);
284 0 : } on FormatException {
285 : return false;
286 : }
287 : return true;
288 : }
289 :
290 1 : bool isValidDate(String value) {
291 : // Reject anything that isn't strictly YYYY-MM-DD.
292 2 : if (!RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(value)) return false;
293 1 : final d = DateTime.tryParse(value);
294 : if (d == null) return false;
295 : // Reject overflow dates (e.g. month 13) by re-formatting and comparing.
296 1 : final reformatted =
297 3 : '${d.year.toString().padLeft(4, '0')}-'
298 3 : '${d.month.toString().padLeft(2, '0')}-'
299 3 : '${d.day.toString().padLeft(2, '0')}';
300 1 : return reformatted == value;
301 : }
|