feat(core/registrystorage): Persist OCI manifest content-type in sidecar and normalize manifest body handling

This commit is contained in:
2025-11-25 22:10:06 +00:00
parent 67188a4e9f
commit 41405eb40a
5 changed files with 511 additions and 22 deletions

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartregistry',
version: '2.1.2',
version: '2.2.0',
description: 'A composable TypeScript library implementing OCI, NPM, Maven, Cargo, Composer, PyPI, and RubyGems registries for building unified container and package registries'
}

View File

@@ -129,7 +129,7 @@ export class RegistryStorage implements IStorageBackend {
}
/**
* Get OCI manifest
* Get OCI manifest and its content type
*/
public async getOciManifest(repository: string, digest: string): Promise<Buffer | null> {
const path = this.getOciManifestPath(repository, digest);
@@ -137,7 +137,17 @@ export class RegistryStorage implements IStorageBackend {
}
/**
* Store OCI manifest
* Get OCI manifest content type
* Returns the stored content type or null if not found
*/
public async getOciManifestContentType(repository: string, digest: string): Promise<string | null> {
const typePath = this.getOciManifestPath(repository, digest) + '.type';
const data = await this.getObject(typePath);
return data ? data.toString('utf-8') : null;
}
/**
* Store OCI manifest with its content type
*/
public async putOciManifest(
repository: string,
@@ -146,7 +156,11 @@ export class RegistryStorage implements IStorageBackend {
contentType: string
): Promise<void> {
const path = this.getOciManifestPath(repository, digest);
return this.putObject(path, data, { 'Content-Type': contentType });
// Store manifest data
await this.putObject(path, data, { 'Content-Type': contentType });
// Store content type in sidecar file for later retrieval
const typePath = path + '.type';
await this.putObject(typePath, Buffer.from(contentType, 'utf-8'));
}
/**

View File

@@ -198,7 +198,7 @@ export class OciRegistry extends BaseRegistry {
const digest = query.digest;
if (digest && body) {
// Monolithic upload: complete upload in single POST
const blobData = Buffer.isBuffer(body) ? body : Buffer.from(JSON.stringify(body));
const blobData = this.toBuffer(body);
// Verify digest
const calculatedDigest = await this.calculateDigest(blobData);
@@ -320,10 +320,17 @@ export class OciRegistry extends BaseRegistry {
};
}
// Get stored content type, falling back to detecting from manifest content
let contentType = await this.storage.getOciManifestContentType(repository, digest);
if (!contentType) {
// Fallback: detect content type from manifest content
contentType = this.detectManifestContentType(manifestData);
}
return {
status: 200,
headers: {
'Content-Type': 'application/vnd.oci.image.manifest.v1+json',
'Content-Type': contentType,
'Docker-Content-Digest': digest,
},
body: manifestData,
@@ -356,10 +363,18 @@ export class OciRegistry extends BaseRegistry {
const manifestData = await this.storage.getOciManifest(repository, digest);
// Get stored content type, falling back to detecting from manifest content
let contentType = await this.storage.getOciManifestContentType(repository, digest);
if (!contentType && manifestData) {
// Fallback: detect content type from manifest content
contentType = this.detectManifestContentType(manifestData);
}
contentType = contentType || 'application/vnd.oci.image.manifest.v1+json';
return {
status: 200,
headers: {
'Content-Type': 'application/vnd.oci.image.manifest.v1+json',
'Content-Type': contentType,
'Docker-Content-Digest': digest,
'Content-Length': manifestData ? manifestData.length.toString() : '0',
},
@@ -388,16 +403,7 @@ export class OciRegistry extends BaseRegistry {
// Preserve raw bytes for accurate digest calculation
// Per OCI spec, digest must match the exact bytes sent by client
let manifestData: Buffer;
if (Buffer.isBuffer(body)) {
manifestData = body;
} else if (typeof body === 'string') {
// String body - convert directly without JSON transformation
manifestData = Buffer.from(body, 'utf-8');
} else {
// Body was already parsed as JSON object - re-serialize as fallback
manifestData = Buffer.from(JSON.stringify(body));
}
const manifestData = this.toBuffer(body);
const contentType = headers?.['content-type'] || headers?.['Content-Type'] || 'application/vnd.oci.image.manifest.v1+json';
// Calculate manifest digest
@@ -525,7 +531,7 @@ export class OciRegistry extends BaseRegistry {
private async uploadChunk(
uploadId: string,
data: Buffer,
data: Buffer | Uint8Array | unknown,
contentRange: string
): Promise<IResponse> {
const session = this.uploadSessions.get(uploadId);
@@ -537,8 +543,9 @@ export class OciRegistry extends BaseRegistry {
};
}
session.chunks.push(data);
session.totalSize += data.length;
const chunkData = this.toBuffer(data);
session.chunks.push(chunkData);
session.totalSize += chunkData.length;
session.lastActivity = new Date();
return {
@@ -555,7 +562,7 @@ export class OciRegistry extends BaseRegistry {
private async completeUpload(
uploadId: string,
digest: string,
finalData?: Buffer
finalData?: Buffer | Uint8Array | unknown
): Promise<IResponse> {
const session = this.uploadSessions.get(uploadId);
if (!session) {
@@ -567,7 +574,7 @@ export class OciRegistry extends BaseRegistry {
}
const chunks = [...session.chunks];
if (finalData) chunks.push(finalData);
if (finalData) chunks.push(this.toBuffer(finalData));
const blobData = Buffer.concat(chunks);
// Verify digest
@@ -665,6 +672,59 @@ export class OciRegistry extends BaseRegistry {
// HELPER METHODS
// ========================================================================
/**
* Detect manifest content type from manifest content.
* OCI Image Index has "manifests" array, OCI Image Manifest has "config" object.
* Also checks the mediaType field if present.
*/
private detectManifestContentType(manifestData: Buffer): string {
try {
const manifest = JSON.parse(manifestData.toString('utf-8'));
// First check if manifest has explicit mediaType field
if (manifest.mediaType) {
return manifest.mediaType;
}
// Otherwise detect from structure
if (Array.isArray(manifest.manifests)) {
// OCI Image Index (multi-arch manifest list)
return 'application/vnd.oci.image.index.v1+json';
} else if (manifest.config) {
// OCI Image Manifest
return 'application/vnd.oci.image.manifest.v1+json';
}
// Fallback to standard manifest type
return 'application/vnd.oci.image.manifest.v1+json';
} catch (e) {
// If parsing fails, return default
return 'application/vnd.oci.image.manifest.v1+json';
}
}
/**
* Convert any binary-like data to Buffer.
* Handles Buffer, Uint8Array (modern cross-platform), string, and objects.
*
* Note: Buffer.isBuffer(Uint8Array) returns false even though Buffer extends Uint8Array.
* This is because Uint8Array is the modern, cross-platform standard while Buffer is Node.js-specific.
* Many HTTP frameworks pass request bodies as Uint8Array for better compatibility.
*/
private toBuffer(data: unknown): Buffer {
if (Buffer.isBuffer(data)) {
return data;
}
if (data instanceof Uint8Array) {
return Buffer.from(data);
}
if (typeof data === 'string') {
return Buffer.from(data, 'utf-8');
}
// Fallback: serialize object to JSON (may cause digest mismatch for manifests)
return Buffer.from(JSON.stringify(data));
}
private async getTagsData(repository: string): Promise<Record<string, string>> {
const path = `oci/tags/${repository}/tags.json`;
const data = await this.storage.getObject(path);