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 { // 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 { 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 { 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 { // 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 { // 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 { 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 { 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 { 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 { 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 { return this.handleYankOperation(crateName, version, token, true); } /** * Handle unyank operation */ private async handleUnyank( crateName: string, version: string, token: IAuthToken | null ): Promise { return this.handleYankOperation(crateName, version, token, false); } /** * Handle yank/unyank operation */ private async handleYankOperation( crateName: string, version: string, token: IAuthToken | null, yank: boolean ): Promise { 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 { 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(); 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 }], }; } }