/** * Helper functions for RubyGems registry * Compact Index generation, dependency formatting, etc. */ import * as plugins from '../plugins.js'; import type { IRubyGemsVersion, IRubyGemsDependency, IRubyGemsRequirement, ICompactIndexVersionsEntry, ICompactIndexInfoEntry, IRubyGemsMetadata, } from './interfaces.rubygems.js'; /** * Generate Compact Index versions file * Format: GEMNAME [-]VERSION_PLATFORM[,VERSION_PLATFORM,...] MD5 * @param entries - Version entries for all gems * @returns Compact Index versions file content */ export function generateCompactIndexVersions(entries: ICompactIndexVersionsEntry[]): string { const lines: string[] = []; // Add metadata header lines.push(`created_at: ${new Date().toISOString()}`); lines.push('---'); // Add gem entries for (const entry of entries) { const versions = entry.versions .map(v => { const yanked = v.yanked ? '-' : ''; const platform = v.platform && v.platform !== 'ruby' ? `_${v.platform}` : ''; return `${yanked}${v.version}${platform}`; }) .join(','); lines.push(`${entry.name} ${versions} ${entry.infoChecksum}`); } return lines.join('\n'); } /** * Generate Compact Index info file for a gem * Format: VERSION[-PLATFORM] [DEP[,DEP,...]]|REQ[,REQ,...] * @param entries - Info entries for gem versions * @returns Compact Index info file content */ export function generateCompactIndexInfo(entries: ICompactIndexInfoEntry[]): string { const lines: string[] = ['---']; // Info files start with --- for (const entry of entries) { // Build version string with optional platform const versionStr = entry.platform && entry.platform !== 'ruby' ? `${entry.version}-${entry.platform}` : entry.version; // Build dependencies string const depsStr = entry.dependencies.length > 0 ? entry.dependencies.map(formatDependency).join(',') : ''; // Build requirements string (checksum is always required) const reqParts: string[] = [`checksum:${entry.checksum}`]; for (const req of entry.requirements) { reqParts.push(`${req.type}:${req.requirement}`); } const reqStr = reqParts.join(','); // Combine: VERSION[-PLATFORM] [DEPS]|REQS const depPart = depsStr ? ` ${depsStr}` : ''; lines.push(`${versionStr}${depPart}|${reqStr}`); } return lines.join('\n'); } /** * Format a dependency for Compact Index * Format: GEM:CONSTRAINT[&CONSTRAINT] * @param dep - Dependency object * @returns Formatted dependency string */ export function formatDependency(dep: IRubyGemsDependency): string { return `${dep.name}:${dep.requirement}`; } /** * Parse dependency string from Compact Index * @param depStr - Dependency string * @returns Dependency object */ export function parseDependency(depStr: string): IRubyGemsDependency { const [name, ...reqParts] = depStr.split(':'); const requirement = reqParts.join(':'); // Handle :: in gem names return { name, requirement }; } /** * Generate names file (newline-separated gem names) * @param names - List of gem names * @returns Names file content */ export function generateNamesFile(names: string[]): string { return `---\n${names.sort().join('\n')}`; } /** * Calculate MD5 hash for Compact Index checksum * @param content - Content to hash * @returns MD5 hash (hex) */ export async function calculateMD5(content: string): Promise { const crypto = await import('crypto'); return crypto.createHash('md5').update(content).digest('hex'); } /** * Calculate SHA256 hash for gem files * @param data - Data to hash * @returns SHA256 hash (hex) */ export async function calculateSHA256(data: Buffer): Promise { const crypto = await import('crypto'); return crypto.createHash('sha256').update(data).digest('hex'); } /** * Parse gem filename to extract name, version, and platform * @param filename - Gem filename (e.g., "rails-7.0.0-x86_64-linux.gem") * @returns Parsed info or null */ export function parseGemFilename(filename: string): { name: string; version: string; platform?: string; } | null { if (!filename.endsWith('.gem')) return null; const withoutExt = filename.slice(0, -4); // Remove .gem // Try to match: name-version-platform // Platform can contain hyphens (e.g., x86_64-linux) const parts = withoutExt.split('-'); if (parts.length < 2) return null; // Find version (first part that starts with a digit) let versionIndex = -1; for (let i = 1; i < parts.length; i++) { if (/^\d/.test(parts[i])) { versionIndex = i; break; } } if (versionIndex === -1) return null; const name = parts.slice(0, versionIndex).join('-'); const version = parts[versionIndex]; const platform = versionIndex + 1 < parts.length ? parts.slice(versionIndex + 1).join('-') : undefined; return { name, version, platform: platform && platform !== 'ruby' ? platform : undefined, }; } /** * Validate gem name * Must contain only ASCII letters, numbers, _, and - * @param name - Gem name * @returns true if valid */ export function isValidGemName(name: string): boolean { return /^[a-zA-Z0-9_-]+$/.test(name); } /** * Validate version string * Basic semantic versioning check * @param version - Version string * @returns true if valid */ export function isValidVersion(version: string): boolean { // Allow semver and other common Ruby version formats return /^[\d.a-zA-Z_-]+$/.test(version); } /** * Build version list entry for Compact Index * @param versions - Version info * @returns Version list string */ export function buildVersionList(versions: Array<{ version: string; platform?: string; yanked: boolean; }>): string { return versions .map(v => { const yanked = v.yanked ? '-' : ''; const platform = v.platform && v.platform !== 'ruby' ? `_${v.platform}` : ''; return `${yanked}${v.version}${platform}`; }) .join(','); } /** * Parse version list from Compact Index * @param versionStr - Version list string * @returns Parsed versions */ export function parseVersionList(versionStr: string): Array<{ version: string; platform?: string; yanked: boolean; }> { return versionStr.split(',').map(v => { const yanked = v.startsWith('-'); const withoutYank = yanked ? v.substring(1) : v; // Split on _ to separate version from platform const [version, ...platformParts] = withoutYank.split('_'); const platform = platformParts.length > 0 ? platformParts.join('_') : undefined; return { version, platform: platform && platform !== 'ruby' ? platform : undefined, yanked, }; }); } /** * Generate JSON response for /api/v1/versions/{gem}.json * @param gemName - Gem name * @param versions - Version list * @returns JSON response object */ export function generateVersionsJson( gemName: string, versions: Array<{ version: string; platform?: string; uploadTime?: string; }> ): any { return { name: gemName, versions: versions.map(v => ({ number: v.version, platform: v.platform || 'ruby', built_at: v.uploadTime, })), }; } /** * Generate JSON response for /api/v1/dependencies * @param gems - Map of gem names to version dependencies * @returns JSON response array */ export function generateDependenciesJson(gems: Map>): any { const result: any[] = []; for (const [name, versions] of gems) { for (const v of versions) { result.push({ name, number: v.version, platform: v.platform || 'ruby', dependencies: v.dependencies.map(d => ({ name: d.name, requirements: d.requirement, })), }); } } return result; } /** * Update Compact Index versions file with new gem version * Handles append-only semantics for the current month * @param existingContent - Current versions file content * @param gemName - Gem name * @param newVersion - New version info * @param infoChecksum - MD5 of info file * @returns Updated versions file content */ export function updateCompactIndexVersions( existingContent: string, gemName: string, newVersion: { version: string; platform?: string; yanked: boolean }, infoChecksum: string ): string { const lines = existingContent.split('\n'); const headerEndIndex = lines.findIndex(l => l === '---'); if (headerEndIndex === -1) { throw new Error('Invalid Compact Index versions file'); } const header = lines.slice(0, headerEndIndex + 1); const entries = lines.slice(headerEndIndex + 1).filter(l => l.trim()); // Find existing entry for gem const gemLineIndex = entries.findIndex(l => l.startsWith(`${gemName} `)); const versionStr = buildVersionList([newVersion]); if (gemLineIndex >= 0) { // Append to existing entry const parts = entries[gemLineIndex].split(' '); const existingVersions = parts[1]; const updatedVersions = `${existingVersions},${versionStr}`; entries[gemLineIndex] = `${gemName} ${updatedVersions} ${infoChecksum}`; } else { // Add new entry entries.push(`${gemName} ${versionStr} ${infoChecksum}`); entries.sort(); // Keep alphabetical } return [...header, ...entries].join('\n'); } /** * Update Compact Index info file with new version * @param existingContent - Current info file content * @param newEntry - New version entry * @returns Updated info file content */ export function updateCompactIndexInfo( existingContent: string, newEntry: ICompactIndexInfoEntry ): string { const lines = existingContent ? existingContent.split('\n').filter(l => l !== '---') : []; // Build version string const versionStr = newEntry.platform && newEntry.platform !== 'ruby' ? `${newEntry.version}-${newEntry.platform}` : newEntry.version; // Build dependencies string const depsStr = newEntry.dependencies.length > 0 ? newEntry.dependencies.map(formatDependency).join(',') : ''; // Build requirements string const reqParts: string[] = [`checksum:${newEntry.checksum}`]; for (const req of newEntry.requirements) { reqParts.push(`${req.type}:${req.requirement}`); } const reqStr = reqParts.join(','); // Combine const depPart = depsStr ? ` ${depsStr}` : ''; const newLine = `${versionStr}${depPart}|${reqStr}`; lines.push(newLine); return `---\n${lines.join('\n')}`; } /** * Extract gem specification from .gem file * Note: This is a simplified version. Full implementation would use tar + gzip + Marshal * @param gemData - Gem file data * @returns Extracted spec or null */ export async function extractGemSpec(gemData: Buffer): Promise { try { // .gem files are gzipped tar archives // They contain metadata.gz which has Marshal-encoded spec // This is a placeholder - full implementation would need: // 1. Unzip outer gzip // 2. Untar to find metadata.gz // 3. Unzip metadata.gz // 4. Parse Ruby Marshal format // For now, return null and expect metadata to be provided return null; } catch (error) { return null; } } /** * Extract basic metadata from a gem file * Gem files are plain tar archives (NOT gzipped) containing: * - metadata.gz: gzipped YAML with gem specification * - data.tar.gz: gzipped tar with actual gem files * This function extracts and parses the metadata.gz to get name/version/platform * @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 { // Step 1: Extract the plain tar archive to get metadata.gz const smartArchive = plugins.smartarchive.SmartArchive.create(); const files = await smartArchive.buffer(gemData).toSmartFiles(); // Find metadata.gz const metadataFile = files.find(f => f.path === 'metadata.gz' || f.relative === 'metadata.gz'); if (!metadataFile) { return null; } // Step 2: Decompress the gzipped metadata const gzipTools = new plugins.smartarchive.GzipTools(); const metadataYaml = await gzipTools.decompress(metadataFile.contentBuffer); const yamlContent = metadataYaml.toString('utf-8'); // Step 3: Parse the YAML to extract name, version, platform // Look for name: field in YAML const nameMatch = yamlContent.match(/name:\s*([^\n\r]+)/); // Look for version in Ruby YAML format: version: !ruby/object:Gem::Version\n version: X.X.X const versionMatch = yamlContent.match(/version:\s*!ruby\/object:Gem::Version[\s\S]*?version:\s*['"]?([^'"\n\r]+)/); // Also try simpler version format const simpleVersionMatch = !versionMatch ? yamlContent.match(/^version:\s*['"]?(\d[^'"\n\r]*)/m) : null; // Look for platform const platformMatch = yamlContent.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 (error) { // Log error for debugging but return null gracefully console.error('Failed to extract gem metadata:', error); return null; } } /** * Generate gzipped specs array for /specs.4.8.gz and /latest_specs.4.8.gz * The format is a gzipped Ruby Marshal array of [name, version, platform] tuples * Since we can't easily generate Ruby Marshal format, we'll use a simple format * that represents the same data structure as a gzipped binary blob * @param specs - Array of [name, version, platform] tuples * @returns Gzipped specs data */ export async function generateSpecsGz(specs: Array<[string, string, string]>): Promise { const gzipTools = new plugins.smartarchive.GzipTools(); // Create a simplified binary representation // Real RubyGems uses Ruby Marshal format, but for compatibility we'll create // a gzipped representation that tools can recognize as valid // Format: Simple binary encoding of specs array // Each spec: name_length(2 bytes) + name + version_length(2 bytes) + version + platform_length(2 bytes) + platform const parts: Buffer[] = []; // Header: number of specs (4 bytes) const headerBuf = Buffer.alloc(4); headerBuf.writeUInt32LE(specs.length, 0); parts.push(headerBuf); for (const [name, version, platform] of specs) { const nameBuf = Buffer.from(name, 'utf-8'); const versionBuf = Buffer.from(version, 'utf-8'); const platformBuf = Buffer.from(platform, 'utf-8'); const nameLenBuf = Buffer.alloc(2); nameLenBuf.writeUInt16LE(nameBuf.length, 0); const versionLenBuf = Buffer.alloc(2); versionLenBuf.writeUInt16LE(versionBuf.length, 0); const platformLenBuf = Buffer.alloc(2); platformLenBuf.writeUInt16LE(platformBuf.length, 0); parts.push(nameLenBuf, nameBuf, versionLenBuf, versionBuf, platformLenBuf, platformBuf); } const uncompressed = Buffer.concat(parts); return gzipTools.compress(uncompressed); } /** * Generate compressed gemspec for /quick/Marshal.4.8/{gem}-{version}.gemspec.rz * The format is a zlib-compressed Ruby Marshal representation of the gemspec * Since we can't easily generate Ruby Marshal, we'll create a simplified format * @param name - Gem name * @param versionMeta - Version metadata * @returns Zlib-compressed gemspec data */ export async function generateGemspecRz( name: string, versionMeta: { version: string; platform?: string; checksum: string; dependencies?: Array<{ name: string; requirement: string }>; } ): Promise { const zlib = await import('zlib'); const { promisify } = await import('util'); const deflate = promisify(zlib.deflate); // Create a YAML-like representation that can be parsed const gemspecYaml = `--- !ruby/object:Gem::Specification name: ${name} version: !ruby/object:Gem::Version version: ${versionMeta.version} platform: ${versionMeta.platform || 'ruby'} authors: [] date: ${new Date().toISOString().split('T')[0]} dependencies: [] description: email: executables: [] extensions: [] extra_rdoc_files: [] files: [] homepage: licenses: [] metadata: {} post_install_message: rdoc_options: [] require_paths: - lib required_ruby_version: !ruby/object:Gem::Requirement requirements: - - ">=" - !ruby/object:Gem::Version version: '0' required_rubygems_version: !ruby/object:Gem::Requirement requirements: - - ">=" - !ruby/object:Gem::Version version: '0' requirements: [] rubygems_version: 3.0.0 signing_key: specification_version: 4 summary: test_files: [] `; // Use zlib deflate (not gzip) for .rz files return deflate(Buffer.from(gemspecYaml, 'utf-8')); }