diff --git a/package.json b/package.json index 5416df170..ad1175b84 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "release:notes:blog": "node tools/release/build-release-notes.mjs --format blog", "release:screenshots": "tsx tools/release/capture-release-screenshots.ts", "stalker:fixtures:validate": "nx run stalker-fixture-tools:validate", + "stalker:fixtures:draft": "tsx --tsconfig tools/stalker-fixtures/tsconfig.json tools/stalker-fixtures/src/cli.ts draft", "lint": "nx run-many --target=lint --all", "build": "nx build electron-backend" }, diff --git a/tools/stalker-fixtures/src/cli.ts b/tools/stalker-fixtures/src/cli.ts index 40e0d292d..3d0409046 100644 --- a/tools/stalker-fixtures/src/cli.ts +++ b/tools/stalker-fixtures/src/cli.ts @@ -5,6 +5,13 @@ import { validateReplayFixtureDirectory, } from './lib/fixture-validation-cli'; import { FixtureValidationError } from './lib/fixture-validator'; +import { + HAR_TO_DRAFT_ERROR_CODES, + HarToDraftError, + convertHarToReplayDraft, +} from './lib/har-to-draft'; +import { HarReaderError, readHarFile } from './lib/har-reader'; +import { SafeOutputError, writeReplayDraftSafely } from './lib/safe-output'; const FIXTURE_ROOT = resolve( process.cwd(), @@ -13,26 +20,50 @@ const FIXTURE_ROOT = resolve( async function main(): Promise { const arguments_ = process.argv.slice(2); - if (arguments_.length !== 1 || arguments_[0] !== 'validate') { - throw new FixtureValidationCliError( - FIXTURE_VALIDATION_CLI_ERROR_CODES.UnsupportedCommand + if (arguments_.length === 1 && arguments_[0] === 'validate') { + const fixtureCount = await validateReplayFixtureDirectory(FIXTURE_ROOT); + process.stdout.write( + `Validated ${fixtureCount} Stalker replay fixture(s).\n` ); + return; } - const fixtureCount = await validateReplayFixtureDirectory(FIXTURE_ROOT); - process.stdout.write( - `Validated ${fixtureCount} Stalker replay fixture(s).\n` + if (arguments_.length === 3 && arguments_[0] === 'draft') { + const inputPath = arguments_[1]; + const outputPath = arguments_[2]; + if (inputPath === undefined || outputPath === undefined) { + throw new HarToDraftError( + HAR_TO_DRAFT_ERROR_CODES.UnsupportedCommand + ); + } + const har = await readHarFile(inputPath); + const draft = convertHarToReplayDraft(har); + await writeReplayDraftSafely(outputPath, draft.formatted); + process.stdout.write('Created sanitized Stalker replay draft.\n'); + return; + } + + if (arguments_[0] === 'draft') { + throw new HarToDraftError(HAR_TO_DRAFT_ERROR_CODES.UnsupportedCommand); + } + throw new FixtureValidationCliError( + FIXTURE_VALIDATION_CLI_ERROR_CODES.UnsupportedCommand ); } void main().catch((error: unknown) => { const safeError = error instanceof FixtureValidationCliError || - error instanceof FixtureValidationError + error instanceof FixtureValidationError || + error instanceof HarReaderError || + error instanceof HarToDraftError || + error instanceof SafeOutputError ? error - : new FixtureValidationCliError( - FIXTURE_VALIDATION_CLI_ERROR_CODES.InternalError - ); + : process.argv[2] === 'draft' + ? new HarToDraftError(HAR_TO_DRAFT_ERROR_CODES.InternalError) + : new FixtureValidationCliError( + FIXTURE_VALIDATION_CLI_ERROR_CODES.InternalError + ); process.stderr.write(`${safeError.message}\n`); process.exitCode = 1; }); diff --git a/tools/stalker-fixtures/src/index.ts b/tools/stalker-fixtures/src/index.ts index 6dcc5ab10..8fb0140f5 100644 --- a/tools/stalker-fixtures/src/index.ts +++ b/tools/stalker-fixtures/src/index.ts @@ -6,3 +6,6 @@ export { } from './lib/fixture-validation-cli'; export type { FixtureValidationCliErrorCode } from './lib/fixture-validation-cli'; export * from './lib/fixture-validator'; +export * from './lib/har-reader'; +export * from './lib/har-to-draft'; +export * from './lib/safe-output'; diff --git a/tools/stalker-fixtures/src/lib/draft-cli.spec.ts b/tools/stalker-fixtures/src/lib/draft-cli.spec.ts new file mode 100644 index 000000000..200f67eeb --- /dev/null +++ b/tools/stalker-fixtures/src/lib/draft-cli.spec.ts @@ -0,0 +1,129 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { validateReplayFixtureText } from './fixture-validator'; + +interface CliResult { + exitCode: number; + stderr: string; + stdout: string; +} + +function minimalHar(): string { + return JSON.stringify({ + log: { + version: '1.2', + entries: [ + { + request: { + method: 'GET', + url: 'https://captured-origin.private/c/', + headers: [], + cookies: [], + }, + response: { + status: 200, + headers: [ + { + name: 'Content-Type', + value: 'application/json', + }, + ], + cookies: [], + content: { + mimeType: 'application/json', + text: JSON.stringify({ js: true }), + }, + }, + }, + ], + }, + }); +} + +function runCli(arguments_: readonly string[]): Promise { + const executable = resolve(process.cwd(), 'node_modules/.bin/tsx'); + const environment = { ...process.env }; + delete environment['FORCE_COLOR']; + delete environment['NO_COLOR']; + return new Promise((resolvePromise) => { + execFile( + executable, + [ + '--tsconfig', + 'tools/stalker-fixtures/tsconfig.json', + 'tools/stalker-fixtures/src/cli.ts', + ...arguments_, + ], + { + cwd: process.cwd(), + encoding: 'utf8', + env: environment, + maxBuffer: 1024 * 1024, + timeout: 15_000, + }, + (error, stdout, stderr) => { + resolvePromise({ + exitCode: + error === null + ? 0 + : typeof error.code === 'number' + ? error.code + : 1, + stdout, + stderr, + }); + } + ); + }); +} + +describe('HAR draft CLI', () => { + let captureRoot: string; + + beforeEach(async () => { + captureRoot = await mkdtemp(join(tmpdir(), 'stalker-draft-cli-')); + }); + + afterEach(async () => { + await rm(captureRoot, { force: true, recursive: true }); + }); + + it('converts an external HAR without printing either path', async () => { + const inputPath = join(captureRoot, 'private-capture.har'); + const outputPath = join(captureRoot, 'sanitized-draft.json'); + await writeFile(inputPath, minimalHar()); + + const result = await runCli(['draft', inputPath, outputPath]); + + expect(result).toEqual({ + exitCode: 0, + stdout: 'Created sanitized Stalker replay draft.\n', + stderr: '', + }); + expect(result.stdout).not.toContain(inputPath); + expect(result.stdout).not.toContain(outputPath); + const output = await readFile(outputPath, 'utf8'); + expect(() => validateReplayFixtureText(output)).not.toThrow(); + expect(output).not.toContain('captured-origin.private'); + }); + + it('emits only a stable sanitized error for invalid input', async () => { + const secret = 'private-captured-secret'; + const inputPath = join(captureRoot, `${secret}.har`); + const outputPath = join(captureRoot, 'sanitized-draft.json'); + await writeFile(inputPath, `{"${secret}":`); + + const result = await runCli(['draft', inputPath, outputPath]); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe( + 'Stalker HAR conversion failed: invalid-har-json.\n' + ); + expect(result.stderr).not.toContain(secret); + expect(result.stderr).not.toContain(inputPath); + expect(result.stderr).not.toContain(outputPath); + }); +}); diff --git a/tools/stalker-fixtures/src/lib/har-reader.spec.ts b/tools/stalker-fixtures/src/lib/har-reader.spec.ts new file mode 100644 index 000000000..a0777fea5 --- /dev/null +++ b/tools/stalker-fixtures/src/lib/har-reader.spec.ts @@ -0,0 +1,268 @@ +import { + link, + mkdtemp, + mkdir, + rename, + rm, + symlink, + utimes, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + HAR_READER_ERROR_CODES, + HAR_READER_LIMITS, + HarReaderError, + decodeHarBase64, + parseGitWorktreeList, + readHarFile, +} from './har-reader'; + +function minimalHar(): string { + return JSON.stringify({ + log: { + version: '1.2', + entries: [], + }, + }); +} + +async function expectReaderCode( + operation: Promise | (() => unknown), + code: string +): Promise { + try { + if (typeof operation === 'function') { + operation(); + } else { + await operation; + } + throw new Error('HAR input unexpectedly passed'); + } catch (error) { + expect(error).toBeInstanceOf(HarReaderError); + expect((error as HarReaderError).code).toBe(code); + expect((error as Error).message).toBe( + `Stalker HAR conversion failed: ${code}.` + ); + } +} + +describe('safe HAR reader', () => { + let captureRoot: string; + + beforeEach(async () => { + captureRoot = await mkdtemp(join(tmpdir(), 'stalker-har-')); + }); + + afterEach(async () => { + await rm(captureRoot, { force: true, recursive: true }); + }); + + it('reads a bounded external regular file with fatal UTF-8 decoding', async () => { + const capturePath = join(captureRoot, 'capture.har'); + await writeFile(capturePath, minimalHar()); + + await expect( + readHarFile(capturePath, { worktreeRoots: [] }) + ).resolves.toEqual({ + log: { + version: '1.2', + entries: [], + }, + }); + }); + + it('rejects a capture inside any injected Git worktree root', async () => { + const capturePath = join(captureRoot, 'capture.har'); + await writeFile(capturePath, minimalHar()); + + await expectReaderCode( + readHarFile(capturePath, { worktreeRoots: [captureRoot] }), + HAR_READER_ERROR_CODES.InputInsideWorktree + ); + }); + + it('parses only bounded worktree records from NUL porcelain output', () => { + expect( + parseGitWorktreeList( + 'worktree /repo/main\0HEAD abc\0branch refs/heads/main\0\0' + + 'worktree /repo/linked\0HEAD def\0detached\0\0' + ) + ).toEqual(['/repo/main', '/repo/linked']); + }); + + it('rejects symlink, hardlink, and directory inputs', async () => { + const sourcePath = join(captureRoot, 'source.har'); + await writeFile(sourcePath, minimalHar()); + const symlinkPath = join(captureRoot, 'symlink.har'); + await symlink(sourcePath, symlinkPath); + const hardlinkPath = join(captureRoot, 'hardlink.har'); + await link(sourcePath, hardlinkPath); + const directoryPath = join(captureRoot, 'directory.har'); + await mkdir(directoryPath); + + for (const unsafePath of [symlinkPath, hardlinkPath, directoryPath]) { + await expectReaderCode( + readHarFile(unsafePath, { worktreeRoots: [] }), + HAR_READER_ERROR_CODES.UnsafeInputPath + ); + } + }); + + it('rejects a file swapped between preflight and open', async () => { + const capturePath = join(captureRoot, 'capture.har'); + const replacementPath = join(captureRoot, 'replacement.har'); + await writeFile(capturePath, minimalHar()); + await writeFile(replacementPath, minimalHar()); + + await expectReaderCode( + readHarFile(capturePath, { + worktreeRoots: [], + beforeOpen: async () => { + await rename( + capturePath, + join(captureRoot, 'original.har') + ); + await rename(replacementPath, capturePath); + }, + }), + HAR_READER_ERROR_CODES.UnsafeInputPath + ); + }); + + it('rejects same-inode same-size mutation after preflight', async () => { + const capturePath = join(captureRoot, 'capture.har'); + const original = minimalHar(); + await writeFile(capturePath, original); + + await expectReaderCode( + readHarFile(capturePath, { + worktreeRoots: [], + beforeOpen: async () => { + await writeFile( + capturePath, + original.replace('"1.2"', '"1.1"') + ); + await utimes(capturePath, new Date(0), new Date(0)); + }, + }), + HAR_READER_ERROR_CODES.UnsafeInputPath + ); + }); + + it('rejects raw captures above the fixed byte ceiling', async () => { + const capturePath = join(captureRoot, 'capture.har'); + await writeFile( + capturePath, + Buffer.alloc(HAR_READER_LIMITS.MaxRawBytes + 1, 0x20) + ); + + await expectReaderCode( + readHarFile(capturePath, { worktreeRoots: [] }), + HAR_READER_ERROR_CODES.HarTooLarge + ); + }); + + it('rejects malformed UTF-8 and a UTF-8 BOM', async () => { + const malformedPath = join(captureRoot, 'malformed.har'); + await writeFile(malformedPath, Buffer.from([0xc3, 0x28])); + await expectReaderCode( + readHarFile(malformedPath, { worktreeRoots: [] }), + HAR_READER_ERROR_CODES.InvalidUtf8 + ); + + const bomPath = join(captureRoot, 'bom.har'); + await writeFile( + bomPath, + Buffer.concat([ + Buffer.from([0xef, 0xbb, 0xbf]), + Buffer.from(minimalHar()), + ]) + ); + await expectReaderCode( + readHarFile(bomPath, { worktreeRoots: [] }), + HAR_READER_ERROR_CODES.InvalidHarJson + ); + }); + + it('rejects excessive JSON nesting before parsing', async () => { + const capturePath = join(captureRoot, 'nested.har'); + const depth = HAR_READER_LIMITS.MaxJsonDepth + 1; + await writeFile( + capturePath, + `${'['.repeat(depth)}null${']'.repeat(depth)}` + ); + + await expectReaderCode( + readHarFile(capturePath, { worktreeRoots: [] }), + HAR_READER_ERROR_CODES.HarTooDeep + ); + }); + + it('rejects oversized JSON collections and strings', async () => { + const collectionPath = join(captureRoot, 'collection.har'); + await writeFile( + collectionPath, + JSON.stringify( + Array.from( + { length: HAR_READER_LIMITS.MaxCollectionItems + 1 }, + () => 0 + ) + ) + ); + await expectReaderCode( + readHarFile(collectionPath, { worktreeRoots: [] }), + HAR_READER_ERROR_CODES.HarCollectionTooLarge + ); + + const stringPath = join(captureRoot, 'string.har'); + await writeFile( + stringPath, + JSON.stringify('x'.repeat(HAR_READER_LIMITS.MaxStringBytes + 1)) + ); + await expectReaderCode( + readHarFile(stringPath, { worktreeRoots: [] }), + HAR_READER_ERROR_CODES.HarStringTooLarge + ); + }); + + it('rejects malformed JSON with one stable sanitized code', async () => { + const capturePath = join(captureRoot, 'capture.har'); + await writeFile(capturePath, '{"log":'); + + await expectReaderCode( + readHarFile(capturePath, { worktreeRoots: [] }), + HAR_READER_ERROR_CODES.InvalidHarJson + ); + }); +}); + +describe('strict HAR base64 decoding', () => { + it('accepts canonical base64 within the decoded-body ceiling', () => { + expect( + decodeHarBase64(Buffer.from('hello').toString('base64')) + ).toEqual(Buffer.from('hello')); + }); + + it.each(['a', 'a===', 'aGVsbG8', 'aGVs bG8=', 'aGVsbG8===', '****'])( + 'rejects noncanonical base64 %s', + async (value) => { + await expectReaderCode( + () => decodeHarBase64(value), + HAR_READER_ERROR_CODES.InvalidBase64 + ); + } + ); + + it('rejects decoded bodies over their independent ceiling', async () => { + const encoded = Buffer.alloc( + HAR_READER_LIMITS.MaxDecodedBodyBytes + 1 + ).toString('base64'); + + await expectReaderCode( + () => decodeHarBase64(encoded), + HAR_READER_ERROR_CODES.DecodedBodyTooLarge + ); + }); +}); diff --git a/tools/stalker-fixtures/src/lib/har-reader.ts b/tools/stalker-fixtures/src/lib/har-reader.ts new file mode 100644 index 000000000..536c75dc0 --- /dev/null +++ b/tools/stalker-fixtures/src/lib/har-reader.ts @@ -0,0 +1,499 @@ +/* eslint-disable max-lines -- Keep the no-follow reader, race checks, and bounded JSON preflight in one auditable module. */ +import { execFile } from 'node:child_process'; +import { constants, type BigIntStats } from 'node:fs'; +import { lstat, open, realpath, type FileHandle } from 'node:fs/promises'; +import { isAbsolute, relative } from 'node:path'; +import { TextDecoder } from 'node:util'; + +export const HAR_READER_ERROR_CODES = { + InputUnavailable: 'input-unavailable', + UnsafeInputPath: 'unsafe-input-path', + InputInsideWorktree: 'input-inside-worktree', + WorktreeDiscoveryFailed: 'worktree-discovery-failed', + InputReadFailed: 'input-read-failed', + HarTooLarge: 'har-too-large', + InvalidUtf8: 'invalid-utf8', + HarTooDeep: 'har-too-deep', + HarCollectionTooLarge: 'har-collection-too-large', + HarStringTooLarge: 'har-string-too-large', + HarStructureTooLarge: 'har-structure-too-large', + InvalidHarJson: 'invalid-har-json', + InvalidBase64: 'invalid-base64', + DecodedBodyTooLarge: 'decoded-body-too-large', +} as const; + +export type HarReaderErrorCode = + (typeof HAR_READER_ERROR_CODES)[keyof typeof HAR_READER_ERROR_CODES]; + +export class HarReaderError extends Error { + constructor(readonly code: HarReaderErrorCode) { + super(`Stalker HAR conversion failed: ${code}.`); + this.name = 'HarReaderError'; + } +} + +export const HAR_READER_LIMITS = { + MaxRawBytes: 16 * 1024 * 1024, + MaxDecodedBodyBytes: 1024 * 1024, + MaxAggregateDecodedBodyBytes: 8 * 1024 * 1024, + MaxJsonDepth: 32, + MaxCollectionItems: 512, + MaxStringBytes: 2 * 1024 * 1024, + MaxJsonNodes: 32 * 1024, + MaxWorktreeOutputBytes: 256 * 1024, + MaxWorktrees: 256, + MaxWorktreePathBytes: 16 * 1024, +} as const; + +export interface HarReaderOptions { + /** + * Test seam and embeddable-call override. Production callers omit this so + * every Git worktree registered for the repository is discovered. + */ + worktreeRoots?: readonly string[]; + /** + * Race-test seam. Production callers must not supply it. + */ + beforeOpen?: () => Promise; +} + +interface JsonCollectionFrame { + kind: '[' | '{'; + commas: number; + hasContent: boolean; +} + +export async function readHarFile( + inputPath: string, + options: HarReaderOptions = {} +): Promise { + const preflight = await safeInputLstat(inputPath); + assertSafeInputFile(preflight); + assertRawSize(preflight.size); + + const canonicalInput = await safeRealpath( + inputPath, + HAR_READER_ERROR_CODES.UnsafeInputPath + ); + const canonicalPreflight = await safeInputLstat(canonicalInput); + assertSafeInputFile(canonicalPreflight); + assertSameInput(preflight, canonicalPreflight); + + const worktreeRoots = + options.worktreeRoots === undefined + ? await discoverGitWorktreeRoots() + : options.worktreeRoots; + await assertOutsideEveryWorktree(canonicalInput, worktreeRoots); + + let handle: FileHandle | undefined; + try { + await options.beforeOpen?.(); + handle = await open( + canonicalInput, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ); + const opened = await handle.stat({ bigint: true }); + assertSafeInputFile(opened); + assertSameInput(canonicalPreflight, opened); + assertRawSize(opened.size); + + const bytes = await readBounded(handle); + const afterRead = await handle.stat({ bigint: true }); + const afterOriginalPath = await lstat(inputPath, { bigint: true }); + const afterCanonicalPath = await lstat(canonicalInput, { + bigint: true, + }); + for (const stat of [afterRead, afterOriginalPath, afterCanonicalPath]) { + assertSafeInputFile(stat); + assertSameInput(opened, stat); + assertRawSize(stat.size); + } + if (BigInt(bytes.byteLength) !== afterRead.size) { + reject(HAR_READER_ERROR_CODES.UnsafeInputPath); + } + + await handle.close(); + handle = undefined; + const text = decodeUtf8(bytes); + preflightJsonStructure(text); + return parseBoundedJson(text); + } catch (error) { + if (error instanceof HarReaderError) { + throw error; + } + throw new HarReaderError(HAR_READER_ERROR_CODES.InputReadFailed); + } finally { + await handle?.close().catch(() => undefined); + } +} + +export function decodeHarBase64(value: string): Buffer { + const maximumEncodedBytes = + Math.ceil(HAR_READER_LIMITS.MaxDecodedBodyBytes / 3) * 4; + if ( + Buffer.byteLength(value, 'ascii') > maximumEncodedBytes || + !isCanonicalBase64Syntax(value) + ) { + if (Buffer.byteLength(value, 'ascii') > maximumEncodedBytes) { + reject(HAR_READER_ERROR_CODES.DecodedBodyTooLarge); + } + reject(HAR_READER_ERROR_CODES.InvalidBase64); + } + + const decoded = Buffer.from(value, 'base64'); + if (decoded.byteLength > HAR_READER_LIMITS.MaxDecodedBodyBytes) { + reject(HAR_READER_ERROR_CODES.DecodedBodyTooLarge); + } + if (decoded.toString('base64') !== value) { + reject(HAR_READER_ERROR_CODES.InvalidBase64); + } + return decoded; +} + +export function parseGitWorktreeList(output: string): string[] { + if (Buffer.byteLength(output) > HAR_READER_LIMITS.MaxWorktreeOutputBytes) { + reject(HAR_READER_ERROR_CODES.WorktreeDiscoveryFailed); + } + + const roots: string[] = []; + const records = output.split('\0\0'); + const finalRecord = records.pop(); + if (finalRecord !== '') { + reject(HAR_READER_ERROR_CODES.WorktreeDiscoveryFailed); + } + + for (const record of records) { + const fields = record.split('\0'); + const worktree = fields[0]; + if ( + worktree === undefined || + !worktree.startsWith('worktree ') || + worktree.length === 'worktree '.length + ) { + reject(HAR_READER_ERROR_CODES.WorktreeDiscoveryFailed); + } + const root = worktree.slice('worktree '.length); + if ( + !isAbsolute(root) || + Buffer.byteLength(root) > HAR_READER_LIMITS.MaxWorktreePathBytes || + root.includes('\n') || + root.includes('\r') + ) { + reject(HAR_READER_ERROR_CODES.WorktreeDiscoveryFailed); + } + roots.push(root); + if (roots.length > HAR_READER_LIMITS.MaxWorktrees) { + reject(HAR_READER_ERROR_CODES.WorktreeDiscoveryFailed); + } + } + + if (roots.length === 0) { + reject(HAR_READER_ERROR_CODES.WorktreeDiscoveryFailed); + } + return roots; +} + +async function safeInputLstat(path: string): Promise { + try { + return await lstat(path, { bigint: true }); + } catch { + reject(HAR_READER_ERROR_CODES.InputUnavailable); + } +} + +async function safeRealpath( + path: string, + code: HarReaderErrorCode +): Promise { + try { + return await realpath(path); + } catch { + reject(code); + } +} + +async function discoverGitWorktreeRoots(): Promise { + let stdout: Buffer; + try { + stdout = await executeGitWorktreeList(); + } catch { + reject(HAR_READER_ERROR_CODES.WorktreeDiscoveryFailed); + } + let text: string; + try { + text = new TextDecoder('utf-8', { + fatal: true, + ignoreBOM: true, + }).decode(stdout); + } catch { + reject(HAR_READER_ERROR_CODES.WorktreeDiscoveryFailed); + } + const roots = parseGitWorktreeList(text); + return Promise.all( + roots.map((root) => + safeRealpath(root, HAR_READER_ERROR_CODES.WorktreeDiscoveryFailed) + ) + ); +} + +function executeGitWorktreeList(): Promise { + return new Promise((resolvePromise, rejectPromise) => { + execFile( + 'git', + ['worktree', 'list', '--porcelain', '-z'], + { + cwd: process.cwd(), + encoding: 'buffer', + maxBuffer: HAR_READER_LIMITS.MaxWorktreeOutputBytes, + timeout: 5_000, + windowsHide: true, + }, + (error, stdout) => { + if (error !== null || !Buffer.isBuffer(stdout)) { + rejectPromise(error ?? new Error('invalid git output')); + return; + } + resolvePromise(stdout); + } + ); + }); +} + +async function assertOutsideEveryWorktree( + canonicalInput: string, + roots: readonly string[] +): Promise { + if (roots.length > HAR_READER_LIMITS.MaxWorktrees) { + reject(HAR_READER_ERROR_CODES.WorktreeDiscoveryFailed); + } + for (const root of roots) { + if ( + !isAbsolute(root) || + Buffer.byteLength(root) > HAR_READER_LIMITS.MaxWorktreePathBytes + ) { + reject(HAR_READER_ERROR_CODES.WorktreeDiscoveryFailed); + } + const canonicalRoot = await safeRealpath( + root, + HAR_READER_ERROR_CODES.WorktreeDiscoveryFailed + ); + const pathFromRoot = relative(canonicalRoot, canonicalInput); + if ( + pathFromRoot === '' || + (!pathFromRoot.startsWith('..') && !isAbsolute(pathFromRoot)) + ) { + reject(HAR_READER_ERROR_CODES.InputInsideWorktree); + } + } +} + +async function readBounded(handle: FileHandle): Promise { + const buffer = Buffer.allocUnsafe(HAR_READER_LIMITS.MaxRawBytes + 1); + let offset = 0; + while (offset < buffer.length) { + const result = await handle.read( + buffer, + offset, + buffer.length - offset, + offset + ); + if (result.bytesRead === 0) { + break; + } + offset += result.bytesRead; + } + assertRawSize(offset); + return buffer.subarray(0, offset); +} + +function decodeUtf8(bytes: Buffer): string { + try { + return new TextDecoder('utf-8', { + fatal: true, + ignoreBOM: true, + }).decode(bytes); + } catch { + reject(HAR_READER_ERROR_CODES.InvalidUtf8); + } +} + +function preflightJsonStructure(text: string): void { + const stack: JsonCollectionFrame[] = []; + let inString = false; + let escaped = false; + + for (const character of text) { + if (inString) { + if (escaped) { + escaped = false; + } else if (character === '\\') { + escaped = true; + } else if (character === '"') { + inString = false; + markCollectionContent(stack); + } + continue; + } + + if (character === '"') { + inString = true; + continue; + } + if (character === '[' || character === '{') { + markCollectionContent(stack); + stack.push({ + kind: character, + commas: 0, + hasContent: false, + }); + if (stack.length > HAR_READER_LIMITS.MaxJsonDepth) { + reject(HAR_READER_ERROR_CODES.HarTooDeep); + } + continue; + } + if (character === ']' || character === '}') { + const frame = stack.pop(); + if ( + frame !== undefined && + ((character === ']' && frame.kind !== '[') || + (character === '}' && frame.kind !== '{')) + ) { + reject(HAR_READER_ERROR_CODES.InvalidHarJson); + } + if ( + frame?.hasContent === true && + frame.commas + 1 > HAR_READER_LIMITS.MaxCollectionItems + ) { + reject(HAR_READER_ERROR_CODES.HarCollectionTooLarge); + } + continue; + } + if (character === ',') { + const frame = stack.at(-1); + if (frame !== undefined) { + frame.commas += 1; + if (frame.commas >= HAR_READER_LIMITS.MaxCollectionItems) { + reject(HAR_READER_ERROR_CODES.HarCollectionTooLarge); + } + } + continue; + } + if (!/\s/u.test(character) && character !== ':') { + markCollectionContent(stack); + } + } +} + +function markCollectionContent(stack: JsonCollectionFrame[]): void { + const frame = stack.at(-1); + if (frame !== undefined) { + frame.hasContent = true; + } +} + +function parseBoundedJson(text: string): unknown { + let value: unknown; + try { + value = JSON.parse(text) as unknown; + } catch { + reject(HAR_READER_ERROR_CODES.InvalidHarJson); + } + assertBoundedJsonValue(value); + return value; +} + +function assertBoundedJsonValue(root: unknown): void { + const pending: Array<{ depth: number; value: unknown }> = [ + { depth: 0, value: root }, + ]; + let nodes = 0; + + while (pending.length > 0) { + const current = pending.pop(); + if (current === undefined) { + continue; + } + nodes += 1; + if (nodes > HAR_READER_LIMITS.MaxJsonNodes) { + reject(HAR_READER_ERROR_CODES.HarStructureTooLarge); + } + if (current.depth > HAR_READER_LIMITS.MaxJsonDepth) { + reject(HAR_READER_ERROR_CODES.HarTooDeep); + } + if (typeof current.value === 'string') { + if ( + Buffer.byteLength(current.value) > + HAR_READER_LIMITS.MaxStringBytes + ) { + reject(HAR_READER_ERROR_CODES.HarStringTooLarge); + } + continue; + } + if (Array.isArray(current.value)) { + if (current.value.length > HAR_READER_LIMITS.MaxCollectionItems) { + reject(HAR_READER_ERROR_CODES.HarCollectionTooLarge); + } + for (const item of current.value) { + pending.push({ + depth: current.depth + 1, + value: item, + }); + } + continue; + } + if (isRecord(current.value)) { + const entries = Object.entries(current.value); + if (entries.length > HAR_READER_LIMITS.MaxCollectionItems) { + reject(HAR_READER_ERROR_CODES.HarCollectionTooLarge); + } + for (const [key, child] of entries) { + if (Buffer.byteLength(key) > HAR_READER_LIMITS.MaxStringBytes) { + reject(HAR_READER_ERROR_CODES.HarStringTooLarge); + } + pending.push({ + depth: current.depth + 1, + value: child, + }); + } + } + } +} + +function isCanonicalBase64Syntax(value: string): boolean { + return /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test( + value + ); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function assertSafeInputFile(stat: BigIntStats): void { + if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1n) { + reject(HAR_READER_ERROR_CODES.UnsafeInputPath); + } +} + +function assertSameInput(expected: BigIntStats, actual: BigIntStats): void { + if ( + expected.dev !== actual.dev || + expected.ino !== actual.ino || + expected.mode !== actual.mode || + expected.size !== actual.size || + expected.mtimeNs !== actual.mtimeNs || + expected.ctimeNs !== actual.ctimeNs + ) { + reject(HAR_READER_ERROR_CODES.UnsafeInputPath); + } +} + +function assertRawSize(size: number | bigint): void { + if (BigInt(size) > BigInt(HAR_READER_LIMITS.MaxRawBytes)) { + reject(HAR_READER_ERROR_CODES.HarTooLarge); + } +} + +function reject(code: HarReaderErrorCode): never { + throw new HarReaderError(code); +} diff --git a/tools/stalker-fixtures/src/lib/har-to-draft.spec.ts b/tools/stalker-fixtures/src/lib/har-to-draft.spec.ts new file mode 100644 index 000000000..935320804 --- /dev/null +++ b/tools/stalker-fixtures/src/lib/har-to-draft.spec.ts @@ -0,0 +1,447 @@ +/* eslint-disable max-lines -- Keep the representative capture and its fail-closed conversion cases together. */ +import { validateReplayFixtureText } from './fixture-validator'; +import { + HAR_TO_DRAFT_ERROR_CODES, + HarToDraftError, + convertHarToReplayDraft, +} from './har-to-draft'; +import { HAR_READER_ERROR_CODES, HarReaderError } from './har-reader'; + +const CAPTURED = { + portalOrigin: 'https://portal.vendor.private:8443', + edgeOrigin: 'https://edge.vendor.private:9443', + mac: '00:1A:79:12:34:56', + token: 'eyJhbGciOiJIUzI1NiJ9.privatePayload.privateSignature', + username: 'customer@example.invalid', + password: 'not-a-real-password', + cookie: 'captured-cookie-session-value', + serial: 'captured-serial-0001', + device: 'captured-device-0002', + signature: 'captured-signature-0003', + futureCredential: 'short-future-secret', +} as const; + +function representativeHar(): unknown { + const responseJson = { + js: { + token: CAPTURED.token, + mac: CAPTURED.mac, + username: CAPTURED.username, + password: CAPTURED.password, + serial: CAPTURED.serial, + device_id: CAPTURED.device, + signature: CAPTURED.signature, + harmless_alias: CAPTURED.futureCredential, + harmless_message: `prefix ${CAPTURED.futureCredential} suffix`, + provider: 'captured-provider-name', + stream_url: `${CAPTURED.edgeOrigin}/play/private-stream`, + received_at: '2026-07-27T12:34:56.000Z', + safe_flag: true, + }, + }; + + return { + log: { + version: '1.2', + creator: { name: 'browser', version: '1' }, + entries: [ + { + startedDateTime: '2026-07-27T12:34:56.000Z', + request: { + method: 'POST', + url: + `${CAPTURED.portalOrigin}/c/portal.php` + + `?type=stb&action=handshake&mac=${encodeURIComponent(CAPTURED.mac)}`, + headers: [ + { name: 'Accept', value: 'application/json' }, + { + name: 'Content-Type', + value: 'application/x-www-form-urlencoded', + }, + { + name: 'Authorization', + value: `Bearer ${CAPTURED.token}`, + }, + { + name: 'Cookie', + value: + `mac=${CAPTURED.mac}; ` + + `PHPSESSID=${CAPTURED.cookie}`, + }, + { + name: 'X-Ignored-Capture-Header', + value: CAPTURED.serial, + }, + ], + cookies: [ + { name: 'mac', value: CAPTURED.mac }, + { + name: 'PHPSESSID', + value: CAPTURED.cookie, + }, + ], + postData: { + mimeType: 'application/x-www-form-urlencoded', + text: + `username=${encodeURIComponent(CAPTURED.username)}` + + `&password=${encodeURIComponent(CAPTURED.password)}`, + }, + }, + response: { + status: 302, + headers: [ + { + name: 'Content-Type', + value: 'application/json; charset=utf-8', + }, + { + name: 'Location', + value: + `${CAPTURED.edgeOrigin}/load.php` + + `?token=${encodeURIComponent(CAPTURED.token)}`, + }, + { + name: 'Set-Cookie', + value: + `PHPSESSID=${CAPTURED.cookie}; ` + + 'Path=/c/; HttpOnly; Secure; SameSite=Lax; ' + + 'Expires=Mon, 27 Jul 2026 12:34:56 GMT', + }, + ], + cookies: [], + content: { + mimeType: 'application/json', + text: JSON.stringify(responseJson), + }, + }, + }, + { + request: { + method: 'GET', + url: + `${CAPTURED.edgeOrigin}/load.php` + + `?token=${encodeURIComponent(CAPTURED.token)}` + + `&password=${encodeURIComponent(CAPTURED.futureCredential)}`, + headers: [ + { + name: 'Accept', + value: 'application/octet-stream', + }, + ], + cookies: [], + }, + response: { + status: 200, + headers: [ + { + name: 'Content-Type', + value: 'application/octet-stream', + }, + ], + cookies: [], + content: { + mimeType: 'application/octet-stream', + encoding: 'base64', + text: Buffer.from([0, 1, 2, 3]).toString('base64'), + }, + }, + }, + ], + }, + }; +} + +function expectDraftCode(operation: () => unknown, code: string): void { + try { + operation(); + throw new Error('HAR conversion unexpectedly passed'); + } catch (error) { + expect(error).toBeInstanceOf(HarToDraftError); + expect((error as HarToDraftError).code).toBe(code); + expect((error as Error).message).toBe( + `Stalker HAR conversion failed: ${code}.` + ); + } +} + +describe('HAR to replay draft conversion', () => { + it('preserves protocol shape while replacing captured identity with typed symbols', () => { + const draft = convertHarToReplayDraft(representativeHar()); + + expect(() => validateReplayFixtureText(draft.formatted)).not.toThrow(); + expect(Object.keys(draft.fixture.origins)).toEqual([ + 'origin-1', + 'origin-2', + ]); + expect(draft.fixture.entry).toEqual({ + origin: 'origin-1', + path: '/c/portal.php', + }); + expect(draft.fixture.expectedEndpoint).toEqual({ + origin: 'origin-2', + path: '/load.php', + }); + expect(draft.fixture.phases).toHaveLength(2); + expect(draft.fixture.phases[0]?.expectations[0]?.response.status).toBe( + 302 + ); + expect( + draft.fixture.phases[0]?.expectations[0]?.response.headers[ + 'location' + ]?.[0] + ).toMatchObject({ + kind: 'origin-url', + origin: 'origin-2', + path: '/load.php', + }); + expect(draft.fixture.phases[1]?.expectations[0]?.response.body).toEqual( + { + kind: 'generated', + byteLength: 4, + byte: 0, + } + ); + + const valueKinds = new Set( + draft.fixture.symbols.map((symbol) => symbol.valueKind) + ); + expect(valueKinds).toEqual( + new Set(['mac', 'credential', 'token', 'cookie', 'random']) + ); + expect(draft.formatted).toContain('"kind": "ref"'); + expect(draft.formatted).toContain('"kind": "parts"'); + + for (const literal of Object.values(CAPTURED)) { + expect(draft.formatted).not.toContain(literal); + expect(draft.formatted).not.toContain(encodeURIComponent(literal)); + } + expect(draft.formatted).not.toContain('captured-provider-name'); + expect(draft.formatted).not.toContain('private-stream'); + expect(draft.formatted).not.toContain('2026-07-27'); + expect(draft.formatted).not.toContain('X-Ignored-Capture-Header'); + }); + + it('uses one symbol for the same correlation across phases', () => { + const draft = convertHarToReplayDraft(representativeHar()); + const serialized = draft.formatted; + const tokenDeclarations = draft.fixture.symbols.filter( + (symbol) => symbol.valueKind === 'token' + ); + + expect(tokenDeclarations).toHaveLength(1); + const tokenSymbol = tokenDeclarations[0]?.symbol; + expect(tokenSymbol).toBeDefined(); + expect( + serialized.split(`"symbol": "${tokenSymbol}"`).length - 1 + ).toBeGreaterThanOrEqual(4); + }); + + it('rejects redirects to an origin absent from captured requests', () => { + const har = representativeHar() as { + log: { + entries: Array<{ + response: { + headers: Array<{ name: string; value: string }>; + }; + }>; + }; + }; + const location = har.log.entries[0]?.response.headers.find( + (header) => header.name === 'Location' + ); + if (location === undefined) { + throw new Error('test HAR is missing Location'); + } + location.value = 'https://unseen.external.invalid/portal.php'; + + expectDraftCode( + () => convertHarToReplayDraft(har), + HAR_TO_DRAFT_ERROR_CODES.UnknownExternalOrigin + ); + }); + + it('resolves relative redirects against the captured request origin', () => { + const har = representativeHar() as { + log: { + entries: Array<{ + response: { + headers: Array<{ name: string; value: string }>; + }; + }>; + }; + }; + const location = requiredEntry( + har.log.entries, + 0 + ).response.headers.find((header) => header.name === 'Location'); + if (location === undefined) { + throw new Error('test HAR is missing Location'); + } + location.value = `/next.php?token=${encodeURIComponent(CAPTURED.token)}`; + + const draft = convertHarToReplayDraft(har); + + expect( + draft.fixture.phases[0]?.expectations[0]?.response.headers[ + 'location' + ]?.[0] + ).toMatchObject({ + kind: 'origin-url', + origin: 'origin-1', + path: '/next.php', + }); + }); + + it('preserves JSONP structure while redacting its payload', () => { + const har = representativeHar() as { + log: { + entries: Array<{ + response: { + content: { mimeType: string; text: string }; + }; + }>; + }; + }; + const content = requiredEntry(har.log.entries, 0).response.content; + content.mimeType = 'application/javascript'; + content.text = `portalCallback(${JSON.stringify({ + js: { token: CAPTURED.token }, + })});`; + + const draft = convertHarToReplayDraft(har); + + expect( + draft.fixture.phases[0]?.expectations[0]?.response.body + ).toMatchObject({ + kind: 'jsonp', + callback: 'portalCallback', + }); + expect(draft.formatted).not.toContain(CAPTURED.token); + }); + + it('accepts canonical base64 bodies above the ordinary literal ceiling', () => { + const har = representativeHar() as { + log: { + entries: Array<{ + response: { + content: { encoding?: string; text: string }; + }; + }>; + }; + }; + const content = requiredEntry(har.log.entries, 1).response.content; + const bytes = Buffer.alloc(70 * 1024, 0x5a); + content.encoding = 'base64'; + content.text = bytes.toString('base64'); + + const draft = convertHarToReplayDraft(har); + + expect(draft.fixture.phases[1]?.expectations[0]?.response.body).toEqual( + { + kind: 'generated', + byteLength: bytes.byteLength, + byte: 0, + } + ); + }); + + it('rejects external URLs hidden in response JSON', () => { + const har = representativeHar() as { + log: { + entries: Array<{ + response: { + content: { text: string }; + }; + }>; + }; + }; + requiredEntry(har.log.entries, 0).response.content.text = + JSON.stringify({ + js: { + stream_url: 'https://unseen.external.invalid/private', + }, + }); + + expectDraftCode( + () => convertHarToReplayDraft(har), + HAR_TO_DRAFT_ERROR_CODES.UnknownExternalOrigin + ); + }); + + it('rejects malformed HAR structure and unsupported methods', () => { + expectDraftCode( + () => convertHarToReplayDraft({ log: { entries: [] } }), + HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure + ); + + const har = representativeHar() as { + log: { entries: Array<{ request: { method: string } }> }; + }; + requiredEntry(har.log.entries, 0).request.method = 'CONNECT'; + expectDraftCode( + () => convertHarToReplayDraft(har), + HAR_TO_DRAFT_ERROR_CODES.UnsupportedMethod + ); + }); + + it('rejects captures above the replay phase ceiling', () => { + const entry = (representativeHar() as { log: { entries: unknown[] } }) + .log.entries[0]; + const entries = Array.from({ length: 129 }, () => entry); + + expectDraftCode( + () => convertHarToReplayDraft({ log: { entries } }), + HAR_TO_DRAFT_ERROR_CODES.TooManyEntries + ); + }); + + it('propagates strict base64 failures without exposing content', () => { + const har = representativeHar() as { + log: { + entries: Array<{ + response: { + content: { encoding?: string; text: string }; + }; + }>; + }; + }; + const content = requiredEntry(har.log.entries, 1).response.content; + content.encoding = 'base64'; + content.text = 'private secret, not base64'; + + expect(() => convertHarToReplayDraft(har)).toThrow( + new HarReaderError(HAR_READER_ERROR_CODES.InvalidBase64) + ); + }); + + it('maps final schema or secret-scan failure to one sanitized code', () => { + const har = representativeHar() as { + log: { + entries: Array<{ + response: { + content: { text: string }; + }; + }>; + }; + }; + requiredEntry(har.log.entries, 0).response.content.text = + JSON.stringify({ + js: { + harmless_label: '${x}', + }, + }); + + expectDraftCode( + () => convertHarToReplayDraft(har), + HAR_TO_DRAFT_ERROR_CODES.DraftValidationFailed + ); + }); +}); + +function requiredEntry(entries: readonly T[], index: number): T { + const entry = entries[index]; + if (entry === undefined) { + throw new Error('test HAR entry is missing'); + } + return entry; +} diff --git a/tools/stalker-fixtures/src/lib/har-to-draft.ts b/tools/stalker-fixtures/src/lib/har-to-draft.ts new file mode 100644 index 000000000..777200937 --- /dev/null +++ b/tools/stalker-fixtures/src/lib/har-to-draft.ts @@ -0,0 +1,1521 @@ +/* eslint-disable max-lines -- Keep the security-sensitive HAR reduction and redaction boundary auditable in one module. */ +import { TextDecoder } from 'node:util'; +import { + REPLAY_HTTP_METHODS, + REPLAY_MAX_ORIGINS, + REPLAY_MAX_PHASES, + type ReplayFieldMatchers, + type ReplayFixtureV1, + type ReplayGenerateNode, + type ReplayHttpMethod, + type ReplayOriginUrlNode, + type ReplayPartsNode, + type ReplayRefNode, + type ReplayRequestBodyMatcher, + type ReplayRequestMatcher, + type ReplayResponseBody, + type ReplayResponseDefinition, + type ReplayResponseHeaderValue, + type ReplaySymbolValueKind, + type ReplayTemplateString, + type ReplayTemplateValue, +} from '@iptvnator/portal/stalker/replay-fixtures'; +import { + HAR_READER_ERROR_CODES, + HAR_READER_LIMITS, + HarReaderError, + decodeHarBase64, +} from './har-reader'; +import { + FixtureValidationError, + type ValidatedReplayFixture, + validateReplayFixtureText, +} from './fixture-validator'; + +export const HAR_TO_DRAFT_ERROR_CODES = { + InvalidHarStructure: 'invalid-har-structure', + TooManyEntries: 'too-many-entries', + TooManyOrigins: 'too-many-origins', + UnsupportedMethod: 'unsupported-method', + UnknownExternalOrigin: 'unknown-external-origin', + InvalidContentBody: 'invalid-content-body', + AggregateBodyTooLarge: 'aggregate-body-too-large', + DraftValidationFailed: 'draft-validation-failed', + UnsupportedCommand: 'unsupported-command', + InternalError: 'internal-error', +} as const; + +export type HarToDraftErrorCode = + (typeof HAR_TO_DRAFT_ERROR_CODES)[keyof typeof HAR_TO_DRAFT_ERROR_CODES]; + +export class HarToDraftError extends Error { + constructor(readonly code: HarToDraftErrorCode) { + super(`Stalker HAR conversion failed: ${code}.`); + this.name = 'HarToDraftError'; + } +} + +interface ParsedEntry { + request: Record; + response: Record; +} + +interface ParsedRequestTarget { + method: ReplayHttpMethod; + origin: string; + path: string; + url: URL; +} + +interface ConversionBudget { + decodedBodyBytes: number; +} + +const MAX_HAR_ENTRIES = REPLAY_MAX_PHASES; +const MAX_HAR_FIELDS = 256; +const MAX_CAPTURED_SYMBOLS = 512; +const MAX_CAPTURED_NAME_BYTES = 256; +const MIN_EMBEDDED_SYMBOL_LENGTH = 4; +const MAX_SAFE_LITERAL_BYTES = 64 * 1024; +const SAFE_HEADER_NAME = /^[a-z0-9!#$%&'*+.^_`|~-]+$/; +const SAFE_COOKIE_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; +const HTTP_SCHEME = /^https?:$/; +const ABSOLUTE_URL = /\b[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s<>"'\\]+/g; +const MAC_VALUE = /\b(?:[0-9A-F]{2}[:-]){5}[0-9A-F]{2}\b/i; +const JWT_VALUE = + /\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{8,}\b/; +const AUTH_VALUE = /\b(?:basic|bearer)\s+[A-Za-z0-9+/_=.-]{8,}/i; +const HIGH_ENTROPY_VALUE = /[A-Za-z0-9+/_=-]{32,}/; +const SENSITIVE_ASSIGNMENT = + /(?:^|[?&;\s"'{}:,])(?:authorization|cookie|set[-_]?cookie|credential|login|mac|password|passwd|prehash|serial|signature\d*|sn|token|username|account_?id|device_?id\d*)["']?\s*(?:=|:)\s*[^\s&;,}]+/i; +const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/i; +const MAC_KEY = /^mac$/i; +const CREDENTIAL_KEY = + /^(?:credential|login|password|passwd|prehash|username)$/i; +const TOKEN_KEY = /^(?:authorization|token)$/i; +const RANDOM_KEY = + /^(?:serial|sn|signature\d*|account_?id|device_?id\d*|artwork|artwork_url|image|image_url|logo|logo_url|poster|poster_url|provider|provider_url|stream|stream_url|thumbnail|thumbnail_url)$/i; +const TEXTUAL_MIME = + /^(?:application\/(?:javascript|x-javascript|xml)|text\/)/i; +const JSONP_MIME = /(?:java|ecma)script|jsonp/i; +const JSONP_BODY = + /^\s*([A-Za-z_$][A-Za-z0-9_$]{0,63})\s*\(([\s\S]*)\)\s*;?\s*$/; +const JSON_MIME = /(?:^|[/+])json(?:$|;)/i; +const FORM_MIME = /^application\/x-www-form-urlencoded(?:$|;)/i; +const RELEVANT_REQUEST_HEADERS = new Set([ + 'accept', + 'accept-language', + 'authorization', + 'content-type', + 'sn', + 'user-agent', + 'x-user-agent', +]); + +export function convertHarToReplayDraft( + input: unknown +): ValidatedReplayFixture { + const entries = parseEntries(input); + const origins = new OriginRegistry(); + const initialTargets = entries.map((entry) => + parseRequestTarget(entry.request, origins) + ); + const symbols = new SymbolRegistry(); + buildPhases(entries, initialTargets, origins, symbols, { + decodedBodyBytes: 0, + }); + const targets = entries.map((entry) => + parseRequestTarget(entry.request, origins, symbols) + ); + const phases = buildPhases(entries, targets, origins, symbols, { + decodedBodyBytes: 0, + }); + + const firstTarget = targets[0]; + const finalTarget = targets.at(-1); + if (firstTarget === undefined || finalTarget === undefined) { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } + + const fixture: ReplayFixtureV1 = { + schemaVersion: 1, + scenarioId: 'har-replay-draft', + description: 'Sanitized HAR replay draft.', + origins: origins.definitions(), + entry: { + origin: origins.nameForKnown(firstTarget.origin), + path: firstTarget.path, + }, + expectedEndpoint: { + origin: origins.nameForKnown(finalTarget.origin), + path: finalTarget.path, + }, + initialState: stateName(0), + terminalState: 'complete', + failOnUnexpectedRequest: true, + symbols: symbols.declarations(), + phases, + }; + + try { + return validateReplayFixtureText(JSON.stringify(fixture)); + } catch (error) { + if (error instanceof FixtureValidationError) { + reject(HAR_TO_DRAFT_ERROR_CODES.DraftValidationFailed); + } + throw error; + } +} + +function buildPhases( + entries: readonly ParsedEntry[], + targets: readonly ParsedRequestTarget[], + origins: OriginRegistry, + symbols: SymbolRegistry, + budget: ConversionBudget +): ReplayFixtureV1['phases'] { + return entries.map((entry, index) => { + const target = targets[index]; + if (target === undefined) { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } + const state = stateName(index); + const nextState = + index === entries.length - 1 ? 'complete' : stateName(index + 1); + return { + name: `phase-${sequence(index)}`, + state, + nextState, + mode: 'ordered' as const, + expectations: [ + { + id: `request-${sequence(index)}`, + operation: `captured-request-${sequence(index)}`, + origin: origins.nameForKnown(target.origin), + method: target.method, + path: target.path, + request: convertRequest( + entry.request, + target.url, + origins, + symbols, + budget + ), + response: convertResponse( + entry.response, + target.url, + origins, + symbols, + budget + ), + cardinality: { min: 1, max: 1 }, + }, + ], + }; + }); +} + +class OriginRegistry { + private readonly names = new Map(); + + register(origin: string): string { + const existing = this.names.get(origin); + if (existing !== undefined) { + return existing; + } + if (this.names.size >= REPLAY_MAX_ORIGINS) { + reject(HAR_TO_DRAFT_ERROR_CODES.TooManyOrigins); + } + const name = `origin-${this.names.size + 1}`; + this.names.set(origin, name); + return name; + } + + nameForKnown(origin: string): string { + const name = this.names.get(origin); + if (name === undefined) { + reject(HAR_TO_DRAFT_ERROR_CODES.UnknownExternalOrigin); + } + return name; + } + + definitions(): ReplayFixtureV1['origins'] { + return Object.fromEntries( + [...this.names.values()].map((name) => [name, {}]) + ); + } +} + +class SymbolRegistry { + private readonly byRawValue = new Map< + string, + { declaration: ReplayGenerateNode; rank: number } + >(); + private readonly ordered: ReplayGenerateNode[] = []; + private readonly counts = new Map(); + + reference( + rawValue: string, + valueKind: ReplaySymbolValueKind + ): ReplayRefNode { + const rank = symbolKindRank(valueKind); + const existing = this.byRawValue.get(rawValue); + if (existing !== undefined) { + if (rank > existing.rank) { + existing.declaration.valueKind = valueKind; + existing.rank = rank; + } + return { + kind: 'ref', + symbol: existing.declaration.symbol, + }; + } + + const count = (this.counts.get(valueKind) ?? 0) + 1; + this.counts.set(valueKind, count); + const declaration: ReplayGenerateNode = { + kind: 'generate', + symbol: `${valueKind}-${count}`, + valueKind, + }; + if (this.ordered.length >= MAX_CAPTURED_SYMBOLS) { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } + this.byRawValue.set(rawValue, { declaration, rank }); + this.ordered.push(declaration); + return { kind: 'ref', symbol: declaration.symbol }; + } + + declarations(): ReplayGenerateNode[] { + return this.ordered.map((declaration) => ({ ...declaration })); + } + + knownReference(rawValue: string): ReplayRefNode | undefined { + const existing = this.byRawValue.get(rawValue); + return existing === undefined + ? undefined + : { + kind: 'ref', + symbol: existing.declaration.symbol, + }; + } + + knownTemplate(rawValue: string): ReplayTemplateString | undefined { + const exact = this.knownReference(rawValue); + if (exact !== undefined) { + return exact; + } + + const parts: ReplayPartsNode['parts'] = []; + let cursor = 0; + let matched = false; + while (cursor < rawValue.length) { + let selected: + | { + declaration: ReplayGenerateNode; + index: number; + raw: string; + } + | undefined; + for (const [raw, { declaration }] of this.byRawValue) { + if (raw.length < MIN_EMBEDDED_SYMBOL_LENGTH) { + continue; + } + const index = rawValue.indexOf(raw, cursor); + if ( + index >= 0 && + (selected === undefined || + index < selected.index || + (index === selected.index && + raw.length > selected.raw.length)) + ) { + selected = { declaration, index, raw }; + } + } + if (selected === undefined) { + break; + } + if (selected.index > cursor) { + parts.push({ + kind: 'literal', + value: rawValue.slice(cursor, selected.index), + }); + } + parts.push({ + kind: 'ref', + symbol: selected.declaration.symbol, + }); + matched = true; + cursor = selected.index + selected.raw.length; + if (parts.length >= 63) { + return this.reference(rawValue, 'random'); + } + } + if (!matched) { + return undefined; + } + if (cursor < rawValue.length) { + parts.push({ + kind: 'literal', + value: rawValue.slice(cursor), + }); + } + return { kind: 'parts', parts }; + } +} + +function parseEntries(input: unknown): ParsedEntry[] { + const root = requireRecord(input); + const log = requireRecord(root['log']); + const rawEntries = log['entries']; + if (!Array.isArray(rawEntries) || rawEntries.length === 0) { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } + if (rawEntries.length > MAX_HAR_ENTRIES) { + reject(HAR_TO_DRAFT_ERROR_CODES.TooManyEntries); + } + return rawEntries.map((rawEntry) => { + const entry = requireRecord(rawEntry); + return { + request: requireRecord(entry['request']), + response: requireRecord(entry['response']), + }; + }); +} + +function parseRequestTarget( + request: Record, + origins: OriginRegistry, + symbols?: SymbolRegistry +): ParsedRequestTarget { + const rawMethod = requireString(request['method']); + if ( + !REPLAY_HTTP_METHODS.includes( + rawMethod as (typeof REPLAY_HTTP_METHODS)[number] + ) + ) { + reject(HAR_TO_DRAFT_ERROR_CODES.UnsupportedMethod); + } + const url = parseHttpUrl(requireString(request['url'])); + origins.register(url.origin); + return { + method: rawMethod as ReplayHttpMethod, + origin: url.origin, + path: sanitizePath(url.pathname, symbols), + url, + }; +} + +function convertRequest( + request: Record, + url: URL, + origins: OriginRegistry, + symbols: SymbolRegistry, + budget: ConversionBudget +): ReplayRequestMatcher { + const capturedHeaders = parseNameValueList(request['headers'], 'header'); + return { + query: convertQuery(url.searchParams, origins, symbols), + headers: convertRequestHeaders(capturedHeaders, origins, symbols), + cookies: { + exact: convertRequestCookies( + request['cookies'], + capturedHeaders, + origins, + symbols + ), + present: [], + absent: [], + attributes: {}, + }, + body: convertRequestBody( + request['postData'], + capturedHeaders, + origins, + symbols, + budget + ), + }; +} + +function convertQuery( + searchParams: URLSearchParams, + origins: OriginRegistry, + symbols: SymbolRegistry +): ReplayRequestMatcher['query'] { + const grouped = new Map(); + for (const [rawName, value] of searchParams) { + const name = normalizeCapturedName(rawName, symbols); + const values = grouped.get(name) ?? []; + values.push(value); + if (values.length > MAX_HAR_FIELDS) { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } + grouped.set(name, values); + } + if (grouped.size > MAX_HAR_FIELDS) { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } + + const exact: ReplayRequestMatcher['query']['exact'] = {}; + for (const [name, values] of grouped) { + const sanitized = values.map((value) => + sanitizeTemplateString(value, name, origins, symbols) + ); + exact[name] = collapseValues(sanitized); + } + return { exact, present: [], absent: [] }; +} + +function convertRequestHeaders( + headers: Map, + origins: OriginRegistry, + symbols: SymbolRegistry +): ReplayRequestMatcher['headers'] { + const exact: ReplayRequestMatcher['headers']['exact'] = {}; + for (const [name, values] of headers) { + if (!RELEVANT_REQUEST_HEADERS.has(name)) { + continue; + } + const sanitized = values.map((value) => { + if (name === 'authorization') { + return sanitizeAuthorization(value, symbols); + } + if (name === 'content-type') { + return sanitizeContentType(value); + } + return sanitizeTemplateString(value, name, origins, symbols); + }); + exact[name] = collapseValues(sanitized); + } + return { exact, present: [], absent: [] }; +} + +function convertRequestCookies( + rawCookies: unknown, + headers: Map, + origins: OriginRegistry, + symbols: SymbolRegistry +): ReplayRequestMatcher['cookies']['exact'] { + const cookies = new Map(); + const cookieHeaders = headers.get('cookie') ?? []; + for (const header of cookieHeaders) { + for (const [name, value] of parseCookieHeader(header)) { + setConsistentValue(cookies, name, value); + } + } + if (rawCookies !== undefined) { + for (const cookie of parseNameValueArray(rawCookies, 'cookie')) { + setConsistentValue(cookies, cookie.name, cookie.value); + } + } + const exact: ReplayRequestMatcher['cookies']['exact'] = {}; + for (const [name, value] of cookies) { + assertNoKnownSymbolLiteral(name, symbols); + exact[name] = sanitizeTemplateString( + value, + name, + origins, + symbols, + cookieValueKind(name, value) + ); + } + return exact; +} + +function convertRequestBody( + rawPostData: unknown, + headers: Map, + origins: OriginRegistry, + symbols: SymbolRegistry, + budget: ConversionBudget +): ReplayRequestBodyMatcher { + if (rawPostData === undefined) { + return { kind: 'absent' }; + } + const postData = requireRecord(rawPostData); + const mimeType = + optionalString(postData['mimeType']) ?? + headers.get('content-type')?.[0] ?? + ''; + + if (Array.isArray(postData['params'])) { + return { + kind: 'form', + ...convertFormPairs( + parseNameValueArray(postData['params'], 'form'), + origins, + symbols + ), + }; + } + + const text = optionalBodyString(postData['text']); + if (text === undefined || text.length === 0) { + return { kind: 'absent' }; + } + const bodyBytes = decodeCapturedBody( + text, + optionalString(postData['encoding']), + budget + ); + const bodyText = decodeBodyUtf8(bodyBytes); + + if (JSON_MIME.test(mimeType)) { + let parsed: unknown; + try { + parsed = JSON.parse(bodyText) as unknown; + } catch { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidContentBody); + } + if (!isRecord(parsed)) { + return { + kind: 'text', + exact: symbols.reference(bodyText, 'random'), + }; + } + const exact: Record = {}; + for (const [name, value] of boundedEntries(parsed)) { + exact[normalizeCapturedName(name, symbols)] = sanitizeJsonValue( + value, + name, + origins, + symbols + ); + } + return { kind: 'json', exact, present: [], absent: [] }; + } + + if (FORM_MIME.test(mimeType)) { + const pairs = [...new URLSearchParams(bodyText)].map( + ([name, value]) => ({ + name: normalizeCapturedName(name, symbols), + value, + }) + ); + return { + kind: 'form', + ...convertFormPairs(pairs, origins, symbols), + }; + } + + return { + kind: 'text', + exact: sanitizeOpaqueText(bodyText, origins, symbols), + }; +} + +function convertFormPairs( + pairs: readonly { name: string; value: string }[], + origins: OriginRegistry, + symbols: SymbolRegistry +): ReplayFieldMatchers { + if (pairs.length > MAX_HAR_FIELDS) { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } + const exact: Record = {}; + for (const pair of pairs) { + const name = normalizeCapturedName(pair.name, symbols); + if (Object.prototype.hasOwnProperty.call(exact, name)) { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } + exact[name] = sanitizeTemplateString( + pair.value, + name, + origins, + symbols + ); + } + return { exact, present: [], absent: [] }; +} + +function convertResponse( + response: Record, + requestUrl: URL, + origins: OriginRegistry, + symbols: SymbolRegistry, + budget: ConversionBudget +): ReplayResponseDefinition { + const status = response['status']; + if ( + typeof status !== 'number' || + !Number.isInteger(status) || + status < 100 || + status > 599 + ) { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } + const capturedHeaders = parseNameValueList(response['headers'], 'header'); + return { + status, + headers: convertResponseHeaders( + capturedHeaders, + response['cookies'], + requestUrl, + origins, + symbols + ), + body: convertResponseBody( + response['content'], + capturedHeaders, + origins, + symbols, + budget + ), + }; +} + +function convertResponseHeaders( + captured: Map, + rawCookies: unknown, + requestUrl: URL, + origins: OriginRegistry, + symbols: SymbolRegistry +): ReplayResponseDefinition['headers'] { + const output: Record = {}; + const contentTypes = captured.get('content-type'); + if (contentTypes !== undefined) { + output['content-type'] = contentTypes.map(sanitizeContentType); + } + const locations = captured.get('location'); + if (locations !== undefined) { + output['location'] = locations.map((value) => + convertLocation(value, requestUrl, origins, symbols) + ); + } + + const setCookies = captured + .get('set-cookie') + ?.map((value) => convertSetCookie(value, origins, symbols)); + if (setCookies !== undefined && setCookies.length > 0) { + output['set-cookie'] = setCookies; + } else if (rawCookies !== undefined) { + const cookieValues = parseResponseCookies(rawCookies, origins, symbols); + if (cookieValues.length > 0) { + output['set-cookie'] = cookieValues; + } + } + return output; +} + +function convertLocation( + value: string, + requestUrl: URL, + origins: OriginRegistry, + symbols: SymbolRegistry +): ReplayOriginUrlNode { + const url = parseHttpUrl(value, requestUrl); + const query: NonNullable = {}; + const grouped = new Map(); + for (const [rawName, rawValue] of url.searchParams) { + const name = normalizeCapturedName(rawName, symbols); + const values = grouped.get(name) ?? []; + values.push(sanitizeTemplateString(rawValue, name, origins, symbols)); + grouped.set(name, values); + } + for (const [name, values] of grouped) { + query[name] = collapseValues(values); + } + + const node: ReplayOriginUrlNode = { + kind: 'origin-url', + origin: origins.nameForKnown(url.origin), + path: sanitizePath(url.pathname, symbols), + }; + if (url.username.length > 0) { + node.userInfo = { + username: symbols.reference( + decodeUrlComponent(url.username), + 'credential' + ), + }; + if (url.password.length > 0) { + node.userInfo.password = symbols.reference( + decodeUrlComponent(url.password), + 'credential' + ); + } + } + if (Object.keys(query).length > 0) { + node.query = query; + } + return node; +} + +function convertSetCookie( + value: string, + origins: OriginRegistry, + symbols: SymbolRegistry +): ReplayPartsNode { + const segments = value.split(';'); + const pair = segments.shift(); + if (pair === undefined) { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } + const separator = pair.indexOf('='); + if (separator <= 0) { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } + const name = normalizeCookieName(pair.slice(0, separator).trim()); + assertNoKnownSymbolLiteral(name, symbols); + const rawValue = pair.slice(separator + 1).trim(); + const suffix = sanitizeSetCookieAttributes(segments, symbols); + const reference = sanitizeTemplateString( + rawValue, + name, + origins, + symbols, + cookieValueKind(name, rawValue) + ); + if (!isRefNode(reference)) { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } + const parts: ReplayPartsNode['parts'] = [ + { kind: 'literal', value: `${name}=` }, + reference, + ]; + if (suffix.length > 0) { + parts.push({ kind: 'literal', value: suffix }); + } + return { kind: 'parts', parts }; +} + +function parseResponseCookies( + rawCookies: unknown, + origins: OriginRegistry, + symbols: SymbolRegistry +): ReplayPartsNode[] { + if (!Array.isArray(rawCookies)) { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } + if (rawCookies.length > MAX_HAR_FIELDS) { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } + return rawCookies.map((rawCookie) => { + const cookie = requireRecord(rawCookie); + const name = normalizeCookieName(requireString(cookie['name'])); + assertNoKnownSymbolLiteral(name, symbols); + const value = requireString(cookie['value']); + const suffixParts: string[] = []; + const path = optionalString(cookie['path']); + if (path !== undefined && isSafeCookiePath(path)) { + suffixParts.push(`Path=${sanitizePath(path, symbols)}`); + } + if (cookie['httpOnly'] === true) { + suffixParts.push('HttpOnly'); + } + if (cookie['secure'] === true) { + suffixParts.push('Secure'); + } + const sameSite = optionalString(cookie['sameSite'])?.toLowerCase(); + if ( + sameSite !== undefined && + ['strict', 'lax', 'none'].includes(sameSite) + ) { + suffixParts.push( + `SameSite=${sameSite.charAt(0).toUpperCase()}${sameSite.slice(1)}` + ); + } + return convertSetCookie( + `${name}=${value}${ + suffixParts.length > 0 ? `; ${suffixParts.join('; ')}` : '' + }`, + origins, + symbols + ); + }); +} + +function sanitizeSetCookieAttributes( + segments: readonly string[], + symbols: SymbolRegistry +): string { + const safe: string[] = []; + for (const rawSegment of segments) { + const segment = rawSegment.trim(); + if (/^secure$/i.test(segment)) { + safe.push('Secure'); + } else if (/^httponly$/i.test(segment)) { + safe.push('HttpOnly'); + } else { + const separator = segment.indexOf('='); + if (separator <= 0) { + continue; + } + const name = segment.slice(0, separator).trim().toLowerCase(); + const value = segment.slice(separator + 1).trim(); + if (name === 'path' && isSafeCookiePath(value)) { + safe.push(`Path=${sanitizePath(value, symbols)}`); + } else if ( + name === 'samesite' && + /^(?:strict|lax|none)$/i.test(value) + ) { + safe.push( + `SameSite=${value.charAt(0).toUpperCase()}${value + .slice(1) + .toLowerCase()}` + ); + } + } + } + return safe.length === 0 ? '' : `; ${safe.join('; ')}`; +} + +function convertResponseBody( + rawContent: unknown, + headers: Map, + origins: OriginRegistry, + symbols: SymbolRegistry, + budget: ConversionBudget +): ReplayResponseBody { + if (rawContent === undefined) { + return { kind: 'empty' }; + } + const content = requireRecord(rawContent); + const text = optionalBodyString(content['text']); + if (text === undefined || text.length === 0) { + return { kind: 'empty' }; + } + const bytes = decodeCapturedBody( + text, + optionalString(content['encoding']), + budget + ); + const mimeType = + optionalString(content['mimeType']) ?? + headers.get('content-type')?.[0] ?? + ''; + + if (JSON_MIME.test(mimeType)) { + const decoded = decodeBodyUtf8(bytes); + let value: unknown; + try { + value = JSON.parse(decoded) as unknown; + } catch { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidContentBody); + } + return { + kind: 'json', + value: sanitizeJsonValue(value, '', origins, symbols), + }; + } + + if (TEXTUAL_MIME.test(mimeType)) { + const decoded = decodeBodyUtf8(bytes); + const jsonp = JSONP_MIME.test(mimeType) + ? parseJsonpBody(decoded, origins, symbols) + : undefined; + if (jsonp !== undefined) { + return jsonp; + } + assertKnownOriginsInText(decoded, origins); + return { + kind: 'text', + value: 'sanitized-text-body', + }; + } + + return { + kind: 'generated', + byteLength: bytes.byteLength, + byte: 0, + }; +} + +function sanitizeJsonValue( + value: unknown, + parentKey: string, + origins: OriginRegistry, + symbols: SymbolRegistry, + depth = 0 +): ReplayTemplateValue { + if (depth > HAR_READER_LIMITS.MaxJsonDepth) { + throw new HarReaderError(HAR_READER_ERROR_CODES.HarTooDeep); + } + if (value === null || typeof value === 'boolean') { + return value; + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidContentBody); + } + return isTimestampNumber(value) ? 0 : value; + } + if (typeof value === 'string') { + if (ISO_TIMESTAMP.test(value)) { + return '1970-01-01T00:00:00.000Z'; + } + return sanitizeTemplateString(value, parentKey, origins, symbols); + } + if (Array.isArray(value)) { + if (value.length > HAR_READER_LIMITS.MaxCollectionItems) { + throw new HarReaderError( + HAR_READER_ERROR_CODES.HarCollectionTooLarge + ); + } + return value.map((item) => + sanitizeJsonValue(item, parentKey, origins, symbols, depth + 1) + ); + } + if (!isRecord(value)) { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidContentBody); + } + const output: Record = {}; + for (const [key, child] of boundedEntries(value)) { + const safeKey = normalizeCapturedName(key, symbols); + output[safeKey] = sanitizeJsonValue( + child, + safeKey, + origins, + symbols, + depth + 1 + ); + } + return output; +} + +function sanitizeTemplateString( + rawValue: string, + key: string, + origins: OriginRegistry, + symbols: SymbolRegistry, + forcedKind?: ReplaySymbolValueKind +): ReplayTemplateString { + assertBoundedLiteral(rawValue); + const variants = decodedVariants(rawValue); + let containsUrl = false; + for (const variant of variants) { + containsUrl = assertKnownOriginsInText(variant, origins) || containsUrl; + } + const valueKind = + forcedKind ?? + symbolKindForKey(key) ?? + symbolKindForVariants(variants) ?? + (containsUrl ? 'random' : undefined); + return valueKind === undefined + ? (symbols.knownTemplate(rawValue) ?? rawValue) + : symbols.reference(rawValue, valueKind); +} + +function sanitizeAuthorization( + rawValue: string, + symbols: SymbolRegistry +): ReplayTemplateString { + assertBoundedLiteral(rawValue); + const match = /^\s*(Basic|Bearer)\s+(.+?)\s*$/i.exec(rawValue); + if (match === null) { + return symbols.reference(rawValue, 'token'); + } + const scheme = match[1]; + const credential = match[2]; + if (scheme === undefined || credential === undefined) { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } + return { + kind: 'parts', + parts: [ + { + kind: 'literal', + value: `${scheme.charAt(0).toUpperCase()}${scheme + .slice(1) + .toLowerCase()} `, + }, + symbols.reference(credential, 'token'), + ], + }; +} + +function sanitizeOpaqueText( + rawValue: string, + origins: OriginRegistry, + symbols: SymbolRegistry +): ReplayTemplateString { + assertBoundedLiteral(rawValue); + assertKnownOriginsInText(rawValue, origins); + return symbols.reference(rawValue, 'random'); +} + +function decodeCapturedBody( + text: string, + encoding: string | undefined, + budget: ConversionBudget +): Buffer { + let bytes: Buffer; + if (encoding === undefined) { + bytes = Buffer.from(text); + if (bytes.byteLength > HAR_READER_LIMITS.MaxDecodedBodyBytes) { + throw new HarReaderError( + HAR_READER_ERROR_CODES.DecodedBodyTooLarge + ); + } + } else if (encoding === 'base64') { + bytes = decodeHarBase64(text); + } else { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidContentBody); + } + budget.decodedBodyBytes += bytes.byteLength; + if ( + budget.decodedBodyBytes > HAR_READER_LIMITS.MaxAggregateDecodedBodyBytes + ) { + reject(HAR_TO_DRAFT_ERROR_CODES.AggregateBodyTooLarge); + } + return bytes; +} + +function decodeBodyUtf8(bytes: Buffer): string { + try { + return new TextDecoder('utf-8', { + fatal: true, + ignoreBOM: true, + }).decode(bytes); + } catch { + throw new HarReaderError(HAR_READER_ERROR_CODES.InvalidUtf8); + } +} + +function parseNameValueList( + input: unknown, + kind: 'header' +): Map { + if (input === undefined) { + return new Map(); + } + const pairs = parseNameValueArray(input, kind); + const output = new Map(); + for (const pair of pairs) { + const name = pair.name.toLowerCase(); + if (!SAFE_HEADER_NAME.test(name)) { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } + const values = output.get(name) ?? []; + values.push(pair.value); + if (values.length > MAX_HAR_FIELDS) { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } + output.set(name, values); + } + return output; +} + +function parseNameValueArray( + input: unknown, + kind: 'cookie' | 'form' | 'header' +): Array<{ name: string; value: string }> { + if (!Array.isArray(input) || input.length > MAX_HAR_FIELDS) { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } + return input.map((rawPair) => { + const pair = requireRecord(rawPair); + const rawName = requireString(pair['name']); + const name = + kind === 'cookie' + ? normalizeCookieName(rawName) + : normalizeCapturedName(rawName); + return { + name, + value: requireString(pair['value']), + }; + }); +} + +function parseCookieHeader(value: string): Array<[string, string]> { + assertBoundedLiteral(value); + const output: Array<[string, string]> = []; + for (const segment of value.split(';')) { + const separator = segment.indexOf('='); + if (separator <= 0) { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } + output.push([ + normalizeCookieName(segment.slice(0, separator).trim()), + segment.slice(separator + 1).trim(), + ]); + if (output.length > MAX_HAR_FIELDS) { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } + } + return output; +} + +function setConsistentValue( + values: Map, + name: string, + value: string +): void { + const existing = values.get(name); + if (existing !== undefined && existing !== value) { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } + values.set(name, value); +} + +function parseHttpUrl(value: string, base?: URL): URL { + assertBoundedLiteral(value); + let url: URL; + try { + url = base === undefined ? new URL(value) : new URL(value, base); + } catch { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } + if (!HTTP_SCHEME.test(url.protocol)) { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } + return url; +} + +function parseJsonpBody( + value: string, + origins: OriginRegistry, + symbols: SymbolRegistry +): Extract | undefined { + const match = JSONP_BODY.exec(value); + if (match === null) { + return undefined; + } + const callback = match[1]; + const json = match[2]; + if ( + callback === undefined || + json === undefined || + ['__proto__', 'constructor', 'prototype'].includes(callback) + ) { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidContentBody); + } + assertNoKnownSymbolLiteral(callback, symbols); + let parsed: unknown; + try { + parsed = JSON.parse(json) as unknown; + } catch { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidContentBody); + } + return { + kind: 'jsonp', + callback, + value: sanitizeJsonValue(parsed, '', origins, symbols), + }; +} + +function assertKnownOriginsInText( + value: string, + origins: OriginRegistry +): boolean { + ABSOLUTE_URL.lastIndex = 0; + let found = false; + for (const match of value.matchAll(ABSOLUTE_URL)) { + found = true; + const rawUrl = trimUrlPunctuation(match[0]); + const url = parseHttpUrl(rawUrl); + origins.nameForKnown(url.origin); + } + return found; +} + +function sanitizePath(path: string, symbols?: SymbolRegistry): string { + if ( + path.length === 0 || + path.length > 2048 || + !path.startsWith('/') || + path.includes('\\') || + path.includes('?') || + path.includes('#') + ) { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } + return path + .split('/') + .map((segment, index) => { + if (index === 0 || segment.length === 0) { + return segment; + } + const decoded = decodeUrlComponent(segment); + return symbolKindForVariants(decodedVariants(decoded)) === + undefined && symbols?.knownTemplate(decoded) === undefined + ? segment + : 'redacted'; + }) + .join('/'); +} + +function sanitizeContentType(value: string): ReplayTemplateString { + assertBoundedLiteral(value); + const mediaType = value.split(';', 1)[0]?.trim().toLowerCase(); + if ( + mediaType === undefined || + !/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(mediaType) + ) { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } + if (HIGH_ENTROPY_VALUE.test(mediaType)) { + const chunks = mediaType.match(/.{1,24}/g); + if (chunks === null) { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } + return { + kind: 'parts', + parts: chunks.map((part) => ({ + kind: 'literal' as const, + value: part, + })), + }; + } + return mediaType; +} + +function normalizeCookieName(value: string): string { + assertBoundedLiteral(value); + if (!SAFE_COOKIE_NAME.test(value)) { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } + return value; +} + +function normalizeCapturedName( + value: string, + symbols?: SymbolRegistry +): string { + assertBoundedLiteral(value); + if ( + value.length === 0 || + Buffer.byteLength(value) > MAX_CAPTURED_NAME_BYTES || + hasControlOrBackslash(value) || + value.includes('${') || + value.includes('{{') || + value.includes('<%') + ) { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } + assertNoSensitiveNameLiteral(value); + if (symbols !== undefined) { + assertNoKnownSymbolLiteral(value, symbols); + } + return value; +} + +function assertNoKnownSymbolLiteral( + value: string, + symbols: SymbolRegistry +): void { + if (symbols.knownTemplate(value) !== undefined) { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } +} + +function assertNoSensitiveNameLiteral(value: string): void { + if ( + MAC_VALUE.test(value) || + JWT_VALUE.test(value) || + HIGH_ENTROPY_VALUE.test(value) || + ABSOLUTE_URL.test(value) + ) { + ABSOLUTE_URL.lastIndex = 0; + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } + ABSOLUTE_URL.lastIndex = 0; +} + +function assertBoundedLiteral(value: string): void { + if (Buffer.byteLength(value) > MAX_SAFE_LITERAL_BYTES) { + throw new HarReaderError(HAR_READER_ERROR_CODES.HarStringTooLarge); + } +} + +function symbolKindForKey(key: string): ReplaySymbolValueKind | undefined { + if (MAC_KEY.test(key)) { + return 'mac'; + } + if (CREDENTIAL_KEY.test(key)) { + return 'credential'; + } + if (TOKEN_KEY.test(key)) { + return 'token'; + } + if (RANDOM_KEY.test(key)) { + return 'random'; + } + return undefined; +} + +function cookieValueKind(name: string, value: string): ReplaySymbolValueKind { + return ( + symbolKindForKey(name) ?? + symbolKindForVariants(decodedVariants(value)) ?? + 'cookie' + ); +} + +function symbolKindForVariants( + variants: readonly string[] +): ReplaySymbolValueKind | undefined { + if (variants.some((variant) => MAC_VALUE.test(variant))) { + return 'mac'; + } + if ( + variants.some( + (variant) => + JWT_VALUE.test(variant) || + AUTH_VALUE.test(variant) || + HIGH_ENTROPY_VALUE.test(variant) + ) + ) { + return 'token'; + } + if (variants.some((variant) => SENSITIVE_ASSIGNMENT.test(variant))) { + return 'random'; + } + return undefined; +} + +function decodedVariants(value: string): readonly string[] { + const variants = new Set([value]); + let candidate = value; + for (let depth = 0; depth < 2; depth += 1) { + if (!/%[0-9A-F]{2}/i.test(candidate)) { + break; + } + try { + const decoded = decodeURIComponent(candidate); + variants.add(decoded); + if (decoded === candidate) { + break; + } + candidate = decoded; + } catch { + break; + } + } + return [...variants]; +} + +function symbolKindRank(kind: ReplaySymbolValueKind): number { + switch (kind) { + case 'mac': + return 6; + case 'credential': + return 5; + case 'token': + return 4; + case 'cookie': + return 3; + case 'correlation': + return 2; + case 'random': + return 1; + } +} + +function isTimestampNumber(value: number): boolean { + return ( + (Number.isInteger(value) && + value >= 946_684_800 && + value <= 4_102_444_800) || + (Number.isInteger(value) && + value >= 946_684_800_000 && + value <= 4_102_444_800_000) + ); +} + +function isSafeCookiePath(value: string): boolean { + return ( + value.length > 0 && + value.length <= 2048 && + value.startsWith('/') && + !value.includes(';') && + !hasControlOrBackslash(value) + ); +} + +function hasControlOrBackslash(value: string): boolean { + return [...value].some((character) => { + const code = character.charCodeAt(0); + return character === '\\' || code <= 31 || code === 127; + }); +} + +function collapseValues(values: readonly T[]): T | T[] { + const first = values[0]; + if (first === undefined) { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } + return values.length === 1 ? first : [...values]; +} + +function isRefNode(value: ReplayTemplateString): value is ReplayRefNode { + return typeof value === 'object' && value.kind === 'ref'; +} + +function decodeUrlComponent(value: string): string { + try { + return decodeURIComponent(value); + } catch { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } +} + +function trimUrlPunctuation(value: string): string { + return value.replace(/[),.;\]}]+$/g, ''); +} + +function boundedEntries( + value: Record +): Array<[string, unknown]> { + const entries = Object.entries(value); + if (entries.length > HAR_READER_LIMITS.MaxCollectionItems) { + throw new HarReaderError(HAR_READER_ERROR_CODES.HarCollectionTooLarge); + } + return entries; +} + +function requireRecord(value: unknown): Record { + if (!isRecord(value)) { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } + return value; +} + +function isRecord(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function requireString(value: unknown): string { + if (typeof value !== 'string') { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } + assertBoundedLiteral(value); + return value; +} + +function optionalString(value: unknown): string | undefined { + if (value === undefined) { + return undefined; + } + return requireString(value); +} + +function optionalBodyString(value: unknown): string | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value !== 'string') { + reject(HAR_TO_DRAFT_ERROR_CODES.InvalidHarStructure); + } + if (Buffer.byteLength(value) > HAR_READER_LIMITS.MaxStringBytes) { + throw new HarReaderError(HAR_READER_ERROR_CODES.HarStringTooLarge); + } + return value; +} + +function stateName(index: number): string { + return `state-${sequence(index)}`; +} + +function sequence(index: number): string { + return String(index + 1).padStart(3, '0'); +} + +function reject(code: HarToDraftErrorCode): never { + throw new HarToDraftError(code); +} diff --git a/tools/stalker-fixtures/src/lib/safe-output.spec.ts b/tools/stalker-fixtures/src/lib/safe-output.spec.ts new file mode 100644 index 000000000..24281f7e3 --- /dev/null +++ b/tools/stalker-fixtures/src/lib/safe-output.spec.ts @@ -0,0 +1,241 @@ +import { + chmod, + lstat, + mkdtemp, + readFile, + readdir, + rename, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + REPLAY_MAX_FIXTURE_BYTES, + type ReplayFixtureV1, +} from '@iptvnator/portal/stalker/replay-fixtures'; +import { validateReplayFixtureText } from './fixture-validator'; +import { + SAFE_OUTPUT_ERROR_CODES, + SafeOutputError, + writeReplayDraftSafely, +} from './safe-output'; + +function canonicalFixture(): string { + const fixture: ReplayFixtureV1 = { + schemaVersion: 1, + scenarioId: 'safe-output-contract', + description: 'Synthetic safe output fixture.', + origins: { portal: {} }, + entry: { origin: 'portal', path: '/c/' }, + expectedEndpoint: { origin: 'portal', path: '/portal.php' }, + initialState: 'start', + terminalState: 'complete', + failOnUnexpectedRequest: true, + symbols: [], + phases: [ + { + name: 'resolve', + state: 'start', + nextState: 'complete', + mode: 'ordered', + expectations: [ + { + id: 'landing', + operation: 'landing', + origin: 'portal', + method: 'GET', + path: '/c/', + request: { + query: { exact: {}, present: [], absent: [] }, + headers: { + exact: {}, + present: [], + absent: [], + }, + cookies: { + exact: {}, + present: [], + absent: [], + attributes: {}, + }, + body: { kind: 'absent' }, + }, + response: { + status: 200, + headers: { + 'content-type': ['application/json'], + }, + body: { kind: 'json', value: { js: true } }, + }, + cardinality: { min: 1, max: 1 }, + }, + ], + }, + ], + }; + return validateReplayFixtureText(JSON.stringify(fixture)).formatted; +} + +async function expectOutputCode( + operation: Promise, + code: string +): Promise { + try { + await operation; + throw new Error('draft output unexpectedly passed'); + } catch (error) { + expect(error).toBeInstanceOf(SafeOutputError); + expect((error as SafeOutputError).code).toBe(code); + expect((error as Error).message).toBe( + `Stalker HAR conversion failed: ${code}.` + ); + } +} + +describe('safe replay draft output', () => { + let outputRoot: string; + + beforeEach(async () => { + outputRoot = await mkdtemp(join(tmpdir(), 'stalker-draft-output-')); + }); + + afterEach(async () => { + await chmod(outputRoot, 0o700).catch(() => undefined); + await rm(outputRoot, { force: true, recursive: true }); + }); + + it('publishes a canonical fixture atomically with private permissions', async () => { + const outputPath = join(outputRoot, 'draft.json'); + const formatted = canonicalFixture(); + + await writeReplayDraftSafely(outputPath, formatted); + + await expect(readFile(outputPath, 'utf8')).resolves.toBe(formatted); + const stat = await lstat(outputPath); + expect(stat.isFile()).toBe(true); + expect(stat.isSymbolicLink()).toBe(false); + expect(stat.nlink).toBe(1); + expect(stat.mode & 0o777).toBe(0o600); + expect(await readdir(outputRoot)).toEqual(['draft.json']); + }); + + it('refuses to overwrite an existing destination', async () => { + const outputPath = join(outputRoot, 'draft.json'); + await writeFile(outputPath, 'owner-data'); + + await expectOutputCode( + writeReplayDraftSafely(outputPath, canonicalFixture()), + SAFE_OUTPUT_ERROR_CODES.OutputExists + ); + await expect(readFile(outputPath, 'utf8')).resolves.toBe('owner-data'); + }); + + it('refuses a destination symlink without touching its target', async () => { + const targetPath = join(outputRoot, 'owner-data.txt'); + const outputPath = join(outputRoot, 'draft.json'); + await writeFile(targetPath, 'owner-data'); + await symlink(targetPath, outputPath); + + await expectOutputCode( + writeReplayDraftSafely(outputPath, canonicalFixture()), + SAFE_OUTPUT_ERROR_CODES.OutputExists + ); + await expect(readFile(targetPath, 'utf8')).resolves.toBe('owner-data'); + }); + + it('rejects a symlink destination parent', async () => { + const actualParent = join(outputRoot, 'actual'); + await import('node:fs/promises').then(({ mkdir }) => + mkdir(actualParent) + ); + const linkedParent = join(outputRoot, 'linked'); + await symlink(actualParent, linkedParent); + + await expectOutputCode( + writeReplayDraftSafely( + join(linkedParent, 'draft.json'), + canonicalFixture() + ), + SAFE_OUTPUT_ERROR_CODES.UnsafeOutputPath + ); + }); + + it('does not overwrite a symlink created during publication', async () => { + const targetPath = join(outputRoot, 'owner-data.txt'); + const outputPath = join(outputRoot, 'draft.json'); + await writeFile(targetPath, 'owner-data'); + + await expectOutputCode( + writeReplayDraftSafely(outputPath, canonicalFixture(), { + beforePublish: async () => { + await symlink(targetPath, outputPath); + }, + }), + SAFE_OUTPUT_ERROR_CODES.OutputExists + ); + await expect(readFile(targetPath, 'utf8')).resolves.toBe('owner-data'); + expect((await readdir(outputRoot)).sort()).toEqual([ + 'draft.json', + 'owner-data.txt', + ]); + }); + + it('rejects a temporary file swapped before publication', async () => { + const ownerPath = join(outputRoot, 'owner-data.txt'); + const outputPath = join(outputRoot, 'draft.json'); + await writeFile(ownerPath, 'owner-data'); + + await expectOutputCode( + writeReplayDraftSafely(outputPath, canonicalFixture(), { + beforePublish: async (temporaryPath) => { + await rename( + temporaryPath, + join(outputRoot, 'displaced.tmp') + ); + await symlink(ownerPath, temporaryPath); + }, + }), + SAFE_OUTPUT_ERROR_CODES.OutputPublishFailed + ); + await expect(readFile(ownerPath, 'utf8')).resolves.toBe('owner-data'); + await expect(lstat(outputPath)).rejects.toThrow(); + }); + + it('rejects oversized, invalid, and noncanonical draft text before opening output', async () => { + const outputPath = join(outputRoot, 'draft.json'); + await expectOutputCode( + writeReplayDraftSafely( + outputPath, + 'x'.repeat(REPLAY_MAX_FIXTURE_BYTES + 1) + ), + SAFE_OUTPUT_ERROR_CODES.OutputTooLarge + ); + await expectOutputCode( + writeReplayDraftSafely(outputPath, '{}'), + SAFE_OUTPUT_ERROR_CODES.InvalidDraft + ); + await expectOutputCode( + writeReplayDraftSafely( + outputPath, + JSON.stringify( + validateReplayFixtureText(canonicalFixture()).fixture + ) + ), + SAFE_OUTPUT_ERROR_CODES.InvalidDraft + ); + await expect(lstat(outputPath)).rejects.toThrow(); + }); + + it('rejects unsafe output names before creating temporary files', async () => { + await expectOutputCode( + writeReplayDraftSafely( + join(outputRoot, '.draft.json'), + canonicalFixture() + ), + SAFE_OUTPUT_ERROR_CODES.UnsafeOutputPath + ); + expect(await readdir(outputRoot)).toEqual([]); + }); +}); diff --git a/tools/stalker-fixtures/src/lib/safe-output.ts b/tools/stalker-fixtures/src/lib/safe-output.ts new file mode 100644 index 000000000..38568d8d4 --- /dev/null +++ b/tools/stalker-fixtures/src/lib/safe-output.ts @@ -0,0 +1,386 @@ +import { randomBytes } from 'node:crypto'; +import { constants, type BigIntStats } from 'node:fs'; +import { + link, + lstat, + open, + realpath, + unlink, + type FileHandle, +} from 'node:fs/promises'; +import { basename, dirname, join, resolve } from 'node:path'; +import { REPLAY_MAX_FIXTURE_BYTES } from '@iptvnator/portal/stalker/replay-fixtures'; +import { + FixtureValidationError, + validateReplayFixtureText, +} from './fixture-validator'; + +export const SAFE_OUTPUT_ERROR_CODES = { + InvalidDraft: 'invalid-draft', + OutputTooLarge: 'output-too-large', + UnsafeOutputPath: 'unsafe-output-path', + OutputExists: 'output-exists', + OutputOpenFailed: 'output-open-failed', + OutputWriteFailed: 'output-write-failed', + OutputPublishFailed: 'output-publish-failed', +} as const; + +export type SafeOutputErrorCode = + (typeof SAFE_OUTPUT_ERROR_CODES)[keyof typeof SAFE_OUTPUT_ERROR_CODES]; + +export class SafeOutputError extends Error { + constructor(readonly code: SafeOutputErrorCode) { + super(`Stalker HAR conversion failed: ${code}.`); + this.name = 'SafeOutputError'; + } +} + +export interface SafeOutputOptions { + /** + * Race-test seam. Production callers must not supply it. + */ + beforePublish?: (temporaryPath: string) => Promise; +} + +const SAFE_OUTPUT_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; + +export async function writeReplayDraftSafely( + outputPath: string, + text: string, + options: SafeOutputOptions = {} +): Promise { + validateDraftBeforeWrite(text); + const absoluteOutput = resolve(outputPath); + const outputName = basename(absoluteOutput); + if (!SAFE_OUTPUT_NAME.test(outputName)) { + reject(SAFE_OUTPUT_ERROR_CODES.UnsafeOutputPath); + } + + const requestedParent = dirname(absoluteOutput); + const parentPreflight = await safeParentLstat(requestedParent); + assertSafeDirectory(parentPreflight); + const canonicalParent = await safeParentRealpath(requestedParent); + const canonicalParentPreflight = await safeParentLstat(canonicalParent); + assertSafeDirectory(canonicalParentPreflight); + assertSameDirectory(parentPreflight, canonicalParentPreflight); + + const canonicalOutput = join(canonicalParent, outputName); + await assertDestinationAbsent(canonicalOutput); + + let parentHandle: FileHandle | undefined; + let temporaryHandle: FileHandle | undefined; + let temporaryPath: string | undefined; + let stage: 'open' | 'write' | 'publish' = 'open'; + try { + parentHandle = await open( + canonicalParent, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW + ); + const openedParent = await parentHandle.stat({ bigint: true }); + assertSafeDirectory(openedParent); + assertSameDirectory(canonicalParentPreflight, openedParent); + + temporaryPath = join( + canonicalParent, + `.${outputName}.${randomBytes(16).toString('hex')}.tmp` + ); + temporaryHandle = await open( + temporaryPath, + constants.O_WRONLY | + constants.O_CREAT | + constants.O_EXCL | + constants.O_NOFOLLOW, + 0o600 + ); + const openedTemporary = await temporaryHandle.stat({ bigint: true }); + assertSafeTemporary(openedTemporary, 0n); + + stage = 'write'; + const bytes = Buffer.from(text); + await writeExactly(temporaryHandle, bytes); + await temporaryHandle.sync(); + const writtenTemporary = await temporaryHandle.stat({ bigint: true }); + assertSafeTemporary(writtenTemporary, BigInt(bytes.byteLength)); + assertSameFileIdentity(openedTemporary, writtenTemporary); + await temporaryHandle.close(); + temporaryHandle = undefined; + + stage = 'publish'; + await options.beforePublish?.(temporaryPath); + await assertParentStillOwned( + requestedParent, + canonicalParent, + canonicalParentPreflight, + parentHandle + ); + const readyTemporary = await safeTemporaryLstat(temporaryPath); + assertSafeTemporary(readyTemporary, BigInt(bytes.byteLength)); + assertSameFileIdentity(openedTemporary, readyTemporary); + await assertDestinationAbsent(canonicalOutput); + + await publishWithoutOverwrite(temporaryPath, canonicalOutput); + const linkedTemporary = await safeTemporaryLstat(temporaryPath); + const linkedOutput = await safeOutputLstat(canonicalOutput); + assertPublishedLink( + openedTemporary, + linkedTemporary, + linkedOutput, + BigInt(bytes.byteLength) + ); + + await unlink(temporaryPath); + temporaryPath = undefined; + const publishedOutput = await safeOutputLstat(canonicalOutput); + assertSafePublishedOutput( + openedTemporary, + publishedOutput, + BigInt(bytes.byteLength) + ); + await parentHandle.sync(); + await parentHandle.close(); + parentHandle = undefined; + } catch (error) { + if (error instanceof SafeOutputError) { + throw error; + } + const code = + stage === 'open' + ? SAFE_OUTPUT_ERROR_CODES.OutputOpenFailed + : stage === 'write' + ? SAFE_OUTPUT_ERROR_CODES.OutputWriteFailed + : SAFE_OUTPUT_ERROR_CODES.OutputPublishFailed; + throw new SafeOutputError(code); + } finally { + await temporaryHandle?.close().catch(() => undefined); + if (temporaryPath !== undefined) { + await unlink(temporaryPath).catch(() => undefined); + } + await parentHandle?.close().catch(() => undefined); + } +} + +function validateDraftBeforeWrite(text: string): void { + if (Buffer.byteLength(text) > REPLAY_MAX_FIXTURE_BYTES) { + reject(SAFE_OUTPUT_ERROR_CODES.OutputTooLarge); + } + try { + const validated = validateReplayFixtureText(text); + if (validated.formatted !== text) { + reject(SAFE_OUTPUT_ERROR_CODES.InvalidDraft); + } + } catch (error) { + if (error instanceof SafeOutputError) { + throw error; + } + if (error instanceof FixtureValidationError) { + reject(SAFE_OUTPUT_ERROR_CODES.InvalidDraft); + } + reject(SAFE_OUTPUT_ERROR_CODES.InvalidDraft); + } +} + +async function writeExactly(handle: FileHandle, bytes: Buffer): Promise { + let offset = 0; + while (offset < bytes.byteLength) { + const result = await handle.write( + bytes, + offset, + bytes.byteLength - offset, + offset + ); + if (result.bytesWritten <= 0) { + reject(SAFE_OUTPUT_ERROR_CODES.OutputWriteFailed); + } + offset += result.bytesWritten; + } +} + +async function assertParentStillOwned( + requestedParent: string, + canonicalParent: string, + expected: BigIntStats, + parentHandle: FileHandle +): Promise { + const requested = await safeParentLstat(requestedParent); + const canonical = await safeParentLstat(canonicalParent); + const opened = await parentHandle.stat({ bigint: true }); + for (const actual of [requested, canonical, opened]) { + assertSafeDirectory(actual); + assertSameDirectory(expected, actual); + } +} + +async function publishWithoutOverwrite( + temporaryPath: string, + outputPath: string +): Promise { + try { + /* + * Node does not expose renameat2(RENAME_NOREPLACE). A same-directory + * hard-link publication has the required atomic, no-overwrite + * property: the complete inode appears at the destination in one + * operation, and EEXIST wins over files and symlinks. + */ + await link(temporaryPath, outputPath); + } catch (error) { + if (isNodeError(error) && error.code === 'EEXIST') { + reject(SAFE_OUTPUT_ERROR_CODES.OutputExists); + } + reject(SAFE_OUTPUT_ERROR_CODES.OutputPublishFailed); + } +} + +async function assertDestinationAbsent(path: string): Promise { + try { + await lstat(path); + reject(SAFE_OUTPUT_ERROR_CODES.OutputExists); + } catch (error) { + if (error instanceof SafeOutputError) { + throw error; + } + if (isNodeError(error) && error.code === 'ENOENT') { + return; + } + reject(SAFE_OUTPUT_ERROR_CODES.UnsafeOutputPath); + } +} + +async function safeParentLstat(path: string): Promise { + try { + return await lstat(path, { bigint: true }); + } catch { + reject(SAFE_OUTPUT_ERROR_CODES.UnsafeOutputPath); + } +} + +async function safeParentRealpath(path: string): Promise { + try { + return await realpath(path); + } catch { + reject(SAFE_OUTPUT_ERROR_CODES.UnsafeOutputPath); + } +} + +async function safeTemporaryLstat(path: string): Promise { + try { + return await lstat(path, { bigint: true }); + } catch { + reject(SAFE_OUTPUT_ERROR_CODES.OutputPublishFailed); + } +} + +async function safeOutputLstat(path: string): Promise { + try { + return await lstat(path, { bigint: true }); + } catch { + reject(SAFE_OUTPUT_ERROR_CODES.OutputPublishFailed); + } +} + +function assertSafeDirectory(stat: BigIntStats): void { + if (!stat.isDirectory() || stat.isSymbolicLink()) { + reject(SAFE_OUTPUT_ERROR_CODES.UnsafeOutputPath); + } +} + +function assertSameDirectory(expected: BigIntStats, actual: BigIntStats): void { + if ( + expected.dev !== actual.dev || + expected.ino !== actual.ino || + expected.mode !== actual.mode + ) { + reject(SAFE_OUTPUT_ERROR_CODES.UnsafeOutputPath); + } +} + +function assertSafeTemporary(stat: BigIntStats, size: bigint): void { + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.nlink !== 1n || + stat.size !== size || + (stat.mode & 0o777n) !== 0o600n + ) { + reject(SAFE_OUTPUT_ERROR_CODES.OutputPublishFailed); + } +} + +function assertPublishedLink( + opened: BigIntStats, + temporary: BigIntStats, + output: BigIntStats, + size: bigint +): void { + for (const stat of [temporary, output]) { + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.nlink !== 2n || + stat.size !== size || + (stat.mode & 0o777n) !== 0o600n + ) { + reject(SAFE_OUTPUT_ERROR_CODES.OutputPublishFailed); + } + assertSameFileIdentity(opened, stat); + } + assertSameFileSnapshot(temporary, output); +} + +function assertSafePublishedOutput( + opened: BigIntStats, + output: BigIntStats, + size: bigint +): void { + if ( + !output.isFile() || + output.isSymbolicLink() || + output.nlink !== 1n || + output.size !== size || + (output.mode & 0o777n) !== 0o600n + ) { + reject(SAFE_OUTPUT_ERROR_CODES.OutputPublishFailed); + } + assertSameFileIdentity(opened, output); +} + +function assertSameFileIdentity( + expected: BigIntStats, + actual: BigIntStats +): void { + if ( + expected.dev !== actual.dev || + expected.ino !== actual.ino || + expected.mode !== actual.mode + ) { + reject(SAFE_OUTPUT_ERROR_CODES.OutputPublishFailed); + } +} + +function assertSameFileSnapshot( + expected: BigIntStats, + actual: BigIntStats +): void { + if ( + expected.dev !== actual.dev || + expected.ino !== actual.ino || + expected.mode !== actual.mode || + expected.size !== actual.size || + expected.mtimeNs !== actual.mtimeNs || + expected.ctimeNs !== actual.ctimeNs + ) { + reject(SAFE_OUTPUT_ERROR_CODES.OutputPublishFailed); + } +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return ( + error !== null && + typeof error === 'object' && + 'code' in error && + typeof (error as { code?: unknown }).code === 'string' + ); +} + +function reject(code: SafeOutputErrorCode): never { + throw new SafeOutputError(code); +}