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
49 changes: 48 additions & 1 deletion src/manifest.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { z } from "zod";

const dockerManifestListContentType = "application/vnd.docker.distribution.manifest.list.v2+json";
export const ociImageManifestContentType = "application/vnd.oci.image.manifest.v1+json";
export const ociImageIndexContentType = "application/vnd.oci.image.index.v1+json";
export const dockerImageManifestContentType = "application/vnd.docker.distribution.manifest.v2+json";
export const dockerManifestListContentType = "application/vnd.docker.distribution.manifest.list.v2+json";

const manifestContentTypes: ReadonlySet<string> = new Set([
ociImageManifestContentType,
ociImageIndexContentType,
dockerImageManifestContentType,
dockerManifestListContentType,
]);

const platformSchema = z.object({
"architecture": z.string(),
Expand Down Expand Up @@ -80,3 +90,40 @@ export const manifestSchema = z
);

export type ManifestSchema = z.infer<typeof manifestSchema>;

/**
* The top-level mediaType is OPTIONAL in the OCI image-spec ("SHOULD be used"), and clients
* such as Helm omit it. The Content-Type header of the PUT carries the same information, so
* fill it in from there before validating.
*
* Only the parsed object is changed. Manifest bytes are stored verbatim, so the stored object
* still hashes to the digest the client pushed it under.
*/
export function withInferredMediaType(manifestJSON: unknown, contentType: string): unknown {
if (typeof manifestJSON !== "object" || manifestJSON === null || Array.isArray(manifestJSON)) {
return manifestJSON;
}

const manifest = manifestJSON as Record<string, unknown>;
if (manifest.schemaVersion !== 2 || manifest.mediaType !== undefined) {
return manifestJSON;
}

const mediaType = contentType.split(";")[0].trim();
if (!manifestContentTypes.has(mediaType)) {
return manifestJSON;
}

return { ...manifest, mediaType };
}

/** A failed union reports a bare "Invalid input" at the root; the detail is in the per-option issues. */
export function manifestIssueMessage(error: z.ZodError): string {
const issue = error.issues[0];
if (issue === undefined) return "invalid manifest";

const candidates = issue.code === "invalid_union" ? issue.errors.flat() : [issue];
const best = candidates.find((candidate) => candidate.path.length > 0) ?? issue;
const path = best.path.length ? `${best.path.join(".")}: ` : "";
return `${path}${best.message}`;
}
10 changes: 4 additions & 6 deletions src/registry/r2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ import {
wrapError,
} from "./registry";
import { GarbageCollectionMode, GarbageCollector } from "./garbage-collector";
import { ManifestSchema, manifestSchema } from "../manifest";
import { ManifestSchema, manifestSchema, manifestIssueMessage, withInferredMediaType } from "../manifest";

export const ociImageIndexContentType = "application/vnd.oci.image.index.v1+json";
export { ociImageIndexContentType } from "../manifest";

function referrersPrefix(name: string, digest: string): string {
return `${name}/_referrers/${digest}/`;
Expand Down Expand Up @@ -519,12 +519,10 @@ export class R2Registry implements Registry {
};
}

const manifestResult = manifestSchema.safeParse(manifestJSON);
const manifestResult = manifestSchema.safeParse(withInferredMediaType(manifestJSON, contentType));
if (!manifestResult.success) {
const firstIssue = manifestResult.error.issues[0];
const path = firstIssue?.path.length ? `${firstIssue.path.join(".")}: ` : "";
return {
response: new ManifestError("MANIFEST_INVALID", `${path}${firstIssue?.message ?? "invalid manifest"}`),
response: new ManifestError("MANIFEST_INVALID", manifestIssueMessage(manifestResult.error)),
};
}

Expand Down
86 changes: 86 additions & 0 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,47 @@ async function seedReferrerIndex(name: string, subjectDigest: string, descriptor
}

describe("v2 manifests", () => {
test("PUT /v2/:name/manifests/:reference infers mediaType from Content-Type", async () => {
const name = "helm-chart";
const bindings = env as Env;
// Helm omits the OPTIONAL top-level mediaType; the Content-Type header carries it instead.
const withoutMediaType: Record<string, unknown> = { ...getImageManifestV2(await generateManifest(name)) };
delete withoutMediaType.mediaType;

const data = JSON.stringify(withoutMediaType);
const sha256 = await getSHA256(data);
const response = await fetch(
createRequest("PUT", `/v2/${name}/manifests/v1`, new Blob([data]).stream(), {
"Content-Type": "application/vnd.oci.image.manifest.v1+json",
}),
);

expect(response.ok).toBeTruthy();
expect(response.headers.get("docker-content-digest")).toEqual(sha256);

// The stored bytes must be exactly what was pushed, or they no longer hash to the digest.
const stored = await bindings.REGISTRY.get(`${name}/manifests/${sha256}`);
expect(await stored?.text()).toEqual(data);
});

test("PUT /v2/:name/manifests/:reference rejects when no mediaType is available at all", async () => {
const name = "helm-chart-unknown-content-type";
const withoutMediaType: Record<string, unknown> = { ...getImageManifestV2(await generateManifest(name)) };
delete withoutMediaType.mediaType;

const response = await fetch(
createRequest("PUT", `/v2/${name}/manifests/v1`, new Blob([JSON.stringify(withoutMediaType)]).stream(), {
"Content-Type": "application/gzip",
}),
);

expect(response.status).toEqual(400);
const body = (await response.json()) as { errors: { code: string; message: string }[] };
expect(body.errors[0].code).toEqual("MANIFEST_INVALID");
// the union error must name the offending field rather than a bare "Invalid input"
expect(body.errors[0].message).toContain("mediaType");
});

test("HEAD /v2/:name/manifests/:reference NOT FOUND", async () => {
const response = await fetch(createRequest("GET", "/v2/notfound/manifests/reference", null));
expect(response.status).toBe(404);
Expand Down Expand Up @@ -480,6 +521,51 @@ describe("v2 manifests", () => {
});

describe("v2 referrers", () => {
test("PUT with subject and an inferred mediaType still indexes referrers", async () => {
const name = "referrers-inferred-mediatype";
const bindings = env as Env;
const subjectManifest = getImageManifestV2(await generateManifest(name));
const { sha256: subjectDigest } = await createManifest(name, subjectManifest, "latest");

const artifactManifest = {
...getImageManifestV2(await generateManifest(name)),
subject: {
mediaType: subjectManifest.mediaType,
digest: subjectDigest,
size: manifestSize(subjectManifest),
},
} satisfies ManifestSchema;
const withoutMediaType: Record<string, unknown> = { ...artifactManifest };
delete withoutMediaType.mediaType;

const data = JSON.stringify(withoutMediaType);
const artifactDigest = await getSHA256(data);
const response = await fetch(
createRequest("PUT", `/v2/${name}/manifests/${artifactDigest}`, new Blob([data]).stream(), {
"Content-Type": "application/vnd.oci.image.manifest.v1+json",
}),
);

expect(response.ok).toBeTruthy();
expect(response.headers.get("oci-subject")).toEqual(subjectDigest);

// Without substituting the inferred mediaType into the parsed manifest, this descriptor is
// written with mediaType undefined and every later read of it is silently dropped.
const expectedDescriptor = {
mediaType: "application/vnd.oci.image.manifest.v1+json",
digest: artifactDigest,
size: new Blob([data]).size,
// no explicit artifactType, so it falls back to the config mediaType
artifactType: artifactManifest.config.mediaType,
};
const descriptorObject = await bindings.REGISTRY.get(`${name}/_referrers/${subjectDigest}/${artifactDigest}`);
expect(descriptorObject).not.toBeNull();
expect(await descriptorObject?.json()).toEqual(expectedDescriptor);

const referrers = await getReferrersIndex(name, subjectDigest);
expect(referrers.body.manifests).toEqual([expectedDescriptor]);
});

test("PUT with subject indexes referrers and paginates results", async () => {
const name = "referrers-index";
const bindings = env as Env;
Expand Down