diff --git a/packages/replay-test/src/internal/__tests__/session-test-discovery.test.ts b/packages/replay-test/src/internal/__tests__/session-test-discovery.test.ts index ebc2eff1b9..3f2a3abb6e 100644 --- a/packages/replay-test/src/internal/__tests__/session-test-discovery.test.ts +++ b/packages/replay-test/src/internal/__tests__/session-test-discovery.test.ts @@ -1,7 +1,12 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import { AppError } from '@agent-device/kernel/errors'; -import { discoverReplayTestEntries } from '../session-test-discovery.ts'; +import { + buildReplayTestInvocationId, + buildReplayTestSessionName, + discoverReplayTestEntries, +} from '../session-test-discovery.ts'; +import { buildReplayTestArtifactSlug, trimEdgeDashes } from '../session-test-artifacts.ts'; import type { ReplayTestManifest, ReplayTestSource } from '../session-test-types.ts'; // Scheduler-owned discovery policy (#1478 P3b): which sources a --platform filter runs, which @@ -75,3 +80,27 @@ test('a suite that matched nothing after filtering is rejected', () => { (error: unknown) => error instanceof AppError && /No replay tests matched/.test(error.message), ); }); + +test('edge-dash trimming stays linear on an interior dash run (CodeQL js/polynomial-redos)', () => { + // The slug pipeline collapses character runs before trimming, so only a + // direct call can carry a long interior run — which is exactly the shape + // the retired /^-+|-+$/g form re-scans quadratically (each interior dash + // restarts a -+$ attempt that fails at the trailing x). 100k dashes take + // seconds there and must stay well under a second here, with the input + // returned byte-identical since nothing sits at the edges. + const interiorRun = `x${'-'.repeat(100_000)}x`; + const startedAt = performance.now(); + const trimmed = trimEdgeDashes(interiorRun); + assert.ok(performance.now() - startedAt < 1000); + assert.equal(trimmed, interiorRun); +}); + +test('all-dash inputs land on the documented fallbacks instead of empty identifiers', () => { + assert.equal(buildReplayTestArtifactSlug('/tmp/####', '/tmp'), 'test'); + assert.equal(buildReplayTestInvocationId('----'), 'suite'); + // A session-name slug that trims to nothing is omitted entirely — no dangling separator. + assert.equal( + buildReplayTestSessionName('s', 'suite1', '/tmp/----.ad', 0), + 's:test:suite1:1:attempt-1', + ); +}); diff --git a/packages/replay-test/src/internal/session-test-artifacts.ts b/packages/replay-test/src/internal/session-test-artifacts.ts index 92d360bfe7..1c65e4c9dc 100644 --- a/packages/replay-test/src/internal/session-test-artifacts.ts +++ b/packages/replay-test/src/internal/session-test-artifacts.ts @@ -23,11 +23,12 @@ export function buildReplayTestArtifactSlug(filePath: string, cwd?: string): str ? path.basename(filePath) : relativePath; return ( - value - .toLowerCase() - .replace(/[\\/]+/g, '__') - .replace(/[^a-z0-9._-]+/g, '-') - .replace(/^-+|-+$/g, '') || 'test' + trimEdgeDashes( + value + .toLowerCase() + .replace(/[\\/]+/g, '__') + .replace(/[^a-z0-9._-]+/g, '-'), + ) || 'test' ); } @@ -135,3 +136,16 @@ function isExistingFile(filePath: string): boolean { return false; } } + +/** + * Linear-time edge trim. The regex form (`/^-+|-+$/g`) backtracks + * polynomially on long dash runs (CodeQL js/polynomial-redos #27/#28), and + * these slugs are built from caller-supplied file paths. + */ +export 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); +} diff --git a/packages/replay-test/src/internal/session-test-discovery.ts b/packages/replay-test/src/internal/session-test-discovery.ts index 0d5b2783f9..0d8550ffd1 100644 --- a/packages/replay-test/src/internal/session-test-discovery.ts +++ b/packages/replay-test/src/internal/session-test-discovery.ts @@ -1,4 +1,5 @@ import path from 'node:path'; +import { trimEdgeDashes } from './session-test-artifacts.ts'; import { AppError } from '@agent-device/kernel/errors'; import { isApplePlatform, type PlatformSelector } from '@agent-device/kernel/device'; import type { @@ -89,20 +90,14 @@ export function buildReplayTestSessionName( attemptIndex = 0, ): string { const baseName = path.basename(filePath, path.extname(filePath)); - const slug = baseName - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); + const slug = trimEdgeDashes(baseName.toLowerCase().replace(/[^a-z0-9]+/g, '-')); const testNumber = caseIndex + 1; return `${sessionName}:test:${suiteInvocationId}:${testNumber}${slug ? `-${slug}` : ''}:attempt-${attemptIndex + 1}`; } export function buildReplayTestInvocationId(requestId?: string): string { const raw = requestId?.trim() || `${process.pid}-${Date.now().toString(36)}`; - const normalized = raw - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); + const normalized = trimEdgeDashes(raw.toLowerCase().replace(/[^a-z0-9]+/g, '-')); return normalized || 'suite'; } diff --git a/src/replay/target-identity.ts b/src/replay/target-identity.ts index 14ae9254ca..2b1fd2582f 100644 --- a/src/replay/target-identity.ts +++ b/src/replay/target-identity.ts @@ -13,7 +13,7 @@ import { AppError } from '@agent-device/kernel/errors'; const TARGET_ANNOTATION_TAG = 'agent-device:target-v1'; // Captures the rest of the line verbatim: a line claiming the tag with a // garbage payload is a malformed v1 annotation, never an ordinary comment. -const TARGET_ANNOTATION_LINE_RE = /^#\s*agent-device:target-v(\d+)(?:\s+(.*))?$/; +const TARGET_ANNOTATION_LINE_RE = /^#\s*agent-device:target-v(\d+)(?:\s+(\S.*))?$/; export const TARGET_ANNOTATION_MAX_FIELD_BYTES = 256; export const TARGET_ANNOTATION_MAX_PAYLOAD_BYTES = 4096;