diff --git a/.changeset/tiny-cats-listen.md b/.changeset/tiny-cats-listen.md new file mode 100644 index 00000000000..48ef4d991eb --- /dev/null +++ b/.changeset/tiny-cats-listen.md @@ -0,0 +1,7 @@ +--- +"@effect/platform-node-shared": patch +"@effect/platform-deno": 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 03d02dc189f..9f893a3348c 100644 --- a/packages/effect/src/FileSystem.ts +++ b/packages/effect/src/FileSystem.ts @@ -298,6 +298,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`. */ @@ -891,6 +897,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/effect/test/FileSystem.test-utils.ts b/packages/effect/test/FileSystem.test-utils.ts index 3490d7db974..d2c6c6b9e2d 100644 --- a/packages/effect/test/FileSystem.test-utils.ts +++ b/packages/effect/test/FileSystem.test-utils.ts @@ -54,6 +54,23 @@ export const testLayer = (layer: Layer.Layer, options: Test assert(error.reason._tag === "NotFound") }))) + it("lstat does not follow symbolic links", () => + runPromise(Effect.scoped(Effect.gen(function*() { + const fs = yield* Fs.FileSystem + const directory = yield* fs.makeTempDirectoryScoped() + const target = `${directory}/target.txt` + const link = `${directory}/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.skipIf(options.accessOnDirectory === false)( "access on a writable directory", () => diff --git a/packages/platform/deno/src/DenoFileSystem.ts b/packages/platform/deno/src/DenoFileSystem.ts index 9e263a7c357..e621ae4684d 100644 --- a/packages/platform/deno/src/DenoFileSystem.ts +++ b/packages/platform/deno/src/DenoFileSystem.ts @@ -375,6 +375,12 @@ const realPath: FileSystem.FileSystem["realPath"] = (path) => tryPromise("realPa const rename: FileSystem.FileSystem["rename"] = (oldPath, newPath) => tryPromise("rename", oldPath, () => Deno.rename(oldPath, newPath)) +const lstat: FileSystem.FileSystem["lstat"] = (path) => + Effect.map( + tryPromise("lstat", path, () => Deno.lstat(path)), + makeFileInfo + ) + const stat: FileSystem.FileSystem["stat"] = (path) => Effect.map( tryPromise("stat", path, () => Deno.stat(path)), @@ -473,6 +479,7 @@ const makeFileSystem = Effect.map(Effect.serviceOption(FileSystem.WatchBackend), copyFile, glob, link, + lstat, makeDirectory, makeTempDirectory, makeTempDirectoryScoped, diff --git a/packages/platform/node-shared/src/NodeFileSystem.ts b/packages/platform/node-shared/src/NodeFileSystem.ts index 8be146df349..6ce3fd1e17f 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, @@ -640,6 +648,7 @@ const makeFileSystem = Effect.map(Effect.serviceOption(FileSystem.WatchBackend), copyFile, glob, link, + lstat, makeDirectory, makeTempDirectory, makeTempDirectoryScoped,