Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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,
},
});
}
Expand Down Expand Up @@ -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,
},
});
});
Expand All @@ -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,
},
});
}
Expand Down Expand Up @@ -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,
},
});
});
Expand Down Expand Up @@ -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,
},
});
}
Expand Down Expand Up @@ -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,
},
});
});
Expand Down Expand Up @@ -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,
},
});
});
Expand Down
48 changes: 48 additions & 0 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
});
Expand Down Expand Up @@ -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");
});
});