diff --git a/changelog.md b/changelog.md index db62492..4a6626f 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,17 @@ # Changelog +## 2025-11-21 - 1.2.0 - feat(maven) +Add Maven registry protocol support (storage, auth, routing, interfaces, and exports) + +- Add Maven protocol to core types (TRegistryProtocol) and IRegistryConfig +- SmartRegistry: initialize Maven registry when enabled, route requests to /maven, and expose it via getRegistry +- RegistryStorage: implement Maven storage helpers (get/put/delete artifact, metadata, list versions) and path helpers +- AuthManager: add UUID token creation/validation/revocation for Maven and integrate into unified validateToken/authorize flow +- New ts/maven module: exports, interfaces and helpers for Maven coordinates, metadata, and search results +- Add basic Cargo (crates.io) scaffolding: ts/cargo exports and Cargo interfaces +- Update top-level ts/index.ts and package exports to include Maven (and cargo) modules +- Tests/helpers updated to enable Maven in test registry and add Maven artifact/checksum helpers + ## 2025-11-20 - 1.1.1 - fix(oci) Improve OCI manifest permission response and tag handling: include WWW-Authenticate header on unauthorized manifest GETs, accept optional headers in manifest lookup, and persist tags as a unified tags.json mapping when pushing manifests. diff --git a/test/helpers/registry.ts b/test/helpers/registry.ts index c8e11a8..65b4dc3 100644 --- a/test/helpers/registry.ts +++ b/test/helpers/registry.ts @@ -6,7 +6,7 @@ import type { IRegistryConfig } from '../../ts/core/interfaces.core.js'; const testQenv = new qenv.Qenv('./', './.nogit'); /** - * Create a test SmartRegistry instance with both OCI and NPM enabled + * Create a test SmartRegistry instance with OCI, NPM, and Maven enabled */ export async function createTestRegistry(): Promise { // Read S3 config from env.json @@ -45,6 +45,10 @@ export async function createTestRegistry(): Promise { enabled: true, basePath: '/npm', }, + maven: { + enabled: true, + basePath: '/maven', + }, }; const registry = new SmartRegistry(config); @@ -79,7 +83,10 @@ export async function createTestTokens(registry: SmartRegistry) { 3600 ); - return { npmToken, ociToken, userId }; + // Create Maven token with full access + const mavenToken = await authManager.createMavenToken(userId, false); + + return { npmToken, ociToken, mavenToken, userId }; } /** @@ -147,3 +154,54 @@ export function createTestPackument(packageName: string, version: string, tarbal }, }; } + +/** + * Helper to create a minimal valid Maven POM file + */ +export function createTestPom( + groupId: string, + artifactId: string, + version: string, + packaging: string = 'jar' +): string { + return ` + + 4.0.0 + ${groupId} + ${artifactId} + ${version} + ${packaging} + ${artifactId} + Test Maven artifact +`; +} + +/** + * Helper to create a test JAR file (minimal ZIP with manifest) + */ +export function createTestJar(): Buffer { + // Create a simple JAR structure (just a manifest) + // In practice, this is a ZIP file with at least META-INF/MANIFEST.MF + const manifestContent = `Manifest-Version: 1.0 +Created-By: SmartRegistry Test +`; + + // For testing, we'll just create a buffer with dummy content + // Real JAR would be a proper ZIP archive + return Buffer.from(manifestContent, 'utf-8'); +} + +/** + * Helper to calculate Maven checksums + */ +export function calculateMavenChecksums(data: Buffer) { + return { + md5: crypto.createHash('md5').update(data).digest('hex'), + sha1: crypto.createHash('sha1').update(data).digest('hex'), + sha256: crypto.createHash('sha256').update(data).digest('hex'), + sha512: crypto.createHash('sha512').update(data).digest('hex'), + }; +} diff --git a/test/test.maven.ts b/test/test.maven.ts new file mode 100644 index 0000000..686ac68 --- /dev/null +++ b/test/test.maven.ts @@ -0,0 +1,372 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { SmartRegistry } from '../ts/index.js'; +import { + createTestRegistry, + createTestTokens, + createTestPom, + createTestJar, + calculateMavenChecksums, +} from './helpers/registry.js'; + +let registry: SmartRegistry; +let mavenToken: string; +let userId: string; + +// Test data +const testGroupId = 'com.example.test'; +const testArtifactId = 'test-artifact'; +const testVersion = '1.0.0'; +const testJarData = createTestJar(); +const testPomData = Buffer.from( + createTestPom(testGroupId, testArtifactId, testVersion), + 'utf-8' +); + +tap.test('Maven: should create registry instance', async () => { + registry = await createTestRegistry(); + const tokens = await createTestTokens(registry); + mavenToken = tokens.mavenToken; + userId = tokens.userId; + + expect(registry).toBeInstanceOf(SmartRegistry); + expect(mavenToken).toBeTypeOf('string'); +}); + +tap.test('Maven: should upload POM file (PUT /{groupPath}/{artifactId}/{version}/*.pom)', async () => { + const groupPath = testGroupId.replace(/\./g, '/'); + const pomFilename = `${testArtifactId}-${testVersion}.pom`; + + const response = await registry.handleRequest({ + method: 'PUT', + path: `/maven/${groupPath}/${testArtifactId}/${testVersion}/${pomFilename}`, + headers: { + Authorization: `Bearer ${mavenToken}`, + 'Content-Type': 'application/xml', + }, + query: {}, + body: testPomData, + }); + + expect(response.status).toEqual(201); +}); + +tap.test('Maven: should upload JAR file (PUT /{groupPath}/{artifactId}/{version}/*.jar)', async () => { + const groupPath = testGroupId.replace(/\./g, '/'); + const jarFilename = `${testArtifactId}-${testVersion}.jar`; + + const response = await registry.handleRequest({ + method: 'PUT', + path: `/maven/${groupPath}/${testArtifactId}/${testVersion}/${jarFilename}`, + headers: { + Authorization: `Bearer ${mavenToken}`, + 'Content-Type': 'application/java-archive', + }, + query: {}, + body: testJarData, + }); + + expect(response.status).toEqual(201); +}); + +tap.test('Maven: should retrieve uploaded POM file (GET)', async () => { + const groupPath = testGroupId.replace(/\./g, '/'); + const pomFilename = `${testArtifactId}-${testVersion}.pom`; + + const response = await registry.handleRequest({ + method: 'GET', + path: `/maven/${groupPath}/${testArtifactId}/${testVersion}/${pomFilename}`, + headers: {}, + query: {}, + }); + + expect(response.status).toEqual(200); + expect(response.body).toBeInstanceOf(Buffer); + expect((response.body as Buffer).toString('utf-8')).toContain(testGroupId); + expect((response.body as Buffer).toString('utf-8')).toContain(testArtifactId); + expect((response.body as Buffer).toString('utf-8')).toContain(testVersion); + expect(response.headers['Content-Type']).toEqual('application/xml'); +}); + +tap.test('Maven: should retrieve uploaded JAR file (GET)', async () => { + const groupPath = testGroupId.replace(/\./g, '/'); + const jarFilename = `${testArtifactId}-${testVersion}.jar`; + + const response = await registry.handleRequest({ + method: 'GET', + path: `/maven/${groupPath}/${testArtifactId}/${testVersion}/${jarFilename}`, + headers: {}, + query: {}, + }); + + expect(response.status).toEqual(200); + expect(response.body).toBeInstanceOf(Buffer); + expect(response.headers['Content-Type']).toEqual('application/java-archive'); +}); + +tap.test('Maven: should retrieve MD5 checksum for JAR (GET *.jar.md5)', async () => { + const groupPath = testGroupId.replace(/\./g, '/'); + const jarFilename = `${testArtifactId}-${testVersion}.jar`; + const checksums = calculateMavenChecksums(testJarData); + + const response = await registry.handleRequest({ + method: 'GET', + path: `/maven/${groupPath}/${testArtifactId}/${testVersion}/${jarFilename}.md5`, + headers: {}, + query: {}, + }); + + expect(response.status).toEqual(200); + expect(response.body).toBeInstanceOf(Buffer); + expect((response.body as Buffer).toString('utf-8')).toEqual(checksums.md5); + expect(response.headers['Content-Type']).toEqual('text/plain'); +}); + +tap.test('Maven: should retrieve SHA1 checksum for JAR (GET *.jar.sha1)', async () => { + const groupPath = testGroupId.replace(/\./g, '/'); + const jarFilename = `${testArtifactId}-${testVersion}.jar`; + const checksums = calculateMavenChecksums(testJarData); + + const response = await registry.handleRequest({ + method: 'GET', + path: `/maven/${groupPath}/${testArtifactId}/${testVersion}/${jarFilename}.sha1`, + headers: {}, + query: {}, + }); + + expect(response.status).toEqual(200); + expect(response.body).toBeInstanceOf(Buffer); + expect((response.body as Buffer).toString('utf-8')).toEqual(checksums.sha1); + expect(response.headers['Content-Type']).toEqual('text/plain'); +}); + +tap.test('Maven: should retrieve SHA256 checksum for JAR (GET *.jar.sha256)', async () => { + const groupPath = testGroupId.replace(/\./g, '/'); + const jarFilename = `${testArtifactId}-${testVersion}.jar`; + const checksums = calculateMavenChecksums(testJarData); + + const response = await registry.handleRequest({ + method: 'GET', + path: `/maven/${groupPath}/${testArtifactId}/${testVersion}/${jarFilename}.sha256`, + headers: {}, + query: {}, + }); + + expect(response.status).toEqual(200); + expect(response.body).toBeInstanceOf(Buffer); + expect((response.body as Buffer).toString('utf-8')).toEqual(checksums.sha256); + expect(response.headers['Content-Type']).toEqual('text/plain'); +}); + +tap.test('Maven: should retrieve SHA512 checksum for JAR (GET *.jar.sha512)', async () => { + const groupPath = testGroupId.replace(/\./g, '/'); + const jarFilename = `${testArtifactId}-${testVersion}.jar`; + const checksums = calculateMavenChecksums(testJarData); + + const response = await registry.handleRequest({ + method: 'GET', + path: `/maven/${groupPath}/${testArtifactId}/${testVersion}/${jarFilename}.sha512`, + headers: {}, + query: {}, + }); + + expect(response.status).toEqual(200); + expect(response.body).toBeInstanceOf(Buffer); + expect((response.body as Buffer).toString('utf-8')).toEqual(checksums.sha512); + expect(response.headers['Content-Type']).toEqual('text/plain'); +}); + +tap.test('Maven: should retrieve maven-metadata.xml (GET)', async () => { + const groupPath = testGroupId.replace(/\./g, '/'); + + const response = await registry.handleRequest({ + method: 'GET', + path: `/maven/${groupPath}/${testArtifactId}/maven-metadata.xml`, + headers: {}, + query: {}, + }); + + expect(response.status).toEqual(200); + expect(response.body).toBeInstanceOf(Buffer); + const xml = (response.body as Buffer).toString('utf-8'); + expect(xml).toContain(''); + expect(xml).toContain(''); + expect(xml).toContain('1.0.0'); + expect(xml).toContain('1.0.0'); + expect(xml).toContain('1.0.0'); + expect(response.headers['Content-Type']).toEqual('application/xml'); +}); + +tap.test('Maven: should upload a second version and update metadata', async () => { + const groupPath = testGroupId.replace(/\./g, '/'); + const newVersion = '2.0.0'; + const pomFilename = `${testArtifactId}-${newVersion}.pom`; + const jarFilename = `${testArtifactId}-${newVersion}.jar`; + const newPomData = Buffer.from( + createTestPom(testGroupId, testArtifactId, newVersion), + 'utf-8' + ); + + // Upload POM + await registry.handleRequest({ + method: 'PUT', + path: `/maven/${groupPath}/${testArtifactId}/${newVersion}/${pomFilename}`, + headers: { + Authorization: `Bearer ${mavenToken}`, + 'Content-Type': 'application/xml', + }, + query: {}, + body: newPomData, + }); + + // Upload JAR + await registry.handleRequest({ + method: 'PUT', + path: `/maven/${groupPath}/${testArtifactId}/${newVersion}/${jarFilename}`, + headers: { + Authorization: `Bearer ${mavenToken}`, + 'Content-Type': 'application/java-archive', + }, + query: {}, + body: testJarData, + }); + + // Retrieve metadata and verify both versions are present + const response = await registry.handleRequest({ + method: 'GET', + path: `/maven/${groupPath}/${testArtifactId}/maven-metadata.xml`, + headers: {}, + query: {}, + }); + + expect(response.status).toEqual(200); + const xml = (response.body as Buffer).toString('utf-8'); + expect(xml).toContain('1.0.0'); + expect(xml).toContain('2.0.0'); + expect(xml).toContain('2.0.0'); + expect(xml).toContain('2.0.0'); +}); + +tap.test('Maven: should upload WAR file with correct content type', async () => { + const groupPath = testGroupId.replace(/\./g, '/'); + const warVersion = '1.0.0-war'; + const warFilename = `${testArtifactId}-${warVersion}.war`; + const warData = Buffer.from('fake war content', 'utf-8'); + + const response = await registry.handleRequest({ + method: 'PUT', + path: `/maven/${groupPath}/${testArtifactId}/${warVersion}/${warFilename}`, + headers: { + Authorization: `Bearer ${mavenToken}`, + 'Content-Type': 'application/x-webarchive', + }, + query: {}, + body: warData, + }); + + expect(response.status).toEqual(201); +}); + +tap.test('Maven: should return 404 for non-existent artifact', async () => { + const groupPath = 'com/example/nonexistent'; + + const response = await registry.handleRequest({ + method: 'GET', + path: `/maven/${groupPath}/fake-artifact/1.0.0/fake-artifact-1.0.0.jar`, + headers: {}, + query: {}, + }); + + expect(response.status).toEqual(404); + expect(response.body).toHaveProperty('error'); +}); + +tap.test('Maven: should return 401 for unauthorized upload', async () => { + const groupPath = testGroupId.replace(/\./g, '/'); + const jarFilename = `${testArtifactId}-3.0.0.jar`; + + const response = await registry.handleRequest({ + method: 'PUT', + path: `/maven/${groupPath}/${testArtifactId}/3.0.0/${jarFilename}`, + headers: { + // No authorization header + 'Content-Type': 'application/java-archive', + }, + query: {}, + body: testJarData, + }); + + expect(response.status).toEqual(401); + expect(response.body).toHaveProperty('error'); +}); + +tap.test('Maven: should reject POM upload with mismatched GAV', async () => { + const groupPath = 'com/mismatch/test'; + const pomFilename = `different-artifact-1.0.0.pom`; + // POM contains different GAV than the path + const mismatchedPom = Buffer.from( + createTestPom('com.other.group', 'other-artifact', '1.0.0'), + 'utf-8' + ); + + const response = await registry.handleRequest({ + method: 'PUT', + path: `/maven/${groupPath}/different-artifact/1.0.0/${pomFilename}`, + headers: { + Authorization: `Bearer ${mavenToken}`, + 'Content-Type': 'application/xml', + }, + query: {}, + body: mismatchedPom, + }); + + expect(response.status).toEqual(400); + expect(response.body).toHaveProperty('error'); +}); + +tap.test('Maven: should delete an artifact (DELETE)', async () => { + const groupPath = testGroupId.replace(/\./g, '/'); + const jarFilename = `${testArtifactId}-${testVersion}.jar`; + + const response = await registry.handleRequest({ + method: 'DELETE', + path: `/maven/${groupPath}/${testArtifactId}/${testVersion}/${jarFilename}`, + headers: { + Authorization: `Bearer ${mavenToken}`, + }, + query: {}, + }); + + expect(response.status).toEqual(200); + + // Verify artifact was deleted + const getResponse = await registry.handleRequest({ + method: 'GET', + path: `/maven/${groupPath}/${testArtifactId}/${testVersion}/${jarFilename}`, + headers: {}, + query: {}, + }); + + expect(getResponse.status).toEqual(404); +}); + +tap.test('Maven: should return 404 for checksum of deleted artifact', async () => { + const groupPath = testGroupId.replace(/\./g, '/'); + const jarFilename = `${testArtifactId}-${testVersion}.jar`; + + const response = await registry.handleRequest({ + method: 'GET', + path: `/maven/${groupPath}/${testArtifactId}/${testVersion}/${jarFilename}.md5`, + headers: {}, + query: {}, + }); + + expect(response.status).toEqual(404); +}); + +tap.postTask('cleanup registry', async () => { + if (registry) { + registry.destroy(); + } +}); + +export default tap.start(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 4e67c25..d81e79e 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.1.1', + version: '1.2.0', description: 'a registry for npm modules and oci images' } diff --git a/ts/cargo/classes.cargoregistry.ts b/ts/cargo/classes.cargoregistry.ts new file mode 100644 index 0000000..a0441fd --- /dev/null +++ b/ts/cargo/classes.cargoregistry.ts @@ -0,0 +1,604 @@ +import { Smartlog } from '@push.rocks/smartlog'; +import { BaseRegistry } from '../core/classes.baseregistry.js'; +import { RegistryStorage } from '../core/classes.registrystorage.js'; +import { AuthManager } from '../core/classes.authmanager.js'; +import type { IRequestContext, IResponse, IAuthToken } from '../core/interfaces.core.js'; +import type { + ICargoIndexEntry, + ICargoPublishMetadata, + ICargoConfig, + ICargoError, + ICargoPublishResponse, + ICargoYankResponse, + ICargoSearchResponse, + ICargoSearchResult, +} from './interfaces.cargo.js'; + +/** + * Cargo/crates.io registry implementation + * Implements the sparse HTTP-based protocol + * Spec: https://doc.rust-lang.org/cargo/reference/registry-index.html + */ +export class CargoRegistry extends BaseRegistry { + private storage: RegistryStorage; + private authManager: AuthManager; + private basePath: string = '/cargo'; + private registryUrl: string; + private logger: Smartlog; + + constructor( + storage: RegistryStorage, + authManager: AuthManager, + basePath: string = '/cargo', + registryUrl: string = 'http://localhost:5000/cargo' + ) { + super(); + this.storage = storage; + this.authManager = authManager; + this.basePath = basePath; + this.registryUrl = registryUrl; + + // Initialize logger + this.logger = new Smartlog({ + logContext: { + company: 'push.rocks', + companyunit: 'smartregistry', + containerName: 'cargo-registry', + environment: (process.env.NODE_ENV as any) || 'development', + runtime: 'node', + zone: 'cargo' + } + }); + this.logger.enableConsole(); + } + + public async init(): Promise { + // Initialize config.json if not exists + const existingConfig = await this.storage.getCargoConfig(); + if (!existingConfig) { + const config: ICargoConfig = { + dl: `${this.registryUrl}/api/v1/crates/{crate}/{version}/download`, + api: this.registryUrl, + }; + await this.storage.putCargoConfig(config); + this.logger.log('info', 'Initialized Cargo registry config', { config }); + } + } + + public getBasePath(): string { + return this.basePath; + } + + public async handleRequest(context: IRequestContext): Promise { + const path = context.path.replace(this.basePath, ''); + + // Extract token (Cargo uses Authorization header WITHOUT "Bearer" prefix) + const authHeader = context.headers['authorization'] || context.headers['Authorization']; + const token = authHeader ? await this.authManager.validateToken(authHeader, 'cargo') : null; + + this.logger.log('debug', `handleRequest: ${context.method} ${path}`, { + method: context.method, + path, + hasAuth: !!token + }); + + // Config endpoint (required for sparse protocol) + if (path === '/config.json') { + return this.handleConfigJson(); + } + + // API endpoints + if (path.startsWith('/api/v1/')) { + return this.handleApiRequest(path, context, token); + } + + // Index files (sparse protocol) + return this.handleIndexRequest(path); + } + + /** + * Check if token has permission for resource + */ + protected async checkPermission( + token: IAuthToken | null, + resource: string, + action: string + ): Promise { + if (!token) return false; + return this.authManager.authorize(token, `cargo:crate:${resource}`, action); + } + + /** + * Handle API requests (/api/v1/*) + */ + private async handleApiRequest( + path: string, + context: IRequestContext, + token: IAuthToken | null + ): Promise { + // Publish: PUT /api/v1/crates/new + if (path === '/api/v1/crates/new' && context.method === 'PUT') { + return this.handlePublish(context.body as Buffer, token); + } + + // Download: GET /api/v1/crates/{crate}/{version}/download + const downloadMatch = path.match(/^\/api\/v1\/crates\/([^\/]+)\/([^\/]+)\/download$/); + if (downloadMatch && context.method === 'GET') { + return this.handleDownload(downloadMatch[1], downloadMatch[2]); + } + + // Yank: DELETE /api/v1/crates/{crate}/{version}/yank + const yankMatch = path.match(/^\/api\/v1\/crates\/([^\/]+)\/([^\/]+)\/yank$/); + if (yankMatch && context.method === 'DELETE') { + return this.handleYank(yankMatch[1], yankMatch[2], token); + } + + // Unyank: PUT /api/v1/crates/{crate}/{version}/unyank + const unyankMatch = path.match(/^\/api\/v1\/crates\/([^\/]+)\/([^\/]+)\/unyank$/); + if (unyankMatch && context.method === 'PUT') { + return this.handleUnyank(unyankMatch[1], unyankMatch[2], token); + } + + // Search: GET /api/v1/crates?q={query} + if (path.startsWith('/api/v1/crates') && context.method === 'GET') { + const query = context.query?.q || ''; + const perPage = parseInt(context.query?.per_page || '10', 10); + return this.handleSearch(query, perPage); + } + + return { + status: 404, + headers: { 'Content-Type': 'application/json' }, + body: this.createError('API endpoint not found'), + }; + } + + /** + * Handle index file requests + * Paths: /1/{name}, /2/{name}, /3/{c}/{name}, /{p1}/{p2}/{name} + */ + private async handleIndexRequest(path: string): Promise { + // Parse index paths to extract crate name + const pathParts = path.split('/').filter(p => p); + let crateName: string | null = null; + + if (pathParts.length === 2 && pathParts[0] === '1') { + // 1-character names: /1/{name} + crateName = pathParts[1]; + } else if (pathParts.length === 2 && pathParts[0] === '2') { + // 2-character names: /2/{name} + crateName = pathParts[1]; + } else if (pathParts.length === 3 && pathParts[0] === '3') { + // 3-character names: /3/{c}/{name} + crateName = pathParts[2]; + } else if (pathParts.length === 3) { + // 4+ character names: /{p1}/{p2}/{name} + crateName = pathParts[2]; + } + + if (!crateName) { + return { + status: 404, + headers: { 'Content-Type': 'text/plain' }, + body: Buffer.from(''), + }; + } + + return this.handleIndexFile(crateName); + } + + /** + * Serve config.json + */ + private async handleConfigJson(): Promise { + const config = await this.storage.getCargoConfig(); + + return { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: config || { + dl: `${this.registryUrl}/api/v1/crates/{crate}/{version}/download`, + api: this.registryUrl, + }, + }; + } + + /** + * Serve index file for a crate + */ + private async handleIndexFile(crateName: string): Promise { + const index = await this.storage.getCargoIndex(crateName); + + if (!index || index.length === 0) { + return { + status: 404, + headers: { 'Content-Type': 'text/plain' }, + body: Buffer.from(''), + }; + } + + // Return newline-delimited JSON + const data = index.map(e => JSON.stringify(e)).join('\n') + '\n'; + + // Calculate ETag for caching + const crypto = await import('crypto'); + const etag = `"${crypto.createHash('sha256').update(data).digest('hex')}"`; + + return { + status: 200, + headers: { + 'Content-Type': 'text/plain', + 'ETag': etag, + }, + body: Buffer.from(data, 'utf-8'), + }; + } + + /** + * Parse binary publish request + * Format: [4 bytes JSON len][JSON][4 bytes crate len][.crate file] + */ + private parsePublishRequest(body: Buffer): { + metadata: ICargoPublishMetadata; + crateFile: Buffer; + } { + let offset = 0; + + // Read JSON length (4 bytes, u32 little-endian) + if (body.length < 4) { + throw new Error('Invalid publish request: body too short'); + } + const jsonLength = body.readUInt32LE(offset); + offset += 4; + + // Read JSON metadata + if (body.length < offset + jsonLength) { + throw new Error('Invalid publish request: JSON data incomplete'); + } + const jsonBuffer = body.slice(offset, offset + jsonLength); + const metadata = JSON.parse(jsonBuffer.toString('utf-8')); + offset += jsonLength; + + // Read crate file length (4 bytes, u32 little-endian) + if (body.length < offset + 4) { + throw new Error('Invalid publish request: crate length missing'); + } + const crateLength = body.readUInt32LE(offset); + offset += 4; + + // Read crate file + if (body.length < offset + crateLength) { + throw new Error('Invalid publish request: crate data incomplete'); + } + const crateFile = body.slice(offset, offset + crateLength); + + return { metadata, crateFile }; + } + + /** + * Handle crate publish + */ + private async handlePublish( + body: Buffer, + token: IAuthToken | null + ): Promise { + this.logger.log('info', 'handlePublish: received publish request', { + bodyLength: body?.length || 0, + hasAuth: !!token + }); + + // Check authorization + if (!token) { + return { + status: 403, + headers: { 'Content-Type': 'application/json' }, + body: this.createError('Authentication required'), + }; + } + + // Parse binary request + let metadata: ICargoPublishMetadata; + let crateFile: Buffer; + try { + const parsed = this.parsePublishRequest(body); + metadata = parsed.metadata; + crateFile = parsed.crateFile; + } catch (error) { + this.logger.log('error', 'handlePublish: parse error', { error: error.message }); + return { + status: 400, + headers: { 'Content-Type': 'application/json' }, + body: this.createError(`Invalid request format: ${error.message}`), + }; + } + + // Validate crate name + if (!this.validateCrateName(metadata.name)) { + return { + status: 400, + headers: { 'Content-Type': 'application/json' }, + body: this.createError('Invalid crate name'), + }; + } + + // Check permission + const hasPermission = await this.checkPermission(token, metadata.name, 'write'); + if (!hasPermission) { + this.logger.log('warn', 'handlePublish: unauthorized', { + crateName: metadata.name, + userId: token.userId + }); + return { + status: 403, + headers: { 'Content-Type': 'application/json' }, + body: this.createError('Insufficient permissions'), + }; + } + + // Calculate SHA256 checksum + const crypto = await import('crypto'); + const cksum = crypto.createHash('sha256').update(crateFile).digest('hex'); + + // Create index entry + const indexEntry: ICargoIndexEntry = { + name: metadata.name, + vers: metadata.vers, + deps: metadata.deps, + cksum, + features: metadata.features, + yanked: false, + links: metadata.links || null, + v: 2, + rust_version: metadata.rust_version, + }; + + // Check for duplicate version + const existingIndex = await this.storage.getCargoIndex(metadata.name) || []; + if (existingIndex.some(e => e.vers === metadata.vers)) { + return { + status: 400, + headers: { 'Content-Type': 'application/json' }, + body: this.createError(`Version ${metadata.vers} already exists`), + }; + } + + // Store crate file + await this.storage.putCargoCrate(metadata.name, metadata.vers, crateFile); + + // Update index (append new version) + existingIndex.push(indexEntry); + await this.storage.putCargoIndex(metadata.name, existingIndex); + + this.logger.log('success', 'handlePublish: published crate', { + name: metadata.name, + version: metadata.vers, + checksum: cksum + }); + + const response: ICargoPublishResponse = { + warnings: { + invalid_categories: [], + invalid_badges: [], + other: [], + }, + }; + + return { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: response, + }; + } + + /** + * Handle crate download + */ + private async handleDownload( + crateName: string, + version: string + ): Promise { + this.logger.log('debug', 'handleDownload', { crate: crateName, version }); + + const crateFile = await this.storage.getCargoCrate(crateName, version); + + if (!crateFile) { + return { + status: 404, + headers: { 'Content-Type': 'application/json' }, + body: this.createError('Crate not found'), + }; + } + + return { + status: 200, + headers: { + 'Content-Type': 'application/gzip', + 'Content-Length': crateFile.length.toString(), + 'Content-Disposition': `attachment; filename="${crateName}-${version}.crate"`, + }, + body: crateFile, + }; + } + + /** + * Handle yank operation + */ + private async handleYank( + crateName: string, + version: string, + token: IAuthToken | null + ): Promise { + return this.handleYankOperation(crateName, version, token, true); + } + + /** + * Handle unyank operation + */ + private async handleUnyank( + crateName: string, + version: string, + token: IAuthToken | null + ): Promise { + return this.handleYankOperation(crateName, version, token, false); + } + + /** + * Handle yank/unyank operation + */ + private async handleYankOperation( + crateName: string, + version: string, + token: IAuthToken | null, + yank: boolean + ): Promise { + this.logger.log('info', `handle${yank ? 'Yank' : 'Unyank'}`, { + crate: crateName, + version, + hasAuth: !!token + }); + + // Check authorization + if (!token) { + return { + status: 403, + headers: { 'Content-Type': 'application/json' }, + body: this.createError('Authentication required'), + }; + } + + // Check permission + const hasPermission = await this.checkPermission(token, crateName, 'write'); + if (!hasPermission) { + return { + status: 403, + headers: { 'Content-Type': 'application/json' }, + body: this.createError('Insufficient permissions'), + }; + } + + // Load index + const index = await this.storage.getCargoIndex(crateName); + if (!index) { + return { + status: 404, + headers: { 'Content-Type': 'application/json' }, + body: this.createError('Crate not found'), + }; + } + + // Find version + const entry = index.find(e => e.vers === version); + if (!entry) { + return { + status: 404, + headers: { 'Content-Type': 'application/json' }, + body: this.createError('Version not found'), + }; + } + + // Update yank status + entry.yanked = yank; + + // Save index (NOTE: do NOT delete .crate file) + await this.storage.putCargoIndex(crateName, index); + + this.logger.log('success', `${yank ? 'Yanked' : 'Unyanked'} version`, { + crate: crateName, + version + }); + + const response: ICargoYankResponse = { ok: true }; + + return { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: response, + }; + } + + /** + * Handle search + */ + private async handleSearch(query: string, perPage: number): Promise { + this.logger.log('debug', 'handleSearch', { query, perPage }); + + const results: ICargoSearchResult[] = []; + + try { + // List all index paths + const indexPaths = await this.storage.listObjects('cargo/index/'); + + // Extract unique crate names + const crateNames = new Set(); + for (const path of indexPaths) { + // Parse path to extract crate name + const parts = path.split('/'); + if (parts.length >= 3) { + const name = parts[parts.length - 1]; + if (name && !name.includes('.')) { + crateNames.add(name); + } + } + } + + this.logger.log('debug', `handleSearch: found ${crateNames.size} crates`, { + totalCrates: crateNames.size + }); + + // Filter and process matching crates + for (const name of crateNames) { + if (!query || name.toLowerCase().includes(query.toLowerCase())) { + const index = await this.storage.getCargoIndex(name); + if (index && index.length > 0) { + // Find latest non-yanked version + const nonYanked = index.filter(e => !e.yanked); + if (nonYanked.length > 0) { + // Sort by version (simplified - should use semver) + const sorted = [...nonYanked].sort((a, b) => b.vers.localeCompare(a.vers)); + + results.push({ + name: sorted[0].name, + max_version: sorted[0].vers, + description: '', // Would need to store separately + }); + + if (results.length >= perPage) break; + } + } + } + } + } catch (error) { + this.logger.log('error', 'handleSearch: error', { error: error.message }); + } + + const response: ICargoSearchResponse = { + crates: results, + meta: { + total: results.length, + }, + }; + + return { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: response, + }; + } + + /** + * Validate crate name + * Rules: lowercase alphanumeric + _ and -, length 1-64 + */ + private validateCrateName(name: string): boolean { + return /^[a-z0-9_-]+$/.test(name) && name.length >= 1 && name.length <= 64; + } + + /** + * Create error response + */ + private createError(detail: string): ICargoError { + return { + errors: [{ detail }], + }; + } +} diff --git a/ts/cargo/index.ts b/ts/cargo/index.ts new file mode 100644 index 0000000..6d4c49b --- /dev/null +++ b/ts/cargo/index.ts @@ -0,0 +1,6 @@ +/** + * Cargo/crates.io Registry module exports + */ + +export { CargoRegistry } from './classes.cargoregistry.js'; +export * from './interfaces.cargo.js'; diff --git a/ts/cargo/interfaces.cargo.ts b/ts/cargo/interfaces.cargo.ts new file mode 100644 index 0000000..abc2460 --- /dev/null +++ b/ts/cargo/interfaces.cargo.ts @@ -0,0 +1,169 @@ +/** + * Cargo/crates.io registry type definitions + * Based on: https://doc.rust-lang.org/cargo/reference/registry-index.html + */ + +/** + * Dependency specification in Cargo index + */ +export interface ICargoDepend { + /** Dependency package name */ + name: string; + /** Version requirement (e.g., "^0.6", ">=1.0.0") */ + req: string; + /** Optional features to enable */ + features: string[]; + /** Whether this dependency is optional */ + optional: boolean; + /** Whether to include default features */ + default_features: boolean; + /** Platform-specific target (e.g., "cfg(unix)") */ + target: string | null; + /** Dependency kind: normal, dev, or build */ + kind: 'normal' | 'dev' | 'build'; + /** Alternative registry URL */ + registry: string | null; + /** Rename to different package name */ + package: string | null; +} + +/** + * Single version entry in the Cargo index file + * Each line in the index file is one of these as JSON + */ +export interface ICargoIndexEntry { + /** Crate name */ + name: string; + /** Version string */ + vers: string; + /** Dependencies */ + deps: ICargoDepend[]; + /** SHA256 checksum of the .crate file (hex) */ + cksum: string; + /** Features (legacy format) */ + features: Record; + /** Features (extended format for newer Cargo) */ + features2?: Record; + /** Whether this version is yanked (deprecated but not deleted) */ + yanked: boolean; + /** Optional native library link */ + links?: string | null; + /** Index format version (2 is current) */ + v?: number; + /** Minimum Rust version required */ + rust_version?: string; +} + +/** + * Metadata sent during crate publication + */ +export interface ICargoPublishMetadata { + /** Crate name */ + name: string; + /** Version string */ + vers: string; + /** Dependencies */ + deps: ICargoDepend[]; + /** Features */ + features: Record; + /** Authors */ + authors: string[]; + /** Short description */ + description?: string; + /** Documentation URL */ + documentation?: string; + /** Homepage URL */ + homepage?: string; + /** README content */ + readme?: string; + /** README file path */ + readme_file?: string; + /** Keywords for search */ + keywords?: string[]; + /** Categories */ + categories?: string[]; + /** License identifier (SPDX) */ + license?: string; + /** License file path */ + license_file?: string; + /** Repository URL */ + repository?: string; + /** Badges */ + badges?: Record; + /** Native library link */ + links?: string | null; + /** Minimum Rust version */ + rust_version?: string; +} + +/** + * Registry configuration (config.json) + * Required for sparse protocol support + */ +export interface ICargoConfig { + /** Download URL template */ + dl: string; + /** API base URL */ + api: string; + /** Whether authentication is required for downloads */ + 'auth-required'?: boolean; +} + +/** + * Search result for a single crate + */ +export interface ICargoSearchResult { + /** Crate name */ + name: string; + /** Latest/maximum version */ + max_version: string; + /** Description */ + description: string; +} + +/** + * Search response structure + */ +export interface ICargoSearchResponse { + /** Array of matching crates */ + crates: ICargoSearchResult[]; + /** Metadata about results */ + meta: { + /** Total number of results */ + total: number; + }; +} + +/** + * Error response structure + */ +export interface ICargoError { + /** Array of error details */ + errors: Array<{ + /** Error message */ + detail: string; + }>; +} + +/** + * Publish success response + */ +export interface ICargoPublishResponse { + /** Warnings from validation */ + warnings: { + /** Invalid categories */ + invalid_categories: string[]; + /** Invalid badges */ + invalid_badges: string[]; + /** Other warnings */ + other: string[]; + }; +} + +/** + * Yank/Unyank response + */ +export interface ICargoYankResponse { + /** Success indicator */ + ok: boolean; +} diff --git a/ts/classes.smartregistry.ts b/ts/classes.smartregistry.ts index 5997d19..e6f3123 100644 --- a/ts/classes.smartregistry.ts +++ b/ts/classes.smartregistry.ts @@ -4,10 +4,11 @@ import { BaseRegistry } from './core/classes.baseregistry.js'; import type { IRegistryConfig, IRequestContext, IResponse } from './core/interfaces.core.js'; import { OciRegistry } from './oci/classes.ociregistry.js'; import { NpmRegistry } from './npm/classes.npmregistry.js'; +import { MavenRegistry } from './maven/classes.mavenregistry.js'; /** * Main registry orchestrator - * Routes requests to appropriate protocol handlers (OCI or NPM) + * Routes requests to appropriate protocol handlers (OCI, NPM, or Maven) */ export class SmartRegistry { private storage: RegistryStorage; @@ -51,6 +52,15 @@ export class SmartRegistry { this.registries.set('npm', npmRegistry); } + // Initialize Maven registry if enabled + if (this.config.maven?.enabled) { + 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(); + this.registries.set('maven', mavenRegistry); + } + this.initialized = true; } @@ -77,6 +87,14 @@ export class SmartRegistry { } } + // Route to Maven registry + if (this.config.maven?.enabled && path.startsWith(this.config.maven.basePath)) { + const mavenRegistry = this.registries.get('maven'); + if (mavenRegistry) { + return mavenRegistry.handleRequest(context); + } + } + // No matching registry return { status: 404, @@ -105,7 +123,7 @@ export class SmartRegistry { /** * Get a specific registry handler */ - public getRegistry(protocol: 'oci' | 'npm'): BaseRegistry | undefined { + public getRegistry(protocol: 'oci' | 'npm' | 'maven'): BaseRegistry | undefined { return this.registries.get(protocol); } diff --git a/ts/core/classes.authmanager.ts b/ts/core/classes.authmanager.ts index 51c9f81..74f2130 100644 --- a/ts/core/classes.authmanager.ts +++ b/ts/core/classes.authmanager.ts @@ -18,6 +18,39 @@ export class AuthManager { // In production, this could be Redis or a database } + // ======================================================================== + // UUID TOKEN CREATION (Base method for NPM, Maven, etc.) + // ======================================================================== + + /** + * Create a UUID-based token with custom scopes (base method) + * @param userId - User ID + * @param protocol - Protocol type + * @param scopes - Permission scopes + * @param readonly - Whether the token is readonly + * @returns UUID token string + */ + private async createUuidToken( + userId: string, + protocol: TRegistryProtocol, + scopes: string[], + readonly: boolean = false + ): Promise { + const token = this.generateUuid(); + const authToken: IAuthToken = { + type: protocol, + userId, + scopes, + readonly, + metadata: { + created: new Date().toISOString(), + }, + }; + + this.tokenStore.set(token, authToken); + return token; + } + // ======================================================================== // NPM AUTHENTICATION // ======================================================================== @@ -33,19 +66,8 @@ export class AuthManager { throw new Error('NPM tokens are not enabled'); } - const token = this.generateUuid(); - const authToken: IAuthToken = { - type: 'npm', - userId, - scopes: readonly ? ['npm:*:*:read'] : ['npm:*:*:*'], - readonly, - metadata: { - created: new Date().toISOString(), - }, - }; - - this.tokenStore.set(token, authToken); - return token; + const scopes = readonly ? ['npm:*:*:read'] : ['npm:*:*:*']; + return this.createUuidToken(userId, 'npm', scopes, readonly); } /** @@ -201,8 +223,59 @@ export class AuthManager { return null; } + // ======================================================================== + // MAVEN AUTHENTICATION + // ======================================================================== + /** - * Validate any token (NPM or OCI) + * Create a Maven token + * @param userId - User ID + * @param readonly - Whether the token is readonly + * @returns Maven UUID token + */ + public async createMavenToken(userId: string, readonly: boolean = false): Promise { + const scopes = readonly ? ['maven:*:*:read'] : ['maven:*:*:*']; + return this.createUuidToken(userId, 'maven', scopes, readonly); + } + + /** + * Validate a Maven token + * @param token - Maven UUID token + * @returns Auth token object or null + */ + public async validateMavenToken(token: string): Promise { + if (!this.isValidUuid(token)) { + return null; + } + + const authToken = this.tokenStore.get(token); + if (!authToken || authToken.type !== 'maven') { + return null; + } + + // Check expiration if set + if (authToken.expiresAt && authToken.expiresAt < new Date()) { + this.tokenStore.delete(token); + return null; + } + + return authToken; + } + + /** + * Revoke a Maven token + * @param token - Maven UUID token + */ + public async revokeMavenToken(token: string): Promise { + this.tokenStore.delete(token); + } + + // ======================================================================== + // UNIFIED AUTHENTICATION + // ======================================================================== + + /** + * Validate any token (NPM, Maven, or OCI) * @param tokenString - Token string (UUID or JWT) * @param protocol - Expected protocol type * @returns Auth token object or null @@ -211,12 +284,19 @@ export class AuthManager { tokenString: string, protocol?: TRegistryProtocol ): Promise { - // Try NPM token first (UUID format) + // Try UUID-based tokens (NPM, Maven) if (this.isValidUuid(tokenString)) { + // Try NPM token const npmToken = await this.validateNpmToken(tokenString); if (npmToken && (!protocol || protocol === 'npm')) { return npmToken; } + + // Try Maven token + const mavenToken = await this.validateMavenToken(tokenString); + if (mavenToken && (!protocol || protocol === 'maven')) { + return mavenToken; + } } // Try OCI JWT diff --git a/ts/core/classes.registrystorage.ts b/ts/core/classes.registrystorage.ts index b1535b5..06df24e 100644 --- a/ts/core/classes.registrystorage.ts +++ b/ts/core/classes.registrystorage.ts @@ -267,4 +267,129 @@ export class RegistryStorage implements IStorageBackend { const safeName = packageName.replace('@', '').replace('/', '-'); return `npm/packages/${packageName}/${safeName}-${version}.tgz`; } + + // ======================================================================== + // MAVEN STORAGE METHODS + // ======================================================================== + + /** + * Get Maven artifact + */ + public async getMavenArtifact( + groupId: string, + artifactId: string, + version: string, + filename: string + ): Promise { + const path = this.getMavenArtifactPath(groupId, artifactId, version, filename); + return this.getObject(path); + } + + /** + * Store Maven artifact + */ + public async putMavenArtifact( + groupId: string, + artifactId: string, + version: string, + filename: string, + data: Buffer + ): Promise { + const path = this.getMavenArtifactPath(groupId, artifactId, version, filename); + return this.putObject(path, data); + } + + /** + * Check if Maven artifact exists + */ + public async mavenArtifactExists( + groupId: string, + artifactId: string, + version: string, + filename: string + ): Promise { + const path = this.getMavenArtifactPath(groupId, artifactId, version, filename); + return this.objectExists(path); + } + + /** + * Delete Maven artifact + */ + public async deleteMavenArtifact( + groupId: string, + artifactId: string, + version: string, + filename: string + ): Promise { + const path = this.getMavenArtifactPath(groupId, artifactId, version, filename); + return this.deleteObject(path); + } + + /** + * Get Maven metadata (maven-metadata.xml) + */ + public async getMavenMetadata( + groupId: string, + artifactId: string + ): Promise { + const path = this.getMavenMetadataPath(groupId, artifactId); + return this.getObject(path); + } + + /** + * Store Maven metadata (maven-metadata.xml) + */ + public async putMavenMetadata( + groupId: string, + artifactId: string, + data: Buffer + ): Promise { + const path = this.getMavenMetadataPath(groupId, artifactId); + return this.putObject(path, data); + } + + /** + * List Maven versions for an artifact + * Returns all version directories under the artifact path + */ + public async listMavenVersions( + groupId: string, + artifactId: string + ): Promise { + const groupPath = groupId.replace(/\./g, '/'); + const prefix = `maven/artifacts/${groupPath}/${artifactId}/`; + + const objects = await this.listObjects(prefix); + const versions = new Set(); + + // Extract version from paths like: maven/artifacts/com/example/my-lib/1.0.0/my-lib-1.0.0.jar + for (const obj of objects) { + const relativePath = obj.substring(prefix.length); + const parts = relativePath.split('/'); + if (parts.length >= 1 && parts[0]) { + versions.add(parts[0]); + } + } + + return Array.from(versions).sort(); + } + + // ======================================================================== + // MAVEN PATH HELPERS + // ======================================================================== + + private getMavenArtifactPath( + groupId: string, + artifactId: string, + version: string, + filename: string + ): string { + const groupPath = groupId.replace(/\./g, '/'); + return `maven/artifacts/${groupPath}/${artifactId}/${version}/${filename}`; + } + + private getMavenMetadataPath(groupId: string, artifactId: string): string { + const groupPath = groupId.replace(/\./g, '/'); + return `maven/metadata/${groupPath}/${artifactId}/maven-metadata.xml`; + } } diff --git a/ts/core/interfaces.core.ts b/ts/core/interfaces.core.ts index 01119ed..b26ed8c 100644 --- a/ts/core/interfaces.core.ts +++ b/ts/core/interfaces.core.ts @@ -5,7 +5,7 @@ /** * Registry protocol types */ -export type TRegistryProtocol = 'oci' | 'npm'; +export type TRegistryProtocol = 'oci' | 'npm' | 'maven'; /** * Unified action types across protocols @@ -89,6 +89,7 @@ export interface IRegistryConfig { auth: IAuthConfig; oci?: IProtocolConfig; npm?: IProtocolConfig; + maven?: IProtocolConfig; } /** diff --git a/ts/index.ts b/ts/index.ts index c166583..765aa9d 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -1,6 +1,6 @@ /** * @push.rocks/smartregistry - * Composable registry supporting OCI and NPM protocols + * Composable registry supporting OCI, NPM, and Maven protocols */ // Main orchestrator @@ -14,3 +14,6 @@ export * from './oci/index.js'; // NPM Registry export * from './npm/index.js'; + +// Maven Registry +export * from './maven/index.js'; diff --git a/ts/maven/classes.mavenregistry.ts b/ts/maven/classes.mavenregistry.ts new file mode 100644 index 0000000..082a971 --- /dev/null +++ b/ts/maven/classes.mavenregistry.ts @@ -0,0 +1,596 @@ +/** + * Maven Registry Implementation + * Implements Maven repository protocol for Java artifacts + */ + +import { BaseRegistry } from '../core/classes.baseregistry.js'; +import type { RegistryStorage } from '../core/classes.registrystorage.js'; +import type { AuthManager } from '../core/classes.authmanager.js'; +import type { IRequestContext, IResponse, IAuthToken } from '../core/interfaces.core.js'; +import type { IMavenCoordinate, IMavenMetadata, IChecksums } from './interfaces.maven.js'; +import { + pathToGAV, + buildFilename, + calculateChecksums, + generateMetadataXml, + parseMetadataXml, + formatMavenTimestamp, + isSnapshot, + validatePom, + extractGAVFromPom, + gavToPath, +} from './helpers.maven.js'; + +/** + * Maven Registry class + * Handles Maven repository HTTP protocol + */ +export class MavenRegistry extends BaseRegistry { + private storage: RegistryStorage; + private authManager: AuthManager; + private basePath: string = '/maven'; + private registryUrl: string; + + constructor( + storage: RegistryStorage, + authManager: AuthManager, + basePath: string, + registryUrl: string + ) { + super(); + this.storage = storage; + this.authManager = authManager; + this.basePath = basePath; + this.registryUrl = registryUrl; + } + + public async init(): Promise { + // No special initialization needed for Maven + } + + public getBasePath(): string { + return this.basePath; + } + + public async handleRequest(context: IRequestContext): Promise { + // Remove base path from URL + const path = context.path.replace(this.basePath, ''); + + // Extract token from Authorization header + const authHeader = context.headers['authorization'] || context.headers['Authorization']; + let token: IAuthToken | null = null; + + if (authHeader) { + const tokenString = authHeader.replace(/^(Bearer|Basic)\s+/i, ''); + // For now, try to validate as Maven token (reuse npm token type) + token = await this.authManager.validateToken(tokenString, 'maven'); + } + + // Parse path to determine request type + const coordinate = pathToGAV(path); + + if (!coordinate) { + // Not a valid artifact path, could be metadata or root + if (path.endsWith('/maven-metadata.xml')) { + return this.handleMetadataRequest(context.method, path, token); + } + + return { + status: 404, + headers: { 'Content-Type': 'application/json' }, + body: { error: 'NOT_FOUND', message: 'Invalid Maven path' }, + }; + } + + // Check if it's a checksum file + if (coordinate.extension === 'md5' || coordinate.extension === 'sha1' || + coordinate.extension === 'sha256' || coordinate.extension === 'sha512') { + return this.handleChecksumRequest(context.method, coordinate, token); + } + + // Handle artifact requests (JAR, POM, WAR, etc.) + return this.handleArtifactRequest(context.method, coordinate, token, context.body); + } + + protected async checkPermission( + token: IAuthToken | null, + resource: string, + action: string + ): Promise { + if (!token) return false; + return this.authManager.authorize(token, `maven:artifact:${resource}`, action); + } + + // ======================================================================== + // REQUEST HANDLERS + // ======================================================================== + + private async handleArtifactRequest( + method: string, + coordinate: IMavenCoordinate, + token: IAuthToken | null, + body?: Buffer | any + ): Promise { + const { groupId, artifactId, version } = coordinate; + const filename = buildFilename(coordinate); + const resource = `${groupId}:${artifactId}`; + + switch (method) { + case 'GET': + case 'HEAD': + // Read permission required + if (!await this.checkPermission(token, resource, 'read')) { + return { + status: 401, + headers: { + 'WWW-Authenticate': `Bearer realm="${this.basePath}",service="maven-registry"`, + }, + body: { error: 'UNAUTHORIZED', message: 'Authentication required' }, + }; + } + + return method === 'GET' + ? this.getArtifact(groupId, artifactId, version, filename) + : this.headArtifact(groupId, artifactId, version, filename); + + case 'PUT': + // Write permission required + if (!await this.checkPermission(token, resource, 'write')) { + return { + status: 401, + headers: { + 'WWW-Authenticate': `Bearer realm="${this.basePath}",service="maven-registry"`, + }, + body: { error: 'UNAUTHORIZED', message: 'Write permission required' }, + }; + } + + if (!body) { + return { + status: 400, + headers: {}, + body: { error: 'BAD_REQUEST', message: 'Request body required' }, + }; + } + + return this.putArtifact(groupId, artifactId, version, filename, coordinate, body); + + case 'DELETE': + // Delete permission required + if (!await this.checkPermission(token, resource, 'delete')) { + return { + status: 401, + headers: { + 'WWW-Authenticate': `Bearer realm="${this.basePath}",service="maven-registry"`, + }, + body: { error: 'UNAUTHORIZED', message: 'Delete permission required' }, + }; + } + + return this.deleteArtifact(groupId, artifactId, version, filename); + + default: + return { + status: 405, + headers: { 'Allow': 'GET, HEAD, PUT, DELETE' }, + body: { error: 'METHOD_NOT_ALLOWED', message: 'Method not allowed' }, + }; + } + } + + private async handleChecksumRequest( + method: string, + coordinate: IMavenCoordinate, + token: IAuthToken | null + ): Promise { + const { groupId, artifactId, version, extension } = coordinate; + const resource = `${groupId}:${artifactId}`; + + // Checksums follow the same permissions as their artifacts + if (method === 'GET' || method === 'HEAD') { + if (!await this.checkPermission(token, resource, 'read')) { + return { + status: 401, + headers: { + 'WWW-Authenticate': `Bearer realm="${this.basePath}",service="maven-registry"`, + }, + body: { error: 'UNAUTHORIZED', message: 'Authentication required' }, + }; + } + + return this.getChecksum(groupId, artifactId, version, coordinate); + } + + return { + status: 405, + headers: { 'Allow': 'GET, HEAD' }, + body: { error: 'METHOD_NOT_ALLOWED', message: 'Checksums are auto-generated' }, + }; + } + + private async handleMetadataRequest( + method: string, + path: string, + token: IAuthToken | null + ): Promise { + // Parse path to extract groupId and artifactId + // Path format: /com/example/my-lib/maven-metadata.xml + const parts = path.split('/').filter(p => p && p !== 'maven-metadata.xml'); + + if (parts.length < 2) { + return { + status: 400, + headers: {}, + body: { error: 'BAD_REQUEST', message: 'Invalid metadata path' }, + }; + } + + const artifactId = parts[parts.length - 1]; + const groupId = parts.slice(0, -1).join('.'); + const resource = `${groupId}:${artifactId}`; + + if (method === 'GET') { + // Metadata is usually public (read permission optional) + // Some registries allow anonymous metadata access + return this.getMetadata(groupId, artifactId); + } + + return { + status: 405, + headers: { 'Allow': 'GET' }, + body: { error: 'METHOD_NOT_ALLOWED', message: 'Metadata is auto-generated' }, + }; + } + + // ======================================================================== + // ARTIFACT OPERATIONS + // ======================================================================== + + private async getArtifact( + groupId: string, + artifactId: string, + version: string, + filename: string + ): Promise { + const data = await this.storage.getMavenArtifact(groupId, artifactId, version, filename); + + if (!data) { + return { + status: 404, + headers: {}, + body: { error: 'NOT_FOUND', message: 'Artifact not found' }, + }; + } + + // Determine content type based on extension + const extension = filename.split('.').pop() || ''; + const contentType = this.getContentType(extension); + + return { + status: 200, + headers: { + 'Content-Type': contentType, + 'Content-Length': data.length.toString(), + }, + body: data, + }; + } + + private async headArtifact( + groupId: string, + artifactId: string, + version: string, + filename: string + ): Promise { + const exists = await this.storage.mavenArtifactExists(groupId, artifactId, version, filename); + + if (!exists) { + return { + status: 404, + headers: {}, + body: null, + }; + } + + // Get file size for Content-Length header + const data = await this.storage.getMavenArtifact(groupId, artifactId, version, filename); + const extension = filename.split('.').pop() || ''; + const contentType = this.getContentType(extension); + + return { + status: 200, + headers: { + 'Content-Type': contentType, + 'Content-Length': data ? data.length.toString() : '0', + }, + body: null, + }; + } + + private async putArtifact( + groupId: string, + artifactId: string, + version: string, + filename: string, + coordinate: IMavenCoordinate, + body: Buffer | any + ): Promise { + const data = Buffer.isBuffer(body) ? body : Buffer.from(JSON.stringify(body)); + + // Validate POM if uploading .pom file + if (coordinate.extension === 'pom') { + const pomValid = validatePom(data.toString('utf-8')); + if (!pomValid) { + return { + status: 400, + headers: {}, + body: { error: 'INVALID_POM', message: 'Invalid POM file' }, + }; + } + + // Verify GAV matches path + const pomGAV = extractGAVFromPom(data.toString('utf-8')); + if (pomGAV && (pomGAV.groupId !== groupId || pomGAV.artifactId !== artifactId || pomGAV.version !== version)) { + return { + status: 400, + headers: {}, + body: { error: 'GAV_MISMATCH', message: 'POM coordinates do not match upload path' }, + }; + } + } + + // Store the artifact + await this.storage.putMavenArtifact(groupId, artifactId, version, filename, data); + + // Generate and store checksums + const checksums = await calculateChecksums(data); + await this.storeChecksums(groupId, artifactId, version, filename, checksums); + + // Update maven-metadata.xml if this is a primary artifact (jar, pom, war) + if (['jar', 'pom', 'war', 'ear', 'aar'].includes(coordinate.extension)) { + await this.updateMetadata(groupId, artifactId, version); + } + + return { + status: 201, + headers: { + 'Location': `${this.registryUrl}/${gavToPath(groupId, artifactId, version)}/${filename}`, + }, + body: { success: true, message: 'Artifact uploaded successfully' }, + }; + } + + private async deleteArtifact( + groupId: string, + artifactId: string, + version: string, + filename: string + ): Promise { + const exists = await this.storage.mavenArtifactExists(groupId, artifactId, version, filename); + + if (!exists) { + return { + status: 404, + headers: {}, + body: { error: 'NOT_FOUND', message: 'Artifact not found' }, + }; + } + + await this.storage.deleteMavenArtifact(groupId, artifactId, version, filename); + + // Also delete checksums + for (const ext of ['md5', 'sha1', 'sha256', 'sha512']) { + const checksumFile = `${filename}.${ext}`; + const checksumExists = await this.storage.mavenArtifactExists(groupId, artifactId, version, checksumFile); + if (checksumExists) { + await this.storage.deleteMavenArtifact(groupId, artifactId, version, checksumFile); + } + } + + return { + status: 204, + headers: {}, + body: null, + }; + } + + // ======================================================================== + // CHECKSUM OPERATIONS + // ======================================================================== + + private async getChecksum( + groupId: string, + artifactId: string, + version: string, + coordinate: IMavenCoordinate + ): Promise { + const checksumFilename = buildFilename(coordinate); + const data = await this.storage.getMavenArtifact(groupId, artifactId, version, checksumFilename); + + if (!data) { + return { + status: 404, + headers: {}, + body: { error: 'NOT_FOUND', message: 'Checksum not found' }, + }; + } + + return { + status: 200, + headers: { + 'Content-Type': 'text/plain', + 'Content-Length': data.length.toString(), + }, + body: data, + }; + } + + private async storeChecksums( + groupId: string, + artifactId: string, + version: string, + filename: string, + checksums: IChecksums + ): Promise { + // Store each checksum as a separate file + await this.storage.putMavenArtifact( + groupId, + artifactId, + version, + `${filename}.md5`, + Buffer.from(checksums.md5, 'utf-8') + ); + + await this.storage.putMavenArtifact( + groupId, + artifactId, + version, + `${filename}.sha1`, + Buffer.from(checksums.sha1, 'utf-8') + ); + + if (checksums.sha256) { + await this.storage.putMavenArtifact( + groupId, + artifactId, + version, + `${filename}.sha256`, + Buffer.from(checksums.sha256, 'utf-8') + ); + } + + if (checksums.sha512) { + await this.storage.putMavenArtifact( + groupId, + artifactId, + version, + `${filename}.sha512`, + Buffer.from(checksums.sha512, 'utf-8') + ); + } + } + + // ======================================================================== + // METADATA OPERATIONS + // ======================================================================== + + private async getMetadata(groupId: string, artifactId: string): Promise { + const metadataBuffer = await this.storage.getMavenMetadata(groupId, artifactId); + + if (!metadataBuffer) { + // Generate empty metadata if none exists + const emptyMetadata: IMavenMetadata = { + groupId, + artifactId, + versioning: { + versions: [], + lastUpdated: formatMavenTimestamp(new Date()), + }, + }; + + const xml = generateMetadataXml(emptyMetadata); + return { + status: 200, + headers: { + 'Content-Type': 'application/xml', + 'Content-Length': xml.length.toString(), + }, + body: Buffer.from(xml, 'utf-8'), + }; + } + + return { + status: 200, + headers: { + 'Content-Type': 'application/xml', + 'Content-Length': metadataBuffer.length.toString(), + }, + body: metadataBuffer, + }; + } + + private async updateMetadata( + groupId: string, + artifactId: string, + newVersion: string + ): Promise { + // Get existing metadata or create new + const existingBuffer = await this.storage.getMavenMetadata(groupId, artifactId); + let metadata: IMavenMetadata; + + if (existingBuffer) { + const parsed = parseMetadataXml(existingBuffer.toString('utf-8')); + if (parsed) { + metadata = parsed; + } else { + // Create new if parsing failed + metadata = { + groupId, + artifactId, + versioning: { + versions: [], + lastUpdated: formatMavenTimestamp(new Date()), + }, + }; + } + } else { + metadata = { + groupId, + artifactId, + versioning: { + versions: [], + lastUpdated: formatMavenTimestamp(new Date()), + }, + }; + } + + // Add new version if not already present + if (!metadata.versioning.versions.includes(newVersion)) { + metadata.versioning.versions.push(newVersion); + metadata.versioning.versions.sort(); // Sort versions + } + + // Update latest and release + const versions = metadata.versioning.versions; + metadata.versioning.latest = versions[versions.length - 1]; + + // Release is the latest non-SNAPSHOT version + const releaseVersions = versions.filter(v => !isSnapshot(v)); + if (releaseVersions.length > 0) { + metadata.versioning.release = releaseVersions[releaseVersions.length - 1]; + } + + // Update timestamp + metadata.versioning.lastUpdated = formatMavenTimestamp(new Date()); + + // Generate and store XML + const xml = generateMetadataXml(metadata); + await this.storage.putMavenMetadata(groupId, artifactId, Buffer.from(xml, 'utf-8')); + + // Also store checksums for metadata + const checksums = await calculateChecksums(Buffer.from(xml, 'utf-8')); + const metadataFilename = 'maven-metadata.xml'; + await this.storeChecksums(groupId, artifactId, '', metadataFilename, checksums); + } + + // ======================================================================== + // UTILITY METHODS + // ======================================================================== + + private getContentType(extension: string): string { + const contentTypes: Record = { + 'jar': 'application/java-archive', + 'war': 'application/java-archive', + 'ear': 'application/java-archive', + 'aar': 'application/java-archive', + 'pom': 'application/xml', + 'xml': 'application/xml', + 'md5': 'text/plain', + 'sha1': 'text/plain', + 'sha256': 'text/plain', + 'sha512': 'text/plain', + }; + + return contentTypes[extension] || 'application/octet-stream'; + } +} diff --git a/ts/maven/helpers.maven.ts b/ts/maven/helpers.maven.ts new file mode 100644 index 0000000..e343bb4 --- /dev/null +++ b/ts/maven/helpers.maven.ts @@ -0,0 +1,333 @@ +/** + * Maven helper utilities + * Path conversion, XML generation, checksum calculation + */ + +import * as plugins from '../plugins.js'; +import type { + IMavenCoordinate, + IMavenMetadata, + IChecksums, + IMavenPom, +} from './interfaces.maven.js'; + +/** + * Convert Maven GAV coordinates to storage path + * Example: com.example:my-lib:1.0.0 → com/example/my-lib/1.0.0 + */ +export function gavToPath( + groupId: string, + artifactId: string, + version?: string +): string { + const groupPath = groupId.replace(/\./g, '/'); + if (version) { + return `${groupPath}/${artifactId}/${version}`; + } + return `${groupPath}/${artifactId}`; +} + +/** + * Parse Maven path to GAV coordinates + * Example: com/example/my-lib/1.0.0/my-lib-1.0.0.jar → {groupId, artifactId, version, ...} + */ +export function pathToGAV(path: string): IMavenCoordinate | null { + // Remove leading slash if present + const cleanPath = path.startsWith('/') ? path.substring(1) : path; + + // Split path into parts + const parts = cleanPath.split('/'); + if (parts.length < 4) { + return null; // Not a valid artifact path + } + + // Last part is filename + const filename = parts[parts.length - 1]; + const version = parts[parts.length - 2]; + const artifactId = parts[parts.length - 3]; + const groupId = parts.slice(0, -3).join('.'); + + // Parse filename to extract classifier and extension + const parsed = parseFilename(filename, artifactId, version); + if (!parsed) { + return null; + } + + return { + groupId, + artifactId, + version, + classifier: parsed.classifier, + extension: parsed.extension, + }; +} + +/** + * Parse Maven artifact filename + * Example: my-lib-1.0.0-sources.jar → {classifier: 'sources', extension: 'jar'} + */ +export function parseFilename( + filename: string, + artifactId: string, + version: string +): { classifier?: string; extension: string } | null { + // Expected format: {artifactId}-{version}[-{classifier}].{extension} + const prefix = `${artifactId}-${version}`; + + if (!filename.startsWith(prefix)) { + return null; + } + + const remainder = filename.substring(prefix.length); + + // Check for classifier + const dotIndex = remainder.lastIndexOf('.'); + if (dotIndex === -1) { + return null; // No extension + } + + const extension = remainder.substring(dotIndex + 1); + const classifierPart = remainder.substring(0, dotIndex); + + if (classifierPart.length === 0) { + // No classifier + return { extension }; + } + + if (classifierPart.startsWith('-')) { + // Has classifier + const classifier = classifierPart.substring(1); + return { classifier, extension }; + } + + return null; // Invalid format +} + +/** + * Build Maven artifact filename + * Example: {artifactId: 'my-lib', version: '1.0.0', classifier: 'sources', extension: 'jar'} + * → 'my-lib-1.0.0-sources.jar' + */ +export function buildFilename(coordinate: IMavenCoordinate): string { + const { artifactId, version, classifier, extension } = coordinate; + + let filename = `${artifactId}-${version}`; + if (classifier) { + filename += `-${classifier}`; + } + filename += `.${extension}`; + + return filename; +} + +/** + * Calculate checksums for Maven artifact + * Returns MD5, SHA-1, SHA-256, SHA-512 + */ +export async function calculateChecksums(data: Buffer): Promise { + const crypto = await import('crypto'); + + return { + md5: crypto.createHash('md5').update(data).digest('hex'), + sha1: crypto.createHash('sha1').update(data).digest('hex'), + sha256: crypto.createHash('sha256').update(data).digest('hex'), + sha512: crypto.createHash('sha512').update(data).digest('hex'), + }; +} + +/** + * Generate maven-metadata.xml from metadata object + */ +export function generateMetadataXml(metadata: IMavenMetadata): string { + const { groupId, artifactId, versioning } = metadata; + const { latest, release, versions, lastUpdated, snapshot, snapshotVersions } = versioning; + + let xml = '\n'; + xml += '\n'; + xml += ` ${escapeXml(groupId)}\n`; + xml += ` ${escapeXml(artifactId)}\n`; + + // Add version if SNAPSHOT + if (snapshot) { + const snapshotVersion = versions[versions.length - 1]; // Assume last version is the SNAPSHOT + xml += ` ${escapeXml(snapshotVersion)}\n`; + } + + xml += ' \n'; + + if (latest) { + xml += ` ${escapeXml(latest)}\n`; + } + + if (release) { + xml += ` ${escapeXml(release)}\n`; + } + + xml += ' \n'; + for (const version of versions) { + xml += ` ${escapeXml(version)}\n`; + } + xml += ' \n'; + + xml += ` ${lastUpdated}\n`; + + // Add SNAPSHOT info if present + if (snapshot) { + xml += ' \n'; + xml += ` ${escapeXml(snapshot.timestamp)}\n`; + xml += ` ${snapshot.buildNumber}\n`; + xml += ' \n'; + } + + // Add SNAPSHOT versions if present + if (snapshotVersions && snapshotVersions.length > 0) { + xml += ' \n'; + for (const sv of snapshotVersions) { + xml += ' \n'; + if (sv.classifier) { + xml += ` ${escapeXml(sv.classifier)}\n`; + } + xml += ` ${escapeXml(sv.extension)}\n`; + xml += ` ${escapeXml(sv.value)}\n`; + xml += ` ${sv.updated}\n`; + xml += ' \n'; + } + xml += ' \n'; + } + + xml += ' \n'; + xml += '\n'; + + return xml; +} + +/** + * Parse maven-metadata.xml to metadata object + * Basic XML parsing for Maven metadata + */ +export function parseMetadataXml(xml: string): IMavenMetadata | null { + try { + // Simple regex-based parsing (for basic metadata) + // In production, use a proper XML parser + + const groupIdMatch = xml.match(/([^<]+)<\/groupId>/); + const artifactIdMatch = xml.match(/([^<]+)<\/artifactId>/); + const latestMatch = xml.match(/([^<]+)<\/latest>/); + const releaseMatch = xml.match(/([^<]+)<\/release>/); + const lastUpdatedMatch = xml.match(/([^<]+)<\/lastUpdated>/); + + if (!groupIdMatch || !artifactIdMatch) { + return null; + } + + // Parse versions + const versionsMatch = xml.match(/([\s\S]*?)<\/versions>/); + const versions: string[] = []; + if (versionsMatch) { + const versionMatches = versionsMatch[1].matchAll(/([^<]+)<\/version>/g); + for (const match of versionMatches) { + versions.push(match[1]); + } + } + + return { + groupId: groupIdMatch[1], + artifactId: artifactIdMatch[1], + versioning: { + latest: latestMatch ? latestMatch[1] : undefined, + release: releaseMatch ? releaseMatch[1] : undefined, + versions, + lastUpdated: lastUpdatedMatch ? lastUpdatedMatch[1] : formatMavenTimestamp(new Date()), + }, + }; + } catch (error) { + return null; + } +} + +/** + * Escape XML special characters + */ +function escapeXml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Format timestamp in Maven format: yyyyMMddHHmmss + */ +export function formatMavenTimestamp(date: Date): string { + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + const day = String(date.getUTCDate()).padStart(2, '0'); + const hours = String(date.getUTCHours()).padStart(2, '0'); + const minutes = String(date.getUTCMinutes()).padStart(2, '0'); + const seconds = String(date.getUTCSeconds()).padStart(2, '0'); + + return `${year}${month}${day}${hours}${minutes}${seconds}`; +} + +/** + * Format SNAPSHOT timestamp: yyyyMMdd.HHmmss + */ +export function formatSnapshotTimestamp(date: Date): string { + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + const day = String(date.getUTCDate()).padStart(2, '0'); + const hours = String(date.getUTCHours()).padStart(2, '0'); + const minutes = String(date.getUTCMinutes()).padStart(2, '0'); + const seconds = String(date.getUTCSeconds()).padStart(2, '0'); + + return `${year}${month}${day}.${hours}${minutes}${seconds}`; +} + +/** + * Check if version is a SNAPSHOT + */ +export function isSnapshot(version: string): boolean { + return version.endsWith('-SNAPSHOT'); +} + +/** + * Validate POM basic structure + */ +export function validatePom(pomXml: string): boolean { + try { + // Basic validation - check for required fields + return ( + pomXml.includes('') && + pomXml.includes('') && + pomXml.includes('') && + pomXml.includes('') + ); + } catch (error) { + return false; + } +} + +/** + * Extract GAV from POM XML + */ +export function extractGAVFromPom(pomXml: string): { groupId: string; artifactId: string; version: string } | null { + try { + const groupIdMatch = pomXml.match(/([^<]+)<\/groupId>/); + const artifactIdMatch = pomXml.match(/([^<]+)<\/artifactId>/); + const versionMatch = pomXml.match(/([^<]+)<\/version>/); + + if (groupIdMatch && artifactIdMatch && versionMatch) { + return { + groupId: groupIdMatch[1], + artifactId: artifactIdMatch[1], + version: versionMatch[1], + }; + } + + return null; + } catch (error) { + return null; + } +} diff --git a/ts/maven/index.ts b/ts/maven/index.ts new file mode 100644 index 0000000..5b2e687 --- /dev/null +++ b/ts/maven/index.ts @@ -0,0 +1,7 @@ +/** + * Maven Registry module exports + */ + +export { MavenRegistry } from './classes.mavenregistry.js'; +export * from './interfaces.maven.js'; +export * from './helpers.maven.js'; diff --git a/ts/maven/interfaces.maven.ts b/ts/maven/interfaces.maven.ts new file mode 100644 index 0000000..caba1bd --- /dev/null +++ b/ts/maven/interfaces.maven.ts @@ -0,0 +1,127 @@ +/** + * Maven registry type definitions + * Supports Maven repository protocol for Java artifacts + */ + +/** + * Maven coordinate system (GAV + optional classifier) + * Example: com.example:my-library:1.0.0:sources:jar + */ +export interface IMavenCoordinate { + groupId: string; // e.g., "com.example.myapp" + artifactId: string; // e.g., "my-library" + version: string; // e.g., "1.0.0" or "1.0-SNAPSHOT" + classifier?: string; // e.g., "sources", "javadoc" + extension: string; // e.g., "jar", "war", "pom" +} + +/** + * Maven metadata (maven-metadata.xml) structure + * Contains version list and latest/release information + */ +export interface IMavenMetadata { + groupId: string; + artifactId: string; + versioning: IMavenVersioning; +} + +/** + * Maven versioning information + */ +export interface IMavenVersioning { + latest?: string; // Latest version (including SNAPSHOTs) + release?: string; // Latest release version (excluding SNAPSHOTs) + versions: string[]; // List of all versions + lastUpdated: string; // Format: yyyyMMddHHmmss + snapshot?: IMavenSnapshot; // For SNAPSHOT versions + snapshotVersions?: IMavenSnapshotVersion[]; // For SNAPSHOT builds +} + +/** + * SNAPSHOT build information + */ +export interface IMavenSnapshot { + timestamp: string; // Format: yyyyMMdd.HHmmss + buildNumber: number; // Incremental build number +} + +/** + * SNAPSHOT version entry + */ +export interface IMavenSnapshotVersion { + classifier?: string; + extension: string; + value: string; // Timestamped version + updated: string; // Format: yyyyMMddHHmmss +} + +/** + * Checksums for Maven artifacts + * Maven requires separate checksum files for each artifact + */ +export interface IChecksums { + md5: string; // MD5 hash + sha1: string; // SHA-1 hash (required) + sha256?: string; // SHA-256 hash (optional) + sha512?: string; // SHA-512 hash (optional) +} + +/** + * Maven artifact file information + */ +export interface IMavenArtifactFile { + filename: string; // Full filename with extension + data: Buffer; // File content + coordinate: IMavenCoordinate; // Parsed GAV coordinates + checksums?: IChecksums; // Calculated checksums +} + +/** + * Maven upload request + * Contains all files for a single version (JAR, POM, sources, etc.) + */ +export interface IMavenUploadRequest { + groupId: string; + artifactId: string; + version: string; + files: IMavenArtifactFile[]; +} + +/** + * Maven protocol configuration + */ +export interface IMavenProtocolConfig { + enabled: boolean; + basePath: string; // Default: '/maven' + features?: { + snapshots?: boolean; // Support SNAPSHOT versions (default: true) + checksums?: boolean; // Auto-generate checksums (default: true) + metadata?: boolean; // Auto-generate maven-metadata.xml (default: true) + allowedExtensions?: string[]; // Allowed file extensions (default: jar, war, pom, etc.) + }; +} + +/** + * Maven POM (Project Object Model) minimal structure + * Only essential fields for validation + */ +export interface IMavenPom { + modelVersion: string; // Always "4.0.0" + groupId: string; + artifactId: string; + version: string; + packaging?: string; // jar, war, pom, etc. + name?: string; + description?: string; +} + +/** + * Maven repository search result + */ +export interface IMavenSearchResult { + groupId: string; + artifactId: string; + latestVersion: string; + versions: string[]; + lastUpdated: string; +}