z-schema 12.3.1 → 12.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,186 @@ import isIPModule from 'validator/lib/isIP.js';
4
4
  const IDN_SEPARATOR_REGEX = /[\u3002\uFF0E\uFF61]/g;
5
5
  const IDN_SEPARATOR_TEST_REGEX = /[\u3002\uFF0E\uFF61]/;
6
6
 
7
+ // IDNA2008 (RFC 5890-5893) support below is a hand-rolled approximation built on
8
+ // General_Category / Script Unicode-property regexes (JS regex has no
9
+ // \p{Bidi_Class} or \p{Joining_Type}). It covers the JSON-Schema-Test-Suite
10
+ // corpus, not the full IDNA table. Known limitations: joining-context (CONTEXTJ)
11
+ // only models Arabic script; the ZWJ/ZWNJ Virama context recognizes only the
12
+ // Devanagari virama U+094D (not other scripts' viramas); Bidi class detection
13
+ // covers L/R/AL/AN/EN/NSM and treats everything else as ON; and U-labels are not
14
+ // required to be in Unicode NFC form. All regexes/Sets are hoisted to module scope
15
+ // so they are compiled once (format validation runs on hot paths).
16
+
17
+ // Matches an A-label (ACE) prefix; hoisted so it is compiled once, not per label.
18
+ const XN_LABEL_REGEX = /^xn--/i;
19
+
20
+ // RFC 5892 section 2.6 lists a handful of code points that are PVALID (or governed
21
+ // by a contextual rule) despite being Punctuation/Symbol. A blanket "reject
22
+ // P/S/Z/C" DISALLOWED heuristic must whitelist exactly these so valid labels are
23
+ // not rejected. Letters (e.g. U+00DF sharp s, U+03C2 final sigma) and Numbers
24
+ // (e.g. U+3007) are unaffected by the heuristic and need no entry here.
25
+ const DISALLOWED_CATEGORY_REGEX = /[\p{P}\p{S}\p{Z}\p{C}]/u;
26
+ const DISALLOWED_CATEGORY_WHITELIST: ReadonlySet<string> = new Set([
27
+ '-', // HYPHEN-MINUS (medial; leading/trailing rejected separately)
28
+ '\u00B7', // MIDDLE DOT (CONTEXTO)
29
+ '\u0375', // GREEK LOWER NUMERAL SIGN / KERAIA (CONTEXTO)
30
+ '\u05F3', // HEBREW PUNCTUATION GERESH (CONTEXTO)
31
+ '\u05F4', // HEBREW PUNCTUATION GERSHAYIM (CONTEXTO)
32
+ '\u30FB', // KATAKANA MIDDLE DOT (CONTEXTO)
33
+ '\u06FD', // ARABIC SIGN SINDHI AMPERSAND (RFC 5892 section 2.6 PVALID)
34
+ '\u06FE', // ARABIC SIGN SINDHI POSTPOSITION MEN (RFC 5892 section 2.6 PVALID)
35
+ '\u0F0B', // TIBETAN MARK INTERSYLLABIC TSHEG (RFC 5892 section 2.6 PVALID)
36
+ '\u200C', // ZERO WIDTH NON-JOINER (CONTEXTJ, checked separately)
37
+ '\u200D', // ZERO WIDTH JOINER (CONTEXTJ, checked separately)
38
+ ]);
39
+
40
+ const isDisallowedCodePoint = (char: string): boolean =>
41
+ DISALLOWED_CATEGORY_REGEX.test(char) && !DISALLOWED_CATEGORY_WHITELIST.has(char);
42
+
43
+ // CONTEXTJ (RFC 5892 Appendix A.1) support for ZERO WIDTH NON-JOINER.
44
+ const TRANSPARENT_MARK_REGEX = /\p{Mn}/u;
45
+ const ARABIC_SCRIPT_REGEX = /\p{Script=Arabic}/u;
46
+ const ARABIC_INDIC_DIGITS_REGEX = /[\u0660-\u0669\u06F0-\u06F9]/;
47
+
48
+ // Approximates Joining_Type {L, D, R} as "an Arabic-script letter" (excluding the
49
+ // Arabic-Indic digit ranges, which are not letters).
50
+ const isArabicJoiningLetter = (char: string | undefined): boolean =>
51
+ char !== undefined && ARABIC_SCRIPT_REGEX.test(char) && !ARABIC_INDIC_DIGITS_REGEX.test(char);
52
+
53
+ // ZWNJ at `idx` is valid if it sits between two joining letters, skipping over any
54
+ // Transparent (combining-mark) code points on either side.
55
+ const hasZwnjJoiningContext = (label: string, idx: number): boolean => {
56
+ let before = idx - 1;
57
+ while (before >= 0 && TRANSPARENT_MARK_REGEX.test(label[before])) {
58
+ before--;
59
+ }
60
+ let after = idx + 1;
61
+ while (after < label.length && TRANSPARENT_MARK_REGEX.test(label[after])) {
62
+ after++;
63
+ }
64
+ return isArabicJoiningLetter(label[before]) && isArabicJoiningLetter(label[after]);
65
+ };
66
+
67
+ // RFC 5893 Bidi rule. Bidi_Class is approximated from Script / General_Category.
68
+ type BidiClass = 'AL' | 'AN' | 'EN' | 'L' | 'NSM' | 'ON' | 'R';
69
+
70
+ const BIDI_NSM_REGEX = /[\p{Mn}\p{Me}]/u;
71
+ const BIDI_ARABIC_INDIC_DIGIT_REGEX = /[\u0660-\u0669]/; // Bidi_Class AN
72
+ const BIDI_EXTENDED_ARABIC_INDIC_DIGIT_REGEX = /[\u06F0-\u06F9]/; // Bidi_Class EN (NOT AN)
73
+ const BIDI_HEBREW_SCRIPT_REGEX = /\p{Script=Hebrew}/u; // Bidi_Class R
74
+ const BIDI_AL_SCRIPT_REGEX = /[\p{Script=Arabic}\p{Script=Syriac}\p{Script=Thaana}]/u; // Bidi_Class AL
75
+ const BIDI_ASCII_DIGIT_REGEX = /[0-9]/; // Bidi_Class EN
76
+ const BIDI_LETTER_REGEX = /\p{L}/u;
77
+
78
+ const classifyBidi = (char: string): BidiClass => {
79
+ if (BIDI_NSM_REGEX.test(char)) {
80
+ return 'NSM';
81
+ }
82
+ if (BIDI_ARABIC_INDIC_DIGIT_REGEX.test(char)) {
83
+ return 'AN';
84
+ }
85
+ if (BIDI_EXTENDED_ARABIC_INDIC_DIGIT_REGEX.test(char)) {
86
+ return 'EN';
87
+ }
88
+ if (BIDI_HEBREW_SCRIPT_REGEX.test(char)) {
89
+ return 'R';
90
+ }
91
+ if (BIDI_AL_SCRIPT_REGEX.test(char)) {
92
+ return 'AL';
93
+ }
94
+ if (BIDI_ASCII_DIGIT_REGEX.test(char)) {
95
+ return 'EN';
96
+ }
97
+ if (BIDI_LETTER_REGEX.test(char)) {
98
+ return 'L';
99
+ }
100
+ return 'ON';
101
+ };
102
+
103
+ // A domain is "Bidi" (and thus subject to the Bidi rule) if any label contains an
104
+ // R, AL, or AN character. Iterated by code point so surrogate pairs stay intact.
105
+ const isLabelBidiTriggering = (label: string): boolean => {
106
+ for (const char of label) {
107
+ const bidiClass = classifyBidi(char);
108
+ if (bidiClass === 'R' || bidiClass === 'AL' || bidiClass === 'AN') {
109
+ return true;
110
+ }
111
+ }
112
+ return false;
113
+ };
114
+
115
+ const isLabelBidiRuleValid = (label: string): boolean => {
116
+ const classes: BidiClass[] = [];
117
+ for (const char of label) {
118
+ classes.push(classifyBidi(char));
119
+ }
120
+ if (classes.length === 0) {
121
+ return false;
122
+ }
123
+
124
+ const first = classes[0];
125
+ // Rule 1: the first character must be L, R, or AL.
126
+ if (first !== 'L' && first !== 'R' && first !== 'AL') {
127
+ return false;
128
+ }
129
+
130
+ // The "last" character check ignores trailing NSM marks.
131
+ let lastIdx = classes.length - 1;
132
+ while (lastIdx > 0 && classes[lastIdx] === 'NSM') {
133
+ lastIdx--;
134
+ }
135
+ const last = classes[lastIdx];
136
+
137
+ if (first === 'R' || first === 'AL') {
138
+ // Rule 2: RTL label.
139
+ let hasEN = false;
140
+ let hasAN = false;
141
+ for (let i = 0; i < classes.length; i++) {
142
+ const c = classes[i];
143
+ if (c !== 'R' && c !== 'AL' && c !== 'AN' && c !== 'EN' && c !== 'NSM' && c !== 'ON') {
144
+ return false;
145
+ }
146
+ if (c === 'EN') {
147
+ hasEN = true;
148
+ } else if (c === 'AN') {
149
+ hasAN = true;
150
+ }
151
+ }
152
+ if (hasEN && hasAN) {
153
+ return false;
154
+ }
155
+ return last === 'R' || last === 'AL' || last === 'EN' || last === 'AN';
156
+ }
157
+
158
+ // Rule 3: LTR label.
159
+ for (let i = 0; i < classes.length; i++) {
160
+ const c = classes[i];
161
+ if (c !== 'L' && c !== 'EN' && c !== 'NSM' && c !== 'ON') {
162
+ return false;
163
+ }
164
+ }
165
+ return last === 'L' || last === 'EN';
166
+ };
167
+
168
+ const isDomainBidiValid = (unicodeLabels: string[]): boolean => {
169
+ let isBidiDomain = false;
170
+ for (let i = 0; i < unicodeLabels.length; i++) {
171
+ if (isLabelBidiTriggering(unicodeLabels[i])) {
172
+ isBidiDomain = true;
173
+ break;
174
+ }
175
+ }
176
+ if (!isBidiDomain) {
177
+ return true;
178
+ }
179
+ for (let i = 0; i < unicodeLabels.length; i++) {
180
+ if (!isLabelBidiRuleValid(unicodeLabels[i])) {
181
+ return false;
182
+ }
183
+ }
184
+ return true;
185
+ };
186
+
7
187
  const splitHostnameLabels = (hostname: string): string[] | null => {
8
188
  if (hostname.length === 0 || hostname.length > 255) {
9
189
  return null;
@@ -31,7 +211,7 @@ const hasCjkKanaOrHan = (input: string): boolean =>
31
211
  /[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}]/u.test(input);
32
212
 
33
213
  const toUnicodeLabel = (label: string): string | null => {
34
- if (!/^xn--/i.test(label)) {
214
+ if (!XN_LABEL_REGEX.test(label)) {
35
215
  return label;
36
216
  }
37
217
  try {
@@ -46,7 +226,7 @@ const isValidIdnUnicodeLabel = (label: string): boolean => {
46
226
  return false;
47
227
  }
48
228
 
49
- if (label.length >= 4 && label[2] === '-' && label[3] === '-' && !/^xn--/i.test(label)) {
229
+ if (label.length >= 4 && label[2] === '-' && label[3] === '-' && !XN_LABEL_REGEX.test(label)) {
50
230
  return false;
51
231
  }
52
232
 
@@ -79,6 +259,21 @@ const isValidIdnUnicodeLabel = (label: string): boolean => {
79
259
  if (char === '\u200D' && (idx === 0 || label[idx - 1] !== '\u094D')) {
80
260
  return false;
81
261
  }
262
+
263
+ // ZERO WIDTH NON-JOINER (CONTEXTJ, RFC 5892 Appendix A.1): valid only when
264
+ // preceded by a Virama, or when it sits in a joining context.
265
+ if (char === '\u200C' && (idx === 0 || (label[idx - 1] !== '\u094D' && !hasZwnjJoiningContext(label, idx)))) {
266
+ return false;
267
+ }
268
+ }
269
+
270
+ // Reject IDNA2008-DISALLOWED code points (Punctuation/Symbol/Separator/Other,
271
+ // minus the RFC 5892 section 2.6 / contextual exceptions). Iterated by code point so a
272
+ // surrogate pair is not mistaken for a lone \p{Cs} half.
273
+ for (const char of label) {
274
+ if (isDisallowedCodePoint(char)) {
275
+ return false;
276
+ }
82
277
  }
83
278
 
84
279
  if (label.includes('\u30FB') && !hasCjkKanaOrHan(label.replaceAll('・', ''))) {
@@ -147,7 +342,7 @@ export const isValidHostname = (hostname: string): boolean => {
147
342
  }
148
343
 
149
344
  // Punycode (A-label) hostnames encode IDN labels, so validate the decoded Unicode form
150
- if (/^xn--/i.test(label)) {
345
+ if (XN_LABEL_REGEX.test(label)) {
151
346
  const unicodeLabel = toUnicodeLabel(label);
152
347
  if (unicodeLabel === null || !isValidIdnUnicodeLabel(unicodeLabel)) {
153
348
  return false;
@@ -165,13 +360,44 @@ export const isValidIdnHostname = (hostname: string): boolean => {
165
360
  return false;
166
361
  }
167
362
 
363
+ const unicodeLabels: string[] = [];
364
+ let totalLength = labels.length - 1; // separator dots between labels
365
+
168
366
  for (let i = 0; i < labels.length; i++) {
169
367
  const label = labels[i];
170
368
  const unicodeLabel = toUnicodeLabel(label);
171
369
  if (unicodeLabel === null || !isValidIdnUnicodeLabel(unicodeLabel)) {
172
370
  return false;
173
371
  }
372
+
373
+ let aLabel: string;
374
+ try {
375
+ aLabel = punycode.toASCII(unicodeLabel);
376
+ } catch {
377
+ return false;
378
+ }
379
+
380
+ // An A-label (xn-- input) must be canonical Punycode: re-encoding its decoded
381
+ // U-label must reproduce the original A-label. This also rejects an A-label
382
+ // that decodes to pure ASCII, since such a label never re-encodes to an "xn--"
383
+ // form (RFC 5890 §2.3.2.1, RFC 5891 §5.4).
384
+ if (XN_LABEL_REGEX.test(label) && aLabel.toLowerCase() !== label.toLowerCase()) {
385
+ return false;
386
+ }
387
+
388
+ // RFC 5890 §2.3.2.1 — the A-label form of any label must not exceed 63 octets.
389
+ if (aLabel.length > 63) {
390
+ return false;
391
+ }
392
+
393
+ totalLength += aLabel.length;
394
+ unicodeLabels.push(unicodeLabel);
174
395
  }
175
396
 
176
- return true;
397
+ // RFC 1035 §3.1 — the domain name (in A-label form) must not exceed 253 octets.
398
+ if (totalLength > 253) {
399
+ return false;
400
+ }
401
+
402
+ return isDomainBidiValid(unicodeLabels);
177
403
  };
package/src/utils/time.ts CHANGED
@@ -24,7 +24,8 @@ export interface ParsedRfc3339Time {
24
24
  utcMinute: number;
25
25
  }
26
26
 
27
- const RFC3339_TIME_REGEX = /^([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?(z|([+-][0-9]{2}:[0-9]{2}))$/i;
27
+ const RFC3339_TIME_REGEX =
28
+ /^(?<hour>[0-9]{2}):(?<minute>[0-9]{2}):(?<second>[0-9]{2})(?<fraction>\.[0-9]+)?(?<offset>z|(?<offsetValue>[+-][0-9]{2}:[0-9]{2}))$/i;
28
29
 
29
30
  export const parseRfc3339Time = (time: string): ParsedRfc3339Time | null => {
30
31
  const matches = RFC3339_TIME_REGEX.exec(time);
@@ -42,7 +43,7 @@ export const parseRfc3339Time = (time: string): ParsedRfc3339Time | null => {
42
43
  let utcHour = hour;
43
44
  let utcMinute = minute;
44
45
  if (matches[5].toLowerCase() !== 'z') {
45
- const offsetMatches = /^([+-])([0-9]{2}):([0-9]{2})$/.exec(matches[5]);
46
+ const offsetMatches = /^(?<sign>[+-])(?<hour>[0-9]{2}):(?<minute>[0-9]{2})$/.exec(matches[5]);
46
47
  if (offsetMatches === null) {
47
48
  return null;
48
49
  }
@@ -107,12 +107,16 @@ export function formatValidator(ctx: ZSchemaBase, report: Report, schema: JsonSc
107
107
  const promiseResult = result;
108
108
  report.addAsyncTaskWithPath(
109
109
  async (callback: (result: unknown) => void) => {
110
+ let resolved: unknown = false;
110
111
  try {
111
- const resolved = await promiseResult;
112
- callback(resolved);
112
+ resolved = await promiseResult;
113
113
  } catch {
114
- callback(false);
114
+ // A rejected validator promise is treated as an invalid result (false).
115
115
  }
116
+ // Terminal callback of the async task; `return callback(...)` would trip
117
+ // no-confusing-void-expression since the callback returns void.
118
+ // oxlint-disable-next-line node/callback-return
119
+ callback(resolved);
116
120
  },
117
121
  [],
118
122
  (resolvedResult) => {