diff --git a/docs/1.guide/10.cli.md b/docs/1.guide/10.cli.md index 47737e7..c0ba703 100644 --- a/docs/1.guide/10.cli.md +++ b/docs/1.guide/10.cli.md @@ -43,6 +43,8 @@ $ srvx serve --prod # Start production server $ srvx serve --port=8080 # Listen on port 8080 $ srvx serve --host=localhost # Bind to localhost only $ srvx serve --static=./dist # Serve static files (no entry needed) +$ srvx serve --static=./dist --no-dir-listing # ...without the dev directory listing +$ srvx serve --prod --dir-listing # Enable the directory listing in production $ srvx serve --import=jiti/register # Enable [jiti](https://github.com/unjs/jiti) loader $ srvx serve --tls --cert=cert.pem --key=key.pem # Enable TLS (HTTPS/HTTP2) @@ -70,6 +72,8 @@ SERVE OPTIONS -p, --port Port to listen on (default: 3000) --host, --hostname Host to bind to (default: all interfaces) -s, --static Serve static files from the specified directory (default: public) + --dir-listing Serve a directory listing for index-less directories (default: on in dev, off with --prod) + --no-dir-listing Disable the directory listing (e.g. in dev) --prod Run in production mode (no watch, no debug) --import ES module to preload --tls Enable TLS (HTTPS/HTTP2) @@ -128,6 +132,13 @@ When both a server entry and a static directory are present, static files take p Static serving includes automatic `index.html` resolution, `.html` extension fallback (e.g. `/about` → `about.html`), common MIME types, gzip/Brotli compression, and path-traversal protection. +In dev mode, a request for a directory with no index file returns a generated HTML listing of its contents, so you can browse a folder without an `index.html`. The listing is a last-resort fallback: static files are tried first, then your server handler runs, and only a `404` response falls back to the listing — a real route (or a custom 404 page for a non-directory path) always wins. This is a dev convenience only: it is disabled under `--prod`, so the directory structure is never exposed in production. Override the default either way with `--dir-listing` (force it on, e.g. in production) or `--no-dir-listing` (force it off in dev). It maps to the [`dirListing`](/guide/middleware#static-files) option of `serveStatic()`. + +```bash +npx srvx --static ./dist --prod --dir-listing # opt in under --prod +npx srvx --static ./dist --no-dir-listing # opt out in dev +``` + ## Programmatic API Both CLI modes are built on `srvx/loader`. The same loader is available to you, so you can build a dev server, a test harness, or a framework CLI that accepts any server entry srvx accepts — without reimplementing entry discovery or handler detection. diff --git a/docs/1.guide/4.middleware.md b/docs/1.guide/4.middleware.md index 07f2529..b4b67f9 100644 --- a/docs/1.guide/4.middleware.md +++ b/docs/1.guide/4.middleware.md @@ -116,10 +116,13 @@ When no file matches the request, it calls `next()` — so your handler acts as - `etag`: Emit a weak `ETag` validator, and answer a matching `If-None-Match` request with `304 Not Modified` (default `true`). - `maxAge`: Freshness lifetime in **seconds**, emitted as `Cache-Control: max-age=` (default `undefined`, no header). Lets a client reuse a response without a request until it goes stale. - `immutable`: Add the `immutable` directive to `Cache-Control`, so a client does not revalidate a still-fresh response even on reload (default `false`). Only takes effect alongside `maxAge`, and only makes sense for fingerprinted (content-hashed) assets. +- `dirListing`: Serve a minimal HTML directory listing as a 404 fallback for a directory with no index file (default `false`). The rest of the app answers first; only a 404 is replaced by the listing. Off by default because it exposes the directory structure — it is opt-in. The [CLI](/guide/cli#serving-static-files) turns it on in dev mode by default. - `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`. +With `dirListing: true`, a request naming a directory with no index file — the root, a trailing-slash path, or an extension-less one — still falls through to `next()` first, and a generated HTML listing replaces the response only when it comes back `404`. A real route always wins over a listing, and a custom 404 page keeps working: it is replaced only when the path actually names a listable directory, and passes through untouched everywhere else. An index always wins, so a directory with an `index.html` still serves it. Entries are the directory's immediate children, sorted directories-first; a denied dot segment (`.env`, `.git`) is hidden from the listing exactly as it is from a direct request, so a listing never names anything the middleware would refuse to serve. Links are absolute paths, so they resolve the same whether the directory was requested with a trailing slash or without, and the page follows the OS light/dark theme. The listing carries `X-Robots-Tag: noindex, nofollow` (mirrored by a `robots` meta tag) and a strict `Content-Security-Policy` — it is a self-contained page with no scripts or external resources — plus `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, and `Cache-Control: no-store` so a browser never shows a stale listing after files change. It is off by default because it reveals the directory structure; the [CLI](/guide/cli#serving-static-files) enables it in dev mode by default. + 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. diff --git a/src/cli/main.ts b/src/cli/main.ts index 62ad044..b25d953 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -113,6 +113,10 @@ function parseArgs(args: string[]): CLIOptions { prod: { type: "boolean" }, port: { type: "string", short: "p" }, static: { type: "string", short: "s" }, + // `parseArgs` has no native `--no-*` negation, so the opt-out is its own + // flag; the two collapse into a tri-state `dirListing` below. + "dir-listing": { type: "boolean" }, + "no-dir-listing": { type: "boolean" }, import: { type: "string" }, cert: { type: "string" }, key: { type: "string" }, @@ -142,6 +146,10 @@ function parseArgs(args: string[]): CLIOptions { return { mode, ...values, url, method }; } + // Collapse the two listing flags into a tri-state: explicit on/off, or + // `undefined` to leave the dev/prod default to `cliServe`. + const dirListing = values["dir-listing"] ? true : values["no-dir-listing"] ? false : undefined; + // Serve mode: allow entry or dir as a positional argument const maybeEntryOrDir = positionals[0]; if (maybeEntryOrDir) { @@ -159,7 +167,7 @@ function parseArgs(args: string[]): CLIOptions { } } - return { mode, ...values }; + return { mode, ...values, dirListing }; } async function startServer(cliOpts: CLIOptions) { diff --git a/src/cli/serve.ts b/src/cli/serve.ts index d28fb8e..0379a37 100644 --- a/src/cli/serve.ts +++ b/src/cli/serve.ts @@ -81,17 +81,23 @@ export async function cliServe(cliOpts: CLIOptions): Promise { }, fetch: loaded.fetch || - (() => - renderError( - cliOpts, - loaded.notFound ? "Server Entry Not Found" : "No Fetch Handler Exported", - 501, - )), + (loaded.notFound + ? // Static-only mode (no entry): an unmatched request is an ordinary + // 404, not a server misconfiguration — and 404 is what the static + // middleware's `dirListing` fallback keys on. + () => new Response("Not Found", { status: 404 }) + : () => renderError(cliOpts, "No Fetch Handler Exported", 501)), middleware: [ log(), cliOpts.static ? serveStatic({ dir: cliOpts.static, + // Dev convenience: browse directories without an index. A 404 + // fallback — static files win first, then the user handler runs, + // and only a 404 falls back to the listing. Off in prod so the + // structure is never exposed by default, unless the explicit + // `--dir-listing` / `--no-dir-listing` flag overrides either way. + dirListing: cliOpts.dirListing ?? !cliOpts.prod, }) : undefined, ...(serverOptions.middleware || []), diff --git a/src/cli/types.ts b/src/cli/types.ts index 0bcb866..30b336e 100644 --- a/src/cli/types.ts +++ b/src/cli/types.ts @@ -37,6 +37,12 @@ export type CLIOptions = { prod?: boolean; /** Serve static files from the specified directory (default: "public") */ static?: string; + /** + * Serve an HTML directory listing for directories without an index file. + * Defaults to on in dev mode and off with `--prod`; set explicitly to override + * either way (`--dir-listing` / `--no-dir-listing`). + */ + dirListing?: boolean; /** ES module to preload */ import?: string; /** Host to bind to (default: all interfaces) */ diff --git a/src/cli/usage.ts b/src/cli/usage.ts index 1c52ebc..a3a18a5 100644 --- a/src/cli/usage.ts +++ b/src/cli/usage.ts @@ -20,6 +20,8 @@ ${c.gray("$")} ${c.cyan(command)} serve --prod ${c.gray("# Start ${c.gray("$")} ${c.cyan(command)} serve --port=8080 ${c.gray("# Listen on port 8080")} ${c.gray("$")} ${c.cyan(command)} serve --host=localhost ${c.gray("# Bind to localhost only")} ${c.gray("$")} ${c.cyan(command)} serve --static=./dist ${c.gray("# Serve static files (no entry needed)")} +${c.gray("$")} ${c.cyan(command)} serve --static=./dist --no-dir-listing ${c.gray("# ...without the dev directory listing")} +${c.gray("$")} ${c.cyan(command)} serve --prod --dir-listing ${c.gray("# Enable the directory listing in production")} ${c.gray("$")} ${c.cyan(command)} serve --import=jiti/register ${c.gray(`# Enable ${c.url("jiti", "https://github.com/unjs/jiti")} loader`)} ${c.gray("$")} ${c.cyan(command)} serve --tls --cert=cert.pem --key=key.pem ${c.gray("# Enable TLS (HTTPS/HTTP2)")} @@ -47,6 +49,8 @@ ${c.bold("SERVE OPTIONS")} ${c.green("-p, --port")} ${c.yellow("")} Port to listen on (default: ${c.yellow("3000")}) ${c.green("--host, --hostname")} ${c.yellow("")} Host to bind to (default: all interfaces) ${c.green("-s, --static")} ${c.yellow("")} Serve static files from the specified directory (default: ${c.yellow("public")}) + ${c.green("--dir-listing")} Serve a directory listing for index-less directories (default: on in dev, off with ${c.green("--prod")}) + ${c.green("--no-dir-listing")} Disable the directory listing (e.g. in dev) ${c.green("--prod")} Run in production mode (no watch, no debug) ${c.green("--import")} ${c.yellow("")} ES module to preload ${c.green("--tls")} Enable TLS (HTTPS/HTTP2) diff --git a/src/static.ts b/src/static.ts index c3c4bc2..cecdc67 100644 --- a/src/static.ts +++ b/src/static.ts @@ -5,7 +5,7 @@ import type { Transform } from "node:stream"; import { extname, join, resolve, sep } from "node:path"; import { constants } from "node:fs"; -import { open, realpath, stat } from "node:fs/promises"; +import { open, readdir, realpath, stat } from "node:fs/promises"; import { pipeline } from "node:stream"; import { constants as zlibConstants, createBrotliCompress, createGzip } from "node:zlib"; import { FastResponse } from "srvx"; @@ -112,6 +112,24 @@ export interface ServeStaticOptions { */ ranges?: boolean; + /** + * Serve a minimal HTML directory listing as a 404 fallback: when a request + * names a directory with no index file (`index.html`), the rest of the app + * answers first via `next()`, and only a 404 response is replaced by the + * listing. A real route always wins over a listing, and a directory with an + * index always serves the index. + * + * Entries are the directory's immediate children, with denied dot segments + * (see `dotfiles`) hidden just as they are for file requests. Only names are + * revealed, never file contents. + * + * Off by default — it exposes the directory structure, so it is opt-in. The + * `srvx` CLI turns it on in dev mode by default. + * + * @default false + */ + dirListing?: boolean; + /** * A function to modify the HTML content before serving it. */ @@ -236,6 +254,7 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { const lastModified = options.lastModified ?? true; const etag = options.etag ?? true; const ranges = options.ranges ?? true; + const dirListing = options.dirListing ?? false; // Depends only on the options, so it is built once. Empty when `maxAge` is // unset — the header is fully opt-in. @@ -314,6 +333,70 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { return null; }; + // The immediate children of a directory safe to list, or null when the path is + // not a listable directory. Re-asserts the same boundaries a file request gets: + // lexical containment, then symlink-resolved containment (a link inside `dir` + // could point the directory out of the root), and the dot-path deny check on + // both the requested path and each entry — so a listing never names what a + // direct request would refuse to serve. + const readListing = async (relPath: string): Promise<{ name: string; dir: boolean }[] | null> => { + const dirPath = relPath === "" ? dir : join(dir, relPath); + if ( + relPath !== "" && + (!dirPath.startsWith(dir) || isDeniedDotPath(dirPath.slice(dir.length))) + ) { + return null; + } + const realPath = await realpath(dirPath).catch(() => null); + if (realPath === null) { + return null; + } + const root = await getRealDir(); + const realWithSep = asPrefix(realPath); + if (!realWithSep.startsWith(root) || isDeniedDotPath(realWithSep.slice(root.length))) { + return null; + } + const dirents = await readdir(realPath, { withFileTypes: true }).catch(() => null); + if (dirents === null) { + return null; + } + const entries: { name: string; dir: boolean }[] = []; + for (const d of dirents) { + if (isDeniedDotPath(d.name)) { + continue; + } + // A non-symlink child is contained by construction, and `withFileTypes` + // already typed it from the `readdir` syscall — no extra work. + if (!d.isSymbolicLink()) { + entries.push({ name: d.name, dir: d.isDirectory() }); + continue; + } + // A symlink is re-checked exactly as a direct request would be: its + // canonical target must stay under the root and off a denied dot path, or + // it is dropped rather than named. It is then classified by that target — + // so a link to a directory lists as one — never by the link itself, whose + // `isDirectory()` is always false. + const target = await realpath(join(realPath, d.name)).catch(() => null); + if (target === null) { + continue; + } + // `asPrefix` like the directory check above, or a link whose target is + // exactly the root (`self -> .`) fails the prefix test on the missing + // trailing separator and vanishes from a listing while still serving. + const targetWithSep = asPrefix(target); + if (!targetWithSep.startsWith(root) || isDeniedDotPath(targetWithSep.slice(root.length))) { + continue; + } + const targetStat = await stat(target).catch(() => null); + if (targetStat) { + entries.push({ name: d.name, dir: targetStat.isDirectory() }); + } + } + // Directories first, then by name — a conventional, stable listing order. + entries.sort((a, b) => (a.dir === b.dir ? (a.name < b.name ? -1 : 1) : a.dir ? -1 : 1)); + return entries; + }; + return async (req, next) => { if (!methods.has(req.method)) { return next(); @@ -628,12 +711,139 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { pipeline(stream, encoded, () => {}); return new FastResponse(encoded as any, { headers }); } - return next(); + // No file matched: the rest of the app answers first. With `dirListing` on, + // a request naming a directory (root, a trailing-slash path, or an + // extension-less one) falls back to a generated listing only when downstream + // returned 404 — so a real route always beats a listing, and an index beats + // both (its `index.html` candidate was probed in the loop above). Any other + // downstream response — including a custom 404 page for a path that is not + // a listable directory — passes through untouched. + const response = await next(); + if ( + dirListing && + response.status === 404 && + (path === "" || trailingSlash || extname(path) === "") + ) { + const entries = await readListing(path); + if (entries) { + // Exactly one trailing slash: entry and parent links are absolute paths + // built off this base, so a request served without a trailing slash + // (`/docs/api`) still yields correct links. + const base = url.pathname.replace(/\/+$/, "") + "/"; + const headers = { + "Content-Type": "text/html; charset=utf-8", + // A generated listing should never be indexed; mirrors the `robots` + // meta tag for crawlers that only read headers. + "X-Robots-Tag": "noindex, nofollow", + // Defense-in-depth for a page that interpolates filenames. The listing + // is fully self-contained — inline CSS, no scripts, images, or fonts — + // so it is pinned to exactly that: even a hypothetical escaping slip + // could neither run a script nor reach an external origin. `base-uri` + // and `frame-ancestors` close off `` injection and clickjacking. + "Content-Security-Policy": + "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'", + // Never let the HTML be MIME-sniffed into another type. + "X-Content-Type-Options": "nosniff", + // The URL reveals directory structure; keep it out of the `Referer` + // on any outbound navigation. + "Referrer-Policy": "no-referrer", + // The listing mirrors live directory state — without this, heuristic + // caching could keep showing a stale listing after files change. + "Cache-Control": "no-store", + }; + // The listing replaces the downstream 404; drop that response's unread + // body so a streamed one is not left dangling. + response.body?.cancel().catch(() => {}); + // HEAD mirrors GET's headers without the body. + const body = req.method === "HEAD" ? null : renderDirListing(base, path, entries); + return new FastResponse(body, { headers }); + } + } + return response; }; }; // --- internal --- +const HTML_ESCAPES: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +}; + +// Escape text before interpolating it into the listing HTML — a filename is +// attacker-controllable and lands in both element text and an `href` attribute. +function escapeHtml(str: string): string { + return str.replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch]!); +} + +// A minimal directory listing. `base` is the request pathname with a guaranteed +// trailing slash, so every entry href is an absolute path that resolves the same +// whether the directory was requested with a trailing slash or without. +// `displayPath` is the decoded relative path, shown in the heading. +function renderDirListing( + base: string, + displayPath: string, + entries: { name: string; dir: boolean }[], +): string { + const heading = escapeHtml("/" + (displayPath ? displayPath + "/" : "")); + const items: string[] = []; + // A parent link everywhere but the root, shown as a folder since it navigates + // up to one. Absolute like the entry links: a relative `../` would resolve + // against the browser's document URL, which drops a segment for an + // extension-less request served without a trailing slash. Deriving the parent + // from `base` (`/docs/api/` → `/docs/`) is correct either way. + if (displayPath !== "") { + const parent = base.slice(0, base.slice(0, -1).lastIndexOf("/") + 1); + items.push(row(parent, "../", true)); + } + for (const entry of entries) { + const suffix = entry.dir ? "/" : ""; + // `encodeURIComponent` before `escapeHtml`: the first makes the name a safe + // URL path segment (a literal `/` in a name is encoded, never a separator), + // the second makes that URL safe inside the attribute. The trailing `/` for + // a directory is appended after encoding so it stays a real separator. + const href = base + encodeURIComponent(entry.name) + suffix; + items.push(row(href, entry.name + suffix, entry.dir)); + } + return ( + `` + + `` + + // A generated listing is not content to index; the `X-Robots-Tag` header + // set alongside covers crawlers that skip the markup. + `` + + `Index of ${heading}` + + `` + + `

