1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Option from "effect/Option";
4import * as Path from "effect/Path";
5import * as PlatformError from "effect/PlatformError";
6
7const MAX_SYMLINK_HOPS = 40;
8
9const isNotFound = (error: PlatformError.PlatformError) => error.reason._tag === "NotFound";
10
11const isNotASymlink = (error: PlatformError.PlatformError) =>
12 isNotFound(error) ||
13 (error.cause instanceof Error && "code" in error.cause && error.cause.code === "EINVAL");
14
15/**
16 * Follows a chain of symlinks to the file it finally names, which may not exist
17 * yet. Any path that is not a symlink resolves to itself. Atomic writers rename
18 * onto this path so a linked file keeps its link. Fails on a cycle, an overly
19 * long chain, or an unreadable link rather than handing back a link that a
20 * rename would replace.
21 */
22export const resolveSymlinkTarget = (filePath: string) =>
23 Effect.gen(function* () {
24 const fs = yield* FileSystem.FileSystem;
25 const path = yield* Path.Path;
26 let current = path.resolve(filePath);
27 for (let hop = 0; hop < MAX_SYMLINK_HOPS; hop++) {
28 const link = yield* fs.readLink(current).pipe(
29 Effect.map(Option.some),
30 Effect.catchIf(isNotASymlink, () => Effect.succeedNone),
31 );
32 if (Option.isNone(link)) {
33 return current;
34 }
35 // A relative target is relative to where the link really lives, which
36 // differs from its lexical parent when that parent is itself a symlink.
37 const linkDirectory = yield* fs
38 .realPath(path.dirname(current))
39 .pipe(Effect.catchIf(isNotFound, () => Effect.succeed(path.dirname(current))));
40 current = path.resolve(linkDirectory, link.value);
41 }
42 return yield* PlatformError.systemError({
43 _tag: "Unknown",
44 module: "FileSystem",
45 method: "readLink",
46 description: "Too many levels of symbolic links",
47 pathOrDescriptor: filePath,
48 });
49 });