Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199/** * A small XML reader and writer for XMPP stanzas. Stanzas come off a network, so this reads exactly what XMPP needs and refuses the rest: no DOCTYPE or entity * declarations (the usual ways XML is made to read files or blow up), only the five predefined entities and numeric character references, only the XML declaration as a * processing instruction, and limits on size, depth, attributes and children. It has no regular expression that can backtrack on its input: it reads one character at a time. */
export interface XmlElement { /** The local name, without a prefix. */ name: string; prefix?: string; /** The namespace in force at this element (the nearest xmlns). */ ns?: string; attrs: Record<string, string>; children: XmlElement[]; /** The element's own text, with the text of CDATA sections, children's text not included. */ text: string;}
const MAX_CHARS = 1_000_000;const MAX_DEPTH = 20;const MAX_ATTRS = 200;const MAX_CHILDREN = 5000;const NAME_START = /[A-Za-z_]/;const NAME_CHAR = /[A-Za-z0-9_.:-]/;const ENTITIES: Record<string, string> = { lt: "<", gt: ">", amp: "&", quot: '"', apos: "'" };
export const text = (e: XmlElement): string => e.text;
export function el(name: string, attrs: Record<string, string> = {}, ...content: Array<XmlElement | string>): XmlElement { return { name, attrs, children: content.filter((c): c is XmlElement => typeof c !== "string"), text: content.filter((c): c is string => typeof c === "string").join("") };}
const escText = (s: string): string => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");const plainName = (n: string): boolean => /^[A-Za-z_][A-Za-z0-9_.:-]*$/.test(n);
export function serialize(e: XmlElement): string { if (!plainName(e.name)) throw new Error(`xml: ${JSON.stringify(e.name).slice(0, 40)} is not a plain name`); const attrs = Object.entries(e.attrs).map(([k, v]) => { if (!plainName(k)) throw new Error(`xml: attribute name ${JSON.stringify(k).slice(0, 40)} is not a plain name`); return ` ${k}="${escText(v)}"`; }).join(""); const inner = escText(e.text) + e.children.map(serialize).join(""); return inner === "" ? `<${e.name}${attrs}/>` : `<${e.name}${attrs}>${inner}</${e.name}>`;}
function decode(raw: string): string { let out = ""; for (let i = 0; i < raw.length; i++) { const c = raw[i]!; if (c !== "&") { out += c; continue; } const end = raw.indexOf(";", i); if (end === -1 || end - i > 10) throw new Error("xml: an & that is not an entity"); const ref = raw.slice(i + 1, end); if (ref[0] === "#") { const code = ref[1] === "x" ? Number.parseInt(ref.slice(2), 16) : Number.parseInt(ref.slice(1), 10); const digits = ref[1] === "x" ? /^#x[0-9A-Fa-f]+$/.test(ref) : /^#[0-9]+$/.test(ref); if (!digits || !Number.isInteger(code) || code === 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff) || (code < 0x20 && code !== 9 && code !== 10 && code !== 13)) throw new Error("xml: a character reference to a character XML does not allow"); out += String.fromCodePoint(code); } else if (Object.hasOwn(ENTITIES, ref)) out += ENTITIES[ref]!; else throw new Error(`xml: unknown entity &${ref.slice(0, 10)};`); i = end; } return out;}
export function parseXml(input: string): XmlElement { if (input.length > MAX_CHARS) throw new Error("xml: input too large"); if (input.trim() === "") throw new Error("xml: empty input"); let i = 0; const fail = (m: string): never => { throw new Error(`xml: ${m} (at ${i})`); }; const noControl = (s: string) => { for (const ch of s) { const c = ch.codePointAt(0)!; if (c < 0x20 && c !== 9 && c !== 10 && c !== 13) throw new Error("xml: a control character"); } }; noControl(input); const ws = () => { while (i < input.length && (input[i] === " " || input[i] === "\t" || input[i] === "\n" || input[i] === "\r")) i++; }; const name = (): string => { const s = i; if (!NAME_START.test(input[i] ?? "")) fail("a name was expected"); while (i < input.length && NAME_CHAR.test(input[i]!)) i++; return input.slice(s, i); };
// prolog: an XML declaration and comments only ws(); while (input.startsWith("<?", i) || input.startsWith("<!--", i)) { if (input.startsWith("<?", i)) { const end = input.indexOf("?>", i); if (end === -1 || !/^<\?xml(\s|\?)/.test(input.slice(i, i + 7))) fail("a processing instruction is not allowed"); i = end + 2; } else { const end = input.indexOf("-->", i + 4); if (end === -1) fail("a comment that never ends"); i = end + 3; } ws(); } if (input.startsWith("<!", i) && !input.startsWith("<![CDATA[", i)) fail("a DOCTYPE or declaration is not allowed");
function element(depth: number, inherited: Record<string, string>): XmlElement { if (depth > MAX_DEPTH) fail("elements nested too deep"); if (input[i] !== "<") fail("an element was expected"); i++; const qname = name(); const attrs: Record<string, string> = {}; for (;;) { ws(); const c = input[i]; if (c === "/" || c === ">" || c === undefined) break; const an = name(); ws(); if (input[i] !== "=") fail("an attribute needs =value"); i++; ws(); const q = input[i]; if (q !== '"' && q !== "'") fail("an attribute value must be quoted"); const end = input.indexOf(q!, i + 1); if (end === -1) fail("an attribute value that never ends"); const raw = input.slice(i + 1, end); if (raw.includes("<")) fail("a < inside an attribute value"); if (Object.hasOwn(attrs, an)) fail(`attribute ${an} given twice`); if (Object.keys(attrs).length >= MAX_ATTRS) fail("too many attributes"); attrs[an] = decode(raw); i = end + 1; } const colon = qname.indexOf(":"); const prefix = colon === -1 ? undefined : qname.slice(0, colon); const local = colon === -1 ? qname : qname.slice(colon + 1); const scope: Record<string, string> = { ...inherited }; for (const [k, v] of Object.entries(attrs)) { if (k === "xmlns") scope[""] = v; else if (k.startsWith("xmlns:")) scope[k.slice(6)] = v; } const ns = scope[prefix ?? ""]; const node: XmlElement = { name: local, ...(prefix !== undefined ? { prefix } : {}), ...(ns !== undefined ? { ns } : {}), attrs, children: [], text: "" }; if (input[i] === "/") { if (input[i + 1] !== ">") fail("a / that does not end the tag"); i += 2; return node; } if (input[i] !== ">") fail("a tag that never ends"); i++; for (;;) { if (i >= input.length) fail(`${qname} is never closed`); if (input.startsWith("</", i)) { i += 2; const close = name(); ws(); if (input[i] !== ">") fail("a closing tag takes no attributes"); i++; if (close !== qname) fail(`</${close}> closes <${qname}>`); return node; } if (input.startsWith("<!--", i)) { const end = input.indexOf("-->", i + 4); if (end === -1) fail("a comment that never ends"); i = end + 3; } else if (input.startsWith("<![CDATA[", i)) { const end = input.indexOf("]]>", i + 9); if (end === -1) fail("a CDATA section that never ends"); node.text += input.slice(i + 9, end); i = end + 3; } else if (input.startsWith("<?", i)) { fail("a processing instruction is not allowed"); } else if (input[i] === "<") { if (input[i + 1] === "!") fail("a declaration is not allowed"); if (node.children.length >= MAX_CHILDREN) fail("too many children"); node.children.push(element(depth + 1, scope)); } else { const next = input.indexOf("<", i); const end = next === -1 ? input.length : next; node.text += decode(input.slice(i, end)); i = end; } } }
const root = element(1, {}); while (i < input.length) { ws(); if (i >= input.length) break; if (input.startsWith("<!--", i)) { const end = input.indexOf("-->", i + 4); if (end === -1) fail("a comment that never ends"); i = end + 3; } else fail("something after the root element"); } return root;}