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.
- package/bin/z-schema +0 -0
- package/cjs/index.cjs +151 -29
- package/dist/format-validators.js +20 -10
- package/dist/json-schema.js +9 -3
- package/dist/json-validation.js +3 -2
- package/dist/report.d.ts +1 -1
- package/dist/schema-cache.d.ts +1 -1
- package/dist/schema-compiler.js +8 -4
- package/dist/utils/hostname.js +109 -5
- package/dist/utils/json.js +2 -1
- package/dist/utils/time.js +3 -3
- package/dist/validation/ref.js +1 -1
- package/dist/validation/shared.js +4 -2
- package/dist/validation/string.js +4 -4
- package/dist/z-schema-options.js +1 -1
- package/dist/z-schema.js +1 -1
- package/package.json +5 -5
- package/src/format-validators.ts +36 -10
- package/src/json-validation.ts +7 -7
- package/src/utils/base64.ts +1 -1
- package/src/utils/hostname.ts +230 -4
- package/src/utils/time.ts +3 -2
- package/src/validation/string.ts +7 -3
- package/umd/ZSchema.js +151 -29
- package/umd/ZSchema.min.js +2 -2
package/src/utils/hostname.ts
CHANGED
|
@@ -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 (
|
|
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] === '-' &&
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
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
|
}
|
package/src/validation/string.ts
CHANGED
|
@@ -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
|
-
|
|
112
|
-
callback(resolved);
|
|
112
|
+
resolved = await promiseResult;
|
|
113
113
|
} catch {
|
|
114
|
-
|
|
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) => {
|