@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 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
- //#region src/Descend.ts
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
- //#region src/Expand.ts
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
- //#region src/Walker.ts
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
- import { Effect, FileSystem, Option, Path, PlatformError, Schema } from "effect";
3
- //#region src/Descend.d.ts
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.18.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.13.0",
40
+ "@effected/glob": "^0.14.0",
41
41
  "effect": "^4.0.0"
42
42
  },
43
43
  "engines": {