mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
feat(stalker): add safe HAR draft converter
This commit is contained in:
1 parent
5b22bee0f4
commit
df92bae87e
10 files changed
+3536
-10
No files matched your search
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user