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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fast-xml-validator",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
4
  "description": "Validate XML for Syntax and Business rules without C/C++ based libraries",
5
5
  "main": "./lib/fxv.cjs",
6
6
  "type": "module",
@@ -21,7 +21,8 @@
21
21
  },
22
22
  "scripts": {
23
23
  "test": "c8 --reporter=lcov --reporter=text jasmine specs/*spec.js",
24
- "bundle": "webpack --config webpack.cjs.config.js"
24
+ "bundle": "webpack --config webpack.cjs.config.js",
25
+ "codemap": "codebase-map scan && codebase-map format --format dsl --include \"src/**\" > .codebase-map.dsl"
25
26
  },
26
27
  "files": [
27
28
  "lib",
@@ -52,6 +53,7 @@
52
53
  "@types/node": "20",
53
54
  "babel-loader": "^8.2.2",
54
55
  "c8": "^10.1.3",
56
+ "codebase-map": "^0.6.1",
55
57
  "eslint": "^8.3.0",
56
58
  "jasmine": "^5.6.0",
57
59
  "typescript": "5",
@@ -100,6 +100,24 @@ export default class SyntaxValidator {
100
100
  docTypeSeen = true;
101
101
  };
102
102
 
103
+ // Character data (text, entity references, CDATA) is only legal inside an
104
+ // element. At depth 0 it is "extra" data, whether before or after the root.
105
+ const checkTopLevelText = (pos) => {
106
+ if (tags.length === 0) {
107
+ throwError('InvalidXml', 'Extra text at the end', getLineNumberForPosition(xmlData, pos));
108
+ }
109
+ };
110
+
111
+ const checkCdataPlacement = (startBang) => {
112
+ if (tags.length === 0 && xmlData.startsWith('<![CDATA[', startBang)) {
113
+ throwError(
114
+ 'InvalidXml',
115
+ 'CDATA section is not allowed outside the root element.',
116
+ getLineNumberForPosition(xmlData, startBang)
117
+ );
118
+ }
119
+ };
120
+
103
121
  for (let i = 0; i < xmlData.length; i++) {
104
122
  if (xmlData[i] === '<' && xmlData[i + 1] === '?') {
105
123
  i += 2;
@@ -109,6 +127,7 @@ export default class SyntaxValidator {
109
127
  i++;
110
128
 
111
129
  if (xmlData[i] === '!') {
130
+ checkCdataPlacement(tagStartPos);
112
131
  try {
113
132
  checkDocTypePlacement(tagStartPos);
114
133
  i = commentCdataReader.read(xmlData, i, docTypeValidator);
@@ -214,14 +233,16 @@ export default class SyntaxValidator {
214
233
  throwError('InvalidXml', 'Multiple possible root nodes found.', getLineNumberForPosition(xmlData, i));
215
234
  } else if (options.unpairedTags.indexOf(tagName) !== -1) {
216
235
  skipMatcher.pop();
236
+ // An unpaired tag at depth 0 is a complete top-level element.
237
+ if (tags.length === 0) reachedRoot = true;
217
238
  } else if (skip) {
218
239
  // Consume the whole skipped subtree now, as opaque raw text,
219
- // then continue scanning right after its closing tag.
240
+ // then fall through to the text scan below so that whatever
241
+ // follows the skipped tag's closing '>' (text, entities,
242
+ // comments, the next tag) is handled like after any other tag.
220
243
  i = RawSubtreeReader.consume(xmlData, tagName, i + 1);
221
244
  skipMatcher.pop();
222
245
  if (tags.length === 0) reachedRoot = true;
223
- tagFound = true;
224
- continue;
225
246
  } else {
226
247
  tags.push({ tagName, tagStartPos });
227
248
  }
@@ -235,6 +256,7 @@ export default class SyntaxValidator {
235
256
  if (xmlData[i] === '<') {
236
257
  if (xmlData[i + 1] === '!') {
237
258
  i++;
259
+ checkCdataPlacement(i - 1);
238
260
  try {
239
261
  checkDocTypePlacement(i - 1);
240
262
  i = commentCdataReader.read(xmlData, i, docTypeValidator);
@@ -251,6 +273,7 @@ export default class SyntaxValidator {
251
273
  break;
252
274
  }
253
275
  } else if (xmlData[i] === '&') {
276
+ checkTopLevelText(i);
254
277
  const afterAmp = this._validateAmpersand(xmlData, i);
255
278
  if (afterAmp === -1)
256
279
  throwError('InvalidChar', "char '&' is not expected.", getLineNumberForPosition(xmlData, i));
@@ -261,7 +284,7 @@ export default class SyntaxValidator {
261
284
  if (isIllegalControlCode(code)) {
262
285
  throwError('IllegalCharacter', `Illegal control character ${ControlCharValidator.describe(code)} in document content.`, getLineNumberForPosition(xmlData, i));
263
286
  }
264
- if (reachedRoot === true && tags.length === 0 && !isWhiteSpace(xmlData[i])) {
287
+ if (tags.length === 0 && !isWhiteSpace(xmlData[i])) {
265
288
  throwError('InvalidXml', 'Extra text at the end', getLineNumberForPosition(xmlData, i));
266
289
  }
267
290
  if (checkTagValueSeq) {
package/src/fxv.d.ts CHANGED
@@ -1,19 +1,18 @@
1
-
2
1
  export type docTypeOptions = {
3
2
  /**
4
- * maximum number of entity present in DOCTYPE
5
- *
3
+ * maximum number of entity present in DOCTYPE
4
+ *
6
5
  * Defaults to `Infinity`
7
6
  */
8
7
  maxEntityCount?: number;
9
8
 
10
9
  /**
11
- * maximum size of each entity present in DOCTYPE
12
- *
10
+ * maximum size of each entity present in DOCTYPE
11
+ *
13
12
  * Defaults to `Infinity`
14
13
  */
15
14
  maxEntitySize?: number;
16
- }
15
+ };
17
16
 
18
17
  export type invalidCharSequenceOptions = {
19
18
  /**
@@ -59,14 +58,14 @@ export type xmlDeclarationOptions = {
59
58
  export type validationOptions = {
60
59
  /**
61
60
  * Whether to allow attributes without value
62
- *
61
+ *
63
62
  * Defaults to `false`
64
63
  */
65
64
  allowBooleanAttributes?: boolean;
66
65
 
67
66
  /**
68
67
  * List of tags without closing tags
69
- *
68
+ *
70
69
  * Defaults to `[]`
71
70
  */
72
71
  unpairedTags?: string[];
@@ -79,10 +78,13 @@ export type validationOptions = {
79
78
  /** Controls handling of the `<?xml ... ?>` prolog. */
80
79
  xmlDeclaraion?: xmlDeclarationOptions;
81
80
 
81
+ /** Correctly-spelled alias of `xmlDeclaraion`. */
82
+ xmlDeclaration?: xmlDeclarationOptions;
83
+
82
84
  /**
83
85
  * Allow more than one root-level element (as if wrapped in a virtual root).
84
86
  *
85
- * Defaults to `false`.
87
+ * Defaults to `true`.
86
88
  */
87
89
  multipleRoots?: boolean;
88
90
 
@@ -95,22 +97,46 @@ export type validationOptions = {
95
97
  skipTags?: string[];
96
98
  };
97
99
 
98
- export type ValidationError = {
99
- err: {
100
- code: string;
101
- msg: string,
102
- line: number,
103
- col: number
104
- };
105
- };
100
+ /**
101
+ * Known error codes. The union stays open (`string & {}`) so new codes
102
+ * added in the future don't break consumers' exhaustive checks.
103
+ */
104
+ export type ValidationErrorCode =
105
+ | 'InvalidXml'
106
+ | 'InvalidTag'
107
+ | 'InvalidAttr'
108
+ | 'InvalidChar'
109
+ | 'InvalidDocType'
110
+ | 'IllegalCharacter'
111
+ | 'UnquotedAttributeValue'
112
+ | (string & {});
113
+
114
+ /**
115
+ * Thrown by `SyntaxValidator#validate` on the first well-formedness problem.
116
+ */
117
+ export class ValidationError extends Error {
118
+ constructor(message: string, code: ValidationErrorCode, line: number, col: number);
119
+ code: ValidationErrorCode;
120
+ line: number;
121
+ col: number;
122
+ }
106
123
 
107
124
  export class SyntaxValidator {
108
125
  constructor(options?: validationOptions);
126
+
127
+ /**
128
+ * @returns `true` when the document is well-formed.
129
+ * @throws {ValidationError} on the first problem found.
130
+ */
109
131
  validate(xmlData: string): true;
132
+
133
+ /**
134
+ * @returns `true` when the document is well-formed.
135
+ * @throws {ValidationError} on the first problem found.
136
+ */
110
137
  static validate(xmlData: string, options?: validationOptions): true;
111
138
  }
112
139
 
113
-
114
140
  export interface BusinessRulesValidatorOptions {
115
141
  unknownAllow?: boolean;
116
142
  boolean?: string[];
@@ -122,7 +148,7 @@ export type ValidationFailure = {
122
148
  value?: string;
123
149
  actual?: number | string;
124
150
  expected?: number | string;
125
- }
151
+ };
126
152
 
127
153
  export type CustomValidatorFn = (
128
154
  value: string,
@@ -140,7 +166,7 @@ export class BusinessRulesValidator {
140
166
  *
141
167
  * @example
142
168
  * ```ts
143
- * const validator = new Validator(`
169
+ * const validator = new BusinessRulesValidator(`
144
170
  * <students>
145
171
  * <student repeatable minOccurs="1">
146
172
  * <email unique="true" nillable="false"></email>
@@ -1,9 +1,106 @@
1
1
  'use strict';
2
2
 
3
- import { getAllMatches, getPositionFromMatch } from '../util.js';
3
+ import { getPositionFromMatch, isWhiteSpace } from '../util.js';
4
4
  import ControlCharValidator from './ControlCharValidator.js';
5
5
 
6
- const validAttrStrRegxp = new RegExp('(\\s*)([^\\s=]+)(\\s*=)?(\\s*([\'"])(([\\s\\S])*?)\\5)?', 'g');
6
+ /**
7
+ * Walk `attrStr` once, left to right, splitting it into attribute tokens.
8
+ *
9
+ * This replaces a regex that used to do the same job
10
+ * (`(\s*)([^\s=]+)(\s*=)?(\s*(['"])(([\s\S])*?)\5)?`). That regex led with an
11
+ * optional whitespace group followed by a required "non-whitespace" group.
12
+ * On a long run of whitespace that never resolves into an attribute name
13
+ * (e.g. a tag with thousands of trailing spaces before `>`), the engine
14
+ * backtracks the whitespace group one character at a time before giving up
15
+ * and moving to the next starting position — one full backtrack per
16
+ * position, which is quadratic in the length of the run. A ~64KB tag made
17
+ * entirely of spaces could stall the whole process for seconds.
18
+ *
19
+ * A single forward-only scan can never backtrack, so it can't be made slow
20
+ * this way no matter how much whitespace the input contains — it's always
21
+ * proportional to the length of the string, once.
22
+ *
23
+ * Each returned token mirrors the shape the old regex match array had, so
24
+ * the validation logic below (which reads token[1]..token[6]) didn't need
25
+ * to change:
26
+ * token.startIndex - where this token begins in attrStr
27
+ * token[1] - leading whitespace before the name
28
+ * token[2] - the attribute name
29
+ * token[3] - whitespace + '=' if present, else undefined
30
+ * token[4] - marker (any defined value) if a quoted value was found
31
+ * token[5] - the quote character used ('"' or "'")
32
+ * token[6] - the value's text, without the surrounding quotes
33
+ *
34
+ * A malformed leading character (e.g. a stray '=' with no name before it)
35
+ * is simply skipped over, one character at a time — the same outcome the
36
+ * old regex produced by failing to match at that position and retrying at
37
+ * the next one.
38
+ */
39
+ function scanAttributeTokens(attrStr) {
40
+ const tokens = [];
41
+ const len = attrStr.length;
42
+ let i = 0;
43
+
44
+ while (i < len) {
45
+ const tokenStart = i;
46
+
47
+ // Leading whitespace before the name.
48
+ while (i < len && isWhiteSpace(attrStr[i])) i++;
49
+ if (i >= len) break; // trailing whitespace only — nothing left to read
50
+
51
+ if (attrStr[i] === '=') {
52
+ // No name before this '=' — not a valid attribute start. Move past
53
+ // just this one character and try again from the next position.
54
+ i = tokenStart + 1;
55
+ continue;
56
+ }
57
+
58
+ const leadingWs = attrStr.slice(tokenStart, i);
59
+
60
+ // Attribute name — everything up to the next whitespace or '='.
61
+ const nameStart = i;
62
+ while (i < len && !isWhiteSpace(attrStr[i]) && attrStr[i] !== '=') i++;
63
+ const name = attrStr.slice(nameStart, i);
64
+
65
+ // Optional whitespace + '='.
66
+ let equalsGroup; // whitespace + '=' text, or undefined if absent
67
+ let j = i;
68
+ while (j < len && isWhiteSpace(attrStr[j])) j++;
69
+ if (j < len && attrStr[j] === '=') {
70
+ equalsGroup = attrStr.slice(i, j + 1);
71
+ i = j + 1;
72
+ }
73
+
74
+ // Optional whitespace + quoted value.
75
+ let quoteChar;
76
+ let value;
77
+ let k = i;
78
+ while (k < len && isWhiteSpace(attrStr[k])) k++;
79
+ if (k < len && (attrStr[k] === '"' || attrStr[k] === "'")) {
80
+ const valueStart = k + 1;
81
+ const closeIdx = attrStr.indexOf(attrStr[k], valueStart);
82
+ if (closeIdx !== -1) {
83
+ quoteChar = attrStr[k];
84
+ value = attrStr.slice(valueStart, closeIdx);
85
+ i = closeIdx + 1;
86
+ }
87
+ // No closing quote found anywhere in the rest of the string — leave
88
+ // quoteChar/value undefined, same as the old regex's group failing
89
+ // to match a backreference-less run.
90
+ }
91
+
92
+ const token = { startIndex: tokenStart };
93
+ token[1] = leadingWs;
94
+ token[2] = name;
95
+ token[3] = equalsGroup;
96
+ token[4] = quoteChar !== undefined ? true : undefined;
97
+ token[5] = quoteChar;
98
+ token[6] = value;
99
+ tokens.push(token);
100
+ }
101
+
102
+ return tokens;
103
+ }
7
104
 
8
105
  /**
9
106
  * AttributeStringValidator — single responsibility: validate the raw
@@ -40,7 +137,7 @@ export default class AttributeStringValidator {
40
137
  * @returns {true | { err: { code: string, msg: string, line: number } }}
41
138
  */
42
139
  validate(attrStr) {
43
- const matches = getAllMatches(attrStr, validAttrStrRegxp);
140
+ const matches = scanAttributeTokens(attrStr);
44
141
  const attrNames = {};
45
142
  const checkAttrLt = this.options.invalidCharSequence?.attrLt === true;
46
143
 
@@ -112,4 +209,4 @@ export default class AttributeStringValidator {
112
209
  _err(code, msg, match) {
113
210
  return { err: { code, msg, line: getPositionFromMatch(match) } };
114
211
  }
115
- }
212
+ }
@@ -28,9 +28,21 @@ export default class DocTypeValidator {
28
28
  let angleBracketsCount = 1;
29
29
  let hasBody = false;
30
30
  let comment = false;
31
+ let quoteChar = null; // tracks an open SYSTEM/PUBLIC literal before the '[' body
31
32
  let entityCount = 0;
32
33
 
33
34
  for (; i < xmlData.length; i++) {
35
+ // Inside a quoted external-identifier literal — '<' and '>' are
36
+ // plain data here per the XML grammar, not DOCTYPE structure.
37
+ if (quoteChar !== null) {
38
+ if (xmlData[i] === quoteChar) quoteChar = null;
39
+ continue;
40
+ }
41
+ if (!hasBody && !comment && (xmlData[i] === '"' || xmlData[i] === "'")) {
42
+ quoteChar = xmlData[i];
43
+ continue;
44
+ }
45
+
34
46
  if (xmlData[i] === '<' && !comment) {
35
47
  if (hasBody && hasSeq(xmlData, '!ENTITY', i)) {
36
48
  i += 7;
@@ -69,7 +81,7 @@ export default class DocTypeValidator {
69
81
  }
70
82
  }
71
83
 
72
- if (angleBracketsCount !== 0) {
84
+ if (quoteChar !== null || angleBracketsCount !== 0) {
73
85
  throw new Error(`Unclosed DOCTYPE`);
74
86
  }
75
87
 
@@ -1,5 +1,93 @@
1
1
  'use strict';
2
2
 
3
+ import { isWhiteSpace } from '../util.js';
4
+
5
+ function isWordChar(ch) {
6
+ return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch === '_';
7
+ }
8
+
9
+ /**
10
+ * Walk the `<?xml ... ?>` body once, left to right, pulling out
11
+ * `name="value"` / `name='value'` pairs (each one preceded by required
12
+ * whitespace) and collecting everything else as leftover text.
13
+ *
14
+ * This replaces two regexes that used to do the same job:
15
+ * /\s+([\w]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g - to find attributes
16
+ * /\s+[\w]+\s*=\s*(?:"[^"]*"|'[^']*')/g (via replace) - to find leftovers
17
+ *
18
+ * Both led with a required-but-greedy `\s+`. On a long run of whitespace
19
+ * that's never followed by a valid `name=value`, the engine backtracks that
20
+ * whitespace group one character at a time before giving up on that
21
+ * starting position — and since none of those backtracked attempts can ever
22
+ * succeed (a whitespace character is never a valid name character, at any
23
+ * backtrack length), all of that retrying was wasted work. A `<?xml ?>`
24
+ * declaration padded with thousands of spaces could make this step take
25
+ * seconds. Scanning forward once, without ever revisiting a position, is
26
+ * both faster and gives the identical result — the retried attempts could
27
+ * never have succeeded anyway.
28
+ *
29
+ * @param {string} declBody
30
+ * @returns {{ matches: Array<{name: string, value: string, index: number}>, leftover: string }}
31
+ */
32
+ function scanDeclAttributes(declBody) {
33
+ const matches = [];
34
+ let leftover = '';
35
+ const len = declBody.length;
36
+ let i = 0;
37
+
38
+ while (i < len) {
39
+ if (!isWhiteSpace(declBody[i])) {
40
+ leftover += declBody[i];
41
+ i++;
42
+ continue;
43
+ }
44
+
45
+ const matchStart = i;
46
+ let p = i;
47
+ while (p < len && isWhiteSpace(declBody[p])) p++;
48
+
49
+ const nameStart = p;
50
+ while (p < len && isWordChar(declBody[p])) p++;
51
+ if (p === nameStart) {
52
+ // Whitespace not followed by a name character — can't become a match
53
+ // no matter how much of the whitespace we consider, so it's leftover.
54
+ leftover += declBody.slice(matchStart, p + 1 <= len ? p : len);
55
+ i = p > matchStart ? p : matchStart + 1;
56
+ continue;
57
+ }
58
+ const name = declBody.slice(nameStart, p);
59
+
60
+ let q = p;
61
+ while (q < len && isWhiteSpace(declBody[q])) q++;
62
+ if (declBody[q] !== '=') {
63
+ leftover += declBody.slice(matchStart, p);
64
+ i = p;
65
+ continue;
66
+ }
67
+ q++; // skip '='
68
+ while (q < len && isWhiteSpace(declBody[q])) q++;
69
+
70
+ const quote = declBody[q];
71
+ if (quote !== '"' && quote !== "'") {
72
+ leftover += declBody.slice(matchStart, p);
73
+ i = p;
74
+ continue;
75
+ }
76
+ const valueStart = q + 1;
77
+ const closeIdx = declBody.indexOf(quote, valueStart);
78
+ if (closeIdx === -1) {
79
+ leftover += declBody.slice(matchStart, p);
80
+ i = p;
81
+ continue;
82
+ }
83
+
84
+ matches.push({ name, value: declBody.slice(valueStart, closeIdx), index: matchStart });
85
+ i = closeIdx + 1;
86
+ }
87
+
88
+ return { matches, leftover };
89
+ }
90
+
3
91
  /**
4
92
  * XmlDeclarationValidator — single responsibility: parse and validate the
5
93
  * `<?xml version="..." encoding="..." standalone="..."?>` prolog.
@@ -49,16 +137,15 @@ export default class XmlDeclarationValidator {
49
137
  const end = piEnd + 2;
50
138
 
51
139
  const ALLOWED = ['version', 'encoding', 'standalone'];
52
- const attrRe = /\s+([\w]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
140
+ const { matches: declAttrs, leftover } = scanDeclAttributes(declBody);
53
141
 
54
142
  let version = '1.0';
55
143
  let lastAllowedIdx = -1;
56
- let match;
57
144
 
58
- while ((match = attrRe.exec(declBody)) !== null) {
59
- const attrName = match[1];
60
- const attrValue = match[2] !== undefined ? match[2] : match[3];
61
- const attrPos = match.index;
145
+ for (let m = 0; m < declAttrs.length; m++) {
146
+ const attrName = declAttrs[m].name;
147
+ const attrValue = declAttrs[m].value;
148
+ const attrPos = declAttrs[m].index;
62
149
 
63
150
  const allowedIdx = ALLOWED.indexOf(attrName);
64
151
  if (allowedIdx === -1) {
@@ -91,13 +178,13 @@ export default class XmlDeclarationValidator {
91
178
  // encoding: value format validation is out of scope
92
179
  }
93
180
 
94
- const leftover = declBody.replace(/\s+[\w]+\s*=\s*(?:"[^"]*"|'[^']*')/g, '').trim();
95
- if (leftover.length > 0) {
181
+ const trimmedLeftover = leftover.trim();
182
+ if (trimmedLeftover.length > 0) {
96
183
  this._throwError('InvalidXml',
97
- 'XML declaration contains invalid content: "' + leftover + '".',
184
+ 'XML declaration contains invalid content: "' + trimmedLeftover + '".',
98
185
  { line: 1, col: 1 });
99
186
  }
100
187
 
101
188
  return { version, end };
102
189
  }
103
- }
190
+ }