diff --git a/src/router.ts b/src/router.ts index f8de37a..5a61dbb 100644 --- a/src/router.ts +++ b/src/router.ts @@ -27,6 +27,11 @@ function formatNextLink(url: URL): string { return `<${url.toString()}>; rel="next"`; } +// Stops the runtime's automatic gzip, which switches the response to chunked transfer-encoding and +// drops the Content-Length the distribution spec requires on blob/manifest GET and HEAD. Bodies are +// served verbatim. Spread into each such response's headers. +const identityEncoding = { "Content-Encoding": "identity" } as const; + const v2Router = Router({ base: "/v2/" }); v2Router.get("/", async (_req, _env: Env) => { @@ -148,6 +153,7 @@ v2Router.head("/:name+/manifests/:reference", async (req, env: Env) => { "Content-Length": res.size.toString(), "Content-Type": res.contentType, "Docker-Content-Digest": res.digest, + ...identityEncoding, }, }); } @@ -207,6 +213,7 @@ v2Router.head("/:name+/manifests/:reference", async (req, env: Env) => { "Content-Length": checkManifestResponse.size.toString(), "Content-Type": checkManifestResponse.contentType, "Docker-Content-Digest": checkManifestResponse.digest, + ...identityEncoding, }, }); }); @@ -220,6 +227,7 @@ v2Router.get("/:name+/manifests/:reference", async (req, env: Env, context: Exec "Content-Length": res.size.toString(), "Content-Type": res.contentType, "Docker-Content-Digest": res.digest, + ...identityEncoding, }, }); } @@ -270,6 +278,7 @@ v2Router.get("/:name+/manifests/:reference", async (req, env: Env, context: Exec "Content-Length": getManifestResponse.size.toString(), "Content-Type": getManifestResponse.contentType, "Docker-Content-Digest": getManifestResponse.digest, + ...identityEncoding, }, }); }); @@ -366,6 +375,7 @@ v2Router.get("/:name+/blobs/:digest", async (req, env: Env, context: ExecutionCo headers: { "Docker-Content-Digest": res.digest, "Content-Length": `${res.size}`, + ...identityEncoding, }, }); } @@ -404,6 +414,7 @@ v2Router.get("/:name+/blobs/:digest", async (req, env: Env, context: ExecutionCo headers: { "Docker-Content-Digest": layerResponse.digest, "Content-Length": `${layerResponse.size}`, + ...identityEncoding, }, }); }); @@ -625,6 +636,7 @@ v2Router.head("/:name+/blobs/:tag", async (req, env: Env) => { headers: { "Content-Length": layerExistsResponse.size.toString(), "Docker-Content-Digest": layerExistsResponse.digest, + ...identityEncoding, }, }); }); diff --git a/test/index.test.ts b/test/index.test.ts index 65ebe53..483391d 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -344,6 +344,7 @@ describe("v2 manifests", () => { "content-length": "2", "content-type": "application/gzip", "docker-content-digest": sha256, + "content-encoding": "identity", }); await bindings.REGISTRY.delete(`${name}/manifests/${reference}`); }); @@ -2380,3 +2381,50 @@ test("docker.io", () => { } } }); + +describe("blob and manifest GET/HEAD response headers", () => { + // These responses must carry a literal Content-Length (and the manifest its Content-Type), and + // set Content-Encoding: identity so the runtime does not switch to chunked transfer-encoding and + // drop Content-Length — the regression these tests guard. + test("blob GET and HEAD return Content-Length, Content-Encoding identity, and the exact bytes", async () => { + const name = "headers/blob"; + const data = "blob-bytes-for-content-length"; + const sha256 = await getSHA256(data); + const post = await fetch(createRequest("POST", `/v2/${name}/blobs/uploads/`, null, {})); + const patch = await fetch( + createRequest("PATCH", post.headers.get("location")!, limit(new Blob([data]).stream(), data.length), {}), + ); + await fetch(createRequest("PUT", patch.headers.get("location")! + "&digest=" + sha256, null, {})); + + const get = await fetch(createRequest("GET", `/v2/${name}/blobs/${sha256}`, null)); + expect(get.status).toBe(200); + expect(get.headers.get("Content-Length")).toEqual(`${data.length}`); + expect(get.headers.get("Content-Encoding")).toEqual("identity"); + expect(await get.text()).toEqual(data); + + const head = await fetch(createRequest("HEAD", `/v2/${name}/blobs/${sha256}`, null)); + expect(head.status).toBe(200); + expect(head.headers.get("Content-Length")).toEqual(`${data.length}`); + expect(head.headers.get("Content-Encoding")).toEqual("identity"); + }); + + test("manifest GET and HEAD return Content-Length, Content-Type and Content-Encoding identity", async () => { + const name = "headers/manifest"; + const manifest = await generateManifest(name); + const { sha256 } = await createManifest(name, manifest, "v1"); + const size = new Blob([JSON.stringify(manifest)]).size; + + // Manifests are stored (uploadManifest) with this content type; GET/HEAD must echo it exactly. + const get = await fetch(createRequest("GET", `/v2/${name}/manifests/v1`, null)); + expect(get.status).toBe(200); + expect(get.headers.get("Content-Length")).toEqual(`${size}`); + expect(get.headers.get("Content-Type")).toEqual("application/gzip"); + expect(get.headers.get("Content-Encoding")).toEqual("identity"); + + const head = await fetch(createRequest("HEAD", `/v2/${name}/manifests/${sha256}`, null)); + expect(head.status).toBe(200); + expect(head.headers.get("Content-Length")).toEqual(`${size}`); + expect(head.headers.get("Content-Type")).toEqual("application/gzip"); + expect(head.headers.get("Content-Encoding")).toEqual("identity"); + }); +});