diff --git a/docs/1.guide/4.middleware.md b/docs/1.guide/4.middleware.md index fb00ccb..96fb795 100644 --- a/docs/1.guide/4.middleware.md +++ b/docs/1.guide/4.middleware.md @@ -94,10 +94,28 @@ When no file matches the request, it calls `next()` — so your handler acts as - `dir`: The directory to serve files from (required). - `methods`: HTTP methods to serve (default `["GET", "HEAD"]`). Other methods fall through to `next()`. -- `renderHTML`: A function receiving `{ request, html, filename }` for every `.html` file, returning the `Response` to send. Use it to inject or template markup before serving. +- `dotfiles`: Dot segments (path segments starting with `.`) that may be served (default `[".well-known"]`). A path containing any other dot segment — `/.env`, `/.git/config` — falls through to `next()`. Pass `true` to serve every dot segment, or `false` (or `[]`) to serve none, including `/.well-known/`. +- `encodings`: Serve precompressed variants from disk (default `false`). Pass `true` for `{ br: ".br", gzip: ".gz" }`, or a map setting the extension per encoding (keys tried in order, preferred first). Off by default because most deployments ship no precompressed files, so the lookup is a `stat` that always misses. +- `compress`: Compress a response on the fly when no precompressed variant is served (default `true`). Pass `false` to serve only what is already on disk. +- `renderHTML`: A function receiving `{ request, html, filename }` for every HTML file (`.html`, `.htm`), returning the `Response` to send. Use it to inject or template markup before serving. + +A request resolves in order: the path itself, then `.html`, then `/index.html`. So `/about` serves `about.html`, while an extension-less file (`LICENSE`, an ACME challenge token) is served at its exact name. A trailing-slash request names a directory, so `/sub/` resolves only `sub/index.html` — never `sub.html` or a file named `sub`. + +By default a compressible response is compressed on the fly as it is sent. Enabling `encodings` adds a disk lookup that takes precedence: for `/app.js` with `Accept-Encoding: br`, `app.js.br` is served if it exists (with `Content-Encoding: br`), and only a missing variant falls back to on-the-fly. A variant always wins because it costs no CPU, and a build can afford a better ratio than a per-request encode can justify — so `encodings: true` plus a build step is the cheapest way to serve maximum-quality compressed assets. The two switches are independent: `compress: false` serves only what is on disk, and `encodings` off with `compress` on always compresses on the fly. + +Brotli compresses at quality 4 rather than the `node:zlib` default of 11, which costs roughly 12x the CPU for a few percent of size. Only files between 1 KiB and 10 MiB are compressed on the fly: below that the encoded body can come out larger than the input, and above it the CPU spent per request outweighs the bandwidth saved — precompress large assets instead. On-the-fly responses are chunked, since the encoded length is not known until the bytes exist, while a variant declares the `Content-Length` it has on disk. + +Compression applies to compressible types only, so a `.br` next to an image or font is ignored, and those responses omit `Vary: Accept-Encoding` — which compressible ones always set, including uncompressed ones, as a shared cache must key on the header either way. `renderHTML` routes are never compressed and always read the source file: a variant on disk would not match the rendered output, and the `Response` the hook returns is the caller's to encode. + +`/.well-known/` is served by default because [RFC 8615](https://www.rfc-editor.org/rfc/rfc8615) reserves it for public metadata: ACME HTTP-01 challenges and `security.txt` live there. Allow-listing is by exact segment name, so `[".well-known"]` serves neither a sibling sharing its prefix (`.well-known-backup`) nor a dot segment nested under it (`.well-known/.env`). + +Text responses declare `charset=utf-8`; without it a browser decodes them with a fallback of its own choosing, mangling any non-ASCII byte the file does not declare inline. + +> [!NOTE] +> Files are only served from within `dir`, and both rules above are re-checked against the path a symlink actually resolves to. Symlinks are followed, but one resolving outside `dir` — or onto a dot segment `dotfiles` hides, such as `public.txt` → `.env` — falls through to `next()` instead of being served. > [!NOTE] -> Despite the runtime-neutral name, `srvx/static` is **Node-API-only** — it uses `node:fs` and `node:zlib` internally, so it works only on runtimes with Node.js compatibility (Node, Deno, Bun). +> Despite the runtime-neutral name, `srvx/static` is **Node-API-only** — it uses `node:fs` internally, so it works only on runtimes with Node.js compatibility (Node, Deno, Bun). See [Serving static files](/guide/cli#serving-static-files) for the equivalent CLI flag. diff --git a/src/static.ts b/src/static.ts index 4028800..e06aed2 100644 --- a/src/static.ts +++ b/src/static.ts @@ -1,11 +1,14 @@ import type { ServerMiddleware } from "./types.ts"; +import type { Stats } from "node:fs"; +import type { FileHandle } from "node:fs/promises"; import type { Transform } from "node:stream"; import { extname, join, resolve, sep } from "node:path"; -import { readFile, stat } from "node:fs/promises"; -import { createReadStream, ReadStream } from "node:fs"; +import { constants } from "node:fs"; +import { open, realpath, stat } from "node:fs/promises"; +import { pipeline } from "node:stream"; +import { constants as zlibConstants, createBrotliCompress, createGzip } from "node:zlib"; import { FastResponse } from "srvx"; -import { createGzip, createBrotliCompress } from "node:zlib"; import { FastURL } from "./_url.ts"; export interface ServeStaticOptions { @@ -19,6 +22,40 @@ export interface ServeStaticOptions { */ methods?: string[]; + /** + * Dot segments (a path segment starting with `.`, such as `.env` or `.git`) that may be served. + * + * An array allow-lists segments by exact name; a path containing any other dot segment falls + * through to `next()`. `true` serves every dot segment, `false` (or `[]`) none. + * + * @default [".well-known"] + */ + dotfiles?: boolean | string[]; + + /** + * Serve precompressed variants from disk. Off by default: most deployments ship none, + * so probing for one is a `stat` that always misses, on every compressible request. + * + * `true` uses `{ br: ".br", gzip: ".gz" }`; a map sets the extension per encoding (keys + * tried in order, so list the preferred encoding first). For `/app.js` with + * `Accept-Encoding: br`, `app.js.br` is served if it exists. A variant always wins over + * on-the-fly `compress`, as it costs no CPU. `false` (the default) skips the lookup. + * + * @default false + */ + encodings?: boolean | Record; + + /** + * Compress a response on the fly when no precompressed variant is served. + * + * Applies to compressible types only, and only to files between 1 KiB and 10 MiB — + * precompress anything larger. Pass `false` to serve only what is already on disk (with + * `encodings` off too, nothing is ever compressed). + * + * @default true + */ + compress?: boolean; + /** * A function to modify the HTML content before serving it. */ @@ -39,6 +76,7 @@ const COMMON_MIME_TYPES: Record = { ".json": "application/json", ".txt": "text/plain", ".xml": "application/xml", + ".wasm": "application/wasm", ".gif": "image/gif", ".ico": "image/vnd.microsoft.icon", ".jpeg": "image/jpeg", @@ -46,67 +84,399 @@ const COMMON_MIME_TYPES: Record = { ".png": "image/png", ".svg": "image/svg+xml", ".webp": "image/webp", + ".avif": "image/avif", ".woff": "font/woff", ".woff2": "font/woff2", + ".mp3": "audio/mpeg", ".mp4": "video/mp4", ".webm": "video/webm", ".zip": "application/zip", + ".gz": "application/gzip", ".pdf": "application/pdf", }; +// RFC 8615 reserves `/.well-known/` for public metadata (ACME HTTP-01 +// challenges, `security.txt`), so it is the only dot segment served by default. +const DEFAULT_DOTFILES = [".well-known"]; + +const DEFAULT_ENCODINGS: Record = { br: ".br", gzip: ".gz" }; + +// `createBrotliCompress()` defaults to BROTLI_DEFAULT_QUALITY (11), the maximum, +// which costs roughly 12x quality 4 for a few percent of size — per request, with +// nothing cached. Quality 4 is what CDNs encode at dynamically. The bill is not +// only this request's latency: zlib streams run on the same 4-thread libuv pool +// as every `stat`/`open` here, so an over-tuned quality stalls the fs work too. +const BROTLI_QUALITY = 4; + +// Under a TCP segment there is nothing to win: the encoded body can come out +// larger than the input, and it costs a round of CPU to find that out. +const COMPRESS_MIN_SIZE = 1024; + +// Compression inverts what normally makes a large file self-limiting — the +// response gets *smaller* while the server burns CPU proportional to the +// *uncompressed* size, so the request stops paying for itself in bandwidth. +// Past this, serve the bytes as-is and let a build step precompress them. +const COMPRESS_MAX_SIZE = 10 * 1024 * 1024; + +const COMPRESSORS: Record Transform> = { + br: (sizeHint) => + createBrotliCompress({ + params: { + [zlibConstants.BROTLI_PARAM_QUALITY]: BROTLI_QUALITY, + // Known here and never a guess, so brotli can size its window and + // allocations up front rather than growing them as the stream runs. + [zlibConstants.BROTLI_PARAM_SIZE_HINT]: sizeHint, + }, + }), + // zlib's own default level (6). gzip is cheap enough at any level — roughly an + // order of magnitude under brotli — that the default needs no walking back. + gzip: () => createGzip(), +}; + +// Append `sep` so prefix checks only match at a segment boundary (`/srv/www` +// must not also match `/srv/www-backup`). Roots already end with `sep`. +const asPrefix = (path: string): string => (path.endsWith(sep) ? path : path + sep); + +// A candidate is only known to be a regular file once it is open (the check is an +// `fstat` on the fd), and `open()` on a FIFO blocks until a writer arrives. Without +// `O_NONBLOCK` a pipe swapped in for a file — before the `stat` that precedes an +// `open`, or between the two — parks a libuv threadpool thread indefinitely, and the +// pool is 4 threads by default, so a handful of requests stall every fs op in the +// process. Reads of regular files ignore the flag. Windows has no `O_NONBLOCK` and +// cannot block this way: the `?? 0` leaves plain `O_RDONLY` there. +const OPEN_FLAGS = constants.O_RDONLY | (constants.O_NONBLOCK ?? 0); + +type ServableFile = { handle: FileHandle; size: number }; + +// An encoding this middleware can answer with: by serving a precompressed variant +// beside the file (`ext`), by encoding on the fly (`compressor`), or either. +type EncodingSpec = { + name: string; + ext?: string; + compressor?: (sizeHint: number) => Transform; +}; + export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { - const dir = resolve(options.dir) + sep; + // `resolve()` also converts separators (`C:/assets` -> `C:\assets`), and + // `join()`/`realpath()` only emit native ones, so every path a `sep`-based + // check sees is already in platform form. + const dir = asPrefix(resolve(options.dir)); const methods = new Set((options.methods || ["GET", "HEAD"]).map((m) => m.toUpperCase())); + const dotfiles = options.dotfiles ?? DEFAULT_DOTFILES; + const allowAllDots = dotfiles === true; + const allowedDots = new Set(Array.isArray(dotfiles) ? dotfiles : []); + + // Deny paths with a non-allow-listed dot segment. Matching is by exact + // segment, so allowing `.well-known` exposes neither `.well-known-backup` + // nor `.well-known/.env`. (`.`/`..` never reach this check: `join()` + // resolves them first.) + const isDeniedDotPath = (relPath: string): boolean => + !allowAllDots && relPath.split(sep).some((s) => s[0] === "." && !allowedDots.has(s)); + + const encodings = options.encodings === true ? DEFAULT_ENCODINGS : options.encodings || {}; + const compress = options.compress ?? true; + + // Encodings served, in server-preference order. Disk variants lead: their order + // is the documented preference, and a variant costs no CPU. An encoding reachable + // only by compressing follows, so the default (no `encodings`, `compress` on) is + // "never probe the disk, always encode on the fly" rather than a dead option. + const served: EncodingSpec[] = [ + ...Object.entries(encodings).map(([name, ext]) => ({ + name, + ext, + compressor: compress ? COMPRESSORS[name] : undefined, + })), + ...(compress + ? Object.keys(COMPRESSORS) + .filter((name) => !(name in encodings)) + .map((name) => ({ name, compressor: COMPRESSORS[name] })) + : []), + ]; + const varyOnEncoding = served.length > 0; + + // Symlink-resolved `dir` for containment checks — `dir` itself may + // legitimately be a symlink. Resolved lazily and cached only on success, so + // a `dir` created after the first request is still picked up. + let realDir: string | undefined; + const getRealDir = async (): Promise => { + if (realDir === undefined) { + const resolved = await realpath(dir).catch(() => null); + if (resolved === null) { + return dir; + } + realDir = asPrefix(resolved); + } + return realDir; + }; + + const statFile = async (candidate: string): Promise => { + const fileStat = await stat(candidate).catch(() => null); + return fileStat?.isFile() ? fileStat : null; + }; + + // The real security boundary, and the only way a served file is opened. The + // handler's checks are lexical while the filesystem follows symlinks, so a + // link inside `dir` could escape the root (`escape.txt` -> `/etc/passwd`) or + // alias a denied dot path to an allowed name (`public.txt` -> `.env`): both + // invariants are re-asserted against the resolved path. Links that stay + // inside `dir` on an allowed path still work. + // + // The bytes served come from the fd opened here, and the final inode + // comparison pins that fd to the path that passed the checks — with a + // check-then-`createReadStream(path)` sequence, a symlink swap in between + // would serve a file the checks never saw. + const openServable = async (candidate: string): Promise => { + const handle = await open(candidate, OPEN_FLAGS).catch(() => null); + if (handle === null) { + return null; + } + try { + const fileStat = await handle.stat(); + const realPath = fileStat.isFile() ? await realpath(candidate).catch(() => null) : null; + if (realPath !== null) { + const root = await getRealDir(); + if (realPath.startsWith(root) && !isDeniedDotPath(realPath.slice(root.length))) { + const realStat = await stat(realPath).catch(() => null); + if (realStat && realStat.ino === fileStat.ino && realStat.dev === fileStat.dev) { + return { handle, size: fileStat.size }; + } + } + } + } catch { + // fall through to close + } + await handle.close().catch(() => {}); + return null; + }; + return async (req, next) => { if (!methods.has(req.method)) { return next(); } const url = (req._url ??= new FastURL(req.url)); - const path = url.pathname.slice(1).replace(/\/$/, ""); + let path = url.pathname.slice(1); + // `/sub/` names a directory, so only its index is probed: serving `sub.html` + // or a file named `sub` there would mint a second URL for them, with + // relative links inside resolving against the wrong base. + const trailingSlash = path.endsWith("/"); + if (trailingSlash) { + path = path.replace(/\/+$/, ""); + } + if (path.includes("%")) { + // Decode the wire encoding exactly once, or names a client must encode + // (`café.txt`) are unreachable. `decodeURI` (not `decodeURIComponent`) + // keeps `%2F`/`%3F`/`%23` encoded, so an encoded separator never becomes + // a separator. Containment does not rely on the pathname arriving + // normalized: `join()` + `startsWith(dir)` below re-check whatever + // reaches them, including dot segments that decoding surfaces. + try { + path = decodeURI(path); + } catch { + // Malformed encoding (`/foo%`, `/%ZZ`): reject like nginx/serve-static. + return new FastResponse("Bad Request", { status: 400 }); + } + } let paths: string[]; if (path === "") { paths = ["index.html"]; + } else if (trailingSlash) { + paths = [`${path}/index.html`]; } else if (extname(path) === "") { - paths = [`${path}.html`, `${path}/index.html`]; + // TODO: consider answering `/sub` with a redirect to `/sub/` instead of + // serving `sub/index.html` in place (nginx sends 301): without the + // trailing slash, relative links inside that index resolve against `/`. + // + // The literal path comes first so an extension-less file is reachable at + // its exact name: ACME challenge tokens, `LICENSE`, and allow-listed + // dotfiles like `.env` (which `extname()` reports as extension-less). + paths = [path, `${path}.html`, `${path}/index.html`]; } else { paths = [path]; } - for (const path of paths) { - const filePath = join(dir, path); - if (!filePath.startsWith(dir)) { + // Parsed lazily: unmatched routes (all non-static traffic) never need it. + let acceptEncodings: EncodingSpec[] | undefined; + for (const candidate of paths) { + const filePath = join(dir, candidate); + // Cheap lexical pre-filter — `isServable` is the real boundary. Also + // guarantees `slice(dir.length)` yields an actual relative path. + if (!filePath.startsWith(dir) || isDeniedDotPath(filePath.slice(dir.length))) { + continue; + } + // The identity file gates the candidate: a variant without one beside it + // is a broken deploy, and probing it first keeps a miss (all unmatched + // traffic) at one syscall per candidate instead of one per encoding. + const identityStat = await statFile(filePath); + if (!identityStat) { continue; } - const fileStat = await stat(filePath).catch(() => null); - if (fileStat?.isFile()) { - const fileExt = extname(filePath); - const headers: HeadersInit = { - "Content-Length": fileStat.size.toString(), - "Content-Type": COMMON_MIME_TYPES[fileExt] || "application/octet-stream", - }; - if (options.renderHTML && fileExt === ".html") { - return options.renderHTML({ - html: await readFile(filePath, "utf8"), - filename: filePath, - request: req, - }); + const contentType = COMMON_MIME_TYPES[extname(filePath)] || "application/octet-stream"; + // Keyed off the resolved type so `.htm` renders like `.html`. + const renderHTML = contentType === "text/html" ? options.renderHTML : undefined; + // Already-compressed types gain nothing from either path. `renderHTML` + // routes are excluded too: a variant on disk would not match the rendered + // output, and the rendered `Response` is the caller's to encode. + const compressible = !renderHTML && isCompressible(contentType); + + let encoding = ""; + let servePath = filePath; + let file: ServableFile | null = null; + if (compressible) { + acceptEncodings ??= parseAcceptEncoding(req.headers.get("accept-encoding"), served); + for (const spec of acceptEncodings) { + if (!spec.ext) { + continue; + } + const variantPath = filePath + spec.ext; + // `stat` before `open`: a missing variant (the common case) should cost + // one syscall rather than an `open`/`fstat`/`close`. + if (!(await statFile(variantPath))) { + continue; + } + // An unservable variant (escapes the root, or resolves onto a denied + // dot path) is skipped, not fatal: the identity file can still serve. + const variant = await openServable(variantPath); + if (variant) { + encoding = spec.name; + servePath = variantPath; + file = variant; + break; + } } - let stream: ReadStream | Transform = createReadStream(filePath); - const acceptEncoding = req.headers.get("accept-encoding") || ""; - if (acceptEncoding.includes("br")) { - headers["Content-Encoding"] = "br"; - delete headers["Content-Length"]; - headers["Vary"] = "Accept-Encoding"; - stream = stream.pipe(createBrotliCompress()); - } else if (acceptEncoding.includes("gzip")) { - headers["Content-Encoding"] = "gzip"; - delete headers["Content-Length"]; - headers["Vary"] = "Accept-Encoding"; - stream = stream.pipe(createGzip()); + } + // Only the bytes actually sent need checking; a winning variant already was. + file ??= await openServable(filePath); + if (!file) { + continue; + } + // Nothing precompressed on disk: encode on the fly instead, provided the + // client takes an encoding we can produce and the file is in the size band + // where spending CPU is worth it. `file.size` (an `fstat` on the fd we are + // about to read) is the size actually served, not the earlier probe's. + let compressor: ((sizeHint: number) => Transform) | undefined; + if ( + compressible && + !encoding && + file.size >= COMPRESS_MIN_SIZE && + file.size <= COMPRESS_MAX_SIZE + ) { + const spec = acceptEncodings!.find((s) => s.compressor); + if (spec) { + encoding = spec.name; + compressor = spec.compressor; } + } + if (renderHTML) { + let html: string; + try { + html = await file.handle.readFile("utf8"); + } finally { + await file.handle.close().catch(() => {}); + } + const rendered = await renderHTML({ + html, + filename: servePath, + request: req, + }); + if (req.method !== "HEAD") { + return rendered; + } + // HEAD carries GET's headers without the body; cancel the unused body + // so a stream-backed rendered response releases its resource. + await rendered.body?.cancel().catch(() => {}); + return new FastResponse(null, { + status: rendered.status, + statusText: rendered.statusText, + headers: rendered.headers, + }); + } + // `Content-Type` comes from the base path: the variant's own extension + // is the encoding (`.br`), not the media type. Text carries an explicit + // charset so browsers do not decode non-ASCII with a guessed fallback. + const headers: Record = { + "Content-Type": contentType.startsWith("text/") + ? `${contentType}; charset=utf-8` + : contentType, + }; + // An encoded length is only known once the bytes exist, so an on-the-fly + // response is chunked. A variant's length is just its size on disk. + if (!compressor) { + headers["Content-Length"] = file.size.toString(); + } + if (encoding) { + headers["Content-Encoding"] = encoding; + } + if (varyOnEncoding && compressible) { + // Also set on the identity response: shared caches must key on the + // header either way. + headers["Vary"] = "Accept-Encoding"; + } + if (req.method === "HEAD") { + // Node discards a HEAD body at the http layer, so skip the read — and + // with it the compression a GET would pay for. The headers still + // describe what GET would send, chunked encoding included. + await file.handle.close().catch(() => {}); + return new FastResponse(null, { headers }); + } + // The stream closes the handle when it ends or errors (`autoClose`). + const stream = file.handle.createReadStream(); + if (!compressor) { return new FastResponse(stream as any, { headers }); } + // `pipeline` rather than `stream.pipe(encoded)`: `pipe` leaves the source + // running if the destination errors or is destroyed, so a client that + // disconnects mid-response would strand the fd until GC. `pipeline` tears + // down both. Errors land in the callback (an aborted response is routine) + // and destroy the streams, which surfaces to the client as a truncated + // body — the response headers are long gone by then. + const encoded = compressor(file.size); + pipeline(stream, encoded, () => {}); + return new FastResponse(encoded as any, { headers }); } return next(); }; }; + +// --- internal --- + +// Types that benefit from compression — everything else (images, video, audio, +// archives, fonts) is already compressed and would not have a `.br`/`.gz` variant. +function isCompressible(mimeType: string): boolean { + return ( + mimeType.startsWith("text/") || + mimeType.endsWith("+json") || + mimeType.endsWith("+xml") || + mimeType === "application/json" || + mimeType === "application/xml" || + mimeType === "application/wasm" + ); +} + +/** + * Encodings from `served` the client accepts, in server-preference order. + * `q=0` is honored as "not acceptable"; `*` applies to encodings not named explicitly. + */ +function parseAcceptEncoding(header: string | null, served: EncodingSpec[]): EncodingSpec[] { + if (!header) { + return []; + } + const quality = new Map(); + for (const part of header.split(",")) { + const [token, ...params] = part.split(";"); + const name = token!.trim().toLowerCase(); + if (!name) { + continue; + } + let q = 1; + for (const param of params) { + const trimmed = param.trim(); + if (trimmed.startsWith("q=")) { + // A malformed q (`q=abc`) parses to NaN: treat it as refused. + q = Number.parseFloat(trimmed.slice(2)) || 0; + } + } + quality.set(name, q); + } + const wildcard = quality.get("*"); + return served.filter(({ name }) => (quality.get(name) ?? wildcard ?? 0) > 0); +} diff --git a/test/static-nonblock.test.ts b/test/static-nonblock.test.ts new file mode 100644 index 0000000..247deb2 --- /dev/null +++ b/test/static-nonblock.test.ts @@ -0,0 +1,74 @@ +import { describe, test, expect, beforeAll, afterAll, vi } from "vitest"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ServerRequest } from "../src/types.ts"; + +// `serveStatic` reads a candidate's mode with `stat()` and opens it a syscall +// later, so an attacker who can write into the root can swap a regular file for +// a FIFO in between and `open()` will wait for a writer that never comes. That +// window is too narrow to hit deterministically, so it is reproduced here by +// making every `stat()` report a regular file whatever is really on disk — the +// state the middleware believes it is in after losing the race. `FileHandle.stat()` +// is a method on the returned handle, not this module binding, so the mode check +// inside `openServable` stays honest. +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + stat: async (path: Parameters[0]) => { + const stats = await actual.stat(path); // still throws ENOENT for a real miss + stats.isFile = () => true; + return stats; + }, + }; +}); + +const { serveStatic } = await import("../src/static.ts"); + +let tmp: string; +let dir: string; + +beforeAll(async () => { + tmp = await mkdtemp(join(tmpdir(), "srvx-nonblock-")); + dir = join(tmp, "public"); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "real.bin"), "REAL"); +}); + +afterAll(async () => { + await rm(tmp, { recursive: true, force: true }); +}); + +const fetchStatic = (path: string) => + serveStatic({ dir })( + new Request(`http://localhost${path}`) as unknown as ServerRequest, + () => new Response("next()", { status: 404 }), + ) as Promise; + +// `mkfifo` is POSIX-only, and `O_NONBLOCK` does not exist on Windows because +// `open()` cannot block this way there. +describe.skipIf(process.platform === "win32")("open() cannot block on a non-regular file", () => { + test("the lying stat still serves a genuine regular file", async () => { + // Guards the mock itself: if this broke, the FIFO test below would pass for + // the wrong reason. + const res = await fetchStatic("/real.bin"); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe("REAL"); + }); + + // `.bin` is incompressible, so no variant probing runs before the open. + test("declines a FIFO that stat claimed was a regular file", async () => { + const fifo = join(dir, "swapped.bin"); + execFileSync("mkfifo", [fifo]); + try { + // Without O_NONBLOCK this never settles and the test times out. + const res = await fetchStatic("/swapped.bin"); + expect(res.status).toBe(404); + await expect(res.text()).resolves.toBe("next()"); + } finally { + await rm(fifo, { force: true }); + } + }); +}); diff --git a/test/static.test.ts b/test/static.test.ts new file mode 100644 index 0000000..9427f0c --- /dev/null +++ b/test/static.test.ts @@ -0,0 +1,873 @@ +import { describe, test, expect, beforeAll, afterAll, afterEach } from "vitest"; +import { mkdtemp, mkdir, rm, writeFile, symlink, truncate } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join, parse, relative, sep } from "node:path"; +import { brotliDecompressSync, gunzipSync } from "node:zlib"; +import { serveStatic, type ServeStaticOptions } from "../src/static.ts"; +import { FastURL } from "../src/_url.ts"; +import type { ServerRequest } from "../src/types.ts"; + +let tmp: string; +let dir: string; +let linkedDir: string; + +// Over the 1 KiB floor under which nothing is compressed on the fly, and +// compressible enough that an encoded body is unmistakably smaller. +const BIG_JS = `/* ${"payload;".repeat(256)} */\n`; + +// Stands in for a precompressed `.gz` on disk (never real gzip — the middleware +// serves those bytes as-is). Padded over the floor as well, so that a test +// reaching it is decided by variant-over-on-the-fly precedence and not by the +// size check quietly refusing to compress a short variant. +const BIG_GZ_MARKER = `GZIP_BIG_FROM_DISK ${"z".repeat(2048)}`; + +// The ceiling past which a file is served as-is rather than compressed per request. +const COMPRESS_MAX_SIZE = 10 * 1024 * 1024; + +beforeAll(async () => { + tmp = await mkdtemp(join(tmpdir(), "srvx-static-")); + + // /outside/secret.txt is never inside the served root. + await mkdir(join(tmp, "outside"), { recursive: true }); + await writeFile(join(tmp, "outside", "secret.txt"), "TOPSECRET"); + + // /public is the served root. + dir = join(tmp, "public"); + await mkdir(join(dir, "sub"), { recursive: true }); + await writeFile(join(dir, "index.html"), "

