diff --git a/README.md b/README.md index c233cb3df..76c1caa8b 100644 --- a/README.md +++ b/README.md @@ -73,9 +73,12 @@ setInterval(() => { by grapheme, and text fields bind the readline chords (Ctrl+A/E/K/U/W). - **Fullscreen** `Element.requestFullscreen()` renders to the alternate screen; exiting restores the shell and its scrollback. -- **Images and canvas** `` decodes PNG, JPEG, GIF and BMP, and - `` has a 2D context and a `"charactergrid"` context; pixels - are drawn two to a cell with half blocks. +- **Images and canvas** `` decodes PNG, JPEG, GIF and BMP on the + runtime's web `Worker` where there is one, and `` has a 2D context and a `"charactergrid"` + context; pixels are drawn two to a cell with half blocks. Images load + only what the program's Content Security Policy allows, such as + `new TermDOM({csp: "img-src https: data:"})`, and a + `"fetch"` listener can answer each load, as a Service Worker does. ## How it works diff --git a/docs/guides/07-images-and-canvas.md b/docs/guides/07-images-and-canvas.md index 1f5af696f..4763b658c 100644 --- a/docs/guides/07-images-and-canvas.md +++ b/docs/guides/07-images-and-canvas.md @@ -3,9 +3,17 @@ title: Images and canvas description: How and draw pixels in cells. --- -A cell can show two pixels: the upper half block, `▀`, in the -foreground color over the background color. `` and `` -draw that way. The pixels are ordinary cells, so they scroll, clip and +A cell can show two pixels: the lower half block, `▄`, in the +foreground color under the background color. `` and `` +draw that way. + +Most terminals draw block characters themselves, so either half fills +its half of the cell exactly. Terminal.app draws them from the font, +and Menlo's and SF Mono's sit low in the cell: the upper half block +leaves a sliver of the wrong color at the top and runs past the +middle. The background fills whatever the glyph leaves, so with the +lower half block only the seam between the two pixels is a little +low. The pixels are ordinary cells, so they scroll, clip and redraw like text, and they work over SSH and inside tmux. ## `` @@ -14,11 +22,96 @@ redraw like text, and they work over SSH and inside tmux. Album cover ``` -`src` can be a `data:` URL, a `file:` URL, a path, or an `http:` or -`https:` URL. A path is relative to the document's URL. A document at -`about:blank` has no URL to resolve against, so a path there is -relative to the working directory. `http:` and `https:` go through -`fetch()`. +`src` is a URL: `https:`, `data:`, `file:` or any other. A path is +relative to the document's URL, given by the `url` option. A document +at `about:blank` has no URL to resolve against, so a path there names +nothing; a program that shows local files sets `url` to a `file:` URL, +such as its working directory. + +### Loading + +Nothing loads unless the program allows it. Which loads a document may +make is a Content Security Policy, given as the `csp` option in the +syntax of a `Content-Security-Policy` header. An `` loads only what +`img-src` allows, or `default-src` when there is no `img-src`. The default is `default-src 'none'`. + +```ts +// Images from anywhere on the web, and data: URLs. +new TermDOM({csp: "img-src https: data:"}); + +// A mail client: attachments by cid:, nothing remote. +new TermDOM({csp: "img-src cid: data:"}); +``` + +Source expressions match as CSP Level 3 defines them: `'none'`, +`'self'`, `*`, a scheme such as `https:` or `cid:`, and a host such as +`https://*.example.com:8443/img/`. `*` covers the network schemes and +the document's own, not `data:` or `cid:`. A host given without a +scheme takes the document's scheme, and for a document not served over +the network, as `about:blank` and `file:` are not, the network schemes. + +A page can narrow the policy with its own +`` in its head, and never +widen it: a load must pass every policy. As in a browser, the tag's +policy holds from when it is inserted into the head, and removing the +tag or changing its `content` later changes nothing. A load a policy blocks fires +`securitypolicyviolation` at its element, a `SecurityPolicyViolationEvent` +that bubbles to the document and names the URL, the directive and the +policy. The image shows as a box with its `alt` text. When the program +set no policy, the first blocked load is also reported once where +uncaught errors go: the transport's log, or the scrollback at exit. + +A load the policy allows goes to the TermDOM as a `"fetch"` event, a +`FetchEvent`, as a Service Worker hears it, and to the network when no +listener answers. A listener answers with `respondWith()`, which stops +the other listeners. `Response.error()`, a promise that rejects, or +`preventDefault()` without an answer fails the load. + +```ts +const term = new TermDOM({csp: "img-src cid: https:"}); +term.addEventListener("fetch", (event) => { + const {request} = event; + if (request.destination === "image" && request.url.startsWith("cid:")) { + event.respondWith(new Response(attachment(request.url))); + } +}); +``` + +The policy comes first, as a page's policy does before its Service +Worker: a load the listener answers must be allowed too, so serving +`cid:` takes both `img-src cid:` and the listener. `request.destination` +says what the load is for (`"image"`). `handled` settles once the load +is answered, and `waitUntil()` keeps `dispose()` waiting for work a +listener goes on with. `dispose()` aborts loads still in flight, which a +listener sees on `request.signal`. A load waiting on a listener's answer +ends when its `src` changes or the TermDOM is disposed, whether or not +the answer ever comes. + +A redirect, from the network or from a listener's `Response.redirect()`, +is checked against the policy again and followed on the network, not +offered to the listeners, as a browser follows one without its Service +Worker. A redirect goes only to another `http:` or `https:` URL, and at +most 20 are followed. A listener that answers with `fetch(event.request)` +follows redirects on its own, and its response is checked against the +policy where it ended up, as a browser checks a Service Worker's +response. A violation names the URL the document asked for, never where +it was redirected. + +To decide image by image, such as no remote images in quoted mail, +change the markup before it is inserted, as a mail client cleans what +it shows. + +The request waits for the script that set the source to finish, so a +listener added just after the markup sees its images. Only a document +whose own URL is `file:` loads `file:` URLs, whatever the policy says, +so untrusted markup in a page at `about:blank` or `https:` cannot read +local files. Node's `fetch` reads no files, and Bun's and Deno's do, so +a program that allows local files on every runtime answers them itself. + +The page's own `window.fetch` is the runtime's, and it resolves a +relative URL against the document's, as a browser's does. No policy +governs it and it dispatches no event: in a terminal it is the program, +not untrusted markup, that calls it. PNG, JPEG, GIF and BMP decode: @@ -35,19 +128,29 @@ lossless JPEGs. `load` and `error` fire as they do in a browser, and `decode()`, `complete`, `naturalWidth`, `naturalHeight` and `currentSrc` report the image. `new Image()` makes an ``. `srcset` is read for its `1x` -candidate. An image that fails to load shows its `alt` text. +candidate. + +An image that is not showing, because it is still loading, failed to +load or was blocked, is a box with its `alt` text inside, cut with an +ellipsis when it does not fit. The box takes the image's size when the +page gives one, and otherwise fits the text. An empty `alt` marks an +image that is only decoration, and it shows nothing, as does an +`` with neither a `src` nor an `alt`. ### Size -An image's pixels are the screen's pixels, so its natural size in -cells is its size in pixels divided by the size of a cell in pixels. -The engine asks the terminal for the cell size (XTWINOPS 16) and -assumes 8 by 16 until the terminal answers. A 400 by 400 image is 50 -columns by 25 rows under that assumption. +An image's pixels are CSS pixels, so its natural size in cells is its +size divided by the size of a cell. With the `cellSize` option set, +that is the page's cell. With the default unit cell, where a CSS pixel +is a whole cell, an image takes the terminal's cell instead: the engine +asks for its size (XTWINOPS 16) and assumes 8 by 16 until the terminal +answers. A 400 by 400 image is 50 columns by 25 rows under that +assumption. -`width` and `height`, as attributes or in CSS, are cells, like every -other length. With only one of them set, the other follows the image's -ratio. `max-width: 100%` keeps an image inside its container. +`width` and `height`, as attributes or in CSS, are lengths like any +other: CSS pixels, which are cells under the unit cell. With only one +of them set, the other follows the image's ratio. `max-width: 100%` +keeps an image inside its container. On a line, an image is as tall as itself and its top is at the top of the line. @@ -66,6 +169,47 @@ A pixel at least half opaque shows, and the rest leave the cell under them as it was. On an element with a `background-color`, translucent pixels blend over that color instead. +### Limits + +An image file names its size before its pixels, so a few bytes can +ask for gigabytes. As a browser does, the engine checks what a file +asks for before it allocates, and an image past a limit fails to load: + +- a response of at most 64 MB, by its declared length and while reading +- at most 32768 pixels to a side and 2^26 pixels in all +- a PNG's data inflated only as far as its image needs +- a JPEG of at most four components and 500 scans + +Images decode one at a time on the runtime's web `Worker`, so a large +one does not stall the page. Bun and Deno have one. Node has one from +26.9 behind a flag, so run `node --experimental-web-worker`, or set +`NODE_OPTIONS=--experimental-web-worker`, to decode off the page's +thread there. Without a `Worker`, and in a CommonJS build, images decode +on the page's thread a slice at a time, so input and frames go on +between slices. A decode that takes more than 10 seconds is stopped +and that image fails; the images behind it still decode. + +The worker ends a second after its last decode, and when the last +TermDOM is disposed, so it never keeps a finished program running. + +On a Node without the flag, a program can supply a `Worker` itself. +TermDOM uses whatever global `Worker` there is, so the +[`@b9g/node-webworker`](https://www.npmjs.com/package/@b9g/node-webworker) +shim gives it one on any Node 18 or later: + +```ts +import {Worker} from "@b9g/node-webworker"; +globalThis.Worker ??= Worker; +``` + +Where Node has its own `Worker`, the `??=` leaves it in place. + +A new `src` cancels the request the old one started, and drops its +decode if it is still waiting for its turn. An image whose +box would sample more than 2^26 pixels draws nothing, as does a 2D +canvas past that size. `ImageData` past it throws a `RangeError`, and +a character grid stops at 2^22 cells. + ## `` `getContext()` takes two context types. @@ -128,18 +272,30 @@ grid.fillRect(0, 0, 20, 3); grid.fillStyle = "#ffcc00"; grid.font = "bold"; grid.fillText("score: 42", 1, 1); +grid.strokeStyle = "#5fafff"; +grid.lineJoin = "round"; +grid.strokeRect(0, 0, 20, 3); document.body.append(canvas); ``` +Lines are box-drawing glyphs. Where two meet they join in a corner, a +tee or a cross, as borders do. A canvas's lines join each other but +not the page's borders around the canvas. + | Member | What it does | | --- | --- | | `fillStyle` | A CSS color. `currentcolor`, the default, is the terminal's foreground | +| `strokeStyle` | The lines' color, the same way | +| `lineStyle` | A CSS `border-style`: `solid`, the default, `double`, `dashed`, `dotted` and the rest | +| `lineJoin` | `miter`, the default, or `round`, which curves `strokeRect()`'s corners | | `font` | Keywords from the CSS `font` shorthand: `bold`, `italic`, `lighter` | | `textAlign` | `left`, `right`, `center`, `start` or `end` | | `textDecoration` | `none` or `underline` | | `fillText(text, x, y, maxWidth?)` | Writes glyphs in the fill color, over the cells' backgrounds. A wide glyph takes two columns | | `fillRect(x, y, w, h)` | Fills cells with the fill color as their background. `currentcolor` fills them in inverse video | | `clearRect(x, y, w, h)` | Empties cells | +| `strokeLine(x1, y1, x2, y2)` | A line from one cell to another, both included, along a row or a column. A slanted line draws nothing | +| `strokeRect(x, y, w, h)` | A box around the cells `w` by `h` from (x, y) | | `drawImage(image, ...)` | Draws an image's pixels, two to a cell. Without a size, the image takes its natural size in cells | | `getCell(x, y)` | The cell's glyph, colors and attributes, or `null` | | `measureText(text)` | The text's width in columns | @@ -147,13 +303,13 @@ document.body.append(canvas); ## Text alone -Pixels can also be written as text: an upper half block in a span -whose `color` is the upper pixel and whose `background-color` is the -lower one. +Pixels can also be written as text: a lower half block in a span +whose `color` is the lower pixel and whose `background-color` is the +upper one. ```html -
▀▀▀
-▀▀▀
+
▄▄▄
+▄▄▄
``` That stays aligned through scrolling, clipping and repaints, as any diff --git a/examples/images.ts b/examples/images.ts index 3b24df56c..e713987a5 100644 --- a/examples/images.ts +++ b/examples/images.ts @@ -1,3 +1,6 @@ +import {readFile} from "node:fs/promises"; +import {pathToFileURL} from "node:url"; + import {TermDOM} from "@b9g/termdom"; // node examples/images.ts [path-or-url] @@ -6,7 +9,22 @@ import {TermDOM} from "@b9g/termdom"; // to as a data: URL, so the example runs anywhere. const source = typeof process === "undefined" ? undefined : process.argv[2]; -const term = new TermDOM(); +// Images load only what the Content Security Policy allows: here files, +// data: URLs and the web. A fetch listener reads a file: URL from disk, +// which Node's own fetch does not, and leaves the rest to the runtime. +// The document's URL is the working directory, so a path given on the +// command line resolves under it. +const term = new TermDOM({ + url: pathToFileURL(`${process.cwd()}/`).href, + csp: "img-src file: data: https:", +}); +term.addEventListener("fetch", (event) => { + if (event.request.url.startsWith("file:")) { + event.respondWith( + readFile(new URL(event.request.url)).then((bytes) => new Response(bytes)), + ); + } +}); term.attach(); const {document} = term; @@ -69,7 +87,7 @@ document.body.innerHTML = ` -

A broken image shows its alt text: [no cover art]

+

A broken image is a box with its alt text: no cover art

`; diff --git a/package.json b/package.json index 9ed06c328..86c3094b9 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ ], "exports": { ".": "./src/index.ts", + "./decode-worker": "./src/decode-worker.ts", "./package.json": "./package.json" }, "files": [ diff --git a/scripts/support-matrix.ts b/scripts/support-matrix.ts index b4bb385c0..fe6a75fa4 100644 --- a/scripts/support-matrix.ts +++ b/scripts/support-matrix.ts @@ -1065,7 +1065,10 @@ function apiProbe( category, async run() { const terminal = new MockProcess({cols: 40, rows: 12}); - const dom = new TermDOM({transport: terminal.transport}); + const dom = new TermDOM({ + transport: terminal.transport, + csp: "img-src data: https:", + }); try { return {supported: await run(dom), note}; } catch (_err) { diff --git a/src/decode-worker.ts b/src/decode-worker.ts new file mode 100644 index 000000000..b3803f104 --- /dev/null +++ b/src/decode-worker.ts @@ -0,0 +1,48 @@ +/** + * The thread images decode on, started as a web Worker. It takes + * `{id, bytes}` and answers `{id, width, height, naturalWidth, + * naturalHeight, data}` with the pixels' buffer transferred, kept at most + * MAX_KEPT_PIXELS, or `{id, error}`. It answers `{ready: true}` first, so + * the page can tell a worker that never started from one that failed on + * an image. + */ +import {decodeImageForPage} from "./internal/images.ts"; + +interface WorkerScope { + postMessage(message: unknown, transfer?: Transferable[]): void; + addEventListener( + type: "message", + listener: (event: {data: unknown}) => void, + ): void; +} + +const scope = globalThis as unknown as WorkerScope; + +async function answer(data: unknown): Promise { + const {id, bytes} = data as {id: number; bytes: ArrayBuffer}; + try { + const bitmap = await decodeImageForPage(new Uint8Array(bytes)); + const pixels = bitmap.data.buffer as ArrayBuffer; + scope.postMessage( + { + id, + width: bitmap.width, + height: bitmap.height, + naturalWidth: bitmap.naturalWidth, + naturalHeight: bitmap.naturalHeight, + data: pixels, + }, + [pixels], + ); + } catch (error) { + scope.postMessage({ + id, + error: error instanceof Error ? error.message : String(error), + }); + } +} + +scope.addEventListener("message", (event) => { + answer(event.data).catch(() => {}); +}); +scope.postMessage({ready: true}); diff --git a/src/index.ts b/src/index.ts index 95635d68a..b227241d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { getCellSize, setCellSizeSource, setColorSchemeSource, + setScreenCellSource, UNIT_CELL, } from "./internal/cssom.ts"; import { @@ -43,12 +44,29 @@ import { transportFromProcess, } from "./internal/exchange.ts"; import {Framebuffer} from "./internal/framebuffer.ts"; +import { + releaseDecoder, + retainDecoder, + setDecodeWorkerURL, +} from "./internal/images.ts"; import {Input} from "./internal/input.ts"; import {Layout} from "./internal/layout.ts"; import {Painter} from "./internal/painter.ts"; +import { + answerRequest, + type FetchEvent, + parseContentSecurityPolicies, + type RequestPolicy, + setResourceLoader, +} from "./internal/resources.ts"; export type {CellSize} from "./internal/cssom.ts"; export {transportFromProcess} from "./internal/exchange.ts"; +export { + ExtendableEvent, + FetchEvent, + type FetchEventInit, +} from "./internal/resources.ts"; export type { ProcessLike, TTYReadStream, @@ -58,6 +76,19 @@ export type { TerminalTransport, } from "./internal/exchange.ts"; +// Images decode on the runtime's web Worker, running src/decode-worker.ts, +// which builds to dist/decode-worker.js beside this file. A runtime with +// no Worker, as Node is without --experimental-web-worker, and a CommonJS +// build, which has no import.meta.url, decode on this thread instead. +setDecodeWorkerURL( + typeof import.meta.url === "string" + ? new URL( + `./decode-worker.${import.meta.url.endsWith(".ts") ? "ts" : "js"}`, + import.meta.url, + ) + : null, +); + export interface TermDOMOptions { /** Defaults to the global process. */ @@ -81,8 +112,21 @@ export interface TermDOMOptions { * - `{width, height}`: that size. */ cellSize?: "unit" | "auto" | CellSize; + + /** + * The Content Security Policy the document's loads are checked against, + * as a `Content-Security-Policy` header would state it: an loads + * only what `img-src`, or `default-src` without it, allows. A page's own + * `` can narrow it, never + * widen it. Defaults to `"default-src 'none'"`, so markup loads nothing + * unless the program allows it. The empty string, like a header with + * no directives, sets no policy and allows every load. + */ + csp?: string; } +const DEFAULT_CONTENT_SECURITY_POLICY = "default-src 'none'"; + function getCellSizeSource( option: TermDOMOptions["cellSize"], measured: () => Readonly, @@ -128,6 +172,8 @@ const kHoverReportingEnabled = Symbol("hoverReportingEnabled"); const kTransport = Symbol("transport"); const kExchange = Symbol("exchange"); const kStaticSibling = Symbol("staticSibling"); +const kLifetimes = Symbol("lifetimes"); +const kLoads = Symbol("loads"); const kAttachBegun = Symbol("attachBegun"); type Lifecycle = "detached" | "attaching" | "attached" | "disposed"; const kLifecycle = Symbol("lifecycle"); @@ -143,6 +189,11 @@ const kHeldErrors = Symbol("heldErrors"); const kUnwrittenErrors = Symbol("unwrittenErrors"); const HELD_ERROR_LIMIT = 50; +/** The events a TermDOM dispatches, by type. */ +export interface TermDOMEventMap { + fetch: FetchEvent; +} + export interface TermDOM { [kFramebuffer]: Framebuffer; [kLayout]: Layout; @@ -188,18 +239,27 @@ export interface TermDOM { // The engine behind renderANSI and print, rebuilt when the width, the // cell or the screen's colors change. [kStaticSibling]: TermDOM | null; + // What "fetch" listeners passed to waitUntil(). dispose() waits for it. + [kLifetimes]: Set>; + // Aborts the document's loads when the TermDOM is disposed. + [kLoads]: AbortController; } -export class TermDOM { +/** + * A document drawn in a terminal. It is the target of a "fetch" event for + * each load its markup asks for that its policy allows (see FetchEvent). + */ +export class TermDOM extends EventTarget { readonly document: Document; readonly window: Window; constructor(options: TermDOMOptions = {}) { - const cellSize = getCellSizeSource(options.cellSize, () => + super(); + const screenCell = () => this[kTransport].interactive ? this[kFramebuffer].cellPixels - : FALLBACK_CELL, - ); + : FALLBACK_CELL; + const cellSize = getCellSizeSource(options.cellSize, screenCell); this[kSealed] = false; this[kRenderQueued] = false; @@ -219,6 +279,9 @@ export class TermDOM { this[kAttachReady] = Promise.resolve(); this[kAttachBegun] = Promise.resolve(); this[kStaticSibling] = null; + this[kLifetimes] = new Set(); + retainDecoder(); + this[kLoads] = new AbortController(); this[kTransport] = options.transport ?? transportFromProcess(); this.window = createWindow( @@ -227,6 +290,59 @@ export class TermDOM { ); const document = this.document = this.window.document; + // The page's fetch resolves a relative URL against the document, as a + // browser's does, and goes to the runtime's. + this.window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => + fetch( + new Request( + typeof input === "string" ? new URL(input, document.baseURI) : input, + init, + ), + )) as typeof this.window.fetch; + const policy = options.csp; + let hinted = false; + const requestPolicy: RequestPolicy = { + policies: parseContentSecurityPolicies( + policy ?? DEFAULT_CONTENT_SECURITY_POLICY, + ), + signal: this[kLoads].signal, + extend: (promise) => { + const lifetime = promise.then(() => {}, () => {}); + this[kLifetimes].add(lifetime); + void lifetime.then(() => this[kLifetimes].delete(lifetime)); + }, + violate: (at, init) => { + const window = this.window as unknown as typeof globalThis; + dispatchAsUserAgent( + at as never, + new window.SecurityPolicyViolationEvent( + "securitypolicyviolation", + init, + ) as never, + ); + // A program that set no policy may not know markup loads + // nothing until it does. + if (policy === undefined && !hinted) { + hinted = true; + // A URL that is not HTTP(S) is reported as its scheme alone. + const blocked = init.blockedURI!.includes(":") + ? init.blockedURI + : `a ${init.blockedURI}: URL`; + reportUncaught( + this, + `TermDOM blocked ${blocked}. The document's markup ` + + "loads nothing until a Content Security Policy allows it: " + + "new TermDOM({csp: \"img-src data: https:\"}), for one.", + ); + } + }, + }; + setResourceLoader( + document, + (request, context) => + answerRequest(this, document, request, context, requestPolicy), + this[kLoads].signal, + ); this[kLayout] = new Layout( this.window, @@ -255,6 +371,7 @@ export class TermDOM { this[kMeasuresCell] = options.cellSize === "auto"; setCellSizeSource(document, cellSize); + setScreenCellSource(document, screenCell); attachDocument( document, @@ -475,6 +592,43 @@ export class TermDOM { return this[kAttachReady]; } + // lib.dom's shape: the keyed overload first. + override addEventListener( + type: K, + listener: (this: TermDOM, event: TermDOMEventMap[K]) => unknown, + options?: boolean | AddEventListenerOptions, + ): void; + override addEventListener( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions, + ): void; + override addEventListener( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions, + ): void { + super.addEventListener(type, listener, options); + } + + override removeEventListener( + type: K, + listener: (this: TermDOM, event: TermDOMEventMap[K]) => unknown, + options?: boolean | EventListenerOptions, + ): void; + override removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | EventListenerOptions, + ): void; + override removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | EventListenerOptions, + ): void { + super.removeEventListener(type, listener, options); + } + /** * Render to ANSI at the transport's width: colors and line breaks, no * cursor controls, no modes. Without an argument, the document as it @@ -516,9 +670,10 @@ export class TermDOM { /** * Hand the terminal back. Resolves when every restore has reached the - * transport. The process transport also restores the shell-critical - * modes synchronously, so exiting without awaiting leaves the shell - * usable. + * transport and what "fetch" listeners passed to waitUntil() has + * settled. Loads still in flight are aborted. The process transport + * also restores the shell-critical modes synchronously, so exiting + * without awaiting leaves the shell usable. */ dispose(): Promise { if (this[kLifecycle] === "disposed") { @@ -527,6 +682,8 @@ export class TermDOM { const wasAttached = isAttached(this); this[kLifecycle] = "disposed"; + this[kLoads].abort(); + releaseDecoder(); setDocumentVisible(this.document, false); // Frames painted in place, so nothing reached the scrollback. Write the @@ -573,7 +730,8 @@ export class TermDOM { this[kLayout].dispose(); clearHighlights(this.document); disconnectObservers(this.document); - return this[kExchange].flush(); + return Promise.all([this[kExchange].flush(), ...this[kLifetimes]]) + .then(() => {}); } } diff --git a/src/internal/canvas.ts b/src/internal/canvas.ts index 85b919c63..7ca33c3f7 100644 --- a/src/internal/canvas.ts +++ b/src/internal/canvas.ts @@ -13,16 +13,24 @@ * programs draw: one glyph, one color and one background a cell. */ import {parseColor, toCellColor} from "./cssvalues.ts"; +import { + type CellContext, + type CellStyle, + LINE_STYLES, + type LineStyle, + readCell, +} from "./framebuffer.ts"; import { averageBitmap, type Bitmap, - type CharacterGrid, createBitmap, encodePNG, - type GridCell, + MAX_IMAGE_PIXELS, + naturalHeightOf, + naturalWidthOf, sampleBitmap, } from "./images.ts"; -import {getStringWidth} from "./text.ts"; +import {getStringWidth, graphemeSegmenter} from "./text.ts"; // --------------------------------------------------------------------------- // What drawImage() and createPattern() take. @@ -83,6 +91,11 @@ export class ImageData { "The width and height must be positive", ); } + if (width * height > MAX_IMAGE_PIXELS) { + throw new RangeError( + `ImageData is limited to ${MAX_IMAGE_PIXELS} pixels`, + ); + } data = new Uint8ClampedArray(width * height * 4); } else { data = dataOrWidth; @@ -125,11 +138,13 @@ export class ImageBitmap { } get width(): number { - return this[kBitmapData]?.width ?? 0; + const bitmap = this[kBitmapData]; + return bitmap ? naturalWidthOf(bitmap) : 0; } get height(): number { - return this[kBitmapData]?.height ?? 0; + const bitmap = this[kBitmapData]; + return bitmap ? naturalHeightOf(bitmap) : 0; } close(): void { @@ -154,14 +169,47 @@ export function createImageBitmapFrom( } const x = Math.floor(sx ?? 0); const y = Math.floor(sy ?? 0); - const width = Math.floor(sw ?? bitmap.width); - const height = Math.floor(sh ?? bitmap.height); + const width = Math.floor(sw ?? naturalWidthOf(bitmap)); + const height = Math.floor(sh ?? naturalHeightOf(bitmap)); if (width <= 0 || height <= 0) { throw domException("IndexSizeError", "The crop rectangle is empty"); } - const copy = createBitmap(width, height); - copyRect(bitmap, x, y, copy, 0, 0, width, height); - return new ImageBitmap(copy); + if (bitmap.width === naturalWidthOf(bitmap)) { + const copy = createBitmap(width, height); + copyRect(bitmap, x, y, copy, 0, 0, width, height); + return new ImageBitmap(copy); + } + // From an image kept smaller than it is, the copy is kept as small, + // and measures the crop. What the crop takes past the image's edge is + // transparent. + const keptX = bitmap.width / naturalWidthOf(bitmap); + const keptY = bitmap.height / naturalHeightOf(bitmap); + const copy = createBitmap( + Math.max(1, Math.round(width * keptX)), + Math.max(1, Math.round(height * keptY)), + ); + const left = Math.max(x, 0); + const top = Math.max(y, 0); + const right = Math.min(x + width, naturalWidthOf(bitmap)); + const bottom = Math.min(y + height, naturalHeightOf(bitmap)); + const toLeft = Math.round((left - x) * keptX); + const toTop = Math.round((top - y) * keptY); + const toWidth = Math.round((right - x) * keptX) - toLeft; + const toHeight = Math.round((bottom - y) * keptY) - toTop; + if (toWidth > 0 && toHeight > 0) { + const part = { + width: toWidth, + height: toHeight, + data: sampleBitmap(bitmap, toWidth, toHeight, { + x: left, + y: top, + width: right - left, + height: bottom - top, + }), + }; + copyRect(part, 0, 0, copy, toLeft, toTop, toWidth, toHeight); + } + return new ImageBitmap({...copy, naturalWidth: width, naturalHeight: height}); } function copyRect( @@ -1463,9 +1511,11 @@ export class CanvasPattern { if (inverse !== null) { [x, y] = apply(inverse, x, y); } - const {width, height, data} = this[kPatternBitmap]; - let px = Math.floor(x); - let py = Math.floor(y); + const bitmap = this[kPatternBitmap]; + const {width, height, data} = bitmap; + // The pattern tiles at the image's natural size. + let px = Math.floor((x * width) / naturalWidthOf(bitmap)); + let py = Math.floor((y * height) / naturalHeightOf(bitmap)); if (this[kRepeatX]) { px = ((px % width) + width) % width; } @@ -2096,6 +2146,8 @@ export class CanvasRenderingContext2D { } const copy = createBitmap(bitmap.width, bitmap.height); copy.data.set(bitmap.data); + copy.naturalWidth = naturalWidthOf(bitmap); + copy.naturalHeight = naturalHeightOf(bitmap); return new CanvasPattern(copy, mode); } @@ -2352,8 +2404,8 @@ export class CanvasRenderingContext2D { } let sx = 0; let sy = 0; - let sw = bitmap.width; - let sh = bitmap.height; + let sw = naturalWidthOf(bitmap); + let sh = naturalHeightOf(bitmap); let dx: number; let dy: number; let dw: number; @@ -2390,8 +2442,8 @@ export class CanvasRenderingContext2D { const scaleY = dh / sh; const clippedX = Math.max(0, sx); const clippedY = Math.max(0, sy); - const clippedRight = Math.min(bitmap.width, sx + sw); - const clippedBottom = Math.min(bitmap.height, sy + sh); + const clippedRight = Math.min(naturalWidthOf(bitmap), sx + sw); + const clippedBottom = Math.min(naturalHeightOf(bitmap), sy + sh); if (clippedRight <= clippedX || clippedBottom <= clippedY) { return; } @@ -2427,9 +2479,12 @@ export class CanvasRenderingContext2D { const clip = this[kState].clip; const smooth = this[kState].imageSmoothingEnabled; const {width, height, data} = target; + // The source rectangle is in natural pixels. An image may keep fewer. + const keptX = source.width / naturalWidthOf(source); + const keptY = source.height / naturalHeightOf(source); const sample = (u: number, v: number): RGBA => { - const px = sx + ((u - dx) / dw) * sw; - const py = sy + ((v - dy) / dh) * sh; + const px = (sx + ((u - dx) / dw) * sw) * keptX; + const py = (sy + ((v - dy) / dh) * sh) * keptY; if (!smooth) { const ix = Math.min(source.width - 1, Math.max(0, Math.floor(px))); const iy = Math.min(source.height - 1, Math.max(0, Math.floor(py))); @@ -2889,7 +2944,8 @@ export function canvasToPNG(bitmap: Bitmap): Uint8Array { /** What the grid context needs from its canvas. */ export interface GridHost { canvas: object; - grid(): CharacterGrid; + // The canvas's cells, `width` columns by `height` rows. + cells(): CellContext; changed(): void; // Terminal pixels per cell, for drawing images at their natural size. cellPixels(): {width: number; height: number}; @@ -2897,39 +2953,45 @@ export interface GridHost { const kGridHost = Symbol("gridHost"); -/** - * getContext("charactergrid"): the canvas as `width` columns by `height` - * rows of cells. fillText() writes glyphs in the fill color over the - * cells' backgrounds, fillRect() fills cells' backgrounds, drawImage() - * draws an image two pixels a cell, and clearRect() empties cells. - * Coordinates are cells. Colors are any CSS color, and `currentcolor`, - * the default, is the terminal's own foreground. - */ const kFillStyle = Symbol("fillStyle"); +const kGridStrokeStyle = Symbol("gridStrokeStyle"); +const kLineStyle = Symbol("lineStyle"); +const kLineJoin = Symbol("lineJoin"); const kFont = Symbol("font"); const kTextAlign = Symbol("textAlign"); const kTextDecoration = Symbol("textDecoration"); -const kColor = Symbol("color"); -const kCell = Symbol("cell"); -const kSet = Symbol("set"); -const kEmptyCell = Symbol("emptyCell"); const kRect = Symbol("rect"); export interface CanvasCharacterGridContext { [kGridHost]: GridHost; [kFillStyle]: string; + [kGridStrokeStyle]: string; + [kLineStyle]: LineStyle["style"]; + [kLineJoin]: "miter" | "round"; [kFont]: string; [kTextAlign]: string; [kTextDecoration]: string; } +/** + * getContext("charactergrid"): the canvas as `width` columns by `height` + * rows of cells. fillText() writes glyphs in the fill color over the + * cells' backgrounds, fillRect() fills cells' backgrounds, strokeLine() + * and strokeRect() draw box-drawing lines that join where they meet, + * drawImage() draws an image two pixels a cell, and clearRect() empties + * cells. Coordinates are cells. Colors are any CSS color, and + * `currentcolor`, the default, is the terminal's own foreground. + */ export class CanvasCharacterGridContext { constructor(host: GridHost) { + this[kGridHost] = host; this[kFillStyle] = "currentcolor"; + this[kGridStrokeStyle] = "currentcolor"; + this[kLineStyle] = "solid"; + this[kLineJoin] = "miter"; this[kFont] = ""; this[kTextAlign] = "left"; this[kTextDecoration] = "none"; - this[kGridHost] = host; } get canvas(): object { @@ -2937,11 +2999,11 @@ export class CanvasCharacterGridContext { } get columns(): number { - return this[kGridHost].grid().cols; + return this[kGridHost].cells().cols; } get rows(): number { - return this[kGridHost].grid().rows; + return this[kGridHost].cells().rows; } get fillStyle(): string { @@ -2949,14 +3011,37 @@ export class CanvasCharacterGridContext { } set fillStyle(value: string) { - const text = String(value).trim().toLowerCase(); - if (text === "currentcolor") { - this[kFillStyle] = "currentcolor"; - return; + this[kFillStyle] = readGridColor(value) ?? this[kFillStyle]; + } + + get strokeStyle(): string { + return this[kGridStrokeStyle]; + } + + set strokeStyle(value: string) { + this[kGridStrokeStyle] = readGridColor(value) ?? this[kGridStrokeStyle]; + } + + /** A CSS border style: solid, double, dashed, dotted and the rest. */ + get lineStyle(): string { + return this[kLineStyle]; + } + + set lineStyle(value: string) { + const style = LINE_STYLES.find((name) => name === value); + if (style !== undefined) { + this[kLineStyle] = style; } - const color = parseCanvasColor(text); - if (color !== null) { - this[kFillStyle] = serializeColor(color); + } + + /** miter, round. A round join curves strokeRect()'s corners. */ + get lineJoin(): string { + return this[kLineJoin]; + } + + set lineJoin(value: string) { + if (value === "miter" || value === "round") { + this[kLineJoin] = value; } } @@ -2992,21 +3077,14 @@ export class CanvasCharacterGridContext { fillRect(x: number, y: number, w: number, h: number): void { const rect = this[kRect](x, y, w, h); - const color = this[kColor](); + const color = toGridColor(this[kFillStyle]); if (rect === null || color === undefined) { return; } const [x0, y0, x1, y1] = rect; - for (let row = y0; row < y1; row++) { - for (let col = x0; col < x1; col++) { - const cell = this[kEmptyCell](); - cell.char = " "; - // The terminal's foreground as a background is its inverse. - cell.bg = color; - cell.inverse = color === null; - this[kSet](col, row, cell); - } - } + // The terminal's foreground as a background is its inverse. + this[kGridHost].cells() + .drawRect(x0, y0, x1 - x0, y1 - y0, color ?? "inverse"); this[kGridHost].changed(); } @@ -3015,11 +3093,11 @@ export class CanvasCharacterGridContext { if (rect === null) { return; } - const [x0, y0, x1, y1] = rect; - for (let row = y0; row < y1; row++) { - for (let col = x0; col < x1; col++) { - this[kSet](col, row, null); - } + const {grid, cols, rows} = this[kGridHost].cells(); + const x0 = Math.max(0, rect[0]); + const x1 = Math.min(cols, rect[2]); + for (let row = Math.max(0, rect[1]); row < Math.min(rows, rect[3]); row++) { + grid.clearRange(row * cols + x0, row * cols + x1); } this[kGridHost].changed(); } @@ -3028,13 +3106,13 @@ export class CanvasCharacterGridContext { if (!isFiniteAll(x, y)) { return; } - const color = this[kColor](); + const color = toGridColor(this[kFillStyle]); if (color === undefined) { return; } - const segmenter = new Intl.Segmenter(undefined, {granularity: "grapheme"}); + const cells = this[kGridHost].cells(); const clusters = [ - ...segmenter.segment(String(text).replace(/[\t\n\f\r]/g, " ")), + ...graphemeSegmenter.segment(String(text).replace(/[\t\n\f\r]/g, " ")), ] .map((segment) => segment.segment) .filter((cluster) => getStringWidth(cluster) > 0); @@ -3054,50 +3132,102 @@ export class CanvasCharacterGridContext { ? col + Math.max(0, Math.floor(maxWidth)) : Infinity; const words = this[kFont].toLowerCase().split(/\s+/); - const bold = words.includes("bold") || words.includes("bolder"); - const italic = words.includes("italic") || words.includes("oblique"); - const dim = words.includes("lighter"); + const base: CellStyle = { + bold: words.includes("bold") || words.includes("bolder"), + italic: words.includes("italic") || words.includes("oblique"), + dim: words.includes("lighter"), + underline: this[kTextDecoration] === "underline", + }; + const {grid} = cells; for (const cluster of clusters) { const columns = getStringWidth(cluster); if (col + columns > limit) { break; } - const previous = this[kCell](col, row); + const index = row * cells.cols + col; + const inside = + row >= 0 && row < cells.rows && col >= 0 && col < cells.cols; // Over a cell filled with the terminal's foreground, which only // inverse video can show, the cell stays inverse: its background - // attribute is then what shows as the glyph's color. - const inverse = previous?.inverse === true; - const cell: GridCell = { - char: cluster, - fg: inverse ? null : color, - bg: inverse ? color : previous?.bg ?? null, - bold, - italic, - underline: this[kTextDecoration] === "underline", - dim, - inverse, - }; - this[kSet](col, row, cell); - // A wide glyph's second column is covered by it. + // is then what shows as the glyph's color. + const inverse = + inside && + grid.cluster[index] !== 0 && + readCell(grid, index)?.inverse === true; + cells.drawText( + cluster, + col, + row, + inverse ? {...base, inverse: true, bg: color} : {...base, fg: color}, + ); + // A wide glyph covers the columns after its first. for (let extra = 1; extra < columns; extra++) { - const next = this[kCell](col + extra, row); - this[kSet](col + extra, row, { - ...this[kEmptyCell](), - char: "", - bg: next?.bg ?? null, - }); + if (inside && col + extra < cells.cols) { + grid.cluster[index + extra] = 0; + } } col += columns; } this[kGridHost].changed(); } - measureText(text: string): {width: number} { - let width = 0; - for (const char of String(text)) { - width += Math.max(0, getStringWidth(char)); + /** + * A line from the cell at (x1, y1) to the cell at (x2, y2), both + * included, in strokeStyle and lineStyle. A line meeting another + * joins it in a corner, a tee or a cross. Only rows and columns: a + * slanted line draws nothing. + */ + strokeLine(x1: number, y1: number, x2: number, y2: number): void { + if (!isFiniteAll(x1, y1, x2, y2)) { + return; + } + const color = toGridColor(this[kGridStrokeStyle]); + if (color === undefined) { + return; + } + const [a, b, c, d] = [x1, y1, x2, y2].map(Math.round); + const line: LineStyle = {style: this[kLineStyle], color}; + const cells = this[kGridHost].cells(); + if (b === d) { + cells.drawLine(Math.min(a, c), b, Math.max(a, c) + 1, b, { + ...line, + startCap: a === c ? "square" : undefined, + endCap: a === c ? "square" : undefined, + }); + } else if (a === c) { + cells.drawLine(a, Math.min(b, d), a, Math.max(b, d) + 1, line); + } else { + return; } - return {width}; + this[kGridHost].changed(); + } + + /** A box on the cells `w` by `h` from (x, y), in strokeStyle and lineStyle. */ + strokeRect(x: number, y: number, w: number, h: number): void { + const rect = this[kRect](x, y, w, h); + const color = toGridColor(this[kGridStrokeStyle]); + if (rect === null || color === undefined) { + return; + } + const [x0, y0, x1, y1] = rect; + const side: LineStyle = {style: this[kLineStyle], color}; + const corner = this[kLineJoin] === "round" ? "round" : undefined; + this[kGridHost].cells() + .drawBox(x0, y0, x1 - x0, y1 - y0, { + top: side, + right: side, + bottom: side, + left: side, + topLeft: corner, + topRight: corner, + bottomRight: corner, + bottomLeft: corner, + }); + this[kGridHost].changed(); + } + + measureText(text: string): {width: number} { + return this[kGridHost].cells().measureText(String(text)); } /** @@ -3113,8 +3243,8 @@ export class CanvasCharacterGridContext { } let sx = 0; let sy = 0; - let sw = bitmap.width; - let sh = bitmap.height; + let sw = naturalWidthOf(bitmap); + let sh = naturalHeightOf(bitmap); let dx: number; let dy: number; let dw: number; @@ -3139,30 +3269,40 @@ export class CanvasCharacterGridContext { if (cols <= 0 || rows <= 0) { return; } - const pixels = sampleBitmap(bitmap, cols, rows * 2, { - x: sx, - y: sy, - width: sw, - height: sh, - }); const left = Math.round(dx); const top = Math.round(dy); - for (let row = 0; row < rows; row++) { - for (let col = 0; col < cols; col++) { - const upper = (row * 2 * cols + col) * 4; - const lower = ((row * 2 + 1) * cols + col) * 4; - const previous = this[kCell](left + col, top + row); - if (previous === undefined) { - continue; - } - const cellOut = halfBlockCell( + const cells = this[kGridHost].cells(); + const {grid} = cells; + // Only the cells on the grid are sampled, from the part of the + // source that lands on them. + const firstCol = Math.max(0, -left); + const firstRow = Math.max(0, -top); + const shownCols = Math.min(cols, cells.cols - left) - firstCol; + const shownRows = Math.min(rows, cells.rows - top) - firstRow; + if (shownCols <= 0 || shownRows <= 0) { + return; + } + const pixels = sampleBitmap(bitmap, shownCols, shownRows * 2, { + x: sx + (firstCol / cols) * sw, + y: sy + (firstRow / rows) * sh, + width: (shownCols / cols) * sw, + height: (shownRows / rows) * sh, + }); + for (let row = 0; row < shownRows; row++) { + for (let col = 0; col < shownCols; col++) { + const x = left + firstCol + col; + const y = top + firstRow + row; + const index = y * cells.cols + x; + const half = halfBlockCell( pixels, - upper, - lower, - previous?.bg ?? null, + (row * 2 * shownCols + col) * 4, + ((row * 2 + 1) * shownCols + col) * 4, + grid.cluster[index] !== 0 && grid.bg[index] !== 0 + ? grid.bg[index] + : null, ); - if (cellOut !== null) { - this[kSet](left + col, top + row, cellOut); + if (half !== null) { + cells.drawText(half.char, x, y, half.style); } } } @@ -3181,12 +3321,18 @@ export class CanvasCharacterGridContext { italic: boolean; underline: boolean; } | null { - const cell = this[kCell](Math.floor(x), Math.floor(y)); - if (cell == null) { + const {grid, cols, rows} = this[kGridHost].cells(); + const col = Math.floor(x); + const row = Math.floor(y); + if (col < 0 || row < 0 || col >= cols || row >= rows) { return null; } - const css = (value: number | null) => { - if (value === null) { + const cell = readCell(grid, row * cols + col); + if (cell === null) { + return null; + } + const css = (value: number) => { + if (value === 0) { return null; } const rgb = value & 0xffffff; @@ -3198,7 +3344,7 @@ export class CanvasCharacterGridContext { }); }; return { - char: cell.char ?? "", + char: cell.char, color: css(cell.fg), background: css(cell.bg), bold: cell.bold, @@ -3208,57 +3354,17 @@ export class CanvasCharacterGridContext { } reset(): void { - const grid = this[kGridHost].grid(); - grid.cells.fill(null); + this[kGridHost].cells().grid.clear(); this[kFillStyle] = "currentcolor"; + this[kGridStrokeStyle] = "currentcolor"; + this[kLineStyle] = "solid"; + this[kLineJoin] = "miter"; this[kFont] = ""; this[kTextAlign] = "left"; this[kTextDecoration] = "none"; this[kGridHost].changed(); } - // The fill color as a cell color, null for the terminal's foreground, - // or undefined for a transparent color. - [kColor](): number | null | undefined { - if (this[kFillStyle] === "currentcolor") { - return null; - } - const color = parseCanvasColor(this[kFillStyle])!; - if (color.a === 0) { - return undefined; - } - return toCellColor((color.r << 16) | (color.g << 8) | color.b); - } - - [kCell](x: number, y: number): GridCell | null | undefined { - const grid = this[kGridHost].grid(); - if (x < 0 || y < 0 || x >= grid.cols || y >= grid.rows) { - return undefined; - } - return grid.cells[y * grid.cols + x]; - } - - [kSet](x: number, y: number, cell: GridCell | null): void { - const grid = this[kGridHost].grid(); - if (x < 0 || y < 0 || x >= grid.cols || y >= grid.rows) { - return; - } - grid.cells[y * grid.cols + x] = cell; - } - - [kEmptyCell](): GridCell { - return { - char: null, - fg: null, - bg: null, - bold: false, - italic: false, - underline: false, - dim: false, - inverse: false, - }; - } - [kRect]( x: number, y: number, @@ -3280,20 +3386,52 @@ export class CanvasCharacterGridContext { } } +// A color as the grid context keeps it, serialized, or null when the +// value is not a color. +function readGridColor(value: string): string | null { + const text = String(value).trim().toLowerCase(); + if (text === "currentcolor") { + return "currentcolor"; + } + const color = parseCanvasColor(text); + return color === null ? null : serializeColor(color); +} + +// A kept color as a cell color: null for the terminal's foreground, or +// undefined for a transparent one, which draws nothing. +function toGridColor(value: string): number | null | undefined { + if (value === "currentcolor") { + return null; + } + const color = parseCanvasColor(value)!; + if (color.a === 0) { + return undefined; + } + return toCellColor((color.r << 16) | (color.g << 8) | color.b); +} + /** - * The cell two stacked pixels make: the upper half block in the upper - * pixel's color over the lower pixel's, a lower half block when only the - * lower pixel shows, a space in one color when both match, and null when - * neither shows. A pixel at least half opaque shows; a partly - * transparent one is first blended over `under` when there is a color - * under it. + * The cell two stacked pixels make: the lower half block in the lower + * pixel's color over a background in the upper's, the upper half block + * when only the upper pixel shows, a space in one color when both match, + * and null when neither shows. A pixel at least half opaque shows; a + * partly transparent one is first blended over `under` when there is a + * color under it. A half block with no background keeps the one under it. + * + * Two colors go in the lower half block, not the upper, because a + * terminal that draws block characters from the font shows the font's + * glyph, and Menlo's and SF Mono's both sit low in the cell: the upper + * one leaves the cell's top in the wrong color and runs past the middle. + * The background fills whatever the glyph leaves, so with the lower half + * block only the seam is off. Terminals that draw the blocks themselves + * show either one exactly. */ export function halfBlockCell( pixels: Uint8ClampedArray, upper: number, lower: number, under: number | null, -): GridCell | null { +): {char: string; style: CellStyle} | null { const read = (at: number): number | null => { const alpha = pixels[at + 3] / 255; let r = pixels[at]; @@ -3316,27 +3454,17 @@ export function halfBlockCell( }; const top = read(upper); const bottom = read(lower); - const cell: GridCell = { - char: "▀", - fg: top, - bg: bottom, - bold: false, - italic: false, - underline: false, - dim: false, - inverse: false, - }; if (top === null && bottom === null) { return null; } if (top === null) { - cell.char = "▄"; - cell.fg = bottom; - cell.bg = null; - } else if (top === bottom) { - cell.char = " "; - cell.fg = null; - cell.bg = top; - } - return cell; + return {char: "▄", style: {fg: bottom}}; + } + if (bottom === null) { + return {char: "▀", style: {fg: top}}; + } + if (top === bottom) { + return {char: " ", style: {bg: top}}; + } + return {char: "▄", style: {fg: bottom, bg: top}}; } diff --git a/src/internal/cssom.ts b/src/internal/cssom.ts index 420c7557c..441e4cddc 100644 --- a/src/internal/cssom.ts +++ b/src/internal/cssom.ts @@ -46,7 +46,7 @@ import { isButtonInput, MATHML_NAMESPACE, } from "./dom.ts"; -import {LINE_STYLES, type LineStyle} from "./framebuffer.ts"; +import {FALLBACK_CELL, LINE_STYLES, type LineStyle} from "./framebuffer.ts"; import { getPresentationalHints, isHintAttribute, @@ -71,11 +71,10 @@ export interface CellSize { export const UNIT_CELL: Readonly = {width: 1, height: 1}; -// What "auto" takes when the terminal cannot say. Most terminal fonts -// run about twice as tall as wide. -export const FALLBACK_CELL: Readonly = {width: 8, height: 16}; +export {FALLBACK_CELL}; const cellSizeSources = new WeakMap Readonly>(); +const screenCellSources = new WeakMap Readonly>(); export function setCellSizeSource( document: object, @@ -92,6 +91,30 @@ export function getCellSize(node: Node | null | undefined): Readonly { return cellSizeSources.get(document)?.() ?? UNIT_CELL; } +/** The cell the terminal draws, in its pixels. */ +export function setScreenCellSource( + document: object, + source: () => Readonly, +): void { + screenCellSources.set(document, source); +} + +/** + * The cell an image's pixels divide by. An image's pixels are CSS + * pixels, so this is the page's cell. Under the unit cell that would be + * a cell per pixel, so the screen's cell stands in. + */ +export function getImageCellSize( + node: Node | null | undefined, +): Readonly { + const page = getCellSize(node); + if (page !== UNIT_CELL) { + return page; + } + const document = node?.ownerDocument ?? node; + return (document && screenCellSources.get(document)?.()) ?? FALLBACK_CELL; +} + /** * CSS pixels along one axis as cells, fractions kept. Layout rounds the * edges of the boxes it places, not each length, so lengths that add up diff --git a/src/internal/dom.ts b/src/internal/dom.ts index f62c2019a..13e1c40ad 100644 --- a/src/internal/dom.ts +++ b/src/internal/dom.ts @@ -30,6 +30,7 @@ import { getBoxModel, getCellSize, getComputedValue, + getImageCellSize, getInlineStyle, getStyleSheets, getWhiteSpace, @@ -50,7 +51,7 @@ import { import * as CSSValues from "./cssvalues.ts"; import {getEditingHost, installEditing} from "./editing.ts"; import type {Exchange} from "./exchange.ts"; -import type {Framebuffer} from "./framebuffer.ts"; +import {CellContext, CellGrid, type Framebuffer} from "./framebuffer.ts"; import { getInterface, getReflections, @@ -58,16 +59,24 @@ import { } from "./htmlreflection.ts"; import { type Bitmap, - type CharacterGrid, createBitmap, - decodeImage, - fetchImageBytes, + decodeImageOffThread, + IMAGE_ACCEPT, + MAX_IMAGE_PIXELS, + naturalHeightOf, + naturalWidthOf, + readImageResponse, registerReplacedContent, resolveImageURL, } from "./images.ts"; import type {Layout} from "./layout.ts"; import {linearizeMath} from "./mathml.ts"; -import {getCellPixels, registerCellPixels} from "./replaced.ts"; +import { + addDocumentPolicy, + getLoadSignal, + loadResource, + setRequestDestination, +} from "./resources.ts"; import { getNextGraphemeBoundary, getPreviousGraphemeBoundary, @@ -2651,6 +2660,127 @@ Object.defineProperty(InputEvent.prototype, Symbol.toStringTag, { configurable: true, }); +// WebIDL's USVString: a lone surrogate becomes U+FFFD. +function toUSVString(value: unknown): string { + return String(value).replace( + /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?( + eventInitDict, + "A security policy violation event init", + ); + const disposition = String(init.disposition ?? "enforce"); + if (disposition !== "enforce" && disposition !== "report") { + throw new TypeError( + `"${disposition}" is not a security policy violation disposition`, + ); + } + this[kViolation] = { + documentURI: toUSVString(init.documentURI ?? ""), + referrer: toUSVString(init.referrer ?? ""), + blockedURI: toUSVString(init.blockedURI ?? ""), + effectiveDirective: String( + init.effectiveDirective || init.violatedDirective || "", + ), + originalPolicy: String(init.originalPolicy ?? ""), + sourceFile: toUSVString(init.sourceFile ?? ""), + sample: String(init.sample ?? ""), + disposition, + statusCode: toUnsignedShort(init.statusCode ?? 0), + lineNumber: toUnsignedLong(init.lineNumber ?? 0), + columnNumber: toUnsignedLong(init.columnNumber ?? 0), + }; + } + + get documentURI(): string { + return this[kViolation].documentURI; + } + + get referrer(): string { + return this[kViolation].referrer; + } + + get blockedURI(): string { + return this[kViolation].blockedURI; + } + + get effectiveDirective(): string { + return this[kViolation].effectiveDirective; + } + + // A historical alias of effectiveDirective. + get violatedDirective(): string { + return this[kViolation].effectiveDirective; + } + + get originalPolicy(): string { + return this[kViolation].originalPolicy; + } + + get sourceFile(): string { + return this[kViolation].sourceFile; + } + + get sample(): string { + return this[kViolation].sample; + } + + get disposition(): globalThis.SecurityPolicyViolationEventDisposition { + return this[kViolation].disposition; + } + + get statusCode(): number { + return this[kViolation].statusCode; + } + + get lineNumber(): number { + return this[kViolation].lineNumber; + } + + get columnNumber(): number { + return this[kViolation].columnNumber; + } +} + +Object.defineProperty( + SecurityPolicyViolationEvent.prototype, + Symbol.toStringTag, + {value: "SecurityPolicyViolationEvent", configurable: true}, +); + // A transfer here carries text under format names and nothing else. There // is no drag and drop in a terminal and no files to hand over, so // `dropEffect`, `effectAllowed`, `setDragImage()` and `files` exist, @@ -14275,7 +14405,7 @@ class HTMLCanvasElement extends HTMLElement { declare height: globalThis.HTMLCanvasElement["height"]; declare width: globalThis.HTMLCanvasElement["width"]; [kCanvasBitmap]: Bitmap | null; - [kCanvasGrid]: CharacterGrid | null; + [kCanvasGrid]: CellContext | null; [kCanvasContext]: CanvasRenderingContext2D | CanvasCharacterGridContext | null; @@ -14294,7 +14424,7 @@ class HTMLCanvasElement extends HTMLElement { if (this[kCanvasContext] instanceof CanvasCharacterGridContext) { return { kind: "grid", - grid: getCanvasGrid(this), + grid: getCanvasGrid(this).grid, version: this[kCanvasVersion], }; } @@ -14384,16 +14514,16 @@ class HTMLCanvasElement extends HTMLElement { if (rect && rect.width > 0 && rect.height > 0) { return {x: this.width / rect.width, y: this.height / rect.height}; } - const cell = getCellPixels(this); + const cell = getImageCellSize(this as unknown as Node); return {x: cell.width, y: cell.height}; }, }); } else if (id === "charactergrid") { this[kCanvasContext] = new CanvasCharacterGridContext({ canvas: this, - grid: () => getCanvasGrid(this), + cells: () => getCanvasGrid(this), changed, - cellPixels: () => getCellPixels(this), + cellPixels: () => getImageCellSize(this as unknown as Node), }); // The natural size is now in cells, not pixels. invalidateReplaced(this, true); @@ -14477,16 +14607,16 @@ class HTMLCanvasElement extends HTMLElement { } // The bitmap, made at the canvas's size when first needed. -// Past this many pixels a canvas gets no bitmap and draws nothing, as a +// Past MAX_IMAGE_PIXELS a canvas gets no bitmap and draws nothing, as a // browser's canvas does past its own limit, rather than failing to -// allocate. -const CANVAS_PIXEL_LIMIT = 1 << 26; +// allocate. A cell takes 18 bytes, so a character grid stops sooner. +const GRID_CELL_LIMIT = 1 << 22; // The bitmap, made at the canvas's size when first drawn on. function getCanvasBitmap(canvas: HTMLCanvasElement): Bitmap { const width = canvas.width; const height = canvas.height; - if (width * height > CANVAS_PIXEL_LIMIT) { + if (width * height > MAX_IMAGE_PIXELS) { return createBitmap(0, 0); } let bitmap = canvas[kCanvasBitmap]; @@ -14496,17 +14626,17 @@ function getCanvasBitmap(canvas: HTMLCanvasElement): Bitmap { return bitmap; } -function getCanvasGrid(canvas: HTMLCanvasElement): CharacterGrid { - const fits = canvas.width * canvas.height <= CANVAS_PIXEL_LIMIT; +function getCanvasGrid(canvas: HTMLCanvasElement): CellContext { + const fits = canvas.width * canvas.height <= GRID_CELL_LIMIT; const cols = fits ? canvas.width : 0; const rows = fits ? canvas.height : 0; - let grid = canvas[kCanvasGrid]; - if (grid === null || grid.cols !== cols || grid.rows !== rows) { - grid = + let cells = canvas[kCanvasGrid]; + if (cells === null || cells.cols !== cols || cells.rows !== rows) { + cells = canvas[kCanvasGrid] = - {cols, rows, cells: new Array(cols * rows).fill(null)}; + new CellContext(new CellGrid(rows, cols), rows, cols, 0); } - return grid; + return cells; } // Resizing a canvas resets its context to the default state (HTML @@ -16468,6 +16598,8 @@ interface ImageState { // Bumped with each new request, so a slow one that finishes after a // newer one started is dropped. generation: number; + // Cancels the request in flight when a newer one replaces it. + abort: AbortController | null; version: number; waiters: Array<{resolve: () => void; reject: (error: unknown) => void}>; // A source set while the document had no window, loaded on insertion @@ -16550,6 +16682,7 @@ class HTMLImageElement extends HTMLElement { url: "", bitmap: null, generation: 0, + abort: null, version: 0, waiters: [], pending: false, @@ -16564,12 +16697,15 @@ class HTMLImageElement extends HTMLElement { smooth: true, }; } - // Alt text stands in for an image that is not there. + // An image not showing, whether loading, broken or blocked, is a + // box with its alt text. An empty alt marks one that is only + // decoration, and with no source and no alt there is nothing to + // stand in for: both show nothing. const alt = this.getAttribute("alt"); - if (alt !== null && alt !== "" && state.status !== "loading") { - return {kind: "text", text: alt}; + if (alt === "" || (alt === null && state.status === "unavailable")) { + return null; } - return null; + return {kind: "text", text: alt ?? ""}; }); registerDrawable(this, () => { const state = this[kImageState]; @@ -16581,11 +16717,13 @@ class HTMLImageElement extends HTMLElement { } get naturalWidth(): number { - return this[kImageState].bitmap?.width ?? 0; + const bitmap = this[kImageState].bitmap; + return bitmap === null ? 0 : naturalWidthOf(bitmap); } get naturalHeight(): number { - return this[kImageState].bitmap?.height ?? 0; + const bitmap = this[kImageState].bitmap; + return bitmap === null ? 0 : naturalHeightOf(bitmap); } // The rendered width in cells when the image is rendered, which is @@ -16715,6 +16853,8 @@ function readImageDimension( function updateImageData(image: HTMLImageElement): void { const state = image[kImageState]; const generation = ++state.generation; + state.abort?.abort(); + state.abort = null; // decode() calls waiting on the request this one replaces reject, as // HTML's "update the image data" says. for (const waiter of state.waiters.splice(0)) { @@ -16787,12 +16927,43 @@ function updateImageData(image: HTMLImageElement): void { // a src does not collapse the box in between. state.status = "loading"; invalidateReplaced(image, false); - fetchImageBytes(url) - .then((bytes) => decodeImage(bytes)) - .then( - (bitmap) => settle("complete", bitmap), - (error) => settle("broken", null, error), - ); + const abort = state.abort = new AbortController(); + const document = image.ownerDocument; + // As HTML's "update the image data" does, the request waits for the + // script that set the source to finish. A listener added just after + // the markup that holds the image sees it. + queueMicrotask(() => { + if (state.generation !== generation) { + return; + } + let request: Request; + try { + request = new Request(url, { + headers: {accept: IMAGE_ACCEPT}, + signal: abort.signal, + }); + } catch (error) { + settle("broken", null, error); + return; + } + setRequestDestination(request, "image"); + // Disposing the TermDOM aborts the load, the request and a decode + // still waiting for its turn alike. Listened for only while the + // load runs, so the TermDOM holds no image it has finished with. + const ended = getLoadSignal(document); + const onEnded = () => abort.abort(ended!.reason); + ended?.addEventListener("abort", onEnded, {once: true}); + loadResource(document, request, { + initiator: image as unknown as globalThis.Element, + }) + .then((response) => readImageResponse(response)) + .then((bytes) => decodeImageOffThread(bytes, abort.signal)) + .finally(() => ended?.removeEventListener("abort", onEnded)) + .then( + (bitmap) => settle("complete", bitmap), + (error) => settle("broken", null, error), + ); + }); } const kDirtyValue = Symbol("dirtyValue"); @@ -18498,6 +18669,24 @@ class HTMLMetaElement extends HTMLElement { declare content: globalThis.HTMLMetaElement["content"]; declare httpEquiv: globalThis.HTMLMetaElement["httpEquiv"]; declare scheme: globalThis.HTMLMetaElement["scheme"]; + + // HTML §4.2.5.3: the Content-Security-Policy pragma runs when the + // element is inserted into a document as a child of its head. + override [kInsertionSteps](): void { + super[kInsertionSteps](); + const content = this.getAttribute("content"); + if ( + this.isConnected && + this.parentNode?.nodeType === ELEMENT_NODE && + (this.parentNode as Element).localName === "head" && + (this.parentNode as Element).namespaceURI === HTML_NAMESPACE && + this.getAttribute("http-equiv")?.toLowerCase() === + "content-security-policy" && + content + ) { + addDocumentPolicy(this.ownerDocument, content); + } + } } // A run of full blocks for the filled bar and a run of light shade for @@ -33368,7 +33557,6 @@ export function attachDocument( attached[kFramebuffer] = framebuffer; attached[kPendingCaretReveal] = null; attached[kPendingEditingReveal] = null; - registerCellPixels(attached, () => framebuffer.cellPixels); for (const type of ["input", "select", "change", "selectionchange"]) { exchange.addEventListener(type, onTextControlEditEvent); } @@ -35541,7 +35729,7 @@ export class Window extends EventTarget { // A Blob is decoded first; an image still loading is waited for. if (typeof Blob !== "undefined" && image instanceof Blob) { return image.arrayBuffer() - .then((buffer) => decodeImage(new Uint8Array(buffer))) + .then((buffer) => decodeImageOffThread(new Uint8Array(buffer))) .then((bitmap) => settle(new ImageBitmap(bitmap))); } if (image instanceof HTMLImageElement && !image.complete) { @@ -35802,6 +35990,7 @@ const platform = { SVGElement, Screen, ScreenOrientation, + SecurityPolicyViolationEvent, Selection, ShadowRoot, StaticRange, @@ -35873,9 +36062,26 @@ function buildWindow(document: Document): Window { }); window.window = window; window.self = window; + startPendingImages(document as unknown as globalThis.Document); return window as unknown as Window; } +// Images the parser built before their document had a window wait for +// one, and start here, once it does. +function startPendingImages( + root: globalThis.Document | globalThis.ShadowRoot, +): void { + for (const element of root.querySelectorAll("*")) { + if (element instanceof HTMLImageElement && element[kImageState].pending) { + updateImageData(element); + } + const shadow = getShadowRoot(element); + if (shadow !== null) { + startPendingImages(shadow); + } + } +} + /** * Parse a document from markup and display it in a window of its own. An * XML content type parses as XML, which is what a frame whose src names a @@ -35928,6 +36134,7 @@ export type { CompositionEvent, TextEvent, InputEvent, + SecurityPolicyViolationEvent, FileList, DataTransferItem, DataTransferItemList, diff --git a/src/internal/framebuffer.ts b/src/internal/framebuffer.ts index ea998cd5c..5113168ca 100644 --- a/src/internal/framebuffer.ts +++ b/src/internal/framebuffer.ts @@ -710,7 +710,7 @@ function getBorderChar(borderEncoding: number): string { * two-column glyph covers. The glyph's width text control steps the emitter * over it. */ -class CellGrid { +export class CellGrid { readonly rows: number; readonly cols: number; readonly cluster: Uint32Array; @@ -768,6 +768,12 @@ class CellGrid { this.border.set(source.border.subarray(start, end), to); } + clone(): CellGrid { + const copy = new CellGrid(this.rows, this.cols); + copy.copyFrom(this, {to: 0, start: 0, end: this.rows * this.cols}); + return copy; + } + bottomRows(rows: number): CellGrid { const kept = new CellGrid(rows, this.cols); kept.copyFrom(this, { @@ -863,6 +869,52 @@ function meetEdges(existing: number, incoming: number): number { return met; } +/** + * A grid's lines joined where they touch and drawn as their glyphs, so + * the grid copies into another without joining what is there. + */ +export function settleBorders(grid: CellGrid): void { + joinTouchingBorders(grid); + const {border, cluster} = grid; + for (let index = 0; index < border.length; index++) { + if (border[index] > 0) { + cluster[index] = encodeGrapheme(getBorderChar(border[index])); + border[index] = 0; + } + } +} + +/** A cell's glyph, colors and attributes, or null for an empty cell. */ +export function readCell( + grid: CellGrid, + index: number, +): { + char: string; + fg: number; + bg: number; + bold: boolean; + italic: boolean; + underline: boolean; + inverse: boolean; +} | null { + if (grid.cluster[index] === 0) { + return null; + } + const attrs = grid.attrs[index]; + const border = grid.border[index]; + return { + char: border > 0 + ? getBorderChar(border) + : decodeGrapheme(grid.cluster[index]), + fg: grid.fg[index], + bg: grid.bg[index], + bold: (attrs & ATTR.Bold) !== 0, + italic: (attrs & ATTR.Italic) !== 0, + underline: (attrs & (ATTR.Underline | ATTR.DoubleUnderline)) !== 0, + inverse: (attrs & ATTR.Inverse) !== 0, + }; +} + function joinTouchingBorders(grid: CellGrid): void { const {rows, cols, border} = grid; const painted = border.slice(); @@ -1119,6 +1171,37 @@ export class CellContext { } } + /** + * Another grid's cells with its top left at (x, y). An empty cell + * leaves what is under it, one with no background takes the + * background under it, and one in the default foreground takes + * `fallback.fg`. + */ + drawGrid(source: CellGrid, x: number, y: number, fallback?: CellStyle): void { + const grid = this.grid; + const fg = (fallback?.fg ?? 0) & COLOR_MASK; + for (let row = 0; row < source.rows; row++) { + for (let col = 0; col < source.cols; col++) { + const from = row * source.cols + col; + if (source.cluster[from] === 0) { + continue; + } + const index = getGuardedWriteIndex(this, y + row, x + col); + if (index < 0) { + continue; + } + const under = grid.cluster[index] !== 0 ? grid.bg[index] : 0; + grid.setFrom(index, source, from); + if (grid.fg[index] === 0) { + grid.fg[index] = fg; + } + if (grid.bg[index] === 0) { + grid.bg[index] = under; + } + } + } + } + drawDecoration(x: number, y: number, width: number, style: CellStyle): void { // An edge across existing glyphs, which drawText cannot draw because it // overwrites. The style's fg is the row's default. A cell with its own @@ -1790,9 +1873,12 @@ const kFrameScroll = Symbol("frameScroll"); const kDirty = Symbol("dirty"); const kDocumentTop = Symbol("documentTop"); const kCellPixels = Symbol("cellPixels"); -// What a cell measures until the terminal says. Most fonts run about -// twice as tall as wide at any size. -const DEFAULT_CELL_PIXELS = {width: 8, height: 16}; +// What a cell measures in pixels until the terminal says, or when it +// cannot. Most fonts run about twice as tall as wide at any size. +export const FALLBACK_CELL: Readonly<{width: number; height: number}> = { + width: 8, + height: 16, +}; export interface Framebuffer { [kPrev]: CellGrid | null; @@ -1846,7 +1932,7 @@ export class Framebuffer { this[kCols] = cols; this[kScrollTop] = 0; this[kDocumentTop] = 0; - this[kCellPixels] = {...DEFAULT_CELL_PIXELS}; + this[kCellPixels] = {...FALLBACK_CELL}; this[kFrameScroll] = 0; this[kDirty] = true; this[kWriter] = new FrameWriter(colorDepth); diff --git a/src/internal/images.ts b/src/internal/images.ts index 36c89bb21..59e6ba886 100644 --- a/src/internal/images.ts +++ b/src/internal/images.ts @@ -3,8 +3,8 @@ * encoding a bitmap as PNG, and sampling a bitmap down to the two * pixels a cell shows. * - * A cell draws two pixels stacked: the upper half block (U+2580) in the - * foreground color over the background color. Everything a terminal can + * A cell draws two pixels stacked: the lower half block (U+2584) in the + * foreground color under the background color. Everything a terminal can * show of an image goes through cells that way, so an image scrolls, * clips and diffs like any other text, over SSH and inside tmux too. * @@ -12,12 +12,117 @@ * and the canvas and the painter read it back through the registry at * the bottom. */ +import type {CellGrid} from "./framebuffer.ts"; /** Straight (not premultiplied) RGBA, four bytes a pixel, rows top down. */ export interface Bitmap { width: number; height: number; data: Uint8ClampedArray; + // The size the image is, when its pixels were kept smaller than that. + // Everything outside the pixels themselves measures this one. + naturalWidth?: number; + naturalHeight?: number; +} + +export function naturalWidthOf(bitmap: Bitmap): number { + return bitmap.naturalWidth ?? bitmap.width; +} + +export function naturalHeightOf(bitmap: Bitmap): number { + return bitmap.naturalHeight ?? bitmap.height; +} + +/** + * The largest image anything here allocates: 32768 pixels to a side and + * 2^26 in all, 256 MB as RGBA. A file states its size before its + * pixels, so a few bytes can ask for any amount of memory. Like a + * browser, a decoder refuses a size past these before it allocates. + */ +const MAX_IMAGE_SIDE = 32768; +export const MAX_IMAGE_PIXELS = 1 << 26; + +/** Throws for a size an image may not have. */ +function checkImageSize(width: number, height: number, format: string): void { + if ( + !Number.isInteger(width) || + !Number.isInteger(height) || + width <= 0 || + height <= 0 + ) { + throw new Error(`The ${format} image has no size`); + } + if ( + width > MAX_IMAGE_SIDE || + height > MAX_IMAGE_SIDE || + width * height > MAX_IMAGE_PIXELS + ) { + throw new Error( + `The ${format} image is ${width} by ${height} pixels, more than an image may be`, + ); + } +} + +// A browser decodes on another thread. On one, a decoder hands the event +// loop a turn this often, so a large image does not stall the page +// while it decodes. +const DECODE_SLICE_MS = 16; + +// Returns a promise to await when a turn is due, and undefined between. +// Past `deadline`, a time from Date.now(), the promise rejects instead. +function createYielder(deadline: number): () => Promise | undefined { + let last = performance.now(); + return () => { + if (performance.now() - last < DECODE_SLICE_MS) { + return undefined; + } + return new Promise((resolve, reject) => { + setTimeout(() => { + last = performance.now(); + if (Date.now() > deadline) { + reject(new Error("The image took too long to decode")); + } else { + resolve(); + } + }, 0); + }); + }; +} + +/** + * The most pixels a decoded image keeps, 4 MB of them. A terminal shows a + * few hundred thousand at most, so a larger image is kept averaged down + * to this, and still reports its full size as its natural size. + */ +export const MAX_KEPT_PIXELS = 1 << 20; + +/** The image as a page keeps it: at most MAX_KEPT_PIXELS. */ +export function keepAtMost(bitmap: Bitmap): Bitmap { + const {width, height} = bitmap; + if (width * height <= MAX_KEPT_PIXELS) { + return bitmap; + } + const scale = Math.sqrt(MAX_KEPT_PIXELS / (width * height)); + const keptWidth = Math.max(1, Math.floor(width * scale)); + const keptHeight = Math.max(1, Math.floor(height * scale)); + return { + width: keptWidth, + height: keptHeight, + data: sampleBitmap(bitmap, keptWidth, keptHeight), + naturalWidth: width, + naturalHeight: height, + }; +} + +/** + * Decode, and keep at most MAX_KEPT_PIXELS. A decode still running at + * `deadline` stops at its next yield. + */ +export async function decodeImageForPage( + bytes: Uint8Array, + deadline = Infinity, +): Promise { + return keepAtMost(await decodeImage(bytes, deadline)); } export function createBitmap(width: number, height: number): Bitmap { @@ -57,12 +162,15 @@ export function sniffImageType(bytes: Uint8Array): string | null { * sniffs it, not from a name or a content type. Rejects with the reason * a format is unsupported or the data is damaged. */ -export async function decodeImage(bytes: Uint8Array): Promise { +export async function decodeImage( + bytes: Uint8Array, + deadline = Infinity, +): Promise { switch (sniffImageType(bytes)) { case "image/png": - return await decodePNG(bytes); + return await decodePNG(bytes, deadline); case "image/jpeg": - return decodeJPEG(bytes); + return await decodeJPEG(bytes, deadline); case "image/gif": return decodeGIF(bytes); case "image/bmp": @@ -88,11 +196,26 @@ function readUint32(bytes: Uint8Array, offset: number): number { // zlib, which is what CompressionStream calls "deflate". Node, Bun, Deno // and browsers all have it, and nothing else here needs a platform API. -async function inflate(data: Uint8Array): Promise { - const stream = new Blob([data as BlobPart]) +// Reading stops at `limit` bytes: deflate shrinks a run of zeros a +// thousandfold, so what a stream would inflate to is no bound at all. +async function inflate(data: Uint8Array, limit: number): Promise { + const reader = new Blob([data as BlobPart]) .stream() - .pipeThrough(new DecompressionStream("deflate")); - return new Uint8Array(await new Response(stream).arrayBuffer()); + .pipeThrough(new DecompressionStream("deflate")) + .getReader(); + const out = new Uint8Array(limit); + let length = 0; + while (length < limit) { + const {done, value} = await reader.read(); + if (done) { + break; + } + const take = Math.min(value.length, limit - length); + out.set(value.subarray(0, take), length); + length += take; + } + await reader.cancel().catch(() => {}); + return out.subarray(0, length); } // Adam7: the start and step of each pass, columns then rows. @@ -106,7 +229,29 @@ const ADAM7 = [ [0, 1, 1, 2], ]; -async function decodePNG(bytes: Uint8Array): Promise { +// The bytes a PNG's image data inflates to: each row's filter byte and +// samples, over each Adam7 pass when interlaced. +function getPNGDataSize( + width: number, + height: number, + bitsPerPixel: number, + interlaced: boolean, +): number { + const rowSize = (columns: number) => + columns === 0 ? 0 : 1 + Math.ceil((columns * bitsPerPixel) / 8); + if (!interlaced) { + return height * rowSize(width); + } + let size = 0; + for (const [x0, y0, dx, dy] of ADAM7) { + const columns = Math.ceil(Math.max(0, width - x0) / dx); + const rows = Math.ceil(Math.max(0, height - y0) / dy); + size += rows * rowSize(columns); + } + return size; +} + +async function decodePNG(bytes: Uint8Array, deadline: number): Promise { for (let i = 0; i < 8; i++) { if (bytes[i] !== PNG_SIGNATURE[i]) { throw new Error("Not a PNG image"); @@ -177,6 +322,7 @@ async function decodePNG(bytes: Uint8Array): Promise { if (channels === undefined || ![1, 2, 4, 8, 16].includes(depth)) { throw new Error("The PNG image has an unknown color type or depth"); } + checkImageSize(width, height, "PNG"); let joined = parts[0]; if (parts.length > 1) { joined = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0)); @@ -186,9 +332,12 @@ async function decodePNG(bytes: Uint8Array): Promise { at += part.length; } } - const raw = await inflate(joined); - const bitmap = createBitmap(width, height); const bitsPerPixel = channels * depth; + const raw = await inflate( + joined, + getPNGDataSize(width, height, bitsPerPixel, interlace === 1), + ); + const bitmap = createBitmap(width, height); const bytesPerPixel = Math.max(1, bitsPerPixel >> 3); const max = (1 << depth) - 1; @@ -247,7 +396,8 @@ async function decodePNG(bytes: Uint8Array): Promise { }; let at = 0; - const unfilter = ( + const pause = createYielder(deadline); + const unfilter = async ( passWidth: number, passHeight: number, emit: (line: Uint8Array, y: number) => void, @@ -290,6 +440,7 @@ async function decodePNG(bytes: Uint8Array): Promise { } emit(current, y); [previous, current] = [current, previous]; + await pause(); } }; @@ -300,12 +451,12 @@ async function decodePNG(bytes: Uint8Array): Promise { if (passWidth <= 0 || passHeight <= 0) { continue; } - unfilter(passWidth, passHeight, (line, y) => + await unfilter(passWidth, passHeight, (line, y) => store(line, passWidth, x0, dx, y0 + y * dy), ); } } else { - unfilter(width, height, (line, y) => store(line, width, 0, 1, y)); + await unfilter(width, height, (line, y) => store(line, width, 0, 1, y)); } return bitmap; } @@ -408,6 +559,9 @@ export function encodePNG(bitmap: Bitmap): Uint8Array { // factors, grayscale, YCbCr, and Adobe's RGB, CMYK and YCCK. Arithmetic // coding and lossless JPEG are rare enough to be left out. +// libjpeg-turbo's suggested limit. A progressive image has ten or so. +const MAX_JPEG_SCANS = 500; + const ZIGZAG = new Int32Array([ 0, 1, @@ -666,7 +820,7 @@ class BitReader { } } -function decodeScan( +async function decodeScan( data: Uint8Array, offset: number, frame: JPEGFrame, @@ -676,7 +830,8 @@ function decodeScan( spectralEnd: number, successivePrev: number, successive: number, -): number { + pause: () => Promise | undefined, +): Promise { const reader = new BitReader(data, offset); const progressive = frame.progressive; let eobrun = 0; @@ -842,6 +997,10 @@ function decodeScan( eobrun = 0; successiveState = 0; for (let n = 0; n < interval && mcu < total; n++, mcu++) { + const turn = (mcu & 63) === 0 ? pause() : undefined; + if (turn !== undefined) { + await turn; + } if (components.length === 1) { const component = components[0]; const row = Math.floor(mcu / component.blocksPerLine); @@ -978,12 +1137,14 @@ function readExifOrientation( return 1; } -function decodeJPEG(data: Uint8Array): Bitmap { +async function decodeJPEG(data: Uint8Array, deadline: number): Promise { + const pause = createYielder(deadline); const quantTables: Uint16Array[] = []; const dcTables: HuffmanTable[] = []; const acTables: HuffmanTable[] = []; let frame: JPEGFrame | null = null; let resetInterval = 0; + let scans = 0; let adobeTransform = -1; let jfif = false; let orientation = 1; @@ -1058,6 +1219,12 @@ function decodeJPEG(data: Uint8Array): Bitmap { const height = (data[start + 1] << 8) | data[start + 2]; const width = (data[start + 3] << 8) | data[start + 4]; const count = data[start + 5]; + checkImageSize(width, height, "JPEG"); + // Each component is a plane the size of the image, and an image + // is gray, YCbCr or CMYK. + if (count < 1 || count > 4) { + throw new Error("The JPEG image has more than four components"); + } const components: JPEGComponent[] = []; let maxH = 1; let maxV = 1; @@ -1065,6 +1232,9 @@ function decodeJPEG(data: Uint8Array): Bitmap { const at = start + 6 + i * 3; const h = data[at + 1] >> 4 || 1; const v = data[at + 1] & 15 || 1; + if (h > 4 || v > 4) { + throw new Error("The JPEG image samples a component past four"); + } maxH = Math.max(maxH, h); maxV = Math.max(maxV, v); components.push({ @@ -1081,9 +1251,6 @@ function decodeJPEG(data: Uint8Array): Bitmap { pred: 0, }); } - if (width === 0 || height === 0) { - throw new Error("The JPEG image has no size"); - } const mcusPerLine = Math.ceil(width / (8 * maxH)); const mcusPerColumn = Math.ceil(height / (8 * maxV)); for (const component of components) { @@ -1115,6 +1282,13 @@ function decodeJPEG(data: Uint8Array): Bitmap { if (frame === null) { throw new Error("The JPEG scan comes before its frame"); } + // Each scan passes over the whole image, so a file of a few + // kilobytes could ask for any number of passes. + if (++scans > MAX_JPEG_SCANS) { + throw new Error( + `The JPEG image has more than ${MAX_JPEG_SCANS} scans`, + ); + } const count = data[start]; const scanComponents: JPEGComponent[] = []; for (let i = 0; i < count; i++) { @@ -1129,7 +1303,7 @@ function decodeJPEG(data: Uint8Array): Bitmap { scanComponents.push(component); } const at = start + 1 + count * 2; - offset = decodeScan( + offset = await decodeScan( data, end, frame, @@ -1139,6 +1313,7 @@ function decodeJPEG(data: Uint8Array): Bitmap { data[at + 1], data[at + 2] >> 4, data[at + 2] & 15, + pause, ); continue; } @@ -1159,20 +1334,28 @@ function decodeJPEG(data: Uint8Array): Bitmap { throw new Error("The JPEG image has no frame"); } return orient( - buildJPEGBitmap(frame, quantTables, adobeTransform, jfif), + await buildJPEGBitmap(frame, quantTables, adobeTransform, jfif, pause), orientation, ); } -function buildJPEGBitmap( +async function buildJPEGBitmap( frame: JPEGFrame, quantTables: Uint16Array[], adobeTransform: number, jfif: boolean, -): Bitmap { + pause: () => Promise | undefined, +): Promise { const {width, height, components} = frame; // Each component's samples at its own resolution. - const planes = components.map((component) => { + const planes: Array<{ + plane: Uint8ClampedArray; + planeWidth: number; + planeHeight: number; + scaleX: number; + scaleY: number; + }> = []; + for (const component of components) { const planeWidth = component.blocksPerLineForMcu * 8; const rows = (component.blocks.length / 64 / component.blocksPerLineForMcu) * 8; @@ -1186,6 +1369,7 @@ function buildJPEGBitmap( const blocksPerRow = component.blocksPerLineForMcu; const blockRows = rows / 8; for (let blockRow = 0; blockRow < blockRows; blockRow++) { + await pause(); for (let blockCol = 0; blockCol < blocksPerRow; blockCol++) { inverseDCT( component.blocks, @@ -1202,14 +1386,14 @@ function buildJPEGBitmap( } } } - return { + planes.push({ plane, planeWidth, planeHeight: rows, scaleX: component.h / frame.maxH, scaleY: component.v / frame.maxV, - }; - }); + }); + } const bitmap = createBitmap(width, height); const data = bitmap.data; @@ -1228,6 +1412,7 @@ function buildJPEGBitmap( ); const sample = new Float32Array(4); for (let y = 0; y < height; y++) { + await pause(); for (let x = 0; x < width; x++) { for (let c = 0; c < count; c++) { const {plane, planeWidth, planeHeight, scaleX, scaleY} = planes[c]; @@ -1413,20 +1598,28 @@ function decodeGIF(data: Uint8Array): Bitmap { if (table === null) { throw new Error("The GIF image has no color table"); } + checkImageSize(frameWidth, frameHeight, "GIF"); + checkImageSize(width || frameWidth, height || frameHeight, "GIF"); const minCodeSize = data[offset++]; - const chunks: number[] = []; + if (minCodeSize < 2 || minCodeSize > 8) { + throw new Error("The GIF frame's code size is not 2 to 8 bits"); + } + const chunks: Uint8Array[] = []; + let length = 0; while (offset < data.length && data[offset] !== 0) { const size = data[offset++]; - for (let i = 0; i < size; i++) { - chunks.push(data[offset + i]); - } + const chunk = data.subarray(offset, offset + size); + chunks.push(chunk); + length += chunk.length; offset += size; } - const indices = decodeLZW( - Uint8Array.from(chunks), - minCodeSize, - frameWidth * frameHeight, - ); + const joined = new Uint8Array(length); + let at = 0; + for (const chunk of chunks) { + joined.set(chunk, at); + at += chunk.length; + } + const indices = decodeLZW(joined, minCodeSize, frameWidth * frameHeight); const bitmap = createBitmap(width || frameWidth, height || frameHeight); const interlaced = (flags & 0x40) !== 0; const rowOrder = interlaced ? interlacedRows(frameHeight) : null; @@ -1554,9 +1747,7 @@ function decodeBMP(data: Uint8Array): Bitmap { const compression = headerSize >= 40 ? view.getUint32(30, true) : 0; const height = Math.abs(rawHeight); const bottomUp = rawHeight > 0; - if (width <= 0 || height === 0) { - throw new Error("The BMP image has no size"); - } + checkImageSize(width, height, "BMP"); const rle = (compression === 1 && bits === 8) || (compression === 2 && bits === 4); if (compression !== 0 && compression !== 3 && !rle) { @@ -1713,21 +1904,31 @@ export function sampleBitmap( bitmap: Bitmap, outWidth: number, outHeight: number, - source: {x: number; y: number; width: number; height: number} = { + natural: {x: number; y: number; width: number; height: number} = { x: 0, y: 0, - width: bitmap.width, - height: bitmap.height, + width: naturalWidthOf(bitmap), + height: naturalHeightOf(bitmap), }, smooth = true, ): Uint8ClampedArray { const out = new Uint8ClampedArray(Math.max(0, outWidth * outHeight * 4)); if ( - outWidth <= 0 || outHeight <= 0 || source.width <= 0 || source.height <= 0 + outWidth <= 0 || outHeight <= 0 || natural.width <= 0 || natural.height <= 0 ) { return out; } const {width, height, data} = bitmap; + // The rectangle is in the image's natural pixels, and the pixels kept + // may be fewer. + const keptX = width / naturalWidthOf(bitmap); + const keptY = height / naturalHeightOf(bitmap); + const source = { + x: natural.x * keptX, + y: natural.y * keptY, + width: natural.width * keptX, + height: natural.height * keptY, + }; const scaleX = source.width / outWidth; const scaleY = source.height / outHeight; for (let oy = 0; oy < outHeight; oy++) { @@ -1835,29 +2036,10 @@ export type ReplacedContent = } | // A canvas not drawn on yet: its size, and nothing to paint. {kind: "blank"; width: number; height: number} | - {kind: "grid"; grid: CharacterGrid; version: number} | + {kind: "grid"; grid: CellGrid; version: number} | {kind: "text"; text: string} | null; -/** One cell of a character grid. A null char leaves the cell's text alone. */ -export interface GridCell { - char: string | null; - fg: number | null; - bg: number | null; - bold: boolean; - italic: boolean; - underline: boolean; - dim: boolean; - // Painted in the terminal's own colors, swapped. - inverse: boolean; -} - -export interface CharacterGrid { - cols: number; - rows: number; - cells: Array; -} - const replacedSources = new WeakMap ReplacedContent>(); export function registerReplacedContent( @@ -1877,116 +2059,414 @@ export function isReplacedElement(owner: object): boolean { } // --------------------------------------------------------------------------- -// Fetching what a src names. +// Reading what a src names. -/** The bytes of a data: URL, or null when it is not one. */ -export function readDataURL(url: string): Uint8Array | null { - const match = /^data:([^,]*?)(;base64)?,(.*)$/is.exec(url); - if (match === null) { - return null; +/** + * The URL a src names, against the document's URL, or null. A relative + * src in a document with no URL of its own, at about:blank, names + * nothing. + */ +export function resolveImageURL(src: string, base: string): string | null { + // A Windows path's drive letter would read as a URL scheme. + if (/^[a-z]:[\\/]/i.test(src)) { + return new URL(`file:///${src.replace(/\\/g, "/")}`).href; } - const body = percentDecode(match[3]); - if (!match[2]) { - return body; + return URL.canParse(src, base) ? new URL(src, base).href : null; +} + +/** The most bytes an image file may have before it decodes. */ +export const MAX_IMAGE_BYTES = 1 << 26; + +function checkImageBytes(length: number): void { + if (length > MAX_IMAGE_BYTES) { + throw new Error(`The image file is more than ${MAX_IMAGE_BYTES} bytes`); } - const binary = atob( - new TextDecoder("latin1").decode(body).replace(/\s+/g, ""), - ); - return Uint8Array.from(binary, (char) => char.charCodeAt(0)); } -// URL's percent-decode: each %XX is that byte, and the rest is UTF-8. -function percentDecode(text: string): Uint8Array { - const input = new TextEncoder().encode(text); - const out = new Uint8Array(input.length); +/** + * The image types the decoders read, for a request's Accept header, so a + * server that picks a format by it picks one of these. + */ +export const IMAGE_ACCEPT = + "image/png,image/jpeg,image/gif,image/bmp;q=0.9,*/*;q=0.5"; + +/** + * A response's bytes, up to MAX_IMAGE_BYTES: by its declared length when + * it has one, and while reading when it has not. + */ +export async function readImageResponse( + response: Response, +): Promise { + // A body left unread holds its connection open. + const discard = () => response.body?.cancel().catch(() => {}); + if (!response.ok) { + await discard(); + throw new Error(`The image request failed with status ${response.status}`); + } + const declared = Number(response.headers.get("content-length") ?? NaN); + if (Number.isFinite(declared) && declared > MAX_IMAGE_BYTES) { + await discard(); + checkImageBytes(declared); + } + if (response.body === null) { + return new Uint8Array(0); + } + // The length a server declares is not a promise. The body is read + // a piece at a time and given up past the limit. + const reader = response.body.getReader(); + const pieces: Uint8Array[] = []; let length = 0; - for (let i = 0; i < input.length; i++) { - const hex = String.fromCharCode(input[i + 1] ?? 0, input[i + 2] ?? 0); - if (input[i] === 0x25 && /^[0-9a-f]{2}$/i.test(hex)) { - out[length++] = parseInt(hex, 16); - i += 2; - } else { - out[length++] = input[i]; + for (;;) { + const {done, value} = await reader.read(); + if (done) { + break; + } + length += value.length; + if (length > MAX_IMAGE_BYTES) { + await reader.cancel().catch(() => {}); + checkImageBytes(length); } + pieces.push(value); } - return out.subarray(0, length); + const bytes = new Uint8Array(length); + let at = 0; + for (const piece of pieces) { + bytes.set(piece, at); + at += piece.length; + } + return bytes; +} + +// --------------------------------------------------------------------------- +// Decoding off the page's thread. + +/** + * How long an image may take to decode. Past it, the worker is ended, + * which ends the decode with it, or the decode here stops at its next + * yield, and the image fails. + */ +let decodeTimeout = 10_000; +// A worker with nothing to do is ended, so it keeps the runtime alive for +// a second at most. +const WORKER_IDLE_MS = 1_000; + +let workerURL: URL | null = null; + +interface DecodeWorker { + post(message: unknown, transfer: Transferable[]): void; + terminate(): void; +} + +interface Job { + bytes: Uint8Array; + resolve(bitmap: Bitmap): void; + reject(error: unknown): void; + // Called when the job leaves the line to decode. + started(): void; } -interface ProcessWithBuiltins { - cwd?: () => string; - getBuiltinModule?: (name: string) => unknown; +interface WorkerState { + worker: DecodeWorker; + // Until the worker says it started, a failure means it cannot start. + ready: boolean; + idle: ReturnType | null; } -function getProcess(): ProcessWithBuiltins | undefined { - return (globalThis as {process?: ProcessWithBuiltins}).process; +// Images decode one at a time, in the order they ask. A decode's time +// limit then counts its own work and not the line ahead of it, and one +// decode's memory is all the decoding a page holds at once. +const waiting: Job[] = []; +let running: { + job: Job; + id: number; + timer: ReturnType | null; +} | + null = null; +let current: WorkerState | null = null; +// False for good once a worker fails to start: decoding stays here. +let workersWork = true; +let nextRequest = 0; + +/** The module src/decode-worker.ts builds to, which decodes off-thread. */ +export function setDecodeWorkerURL(url: URL | null): void { + if (current !== null) { + endWorker(current); + } + workerURL = url; + workersWork = url !== null; + // An image the ended worker had goes to whatever decodes next. + if (running !== null && running.timer !== null) { + clearTimeout(running.timer); + waiting.unshift(running.job); + running = null; + startNextDecode(); + } +} + +/** How long a decode may take before it is stopped. */ +export function setDecodeTimeout(ms: number): void { + decodeTimeout = ms; } /** - * Where a relative src points. A document at about:blank has no base, - * and a program in a terminal works in its working directory, so a - * relative path there is a file under it. + * Decode on a worker thread, where the runtime has one, so a large image + * does not stall the page. Where no worker starts, the decode runs here, + * a slice at a time. Either way it stops past decodeTimeout. */ -export function resolveImageURL(src: string, base: string): string | null { - // A Windows path's drive letter would read as a URL scheme. - if (/^[a-z]:[\\/]/i.test(src)) { - return new URL(`file:///${src.replace(/\\/g, "/")}`).href; +export function decodeImageOffThread( + bytes: Uint8Array, + signal?: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason); + return; + } + const job: Job = {bytes, resolve, reject, started: () => {}}; + // A decode that has not started is dropped, with its bytes. One + // that has runs to its end or its time limit. + if (signal !== undefined) { + const onAbort = () => { + const index = waiting.indexOf(job); + if (index !== -1) { + waiting.splice(index, 1); + reject(signal.reason); + } + }; + signal.addEventListener("abort", onAbort, {once: true}); + job.started = () => signal.removeEventListener("abort", onAbort); + } + waiting.push(job); + startNextDecode(); + }); +} + +function startNextDecode(): void { + if (running !== null) { + return; + } + const job = waiting.shift(); + job?.started(); + if (job === undefined) { + const idle = current; + if (idle !== null && idle.idle === null) { + idle.idle = setTimeout(() => endWorker(idle), WORKER_IDLE_MS); + } + return; + } + const id = nextRequest++; + const state = workersWork ? getWorker() : null; + if (state === null) { + running = {job, id, timer: null}; + decodeImageForPage(job.bytes, Date.now() + decodeTimeout) + .then(job.resolve, job.reject) + .finally(() => { + running = null; + startNextDecode(); + }); + return; + } + if (state.idle !== null) { + clearTimeout(state.idle); + state.idle = null; + } + running = {job, id, timer: startDecodeTimer(id, state)}; + // A copy the worker can take, whatever buffer the bytes sit in. The + // job keeps its own, in case the worker never starts. + const copy = job.bytes.slice(); + state.worker.post({id, bytes: copy.buffer}, [copy.buffer]); +} + +// The running decode's time limit, counted again once the worker is +// ready, so a worker's start does not count against its first image. +function startDecodeTimer( + id: number, + state: WorkerState, +): ReturnType { + return setTimeout(() => { + if (running?.id !== id) { + return; + } + const {job} = running; + running = null; + job.reject( + new Error(`The image took more than ${decodeTimeout} ms to decode`), + ); + // Ending the worker ends the decode. A new one takes the next image. + endWorker(state); + startNextDecode(); + }, decodeTimeout); +} + +function finishDecode(id: number, settle: (job: Job) => void): void { + if (running?.id !== id) { + return; + } + const {job, timer} = running; + if (timer !== null) { + clearTimeout(timer); + } + running = null; + settle(job); + startNextDecode(); +} + +function getWorker(): WorkerState | null { + if (current !== null) { + return current; } - if (URL.canParse(src, base)) { - return new URL(src, base).href; + const url = workerURL; + if (url === null) { + return null; } - const process = getProcess(); - if (process?.cwd === undefined) { + let state: WorkerState | null = null; + const onMessage = (data: unknown) => { + if (state === null || current !== state) { + return; + } + const message = data as { + ready?: boolean; + id?: number; + width?: number; + height?: number; + naturalWidth?: number; + naturalHeight?: number; + data?: ArrayBuffer; + error?: string; + }; + if (message.ready) { + state.ready = true; + if (running !== null && running.timer !== null) { + clearTimeout(running.timer); + running.timer = startDecodeTimer(running.id, state); + } + return; + } + finishDecode(message.id!, (job) => { + if (message.error !== undefined) { + job.reject(new Error(message.error)); + } else { + job.resolve({ + width: message.width!, + height: message.height!, + data: new Uint8ClampedArray(message.data!), + naturalWidth: message.naturalWidth, + naturalHeight: message.naturalHeight, + }); + } + }); + }; + const onError = (error: unknown) => { + if (state === null || current !== state) { + return; + } + const started = state.ready; + endWorker(state); + if (!started) { + // It never started: the image in hand decodes here, and so does + // every one after it. + workersWork = false; + if (running !== null) { + const {job, timer} = running; + if (timer !== null) { + clearTimeout(timer); + } + running = null; + waiting.unshift(job); + } + startNextDecode(); + return; + } + // It started and then failed on this image. The image fails, and a + // new worker takes the next. + if (running !== null) { + finishDecode(running.id, (job) => + job.reject( + new Error( + (error as {message?: string} | null)?.message || + "The image decoder failed", + ), + ), + ); + } + }; + const worker = startWorker(url, onMessage, onError); + if (worker === null) { + workersWork = false; return null; } - const dir = process.cwd().replace(/\\/g, "/"); - const root = `file://${dir.startsWith("/") ? "" : "/"}${dir}/`; - return URL.canParse(src, root) ? new URL(src, root).href : null; + state = {worker, ready: false, idle: null}; + current = state; + return state; } -interface FileSystem { - readFile(path: URL): Promise; +function endWorker(state: WorkerState): void { + if (current === state) { + current = null; + } + if (state.idle !== null) { + clearTimeout(state.idle); + state.idle = null; + } + state.worker.terminate(); } -// Node's file system, which Bun and Deno provide too. getBuiltinModule -// is synchronous and needs no import a bundler would follow; Node before -// 20.16 lacks it and gets the dynamic import, whose specifier is built at -// run time for the same reason. -async function getFileSystem(): Promise { - const process = getProcess(); - if (process === undefined) { - return undefined; - } - if (process.getBuiltinModule !== undefined) { - return process.getBuiltinModule("node:fs/promises") as FileSystem | - undefined; +interface WebWorker { + postMessage(message: unknown, transfer: Transferable[]): void; + addEventListener(type: string, listener: (event: any) => void): void; + terminate(): void; +} + +// The runtime's own web Worker, where it has one: Bun, Deno, a browser, +// and Node from 26.9 with --experimental-web-worker. Without one, images +// decode on this thread. +function startWorker( + url: URL, + onMessage: (data: unknown) => void, + onError: (error: unknown) => void, +): DecodeWorker | null { + const Worker = (globalThis as {Worker?: new ( + url: URL, + options: {type: "module"}, + ) => WebWorker;}).Worker; + if (Worker === undefined) { + return null; } - const specifier = ["node:fs", "promises"].join("/"); try { - return await import(specifier) as FileSystem; + const worker = new Worker(url, {type: "module"}); + worker.addEventListener("message", (event) => onMessage(event.data)); + worker.addEventListener("error", (event) => { + event.preventDefault?.(); + onError(event.error ?? event); + }); + return { + post: (message, transfer) => worker.postMessage(message, transfer), + terminate: () => worker.terminate(), + }; } catch (_error) { - return undefined; + return null; } } -/** - * The bytes a URL names. data: is read here. file: goes through the - * runtime's file system where it has one (Node's fetch does not read - * files), and anything else through fetch. - */ -export async function fetchImageBytes(url: string): Promise { - const inline = readDataURL(url); - if (inline !== null) { - return inline; - } - if (url.startsWith("file:")) { - const fs = await getFileSystem(); - if (fs !== undefined) { - return new Uint8Array(await fs.readFile(new URL(url))); - } +// The TermDOMs alive that may decode. A running worker keeps the runtime +// alive, so when the last is disposed the worker ends with it, and with +// it any decode still running. +let decoderUsers = 0; + +export function retainDecoder(): void { + decoderUsers++; +} + +export function releaseDecoder(): void { + decoderUsers = Math.max(0, decoderUsers - 1); + if (decoderUsers > 0 || current === null) { + return; } - const response = await fetch(url); - if (!response.ok) { - throw new Error(`The image request failed with status ${response.status}`); + if (running !== null && running.timer !== null) { + clearTimeout(running.timer); + const {job} = running; + running = null; + job.reject(new DOMException("The decoder was closed", "AbortError")); } - return new Uint8Array(await response.arrayBuffer()); + endWorker(current); } diff --git a/src/internal/replaced.ts b/src/internal/replaced.ts index d642e776e..72700863c 100644 --- a/src/internal/replaced.ts +++ b/src/internal/replaced.ts @@ -14,17 +14,24 @@ import {type CanvasTextRun, halfBlockCell} from "./canvas.ts"; import { getBoxModel, - getCellSize, getComputedValue, + getImageCellSize, toCellLength, } from "./cssom.ts"; import * as CSSValues from "./cssvalues.ts"; -import type {CellContext, CellStyle} from "./framebuffer.ts"; +import { + CellContext, + CellGrid, + type CellStyle, + settleBorders, +} from "./framebuffer.ts"; import { type Bitmap, getReplacedContent, - type GridCell, isReplacedElement, + MAX_IMAGE_PIXELS, + naturalHeightOf, + naturalWidthOf, type ReplacedContent, sampleBitmap, } from "./images.ts"; @@ -32,34 +39,6 @@ import {getStringWidth} from "./text.ts"; export {isReplacedElement}; -const DEFAULT_CELL_PIXELS = {width: 8, height: 16}; - -const cellPixelSources = new WeakMap< - object, - () => {width: number; height: number} ->(); - -/** Where a document's cell size comes from, once it has a screen. */ -export function registerCellPixels( - document: object, - read: () => {width: number; height: number}, -): void { - cellPixelSources.set(document, read); -} - -export function getCellPixels(element: Element): { - width: number; - height: number; -} { - const page = getCellSize(element as unknown as Node); - if (page.width !== 1 || page.height !== 1) { - return page; - } - const document = element.ownerDocument; - return (document && cellPixelSources.get(document)?.()) ?? - DEFAULT_CELL_PIXELS; -} - /** The natural size in cells, or null for content with none. */ export function getNaturalSize( element: Element, @@ -72,13 +51,20 @@ export function getNaturalSize( return {width: content.grid.cols, height: content.grid.rows}; } if (content.kind === "text") { - return {width: textWidth(content.text), height: content.text ? 1 : 0}; + // The alt text on one line inside a border, or an empty cell + // inside one when there is none. + return {width: Math.max(1, textWidth(content.text)) + 2, height: 3}; } - const {width, height} = content.kind === "blank" ? content : content.bitmap; + const {width, height} = content.kind === "blank" + ? content + : { + width: naturalWidthOf(content.bitmap), + height: naturalHeightOf(content.bitmap), + }; if (width === 0 || height === 0) { return {width: 0, height: 0}; } - const cell = getCellPixels(element); + const cell = getImageCellSize(element as unknown as Node); return { width: Math.max(1, Math.round(width / cell.width)), height: Math.max(1, Math.round(height / cell.height)), @@ -96,11 +82,16 @@ export function getNaturalRatio( if (content.kind === "grid") { return content.grid.rows > 0 ? content.grid.cols / content.grid.rows : NaN; } - const {width, height} = content.kind === "blank" ? content : content.bitmap; + const {width, height} = content.kind === "blank" + ? content + : { + width: naturalWidthOf(content.bitmap), + height: naturalHeightOf(content.bitmap), + }; if (width === 0 || height === 0) { return NaN; } - const cell = getCellPixels(element); + const cell = getImageCellSize(element as unknown as Node); return (width / cell.width) / (height / cell.height); } @@ -355,7 +346,7 @@ function placeImage( interface CellCache { key: string; - cells: Array; + cells: CellGrid; } const bitmapCells = new WeakMap(); @@ -368,11 +359,11 @@ function getBitmapCells( cols: number, rows: number, under: number | null, -): Array { +): CellGrid { const {bitmap} = content; - const cell = getCellPixels(element); - const naturalWidth = bitmap.width / cell.width; - const naturalHeight = bitmap.height / cell.height; + const cell = getImageCellSize(element as unknown as Node); + const naturalWidth = naturalWidthOf(bitmap) / cell.width; + const naturalHeight = naturalHeightOf(bitmap) / cell.height; const place = placeImage(element, cols, rows, naturalWidth, naturalHeight); const rendering = getComputedValue(element, "image-rendering") .trim() @@ -402,8 +393,8 @@ function getBitmapCells( const bottom = Math.min(rows * 2, Math.round(place.y + place.height)); const pixels = new Uint8ClampedArray(cols * rows * 2 * 4); if (right > left && bottom > top && place.width > 0 && place.height > 0) { - const scaleX = bitmap.width / place.width; - const scaleY = bitmap.height / place.height; + const scaleX = naturalWidthOf(bitmap) / place.width; + const scaleY = naturalHeightOf(bitmap) / place.height; const sampled = sampleBitmap( bitmap, right - left, @@ -424,49 +415,39 @@ function getBitmapCells( ); } } - const cells: Array = []; + const cells = new CellContext(new CellGrid(rows, cols), rows, cols, 0); for (let row = 0; row < rows; row++) { for (let col = 0; col < cols; col++) { - cells.push( - halfBlockCell( - pixels, - (row * 2 * cols + col) * 4, - ((row * 2 + 1) * cols + col) * 4, - under, - ), + const half = halfBlockCell( + pixels, + (row * 2 * cols + col) * 4, + ((row * 2 + 1) * cols + col) * 4, + under, ); + if (half !== null) { + cells.drawText(half.char, col, row, half.style); + } } } - bitmapCells.set(bitmap, {key, cells}); - return cells; + bitmapCells.set(bitmap, {key, cells: cells.grid}); + return cells.grid; } -function drawCell( - ctx: CellContext, - cell: GridCell, - x: number, - y: number, - fallback: CellStyle, -): void { - // The second column of a wide glyph belongs to the glyph before it. - if (cell.char === "") { - return; - } - if (cell.char === null) { - if (cell.bg !== null) { - ctx.drawRect(x, y, 1, 1, cell.bg); - } - return; +const settledGrids = new WeakMap(); + +// A charactergrid's cells with its lines joined among themselves and +// drawn, kept until the canvas draws again. +function getSettledGrid( + content: Extract, +): CellGrid { + const cached = settledGrids.get(content.grid); + if (cached?.version === content.version) { + return cached.grid; } - ctx.drawText(cell.char, x, y, { - fg: cell.fg ?? fallback.fg, - bg: cell.bg ?? undefined, - bold: cell.bold || undefined, - italic: cell.italic || undefined, - underline: cell.underline || undefined, - dim: cell.dim || undefined, - inverse: cell.inverse || undefined, - }); + const grid = content.grid.clone(); + settleBorders(grid); + settledGrids.set(content.grid, {version: content.version, grid}); + return grid; } /** @@ -528,27 +509,18 @@ export function renderReplaced( }; try { if (content.kind === "text") { - ctx.drawText(content.text, left, top, style); + drawPlaceholder(content.text, left, top, cols, rows, ctx, style); } else if (content.kind === "grid") { - const {grid} = content; - for (let row = 0; row < Math.min(rows, grid.rows); row++) { - for (let col = 0; col < Math.min(cols, grid.cols); col++) { - const cell = grid.cells[row * grid.cols + col]; - if (cell !== null) { - drawCell(ctx, cell, left + col, top + row, style); - } - } - } - } else { - const cells = getBitmapCells(element, content, cols, rows, under); - for (let row = 0; row < rows; row++) { - for (let col = 0; col < cols; col++) { - const cell = cells[row * cols + col]; - if (cell !== null) { - drawCell(ctx, cell, left + col, top + row, style); - } - } - } + ctx.drawGrid(getSettledGrid(content), left, top, style); + } else if (cols * rows * 2 <= MAX_IMAGE_PIXELS) { + // Past the limit the pixels a box samples are more than an image + // may hold, and like a canvas past it, the box shows nothing. + ctx.drawGrid( + getBitmapCells(element, content, cols, rows, under), + left, + top, + style, + ); if (textRuns.length > 0) { paintTextRuns( element, @@ -567,6 +539,55 @@ export function renderReplaced( } } +/** + * An image that is not showing: a box around the content box with the + * alt text inside it, cut to fit. A box too small for a border holds + * the text alone. + */ +function drawPlaceholder( + text: string, + left: number, + top: number, + cols: number, + rows: number, + ctx: CellContext, + style: CellStyle, +): void { + if (cols < 3 || rows < 3) { + ctx.drawText(fitText(text, cols), left, top, style); + return; + } + const side = {style: "solid" as const, color: style.fg}; + ctx.drawBox(left, top, cols, rows, { + top: side, + right: side, + bottom: side, + left: side, + }); + ctx.drawText(fitText(text, cols - 2), left + 1, top + 1, style); +} + +// The text cut to `width` columns, with an ellipsis where it was cut. +function fitText(text: string, width: number): string { + if (textWidth(text) <= width) { + return text; + } + if (width <= 0) { + return ""; + } + let out = ""; + let used = 0; + for (const char of text) { + const columns = Math.max(0, getStringWidth(char)); + if (used + columns > width - 1) { + break; + } + out += char; + used += columns; + } + return out + "…"; +} + // A 2d context's text at the cells its anchors fall in, through the same // placement as the pixels. function paintTextRuns( @@ -579,7 +600,7 @@ function paintTextRuns( rows: number, ctx: CellContext, ): void { - const cell = getCellPixels(element); + const cell = getImageCellSize(element as unknown as Node); const place = placeImage( element, cols, diff --git a/src/internal/resources.ts b/src/internal/resources.ts new file mode 100644 index 000000000..a07d40293 --- /dev/null +++ b/src/internal/resources.ts @@ -0,0 +1,687 @@ +/** + * Loading what a document's markup asks for, such as an 's source, + * in the order Fetch takes a browser's loads. The document's Content + * Security Policy decides whether a load may be made at all. One it + * allows goes to the TermDOM's "fetch" listeners as a FetchEvent, which + * answer it as a Service Worker does, and to the network when none does. + * Each redirect is checked against the policy again and followed on the + * network. Only a document that is itself a file: URL loads file: URLs. + */ + +const kExtend = Symbol("extend"); +const kPending = Symbol("pending"); + +export interface ExtendableEvent { + // Where waitUntil()'s promises go: the TermDOM that dispatched the + // event. Null for an event a program made, which nothing waits on. + [kExtend]: ((promise: Promise) => void) | null; + [kPending]: number; +} + +/** An event whose listeners can ask the TermDOM to wait for their work. */ +export class ExtendableEvent extends Event { + constructor(type: string, init?: EventInit) { + super(type, init); + this[kExtend] = null; + this[kPending] = 0; + } + + /** Keeps the TermDOM's dispose() waiting until `promise` settles. */ + waitUntil(promise: PromiseLike): void { + const lifetime = Promise.resolve(promise); + addLifetimePromise(this, lifetime); + this[kExtend]!(lifetime); + } +} + +// Service Workers §4.4.1: an event is active while it is dispatched, or +// while a promise it was given is pending. +function addLifetimePromise( + event: ExtendableEvent, + promise: Promise, +): void { + if (event[kExtend] === null) { + throw new DOMException( + "Only an event TermDOM dispatches can be extended", + "InvalidStateError", + ); + } + if (event.eventPhase === Event.NONE && event[kPending] === 0) { + throw new DOMException( + "The event is no longer active", + "InvalidStateError", + ); + } + event[kPending]++; + const settled = () => { + queueMicrotask(() => event[kPending]--); + }; + promise.then(settled, settled); +} + +export interface FetchEventInit extends EventInit { + request: Request; + preloadResponse?: Promise; + clientId?: string; + resultingClientId?: string; + replacesClientId?: string; + handled?: Promise; +} + +const kResponse = Symbol("response"); + +export interface FetchEvent { + [kResponse]: Promise | null; +} + +/** + * A load the document's markup asks for, dispatched at the TermDOM as a + * Service Worker's fetch event is at its global scope. A listener answers + * it with respondWith(), which stops the other listeners. One that calls + * preventDefault() without answering fails the load. + */ +export class FetchEvent extends ExtendableEvent { + readonly request: Request; + readonly preloadResponse: Promise; + readonly clientId: string; + readonly resultingClientId: string; + readonly replacesClientId: string; + // Settles once the load is answered: rejects with a NetworkError when + // it fails. + readonly handled: Promise; + + constructor(type: string, init: FetchEventInit) { + super(type, init); + this.request = init.request; + this.preloadResponse = init.preloadResponse ?? Promise.resolve(undefined); + this.clientId = init.clientId ?? ""; + this.resultingClientId = init.resultingClientId ?? ""; + this.replacesClientId = init.replacesClientId ?? ""; + this.handled = init.handled ?? new Promise(() => {}); + this[kResponse] = null; + } + + /** + * Answer the request, during the event. Response.error(), a promise + * that rejects, or a value that is not a Response fails it as a + * network error does. + */ + respondWith(response: Response | PromiseLike): void { + if (this.eventPhase === Event.NONE) { + throw new DOMException( + "respondWith() is only called while the event is dispatched", + "InvalidStateError", + ); + } + if (this[kResponse] !== null) { + throw new DOMException( + "The request was already answered", + "InvalidStateError", + ); + } + const answer = Promise.resolve(response); + addLifetimePromise(this, answer); + this.stopImmediatePropagation(); + this[kResponse] = answer.then((value) => { + if (!(value instanceof Response)) { + throw new TypeError("respondWith() takes a Response"); + } + return value; + }); + } +} + +/** + * Gives a request its Fetch destination, such as "image", which a + * runtime's Request leaves empty. + */ +export function setRequestDestination( + request: Request, + destination: RequestDestination, +): void { + Object.defineProperty(request, "destination", { + value: destination, + enumerable: true, + configurable: true, + }); +} + +/** What a load knows of what asked for it. */ +export interface RequestContext { + // The element that asked, where a policy violation is reported. + initiator: Element | null; +} + +/** How a document's loads are answered, set by the TermDOM that owns it. */ +type ResourceLoader = ( + request: Request, + context: RequestContext, +) => Promise; + +const loaders = new WeakMap< + object, + {loader: ResourceLoader; signal: AbortSignal} +>(); + +/** `signal` aborts when the TermDOM that owns the document is disposed. */ +export function setResourceLoader( + document: object, + loader: ResourceLoader, + signal: AbortSignal, +): void { + loaders.set(document, {loader, signal}); +} + +/** + * Aborts when the document's loads end for good, or null for a document + * no TermDOM owns. + */ +export function getLoadSignal(document: object): AbortSignal | null { + return loaders.get(document)?.signal ?? null; +} + +/** + * The response to a load the document's markup asks for. A document no + * TermDOM owns, such as DOMParser's, loads nothing. + */ +export function loadResource( + document: object, + request: Request, + context: RequestContext, +): Promise { + const entry = loaders.get(document); + if (entry === undefined) { + return Promise.reject(new TypeError("This document loads nothing")); + } + return entry.loader(request, context); +} + +/** What a TermDOM decides a document's loads by. */ +export interface RequestPolicy { + // The program's policy, or the default that allows nothing. A page's + // own meta tags are read with each request and can only narrow it. + policies: readonly ContentSecurityPolicy[]; + // Tells of a load a policy blocked. + violate(at: EventTarget, init: SecurityPolicyViolationEventInit): void; + // Takes the work a listener asked the TermDOM to wait for. + extend(promise: Promise): void; + // Aborted when the TermDOM is disposed, after which nothing loads. + signal: AbortSignal; +} + +const REDIRECT_LIMIT = 20; +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); + +/** + * Settle a load the document's markup asks for: checked against the + * document's policies, offered to `target`'s "fetch" listeners, and sent + * to the network when none answers. A redirect, from a listener or the + * network, is checked again and followed on the network, as Fetch follows + * one without its Service Worker. + */ +export async function answerRequest( + target: EventTarget, + document: Document, + request: Request, + context: RequestContext, + policy: RequestPolicy, +): Promise { + policy.signal.throwIfAborted(); + const destination = request.destination; + checkRequest(document, request, new URL(request.url), 0, context, policy); + let response = await dispatchFetchEvent(target, request, policy); + if (response === null) { + response = await fetch(new Request(request, {redirect: "manual"})); + } else if (response.url !== "" && response.url !== request.url) { + // A listener's response that came from somewhere, such as its own + // fetch(event.request), is checked where it ended up, as Fetch checks + // a Service Worker's response against the page's policy. + const final = new URL(response.url); + try { + if (response.redirected) { + checkRedirectScheme(request, final); + } + checkRequest( + document, + request, + final, + response.redirected ? 1 : 0, + context, + policy, + ); + } catch (error) { + await response.body?.cancel().catch(() => {}); + throw error; + } + } + let current = new URL(response.url || request.url); + for (let redirects = 1; isRedirect(response); redirects++) { + await response.body?.cancel().catch(() => {}); + if (request.redirect !== "follow") { + throw new TypeError(`${request.url} redirects`); + } + if (redirects > REDIRECT_LIMIT) { + throw new TypeError(`${request.url} redirects too many times`); + } + current = new URL(response.headers.get("location")!, current); + checkRedirectScheme(request, current); + checkRequest(document, request, current, redirects, context, policy); + const next = new Request(current, { + headers: request.headers, + signal: request.signal, + redirect: "manual", + }); + setRequestDestination(next, destination); + response = await fetch(next); + } + if (response.type === "error") { + throw new TypeError("The request was refused"); + } + return response; +} + +// Fetch's "HTTP-redirect fetch": a redirect goes only to another HTTP(S) +// URL, never to a file, data: or anything the network does not serve. +function checkRedirectScheme(request: Request, url: URL): void { + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new TypeError( + `${request.url} redirects to a URL that is not HTTP(S)`, + ); + } +} + +function isRedirect(response: Response): boolean { + return REDIRECT_STATUSES.has(response.status) && + response.headers.has("location"); +} + +// Fetch's "should request be blocked", for the request's URL or a URL it +// was redirected to. As in a browser, a document that is not a file loads +// no file, about:blank included. +function checkRequest( + document: Document, + request: Request, + url: URL, + redirects: number, + context: RequestContext, + policy: RequestPolicy, +): void { + const self = new URL(document.URL); + if (url.protocol === "file:" && self.protocol !== "file:") { + throw new TypeError("A document that is not a file cannot load a file"); + } + const blocked = getBlockingDirective([ + ...policy.policies, + ...getDocumentPolicies(document), + ], url, request.destination, self, redirects); + if (blocked === null) { + return; + } + const at = context.initiator?.isConnected ? context.initiator : document; + // The URL the document asked for, never where it was redirected + // (CSP3 §2.4.2), and only as much of it as a report may carry. + policy.violate(at, { + documentURI: stripURLForReport(new URL(document.URL)), + blockedURI: stripURLForReport(new URL(request.url)), + effectiveDirective: blocked.directive, + originalPolicy: blocked.policy.text, + disposition: "enforce", + bubbles: true, + composed: true, + }); + throw new TypeError( + `The Content Security Policy directive "${blocked.directive}" ` + + `blocked ${stripURLForReport(new URL(request.url))}`, + ); +} + +// CSP3 §5.4: a URL that is not HTTP(S) reports as its scheme alone, and +// one that is reports without its fragment and credentials. +function stripURLForReport(url: URL): string { + if (url.protocol !== "http:" && url.protocol !== "https:") { + return url.protocol.slice(0, -1); + } + const stripped = new URL(url); + stripped.hash = ""; + stripped.username = ""; + stripped.password = ""; + return stripped.href; +} + +// Service Workers' Handle Fetch, as far as a page's load needs it: the +// listener's response, or null when none answered and the load goes to +// the network. +async function dispatchFetchEvent( + target: EventTarget, + request: Request, + policy: RequestPolicy, +): Promise { + let resolveHandled!: () => void; + let rejectHandled!: (error: DOMException) => void; + const handled = new Promise((resolve, reject) => { + resolveHandled = () => resolve(undefined); + rejectHandled = reject; + }); + handled.catch(() => {}); + const event = new FetchEvent("fetch", {request, handled, cancelable: true}); + event[kExtend] = (promise) => { + policy.extend(promise); + }; + target.dispatchEvent(event); + const answer = event[kResponse]; + if (answer === null) { + if (event.defaultPrevented) { + rejectHandled(new DOMException("The load was canceled", "NetworkError")); + throw new TypeError("A fetch listener canceled the load"); + } + resolveHandled(); + return null; + } + try { + const response = await whileNotAborted(answer, request.signal); + resolveHandled(); + return response; + } catch (error) { + rejectHandled(new DOMException("The load failed", "NetworkError")); + throw error; + } +} + +// An answer still pending when its request is aborted, by a new source or +// the TermDOM's disposal, ends the load then, as Fetch ends a fetch +// whatever its Service Worker is doing. +function whileNotAborted( + promise: Promise, + signal: AbortSignal, +): Promise { + if (signal.aborted) { + return Promise.reject(signal.reason); + } + return new Promise((resolve, reject) => { + const onAbort = () => reject(signal.reason); + signal.addEventListener("abort", onAbort, {once: true}); + promise.then(resolve, reject).finally(() => { + signal.removeEventListener("abort", onAbort); + }); + }); +} + +// The policies a page stated for itself with in its head. +const documentPolicies = new WeakMap(); + +/** + * A `` inserted as a child of + * a head (HTML §4.2.5.3): its policy holds for the document from then on, + * whatever later becomes of the element. A meta policy cannot report, + * frame or sandbox, so those directives are dropped. + */ +export function addDocumentPolicy(document: object, content: string): void { + const policy = parseContentSecurityPolicy(content); + for (const directive of ["report-uri", "frame-ancestors", "sandbox"]) { + policy.directives.delete(directive); + } + const policies = documentPolicies.get(document); + if (policies === undefined) { + documentPolicies.set(document, [policy]); + } else { + policies.push(policy); + } +} + +function getDocumentPolicies(document: object): ContentSecurityPolicy[] { + return documentPolicies.get(document) ?? []; +} + +// --------------------------------------------------------------------------- +// Content Security Policy (CSP3): which loads a document may make. + +/** One parsed policy: each directive's source expressions, by name. */ +export interface ContentSecurityPolicy { + text: string; + directives: Map; +} + +/** + * A serialized policy list, as a header or a meta tag carries it: policies + * separated by commas, directives by semicolons (CSP3 §2.2.1). A directive + * named twice keeps its first value. + */ +export function parseContentSecurityPolicies( + serialized: string, +): ContentSecurityPolicy[] { + return serialized.split(",").map(parseContentSecurityPolicy); +} + +/** One serialized policy, as a meta tag carries it (CSP3 §2.2.1). */ +function parseContentSecurityPolicy(text: string): ContentSecurityPolicy { + const directives = new Map(); + for (const token of text.split(";")) { + const [name, ...values] = token.trim().split(/[\t\n\f\r ]+/); + if (!name || !/^[a-zA-Z0-9-]+$/.test(name)) { + continue; + } + const key = name.toLowerCase(); + if (!directives.has(key)) { + directives.set(key, values.filter(Boolean)); + } + } + return {text: text.trim(), directives}; +} + +// The directive that governs a destination, and the one it falls back to. +const FETCH_DIRECTIVES: Partial> = { + image: "img-src", + style: "style-src", + font: "font-src", + audio: "media-src", + video: "media-src", + track: "media-src", +}; + +/** The directive a policy enforces for a load, or null when none applies. */ +function getEffectiveDirective( + policy: ContentSecurityPolicy, + destination: RequestDestination, +): string | null { + const directive = FETCH_DIRECTIVES[destination]; + if (directive !== undefined && policy.directives.has(directive)) { + return directive; + } + return policy.directives.has("default-src") ? "default-src" : null; +} + +/** + * The first policy and directive that block a load of `url` for + * `destination`, from a document at `self`, or null when every policy + * allows it. `redirects` counts the redirects that led to `url`. + */ +export function getBlockingDirective( + policies: readonly ContentSecurityPolicy[], + url: URL, + destination: RequestDestination, + self: URL, + redirects = 0, +): {policy: ContentSecurityPolicy; directive: string} | null { + for (const policy of policies) { + const directive = getEffectiveDirective(policy, destination); + if ( + directive !== null && + !matchesSourceList( + policy.directives.get(directive)!, + url, + self, + redirects, + ) + ) { + return {policy, directive}; + } + } + return null; +} + +// CSP3 §6.7.2.6. An empty list, or one that is just 'none', matches nothing. +function matchesSourceList( + sources: string[], + url: URL, + self: URL, + redirects: number, +): boolean { + return sources.some((source) => matchesSource(source, url, self, redirects)); +} + +const NETWORK_SCHEMES = new Set(["http", "https", "ws", "wss"]); + +const SCHEME_SOURCE = /^([a-zA-Z][a-zA-Z0-9+.-]*):$/; +const HOST_SOURCE = + /^(?:([a-zA-Z][a-zA-Z0-9+.-]*):\/\/)?(\*|(?:\*\.)?[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*)(?::(\*|\d+))?(\/[^?#]*)?$/; + +function matchesSource( + source: string, + url: URL, + self: URL, + redirects: number, +): boolean { + const scheme = url.protocol.slice(0, -1); + const selfScheme = self.protocol.slice(0, -1); + const lower = source.toLowerCase(); + if (lower === "'self'") { + return matchesSelf(url, self); + } + // Keywords, nonces and hashes govern scripts and styles, never a load. + if (lower.startsWith("'")) { + return false; + } + // A network scheme, or the document's own (§6.7.2.7, step 1). + if (lower === "*") { + return NETWORK_SCHEMES.has(scheme) || scheme === selfScheme; + } + const schemeSource = SCHEME_SOURCE.exec(source); + if (schemeSource !== null) { + return schemePartMatches(schemeSource[1].toLowerCase(), scheme); + } + const hostSource = HOST_SOURCE.exec(source); + if (hostSource === null || url.host === "") { + return false; + } + const [, sourceScheme, host, port, path] = hostSource; + // Without a scheme, the document's own (§6.7.2.7, step 4). A document + // not served over the network, as about:blank and file: are not, has no + // network scheme to lend, so its host sources mean the network ones. + if (sourceScheme !== undefined) { + if (!schemePartMatches(sourceScheme.toLowerCase(), scheme)) { + return false; + } + } else if ( + !schemePartMatches(selfScheme, scheme) && + !(!NETWORK_SCHEMES.has(selfScheme) && NETWORK_SCHEMES.has(scheme)) + ) { + return false; + } + if (!hostPartMatches(host.toLowerCase(), url.hostname.toLowerCase())) { + return false; + } + if (!portPartMatches(port, url)) { + return false; + } + // A redirect's target is matched without its path, which would tell + // the page where a cross-origin redirect went (§6.7.2.5). + return path === undefined || redirects > 0 || pathPartMatches(path, url); +} + +// §6.7.2.8: a scheme matches itself and its secure counterpart, and ws +// matches the http schemes too. +function schemePartMatches(expression: string, scheme: string): boolean { + return ( + expression === scheme || + (expression === "http" && scheme === "https") || + (expression === "ws" && + (scheme === "wss" || scheme === "http" || scheme === "https")) || + (expression === "wss" && scheme === "https") + ); +} + +// §6.7.2.9: `*.example.com` matches a subdomain, never example.com. +function hostPartMatches(expression: string, host: string): boolean { + if (expression === "*") { + return true; + } + if (expression.startsWith("*.")) { + return host.endsWith(expression.slice(1)); + } + return expression === host; +} + +const DEFAULT_PORTS: Record = { + http: "80", + https: "443", + ws: "80", + wss: "443", +}; + +// §6.7.2.10: no port means the scheme's default, which the URL parser +// leaves empty. Port 80 also matches 443, an upgrade to https. +function portPartMatches(port: string | undefined, url: URL): boolean { + if (port === "*") { + return true; + } + if (port === undefined) { + return url.port === ""; + } + const scheme = url.protocol.slice(0, -1); + const urlPort = url.port === "" ? DEFAULT_PORTS[scheme] : url.port; + return port === urlPort || (port === "80" && urlPort === "443"); +} + +// §6.7.2.11: a path ending in a slash matches what is under it, and any +// other the one resource it names, compared piece by piece once each +// piece is percent-decoded. +function pathPartMatches(path: string, url: URL): boolean { + if (path === "" || (path === "/" && url.pathname === "")) { + return true; + } + const exact = !path.endsWith("/"); + const expected = path.split("/"); + const actual = url.pathname.split("/"); + if (expected.length > actual.length) { + return false; + } + if (exact && expected.length !== actual.length) { + return false; + } + if (!exact) { + expected.pop(); + } + return expected.every((piece, i) => + percentDecode(piece) === percentDecode(actual[i]), + ); +} + +// The URL Standard's percent-decode, which leaves a malformed sequence as +// it is, comparing as bytes. +function percentDecode(text: string): string { + const bytes = String.fromCharCode(...new TextEncoder().encode(text)); + return bytes.replace(/%([0-9A-Fa-f]{2})/g, (_, hex: string) => + String.fromCharCode(parseInt(hex, 16)), + ); +} + +// §6.7.2.7: the document's own origin, or the same host and port, over +// https or wss, or over http or ws from an http document. The URL parser +// leaves a scheme's default port empty, so two defaults compare equal. +function matchesSelf(url: URL, self: URL): boolean { + if (self.origin === "null" || url.origin === "null") { + return false; + } + if (url.origin === self.origin) { + return true; + } + const scheme = url.protocol.slice(0, -1); + const selfScheme = self.protocol.slice(0, -1); + return ( + url.hostname === self.hostname && + url.port === self.port && + (scheme === "https" || + scheme === "wss" || + (selfScheme === "http" && (scheme === "http" || scheme === "ws"))) + ); +} diff --git a/tests/canvas.test.ts b/tests/canvas.test.ts index be3a0cfea..8dc6b71cb 100644 --- a/tests/canvas.test.ts +++ b/tests/canvas.test.ts @@ -13,6 +13,7 @@ import {expect, test} from "@b9g/libuild/test"; import {TermDOM} from "../src/index.ts"; +import {createImageBitmapFrom, ImageBitmap} from "../src/internal/canvas.ts"; import {decodeImage, encodePNG} from "../src/internal/images.ts"; import {MockProcess, nextFrame, until} from "./test-utils.ts"; @@ -50,7 +51,10 @@ async function mount( rows = 12, ): Promise<{dom: TermDOM; terminal: MockProcess; document: Document}> { const terminal = new MockProcess({cols, rows}); - const dom = new TermDOM({transport: terminal.transport}); + const dom = new TermDOM({ + transport: terminal.transport, + csp: "img-src data: https:", + }); dom.document.body.innerHTML = html; await nextFrame(dom); return {dom, terminal, document: dom.document}; @@ -128,14 +132,14 @@ test("drawing repaints the canvas's cells without any DOM change", async () => { ctx.fillRect(0, 8, 16, 8); await nextFrame(dom); expect(cell(terminal, 0, 0)).toMatchObject({ - char: "▀", - fg: 0xff0000, - bg: 0x0000ff, + char: "▄", + fg: 0x0000ff, + bg: 0xff0000, }); expect(cell(terminal, 1, 0)).toMatchObject({ - char: "▀", - fg: 0xff0000, - bg: 0x0000ff, + char: "▄", + fg: 0x0000ff, + bg: 0xff0000, }); // The second row of cells is still empty. expect(cell(terminal, 0, 1).char).toBe(" "); @@ -690,14 +694,14 @@ test("drawImage draws pixels into cells, two a cell", async () => { ctx.drawImage(document.getElementById("src"), 1, 0, 2, 1); await nextFrame(dom); expect(cell(terminal, 1, 0)).toMatchObject({ - char: "▀", - fg: 0xff0000, - bg: 0x00ff00, + char: "▄", + fg: 0x00ff00, + bg: 0xff0000, }); expect(cell(terminal, 2, 0)).toMatchObject({ - char: "▀", - fg: 0xff0000, - bg: 0x00ff00, + char: "▄", + fg: 0x00ff00, + bg: 0xff0000, }); expect(cell(terminal, 0, 0).char).toBe(" "); dom.dispose(); @@ -849,3 +853,116 @@ test("text over a currentcolor fill stays in inverse video", async () => { expect(cell(terminal, 0, 0)).toMatchObject({char: " ", inverse: true}); dom.dispose(); }); + +test("strokeRect draws a box and strokeLine joins it as tees", async () => { + const {dom, terminal, document} = + await mount(""); + const ctx = grid(document); + ctx.strokeRect(0, 0, 6, 4); + ctx.strokeLine(0, 2, 5, 2); + ctx.strokeLine(3, 0, 3, 2); + await nextFrame(dom); + expect(rowText(terminal, 0)).toBe("┌──┬─┐"); + expect(rowText(terminal, 1)).toBe("│ │ │"); + expect(rowText(terminal, 2)).toBe("├──┴─┤"); + expect(rowText(terminal, 3)).toBe("└────┘"); + expect(ctx.getCell(3, 2).char).toBe("┴"); + dom.dispose(); +}); + +test("lineStyle, lineJoin and strokeStyle set how lines draw", async () => { + const {dom, terminal, document} = + await mount(""); + const ctx = grid(document); + ctx.lineStyle = "double"; + ctx.strokeRect(0, 0, 4, 3); + ctx.lineStyle = "solid"; + ctx.lineJoin = "round"; + ctx.strokeStyle = "#ff0000"; + ctx.strokeRect(0, 3, 4, 3); + ctx.lineStyle = "wavy"; + expect(ctx.lineStyle).toBe("solid"); + await nextFrame(dom); + expect(rowText(terminal, 0)).toBe("╔══╗"); + expect(rowText(terminal, 2)).toBe("╚══╝"); + expect(rowText(terminal, 3)).toBe("╭──╮"); + expect(cell(terminal, 1, 3)).toMatchObject({char: "─", fg: 0xff0000}); + expect(cell(terminal, 1, 0).fg).toBe(-1); + dom.dispose(); +}); + +test("a slanted line draws nothing", async () => { + const {dom, document} = + await mount(""); + const ctx = grid(document); + ctx.strokeLine(0, 0, 3, 3); + expect(ctx.getCell(0, 0)).toBeNull(); + dom.dispose(); +}); + +test("a canvas's lines do not join the page's borders beside it", async () => { + const {dom, terminal, document} = await mount( + "
" + + "
", + ); + const ctx = grid(document); + ctx.strokeLine(0, 1, 0, 1); + await nextFrame(dom); + expect(rowText(terminal, 2).slice(0, 2)).toBe("│─"); + dom.dispose(); +}); + +test("ImageData past the pixel limit throws, and so does getImageData", async () => { + const {dom, document} = + await mount(""); + const ctx = + (document.getElementById("c") as HTMLCanvasElement).getContext("2d")!; + expect(() => ctx.createImageData(1 << 14, 1 << 13)).toThrow(RangeError); + expect(() => ctx.getImageData(0, 0, 1 << 14, 1 << 13)).toThrow(RangeError); + dom.dispose(); +}); + +test("a charactergrid samples only the cells an image lands on, however large", async () => { + const {dom, document} = await mount( + "", + ); + const source = + (document.getElementById("src") as HTMLCanvasElement).getContext("2d")!; + source.fillStyle = "#ff0000"; + source.fillRect(0, 0, 1, 2); + source.fillStyle = "#0000ff"; + source.fillRect(1, 0, 1, 2); + const ctx = grid(document); + ctx.drawImage(document.getElementById("src"), -1e6 + 2, 0, 2e6, 1e6); + expect(ctx.getCell(1, 0).background).toBe("#ff0000"); + expect(ctx.getCell(2, 0).background).toBe("#0000ff"); + expect(ctx.getCell(3, 1).background).toBe("#0000ff"); + dom.dispose(); +}); + +test("createImageBitmap from a kept-down image crops by each side's ratio, and past the edge is clear", async () => { + const data = new Uint8ClampedArray(4 * 2 * 4); + for (let i = 0; i < data.length; i += 4) { + data.set([255, 0, 0, 255], i); + } + const image = new ImageBitmap({ + width: 4, + height: 2, + data, + naturalWidth: 8, + naturalHeight: 8, + }); + const copy = createImageBitmapFrom(image, 4, 4, 8, 8); + expect([copy.width, copy.height]).toEqual([8, 8]); + const {dom, document} = + await mount(""); + const ctx = + (document.getElementById("c") as HTMLCanvasElement).getContext("2d")!; + ctx.imageSmoothingEnabled = false; + ctx.drawImage(copy as unknown as CanvasImageSource, 0, 0); + const alphaAt = + (x: number, y: number) => ctx.getImageData(x, y, 1, 1).data[3]; + expect([alphaAt(1, 1), alphaAt(3, 3)]).toEqual([255, 255]); + expect([alphaAt(6, 1), alphaAt(1, 6), alphaAt(6, 6)]).toEqual([0, 0, 0]); + dom.dispose(); +}); diff --git a/tests/content-security-policy.test.ts b/tests/content-security-policy.test.ts new file mode 100644 index 000000000..b53e600fd --- /dev/null +++ b/tests/content-security-policy.test.ts @@ -0,0 +1,232 @@ +/** + * The policy a document's loads are checked against: CSP3's directives, + * source lists and matching, as a program and a page's own meta tags set + * it. + */ +import {expect, test} from "@b9g/libuild/test"; + +import { + getBlockingDirective, + parseContentSecurityPolicies, +} from "../src/internal/resources.ts"; + +function allows( + policy: string, + url: string, + self = "https://mail.example/inbox", + destination: RequestDestination = "image", +): boolean { + return getBlockingDirective( + parseContentSecurityPolicies(policy), + new URL(url), + destination, + new URL(self), + ) === null; +} + +test("img-src governs an image, and default-src stands in for it", () => { + expect(allows("img-src https:", "https://cdn.example/a.png")).toBe(true); + expect(allows("img-src https:", "http://cdn.example/a.png")).toBe(false); + expect(allows("default-src data:", "data:image/png;base64,AAAA")).toBe(true); + expect(allows("default-src data:", "https://cdn.example/a.png")).toBe(false); + // img-src replaces default-src for images; it does not add to it. + expect(allows("default-src *; img-src cid:", "https://cdn.example/a.png")) + .toBe(false); + // A policy that names neither governs nothing an image does. + expect(allows("style-src 'none'", "https://cdn.example/a.png")).toBe(true); +}); + +test("'none', an empty list and keywords match nothing", () => { + expect(allows("img-src 'none'", "data:image/png;base64,AAAA")).toBe(false); + expect(allows("img-src", "https://cdn.example/a.png")).toBe(false); + expect( + allows("img-src 'unsafe-inline' 'nonce-abc'", "https://cdn.example/a.png"), + ) + .toBe(false); +}); + +test("* matches the network schemes and the document's own, not data: or cid:", () => { + expect(allows("img-src *", "https://cdn.example/a.png")).toBe(true); + expect(allows("img-src *", "http://cdn.example/a.png")).toBe(true); + expect(allows("img-src *", "data:image/png;base64,AAAA")).toBe(false); + expect(allows("img-src *", "cid:logo@example.com")).toBe(false); + expect(allows("img-src *", "file:///a.png", "file:///home/me/")).toBe(true); +}); + +test("a scheme matches itself and its secure counterpart", () => { + expect(allows("img-src cid:", "cid:logo@example.com")).toBe(true); + expect(allows("img-src CID:", "cid:logo@example.com")).toBe(true); + expect(allows("img-src http:", "https://cdn.example/a.png")).toBe(true); + expect(allows("img-src https:", "http://cdn.example/a.png")).toBe(false); + // ws also matches the http schemes. + expect(allows("img-src ws:", "http://cdn.example/a.png")).toBe(true); + expect(allows("img-src ws:", "https://cdn.example/a.png")).toBe(true); + expect(allows("img-src wss:", "http://cdn.example/a.png")).toBe(false); +}); + +test("a path is compared piece by piece, each piece percent-decoded", () => { + expect( + allows( + "img-src https://cdn.example/a%20b/", + "https://cdn.example/a b/c.png", + ), + ) + .toBe(true); + // An encoded slash is part of a piece, not a separator. + expect(allows("img-src https://c.example/a/b", "https://c.example/a%2Fb")) + .toBe(false); + // A malformed escape compares as itself rather than failing. + expect( + allows( + "img-src https://cdn.example/images/", + "https://cdn.example/images/%E0.png", + ), + ) + .toBe(true); + expect(allows("img-src https://cdn.example/%E0", "https://cdn.example/%E0")) + .toBe(true); + expect(allows("img-src https://cdn.example/", "https://cdn.example/a/b.png")) + .toBe(true); +}); + +test("a redirect's target is matched without its path", () => { + const policies = + parseContentSecurityPolicies("img-src https://cdn.example/img/"); + const self = new URL("https://mail.example/"); + const elsewhere = new URL("https://cdn.example/other/a.png"); + expect(getBlockingDirective(policies, elsewhere, "image", self, 0)) + .not.toBeNull(); + expect(getBlockingDirective(policies, elsewhere, "image", self, 1)) + .toBeNull(); + expect( + getBlockingDirective( + policies, + new URL("https://evil.example/img/a.png"), + "image", + self, + 1, + ), + ) + .not.toBeNull(); +}); + +test("a host matches itself, a wildcard its subdomains, and a port or path narrows it", () => { + expect(allows("img-src https://cdn.example", "https://cdn.example/a.png")) + .toBe(true); + expect(allows("img-src https://CDN.example", "https://cdn.example/a.png")) + .toBe(true); + expect(allows("img-src https://*.example", "https://cdn.example/a.png")) + .toBe(true); + expect(allows("img-src https://*.example", "https://example/a.png")) + .toBe(false); + expect( + allows("img-src https://cdn.example", "https://cdn.example:8443/a.png"), + ) + .toBe(false); + expect( + allows("img-src https://cdn.example:*", "https://cdn.example:8443/a.png"), + ) + .toBe(true); + expect( + allows("img-src https://cdn.example/img/", "https://cdn.example/img/a.png"), + ) + .toBe(true); + expect( + allows("img-src https://cdn.example/img/", "https://cdn.example/css/a.png"), + ) + .toBe(false); + expect( + allows( + "img-src https://cdn.example/img/a.png", + "https://cdn.example/img/a.png", + ), + ) + .toBe(true); + expect( + allows( + "img-src https://cdn.example/img/a.png", + "https://cdn.example/img/b.png", + ), + ) + .toBe(false); +}); + +test("a host without a scheme takes the document's, and the network's for a document not served from it", () => { + expect(allows("img-src cdn.example", "https://cdn.example/a.png")).toBe(true); + expect( + allows("img-src cdn.example", "https://cdn.example/a.png", "about:blank"), + ) + .toBe(true); + expect( + allows( + "img-src cdn.example", + "http://cdn.example/a.png", + "https://mail.example/", + ), + ) + .toBe(false); +}); + +test("'self' matches the document's origin and its upgrade, and nothing for an opaque one", () => { + expect(allows("img-src 'self'", "https://mail.example/logo.png")).toBe(true); + expect(allows("img-src 'self'", "https://cdn.example/logo.png")).toBe(false); + expect( + allows( + "img-src 'self'", + "https://mail.example/a.png", + "http://mail.example/", + ), + ) + .toBe(true); + expect(allows("img-src 'self'", "file:///a.png", "file:///home/me/")).toBe( + false, + ); + // The same host over wss, and the port must agree. + expect( + allows("img-src 'self'", "wss://mail.example/a", "https://mail.example/"), + ) + .toBe(true); + expect( + allows( + "img-src 'self'", + "https://mail.example/a.png", + "http://mail.example:8080/", + ), + ) + .toBe(false); + expect( + allows( + "img-src 'self'", + "https://mail.example:8080/a.png", + "http://mail.example:8080/", + ), + ) + .toBe(true); + // An https document does not allow http from its own host. + expect(allows("img-src 'self'", "http://mail.example/a.png")).toBe(false); +}); + +test("every policy in a list must allow a load", () => { + expect( + allows( + "img-src https:, img-src https://cdn.example", + "https://cdn.example/a.png", + ), + ) + .toBe(true); + expect( + allows( + "img-src https:, img-src https://cdn.example", + "https://other.example/a.png", + ), + ) + .toBe(false); + const blocked = getBlockingDirective( + parseContentSecurityPolicies("img-src *, default-src 'self'"), + new URL("https://cdn.example/a.png"), + "image", + new URL("https://mail.example/"), + ); + expect([blocked?.directive, blocked?.policy.text]) + .toEqual(["default-src", "default-src 'self'"]); +}); diff --git a/tests/images.test.ts b/tests/images.test.ts index a5feaf401..6429ccbd4 100644 --- a/tests/images.test.ts +++ b/tests/images.test.ts @@ -13,10 +13,18 @@ import {expect, test} from "@b9g/libuild/test"; import { type Bitmap, decodeImage, + decodeImageOffThread, encodePNG, - readDataURL, + keepAtMost, + MAX_IMAGE_BYTES, + MAX_KEPT_PIXELS, + readImageResponse, + releaseDecoder, resolveImageURL, + retainDecoder, sampleBitmap, + setDecodeTimeout, + setDecodeWorkerURL, sniffImageType, } from "../src/internal/images.ts"; import {IMAGES} from "./fixtures/images.ts"; @@ -290,33 +298,359 @@ test("sampling averages the pixels a cell half covers, weighted by alpha", () => .toEqual([0, 0, 255, 255]); }); -test("data: URLs decode percent-encoded bytes, binary ones included", () => { - const bytes = readDataURL("data:application/octet-stream,%89PNG%0d%0a"); - expect(Array.from(bytes!)).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]); - expect(Array.from(readDataURL("data:;base64,AQ%3D%3D")!)).toEqual([1]); -}); - test("a Windows path is a file URL, not a URL with a drive-letter scheme", () => { expect(resolveImageURL("C:\\art\\cover.png", "about:blank")).toBe( "file:///C:/art/cover.png", ); }); -test("data: URLs decode base64 and percent-encoding", () => { - expect(Array.from(readDataURL("data:image/png;base64,AQID")!)).toEqual([ - 1, - 2, - 3, - ]); - expect(new TextDecoder().decode(readDataURL("data:,a%20b")!)).toBe("a b"); - expect(readDataURL("https://example.com/a.png")).toBeNull(); -}); - -test("a relative src resolves against the document, or the working directory", () => { +test("a relative src resolves against the document's URL, and at about:blank to nothing", () => { expect(resolveImageURL("b.png", "https://example.com/a/")).toBe( "https://example.com/a/b.png", ); - const local = resolveImageURL("art/cover.png", "about:blank"); - expect(local?.startsWith("file:///")).toBe(true); - expect(local?.endsWith("/art/cover.png")).toBe(true); + expect(resolveImageURL("art/cover.png", "file:///home/me/")).toBe( + "file:///home/me/art/cover.png", + ); + expect(resolveImageURL("art/cover.png", "about:blank")).toBeNull(); +}); + +// Images that ask for more than they are: a header's size, a stream +// that inflates past its image, more scans or components than an image +// has, or more bytes than an image file may have. + +function patched(name: string, at: number, ...values: number[]): Uint8Array { + const bytes = bytesOf(name).slice(); + bytes.set(values, at); + return bytes; +} + +function findMarker(bytes: Uint8Array, marker: number): number { + for (let i = 2; i < bytes.length - 1; i++) { + if (bytes[i] === 0xff && bytes[i + 1] === marker) { + return i; + } + } + throw new Error(`no marker ${marker.toString(16)}`); +} + +test("a size no image may have rejects before it is allocated", async () => { + const big = [0x00, 0x01, 0x86, 0xa0]; + await expect(decodeImage(patched("png-rgb.png", 16, ...big, ...big))) + .rejects.toThrow("more than an image may be"); + await expect(decodeImage(patched("png-rgb.png", 16, 0, 0, 0x9c, 0x40))) + .rejects.toThrow("more than an image may be"); + const sof = findMarker(bytesOf("jpeg-444.jpg"), 0xc0); + await expect( + decodeImage(patched("jpeg-444.jpg", sof + 5, 0xff, 0xff, 0xff, 0xff)), + ).rejects.toThrow("more than an image may be"); + await expect(decodeImage(patched("gif.gif", 6, 0xff, 0xff, 0xff, 0xff))) + .rejects.toThrow("more than an image may be"); + await expect( + decodeImage( + patched("bmp-24.bmp", 18, 0xa0, 0x86, 0x01, 0x00, 0xa0, 0x86, 0x01, 0x00), + ), + ).rejects.toThrow("more than an image may be"); +}); + +test("a JPEG with more components, sampling or scans than an image has rejects", async () => { + const sof = findMarker(bytesOf("jpeg-444.jpg"), 0xc0); + await expect(decodeImage(patched("jpeg-444.jpg", sof + 9, 5))) + .rejects.toThrow("more than four components"); + await expect(decodeImage(patched("jpeg-444.jpg", sof + 11, 0x55))) + .rejects.toThrow("past four"); + // The last scan again and again, ahead of the end marker. + const bytes = bytesOf("jpeg-progressive.jpg"); + let last = -1; + for (let at = findMarker(bytes, 0xda); at !== -1;) { + last = at; + let next = -1; + for (let i = at + 2; i < bytes.length - 1; i++) { + if (bytes[i] === 0xff && bytes[i + 1] === 0xda) { + next = i; + break; + } + } + at = next; + } + const end = bytes.length - 2; + const scan = bytes.subarray(last, end); + const many = new Uint8Array(last + scan.length * 501 + 2); + many.set(bytes.subarray(0, last)); + for (let i = 0; i < 501; i++) { + many.set(scan, last + i * scan.length); + } + many.set([0xff, 0xd9], many.length - 2); + await expect(decodeImage(many)).rejects.toThrow("more than 500 scans"); +}); + +test("a GIF frame's code size past 8 bits rejects", async () => { + const bytes = bytesOf("gif.gif"); + const descriptor = bytes.indexOf(0x2c, 13); + const flags = bytes[descriptor + 9]; + const table = flags & 0x80 ? 3 * (1 << ((flags & 7) + 1)) : 0; + await expect(decodeImage(patched("gif.gif", descriptor + 10 + table, 12))) + .rejects.toThrow("code size"); +}); + +test("PNG data that inflates past its image is read only as far as the image", async () => { + // 16 by 16 gray, filter byte 0 and a ramp on each row, and then + // megabytes of zeros the image has no room for. + const raw = new Uint8Array(16 * 17 + (1 << 24)); + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + raw[y * 17 + 1 + x] = x * 16; + } + } + const deflated = new Uint8Array( + await new Response( + new Blob([raw]).stream().pipeThrough(new CompressionStream("deflate")), + ).arrayBuffer(), + ); + const header = new Uint8Array(13); + header.set([0, 0, 0, 16, 0, 0, 0, 16, 8, 0, 0, 0, 0]); + const chunk = (type: string, data: Uint8Array) => { + const out = new Uint8Array(12 + data.length); + new DataView(out.buffer).setUint32(0, data.length); + out.set([...type].map((c) => c.charCodeAt(0)), 4); + out.set(data, 8); + return out; + }; + const parts = [ + new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk("IHDR", header), + chunk("IDAT", deflated), + chunk("IEND", new Uint8Array(0)), + ]; + const png = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0)); + let at = 0; + for (const part of parts) { + png.set(part, at); + at += part.length; + } + const bitmap = await decodeImage(png); + expect([bitmap.width, bitmap.height]).toEqual([16, 16]); + expect(pixel(bitmap, 15, 15)).toEqual([240, 240, 240, 255]); +}); + +test("an image response past the byte limit is not read whole", async () => { + const piece = new Uint8Array(1 << 20); + const declared = new Response(piece, { + headers: {"content-length": String(MAX_IMAGE_BYTES + 1)}, + }); + await expect(readImageResponse(declared)).rejects.toThrow("more than"); + let pulled = 0; + const endless = new Response( + new ReadableStream({ + pull(controller) { + pulled++; + controller.enqueue(piece); + }, + }), + ); + await expect(readImageResponse(endless)).rejects.toThrow("more than"); + expect(pulled).toBeLessThanOrEqual(MAX_IMAGE_BYTES / piece.length + 2); + await expect(readImageResponse(new Response(null, {status: 404}))) + .rejects.toThrow("status 404"); +}); + +// A worker from source text: it reports ready, then answers each +// request as `reply` says, or never. +function fakeWorker(reply: string): URL { + const source = + "const port = globalThis;" + + "const answer = (data) => {" + + reply + + "};" + + "addEventListener(\"message\", (e) => answer(e.data));" + + "postMessage({ready: true});"; + return new URL(`data:text/javascript,${encodeURIComponent(source)}`); +} + +// These need the runtime's own Worker, which Node has only with +// --experimental-web-worker. Without one, images decode on this thread, +// which the tests after them cover. +const withWorker = + typeof (globalThis as {Worker?: unknown}).Worker === + "function" + ? test + : test.skip; + +// A one-byte image stands for a slow or broken one in these workers. +const STAND_IN = new Uint8Array([0]); + +// How each decode ended: its width, or its error. +function outcomes(decodes: Array>): Promise { + return Promise.all( + decodes.map((decode) => + decode.then((bitmap) => + `${bitmap.width} wide`, (error: Error) => error.message), + ), + ); +} + +const ANSWER = + "port.postMessage({id: data.id, width: 1, height: 1, data: new ArrayBuffer(4)})"; + +withWorker( + "an image past its time fails alone, and the images behind it decode", + async () => { + try { + setDecodeTimeout(1000); + setDecodeWorkerURL( + fakeWorker(`if (data.bytes.byteLength !== 1) ${ANSWER};`), + ); + expect( + await outcomes([ + decodeImageOffThread(STAND_IN), + decodeImageOffThread(bytesOf("png-rgb.png")), + decodeImageOffThread(bytesOf("png-rgb.png")), + ]), + ).toEqual([ + "The image took more than 1000 ms to decode", + "1 wide", + "1 wide", + ]); + } finally { + setDecodeTimeout(10_000); + setDecodeWorkerURL(null); + } + }, +); + +withWorker( + "a decode aborted while it waits is dropped, and the line moves on", + async () => { + try { + setDecodeTimeout(1000); + setDecodeWorkerURL( + fakeWorker(`if (data.bytes.byteLength !== 1) ${ANSWER};`), + ); + const abort = new AbortController(); + const started = Date.now(); + let droppedAfter = -1; + // The stand-in, ahead of it, holds the worker until its time is up. + const stalled = decodeImageOffThread(STAND_IN); + const dropped = decodeImageOffThread(bytesOf("png-rgb.png"), abort.signal) + .finally(() => { + droppedAfter = Date.now() - started; + }); + const decodes = [ + stalled, + dropped, + decodeImageOffThread(bytesOf("png-rgb.png")), + ]; + abort.abort(new Error("The source changed")); + expect(await outcomes(decodes)).toEqual([ + "The image took more than 1000 ms to decode", + "The source changed", + "1 wide", + ]); + expect(droppedAfter).toBeLessThan(500); + await expect( + decodeImageOffThread(bytesOf("png-rgb.png"), abort.signal), + ).rejects.toThrow("The source changed"); + } finally { + setDecodeTimeout(10_000); + setDecodeWorkerURL(null); + } + }, +); + +withWorker( + "a worker that fails on an image fails that image alone", + async () => { + try { + setDecodeWorkerURL( + fakeWorker( + `if (data.bytes.byteLength === 1) throw new Error("broke"); ${ANSWER};`, + ), + ); + const [before, broken, after] = await outcomes([ + decodeImageOffThread(bytesOf("png-rgb.png")), + decodeImageOffThread(STAND_IN), + decodeImageOffThread(bytesOf("png-rgb.png")), + ]); + expect([before, after]).toEqual(["1 wide", "1 wide"]); + expect(broken).toContain("broke"); + setDecodeWorkerURL( + fakeWorker( + "port.postMessage({id: data.id, error: \"from the worker\"})", + ), + ); + await expect(decodeImageOffThread(bytesOf("png-rgb.png"))) + .rejects.toThrow("from the worker"); + } finally { + setDecodeWorkerURL(null); + } + }, +); + +withWorker( + "the last TermDOM disposed ends the worker, and the decode it ran", + async () => { + try { + setDecodeWorkerURL(fakeWorker("")); + retainDecoder(); + const decode = decodeImageOffThread(bytesOf("png-rgb.png")); + await new Promise((resolve) => setTimeout(resolve, 50)); + releaseDecoder(); + const outcome = await decode.then(() => "decoded", (error: Error) => + error.name); + expect(outcome).toBe("AbortError"); + } finally { + setDecodeWorkerURL(null); + } + }, +); + +test("where no worker starts, every image decodes on this thread", async () => { + try { + setDecodeWorkerURL( + new URL( + `data:text/javascript,${encodeURIComponent("throw new Error(\"no\");")}`, + ), + ); + const bitmaps = await Promise.all( + [0, 1, 2].map(() => decodeImageOffThread(bytesOf("png-rgb.png"))), + ); + expect(bitmaps.map((bitmap) => [bitmap.width, bitmap.height])) + .toEqual([[16, 16], [16, 16], [16, 16]]); + } finally { + setDecodeWorkerURL(null); + } +}); + +test("a decode on this thread stops past its time", async () => { + const width = 1500; + const data = new Uint8ClampedArray(width * width * 4); + for (let i = 0; i < data.length; i++) { + data[i] = (i * 2654435761) >>> 24; + } + const png = encodePNG({width, height: width, data}); + try { + setDecodeTimeout(0); + await expect(decodeImageOffThread(png)).rejects.toThrow("too long"); + setDecodeTimeout(10_000); + const bitmap = await decodeImageOffThread(png); + expect(bitmap.naturalWidth ?? bitmap.width).toBe(width); + } finally { + setDecodeTimeout(10_000); + } +}); + +test("an image past MAX_KEPT_PIXELS is kept averaged down, with its natural size", () => { + const width = 2000; + const height = 1000; + const data = new Uint8ClampedArray(width * height * 4); + for (let i = 0; i < width * height; i++) { + data.set(i % width < 1000 ? [255, 0, 0, 255] : [0, 0, 255, 255], i * 4); + } + const kept = keepAtMost({width, height, data}); + expect(kept.width * kept.height).toBeLessThanOrEqual(MAX_KEPT_PIXELS); + expect([kept.naturalWidth, kept.naturalHeight]).toEqual([2000, 1000]); + expect(kept.width / kept.height).toBeCloseTo(2, 1); + expect(pixel(kept, 0, 0)).toEqual([255, 0, 0, 255]); + expect(pixel(kept, kept.width - 1, 0)).toEqual([0, 0, 255, 255]); + const small = {width: 10, height: 10, data: new Uint8ClampedArray(400)}; + expect(keepAtMost(small)).toBe(small); }); diff --git a/tests/img.test.ts b/tests/img.test.ts index 240ea554f..fc5530a16 100644 --- a/tests/img.test.ts +++ b/tests/img.test.ts @@ -93,7 +93,10 @@ async function mount( {ask: "\x1b[16t", reply: `\x1b[6;${height};${width}t`}, ]); } - const dom = new TermDOM({transport: terminal.transport}); + const dom = new TermDOM({ + transport: terminal.transport, + csp: "img-src data: https:", + }); dom.document.body.innerHTML = html; await nextFrame(dom); return {dom, terminal, document: dom.document}; @@ -138,9 +141,9 @@ test("an image paints two pixels a cell in their own colors", async () => { [1, 1, BLUE, WHITE], ] as const) { const painted = cell(terminal, col, row); - expect(painted.char).toBe("▀"); - expect(painted.fg).toBe(hex(top)); - expect(painted.bg).toBe(hex(bottom)); + expect(painted.char).toBe("▄"); + expect(painted.fg).toBe(hex(bottom)); + expect(painted.bg).toBe(hex(top)); } // Two columns wide and no more. expect(cell(terminal, 2, 0).char).toBe(" "); @@ -263,7 +266,9 @@ test("an image sits on a line with the text around it", async () => { await mount(`

before after

`); await loaded(document.querySelector("img")!); await nextFrame(dom); - expect(rowText(terminal, 0)).toBe("before ▀▀ after"); + // A frame drawn while it loaded showed its wider loading box, whose + // last cell was erased. + expect(rowText(terminal, 0).trimEnd()).toBe("before ▄▄ after"); // The image's computed display stays inline, as in a browser. expect( dom.window.getComputedStyle(document.querySelector("img")!).display, @@ -305,7 +310,7 @@ test("object-fit: contain centers the image and leaves the rest alone", async () dom.dispose(); }); -test("a broken image fires error, rejects decode() and shows its alt text", async () => { +test("a broken image fires error, rejects decode() and shows a box with its alt text", async () => { const {dom, terminal, document} = await mount("

"); const image = document.createElement("img"); image.alt = "cover art"; @@ -325,7 +330,9 @@ test("a broken image fires error, rejects decode() and shows its alt text", asyn }); expect((rejected as Error | null)?.name).toBe("EncodingError"); await nextFrame(dom); - expect(rowText(terminal, 0)).toBe("[cover art]"); + expect(rowText(terminal, 0)).toBe("[┌─────────┐]"); + expect(rowText(terminal, 1)).toBe(" │cover art│"); + expect(rowText(terminal, 2)).toBe(" └─────────┘"); dom.dispose(); }); @@ -455,9 +462,9 @@ test("an image keeps its own height on a line with a taller neighbor", async () // tall, and the pixels are not stretched to fill it. expect(image.getBoundingClientRect().height).toBe(2); expect(cell(terminal, 0, 1)).toEqual({ - char: "▀", - fg: hex(BLUE), - bg: hex(WHITE), + char: "▄", + fg: hex(WHITE), + bg: hex(BLUE), }); expect(cell(terminal, 0, 2).char).toBe(" "); dom.dispose(); @@ -579,7 +586,8 @@ test("decode() settles when src changes, and the current image stays up meanwhil release = resolve; }); globalThis.fetch = (async (input: RequestInfo | URL) => { - if (String(input) === "https://images.test/bands.png") { + const url = input instanceof Request ? input.url : String(input); + if (url === "https://images.test/bands.png") { await held; return new Response(bands); } @@ -608,6 +616,7 @@ test("with a cell size, an image's pixels and lengths are CSS pixels", async () const dom = new TermDOM({ transport: terminal.transport, cellSize: {width: 7, height: 15}, + csp: "img-src data: https:", }); dom.document.body.innerHTML = `` + @@ -628,3 +637,109 @@ test("with a cell size, an image's pixels and lengths are CSS pixels", async () expect(cells("ratio")).toEqual([15, 3]); dom.dispose(); }); + +test("a new src cancels the request the old one started", async () => { + const {dom, document} = await mount(""); + const signals: AbortSignal[] = []; + dom.addEventListener("fetch", (event) => { + const {signal} = event.request; + signals.push(signal); + event.respondWith( + new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason)); + }), + ); + }); + const image = document.getElementById("i") as HTMLImageElement; + image.src = "https://example.com/first.png"; + await until(() => signals.length === 1); + image.src = "https://example.com/second.png"; + await until(() => signals.length === 2); + expect(signals.map((signal) => signal.aborted)).toEqual([true, false]); + dom.dispose(); +}); + +test("an image whose box is past the pixel limit paints nothing", async () => { + const square = png(4, 4, () => RED); + const {dom, terminal, document} = await mount( + ``, + ); + await loaded(document.querySelector("img")!); + await nextFrame(dom); + expect(cell(terminal, 0, 0).bg).toBe(-1); + dom.dispose(); +}); + +test("an image that is not showing is a box, sized as the image is", async () => { + const {dom, terminal, document} = await mount( + "
" + + "
\"\"|
" + + "
\"the
" + + "
\"logo\"
", + ); + await until(() => + [...document.querySelectorAll("img")].every((image) => image.complete), + ); + await nextFrame(dom); + expect([0, 1, 2].map((row) => rowText(terminal, row))).toEqual([ + "┌─┐", + "│ │", + "└─┘", + ]); + expect(rowText(terminal, 3)).toBe("|"); + expect([4, 5, 6, 7].map((row) => rowText(terminal, row))).toEqual([ + "┌────────┐", + "│the ban…│", + "│ │", + "└────────┘", + ]); + expect(rowText(terminal, 8)).toBe("lo…"); + dom.dispose(); +}); + +test("an image is a box while it loads", async () => { + const terminal = new MockProcess({cols: 40, rows: 12}); + const dom = new TermDOM({transport: terminal.transport}); + dom.addEventListener("fetch", (event) => { + event.respondWith(new Promise(() => {})); + }); + dom.document.body.innerHTML = + "\"a\""; + await nextFrame(dom); + await nextFrame(dom); + expect(rowText(terminal, 1)).toBe("│a│"); + dom.dispose(); +}); + +test("an image larger than a page keeps measures as large as it is", async () => { + // 1500 by 800 is past MAX_KEPT_PIXELS, so fewer pixels are kept. Red + // on the left half, blue on the right. + const wide = png(1500, 800, (x) => x < 750 ? RED : BLUE); + const {dom, document} = await mount( + `` + + "", + ); + const image = document.getElementById("a") as HTMLImageElement; + await loaded(image); + await nextFrame(dom); + expect([image.naturalWidth, image.naturalHeight]).toEqual([1500, 800]); + // 1500 by 800 pixels under the 8 by 16 guess is 187.5 by 50 cells. + const box = image.getBoundingClientRect(); + expect(box.height).toBe(Math.round(40 * 50 / 187.5)); + + const canvas = document.getElementById("c") as HTMLCanvasElement; + const context = canvas.getContext("2d")!; + // A source rectangle in the image's own pixels: its right half. + context.drawImage(image, 750, 0, 750, 800, 0, 0, 10, 10); + expect([...context.getImageData(5, 5, 1, 1).data]).toEqual(BLUE); + + const bitmap = await dom.window.createImageBitmap(image); + expect([bitmap.width, bitmap.height]).toEqual([1500, 800]); + + const pattern = context.createPattern(image, "repeat")!; + context.fillStyle = pattern; + context.fillRect(0, 0, 1500, 800); + expect([...context.getImageData(100, 400, 1, 1).data]).toEqual(RED); + expect([...context.getImageData(1400, 400, 1, 1).data]).toEqual(BLUE); + dom.dispose(); +}); diff --git a/tests/memory.test.ts b/tests/memory.test.ts index 58a95f716..d860a5654 100644 --- a/tests/memory.test.ts +++ b/tests/memory.test.ts @@ -6,6 +6,7 @@ import {expect, test} from "@b9g/libuild/test"; import {TermDOM} from "../src/index.ts"; +import {encodePNG} from "../src/internal/images.ts"; import {findCollector, MockProcess, nextFrame} from "./test-utils.js"; const collect = await findCollector(); @@ -196,3 +197,72 @@ async function growth(html: (round: number) => string): Promise { expect(grown).toBeLessThan(MB); }, ); + +// Pixels sit in array buffers, which the heap does not count. +async function heldMemory(): Promise { + let lowest = Infinity; + for (let i = 0; i < 3; i++) { + await settle(); + const usage = process.memoryUsage(); + lowest = Math.min(lowest, usage.heapUsed + (usage.external ?? 0)); + } + return lowest; +} + +// A 256 by 256 image is 256 KB decoded, so a page that held its images +// after they went would hold some 100 MB here. +const IMAGE = (() => { + const bytes = encodePNG({ + width: 256, + height: 256, + data: new Uint8ClampedArray(256 * 256 * 4).fill(200), + }); + let binary = ""; + for (let i = 0; i < bytes.length; i += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + } + return `data:image/png;base64,${btoa(binary)}`; +})(); + +async function showImages(dom: TermDOM, round: number): Promise { + const host = dom.document.createElement("div"); + host.innerHTML = + ``.repeat(4) + + "".repeat(2); + dom.document.body.append(host); + for (const canvas of host.querySelectorAll("canvas")) { + const context = canvas.getContext("2d")!; + context.fillStyle = `#${(round * 997 % 0xffffff).toString(16).padStart(6, "0")}`; + context.fillRect(0, 0, 256, 256); + } + // Every other round, the images go before they load. + if (round % 2 === 0) { + const images = [...host.querySelectorAll("img")]; + while (!images.every((image) => image.complete)) { + await new Promise((resolve) => setTimeout(resolve, 1)); + } + } + await nextFrame(dom); + host.remove(); + await nextFrame(dom); +} + +(collect === null ? test.skip : test)( + "showing and removing images and canvases does not grow memory", + async () => { + const dom = new TermDOM({ + transport: new MockProcess({cols: 100, rows: 40}).transport, + csp: "img-src data: https:", + }); + for (let round = 0; round < WARM_UP; round++) { + await showImages(dom, round); + } + const before = await heldMemory(); + for (let round = WARM_UP; round < WARM_UP + ROUNDS; round++) { + await showImages(dom, round); + } + const grown = (await heldMemory() - before) / 1e6; + dom.dispose(); + expect(grown).toBeLessThan(MB); + }, +); diff --git a/tests/requests.test.ts b/tests/requests.test.ts new file mode 100644 index 000000000..e2da53fdb --- /dev/null +++ b/tests/requests.test.ts @@ -0,0 +1,568 @@ +/** + * What a document's markup loads is first checked against its Content + * Security Policy. What the policy allows goes to the TermDOM as a + * "fetch" event, as a Service Worker hears it, and to the network when no + * listener answers. A redirect is checked against the policy again and + * followed on the network. + */ +import {mkdtempSync, rmSync, writeFileSync} from "node:fs"; +import {createServer} from "node:http"; +import type {AddressInfo} from "node:net"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; +import {pathToFileURL} from "node:url"; + +import {expect, test} from "@b9g/libuild/test"; + +import {ExtendableEvent, FetchEvent, TermDOM} from "../src/index.ts"; +import {encodePNG} from "../src/internal/images.ts"; +import {MockProcess, until} from "./test-utils.ts"; + +const PNG = encodePNG({ + width: 2, + height: 2, + data: new Uint8ClampedArray(16).fill(255), +}) as Uint8Array; + +function create( + options: {html?: string; url?: string; csp?: string} = {}, +): TermDOM { + return new TermDOM({ + transport: new MockProcess({cols: 40, rows: 12}).transport, + ...options, + }); +} + +async function settled(image: HTMLImageElement): Promise { + await until(() => image.complete); + return image.naturalWidth > 0 ? "loaded" : "broken"; +} + +interface Server { + origin: string; + port: number; + // Each request the server heard, as "METHOD /path". + heard: string[]; + close(): Promise; +} + +// A server that answers /redirect?to=URL with a 302 to it, /hop/N with a +// 302 to /hop/N-1 until /hop/0, and any other path with the PNG. +async function serve(): Promise { + const heard: string[] = []; + const server = createServer((request, response) => { + heard.push(`${request.method} ${request.url}`); + const url = new URL(request.url!, "http://localhost"); + if (url.pathname === "/redirect") { + response.writeHead(302, {location: url.searchParams.get("to")!}); + response.end(); + return; + } + const hop = /^\/hop\/(\d+)$/.exec(url.pathname); + if (hop !== null && hop[1] !== "0") { + response.writeHead(302, {location: `/hop/${Number(hop[1]) - 1}`}); + response.end(); + return; + } + response.writeHead(200, {"content-type": "image/png"}); + response.end(PNG); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const {port} = server.address() as AddressInfo; + return { + origin: `http://127.0.0.1:${port}`, + port, + heard, + close: () => new Promise((resolve) => { + server.closeAllConnections?.(); + server.close(() => resolve()); + }), + }; +} + +test("without a policy, markup loads nothing, and the block is reported", async () => { + const dom = create(); + let heard = 0; + dom.addEventListener("fetch", () => { + heard++; + }); + const violations: Array> = []; + dom.document.addEventListener("securitypolicyviolation", (event) => { + violations.push(event as SecurityPolicyViolationEvent); + }); + dom.document.body.innerHTML = + "\"logo\"" + + ""; + const images = [...dom.document.querySelectorAll("img")]; + expect(await Promise.all(images.map(settled))).toEqual(["broken", "broken"]); + expect(heard).toBe(0); + expect(violations).toHaveLength(2); + const [violation] = violations; + expect(violation.target).toBe(images[0]); + // A report carries no fragment, and only the scheme of a URL that is + // not HTTP(S). + expect([ + violation.blockedURI, + violations[1].blockedURI, + violation.effectiveDirective, + violation.originalPolicy, + violation.disposition, + violation.isTrusted, + ]).toEqual([ + "https://example.com/a.png", + "data", + "default-src", + "default-src 'none'", + "enforce", + true, + ]); + dom.dispose(); +}); + +test("a listener answers what the policy allows, as a Service Worker does", async () => { + const dom = create({csp: "img-src cid:"}); + const seen: FetchEvent[] = []; + dom.addEventListener("fetch", (event) => { + seen.push(event); + event.respondWith(new Response(PNG)); + }); + dom.document.body.innerHTML = + ""; + const a = dom.document.getElementById("a") as HTMLImageElement; + const b = dom.document.getElementById("b") as HTMLImageElement; + expect([await settled(a), await settled(b)]).toEqual(["loaded", "broken"]); + expect(seen).toHaveLength(1); + const [event] = seen; + expect(event).toBeInstanceOf(FetchEvent); + expect(event).toBeInstanceOf(ExtendableEvent); + expect([ + event.type, + event.cancelable, + event.request.url, + event.request.destination, + event.clientId, + event.resultingClientId, + event.replacesClientId, + ]).toEqual(["fetch", true, "cid:logo@example.com", "image", "", "", ""]); + expect(event.request.headers.get("accept")).toContain("image/png"); + expect(await event.preloadResponse).toBeUndefined(); + expect(await event.handled).toBeUndefined(); + dom.dispose(); +}); + +test("an allowed request no listener answers goes to the network", async () => { + const server = await serve(); + const dom = create({csp: `img-src ${server.origin}`}); + let heard = 0; + dom.addEventListener("fetch", () => { + heard++; + }); + dom.document.body.innerHTML = ``; + expect(await settled(dom.document.querySelector("img")!)).toBe("loaded"); + expect(heard).toBe(1); + expect(server.heard).toEqual(["GET /a.png"]); + dom.dispose(); + await server.close(); + + const data = create({csp: "img-src data:"}); + data.document.body.innerHTML = + ``; + expect(await settled(data.document.querySelector("img")!)).toBe("loaded"); + data.dispose(); +}); + +test("a redirect is checked against the policy again, and reported as the URL asked for", async () => { + const server = await serve(); + const elsewhere = `http://localhost:${server.port}/tracker.png`; + const dom = create({csp: `img-src ${server.origin}`}); + const violations: SecurityPolicyViolationEvent[] = []; + dom.document.addEventListener("securitypolicyviolation", (event) => { + violations.push(event as SecurityPolicyViolationEvent); + }); + const away = `${server.origin}/redirect?to=${encodeURIComponent(elsewhere)}`; + const home = `${server.origin}/redirect?to=%2Fhome.png`; + dom.document.body.innerHTML = ``; + const a = dom.document.getElementById("a") as HTMLImageElement; + const b = dom.document.getElementById("b") as HTMLImageElement; + expect([await settled(a), await settled(b)]).toEqual(["broken", "loaded"]); + expect(server.heard).not.toContain("GET /tracker.png"); + expect(server.heard).toContain("GET /home.png"); + expect(violations.map((violation) => violation.blockedURI)).toEqual([away]); + dom.dispose(); + await server.close(); +}); + +test("a redirect goes only to another HTTP(S) URL", async () => { + const server = await serve(); + const folder = mkdtempSync(join(tmpdir(), "termdom-redirect-")); + try { + writeFileSync(join(folder, "secret.png"), PNG); + const file = pathToFileURL(join(folder, "secret.png")).href; + const data = `data:image/png;base64,${Buffer.from(PNG).toString("base64")}`; + const dom = create({ + url: pathToFileURL(`${folder}/`).href, + csp: `img-src file: data: cid: ${server.origin}`, + }); + // The file loads when the page asks for it, through the listener, + // which a redirect never reaches. + const answered: string[] = []; + dom.addEventListener("fetch", (event) => { + if (event.request.url.startsWith("file:")) { + answered.push(event.request.url); + event.respondWith(new Response(PNG)); + } + }); + const to = (target: string) => + `${server.origin}/redirect?to=${encodeURIComponent(target)}`; + dom.document.body.innerHTML = + `` + + ``; + const images = [...dom.document.querySelectorAll("img")]; + expect(await Promise.all(images.map(settled))) + .toEqual(["broken", "broken", "broken", "loaded"]); + expect(answered).toEqual([file]); + dom.dispose(); + } finally { + await server.close(); + rmSync(folder, {recursive: true}); + } +}); + +test("twenty redirects are followed, and a twenty-first fails", async () => { + const server = await serve(); + const dom = create({csp: `img-src ${server.origin}`}); + dom.document.body.innerHTML = + ``; + const a = dom.document.getElementById("a") as HTMLImageElement; + const b = dom.document.getElementById("b") as HTMLImageElement; + expect([await settled(a), await settled(b)]).toEqual(["loaded", "broken"]); + dom.dispose(); + await server.close(); +}); + +test("a listener that passes the load on is checked where the load ended up", async () => { + const server = await serve(); + const elsewhere = `http://localhost:${server.port}/secret.png`; + const dom = create({csp: `img-src ${server.origin}`}); + dom.addEventListener("fetch", (event) => { + event.respondWith(fetch(event.request)); + }); + const violations: SecurityPolicyViolationEvent[] = []; + dom.document.addEventListener("securitypolicyviolation", (event) => { + violations.push(event as SecurityPolicyViolationEvent); + }); + const away = `${server.origin}/redirect?to=${encodeURIComponent(elsewhere)}`; + dom.document.body.innerHTML = + ``; + const a = dom.document.getElementById("a") as HTMLImageElement; + const b = dom.document.getElementById("b") as HTMLImageElement; + expect([await settled(a), await settled(b)]).toEqual(["broken", "loaded"]); + expect(violations.map((violation) => violation.blockedURI)).toEqual([away]); + dom.dispose(); + await server.close(); +}); + +test("an answer still pending ends when the source changes or the TermDOM is disposed", async () => { + const dom = create({csp: "img-src https:"}); + const handled: Array> = []; + dom.addEventListener("fetch", (event) => { + handled.push(event.handled); + event.respondWith(new Promise(() => {})); + }); + dom.document.body.innerHTML = ""; + const image = dom.document.querySelector("img")!; + await until(() => handled.length === 1); + image.src = "https://example.com/b.png"; + await until(() => handled.length === 2); + const first = await handled[0].then(() => "resolved", (error: DOMException) => + error.name); + expect(first).toBe("NetworkError"); + void dom.dispose(); + expect(await settled(image)).toBe("broken"); + const second = await handled[1].then( + () => "resolved", + (error: DOMException) => + error.name, + ); + expect(second).toBe("NetworkError"); +}); + +test("a listener's redirect is followed on the network, and checked", async () => { + const server = await serve(); + const dom = create({csp: `img-src cid: ${server.origin}`}); + let heard = 0; + dom.addEventListener("fetch", (event) => { + heard++; + const to = event.request.url === "cid:a" + ? `${server.origin}/a.png` + : "https://elsewhere.example/b.png"; + event.respondWith(Response.redirect(to, 302)); + }); + dom.document.body.innerHTML = ""; + const a = dom.document.getElementById("a") as HTMLImageElement; + const b = dom.document.getElementById("b") as HTMLImageElement; + expect([await settled(a), await settled(b)]).toEqual(["loaded", "broken"]); + // The redirect goes to the network, not back to the listener. + expect(heard).toBe(2); + expect(server.heard).toEqual(["GET /a.png"]); + dom.dispose(); + await server.close(); +}); + +test("a page's own meta policy narrows the program's, and never widens it", async () => { + const dom = create({csp: "img-src https:"}); + dom.addEventListener("fetch", (event) => { + event.respondWith(new Response(PNG)); + }); + dom.document.head.innerHTML = + ""; + dom.document.body.innerHTML = + "" + + "" + + ""; + const images = ["a", "b", "c"].map((id) => + dom.document.getElementById(id) as HTMLImageElement); + expect(await Promise.all(images.map(settled))) + .toEqual(["loaded", "broken", "broken"]); + dom.dispose(); +}); + +test("a meta policy holds from its insertion into the head, whatever becomes of the element", async () => { + const dom = create({csp: "img-src https:"}); + dom.addEventListener("fetch", (event) => { + event.respondWith(new Response(PNG)); + }); + const load = async (src: string): Promise => { + const image = dom.document.createElement("img"); + image.src = src; + dom.document.body.append(image); + return settled(image); + }; + const meta = dom.document.createElement("meta"); + meta.httpEquiv = "Content-Security-Policy"; + meta.content = "img-src https://cdn.example"; + dom.document.body.append(meta.cloneNode()); + expect(await load("https://other.example/a.png")).toBe("loaded"); + dom.document.head.append(meta); + expect(await load("https://other.example/b.png")).toBe("broken"); + meta.content = "img-src https:"; + meta.remove(); + expect(await load("https://other.example/c.png")).toBe("broken"); + expect(await load("https://cdn.example/d.png")).toBe("loaded"); + dom.dispose(); +}); + +test("Response.error(), a rejection, or preventDefault() without an answer fails the load", async () => { + const dom = create({csp: "img-src https:"}); + const handled: Array> = []; + dom.addEventListener("fetch", (event) => { + handled.push(event.handled); + const name = event.request.url.slice(-5); + if (name === "a.png") { + event.respondWith(Response.error()); + } else if (name === "b.png") { + event.respondWith(Promise.reject(new Error("no"))); + } else { + event.preventDefault(); + } + }); + dom.document.body.innerHTML = + "" + + "" + + ""; + const images = ["a", "b", "c"].map((id) => + dom.document.getElementById(id) as HTMLImageElement); + expect(await Promise.all(images.map(settled))) + .toEqual(["broken", "broken", "broken"]); + const outcomes = await Promise.all( + handled.map((promise) => + promise.then(() => "resolved", (error: DOMException) => error.name)), + ); + expect(outcomes).toEqual(["resolved", "NetworkError", "NetworkError"]); + dom.dispose(); +}); + +test("respondWith() is called once, during the event, and stops the other listeners", async () => { + const dom = create({csp: "img-src https:"}); + let late: FetchEvent | null = null; + const errors: string[] = []; + dom.addEventListener("fetch", (event) => { + late = event; + event.respondWith(new Response(PNG)); + try { + event.respondWith(new Response(PNG)); + } catch (error) { + errors.push((error as DOMException).name); + } + }); + dom.addEventListener("fetch", () => { + errors.push("second listener ran"); + }); + dom.document.body.innerHTML = ""; + expect(await settled(dom.document.querySelector("img")!)).toBe("loaded"); + expect(() => late!.respondWith(new Response(PNG))).toThrow(); + expect(errors).toEqual(["InvalidStateError"]); + dom.dispose(); +}); + +test("waitUntil() keeps dispose() waiting, and dispose() aborts loads in flight", async () => { + const dom = create({csp: "img-src https:"}); + let finish!: () => void; + let signal!: AbortSignal; + dom.addEventListener("fetch", (event) => { + signal = event.request.signal; + event.waitUntil( + new Promise((resolve) => { + finish = resolve; + }), + ); + event.respondWith(new Promise(() => {})); + }); + dom.document.body.innerHTML = ""; + await until(() => signal !== undefined); + let disposed = false; + const disposing = dom.dispose().then(() => { + disposed = true; + }); + expect(signal.aborted).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(disposed).toBe(false); + finish(); + await disposing; + expect(disposed).toBe(true); +}); + +test("a listener added after the constructor's markup hears its images", async () => { + const dom = create({html: "", csp: "img-src cid:"}); + dom.addEventListener("fetch", (event) => { + event.respondWith(new Response(PNG)); + }); + expect(await settled(dom.document.querySelector("img")!)).toBe("loaded"); + dom.dispose(); +}); + +test("only a document that is a file loads files, whatever the policy", async () => { + let heard = 0; + const dom = create({url: "https://mail.example/message/1", csp: "img-src *"}); + dom.addEventListener("fetch", (event) => { + heard++; + event.respondWith(new Response(PNG)); + }); + dom.document.body.innerHTML = ""; + expect(await settled(dom.document.querySelector("img")!)).toBe("broken"); + expect(heard).toBe(0); + dom.dispose(); + + const asked: string[] = []; + const local = create({url: "file:///home/me/art/", csp: "img-src file:"}); + local.addEventListener("fetch", (event) => { + asked.push(event.request.url); + event.respondWith(new Response(PNG)); + }); + local.document.body.innerHTML = ""; + expect(await settled(local.document.querySelector("img")!)).toBe("loaded"); + expect(asked).toEqual(["file:///home/me/art/cover.png"]); + local.dispose(); +}); + +test("window.fetch is the runtime's, resolved against the document, and no policy or listener governs it", async () => { + const server = await serve(); + const dom = create({url: `${server.origin}/app/`}); + let heard = 0; + dom.addEventListener("fetch", () => { + heard++; + }); + const answer = await dom.window.fetch("data", {method: "POST", body: "x"}); + expect(answer.status).toBe(200); + await answer.arrayBuffer(); + expect(server.heard).toEqual(["POST /app/data"]); + expect(heard).toBe(0); + dom.dispose(); + await server.close(); +}); + +test("a FetchEvent can be made by hand, and only TermDOM's can be answered or extended", () => { + const request = new Request("https://example.com/"); + const event = new FetchEvent("fetch", {request}); + expect([ + event.request, + event.clientId, + event.resultingClientId, + event.replacesClientId, + event.cancelable, + ]).toEqual([request, "", "", "", false]); + expect(event.handled).toBeInstanceOf(Promise); + expect(() => event.respondWith(new Response(""))).toThrow(); + expect(() => event.waitUntil(Promise.resolve())).toThrow(); + const target = new EventTarget(); + const errors: string[] = []; + target.addEventListener("fetch", (dispatched) => { + try { + (dispatched as FetchEvent).respondWith(new Response("")); + } catch (error) { + errors.push((error as DOMException).name); + } + }); + target.dispatchEvent(new FetchEvent("fetch", {request})); + expect(errors).toEqual(["InvalidStateError"]); +}); + +test("a SecurityPolicyViolationEvent takes its init, with violatedDirective an alias", () => { + const dom = create(); + const Violation = (dom.window as unknown as typeof globalThis) + .SecurityPolicyViolationEvent; + const event = new Violation("securitypolicyviolation", { + blockedURI: "https://example.com/a.png", + effectiveDirective: "img-src", + originalPolicy: "img-src 'none'", + statusCode: 200, + }); + expect([ + event.blockedURI, + event.violatedDirective, + event.disposition, + event.statusCode, + String(event), + ]).toEqual([ + "https://example.com/a.png", + "img-src", + "enforce", + 200, + "[object SecurityPolicyViolationEvent]", + ]); + expect(() => + new Violation("x", { + disposition: "bogus" as SecurityPolicyViolationEventDisposition, + })) + .toThrow(TypeError); + dom.dispose(); +}); + +test("a program with no policy hears once that markup loads nothing until one allows it", async () => { + const logged: string[] = []; + const transport = { + ...new MockProcess({cols: 40, rows: 12}).transport, + logError: (text: string) => { + logged.push(text); + return true; + }, + }; + const dom = new TermDOM({transport}); + dom.document.body.innerHTML = + ""; + const images = [...dom.document.querySelectorAll("img")]; + await Promise.all(images.map(settled)); + expect(logged).toHaveLength(1); + expect(logged[0]).toContain("csp"); + dom.dispose(); + + const quiet: string[] = []; + const set = new TermDOM({ + transport: {...transport, logError: (text: string) => quiet.push(text) > 0}, + csp: "img-src 'none'", + }); + set.document.body.innerHTML = ""; + await settled(set.document.querySelector("img")!); + expect(quiet).toEqual([]); + set.dispose(); +});