diff --git a/src/utils/__tests__/swift-cache.test.ts b/src/utils/__tests__/swift-cache.test.ts index 14cceda11c..9ab475d37b 100644 --- a/src/utils/__tests__/swift-cache.test.ts +++ b/src/utils/__tests__/swift-cache.test.ts @@ -13,7 +13,7 @@ vi.mock('../exec.ts', () => ({ })); import { runCmd } from '../exec.ts'; -import { compileSwiftSourceFile } from '../swift-cache.ts'; +import { compileSwiftSourceFile, compileSwiftSourceText } from '../swift-cache.ts'; const mockRunCmd = vi.mocked(runCmd); @@ -100,6 +100,33 @@ test('cache lock timeout reports the lock path', async () => { expect(mockRunCmd).not.toHaveBeenCalled(); }); +test('compileSwiftSourceText resolves a cache name with a long interior dash run in sub-second time', async () => { + // Regression pin for the polynomial-regex ReDoS fix in `sanitizeCacheName`'s edge-dash + // trim. The interior dashes are never touched by the trim, so a correct sanitizer never + // needs to inspect this whole run — only a backtracking one pays for its length. + const value = `x${'-'.repeat(100_000)}x`; + + const start = Date.now(); + // The cache name is long enough to exceed the filesystem's path-component limit, so the + // call is expected to reject once it reaches disk I/O; that happens only *after* the + // (now fast) sanitize step this test pins, so timing the settle either way still proves + // no catastrophic backtracking occurred. + await compileSwiftSourceText({ source: 'print(1)', cacheName: value }).catch(() => {}); + const elapsedMs = Date.now() - start; + + expect(elapsedMs).toBeLessThan(1_000); +}); + +test('compileSwiftSourceText falls back to swift-helper when the cache name sanitizes to nothing', async () => { + const executablePath = await compileSwiftSourceText({ + source: 'print(1)', + cacheName: '---', + }); + + expect(path.basename(executablePath).startsWith('swift-helper-')).toBe(true); + expect(fs.statSync(executablePath).mode & 0o111).not.toBe(0); +}); + function writeSourceFile(source = 'print("recording")'): string { const sourcePath = path.join(tmpDir, 'recording-overlay.swift'); fs.writeFileSync(sourcePath, source); diff --git a/src/utils/swift-cache.ts b/src/utils/swift-cache.ts index e1ce0c7d5d..a111f26e05 100644 --- a/src/utils/swift-cache.ts +++ b/src/utils/swift-cache.ts @@ -169,7 +169,20 @@ function isExecutableFile(filePath: string): boolean { } function sanitizeCacheName(value: string): string { - return value.replaceAll(/[^A-Za-z0-9._-]/g, '-').replaceAll(/^-+|-+$/g, '') || 'swift-helper'; + return trimEdgeDashes(value.replaceAll(/[^A-Za-z0-9._-]/g, '-')) || 'swift-helper'; +} + +/** + * Linear-time edge trim. The regex form (`/^-+|-+$/g`) backtracks + * polynomially on long dash runs (CodeQL js/polynomial-redos), and cache + * names are derived from caller-supplied strings. + */ +function trimEdgeDashes(value: string): string { + let start = 0; + let end = value.length; + while (start < end && value[start] === '-') start += 1; + while (end > start && value[end - 1] === '-') end -= 1; + return value.slice(start, end); } function hashParts(parts: Array): string {