import * as crypto from 'node:crypto'; import * as http from 'node:http'; import type { AddressInfo } from 'node:net'; import { tap, expect } from '@git.zone/tstest/tapbundle'; import { RegistryCopy } from './ts/classes.registrycopy.js'; const chunkSize = 32 * 1024 * 1024; const createDigest = (data: Buffer): string => { return `sha256:${crypto.createHash('sha256').update(data).digest('hex')}`; }; const readRequestBody = async (request: http.IncomingMessage): Promise => { const chunks: Buffer[] = []; for await (const chunk of request) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } return Buffer.concat(chunks); }; const startServer = async ( handler: http.RequestListener, ): Promise<{ server: http.Server; registry: string }> => { const server = http.createServer(handler); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const address = server.address() as AddressInfo; return { server, registry: `127.0.0.1:${address.port}` }; }; const closeServer = async (server: http.Server): Promise => { await new Promise((resolve, reject) => { server.close((error) => error ? reject(error) : resolve()); }); }; tap.test('should include Content-Range for multi-patch Gitea uploads', async () => { const configBlob = Buffer.from(JSON.stringify({ created: '2026-06-02T00:00:00.000Z' })); const configDigest = createDigest(configBlob); const layerBlob = Buffer.alloc(chunkSize + 7, 42); const layerDigest = createDigest(layerBlob); const manifest = { schemaVersion: 2, mediaType: 'application/vnd.oci.image.manifest.v1+json', config: { mediaType: 'application/vnd.oci.image.config.v1+json', digest: configDigest, size: configBlob.length, }, layers: [ { mediaType: 'application/vnd.oci.image.layer.v1.tar+gzip', digest: layerDigest, size: layerBlob.length, }, ], }; const manifestRaw = Buffer.from(JSON.stringify(manifest)); const manifestDigest = createDigest(manifestRaw); const source = await startServer(async (request, response) => { const url = new URL(request.url || '/', 'http://127.0.0.1'); if (url.pathname === '/v2/') { response.writeHead(200, { 'Docker-Distribution-Api-Version': 'registry/2.0' }); response.end(); return; } if (request.method === 'GET' && url.pathname === '/v2/src/manifests/latest') { response.writeHead(200, { 'Content-Type': 'application/vnd.oci.image.manifest.v1+json', 'Docker-Content-Digest': manifestDigest, }); response.end(manifestRaw); return; } if (request.method === 'GET' && url.pathname.startsWith('/v2/src/blobs/')) { const digest = url.pathname.substring('/v2/src/blobs/'.length); const body = digest === configDigest ? configBlob : digest === layerDigest ? layerBlob : null; if (!body) { response.writeHead(404); response.end(); return; } response.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Length': String(body.length), 'Docker-Content-Digest': digest, }); response.end(body); return; } response.writeHead(404); response.end(); }); const uploadSessions = new Map(); const blobRanges = new Map(); const storedBlobs = new Set(); let uploadIndex = 0; const destination = await startServer(async (request, response) => { const url = new URL(request.url || '/', 'http://127.0.0.1'); if (url.pathname === '/v2/') { response.writeHead(200, { 'Docker-Distribution-Api-Version': 'registry/2.0' }); response.end(); return; } if (request.method === 'HEAD' && url.pathname.startsWith('/v2/dest/blobs/')) { const digest = url.pathname.substring('/v2/dest/blobs/'.length); response.writeHead(storedBlobs.has(digest) ? 200 : 404, storedBlobs.has(digest) ? { 'Docker-Content-Digest': digest, } : {}); response.end(); return; } if (request.method === 'POST' && url.pathname === '/v2/dest/blobs/uploads/') { const uploadId = `upload-${++uploadIndex}`; uploadSessions.set(uploadId, { size: 0, chunks: [], ranges: [] }); response.writeHead(202, { Location: `/v2/dest/blobs/uploads/${uploadId}`, 'Docker-Upload-UUID': uploadId, }); response.end(); return; } if (url.pathname.startsWith('/v2/dest/blobs/uploads/')) { const uploadId = url.pathname.substring('/v2/dest/blobs/uploads/'.length); const upload = uploadSessions.get(uploadId); if (!upload) { response.writeHead(404); response.end(); return; } if (request.method === 'GET') { response.writeHead(204, { Location: `/v2/dest/blobs/uploads/${uploadId}`, Range: upload.size > 0 ? `0-${upload.size - 1}` : '0-0', 'Docker-Upload-UUID': uploadId, }); response.end(); return; } if (request.method === 'PATCH') { const body = await readRequestBody(request); const contentRange = request.headers['content-range']; if (!contentRange && upload.size !== 0) { response.writeHead(400, { 'Content-Type': 'application/json' }); response.end(JSON.stringify({ errors: [ { code: 'BLOB_UPLOAD_INVALID', message: 'Stream uploads after first write are not allowed', }, ], })); return; } if (typeof contentRange === 'string') { const rangeMatch = contentRange.match(/^(\d+)-(\d+)$/); const start = rangeMatch ? Number(rangeMatch[1]) : NaN; const end = rangeMatch ? Number(rangeMatch[2]) : NaN; if (start !== upload.size || end !== upload.size + body.length - 1) { response.writeHead(416, { Location: `/v2/dest/blobs/uploads/${uploadId}`, Range: upload.size > 0 ? `0-${upload.size - 1}` : '0-0', }); response.end(); return; } upload.ranges.push(contentRange); } upload.chunks.push(body); upload.size += body.length; response.writeHead(202, { Location: `/v2/dest/blobs/uploads/${uploadId}`, Range: `0-${upload.size - 1}`, 'Docker-Upload-UUID': uploadId, }); response.end(); return; } if (request.method === 'PUT') { const digest = url.searchParams.get('digest') || ''; const finalBody = await readRequestBody(request); if (finalBody.length > 0) { upload.chunks.push(finalBody); upload.size += finalBody.length; } const blob = Buffer.concat(upload.chunks); if (createDigest(blob) !== digest) { response.writeHead(400, { 'Content-Type': 'application/json' }); response.end(JSON.stringify({ errors: [{ code: 'DIGEST_INVALID', message: 'Digest mismatch' }] })); return; } storedBlobs.add(digest); blobRanges.set(digest, [...upload.ranges]); uploadSessions.delete(uploadId); response.writeHead(201, { Location: `/v2/dest/blobs/${digest}`, 'Docker-Content-Digest': digest, }); response.end(); return; } if (request.method === 'DELETE') { uploadSessions.delete(uploadId); response.writeHead(204); response.end(); return; } } if (request.method === 'PUT' && url.pathname === '/v2/dest/manifests/latest') { await readRequestBody(request); response.writeHead(201, { 'Docker-Content-Digest': manifestDigest }); response.end(); return; } response.writeHead(404); response.end(); }); try { const registryCopy = new RegistryCopy(); await registryCopy.copyImage(source.registry, 'src', 'latest', destination.registry, 'dest', 'latest'); expect(blobRanges.get(layerDigest)).toEqual([ `0-${chunkSize - 1}`, `${chunkSize}-${chunkSize + 6}`, ]); } finally { await closeServer(source.server); await closeServer(destination.server); } }); export default tap.start();