feat(stalker): add safe HAR draft converter

This commit is contained in:
4gray committed 2026-07-27 10:28:47 +02:00
1 parent 5b22bee0f4
commit df92bae87e
10 files changed
+3536 -10

No files matched your search

+1
View File
@@ -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"
},
+41 -10
View File
@@ -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<void> {
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;
});
+3
View File
@@ -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';
@@ -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<CliResult> {
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);
});
});
@@ -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> | (() => unknown),
code: string
): Promise<void> {
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
);
});
});
@@ -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<void>;
}
interface JsonCollectionFrame {
kind: '[' | '{';
commas: number;
hasContent: boolean;
}
export async function readHarFile(
inputPath: string,
options: HarReaderOptions = {}
): Promise<unknown> {
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<BigIntStats> {
try {
return await lstat(path, { bigint: true });
} catch {
reject(HAR_READER_ERROR_CODES.InputUnavailable);
}
}
async function safeRealpath(
path: string,
code: HarReaderErrorCode
): Promise<string> {
try {
return await realpath(path);
} catch {
reject(code);
}
}
async function discoverGitWorktreeRoots(): Promise<readonly string[]> {
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<Buffer> {
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<void> {
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<Buffer> {
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<string, unknown> {
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);
}
@@ -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<T>(entries: readonly T[], index: number): T {
const entry = entries[index];
if (entry === undefined) {
throw new Error('test HAR entry is missing');
}
return entry;
}
File diff suppressed because it is too large. Load diff
@@ -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<unknown>,
code: string
): Promise<void> {
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([]);
});
});
@@ -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<void>;
}
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<BigIntStats> {
try {
return await lstat(path, { bigint: true });
} catch {
reject(SAFE_OUTPUT_ERROR_CODES.UnsafeOutputPath);
}
}
async function safeParentRealpath(path: string): Promise<string> {
try {
return await realpath(path);
} catch {
reject(SAFE_OUTPUT_ERROR_CODES.UnsafeOutputPath);
}
}
async function safeTemporaryLstat(path: string): Promise<BigIntStats> {
try {
return await lstat(path, { bigint: true });
} catch {
reject(SAFE_OUTPUT_ERROR_CODES.OutputPublishFailed);
}
}
async function safeOutputLstat(path: string): Promise<BigIntStats> {
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);
}