mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-27 23:21:53 +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
1696 lines
109 KiB
TypeScript
1696 lines
109 KiB
TypeScript
import { afterAll, describe, expect, spyOn, test } from 'bun:test';
|
|
import { createRequire } from 'node:module';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { appendFileSync, chmodSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { Database } from 'bun:sqlite';
|
|
import {
|
|
allowedFixturePage, assertDiaSocketPath, assertOwnedDiaProfile, browserCleanupError, browserGroupFacts, browserOperationTimedOut, browserPreflightError, browserRootFacts,
|
|
browserStartupCategory, browserStartupFacts, browserStderrFacts, browserStderrReasons, captureUserKeychains, classifyNativeWaitSample, createBrowserStderrCapture, createOwnedDiaProfile, DIA_DOWNLOAD, fixtureKeychainRestoreCommands,
|
|
hasSandboxDisablingArgument, inspectMachOArchitectures, joinOwnedBrowserClose, macosCompatibility, nativeDiaLaunchOptions, observeBrowserLaunches,
|
|
observePendingBrowserLaunch, sampleOwnedDiaWait, stopOwnedBrowserGroup,
|
|
observeDiaKeychainEnvironments, observeFixtureKeychain, parseDefaultKeychain, parseKeychainPaths, playwrightModuleLoadFacts, prepareKeychainHome,
|
|
qualifyDia, readFreshAccountConfiguration, removeOwnedDiaProfile, validateQualificationHost, writePrivateReceipt,
|
|
} from '../../.github/scripts/qualify-dia-macos';
|
|
import { ARCHIVE_CHECK, FRESH_WORK_PREFIX, PRIVATE_RECEIPT_READ, classifyParentDomain, classifyUserDomain, freshLaunchDefinition, freshQualificationPassed,
|
|
inspectParentDomain, inspectUidProcesses, inspectUserDomain,
|
|
ownedUserDomainTarget, ownsFreshAccount, ownsLaunchService, parseDirectoryIds, parseDirectoryRecord, passiveUserDomainState,
|
|
runFreshAccountQualification, uidProcessFacts } from '../../.github/scripts/run-dia-native-qualification';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const root = mkdtempSync(path.join(tmpdir(), 'dia-qualification-test-'));
|
|
const script = path.resolve(import.meta.dir, '../../.github/scripts/qualify-dia-macos.ts');
|
|
const nativeEnvironment = {
|
|
CI: 'true', GITHUB_ACTIONS: 'true', RUNNER_ENVIRONMENT: 'github-hosted', RUNNER_OS: 'macOS', RUNNER_ARCH: 'ARM64',
|
|
GSTACK_DIA_NATIVE_QUALIFY: '1', RUNNER_TEMP: root, GITHUB_RUN_ID: 'fixture-run', GITHUB_RUN_ATTEMPT: '1',
|
|
};
|
|
|
|
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
|
|
|
describe('Dia macOS CI qualification safety', () => {
|
|
test('admission accepts only the explicitly enabled disposable ARM64 Mac combination', () => {
|
|
expect(() => validateQualificationHost(nativeEnvironment, 'darwin', 'arm64')).not.toThrow();
|
|
for (const [platform, architecture] of [['linux', 'arm64'], ['win32', 'arm64'], ['darwin', 'x64']]) {
|
|
expect(() => validateQualificationHost(nativeEnvironment, platform as any, architecture as any)).toThrow('disposable_arm64_macos_ci_required');
|
|
}
|
|
expect(readdirSync(root)).toEqual([]);
|
|
});
|
|
|
|
for (const name of Object.keys(nativeEnvironment)) {
|
|
test(`requires admission field ${name} before any native work`, () => {
|
|
const env: NodeJS.ProcessEnv = { ...nativeEnvironment };
|
|
delete env[name];
|
|
expect(() => validateQualificationHost(env, 'darwin', 'arm64')).toThrow('disposable_arm64_macos_ci_required');
|
|
expect(readdirSync(root)).toEqual([]);
|
|
});
|
|
}
|
|
|
|
test('rejects a self-hosted machine even when the OS and opt-in match', () => {
|
|
expect(() => validateQualificationHost({ ...nativeEnvironment, RUNNER_ENVIRONMENT: 'self-hosted' }, 'darwin', 'arm64'))
|
|
.toThrow('disposable_arm64_macos_ci_required');
|
|
});
|
|
|
|
test('the actual launcher refuses an unapproved environment without staging files', () => {
|
|
const nativeTemp = mkdtempSync(path.join(root, 'runner-temp-'));
|
|
const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--no-macros', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, script], {
|
|
cwd: root, env: { PATH: path.dirname(process.execPath), HOME: root, RUNNER_TEMP: nativeTemp,
|
|
...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}) },
|
|
encoding: 'utf8', timeout: 10_000,
|
|
});
|
|
expect(result.status).toBe(2);
|
|
expect(result.stderr).toBe('');
|
|
expect(JSON.parse(result.stdout)).toEqual({ status: 'incomplete', reason: 'qualification_preflight_failed', counts: { pass: 0, fail: 0, skip: 0 } });
|
|
expect(readdirSync(nativeTemp)).toEqual([]);
|
|
});
|
|
|
|
test('a fresh fixture-HOME child uses unmodified production Dia profile and domain discovery', () => {
|
|
const home = realpathSync(mkdtempSync(path.join(root, 'fixture-home-')));
|
|
const ownership = createOwnedDiaProfile(home);
|
|
const profile = path.join(ownership.profile, 'Default');
|
|
mkdirSync(profile, { recursive: true });
|
|
const database = new Database(path.join(profile, 'Cookies'));
|
|
database.run('CREATE TABLE cookies (host_key TEXT, has_expires INTEGER, expires_utc INTEGER)');
|
|
database.query('INSERT INTO cookies VALUES (?, ?, ?)').run('.fixture.test', 0, 0);
|
|
database.close();
|
|
const production = pathToFileURL(path.resolve(import.meta.dir, '../src/cookie-import-browser.ts')).href;
|
|
const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--no-macros', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, '-e', `
|
|
import { homedir } from 'node:os';
|
|
const { listProfiles, listDomains } = await import(${JSON.stringify(production)});
|
|
console.log(JSON.stringify({ homeAtStartup: homedir() === process.env.HOME,
|
|
profiles: listProfiles('Dia').map(profile => profile.name), domains: listDomains('Dia', 'Default').domains }));
|
|
`], {
|
|
cwd: root, env: { PATH: path.dirname(process.execPath), HOME: home, USERPROFILE: home,
|
|
...(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({ homeAtStartup: true, profiles: ['Default'], domains: [{ domain: '.fixture.test', count: 1 }] });
|
|
expect(() => assertOwnedDiaProfile(ownership)).not.toThrow();
|
|
removeOwnedDiaProfile(ownership, true);
|
|
expect(existsSync(home)).toBe(true);
|
|
});
|
|
|
|
for (const precreate of [false, true]) {
|
|
test(`late-installed real Playwright loads only with its dependency directory present at startup (${precreate})`, () => {
|
|
const fixture = realpathSync(mkdtempSync(path.join(root, 'late-playwright-')));
|
|
const home = path.join(fixture, 'home');
|
|
const scripts = path.join(fixture, '.github/scripts');
|
|
const modules = path.join(fixture, 'node_modules');
|
|
mkdirSync(home);
|
|
mkdirSync(scripts, { recursive: true });
|
|
if (precreate) mkdirSync(modules, { mode: 0o700 });
|
|
writeFileSync(path.join(fixture, 'package.json'), JSON.stringify({ type: 'module', dependencies: { playwright: '1.62.1' } }));
|
|
const sourceModules = path.resolve(import.meta.dir, '../../node_modules');
|
|
const worker = path.join(scripts, 'worker.ts');
|
|
writeFileSync(worker, `
|
|
import { cpSync, mkdirSync } from 'node:fs';
|
|
import { createRequire } from 'node:module';
|
|
const require = createRequire(import.meta.url);
|
|
mkdirSync(${JSON.stringify(modules)}, { recursive: true });
|
|
for (const name of ['playwright', 'playwright-core']) cpSync(${JSON.stringify(sourceModules)} + '/' + name, ${JSON.stringify(modules)} + '/' + name, { recursive: true });
|
|
const version = require(${JSON.stringify(path.join(modules, 'playwright/package.json'))}).version;
|
|
try {
|
|
const loaded = await import('playwright');
|
|
console.log(JSON.stringify({ version, loaded: !!loaded.chromium }));
|
|
} catch (error) {
|
|
console.log(JSON.stringify({ version, type: error.name, code: error.code }));
|
|
process.exitCode = 2;
|
|
}
|
|
`);
|
|
const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--no-macros',
|
|
`--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, worker], {
|
|
cwd: fixture, env: { HOME: home, PATH: path.dirname(process.execPath) }, encoding: 'utf8', timeout: 30_000,
|
|
});
|
|
expect(result.stderr).toBe('');
|
|
expect(result.status).toBe(precreate ? 0 : 2);
|
|
expect(JSON.parse(result.stdout)).toEqual(precreate ? { version: '1.62.1', loaded: true }
|
|
: { version: '1.62.1', type: 'ResolveMessage', code: 'ERR_MODULE_NOT_FOUND' });
|
|
});
|
|
}
|
|
|
|
test('the download is the published HTTPS Dia release endpoint', () => {
|
|
expect(DIA_DOWNLOAD).toBe('https://releases.diabrowser.com/release/Dia-latest.dmg');
|
|
});
|
|
|
|
describe('bounded Mach-O architecture inspection', () => {
|
|
const thin = (cpu = 0x0100000c, subtype = 0, little = true, wide = true) => {
|
|
const buffer = Buffer.alloc((wide ? 32 : 28) + 8);
|
|
const word = (value: number, at: number) => little ? buffer.writeUInt32LE(value, at) : buffer.writeUInt32BE(value, at);
|
|
word(wide ? 0xfeedfacf : 0xfeedface, 0);
|
|
word(cpu, 4);
|
|
word(subtype, 8);
|
|
word(2, 12);
|
|
word(1, 16);
|
|
word(8, 20);
|
|
return buffer;
|
|
};
|
|
const fat = (little = false, wide = false) => {
|
|
const buffer = Buffer.alloc(512);
|
|
const word = (value: number, at: number) => little ? buffer.writeUInt32LE(value, at) : buffer.writeUInt32BE(value, at);
|
|
const wideWord = (value: bigint, at: number) => little ? buffer.writeBigUInt64LE(value, at) : buffer.writeBigUInt64BE(value, at);
|
|
word(wide ? 0xcafebabf : 0xcafebabe, 0);
|
|
word(2, 4);
|
|
for (const [index, cpu, subtype] of [[0, 0x01000007, 3], [1, 0x0100000c, 0]]) {
|
|
const at = 8 + index * (wide ? 32 : 20);
|
|
const image = thin(cpu, subtype);
|
|
word(cpu, at);
|
|
word(subtype, at + 4);
|
|
if (wide) { wideWord(BigInt(128 * (index + 1)), at + 8); wideWord(BigInt(image.length), at + 16); }
|
|
else { word(128 * (index + 1), at + 8); word(image.length, at + 12); }
|
|
word(7, at + (wide ? 24 : 16));
|
|
image.copy(buffer, 128 * (index + 1));
|
|
}
|
|
return buffer;
|
|
};
|
|
const store = (buffer: Buffer) => {
|
|
const directory = realpathSync(mkdtempSync(path.join(root, 'macho-')));
|
|
const file = path.join(directory, 'executable');
|
|
writeFileSync(file, buffer, { mode: 0o600 });
|
|
return file;
|
|
};
|
|
for (const little of [true, false]) {
|
|
test(`thin headers honor byte order (${little}) and distinguish arm64 subtypes`, () => {
|
|
expect(inspectMachOArchitectures(store(thin(0x0100000c, 0, little))).architectures).toEqual(['arm64']);
|
|
expect(inspectMachOArchitectures(store(thin(0x0100000c, 0x80000002, little))).architectures).toEqual(['arm64e']);
|
|
expect(inspectMachOArchitectures(store(thin(0x01000007, 3, little))).architectures).toEqual(['x86_64']);
|
|
expect(inspectMachOArchitectures(store(thin(7, 3, little, false))).architectures).toEqual(['i386']);
|
|
});
|
|
for (const wide of [true, false]) {
|
|
test(`fat headers verify their actual slices (${little}, ${wide})`, () => {
|
|
const result = inspectMachOArchitectures(store(fat(little, wide)));
|
|
expect(result).toMatchObject({ method: 'bounded_macho_headers', format: wide ? 'fat64' : 'fat32', slices: 2,
|
|
architectures: ['x86_64', 'arm64'] });
|
|
expect(result.bytesRead).toBeLessThanOrEqual(4096);
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const invalid of ['magic', 'truncated', 'empty_fat', 'excessive_fat', 'truncated_table', 'table_overlap', 'slice_overlap',
|
|
'slice_out_of_bounds', 'slice_too_short', 'misaligned', 'alignment_overflow', 'cpu_mismatch', 'subtype_mismatch', 'nested_fat',
|
|
'duplicate_architecture', 'wide_offset_overflow', 'reserved', 'header_width', 'file_type', 'load_commands', 'unknown_cpu']) {
|
|
test(`architecture inspection rejects ${invalid} rather than guessing arm64`, () => {
|
|
let image = fat();
|
|
if (invalid === 'magic') image = Buffer.from('not a macho executable');
|
|
if (invalid === 'truncated') image = thin().subarray(0, 20);
|
|
if (invalid === 'empty_fat') image.writeUInt32BE(0, 4);
|
|
if (invalid === 'excessive_fat') image.writeUInt32BE(33, 4);
|
|
if (invalid === 'truncated_table') image = image.subarray(0, 24);
|
|
if (invalid === 'table_overlap') image.writeUInt32BE(0, 16);
|
|
if (invalid === 'slice_overlap') image.writeUInt32BE(128, 36);
|
|
if (invalid === 'slice_out_of_bounds') image.writeUInt32BE(1024, 36);
|
|
if (invalid === 'slice_too_short') image.writeUInt32BE(28, 40);
|
|
if (invalid === 'misaligned') image.writeUInt32BE(257, 36);
|
|
if (invalid === 'alignment_overflow') image.writeUInt32BE(0xffffffff, 44);
|
|
if (invalid === 'cpu_mismatch') image.writeUInt32LE(0x01000007, 260);
|
|
if (invalid === 'subtype_mismatch') image.writeUInt32LE(2, 264);
|
|
if (invalid === 'nested_fat') image.writeUInt32BE(0xcafebabe, 256);
|
|
if (invalid === 'duplicate_architecture') { image.writeUInt32BE(0x01000007, 28); image.writeUInt32BE(3, 32); thin(0x01000007, 3).copy(image, 256); }
|
|
if (invalid === 'wide_offset_overflow') { image = fat(false, true); image.writeBigUInt64BE(1n << 60n, 16); }
|
|
if (invalid === 'reserved') { image = fat(false, true); image.writeUInt32BE(1, 36); }
|
|
if (invalid === 'header_width') image = thin(0x0100000c, 0, true, false);
|
|
if (invalid === 'file_type') { image = thin(); image.writeUInt32LE(6, 12); }
|
|
if (invalid === 'load_commands') { image = thin(); image.writeUInt32LE(1000, 20); }
|
|
if (invalid === 'unknown_cpu') image = thin(0x010000ff, 0);
|
|
expect(() => inspectMachOArchitectures(store(image))).toThrow();
|
|
});
|
|
}
|
|
|
|
test('architecture inspection rejects links, non-files, and expired budgets', () => {
|
|
const file = store(thin());
|
|
symlinkSync(file, file + '.link');
|
|
expect(() => inspectMachOArchitectures(file + '.link')).toThrow('unsafe_macho_file');
|
|
expect(() => inspectMachOArchitectures(path.dirname(file))).toThrow('unsafe_macho_file');
|
|
for (const budget of [0, NaN, Infinity]) expect(() => inspectMachOArchitectures(file, budget)).toThrow('macho_read_budget_exhausted');
|
|
expect(qualifyDia.toString()).not.toContain('/usr/bin/lipo');
|
|
});
|
|
|
|
for (const change of ['append', 'replace']) {
|
|
test(`architecture inspection detects a file that changes by ${change}`, () => {
|
|
const image = thin();
|
|
const file = store(image);
|
|
let ticks = 0;
|
|
const clock = spyOn(performance, 'now').mockImplementation(() => {
|
|
if (++ticks === 3) {
|
|
if (change === 'append') appendFileSync(file, Buffer.from([0]));
|
|
else { renameSync(file, file + '.previous'); writeFileSync(file, image); }
|
|
}
|
|
return 100;
|
|
});
|
|
try { expect(() => inspectMachOArchitectures(file)).toThrow('macho_changed_during_inspection'); }
|
|
finally { clock.mockRestore(); }
|
|
});
|
|
}
|
|
});
|
|
test('the exact nested macOS account and qualifier socket layout stays below the unchanged limit', () => {
|
|
const sourceProfile = path.posix.join(FRESH_WORK_PREFIX + 'XXXXXX', 'tmp', 'dia-XXXXXX', 'h', 'Library/Application Support/Dia/User Data');
|
|
expect(Buffer.byteLength(sourceProfile + '/SingletonSocket')).toBe(97);
|
|
expect(() => assertDiaSocketPath(sourceProfile)).not.toThrow();
|
|
const oldProfile = '/private/tmp/dia-native-XXXXXX/tmp/dia-XXXXXX/h/Library/Application Support/Dia/User Data';
|
|
expect(Buffer.byteLength(oldProfile + '/SingletonSocket')).toBe(105);
|
|
expect(() => assertDiaSocketPath(oldProfile)).toThrow('fixture_socket_path_too_long');
|
|
const atLimit = '/' + 'x'.repeat(100 - Buffer.byteLength('//SingletonSocket'));
|
|
expect(Buffer.byteLength(atLimit + '/SingletonSocket')).toBe(100);
|
|
expect(() => assertDiaSocketPath(atLimit)).toThrow('fixture_socket_path_too_long');
|
|
});
|
|
|
|
test('the registered-home source profile fits the socket bound without a shadow HOME', () => {
|
|
const profile = path.posix.join(FRESH_WORK_PREFIX + 'XXXXXX', 'home', 'Library/Application Support/Dia/User Data');
|
|
expect(Buffer.byteLength(profile + '/SingletonSocket')).toBe(85);
|
|
expect(() => assertDiaSocketPath(profile)).not.toThrow();
|
|
const implementation = qualifyDia.toString();
|
|
expect(implementation).toContain('readFreshAccountConfiguration');
|
|
expect(implementation).toContain('const home = account.home');
|
|
expect(implementation).not.toMatch(/fixtureEnvironment|systemEnvironment|isolation\.originalHome/);
|
|
expect(implementation).toContain('removeOwnedDiaProfile');
|
|
});
|
|
|
|
test('profile ownership is exclusive and cleanup preserves the account home and sibling state', () => {
|
|
const home = realpathSync(mkdtempSync(path.join(root, 'owned-profile-home-')));
|
|
prepareKeychainHome(home);
|
|
const preserved = path.join(home, 'Library/Keychains/fixture-state');
|
|
writeFileSync(preserved, 'preserved fixture state');
|
|
const ownership = createOwnedDiaProfile(home);
|
|
const sibling = path.join(path.dirname(ownership.profile), 'sibling-state');
|
|
writeFileSync(sibling, 'preserved sibling state');
|
|
expect(() => assertOwnedDiaProfile(ownership)).not.toThrow();
|
|
expect(() => createOwnedDiaProfile(home)).toThrow();
|
|
expect(() => removeOwnedDiaProfile(ownership, false)).toThrow('owned_browsers_not_stopped');
|
|
expect(existsSync(ownership.profile)).toBe(true);
|
|
removeOwnedDiaProfile(ownership, true);
|
|
expect(existsSync(ownership.profile)).toBe(false);
|
|
expect(existsSync(home)).toBe(true);
|
|
expect(readFileSync(preserved, 'utf8')).toBe('preserved fixture state');
|
|
expect(readFileSync(sibling, 'utf8')).toBe('preserved sibling state');
|
|
});
|
|
|
|
for (const change of ['nonce', 'inode', 'marker_symlink', 'profile_symlink', 'uid', 'home_escape', 'marker_permissions', 'marker_hardlink']) {
|
|
test(`profile cleanup refuses changed ${change} ownership without deleting the directory`, () => {
|
|
const home = realpathSync(mkdtempSync(path.join(root, 'profile-refusal-')));
|
|
const ownership = createOwnedDiaProfile(home);
|
|
const profile = ownership.profile;
|
|
const marker = path.join(profile, '.gstack-dia-owner');
|
|
if (change === 'nonce') writeFileSync(marker, (ownership.nonce[0] === '0' ? '1' : '0') + ownership.nonce.slice(1));
|
|
if (change === 'inode') {
|
|
renameSync(profile, profile + '.original');
|
|
mkdirSync(profile, { mode: 0o700 });
|
|
writeFileSync(marker, ownership.nonce, { mode: 0o600 });
|
|
}
|
|
if (change === 'marker_symlink') { renameSync(marker, marker + '.original'); symlinkSync(marker + '.original', marker); }
|
|
if (change === 'profile_symlink') { renameSync(profile, profile + '.original'); symlinkSync(profile + '.original', profile, 'dir'); }
|
|
if (change === 'uid') ownership.uid = process.getuid!() + 1;
|
|
if (change === 'home_escape') ownership.profile = home;
|
|
if (change === 'marker_permissions') chmodSync(marker, 0o644);
|
|
if (change === 'marker_hardlink') linkSync(marker, path.join(home, 'marker-link'));
|
|
expect(() => removeOwnedDiaProfile(ownership, true)).toThrow();
|
|
expect(existsSync(profile)).toBe(true);
|
|
expect(existsSync(home)).toBe(true);
|
|
});
|
|
}
|
|
|
|
test('source profile creation refuses existing profiles and linked ancestors without writing through them', () => {
|
|
const home = realpathSync(mkdtempSync(path.join(root, 'existing-profile-')));
|
|
const profile = path.join(home, 'Library/Application Support/Dia/User Data');
|
|
mkdirSync(profile, { recursive: true });
|
|
writeFileSync(path.join(profile, 'existing-state'), 'untouched');
|
|
expect(() => createOwnedDiaProfile(home)).toThrow();
|
|
expect(readdirSync(profile)).toEqual(['existing-state']);
|
|
const linkedHome = realpathSync(mkdtempSync(path.join(root, 'linked-profile-home-')));
|
|
const elsewhere = realpathSync(mkdtempSync(path.join(root, 'linked-profile-target-')));
|
|
symlinkSync(elsewhere, path.join(linkedHome, 'Library'), 'dir');
|
|
expect(() => createOwnedDiaProfile(linkedHome)).toThrow('unsafe_profile_ancestor');
|
|
expect(readdirSync(elsewhere)).toEqual([]);
|
|
});
|
|
|
|
test('the qualifier rejects an arbitrary caller-supplied home as fresh account authority', () => {
|
|
const fake = path.join(root, 'account.json');
|
|
writeFileSync(fake, JSON.stringify({ home: root, uid: process.getuid?.() }));
|
|
expect(() => readFreshAccountConfiguration(fake)).toThrow('unsafe_fresh_account_configuration');
|
|
expect(() => readFreshAccountConfiguration('account.json')).toThrow('unsafe_fresh_account_configuration');
|
|
});
|
|
|
|
test('headless source launch removes mock Keychain and first-run suppression defaults', () => {
|
|
const env = { HOME: '/fixture/home', PATH: '/usr/bin:/bin' };
|
|
const options = nativeDiaLaunchOptions('/fixture/Dia.app/Contents/MacOS/Dia', env);
|
|
expect(options.headless).toBe(true);
|
|
expect(options.chromiumSandbox).toBe(true);
|
|
expect(options.timeout).toBe(30_000);
|
|
expect(options.ignoreDefaultArgs).toEqual(['--use-mock-keychain', '--password-store=basic', '--no-first-run']);
|
|
expect(options.args).toEqual(['--disable-sync', '--no-default-browser-check', '--profile-directory=Default']);
|
|
expect(options.env).toBe(env);
|
|
expect(options.serviceWorkers).toBe('block');
|
|
expect(options.args.some(arg => /onboarding|skip-login|remote-debugging-port/.test(arg))).toBe(false);
|
|
});
|
|
|
|
test('Mac native spawn policy refuses sandbox-disabling arguments regardless of caller options', () => {
|
|
const descriptor = Object.getOwnPropertyDescriptor(process, 'platform')!;
|
|
const profile = path.join(root, 'mac-sandbox-policy');
|
|
const childProcess = require('node:child_process');
|
|
const observer = observeBrowserLaunches(new Map([[process.execPath, profile]]));
|
|
try {
|
|
Object.defineProperty(process, 'platform', { ...descriptor, value: 'darwin' });
|
|
for (const disabling of [['--no-sandbox'], ['--no-sandbox=false'], ['--disable-sandbox'], ['--disable-gpu-sandbox'],
|
|
['--disable-setuid-sandbox'], ['--disable-seccomp-filter-sandbox'], ['--disable-namespace-sandbox'], ['--no-zygote-sandbox'],
|
|
['--single-process'], ['--in-process-gpu'], ['--disable-features=GpuSandboxV2'], ['--disable-features', 'RendererSandbox']]) {
|
|
expect(hasSandboxDisablingArgument(disabling)).toBe(true);
|
|
expect(() => childProcess.spawn(process.execPath, ['--remote-debugging-pipe', '--user-data-dir=' + profile, ...disabling], {
|
|
detached: true, shell: false, stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'], chromiumSandbox: false,
|
|
allowSandboxDisable: true, env: { DISABLE_SANDBOX: '1' },
|
|
})).toThrow('browser_launch_policy_rejected');
|
|
expect(observer.attempts.at(-1)).toMatchObject({ sandboxRequired: true, sandboxDisablingFlag: true });
|
|
}
|
|
expect(observer.children).toHaveLength(0);
|
|
} finally { Object.defineProperty(process, 'platform', descriptor); observer.restore(); }
|
|
expect(hasSandboxDisablingArgument(['--headless', '--disable-sync', '--disable-features=MediaRouter,Translate'])).toBe(false);
|
|
});
|
|
|
|
test('startup admits only blank pages or the exact synthetic loopback origin', () => {
|
|
const origin = 'http://127.0.0.1:8123';
|
|
expect(allowedFixturePage('about:blank', origin)).toBe(true);
|
|
expect(allowedFixturePage(origin + '/seed', origin)).toBe(true);
|
|
for (const url of ['https://www.diabrowser.com/login', 'dia://onboarding', 'chrome://welcome', 'http://localhost:8123/seed',
|
|
'http://127.0.0.1:8124/seed', 'javascript:alert(1)', 'about:config', 'invalid']) expect(allowedFixturePage(url, origin)).toBe(false);
|
|
const authenticated = new URL(origin);
|
|
authenticated.username = 'fixture-user';
|
|
authenticated.password = 'fixture';
|
|
expect(allowedFixturePage(authenticated.href, origin)).toBe(false);
|
|
});
|
|
|
|
test('startup diagnostics classify pages without exposing URLs or broadening admission', () => {
|
|
const pages = [
|
|
['about:blank', 'blank'], ['about:blank#synthetic-private-value', 'other_about'],
|
|
['chrome://newtab/?token=synthetic-private-value', 'chromium_new_tab'],
|
|
['chrome://new-tab-page/', 'chromium_new_tab'], ['chrome://intro/', 'chromium_onboarding'],
|
|
['chrome://welcome/', 'chromium_onboarding'], ['chrome://settings/', 'chromium_internal'],
|
|
['dia://onboarding', 'dia_internal'], ['chrome-extension://fixture/path', 'extension'],
|
|
['https://fixture.invalid/login?token=synthetic-private-value', 'external_web'],
|
|
['http://127.0.0.1:8123' + '/fixture', 'loopback_web'], ['file:///synthetic-private-value', 'file'],
|
|
['data:text/html,synthetic-private-value', 'data'], ['invalid synthetic-private-value', 'invalid'],
|
|
];
|
|
const categories = pages.map(([url]) => browserStartupCategory(url));
|
|
expect(categories).toEqual(pages.map(([, category]) => category));
|
|
expect(JSON.stringify(categories)).not.toContain('synthetic-private-value');
|
|
expect(JSON.stringify(categories)).not.toContain('fixture.invalid');
|
|
for (const [url] of pages.slice(1, 10)) expect(allowedFixturePage(url, 'http://127.0.0.1:8123')).toBe(false);
|
|
});
|
|
|
|
test('browser stderr classifies only known policy, pipe, Keychain, loader and bootstrap diagnostics', () => {
|
|
for (const [line, reason] of [
|
|
['DevTools remote debugging requires a non-default data directory. Specify this using --user-data-dir.', 'default_profile_policy'],
|
|
['Remote debugging pipe file descriptors are not open.', 'debugging_pipe_unavailable'],
|
|
['errSecInteractionNotAllowed: User interaction is not allowed.', 'keychain_interaction_disallowed'],
|
|
['errSecInteractionRequired', 'keychain_interaction_required'],
|
|
['errSecAuthFailed', 'keychain_access_failed'],
|
|
['dyld[123]: Library not loaded:', 'dynamic_library_error'],
|
|
['code signature invalid', 'code_signing_error'],
|
|
['bootstrap_check_in failed', 'graphics_or_bootstrap_error'],
|
|
['ProcessSingleton', 'browser_profile_unavailable'],
|
|
]) {
|
|
const reasons = browserStderrReasons(line + ' synthetic-private-value /private/fixture/profile');
|
|
expect(reasons).toContain(reason);
|
|
expect(JSON.stringify(reasons)).not.toContain('synthetic-private');
|
|
expect(JSON.stringify(reasons)).not.toContain('/private');
|
|
}
|
|
for (const line of ['using a non-default data directory', 'remote-debugging-pipe enabled', 'Waiting for Keychain',
|
|
'WindowServer connection available', 'private unknown diagnostic']) expect(browserStderrReasons(line)).toEqual([]);
|
|
});
|
|
|
|
test('stderr capture handles chunk boundaries and final unterminated lines without retaining text', () => {
|
|
const capture = createBrowserStderrCapture();
|
|
const policy = 'DevTools remote debugging requires a non-default data directory.';
|
|
capture.consume(Buffer.from('synthetic-private-value\n' + policy.slice(0, 23)));
|
|
expect(capture.snapshot().reasonCounts.default_profile_policy).toBe(0);
|
|
capture.consume(Buffer.from(policy.slice(23)));
|
|
const before = capture.snapshot();
|
|
expect(before.reasonCounts.default_profile_policy).toBe(1);
|
|
capture.consume(Buffer.from('\n' + policy));
|
|
capture.end();
|
|
expect(capture.snapshot().reasonCounts.default_profile_policy).toBe(2);
|
|
expect(before.reasonCounts.default_profile_policy).toBe(1);
|
|
expect(capture.snapshot().ended).toBe(true);
|
|
expect(JSON.stringify(capture.snapshot())).not.toContain('synthetic-private');
|
|
expect(JSON.stringify(capture.snapshot())).not.toContain('DevTools');
|
|
});
|
|
|
|
test('stderr inspection caps bytes and pending line size while exposing truncation honestly', () => {
|
|
const capture = createBrowserStderrCapture();
|
|
capture.consume(Buffer.alloc(65_536, 120));
|
|
capture.consume(Buffer.from('\nDevTools remote debugging requires a non-default data directory.\n'));
|
|
const facts = capture.snapshot();
|
|
expect(facts.bytesInspected).toBe(65_536);
|
|
expect(facts.bytesSeen).toBeGreaterThan(65_536);
|
|
expect(facts.truncated).toBe(true);
|
|
expect(facts.discardedLongLines).toBe(1);
|
|
expect(facts.reasonCounts.default_profile_policy).toBe(0);
|
|
const lines = createBrowserStderrCapture();
|
|
lines.consume('x'.repeat(5000) + '\nRemote debugging pipe file descriptors are not open.\n');
|
|
expect(lines.snapshot().reasonCounts.debugging_pipe_unavailable).toBe(1);
|
|
expect(lines.snapshot().truncated).toBe(true);
|
|
});
|
|
|
|
test('causal stderr reasons outrank a generic timeout without changing its timeout identity', () => {
|
|
const timeout = Object.assign(new Error('synthetic-private-timeout'), { name: 'TimeoutError' });
|
|
expect(browserOperationTimedOut(timeout)).toBe(true);
|
|
expect(browserPreflightError(timeout)).toBe('operation_timeout');
|
|
expect(browserPreflightError(timeout, ['default_profile_policy'])).toBe('default_profile_policy');
|
|
expect(browserPreflightError(timeout, ['debugging_pipe_unavailable'])).toBe('debugging_pipe_unavailable');
|
|
expect(browserPreflightError(timeout, ['keychain_interaction_required'])).toBe('keychain_interaction_required');
|
|
const embedded = Object.assign(new Error('Timed out\nDevTools remote debugging requires a non-default data directory.'), { name: 'TimeoutError' });
|
|
expect(browserPreflightError(embedded)).toBe('default_profile_policy');
|
|
expect(browserOperationTimedOut(embedded)).toBe(true);
|
|
});
|
|
|
|
test('stderr snapshots keep source, destination, and cleanup-time observations separate', () => {
|
|
const source = createBrowserStderrCapture();
|
|
const destination = createBrowserStderrCapture();
|
|
source.consume('DevTools remote debugging requires a non-default data directory.\n');
|
|
const beforeCleanup = source.snapshot();
|
|
source.consume('bootstrap_check_in failed\n');
|
|
destination.consume('Remote debugging pipe file descriptors are not open.\n');
|
|
expect(beforeCleanup.reasonCounts.graphics_or_bootstrap_error).toBe(0);
|
|
expect(source.snapshot().reasonCounts.graphics_or_bootstrap_error).toBe(1);
|
|
expect(destination.snapshot().reasonCounts.default_profile_policy).toBe(0);
|
|
expect(destination.snapshot().reasonCounts.debugging_pipe_unavailable).toBe(1);
|
|
});
|
|
|
|
test('signed macOS requirements compare numeric versions and preserve unknown metadata', () => {
|
|
expect(macosCompatibility({ LSMinimumSystemVersion: '14.0' }, '15.6.1')).toEqual({ hostVersion: '15.6.1',
|
|
minimumSystemVersion: '14.0', minimumArm64Version: null, compatible: true });
|
|
expect(macosCompatibility({ LSMinimumSystemVersion: '26.0' }, '15.6.1').compatible).toBe(false);
|
|
expect(macosCompatibility({ LSMinimumSystemVersion: '15.9' }, '15.10').compatible).toBe(true);
|
|
expect(macosCompatibility({ LSMinimumSystemVersion: '15.6.1' }, '15.6').compatible).toBe(false);
|
|
expect(macosCompatibility({ LSMinimumSystemVersion: '15.6' }, '15.6.0').compatible).toBe(true);
|
|
expect(macosCompatibility({ LSMinimumSystemVersion: '14.0', LSMinimumSystemVersionByArchitecture: { arm64: '26.0' } }, '15.6.1').compatible).toBe(false);
|
|
expect(macosCompatibility({ LSMinimumSystemVersion: '26.0', LSMinimumSystemVersionByArchitecture: { arm64: '14.0' } }, '15.6.1').compatible).toBe(false);
|
|
expect(macosCompatibility({ LSMinimumSystemVersionByArchitecture: { arm64: '14.0' } }, '15.6.1').compatible).toBe(true);
|
|
for (const plist of [{}, null, { LSMinimumSystemVersion: 'synthetic-private-value' }, { LSMinimumSystemVersion: 14 },
|
|
{ LSMinimumSystemVersion: '14.0', LSMinimumSystemVersionByArchitecture: 'synthetic-private-value' },
|
|
{ LSMinimumSystemVersion: '14.0', LSMinimumSystemVersionByArchitecture: [] },
|
|
{ LSMinimumSystemVersion: '14.0', LSMinimumSystemVersionByArchitecture: { arm64: 'private-version' } }]) {
|
|
const facts = macosCompatibility(plist, '15.6.1');
|
|
expect(facts.compatible).toBeNull();
|
|
expect(JSON.stringify(facts)).not.toContain('private');
|
|
}
|
|
expect(macosCompatibility({ LSMinimumSystemVersion: '14.0' }, 'private-host-version').compatible).toBeNull();
|
|
});
|
|
|
|
test('bounded startup categories do not hide a disallowed page beyond the receipt limit', () => {
|
|
const urls = [...Array(64).fill('about:blank'), 'https://private.invalid/?token=synthetic-private-value'];
|
|
const facts = browserStartupFacts(urls, 'http://127.0.0.1:8123');
|
|
expect(facts).toMatchObject({ count: 65, truncated: true, allowed: false });
|
|
expect(facts.categories).toHaveLength(64);
|
|
expect(JSON.stringify(facts)).not.toContain('private');
|
|
});
|
|
|
|
test('browser root facts retain exit and allowlisted signal evidence without process payloads', () => {
|
|
const facts = browserRootFacts([
|
|
{ pid: 300, process: { exitCode: null, signalCode: null, spawnargs: ['synthetic-private-value'] } },
|
|
{ pid: 301, process: { exitCode: 0, signalCode: null } },
|
|
{ pid: 302, process: { exitCode: null, signalCode: 'SIGABRT' } },
|
|
{ pid: 303, process: { exitCode: null, signalCode: 'synthetic-private-signal' } },
|
|
] as any);
|
|
expect(facts).toEqual([{ pid: 300, exitCode: null, signal: null }, { pid: 301, exitCode: 0, signal: null },
|
|
{ pid: 302, exitCode: null, signal: 'SIGABRT' }, { pid: 303, exitCode: null, signal: 'other' }]);
|
|
expect(JSON.stringify(facts)).not.toContain('private');
|
|
});
|
|
|
|
test('browser cleanup distinguishes live groups from signal errors without retaining raw errors', () => {
|
|
expect(browserCleanupError(new Error('owned_process_group_still_live')).reason).toBe('group_still_live');
|
|
expect(browserCleanupError(new Error('cleanup_budget_exhausted')).reason).toBe('cleanup_deadline');
|
|
expect(browserCleanupError(Object.assign(new Error('synthetic-private-value'), { code: 'EPERM', errno: 1 })))
|
|
.toEqual({ reason: 'signal_or_probe_failed', code: 'EPERM', errno: 1 });
|
|
const unknown = browserCleanupError({ message: 'synthetic-private-value', code: 'private-code', errno: 'private-errno' });
|
|
expect(unknown).toEqual({ reason: 'signal_or_probe_failed', code: 'unclassified', errno: null });
|
|
});
|
|
|
|
test('process-group diagnostics distinguish owned zombies, live members, and unrelated UID counts', () => {
|
|
const facts = browserGroupFacts('20000 300 1 300 Z\n20000 301 1 300 S+\n501 302 1 300 S\n20000 400 1 400 S\n', 20000, 300);
|
|
expect(facts).toEqual({ available: true, count: 2, foreignUidCount: 1, zombies: 1, live: 1, truncated: false,
|
|
processes: [{ pid: 300, ppid: 1, state: 'Z' }, { pid: 301, ppid: 1, state: 'S' }] });
|
|
expect(browserGroupFacts(Array.from({ length: 70 }, (_, index) => `20000 ${index + 300} 1 300 S`).join('\n'), 20000, 300).processes).toHaveLength(64);
|
|
expect(() => browserGroupFacts('private-invalid-row', 20000, 300)).toThrow('invalid_group_snapshot');
|
|
expect(() => browserGroupFacts('', 20000, 0)).toThrow('invalid_group_snapshot_target');
|
|
});
|
|
|
|
test('native samples retain fixed wait families only from call-graph frames', () => {
|
|
const output = `Process: private-process [123]
|
|
Path: /private/sensitive/Security/AppKit/CFNetwork
|
|
Call graph:
|
|
100 Thread_11 DispatchQueue_1: com.apple.main-thread (serial)
|
|
+ 100 NSApplicationMain (in AppKit) private-source-path
|
|
+ 100 SecKeychainFindGenericPassword (in Security) private-item-name
|
|
+ 100 mach_msg_trap (in libsystem_kernel)
|
|
100 Thread_12 private-thread-name
|
|
+ 100 NSURLSessionTask (in CFNetwork) private-request-url
|
|
Total number in stack (recursive counted multiple):
|
|
999 SecItemCopyMatching private-unrelated-summary
|
|
Binary Images:
|
|
Security AppKit CFNetwork /private/image/path
|
|
`;
|
|
const result = classifyNativeWaitSample(output);
|
|
expect(result).toEqual({ available: true, callGraphSeen: true, mainThreadSeen: true, frames: 4,
|
|
frameCounts: { security_keychain: 1, appkit_bootstrap: 1, network: 1, runloop: 1 },
|
|
mainThreadFrameCounts: { security_keychain: 1, appkit_bootstrap: 1, network: 0, runloop: 1 },
|
|
shape: { graphLines: 6, nonemptyGraphLines: 6, threadTokenLines: 2, numericPrefixLines: 2, imageAnnotatedLines: 4,
|
|
nonAsciiGraphLines: 0, unrecognizedGraphLines: 0, graphEnd: 'totals', binaryImagesSeen: true } });
|
|
expect(JSON.stringify(result)).not.toContain('private');
|
|
expect(JSON.stringify(result)).not.toContain('SecKeychain');
|
|
expect(classifyNativeWaitSample('private missing call graph').available).toBe(false);
|
|
expect(classifyNativeWaitSample('x'.repeat(1024 * 1024 + 1))).toEqual({ available: false, reason: 'sample_output_oversized' });
|
|
});
|
|
|
|
test('sample structure distinguishes an empty graph from unrecognized stack rows without exposing text', () => {
|
|
const empty = classifyNativeWaitSample('Call graph:\n\nTotal number in stack:\nBinary Images:\nprivate-image');
|
|
expect(empty).toMatchObject({ available: false, frames: 0, callGraphSeen: true,
|
|
shape: { graphLines: 1, nonemptyGraphLines: 0, unrecognizedGraphLines: 0, graphEnd: 'totals', binaryImagesSeen: true } });
|
|
const unfamiliar = classifyNativeWaitSample('Call graph:\n ◇ private-row (in Security)\nBinary Images:\nprivate-image');
|
|
expect(unfamiliar).toMatchObject({ available: false, frames: 0,
|
|
shape: { nonemptyGraphLines: 1, unrecognizedGraphLines: 1, nonAsciiGraphLines: 1, imageAnnotatedLines: 1, graphEnd: 'binary_images' } });
|
|
expect(JSON.stringify(unfamiliar)).not.toContain('private');
|
|
expect(JSON.stringify(unfamiliar)).not.toContain('Security');
|
|
});
|
|
|
|
test('a pending launch is sampled once with a deadline before the native launch timeout', async () => {
|
|
let resolve!: (value: string) => void;
|
|
const pending = new Promise<string>(done => { resolve = done; });
|
|
const started = performance.now();
|
|
const deadlines: number[] = [];
|
|
const result = await observePendingBrowserLaunch(() => pending, deadline => { deadlines.push(deadline); resolve('ready'); }, started + 60_000, 5);
|
|
expect(result).toBe('ready');
|
|
expect(deadlines).toHaveLength(1);
|
|
expect(deadlines[0]).toBeLessThan(started + 30_000);
|
|
expect(deadlines[0]).toBeGreaterThan(started + 24_000);
|
|
});
|
|
|
|
test('settled launches cancel sampling and exhausted sampling windows do not extend the launch', async () => {
|
|
let observations = 0;
|
|
const observe = () => { observations++; };
|
|
expect(await observePendingBrowserLaunch(async () => 'ready', observe, performance.now() + 60_000, 5)).toBe('ready');
|
|
const original = new Error('original_launch_failure');
|
|
await expect(observePendingBrowserLaunch(async () => { throw original; }, observe, performance.now() + 60_000, 5)).rejects.toBe(original);
|
|
expect(await observePendingBrowserLaunch(async () => { await Bun.sleep(10); return 'ready'; }, observe, performance.now() + 100, 0)).toBe('ready');
|
|
await Bun.sleep(10);
|
|
expect(observations).toBe(0);
|
|
});
|
|
|
|
test('native wait sampling uses only the observed live child and never writes a stack artifact', () => {
|
|
const pid = 12345;
|
|
const uid = process.getuid!();
|
|
const child: any = { pid, executable: '/owned/Dia', closeObserved: false, process: { pid, exitCode: null, signalCode: null } };
|
|
const calls: string[][] = [];
|
|
const result = sampleOwnedDiaWait(child, uid, child.executable, performance.now() + 10_000, { HOME: root },
|
|
((command: string, args: string[], options: any) => {
|
|
calls.push([command, ...args]);
|
|
expect(Number.isInteger(options.timeout)).toBe(true);
|
|
expect(options.timeout).toBeGreaterThan(0);
|
|
expect(options.timeout).toBeLessThanOrEqual(5_000);
|
|
expect(options.killSignal).toBe('SIGKILL');
|
|
if (command === '/bin/ps') return { status: 0, stdout: `${uid} ${pid} ${process.pid} S Dia\n`, stderr: '' };
|
|
return { status: 0, stdout: 'private-header\nCall graph:\n 10 Thread_1 DispatchQueue_1: com.apple.main-thread\n + 10 CFRunLoopRun (in CoreFoundation) private-path\nBinary Images:\nprivate-image', stderr: '' };
|
|
}) as typeof spawnSync);
|
|
expect(calls).toEqual([['/bin/ps', '-p', String(pid), '-o', 'uid=,pid=,ppid=,state=,ucomm='],
|
|
['/usr/bin/sample', String(pid), '1', '10', '-file', '/dev/stdout']]);
|
|
expect(result).toMatchObject({ available: true, attempted: true, ownedLiveChildConfirmed: true, reason: 'sampled',
|
|
waitFamilies: { mainThreadFrameCounts: { runloop: 1 } } });
|
|
expect(JSON.stringify(result)).not.toContain('private-');
|
|
expect(JSON.stringify(result)).not.toContain('CFRunLoopRun');
|
|
});
|
|
|
|
test('sampling refuses changed identity, zombies, and closed roots without attempting a native sample', () => {
|
|
const uid = process.getuid!();
|
|
const child: any = { pid: 12345, executable: '/owned/Dia', closeObserved: false, process: { pid: 12345, exitCode: null, signalCode: null } };
|
|
for (const row of [`${uid + 1} 12345 ${process.pid} S Dia`, `${uid} 12345 ${process.pid + 1} S Dia`,
|
|
`${uid} 12345 ${process.pid} Z Dia`, `${uid} 12345 ${process.pid} S other`, `${uid} 12346 ${process.pid} S Dia`]) {
|
|
let calls = 0;
|
|
const result = sampleOwnedDiaWait(child, uid, child.executable, performance.now() + 10_000, {},
|
|
((command: string) => { calls++; expect(command).toBe('/bin/ps'); return { status: 0, stdout: row, stderr: '' }; }) as typeof spawnSync);
|
|
expect(calls).toBe(1);
|
|
expect(result.attempted).toBe(false);
|
|
}
|
|
for (const changed of [{ ...child, closeObserved: true }, { ...child, executable: '/other/Chromium' },
|
|
{ ...child, process: { ...child.process, exitCode: 0 } }]) {
|
|
const result = sampleOwnedDiaWait(changed, uid, '/owned/Dia', performance.now() + 10_000, {}, (() => { throw new Error('must_not_spawn'); }) as typeof spawnSync);
|
|
expect(result).toMatchObject({ available: false, attempted: false });
|
|
}
|
|
});
|
|
|
|
test('sampling reports OS permission refusal without escalation or raw failure text', () => {
|
|
const uid = process.getuid!();
|
|
const child: any = { pid: 12345, executable: '/owned/Dia', closeObserved: false, process: { pid: 12345, exitCode: null, signalCode: null } };
|
|
const result = sampleOwnedDiaWait(child, uid, child.executable, performance.now() + 10_000, {},
|
|
((command: string) => command === '/bin/ps'
|
|
? { status: 0, stdout: `${uid} 12345 ${process.pid} S Dia`, stderr: '' }
|
|
: { status: 1, stdout: '', stderr: 'Failed to get task for pid: private-process-path. Operation not permitted.' }) as typeof spawnSync);
|
|
expect(result).toMatchObject({ available: false, attempted: true, reason: 'sampling_permission_denied' });
|
|
expect(JSON.stringify(result)).not.toContain('private-process');
|
|
});
|
|
|
|
test('child close joining stays within its deadline and refuses an unconfirmed close', async () => {
|
|
await expect(joinOwnedBrowserClose({ closed: Promise.resolve(), closeObserved: false } as any, performance.now() + 1000))
|
|
.rejects.toThrow('owned_child_close_unconfirmed');
|
|
await expect(joinOwnedBrowserClose({ closed: new Promise(() => {}), closeObserved: false } as any, performance.now() + 5))
|
|
.rejects.toThrow('operation_timeout');
|
|
await expect(joinOwnedBrowserClose({ closed: Promise.resolve(), closeObserved: true } as any, 0))
|
|
.rejects.toThrow('cleanup_budget_exhausted');
|
|
});
|
|
|
|
for (const phase of ['probe', 'signal']) {
|
|
test(`an initial ${phase} error is retained while child reaping still precedes the final absence proof`, async () => {
|
|
const facts: Record<string, any> = { signalSent: false, absenceConfirmed: false };
|
|
const child: any = { pid: 12345, closeObserved: false };
|
|
child.closed = Promise.resolve().then(() => { child.closeObserved = true; });
|
|
const calls: Array<0 | 'SIGKILL'> = [];
|
|
await stopOwnedBrowserGroup(child, performance.now() + 1000, facts, (_pid, signal) => {
|
|
calls.push(signal);
|
|
if (!child.closeObserved) {
|
|
if (phase === 'signal' && signal === 0) return;
|
|
throw Object.assign(new Error('private-probe-error'), { code: 'EPERM', errno: 1 });
|
|
}
|
|
throw Object.assign(new Error('gone'), { code: 'ESRCH' });
|
|
});
|
|
expect(calls).toEqual(phase === 'probe' ? [0, 0] : [0, 'SIGKILL', 0]);
|
|
expect(facts).toMatchObject({ stage: 'completed', signalSent: false, childCloseObserved: true, absenceConfirmed: true,
|
|
initialSignalFailure: { stage: phase === 'probe' ? 'probe_before_signal' : 'signal', code: 'EPERM', errno: 1 } });
|
|
expect(JSON.stringify(facts)).not.toContain('private-probe-error');
|
|
});
|
|
}
|
|
|
|
test('permission errors and zombies never replace the required child close and actual group absence', async () => {
|
|
const facts = { signalSent: false, absenceConfirmed: false };
|
|
const waiting: any = { pid: 12345, closed: new Promise(() => {}), closeObserved: false };
|
|
const denied = () => { throw Object.assign(new Error('denied'), { code: 'EPERM' }); };
|
|
await expect(stopOwnedBrowserGroup(waiting, performance.now() + 10, facts, denied)).rejects.toThrow('operation_timeout');
|
|
expect(facts.absenceConfirmed).toBe(false);
|
|
const closed: any = { pid: 12345, closed: Promise.resolve(), closeObserved: true };
|
|
await expect(stopOwnedBrowserGroup(closed, performance.now() + 1000, facts, denied)).rejects.toMatchObject({ code: 'EPERM' });
|
|
expect(facts.absenceConfirmed).toBe(false);
|
|
await expect(stopOwnedBrowserGroup(closed, performance.now() + 10, facts, () => {})).rejects.toThrow('owned_process_group_still_live');
|
|
expect(facts.absenceConfirmed).toBe(false);
|
|
});
|
|
for (const [error, category] of [
|
|
[new Error('browserType.launchPersistentContext: browser_launch_policy_rejected synthetic-private-value'), 'launch_policy_rejected'],
|
|
[new Error('background_browser_ownership_failed'), 'ownership_unconfirmed'],
|
|
[new Error('background_browser_startup_page_rejected'), 'startup_page_rejected'],
|
|
[new Error('background_browser_render_failed'), 'render_mismatch'],
|
|
[Object.assign(new Error('synthetic-private-value'), { name: 'TimeoutError' }), 'operation_timeout'],
|
|
[new Error('native_operation_timed_out'), 'operation_timeout'],
|
|
[new Error('operation_timeout'), 'operation_timeout'],
|
|
[new Error('qualification_budget_exhausted'), 'operation_timeout'],
|
|
[new Error('source_process_ownership_unconfirmed'), 'ownership_unconfirmed'],
|
|
[new Error('destination_process_ownership_unconfirmed'), 'ownership_unconfirmed'],
|
|
[new Error('onboarding_or_external_page'), 'startup_page_rejected'],
|
|
[Object.assign(new Error('synthetic-private-value'), { code: 'ENOENT' }), 'executable_unavailable'],
|
|
[Object.assign(new Error('synthetic-private-value'), { code: 'EACCES' }), 'permission_denied'],
|
|
[{ name: 'ResolveMessage', code: 'ERR_MODULE_NOT_FOUND', message: 'synthetic-private-value' }, 'module_unavailable'],
|
|
[Object.assign(new Error('synthetic-private-value'), { code: 'MODULE_NOT_FOUND' }), 'module_unavailable'],
|
|
[Object.assign(new Error('synthetic-private-value'), { code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' }), 'module_export_unavailable'],
|
|
[Object.assign(new Error('synthetic-private-value'), { code: 'ERR_REQUIRE_ESM' }), 'module_format_error'],
|
|
[new RangeError('synthetic-private-value'), 'invalid_runtime_range'],
|
|
[new TypeError('synthetic-private-value'), 'runtime_type_error'],
|
|
[new Error('dyld[123]: Library not loaded: synthetic-private-value'), 'dynamic_library_error'],
|
|
[new Error('code signature invalid: synthetic-private-value'), 'code_signing_error'],
|
|
[new Error('ProcessSingleton synthetic-private-value'), 'browser_profile_unavailable'],
|
|
[new Error('bootstrap_check_in failed synthetic-private-value'), 'graphics_or_bootstrap_error'],
|
|
[new Error('Target page, context or browser has been closed synthetic-private-value'), 'target_closed'],
|
|
[new Error('Protocol error: synthetic-private-value'), 'protocol_error'],
|
|
[new Error('synthetic-private-value'), 'unclassified_browser_error'],
|
|
[{ code: 'synthetic-private-value', message: 'synthetic-private-value' }, 'unclassified_browser_error'],
|
|
] as const) {
|
|
test(`browser diagnostics return only the allowlisted ${category} category`, () => {
|
|
expect(browserPreflightError(error)).toBe(category);
|
|
expect(browserPreflightError(error)).not.toContain('synthetic-private-value');
|
|
});
|
|
}
|
|
|
|
test('module-load facts retain only known error identifiers and known dependency filenames', () => {
|
|
const snapshot = realpathSync(mkdtempSync(path.join(root, 'module-facts-')));
|
|
const packageDirectory = path.join(snapshot, 'node_modules/playwright');
|
|
mkdirSync(packageDirectory, { recursive: true });
|
|
writeFileSync(path.join(packageDirectory, 'package.json'), '{}', { mode: 0o600 });
|
|
const facts = playwrightModuleLoadFacts(snapshot, { name: 'ResolveMessage', code: 'ERR_MODULE_NOT_FOUND',
|
|
message: "Cannot find package 'playwright' imported from /synthetic-private-value/worker.ts" });
|
|
expect(facts.errorType).toBe('ResolveMessage');
|
|
expect(facts.errorCode).toBe('ERR_MODULE_NOT_FOUND');
|
|
expect(facts.requestedModule).toBe('playwright');
|
|
expect(facts.files['playwright/package.json']).toEqual({ exists: true, readable: true, ownedByCurrentUid: true, insideSnapshot: true });
|
|
expect(facts.files['playwright-core/lib/coreBundle.js'].exists).toBe(false);
|
|
expect(JSON.stringify(facts)).not.toContain('synthetic-private-value');
|
|
expect(JSON.stringify(facts)).not.toContain(snapshot);
|
|
const unknown = playwrightModuleLoadFacts(snapshot, { name: 'synthetic-private-value', code: 'synthetic-private-value', message: "Cannot find package 'synthetic-private-value'" });
|
|
expect(unknown.errorType).toBe('unclassified');
|
|
expect(unknown.errorCode).toBe('unclassified');
|
|
expect(unknown.requestedModule).toBe('unclassified');
|
|
expect(JSON.stringify(unknown)).not.toContain('synthetic-private-value');
|
|
});
|
|
|
|
test('Keychain snapshots preserve exact quoted paths without shell parsing', () => {
|
|
expect(parseKeychainPaths(' "/Users/runner/Library/Keychains/login.keychain-db"\n "/tmp/fixture keychain.keychain-db"\n'))
|
|
.toEqual(['/Users/runner/Library/Keychains/login.keychain-db', '/tmp/fixture keychain.keychain-db']);
|
|
for (const output of ['', 'not-json', '"relative-path"', '42', '"/valid/path"\ninvalid']) {
|
|
expect(() => parseKeychainPaths(output)).toThrow();
|
|
}
|
|
});
|
|
|
|
test('fresh users can have an empty user search list and no default Keychain', () => {
|
|
const calls: string[][] = [];
|
|
const snapshot = captureUserKeychains({ HOME: root }, [root], 1000, (args, timeout) => {
|
|
calls.push(args);
|
|
expect(timeout).toBeGreaterThan(0);
|
|
expect(timeout).toBeLessThanOrEqual(1000);
|
|
return args[0] === 'list-keychains' ? { status: 0, stdout: '', stderr: '' }
|
|
: { status: 1, stdout: '', stderr: 'security: SecKeychainCopyDomainDefault user: A default keychain could not be found.\n' };
|
|
});
|
|
expect(snapshot).toEqual({ search: [], default: [] });
|
|
expect(calls).toEqual([['list-keychains', '-d', 'user'], ['default-keychain', '-d', 'user']]);
|
|
});
|
|
|
|
test('computed Keychain timeouts reach real spawnSync as bounded integer milliseconds', () => {
|
|
let reads = 0;
|
|
const times = [0.125, 0.5, 5.75];
|
|
const clock = spyOn(performance, 'now').mockImplementation(() => times[Math.min(reads++, times.length - 1)]);
|
|
const timeouts: number[] = [];
|
|
const calls: string[][] = [];
|
|
try {
|
|
const snapshot = captureUserKeychains({ HOME: root }, [root], 10_000, (args, timeout) => {
|
|
const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--no-macros',
|
|
`--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, '-e', 'process.exit(0)'], {
|
|
cwd: root, env: { HOME: root, PATH: path.dirname(process.execPath) }, encoding: 'utf8', timeout,
|
|
});
|
|
expect(result.status).toBe(0);
|
|
expect(result.error).toBeUndefined();
|
|
timeouts.push(timeout);
|
|
calls.push(args);
|
|
return result;
|
|
});
|
|
expect(snapshot).toEqual({ search: [], default: [] });
|
|
expect(timeouts).toEqual([9999, 9994]);
|
|
expect(calls).toEqual([['list-keychains', '-d', 'user'], ['default-keychain', '-d', 'user']]);
|
|
} finally {
|
|
clock.mockRestore();
|
|
}
|
|
});
|
|
|
|
test('an expired or sub-millisecond Keychain budget never becomes an unbounded subprocess', () => {
|
|
const clock = spyOn(performance, 'now').mockReturnValue(100);
|
|
let commands = 0;
|
|
try {
|
|
for (const budget of [0.75, 0, -1, NaN, Infinity]) {
|
|
expect(() => captureUserKeychains({ HOME: root }, [root], budget, () => {
|
|
commands++;
|
|
return { status: 0, stdout: '', stderr: '' };
|
|
})).toThrow('user_keychain_probe_timeout');
|
|
}
|
|
expect(commands).toBe(0);
|
|
} finally {
|
|
clock.mockRestore();
|
|
}
|
|
});
|
|
|
|
test('permission, securityd, and transport errors are never mistaken for no default Keychain', () => {
|
|
for (const result of [
|
|
{ status: 1, stdout: '', stderr: 'security: SecKeychainCopyDefault: User interaction is not allowed.' },
|
|
{ status: 1, stdout: '', stderr: 'security: SecKeychainCopyDomainDefault system: A default keychain could not be found.' },
|
|
{ status: 1, stdout: '', stderr: 'synthetic-private-error' },
|
|
{ status: 0, stdout: '', stderr: 'synthetic-private-error' },
|
|
{ status: 1, stdout: 'unexpected-data', stderr: 'security: SecKeychainCopyDefault: A default keychain could not be found.' },
|
|
{ status: null, stdout: '', stderr: '', error: new Error('synthetic-private-error') },
|
|
]) expect(() => parseDefaultKeychain(result)).toThrow('user_default_keychain_unavailable');
|
|
expect(parseDefaultKeychain({ status: 0, stdout: '', stderr: '' })).toEqual([]);
|
|
expect(parseDefaultKeychain({ status: 1, stdout: '', stderr: 'security: SecKeychainCopyDefault: A default keychain could not be found.' })).toEqual([]);
|
|
});
|
|
|
|
test('the snapshot boundary rejects System Keychain fallback and paths outside the owned home', () => {
|
|
for (const file of ['/Library/Keychains/System.keychain', '/System/Library/Keychains/SystemRootCertificates.keychain', '/unowned/keychain']) {
|
|
expect(() => captureUserKeychains({ HOME: root }, [root], 1000, args => ({ status: 0,
|
|
stdout: args[0] === 'list-keychains' ? JSON.stringify(file) : '', stderr: '' })))
|
|
.toThrow('keychain_outside_owned_home_refused');
|
|
}
|
|
});
|
|
|
|
test('a fresh Keychain home gains only owned standard directories, not a fabricated preference file', () => {
|
|
const home = realpathSync(mkdtempSync(path.join(root, 'keychain-home-')));
|
|
expect(prepareKeychainHome(home)).toEqual({ before: { Library: false, 'Library/Preferences': false, 'Library/Keychains': false }, directoriesReady: true });
|
|
for (const directory of ['Library', 'Library/Preferences', 'Library/Keychains']) {
|
|
expect(lstatSync(path.join(home, directory)).uid).toBe(process.getuid!());
|
|
expect(lstatSync(path.join(home, directory)).isDirectory()).toBe(true);
|
|
}
|
|
const preferences = path.join(home, 'Library/Preferences/com.apple.security.plist');
|
|
expect(existsSync(preferences)).toBe(false);
|
|
expect(existsSync(path.join(home, 'Library/Safari'))).toBe(false);
|
|
writeFileSync(preferences, 'opaque fixture preferences', { mode: 0o600 });
|
|
expect(prepareKeychainHome(home).before).toEqual({ Library: true, 'Library/Preferences': true, 'Library/Keychains': true });
|
|
expect(readFileSync(preferences, 'utf8')).toBe('opaque fixture preferences');
|
|
});
|
|
|
|
test('home preparation rejects a linked or unowned home without writing through it', () => {
|
|
const home = realpathSync(mkdtempSync(path.join(root, 'linked-home-')));
|
|
const elsewhere = realpathSync(mkdtempSync(path.join(root, 'other-home-')));
|
|
symlinkSync(elsewhere, path.join(home, 'Library'), 'dir');
|
|
expect(() => prepareKeychainHome(home)).toThrow('keychain_home_unsafe');
|
|
expect(readdirSync(elsewhere)).toEqual([]);
|
|
expect(() => prepareKeychainHome(elsewhere, process.getuid!() + 1)).toThrow('keychain_home_unsafe');
|
|
expect(readdirSync(elsewhere)).toEqual([]);
|
|
});
|
|
|
|
for (const mismatch of ['search-empty', 'default-empty', 'search-path', 'default-path', 'search-error', 'default-error', 'read-mismatch', 'read-error']) {
|
|
test(`Keychain diagnostics observe the explicit read independently of ${mismatch}`, () => {
|
|
const directory = realpathSync(mkdtempSync(path.join(root, 'keychain-facts-')));
|
|
const keychain = path.join(directory, 'fixture.keychain-db');
|
|
const other = path.join(directory, 'other.keychain-db');
|
|
writeFileSync(keychain, 'opaque synthetic keychain', { mode: 0o600 });
|
|
writeFileSync(other, 'other synthetic keychain', { mode: 0o600 });
|
|
const expected = 'synthetic-private-equality-value';
|
|
const calls: string[][] = [];
|
|
const facts = observeFixtureKeychain({ HOME: directory }, [directory], keychain, expected, 1000, (args, timeout) => {
|
|
expect(Number.isInteger(timeout)).toBe(true);
|
|
expect(timeout).toBeGreaterThan(0);
|
|
calls.push(args);
|
|
if (args[0] === 'list-keychains') return mismatch === 'search-error'
|
|
? { status: 1, stdout: '', stderr: 'synthetic-private-error' }
|
|
: { status: 0, stdout: mismatch === 'search-empty' ? '' : JSON.stringify(mismatch === 'search-path' ? other : keychain), stderr: '' };
|
|
if (args[0] === 'default-keychain') return mismatch === 'default-error'
|
|
? { status: 1, stdout: '', stderr: 'synthetic-private-error' }
|
|
: mismatch === 'default-empty'
|
|
? { status: 1, stdout: '', stderr: 'security: SecKeychainCopyDomainDefault user: A default keychain could not be found.' }
|
|
: { status: 0, stdout: JSON.stringify(mismatch === 'default-path' ? other : keychain), stderr: '' };
|
|
if (mismatch === 'read-error') return { status: 1, stdout: '', stderr: 'synthetic-private-error' };
|
|
return { status: 0, stdout: mismatch === 'read-mismatch' ? 'other synthetic value' : expected + '\n', stderr: '' };
|
|
});
|
|
expect(calls).toEqual([['list-keychains', '-d', 'user'], ['default-keychain', '-d', 'user'],
|
|
['find-generic-password', '-s', 'Gstack Native Probe', '-w', keychain]]);
|
|
expect(facts.searchCount).toBe(mismatch === 'search-error' ? null : mismatch === 'search-empty' ? 0 : 1);
|
|
expect(facts.searchPathMatches).toBe(!mismatch.startsWith('search-'));
|
|
expect(facts.defaultCount).toBe(mismatch === 'default-error' ? null : mismatch === 'default-empty' ? 0 : 1);
|
|
expect(facts.defaultPathMatches).toBe(!mismatch.startsWith('default-'));
|
|
expect(facts.explicitReadAttempted).toBe(true);
|
|
expect(facts.explicitReadSucceeded).toBe(mismatch !== 'read-error');
|
|
expect(facts.explicitReadMatches).toBe(!mismatch.startsWith('read-'));
|
|
expect(JSON.stringify(facts)).not.toContain(expected);
|
|
expect(JSON.stringify(facts)).not.toContain('synthetic-private-error');
|
|
expect(JSON.stringify(facts)).not.toContain(keychain);
|
|
});
|
|
}
|
|
|
|
test('Dia Keychain diagnostics distinguish both HOME environments without configuring the shadow home', () => {
|
|
const directory = realpathSync(mkdtempSync(path.join(root, 'dual-keychain-')));
|
|
const registered = path.join(directory, 'registered');
|
|
const shadow = path.join(directory, 'shadow');
|
|
mkdirSync(registered);
|
|
mkdirSync(shadow);
|
|
prepareKeychainHome(registered);
|
|
writeFileSync(path.join(registered, 'Library/Preferences/com.apple.security.plist'), 'opaque private fixture preference');
|
|
const keychain = path.join(directory, 'fixture.keychain-db');
|
|
writeFileSync(keychain, 'opaque synthetic keychain', { mode: 0o600 });
|
|
const expected = 'synthetic-private-dual-home-value';
|
|
const environments = { keychainHome: { HOME: registered }, profileHome: { HOME: shadow } };
|
|
const calls: Array<{ home: string; args: string[] }> = [];
|
|
const observed = observeDiaKeychainEnvironments(environments, [directory], keychain, expected, 20_000, (env, args, timeout) => {
|
|
expect(Number.isInteger(timeout)).toBe(true);
|
|
expect(timeout).toBeGreaterThan(0);
|
|
expect(timeout).toBeLessThanOrEqual(10_000);
|
|
calls.push({ home: env.HOME, args });
|
|
if (args[0] === 'list-keychains') return { status: 0, stdout: env.HOME === registered ? JSON.stringify(keychain) : '', stderr: '' };
|
|
if (args[0] === 'default-keychain') return env.HOME === registered
|
|
? { status: 0, stdout: JSON.stringify(keychain), stderr: '' }
|
|
: { status: 1, stdout: '', stderr: 'security: SecKeychainCopyDomainDefault user: A default keychain could not be found.' };
|
|
expect(args.slice(0, 4)).toEqual(['find-generic-password', '-s', 'Dia Safe Storage', '-w']);
|
|
return { status: 0, stdout: expected, stderr: '' };
|
|
});
|
|
expect(observed.firstFailure).toEqual({ environment: 'profileHome', check: 'search_path' });
|
|
expect(observed.environments.keychainHome).toMatchObject({ searchCount: 1, searchPathMatches: true, defaultCount: 1, defaultPathMatches: true,
|
|
explicitReadMatches: true, lookupReadAttempted: true, lookupReadMatches: true, preferencesDirectoryExists: true, preferencesFileExists: true });
|
|
expect(observed.environments.profileHome).toMatchObject({ searchCount: 0, searchPathMatches: false, defaultCount: 0, defaultPathMatches: false,
|
|
explicitReadMatches: true, lookupReadAttempted: false, preferencesDirectoryExists: false, preferencesFileExists: false });
|
|
expect(calls).toHaveLength(7);
|
|
expect(calls.filter(call => call.home === shadow)).toHaveLength(3);
|
|
expect(calls.some(call => call.args[0] === 'default-keychain' && call.args.includes('-s'))).toBe(false);
|
|
expect(existsSync(path.join(shadow, 'Library'))).toBe(false);
|
|
const serialized = JSON.stringify(observed);
|
|
for (const privateValue of [expected, registered, shadow, keychain, 'opaque private']) expect(serialized).not.toContain(privateValue);
|
|
});
|
|
|
|
for (const failure of ['search', 'default', 'explicit_read']) {
|
|
test(`Dia diagnostic refuses unqualified lookup when the owned ${failure} proof fails`, () => {
|
|
const directory = realpathSync(mkdtempSync(path.join(root, 'guarded-keychain-')));
|
|
const keychain = path.join(directory, 'fixture.keychain-db');
|
|
writeFileSync(keychain, 'opaque synthetic keychain', { mode: 0o600 });
|
|
let implicitCalls = 0;
|
|
const result = observeFixtureKeychain({ HOME: directory }, [directory], keychain, 'expected-private-value', 1000, args => {
|
|
if (args[0] === 'list-keychains') return { status: 0, stdout: failure === 'search' ? '' : JSON.stringify(keychain), stderr: '' };
|
|
if (args[0] === 'default-keychain') return { status: 0, stdout: failure === 'default' ? '' : JSON.stringify(keychain), stderr: '' };
|
|
if (args.length === 4) implicitCalls++;
|
|
return { status: 0, stdout: failure === 'explicit_read' ? 'wrong-private-value' : 'expected-private-value', stderr: '' };
|
|
}, 'Dia Safe Storage');
|
|
expect(result.explicitReadAttempted).toBe(true);
|
|
expect(result.lookupReadAttempted).toBe(false);
|
|
expect(implicitCalls).toBe(0);
|
|
});
|
|
}
|
|
|
|
test('both Keychain environments remain observable while the first original validation failure is retained', () => {
|
|
const directory = realpathSync(mkdtempSync(path.join(root, 'keychain-failures-')));
|
|
const keychain = path.join(directory, 'fixture.keychain-db');
|
|
writeFileSync(keychain, 'opaque synthetic keychain', { mode: 0o600 });
|
|
const environments = { keychainHome: { HOME: directory }, profileHome: { HOME: directory, MARKER: 'shadow' } };
|
|
for (const keychainSearchFails of [true, false]) {
|
|
const homesObserved = new Set<Record<string, string>>();
|
|
const observed = observeDiaKeychainEnvironments(environments, [directory], keychain, 'expected-private-value', 20_000, (env, args) => {
|
|
homesObserved.add(env);
|
|
if (args[0] === 'list-keychains') return { status: 0,
|
|
stdout: env === environments.profileHome || keychainSearchFails ? '' : JSON.stringify(keychain), stderr: '' };
|
|
if (args[0] === 'default-keychain') return { status: 1, stdout: '', stderr: 'private-default-error' };
|
|
return { status: 0, stdout: 'wrong-private-value', stderr: '' };
|
|
});
|
|
expect(homesObserved.size).toBe(2);
|
|
expect(observed.firstFailure).toEqual({ environment: keychainSearchFails ? 'keychainHome' : 'profileHome', check: 'search_path' });
|
|
expect(observed.environments.keychainHome).toMatchObject({ defaultCount: null, explicitReadAttempted: true, explicitReadMatches: false });
|
|
expect(observed.environments.profileHome).toMatchObject({ defaultCount: null, explicitReadAttempted: true, explicitReadMatches: false });
|
|
expect(JSON.stringify(observed)).not.toContain('private-default-error');
|
|
expect(JSON.stringify(observed)).not.toContain('wrong-private-value');
|
|
}
|
|
});
|
|
|
|
test('Dia environment validation preserves successful and explicit-read-only outcomes', () => {
|
|
const directory = realpathSync(mkdtempSync(path.join(root, 'keychain-outcomes-')));
|
|
const keychain = path.join(directory, 'fixture.keychain-db');
|
|
writeFileSync(keychain, 'opaque synthetic keychain', { mode: 0o600 });
|
|
const environments = { keychainHome: { HOME: directory }, profileHome: { HOME: directory } };
|
|
for (const readMismatch of [false, true]) {
|
|
const observed = observeDiaKeychainEnvironments(environments, [directory], keychain, 'expected-private-value', 20_000, (env, args) => {
|
|
if (args[0] === 'list-keychains' || args[0] === 'default-keychain') return { status: 0, stdout: JSON.stringify(keychain), stderr: '' };
|
|
return { status: 0, stdout: readMismatch && env === environments.keychainHome ? 'wrong-private-value' : 'expected-private-value', stderr: '' };
|
|
});
|
|
if (readMismatch) expect(observed.firstFailure).toEqual({ environment: 'keychainHome', check: 'explicit_read' });
|
|
else expect(observed.firstFailure).toBeUndefined();
|
|
expect(observed.environments.profileHome).toMatchObject({ searchPathMatches: true, defaultPathMatches: true, explicitReadMatches: true,
|
|
lookupReadAttempted: true, lookupReadMatches: true });
|
|
}
|
|
});
|
|
|
|
test('an exhausted dual-environment diagnostic budget launches no Keychain commands', () => {
|
|
const directory = realpathSync(mkdtempSync(path.join(root, 'keychain-budget-')));
|
|
const keychain = path.join(directory, 'fixture.keychain-db');
|
|
writeFileSync(keychain, 'opaque synthetic keychain', { mode: 0o600 });
|
|
let calls = 0;
|
|
const observed = observeDiaKeychainEnvironments({ keychainHome: { HOME: directory }, profileHome: { HOME: directory } },
|
|
[directory], keychain, 'expected-private-value', 0, () => { calls++; throw new Error('must_not_spawn'); });
|
|
expect(calls).toBe(0);
|
|
expect(observed.environments.keychainHome).toMatchObject({ searchCount: null, defaultCount: null, explicitReadAttempted: false, lookupReadAttempted: false });
|
|
expect(observed.environments.profileHome).toMatchObject({ searchCount: null, defaultCount: null, explicitReadAttempted: false, lookupReadAttempted: false });
|
|
expect(observed.firstFailure).toEqual({ environment: 'keychainHome', check: 'search_path' });
|
|
});
|
|
|
|
test('an originally absent default is restored by deleting only the created fixture, never by a null default setter', () => {
|
|
expect(fixtureKeychainRestoreCommands({ search: [], default: [] }, '/owned/fixture.keychain-db', true)).toEqual([
|
|
['delete-keychain', '/owned/fixture.keychain-db'], ['list-keychains', '-d', 'user', '-s'],
|
|
]);
|
|
expect(fixtureKeychainRestoreCommands({ search: [], default: [] }, '/owned/fixture.keychain-db', false)).toEqual([
|
|
['list-keychains', '-d', 'user', '-s'],
|
|
]);
|
|
});
|
|
|
|
test('an existing default is restored before deleting the fixture, and preexisting fixture references are refused', () => {
|
|
const before = { search: ['/owned/prior.keychain-db', '/owned/other.keychain-db'], default: ['/owned/prior.keychain-db'] };
|
|
expect(fixtureKeychainRestoreCommands(before, '/owned/fixture.keychain-db', true)).toEqual([
|
|
['default-keychain', '-d', 'user', '-s', '/owned/prior.keychain-db'],
|
|
['delete-keychain', '/owned/fixture.keychain-db'],
|
|
['list-keychains', '-d', 'user', '-s', '/owned/prior.keychain-db', '/owned/other.keychain-db'],
|
|
]);
|
|
expect(() => fixtureKeychainRestoreCommands(before, '/owned/prior.keychain-db', true)).toThrow('fixture_keychain_not_fresh');
|
|
});
|
|
|
|
test('fresh account cleanup requires the same GUID, UID, private group, and registered home', () => {
|
|
const identity = { guid: 'A38AC39B-5960-4F0C-B02F-C32A4F625B33', uid: 23456, gid: 23456, home: '/private/tmp/fixture/home' };
|
|
const record = parseDirectoryRecord(`GeneratedUID: ${identity.guid}\nUniqueID: ${identity.uid}\nPrimaryGroupID: ${identity.gid}\nNFSHomeDirectory: ${identity.home}\n`);
|
|
expect(ownsFreshAccount(record, identity)).toBe(true);
|
|
for (const key of ['GeneratedUID', 'UniqueID', 'PrimaryGroupID', 'NFSHomeDirectory']) {
|
|
expect(ownsFreshAccount({ ...record, [key]: 'different' }, identity)).toBe(false);
|
|
}
|
|
expect(() => parseDirectoryRecord('UniqueID: 23456\nUniqueID: 501')).toThrow('invalid_directory_record');
|
|
});
|
|
|
|
test('user-domain absence requires an explicit matching-domain response, not an arbitrary command failure', () => {
|
|
for (const status of [64, 113]) {
|
|
const absent = classifyUserDomain(23456, { status, stdout: '', stderr: 'Bad request.\nCould not find domain for user uid: 23456\n' });
|
|
expect(absent.state).toBe('absent');
|
|
expect(absent.uid).toBe(23456);
|
|
}
|
|
for (const result of [
|
|
{ status: 1, stdout: '', stderr: 'sudo: a password is required' },
|
|
{ status: 113, stdout: '', stderr: 'Could not find domain for user uid: 23457' },
|
|
{ status: 113, stdout: '', stderr: 'Could not find domain for user gui: 23456' },
|
|
{ status: 113, stdout: '', stderr: 'synthetic-private-error' },
|
|
{ status: null, stdout: '', stderr: '', error: new Error('synthetic-private-error') },
|
|
]) expect(classifyUserDomain(23456, result).state).toBe('unavailable');
|
|
expect(() => classifyUserDomain(0, { status: 0, stdout: '', stderr: '' })).toThrow('invalid_fresh_user_domain');
|
|
});
|
|
|
|
test('user-domain inspection records only safe state facts and notices an unexpected GUI domain', () => {
|
|
const present = classifyUserDomain(23456, { status: 0, stdout: 'user/23456 = {\n type = user\n synthetic-private-value\n}', stderr: '' });
|
|
expect(present).toMatchObject({ uid: 23456, state: 'present', hasGuiDomain: false, exitCode: 0 });
|
|
expect(JSON.stringify(present)).not.toContain('synthetic-private-value');
|
|
expect(classifyUserDomain(23456, { status: 0, stdout: 'user/23456 = {\n subdomains = { gui/23456 }\n}', stderr: '' }).hasGuiDomain).toBe(true);
|
|
expect(classifyUserDomain(23456, { status: 0, stdout: 'user/23456 = {\n session = Aqua\n}', stderr: '' }).hasGuiDomain).toBe(true);
|
|
expect(classifyUserDomain(23456, { status: 0, stdout: 'user/501 = { }', stderr: '' }).state).toBe('unavailable');
|
|
});
|
|
|
|
test('domain structure retains counts and creator correlation, never arbitrary labels or environment values', () => {
|
|
const stdout = `user/23456 = {
|
|
\ttype = user
|
|
\thandle = 23456
|
|
\tactive count = 3
|
|
\ton-demand count = 0
|
|
\tservice count = 2
|
|
\tactive service count = 1
|
|
\texternal activation count = 0
|
|
\tin-progress bootstraps = 0
|
|
\tpended requests = 0
|
|
\tcreator = launchctl.4567
|
|
\tcreator euid = 0
|
|
\tenvironment = {
|
|
\t\tsynthetic-private-variable => synthetic-private-value
|
|
\t\tservice count = 999
|
|
\t}
|
|
\tservices = {
|
|
\t\t234 0 synthetic-private-service
|
|
\t\t0 0 synthetic-private-job
|
|
\t}
|
|
\tsubdomains = {
|
|
\t\tsynthetic-private-child
|
|
\t}
|
|
\tunmanaged processes = {
|
|
\t}
|
|
\tendpoints = {}
|
|
}`;
|
|
const observation = classifyUserDomain(23456, { status: 0, stdout, stderr: '' }, 4567);
|
|
expect(observation.structure).toMatchObject({ complete: true, type: 'user', handleMatchesUid: true, creator: 'launchctl', creatorIsProbe: true,
|
|
counts: { 'service count': 2, 'active service count': 1, 'creator euid': 0, 'in-progress bootstraps': 0 },
|
|
sectionNonemptyLines: { services: 2, subdomains: 1, 'unmanaged processes': 0, endpoints: 0, jobs: null } });
|
|
expect(JSON.stringify(observation)).not.toContain('synthetic-private');
|
|
expect(JSON.stringify(observation)).not.toContain('4567');
|
|
expect(classifyUserDomain(23456, { status: 0, stdout, stderr: '' }, 4568).structure?.creatorIsProbe).toBe(false);
|
|
expect(classifyUserDomain(23456, { status: 0, stdout, stderr: '' }).structure?.creatorIsProbe).toBeNull();
|
|
expect(classifyUserDomain(23456, { status: 0, stdout: stdout.replace('launchctl.4567', 'synthetic-private-creator.4567'), stderr: '' }, 4567).structure?.creator).toBe('other');
|
|
});
|
|
|
|
test('missing, malformed, duplicate, and incomplete domain facts never become empty-baseline evidence', () => {
|
|
const minimal = 'user/23456 = {\n\ttype = user\n\thandle = 23456\n}';
|
|
const observation = classifyUserDomain(23456, { status: 0, stdout: minimal, stderr: '' });
|
|
expect(observation.structure?.counts['service count']).toBeNull();
|
|
expect(observation.structure?.sectionNonemptyLines.services).toBeNull();
|
|
expect(observation.structure?.creatorIsProbe).toBeNull();
|
|
for (const stdout of [minimal.slice(0, -1), minimal.replace('\thandle', '\ttype = user\n\thandle'),
|
|
minimal.replace('\thandle = 23456', '\tservices = {\n\t\tsynthetic-private-service')]) {
|
|
expect(classifyUserDomain(23456, { status: 0, stdout, stderr: '' }).structure?.complete).toBe(false);
|
|
}
|
|
for (const value of ['-1', 'NaN', '1.5', '9007199254740992', 'synthetic-private-value']) {
|
|
const stdout = minimal.replace('\thandle', '\tservice count = ' + value + '\n\thandle');
|
|
expect(classifyUserDomain(23456, { status: 0, stdout, stderr: '' }).structure?.counts['service count']).toBeNull();
|
|
}
|
|
});
|
|
|
|
test('known bracketed creator syntax is matched exactly while unsupported spellings remain unknown', () => {
|
|
const fixture = (creator: string) => classifyUserDomain(23456, { status: 0,
|
|
stdout: `user/23456 = {\n\ttype = user\n\tcreator = ${creator}\n}`, stderr: '' }, 4567);
|
|
expect(fixture('launchctl[4567]').structure?.creatorIsProbe).toBe(true);
|
|
expect(fixture('launchctl[4568]').structure?.creatorIsProbe).toBe(false);
|
|
for (const creator of ['launchctl(4567)', 'launchctl[4567] extra', 'other[4567]', 'launchctl[0]', 'launchctl[4567)']) {
|
|
expect(fixture(creator).structure?.creatorIsProbe).toBeNull();
|
|
expect(fixture(creator).structure?.creator).toBe('other');
|
|
}
|
|
});
|
|
|
|
test('parent-domain observation extracts only candidate UID membership from complete subdomains', () => {
|
|
for (const [header, target, gui] of [
|
|
['system', 'user/23456', 'gui/23456'],
|
|
['com.apple.xpc.launchd.domain.system', 'com.apple.xpc.launchd.domain.user.23456', 'com.apple.xpc.launchd.user.domain.23456.100007.Aqua'],
|
|
]) {
|
|
const stdout = `${header} = {\n\ttype = system\n\tsubdomains = {\n\t\t${target}\n\t\t${gui}\n\t\tpid/15\n\t}\n}`;
|
|
expect(classifyParentDomain(23456, { status: 0, stdout, stderr: '' })).toMatchObject({ state: 'present', parseStage: 'parsed',
|
|
subdomainCount: 3, matchingUserDomains: 1, matchingGuiDomains: 1, unrecognizedEntries: 0 });
|
|
}
|
|
const stdout = `system = {
|
|
\ttype = system
|
|
\tenvironment = {
|
|
\t\tsynthetic-private-name => user/23456
|
|
\t\tsubdomains = {
|
|
\t\t\tuser/23456
|
|
\t\t}
|
|
\t}
|
|
\tsubdomains = {
|
|
\t\tuser/234560
|
|
\t\tgui/501
|
|
\t\tpid/123
|
|
\t\tsession/100007
|
|
\t\tcom.apple.xpc.launchd.domain.pid.synthetic-private-process.23456
|
|
\t}
|
|
\tservices = {
|
|
\t\tsynthetic-private-service-user/23456
|
|
\t}
|
|
}`;
|
|
const observation = classifyParentDomain(23456, { status: 0, stdout, stderr: '' });
|
|
expect(observation).toMatchObject({ state: 'absent', parseStage: 'parsed', subdomainCount: 5, matchingUserDomains: 0 });
|
|
expect(JSON.stringify(observation)).not.toContain('synthetic-private');
|
|
expect(JSON.stringify(observation)).not.toContain('234560');
|
|
});
|
|
|
|
test('parent-domain absence refuses incomplete, missing, duplicate, nested-only, or unknown subdomain output', () => {
|
|
const empty = 'system = {\n\ttype = system\n\tsubdomains = {}\n}';
|
|
expect(classifyParentDomain(23456, { status: 0, stdout: empty, stderr: '' }).state).toBe('absent');
|
|
for (const stdout of [empty.slice(0, -1), empty.replace('system = {', 'user/23456 = {'),
|
|
empty.replace('\tsubdomains = {}\n', ''), empty.replace('\tsubdomains = {}', '\tsubdomains = {}\n\tsubdomains = {}'),
|
|
empty.replace('type = system', 'type = user'),
|
|
empty.replace('\tsubdomains = {}', '\tenvironment = {\n\t\tsubdomains = {}\n\t}'),
|
|
empty.replace('subdomains = {}', 'subdomains = {\n\t\tunknown-private-domain\n\t}'),
|
|
empty.replace('subdomains = {}', 'subdomains = {\n\t\tuser/23456\n\t\tunknown-private-domain\n\t}')]) {
|
|
const observation = classifyParentDomain(23456, { status: 0, stdout, stderr: '' });
|
|
expect(observation.state).toBe('unavailable');
|
|
expect(JSON.stringify(observation)).not.toContain('private-domain');
|
|
}
|
|
expect(classifyParentDomain(23456, { status: 1, stdout: empty, stderr: 'synthetic-private-error' }).state).toBe('unavailable');
|
|
expect(classifyParentDomain(23456, { status: 0, stdout: empty, stderr: '', error: new Error('synthetic-private-error') }).state).toBe('unavailable');
|
|
expect(classifyParentDomain(23456, { status: 0, stdout: empty + ' '.repeat(1024 * 1024), stderr: '' }).parseStage).toBe('oversized');
|
|
});
|
|
|
|
test('the registered parent observer queries only the existing system domain under its original bounds', () => {
|
|
const env = { HOME: root, PATH: '/usr/bin:/bin' };
|
|
const observation = inspectParentDomain(23456, performance.now() + 10_000, env,
|
|
((command: string, args: string[], options: any) => {
|
|
expect(command).toBe('/usr/bin/sudo');
|
|
expect(args).toEqual(['-n', '/bin/launchctl', 'print', 'system']);
|
|
expect(options.env).toBe(env);
|
|
expect(options.maxBuffer).toBe(1024 * 1024);
|
|
expect(Number.isInteger(options.timeout)).toBe(true);
|
|
expect(options.timeout).toBeGreaterThan(0);
|
|
expect(options.timeout).toBeLessThanOrEqual(3_000);
|
|
return { status: 0, stdout: 'system = {\n\ttype = system\n\tsubdomains = {}\n}', stderr: '' };
|
|
}) as typeof spawnSync);
|
|
expect(observation.state).toBe('absent');
|
|
const never = (() => { throw new Error('must_not_spawn'); }) as typeof spawnSync;
|
|
expect(() => inspectParentDomain(501, performance.now() + 10_000, {}, never)).toThrow('invalid_fresh_user_domain');
|
|
for (const deadline of [0, NaN, Infinity]) expect(() => inspectParentDomain(23456, deadline, {}, never)).toThrow('fresh_launcher_deadline');
|
|
});
|
|
|
|
test('the registered UID observer filters numeric global ps rows without resolving an unregistered account', () => {
|
|
const env = { HOME: root, PATH: '/usr/bin:/bin' };
|
|
const observation = inspectUidProcesses(23456, performance.now() + 10_000, env,
|
|
((command: string, args: string[], options: any) => {
|
|
expect(command).toBe('/bin/ps');
|
|
expect(args).toEqual(['-axo', 'uid=,pid=,ppid=,state=,ucomm=']);
|
|
expect(options.env).toBe(env);
|
|
expect(options.maxBuffer).toBe(128 * 1024);
|
|
expect(Number.isInteger(options.timeout)).toBe(true);
|
|
expect(options.timeout).toBeGreaterThan(0);
|
|
expect(options.timeout).toBeLessThanOrEqual(2_000);
|
|
return { status: 0, stdout: '501 300 1 S synthetic-private-name\n23456 400 1 S distnoted\n', stderr: '' };
|
|
}) as typeof spawnSync);
|
|
expect(observation).toMatchObject({ available: true, count: 1, processes: [{ pid: 400, ppid: 1, state: 'S', basename: 'distnoted' }] });
|
|
expect(JSON.stringify(observation)).not.toContain('synthetic-private');
|
|
expect(inspectUidProcesses(23456, performance.now() + 10_000, {}, (() => ({ status: 0,
|
|
stdout: '501 300 1 S synthetic-private-name\n', stderr: '' })) as typeof spawnSync)).toMatchObject({ available: true, count: 0, processes: [] });
|
|
for (const result of [
|
|
{ status: 1, stdout: '', stderr: '' }, { status: 0, stdout: '', stderr: '' },
|
|
{ status: 0, stdout: 'truncated-private-row', stderr: '' },
|
|
{ status: null, stdout: null, stderr: null, error: new Error('synthetic-private-error') },
|
|
]) expect(inspectUidProcesses(23456, performance.now() + 10_000, {}, (() => result) as typeof spawnSync)).toEqual({ available: false });
|
|
});
|
|
|
|
test('numeric UID process filtering runs through the real global process table', () => {
|
|
const observation = inspectUidProcesses(23456, performance.now() + 10_000, { HOME: root, PATH: '/usr/bin:/bin' });
|
|
expect(observation.available).toBe(true);
|
|
expect('count' in observation).toBe(true);
|
|
});
|
|
|
|
test('the registered domain probe binds its creator check to the shell exec PID and bounds the query', () => {
|
|
const env = { PATH: '/usr/bin:/bin', HOME: root };
|
|
let called = false;
|
|
const observation = inspectUserDomain(23456, performance.now() + 10_000, env, ((command: string, args: string[], options: any) => {
|
|
called = true;
|
|
expect(command).toBe('/usr/bin/sudo');
|
|
expect(args).toEqual(['-n', '/bin/sh', '-c', 'printf "GSTACK_DIA_DOMAIN_PROBE_PID=%s\\n" "$$"; exec /bin/launchctl print "$1"',
|
|
'gstack-dia-domain-probe', 'user/23456']);
|
|
expect(options.env).toBe(env);
|
|
expect(options.timeout).toBeGreaterThan(0);
|
|
expect(options.timeout).toBeLessThanOrEqual(3_000);
|
|
expect(Number.isInteger(options.timeout)).toBe(true);
|
|
expect(options.maxBuffer).toBe(1024 * 1024);
|
|
return { status: 0, stdout: 'GSTACK_DIA_DOMAIN_PROBE_PID=4567\nuser/23456 = {\n\ttype = user\n\tcreator = launchctl.4567\n}', stderr: '' };
|
|
}) as typeof spawnSync);
|
|
expect(called).toBe(true);
|
|
expect(observation.state).toBe('present');
|
|
expect(observation.structure?.creatorIsProbe).toBe(true);
|
|
expect(JSON.stringify(observation)).not.toContain('GSTACK_DIA_DOMAIN_PROBE_PID');
|
|
});
|
|
|
|
test('domain probe failures preserve unknown state instead of manufacturing absence or creator ownership', () => {
|
|
for (const result of [
|
|
{ status: 0, stdout: 'user/23456 = {\n\ttype = user\n}', stderr: '' },
|
|
{ status: 0, stdout: 'GSTACK_DIA_DOMAIN_PROBE_PID=9007199254740992\nuser/23456 = {\n\ttype = user\n}', stderr: '' },
|
|
{ status: 113, stdout: '', stderr: 'Could not find domain for user uid: 23456' },
|
|
{ status: null, stdout: null, stderr: null, error: new Error('synthetic-private-error') },
|
|
]) {
|
|
const observation = inspectUserDomain(23456, performance.now() + 10_000, {}, (() => result) as typeof spawnSync);
|
|
expect(observation.state).toBe('unavailable');
|
|
expect(observation.structure).toBeUndefined();
|
|
expect(JSON.stringify(observation)).not.toContain('synthetic-private');
|
|
}
|
|
expect(inspectUserDomain(23456, performance.now() + 10_000, {}, (() => ({ status: 113,
|
|
stdout: 'GSTACK_DIA_DOMAIN_PROBE_PID=4567\n', stderr: 'Could not find domain for user uid: 23456' })) as typeof spawnSync).state).toBe('absent');
|
|
const never = (() => { throw new Error('must_not_spawn'); }) as typeof spawnSync;
|
|
expect(() => inspectUserDomain(0, performance.now() + 10_000, {}, never)).toThrow('invalid_fresh_user_domain');
|
|
for (const deadline of [0, NaN, Infinity]) expect(() => inspectUserDomain(23456, deadline, {}, never)).toThrow('fresh_launcher_deadline');
|
|
});
|
|
|
|
test('the domain probe PID protocol survives real shell exec without logging the PID or child output', () => {
|
|
const fixture = path.join(root, 'domain-probe.cjs');
|
|
writeFileSync(fixture, 'process.stdout.write("user/23456 = {\\n\\ttype = user\\n\\tcreator = launchctl." + process.pid + "\\n}");');
|
|
const observation = inspectUserDomain(23456, performance.now() + 10_000, { PATH: '/usr/bin:/bin', HOME: root },
|
|
((_command: string, args: string[], options: any) => spawnSync('/bin/sh', ['-c',
|
|
args[3].replace('/bin/launchctl print "$1"', '"$1" "$2"'), args[4], process.execPath, fixture],
|
|
{ ...options, timeout: 3_000 })) as typeof spawnSync);
|
|
expect(observation.state).toBe('present');
|
|
expect(observation.structure?.creatorIsProbe).toBe(true);
|
|
expect(observation.structure?.complete).toBe(true);
|
|
expect(JSON.stringify(observation)).not.toContain('GSTACK_DIA_DOMAIN_PROBE_PID');
|
|
});
|
|
|
|
test('user-domain teardown is bound to the new account and its pre-creation absence proof', () => {
|
|
const account = { guid: 'A38AC39B-5960-4F0C-B02F-C32A4F625B33', uid: 23456, gid: 23456, home: '/private/tmp/dn-fixture/home' };
|
|
const record = { GeneratedUID: account.guid, UniqueID: '23456', PrimaryGroupID: '23456', NFSHomeDirectory: account.home };
|
|
const proof = classifyParentDomain(23456, { status: 0, stdout: 'system = {\n\ttype = system\n\tsubdomains = {}\n}', stderr: '' });
|
|
const current = classifyParentDomain(23456, { status: 0, stdout: 'system = {\n\ttype = system\n\tsubdomains = {\n\t\tuser/23456\n\t}\n}', stderr: '' });
|
|
expect(ownedUserDomainTarget(record, account, proof, current, 501)).toBe('user/23456');
|
|
expect(ownedUserDomainTarget(record, account, proof, proof, 501)).toBeNull();
|
|
for (const key of ['GeneratedUID', 'UniqueID', 'PrimaryGroupID', 'NFSHomeDirectory']) {
|
|
expect(() => ownedUserDomainTarget({ ...record, [key]: 'changed' }, account, proof, current, 501)).toThrow('fresh_user_domain_ownership_unconfirmed');
|
|
}
|
|
expect(() => ownedUserDomainTarget(record, account, undefined, current, 501)).toThrow('fresh_user_domain_ownership_unconfirmed');
|
|
expect(() => ownedUserDomainTarget(record, account, { ...proof, state: 'present' }, current, 501)).toThrow('fresh_user_domain_ownership_unconfirmed');
|
|
expect(() => ownedUserDomainTarget(record, account, { ...proof, uid: 23457 }, current, 501)).toThrow('fresh_user_domain_ownership_unconfirmed');
|
|
expect(() => ownedUserDomainTarget(record, account, proof, current, 23456)).toThrow('fresh_user_domain_ownership_unconfirmed');
|
|
expect(() => ownedUserDomainTarget(record, account, proof, current, NaN)).toThrow('fresh_user_domain_ownership_unconfirmed');
|
|
for (const changed of [{ ...current, matchingGuiDomains: 1 }, { ...current, uid: 23457 }, { ...current, state: 'unavailable' as const },
|
|
{ ...current, duplicateEntries: 1 }, { ...current, matchingUserDomains: 2 }, { ...current, parseStage: 'missing_subdomains' },
|
|
{ ...current, unrecognizedEntries: 1 }]) {
|
|
expect(() => ownedUserDomainTarget(record, account, proof, changed, 501)).toThrow('fresh_user_domain_ownership_unconfirmed');
|
|
}
|
|
});
|
|
|
|
test('duplicated native subdomain entries and equivalent aliases never authorize teardown or absence', () => {
|
|
for (const entries of [
|
|
['user/23456', 'user/23456'], ['user/23456', 'com.apple.xpc.launchd.domain.user.23456'],
|
|
['user/23456', 'gui/23456', 'gui/23456'], ['user/501', 'user/501'],
|
|
['user/23456', 'pid/22', 'com.apple.xpc.launchd.domain.pid.synthetic-private-process.22'],
|
|
]) {
|
|
const observation = classifyParentDomain(23456, { status: 0,
|
|
stdout: 'system = {\n\ttype = system\n\tsubdomains = {\n' + entries.map(entry => '\t\t' + entry).join('\n') + '\n\t}\n}', stderr: '' });
|
|
expect(observation.duplicateEntries).toBe(1);
|
|
expect(observation.parseStage).toBe('duplicate_subdomain');
|
|
expect(observation.state).toBe('unavailable');
|
|
expect(passiveUserDomainState(observation, 23456)).toBe('unavailable');
|
|
}
|
|
});
|
|
|
|
test('passive absence and presence require consistent complete facts with no GUI association', () => {
|
|
const absent = classifyParentDomain(23456, { status: 0, stdout: 'system = {\n\ttype = system\n\tsubdomains = {}\n}', stderr: '' });
|
|
expect(passiveUserDomainState(absent, 23456)).toBe('absent');
|
|
for (const changed of [undefined, { ...absent, uid: 23457 }, { ...absent, matchingUserDomains: 1 },
|
|
{ ...absent, matchingGuiDomains: 1 }, { ...absent, exitCode: 1 }, { ...absent, duplicateEntries: 1 },
|
|
{ ...absent, unrecognizedEntries: 1 }, { ...absent, state: 'present' as const }]) {
|
|
expect(passiveUserDomainState(changed, 23456)).toBe('unavailable');
|
|
}
|
|
const gui = classifyParentDomain(23456, { status: 0,
|
|
stdout: 'system = {\n\ttype = system\n\tsubdomains = {\n\t\tuser/23456\n\t\tgui/23456\n\t}\n}', stderr: '' });
|
|
expect(passiveUserDomainState(gui, 23456)).toBe('unavailable');
|
|
const oldTargetObservation = classifyUserDomain(23456, { status: 113, stdout: '', stderr: 'Could not find domain for user uid: 23456' });
|
|
expect(passiveUserDomainState(oldTargetObservation as any, 23456)).toBe('unavailable');
|
|
});
|
|
|
|
test('directory UID inventory retains occupied candidate IDs and refuses malformed or empty listings', () => {
|
|
expect([...parseDirectoryIds('root 0\nfixture 20000\nnobody -2\nother fixture 20001 \n')]).toEqual([0, 20000, -2, 20001]);
|
|
for (const value of ['', 'malformed', 'root 0\nfixture unknown\n', 'root 0\nfixture 9007199254740992\n']) {
|
|
expect(() => parseDirectoryIds(value)).toThrow();
|
|
}
|
|
});
|
|
|
|
test('normal qualification never invokes the materializing target-domain diagnostic', () => {
|
|
const implementation = runFreshAccountQualification.toString();
|
|
expect(implementation).toContain('inspectParentDomain');
|
|
expect(implementation).toContain('passiveUserDomainState');
|
|
expect(implementation).not.toMatch(/inspectUserDomain|probeUserDomain|classifyUserDomain/);
|
|
expect(implementation).not.toMatch(/['"]print['"],\s*['"](?:user|gui)\//);
|
|
});
|
|
|
|
test('launchd receives a one-shot fresh-user security session without an Aqua or auto-login workaround', () => {
|
|
const account: any = { label: 'ai.gstack.dia.fixture', account: 'gsdiafixture', bun: '/private/tmp/fixture/bin/bun',
|
|
snapshot: '/private/tmp/fixture/repo', configFile: '/private/tmp/fixture/account.json', environment: { HOME: '/private/tmp/fixture/home', CI: 'true' } };
|
|
const definition = freshLaunchDefinition(account);
|
|
expect(definition.UserName).toBe(account.account);
|
|
expect(definition.GroupName).toBe(account.account);
|
|
expect(definition.SessionCreate).toBe(true);
|
|
expect(definition.RunAtLoad).toBe(true);
|
|
expect(definition.KeepAlive).toBe(false);
|
|
expect(definition.Umask).toBe(63);
|
|
expect(definition.ProgramArguments[0]).toBe(account.bun);
|
|
expect(definition.ProgramArguments).toContain('--fresh-worker');
|
|
expect(definition.StandardOutPath).toBe('/dev/null');
|
|
expect(definition.StandardErrorPath).toBe('/dev/null');
|
|
expect(JSON.stringify(definition)).not.toMatch(/Aqua|autoLogin|LoginWindow|GITHUB_TOKEN/);
|
|
});
|
|
|
|
test('service cleanup binds the exact system label, executable, user, and private group', () => {
|
|
const owner = { label: 'ai.gstack.dia.fixture', bun: '/private/tmp/fixture/bin/bun', account: 'gsdiafixture' };
|
|
const state = `system/${owner.label} = {\n program = ${owner.bun}\n username = ${owner.account}\n group = ${owner.account}\n}`;
|
|
expect(ownsLaunchService(state, owner)).toBe(true);
|
|
for (const replacement of [state.replace('system/', 'gui/501/'), state.replace(owner.label, 'unrelated'),
|
|
state.replace(owner.bun, '/unrelated/bun'), state.replace('username = gsdiafixture', 'username = runner'),
|
|
state.replace('group = gsdiafixture', 'group = staff')]) expect(ownsLaunchService(replacement, owner)).toBe(false);
|
|
});
|
|
|
|
test('the fresh-account launcher refuses this non-authorized invocation without privileged work', () => {
|
|
const launcher = path.resolve(import.meta.dir, '../../.github/scripts/run-dia-native-qualification.ts');
|
|
const result = spawnSync(process.execPath, ['--no-env-file', '--no-install', '--no-macros', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, launcher], {
|
|
cwd: root, env: { HOME: root, PATH: path.dirname(process.execPath) }, encoding: 'utf8', timeout: 10_000,
|
|
});
|
|
expect(result.status).toBe(2);
|
|
expect(result.stderr).toBe('');
|
|
expect(JSON.parse(result.stdout).reason).toBe('fresh_account_launcher_preflight_failed');
|
|
});
|
|
|
|
for (const shape of ['normal', 'safe-link', 'traversal', 'absolute', 'escape-link', 'symlink-parent', 'symlink-dotdot', 'hardlink', 'case-collision', 'unicode-collision', 'device']) {
|
|
test(`archive preflight classifies ${shape} before extraction`, () => {
|
|
const python = Bun.which('python3');
|
|
if (!python) throw new Error('Python 3 is required for archive boundary tests');
|
|
const archive = path.join(root, shape + '.tar');
|
|
const create = spawnSync(python, ['-I', '-c', `
|
|
import io, sys, tarfile
|
|
shape, file = sys.argv[1:]
|
|
with tarfile.open(file, 'w') as out:
|
|
def entry(name, kind=tarfile.REGTYPE, link=''):
|
|
item = tarfile.TarInfo(name); item.type = kind; item.linkname = link
|
|
if kind == tarfile.REGTYPE:
|
|
item.size = 1; out.addfile(item, io.BytesIO(b'x'))
|
|
else: out.addfile(item)
|
|
if shape == 'normal': entry('src/file.ts')
|
|
elif shape == 'safe-link': entry('target'); entry('link', tarfile.SYMTYPE, 'target')
|
|
elif shape == 'traversal': entry('../outside')
|
|
elif shape == 'absolute': entry('/outside')
|
|
elif shape == 'escape-link': entry('link', tarfile.SYMTYPE, '../outside')
|
|
elif shape == 'symlink-parent': entry('link', tarfile.SYMTYPE, 'target'); entry('link/child')
|
|
elif shape == 'symlink-dotdot': entry('b', tarfile.SYMTYPE, '.'); entry('a/link', tarfile.SYMTYPE, '../b/..')
|
|
elif shape == 'hardlink': entry('target'); entry('link', tarfile.LNKTYPE, 'target')
|
|
elif shape == 'case-collision': entry('File'); entry('file')
|
|
elif shape == 'unicode-collision': entry('Caf' + chr(233)); entry('Cafe' + chr(769))
|
|
elif shape == 'device': entry('device', tarfile.CHRTYPE)
|
|
`, shape, archive], { encoding: 'utf8', timeout: 10_000 });
|
|
expect(create.status).toBe(0);
|
|
const checked = spawnSync(python, ['-I', '-c', ARCHIVE_CHECK, archive], { encoding: 'utf8', timeout: 10_000 });
|
|
expect(checked.status).toBe(['normal', 'safe-link'].includes(shape) ? 0 : 2);
|
|
expect(JSON.parse(checked.stdout).valid).toBe(['normal', 'safe-link'].includes(shape));
|
|
expect(checked.stderr).toBe('');
|
|
});
|
|
}
|
|
|
|
test('receipt collection rejects symlinks and an unrelated owner without printing content', () => {
|
|
const python = Bun.which('python3');
|
|
if (!python) throw new Error('Python 3 is required for receipt boundary tests');
|
|
const directory = realpathSync(mkdtempSync(path.join(root, 'receipts-')));
|
|
const file = path.join(directory, 'receipt.json');
|
|
writeFileSync(file, JSON.stringify({ status: 'incomplete', reason: 'synthetic_fixture' }), { mode: 0o600 });
|
|
chmodSync(file, 0o600);
|
|
const uid = process.getuid!();
|
|
const read = (selected: string, owner: number) => spawnSync(python, ['-I', '-c', PRIVATE_RECEIPT_READ, selected, String(owner), directory], {
|
|
encoding: 'utf8', timeout: 10_000,
|
|
});
|
|
const valid = read(file, uid);
|
|
expect(valid.status).toBe(0);
|
|
expect(JSON.parse(valid.stdout).status).toBe('incomplete');
|
|
const wrong = read(file, uid + 1);
|
|
expect(wrong.status).toBe(2);
|
|
expect(wrong.stdout).toBe('');
|
|
const link = path.join(directory, 'linked.json');
|
|
symlinkSync(file, link);
|
|
const linked = read(link, uid);
|
|
expect(linked.status).toBe(2);
|
|
expect(linked.stdout).toBe('');
|
|
});
|
|
|
|
test('receipt collection rejects linked ancestors and incomplete JSON without disclosing content', () => {
|
|
const python = Bun.which('python3');
|
|
if (!python) throw new Error('Python 3 is required for receipt boundary tests');
|
|
const directory = realpathSync(mkdtempSync(path.join(root, 'receipt-ancestors-')));
|
|
const nested = path.join(directory, 'nested');
|
|
mkdirSync(nested, { mode: 0o700 });
|
|
const file = path.join(nested, 'receipt.json');
|
|
writePrivateReceipt(file, { status: 'incomplete' });
|
|
const linked = path.join(directory, 'linked');
|
|
symlinkSync(nested, linked, 'dir');
|
|
const read = (selected: string) => spawnSync(python, ['-I', '-c', PRIVATE_RECEIPT_READ, selected, String(process.getuid!()), directory], {
|
|
encoding: 'utf8', timeout: 3_000,
|
|
});
|
|
expect(read(file).status).toBe(0);
|
|
const rejected = read(path.join(linked, 'receipt.json'));
|
|
expect(rejected.status).toBe(2);
|
|
expect(rejected.stdout).toBe('');
|
|
writeFileSync(file, '{"synthetic-private-value":');
|
|
const partial = read(file);
|
|
expect(partial.status).toBe(2);
|
|
expect(partial.stdout).toBe('');
|
|
});
|
|
|
|
test('receipt publication is private, atomic, and refuses unintended overwrites or symlink targets', () => {
|
|
const directory = realpathSync(mkdtempSync(path.join(root, 'atomic-receipt-')));
|
|
const file = path.join(directory, 'receipt.json');
|
|
writePrivateReceipt(file, { status: 'incomplete', phase: 'first' });
|
|
expect(lstatSync(file).mode & 0o777).toBe(0o600);
|
|
expect(() => writePrivateReceipt(file, { status: 'passed' })).toThrow();
|
|
expect(JSON.parse(readFileSync(file, 'utf8')).phase).toBe('first');
|
|
writePrivateReceipt(file, { status: 'incomplete', phase: 'second' }, true);
|
|
expect(JSON.parse(readFileSync(file, 'utf8')).phase).toBe('second');
|
|
const link = path.join(directory, 'linked.json');
|
|
symlinkSync(file, link);
|
|
expect(() => writePrivateReceipt(link, { status: 'passed' }, true)).toThrow('unsafe_receipt_replacement');
|
|
expect(JSON.parse(readFileSync(file, 'utf8')).phase).toBe('second');
|
|
expect(readdirSync(directory).some(name => name.startsWith('.dia-receipt-'))).toBe(false);
|
|
});
|
|
|
|
test('an atomic owner-checked diagnostic receipt is readable while its producer is still alive', async () => {
|
|
const python = Bun.which('python3');
|
|
if (!python) throw new Error('Python 3 is required for receipt boundary tests');
|
|
const directory = realpathSync(mkdtempSync(path.join(root, 'live-receipt-')));
|
|
const file = path.join(directory, 'receipt.json');
|
|
const module = pathToFileURL(path.resolve(import.meta.dir, '../../.github/scripts/qualify-dia-macos.ts')).href;
|
|
const child = Bun.spawn([process.execPath, '--no-env-file', '--no-install', '--no-macros', '--config=/dev/null', '-e', `
|
|
const { writePrivateReceipt } = await import(${JSON.stringify(module)});
|
|
writePrivateReceipt(${JSON.stringify(file)}, { status: 'incomplete', reason: 'worker_diagnostic', cleanup: { complete: false } });
|
|
console.log('ready');
|
|
setInterval(() => {}, 1000);
|
|
`], { cwd: directory, env: { HOME: directory, PATH: path.dirname(process.execPath) }, stdout: 'pipe', stderr: 'pipe' });
|
|
try {
|
|
const reader = child.stdout.getReader();
|
|
expect(new TextDecoder().decode((await reader.read()).value)).toContain('ready');
|
|
reader.releaseLock();
|
|
expect(child.exitCode).toBeNull();
|
|
const result = spawnSync(python, ['-I', '-c', PRIVATE_RECEIPT_READ, file, String(process.getuid!()), directory], {
|
|
encoding: 'utf8', timeout: 3_000,
|
|
});
|
|
expect(result.status).toBe(0);
|
|
expect(JSON.parse(result.stdout)).toEqual({ status: 'incomplete', reason: 'worker_diagnostic', cleanup: { complete: false } });
|
|
expect(child.exitCode).toBeNull();
|
|
} finally {
|
|
child.kill();
|
|
await child.exited;
|
|
}
|
|
});
|
|
|
|
test('UID process diagnostics preserve zombie/service/browser distinctions without argv or arbitrary names', () => {
|
|
const facts = uidProcessFacts('20000 301 1 Ss cfprefsd\n20000 302 1 Z bun\n20000 303 301 R Google Chrome for Testing\n20000 304 1 S synthetic-private-value\n501 999 1 S security\n', 20000);
|
|
expect(facts).toEqual({ available: true, count: 4, zombies: 1, live: 3, truncated: false, processes: [
|
|
{ pid: 301, ppid: 1, state: 'S', basename: 'cfprefsd' }, { pid: 302, ppid: 1, state: 'Z', basename: 'bun' },
|
|
{ pid: 303, ppid: 301, state: 'R', basename: 'Google Chrome for Testing' }, { pid: 304, ppid: 1, state: 'S', basename: 'other' },
|
|
] });
|
|
expect(JSON.stringify(facts)).not.toContain('synthetic-private-value');
|
|
expect(JSON.stringify(facts)).not.toContain('999');
|
|
expect(uidProcessFacts('20000 401 1 S cfprefsd\n', 20000).processes[0].pid).toBe(401);
|
|
expect(uidProcessFacts('20000 402 1 Z+ bun\n', 20000)).toMatchObject({ count: 1, zombies: 1, live: 0 });
|
|
expect(() => uidProcessFacts('malformed synthetic-private-value', 20000)).toThrow('invalid_uid_process_snapshot');
|
|
expect(uidProcessFacts(Array.from({ length: 80 }, (_, index) => `20000 ${1000 + index} 1 S bun`).join('\n'), 20000))
|
|
.toMatchObject({ count: 80, truncated: true });
|
|
expect(uidProcessFacts(Array.from({ length: 80 }, (_, index) => `20000 ${1000 + index} 1 S bun`).join('\n'), 20000).processes).toHaveLength(64);
|
|
});
|
|
|
|
test('captured passing inner receipts cannot qualify a run with incomplete cleanup', () => {
|
|
const cleanup = { serviceStopped: true, userDomainStopped: true, userProcessesStopped: true, accountRemoved: true, groupRemoved: true, stagingRemoved: true };
|
|
expect(freshQualificationPassed(0, 'passed', 'passed', cleanup)).toBe(true);
|
|
for (const key of Object.keys(cleanup)) expect(freshQualificationPassed(0, 'passed', 'passed', { ...cleanup, [key]: false })).toBe(false);
|
|
expect(freshQualificationPassed(0, 'passed', 'passed', {})).toBe(false);
|
|
expect(freshQualificationPassed(2, 'passed', 'passed', cleanup)).toBe(false);
|
|
expect(freshQualificationPassed(0, 'incomplete', 'passed', cleanup)).toBe(false);
|
|
});
|
|
|
|
test('the registered spawn observer records the actual owned child and closes launch admission', async () => {
|
|
const profile = path.join(root, 'profile');
|
|
const childProcess = require('node:child_process');
|
|
const original = childProcess.spawn;
|
|
const observer = observeBrowserLaunches(new Map([[process.execPath, profile]]));
|
|
const args = ['--no-env-file', '--no-install', '-e', 'process.exit(0)', '--', '--remote-debugging-pipe', '--user-data-dir=' + profile];
|
|
const options = { cwd: root, detached: true, shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'],
|
|
env: { PATH: path.dirname(process.execPath), HOME: root, ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}) } };
|
|
try {
|
|
const child = childProcess.spawn(process.execPath, args, options);
|
|
const code = await new Promise(resolve => child.once('close', resolve));
|
|
expect(code).toBe(0);
|
|
expect(observer.children).toHaveLength(1);
|
|
expect(observer.children[0].process).toBe(child);
|
|
expect(observer.children[0].pid).toBe(child.pid);
|
|
observer.stop();
|
|
expect(() => childProcess.spawn(process.execPath, args, options)).toThrow('browser_launch_policy_rejected');
|
|
expect(observer.children).toHaveLength(1);
|
|
} finally {
|
|
observer.restore();
|
|
}
|
|
expect(childProcess.spawn).toBe(original);
|
|
});
|
|
|
|
test('the owned spawn stderr observer preserves the consumer stream and removes only its own listeners', async () => {
|
|
const childProcess = require('node:child_process');
|
|
const original = childProcess.spawn;
|
|
const profile = path.join(root, 'stderr-observer-profile');
|
|
const observer = observeBrowserLaunches(new Map([[process.execPath, profile]]));
|
|
const text = 'DevTools remote debugging requires a non-default data directory.\nsynthetic-private-value\n';
|
|
try {
|
|
const child = childProcess.spawn(process.execPath, ['--no-env-file', '--no-install', '-e',
|
|
`process.stderr.write(${JSON.stringify(text)})`, '--', '--remote-debugging-pipe', '--user-data-dir=' + profile], {
|
|
detached: true, shell: false, stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'], env: { HOME: root, PATH: path.dirname(process.execPath) },
|
|
});
|
|
const chunks: Buffer[] = [];
|
|
const consumer = (chunk: Buffer) => { chunks.push(chunk); };
|
|
child.stderr.on('data', consumer);
|
|
const code = await new Promise(resolve => child.once('close', resolve));
|
|
expect(code).toBe(0);
|
|
expect(Buffer.concat(chunks).toString()).toBe(text);
|
|
const facts = browserStderrFacts(observer.children);
|
|
expect(facts).toHaveLength(1);
|
|
expect(facts[0]).toMatchObject({ available: true, bytesSeen: Buffer.byteLength(text), ended: true,
|
|
reasonCounts: { default_profile_policy: 1 } });
|
|
expect(JSON.stringify(facts)).not.toContain('synthetic-private');
|
|
observer.restore();
|
|
expect(child.stderr.listeners('data')).toContain(consumer);
|
|
expect(childProcess.spawn).toBe(original);
|
|
} finally { observer.restore(); }
|
|
});
|
|
|
|
test('an owned killed child is joined before proving its process group absent', async () => {
|
|
const childProcess = require('node:child_process');
|
|
const profile = path.join(root, 'reaping-profile');
|
|
const observer = observeBrowserLaunches(new Map([[process.execPath, profile]]));
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
try {
|
|
const child = childProcess.spawn(process.execPath, ['--no-env-file', '--no-install', '-e',
|
|
'process.stdout.write("ready\\n"); setInterval(() => {}, 1000)', '--', '--remote-debugging-pipe', '--user-data-dir=' + profile], {
|
|
detached: true, shell: false, stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'], env: { HOME: root, PATH: path.dirname(process.execPath) },
|
|
});
|
|
await Promise.race([new Promise(resolve => child.stdout.once('data', resolve)), new Promise((_, reject) => {
|
|
timer = setTimeout(() => reject(new Error('fixture_startup_timeout')), 3000);
|
|
})]);
|
|
clearTimeout(timer);
|
|
const owned = observer.children[0];
|
|
expect(owned.closeObserved).toBe(false);
|
|
const until = performance.now() + 5_000;
|
|
const facts: Record<string, any> = { signalSent: false, absenceConfirmed: false };
|
|
await stopOwnedBrowserGroup(owned, until, facts);
|
|
expect(owned.closeObserved).toBe(true);
|
|
expect(facts).toMatchObject({ signalSent: true, childCloseObserved: true, absenceConfirmed: true });
|
|
let absent = false;
|
|
while (performance.now() < until) {
|
|
try { process.kill(-owned.pid, 0); }
|
|
catch (error: any) { if (error.code === 'ESRCH') { absent = true; break; } throw error; }
|
|
await Bun.sleep(10);
|
|
}
|
|
expect(absent).toBe(true);
|
|
expect(browserRootFacts(observer.children)[0]).toMatchObject({ closeObserved: true, signal: 'SIGKILL' });
|
|
} finally {
|
|
clearTimeout(timer);
|
|
for (const owned of observer.children) {
|
|
if (owned.closeObserved) continue;
|
|
try { process.kill(-owned.pid, 'SIGKILL'); } catch {}
|
|
await joinOwnedBrowserClose(owned, performance.now() + 5_000);
|
|
}
|
|
observer.restore();
|
|
}
|
|
});
|
|
|
|
test('the pinned Playwright launch is captured with the observer installed after importing Playwright', async () => {
|
|
const { chromium } = await import('playwright');
|
|
expect(require('playwright/package.json').version).toBe('1.62.1');
|
|
const executable = realpathSync(chromium.executablePath());
|
|
const profile = path.join(root, 'playwright-profile');
|
|
const observer = observeBrowserLaunches(new Map([[executable, profile]]));
|
|
let context: Awaited<ReturnType<typeof chromium.launchPersistentContext>> | undefined;
|
|
try {
|
|
const nativeOptions = nativeDiaLaunchOptions(executable, {
|
|
HOME: root, PATH: path.dirname(process.execPath) + ':/usr/bin:/bin:/usr/sbin:/sbin',
|
|
...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}),
|
|
});
|
|
expect(nativeOptions.chromiumSandbox).toBe(true);
|
|
context = await chromium.launchPersistentContext(profile, nativeOptions);
|
|
expect(observer.children).toHaveLength(1);
|
|
expect(observer.children[0].executable).toBe(executable);
|
|
expect(observer.children[0].pid).toBeGreaterThan(1);
|
|
expect(browserRootFacts(observer.children)).toEqual([{ pid: observer.children[0].pid, exitCode: null, closeObserved: false, signal: null }]);
|
|
expect(browserStartupFacts(context.pages().map(page => page.url()), 'http://127.0.0.1:8123').allowed).toBe(true);
|
|
expect(browserStderrFacts(observer.children)[0].available).toBe(true);
|
|
expect(browserStderrFacts(observer.children)[0].bytesInspected).toBeLessThanOrEqual(65_536);
|
|
expect(observer.attempts).toHaveLength(1);
|
|
expect(observer.attempts[0]).toEqual({ admissionOpen: true, argumentsArray: true, pipeFlag: true, profileArgumentCount: 1,
|
|
expectedProfile: true, detached: true, shellDisabled: true, stdioCount: 5, extraPipeDescriptors: true,
|
|
headlessFlag: true, blankStartupArgument: true, tcpDebuggingFlag: false, mockKeychainFlag: false,
|
|
passwordStoreFlag: false, firstRunSuppressed: false, sandboxRequired: process.platform === 'darwin', sandboxDisablingFlag: false });
|
|
expect(JSON.stringify(observer.attempts)).not.toContain(executable);
|
|
expect(JSON.stringify(observer.attempts)).not.toContain(profile);
|
|
const page = context.pages()[0] ?? await context.newPage();
|
|
await page.setContent('<div id="fixture">isolated browser smoke</div>');
|
|
expect(await page.locator('#fixture').innerText()).toBe('isolated browser smoke');
|
|
await context.close();
|
|
context = undefined;
|
|
expect(observer.children[0].process.exitCode !== null || observer.children[0].process.signalCode !== null).toBe(true);
|
|
} finally {
|
|
observer.stop();
|
|
await context?.close();
|
|
observer.restore();
|
|
}
|
|
}, 40_000);
|
|
|
|
for (const forbidden of ['--remote-debugging-port=9222', '--use-mock-keychain', '--password-store=basic', '--no-first-run']) {
|
|
test(`the actual spawn callback rejects ${forbidden} before process creation`, () => {
|
|
const profile = path.join(root, 'profile');
|
|
const childProcess = require('node:child_process');
|
|
const observer = observeBrowserLaunches(new Map([[process.execPath, profile]]));
|
|
try {
|
|
expect(() => childProcess.spawn(process.execPath, ['--remote-debugging-pipe', '--user-data-dir=' + profile, forbidden],
|
|
{ detached: true, shell: false, stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'] })).toThrow('browser_launch_policy_rejected');
|
|
expect(observer.children).toHaveLength(0);
|
|
} finally { observer.restore(); }
|
|
});
|
|
}
|
|
|
|
test('the spawn callback rejects an unowned profile, shell expansion, and a shared process group', () => {
|
|
const profile = path.join(root, 'profile');
|
|
const childProcess = require('node:child_process');
|
|
const observer = observeBrowserLaunches(new Map([[process.execPath, profile]]));
|
|
try {
|
|
for (const [selected, detached, shell] of [[path.join(root, 'other'), true, false], [profile, false, false], [profile, true, true]]) {
|
|
expect(() => childProcess.spawn(process.execPath, ['--remote-debugging-pipe', '--user-data-dir=' + selected], { detached, shell, stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'] }))
|
|
.toThrow('browser_launch_policy_rejected');
|
|
}
|
|
expect(() => childProcess.spawn(process.execPath, ['--remote-debugging-pipe', '--user-data-dir=' + profile, '--user-data-dir=' + path.join(root, 'other')],
|
|
{ detached: true, shell: false, stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'] })).toThrow('browser_launch_policy_rejected');
|
|
expect(() => childProcess.spawn(process.execPath, ['--remote-debugging-pipe', '--user-data-dir=' + profile],
|
|
{ detached: true, shell: false, stdio: 'inherit' })).toThrow('browser_launch_policy_rejected');
|
|
expect(observer.children).toHaveLength(0);
|
|
} finally { observer.restore(); }
|
|
});
|
|
});
|