255 lines
8.3 KiB
TypeScript
255 lines
8.3 KiB
TypeScript
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<Buffer> => {
|
|
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<void>((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<void> => {
|
|
await new Promise<void>((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<string, { size: number; chunks: Buffer[]; ranges: string[] }>();
|
|
const blobRanges = new Map<string, string[]>();
|
|
const storedBlobs = new Set<string>();
|
|
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();
|