fast-xml-validator 1.3.0 → 1.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/lib/fxv.cjs +1 -1
- package/lib/fxv.d.cts +64 -0
- package/lib/fxv.min.js +1 -1
- package/lib/fxv.min.js.map +1 -1
- package/package.json +2 -1
- package/src/SyntaxValidator.js +414 -0
- package/src/fxv.d.ts +64 -0
- package/src/fxv.js +2 -8
- package/src/util.js +27 -1
- package/src/validators/AttributeStringValidator.js +115 -0
- package/src/validators/CommentCdataReader.js +106 -0
- package/src/validators/ControlCharValidator.js +44 -0
- package/src/validators/DocTypeValidator.js +280 -0
- package/src/validators/RawSubtreeReader.js +47 -0
- package/src/validators/SkipTagMatcher.js +54 -0
- package/src/validators/XmlDeclarationValidator.js +103 -0
- package/src/validators/XmlNameValidator.js +24 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fast-xml-validator",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
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",
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
],
|
|
67
67
|
"dependencies": {
|
|
68
68
|
"detailed-xml-validator": "^2.2.0",
|
|
69
|
+
"path-expression-matcher": "^1.6.2",
|
|
69
70
|
"xml-naming": "^0.3.0"
|
|
70
71
|
}
|
|
71
72
|
}
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import ValidationError from './ValidationError.js';
|
|
4
|
+
import { isWhiteSpace, getLineNumberForPosition, isIllegalControlCode } from './util.js';
|
|
5
|
+
import XmlNameValidator from './validators/XmlNameValidator.js';
|
|
6
|
+
import XmlDeclarationValidator from './validators/XmlDeclarationValidator.js';
|
|
7
|
+
import AttributeStringValidator from './validators/AttributeStringValidator.js';
|
|
8
|
+
import CommentCdataReader from './validators/CommentCdataReader.js';
|
|
9
|
+
import ControlCharValidator from './validators/ControlCharValidator.js';
|
|
10
|
+
import SkipTagMatcher from './validators/SkipTagMatcher.js';
|
|
11
|
+
import RawSubtreeReader from './validators/RawSubtreeReader.js';
|
|
12
|
+
import DocTypeValidator from './validators/DocTypeValidator.js';
|
|
13
|
+
|
|
14
|
+
const defaultOptions = {
|
|
15
|
+
allowBooleanAttributes: false,
|
|
16
|
+
unpairedTags: [],
|
|
17
|
+
docType: {
|
|
18
|
+
maxEntityCount: Infinity,
|
|
19
|
+
maxEntitySize: Infinity,
|
|
20
|
+
},
|
|
21
|
+
invalidCharSequence: {
|
|
22
|
+
comment: false, // '--' inside a comment body
|
|
23
|
+
tagValue: false, // ']]>' inside element text content
|
|
24
|
+
attrLt: false, // '<' inside an attribute value
|
|
25
|
+
},
|
|
26
|
+
xmlDeclaraion: {
|
|
27
|
+
optional: true,
|
|
28
|
+
argPosition: true,
|
|
29
|
+
},
|
|
30
|
+
multipleRoots: true,
|
|
31
|
+
skipTags: [],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* SyntaxValidator — validates that an XML document is well-formed.
|
|
36
|
+
*
|
|
37
|
+
* This is an orchestrator: it owns the top-level character scan and the
|
|
38
|
+
* open/close tag stack, and delegates every other concern to a focused,
|
|
39
|
+
* single-responsibility collaborator:
|
|
40
|
+
*
|
|
41
|
+
* - XmlDeclarationValidator — the `<?xml ... ?>` prolog
|
|
42
|
+
* - DocTypeValidator — `<!DOCTYPE ...>`
|
|
43
|
+
* - CommentCdataReader — `<!-- -->` and `<![CDATA[ ]]>`
|
|
44
|
+
* - AttributeStringValidator — a tag's attribute expression
|
|
45
|
+
* - XmlNameValidator — element/attribute/PI name well-formedness
|
|
46
|
+
* - ControlCharValidator — illegal control characters (always on)
|
|
47
|
+
* - SkipTagMatcher / RawSubtreeReader — `skipTags` support
|
|
48
|
+
*
|
|
49
|
+
* Usage: `new SyntaxValidator(options).validate(xmlData)` or the static
|
|
50
|
+
* convenience `SyntaxValidator.validate(xmlData, options)`.
|
|
51
|
+
*/
|
|
52
|
+
export default class SyntaxValidator {
|
|
53
|
+
constructor(options) {
|
|
54
|
+
this.options = mergeOptions(defaultOptions, options);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Static convenience wrapper — matches the historical fxv v1 API. */
|
|
58
|
+
static validate(xmlData, options) {
|
|
59
|
+
return new SyntaxValidator(options).validate(xmlData);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
validate(xmlData) {
|
|
63
|
+
const options = this.options;
|
|
64
|
+
|
|
65
|
+
const tags = [];
|
|
66
|
+
let tagFound = false;
|
|
67
|
+
let reachedRoot = false;
|
|
68
|
+
let docTypeSeen = false;
|
|
69
|
+
|
|
70
|
+
if (xmlData[0] === '\ufeff') {
|
|
71
|
+
xmlData = xmlData.substr(1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const throwError = (code, message, lineNumber) => {
|
|
75
|
+
throw new ValidationError(message, code, lineNumber.line || lineNumber, lineNumber.col);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const declValidator = new XmlDeclarationValidator(options.xmlDeclaraion, throwError);
|
|
79
|
+
const declResult = declValidator.validate(xmlData);
|
|
80
|
+
const xmlVersion = declResult.version;
|
|
81
|
+
xmlData = xmlData.substring(declResult.end);
|
|
82
|
+
|
|
83
|
+
const docTypeValidator = new DocTypeValidator(options.docType, xmlVersion);
|
|
84
|
+
const commentCdataReader = new CommentCdataReader(options);
|
|
85
|
+
const nameValidator = new XmlNameValidator(xmlVersion);
|
|
86
|
+
const attrValidator = new AttributeStringValidator(options, nameValidator, xmlVersion);
|
|
87
|
+
const skipMatcher = new SkipTagMatcher(options.skipTags);
|
|
88
|
+
|
|
89
|
+
const checkTagValueSeq = options.invalidCharSequence?.tagValue === true;
|
|
90
|
+
|
|
91
|
+
const checkDocTypePlacement = (startBang) => {
|
|
92
|
+
const isDoctype = xmlData.substring(startBang, startBang + 9).toUpperCase() === '<!DOCTYPE';
|
|
93
|
+
if (!isDoctype) return;
|
|
94
|
+
if (docTypeSeen) {
|
|
95
|
+
throwError('InvalidXml', 'Multiple DOCTYPE declarations found.', getLineNumberForPosition(xmlData, startBang));
|
|
96
|
+
}
|
|
97
|
+
if (tagFound || tags.length > 0) {
|
|
98
|
+
throwError('InvalidXml', 'DOCTYPE must appear before the root element.', getLineNumberForPosition(xmlData, startBang));
|
|
99
|
+
}
|
|
100
|
+
docTypeSeen = true;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
for (let i = 0; i < xmlData.length; i++) {
|
|
104
|
+
if (xmlData[i] === '<' && xmlData[i + 1] === '?') {
|
|
105
|
+
i += 2;
|
|
106
|
+
i = this._readPI(xmlData, i, nameValidator, throwError);
|
|
107
|
+
} else if (xmlData[i] === '<') {
|
|
108
|
+
let tagStartPos = i;
|
|
109
|
+
i++;
|
|
110
|
+
|
|
111
|
+
if (xmlData[i] === '!') {
|
|
112
|
+
try {
|
|
113
|
+
checkDocTypePlacement(tagStartPos);
|
|
114
|
+
i = commentCdataReader.read(xmlData, i, docTypeValidator);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
throwError('InvalidDocType', err.message, getLineNumberForPosition(xmlData, i - 1));
|
|
117
|
+
}
|
|
118
|
+
continue;
|
|
119
|
+
} else {
|
|
120
|
+
let closingTag = false;
|
|
121
|
+
if (xmlData[i] === '/') {
|
|
122
|
+
closingTag = true;
|
|
123
|
+
i++;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let tagName = '';
|
|
127
|
+
for (;
|
|
128
|
+
i < xmlData.length &&
|
|
129
|
+
xmlData[i] !== '>' &&
|
|
130
|
+
xmlData[i] !== ' ' &&
|
|
131
|
+
xmlData[i] !== '\t' &&
|
|
132
|
+
xmlData[i] !== '\n' &&
|
|
133
|
+
xmlData[i] !== '\r';
|
|
134
|
+
i++
|
|
135
|
+
) {
|
|
136
|
+
tagName += xmlData[i];
|
|
137
|
+
}
|
|
138
|
+
tagName = tagName.trim();
|
|
139
|
+
|
|
140
|
+
if (tagName[tagName.length - 1] === '/') {
|
|
141
|
+
tagName = tagName.substring(0, tagName.length - 1);
|
|
142
|
+
i--;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!nameValidator.isValidQName(tagName)) {
|
|
146
|
+
let msg;
|
|
147
|
+
if (tagName.trim().length === 0) {
|
|
148
|
+
msg = "Invalid space after '<'.";
|
|
149
|
+
} else {
|
|
150
|
+
msg = "Tag '" + tagName + "' is an invalid name.";
|
|
151
|
+
}
|
|
152
|
+
throwError('InvalidTag', msg, getLineNumberForPosition(xmlData, tagStartPos));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const result = this._readAttributeStr(xmlData, i);
|
|
156
|
+
if (result === false) {
|
|
157
|
+
throwError('InvalidAttr', "Attributes for '" + tagName + "' have open quote.", getLineNumberForPosition(xmlData, i));
|
|
158
|
+
}
|
|
159
|
+
let attrStr = result.value;
|
|
160
|
+
i = result.index;
|
|
161
|
+
|
|
162
|
+
if (attrStr[attrStr.length - 1] === '/') {
|
|
163
|
+
const attrStrStart = i - attrStr.length;
|
|
164
|
+
attrStr = attrStr.substring(0, attrStr.length - 1);
|
|
165
|
+
const isValid = attrValidator.validate(attrStr);
|
|
166
|
+
if (isValid === true) {
|
|
167
|
+
if (reachedRoot === true && !options.multipleRoots) {
|
|
168
|
+
throwError('InvalidXml', 'Multiple possible root nodes found.', getLineNumberForPosition(xmlData, tagStartPos));
|
|
169
|
+
}
|
|
170
|
+
tagFound = true;
|
|
171
|
+
if (tags.length === 0) {
|
|
172
|
+
reachedRoot = true;
|
|
173
|
+
}
|
|
174
|
+
skipMatcher.push(tagName);
|
|
175
|
+
skipMatcher.pop();
|
|
176
|
+
} else {
|
|
177
|
+
throwError(isValid.err.code, isValid.err.msg, getLineNumberForPosition(xmlData, attrStrStart + isValid.err.line));
|
|
178
|
+
}
|
|
179
|
+
} else if (closingTag) {
|
|
180
|
+
if (!result.tagClosed) {
|
|
181
|
+
throwError('InvalidTag', "Closing tag '" + tagName + "' doesn't have proper closing.", getLineNumberForPosition(xmlData, i));
|
|
182
|
+
} else if (attrStr.trim().length > 0) {
|
|
183
|
+
throwError('InvalidTag', "Closing tag '" + tagName + "' can't have attributes or invalid starting.", getLineNumberForPosition(xmlData, tagStartPos));
|
|
184
|
+
} else if (tags.length === 0) {
|
|
185
|
+
throwError('InvalidTag', "Closing tag '" + tagName + "' has not been opened.", getLineNumberForPosition(xmlData, tagStartPos));
|
|
186
|
+
} else {
|
|
187
|
+
const otg = tags.pop();
|
|
188
|
+
if (tagName !== otg.tagName) {
|
|
189
|
+
let openPos = getLineNumberForPosition(xmlData, otg.tagStartPos);
|
|
190
|
+
throwError(
|
|
191
|
+
'InvalidTag',
|
|
192
|
+
"Expected closing tag '" + otg.tagName + "' (opened in line " + openPos.line + ", col " + openPos.col + ") instead of closing tag '" + tagName + "'.",
|
|
193
|
+
getLineNumberForPosition(xmlData, tagStartPos)
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
skipMatcher.pop();
|
|
197
|
+
if (tags.length === 0) {
|
|
198
|
+
reachedRoot = true;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
// Opening tag — decide whether this subtree should be skipped
|
|
203
|
+
// from validation entirely before validating its attributes.
|
|
204
|
+
const skip = skipMatcher.push(tagName);
|
|
205
|
+
|
|
206
|
+
if (!skip) {
|
|
207
|
+
const isValid = attrValidator.validate(attrStr);
|
|
208
|
+
if (isValid !== true) {
|
|
209
|
+
throwError(isValid.err.code, isValid.err.msg, getLineNumberForPosition(xmlData, i - attrStr.length + isValid.err.line));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (reachedRoot === true && !options.multipleRoots) {
|
|
214
|
+
throwError('InvalidXml', 'Multiple possible root nodes found.', getLineNumberForPosition(xmlData, i));
|
|
215
|
+
} else if (options.unpairedTags.indexOf(tagName) !== -1) {
|
|
216
|
+
skipMatcher.pop();
|
|
217
|
+
} else if (skip) {
|
|
218
|
+
// Consume the whole skipped subtree now, as opaque raw text,
|
|
219
|
+
// then continue scanning right after its closing tag.
|
|
220
|
+
i = RawSubtreeReader.consume(xmlData, tagName, i + 1);
|
|
221
|
+
skipMatcher.pop();
|
|
222
|
+
if (tags.length === 0) reachedRoot = true;
|
|
223
|
+
tagFound = true;
|
|
224
|
+
continue;
|
|
225
|
+
} else {
|
|
226
|
+
tags.push({ tagName, tagStartPos });
|
|
227
|
+
}
|
|
228
|
+
tagFound = true;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
//skip tag text value
|
|
232
|
+
//It may include comments and CDATA value
|
|
233
|
+
let prevTwo = '';
|
|
234
|
+
for (i++; i < xmlData.length; i++) {
|
|
235
|
+
if (xmlData[i] === '<') {
|
|
236
|
+
if (xmlData[i + 1] === '!') {
|
|
237
|
+
i++;
|
|
238
|
+
try {
|
|
239
|
+
checkDocTypePlacement(i - 1);
|
|
240
|
+
i = commentCdataReader.read(xmlData, i, docTypeValidator);
|
|
241
|
+
} catch (err) {
|
|
242
|
+
throwError('InvalidDocType', err.message, getLineNumberForPosition(xmlData, i - 1));
|
|
243
|
+
}
|
|
244
|
+
prevTwo = '';
|
|
245
|
+
continue;
|
|
246
|
+
} else if (xmlData[i + 1] === '?') {
|
|
247
|
+
i += 2;
|
|
248
|
+
i = this._readPI(xmlData, i, nameValidator, throwError);
|
|
249
|
+
prevTwo = '';
|
|
250
|
+
} else {
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
} else if (xmlData[i] === '&') {
|
|
254
|
+
const afterAmp = this._validateAmpersand(xmlData, i);
|
|
255
|
+
if (afterAmp === -1)
|
|
256
|
+
throwError('InvalidChar', "char '&' is not expected.", getLineNumberForPosition(xmlData, i));
|
|
257
|
+
i = afterAmp;
|
|
258
|
+
prevTwo = '';
|
|
259
|
+
} else {
|
|
260
|
+
const code = xmlData.charCodeAt(i);
|
|
261
|
+
if (isIllegalControlCode(code)) {
|
|
262
|
+
throwError('IllegalCharacter', `Illegal control character ${ControlCharValidator.describe(code)} in document content.`, getLineNumberForPosition(xmlData, i));
|
|
263
|
+
}
|
|
264
|
+
if (reachedRoot === true && tags.length === 0 && !isWhiteSpace(xmlData[i])) {
|
|
265
|
+
throwError('InvalidXml', 'Extra text at the end', getLineNumberForPosition(xmlData, i));
|
|
266
|
+
}
|
|
267
|
+
if (checkTagValueSeq) {
|
|
268
|
+
prevTwo = (prevTwo + xmlData[i]).slice(-2);
|
|
269
|
+
if (prevTwo === ']]' && xmlData[i + 1] === '>') {
|
|
270
|
+
throwError('InvalidXml', "Element text content must not contain ']]>'.", getLineNumberForPosition(xmlData, i - 1));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
} //end of reading tag text value
|
|
275
|
+
if (xmlData[i] === '<') {
|
|
276
|
+
i--;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
} else {
|
|
280
|
+
if (isWhiteSpace(xmlData[i])) {
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (reachedRoot) {
|
|
284
|
+
throwError('InvalidXml', "Extra text at the end: '" + xmlData[i] + "' is not expected.", getLineNumberForPosition(xmlData, i));
|
|
285
|
+
}
|
|
286
|
+
throwError('InvalidChar', "char '" + xmlData[i] + "' is not expected.", getLineNumberForPosition(xmlData, i));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (!tagFound) {
|
|
291
|
+
throwError('InvalidXml', 'Start tag expected.', { line: 1, col: 1 });
|
|
292
|
+
} else if (tags.length === 1) {
|
|
293
|
+
throwError('InvalidTag', "Unclosed tag '" + tags[0].tagName + "'.", getLineNumberForPosition(xmlData, tags[0].tagStartPos));
|
|
294
|
+
} else if (tags.length > 0) {
|
|
295
|
+
throwError('InvalidXml', "Invalid '" +
|
|
296
|
+
JSON.stringify(tags.map(t => t.tagName)).replace(/\r?\n/g, '') +
|
|
297
|
+
"' found.", { line: 1, col: 1 });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
_readPI(xmlData, i, nameValidator, throwError) {
|
|
304
|
+
const piStart = i;
|
|
305
|
+
const nameStart = i;
|
|
306
|
+
let nameValidated = false;
|
|
307
|
+
|
|
308
|
+
for (; i < xmlData.length; i++) {
|
|
309
|
+
const ch = xmlData[i];
|
|
310
|
+
|
|
311
|
+
if (!nameValidated && (ch === ' ' || ch === '?')) {
|
|
312
|
+
const piName = xmlData.substr(nameStart, i - nameStart);
|
|
313
|
+
nameValidated = true;
|
|
314
|
+
|
|
315
|
+
if (piName.toLowerCase() === 'xml') {
|
|
316
|
+
throwError(
|
|
317
|
+
'InvalidXml',
|
|
318
|
+
'XML declaration allowed only at the start of the document.',
|
|
319
|
+
getLineNumberForPosition(xmlData, piStart - 2)
|
|
320
|
+
);
|
|
321
|
+
} else if (!nameValidator.isValidName(piName)) {
|
|
322
|
+
throwError(
|
|
323
|
+
'InvalidXml',
|
|
324
|
+
`Processing instruction target "${piName}" is not a valid XML Name.`,
|
|
325
|
+
getLineNumberForPosition(xmlData, piStart - 2)
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (ch === '?' && xmlData[i + 1] === '>') {
|
|
331
|
+
i++;
|
|
332
|
+
return i;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
throwError(
|
|
337
|
+
'InvalidXml',
|
|
338
|
+
'Processing instruction is not closed with "?>".',
|
|
339
|
+
getLineNumberForPosition(xmlData, piStart - 2)
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
_readAttributeStr(xmlData, i) {
|
|
344
|
+
let attrStr = '';
|
|
345
|
+
let startChar = '';
|
|
346
|
+
let tagClosed = false;
|
|
347
|
+
for (; i < xmlData.length; i++) {
|
|
348
|
+
if (xmlData[i] === '"' || xmlData[i] === "'") {
|
|
349
|
+
if (startChar === '') {
|
|
350
|
+
startChar = xmlData[i];
|
|
351
|
+
} else if (startChar !== xmlData[i]) {
|
|
352
|
+
// opposite quote inside value — allowed
|
|
353
|
+
} else {
|
|
354
|
+
startChar = '';
|
|
355
|
+
}
|
|
356
|
+
} else if (xmlData[i] === '>') {
|
|
357
|
+
if (startChar === '') {
|
|
358
|
+
tagClosed = true;
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
attrStr += xmlData[i];
|
|
363
|
+
}
|
|
364
|
+
if (startChar !== '') {
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
return { value: attrStr, index: i, tagClosed };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
_validateNumberAmpersand(xmlData, i) {
|
|
371
|
+
let re = /\d/;
|
|
372
|
+
if (xmlData[i] === 'x') {
|
|
373
|
+
i++;
|
|
374
|
+
re = /[\da-fA-F]/;
|
|
375
|
+
}
|
|
376
|
+
for (; i < xmlData.length; i++) {
|
|
377
|
+
if (xmlData[i] === ';') return i;
|
|
378
|
+
if (!xmlData[i].match(re)) break;
|
|
379
|
+
}
|
|
380
|
+
return -1;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
_validateAmpersand(xmlData, i) {
|
|
384
|
+
i++;
|
|
385
|
+
if (xmlData[i] === ';') return -1;
|
|
386
|
+
if (xmlData[i] === '#') {
|
|
387
|
+
i++;
|
|
388
|
+
return this._validateNumberAmpersand(xmlData, i);
|
|
389
|
+
}
|
|
390
|
+
let count = 0;
|
|
391
|
+
for (; i < xmlData.length; i++, count++) {
|
|
392
|
+
if (xmlData[i].match(/\w/) && count < 20) continue;
|
|
393
|
+
if (xmlData[i] === ';') break;
|
|
394
|
+
return -1;
|
|
395
|
+
}
|
|
396
|
+
return i;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function mergeOptions(defaults, options) {
|
|
401
|
+
const merged = Object.assign({}, defaults, options);
|
|
402
|
+
merged.docType = Object.assign({}, defaults.docType, options?.docType);
|
|
403
|
+
merged.invalidCharSequence = Object.assign({}, defaults.invalidCharSequence, options?.invalidCharSequence);
|
|
404
|
+
merged.xmlDeclaraion = Object.assign(
|
|
405
|
+
{},
|
|
406
|
+
defaults.xmlDeclaraion,
|
|
407
|
+
options?.xmlDeclaraion,
|
|
408
|
+
options?.xmlDeclaration // accept the correctly-spelled alias too
|
|
409
|
+
);
|
|
410
|
+
merged.skipTags = options?.skipTags || defaults.skipTags;
|
|
411
|
+
return merged;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export { SyntaxValidator };
|
package/src/fxv.d.ts
CHANGED
|
@@ -15,6 +15,47 @@ export type docTypeOptions = {
|
|
|
15
15
|
maxEntitySize?: number;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
export type invalidCharSequenceOptions = {
|
|
19
|
+
/**
|
|
20
|
+
* Error if a comment body contains the literal sequence `--`
|
|
21
|
+
* (besides the `--` immediately before the closing `-->`).
|
|
22
|
+
*
|
|
23
|
+
* Defaults to `false`.
|
|
24
|
+
*/
|
|
25
|
+
comment?: boolean;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Error if element text content contains the literal sequence `]]>`.
|
|
29
|
+
*
|
|
30
|
+
* Defaults to `false`.
|
|
31
|
+
*/
|
|
32
|
+
tagValue?: boolean;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Error if an attribute value contains a literal `<`.
|
|
36
|
+
*
|
|
37
|
+
* Defaults to `false`.
|
|
38
|
+
*/
|
|
39
|
+
attrLt?: boolean;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type xmlDeclarationOptions = {
|
|
43
|
+
/**
|
|
44
|
+
* When `false`, the document MUST start with an `<?xml ... ?>` declaration.
|
|
45
|
+
*
|
|
46
|
+
* Defaults to `true`.
|
|
47
|
+
*/
|
|
48
|
+
optional?: boolean;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* When `true`, `version`/`encoding`/`standalone` must appear in that fixed
|
|
52
|
+
* order inside the XML declaration. When `false`, any order is accepted.
|
|
53
|
+
*
|
|
54
|
+
* Defaults to `true`.
|
|
55
|
+
*/
|
|
56
|
+
argPosition?: boolean;
|
|
57
|
+
};
|
|
58
|
+
|
|
18
59
|
export type validationOptions = {
|
|
19
60
|
/**
|
|
20
61
|
* Whether to allow attributes without value
|
|
@@ -31,6 +72,27 @@ export type validationOptions = {
|
|
|
31
72
|
unpairedTags?: string[];
|
|
32
73
|
|
|
33
74
|
docType?: docTypeOptions;
|
|
75
|
+
|
|
76
|
+
/** Opt-in checks for otherwise-legal-looking but suspicious sequences. */
|
|
77
|
+
invalidCharSequence?: invalidCharSequenceOptions;
|
|
78
|
+
|
|
79
|
+
/** Controls handling of the `<?xml ... ?>` prolog. */
|
|
80
|
+
xmlDeclaraion?: xmlDeclarationOptions;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Allow more than one root-level element (as if wrapped in a virtual root).
|
|
84
|
+
*
|
|
85
|
+
* Defaults to `false`.
|
|
86
|
+
*/
|
|
87
|
+
multipleRoots?: boolean;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Path expressions (see the `path-expression-matcher` package) for tags
|
|
91
|
+
* whose subtree should be excluded from validation entirely.
|
|
92
|
+
*
|
|
93
|
+
* Defaults to `[]`
|
|
94
|
+
*/
|
|
95
|
+
skipTags?: string[];
|
|
34
96
|
};
|
|
35
97
|
|
|
36
98
|
export type ValidationError = {
|
|
@@ -43,6 +105,8 @@ export type ValidationError = {
|
|
|
43
105
|
};
|
|
44
106
|
|
|
45
107
|
export class SyntaxValidator {
|
|
108
|
+
constructor(options?: validationOptions);
|
|
109
|
+
validate(xmlData: string): true;
|
|
46
110
|
static validate(xmlData: string, options?: validationOptions): true | ValidationError;
|
|
47
111
|
}
|
|
48
112
|
|
package/src/fxv.js
CHANGED
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import SyntaxValidator from './SyntaxValidator.js';
|
|
4
4
|
import BusinessRulesValidator from 'detailed-xml-validator';
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
static validate(xmlData, options) {
|
|
8
|
-
return validateSyntax(xmlData, options);
|
|
9
|
-
}
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export { SyntaxValidator, BusinessRulesValidator };
|
|
6
|
+
export { SyntaxValidator, BusinessRulesValidator };
|
package/src/util.js
CHANGED
|
@@ -25,9 +25,35 @@ export function isEmptyObject(obj) {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export function getValue(v) {
|
|
28
|
-
if (
|
|
28
|
+
if (isExist(v)) {
|
|
29
29
|
return v;
|
|
30
30
|
} else {
|
|
31
31
|
return '';
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
|
+
|
|
35
|
+
export function isWhiteSpace(char) {
|
|
36
|
+
return char === ' ' || char === '\t' || char === '\n' || char === '\r';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* True for character codes that are illegal as literal characters anywhere in
|
|
41
|
+
* an XML document (element text, attribute values, comments): 0x00-0x08,
|
|
42
|
+
* 0x0B, 0x0C, 0x0E-0x1F. Tab/LF/CR (0x09/0x0A/0x0D) are legal whitespace and
|
|
43
|
+
* are excluded.
|
|
44
|
+
*/
|
|
45
|
+
export function isIllegalControlCode(code) {
|
|
46
|
+
return code <= 8 || code === 11 || code === 12 || (code >= 14 && code <= 31);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getLineNumberForPosition(xmlData, index) {
|
|
50
|
+
const lines = xmlData.substring(0, index).split(/\r?\n/);
|
|
51
|
+
return {
|
|
52
|
+
line: lines.length,
|
|
53
|
+
col: lines[lines.length - 1].length + 1,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getPositionFromMatch(match) {
|
|
58
|
+
return match.startIndex + match[1].length;
|
|
59
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { getAllMatches, getPositionFromMatch } from '../util.js';
|
|
4
|
+
import ControlCharValidator from './ControlCharValidator.js';
|
|
5
|
+
|
|
6
|
+
const validAttrStrRegxp = new RegExp('(\\s*)([^\\s=]+)(\\s*=)?(\\s*([\'"])(([\\s\\S])*?)\\5)?', 'g');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* AttributeStringValidator — single responsibility: validate the raw
|
|
10
|
+
* attribute-expression substring of a start tag (everything between the tag
|
|
11
|
+
* name and the closing `>`/`/>`).
|
|
12
|
+
*
|
|
13
|
+
* Checks performed (always on):
|
|
14
|
+
* - proper whitespace separation between attributes
|
|
15
|
+
* - attribute names are valid QNames
|
|
16
|
+
* - no duplicate attribute names
|
|
17
|
+
* - `=` present without a value -> "without value"
|
|
18
|
+
* - value present but not quoted -> UnquotedAttributeValue
|
|
19
|
+
* - boolean attributes rejected unless `allowBooleanAttributes`
|
|
20
|
+
* - illegal control characters inside the attribute value
|
|
21
|
+
* - `xmlns:x=""` only allowed in XML 1.1
|
|
22
|
+
*
|
|
23
|
+
* Checks performed (opt-in via `invalidCharSequence.attrLt`):
|
|
24
|
+
* - literal `<` inside an attribute value
|
|
25
|
+
*/
|
|
26
|
+
export default class AttributeStringValidator {
|
|
27
|
+
/**
|
|
28
|
+
* @param {object} options - the full validation options object
|
|
29
|
+
* @param {import('./XmlNameValidator.js').default} nameValidator
|
|
30
|
+
* @param {string} xmlVersion
|
|
31
|
+
*/
|
|
32
|
+
constructor(options, nameValidator, xmlVersion) {
|
|
33
|
+
this.options = options;
|
|
34
|
+
this.nameValidator = nameValidator;
|
|
35
|
+
this.xmlVersion = xmlVersion;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {string} attrStr
|
|
40
|
+
* @returns {true | { err: { code: string, msg: string, line: number } }}
|
|
41
|
+
*/
|
|
42
|
+
validate(attrStr) {
|
|
43
|
+
const matches = getAllMatches(attrStr, validAttrStrRegxp);
|
|
44
|
+
const attrNames = {};
|
|
45
|
+
const checkAttrLt = this.options.invalidCharSequence?.attrLt === true;
|
|
46
|
+
|
|
47
|
+
for (let i = 0; i < matches.length; i++) {
|
|
48
|
+
const m = matches[i];
|
|
49
|
+
|
|
50
|
+
if (m[1].length === 0) {
|
|
51
|
+
return this._err('InvalidAttr', "Attribute '" + m[2] + "' has no space in starting.", m);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const hasEquals = m[3] !== undefined;
|
|
55
|
+
const hasQuotedValue = m[4] !== undefined;
|
|
56
|
+
|
|
57
|
+
if (hasEquals && !hasQuotedValue) {
|
|
58
|
+
// Either `attr=` with nothing after it, or `attr=value` where value
|
|
59
|
+
// isn't wrapped in quotes — XML requires every attribute value to be
|
|
60
|
+
// quoted, so both shapes are rejected as UnquotedAttributeValue.
|
|
61
|
+
return this._err(
|
|
62
|
+
'UnquotedAttributeValue',
|
|
63
|
+
"Attribute '" + m[2] + "' is without value.",
|
|
64
|
+
m
|
|
65
|
+
);
|
|
66
|
+
} else if (!hasEquals && !this.options.allowBooleanAttributes) {
|
|
67
|
+
return this._err('InvalidAttr', "boolean attribute '" + m[2] + "' is not allowed.", m);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const attrName = m[2];
|
|
71
|
+
|
|
72
|
+
if (!this.nameValidator.isValidQName(attrName)) {
|
|
73
|
+
return this._err('InvalidAttr', "Attribute '" + attrName + "' is an invalid name.", m);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const attrValue = m[6] || '';
|
|
77
|
+
|
|
78
|
+
if (hasQuotedValue) {
|
|
79
|
+
const illegalIdx = ControlCharValidator.findIllegalIndex(attrValue);
|
|
80
|
+
if (illegalIdx !== -1) {
|
|
81
|
+
const code = attrValue.charCodeAt(illegalIdx);
|
|
82
|
+
return this._err(
|
|
83
|
+
'IllegalCharacter',
|
|
84
|
+
`Illegal control character ${ControlCharValidator.describe(code)} in attribute '${attrName}' value.`,
|
|
85
|
+
m
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (checkAttrLt && attrValue.indexOf('<') !== -1) {
|
|
90
|
+
return this._err('InvalidAttr', `Attribute '${attrName}' value must not contain '<'.`, m);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (attrName.startsWith('xmlns:') && m[6] === '' && this.xmlVersion === '1.0') {
|
|
95
|
+
return this._err(
|
|
96
|
+
'InvalidAttr',
|
|
97
|
+
`Undeclaring the prefixed namespace ${attrName}="" is only permitted in XML 1.1 documents.`,
|
|
98
|
+
m
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!Object.prototype.hasOwnProperty.call(attrNames, attrName)) {
|
|
103
|
+
attrNames[attrName] = 1;
|
|
104
|
+
} else {
|
|
105
|
+
return this._err('InvalidAttr', "Attribute '" + attrName + "' is repeated.", m);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
_err(code, msg, match) {
|
|
113
|
+
return { err: { code, msg, line: getPositionFromMatch(match) } };
|
|
114
|
+
}
|
|
115
|
+
}
|