Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions backend/src/routes/dependencies.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Router } from 'express';
import { checkDependencies, updateDependencies } from '../services/dependency-update.service.js';
import { validateRequest } from '../utils/validation.js';
import logger from '../utils/logger.js';
import { dependencyCheckSchema, dependencyUpdateSchema } from './dependencies.validation.schemas.js';

const router = Router();

/**
* POST /dependencies/check
* Parse a Cargo.toml and return outdated dependency information.
*/
router.post('/check', validateRequest(dependencyCheckSchema), async (req, res) => {
try {
const result = await checkDependencies(req.body.cargoToml);
res.json({ status: 'success', ...result });
} catch (error) {
logger.error('Error checking dependencies', error);
res.status(500).json({ status: 'error', message: 'Unable to check dependencies' });
}
});

/**
* POST /dependencies/update
* Apply selected dependency updates and return a suggested Cargo.toml.
*/
router.post('/update', validateRequest(dependencyUpdateSchema), async (req, res) => {
try {
const result = await updateDependencies(req.body.cargoToml, req.body.dependencies);
res.json({ status: 'success', ...result });
} catch (error) {
logger.error('Error updating dependencies', error);
res.status(500).json({ status: 'error', message: 'Unable to update dependencies' });
}
});

export default router;
22 changes: 22 additions & 0 deletions backend/src/routes/dependencies.validation.schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// @ts-nocheck
import { z } from 'zod';

export const dependencyCheckSchema = z.object({
cargoToml: z
.string()
.min(1, { message: 'Cargo.toml content is required.' })
.max(50_000, { message: 'Cargo.toml must not exceed 50,000 characters.' }),
});

export const dependencyUpdateSchema = z.object({
cargoToml: z
.string()
.min(1, { message: 'Cargo.toml content is required.' })
.max(50_000, { message: 'Cargo.toml must not exceed 50,000 characters.' }),
dependencies: z
.array(z.string().min(1).max(128), {
invalid_type_error: 'Dependencies must be an array of strings.',
})
.min(1, { message: 'At least one dependency name is required.' })
.max(100, { message: 'Cannot update more than 100 dependencies at once.' }),
});
2 changes: 2 additions & 0 deletions backend/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import studentsRouter from './students.js';
import notificationRouter from '../notifications/notification.routes.js';
import notificationPreferencesRouter from '../notifications/preferences.routes.js';
import metricsRouter from './metrics.routes.js';
import dependenciesRouter from './dependencies.routes.js';

import webhooksRouter from './webhooks.js';

Expand All @@ -43,5 +44,6 @@ router.use('/export', exportRouter);
router.use('/webhooks', webhooksRouter);
router.use('/user', userRouter);
router.use('/metrics', metricsRouter);
router.use('/dependencies', dependenciesRouter);

export default router;
176 changes: 176 additions & 0 deletions backend/src/services/dependency-update.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import logger from '../utils/logger.js';

export interface CargoTomlDependency {
name: string;
currentVersion: string;
latestVersion: string;
isOutdated: boolean;
updateType: 'major' | 'minor' | 'patch' | 'none';
releaseNotes?: string;
}

export interface DependencyCheckResult {
dependencies: CargoTomlDependency[];
outdatedCount: number;
checkedAt: string;
cargoTomlHash: string;
}

export interface DependencyUpdateResult {
updated: string[];
failed: string[];
suggestedCargoToml: string;
}

// Simulated Soroban/Stellar Rust dependency registry (latest known versions)
const REGISTRY: Record<string, string> = {
'soroban-sdk': '22.0.7',
'soroban-auth': '22.0.7',
'stellar-xdr': '22.1.0',
'num-integer': '0.1.46',
'num-traits': '0.2.19',
'serde': '1.0.219',
'serde_json': '1.0.140',
'base64': '0.22.1',
'hex': '0.4.3',
'sha2': '0.10.9',
'hmac': '0.12.1',
'ed25519-dalek': '2.1.1',
};

