feat(auth): Implement HMAC-SHA256 OCI JWTs; enhance PyPI & RubyGems uploads and normalize responses

This commit is contained in:
2025-11-25 14:28:19 +00:00
parent 2d6059ba7f
commit 547c262578
14 changed files with 765 additions and 158 deletions

View File

@@ -106,7 +106,7 @@ export class RubyGemsRegistry extends BaseRegistry {
// API v1 endpoints
if (path.startsWith('/api/v1/')) {
return this.handleApiRequest(path.substring(8), context, token);
return this.handleApiRequest(path.substring(7), context, token);
}
return {
@@ -289,14 +289,22 @@ export class RubyGemsRegistry extends BaseRegistry {
return this.errorResponse(400, 'No gem file provided');
}
// For now, we expect metadata in query params or headers
// Full implementation would parse .gem file (tar + gzip + Marshal)
const gemName = context.query?.name || context.headers['x-gem-name'];
const version = context.query?.version || context.headers['x-gem-version'];
const platform = context.query?.platform || context.headers['x-gem-platform'];
// Try to get metadata from query params or headers first
let gemName = context.query?.name || context.headers['x-gem-name'] as string | undefined;
let version = context.query?.version || context.headers['x-gem-version'] as string | undefined;
const platform = context.query?.platform || context.headers['x-gem-platform'] as string | undefined;
// If not provided, try to extract from gem binary
if (!gemName || !version) {
const extracted = await helpers.extractGemMetadata(gemData);
if (extracted) {
gemName = gemName || extracted.name;
version = version || extracted.version;
}
}
if (!gemName || !version) {
return this.errorResponse(400, 'Gem name and version required');
return this.errorResponse(400, 'Gem name and version required (provide in query, headers, or valid gem format)');
}
// Validate gem name
@@ -351,7 +359,7 @@ export class RubyGemsRegistry extends BaseRegistry {
});
return {
status: 200,
status: 201,
headers: { 'Content-Type': 'application/json' },
body: Buffer.from(JSON.stringify({
message: 'Gem uploaded successfully',

View File

@@ -396,3 +396,54 @@ export async function extractGemSpec(gemData: Buffer): Promise<any | null> {
return null;
}
}
/**
* Extract basic metadata from a gem file
* Gem files are tar.gz archives containing metadata.gz (gzipped YAML with spec)
* This function attempts to parse the YAML from the metadata to extract name/version
* @param gemData - Gem file data
* @returns Extracted metadata or null
*/
export async function extractGemMetadata(gemData: Buffer): Promise<{
name: string;
version: string;
platform?: string;
} | null> {
try {
// Gem format: outer tar.gz containing metadata.gz and data.tar.gz
// metadata.gz contains YAML with gem specification
// Attempt to find YAML metadata in the gem binary
// The metadata is gzipped, but we can look for patterns in the decompressed portion
// For test gems created with our helper, the YAML is accessible after gunzip
const searchBuffer = gemData.toString('utf-8', 0, Math.min(gemData.length, 20000));
// Look for name: field in YAML
const nameMatch = searchBuffer.match(/name:\s*([^\n\r]+)/);
// Look for version in Ruby YAML format: version: !ruby/object:Gem::Version\n version: X.X.X
const versionMatch = searchBuffer.match(/version:\s*!ruby\/object:Gem::Version[\s\S]*?version:\s*['"]?([^'"\n\r]+)/);
// Also try simpler version format
const simpleVersionMatch = !versionMatch ? searchBuffer.match(/^version:\s*['"]?(\d[^'"\n\r]*)/m) : null;
// Look for platform
const platformMatch = searchBuffer.match(/platform:\s*([^\n\r]+)/);
const name = nameMatch?.[1]?.trim();
const version = versionMatch?.[1]?.trim() || simpleVersionMatch?.[1]?.trim();
const platform = platformMatch?.[1]?.trim();
if (name && version) {
return {
name,
version,
platform: platform && platform !== 'ruby' ? platform : undefined,
};
}
return null;
} catch {
return null;
}
}