From 044a28816118e171d8e6e8551333f3f0d55263d9 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 14 Sep 2026 20:59:57 +0000 Subject: [PATCH] fix(artifacts): reject missing prerelease version fallback --- server/src/schemas-middleware.ts | 48 +-- .../tests/integration/schema-routing.test.ts | 293 +++++++++++++++++- tests/artifact-cdn-worker.test.cjs | 72 +++++ workers/artifact-cdn/src/index.js | 107 ++++--- 4 files changed, 461 insertions(+), 59 deletions(-) diff --git a/server/src/schemas-middleware.ts b/server/src/schemas-middleware.ts index a0ecf6026df..a8a7dbd51e5 100644 --- a/server/src/schemas-middleware.ts +++ b/server/src/schemas-middleware.ts @@ -211,25 +211,21 @@ function releaseStatusMetadata(version: string): { * release in the same major. Staying at-or-below keeps a frozen 3.0.x doc * pointing at 3.0.x schemas rather than jumping forward to a newer minor. * - * When the requested version is stable, prerelease candidates are excluded so - * a missing stable pin (e.g. /schemas/3.0.0/...) never silently resolves to a - * release candidate; when the requested version is itself a prerelease, lower - * prereleases (and stables) on the line are eligible. Returns undefined when - * nothing in the same major qualifies. + * Only stable schema docs pins may fall back, and only to stable candidates. + * Prerelease requests always require their exact published version. */ export function resolvePinnedFallback( versions: string[], requested: string, ): string | undefined { const parsed = semver.parse(requested); - if (!parsed) return undefined; + if (!parsed || parsed.prerelease.length > 0) return undefined; - const wantStable = parsed.prerelease.length === 0; const eligible = (v: string): boolean => { if (!isSelectableRelease(v)) return false; const p = semver.parse(v); if (!p || p.major !== parsed.major) return false; - if (wantStable && p.prerelease.length > 0) return false; + if (p.prerelease.length > 0) return false; return semver.lte(v, requested); }; @@ -411,6 +407,13 @@ function mountVersionedStaticRoutes( }); app.use(mountPath, async (req, res, next) => { + const notFound = () => { + res.status(404).set({ + "Access-Control-Allow-Origin": "*", + "Cache-Control": "no-store", + "Content-Type": "text/plain; charset=utf-8", + }).end(req.method === "HEAD" ? undefined : "Not Found"); + }; // Capture before any rewrite — caches key on the original URL, so the // immutable-cache decision must be based on what the client requested. const originalPath = req.path; @@ -444,29 +447,33 @@ function mountVersionedStaticRoutes( } } - // 2. Pinned semver path whose exact version directory is missing: resolve - // to the nearest published release on the same line (e.g. a 3.0.19 docs - // snapshot's links to /schemas/3.0.19/... resolve to the 3.0.18 schemas - // they were built against). Without this, frozen doc snapshots that pin - // schema links to a docs-only version bump 404. Tracks whether the - // original request was an exact published directory hit so the cache - // policy below stays correct. + // 2. Only stable schema pins have a docs-gap fallback contract. Prereleases + // and compliance pins require an exact published version before any + // directory redirect, cache header, or compatibility rewrite can run. let exactPinnedHit = false; + let requiresExactVersion = false; + let resolvedPinnedFallback = false; if (!isAlias && isPinnedVersionPath(originalPath)) { const requestedVersion = originalPath.split("/")[1]; + requiresExactVersion = mountPath === "/compliance" + || semver.prerelease(requestedVersion) !== null; try { const versions = await getSchemaVersions(); const exactVersion = versions.find((version) => version === requestedVersion); if (exactVersion) { exactPinnedHit = true; - } else { + } else if (!requiresExactVersion) { const fallback = resolvePinnedFallback(versions, requestedVersion); if (fallback) { req.url = "/" + fallback + originalPath.slice(requestedVersion.length + 1); + resolvedPinnedFallback = true; } } } catch { - // Fall through; static handler below will produce the 404. + // Exact-only pins fail closed if the published versions cannot be read. + } + if (requiresExactVersion && !exactPinnedHit) { + return notFound(); } } @@ -506,11 +513,16 @@ function mountVersionedStaticRoutes( } // 5. Redirect bare version directories to their index.json. + // serve-static redirects using the original URL; match the Worker's + // resolved target for bare docs-gap pins instead. + if (resolvedPinnedFallback && matchVersionedDir(req.path + "/")) { + return res.redirect(301, mountPath + req.path + "/"); + } if (matchVersionedDir(req.path)) { return res.redirect(mountPath + req.path + "index.json"); } - schemasStatic(req, res, next); + schemasStatic(req, res, requiresExactVersion ? notFound : next); }); app.get(mountPath + "/", async (_req, res) => { diff --git a/server/tests/integration/schema-routing.test.ts b/server/tests/integration/schema-routing.test.ts index c2e52593104..9ddaec7430d 100644 --- a/server/tests/integration/schema-routing.test.ts +++ b/server/tests/integration/schema-routing.test.ts @@ -1,11 +1,21 @@ -import { describe, it, expect, beforeAll } from "vitest"; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; import express from "express"; import request from "supertest"; import fs from "fs"; import os from "os"; import path from "path"; import semver from "semver"; -import { mountProtocolRoutes, mountSchemasRoutes } from "../../src/schemas-middleware.js"; +import { + mountComplianceRoutes, + mountProtocolRoutes, + mountSchemasRoutes, + resolvePinnedFallback as resolveFlyFallback, +} from "../../src/schemas-middleware.js"; +import { + clearVersionCacheForTests, + handleRequest, + resolvePinnedFallback as resolveWorkerFallback, +} from "../../../workers/artifact-cdn/src/index.js"; /** * End-to-end tests for /schemas routing: version alias rewriting, bare-directory @@ -437,3 +447,282 @@ describe("/protocol discovery release overrides", () => { } }); }); + +describe("Worker and Fly artifact routing parity", () => { + const mounts = ["schemas", "compliance"] as const; + const methods = ["GET", "HEAD"] as const; + const immutable = "public, max-age=31536000, immutable"; + const revalidate = "public, no-cache, must-revalidate"; + const versions = ["2.9.9", "3.0.18", "3.0.20", "3.1.4", "3.2.0-rc.3", "3.2.0-rc.3+build.7", "3.4.0", "5.0.0-rc.3"]; + const objects = new Map(); + let fixtureRoot: string; + let app: express.Express; + let appWithFallback: express.Express; + + // Only the R2 storage boundary is faked. Both production routers serve the + // same tiny fixture tree, without building or changing released artifacts. + const bucket = { + async list({ prefix, delimiter }: { prefix: string; delimiter?: string }) { + const keys = [...objects.keys()].filter((key) => key.startsWith(prefix)); + return { + objects: keys.map((key) => ({ key })), + delimitedPrefixes: delimiter + ? [...new Set(keys.map((key) => prefix + key.slice(prefix.length).split("/")[0] + "/"))] + : [], + truncated: false, + }; + }, + async get(key: string) { + const body = objects.get(key); + return body === undefined ? null : { body, httpEtag: '"fixture"' }; + }, + async head(key: string) { + return this.get(key); + }, + }; + + beforeAll(() => { + clearVersionCacheForTests(); + fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "artifact-routing-parity-")); + const add = (key: string, body: string) => { + objects.set(key, body); + const filename = path.join(fixtureRoot, key); + fs.mkdirSync(path.dirname(filename), { recursive: true }); + fs.writeFileSync(filename, body); + }; + for (const mount of mounts) { + for (const version of [...versions, "latest"]) { + for (const filename of ["artifact.json", "index.json", "nested/index.json", "nested/artifact.json"]) { + add(`${mount}/${version}/${filename}`, JSON.stringify({ version, filename })); + } + } + add(`${mount}/latest/candidate-only.json`, JSON.stringify({ version: "latest" })); + } + for (const version of ["3.1.4", "3.2.0-rc.3", "latest"]) { + for (const suffix of ["", ".sha256", ".sig", ".crt"]) { + add(`protocol/${version}.tgz${suffix}`, `${version}${suffix}`); + } + } + app = express(); + mountSchemasRoutes(app, path.join(fixtureRoot, "schemas")); + mountComplianceRoutes(app, path.join(fixtureRoot, "compliance")); + mountProtocolRoutes(app, path.join(fixtureRoot, "protocol")); + appWithFallback = express(); + mountSchemasRoutes(appWithFallback, path.join(fixtureRoot, "schemas")); + mountComplianceRoutes(appWithFallback, path.join(fixtureRoot, "compliance")); + appWithFallback.use((_req, res) => res.json({ version: "older" })); + }); + + afterAll(() => { + clearVersionCacheForTests(); + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + }); + + const clients = { + Worker: async (url: string, method: "GET" | "HEAD") => { + const response = await handleRequest(new Request(`https://artifacts.example${url}`, { method }), { ARTIFACTS: bucket }); + return { status: response.status, headers: Object.fromEntries(response.headers), text: await response.text() }; + }, + Fly: async (url: string, method: "GET" | "HEAD") => { + const response = await (method === "HEAD" ? request(app).head(url) : request(app).get(url)); + return { status: response.status, headers: response.headers, text: response.text ?? (method === "HEAD" ? "" : response.body.toString()) }; + }, + }; + + for (const [runtime, get] of Object.entries(clients)) { + describe(runtime, () => { + for (const mount of mounts) { + describe(`/${mount}`, () => { + it.each(methods)("%s serves exact prerelease files and directory forms with immutable caching", async (method) => { + for (const version of ["3.2.0-rc.3", "3.2.0-rc.3+build.7"]) { + for (const [suffix, status, location] of [ + ["/artifact.json", 200, undefined], + ["/index.json", 200, undefined], + ["", 301, `/${mount}/${version}/`], + ["/", 302, `/${mount}/${version}/index.json`], + ] as const) { + const response = await get(`/${mount}/${version}${suffix}`, method); + expect(response.status, suffix).toBe(status); + expect(response.headers.location, suffix).toBe(location); + expect(response.headers["cache-control"], suffix).toBe(immutable); + if (method === "GET" && status === 200) expect(JSON.parse(response.text).version).toBe(version); + if (method === "HEAD") expect(response.text).toBe(""); + } + } + }); + + it.each(methods)("%s denies every absent prerelease form before fallback or directory redirects", async (method) => { + for (const version of ["3.2.0-rc.4", "3.5.0-rc.1", "3.2.0-rc.4+build.7", "3.2.0-rc.3+missing", "v3.2.0-rc.4", "v3.2.0-rc.3"]) { + for (const suffix of ["", "/", "/artifact.json", "/index.json", "/nested", "/nested/", "/nested/index.json", "/nested/artifact.json", "/candidate-only.json"]) { + const url = `/${mount}/${version}${suffix}`; + const response = await get(url, method); + expect(response.status, url).toBe(404); + expect(response.headers.location, url).toBeUndefined(); + expect(response.headers["cache-control"], url).toBe("no-store"); + expect(response.headers.etag, url).toBeUndefined(); + expect(response.text, url).toBe(method === "HEAD" ? "" : "Not Found"); + } + } + }); + + it.each(methods)("%s keeps candidate-only files absent even under an exact published prerelease", async (method) => { + const url = `/${mount}/3.2.0-rc.3/candidate-only.json`; + const response = runtime === "Fly" + ? await (method === "HEAD" ? request(appWithFallback).head(url) : request(appWithFallback).get(url)) + : await get(url, method); + expect(response.status).toBe(404); + expect(response.headers.location).toBeUndefined(); + expect(response.headers["cache-control"]).toBe("no-store"); + expect(response.headers.etag).toBeUndefined(); + expect(response.text ?? "").toBe(method === "HEAD" ? "" : "Not Found"); + }); + + it("lists only stored versions and stable aliases, omitting the unpublished candidate", async () => { + const response = await get(`/${mount}/`, "GET"); + expect(response.status).toBe(200); + const discovery = JSON.parse(response.text); + expect(discovery.versions.map((entry: { version: string }) => entry.version).sort()).toEqual([...versions].sort()); + expect(discovery.aliases.every((entry: { resolves_to: string }) => versions.includes(entry.resolves_to) && !entry.resolves_to.includes("-"))).toBe(true); + expect(discovery.latest_stable).toBe("3.4.0"); + expect(discovery.latest.path).toBe(`/${mount}/latest/`); + expect(response.text).not.toContain("3.2.0-rc.4"); + }); + + it.each(methods)("%s limits stable docs-gap fallback to schemas", async (method) => { + for (const [pin, target] of [["3.0.19", "3.0.18"], ["3.3.0", "3.1.4"]]) { + for (const suffix of ["", "/", "/artifact.json", "/index.json"]) { + const response = await get(`/${mount}/${pin}${suffix}`, method); + if (mount === "schemas") { + expect(response.status, pin + suffix).toBe(suffix === "" ? 301 : suffix === "/" ? 302 : 200); + expect(response.headers["cache-control"], pin + suffix).toBe(revalidate); + if (suffix === "") expect(response.headers.location, pin).toBe(`/${mount}/${target}/`); + else if (suffix === "/") expect(response.headers.location, pin).toBe(`/${mount}/${target}/index.json`); + else if (method === "GET") expect(JSON.parse(response.text).version, pin).toBe(target); + } else { + expect(response.status, pin + suffix).toBe(404); + expect(response.headers.location, pin + suffix).toBeUndefined(); + expect(response.headers["cache-control"], pin + suffix).toBe("no-store"); + } + } + } + for (const pin of ["3.0.1", "4.0.0", "5.0.0"]) { + const response = await get(`/${mount}/${pin}/artifact.json`, method); + expect(response.status, pin).toBe(404); + expect(response.headers.location, pin).toBeUndefined(); + } + }); + + it.each(methods)("%s does not resolve malformed versions", async (method) => { + for (const version of ["3.2", "3.2.0junk", "03.2.0", "3.2.0-rc.04", "3.2.0-rc..4", "3.2.0-rc.4+"]) { + for (const suffix of ["", "/", "/artifact.json", "/index.json"]) { + const response = await get(`/${mount}/${version}${suffix}`, method); + expect(response.status, version + suffix).toBe(404); + expect(response.headers.location, version + suffix).toBeUndefined(); + } + } + }); + + it("keeps stable aliases and latest mutable", async () => { + for (const [alias, target] of [["v3", "3.4.0"], ["v3.0", "3.0.20"], ["latest", "latest"], ["v1", "latest"]]) { + const response = await get(`/${mount}/${alias}/artifact.json`, "GET"); + expect(response.status, alias).toBe(200); + expect(JSON.parse(response.text).version, alias).toBe(target); + expect(response.headers["cache-control"], alias).toBe(revalidate); + } + }); + }); + } + + it.each(methods)("%s leaves exact protocol artifacts and absent protocol versions unchanged", async (method) => { + for (const suffix of ["", ".sha256", ".sig", ".crt"]) { + const exact = await get(`/protocol/3.2.0-rc.3.tgz${suffix}`, method); + expect(exact.status, suffix).toBe(200); + // Protocol cache policies already differ between hosts; this fix + // must leave those tarball and sidecar policies untouched. + expect(exact.headers["cache-control"], suffix).toBe(suffix && runtime === "Worker" ? revalidate : immutable); + if (method === "GET") expect(exact.text, suffix).toBe(`3.2.0-rc.3${suffix}`); + for (const missing of ["3.2.0-rc.4", "3.1.5"]) { + const response = await get(`/protocol/${missing}.tgz${suffix}`, method); + expect(response.status, missing + suffix).toBe(404); + expect(response.headers.location).toBeUndefined(); + } + } + for (const url of ["/protocol/3.2.0-rc.4", "/protocol/3.2.0-rc.4/", "/protocol/v3.tgz"]) { + expect((await get(url, method)).status, url).toBe(404); + } + const latest = await get("/protocol/latest.tgz", method); + expect(latest.status).toBe(200); + expect(latest.headers["cache-control"]).toBe(runtime === "Worker" ? revalidate : "public, max-age=600"); + if (method === "GET") expect(latest.text).toBe("latest"); + }); + + it("keeps protocol discovery based on exact tarballs", async () => { + const response = await get("/protocol/", "GET"); + expect(response.status).toBe(200); + const discovery = JSON.parse(response.text); + expect(discovery.versions.map((entry: { version: string }) => entry.version)).toEqual(["3.2.0-rc.3", "3.1.4"]); + expect(discovery.latest.published_version).toBe("3.2.0-rc.3"); + expect(response.text).not.toContain("3.2.0-rc.4"); + }); + }); + } + + it("denies absent prereleases without exposing failed version-listing details", async () => { + clearVersionCacheForTests(); + const unavailableApp = express(); + mountSchemasRoutes(unavailableApp, path.join(fixtureRoot, "missing-schemas")); + mountComplianceRoutes(unavailableApp, path.join(fixtureRoot, "missing-compliance")); + let listCalls = 0; + const unavailableBucket = { + ...bucket, + async list() { + listCalls += 1; + throw new Error("Private bucket listing credentials unavailable"); + }, + }; + for (const mount of mounts) { + for (const method of methods) { + for (const suffix of ["", "/", "/artifact.json", "/index.json"]) { + const url = `/${mount}/3.2.0-rc.4${suffix}`; + const worker = await handleRequest(new Request(`https://artifacts.example${url}`, { method }), { ARTIFACTS: unavailableBucket }); + const fly = await (method === "HEAD" ? request(unavailableApp).head(url) : request(unavailableApp).get(url)); + for (const response of [ + { status: worker.status, headers: Object.fromEntries(worker.headers), text: await worker.text() }, + { status: fly.status, headers: fly.headers, text: fly.text ?? "" }, + ]) { + expect(response.status, url).toBe(404); + expect(response.headers["cache-control"], url).toBe("no-store"); + expect(response.headers.location, url).toBeUndefined(); + expect(response.headers.etag, url).toBeUndefined(); + expect(response.text, url).toBe(method === "HEAD" ? "" : "Not Found"); + } + } + } + } + expect(listCalls).toBe(16); + }); + + it.each([ + ["3.2.0-rc.4", ["3.2.0-rc.3", "3.1.4"], undefined], + ["v3.2.0-rc.4", ["3.2.0-rc.3", "3.1.4"], undefined], + ["3.2.0-rc.4+build.7", ["3.2.0-rc.3+build.7"], undefined], + ["3.5.0-rc.1", ["3.4.0"], undefined], + ["3.0.19", ["3.0.20", "3.1.4", "3.0.18"], "3.0.18"], + ["3.0.19+build-rc.4", ["3.0.18"], "3.0.18"], + ["3.3.0", ["3.4.0", "3.2.0-rc.3", "3.1.4"], "3.1.4"], + ["3.0.1", ["2.9.9", "3.0.18"], undefined], + ["4.0.0", ["3.4.0", "5.0.0"], undefined], + ["5.0.0", ["5.0.0-rc.3"], undefined], + ["3.1.5", ["3.1.3", "3.1.2"], "3.1.2"], + ["3.2.1", ["3.2.0", "3.1.4"], "3.1.4"], + ["3.2", ["3.1.4"], undefined], + ["3.2.0junk", ["3.1.4"], undefined], + ["03.2.0", ["3.1.4"], undefined], + ["3.2.0-rc.04", ["3.1.4"], undefined], + ["3.2.0-rc..4", ["3.1.4"], undefined], + ["3.2.0-rc.4+", ["3.1.4"], undefined], + ] as const)("production resolvers agree for %s with %j", (requested, published, expected) => { + expect(resolveWorkerFallback([...published], requested)).toBe(expected); + expect(resolveFlyFallback([...published], requested)).toBe(expected); + }); +}); diff --git a/tests/artifact-cdn-worker.test.cjs b/tests/artifact-cdn-worker.test.cjs index e1368b2f3f3..7e8626fa3d1 100644 --- a/tests/artifact-cdn-worker.test.cjs +++ b/tests/artifact-cdn-worker.test.cjs @@ -301,6 +301,78 @@ describe('artifact CDN Worker', () => { }); } + it('denies absent prereleases before redirects, origin fallback or edge-cache access', async () => { + const { clearVersionCacheForTests, handleRequest } = await loadWorker(); + const testEnv = env(); + testEnv.FALLBACK_ORIGIN = 'https://origin.example'; + const edgeCache = new MockEdgeCache(); + const previousFetch = globalThis.fetch; + globalThis.fetch = async () => assert.fail('missing prereleases must not reach the origin'); + clearVersionCacheForTests(); + + try { + await withMockEdgeCache(edgeCache, async () => { + for (const mount of ['schemas', 'compliance']) { + for (const version of ['3.2.0-rc.4', '3.2.0-rc.4+build.1', 'v3.2.0-rc.4']) { + for (const suffix of ['', '/', '/index.json', '/foo.json', '/candidate-only.yaml', '/tmp/offer.json']) { + for (const method of ['GET', 'HEAD']) { + const response = await handleRequest( + new Request(`https://artifacts.example/${mount}/${version}${suffix}`, { method }), + testEnv, + {}, + ); + assert.equal(response.status, 404, `${method} /${mount}/${version}${suffix}`); + assert.equal(response.headers.get('location'), null); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal(response.headers.get('etag'), null); + assert.equal(response.headers.get('access-control-allow-origin'), '*'); + assert.equal(await response.text(), method === 'HEAD' ? '' : 'Not Found'); + } + } + } + } + }); + } finally { + globalThis.fetch = previousFetch; + } + + assert.equal(testEnv.ARTIFACTS.getCalls.size, 0); + assert.equal(testEnv.ARTIFACTS.headCalls.size, 0); + assert.equal(edgeCache.matches, 0); + assert.equal(edgeCache.puts, 0); + }); + + it('does not proxy missing files from an exact prerelease or a stale version listing', async () => { + const { clearVersionCacheForTests, handleRequest } = await loadWorker(); + const testEnv = env(); + testEnv.FALLBACK_ORIGIN = 'https://origin.example'; + const previousFetch = globalThis.fetch; + globalThis.fetch = async () => assert.fail('exact prerelease file misses must not reach the origin'); + clearVersionCacheForTests(); + + try { + for (const mount of ['schemas', 'compliance']) { + const key = `${mount}/3.1.0-beta.3/index.json`; + for (const method of ['GET', 'HEAD']) { + const response = await handleRequest( + new Request(`https://artifacts.example/${mount}/3.1.0-beta.3/candidate-only.json`, { method }), + testEnv, + {}, + ); + assert.equal(response.status, 404); + assert.equal(response.headers.get('cache-control'), 'no-store'); + } + // The cached prefix can outlive its objects; an object miss still cannot proxy. + testEnv.ARTIFACTS.entries.delete(key); + const response = await handleRequest(new Request(`https://artifacts.example/${key}`), testEnv, {}); + assert.equal(response.status, 404); + assert.equal(response.headers.get('cache-control'), 'no-store'); + } + } finally { + globalThis.fetch = previousFetch; + } + }); + it('rewrites major aliases to the latest matching semver directory', async () => { const response = await fetchPath('/schemas/v3/foo.json'); diff --git a/workers/artifact-cdn/src/index.js b/workers/artifact-cdn/src/index.js index 405e7d7e0f5..31bba5942bf 100644 --- a/workers/artifact-cdn/src/index.js +++ b/workers/artifact-cdn/src/index.js @@ -1,6 +1,5 @@ const ALIAS_PATH = /^\/v(\d+)(?:\.(\d+))?(\/.*)?$/; const VERSION_DIR_PATH = /^\/([^/]+)\/$/; -const SEMVER_PATH = /^\/(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?:\/|$)/; const PINNED_TARBALL = /(?:^|\/)(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\.tgz$/; const PINNED_TARBALL_SIDECAR = /(?:^|\/)(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\.tgz\.(?:sha256|sig|crt)$/; const LEGACY_TMP_SCHEMA_KEY = /^schemas\/(latest|\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\/tmp\/([A-Za-z0-9._-]+\.json)$/; @@ -104,21 +103,27 @@ async function versionedArtifactResponse(request, env, ctx, mount, pathname) { } } - // Pinned semver path whose exact version directory is absent from R2: resolve - // to the nearest published release on the same line (e.g. a 3.0.19 docs - // snapshot's links to /schemas/3.0.19/... resolve to the 3.0.18 artifacts it - // was built against). Mirrors the Fly schemas middleware so docs-only version - // bumps don't 404 when no schema directory is published for them. Tracks - // whether the exact directory exists so the cache policy below stays correct. + // Only stable schema pins have a docs-gap fallback contract. Prereleases + // and compliance pins require an exact published version, before any + // directory redirect, cache lookup, or origin fallback can run. let exactPinnedHit = false; + let requiresExactVersion = false; if (!isAlias) { - const semverMatch = requestPath.match(SEMVER_PATH); - if (semverMatch) { - const requestedVersion = semverMatch[1]; - const versions = await getVersions(env.ARTIFACTS, mount); + const requestedVersion = requestPath.split("/")[1]; + const parsed = parseArtifactVersion(requestedVersion); + if (parsed) { + requiresExactVersion = mount === "compliance" || parsed.prerelease.length > 0; + let versions; + try { + versions = await getVersions(env.ARTIFACTS, mount); + } catch (error) { + if (requiresExactVersion) return artifactNotFound(request); + throw error; + } if (versions.includes(requestedVersion)) { exactPinnedHit = true; } else { + if (requiresExactVersion) return artifactNotFound(request); const fallback = resolvePinnedFallback(versions, requestedVersion); if (fallback) { resolvedPath = `/${fallback}${requestPath.slice(requestedVersion.length + 1)}`; @@ -127,33 +132,40 @@ async function versionedArtifactResponse(request, env, ctx, mount, pathname) { } } + // Redirects follow the same cache policy as files in the Fly middleware. + const cacheControl = exactPinnedHit ? IMMUTABLE_CACHE_CONTROL : REVALIDATE_CACHE_CONTROL; const bareVersionMatch = resolvedPath.match(/^\/([^/]+)$/); - if (bareVersionMatch && (bareVersionMatch[1] === "latest" || parseSemver(bareVersionMatch[1]))) { - return redirect(`/${mount}${resolvedPath}/`, 301); + if (bareVersionMatch && (bareVersionMatch[1] === "latest" || parseArtifactVersion(bareVersionMatch[1]))) { + return redirect(`/${mount}${resolvedPath}/`, 301, { "cache-control": cacheControl }); } const dirMatch = resolvedPath.match(VERSION_DIR_PATH); - if (dirMatch && (dirMatch[1] === "latest" || parseSemver(dirMatch[1]))) { - return redirect(`/${mount}${resolvedPath}index.json`, 302); + if (dirMatch && (dirMatch[1] === "latest" || parseArtifactVersion(dirMatch[1]))) { + return redirect(`/${mount}${resolvedPath}index.json`, 302, { "cache-control": cacheControl }); } // Only an exact pinned directory hit is immutable. Resolved fallbacks (like // aliases) revalidate, since the version they point at can shift as patches // land on the line. const isImmutableArtifact = exactPinnedHit; - const cacheControl = isImmutableArtifact - ? IMMUTABLE_CACHE_CONTROL - : REVALIDATE_CACHE_CONTROL; const key = `${mount}${resolvedPath}`; return r2ArtifactResponse(request, env, key, cacheControl, { edgeCache: isImmutableArtifact, overrideCacheControl: true, + requiresExactVersion, fallbackKey: mount === "schemas" ? legacyTmpFallbackKey(key) : undefined, ctx, }); } +function artifactNotFound(request) { + return new Response(request.method === "HEAD" ? null : "Not Found", { + status: 404, + headers: corsHeaders({ "cache-control": "no-store", "content-type": "text/plain; charset=utf-8" }), + }); +} + async function discoveryResponse(bucket, mount) { try { const versions = await getVersions(bucket, mount); @@ -246,6 +258,7 @@ async function r2ArtifactResponse(request, env, key, fallbackCacheControl, optio } if (!object) { + if (options.requiresExactVersion) return artifactNotFound(request); return fallbackResponse(request, env); } @@ -306,8 +319,8 @@ async function getVersions(bucket, mount) { const prefixes = await listPrefixes(bucket, prefix); const versions = prefixes .map((entry) => entry.slice(prefix.length).replace(/\/$/, "")) - .filter((segment) => parseSemver(segment)) - .sort((a, b) => compareVersions(b, a)); + .filter((segment) => parseArtifactVersion(segment)) + .sort((a, b) => compareArtifactVersions(b, a)); versionCache.set(cacheKey, { versions, timestamp: now }); return versions; @@ -344,7 +357,7 @@ async function listObjects(bucket, prefix) { export function findMatchingVersion(versions, requestedMajor, requestedMinor) { return versions.find((version) => { if (!isSelectableRelease(version)) return false; - const parsed = parseSemver(version); + const parsed = parseArtifactVersion(version); if (!parsed || parsed.major !== requestedMajor) return false; if (parsed.prerelease.length > 0) return false; return requestedMinor === undefined || parsed.minor === requestedMinor; @@ -358,30 +371,27 @@ export function findMatchingVersion(versions, requestedMajor, requestedMinor) { * in the same major. Staying at-or-below keeps a frozen 3.0.x doc pointing at * 3.0.x artifacts rather than jumping forward to a newer minor. * - * A stable request excludes prerelease candidates so a missing stable pin never - * silently resolves to a release candidate; a prerelease request also accepts - * lower prereleases (and stables) on the line. Returns undefined when nothing in - * the same major qualifies. + * Only stable schema docs pins may fall back, and only to stable candidates. + * Prerelease requests always require their exact published version. */ export function resolvePinnedFallback(versions, requested) { - const parsed = parseSemver(requested); - if (!parsed) return undefined; + const parsed = parseArtifactVersion(requested); + if (!parsed || parsed.prerelease.length > 0) return undefined; - const wantStable = parsed.prerelease.length === 0; const eligible = (candidate) => { if (!isSelectableRelease(candidate)) return false; - const p = parseSemver(candidate); + const p = parseArtifactVersion(candidate); if (!p || p.major !== parsed.major) return false; - if (wantStable && p.prerelease.length > 0) return false; - return compareVersions(candidate, requested) <= 0; + if (p.prerelease.length > 0) return false; + return compareArtifactVersions(candidate, requested) <= 0; }; const sameMinor = versions - .filter((candidate) => eligible(candidate) && parseSemver(candidate).minor === parsed.minor) - .sort((a, b) => compareVersions(b, a)); + .filter((candidate) => eligible(candidate) && parseArtifactVersion(candidate).minor === parsed.minor) + .sort((a, b) => compareArtifactVersions(b, a)); if (sameMinor[0]) return sameMinor[0]; - const sameMajor = versions.filter(eligible).sort((a, b) => compareVersions(b, a)); + const sameMajor = versions.filter(eligible).sort((a, b) => compareArtifactVersions(b, a)); return sameMajor[0]; } @@ -395,7 +405,7 @@ function buildAliases(versions, mount) { for (const version of versions) { if (!isSelectableRelease(version)) continue; - const parsed = parseSemver(version); + const parsed = parseArtifactVersion(version); if (!parsed) continue; if (parsed.prerelease.length > 0) continue; const majorKey = `${parsed.major}`; @@ -426,7 +436,7 @@ function buildAliases(versions, mount) { function versionEntry(version, mountPath, knownVersions = []) { const statusMetadata = releaseStatusMetadata(version); - const parsed = parseSemver(version); + const parsed = parseArtifactVersion(version); const prerelease = !!parsed && parsed.prerelease.length > 0; const label = prerelease ? String(parsed.prerelease[0]).toLowerCase() : ""; const stableVersion = prerelease ? version.split("-")[0] : undefined; @@ -448,7 +458,7 @@ function versionEntry(version, mountPath, knownVersions = []) { function latestStableVersion(versions) { return versions.find((version) => { if (!isSelectableRelease(version)) return false; - const parsed = parseSemver(version); + const parsed = parseArtifactVersion(version); return parsed && parsed.prerelease.length === 0; }) || null; } @@ -467,6 +477,25 @@ function releaseStatusMetadata(version) { return {}; } +// Schema/compliance pins preserve the full prefix, including build metadata. +// Keep protocol parsing unchanged: its exact tarball routes have no fallback. +function parseArtifactVersion(version) { + if (version.length > 256) return null; + // Fly's semver parser also accepts a leading v; keep the raw prefix for lookup. + const [core, build, ...extra] = (version.startsWith("v") ? version.slice(1) : version).split("+"); + if (extra.length || (build !== undefined && !/^[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*$/.test(build))) return null; + const parsed = parseSemver(core); + if (!parsed) return null; + const numbers = core.split("-")[0].split("."); + if (numbers.some((part) => /^0\d/.test(part) || !Number.isSafeInteger(Number(part)))) return null; + if (parsed.prerelease.some((part) => !part || (/^\d+$/.test(part) && /^0\d/.test(part)))) return null; + return parsed; +} + +function compareArtifactVersions(left, right) { + return compareVersions(left.replace(/^v/, "").split("+")[0], right.replace(/^v/, "").split("+")[0]); +} + function parseSemver(version) { const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/); if (!match) return null; @@ -550,8 +579,8 @@ function normalizePath(pathname) { return pathname.replace(/\/{2,}/g, "/"); } -function redirect(location, status) { - return new Response(null, { status, headers: corsHeaders({ Location: location }) }); +function redirect(location, status, headers = {}) { + return new Response(null, { status, headers: corsHeaders({ ...headers, Location: location }) }); } function jsonResponse(body, status = 200) {