605 lines
17 KiB
TypeScript
605 lines
17 KiB
TypeScript
|
|
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 }],
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|