index

"); + await writeFile(join(dir, "sub", "inside.txt"), "INSIDE"); + + // A nested index, reached by naming its directory rather than the file. + await writeFile(join(dir, "sub", "index.html"), "

sub index

"); + + // Dotfiles: bare, with an extension, nested, and inside a dot directory. + await mkdir(join(dir, ".git"), { recursive: true }); + await writeFile(join(dir, ".env"), "DOTENV"); + await writeFile(join(dir, ".env.production"), "PROD_SECRET"); + await writeFile(join(dir, "sub", ".env.local"), "LOCAL_SECRET"); + await writeFile(join(dir, ".git", "config.txt"), "GIT_CONFIG"); + + // `.well-known` (RFC 8615) is allow-listed by default. + await mkdir(join(dir, ".well-known"), { recursive: true }); + await writeFile(join(dir, ".well-known", "security.txt"), "SECURITY_TXT"); + + // A dot segment nested under an allow-listed one is still a dot segment. + await writeFile(join(dir, ".well-known", ".env"), "WELLKNOWN_SECRET"); + + // A sibling that merely shares the `.well-known` prefix must not be matched. + await mkdir(join(dir, ".well-known-backup"), { recursive: true }); + await writeFile(join(dir, ".well-known-backup", "secret.txt"), "BACKUP_SECRET"); + + // Precompressed variants. Contents are markers, not real brotli/gzip: the + // middleware serves the bytes as-is and never decompresses them. + await writeFile(join(dir, "app.js"), "PLAIN_JS"); + await writeFile(join(dir, "app.js.br"), "BROTLI_JS"); + await writeFile(join(dir, "app.js.gz"), "GZIP_JS"); + await writeFile(join(dir, "only-gz.js"), "PLAIN_ONLY_GZ"); + await writeFile(join(dir, "only-gz.js.gz"), "GZIP_ONLY_GZ"); + + // A variant with no identity file beside it. + await writeFile(join(dir, "orphan.js.br"), "ORPHAN_BR"); + + // On-the-fly compression fixtures. Everything above is deliberately under the + // 1 KiB floor, so only these are ever encoded per request. + // + // `big.js` has no variant beside it: encoding it here is the only way it is + // served compressed. `big-gz.js` has one, so it pins the precedence between + // the two paths. + await writeFile(join(dir, "big.js"), BIG_JS); + await writeFile(join(dir, "big-gz.js"), BIG_JS); + await writeFile(join(dir, "big-gz.js.gz"), BIG_GZ_MARKER); + + // The two size bounds, from either side. `truncate` extends `huge.js` with + // zeros sparsely, so a 10 MiB size costs neither the write nor the disk. + await writeFile(join(dir, "small.js"), "s".repeat(1023)); + await writeFile(join(dir, "huge.js"), "// huge\n"); + await truncate(join(dir, "huge.js"), COMPRESS_MAX_SIZE + 1); + + // Compressible-sized bodies behind a type and a route that must not encode. + await writeFile(join(dir, "big.png"), BIG_JS); + await writeFile(join(dir, "big.html"), `

