Skip to content

Commit 4306bd6

Browse files
committed
Fix range not forwarded to upstream issue
1 parent d895613 commit 4306bd6

2 files changed

Lines changed: 78 additions & 21 deletions

File tree

src/registry/http.ts

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
RegistryError,
1818
UploadId,
1919
UploadObject,
20+
BlobRangeRequest,
2021
} from "./registry";
2122
import { ociImageIndexContentType } from "./r2";
2223

@@ -156,18 +157,41 @@ function ctxIntoHeaders(ctx: HTTPContext): Headers {
156157
return headers;
157158
}
158159

159-
function ctxIntoRequest(ctx: HTTPContext, url: URL, method: string, path: string, body?: BodyInit): Request {
160+
function ctxIntoRequest(
161+
ctx: HTTPContext,
162+
url: URL,
163+
method: string,
164+
path: string,
165+
body?: BodyInit,
166+
extraHeaders?: HeadersInit,
167+
): Request {
160168
const urlReq = `${url.protocol}//${url.host}/v2${
161169
ctx.repository === "" || ctx.repository === "/" ? "/" : ctx.repository + "/"
162170
}${path}`;
171+
const headers = ctxIntoHeaders(ctx);
172+
if (extraHeaders !== undefined) {
173+
new Headers(extraHeaders).forEach((value, key) => headers.set(key, value));
174+
}
163175
return new Request(urlReq, {
164176
method,
165177
body,
166178
redirect: "follow",
167-
headers: ctxIntoHeaders(ctx),
179+
headers,
168180
});
169181
}
170182

183+
// Parses an HTTP "Content-Range: bytes <start>-<end>/<size>" response header.
184+
function parseContentRange(header: string | null): { start: number; end: number; size: number } | null {
185+
if (header === null) return null;
186+
const match = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(header.trim());
187+
if (match === null) return null;
188+
const start = Number(match[1]);
189+
const end = Number(match[2]);
190+
const size = Number(match[3]);
191+
if (!Number.isInteger(start) || !Number.isInteger(end) || !Number.isInteger(size)) return null;
192+
return { start, end, size };
193+
}
194+
171195
function authHeaderIntoAuthContext(urlObject: URL, authenticateHeader: string): AuthContext {
172196
const url = urlObject.toString();
173197
const parts = authenticateHeader.split(" ");
@@ -519,18 +543,29 @@ export class RegistryHTTPClient implements Registry {
519543
}
520544
}
521545

522-
async getLayer(name: string, digest: string): Promise<GetLayerResponse | RegistryError> {
546+
async getLayer(name: string, digest: string, range?: BlobRangeRequest): Promise<GetLayerResponse | RegistryError> {
523547
const namespace = name.includes("/") || !isDockerDotIO(this.url) ? name : `library/${name}`;
524548
try {
525549
const ctx = await this.authenticate(namespace);
526-
const req = ctxIntoRequest(ctx, this.url, "GET", `${namespace}/blobs/${digest}`);
550+
const rangeHeader =
551+
range === undefined ? undefined : `bytes=${range.offset}-${range.end === undefined ? "" : range.end}`;
552+
const req = ctxIntoRequest(
553+
ctx,
554+
this.url,
555+
"GET",
556+
`${namespace}/blobs/${digest}`,
557+
undefined,
558+
rangeHeader !== undefined ? { Range: rangeHeader } : undefined,
559+
);
527560
let res = await fetch(req);
528561
if (!res.ok) {
529562
// This means we got a redirect, so let's try again this URL but
530563
// without any headers. Services like S3 reject authorization headers altogether
531564
// if the authentication is included in the URL.
532565
if (res.url !== req.url) {
533-
const redirectResponse = await fetch(new Request(res.url));
566+
const redirectResponse = await fetch(
567+
new Request(res.url, rangeHeader !== undefined ? { headers: { Range: rangeHeader } } : undefined),
568+
);
534569
if (!redirectResponse.ok) {
535570
return {
536571
response: res,
@@ -549,11 +584,27 @@ export class RegistryHTTPClient implements Registry {
549584
throw new Error("returned body is null");
550585
}
551586

552-
return {
587+
const layer: GetLayerResponse = {
553588
stream: res.body,
554589
size: +(res.headers.get("Content-Length") ?? "0"),
555590
digest: res.headers.get("Digest-Content-Digest") ?? digest,
556591
};
592+
593+
// If we asked for a range and the upstream honored it, surface the partial-content metadata so
594+
// the caller can reply with 206. A 200 here means the upstream ignored the range and we serve
595+
// the full blob (best-effort). Serving a partial body as if it were complete would corrupt it,
596+
// so a 206 without a parseable Content-Range is treated as an error.
597+
if (range !== undefined && res.status === 206) {
598+
const contentRange = parseContentRange(res.headers.get("Content-Range"));
599+
if (contentRange === null) {
600+
throw new Error("upstream returned 206 without a parseable Content-Range header");
601+
}
602+
603+
layer.size = contentRange.size;
604+
layer.contentRange = contentRange;
605+
}
606+
607+
return layer;
557608
} catch (err) {
558609
console.error(`Error doing get layer with ${namespace} and ${digest}: ` + errorString(err));
559610
return {

src/router.ts

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -406,27 +406,33 @@ v2Router.get("/:name+/blobs/:digest", async (req, env: Env, context: ExecutionCo
406406
const registriesList = registries(env);
407407
for (const registry of registriesList) {
408408
const client = new RegistryHTTPClient(env, registry);
409-
const response = await client.getLayer(name, digest);
409+
const response = await client.getLayer(name, digest, range);
410410
if ("response" in response) {
411411
continue;
412412
}
413413

414414
layerResponse = response;
415-
const [s1, s2] = layerResponse.stream.tee();
416-
layerResponse.stream = s1;
417-
context.waitUntil(
418-
(async () => {
419-
const [response, err] = await wrap(env.REGISTRY_CLIENT.monolithicUpload(name, digest, s2, layerResponse.size));
420-
if (err) {
421-
console.error("Error uploading asynchronously the layer ", digest, "into main registry");
422-
return;
423-
}
415+
// Only cache full-object responses. A ranged/partial upstream response must never be written to
416+
// R2 as if it were the complete blob, or the cached object would be corrupt.
417+
if (range === undefined && layerResponse.contentRange === undefined) {
418+
const fullLayer = layerResponse;
419+
const [s1, s2] = fullLayer.stream.tee();
420+
fullLayer.stream = s1;
421+
context.waitUntil(
422+
(async () => {
423+
const [response, err] = await wrap(env.REGISTRY_CLIENT.monolithicUpload(name, digest, s2, fullLayer.size));
424+
if (err) {
425+
console.error("Error uploading asynchronously the layer ", digest, "into main registry");
426+
return;
427+
}
428+
429+
if (response === false) {
430+
console.error("Layer might be too big for the registry client", fullLayer.size);
431+
}
432+
})(),
433+
);
434+
}
424435

425-
if (response === false) {
426-
console.error("Layer might be too big for the registry client", layerResponse.size);
427-
}
428-
})(),
429-
);
430436
break;
431437
}
432438

0 commit comments

Comments
 (0)