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
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import ControlCharValidator from './ControlCharValidator.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* CommentCdataReader — single responsibility: recognize and consume
|
|
7
|
+
* `<!-- comment -->`, `<![CDATA[ ... ]]>` and `<!DOCTYPE ...>` constructs
|
|
8
|
+
* starting at a given index (which points at the `!`), returning the index
|
|
9
|
+
* of the last character consumed (the closing `>`).
|
|
10
|
+
*
|
|
11
|
+
* DOCTYPE handling is fully delegated to a DocTypeValidator instance passed
|
|
12
|
+
* to `read()` by the caller (SyntaxValidator), since DOCTYPE parsing is a
|
|
13
|
+
* large, separate concern in its own right.
|
|
14
|
+
*
|
|
15
|
+
* Opt-in checks (via `invalidCharSequence`):
|
|
16
|
+
* - `comment: true` -> literal `--` inside a comment's body is rejected
|
|
17
|
+
* (besides the sequence immediately before the closing `-->`).
|
|
18
|
+
*/
|
|
19
|
+
export default class CommentCdataReader {
|
|
20
|
+
constructor(options = {}) {
|
|
21
|
+
this.checkCommentDashes = options.invalidCharSequence?.comment === true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {string} xmlData
|
|
26
|
+
* @param {number} i - index of the '!' character
|
|
27
|
+
* @param {import('./DocTypeValidator.js').default} docTypeValidator
|
|
28
|
+
* @returns {number} index of the last character consumed
|
|
29
|
+
*/
|
|
30
|
+
read(xmlData, i, docTypeValidator) {
|
|
31
|
+
if (xmlData.length > i + 2 && xmlData[i + 1] === '-' && xmlData[i + 2] === '-') {
|
|
32
|
+
return this._readComment(xmlData, i);
|
|
33
|
+
} else if (
|
|
34
|
+
xmlData.length > i + 7 &&
|
|
35
|
+
xmlData[i + 1] === 'D' &&
|
|
36
|
+
xmlData[i + 2] === 'O' &&
|
|
37
|
+
xmlData[i + 3] === 'C' &&
|
|
38
|
+
xmlData[i + 4] === 'T' &&
|
|
39
|
+
xmlData[i + 5] === 'Y' &&
|
|
40
|
+
xmlData[i + 6] === 'P' &&
|
|
41
|
+
xmlData[i + 7] === 'E'
|
|
42
|
+
) {
|
|
43
|
+
return docTypeValidator.validateDocType(xmlData, i - 1);
|
|
44
|
+
} else if (
|
|
45
|
+
xmlData.length > i + 8 &&
|
|
46
|
+
xmlData[i + 1] === '[' &&
|
|
47
|
+
xmlData[i + 2] === 'C' &&
|
|
48
|
+
xmlData[i + 3] === 'D' &&
|
|
49
|
+
xmlData[i + 4] === 'A' &&
|
|
50
|
+
xmlData[i + 5] === 'T' &&
|
|
51
|
+
xmlData[i + 6] === 'A' &&
|
|
52
|
+
xmlData[i + 7] === '['
|
|
53
|
+
) {
|
|
54
|
+
return this._readCdata(xmlData, i);
|
|
55
|
+
} else {
|
|
56
|
+
throw new Error("Invalid construct starting with '<!'.");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
_readComment(xmlData, i) {
|
|
61
|
+
const bodyStart = i + 3;
|
|
62
|
+
for (i = bodyStart; i < xmlData.length; i++) {
|
|
63
|
+
if (xmlData[i] === '-' && xmlData[i + 1] === '-' && xmlData[i + 2] === '>') {
|
|
64
|
+
i += 2;
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (xmlData[i] !== '>') {
|
|
69
|
+
throw new Error('Comment is not closed with "-->".');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const body = xmlData.substring(bodyStart, i - 2);
|
|
73
|
+
const illegalIdx = ControlCharValidator.findIllegalIndex(body);
|
|
74
|
+
if (illegalIdx !== -1) {
|
|
75
|
+
const code = body.charCodeAt(illegalIdx);
|
|
76
|
+
throw new Error(`Illegal control character ${ControlCharValidator.describe(code)} in comment.`);
|
|
77
|
+
}
|
|
78
|
+
if (this.checkCommentDashes && body.indexOf('--') !== -1) {
|
|
79
|
+
throw new Error(`Comment must not contain '--'.`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return i;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
_readCdata(xmlData, i) {
|
|
86
|
+
const bodyStart = i + 8;
|
|
87
|
+
for (i = bodyStart; i < xmlData.length; i++) {
|
|
88
|
+
if (xmlData[i] === ']' && xmlData[i + 1] === ']' && xmlData[i + 2] === '>') {
|
|
89
|
+
i += 2;
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (xmlData[i] !== '>') {
|
|
94
|
+
throw new Error('CDATA section is not closed with "]]>".');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const body = xmlData.substring(bodyStart, i - 2);
|
|
98
|
+
const illegalIdx = ControlCharValidator.findIllegalIndex(body);
|
|
99
|
+
if (illegalIdx !== -1) {
|
|
100
|
+
const code = body.charCodeAt(illegalIdx);
|
|
101
|
+
throw new Error(`Illegal control character ${ControlCharValidator.describe(code)} in CDATA section.`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return i;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { isIllegalControlCode } from '../util.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* ControlCharValidator — single responsibility: detect illegal control
|
|
7
|
+
* characters (0x00-0x08, 0x0B, 0x0C, 0x0E-0x1F) anywhere they appear as
|
|
8
|
+
* literal characters in the document (element text, attribute values,
|
|
9
|
+
* comments, CDATA, PI content, etc.).
|
|
10
|
+
*
|
|
11
|
+
* This check is always on — it is not gated by any option — since illegal
|
|
12
|
+
* control characters make a document non-well-formed per the XML spec.
|
|
13
|
+
*/
|
|
14
|
+
export default class ControlCharValidator {
|
|
15
|
+
/**
|
|
16
|
+
* Scan `str` for the first illegal control character.
|
|
17
|
+
* @param {string} str
|
|
18
|
+
* @returns {number} local index (within `str`) of the first offending
|
|
19
|
+
* character, or -1 if none found.
|
|
20
|
+
*/
|
|
21
|
+
static findIllegalIndex(str) {
|
|
22
|
+
const len = str.length;
|
|
23
|
+
for (let i = 0; i < len; i++) {
|
|
24
|
+
if (isIllegalControlCode(str.charCodeAt(i))) return i;
|
|
25
|
+
}
|
|
26
|
+
return -1;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {string} str
|
|
31
|
+
* @returns {boolean}
|
|
32
|
+
*/
|
|
33
|
+
static hasIllegalChar(str) {
|
|
34
|
+
return this.findIllegalIndex(str) !== -1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Convenience formatter for error messages.
|
|
39
|
+
* @param {number} code - char code
|
|
40
|
+
*/
|
|
41
|
+
static describe(code) {
|
|
42
|
+
return `0x${code.toString(16).padStart(2, '0')}`;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { name as isXmlName } from 'xml-naming';
|
|
2
|
+
|
|
3
|
+
const defaultOptions = {
|
|
4
|
+
maxEntityCount: Infinity,
|
|
5
|
+
maxEntitySize: Infinity,
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export default class DocTypeValidator {
|
|
9
|
+
constructor(options, xmlVersion = '1.0') {
|
|
10
|
+
this.options = Object.assign({}, defaultOptions, options);
|
|
11
|
+
this.suppressValidationErr = !options;
|
|
12
|
+
this.xmlVersion = xmlVersion;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
validateDocType(xmlData, i) {
|
|
16
|
+
if (
|
|
17
|
+
xmlData[i + 3] !== 'O' ||
|
|
18
|
+
xmlData[i + 4] !== 'C' ||
|
|
19
|
+
xmlData[i + 5] !== 'T' ||
|
|
20
|
+
xmlData[i + 6] !== 'Y' ||
|
|
21
|
+
xmlData[i + 7] !== 'P' ||
|
|
22
|
+
xmlData[i + 8] !== 'E'
|
|
23
|
+
) {
|
|
24
|
+
throw new Error(`Invalid Tag instead of DOCTYPE`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
i += 9;
|
|
28
|
+
let angleBracketsCount = 1;
|
|
29
|
+
let hasBody = false;
|
|
30
|
+
let comment = false;
|
|
31
|
+
let entityCount = 0;
|
|
32
|
+
|
|
33
|
+
for (; i < xmlData.length; i++) {
|
|
34
|
+
if (xmlData[i] === '<' && !comment) {
|
|
35
|
+
if (hasBody && hasSeq(xmlData, '!ENTITY', i)) {
|
|
36
|
+
i += 7;
|
|
37
|
+
[entityCount, i] = this._validateEntityExp(xmlData, i + 1, entityCount);
|
|
38
|
+
// continue;
|
|
39
|
+
} else if (hasBody && hasSeq(xmlData, '!ELEMENT', i)) {
|
|
40
|
+
i += 8;
|
|
41
|
+
i = this._validateElementExp(xmlData, i + 1);
|
|
42
|
+
// continue;
|
|
43
|
+
} else if (hasBody && hasSeq(xmlData, '!ATTLIST', i)) {
|
|
44
|
+
i += 8;
|
|
45
|
+
i = this._validateAttlistExp(xmlData, i + 1);
|
|
46
|
+
continue;
|
|
47
|
+
} else if (hasBody && hasSeq(xmlData, '!NOTATION', i)) {
|
|
48
|
+
i += 9;
|
|
49
|
+
i = this._validateNotationExp(xmlData, i + 1);
|
|
50
|
+
// continue;
|
|
51
|
+
} else if (hasSeq(xmlData, '!--', i)) {
|
|
52
|
+
comment = true;
|
|
53
|
+
} else {
|
|
54
|
+
throw new Error(`Invalid DOCTYPE`);
|
|
55
|
+
}
|
|
56
|
+
angleBracketsCount++;
|
|
57
|
+
} else if (xmlData[i] === '>') {
|
|
58
|
+
if (comment) {
|
|
59
|
+
if (xmlData[i - 1] === '-' && xmlData[i - 2] === '-') {
|
|
60
|
+
comment = false;
|
|
61
|
+
angleBracketsCount--;
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
angleBracketsCount--;
|
|
65
|
+
}
|
|
66
|
+
if (angleBracketsCount === 0) break;
|
|
67
|
+
} else if (xmlData[i] === '[') {
|
|
68
|
+
hasBody = true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (angleBracketsCount !== 0) {
|
|
73
|
+
throw new Error(`Unclosed DOCTYPE`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return i;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
_validateEntityExp(xmlData, i, entityCount) {
|
|
80
|
+
i = skipWhitespace(xmlData, i);
|
|
81
|
+
|
|
82
|
+
// Read and validate entity name
|
|
83
|
+
const nameStart = i;
|
|
84
|
+
while (i < xmlData.length && !/\s/.test(xmlData[i]) && xmlData[i] !== '"' && xmlData[i] !== "'") {
|
|
85
|
+
i++;
|
|
86
|
+
}
|
|
87
|
+
const entityName = xmlData.substring(nameStart, i);
|
|
88
|
+
validateName(entityName, this.xmlVersion);
|
|
89
|
+
|
|
90
|
+
i = skipWhitespace(xmlData, i);
|
|
91
|
+
|
|
92
|
+
// Check for unsupported constructs
|
|
93
|
+
if (!this.suppressValidationErr) {
|
|
94
|
+
if (xmlData.substring(i, i + 6).toUpperCase() === 'SYSTEM') {
|
|
95
|
+
throw new Error('External entities are not supported');
|
|
96
|
+
} else if (xmlData[i] === '%') {
|
|
97
|
+
throw new Error('Parameter entities are not supported');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Read and validate the entity value (validates quoting + size limit)
|
|
102
|
+
let entityValue;
|
|
103
|
+
[i, entityValue] = readQuotedValue(xmlData, i, 'entity');
|
|
104
|
+
|
|
105
|
+
const { maxEntitySize, maxEntityCount } = this.options;
|
|
106
|
+
|
|
107
|
+
if (entityValue.length > maxEntitySize) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Entity "${entityName}" size (${entityValue.length}) exceeds maximum allowed size (${maxEntitySize})`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Only count non-parameter entities (parameter entities contain '&')
|
|
114
|
+
if (entityValue.indexOf('&') === -1) {
|
|
115
|
+
entityCount++;
|
|
116
|
+
if (entityCount > maxEntityCount) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`Entity count (${entityCount}) exceeds maximum allowed (${maxEntityCount})`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return [entityCount, i - 1];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
_validateNotationExp(xmlData, i) {
|
|
127
|
+
i = skipWhitespace(xmlData, i);
|
|
128
|
+
|
|
129
|
+
// Read and validate notation name
|
|
130
|
+
const nameStart = i;
|
|
131
|
+
while (i < xmlData.length && !/\s/.test(xmlData[i])) i++;
|
|
132
|
+
const notationName = xmlData.substring(nameStart, i);
|
|
133
|
+
!this.suppressValidationErr && validateName(notationName, this.xmlVersion);
|
|
134
|
+
|
|
135
|
+
i = skipWhitespace(xmlData, i);
|
|
136
|
+
|
|
137
|
+
const identifierType = xmlData.substring(i, i + 6).toUpperCase();
|
|
138
|
+
if (!this.suppressValidationErr && identifierType !== 'SYSTEM' && identifierType !== 'PUBLIC') {
|
|
139
|
+
throw new Error(`Expected SYSTEM or PUBLIC, found "${identifierType}"`);
|
|
140
|
+
}
|
|
141
|
+
i += identifierType.length;
|
|
142
|
+
i = skipWhitespace(xmlData, i);
|
|
143
|
+
|
|
144
|
+
if (identifierType === 'PUBLIC') {
|
|
145
|
+
[i] = readQuotedValue(xmlData, i, 'publicIdentifier');
|
|
146
|
+
i = skipWhitespace(xmlData, i);
|
|
147
|
+
if (xmlData[i] === '"' || xmlData[i] === "'") {
|
|
148
|
+
[i] = readQuotedValue(xmlData, i, 'systemIdentifier');
|
|
149
|
+
}
|
|
150
|
+
} else if (identifierType === 'SYSTEM') {
|
|
151
|
+
[i] = readQuotedValue(xmlData, i, 'systemIdentifier');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return --i;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
_validateElementExp(xmlData, i) {
|
|
158
|
+
i = skipWhitespace(xmlData, i);
|
|
159
|
+
|
|
160
|
+
// Read and validate element name
|
|
161
|
+
const nameStart = i;
|
|
162
|
+
while (i < xmlData.length && !/\s/.test(xmlData[i])) i++;
|
|
163
|
+
const elementName = xmlData.substring(nameStart, i);
|
|
164
|
+
if (!this.suppressValidationErr && !isXmlName(elementName, { xmlVersion: this.xmlVersion })) {
|
|
165
|
+
throw new Error(`Invalid element name: "${elementName}"`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
i = skipWhitespace(xmlData, i);
|
|
169
|
+
|
|
170
|
+
// Validate content model keyword or group
|
|
171
|
+
if (xmlData[i] === 'E' && hasSeq(xmlData, 'MPTY', i)) {
|
|
172
|
+
i += 4;
|
|
173
|
+
} else if (xmlData[i] === 'A' && hasSeq(xmlData, 'NY', i)) {
|
|
174
|
+
i += 2;
|
|
175
|
+
} else if (xmlData[i] === '(') {
|
|
176
|
+
i++;
|
|
177
|
+
while (i < xmlData.length && xmlData[i] !== ')') i++;
|
|
178
|
+
if (xmlData[i] !== ')') throw new Error('Unterminated content model');
|
|
179
|
+
} else if (!this.suppressValidationErr) {
|
|
180
|
+
throw new Error(`Invalid Element Expression, found "${xmlData[i]}"`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return i;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
_validateAttlistExp(xmlData, i) {
|
|
187
|
+
i = skipWhitespace(xmlData, i);
|
|
188
|
+
|
|
189
|
+
// Read element name
|
|
190
|
+
const elemNameStart = i;
|
|
191
|
+
while (i < xmlData.length && !/\s/.test(xmlData[i])) i++;
|
|
192
|
+
const elementName = xmlData.substring(elemNameStart, i);
|
|
193
|
+
validateName(elementName, this.xmlVersion);
|
|
194
|
+
|
|
195
|
+
i = skipWhitespace(xmlData, i);
|
|
196
|
+
|
|
197
|
+
// Read attribute name
|
|
198
|
+
const attrNameStart = i;
|
|
199
|
+
while (i < xmlData.length && !/\s/.test(xmlData[i])) i++;
|
|
200
|
+
const attributeName = xmlData.substring(attrNameStart, i);
|
|
201
|
+
validateName(attributeName, this.xmlVersion);
|
|
202
|
+
|
|
203
|
+
i = skipWhitespace(xmlData, i);
|
|
204
|
+
|
|
205
|
+
// Read attribute type
|
|
206
|
+
if (xmlData.substring(i, i + 8).toUpperCase() === 'NOTATION') {
|
|
207
|
+
i += 8;
|
|
208
|
+
i = skipWhitespace(xmlData, i);
|
|
209
|
+
if (xmlData[i] !== '(') throw new Error(`Expected '(', found "${xmlData[i]}"`);
|
|
210
|
+
i++;
|
|
211
|
+
while (i < xmlData.length && xmlData[i] !== ')') {
|
|
212
|
+
const notationStart = i;
|
|
213
|
+
while (i < xmlData.length && xmlData[i] !== '|' && xmlData[i] !== ')') i++;
|
|
214
|
+
const notation = xmlData.substring(notationStart, i).trim();
|
|
215
|
+
validateName(notation, this.xmlVersion);
|
|
216
|
+
if (xmlData[i] === '|') {
|
|
217
|
+
i++;
|
|
218
|
+
i = skipWhitespace(xmlData, i);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (xmlData[i] !== ')') throw new Error('Unterminated list of notations');
|
|
222
|
+
i++;
|
|
223
|
+
} else {
|
|
224
|
+
const typeStart = i;
|
|
225
|
+
while (i < xmlData.length && !/\s/.test(xmlData[i])) i++;
|
|
226
|
+
const attributeType = xmlData.substring(typeStart, i);
|
|
227
|
+
const validTypes = ['CDATA', 'ID', 'IDREF', 'IDREFS', 'ENTITY', 'ENTITIES', 'NMTOKEN', 'NMTOKENS'];
|
|
228
|
+
if (!this.suppressValidationErr && !validTypes.includes(attributeType.toUpperCase())) {
|
|
229
|
+
throw new Error(`Invalid attribute type: "${attributeType}"`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
i = skipWhitespace(xmlData, i);
|
|
234
|
+
|
|
235
|
+
// Read default value declaration
|
|
236
|
+
if (xmlData.substring(i, i + 9).toUpperCase() === '#REQUIRED') {
|
|
237
|
+
i += 9;
|
|
238
|
+
} else if (xmlData.substring(i, i + 8).toUpperCase() === '#IMPLIED') {
|
|
239
|
+
i += 8;
|
|
240
|
+
} else {
|
|
241
|
+
[i] = readQuotedValue(xmlData, i, 'ATTLIST default value');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return i;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
function skipWhitespace(data, index) {
|
|
251
|
+
while (index < data.length && /\s/.test(data[index])) index++;
|
|
252
|
+
return index;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function hasSeq(data, seq, i) {
|
|
256
|
+
for (let j = 0; j < seq.length; j++) {
|
|
257
|
+
if (seq[j] !== data[i + j + 1]) return false;
|
|
258
|
+
}
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function validateName(name, xmlVersion = '1.0') {
|
|
263
|
+
if (!isXmlName(name, { xmlVersion })) throw new Error(`Invalid entity name "${name}"`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Read a single- or double-quoted string and return [nextIndex, value].
|
|
268
|
+
*/
|
|
269
|
+
function readQuotedValue(xmlData, i, type) {
|
|
270
|
+
const startChar = xmlData[i];
|
|
271
|
+
if (startChar !== '"' && startChar !== "'") {
|
|
272
|
+
throw new Error(`Expected quoted string for ${type}, found "${startChar}"`);
|
|
273
|
+
}
|
|
274
|
+
i++;
|
|
275
|
+
const start = i;
|
|
276
|
+
while (i < xmlData.length && xmlData[i] !== startChar) i++;
|
|
277
|
+
if (xmlData[i] !== startChar) throw new Error(`Unterminated ${type} value`);
|
|
278
|
+
const value = xmlData.substring(start, i);
|
|
279
|
+
return [++i, value];
|
|
280
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* RawSubtreeReader — single responsibility: given the index right after a
|
|
5
|
+
* skipped tag's opening `>`, consume raw characters until the literal
|
|
6
|
+
* closing tag `</tagName>` (optional whitespace before `>`) is found.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately does NOT try to parse anything in between: skipped content is
|
|
9
|
+
* assumed to be non-XML or otherwise not meant to be validated (script/style
|
|
10
|
+
* bodies, vendor blobs, etc.), so it may contain unmatched `<`, quotes, or
|
|
11
|
+
* anything else. This mirrors how CDATA content is treated — opaque text,
|
|
12
|
+
* found by looking for its one true terminator.
|
|
13
|
+
*
|
|
14
|
+
* Same-name tags never "nest" under this strategy — the first `</tagName>`
|
|
15
|
+
* found ends the skip, matching the common script/style use case. Use a more
|
|
16
|
+
* specific skip expression (e.g. targeting only the outermost occurrence) if
|
|
17
|
+
* your skipped content can itself contain literal `</tagName>` text.
|
|
18
|
+
*/
|
|
19
|
+
export default class RawSubtreeReader {
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} xmlData
|
|
22
|
+
* @param {string} tagName
|
|
23
|
+
* @param {number} i - index right after the opening tag's '>'
|
|
24
|
+
* @returns {number} index of the matching closing tag's own '>'
|
|
25
|
+
*/
|
|
26
|
+
static consume(xmlData, tagName, i) {
|
|
27
|
+
const needle = '</' + tagName;
|
|
28
|
+
let searchFrom = i;
|
|
29
|
+
|
|
30
|
+
while (true) {
|
|
31
|
+
const idx = xmlData.indexOf(needle, searchFrom);
|
|
32
|
+
if (idx === -1) {
|
|
33
|
+
throw new Error(`Unclosed skipped tag '${tagName}'.`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let j = idx + needle.length;
|
|
37
|
+
while (j < xmlData.length && /\s/.test(xmlData[j])) j++;
|
|
38
|
+
|
|
39
|
+
if (xmlData[j] === '>') {
|
|
40
|
+
return j;
|
|
41
|
+
}
|
|
42
|
+
// Not actually a match (e.g. tagName is a prefix of a longer name) —
|
|
43
|
+
// keep looking further in the string.
|
|
44
|
+
searchFrom = idx + needle.length;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { Expression, Matcher } from 'path-expression-matcher';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* SkipTagMatcher — single responsibility: decide whether a tag currently
|
|
7
|
+
* being opened should be excluded from validation, based on a list of path
|
|
8
|
+
* expressions supplied via `options.skipTags` (same expression syntax as
|
|
9
|
+
* `path-expression-matcher`, e.g. `"..script"`, `"root.data.raw"`,
|
|
10
|
+
* `"row[type=raw]"`).
|
|
11
|
+
*
|
|
12
|
+
* Skip decisions are only ever made for tags reached through normal
|
|
13
|
+
* (non-skipped) parsing — once a match is found, the caller
|
|
14
|
+
* (`SyntaxValidator`) consumes the whole matched subtree as opaque raw text
|
|
15
|
+
* (mirroring how CDATA is treated) and calls `pop()` once for that tag, so
|
|
16
|
+
* this matcher's path stack never needs to represent the skipped subtree's
|
|
17
|
+
* internal structure.
|
|
18
|
+
*/
|
|
19
|
+
export default class SkipTagMatcher {
|
|
20
|
+
/**
|
|
21
|
+
* @param {Array<string>} [skipTags]
|
|
22
|
+
*/
|
|
23
|
+
constructor(skipTags = []) {
|
|
24
|
+
this._expressions = skipTags.map((expr) => new Expression(expr));
|
|
25
|
+
this._matcher = new Matcher();
|
|
26
|
+
this._enabled = this._expressions.length > 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get enabled() {
|
|
30
|
+
return this._enabled;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Push the tag currently being opened onto the path and report whether it
|
|
35
|
+
* (and its whole subtree) should be skipped.
|
|
36
|
+
* @param {string} tagName
|
|
37
|
+
* @param {Record<string,string>} [rawAttributes] - for `[attr=value]` conditions
|
|
38
|
+
* @returns {boolean}
|
|
39
|
+
*/
|
|
40
|
+
push(tagName, rawAttributes) {
|
|
41
|
+
if (!this._enabled) return false;
|
|
42
|
+
this._matcher.push(tagName, rawAttributes || null);
|
|
43
|
+
for (let i = 0; i < this._expressions.length; i++) {
|
|
44
|
+
if (this._matcher.matches(this._expressions[i])) return true;
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Pop the most recently pushed tag off the path (tag fully closed). */
|
|
50
|
+
pop() {
|
|
51
|
+
if (!this._enabled) return;
|
|
52
|
+
this._matcher.pop();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* XmlDeclarationValidator — single responsibility: parse and validate the
|
|
5
|
+
* `<?xml version="..." encoding="..." standalone="..."?>` prolog.
|
|
6
|
+
*
|
|
7
|
+
* Behaviour is controlled by the `xmlDeclaraion` config block:
|
|
8
|
+
*
|
|
9
|
+
* xmlDeclaraion: {
|
|
10
|
+
* optional: true, // when false, the document MUST start with a decl
|
|
11
|
+
* argPosition: true, // when true, version/encoding/standalone must
|
|
12
|
+
* // appear in that fixed order (spec-mandated);
|
|
13
|
+
* // set false to allow any order.
|
|
14
|
+
* }
|
|
15
|
+
*
|
|
16
|
+
* (`argPostion` — the spelling used in the original design note — is also
|
|
17
|
+
* accepted as an alias for `argPosition`.)
|
|
18
|
+
*/
|
|
19
|
+
export default class XmlDeclarationValidator {
|
|
20
|
+
constructor(options = {}, throwError) {
|
|
21
|
+
this.optional = options.optional !== undefined ? options.optional : true;
|
|
22
|
+
this.argPosition = options.argPosition !== undefined
|
|
23
|
+
? options.argPosition
|
|
24
|
+
: (options.argPostion !== undefined ? options.argPostion : true);
|
|
25
|
+
this._throwError = throwError;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {string} xmlData - full document (BOM already stripped)
|
|
30
|
+
* @returns {{ version: string, end: number }} version defaults to '1.0'
|
|
31
|
+
* and end is 0 when there is no declaration present.
|
|
32
|
+
*/
|
|
33
|
+
validate(xmlData) {
|
|
34
|
+
const hasDecl = xmlData.startsWith('<?xml');
|
|
35
|
+
|
|
36
|
+
if (!hasDecl) {
|
|
37
|
+
if (!this.optional) {
|
|
38
|
+
this._throwError('InvalidXml', 'XML declaration is required but missing.', { line: 1, col: 1 });
|
|
39
|
+
}
|
|
40
|
+
return { version: '1.0', end: 0 };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const piEnd = xmlData.indexOf('?>');
|
|
44
|
+
if (piEnd === -1) {
|
|
45
|
+
this._throwError('InvalidXml', 'XML declaration is not closed with "?>".', { line: 1, col: 1 });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const declBody = xmlData.substring(5, piEnd);
|
|
49
|
+
const end = piEnd + 2;
|
|
50
|
+
|
|
51
|
+
const ALLOWED = ['version', 'encoding', 'standalone'];
|
|
52
|
+
const attrRe = /\s+([\w]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
|
53
|
+
|
|
54
|
+
let version = '1.0';
|
|
55
|
+
let lastAllowedIdx = -1;
|
|
56
|
+
let match;
|
|
57
|
+
|
|
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;
|
|
62
|
+
|
|
63
|
+
const allowedIdx = ALLOWED.indexOf(attrName);
|
|
64
|
+
if (allowedIdx === -1) {
|
|
65
|
+
this._throwError('InvalidXml',
|
|
66
|
+
'XML declaration contains unknown attribute "' + attrName + '". Allowed: version, encoding, standalone.',
|
|
67
|
+
{ line: 1, col: 6 + attrPos });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (this.argPosition && allowedIdx <= lastAllowedIdx) {
|
|
71
|
+
this._throwError('InvalidXml',
|
|
72
|
+
'XML declaration attribute "' + attrName + '" is out of order. Required order: version, encoding, standalone.',
|
|
73
|
+
{ line: 1, col: 6 + attrPos });
|
|
74
|
+
}
|
|
75
|
+
lastAllowedIdx = allowedIdx;
|
|
76
|
+
|
|
77
|
+
if (attrName === 'version') {
|
|
78
|
+
if (attrValue !== '1.0' && attrValue !== '1.1') {
|
|
79
|
+
this._throwError('InvalidXml',
|
|
80
|
+
'XML declaration version "' + attrValue + '" is not supported. Must be "1.0" or "1.1".',
|
|
81
|
+
{ line: 1, col: 6 + attrPos });
|
|
82
|
+
}
|
|
83
|
+
version = attrValue;
|
|
84
|
+
} else if (attrName === 'standalone') {
|
|
85
|
+
if (attrValue !== 'yes' && attrValue !== 'no') {
|
|
86
|
+
this._throwError('InvalidXml',
|
|
87
|
+
'XML declaration standalone "' + attrValue + '" is invalid. Must be "yes" or "no".',
|
|
88
|
+
{ line: 1, col: 6 + attrPos });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// encoding: value format validation is out of scope
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const leftover = declBody.replace(/\s+[\w]+\s*=\s*(?:"[^"]*"|'[^']*')/g, '').trim();
|
|
95
|
+
if (leftover.length > 0) {
|
|
96
|
+
this._throwError('InvalidXml',
|
|
97
|
+
'XML declaration contains invalid content: "' + leftover + '".',
|
|
98
|
+
{ line: 1, col: 1 });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { version, end };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { createValidator } from 'xml-naming';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* XmlNameValidator — single responsibility: expose XML Name / QName
|
|
7
|
+
* validators for a given xmlVersion, memoized per instance since element
|
|
8
|
+
* and attribute names repeat heavily across a document.
|
|
9
|
+
*/
|
|
10
|
+
export default class XmlNameValidator {
|
|
11
|
+
constructor(xmlVersion = '1.0') {
|
|
12
|
+
this.xmlVersion = xmlVersion;
|
|
13
|
+
this.qName = createValidator('qName', { xmlVersion });
|
|
14
|
+
this.name = createValidator('name', { xmlVersion });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
isValidQName(value) {
|
|
18
|
+
return this.qName(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
isValidName(value) {
|
|
22
|
+
return this.name(value);
|
|
23
|
+
}
|
|
24
|
+
}
|