${"x".repeat(2048)}

`); + + // Extension-less files, reachable at their exact name. + await writeFile(join(dir, "LICENSE"), "LICENSE_BODY"); + await writeFile(join(dir, "apple-app-site-association"), "AASA_BODY"); + await mkdir(join(dir, ".well-known", "acme-challenge"), { recursive: true }); + await writeFile(join(dir, ".well-known", "acme-challenge", "tok3n"), "ACME_KEY_AUTH"); + + // An extension-less route that must still resolve to its `.html` file. + await writeFile(join(dir, "about.html"), "

about

"); + + // `.htm` maps to `text/html` just as `.html` does. + await writeFile(join(dir, "page.htm"), "

htm

"); + + // Names that only appear percent-encoded on the wire. + await writeFile(join(dir, "hello world.txt"), "SPACE_NAME"); + await writeFile(join(dir, "café.txt"), "UNICODE_NAME"); + await writeFile(join(dir, "50%.txt"), "PERCENT_NAME"); + + // Extensions whose MIME mapping is pinned below; the contents never matter. + for (const name of ["mod.wasm", "pic.avif", "song.mp3", "bundle.gz"]) { + await writeFile(join(dir, name), "X"); + } + + // Already-compressed type: a `.br` next to it must never be looked up. + await writeFile(join(dir, "logo.png"), "PNG_BYTES"); + await writeFile(join(dir, "logo.png.br"), "PNG_BR_SHOULD_BE_IGNORED"); + + // Escaping links: one to a file, one to a directory. + await symlink(join(tmp, "outside", "secret.txt"), join(dir, "escape.txt")); + await symlink(join(tmp, "outside"), join(dir, "escape-dir")); + + // An escaping link reached via the precompressed-variant lookup: the plain + // file is contained, but `.br` points outside the root. + await writeFile(join(dir, "escape-variant.js"), "PLAIN_VARIANT"); + await symlink(join(tmp, "outside", "secret.txt"), join(dir, "escape-variant.js.br")); + + // A link that stays within the root must keep working. + await symlink(join(dir, "sub", "inside.txt"), join(dir, "contained.txt")); + + // Links that stay inside the root but land on a dot path: containment alone + // serves these, since the name they are requested under has no dot segment. + await symlink(join(dir, ".env"), join(dir, "alias-dotfile.txt")); + await symlink(join(dir, ".git"), join(dir, "alias-dotdir")); + await symlink(join(dir, ".well-known", "security.txt"), join(dir, "alias-allowed.txt")); + + // The same alias reached through the precompressed-variant lookup. + await writeFile(join(dir, "alias-variant.js"), "PLAIN_ALIAS_VARIANT"); + await symlink(join(dir, ".env"), join(dir, "alias-variant.js.br")); + + // A root that is itself a symlink must keep working. + linkedDir = join(tmp, "public-link"); + await symlink(dir, linkedDir); +}); + +afterAll(async () => { + await rm(tmp, { recursive: true, force: true }); +}); + +const req = (path: string, init?: RequestInit) => + new Request(`http://localhost${path}`, init) as unknown as ServerRequest; +const notFound = () => new Response("next()", { status: 404 }); + +// Served bodies are backed by an open file handle that a real server drains to +// the socket. Tests that only look at the status or headers must still release +// it, or the run leaks handles until GC — so every response is registered and +// any unconsumed body cancelled after each test. +const responses: Response[] = []; +const track = async (res: Promise) => { + const resolved = await res; + responses.push(resolved); + return resolved; +}; +afterEach(async () => { + for (const res of responses.splice(0)) { + if (!res.bodyUsed) { + // Drained, not `body.cancel()`-ed: undici does not propagate a cancel + // to a wrapped Node readable, so only a read releases the handle. + await res.arrayBuffer().catch(() => {}); + } + } +}); + +const fetchStatic = (path: string, opts: Partial = {}, init?: RequestInit) => + track(serveStatic({ dir, ...opts })(req(path, init), notFound) as Promise); + +const fetchEncoded = ( + path: string, + acceptEncoding: string, + opts: Partial = {}, +) => fetchStatic(path, opts, { headers: { "accept-encoding": acceptEncoding } }); + +// The precompressed-variant lookup is opt-in (off by default), so any test that +// exercises it enables it. On-the-fly compression keeps its own default. +const fetchVariant = ( + path: string, + acceptEncoding: string, + opts: Partial = {}, +) => fetchEncoded(path, acceptEncoding, { encodings: true, ...opts }); + +// Every denial falls through to the app rather than being answered here, so the +// sentinel body from `notFound()` is what proves the middleware declined. It is +// strictly stronger than asserting the secret is absent. +const expectNext = async (res: Response, label?: string) => { + expect(res.status, label).toBe(404); + await expect(res.text()).resolves.toBe("next()"); +}; + +// `new Request()` collapses dot segments in its constructor, so a request built +// through it hands the middleware an already-resolved pathname and can never +// exercise the containment check. `FastURL`'s origin-form fast path returns the +// target verbatim (`_searchNeedsNormRE` in `_url.ts` does not deopt on `..`), +// which is the one way an unresolved pathname reaches `static.ts` — so build +// `_url` directly to test the check rather than the test harness. +const rawReq = (path: string) => { + const request = new Request("http://localhost/") as unknown as ServerRequest; + request._url = new FastURL(path); + return request; +}; + +const fetchRaw = (path: string) => + track(serveStatic({ dir })(rawReq(path), notFound) as Promise); + +describe("serveStatic", () => { + test("serves a file", async () => { + const res = await fetchStatic("/sub/inside.txt"); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe("INSIDE"); + }); + + test("serves index.html for /", async () => { + const res = await fetchStatic("/"); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toContain("index"); + }); + + test("serves from a dir that is a filesystem root", async () => { + // A root already ends at a segment boundary, so the `dir + sep` prefix must + // not become `//` — nothing would match it and every request would 404. + // `dotfiles: true` because the host path above `tmpdir()` may itself hold a + // dot segment (`~/.cache/...`), which is not what this pins. + const { root } = parse(dir); + const urlPath = "/" + relative(root, join(dir, "index.html")).split(sep).join("/"); + const res = await fetchStatic(urlPath, { dir: root, dotfiles: true }); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toContain("index"); + }); + + describe("methods", () => { + test("falls through for a method outside the default GET/HEAD", async () => { + await expectNext(await fetchStatic("/app.js", {}, { method: "POST" })); + }); + + test("serves a method named in `methods`", async () => { + const res = await fetchStatic("/app.js", { methods: ["POST"] }, { method: "POST" }); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe("PLAIN_JS"); + }); + + test("matches the method case-insensitively", async () => { + const res = await fetchStatic("/app.js", { methods: ["post"] }, { method: "POST" }); + expect(res.status).toBe(200); + }); + + test("no longer serves GET when `methods` omits it", async () => { + await expectNext(await fetchStatic("/app.js", { methods: ["POST"] })); + }); + }); + + describe("directory routes", () => { + test("serves /index.html for an extension-less directory route", async () => { + const res = await fetchStatic("/sub"); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toContain("sub index"); + }); + + test.each(["/sub/", "/sub//"])("serves /index.html for %s", async (path) => { + const res = await fetchStatic(path); + expect(res.status, path).toBe(200); + await expect(res.text()).resolves.toContain("sub index"); + }); + + test.each(["/app.js/", "/sub/inside.txt/", "/about/"])( + "only probes the index for a slash-terminated URL (%s)", + async (path) => { + // `/app.js/` must not serve `app.js`, nor `/about/` serve `about.html`: + // a directory URL names the index or nothing, or the same file gains a + // second URL whose relative links resolve against the wrong base. + await expectNext(await fetchStatic(path)); + }, + ); + }); + + describe("symlinks", () => { + test("does not serve a symlink escaping the root", async () => { + await expectNext(await fetchStatic("/escape.txt")); + }); + + test("does not serve through a symlinked directory escaping the root", async () => { + await expectNext(await fetchStatic("/escape-dir/secret.txt")); + }); + + test("serves a symlink contained within the root", async () => { + const res = await fetchStatic("/contained.txt"); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe("INSIDE"); + }); + + test("serves files when dir is itself a symlink", async () => { + const res = await fetchStatic("/sub/inside.txt", { dir: linkedDir }); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe("INSIDE"); + }); + + test("still rejects an escaping symlink when dir is itself a symlink", async () => { + await expectNext(await fetchStatic("/escape.txt", { dir: linkedDir })); + }); + + describe("aliasing a dot path", () => { + // The dotfile policy reads the request path, containment reads the + // resolved one. A link inside the root that lands on a hidden dot path + // satisfies containment, so the policy has to be re-checked after + // resolving or the link publishes what it names. + const DOT_ALIASES = [ + ["/alias-dotfile.txt", "DOTENV"], + ["/alias-dotdir/config.txt", "GIT_CONFIG"], + ]; + + test.each(DOT_ALIASES)( + "does not serve %s, which resolves onto a denied dot path", + async (path) => { + await expectNext(await fetchStatic(path!)); + }, + ); + + test.each(DOT_ALIASES)("serves %s with dotfiles: true", async (path, contents) => { + // The policy is what hides these, not containment: both links resolve + // inside the root, so lifting the policy must serve them. + await expect(fetchStatic(path!, { dotfiles: true }).then((r) => r.text())).resolves.toBe( + contents, + ); + }); + + test("serves an alias whose target is allow-listed", async () => { + // Resolving must re-apply the policy, not blanket-deny every link that + // lands on a dot segment. + const res = await fetchStatic("/alias-allowed.txt"); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe("SECURITY_TXT"); + }); + }); + }); + + // `mkfifo` is POSIX-only; Windows has no FIFO to open in the first place. + // + // These pin the ordinary outcome — a pipe sitting in the root is refused by the + // `stat()` mode check, before any `open()`. The harder case, where that check is + // won by a swap and `open()` itself meets the FIFO, needs a lying `stat` to + // reproduce and lives in `static-nonblock.test.ts`. + describe.skipIf(process.platform === "win32")("non-regular files", () => { + test("declines a FIFO", async () => { + const fifo = join(dir, "pipe.txt"); + execFileSync("mkfifo", [fifo]); + try { + await expectNext(await fetchStatic("/pipe.txt")); + } finally { + await rm(fifo, { force: true }); + } + }); + + // Its own identity file rather than `app.js`, whose `.br` the shared fixture + // already occupies. + test("declines a FIFO standing in for a precompressed variant", async () => { + const identity = join(dir, "piped.js"); + const fifo = `${identity}.br`; + await writeFile(identity, "PIPED_JS"); + execFileSync("mkfifo", [fifo]); + try { + // The identity file still serves; only the variant is unusable. + const res = await fetchVariant("/piped.js", "br"); + expect(res.status).toBe(200); + expect(res.headers.get("content-encoding")).toBe(null); + await expect(res.text()).resolves.toBe("PIPED_JS"); + } finally { + await rm(fifo, { force: true }); + await rm(identity, { force: true }); + } + }); + }); + + describe("dotfiles", () => { + const DOT_PATHS = [ + ["/.env", "DOTENV"], + ["/.env.production", "PROD_SECRET"], + ["/sub/.env.local", "LOCAL_SECRET"], + ["/.git/config.txt", "GIT_CONFIG"], + ]; + + test.each(DOT_PATHS)("does not serve %s by default", async (path) => { + await expectNext(await fetchStatic(path!)); + }); + + test.each(DOT_PATHS)("serves %s with dotfiles: true", async (path, contents) => { + const res = await fetchStatic(path!, { dotfiles: true }); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe(contents); + }); + + test("serves an arbitrary allow-listed segment and nothing else", async () => { + const opts = { dotfiles: [".git"] }; + const res = await fetchStatic("/.git/config.txt", opts); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe("GIT_CONFIG"); + expect((await fetchStatic("/.env", opts)).status).toBe(404); + }); + + describe(".well-known", () => { + test("is served by default", async () => { + const res = await fetchStatic("/.well-known/security.txt"); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe("SECURITY_TXT"); + }); + + test("serves an ACME challenge token by default", async () => { + // Extension-less and under a dot directory, so this needs both the + // literal probe and the default allow-list. Renewing a certificate must + // not require `dotfiles: true`, which would also publish `.env`/`.git`. + const res = await fetchStatic("/.well-known/acme-challenge/tok3n"); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe("ACME_KEY_AUTH"); + }); + + test("matches by exact segment, not by prefix", async () => { + await expectNext(await fetchStatic("/.well-known-backup/secret.txt")); + }); + + test("does not serve a dot segment nested under it", async () => { + await expectNext(await fetchStatic("/.well-known/.env")); + }); + + test.each([ + ["false", false], + ["[]", []], + ])("is hidden with dotfiles: %s", async (_label, dotfiles) => { + const res = await fetchStatic("/.well-known/security.txt", { dotfiles }); + expect(res.status).toBe(404); + }); + }); + }); + + describe("precompressed lookup (opt-in via encodings)", () => { + // (file, Accept-Encoding) -> which bytes are served. `/app.js` has both a + // `.br` and a `.gz` beside it; `/only-gz.js` only a `.gz`; `/index.html` + // neither. All fixtures are under the 1 KiB floor, so on-the-fly compression + // never fires here and the served bytes reflect the variant lookup alone. + const VARIANT_CASES: { + why: string; + accept: string; + enc: string | null; + body: string; + path?: string; + }[] = [ + { + why: "prefers brotli when both variants exist", + accept: "gzip, br", + enc: "br", + body: "BROTLI_JS", + }, + { + why: "falls back to gzip when brotli is not accepted", + accept: "gzip", + enc: "gzip", + body: "GZIP_JS", + }, + { + why: "falls back to the plain file when no variant is accepted", + accept: "", + enc: null, + body: "PLAIN_JS", + }, + { why: "honors q=0 as a refusal", accept: "br;q=0, gzip", enc: "gzip", body: "GZIP_JS" }, + { why: "honors an explicit q ranking", accept: "br;q=1.0", enc: "br", body: "BROTLI_JS" }, + { why: "treats a malformed q as a refusal", accept: "br;q=abc", enc: null, body: "PLAIN_JS" }, + { why: "supports the * wildcard", accept: "*", enc: "br", body: "BROTLI_JS" }, + // "x-gzip" must not satisfy "gzip", nor "brotli" satisfy "br". + { + why: "does not match an encoding as a substring", + accept: "x-gzip, brotli", + enc: null, + body: "PLAIN_JS", + }, + // An empty token must be skipped rather than parsed as an encoding. + { why: "ignores empty tokens", accept: ", , br", enc: "br", body: "BROTLI_JS" }, + { + why: "skips a missing variant and uses the next accepted one", + accept: "br, gzip", + enc: "gzip", + body: "GZIP_ONLY_GZ", + path: "/only-gz.js", + }, + { + why: "falls back to the plain file when no variant exists on disk", + accept: "br", + enc: null, + body: "

