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

450 lines
13 KiB
TypeScript

/**
* 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<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 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;
}
}