@effected/walker 0.18.0 → 0.20.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/Confine.js +191 -0
- package/Descend.js +3 -11
- package/Expand.js +1 -2
- package/README.md +1 -0
- package/Walker.js +1 -2
- package/index.d.ts +134 -7
- package/index.js +2 -1
- package/internal/segments.js +25 -0
- package/package.json +2 -2
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,6 +1,7 @@
|
|
|
1
|
+
import { resolveSegments } from "./internal/segments.js";
|
|
1
2
|
import { Effect, FileSystem, Path, Schema } from "effect";
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
// src/Descend.ts
|
|
4
5
|
/**
|
|
5
6
|
* Typed failure raised by `descend`: a directory mid-walk was unreadable
|
|
6
7
|
* (under `onUnreadable: "fail"`), or the walk descended past `maxDepth`. Depth
|
|
@@ -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. */
|
|
@@ -168,5 +161,4 @@ function descend(pattern, options) {
|
|
|
168
161
|
return descendImpl(pattern, options);
|
|
169
162
|
}
|
|
170
163
|
|
|
171
|
-
//#endregion
|
|
172
164
|
export { DescendError, descend };
|
package/Expand.js
CHANGED
|
@@ -2,7 +2,7 @@ import { DescendError, descend } from "./Descend.js";
|
|
|
2
2
|
import { Effect, Result, Schema } from "effect";
|
|
3
3
|
import { GlobPattern, GlobPatternError } from "@effected/glob";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
// src/Expand.ts
|
|
6
6
|
/**
|
|
7
7
|
* Typed failure raised by {@link compileAndExpand}: the single error the
|
|
8
8
|
* compile+expand recipe fails with, so a caller catches one tag rather than
|
|
@@ -97,5 +97,4 @@ const compileAndExpand = Effect.fn("Walker.compileAndExpand")(function* (pattern
|
|
|
97
97
|
})));
|
|
98
98
|
});
|
|
99
99
|
|
|
100
|
-
//#endregion
|
|
101
100
|
export { GlobExpansionError, compileAndExpand };
|
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,6 +1,6 @@
|
|
|
1
1
|
import { Effect, FileSystem, Option, Path } from "effect";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// src/Walker.ts
|
|
4
4
|
const ascend = (start, options) => Effect.gen(function* () {
|
|
5
5
|
const path = yield* Path.Path;
|
|
6
6
|
const maxDepth = options?.maxDepth ?? 256;
|
|
@@ -154,5 +154,4 @@ var Walker = class {
|
|
|
154
154
|
static findRoot = findRoot;
|
|
155
155
|
};
|
|
156
156
|
|
|
157
|
-
//#endregion
|
|
158
157
|
export { Walker };
|
package/index.d.ts
CHANGED
|
@@ -1,6 +1,138 @@
|
|
|
1
|
+
import { Effect, FileSystem, Option, Path, PlatformError, Result, Schema } from "effect";
|
|
1
2
|
import { GlobPattern, GlobPatternError, GlobPatternOptions } from "@effected/glob";
|
|
2
|
-
|
|
3
|
-
|
|
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>;
|
|
4
136
|
/**
|
|
5
137
|
* Options for `descend`.
|
|
6
138
|
*
|
|
@@ -200,8 +332,6 @@ export declare function descend(pattern: GlobPattern, options: DescendRecordOpti
|
|
|
200
332
|
* @public
|
|
201
333
|
*/
|
|
202
334
|
export declare function descend(pattern: GlobPattern, options: DescendOptions): Effect.Effect<ReadonlyArray<string>, DescendError, FileSystem.FileSystem | Path.Path>;
|
|
203
|
-
//#endregion
|
|
204
|
-
//#region src/Expand.d.ts
|
|
205
335
|
/**
|
|
206
336
|
* Options for {@link compileAndExpand}: every {@link DescendOptions} field,
|
|
207
337
|
* plus the glob options the pattern compiles under.
|
|
@@ -303,8 +433,6 @@ export declare class GlobExpansionError extends GlobExpansionError_base {
|
|
|
303
433
|
* @public
|
|
304
434
|
*/
|
|
305
435
|
export declare const compileAndExpand: (pattern: string, options: CompileAndExpandOptions) => Effect.Effect<ReadonlyArray<string>, GlobExpansionError, FileSystem.FileSystem | Path.Path>;
|
|
306
|
-
//#endregion
|
|
307
|
-
//#region src/Walker.d.ts
|
|
308
436
|
/**
|
|
309
437
|
* Options for {@link Walker.ascend}.
|
|
310
438
|
*
|
|
@@ -465,6 +593,5 @@ export declare class Walker {
|
|
|
465
593
|
*/
|
|
466
594
|
static readonly findRoot: <E, R>(dirs: ReadonlyArray<string>, isRoot: (dir: string) => Effect.Effect<boolean, E, R>) => Effect.Effect<Option.Option<string>, never, R>;
|
|
467
595
|
}
|
|
468
|
-
//#endregion
|
|
469
596
|
export type { AscendOptions, CompileAndExpandOptions, DescendOptions, DescendRecordOptions, DescendResult, UnreadableDirectory };
|
|
470
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.0",
|
|
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": [
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"./package.json": "./package.json"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
|
-
"@effected/glob": "^0.
|
|
40
|
+
"@effected/glob": "^0.14.0",
|
|
41
41
|
"effect": "^4.0.0"
|
|
42
42
|
},
|
|
43
43
|
"engines": {
|