fast-xml-validator 1.4.1 → 1.4.3

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