From c5f9c1ad7a2ccfbf19ccdc1c711731319936b881 Mon Sep 17 00:00:00 2001 From: SUaDtL Date: Sat, 1 Aug 2026 11:48:56 -0400 Subject: [PATCH] fix(site): make an unusable base path impossible, not merely documented MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The base was guarded by a comment saying it must never be "/". A comment stops nobody, and "/" is the obvious thing to reach for when moving a site to a domain root. The failure it prevents is worse than a broken link. rehypeBaseLinks prefixes every root-absolute href/src with the base, so a base of "/" rewrites "/diagrams/x.svg" into "//diagrams/x.svg" — a protocol-relative URL that resolves against a DIFFERENT HOST. Nothing 404s: the build succeeds and the link audit passes, because the target still parses as a valid reference. The page just quietly fetches from somewhere else. base.mjs now validates at import time and throws. Verified by mutation — setting the base to "/" fails the Astro build, the link audit, and the test suite, because every one of them imports the module. There is no path that ships it. The invariant is "" or a root-absolute path with no trailing slash, so the same guard also catches "/codeArbiter/" and "codeArbiter". test/base.test.ts pins the rejected forms and, rather than describing the hazard in prose, asserts the corruption directly: prefixing under a base of "/" produces a value starting with "//". 511/511 vitest, tsc clean, build green, 20,057 links resolve. --- site/base.mjs | 68 ++++++++++++++++++++++++++++-------- site/test/base.test.ts | 79 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 14 deletions(-) create mode 100644 site/test/base.test.ts diff --git a/site/base.mjs b/site/base.mjs index a7ca4ed8..8b0e29d0 100644 --- a/site/base.mjs +++ b/site/base.mjs @@ -5,20 +5,60 @@ * base-dependent output. It lives in its own module so none of those has to * import the full Astro config just to learn one string. * - * This file exists because the literal was previously copied into four places - * — astro.config.mjs, link-audit/lib.ts, and three test files — each with a - * comment saying it had to match the others. Moving the site to the - * codearbiter.dev apex domain desynced all four at once: four test failures and - * 19,940 link-audit failures from a one-line change. - * - * MUST be "" for an apex domain, never "/". rehypeBaseLinks prefixes every - * root-absolute href/src with this value, so "/" would rewrite - * "/diagrams/x.svg" into "//diagrams/x.svg" — a protocol-relative URL that - * resolves against a different host and fails silently rather than 404-ing. - * Empty string makes the prefixing a correct no-op. Consumers that need a real - * path (Astro's own `base`) use `BASE || "/"`. + * This file exists because the literal was previously copied into six places — + * astro.config.mjs, link-audit/lib.ts, and four test files — each with a comment + * saying it had to match the others. Moving the site to the codearbiter.dev apex + * domain desynced all six at once: four test failures and 19,940 link-audit + * failures from a one-line change. * * For a project subpath the value is "/" with no trailing slash, e.g. - * "/codeArbiter" when served from arbiterforge.github.io/codeArbiter/. + * "/codeArbiter" when served from arbiterforge.github.io/codeArbiter/. For an + * apex domain it is the empty string. + */ + +/** The configured value, before validation. */ +const RAW_BASE = ""; + +/** Reject any base that would corrupt links, loudly and at import time. + * + * The dangerous value is "/". rehypeBaseLinks prefixes every root-absolute + * href/src with the base, so a base of "/" rewrites "/diagrams/x.svg" into + * "//diagrams/x.svg" — a protocol-relative URL that resolves against a + * DIFFERENT HOST. It does not 404. The build succeeds, the link audit passes + * (the target still parses), and the page quietly fetches from somewhere else. + * That failure mode is why this is a thrown error and not a comment: prose + * cannot stop anyone, and "/" is the obvious thing to reach for when moving a + * site to a domain root. + * + * Exported for the tests; callers should import BASE. + * + * @param {string} value + * @returns {string} the validated base */ -export const BASE = ""; +export function validateBase(value) { + if (typeof value !== "string") { + throw new TypeError(`site base must be a string, got ${typeof value}`); + } + // The apex-domain form. Makes rehypeBaseLinks a correct no-op, because a + // root-absolute target already starts with `${BASE}/`. + if (value === "") return value; + if (!value.startsWith("/")) { + throw new Error( + `site base ${JSON.stringify(value)} must be "" or start with "/" ` + + `(a base is a root-absolute path, e.g. "/codeArbiter")`, + ); + } + // Catches "/" as well as "/codeArbiter/". A trailing slash doubles the + // separator when the base is prefixed onto a root-absolute target. + if (value.endsWith("/")) { + const hint = + value === "/" + ? ` Use "" for a domain root: a base of "/" turns "/diagrams/x.svg" into ` + + `"//diagrams/x.svg", a protocol-relative URL pointing at another host.` + : ` Drop the trailing slash.`; + throw new Error(`site base ${JSON.stringify(value)} must not end with "/".${hint}`); + } + return value; +} + +export const BASE = validateBase(RAW_BASE); diff --git a/site/test/base.test.ts b/site/test/base.test.ts new file mode 100644 index 00000000..6bf54fbd --- /dev/null +++ b/site/test/base.test.ts @@ -0,0 +1,79 @@ +/** base.test.ts — the site base path is the one value that silently corrupts + * every internal link when it is wrong, so its invariants are gated rather than + * documented. + * + * The motivating failure: a base of "/" makes rehypeBaseLinks rewrite + * "/diagrams/x.svg" into "//diagrams/x.svg", a protocol-relative URL that + * resolves against a DIFFERENT HOST. Nothing 404s. The build succeeds and the + * link audit passes, because the target still parses as a valid reference — the + * page just quietly fetches from somewhere else. A comment saying "never use + * '/'" stops nobody; "/" is the obvious thing to reach for when moving a site to + * a domain root. + */ +import { describe, it, expect } from "vitest"; +// @ts-expect-error -- untyped .mjs module +import { BASE, validateBase } from "../base.mjs"; + +/** The link-prefixing rule from scripts/rehype-base-links.ts, reproduced so this + * file can demonstrate the corruption a bad base causes without depending on the + * plugin's internals. */ +function prefix(value: string, base: string): string { + if (value === "" || value.startsWith("#") || value.startsWith("//") || !value.startsWith("/")) { + return value; + } + if (value === base || value.startsWith(`${base}/`)) return value; + return `${base}${value}`; +} + +describe("validateBase", () => { + it("accepts the apex-domain form", () => { + expect(validateBase("")).toBe(""); + }); + + it("accepts a project subpath", () => { + expect(validateBase("/codeArbiter")).toBe("/codeArbiter"); + }); + + it("rejects '/' — the value that produces protocol-relative URLs", () => { + expect(() => validateBase("/")).toThrow(/protocol-relative/); + }); + + it("rejects a trailing slash", () => { + expect(() => validateBase("/codeArbiter/")).toThrow(/must not end with/); + }); + + it("rejects a base that is not root-absolute", () => { + expect(() => validateBase("codeArbiter")).toThrow(/must be "" or start with/); + }); + + it("rejects a non-string", () => { + expect(() => validateBase(42 as unknown as string)).toThrow(TypeError); + }); +}); + +describe("why '/' is rejected", () => { + it("would rewrite a root-absolute asset into a protocol-relative URL", () => { + // This is the corruption the guard exists to prevent. Pinned as an + // executable statement of the hazard, not a comment about it. + expect(prefix("/diagrams/x.svg", "/")).toBe("//diagrams/x.svg"); + expect(prefix("/diagrams/x.svg", "/").startsWith("//")).toBe(true); + }); + + it("is a correct no-op under the sanctioned apex form", () => { + expect(prefix("/diagrams/x.svg", "")).toBe("/diagrams/x.svg"); + }); + + it("prefixes correctly under a sanctioned subpath", () => { + expect(prefix("/diagrams/x.svg", "/codeArbiter")).toBe("/codeArbiter/diagrams/x.svg"); + }); +}); + +describe("the configured BASE", () => { + it("is itself valid — the guard runs at import time, so this cannot regress", () => { + expect(() => validateBase(BASE)).not.toThrow(); + }); + + it("never ends with a slash", () => { + expect(BASE.endsWith("/")).toBe(false); + }); +});