/** * Decodes and encodes XML 1.0 documents as streams of events in Effect * channels. * * `decode` and `decodeString` turn UTF-8 bytes or text into events, and * `encode` and `encodeString` write events back as XML. `elements` collects * repeated elements, such as the items of a feed, into small trees, so that a * large document can be processed one record at a time. * * The decoder checks that documents are well-formed. It never reads external * resources and never expands entities declared in a DOCTYPE, so external * entity (XXE) and entity expansion attacks do not apply. It does not validate * documents against a DTD or schema, and reports names as written, without * resolving namespaces. * * @stability experimental * @since 4.0.6 */ import * as Arr from "../Array.ts" import * as Cause from "../Cause.ts" import * as Channel from "../Channel.ts" import * as Data from "../Data.ts" import * as Effect from "../Effect.ts" import * as Predicate from "../Predicate.ts" import * as Pull from "../Pull.ts" const XmlErrorTypeId = "~effect/encoding/Xml/XmlError" /** * Error raised when XML cannot be decoded or encoded. * * **Details** * * The `kind` field identifies the failure: * * - `Syntax`: the input is not well-formed XML or not valid UTF-8. * - `Unsupported`: the input uses a feature this module does not process, such * as an entity declared in a DOCTYPE or a byte encoding other than UTF-8. * - `Limit`: the input exceeds `maxTokenSize`, `maxDepth` or `maxElementSize`. * - `InvalidEvent`: the events do not form a well-formed document, such as an * end tag that does not match its start tag. * * Errors from `decode` and `decodeString` carry the one-based `line` and * `column` where the problem was found, also appended to `message`. Columns * count UTF-16 code units, and lines end at LF, CR or CRLF. * * @stability experimental * @category errors * @since 4.0.6 */ export class XmlError extends Data.TaggedError("XmlError")<{ readonly kind: "Syntax" | "Unsupported" | "Limit" | "InvalidEvent" readonly message: string readonly line?: number | undefined readonly column?: number | undefined }> { /** * Marks this value as an XML error for runtime guards. * * @stability experimental * @since 4.0.6 */ readonly [XmlErrorTypeId] = XmlErrorTypeId } /** * Checks whether a value is an `XmlError`. * * @stability experimental * @category guards * @since 4.0.6 */ export const isXmlError = (u: unknown): u is XmlError => Predicate.hasProperty(u, XmlErrorTypeId) /** * Event for the XML declaration at the start of a document, such as * ``. * * **Details** * * `encoding` and `standalone` are present only when the declaration sets them. * * @stability experimental * @category models * @since 4.0.6 */ export interface Declaration { readonly _tag: "Declaration" readonly version: string readonly encoding?: string | undefined readonly standalone?: boolean | undefined } /** * Event for a document type declaration, such as ``. * * **Details** * * `publicId` and `systemId` are present only when the declaration names an * external DTD; the DTD is never read. Whitespace in `publicId` is collapsed * to single spaces and trimmed. The internal subset is not reported. * * @stability experimental * @category models * @since 4.0.6 */ export interface Doctype { readonly _tag: "Doctype" readonly name: string readonly publicId?: string | undefined readonly systemId?: string | undefined } /** * Event for a start tag, with its attributes in document order. * * **Details** * * `name` is the qualified name as written and is the only element-name field * the encoder reads. * * Attribute values have their references replaced and whitespace normalized as * XML requires. Decoded attributes inherit nothing, so only the attributes of * the tag are present and `__proto__` is an ordinary key. Their key order is * document order, as XML names are never integer-like. A `structuredClone` of * a decoded map gets `Object.prototype` back. A self-closing tag such as * `
` is decoded as a `StartElement` followed by an `EndElement`. * * @stability experimental * @category models * @since 4.0.6 */ export interface StartElement { readonly _tag: "StartElement" readonly name: string readonly attributes: Readonly> } /** * Event for an end tag. * * @stability experimental * @category models * @since 4.0.6 */ export interface EndElement { readonly _tag: "EndElement" readonly name: string } /** * Event for character data inside the root element. * * **Details** * * Adjacent text, references and CDATA sections are decoded into one `Text` * event that ends at the next tag, comment or processing instruction, however * the input is chunked. Line endings are normalized to `\n`. * * @stability experimental * @category models * @since 4.0.6 */ export interface Text { readonly _tag: "Text" readonly text: string } /** * Event for a comment, holding the text between ``. * * @stability experimental * @category models * @since 4.0.6 */ export interface Comment { readonly _tag: "Comment" readonly text: string } /** * Event for a processing instruction, such as * ``. * * **Details** * * `data` is the text after the target, without leading whitespace. * * @stability experimental * @category models * @since 4.0.6 */ export interface ProcessingInstruction { readonly _tag: "ProcessingInstruction" readonly target: string readonly data: string } /** * Event produced by the decoders and consumed by the encoders. * * @stability experimental * @category models * @since 4.0.6 */ export type Event = | Declaration | Doctype | StartElement | EndElement | Text | Comment | ProcessingInstruction /** * Element collected by `elements`, with its attributes and children. * * **Details** * * Children are elements and strings of character data, in document order. * Adjacent text is merged into one string; comments and processing * instructions are dropped. * * @stability experimental * @category models * @since 4.0.6 */ export interface Element { readonly name: string readonly attributes: Readonly> readonly children: ReadonlyArray } /** * Options for the XML decoders. * * **Details** * * - `maxTokenSize`: the maximum length of a single token in UTF-16 code units, * as written in the input: a tag with its attributes, a comment, a processing * instruction, a DOCTYPE, or a text node including its references and CDATA * sections. Defaults to 10 MiB; `Infinity` disables the limit. * - `maxDepth`: the maximum number of nested elements. Defaults to 1024; * `Infinity` disables the limit. * * Memory per token and for open element names is bounded by `maxTokenSize`. * Invalid options throw when the channel is created. * * @stability experimental * @category configuration * @since 4.0.6 */ export interface DecodeOptions { readonly maxTokenSize?: number | undefined readonly maxDepth?: number | undefined } /** * Options for `elements`. * * **Details** * * `maxElementSize` is the maximum size of one collected element, counted as * the UTF-16 code units of its names, attribute names and values, and text. * Defaults to 16 MiB; `Infinity` disables the limit. Invalid options throw when * the channel is created. * * @stability experimental * @category configuration * @since 4.0.6 */ export interface ElementsOptions { readonly maxElementSize?: number | undefined } /** * Creates a channel that decodes XML text chunks into events. * * **Details** * * - Tokens can be split anywhere between chunks. Each output chunk holds the * events completed by one input chunk. * - The document must have one root element. Whitespace outside it is not * reported. * - A leading byte order mark is skipped, and the encoding named by the XML * declaration is ignored. * - Events completed before an error are emitted before the channel fails. * * **Gotchas** * * - End of input inside the root fails with a `Syntax` error ("unclosed * element"); interruption ends the stream without this check. * - A DOCTYPE is reported as a `Doctype` event, and its internal subset is * skipped without checking its declarations. They are not applied, so * default attribute values are not added, and parameter entities are not * expanded. * - References to entities other than `<`, `>`, `&`, `'` and * `"` fail with an `Unsupported` `XmlError` when the document has a * DOCTYPE, and with a `Syntax` `XmlError` otherwise. Character references * such as `é` are always decoded. * - Names are reported as written, including namespace prefixes, and `xmlns` * declarations are ordinary attributes. * - Tokens or aggregate open element names longer than `maxTokenSize`, and * elements nested deeper than `maxDepth`, fail with a `Limit` `XmlError`. A * long token can instead fail with an error found in its first * `maxTokenSize + 1` characters. * - Strings in events can share memory with their input chunk, so keeping a * short string, such as an id from each record, can keep the whole chunk in * memory. Copy strings that are kept long, for example with * `structuredClone`. * * **Example** (Decoding events) * * ```ts import.meta.vitest * import { Effect, Stream } from "effect" * import { Xml } from "effect/encoding" * * const events = Stream.make("Hi & bye
").pipe( * Stream.pipeThroughChannel(Xml.decodeString()), * Stream.map((event) => event._tag), * Stream.runCollect * ) * await Effect.runPromise(events) // => ["StartElement", "Text", "StartElement", "EndElement", "EndElement"] * ``` * * @see {@link decode} for UTF-8 byte input * @see {@link elements} for collecting repeated elements into trees * @see {@link encodeString} for the inverse operation * * @stability experimental * @category decoding * @since 4.0.6 */ export const decodeString = (options?: DecodeOptions): Channel.Channel< Arr.NonEmptyReadonlyArray, XmlError | IE, Done, Arr.NonEmptyReadonlyArray, IE, Done > => { const parser = makeParser(options, false) return makeChannel(() => new TextInput(parser())) } /** * Creates a channel that decodes UTF-8 XML bytes into events. * * **Details** * * Works like `decodeString` after decoding the bytes as UTF-8. Multi-byte * characters can be split between chunks, and a leading byte order mark is * skipped. * * **Gotchas** * * - Invalid or truncated UTF-8 fails with a `Syntax` `XmlError`. * - An initial UTF-16 byte order mark or an XML declaration naming an encoding * other than UTF-8 or US-ASCII fails with an `Unsupported` `XmlError`. * Decode such bytes with a `TextDecoder` for that encoding and use `decodeString`. * * @see {@link decodeString} for text input and the shared gotchas * * @stability experimental * @category decoding * @since 4.0.6 */ export const decode = (options?: DecodeOptions): Channel.Channel< Arr.NonEmptyReadonlyArray, XmlError | IE, Done, Arr.NonEmptyReadonlyArray, IE, Done > => { const parser = makeParser(options, true) return makeChannel(() => new Utf8Input(parser())) } /** * Creates a channel that collects the elements at a path into trees. * * **When to use** * * Use to process large documents such as feeds, sitemaps or exports one record * at a time, without holding the whole document in memory. * * **Details** * * `path` lists element names from the root, and `"*"` matches any name. Each * element whose ancestors and own name match the path is emitted once its end * tag arrives, with all of its descendants; everything outside the matched * elements is skipped. Because paths start at the root, an element nested in a * matched element is part of its tree and is not emitted separately. * * **Gotchas** * * - A path that does not start at the root element matches nothing. * - Whitespace between child elements, such as indentation, is kept as string * children. * - A collected element larger than `maxElementSize` fails with a `Limit` * `XmlError`. The size counts characters, not memory: an element with many * small children can take many times its size in memory, so use a lower * limit for untrusted input. * - Events that end inside an element, or end an element that was never * started, fail with an `InvalidEvent` `XmlError`. * - A path that is empty or contains an invalid name throws when the channel is * created. * * **Example** (Reading feed items) * * ```ts import.meta.vitest * import { Effect, Stream } from "effect" * import { Xml } from "effect/encoding" * * const feed = `News * First * Second * ` * * const items = await Effect.runPromise( * Stream.make(feed).pipe( * Stream.pipeThroughChannel(Xml.decodeString()), * Stream.pipeThroughChannel(Xml.elements(["rss", "channel", "item"])), * Stream.runCollect * ) * ) * items.length // => 2 * items[1] // => { name: "item", attributes: {}, children: [{ name: "title", attributes: {}, children: ["Second"] }] } * ``` * * @see {@link decode} for producing events from bytes * * @stability experimental * @category decoding * @since 4.0.6 */ export const elements = ( path: ReadonlyArray, options?: ElementsOptions ): Channel.Channel< Arr.NonEmptyReadonlyArray, XmlError | IE, Done, Arr.NonEmptyReadonlyArray, IE, Done > => { if (path.length === 0) { throw new Error("Xml: elements path must not be empty") } for (const name of path) { if (name !== "*" && !isName(name)) { throw new Error(`Xml: invalid element name in path: ${JSON.stringify(name)}`) } } const maxElementSize = checkLimit("maxElementSize", options?.maxElementSize, defaultMaxElementSize) const segments = Array.from(path) return makeChannel(() => new Collector(segments, maxElementSize)) } /** * Creates a channel that writes events as XML text. * * **Details** * * - Each input chunk is written as one string. * - Text escapes `&`, `<` and `>`. Attribute values are double-quoted and * escape `&`, `<`, `"`, tabs and line breaks. Carriage returns are written * as ` ` so that they survive decoding. * - Start tags are written completely on their event. Empty elements use * explicit closing tags, such as `

`, never self-closing tags. * - Nothing is added between events, so whitespace comes only from `Text` * events. * * **Gotchas** * * Events that do not form one well-formed document fail with an * `InvalidEvent` `XmlError`, such as an end tag that does not match the open * element, a second root element, non-whitespace text outside the root * element, invalid names or characters, `--` in a comment, `?>` in processing * instruction data, or a declaration that is not the first event. The stream * must end with the root element closed. * * **Example** (Writing a document) * * ```ts import.meta.vitest * import { Effect, Stream } from "effect" * import { Xml } from "effect/encoding" * * const events: ReadonlyArray = [ * { _tag: "StartElement", name: "a", attributes: { href: "x?a=1&b=2" } }, * { _tag: "Text", text: "1 < 2" }, * { _tag: "EndElement", name: "a" } * ] * * const xml = Stream.fromIterable(events).pipe( * Stream.pipeThroughChannel(Xml.encodeString()), * Stream.mkString * ) * await Effect.runPromise(xml) // => '1 < 2' * ``` * * @see {@link encode} for UTF-8 byte output * @see {@link decodeString} for the inverse operation * * @stability experimental * @category encoding * @since 4.0.6 */ export const encodeString = (): Channel.Channel< Arr.NonEmptyReadonlyArray, XmlError | IE, Done, Arr.NonEmptyReadonlyArray, IE, Done > => makeChannel(() => new Writer(false)) /** * Creates a channel that writes events as UTF-8 XML bytes. * * **Details** * * Works like `encodeString`, with one byte array per input chunk. * * **Gotchas** * * A `Declaration` event naming an encoding other than UTF-8 fails * with an `InvalidEvent` `XmlError`. * * @see {@link encodeString} for text output and the shared gotchas * * @stability experimental * @category encoding * @since 4.0.6 */ export const encode = (): Channel.Channel< Arr.NonEmptyReadonlyArray, XmlError | IE, Done, Arr.NonEmptyReadonlyArray, IE, Done > => Channel.map(makeChannel(() => new Writer(true)), Arr.map((text) => textEncoder.encode(text))) const defaultMaxTokenSize = 10 * 1024 * 1024 const defaultMaxDepth = 1024 const defaultMaxElementSize = 16 * 1024 * 1024 const checkLimit = (name: string, value: number | undefined, defaultValue: number): number => { const limit = value ?? defaultValue if (!(limit > 0)) { throw new Error(`Xml: ${name} must be positive, got ${limit}`) } return limit } // The per-run state of a channel. `push` and `end` add output to `out`, and // throw `XmlError`s. interface Machine { out: Array push(input: In): void end(): void } const makeChannel = ( make: () => Machine ): Channel.Channel< Arr.NonEmptyReadonlyArray, XmlError | IE, Done, Arr.NonEmptyReadonlyArray, IE, Done > => Channel.fromTransform((upstream: Pull.Pull, IE, Done>, _scope) => Effect.sync(() => { const machine = make() let done: Pull.Pull | undefined let failure: XmlError | undefined // Output completed before an error is emitted before the failure const emit = ( inputs: ReadonlyArray | undefined ): Pull.Pull, XmlError | IE, Done> => { try { if (inputs === undefined) machine.end() else for (let i = 0; i < inputs.length; i++) machine.push(inputs[i]) } catch (error) { if (!isXmlError(error)) throw error failure = error } const out = machine.out if (Arr.isArrayNonEmpty(out)) { machine.out = [] return Effect.succeed(out) } return pull } const step = Pull.matchEffect(upstream, { onSuccess: emit, onFailure: Effect.failCause, onDone: (value) => { done = Cause.done(value) return emit(undefined) } }) const pull: Pull.Pull, XmlError | IE, Done> = Effect.suspend(() => failure !== undefined ? Effect.fail(failure) : done ?? step ) return pull }) ) const makeParser = (options: DecodeOptions | undefined, bytes: boolean): () => Parser => { const maxTokenSize = checkLimit("maxTokenSize", options?.maxTokenSize, defaultMaxTokenSize) const maxDepth = checkLimit("maxDepth", options?.maxDepth, defaultMaxDepth) return () => new Parser(maxTokenSize, maxDepth, bytes) } const textEncoder = new TextEncoder() const TAB = 9 const LF = 10 const CR = 13 const SPACE = 32 const BANG = 33 const DQUOTE = 34 const HASH = 35 const AMPERSAND = 38 const SQUOTE = 39 const DASH = 45 const SLASH = 47 const LT = 60 const EQUALS = 61 const GT = 62 const QUESTION = 63 const LBRACKET = 91 const RBRACKET = 93 const LOWER_X = 120 const isSpace = (c: number): boolean => c === SPACE || c === LF || c === TAB || c === CR // Characters outside the XML `Char` production, including unpaired surrogates // oxlint-disable-next-line no-control-regex const invalidChar = /[\0-\x08\x0B\x0C\x0E-\x1F\uFFFE\uFFFF\uD800-\uDFFF]/u // Decoded UTF-8 has no unpaired surrogates, and without the `u` flag the // search runs several times faster on non-ASCII text // oxlint-disable-next-line no-control-regex const invalidDecodedChar = /[\0-\x08\x0B\x0C\x0E-\x1F\uFFFE\uFFFF]/ // `isWellFormed` (ES2024) finds unpaired surrogates much faster than a `u` // regex, which is slow on non-ASCII text const isWellFormed: ((s: string) => boolean) | undefined = typeof (String.prototype as { isWellFormed?: unknown }).isWellFormed === "function" ? (s) => (s as unknown as { isWellFormed(): boolean }).isWellFormed() : undefined // Returns the index of the first character outside the XML `Char` production, // or -1 const searchInvalid = (s: string): number => isWellFormed !== undefined && s.search(invalidDecodedChar) === -1 && isWellFormed(s) ? -1 : s.search(invalidChar) const isChar = (c: number): boolean => c === TAB || c === LF || c === CR || (c >= 0x20 && c <= 0xD7FF) || (c >= 0xE000 && c <= 0xFFFD) || (c >= 0x10000 && c <= 0x10FFFF) const NAME_START = 1 const NAME_CHAR = 2 const asciiName = new Uint8Array(128) for (let c = 0; c < 128; c++) { if ((c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c === 95 || c === 58) asciiName[c] = NAME_START | NAME_CHAR else if ((c >= 48 && c <= 57) || c === DASH || c === 46) asciiName[c] = NAME_CHAR } // Name characters of XML 1.0 fifth edition. Supplementary characters are // accepted by their surrogates, which are paired once input is validated. const isNameStartCode = (c: number): boolean => c < 128 ? (asciiName[c] & NAME_START) !== 0 : (c >= 0xC0 && c <= 0xD6) || (c >= 0xD8 && c <= 0xF6) || (c >= 0xF8 && c <= 0x2FF) || (c >= 0x370 && c <= 0x37D) || (c >= 0x37F && c <= 0x1FFF) || (c >= 0x200C && c <= 0x200D) || (c >= 0x2070 && c <= 0x218F) || (c >= 0x2C00 && c <= 0x2FEF) || (c >= 0x3001 && c <= 0xD7FF) || (c >= 0xF900 && c <= 0xFDCF) || (c >= 0xFDF0 && c <= 0xFFFD) || (c >= 0xD800 && c <= 0xDB7F) const isNameCode = (c: number): boolean => c < 128 ? (asciiName[c] & NAME_CHAR) !== 0 : isNameStartCode(c) || c === 0xB7 || (c >= 0x300 && c <= 0x36F) || (c >= 0x203F && c <= 0x2040) || (c >= 0xDC00 && c <= 0xDFFF) // Returns the end of the name starting at `i`, or `i` when there is none const scanName = (s: string, i: number, end: number): number => { if (i >= end || !isNameStartCode(s.charCodeAt(i))) return i i++ while (i < end && isNameCode(s.charCodeAt(i))) i++ return i } // Name characters exclude every invalid character but unpaired surrogates const isName = (s: string): boolean => s.length > 0 && scanName(s, 0, s.length) === s.length && (isWellFormed !== undefined ? isWellFormed(s) : !invalidChar.test(s)) const skipSpace = (s: string, i: number, end: number): number => { while (i < end && isSpace(s.charCodeAt(i))) i++ return i } const lineEndings = /\r\n?/g const attributeWhitespace = /\r\n|[\t\n\r]/g // Characters of an attribute value that need decoding, or are not allowed const attributeSpecial = /[&<\t\n\r]/ const attributeWhitespaceChar = /[\t\n\r]/ // Attribute values shorter than this are scanned with a loop const shortValue = 24 const publicIdWhitespace = /[ \r\n]+/g const decimalDigits = /^[0-9]+$/ const hexDigits = /^[0-9a-fA-F]+$/ const versionPattern = /^1\.[0-9]+$/ const encodingPattern = /^[A-Za-z][A-Za-z0-9._-]*$/ const normalizeLines = (s: string): string => s.indexOf("\r") === -1 ? s : s.replace(lineEndings, "\n") // Normalizes the line endings and whitespace of a literal attribute value part const normalizeAttribute = (s: string): string => s.replace(attributeWhitespace, " ") const isReservedTarget = (target: string): boolean => target.length === 3 && target.toLowerCase() === "xml" const isUtf8 = (encoding: string): boolean => { const lower = encoding.toLowerCase() return lower === "utf-8" || lower === "us-ascii" } const declarationPattern = /^[ \t\r\n]+version[ \t\r\n]*=[ \t\r\n]*(?:"(1\.[0-9]+)"|'(1\.[0-9]+)')(?:[ \t\r\n]+encoding[ \t\r\n]*=[ \t\r\n]*(?:"([A-Za-z][A-Za-z0-9._-]*)"|'([A-Za-z][A-Za-z0-9._-]*)'))?(?:[ \t\r\n]+standalone[ \t\r\n]*=[ \t\r\n]*(?:"(yes|no)"|'(yes|no)'))?[ \t\r\n]*$/ const pubidPattern = /^[ \r\na-zA-Z0-9\-'()+,./:=?;!*#@$_%]*$/ // States of `Scanner`, which finds where a token split between chunks ends const S_START = 0 const S_TEXT = 1 const S_LT = 2 // after "<" const S_BANG = 3 // after "= 2) { if (this.state === S_COMMENT) return i + 1 this.reset(S_SUBSET) } else { this.aux = 0 } break } case S_CDATA: { if (c === RBRACKET) this.aux++ else if (c === GT && this.aux >= 2) return i + 1 else this.aux = 0 break } case S_PI: case S_SUBSET_PI: { if (c === GT && this.aux === 1) { if (this.state === S_PI) return i + 1 this.reset(S_SUBSET) } else { this.aux = c === QUESTION ? 1 : 0 } break } case S_TAG: { // Attribute values cannot contain "<" if (c === LT) return i if (this.aux === 0) { if (c === GT) return i + 1 if (c === DQUOTE || c === SQUOTE) this.aux = c } else if (c === this.aux) { this.aux = 0 } break } case S_DOCTYPE: case S_DECLARATION: { if (this.aux === 0) { if (c === LT) return i if (c === GT) { if (this.state === S_DOCTYPE) return i + 1 this.reset(S_SUBSET) } else if (c === LBRACKET && this.state === S_DOCTYPE) { this.reset(S_SUBSET) } else if (c === DQUOTE || c === SQUOTE) { this.aux = c } } else if (c === this.aux) { this.aux = 0 } break } case S_SUBSET: { if (c === RBRACKET) this.state = S_AFTER_SUBSET else if (c === LT) this.state = S_SUBSET_LT else if (c === DQUOTE || c === SQUOTE || c === GT) return i break } case S_SUBSET_LT: { if (c === BANG) this.state = S_SUBSET_BANG else if (c === QUESTION) this.reset(S_SUBSET_PI) else return i break } case S_SUBSET_BANG: { if (c === DASH) { this.state = S_SUBSET_BANG_DASH } else { this.reset(S_DECLARATION) continue } break } case S_SUBSET_BANG_DASH: { if (c !== DASH) return i this.reset(S_SUBSET_COMMENT) break } case S_AFTER_SUBSET: { if (c === GT) return i + 1 if (!isSpace(c)) return i break } } i++ } return -1 } } const PROLOG = 0 const CONTENT = 1 const EPILOG = 2 const describeToken = (s: string, i: number): string => s.startsWith("", i + 4) if (end === -1) return -1 const dashes = s.indexOf("--", i + 4) if (dashes < end) throw this.syntax("\"--\" is not allowed in comments", dashes) this.checkSize(i, end + 3) this.flushText() this.out.push({ _tag: "Comment", text: normalizeLines(s.slice(i + 4, end)) }) return end + 3 } private processingInstruction(s: string, i: number): number { const end = s.indexOf("?>", i + 2) if (end === -1) return -1 const t = scanName(s, i + 2, end) if (t === i + 2) throw this.syntax("invalid processing instruction target", i + 2) const target = s.slice(i + 2, t) if (isReservedTarget(target)) { if (target === "xml" && this.base + i === 0) return this.declaration(s, i, t, end) throw this.syntax( target === "xml" ? "the XML declaration is only allowed at the start of the document" : `processing instruction target ${target} is reserved`, i ) } let data = "" if (t < end) { if (!isSpace(s.charCodeAt(t))) throw this.syntax("expected whitespace after the target", t) data = normalizeLines(s.slice(skipSpace(s, t, end), end)) } this.checkSize(i, end + 2) this.flushText() this.out.push({ _tag: "ProcessingInstruction", target, data }) return end + 2 } private declaration(s: string, i: number, t: number, end: number): number { this.checkSize(i, end + 2) const match = declarationPattern.exec(s.slice(t, end)) if (match === null) throw this.syntax("invalid XML declaration", i) const encoding = match[3] ?? match[4] const standalone = match[5] ?? match[6] if (this.bytes && encoding !== undefined && !isUtf8(encoding)) { throw this.error( "Unsupported", `encoding ${encoding} is not supported for byte input, decode the bytes to text first`, this.base + i ) } this.ascii = this.bytes && encoding?.toLowerCase() === "us-ascii" const event: { -readonly [K in keyof Declaration]: Declaration[K] } = { _tag: "Declaration", version: match[1] ?? match[2] } if (encoding !== undefined) event.encoding = encoding if (standalone !== undefined) event.standalone = standalone === "yes" this.out.push(event) return end + 2 } private cdata(s: string, i: number): number { const end = s.indexOf("]]>", i + 9) if (end === -1) return -1 if (this.state !== CONTENT) throw this.syntax("CDATA sections are only allowed inside the root element", i) this.addText(i, end + 3 - i) this.text += normalizeLines(s.slice(i + 9, end)) return end + 3 } private doctypeDeclaration(s: string, i: number): number { if (this.state !== PROLOG || this.doctype) { throw this.syntax("a DOCTYPE is only allowed once, before the root element", i) } const scanner = new Scanner() scanner.reset(S_DOCTYPE) const end = scanner.scan(s, i + 9) if (end === -1) return -1 this.checkSize(i, end) this.doctype = true let j = this.space(s, i + 9, end) const nameEnd = this.name(s, j, end) const event: { -readonly [K in keyof Doctype]: Doctype[K] } = { _tag: "Doctype", name: s.slice(j, nameEnd) } j = skipSpace(s, nameEnd, end) if (j > nameEnd && (s.startsWith("PUBLIC", j) || s.startsWith("SYSTEM", j))) { if (s.charCodeAt(j) === 80) { // Whitespace in public identifiers is normalized for matching event.publicId = this.publicLiteral(s, this.space(s, j + 6, end), end).replace(publicIdWhitespace, " ").trim() j = this.literalEnd } event.systemId = normalizeLines( this.literal(s, this.space(s, j + (event.publicId === undefined ? 6 : 0), end), end) ) j = skipSpace(s, this.literalEnd, end) } if (s.charCodeAt(j) === LBRACKET) { // The scanner skips the internal subset, whose declarations are not // checked, and ends the token early only at malformed markup if (scanner.state !== S_AFTER_SUBSET) throw this.syntax("invalid markup in the internal subset", end) j = skipSpace(s, s.lastIndexOf("]", end - 1) + 1, end) } if (j !== end - 1 || s.charCodeAt(j) !== GT) throw this.syntax("invalid DOCTYPE", j) this.out.push(event) return end } // Skips the whitespace that is required at `i` private space(s: string, i: number, end: number): number { if (i >= end || !isSpace(s.charCodeAt(i))) throw this.syntax("expected whitespace", i) return skipSpace(s, i, end) } // Returns the end of the name that is required at `i` private name(s: string, i: number, end: number): number { const nameEnd = scanName(s, i, end) if (nameEnd === i) throw this.syntax("expected a name", i) return nameEnd } // Reads the quoted literal at `i`, and sets `literalEnd` to the index after it private literal(s: string, i: number, end: number): string { const quote = s.charCodeAt(i) if (quote !== DQUOTE && quote !== SQUOTE) throw this.syntax("expected a quoted literal", i) const close = s.indexOf(quote === DQUOTE ? "\"" : "'", i + 1) // Unreachable while the scanner ends a DOCTYPE only after its literals close if (close === -1 || close >= end) throw this.syntax("unterminated literal", i) this.literalEnd = close + 1 return s.slice(i + 1, close) } private publicLiteral(s: string, i: number, end: number): string { const literal = this.literal(s, i, end) if (!pubidPattern.test(literal)) throw this.syntax("invalid public identifier", i) return literal } } const indexOrEnd = (s: string, search: string, from: number): number => { const i = s.indexOf(search, from) return i === -1 ? s.length : i } class TextInput implements Machine { private readonly parser: Parser // A high surrogate at the end of a chunk, which pairs with the next chunk private surrogate = "" constructor(parser: Parser) { this.parser = parser } get out(): Array { return this.parser.out } set out(out: Array) { this.parser.out = out } push(chunk: string): void { if (this.surrogate !== "") { chunk = this.surrogate + chunk this.surrogate = "" } const last = chunk.charCodeAt(chunk.length - 1) if (last >= 0xD800 && last <= 0xDBFF) { this.surrogate = chunk.slice(-1) chunk = chunk.slice(0, -1) } this.parser.feed(chunk) } end(): void { // An unpaired high surrogate fails as an invalid character this.parser.feed(this.surrogate) this.parser.end() } } const utf8Length = (lead: number): number => lead >= 0xF0 ? 4 : lead >= 0xE0 ? 3 : lead >= 0xC0 ? 2 : 1 // Returns the length of the valid UTF-8 prefix of `bytes` const validUtf8 = (bytes: Uint8Array): number => { let i = 0 while (i < bytes.length) { const lead = bytes[i] if (lead < 0x80) { i++ continue } const length = lead >= 0xC2 && lead <= 0xF4 ? utf8Length(lead) : 0 if (length === 0 || i + length > bytes.length) return i let code = lead & (0xFF >> (length + 1)) for (let k = 1; k < length; k++) { const c = bytes[i + k] if ((c & 0xC0) !== 0x80) return i code = (code << 6) | (c & 0x3F) } const min = length === 2 ? 0x80 : length === 3 ? 0x800 : 0x10000 if (code < min || code > 0x10FFFF || (code >= 0xD800 && code <= 0xDFFF)) return i i += length } return i } const utf8Decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }) // Decodes UTF-8 without streaming state, so that the position of invalid bytes // is known: an incomplete sequence at the end of a chunk is carried over. class Utf8Input implements Machine { private readonly parser: Parser private carry: Uint8Array | undefined private started = false constructor(parser: Parser) { this.parser = parser } get out(): Array { return this.parser.out } set out(out: Array) { this.parser.out = out } push(chunk: Uint8Array): void { if (!this.started && chunk.length > 0) { const first = this.carry === undefined ? chunk[0] : this.carry[0] const second = this.carry === undefined ? chunk[1] : chunk[0] if ((first === 0xFE && second === 0xFF) || (first === 0xFF && second === 0xFE)) { throw this.parser.error( "Unsupported", "UTF-16 is not supported for byte input, decode the bytes to text first", 0 ) } // Keep a lone BOM prefix pending in the UTF-8 carry below this.started = second !== undefined || (first !== 0xFE && first !== 0xFF) } let start = 0 if (this.carry !== undefined) { const need = utf8Length(this.carry[0]) - this.carry.length const head = new Uint8Array(this.carry.length + Math.min(need, chunk.length)) head.set(this.carry) head.set(chunk.subarray(0, need), this.carry.length) if (chunk.length < need) { this.carry = head return } this.carry = undefined this.decode(head) start = need } const n = chunk.length let end = n for (let k = 1; k <= 3 && n - k >= start; k++) { const b = chunk[n - k] if ((b & 0xC0) === 0x80) continue if (b >= 0xC0 && k < utf8Length(b)) end = n - k break } if (end < n) this.carry = chunk.slice(end) if (end > start) this.decode(start === 0 && end === n ? chunk : chunk.subarray(start, end)) } end(): void { // The carried bytes may also be an invalid sequence, such as a single 0xFF if (this.carry !== undefined) { this.parser.feed("", true) throw this.parser.error("Syntax", "invalid UTF-8", this.parser.received) } this.parser.end() } private decode(bytes: Uint8Array): void { let text: string try { text = utf8Decoder.decode(bytes) } catch { this.parser.feed(utf8Decoder.decode(bytes.subarray(0, validUtf8(bytes))), true) throw this.parser.error("Syntax", "invalid UTF-8", this.parser.received) } this.parser.feed(text) } } interface MutableElement { readonly name: string readonly attributes: Readonly> readonly children: Array } class Collector implements Machine { out: Array = [] private depth = 0 // Number of open elements, from the root, that match the path private matched = 0 // The element being collected and its open descendants private readonly stack: Array = [] private size = 0 private readonly path: ReadonlyArray private readonly maxElementSize: number constructor(path: ReadonlyArray, maxElementSize: number) { this.path = path this.maxElementSize = maxElementSize } push(event: Event): void { switch (event._tag) { case "StartElement": { const depth = this.depth++ if (this.stack.length === 0) { const segment = this.path[depth] if (this.matched !== depth || (segment !== "*" && segment !== event.name)) return if (++this.matched < this.path.length) return this.size = 0 } const element: MutableElement = { name: event.name, attributes: event.attributes, children: [] } if (this.stack.length > 0) this.stack[this.stack.length - 1].children.push(element) this.stack.push(element) let size = event.name.length for (const key in event.attributes) size += key.length + event.attributes[key].length this.grow(size) return } case "EndElement": { if (this.depth === 0) throw invalidEvent(`end tag without a start tag`) if (this.stack.length > 0) { const element = this.stack.pop()! if (this.stack.length === 0) this.out.push(element) } if (this.matched === this.depth) this.matched-- this.depth-- return } case "Text": { if (this.stack.length === 0 || event.text.length === 0) return this.grow(event.text.length) const children = this.stack[this.stack.length - 1].children const last = children.length - 1 if (last >= 0 && typeof children[last] === "string") children[last] += event.text else children.push(event.text) return } } } end(): void { if (this.depth > 0) throw invalidEvent("events ended inside an element") } private grow(size: number): void { this.size += size if (this.size > this.maxElementSize) { throw positionless("Limit", `element <${this.stack[0].name}> exceeds maxElementSize of ${this.maxElementSize}`) } } } // JavaScriptCore gives every Error its own source `line` and `column`, which // would pass for an XML position, so errors without one set both to undefined const positionless = (kind: XmlError["kind"], message: string) => new XmlError({ kind, message, line: undefined, column: undefined }) const invalidEvent = (message: string) => positionless("InvalidEvent", message) const textSpecial = /[&<>\r]/ const attributeSpecials = /[&<"\t\n\r]/ // The regex finds the first special character, which is fast when there is // none; the rest is copied in runs between escapes. Invalid characters are // kept, so that the writer can check them in the output. const escapeText = (s: string): string => { const first = s.search(textSpecial) if (first === -1) return s let out = "" let last = 0 for (let i = first; i < s.length; i++) { const c = s.charCodeAt(i) const escaped = c === AMPERSAND ? "&" : c === LT ? "<" : c === GT ? ">" : c === CR ? " " : undefined if (escaped !== undefined) { out += s.slice(last, i) + escaped last = i + 1 } } return out + s.slice(last) } const escapeAttribute = (s: string): string => { const first = s.search(attributeSpecials) if (first === -1) return s let out = "" let last = 0 for (let i = first; i < s.length; i++) { const c = s.charCodeAt(i) const escaped = c === AMPERSAND ? "&" : c === LT ? "<" : c === DQUOTE ? """ : c === TAB ? " " : c === LF ? " " : c === CR ? " " : undefined if (escaped !== undefined) { out += s.slice(last, i) + escaped last = i + 1 } } return out + s.slice(last) } const checkChars = (what: string, s: string): void => { const invalid = searchInvalid(s) if (invalid !== -1) { const code = s.codePointAt(invalid)!.toString(16).toUpperCase().padStart(4, "0") throw invalidEvent(`${what} contains the invalid character U+${code}`) } } const checkName = (what: string, name: string): void => { if (typeof name !== "string" || !isName(name)) throw invalidEvent(`invalid ${what} ${JSON.stringify(name)}`) } const isWhitespace = (s: string): boolean => { for (let i = 0; i < s.length; i++) { if (!isSpace(s.charCodeAt(i))) return false } return true } const NOTHING_WRITTEN = 3 class Writer implements Machine { out: Array = [] private readonly stack: Array = [] private state = NOTHING_WRITTEN private doctype = false private readonly bytes: boolean // Element and attribute names already checked, which repeat in most // documents. Cleared when full, so that it stays small. private readonly names = new Set() constructor(bytes: boolean) { this.bytes = bytes } private checkName(what: string, name: string): void { if (!this.isName(name)) checkName(what, name) } private isName(name: string): boolean { if (this.names.has(name)) return true if (typeof name !== "string" || !isName(name)) return false if (this.names.size >= 1024) this.names.clear() this.names.add(name) return true } // Checks attributes one by one, in order, after a batched check has failed private checkAttributes(attributes: Readonly>): void { for (const name in attributes) { if (!Object.hasOwn(attributes, name)) continue const value = attributes[name] this.checkName("attribute name", name) if (typeof value !== "string") throw invalidEvent(`attribute ${name} must be a string`) checkChars(`attribute ${name}`, value) } } push(event: Event): void { const text = this.write(event) if (text.length > 0) { const out = this.out if (out.length > 0) out[0] += text else out.push(text) } } end(): void { if (this.stack.length > 0) throw invalidEvent(`unclosed element <${this.stack[this.stack.length - 1]}>`) if (this.state !== EPILOG) throw invalidEvent("no root element") } private write(event: Event): string { if (event._tag === "EndElement") { const open = this.stack.length > 0 ? this.stack[this.stack.length - 1] : undefined if (open === undefined || event.name !== open) { // Only valid names are written into messages checkName("element name", event.name) throw invalidEvent( open === undefined ? `end tag without a start tag` : `end tag does not match <${open}>` ) } this.stack.pop() if (this.stack.length === 0) this.state = EPILOG return `` } switch (event._tag) { case "StartElement": { this.checkName("element name", event.name) if (this.state === EPILOG) throw invalidEvent(`second root element <${event.name}>`) let tag = "<" + event.name let valid = true for (const name in event.attributes) { if (!Object.hasOwn(event.attributes, name)) continue const value = event.attributes[name] if (typeof value !== "string" || !this.isName(name)) { valid = false break } tag += ` ${name}="${escapeAttribute(value)}"` } // Escaping keeps invalid characters, so one search of the tag checks // every value; the first error is then found in attribute order if (!valid || (tag.length > event.name.length + 1 && searchInvalid(tag) !== -1)) { this.checkAttributes(event.attributes) } this.stack.push(event.name) this.state = CONTENT return tag + ">" } case "Text": { if (this.state !== CONTENT) { checkChars("text", event.text) if (!isWhitespace(event.text)) throw invalidEvent("text outside the root element must be whitespace") if (this.state === NOTHING_WRITTEN && event.text.length > 0) this.state = PROLOG return event.text } checkChars("text", event.text) return escapeText(event.text) } case "Comment": { checkChars("comment", event.text) if (event.text.includes("--") || event.text.endsWith("-")) { throw invalidEvent("comments cannot contain \"--\" or end with \"-\"") } if (this.state === NOTHING_WRITTEN) this.state = PROLOG return `` } case "ProcessingInstruction": { checkName("processing instruction target", event.target) if (isReservedTarget(event.target)) { throw invalidEvent(`processing instruction target ${event.target} is reserved`) } checkChars("processing instruction", event.data) if (event.data.includes("?>")) throw invalidEvent("processing instruction data cannot contain \"?>\"") if (this.state === NOTHING_WRITTEN) this.state = PROLOG return `` } case "Declaration": { if (this.state !== NOTHING_WRITTEN) throw invalidEvent("the XML declaration must be the first event") if (!versionPattern.test(event.version)) { throw invalidEvent(`invalid XML version ${JSON.stringify(event.version)}`) } let declaration = `" } case "Doctype": { if (this.state === CONTENT || this.state === EPILOG || this.doctype) { throw invalidEvent("a DOCTYPE must appear once, before the root element") } checkName("DOCTYPE name", event.name) let doctype = `" } } } }