@effected/kit 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,90 @@
1
+ import { Effect, FileSystem, Path } from "effect";
2
+
3
+ // src/PathContainment.ts
4
+ /** Link hops followed before a candidate is judged outside (a loop never settles). */
5
+ const MAX_LINK_HOPS = 40;
6
+ /** Whether a platform failure means "nothing is there", the only failure read as absent. */
7
+ const isNotFound = (error) => error.reason?._tag === "NotFound";
8
+ /**
9
+ * Decides whether a caller-supplied path stays inside a root.
10
+ *
11
+ * @remarks
12
+ * Audience: a handler that must refuse a path outside its workspace before
13
+ * reading or writing it. Neither form fails: "outside" is the answer `false`,
14
+ * and the filesystem is required in `R` from core, never owned here. A
15
+ * relative `candidate` is resolved against `root`, never the process working
16
+ * directory.
17
+ *
18
+ * {@link PathContainment.isWithinLexical} is pure and compares spellings, so a
19
+ * link inside the root that points out is invisible to it. Use
20
+ * {@link PathContainment.isWithin} for anything that will touch the disk.
21
+ *
22
+ * @public
23
+ */
24
+ var PathContainment = class PathContainment {
25
+ constructor() {}
26
+ /**
27
+ * Whether `candidate` is `root` or lies under it, comparing resolved
28
+ * spellings only (`..` segments and trailing separators are normalised;
29
+ * links are not followed). A sibling sharing a prefix (`/repo-other` against
30
+ * `/repo`) is outside. A relative `candidate` is resolved against `root`.
31
+ */
32
+ static isWithinLexical(path, root, candidate) {
33
+ const base = path.resolve(root);
34
+ const target = path.resolve(base, candidate);
35
+ if (target === base) return true;
36
+ const relative = path.relative(base, target);
37
+ return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
38
+ }
39
+ /**
40
+ * Whether `candidate` is `root` or lies under it once links are resolved.
41
+ *
42
+ * @remarks
43
+ * The candidate need not exist: its nearest existing ancestor is resolved
44
+ * with `realPath` and the missing tail appended, so a path about to be
45
+ * created is judged by where its parent really is. A component that is a
46
+ * dangling link is followed to where it points (a link out of the root is
47
+ * outside even when its target is absent), up to a fixed number of hops;
48
+ * a loop is outside.
49
+ *
50
+ * Fails closed: only a `NotFound` counts as absent. Any other failure on a
51
+ * candidate component (permission denied, a link loop, a non-directory
52
+ * parent) answers `false`. The lexical answer stands in only when the root's
53
+ * own real path is `NotFound`; any other failure reading it is `false`.
54
+ *
55
+ * A point-in-time check: the filesystem can change between this answer and
56
+ * the read or write it guards.
57
+ */
58
+ static isWithin = (root, candidate) => Effect.gen(function* () {
59
+ const fs = yield* FileSystem.FileSystem;
60
+ const path = yield* Path.Path;
61
+ const base = path.resolve(root);
62
+ const realRoot = yield* Effect.result(fs.realPath(base));
63
+ if (realRoot._tag === "Failure") return isNotFound(realRoot.failure) && PathContainment.isWithinLexical(path, root, candidate);
64
+ let existing = path.resolve(base, candidate);
65
+ let tail = [];
66
+ let hops = 0;
67
+ for (;;) {
68
+ const real = yield* Effect.result(fs.realPath(existing));
69
+ if (real._tag === "Success") return PathContainment.isWithinLexical(path, realRoot.success, path.join(real.success, ...tail));
70
+ if (!isNotFound(real.failure)) return false;
71
+ const link = yield* Effect.result(fs.readLink(existing));
72
+ if (link._tag === "Success") {
73
+ hops += 1;
74
+ if (hops > MAX_LINK_HOPS) return false;
75
+ const parent = yield* Effect.result(fs.realPath(path.dirname(existing)));
76
+ if (parent._tag === "Failure") return false;
77
+ existing = path.resolve(parent.success, link.success, ...tail);
78
+ tail = [];
79
+ continue;
80
+ }
81
+ if (!isNotFound(link.failure)) return false;
82
+ const parent = path.dirname(existing);
83
+ if (parent === existing) return false;
84
+ tail = [path.basename(existing), ...tail];
85
+ existing = parent;
86
+ }
87
+ });
88
+ };
89
+
90
+ export { PathContainment };
package/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Context, Duration, Effect, Option, Schema, Scope } from "effect";
1
+ import { Context, Duration, Effect, FileSystem, Option, Path, Schema, Scope } from "effect";
2
2
  /**
3
3
  * The carrier package a tool's bins were installed through.
4
4
  *
@@ -400,6 +400,52 @@ export declare class LaunchContext {
400
400
  */
