diff --git a/.github/workflows/desktop-connect-discovery-guard.yml b/.github/workflows/desktop-connect-discovery-guard.yml index 6290ab9f7..b98f6808e 100644 --- a/.github/workflows/desktop-connect-discovery-guard.yml +++ b/.github/workflows/desktop-connect-discovery-guard.yml @@ -82,6 +82,10 @@ jobs: - name: Package the target-native desktop app run: npm run desktop:package + - name: Run native concurrent Windows authority broker regressions + if: matrix.platform == 'win32' + run: node apps/desktop/scripts/windows-authority-batch-regression.mjs + - name: Run packaged Linux main-to-renderer discovery if: matrix.platform == 'linux' shell: bash diff --git a/apps/desktop/scripts/packaged-connect-lifecycle.mjs b/apps/desktop/scripts/packaged-connect-lifecycle.mjs index 93deaaffb..9fd717d09 100644 --- a/apps/desktop/scripts/packaged-connect-lifecycle.mjs +++ b/apps/desktop/scripts/packaged-connect-lifecycle.mjs @@ -1,10 +1,14 @@ import { spawn as nodeSpawn } from 'node:child_process'; -import { lstat, realpath, rm } from 'node:fs/promises'; +import { lstat, open, realpath, rm } from 'node:fs/promises'; import { basename, dirname, isAbsolute, relative } from 'node:path'; import { TextDecoder } from 'node:util'; import { fileURLToPath } from 'node:url'; +import { + CONNECT_READY_EVENT, + isExactConnectReadyRecord, +} from './packaged-connect-ready.mjs'; -export const CONNECT_READY_EVENT = 'desktop.renderer.connect_discovery.ready'; +export { CONNECT_READY_EVENT }; export const CHILD_CAPTURE_MAX_BYTES = 64 * 1024; export const CHILD_DIAGNOSTIC_MAX_RECORDS = 20; @@ -24,6 +28,8 @@ const diagnosticEvents = new Set([ 'desktop.main_process.uncaught_exception', CONNECT_READY_EVENT, 'desktop.renderer.connect_discovery.phase', + // #2056 must enumerate this event in its nested diagnostic-record allowlist. + 'desktop.renderer.connect_discovery.proof', 'desktop.renderer.connect_discovery.status', 'desktop.renderer.gone', 'desktop.renderer.ready', @@ -60,6 +66,16 @@ const diagnosticCategories = new Set([ 'type-mismatch', 'unexpected', ]); +const failureMilestones = new Map([ + ['desktop.app.ready', 'app-ready'], + ['desktop.renderer.ready', 'renderer-ready'], + ['desktop.renderer.connect_discovery.proof', 'connect-proof'], + [CONNECT_READY_EVENT, 'ready-publication'], +]); +const allowedFailureMilestones = new Set(failureMilestones.values()); +const failureMilestoneEvents = new Map( + [...failureMilestones].map(([event, milestone]) => [milestone, event]), +); export const boundedChildDiagnostics = records => records.flatMap(record => { if (!record || typeof record !== 'object' || !diagnosticEvents.has(record.event)) return []; @@ -81,25 +97,41 @@ export const boundedChildDiagnostics = records => records.flatMap(record => { }]; }).slice(0, CHILD_DIAGNOSTIC_MAX_RECORDS); -const exactKeys = (record, expected) => { - const actual = Object.keys(record).sort(); - return actual.length === expected.length && actual.every((key, index) => key === expected[index]); -}; +export const isExactReadyRecord = isExactConnectReadyRecord; -export const isExactReadyRecord = (record, { platform, arch, authorityMechanism }) => { - if (!record || typeof record !== 'object' || Array.isArray(record)) return false; - if (!exactKeys(record, [ - 'authorityMechanism', 'event', 'level', 'rendererSchemaValid', - 'selectedArch', 'selectedPlatform', 'timestamp', - ])) return false; - return record.event === CONNECT_READY_EVENT - && record.level === 'info' - && typeof record.timestamp === 'string' - && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(record.timestamp) - && record.selectedPlatform === platform - && record.selectedArch === arch - && record.authorityMechanism === authorityMechanism - && record.rendererSchemaValid === true; +export const readPackagedConnectFailureMilestone = async evidencePath => { + if (typeof evidencePath !== 'string' || !isAbsolute(evidencePath)) return undefined; + let handle; + try { + handle = await open(evidencePath, 'r'); + const before = await handle.stat(); + if (!before.isFile() || before.size <= 0 || before.size > CHILD_CAPTURE_MAX_BYTES) return undefined; + const bytes = Buffer.alloc(before.size); + let offset = 0; + while (offset < bytes.byteLength) { + const result = await handle.read(bytes, offset, bytes.byteLength - offset, offset); + if (!Number.isSafeInteger(result.bytesRead) || result.bytesRead <= 0) return undefined; + offset += result.bytesRead; + } + const after = await handle.stat(); + if (after.dev !== before.dev || after.ino !== before.ino || after.size !== before.size) return undefined; + const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + if (!text.endsWith('\n')) return undefined; + let lastMilestone; + for (const line of text.slice(0, -1).split('\n')) { + let record; + try { record = JSON.parse(line); } catch { continue; } + if (!record || typeof record !== 'object' || Array.isArray(record) + || Object.keys(record).length !== 1 || typeof record.event !== 'string') continue; + const milestone = failureMilestones.get(record.event); + if (milestone) lastMilestone = milestone; + } + return lastMilestone; + } catch { + return undefined; + } finally { + await handle?.close().catch(() => undefined); + } }; const createRecordCapture = ({ sensitiveNeedles, onRecord, onSensitiveOutput }) => { @@ -366,11 +398,13 @@ export const runPackagedConnectLifecycle = async ({ terminationTimeoutMs = 10_000, streamDrainTimeoutMs = 5_000, requestShutdown = () => undefined, + readFailureMilestone, }) => { const records = []; const first = deferred(); let firstSettled = false; let invalidReadyObserved = false; + let readyRecordCount = 0; let child; const settleFirst = value => { if (firstSettled) return; @@ -383,6 +417,7 @@ export const runPackagedConnectLifecycle = async ({ onRecord: record => { if (records.length < RECORD_MAX_COUNT) records.push(record); if (record.event !== CONNECT_READY_EVENT) return; + readyRecordCount += 1; const valid = isExactReadyRecord(record, { platform, arch, authorityMechanism }); if (!valid) invalidReadyObserved = true; settleFirst(valid ? { category: 'ready' } : { category: 'ready-validation' }); @@ -432,11 +467,12 @@ export const runPackagedConnectLifecycle = async ({ }); close = await waitForClose(child, streamDrainTimeoutMs); streamsDrained = await drainChildStreams(child, streamDrainTimeoutMs); - primary = closeIsClean(close) && streamsDrained - ? 'ready-clean-exit' - : terminationSucceeded && close.closed && streamsDrained - ? 'ready-forced-exit' - : 'tree-termination'; + // These two categories are intentionally fixed protocol values. The + // #2056 PowerShell parser must enumerate child-remained-alive and + // ready-duplicate in its lifecycle allowlist; they are not a broad union. + primary = terminationSucceeded && close.closed && streamsDrained + ? 'child-remained-alive' + : 'tree-termination'; } } else if (primary === 'child-exit') { primary = 'child-exit-before-ready'; @@ -454,10 +490,12 @@ export const runPackagedConnectLifecycle = async ({ if (!streamsDrained) streamsDrained = await drainChildStreams(child, streamDrainTimeoutMs); capture.finish(); const captureResult = capture.result(); - if (primary === 'ready-clean-exit' || primary === 'ready-forced-exit') { + if (primary === 'ready-clean-exit') { if (captureResult.sensitiveOutput || captureResult.capture === 'truncated') { primary = 'output-rejected'; - } else if (invalidReadyObserved) primary = 'ready-validation'; + } else if (invalidReadyObserved) { + primary = 'ready-validation'; + } else if (readyRecordCount !== 1) primary = 'ready-duplicate'; } const secondary = []; if (terminationAttempted && !terminationSucceeded && primary !== 'ready-clean-exit') { @@ -470,8 +508,27 @@ export const runPackagedConnectLifecycle = async ({ child.stderr?.destroy(); child.unref?.(); } + const failureDiagnosticsAuthorized = primary !== 'ready-clean-exit' + && close?.closed + && streamsDrained + && (!terminationAttempted || terminationSucceeded); + if (failureDiagnosticsAuthorized && typeof readFailureMilestone === 'function') { + try { + const boundedMilestone = await withTimeout( + Promise.resolve().then(() => readFailureMilestone()), + streamDrainTimeoutMs, + ); + const candidate = boundedMilestone.timedOut ? undefined : boundedMilestone.value; + if (allowedFailureMilestones.has(candidate)) { + const event = failureMilestoneEvents.get(candidate); + // Evidence is appended only to the bounded diagnostic input. It never + // increments readyRecordCount and can never authorize READY. + if (event && records.length < RECORD_MAX_COUNT) records.push({ event }); + } + } catch { /* Diagnostic attribution cannot replace the primary lifecycle result. */ } + } return { - ok: primary === 'ready-clean-exit' || primary === 'ready-forced-exit', + ok: primary === 'ready-clean-exit', category: primary, capture: captureResult.capture, records: boundedChildDiagnostics(records), diff --git a/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs b/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs index 65c9639e0..1db7b3ca7 100644 --- a/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs +++ b/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; -import { lstat, mkdtemp, realpath, rm } from 'node:fs/promises'; +import { lstat, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { PassThrough } from 'node:stream'; @@ -10,6 +10,7 @@ import { CONNECT_READY_EVENT, isExactReadyRecord, preservePrimaryWithCleanup, + readPackagedConnectFailureMilestone, removeAuthorizedConnectFixture, runPackagedConnectLifecycle, } from './packaged-connect-lifecycle.mjs'; @@ -106,7 +107,7 @@ describe('packaged Connect bounded child lifecycle', () => { assert.equal(invocations.length, 1); }); - test('forces a ready app with a hung descendant through an exact bounded taskkill invocation', async () => { + test('terminates but rejects a ready app that remains alive past the shutdown bound', async () => { const { result, invocations } = await run({ onApp: app => app.write(readyRecord()), onKiller: (killer, app) => { @@ -114,8 +115,8 @@ describe('packaged Connect bounded child lifecycle', () => { killer.close(0, null); }, }); - assert.equal(result.ok, true); - assert.equal(result.category, 'ready-forced-exit'); + assert.equal(result.ok, false); + assert.equal(result.category, 'child-remained-alive'); assert.equal(invocations.length, 2); assert.deepEqual(invocations[1].args, ['/PID', '4242', '/T', '/F']); assert.equal(invocations[1].options.shell, false); @@ -159,7 +160,7 @@ describe('packaged Connect bounded child lifecycle', () => { assert.equal(result.ok, false); }); - test('accepts a clean post-proof close racing a taskkill no-process result', async () => { + test('rejects a post-bound close racing a taskkill no-process result', async () => { const { result } = await run({ onApp: app => app.write(readyRecord()), onKiller: (killer, app) => { @@ -167,12 +168,9 @@ describe('packaged Connect bounded child lifecycle', () => { killer.close(128, null); }, }); - assert.deepEqual(result, { - ok: true, - category: 'ready-clean-exit', - capture: 'complete', - records: [{ event: CONNECT_READY_EVENT }], - }); + assert.equal(result.ok, false); + assert.equal(result.category, 'tree-termination'); + assert.deepEqual(result.secondary, ['tree-termination-failed']); }); test('rejects malformed, partial, truncated, and extra-field ready records', async () => { @@ -208,6 +206,22 @@ describe('packaged Connect bounded child lifecycle', () => { assert.deepEqual(result.records, [{ event: CONNECT_READY_EVENT }]); }); + test('rejects duplicate exact READY records after a clean close', async () => { + const { result } = await run({ + onApp: app => { + app.write(readyRecord()); + app.write(readyRecord({ timestamp: '2026-09-01T22:00:01.000Z' })); + queueMicrotask(() => app.close(0, null)); + }, + }); + assert.equal(result.ok, false); + assert.equal(result.category, 'ready-duplicate'); + assert.deepEqual(result.records, [ + { event: CONNECT_READY_EVENT }, + { event: CONNECT_READY_EVENT }, + ]); + }); + test('fails after proof when Windows tree termination cannot be proven', async () => { const { result } = await run({ onApp: app => app.write(readyRecord()), @@ -337,6 +351,68 @@ describe('packaged Connect bounded child lifecycle', () => { }); }); +describe('packaged Connect fixed failure milestone attribution', () => { + test('reads only the last allowlisted event-only milestone from bounded evidence', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-connect-milestone-')); + const evidence = join(directory, 'application.smoke-evidence.jsonl'); + try { + await writeFile(evidence, [ + JSON.stringify({ event: 'desktop.app.ready' }), + JSON.stringify({ event: 'desktop.renderer.ready' }), + JSON.stringify({ event: 'desktop.renderer.connect_discovery.proof' }), + JSON.stringify({ event: CONNECT_READY_EVENT, path: privateWindowsPath }), + JSON.stringify({ event: 'untrusted.event' }), + '', + ].join('\n')); + assert.equal(await readPackagedConnectFailureMilestone(evidence), 'connect-proof'); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + test('reads diagnostics only after failed lifecycle termination and stream drain', async () => { + let diagnosticReads = 0; + const { result } = await run({ + onKiller: (killer, app) => { + app.close(null, 'SIGKILL'); + killer.close(0, null); + }, + readFailureMilestone: async () => { + diagnosticReads += 1; + return 'connect-proof'; + }, + }); + assert.equal(diagnosticReads, 1); + assert.equal(result.category, 'timeout-before-ready'); + assert.deepEqual(result.records, [{ event: 'desktop.renderer.connect_discovery.proof' }]); + assert.equal(Object.hasOwn(result, 'lastMilestone'), false); + }); + + test('does not read evidence after success or unproven tree termination', async () => { + let diagnosticReads = 0; + const readFailureMilestone = async () => { + diagnosticReads += 1; + return privateWindowsPath; + }; + const success = await run({ + onApp: app => { + app.write(readyRecord()); + queueMicrotask(() => app.close(0, null)); + }, + readFailureMilestone, + }); + assert.equal(success.result.ok, true); + const failedTermination = await run({ + onKiller: killer => killer.close(1, null), + readFailureMilestone, + }); + assert.equal(failedTermination.result.category, 'timeout-before-ready'); + assert.equal(diagnosticReads, 0); + assert.equal(Object.hasOwn(failedTermination.result, 'lastMilestone'), false); + assert.doesNotMatch(JSON.stringify(failedTermination.result), /private-user|SENTINEL/u); + }); +}); + describe('packaged Connect fixture cleanup', () => { const fixture = '/canonical-temp/propr-desktop-connect-smoke-AbC123'; const stats = { isDirectory: () => true, isSymbolicLink: () => false }; diff --git a/apps/desktop/scripts/packaged-connect-ready-child.mjs b/apps/desktop/scripts/packaged-connect-ready-child.mjs new file mode 100644 index 000000000..c00118e73 --- /dev/null +++ b/apps/desktop/scripts/packaged-connect-ready-child.mjs @@ -0,0 +1,15 @@ +import { + createConnectReadyPublisher, + createConnectReadyRecord, +} from './packaged-connect-ready.mjs'; + +if (process.platform !== 'win32' || !['x64', 'arm64'].includes(process.arch)) process.exit(2); + +const expected = { + platform: process.platform, + arch: process.arch, + authorityMechanism: 'inherited-standard-handle', +}; +const result = createConnectReadyPublisher().publish(createConnectReadyRecord(expected), expected); +if (!result.ok) process.exit(3); +if (process.argv[2] === 'remain-alive') setInterval(() => undefined, 1000); diff --git a/apps/desktop/scripts/packaged-connect-ready-regression.mjs b/apps/desktop/scripts/packaged-connect-ready-regression.mjs new file mode 100644 index 000000000..4b772aa98 --- /dev/null +++ b/apps/desktop/scripts/packaged-connect-ready-regression.mjs @@ -0,0 +1,93 @@ +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + CONNECT_READY_EVENT, + createConnectReadyPublisher, + createConnectReadyRecord, + isExactConnectReadyRecord, +} from './packaged-connect-ready.mjs'; +import { runPackagedConnectLifecycle } from './packaged-connect-lifecycle.mjs'; + +const childFixture = fileURLToPath(new URL('./packaged-connect-ready-child.mjs', import.meta.url)); + +/** Run inside the ordinary-user Windows gate so Node owns the actual inherited pipe. */ +export const verifyNativeWindowsReadyPipeRegression = async ({ treeKillerPath }) => { + if (process.platform !== 'win32') return; + const expected = { + platform: process.platform, + arch: process.arch, + authorityMechanism: 'inherited-standard-handle', + }; + const record = createConnectReadyRecord(expected); + const chunks = []; + const partial = createConnectReadyPublisher({ + writeSync(_descriptor, bytes, offset, length) { + const progress = Math.min(7, length); + chunks.push(Buffer.from(bytes.subarray(offset, offset + progress))); + return progress; + }, + }); + if (!partial.publish(record, expected).ok) { + throw new Error('Native Windows READY partial-write regression failed'); + } + const partialOutput = Buffer.concat(chunks).toString('utf8'); + let parsedPartial; + try { parsedPartial = JSON.parse(partialOutput); } catch { /* Fixed failure below. */ } + if (!partialOutput.endsWith('\n') || partialOutput.slice(0, -1).includes('\n') + || !isExactConnectReadyRecord(parsedPartial, expected)) { + throw new Error('Native Windows READY partial-write regression failed'); + } + const zeroProgress = createConnectReadyPublisher({ writeSync: () => 0 }).publish(record, expected); + if (zeroProgress.ok || zeroProgress.category !== 'zero-progress') { + throw new Error('Native Windows READY zero-progress regression failed'); + } + const brokenPipe = createConnectReadyPublisher({ + writeSync() { throw new Error('discarded'); }, + }).publish(record, expected); + if (brokenPipe.ok || brokenPipe.category !== 'broken-pipe') { + throw new Error('Native Windows READY broken-pipe regression failed'); + } + const duplicateBytes = []; + const duplicate = createConnectReadyPublisher({ + writeSync(_descriptor, bytes, offset, length) { + duplicateBytes.push(Buffer.from(bytes.subarray(offset, offset + length))); + return length; + }, + }); + if (!duplicate.publish(record, expected).ok + || duplicate.publish(record, expected).category !== 'duplicate' + || Buffer.concat(duplicateBytes).toString('utf8').trimEnd().split('\n').length !== 1) { + throw new Error('Native Windows READY duplicate regression failed'); + } + let invalidWriteAttempted = false; + const wrongSchema = createConnectReadyPublisher({ + writeSync() { invalidWriteAttempted = true; return 1; }, + }).publish({ ...record, rendererSchemaValid: 'true' }, expected); + if (wrongSchema.ok || wrongSchema.category !== 'schema' || invalidWriteAttempted) { + throw new Error('Native Windows READY schema regression failed'); + } + const run = behavior => runPackagedConnectLifecycle({ + binaryPath: process.execPath, + args: [childFixture, behavior], + env: {}, + cwd: dirname(childFixture), + ...expected, + treeKillerPath, + readyTimeoutMs: 5_000, + shutdownGraceMs: 100, + terminationTimeoutMs: 5_000, + streamDrainTimeoutMs: 2_000, + }); + const clean = await run('clean'); + if (!clean.ok || clean.category !== 'ready-clean-exit' + || clean.capture !== 'complete' + || clean.records.length !== 1 + || clean.records[0]?.event !== CONNECT_READY_EVENT) { + throw new Error('Native Windows READY pipe clean-close regression failed'); + } + const remainedAlive = await run('remain-alive'); + if (remainedAlive.ok || remainedAlive.category !== 'child-remained-alive' + || remainedAlive.secondary !== undefined) { + throw new Error('Native Windows READY pipe live-child regression failed'); + } +}; diff --git a/apps/desktop/scripts/packaged-connect-ready.d.mts b/apps/desktop/scripts/packaged-connect-ready.d.mts new file mode 100644 index 000000000..84294ed3d --- /dev/null +++ b/apps/desktop/scripts/packaged-connect-ready.d.mts @@ -0,0 +1,38 @@ +export const CONNECT_READY_EVENT: 'desktop.renderer.connect_discovery.ready'; +export const CONNECT_READY_MAX_BYTES: number; + +export interface ConnectReadyExpected { + platform: string; + arch: string; + authorityMechanism: string; +} + +export interface ConnectReadyRecord { + timestamp: string; + level: 'info'; + event: typeof CONNECT_READY_EVENT; + selectedPlatform: string; + selectedArch: string; + authorityMechanism: string; + rendererSchemaValid: true; +} + +export type ConnectReadyPublication = + | { ok: true; byteLength: number } + | { ok: false; category: 'duplicate' | 'schema' | 'byte-bound' | 'broken-pipe' | 'zero-progress' }; + +export function isExactConnectReadyRecord( + record: unknown, + expected: ConnectReadyExpected, +): record is ConnectReadyRecord; + +export function createConnectReadyRecord( + expected: ConnectReadyExpected & { timestamp?: string }, +): ConnectReadyRecord; + +export function createConnectReadyPublisher(options?: { + writeSync?: (fd: number, buffer: Buffer, offset: number, length: number) => number; + maximumBytes?: number; +}): { + publish(record: unknown, expected: ConnectReadyExpected): ConnectReadyPublication; +}; diff --git a/apps/desktop/scripts/packaged-connect-ready.mjs b/apps/desktop/scripts/packaged-connect-ready.mjs new file mode 100644 index 000000000..f53a87b90 --- /dev/null +++ b/apps/desktop/scripts/packaged-connect-ready.mjs @@ -0,0 +1,78 @@ +import { writeSync as nodeWriteSync } from 'node:fs'; + +export const CONNECT_READY_EVENT = 'desktop.renderer.connect_discovery.ready'; +export const CONNECT_READY_MAX_BYTES = 1024; + +const exactKeys = (record, expected) => { + const actual = Object.keys(record).sort(); + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); +}; + +export const isExactConnectReadyRecord = (record, { platform, arch, authorityMechanism }) => { + if (!record || typeof record !== 'object' || Array.isArray(record)) return false; + if (!exactKeys(record, [ + 'authorityMechanism', 'event', 'level', 'rendererSchemaValid', + 'selectedArch', 'selectedPlatform', 'timestamp', + ])) return false; + return record.event === CONNECT_READY_EVENT + && record.level === 'info' + && typeof record.timestamp === 'string' + && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(record.timestamp) + && record.selectedPlatform === platform + && record.selectedArch === arch + && record.authorityMechanism === authorityMechanism + && record.rendererSchemaValid === true; +}; + +export const createConnectReadyRecord = ({ + platform, + arch, + authorityMechanism, + timestamp = new Date().toISOString(), +}) => ({ + timestamp, + level: 'info', + event: CONNECT_READY_EVENT, + selectedPlatform: platform, + selectedArch: arch, + authorityMechanism, + rendererSchemaValid: true, +}); + +/** + * Publish the smoke-only READY authority once through fd 1. The result contains + * only fixed classifications: OS error details and written bytes never escape. + */ +export const createConnectReadyPublisher = ({ + writeSync = nodeWriteSync, + maximumBytes = CONNECT_READY_MAX_BYTES, +} = {}) => { + let attempted = false; + return { + publish(record, expected) { + if (attempted) return { ok: false, category: 'duplicate' }; + attempted = true; + if (!isExactConnectReadyRecord(record, expected)) { + return { ok: false, category: 'schema' }; + } + const bytes = Buffer.from(`${JSON.stringify(record)}\n`, 'utf8'); + if (bytes.byteLength <= 1 || bytes.byteLength > maximumBytes) { + return { ok: false, category: 'byte-bound' }; + } + let offset = 0; + while (offset < bytes.byteLength) { + let written; + try { + written = writeSync(1, bytes, offset, bytes.byteLength - offset); + } catch { + return { ok: false, category: 'broken-pipe' }; + } + if (!Number.isSafeInteger(written) || written <= 0 || written > bytes.byteLength - offset) { + return { ok: false, category: 'zero-progress' }; + } + offset += written; + } + return { ok: true, byteLength: bytes.byteLength }; + }, + }; +}; diff --git a/apps/desktop/scripts/packaged-connect-ready.test.mjs b/apps/desktop/scripts/packaged-connect-ready.test.mjs new file mode 100644 index 000000000..93251b8a8 --- /dev/null +++ b/apps/desktop/scripts/packaged-connect-ready.test.mjs @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, test } from 'node:test'; +import { + createConnectReadyPublisher, + createConnectReadyRecord, + isExactConnectReadyRecord, +} from './packaged-connect-ready.mjs'; +import { runPackagedConnectLifecycle } from './packaged-connect-lifecycle.mjs'; + +const expected = Object.freeze({ + platform: 'win32', + arch: 'x64', + authorityMechanism: 'inherited-standard-handle', +}); +const record = () => createConnectReadyRecord({ + ...expected, + timestamp: '2026-09-02T15:27:57.000Z', +}); + +describe('packaged Connect READY fd-1 publisher', () => { + test('completes partial writes and publishes one exact UTF-8 line', () => { + const chunks = []; + const publisher = createConnectReadyPublisher({ + writeSync(fd, bytes, offset, length) { + assert.equal(fd, 1); + const progress = Math.min(7, length); + chunks.push(Buffer.from(bytes.subarray(offset, offset + progress))); + return progress; + }, + }); + const result = publisher.publish(record(), expected); + assert.equal(result.ok, true); + const output = Buffer.concat(chunks).toString('utf8'); + assert.equal(output.endsWith('\n'), true); + assert.equal(output.slice(0, -1).includes('\n'), false); + assert.equal(isExactConnectReadyRecord(JSON.parse(output), expected), true); + }); + + test('classifies zero progress without retrying indefinitely', () => { + let calls = 0; + const result = createConnectReadyPublisher({ + writeSync() { calls += 1; return 0; }, + }).publish(record(), expected); + assert.deepEqual(result, { ok: false, category: 'zero-progress' }); + assert.equal(calls, 1); + }); + + test('classifies a broken pipe without exposing exception text', () => { + const result = createConnectReadyPublisher({ + writeSync() { throw new Error('private-pipe-path-SENTINEL'); }, + }).publish(record(), expected); + assert.deepEqual(result, { ok: false, category: 'broken-pipe' }); + assert.doesNotMatch(JSON.stringify(result), /private|SENTINEL/u); + }); + + test('rejects a duplicate publication and writes no duplicate bytes', () => { + const chunks = []; + const publisher = createConnectReadyPublisher({ + writeSync(_fd, bytes, offset, length) { + chunks.push(Buffer.from(bytes.subarray(offset, offset + length))); + return length; + }, + }); + assert.equal(publisher.publish(record(), expected).ok, true); + assert.deepEqual(publisher.publish(record(), expected), { ok: false, category: 'duplicate' }); + assert.equal(Buffer.concat(chunks).toString('utf8').trimEnd().split('\n').length, 1); + }); + + test('rejects the wrong schema before writing fd 1', () => { + let called = false; + const publisher = createConnectReadyPublisher({ + writeSync() { called = true; return 1; }, + }); + assert.deepEqual(publisher.publish({ ...record(), rendererSchemaValid: 'true' }, expected), { + ok: false, + category: 'schema', + }); + assert.equal(called, false); + }); + + test('rejects the exact record before writing when the byte bound is exceeded', () => { + let called = false; + const result = createConnectReadyPublisher({ + maximumBytes: 1, + writeSync() { called = true; return 1; }, + }).publish(record(), expected); + assert.deepEqual(result, { ok: false, category: 'byte-bound' }); + assert.equal(called, false); + }); + + test('keeps the native pipe regression in front of the real packaged lifecycle', () => { + const smoke = readFileSync(fileURLToPath(new URL('./smoke-packaged-connect.mjs', import.meta.url)), 'utf8'); + const regression = smoke.indexOf('await verifyNativeWindowsReadyPipeRegression({ treeKillerPath });'); + const lifecycle = smoke.indexOf('outcome = await runPackagedConnectLifecycle({'); + assert.ok(regression !== -1 && regression < lifecycle); + assert.doesNotMatch(smoke.slice(lifecycle, smoke.indexOf(' });', lifecycle)), /readyTimeoutMs/u); + const lifecycleSource = readFileSync( + fileURLToPath(new URL('./packaged-connect-lifecycle.mjs', import.meta.url)), + 'utf8', + ); + assert.match(lifecycleSource, /readyTimeoutMs = 240_000/u); + }); +}); + +const windowsTreeKiller = process.platform === 'win32' && process.env.SystemRoot + ? join(process.env.SystemRoot, 'System32', 'taskkill.exe') + : undefined; +const childFixture = fileURLToPath(new URL('./packaged-connect-ready-child.mjs', import.meta.url)); + +describe('native Windows inherited READY pipe', { skip: process.platform !== 'win32' }, () => { + const nativeExpected = { + platform: 'win32', + arch: process.arch, + authorityMechanism: 'inherited-standard-handle', + }; + const runNative = behavior => runPackagedConnectLifecycle({ + binaryPath: process.execPath, + args: [childFixture, behavior], + env: {}, + cwd: fileURLToPath(new URL('.', import.meta.url)), + ...nativeExpected, + treeKillerPath: windowsTreeKiller, + readyTimeoutMs: 5_000, + shutdownGraceMs: 100, + terminationTimeoutMs: 5_000, + streamDrainTimeoutMs: 2_000, + }); + + test('receives one exact line from an actual child and closes cleanly', async () => { + const result = await runNative('clean'); + assert.deepEqual(result, { + ok: true, + category: 'ready-clean-exit', + capture: 'complete', + records: [{ event: 'desktop.renderer.connect_discovery.ready' }], + }); + }); + + test('terminates but rejects a child that remains alive after READY', async () => { + const result = await runNative('remain-alive'); + assert.equal(result.ok, false); + assert.equal(result.category, 'child-remained-alive'); + assert.equal(result.secondary, undefined); + }); +}); diff --git a/apps/desktop/scripts/smoke-packaged-connect.mjs b/apps/desktop/scripts/smoke-packaged-connect.mjs index bc5dcd743..36cdcc64f 100644 --- a/apps/desktop/scripts/smoke-packaged-connect.mjs +++ b/apps/desktop/scripts/smoke-packaged-connect.mjs @@ -7,9 +7,11 @@ import { tmpdir } from 'node:os'; import { basename, dirname, join, relative, resolve } from 'node:path'; import { preservePrimaryWithCleanup, + readPackagedConnectFailureMilestone, removeAuthorizedConnectFixture, runPackagedConnectLifecycle, } from './packaged-connect-lifecycle.mjs'; +import { verifyNativeWindowsReadyPipeRegression } from './packaged-connect-ready-regression.mjs'; import { canonicalizeWindowsFixtureEntry, encodedWindowsFixtureAcl, @@ -229,6 +231,8 @@ try { failurePhase = 'package-validation'; await assertPackageAuthority(); const treeKillerPath = await windowsTreeKiller(); + failurePhase = 'ready-pipe-regression'; + await verifyNativeWindowsReadyPipeRegression({ treeKillerPath }); const sensitiveNeedles = [ ...secrets, fixture, configRoot, stackRoot, identity, 'S-1-5-', 'volumeSerialNumber', 'fileId', 'authorityDiagnostic', @@ -242,6 +246,9 @@ try { authorityMechanism: authorityMechanism(), sensitiveNeedles, treeKillerPath, + readFailureMilestone: () => readPackagedConnectFailureMilestone( + join(userDataPath, 'application.smoke-evidence.jsonl'), + ), env: { ...process.env, PROPR_DESKTOP_CONNECT_SMOKE_TEST: '1', @@ -268,6 +275,8 @@ try { if (outcome.ok && cleanup.ok) { process.stdout.write(`Packaged Connect discovery passed for ${process.platform}-${process.arch}: ${authorityMechanism()}.\n`); } else { + // Keep this exact #2056 producer schema: event/category/capture/records + // with optional secondary, and no top-level milestone extension. process.stderr.write(`${JSON.stringify({ event: 'packaged_connect.smoke_failed', category: outcome.category, diff --git a/apps/desktop/scripts/windows-authority-batch-regression.mjs b/apps/desktop/scripts/windows-authority-batch-regression.mjs new file mode 100644 index 000000000..20dd1a674 --- /dev/null +++ b/apps/desktop/scripts/windows-authority-batch-regression.mjs @@ -0,0 +1,177 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { closeSync, fstatSync, mkdtempSync, openSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { performance } from 'node:perf_hooks'; + +if (process.platform !== 'win32' || !['x64', 'arm64'].includes(process.arch)) { + process.stderr.write('Windows authority batch regression requires native win32-x64 or win32-arm64.\n'); + process.exit(1); +} + +const { + parseWindowsBrokerDocument, + runWindowsReadOnlyInspection, + runWindowsInspectionBrokerBatch, + WindowsNativeStageError, +} = await import('../../../packages/cli/dist/connectWindowsAuthority.js'); + +const systemRoot = process.env.SystemRoot; +assert.match(systemRoot ?? '', /^[A-Za-z]:\\/u); +const directory = mkdtempSync(join(tmpdir(), 'propr-authority-batch-')); +const descriptors = []; +const livePids = new Set(); + +const portableSupervisorFixture = String.raw` +const mode=process.argv[1];const value=process.argv[2]??'';const delay=Number(process.argv[3]??0); +if(mode==='hang'){setInterval(()=>{},60000)} +else if(mode==='status'){process.exit(70)} +else setTimeout(()=>{ + if(mode==='stderr')process.stderr.write('fixed-fixture-stderr'); + else if(mode==='overflow')process.stdout.write('x'.repeat(2048)); + else process.stdout.write(value); +},delay); +`; + +const startPortableFixture = (mode, value = '', delay = 0) => { + const child = spawn(process.execPath, ['-e', portableSupervisorFixture, mode, String(value), String(delay)], { + shell: false, + windowsHide: true, + env: { SystemRoot: systemRoot }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (Number.isSafeInteger(child.pid)) livePids.add(child.pid); + child.once('close', () => livePids.delete(child.pid)); + return child; +}; + +const targetFor = (fd, index) => { + const stat = fstatSync(fd, { bigint: true }); + return { + path: '', + kind: 'env', + pinnedFd: fd, + expectedIdentity: { device: stat.dev.toString(10), file: stat.ino.toString(10) }, + index, + }; +}; + +const fixedEvidence = (count, outcome, stage, diagnostics) => ({ + brokers: count, + outcome, + stage, + results: diagnostics, +}); + +const assertStage = async (promise, stage) => { + await assert.rejects( + promise, + error => error instanceof WindowsNativeStageError && error.stage === stage, + ); + assert.equal(livePids.size, 0, `${stage} left a broker process alive`); +}; + +try { + for (let index = 0; index < 4; index += 1) { + const path = join(directory, `target-${index}`); + writeFileSync(path, `fixture-${index}`); + descriptors.push(openSync(path, 'r')); + } + + // Production proof: 1, 2, and 4 targets each use one cold PowerShell process + // and remain inside the unchanged single 60-second wall bound. + let validEntry; + for (const count of [1, 2, 4]) { + const targets = descriptors.slice(0, count).map(targetFor); + const started = performance.now(); + const inspections = await runWindowsReadOnlyInspection(targets); + assert.equal(inspections.length, count); + assert.ok(performance.now() - started < 60_000, `one-broker ${count}-handle batch exceeded its wall bound`); + if (count === 1) { + const { index: _index, kind: _kind, authorityKind: _authorityKind, ...entry } = inspections[0]; + validEntry = entry; + } + } + + // Supervisor concurrency is process-level behavior, so prove it with a + // deterministic portable child instead of recreating a PS5.1 cold-start + // storm after the dedicated sequential native proofs above. + for (const count of [1, 2, 4]) { + const diagnostics = []; + const outputs = await runWindowsInspectionBrokerBatch({ + entryCount: count, + startBroker: index => startPortableFixture('output', index, 40 - index), + deadlineMs: 10_000, + cleanupTimeoutMs: 5_000, + maxOutputBytes: 128 * 1024, + onBrokerResult: diagnostic => diagnostics.push(diagnostic), + }); + assert.deepEqual(outputs.map(output => output.toString('utf8')), Array.from({ length: count }, (_, i) => String(i))); + assert.equal(diagnostics.length, count); + assert.equal(livePids.size, 0, `${count}-child supervisor evidence left a process alive`); + process.stdout.write(`Windows authority supervisor evidence ${JSON.stringify( + fixedEvidence(count, 'passed', 'ok', diagnostics), + )}\n`); + } + + const reorderedDelays = [80, 10, 45]; + const reordered = await runWindowsInspectionBrokerBatch({ + entryCount: reorderedDelays.length, + startBroker: index => startPortableFixture('output', index, reorderedDelays[index]), + deadlineMs: 10_000, + cleanupTimeoutMs: 5_000, + maxOutputBytes: 128, + }); + assert.deepEqual(reordered.map(output => output.toString('utf8')), ['0', '1', '2']); + assert.equal(livePids.size, 0, 'reordered batch left a broker process alive'); + + await assertStage(runWindowsInspectionBrokerBatch({ + entryCount: 2, + startBroker: index => startPortableFixture(index === 0 ? 'hang' : 'output', 'sibling'), + deadlineMs: 1_000, + cleanupTimeoutMs: 5_000, + maxOutputBytes: 128, + }), 'spawn:timeout'); + + await assertStage(runWindowsInspectionBrokerBatch({ + entryCount: 3, + startBroker: index => startPortableFixture(index === 0 ? 'status' : 'hang'), + deadlineMs: 10_000, + cleanupTimeoutMs: 5_000, + maxOutputBytes: 128, + }), 'spawn:status'); + + await assertStage(runWindowsInspectionBrokerBatch({ + entryCount: 2, + startBroker: index => startPortableFixture(index === 0 ? 'overflow' : 'hang'), + deadlineMs: 10_000, + cleanupTimeoutMs: 5_000, + maxOutputBytes: 1024, + }), 'parent:utf8'); + + await assertStage(runWindowsInspectionBrokerBatch({ + entryCount: 2, + startBroker: index => startPortableFixture(index === 0 ? 'stderr' : 'hang'), + deadlineMs: 10_000, + cleanupTimeoutMs: 5_000, + maxOutputBytes: 1024, + }), 'spawn:stderr'); + + assert.ok(validEntry); + assert.deepEqual(parseWindowsBrokerDocument(JSON.stringify({ version: 1, entries: [validEntry] })), validEntry); + for (const entries of [[], [validEntry, validEntry]]) assert.throws( + () => parseWindowsBrokerDocument(JSON.stringify({ version: 1, entries })), + error => error instanceof WindowsNativeStageError && error.stage === 'parent:entry-count', + ); + assert.throws( + () => parseWindowsBrokerDocument(JSON.stringify({ version: 1, entries: [{ ...validEntry, extra: true }] })), + error => error instanceof WindowsNativeStageError && error.stage === 'parent:entry-shape', + ); + + process.stdout.write(`Windows ${process.arch} authority batch regression passed.\n`); +} finally { + for (const fd of descriptors) closeSync(fd); + rmSync(directory, { recursive: true, force: true }); +} diff --git a/apps/desktop/scripts/windows-fixture-acl.mjs b/apps/desktop/scripts/windows-fixture-acl.mjs index e935478f0..51fc5c678 100644 --- a/apps/desktop/scripts/windows-fixture-acl.mjs +++ b/apps/desktop/scripts/windows-fixture-acl.mjs @@ -74,6 +74,7 @@ export const encodedWindowsFixtureAcl = Buffer.from(windowsFixtureAclSource, 'ut const WINDOWS_FIXTURE_PATH_MAX_BYTES = 4 * 1024; const WINDOWS_FIXTURE_PROCESS_MAX_BYTES = 8 * 1024; +export const WINDOWS_FIXTURE_PROCESS_TIMEOUT_MS = 60_000; const windowsFixtureCanonicalPathSource = String.raw` $ErrorActionPreference='Stop' @@ -160,7 +161,7 @@ export const canonicalizeWindowsFixtureEntry = ({ entryKind, entryPath, powershe ], { shell: false, windowsHide: true, - timeout: 10_000, + timeout: WINDOWS_FIXTURE_PROCESS_TIMEOUT_MS, maxBuffer: WINDOWS_FIXTURE_PROCESS_MAX_BYTES, env: { ...process.env, diff --git a/apps/desktop/scripts/windows-fixture-acl.test.mjs b/apps/desktop/scripts/windows-fixture-acl.test.mjs index c46767bc2..4327f435d 100644 --- a/apps/desktop/scripts/windows-fixture-acl.test.mjs +++ b/apps/desktop/scripts/windows-fixture-acl.test.mjs @@ -7,6 +7,7 @@ import { it } from 'node:test'; import { canonicalizeWindowsFixtureEntry, encodedWindowsFixtureAcl, + WINDOWS_FIXTURE_PROCESS_TIMEOUT_MS, windowsFixtureAclSource, windowsPowerShell51Path, } from './windows-fixture-acl.mjs'; @@ -140,7 +141,7 @@ const runAclProof = (powershell, entry, proofKind, ownerCategory = '') => spawnS { shell: false, windowsHide: true, - timeout: 30_000, + timeout: WINDOWS_FIXTURE_PROCESS_TIMEOUT_MS, env: { ...process.env, PROPR_FIXTURE_ACL_KIND: entry.kind, @@ -161,9 +162,18 @@ const proofFailureCategory = status => new Map([ [79, 'owner-category-mismatch'], ]).get(status) ?? 'unexpected-exit'; -const assertProofProcess = result => { - assert.ifError(result.error); +const assertPowerShellInvocation = (result, category) => { + if (result.error) { + const reason = result.error.code === 'ETIMEDOUT' ? 'timeout' : 'spawn'; + const error = new Error(`Windows fixture ACL helper failed [category=${category} reason=${reason}]`); + error.stack = error.message; + throw error; + } assert.equal(result.signal, null); +}; + +const assertProofProcess = (result, category = 'acl-proof') => { + assertPowerShellInvocation(result, category); assertPowerShellStreamEmpty(result.stdout, 'dacl-proof-stdout'); assertPowerShellStreamEmpty(result.stderr, 'dacl-proof-stderr'); }; @@ -197,12 +207,15 @@ const assertOwnerCategoryMismatch = (powershell, entry, ownerCategory) => { }; windowsIt('keeps the encoded Windows PowerShell 5.1 ACL helper fail-closed and byte-empty', t => { + assert.equal(WINDOWS_FIXTURE_PROCESS_TIMEOUT_MS, 60_000); const powershell = windowsPowerShell51Path(); const version = spawnSync(powershell, [ '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '[Console]::Out.Write($PSVersionTable.PSVersion.ToString(2))', - ], { shell: false, windowsHide: true, encoding: 'utf8', timeout: 10_000 }); - assert.ifError(version.error); + ], { + shell: false, windowsHide: true, encoding: 'utf8', timeout: WINDOWS_FIXTURE_PROCESS_TIMEOUT_MS, + }); + assertPowerShellInvocation(version, 'version'); assert.equal(version.status, 0); assert.equal(version.stdout, '5.1'); assert.equal(version.stderr, ''); @@ -221,8 +234,8 @@ windowsIt('keeps the encoded Windows PowerShell 5.1 ACL helper fail-closed and b const classifierRegression = spawnSync(powershell, [ '-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', encodedOwnerClassifierRegression, - ], { shell: false, windowsHide: true, timeout: 10_000 }); - assertProofProcess(classifierRegression); + ], { shell: false, windowsHide: true, timeout: WINDOWS_FIXTURE_PROCESS_TIMEOUT_MS }); + assertProofProcess(classifierRegression, 'owner-classifier'); const classifierCategory = new Map([ [78, 'unknown-owner'], [80, 'allowlisted-owner'], @@ -308,7 +321,7 @@ windowsIt('keeps the encoded Windows PowerShell 5.1 ACL helper fail-closed and b ], { shell: false, windowsHide: true, - timeout: 30_000, + timeout: WINDOWS_FIXTURE_PROCESS_TIMEOUT_MS, env: { ...process.env, PROPR_FIXTURE_ACL_KIND: entry.kind, @@ -316,8 +329,7 @@ windowsIt('keeps the encoded Windows PowerShell 5.1 ACL helper fail-closed and b }, }); - assert.ifError(result.error); - assert.equal(result.signal, null); + assertPowerShellInvocation(result, 'mutation-case'); assertPowerShellStreamEmpty(result.stdout, 'powershell-stdout'); assertPowerShellStreamEmpty(result.stderr, 'powershell-stderr'); assert.equal(result.status, entry.status, `${entry.label} returned the wrong redacted phase code`); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 7065b87b9..d2b7ac387 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -37,6 +37,11 @@ import { DESKTOP_PROTOCOL, IPC_CHANNELS } from './shared/contract'; import { checkForSignedUpdates } from './signed-updates'; import { authorizePackagedSmokeTest } from './smoke-test-authorization'; import { createPackagedSmokeEvidenceSink } from './smoke-test-evidence'; +import { + CONNECT_READY_EVENT, + createConnectReadyPublisher, + createConnectReadyRecord, +} from '../scripts/packaged-connect-ready.mjs'; import { createBrowserWindowOptions, MINIMUM_BROWSER_WINDOW_SIZE, @@ -83,6 +88,7 @@ const deepLinkDelivery = new DeepLinkDelivery( ); let logger: DesktopLogger | null = null; let shutdownStarted = false; +const packagedConnectReadyPublisher = createConnectReadyPublisher(); if (process.platform === 'win32') { app.setAppUserModelId('dev.propr.desktop'); } @@ -364,16 +370,21 @@ const runPackagedConnectDiscoverySmoke = async (window: BrowserWindow): Promise< || candidate.apiBaseUrl !== 'https://t-packaged123.propr.dev') { throw new Error('Packaged Connect renderer discovery proof was invalid'); } - log('info', 'desktop.renderer.connect_discovery.ready', { - selectedPlatform: process.platform, - selectedArch: process.arch, - authorityMechanism: process.platform === 'darwin' - ? 'packaged-broker' - : process.platform === 'linux' - ? 'in-process-native-addon' - : 'inherited-standard-handle', - rendererSchemaValid: true, - }); + packagedSmokeEvidence?.write('desktop.renderer.connect_discovery.proof'); + const authorityMechanism = process.platform === 'darwin' + ? 'packaged-broker' + : process.platform === 'linux' + ? 'in-process-native-addon' + : 'inherited-standard-handle'; + const expected = { platform: process.platform, arch: process.arch, authorityMechanism }; + const publication = packagedConnectReadyPublisher.publish( + createConnectReadyRecord(expected), + expected, + ); + if (!publication.ok) { + throw new Error(`Packaged Connect READY publication failed: ${publication.category}`); + } + packagedSmokeEvidence?.write(CONNECT_READY_EVENT); }; const runPackagedTransportSmoke = async ( diff --git a/apps/desktop/src/smoke-test-evidence.test.ts b/apps/desktop/src/smoke-test-evidence.test.ts index d0ff7beea..140603040 100644 --- a/apps/desktop/src/smoke-test-evidence.test.ts +++ b/apps/desktop/src/smoke-test-evidence.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { describe, it } from 'node:test'; import { createPackagedSmokeEvidenceSink, + PACKAGED_CONNECT_SMOKE_EVIDENCE_EVENTS, PACKAGED_SMOKE_EVIDENCE_EVENTS, PACKAGED_SMOKE_EVIDENCE_FILE, } from './smoke-test-evidence'; @@ -73,4 +74,20 @@ describe('packaged smoke evidence', () => { assert.ok(Buffer.byteLength(contents, 'utf8') < 1024); }); }); + + it('retains only the two fixed Connect attribution milestones', () => { + withSmokeDirectory(directory => { + const sink = createPackagedSmokeEvidenceSink(directory); + assert.ok(sink); + for (const event of PACKAGED_CONNECT_SMOKE_EVIDENCE_EVENTS) sink.write(event); + sink.write('desktop.renderer.connect_discovery.private-value'); + sink.close(); + + const contents = readFileSync(join(directory, PACKAGED_SMOKE_EVIDENCE_FILE), 'utf8'); + assert.deepEqual(contents.trimEnd().split('\n').map(line => JSON.parse(line)), [ + { event: 'desktop.renderer.connect_discovery.proof' }, + { event: 'desktop.renderer.connect_discovery.ready' }, + ]); + }); + }); }); diff --git a/apps/desktop/src/smoke-test-evidence.ts b/apps/desktop/src/smoke-test-evidence.ts index a9d26bfb6..8ad43448b 100644 --- a/apps/desktop/src/smoke-test-evidence.ts +++ b/apps/desktop/src/smoke-test-evidence.ts @@ -23,9 +23,19 @@ export const PACKAGED_SMOKE_EVIDENCE_EVENTS = [ 'desktop.log.write_failed', ] as const; -export type PackagedSmokeEvidenceEvent = typeof PACKAGED_SMOKE_EVIDENCE_EVENTS[number]; +export const PACKAGED_CONNECT_SMOKE_EVIDENCE_EVENTS = [ + 'desktop.renderer.connect_discovery.proof', + 'desktop.renderer.connect_discovery.ready', +] as const; + +export type PackagedSmokeEvidenceEvent = + | typeof PACKAGED_SMOKE_EVIDENCE_EVENTS[number] + | typeof PACKAGED_CONNECT_SMOKE_EVIDENCE_EVENTS[number]; -const allowedEvents = new Set(PACKAGED_SMOKE_EVIDENCE_EVENTS); +const allowedEvents = new Set([ + ...PACKAGED_SMOKE_EVIDENCE_EVENTS, + ...PACKAGED_CONNECT_SMOKE_EVIDENCE_EVENTS, +]); export interface PackagedSmokeEvidenceSink { write(event: string): void; diff --git a/package-lock.json b/package-lock.json index 6c50b90c4..fad20a9ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7428,9 +7428,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", "funding": [ { "type": "github", @@ -12020,9 +12020,9 @@ } }, "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", diff --git a/packages/cli/src/commands/connectCommand.ts b/packages/cli/src/commands/connectCommand.ts index eb1ac66b2..e0c4c8d0b 100644 --- a/packages/cli/src/commands/connectCommand.ts +++ b/packages/cli/src/commands/connectCommand.ts @@ -380,9 +380,17 @@ export async function getLocalConnectStatus( dependencies.reportSmokeDiagnostic?.(phase, 'STARTED'); try { const prepared = await prepareConnectHostConfig(); + // A native Windows generation owns one broker under one fixed 60-second + // deadline. Finish the independent trusted-home generation before opening + // the root generation so neither cold start consumes the other's bound. + const windowsTunnelEnabledOverride = process.platform === "win32" && root + ? await readTrustedConnectTunnelOverride(root) + : undefined; const local = await withOwnedConnectRootSnapshot(root, async (snapshot) => { const cfg = prepared.resolveSnapshot(snapshot); - const tunnelEnabledOverride = await readTrustedConnectTunnelOverride(snapshot.requestedRoot); + const tunnelEnabledOverride = process.platform === "win32" + ? windowsTunnelEnabledOverride + : await readTrustedConnectTunnelOverride(snapshot.requestedRoot); const effectiveCfg = tunnelEnabledOverride === undefined ? cfg : { ...cfg, uiTunnelEnabled: tunnelEnabledOverride }; @@ -400,7 +408,7 @@ export async function getLocalConnectStatus( publicInstanceIdentity, sidecarInspection, }; - }, { parseEnvFile: prepared.parseEnvFile }); + }, { parseEnvFile: prepared.parseEnvFile, pinPublicIdentityAuthority: process.platform === "win32" }); dependencies.reportSmokeDiagnostic?.(phase, 'PASSED'); phase = 'status-resolution'; dependencies.reportSmokeDiagnostic?.(phase, 'STARTED'); diff --git a/packages/cli/src/connectIdentity.ts b/packages/cli/src/connectIdentity.ts index c0831d108..8a05839da 100644 --- a/packages/cli/src/connectIdentity.ts +++ b/packages/cli/src/connectIdentity.ts @@ -19,6 +19,7 @@ import { readPublicInstanceIdentityPinned, type PinnedPublicIdentityDirectory, } from "@propr/local-setup"; +import { PUBLIC_INSTANCE_IDENTITY_FILENAME } from "@propr/shared"; import { directoryDescriptorAccess, mkdirAt, @@ -31,11 +32,13 @@ import { import { assertNativeEntryAuthority, assertNativeWindowsEntriesAuthority, + beginNativeWindowsEntriesAuthorityGeneration, nativeConnectRootAuthorityInspector, WindowsAuthorityInspectionError, WindowsAuthorityPolicyError, type ConnectAuthorityEntryKind, type ConnectRootAuthorityInspector, + type NativeWindowsEntriesAuthorityGeneration, } from "./connectRootAuthority.js"; import { canonicalRootKey } from "./config/rootKey.js"; @@ -96,6 +99,8 @@ export interface ConnectRootSnapshotOptions { authorityInspector?: ConnectRootAuthorityInspector; onBoundary?: (boundary: ConnectRootSnapshotBoundary) => void | Promise; parseEnvFile?: (contents: string) => Record; + /** @internal Discovery pre-pins the read-only identity into the same Windows authority generation. */ + pinPublicIdentityAuthority?: boolean; } interface HeldDirectory { @@ -357,6 +362,7 @@ export async function readTrustedConnectTunnelOverride( let homeAncestorsClosed = false; let configDir: HeldDirectory | undefined; let configFd: number | undefined; + let windowsAuthorityGeneration: NativeWindowsEntriesAuthorityGeneration | undefined; try { if (!sameResolvedPath(realpathSync.native(homePath), homePath, platform)) { throw new TrustedConnectConfigError("REPARSE_POINT"); @@ -487,7 +493,7 @@ export async function readTrustedConnectTunnelOverride( if (platform === "darwin") { await authorityEntry(inspector, platform, join(configDir.visiblePath, "config.json"), "env", configFd); } else if (platform === "win32") { - await authorityEntries(inspector, [ + windowsAuthorityGeneration = await beginNativeWindowsEntriesAuthorityGeneration(inspector, [ ...home.ancestry.slice(0, -1).map((entry) => ({ path: entry.path, kind: "ancestor" as const, pinnedFd: entry.fd, })), @@ -495,8 +501,6 @@ export async function readTrustedConnectTunnelOverride( { path: configDir.visiblePath, kind: "data", pinnedFd: configDir.fd }, { path: join(configDir.visiblePath, "config.json"), kind: "env", pinnedFd: configFd }, ]); - closeAcquiredAncestors(home); - homeAncestorsClosed = true; } verifyNamedConfigDirectory(); await options.onBoundary?.("config-opened"); @@ -516,7 +520,10 @@ export async function readTrustedConnectTunnelOverride( await authorityEntry(inspector, platform, configDir.visiblePath, "data", configDir.fd); await authorityEntry(inspector, platform, join(configDir.visiblePath, "config.json"), "env", configFd); } else if (platform === "win32") { - await authorityEntries(inspector, [ + await windowsAuthorityGeneration!.revalidate([ + ...home.ancestry.slice(0, -1).map((entry) => ({ + path: entry.path, kind: "ancestor" as const, pinnedFd: entry.fd, + })), { path: home.root.visiblePath, kind: "home", pinnedFd: home.root.fd }, { path: configDir.visiblePath, kind: "data", pinnedFd: configDir.fd }, { path: join(configDir.visiblePath, "config.json"), kind: "env", pinnedFd: configFd }, @@ -525,17 +532,22 @@ export async function readTrustedConnectTunnelOverride( return parseTrustedTunnelOverride(contents, requestedRoot, platform); } catch (error) { if (error instanceof TrustedConnectConfigError) throw error; + if (error instanceof WindowsAuthorityInspectionError) throw error; if (error instanceof WindowsAuthorityPolicyError) { throw new TrustedConnectConfigError(`NATIVE_ENTRY_${error.entryIndex}_${error.policyReason}`); } if (error instanceof ConnectRootError) throw new TrustedConnectConfigError(error.reason); throw new TrustedConnectConfigError(); } finally { - if (configFd !== undefined) closeSync(configFd); - if (configDir !== undefined) closeSync(configDir.fd); - if (home !== undefined) { - if (!homeAncestorsClosed) closeAcquiredAncestors(home); - closeSync(home.root.fd); + try { + await windowsAuthorityGeneration?.abort(); + } finally { + if (configFd !== undefined) closeSync(configFd); + if (configDir !== undefined) closeSync(configDir.fd); + if (home !== undefined) { + if (!homeAncestorsClosed) closeAcquiredAncestors(home); + closeSync(home.root.fd); + } } } } @@ -683,8 +695,10 @@ export async function withOwnedConnectRootSnapshot( let root: HeldDirectory | undefined; let data: HeldDirectory | undefined; let envFd: number | undefined; + let publicIdentityAuthorityFd: number | undefined; let acquiredRoot: AcquiredRoot | undefined; let acquiredAncestorsClosed = false; + let windowsAuthorityGeneration: NativeWindowsEntriesAuthorityGeneration | undefined; try { const acquired = openRootNoFollow(requestedRoot, ioPlatform); acquiredRoot = acquired; @@ -719,19 +733,33 @@ export async function withOwnedConnectRootSnapshot( const initialEnvStat = fstatSync(envFd); assertPrivateEnv(initialEnvStat, callerUid, platform); assertNamedEntry(requestedRoot, ".env", initialEnvStat); + if (platform === "win32" && options.pinPublicIdentityAuthority === true) { + publicIdentityAuthorityFd = data.openChild( + PUBLIC_INSTANCE_IDENTITY_FILENAME, + constants.O_RDONLY | constants.O_NOFOLLOW, + ); + const identityStat = fstatSync(publicIdentityAuthorityFd); + if (!identityStat.isFile() || identityStat.isSymbolicLink() || identityStat.nlink !== 1) { + throw new PublicInstanceIdentityError(); + } + assertNamedEntry(data.visiblePath, PUBLIC_INSTANCE_IDENTITY_FILENAME, identityStat); + } if (platform === "darwin") { await authorityEntry(inspector, platform, join(requestedRoot, ".env"), "env", envFd); } else if (platform === "win32") { - await authorityEntries(inspector, [ + windowsAuthorityGeneration = await beginNativeWindowsEntriesAuthorityGeneration(inspector, [ ...acquired.ancestry.slice(0, -1).map((entry) => ({ path: entry.path, kind: "ancestor" as const, pinnedFd: entry.fd, })), { path: root.visiblePath, kind: "root", pinnedFd: root.fd }, { path: data.visiblePath, kind: "data", pinnedFd: data.fd }, { path: join(requestedRoot, ".env"), kind: "env", pinnedFd: envFd }, + ...(publicIdentityAuthorityFd === undefined ? [] : [{ + path: join(data.visiblePath, PUBLIC_INSTANCE_IDENTITY_FILENAME), + kind: "env" as const, + pinnedFd: publicIdentityAuthorityFd, + }]), ]); - closeAcquiredAncestors(acquired); - acquiredAncestorsClosed = true; } await options.onBoundary?.("acquired"); @@ -777,7 +805,13 @@ export async function withOwnedConnectRootSnapshot( }, validateEntry: async (name, fd) => { const entryPath = join(data!.visiblePath, name); - if (platform !== "linux") { + if (platform === "win32" && publicIdentityAuthorityFd !== undefined + && name === PUBLIC_INSTANCE_IDENTITY_FILENAME) { + const retained = fstatSync(publicIdentityAuthorityFd); + const current = fstatSync(fd); + if (!sameIdentity(retained, current)) throw new PublicInstanceIdentityError(); + assertNamedEntry(data!.visiblePath, name, current); + } else if (platform !== "linux") { await authorityEntry(inspector, platform, entryPath, "env", fd); } }, @@ -834,13 +868,18 @@ export async function withOwnedConnectRootSnapshot( || before.some((entry, index) => !sameIdentity(entry.stat, after[index].stat)) ) throw new ConnectRootError(); if (platform === "win32") { - await authorityEntries(inspector, [ + await windowsAuthorityGeneration!.revalidate([ ...reacquired.ancestry.slice(0, -1).map((entry) => ({ path: entry.path, kind: "ancestor" as const, pinnedFd: entry.fd, })), { path: reacquired.root.visiblePath, kind: "root", pinnedFd: reacquired.root.fd }, { path: data.visiblePath, kind: "data", pinnedFd: data.fd }, { path: join(requestedRoot, ".env"), kind: "env", pinnedFd: envFd }, + ...(publicIdentityAuthorityFd === undefined ? [] : [{ + path: join(data.visiblePath, PUBLIC_INSTANCE_IDENTITY_FILENAME), + kind: "env" as const, + pinnedFd: publicIdentityAuthorityFd, + }]), ]); } else { await assertPlatformAuthority(reacquired, platform, inspector, callerUid); @@ -860,10 +899,15 @@ export async function withOwnedConnectRootSnapshot( } throw new ConnectRootError(); } finally { - if (acquiredRoot !== undefined && !acquiredAncestorsClosed) closeAcquiredAncestors(acquiredRoot); - if (envFd !== undefined) closeSync(envFd); - if (data !== undefined) closeSync(data.fd); - if (root !== undefined) closeSync(root.fd); + try { + await windowsAuthorityGeneration?.abort(); + } finally { + if (acquiredRoot !== undefined && !acquiredAncestorsClosed) closeAcquiredAncestors(acquiredRoot); + if (publicIdentityAuthorityFd !== undefined) closeSync(publicIdentityAuthorityFd); + if (envFd !== undefined) closeSync(envFd); + if (data !== undefined) closeSync(data.fd); + if (root !== undefined) closeSync(root.fd); + } } } diff --git a/packages/cli/src/connectRootAuthority.test.ts b/packages/cli/src/connectRootAuthority.test.ts index 7839adbaf..10e7b5b76 100644 --- a/packages/cli/src/connectRootAuthority.test.ts +++ b/packages/cli/src/connectRootAuthority.test.ts @@ -1,7 +1,11 @@ import assert from "node:assert/strict"; +import type { ChildProcess } from "node:child_process"; +import { EventEmitter } from "node:events"; import { closeSync, mkdtempSync, openSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { performance } from "node:perf_hooks"; +import { PassThrough, Writable } from "node:stream"; import { test } from "node:test"; import { assertNativeWindowsEntriesAuthority, @@ -17,7 +21,10 @@ import { } from "./connectRootAuthority.js"; import { parseWindowsNativeProbeOutput, - WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS, + parseWindowsBrokerDocument, + runWindowsInspectionBrokerBatch, + writeWindowsInspectionRevalidationControl, + WINDOWS_INSPECTION_CLEANUP_TIMEOUT_MS, WINDOWS_INSPECTION_SOURCE, WINDOWS_INSPECTION_TIMEOUT_MS, WINDOWS_INSPECTOR_CREATES_CHILD_PROCESSES, @@ -29,15 +36,85 @@ import { WINDOWS_UINT64_COMPOSER_SOURCE, WINDOWS_UNSIGNED_FIELD_DECODER_SOURCE, windowsBrokerFailureStage, - windowsInspectionTimeoutForElapsed, + windowsBrokerFailureAttribution, WindowsNativeStageError, windowsNativeTimingBucket, windowsPowerShellEnvironment, + type WindowsBrokerResultDiagnostic, } from "./connectWindowsAuthority.js"; const USER = "S-1-5-21-100-200-300-1001"; const SYSTEM = "S-1-5-18"; const ADMINISTRATORS = "S-1-5-32-544"; +let fixtureBrokerPid = 20_000; + +function fixtureBroker({ + delayMs = 0, + stdout = "", + stderr = "", + status = 0, + hang = false, + killReturns = true, + releaseAfterKillMs, + closeEventDelayMs = 0, + streamDrainDelayMs = 0, + onStart = () => undefined, + onClose = () => undefined, +}: { + delayMs?: number; + stdout?: string; + stderr?: string; + status?: number; + hang?: boolean; + killReturns?: boolean; + releaseAfterKillMs?: number; + closeEventDelayMs?: number; + streamDrainDelayMs?: number; + onStart?: () => void; + onClose?: () => void; +} = {}): ChildProcess { + const child = new EventEmitter() as EventEmitter & Record; + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.pid = fixtureBrokerPid += 1; + child.exitCode = null; + child.signalCode = null; + let closing = false; + let closed = false; + let timer: ReturnType | undefined; + const close = (code: number | null, signal: NodeJS.Signals | null): void => { + if (closing) return; + closing = true; + if (timer !== undefined) clearTimeout(timer); + child.exitCode = code; + child.signalCode = signal; + setTimeout(() => { + (child.stdout as PassThrough).end(); + (child.stderr as PassThrough).end(); + }, streamDrainDelayMs); + setTimeout(() => { + closed = true; + onClose(); + child.emit("close", code, signal); + }, closeEventDelayMs); + }; + child.kill = (): boolean => { + if (killReturns) close(null, "SIGKILL"); + else if (releaseAfterKillMs !== undefined && !closing) { + setTimeout(() => close(null, "SIGKILL"), releaseAfterKillMs); + } + return killReturns; + }; + onStart(); + if (!hang) timer = setTimeout(() => { + (child.stdout as PassThrough).write(stdout); + if (closed) return; + (child.stderr as PassThrough).write(stderr); + if (closed) return; + close(status, null); + }, delayMs); + return child as unknown as ChildProcess; +} test("unpackaged Connect authority brokers reject group/other-writable modes", () => { assert.equal(isConnectAuthorityBrokerModeSafe(0o644n, false), true); @@ -197,34 +274,240 @@ test("Windows native timing uses only coarse fixed buckets", () => { assert.throws(() => windowsNativeTimingBucket(Number.NaN), WindowsNativeStageError); }); -test("Windows production inspection has one cold-start deadline and a cumulative batch cap", () => { +test("Windows production inspection has one fixed batch deadline and short cleanup bound", () => { assert.equal(WINDOWS_INSPECTION_TIMEOUT_MS, 60_000); - assert.equal(WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS, 240_000); + assert.equal(WINDOWS_INSPECTION_CLEANUP_TIMEOUT_MS, 5_000); assert.equal(WINDOWS_NATIVE_TIMING_PROBE_TIMEOUT_MS, 60_000); - assert.equal(WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS, 4 * WINDOWS_INSPECTION_TIMEOUT_MS); - assert.notEqual( - WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS / WINDOWS_INSPECTION_TIMEOUT_MS, - 32, + assert.ok(WINDOWS_INSPECTION_CLEANUP_TIMEOUT_MS < WINDOWS_INSPECTION_TIMEOUT_MS); + assert.ok(WINDOWS_NATIVE_STAGE_CODES.includes("spawn:cleanup")); + assert.equal((WINDOWS_NATIVE_STAGE_CODES as readonly string[]).includes("spawn:cumulative-timeout"), false); +}); + +test("Windows broker supervisor starts slow entries concurrently and preserves input order", async () => { + let active = 0; + let maximumActive = 0; + const started = performance.now(); + const outputs = await runWindowsInspectionBrokerBatch({ + entryCount: 4, + startBroker: (index) => fixtureBroker({ + delayMs: [70, 20, 50, 35][index], + stdout: String(index), + onStart: () => { active += 1; maximumActive = Math.max(maximumActive, active); }, + onClose: () => { active -= 1; }, + }), + deadlineMs: 500, + cleanupTimeoutMs: 100, + maxOutputBytes: 64, + }); + const elapsed = performance.now() - started; + assert.deepEqual(outputs.map((value) => value.toString("utf8")), ["0", "1", "2", "3"]); + assert.equal(maximumActive, 4); + assert.equal(active, 0); + assert.ok(elapsed < 500, `concurrent batch exceeded its one wall bound: ${elapsed}ms`); +}); + +test("Windows broker supervisor terminates and drains all siblings on timeout, failure, and overflow", async () => { + const runFailure = async ( + expectedStage: string, + startBroker: (index: number, closed: () => void) => ChildProcess, + overrides: Partial[0]> = {}, + ): Promise => { + let active = 0; + let started = 0; + const diagnostics: WindowsBrokerResultDiagnostic[] = []; + await assert.rejects( + runWindowsInspectionBrokerBatch({ + entryCount: 3, + startBroker: (index) => { + active += 1; + started += 1; + return startBroker(index, () => { active -= 1; }); + }, + deadlineMs: 30, + cleanupTimeoutMs: 100, + maxOutputBytes: 32, + onBrokerResult: (diagnostic) => diagnostics.push(diagnostic), + ...overrides, + }), + (error) => error instanceof WindowsNativeStageError && error.stage === expectedStage, + ); + assert.equal(started, 3); + assert.equal(active, 0); + assert.deepEqual(diagnostics.map(({ brokerIndex }) => brokerIndex).sort(), ["0", "1", "2-3"]); + assert.ok(diagnostics.every((diagnostic) => ( + Object.keys(diagnostic).sort().join(",") + === "brokerIndex,cleanup,deadline,entryIndex,operation,statusStage,stderr,stdout" + ))); + assert.doesNotMatch(JSON.stringify(diagnostics), /SENTINEL|powershell|S-1-|[A-Za-z]:\\/i); + }; + + await runFailure("spawn:timeout", (_index, closed) => fixtureBroker({ hang: true, onClose: closed })); + await runFailure("spawn:status", (index, closed) => fixtureBroker({ + hang: index !== 0, + status: index === 0 ? 70 : 0, + onClose: closed, + }), { deadlineMs: 200 }); + await runFailure("parent:utf8", (index, closed) => fixtureBroker({ + hang: index !== 0, + stdout: index === 0 ? "x".repeat(33) : "", + onClose: closed, + }), { deadlineMs: 200 }); +}); + +test("Windows broker cleanup deadline never settles before an unkillable child is contained", async () => { + let active = 0; + let settled = false; + const diagnostics: unknown[] = []; + const started = performance.now(); + const result = runWindowsInspectionBrokerBatch({ + entryCount: 1, + startBroker: () => fixtureBroker({ + hang: true, + killReturns: false, + releaseAfterKillMs: 80, + onStart: () => { active += 1; }, + onClose: () => { active -= 1; }, + }), + deadlineMs: 10, + cleanupTimeoutMs: 20, + maxOutputBytes: 32, + onBrokerResult: (diagnostic) => diagnostics.push(diagnostic), + }).then( + () => { settled = true; throw new Error("unkillable broker unexpectedly resolved"); }, + (error) => { settled = true; throw error; }, ); - assert.equal(windowsInspectionTimeoutForElapsed(0), 60_000); - assert.equal(windowsInspectionTimeoutForElapsed(60_000), 60_000); - assert.equal(windowsInspectionTimeoutForElapsed(120_000), 60_000); - assert.equal(windowsInspectionTimeoutForElapsed(180_000), 60_000); - assert.equal(windowsInspectionTimeoutForElapsed(180_001), 59_999); - assert.equal(windowsInspectionTimeoutForElapsed(210_000), 30_000); - assert.equal(windowsInspectionTimeoutForElapsed(225_000), 15_000); - assert.equal(windowsInspectionTimeoutForElapsed(239_999.9), 1); - assert.throws( - () => windowsInspectionTimeoutForElapsed(240_000), - (error) => error instanceof WindowsNativeStageError && error.stage === "spawn:cumulative-timeout", + const rejection = assert.rejects(result, (error) => error instanceof WindowsNativeStageError + && error.stage === "spawn:cleanup" && error.primaryStage === "spawn:timeout"); + await new Promise((resolve) => setTimeout(resolve, 45)); + assert.equal(settled, false); + assert.equal(active, 1); + await rejection; + assert.equal(active, 0); + assert.ok(performance.now() - started >= 75); + assert.deepEqual(diagnostics, [{ + brokerIndex: "0", + statusStage: "spawn:timeout", + entryIndex: null, + operation: null, + stderr: "empty", + stdout: "empty", + deadline: "expired", + cleanup: "deadline-expired", + }]); + assert.doesNotMatch(JSON.stringify(diagnostics), /SENTINEL|powershell|S-1-|[A-Za-z]:\\/i); +}); + +test("Windows broker supervisor waits for delayed close and both stream drains", async () => { + let active = 0; + let settled = false; + const diagnostics: unknown[] = []; + const started = performance.now(); + const result = runWindowsInspectionBrokerBatch({ + entryCount: 1, + startBroker: () => fixtureBroker({ + hang: true, + closeEventDelayMs: 15, + streamDrainDelayMs: 55, + onStart: () => { active += 1; }, + onClose: () => { active -= 1; }, + }), + deadlineMs: 5, + cleanupTimeoutMs: 100, + maxOutputBytes: 32, + onBrokerResult: (diagnostic) => diagnostics.push(diagnostic), + }).finally(() => { settled = true; }); + const rejection = assert.rejects(result, (error) => error instanceof WindowsNativeStageError + && error.stage === "spawn:timeout" && error.primaryStage === "spawn:timeout"); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.equal(active, 0); + assert.equal(settled, false); + await rejection; + assert.ok(performance.now() - started >= 50); + assert.deepEqual(diagnostics, [{ + brokerIndex: "0", + statusStage: "spawn:timeout", + entryIndex: null, + operation: null, + stderr: "empty", + stdout: "empty", + deadline: "expired", + cleanup: "contained", + }]); +}); + +test("Windows broker entry failures carry only a bounded index and operation token", async () => { + const diagnostics: WindowsBrokerResultDiagnostic[] = []; + const encodedAclFailure = 1_000 + (7 * 100) + 76; + await assert.rejects(runWindowsInspectionBrokerBatch({ + entryCount: 1, + startBroker: () => fixtureBroker({ status: encodedAclFailure }), + deadlineMs: 200, + cleanupTimeoutMs: 100, + maxOutputBytes: 32, + onBrokerResult: diagnostic => diagnostics.push(diagnostic), + }), (error) => error instanceof WindowsNativeStageError + && error.stage === "broker:acl" + && error.entryIndex === "07" + && error.operation === "acl" + && error.message === "Windows native authority inspection failed [entry=07 operation=acl]"); + assert.deepEqual(windowsBrokerFailureAttribution(1_000 + (31 * 100) + 86), { + stage: "broker:entry-rules", entryIndex: "31", operation: "entry-rules", + }); + assert.deepEqual(windowsBrokerFailureAttribution(1_000 + (32 * 100) + 76), { + stage: "spawn:status", entryIndex: null, operation: null, + }); + assert.deepEqual(diagnostics, [{ + brokerIndex: "0", + statusStage: "broker:acl", + entryIndex: "07", + operation: "acl", + stderr: "empty", + stdout: "empty", + deadline: "active", + cleanup: "contained", + }]); + assert.doesNotMatch(JSON.stringify(diagnostics), /SENTINEL|HANDLE|S-1-|[A-Za-z]:\\/i); +}); + +test("Windows revalidation control write maps callback, error, and EPIPE failures to broker control", async () => { + const success = new PassThrough(); + success.resume(); + await writeWindowsInspectionRevalidationControl({ stdin: success } as unknown as ChildProcess); + + for (const input of [ + new Writable({ + write(_chunk, _encoding, callback) { + callback(Object.assign(new Error("private-path-SENTINEL"), { code: "EPIPE" })); + }, + }), + Object.assign(new PassThrough(), { + end: () => { throw new Error("private-path-SENTINEL"); }, + }), + ]) { + await assert.rejects( + writeWindowsInspectionRevalidationControl({ stdin: input } as unknown as ChildProcess), + (error) => error instanceof WindowsNativeStageError + && error.stage === "broker:control" + && error.message === "Windows native authority inspection failed", + ); + } +}); + +test("Windows one-target broker documents reject missing, duplicate, and extra results", () => { + const { index: _index, kind: _kind, authorityKind: _authorityKind, ...entry } = inspection(); + const document = (entries: readonly unknown[]): string => JSON.stringify({ version: 1, entries }); + assert.deepEqual(parseWindowsBrokerDocument(document([entry])), entry); + for (const entries of [[], [entry, entry]]) assert.throws( + () => parseWindowsBrokerDocument(document(entries)), + (error) => error instanceof WindowsNativeStageError && error.stage === "parent:entry-count", ); assert.throws( - () => windowsInspectionTimeoutForElapsed(240_001), - (error) => error instanceof WindowsNativeStageError && error.stage === "spawn:cumulative-timeout", + () => parseWindowsBrokerDocument(document([{ ...entry, extra: true }])), + (error) => error instanceof WindowsNativeStageError && error.stage === "parent:entry-shape", ); }); -test("Windows production isolates entry fields and retains private handle lifetime", () => { +test("Windows production batches fixed inherited handles and revalidates each entry in-process", () => { assert.ok(WINDOWS_NATIVE_STAGE_CODES.includes("broker:fd-duplicate")); assert.ok(WINDOWS_NATIVE_STAGE_CODES.includes("broker:index-info-initial")); assert.ok(WINDOWS_NATIVE_STAGE_CODES.includes("broker:current-user-sid")); @@ -243,32 +526,24 @@ test("Windows production isolates entry fields and retains private handle lifeti assert.equal(windowsBrokerFailureStage(84), "broker:entry-format"); assert.equal(windowsBrokerFailureStage(85), "broker:entry-flags"); assert.equal(windowsBrokerFailureStage(86), "broker:entry-rules"); - - const duplicate = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=80"); + assert.equal(windowsBrokerFailureStage(87), "broker:control"); + assert.match(WINDOWS_INSPECTION_SOURCE, /\$n=__PROPR_ENTRY_COUNT__\n\$r=__PROPR_ROUND_COUNT__/); + assert.match(WINDOWS_INSPECTION_SOURCE, /for\(\$i=0;\$i-lt \$n;\$i\+\+\)/); + assert.match(WINDOWS_INSPECTION_SOURCE, /_get_osfhandle\(3\+\$i\)/); + assert.match(WINDOWS_INSPECTION_SOURCE, + /if\(\$script:e-ge 0-and \$script:e-lt 32\)\{\s+exit \(1000\+\(\$script:e\*100\)\+\$stage\)\s+\}/); + assert.match(WINDOWS_INSPECTION_SOURCE, /\$script:e=\$i/); + assert.match(WINDOWS_INSPECTION_SOURCE, /\} catch \{ Fail \} finally \{/); + assert.match(WINDOWS_INSPECTION_SOURCE, + /DuplicateHandle\(\s*\[ProprReadOnlyAuthority\]::GetCurrentProcess\(\),\$originalHandle,\s*\[ProprReadOnlyAuthority\]::GetCurrentProcess\(\),\[ref\]\$privateHandle,0,\$false,2\)\)\{Fail\}/); const initial = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=74"); - const sid = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=78"); - const revalidation = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=79"); + const revalidation = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=79", initial); const decode = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=81", revalidation); const compose = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=82", decode); - const entryFormat = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=84", compose); - const entryFlags = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=85", entryFormat); - const entryRules = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=86", entryFlags); - const entryBuild = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=83", entryRules); - const json = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=77", entryBuild); - assert.ok(duplicate >= 0 && duplicate < initial && initial < sid && sid < revalidation - && revalidation < decode && decode < compose && compose < entryFormat - && entryFormat < entryFlags && entryFlags < entryRules && entryRules < entryBuild - && entryBuild < json); - assert.match(WINDOWS_INSPECTION_SOURCE.slice(duplicate, initial), - /DuplicateHandle\(\s*\[ProprReadOnlyAuthority\]::GetCurrentProcess\(\),\$originalHandle,\s*\[ProprReadOnlyAuthority\]::GetCurrentProcess\(\),\[ref\]\$privateHandle,0,\$false,2\)\)\{exit \$stage\}/); - assert.match(WINDOWS_INSPECTION_SOURCE.slice(initial, sid), - /^\$stage=74\n \$before=.*AllocHGlobal\(52\)\n if\(-not .*GetFileInformationByHandle\(\$privateHandle,\$before\)\)\{exit \$stage\}\n $/s); - assert.match(WINDOWS_INSPECTION_SOURCE.slice(sid, WINDOWS_INSPECTION_SOURCE.indexOf("$stage=75", sid)), - /^\$stage=78\n \$current=.*WindowsIdentity\]::GetCurrent\(\)\.User\n if\(\$null-eq \$current\)\{exit \$stage\}\n \$currentSid=\$current\.Value\n $/s); - assert.match(WINDOWS_INSPECTION_SOURCE.slice(revalidation, decode), - /^\$stage=79\n \$after=.*AllocHGlobal\(52\)\n if\(-not .*GetFileInformationByHandle\(\$privateHandle,\$after\)\)\{exit \$stage\}\n $/s); + const entryBuild = WINDOWS_INSPECTION_SOURCE.indexOf("$stage=83", compose); + assert.ok(initial >= 0 && initial < revalidation && revalidation < decode + && decode < compose && compose < entryBuild); const decodedIdentity = WINDOWS_INSPECTION_SOURCE.slice(decode, compose); - assert.match(decodedIdentity, /^\$stage=81\n \$beforeVolume=/); for (const [field, structure, offset] of [ ["beforeVolume", "before", 28], ["afterVolume", "after", 28], ["beforeHigh", "before", 44], ["beforeLow", "before", 48], @@ -279,72 +554,21 @@ test("Windows production isolates entry fields and retains private handle lifeti assert.equal(WINDOWS_INSPECTION_SOURCE.match(/function Read-ProprUInt32/g)?.length, 1); assert.equal(WINDOWS_INSPECTION_SOURCE.match(/Read-ProprUInt32 \$(?:before|after) (?:28|44|48)/g)?.length, 6); assert.match(decodedIdentity, - /\$afterHigh=Read-ProprUInt32 \$after 44;\$afterLow=Read-ProprUInt32 \$after 48\n $/); + /\$afterHigh=Read-ProprUInt32 \$after 44;\$afterLow=Read-ProprUInt32 \$after 48\n\s*$/); assert.doesNotMatch(WINDOWS_INSPECTION_SOURCE, /\[uint32\]\[Runtime\.InteropServices\.Marshal\]::ReadInt32/); assert.match(WINDOWS_UNSIGNED_FIELD_DECODER_SOURCE, - /if\(-not \[BitConverter\]::IsLittleEndian\)\{exit \$stage\}\n \$signed=\[int32\]\[Runtime\.InteropServices\.Marshal\]::ReadInt32\(\$pointer,\$offset\)\n \$bytes=\[BitConverter\]::GetBytes\(\$signed\)\n \[BitConverter\]::ToUInt32\(\$bytes,0\)/); - const composedIdentity = WINDOWS_INSPECTION_SOURCE.slice( - compose, entryFormat, - ); + /if\(-not \[BitConverter\]::IsLittleEndian\)\{Exit-ProprStage\}\n \$signed=\[int32\]\[Runtime\.InteropServices\.Marshal\]::ReadInt32\(\$pointer,\$offset\)\n \$bytes=\[BitConverter\]::GetBytes\(\$signed\)\n \[BitConverter\]::ToUInt32\(\$bytes,0\)/); + const composedIdentity = WINDOWS_INSPECTION_SOURCE.slice(compose, WINDOWS_INSPECTION_SOURCE.indexOf("$stage=84", compose)); assert.match(composedIdentity, - /^\$stage=82\n \$beforeId=Join-ProprUInt64 \$beforeLow \$beforeHigh\n if\(\$beforeId-isnot \[uint64\]\)\{exit \$stage\}\n \$afterId=Join-ProprUInt64 \$afterLow \$afterHigh\n if\(\$afterId-isnot \[uint64\]\)\{exit \$stage\}\n $/); - const formattedIdentity = WINDOWS_INSPECTION_SOURCE.slice(entryFormat, entryFlags); - assert.equal(formattedIdentity, [ - "$stage=84", - " $beforeVolumeDecimal=$beforeVolume.ToString([Globalization.CultureInfo]::InvariantCulture)", - " $afterVolumeDecimal=$afterVolume.ToString([Globalization.CultureInfo]::InvariantCulture)", - " $beforeIdDecimal=$beforeId.ToString([Globalization.CultureInfo]::InvariantCulture)", - " $afterIdDecimal=$afterId.ToString([Globalization.CultureInfo]::InvariantCulture)", - " if($beforeVolumeDecimal-isnot [string]-or $beforeVolumeDecimal.Length-eq 0-or $beforeVolumeDecimal.Length-gt 10-or $beforeVolumeDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage}", - " if($afterVolumeDecimal-isnot [string]-or $afterVolumeDecimal.Length-eq 0-or $afterVolumeDecimal.Length-gt 10-or $afterVolumeDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage}", - " if($beforeIdDecimal-isnot [string]-or $beforeIdDecimal.Length-eq 0-or $beforeIdDecimal.Length-gt 20-or $beforeIdDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage}", - " if($afterIdDecimal-isnot [string]-or $afterIdDecimal.Length-eq 0-or $afterIdDecimal.Length-gt 20-or $afterIdDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage}", - " ", - ].join("\n")); - assert.equal(formattedIdentity.match(/\.ToString\(\[Globalization\.CultureInfo\]::InvariantCulture\)/g)?.length, 4); - assert.doesNotMatch(formattedIdentity, /\$entry=|Console|Write-|Out\./); - const entryFlagValidation = WINDOWS_INSPECTION_SOURCE.slice(entryFlags, entryRules); - assert.equal(entryFlagValidation, [ - "$stage=85", - " $daclProtected=[bool](($control-band 0x1000)-ne 0)", - " $reparsePoint=[bool](([Runtime.InteropServices.Marshal]::ReadInt32($before,0)-band 0x400)-ne 0)", - " if($daclProtected-isnot [bool]-or $reparsePoint-isnot [bool]){exit $stage}", - " ", - ].join("\n")); - assert.doesNotMatch(entryFlagValidation, /Console|Write-|Out\./); - const entryRuleValidation = WINDOWS_INSPECTION_SOURCE.slice(entryRules, entryBuild); - assert.equal(entryRuleValidation, [ - "$stage=86", - " [object[]]$rulesArray=$rules.ToArray()", - " if($rulesArray-isnot [object[]]-or $rulesArray.Count-ne $rules.Count-or $rulesArray.Count-gt 128){exit $stage}", - " for($ruleIndex=0;$ruleIndex-lt $rulesArray.Count;$ruleIndex++){", - " if(-not [object]::ReferenceEquals($rulesArray[$ruleIndex],$rules[$ruleIndex])){exit $stage}", - " }", - " ", - ].join("\n")); + /^\$stage=82\n\s+\$beforeId=Join-ProprUInt64 \$beforeLow \$beforeHigh\n\s+if\(\$beforeId-isnot \[uint64\]\)\{Fail\}\n\s+\$afterId=Join-ProprUInt64 \$afterLow \$afterHigh\n\s+if\(\$afterId-isnot \[uint64\]\)\{Fail\}\n\s*$/); assert.equal(WINDOWS_INSPECTION_SOURCE.match(/\[object\[\]\]\$rulesArray=\$rules\.ToArray\(\)/g)?.length, 1); assert.doesNotMatch(WINDOWS_INSPECTION_SOURCE, /@\(\s*\$rules\s*\)/); - assert.doesNotMatch(entryRuleValidation, /ConvertTo-Json|\.ToString|Console|Write-|Out\./); - const entryConstruction = WINDOWS_INSPECTION_SOURCE.slice(entryBuild, json); - assert.equal(entryConstruction, [ - "$stage=83", - " $entry=[pscustomobject][ordered]@{", - " index=__PROPR_INDEX__;kind='__PROPR_ENTRY_KIND__';authorityKind='__PROPR_AUTHORITY_KIND__';currentUserSid=$currentSid;ownerSid=$ownerSid", - " daclProtected=$daclProtected;reparsePoint=$reparsePoint", - " volumeSerialNumber=$beforeVolumeDecimal", - " fileId=$beforeIdDecimal", - " verifiedVolumeSerialNumber=$afterVolumeDecimal", - " verifiedFileId=$afterIdDecimal;rules=$rulesArray", - " }", - " ", - ].join("\n")); - assert.doesNotMatch(entryConstruction, - /Marshal|\.ToString|InvariantCulture|@\(\$rules\)|ReferenceEquals|-band|\bfor\s*\(/); + assert.doesNotMatch(WINDOWS_INSPECTION_SOURCE, /\bpath=|\bkind=|authorityKind=|S-1-/); assert.doesNotMatch(composedIdentity, /ToString|\$entry=/); assert.doesNotMatch(WINDOWS_INSPECTION_SOURCE, /4294967296|\[uint64\]\$(?:before|after)High\*/); assert.match(WINDOWS_UINT64_COMPOSER_SOURCE, - /function Join-ProprUInt64\(\[uint32\]\$low,\[uint32\]\$high\)\{\n if\(-not \[BitConverter\]::IsLittleEndian\)\{exit \$stage\}\n \$bytes=New-Object byte\[\] 8\n \[Array\]::Copy\(\[BitConverter\]::GetBytes\(\[uint32\]\$low\),0,\$bytes,0,4\)\n \[Array\]::Copy\(\[BitConverter\]::GetBytes\(\[uint32\]\$high\),0,\$bytes,4,4\)\n \[BitConverter\]::ToUInt64\(\$bytes,0\)\n\}/); + /function Join-ProprUInt64\(\[uint32\]\$low,\[uint32\]\$high\)\{\n if\(-not \[BitConverter\]::IsLittleEndian\)\{Exit-ProprStage\}\n \$bytes=New-Object byte\[\] 8\n \[Array\]::Copy\(\[BitConverter\]::GetBytes\(\[uint32\]\$low\),0,\$bytes,0,4\)\n \[Array\]::Copy\(\[BitConverter\]::GetBytes\(\[uint32\]\$high\),0,\$bytes,4,4\)\n \[BitConverter\]::ToUInt64\(\$bytes,0\)\n\}/); const unsignedDecimal = (value: number): string => { const bytes = Buffer.alloc(4); bytes.writeInt32LE(value, 0); @@ -369,10 +593,14 @@ test("Windows production isolates entry fields and retains private handle lifeti assert.match(WINDOWS_INSPECTION_SOURCE, /GetSecurityInfo\(\$privateHandle,1,5,\[ref\]\$owner,\[ref\]\$group,\[ref\]\$dacl,\[ref\]\$sacl,\[ref\]\$descriptor\)/); assert.equal(WINDOWS_INSPECTION_SOURCE.match(/::CloseHandle\(\$privateHandle\)/g)?.length, 1); - assert.match(WINDOWS_INSPECTION_SOURCE, - /finally \{if\(\$privateHandleOwned\)\{\$null=\[ProprReadOnlyAuthority\]::CloseHandle\(\$privateHandle\)\}\}/); + assert.match(WINDOWS_INSPECTION_SOURCE, /ReadLine\(\)-cne 'PROPR_REVALIDATE_V1'/); + assert.match(WINDOWS_INSPECTION_SOURCE, /\[Console\]::Out\.WriteLine\(\$json\)/); + assert.match(WINDOWS_INSPECTION_SOURCE, /FreeHGlobal\(\$before\)/); assert.doesNotMatch(WINDOWS_INSPECTION_SOURCE, /CloseHandle\(\$originalHandle\)/); - assert.doesNotMatch(WINDOWS_INSPECTION_SOURCE.slice(initial), /\$originalHandle/); + const maximumSource = WINDOWS_INSPECTION_SOURCE + .replace("__PROPR_ENTRY_COUNT__", "32") + .replace("__PROPR_ROUND_COUNT__", "2"); + assert.ok(Buffer.from(maximumSource, "utf16le").toString("base64").length <= 28_000); }); test("Windows PowerShell boundary retains a derived minimal environment and no filesystem writes", () => { @@ -386,7 +614,7 @@ test("Windows PowerShell boundary retains a derived minimal environment and no f ]) assert.equal(forbidden in windowsPowerShellEnvironment("C:\\Windows"), false); assert.equal(WINDOWS_INSPECTOR_CREATES_CHILD_PROCESSES, false); assert.equal(WINDOWS_INSPECTOR_WRITES_FILESYSTEM, false); - assert.equal(WINDOWS_INSPECTOR_TRANSPORT, "inherited-standard-handle"); + assert.equal(WINDOWS_INSPECTOR_TRANSPORT, "inherited-fixed-fd-table"); for (const source of [WINDOWS_INSPECTION_SOURCE, WINDOWS_NATIVE_TIMING_PROBE_SOURCE]) { assert.doesNotMatch(source, /Add-Type|Start-Process|Set-Content|Out-File|New-Item|Remove-Item|Invoke-Expression/i); } @@ -417,12 +645,12 @@ test("Windows timing probe isolates baseline, Reflection.Emit, Win32, and standa assert.equal(WINDOWS_NATIVE_TIMING_PROBE_SOURCE.match(/function Read-ProprUInt32/g)?.length, 1); assert.equal(WINDOWS_NATIVE_TIMING_PROBE_SOURCE.match(/Read-ProprUInt32 \$info (?:28|44|48)/g)?.length, 3); assert.match(WINDOWS_NATIVE_TIMING_PROBE_SOURCE.slice(probeCompose, probeFormat), - /^Join-ProprUInt64 \$probeLow \$probeHigh\n if\(\$probeId-isnot \[uint64\]\)\{exit \$stage\}\n $/); + /^Join-ProprUInt64 \$probeLow \$probeHigh\n if\(\$probeId-isnot \[uint64\]\)\{Fail\}\n $/); assert.equal(WINDOWS_NATIVE_TIMING_PROBE_SOURCE.slice(probeFormat, milestones[4]), [ "$probeVolumeDecimal=$probeVolume.ToString([Globalization.CultureInfo]::InvariantCulture)", " $probeIdDecimal=$probeId.ToString([Globalization.CultureInfo]::InvariantCulture)", - " if($probeVolumeDecimal-isnot [string]-or $probeVolumeDecimal.Length-eq 0-or $probeVolumeDecimal.Length-gt 10-or $probeVolumeDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage}", - " if($probeIdDecimal-isnot [string]-or $probeIdDecimal.Length-eq 0-or $probeIdDecimal.Length-gt 20-or $probeIdDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage}", + " if($probeVolumeDecimal-isnot [string]-or $probeVolumeDecimal.Length-eq 0-or $probeVolumeDecimal.Length-gt 10-or $probeVolumeDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){Fail}", + " if($probeIdDecimal-isnot [string]-or $probeIdDecimal.Length-eq 0-or $probeIdDecimal.Length-gt 20-or $probeIdDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){Fail}", " ", ].join("\n")); }); diff --git a/packages/cli/src/connectRootAuthority.ts b/packages/cli/src/connectRootAuthority.ts index 0f21ab92e..8a9d08ce3 100644 --- a/packages/cli/src/connectRootAuthority.ts +++ b/packages/cli/src/connectRootAuthority.ts @@ -18,11 +18,14 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { + beginWindowsReadOnlyInspectionGeneration, parseWindowsInspectionDocument, reportWindowsNativeStage, runWindowsReadOnlyInspection, WindowsNativeStageError, windowsInspectionEntryKind, + type WindowsBrokerEntryIndexToken, + type WindowsBrokerEntryOperationToken, } from "./connectWindowsAuthority.js"; import { assertCanonicalNativeArtifactParents, @@ -128,6 +131,15 @@ export interface ConnectRootAuthorityInspector { kind?: ConnectAuthorityEntryKind, ): Promise; inspectWindowsAcls?(entries: readonly WindowsAuthorityTarget[]): Promise; + beginWindowsAclsGeneration?( + entries: readonly WindowsAuthorityTarget[], + ): Promise; +} + +export interface WindowsAuthorityInspectionGeneration { + readonly initial: readonly WindowsAuthorityInspection[]; + revalidate(entries: readonly WindowsAuthorityTarget[]): Promise; + abort(): Promise; } export type WindowsAuthorityPolicyReason = @@ -151,12 +163,25 @@ export class WindowsAuthorityPolicyError extends Error { /** Fixed, redacted boundary for a failed read-only Windows ACL inspection. */ export class WindowsAuthorityInspectionError extends Error { - constructor() { - super("Windows ACL authority inspection is unavailable"); + constructor( + readonly entryIndex: WindowsBrokerEntryIndexToken | null = null, + readonly operation: WindowsBrokerEntryOperationToken | null = null, + ) { + super(entryIndex === null || operation === null + ? "Windows ACL authority inspection is unavailable" + : `Windows ACL authority inspection is unavailable [entry=${entryIndex} operation=${operation}]`); this.name = "WindowsAuthorityInspectionError"; } } +function unavailableWindowsAuthority(error?: unknown): WindowsAuthorityInspectionError { + if (error instanceof WindowsNativeStageError) { + reportWindowsNativeStage(error.stage); + return new WindowsAuthorityInspectionError(error.entryIndex, error.operation); + } + return new WindowsAuthorityInspectionError(); +} + export function stableAuthorityIdentity(fd: number): StableAuthorityIdentity { const stat = fstatSync(fd, { bigint: true }); return { device: stat.dev.toString(10), file: stat.ino.toString(10) }; @@ -385,10 +410,32 @@ async function nativeWindowsAcls( entries: readonly WindowsAuthorityTarget[], ): Promise { try { - return runWindowsReadOnlyInspection(entries); + return await runWindowsReadOnlyInspection(entries); } catch (error) { - if (error instanceof WindowsNativeStageError) reportWindowsNativeStage(error.stage); - throw new WindowsAuthorityInspectionError(); + throw unavailableWindowsAuthority(error); + } +} + +async function beginNativeWindowsAclsGeneration( + entries: readonly WindowsAuthorityTarget[], +): Promise { + try { + const generation = await beginWindowsReadOnlyInspectionGeneration(entries); + return { + initial: generation.initial, + revalidate: async (finalEntries) => { + try { return await generation.revalidate(finalEntries); } catch (error) { + throw unavailableWindowsAuthority(error); + } + }, + abort: async () => { + try { await generation.abort(); } catch (error) { + throw unavailableWindowsAuthority(error); + } + }, + }; + } catch (error) { + throw unavailableWindowsAuthority(error); } } @@ -411,6 +458,7 @@ export const nativeConnectRootAuthorityInspector: ConnectRootAuthorityInspector inspectDarwinAcl: nativeDarwinAcl, inspectWindowsAcl: nativeWindowsAcl, inspectWindowsAcls: nativeWindowsAcls, + beginWindowsAclsGeneration: beginNativeWindowsAclsGeneration, }; /** Windows mutation is unsupported until the separately reviewed authority work lands. */ @@ -599,23 +647,23 @@ export async function assertNativeEntryAuthority( } } -/** Inspect and bind one Windows descriptor batch before applying entry policy. */ -export async function assertNativeWindowsEntriesAuthority( - inspector: ConnectRootAuthorityInspector, - entries: readonly { path: string; kind: ConnectAuthorityEntryKind; pinnedFd: number }[], -): Promise { - const targets = entries.map((entry) => ({ +type WindowsAuthorityEntry = { path: string; kind: ConnectAuthorityEntryKind; pinnedFd: number }; + +function windowsAuthorityTargets(entries: readonly WindowsAuthorityEntry[]): readonly WindowsAuthorityTarget[] { + return entries.map((entry) => ({ path: entry.path, kind: entry.kind, expectedIdentity: stableAuthorityIdentity(entry.pinnedFd), pinnedFd: entry.pinnedFd, })); - const batched = inspector.inspectWindowsAcls !== undefined; - const inspections = inspector.inspectWindowsAcls - ? await inspector.inspectWindowsAcls(targets) - : await Promise.all(targets.map((target) => inspector.inspectWindowsAcl( - target.path, target.expectedIdentity, target.pinnedFd, target.kind, - ))); +} + +function assertWindowsAuthorityInspectionResults( + inspections: readonly WindowsAuthorityInspection[], + targets: readonly WindowsAuthorityTarget[], + entries: readonly WindowsAuthorityEntry[], + batched: boolean, +): void { if (inspections.length !== targets.length) { reportWindowsNativeStage("parent:entry-count"); throw new WindowsAuthorityInspectionError(); @@ -660,4 +708,69 @@ export async function assertNativeWindowsEntriesAuthority( } } +/** Inspect and bind one Windows descriptor batch before applying entry policy. */ +export async function assertNativeWindowsEntriesAuthority( + inspector: ConnectRootAuthorityInspector, + entries: readonly WindowsAuthorityEntry[], +): Promise { + const targets = windowsAuthorityTargets(entries); + const batched = inspector.inspectWindowsAcls !== undefined; + const inspections = inspector.inspectWindowsAcls + ? await inspector.inspectWindowsAcls(targets) + : await Promise.all(targets.map((target) => inspector.inspectWindowsAcl( + target.path, target.expectedIdentity, target.pinnedFd, target.kind, + ))); + assertWindowsAuthorityInspectionResults(inspections, targets, entries, batched); +} + +export interface NativeWindowsEntriesAuthorityGeneration { + revalidate(entries: readonly WindowsAuthorityEntry[]): Promise; + abort(): Promise; +} + +/** + * Keep native before/after authority in one cold-start generation when the + * inspector supports it. Fixture/custom inspectors retain the two-call path. + */ +export async function beginNativeWindowsEntriesAuthorityGeneration( + inspector: ConnectRootAuthorityInspector, + entries: readonly WindowsAuthorityEntry[], +): Promise { + if (!inspector.beginWindowsAclsGeneration) { + await assertNativeWindowsEntriesAuthority(inspector, entries); + let consumed = false; + return { + revalidate: async (finalEntries) => { + if (consumed) throw new WindowsAuthorityInspectionError(); + consumed = true; + await assertNativeWindowsEntriesAuthority(inspector, finalEntries); + }, + abort: async () => { consumed = true; }, + }; + } + const targets = windowsAuthorityTargets(entries); + const generation = await inspector.beginWindowsAclsGeneration(targets); + try { + assertWindowsAuthorityInspectionResults(generation.initial, targets, entries, true); + } catch (error) { + await generation.abort(); + throw error; + } + let consumed = false; + return { + revalidate: async (finalEntries) => { + if (consumed) throw new WindowsAuthorityInspectionError(); + consumed = true; + const finalTargets = windowsAuthorityTargets(finalEntries); + const inspections = await generation.revalidate(finalTargets); + assertWindowsAuthorityInspectionResults(inspections, finalTargets, finalEntries, true); + }, + abort: async () => { + if (consumed) return; + consumed = true; + await generation.abort(); + }, + }; +} + export { parseWindowsInspectionDocument }; diff --git a/packages/cli/src/connectWindowsAuthority.ts b/packages/cli/src/connectWindowsAuthority.ts index 0ff26064e..6ffd7d35b 100644 --- a/packages/cli/src/connectWindowsAuthority.ts +++ b/packages/cli/src/connectWindowsAuthority.ts @@ -1,4 +1,4 @@ -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { closeSync, constants, @@ -16,11 +16,10 @@ import type { } from "./connectRootAuthority.js"; // Hosted alternate-user Windows can spend more than fifteen seconds entering -// the fixed PowerShell/Reflection.Emit boundary. Each production call gets one -// bounded cold-start allowance. The cumulative cap is a fixed four-process -// proof ceiling and is independent of the 32-entry input-schema bound. +// the fixed PowerShell/Reflection.Emit boundary. All one-target brokers start +// concurrently and share this single wall-clock allowance. export const WINDOWS_INSPECTION_TIMEOUT_MS = 60_000; -export const WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS = 240_000; +export const WINDOWS_INSPECTION_CLEANUP_TIMEOUT_MS = 5_000; export const WINDOWS_NATIVE_TIMING_PROBE_TIMEOUT_MS = 60_000; const WINDOWS_INSPECTION_MAX_BYTES = 128 * 1024; const WINDOWS_NATIVE_PROBE_MAX_BYTES = 2 * 1024; @@ -29,12 +28,12 @@ const GLOBAL_SYSTEM_ROOT = String.raw`\\?\GLOBALROOT\SystemRoot`; export const WINDOWS_NATIVE_STAGE_CODES = Object.freeze([ "resolver:env", "resolver:canonical", "resolver:global-open", "resolver:global-id", - "spawn:create", "spawn:error", "spawn:timeout", "spawn:cumulative-timeout", "spawn:status", "spawn:stderr", + "spawn:create", "spawn:error", "spawn:timeout", "spawn:status", "spawn:stderr", "spawn:cleanup", "probe:entry", "probe:baseline", "probe:reflection-emit", "probe:win32", "probe:standard-handle", "probe:output", "broker:ps-version", "broker:job", "broker:fd", "broker:fd-duplicate", "broker:index-info-initial", "broker:security-info", "broker:acl", "broker:json", "broker:current-user-sid", "broker:index-info-revalidation", "broker:index-info-decode", "broker:index-info-compose", "broker:entry-format", - "broker:entry-flags", "broker:entry-rules", "broker:entry-build", + "broker:entry-flags", "broker:entry-rules", "broker:entry-build", "broker:control", "parent:utf8", "parent:json-parse", "parent:json-canonical", "parent:document-shape", "parent:entry-count", "parent:entry-shape", "parent:json-shape", "parent:descriptor-bind", "parent:post-bind", ] as const); @@ -43,10 +42,31 @@ export type WindowsNativeStageCode = (typeof WINDOWS_NATIVE_STAGE_CODES)[number] const WINDOWS_NATIVE_STAGE_SET: ReadonlySet = new Set(WINDOWS_NATIVE_STAGE_CODES); const WINDOWS_NATIVE_DIAGNOSTIC_HOOK = Symbol.for("propr.test.windowsNativeDiagnostic"); +const WINDOWS_BROKER_ENTRY_FAILURE_BASE = 1_000; +const WINDOWS_BROKER_ENTRY_FAILURE_STRIDE = 100; + +export type WindowsBrokerEntryIndexToken = + | "00" | "01" | "02" | "03" | "04" | "05" | "06" | "07" + | "08" | "09" | "10" | "11" | "12" | "13" | "14" | "15" + | "16" | "17" | "18" | "19" | "20" | "21" | "22" | "23" + | "24" | "25" | "26" | "27" | "28" | "29" | "30" | "31"; + +export type WindowsBrokerEntryOperationToken = + | "fd" | "fd-duplicate" | "identity-initial" | "security-info" | "acl" + | "identity-revalidation" | "identity-decode" | "identity-compose" + | "entry-format" | "entry-flags" | "entry-rules" | "entry-build"; export class WindowsNativeStageError extends Error { - constructor(readonly stage: WindowsNativeStageCode) { - super("Windows native authority inspection failed"); + constructor( + readonly stage: WindowsNativeStageCode, + /** The initiating failure retained when cleanup becomes the terminal result. */ + readonly primaryStage: WindowsNativeStageCode = stage, + readonly entryIndex: WindowsBrokerEntryIndexToken | null = null, + readonly operation: WindowsBrokerEntryOperationToken | null = null, + ) { + super(entryIndex === null || operation === null + ? "Windows native authority inspection failed" + : `Windows native authority inspection failed [entry=${entryIndex} operation=${operation}]`); this.name = "WindowsNativeStageError"; } } @@ -58,22 +78,36 @@ export function reportWindowsNativeStage(stage: WindowsNativeStageCode): void { try { (hook as (value: string) => void)(stage); } catch { /* Diagnostics never alter production status. */ } } -function stageError(stage: WindowsNativeStageCode): WindowsNativeStageError { - return new WindowsNativeStageError(stage); +function stageError( + stage: WindowsNativeStageCode, + primaryStage: WindowsNativeStageCode = stage, + entryIndex: WindowsBrokerEntryIndexToken | null = null, + operation: WindowsBrokerEntryOperationToken | null = null, +): WindowsNativeStageError { + return new WindowsNativeStageError(stage, primaryStage, entryIndex, operation); } -// Each production inspector receives exactly one already-open target as its -// standard-input HANDLE. Unlike Node extra stdio slots, STARTF_USESTDHANDLES is -// a documented Windows process boundary and GetStdHandle returns the inherited -// HANDLE directly. The script contains no process-creation API or external -// command; terminating powershell.exe therefore terminates the complete tree. +// Each production inspector receives every already-open target in one fixed, +// bounded fd table beginning at fd 3. The only per-spawn source substitutions +// are canonical entry/round counts; target paths, HANDLE values, identities, +// kinds, SIDs, and metadata never cross argv/environment. The script contains +// no process-creation API or external command, so powershell.exe is the entire +// owned process tree. export const WINDOWS_INSPECTOR_CREATES_CHILD_PROCESSES = false; export const WINDOWS_INSPECTOR_WRITES_FILESYSTEM = false; -export const WINDOWS_INSPECTOR_TRANSPORT = "inherited-standard-handle" as const; +export const WINDOWS_INSPECTOR_TRANSPORT = "inherited-fixed-fd-table" as const; + +const WINDOWS_BROKER_STAGE_EXIT_SOURCE = String.raw` +function Exit-ProprStage { + if($script:proprEntryIndex-ge 0-and $script:proprEntryIndex-lt 32){ + exit (${WINDOWS_BROKER_ENTRY_FAILURE_BASE}+($script:proprEntryIndex*${WINDOWS_BROKER_ENTRY_FAILURE_STRIDE})+$stage) + } + exit $stage +}`; export const WINDOWS_UNSIGNED_FIELD_DECODER_SOURCE = String.raw` function Read-ProprUInt32([IntPtr]$pointer,[int]$offset){ - if(-not [BitConverter]::IsLittleEndian){exit $stage} + if(-not [BitConverter]::IsLittleEndian){Exit-ProprStage} $signed=[int32][Runtime.InteropServices.Marshal]::ReadInt32($pointer,$offset) $bytes=[BitConverter]::GetBytes($signed) [BitConverter]::ToUInt32($bytes,0) @@ -81,7 +115,7 @@ function Read-ProprUInt32([IntPtr]$pointer,[int]$offset){ export const WINDOWS_UINT64_COMPOSER_SOURCE = String.raw` function Join-ProprUInt64([uint32]$low,[uint32]$high){ - if(-not [BitConverter]::IsLittleEndian){exit $stage} + if(-not [BitConverter]::IsLittleEndian){Exit-ProprStage} $bytes=New-Object byte[] 8 [Array]::Copy([BitConverter]::GetBytes([uint32]$low),0,$bytes,0,4) [Array]::Copy([BitConverter]::GetBytes([uint32]$high),0,$bytes,4,4) @@ -90,18 +124,24 @@ function Join-ProprUInt64([uint32]$low,[uint32]$high){ // Reflection.Emit keeps the fixed P/Invoke surface in memory. Add-Type and its // writable compiler workspace are deliberately absent. +// Two fixed internal spellings are compacted before UTF-16/base64 encoding so +// this audited source remains below the existing 28k argv bound. They contain +// no caller-controlled value and do not alter the protocol. export const WINDOWS_INSPECTION_SOURCE = String.raw` $ErrorActionPreference='Stop' $ProgressPreference='SilentlyContinue' Set-StrictMode -Version 2 +$script:proprEntryIndex=-1 +${WINDOWS_BROKER_STAGE_EXIT_SOURCE} ${WINDOWS_UNSIGNED_FIELD_DECODER_SOURCE} ${WINDOWS_UINT64_COMPOSER_SOURCE} +$n=__PROPR_ENTRY_COUNT__ +$r=__PROPR_ROUND_COUNT__ $stage=71 -$privateHandle=[IntPtr]::Zero -$privateHandleOwned=$false try { + if($n-lt 1-or $n-gt 32-or ($r-ne 1-and $r-ne 2)){Exit-ProprStage} if($PSVersionTable.PSVersion.Major-ne 5-or $PSVersionTable.PSVersion.Minor-ne 1-or - $PSVersionTable.PSEdition-ne 'Desktop'-or -not [Environment]::Is64BitProcess){exit $stage} + $PSVersionTable.PSEdition-ne 'Desktop'-or -not [Environment]::Is64BitProcess){Exit-ProprStage} $assembly=[AppDomain]::CurrentDomain.DefineDynamicAssembly( (New-Object Reflection.AssemblyName('ProprReadOnlyAuthorityAssembly')), [Reflection.Emit.AssemblyBuilderAccess]::Run) @@ -115,7 +155,7 @@ try { } $winapi=[Runtime.InteropServices.CallingConvention]::Winapi $intptr=[IntPtr];$intptrRef=$intptr.MakeByRefType();$uint=[uint32];$uintRef=$uint.MakeByRefType();$ushortRef=([uint16]).MakeByRefType();$boolRef=([bool]).MakeByRefType() - Add-NativeMethod 'GetStdHandle' 'kernel32.dll' $intptr @([int]) $winapi + Add-NativeMethod '_get_osfhandle' 'msvcrt.dll' $intptr @([int]) $winapi Add-NativeMethod 'DuplicateHandle' 'kernel32.dll' ([bool]) @($intptr,$intptr,$intptr,$intptrRef,$uint,[bool],$uint) $winapi Add-NativeMethod 'CloseHandle' 'kernel32.dll' ([bool]) @($intptr) $winapi Add-NativeMethod 'GetFileInformationByHandle' 'kernel32.dll' ([bool]) @($intptr,$intptr) $winapi @@ -129,106 +169,127 @@ try { $null=$builder.CreateType() $stage=72 $inJob=$false - if(-not [ProprReadOnlyAuthority]::IsProcessInJob([ProprReadOnlyAuthority]::GetCurrentProcess(),[IntPtr]::Zero,[ref]$inJob)){exit $stage} - $stage=73 - $originalHandle=[ProprReadOnlyAuthority]::GetStdHandle(-10) - if($originalHandle-eq [IntPtr](-1)-or $originalHandle-eq [IntPtr](-2)-or $originalHandle-eq [IntPtr]::Zero){exit $stage} - $stage=80 - if(-not [ProprReadOnlyAuthority]::DuplicateHandle( - [ProprReadOnlyAuthority]::GetCurrentProcess(),$originalHandle, - [ProprReadOnlyAuthority]::GetCurrentProcess(),[ref]$privateHandle,0,$false,2)){exit $stage} - $privateHandleOwned=$true - if($privateHandle-eq [IntPtr](-1)-or $privateHandle-eq [IntPtr](-2)-or $privateHandle-eq [IntPtr]::Zero){exit $stage} - $stage=74 - $before=[Runtime.InteropServices.Marshal]::AllocHGlobal(52) - if(-not [ProprReadOnlyAuthority]::GetFileInformationByHandle($privateHandle,$before)){exit $stage} - $stage=78 - $current=[Security.Principal.WindowsIdentity]::GetCurrent().User - if($null-eq $current){exit $stage} - $currentSid=$current.Value - $stage=75 - $owner=[IntPtr]::Zero;$group=[IntPtr]::Zero;$dacl=[IntPtr]::Zero;$sacl=[IntPtr]::Zero;$descriptor=[IntPtr]::Zero + if(-not [ProprReadOnlyAuthority]::IsProcessInJob([ProprReadOnlyAuthority]::GetCurrentProcess(),[IntPtr]::Zero,[ref]$inJob)){Exit-ProprStage} + function Inspect-ProprEntry([int]$i,[string]$currentSid){ + $script:proprEntryIndex=$i + $privateHandle=[IntPtr]::Zero + $before=[IntPtr]::Zero;$after=[IntPtr]::Zero;$aclInfo=[IntPtr]::Zero + $descriptor=[IntPtr]::Zero try { - if([ProprReadOnlyAuthority]::GetSecurityInfo($privateHandle,1,5,[ref]$owner,[ref]$group,[ref]$dacl,[ref]$sacl,[ref]$descriptor)-ne 0){exit $stage} - if($owner-eq [IntPtr]::Zero-or $dacl-eq [IntPtr]::Zero-or $descriptor-eq [IntPtr]::Zero){exit $stage} + $stage=73 + $originalHandle=[ProprReadOnlyAuthority]::_get_osfhandle(3+$i) + if($originalHandle-eq [IntPtr](-1)-or $originalHandle-eq [IntPtr](-2)-or $originalHandle-eq [IntPtr]::Zero){Exit-ProprStage} + $stage=80 + if(-not [ProprReadOnlyAuthority]::DuplicateHandle( + [ProprReadOnlyAuthority]::GetCurrentProcess(),$originalHandle, + [ProprReadOnlyAuthority]::GetCurrentProcess(),[ref]$privateHandle,0,$false,2)){Exit-ProprStage} + if($privateHandle-eq [IntPtr](-1)-or $privateHandle-eq [IntPtr](-2)-or $privateHandle-eq [IntPtr]::Zero){Exit-ProprStage} + $stage=74 + $before=[Runtime.InteropServices.Marshal]::AllocHGlobal(52) + if(-not [ProprReadOnlyAuthority]::GetFileInformationByHandle($privateHandle,$before)){Exit-ProprStage} + $stage=75 + $owner=[IntPtr]::Zero;$group=[IntPtr]::Zero;$dacl=[IntPtr]::Zero;$sacl=[IntPtr]::Zero + if([ProprReadOnlyAuthority]::GetSecurityInfo($privateHandle,1,5,[ref]$owner,[ref]$group,[ref]$dacl,[ref]$sacl,[ref]$descriptor)-ne 0){Exit-ProprStage} + if($owner-eq [IntPtr]::Zero-or $dacl-eq [IntPtr]::Zero-or $descriptor-eq [IntPtr]::Zero){Exit-ProprStage} $ownerSid=(New-Object Security.Principal.SecurityIdentifier($owner)).Value $control=[uint16]0;$revision=[uint32]0 - if(-not [ProprReadOnlyAuthority]::GetSecurityDescriptorControl($descriptor,[ref]$control,[ref]$revision)){exit $stage} + if(-not [ProprReadOnlyAuthority]::GetSecurityDescriptorControl($descriptor,[ref]$control,[ref]$revision)){Exit-ProprStage} $stage=76 $aclInfo=[Runtime.InteropServices.Marshal]::AllocHGlobal(12) - if(-not [ProprReadOnlyAuthority]::GetAclInformation($dacl,$aclInfo,12,2)){exit $stage} + if(-not [ProprReadOnlyAuthority]::GetAclInformation($dacl,$aclInfo,12,2)){Exit-ProprStage} $aceCount=Read-ProprUInt32 $aclInfo 0 $aclBytes=Read-ProprUInt32 $aclInfo 4 - if($aceCount-gt 128-or $aclBytes-lt 8-or $aclBytes-gt 65535){exit $stage} + if($aceCount-gt 128-or $aclBytes-lt 8-or $aclBytes-gt 65535){Exit-ProprStage} $aclRevision=[Runtime.InteropServices.Marshal]::ReadByte($dacl,0) - if(($aclRevision-ne 2-and $aclRevision-ne 4)-or [Runtime.InteropServices.Marshal]::ReadByte($dacl,1)-ne 0){exit $stage} + if(($aclRevision-ne 2-and $aclRevision-ne 4)-or [Runtime.InteropServices.Marshal]::ReadByte($dacl,1)-ne 0){Exit-ProprStage} $rules=New-Object Collections.Generic.List[object] for($aceIndex=0;$aceIndex-lt $aceCount;$aceIndex++){ $ace=[IntPtr]::Zero - if(-not [ProprReadOnlyAuthority]::GetAce($dacl,$aceIndex,[ref]$ace)-or $ace-eq [IntPtr]::Zero){exit $stage} + if(-not [ProprReadOnlyAuthority]::GetAce($dacl,$aceIndex,[ref]$ace)-or $ace-eq [IntPtr]::Zero){Exit-ProprStage} $aceType=[Runtime.InteropServices.Marshal]::ReadByte($ace,0);$flags=[Runtime.InteropServices.Marshal]::ReadByte($ace,1) $aceSize=[uint16][Runtime.InteropServices.Marshal]::ReadInt16($ace,2) - if(($aceType-ne 0-and $aceType-ne 1)-or ($flags-band 0xE0)-ne 0-or $aceSize-lt 16-or $aceSize-gt 4096){exit $stage} + if(($aceType-ne 0-and $aceType-ne 1)-or ($flags-band 0xE0)-ne 0-or $aceSize-lt 16-or $aceSize-gt 4096){Exit-ProprStage} $mask=Read-ProprUInt32 $ace 4 $sidPointer=[IntPtr]::Add($ace,8);$sid=New-Object Security.Principal.SecurityIdentifier($sidPointer) - if($sid.BinaryLength-gt ($aceSize-8)){exit $stage} + if($sid.BinaryLength-gt ($aceSize-8)){Exit-ProprStage} $rules.Add([pscustomobject][ordered]@{ identitySid=$sid.Value;inherited=[bool](($flags-band 0x10)-ne 0) accessType=$(if($aceType-eq 0){'allow'}else{'deny'});appliesToSelf=[bool](($flags-band 8)-eq 0) rights=$mask.ToString([Globalization.CultureInfo]::InvariantCulture) }) } - } finally {if($descriptor-ne [IntPtr]::Zero){$null=[ProprReadOnlyAuthority]::LocalFree($descriptor)}} - $stage=79 - $after=[Runtime.InteropServices.Marshal]::AllocHGlobal(52) - if(-not [ProprReadOnlyAuthority]::GetFileInformationByHandle($privateHandle,$after)){exit $stage} - $stage=81 - $beforeVolume=Read-ProprUInt32 $before 28 - $afterVolume=Read-ProprUInt32 $after 28 - $beforeHigh=Read-ProprUInt32 $before 44;$beforeLow=Read-ProprUInt32 $before 48 - $afterHigh=Read-ProprUInt32 $after 44;$afterLow=Read-ProprUInt32 $after 48 - $stage=82 - $beforeId=Join-ProprUInt64 $beforeLow $beforeHigh - if($beforeId-isnot [uint64]){exit $stage} - $afterId=Join-ProprUInt64 $afterLow $afterHigh - if($afterId-isnot [uint64]){exit $stage} - $stage=84 - $beforeVolumeDecimal=$beforeVolume.ToString([Globalization.CultureInfo]::InvariantCulture) - $afterVolumeDecimal=$afterVolume.ToString([Globalization.CultureInfo]::InvariantCulture) - $beforeIdDecimal=$beforeId.ToString([Globalization.CultureInfo]::InvariantCulture) - $afterIdDecimal=$afterId.ToString([Globalization.CultureInfo]::InvariantCulture) - if($beforeVolumeDecimal-isnot [string]-or $beforeVolumeDecimal.Length-eq 0-or $beforeVolumeDecimal.Length-gt 10-or $beforeVolumeDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage} - if($afterVolumeDecimal-isnot [string]-or $afterVolumeDecimal.Length-eq 0-or $afterVolumeDecimal.Length-gt 10-or $afterVolumeDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage} - if($beforeIdDecimal-isnot [string]-or $beforeIdDecimal.Length-eq 0-or $beforeIdDecimal.Length-gt 20-or $beforeIdDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage} - if($afterIdDecimal-isnot [string]-or $afterIdDecimal.Length-eq 0-or $afterIdDecimal.Length-gt 20-or $afterIdDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage} - $stage=85 - $daclProtected=[bool](($control-band 0x1000)-ne 0) - $reparsePoint=[bool](([Runtime.InteropServices.Marshal]::ReadInt32($before,0)-band 0x400)-ne 0) - if($daclProtected-isnot [bool]-or $reparsePoint-isnot [bool]){exit $stage} - $stage=86 - [object[]]$rulesArray=$rules.ToArray() - if($rulesArray-isnot [object[]]-or $rulesArray.Count-ne $rules.Count-or $rulesArray.Count-gt 128){exit $stage} - for($ruleIndex=0;$ruleIndex-lt $rulesArray.Count;$ruleIndex++){ - if(-not [object]::ReferenceEquals($rulesArray[$ruleIndex],$rules[$ruleIndex])){exit $stage} + $stage=79 + $after=[Runtime.InteropServices.Marshal]::AllocHGlobal(52) + if(-not [ProprReadOnlyAuthority]::GetFileInformationByHandle($privateHandle,$after)){Exit-ProprStage} + $stage=81 + $beforeVolume=Read-ProprUInt32 $before 28 + $afterVolume=Read-ProprUInt32 $after 28 + $beforeHigh=Read-ProprUInt32 $before 44;$beforeLow=Read-ProprUInt32 $before 48 + $afterHigh=Read-ProprUInt32 $after 44;$afterLow=Read-ProprUInt32 $after 48 + $stage=82 + $beforeId=Join-ProprUInt64 $beforeLow $beforeHigh + if($beforeId-isnot [uint64]){Exit-ProprStage} + $afterId=Join-ProprUInt64 $afterLow $afterHigh + if($afterId-isnot [uint64]){Exit-ProprStage} + $stage=84 + $beforeVolumeDecimal=$beforeVolume.ToString([Globalization.CultureInfo]::InvariantCulture) + $afterVolumeDecimal=$afterVolume.ToString([Globalization.CultureInfo]::InvariantCulture) + $beforeIdDecimal=$beforeId.ToString([Globalization.CultureInfo]::InvariantCulture) + $afterIdDecimal=$afterId.ToString([Globalization.CultureInfo]::InvariantCulture) + if($beforeVolumeDecimal-isnot [string]-or $beforeVolumeDecimal.Length-eq 0-or $beforeVolumeDecimal.Length-gt 10-or $beforeVolumeDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){Exit-ProprStage} + if($afterVolumeDecimal-isnot [string]-or $afterVolumeDecimal.Length-eq 0-or $afterVolumeDecimal.Length-gt 10-or $afterVolumeDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){Exit-ProprStage} + if($beforeIdDecimal-isnot [string]-or $beforeIdDecimal.Length-eq 0-or $beforeIdDecimal.Length-gt 20-or $beforeIdDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){Exit-ProprStage} + if($afterIdDecimal-isnot [string]-or $afterIdDecimal.Length-eq 0-or $afterIdDecimal.Length-gt 20-or $afterIdDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){Exit-ProprStage} + $stage=85 + $daclProtected=[bool](($control-band 0x1000)-ne 0) + $reparsePoint=[bool](([Runtime.InteropServices.Marshal]::ReadInt32($before,0)-band 0x400)-ne 0) + if($daclProtected-isnot [bool]-or $reparsePoint-isnot [bool]){Exit-ProprStage} + $stage=86 + [object[]]$rulesArray=$rules.ToArray() + if($rulesArray-isnot [object[]]-or $rulesArray.Count-ne $rules.Count-or $rulesArray.Count-gt 128){Exit-ProprStage} + for($ruleIndex=0;$ruleIndex-lt $rulesArray.Count;$ruleIndex++){ + if(-not [object]::ReferenceEquals($rulesArray[$ruleIndex],$rules[$ruleIndex])){Exit-ProprStage} + } + $stage=83 + return [pscustomobject][ordered]@{ + currentUserSid=$currentSid;ownerSid=$ownerSid + daclProtected=$daclProtected;reparsePoint=$reparsePoint + volumeSerialNumber=$beforeVolumeDecimal + fileId=$beforeIdDecimal + verifiedVolumeSerialNumber=$afterVolumeDecimal + verifiedFileId=$afterIdDecimal;rules=$rulesArray + } + } catch { Exit-ProprStage } finally { + if($descriptor-ne [IntPtr]::Zero){$null=[ProprReadOnlyAuthority]::LocalFree($descriptor)};if($aclInfo-ne [IntPtr]::Zero){[Runtime.InteropServices.Marshal]::FreeHGlobal($aclInfo)} + if($after-ne [IntPtr]::Zero){[Runtime.InteropServices.Marshal]::FreeHGlobal($after)};if($before-ne [IntPtr]::Zero){[Runtime.InteropServices.Marshal]::FreeHGlobal($before)} + if($privateHandle-ne [IntPtr]::Zero){$null=[ProprReadOnlyAuthority]::CloseHandle($privateHandle)} } - $stage=83 - $entry=[pscustomobject][ordered]@{ - index=__PROPR_INDEX__;kind='__PROPR_ENTRY_KIND__';authorityKind='__PROPR_AUTHORITY_KIND__';currentUserSid=$currentSid;ownerSid=$ownerSid - daclProtected=$daclProtected;reparsePoint=$reparsePoint - volumeSerialNumber=$beforeVolumeDecimal - fileId=$beforeIdDecimal - verifiedVolumeSerialNumber=$afterVolumeDecimal - verifiedFileId=$afterIdDecimal;rules=$rulesArray } - $stage=77 - $json=ConvertTo-Json ([pscustomobject][ordered]@{version=1;entries=@($entry)}) -Compress -Depth 5 - if([Text.Encoding]::UTF8.GetByteCount($json)-gt 131072){exit $stage} [Console]::OutputEncoding=New-Object Text.UTF8Encoding($false,$true) - [Console]::Out.Write($json) + for($q=0;$q-lt $r;$q++){ + if($q-ne 0){ + $stage=87 + if([Console]::In.ReadLine()-cne 'PROPR_REVALIDATE_V1'){Exit-ProprStage} + } + $stage=78 + $current=[Security.Principal.WindowsIdentity]::GetCurrent().User + if($null-eq $current){Exit-ProprStage} + $entries=New-Object Collections.Generic.List[object] + for($i=0;$i-lt $n;$i++){ + $entries.Add((Inspect-ProprEntry $i $current.Value)) + $script:proprEntryIndex=-1 + } + $stage=77 + [object[]]$entryArray=$entries.ToArray() + if($entryArray.Count-ne $n){Exit-ProprStage} + $json=ConvertTo-Json ([pscustomobject][ordered]@{version=1;entries=$entryArray}) -Compress -Depth 5 + if([Text.Encoding]::UTF8.GetByteCount($json)-gt 131072){Exit-ProprStage} + [Console]::Out.WriteLine($json) + [Console]::Out.Flush() + } exit 0 -}catch{exit $stage} -finally {if($privateHandleOwned){$null=[ProprReadOnlyAuthority]::CloseHandle($privateHandle)}} -`; +}catch{Exit-ProprStage} +`.replaceAll("Exit-ProprStage", "Fail").replaceAll("proprEntryIndex", "e"); export const WINDOWS_NATIVE_PROBE_MILESTONES = Object.freeze([ "entry-ps51-desktop-x64", @@ -250,6 +311,8 @@ export const WINDOWS_NATIVE_TIMING_PROBE_SOURCE = String.raw` $ErrorActionPreference='Stop' $ProgressPreference='SilentlyContinue' Set-StrictMode -Version 2 +$script:proprEntryIndex=-1 +${WINDOWS_BROKER_STAGE_EXIT_SOURCE} ${WINDOWS_UNSIGNED_FIELD_DECODER_SOURCE} ${WINDOWS_UINT64_COMPOSER_SOURCE} $clock=[Diagnostics.Stopwatch]::StartNew() @@ -262,11 +325,11 @@ function Write-ProprMilestone([string]$name){ $stage=91 try { if($PSVersionTable.PSVersion.Major-ne 5-or $PSVersionTable.PSVersion.Minor-ne 1-or - $PSVersionTable.PSEdition-ne 'Desktop'-or -not [Environment]::Is64BitProcess){exit $stage} + $PSVersionTable.PSEdition-ne 'Desktop'-or -not [Environment]::Is64BitProcess){Exit-ProprStage} Write-ProprMilestone 'entry-ps51-desktop-x64' $stage=92 $baseline='{"version":1,"baseline":"constant"}' - if($baseline-ne '{"version":1,"baseline":"constant"}'){exit $stage} + if($baseline-ne '{"version":1,"baseline":"constant"}'){Exit-ProprStage} Write-ProprMilestone 'constant-json' $stage=93 $assembly=[AppDomain]::CurrentDomain.DefineDynamicAssembly( @@ -287,25 +350,25 @@ try { $null=$builder.CreateType() Write-ProprMilestone 'reflection-emit' $stage=94 - if([ProprNativeTimingProbe]::GetCurrentProcessId()-eq 0){exit $stage} + if([ProprNativeTimingProbe]::GetCurrentProcessId()-eq 0){Exit-ProprStage} Write-ProprMilestone 'harmless-win32' $stage=95 $handle=[ProprNativeTimingProbe]::GetStdHandle(-10) - if($handle-eq [IntPtr](-1)-or $handle-eq [IntPtr](-2)-or $handle-eq [IntPtr]::Zero){exit $stage} + if($handle-eq [IntPtr](-1)-or $handle-eq [IntPtr](-2)-or $handle-eq [IntPtr]::Zero){Exit-ProprStage} $info=[Runtime.InteropServices.Marshal]::AllocHGlobal(52) - if(-not [ProprNativeTimingProbe]::GetFileInformationByHandle($handle,$info)){exit $stage} + if(-not [ProprNativeTimingProbe]::GetFileInformationByHandle($handle,$info)){Exit-ProprStage} $probeVolume=Read-ProprUInt32 $info 28 $probeHigh=Read-ProprUInt32 $info 44;$probeLow=Read-ProprUInt32 $info 48 $probeId=Join-ProprUInt64 $probeLow $probeHigh - if($probeId-isnot [uint64]){exit $stage} + if($probeId-isnot [uint64]){Exit-ProprStage} $probeVolumeDecimal=$probeVolume.ToString([Globalization.CultureInfo]::InvariantCulture) $probeIdDecimal=$probeId.ToString([Globalization.CultureInfo]::InvariantCulture) - if($probeVolumeDecimal-isnot [string]-or $probeVolumeDecimal.Length-eq 0-or $probeVolumeDecimal.Length-gt 10-or $probeVolumeDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage} - if($probeIdDecimal-isnot [string]-or $probeIdDecimal.Length-eq 0-or $probeIdDecimal.Length-gt 20-or $probeIdDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){exit $stage} + if($probeVolumeDecimal-isnot [string]-or $probeVolumeDecimal.Length-eq 0-or $probeVolumeDecimal.Length-gt 10-or $probeVolumeDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){Exit-ProprStage} + if($probeIdDecimal-isnot [string]-or $probeIdDecimal.Length-eq 0-or $probeIdDecimal.Length-gt 20-or $probeIdDecimal-cnotmatch '^(0|[1-9][0-9]*)$'){Exit-ProprStage} Write-ProprMilestone 'standard-handle-identity' exit 0 -}catch{exit $stage} -`; +}catch{Exit-ProprStage} +`.replaceAll("Exit-ProprStage", "Fail").replaceAll("proprEntryIndex", "e"); interface HeldExecutable { readonly path: string; @@ -378,19 +441,29 @@ function resolveWindowsPowerShell(): HeldExecutable { function revalidateWindowsPowerShell(executable: HeldExecutable): void { let namedFd: number | undefined; + let globalFd: number | undefined; try { try { namedFd = openSync(executable.path, constants.O_RDONLY | constants.O_NOFOLLOW); } catch { throw stageError("resolver:global-id"); } + try { + globalFd = openSync( + `${GLOBAL_SYSTEM_ROOT}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`, + constants.O_RDONLY | constants.O_NOFOLLOW, + ); + } catch { throw stageError("resolver:global-id"); } const held = fstatSync(executable.fd, { bigint: true }); const named = fstatSync(namedFd, { bigint: true }); + const global = fstatSync(globalFd, { bigint: true }); if ( - !held.isFile() || !named.isFile() + !held.isFile() || !named.isFile() || !global.isFile() || held.dev.toString(10) !== executable.device || held.ino.toString(10) !== executable.file || named.dev.toString(10) !== executable.device || named.ino.toString(10) !== executable.file + || global.dev.toString(10) !== executable.device || global.ino.toString(10) !== executable.file ) throw stageError("resolver:global-id"); } finally { if (namedFd !== undefined) closeSync(namedFd); + if (globalFd !== undefined) closeSync(globalFd); } } @@ -420,23 +493,51 @@ export function parseWindowsInspectionDocument(value: Buffer | string): readonly return document.entries as WindowsAuthorityInspection[]; } -export function windowsBrokerFailureStage(status: number | null): WindowsNativeStageCode { - const stages: Readonly> = { - 71: "broker:ps-version", 72: "broker:job", 73: "broker:fd", 74: "broker:index-info-initial", - 75: "broker:security-info", 76: "broker:acl", 77: "broker:json", - 78: "broker:current-user-sid", 79: "broker:index-info-revalidation", 80: "broker:fd-duplicate", - 81: "broker:index-info-decode", 82: "broker:index-info-compose", 83: "broker:entry-build", - 84: "broker:entry-format", 85: "broker:entry-flags", 86: "broker:entry-rules", - }; - return status === null ? "spawn:status" : (stages[status] ?? "spawn:status"); +const WINDOWS_BROKER_FAILURE_STAGES: Readonly> = { + 71: "broker:ps-version", 72: "broker:job", 73: "broker:fd", 74: "broker:index-info-initial", + 75: "broker:security-info", 76: "broker:acl", 77: "broker:json", + 78: "broker:current-user-sid", 79: "broker:index-info-revalidation", 80: "broker:fd-duplicate", + 81: "broker:index-info-decode", 82: "broker:index-info-compose", 83: "broker:entry-build", + 84: "broker:entry-format", 85: "broker:entry-flags", 86: "broker:entry-rules", 87: "broker:control", +}; + +const WINDOWS_BROKER_ENTRY_OPERATIONS: Readonly> = { + 73: "fd", 74: "identity-initial", 75: "security-info", 76: "acl", + 79: "identity-revalidation", 80: "fd-duplicate", 81: "identity-decode", + 82: "identity-compose", 83: "entry-build", 84: "entry-format", + 85: "entry-flags", 86: "entry-rules", +}; + +export interface WindowsBrokerFailureAttribution { + readonly stage: WindowsNativeStageCode; + readonly entryIndex: WindowsBrokerEntryIndexToken | null; + readonly operation: WindowsBrokerEntryOperationToken | null; } -function inspectionSource(target: WindowsAuthorityTarget, index: number): string { - const entryKind = target.kind === "env" ? "file" : "directory"; - return WINDOWS_INSPECTION_SOURCE - .replace("__PROPR_INDEX__", String(index)) - .replace("__PROPR_ENTRY_KIND__", entryKind) - .replace("__PROPR_AUTHORITY_KIND__", target.kind); +export function windowsBrokerFailureAttribution(status: number | null): WindowsBrokerFailureAttribution { + if (status !== null && Number.isInteger(status) && status >= WINDOWS_BROKER_ENTRY_FAILURE_BASE) { + const encoded = status - WINDOWS_BROKER_ENTRY_FAILURE_BASE; + const index = Math.floor(encoded / WINDOWS_BROKER_ENTRY_FAILURE_STRIDE); + const operationCode = encoded % WINDOWS_BROKER_ENTRY_FAILURE_STRIDE; + const stage = WINDOWS_BROKER_FAILURE_STAGES[operationCode]; + const operation = WINDOWS_BROKER_ENTRY_OPERATIONS[operationCode]; + if (index >= 0 && index < WINDOWS_INSPECTION_MAX_ENTRIES && stage && operation) { + return Object.freeze({ + stage, + entryIndex: String(index).padStart(2, "0") as WindowsBrokerEntryIndexToken, + operation, + }); + } + } + return Object.freeze({ + stage: status === null ? "spawn:status" : (WINDOWS_BROKER_FAILURE_STAGES[status] ?? "spawn:status"), + entryIndex: null, + operation: null, + }); +} + +export function windowsBrokerFailureStage(status: number | null): WindowsNativeStageCode { + return windowsBrokerFailureAttribution(status).stage; } /** The fixed inspector receives no caller-controlled executable/module/profile/temp authority. */ @@ -445,19 +546,23 @@ export function windowsPowerShellEnvironment(systemRoot: string): Readonly 28_000) throw stageError("spawn:create"); + return [ + "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded, + ]; +} + +function spawnPowerShellSync( executable: HeldExecutable, source: string, stdin: "ignore" | number, timeout = WINDOWS_INSPECTION_TIMEOUT_MS, maxBuffer = WINDOWS_INSPECTION_MAX_BYTES, ) { - const encoded = Buffer.from(source, "utf16le").toString("base64"); - if (encoded.length > 28_000) throw stageError("spawn:create"); try { - return spawnSync(executable.path, [ - "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded, - ], { + return spawnSync(executable.path, powerShellArguments(source), { shell: false, windowsHide: true, encoding: "buffer", @@ -471,6 +576,31 @@ function spawnPowerShell( } catch { throw stageError("spawn:create"); } } +function inspectionSource(entryCount: number, roundCount: 1 | 2): string { + if (!Number.isInteger(entryCount) || entryCount < 1 || entryCount > WINDOWS_INSPECTION_MAX_ENTRIES) { + throw stageError("spawn:create"); + } + return WINDOWS_INSPECTION_SOURCE + .replace("__PROPR_ENTRY_COUNT__", String(entryCount)) + .replace("__PROPR_ROUND_COUNT__", String(roundCount)); +} + +function spawnInspectionBroker( + executable: HeldExecutable, + targets: readonly WindowsAuthorityTarget[], + roundCount: 1 | 2, +): ChildProcess { + try { + return spawn(executable.path, powerShellArguments(inspectionSource(targets.length, roundCount)), { + shell: false, + windowsHide: true, + cwd: win32.dirname(executable.path), + env: windowsPowerShellEnvironment(executable.systemRoot), + stdio: [roundCount === 2 ? "pipe" : "ignore", "pipe", "pipe", ...targets.map((target) => target.pinnedFd)], + }); + } catch { throw stageError("spawn:create"); } +} + export interface WindowsNativeProbeRecord { readonly milestone: WindowsNativeProbeMilestone; readonly timingBucket: WindowsNativeTimingBucket; @@ -528,70 +658,580 @@ export function parseWindowsNativeProbeOutput( return records; } -function assertSpawnSuccess(result: ReturnType): void { - if (result.error) { - if ((result.error as NodeJS.ErrnoException).code === "ETIMEDOUT") throw stageError("spawn:timeout"); - throw stageError("spawn:error"); +export type WindowsBrokerInspection = Omit; + +const WINDOWS_BROKER_ENTRY_KEYS = Object.freeze([ + "currentUserSid", "ownerSid", "daclProtected", "reparsePoint", "volumeSerialNumber", "fileId", + "verifiedVolumeSerialNumber", "verifiedFileId", "rules", +]); + +export function parseWindowsBrokerDocument(value: Buffer | string): WindowsBrokerInspection { + const entries = parseWindowsBrokerBatchDocument(value, 1); + return entries[0]; +} + +export function parseWindowsBrokerBatchDocument( + value: Buffer | string, + expectedEntryCount: number, +): readonly WindowsBrokerInspection[] { + if (!Number.isInteger(expectedEntryCount) || expectedEntryCount < 1 + || expectedEntryCount > WINDOWS_INSPECTION_MAX_ENTRIES) throw stageError("parent:entry-count"); + const text = strictUtf8(value); + let parsed: unknown; + try { parsed = JSON.parse(text); } catch { throw stageError("parent:json-parse"); } + if (JSON.stringify(parsed) !== text) throw stageError("parent:json-canonical"); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw stageError("parent:document-shape"); + } + const document = parsed as Record; + if (Object.keys(document).sort().join(",") !== "entries,version" || document.version !== 1 + || !Array.isArray(document.entries)) throw stageError("parent:document-shape"); + if (document.entries.length !== expectedEntryCount) throw stageError("parent:entry-count"); + for (const entry of document.entries) { + if (!entry || typeof entry !== "object" || Array.isArray(entry) + || Object.keys(entry).sort().join(",") !== [...WINDOWS_BROKER_ENTRY_KEYS].sort().join(",")) { + throw stageError("parent:entry-shape"); + } } - if (result.signal) throw stageError(result.signal === "SIGKILL" ? "spawn:timeout" : "spawn:status"); - if (result.status !== 0) throw stageError(windowsBrokerFailureStage(result.status)); - const stderrBytes = typeof result.stderr === "string" - ? Buffer.byteLength(result.stderr, "utf8") - : (result.stderr?.byteLength ?? 0); - if (stderrBytes !== 0) throw stageError("spawn:stderr"); + return document.entries as readonly WindowsBrokerInspection[]; } -export function windowsInspectionTimeoutForElapsed(elapsedMs: number): number { - if (!Number.isFinite(elapsedMs) || elapsedMs < 0) throw stageError("spawn:cumulative-timeout"); - const remaining = WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS - Math.floor(elapsedMs); - if (remaining <= 0) throw stageError("spawn:cumulative-timeout"); - return Math.min(WINDOWS_INSPECTION_TIMEOUT_MS, remaining); +interface InspectionBrokerState { + readonly child: ChildProcess; + readonly stdout: Buffer[]; + stdoutState: WindowsBrokerStreamState; + stderrState: WindowsBrokerStreamState; + stdoutEnded: boolean; + stderrEnded: boolean; + closed: boolean; + statusStage: WindowsBrokerStatusStage; + entryIndex: WindowsBrokerEntryIndexToken | null; + operation: WindowsBrokerEntryOperationToken | null; + diagnosticReported: boolean; } -export function runWindowsReadOnlyInspection( +export type WindowsBrokerIndexBucket = "0" | "1" | "2-3" | "4-7" | "8-15" | "16-31"; +export type WindowsBrokerStreamState = "empty" | "nonempty" | "overflow"; +export type WindowsBrokerStatusStage = WindowsNativeStageCode | "ok" | "sibling-termination"; +export type WindowsBrokerDeadlineState = "active" | "expired"; +export type WindowsBrokerCleanupState = "not-started" | "contained" | "deadline-expired"; + +/** Fixed, secret-free post-drain attribution for native regression evidence. */ +export interface WindowsBrokerResultDiagnostic { + readonly brokerIndex: WindowsBrokerIndexBucket; + readonly statusStage: WindowsBrokerStatusStage; + readonly entryIndex: WindowsBrokerEntryIndexToken | null; + readonly operation: WindowsBrokerEntryOperationToken | null; + readonly stderr: WindowsBrokerStreamState; + readonly stdout: WindowsBrokerStreamState; + readonly deadline: WindowsBrokerDeadlineState; + readonly cleanup: WindowsBrokerCleanupState; +} + +function windowsBrokerIndexBucket(index: number): WindowsBrokerIndexBucket { + if (index === 0) return "0"; + if (index === 1) return "1"; + if (index <= 3) return "2-3"; + if (index <= 7) return "4-7"; + if (index <= 15) return "8-15"; + return "16-31"; +} + +/** + * Start all fixed one-target brokers before yielding and supervise them under + * one deadline. A resolved batch proves that every broker closed after both + * output streams drained; a rejected batch first terminates every live broker. + */ +export interface WindowsInspectionBrokerBatchOptions { + readonly entryCount: number; + readonly startBroker: (index: number) => ChildProcess; + readonly deadlineMs: number; + readonly cleanupTimeoutMs: number; + readonly maxOutputBytes: number; + readonly onBrokerResult?: (diagnostic: WindowsBrokerResultDiagnostic) => void; +} + +export function runWindowsInspectionBrokerBatch({ + entryCount, + startBroker, + deadlineMs, + cleanupTimeoutMs, + maxOutputBytes, + onBrokerResult, +}: WindowsInspectionBrokerBatchOptions): Promise { + if (!Number.isInteger(entryCount) || entryCount < 1 || entryCount > WINDOWS_INSPECTION_MAX_ENTRIES + || !Number.isInteger(deadlineMs) || deadlineMs < 1 || deadlineMs > WINDOWS_INSPECTION_TIMEOUT_MS + || !Number.isInteger(cleanupTimeoutMs) || cleanupTimeoutMs < 1 + || cleanupTimeoutMs > WINDOWS_INSPECTION_CLEANUP_TIMEOUT_MS + || !Number.isInteger(maxOutputBytes) || maxOutputBytes < 1 + || maxOutputBytes > WINDOWS_INSPECTION_MAX_BYTES) throw stageError("spawn:create"); + return new Promise((resolve, reject) => { + const brokers: InspectionBrokerState[] = []; + let aggregateBytes = 0; + let closedCount = 0; + let spawningComplete = false; + let failure: WindowsNativeStageError | undefined; + let settled = false; + let deadlineExpired = false; + let cleanupDeadlineExpired = false; + let cleanupTimer: ReturnType | undefined; + let containmentTimer: ReturnType | undefined; + + const report = (broker: InspectionBrokerState, index: number): void => { + if (broker.diagnosticReported || !broker.closed || !broker.stdoutEnded || !broker.stderrEnded) return; + broker.diagnosticReported = true; + try { + onBrokerResult?.(Object.freeze({ + brokerIndex: windowsBrokerIndexBucket(index), + statusStage: broker.statusStage, + entryIndex: broker.entryIndex, + operation: broker.operation, + stderr: broker.stderrState, + stdout: broker.stdoutState, + deadline: deadlineExpired ? "expired" : "active", + cleanup: cleanupDeadlineExpired + ? "deadline-expired" + : failure ? "contained" : "not-started", + })); + } catch { /* Diagnostics never alter containment or the production result. */ } + }; + + const settle = (): void => { + brokers.forEach(report); + if (settled || !spawningComplete || closedCount !== brokers.length + || brokers.some((broker) => !broker.stdoutEnded || !broker.stderrEnded)) return; + settled = true; + clearTimeout(deadlineTimer); + if (cleanupTimer !== undefined) clearTimeout(cleanupTimer); + if (containmentTimer !== undefined) clearInterval(containmentTimer); + if (failure) reject(failure); + else resolve(brokers.map((broker) => Buffer.concat(broker.stdout))); + }; + const terminateLiveBrokers = (): void => { + for (const broker of brokers) { + if (broker.closed) continue; + try { broker.child.kill("SIGKILL"); } catch { /* Retain ownership and retry until close/drain. */ } + } + }; + const fail = (stageOrError: WindowsNativeStageCode | WindowsNativeStageError): void => { + if (failure || settled) return; + failure = stageOrError instanceof WindowsNativeStageError ? stageOrError : stageError(stageOrError); + clearTimeout(deadlineTimer); + for (const broker of brokers) { + if (!broker.closed && broker.statusStage === "ok") broker.statusStage = "sibling-termination"; + } + terminateLiveBrokers(); + cleanupTimer = setTimeout(() => { + if (settled) return; + cleanupDeadlineExpired = true; + failure = stageError( + "spawn:cleanup", + failure!.primaryStage, + failure!.entryIndex, + failure!.operation, + ); + terminateLiveBrokers(); + // A cleanup deadline classifies the eventual fixed failure; it never + // proves containment. Keep every listener and a referenced retry alive + // until child close plus both stream terminals prove ownership ended. + containmentTimer = setInterval(terminateLiveBrokers, Math.min(250, cleanupTimeoutMs)); + settle(); + }, cleanupTimeoutMs); + settle(); + }; + const accept = (broker: InspectionBrokerState, stream: "stdout" | "stderr", chunk: unknown): void => { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array); + aggregateBytes += bytes.byteLength; + if (aggregateBytes > maxOutputBytes) { + broker[stream === "stdout" ? "stdoutState" : "stderrState"] = "overflow"; + broker.statusStage = "parent:utf8"; + fail("parent:utf8"); + return; + } + if (stream === "stderr") { + if (bytes.byteLength > 0) { + broker.stderrState = "nonempty"; + broker.statusStage = "spawn:stderr"; + fail("spawn:stderr"); + } + return; + } + if (bytes.byteLength > 0) broker.stdoutState = "nonempty"; + broker.stdout.push(bytes); + }; + + const deadlineTimer = setTimeout(() => { + deadlineExpired = true; + for (const broker of brokers) { + if (!broker.closed && broker.statusStage === "ok") broker.statusStage = "spawn:timeout"; + } + fail("spawn:timeout"); + }, deadlineMs); + try { + for (let index = 0; index < entryCount; index += 1) { + const child = startBroker(index); + const broker: InspectionBrokerState = { + child, + stdout: [], + stdoutState: "empty", + stderrState: "empty", + stdoutEnded: !child.stdout, + stderrEnded: !child.stderr, + closed: false, + statusStage: "ok", + entryIndex: null, + operation: null, + diagnosticReported: false, + }; + brokers.push(broker); + if (!child.stdout || !child.stderr) { + broker.statusStage = "spawn:create"; + fail("spawn:create"); + break; + } + child.stdout.on("data", (chunk) => accept(broker, "stdout", chunk)); + child.stderr.on("data", (chunk) => accept(broker, "stderr", chunk)); + child.stdout.once("end", () => { broker.stdoutEnded = true; settle(); }); + child.stderr.once("end", () => { broker.stderrEnded = true; settle(); }); + child.once("error", (error) => { + const stage = (error as NodeJS.ErrnoException).code === "ETIMEDOUT" ? "spawn:timeout" : "spawn:error"; + broker.statusStage = stage; + fail(stage); + }); + child.once("close", (status, signal) => { + if (broker.closed) return; + broker.closed = true; + closedCount += 1; + if (broker.statusStage === "ok" || broker.statusStage === "sibling-termination") { + if (signal !== null && !failure) { + broker.statusStage = "spawn:status"; + } else if (status !== 0) { + const attribution = windowsBrokerFailureAttribution(status); + broker.statusStage = attribution.stage; + broker.entryIndex = attribution.entryIndex; + broker.operation = attribution.operation; + } + } + if (!failure && broker.statusStage !== "ok") { + fail(broker.statusStage === "sibling-termination" + ? "spawn:status" + : stageError(broker.statusStage, broker.statusStage, broker.entryIndex, broker.operation)); + } + settle(); + }); + } + } catch (error) { + fail(error instanceof WindowsNativeStageError ? error.stage : "spawn:create"); + } finally { + spawningComplete = true; + if (failure) terminateLiveBrokers(); + settle(); + } + }); +} + +function revalidateWindowsTargets(targets: readonly WindowsAuthorityTarget[]): void { + try { + for (const target of targets) { + const held = fstatSync(target.pinnedFd, { bigint: true }); + if (held.dev.toString(10) !== target.expectedIdentity.device + || held.ino.toString(10) !== target.expectedIdentity.file) throw new Error(); + } + } catch { throw stageError("parent:post-bind"); } +} + +function splitWindowsBrokerFrames(output: Buffer, expectedCount: 1 | 2): readonly Buffer[] { + const frames: Buffer[] = []; + let offset = 0; + while (offset < output.byteLength) { + const newline = output.indexOf(0x0a, offset); + if (newline < 0) throw stageError("parent:json-canonical"); + const end = newline > offset && output[newline - 1] === 0x0d ? newline - 1 : newline; + if (end === offset) throw stageError("parent:json-canonical"); + frames.push(output.subarray(offset, end)); + offset = newline + 1; + } + if (frames.length !== expectedCount) throw stageError("parent:entry-count"); + return frames; +} + +function bindWindowsInspectionTargets( + frame: Buffer, targets: readonly WindowsAuthorityTarget[], ): readonly WindowsAuthorityInspection[] { + const rawEntries = parseWindowsBrokerBatchDocument(frame, targets.length); + const inspections: WindowsAuthorityInspection[] = []; + for (let index = 0; index < targets.length; index += 1) { + const target = targets[index]; + const entry: WindowsAuthorityInspection = { + index, + kind: target.kind === "env" ? "file" : "directory", + authorityKind: target.kind, + ...rawEntries[index], + }; + try { + if ( + entry.index !== index + || entry.kind !== (target.kind === "env" ? "file" : "directory") + || entry.authorityKind !== target.kind + || BigInt(entry.volumeSerialNumber) !== BigInt(target.expectedIdentity.device) + || BigInt(entry.fileId) !== BigInt(target.expectedIdentity.file) + || BigInt(entry.volumeSerialNumber) !== BigInt(entry.verifiedVolumeSerialNumber) + || BigInt(entry.fileId) !== BigInt(entry.verifiedFileId) + ) throw new Error(); + } catch { throw stageError("parent:descriptor-bind"); } + inspections.push(entry); + } + return inspections; +} + +interface WindowsInspectionProcess { + readonly child: ChildProcess; + readonly terminal: Promise; + readonly firstFrame: Promise; +} + +export function writeWindowsInspectionRevalidationControl(child: ChildProcess): Promise { + return new Promise((resolve, reject) => { + const input = child.stdin; + if (!input || input.destroyed || !input.writable) { + reject(stageError("broker:control")); + return; + } + let completed = false; + const complete = (error?: Error | null): void => { + if (completed) return; + completed = true; + if (error) reject(stageError("broker:control")); + else resolve(); + }; + const onError = (): void => complete(stageError("broker:control")); + // Keep the listener through the stream's terminal lifetime. Node may invoke + // the end callback before emitting its paired EPIPE/error event. + input.on("error", onError); + try { + input.end("PROPR_REVALIDATE_V1\n", "ascii", complete); + } catch { + complete(stageError("broker:control")); + } + }); +} + +function startWindowsInspectionProcess( + executable: HeldExecutable, + targets: readonly WindowsAuthorityTarget[], + roundCount: 1 | 2, +): WindowsInspectionProcess { + const child = spawnInspectionBroker(executable, targets, roundCount); + const terminal = runWindowsInspectionBrokerBatch({ + entryCount: 1, + startBroker: () => child, + deadlineMs: WINDOWS_INSPECTION_TIMEOUT_MS, + cleanupTimeoutMs: WINDOWS_INSPECTION_CLEANUP_TIMEOUT_MS, + maxOutputBytes: WINDOWS_INSPECTION_MAX_BYTES, + }); + const firstFrame = new Promise((resolve, reject) => { + if (!child.stdout) { + reject(stageError("spawn:create")); + return; + } + const chunks: Buffer[] = []; + let byteLength = 0; + let finished = false; + child.stdout.on("data", (chunk: Buffer | Uint8Array) => { + if (finished) return; + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + chunks.push(bytes); + byteLength += bytes.byteLength; + if (byteLength > WINDOWS_INSPECTION_MAX_BYTES) { + finished = true; + reject(stageError("parent:utf8")); + return; + } + const combined = Buffer.concat(chunks, byteLength); + const newline = combined.indexOf(0x0a); + if (newline < 0) return; + const end = newline > 0 && combined[newline - 1] === 0x0d ? newline - 1 : newline; + if (end === 0 || combined.subarray(newline + 1).byteLength !== 0) { + finished = true; + reject(stageError("parent:json-canonical")); + return; + } + finished = true; + resolve(combined.subarray(0, end)); + }); + }); + // A close/failure before the first complete frame must reject the initial + // proof instead of leaving the frame waiter pending. + return { + child, + terminal, + firstFrame: Promise.race([ + firstFrame, + terminal.then((outputs) => splitWindowsBrokerFrames(outputs[0], roundCount)[0]), + ]), + }; +} + +async function terminateWindowsInspectionProcess(processState: WindowsInspectionProcess): Promise { + if (processState.child.exitCode === null && processState.child.signalCode === null) { + try { processState.child.kill("SIGKILL"); } catch { /* Supervisor retains ownership and retries. */ } + } + try { + await processState.terminal; + } catch (error) { + if (error instanceof WindowsNativeStageError && error.stage === "spawn:cleanup") throw error; + } +} + +function sameWindowsInspectionTargets( + initial: readonly WindowsAuthorityTarget[], + final: readonly WindowsAuthorityTarget[], +): boolean { + return initial.length === final.length && initial.every((target, index) => ( + target.kind === final[index].kind + && target.expectedIdentity.device === final[index].expectedIdentity.device + && target.expectedIdentity.file === final[index].expectedIdentity.file + )); +} + +export interface WindowsReadOnlyInspectionGeneration { + readonly initial: readonly WindowsAuthorityInspection[]; + revalidate(targets: readonly WindowsAuthorityTarget[]): Promise; + abort(): Promise; +} + +/** + * Hold one multi-handle broker across a snapshot generation. The second round + * re-reads every descriptor/ACL after the caller's operation; no initial result + * is reused as final authority and the one 60-second broker deadline is fixed. + */ +export async function beginWindowsReadOnlyInspectionGeneration( + targets: readonly WindowsAuthorityTarget[], +): Promise { if (targets.length < 1 || targets.length > WINDOWS_INSPECTION_MAX_ENTRIES) { throw stageError("parent:entry-count"); } const executable = resolveWindowsPowerShell(); - const inspections: WindowsAuthorityInspection[] = []; - let totalOutputBytes = 0; - const inspectionStarted = performance.now(); + let processState: WindowsInspectionProcess | undefined; + let finished = false; + const closeExecutable = (): void => { + if (finished) return; + finished = true; + closeSync(executable.fd); + }; try { - for (let index = 0; index < targets.length; index += 1) { - const target = targets[index]; - const timeout = windowsInspectionTimeoutForElapsed(performance.now() - inspectionStarted); - const result = spawnPowerShell(executable, inspectionSource(target, index), target.pinnedFd, timeout); - assertSpawnSuccess(result); - totalOutputBytes += typeof result.stdout === "string" - ? Buffer.byteLength(result.stdout, "utf8") - : (result.stdout?.byteLength ?? 0); - if (totalOutputBytes > WINDOWS_INSPECTION_MAX_BYTES) throw stageError("parent:utf8"); - const entries = parseWindowsInspectionDocument(result.stdout ?? Buffer.alloc(0)); - if (entries.length !== 1) throw stageError("parent:entry-count"); - const entry = entries[0]; + revalidateWindowsPowerShell(executable); + revalidateWindowsTargets(targets); + processState = startWindowsInspectionProcess(executable, targets, 2); + const initialFrame = await processState.firstFrame; + const initial = bindWindowsInspectionTargets(initialFrame, targets); + revalidateWindowsTargets(targets); + revalidateWindowsPowerShell(executable); + let consumed = false; + return { + initial, + revalidate: async (finalTargets) => { + if (consumed || finished || !processState) throw stageError("parent:post-bind"); + consumed = true; + let result: readonly WindowsAuthorityInspection[] | undefined; + let finalError: unknown; + try { + revalidateWindowsPowerShell(executable); + revalidateWindowsTargets(finalTargets); + if (!sameWindowsInspectionTargets(targets, finalTargets)) throw stageError("parent:post-bind"); + await writeWindowsInspectionRevalidationControl(processState.child); + const outputs = await processState.terminal; + const frames = splitWindowsBrokerFrames(outputs[0], 2); + result = bindWindowsInspectionTargets(frames[1], finalTargets); + revalidateWindowsTargets(finalTargets); + revalidateWindowsPowerShell(executable); + } catch (error) { + finalError = error; + } finally { + if (processState.child.exitCode === null && processState.child.signalCode === null) { + try { await terminateWindowsInspectionProcess(processState); } catch (error) { finalError = error; } + } + try { + revalidateWindowsTargets(finalTargets); + revalidateWindowsPowerShell(executable); + } catch (error) { + if (!(finalError instanceof WindowsNativeStageError && finalError.stage === "spawn:cleanup")) { + const validation = error instanceof WindowsNativeStageError ? error : stageError("parent:post-bind"); + finalError = stageError( + validation.stage, + finalError instanceof WindowsNativeStageError ? finalError.primaryStage : validation.primaryStage, + ); + } + } + closeExecutable(); + } + if (finalError !== undefined) throw finalError; + return result!; + }, + abort: async () => { + if (consumed || finished || !processState) return; + consumed = true; + let finalError: unknown; + try { await terminateWindowsInspectionProcess(processState); } catch (error) { finalError = error; } + try { + revalidateWindowsTargets(targets); + revalidateWindowsPowerShell(executable); + } catch (error) { + if (!(finalError instanceof WindowsNativeStageError && finalError.stage === "spawn:cleanup")) { + finalError = error; + } + } finally { closeExecutable(); } + if (finalError !== undefined) throw finalError; + }, + }; + } catch (error) { + let finalError: unknown = error; + try { + if (processState) { + try { await terminateWindowsInspectionProcess(processState); } catch (cleanupError) { finalError = cleanupError; } + } try { - if ( - entry.index !== index - || entry.kind !== (target.kind === "env" ? "file" : "directory") - || entry.authorityKind !== target.kind - || BigInt(entry.volumeSerialNumber) !== BigInt(target.expectedIdentity.device) - || BigInt(entry.fileId) !== BigInt(target.expectedIdentity.file) - || BigInt(entry.volumeSerialNumber) !== BigInt(entry.verifiedVolumeSerialNumber) - || BigInt(entry.fileId) !== BigInt(entry.verifiedFileId) - ) throw new Error(); - } catch { throw stageError("parent:descriptor-bind"); } - const after = fstatSync(target.pinnedFd, { bigint: true }); - if (after.dev.toString(10) !== target.expectedIdentity.device || after.ino.toString(10) !== target.expectedIdentity.file) { - throw stageError("parent:post-bind"); + revalidateWindowsTargets(targets); + revalidateWindowsPowerShell(executable); + } catch (validationError) { + if (!(finalError instanceof WindowsNativeStageError && finalError.stage === "spawn:cleanup")) { + const validation = validationError instanceof WindowsNativeStageError + ? validationError + : stageError("parent:post-bind"); + finalError = stageError( + validation.stage, + finalError instanceof WindowsNativeStageError ? finalError.primaryStage : validation.primaryStage, + ); + } } - inspections.push(entry); - } + } finally { closeExecutable(); } + throw finalError; + } +} + +export async function runWindowsReadOnlyInspection( + targets: readonly WindowsAuthorityTarget[], +): Promise { + if (targets.length < 1 || targets.length > WINDOWS_INSPECTION_MAX_ENTRIES) { + throw stageError("parent:entry-count"); + } + const executable = resolveWindowsPowerShell(); + try { + revalidateWindowsPowerShell(executable); + revalidateWindowsTargets(targets); + const processState = startWindowsInspectionProcess(executable, targets, 1); + const outputs = await processState.terminal; + const inspections = bindWindowsInspectionTargets(splitWindowsBrokerFrames(outputs[0], 1)[0], targets); + revalidateWindowsTargets(targets); revalidateWindowsPowerShell(executable); return inspections; } finally { - closeSync(executable.fd); + try { + revalidateWindowsTargets(targets); + } finally { + try { revalidateWindowsPowerShell(executable); } finally { closeSync(executable.fd); } + } } } @@ -610,7 +1250,7 @@ export function runWindowsNativeTimingProbe(targetFd: number): WindowsNativeTimi const executable = resolveWindowsPowerShell(); try { const started = performance.now(); - const result = spawnPowerShell( + const result = spawnPowerShellSync( executable, WINDOWS_NATIVE_TIMING_PROBE_SOURCE, targetFd, diff --git a/scripts/verify-platform-safe-connect.mjs b/scripts/verify-platform-safe-connect.mjs index 2ff1d452c..6720a2fd8 100644 --- a/scripts/verify-platform-safe-connect.mjs +++ b/scripts/verify-platform-safe-connect.mjs @@ -37,14 +37,14 @@ const tapValue = (name) => { const valid = result.status === 0 && !result.error && !result.signal - && tapValue('tests') === 85 - && tapValue('pass') === 85 + && tapValue('tests') === 92 + && tapValue('pass') === 92 && tapValue('fail') === 0 && tapValue('skipped') === 0; if (!valid) { - process.stderr.write('Platform-safe Connect proof did not complete 85/85 within 90000ms.\n'); + process.stderr.write('Platform-safe Connect proof did not complete 92/92 within 90000ms.\n'); process.exitCode = 1; } else { - process.stdout.write('Platform-safe Connect proof: tests=85 pass=85 fail=0 skipped=0 budgetMs=90000\n'); + process.stdout.write('Platform-safe Connect proof: tests=92 pass=92 fail=0 skipped=0 budgetMs=90000\n'); } diff --git a/scripts/verify-windows-standard-user-connect.mjs b/scripts/verify-windows-standard-user-connect.mjs index 010d7c4f0..7b45f626e 100644 --- a/scripts/verify-windows-standard-user-connect.mjs +++ b/scripts/verify-windows-standard-user-connect.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { closeSync, constants, mkdtempSync, openSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; import { userInfo } from "node:os"; import { dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; @@ -87,7 +87,7 @@ const scenarioAllowlist = Object.freeze([ "authority-missing-system-root", "authority-mismatched-system-root", "authority-untrusted-system-root", ]); const assertionStageAllowlist = Object.freeze([ - "native-timing", "authority-probe", "scaffold", "identity-assertion", "config-init", "config-save", + "authority-probe", "scaffold", "identity-assertion", "config-init", "config-save", "config-assertion", "write-env", "spawn", "signal", "exit", "bounds", "schema", "status", "endpoint", "identity", "reasons", "api-ready", "restart", "stderr", "sentinel", "api-spawn", @@ -105,32 +105,26 @@ const reasonCodeAllowlist = Object.freeze([ ]); const nativeStageAllowlist = Object.freeze([ "resolver:env", "resolver:canonical", "resolver:global-open", "resolver:global-id", - "spawn:create", "spawn:error", "spawn:timeout", "spawn:cumulative-timeout", "spawn:status", "spawn:stderr", - "probe:entry", "probe:baseline", "probe:reflection-emit", "probe:win32", "probe:standard-handle", "probe:output", + "spawn:create", "spawn:error", "spawn:timeout", "spawn:status", "spawn:stderr", "spawn:cleanup", "broker:ps-version", "broker:job", "broker:fd", "broker:fd-duplicate", "broker:index-info-initial", "broker:security-info", "broker:acl", "broker:json", "broker:current-user-sid", "broker:index-info-revalidation", "broker:index-info-decode", "broker:index-info-compose", "broker:entry-format", - "broker:entry-flags", "broker:entry-rules", "broker:entry-build", + "broker:entry-flags", "broker:entry-rules", "broker:entry-build", "broker:control", "parent:utf8", "parent:json-parse", "parent:json-canonical", "parent:document-shape", "parent:entry-count", "parent:entry-shape", "parent:json-shape", "parent:descriptor-bind", "parent:post-bind", ]); -const probeMilestoneAllowlist = Object.freeze([ - "none", "entry-ps51-desktop-x64", "constant-json", "reflection-emit", "harmless-win32", - "standard-handle-identity", -]); -const probeTimingAllowlist = Object.freeze([ - "under-5s", "5-to-15s", "15-to-30s", "30-to-45s", "45-to-60s", "at-least-60s", -]); +const WINDOWS_PRODUCT_AUTHORITY_TIMEOUT_MS = 60_000; const WINDOWS_PRODUCT_AUTHORITY_PHASE_COUNT = 2; const WINDOWS_PRODUCT_SCENARIO_OVERHEAD_MS = 15_000; +const WINDOWS_PRODUCT_SCENARIO_TIMEOUT_MS = ( + WINDOWS_PRODUCT_AUTHORITY_PHASE_COUNT * WINDOWS_PRODUCT_AUTHORITY_TIMEOUT_MS +) + WINDOWS_PRODUCT_SCENARIO_OVERHEAD_MS; const scenarioNames = new Set(scenarioAllowlist); const assertionStages = new Set(assertionStageAllowlist); const statusKinds = new Set(statusKindAllowlist); const diagnosticStatuses = new Set([null, ...statusKindAllowlist]); const reasonCodes = new Set(reasonCodeAllowlist); const nativeStages = new Set(nativeStageAllowlist); -const probeMilestones = new Set(probeMilestoneAllowlist); -const probeTimings = new Set(probeTimingAllowlist); function parseBoundedFailureStatus(stdout) { if (typeof stdout !== "string" || stdout.length === 0 || Buffer.byteLength(stdout, "utf8") >= 2048) return null; @@ -148,25 +142,18 @@ function parseBoundedFailureStatus(stdout) { } } -function createFailureDiagnostic(scenario, stage, failureStatus, nativeStage, probe) { +function createFailureDiagnostic(scenario, stage, failureStatus, nativeStage) { const status = failureStatus?.status ?? null; const codes = failureStatus?.reasonCodes ?? []; if (!scenarioNames.has(scenario) || !assertionStages.has(stage) || !diagnosticStatuses.has(status) || (nativeStage !== null && !nativeStages.has(nativeStage)) - || (probe.milestone !== null && !probeMilestones.has(probe.milestone)) - || (probe.timing !== null && !probeTimings.has(probe.timing)) || !Array.isArray(codes) || codes.length > reasonCodes.size || new Set(codes).size !== codes.length || codes.some((code) => !reasonCodes.has(code))) { return { scenario: "ready", stage: "write-env", nativeStage: null, status: null, reasonCodes: [], - probeMilestone: null, probeTiming: null, }; } - return { - scenario, stage, nativeStage, status, reasonCodes: [...codes], - probeMilestone: probe.milestone, - probeTiming: probe.timing, - }; + return { scenario, stage, nativeStage, status, reasonCodes: [...codes] }; } function extractNativeDiagnostic(stderr) { @@ -220,47 +207,13 @@ let currentScenario = "ready"; let currentStage = "write-env"; let failureStatus = null; let currentNativeStage = null; -const nativeProbe = { milestone: null, timing: null, evidence: null }; try { assert.ok(expectedUser && actualUser.toLowerCase() === expectedUser.toLowerCase(), "proof did not run as the limited user"); - currentStage = "native-timing"; - const nativeAuthority = await import(windowsAuthorityModule); - const WINDOWS_PRODUCT_SCENARIO_TIMEOUT_MS = ( - WINDOWS_PRODUCT_AUTHORITY_PHASE_COUNT - * nativeAuthority.WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS - ) + WINDOWS_PRODUCT_SCENARIO_OVERHEAD_MS; - const probeFd = openSync( - fixture, - constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, - ); - try { - try { - const proof = nativeAuthority.runWindowsNativeTimingProbe(probeFd); - assert.equal(proof.version, 1); - nativeProbe.milestone = proof.lastMilestone; - nativeProbe.timing = proof.timingBucket; - if (proof.outcome === "timeout") currentNativeStage = "spawn:timeout"; - assert.equal(proof.outcome, "complete"); - assert.deepEqual(proof.milestones.map(({ milestone }) => milestone), [ - "entry-ps51-desktop-x64", "constant-json", "reflection-emit", "harmless-win32", - "standard-handle-identity", - ]); - assert.ok(proof.milestones.every(({ milestone, timingBucket }) => ( - probeMilestones.has(milestone) && probeTimings.has(timingBucket) - ))); - nativeProbe.evidence = proof.milestones.map( - ({ milestone, timingBucket }) => `${milestone}:${timingBucket}`, - ).join(","); - } catch (error) { - currentNativeStage = nativeStages.has(error?.stage) - ? error.stage - : (currentNativeStage ?? "parent:json-shape"); - throw error; - } - } finally { - closeSync(probeFd); - } currentStage = "authority-probe"; + // Keep the diagnostic timing probe out of this gate: the first PowerShell + // cold start must belong to a production status/authority scenario. + const nativeAuthority = await import(windowsAuthorityModule); + assert.equal(nativeAuthority.WINDOWS_INSPECTION_TIMEOUT_MS, WINDOWS_PRODUCT_AUTHORITY_TIMEOUT_MS); const authority = await import(authorityModule); await assert.rejects( authority.protectWindowsSetupEntries([{ path: root, kind: "directory" }]), @@ -465,10 +418,10 @@ try { const fail = [...api.stdout.matchAll(/^# fail (\d+)$/gm)].at(-1); assert.ok(pass && Number(pass[1]) > 0, "API discovery tests did not report passes"); assert.equal(Number(fail?.[1]), 0, "API discovery tests reported failures"); - process.stdout.write(`Windows ordinary-user discovery proof: ready=standard-handle-passed native-timing=${nativeProbe.evidence};total:${nativeProbe.timing} cli=${cases.length} api=${pass[1]} authority=${authorityFailures.length}\n`); + process.stdout.write(`Windows ordinary-user discovery proof: ready=standard-handle-passed cli=${cases.length} api=${pass[1]} authority=${authorityFailures.length}\n`); } catch { const diagnostic = createFailureDiagnostic( - currentScenario, currentStage, failureStatus, currentNativeStage, nativeProbe, + currentScenario, currentStage, failureStatus, currentNativeStage, ); process.stderr.write(`Windows ordinary-user discovery assertion failed: ${JSON.stringify( diagnostic, diff --git a/test/fixtures/windowsConnectProcessMock.mjs b/test/fixtures/windowsConnectProcessMock.mjs index a9405c0b9..7dea4dc74 100644 --- a/test/fixtures/windowsConnectProcessMock.mjs +++ b/test/fixtures/windowsConnectProcessMock.mjs @@ -1,7 +1,9 @@ import childProcess from "node:child_process"; +import { EventEmitter } from "node:events"; import { fstatSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { syncBuiltinESMExports } from "node:module"; import { join, resolve } from "node:path"; +import { PassThrough } from "node:stream"; const WINDOWS_ROOT_MISSING_MARKER = "PROPR_TEST_WINDOWS_ROOT_MISSING"; const WINDOWS_ROOT_MISSING_MARKER_VALUE = "windows-root-missing-v1"; @@ -43,16 +45,18 @@ consumeMissingWindowsRootFixtureMarker(); consumeUntrustedWindowsRootFixtureMarker(); const originalSpawnSync = childProcess.spawnSync; +const originalSpawn = childProcess.spawn; const forbidden = /(?:connect-authority|ProPRConnectAuthority|pwsh|csc|msiexec)(?:\.exe)?$/i; let abaPerformed = false; +let authorityInvocation = 0; const nativeStages = new Set([ "resolver:env", "resolver:canonical", "resolver:global-open", "resolver:global-id", - "spawn:create", "spawn:error", "spawn:timeout", "spawn:cumulative-timeout", "spawn:status", "spawn:stderr", + "spawn:create", "spawn:error", "spawn:timeout", "spawn:status", "spawn:stderr", "spawn:cleanup", "probe:entry", "probe:baseline", "probe:reflection-emit", "probe:win32", "probe:standard-handle", "probe:output", "broker:ps-version", "broker:job", "broker:fd", "broker:fd-duplicate", "broker:index-info-initial", "broker:security-info", "broker:acl", "broker:json", "broker:current-user-sid", "broker:index-info-revalidation", "broker:index-info-decode", "broker:index-info-compose", "broker:entry-format", - "broker:entry-flags", "broker:entry-rules", "broker:entry-build", + "broker:entry-flags", "broker:entry-rules", "broker:entry-build", "broker:control", "parent:utf8", "parent:json-parse", "parent:json-canonical", "parent:document-shape", "parent:entry-count", "parent:entry-shape", "parent:json-shape", "parent:descriptor-bind", "parent:post-bind", ]); @@ -61,62 +65,186 @@ globalThis[Symbol.for("propr.test.windowsNativeDiagnostic")] = (stage) => { process.stderr.write(`[propr-windows-native-stage:${fixed}]\n`); }; -function authorityDocument(args, options, mode) { - const encodedIndex = args.indexOf("-EncodedCommand") + 1; - const source = Buffer.from(args[encodedIndex], "base64").toString("utf16le"); - const specs = [...source.matchAll(/index=(\d+);kind='(directory|file)';authorityKind='(ancestor|home|root|data|env)'/g)]; - const identities = [options.stdio[0]].map((fd) => { - const stat = fstatSync(fd, { bigint: true }); - return { device: stat.dev.toString(10), file: stat.ino.toString(10) }; - }); +function authorityDocument(args, options, mode, invocation = authorityInvocation) { const userSid = "S-1-5-21-100-200-300-1001"; - const entries = specs.map((spec, index) => ({ - index: Number(spec[1]), - kind: spec[2], - authorityKind: spec[3], - currentUserSid: userSid, - ownerSid: userSid, - daclProtected: true, - reparsePoint: false, - volumeSerialNumber: identities[index].device, - fileId: identities[index].file, - verifiedVolumeSerialNumber: identities[index].device, - verifiedFileId: identities[index].file, - rules: [{ - identitySid: userSid, - inherited: false, - accessType: "allow", - appliesToSelf: true, - rights: "2032127", - }], - })); - const protectedEntry = entries.find((entry) => ["root", "data", "env"].includes(entry.authorityKind)); + const descriptors = options.stdio.slice(3).filter(Number.isInteger); + const entries = descriptors.map((descriptor) => { + const stat = fstatSync(descriptor, { bigint: true }); + const identity = { device: stat.dev.toString(10), file: stat.ino.toString(10) }; + return { + currentUserSid: userSid, + ownerSid: userSid, + daclProtected: true, + reparsePoint: false, + volumeSerialNumber: identity.device, + fileId: identity.file, + verifiedVolumeSerialNumber: identity.device, + verifiedFileId: identity.file, + rules: [{ + identitySid: userSid, + inherited: false, + accessType: "allow", + appliesToSelf: true, + rights: "2032127", + }], + }; + }); if (mode === "descriptor-mismatch") { entries[0].fileId = (BigInt(entries[0].fileId) + 1n).toString(10); entries[0].verifiedFileId = entries[0].fileId; - } else if (mode === "index-mismatch") entries[0].index += 1; - else if (mode === "kind-mismatch") entries[0].kind = entries[0].kind === "file" ? "directory" : "file"; - else if (mode === "authority-kind-mismatch") entries[0].authorityKind = entries[0].authorityKind === "root" ? "data" : "root"; + } else if (mode === "index-mismatch") entries[0].extraIndex = 1; + else if (mode === "kind-mismatch") entries[0].extraKind = "file"; + else if (mode === "authority-kind-mismatch") entries[0].extraAuthorityKind = "root"; else if (mode === "identity-mismatch") { entries[0].fileId = (BigInt(entries[0].fileId) + 1n).toString(10); - } else if (mode === "sid-mismatch" && entries[0].index > 0) { - entries[0].currentUserSid = "S-1-5-21-100-200-300-1002"; - } else if (mode === "broad-write" && protectedEntry) { - protectedEntry.rules = [{ + } else if (mode === "sid-mismatch" && entries.length > 1) { + entries[1].currentUserSid = "S-1-5-21-100-200-300-1002"; + } else if (mode === "broad-write") { + for (const entry of entries) entry.rules = [{ identitySid: "S-1-1-0", inherited: false, accessType: "allow", appliesToSelf: true, rights: "2", }]; - } else if (mode === "inherited-write" && protectedEntry) { - protectedEntry.rules[0].inherited = true; - } else if (mode === "unprotected" && protectedEntry) { - protectedEntry.daclProtected = false; - } else if (mode === "owner-mismatch" && protectedEntry) { - protectedEntry.ownerSid = "S-1-5-18"; - } else if (mode === "reparse" && protectedEntry) { - protectedEntry.reparsePoint = true; + } else if (mode === "inherited-write") { + for (const entry of entries) entry.rules[0].inherited = true; + } else if (mode === "unprotected") { + for (const entry of entries) entry.daclProtected = false; + } else if (mode === "owner-mismatch") { + for (const entry of entries) entry.ownerSid = "S-1-5-18"; + } else if (mode === "reparse") { + for (const entry of entries) entry.reparsePoint = true; } return JSON.stringify({ version: 1, entries }); } +function fakeAuthorityChild(args, options, mode) { + const invocation = authorityInvocation += 1; + const child = new EventEmitter(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.stdin = options.stdio[0] === "pipe" ? new PassThrough() : null; + child.pid = 0x7000_0000 + authorityInvocation; + child.exitCode = null; + child.signalCode = null; + child.killed = false; + let terminalRequested = false; + let terminalEmitted = false; + let closeEmitted = false; + let terminalStatus = null; + let terminalSignal = null; + const streams = [child.stdin, child.stdout, child.stderr].filter(Boolean); + const emitCloseWhenDrained = () => { + if (!terminalEmitted || closeEmitted || streams.some((stream) => !stream.closed)) return; + closeEmitted = true; + child.emit("close", terminalStatus, terminalSignal); + }; + for (const stream of streams) stream.once("close", emitCloseWhenDrained); + const close = (status, signal = null) => { + if (terminalRequested) return; + terminalRequested = true; + terminalStatus = status; + terminalSignal = signal; + child.exitCode = status; + child.signalCode = signal; + // Make the control side terminal synchronously with kill. A control end + // attempted after this point must observe the destroyed pipe, never race + // the deferred output drain and appear to succeed. + if (child.stdin && !child.stdin.destroyed) child.stdin.destroy(); + queueMicrotask(() => { + if (terminalEmitted) return; + terminalEmitted = true; + child.emit("exit", terminalStatus, terminalSignal); + // A real ChildProcess closes its control pipe on termination and emits + // `close` only after both output pipes have reached their terminals. + // Defer this work out of a possibly re-entrant stdout `data` handler so + // overflow, backpressure, end callbacks, errors, and EPIPE are ordered. + for (const output of [child.stdout, child.stderr]) { + output.resume(); + if (!output.destroyed && !output.writableEnded) output.end(); + } + emitCloseWhenDrained(); + }); + }; + child.kill = () => { + child.killed = true; + close(null, "SIGKILL"); + return true; + }; + const publish = () => { + if (terminalRequested) return; + if (mode === "timeout") { + const error = Object.assign(new Error("private-path-SENTINEL"), { code: "ETIMEDOUT" }); + child.emit("error", error); + return; + } + if (mode === "malformed") child.stdout.write("{\n"); + else if (mode === "oversized") child.stdout.write("x".repeat(128 * 1024 + 1)); + else if (mode === "extra-key") child.stdout.write('{"version":1,"entries":[],"extra":true}\n'); + else if (mode === "duplicate") child.stdout.write('{"version":1,"version":1,"entries":[]}\n'); + else if (mode === "entry-count") child.stdout.write('{"version":1,"entries":[]}\n'); + else if (mode === "entry-shape") { + const document = JSON.parse(authorityDocument(args, options, mode, invocation)); + document.entries[0].extra = true; + child.stdout.write(`${JSON.stringify(document)}\n`); + } else if (mode === "stderr") child.stderr.write("private-path-SENTINEL S-1-5-21-999 raw-error-SENTINEL"); + else if (mode !== "nonzero") child.stdout.write(`${authorityDocument(args, options, mode, invocation)}\n`); + if (child.stdin && !terminalRequested && mode !== "nonzero") return; + close(mode === "nonzero" ? 70 : 0); + }; + if (child.stdin) { + const controlChunks = []; + let controlBytes = 0; + child.stdin.on("data", (chunk) => { + if (terminalRequested) return; + const bytes = Buffer.from(chunk); + controlBytes += bytes.byteLength; + if (controlBytes > Buffer.byteLength("PROPR_REVALIDATE_V1\n", "ascii")) { + close(87); + return; + } + controlChunks.push(bytes); + }); + child.stdin.once("finish", () => { + if (terminalRequested) return; + const control = Buffer.concat(controlChunks, controlBytes).toString("ascii"); + if (control !== "PROPR_REVALIDATE_V1\n") { + close(87); + return; + } + publish(); + if (!terminalRequested) close(0); + }); + child.stdin.once("error", () => close(87)); + } + queueMicrotask(publish); + return child; +} + +childProcess.spawn = (command, args, options) => { + const executable = String(command); + if (forbidden.test(executable)) throw new Error("forbidden Windows authority executable"); + if (!/powershell\.exe$/i.test(executable)) return originalSpawn(command, args, options); + const mode = process.env.PROPR_TEST_AUTHORITY_MODE; + if (mode === "path-aba" && !abaPerformed) { + abaPerformed = true; + const envPath = join(process.env.PROPR_TEST_AUTHORITY_ROOT, ".env"); + const detached = `${envPath}-aba-detached`; + renameSync(envPath, detached); + writeFileSync(envPath, [ + "PROPR_STACK=attacker-replacement-SENTINEL", + "PROPR_INSTANCE_ID=attacker", + "PROPR_UI_PUBLIC_API_URL=https://t-attacker.propr.dev", + "PROPR_UI_TUNNEL_ENABLED=true", + "PROPR_UI_TUNNEL_TOKEN=attacker-replacement-SENTINEL", + "", + ].join("\n")); + process.once("exit", () => { + rmSync(envPath, { force: true }); + renameSync(detached, envPath); + }); + } + if (!mode || mode === "path-aba") return originalSpawn(command, args, options); + return fakeAuthorityChild(args, options, mode); +}; + childProcess.spawnSync = (command, args, options) => { const executable = String(command); if (forbidden.test(executable)) throw new Error("forbidden Windows authority executable"); diff --git a/test/publicInstanceIdentity.test.ts b/test/publicInstanceIdentity.test.ts index a8d3e7a3b..db6a1bb30 100644 --- a/test/publicInstanceIdentity.test.ts +++ b/test/publicInstanceIdentity.test.ts @@ -45,6 +45,7 @@ import { assertSafeDarwinAclOutput, assertSafeWindowsAuthority, stableAuthorityIdentity, + WindowsAuthorityInspectionError, type ConnectRootAuthorityInspector, type WindowsAuthorityInspection, } from '../packages/cli/src/connectRootAuthority.js'; @@ -926,6 +927,29 @@ test('trusted Connect config read is bounded, root-specific, replacement-safe, a } }); +test('trusted Connect config preserves unavailable Windows root authority for status mapping', async () => { + const parent = temporaryRoot('propr-connect-trusted-authority-'); + const home = join(parent, 'os-home'); + const configDir = join(home, '.propr'); + privateDirectory(configDir); + writeFileSync(join(configDir, 'config.json'), JSON.stringify({ + tunnelEnabledByRoot: { 'C:\\trusted\\stack': true }, + }), { mode: 0o600 }); + const unavailable = new WindowsAuthorityInspectionError(); + const inspector: ConnectRootAuthorityInspector = { + inspectDarwinAcl: () => { throw new Error('unused'); }, + inspectWindowsAcl: async () => { throw unavailable; }, + inspectWindowsAcls: async () => { throw unavailable; }, + }; + try { + await assert.rejects(readTrustedConnectTunnelOverride('C:\\trusted\\stack', { + platform: 'win32', trustedHome: home, authorityInspector: inspector, + }), (error) => error === unavailable); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + test('trusted config authenticates absence only at the exact config child open', async () => { const root = '/trusted/stack'; const boundaries = [ diff --git a/test/windowsStandardUserConnectHarness.test.ts b/test/windowsStandardUserConnectHarness.test.ts index 856705bde..be4e0fe03 100644 --- a/test/windowsStandardUserConnectHarness.test.ts +++ b/test/windowsStandardUserConnectHarness.test.ts @@ -1,14 +1,15 @@ import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; import { readFileSync, readdirSync } from 'node:fs'; import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { runInNewContext } from 'node:vm'; import { test } from 'node:test'; import { parseWindowsNativeProbeOutput, - WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS, + WINDOWS_INSPECTION_CLEANUP_TIMEOUT_MS, WINDOWS_INSPECTION_TIMEOUT_MS, WINDOWS_NATIVE_TIMING_PROBE_TIMEOUT_MS, - windowsInspectionTimeoutForElapsed, WindowsNativeStageError, windowsNativeTimingBucket, } from '../packages/cli/src/connectWindowsAuthority.js'; @@ -16,6 +17,8 @@ import { const harness = readFileSync('scripts/verify-windows-standard-user-connect.mjs', 'utf8'); const processMock = readFileSync('test/fixtures/windowsConnectProcessMock.mjs', 'utf8'); const windowsAuthority = readFileSync('packages/cli/src/connectWindowsAuthority.ts', 'utf8'); +const connectCommand = readFileSync('packages/cli/src/commands/connectCommand.ts', 'utf8'); +const packagedConnectLifecycle = readFileSync('apps/desktop/scripts/packaged-connect-lifecycle.mjs', 'utf8'); function diagnosticDefinitions(): { scenarioAllowlist: string[]; @@ -23,14 +26,11 @@ function diagnosticDefinitions(): { statusKindAllowlist: string[]; reasonCodeAllowlist: string[]; nativeStageAllowlist: string[]; - probeMilestoneAllowlist: string[]; - probeTimingAllowlist: string[]; createFailureDiagnostic: ( scenario: string, stage: string, failureStatus: { status?: unknown; reasonCodes?: unknown } | null, nativeStage: string | null, - probe: { milestone: string | null; timing: string | null }, ) => Record; } { const start = harness.indexOf('const scenarioAllowlist ='); @@ -43,8 +43,6 @@ function diagnosticDefinitions(): { statusKindAllowlist, reasonCodeAllowlist, nativeStageAllowlist, - probeMilestoneAllowlist, - probeTimingAllowlist, createFailureDiagnostic, })`) as ReturnType; } @@ -321,13 +319,131 @@ test('the ordinary-user Windows proof retains native security paths and bounds r } assert.match( processMock, - /if \(mode === "valid-authority"\) return result\(0, authorityDocument\(args, options, mode\)\);/, + /else if \(mode !== "nonzero"\) child\.stdout\.write\(`\$\{authorityDocument\(args, options, mode, invocation\)\}\\n`\);/, ); assert.match(harness, /\{ name: "path-aba", mode: "path-aba", reason: "INVALID_ROOT" \}/); assert.match(harness, /\{ name: "authority-missing-system-root", systemRootMode: "missing", nativeStage: "resolver:env" \}/); assert.match(harness, /\{ name: "authority-untrusted-system-root", systemRootMode: "untrusted", nativeStage: "resolver:global-id" \}/); }); +test('the async two-round fake authority child contains exact-cap overflow promptly', () => { + const processFixture = pathToFileURL(resolve('test/fixtures/windowsConnectProcessMock.mjs')).href; + const authorityModule = pathToFileURL(resolve('packages/cli/src/connectWindowsAuthority.ts')).href; + const regression = ` + import assert from "node:assert/strict"; + import childProcess from "node:child_process"; + import { + runWindowsInspectionBrokerBatch, + WindowsNativeStageError, + writeWindowsInspectionRevalidationControl, + } from ${JSON.stringify(authorityModule)}; + + const tick = () => new Promise((resolve) => setImmediate(resolve)); + await tick(); + const baselineHandles = new Set(process._getActiveHandles()); + const baselineResources = process.getActiveResourcesInfo().reduce((counts, name) => { + counts.set(name, (counts.get(name) ?? 0) + 1); + return counts; + }, new Map()); + const child = childProcess.spawn("powershell.exe", [], { + shell: false, + windowsHide: true, + stdio: ["pipe", "pipe", "pipe"], + }); + const input = child.stdin; + assert.ok(input && child.stdout && child.stderr); + let publishedBytes = 0; + let exitEvents = 0; + let closeEvents = 0; + let stdoutEnds = 0; + let stderrEnds = 0; + let stdoutCloses = 0; + let stderrCloses = 0; + let stdinCloses = 0; + child.stdout.on("data", (chunk) => { publishedBytes += Buffer.byteLength(chunk); }); + child.stdout.on("end", () => { stdoutEnds += 1; }); + child.stderr.on("end", () => { stderrEnds += 1; }); + child.stdout.on("close", () => { stdoutCloses += 1; }); + child.stderr.on("close", () => { stderrCloses += 1; }); + input.on("close", () => { stdinCloses += 1; }); + child.on("exit", () => { exitEvents += 1; }); + child.on("close", () => { closeEvents += 1; }); + + const started = performance.now(); + await assert.rejects(runWindowsInspectionBrokerBatch({ + entryCount: 1, + startBroker: () => child, + deadlineMs: 1_000, + cleanupTimeoutMs: 250, + maxOutputBytes: 128 * 1024, + }), (error) => error instanceof WindowsNativeStageError && error.stage === "parent:utf8"); + assert.ok(performance.now() - started < 750, "overflow did not reject inside its short bound"); + assert.equal(publishedBytes, 128 * 1024 + 1); + assert.equal(child.exitCode, null); + assert.equal(child.signalCode, "SIGKILL"); + assert.equal(child.killed, true); + assert.equal(exitEvents, 1); + assert.equal(closeEvents, 1); + assert.equal(stdoutEnds, 1); + assert.equal(stderrEnds, 1); + assert.equal(stdoutCloses, 1); + assert.equal(stderrCloses, 1); + assert.equal(stdinCloses, 1); + for (const output of [child.stdout, child.stderr]) { + assert.equal(output.readableEnded, true); + assert.equal(output.destroyed, true); + assert.equal(output.closed, true); + } + assert.equal(input.destroyed, true); + assert.equal(input.closed, true); + assert.equal(input.writable, false); + await assert.rejects( + writeWindowsInspectionRevalidationControl(child), + (error) => error instanceof WindowsNativeStageError && error.stage === "broker:control", + ); + assert.equal(child.kill("SIGKILL"), true); + assert.equal(child.kill("SIGKILL"), true); + await tick(); + assert.equal(exitEvents, 1); + assert.equal(closeEvents, 1); + + const referencedHandles = process._getActiveHandles().filter((handle) => ( + !baselineHandles.has(handle) + && (typeof handle.hasRef !== "function" || handle.hasRef()) + )); + assert.deepEqual(referencedHandles.map((handle) => handle.constructor?.name ?? "unknown"), []); + const finalResources = process.getActiveResourcesInfo().reduce((counts, name) => { + counts.set(name, (counts.get(name) ?? 0) + 1); + return counts; + }, new Map()); + for (const [name, count] of finalResources) { + assert.ok(count <= (baselineResources.get(name) ?? 0), name + " remained referenced"); + } + `; + const started = performance.now(); + const result = spawnSync(process.execPath, [ + '--no-warnings', + '--import', 'tsx', + '--import', processFixture, + '--input-type=module', + '--eval', regression, + ], { + cwd: resolve('.'), + shell: false, + windowsHide: true, + encoding: 'utf8', + timeout: 3_000, + maxBuffer: 16 * 1024, + env: { ...process.env, PROPR_TEST_AUTHORITY_MODE: 'oversized' }, + }); + assert.equal(result.error, undefined, result.error?.message); + assert.equal(result.signal, null, result.stderr); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, ''); + assert.equal(result.stderr, ''); + assert.ok(performance.now() - started < 3_000); +}); + test('the ordinary-user Windows diagnostic has fixed allowlists and redacts all other values', () => { const definitions = diagnosticDefinitions(); assert.deepEqual([...definitions.scenarioAllowlist], [ @@ -342,7 +458,7 @@ test('the ordinary-user Windows diagnostic has fixed allowlists and redacts all 'authority-missing-system-root', 'authority-mismatched-system-root', 'authority-untrusted-system-root', ]); assert.deepEqual([...definitions.assertionStageAllowlist], [ - 'native-timing', 'authority-probe', 'scaffold', 'identity-assertion', 'config-init', 'config-save', + 'authority-probe', 'scaffold', 'identity-assertion', 'config-init', 'config-save', 'config-assertion', 'write-env', 'spawn', 'signal', 'exit', 'bounds', 'schema', 'status', 'endpoint', 'identity', 'reasons', 'api-ready', 'restart', 'stderr', 'sentinel', 'api-spawn', @@ -360,22 +476,14 @@ test('the ordinary-user Windows diagnostic has fixed allowlists and redacts all ]); assert.deepEqual([...definitions.nativeStageAllowlist], [ 'resolver:env', 'resolver:canonical', 'resolver:global-open', 'resolver:global-id', - 'spawn:create', 'spawn:error', 'spawn:timeout', 'spawn:cumulative-timeout', 'spawn:status', 'spawn:stderr', - 'probe:entry', 'probe:baseline', 'probe:reflection-emit', 'probe:win32', 'probe:standard-handle', 'probe:output', + 'spawn:create', 'spawn:error', 'spawn:timeout', 'spawn:status', 'spawn:stderr', 'spawn:cleanup', 'broker:ps-version', 'broker:job', 'broker:fd', 'broker:fd-duplicate', 'broker:index-info-initial', 'broker:security-info', 'broker:acl', 'broker:json', 'broker:current-user-sid', 'broker:index-info-revalidation', 'broker:index-info-decode', 'broker:index-info-compose', 'broker:entry-format', - 'broker:entry-flags', 'broker:entry-rules', 'broker:entry-build', + 'broker:entry-flags', 'broker:entry-rules', 'broker:entry-build', 'broker:control', 'parent:utf8', 'parent:json-parse', 'parent:json-canonical', 'parent:document-shape', 'parent:entry-count', 'parent:entry-shape', 'parent:json-shape', 'parent:descriptor-bind', 'parent:post-bind', ]); - assert.deepEqual([...definitions.probeMilestoneAllowlist], [ - 'none', 'entry-ps51-desktop-x64', 'constant-json', 'reflection-emit', 'harmless-win32', - 'standard-handle-identity', - ]); - assert.deepEqual([...definitions.probeTimingAllowlist], [ - 'under-5s', '5-to-15s', '15-to-30s', '30-to-45s', '45-to-60s', 'at-least-60s', - ]); const assignedStages = [...harness.matchAll(/currentStage = "([^"]+)";/g)] .map((match) => match[1]); assert.deepEqual(new Set(assignedStages), new Set(definitions.assertionStageAllowlist)); @@ -393,12 +501,9 @@ test('the ordinary-user Windows diagnostic has fixed allowlists and redacts all identity: 'identity-SENTINEL', endpoint: 'endpoint-SENTINEL', secret: 'secret-SENTINEL', - } as { status: string; reasonCodes: string[] }, 'broker:fd', { - milestone: 'standard-handle-identity', timing: '15-to-30s', - }); + } as { status: string; reasonCodes: string[] }, 'broker:fd'); assert.deepEqual(Object.keys(diagnostic), [ 'scenario', 'stage', 'nativeStage', 'status', 'reasonCodes', - 'probeMilestone', 'probeTiming', ]); assert.deepEqual(JSON.parse(JSON.stringify(diagnostic)), { scenario: 'ready', @@ -406,30 +511,14 @@ test('the ordinary-user Windows diagnostic has fixed allowlists and redacts all nativeStage: 'broker:fd', status: 'ready', reasonCodes: ['ACL_DIAGNOSTIC_UNAVAILABLE'], - probeMilestone: 'standard-handle-identity', - probeTiming: '15-to-30s', }); assert.equal(JSON.stringify(diagnostic).includes('SENTINEL'), false); - assert.deepEqual(JSON.parse(JSON.stringify(definitions.createFailureDiagnostic( - 'ready', 'native-timing', null, 'spawn:timeout', - { milestone: 'reflection-emit', timing: 'at-least-60s' }, - ))), { - scenario: 'ready', - stage: 'native-timing', - nativeStage: 'spawn:timeout', - status: null, - reasonCodes: [], - probeMilestone: 'reflection-emit', - probeTiming: 'at-least-60s', - }); - const rejected = definitions.createFailureDiagnostic( 'private-scenario-SENTINEL', 'raw-output-SENTINEL', { status: 'secret-status-SENTINEL', reasonCodes: ['secret-reason-SENTINEL'] }, 'raw-native-stage-SENTINEL', - { milestone: 'secret-SENTINEL', timing: '12345ms-SENTINEL' }, ); assert.deepEqual(JSON.parse(JSON.stringify(rejected)), { scenario: 'ready', @@ -437,47 +526,47 @@ test('the ordinary-user Windows diagnostic has fixed allowlists and redacts all nativeStage: null, status: null, reasonCodes: [], - probeMilestone: null, - probeTiming: null, }); const catchStart = harness.lastIndexOf('} catch {'); const catchEnd = harness.indexOf('} finally {', catchStart); const catchBody = harness.slice(catchStart, catchEnd); - assert.match(catchBody, /createFailureDiagnostic\(\s*currentScenario, currentStage, failureStatus, currentNativeStage, nativeProbe,/); + assert.match(catchBody, /createFailureDiagnostic\(\s*currentScenario, currentStage, failureStatus, currentNativeStage,/); assert.match(catchBody, /JSON\.stringify\(\s*diagnostic,\s*\)/); assert.doesNotMatch(catchBody, /(?:result|api|error)\.(?:stdout|stderr|message|path|argv|env|config)/i); }); -test('the staged hosted probe and production inspector both use the inherited standard handle', () => { - assert.doesNotMatch(windowsAuthority, /_get_osfhandle|AssignProcessToJobObject|CreateJobObject/); - assert.match(harness, /runWindowsNativeTimingProbe\(probeFd\)/); - assert.match(harness, /openSync\(\s*fixture,\s*constants\.O_RDONLY \| constants\.O_DIRECTORY \| constants\.O_NOFOLLOW,\s*\)/); - assert.match(harness, /native-timing=\$\{nativeProbe\.evidence\}/); - assert.match(harness, /;total:\$\{nativeProbe\.timing\}/); +test('the ordinary-user path skips the isolated timing probe and production inherits a fixed multi-handle fd table', () => { + assert.doesNotMatch(windowsAuthority, /AssignProcessToJobObject|CreateJobObject/); + assert.doesNotMatch(harness, /runWindowsNativeTimingProbe|native-timing|nativeProbe|probeMilestone|probeTiming/); assert.match(harness, /ready=standard-handle-passed/); const productionSourceStart = windowsAuthority.indexOf('export const WINDOWS_INSPECTION_SOURCE'); const productionSourceEnd = windowsAuthority.indexOf('export const WINDOWS_NATIVE_PROBE_MILESTONES', productionSourceStart); const productionSource = windowsAuthority.slice(productionSourceStart, productionSourceEnd); - assert.match(productionSource, /GetStdHandle\(-10\)/); - assert.doesNotMatch(productionSource, /_get_osfhandle|AssignProcessToJobObject|CreateJobObject|Start-Process|CreateProcess/); + assert.match(productionSource, /_get_osfhandle\(3\+\$i\)/); + assert.doesNotMatch(productionSource, /AssignProcessToJobObject|CreateJobObject|Start-Process|CreateProcess/); assert.match(windowsAuthority, /stdio: \[stdin, "pipe", "pipe"\]/); + assert.match(windowsAuthority, /stdio: \[roundCount === 2 \? "pipe" : "ignore", "pipe", "pipe", \.\.\.targets\.map/); + assert.doesNotMatch(productionSource, /\bpath=|\bkind=|authorityKind=|S-1-/); + assert.match(windowsAuthority, /powerShellArguments\(inspectionSource\(targets\.length, roundCount\)\)/); + assert.match(windowsAuthority, /\.replace\("__PROPR_ENTRY_COUNT__", String\(entryCount\)\)/); + assert.match(windowsAuthority, /\.replace\("__PROPR_ROUND_COUNT__", String\(roundCount\)\)/); assert.match(windowsAuthority, /WINDOWS_INSPECTOR_CREATES_CHILD_PROCESSES = false/); assert.match(windowsAuthority, /WINDOWS_INSPECTOR_WRITES_FILESYSTEM = false/); }); -test('the production inspector duplicates its standard handle before the split native operations', () => { +test('the production inspector duplicates each fixed fd before split native operations', () => { const productionSourceStart = windowsAuthority.indexOf('export const WINDOWS_INSPECTION_SOURCE'); const productionSourceEnd = windowsAuthority.indexOf('export const WINDOWS_NATIVE_PROBE_MILESTONES', productionSourceStart); const productionSource = windowsAuthority.slice(productionSourceStart, productionSourceEnd); - assert.match(productionSource, /\$stage=80\s+if\(-not \[ProprReadOnlyAuthority\]::DuplicateHandle\(\s*\[ProprReadOnlyAuthority\]::GetCurrentProcess\(\),\$originalHandle,\s*\[ProprReadOnlyAuthority\]::GetCurrentProcess\(\),\[ref\]\$privateHandle,0,\$false,2\)\)\{exit \$stage\}/); - assert.match(productionSource, /\$stage=74\s+\$before=\[Runtime\.InteropServices\.Marshal\]::AllocHGlobal\(52\)\s+if\(-not \[ProprReadOnlyAuthority\]::GetFileInformationByHandle\(\$privateHandle,\$before\)\)\{exit \$stage\}/); - assert.match(productionSource, /\$stage=78\s+\$current=\[Security\.Principal\.WindowsIdentity\]::GetCurrent\(\)\.User\s+if\(\$null-eq \$current\)\{exit \$stage\}\s+\$currentSid=\$current\.Value/); + assert.match(productionSource, /\$stage=80\s+if\(-not \[ProprReadOnlyAuthority\]::DuplicateHandle\(\s*\[ProprReadOnlyAuthority\]::GetCurrentProcess\(\),\$originalHandle,\s*\[ProprReadOnlyAuthority\]::GetCurrentProcess\(\),\[ref\]\$privateHandle,0,\$false,2\)\)\{Exit-ProprStage\}/); + assert.match(productionSource, /\$stage=74\s+\$before=\[Runtime\.InteropServices\.Marshal\]::AllocHGlobal\(52\)\s+if\(-not \[ProprReadOnlyAuthority\]::GetFileInformationByHandle\(\$privateHandle,\$before\)\)\{Exit-ProprStage\}/); + assert.match(productionSource, /\$stage=78\s+\$current=\[Security\.Principal\.WindowsIdentity\]::GetCurrent\(\)\.User\s+if\(\$null-eq \$current\)\{Exit-ProprStage\}/); assert.match(productionSource, /GetSecurityInfo\(\$privateHandle,1,5,\[ref\]\$owner,\[ref\]\$group,\[ref\]\$dacl,\[ref\]\$sacl,\[ref\]\$descriptor\)/); - assert.match(productionSource, /\$stage=79\s+\$after=\[Runtime\.InteropServices\.Marshal\]::AllocHGlobal\(52\)\s+if\(-not \[ProprReadOnlyAuthority\]::GetFileInformationByHandle\(\$privateHandle,\$after\)\)\{exit \$stage\}/); + assert.match(productionSource, /\$stage=79\s+\$after=\[Runtime\.InteropServices\.Marshal\]::AllocHGlobal\(52\)\s+if\(-not \[ProprReadOnlyAuthority\]::GetFileInformationByHandle\(\$privateHandle,\$after\)\)\{Exit-ProprStage\}/); assert.equal(productionSource.match(/::CloseHandle\(\$privateHandle\)/g)?.length, 1); - assert.match(productionSource, /finally \{if\(\$privateHandleOwned\)\{\$null=\[ProprReadOnlyAuthority\]::CloseHandle\(\$privateHandle\)\}\}/); + assert.match(productionSource, /if\(\$privateHandle-ne \[IntPtr\]::Zero\)\{\$null=\[ProprReadOnlyAuthority\]::CloseHandle\(\$privateHandle\)\}/); assert.doesNotMatch(productionSource, /CloseHandle\(\$originalHandle\)/); assert.doesNotMatch(windowsAuthority, /"broker:index-info"/); assert.match(windowsAuthority, /74: "broker:index-info-initial"/); @@ -510,51 +599,60 @@ test('the staged probe accepts only ordered milestone tokens and coarse timing b assert.match(windowsAuthority, /GetFileInformationByHandle/); }); -test('the diagnostic allowance precedes a cumulatively bounded production standard-handle proof', () => { +test('the fixed product contracts run ready and malformed authority scenarios without a timing-probe gate', () => { assert.equal(WINDOWS_NATIVE_TIMING_PROBE_TIMEOUT_MS, 60_000); assert.equal(WINDOWS_INSPECTION_TIMEOUT_MS, 60_000); - assert.equal(WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS, 240_000); + assert.equal(WINDOWS_INSPECTION_CLEANUP_TIMEOUT_MS, 5_000); assert.match( windowsAuthority, - /export const WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS = 240_000;/, + /export const WINDOWS_INSPECTION_TIMEOUT_MS = 60_000;/, ); assert.match(harness, /const WINDOWS_PRODUCT_AUTHORITY_PHASE_COUNT = 2;/); assert.match(harness, /const WINDOWS_PRODUCT_SCENARIO_OVERHEAD_MS = 15_000;/); + assert.match(harness, /const WINDOWS_PRODUCT_AUTHORITY_TIMEOUT_MS = 60_000;/); assert.match( harness, - /const WINDOWS_PRODUCT_SCENARIO_TIMEOUT_MS = \(\s*WINDOWS_PRODUCT_AUTHORITY_PHASE_COUNT\s*\* nativeAuthority\.WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS\s*\) \+ WINDOWS_PRODUCT_SCENARIO_OVERHEAD_MS;/, + /const WINDOWS_PRODUCT_SCENARIO_TIMEOUT_MS = \(\s*WINDOWS_PRODUCT_AUTHORITY_PHASE_COUNT \* WINDOWS_PRODUCT_AUTHORITY_TIMEOUT_MS\s*\) \+ WINDOWS_PRODUCT_SCENARIO_OVERHEAD_MS;/, ); const windowsProductScenarioTimeoutMs = ( - 2 * WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS + 2 * WINDOWS_INSPECTION_TIMEOUT_MS ) + 15_000; - assert.equal(windowsProductScenarioTimeoutMs, 495_000); + assert.equal(windowsProductScenarioTimeoutMs, 135_000); assert.equal(Number.isFinite(windowsProductScenarioTimeoutMs), true); assert.equal(Number.isSafeInteger(windowsProductScenarioTimeoutMs), true); - assert.equal(WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS, 4 * WINDOWS_INSPECTION_TIMEOUT_MS); - assert.notEqual( - WINDOWS_INSPECTION_CUMULATIVE_TIMEOUT_MS / WINDOWS_INSPECTION_TIMEOUT_MS, - 32, - ); - assert.equal(windowsInspectionTimeoutForElapsed(0), 60_000); - assert.equal(windowsInspectionTimeoutForElapsed(60_000), 60_000); - assert.equal(windowsInspectionTimeoutForElapsed(120_000), 60_000); - assert.equal(windowsInspectionTimeoutForElapsed(180_000), 60_000); - assert.equal(windowsInspectionTimeoutForElapsed(180_001), 59_999); - assert.equal(windowsInspectionTimeoutForElapsed(210_000), 30_000); - assert.equal(windowsInspectionTimeoutForElapsed(225_000), 15_000); - assert.equal(windowsInspectionTimeoutForElapsed(239_999.9), 1); - assert.throws( - () => windowsInspectionTimeoutForElapsed(240_000), - (error) => error instanceof WindowsNativeStageError && error.stage === 'spawn:cumulative-timeout', - ); - assert.throws( - () => windowsInspectionTimeoutForElapsed(240_001), - (error) => error instanceof WindowsNativeStageError && error.stage === 'spawn:cumulative-timeout', + assert.match(packagedConnectLifecycle, /readyTimeoutMs = 240_000,/u); + assert.ok(WINDOWS_INSPECTION_CLEANUP_TIMEOUT_MS < WINDOWS_INSPECTION_TIMEOUT_MS); + assert.match(windowsAuthority, /startBroker: \(\) => child/u); + assert.match(windowsAuthority, /const deadlineTimer = setTimeout\(\(\) => \{\s*deadlineExpired = true;/u); + assert.match(windowsAuthority, /deadlineMs: WINDOWS_INSPECTION_TIMEOUT_MS/u); + const trustedGeneration = connectCommand.indexOf('await readTrustedConnectTunnelOverride(root)'); + const rootGeneration = connectCommand.indexOf('await withOwnedConnectRootSnapshot(root'); + assert.ok(trustedGeneration >= 0 && trustedGeneration < rootGeneration); + const rootSnapshot = connectCommand.slice(rootGeneration, connectCommand.indexOf("dependencies.reportSmokeDiagnostic?.(phase, 'PASSED')", rootGeneration)); + assert.match(rootSnapshot, /process\.platform === "win32"\s*\? windowsTunnelEnabledOverride/u); + assert.doesNotMatch(rootSnapshot, /process\.platform === "win32"\s*\? await readTrustedConnectTunnelOverride/u); + const productionInspection = windowsAuthority.slice( + windowsAuthority.indexOf('export async function runWindowsReadOnlyInspection'), + windowsAuthority.indexOf('function probeFailureStage'), ); - const probeCall = harness.indexOf('runWindowsNativeTimingProbe(probeFd)'); - const productionMatrix = harness.indexOf('for (const scenario of cases)', probeCall); + assert.doesNotMatch(productionInspection, /spawnSync|windowsInspectionTimeoutForElapsed/u); + assert.doesNotMatch(harness, /runWindowsNativeTimingProbe/); + assert.equal(fixtureScenarios()[0]?.name, 'ready'); + assert.match(harness, /\{ name: "authority-malformed", mode: "malformed", nativeStage: "parent:json-parse" \}/); + const productionMatrix = harness.indexOf('for (const scenario of cases)'); const productionSpawn = harness.indexOf('const result = spawnSync(process.execPath', productionMatrix); - assert.ok(probeCall < productionMatrix && productionMatrix < productionSpawn); + const authorityMatrix = harness.indexOf('for (const scenario of authorityFailures)', productionSpawn); + const authoritySpawn = harness.indexOf('const result = spawnSync(process.execPath', authorityMatrix); + assert.ok(productionMatrix < productionSpawn && productionSpawn < authorityMatrix && authorityMatrix < authoritySpawn); + const productScenarioLoop = harness.slice(productionMatrix, authorityMatrix); + assert.match(productScenarioLoop, /currentScenario = scenario\.name;[\s\S]*assert\.equal\(document\.status, scenario\.status, scenario\.name\);/u); + assert.match(productScenarioLoop, /const expectedStderr = scenario\.status === "ready" \? "" : `ProPR Connect discovery: \$\{scenario\.status\}\.\\n`;/u); + assert.match(productScenarioLoop, /assert\.equal\(nativeDiagnostic\.applicationStderr, expectedStderr, scenario\.name\);/u); + const authorityScenarioLoop = harness.slice(authorityMatrix, harness.indexOf('currentScenario = "api"', authorityMatrix)); + assert.match(authorityScenarioLoop, /assert\.equal\(currentNativeStage, scenario\.nativeStage, scenario\.name\);/u); + assert.match(authorityScenarioLoop, /assert\.equal\(document\.status, "invalidConfig", scenario\.name\);/u); + assert.match(authorityScenarioLoop, /scenario\.reason \?\? "ACL_DIAGNOSTIC_UNAVAILABLE"/u); + assert.match(authorityScenarioLoop, /assert\.equal\(nativeDiagnostic\.applicationStderr, "ProPR Connect discovery: invalidConfig\.\\n", scenario\.name\);/u); const probeStart = windowsAuthority.indexOf('export function runWindowsNativeTimingProbe'); const probeEnd = windowsAuthority.indexOf('\n}\n\nexport function windowsInspectionEntryKind', probeStart); const probe = windowsAuthority.slice(probeStart, probeEnd); @@ -570,7 +668,7 @@ test('the hostile path ABA remains replaced through validation and is rejected a assert.match(processMock, /process\.once\("exit", \(\) => \{/); const replacement = processMock.indexOf('writeFileSync(envPath'); const exitHook = processMock.indexOf('process.once("exit"', replacement); - const spawn = processMock.indexOf('return originalSpawnSync(command, args, options);', replacement); + const spawn = processMock.indexOf('return originalSpawn(command, args, options);', replacement); const restore = processMock.indexOf('renameSync(detached, envPath);', replacement); assert.ok( replacement < exitHook && exitHook < restore && restore < spawn,