feat(maven): Add Maven registry protocol support (storage, auth, routing, interfaces, and exports)
This commit is contained in:
604
ts/cargo/classes.cargoregistry.ts
Normal file
604
ts/cargo/classes.cargoregistry.ts
Normal file
@@ -0,0 +1,604 @@
|
||||
import { Smartlog } from '@push.rocks/smartlog';
|
||||
import { BaseRegistry } from '../core/classes.baseregistry.js';
|
||||
import { RegistryStorage } from '../core/classes.registrystorage.js';
|
||||
import { AuthManager } from '../core/classes.authmanager.js';
|
||||
import type { IRequestContext, IResponse, IAuthToken } from '../core/interfaces.core.js';
|
||||
import type {
|
||||
ICargoIndexEntry,
|
||||
ICargoPublishMetadata,
|
||||
ICargoConfig,
|
||||
ICargoError,
|
||||
ICargoPublishResponse,
|
||||
ICargoYankResponse,
|
||||
ICargoSearchResponse,
|
||||
ICargoSearchResult,
|
||||
} from './interfaces.cargo.js';
|
||||
|
||||
/**
|
||||
* Cargo/crates.io registry implementation
|
||||
* Implements the sparse HTTP-based protocol
|
||||
* Spec: https://doc.rust-lang.org/cargo/reference/registry-index.html
|
||||
*/
|
||||
export class CargoRegistry extends BaseRegistry {
|
||||
private storage: RegistryStorage;
|
||||
private authManager: AuthManager;
|
||||
private basePath: string = '/cargo';
|
||||
private registryUrl: string;
|
||||
private logger: Smartlog;
|
||||
|
||||
constructor(
|
||||
storage: RegistryStorage,
|
||||
authManager: AuthManager,
|
||||
basePath: string = '/cargo',
|
||||
registryUrl: string = 'http://localhost:5000/cargo'
|
||||
) {
|
||||
super();
|
||||
this.storage = storage;
|
||||
this.authManager = authManager;
|
||||
this.basePath = basePath;
|
||||
this.registryUrl = registryUrl;
|
||||
|
||||
// Initialize logger
|
||||
this.logger = new Smartlog({
|
||||
logContext: {
|
||||
company: 'push.rocks',
|
||||
companyunit: 'smartregistry',
|
||||
containerName: 'cargo-registry',
|
||||
environment: (process.env.NODE_ENV as any) || 'development',
|
||||
runtime: 'node',
|
||||
zone: 'cargo'
|
||||
}
|
||||
});
|
||||
this.logger.enableConsole();
|
||||
}
|
||||
|
||||
public async init(): Promise<void> {
|
||||
// Initialize config.json if not exists
|
||||
const existingConfig = await this.storage.getCargoConfig();
|
||||
if (!existingConfig) {
|
||||
const config: ICargoConfig = {
|
||||
dl: `${this.registryUrl}/api/v1/crates/{crate}/{version}/download`,
|
||||
api: this.registryUrl,
|
||||
};
|
||||
await this.storage.putCargoConfig(config);
|
||||
this.logger.log('info', 'Initialized Cargo registry config', { config });
|
||||
}
|
||||
}
|
||||
|
||||
public getBasePath(): string {
|
||||
return this.basePath;
|
||||
}
|
||||
|
||||
public async handleRequest(context: IRequestContext): Promise<IResponse> {
|
||||
const path = context.path.replace(this.basePath, '');
|
||||
|
||||
// Extract token (Cargo uses Authorization header WITHOUT "Bearer" prefix)
|
||||
const authHeader = context.headers['authorization'] || context.headers['Authorization'];
|
||||
const token = authHeader ? await this.authManager.validateToken(authHeader, 'cargo') : null;
|
||||
|
||||
this.logger.log('debug', `handleRequest: ${context.method} ${path}`, {
|
||||
method: context.method,
|
||||
path,
|
||||
hasAuth: !!token
|
||||
});
|
||||
|
||||
// Config endpoint (required for sparse protocol)
|
||||
if (path === '/config.json') {
|
||||
return this.handleConfigJson();
|
||||
}
|
||||
|
||||
// API endpoints
|
||||
if (path.startsWith('/api/v1/')) {
|
||||
return this.handleApiRequest(path, context, token);
|
||||
}
|
||||
|
||||
// Index files (sparse protocol)
|
||||
return this.handleIndexRequest(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token has permission for resource
|
||||
*/
|
||||
protected async checkPermission(
|
||||
token: IAuthToken | null,
|
||||
resource: string,
|
||||
action: string
|
||||
): Promise<boolean> {
|
||||
if (!token) return false;
|
||||
return this.authManager.authorize(token, `cargo:crate:${resource}`, action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle API requests (/api/v1/*)
|
||||
*/
|
||||
private async handleApiRequest(
|
||||
path: string,
|
||||
context: IRequestContext,
|
||||
token: IAuthToken | null
|
||||
): Promise<IResponse> {
|
||||
// Publish: PUT /api/v1/crates/new
|
||||
if (path === '/api/v1/crates/new' && context.method === 'PUT') {
|
||||
return this.handlePublish(context.body as Buffer, token);
|
||||
}
|
||||
|
||||
// Download: GET /api/v1/crates/{crate}/{version}/download
|
||||
const downloadMatch = path.match(/^\/api\/v1\/crates\/([^\/]+)\/([^\/]+)\/download$/);
|
||||
if (downloadMatch && context.method === 'GET') {
|
||||
return this.handleDownload(downloadMatch[1], downloadMatch[2]);
|
||||
}
|
||||
|
||||
// Yank: DELETE /api/v1/crates/{crate}/{version}/yank
|
||||
const yankMatch = path.match(/^\/api\/v1\/crates\/([^\/]+)\/([^\/]+)\/yank$/);
|
||||
if (yankMatch && context.method === 'DELETE') {
|
||||
return this.handleYank(yankMatch[1], yankMatch[2], token);
|
||||
}
|
||||
|
||||
// Unyank: PUT /api/v1/crates/{crate}/{version}/unyank
|
||||
const unyankMatch = path.match(/^\/api\/v1\/crates\/([^\/]+)\/([^\/]+)\/unyank$/);
|
||||
if (unyankMatch && context.method === 'PUT') {
|
||||
return this.handleUnyank(unyankMatch[1], unyankMatch[2], token);
|
||||
}
|
||||
|
||||
// Search: GET /api/v1/crates?q={query}
|
||||
if (path.startsWith('/api/v1/crates') && context.method === 'GET') {
|
||||
const query = context.query?.q || '';
|
||||
const perPage = parseInt(context.query?.per_page || '10', 10);
|
||||
return this.handleSearch(query, perPage);
|
||||
}
|
||||
|
||||
return {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: this.createError('API endpoint not found'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle index file requests
|
||||
* Paths: /1/{name}, /2/{name}, /3/{c}/{name}, /{p1}/{p2}/{name}
|
||||
*/
|
||||
private async handleIndexRequest(path: string): Promise<IResponse> {
|
||||
// Parse index paths to extract crate name
|
||||
const pathParts = path.split('/').filter(p => p);
|
||||
let crateName: string | null = null;
|
||||
|
||||
if (pathParts.length === 2 && pathParts[0] === '1') {
|
||||
// 1-character names: /1/{name}
|
||||
crateName = pathParts[1];
|
||||
} else if (pathParts.length === 2 && pathParts[0] === '2') {
|
||||
// 2-character names: /2/{name}
|
||||
crateName = pathParts[1];
|
||||
} else if (pathParts.length === 3 && pathParts[0] === '3') {
|
||||
// 3-character names: /3/{c}/{name}
|
||||
crateName = pathParts[2];
|
||||
} else if (pathParts.length === 3) {
|
||||
// 4+ character names: /{p1}/{p2}/{name}
|
||||
crateName = pathParts[2];
|
||||
}
|
||||
|
||||
if (!crateName) {
|
||||
return {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: Buffer.from(''),
|
||||
};
|
||||
}
|
||||
|
||||
return this.handleIndexFile(crateName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve config.json
|
||||
*/
|
||||
private async handleConfigJson(): Promise<IResponse> {
|
||||
const config = await this.storage.getCargoConfig();
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: config || {
|
||||
dl: `${this.registryUrl}/api/v1/crates/{crate}/{version}/download`,
|
||||
api: this.registryUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve index file for a crate
|
||||
*/
|
||||
private async handleIndexFile(crateName: string): Promise<IResponse> {
|
||||
const index = await this.storage.getCargoIndex(crateName);
|
||||
|
||||
if (!index || index.length === 0) {
|
||||
return {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: Buffer.from(''),
|
||||
};
|
||||
}
|
||||
|
||||
// Return newline-delimited JSON
|
||||
const data = index.map(e => JSON.stringify(e)).join('\n') + '\n';
|
||||
|
||||
// Calculate ETag for caching
|
||||
const crypto = await import('crypto');
|
||||
const etag = `"${crypto.createHash('sha256').update(data).digest('hex')}"`;
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
'ETag': etag,
|
||||
},
|
||||
body: Buffer.from(data, 'utf-8'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse binary publish request
|
||||
* Format: [4 bytes JSON len][JSON][4 bytes crate len][.crate file]
|
||||
*/
|
||||
private parsePublishRequest(body: Buffer): {
|
||||
metadata: ICargoPublishMetadata;
|
||||
crateFile: Buffer;
|
||||
} {
|
||||
let offset = 0;
|
||||
|
||||
// Read JSON length (4 bytes, u32 little-endian)
|
||||
if (body.length < 4) {
|
||||
throw new Error('Invalid publish request: body too short');
|
||||
}
|
||||
const jsonLength = body.readUInt32LE(offset);
|
||||
offset += 4;
|
||||
|
||||
// Read JSON metadata
|
||||
if (body.length < offset + jsonLength) {
|
||||
throw new Error('Invalid publish request: JSON data incomplete');
|
||||
}
|
||||
const jsonBuffer = body.slice(offset, offset + jsonLength);
|
||||
const metadata = JSON.parse(jsonBuffer.toString('utf-8'));
|
||||
offset += jsonLength;
|
||||
|
||||
// Read crate file length (4 bytes, u32 little-endian)
|
||||
if (body.length < offset + 4) {
|
||||
throw new Error('Invalid publish request: crate length missing');
|
||||
}
|
||||
const crateLength = body.readUInt32LE(offset);
|
||||
offset += 4;
|
||||
|
||||
// Read crate file
|
||||
if (body.length < offset + crateLength) {
|
||||
throw new Error('Invalid publish request: crate data incomplete');
|
||||
}
|
||||
const crateFile = body.slice(offset, offset + crateLength);
|
||||
|
||||
return { metadata, crateFile };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle crate publish
|
||||
*/
|
||||
private async handlePublish(
|
||||
body: Buffer,
|
||||
token: IAuthToken | null
|
||||
): Promise<IResponse> {
|
||||
this.logger.log('info', 'handlePublish: received publish request', {
|
||||
bodyLength: body?.length || 0,
|
||||
hasAuth: !!token
|
||||
});
|
||||
|
||||
// Check authorization
|
||||
if (!token) {
|
||||
return {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: this.createError('Authentication required'),
|
||||
};
|
||||
}
|
||||
|
||||
// Parse binary request
|
||||
let metadata: ICargoPublishMetadata;
|
||||
let crateFile: Buffer;
|
||||
try {
|
||||
const parsed = this.parsePublishRequest(body);
|
||||
metadata = parsed.metadata;
|
||||
crateFile = parsed.crateFile;
|
||||
} catch (error) {
|
||||
this.logger.log('error', 'handlePublish: parse error', { error: error.message });
|
||||
return {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: this.createError(`Invalid request format: ${error.message}`),
|
||||
};
|
||||
}
|
||||
|
||||
// Validate crate name
|
||||
if (!this.validateCrateName(metadata.name)) {
|
||||
return {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: this.createError('Invalid crate name'),
|
||||
};
|
||||
}
|
||||
|
||||
// Check permission
|
||||
const hasPermission = await this.checkPermission(token, metadata.name, 'write');
|
||||
if (!hasPermission) {
|
||||
this.logger.log('warn', 'handlePublish: unauthorized', {
|
||||
crateName: metadata.name,
|
||||
userId: token.userId
|
||||
});
|
||||
return {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: this.createError('Insufficient permissions'),
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate SHA256 checksum
|
||||
const crypto = await import('crypto');
|
||||
const cksum = crypto.createHash('sha256').update(crateFile).digest('hex');
|
||||
|
||||
// Create index entry
|
||||
const indexEntry: ICargoIndexEntry = {
|
||||
name: metadata.name,
|
||||
vers: metadata.vers,
|
||||
deps: metadata.deps,
|
||||
cksum,
|
||||
features: metadata.features,
|
||||
yanked: false,
|
||||
links: metadata.links || null,
|
||||
v: 2,
|
||||
rust_version: metadata.rust_version,
|
||||
};
|
||||
|
||||
// Check for duplicate version
|
||||
const existingIndex = await this.storage.getCargoIndex(metadata.name) || [];
|
||||
if (existingIndex.some(e => e.vers === metadata.vers)) {
|
||||
return {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: this.createError(`Version ${metadata.vers} already exists`),
|
||||
};
|
||||
}
|
||||
|
||||
// Store crate file
|
||||
await this.storage.putCargoCrate(metadata.name, metadata.vers, crateFile);
|
||||
|
||||
// Update index (append new version)
|
||||
existingIndex.push(indexEntry);
|
||||
await this.storage.putCargoIndex(metadata.name, existingIndex);
|
||||
|
||||
this.logger.log('success', 'handlePublish: published crate', {
|
||||
name: metadata.name,
|
||||
version: metadata.vers,
|
||||
checksum: cksum
|
||||
});
|
||||
|
||||
const response: ICargoPublishResponse = {
|
||||
warnings: {
|
||||
invalid_categories: [],
|
||||
invalid_badges: [],
|
||||
other: [],
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: response,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle crate download
|
||||
*/
|
||||
private async handleDownload(
|
||||
crateName: string,
|
||||
version: string
|
||||
): Promise<IResponse> {
|
||||
this.logger.log('debug', 'handleDownload', { crate: crateName, version });
|
||||
|
||||
const crateFile = await this.storage.getCargoCrate(crateName, version);
|
||||
|
||||
if (!crateFile) {
|
||||
return {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: this.createError('Crate not found'),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/gzip',
|
||||
'Content-Length': crateFile.length.toString(),
|
||||
'Content-Disposition': `attachment; filename="${crateName}-${version}.crate"`,
|
||||
},
|
||||
body: crateFile,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle yank operation
|
||||
*/
|
||||
private async handleYank(
|
||||
crateName: string,
|
||||
version: string,
|
||||
token: IAuthToken | null
|
||||
): Promise<IResponse> {
|
||||
return this.handleYankOperation(crateName, version, token, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle unyank operation
|
||||
*/
|
||||
private async handleUnyank(
|
||||
crateName: string,
|
||||
version: string,
|
||||
token: IAuthToken | null
|
||||
): Promise<IResponse> {
|
||||
return this.handleYankOperation(crateName, version, token, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle yank/unyank operation
|
||||
*/
|
||||
private async handleYankOperation(
|
||||
crateName: string,
|
||||
version: string,
|
||||
token: IAuthToken | null,
|
||||
yank: boolean
|
||||
): Promise<IResponse> {
|
||||
this.logger.log('info', `handle${yank ? 'Yank' : 'Unyank'}`, {
|
||||
crate: crateName,
|
||||
version,
|
||||
hasAuth: !!token
|
||||
});
|
||||
|
||||
// Check authorization
|
||||
if (!token) {
|
||||
return {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: this.createError('Authentication required'),
|
||||
};
|
||||
}
|
||||
|
||||
// Check permission
|
||||
const hasPermission = await this.checkPermission(token, crateName, 'write');
|
||||
if (!hasPermission) {
|
||||
return {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: this.createError('Insufficient permissions'),
|
||||
};
|
||||
}
|
||||
|
||||
// Load index
|
||||
const index = await this.storage.getCargoIndex(crateName);
|
||||
if (!index) {
|
||||
return {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: this.createError('Crate not found'),
|
||||
};
|
||||
}
|
||||
|
||||
// Find version
|
||||
const entry = index.find(e => e.vers === version);
|
||||
if (!entry) {
|
||||
return {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: this.createError('Version not found'),
|
||||
};
|
||||
}
|
||||
|
||||
// Update yank status
|
||||
entry.yanked = yank;
|
||||
|
||||
// Save index (NOTE: do NOT delete .crate file)
|
||||
await this.storage.putCargoIndex(crateName, index);
|
||||
|
||||
this.logger.log('success', `${yank ? 'Yanked' : 'Unyanked'} version`, {
|
||||
crate: crateName,
|
||||
version
|
||||
});
|
||||
|
||||
const response: ICargoYankResponse = { ok: true };
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: response,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle search
|
||||
*/
|
||||
private async handleSearch(query: string, perPage: number): Promise<IResponse> {
|
||||
this.logger.log('debug', 'handleSearch', { query, perPage });
|
||||
|
||||
const results: ICargoSearchResult[] = [];
|
||||
|
||||
try {
|
||||
// List all index paths
|
||||
const indexPaths = await this.storage.listObjects('cargo/index/');
|
||||
|
||||
// Extract unique crate names
|
||||
const crateNames = new Set<string>();
|
||||
for (const path of indexPaths) {
|
||||
// Parse path to extract crate name
|
||||
const parts = path.split('/');
|
||||
if (parts.length >= 3) {
|
||||
const name = parts[parts.length - 1];
|
||||
if (name && !name.includes('.')) {
|
||||
crateNames.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log('debug', `handleSearch: found ${crateNames.size} crates`, {
|
||||
totalCrates: crateNames.size
|
||||
});
|
||||
|
||||
// Filter and process matching crates
|
||||
for (const name of crateNames) {
|
||||
if (!query || name.toLowerCase().includes(query.toLowerCase())) {
|
||||
const index = await this.storage.getCargoIndex(name);
|
||||
if (index && index.length > 0) {
|
||||
// Find latest non-yanked version
|
||||
const nonYanked = index.filter(e => !e.yanked);
|
||||
if (nonYanked.length > 0) {
|
||||
// Sort by version (simplified - should use semver)
|
||||
const sorted = [...nonYanked].sort((a, b) => b.vers.localeCompare(a.vers));
|
||||
|
||||
results.push({
|
||||
name: sorted[0].name,
|
||||
max_version: sorted[0].vers,
|
||||
description: '', // Would need to store separately
|
||||
});
|
||||
|
||||
if (results.length >= perPage) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.log('error', 'handleSearch: error', { error: error.message });
|
||||
}
|
||||
|
||||
const response: ICargoSearchResponse = {
|
||||
crates: results,
|
||||
meta: {
|
||||
total: results.length,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: response,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate crate name
|
||||
* Rules: lowercase alphanumeric + _ and -, length 1-64
|
||||
*/
|
||||
private validateCrateName(name: string): boolean {
|
||||
return /^[a-z0-9_-]+$/.test(name) && name.length >= 1 && name.length <= 64;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create error response
|
||||
*/
|
||||
private createError(detail: string): ICargoError {
|
||||
return {
|
||||
errors: [{ detail }],
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user