mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
fix: internalize xml parsing (#606)
This commit is contained in:
committed by
GitHub
parent
5083d04560
commit
46784f2e81
@@ -204,7 +204,6 @@
|
||||
"detox"
|
||||
],
|
||||
"dependencies": {
|
||||
"fast-xml-parser": "^5.7.2",
|
||||
"pngjs": "^7.0.0",
|
||||
"yaml": "^2.9.0"
|
||||
},
|
||||
|
||||
Generated
-37
@@ -8,9 +8,6 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
fast-xml-parser:
|
||||
specifier: ^5.7.2
|
||||
version: 5.7.2
|
||||
pngjs:
|
||||
specifier: ^7.0.0
|
||||
version: 7.0.0
|
||||
@@ -260,9 +257,6 @@ packages:
|
||||
'@emnapi/core': ^1.7.1
|
||||
'@emnapi/runtime': ^1.7.1
|
||||
|
||||
'@nodable/entities@2.1.0':
|
||||
resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==}
|
||||
|
||||
'@oxc-project/types@0.127.0':
|
||||
resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==}
|
||||
|
||||
@@ -1206,13 +1200,6 @@ packages:
|
||||
fast-uri@3.1.2:
|
||||
resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
|
||||
|
||||
fast-xml-builder@1.1.5:
|
||||
resolution: {integrity: sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==}
|
||||
|
||||
fast-xml-parser@5.7.2:
|
||||
resolution: {integrity: sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==}
|
||||
hasBin: true
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -1684,10 +1671,6 @@ packages:
|
||||
parse5@7.3.0:
|
||||
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
|
||||
|
||||
path-expression-matcher@1.5.0:
|
||||
resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
path-parse@1.0.7:
|
||||
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
||||
|
||||
@@ -1930,9 +1913,6 @@ packages:
|
||||
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
strnum@2.2.3:
|
||||
resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==}
|
||||
|
||||
style-to-js@1.1.21:
|
||||
resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
|
||||
|
||||
@@ -2371,8 +2351,6 @@ snapshots:
|
||||
'@tybys/wasm-util': 0.10.1
|
||||
optional: true
|
||||
|
||||
'@nodable/entities@2.1.0': {}
|
||||
|
||||
'@oxc-project/types@0.127.0': {}
|
||||
|
||||
'@oxfmt/binding-android-arm-eabi@0.42.0':
|
||||
@@ -3139,17 +3117,6 @@ snapshots:
|
||||
|
||||
fast-uri@3.1.2: {}
|
||||
|
||||
fast-xml-builder@1.1.5:
|
||||
dependencies:
|
||||
path-expression-matcher: 1.5.0
|
||||
|
||||
fast-xml-parser@5.7.2:
|
||||
dependencies:
|
||||
'@nodable/entities': 2.1.0
|
||||
fast-xml-builder: 1.1.5
|
||||
path-expression-matcher: 1.5.0
|
||||
strnum: 2.2.3
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.4):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.4
|
||||
@@ -3965,8 +3932,6 @@ snapshots:
|
||||
dependencies:
|
||||
entities: 6.0.1
|
||||
|
||||
path-expression-matcher@1.5.0: {}
|
||||
|
||||
path-parse@1.0.7: {}
|
||||
|
||||
pathe@2.0.3: {}
|
||||
@@ -4251,8 +4216,6 @@ snapshots:
|
||||
dependencies:
|
||||
ansi-regex: 6.2.2
|
||||
|
||||
strnum@2.2.3: {}
|
||||
|
||||
style-to-js@1.1.21:
|
||||
dependencies:
|
||||
style-to-object: 1.0.14
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'vitest';
|
||||
|
||||
import { parseXmlDocumentSync } from '../xml.ts';
|
||||
|
||||
test('parseXmlDocumentSync preserves ordered nodes with attributes and decoded text', () => {
|
||||
const nodes = parseXmlDocumentSync(
|
||||
[
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<plist version="1.0">',
|
||||
'<dict>',
|
||||
'<key>CFBundleDisplayName</key>',
|
||||
'<string escaped=""yes"">Example & App</string>',
|
||||
'<empty enabled="true"/>',
|
||||
'</dict>',
|
||||
'</plist>',
|
||||
].join(''),
|
||||
);
|
||||
|
||||
assert.deepEqual(nodes, [
|
||||
{
|
||||
name: 'plist',
|
||||
attributes: { version: '1.0' },
|
||||
text: null,
|
||||
children: [
|
||||
{
|
||||
name: 'dict',
|
||||
attributes: {},
|
||||
text: null,
|
||||
children: [
|
||||
{ name: 'key', attributes: {}, text: 'CFBundleDisplayName', children: [] },
|
||||
{
|
||||
name: 'string',
|
||||
attributes: { escaped: '"yes"' },
|
||||
text: 'Example & App',
|
||||
children: [],
|
||||
},
|
||||
{ name: 'empty', attributes: { enabled: 'true' }, text: null, children: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseXmlDocumentSync reads cdata text and skips declarations', () => {
|
||||
const nodes = parseXmlDocumentSync(
|
||||
[
|
||||
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
||||
'<root>',
|
||||
'<!-- ignored -->',
|
||||
'<value><![CDATA[ <raw>&text</raw> ]]></value>',
|
||||
'</root>',
|
||||
].join(''),
|
||||
);
|
||||
|
||||
assert.equal(nodes[0]?.children[0]?.text, '<raw>&text</raw>');
|
||||
});
|
||||
|
||||
test('parseXmlDocumentSync skips UTF-8 byte order marks', () => {
|
||||
const nodes = parseXmlDocumentSync('\uFEFF<root/>');
|
||||
|
||||
assert.equal(nodes[0]?.name, 'root');
|
||||
});
|
||||
|
||||
test('parseXmlDocumentSync rejects mismatched closing tags', () => {
|
||||
assert.throws(() => parseXmlDocumentSync('<root><child></root>'), /Expected <\/child>/);
|
||||
});
|
||||
|
||||
test('parseXmlDocumentSync does not expand custom doctype entities', () => {
|
||||
const nodes = parseXmlDocumentSync(
|
||||
'<!DOCTYPE root [<!ENTITY secret "expanded">]><root>&secret;</root>',
|
||||
);
|
||||
|
||||
assert.equal(nodes[0]?.text, '&secret;');
|
||||
});
|
||||
|
||||
test('parseXmlDocumentSync rejects unsafe attribute names', () => {
|
||||
for (const attributeName of [
|
||||
'__defineGetter__',
|
||||
'__defineSetter__',
|
||||
'__proto__',
|
||||
'constructor',
|
||||
'prototype',
|
||||
]) {
|
||||
assert.throws(
|
||||
() => parseXmlDocumentSync(`<root ${attributeName}="polluted"/>`),
|
||||
new RegExp(`Unsupported XML attribute name "${attributeName}"`),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('parseXmlDocumentSync rejects excessive nesting depth', () => {
|
||||
const xml = `${'<node>'.repeat(257)}${'</node>'.repeat(257)}`;
|
||||
|
||||
assert.throws(() => parseXmlDocumentSync(xml), /Maximum XML nesting depth/);
|
||||
});
|
||||
|
||||
test('parseXmlDocumentSync rejects documents above the configured size limit', () => {
|
||||
assert.throws(
|
||||
() => parseXmlDocumentSync('<root>oversized</root>', { maxDocumentChars: 10 }),
|
||||
/XML document exceeds maximum supported size of 10 characters/,
|
||||
);
|
||||
});
|
||||
+322
-51
@@ -1,5 +1,3 @@
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
|
||||
export type XmlNode = {
|
||||
name: string;
|
||||
attributes: Record<string, string>;
|
||||
@@ -7,17 +5,31 @@ export type XmlNode = {
|
||||
children: XmlNode[];
|
||||
};
|
||||
|
||||
let xmlParser: XMLParser | null = null;
|
||||
const MAX_XML_NESTING_DEPTH = 256;
|
||||
const MAX_XML_DOCUMENT_CHARS = 128 * 1024 * 1024;
|
||||
const XML_NAME_CHARS = new Set(
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.:-',
|
||||
);
|
||||
const XML_WHITESPACE_CHARS = new Set([' ', '\t', '\n', '\r']);
|
||||
const UNSAFE_XML_ATTRIBUTE_NAMES = new Set([
|
||||
'__defineGetter__',
|
||||
'__defineSetter__',
|
||||
'__proto__',
|
||||
'constructor',
|
||||
'prototype',
|
||||
]);
|
||||
|
||||
export function parseXmlDocumentSync(xml: string): XmlNode[] {
|
||||
xmlParser ??= new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '',
|
||||
preserveOrder: true,
|
||||
trimValues: true,
|
||||
parseTagValue: false,
|
||||
});
|
||||
return normalizeXmlNodes(xmlParser.parse(xml));
|
||||
export function parseXmlDocumentSync(
|
||||
xml: string,
|
||||
options: { maxDocumentChars?: number } = {},
|
||||
): XmlNode[] {
|
||||
const maxDocumentChars = options.maxDocumentChars ?? MAX_XML_DOCUMENT_CHARS;
|
||||
if (xml.length > maxDocumentChars) {
|
||||
throw new Error(
|
||||
`XML document exceeds maximum supported size of ${maxDocumentChars} characters.`,
|
||||
);
|
||||
}
|
||||
return new LimitedXmlParser(xml).parse();
|
||||
}
|
||||
|
||||
export function visitXmlPlistEntries(
|
||||
@@ -38,50 +50,309 @@ export function visitXmlPlistEntries(
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeXmlNodes(value: unknown): XmlNode[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const nodes: XmlNode[] = [];
|
||||
for (const entry of value) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
||||
const record = entry as Record<string, unknown>;
|
||||
for (const [name, childValue] of Object.entries(record)) {
|
||||
if (name === ':@' || name === '#text') continue;
|
||||
nodes.push({
|
||||
name,
|
||||
attributes: normalizeXmlAttributes(record[':@']),
|
||||
text: readXmlText(childValue) ?? readXmlText(record['#text']),
|
||||
children: normalizeXmlNodes(childValue),
|
||||
});
|
||||
class LimitedXmlParser {
|
||||
private readonly roots: XmlNode[] = [];
|
||||
private readonly stack: XmlNode[] = [];
|
||||
private index = 0;
|
||||
private readonly xml: string;
|
||||
|
||||
constructor(xml: string) {
|
||||
this.xml = xml;
|
||||
}
|
||||
|
||||
parse(): XmlNode[] {
|
||||
this.skipByteOrderMark();
|
||||
while (this.index < this.xml.length) {
|
||||
this.readNextToken();
|
||||
}
|
||||
this.assertFullyClosed();
|
||||
return this.roots;
|
||||
}
|
||||
|
||||
private readNextToken(): void {
|
||||
if (this.xml[this.index] !== '<') {
|
||||
this.readText();
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = this.resolveMarkupReader();
|
||||
reader();
|
||||
}
|
||||
|
||||
private resolveMarkupReader(): () => void {
|
||||
if (this.startsWith('<!--')) return () => this.skipUntil('-->', 'Comment is not closed.');
|
||||
if (this.startsWith('<?'))
|
||||
return () => this.skipUntil('?>', 'Processing instruction is not closed.');
|
||||
if (this.startsWith('<![CDATA[')) return () => this.readCdata();
|
||||
if (this.startsWith('<!')) return () => this.skipDeclaration();
|
||||
if (this.startsWith('</')) return () => this.readClosingTag();
|
||||
return () => this.readOpeningTag();
|
||||
}
|
||||
|
||||
private assertFullyClosed(): void {
|
||||
if (this.stack.length > 0) {
|
||||
const node = this.stack[this.stack.length - 1];
|
||||
throw new Error(`Unclosed XML tag <${node?.name ?? 'unknown'}>.`);
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function normalizeXmlAttributes(value: unknown): Record<string, string> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
const attributes: Record<string, string> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
if (typeof entry === 'string') {
|
||||
attributes[key] = entry;
|
||||
private skipByteOrderMark(): void {
|
||||
if (this.xml.charCodeAt(0) === 0xfeff) {
|
||||
this.index = 1;
|
||||
}
|
||||
}
|
||||
return attributes;
|
||||
|
||||
private readOpeningTag(): void {
|
||||
this.index += 1;
|
||||
this.skipWhitespace();
|
||||
const name = this.readRequiredName(`Missing XML tag name at offset ${this.index}.`);
|
||||
const { attributes, selfClosing } = this.readOpeningTagBody();
|
||||
|
||||
const node: XmlNode = { name, attributes, text: null, children: [] };
|
||||
this.addNode(node);
|
||||
if (!selfClosing) {
|
||||
this.pushOpenNode(node);
|
||||
}
|
||||
}
|
||||
|
||||
private readOpeningTagBody(): { attributes: Record<string, string>; selfClosing: boolean } {
|
||||
const attributes: Record<string, string> = {};
|
||||
while (true) {
|
||||
this.skipWhitespace();
|
||||
const tagEnd = this.readOpeningTagEnd();
|
||||
if (tagEnd) return { attributes, selfClosing: tagEnd === 'self-closing' };
|
||||
const attribute = this.readAttribute();
|
||||
attributes[attribute.name] = attribute.value;
|
||||
}
|
||||
}
|
||||
|
||||
private readOpeningTagEnd(): 'open' | 'self-closing' | null {
|
||||
if (this.index >= this.xml.length) throw new Error('Opening XML tag is not closed.');
|
||||
if (this.xml[this.index] === '>') {
|
||||
this.index += 1;
|
||||
return 'open';
|
||||
}
|
||||
if (this.xml[this.index] === '/' && this.xml[this.index + 1] === '>') {
|
||||
this.index += 2;
|
||||
return 'self-closing';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private readAttribute(): { name: string; value: string } {
|
||||
const name = this.readRequiredName(`Invalid XML attribute at offset ${this.index}.`);
|
||||
assertSafeXmlAttributeName(name);
|
||||
this.skipWhitespace();
|
||||
if (this.xml[this.index] !== '=') {
|
||||
throw new Error(`Missing value for XML attribute "${name}".`);
|
||||
}
|
||||
this.index += 1;
|
||||
this.skipWhitespace();
|
||||
return { name, value: this.readAttributeValue(name) };
|
||||
}
|
||||
|
||||
private pushOpenNode(node: XmlNode): void {
|
||||
if (this.stack.length >= MAX_XML_NESTING_DEPTH) {
|
||||
throw new Error(`Maximum XML nesting depth of ${MAX_XML_NESTING_DEPTH} exceeded.`);
|
||||
}
|
||||
this.stack.push(node);
|
||||
}
|
||||
|
||||
private readClosingTag(): void {
|
||||
this.index += 2;
|
||||
this.skipWhitespace();
|
||||
const name = this.readName();
|
||||
this.skipWhitespace();
|
||||
if (this.xml[this.index] !== '>') {
|
||||
throw new Error(`Closing XML tag </${name}> is not closed.`);
|
||||
}
|
||||
this.index += 1;
|
||||
|
||||
const node = this.stack.pop();
|
||||
if (!node) {
|
||||
throw new Error(`Unexpected closing XML tag </${name}>.`);
|
||||
}
|
||||
if (node.name !== name) {
|
||||
throw new Error(`Expected </${node.name}> before </${name}>.`);
|
||||
}
|
||||
}
|
||||
|
||||
private readText(): void {
|
||||
const nextTagIndex = this.xml.indexOf('<', this.index);
|
||||
const endIndex = nextTagIndex === -1 ? this.xml.length : nextTagIndex;
|
||||
this.appendText(this.xml.slice(this.index, endIndex), true);
|
||||
this.index = endIndex;
|
||||
}
|
||||
|
||||
private readCdata(): void {
|
||||
const startIndex = this.index + '<![CDATA['.length;
|
||||
const endIndex = this.xml.indexOf(']]>', startIndex);
|
||||
if (endIndex === -1) throw new Error('CDATA section is not closed.');
|
||||
this.appendText(this.xml.slice(startIndex, endIndex), false);
|
||||
this.index = endIndex + ']]>'.length;
|
||||
}
|
||||
|
||||
private appendText(text: string, decodeEntities: boolean): void {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
const node = this.stack[this.stack.length - 1];
|
||||
if (!node) return;
|
||||
// Preserve fast-xml-parser's trimValues behavior for each text segment we keep.
|
||||
node.text = `${node.text ?? ''}${decodeEntities ? decodeXmlEntities(trimmed) : trimmed}`;
|
||||
}
|
||||
|
||||
private addNode(node: XmlNode): void {
|
||||
const parent = this.stack[this.stack.length - 1];
|
||||
if (parent) {
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
this.roots.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
private readName(): string {
|
||||
const startIndex = this.index;
|
||||
while (this.index < this.xml.length && isXmlNameChar(this.xml[this.index])) {
|
||||
this.index += 1;
|
||||
}
|
||||
return this.xml.slice(startIndex, this.index);
|
||||
}
|
||||
|
||||
private readRequiredName(errorMessage: string): string {
|
||||
const name = this.readName();
|
||||
if (!name) throw new Error(errorMessage);
|
||||
return name;
|
||||
}
|
||||
|
||||
private readAttributeValue(attributeName: string): string {
|
||||
const quote = this.xml[this.index];
|
||||
if (quote !== '"' && quote !== "'") {
|
||||
throw new Error(`XML attribute "${attributeName}" must use a quoted value.`);
|
||||
}
|
||||
this.index += 1;
|
||||
const startIndex = this.index;
|
||||
const endIndex = this.xml.indexOf(quote, startIndex);
|
||||
if (endIndex === -1) {
|
||||
throw new Error(`XML attribute "${attributeName}" is not closed.`);
|
||||
}
|
||||
this.index = endIndex + 1;
|
||||
return decodeXmlEntities(this.xml.slice(startIndex, endIndex).trim());
|
||||
}
|
||||
|
||||
private skipDeclaration(): void {
|
||||
const state: DeclarationScanState = { quote: null, bracketDepth: 0 };
|
||||
for (let cursor = this.index + 2; cursor < this.xml.length; cursor += 1) {
|
||||
if (updateDeclarationScan(state, this.xml[cursor])) {
|
||||
this.index = cursor + 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error('XML declaration is not closed.');
|
||||
}
|
||||
|
||||
private skipUntil(token: string, errorMessage: string): void {
|
||||
// Opening markup tokens are longer than or equal to their closing tokens here, so
|
||||
// this skips past the opening token without missing a valid overlapping close.
|
||||
const endIndex = this.xml.indexOf(token, this.index + token.length);
|
||||
if (endIndex === -1) throw new Error(errorMessage);
|
||||
this.index = endIndex + token.length;
|
||||
}
|
||||
|
||||
private skipWhitespace(): void {
|
||||
while (this.index < this.xml.length && isXmlWhitespace(this.xml[this.index])) {
|
||||
this.index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
private startsWith(token: string): boolean {
|
||||
return this.xml.startsWith(token, this.index);
|
||||
}
|
||||
}
|
||||
|
||||
function readXmlText(value: unknown): string | null {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
if (!Array.isArray(value)) return null;
|
||||
const text = value
|
||||
.map((entry) => {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null;
|
||||
const textValue = (entry as Record<string, unknown>)['#text'];
|
||||
return typeof textValue === 'string' ? textValue.trim() : null;
|
||||
})
|
||||
.filter((entry): entry is string => entry !== null && entry.length > 0)
|
||||
.join('')
|
||||
.trim();
|
||||
return text.length > 0 ? text : null;
|
||||
type DeclarationScanState = {
|
||||
quote: string | null;
|
||||
bracketDepth: number;
|
||||
};
|
||||
|
||||
function isXmlNameChar(char: string | undefined): boolean {
|
||||
return char !== undefined && XML_NAME_CHARS.has(char);
|
||||
}
|
||||
|
||||
function isXmlWhitespace(char: string | undefined): boolean {
|
||||
return char !== undefined && XML_WHITESPACE_CHARS.has(char);
|
||||
}
|
||||
|
||||
function updateDeclarationScan(state: DeclarationScanState, char: string | undefined): boolean {
|
||||
if (char === undefined) return false;
|
||||
if (updateDeclarationQuote(state, char)) return false;
|
||||
updateDeclarationBracketDepth(state, char);
|
||||
return isDeclarationEnd(state, char);
|
||||
}
|
||||
|
||||
function updateDeclarationQuote(state: DeclarationScanState, char: string): boolean {
|
||||
if (state.quote) {
|
||||
if (char === state.quote) state.quote = null;
|
||||
return true;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
state.quote = char;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function updateDeclarationBracketDepth(state: DeclarationScanState, char: string): void {
|
||||
if (char === '[') {
|
||||
state.bracketDepth += 1;
|
||||
return;
|
||||
}
|
||||
if (char === ']' && state.bracketDepth > 0) {
|
||||
state.bracketDepth -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
function isDeclarationEnd(state: DeclarationScanState, char: string): boolean {
|
||||
return char === '>' && state.bracketDepth === 0;
|
||||
}
|
||||
|
||||
function assertSafeXmlAttributeName(name: string): void {
|
||||
if (UNSAFE_XML_ATTRIBUTE_NAMES.has(name)) {
|
||||
throw new Error(`Unsupported XML attribute name "${name}".`);
|
||||
}
|
||||
}
|
||||
|
||||
function decodeXmlEntities(value: string): string {
|
||||
return value.replace(
|
||||
/&(#x[0-9a-fA-F]+|#[0-9]+|amp|lt|gt|quot|apos);/g,
|
||||
(entity, body: string) => {
|
||||
switch (body) {
|
||||
case 'amp':
|
||||
return '&';
|
||||
case 'lt':
|
||||
return '<';
|
||||
case 'gt':
|
||||
return '>';
|
||||
case 'quot':
|
||||
return '"';
|
||||
case 'apos':
|
||||
return "'";
|
||||
default:
|
||||
return decodeNumericXmlEntity(entity, body);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function decodeNumericXmlEntity(entity: string, body: string): string {
|
||||
const codePoint = body.startsWith('#x')
|
||||
? Number.parseInt(body.slice(2), 16)
|
||||
: Number(body.slice(1));
|
||||
if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) {
|
||||
return entity;
|
||||
}
|
||||
try {
|
||||
return String.fromCodePoint(codePoint);
|
||||
} catch {
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user