@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.
- package/PathContainment.js +90 -0
- package/index.d.ts +47 -1
- package/index.js +2 -1
- package/package.json +1 -1
|
@@ -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.
|
|
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": [
|