mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-28 15:41:57 +02:00
* fix(browse): prepare reliable cookie import wave for validation * ci: sequence quality and behavior for validation branch * fix(browse): isolate Windows qualification and preserve native diagnostics * test(browse): cover cookie workflow quality and isolate Windows user paths * test(browse): trace native member startup and initialize fresh folders * fix(browse): keep Windows member stdin alive through EOF * fix(browse): latch native timeouts and compare contained Edge startup * test(browse): verify native version metadata and actual Windows argv * test(browse): qualify Dia import on isolated macOS CI * fix(browse): require picker origin for session mutations * fix(browse): bound credential reads through stream completion * test(browse): inspect owned Windows process arguments natively * test(evals): preserve passing coverage during cookie repair reruns * test(browse): isolate Dia qualification in a fresh macOS account * test(browse): pass bounded integer timeouts to native Mac probes * test(browse): distinguish Windows profile initialization from containment * test(browse): await descendant pipe readiness before parent exit * test(browse): initialize and restore isolated macOS Keychain state * test(browse): initialize Windows fixture folders before qualification * test(ci): pin the same Node runtime across Windows checks * test(browse): distinguish native macOS browser preflight stages * test(browse): isolate Windows descendant console lifetime * test(browse): preserve native receipts and identify fixture lock holders * test(browse): prepare dependency resolution before native Mac worker startup * test(ci): include lock and close checks in native diagnostics * test(browse): preserve native owner probe stages and subprocess deadlines * fix(browse): classify Chromium profile-in-use exit precisely * test(browse): retain Mac qualification evidence through cleanup failures * test(browse): bound Mac fixture paths and retire its owned user domain * test(browse): accept vanished fixture entries without weakening cleanup * test(browse): identify probe-created macOS user domains safely * test(browse): observe Mac user domains without targeting them first * test(browse): use passive fresh-user ownership throughout Mac qualification * test(browse): distinguish profile and registered-home Keychain lookups * test(browse): qualify Dia under one registered account home * test(browse): identify Dia startup and owned process-group failures * test(browse): classify bounded Dia startup diagnostics without leaking output * fix(test): preserve native Mac sandboxing and reap owned browser children * fix(browse): preserve Chromium sandboxing for native profile imports * test(browse): inspect signed Mach-O architecture without launching Xcode tools * test(browse): sample pending Dia startup and reap on all cleanup paths * test(browse): compare protected Dia launches in fresh Bun and Node accounts * test(browse): inspect isolated Mac GUI readiness without browser access * v1.90.0.0 fix: bind cookie picker actions to their document * test: validate cookie guards and fit nested launch fixtures * ci: configure the bundled Chromium sandbox helper * fix(browse): classify Playwright authentication timeouts * test: retain bounded Windows lifecycle diagnostics * test(cso): reuse bounded NTFS precision candidates * test(review): handle explicit preservation choices safely * test(browse): remove owned fixture directories with explicit primitives * test(review): distinguish descriptive reuse from edit commitments * test: admit only the approved unscored cookie workflow refusal * test: keep the Office Hours judge mock export-complete * fix: keep dependency-free CI planners independent of the model SDK * test: observe the exact holder after a native fixture unlink failure * fix: start seeded PTY observations at owned readiness * test: acquire identity-bound Windows deletion admission before profile resets * test: preserve qualified Git index bits without authorizing mutations
1589 lines
94 KiB
TypeScript
1589 lines
94 KiB
TypeScript
import { afterAll, describe, expect, test } from 'bun:test';
|
|
import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
|
|
import { existsSync, lstatSync, mkdirSync, mkdtempSync, opendirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, rmdirSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { createRequire } from 'node:module';
|
|
import { once } from 'node:events';
|
|
import { createHash, randomUUID } from 'node:crypto';
|
|
import { Database } from 'bun:sqlite';
|
|
import { dlopen, FFIType, ptr } from 'bun:ffi';
|
|
import { nativeBrowserPaths } from '../src/cookie-import-native';
|
|
import { NATIVE_BROWSER_VERSION_COMMAND } from '../src/cookie-import-native-integrity';
|
|
import { createNativeCookieJob, joinNativeCookieJob, NativeCookieJobError, nativeCookieDiagnostic, parseNativeCookieDiagnostic, type NativeCookieJob } from '../src/cookie-import-native-job';
|
|
import { nativeCookieEnvironment, NATIVE_COOKIE_NODE_SCRIPT, superviseNativeCookieImport, type NativeCookieMember, type NativeCookieReply, type NativeCookieRequest } from '../src/cookie-import-native-worker';
|
|
import { decodeNativeCommandLine } from './fixtures/native-cookie-process-observer';
|
|
import { createFixtureDeleteLease, FixtureDeleteError } from './fixtures/native-cookie-delete-lease';
|
|
|
|
const root = mkdtempSync(path.join(tmpdir(), 'cookie-job-'));
|
|
const resolvedRoot = realpathSync(root);
|
|
const initialRootState = lstatSync(root, { bigint: true });
|
|
const fixtureChildren = new Set<ChildProcess>();
|
|
const fixturePrimitiveFailures = new WeakMap<object, object>();
|
|
|
|
function removeOwnedFixtureDirectory(directory: string, identity: { dev: bigint; ino: bigint }, operations = {
|
|
lstat: (file: string) => lstatSync(file, { bigint: true }),
|
|
enumerate: (file: string) => readdirSync(file),
|
|
unlink: (file: string, _identity: { dev: bigint; ino: bigint; mode: bigint }, _verify: () => void) => unlinkSync(file),
|
|
rmdir: (file: string) => rmdirSync(file),
|
|
}): void {
|
|
let primitive = 'identity';
|
|
let object = directory;
|
|
let objectIdentityMatched = false;
|
|
let rootIdentityMatched = false;
|
|
let objectIdentity: { dev: bigint; ino: bigint; mode: bigint } | undefined;
|
|
const ancestors: { file: string; dev: bigint; ino: bigint }[] = [{ file: root, dev: initialRootState.dev, ino: initialRootState.ino }];
|
|
const inspect = (file: string) => {
|
|
primitive = 'lstat'; object = file; objectIdentityMatched = false;
|
|
objectIdentity = undefined;
|
|
const state = operations.lstat(file);
|
|
objectIdentity = { dev: state.dev, ino: state.ino, mode: state.mode };
|
|
return state;
|
|
};
|
|
const verifyAncestors = (stopBefore?: string) => {
|
|
for (const ancestor of ancestors) {
|
|
if (ancestor.file === stopBefore) break;
|
|
if (ancestor.file === root) rootIdentityMatched = false;
|
|
const state = inspect(ancestor.file);
|
|
const matches = state.isDirectory() && !state.isSymbolicLink() && state.dev === ancestor.dev && state.ino === ancestor.ino;
|
|
if (ancestor.file === root) rootIdentityMatched = matches;
|
|
if (!matches) { primitive = 'identity'; throw new Error('Native fixture ancestor identity changed'); }
|
|
objectIdentityMatched = true;
|
|
}
|
|
};
|
|
const remove = (file: string, expected?: { dev: bigint; ino: bigint }) => {
|
|
const ancestorCount = ancestors.length;
|
|
try {
|
|
verifyAncestors();
|
|
const state = inspect(file);
|
|
if (expected && (state.dev !== expected.dev || state.ino !== expected.ino || !state.isDirectory() || state.isSymbolicLink())) {
|
|
primitive = 'identity'; throw new Error('Native fixture directory identity changed');
|
|
}
|
|
if (state.isDirectory() && !state.isSymbolicLink()) {
|
|
ancestors.push({ file, dev: state.dev, ino: state.ino });
|
|
verifyAncestors();
|
|
primitive = 'enumerate'; object = file; objectIdentityMatched = true;
|
|
const entries = operations.enumerate(file);
|
|
for (const entry of entries) {
|
|
if (!entry || entry === '.' || entry === '..' || path.basename(entry) !== entry) {
|
|
primitive = 'identity'; object = file; throw new Error('Native fixture enumeration escaped its directory');
|
|
}
|
|
remove(path.join(file, entry));
|
|
}
|
|
verifyAncestors();
|
|
primitive = 'rmdir'; object = file; objectIdentityMatched = true;
|
|
operations.rmdir(file);
|
|
} else {
|
|
const verify = () => {
|
|
verifyAncestors();
|
|
if (realpathSync(root) !== resolvedRoot) { primitive = 'identity'; throw new Error('Native fixture root path changed'); }
|
|
const current = inspect(file);
|
|
if (current.dev !== state.dev || current.ino !== state.ino || current.mode !== state.mode) {
|
|
primitive = 'identity'; throw new Error('Native fixture entry identity changed');
|
|
}
|
|
primitive = 'unlink'; object = file; objectIdentityMatched = true;
|
|
};
|
|
verify();
|
|
operations.unlink(file, state, verify);
|
|
}
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT' || object !== file) throw error;
|
|
const failedPrimitive = primitive;
|
|
const matchedBeforeFailure = objectIdentityMatched;
|
|
verifyAncestors(file);
|
|
try { inspect(file); }
|
|
catch (absenceError) {
|
|
if ((absenceError as NodeJS.ErrnoException).code === 'ENOENT') return;
|
|
throw absenceError;
|
|
}
|
|
primitive = failedPrimitive; object = file; objectIdentityMatched = matchedBeforeFailure;
|
|
throw error;
|
|
} finally {
|
|
ancestors.length = ancestorCount;
|
|
}
|
|
};
|
|
try {
|
|
const relative = path.relative(path.resolve(root), path.resolve(directory));
|
|
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('Native fixture removal must stay below its owned root');
|
|
verifyAncestors();
|
|
if (realpathSync(root) !== resolvedRoot) { primitive = 'identity'; rootIdentityMatched = false; throw new Error('Native fixture root path changed'); }
|
|
let parent = root;
|
|
for (const part of relative.split(path.sep).slice(0, -1)) {
|
|
verifyAncestors();
|
|
parent = path.join(parent, part);
|
|
const state = inspect(parent);
|
|
if (!state.isDirectory() || state.isSymbolicLink()) { primitive = 'identity'; throw new Error('Native fixture ancestor is not an owned directory'); }
|
|
ancestors.push({ file: parent, dev: state.dev, ino: state.ino });
|
|
}
|
|
remove(directory, identity);
|
|
verifyAncestors();
|
|
primitive = 'postcondition'; object = directory; objectIdentityMatched = false;
|
|
try { operations.lstat(directory); }
|
|
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; throw error; }
|
|
throw new Error('Native fixture directory remains after removal');
|
|
} catch (error) {
|
|
const relative = path.relative(path.resolve(directory), path.resolve(object));
|
|
if (error && typeof error === 'object') fixturePrimitiveFailures.set(error, {
|
|
primitive, rootIdentityMatched, objectIdentityMatched,
|
|
...(objectIdentity ? { objectDev: objectIdentity.dev.toString(), objectIno: objectIdentity.ino.toString(), objectMode: Number(objectIdentity.mode) } : {}),
|
|
relativeObjectHash: !relative.startsWith('..') && !path.isAbsolute(relative) ? createHash('sha256').update(relative).digest('hex') : undefined,
|
|
objectScope: object === directory ? 'target' : relative.startsWith('..') || path.isAbsolute(relative) ? 'ancestor' : 'child',
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function resetOwnedProfileDirectory(directory: string, identity: { dev: bigint; ino: bigint }, supervisionDeadline: number): void {
|
|
const lease = createFixtureDeleteLease(Math.min(supervisionDeadline, performance.now() + 5_000));
|
|
try {
|
|
removeOwnedFixtureDirectory(directory, identity, {
|
|
lstat: file => lstatSync(file, { bigint: true }), enumerate: file => readdirSync(file),
|
|
unlink: lease.unlink, rmdir: file => rmdirSync(file),
|
|
});
|
|
} finally { lease.close(); }
|
|
}
|
|
|
|
function ownFixtureChild<T extends ChildProcess>(child: T): T {
|
|
fixtureChildren.add(child);
|
|
child.once('close', () => fixtureChildren.delete(child));
|
|
return child;
|
|
}
|
|
|
|
function nativeFixtureEnvironment(fixture: string, node: string): Record<string, string> {
|
|
const relative = path.relative(resolvedRoot, realpathSync(fixture));
|
|
if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('Native environment fixture is outside its owned root');
|
|
const local = path.join(fixture, 'AppData', 'Local');
|
|
const roaming = path.join(fixture, 'AppData', 'Roaming');
|
|
const temporary = path.join(local, 'Temp');
|
|
for (const directory of [local, roaming, temporary]) mkdirSync(directory, { recursive: true });
|
|
return nativeCookieEnvironment({
|
|
SystemRoot: process.env.SystemRoot, USERPROFILE: fixture,
|
|
LOCALAPPDATA: local, APPDATA: roaming, TEMP: temporary, TMP: temporary,
|
|
PATH: path.dirname(node),
|
|
});
|
|
}
|
|
|
|
function fixtureFileOwners(file: string, timeout = 5_000, expectedIdentity?: { dev: string; ino: string }): object {
|
|
if (process.platform !== 'win32') return { available: false, reason: 'not_windows' };
|
|
const result = spawnSync(process.execPath, [
|
|
'--no-env-file', '--no-install', '--no-macros', '--config=NUL', path.resolve(import.meta.dir, 'fixtures/native-cookie-file-owners.ts'),
|
|
Buffer.from(JSON.stringify({ root: resolvedRoot, file, testPid: process.pid, expectedIdentity })).toString('base64'),
|
|
], { env: nativeCookieEnvironment(process.env), encoding: 'utf8', timeout, maxBuffer: 65536, windowsHide: true });
|
|
try { return { ...JSON.parse(result.stdout), exitCode: result.status, stderrBytes: Buffer.byteLength(result.stderr || '') }; }
|
|
catch { return { available: false, reason: 'owner_probe_no_receipt', exitCode: result.status }; }
|
|
}
|
|
|
|
function fixtureRemovalEvidence(error: unknown, directory: string, identity: { dev: bigint; ino: bigint }, probeOwners = fixtureFileOwners): object {
|
|
const failure = error as NodeJS.ErrnoException;
|
|
const primitive = error && typeof error === 'object' ? fixturePrimitiveFailures.get(error) as {
|
|
primitive: string; relativeObjectHash?: string; objectDev?: string; objectIno?: string;
|
|
} | undefined : undefined;
|
|
const evidence = {
|
|
code: ['EBUSY', 'EPERM', 'EACCES', 'ENOENT', 'ENOTEMPTY', 'ENOTDIR'].includes(failure?.code || '') ? failure.code : 'filesystem_error',
|
|
syscall: ['rm', 'rmdir', 'unlink', 'scandir', 'lstat', 'open', 'fstat', 'close'].includes(failure?.syscall || '') ? failure.syscall : 'unavailable',
|
|
errno: Number.isSafeInteger(failure?.errno) ? failure.errno : undefined,
|
|
pendingChildCloses: fixtureChildren.size,
|
|
...(error instanceof FixtureDeleteError ? { deletionStage: error.stage, win32Error: error.win32Error, deadlineExceeded: error.deadlineExceeded } : {}),
|
|
...(error && typeof error === 'object' ? fixturePrimitiveFailures.get(error) : undefined),
|
|
};
|
|
try {
|
|
const rootState = lstatSync(root, { bigint: true });
|
|
if (rootState.isSymbolicLink() || rootState.dev !== initialRootState.dev || rootState.ino !== initialRootState.ino || realpathSync(root) !== resolvedRoot) {
|
|
return { ...evidence, inspected: false, reason: 'root_identity_changed' };
|
|
}
|
|
const relative = path.relative(resolvedRoot, path.resolve(directory));
|
|
const parts = relative ? relative.split(path.sep) : [];
|
|
if (relative.startsWith('..') || path.isAbsolute(relative) || parts.length > 16) return { ...evidence, inspected: false, reason: 'outside_owned_fixture' };
|
|
let current = root;
|
|
for (const part of parts) {
|
|
current = path.join(current, part);
|
|
if (lstatSync(current).isSymbolicLink()) return { ...evidence, inspected: false, reason: 'linked_object' };
|
|
}
|
|
const state = lstatSync(directory, { bigint: true });
|
|
if (!state.isDirectory() || state.dev !== identity.dev || state.ino !== identity.ino) return { ...evidence, inspected: false, reason: 'object_identity_changed' };
|
|
const reported = typeof failure.path === 'string' ? path.relative(directory, path.resolve(failure.path)) : null;
|
|
let fileOwners: object | undefined;
|
|
if (failure.code === 'EBUSY' && primitive?.primitive === 'unlink' && reported && !reported.startsWith('..') && !path.isAbsolute(reported)
|
|
&& createHash('sha256').update(reported).digest('hex') === primitive.relativeObjectHash) {
|
|
try {
|
|
const file = path.join(directory, reported);
|
|
const current = lstatSync(file, { bigint: true });
|
|
const resolved = realpathSync(file);
|
|
if (!current.isFile() || current.isSymbolicLink() || current.dev.toString() !== primitive.objectDev
|
|
|| current.ino.toString() !== primitive.objectIno || path.relative(realpathSync(directory), resolved) !== reported) {
|
|
fileOwners = { available: false, reason: 'failed_object_identity_changed' };
|
|
} else {
|
|
fileOwners = probeOwners(file, 5_000, { dev: primitive.objectDev!, ino: primitive.objectIno! });
|
|
}
|
|
} catch (probeError) {
|
|
const code = (probeError as NodeJS.ErrnoException).code;
|
|
fileOwners = { available: false, reason: 'failed_object_unavailable', errorCode: ['ENOENT', 'EBUSY', 'EPERM', 'EACCES'].includes(code || '') ? code : 'filesystem_error' };
|
|
}
|
|
}
|
|
const entries: { nameHash: string; type: string }[] = [];
|
|
const listing = opendirSync(directory);
|
|
let entriesTruncated = false;
|
|
try {
|
|
while (entries.length < 16) {
|
|
const entry = listing.readSync();
|
|
if (!entry) break;
|
|
entries.push({ nameHash: createHash('sha256').update(entry.name).digest('hex'), type: entry.isSymbolicLink() ? 'link' : entry.isDirectory() ? 'directory' : entry.isFile() ? 'file' : 'other' });
|
|
}
|
|
entriesTruncated = entries.length === 16 && listing.readSync() !== null;
|
|
} finally { listing.closeSync(); }
|
|
return {
|
|
...evidence, inspected: true, directoryIdentityMatched: true, directoryMode: Number(state.mode),
|
|
reportedPath: reported === null ? 'unavailable' : reported === '' ? 'target' : reported.startsWith('..') || path.isAbsolute(reported) ? 'outside_target' : 'child',
|
|
reportedPathHash: reported !== null && !reported.startsWith('..') && !path.isAbsolute(reported) ? createHash('sha256').update(reported).digest('hex') : undefined,
|
|
entries, entriesTruncated, ...(fileOwners ? { fileOwners } : {}),
|
|
};
|
|
} catch (inspectionError) {
|
|
const code = (inspectionError as NodeJS.ErrnoException).code;
|
|
return { ...evidence, inspected: false, reason: 'inspection_failed', inspectionCode: ['ENOENT', 'EBUSY', 'EPERM', 'EACCES', 'ENOTDIR'].includes(code || '') ? code : 'filesystem_error' };
|
|
}
|
|
}
|
|
|
|
function clearOwnedFixtureContents(fixture: string, identity: { path: string; dev: bigint; ino: bigint }, listEntries: (directory: string) => string[] = readdirSync): void {
|
|
const before = lstatSync(fixture, { bigint: true });
|
|
if (before.isSymbolicLink() || realpathSync(fixture) !== identity.path || before.dev !== identity.dev || before.ino !== identity.ino) {
|
|
throw new Error('Native fixture root identity changed');
|
|
}
|
|
const ownerProbeDeadline = Date.now() + 5_000;
|
|
for (const entry of listEntries(fixture)) {
|
|
const child = path.join(fixture, entry);
|
|
try {
|
|
const state = lstatSync(child);
|
|
if (state.isSymbolicLink()) {
|
|
unlinkSync(child);
|
|
continue;
|
|
}
|
|
const relative = path.relative(identity.path, realpathSync(child));
|
|
if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('Native fixture entry is outside its owned root');
|
|
const ownersBeforeDelete = state.isFile() && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\.tmp$/i.test(entry)
|
|
? Date.now() < ownerProbeDeadline ? fixtureFileOwners(child, Math.max(1, ownerProbeDeadline - Date.now())) : { available: false, reason: 'owner_probe_budget_exhausted' }
|
|
: undefined;
|
|
if (ownersBeforeDelete) console.log(JSON.stringify({ nativeFixtureBeforeDelete: { file: path.relative(resolvedRoot, child), owners: ownersBeforeDelete } }));
|
|
try { rmSync(child, { recursive: true, force: true }); }
|
|
catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue;
|
|
console.error(JSON.stringify({ nativeFixtureFileLock: {
|
|
file: path.relative(resolvedRoot, child), code: (error as NodeJS.ErrnoException).code, ownersBeforeDelete,
|
|
ownersAfterFailure: state.isFile() ? fixtureFileOwners(child) : undefined,
|
|
} }));
|
|
throw error;
|
|
}
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue;
|
|
throw error;
|
|
}
|
|
}
|
|
const after = lstatSync(fixture, { bigint: true });
|
|
if (readdirSync(fixture).length !== 0 || realpathSync(fixture) !== identity.path || after.dev !== identity.dev || after.ino !== identity.ino) {
|
|
throw new Error('Native fixture contents reset did not preserve its empty root');
|
|
}
|
|
}
|
|
|
|
afterAll(() => {
|
|
try {
|
|
if (existsSync(root) && realpathSync(root) !== resolvedRoot) throw new Error('Native fixture root ownership changed');
|
|
rmSync(root, { recursive: true, force: true });
|
|
} catch (error) {
|
|
console.error(JSON.stringify({ nativeFixtureRemovalFailure: { stage: 'after_all', ...fixtureRemovalEvidence(error, root, initialRootState) } }));
|
|
const entries: { path: string; type: string; mode?: number; code?: string }[] = [];
|
|
let rootVerified = false;
|
|
let rootMode: number | null = null;
|
|
try { rootVerified = realpathSync(root) === resolvedRoot; rootMode = lstatSync(root).mode; } catch {}
|
|
const pending = rootVerified ? [root] : [];
|
|
while (pending.length && entries.length < 128) {
|
|
const directory = pending.shift()!;
|
|
try {
|
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
if (entries.length >= 128) break;
|
|
const child = path.join(directory, entry.name);
|
|
const state = lstatSync(child);
|
|
entries.push({ path: path.relative(root, child), type: state.isSymbolicLink() ? 'link' : state.isDirectory() ? 'directory' : 'file', mode: state.mode });
|
|
if (state.isDirectory() && !state.isSymbolicLink() && realpathSync(child).startsWith(resolvedRoot + path.sep)) pending.push(child);
|
|
}
|
|
} catch (inspectionError) {
|
|
entries.push({ path: path.relative(root, directory), type: 'unreadable', code: (inspectionError as NodeJS.ErrnoException).code });
|
|
}
|
|
}
|
|
const lockedFile = entries.find(entry => entry.type === 'file' && /^[0-9a-f-]{36}\.tmp$/i.test(path.basename(entry.path)));
|
|
console.error(JSON.stringify({ nativeFixtureCleanup: { code: (error as NodeJS.ErrnoException).code, pendingChildCloses: fixtureChildren.size, rootVerified, rootMode, remaining: entries,
|
|
fileOwners: lockedFile ? { file: lockedFile.path, owners: fixtureFileOwners(path.join(root, lockedFile.path)) } : undefined,
|
|
} }));
|
|
const node = Bun.which('node');
|
|
if (rootVerified && node) {
|
|
const comparison = spawnSync(node, [path.resolve(import.meta.dir, 'fixtures/native-cookie-remove-fixture.cjs'), Buffer.from(JSON.stringify({
|
|
root, realpath: resolvedRoot, dev: initialRootState.dev.toString(), ino: initialRootState.ino.toString(),
|
|
})).toString('base64')], { env: nativeCookieEnvironment(process.env), encoding: 'utf8', timeout: 5_000, windowsHide: true, maxBuffer: 65536 });
|
|
let evidence: object;
|
|
try { evidence = JSON.parse(comparison.stdout); }
|
|
catch { evidence = { removed: false, reason: 'node_cleanup_no_receipt', exitCode: comparison.status }; }
|
|
console.error(JSON.stringify({ nativeFixtureNodeCleanupComparison: evidence }));
|
|
}
|
|
throw error;
|
|
}
|
|
}, 15_000);
|
|
|
|
const request: NativeCookieRequest = {
|
|
nodeExecutable: 'C:\\fixture\\node.exe',
|
|
nodeArchitecture: process.arch,
|
|
playwrightEntry: 'C:\\fixture\\playwright.cjs',
|
|
executablePath: 'C:\\fixture\\msedge.exe',
|
|
userDataDir: 'C:\\fixture\\User Data',
|
|
profile: 'Default',
|
|
domains: ['example.test'],
|
|
deadline: 25_000,
|
|
qualifiedBunVersions: ['1.4.0'],
|
|
};
|
|
|
|
function kernelContract(mode: string) {
|
|
const script = `
|
|
const calls = [];
|
|
globalThis.__nativeCookieFfi = {
|
|
FFIType: { ptr: 'ptr', u64: 'u64', u32: 'u32', i32: 'i32' },
|
|
ptr: buffer => buffer,
|
|
dlopen: (name, signatures) => {
|
|
calls.push(['library', name, Object.keys(signatures)]);
|
|
return { close() { calls.push(['unload']); }, symbols: {
|
|
CreateJobObjectW: (security, name) => { calls.push(['create', security, name.toString('utf16le')]); return 42; },
|
|
OpenJobObjectW: (access, inherit, name) => { calls.push(['open', access, inherit, name.toString('utf16le')]); return 42; },
|
|
SetInformationJobObject: (handle, kind, buffer, length) => { calls.push(['limits', handle, kind, length, buffer.readUInt32LE(16)]); return 1; },
|
|
QueryInformationJobObject: (handle, kind, buffer, length) => { calls.push(['query', handle, kind, length]); buffer.writeUInt32LE(3, 40); return 1; },
|
|
OpenProcess: (access, inherit, pid) => { calls.push(['open-process', access, inherit, pid === process.pid]); return 999; },
|
|
AssignProcessToJobObject: (job, process) => { calls.push(['assign', job, process]); return ${mode === 'join-fail' ? 0 : 1}; },
|
|
TerminateJobObject: (job, code) => { calls.push(['terminate', job, code]); return 1; },
|
|
CloseHandle: handle => { calls.push(['close', handle]); return 1; },
|
|
GetLastError: () => ${mode === 'join-fail' ? 5 : 0},
|
|
} };
|
|
},
|
|
};
|
|
const source = await Bun.file(${JSON.stringify(path.resolve(import.meta.dir, '../src/cookie-import-native-job.ts'))}).text();
|
|
const boundary = "await import('bun:ffi')";
|
|
if (source.split(boundary).length !== 2) throw new Error('FFI adapter boundary changed');
|
|
const javascript = new Bun.Transpiler({ loader: 'ts' }).transformSync(source.replace(boundary, 'globalThis.__nativeCookieFfi'));
|
|
const { createNativeCookieJob, joinNativeCookieJob } = await import('data:text/javascript;base64,' + Buffer.from(javascript).toString('base64'));
|
|
Object.defineProperty(process, 'platform', { value: 'win32' });
|
|
if (${JSON.stringify(mode)} === 'create') {
|
|
const job = await createNativeCookieJob();
|
|
const active = job.activeProcesses();
|
|
job.terminate(); job.close(); job.close();
|
|
console.log(JSON.stringify({ name: job.name, active, calls }));
|
|
} else {
|
|
let error, diagnostic;
|
|
try { await joinNativeCookieJob('Local\\\\gstack-cookie-12345678-1234-1234-1234-123456789abc'); } catch (caught) { error = caught.message; diagnostic = caught.diagnostic; }
|
|
console.log(JSON.stringify({ calls, error, diagnostic }));
|
|
}
|
|
`;
|
|
const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, '-e', script], {
|
|
env: { TEMP: root, TMP: root, HOME: root, USERPROFILE: root, ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}) },
|
|
encoding: 'utf8',
|
|
timeout: 10_000,
|
|
});
|
|
expect(result.status).toBe(0);
|
|
expect(result.stderr).toBe('');
|
|
return JSON.parse(result.stdout);
|
|
}
|
|
|
|
describe('production Windows Job Object API contract', () => {
|
|
test('diagnostics expose only known native stages and numeric Windows errors', () => {
|
|
expect(nativeCookieDiagnostic(new NativeCookieJobError('job_assign', 5), 'job_create')).toEqual({ stage: 'job_assign', win32Error: 5 });
|
|
expect(nativeCookieDiagnostic(new Error('sensitive-sentinel'), 'ffi_open')).toEqual({ stage: 'ffi_open' });
|
|
expect(parseNativeCookieDiagnostic({ stage: 'job_assign', win32Error: 87, message: 'sensitive-sentinel' })).toEqual({ stage: 'job_assign', win32Error: 87 });
|
|
expect(parseNativeCookieDiagnostic({ stage: 'sensitive-sentinel', win32Error: 5 })).toBeUndefined();
|
|
expect(parseNativeCookieDiagnostic({ stage: 'job_assign', win32Error: 'sensitive-sentinel' })).toEqual({ stage: 'job_assign' });
|
|
expect(parseNativeCookieDiagnostic({ stage: 'member_exit', lastStage: 'node_spawned', exitCode: 1, nodeExitCode: 1, signal: 'SIGTERM', memberMode: true, stderrBytes: 50 })).toEqual({ stage: 'member_exit', lastStage: 'node_spawned', exitCode: 1, nodeExitCode: 1, signal: 'SIGTERM', memberMode: true, stderrBytes: 50 });
|
|
expect(parseNativeCookieDiagnostic({ stage: 'member_exit', lastStage: 'sensitive-sentinel', signal: 'sensitive-sentinel', exitCode: 'sensitive-sentinel', memberMode: 'sensitive-sentinel', stderrBytes: -1 })).toEqual({ stage: 'member_exit' });
|
|
});
|
|
|
|
test('owner creates a non-inheritable kill-on-close job and queries active members', () => {
|
|
const result = kernelContract('create');
|
|
expect(result.name).toMatch(/^Local\\gstack-cookie-[0-9a-f-]{36}$/);
|
|
expect(result.active).toBe(3);
|
|
expect(result.calls).toContainEqual(['create', null, `${result.name}\0`]);
|
|
expect(result.calls).toContainEqual(['limits', 42, 9, 144, 0x2000]);
|
|
expect(result.calls).toContainEqual(['query', 42, 1, 48]);
|
|
expect(result.calls).toContainEqual(['terminate', 42, 1]);
|
|
expect(result.calls.filter((call: unknown[]) => call[0] === 'close')).toEqual([['close', 42]]);
|
|
});
|
|
|
|
test('member assigns its own handle, never a discovered PID', () => {
|
|
const result = kernelContract('join');
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.calls).toContainEqual(['open', 1, 0, 'Local\\gstack-cookie-12345678-1234-1234-1234-123456789abc\0']);
|
|
expect(result.calls).toContainEqual(['open-process', 0x0101, 0, true]);
|
|
expect(result.calls).toContainEqual(['assign', 42, 999]);
|
|
expect(result.calls).toContainEqual(['close', 999]);
|
|
expect(result.calls).toContainEqual(['close', 42]);
|
|
});
|
|
|
|
test('failed self-assignment closes its handle and fails without a child launch', () => {
|
|
const result = kernelContract('join-fail');
|
|
expect(result.error).toBe('native_supervision_failed');
|
|
expect(result.diagnostic).toEqual({ stage: 'job_assign', win32Error: 5 });
|
|
expect(result.calls).toContainEqual(['close', 999]);
|
|
expect(result.calls).toContainEqual(['close', 42]);
|
|
});
|
|
});
|
|
|
|
function simulation(options: { reply?: NativeCookieReply; replyAt?: number; exitAt?: number; retainAfterTerminate?: boolean; signal?: AbortSignal; cancelAt?: () => void } = {}) {
|
|
let time = 0;
|
|
let active = 1;
|
|
let terminated = 0;
|
|
let stopped = 0;
|
|
let jobClosed = 0;
|
|
let started = 0;
|
|
let settleReply!: (reply: NativeCookieReply) => void;
|
|
let settleClose!: () => void;
|
|
const member: NativeCookieMember = {
|
|
result: new Promise(resolve => { settleReply = resolve; }),
|
|
closed: new Promise(resolve => { settleClose = resolve; }),
|
|
stop: () => { stopped++; },
|
|
};
|
|
const job: NativeCookieJob = {
|
|
name: 'synthetic-owned-job',
|
|
terminate: () => {
|
|
terminated++;
|
|
if (!options.retainAfterTerminate) { active = 0; settleClose(); }
|
|
},
|
|
activeProcesses: () => active,
|
|
close: () => { jobClosed++; },
|
|
};
|
|
const run = superviseNativeCookieImport(request, {
|
|
createJob: async () => job,
|
|
startMember: actual => { expect(actual.deadline).toBe(25_000); started++; return member; },
|
|
now: () => time,
|
|
sleep: async milliseconds => {
|
|
time += milliseconds;
|
|
if (options.reply && time >= (options.replyAt ?? 20)) settleReply(options.reply);
|
|
if (options.exitAt !== undefined && time >= options.exitAt) { active = 0; settleClose(); }
|
|
options.cancelAt?.();
|
|
},
|
|
signal: options.signal,
|
|
});
|
|
return { run, state: () => ({ time, active, terminated, stopped, jobClosed, started }) };
|
|
}
|
|
|
|
describe('owned native-cookie lifecycle', () => {
|
|
test('single-pass removal visits directories before removing them and never follows a leaf link', () => {
|
|
const fixture = mkdtempSync(path.join(root, 'single-pass-'));
|
|
const target = path.join(fixture, 'target');
|
|
const outside = path.join(fixture, 'outside');
|
|
mkdirSync(path.join(target, 'nested'), { recursive: true });
|
|
mkdirSync(outside);
|
|
writeFileSync(path.join(target, 'nested', 'file'), 'fixture-only');
|
|
writeFileSync(path.join(outside, 'preserved'), 'fixture-only');
|
|
symlinkSync(outside, path.join(target, 'link'), process.platform === 'win32' ? 'junction' : 'dir');
|
|
const identity = lstatSync(target, { bigint: true });
|
|
const actions: string[] = [];
|
|
removeOwnedFixtureDirectory(target, identity, {
|
|
lstat: file => lstatSync(file, { bigint: true }),
|
|
enumerate: file => { actions.push(`enumerate:${path.relative(target, file)}`); return readdirSync(file); },
|
|
unlink: file => { actions.push(`unlink:${path.relative(target, file)}`); unlinkSync(file); },
|
|
rmdir: file => { actions.push(`rmdir:${path.relative(target, file)}`); rmdirSync(file); },
|
|
});
|
|
expect(existsSync(target)).toBe(false);
|
|
expect(readFileSync(path.join(outside, 'preserved'), 'utf8')).toBe('fixture-only');
|
|
expect(actions.filter(action => action.startsWith('enumerate:'))).toHaveLength(2);
|
|
expect(actions.filter(action => action.startsWith('unlink:'))).toHaveLength(2);
|
|
expect(actions.filter(action => action.startsWith('rmdir:'))).toHaveLength(2);
|
|
expect(actions.indexOf(`unlink:${path.join('nested', 'file')}`)).toBeLessThan(actions.indexOf('rmdir:nested'));
|
|
expect(actions.at(-1)).toBe('rmdir:');
|
|
});
|
|
|
|
test('single-pass removal preserves the first original primitive error and stops', () => {
|
|
for (const failedPrimitive of ['lstat', 'enumerate', 'unlink', 'rmdir']) {
|
|
const target = mkdtempSync(path.join(root, 'first-removal-error-'));
|
|
const identity = lstatSync(target, { bigint: true });
|
|
if (failedPrimitive !== 'rmdir') {
|
|
writeFileSync(path.join(target, 'first'), 'fixture-only');
|
|
writeFileSync(path.join(target, 'second'), 'fixture-only');
|
|
}
|
|
const original = Object.freeze(Object.assign(new Error('sensitive-sentinel'), { code: 'EBUSY', errno: -4082, syscall: failedPrimitive }));
|
|
const calls: string[] = [];
|
|
let caught: unknown;
|
|
try {
|
|
removeOwnedFixtureDirectory(target, identity, {
|
|
lstat: file => {
|
|
if (failedPrimitive === 'lstat' && file === path.join(target, 'first')) { calls.push('lstat'); throw original; }
|
|
return lstatSync(file, { bigint: true });
|
|
},
|
|
enumerate: file => { calls.push('enumerate'); if (failedPrimitive === 'enumerate') throw original; return failedPrimitive === 'rmdir' ? [] : ['first', 'second']; },
|
|
unlink: file => { calls.push('unlink'); throw original; },
|
|
rmdir: file => { calls.push('rmdir'); throw original; },
|
|
});
|
|
} catch (error) { caught = error; }
|
|
expect(caught).toBe(original);
|
|
expect(calls).toEqual(failedPrimitive === 'enumerate' ? ['enumerate'] : ['enumerate', failedPrimitive]);
|
|
const receipt = fixtureRemovalEvidence(caught, target, identity);
|
|
expect(receipt).toMatchObject({ primitive: failedPrimitive, rootIdentityMatched: true, objectIdentityMatched: failedPrimitive !== 'lstat', directoryIdentityMatched: true, relativeObjectHash: createHash('sha256').update(['lstat', 'unlink'].includes(failedPrimitive) ? 'first' : '').digest('hex') });
|
|
expect(JSON.stringify(receipt)).not.toContain('sensitive-sentinel');
|
|
if (failedPrimitive !== 'rmdir') {
|
|
expect(readFileSync(path.join(target, 'first'), 'utf8')).toBe('fixture-only');
|
|
expect(readFileSync(path.join(target, 'second'), 'utf8')).toBe('fixture-only');
|
|
}
|
|
}
|
|
});
|
|
|
|
test('single-pass removal accepts disappeared entries without repeating a removal', () => {
|
|
for (const stage of ['before_lstat', 'during_unlink', 'during_rmdir']) {
|
|
const fixture = mkdtempSync(path.join(root, 'removal-disappearing-'));
|
|
const target = path.join(fixture, 'target');
|
|
const vanished = path.join(target, 'first');
|
|
const moved = path.join(fixture, 'moved');
|
|
mkdirSync(target);
|
|
if (stage === 'during_rmdir') mkdirSync(vanished);
|
|
else writeFileSync(vanished, 'fixture-only');
|
|
writeFileSync(path.join(target, 'second'), 'fixture-only');
|
|
const identity = lstatSync(target, { bigint: true });
|
|
const vanishedIdentity = lstatSync(vanished, { bigint: true });
|
|
const removals: string[] = [];
|
|
const enumerated: string[] = [];
|
|
removeOwnedFixtureDirectory(target, identity, {
|
|
lstat: file => lstatSync(file, { bigint: true }),
|
|
enumerate: file => {
|
|
enumerated.push(file);
|
|
const entries = readdirSync(file).sort();
|
|
if (stage === 'before_lstat' && file === target) renameSync(vanished, moved);
|
|
return entries;
|
|
},
|
|
unlink: file => {
|
|
removals.push(file);
|
|
if (stage === 'during_unlink' && file === vanished) renameSync(vanished, moved);
|
|
unlinkSync(file);
|
|
},
|
|
rmdir: file => {
|
|
removals.push(file);
|
|
if (stage === 'during_rmdir' && file === vanished) renameSync(vanished, moved);
|
|
rmdirSync(file);
|
|
},
|
|
});
|
|
expect(existsSync(target)).toBe(false);
|
|
expect(new Set(removals).size).toBe(removals.length);
|
|
expect(new Set(enumerated).size).toBe(enumerated.length);
|
|
expect(removals.filter(file => file === vanished)).toHaveLength(stage === 'before_lstat' ? 0 : 1);
|
|
expect(lstatSync(moved, { bigint: true }).ino).toBe(vanishedIdentity.ino);
|
|
if (stage === 'during_rmdir') expect(readdirSync(moved)).toEqual([]);
|
|
else expect(readFileSync(moved, 'utf8')).toBe('fixture-only');
|
|
}
|
|
});
|
|
|
|
test('single-pass removal never treats replacement, ancestor changes, or other errors as absence', () => {
|
|
for (const code of ['ENOENT', 'EBUSY', 'EPERM', 'EACCES', 'ancestor_replacement']) {
|
|
const fixture = mkdtempSync(path.join(root, 'removal-not-absent-'));
|
|
const target = path.join(fixture, 'target');
|
|
const first = path.join(target, 'first');
|
|
const moved = path.join(fixture, 'moved');
|
|
mkdirSync(target);
|
|
writeFileSync(first, 'fixture-only');
|
|
writeFileSync(path.join(target, 'second'), 'untouched-only');
|
|
const identity = lstatSync(target, { bigint: true });
|
|
const original = Object.freeze(Object.assign(new Error('synthetic-removal-error'), { code: code === 'ancestor_replacement' ? 'ENOENT' : code, syscall: 'unlink' }));
|
|
let calls = 0;
|
|
let caught: unknown;
|
|
try {
|
|
removeOwnedFixtureDirectory(target, identity, {
|
|
lstat: file => lstatSync(file, { bigint: true }),
|
|
enumerate: file => readdirSync(file).sort(),
|
|
unlink: file => {
|
|
calls++;
|
|
if (code === 'ENOENT') {
|
|
renameSync(file, moved);
|
|
writeFileSync(file, 'replacement-only');
|
|
} else if (code === 'ancestor_replacement') {
|
|
renameSync(target, moved);
|
|
mkdirSync(target);
|
|
writeFileSync(first, 'replacement-only');
|
|
}
|
|
throw original;
|
|
},
|
|
rmdir: file => rmdirSync(file),
|
|
});
|
|
} catch (error) { caught = error; }
|
|
expect(calls).toBe(1);
|
|
if (code === 'ancestor_replacement') {
|
|
expect(caught).toBeInstanceOf(Error);
|
|
expect((caught as Error).message).toContain('ancestor identity changed');
|
|
expect(fixturePrimitiveFailures.get(caught as object)).toMatchObject({ primitive: 'identity', objectIdentityMatched: false });
|
|
expect(readFileSync(path.join(moved, 'second'), 'utf8')).toBe('untouched-only');
|
|
} else {
|
|
expect(caught).toBe(original);
|
|
expect(fixturePrimitiveFailures.get(caught as object)).toMatchObject({ primitive: 'unlink', relativeObjectHash: createHash('sha256').update('first').digest('hex') });
|
|
expect(readFileSync(path.join(target, 'second'), 'utf8')).toBe('untouched-only');
|
|
}
|
|
expect(readFileSync(first, 'utf8')).toBe(code === 'ENOENT' || code === 'ancestor_replacement' ? 'replacement-only' : 'fixture-only');
|
|
}
|
|
});
|
|
|
|
test('single-pass removal rejects changed identities and linked ancestors before touching their contents', () => {
|
|
const fixture = mkdtempSync(path.join(root, 'removal-identity-'));
|
|
const target = path.join(fixture, 'target');
|
|
mkdirSync(target);
|
|
writeFileSync(path.join(target, 'preserved'), 'fixture-only');
|
|
const identity = lstatSync(target, { bigint: true });
|
|
expect(() => removeOwnedFixtureDirectory(target, { dev: identity.dev, ino: identity.ino + 1n })).toThrow('directory identity changed');
|
|
expect(() => removeOwnedFixtureDirectory(path.dirname(root), identity)).toThrow('stay below its owned root');
|
|
const link = path.join(fixture, 'link');
|
|
symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir');
|
|
expect(() => removeOwnedFixtureDirectory(path.join(link, 'preserved'), identity)).toThrow('ancestor is not an owned directory');
|
|
unlinkSync(link);
|
|
const moved = path.join(fixture, 'moved');
|
|
let caught: unknown;
|
|
try {
|
|
removeOwnedFixtureDirectory(target, identity, {
|
|
lstat: file => lstatSync(file, { bigint: true }),
|
|
enumerate: file => {
|
|
const entries = readdirSync(file);
|
|
renameSync(target, moved);
|
|
mkdirSync(target);
|
|
writeFileSync(path.join(target, 'preserved'), 'replacement-only');
|
|
return entries;
|
|
},
|
|
unlink: file => unlinkSync(file),
|
|
rmdir: file => rmdirSync(file),
|
|
});
|
|
} catch (error) { caught = error; }
|
|
expect(caught).toBeInstanceOf(Error);
|
|
expect(fixturePrimitiveFailures.get(caught as object)).toMatchObject({ primitive: 'identity', objectScope: 'target', objectIdentityMatched: false });
|
|
expect(readFileSync(path.join(moved, 'preserved'), 'utf8')).toBe('fixture-only');
|
|
expect(readFileSync(path.join(target, 'preserved'), 'utf8')).toBe('replacement-only');
|
|
});
|
|
|
|
test('removal diagnostics preserve errors and inspect only the unchanged synthetic directory', () => {
|
|
const fixture = mkdtempSync(path.join(root, 'removal-evidence-'));
|
|
const identity = lstatSync(fixture, { bigint: true });
|
|
writeFileSync(path.join(fixture, 'sensitive-sentinel'), 'fixture-only');
|
|
const error = Object.assign(new Error('sensitive-sentinel'), { code: 'EBUSY', syscall: 'rm', errno: -4082, path: fixture });
|
|
const evidence = fixtureRemovalEvidence(error, fixture, identity);
|
|
expect(evidence).toMatchObject({ code: 'EBUSY', syscall: 'rm', errno: -4082, inspected: true, directoryIdentityMatched: true, reportedPath: 'target', entries: [{ type: 'file' }] });
|
|
expect(JSON.stringify(evidence)).not.toContain('sensitive-sentinel');
|
|
expect(JSON.stringify(evidence)).not.toContain(fixture);
|
|
expect(readFileSync(path.join(fixture, 'sensitive-sentinel'), 'utf8')).toBe('fixture-only');
|
|
expect(error.path).toBe(fixture);
|
|
expect(fixtureRemovalEvidence(error, fixture, { dev: identity.dev, ino: identity.ino + 1n })).toMatchObject({ inspected: false, reason: 'object_identity_changed' });
|
|
expect(fixtureRemovalEvidence(error, path.dirname(root), identity)).toMatchObject({ inspected: false, reason: 'outside_owned_fixture' });
|
|
const missing = path.join(fixture, 'missing');
|
|
expect(fixtureRemovalEvidence(error, missing, identity)).toMatchObject({ inspected: false, inspectionCode: 'ENOENT' });
|
|
const link = path.join(root, 'removal-evidence-link');
|
|
symlinkSync(fixture, link, process.platform === 'win32' ? 'junction' : 'dir');
|
|
expect(fixtureRemovalEvidence(error, link, identity)).toMatchObject({ inspected: false, reason: 'linked_object' });
|
|
unlinkSync(link);
|
|
for (let index = 0; index < 20; index++) writeFileSync(path.join(fixture, String(index)), 'fixture-only');
|
|
const bounded = fixtureRemovalEvidence(error, fixture, identity) as { entries: unknown[]; entriesTruncated: boolean };
|
|
expect(bounded.entries).toHaveLength(16);
|
|
expect(bounded.entriesTruncated).toBe(true);
|
|
});
|
|
|
|
test.each(['unchanged', 'replaced', 'linked', 'missing', 'wrong_path'])('a leaf lock probe is bound to the exact failed object: %s', state => {
|
|
const fixture = mkdtempSync(path.join(root, 'leaf-owner-'));
|
|
const target = path.join(fixture, 'target');
|
|
const nested = path.join(target, 'nested');
|
|
mkdirSync(nested, { recursive: true });
|
|
const file = path.join(nested, 'sensitive-sentinel.sqlite');
|
|
writeFileSync(file, 'fixture-only');
|
|
const identity = lstatSync(target, { bigint: true });
|
|
const fileIdentity = lstatSync(file, { bigint: true });
|
|
const original = Object.assign(new Error('sensitive-sentinel'), { code: 'EBUSY', syscall: 'unlink', errno: -4082, path: file });
|
|
let removals = 0;
|
|
let caught: unknown;
|
|
try {
|
|
removeOwnedFixtureDirectory(target, identity, {
|
|
lstat: candidate => lstatSync(candidate, { bigint: true }), enumerate: candidate => readdirSync(candidate),
|
|
unlink: candidate => { expect(candidate).toBe(file); removals++; throw original; },
|
|
rmdir: () => { throw new Error('Must stop at the first failure'); },
|
|
});
|
|
} catch (error) { caught = error; }
|
|
expect(caught).toBe(original);
|
|
if (state === 'replaced') {
|
|
renameSync(file, path.join(fixture, 'original'));
|
|
writeFileSync(file, 'replacement-only');
|
|
} else if (state === 'linked') {
|
|
const moved = path.join(fixture, 'moved');
|
|
renameSync(nested, moved);
|
|
symlinkSync(moved, nested, process.platform === 'win32' ? 'junction' : 'dir');
|
|
} else if (state === 'missing') unlinkSync(file);
|
|
else if (state === 'wrong_path') original.path = path.join(fixture, 'elsewhere');
|
|
const queried: string[] = [];
|
|
const owners = { available: true, owners: [{ pid: 123, image: 'bun.exe', creationMatched: true, isTestHost: true }] };
|
|
const receipt = fixtureRemovalEvidence(caught, target, identity, (candidate, timeout, expectedIdentity) => {
|
|
queried.push(candidate);
|
|
expect(timeout).toBe(5_000);
|
|
expect(expectedIdentity).toEqual({ dev: fileIdentity.dev.toString(), ino: fileIdentity.ino.toString() });
|
|
return owners;
|
|
});
|
|
expect(receipt).toMatchObject({ code: 'EBUSY', primitive: 'unlink', objectDev: fileIdentity.dev.toString(), objectIno: fileIdentity.ino.toString() });
|
|
expect(queried).toEqual(state === 'unchanged' ? [file] : []);
|
|
if (state === 'unchanged') {
|
|
expect(receipt).toMatchObject({ fileOwners: owners });
|
|
expect(readFileSync(file, 'utf8')).toBe('fixture-only');
|
|
} else if (state !== 'wrong_path') expect(receipt).toMatchObject({ fileOwners: { available: false } });
|
|
else expect(receipt).not.toHaveProperty('fileOwners');
|
|
expect(removals).toBe(1);
|
|
expect(JSON.stringify(receipt)).not.toContain('sensitive-sentinel');
|
|
expect(JSON.stringify(receipt)).not.toContain(fixture);
|
|
expect(caught).toBe(original);
|
|
});
|
|
|
|
test('positive fixture environments create matching Windows known-folder directories', () => {
|
|
const fixture = mkdtempSync(path.join(root, 'environment-'));
|
|
const node = Bun.which('node');
|
|
if (!node) throw new Error('Node is required for fixture setup');
|
|
const environment = nativeFixtureEnvironment(fixture, node);
|
|
expect(environment.USERPROFILE).toBe(fixture);
|
|
expect(environment.LOCALAPPDATA).toBe(path.join(fixture, 'AppData', 'Local'));
|
|
expect(environment.APPDATA).toBe(path.join(fixture, 'AppData', 'Roaming'));
|
|
for (const name of ['LOCALAPPDATA', 'APPDATA', 'TEMP']) expect(lstatSync(environment[name]).isDirectory()).toBe(true);
|
|
expect(environment.TMP).toBe(environment.TEMP);
|
|
});
|
|
|
|
test('a contents reset preserves an existing root even while an owned process holds it', async () => {
|
|
const fixture = mkdtempSync(path.join(root, 'held-root-'));
|
|
const state = lstatSync(fixture, { bigint: true });
|
|
const identity = { path: realpathSync(fixture), dev: state.dev, ino: state.ino };
|
|
const node = Bun.which('node');
|
|
if (!node) throw new Error('Node is required for root ownership verification');
|
|
const child = ownFixtureChild(spawn(node, ['-e', 'process.stdout.write("ready"); setInterval(() => {}, 1000)'], {
|
|
cwd: fixture,
|
|
env: { PATH: path.dirname(node), ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}) },
|
|
stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true,
|
|
}));
|
|
const closed = new Promise<void>(resolve => child.once('close', () => resolve()));
|
|
try {
|
|
await once(child.stdout, 'data', { signal: AbortSignal.timeout(5_000) });
|
|
if (process.platform === 'win32') expect(() => rmdirSync(fixture)).toThrow();
|
|
mkdirSync(path.join(fixture, 'AppData', 'Local'), { recursive: true });
|
|
writeFileSync(path.join(fixture, 'AppData', 'Local', 'synthetic'), 'fixture-only');
|
|
writeFileSync(path.join(fixture, 'wrapper.cjs'), 'fixture-only');
|
|
clearOwnedFixtureContents(fixture, identity);
|
|
expect(readdirSync(fixture)).toEqual([]);
|
|
const after = lstatSync(fixture, { bigint: true });
|
|
expect(after.dev).toBe(identity.dev);
|
|
expect(after.ino).toBe(identity.ino);
|
|
expect(alive(child.pid!)).toBe(true);
|
|
} finally {
|
|
child.kill();
|
|
await closed;
|
|
}
|
|
}, 10_000);
|
|
|
|
test('a contents reset rejects a different root identity without removing entries', () => {
|
|
const fixture = mkdtempSync(path.join(root, 'identity-'));
|
|
const state = lstatSync(fixture, { bigint: true });
|
|
const marker = path.join(fixture, 'preserved');
|
|
writeFileSync(marker, 'fixture-only');
|
|
expect(() => clearOwnedFixtureContents(fixture, { path: realpathSync(fixture), dev: state.dev, ino: state.ino + 1n })).toThrow('identity changed');
|
|
expect(readFileSync(marker, 'utf8')).toBe('fixture-only');
|
|
});
|
|
|
|
test('a contents reset accepts an entry that disappears after enumeration without retrying it', () => {
|
|
const fixture = mkdtempSync(path.join(root, 'disappearing-entry-'));
|
|
const state = lstatSync(fixture, { bigint: true });
|
|
const identity = { path: realpathSync(fixture), dev: state.dev, ino: state.ino };
|
|
const disappearing = path.join(fixture, '00000000-0000-0000-0000-000000000000.tmp');
|
|
writeFileSync(disappearing, 'fixture-only');
|
|
writeFileSync(path.join(fixture, 'remaining'), 'fixture-only');
|
|
let listings = 0;
|
|
clearOwnedFixtureContents(fixture, identity, directory => {
|
|
listings++;
|
|
const entries = readdirSync(directory);
|
|
unlinkSync(disappearing);
|
|
return entries;
|
|
});
|
|
expect(listings).toBe(1);
|
|
expect(readdirSync(fixture)).toEqual([]);
|
|
const after = lstatSync(fixture, { bigint: true });
|
|
expect(after.dev).toBe(identity.dev);
|
|
expect(after.ino).toBe(identity.ino);
|
|
});
|
|
|
|
test('a contents reset still rejects a nonempty postcondition', () => {
|
|
const fixture = mkdtempSync(path.join(root, 'nonempty-reset-'));
|
|
const state = lstatSync(fixture, { bigint: true });
|
|
writeFileSync(path.join(fixture, 'preserved'), 'fixture-only');
|
|
expect(() => clearOwnedFixtureContents(fixture, { path: realpathSync(fixture), dev: state.dev, ino: state.ino }, () => [])).toThrow('empty root');
|
|
expect(readFileSync(path.join(fixture, 'preserved'), 'utf8')).toBe('fixture-only');
|
|
});
|
|
|
|
test('the Node cleanup comparison refuses changed identity and removes only its owned fixture', () => {
|
|
const node = Bun.which('node');
|
|
if (!node) throw new Error('Node is required for cleanup comparison');
|
|
const fixture = mkdtempSync(path.join(root, 'node-cleanup-'));
|
|
const state = lstatSync(fixture, { bigint: true });
|
|
const marker = path.join(fixture, 'preserved');
|
|
writeFileSync(marker, 'fixture-only');
|
|
const invoke = (ino: bigint) => spawnSync(node, [path.resolve(import.meta.dir, 'fixtures/native-cookie-remove-fixture.cjs'), Buffer.from(JSON.stringify({
|
|
root: fixture, realpath: realpathSync(fixture), dev: state.dev.toString(), ino: ino.toString(),
|
|
})).toString('base64')], { env: nativeCookieEnvironment(process.env), encoding: 'utf8', timeout: 5_000, windowsHide: true });
|
|
const refused = invoke(state.ino + 1n);
|
|
expect(refused.status).toBe(1);
|
|
expect(JSON.parse(refused.stdout).reason).toBe('identity_mismatch');
|
|
expect(readFileSync(marker, 'utf8')).toBe('fixture-only');
|
|
const removed = invoke(state.ino);
|
|
expect(removed.status).toBe(0);
|
|
expect(JSON.parse(removed.stdout).removed).toBe(true);
|
|
expect(existsSync(fixture)).toBe(false);
|
|
expect(existsSync(root)).toBe(true);
|
|
}, 15_000);
|
|
|
|
test('success is withheld until the entire job is empty and member exits', async () => {
|
|
const run = simulation({ reply: { cookies: [] }, exitAt: 200 });
|
|
expect(await run.run).toEqual({ cookies: [] });
|
|
expect(run.state()).toMatchObject({ time: 200, active: 0, terminated: 0, stopped: 1, jobClosed: 1, started: 1 });
|
|
});
|
|
|
|
test('a stuck graceful close is forcibly cleaned after two seconds', async () => {
|
|
const run = simulation({ reply: { cookies: [] } });
|
|
expect(await run.run).toEqual({ cookies: [] });
|
|
expect(run.state()).toMatchObject({ active: 0, terminated: 1, jobClosed: 1, started: 1 });
|
|
expect(run.state().time).toBeLessThan(5_000);
|
|
});
|
|
|
|
test('launch/read timeout gets one 25s operation budget and owned cleanup', async () => {
|
|
const run = simulation();
|
|
expect(await run.run).toEqual({ error: 'native_timeout' });
|
|
expect(run.state()).toMatchObject({ active: 0, terminated: 1, jobClosed: 1, started: 1 });
|
|
expect(run.state().time).toBeGreaterThanOrEqual(25_000);
|
|
expect(run.state().time).toBeLessThan(30_000);
|
|
});
|
|
|
|
test.each(['failure', 'success', 'rejection'])('a late member %s cannot overwrite the timeout selected before cleanup', async mode => {
|
|
let now = 0;
|
|
let active = 1;
|
|
let resolveReply!: (reply: NativeCookieReply) => void;
|
|
let rejectReply!: (error: Error) => void;
|
|
let resolveClosed!: () => void;
|
|
const reply = new Promise<NativeCookieReply>((resolve, reject) => { resolveReply = resolve; rejectReply = reject; });
|
|
const closed = new Promise<void>(resolve => { resolveClosed = resolve; });
|
|
const result = await superviseNativeCookieImport(request, {
|
|
now: () => now,
|
|
sleep: async milliseconds => { now += milliseconds; },
|
|
createJob: async () => ({
|
|
name: 'synthetic-owned-job', activeProcesses: () => active, close() {},
|
|
terminate() {
|
|
expect(now).toBe(25_000);
|
|
active = 0;
|
|
if (mode === 'rejection') rejectReply(new Error('synthetic late rejection'));
|
|
else resolveReply(mode === 'success' ? { cookies: [] } : { error: 'native_failed', diagnostic: { stage: 'member_exit' } });
|
|
resolveClosed();
|
|
},
|
|
}),
|
|
startMember: () => ({ result: reply, closed, stop() {} }),
|
|
});
|
|
expect(result).toEqual({ error: 'native_timeout' });
|
|
expect(active).toBe(0);
|
|
expect(now).toBeLessThan(30_000);
|
|
});
|
|
|
|
test('unconfirmed termination is reported as cleanup failure, never success', async () => {
|
|
const run = simulation({ retainAfterTerminate: true });
|
|
expect(await run.run).toEqual({ error: 'native_cleanup_failed' });
|
|
expect(run.state()).toMatchObject({ time: 30_000, terminated: 1, stopped: 1, jobClosed: 1 });
|
|
});
|
|
|
|
test('a process crash cannot strand remaining members', async () => {
|
|
let terminated = 0;
|
|
let active = 1;
|
|
const result = await superviseNativeCookieImport(request, {
|
|
now: () => 0,
|
|
sleep: async () => {},
|
|
createJob: async () => ({ name: 'synthetic', activeProcesses: () => active, terminate: () => { active = 0; terminated++; }, close() {} }),
|
|
startMember: () => ({ result: new Promise(() => {}), closed: Promise.resolve(), stop() {} }),
|
|
});
|
|
expect(result).toEqual({ error: 'native_failed' });
|
|
expect(terminated).toBe(1);
|
|
});
|
|
|
|
test('a failed job close still stops its owned member and cannot report success', async () => {
|
|
let stopped = false;
|
|
const result = await superviseNativeCookieImport(request, {
|
|
now: () => 0,
|
|
sleep: async () => {},
|
|
createJob: async () => ({ name: 'synthetic', activeProcesses: () => 0, terminate() {}, close() { throw new Error('sensitive-close-detail'); } }),
|
|
startMember: () => ({ result: Promise.resolve({ cookies: [] }), closed: Promise.resolve(), stop() { stopped = true; } }),
|
|
});
|
|
expect(result).toEqual({ error: 'native_cleanup_failed', diagnostic: { stage: 'job_close' } });
|
|
expect(stopped).toBe(true);
|
|
});
|
|
|
|
test('locked-profile errors are classified and never retried', async () => {
|
|
const run = simulation({ reply: { error: 'browser_running' } });
|
|
expect(await run.run).toEqual({ error: 'browser_running' });
|
|
expect(run.state()).toMatchObject({ active: 0, terminated: 1, started: 1 });
|
|
});
|
|
|
|
test('loss of the parent channel cancels the operation and cleans the job', async () => {
|
|
const cancellation = new AbortController();
|
|
const run = simulation({ signal: cancellation.signal, cancelAt: () => cancellation.abort() });
|
|
expect(await run.run).toEqual({ error: 'native_failed' });
|
|
expect(run.state()).toMatchObject({ active: 0, terminated: 1, started: 1 });
|
|
expect(run.state().time).toBeLessThan(5_000);
|
|
});
|
|
|
|
test('job initialization failure dispatches no child and hides native errors', async () => {
|
|
let started = false;
|
|
const result = await superviseNativeCookieImport(request, {
|
|
createJob: async () => { throw new Error('sensitive-sentinel'); },
|
|
startMember: () => { started = true; throw new Error('unexpected'); },
|
|
});
|
|
expect(result).toEqual({ error: 'native_supervision_failed', diagnostic: { stage: 'job_create' } });
|
|
expect(started).toBe(false);
|
|
});
|
|
|
|
test.skipIf(process.platform === 'win32')('non-Windows job API fails before any process is launched', async () => {
|
|
await expect(createNativeCookieJob()).rejects.toThrow('native_supervision_unavailable');
|
|
await expect(joinNativeCookieJob('invalid')).rejects.toThrow('native_supervision_failed');
|
|
});
|
|
});
|
|
|
|
function alive(pid: number): boolean {
|
|
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
}
|
|
|
|
function safeNativeEnvelope(output: string): object {
|
|
try {
|
|
const parsed = JSON.parse(output);
|
|
if (Array.isArray(parsed.cookies)) return { cookiesRead: parsed.cookies.length };
|
|
const errors = ['native_timeout', 'native_failed', 'native_cleanup_failed', 'native_supervision_failed', 'browser_running', 'native_profile_unsupported'];
|
|
return { error: errors.includes(parsed.error) ? parsed.error : 'unexpected_reply', diagnostic: parseNativeCookieDiagnostic(parsed.diagnostic) };
|
|
} catch {
|
|
return { error: 'no_complete_reply' };
|
|
}
|
|
}
|
|
|
|
function assertOwnerReceipt(receipt: { available: boolean; owners: { pid: number; creationMatched: boolean; isTestHost: boolean }[] }, pid: number) {
|
|
const before = JSON.stringify(receipt);
|
|
expect(receipt.available).toBe(true);
|
|
expect(Array.isArray(receipt.owners)).toBe(true);
|
|
expect(receipt.owners.some(owner => owner.pid === pid && owner.creationMatched && owner.isTestHost)).toBe(true);
|
|
expect(JSON.stringify(receipt)).toBe(before);
|
|
}
|
|
|
|
function assertCookieReceipt(receipt: NativeCookieReply, evidence: object = {}) {
|
|
if (!('cookies' in receipt) || !Array.isArray(receipt.cookies)) {
|
|
throw new Error(JSON.stringify({ nativeCookieReceipt: safeNativeEnvelope(JSON.stringify(receipt)), ...evidence }));
|
|
}
|
|
return receipt.cookies;
|
|
}
|
|
|
|
function safeLaunchEvidence(file: string): object {
|
|
try {
|
|
const observed = JSON.parse(readFileSync(file, 'utf8'));
|
|
return {
|
|
spawned: Number.isInteger(observed.pid), pipe: observed.args?.includes('--remote-debugging-pipe'),
|
|
argsHash: observed.argsHash, envHash: observed.envHash,
|
|
reasons: observed.reasons, stderrBytes: observed.stderrBytes,
|
|
exitCode: observed.exitCode, signal: observed.signal, spawnError: observed.spawnError,
|
|
runtime: observed.runtime, observedCommandLine: observed.observedCommandLine,
|
|
folderEvidence: observed.folderEvidence,
|
|
};
|
|
} catch {
|
|
return { spawned: false };
|
|
}
|
|
}
|
|
|
|
function nativeSupervisor(input: NativeCookieRequest, env: NodeJS.ProcessEnv) {
|
|
const cleanupDeadline = performance.now() + 30_000;
|
|
const child = ownFixtureChild(spawn(process.execPath, ['--no-env-file', '--no-install', '--no-macros', '--config=NUL', path.resolve(import.meta.dir, '../src/cookie-import-native-worker.ts')], { env, stdio: ['pipe', 'pipe', 'ignore'], windowsHide: true }));
|
|
let output = '';
|
|
child.stdout.on('data', chunk => { output += chunk; });
|
|
const timer = setTimeout(() => child.kill(), 30_000);
|
|
const done = new Promise<NativeCookieReply>((resolve, reject) => {
|
|
child.once('error', reject);
|
|
child.once('close', () => {
|
|
clearTimeout(timer);
|
|
try { resolve(JSON.parse(output)); } catch { reject(new Error('Native supervisor did not return a complete receipt')); }
|
|
});
|
|
});
|
|
void done.catch(() => {});
|
|
child.stdin.on('error', () => {});
|
|
child.stdin.write(JSON.stringify({ ...input, deadline: Date.now() + 25_000, qualifiedBunVersions: [Bun.version] }) + '\n');
|
|
return { child, done, cleanupDeadline, envelope: () => safeNativeEnvelope(output) };
|
|
}
|
|
|
|
describe('native Windows process qualification', () => {
|
|
test.skipIf(process.platform !== 'win32' || process.env.GSTACK_COOKIE_NATIVE_DEFAULT_FIXTURE !== '1')('an exclusively created default Edge profile persists v20 and reimports it through the owned Node worker', async () => {
|
|
if (process.env.GITHUB_ACTIONS !== 'true' || process.env.CI !== 'true') throw new Error('Default-profile qualification requires a disposable GitHub Actions Windows runner');
|
|
const node = Bun.which('node');
|
|
const mapping = nativeBrowserPaths('Edge', process.env);
|
|
const edge = mapping.executables.find(existsSync);
|
|
if (!node || !edge) throw new Error('Native qualification requires Node and installed Microsoft Edge');
|
|
const fixture = mkdtempSync(path.join(root, 'default-edge-'));
|
|
const observation = path.join(fixture, 'default-launch.json');
|
|
const playwrightEntry = path.join(fixture, 'seed-playwright.cjs');
|
|
const require = createRequire(import.meta.url);
|
|
const marker = path.join(mapping.userDataDir, '.gstack-owned-fixture');
|
|
const nonce = randomUUID();
|
|
let owned = false;
|
|
const createdParents: string[] = [];
|
|
let supervisor: ReturnType<typeof nativeSupervisor> | undefined;
|
|
try {
|
|
if (existsSync(mapping.userDataDir)) throw new Error('Refusing an existing Edge default data directory; qualification needs a fresh disposable runner');
|
|
let parent = process.env.LOCALAPPDATA!;
|
|
if (realpathSync(parent).toLowerCase() !== path.resolve(parent).toLowerCase()) throw new Error('Refusing a redirected local-data root');
|
|
for (const segment of ['Microsoft', 'Edge']) {
|
|
parent = path.join(parent, segment);
|
|
if (!existsSync(parent)) { mkdirSync(parent); createdParents.push(parent); }
|
|
if (realpathSync(parent).toLowerCase() !== path.resolve(parent).toLowerCase()) throw new Error('Refusing a redirected default-profile parent');
|
|
}
|
|
mkdirSync(mapping.userDataDir);
|
|
owned = true;
|
|
writeFileSync(marker, nonce, { flag: 'wx', mode: 0o600 });
|
|
writeFileSync(playwrightEntry, `module.exports = require(${JSON.stringify(path.resolve(import.meta.dir, 'fixtures/native-cookie-launch.cjs'))})(${JSON.stringify({
|
|
observation, playwrightEntry: require.resolve('playwright'),
|
|
seedCookie: { name: 'synthetic-native-qualification', value: 'synthetic-only', domain: 'example.test', path: '/', secure: true, httpOnly: true, expires: Math.floor(Date.now() / 1000) + 3600 },
|
|
})});`);
|
|
const input = { ...request, nodeExecutable: node, executablePath: edge, userDataDir: mapping.userDataDir, playwrightEntry };
|
|
const env = nativeCookieEnvironment(process.env);
|
|
supervisor = nativeSupervisor(input, env);
|
|
const seeded = await supervisor.done;
|
|
const seededCookies = assertCookieReceipt(seeded, { realDefaultProfile: true, launch: safeLaunchEvidence(observation) });
|
|
expect(seededCookies).toHaveLength(1);
|
|
const database = new Database(path.join(mapping.userDataDir, 'Default', 'Network', 'Cookies'), { readonly: true });
|
|
try {
|
|
const row = database.query("SELECT hex(substr(encrypted_value, 1, 3)) AS prefix FROM cookies WHERE name = 'synthetic-native-qualification' AND host_key = 'example.test'").get() as { prefix: string } | null;
|
|
expect(row?.prefix).toBe('763230');
|
|
} finally {
|
|
database.close();
|
|
}
|
|
supervisor = nativeSupervisor({ ...input, playwrightEntry: require.resolve('playwright') }, env);
|
|
const imported = await supervisor.done;
|
|
const importedCookies = assertCookieReceipt(imported);
|
|
expect(importedCookies).toHaveLength(1);
|
|
expect(importedCookies[0].name).toBe('synthetic-native-qualification');
|
|
expect(importedCookies[0].value).toBe('synthetic-only');
|
|
expect(importedCookies[0].domain).toBe('example.test');
|
|
} finally {
|
|
supervisor?.child.kill();
|
|
await supervisor?.done.catch(() => {});
|
|
if (owned) {
|
|
if (realpathSync(mapping.userDataDir).toLowerCase() !== path.resolve(mapping.userDataDir).toLowerCase() || readFileSync(marker, 'utf8') !== nonce) throw new Error('Default fixture ownership changed; refusing cleanup');
|
|
rmSync(mapping.userDataDir, { recursive: true, force: true });
|
|
}
|
|
for (const parent of createdParents.reverse()) rmdirSync(parent);
|
|
}
|
|
}, 65_000);
|
|
|
|
test.skipIf(process.platform !== 'win32')('a locked real Edge profile leaves its existing owner alive', async () => {
|
|
const node = Bun.which('node');
|
|
const edge = nativeBrowserPaths('Edge', process.env).executables.find(existsSync);
|
|
if (!node || !edge) throw new Error('Native qualification requires Node and installed Microsoft Edge');
|
|
const fixture = mkdtempSync(path.join(root, 'locked-edge-'));
|
|
const marker = path.join(fixture, 'owner-ready.json');
|
|
const contenderObservation = path.join(fixture, 'contender-launch.json');
|
|
const contenderEntry = path.join(fixture, 'contender-playwright.cjs');
|
|
const playwrightEntry = path.join(fixture, 'held-playwright.cjs');
|
|
const require = createRequire(import.meta.url);
|
|
writeFileSync(contenderEntry, `module.exports = require(${JSON.stringify(path.resolve(import.meta.dir, 'fixtures/native-cookie-launch.cjs'))})(${JSON.stringify({ observation: contenderObservation, playwrightEntry: require.resolve('playwright') })});`);
|
|
writeFileSync(playwrightEntry, `
|
|
const cp = require('node:child_process');
|
|
const spawn = cp.spawn;
|
|
let pid;
|
|
cp.spawn = function(command, args, options) {
|
|
if (args.some(arg => /^--(?:no-sandbox|disable-setuid-sandbox)(?:=|$)/.test(arg))) throw new Error('Native owner fixture refuses a sandbox-disabled browser');
|
|
const child = spawn.call(this, command, args, options); pid = child.pid; return child;
|
|
};
|
|
const { chromium } = require(${JSON.stringify(require.resolve('playwright'))});
|
|
exports.chromium = { async launchPersistentContext(root, options) {
|
|
const context = await chromium.launchPersistentContext(root, options);
|
|
require('node:fs').writeFileSync(${JSON.stringify(marker)}, JSON.stringify({ pid }));
|
|
context.cookies = () => new Promise(() => {});
|
|
return context;
|
|
} };
|
|
`);
|
|
const env = nativeFixtureEnvironment(fixture, node);
|
|
const input = { ...request, nodeExecutable: node, executablePath: edge, userDataDir: path.join(fixture, 'User Data'), playwrightEntry };
|
|
const owner = nativeSupervisor(input, env);
|
|
let contender: ReturnType<typeof nativeSupervisor> | undefined;
|
|
try {
|
|
const readyBy = Date.now() + 10_000;
|
|
while (!existsSync(marker) && Date.now() < readyBy) await Bun.sleep(20);
|
|
expect({ ready: existsSync(marker), reply: owner.envelope() }).toMatchObject({ ready: true });
|
|
const { pid } = JSON.parse(readFileSync(marker, 'utf8'));
|
|
contender = nativeSupervisor({ ...input, playwrightEntry: contenderEntry }, env);
|
|
expect({ result: await contender.done, launch: safeLaunchEvidence(contenderObservation) }).toMatchObject({ result: { error: 'browser_running' } });
|
|
expect(alive(pid)).toBe(true);
|
|
} finally {
|
|
contender?.child.kill();
|
|
owner.child.kill();
|
|
await contender?.done.catch(() => {});
|
|
await owner.done.catch(() => {});
|
|
}
|
|
}, 35_000);
|
|
|
|
for (const mode of ['normal-close', 'stalled-close']) {
|
|
test.skipIf(process.platform !== 'win32')(`real Edge synthetic profile: ${mode} returns only after the owned browser exits`, async () => {
|
|
const node = Bun.which('node');
|
|
const edge = nativeBrowserPaths('Edge', process.env).executables.find(existsSync);
|
|
if (!node || !edge) throw new Error('Native qualification requires Node and installed Microsoft Edge');
|
|
const fixture = mkdtempSync(path.join(root, 'edge-'));
|
|
const observation = path.join(fixture, 'browser.json');
|
|
const playwrightEntry = path.join(fixture, 'observed-playwright.cjs');
|
|
const require = createRequire(import.meta.url);
|
|
writeFileSync(playwrightEntry, `module.exports = require(${JSON.stringify(path.resolve(import.meta.dir, 'fixtures/native-cookie-launch.cjs'))})(${JSON.stringify({ observation, playwrightEntry: require.resolve('playwright'), mode })});`);
|
|
const environment = nativeFixtureEnvironment(fixture, node);
|
|
const supervisor = ownFixtureChild(spawn(process.execPath, ['--no-env-file', '--no-install', '--no-macros', '--config=NUL', path.resolve(import.meta.dir, '../src/cookie-import-native-worker.ts')], { env: environment, stdio: ['pipe', 'pipe', 'ignore'], windowsHide: true }));
|
|
const closed = new Promise<void>(resolve => supervisor.once('close', () => resolve()));
|
|
let output = '';
|
|
supervisor.stdout.on('data', chunk => { output += chunk; });
|
|
const started = Date.now();
|
|
const deadline = setTimeout(() => supervisor.kill(), 30_000);
|
|
supervisor.stdin.write(JSON.stringify({ ...request, nodeExecutable: node, playwrightEntry, executablePath: edge, userDataDir: path.join(fixture, 'User Data'), deadline: started + 25_000, qualifiedBunVersions: [Bun.version] }) + '\n');
|
|
try {
|
|
await closed;
|
|
const result = JSON.parse(output);
|
|
const cookies = assertCookieReceipt(result, { launch: safeLaunchEvidence(observation) });
|
|
expect(cookies).toHaveLength(1);
|
|
expect(cookies[0].domain).toBe('example.test');
|
|
const browser = JSON.parse(readFileSync(observation, 'utf8'));
|
|
expect(browser.command).toBe(edge);
|
|
expect(browser.args).toContain('--remote-debugging-pipe');
|
|
expect(browser.args.some((arg: string) => arg.startsWith('--remote-debugging-port'))).toBe(false);
|
|
expect(browser.args.some((arg: string) => /^--(?:no-sandbox|disable-setuid-sandbox)(?:=|$)/.test(arg))).toBe(false);
|
|
expect(alive(browser.pid)).toBe(false);
|
|
expect(Date.now() - started).toBeLessThan(30_000);
|
|
} finally {
|
|
clearTimeout(deadline);
|
|
supervisor.kill();
|
|
await closed;
|
|
}
|
|
}, 35_000);
|
|
}
|
|
|
|
for (const mode of ['timeout', 'owner-exit', 'worker-crash']) {
|
|
test.skipIf(process.platform !== 'win32')(`${mode} kills owned descendants and preserves an unrelated process`, async () => {
|
|
const node = Bun.which('node');
|
|
if (!node) throw new Error('Node is required for Windows qualification');
|
|
const fixture = mkdtempSync(path.join(root, 'native-'));
|
|
const pids = path.join(fixture, 'owned.json');
|
|
const playwrightEntry = path.join(fixture, 'playwright.cjs');
|
|
writeFileSync(playwrightEntry, `module.exports = require(${JSON.stringify(path.resolve(import.meta.dir, 'fixtures/native-cookie-process.cjs'))})(${JSON.stringify({ pidsFile: pids, mode })});`);
|
|
const environment = nativeFixtureEnvironment(fixture, node);
|
|
const sibling = ownFixtureChild(spawn(node, ['-e', 'setInterval(() => {}, 1000)'], { env: environment, stdio: 'ignore', windowsHide: true }));
|
|
const siblingClosed = new Promise<void>(resolve => sibling.once('close', () => resolve()));
|
|
const supervisor = ownFixtureChild(spawn(process.execPath, ['--no-env-file', '--no-install', '--no-macros', '--config=NUL', path.resolve(import.meta.dir, '../src/cookie-import-native-worker.ts')], { env: environment, stdio: ['pipe', 'pipe', 'ignore'], windowsHide: true }));
|
|
const closed = new Promise<void>(resolve => supervisor.once('close', () => resolve()));
|
|
let output = '';
|
|
supervisor.stdout.on('data', chunk => { output += chunk; });
|
|
const started = Date.now();
|
|
supervisor.stdin.write(JSON.stringify({ ...request, nodeExecutable: node, playwrightEntry, userDataDir: fixture, deadline: started + (mode === 'timeout' ? 3_000 : 25_000), qualifiedBunVersions: [Bun.version] }) + '\n');
|
|
try {
|
|
while (!existsSync(pids) && Date.now() - started < 2_500) await Bun.sleep(20);
|
|
expect({ ready: existsSync(pids), reply: safeNativeEnvelope(output) }).toMatchObject({ ready: true });
|
|
const owned = JSON.parse(readFileSync(pids, 'utf8')) as number[];
|
|
if (mode === 'owner-exit') supervisor.kill();
|
|
await closed;
|
|
const cleanupDeadline = Date.now() + 5_000;
|
|
while (owned.some(alive) && Date.now() < cleanupDeadline) await Bun.sleep(20);
|
|
expect(owned.some(alive)).toBe(false);
|
|
expect(alive(sibling.pid!)).toBe(true);
|
|
if (mode === 'timeout') {
|
|
expect(JSON.parse(output)).toEqual({ error: 'native_timeout' });
|
|
expect(Date.now() - started).toBeLessThan(8_000);
|
|
}
|
|
if (mode === 'worker-crash') expect(JSON.parse(output)).toMatchObject({ error: 'native_failed' });
|
|
} finally {
|
|
supervisor.kill();
|
|
sibling.kill();
|
|
await Promise.all([closed, siblingClosed]);
|
|
}
|
|
}, 15_000);
|
|
}
|
|
});
|
|
|
|
describe('native Windows launch diagnostics', () => {
|
|
for (const kind of ['file', 'directory'] as const) {
|
|
test.skipIf(process.platform !== 'win32')(`single-pass removal preserves a real ${kind} delete-sharing conflict`, () => {
|
|
const fixture = mkdtempSync(path.join(root, 'removal-lock-'));
|
|
const target = path.join(fixture, 'target');
|
|
const sibling = path.join(fixture, 'preserved');
|
|
mkdirSync(target);
|
|
writeFileSync(sibling, 'unrelated-fixture-only');
|
|
const locked = kind === 'file' ? path.join(target, 'held') : target;
|
|
if (kind === 'file') writeFileSync(locked, 'held-fixture-only');
|
|
const identity = lstatSync(target, { bigint: true });
|
|
const lockedIdentity = lstatSync(locked, { bigint: true });
|
|
expect(realpathSync(target)).toBe(path.join(realpathSync(fixture), 'target'));
|
|
expect(lockedIdentity.isSymbolicLink()).toBe(false);
|
|
const kernel = dlopen('kernel32.dll', {
|
|
CreateFileW: { args: [FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.u64], returns: FFIType.u64 },
|
|
CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 },
|
|
});
|
|
const name = Buffer.from(locked + '\0', 'utf16le');
|
|
const handle = kernel.symbols.CreateFileW(ptr(name), 0x80000000, 3, null, 3, kind === 'directory' ? 0x02000000 : 0x80, 0);
|
|
try {
|
|
expect(BigInt(handle)).not.toBe(0xffffffffffffffffn);
|
|
expect(BigInt(handle)).not.toBe(0n);
|
|
let caught: unknown;
|
|
try { removeOwnedFixtureDirectory(target, identity); }
|
|
catch (error) { caught = error; }
|
|
expect(caught).toBeInstanceOf(Error);
|
|
expect((caught as NodeJS.ErrnoException).code).toBe('EBUSY');
|
|
const primitive = kind === 'file' ? 'unlink' : 'rmdir';
|
|
expect((caught as NodeJS.ErrnoException).syscall).toBe(primitive);
|
|
const receipt = fixtureRemovalEvidence(caught, target, identity);
|
|
expect(receipt).toMatchObject({ primitive, rootIdentityMatched: true, objectIdentityMatched: true, directoryIdentityMatched: true, relativeObjectHash: createHash('sha256').update(kind === 'file' ? 'held' : '').digest('hex') });
|
|
if (kind === 'file') {
|
|
const owners = (receipt as { fileOwners: { available: boolean; owners: { pid: number; creationMatched: boolean; isTestHost: boolean }[] } }).fileOwners;
|
|
assertOwnerReceipt(owners, process.pid);
|
|
}
|
|
console.log(JSON.stringify({ nativeFixtureOwnedLockControl: { kind, ...receipt } }));
|
|
expect(lstatSync(locked, { bigint: true }).ino).toBe(lockedIdentity.ino);
|
|
expect(lstatSync(locked, { bigint: true }).dev).toBe(lockedIdentity.dev);
|
|
if (kind === 'file') expect(readFileSync(locked, 'utf8')).toBe('held-fixture-only');
|
|
else expect(readdirSync(locked)).toEqual([]);
|
|
expect(readFileSync(sibling, 'utf8')).toBe('unrelated-fixture-only');
|
|
} finally {
|
|
try {
|
|
if (BigInt(handle) !== 0xffffffffffffffffn && BigInt(handle) !== 0n) expect(kernel.symbols.CloseHandle(handle)).toBe(1);
|
|
} finally { kernel.close(); }
|
|
}
|
|
removeOwnedFixtureDirectory(target, identity);
|
|
expect(existsSync(target)).toBe(false);
|
|
expect(readFileSync(sibling, 'utf8')).toBe('unrelated-fixture-only');
|
|
}, 10_000);
|
|
}
|
|
|
|
test.each(['resolve_root', 'resolve_file'])('file-owner diagnostics preserve the %s filesystem failure without exposing its path', stage => {
|
|
const fixture = mkdtempSync(path.join(root, 'owner-stage-'));
|
|
const missing = path.join(fixture, 'sensitive-sentinel');
|
|
const input = { root: stage === 'resolve_root' ? missing : fixture, file: missing, testPid: process.pid };
|
|
const script = `
|
|
await import('node:fs');
|
|
await import('node:path');
|
|
Object.defineProperty(process, 'platform', { value: 'win32' });
|
|
process.argv = [process.execPath, 'fixture', ${JSON.stringify(Buffer.from(JSON.stringify(input)).toString('base64'))}];
|
|
await import(${JSON.stringify(path.resolve(import.meta.dir, 'fixtures/native-cookie-file-owners.ts'))});
|
|
`;
|
|
const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, '-e', script], {
|
|
env: { TEMP: fixture, TMP: fixture, HOME: fixture, USERPROFILE: fixture, ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}) },
|
|
encoding: 'utf8', timeout: 10_000,
|
|
});
|
|
expect(result.status).toBe(0);
|
|
expect(result.stderr).toBe('');
|
|
expect(JSON.parse(result.stdout)).toEqual({ available: false, reason: 'owner_query_failed', stage, errorCode: 'ENOENT' });
|
|
expect(result.stdout).not.toContain('sensitive-sentinel');
|
|
});
|
|
|
|
test.skipIf(process.platform !== 'win32')('Restart Manager identifies the exact fixture file holder without stopping it', () => {
|
|
const fixture = mkdtempSync(path.join(root, 'file-owner-'));
|
|
const file = path.join(fixture, 'held.tmp');
|
|
writeFileSync(file, 'fixture-only');
|
|
const kernel = dlopen('kernel32.dll', {
|
|
CreateFileW: { args: [FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.u64], returns: FFIType.u64 },
|
|
CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 },
|
|
});
|
|
const name = Buffer.from(file + '\0', 'utf16le');
|
|
const handle = kernel.symbols.CreateFileW(ptr(name), 0x80000000, 1, null, 3, 0x80, 0);
|
|
try {
|
|
expect(BigInt(handle)).not.toBe(0xffffffffffffffffn);
|
|
expect(() => unlinkSync(file)).toThrow();
|
|
const evidence = fixtureFileOwners(file) as { available: boolean; owners: { pid: number; image: string; creationMatched: boolean; isTestHost: boolean }[] };
|
|
expect(evidence.available).toBe(true);
|
|
expect(Array.isArray(evidence.owners)).toBe(true);
|
|
expect(evidence.owners.some(owner => owner.pid === process.pid && owner.image === 'bun.exe' && owner.creationMatched && owner.isTestHost)).toBe(true);
|
|
expect(readFileSync(file, 'utf8')).toBe('fixture-only');
|
|
} finally {
|
|
if (BigInt(handle) !== 0xffffffffffffffffn) kernel.symbols.CloseHandle(handle);
|
|
kernel.close();
|
|
}
|
|
}, 10_000);
|
|
|
|
test('the owner-probe child rejects a changed file identity before loading native APIs', () => {
|
|
const fixture = mkdtempSync(path.join(root, 'owner-identity-'));
|
|
const file = path.join(fixture, 'sensitive-sentinel');
|
|
writeFileSync(file, 'fixture-only');
|
|
const identity = lstatSync(file, { bigint: true });
|
|
const input = { root: fixture, file, testPid: process.pid,
|
|
expectedIdentity: { dev: identity.dev.toString(), ino: (identity.ino + 1n).toString() } };
|
|
const script = `
|
|
await import('node:fs');
|
|
await import('node:path');
|
|
Object.defineProperty(process, 'platform', { value: 'win32' });
|
|
process.argv = [process.execPath, 'fixture', ${JSON.stringify(Buffer.from(JSON.stringify(input)).toString('base64'))}];
|
|
await import(${JSON.stringify(path.resolve(import.meta.dir, 'fixtures/native-cookie-file-owners.ts'))});
|
|
`;
|
|
const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, '-e', script], {
|
|
env: { TEMP: fixture, TMP: fixture, HOME: fixture, USERPROFILE: fixture, ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}) },
|
|
encoding: 'utf8', timeout: 10_000,
|
|
});
|
|
expect(result.status).toBe(0);
|
|
expect(result.stderr).toBe('');
|
|
expect(JSON.parse(result.stdout)).toEqual({ available: false, reason: 'failed_object_identity_changed' });
|
|
expect(result.stdout).not.toContain('sensitive-sentinel');
|
|
expect(readFileSync(file, 'utf8')).toBe('fixture-only');
|
|
});
|
|
|
|
test('receipt assertions preserve the cookie array and all cookie fields', () => {
|
|
const receipt: NativeCookieReply = { cookies: [{ name: 'synthetic', value: 'synthetic', domain: 'example.test', path: '/', expires: -1, httpOnly: true, secure: true, sameSite: 'Lax' }] };
|
|
const before = JSON.stringify(receipt);
|
|
const cookies = receipt.cookies;
|
|
Object.freeze(receipt);
|
|
Object.freeze(cookies);
|
|
Object.freeze(cookies[0]);
|
|
expect(assertCookieReceipt(receipt)).toBe(cookies);
|
|
expect(Array.isArray(receipt.cookies)).toBe(true);
|
|
expect(JSON.stringify(receipt)).toBe(before);
|
|
expect(() => assertCookieReceipt({ error: 'native_timeout' })).toThrow('native_timeout');
|
|
});
|
|
|
|
test('holder assertions preserve the real owner array and reject nonmatching identities', () => {
|
|
const receipt = { available: true, owners: [{ pid: 123, image: 'bun.exe', creationMatched: true, isTestHost: true }] };
|
|
const before = JSON.stringify(receipt);
|
|
const owners = receipt.owners;
|
|
Object.freeze(receipt);
|
|
Object.freeze(owners);
|
|
Object.freeze(owners[0]);
|
|
assertOwnerReceipt(receipt, 123);
|
|
expect(receipt.owners).toBe(owners);
|
|
expect(Array.isArray(receipt.owners)).toBe(true);
|
|
expect(JSON.stringify(receipt)).toBe(before);
|
|
expect(() => assertOwnerReceipt(receipt, 124)).toThrow();
|
|
expect(() => assertOwnerReceipt({ available: true, owners: [{ pid: 123, creationMatched: false, isTestHost: true }] }, 123)).toThrow();
|
|
});
|
|
|
|
test('native Unicode output retains the exact allocation address for short and long buffers', () => {
|
|
const library = dlopen(process.platform === 'win32' ? 'msvcrt.dll' : process.platform === 'darwin' ? '/usr/lib/libSystem.B.dylib' : 'libc.so.6', {
|
|
memcpy: { args: [FFIType.ptr, FFIType.ptr, FFIType.u64], returns: FFIType.ptr },
|
|
});
|
|
try {
|
|
for (const length of [32, 128, 512, 1024, 4096]) {
|
|
const target = Buffer.alloc(length);
|
|
const address = ptr(target);
|
|
const source = Buffer.alloc(length);
|
|
source.writeUInt16LE(4, 0);
|
|
source.writeBigUInt64LE(BigInt(address) + 16n, 8);
|
|
source.write('ab', 16, 'utf16le');
|
|
library.symbols.memcpy(address, ptr(source), length);
|
|
expect(decodeNativeCommandLine(target, address)).toBe('ab');
|
|
target.writeBigUInt64LE(BigInt(address) + BigInt(length), 8);
|
|
expect(decodeNativeCommandLine(target, address)).toBeNull();
|
|
}
|
|
} finally {
|
|
library.close();
|
|
}
|
|
});
|
|
|
|
test.skipIf(process.platform !== 'win32')('the native observer reads only the owned process and preserves Windows argument boundaries', async () => {
|
|
const node = Bun.which('node');
|
|
if (!node) throw new Error('Node is required for process metadata verification');
|
|
const environment = nativeCookieEnvironment(process.env);
|
|
const args = ['-e', 'setInterval(() => {}, 1000)', '--', 'argument with spaces', '--user-data-dir=C:\\synthetic sensitive-sentinel'];
|
|
const child = ownFixtureChild(spawn(node, args, { env: environment, stdio: 'ignore', windowsHide: true }));
|
|
const closed = new Promise<void>(resolve => child.once('close', () => resolve()));
|
|
const observe = (owner: number) => spawnSync(process.execPath, [
|
|
'--no-env-file', '--no-install', '--no-macros', '--config=NUL', path.resolve(import.meta.dir, 'fixtures/native-cookie-process-observer.ts'),
|
|
Buffer.from(JSON.stringify({ pid: child.pid, owner, image: node })).toString('base64'),
|
|
], { env: environment, encoding: 'utf8', timeout: 5_000, windowsHide: true });
|
|
try {
|
|
const denied = observe(process.pid + 1);
|
|
expect(denied.status).toBe(0);
|
|
expect(JSON.parse(denied.stdout)).toMatchObject({ available: false, reason: 'owned_process_unavailable', parentMatched: false });
|
|
const observed = observe(process.pid);
|
|
expect(observed.status).toBe(0);
|
|
expect(observed.stderr).toBe('');
|
|
expect(observed.stdout).not.toContain('sensitive-sentinel');
|
|
expect(JSON.parse(observed.stdout)).toMatchObject({
|
|
available: true, parentMatched: true, imageMatched: true,
|
|
argumentHashes: args.map(arg => createHash('sha256').update(arg).digest('hex')),
|
|
userDataDirCount: 1,
|
|
});
|
|
} finally {
|
|
child.kill();
|
|
await closed;
|
|
}
|
|
}, 15_000);
|
|
|
|
test.skipIf(process.platform !== 'win32')('the known-folder observer reports hashes and statuses without exposing profile paths', () => {
|
|
const result = spawnSync(process.execPath, [
|
|
'--no-env-file', '--no-install', '--no-macros', '--config=NUL', path.resolve(import.meta.dir, 'fixtures/native-cookie-process-observer.ts'),
|
|
Buffer.from(JSON.stringify({ mode: 'known-folders' })).toString('base64'),
|
|
], { env: nativeCookieEnvironment(process.env), encoding: 'utf8', timeout: 5_000, windowsHide: true });
|
|
expect(result.status).toBe(0);
|
|
expect(result.stderr).toBe('');
|
|
const resultObject = JSON.parse(result.stdout);
|
|
expect(resultObject).toMatchObject({ available: true });
|
|
for (const name of ['local', 'roaming']) {
|
|
expect(Number.isInteger(resultObject.knownFolders[name].verified.hresult)).toBe(true);
|
|
expect(resultObject.knownFolders[name].dontVerify.pathHash).toMatch(/^[a-f0-9]{64}$/);
|
|
}
|
|
if (process.env.USERPROFILE) expect(result.stdout).not.toContain(process.env.USERPROFILE);
|
|
});
|
|
|
|
test.skipIf(process.platform !== 'win32')('the qualification version command reads the explicit executable environment variable', () => {
|
|
const node = Bun.which('node');
|
|
if (!node) throw new Error('Node is required for version metadata verification');
|
|
const powershell = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
const env = { ...nativeCookieEnvironment(process.env), GSTACK_QUALIFY_BROWSER_EXE: node };
|
|
const old = spawnSync(powershell, ['-NoProfile', '-NonInteractive', '-Command', NATIVE_BROWSER_VERSION_COMMAND.replace('$env:GSTACK_QUALIFY_BROWSER_EXE', '$env.GSTACK_QUALIFY_BROWSER_EXE')], {
|
|
env, encoding: 'utf8', timeout: 10_000, windowsHide: true,
|
|
});
|
|
expect(old.status).not.toBe(0);
|
|
expect(old.stdout.trim()).toBe('');
|
|
const result = spawnSync(powershell, ['-NoProfile', '-NonInteractive', '-Command', NATIVE_BROWSER_VERSION_COMMAND], {
|
|
env, encoding: 'utf8', timeout: 10_000, windowsHide: true,
|
|
});
|
|
expect(result.status).toBe(0);
|
|
expect(result.stderr).toBe('');
|
|
expect(result.stdout.trim()).toMatch(/^\d+(?:\.\d+){2,3}$/);
|
|
}, 25_000);
|
|
|
|
test('the synthetic launch observer records safe reasons without raw stderr or environment values', () => {
|
|
const node = Bun.which('node');
|
|
if (!node) throw new Error('Node is required for launch diagnostics');
|
|
const fixture = mkdtempSync(path.join(root, 'launch-observer-'));
|
|
const observation = path.join(fixture, 'launch.json');
|
|
const playwrightEntry = path.join(fixture, 'fake-playwright.cjs');
|
|
writeFileSync(playwrightEntry, `
|
|
exports.chromium = { async launchPersistentContext() {
|
|
const child = require('node:child_process').spawn('synthetic-browser.exe', ['--remote-debugging-pipe'], { env: { SYNTHETIC: 'sensitive-sentinel' } });
|
|
child.stderr.write('AssignProcessToJobObject ERROR_ACCESS_DENIED sensitive-sentinel');
|
|
child.emit('exit', 5, null);
|
|
return { addCookies: async () => {} };
|
|
} };
|
|
`);
|
|
const script = `
|
|
const cp = require('node:child_process');
|
|
const child = new (require('node:events').EventEmitter)();
|
|
child.pid = 12345;
|
|
child.stderr = new (require('node:stream').PassThrough)();
|
|
cp.spawn = () => child;
|
|
const api = require(${JSON.stringify(path.resolve(import.meta.dir, 'fixtures/native-cookie-launch.cjs'))})(${JSON.stringify({ observation, playwrightEntry })});
|
|
api.chromium.launchPersistentContext('synthetic-profile', { chromiumSandbox: true }).then(() => console.log('observed'));
|
|
`;
|
|
const result = spawnSync(node, ['-e', script], {
|
|
env: { PATH: path.dirname(node), TEMP: fixture, TMP: fixture, HOME: fixture, USERPROFILE: fixture, ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}) },
|
|
encoding: 'utf8', timeout: 10_000,
|
|
});
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout.trim()).toBe('observed');
|
|
expect(result.stderr).toBe('');
|
|
const observed = JSON.parse(readFileSync(observation, 'utf8'));
|
|
expect(observed).toMatchObject({ exitCode: 5, reasons: ['job_assignment_failed', 'permission_denied'] });
|
|
expect(observed.argsHash).toMatch(/^[a-f0-9]{64}$/);
|
|
expect(observed.envHash).toMatch(/^[a-f0-9]{64}$/);
|
|
expect(observed.runtime.node).toMatch(/^v\d+\.\d+\.\d+/);
|
|
expect(observed.runtime.bun).toBeNull();
|
|
expect(JSON.stringify(observed)).not.toContain('sensitive-sentinel');
|
|
});
|
|
|
|
for (const layout of ['flat', 'folders-ready']) {
|
|
test.skipIf(process.platform !== 'win32' || process.env.GITHUB_ACTIONS !== 'true')(`compares contained and direct Node Edge launch with identical argv and environment (${layout} profile layout)`, async () => {
|
|
const node = Bun.which('node');
|
|
const edge = nativeBrowserPaths('Edge', process.env).executables.find(existsSync);
|
|
if (!node || !edge) throw new Error('Native launch comparison requires Node and installed Microsoft Edge');
|
|
const fixture = mkdtempSync(path.join(root, 'edge-comparison-'));
|
|
const userDataDir = path.join(fixture, 'User Data');
|
|
const observation = path.join(fixture, 'launch.json');
|
|
const playwrightEntry = path.join(fixture, 'observed-playwright.cjs');
|
|
const require = createRequire(import.meta.url);
|
|
writeFileSync(playwrightEntry, `module.exports = require(${JSON.stringify(path.resolve(import.meta.dir, 'fixtures/native-cookie-launch.cjs'))})(${JSON.stringify({ observation, playwrightEntry: require.resolve('playwright'), inspectCommandLine: true, observerExecutable: process.execPath })});`);
|
|
if (layout !== 'flat') {
|
|
mkdirSync(path.join(fixture, 'AppData', 'Local'), { recursive: true });
|
|
mkdirSync(path.join(fixture, 'AppData', 'Roaming'), { recursive: true });
|
|
}
|
|
const environment = nativeCookieEnvironment({ SystemRoot: process.env.SystemRoot!, TEMP: fixture, TMP: fixture, USERPROFILE: fixture, LOCALAPPDATA: fixture, APPDATA: fixture, PATH: path.dirname(node) });
|
|
const input = { ...request, nodeExecutable: node, executablePath: edge, userDataDir, playwrightEntry };
|
|
const supervisor = nativeSupervisor(input, environment);
|
|
const contained = await supervisor.done;
|
|
const containedLaunch = safeLaunchEvidence(observation);
|
|
console.log(JSON.stringify({ nativeEdgeBeforeReset: { comparison: 'launch', layout, contained: 'error' in contained ? contained : { cookiesRead: contained.cookies.length }, containedLaunch, pendingChildCloses: fixtureChildren.size } }));
|
|
if ('error' in contained && contained.error === 'native_cleanup_failed') throw new Error('Contained cleanup was not confirmed; direct comparison refused');
|
|
if (existsSync(userDataDir)) {
|
|
if (realpathSync(userDataDir).toLowerCase() !== path.resolve(userDataDir).toLowerCase()) throw new Error('Synthetic profile ownership changed; comparison refused');
|
|
const identity = lstatSync(userDataDir, { bigint: true });
|
|
try { resetOwnedProfileDirectory(userDataDir, identity, supervisor.cleanupDeadline); }
|
|
catch (error) {
|
|
console.error(JSON.stringify({ nativeFixtureRemovalFailure: { stage: 'launch_reset', layout, ...fixtureRemovalEvidence(error, userDataDir, identity) } }));
|
|
throw error;
|
|
}
|
|
}
|
|
const containedObservation = existsSync(observation) ? JSON.parse(readFileSync(observation, 'utf8')) : null;
|
|
rmSync(observation, { force: true });
|
|
const direct = spawnSync(node, ['--input-type=commonjs', '-e', NATIVE_COOKIE_NODE_SCRIPT], {
|
|
env: environment, input: JSON.stringify({ ...input, deadline: Date.now() + 25_000 }),
|
|
encoding: 'utf8', timeout: 30_000, windowsHide: true,
|
|
});
|
|
const directObservation = existsSync(observation) ? JSON.parse(readFileSync(observation, 'utf8')) : null;
|
|
console.log(JSON.stringify({
|
|
nativeEdgeLaunchComparison: {
|
|
layout,
|
|
contained: 'error' in contained ? contained : { cookiesRead: contained.cookies.length },
|
|
containedLaunch, direct: safeNativeEnvelope(direct.stdout || ''), directStatus: direct.status,
|
|
directLaunch: safeLaunchEvidence(observation),
|
|
argvEqual: containedObservation?.argsHash === directObservation?.argsHash,
|
|
environmentEqual: containedObservation?.envHash === directObservation?.envHash,
|
|
observedCommandLinesEqual: containedObservation?.observedCommandLine?.available === true && directObservation?.observedCommandLine?.available === true
|
|
? containedObservation.observedCommandLine.commandLineHash === directObservation.observedCommandLine.commandLineHash : null,
|
|
},
|
|
}));
|
|
expect(direct.error).toBeUndefined();
|
|
expect(direct.status).toBe(0);
|
|
assertCookieReceipt(JSON.parse(direct.stdout));
|
|
expect(directObservation).not.toBeNull();
|
|
expect(alive(directObservation.pid)).toBe(false);
|
|
expect(containedObservation?.argsHash).toBe(directObservation.argsHash);
|
|
expect(containedObservation?.envHash).toBe(directObservation.envHash);
|
|
expect(containedObservation?.observedCommandLine).toMatchObject({ available: true });
|
|
expect(directObservation.observedCommandLine).toMatchObject({ available: true });
|
|
expect(containedObservation.folderEvidence.directoriesAfterProbe).toEqual(containedObservation.folderEvidence.directoriesBefore);
|
|
expect(directObservation.folderEvidence.directoriesAfterProbe).toEqual(directObservation.folderEvidence.directoriesBefore);
|
|
}, 65_000);
|
|
}
|
|
|
|
for (const state of ['preserved', 'fresh']) {
|
|
test.skipIf(process.platform !== 'win32' || process.env.GITHUB_ACTIONS !== 'true')(`separates contained launch initialization from Job membership (${state} filesystem)`, async () => {
|
|
const node = Bun.which('node');
|
|
const edge = nativeBrowserPaths('Edge', process.env).executables.find(existsSync);
|
|
if (!node || !edge) throw new Error('Native initialization comparison requires Node and installed Microsoft Edge');
|
|
const fixture = mkdtempSync(path.join(root, 'edge-initialization-'));
|
|
const ownedRoot = realpathSync(fixture);
|
|
const rootState = lstatSync(fixture, { bigint: true });
|
|
const rootIdentity = { path: ownedRoot, dev: rootState.dev, ino: rootState.ino };
|
|
const userDataDir = path.join(fixture, 'User Data');
|
|
const observation = path.join(fixture, 'launch.json');
|
|
const playwrightEntry = path.join(fixture, 'observed-playwright.cjs');
|
|
const require = createRequire(import.meta.url);
|
|
const wrapper = `module.exports = require(${JSON.stringify(path.resolve(import.meta.dir, 'fixtures/native-cookie-launch.cjs'))})(${JSON.stringify({ observation, playwrightEntry: require.resolve('playwright'), inspectCommandLine: true, observerExecutable: process.execPath })});`;
|
|
const environment = nativeCookieEnvironment({ SystemRoot: process.env.SystemRoot!, TEMP: fixture, TMP: fixture, USERPROFILE: fixture, LOCALAPPDATA: fixture, APPDATA: fixture, PATH: path.dirname(node) });
|
|
const input = { ...request, nodeExecutable: node, executablePath: edge, userDataDir, playwrightEntry };
|
|
writeFileSync(playwrightEntry, wrapper);
|
|
const firstSupervisor = nativeSupervisor(input, environment);
|
|
const first = await firstSupervisor.done;
|
|
const firstLaunch = safeLaunchEvidence(observation);
|
|
const firstObservation = JSON.parse(readFileSync(observation, 'utf8'));
|
|
console.log(JSON.stringify({ nativeEdgeBeforeReset: { comparison: 'initialization', state, first: 'error' in first ? first : { cookiesRead: first.cookies.length }, firstLaunch, pendingChildCloses: fixtureChildren.size } }));
|
|
if ('error' in first && first.error === 'native_cleanup_failed') throw new Error('Initial containment cleanup was not confirmed');
|
|
if (realpathSync(fixture) !== ownedRoot) throw new Error('Initialization fixture ownership changed');
|
|
if (state === 'fresh') {
|
|
clearOwnedFixtureContents(fixture, rootIdentity);
|
|
} else {
|
|
if (existsSync(userDataDir) && realpathSync(userDataDir) !== path.join(ownedRoot, 'User Data')) throw new Error('Synthetic profile ownership changed');
|
|
const identity = existsSync(userDataDir) ? lstatSync(userDataDir, { bigint: true }) : undefined;
|
|
try { if (identity) resetOwnedProfileDirectory(userDataDir, identity, firstSupervisor.cleanupDeadline); }
|
|
catch (error) {
|
|
console.error(JSON.stringify({ nativeFixtureRemovalFailure: { stage: 'initialization_reset', state, ...(identity ? fixtureRemovalEvidence(error, userDataDir, identity) : { inspected: false, reason: 'no_pre_reset_identity' }) } }));
|
|
throw error;
|
|
}
|
|
rmSync(observation, { force: true });
|
|
}
|
|
writeFileSync(playwrightEntry, wrapper);
|
|
const second = await nativeSupervisor(input, environment).done;
|
|
const secondObservation = JSON.parse(readFileSync(observation, 'utf8'));
|
|
console.log(JSON.stringify({ nativeEdgeInitializationComparison: {
|
|
state,
|
|
first: 'error' in first ? first : { cookiesRead: first.cookies.length }, firstLaunch,
|
|
second: 'error' in second ? second : { cookiesRead: second.cookies.length }, secondLaunch: safeLaunchEvidence(observation),
|
|
argvEqual: firstObservation.argsHash === secondObservation.argsHash,
|
|
environmentEqual: firstObservation.envHash === secondObservation.envHash,
|
|
} }));
|
|
expect(firstObservation.argsHash).toBe(secondObservation.argsHash);
|
|
expect(firstObservation.envHash).toBe(secondObservation.envHash);
|
|
expect(firstObservation.observedCommandLine).toMatchObject({ available: true });
|
|
expect(secondObservation.observedCommandLine).toMatchObject({ available: true });
|
|
expect('error' in second ? second.error : null).not.toBe('native_cleanup_failed');
|
|
if (state === 'fresh') expect(firstObservation.folderEvidence.directoriesBefore).toEqual(secondObservation.folderEvidence.directoriesBefore);
|
|
}, 65_000);
|
|
}
|
|
});
|