diff --git a/src/registry/r2.ts b/src/registry/r2.ts index 91e8fcf..b1d0870 100644 --- a/src/registry/r2.ts +++ b/src/registry/r2.ts @@ -102,6 +102,8 @@ export async function encodeState(state: State, env: Env): Promise<{ jwt: string } export const symlinkHeader = "X-Serverless-Registry-Symlink"; +export const symlinkDigestHeader = "X-Serverless-Registry-Symlink-Digest"; +export const symlinkSizeHeader = "X-Serverless-Registry-Symlink-Size"; export async function getUploadState( name: string, @@ -417,11 +419,22 @@ export class R2Registry implements Registry { // Trying to mount a layer from sourceLayerPath to destinationLayerPath // Create linked file with custom metadata + if (res.checksums.sha256 === null) { + return { response: new ServerError("invalid checksum from R2 backend") }; + } const [newFile, error] = await wrap( this.env.REGISTRY.put(destinationLayerPath, sourceLayerPath, { + // Symlink object content is the source blob path string. + // The object checksum must match the symlink payload to satisfy R2. sha256: await getSHA256(sourceLayerPath, ""), httpMetadata: res.httpMetadata, - customMetadata: { [symlinkHeader]: sourceName }, // Storing target repository name in metadata (to easily resolve recursive layer mounting) + customMetadata: { + // Storing target repository name in metadata (to easily resolve recursive layer mounting) + [symlinkHeader]: sourceName, + // Store source layer metadata so HEAD can answer without loading symlink body. + [symlinkDigestHeader]: digest, + [symlinkSizeHeader]: `${res.size}`, + }, }), ); if (error) { @@ -450,6 +463,63 @@ export class R2Registry implements Registry { }; } + const expectedDigest = + tag.startsWith("sha256:") && tag.length > SHA256_PREFIX_LEN ? tag.slice(SHA256_PREFIX_LEN) : null; + const symlinkByChecksumMismatch = + expectedDigest !== null && res.checksums.sha256 !== null && res.checksums.sha256 !== expectedDigest; + const symlinkMetadata = res.customMetadata ?? {}; + const symlinkByMetadata = symlinkHeader in symlinkMetadata; + + // Handle R2 symlink layers. + // We detect symlinks by: + // 1) explicit metadata, or + // 2) checksum mismatch between requested digest and R2 object checksum + // (the symlink object checksum is based on symlink payload, not mounted blob bytes). + if (symlinkByMetadata || symlinkByChecksumMismatch) { + // Fast path for symlinks created by newer versions that include source metadata. + const metadataSize = +(symlinkMetadata[symlinkSizeHeader] ?? ""); + const metadataDigest = symlinkMetadata[symlinkDigestHeader]; + if (Number.isFinite(metadataSize) && metadataSize >= 0 && metadataDigest) { + return { + digest: metadataDigest, + size: metadataSize, + exists: true, + }; + } + + // Backward-compatibility path for old symlinks: resolve link body and query target. + const [obj, getErr] = await wrap(this.env.REGISTRY.get(`${name}/blobs/${tag}`)); + if (getErr) { + return wrapError("layerExists", getErr); + } + if (!obj) { + return { exists: false }; + } + + const layerPath = await obj.text(); + const [linkName, linkDigest] = layerPath.split("/blobs/"); + if (!linkName || !linkDigest) { + // Backward compatibility: if this does not look like a symlink payload, + // fall back to object metadata from HEAD. + if (res.checksums.sha256 === null) { + return { response: new ServerError("invalid checksum from R2 backend") }; + } + + return { + digest: hexToDigest(res.checksums.sha256!), + size: res.size, + exists: true, + }; + } + + // Prevent recursive self-reference. + if (linkName === name && linkDigest === tag) { + return { exists: false }; + } + + return await this.env.REGISTRY_CLIENT.layerExists(linkName, linkDigest); + } + return { digest: hexToDigest(res.checksums.sha256!), size: res.size, diff --git a/src/router.ts b/src/router.ts index aa000ca..e87b6cb 100644 --- a/src/router.ts +++ b/src/router.ts @@ -501,9 +501,12 @@ v2Router.put("/:name+/blobs/uploads/:uuid", async (req, env: Env) => { v2Router.head("/:name+/blobs/:tag", async (req, env: Env) => { const { name, tag } = req.params; - const res = await env.REGISTRY.head(`${name}/blobs/${tag}`); let layerExistsResponse: CheckLayerResponse | null = null; - if (!res) { + const localResponse = await env.REGISTRY_CLIENT.layerExists(name, tag); + if ("response" in localResponse) { + return localResponse.response; + } + if (!localResponse.exists) { const registryList = registries(env); for (const registry of registryList) { const client = new RegistryHTTPClient(env, registry); @@ -521,15 +524,7 @@ v2Router.head("/:name+/blobs/:tag", async (req, env: Env) => { if (layerExistsResponse === null || !layerExistsResponse.exists) return new Response(JSON.stringify(BlobUnknownError), { status: 404 }); } else { - if (res.checksums.sha256 === null) { - throw new ServerError("invalid checksum from R2 backend"); - } - - layerExistsResponse = { - digest: hexToDigest(res.checksums.sha256!), - size: res.size, - exists: true, - }; + layerExistsResponse = localResponse; } return new Response(null, { diff --git a/test/index.test.ts b/test/index.test.ts index 2877c3c..941acee 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -407,6 +407,17 @@ describe("v2 manifests", () => { expect(layerC.ok).toBeTruthy(); expect(await layerB.bytes()).toEqual(sourceData); expect(await layerC.bytes()).toEqual(sourceData); + + // Check layer HEAD returns source metadata for mounted symlinks. + const layerHeadB = await fetch(createRequest("HEAD", `/v2/${repoB}/blobs/${layer}`, null)); + expect(layerHeadB.ok).toBeTruthy(); + expect(layerHeadB.headers.get("Docker-Content-Digest")).toEqual(layer); + expect(+(layerHeadB.headers.get("Content-Length") ?? "-1")).toEqual(sourceData.byteLength); + + const layerHeadC = await fetch(createRequest("HEAD", `/v2/${repoC}/blobs/${layer}`, null)); + expect(layerHeadC.ok).toBeTruthy(); + expect(layerHeadC.headers.get("Docker-Content-Digest")).toEqual(layer); + expect(+(layerHeadC.headers.get("Content-Length") ?? "-1")).toEqual(sourceData.byteLength); } } });