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
29 changes: 28 additions & 1 deletion src/utils/__tests__/swift-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);
Expand Down
15 changes: 14 additions & 1 deletion src/utils/swift-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 | number | Buffer>): string {
Expand Down
Loading