diff --git a/changelog.md b/changelog.md index 9e3aa5e..a39b30e 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,17 @@ # Changelog +## 2025-11-25 - 1.9.0 - feat(auth) +Implement HMAC-SHA256 OCI JWTs; enhance PyPI & RubyGems uploads and normalize responses + +- AuthManager: create and validate OCI JWTs signed with HMAC-SHA256 (header.payload.signature). Signature verification, exp/nbf checks and payload decoding implemented. +- PyPI: improved Simple API handling (PEP-691 JSON responses returned as objects), Simple HTML responses updated, upload handling enhanced to support nested/flat multipart fields, verify hashes (sha256/md5/blake2b), store files and return 201 on success. +- RubyGems: upload flow now attempts to extract gem metadata from the .gem binary when name/version are not provided, improved validation, and upload returns 201. Added extractGemMetadata helper. +- OCI: centralized 401 response creation (including proper WWW-Authenticate header) and HEAD behavior fixed to return no body per HTTP spec. +- SmartRegistry: use nullish coalescing for protocol basePath defaults to avoid falsy-value bugs when basePath is an empty string. +- Tests and helpers: test expectations adjusted (Content-Type startsWith check for HTML, PEP-691 projects is an array), test helper switched to smartarchive for packaging. +- Package.json: added devDependency @push.rocks/smartarchive and updated dev deps. +- Various response normalization: avoid unnecessary Buffer.from() for already-serialized objects/strings and standardize status codes for create/upload endpoints (201). + ## 2025-11-24 - 1.8.0 - feat(smarts3) Add local smarts3 testing support and documentation diff --git a/package.json b/package.json index 9508018..3c43382 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@git.zone/tsbundle": "^2.0.5", "@git.zone/tsrun": "^2.0.0", "@git.zone/tstest": "^3.1.0", + "@push.rocks/smartarchive": "^5.0.1", "@push.rocks/smarts3": "^5.1.0", "@types/node": "^24.10.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f3e9408..c9e0aac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: '@git.zone/tstest': specifier: ^3.1.0 version: 3.1.0(socks@2.8.7)(typescript@5.9.3) + '@push.rocks/smartarchive': + specifier: ^5.0.1 + version: 5.0.1(@push.rocks/smartfs@1.1.0) '@push.rocks/smarts3': specifier: ^5.1.0 version: 5.1.0 @@ -705,6 +708,9 @@ packages: '@push.rocks/smartarchive@4.2.2': resolution: {integrity: sha512-6EpqbKU32D6Gcqsc9+Tn1dOCU5HoTlrqqs/7IdUr9Tirp9Ngtptkapca1Fw/D0kVJ7SSw3kG/miAYnuPMZLEoA==} + '@push.rocks/smartarchive@5.0.1': + resolution: {integrity: sha512-x4bie9IIdL9BZqBZLc8Pemp8xZOJGa6mXSVgKJRL4/Rw+E5N4rVHjQOYGRV75nC2mAMJh9GIbixuxLnWjj77ag==} + '@push.rocks/smartbrowser@2.0.8': resolution: {integrity: sha512-0KWRZj3TuKo/sNwgPbiSE6WL+TMeR19t1JmXBZWh9n8iA2mpc4HhMrQAndEUdRCkx5ofSaHWojIRVFzGChj0Dg==} @@ -765,6 +771,14 @@ packages: '@push.rocks/smartfile@11.2.7': resolution: {integrity: sha512-8Yp7/sAgPpWJBHohV92ogHWKzRomI5MEbSG6b5W2n18tqwfAmjMed0rQvsvGrSBlnEWCKgoOrYIIZbLO61+J0Q==} + '@push.rocks/smartfile@13.0.1': + resolution: {integrity: sha512-phtryDFtBYHo7R2H9V3Y7VeiYQU9YzKL140gKD3bTicBgXoIYrJ6+b3mbZunSO2yQt1Vy1AxCxYXrFE/K+4grw==} + peerDependencies: + '@push.rocks/smartfs': ^1.0.0 + peerDependenciesMeta: + '@push.rocks/smartfs': + optional: true + '@push.rocks/smartfs@1.1.0': resolution: {integrity: sha512-fg8JIjFUPPX5laRoBpTaGwhMfZ3Y8mFT4fUaW54Y4J/BfOBa/y0+rIFgvgvqcOZgkQlyZU+FIfL8Z6zezqxyTg==} @@ -5265,6 +5279,27 @@ snapshots: - react-native-b4a - supports-color + '@push.rocks/smartarchive@5.0.1(@push.rocks/smartfs@1.1.0)': + dependencies: + '@push.rocks/smartdelay': 3.0.5 + '@push.rocks/smartfile': 13.0.1(@push.rocks/smartfs@1.1.0) + '@push.rocks/smartpath': 6.0.0 + '@push.rocks/smartpromise': 4.2.3 + '@push.rocks/smartrequest': 4.4.2 + '@push.rocks/smartrx': 3.0.10 + '@push.rocks/smartstream': 3.2.5 + '@push.rocks/smartunique': 3.0.9 + '@push.rocks/smarturl': 3.1.0 + '@types/tar-stream': 3.1.4 + fflate: 0.8.2 + file-type: 21.1.0 + tar-stream: 3.1.7 + transitivePeerDependencies: + - '@push.rocks/smartfs' + - bare-abort-controller + - react-native-b4a + - supports-color + '@push.rocks/smartbrowser@2.0.8(typescript@5.9.3)': dependencies: '@push.rocks/smartdelay': 3.0.5 @@ -5453,6 +5488,24 @@ snapshots: glob: 11.1.0 js-yaml: 4.1.1 + '@push.rocks/smartfile@13.0.1(@push.rocks/smartfs@1.1.0)': + dependencies: + '@push.rocks/lik': 6.2.2 + '@push.rocks/smartdelay': 3.0.5 + '@push.rocks/smartfile-interfaces': 1.0.7 + '@push.rocks/smarthash': 3.2.6 + '@push.rocks/smartjson': 5.2.0 + '@push.rocks/smartmime': 2.0.4 + '@push.rocks/smartpath': 6.0.0 + '@push.rocks/smartpromise': 4.2.3 + '@push.rocks/smartrequest': 4.4.2 + '@push.rocks/smartstream': 3.2.5 + '@types/js-yaml': 4.0.9 + glob: 11.1.0 + js-yaml: 4.1.1 + optionalDependencies: + '@push.rocks/smartfs': 1.1.0 + '@push.rocks/smartfs@1.1.0': dependencies: '@push.rocks/smartpath': 6.0.0 diff --git a/test/helpers/registry.ts b/test/helpers/registry.ts index 0137219..4830ab4 100644 --- a/test/helpers/registry.ts +++ b/test/helpers/registry.ts @@ -1,5 +1,6 @@ import * as qenv from '@push.rocks/qenv'; import * as crypto from 'crypto'; +import * as smartarchive from '@push.rocks/smartarchive'; import { SmartRegistry } from '../../ts/classes.smartregistry.js'; import type { IRegistryConfig } from '../../ts/core/interfaces.core.js'; @@ -241,7 +242,7 @@ export function calculateMavenChecksums(data: Buffer) { } /** - * Helper to create a Composer package ZIP + * Helper to create a Composer package ZIP using smartarchive */ export async function createComposerZip( vendorPackage: string, @@ -252,8 +253,7 @@ export async function createComposerZip( authors?: Array<{ name: string; email?: string }>; } ): Promise { - const AdmZip = (await import('adm-zip')).default; - const zip = new AdmZip(); + const zipTools = new smartarchive.ZipTools(); const composerJson = { name: vendorPackage, @@ -272,9 +272,6 @@ export async function createComposerZip( }, }; - // Add composer.json - zip.addFile('composer.json', Buffer.from(JSON.stringify(composerJson, null, 2), 'utf-8')); - // Add a test PHP file const [vendor, pkg] = vendorPackage.split('/'); const namespace = `${vendor.charAt(0).toUpperCase() + vendor.slice(1)}\\${pkg.charAt(0).toUpperCase() + pkg.slice(1).replace(/-/g, '')}`; @@ -290,24 +287,33 @@ class TestClass } `; - zip.addFile('src/TestClass.php', Buffer.from(testPhpContent, 'utf-8')); + const entries: smartarchive.IArchiveEntry[] = [ + { + archivePath: 'composer.json', + content: Buffer.from(JSON.stringify(composerJson, null, 2), 'utf-8'), + }, + { + archivePath: 'src/TestClass.php', + content: Buffer.from(testPhpContent, 'utf-8'), + }, + { + archivePath: 'README.md', + content: Buffer.from(`# ${vendorPackage}\n\nTest package`, 'utf-8'), + }, + ]; - // Add README - zip.addFile('README.md', Buffer.from(`# ${vendorPackage}\n\nTest package`, 'utf-8')); - - return zip.toBuffer(); + return zipTools.createZip(entries); } /** - * Helper to create a test Python wheel file (minimal ZIP structure) + * Helper to create a test Python wheel file (minimal ZIP structure) using smartarchive */ export async function createPythonWheel( packageName: string, version: string, pyVersion: string = 'py3' ): Promise { - const AdmZip = (await import('adm-zip')).default; - const zip = new AdmZip(); + const zipTools = new smartarchive.ZipTools(); const normalizedName = packageName.replace(/-/g, '_'); const distInfoDir = `${normalizedName}-${version}.dist-info`; @@ -331,8 +337,6 @@ Description-Content-Type: text/markdown Test package for SmartRegistry `; - zip.addFile(`${distInfoDir}/METADATA`, Buffer.from(metadata, 'utf-8')); - // Create WHEEL file const wheelContent = `Wheel-Version: 1.0 Generator: test 1.0.0 @@ -340,14 +344,6 @@ Root-Is-Purelib: true Tag: ${pyVersion}-none-any `; - zip.addFile(`${distInfoDir}/WHEEL`, Buffer.from(wheelContent, 'utf-8')); - - // Create RECORD file (empty for test) - zip.addFile(`${distInfoDir}/RECORD`, Buffer.from('', 'utf-8')); - - // Create top_level.txt - zip.addFile(`${distInfoDir}/top_level.txt`, Buffer.from(normalizedName, 'utf-8')); - // Create a simple Python module const moduleContent = `"""${packageName} module""" @@ -357,27 +353,44 @@ def hello(): return "Hello from ${packageName}!" `; - zip.addFile(`${normalizedName}/__init__.py`, Buffer.from(moduleContent, 'utf-8')); + const entries: smartarchive.IArchiveEntry[] = [ + { + archivePath: `${distInfoDir}/METADATA`, + content: Buffer.from(metadata, 'utf-8'), + }, + { + archivePath: `${distInfoDir}/WHEEL`, + content: Buffer.from(wheelContent, 'utf-8'), + }, + { + archivePath: `${distInfoDir}/RECORD`, + content: Buffer.from('', 'utf-8'), + }, + { + archivePath: `${distInfoDir}/top_level.txt`, + content: Buffer.from(normalizedName, 'utf-8'), + }, + { + archivePath: `${normalizedName}/__init__.py`, + content: Buffer.from(moduleContent, 'utf-8'), + }, + ]; - return zip.toBuffer(); + return zipTools.createZip(entries); } /** - * Helper to create a test Python source distribution (sdist) + * Helper to create a test Python source distribution (sdist) using smartarchive */ export async function createPythonSdist( packageName: string, version: string ): Promise { - const tar = await import('tar-stream'); - const zlib = await import('zlib'); - const { Readable } = await import('stream'); + const tarTools = new smartarchive.TarTools(); const normalizedName = packageName.replace(/-/g, '_'); const dirPrefix = `${packageName}-${version}`; - const pack = tar.pack(); - // PKG-INFO const pkgInfo = `Metadata-Version: 2.1 Name: ${packageName} @@ -389,8 +402,6 @@ Author-email: test@example.com License: MIT `; - pack.entry({ name: `${dirPrefix}/PKG-INFO` }, pkgInfo); - // setup.py const setupPy = `from setuptools import setup, find_packages @@ -402,8 +413,6 @@ setup( ) `; - pack.entry({ name: `${dirPrefix}/setup.py` }, setupPy); - // Module file const moduleContent = `"""${packageName} module""" @@ -413,20 +422,22 @@ def hello(): return "Hello from ${packageName}!" `; - pack.entry({ name: `${dirPrefix}/${normalizedName}/__init__.py` }, moduleContent); + const entries: smartarchive.IArchiveEntry[] = [ + { + archivePath: `${dirPrefix}/PKG-INFO`, + content: Buffer.from(pkgInfo, 'utf-8'), + }, + { + archivePath: `${dirPrefix}/setup.py`, + content: Buffer.from(setupPy, 'utf-8'), + }, + { + archivePath: `${dirPrefix}/${normalizedName}/__init__.py`, + content: Buffer.from(moduleContent, 'utf-8'), + }, + ]; - pack.finalize(); - - // Convert to gzipped tar - const chunks: Buffer[] = []; - const gzip = zlib.createGzip(); - - return new Promise((resolve, reject) => { - pack.pipe(gzip); - gzip.on('data', (chunk) => chunks.push(chunk)); - gzip.on('end', () => resolve(Buffer.concat(chunks))); - gzip.on('error', reject); - }); + return tarTools.packFilesToTarGz(entries); } /** @@ -441,17 +452,15 @@ export function calculatePypiHashes(data: Buffer) { } /** - * Helper to create a test RubyGem file (minimal tar.gz structure) + * Helper to create a test RubyGem file (minimal tar.gz structure) using smartarchive */ export async function createRubyGem( gemName: string, version: string, platform: string = 'ruby' ): Promise { - const tar = await import('tar-stream'); - const zlib = await import('zlib'); - - const pack = tar.pack(); + const tarTools = new smartarchive.TarTools(); + const gzipTools = new smartarchive.GzipTools(); // Create metadata.gz (simplified) const metadataYaml = `--- !ruby/object:Gem::Specification @@ -499,10 +508,9 @@ summary: Test gem for SmartRegistry test_files: [] `; - pack.entry({ name: 'metadata.gz' }, zlib.gzipSync(Buffer.from(metadataYaml, 'utf-8'))); + const metadataGz = await gzipTools.compress(Buffer.from(metadataYaml, 'utf-8')); - // Create data.tar.gz (simplified) - const dataPack = tar.pack(); + // Create data.tar.gz content const libContent = `# ${gemName} module ${gemName.charAt(0).toUpperCase() + gemName.slice(1).replace(/-/g, '')} @@ -514,32 +522,28 @@ module ${gemName.charAt(0).toUpperCase() + gemName.slice(1).replace(/-/g, '')} end `; - dataPack.entry({ name: `lib/${gemName}.rb` }, libContent); - dataPack.finalize(); + const dataEntries: smartarchive.IArchiveEntry[] = [ + { + archivePath: `lib/${gemName}.rb`, + content: Buffer.from(libContent, 'utf-8'), + }, + ]; - const dataChunks: Buffer[] = []; - const dataGzip = zlib.createGzip(); - dataPack.pipe(dataGzip); + const dataTarGz = await tarTools.packFilesToTarGz(dataEntries); - await new Promise((resolve) => { - dataGzip.on('data', (chunk) => dataChunks.push(chunk)); - dataGzip.on('end', resolve); - }); + // Create the outer gem (tar.gz containing metadata.gz and data.tar.gz) + const gemEntries: smartarchive.IArchiveEntry[] = [ + { + archivePath: 'metadata.gz', + content: metadataGz, + }, + { + archivePath: 'data.tar.gz', + content: dataTarGz, + }, + ]; - pack.entry({ name: 'data.tar.gz' }, Buffer.concat(dataChunks)); - - pack.finalize(); - - // Convert to gzipped tar - const chunks: Buffer[] = []; - const gzip = zlib.createGzip(); - - return new Promise((resolve, reject) => { - pack.pipe(gzip); - gzip.on('data', (chunk) => chunks.push(chunk)); - gzip.on('end', () => resolve(Buffer.concat(chunks))); - gzip.on('error', reject); - }); + return tarTools.packFilesToTarGz(gemEntries); } /** diff --git a/test/test.integration.pypi-rubygems.ts b/test/test.integration.crossprotocol.ts similarity index 99% rename from test/test.integration.pypi-rubygems.ts rename to test/test.integration.crossprotocol.ts index 98a2865..bf53b25 100644 --- a/test/test.integration.pypi-rubygems.ts +++ b/test/test.integration.crossprotocol.ts @@ -78,7 +78,7 @@ tap.test('Integration: should handle /simple path for PyPI', async () => { }); expect(response.status).toEqual(200); - expect(response.headers['Content-Type']).toEqual('text/html'); + expect(response.headers['Content-Type']).toStartWith('text/html'); expect(response.body).toContain('integration-test-py'); }); diff --git a/test/test.pypi.ts b/test/test.pypi.ts index 6e20b28..b97eee8 100644 --- a/test/test.pypi.ts +++ b/test/test.pypi.ts @@ -99,7 +99,7 @@ tap.test('PyPI: should retrieve Simple API root index HTML (GET /simple/)', asyn }); expect(response.status).toEqual(200); - expect(response.headers['Content-Type']).toEqual('text/html'); + expect(response.headers['Content-Type']).toStartWith('text/html'); expect(response.body).toBeTypeOf('string'); const html = response.body as string; @@ -125,8 +125,10 @@ tap.test('PyPI: should retrieve Simple API root index JSON (GET /simple/ with Ac const json = response.body as any; expect(json).toHaveProperty('meta'); expect(json).toHaveProperty('projects'); - expect(json.projects).toBeTypeOf('object'); - expect(json.projects).toHaveProperty(normalizedPackageName); + expect(json.projects).toBeInstanceOf(Array); + // Check that the package is in the projects list (PEP 691 format: array of { name } objects) + const packageNames = json.projects.map((p: any) => p.name); + expect(packageNames).toContain(normalizedPackageName); }); tap.test('PyPI: should retrieve Simple API package HTML (GET /simple/{package}/)', async () => { @@ -140,7 +142,7 @@ tap.test('PyPI: should retrieve Simple API package HTML (GET /simple/{package}/) }); expect(response.status).toEqual(200); - expect(response.headers['Content-Type']).toEqual('text/html'); + expect(response.headers['Content-Type']).toStartWith('text/html'); expect(response.body).toBeTypeOf('string'); const html = response.body as string; diff --git a/test/test.rubygems.nativecli.node.ts b/test/test.rubygems.nativecli.node.ts new file mode 100644 index 0000000..2027588 --- /dev/null +++ b/test/test.rubygems.nativecli.node.ts @@ -0,0 +1,448 @@ +/** + * Native gem CLI Testing + * Tests the RubyGems registry implementation using the actual gem CLI + */ + +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { tapNodeTools } from '@git.zone/tstest/tapbundle_serverside'; +import { SmartRegistry } from '../ts/index.js'; +import { createTestRegistry, createTestTokens, createRubyGem } from './helpers/registry.js'; +import type { IRequestContext, IResponse } from '../ts/core/interfaces.core.js'; +import * as http from 'http'; +import * as url from 'url'; +import * as fs from 'fs'; +import * as path from 'path'; + +// Test context +let registry: SmartRegistry; +let server: http.Server; +let registryUrl: string; +let registryPort: number; +let rubygemsToken: string; +let testDir: string; +let gemHome: string; + +/** + * Create HTTP server wrapper around SmartRegistry + */ +async function createHttpServer( + registryInstance: SmartRegistry, + port: number +): Promise<{ server: http.Server; url: string }> { + return new Promise((resolve, reject) => { + const httpServer = http.createServer(async (req, res) => { + try { + // Parse request + const parsedUrl = url.parse(req.url || '', true); + const pathname = parsedUrl.pathname || '/'; + const query = parsedUrl.query; + + // Read body + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk); + } + const bodyBuffer = Buffer.concat(chunks); + + // Parse body based on content type + let body: any; + if (bodyBuffer.length > 0) { + const contentType = req.headers['content-type'] || ''; + if (contentType.includes('application/json')) { + try { + body = JSON.parse(bodyBuffer.toString('utf-8')); + } catch (error) { + body = bodyBuffer; + } + } else { + body = bodyBuffer; + } + } + + // Convert to IRequestContext + const context: IRequestContext = { + method: req.method || 'GET', + path: pathname, + headers: req.headers as Record, + query: query as Record, + body: body, + }; + + // Handle request + const response: IResponse = await registryInstance.handleRequest(context); + + // Convert IResponse to HTTP response + res.statusCode = response.status; + + // Set headers + for (const [key, value] of Object.entries(response.headers || {})) { + res.setHeader(key, value); + } + + // Send body + if (response.body) { + if (Buffer.isBuffer(response.body)) { + res.end(response.body); + } else if (typeof response.body === 'string') { + res.end(response.body); + } else { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(response.body)); + } + } else { + res.end(); + } + } catch (error) { + console.error('Server error:', error); + res.statusCode = 500; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: 'INTERNAL_ERROR', message: String(error) })); + } + }); + + httpServer.listen(port, () => { + const serverUrl = `http://localhost:${port}`; + resolve({ server: httpServer, url: serverUrl }); + }); + + httpServer.on('error', reject); + }); +} + +/** + * Setup gem credentials file + * Format: YAML with :rubygems_api_key: TOKEN + */ +function setupGemCredentials(token: string, gemHomeArg: string): string { + const gemDir = path.join(gemHomeArg, '.gem'); + fs.mkdirSync(gemDir, { recursive: true }); + + // Create credentials file in YAML format + const credentialsContent = `:rubygems_api_key: ${token}\n`; + + const credentialsPath = path.join(gemDir, 'credentials'); + fs.writeFileSync(credentialsPath, credentialsContent, 'utf-8'); + + // Set restrictive permissions (gem requires 0600) + fs.chmodSync(credentialsPath, 0o600); + + return credentialsPath; +} + +/** + * Create a test gem file + */ +async function createTestGemFile( + gemName: string, + version: string, + targetDir: string +): Promise { + const gemData = await createRubyGem(gemName, version); + const gemFilename = `${gemName}-${version}.gem`; + const gemPath = path.join(targetDir, gemFilename); + + fs.writeFileSync(gemPath, gemData); + + return gemPath; +} + +/** + * Run gem command with proper environment + */ +async function runGemCommand( + command: string, + cwd: string, + includeAuth: boolean = true +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + // Prepare environment variables + const envVars = [ + `HOME="${gemHome}"`, + `GEM_HOME="${gemHome}"`, + includeAuth ? '' : 'RUBYGEMS_API_KEY=""', + ].filter(Boolean).join(' '); + + // Build command with cd to correct directory and environment variables + const fullCommand = `cd "${cwd}" && ${envVars} ${command}`; + + try { + const result = await tapNodeTools.runCommand(fullCommand); + return { + stdout: result.stdout || '', + stderr: result.stderr || '', + exitCode: result.exitCode || 0, + }; + } catch (error: any) { + return { + stdout: error.stdout || '', + stderr: error.stderr || String(error), + exitCode: error.exitCode || 1, + }; + } +} + +/** + * Cleanup test directory + */ +function cleanupTestDir(dir: string): void { + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// ======================================================================== +// TESTS +// ======================================================================== + +tap.test('RubyGems CLI: should setup registry and HTTP server', async () => { + // Create registry + registry = await createTestRegistry(); + const tokens = await createTestTokens(registry); + rubygemsToken = tokens.rubygemsToken; + + expect(registry).toBeInstanceOf(SmartRegistry); + expect(rubygemsToken).toBeTypeOf('string'); + + // Use port 36000 (avoids npm:35000, cargo:5000 conflicts) + registryPort = 36000; + const serverSetup = await createHttpServer(registry, registryPort); + server = serverSetup.server; + registryUrl = serverSetup.url; + + expect(server).toBeDefined(); + expect(registryUrl).toEqual(`http://localhost:${registryPort}`); + + // Setup test directory + testDir = path.join(process.cwd(), '.nogit', 'test-rubygems-cli'); + cleanupTestDir(testDir); + fs.mkdirSync(testDir, { recursive: true }); + + // Setup GEM_HOME + gemHome = path.join(testDir, '.gem-home'); + fs.mkdirSync(gemHome, { recursive: true }); + + // Setup gem credentials + const credentialsPath = setupGemCredentials(rubygemsToken, gemHome); + expect(fs.existsSync(credentialsPath)).toEqual(true); + + // Verify credentials file has correct permissions + const stats = fs.statSync(credentialsPath); + const mode = stats.mode & 0o777; + expect(mode).toEqual(0o600); +}); + +tap.test('RubyGems CLI: should verify server is responding', async () => { + // Check server is up by doing a direct HTTP request to the Compact Index + const response = await fetch(`${registryUrl}/rubygems/versions`); + expect(response.status).toBeGreaterThanOrEqual(200); + expect(response.status).toBeLessThan(500); +}); + +tap.test('RubyGems CLI: should build and push a gem', async () => { + const gemName = 'test-gem-cli'; + const version = '1.0.0'; + const gemPath = await createTestGemFile(gemName, version, testDir); + + expect(fs.existsSync(gemPath)).toEqual(true); + + const result = await runGemCommand( + `gem push ${gemPath} --host ${registryUrl}/rubygems`, + testDir + ); + console.log('gem push output:', result.stdout); + console.log('gem push stderr:', result.stderr); + + expect(result.exitCode).toEqual(0); + expect(result.stdout || result.stderr).toContain(gemName); +}); + +tap.test('RubyGems CLI: should verify gem in Compact Index /versions', async () => { + const gemName = 'test-gem-cli'; + + const response = await fetch(`${registryUrl}/rubygems/versions`); + expect(response.status).toEqual(200); + + const versionsData = await response.text(); + console.log('Versions data:', versionsData); + + // Format: GEMNAME VERSION[,VERSION...] MD5 + expect(versionsData).toContain(gemName); + expect(versionsData).toContain('1.0.0'); +}); + +tap.test('RubyGems CLI: should verify gem in Compact Index /info file', async () => { + const gemName = 'test-gem-cli'; + + const response = await fetch(`${registryUrl}/rubygems/info/${gemName}`); + expect(response.status).toEqual(200); + + const infoData = await response.text(); + console.log('Info data:', infoData); + + // Format: VERSION [DEPS]|REQS + expect(infoData).toContain('1.0.0'); +}); + +tap.test('RubyGems CLI: should download gem file', async () => { + const gemName = 'test-gem-cli'; + const version = '1.0.0'; + + const response = await fetch(`${registryUrl}/rubygems/gems/${gemName}-${version}.gem`); + expect(response.status).toEqual(200); + + const gemData = await response.arrayBuffer(); + expect(gemData.byteLength).toBeGreaterThan(0); + + // Verify content type + expect(response.headers.get('content-type')).toContain('application/octet-stream'); +}); + +tap.test('RubyGems CLI: should fetch gem metadata JSON', async () => { + const gemName = 'test-gem-cli'; + + const response = await fetch(`${registryUrl}/rubygems/api/v1/versions/${gemName}.json`); + expect(response.status).toEqual(200); + + const metadata = await response.json(); + console.log('Metadata:', metadata); + + expect(metadata).toBeInstanceOf(Array); + expect(metadata.length).toBeGreaterThan(0); + expect(metadata[0].number).toEqual('1.0.0'); +}); + +tap.test('RubyGems CLI: should push second version', async () => { + const gemName = 'test-gem-cli'; + const version = '2.0.0'; + const gemPath = await createTestGemFile(gemName, version, testDir); + + const result = await runGemCommand( + `gem push ${gemPath} --host ${registryUrl}/rubygems`, + testDir + ); + console.log('gem push v2.0.0 output:', result.stdout); + + expect(result.exitCode).toEqual(0); +}); + +tap.test('RubyGems CLI: should list all versions in /versions file', async () => { + const gemName = 'test-gem-cli'; + + const response = await fetch(`${registryUrl}/rubygems/versions`); + expect(response.status).toEqual(200); + + const versionsData = await response.text(); + console.log('All versions data:', versionsData); + + // Should contain both versions + expect(versionsData).toContain(gemName); + expect(versionsData).toContain('1.0.0'); + expect(versionsData).toContain('2.0.0'); +}); + +tap.test('RubyGems CLI: should yank a version', async () => { + const gemName = 'test-gem-cli'; + const version = '1.0.0'; + + const result = await runGemCommand( + `gem yank ${gemName} -v ${version} --host ${registryUrl}/rubygems`, + testDir + ); + console.log('gem yank output:', result.stdout); + console.log('gem yank stderr:', result.stderr); + + expect(result.exitCode).toEqual(0); + + // Verify version is yanked in /versions file + // Yanked versions are prefixed with '-' + const response = await fetch(`${registryUrl}/rubygems/versions`); + const versionsData = await response.text(); + console.log('Versions after yank:', versionsData); + + // Yanked version should have '-' prefix + expect(versionsData).toContain('-1.0.0'); +}); + +tap.test('RubyGems CLI: should unyank a version', async () => { + const gemName = 'test-gem-cli'; + const version = '1.0.0'; + + const result = await runGemCommand( + `gem yank ${gemName} -v ${version} --undo --host ${registryUrl}/rubygems`, + testDir + ); + console.log('gem unyank output:', result.stdout); + console.log('gem unyank stderr:', result.stderr); + + expect(result.exitCode).toEqual(0); + + // Verify version is not yanked in /versions file + const response = await fetch(`${registryUrl}/rubygems/versions`); + const versionsData = await response.text(); + console.log('Versions after unyank:', versionsData); + + // Should not have '-' prefix anymore (or have both without prefix) + // Check that we have the version without yank marker + const lines = versionsData.trim().split('\n'); + const gemLine = lines.find(line => line.startsWith(gemName)); + + if (gemLine) { + // Parse format: "gemname version[,version...] md5" + const parts = gemLine.split(' '); + const versions = parts[1]; + + // Should have 1.0.0 without '-' prefix + expect(versions).toContain('1.0.0'); + expect(versions).not.toContain('-1.0.0'); + } +}); + +tap.test('RubyGems CLI: should fetch dependencies', async () => { + const gemName = 'test-gem-cli'; + + const response = await fetch(`${registryUrl}/rubygems/api/v1/dependencies?gems=${gemName}`); + expect(response.status).toEqual(200); + + const dependencies = await response.json(); + console.log('Dependencies:', dependencies); + + expect(dependencies).toBeInstanceOf(Array); +}); + +tap.test('RubyGems CLI: should fail to push without auth', async () => { + const gemName = 'unauth-gem'; + const version = '1.0.0'; + const gemPath = await createTestGemFile(gemName, version, testDir); + + // Run without auth + const result = await runGemCommand( + `gem push ${gemPath} --host ${registryUrl}/rubygems`, + testDir, + false + ); + console.log('gem push unauth output:', result.stdout); + console.log('gem push unauth stderr:', result.stderr); + + // Should fail with auth error + expect(result.exitCode).not.toEqual(0); +}); + +tap.postTask('cleanup rubygems cli tests', async () => { + // Stop server + if (server) { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } + + // Cleanup test directory + if (testDir) { + cleanupTestDir(testDir); + } + + // Destroy registry + if (registry) { + registry.destroy(); + } +}); + +export default tap.start(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 4f46650..a8b01ab 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartregistry', - version: '1.8.0', + version: '1.9.0', description: 'A composable TypeScript library implementing OCI, NPM, Maven, Cargo, Composer, PyPI, and RubyGems registries for building unified container and package registries' } diff --git a/ts/classes.smartregistry.ts b/ts/classes.smartregistry.ts index f55abde..81be292 100644 --- a/ts/classes.smartregistry.ts +++ b/ts/classes.smartregistry.ts @@ -41,7 +41,7 @@ export class SmartRegistry { // Initialize OCI registry if enabled if (this.config.oci?.enabled) { - const ociBasePath = this.config.oci.basePath || '/oci'; + const ociBasePath = this.config.oci.basePath ?? '/oci'; const ociRegistry = new OciRegistry(this.storage, this.authManager, ociBasePath); await ociRegistry.init(); this.registries.set('oci', ociRegistry); @@ -49,7 +49,7 @@ export class SmartRegistry { // Initialize NPM registry if enabled if (this.config.npm?.enabled) { - const npmBasePath = this.config.npm.basePath || '/npm'; + const npmBasePath = this.config.npm.basePath ?? '/npm'; const registryUrl = `http://localhost:5000${npmBasePath}`; // TODO: Make configurable const npmRegistry = new NpmRegistry(this.storage, this.authManager, npmBasePath, registryUrl); await npmRegistry.init(); @@ -58,7 +58,7 @@ export class SmartRegistry { // Initialize Maven registry if enabled if (this.config.maven?.enabled) { - const mavenBasePath = this.config.maven.basePath || '/maven'; + const mavenBasePath = this.config.maven.basePath ?? '/maven'; const registryUrl = `http://localhost:5000${mavenBasePath}`; // TODO: Make configurable const mavenRegistry = new MavenRegistry(this.storage, this.authManager, mavenBasePath, registryUrl); await mavenRegistry.init(); @@ -67,7 +67,7 @@ export class SmartRegistry { // Initialize Cargo registry if enabled if (this.config.cargo?.enabled) { - const cargoBasePath = this.config.cargo.basePath || '/cargo'; + const cargoBasePath = this.config.cargo.basePath ?? '/cargo'; const registryUrl = `http://localhost:5000${cargoBasePath}`; // TODO: Make configurable const cargoRegistry = new CargoRegistry(this.storage, this.authManager, cargoBasePath, registryUrl); await cargoRegistry.init(); @@ -76,7 +76,7 @@ export class SmartRegistry { // Initialize Composer registry if enabled if (this.config.composer?.enabled) { - const composerBasePath = this.config.composer.basePath || '/composer'; + const composerBasePath = this.config.composer.basePath ?? '/composer'; const registryUrl = `http://localhost:5000${composerBasePath}`; // TODO: Make configurable const composerRegistry = new ComposerRegistry(this.storage, this.authManager, composerBasePath, registryUrl); await composerRegistry.init(); @@ -85,7 +85,7 @@ export class SmartRegistry { // Initialize PyPI registry if enabled if (this.config.pypi?.enabled) { - const pypiBasePath = this.config.pypi.basePath || '/pypi'; + const pypiBasePath = this.config.pypi.basePath ?? '/pypi'; const registryUrl = `http://localhost:5000`; // TODO: Make configurable const pypiRegistry = new PypiRegistry(this.storage, this.authManager, pypiBasePath, registryUrl); await pypiRegistry.init(); @@ -94,7 +94,7 @@ export class SmartRegistry { // Initialize RubyGems registry if enabled if (this.config.rubygems?.enabled) { - const rubygemsBasePath = this.config.rubygems.basePath || '/rubygems'; + const rubygemsBasePath = this.config.rubygems.basePath ?? '/rubygems'; const registryUrl = `http://localhost:5000${rubygemsBasePath}`; // TODO: Make configurable const rubygemsRegistry = new RubyGemsRegistry(this.storage, this.authManager, rubygemsBasePath, registryUrl); await rubygemsRegistry.init(); @@ -153,7 +153,7 @@ export class SmartRegistry { // Route to PyPI registry (also handles /simple prefix) if (this.config.pypi?.enabled) { - const pypiBasePath = this.config.pypi.basePath || '/pypi'; + const pypiBasePath = this.config.pypi.basePath ?? '/pypi'; if (path.startsWith(pypiBasePath) || path.startsWith('/simple')) { const pypiRegistry = this.registries.get('pypi'); if (pypiRegistry) { diff --git a/ts/core/classes.authmanager.ts b/ts/core/classes.authmanager.ts index 1f6ea81..d84bad3 100644 --- a/ts/core/classes.authmanager.ts +++ b/ts/core/classes.authmanager.ts @@ -1,4 +1,5 @@ import type { IAuthConfig, IAuthToken, ICredentials, TRegistryProtocol } from './interfaces.core.js'; +import * as crypto from 'crypto'; /** * Unified authentication manager for all registry protocols @@ -136,7 +137,7 @@ export class AuthManager { * @param userId - User ID * @param scopes - Permission scopes * @param expiresIn - Expiration time in seconds - * @returns JWT token string + * @returns JWT token string (HMAC-SHA256 signed) */ public async createOciToken( userId: string, @@ -158,9 +159,17 @@ export class AuthManager { access: this.scopesToOciAccess(scopes), }; - // In production, use proper JWT library with signing - // For now, return JSON string (mock JWT) - return JSON.stringify(payload); + // Create JWT with HMAC-SHA256 signature + const header = { alg: 'HS256', typ: 'JWT' }; + const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url'); + const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url'); + + const signature = crypto + .createHmac('sha256', this.config.jwtSecret) + .update(`${headerB64}.${payloadB64}`) + .digest('base64url'); + + return `${headerB64}.${payloadB64}.${signature}`; } /** @@ -170,8 +179,25 @@ export class AuthManager { */ public async validateOciToken(jwt: string): Promise { try { - // In production, verify JWT signature - const payload = JSON.parse(jwt); + const parts = jwt.split('.'); + if (parts.length !== 3) { + return null; + } + + const [headerB64, payloadB64, signatureB64] = parts; + + // Verify signature + const expectedSignature = crypto + .createHmac('sha256', this.config.jwtSecret) + .update(`${headerB64}.${payloadB64}`) + .digest('base64url'); + + if (signatureB64 !== expectedSignature) { + return null; + } + + // Decode and parse payload + const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf-8')); // Check expiration const now = Math.floor(Date.now() / 1000); @@ -179,6 +205,11 @@ export class AuthManager { return null; } + // Check not-before time + if (payload.nbf && payload.nbf > now) { + return null; + } + // Convert to unified token format const scopes = this.ociAccessToScopes(payload.access || []); diff --git a/ts/oci/classes.ociregistry.ts b/ts/oci/classes.ociregistry.ts index 56dfe50..8466d46 100644 --- a/ts/oci/classes.ociregistry.ts +++ b/ts/oci/classes.ociregistry.ts @@ -180,11 +180,7 @@ export class OciRegistry extends BaseRegistry { body?: Buffer | any ): Promise { if (!await this.checkPermission(token, repository, 'push')) { - return { - status: 401, - headers: {}, - body: this.createError('DENIED', 'Insufficient permissions'), - }; + return this.createUnauthorizedResponse(repository, 'push'); } // Check for monolithic upload (digest + body provided) @@ -255,11 +251,7 @@ export class OciRegistry extends BaseRegistry { } if (!await this.checkPermission(token, session.repository, 'push')) { - return { - status: 401, - headers: {}, - body: this.createError('DENIED', 'Insufficient permissions'), - }; + return this.createUnauthorizedResponse(session.repository, 'push'); } switch (method) { @@ -336,11 +328,7 @@ export class OciRegistry extends BaseRegistry { token: IAuthToken | null ): Promise { if (!await this.checkPermission(token, repository, 'pull')) { - return { - status: 401, - headers: {}, - body: null, - }; + return this.createUnauthorizedHeadResponse(repository, 'pull'); } // Similar logic as getManifest but return headers only @@ -437,11 +425,7 @@ export class OciRegistry extends BaseRegistry { } if (!await this.checkPermission(token, repository, 'delete')) { - return { - status: 401, - headers: {}, - body: this.createError('DENIED', 'Insufficient permissions'), - }; + return this.createUnauthorizedResponse(repository, 'delete'); } await this.storage.deleteOciManifest(repository, digest); @@ -460,11 +444,7 @@ export class OciRegistry extends BaseRegistry { range?: string ): Promise { if (!await this.checkPermission(token, repository, 'pull')) { - return { - status: 401, - headers: {}, - body: this.createError('DENIED', 'Insufficient permissions'), - }; + return this.createUnauthorizedResponse(repository, 'pull'); } const data = await this.storage.getOciBlob(digest); @@ -492,7 +472,7 @@ export class OciRegistry extends BaseRegistry { token: IAuthToken | null ): Promise { if (!await this.checkPermission(token, repository, 'pull')) { - return { status: 401, headers: {}, body: null }; + return this.createUnauthorizedHeadResponse(repository, 'pull'); } const exists = await this.storage.ociBlobExists(digest); @@ -518,11 +498,7 @@ export class OciRegistry extends BaseRegistry { token: IAuthToken | null ): Promise { if (!await this.checkPermission(token, repository, 'delete')) { - return { - status: 401, - headers: {}, - body: this.createError('DENIED', 'Insufficient permissions'), - }; + return this.createUnauthorizedResponse(repository, 'delete'); } await this.storage.deleteOciBlob(digest); @@ -631,11 +607,7 @@ export class OciRegistry extends BaseRegistry { query: Record ): Promise { if (!await this.checkPermission(token, repository, 'pull')) { - return { - status: 401, - headers: {}, - body: this.createError('DENIED', 'Insufficient permissions'), - }; + return this.createUnauthorizedResponse(repository, 'pull'); } const tags = await this.getTagsData(repository); @@ -660,11 +632,7 @@ export class OciRegistry extends BaseRegistry { query: Record ): Promise { if (!await this.checkPermission(token, repository, 'pull')) { - return { - status: 401, - headers: {}, - body: this.createError('DENIED', 'Insufficient permissions'), - }; + return this.createUnauthorizedResponse(repository, 'pull'); } const response: IReferrersResponse = { @@ -712,6 +680,33 @@ export class OciRegistry extends BaseRegistry { }; } + /** + * Create an unauthorized response with proper WWW-Authenticate header. + * Per OCI Distribution Spec, 401 responses MUST include WWW-Authenticate header. + */ + private createUnauthorizedResponse(repository: string, action: string): IResponse { + return { + status: 401, + headers: { + 'WWW-Authenticate': `Bearer realm="${this.basePath}/v2/token",service="registry",scope="repository:${repository}:${action}"`, + }, + body: this.createError('DENIED', 'Insufficient permissions'), + }; + } + + /** + * Create an unauthorized HEAD response (no body per HTTP spec). + */ + private createUnauthorizedHeadResponse(repository: string, action: string): IResponse { + return { + status: 401, + headers: { + 'WWW-Authenticate': `Bearer realm="${this.basePath}/v2/token",service="registry",scope="repository:${repository}:${action}"`, + }, + body: null, + }; + } + private startUploadSessionCleanup(): void { this.cleanupInterval = setInterval(() => { const now = new Date(); diff --git a/ts/pypi/classes.pypiregistry.ts b/ts/pypi/classes.pypiregistry.ts index 4a2d39a..396a66f 100644 --- a/ts/pypi/classes.pypiregistry.ts +++ b/ts/pypi/classes.pypiregistry.ts @@ -185,7 +185,7 @@ export class PypiRegistry extends BaseRegistry { 'Content-Type': 'application/vnd.pypi.simple.v1+json', 'Cache-Control': 'public, max-age=600' }, - body: Buffer.from(JSON.stringify(response)), + body: response, }; } else { // PEP 503: HTML response @@ -200,7 +200,7 @@ export class PypiRegistry extends BaseRegistry { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=600' }, - body: Buffer.from(html), + body: html, }; } } @@ -218,7 +218,7 @@ export class PypiRegistry extends BaseRegistry { return { status: 404, headers: { 'Content-Type': 'text/html; charset=utf-8' }, - body: Buffer.from('