const RELEASE_NOTES: Record<string, string> = {
'soroban-sdk': 'Protocol 22 support, improved storage APIs, and security patches.',
'stellar-xdr': 'Updated XDR definitions for Stellar Protocol 22.',
'soroban-auth': 'Improved authorization framework compatibility with Protocol 22.',
'serde': 'Performance improvements and new derive macro features.',
};

function compareVersions(a: string, b: string): 'major' | 'minor' | 'patch' | 'none' {
const [aMaj, aMin, aPat] = a.split('.').map(Number);
const [bMaj, bMin, bPat] = b.split('.').map(Number);
if (bMaj > aMaj) return 'major';
if (bMin > aMin) return 'minor';
if (bPat > aPat) return 'patch';
return 'none';
}

function simpleHash(input: string): string {
let hash = 0;
for (let i = 0; i < input.length; i++) {
hash = (hash * 31 + input.charCodeAt(i)) >>> 0;
}
return hash.toString(16).padStart(8, '0');
}

/**
* Parse dependency lines from a Cargo.toml content string.
* Handles both `name = "version"` and `name = { version = "version", ... }` formats.
*/
export function parseCargoTomlDependencies(cargoToml: string): Array<{ name: string; version: string }> {
const deps: Array<{ name: string; version: string }> = [];
const inDepSection = /^\[dependencies\]/m.test(cargoToml);
if (!inDepSection) return deps;

const sectionMatch = cargoToml.match(/\[dependencies\]([\s\S]*?)(?=\n\[|$)/);
if (!sectionMatch) return deps;

const section = sectionMatch[1];
const lines = section.split('\n');

for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;

// Simple: name = "version"
const simpleMatch = trimmed.match(/^([\w-]+)\s*=\s*"([^"]+)"/);
if (simpleMatch) {
deps.push({ name: simpleMatch[1], version: simpleMatch[2] });
continue;
}

// Inline table: name = { version = "...", ... }
const tableMatch = trimmed.match(/^([\w-]+)\s*=\s*\{[^}]*version\s*=\s*"([^"]+)"/);
if (tableMatch) {
deps.push({ name: tableMatch[1], version: tableMatch[2] });
}
}

return deps;
}

export async function checkDependencies(cargoToml: string): Promise<DependencyCheckResult> {
const parsed = parseCargoTomlDependencies(cargoToml);
const cargoTomlHash = simpleHash(cargoToml);

const dependencies: CargoTomlDependency[] = parsed.map(({ name, version }) => {
const latestVersion = REGISTRY[name] ?? version;
const updateType = compareVersions(version, latestVersion);
return {
name,
currentVersion: version,
latestVersion,
isOutdated: updateType !== 'none',
updateType,
...(RELEASE_NOTES[name] ? { releaseNotes: RELEASE_NOTES[name] } : {}),
};
});

const outdatedCount = dependencies.filter((d) => d.isOutdated).length;

logger.info('Dependency check completed', {
cargoTomlHash,
totalDeps: dependencies.length,
outdatedCount,
});

return {
dependencies,
outdatedCount,
checkedAt: new Date().toISOString(),
cargoTomlHash,
};
}

export async function updateDependencies(
cargoToml: string,
dependenciesToUpdate: string[]
): Promise<DependencyUpdateResult> {
const parsed = parseCargoTomlDependencies(cargoToml);
const updated: string[] = [];
const failed: string[] = [];
let updatedCargoToml = cargoToml;

for (const depName of dependenciesToUpdate) {
const dep = parsed.find((d) => d.name === depName);
if (!dep) {
failed.push(depName);
continue;
}
const latestVersion = REGISTRY[dep.name];
if (!latestVersion) {
failed.push(depName);
continue;
}
const escapedName = depName.replace(/[-]/g, '\\-');
const escapedVersion = dep.version.replace(/\./g, '\\.');
// Replace simple: name = "version"
updatedCargoToml = updatedCargoToml.replace(
new RegExp(`(${escapedName}\\s*=\\s*)"${escapedVersion}"`, 'g'),
`$1"${latestVersion}"`
);
// Replace inline table: name = { version = "version", ... }
updatedCargoToml = updatedCargoToml.replace(
new RegExp(`(${escapedName}\\s*=\\s*\\{[^}]*version\\s*=\\s*)"${escapedVersion}"`, 'g'),
`$1"${latestVersion}"`
);
updated.push(depName);
}

logger.info('Dependency update completed', {
requested: dependenciesToUpdate.length,
updated: updated.length,
failed: failed.length,
});

return { updated, failed, suggestedCargoToml: updatedCargoToml };
}
108 changes: 108 additions & 0 deletions backend/tests/dependency-update.routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import request from 'supertest';
import { app } from '../src/index.js';

