diff --git a/apps/gateway/src/cli.ts b/apps/gateway/src/cli.ts index facdd1d..e173a31 100755 --- a/apps/gateway/src/cli.ts +++ b/apps/gateway/src/cli.ts @@ -13,6 +13,7 @@ import { publicOriginHttpsPort, restoreGatewayConfigFile, rotatePublisherToken, + windowsAclSpawnCostMs, writeGatewayConfigFile, } from "./config.ts"; import { createDiagnosticsBundle } from "./diagnostics.ts"; @@ -191,13 +192,32 @@ async function runServe(arguments_: ParsedArguments): Promise { } } +const READINESS_BASE_MS = 15_000; +const READINESS_ACL_ALLOWANCE_CEILING_MS = 30_000; + +/** + * Loopback readiness budget for a freshly started service. + * + * The base covers a healthy host. On Windows the daemon must start one `powershell.exe` to enforce + * ACLs on its private paths before it can bind, and that start measured 1762-2132 ms on a 2-vCPU + * Windows Server 2025 host and has been recorded far higher under contention (#90). `install` paid + * for the same start in this process while writing the config, so the observed cost is the evidence + * the budget is extended by - exactly one start, because exactly one remains on the daemon's path - + * and the extension is capped so a genuinely broken install still fails in bounded time. + */ +function readinessBudgetMs(): number { + const observed = windowsAclSpawnCostMs(); + if (observed === undefined) return READINESS_BASE_MS; + return READINESS_BASE_MS + Math.min(Math.round(observed), READINESS_ACL_ALLOWANCE_CEILING_MS); +} + async function waitForGateway( config: Awaited>, readinessToken: string, readinessInstance?: string, requireManagedService = false, ): Promise { - const deadline = Date.now() + 15_000; + const deadline = Date.now() + readinessBudgetMs(); while (Date.now() < deadline) { if (await gatewayReady(config, readinessToken, readinessInstance)) { if (!requireManagedService) return; diff --git a/apps/gateway/src/config.ts b/apps/gateway/src/config.ts index e14d671..5f7f2a8 100644 --- a/apps/gateway/src/config.ts +++ b/apps/gateway/src/config.ts @@ -55,57 +55,236 @@ function currentUserId(): number { return uid; } -function windowsPowerShellEnvironment(overrides: Record): Record { +/** + * Environment for the ACL helper. `PSModulePath` is dropped so a writable module directory inherited + * from the caller cannot inject code into the helper; every other variable is inherited because + * `powershell.exe` needs `SystemRoot` and friends to start at all. + */ +function windowsPowerShellEnvironment(): Record { const environment: Record = {}; for (const [key, value] of Object.entries(process.env)) { if (value !== undefined && key.toLowerCase() !== "psmodulepath") environment[key] = value; } - return { ...environment, ...overrides }; + return environment; +} + +/** + * Newline-delimited JSON request loop over `Get-Acl`/`Set-Acl`. + * + * Starting `powershell.exe` measured 1.8-2.1 s on a 2-vCPU Windows Server 2025 host, and the daemon + * secures or verifies five private paths before it can bind its loopback listener, so one process per + * path consumed more than the whole `install` readiness budget and every install was torn back down + * (#90). One process answers every request of a run instead. + * + * Paths arrive as JSON data and are never spliced into the script, so a hostile path cannot become + * code. Each reply carries its request id back, so a failure can only be attributed to the path that + * produced it. Reply text is squeezed to printable ASCII because a redirected PowerShell writes + * stdout in the console code page, which would otherwise corrupt the framing. + */ +const WINDOWS_ACL_HELPER_SCRIPT = [ + "$ErrorActionPreference='Stop'", + "$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value", + "$out=[Console]::Out", + "while($true){ $line=[Console]::In.ReadLine(); if($null -eq $line){break}; if($line.Length -eq 0){continue}; " + + "$id=0; try{ $request=$line|ConvertFrom-Json; $id=[int]$request.i; $path=[string]$request.p; " + + "if($request.dir -eq 1){$flags='OICI'}else{$flags=''}; " + + "if($request.op -eq 'apply'){ " + + "$sddl='D:P(A;'+$flags+';FA;;;SY)(A;'+$flags+';FA;;;'+$sid+')'; " + + "$acl=Get-Acl -LiteralPath $path; $acl.SetSecurityDescriptorSddlForm($sddl); " + + "$acl.SetOwner([System.Security.Principal.SecurityIdentifier]::new($sid)); " + + "Set-Acl -LiteralPath $path -AclObject $acl; $reply=[pscustomobject]@{i=$id;ok=$true} " + + "}elseif($request.op -eq 'inspect'){ " + + "$acl=Get-Acl -LiteralPath $path; $owner=$acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value; " + + "$descriptor=[System.Security.AccessControl.RawSecurityDescriptor]::new($acl.Sddl); " + + "$rules=@($descriptor.DiscretionaryAcl | ForEach-Object { [pscustomobject]@{ " + + "Sid=$_.SecurityIdentifier.Value; Type=$_.AceType.ToString(); Mask=$_.AccessMask; Flags=[int]$_.AceFlags } }); " + + "$reply=[pscustomobject]@{i=$id;ok=$true;acl=[pscustomobject]@{ " + + "Protected=$acl.AreAccessRulesProtected; Current=$sid; Owner=$owner; Rules=@($rules) }} " + + "}else{ throw 'unsupported private ACL operation' } " + + "}catch{ $reply=[pscustomobject]@{i=$id;ok=$false;e=([string]$_.Exception.Message -replace '[^\\x20-\\x7E]','?')} }; " + + "$out.WriteLine(($reply|ConvertTo-Json -Compress -Depth 6)); $out.Flush() }", +].join("; "); + +interface WindowsAclHelper { + readonly send: (line: string) => Promise; + readonly receive: () => Promise; + readonly kill: () => void; + readonly drainStderr: () => Promise; + readonly decoder: TextDecoder; + buffer: string; + nextRequestId: number; + spawnCostMs: number | undefined; +} + +let windowsAclHelper: WindowsAclHelper | undefined; +let windowsAclHelperSpawnCostMs: number | undefined; +let windowsAclRequestTail: Promise = Promise.resolve(); + +/** + * JSON restricted to printable ASCII, so the helper's stdin code page cannot mangle a path. Matching + * per UTF-16 code unit is deliberate: each surrogate half becomes its own escape, which + * `ConvertFrom-Json` recombines into the original code point. + */ +function asciiJson(value: unknown): string { + return JSON.stringify(value).replace( + /[^\x20-\x7E]/g, + character => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`, + ); +} + +// A helper that accepts a request and never answers would otherwise block this check forever. During +// `install` the readiness budget bounds that, but a directly-run `serve` has no such bound, so the +// wait is capped here and reported as a timeout rather than as a hang with no diagnostic. +const WINDOWS_ACL_REPLY_TIMEOUT_MS = 20_000; + +function startWindowsAclHelper(): WindowsAclHelper { + const subprocess = Bun.spawn( + ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", WINDOWS_ACL_HELPER_SCRIPT], + { env: windowsPowerShellEnvironment(), stdin: "pipe", stdout: "pipe", stderr: "pipe" }, + ); + // The helper must never hold the daemon's event loop open. It ends by itself: closing our stdin at + // exit makes its `ReadLine` return null and the loop break. + subprocess.unref(); + const stdout = subprocess.stdout.getReader(); + const stderr = subprocess.stderr as ReadableStream; + return { + send: async line => { + subprocess.stdin.write(line); + await subprocess.stdin.flush(); + }, + receive: async () => (await stdout.read()).value, + kill: () => subprocess.kill(), + // Read only after the helper has been killed, never as a standing background task. An + // open-ended read of this pipe is not free: a pending read keeps the process alive even though + // the child is unref'd, which hung `bun test` on Windows for 23 minutes until CI cancelled it. + // Killing first makes the pipe EOF, so this returns promptly; the race is a backstop only. + drainStderr: async () => { + const collected = await Promise.race([ + new Response(stderr).text().catch(() => ""), + new Promise(resolve => setTimeout(() => resolve(""), 250).unref?.()), + ]); + return collected.trim().slice(0, 500); + }, + decoder: new TextDecoder(), + buffer: "", + nextRequestId: 1, + spawnCostMs: undefined, + }; +} + +function stopWindowsAclHelper(): void { + const helper = windowsAclHelper; + windowsAclHelper = undefined; + if (helper === undefined) return; + try { + helper.kill(); + } catch { + // Already gone; there is nothing left to reclaim. + } +} + +async function readWindowsAclReply(helper: WindowsAclHelper): Promise { + const deadline = Date.now() + WINDOWS_ACL_REPLY_TIMEOUT_MS; + for (;;) { + const newline = helper.buffer.indexOf("\n"); + if (newline >= 0) { + const line = helper.buffer.slice(0, newline).trim(); + helper.buffer = helper.buffer.slice(newline + 1); + if (line.length > 0) return line; + continue; + } + const remaining = deadline - Date.now(); + // The caller attaches the helper's stderr; these messages stay plain so there is one place that + // decides how a failure is reported. + if (remaining <= 0) throw new Error("the private Windows ACL helper did not reply in time"); + const chunk = await Promise.race([ + helper.receive(), + new Promise<"timeout">(resolve => setTimeout(() => resolve("timeout"), remaining).unref?.()), + ]); + if (chunk === "timeout") throw new Error("the private Windows ACL helper did not reply in time"); + if (chunk === undefined) throw new Error("the private Windows ACL helper exited before replying"); + helper.buffer += helper.decoder.decode(chunk, { stream: true }); + } +} + +async function performWindowsAclRequest( + operation: "apply" | "inspect", + path: string, + directory: boolean, +): Promise { + const started = performance.now(); + let requestId = 0; + let reply: unknown; + let active: WindowsAclHelper | undefined; + try { + const helper = (windowsAclHelper ??= startWindowsAclHelper()); + active = helper; + requestId = helper.nextRequestId; + helper.nextRequestId += 1; + await helper.send(`${asciiJson({ i: requestId, op: operation, p: path, dir: directory ? 1 : 0 })}\n`); + reply = JSON.parse(await readWindowsAclReply(helper)); + if (helper.spawnCostMs === undefined) { + // Only the first round trip pays for `powershell.exe` starting; the rest are pipe writes. + helper.spawnCostMs = performance.now() - started; + windowsAclHelperSpawnCostMs = Math.max(windowsAclHelperSpawnCostMs ?? 0, helper.spawnCostMs); + } + } catch (error) { + // Kill first so the helper's stderr pipe reaches EOF, then read it. Reading before the kill + // would block on a live pipe, and reading it continuously in the background keeps the process + // alive even with the child unref'd. + stopWindowsAclHelper(); + const captured = active === undefined ? "" : await active.drainStderr(); + const reason = error instanceof Error ? error.message : String(error); + const suffix = captured.length > 0 && !reason.includes(captured) ? `: ${captured}` : ""; + throw new Error(`the private Windows ACL helper failed for ${path}: ${reason}${suffix}`, { cause: error }); + } + const envelope = typeof reply === "object" && reply !== null ? reply : undefined; + if (envelope === undefined || Reflect.get(envelope, "i") !== requestId) { + // Answering the wrong request would clear the wrong path, so a desynchronised helper is fatal. + stopWindowsAclHelper(); + throw new Error(`the private Windows ACL helper answered out of order for ${path}`); + } + if (Reflect.get(envelope, "ok") !== true) { + const failure = Reflect.get(envelope, "e"); + const detail = typeof failure === "string" && failure.length > 0 ? failure : "unknown helper failure"; + throw new Error( + operation === "apply" + ? `failed to secure private Windows path ${path}: ${detail}` + : `failed to inspect private Windows ACL for ${path}: ${detail}`, + ); + } + return Reflect.get(envelope, "acl"); +} + +/** + * Requests are strictly serialised: the helper answers one line per line it reads, so overlapping + * writers would race for each other's replies. + */ +function windowsAclRequest(operation: "apply" | "inspect", path: string, directory: boolean): Promise { + const attempt = windowsAclRequestTail + .catch(() => undefined) + .then(() => performWindowsAclRequest(operation, path, directory)); + windowsAclRequestTail = attempt.catch(() => undefined); + return attempt; +} + +/** + * Cost of the single `powershell.exe` start this process paid for ACL enforcement, or `undefined` + * when no ACL work happened (every non-Windows platform). `install` derives its readiness budget + * from it, because the daemon it starts must pay the same start before it can bind. + */ +export function windowsAclSpawnCostMs(): number | undefined { + return windowsAclHelperSpawnCostMs; } async function applyWindowsAcl(path: string, directory: boolean): Promise { if (process.platform !== "win32") return; - const script = - "$Path=$env:OMP_GATEWAY_ACL_PATH; $Directory=$env:OMP_GATEWAY_ACL_DIRECTORY; " + - "$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value; " + - "$flags=if($Directory -eq '1'){'OICI'}else{''}; " + - "$sddl='D:P(A;'+$flags+';FA;;;SY)(A;'+$flags+';FA;;;'+$sid+')'; " + - "$acl=Get-Acl -LiteralPath $Path; " + - "$acl.SetSecurityDescriptorSddlForm($sddl); " + - "$acl.SetOwner([System.Security.Principal.SecurityIdentifier]::new($sid)); Set-Acl -LiteralPath $Path -AclObject $acl"; - const subprocess = Bun.spawn(["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script], { - env: windowsPowerShellEnvironment({ - OMP_GATEWAY_ACL_PATH: path, - OMP_GATEWAY_ACL_DIRECTORY: directory ? "1" : "0", - }), - stdin: "ignore", - stdout: "ignore", - stderr: "pipe", - }); - const stderr = await new Response(subprocess.stderr).text(); - if ((await subprocess.exited) !== 0) throw new Error(`failed to secure private Windows path: ${stderr.trim()}`); + await windowsAclRequest("apply", path, directory); } async function assertWindowsAclPrivate(path: string, directory: boolean): Promise { if (process.platform !== "win32") return; - const script = - "$Path=$env:OMP_GATEWAY_ACL_PATH; $acl=Get-Acl -LiteralPath $Path; " + - "$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value; " + - "$owner=$acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value; " + - "$descriptor=[System.Security.AccessControl.RawSecurityDescriptor]::new($acl.Sddl); " + - "$rules=@($descriptor.DiscretionaryAcl | ForEach-Object { [pscustomobject]@{ " + - "Sid=$_.SecurityIdentifier.Value; Type=$_.AceType.ToString(); Mask=$_.AccessMask; Flags=[int]$_.AceFlags } }); " + - "[pscustomobject]@{ Protected=$acl.AreAccessRulesProtected; Current=$sid; Owner=$owner; Rules=@($rules) } " + - "| ConvertTo-Json -Compress -Depth 3"; - const subprocess = Bun.spawn(["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script], { - env: windowsPowerShellEnvironment({ OMP_GATEWAY_ACL_PATH: path }), - stdin: "ignore", - stdout: "pipe", - stderr: "ignore", - }); - const text = await new Response(subprocess.stdout).text(); - if ((await subprocess.exited) !== 0) throw new Error("failed to inspect private Windows ACL"); - const value: unknown = JSON.parse(text); + const value = await windowsAclRequest("inspect", path, directory); const protectedAcl = typeof value === "object" && value !== null ? Reflect.get(value, "Protected") : undefined; const current = typeof value === "object" && value !== null ? Reflect.get(value, "Current") : undefined; const owner = typeof value === "object" && value !== null ? Reflect.get(value, "Owner") : undefined; @@ -142,7 +321,7 @@ async function assertWindowsAclPrivate(path: string, directory: boolean): Promis }) : "not-array"; throw new Error( - `unsafe private Windows ACL (protected=${String(protectedAcl)}, ownerMatches=${String(owner === current)}, currentIsSystem=${String(current === "S-1-5-18")}, rules=${JSON.stringify(ruleDiagnostics)})`, + `unsafe private Windows ACL for ${path} (protected=${String(protectedAcl)}, ownerMatches=${String(owner === current)}, currentIsSystem=${String(current === "S-1-5-18")}, rules=${JSON.stringify(ruleDiagnostics)})`, ); } } diff --git a/apps/gateway/test/config.test.ts b/apps/gateway/test/config.test.ts index 174f041..f3c5aa5 100644 --- a/apps/gateway/test/config.test.ts +++ b/apps/gateway/test/config.test.ts @@ -11,6 +11,7 @@ import { publicOriginHttpsPort, restoreGatewayConfigFile, rotatePublisherToken, + windowsAclSpawnCostMs, } from "../src/config.ts"; const roots: string[] = []; @@ -82,6 +83,144 @@ async function makeFixtureUnsafe(path: string): Promise { if ((await subprocess.exited) !== 0) throw new Error(`failed to loosen test fixture ACL: ${stderr.trim()}`); } +const FAKE_CURRENT_SID = "S-1-5-21-2000000000-2000000001-2000000002-1001"; +const FULL_CONTROL_MASK = 2_032_127; + +type FakeAclOutcome = "private" | "foreign-principal" | "missing"; + +const fakeAcl = { + spawns: 0, + requests: [] as string[], + outcomes: new Map(), + desynchronise: false, + stderr: "", + exitBeforeReply: false, +}; + +function answerFakeAclRequest(line: string): string { + const request = JSON.parse(line) as { readonly i: number; readonly op: string; readonly p: string; readonly dir: number }; + fakeAcl.requests.push(`${request.op} ${request.p}`); + const identifier = fakeAcl.desynchronise ? request.i + 1 : request.i; + const outcome = fakeAcl.outcomes.get(request.p) ?? "private"; + if (outcome === "missing") { + return JSON.stringify({ i: identifier, ok: false, e: `Cannot find path '${request.p}' because it does not exist.` }); + } + if (request.op === "apply") return JSON.stringify({ i: identifier, ok: true }); + const flags = request.dir === 1 ? 3 : 0; + const rules = [ + { Sid: "S-1-5-18", Type: "AccessAllowed", Mask: FULL_CONTROL_MASK, Flags: flags }, + { Sid: FAKE_CURRENT_SID, Type: "AccessAllowed", Mask: FULL_CONTROL_MASK, Flags: flags }, + ]; + // Mirrors `icacls /grant *S-1-1-0:F`, which is how the real fixtures above are loosened. + if (outcome === "foreign-principal") { + rules.push({ Sid: "S-1-1-0", Type: "AccessAllowed", Mask: FULL_CONTROL_MASK, Flags: flags }); + } + return JSON.stringify({ + i: identifier, + ok: true, + acl: { Protected: true, Current: FAKE_CURRENT_SID, Owner: FAKE_CURRENT_SID, Rules: rules }, + }); +} + +/** + * Stands in for `powershell.exe` speaking the ACL helper's newline-delimited JSON protocol, so the + * Windows-only contract can be exercised on every platform. Spawns of anything else pass through. + * Returns the restore function; call it in a `finally` so a failure cannot leak the fake platform. + */ +function installFakePowerShell(): () => void { + const realSpawn = Bun.spawn; + const realPlatform = process.platform; + const setPlatform = (value: string): void => { + Object.defineProperty(process, "platform", { value, writable: true, configurable: true, enumerable: true }); + }; + const fakeSpawn = (command: readonly string[], options?: unknown): unknown => { + if (command[0] !== "powershell.exe") { + return (realSpawn as unknown as (used: readonly string[], rest?: unknown) => unknown)(command, options); + } + fakeAcl.spawns += 1; + const encoder = new TextEncoder(); + const pending: Uint8Array[] = []; + let waiting: ((result: { value?: Uint8Array; done: boolean }) => void) | undefined; + let stdin = ""; + return { + stdin: { + write: (chunk: string): number => { + stdin += chunk; + return chunk.length; + }, + flush: (): number => { + for (;;) { + const newline = stdin.indexOf("\n"); + if (newline < 0) return 0; + const line = stdin.slice(0, newline); + stdin = stdin.slice(newline + 1); + // A helper that died during start-up consumes the request and answers nothing; its + // stdout closes instead, which the reader below reports as EOF. + if (fakeAcl.exitBeforeReply) { + const closed = waiting; + waiting = undefined; + closed?.({ done: true }); + continue; + } + const reply = encoder.encode(`${answerFakeAclRequest(line)}\n`); + const resolve = waiting; + waiting = undefined; + if (resolve === undefined) pending.push(reply); + else resolve({ value: reply, done: false }); + } + }, + }, + stdout: { + getReader: () => ({ + read: async (): Promise<{ value?: Uint8Array; done: boolean }> => { + const next = pending.shift(); + if (next !== undefined) return { value: next, done: false }; + // A helper that dies without answering closes its stdout, which the reader sees as EOF. + if (fakeAcl.exitBeforeReply) return { done: true }; + const gate = Promise.withResolvers<{ value?: Uint8Array; done: boolean }>(); + waiting = gate.resolve; + return await gate.promise; + }, + }), + }, + // The real helper writes start-up and parse failures only to stderr, so the fake has to offer + // the same channel or a test can never observe that they reach the caller. + stderr: new ReadableStream({ + start(controller) { + if (fakeAcl.stderr.length > 0) controller.enqueue(new TextEncoder().encode(fakeAcl.stderr)); + controller.close(); + }, + }), + unref: (): undefined => undefined, + kill: (): undefined => undefined, + }; + }; + Bun.spawn = fakeSpawn as unknown as typeof Bun.spawn; + setPlatform("win32"); + return () => { + Bun.spawn = realSpawn; + setPlatform(realPlatform); + fakeAcl.outcomes.clear(); + fakeAcl.desynchronise = false; + fakeAcl.stderr = ""; + fakeAcl.exitBeforeReply = false; + }; +} + +/** + * Discards whatever helper process an earlier test left cached inside `config.ts`, by answering one + * request with the wrong id. The product treats that desynchronisation as fatal and drops the + * helper, which both pins that guard and makes the following spawn count exact. + */ +async function dropCachedAclHelper(config: GatewayConfig): Promise { + fakeAcl.desynchronise = true; + try { + await expect(loadOrCreatePublisherToken(config)).rejects.toThrow("answered out of order"); + } finally { + fakeAcl.desynchronise = false; + } +} + describe("secure config", () => { test("loads strict production config and normalizes exact allowlist logins", async () => { const root = await privateRoot(); @@ -266,3 +405,111 @@ describe("secure config", () => { await expect(loadOrCreatePublisherToken(config)).rejects.toThrow("invalid encoding or length"); }, 20_000); }); + +describe("Windows private-path ACL enforcement", () => { + test("secures every private path of a run from a single PowerShell process", async () => { + const root = await privateRoot(); + const config = configForRoot(root); + const restore = installFakePowerShell(); + try { + await dropCachedAclHelper(config); + const spawnsBefore = fakeAcl.spawns; + fakeAcl.requests.length = 0; + await loadOrCreatePublisherToken(config); + await loadOrCreatePublisherToken(config); + await rotatePublisherToken(config); + // Each call applies and inspects the config and state directories and touches the token once: + // fifteen ACL operations that used to cost fifteen `powershell.exe` starts. + expect(fakeAcl.requests).toHaveLength(15); + expect(fakeAcl.spawns - spawnsBefore).toBe(1); + // `install` derives its readiness budget from this, so losing the measurement matters. + expect(windowsAclSpawnCostMs()).toBeGreaterThanOrEqual(0); + } finally { + restore(); + } + }); + + test("rejects an ACL that admits a foreign principal and blames the offending path", async () => { + const root = await privateRoot(); + const config = configForRoot(root); + const restore = installFakePowerShell(); + try { + await loadOrCreatePublisherToken(config); + fakeAcl.outcomes.set(config.paths.stateDir, "foreign-principal"); + fakeAcl.requests.length = 0; + const failure = await loadOrCreatePublisherToken(config).then( + () => undefined, + (error: unknown) => error, + ); + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toContain("unsafe private Windows ACL"); + expect((failure as Error).message).toContain(config.paths.stateDir); + expect((failure as Error).message).not.toContain(config.paths.configDir); + // The safe directory ahead of it was verified, and the run stopped at the unsafe one. + expect(fakeAcl.requests).toEqual([ + `apply ${config.paths.configDir}`, + `inspect ${config.paths.configDir}`, + `apply ${config.paths.stateDir}`, + `inspect ${config.paths.stateDir}`, + ]); + } finally { + restore(); + } + }); + + test("rejects a directory the ACL helper cannot resolve, naming the one that failed", async () => { + const root = await privateRoot(); + const config = configForRoot(root); + const restore = installFakePowerShell(); + try { + fakeAcl.outcomes.set(config.paths.stateDir, "missing"); + await expect(loadOrCreatePublisherToken(config)).rejects.toThrow( + `failed to secure private Windows path ${config.paths.stateDir}`, + ); + fakeAcl.outcomes.set(config.paths.configDir, "missing"); + await expect(loadOrCreatePublisherToken(config)).rejects.toThrow( + `failed to secure private Windows path ${config.paths.configDir}`, + ); + } finally { + restore(); + } + }); + + test("treats an unreadable token ACL as fatal instead of a missing token", async () => { + const root = await privateRoot(); + const config = configForRoot(root); + const restore = installFakePowerShell(); + try { + await loadOrCreatePublisherToken(config); + const stored = await readFile(config.paths.tokenPath, "utf8"); + fakeAcl.outcomes.set(config.paths.tokenPath, "missing"); + await expect(loadOrCreatePublisherToken(config)).rejects.toThrow( + `failed to inspect private Windows ACL for ${config.paths.tokenPath}`, + ); + // A helper failure must never be mistaken for ENOENT and remediated by minting a new token. + expect(await readFile(config.paths.tokenPath, "utf8")).toBe(stored); + } finally { + restore(); + } + }); + + test("surfaces the helper's own stderr when it dies before replying", async () => { + const root = await privateRoot(); + const config = configForRoot(root); + const restore = installFakePowerShell(); + try { + // The realistic failure of this design is a helper that never gets as far as its reply loop: + // a bad script, a missing powershell.exe, a blocked execution policy. That cause reaches only + // stderr, so discarding it would reduce every such case to "exited before replying" and leave + // the operator with nothing to act on. + // A helper cached by an earlier assertion would answer from its own queue and never reach the + // start-up path this test is about. + await dropCachedAclHelper(config); + fakeAcl.stderr = "ParserError: unexpected token in expression"; + fakeAcl.exitBeforeReply = true; + await expect(loadOrCreatePublisherToken(config)).rejects.toThrow("ParserError: unexpected token in expression"); + } finally { + restore(); + } + }); +});