401
401
  static readonly projectDir: (input: ProjectDirInput) => string;
402
402
  }
403
+ /**
404
+ * Decides whether a caller-supplied path stays inside a root.
405
+ *
406
+ * @remarks
407
+ * Audience: a handler that must refuse a path outside its workspace before
408
+ * reading or writing it. Neither form fails: "outside" is the answer `false`,
409
+ * and the filesystem is required in `R` from core, never owned here. A
410
+ * relative `candidate` is resolved against `root`, never the process working
411
+ * directory.
412
+ *
413
+ * {@link PathContainment.isWithinLexical} is pure and compares spellings, so a
414
+ * link inside the root that points out is invisible to it. Use
415
+ * {@link PathContainment.isWithin} for anything that will touch the disk.
416
+ *
417
+ * @public
418
+ */
419
+ export declare class PathContainment {
420
+ private constructor();
421
+ /**
422
+ * Whether `candidate` is `root` or lies under it, comparing resolved
423
+ * spellings only (`..` segments and trailing separators are normalised;
424
+ * links are not followed). A sibling sharing a prefix (`/repo-other` against
425
+ * `/repo`) is outside. A relative `candidate` is resolved against `root`.
426
+ */
427
+ static isWithinLexical(path: Path.Path, root: string, candidate: string): boolean;
428
+ /**
429
+ * Whether `candidate` is `root` or lies under it once links are resolved.
430
+ *
431
+ * @remarks
432
+ * The candidate need not exist: its nearest existing ancestor is resolved
433
+ * with `realPath` and the missing tail appended, so a path about to be
434
+ * created is judged by where its parent really is. A component that is a
435
+ * dangling link is followed to where it points (a link out of the root is
436
+ * outside even when its target is absent), up to a fixed number of hops;
437
+ * a loop is outside.
438
+ *
439
+ * Fails closed: only a `NotFound` counts as absent. Any other failure on a
440
+ * candidate component (permission denied, a link loop, a non-directory
441
+ * parent) answers `false`. The lexical answer stands in only when the root's
442
+ * own real path is `NotFound`; any other failure reading it is `false`.
443
+ *
444
+ * A point-in-time check: the filesystem can change between this answer and
445
+ * the read or write it guards.
446
+ */
447
+ static readonly isWithin: (root: string, candidate: string) => Effect.Effect<boolean, never, FileSystem.FileSystem | Path.Path>;
448
+ }
403
449
  /**
404
450
  * The working-directory half of a process, structurally. Node's `process`
405
451
  * satisfies it; pass it in, so nothing here reads `process` itself.
package/index.js CHANGED
@@ -2,7 +2,8 @@ import { CurrentDistribution, Distribution, DistributionField, distributionSuffi
2
2
  import { ErrorText } from "./ErrorText.js";
3
3
  import { Exclusive, ExclusiveTimeout } from "./Exclusive.js";
4
4
  import { LaunchContext } from "./LaunchContext.js";
5
+ import { PathContainment } from "./PathContainment.js";
5
6
  import { ProcessScope, ProcessScopeError } from "./ProcessScope.js";
6
7
  import { Remediation } from "./Remediation.js";
7
8
 
8
- export { CurrentDistribution, Distribution, DistributionField, ErrorText, Exclusive, ExclusiveTimeout, LaunchContext, ProcessScope, ProcessScopeError, Remediation, distributionSuffix, withDistribution, withDistributionVersion };
9
+ export { CurrentDistribution, Distribution, DistributionField, ErrorText, Exclusive, ExclusiveTimeout, LaunchContext, PathContainment, ProcessScope, ProcessScopeError, Remediation, distributionSuffix, withDistribution, withDistributionVersion };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/kit",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "private": false,
5
5
  "description": "Platform-free primitives shared by every front end of an Effect v4 tool: distribution identity, remediation, launch context, and transport-neutral process crash guards",
6
6
  "keywords": [