index

", + path: "/index.html", + }, + ]; + + test.each(VARIANT_CASES)("$why", async ({ accept, enc, body, path = "/app.js" }) => { + const res = await fetchVariant(path, accept); + expect(res.headers.get("content-encoding")).toBe(enc); + await expect(res.text()).resolves.toBe(body); + }); + + test("is off by default, so a variant on disk is not served", async () => { + // `/app.js` (8 bytes) has a `.br` beside it, but without `encodings` the + // lookup never runs and the file is under the on-the-fly floor, so the + // plain bytes are what come back. + const res = await fetchEncoded("/app.js", "br"); + expect(res.headers.get("content-encoding")).toBe(null); + await expect(res.text()).resolves.toBe("PLAIN_JS"); + }); + + test("sets Vary: Accept-Encoding on a variant response and its identity", async () => { + expect((await fetchVariant("/app.js", "br")).headers.get("vary")).toBe("Accept-Encoding"); + // Also on the uncompressed response: caches must key on the header. + expect((await fetchVariant("/index.html", "")).headers.get("vary")).toBe("Accept-Encoding"); + }); + + test("sets Content-Length to the served variant's size", async () => { + const res = await fetchVariant("/app.js", "br"); + expect(res.headers.get("content-length")).toBe(String("BROTLI_JS".length)); + }); + + test("skips the lookup with encodings: {}", async () => { + // `{}` is as explicit an off as the default. `/app.js` is under the floor, + // so with no lookup there is nothing left to serve but the plain bytes. + const res = await fetchEncoded("/app.js", "br", { encodings: {} }); + expect(res.headers.get("content-encoding")).toBe(null); + await expect(res.text()).resolves.toBe("PLAIN_JS"); + }); + + test("ignores a variant with no identity file beside it", async () => { + // The identity file gates the lookup, so an orphan `.br` is not a route. + // Nothing is lost: a client accepting no encoding could not be served it + // anyway, so shipping one without its source is already a broken deploy. + await expectNext(await fetchVariant("/orphan.js", "br")); + }); + + test.each([ + // Links out of the root, and onto a denied dot path respectively. Both + // plain files stay put, and a variant that is not servable is skipped + // rather than fatal — the identity file below it still serves. + ["/escape-variant.js", "PLAIN_VARIANT", "TOPSECRET"], + ["/alias-variant.js", "PLAIN_ALIAS_VARIANT", "DOTENV"], + ])( + "falls back to the plain file when %s's variant is not servable", + async (path, body, secret) => { + const res = await fetchVariant(path!, "br"); + expect(res.status).toBe(200); + expect(res.headers.get("content-encoding")).toBe(null); + const text = await res.text(); + expect(text).toBe(body); + expect(text).not.toContain(secret); + }, + ); + }); + + describe("on-the-fly compression", () => { + const decode: Record Buffer> = { + br: brotliDecompressSync, + gzip: gunzipSync, + }; + + test.each(["br", "gzip"])("encodes with %s when no variant exists", async (enc) => { + const res = await fetchEncoded("/big.js", enc); + expect(res.headers.get("content-encoding")).toBe(enc); + // Decoding the body is what proves it was really encoded, rather than the + // plain bytes sent under an encoding header. + const body = Buffer.from(await res.arrayBuffer()); + expect(decode[enc]!(body).toString()).toBe(BIG_JS); + expect(body.length).toBeLessThan(BIG_JS.length); + }); + + test("prefers brotli over gzip", async () => { + const res = await fetchEncoded("/big.js", "gzip, br"); + expect(res.headers.get("content-encoding")).toBe("br"); + }); + + test("honors q=0 as a refusal", async () => { + const res = await fetchEncoded("/big.js", "br;q=0, gzip"); + expect(res.headers.get("content-encoding")).toBe("gzip"); + }); + + test("omits Content-Length, unknown until the bytes are encoded", async () => { + const res = await fetchEncoded("/big.js", "br"); + expect(res.headers.get("content-length")).toBe(null); + }); + + test("sets Vary: Accept-Encoding", async () => { + expect((await fetchEncoded("/big.js", "br")).headers.get("vary")).toBe("Accept-Encoding"); + }); + + test("prefers a variant on disk over encoding on the fly", async () => { + // `/big-gz.js` is over the floor and has a `.gz` beside it. Brotli is + // accepted and ranks first, but a variant costs no CPU, so the `.gz` wins + // — and its marker contents are what prove it was not encoded here. Both + // the variant and the file under it are over the floor, so nothing but + // precedence decides this. The lookup is opt-in, hence `encodings: true`. + const res = await fetchVariant("/big-gz.js", "br, gzip"); + expect(res.headers.get("content-encoding")).toBe("gzip"); + await expect(res.text()).resolves.toBe(BIG_GZ_MARKER); + }); + + test.each([ + ["under the size floor", "/small.js"], + ["over the size ceiling", "/huge.js"], + ])("serves a file %s as-is", async (_why, path) => { + const res = await fetchEncoded(path!, "br"); + expect(res.status).toBe(200); + expect(res.headers.get("content-encoding")).toBe(null); + // Unencoded, so the length is known and must still be declared. + expect(res.headers.get("content-length")).not.toBe(null); + }); + + test("never encodes an already-compressed type", async () => { + const res = await fetchEncoded("/big.png", "br"); + expect(res.headers.get("content-encoding")).toBe(null); + expect(res.headers.get("vary")).toBe(null); + await expect(res.text()).resolves.toBe(BIG_JS); + }); + + test("does not encode a renderHTML route", async () => { + // The rendered `Response` belongs to the caller; encoding it would mean + // rewriting a body this middleware does not own. + const res = await fetchStatic( + "/big.html", + { renderHTML: ({ html }: { html: string }) => new Response(html) }, + { headers: { "accept-encoding": "br" } }, + ); + expect(res.headers.get("content-encoding")).toBe(null); + }); + + test("serves the plain file with compress: false", async () => { + const res = await fetchEncoded("/big.js", "br", { compress: false }); + expect(res.headers.get("content-encoding")).toBe(null); + await expect(res.text()).resolves.toBe(BIG_JS); + }); + + test("encodes without probing the disk with encodings: {}", async () => { + // `/big-gz.js` has a `.gz` the lookup would have served. With no encodings + // configured there is no lookup, so the response is encoded here instead — + // which the disk variant's marker contents would give away. + const res = await fetchEncoded("/big-gz.js", "gzip", { encodings: {} }); + expect(res.headers.get("content-encoding")).toBe("gzip"); + expect(gunzipSync(Buffer.from(await res.arrayBuffer())).toString()).toBe(BIG_JS); + }); + + test("serves nothing encoded with encodings: {} and compress: false", async () => { + const res = await fetchEncoded("/big.js", "br", { encodings: {}, compress: false }); + expect(res.headers.get("content-encoding")).toBe(null); + expect(res.headers.get("vary")).toBe(null); + await expect(res.text()).resolves.toBe(BIG_JS); + }); + + test("reports HEAD headers without encoding a body", async () => { + const res = await fetchStatic( + "/big.js", + {}, + { method: "HEAD", headers: { "accept-encoding": "br" } }, + ); + // Exactly what GET would send: encoded, and chunked rather than declaring + // a length... + expect(res.headers.get("content-encoding")).toBe("br"); + expect(res.headers.get("content-length")).toBe(null); + // ...except that the bytes are never produced. + await expect(res.text()).resolves.toBe(""); + }); + }); + + describe("extension-less paths", () => { + test.each([ + ["/LICENSE", "LICENSE_BODY"], + ["/apple-app-site-association", "AASA_BODY"], + ])("serves %s at its exact name", async (path, contents) => { + const res = await fetchStatic(path!); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe(contents); + }); + + test("still resolves an extension-less route to its .html file", async () => { + const res = await fetchStatic("/about"); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toContain("about"); + }); + }); + + describe("incompressible types", () => { + test("never serves a variant for an already-compressed type", async () => { + const res = await fetchEncoded("/logo.png", "br"); + expect(res.headers.get("content-type")).toBe("image/png"); + expect(res.headers.get("content-encoding")).toBe(null); + await expect(res.text()).resolves.toBe("PNG_BYTES"); + }); + + test("omits Vary for an already-compressed type", async () => { + expect((await fetchEncoded("/logo.png", "br")).headers.get("vary")).toBe(null); + // ...but still sets it for compressible types. + expect((await fetchEncoded("/index.html", "br")).headers.get("vary")).toBe("Accept-Encoding"); + }); + }); + + describe("renderHTML", () => { + const opts = { + renderHTML: ({ html }: { html: string }) => new Response(`${html}`), + }; + + test.each(["/index.html", "/page.htm"])("renders %s", async (path) => { + // Both map to `text/html`, so both render: an extension the MIME table + // treats as HTML must not be served as raw markup. + const res = await fetchStatic(path, opts); + await expect(res.text()).resolves.toContain(""); + }); + + test("does not render a non-HTML file", async () => { + const res = await fetchStatic("/sub/inside.txt", opts); + await expect(res.text()).resolves.toBe("INSIDE"); + }); + }); + + describe("Content-Type", () => { + test.each([ + // Text carries an explicit charset... + ["/index.html", "text/html; charset=utf-8"], + ["/sub/inside.txt", "text/plain; charset=utf-8"], + ["/app.js", "text/javascript; charset=utf-8"], + // ...while non-text bytes have none to declare. + ["/logo.png", "image/png"], + ["/LICENSE", "application/octet-stream"], + // Straight from the MIME table. + ["/mod.wasm", "application/wasm"], + ["/pic.avif", "image/avif"], + ["/song.mp3", "audio/mpeg"], + ["/bundle.gz", "application/gzip"], + ])("declares %s as %s", async (path, type) => { + expect((await fetchStatic(path!)).headers.get("content-type")).toBe(type); + }); + + test("declares a charset alongside Content-Encoding", async () => { + // The charset describes the decoded bytes, so a variant keeps it. + const res = await fetchVariant("/app.js", "br"); + expect(res.headers.get("content-type")).toBe("text/javascript; charset=utf-8"); + expect(res.headers.get("content-encoding")).toBe("br"); + }); + }); + + describe("HEAD", () => { + test("returns headers with no body", async () => { + const res = await fetchStatic("/app.js", {}, { method: "HEAD" }); + expect(res.status).toBe(200); + expect(res.headers.get("content-length")).toBe(String("PLAIN_JS".length)); + expect(res.headers.get("content-type")).toBe("text/javascript; charset=utf-8"); + await expect(res.text()).resolves.toBe(""); + }); + + test("reports the variant's headers without a body", async () => { + const res = await fetchStatic( + "/app.js", + { encodings: true }, + { method: "HEAD", headers: { "accept-encoding": "br" } }, + ); + expect(res.headers.get("content-encoding")).toBe("br"); + expect(res.headers.get("content-length")).toBe(String("BROTLI_JS".length)); + await expect(res.text()).resolves.toBe(""); + }); + + test("returns no body for a renderHTML route", async () => { + const opts = { + renderHTML: ({ html }: { html: string }) => + new Response(`${html}`, { headers: { "x-rendered": "1" } }), + }; + const get = await fetchStatic("/index.html", opts); + await expect(get.text()).resolves.toContain(""); + + const head = await fetchStatic("/index.html", opts, { method: "HEAD" }); + expect(head.status).toBe(200); + expect(head.headers.get("x-rendered")).toBe("1"); + await expect(head.text()).resolves.toBe(""); + }); + + test("cancels the unused rendered body", async () => { + let cancelled = false; + const opts = { + renderHTML: () => + new Response( + new ReadableStream({ + cancel() { + cancelled = true; + }, + }), + ), + }; + const head = await fetchStatic("/index.html", opts, { method: "HEAD" }); + await expect(head.text()).resolves.toBe(""); + expect(cancelled).toBe(true); + }); + }); + + describe("percent-encoded paths", () => { + test.each([ + ["/hello%20world.txt", "SPACE_NAME"], + ["/caf%C3%A9.txt", "UNICODE_NAME"], + ["/50%25.txt", "PERCENT_NAME"], + ])("decodes %s exactly once for the lookup", async (path, contents) => { + const res = await fetchStatic(path!); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe(contents); + }); + + test.each([ + // The decoded lookup is for a file literally named `sub%2Finside.txt`. + "/sub%2Finside.txt", + // `%2f` survives `decodeURI`, so this stays a single literal filename + // rather than traversing. Reaches the middleware verbatim: `new Request()` + // only collapses real separators. + "/%2e%2e%2foutside%2fsecret.txt", + ])("keeps the encoded separator in %s encoded", async (path) => { + await expectNext(await fetchStatic(path), path); + }); + + test("applies the dotfile policy to the decoded name", async () => { + expect((await fetchStatic("/%2Eenv")).status).toBe(404); + await expect(fetchStatic("/%2Eenv", { dotfiles: true }).then((r) => r.text())).resolves.toBe( + "DOTENV", + ); + }); + + test("applies the dotfile allow-list to the decoded name", async () => { + // The allow-list is matched after decoding, so an encoded `.well-known` + // is neither denied as an unknown dot segment nor let through unchecked. + await expect(fetchStatic("/%2Ewell-known/security.txt").then((r) => r.text())).resolves.toBe( + "SECURITY_TXT", + ); + expect((await fetchStatic("/%2Ewell-known/%2Eenv")).status).toBe(404); + }); + + test("does not decode twice", async () => { + // `%252e%252e` decodes once to the harmless literal `%2e%2e`. + await expectNext(await fetchStatic("/%252e%252e/outside/secret.txt")); + }); + + test.each(["/foo%", "/%ZZ"])("rejects the malformed encoding %s with 400", async (path) => { + expect((await fetchStatic(path)).status, path).toBe(400); + }); + }); + + describe("unresolved pathname", () => { + // Every request here bypasses `new Request()` — see `rawReq`. Without that, + // the pathname arrives already collapsed and these assert nothing: they pass + // against a `serveStatic` with both containment checks deleted. + test.each([ + "/../outside/secret.txt", + "/sub/../../outside/secret.txt", + "/../../../../../../etc/passwd", + // A traversal that starts inside an allow-listed dot segment. + "/.well-known/../../outside/secret.txt", + ])("serves no traversal from a raw %s", async (path) => { + await expectNext(await fetchRaw(path), path); + }); + + test.each(["/sub/../index.html", "/./index.html"])( + "serves %s, which resolves back inside the root", + async (path) => { + // Not every dot segment escapes, and `join()` collapses these before the + // dotfile check, so they must not be read as dotfiles either. + const res = await fetchRaw(path); + expect(res.status, path).toBe(200); + await expect(res.text()).resolves.toContain("index"); + }, + ); + }); +}); diff --git a/vitest.config.mjs b/vitest.config.mjs index 65494d9..87a31f7 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -3,6 +3,13 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { typecheck: { enabled: true }, + // `src/static.ts` imports `FastResponse` from "srvx" at runtime to pick up + // the adapter matching the runtime (via the `exports` conditions). That + // self-reference resolves through `dist/`, which `pnpm test` does not + // build, so point it at the node adapter the tests run on. + alias: [ + { find: /^srvx$/, replacement: new URL("src/adapters/node.ts", import.meta.url).pathname }, + ], // Some tests rely on short real-time timers (~100ms). Under the full suite // (lint + typecheck + coverage + parallel forks) the machine can be starved // enough to stretch those past the defaults, causing load-only flakes