diff --git a/packages/shared/src/atomicWrite.test.ts b/packages/shared/src/atomicWrite.test.ts index 366009f833132..3b0a99ec0d2d2 100644 --- a/packages/shared/src/atomicWrite.test.ts +++ b/packages/shared/src/atomicWrite.test.ts @@ -14,6 +14,36 @@ import { writeFileStringAtomically } from "./atomicWrite.ts"; import * as HostProcess from "./HostProcess.ts"; it.layer(NodeServices.layer)("writeFileStringAtomically", (it) => { + it.effect.each(HostProcess.Platform.defaultValue() === "linux" ? [39, 40, 41] : [])( + "matches native file access for a chain of %i symlinks", + (hops) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-atomic-write-" }); + const destination = path.join(root, "settings.json"); + const first = path.join(root, "link-0"); + yield* fs.writeFileString(destination, "before"); + for (let index = hops - 1; index >= 0; index--) { + const target = index === hops - 1 ? "settings.json" : `link-${index + 1}`; + yield* fs.symlink(target, path.join(root, `link-${index}`)); + } + + const read = yield* Effect.exit(fs.readFileString(first)); + const write = yield* Effect.exit( + writeFileStringAtomically({ filePath: first, contents: "after" }), + ); + + assert.strictEqual(Exit.isSuccess(read), hops <= 40); + assert.strictEqual(Exit.isSuccess(write), Exit.isSuccess(read)); + assert.strictEqual( + yield* fs.readFileString(destination), + Exit.isSuccess(read) ? "after" : "before", + ); + assert.strictEqual(yield* fs.readLink(first), "link-1"); + }), + ); + it.effect("keeps a symlinked file linked and rewrites its destination", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/packages/shared/src/symlink.ts b/packages/shared/src/symlink.ts index 2dcb9631c3862..9d71d2a687d93 100644 --- a/packages/shared/src/symlink.ts +++ b/packages/shared/src/symlink.ts @@ -23,7 +23,8 @@ export const resolveSymlinkTarget = Effect.fn("symlink.resolveSymlinkTarget")(fu const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; let current = path.resolve(filePath); - for (let hop = 0; hop < MAX_SYMLINK_HOPS; hop++) { + // The terminal file needs a probe after the last permitted link is followed. + for (let hop = 0; hop <= MAX_SYMLINK_HOPS; hop++) { const link = yield* fs.readLink(current).pipe( Effect.asSome, Effect.catchIf(isNotASymlink, () => Effect.succeedNone),