404 Not Found

'), + body: '

404 Not Found

', }; } @@ -251,7 +251,7 @@ export class PypiRegistry extends BaseRegistry { 'Content-Type': 'application/vnd.pypi.simple.v1+json', 'Cache-Control': 'public, max-age=300' }, - body: Buffer.from(JSON.stringify(response)), + body: response, }; } else { // PEP 503: HTML response @@ -266,7 +266,7 @@ export class PypiRegistry extends BaseRegistry { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=300' }, - body: Buffer.from(html), + body: html, }; } } @@ -327,11 +327,13 @@ export class PypiRegistry extends BaseRegistry { return this.errorResponse(400, 'Invalid upload request'); } - // Extract required fields + // Extract required fields - support both nested and flat body formats const packageName = formData.name; const version = formData.version; - const filename = formData.content?.filename; - const fileData = formData.content?.data as Buffer; + // Support both: formData.content.filename (multipart parsed) and formData.filename (flat) + const filename = formData.content?.filename || formData.filename; + // Support both: formData.content.data (multipart parsed) and formData.content (Buffer directly) + const fileData = (formData.content?.data || (Buffer.isBuffer(formData.content) ? formData.content : null)) as Buffer; const filetype = formData.filetype; // 'bdist_wheel' or 'sdist' const pyversion = formData.pyversion; @@ -431,7 +433,7 @@ export class PypiRegistry extends BaseRegistry { }); return { - status: 200, + status: 201, headers: { 'Content-Type': 'application/json' }, body: Buffer.from(JSON.stringify({ message: 'Package uploaded successfully', diff --git a/ts/rubygems/classes.rubygemsregistry.ts b/ts/rubygems/classes.rubygemsregistry.ts index 5f69620..2b8fd75 100644 --- a/ts/rubygems/classes.rubygemsregistry.ts +++ b/ts/rubygems/classes.rubygemsregistry.ts @@ -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', diff --git a/ts/rubygems/helpers.rubygems.ts b/ts/rubygems/helpers.rubygems.ts index b86126e..9b30fe7 100644 --- a/ts/rubygems/helpers.rubygems.ts +++ b/ts/rubygems/helpers.rubygems.ts @@ -396,3 +396,54 @@ export async function extractGemSpec(gemData: Buffer): Promise { 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; + } +}