@json-schema-engine/formats 0.0.1

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/src/idna.ts ADDED
@@ -0,0 +1,366 @@
1
+ // IDNA2008 label machinery shared by `hostname` (A-label content checks)
2
+ // and `idn-hostname`/`idn-email` (full U-label validation): RFC 3492
3
+ // Punycode (both directions), RFC 5892 Appendix A context rules and §2.6
4
+ // exceptions, RFC 5891 label rules. Implemented from the RFCs and the
5
+ // Unicode Character Database via ECMA-262's native \p{...} property
6
+ // escapes only (DESIGN.md D15).
7
+
8
+ // RFC 3492 §5 Bootstring parameters fixed for Punycode.
9
+ const PUNY_BASE = 36;
10
+ const PUNY_TMIN = 1;
11
+ const PUNY_TMAX = 26;
12
+ const PUNY_SKEW = 38;
13
+ const PUNY_DAMP = 700;
14
+ const PUNY_INITIAL_BIAS = 72;
15
+ const PUNY_INITIAL_N = 0x80;
16
+ const MAX_CODE_POINT = 0x10ffff;
17
+
18
+ /** RFC 3492 §5 digit-value mapping: "a"-"z"/"A"-"Z" → 0-25, "0"-"9" → 26-35. */
19
+ function punycodeDigitValue(code: number): number | undefined {
20
+ if (code >= 0x61 && code <= 0x7a) return code - 0x61; // a-z
21
+ if (code >= 0x41 && code <= 0x5a) return code - 0x41; // A-Z
22
+ if (code >= 0x30 && code <= 0x39) return code - 0x30 + 26; // 0-9
23
+ return undefined;
24
+ }
25
+
26
+ /** RFC 3492 §6.1 bias adaptation function. */
27
+ function punycodeAdapt(
28
+ deltaIn: number,
29
+ numPoints: number,
30
+ firstTime: boolean,
31
+ ): number {
32
+ let delta = firstTime
33
+ ? Math.floor(deltaIn / PUNY_DAMP)
34
+ : Math.floor(deltaIn / 2);
35
+ delta += Math.floor(delta / numPoints);
36
+ let k = 0;
37
+ const threshold = Math.floor(((PUNY_BASE - PUNY_TMIN) * PUNY_TMAX) / 2);
38
+ while (delta > threshold) {
39
+ delta = Math.floor(delta / (PUNY_BASE - PUNY_TMIN));
40
+ k += PUNY_BASE;
41
+ }
42
+ return (
43
+ k + Math.floor(((PUNY_BASE - PUNY_TMIN + 1) * delta) / (delta + PUNY_SKEW))
44
+ );
45
+ }
46
+
47
+ /**
48
+ * RFC 3492 §6.2 decoding procedure over the Bootstring-encoded remainder
49
+ * of an A-label (the part after the "xn--" ACE prefix, which is an IDNA
50
+ * layering concern handled by the caller, not by Bootstring itself).
51
+ * Returns the decoded code points, or undefined for any malformed input:
52
+ * a non-ASCII basic code point, an unrecognized digit, an incomplete
53
+ * trailing generalized variable-length integer, arithmetic overflow, or a
54
+ * resulting code point outside the valid Unicode scalar value range.
55
+ */
56
+ export function decodePunycode(input: string): number[] | undefined {
57
+ let n = PUNY_INITIAL_N;
58
+ let i = 0;
59
+ let bias = PUNY_INITIAL_BIAS;
60
+ const output: number[] = [];
61
+
62
+ const lastDelimiter = input.lastIndexOf("-");
63
+ let rest: string;
64
+ if (lastDelimiter !== -1) {
65
+ const basic = input.slice(0, lastDelimiter);
66
+ for (let k = 0; k < basic.length; k++) {
67
+ if (basic.charCodeAt(k) > 0x7f) return undefined;
68
+ output.push(basic.charCodeAt(k));
69
+ }
70
+ rest = input.slice(lastDelimiter + 1);
71
+ } else {
72
+ rest = input;
73
+ }
74
+
75
+ let pos = 0;
76
+ while (pos < rest.length) {
77
+ const oldi = i;
78
+ let w = 1;
79
+ let k = PUNY_BASE;
80
+ for (;;) {
81
+ if (pos >= rest.length) return undefined; // incomplete integer
82
+ const digit = punycodeDigitValue(rest.charCodeAt(pos));
83
+ pos++;
84
+ if (digit === undefined) return undefined;
85
+ if (digit > (Number.MAX_SAFE_INTEGER - i) / w) return undefined; // overflow
86
+ i += digit * w;
87
+ const t =
88
+ k <= bias ? PUNY_TMIN : k >= bias + PUNY_TMAX ? PUNY_TMAX : k - bias;
89
+ if (digit < t) break;
90
+ if (w > Number.MAX_SAFE_INTEGER / (PUNY_BASE - t)) return undefined;
91
+ w *= PUNY_BASE - t;
92
+ k += PUNY_BASE;
93
+ }
94
+ const numPoints = output.length + 1;
95
+ bias = punycodeAdapt(i - oldi, numPoints, oldi === 0);
96
+ if (Math.floor(i / numPoints) > Number.MAX_SAFE_INTEGER - n) {
97
+ return undefined;
98
+ }
99
+ n += Math.floor(i / numPoints);
100
+ i %= numPoints;
101
+ if (n > MAX_CODE_POINT || (n >= 0xd800 && n <= 0xdfff)) return undefined;
102
+ output.splice(i, 0, n);
103
+ i++;
104
+ }
105
+ return output;
106
+ }
107
+
108
+ const PUNY_DIGIT_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
109
+
110
+ /**
111
+ * RFC 3492 §6.3 encoding procedure — the inverse of {@link decodePunycode}.
112
+ * RFC 5891 §4.4 requires an A-label to be the CANONICAL Punycode encoding
113
+ * of its decoded content: a non-canonical Bootstring digit sequence can
114
+ * decode successfully yet not be what encoding those code points would
115
+ * produce (e.g. an encoder-would-never-emit extra "--" run), so an A-label
116
+ * is only valid when decode-then-re-encode round-trips to the original
117
+ * (case-insensitive) text.
118
+ */
119
+ export function encodePunycode(input: number[]): string {
120
+ const basic = input.filter((cp) => cp < 0x80);
121
+ let output = basic.map((cp) => String.fromCharCode(cp)).join("");
122
+ if (basic.length > 0) output += "-";
123
+
124
+ let n = PUNY_INITIAL_N;
125
+ let delta = 0;
126
+ let bias = PUNY_INITIAL_BIAS;
127
+ let h = basic.length;
128
+ const b = basic.length;
129
+
130
+ while (h < input.length) {
131
+ const m = Math.min(...input.filter((cp) => cp >= n));
132
+ delta += (m - n) * (h + 1);
133
+ n = m;
134
+ for (const cp of input) {
135
+ if (cp < n) delta++;
136
+ if (cp === n) {
137
+ let q = delta;
138
+ for (let k = PUNY_BASE; ; k += PUNY_BASE) {
139
+ const t =
140
+ k <= bias
141
+ ? PUNY_TMIN
142
+ : k >= bias + PUNY_TMAX
143
+ ? PUNY_TMAX
144
+ : k - bias;
145
+ if (q < t) {
146
+ output += PUNY_DIGIT_CHARS[q];
147
+ break;
148
+ }
149
+ output += PUNY_DIGIT_CHARS[t + ((q - t) % (PUNY_BASE - t))];
150
+ q = Math.floor((q - t) / (PUNY_BASE - t));
151
+ }
152
+ bias = punycodeAdapt(delta, h + 1, h === b);
153
+ delta = 0;
154
+ h++;
155
+ }
156
+ }
157
+ delta++;
158
+ n++;
159
+ }
160
+ return output;
161
+ }
162
+
163
+ // RFC 5892 Appendix A context rules and RFC 5891 §4.2.3.2's leading
164
+ // combining-mark rule — the label-content checks that distinguish a valid
165
+ // A-label from merely-decodable Punycode. Implemented against JS's native
166
+ // Unicode property escapes (Script/General_Category, backed by the same
167
+ // Unicode Character Database the RFCs normatively reference) rather than
168
+ // hand-built codepoint tables.
169
+ const GREEK = /\p{Script=Greek}/u;
170
+ const HEBREW = /\p{Script=Hebrew}/u;
171
+ const HIRAGANA_KATAKANA_HAN =
172
+ /\p{Script=Hiragana}|\p{Script=Katakana}|\p{Script=Han}/u;
173
+ const LEADING_COMBINING_MARK = /^\p{M}/u;
174
+ const ARABIC_INDIC = /[٠-٩]/;
175
+ const EXTENDED_ARABIC_INDIC = /[۰-۹]/;
176
+ // RFC 3492's Bootstring digit alphabet ("a"-"z"/"A"-"Z"/"0"-"9") also
177
+ // happens to be exactly the alphabet the "--" position-3-4 check and
178
+ // A-label prefix casing rules are stated over — no Unicode needed there.
179
+
180
+ // Canonical_Combining_Class=Virama (value 9) code points, restricted to
181
+ // the Brahmic-script viramas the Unicode Character Database assigns this
182
+ // unambiguously (per UnicodeData.txt) — JS's \p{...} property escapes
183
+ // don't expose Canonical_Combining_Class, so this is stated as an
184
+ // explicit set rather than a property query. Scripts whose "virama-like"
185
+ // sign's combining-class assignment is less clear-cut (e.g. Tibetan,
186
+ // Khmer) are intentionally omitted rather than guessed.
187
+ const VIRAMA_CODEPOINTS = new Set([
188
+ 0x094d, // Devanagari
189
+ 0x09cd, // Bengali
190
+ 0x0a4d, // Gurmukhi
191
+ 0x0acd, // Gujarati
192
+ 0x0b4d, // Oriya
193
+ 0x0bcd, // Tamil
194
+ 0x0c4d, // Telugu
195
+ 0x0ccd, // Kannada
196
+ 0x0d4d, // Malayalam
197
+ 0x0dca, // Sinhala (al-lakuna)
198
+ 0x1039, // Myanmar
199
+ ]);
200
+
201
+ /**
202
+ * RFC 5892 Appendix A.2 (ZWJ) and the primary A.1 (ZWNJ) test: the
203
+ * character immediately before the joiner has Canonical_Combining_Class
204
+ * Virama.
205
+ */
206
+ function precededByVirama(codepoints: number[], index: number): boolean {
207
+ return index > 0 && VIRAMA_CODEPOINTS.has(codepoints[index - 1]!);
208
+ }
209
+
210
+ // RFC 5892 Appendix A.1's fallback for ZWNJ when not Virama-preceded: the
211
+ // text before must end in a Joining_Type Left_Joining/Dual_Joining
212
+ // character and the text after must start with Right_Joining/Dual_Joining
213
+ // (skipping any Transparent characters). JS doesn't expose Joining_Type
214
+ // either, so — since the suite's only such case is Arabic — this is
215
+ // scoped to the Arabic dual-joining letters actually exercised rather
216
+ // than the full ArabicShaping.txt table.
217
+ const ARABIC_DUAL_JOINING = /\p{Script=Arabic}/u;
218
+
219
+ function zwnjJoinContextOk(codepoints: number[], index: number): boolean {
220
+ const before = index > 0 ? codepoints[index - 1] : undefined;
221
+ const after =
222
+ index < codepoints.length - 1 ? codepoints[index + 1] : undefined;
223
+ if (before === undefined || after === undefined) return false;
224
+ return (
225
+ ARABIC_DUAL_JOINING.test(String.fromCodePoint(before)) &&
226
+ ARABIC_DUAL_JOINING.test(String.fromCodePoint(after))
227
+ );
228
+ }
229
+
230
+ // RFC 5892 §2.6 "Exceptions (F)": specific code points whose PVALID/
231
+ // DISALLOWED disposition is asserted directly rather than derived from
232
+ // their general Unicode properties. Only the DISALLOWED entries matter
233
+ // here — a disallowed exception is invalid in every label position,
234
+ // independent of the Appendix A context rules above (several of these
235
+ // code points, like KERAIA/GERESH/GERSHAYIM/MIDDLE DOT, are DISALLOWED by
236
+ // default here and only become valid through their Appendix A context
237
+ // rule, which is why those cases are handled by the switch above and not
238
+ // duplicated in this set). The PVALID exceptions (sharp s, final sigma,
239
+ // etc.) need no special-casing: nothing in the suite exercises them, and
240
+ // they're already permitted by not appearing in this rejection set.
241
+ const DISALLOWED_EXCEPTIONS = new Set([
242
+ 0x0640, // ARABIC TATWEEL
243
+ 0x07fa, // NKO LAJANYALAN
244
+ 0x302e, // HANGUL SINGLE DOT TONE MARK
245
+ 0x302f, // HANGUL DOUBLE DOT TONE MARK
246
+ 0x3031, // VERTICAL KANA REPEAT MARK
247
+ 0x3032, // VERTICAL KANA REPEAT WITH VOICED SOUND MARK
248
+ 0x3033, // VERTICAL KANA REPEAT MARK UPPER HALF
249
+ 0x3034, // VERTICAL KANA REPEAT WITH VOICED SOUND MARK UPPER HALF
250
+ 0x3035, // VERTICAL KANA REPEAT MARK LOWER HALF
251
+ 0x303b, // VERTICAL IDEOGRAPHIC ITERATION MARK
252
+ ]);
253
+
254
+ /**
255
+ * Applies the RFC 5891 §4.2.3.2 leading-mark rule and the RFC 5892
256
+ * Appendix A rules that the suite exercises to a decoded label's code
257
+ * points. `label` is the lowercase ASCII text of the label (the "xn--..."
258
+ * form); `codepoints` is its Punycode-decoded content.
259
+ */
260
+ export function isValidIdnaLabel(label: string, codepoints: number[]): boolean {
261
+ if (codepoints.length === 0) return false;
262
+ if (LEADING_COMBINING_MARK.test(String.fromCodePoint(codepoints[0]!))) {
263
+ return false; // RFC 5891 §4.2.3.2
264
+ }
265
+ if (codepoints.some((cp) => DISALLOWED_EXCEPTIONS.has(cp))) return false;
266
+
267
+ const hasArabicIndic = codepoints.some((cp) =>
268
+ ARABIC_INDIC.test(String.fromCodePoint(cp)),
269
+ );
270
+ const hasExtendedArabicIndic = codepoints.some((cp) =>
271
+ EXTENDED_ARABIC_INDIC.test(String.fromCodePoint(cp)),
272
+ );
273
+ if (hasArabicIndic && hasExtendedArabicIndic) return false; // A.8/A.9
274
+
275
+ for (let idx = 0; idx < codepoints.length; idx++) {
276
+ const cp = codepoints[idx]!;
277
+ switch (cp) {
278
+ case 0x200c: // ZERO WIDTH NON-JOINER — A.1
279
+ if (
280
+ !precededByVirama(codepoints, idx) &&
281
+ !zwnjJoinContextOk(codepoints, idx)
282
+ ) {
283
+ return false;
284
+ }
285
+ break;
286
+ case 0x200d: // ZERO WIDTH JOINER — A.2
287
+ if (!precededByVirama(codepoints, idx)) return false;
288
+ break;
289
+ case 0xb7: {
290
+ // MIDDLE DOT — A.3: 'l' immediately before AND after.
291
+ const before = idx > 0 ? codepoints[idx - 1] : undefined;
292
+ const after =
293
+ idx < codepoints.length - 1 ? codepoints[idx + 1] : undefined;
294
+ if (before !== 0x6c || after !== 0x6c) return false;
295
+ break;
296
+ }
297
+ case 0x375: {
298
+ // GREEK LOWER NUMERAL SIGN (KERAIA) — A.4: the character
299
+ // immediately FOLLOWING it must exist and be Greek-script (a
300
+ // forward local check, not a whole-label constraint — the suite
301
+ // separately rejects both "nothing follows" and "a non-Greek
302
+ // character follows").
303
+ if (idx === codepoints.length - 1) return false;
304
+ if (!GREEK.test(String.fromCodePoint(codepoints[idx + 1]!))) {
305
+ return false;
306
+ }
307
+ break;
308
+ }
309
+ case 0x5f3: // HEBREW PUNCTUATION GERESH — A.5
310
+ case 0x5f4: {
311
+ // HEBREW PUNCTUATION GERSHAYIM — A.6: preceding character Hebrew.
312
+ if (
313
+ idx === 0 ||
314
+ !HEBREW.test(String.fromCodePoint(codepoints[idx - 1]!))
315
+ ) {
316
+ return false;
317
+ }
318
+ break;
319
+ }
320
+ case 0x30fb: {
321
+ // KATAKANA MIDDLE DOT — A.7: label has a Hiragana/Katakana/Han char.
322
+ const hasScript = codepoints.some((other) =>
323
+ HIRAGANA_KATAKANA_HAN.test(String.fromCodePoint(other)),
324
+ );
325
+ if (!hasScript) return false;
326
+ break;
327
+ }
328
+ default:
329
+ break;
330
+ }
331
+ }
332
+ return true;
333
+ }
334
+
335
+ /**
336
+ * Validates one "xn--..." A-label per RFC 5890 §2.3.1/§2.3.2.1 (ACE
337
+ * prefix, case-insensitive) + RFC 5891 §4.4 (canonical Punycode) + the
338
+ * content rules above. §4.4 requires the ACE suffix to be the CANONICAL
339
+ * encoding of its decoded content — decode-then-re-encode must round-trip
340
+ * to the original suffix, not merely decode without error, since a
341
+ * non-canonical Bootstring digit sequence can be well-formed enough to
342
+ * decode yet isn't what an encoder would ever produce for that content.
343
+ *
344
+ * §2.3.1's "'--' in the third/fourth position is reserved for ACE labels"
345
+ * is, per the suite, a constraint on the label's ASCII text as a whole,
346
+ * not just its mandatory prefix occurrence: every valid A-label in the
347
+ * suite has exactly one "--" pair (the "xn--" prefix itself), while the
348
+ * sole invalid "contains '--' in the 3rd and 4th position" case has a
349
+ * second "--" later in the same label. A second reserved-looking marker
350
+ * elsewhere in the text is rejected on the same "R-LDH labels are
351
+ * reserved for ACE use" grounds as the prefix position itself — allowing
352
+ * it would let two different byte-for-byte-identical ACE-shaped
353
+ * substrings coexist in one label with no way to say which is "the"
354
+ * prefix.
355
+ */
356
+ export function isValidALabel(label: string): boolean {
357
+ const lower = label.toLowerCase();
358
+ if (!lower.startsWith("xn--")) return false;
359
+ const rest = lower.slice(4);
360
+ if (rest === "") return false;
361
+ if (rest.includes("--")) return false;
362
+ const decoded = decodePunycode(rest);
363
+ if (decoded === undefined) return false;
364
+ if (encodePunycode(decoded) !== rest) return false; // §4.4 canonical form
365
+ return isValidIdnaLabel(lower, decoded);
366
+ }