@effected/walker 0.19.0 → 0.20.1
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/Confine.js +191 -0
- package/Descend.js +2 -9
- package/README.md +1 -0
- package/Walker.js +45 -40
- package/index.d.ts +139 -6
- package/index.js +2 -1
- package/internal/segments.js +25 -0
- package/package.json +1 -1
- package/tsdoc-metadata.json +1 -1
package/Confine.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { resolveSegments } from "./internal/segments.js";
|
|
2
|
+
import { Effect, Result, Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
// src/Confine.ts
|
|
5
|
+
/**
|
|
6
|
+
* Why {@link confineRelativeResult} refused a candidate path.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* - `nulByte` — the candidate holds a NUL character, which no filesystem
|
|
10
|
+
* path can carry and which some APIs truncate at.
|
|
11
|
+
* - `absolute` — the candidate is rooted: a leading `/` or `\` (which covers
|
|
12
|
+
* UNC `\\server\share` and `//server/share`), or a win32 drive prefix such
|
|
13
|
+
* as `C:\x`, `C:/x` or the drive-relative `C:x`, before or after
|
|
14
|
+
* normalisation (`./C:/x` and `og/../C:x` are absolute too).
|
|
15
|
+
* - `traversal` — after normalisation a `..` segment climbs above the base.
|
|
16
|
+
* - `empty` — the candidate names the base itself: `""`, `.`, `./`, or a
|
|
17
|
+
* path such as `og/..` that normalises to nothing.
|
|
18
|
+
* - `reservedName` — a normalised segment win32 reads as something other
|
|
19
|
+
* than a plain entry: one holding a `:` (a drive designator, or an NTFS
|
|
20
|
+
* alternate data stream such as `x::$DATA`), one made only of dots and
|
|
21
|
+
* spaces (`...`, `.. `, which win32 trims to nothing or to `..`), or a
|
|
22
|
+
* reserved device name (`CON`, `PRN`, `AUX`, `NUL`, `COM1`–`COM9`,
|
|
23
|
+
* `LPT1`–`LPT9`), in any case and with or without an extension
|
|
24
|
+
* (`nul.txt`).
|
|
25
|
+
*
|
|
26
|
+
* @public
|
|
27
|
+
*/
|
|
28
|
+
const PathEscapeReason = Schema.Literals([
|
|
29
|
+
"nulByte",
|
|
30
|
+
"absolute",
|
|
31
|
+
"traversal",
|
|
32
|
+
"empty",
|
|
33
|
+
"reservedName"
|
|
34
|
+
]);
|
|
35
|
+
/**
|
|
36
|
+
* An untrusted relative path would leave, or does not name anything under,
|
|
37
|
+
* the base directory it was confined to.
|
|
38
|
+
*
|
|
39
|
+
* @remarks
|
|
40
|
+
* This is caller input, not a wiring defect: recover from it with
|
|
41
|
+
* `Effect.catchTag("PathEscapeError", ...)`, typically by rejecting the
|
|
42
|
+
* archive or manifest that named the path. The candidate is carried
|
|
43
|
+
* verbatim, and the message renders it JSON-quoted so a control character
|
|
44
|
+
* in it cannot forge a log line.
|
|
45
|
+
*
|
|
46
|
+
* @public
|
|
47
|
+
*/
|
|
48
|
+
var PathEscapeError = class extends Schema.TaggedError()("PathEscapeError", {
|
|
49
|
+
/** The candidate path, exactly as the caller passed it. */
|
|
50
|
+
candidate: Schema.String,
|
|
51
|
+
/** Why the candidate was refused. */
|
|
52
|
+
reason: PathEscapeReason
|
|
53
|
+
}) {
|
|
54
|
+
get message() {
|
|
55
|
+
return `the path ${JSON.stringify(this.candidate)} ${REASON_TEXT[this.reason]}`;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
/** The predicate of each refusal's message, after the quoted candidate. */
|
|
59
|
+
const REASON_TEXT = {
|
|
60
|
+
nulByte: "contains a NUL character",
|
|
61
|
+
absolute: "is absolute, so it is not confined to the base directory",
|
|
62
|
+
traversal: "climbs above the base directory",
|
|
63
|
+
empty: "names the base directory itself, not an entry under it",
|
|
64
|
+
reservedName: "has a segment win32 reserves or reads as something else"
|
|
65
|
+
};
|
|
66
|
+
/** A win32 drive prefix: `C:\x`, `C:/x`, and the drive-relative `C:x`. */
|
|
67
|
+
const DRIVE_PREFIX = /^[A-Za-z]:/;
|
|
68
|
+
/** A segment of dots and spaces only, which win32 trims to nothing or to `..`. */
|
|
69
|
+
const DOTS_AND_SPACES = /^[. ]+$/;
|
|
70
|
+
/** A win32 reserved device name, with or without an extension, in any case. */
|
|
71
|
+
const DEVICE_NAME = /^(?:con|prn|aux|nul|com[0-9\u00b9\u00b2\u00b3]|lpt[0-9\u00b9\u00b2\u00b3]|conin\$|conout\$) *(?:\..*)?$/i;
|
|
72
|
+
/** Whether win32 reads a normalised segment as something other than a plain entry. */
|
|
73
|
+
const isReservedSegment = (segment) => segment.includes(":") || DOTS_AND_SPACES.test(segment) || DEVICE_NAME.test(segment);
|
|
74
|
+
/** `path` without trailing `/` or `\`: a loop, because `/[\\/]+$/` backtracks quadratically on a long separator run. */
|
|
75
|
+
const stripTrailingSeparators = (path) => {
|
|
76
|
+
let end = path.length;
|
|
77
|
+
while (end > 0 && (path[end - 1] === "/" || path[end - 1] === "\\")) end -= 1;
|
|
78
|
+
return path.slice(0, end);
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Confine an untrusted relative path to a base directory, synchronously.
|
|
82
|
+
*
|
|
83
|
+
* @remarks
|
|
84
|
+
* The check is lexical and does no IO. Both `/` and `\` are separators,
|
|
85
|
+
* whatever the platform, so a backslash variant of an escape is caught on
|
|
86
|
+
* POSIX as well as on win32. In order, a candidate is refused when it holds
|
|
87
|
+
* a NUL character, when it is absolute (POSIX `/x`, UNC `\\server\share`,
|
|
88
|
+
* a win32 drive form such as `C:\x` or `C:x`), when a `..` segment climbs
|
|
89
|
+
* above the base after normalisation (`og/../x` is fine, `og/../../x` is
|
|
90
|
+
* not), or when it normalises to the base itself (`""`, `.`, `og/..`). The
|
|
91
|
+
* last is a deliberate choice: a caller confining a path means to name an
|
|
92
|
+
* entry under the base, and an archive entry naming its own root is malformed.
|
|
93
|
+
* The normalised path is then checked again: a first segment with a drive
|
|
94
|
+
* prefix is absolute (`./C:/x` would otherwise survive an empty base as
|
|
95
|
+
* `C:/x`), and any segment win32 reserves (a `:`, dots and spaces only, a
|
|
96
|
+
* device name such as `CON` or `nul.txt`) is refused as `reservedName`, on
|
|
97
|
+
* every platform, so an archive that is safe on POSIX is safe on win32.
|
|
98
|
+
*
|
|
99
|
+
* No percent-decoding happens. `%2e%2e%2fx` is one ordinary file name, which
|
|
100
|
+
* is exactly how a filesystem reads it, so it stays confined; a caller whose
|
|
101
|
+
* input is URL-encoded decodes first and confines the decoded text.
|
|
102
|
+
*
|
|
103
|
+
* On success the result is the base joined with the normalised candidate:
|
|
104
|
+
* `.` and empty segments dropped, `..` resolved, segments joined with `/`.
|
|
105
|
+
* The base is kept verbatim apart from trailing separators, so a win32 base
|
|
106
|
+
* yields `C:\out/og/x`, which every Node filesystem API accepts. An empty
|
|
107
|
+
* base yields the normalised relative path alone. The check is lexical, not
|
|
108
|
+
* physical, and symlinks are out of scope: a symlink already inside the
|
|
109
|
+
* base can still point out of it, so a caller extracting into a directory
|
|
110
|
+
* it does not own resolves links separately.
|
|
111
|
+
*
|
|
112
|
+
* {@link confineRelative} is defined in terms of this function and adds
|
|
113
|
+
* only the tracing span.
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* ```ts
|
|
117
|
+
* import { confineRelativeResult } from "@effected/walker";
|
|
118
|
+
* import { Result } from "effect";
|
|
119
|
+
*
|
|
120
|
+
* const ok = confineRelativeResult("/srv/site", "og\\card.png");
|
|
121
|
+
* // Result.succeed("/srv/site/og/card.png")
|
|
122
|
+
* const bad = confineRelativeResult("/srv/site", "og/../../etc/passwd");
|
|
123
|
+
* // Result.fail(PathEscapeError { reason: "traversal" })
|
|
124
|
+
* if (Result.isFailure(bad)) console.error(bad.failure.message);
|
|
125
|
+
* ```
|
|
126
|
+
*
|
|
127
|
+
* @param base - The directory the candidate must stay under.
|
|
128
|
+
* @param candidate - The untrusted relative path.
|
|
129
|
+
* @returns A `Result` succeeding with the joined path, or failing with
|
|
130
|
+
* {@link PathEscapeError}.
|
|
131
|
+
*
|
|
132
|
+
* @public
|
|
133
|
+
*/
|
|
134
|
+
const confineRelativeResult = (base, candidate) => {
|
|
135
|
+
if (candidate.includes("\0")) return Result.fail(new PathEscapeError({
|
|
136
|
+
candidate,
|
|
137
|
+
reason: "nulByte"
|
|
138
|
+
}));
|
|
139
|
+
const unified = candidate.replaceAll("\\", "/");
|
|
140
|
+
if (unified.startsWith("/") || DRIVE_PREFIX.test(unified)) return Result.fail(new PathEscapeError({
|
|
141
|
+
candidate,
|
|
142
|
+
reason: "absolute"
|
|
143
|
+
}));
|
|
144
|
+
const segments = resolveSegments(unified.split("/"));
|
|
145
|
+
if (segments === void 0) return Result.fail(new PathEscapeError({
|
|
146
|
+
candidate,
|
|
147
|
+
reason: "traversal"
|
|
148
|
+
}));
|
|
149
|
+
if (segments.length === 0) return Result.fail(new PathEscapeError({
|
|
150
|
+
candidate,
|
|
151
|
+
reason: "empty"
|
|
152
|
+
}));
|
|
153
|
+
if (DRIVE_PREFIX.test(segments[0] ?? "")) return Result.fail(new PathEscapeError({
|
|
154
|
+
candidate,
|
|
155
|
+
reason: "absolute"
|
|
156
|
+
}));
|
|
157
|
+
if (segments.some(isReservedSegment)) return Result.fail(new PathEscapeError({
|
|
158
|
+
candidate,
|
|
159
|
+
reason: "reservedName"
|
|
160
|
+
}));
|
|
161
|
+
const relative = segments.join("/");
|
|
162
|
+
return Result.succeed(base === "" ? relative : `${stripTrailingSeparators(base)}/${relative}`);
|
|
163
|
+
};
|
|
164
|
+
/**
|
|
165
|
+
* Confine an untrusted relative path to a base directory.
|
|
166
|
+
*
|
|
167
|
+
* @remarks
|
|
168
|
+
* Defined in terms of {@link confineRelativeResult}, whose remarks carry the
|
|
169
|
+
* full contract; this form adds the `Walker.confineRelative` tracing span
|
|
170
|
+
* and puts {@link PathEscapeError} on the error channel.
|
|
171
|
+
*
|
|
172
|
+
* @example
|
|
173
|
+
* ```ts
|
|
174
|
+
* import { confineRelative } from "@effected/walker";
|
|
175
|
+
* import { Effect } from "effect";
|
|
176
|
+
*
|
|
177
|
+
* const target = confineRelative("/srv/site", "og/card.png").pipe(
|
|
178
|
+
* Effect.catchTag("PathEscapeError", (error) => Effect.die(error)),
|
|
179
|
+
* );
|
|
180
|
+
* ```
|
|
181
|
+
*
|
|
182
|
+
* @param base - The directory the candidate must stay under.
|
|
183
|
+
* @param candidate - The untrusted relative path.
|
|
184
|
+
* @returns An `Effect` succeeding with the joined path, or failing with
|
|
185
|
+
* {@link PathEscapeError}.
|
|
186
|
+
*
|
|
187
|
+
* @public
|
|
188
|
+
*/
|
|
189
|
+
const confineRelative = Effect.fn("Walker.confineRelative")((base, candidate) => Effect.fromResult(confineRelativeResult(base, candidate)));
|
|
190
|
+
|
|
191
|
+
export { PathEscapeError, PathEscapeReason, confineRelative, confineRelativeResult };
|
package/Descend.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveSegments } from "./internal/segments.js";
|
|
1
2
|
import { Effect, FileSystem, Path, Schema } from "effect";
|
|
2
3
|
|
|
3
4
|
// src/Descend.ts
|
|
@@ -33,15 +34,7 @@ const DEFAULT_PRUNE = ["node_modules", ".git"];
|
|
|
33
34
|
* ever happens (a pattern must not enumerate the tree above its documented
|
|
34
35
|
* root).
|
|
35
36
|
*/
|
|
36
|
-
const escapesCwd = (relative) =>
|
|
37
|
-
let depth = 0;
|
|
38
|
-
for (const segment of relative.split("/")) {
|
|
39
|
-
if (segment === "" || segment === ".") continue;
|
|
40
|
-
depth += segment === ".." ? -1 : 1;
|
|
41
|
-
if (depth < 0) return true;
|
|
42
|
-
}
|
|
43
|
-
return false;
|
|
44
|
-
};
|
|
37
|
+
const escapesCwd = (relative) => resolveSegments(relative.split("/")) === void 0;
|
|
45
38
|
/** Shared empty ancestor chain: frames under `followSymlinks: false` never consult theirs. */
|
|
46
39
|
const NO_ANCESTORS = Object.freeze([]);
|
|
47
40
|
/** Not exported — `descend`'s overloads below carry the public documentation. */
|
package/README.md
CHANGED
|
@@ -95,6 +95,7 @@ import { Walker, compileAndExpand, descend } from "@effected/walker";
|
|
|
95
95
|
- `Walker.findRoot(dirs, isRoot)` — the nearest directory a marker predicate accepts. `firstMatch` where the candidate expansion is the identity.
|
|
96
96
|
- `descend(pattern, options)` — the file paths a compiled `@effected/glob` pattern selects under `cwd`, POSIX separators, sorted. `onUnreadable` decides what an unreadable directory mid-walk means: `"fail"` (the default) raises a typed `DescendError`, `"skip"` continues past it and forgets it, and `"record"` continues past it and returns a `DescendResult` — `{ matches, unreadable }`, where each `UnreadableDirectory` carries the directory's `cwd`-relative `path` and the `PlatformError` it failed with, so a report never has to re-read the directory to learn why. The walk base records as `path: ""`, and a directory that vanished mid-walk (`NotFound`) is a benign race, never recorded. `followSymlinks` (default `false`) decides symlinked directories: by default they are never entered (cycle safety); under `true` they are descended under `@actions/glob`'s `traversalChain` cycle guard — a directory is a cycle only when its real path is already an ancestor of the current branch, so link loops terminate while two sibling links resolving to the same target both enumerate; a link whose real path cannot be resolved is never entered, and that failure follows `onUnreadable` exactly as an unreadable directory does — matching `@actions/glob`'s default `followSymbolicLinks: true` (Node's recursive `readdir` also follows links, but keeps no traversal chain and recurses without bound on a loop).
|
|
97
97
|
- `compileAndExpand(pattern, options)` — `descend` from a glob **string**: compiles it with `@effected/glob` (`options.glob`) and expands it, failing typed with `GlobExpansionError` when the pattern does not compile.
|
|
98
|
+
- `confineRelativeResult(base, candidate)` — keep an untrusted relative path (an archive entry, a manifest's asset path) under `base`, synchronously, as a `Result<string, PathEscapeError>`. Both `/` and `\` are separators; a NUL character, an absolute form (POSIX `/x`, UNC `\\server\share`, win32 `C:\x` or `C:x`), a `..` that climbs above `base` after normalisation (`og/../x` is fine, `og/../../x` is not) a path naming `base` itself (`""`, `.`, `og/..`), and a segment win32 reserves (a `:` such as an alternate data stream, dots and spaces only, a device name such as `CON` or `nul.txt`) are refused, with the reason on the error. Lexical and IO-free, with no percent-decoding; the success value is `base` joined with the normalised path. Symlinks are out of scope: the check is lexical. `confineRelative(base, candidate)` is the `Effect` twin.
|
|
98
99
|
|
|
99
100
|
## License
|
|
100
101
|
|
package/Walker.js
CHANGED
|
@@ -1,32 +1,11 @@
|
|
|
1
1
|
import { Effect, FileSystem, Option, Path } from "effect";
|
|
2
2
|
|
|
3
3
|
// src/Walker.ts
|
|
4
|
-
const ascend = (start, options) => Effect.gen(function* () {
|
|
5
|
-
const path = yield* Path.Path;
|
|
6
|
-
const maxDepth = options?.maxDepth ?? 256;
|
|
7
|
-
if (!Number.isInteger(maxDepth) || maxDepth < 1) return yield* Effect.die(/* @__PURE__ */ new Error(`Walker.ascend: maxDepth must be a positive integer, received ${maxDepth}`));
|
|
8
|
-
if (options?.stopAt !== void 0 && !path.isAbsolute(options.stopAt)) return yield* Effect.die(/* @__PURE__ */ new Error(`Walker.ascend: stopAt must be an absolute path, received ${JSON.stringify(options.stopAt)} (ascending from ${JSON.stringify(start)})`));
|
|
9
|
-
const ceiling = options?.stopAt === void 0 ? void 0 : path.resolve(options.stopAt);
|
|
10
|
-
const dirs = [];
|
|
11
|
-
let current = start;
|
|
12
|
-
for (let depth = 0; depth < maxDepth; depth++) {
|
|
13
|
-
dirs.push(current);
|
|
14
|
-
if (ceiling !== void 0 && (current === ceiling || path.resolve(current) === ceiling)) break;
|
|
15
|
-
const parent = path.dirname(current);
|
|
16
|
-
if (parent === current) break;
|
|
17
|
-
current = parent;
|
|
18
|
-
}
|
|
19
|
-
return dirs;
|
|
20
|
-
});
|
|
21
|
-
const ascendWithin = (start, ceiling, options) => Effect.gen(function* () {
|
|
22
|
-
if (Option.isNone(ceiling)) return yield* ascend(start, options);
|
|
23
|
-
return yield* ascendToPhysical(start, ceiling.value, options);
|
|
24
|
-
});
|
|
25
4
|
const ascendToPhysical = (start, ceiling, options) => Effect.gen(function* () {
|
|
26
5
|
const fs = yield* FileSystem.FileSystem;
|
|
27
6
|
const path = yield* Path.Path;
|
|
28
7
|
if (!path.isAbsolute(ceiling)) return yield* Effect.die(/* @__PURE__ */ new Error(`Walker.ascendWithin: ceiling must be an absolute path, received ${JSON.stringify(ceiling)} (ascending from ${JSON.stringify(start)})`));
|
|
29
|
-
const dirs = yield* ascend(start, {
|
|
8
|
+
const dirs = yield* Walker.ascend(start, {
|
|
30
9
|
...options,
|
|
31
10
|
stopAt: ceiling
|
|
32
11
|
});
|
|
@@ -34,27 +13,16 @@ const ascendToPhysical = (start, ceiling, options) => Effect.gen(function* () {
|
|
|
34
13
|
if (last !== void 0 && path.resolve(last) === path.resolve(ceiling)) return dirs;
|
|
35
14
|
const physical = yield* Effect.option(fs.realPath(ceiling));
|
|
36
15
|
if (Option.isNone(physical)) return dirs;
|
|
37
|
-
const hit = yield* findRoot(dirs, (dir) => Effect.map(fs.realPath(dir), (real) => real === physical.value));
|
|
16
|
+
const hit = yield* Walker.findRoot(dirs, (dir) => Effect.map(fs.realPath(dir), (real) => real === physical.value));
|
|
38
17
|
return Option.isSome(hit) ? dirs.slice(0, dirs.indexOf(hit.value) + 1) : dirs;
|
|
39
18
|
});
|
|
40
|
-
const firstMatch = (candidates, predicate) => Effect.gen(function* () {
|
|
41
|
-
for (const candidate of candidates) if (yield* Effect.catch(predicate(candidate), () => Effect.succeed(false))) return Option.some(candidate);
|
|
42
|
-
return Option.none();
|
|
43
|
-
});
|
|
44
|
-
const findUpward = (dirs, candidatesFor) => Effect.gen(function* () {
|
|
45
|
-
const fs = yield* FileSystem.FileSystem;
|
|
46
|
-
const candidates = [];
|
|
47
|
-
for (const dir of dirs) candidates.push(...candidatesFor(dir));
|
|
48
|
-
return yield* firstMatch(candidates, (candidate) => fs.exists(candidate));
|
|
49
|
-
});
|
|
50
|
-
const findRoot = (dirs, isRoot) => firstMatch(dirs, isRoot);
|
|
51
19
|
/**
|
|
52
20
|
* Upward path traversal primitives: the directory chain from a start path to the
|
|
53
21
|
* root, and first-match scans over it.
|
|
54
22
|
*
|
|
55
23
|
* @public
|
|
56
24
|
*/
|
|
57
|
-
var Walker = class {
|
|
25
|
+
var Walker = class Walker {
|
|
58
26
|
constructor() {}
|
|
59
27
|
/**
|
|
60
28
|
* Ascend from `start` toward the filesystem root, yielding each directory,
|
|
@@ -90,7 +58,25 @@ var Walker = class {
|
|
|
90
58
|
* // Requires `Path` from the platform layer.
|
|
91
59
|
* ```
|
|
92
60
|
*/
|
|
93
|
-
static ascend
|
|
61
|
+
static ascend(start, options) {
|
|
62
|
+
return Effect.gen(function* () {
|
|
63
|
+
const path = yield* Path.Path;
|
|
64
|
+
const maxDepth = options?.maxDepth ?? 256;
|
|
65
|
+
if (!Number.isInteger(maxDepth) || maxDepth < 1) return yield* Effect.die(/* @__PURE__ */ new Error(`Walker.ascend: maxDepth must be a positive integer, received ${maxDepth}`));
|
|
66
|
+
if (options?.stopAt !== void 0 && !path.isAbsolute(options.stopAt)) return yield* Effect.die(/* @__PURE__ */ new Error(`Walker.ascend: stopAt must be an absolute path, received ${JSON.stringify(options.stopAt)} (ascending from ${JSON.stringify(start)})`));
|
|
67
|
+
const ceiling = options?.stopAt === void 0 ? void 0 : path.resolve(options.stopAt);
|
|
68
|
+
const dirs = [];
|
|
69
|
+
let current = start;
|
|
70
|
+
for (let depth = 0; depth < maxDepth; depth++) {
|
|
71
|
+
dirs.push(current);
|
|
72
|
+
if (ceiling !== void 0 && (current === ceiling || path.resolve(current) === ceiling)) break;
|
|
73
|
+
const parent = path.dirname(current);
|
|
74
|
+
if (parent === current) break;
|
|
75
|
+
current = parent;
|
|
76
|
+
}
|
|
77
|
+
return dirs;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
94
80
|
/**
|
|
95
81
|
* {@link Walker.ascend} bounded by a PHYSICAL ceiling: stops at the nearest
|
|
96
82
|
* directory whose real path is the ceiling's real path, inclusive.
|
|
@@ -118,7 +104,12 @@ var Walker = class {
|
|
|
118
104
|
* A present ceiling must be absolute; a relative one **dies**, for the
|
|
119
105
|
* reasons {@link AscendOptions.stopAt} gives.
|
|
120
106
|
*/
|
|
121
|
-
static ascendWithin
|
|
107
|
+
static ascendWithin(start, ceiling, options) {
|
|
108
|
+
return Effect.gen(function* () {
|
|
109
|
+
if (Option.isNone(ceiling)) return yield* Walker.ascend(start, options);
|
|
110
|
+
return yield* ascendToPhysical(start, ceiling.value, options);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
122
113
|
/**
|
|
123
114
|
* The first candidate whose `predicate` reports true, or `Option.none()`.
|
|
124
115
|
*
|
|
@@ -133,7 +124,12 @@ var Walker = class {
|
|
|
133
124
|
* programmer error and surfaces as a defect. Do not change this to
|
|
134
125
|
* `Effect.catchCause` — the distinction is load-bearing.
|
|
135
126
|
*/
|
|
136
|
-
static firstMatch
|
|
127
|
+
static firstMatch(candidates, predicate) {
|
|
128
|
+
return Effect.gen(function* () {
|
|
129
|
+
for (const candidate of candidates) if (yield* Effect.catch(predicate(candidate), () => Effect.succeed(false))) return Option.some(candidate);
|
|
130
|
+
return Option.none();
|
|
131
|
+
});
|
|
132
|
+
}
|
|
137
133
|
/**
|
|
138
134
|
* The first existing path among the candidates `candidatesFor` produces for each
|
|
139
135
|
* directory in `dirs`, scanned in order. Nearer directories win.
|
|
@@ -142,7 +138,14 @@ var Walker = class {
|
|
|
142
138
|
* Candidates materialize up front, bounded by `dirs.length × candidatesFor`'s
|
|
143
139
|
* output — a few hundred strings under the default `maxDepth`.
|
|
144
140
|
*/
|
|
145
|
-
static findUpward
|
|
141
|
+
static findUpward(dirs, candidatesFor) {
|
|
142
|
+
return Effect.gen(function* () {
|
|
143
|
+
const fs = yield* FileSystem.FileSystem;
|
|
144
|
+
const candidates = [];
|
|
145
|
+
for (const dir of dirs) candidates.push(...candidatesFor(dir));
|
|
146
|
+
return yield* Walker.firstMatch(candidates, (candidate) => fs.exists(candidate));
|
|
147
|
+
});
|
|
148
|
+
}
|
|
146
149
|
/**
|
|
147
150
|
* The first directory in `dirs` that `isRoot` accepts.
|
|
148
151
|
*
|
|
@@ -151,7 +154,9 @@ var Walker = class {
|
|
|
151
154
|
* identity. `isRoot` is a caller-supplied marker test (a `.git` entry, a
|
|
152
155
|
* `pnpm-workspace.yaml`), and its failures are absorbed per directory.
|
|
153
156
|
*/
|
|
154
|
-
static findRoot
|
|
157
|
+
static findRoot(dirs, isRoot) {
|
|
158
|
+
return Walker.firstMatch(dirs, isRoot);
|
|
159
|
+
}
|
|
155
160
|
};
|
|
156
161
|
|
|
157
162
|
export { Walker };
|
package/index.d.ts
CHANGED
|
@@ -1,5 +1,138 @@
|
|
|
1
|
+
import { Effect, FileSystem, Option, Path, PlatformError, Result, Schema } from "effect";
|
|
1
2
|
import { GlobPattern, GlobPatternError, GlobPatternOptions } from "@effected/glob";
|
|
2
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Why {@link confineRelativeResult} refused a candidate path.
|
|
5
|
+
*
|
|
6
|
+
* @remarks
|
|
7
|
+
* - `nulByte` — the candidate holds a NUL character, which no filesystem
|
|
8
|
+
* path can carry and which some APIs truncate at.
|
|
9
|
+
* - `absolute` — the candidate is rooted: a leading `/` or `\` (which covers
|
|
10
|
+
* UNC `\\server\share` and `//server/share`), or a win32 drive prefix such
|
|
11
|
+
* as `C:\x`, `C:/x` or the drive-relative `C:x`, before or after
|
|
12
|
+
* normalisation (`./C:/x` and `og/../C:x` are absolute too).
|
|
13
|
+
* - `traversal` — after normalisation a `..` segment climbs above the base.
|
|
14
|
+
* - `empty` — the candidate names the base itself: `""`, `.`, `./`, or a
|
|
15
|
+
* path such as `og/..` that normalises to nothing.
|
|
16
|
+
* - `reservedName` — a normalised segment win32 reads as something other
|
|
17
|
+
* than a plain entry: one holding a `:` (a drive designator, or an NTFS
|
|
18
|
+
* alternate data stream such as `x::$DATA`), one made only of dots and
|
|
19
|
+
* spaces (`...`, `.. `, which win32 trims to nothing or to `..`), or a
|
|
20
|
+
* reserved device name (`CON`, `PRN`, `AUX`, `NUL`, `COM1`–`COM9`,
|
|
21
|
+
* `LPT1`–`LPT9`), in any case and with or without an extension
|
|
22
|
+
* (`nul.txt`).
|
|
23
|
+
*
|
|
24
|
+
* @public
|
|
25
|
+
*/
|
|
26
|
+
export declare const PathEscapeReason: Schema.Literals<readonly ["nulByte", "absolute", "traversal", "empty", "reservedName"]>;
|
|
27
|
+
/**
|
|
28
|
+
* The decoded type of {@link (PathEscapeReason:variable)}.
|
|
29
|
+
*
|
|
30
|
+
* @public
|
|
31
|
+
*/
|
|
32
|
+
export type PathEscapeReason = typeof PathEscapeReason.Type;
|
|
33
|
+
declare const PathEscapeError_base: Schema.Class<PathEscapeError, Schema.TaggedStruct<"PathEscapeError", {
|
|
34
|
+
/** The candidate path, exactly as the caller passed it. */
|
|
35
|
+
readonly candidate: Schema.String;
|
|
36
|
+
/** Why the candidate was refused. */
|
|
37
|
+
readonly reason: Schema.Literals<readonly ["nulByte", "absolute", "traversal", "empty", "reservedName"]>;
|
|
38
|
+
}>, import("effect/Cause").YieldableError>;
|
|
39
|
+
/**
|
|
40
|
+
* An untrusted relative path would leave, or does not name anything under,
|
|
41
|
+
* the base directory it was confined to.
|
|
42
|
+
*
|
|
43
|
+
* @remarks
|
|
44
|
+
* This is caller input, not a wiring defect: recover from it with
|
|
45
|
+
* `Effect.catchTag("PathEscapeError", ...)`, typically by rejecting the
|
|
46
|
+
* archive or manifest that named the path. The candidate is carried
|
|
47
|
+
* verbatim, and the message renders it JSON-quoted so a control character
|
|
48
|
+
* in it cannot forge a log line.
|
|
49
|
+
*
|
|
50
|
+
* @public
|
|
51
|
+
*/
|
|
52
|
+
export declare class PathEscapeError extends PathEscapeError_base {
|
|
53
|
+
get message(): string;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Confine an untrusted relative path to a base directory, synchronously.
|
|
57
|
+
*
|
|
58
|
+
* @remarks
|
|
59
|
+
* The check is lexical and does no IO. Both `/` and `\` are separators,
|
|
60
|
+
* whatever the platform, so a backslash variant of an escape is caught on
|
|
61
|
+
* POSIX as well as on win32. In order, a candidate is refused when it holds
|
|
62
|
+
* a NUL character, when it is absolute (POSIX `/x`, UNC `\\server\share`,
|
|
63
|
+
* a win32 drive form such as `C:\x` or `C:x`), when a `..` segment climbs
|
|
64
|
+
* above the base after normalisation (`og/../x` is fine, `og/../../x` is
|
|
65
|
+
* not), or when it normalises to the base itself (`""`, `.`, `og/..`). The
|
|
66
|
+
* last is a deliberate choice: a caller confining a path means to name an
|
|
67
|
+
* entry under the base, and an archive entry naming its own root is malformed.
|
|
68
|
+
* The normalised path is then checked again: a first segment with a drive
|
|
69
|
+
* prefix is absolute (`./C:/x` would otherwise survive an empty base as
|
|
70
|
+
* `C:/x`), and any segment win32 reserves (a `:`, dots and spaces only, a
|
|
71
|
+
* device name such as `CON` or `nul.txt`) is refused as `reservedName`, on
|
|
72
|
+
* every platform, so an archive that is safe on POSIX is safe on win32.
|
|
73
|
+
*
|
|
74
|
+
* No percent-decoding happens. `%2e%2e%2fx` is one ordinary file name, which
|
|
75
|
+
* is exactly how a filesystem reads it, so it stays confined; a caller whose
|
|
76
|
+
* input is URL-encoded decodes first and confines the decoded text.
|
|
77
|
+
*
|
|
78
|
+
* On success the result is the base joined with the normalised candidate:
|
|
79
|
+
* `.` and empty segments dropped, `..` resolved, segments joined with `/`.
|
|
80
|
+
* The base is kept verbatim apart from trailing separators, so a win32 base
|
|
81
|
+
* yields `C:\out/og/x`, which every Node filesystem API accepts. An empty
|
|
82
|
+
* base yields the normalised relative path alone. The check is lexical, not
|
|
83
|
+
* physical, and symlinks are out of scope: a symlink already inside the
|
|
84
|
+
* base can still point out of it, so a caller extracting into a directory
|
|
85
|
+
* it does not own resolves links separately.
|
|
86
|
+
*
|
|
87
|
+
* {@link confineRelative} is defined in terms of this function and adds
|
|
88
|
+
* only the tracing span.
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* ```ts
|
|
92
|
+
* import { confineRelativeResult } from "@effected/walker";
|
|
93
|
+
* import { Result } from "effect";
|
|
94
|
+
*
|
|
95
|
+
* const ok = confineRelativeResult("/srv/site", "og\\card.png");
|
|
96
|
+
* // Result.succeed("/srv/site/og/card.png")
|
|
97
|
+
* const bad = confineRelativeResult("/srv/site", "og/../../etc/passwd");
|
|
98
|
+
* // Result.fail(PathEscapeError { reason: "traversal" })
|
|
99
|
+
* if (Result.isFailure(bad)) console.error(bad.failure.message);
|
|
100
|
+
* ```
|
|
101
|
+
*
|
|
102
|
+
* @param base - The directory the candidate must stay under.
|
|
103
|
+
* @param candidate - The untrusted relative path.
|
|
104
|
+
* @returns A `Result` succeeding with the joined path, or failing with
|
|
105
|
+
* {@link PathEscapeError}.
|
|
106
|
+
*
|
|
107
|
+
* @public
|
|
108
|
+
*/
|
|
109
|
+
export declare const confineRelativeResult: (base: string, candidate: string) => Result.Result<string, PathEscapeError>;
|
|
110
|
+
/**
|
|
111
|
+
* Confine an untrusted relative path to a base directory.
|
|
112
|
+
*
|
|
113
|
+
* @remarks
|
|
114
|
+
* Defined in terms of {@link confineRelativeResult}, whose remarks carry the
|
|
115
|
+
* full contract; this form adds the `Walker.confineRelative` tracing span
|
|
116
|
+
* and puts {@link PathEscapeError} on the error channel.
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* ```ts
|
|
120
|
+
* import { confineRelative } from "@effected/walker";
|
|
121
|
+
* import { Effect } from "effect";
|
|
122
|
+
*
|
|
123
|
+
* const target = confineRelative("/srv/site", "og/card.png").pipe(
|
|
124
|
+
* Effect.catchTag("PathEscapeError", (error) => Effect.die(error)),
|
|
125
|
+
* );
|
|
126
|
+
* ```
|
|
127
|
+
*
|
|
128
|
+
* @param base - The directory the candidate must stay under.
|
|
129
|
+
* @param candidate - The untrusted relative path.
|
|
130
|
+
* @returns An `Effect` succeeding with the joined path, or failing with
|
|
131
|
+
* {@link PathEscapeError}.
|
|
132
|
+
*
|
|
133
|
+
* @public
|
|
134
|
+
*/
|
|
135
|
+
export declare const confineRelative: (base: string, candidate: string) => Effect.Effect<string, PathEscapeError>;
|
|
3
136
|
/**
|
|
4
137
|
* Options for `descend`.
|
|
5
138
|
*
|
|
@@ -397,7 +530,7 @@ export declare class Walker {
|
|
|
397
530
|
* // Requires `Path` from the platform layer.
|
|
398
531
|
* ```
|
|
399
532
|
*/
|
|
400
|
-
static
|
|
533
|
+
static ascend(start: string, options?: AscendOptions): Effect.Effect<ReadonlyArray<string>, never, Path.Path>;
|
|
401
534
|
/**
|
|
402
535
|
* {@link Walker.ascend} bounded by a PHYSICAL ceiling: stops at the nearest
|
|
403
536
|
* directory whose real path is the ceiling's real path, inclusive.
|
|
@@ -425,7 +558,7 @@ export declare class Walker {
|
|
|
425
558
|
* A present ceiling must be absolute; a relative one **dies**, for the
|
|
426
559
|
* reasons {@link AscendOptions.stopAt} gives.
|
|
427
560
|
*/
|
|
428
|
-
static
|
|
561
|
+
static ascendWithin(start: string, ceiling: Option.Option<string>, options?: Pick<AscendOptions, "maxDepth">): Effect.Effect<ReadonlyArray<string>, never, FileSystem.FileSystem | Path.Path>;
|
|
429
562
|
/**
|
|
430
563
|
* The first candidate whose `predicate` reports true, or `Option.none()`.
|
|
431
564
|
*
|
|
@@ -440,7 +573,7 @@ export declare class Walker {
|
|
|
440
573
|
* programmer error and surfaces as a defect. Do not change this to
|
|
441
574
|
* `Effect.catchCause` — the distinction is load-bearing.
|
|
442
575
|
*/
|
|
443
|
-
static
|
|
576
|
+
static firstMatch<E, R>(candidates: ReadonlyArray<string>, predicate: (candidate: string) => Effect.Effect<boolean, E, R>): Effect.Effect<Option.Option<string>, never, R>;
|
|
444
577
|
/**
|
|
445
578
|
* The first existing path among the candidates `candidatesFor` produces for each
|
|
446
579
|
* directory in `dirs`, scanned in order. Nearer directories win.
|
|
@@ -449,7 +582,7 @@ export declare class Walker {
|
|
|
449
582
|
* Candidates materialize up front, bounded by `dirs.length × candidatesFor`'s
|
|
450
583
|
* output — a few hundred strings under the default `maxDepth`.
|
|
451
584
|
*/
|
|
452
|
-
static
|
|
585
|
+
static findUpward(dirs: ReadonlyArray<string>, candidatesFor: (dir: string) => ReadonlyArray<string>): Effect.Effect<Option.Option<string>, never, FileSystem.FileSystem>;
|
|
453
586
|
/**
|
|
454
587
|
* The first directory in `dirs` that `isRoot` accepts.
|
|
455
588
|
*
|
|
@@ -458,7 +591,7 @@ export declare class Walker {
|
|
|
458
591
|
* identity. `isRoot` is a caller-supplied marker test (a `.git` entry, a
|
|
459
592
|
* `pnpm-workspace.yaml`), and its failures are absorbed per directory.
|
|
460
593
|
*/
|
|
461
|
-
static
|
|
594
|
+
static findRoot<E, R>(dirs: ReadonlyArray<string>, isRoot: (dir: string) => Effect.Effect<boolean, E, R>): Effect.Effect<Option.Option<string>, never, R>;
|
|
462
595
|
}
|
|
463
596
|
export type { AscendOptions, CompileAndExpandOptions, DescendOptions, DescendRecordOptions, DescendResult, UnreadableDirectory };
|
|
464
597
|
//# sourceMappingURL=index.d.ts.map
|
package/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { PathEscapeError, PathEscapeReason, confineRelative, confineRelativeResult } from "./Confine.js";
|
|
1
2
|
import { DescendError, descend } from "./Descend.js";
|
|
2
3
|
import { GlobExpansionError, compileAndExpand } from "./Expand.js";
|
|
3
4
|
import { Walker } from "./Walker.js";
|
|
4
5
|
|
|
5
|
-
export { DescendError, GlobExpansionError, Walker, compileAndExpand, descend };
|
|
6
|
+
export { DescendError, GlobExpansionError, PathEscapeError, PathEscapeReason, Walker, compileAndExpand, confineRelative, confineRelativeResult, descend };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// src/internal/segments.ts
|
|
2
|
+
/**
|
|
3
|
+
* Resolve `.` and `..` lexically over path segments, never above the start.
|
|
4
|
+
*
|
|
5
|
+
* Empty and `.` segments are dropped, a `..` pops its parent, and a `..` with
|
|
6
|
+
* nothing left to pop is a climb above the start: the answer is then
|
|
7
|
+
* `undefined`. `og/../x` resolves to `["x"]`; `og/../../x` climbs. The caller
|
|
8
|
+
* splits, because the separator set is the caller's: a glob pattern's
|
|
9
|
+
* backslash is an escape, never a separator.
|
|
10
|
+
*/
|
|
11
|
+
const resolveSegments = (segments) => {
|
|
12
|
+
const kept = [];
|
|
13
|
+
for (const segment of segments) {
|
|
14
|
+
if (segment === "" || segment === ".") continue;
|
|
15
|
+
if (segment === "..") {
|
|
16
|
+
if (kept.length === 0) return void 0;
|
|
17
|
+
kept.pop();
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
kept.push(segment);
|
|
21
|
+
}
|
|
22
|
+
return kept;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export { resolveSegments };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effected/walker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Path traversal as Effect primitives: ascend a directory chain to the first matching candidate, or descend a tree expanding a glob against the filesystem.",
|
|
6
6
|
"keywords": [
|