// Where a Pages deploy's assets actually start. Task 1917 — `wrangler pages // deploy ` treats the positional argument as the asset root and overrides // `pages_build_output_dir`, so handing it the site root publishes a declared // output dir one level too deep and the served root 404s. Measured on glsmith // 2026-07-22: two trees with identical shape and config, one deployed by house // wrangler (document at the root, 200) and one by this broker (document // reachable only under /public/, root 404). // // Only the one key is interpreted, in any of the three config formats. This is // deliberately not a wrangler config parser: everything else in the file is // wrangler's business and stays wrangler's business. import { lstat, readFile, realpath } from "node:fs/promises"; import { isAbsolute, join, relative, resolve } from "node:path"; // The filenames `wrangler pages deploy` discovers as its own configuration, in // wrangler's own precedence order (wrangler 4.112.0, wrangler-dist/cli.js:3189 // — json, then jsonc, then toml). The order is load-bearing twice: it decides // which config declares the output dir here, and Task 1782 drops all three from // the upload so a config is never served publicly at /wrangler.toml, where it // would disclose the D1 database id and R2 bucket name. export const WRANGLER_CONFIG_NAMES = [ "wrangler.json", "wrangler.jsonc", "wrangler.toml", ] as const; export type UploadDirSource = (typeof WRANGLER_CONFIG_NAMES)[number] | "site-root"; export type UploadDirResolution = { /** Absolute path to stage and hand wrangler as the asset root. */ uploadDir: string; /** Which input decided it, so the caller logs the reason and not the value alone. */ source: UploadDirSource; /** The verbatim declared value, present only when a config decided it. */ declared?: string; }; const KEY = "pages_build_output_dir"; /** Strips line comments and block comments that sit outside string literals. * JSON has no comments and jsonc does, so a jsonc file is de-commented before * JSON.parse. Scanning character-wise rather than by regex is what keeps a * `//` inside a value (a path like "a//b") from being eaten as a comment. */ function stripJsonComments(text: string): string { let out = ""; let inString = false; let escaped = false; for (let i = 0; i < text.length; i++) { const c = text[i]; if (inString) { out += c; if (escaped) escaped = false; else if (c === "\\") escaped = true; else if (c === '"') inString = false; continue; } if (c === '"') { inString = true; out += c; continue; } if (c === "/" && text[i + 1] === "/") { while (i < text.length && text[i] !== "\n") i++; out += "\n"; continue; } if (c === "/" && text[i + 1] === "*") { i += 2; while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++; i++; continue; } out += c; } return out; } /** Reads the one key from a TOML file's top level. Top level means before the * first `[section]` header: a key under `[env.preview]` is that environment's, * not the deploy's, and reading it would publish the wrong root. Nothing else * in the file is interpreted. */ function readTomlKey(text: string): string | undefined { for (const raw of text.split("\n")) { const line = raw.trim(); if (line === "" || line.startsWith("#")) continue; if (line.startsWith("[")) return undefined; const m = /^pages_build_output_dir\s*=\s*(?:"([^"]*)"|'([^']*)')\s*(?:#.*)?$/.exec(line); if (m) return m[1] ?? m[2]; } return undefined; } /** Reads the one key from a JSON or JSONC file's top level. A present key whose * value is not a string is an error rather than an ignore: the tree declared an * output dir, and publishing its parent instead is the defect this closes. */ function readJsonKey(text: string, name: string, jsonc: boolean): string | undefined { let parsed: unknown; try { parsed = JSON.parse(jsonc ? stripJsonComments(text) : text); } catch (err) { throw new Error(`storage-broker: ${name} could not be parsed: ${String(err)}`); } if (typeof parsed !== "object" || parsed === null) { throw new Error(`storage-broker: ${name} is not an object`); } const value = (parsed as Record)[KEY]; if (value === undefined) return undefined; if (typeof value !== "string") { throw new Error(`storage-broker: ${name} declares a non-string ${KEY}`); } return value; } /** True when `child` is not `root` or a descendant of it. */ function escapes(root: string, child: string): boolean { const rel = relative(root, child); return rel.startsWith("..") || isAbsolute(rel); } function outsideError(name: string, declared: string, resolved: string, root: string): Error { return new Error( `storage-broker: ${name} declares ${KEY}="${declared}", which resolves to ${resolved}, outside the site root ${root}`, ); } /** Resolves the directory whose contents become the served root for `dir`. * * A tree that declares nothing keeps the site root, which is what every deploy * did before Task 1917. A tree that declares an output dir gets that dir. A * tree that declares one which cannot be resolved is an error naming the path: * falling back to the site root here is exactly the defect this closes, because * the deploy then succeeds while serving the wrong root. */ export async function resolveUploadDir(dir: string): Promise { for (const name of WRANGLER_CONFIG_NAMES) { let text: string; try { text = await readFile(join(dir, name), "utf8"); } catch { continue; // absent, or not readable as a file: the next name is wrangler's next choice } const declared = name === "wrangler.toml" ? readTomlKey(text) : readJsonKey(text, name, name === "wrangler.jsonc"); // A config that declares nothing leaves the site root as the asset root, // which is wrangler's own behaviour for a Pages project with no output dir. if (declared === undefined) return { uploadDir: dir, source: "site-root" }; const uploadDir = resolve(dir, declared); // Confinement, lexically first. A declared value is file content, and // resolving file content into a publish path through the route that holds // the account-wide Cloudflare credential is the moment it has to be // bounded. This pass costs no filesystem call and rejects the plain // `"../.."` before anything is touched. if (escapes(dir, uploadDir)) throw outsideError(name, declared, uploadDir, dir); // lstat, not stat: the link itself is what matters here, and stat would // report a link to a directory as a directory. let info; try { info = await lstat(uploadDir); } catch { throw new Error( `storage-broker: ${name} declares ${KEY}="${declared}" but ${uploadDir} does not exist`, ); } // A link is refused wherever it points. The staging copy takes the upload // directory as its source and does not dereference, so a link source fails // the copy outright; and a target that can change between this check and // that copy is not something to publish from a route holding the // account-wide credential. if (info.isSymbolicLink()) { throw new Error( `storage-broker: ${name} declares ${KEY}="${declared}" but ${uploadDir} is a symbolic link; the published root must be a real directory`, ); } if (!info.isDirectory()) { throw new Error( `storage-broker: ${name} declares ${KEY}="${declared}" but ${uploadDir} is not a directory`, ); } // Confinement again, on the real paths. The lexical pass above is blind to // symlinks in the declared value's own parent segments: a declared "a/b" // whose `a` links out of the tree stays inside by string comparison while // resolving anywhere on disk, and the deploy would then publish whatever it // resolves to. Both sides are resolved because the site root itself commonly // arrives through a symlinked prefix (macOS /var, a linked account // directory), which would otherwise read as an escape on its own. const realDir = await realpath(dir); const realUpload = await realpath(uploadDir); if (escapes(realDir, realUpload)) throw outsideError(name, declared, realUpload, realDir); return { uploadDir, source: name, declared }; } return { uploadDir: dir, source: "site-root" }; }