Files
smartregistry/ts/cargo/classes.cargoregistry.ts

605 lines
17 KiB
TypeScript
Raw Permalink Normal View History

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 }],
};
}
}