Skip to content
Merged
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
79 changes: 79 additions & 0 deletions nest/src/services/ipfs/ipfs.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { Test, TestingModule } from '@nestjs/testing';
import axios from 'axios';
import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { IpfsService } from './ipfs.service';

jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;

describe('IpfsService', () => {
let service: IpfsService;
const tempDirs: string[] = [];

beforeEach(async () => {
process.env.IPFS_HOST = 'localhost';
Expand All @@ -23,6 +27,14 @@ describe('IpfsService', () => {
jest.clearAllMocks();
});

afterAll(async () => {
await Promise.all(
tempDirs.map((tempDir) =>
fs.rm(tempDir, { recursive: true, force: true }),
),
);
});

it('should be defined', () => {
expect(service).toBeDefined();
});
Expand Down Expand Up @@ -62,4 +74,71 @@ describe('IpfsService', () => {
expect(result).toEqual([]);
});
});

describe('addFolder safety', () => {
async function createTempRepo(): Promise<{
tempDir: string;
repoDir: string;
outsideDir: string;
}> {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ipfs-service-'));
tempDirs.push(tempDir);

const repoDir = path.join(tempDir, 'repo');
const outsideDir = path.join(tempDir, 'outside');
await fs.mkdir(path.join(repoDir, 'src'), { recursive: true });
await fs.mkdir(outsideDir, { recursive: true });
await fs.writeFile(path.join(repoDir, 'src', 'lib.rs'), 'pub fn ok() {}');

return { tempDir, repoDir, outsideDir };
}

it('should skip symlinked files while collecting IPFS files', async () => {
const { repoDir, outsideDir } = await createTempRepo();
const outsideFile = path.join(outsideDir, 'env');
await fs.writeFile(outsideFile, 'NEAR_MAINNET_PRIVATE_KEY=secret');
await fs.symlink(outsideFile, path.join(repoDir, 'src', 'leak.env'));

const files = await (service as any).getFilesRecursively(
await fs.realpath(repoDir),
);
const relativeFiles = files.map((file: string) =>
path.relative(repoDir, file),
);

expect(relativeFiles).toEqual(['src/lib.rs']);
});

it('should skip symlinked directories while collecting IPFS files', async () => {
const { repoDir, outsideDir } = await createTempRepo();
await fs.writeFile(
path.join(outsideDir, 'secret.txt'),
'NEAR_TESTNET_PRIVATE_KEY=secret',
);
await fs.symlink(outsideDir, path.join(repoDir, 'linked-outside-dir'));

const files = await (service as any).getFilesRecursively(
await fs.realpath(repoDir),
);
const relativeFiles = files.map((file: string) =>
path.relative(repoDir, file),
);

expect(relativeFiles).toEqual(['src/lib.rs']);
});

it('should reject resolved paths outside the IPFS root', () => {
const rootPath = path.resolve('/tmp/sourcescan-ipfs-root');

expect(
(service as any).isPathInsideRoot(rootPath, path.join(rootPath, 'a.rs')),
).toBe(true);
expect(
(service as any).isPathInsideRoot(
rootPath,
path.resolve('/tmp/not-in-root/a.rs'),
),
).toBe(false);
});
});
});
62 changes: 42 additions & 20 deletions nest/src/services/ipfs/ipfs.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,11 @@ export class IpfsService {
this.logger.log(`Adding folder to IPFS from path: ${folderPath}`);

const form = new FormData();
const files = await this.getFilesRecursively(folderPath);
const rootPath = await fs.realpath(folderPath);
const files = await this.getFilesRecursively(rootPath);

for (const file of files) {
const relativePath = path.relative(folderPath, file);
const relativePath = path.relative(rootPath, file);
const content = await fs.readFile(file);
form.append('file', content, {
filename: relativePath,
Expand Down Expand Up @@ -114,32 +115,53 @@ export class IpfsService {
}
}

private async getFilesRecursively(dir: string): Promise<string[]> {
private async getFilesRecursively(
dir: string,
rootPath = dir,
): Promise<string[]> {
const files: string[] = [];
const entries = await fs.readdir(dir, { withFileTypes: true });

for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await this.getFilesRecursively(fullPath)));
} else if (entry.isSymbolicLink()) {
// Check if symlink points to a directory or file
try {
const stat = await fs.stat(fullPath);
if (stat.isDirectory()) {
files.push(...(await this.getFilesRecursively(fullPath)));
} else {
files.push(fullPath);
}
} catch {
// Skip broken symlinks
this.logger.warn(`Skipping broken symlink: ${fullPath}`);
}
} else {
files.push(fullPath);
const stats = await fs.lstat(fullPath);

if (stats.isSymbolicLink()) {
this.logger.warn(
`Skipping symbolic link while adding to IPFS: ${fullPath}`,
);
continue;
Comment on lines +129 to +133

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve in-repo symlinks when pinning sources

When a verified Git repo contains a symlink whose target is still inside the checked-out repo, this unconditional skip drops that source path from the CID even though the build may have followed it successfully. Since verify.controller.ts stores this CID as the pinned source snapshot after checkout and verification, users fetching the pinned source can get a tree that no longer matches or rebuilds the verified commit; the existing isPathInsideRoot check below can distinguish safe in-repo targets from external ones instead of skipping both.

Useful? React with 👍 / 👎.

}

if (stats.isDirectory()) {
files.push(...(await this.getFilesRecursively(fullPath, rootPath)));
continue;
}

if (!stats.isFile()) {
this.logger.warn(
`Skipping non-regular file while adding to IPFS: ${fullPath}`,
);
continue;
}

const realPath = await fs.realpath(fullPath);
if (!this.isPathInsideRoot(rootPath, realPath)) {
this.logger.warn(`Skipping file outside IPFS root: ${fullPath}`);
continue;
}

files.push(realPath);
}

return files;
}

private isPathInsideRoot(rootPath: string, candidatePath: string): boolean {
const relativePath = path.relative(rootPath, candidatePath);
return (
relativePath === '' ||
(!relativePath.startsWith('..') && !path.isAbsolute(relativePath))
);
}
}
Loading