diff --git a/nest/src/services/ipfs/ipfs.service.spec.ts b/nest/src/services/ipfs/ipfs.service.spec.ts index ee47d7d..d75d28c 100644 --- a/nest/src/services/ipfs/ipfs.service.spec.ts +++ b/nest/src/services/ipfs/ipfs.service.spec.ts @@ -93,20 +93,23 @@ describe('IpfsService', () => { return { tempDir, repoDir, outsideDir }; } + async function collectFiles(repoDir: string) { + return (service as any).getFilesRecursively(await fs.realpath(repoDir)); + } + + function relativeFiles(files: Array<{ relativePath: string }>): string[] { + return files.map((file) => file.relativePath).sort(); + } + 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), - ); + const files = await collectFiles(repoDir); - expect(relativeFiles).toEqual(['src/lib.rs']); + expect(relativeFiles(files)).toEqual(['src/lib.rs']); }); it('should skip symlinked directories while collecting IPFS files', async () => { @@ -117,21 +120,61 @@ describe('IpfsService', () => { ); await fs.symlink(outsideDir, path.join(repoDir, 'linked-outside-dir')); - const files = await (service as any).getFilesRecursively( - await fs.realpath(repoDir), + const files = await collectFiles(repoDir); + + expect(relativeFiles(files)).toEqual(['src/lib.rs']); + }); + + it('should preserve symlinked files inside the IPFS root', async () => { + const { repoDir } = await createTempRepo(); + const targetFile = path.join(repoDir, 'src', 'lib.rs'); + await fs.symlink(targetFile, path.join(repoDir, 'src', 'lib-link.rs')); + + const files = await collectFiles(repoDir); + const linkedFile = files.find( + (file: { relativePath: string }) => + file.relativePath === 'src/lib-link.rs', ); - const relativeFiles = files.map((file: string) => - path.relative(repoDir, file), + + expect(relativeFiles(files)).toEqual(['src/lib-link.rs', 'src/lib.rs']); + expect(linkedFile?.sourcePath).toBe(await fs.realpath(targetFile)); + }); + + it('should preserve symlinked directories inside the IPFS root', async () => { + const { repoDir } = await createTempRepo(); + await fs.mkdir(path.join(repoDir, 'shared'), { recursive: true }); + await fs.writeFile(path.join(repoDir, 'shared', 'mod.rs'), 'mod shared;'); + await fs.symlink( + path.join(repoDir, 'shared'), + path.join(repoDir, 'src', 'shared-link'), ); - expect(relativeFiles).toEqual(['src/lib.rs']); + const files = await collectFiles(repoDir); + + expect(relativeFiles(files)).toEqual([ + 'shared/mod.rs', + 'src/lib.rs', + 'src/shared-link/mod.rs', + ]); + }); + + it('should skip recursive symlinks inside the IPFS root', async () => { + const { repoDir } = await createTempRepo(); + await fs.symlink(repoDir, path.join(repoDir, 'src', 'loop')); + + const files = await collectFiles(repoDir); + + expect(relativeFiles(files)).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')), + (service as any).isPathInsideRoot( + rootPath, + path.join(rootPath, 'a.rs'), + ), ).toBe(true); expect( (service as any).isPathInsideRoot( diff --git a/nest/src/services/ipfs/ipfs.service.ts b/nest/src/services/ipfs/ipfs.service.ts index 6abc6f6..9fcabb6 100644 --- a/nest/src/services/ipfs/ipfs.service.ts +++ b/nest/src/services/ipfs/ipfs.service.ts @@ -11,6 +11,11 @@ export interface IpfsFileEntry { Type: number; // 1 = directory, 2 = file } +interface IpfsUploadFile { + sourcePath: string; + relativePath: string; +} + @Injectable() export class IpfsService { private readonly ipfsHost = process.env.IPFS_HOST; @@ -32,11 +37,10 @@ export class IpfsService { const files = await this.getFilesRecursively(rootPath); for (const file of files) { - const relativePath = path.relative(rootPath, file); - const content = await fs.readFile(file); + const content = await fs.readFile(file.sourcePath); form.append('file', content, { - filename: relativePath, - filepath: relativePath, + filename: file.relativePath, + filepath: file.relativePath, }); } @@ -118,23 +122,52 @@ export class IpfsService { private async getFilesRecursively( dir: string, rootPath = dir, - ): Promise { - const files: string[] = []; + relativeDir = '', + ancestorDirs = new Set([rootPath]), + ): Promise { + const files: IpfsUploadFile[] = []; const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); + const relativePath = relativeDir + ? path.join(relativeDir, entry.name) + : entry.name; const stats = await fs.lstat(fullPath); if (stats.isSymbolicLink()) { - this.logger.warn( - `Skipping symbolic link while adding to IPFS: ${fullPath}`, + await this.addSymbolicLinkTarget( + files, + fullPath, + relativePath, + rootPath, + ancestorDirs, ); continue; } if (stats.isDirectory()) { - files.push(...(await this.getFilesRecursively(fullPath, rootPath))); + const realPath = await fs.realpath(fullPath); + if (!this.isPathInsideRoot(rootPath, realPath)) { + this.logger.warn(`Skipping directory outside IPFS root: ${fullPath}`); + continue; + } + + if (ancestorDirs.has(realPath)) { + this.logger.warn( + `Skipping recursive directory while adding to IPFS: ${fullPath}`, + ); + continue; + } + + files.push( + ...(await this.getFilesRecursively( + realPath, + rootPath, + relativePath, + new Set([...ancestorDirs, realPath]), + )), + ); continue; } @@ -151,12 +184,64 @@ export class IpfsService { continue; } - files.push(realPath); + files.push({ sourcePath: realPath, relativePath }); } return files; } + private async addSymbolicLinkTarget( + files: IpfsUploadFile[], + symlinkPath: string, + relativePath: string, + rootPath: string, + ancestorDirs: Set, + ): Promise { + let realPath: string; + try { + realPath = await fs.realpath(symlinkPath); + } catch { + this.logger.warn(`Skipping broken symlink: ${symlinkPath}`); + return; + } + + if (!this.isPathInsideRoot(rootPath, realPath)) { + this.logger.warn( + `Skipping symlink outside IPFS root while adding to IPFS: ${symlinkPath}`, + ); + return; + } + + const stats = await fs.stat(realPath); + if (stats.isDirectory()) { + if (ancestorDirs.has(realPath)) { + this.logger.warn( + `Skipping recursive symlink while adding to IPFS: ${symlinkPath}`, + ); + return; + } + + files.push( + ...(await this.getFilesRecursively( + realPath, + rootPath, + relativePath, + new Set([...ancestorDirs, realPath]), + )), + ); + return; + } + + if (!stats.isFile()) { + this.logger.warn( + `Skipping symlink to non-regular file while adding to IPFS: ${symlinkPath}`, + ); + return; + } + + files.push({ sourcePath: realPath, relativePath }); + } + private isPathInsideRoot(rootPath: string, candidatePath: string): boolean { const relativePath = path.relative(rootPath, candidatePath); return (