fast-xml-validator 1.0.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.
@@ -0,0 +1,472 @@
1
+ 'use strict';
2
+
3
+ import { getAllMatches, isName } from './util.js';
4
+ import DocTypeValidator from './DocTypeValidator.js';
5
+ import ValidationError from './ValidationError.js';
6
+
7
+ const defaultOptions = {
8
+ allowBooleanAttributes: false,
9
+ unpairedTags: [],
10
+ // DOCTYPE entity limits — Infinity means no limit by default
11
+ docType: {
12
+ maxEntityCount: Infinity,
13
+ maxEntitySize: Infinity,
14
+ },
15
+ };
16
+
17
+ export function validate(xmlData, options) {
18
+ options = Object.assign({}, defaultOptions, options);
19
+
20
+ const docTypeValidator = new DocTypeValidator(options.docType);
21
+
22
+ const tags = [];
23
+ let tagFound = false;
24
+
25
+ //indicates that the root tag has been closed (aka. depth 0 has been reached)
26
+ let reachedRoot = false;
27
+
28
+ if (xmlData[0] === '\ufeff') {
29
+ // check for byte order mark (BOM)
30
+ xmlData = xmlData.substr(1);
31
+ }
32
+
33
+ if (xmlData.startsWith('<?xml')) {
34
+ const piEnd = xmlData.indexOf('?>');
35
+ if (piEnd === -1) {
36
+ return throwError('InvalidXml', 'Processing instruction is not closed with "?>".',
37
+ { line: 1, col: 1 });
38
+ }
39
+ xmlData = xmlData.substring(piEnd + 2);
40
+ }
41
+
42
+ for (let i = 0; i < xmlData.length; i++) {
43
+ if (xmlData[i] === '<' && xmlData[i + 1] === '?') {
44
+ i += 2;
45
+ i = readPI(xmlData, i);
46
+ if (i.err) return i;
47
+ } else if (xmlData[i] === '<') {
48
+ //starting of tag
49
+ //read until you reach to '>' avoiding any '>' in attribute value
50
+ let tagStartPos = i;
51
+ i++;
52
+
53
+ if (xmlData[i] === '!') {
54
+ try {
55
+ i = readCommentAndCDATA(xmlData, i, docTypeValidator);
56
+ } catch (err) {
57
+ return throwError("InvalidTag", err.message, getLineNumberForPosition(xmlData, i - 1));
58
+ }
59
+ continue;
60
+ } else {
61
+ let closingTag = false;
62
+ if (xmlData[i] === '/') {
63
+ closingTag = true;
64
+ i++;
65
+ }
66
+
67
+ let tagName = '';
68
+ for (;
69
+ i < xmlData.length &&
70
+ xmlData[i] !== '>' &&
71
+ xmlData[i] !== ' ' &&
72
+ xmlData[i] !== '\t' &&
73
+ xmlData[i] !== '\n' &&
74
+ xmlData[i] !== '\r';
75
+ i++
76
+ ) {
77
+ tagName += xmlData[i];
78
+ }
79
+ tagName = tagName.trim();
80
+
81
+ if (tagName[tagName.length - 1] === '/') {
82
+ //self closing tag without attributes
83
+ tagName = tagName.substring(0, tagName.length - 1);
84
+ i--;
85
+ }
86
+
87
+ // use validateQName so namespaced tags (e.g. <ns:tag>) are
88
+ // accepted and malformed ones (e.g. <:tag>, <ns:>, <a:b:c>) are rejected.
89
+ if (!validateQName(tagName)) {
90
+ let msg;
91
+ if (tagName.trim().length === 0) {
92
+ msg = "Invalid space after '<'.";
93
+ } else {
94
+ msg = "Tag '" + tagName + "' is an invalid name.";
95
+ }
96
+ return throwError('InvalidTag', msg, getLineNumberForPosition(xmlData, tagStartPos));
97
+ }
98
+
99
+ const result = readAttributeStr(xmlData, i);
100
+ if (result === false) {
101
+ return throwError('InvalidAttr', "Attributes for '" + tagName + "' have open quote.", getLineNumberForPosition(xmlData, i));
102
+ }
103
+ let attrStr = result.value;
104
+ i = result.index;
105
+
106
+ if (attrStr[attrStr.length - 1] === '/') {
107
+ //self closing tag
108
+ const attrStrStart = i - attrStr.length;
109
+ attrStr = attrStr.substring(0, attrStr.length - 1);
110
+ const isValid = validateAttributeString(attrStr, options);
111
+ if (isValid === true) {
112
+ if (reachedRoot === true) {
113
+ return throwError('InvalidXml', 'Multiple possible root nodes found.', getLineNumberForPosition(xmlData, tagStartPos));
114
+ }
115
+ tagFound = true;
116
+ //if no open tags remain on the stack, this self-closing tag is the root
117
+ if (tags.length === 0) {
118
+ reachedRoot = true;
119
+ }
120
+ } else {
121
+ //the result from the nested function returns the position of the error within the attribute
122
+ //in order to get the 'true' error line, we need to calculate the position where the attribute begins (i - attrStr.length) and then add the position within the attribute
123
+ //this gives us the absolute index in the entire xml, which we can use to find the line at last
124
+ return throwError(isValid.err.code, isValid.err.msg, getLineNumberForPosition(xmlData, attrStrStart + isValid.err.line));
125
+ }
126
+ } else if (closingTag) {
127
+ if (!result.tagClosed) {
128
+ return throwError('InvalidTag', "Closing tag '" + tagName + "' doesn't have proper closing.", getLineNumberForPosition(xmlData, i));
129
+ } else if (attrStr.trim().length > 0) {
130
+ return throwError('InvalidTag', "Closing tag '" + tagName + "' can't have attributes or invalid starting.", getLineNumberForPosition(xmlData, tagStartPos));
131
+ } else if (tags.length === 0) {
132
+ return throwError('InvalidTag', "Closing tag '" + tagName + "' has not been opened.", getLineNumberForPosition(xmlData, tagStartPos));
133
+ } else {
134
+ const otg = tags.pop();
135
+ if (tagName !== otg.tagName) {
136
+ let openPos = getLineNumberForPosition(xmlData, otg.tagStartPos);
137
+ return throwError(
138
+ 'InvalidTag',
139
+ "Expected closing tag '" + otg.tagName + "' (opened in line " + openPos.line + ", col " + openPos.col + ") instead of closing tag '" + tagName + "'.",
140
+ getLineNumberForPosition(xmlData, tagStartPos)
141
+ );
142
+ }
143
+
144
+ //when there are no more tags, we reached the root level.
145
+ if (tags.length === 0) {
146
+ reachedRoot = true;
147
+ }
148
+ }
149
+ } else {
150
+ const isValid = validateAttributeString(attrStr, options);
151
+ if (isValid !== true) {
152
+ return throwError(isValid.err.code, isValid.err.msg, getLineNumberForPosition(xmlData, i - attrStr.length + isValid.err.line));
153
+ }
154
+
155
+ if (reachedRoot === true) {
156
+ return throwError('InvalidXml', 'Multiple possible root nodes found.', getLineNumberForPosition(xmlData, i));
157
+ } else if (options.unpairedTags.indexOf(tagName) !== -1) {
158
+ // don't push into stack
159
+ } else {
160
+ tags.push({ tagName, tagStartPos });
161
+ }
162
+ tagFound = true;
163
+ }
164
+
165
+ //skip tag text value
166
+ //It may include comments and CDATA value
167
+ for (i++; i < xmlData.length; i++) {
168
+ if (xmlData[i] === '<') {
169
+ if (xmlData[i + 1] === '!') {
170
+ //comment or CADATA
171
+ i++;
172
+ try {
173
+ i = readCommentAndCDATA(xmlData, i, docTypeValidator);
174
+ } catch (err) {
175
+ return throwError("InvalidDocType", err.message, getLineNumberForPosition(xmlData, i - 1));
176
+ }
177
+ continue;
178
+ } else if (xmlData[i + 1] === '?') {
179
+ i += 2;
180
+ i = readPI(xmlData, i);
181
+ if (i.err) return i;
182
+ } else {
183
+ break;
184
+ }
185
+ } else if (xmlData[i] === '&') {
186
+ const afterAmp = validateAmpersand(xmlData, i);
187
+ if (afterAmp === -1)
188
+ return throwError('InvalidChar', "char '&' is not expected.", getLineNumberForPosition(xmlData, i));
189
+ i = afterAmp;
190
+ } else {
191
+ if (reachedRoot === true && !isWhiteSpace(xmlData[i])) {
192
+ return throwError('InvalidXml', 'Extra text at the end', getLineNumberForPosition(xmlData, i));
193
+ }
194
+ }
195
+ } //end of reading tag text value
196
+ if (xmlData[i] === '<') {
197
+ i--;
198
+ }
199
+ }
200
+ } else {
201
+ if (isWhiteSpace(xmlData[i])) {
202
+ continue;
203
+ }
204
+ if (reachedRoot) {
205
+ return throwError('InvalidXml', "Extra text at the end: '" + xmlData[i] + "' is not expected.", getLineNumberForPosition(xmlData, i));
206
+ }
207
+ return throwError('InvalidChar', "char '" + xmlData[i] + "' is not expected.", getLineNumberForPosition(xmlData, i));
208
+ }
209
+ }
210
+
211
+ if (!tagFound) {
212
+ return throwError('InvalidXml', 'Start tag expected.', { line: 1, col: 1 });
213
+ } else if (tags.length === 1) {
214
+ return throwError('InvalidTag', "Unclosed tag '" + tags[0].tagName + "'.", getLineNumberForPosition(xmlData, tags[0].tagStartPos));
215
+ } else if (tags.length > 0) {
216
+ return throwError('InvalidXml', "Invalid '" +
217
+ JSON.stringify(tags.map(t => t.tagName)).replace(/\r?\n/g, '') +
218
+ "' found.", { line: 1, col: 1 });
219
+ }
220
+
221
+ return true;
222
+ }
223
+
224
+ function isWhiteSpace(char) {
225
+ return char === ' ' || char === '\t' || char === '\n' || char === '\r';
226
+ }
227
+
228
+ function readPI(xmlData, i) {
229
+ const piStart = i; // points just after '<?'
230
+ const nameStart = i;
231
+
232
+ for (; i < xmlData.length; i++) {
233
+ const ch = xmlData[i];
234
+ if (ch === ' ' || ch === '?') { //end of name
235
+ const piName = xmlData.substr(nameStart, i - nameStart);
236
+
237
+ if (piName.toLowerCase() === 'xml') {
238
+ return throwError(
239
+ 'InvalidXml',
240
+ 'XML declaration allowed only at the start of the document.',
241
+ getLineNumberForPosition(xmlData, piStart - 2) // point at '<'
242
+ );
243
+ } else if (ch === '?' && xmlData[i + 1] === '>') {
244
+ i++;
245
+ return i;
246
+ }
247
+ }
248
+ continue;
249
+ }
250
+
251
+ // reached EOF without closing '?>'
252
+ return throwError(
253
+ 'InvalidXml',
254
+ 'Processing instruction is not closed with "?>".',
255
+ getLineNumberForPosition(xmlData, piStart - 2) // point at '<'
256
+ );
257
+ }
258
+
259
+ /**
260
+ * Handles comments, CDATA sections, and DOCTYPE declarations.
261
+ * DOCTYPE validation is delegated to DocTypeValidator.
262
+ * @param {string} xmlData
263
+ * @param {number} i — points at the '!' character
264
+ * @param {DocTypeValidator} docTypeValidator
265
+ */
266
+ function readCommentAndCDATA(xmlData, i, docTypeValidator) {
267
+ if (xmlData.length > i + 2 && xmlData[i + 1] === '-' && xmlData[i + 2] === '-') {
268
+ // Comment: <!-- ... -->
269
+ for (i += 3; i < xmlData.length; i++) {
270
+ if (xmlData[i] === '-' && xmlData[i + 1] === '-' && xmlData[i + 2] === '>') {
271
+ i += 2;
272
+ break;
273
+ }
274
+ }
275
+ if (xmlData[i] !== '>') {
276
+ throw new Error('Comment is not closed with "-->".');
277
+ }
278
+ } else if (
279
+ xmlData.length > i + 7 &&
280
+ xmlData[i + 1] === 'D' &&
281
+ xmlData[i + 2] === 'O' &&
282
+ xmlData[i + 3] === 'C' &&
283
+ xmlData[i + 4] === 'T' &&
284
+ xmlData[i + 5] === 'Y' &&
285
+ xmlData[i + 6] === 'P' &&
286
+ xmlData[i + 7] === 'E'
287
+ ) {
288
+ // DOCTYPE: delegate fully to DocTypeValidator
289
+ // i currently points at '!', so the '<' is at i - 1
290
+ i = docTypeValidator.validateDocType(xmlData, i - 1);
291
+ } else if (
292
+ xmlData.length > i + 8 &&
293
+ xmlData[i + 1] === '[' &&
294
+ xmlData[i + 2] === 'C' &&
295
+ xmlData[i + 3] === 'D' &&
296
+ xmlData[i + 4] === 'A' &&
297
+ xmlData[i + 5] === 'T' &&
298
+ xmlData[i + 6] === 'A' &&
299
+ xmlData[i + 7] === '['
300
+ ) {
301
+ // CDATA: <![CDATA[ ... ]]>
302
+ for (i += 8; i < xmlData.length; i++) {
303
+ if (xmlData[i] === ']' && xmlData[i + 1] === ']' && xmlData[i + 2] === '>') {
304
+ i += 2;
305
+ break;
306
+ }
307
+ }
308
+ if (xmlData[i] !== '>') {
309
+ throw new Error('CDATA section is not closed with "]]>".');
310
+ }
311
+ } else {
312
+ //Reject unrecognised <! constructs (e.g. <!ELEMENT, <!ATTLIST
313
+ // appearing outside a DOCTYPE, or simply malformed "<!xyz").
314
+ throw new Error("Invalid construct starting with '<!'.");
315
+ }
316
+
317
+ return i;
318
+ }
319
+
320
+ const doubleQuote = '"';
321
+ const singleQuote = "'";
322
+
323
+ /**
324
+ * Keep reading xmlData until '<' is found outside the attribute value.
325
+ * @param {string} xmlData
326
+ * @param {number} i
327
+ */
328
+ function readAttributeStr(xmlData, i) {
329
+ let attrStr = '';
330
+ let startChar = '';
331
+ let tagClosed = false;
332
+ for (; i < xmlData.length; i++) {
333
+ if (xmlData[i] === doubleQuote || xmlData[i] === singleQuote) {
334
+ if (startChar === '') {
335
+ startChar = xmlData[i];
336
+ } else if (startChar !== xmlData[i]) {
337
+ // opposite quote inside value — allowed
338
+ } else {
339
+ startChar = '';
340
+ }
341
+ } else if (xmlData[i] === '>') {
342
+ if (startChar === '') {
343
+ tagClosed = true;
344
+ break;
345
+ }
346
+ }
347
+ attrStr += xmlData[i];
348
+ }
349
+ if (startChar !== '') {
350
+ return false;
351
+ }
352
+ return { value: attrStr, index: i, tagClosed };
353
+ }
354
+
355
+ const validAttrStrRegxp = new RegExp('(\\s*)([^\\s=]+)(\\s*=)?(\\s*([\'"])(([\\s\\S])*?)\\5)?', 'g');
356
+
357
+ function validateAttributeString(attrStr, options) {
358
+ const matches = getAllMatches(attrStr, validAttrStrRegxp);
359
+ const attrNames = {};
360
+
361
+ for (let i = 0; i < matches.length; i++) {
362
+ if (matches[i][1].length === 0) {
363
+ return { err: { code: 'InvalidAttr', msg: "Attribute '" + matches[i][2] + "' has no space in starting.", line: getPositionFromMatch(matches[i]) } };
364
+ } else if (matches[i][3] !== undefined && matches[i][4] === undefined) {
365
+ return { err: { code: 'InvalidAttr', msg: "Attribute '" + matches[i][2] + "' is without value.", line: getPositionFromMatch(matches[i]) } };
366
+ } else if (matches[i][3] === undefined && !options.allowBooleanAttributes) {
367
+ return { err: { code: 'InvalidAttr', msg: "boolean attribute '" + matches[i][2] + "' is not allowed.", line: getPositionFromMatch(matches[i]) } };
368
+ }
369
+
370
+ const attrName = matches[i][2];
371
+
372
+ //validate attribute names as QNames so namespaced attributes
373
+ // (e.g. xml:lang, xmlns:xsi) are accepted and malformed ones are rejected.
374
+ if (!validateQName(attrName)) {
375
+ return { err: { code: 'InvalidAttr', msg: "Attribute '" + attrName + "' is an invalid name.", line: getPositionFromMatch(matches[i]) } };
376
+ }
377
+
378
+ // xmlns="" (undeclaring the default namespace) is valid in XML 1.1
379
+ // only. Emit a clear error rather than silently accepting it.
380
+ if (attrName === 'xmlns' && matches[i][6] === '') {
381
+ return { err: { code: 'InvalidAttr', msg: 'Undeclaring the default namespace with xmlns="" is only permitted in XML 1.1 documents.', line: getPositionFromMatch(matches[i]) } };
382
+ }
383
+
384
+ if (!Object.prototype.hasOwnProperty.call(attrNames, attrName)) {
385
+ attrNames[attrName] = 1;
386
+ } else {
387
+ return { err: { code: 'InvalidAttr', msg: "Attribute '" + attrName + "' is repeated.", line: getPositionFromMatch(matches[i]) } };
388
+ }
389
+ }
390
+
391
+ return true;
392
+ }
393
+
394
+ function validateNumberAmpersand(xmlData, i) {
395
+ let re = /\d/;
396
+ if (xmlData[i] === 'x') {
397
+ i++;
398
+ re = /[\da-fA-F]/;
399
+ }
400
+ for (; i < xmlData.length; i++) {
401
+ if (xmlData[i] === ';') return i;
402
+ if (!xmlData[i].match(re)) break;
403
+ }
404
+ return -1;
405
+ }
406
+
407
+ function validateAmpersand(xmlData, i) {
408
+ i++;
409
+ if (xmlData[i] === ';') return -1;
410
+ if (xmlData[i] === '#') {
411
+ i++;
412
+ return validateNumberAmpersand(xmlData, i);
413
+ }
414
+ let count = 0;
415
+ for (; i < xmlData.length; i++, count++) {
416
+ if (xmlData[i].match(/\w/) && count < 20) continue;
417
+ if (xmlData[i] === ';') break;
418
+ return -1;
419
+ }
420
+ return i;
421
+ }
422
+
423
+ function throwError(code, message, lineNumber) {
424
+ throw new ValidationError(message, code, lineNumber.line || lineNumber, lineNumber.col);
425
+ }
426
+
427
+ /**
428
+ * Validate an XML QName (Namespaces in XML 1.0 §2.3).
429
+ *
430
+ * A QName is either:
431
+ * • a plain NCName (no colon) — e.g. "foo"
432
+ * • a prefixed name NCName ':' NCName — e.g. "ns:foo"
433
+ *
434
+ * Rules enforced:
435
+ * - The name must not be empty.
436
+ * - At most one colon is allowed.
437
+ * - Neither the prefix nor the local part may be empty
438
+ * (so ":foo", "ns:", and ":" are all rejected).
439
+ * - Both parts must individually satisfy isName().
440
+ */
441
+ function validateQName(name) {
442
+ if (!name || name.length === 0) return false;
443
+
444
+ const colonIndex = name.indexOf(':');
445
+
446
+ if (colonIndex === -1) {
447
+ // Plain name — no namespace prefix.
448
+ return isName(name);
449
+ }
450
+
451
+ // Reject multiple colons (e.g. "a:b:c").
452
+ if (name.indexOf(':', colonIndex + 1) !== -1) return false;
453
+
454
+ const prefix = name.substring(0, colonIndex);
455
+ const local = name.substring(colonIndex + 1);
456
+
457
+ // Both parts must be non-empty valid Names.
458
+ if (prefix.length === 0 || local.length === 0) return false;
459
+ return isName(prefix) && isName(local);
460
+ }
461
+
462
+ function getLineNumberForPosition(xmlData, index) {
463
+ const lines = xmlData.substring(0, index).split(/\r?\n/);
464
+ return {
465
+ line: lines.length,
466
+ col: lines[lines.length - 1].length + 1,
467
+ };
468
+ }
469
+
470
+ function getPositionFromMatch(match) {
471
+ return match.startIndex + match[1].length;
472
+ }