Index of ${heading}

    ${items.join("")}
` + ); +} + +// One listing row. `href` is a raw (unescaped) URL and `label` raw text; both +// are escaped here before landing in the attribute and element text. The icon +// is a fixed emoji, so it needs none. +function row(href: string, label: string, dir: boolean): string { + const icon = dir ? "📁" : "📄"; + return `
  • ${icon}${escapeHtml(label)}
  • `; +} + // The `Cache-Control` value, or "" when `maxAge` is unset so the header is // omitted entirely. `max-age` takes non-negative integer seconds, so a // fractional or negative `maxAge` is floored and clamped, non-finite values fall diff --git a/test/cli.test.ts b/test/cli.test.ts index 6c06f1c..e66b2ee 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from "vitest"; import { fileURLToPath } from "node:url"; -import { resolve } from "node:path"; +import { join, resolve } from "node:path"; import { createServer } from "node:http"; import { mkdtemp, writeFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -38,6 +38,9 @@ describe("cli", () => { expect(exitCode, `${flag} should exit 0`).toBe(0); expect(stdout).toContain("SERVE MODE"); expect(stdout).toContain("FETCH MODE"); + // The directory-listing opt-in/opt-out flags are documented. + expect(stdout).toContain("--dir-listing"); + expect(stdout).toContain("--no-dir-listing"); } }); @@ -146,6 +149,30 @@ describe("cli", () => { } }); + it("--dir-listing opts the directory listing into prod", async () => { + // Listing is dev-only by default; the explicit flag turns it on in prod. + const dir = await mkdtemp(join(tmpdir(), "srvx-cli-list-")); + await writeFile(join(dir, "hello.txt"), "hi"); + const port = await getRandomPort("localhost"); + const child = runCli(["--prod", "--static", dir, "--dir-listing", "--port", String(port)]); + try { + await waitForPort(port, { host: "localhost", delay: 50, retries: 100 }); + const res = await fetch(`http://localhost:${port}/`); + expect(res.status).toBe(200); + expect(res.headers.get("x-robots-tag")).toBe("noindex, nofollow"); + expect(await res.text()).toContain("hello.txt"); + // Static-only mode answers a miss with an ordinary 404 (the status the + // listing fallback keys on), not a 501 error page. + const miss = await fetch(`http://localhost:${port}/missing.txt`); + expect(miss.status).toBe(404); + await miss.arrayBuffer(); + } finally { + child.kill("SIGTERM"); + await child.catch(() => {}); + await rm(dir, { recursive: true, force: true }); + } + }); + it("F42: `--tls` without cert/key errors instead of downgrading to http", async () => { const port = await getRandomPort("localhost"); const { stderr, exitCode } = await runCli([ diff --git a/test/static.test.ts b/test/static.test.ts index 4f93dd7..af3b2a3 100644 --- a/test/static.test.ts +++ b/test/static.test.ts @@ -144,6 +144,29 @@ beforeAll(async () => { await writeFile(join(dir, "alias-variant.js"), "PLAIN_ALIAS_VARIANT"); await symlink(join(dir, ".env"), join(dir, "alias-variant.js.br")); + // A directory with no index, for directory-listing tests. Holds a nested + // directory, plain files, and a dotfile that a listing must hide. + await mkdir(join(dir, "files", "nested"), { recursive: true }); + await writeFile(join(dir, "files", "a.txt"), "A"); + await writeFile(join(dir, "files", "b.txt"), "B"); + await writeFile(join(dir, "files", ".hidden"), "HIDDEN"); + + // Symlink entries a listing must resolve like a direct request: a contained + // link to a file, a contained link to a directory (classified by its target), + // one escaping the root, one aliasing a denied dot path, and one whose target + // is exactly the served root (the containment edge case: root minus its + // trailing separator must still count as contained). + await symlink(join(dir, "files", "a.txt"), join(dir, "files", "good-link.txt")); + await symlink(join(dir, "files", "nested"), join(dir, "files", "dir-link")); + await symlink(join(tmp, "outside", "secret.txt"), join(dir, "files", "escape-link.txt")); + await symlink(join(dir, ".env"), join(dir, "files", "dot-alias.txt")); + await symlink(dir, join(dir, "files", "root-link")); + + // A denied dot directory: a listing request for it must fall through, like + // any direct request under it. + await mkdir(join(dir, ".secret-dir")); + await writeFile(join(dir, ".secret-dir", "inner.txt"), "SECRET"); + // A root that is itself a symlink must keep working. linkedDir = join(tmp, "public-link"); await symlink(dir, linkedDir); @@ -287,6 +310,143 @@ describe("serveStatic", () => { ); }); + describe("directory listing (dirListing)", () => { + test("off by default: a directory without an index falls through", async () => { + await expectNext(await fetchStatic("/files/")); + }); + + test("a handled route wins: the listing only replaces a downstream 404", async () => { + // The listing is a 404 fallback — `next()` runs first, and a real + // response for the same path is returned untouched. + const middleware = serveStatic({ dir, dirListing: true }); + const res = await track( + middleware(req("/files/"), () => new Response("app route")) as Promise, + ); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe("app route"); + }); + + test("a custom 404 page passes through when the path is not a listable directory", async () => { + const middleware = serveStatic({ dir, dirListing: true }); + const res = await track( + middleware( + req("/nope/"), + () => new Response("custom 404", { status: 404 }), + ) as Promise, + ); + expect(res.status).toBe(404); + await expect(res.text()).resolves.toBe("custom 404"); + }); + + test("lists a directory without an index when enabled", async () => { + const res = await fetchStatic("/files/", { dirListing: true }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8"); + const html = await res.text(); + expect(html).toContain("Index of /files/"); + expect(html).toContain('href="/files/a.txt"'); + expect(html).toContain('href="/files/b.txt"'); + // A nested directory is linked with a trailing slash. + expect(html).toContain('href="/files/nested/"'); + // The parent link is absolute — one segment up from `/files/`. + expect(html).toContain('href="/"'); + }); + + test("follows the OS dark theme", async () => { + const html = await (await fetchStatic("/files/", { dirListing: true })).text(); + expect(html).toContain("prefers-color-scheme:dark"); + }); + + test("marks the listing noindex (meta tag and header)", async () => { + const res = await fetchStatic("/files/", { dirListing: true }); + expect(res.headers.get("x-robots-tag")).toBe("noindex, nofollow"); + await expect(res.text()).resolves.toContain('name="robots" content="noindex, nofollow"'); + }); + + test("locks the listing down with security headers", async () => { + const res = await fetchStatic("/files/", { dirListing: true }); + // A self-contained page: no scripts, no external origins. + expect(res.headers.get("content-security-policy")).toBe( + "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'", + ); + expect(res.headers.get("x-content-type-options")).toBe("nosniff"); + expect(res.headers.get("referrer-policy")).toBe("no-referrer"); + // Live directory state: never cached, so a listing is never stale. + expect(res.headers.get("cache-control")).toBe("no-store"); + }); + + test("hides denied dot segments from the listing", async () => { + const html = await (await fetchStatic("/files/", { dirListing: true })).text(); + expect(html).not.toContain(".hidden"); + }); + + test("resolves symlink entries by their target and hides unservable ones", async () => { + const html = await (await fetchStatic("/files/", { dirListing: true })).text(); + // A contained symlink to a file is listed as a file. + expect(html).toContain('href="/files/good-link.txt"'); + // A contained symlink to a directory is classified by its target — listed + // as a directory (trailing slash), not as the (non-directory) link. + expect(html).toContain('href="/files/dir-link/"'); + // A symlink escaping the root, or aliasing a denied dot path, is hidden — + // just as a direct request for it is refused. + expect(html).not.toContain("escape-link"); + expect(html).not.toContain("dot-alias"); + // A link to the served root itself is contained (a request through it + // serves), so it is listed — as a directory. + expect(html).toContain('href="/files/root-link/"'); + }); + + test("a denied dot directory is not listable", async () => { + // The request-path deny: `/.secret-dir/` must fall through with the + // downstream 404 intact, exactly like a direct request under it. + await expectNext(await fetchStatic("/.secret-dir/", { dirListing: true })); + }); + + test("parent link is absolute for an extension-less nested directory", async () => { + // `nested/` has no index; requested without a trailing slash, `../` must + // point to `/files/`, not `/`. + const html = await (await fetchStatic("/files/nested", { dirListing: true })).text(); + expect(html).toContain('href="/files/"'); + }); + + test("an index always wins over a listing", async () => { + // `/sub/` has an index.html, so it serves that rather than a listing. + const res = await fetchStatic("/sub/", { dirListing: true }); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toContain("sub index"); + }); + + test("root serves its index rather than a listing", async () => { + // Root has an index.html, so it is served even with listing enabled. + const html = await (await fetchStatic("/", { dirListing: true })).text(); + expect(html).toContain("

    index

    "); + }); + + test("serves a listing for an extension-less directory route", async () => { + // No trailing slash: hrefs are still absolute, so relative links resolve + // the same as under `/files/`. + const res = await fetchStatic("/files", { dirListing: true }); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain('href="/files/a.txt"'); + }); + + test("HEAD returns the listing headers without a body", async () => { + const res = await fetchStatic("/files/", { dirListing: true }, { method: "HEAD" }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8"); + await expect(res.text()).resolves.toBe(""); + }); + + test("does not list through a symlinked directory escaping the root", async () => { + await expectNext(await fetchStatic("/escape-dir/", { dirListing: true })); + }); + + test("a missing directory still falls through", async () => { + await expectNext(await fetchStatic("/nope/", { dirListing: true })); + }); + }); + describe("symlinks", () => { test("does not serve a symlink escaping the root", async () => { await expectNext(await fetchStatic("/escape.txt"));