Skip to content
Draft
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
65 changes: 65 additions & 0 deletions nest/src/constants/validation.constants.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import {
isValidCommitSha,
isValidGitRef,
isValidNearAccountId,
} from './validation.constants';

describe('validation constants', () => {
it('should accept real NEAR account ID formats', () => {
const validAccountIds = [
'sourcescan.near',
'v2-verifier.sourcescan.near',
'wrap.near',
'aurora',
'dev-123.testnet',
'b-o_w_e-n',
'98793cd91a3f870fb126f66285808c7e094afcfc4eda8a970f6648cdf0dbd6de',
'0x87b435f1fcb4519306f9b755e274107cc78ac4e3',
'0s87b435f1fcb4519306f9b755e274107cc78ac4e3',
];

for (const accountId of validAccountIds) {
expect(isValidNearAccountId(accountId)).toBe(true);
}
});

it('should reject invalid NEAR account IDs', () => {
const invalidAccountIds = [
'',
'a',
'SourceScan.near',
'-near',
'near-',
'.near',
'near.',
'a..near',
'0__0',
'0_-_0',
'hello world',
'near;touch',
'abcdefghijklmnopqrstuvwxyz.abcdefghijklmnopqrstuvwxyz.abcdefghijklmnopqrstuvwxyz',
];

for (const accountId of invalidAccountIds) {
expect(isValidNearAccountId(accountId)).toBe(false);
}
});

it('should validate full commit SHAs only', () => {
expect(isValidCommitSha('0123456789abcdef0123456789abcdef01234567')).toBe(
true,
);
expect(isValidCommitSha('a80bc29')).toBe(false);
expect(isValidCommitSha('main')).toBe(false);
});

it('should validate safe git refs', () => {
for (const ref of ['main', 'v1.2.3', 'feature/repro-build', 'a80bc29']) {
expect(isValidGitRef(ref)).toBe(true);
}

for (const ref of ['--help', 'main;touch', 'foo..bar', 'foo@{bar}']) {
expect(isValidGitRef(ref)).toBe(false);
}
});
});
28 changes: 28 additions & 0 deletions nest/src/constants/validation.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export const NETWORK_IDS = ['mainnet', 'testnet'] as const;

export type NetworkId = (typeof NETWORK_IDS)[number];