const VALID_CARGO_TOML = `[package]
name = "test-contract"
version = "0.1.0"
edition = "2021"

[dependencies]
soroban-sdk = "21.7.6"
soroban-auth = "21.0.0"
`;

describe('Dependencies API - POST /dependencies/check', () => {
it('returns dependency check results for valid Cargo.toml', async () => {
const res = await request(app)
.post('/api/v1/dependencies/check')
.send({ cargoToml: VALID_CARGO_TOML })
.expect(200);

expect(res.body.status).toBe('success');
expect(Array.isArray(res.body.dependencies)).toBe(true);
expect(typeof res.body.outdatedCount).toBe('number');
expect(res.body.checkedAt).toBeDefined();
expect(res.body.cargoTomlHash).toBeDefined();
});

it('identifies soroban-sdk as outdated', async () => {
const res = await request(app)
.post('/api/v1/dependencies/check')
.send({ cargoToml: VALID_CARGO_TOML })
.expect(200);

const sdk = res.body.dependencies.find((d: { name: string }) => d.name === 'soroban-sdk');
expect(sdk).toBeDefined();
expect(sdk.isOutdated).toBe(true);
expect(sdk.latestVersion).toBe('22.0.7');
});

it('rejects missing cargoToml with 400', async () => {
const res = await request(app)
.post('/api/v1/dependencies/check')
.send({})
.expect(400);

expect(res.body).toHaveProperty('error', 'Validation failed');
});

it('rejects cargoToml exceeding max length with 400', async () => {
const res = await request(app)
.post('/api/v1/dependencies/check')
.send({ cargoToml: 'x'.repeat(50_001) })
.expect(400);

expect(res.body).toHaveProperty('error', 'Validation failed');
});
});

describe('Dependencies API - POST /dependencies/update', () => {
it('applies update and returns suggestedCargoToml', async () => {
const res = await request(app)
.post('/api/v1/dependencies/update')
.send({ cargoToml: VALID_CARGO_TOML, dependencies: ['soroban-sdk'] })
.expect(200);

expect(res.body.status).toBe('success');
expect(res.body.updated).toContain('soroban-sdk');
expect(typeof res.body.suggestedCargoToml).toBe('string');
expect(res.body.suggestedCargoToml).toContain('"22.0.7"');
});

it('reports failure for non-existent dependencies', async () => {
const res = await request(app)
.post('/api/v1/dependencies/update')
.send({ cargoToml: VALID_CARGO_TOML, dependencies: ['nonexistent-crate'] })
.expect(200);

expect(res.body.failed).toContain('nonexistent-crate');
expect(res.body.updated).not.toContain('nonexistent-crate');
});

it('rejects missing dependencies array with 400', async () => {
const res = await request(app)
.post('/api/v1/dependencies/update')
.send({ cargoToml: VALID_CARGO_TOML })
.expect(400);

expect(res.body).toHaveProperty('error', 'Validation failed');
});

it('rejects empty dependencies array with 400', async () => {
const res = await request(app)
.post('/api/v1/dependencies/update')
.send({ cargoToml: VALID_CARGO_TOML, dependencies: [] })
.expect(400);

expect(res.body).toHaveProperty('error', 'Validation failed');
});

it('rejects missing cargoToml with 400', async () => {
const res = await request(app)
.post('/api/v1/dependencies/update')
.send({ dependencies: ['soroban-sdk'] })
.expect(400);

expect(res.body).toHaveProperty('error', 'Validation failed');
});
});
Loading