/** * Helper functions for RubyGems registry * Compact Index generation, dependency formatting, etc. */ 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 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; } }