diff --git a/scripts/build-standalone.ts b/scripts/build-standalone.ts index 5cee96206ae..8f1f145429a 100644 --- a/scripts/build-standalone.ts +++ b/scripts/build-standalone.ts @@ -38,6 +38,7 @@ const WORKER_ENTRIES: Record = { "restore-worker": join(repoRoot, "src", "storage", "restore-worker.ts"), "history-worker": join(repoRoot, "src", "codex", "history-worker.ts"), "request-history-rebuild-worker": join(repoRoot, "src", "routing", "history", "rebuild-worker.ts"), + "windows-native-principal-worker": join(repoRoot, "src", "lib", "windows-native-principal-worker.ts"), }; const GEN_FILE = join(repoRoot, "src", "generated", "worker-bundles.gen.ts"); const GEN_PLACEHOLDER = `// Generated by scripts/build-standalone.ts — do not edit by hand.\n// Placeholder for source checkouts; standalone builds overwrite this file\n// with pre-bundled worker sources before compiling.\nexport const WORKER_BUNDLES: Record = {};\n`; diff --git a/scripts/test-layout/layout-agent-assertion.json b/scripts/test-layout/layout-agent-assertion.json index 70b8527af96..6504b6670f2 100644 --- a/scripts/test-layout/layout-agent-assertion.json +++ b/scripts/test-layout/layout-agent-assertion.json @@ -1,4 +1,5 @@ { + "windows-native-principal.test.ts": "windows", "agent-assertion-auth.test.ts": "server", "agent-assertion-routes.test.ts": "responses", "authorization-scheme.test.ts": "lib", diff --git a/src/lib/windows-native-principal-worker.ts b/src/lib/windows-native-principal-worker.ts new file mode 100644 index 00000000000..3c737238d4d --- /dev/null +++ b/src/lib/windows-native-principal-worker.ts @@ -0,0 +1,14 @@ +import { resolveWindowsNativePrincipal } from "./windows-native-principal"; + +declare const self: Worker; +self.addEventListener("message", () => { + try { + const identity = resolveWindowsNativePrincipal(); + self.postMessage(identity ? { kind: "identity", ...identity } : { kind: "unavailable" }); + } catch { + self.postMessage({ kind: "failure" }); + } finally { + // Native token/string/DLL finally blocks completed before the result was posted. + process.exit(0); + } +}, { once: true }); diff --git a/src/lib/windows-native-principal.ts b/src/lib/windows-native-principal.ts new file mode 100644 index 00000000000..559eec5016b --- /dev/null +++ b/src/lib/windows-native-principal.ts @@ -0,0 +1,188 @@ +import { dlopen, ptr, read, type Pointer } from "bun:ffi"; +import { win32 } from "node:path"; +import { readWindowsCurrentSid, type WindowsTokenSidApi } from "../codex/windows-current-sid"; +import { resolveTrustedWindowsSystemDirectory } from "./windows-elevation"; +import { spawnWorker } from "./worker-embed"; + +export interface NativeWindowsPrincipal { readonly sid: string; readonly name: string } +export interface WindowsNativePrincipalApi extends WindowsTokenSidApi { + lookupAccountSid(sid: number, name: Uint16Array | null, nameLength: Uint32Array, + domain: Uint16Array | null, domainLength: Uint32Array, use: Uint32Array): number; + lastError(): number; +} + +const MAX_ACCOUNT_CODE_UNITS = 32_768; +function lookupError(): Error { return new Error("Windows native account translation failed."); } + +function wideString(buffer: Uint16Array, reported: number): string { + if (reported > buffer.length) throw lookupError(); + const end = buffer.indexOf(0); + if (end < 0) throw lookupError(); + const value = String.fromCharCode(...buffer.subarray(0, end)); + if (/[\r\n]/.test(value)) throw lookupError(); + return value; +} + +function accountName(api: WindowsNativePrincipalApi, sid: number): string { + const nameLength = new Uint32Array(1), domainLength = new Uint32Array(1), use = new Uint32Array(1); + const sized = api.lookupAccountSid(sid, null, nameLength, null, domainLength, use); + const error = sized === 0 ? api.lastError() : 0; + if (sized !== 0 || error !== 122 || nameLength[0]! < 2 + || nameLength[0]! > MAX_ACCOUNT_CODE_UNITS || domainLength[0]! > MAX_ACCOUNT_CODE_UNITS) { + throw lookupError(); + } + const name = new Uint16Array(nameLength[0]!); + // An empty domain is valid; its storage still needs a non-null, addressable NUL. + const domain = new Uint16Array(Math.max(1, domainLength[0]!)); + domainLength[0] = domain.length; + if (api.lookupAccountSid(sid, name, nameLength, domain, domainLength, use) === 0) { + // No resize/retry downgrade: an unmapped SID or changing requirement refuses. + throw lookupError(); + } + const account = wideString(name, nameLength[0]!); + const authority = wideString(domain, domainLength[0]!); + if (!account) throw lookupError(); + return authority ? `${authority}\\${account}` : account; +} + +/** Reuse the token reader's lifetime; translate its live binary SID before allocating the string. */ +export function readWindowsNativePrincipal(api: WindowsNativePrincipalApi): NativeWindowsPrincipal { + let name = ""; + const sid = readWindowsCurrentSid({ + ...api, + convertSidToStringSid(address, output) { + // The reader synchronously invokes this while retaining TOKEN_USER's backing buffer. + name = accountName(api, address); + return api.convertSidToStringSid(address, output); + }, + }); + return Object.freeze({ sid, name }); +} + +interface NativeResolution { + platform: NodeJS.Platform; + arch: string; + systemDirectory(): string; + load: typeof dlopen; +} + +/** Null means unsupported native execution. DLL/path/API failures propagate, never downgrade. */ +export function resolveWindowsNativePrincipal(resolution: NativeResolution = { + platform: process.platform, arch: process.arch, + systemDirectory: resolveTrustedWindowsSystemDirectory, load: dlopen, +}): NativeWindowsPrincipal | null { + if (resolution.platform !== "win32" || resolution.arch !== "x64") return null; + const directory = resolution.systemDirectory(); + const advapi = resolution.load(win32.join(directory, "advapi32.dll"), { + OpenProcessToken: { args: ["u64", "u32", "ptr"], returns: "i32" }, + GetTokenInformation: { args: ["u64", "u32", "ptr", "u32", "ptr"], returns: "i32" }, + ConvertSidToStringSidW: { args: ["ptr", "ptr"], returns: "i32" }, + LookupAccountSidW: { args: ["ptr", "ptr", "ptr", "ptr", "ptr", "ptr", "ptr"], returns: "i32" }, + }); + try { + const kernel = resolution.load(win32.join(directory, "kernel32.dll"), { + GetCurrentProcess: { args: [], returns: "u64" }, + CloseHandle: { args: ["u64"], returns: "i32" }, + LocalFree: { args: ["ptr"], returns: "ptr" }, + GetLastError: { args: [], returns: "u32" }, + }); + try { + return readWindowsNativePrincipal({ + openProcessToken: (access, output) => advapi.symbols.OpenProcessToken( + kernel.symbols.GetCurrentProcess(), access, ptr(output)), + getTokenInformation: (token, kind, buffer, length) => advapi.symbols.GetTokenInformation( + token, kind, ptr(buffer), buffer.byteLength, ptr(length)), + convertSidToStringSid: (address, output) => advapi.symbols.ConvertSidToStringSidW( + address as Pointer, ptr(output)), + lookupAccountSid: (address, name, nameLength, domain, domainLength, use) => + advapi.symbols.LookupAccountSidW(null, address as Pointer, name ? ptr(name) : null, + ptr(nameLength), domain ? ptr(domain) : null, ptr(domainLength), ptr(use)), + lastError: () => kernel.symbols.GetLastError(), + readCodeUnit: (address, offset) => read.u16(address as Pointer, offset), + localFree: address => { kernel.symbols.LocalFree(address as Pointer); }, + closeHandle: token => { kernel.symbols.CloseHandle(token); }, + }); + } finally { kernel.close(); } + } finally { advapi.close(); } +} + +export interface NativePrincipalTiming { + now(): number; + schedule(callback: () => void, milliseconds: number): () => void; +} +const timing: NativePrincipalTiming = { + now: () => performance.now(), + schedule: (callback, milliseconds) => { + const timer = setTimeout(callback, milliseconds); + return () => clearTimeout(timer); + }, +}; + +/** A worker may remain in FFI after a caller expires; its slot belongs to closure, not the caller. */ +export function createWindowsNativePrincipalLookup( + createWorker: () => Worker = () => spawnWorker( + new URL("./windows-native-principal-worker.ts", import.meta.url).href, "windows-native-principal-worker"), +): (timeoutMs: number, clock?: NativePrincipalTiming) => Promise { + let active: { result: Promise } | null = null; + const start = () => { + const worker = createWorker(); + let resolve!: (identity: NativeWindowsPrincipal | null) => void, reject!: (error: Error) => void; + const job = { result: new Promise((yes, no) => { resolve = yes; reject = no; }) }; + void job.result.catch(() => {}); + active = job; + let response: NativeWindowsPrincipal | null | undefined; + let received = false; + const fail = () => reject(lookupError()); + worker.addEventListener("message", event => { + if (received) { fail(); return; } + received = true; + const message: unknown = event.data; + if (!message || typeof message !== "object") { fail(); return; } + const data = message as Record; + if (data.kind === "unavailable") response = null; + else if (data.kind === "identity" && typeof data.sid === "string" && typeof data.name === "string") { + response = { sid: data.sid, name: data.name }; + } else fail(); + }); + worker.addEventListener("error", fail); + worker.addEventListener("messageerror", fail); + worker.addEventListener("close", () => { + if (active === job) active = null; + if (received && response !== undefined) resolve(response); + else fail(); + }, { once: true }); + (worker as Worker & { unref(): void }).unref(); + try { worker.postMessage({ kind: "lookup" }); } + catch { fail(); } + return job; + }; + return (timeoutMs, clock = timing) => { + const deadline = clock.now() + timeoutMs; + return new Promise((resolve, reject) => { + let settled = false, cancel = () => {}; + const finish = (error?: unknown, identity?: NativeWindowsPrincipal | null) => { + if (settled) return; + settled = true; cancel(); + if (error) reject(error); else resolve(identity!); + }; + const expired = () => finish(new Error("Windows native principal lookup timed out.")); + if (timeoutMs <= 0) { expired(); return; } + cancel = clock.schedule(expired, timeoutMs); + if (settled) { cancel(); return; } + try { + const job = active ?? start(); + // Timer callbacks can lag synchronous worker construction or promise delivery. + if (clock.now() >= deadline) expired(); + void job.result.then(identity => { + if (clock.now() >= deadline) expired(); else finish(undefined, identity); + }, error => finish(error)); + } catch (error) { finish(error); } + }); + }; +} + +const nativeLookup = createWindowsNativePrincipalLookup(); +export function resolveWindowsNativePrincipalAsync(timeoutMs: number): Promise { + if (process.platform !== "win32" || process.arch !== "x64") return Promise.resolve(null); + return nativeLookup(timeoutMs); +} diff --git a/src/lib/windows-user-principal.ts b/src/lib/windows-user-principal.ts index 2d46b32fc45..d1110d39b91 100644 --- a/src/lib/windows-user-principal.ts +++ b/src/lib/windows-user-principal.ts @@ -12,18 +12,21 @@ * different account holding the secret or strands the file with no usable ACE. * When the SID cannot be resolved, the caller declines to touch the ACL at all. * - * Budget caveat: callers pass their REMAINING harden budget, which becomes the - * child process timeout. Trusted-executable resolution (a `GetSystemDirectoryW` - * FFI call) and spawn setup happen before that timeout starts, and the async - * timer only arms once `Bun.spawn` returns. Both are small in practice, but the - * lookup is not bounded by the deadline to the microsecond. Tightening that - * would mean passing an absolute deadline through the runner interface. + * Windows x64 reads SID and account name natively. The async path owns one + * Worker through closure, with a separate monotonic deadline per caller. A late + * result cannot enter the successful identity cache. The sync path checks elapsed + * time after its native call; synchronous FFI cannot be interrupted. Unsupported + * architectures retain the trusted, bounded PowerShell helper. */ import { existsSync } from "node:fs"; import { win32 as windowsPath } from "node:path"; import { waitForSubprocessExit } from "./bounded-subprocess"; import { decodeWindowsTextBytes } from "./windows-text"; +import { + resolveWindowsNativePrincipal, resolveWindowsNativePrincipalAsync, + type NativeWindowsPrincipal, +} from "./windows-native-principal"; import { resolveTrustedWindowsPowerShellExe, @@ -31,7 +34,7 @@ import { } from "./windows-elevation"; /** - * Shared ceiling for a full effective-token identity lookup. PowerShell startup can + * Shared ceiling for a full effective-token identity lookup. Fallback PowerShell startup can * legitimately take several seconds on loaded desktops as well as CI, so every caller * that is not spending a smaller pre-existing deadline uses the same #2914-tested budget. */ @@ -133,11 +136,16 @@ export function windowsPrincipalPowerShellCommandForTests(): string[] { } function defaultWindowsPrincipalRunner(timeoutMs: number): WindowsPrincipalLookupResult { + const started = performance.now(); + const native = nativeLookups.sync(); + const remaining = timeoutMs - (performance.now() - started); + if (remaining <= 0) return { success: false, exitCode: null, timedOut: true, stdout: "" }; + if (native) return nativeResult(native); const result = Bun.spawnSync(windowsPrincipalPowerShellCommand(), { stdin: "ignore", stdout: "pipe", stderr: "ignore", - timeout: Math.max(1, timeoutMs), + timeout: Math.max(1, remaining), windowsHide: true, }); return { @@ -154,6 +162,11 @@ function defaultWindowsPrincipalRunner(timeoutMs: number): WindowsPrincipalLooku async function defaultAsyncWindowsPrincipalRunner( timeoutMs: number, ): Promise { + const started = performance.now(); + const native = await nativeLookups.async(timeoutMs); + const remaining = timeoutMs - (performance.now() - started); + if (remaining <= 0) return { success: false, exitCode: null, timedOut: true, stdout: "" }; + if (native) return nativeResult(native); const proc = Bun.spawn(windowsPrincipalPowerShellCommand(), { stdin: "ignore", stdout: "pipe", @@ -164,7 +177,7 @@ async function defaultAsyncWindowsPrincipalRunner( // remove; this lookup holds no such path, and it runs during `ocx start`, where the composed // acceptance cases already measure real startups at up to 38.8s against a bounded watchdog. // Paying two extra seconds per timed-out resolution there buys nothing and costs margin. - const { exitCode, timedOut } = await waitForSubprocessExit(proc, timeoutMs, 0); + const { exitCode, timedOut } = await waitForSubprocessExit(proc, remaining, 0); // `.bytes()` rather than `.text()`, for the same reason as the sync runner above. const stdout: string | Uint8Array = !timedOut && proc.stdout ? await new Response(proc.stdout).bytes().catch(() => new Uint8Array()) @@ -180,6 +193,19 @@ async function defaultAsyncWindowsPrincipalRunner( let principalRunner: WindowsPrincipalRunner = defaultWindowsPrincipalRunner; let asyncPrincipalRunner: AsyncWindowsPrincipalRunner = defaultAsyncWindowsPrincipalRunner; let principalLocaleForTests: string | undefined; +const defaultNativeLookups = { sync: resolveWindowsNativePrincipal, async: resolveWindowsNativePrincipalAsync }; +let nativeLookups = defaultNativeLookups; +let defaultAsyncCallers = 0; +function nativeResult(identity: NativeWindowsPrincipal): WindowsPrincipalLookupResult { + return { success: true, exitCode: 0, timedOut: false, stdout: `${identity.sid}\n${identity.name}` }; +} + +/** Boundary injection; undefined restores production. Existing runner overrides still win. */ +export function setNativeWindowsPrincipalLookupsForTests(next: typeof defaultNativeLookups | null): void { + if (asyncLookupInFlight || defaultAsyncCallers) throw new Error("Cannot replace a native lookup in flight."); + nativeLookups = next ?? defaultNativeLookups; + cachedIdentity = null; +} /** * Decode child stdout the way the rest of this repository already decodes Windows @@ -288,13 +314,16 @@ export function resolveCurrentWindowsPrincipal(timeoutMs: number): string { if (cachedIdentity) return `*${cachedIdentity.sid}`; if (syntheticPrincipalForTests) return syntheticPrincipalForTests; if (timeoutMs <= 0) throw identityError("had no remaining deadline"); + const deadline = performance.now() + timeoutMs; let result: WindowsPrincipalLookupResult; try { result = principalRunner(timeoutMs); } catch { throw identityError("could not start"); } - cachedIdentity = identityFromResult(result); + const identity = identityFromResult(result); + if (performance.now() >= deadline) throw identityError("timed out"); + cachedIdentity = identity; return `*${cachedIdentity.sid}`; } @@ -320,10 +349,9 @@ async function waitForExistingLookup( } /** - * Async counterpart. Concurrent callers share one owned child lookup; a later - * caller may exhaust its own budget without cancelling the lookup owned by the - * first caller. The first caller owns that child and its process timeout; a - * later, longer budget deliberately does not extend an already-running child. + * Native callers share one Worker with independent waits. Fallback/injected + * child runners retain the first caller's owned process timeout; later callers + * may exhaust their own wait without extending or cancelling that child. */ export async function resolveCurrentWindowsPrincipalAsync(timeoutMs: number): Promise { const overridden = hasAsyncRunnerOverride(); @@ -333,6 +361,22 @@ export async function resolveCurrentWindowsPrincipalAsync(timeoutMs: number): Pr if (!overridden && syntheticPrincipalForTests) return syntheticPrincipalForTests; if (timeoutMs <= 0) throw identityError("had no remaining deadline"); + // Native jobs own their Worker through close; each caller owns its own wait/cache admission. + // Keep the old first-owner child timeout contract for explicit runners and ARM64 fallback. + if (!overridden && process.platform === "win32" && process.arch === "x64") { + defaultAsyncCallers += 1; + try { + const deadline = performance.now() + timeoutMs; + let result: WindowsPrincipalLookupResult; + try { result = await defaultAsyncWindowsPrincipalRunner(timeoutMs); } + catch { throw identityError("could not complete the native lookup"); } + const identity = identityFromResult(result); + if (performance.now() >= deadline) throw identityError("timed out"); + cachedIdentity = identity; + return `*${cachedIdentity.sid}`; + } finally { defaultAsyncCallers -= 1; } + } + const lookup = (async (): Promise => { let result: WindowsPrincipalLookupResult; try { @@ -355,7 +399,7 @@ export async function resolveCurrentWindowsPrincipalAsync(timeoutMs: number): Pr export function setWindowsPrincipalRunnerForTests( runner: WindowsPrincipalRunner | null, ): void { - if (asyncLookupInFlight) { + if (asyncLookupInFlight || defaultAsyncCallers) { throw new Error("Cannot replace the Windows principal runner while a lookup is in flight."); } principalRunner = runner ?? defaultWindowsPrincipalRunner; @@ -366,7 +410,7 @@ export function setWindowsPrincipalRunnerForTests( export function setAsyncWindowsPrincipalRunnerForTests( runner: AsyncWindowsPrincipalRunner | null, ): void { - if (asyncLookupInFlight) { + if (asyncLookupInFlight || defaultAsyncCallers) { throw new Error("Cannot replace the Windows principal runner while a lookup is in flight."); } asyncPrincipalRunner = runner ?? defaultAsyncWindowsPrincipalRunner; @@ -386,7 +430,7 @@ export function setAsyncWindowsPrincipalRunnerForTests( * decodes after its runner resolves. */ export function setWindowsPrincipalLocaleForTests(locale: string | null): void { - if (asyncLookupInFlight) { + if (asyncLookupInFlight || defaultAsyncCallers) { throw new Error("Cannot change the Windows principal locale while a lookup is in flight."); } principalLocaleForTests = locale ?? undefined; @@ -395,7 +439,7 @@ export function setWindowsPrincipalLocaleForTests(locale: string | null): void { /** Test seam: clear only process-local principal state. */ export function resetWindowsPrincipalForTests(): void { - if (asyncLookupInFlight) { + if (asyncLookupInFlight || defaultAsyncCallers) { throw new Error("Cannot reset the Windows principal while a lookup is in flight."); } cachedIdentity = null; diff --git a/structure/ops/test-sandbox-cleanup.md b/structure/ops/test-sandbox-cleanup.md index 1caf20bb3d7..d32776995d8 100644 --- a/structure/ops/test-sandbox-cleanup.md +++ b/structure/ops/test-sandbox-cleanup.md @@ -11,6 +11,13 @@ and a null token, bounds UTF-16 decoding, and frees the allocation even when the with a bounded SID read, `LocalFree` and `CloseHandle` on every exit. A cold PowerShell start cannot breach the lookup budget on x64 because no child process starts (#7544). Unsupported FFI retains the bounded trusted PowerShell helper; native API failures refuse. +`src/lib/windows-native-principal.ts` reuses that token reader for the ACL identity pair and +translates its live SID with `LookupAccountSidW`, preserving native UTF-16 account/domain names. +The async resolver runs native work in `src/lib/windows-native-principal-worker.ts`; callers have +independent monotonic deadlines while one shared job stays owned through Worker closure. Expired +results do not enter the identity cache or the ACL timeout memo. The sync resolver rejects an +elapsed budget before caching; unsupported architectures retain the trusted PowerShell helper. +Token, SID allocation and DLL cleanup finish before the Worker posts its result and exits. `USERPROFILE` and `LOCALAPPDATA` never choose this root. Runtime canonicalization/reparse checks and the Bun test lock's required ACL hardening remain separate admission checks. @@ -72,6 +79,7 @@ one promise. After teardown starts only the lifecycle's own abort reason is abso error, including a foreign AbortError, still fails its case. Callers settle that lifecycle before draining producers/reaps and restoring or removing a home. The helper does not replace fixture-specific cleanup or claim OS ACL coverage for synthetic tests. +The response-state stalled-runner child proves queue settlement while its synthetic runner remains held, then releases and drains that work before removing its home. `tests/helpers/fixture-teardown.ts` owns management-auth, OAuth live-update, Codex quota-auto-refresh case roots (`tests/codex-integration/codex-quota-auto-refresh.test.ts`), and Anthropic instance diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index c58da960401..0ffb1c89df7 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1401,6 +1401,7 @@ "management-desktop-compatibility-runtime-routes.test.ts": "server", "management-desktop-compatibility-settings.test.ts": "server", "optional-desktop-upstream.test.ts": "lib", + "windows-native-principal.test.ts": "windows", "opaque-proxy-tunnel.test.ts": "lib", "opaque-proxy-tunnel-protocol.test.ts": "lib", "server-desktop-compatibility-startup.test.ts": "server", diff --git a/tests/helpers/responses-state-never-settling-acl-child.ts b/tests/helpers/responses-state-never-settling-acl-child.ts index 0f83f78ff19..adb1b51d2b7 100644 --- a/tests/helpers/responses-state-never-settling-acl-child.ts +++ b/tests/helpers/responses-state-never-settling-acl-child.ts @@ -15,7 +15,7 @@ import { setPlatformForTests, } from "../../src/lib/windows-secret-acl"; import { setAsyncWindowsPrincipalRunnerForTests } from "../../src/lib/windows-user-principal"; -import { removeTreeWithRetry } from "./remove-tree"; +import { removeTreeAfterBackgroundWork } from "./remove-tree"; type Mode = "principal" | "icacls"; @@ -41,20 +41,33 @@ setPlatformForTests("win32"); setResponseSpillAsyncAclAttemptBudgetForTests(100); setResponseStateByteCapForTests(1_024); +let release!: () => void; +let runnerCalls = 0; +const held = new Promise<{ success: boolean; exitCode: number; timedOut: boolean; stdout: string }>(resolve => { + release = () => resolve({ success: false, exitCode: 1, timedOut: false, stdout: "" }); +}); if (mode === "principal") { - setAsyncWindowsPrincipalRunnerForTests(() => new Promise(() => {})); + setAsyncWindowsPrincipalRunnerForTests(() => { runnerCalls++; return held; }); setAsyncIcaclsRunnerForTests(async () => ({ success: true, exitCode: 0, timedOut: false, stdout: "" })); } else { - setAsyncIcaclsRunnerForTests(() => new Promise(() => {})); + // This case owns the stalled icacls boundary; identity startup must not consume its budget first. + setAsyncWindowsPrincipalRunnerForTests(async () => ({ success: true, exitCode: 0, timedOut: false, + stdout: "S-1-5-21-111-222-333-1001\nFIXTURE\\user" })); + setAsyncIcaclsRunnerForTests(() => { runnerCalls++; return held; }); } -rememberLarge(`resp_never_settling_${mode}_first`); -rememberLarge(`resp_never_settling_${mode}_second`); -await awaitResponseSpillPublicationTailForTests(); - -console.log(JSON.stringify({ - settled: true, - pending: pendingResponseSpillMetricsForTests(), - metrics: responseStateMetrics(), -})); -removeTreeWithRetry(home); +try { + rememberLarge(`resp_never_settling_${mode}_first`); + rememberLarge(`resp_never_settling_${mode}_second`); + await awaitResponseSpillPublicationTailForTests(); + if (runnerCalls === 0) throw new Error(`The ${mode} stall was not reached.`); + console.log(JSON.stringify({ + settled: true, + pending: pendingResponseSpillMetricsForTests(), + metrics: responseStateMetrics(), + })); +} finally { + // Keep the runner unresolved until queue settlement is proved; then release owned work before removal. + release(); + await removeTreeAfterBackgroundWork(home); +} diff --git a/tests/windows/windows-native-principal.test.ts b/tests/windows/windows-native-principal.test.ts new file mode 100644 index 00000000000..987fa854948 --- /dev/null +++ b/tests/windows/windows-native-principal.test.ts @@ -0,0 +1,277 @@ +import { afterEach, expect, spyOn, test } from "bun:test"; +import { dlopen } from "bun:ffi"; +import { win32 } from "node:path"; +import { join } from "node:path"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { + createWindowsNativePrincipalLookup, readWindowsNativePrincipal, resolveWindowsNativePrincipal, + type NativePrincipalTiming, type WindowsNativePrincipalApi, +} from "../../src/lib/windows-native-principal"; +import { + cachedCurrentWindowsIdentity, resetWindowsPrincipalForTests, resolveCurrentWindowsPrincipal, + resolveCurrentWindowsPrincipalAsync, setNativeWindowsPrincipalLookupsForTests, + setWindowsPrincipalRunnerForTests, setAsyncWindowsPrincipalRunnerForTests, +} from "../../src/lib/windows-user-principal"; +import { resolveTrustedWindowsSystemDirectory, resolveTrustedWindowsPowerShellExe } from "../../src/lib/windows-elevation"; +import { + hardenSecretPathAsync, resetHardenedStateForTests, setAsyncIcaclsRunnerForTests, + timedOutSecretPathCountForTests, flushWindowsSecretAclReapsBeforeRemoval, +} from "../../src/lib/windows-secret-acl"; + +const SID = "S-1-5-21-111-222-333-1001", SID_ADDRESS = 8192, STRING_ADDRESS = 4096, TOKEN = 0x1a4n; +const IDENTITY = { sid: SID, name: "EXAMPLE\\Owner" }; +function utf16(target: Uint16Array, text: string) { + for (let i = 0; i < text.length; i++) target[i] = text.charCodeAt(i); +} +function fixture(account = "Owner", domain = "EXAMPLE", sid = SID) { + const freed: number[] = [], closed: bigint[] = []; + let formats = 0, translations = 0; + const api: WindowsNativePrincipalApi = { + openProcessToken(access, output) { expect(access).toBe(8); output[0] = TOKEN; return 1; }, + getTokenInformation(token, kind, buffer, length) { + expect(token).toBe(TOKEN); expect(kind).toBe(1); + new DataView(buffer.buffer).setBigUint64(0, BigInt(SID_ADDRESS), true); length[0] = 44; return 1; + }, + convertSidToStringSid(address, output) { + expect(address).toBe(SID_ADDRESS); formats++; output[0] = BigInt(STRING_ADDRESS); return 1; + }, + readCodeUnit(address, offset) { expect(address).toBe(STRING_ADDRESS); return sid.charCodeAt(offset / 2) || 0; }, + localFree(address) { freed.push(address); }, closeHandle(token) { closed.push(token); }, + lastError: () => 122, + lookupAccountSid(address, name, nameLength, authority, domainLength) { + expect(address).toBe(SID_ADDRESS); translations++; + if (!name) { nameLength[0] = account.length + 1; domainLength[0] = domain.length + 1; return 0; } + expect(authority).not.toBeNull(); utf16(name, account); utf16(authority!, domain); + nameLength[0] = account.length; domainLength[0] = domain.length; return 1; + }, + }; + return { api, freed, closed, formats: () => formats, translations: () => translations }; +} + +afterEach(() => { + setNativeWindowsPrincipalLookupsForTests(null); + setWindowsPrincipalRunnerForTests(null); setAsyncWindowsPrincipalRunnerForTests(null); + resetWindowsPrincipalForTests(); +}); + +for (const [account, domain] of [["Owner", "LOCALHOST"], ["alice", "CORP"], + ["person@example.com", "MicrosoftAccount"], ["테스트", "机器"], ["名𐐀", "" ]]) { + test(`native UTF16 account translation preserves ${domain || "empty domain"}`, () => { + const f = fixture(account, domain); + expect(readWindowsNativePrincipal(f.api)).toEqual({ sid: SID, name: domain ? `${domain}\\${account}` : account }); + expect(f.translations()).toBe(2); expect(f.formats()).toBe(1); + expect(f.freed).toEqual([STRING_ADDRESS]); expect(f.closed).toEqual([TOKEN]); + }); +} + +test("name failures refuse before SID allocation and always close the token", () => { + const breaks: Array<(api: WindowsNativePrincipalApi) => void> = [ + api => { api.lookupAccountSid = () => 1; }, + api => { api.lastError = () => 1332; }, + api => { api.lookupAccountSid = (_sid, _name, length) => { length[0] = 0; return 0; }; }, + api => { api.lookupAccountSid = (_sid, _name, length) => { length[0] = 32_769; return 0; }; }, + api => { api.lookupAccountSid = (_sid, _name, length, _domain, domainLength) => { + length[0] = 2; domainLength[0] = 32_769; return 0; + }; }, + api => { const lookup = api.lookupAccountSid; api.lookupAccountSid = (...args) => args[1] ? 0 : lookup(...args); }, + api => { const lookup = api.lookupAccountSid; api.lookupAccountSid = (...args) => { + const result = lookup(...args); if (args[1]) args[1].fill(65); return result; + }; }, + api => { const lookup = api.lookupAccountSid; api.lookupAccountSid = (...args) => { + const result = lookup(...args); if (args[1]) args[1].fill(0); return result; + }; }, + api => { const lookup = api.lookupAccountSid; api.lookupAccountSid = (...args) => { + const result = lookup(...args); if (args[1]) args[2][0] = args[1].length + 1; return result; + }; }, + ]; + for (const change of breaks) { + const f = fixture(); change(f.api); + expect(() => readWindowsNativePrincipal(f.api)).toThrow(); + expect(f.formats()).toBe(0); expect(f.freed).toEqual([]); expect(f.closed).toEqual([TOKEN]); + } +}); + +test("SID failures after name translation retain the reused reader's resource cleanup", () => { + for (const sid of ["S-1-", "1".repeat(300)]) { + const f = fixture("Owner", "EXAMPLE", sid); + expect(() => readWindowsNativePrincipal(f.api)).toThrow(); + expect(f.freed).toEqual([STRING_ADDRESS]); expect(f.closed).toEqual([TOKEN]); + } + const f = fixture(); f.api.getTokenInformation = () => 0; + expect(() => readWindowsNativePrincipal(f.api)).toThrow(); + expect(f.translations()).toBe(0); expect(f.closed).toEqual([TOKEN]); +}); + +test("unsupported native execution is explicit; trusted path and DLL errors never downgrade", () => { + let opened = 0, closed = 0; + const load = (() => { opened++; return { symbols: {}, close() { closed++; } }; }) as unknown as typeof dlopen; + const deps = { platform: "win32" as NodeJS.Platform, arch: "arm64", systemDirectory: () => "C:\\OS\\System32", load }; + expect(resolveWindowsNativePrincipal(deps)).toBeNull(); expect(opened).toBe(0); + expect(() => resolveWindowsNativePrincipal({ ...deps, arch: "x64", systemDirectory: () => { throw new Error("trusted failure"); } })) + .toThrow("trusted failure"); + expect(opened).toBe(0); + const failed = ((path: string) => { + if (path.endsWith("kernel32.dll")) throw new Error("second DLL failed"); + return { symbols: {}, close() { closed++; } }; + }) as unknown as typeof dlopen; + expect(() => resolveWindowsNativePrincipal({ ...deps, arch: "x64", load: failed })).toThrow("second DLL failed"); + expect(closed).toBe(1); +}); + +class HeldWorker extends EventTarget { + sent = 0; + unref() {} + postMessage() { this.sent++; } + reply(data: unknown) { this.dispatchEvent(new MessageEvent("message", { data })); } + close() { this.dispatchEvent(new Event("close")); } +} +function clock() { + let now = 0; + const pending = new Set<{ at: number; callback: () => void }>(); + const timing: NativePrincipalTiming = { + now: () => now, + schedule(callback, ms) { const timer = { at: now + ms, callback }; pending.add(timer); return () => { pending.delete(timer); }; }, + }; + return { timing, advance(ms: number) { now += ms; for (const t of [...pending]) if (t.at <= now) t.callback(); }, + elapseWithoutTimers(ms: number) { now += ms; } }; +} + +test("expired A and live B share one Worker through native completion and closure", async () => { + const worker = new HeldWorker(), c = clock(); let created = 0; + const lookup = createWindowsNativePrincipalLookup(() => { created++; return worker as unknown as Worker; }); + const a = lookup(10, c.timing).catch(error => error), b = lookup(100, c.timing); + let progressed = false; await Promise.resolve().then(() => { progressed = true; }); + expect(progressed).toBe(true); expect(worker.sent).toBe(1); + c.advance(10); expect(await a).toBeInstanceOf(Error); + const later = lookup(100, c.timing); expect(created).toBe(1); + worker.reply({ kind: "identity", ...IDENTITY }); worker.close(); + expect(await b).toEqual(IDENTITY); expect(await later).toEqual(IDENTITY); +}); + +test("timer delivery lag does not admit an expired native result", async () => { + const worker = new HeldWorker(), c = clock(); + const lookup = createWindowsNativePrincipalLookup(() => worker as unknown as Worker); + const result = lookup(10, c.timing).catch(error => error); + c.elapseWithoutTimers(11); worker.reply({ kind: "identity", ...IDENTITY }); worker.close(); + expect(await result).toBeInstanceOf(Error); +}); + +test("worker constructor, malformed messages, errors and early closure refuse", async () => { + await expect(createWindowsNativePrincipalLookup(() => { throw new Error("constructor failed"); })(100)) + .rejects.toThrow("constructor failed"); + for (const message of [undefined, { kind: "failure" }, { kind: "identity", sid: SID }, { kind: "other" }]) { + const worker = new HeldWorker(); const lookup = createWindowsNativePrincipalLookup(() => worker as unknown as Worker); + const result = lookup(100).catch(error => error); + if (message !== undefined) worker.reply(message); worker.close(); + expect(await result).toBeInstanceOf(Error); + } + const worker = new HeldWorker(), lookup = createWindowsNativePrincipalLookup(() => worker as unknown as Worker); + const result = lookup(100).catch(error => error); worker.dispatchEvent(new Event("error")); worker.close(); + expect(await result).toBeInstanceOf(Error); +}); + +test.if(process.platform === "win32" && process.arch === "x64")("expired default caller never caches a late native result", async () => { + const worker = new HeldWorker(), c = clock(); + const lookup = createWindowsNativePrincipalLookup(() => worker as unknown as Worker); + setNativeWindowsPrincipalLookupsForTests({ sync: () => IDENTITY, async: ms => lookup(ms, c.timing) }); + const expired = resolveCurrentWindowsPrincipalAsync(10).catch(error => error); + c.advance(10); expect((await expired).code).toBe("EACLIDENTITY"); + expect(cachedCurrentWindowsIdentity()).toBeNull(); + worker.reply({ kind: "identity", ...IDENTITY }); worker.close(); await Promise.resolve(); + expect(cachedCurrentWindowsIdentity()).toBeNull(); +}); + +test("injected principal runners beat native lookup and native failures are uncached without PowerShell", () => { + let calls = 0; + setNativeWindowsPrincipalLookupsForTests({ sync: () => { calls++; throw new Error("native failed"); }, async: async () => null }); + setWindowsPrincipalRunnerForTests(() => ({ success: true, exitCode: 0, timedOut: false, stdout: `${SID}\nINJECTED\\user` })); + expect(resolveCurrentWindowsPrincipal(100)).toBe(`*${SID}`); expect(calls).toBe(0); + setWindowsPrincipalRunnerForTests(null); + const spawn = spyOn(Bun, "spawnSync").mockImplementation(() => { throw new Error("fallback must not start"); }); + try { + for (let i = 0; i < 2; i++) { + let error: unknown; try { resolveCurrentWindowsPrincipal(100); } catch (e) { error = e; } + expect((error as NodeJS.ErrnoException).code).toBe("EACLIDENTITY"); + expect(cachedCurrentWindowsIdentity()).toBeNull(); + } + expect(calls).toBe(2); expect(spawn).not.toHaveBeenCalled(); + } finally { spawn.mockRestore(); } +}); + +test("explicit native unavailability retains the trusted helper", () => { + setNativeWindowsPrincipalLookupsForTests({ sync: () => null, async: async () => null }); + // Override only the OS process boundary, leaving the existing executable resolver intact on Windows. + if (process.platform !== "win32") return; + const spawn = spyOn(Bun, "spawnSync").mockReturnValue({ + success: true, exitCode: 0, stdout: Buffer.from(`${SID}\nHELPER\\user`), exitedDueToTimeout: false, + } as ReturnType); + try { + expect(resolveCurrentWindowsPrincipal(1000)).toBe(`*${SID}`); + expect(cachedCurrentWindowsIdentity()?.name).toBe("HELPER\\user"); expect(spawn).toHaveBeenCalledTimes(1); + } finally { spawn.mockRestore(); } +}); + +test("a synchronous native overrun refuses before cache admission", () => { + setNativeWindowsPrincipalLookupsForTests({ sync: () => IDENTITY, async: async () => IDENTITY }); + let reads = 0; + const now = spyOn(performance, "now").mockImplementation(() => reads++ === 0 ? 0 : 101); + try { + let error: unknown; try { resolveCurrentWindowsPrincipal(100); } catch (e) { error = e; } + expect((error as NodeJS.ErrnoException).code).toBe("EACLIDENTITY"); + expect(cachedCurrentWindowsIdentity()).toBeNull(); + } finally { now.mockRestore(); } +}); + +test.if(process.platform === "win32" && process.arch === "x64")("async native unavailable fallback and injected runner retain precedence", async () => { + let nativeCalls = 0; + setNativeWindowsPrincipalLookupsForTests({ sync: () => null, async: async () => { nativeCalls++; return null; } }); + setAsyncWindowsPrincipalRunnerForTests(async () => ({ success: true, exitCode: 0, timedOut: false, stdout: `${SID}\nINJECTED\\async` })); + expect(await resolveCurrentWindowsPrincipalAsync(1000)).toBe(`*${SID}`); expect(nativeCalls).toBe(0); + setAsyncWindowsPrincipalRunnerForTests(null); + const spawn = spyOn(Bun, "spawn"); + try { + expect(await resolveCurrentWindowsPrincipalAsync(5000)).toMatch(/^\*S-1-/); + expect(nativeCalls).toBe(1); expect(spawn).toHaveBeenCalledTimes(1); + } finally { spawn.mockRestore(); } +}); + +test.if(process.platform === "win32" && process.arch === "x64")("native caller expiry stays out of the ACL timeout memo and never starts icacls", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-native-identity-")), target = join(root, "secret"); + writeFileSync(target, "fixture"); + const worker = new HeldWorker(), c = clock(); let entered!: () => void, icacls = 0; + const started = new Promise(resolve => { entered = resolve; }); + const lookup = createWindowsNativePrincipalLookup(() => { entered(); return worker as unknown as Worker; }); + setNativeWindowsPrincipalLookupsForTests({ sync: () => IDENTITY, async: ms => lookup(ms, c.timing) }); + setAsyncIcaclsRunnerForTests(async () => { icacls++; return { success: true, exitCode: 0, timedOut: false, stdout: "" }; }); + resetHardenedStateForTests(); + const work = hardenSecretPathAsync(target, { required: true, deadlineMs: 1000 }).catch(error => error); + try { + await started; c.advance(1001); + expect((await work).code).toBe("EACLIDENTITY"); expect(icacls).toBe(0); + expect(cachedCurrentWindowsIdentity()).toBeNull(); expect(timedOutSecretPathCountForTests()).toBe(0); + worker.reply({ kind: "identity", ...IDENTITY }); worker.close(); await Promise.resolve(); + expect(cachedCurrentWindowsIdentity()).toBeNull(); + } finally { + worker.close(); await work; await flushWindowsSecretAclReapsBeforeRemoval(root); + setAsyncIcaclsRunnerForTests(null); resetHardenedStateForTests(); rmSync(root, { recursive: true, force: true }); + } +}); + +test.if(process.platform === "win32" && process.arch === "x64")("native SID/name match independent Unicode PowerShell and DLLs close", () => { + const control = Bun.spawnSync([resolveTrustedWindowsPowerShellExe(), "-NoProfile", "-NonInteractive", "-Command", + "[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false);$i=[Security.Principal.WindowsIdentity]::GetCurrent();$i.User.Value;$i.Name"], + { stdin: "ignore", stdout: "pipe", stderr: "ignore", windowsHide: true, timeout: 30_000 }); + expect(control.success).toBe(true); + const [sid, name] = new TextDecoder().decode(control.stdout).trim().split(/\r?\n/); + const closed: string[] = []; + const load = ((...args: Parameters) => { + const library = dlopen(...args), close = library.close; + return { ...library, symbols: library.symbols, close() { closed.push(String(args[0])); close(); } }; + }) as typeof dlopen; + const directory = resolveTrustedWindowsSystemDirectory(); + const identity = resolveWindowsNativePrincipal({ platform: "win32", arch: "x64", systemDirectory: () => directory, load }); + // Equality booleans keep real account identifiers out of a failing assertion's diff. + expect(identity?.sid === sid).toBe(true); expect(identity?.name === name).toBe(true); + expect(closed).toEqual([win32.join(directory, "kernel32.dll"), win32.join(directory, "advapi32.dll")]); +}); diff --git a/tests/windows/windows-user-principal.test.ts b/tests/windows/windows-user-principal.test.ts index b67bbbafa3c..df707817e3c 100644 --- a/tests/windows/windows-user-principal.test.ts +++ b/tests/windows/windows-user-principal.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { cachedCurrentWindowsIdentity, @@ -30,6 +30,30 @@ afterEach(() => { }); describe("Windows effective ACL principal", () => { + test.if(process.platform === "win32" && process.arch === "x64")( + "an uncached native ACL principal needs no PowerShell child (#7547)", () => { + resetWindowsPrincipalForTests(); + const spawn = spyOn(Bun, "spawnSync").mockImplementation(() => { throw new Error("PowerShell must not start"); }); + try { + expect(resolveCurrentWindowsPrincipal(5_000)).toMatch(/^\*S-1-/); + expect(Boolean(cachedCurrentWindowsIdentity()?.name)).toBe(true); + expect(spawn).not.toHaveBeenCalled(); + } finally { spawn.mockRestore(); } + }, + ); + + test.if(process.platform === "win32" && process.arch === "x64")( + "an uncached async native ACL principal needs no PowerShell child (#7547)", async () => { + resetWindowsPrincipalForTests(); + const spawn = spyOn(Bun, "spawn").mockImplementation(() => { throw new Error("PowerShell must not start"); }); + try { + expect(await resolveCurrentWindowsPrincipalAsync(5_000)).toMatch(/^\*S-1-/); + expect(Boolean(cachedCurrentWindowsIdentity()?.name)).toBe(true); + expect(spawn).not.toHaveBeenCalled(); + } finally { spawn.mockRestore(); } + }, + ); + test("builds a non-interactive command without the Bun-incompatible PowerShell window flag", () => { const trusted = "C:\\trusted-system32\\WindowsPowerShell\\v1.0\\powershell.exe"; setTrustedWindowsElevationExecutablesForTests({ powershell: trusted });