Files
smartregistry/ts/rubygems/helpers.rubygems.ts

574 lines
17 KiB
TypeScript

/**
* 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<string> {
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<string> {
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<string, Array<{
version: string;
platform?: string;
dependencies: IRubyGemsDependency[];
}>>): 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<any | null> {
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<Buffer> {
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<Buffer> {
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'));
}