export const COMMIT_SHA_PATTERN = /^[0-9a-f]{40}$/i;
export const GIT_REF_PATTERN =
/^(?!-)(?!.*(?:\.\.|@\{|\/\/|\\))[A-Za-z0-9._/@+-]{1,256}$/;

const NAMED_ACCOUNT_PATTERN =
/^(?=.{2,64}$)(?:(?:[a-z0-9]+[-_])*[a-z0-9]+\.)*(?:[a-z0-9]+[-_])*[a-z0-9]+$/;

export const NEAR_ACCOUNT_ID_PATTERN = NAMED_ACCOUNT_PATTERN;

export function isValidNetworkId(networkId: string): networkId is NetworkId {
return NETWORK_IDS.includes(networkId as NetworkId);
}

export function isValidCommitSha(sha: string): boolean {
return COMMIT_SHA_PATTERN.test(sha);
}

export function isValidGitRef(ref: string): boolean {
return GIT_REF_PATTERN.test(ref);
}

export function isValidNearAccountId(accountId: string): boolean {
return NEAR_ACCOUNT_ID_PATTERN.test(accountId);
}
64 changes: 39 additions & 25 deletions nest/src/controllers/verify/verify.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,8 @@ export class VerifyController {

buildInfo = contractMetadata.build_info;

// Extract the repository URL and commit hash
const { repoUrl, sha } = this.githubService.parseSourceCodeSnapshot(
// Extract the repository URL and pinned git ref
const { repoUrl, ref } = this.githubService.parseSourceCodeSnapshot(
buildInfo.source_code_snapshot,
);

Expand All @@ -122,7 +122,7 @@ export class VerifyController {
try {
await this.githubService.clone(tempFolder, repoUrl);
const repoPath = this.githubService.getRepoPath(tempFolder, repoUrl);
await this.githubService.checkout(repoPath, sha);
await this.githubService.checkout(repoPath, ref);
} catch (error) {
if (
error.message.includes('Authentication failed') ||
Expand All @@ -131,18 +131,15 @@ export class VerifyController {
error.message.includes('remote: Repository not found') ||
error.message.includes('fatal: repository')
) {
await this.tempService.deleteFolder(tempFolder);
return res.status(400).json({
message: `Repository is not publicly accessible: ${repoUrl}. Contract verification requires public repositories.`,
});
}
await this.tempService.deleteFolder(tempFolder);
throw error; // Re-throw other errors
} finally {
await this.cleanupTempFolder(tempFolder);
}

// Clean up successful clone
await this.tempService.deleteFolder(tempFolder);

// Check Docker image availability if using Docker build environment
if (
buildInfo.build_environment &&
Expand All @@ -163,7 +160,7 @@ export class VerifyController {
);
}
} catch (error) {
if (error.status === 400) {
if (error instanceof HttpException && error.getStatus() === 400) {
throw error; // Re-throw validation errors
}
this.logger.error(`Pre-verification checks failed: ${error.message}`);
Expand Down Expand Up @@ -227,27 +224,30 @@ export class VerifyController {

// Contract metadata and buildInfo already fetched in pre-verification checks

// Extract the repository URL and the commit hash
const { repoUrl, sha } = this.githubService.parseSourceCodeSnapshot(
// Extract the repository URL and pinned git ref
const { repoUrl, ref } = this.githubService.parseSourceCodeSnapshot(
buildInfo.source_code_snapshot,
);

// Create a temporary folder to clone the repository for IPFS
const tempFolder = await this.tempService.createFolder();

// Clone the repository and checkout the commit
await this.githubService.clone(tempFolder, repoUrl);
const repoPath = this.githubService.getRepoPath(tempFolder, repoUrl);
await this.githubService.checkout(repoPath, sha);

// Pin to IPFS
this.logger.log(`Adding repository to IPFS for ${accountId}`);
let tempFolder: string = null;
let cid = '';
cid = await this.ipfsService.addFolder(repoPath);
this.logger.log(`IPFS CID for ${accountId}: ${cid}`);

// Clean up temp folder
await this.tempService.deleteFolder(tempFolder);
try {
// Create a temporary folder to clone the repository for IPFS
tempFolder = await this.tempService.createFolder();

// Clone the repository and checkout the pinned git ref
await this.githubService.clone(tempFolder, repoUrl);
const repoPath = this.githubService.getRepoPath(tempFolder, repoUrl);
await this.githubService.checkout(repoPath, ref);

// Pin to IPFS
this.logger.log(`Adding repository to IPFS for ${accountId}`);
cid = await this.ipfsService.addFolder(repoPath);
this.logger.log(`IPFS CID for ${accountId}: ${cid}`);
} finally {
await this.cleanupTempFolder(tempFolder);
}

// Store verification result
this.logger.log(
Expand All @@ -271,4 +271,18 @@ export class VerifyController {
return res.status(500).json({ message: error.message });
}
}

private async cleanupTempFolder(tempFolder: string | null): Promise<void> {
if (!tempFolder) {
return;
}

try {
await this.tempService.deleteFolder(tempFolder);
} catch (cleanupError) {
this.logger.error(
`Failed to clean up ${tempFolder}: ${cleanupError.message}`,
);
}
}
}
27 changes: 24 additions & 3 deletions nest/src/dtos/verify.dto.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,50 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator';
import { Type } from 'class-transformer';
import {
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Matches,
Min,
} from 'class-validator';
import {
NEAR_ACCOUNT_ID_PATTERN,
NETWORK_IDS,
NetworkId,
} from '../constants/validation.constants';

export class VerifyRustDto {
@ApiProperty({
description: 'Network ID',
example: 'mainnet',
enum: NETWORK_IDS,
})
@IsNotEmpty()
@IsString()
networkId: string;
@IsIn(NETWORK_IDS)
networkId: NetworkId;

@ApiProperty({
description: 'Account ID',
example: 'sourcescan.near',
})
@IsNotEmpty()
@IsString()
@Matches(NEAR_ACCOUNT_ID_PATTERN, {
message: 'accountId must be a valid NEAR account ID',
})
accountId: string;

@ApiProperty({
description: 'Number of block',
example: 165753012,
})
@IsOptional()
@IsNumber()
@Type(() => Number)
@IsInt()
@Min(0)
blockId: number;
}

Expand Down
25 changes: 19 additions & 6 deletions nest/src/services/compiler/compiler.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ describe('CompilerService', () => {
{
provide: ExecService,
useValue: {
executeCommand: jest.fn(),
executeFile: jest.fn(),
},
},
],
Expand All @@ -26,22 +26,35 @@ describe('CompilerService', () => {
it('should verify contract successfully', async () => {
const mockOutput = ['Contract verified'];
jest
.spyOn(execService, 'executeCommand')
.spyOn(execService, 'executeFile')
.mockResolvedValue({ stderr: [], stdout: mockOutput });

const result = await compilerService.verifyContract('test.near', 'mainnet');
expect(result.stdout).toEqual(mockOutput);
expect(execService.executeCommand).toHaveBeenCalledWith(
'near contract verify deployed-at test.near network-config mainnet now',
);
expect(execService.executeFile).toHaveBeenCalledWith('near', [
'contract',
'verify',
'deployed-at',
'test.near',
'network-config',
'mainnet',
'now',
]);
});

it('should handle errors in contract verification', async () => {
const error = new Error('Verification error');
jest.spyOn(execService, 'executeCommand').mockRejectedValue(error);
jest.spyOn(execService, 'executeFile').mockRejectedValue(error);

await expect(
compilerService.verifyContract('test.near', 'mainnet'),
).rejects.toThrow(error);
});

it('should reject unsafe account IDs before executing', async () => {
await expect(
compilerService.verifyContract('test.near; touch /tmp/pwned', 'mainnet'),
).rejects.toThrow('Invalid NEAR account ID');
expect(execService.executeFile).not.toHaveBeenCalled();
});
});
29 changes: 25 additions & 4 deletions nest/src/services/compiler/compiler.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { Injectable, Logger } from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import {
isValidNearAccountId,
isValidNetworkId,
} from '../../constants/validation.constants';
import { ExecService } from '../exec/exec.service';

@Injectable()
Expand All @@ -14,10 +18,27 @@ export class CompilerService {
accountId: string,
networkId: string,
): Promise<{ stdout: string[] }> {
const command = `near contract verify deployed-at ${accountId} network-config ${networkId} now`;
this.logger.log(`Starting contract verification: ${command}`);
if (!isValidNearAccountId(accountId)) {
throw new BadRequestException('Invalid NEAR account ID');
}

if (!isValidNetworkId(networkId)) {
throw new BadRequestException('Invalid network ID');
}

this.logger.log(
`Starting contract verification for ${accountId} on ${networkId}`,
);
try {
const { stdout } = await this.execService.executeCommand(command);
const { stdout } = await this.execService.executeFile('near', [
'contract',
'verify',
'deployed-at',
accountId,
'network-config',
networkId,
'now',
]);
this.logger.log(`Contract verification completed`);
return { stdout };
} catch (error) {
Expand Down
Loading
Loading