/** * Lexicon for OCI image repositories stored natively in an atproto repo. * * One record per image repository (rkey = encoded repository name). The * record holds the tag list and a blob reference for every manifest, config * and layer, which keeps those blobs out of orphan cleanup. Manifest and * layer bytes live in PDS blob storage. A blob's CID and its OCI digest * carry the same SHA-256 bytes, so content addressing needs no extra index. */ export const OCI_REPO_COLLECTION = 'dev.pdsjs.oci.repo'; /** * Repository name grammar from the OCI distribution spec: lowercase * components joined by `.`, `_`, `__` or runs of `-`, with `/` between * path components. */ const NAME_COMPONENT = /^[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*$/; const MAX_NAME_LENGTH = 255; /** * @param {string} name * @returns {boolean} */ export function isValidName(name) { if (!name || name.length > MAX_NAME_LENGTH) return false; return name.split('/').every((part) => NAME_COMPONENT.test(part)); } /** Tag grammar from the OCI distribution spec. */ const TAG_PATTERN = /^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$/; /** * @param {string} tag * @returns {boolean} */ export function isValidTag(tag) { return TAG_PATTERN.test(tag); } /** * A repository name may hold `/`, which a record key may not. `:` is a * valid record-key character that no repository name contains, so the two * substitute cleanly in both directions. * @param {string} name * @returns {string} */ export function nameToRkey(name) { return name.replaceAll('/', ':'); } /** * @param {string} rkey * @returns {string} */ export function rkeyToName(rkey) { return rkey.replaceAll(':', '/'); } export const ociRepoLexicon = { lexicon: 1, id: OCI_REPO_COLLECTION, defs: { main: { type: 'record', description: 'An OCI image repository: its tags plus a blob reference for every stored manifest, config and layer. A blob CID and an OCI sha256 digest hold the same hash bytes.', key: 'any', record: { type: 'object', required: ['name', 'tags', 'manifests', 'layers', 'createdAt'], properties: { name: { type: 'string', maxLength: 512, description: 'Repository name. The record key encodes this name with `/` replaced by `:`.', }, description: { type: 'string', maxLength: 3000 }, tags: { type: 'array', items: { type: 'ref', ref: '#tag' }, }, manifests: { type: 'array', description: 'Every stored manifest, tagged or not.', items: { type: 'ref', ref: '#manifest' }, }, layers: { type: 'array', description: 'Config and layer blobs the stored manifests reference.', items: { type: 'ref', ref: '#layer' }, }, createdAt: { type: 'string', format: 'datetime' }, updatedAt: { type: 'string', format: 'datetime' }, }, }, }, tag: { type: 'object', required: ['tag', 'digest'], properties: { tag: { type: 'string', maxLength: 128, description: 'Tag name, e.g. latest.', }, digest: { type: 'string', maxLength: 128, description: 'Digest of the manifest the tag points at.', }, }, }, manifest: { type: 'object', required: ['digest', 'mediaType', 'blob'], properties: { digest: { type: 'string', maxLength: 128 }, mediaType: { type: 'string', maxLength: 256, description: 'Exact media type the manifest is served with, e.g. application/vnd.oci.image.manifest.v1+json.', }, blob: { type: 'blob', description: 'The manifest bytes, exactly as pushed.', }, }, }, layer: { type: 'object', required: ['digest', 'blob'], properties: { digest: { type: 'string', maxLength: 128 }, blob: { type: 'blob', description: 'Config or layer bytes.', }, }, }, }, };