diff --git a/docs-site/src/content/docs/troubleshooting/windows-acl-timeouts.md b/docs-site/src/content/docs/troubleshooting/windows-acl-timeouts.md new file mode 100644 index 00000000000..e8d34ca0d17 --- /dev/null +++ b/docs-site/src/content/docs/troubleshooting/windows-acl-timeouts.md @@ -0,0 +1,25 @@ +--- +title: Windows File Permission Timeouts +description: How atomic file writes recover after a temporary icacls stall. +--- + +On Windows, opencodex uses `icacls` to protect temporary files before writing +configuration, credentials, or cached state. If permission hardening fails, the +write fails and the previous destination stays in place. + +A temporary `icacls` timeout does not require a proxy restart to recover. A later +atomic write to the same destination may make one recovery attempt. If that +attempt also times out, further writes to that destination are refused until +five minutes after the recovery attempt started. The next write can then try +hardening again; recovery happens when a write is requested, without a timer. + +If an earlier `icacls` process has outlived its caller's timeout belt and has not +yet been reaped, writes to the same destination are refused, including after +the five-minute window. These refusals return promptly and preserve the next +recovery attempt. Healthy overlapping writes can proceed, including while an +earlier write is hardening its temporary file or waiting to rename it. + +Every recovery attempt must complete permission hardening before publishing the +file. Permission-denied errors remain failures and do not trigger an automatic +retry. Cleanup of a failed write does not consume the next write's recovery +attempt. Other file destinations have independent timeout tracking. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index a61f3c0d32b..f69fb961e18 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1927,6 +1927,7 @@ "win-exec.test.ts": "windows", "win-paths.test.ts": "windows", "windows-acl-start-cost.test.ts": "windows", + "windows-atomic-write-acl-rearm.test.ts": "windows", "windows-atomic-replace.test.ts": "windows", "windows-deploy-close-regressions.test.ts": "windows", "windows-elevation-spawn.test.ts": "windows", diff --git a/src/config/atomic-write.ts b/src/config/atomic-write.ts index 756c8cb13d7..e177229340b 100644 --- a/src/config/atomic-write.ts +++ b/src/config/atomic-write.ts @@ -18,10 +18,12 @@ import { recordOwnedConfigPath } from "../lib/config-ownership"; import { assertNotRealCodexHomeUnderTest, assertNotRealHomeUnderTest } from "../lib/test-home-guard"; import { forgetEphemeralSecretPath, + flushWindowsSecretAclReapsBeforeRemoval, hardenSecretPath, hardenSecretPathAsync, reattributeHardenedSecretPath, windowsSecretAclReapPendingForPath, + WindowsSecretAclSettleTimeoutError, } from "../lib/windows-secret-acl"; import { renameAtomicFile, @@ -30,6 +32,28 @@ import { import { getConfigDir } from "./paths"; let atomicSequence = 0; +const aclDestinations = new Map>(); + +/** Track overlapping writes, refusing only children retained past their timeout belt. */ +function admitAtomicAclWork(target: string, temp: string, enabled: boolean): (() => void) | undefined { + if (!enabled || !windowsHardeningApplies()) return undefined; + const destination = join(realpathSync(dirname(target)), basename(target)); + const key = process.platform === "win32" ? destination.toLowerCase() : destination; + const temps = aclDestinations.get(key) ?? new Set(); + if ([...temps].some(windowsSecretAclReapPendingForPath)) throw new WindowsSecretAclSettleTimeoutError(); + temps.add(temp); + aclDestinations.set(key, temps); + const release = (): void => { + temps.delete(temp); + if (temps.size === 0) aclDestinations.delete(key); + }; + return () => { + if (windowsSecretAclReapPendingForPath(temp)) { + // Caller settlement is bounded; admission resumes only after actual handle release. + void flushWindowsSecretAclReapsBeforeRemoval(temp).then(release); + } else release(); + }; +} /** * Whether this writer's Windows ACL branch applies. @@ -207,7 +231,7 @@ function writePrivateTempFile( try { onCreated(descriptor); if (windowsHardeningApplies()) { - hardenSecretPath(path, { required: true, timeoutMemoKey }); + hardenSecretPath(path, { required: true, timeoutMemoKey, retryTimedOutOnce: true }); } // Keyed on the REAL platform, not the override: on a POSIX host the mode is // the boundary and `assertPrivateTempDescriptor` demands 0o600, which an @@ -248,7 +272,7 @@ function writePrivateTempFileWith( try { onCreated(descriptor); if (windowsHardeningApplies()) { - hardenSecretPath(path, { required: true, timeoutMemoKey }); + hardenSecretPath(path, { required: true, timeoutMemoKey, retryTimedOutOnce: true }); } if (process.platform !== "win32") fchmodSync(descriptor, 0o600); assertPrivateTempDescriptor(path, descriptor, requireStableIdentity); @@ -271,7 +295,7 @@ async function writePrivateTempFileAsync( try { onCreated(descriptor); if (windowsHardeningApplies()) { - await hardenSecretPathAsync(path, { required: true, timeoutMemoKey }); + await hardenSecretPathAsync(path, { required: true, timeoutMemoKey, retryTimedOutOnce: true }); } if (process.platform !== "win32") { fchmodSync(descriptor, 0o600); @@ -297,8 +321,11 @@ function atomicWriteFileToTarget( if (recordOwnership) recordOwnedConfigPath(getConfigDir(), path); assertResolvedTargetAllowed(path, target); const tmp = `${target}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`; + const releaseAcl = admitAtomicAclWork(target, tmp, io === undefined); let hardened = false; let ownsTemp = false; + // Cleanup must not consume the destination's recovery attempt for a later write. + let cleanupHarden = false; let publicationAttempted = false; let identity: BigIntStats | undefined; const created = (descriptor: number): void => { @@ -321,7 +348,7 @@ function atomicWriteFileToTarget( // its ChangeTime bump is what used to invalidate the harden memo one line // later. On POSIX the mode IS the boundary, so it stays. if (windowsHardeningApplies()) { - hardenSecretPath(tempPath, { required: true, timeoutMemoKey: path }); + hardenSecretPath(tempPath, { required: true, timeoutMemoKey: path, retryTimedOutOnce: !cleanupHarden }); return; } try { chmodSync(tempPath, 0o600); } catch { /* platform may ignore chmod */ } @@ -365,6 +392,7 @@ function atomicWriteFileToTarget( forgetEphemeralSecretPath(tmp); } catch (cause) { if (!ownsTemp) throw cause; + cleanupHarden = true; let sharedTemp = false; if (exclusive) { try { sharedTemp = assertIdentity(tmp).nlink > 1n; } catch (error) { @@ -403,7 +431,7 @@ function atomicWriteFileToTarget( if (removed) forgetEphemeralSecretPath(tmp); if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause }); throw cause; - } + } finally { releaseAcl?.(); } } export function atomicWriteFile( @@ -490,12 +518,13 @@ export async function atomicWriteFileAsync( testSeam?: AtomicWriteAsyncTestSeam, ): Promise { let ownsTemp = false; + let cleanupHarden = false; const effective: AtomicWriteAsyncIO = io ?? { write: (tempPath, value) => writePrivateTempFileAsync(tempPath, value, path, () => { ownsTemp = true; }), harden: async tempPath => { // Same reasoning as the synchronous writer above. if (windowsHardeningApplies()) { - await hardenSecretPathAsync(tempPath, { required: true, timeoutMemoKey: path }); + await hardenSecretPathAsync(tempPath, { required: true, timeoutMemoKey: path, retryTimedOutOnce: !cleanupHarden }); return; } try { chmodSync(tempPath, 0o600); } catch { /* platform may ignore chmod */ } @@ -507,6 +536,7 @@ export async function atomicWriteFileAsync( const target = resolveWriteTarget(path); assertResolvedTargetAllowed(path, target); const tmp = `${target}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`; + const releaseAcl = admitAtomicAclWork(target, tmp, io === undefined); let hardened = false; let tempWasHardenedBeforeContent = false; try { @@ -520,6 +550,7 @@ export async function atomicWriteFileAsync( forgetEphemeralSecretPath(tmp); } catch (cause) { if (!ownsTemp) throw cause; + cleanupHarden = true; // The async ACL belt bounds the writer, but it is not evidence that icacls released this // path. Leave the temp in the existing residual state instead of racing an unlink against a // live Windows handle. The default Windows writer hardens before writing secret bytes; a @@ -555,5 +586,5 @@ export async function atomicWriteFileAsync( if (removed) forgetEphemeralSecretPath(tmp); if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause }); throw cause; - } + } finally { releaseAcl?.(); } } diff --git a/structure/config.md b/structure/config.md index 5b3301556a4..ce3166854ee 100644 --- a/structure/config.md +++ b/structure/config.md @@ -70,7 +70,7 @@ identity, and snapshot-guarded removal. `RuntimePortState.attestationSecret` and owner-only state and are validated before a record is returned. `src/config.ts` re-exports the same symbols for compatibility, but new lifecycle-only callers import the process-state leaf directly. -Replacing config and process-state writes use `src/config/atomic-write.ts`. The leaf preserves the shared +Replacing config and process-state writes use `src/config/atomic-write.ts`. Normal Windows writes opt into the destination's single ACL timeout recovery attempt; a consumed attempt re-arms after `TIMEOUT_MEMO_REARM_MS` in `src/lib/windows-secret-acl.ts`. Default writers track temporary paths per resolved destination across synchronous, streamed and asynchronous writes. A new write refuses immediately with sanitized `ETIMEDOUT` only while one of that destination's tracked paths has an icacls child retained past its caller's timeout belt and not yet reaped, including across re-arm windows. Healthy overlapping writes proceed, including during ACL hardening or a pause before rename. Refusal consumes no recovery attempt and leaves removal ownership intact; completing one write does not drop another write's retained-child tracking. Both cleanup paths leave that recovery unconsumed. Every admitted attempt still requires a successful harden before secret bytes or publication; non-timeout failures retain their existing fail-closed behavior. `tests/windows/windows-atomic-write-acl-rearm.test.ts` covers all five harden calls, the re-arm boundary, healthy overlap, retained child admission and cleanup. The leaf preserves the shared process-wide temp sequence, optional synchronous caller destination validation before every rename attempt, symlink target resolution, no-follow directory-entry replacement for externally writable integration directories, real-home test guard, owner manifest, Windows ACL hardening, scrub-before-unlink failure path, and explicit residual-temp errors. No-follow writes also accept raw bytes and exclusive publication: the same protected descriptor writer checks bigint file identity before and after content, then an atomic hard link refuses any occupied destination and verifies the published identity. Exclusive cleanup never truncates a shared inode or any inode after a link attempt, even if the syscall reports failure; a substituted temporary entry is neither scrubbed nor removed. A caller must not replace it with a local temp-and-rename shortcut. Publication failures in diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 0b5ed542af8..ab80f37758e 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,5 +1,6 @@ { "update-reclaim-authorizer.test.ts": "update", "update-capability-policy.test.ts": "update", "update-service-home.test.ts": "update", "update-service-home-policy.test.ts": "update", "update-home-recovery.test.ts": "update", "update-recovery-authority.test.ts": "update", "update-recovery-boundaries.test.ts": "update", "update-home-launcher.test.ts": "update", "update-home-worker.test.ts": "update", "update-mutation-boundaries.test.ts": "update", "update-mutation-guard.test.ts": "update", "update-guarded-ops.test.ts": "update", "update-effect-authority.test.ts": "update", "update-windows-wrapper-authority.test.ts": "update", "update-mutation-scanner.test.ts": "update", "refreshed-upstream-deadlines.test.ts": "responses", "local-upstream-deadlines.test.ts": "responses", "service-desktop-recovery.test.ts": "service", "provider-input-modalities.test.ts": "providers", "turn-membership.test.ts": "responses", "turn-membership-ingress.test.ts": "responses", "workbuddy-quota.test.ts": "providers", "workbuddy-login.test.ts": "oauth", "workbuddy-adapter.test.ts": "adapters", "workbuddy-provider.test.ts": "providers", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "responses-phase-inference.test.ts": "responses", "windows-config-root-async-writers.test.ts": "windows", + "windows-atomic-write-acl-rearm.test.ts": "windows", "t3code-client.test.ts": "clients", "t3code-client-lifecycle.test.ts": "clients", "fix-jsonc-cr-comments.test.ts": "lib", diff --git a/tests/server/config.test.ts b/tests/server/config.test.ts index 16bdc0f5d55..f9a4c4915fa 100644 --- a/tests/server/config.test.ts +++ b/tests/server/config.test.ts @@ -3440,7 +3440,7 @@ describe("config.ts – sync writer timeout keying (#840 refinement)", () => { atomicWriteFile(destination, "secret"); expect(hardenSpy).toHaveBeenCalledWith( expect.stringContaining(".tmp"), - { required: true, timeoutMemoKey: destination }, + { required: true, timeoutMemoKey: destination, retryTimedOutOnce: true }, ); } finally { hardenSpy.mockRestore(); diff --git a/tests/windows/windows-atomic-write-acl-rearm.test.ts b/tests/windows/windows-atomic-write-acl-rearm.test.ts new file mode 100644 index 00000000000..4775e99a43b --- /dev/null +++ b/tests/windows/windows-atomic-write-acl-rearm.test.ts @@ -0,0 +1,420 @@ +import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + forgetHardenedSecretPath, + flushWindowsSecretAclReapsBeforeRemoval, + hardenSecretPath, + resetHardenedStateForTests, + setAsyncIcaclsRunnerForTests, + setAsyncIcaclsBeltSchedulerForTests, + setIcaclsRunnerForTests, + setNowForTests, + setPlatformForTests, + TIMEOUT_MEMO_REARM_MS, + timedOutSecretPathCountForTests, + windowsSecretAclReapPendingForPath, + type IcaclsResult, +} from "../../src/lib/windows-secret-acl"; +import { + resetWindowsPrincipalForTests, + setAsyncWindowsPrincipalRunnerForTests, + setWindowsPrincipalRunnerForTests, +} from "../../src/lib/windows-user-principal"; + +const native = { ...fs }; +let root = ""; +let denyUnlink = false; +// Exercise the default cleanup hardener, rather than replacing io.harden with a fake. +mock.module("node:fs", () => ({ + ...native, + unlinkSync(path: fs.PathLike) { + if (denyUnlink && String(path).startsWith(join(root, "secret.json.ocx."))) { + throw Object.assign(new Error("fixture holds temporary file"), { code: "EACCES" }); + } + native.unlinkSync(path); + }, +})); +const { + atomicWriteFile, + atomicWriteFileAsync, + atomicWriteFileStreamed, + AtomicWriteResidualTempError, + setWindowsHardeningForTests, +} = await import("../../src/config/atomic-write"); + +const OK: IcaclsResult = { success: true, exitCode: 0, timedOut: false, stdout: "" }; +const PRINCIPAL = { ...OK, stdout: "S-1-5-21-9-9-9-1001\r\nTESTHOST\\tester" }; +let now = 0; +let state: "healthy" | "timeout" | "denied" = "healthy"; +let grants: string[] = []; +let previousTimeout: string | undefined; +let previousVerify: string | undefined; + +function runAcl(args: string[]): IcaclsResult { + if (args.includes("/grant:r")) grants.push(native.readFileSync(args[0]!, "utf8")); + if (state === "timeout") { + now += 1_000; // Exhaust the injected 1s hardening budget without a real wait. + return { success: false, exitCode: null, timedOut: true, stdout: "" }; + } + return state === "healthy" ? OK : { ...OK, success: false, exitCode: 5 }; +} + +beforeEach(() => { + root = native.mkdtempSync(join(tmpdir(), "ocx-atomic-acl-rearm-")); + native.writeFileSync(join(root, "secret.json"), "previous", "utf8"); + previousTimeout = process.env.OPENCODEX_ACL_TIMEOUT_MS; + previousVerify = process.env.OPENCODEX_ACL_VERIFY_EXISTING; + process.env.OPENCODEX_ACL_TIMEOUT_MS = "1000"; + delete process.env.OPENCODEX_ACL_VERIFY_EXISTING; + resetHardenedStateForTests(); + now = 0; state = "healthy"; grants = []; denyUnlink = false; + setPlatformForTests("win32"); + setWindowsHardeningForTests(true); + setWindowsPrincipalRunnerForTests(() => PRINCIPAL); + setAsyncWindowsPrincipalRunnerForTests(async () => PRINCIPAL); + setIcaclsRunnerForTests(runAcl); + setAsyncIcaclsRunnerForTests(async args => runAcl(args)); + setNowForTests(() => now); +}); + +afterEach(() => { + denyUnlink = false; + setWindowsHardeningForTests(null); + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + setNowForTests(null); + setPlatformForTests(null); + setWindowsPrincipalRunnerForTests(null); + setAsyncWindowsPrincipalRunnerForTests(null); + resetWindowsPrincipalForTests(); + resetHardenedStateForTests(); + if (previousTimeout === undefined) delete process.env.OPENCODEX_ACL_TIMEOUT_MS; + else process.env.OPENCODEX_ACL_TIMEOUT_MS = previousTimeout; + if (previousVerify === undefined) delete process.env.OPENCODEX_ACL_VERIFY_EXISTING; + else process.env.OPENCODEX_ACL_VERIFY_EXISTING = previousVerify; + native.rmSync(root, { recursive: true, force: true }); +}); +afterAll(() => { mock.restore(); }); + +const writers = { + sync: async (path: string, content: string) => { atomicWriteFile(path, content); }, + streamed: async (path: string, content: string) => { + atomicWriteFileStreamed(path, descriptor => native.writeFileSync(descriptor, content)); + }, + async: (path: string, content: string) => atomicWriteFileAsync(path, content), +}; + +for (const [name, write] of Object.entries(writers)) { + describe(`${name} atomic writer timeout recovery (#7834)`, () => { + test("a later write recovers the same destination, preserving bytes on failure", async () => { + const destination = join(root, "secret.json"); + state = "timeout"; + await expect(write(destination, "unpublished")).rejects.toMatchObject({ code: "ETIMEDOUT" }); + expect(native.readFileSync(destination, "utf8")).toBe("previous"); + expect(grants).toEqual([""]); // Required ACL admission precedes secret bytes. + state = "healthy"; + await write(destination, "recovered"); + expect(native.readFileSync(destination, "utf8")).toBe("recovered"); + expect(grants).toEqual(["", ""]); + expect(timedOutSecretPathCountForTests()).toBe(0); + }); + + test("a stalled recovery admits only one attempt per re-arm window", async () => { + const destination = join(root, "secret.json"); + state = "timeout"; + await expect(write(destination, "first")).rejects.toMatchObject({ code: "ETIMEDOUT" }); + const consumedAt = now; + await expect(write(destination, "recovery")).rejects.toMatchObject({ code: "ETIMEDOUT" }); + expect(grants).toHaveLength(2); + now = consumedAt + TIMEOUT_MEMO_REARM_MS - 1; + state = "healthy"; + await expect(write(destination, "too early")).rejects.toMatchObject({ code: "EACLRETRYEXHAUSTED" }); + await expect(write(destination, "still early")).rejects.toMatchObject({ code: "EACLRETRYEXHAUSTED" }); + expect(grants).toHaveLength(2); + now++; + state = "timeout"; + const rearmedAt = now; + await expect(write(destination, "still stalled")).rejects.toMatchObject({ code: "ETIMEDOUT" }); + expect(grants).toHaveLength(3); + expect(native.readFileSync(destination, "utf8")).toBe("previous"); + state = "healthy"; + await expect(write(destination, "blocked again")).rejects.toMatchObject({ code: "EACLRETRYEXHAUSTED" }); + expect(grants).toHaveLength(3); + now = rearmedAt + TIMEOUT_MEMO_REARM_MS; + await write(destination, "recovered"); + expect(grants).toEqual(["", "", "", ""]); + expect(native.readFileSync(destination, "utf8")).toBe("recovered"); + }); + + test("non-timeout ACL errors fail closed without a retry or timeout memo", async () => { + const destination = join(root, "secret.json"); + state = "denied"; + for (let attempt = 1; attempt <= 2; attempt++) { + await expect(write(destination, "unpublished")).rejects.toThrow(); + expect(grants).toHaveLength(attempt); + expect(timedOutSecretPathCountForTests()).toBe(0); + expect(native.readFileSync(destination, "utf8")).toBe("previous"); + } + state = "healthy"; + await write(destination, "recovered"); + expect(native.readFileSync(destination, "utf8")).toBe("recovered"); + }); + + test("cleanup leaves an empty residual without spending the recovery attempt", async () => { + const destination = join(root, "secret.json"); + state = "timeout"; denyUnlink = true; + await expect(write(destination, "unpublished")).rejects.toBeInstanceOf(AtomicWriteResidualTempError); + expect(grants).toEqual([""]); + const residuals = native.readdirSync(root).filter(path => path.endsWith(".tmp")); + expect(residuals).toHaveLength(1); + expect(native.readFileSync(join(root, residuals[0]!), "utf8")).toBe(""); + expect(native.readFileSync(destination, "utf8")).toBe("previous"); + denyUnlink = false; state = "healthy"; + await write(destination, "recovered"); + expect(grants).toEqual(["", ""]); + expect(native.readFileSync(destination, "utf8")).toBe("recovered"); + }); + }); +} + +for (const kind of ["sync", "async"] as const) { + test(`${kind} pre-rename hardener admits a re-armed memo only after a full harden`, async () => { + const destination = join(root, "secret.json"); + const afterTempWrite = (temp: string) => { + // Independently poison/consume the destination memo AFTER the initial harden, + // then invalidate the temp's success memo to exercise the fifth/fourth call site. + forgetHardenedSecretPath(temp); + state = "timeout"; + expect(() => hardenSecretPath(temp, { required: true, timeoutMemoKey: destination })).toThrow(); + const consumedAt = now; + expect(() => hardenSecretPath(temp, { + required: true, timeoutMemoKey: destination, retryTimedOutOnce: true, + })).toThrow(); + now = consumedAt + TIMEOUT_MEMO_REARM_MS; + state = "healthy"; + }; + if (kind === "sync") atomicWriteFile(destination, "replacement", undefined, { afterTempWrite }); + else await atomicWriteFileAsync(destination, "replacement", undefined, { afterTempWrite }); + expect(grants).toEqual(["", "replacement", "replacement", "replacement"]); + expect(native.readFileSync(destination, "utf8")).toBe("replacement"); + expect(timedOutSecretPathCountForTests()).toBe(0); + }); + + test(`${kind} pre-rename ACL denial still prevents publication`, async () => { + const destination = join(root, "secret.json"); + const afterTempWrite = (temp: string) => { + forgetHardenedSecretPath(temp); + state = "denied"; + }; + if (kind === "sync") expect(() => atomicWriteFile(destination, "replacement", undefined, { afterTempWrite })).toThrow(); + else await expect(atomicWriteFileAsync(destination, "replacement", undefined, { afterTempWrite })).rejects.toThrow(); + expect(grants).toEqual(["", "replacement"]); + expect(native.readFileSync(destination, "utf8")).toBe("previous"); + }); +} + +test.each(["timeout", "success", "rejection"] as const)("sequential retries retain one ACL child across re-arm windows (%s)", async reapOutcome => { + const destination = join(root, "secret.json"); + const held: { path: string; release: (result: IcaclsResult) => void; reject: () => void }[] = []; + const beltRefusals: string[] = []; + setAsyncIcaclsRunnerForTests(args => { + if (!args[0]!.startsWith(`${destination}.ocx.`)) return Promise.resolve(runAcl(args)); + return new Promise((resolve, reject) => { + held.push({ path: args[0]!, release: resolve, reject: () => reject(new Error("fixture runner failure")) }); + }); + }); + setAsyncIcaclsBeltSchedulerForTests(callback => { + let cancelled = false; + queueMicrotask(() => { + if (cancelled) return; + now += 1_001; + callback(); + // Retention must block admission before the timed-out writer reaches finally. + try { atomicWriteFile(destination, "blocked during belt"); beltRefusals.push("published"); } + catch (error) { beltRefusals.push((error as NodeJS.ErrnoException).code ?? "unknown"); } + }); + return () => { cancelled = true; }; + }); + try { + const retained: number[] = []; + for (let attempt = 0; attempt < 3; attempt++) { + if (attempt > 0) now += TIMEOUT_MEMO_REARM_MS; + await expect(atomicWriteFileAsync(destination, "unpublished")).rejects.toThrow(); + retained.push(held.filter(entry => windowsSecretAclReapPendingForPath(entry.path)).length); + } + expect(retained).toEqual([1, 1, 1]); + expect(beltRefusals).toEqual(["ETIMEDOUT"]); + expect(held).toHaveLength(1); + for (const write of [writers.sync, writers.streamed, writers.async]) { + now += TIMEOUT_MEMO_REARM_MS; + await expect(write(destination, "unpublished")).rejects.toMatchObject({ code: "ETIMEDOUT" }); + expect(held).toHaveLength(1); + expect(grants).toEqual([]); + expect(native.readFileSync(destination, "utf8")).toBe("previous"); + expect(native.readFileSync(held[0]!.path, "utf8")).toBe(""); + } + // A different destination is independent, even while this child is retained. + setAsyncIcaclsBeltSchedulerForTests(null); + await atomicWriteFileAsync(join(root, "other.json"), "other"); + expect(native.readFileSync(join(root, "other.json"), "utf8")).toBe("other"); + if (reapOutcome === "rejection") held[0]!.reject(); + else held[0]!.release(reapOutcome === "success" ? OK : { ...OK, success: false, timedOut: true, exitCode: null }); + await flushWindowsSecretAclReapsBeforeRemoval(root); + setAsyncIcaclsRunnerForTests(async args => runAcl(args)); + // Refusals while held must not consume recovery; actual reap admits it immediately. + await atomicWriteFileAsync(destination, "recovered"); + expect(native.readFileSync(destination, "utf8")).toBe("recovered"); + expect(timedOutSecretPathCountForTests()).toBe(0); + } finally { + for (const entry of held) entry.release({ ...OK, success: false, timedOut: true, exitCode: null }); + await flushWindowsSecretAclReapsBeforeRemoval(root); + setAsyncIcaclsBeltSchedulerForTests(null); + } +}); + +test("healthy ACL work allows overlapping async, sync and streamed writers", async () => { + const destination = join(root, "secret.json"); + let release!: (result: IcaclsResult) => void; + let entered!: () => void; + const started = new Promise(resolve => { entered = resolve; }); + let firstCall = true; + setAsyncIcaclsRunnerForTests(args => { + if (!firstCall) return Promise.resolve(runAcl(args)); + firstCall = false; + expect(native.readFileSync(args[0]!, "utf8")).toBe(""); + runAcl(args); + return new Promise(resolve => { release = resolve; entered(); }); + }); + const first = atomicWriteFileAsync(destination, "first"); + try { + await started; + for (const name of ["async", "sync", "streamed"] as const) { + await writers[name](destination, name); + expect(native.readFileSync(destination, "utf8")).toBe(name); + } + release(OK); + await first; + expect(native.readFileSync(destination, "utf8")).toBe("first"); + expect(grants).toEqual(["", "", "", ""]); + expect(timedOutSecretPathCountForTests()).toBe(0); + } finally { + release(OK); + await first; + await flushWindowsSecretAclReapsBeforeRemoval(root); + } +}); + +test("healthy writes succeed while an earlier write is paused before rename", async () => { + const destination = join(root, "secret.json"); + let resume!: () => void; + let entered!: () => void; + const paused = new Promise(resolve => { entered = resolve; }); + const gate = new Promise(resolve => { resume = resolve; }); + const first = atomicWriteFileAsync(destination, "first", undefined, { + afterTempWrite: async temp => { + expect(native.readFileSync(temp, "utf8")).toBe("first"); + entered(); + await gate; + }, + }); + try { + await paused; + for (const name of ["async", "sync", "streamed"] as const) { + await writers[name](destination, name); + expect(native.readFileSync(destination, "utf8")).toBe(name); + } + resume(); + await first; + expect(native.readFileSync(destination, "utf8")).toBe("first"); + expect(grants).toEqual(["", "", "", ""]); + } finally { + resume(); + await first; + } +}); + +test("completing a healthy peer preserves another write's retained-child guard", async () => { + const destination = join(root, "secret.json"); + let resume!: () => void; + let entered!: () => void; + let reap!: (result: IcaclsResult) => void; + const paused = new Promise(resolve => { entered = resolve; }); + const gate = new Promise(resolve => { resume = resolve; }); + const first = atomicWriteFileAsync(destination, "first", undefined, { + afterTempWrite: async () => { entered(); await gate; }, + }); + try { + await paused; + setAsyncIcaclsRunnerForTests(() => new Promise(resolve => { reap = resolve; })); + setAsyncIcaclsBeltSchedulerForTests(callback => { + let cancelled = false; + queueMicrotask(() => { if (!cancelled) { now += 1_001; callback(); } }); + return () => { cancelled = true; }; + }); + await expect(atomicWriteFileAsync(destination, "unpublished")).rejects.toBeInstanceOf(AtomicWriteResidualTempError); + resume(); + await first; + expect(native.readFileSync(destination, "utf8")).toBe("first"); + setAsyncIcaclsBeltSchedulerForTests(null); + setAsyncIcaclsRunnerForTests(async args => runAcl(args)); + for (const write of Object.values(writers)) { + await expect(write(destination, "blocked")).rejects.toMatchObject({ code: "ETIMEDOUT" }); + } + expect(grants).toEqual([""]); + reap({ ...OK, success: false, timedOut: true, exitCode: null }); + await flushWindowsSecretAclReapsBeforeRemoval(root); + await atomicWriteFileAsync(destination, "recovered"); + expect(native.readFileSync(destination, "utf8")).toBe("recovered"); + } finally { + resume(); + reap?.({ ...OK, success: false, timedOut: true, exitCode: null }); + await first; + await flushWindowsSecretAclReapsBeforeRemoval(root); + setAsyncIcaclsBeltSchedulerForTests(null); + } +}); + +test("overlaps admitted before timeout stay guarded until the last child reaps", async () => { + const destination = join(root, "secret.json"); + const held: { path: string; release: (result: IcaclsResult) => void }[] = []; + const belts: (() => void)[] = []; + let entered!: () => void; + const started = new Promise(resolve => { entered = resolve; }); + setAsyncIcaclsRunnerForTests(args => new Promise(resolve => { + held.push({ path: args[0]!, release: resolve }); + if (held.length === 2) entered(); + })); + setAsyncIcaclsBeltSchedulerForTests(callback => { belts.push(callback); return () => {}; }); + const results = ["first", "second"].map(content => atomicWriteFileAsync(destination, content).then( + () => "published", error => error.name, + )); + try { + expect(await Promise.race([started.then(() => true), results[1]!.then(() => false)])).toBe(true); + now += 1_001; + for (const belt of belts) belt(); + expect(await Promise.all(results)).toEqual(["AtomicWriteResidualTempError", "AtomicWriteResidualTempError"]); + expect(held.every(entry => windowsSecretAclReapPendingForPath(entry.path))).toBe(true); + setAsyncIcaclsBeltSchedulerForTests(null); + setAsyncIcaclsRunnerForTests(async args => runAcl(args)); + await expect(atomicWriteFileAsync(destination, "blocked")).rejects.toMatchObject({ code: "ETIMEDOUT" }); + held[0]!.release({ ...OK, success: false, timedOut: true, exitCode: null }); + await flushWindowsSecretAclReapsBeforeRemoval(held[0]!.path); + now += TIMEOUT_MEMO_REARM_MS; + await expect(atomicWriteFileAsync(destination, "still blocked")).rejects.toMatchObject({ code: "ETIMEDOUT" }); + expect(native.readFileSync(destination, "utf8")).toBe("previous"); + expect(grants).toEqual([]); + held[1]!.release(OK); + await flushWindowsSecretAclReapsBeforeRemoval(root); + await atomicWriteFileAsync(destination, "recovered"); + expect(native.readFileSync(destination, "utf8")).toBe("recovered"); + } finally { + now += 1_001; + for (const belt of belts) belt(); + for (const entry of held) entry.release({ ...OK, success: false, timedOut: true, exitCode: null }); + await Promise.all(results); + await flushWindowsSecretAclReapsBeforeRemoval(root); + setAsyncIcaclsBeltSchedulerForTests(null); + } +});