@safeaccess/inline 0.1.1
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/.gitattributes +16 -0
- package/.gitkeep +0 -0
- package/CHANGELOG.md +38 -0
- package/LICENSE +21 -0
- package/README.md +454 -0
- package/benchmarks/get.bench.ts +26 -0
- package/benchmarks/parse.bench.ts +41 -0
- package/dist/accessors/abstract-accessor.d.ts +213 -0
- package/dist/accessors/abstract-accessor.js +294 -0
- package/dist/accessors/formats/any-accessor.d.ts +35 -0
- package/dist/accessors/formats/any-accessor.js +44 -0
- package/dist/accessors/formats/array-accessor.d.ts +26 -0
- package/dist/accessors/formats/array-accessor.js +39 -0
- package/dist/accessors/formats/env-accessor.d.ts +27 -0
- package/dist/accessors/formats/env-accessor.js +64 -0
- package/dist/accessors/formats/ini-accessor.d.ts +41 -0
- package/dist/accessors/formats/ini-accessor.js +109 -0
- package/dist/accessors/formats/json-accessor.d.ts +26 -0
- package/dist/accessors/formats/json-accessor.js +56 -0
- package/dist/accessors/formats/ndjson-accessor.d.ts +28 -0
- package/dist/accessors/formats/ndjson-accessor.js +71 -0
- package/dist/accessors/formats/object-accessor.d.ts +48 -0
- package/dist/accessors/formats/object-accessor.js +90 -0
- package/dist/accessors/formats/xml-accessor.d.ts +27 -0
- package/dist/accessors/formats/xml-accessor.js +52 -0
- package/dist/accessors/formats/yaml-accessor.d.ts +29 -0
- package/dist/accessors/formats/yaml-accessor.js +46 -0
- package/dist/contracts/accessors-interface.d.ts +11 -0
- package/dist/contracts/accessors-interface.js +1 -0
- package/dist/contracts/factory-accessors-interface.d.ts +16 -0
- package/dist/contracts/factory-accessors-interface.js +1 -0
- package/dist/contracts/parse-integration-interface.d.ts +31 -0
- package/dist/contracts/parse-integration-interface.js +1 -0
- package/dist/contracts/path-cache-interface.d.ts +40 -0
- package/dist/contracts/path-cache-interface.js +1 -0
- package/dist/contracts/readable-accessors-interface.d.ts +79 -0
- package/dist/contracts/readable-accessors-interface.js +1 -0
- package/dist/contracts/security-guard-interface.d.ts +40 -0
- package/dist/contracts/security-guard-interface.js +1 -0
- package/dist/contracts/security-parser-interface.d.ts +67 -0
- package/dist/contracts/security-parser-interface.js +1 -0
- package/dist/contracts/writable-accessors-interface.d.ts +65 -0
- package/dist/contracts/writable-accessors-interface.js +1 -0
- package/dist/core/dot-notation-parser.d.ts +204 -0
- package/dist/core/dot-notation-parser.js +343 -0
- package/dist/exceptions/accessor-exception.d.ts +13 -0
- package/dist/exceptions/accessor-exception.js +16 -0
- package/dist/exceptions/invalid-format-exception.d.ts +14 -0
- package/dist/exceptions/invalid-format-exception.js +17 -0
- package/dist/exceptions/parser-exception.d.ts +14 -0
- package/dist/exceptions/parser-exception.js +17 -0
- package/dist/exceptions/path-not-found-exception.d.ts +14 -0
- package/dist/exceptions/path-not-found-exception.js +17 -0
- package/dist/exceptions/readonly-violation-exception.d.ts +15 -0
- package/dist/exceptions/readonly-violation-exception.js +18 -0
- package/dist/exceptions/security-exception.d.ts +18 -0
- package/dist/exceptions/security-exception.js +21 -0
- package/dist/exceptions/unsupported-type-exception.d.ts +14 -0
- package/dist/exceptions/unsupported-type-exception.js +17 -0
- package/dist/exceptions/yaml-parse-exception.d.ts +17 -0
- package/dist/exceptions/yaml-parse-exception.js +20 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +30 -0
- package/dist/inline.d.ts +402 -0
- package/dist/inline.js +512 -0
- package/dist/parser/xml-parser.d.ts +46 -0
- package/dist/parser/xml-parser.js +288 -0
- package/dist/parser/yaml-parser.d.ts +94 -0
- package/dist/parser/yaml-parser.js +286 -0
- package/dist/security/forbidden-keys.d.ts +34 -0
- package/dist/security/forbidden-keys.js +80 -0
- package/dist/security/security-guard.d.ts +94 -0
- package/dist/security/security-guard.js +172 -0
- package/dist/security/security-parser.d.ts +130 -0
- package/dist/security/security-parser.js +192 -0
- package/dist/type-format.d.ts +28 -0
- package/dist/type-format.js +29 -0
- package/eslint.config.js +1 -0
- package/package.json +39 -0
- package/src/accessors/abstract-accessor.ts +353 -0
- package/src/accessors/formats/any-accessor.ts +51 -0
- package/src/accessors/formats/array-accessor.ts +45 -0
- package/src/accessors/formats/env-accessor.ts +79 -0
- package/src/accessors/formats/ini-accessor.ts +124 -0
- package/src/accessors/formats/json-accessor.ts +66 -0
- package/src/accessors/formats/ndjson-accessor.ts +82 -0
- package/src/accessors/formats/object-accessor.ts +100 -0
- package/src/accessors/formats/xml-accessor.ts +58 -0
- package/src/accessors/formats/yaml-accessor.ts +52 -0
- package/src/contracts/accessors-interface.ts +12 -0
- package/src/contracts/factory-accessors-interface.ts +16 -0
- package/src/contracts/parse-integration-interface.ts +32 -0
- package/src/contracts/path-cache-interface.ts +43 -0
- package/src/contracts/readable-accessors-interface.ts +88 -0
- package/src/contracts/security-guard-interface.ts +43 -0
- package/src/contracts/security-parser-interface.ts +74 -0
- package/src/contracts/writable-accessors-interface.ts +70 -0
- package/src/core/dot-notation-parser.ts +419 -0
- package/src/exceptions/accessor-exception.ts +16 -0
- package/src/exceptions/invalid-format-exception.ts +18 -0
- package/src/exceptions/parser-exception.ts +18 -0
- package/src/exceptions/path-not-found-exception.ts +18 -0
- package/src/exceptions/readonly-violation-exception.ts +19 -0
- package/src/exceptions/security-exception.ts +22 -0
- package/src/exceptions/unsupported-type-exception.ts +18 -0
- package/src/exceptions/yaml-parse-exception.ts +21 -0
- package/src/index.ts +46 -0
- package/src/inline.ts +570 -0
- package/src/parser/xml-parser.ts +334 -0
- package/src/parser/yaml-parser.ts +368 -0
- package/src/security/forbidden-keys.ts +81 -0
- package/src/security/security-guard.ts +195 -0
- package/src/security/security-parser.ts +233 -0
- package/src/type-format.ts +28 -0
- package/stryker.config.json +24 -0
- package/tests/accessors/accessors.test.ts +1017 -0
- package/tests/accessors/json-accessor.test.ts +171 -0
- package/tests/core/dot-notation-parser.test.ts +587 -0
- package/tests/exceptions/parser-exception.test.ts +31 -0
- package/tests/inline.test.ts +445 -0
- package/tests/mocks/fake-parse-integration.ts +24 -0
- package/tests/mocks/fake-path-cache.ts +31 -0
- package/tests/parity.test.ts +164 -0
- package/tests/parser/xml-parser.test.ts +618 -0
- package/tests/parser/yaml-parser.test.ts +463 -0
- package/tests/security/security-guard.test.ts +646 -0
- package/tests/security/security-parser.test.ts +391 -0
- package/tsconfig.json +16 -0
- package/vitest.config.ts +19 -0
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { XmlParser } from '../../src/parser/xml-parser.js';
|
|
3
|
+
import { InvalidFormatException } from '../../src/exceptions/invalid-format-exception.js';
|
|
4
|
+
import { SecurityException } from '../../src/exceptions/security-exception.js';
|
|
5
|
+
|
|
6
|
+
function makeParser(maxDepth = 10): XmlParser {
|
|
7
|
+
return new XmlParser(maxDepth);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type FakeAttr = { name: string; value: string };
|
|
11
|
+
type FakeNode = {
|
|
12
|
+
nodeType: number;
|
|
13
|
+
textContent?: string;
|
|
14
|
+
nodeName?: string;
|
|
15
|
+
attributes?: FakeAttrs;
|
|
16
|
+
childNodes?: FakeChildNodes;
|
|
17
|
+
};
|
|
18
|
+
type FakeAttrs = { length: number; [index: number]: FakeAttr | undefined };
|
|
19
|
+
type FakeChildNodes = { length: number; [index: number]: FakeNode | undefined };
|
|
20
|
+
|
|
21
|
+
function makeTextNode(text: string): FakeNode {
|
|
22
|
+
return { nodeType: 3, textContent: text };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makeElement(name: string, children: FakeNode[] = [], attrs: FakeAttr[] = []): FakeNode {
|
|
26
|
+
const attributes: FakeAttrs = { length: attrs.length };
|
|
27
|
+
attrs.forEach((a, i) => {
|
|
28
|
+
attributes[i] = a;
|
|
29
|
+
});
|
|
30
|
+
const childNodes: FakeChildNodes = { length: children.length };
|
|
31
|
+
children.forEach((c, i) => {
|
|
32
|
+
childNodes[i] = c;
|
|
33
|
+
});
|
|
34
|
+
return { nodeType: 1, nodeName: name, attributes, childNodes };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function stubDomParser(root: FakeNode | null, hasParserError = false): void {
|
|
38
|
+
const parserErrorEl = hasParserError ? { textContent: 'parse failed' } : null;
|
|
39
|
+
vi.stubGlobal(
|
|
40
|
+
'DOMParser',
|
|
41
|
+
class {
|
|
42
|
+
parseFromString(): unknown {
|
|
43
|
+
return {
|
|
44
|
+
querySelector: (sel: string) => (sel === 'parsererror' ? parserErrorEl : null),
|
|
45
|
+
documentElement: root,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe(XmlParser.name, () => {
|
|
53
|
+
it('parses a single child element', () => {
|
|
54
|
+
expect(makeParser().parse('<root><name>Alice</name></root>')).toEqual({
|
|
55
|
+
name: 'Alice',
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('parses multiple sibling elements', () => {
|
|
60
|
+
expect(makeParser().parse('<root><name>Alice</name><age>30</age></root>')).toEqual({
|
|
61
|
+
name: 'Alice',
|
|
62
|
+
age: '30',
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('returns empty object for whitespace-only root content', () => {
|
|
67
|
+
expect(makeParser().parse('<root> </root>')).toEqual({});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('returns empty object for self-closing root element', () => {
|
|
71
|
+
expect(makeParser().parse('<root/>')).toEqual({});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('returns empty object for single-character self-closing root element', () => {
|
|
75
|
+
expect(makeParser().parse('<r/>')).toEqual({});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('strips XML declaration header before parsing', () => {
|
|
79
|
+
expect(makeParser().parse('<?xml version="1.0"?><root><key>value</key></root>')).toEqual({
|
|
80
|
+
key: 'value',
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('throws InvalidFormatException for non-XML string', () => {
|
|
85
|
+
expect(() => makeParser().parse('not xml at all')).toThrow(InvalidFormatException);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('throws InvalidFormatException with message for non-XML string', () => {
|
|
89
|
+
expect(() => makeParser().parse('not xml at all')).toThrow(
|
|
90
|
+
/XmlAccessor failed to parse XML string/i,
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('ignores leading and trailing whitespace around XML', () => {
|
|
95
|
+
expect(makeParser().parse(' <root><key>value</key></root> ')).toEqual({ key: 'value' });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('returns empty object for self-closing root with surrounding whitespace', () => {
|
|
99
|
+
expect(makeParser().parse(' <root/> ')).toEqual({});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('throws for XML with non-XML prefix', () => {
|
|
103
|
+
expect(() => makeParser().parse('header<root>value</root>')).toThrow(
|
|
104
|
+
InvalidFormatException,
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('throws for self-closing XML with non-XML prefix', () => {
|
|
109
|
+
expect(() => makeParser().parse('header<root/>')).toThrow(InvalidFormatException);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('throws for XML with trailing garbage after close tag', () => {
|
|
113
|
+
expect(() => makeParser().parse('<root>val</root><extra>')).toThrow(InvalidFormatException);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('throws for self-closing root with trailing content', () => {
|
|
117
|
+
expect(() => makeParser().parse('<root/><extra>')).toThrow(InvalidFormatException);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('parses root element with attributes', () => {
|
|
121
|
+
expect(makeParser().parse('<root id="1"><key>value</key></root>')).toEqual({
|
|
122
|
+
key: 'value',
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('parses self-closing root element with attributes', () => {
|
|
127
|
+
expect(makeParser().parse('<root id="1"/>')).toEqual({});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('parses self-closing root with space before closing >', () => {
|
|
131
|
+
expect(makeParser().parse('<root/ >')).toEqual({});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('parses root where closing tag has trailing whitespace before >', () => {
|
|
135
|
+
expect(makeParser().parse('<root><key>value</key></root >')).toEqual({ key: 'value' });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('throws InvalidFormatException for opening tag without closing tag', () => {
|
|
139
|
+
expect(() => makeParser().parse('<root><unclosed>')).toThrow(InvalidFormatException);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('throws InvalidFormatException when closing tag is embedded inside opening tag body (closeTagStart <= openGt)', () => {
|
|
143
|
+
// <abc</abc> — backward scan finds 'abc' at the end, confirms '</abc',
|
|
144
|
+
// but closeTagStart (4) is <= openGt (9), meaning the close marker is
|
|
145
|
+
// inside the opening-tag span — structurally impossible, must throw
|
|
146
|
+
expect(() => makeParser().parse('<abc</abc>')).toThrow(InvalidFormatException);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('throws InvalidFormatException when document does not end with > (no closing tag)', () => {
|
|
150
|
+
// '<root>unclosed text' ends with 't', not '>' — triggers the
|
|
151
|
+
// doc[doc.length - 1] !== '>' guard in extractRootContent
|
|
152
|
+
expect(() => makeParser().parse('<root>unclosed text')).toThrow(InvalidFormatException);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('throws InvalidFormatException when opening tag has no closing > at all', () => {
|
|
156
|
+
// '<root' has no '>' — openGt === -1 guard in extractRootContent
|
|
157
|
+
expect(() => makeParser().parse('<root')).toThrow(InvalidFormatException);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('throws InvalidFormatException when tag name found at end but not preceded by </ (space before tag name)', () => {
|
|
161
|
+
// '<root>text root>' — backward scan finds 'root' at the end but
|
|
162
|
+
// the preceding char is ' ' not '/', triggering the </ guard
|
|
163
|
+
expect(() => makeParser().parse('<root>text root>')).toThrow(InvalidFormatException);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe(`${XmlParser.name} > nested elements`, () => {
|
|
168
|
+
it('parses two-level nesting', () => {
|
|
169
|
+
expect(makeParser().parse('<root><user><name>Alice</name></user></root>')).toEqual({
|
|
170
|
+
user: { name: 'Alice' },
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('parses three-level nesting', () => {
|
|
175
|
+
expect(makeParser().parse('<root><a><b><c>deep</c></b></a></root>')).toEqual({
|
|
176
|
+
a: { b: { c: 'deep' } },
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('returns plain string when child has only text content', () => {
|
|
181
|
+
const result = makeParser().parse('<root><title>Hello World</title></root>');
|
|
182
|
+
expect(result['title']).toBe('Hello World');
|
|
183
|
+
expect(typeof result['title']).toBe('string');
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe(`${XmlParser.name} > duplicate sibling elements`, () => {
|
|
188
|
+
it('merges two duplicate siblings into an array', () => {
|
|
189
|
+
const result = makeParser().parse('<root><item>a</item><item>b</item></root>');
|
|
190
|
+
expect(result['item']).toEqual(['a', 'b']);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('merges three duplicate siblings into an array of 3', () => {
|
|
194
|
+
const result = makeParser().parse(
|
|
195
|
+
'<root><item>a</item><item>b</item><item>c</item></root>',
|
|
196
|
+
);
|
|
197
|
+
expect(Array.isArray(result['item'])).toBe(true);
|
|
198
|
+
expect((result['item'] as unknown[]).length).toBe(3);
|
|
199
|
+
expect((result['item'] as unknown[])[2]).toBe('c');
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe(`${XmlParser.name} > security — depth limit`, () => {
|
|
204
|
+
it('parses successfully when depth equals maxDepth', () => {
|
|
205
|
+
const result = new XmlParser(1).parse('<root><a>value</a></root>');
|
|
206
|
+
expect(result['a']).toBe('value');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('parses successfully when nested depth exactly equals maxDepth (manual path)', () => {
|
|
210
|
+
const result = new XmlParser(1).parse('<root><a><b>leaf</b></a></root>');
|
|
211
|
+
expect((result['a'] as Record<string, unknown>)['b']).toBe('leaf');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('throws SecurityException when nesting exceeds maxDepth', () => {
|
|
215
|
+
expect(() => new XmlParser(1).parse('<root><a><b><c>deep</c></b></a></root>')).toThrow(
|
|
216
|
+
SecurityException,
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('includes actual depth and maxDepth in SecurityException message', () => {
|
|
221
|
+
expect(() => new XmlParser(1).parse('<root><a><b><c>deep</c></b></a></root>')).toThrow(
|
|
222
|
+
/XML structural depth \d+ exceeds maximum of \d+/i,
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('includes depth value 2 in SecurityException message when maxDepth is 1', () => {
|
|
227
|
+
expect(() => new XmlParser(1).parse('<root><a><b><c>deep</c></b></a></root>')).toThrow(/2/);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('throws SecurityException with maxDepth=0 when nesting is encountered', () => {
|
|
231
|
+
expect(() => new XmlParser(0).parse('<root><a><b>value</b></a></root>')).toThrow(
|
|
232
|
+
SecurityException,
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe(`${XmlParser.name} > security — element count limit (maxElements)`, () => {
|
|
238
|
+
it('throws SecurityException when element count exceeds custom maxElements', () => {
|
|
239
|
+
const xml = '<root>' + '<item>x</item>'.repeat(3) + '</root>';
|
|
240
|
+
expect(() => new XmlParser(10, 2).parse(xml)).toThrow(SecurityException);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('does not throw when element count equals maxElements', () => {
|
|
244
|
+
// <root> + 3 × <item> = 4 opening tags counted by the guard
|
|
245
|
+
const xml = '<root>' + '<item>x</item>'.repeat(3) + '</root>';
|
|
246
|
+
expect(() => new XmlParser(10, 4).parse(xml)).not.toThrow();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('includes element count and limit in SecurityException message', () => {
|
|
250
|
+
const xml = '<root>' + '<item>x</item>'.repeat(3) + '</root>';
|
|
251
|
+
expect(() => new XmlParser(10, 2).parse(xml)).toThrow(
|
|
252
|
+
/XML element count \d+ exceeds maximum of \d+/i,
|
|
253
|
+
);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe(`${XmlParser.name} > constructor — maxElements clamping (SEC-017)`, () => {
|
|
258
|
+
it('clamps NaN to 10 000 so the element guard still fires at 10 000', () => {
|
|
259
|
+
const xml = '<root>' + '<item>x</item>'.repeat(10_001) + '</root>';
|
|
260
|
+
expect(() => new XmlParser(100, NaN).parse(xml)).toThrow(SecurityException);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('does not throw for NaN when element count is within the clamped default limit', () => {
|
|
264
|
+
const xml = '<root><item>x</item></root>';
|
|
265
|
+
expect(() => new XmlParser(100, NaN).parse(xml)).not.toThrow();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('clamps Infinity to 10 000 so the element guard still fires at 10 000', () => {
|
|
269
|
+
const xml = '<root>' + '<item>x</item>'.repeat(10_001) + '</root>';
|
|
270
|
+
expect(() => new XmlParser(100, Infinity).parse(xml)).toThrow(SecurityException);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('clamps zero to 10 000 so the element guard still fires at 10 000', () => {
|
|
274
|
+
const xml = '<root>' + '<item>x</item>'.repeat(10_001) + '</root>';
|
|
275
|
+
expect(() => new XmlParser(100, 0).parse(xml)).toThrow(SecurityException);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('clamps negative values to 10 000 so the element guard still fires at 10 000', () => {
|
|
279
|
+
const xml = '<root>' + '<item>x</item>'.repeat(10_001) + '</root>';
|
|
280
|
+
expect(() => new XmlParser(100, -1).parse(xml)).toThrow(SecurityException);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('accepts a valid positive finite maxElements and enforces it', () => {
|
|
284
|
+
const xml = '<root>' + '<item>x</item>'.repeat(5) + '</root>';
|
|
285
|
+
expect(() => new XmlParser(100, 4).parse(xml)).toThrow(SecurityException);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('uses the provided positive finite maxElements when within limit — no exception', () => {
|
|
289
|
+
const xml = '<root>' + '<item>x</item>'.repeat(3) + '</root>';
|
|
290
|
+
expect(() => new XmlParser(100, 10).parse(xml)).not.toThrow();
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
describe(`${XmlParser.name} > manual parser edge cases`, () => {
|
|
295
|
+
it('maps self-closing child element to empty string', () => {
|
|
296
|
+
const result = makeParser().parse('<root><empty/><name>Alice</name></root>');
|
|
297
|
+
expect(result['empty']).toBe('');
|
|
298
|
+
expect(result['name']).toBe('Alice');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('parses child element with attributes discarding them', () => {
|
|
302
|
+
const result = makeParser().parse('<root><item id="1">value</item></root>');
|
|
303
|
+
expect(result['item']).toBe('value');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('parses self-closing child element with attributes', () => {
|
|
307
|
+
const result = makeParser().parse('<root><flag enabled="true"/><name>Alice</name></root>');
|
|
308
|
+
expect(result['flag']).toBe('');
|
|
309
|
+
expect(result['name']).toBe('Alice');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('recurses into self-closing child elements nested inside parent', () => {
|
|
313
|
+
expect(makeParser().parse('<root><parent><empty/></parent></root>')).toEqual({
|
|
314
|
+
parent: { empty: '' },
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('trims whitespace from text content in child elements', () => {
|
|
319
|
+
expect(makeParser().parse('<root><item> hello </item></root>')).toEqual({
|
|
320
|
+
item: 'hello',
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('does not flatten nested elements to #text when childResult has multiple keys', () => {
|
|
325
|
+
const result = makeParser().parse('<root><parent><a>1</a><b>2</b></parent></root>');
|
|
326
|
+
const parent = result['parent'] as Record<string, unknown>;
|
|
327
|
+
expect(parent['a']).toBe('1');
|
|
328
|
+
expect(parent['b']).toBe('2');
|
|
329
|
+
expect('#text' in parent).toBe(false);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('parses text content containing partial element-like syntax as plain text', () => {
|
|
333
|
+
const result = makeParser().parse('<root><item>hello <world</item></root>');
|
|
334
|
+
expect(result['item']).toBe('hello <world');
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
describe(`${XmlParser.name} > browser DOMParser path`, () => {
|
|
339
|
+
afterEach(() => {
|
|
340
|
+
vi.unstubAllGlobals();
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('delegates to DOMParser when available', () => {
|
|
344
|
+
const root = makeElement('root', [makeElement('name', [makeTextNode('Alice')])]);
|
|
345
|
+
stubDomParser(root);
|
|
346
|
+
expect(makeParser().parse('<root><name>Alice</name></root>')).toEqual({
|
|
347
|
+
name: { '#text': 'Alice' },
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('passes application/xml as MIME type to DOMParser', () => {
|
|
352
|
+
let capturedType = '';
|
|
353
|
+
vi.stubGlobal(
|
|
354
|
+
'DOMParser',
|
|
355
|
+
class {
|
|
356
|
+
parseFromString(_xml: string, type: string): unknown {
|
|
357
|
+
capturedType = type;
|
|
358
|
+
return {
|
|
359
|
+
querySelector: () => null,
|
|
360
|
+
documentElement: makeElement('root', []),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
);
|
|
365
|
+
makeParser().parse('<root/>');
|
|
366
|
+
expect(capturedType).toBe('application/xml');
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('throws InvalidFormatException when DOMParser reports parsererror', () => {
|
|
370
|
+
stubDomParser(null, true);
|
|
371
|
+
expect(() => makeParser().parse('<root/>')).toThrow(InvalidFormatException);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('includes the parseerror detail in the exception message', () => {
|
|
375
|
+
stubDomParser(null, true);
|
|
376
|
+
expect(() => makeParser().parse('<root/>')).toThrow(/parse failed/);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('uses Unknown error when parseerror textContent is null', () => {
|
|
380
|
+
vi.stubGlobal(
|
|
381
|
+
'DOMParser',
|
|
382
|
+
class {
|
|
383
|
+
parseFromString(): unknown {
|
|
384
|
+
return {
|
|
385
|
+
querySelector: (sel: string) =>
|
|
386
|
+
sel === 'parsererror' ? { textContent: null } : null,
|
|
387
|
+
documentElement: null,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
);
|
|
392
|
+
expect(() => makeParser().parse('<root/>')).toThrow(/Unknown error/);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('returns empty object when documentElement is null', () => {
|
|
396
|
+
stubDomParser(null, false);
|
|
397
|
+
expect(makeParser().parse('<root/>')).toEqual({});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('maps element attributes with @ prefix', () => {
|
|
401
|
+
const root = makeElement('root', [], [{ name: 'id', value: '42' }]);
|
|
402
|
+
stubDomParser(root);
|
|
403
|
+
const result = makeParser().parse('<root id="42"/>');
|
|
404
|
+
expect(result['@id']).toBe('42');
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('ignores undefined attribute slots', () => {
|
|
408
|
+
const attrs: FakeAttrs = { length: 2, 0: { name: 'a', value: '1' }, 1: undefined };
|
|
409
|
+
const root: FakeNode = {
|
|
410
|
+
nodeType: 1,
|
|
411
|
+
nodeName: 'root',
|
|
412
|
+
attributes: attrs,
|
|
413
|
+
childNodes: { length: 0 },
|
|
414
|
+
};
|
|
415
|
+
stubDomParser(root);
|
|
416
|
+
expect(makeParser().parse('<root a="1"/>')).toEqual({ '@a': '1' });
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('captures non-empty text nodes as #text', () => {
|
|
420
|
+
const root = makeElement('root', [makeTextNode(' hello ')]);
|
|
421
|
+
stubDomParser(root);
|
|
422
|
+
const result = makeParser().parse('<root>hello</root>');
|
|
423
|
+
expect(result['#text']).toBe('hello');
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('ignores whitespace-only text nodes', () => {
|
|
427
|
+
const root = makeElement('root', [makeTextNode(' ')]);
|
|
428
|
+
stubDomParser(root);
|
|
429
|
+
expect(makeParser().parse('<root> </root>')).toEqual({});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('handles text node with null textContent gracefully', () => {
|
|
433
|
+
const nullTextNode: FakeNode = { nodeType: 3, textContent: null as unknown as string };
|
|
434
|
+
const root = makeElement('root', [nullTextNode]);
|
|
435
|
+
stubDomParser(root);
|
|
436
|
+
expect(makeParser().parse('<root/>')).toEqual({});
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('ignores undefined child node slots', () => {
|
|
440
|
+
const undef: FakeNode = undefined as unknown as FakeNode;
|
|
441
|
+
const childNodes: FakeChildNodes = { length: 2, 0: makeTextNode('hello'), 1: undef };
|
|
442
|
+
const root: FakeNode = {
|
|
443
|
+
nodeType: 1,
|
|
444
|
+
nodeName: 'root',
|
|
445
|
+
attributes: { length: 0 },
|
|
446
|
+
childNodes,
|
|
447
|
+
};
|
|
448
|
+
stubDomParser(root);
|
|
449
|
+
const result = makeParser().parse('<root/>');
|
|
450
|
+
expect(result['#text']).toBe('hello');
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('merges duplicate child elements into an array via DOMParser', () => {
|
|
454
|
+
const root = makeElement('root', [
|
|
455
|
+
makeElement('item', [makeTextNode('a')]),
|
|
456
|
+
makeElement('item', [makeTextNode('b')]),
|
|
457
|
+
]);
|
|
458
|
+
stubDomParser(root);
|
|
459
|
+
const result = makeParser().parse('<root><item>a</item><item>b</item></root>');
|
|
460
|
+
expect(result['item']).toEqual([{ '#text': 'a' }, { '#text': 'b' }]);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it('pushes into existing array when third duplicate appears', () => {
|
|
464
|
+
const root = makeElement('root', [
|
|
465
|
+
makeElement('item', [makeTextNode('a')]),
|
|
466
|
+
makeElement('item', [makeTextNode('b')]),
|
|
467
|
+
makeElement('item', [makeTextNode('c')]),
|
|
468
|
+
]);
|
|
469
|
+
stubDomParser(root);
|
|
470
|
+
const result = makeParser().parse(
|
|
471
|
+
'<root><item>a</item><item>b</item><item>c</item></root>',
|
|
472
|
+
);
|
|
473
|
+
expect(Array.isArray(result['item'])).toBe(true);
|
|
474
|
+
expect((result['item'] as unknown[]).length).toBe(3);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('returns element with only #text key as-is (not flattened)', () => {
|
|
478
|
+
const root = makeElement('root', [makeTextNode('only-text')]);
|
|
479
|
+
stubDomParser(root);
|
|
480
|
+
const result = makeParser().parse('<root>only-text</root>');
|
|
481
|
+
expect(result['#text']).toBe('only-text');
|
|
482
|
+
expect(Object.keys(result).length).toBe(1);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('does not throw when DOM element depth exactly equals maxDepth', () => {
|
|
486
|
+
const child = makeElement('child', [makeTextNode('v')]);
|
|
487
|
+
const root = makeElement('root', [child]);
|
|
488
|
+
stubDomParser(root);
|
|
489
|
+
expect(() => new XmlParser(1).parse('<root/>')).not.toThrow();
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('throws SecurityException when DOM element depth exceeds maxDepth', () => {
|
|
493
|
+
const deep = makeElement('c', [makeTextNode('v')]);
|
|
494
|
+
const mid = makeElement('b', [deep]);
|
|
495
|
+
const root = makeElement('root', [makeElement('a', [mid])]);
|
|
496
|
+
stubDomParser(root);
|
|
497
|
+
expect(() => new XmlParser(1).parse('<root/>')).toThrow(SecurityException);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it('includes depth information in the SecurityException message', () => {
|
|
501
|
+
const deep = makeElement('c', [makeTextNode('v')]);
|
|
502
|
+
const mid = makeElement('b', [deep]);
|
|
503
|
+
const root = makeElement('root', [makeElement('a', [mid])]);
|
|
504
|
+
stubDomParser(root);
|
|
505
|
+
expect(() => new XmlParser(1).parse('<root/>')).toThrow(/exceed/i);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it('ignores child nodes with nodeType other than 1 or 3', () => {
|
|
509
|
+
const unknownNode: FakeNode = { nodeType: 8 };
|
|
510
|
+
const root = makeElement('root', [unknownNode, makeElement('name', [makeTextNode('Bob')])]);
|
|
511
|
+
stubDomParser(root);
|
|
512
|
+
const result = makeParser().parse('<root/>');
|
|
513
|
+
expect(result['name']).toEqual({ '#text': 'Bob' });
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
describe(`${XmlParser.name} > linear scanner — nesting counter`, () => {
|
|
518
|
+
it('extracts outer element when same-name elements nest (kills nestDepth++ mutant)', () => {
|
|
519
|
+
// nestDepth must be incremented at inner <a> so the first </a> does not
|
|
520
|
+
// prematurely close the outer element
|
|
521
|
+
const result = makeParser().parse('<root><a><a>inner</a>rest</a></root>');
|
|
522
|
+
const a = result['a'] as Record<string, unknown>;
|
|
523
|
+
expect(a['a']).toBe('inner');
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it('resolves 3-deep same-name nesting (kills off-by-one in nestDepth-- condition)', () => {
|
|
527
|
+
// nestDepth starts at 1, increments twice, decrements 3× — only
|
|
528
|
+
// when it hits exactly 0 should inner content be collected
|
|
529
|
+
const result = makeParser().parse('<root><a><a><a>deep</a></a></a></root>');
|
|
530
|
+
const a1 = result['a'] as Record<string, unknown>;
|
|
531
|
+
const a2 = a1['a'] as Record<string, unknown>;
|
|
532
|
+
expect(a2['a']).toBe('deep');
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('does not count a self-closing same-name tag as open nestDepth (kills self-closing increment mutant)', () => {
|
|
536
|
+
// <a/> inside <a>…</a> must NOT increment nestDepth; if it did, the first
|
|
537
|
+
// </a> would only bring nestDepth to 1 and the parser would scan past it
|
|
538
|
+
const result = makeParser().parse('<root><a><a/>text</a></root>');
|
|
539
|
+
const a = result['a'] as Record<string, unknown>;
|
|
540
|
+
expect(a['a']).toBe('');
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
describe(`${XmlParser.name} > linear scanner — self-closing detection`, () => {
|
|
545
|
+
it('treats <tag /> (spaces before />) as self-closing (kills trimEnd mutant)', () => {
|
|
546
|
+
const result = makeParser().parse('<root><empty /></root>');
|
|
547
|
+
expect(result['empty']).toBe('');
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('treats <tag attr="v" /> as self-closing (attribute + space + /)', () => {
|
|
551
|
+
const result = makeParser().parse('<root><flag enabled="true" /></root>');
|
|
552
|
+
expect(result['flag']).toBe('');
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
describe(`${XmlParser.name} > linear scanner — skip non-element tokens`, () => {
|
|
557
|
+
it('skips XML comment nodes inside children (kills nextChar === "!" mutant)', () => {
|
|
558
|
+
const result = makeParser().parse('<root><!-- comment --><name>Alice</name></root>');
|
|
559
|
+
expect(result['name']).toBe('Alice');
|
|
560
|
+
expect(Object.keys(result)).toEqual(['name']);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it('skips processing instructions inside children (kills nextChar === "?" mutant)', () => {
|
|
564
|
+
const result = makeParser().parse('<root><?pi data?><name>Bob</name></root>');
|
|
565
|
+
expect(result['name']).toBe('Bob');
|
|
566
|
+
expect(Object.keys(result)).toEqual(['name']);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it('skips stray closing tags inside children (kills nextChar === "/" mutant)', () => {
|
|
570
|
+
const result = makeParser().parse('<root></stray><name>Charlie</name></root>');
|
|
571
|
+
expect(result['name']).toBe('Charlie');
|
|
572
|
+
expect(Object.keys(result)).toEqual(['name']);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it('handles comment-like token with no closing > (gt === -1 ternary branch)', () => {
|
|
576
|
+
// '<!no close tag' in inner content — no '>' found, so i is set to
|
|
577
|
+
// content.length terminating the loop; content falls through as #text
|
|
578
|
+
const result = makeParser().parse('<root><!no close tag</root>');
|
|
579
|
+
expect(result['#text']).toBe('<!no close tag');
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it('skips child tag whose name starts with a digit (kills !\\[a-zA-Z_\\] continue branch)', () => {
|
|
583
|
+
// <1tag> — nextChar is '1', fails [a-zA-Z_] test; loop advances past it
|
|
584
|
+
// and the content falls through as #text
|
|
585
|
+
const result = makeParser().parse('<root><1tag>value</root>');
|
|
586
|
+
expect(result['#text']).toBe('<1tag>value');
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
describe(`${XmlParser.name} > linear scanner — unclosed and malformed tags`, () => {
|
|
591
|
+
it('skips an unclosed child and continues parsing siblings (kills innerEnd === -1 check mutant)', () => {
|
|
592
|
+
// <unclosed> has no </unclosed> — parser must skip it and continue
|
|
593
|
+
const result = makeParser().parse('<root><unclosed><name>Bob</name></root>');
|
|
594
|
+
expect(result['name']).toBe('Bob');
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it('accepts closing tag at end of string with no trailing > (c === undefined branch)', () => {
|
|
598
|
+
// </a is the last token with no > — charAfter is undefined; the undefined
|
|
599
|
+
// branch must accept this as the close tag or the inner value is lost
|
|
600
|
+
const result = makeParser().parse('<root><a>1</a</root>');
|
|
601
|
+
expect(result['a']).toBe('1');
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it('skips close-tag prefix that matches a longer tag name', () => {
|
|
605
|
+
// </a matches the prefix of </ab> — the char after </a is 'b', not
|
|
606
|
+
// a delimiter, so the scanner must skip it and keep looking for </a>
|
|
607
|
+
const result = makeParser().parse('<root><a><ab>inner</ab>rest</a></root>');
|
|
608
|
+
const a = result['a'] as Record<string, unknown>;
|
|
609
|
+
expect(a['ab']).toBe('inner');
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it('handles a trailing bare < in inner content gracefully (nextChar === undefined break)', () => {
|
|
613
|
+
// Bare < at the very end of inner content — nextChar is undefined, the
|
|
614
|
+
// outer loop must terminate without crashing
|
|
615
|
+
const result = makeParser().parse('<root><name>Alice</name><</root>');
|
|
616
|
+
expect(result['name']).toBe('Alice');
|
|
617
|
+
});
|
|
618
|
+
});
|