// 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" };
}