From c52a2899507d882910569f89d7c7900ffa30f703 Mon Sep 17 00:00:00 2001 From: coyaSONG <66289470+coyaSONG@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:01:40 +0900 Subject: [PATCH] Add FileSystem.lstat support --- .changeset/tiny-cats-listen.md | 6 ++++++ packages/effect/src/FileSystem.ts | 9 +++++++++ .../platform-node-shared/src/NodeFileSystem.ts | 9 +++++++++ .../test/NodeFileSystem.test.ts | 17 +++++++++++++++++ 4 files changed, 41 insertions(+) create mode 100644 .changeset/tiny-cats-listen.md diff --git a/.changeset/tiny-cats-listen.md b/.changeset/tiny-cats-listen.md new file mode 100644 index 00000000000..45d6ca9255a --- /dev/null +++ b/.changeset/tiny-cats-listen.md @@ -0,0 +1,6 @@ +--- +"@effect/platform-node-shared": patch +"effect": patch +--- + +Add `FileSystem.lstat` for inspecting symbolic links without following them. diff --git a/packages/effect/src/FileSystem.ts b/packages/effect/src/FileSystem.ts index beadaa7a2f7..9e9ed6ba26d 100644 --- a/packages/effect/src/FileSystem.ts +++ b/packages/effect/src/FileSystem.ts @@ -289,6 +289,12 @@ export interface FileSystem { readonly mode?: number | undefined } ) => Sink.Sink + /** + * Get information about a file at `path` without following symbolic links. + */ + readonly lstat: ( + path: string + ) => Effect.Effect /** * Get information about a file at `path`. */ @@ -978,6 +984,9 @@ export const makeNoop = (fileSystem: Partial): FileSystem => sink(path) { return Sink.fail(notFound("sink", path)) }, + lstat(path) { + return Effect.fail(notFound("lstat", path)) + }, stat(path) { return Effect.fail(notFound("stat", path)) }, diff --git a/packages/platform-node-shared/src/NodeFileSystem.ts b/packages/platform-node-shared/src/NodeFileSystem.ts index fe7dd631493..35f72e16571 100644 --- a/packages/platform-node-shared/src/NodeFileSystem.ts +++ b/packages/platform-node-shared/src/NodeFileSystem.ts @@ -505,6 +505,14 @@ const makeFileInfo = (stat: NFS.Stats): FileSystem.File.Info => ({ blksize: stat.blksize !== undefined ? Option.some(FileSystem.Size(stat.blksize)) : Option.none(), blocks: Option.fromNullishOr(stat.blocks) }) +const lstat = (() => { + const nodeLstat = effectify( + NFS.lstat, + handleErrnoException("FileSystem", "lstat"), + handleBadArgument("lstat") + ) + return (path: string) => Effect.map(nodeLstat(path), makeFileInfo) +})() const stat = (() => { const nodeStat = effectify( NFS.stat, @@ -636,6 +644,7 @@ const makeFileSystem = Effect.map(Effect.serviceOption(FileSystem.WatchBackend), copyFile, glob, link, + lstat, makeDirectory, makeTempDirectory, makeTempDirectoryScoped, diff --git a/packages/platform-node-shared/test/NodeFileSystem.test.ts b/packages/platform-node-shared/test/NodeFileSystem.test.ts index 017f2eee945..546c82362ca 100644 --- a/packages/platform-node-shared/test/NodeFileSystem.test.ts +++ b/packages/platform-node-shared/test/NodeFileSystem.test.ts @@ -47,6 +47,23 @@ describe("FileSystem", () => { assert(error.reason._tag === "NotFound") }))) + it("lstat does not follow symbolic links", () => + runPromise(Effect.scoped(Effect.gen(function*() { + const fs = yield* Fs.FileSystem + const dir = yield* fs.makeTempDirectoryScoped() + const target = `${dir}/target.txt` + const link = `${dir}/link.txt` + + yield* fs.writeFileString(target, "target") + yield* fs.symlink(target, link) + + const followed = yield* fs.stat(link) + const notFollowed = yield* fs.lstat(link) + + assert.strictEqual(followed.type, "File") + assert.strictEqual(notFollowed.type, "SymbolicLink") + })))) + it("truncate", () => runPromise(Effect.gen(function*() { const fs = yield* Fs.FileSystem