Process and API-only Playwright harness: binary and image setup, server/CLI runners, fake Docker Engine socket, read-only database inspection, secret redaction in reports, seeded fuzzing, leak detection and harness self-checks.
121 lines
4.9 KiB
TypeScript
121 lines
4.9 KiB
TypeScript
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
// Copyright (C) 2026 Bryan Joshua Pedini
|
|
|
|
/**
|
|
* Per-test temporary workspaces and TCP port allocation.
|
|
*/
|
|
import fs from 'node:fs';
|
|
import net from 'node:net';
|
|
import path from 'node:path';
|
|
import { ALPINE_IMAGE, LABEL_RUN, RESOURCE_PREFIX, runId, workspacesDir } from './env';
|
|
import { HarnessError } from './errors';
|
|
import { runProcess, toolEnv } from './proc';
|
|
|
|
export class Workspace {
|
|
/** Default SQLite path for the test (`<dir>/dpsapi.db`); nothing is created there by the harness. */
|
|
readonly dbPath: string;
|
|
|
|
constructor(readonly dir: string) {
|
|
this.dbPath = path.join(dir, 'dpsapi.db');
|
|
}
|
|
|
|
/** Creates a fresh workspace directory under the run's workspaces dir. */
|
|
static create(key: string): Workspace {
|
|
fs.mkdirSync(workspacesDir(), { recursive: true });
|
|
const dir = fs.mkdtempSync(path.join(workspacesDir(), `${key}-`));
|
|
return new Workspace(dir);
|
|
}
|
|
|
|
/** Absolute path inside the workspace (not created). */
|
|
path(...parts: string[]): string {
|
|
return path.join(this.dir, ...parts);
|
|
}
|
|
|
|
/** Creates (recursively) and returns a directory inside the workspace. */
|
|
mkdir(...parts: string[]): string {
|
|
const p = this.path(...parts);
|
|
fs.mkdirSync(p, { recursive: true });
|
|
return p;
|
|
}
|
|
|
|
writeFile(name: string, data: string | Buffer, mode?: number): string {
|
|
const p = this.path(name);
|
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
fs.writeFileSync(p, data, mode === undefined ? undefined : { mode });
|
|
return p;
|
|
}
|
|
|
|
/** Removes the workspace, falling back to a throwaway container for root-owned files. */
|
|
async remove(): Promise<void> {
|
|
await forceRemoveDir(this.dir);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes a directory tree. Files written by containers running as root cannot be removed by
|
|
* the host user; for those, the contents are deleted from a throwaway, labelled alpine container.
|
|
*/
|
|
export async function forceRemoveDir(dir: string): Promise<void> {
|
|
try {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
return;
|
|
} catch (e) {
|
|
const code = (e as NodeJS.ErrnoException).code;
|
|
if (code !== 'EACCES' && code !== 'EPERM' && code !== 'ENOTEMPTY') throw e;
|
|
}
|
|
const name = `${RESOURCE_PREFIX}${runId()}-rm-${Math.random().toString(36).slice(2, 10)}`;
|
|
const r = await runProcess(
|
|
'docker',
|
|
['run', '--rm', '--name', name, '--label', `${LABEL_RUN}=${runId()}`, '--network', 'none', '-v', `${dir}:/victim`, ALPINE_IMAGE, 'sh', '-c', 'find /victim -mindepth 1 -delete'],
|
|
{ env: toolEnv(), timeoutMs: 60_000, label: 'docker cleanup container' },
|
|
);
|
|
if (r.exitCode !== 0) throw new HarnessError('infrastructure', `cannot clean ${dir} via container: ${r.stderr}`);
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
|
|
/** Lowest port used by the allocator; windows of PORT_WINDOW ports per parallel slot, below the ephemeral range. */
|
|
const PORT_BASE = 20_000;
|
|
const PORT_WINDOW = 500;
|
|
const PORT_SLOTS = 24; // 20000..31999, below Linux's default ephemeral range (32768+)
|
|
let portCursor = 0;
|
|
|
|
/** True when `port` can currently be bound on every local address (checked dual-stack). */
|
|
export function isPortFree(port: number): Promise<boolean> {
|
|
const tryListen = (host: string, ipv6Only: boolean) =>
|
|
new Promise<'free' | 'busy' | 'unsupported'>((resolve) => {
|
|
const srv = net.createServer();
|
|
srv.once('error', (e: NodeJS.ErrnoException) => {
|
|
resolve(e.code === 'EADDRINUSE' || e.code === 'EACCES' ? 'busy' : 'unsupported');
|
|
});
|
|
srv.listen({ host, port, ipv6Only, exclusive: true }, () => srv.close(() => resolve('free')));
|
|
});
|
|
return tryListen('::', false).then(async (r) => {
|
|
if (r === 'unsupported') return (await tryListen('0.0.0.0', false)) === 'free';
|
|
return r === 'free';
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Allocates a currently free TCP port. Concurrent workers draw from disjoint windows keyed
|
|
* by their parallel index, so two running tests never get the same port from the allocator;
|
|
* the bind check additionally skips ports used by anything else on the host.
|
|
*/
|
|
export async function allocatePort(parallelIndex: number): Promise<number> {
|
|
const slot = parallelIndex % PORT_SLOTS;
|
|
for (let attempt = 0; attempt < PORT_WINDOW; attempt++) {
|
|
const port = PORT_BASE + slot * PORT_WINDOW + (portCursor++ % PORT_WINDOW);
|
|
if (await isPortFree(port)) return port;
|
|
}
|
|
throw new HarnessError('infrastructure', `no free port in window of parallel slot ${slot}`);
|
|
}
|
|
|
|
/** Holds a port busy (listening socket) until close() — for "address already in use" tests. */
|
|
export async function occupyPort(port: number, host = '127.0.0.1'): Promise<{ close: () => Promise<void> }> {
|
|
const srv = net.createServer((s) => s.destroy());
|
|
await new Promise<void>((resolve, reject) => {
|
|
srv.once('error', reject);
|
|
srv.listen({ host, port, exclusive: true }, () => resolve());
|
|
});
|
|
return { close: () => new Promise<void>((r) => srv.close(() => r())) };
|
|
}
|