diff --git a/src/lib/private-file.ts b/src/lib/private-file.ts index 48e7dac0..1533f43c 100644 --- a/src/lib/private-file.ts +++ b/src/lib/private-file.ts @@ -272,13 +272,29 @@ async function observeReplaceableTarget(opts: { return observed; } +/** + * How many times {@link readReplaceablePrivateFile} re-observes a path whose + * regular file was atomically replaced between its `lstat` and its `open` + * before it refuses closed. One concurrent writer's rename costs one retry; + * only a path replaced on every attempt exhausts the budget. + */ +export const REPLACEABLE_READ_ATTEMPTS = 8; + /** * Reads a private file that is later replaced with * {@link replacePrivateFileAtomic}. Returns null when the entry is absent. A * symlink at the path, a non-regular entry, a symlinked or replaced directory - * from `directories`, or a file that changes identity during the read throws + * from `directories`, or a file that keeps changing identity throws * `refusal()`; other filesystem errors (for example EACCES) propagate, so an * unreadable file is never mistaken for an empty one and overwritten. + * + * Concurrency: readers need no lock. When the regular file seen by `lstat` + * and the regular file opened (no-follow) differ, a writer renamed a complete + * sibling over the path in between, which is the replacement this module's + * writer promises readers may observe; the read starts again (at most + * {@link REPLACEABLE_READ_ATTEMPTS} times) and returns the previous or the new + * contents, never a false symlink refusal (HACK-1422). A symlink or + * non-regular entry opened at any attempt is still refused at once. */ export async function readReplaceablePrivateFile( opts: ReplaceablePrivateFileOptions & { @@ -287,9 +303,28 @@ export async function readReplaceablePrivateFile( } ): Promise { const directories = await captureRealDirectories(opts); + for (let attempt = 1; ; attempt += 1) { + const read = await readReplaceableTargetOnce(opts); + if (read === "replaced") { + if (attempt < REPLACEABLE_READ_ATTEMPTS) { + continue; + } + throw opts.refusal(); + } + await recheckRealDirectories({ directories, refusal: opts.refusal }); + return read; + } +} + +/** + * One no-follow observe-open-read of `opts.path`: null when absent, + * `"replaced"` when the opened regular file is not the one `lstat` observed. + */ +async function readReplaceableTargetOnce( + opts: ReplaceablePrivateFileOptions & { readonly maxBytes?: number } +): Promise { const observed = await observeReplaceableTarget(opts); if (!observed) { - await recheckRealDirectories({ directories, refusal: opts.refusal }); return null; } let file: FileHandle; @@ -308,12 +343,13 @@ export async function readReplaceablePrivateFile( const opened = await file.stat(); if ( !opened.isFile() || - opened.dev !== observed.dev || - opened.ino !== observed.ino || (opts.maxBytes !== undefined && opened.size > opts.maxBytes) ) { throw opts.refusal(); } + if (opened.dev !== observed.dev || opened.ino !== observed.ino) { + return "replaced"; + } const text = await file.readFile({ encoding: "utf8" }); if ( opts.maxBytes !== undefined && @@ -322,7 +358,6 @@ export async function readReplaceablePrivateFile( // The file grew in place after the size check. throw opts.refusal(); } - await recheckRealDirectories({ directories, refusal: opts.refusal }); return { text, mode: opened.mode & 0o7777 }; } finally { await file.close(); diff --git a/tests/native-compose-storage-witness-xattr-owner.test.ts b/tests/native-compose-storage-witness-xattr-owner.test.ts index 2073dd0e..83841320 100644 --- a/tests/native-compose-storage-witness-xattr-owner.test.ts +++ b/tests/native-compose-storage-witness-xattr-owner.test.ts @@ -78,6 +78,25 @@ const artifact: NativeComposeStorageXattrArtifact = { helperHash: "e".repeat(64), kernelAbi: 1, }; +/** + * Bun timeout for store cases that drive a full durable generation lifecycle + * (publish, enroll, verify, retire) in process. They wait on no timer: the + * fake carrier answers at once. Their cost is a fixed amount of durable store + * file work, mostly system time: the second `up` of the same-birth case alone + * makes about 19k lstat, 13k realpath and 4.5k open calls, the same count on + * every run. That takes about 5-7 s on hosted CI, but 5-60 s or more on a + * host whose load is several times its CPU count, so this budget is a hang + * guard, not a speed assertion (HACK-1422). + */ +const STORE_CASE_BUDGET_MS = 180_000; +/** + * Wall-clock lifetime of the fake carrier. Every carrier is captured inside a + * case, so its deadline is always later than that case's Bun budget: a slow + * host fails the case as a clean timeout, never by exhausting the carrier + * deadline, which refuses with the storage witness error and would change the + * asserted outcome (HACK-1422). + */ +const FAKE_CARRIER_LIFETIME_MS = 2 * STORE_CASE_BUDGET_MS; const roots: string[] = []; const stores: NativeComposeGenerationStore[] = []; let commandReaderCases = 0, @@ -298,7 +317,7 @@ function fake(store: NativeComposeGenerationStore) { artifact, ports, signal: controller.signal, - deadline: Date.now() + 60_000, + deadline: Date.now() + FAKE_CARRIER_LIFETIME_MS, }); return { state, ports, carrier, calls, attributes, controller }; } @@ -664,50 +683,55 @@ test("completion publication refusal retains its first boundary after complete h test.each([ ["unset", undefined, 90_000], ["configured", "240000", 240_000], -] as const)("command storage carriers use the %s named storage budget, never a short start budget", async (_label, configured, expected) => { - const { store, transport } = await rememberedActive(); - const command = commandPorts(transport); - const deadlines: { readonly deadline: number; readonly from: number }[] = []; - const carrier = command.ports.carrier; - const ports = { - ...command.ports, - carrier: async (opts: { - readonly signal: AbortSignal; - readonly deadline: number; - }) => { - deadlines.push({ deadline: opts.deadline, from: Date.now() }); - return await carrier(opts); - }, - }; - const oldStartup = process.env.HACK_COMPOSE_STARTUP_TIMEOUT_MS; - const oldStorage = process.env.HACK_NATIVE_STORAGE_TIMEOUT_MS; - restoreEnv("HACK_COMPOSE_STARTUP_TIMEOUT_MS", "15000"); - restoreEnv("HACK_NATIVE_STORAGE_TIMEOUT_MS", configured); - try { - await store.withMutation(async (mutation) => { - const before = Date.now(); - const storage = await prepareNativeComposeCommandStorage({ - store, - mutation, - operation: "up", - selected: [selection], - signal: transport.controller.signal, - ports, +] as const)( + "command storage carriers use the %s named storage budget, never a short start budget", + async (_label, configured, expected) => { + const { store, transport } = await rememberedActive(); + const command = commandPorts(transport); + const deadlines: { readonly deadline: number; readonly from: number }[] = + []; + const carrier = command.ports.carrier; + const ports = { + ...command.ports, + carrier: async (opts: { + readonly signal: AbortSignal; + readonly deadline: number; + }) => { + deadlines.push({ deadline: opts.deadline, from: Date.now() }); + return await carrier(opts); + }, + }; + const oldStartup = process.env.HACK_COMPOSE_STARTUP_TIMEOUT_MS; + const oldStorage = process.env.HACK_NATIVE_STORAGE_TIMEOUT_MS; + restoreEnv("HACK_COMPOSE_STARTUP_TIMEOUT_MS", "15000"); + restoreEnv("HACK_NATIVE_STORAGE_TIMEOUT_MS", configured); + try { + await store.withMutation(async (mutation) => { + const before = Date.now(); + const storage = await prepareNativeComposeCommandStorage({ + store, + mutation, + operation: "up", + selected: [selection], + signal: transport.controller.signal, + ports, + }); + expect(storage).not.toBeNull(); + expect(deadlines.length).toBeGreaterThanOrEqual(2); + for (const { deadline, from } of deadlines) { + // Each carrier deadline is taken when it is created, from the storage + // budget alone: never the 15 s start budget the command was given. + expect(deadline).toBeGreaterThanOrEqual(before + expected); + expect(deadline).toBeLessThanOrEqual(from + expected); + } }); - expect(storage).not.toBeNull(); - expect(deadlines.length).toBeGreaterThanOrEqual(2); - for (const { deadline, from } of deadlines) { - // Each carrier deadline is taken when it is created, from the storage - // budget alone: never the 15 s start budget the command was given. - expect(deadline).toBeGreaterThanOrEqual(before + expected); - expect(deadline).toBeLessThanOrEqual(from + expected); - } - }); - } finally { - restoreEnv("HACK_COMPOSE_STARTUP_TIMEOUT_MS", oldStartup); - restoreEnv("HACK_NATIVE_STORAGE_TIMEOUT_MS", oldStorage); - } -}); + } finally { + restoreEnv("HACK_COMPOSE_STARTUP_TIMEOUT_MS", oldStartup); + restoreEnv("HACK_NATIVE_STORAGE_TIMEOUT_MS", oldStorage); + } + }, + STORE_CASE_BUDGET_MS +); test.each([ "up", @@ -2143,76 +2167,84 @@ test.each( ); }); -test("post-workload marker loss refuses completion, retaining exact pending generation", async () => { - const { store, transport, generation } = await active(); - transport.calls.length = 0; - await store.withMutation(async (mutation) => { - await expect( - mutation.runEffect({ - generation, - operation: "up", - assertOwned: async () => {}, - assertFresh: async () => {}, - storageWitnesses: { - kind: "directory-xattr", - engineId, - carrier: transport.carrier, - }, - effect: async () => { - transport.calls.push("workload"); - transport.attributes.clear(); - return { value: 0, outcome: "complete" }; - }, - }) - ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_UNCERTAIN" }); - }); - expect(transport.calls).toContain("workload"); - expect((await store.loadCurrent()).pending?.generationId).toBe( - generation.generationId - ); - expect((await store.loadCurrent()).storageWitnesses?.[0]?.state).toBe( - "enrolled" - ); - expect(transport.calls).not.toContain("seed"); -}); +test( + "post-workload marker loss refuses completion, retaining exact pending generation", + async () => { + const { store, transport, generation } = await active(); + transport.calls.length = 0; + await store.withMutation(async (mutation) => { + await expect( + mutation.runEffect({ + generation, + operation: "up", + assertOwned: async () => {}, + assertFresh: async () => {}, + storageWitnesses: { + kind: "directory-xattr", + engineId, + carrier: transport.carrier, + }, + effect: async () => { + transport.calls.push("workload"); + transport.attributes.clear(); + return { value: 0, outcome: "complete" }; + }, + }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_UNCERTAIN" }); + }); + expect(transport.calls).toContain("workload"); + expect((await store.loadCurrent()).pending?.generationId).toBe( + generation.generationId + ); + expect((await store.loadCurrent()).storageWitnesses?.[0]?.state).toBe( + "enrolled" + ); + expect(transport.calls).not.toContain("seed"); + }, + STORE_CASE_BUDGET_MS +); -test("same-birth root replacement while awaiting the final reader cannot publish ready from its pinned old directory", async () => { - const { store, transport, generation } = await active(); - let exchanged = false; - await store.withMutation(async (mutation) => { - await expect( - mutation.runEffect({ - generation, - operation: "up", - assertOwned: async () => {}, - assertFresh: async () => {}, - storageWitnesses: { - kind: "directory-xattr", - engineId, - carrier: transport.carrier, - }, - beforeComplete: async () => { - transport.state.afterInvoke = (input) => { - if (!exchanged && input.request.operation === "verify") { - exchanged = true; - transport.state.root = { ...transport.state.root, inode: "43" }; - transport.attributes.clear(); - } - }; - }, - effect: async () => ({ value: 0, outcome: "complete" }), - }) - ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_UNCERTAIN" }); - }); - expect(exchanged).toBe(true); - expect(transport.state.volume).toEqual(volume); - expect((await store.loadCurrent()).pending?.generationId).toBe( - generation.generationId - ); - expect((await store.loadCurrent()).storageWitnesses?.[0]?.state).toBe( - "enrolled" - ); -}); +test( + "same-birth root replacement while awaiting the final reader cannot publish ready from its pinned old directory", + async () => { + const { store, transport, generation } = await active(); + let exchanged = false; + await store.withMutation(async (mutation) => { + await expect( + mutation.runEffect({ + generation, + operation: "up", + assertOwned: async () => {}, + assertFresh: async () => {}, + storageWitnesses: { + kind: "directory-xattr", + engineId, + carrier: transport.carrier, + }, + beforeComplete: async () => { + transport.state.afterInvoke = (input) => { + if (!exchanged && input.request.operation === "verify") { + exchanged = true; + transport.state.root = { ...transport.state.root, inode: "43" }; + transport.attributes.clear(); + } + }; + }, + effect: async () => ({ value: 0, outcome: "complete" }), + }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_UNCERTAIN" }); + }); + expect(exchanged).toBe(true); + expect(transport.state.volume).toEqual(volume); + expect((await store.loadCurrent()).pending?.generationId).toBe( + generation.generationId + ); + expect((await store.loadCurrent()).storageWitnesses?.[0]?.state).toBe( + "enrolled" + ); + }, + STORE_CASE_BUDGET_MS +); test("known finite refused marker read clears only its proven absent helper intent and never repairs the witness", async () => { const { store, transport, generation, reference } = await active(); @@ -2286,96 +2318,100 @@ test.each([ "changed-created", "created-uncertain", "cleanup", -] as const)("unknown enrolled helper %s blocks replay and saved retirement without changing the main material receipt", async (failure) => { - const { store, transport, generation, reference } = await active(); - transport.state.failure = failure; - transport.calls.length = 0; - let workload = 0; - await store.withMutation(async (mutation) => { - await expect( - mutation.runEffect({ - generation, - operation: "run", - assertFresh: async () => {}, - assertOwned: async () => {}, - storageWitnesses: { - kind: "directory-xattr", - engineId, - carrier: transport.carrier, - }, - effect: async () => { - workload++; - return { value: 0, outcome: "complete" }; - }, - }) - ).rejects.toThrow("values omitted"); - }); - expect(workload).toBe(0); - const unknown = await store.loadCurrent(); - expect(unknown.generation?.generationId).toBe(generation.generationId); - expect(unknown.pending).toBeNull(); - expect(unknown.storageWitnessesPending).toBe(true); - expect(unknown.storageWitnesses?.[0]).toMatchObject({ - state: "enrolled", - reference, - }); - const observed = [...transport.calls]; - await store.withMutation(async (mutation) => { - if (failure === "no-created") { - const foreignGeneration = await publish(mutation); - await expect( - mutation.runEffect({ - generation: foreignGeneration, - operation: "down", - recoverPending: true, - assertOwned: async () => {}, - effect: async () => { - workload++; - return { value: 0, outcome: "complete" }; - }, - }) - ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_STATE" }); - } - for (const operation of ["up", "down"] as const) { +] as const)( + "unknown enrolled helper %s blocks replay and saved retirement without changing the main material receipt", + async (failure) => { + const { store, transport, generation, reference } = await active(); + transport.state.failure = failure; + transport.calls.length = 0; + let workload = 0; + await store.withMutation(async (mutation) => { await expect( mutation.runEffect({ generation, - operation, + operation: "run", assertFresh: async () => {}, assertOwned: async () => {}, + storageWitnesses: { + kind: "directory-xattr", + engineId, + carrier: transport.carrier, + }, effect: async () => { workload++; return { value: 0, outcome: "complete" }; }, }) - ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_UNCERTAIN" }); - } - const stopped = await mutation.runEffect({ - generation, - operation: "down", - recoverPending: true, - assertFresh: async () => {}, - assertOwned: async () => {}, - effect: async () => { - workload++; - return { value: 0, outcome: "complete" }; - }, - beforeComplete: async () => { - throw new Error("Unknown helper cannot retire claims"); - }, + ).rejects.toThrow("values omitted"); }); - expect(stopped).toEqual({ value: 0, outcome: "uncertain" }); - }); - expect(workload).toBe(1); - expect(transport.calls).toEqual(observed); - const retained = await store.loadCurrent(); - expect(retained.pending?.generationId).toBe(generation.generationId); - expect(retained.storageWitnessesPending).toBe(true); - expect(retained.storageWitnesses?.[0]).toMatchObject({ - state: "enrolled", - reference, - }); -}); + expect(workload).toBe(0); + const unknown = await store.loadCurrent(); + expect(unknown.generation?.generationId).toBe(generation.generationId); + expect(unknown.pending).toBeNull(); + expect(unknown.storageWitnessesPending).toBe(true); + expect(unknown.storageWitnesses?.[0]).toMatchObject({ + state: "enrolled", + reference, + }); + const observed = [...transport.calls]; + await store.withMutation(async (mutation) => { + if (failure === "no-created") { + const foreignGeneration = await publish(mutation); + await expect( + mutation.runEffect({ + generation: foreignGeneration, + operation: "down", + recoverPending: true, + assertOwned: async () => {}, + effect: async () => { + workload++; + return { value: 0, outcome: "complete" }; + }, + }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_STATE" }); + } + for (const operation of ["up", "down"] as const) { + await expect( + mutation.runEffect({ + generation, + operation, + assertFresh: async () => {}, + assertOwned: async () => {}, + effect: async () => { + workload++; + return { value: 0, outcome: "complete" }; + }, + }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_UNCERTAIN" }); + } + const stopped = await mutation.runEffect({ + generation, + operation: "down", + recoverPending: true, + assertFresh: async () => {}, + assertOwned: async () => {}, + effect: async () => { + workload++; + return { value: 0, outcome: "complete" }; + }, + beforeComplete: async () => { + throw new Error("Unknown helper cannot retire claims"); + }, + }); + expect(stopped).toEqual({ value: 0, outcome: "uncertain" }); + }); + expect(workload).toBe(1); + expect(transport.calls).toEqual(observed); + const retained = await store.loadCurrent(); + expect(retained.pending?.generationId).toBe(generation.generationId); + expect(retained.storageWitnessesPending).toBe(true); + expect(retained.storageWitnesses?.[0]).toMatchObject({ + state: "enrolled", + reference, + }); + }, + STORE_CASE_BUDGET_MS +); test("retirement rereads the original receipt after its final carrier-journal await", async () => { const { store, generation } = await active(); diff --git a/tests/native-runtime-client.test.ts b/tests/native-runtime-client.test.ts index 2710cb20..fcd15515 100644 --- a/tests/native-runtime-client.test.ts +++ b/tests/native-runtime-client.test.ts @@ -88,6 +88,31 @@ async function waitForFile( } } +/** + * Wait until a fixture's completion file holds exactly `expected`. A + * fixture's writeFileSync creates the file before it writes the bytes, so an + * existence wait can return while the file is still "" (HACK-1422). + */ +async function waitForFileText( + path: string, + expected: string, + budgetMs = HANDSHAKE_BUDGET_MS +): Promise { + const deadline = performance.now() + budgetMs; + for (;;) { + const file = Bun.file(path); + if ((await file.exists()) && (await file.text()) === expected) { + return; + } + if (performance.now() >= deadline) { + throw new Error( + `Fixture completion did not write its text within ${budgetMs} ms.` + ); + } + await Bun.sleep(10); + } +} + /** * Wait until a fixture's pid handshake holds a complete pid. A fixture's * writeFileSync creates the file before it writes the digits, so a read that @@ -409,11 +434,15 @@ bunTest.each([ // The keeper holds the request's stdout/stderr until the test releases it, // so the request can only finish by bounding its drain. The bound is // measured from the stand-in's own exit (or the abort), never from spawn, - // so slow process startup under load cannot decide the outcome. + // so slow process startup under load cannot decide the outcome. Its + // completion write opens the empty-file window on every run, so the + // completion wait below must wait for the text, not for existence. const keeper = `import {existsSync,writeFileSync} from 'node:fs'; writeFileSync(${JSON.stringify(holding)},String(process.pid)); const cap=performance.now()+${KEEPER_RELEASE_CAP_MS}; while(!existsSync(${JSON.stringify(release)})&&performance.now() undefined); await Bun.write(release, "release"); - completed = await waitForFile(finished).then( + completed = await waitForFileText(finished, "finished").then( () => true, () => false ); diff --git a/tests/private-env-file.test.ts b/tests/private-env-file.test.ts index dabf0152..a1063d2b 100644 --- a/tests/private-env-file.test.ts +++ b/tests/private-env-file.test.ts @@ -1,4 +1,6 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import * as privateFiles from "node:fs/promises"; import { chmod, link, @@ -22,7 +24,10 @@ import { } from "../src/constants.ts"; import { removeDotEnvKey, upsertDotEnvValue } from "../src/lib/hack-env.ts"; import { PRIVATE_ENV_FILE_REFUSAL } from "../src/lib/private-env-file.ts"; -import { replacePrivateFileAtomic } from "../src/lib/private-file.ts"; +import { + REPLACEABLE_READ_ATTEMPTS, + replacePrivateFileAtomic, +} from "../src/lib/private-file.ts"; import { materializeProjectEnv, setProjectEnvValue, @@ -399,3 +404,124 @@ test("negative control: replacePrivateFileAtomic itself refuses a symlinked targ await expect(lstat(danglingTarget)).rejects.toThrow(); expect(await stagingLeftovers(project.projectDir)).toEqual([]); }); + +/** + * HACK-1422: env files are read without the layer lock (the preflight of + * `upsertDotEnvValue`/`removeDotEnvKey`, and every resolver). A concurrent + * writer's atomic rename landing between the reader's `lstat` and its + * no-follow `open` used to fail the reader with the symlink refusal. `open` is + * intercepted so the replacement lands exactly in that window. + */ +function replaceOnOpen(opts: { + readonly envFile: string; + readonly replace: (call: number) => Promise; +}): { readonly calls: () => number; readonly restore: () => void } { + const originalOpen = privateFiles.open; + let calls = 0; + const intercept = spyOn(privateFiles, "open").mockImplementation( + async (...args) => { + if (args[0] === opts.envFile) { + calls += 1; + await opts.replace(calls); + } + return await Reflect.apply(originalOpen, privateFiles, args); + } + ); + return { calls: () => calls, restore: () => intercept.mockRestore() }; +} + +/** Renames a complete sibling over `path`, as `replacePrivateFileAtomic` does. */ +async function renameOver(path: string, text: string): Promise { + const sibling = `${path}.${randomUUID()}.racing`; + await writeFile(sibling, text, { mode: 0o600 }); + await privateFiles.rename(sibling, path); +} + +test("a set whose unlocked read races another writer's atomic replace keeps both keys (HACK-1422)", async () => { + const project = await createProject(); + await writeFile(project.envFile, "PRESET=1\n", { mode: 0o600 }); + const racing = replaceOnOpen({ + envFile: project.envFile, + replace: async (call) => { + if (call === 1) { + await renameOver(project.envFile, "OTHER=2\nPRESET=1\n"); + } + }, + }); + try { + const result = await upsertDotEnvValue({ + envFile: project.envFile, + key: SENTINEL_KEY, + value: SENTINEL_VALUE, + }); + expect(result.changed).toBe(true); + } finally { + racing.restore(); + } + expect(racing.calls()).toBeGreaterThan(1); + expect(await readFile(project.envFile, "utf8")).toBe( + `OTHER=2\nPRESET=1\n${SENTINEL_KEY}=${SENTINEL_VALUE}\n` + ); + expect((await stat(project.envFile)).mode & 0o777).toBe(0o600); +}); + +test("negative control: an env file replaced on every read attempt is refused closed after the bounded retries", async () => { + const project = await createProject(); + await writeFile(project.envFile, `${SENTINEL_KEY}=${SENTINEL_VALUE}\n`, { + mode: 0o600, + }); + const racing = replaceOnOpen({ + envFile: project.envFile, + replace: async (call) => { + await renameOver( + project.envFile, + `${SENTINEL_KEY}=${SENTINEL_VALUE}\nCHURN=${call}\n` + ); + }, + }); + let error: unknown; + try { + error = await caught(() => + removeDotEnvKey({ envFile: project.envFile, key: SENTINEL_KEY }) + ); + } finally { + racing.restore(); + } + expectClosedRefusal(error, [project.envFile]); + expect(racing.calls()).toBe(REPLACEABLE_READ_ATTEMPTS); + expect(await readFile(project.envFile, "utf8")).toBe( + `${SENTINEL_KEY}=${SENTINEL_VALUE}\nCHURN=${REPLACEABLE_READ_ATTEMPTS}\n` + ); + expect(await readdir(project.projectDir)).not.toContain(".internal"); +}); + +test("negative control: a symlink swapped in between the read's lstat and open is still refused at once", async () => { + const project = await createProject(); + await writeFile(project.envFile, "PRESET=1\n", { mode: 0o600 }); + const racing = replaceOnOpen({ + envFile: project.envFile, + replace: async (call) => { + if (call === 1) { + const swapped = `${project.envFile}.swapped`; + await symlink(project.outsideFile, swapped); + await privateFiles.rename(swapped, project.envFile); + } + }, + }); + let error: unknown; + try { + error = await caught(() => + upsertDotEnvValue({ + envFile: project.envFile, + key: SENTINEL_KEY, + value: SENTINEL_VALUE, + }) + ); + } finally { + racing.restore(); + } + expectClosedRefusal(error, [project.envFile, project.outsideFile]); + expect(racing.calls()).toBe(1); + expect(await readFile(project.outsideFile, "utf8")).toBe(OUTSIDE_TEXT); + expect((await lstat(project.envFile)).isSymbolicLink()).toBe(true); +});