/** * Manifest parsing: what a pushed manifest references, so the record can * keep those blobs and the server can refuse a manifest whose parts are * missing. The bytes themselves are stored untouched; a digest covers the * exact serialization. */ export const MEDIA_TYPES = { dockerManifest: 'application/vnd.docker.distribution.manifest.v2+json', dockerManifestList: 'application/vnd.docker.distribution.manifest.list.v2+json', ociManifest: 'application/vnd.oci.image.manifest.v1+json', ociIndex: 'application/vnd.oci.image.index.v1+json', }; const INDEX_TYPES = new Set([ MEDIA_TYPES.dockerManifestList, MEDIA_TYPES.ociIndex, ]); /** Largest manifest accepted, in bytes. Real manifests are a few KB. */ export const MAX_MANIFEST_SIZE = 4 * 1024 * 1024; /** * @typedef {Object} ManifestDescriptor * @property {string} digest * @property {string} [mediaType] * @property {number} [size] * @property {string[]} [urls] - locations outside this registry; such a * layer is not stored here and existence checks skip it */ /** * @typedef {Object} ParsedManifest * @property {string} mediaType - from the body, else the Content-Type header * @property {boolean} isIndex * @property {ManifestDescriptor[]} blobs - config and layers an image * manifest references * @property {ManifestDescriptor[]} children - manifests an index references */ /** * @param {unknown} value * @returns {ManifestDescriptor|null} */ function asDescriptor(value) { if (!value || typeof value !== 'object') return null; const entry = /** @type {Record} */ (value); if (typeof entry.digest !== 'string') return null; return { digest: entry.digest, mediaType: typeof entry.mediaType === 'string' ? entry.mediaType : undefined, size: typeof entry.size === 'number' ? entry.size : undefined, urls: Array.isArray(entry.urls) ? entry.urls.filter((u) => typeof u === 'string') : undefined, }; } /** * @param {Uint8Array} bytes - manifest body, exactly as pushed * @param {string} contentType - Content-Type header from the push * @returns {ParsedManifest} */ export function parseManifest(bytes, contentType) { /** @type {Record} */ let body; try { body = JSON.parse(new TextDecoder().decode(bytes)); } catch { throw new Error('manifest is not valid JSON'); } if (!body || typeof body !== 'object' || Array.isArray(body)) { throw new Error('manifest is not a JSON object'); } const declaredType = typeof body.mediaType === 'string' ? body.mediaType : null; const headerType = contentType.split(';')[0].trim(); const mediaType = declaredType ?? headerType; if (declaredType && headerType && declaredType !== headerType) { throw new Error( `manifest mediaType ${declaredType} does not match Content-Type ${headerType}`, ); } const isIndex = INDEX_TYPES.has(mediaType) || Array.isArray(body.manifests); if (isIndex) { const children = (Array.isArray(body.manifests) ? body.manifests : []) .map(asDescriptor) .filter((d) => d !== null); return { mediaType, isIndex, blobs: [], children }; } /** @type {ManifestDescriptor[]} */ const blobs = []; const config = asDescriptor(body.config); if (config) blobs.push(config); for (const layer of Array.isArray(body.layers) ? body.layers : []) { const descriptor = asDescriptor(layer); if (descriptor) blobs.push(descriptor); } return { mediaType, isIndex, blobs, children: [] }; }