Skip to content
Source
Sign inSign up

symlink.ts

49 lines1.8 KB

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 * Atomic writers rename onto this path so a linked file keeps its link. Fails
17 * on a cycle, an overly long chain, or an unreadable link rather than handing
18 * back a link that a rename would replace.
19 */
20export const resolveSymlinkTarget = Effect.fn("symlink.resolveSymlinkTarget")(function* (
21 filePath: string,
22) {
23 const fs = yield* FileSystem.FileSystem;
24 const path = yield* Path.Path;
25 let current = path.resolve(filePath);
26 // The terminal file needs a probe after the last permitted link is followed.
27 for (let hop = 0; hop <= MAX_SYMLINK_HOPS; hop++) {
28 const link = yield* fs.readLink(current).pipe(
29 Effect.asSome,
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});