@statewalker/webrun-files-composite 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,257 @@
1
+ import type {
2
+ FileInfo,
3
+ FileStats,
4
+ FilesApi,
5
+ ListOptions,
6
+ ReadOptions,
7
+ } from "@statewalker/webrun-files";
8
+ import { normalizePath } from "@statewalker/webrun-files";
9
+ import { globToRegExp } from "./glob-to-regexp.js";
10
+
11
+ /**
12
+ * Predicate evaluated against a **normalized** path (single leading slash,
13
+ * no trailing slash) by `FilteredFilesApi` to decide visibility.
14
+ *
15
+ * - `true` → the path is visible (the wrapped operation runs).
16
+ * - `false` → the path is hidden (the wrapper short-circuits as if the path
17
+ * did not exist).
18
+ *
19
+ * The predicate may be synchronous or return a `Promise<boolean>`. When
20
+ * async, it is awaited on each call — so prefer pure / cheap checks.
21
+ */
22
+ export type PathFilter = (path: string) => boolean | Promise<boolean>;
23
+
24
+ /**
25
+ * Builds a {@link PathFilter} that hides any path whose normalized form
26
+ * equals one of the provided prefixes or lives under `${prefix}/`.
27
+ *
28
+ * Prefixes are normalized through `normalizePath` (so `"foo"`, `"/foo"`, and
29
+ * `"/foo/"` are equivalent). Empty / root entries are dropped — they would
30
+ * otherwise hide every path.
31
+ *
32
+ * Matching is **boundary-aware**: the prefix `"/priv"` does not match the
33
+ * path `"/private"` because there is no `/` boundary between them.
34
+ *
35
+ * @param pathPrefixes Path prefixes whose contents (and the prefix itself)
36
+ * should be hidden. Pass none to hide nothing.
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * const filter = newPathFilter("/.git", "/node_modules");
41
+ * filter("/src/index.ts"); // true
42
+ * filter("/.git"); // false
43
+ * filter("/.git/HEAD"); // false
44
+ * filter("/notgit"); // true (boundary-aware, no false match)
45
+ * ```
46
+ */
47
+ export function newPathFilter(...pathPrefixes: string[]): PathFilter {
48
+ const normalized = pathPrefixes.map((p) => normalizePath(p)).filter((p) => p !== "/");
49
+ return (path: string) => {
50
+ const target = normalizePath(path);
51
+ for (const prefix of normalized) {
52
+ if (target === prefix) return false;
53
+ if (target.startsWith(`${prefix}/`)) return false;
54
+ }
55
+ return true;
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Builds a {@link PathFilter} that hides any path whose normalized form
61
+ * matches at least one of the provided regular expressions.
62
+ *
63
+ * The path is normalized through `normalizePath` before matching, so a
64
+ * regexp anchored on `^/` always sees a leading slash and never a trailing
65
+ * one. The regexp's `lastIndex` is irrelevant — the filter calls `test`
66
+ * via a fresh evaluation each time, but stateful (`/g`, `/y`) regexps
67
+ * still mutate `lastIndex` across calls; pass non-stateful regexps unless
68
+ * you know what you are doing.
69
+ *
70
+ * @param pathRegexps Regular expressions whose match means "hide this
71
+ * path". Pass none to hide nothing.
72
+ *
73
+ * @example
74
+ * ```ts
75
+ * // Hide every dotfile and every *.log file
76
+ * const filter = newRegexpPathFilter(/\/\.[^/]+$/, /\.log$/);
77
+ * filter("/src/index.ts"); // true
78
+ * filter("/.env"); // false (matches /\/\.[^/]+$/)
79
+ * filter("/build.log"); // false (matches /\.log$/)
80
+ * ```
81
+ */
82
+ export function newRegexpPathFilter(...pathRegexps: RegExp[]): PathFilter {
83
+ return (path: string) => {
84
+ const target = normalizePath(path);
85
+ for (const regexp of pathRegexps) {
86
+ if (regexp.test(target)) return false;
87
+ }
88
+ return true;
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Builds a {@link PathFilter} that hides any path whose normalized form
94
+ * matches at least one of the provided glob patterns.
95
+ *
96
+ * Each glob is compiled with `extended: true` and `globstar: true`, the
97
+ * standard "filesystem-style" mode:
98
+ *
99
+ * - `*` matches any number of characters within a single path segment
100
+ * (does **not** cross `/`).
101
+ * - `**` between slashes matches zero or more whole path segments.
102
+ * - `?` matches exactly one character.
103
+ * - `[abc]` / `[a-z]` matches a single character in the set / range.
104
+ * - `{a,b,c}` matches one of the alternatives.
105
+ *
106
+ * Because matching is done on the **normalized** path (which always starts
107
+ * with `/`), patterns that should match anywhere in the tree need a
108
+ * leading `**​/`, e.g. `**​/*.log` to hide every `.log` file at any depth.
109
+ *
110
+ * Gotcha: `/foo/**` matches descendants of `/foo` but **not** `/foo`
111
+ * itself, because the glob requires a `/` after `foo` before `**` can
112
+ * match. To hide both the directory and its contents, list both prefixes:
113
+ * `newGlobPathFilter("/foo", "/foo/**")`. {@link newPathFilter} doesn't
114
+ * have this problem and may be a better fit for prefix-only hiding.
115
+ *
116
+ * @param pathGlobs Glob patterns whose match means "hide this path". Pass
117
+ * none to hide nothing.
118
+ *
119
+ * @example
120
+ * ```ts
121
+ * const filter = newGlobPathFilter("**​/*.log", "/.git", "/.git/**");
122
+ * filter("/src/index.ts"); // true
123
+ * filter("/build.log"); // false (matches **​/*.log)
124
+ * filter("/.git"); // false (matches /.git)
125
+ * filter("/.git/HEAD"); // false (matches /.git/**)
126
+ * ```
127
+ */
128
+ export function newGlobPathFilter(...pathGlobs: string[]): PathFilter {
129
+ const regexps = pathGlobs.map((glob) => globToRegExp(glob, { extended: true, globstar: true }));
130
+ return newRegexpPathFilter(...regexps);
131
+ }
132
+
133
+ /**
134
+ * `FilesApi` decorator that hides every path the supplied {@link PathFilter}
135
+ * rejects. Hidden paths are treated as if they do not exist:
136
+ *
137
+ * - `read` / `list` yield empty iterables.
138
+ * - `stats` returns `undefined`; `exists` returns `false`.
139
+ * - `remove` returns `false` (no error, nothing changed).
140
+ * - `move` / `copy` return `false` if either endpoint is hidden.
141
+ * - `write` / `mkdir` reject with an `Error` (since silently dropping a
142
+ * write would lose data).
143
+ * - `list` recursively skips entries whose paths are hidden, so iterating a
144
+ * visible parent never reveals a hidden child.
145
+ *
146
+ * Wrap any `FilesApi` to scope its visibility without changing the
147
+ * underlying storage; the wrapped instance still holds the data, it is just
148
+ * not reachable through this decorator.
149
+ *
150
+ * Pair with one of the built-in {@link PathFilter} factories
151
+ * ({@link newPathFilter}, {@link newRegexpPathFilter},
152
+ * {@link newGlobPathFilter}) or pass any predicate of shape
153
+ * `(path) => boolean | Promise<boolean>`.
154
+ *
155
+ * @example
156
+ * ```ts
157
+ * import {
158
+ * FilteredFilesApi,
159
+ * newGlobPathFilter,
160
+ * newPathFilter,
161
+ * newRegexpPathFilter,
162
+ * } from "@statewalker/webrun-files-composite";
163
+ *
164
+ * // Hide by path prefix
165
+ * const noVcs = new FilteredFilesApi(sourceFiles, newPathFilter("/.git", "/.cache"));
166
+ * await noVcs.exists("/.git"); // false
167
+ * await noVcs.write("/.git/x", data); // throws "Path is hidden"
168
+ *
169
+ * // Hide by regexp
170
+ * const noLogs = new FilteredFilesApi(sourceFiles, newRegexpPathFilter(/\.log$/));
171
+ *
172
+ * // Hide by glob (extended + globstar mode)
173
+ * const noJunk = new FilteredFilesApi(
174
+ * sourceFiles,
175
+ * newGlobPathFilter("**​/*.log", "/.git", "/.git/**"),
176
+ * );
177
+ * ```
178
+ */
179
+ export class FilteredFilesApi implements FilesApi {
180
+ private readonly source: FilesApi;
181
+ private readonly pathFilter: PathFilter;
182
+
183
+ /**
184
+ * @param source The underlying `FilesApi` whose paths will be selectively
185
+ * hidden. Operations always delegate to this instance; the decorator
186
+ * only adds the visibility check.
187
+ * @param pathFilter Predicate that decides per-call whether a normalized
188
+ * path is visible. See {@link PathFilter}.
189
+ */
190
+ constructor(source: FilesApi, pathFilter: PathFilter) {
191
+ this.source = source;
192
+ this.pathFilter = pathFilter;
193
+ }
194
+
195
+ protected async isHidden(path: string): Promise<boolean> {
196
+ return (await this.pathFilter(normalizePath(path))) === false;
197
+ }
198
+
199
+ async *read(path: string, options?: ReadOptions): AsyncIterable<Uint8Array> {
200
+ if (await this.isHidden(path)) return;
201
+ yield* this.source.read(path, options);
202
+ }
203
+
204
+ async write(
205
+ path: string,
206
+ content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>,
207
+ ): Promise<void> {
208
+ if (await this.isHidden(path)) {
209
+ throw new Error(`Path is hidden: ${path}`);
210
+ }
211
+ await this.source.write(path, content);
212
+ }
213
+
214
+ async mkdir(path: string): Promise<void> {
215
+ if (await this.isHidden(path)) {
216
+ throw new Error(`Path is hidden: ${path}`);
217
+ }
218
+ await this.source.mkdir(path);
219
+ }
220
+
221
+ async *list(path: string, options?: ListOptions): AsyncIterable<FileInfo> {
222
+ if (await this.isHidden(path)) return;
223
+ for await (const entry of this.source.list(path, options)) {
224
+ if (await this.isHidden(entry.path)) continue;
225
+ yield entry;
226
+ }
227
+ }
228
+
229
+ async stats(path: string): Promise<FileStats | undefined> {
230
+ if (await this.isHidden(path)) return undefined;
231
+ return this.source.stats(path);
232
+ }
233
+
234
+ async exists(path: string): Promise<boolean> {
235
+ if (await this.isHidden(path)) return false;
236
+ return this.source.exists(path);
237
+ }
238
+
239
+ async remove(path: string): Promise<boolean> {
240
+ if (await this.isHidden(path)) return false;
241
+ return this.source.remove(path);
242
+ }
243
+
244
+ async move(source: string, target: string): Promise<boolean> {
245
+ if ((await this.isHidden(source)) || (await this.isHidden(target))) {
246
+ return false;
247
+ }
248
+ return this.source.move(source, target);
249
+ }
250
+
251
+ async copy(source: string, target: string): Promise<boolean> {
252
+ if ((await this.isHidden(source)) || (await this.isHidden(target))) {
253
+ return false;
254
+ }
255
+ return this.source.copy(source, target);
256
+ }
257
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Glob → RegExp compiler.
3
+ *
4
+ * TypeScript port of `glob-to-regexp` by Nick Fitzgerald.
5
+ *
6
+ * Upstream:
7
+ * - https://github.com/fitzgen/glob-to-regexp
8
+ * - source: https://raw.githubusercontent.com/fitzgen/glob-to-regexp/master/index.js
9
+ * - tests: https://raw.githubusercontent.com/fitzgen/glob-to-regexp/master/test.js
10
+ *
11
+ * The original is published under the BSD 2-Clause license — see the
12
+ * upstream repository for the full text. This port preserves the original
13
+ * semantics; only the surface API has been re-typed for TypeScript.
14
+ */
15
+
16
+ export interface GlobToRegExpOptions {
17
+ /**
18
+ * Enable bash-style extended globs. When `true`:
19
+ *
20
+ * - `?` matches exactly one character.
21
+ * - `[abc]` / `[a-z]` matches a single character in the set / range.
22
+ * - `{foo,bar}` matches one of the alternatives.
23
+ *
24
+ * When `false` (default), each of these characters is treated literally
25
+ * (they are escaped in the output regexp).
26
+ */
27
+ extended?: boolean;
28
+
29
+ /**
30
+ * Enable bash-style globstar semantics for `*` and `**`.
31
+ *
32
+ * - With `globstar: false` (default), every run of `*`s is translated to
33
+ * `.*` — so `*` matches any number of characters, including `/`.
34
+ * - With `globstar: true`, a single `*` only matches within one path
35
+ * segment (`[^/]*`), and a `**` segment (`**` between `/`s, or at the
36
+ * start/end of the pattern) matches zero or more whole segments.
37
+ */
38
+ globstar?: boolean;
39
+
40
+ /**
41
+ * RegExp flags passed to the `RegExp` constructor. When `flags` includes
42
+ * `"g"`, the produced regexp is **not** anchored with `^…$`, so the glob
43
+ * matches anywhere in the string instead of the whole string.
44
+ */
45
+ flags?: string;
46
+ }
47
+
48
+ /**
49
+ * Compiles a glob pattern into a `RegExp`.
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * globToRegExp("*.js"); // /^.*\.js$/
54
+ * globToRegExp("*.js", { globstar: true }); // /^([^/]*)\.js$/
55
+ * globToRegExp("/foo/**", { globstar: true }) // /^\/foo\/((?:[^/]*(?:\/|$))*)$/
56
+ * globToRegExp("foo{bar,baz}", { extended: true }); // /^foo(bar|baz)$/
57
+ * ```
58
+ */
59
+ export function globToRegExp(glob: string, opts: GlobToRegExpOptions = {}): RegExp {
60
+ if (typeof glob !== "string") {
61
+ throw new TypeError("Expected a string");
62
+ }
63
+
64
+ const str = String(glob);
65
+
66
+ // The regexp we are building, as a string.
67
+ let reStr = "";
68
+
69
+ // Whether we are matching so called "extended" globs (like bash) and
70
+ // should support single character matching, matching ranges of
71
+ // characters, group matching, etc.
72
+ const extended = !!opts.extended;
73
+
74
+ // Globstar semantics for `*` / `**`.
75
+ // - false: '/foo/*' becomes '^/foo/.*$' (matches '/foo/bar' AND '/foo/bar/baz')
76
+ // - true: '/foo/*' becomes '^/foo/[^/]*$' (matches '/foo/bar' only)
77
+ // '/foo/**' (with globstar=true) means "any depth under /foo".
78
+ const globstar = !!opts.globstar;
79
+
80
+ // True while inside an extended `{a,b}` group. Used to translate `,`.
81
+ let inGroup = false;
82
+
83
+ // RegExp flags passed straight through to the RegExp constructor.
84
+ const flags = typeof opts.flags === "string" ? opts.flags : "";
85
+
86
+ for (let i = 0; i < str.length; i++) {
87
+ const c = str[i];
88
+
89
+ switch (c) {
90
+ case "/":
91
+ case "$":
92
+ case "^":
93
+ case "+":
94
+ case ".":
95
+ case "(":
96
+ case ")":
97
+ case "=":
98
+ case "!":
99
+ case "|":
100
+ reStr += `\\${c}`;
101
+ break;
102
+
103
+ case "?":
104
+ reStr += extended ? "." : `\\${c}`;
105
+ break;
106
+
107
+ case "[":
108
+ case "]":
109
+ reStr += extended ? c : `\\${c}`;
110
+ break;
111
+
112
+ case "{":
113
+ if (extended) {
114
+ inGroup = true;
115
+ reStr += "(";
116
+ } else {
117
+ reStr += `\\${c}`;
118
+ }
119
+ break;
120
+
121
+ case "}":
122
+ if (extended) {
123
+ inGroup = false;
124
+ reStr += ")";
125
+ } else {
126
+ reStr += `\\${c}`;
127
+ }
128
+ break;
129
+
130
+ case ",":
131
+ if (inGroup) {
132
+ reStr += "|";
133
+ } else {
134
+ reStr += `\\${c}`;
135
+ }
136
+ break;
137
+
138
+ case "*": {
139
+ // Coalesce consecutive "*"s and remember the surrounding chars so
140
+ // we can decide whether this run is a "globstar" segment.
141
+ const prevChar = str[i - 1];
142
+ let starCount = 1;
143
+ while (str[i + 1] === "*") {
144
+ starCount++;
145
+ i++;
146
+ }
147
+ const nextChar = str[i + 1];
148
+
149
+ if (!globstar) {
150
+ // globstar disabled: any number of "*" maps to ".*".
151
+ reStr += ".*";
152
+ } else {
153
+ const isGlobstar =
154
+ starCount > 1 && // multiple "*"s
155
+ (prevChar === "/" || prevChar === undefined) && // segment start
156
+ (nextChar === "/" || nextChar === undefined); // segment end
157
+
158
+ if (isGlobstar) {
159
+ // Match zero or more whole path segments.
160
+ reStr += "((?:[^/]*(?:\\/|$))*)";
161
+ i++; // consume the trailing "/"
162
+ } else {
163
+ // Match within one path segment only.
164
+ reStr += "([^/]*)";
165
+ }
166
+ }
167
+ break;
168
+ }
169
+
170
+ default:
171
+ reStr += c;
172
+ }
173
+ }
174
+
175
+ // When the "g" flag is set, leave the regexp un-anchored so the caller
176
+ // can match the glob anywhere within a longer string.
177
+ if (!flags?.includes("g")) {
178
+ reStr = `^${reStr}$`;
179
+ }
180
+
181
+ return new RegExp(reStr, flags);
182
+ }
@@ -0,0 +1,131 @@
1
+ import type {
2
+ FileInfo,
3
+ FileStats,
4
+ FilesApi,
5
+ ListOptions,
6
+ ReadOptions,
7
+ } from "@statewalker/webrun-files";
8
+ import { normalizePath } from "@statewalker/webrun-files";
9
+ import type { FileGuard, FileOperation } from "./types.js";
10
+
11
+ /**
12
+ * `FilesApi` decorator that runs every call through an ordered list of
13
+ * {@link FileGuard}s. A guard fires when its `operations` set intersects the
14
+ * effective operation(s) for the current call. The first guard whose
15
+ * `check` returns `false` aborts the call by throwing an `Error` with that
16
+ * guard's `message` (defaulting to `"Access denied"`) followed by the
17
+ * normalized path.
18
+ *
19
+ * Effective operations per call:
20
+ *
21
+ * | Method | Operations checked |
22
+ * | ------------- | --------------------------------------------------- |
23
+ * | `read` | `read` |
24
+ * | `write` | `write` |
25
+ * | `mkdir` | `mkdir` |
26
+ * | `remove` | `remove` |
27
+ * | `list` | `list` on the path AND on each directory entry |
28
+ * | `stats` | `list` (a stat reveals existence like a tiny list) |
29
+ * | `exists` | `read` (existence is a read of metadata) |
30
+ * | `move(s, t)` | `move`+`read` on source; `move`+`write` on target |
31
+ * | `copy(s, t)` | `copy`+`read` on source; `copy`+`write` on target |
32
+ *
33
+ * The expanded checks for `move`/`copy` mean a guard that blocks `read` on
34
+ * a path also prevents move/copy *from* that path, and a `write`-blocking
35
+ * guard prevents move/copy *to* it. Likewise, an `exists` call respects any
36
+ * read guard, and `stats` respects any list guard.
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * const api = new GuardedFilesApi(source, [
41
+ * {
42
+ * operations: ["write", "remove", "move", "mkdir"],
43
+ * check: (p) => !p.startsWith("/.system/"),
44
+ * message: "system folder is read-only",
45
+ * },
46
+ * ]);
47
+ * await api.write("/.system/cfg", data); // throws "system folder is read-only: /.system/cfg"
48
+ * ```
49
+ */
50
+ export class GuardedFilesApi implements FilesApi {
51
+ private readonly source: FilesApi;
52
+ private readonly guards: FileGuard[];
53
+
54
+ /**
55
+ * @param source The underlying `FilesApi` whose calls will be policed.
56
+ * Allowed operations delegate straight through.
57
+ * @param guards Ordered list of access policies. The wrapper takes a
58
+ * defensive copy, so mutating the array afterwards has no effect.
59
+ * An empty list disables every check (the wrapper becomes a passthrough).
60
+ */
61
+ constructor(source: FilesApi, guards: FileGuard[]) {
62
+ this.source = source;
63
+ this.guards = [...guards];
64
+ }
65
+
66
+ private checkGuard(path: string, ...operations: FileOperation[]): void {
67
+ const normalized = normalizePath(path);
68
+ for (const guard of this.guards) {
69
+ if (!operations.some((op) => guard.operations.includes(op))) continue;
70
+ if (!guard.check(normalized)) {
71
+ const msg = guard.message ?? "Access denied";
72
+ throw new Error(`${msg}: ${normalized}`);
73
+ }
74
+ }
75
+ }
76
+
77
+ read(path: string, options?: ReadOptions): AsyncIterable<Uint8Array> {
78
+ this.checkGuard(path, "read");
79
+ return this.source.read(path, options);
80
+ }
81
+
82
+ async write(
83
+ path: string,
84
+ content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>,
85
+ ): Promise<void> {
86
+ this.checkGuard(path, "write");
87
+ return this.source.write(path, content);
88
+ }
89
+
90
+ async mkdir(path: string): Promise<void> {
91
+ this.checkGuard(path, "mkdir");
92
+ return this.source.mkdir(path);
93
+ }
94
+
95
+ async *list(path: string, options?: ListOptions): AsyncIterable<FileInfo> {
96
+ this.checkGuard(path, "list");
97
+ for await (const info of this.source.list(path, options)) {
98
+ if (info.kind === "directory") {
99
+ this.checkGuard(info.path, "list");
100
+ }
101
+ yield info;
102
+ }
103
+ }
104
+
105
+ stats(path: string): Promise<FileStats | undefined> {
106
+ this.checkGuard(path, "list");
107
+ return this.source.stats(path);
108
+ }
109
+
110
+ exists(path: string): Promise<boolean> {
111
+ this.checkGuard(path, "read");
112
+ return this.source.exists(path);
113
+ }
114
+
115
+ async remove(path: string): Promise<boolean> {
116
+ this.checkGuard(path, "remove");
117
+ return this.source.remove(path);
118
+ }
119
+
120
+ async move(source: string, target: string): Promise<boolean> {
121
+ this.checkGuard(source, "move", "read");
122
+ this.checkGuard(target, "move", "write");
123
+ return this.source.move(source, target);
124
+ }
125
+
126
+ async copy(source: string, target: string): Promise<boolean> {
127
+ this.checkGuard(source, "copy", "read");
128
+ this.checkGuard(target, "copy", "write");
129
+ return this.source.copy(source, target);
130
+ }
131
+ }
package/src/index.ts CHANGED
@@ -1,2 +1,12 @@
1
1
  export { CompositeFilesApi } from "./composite-files-api.js";
2
+ export type { PathFilter } from "./filtered-files-api.js";
3
+ export {
4
+ FilteredFilesApi,
5
+ newGlobPathFilter,
6
+ newPathFilter,
7
+ newRegexpPathFilter,
8
+ } from "./filtered-files-api.js";
9
+ export type { GlobToRegExpOptions } from "./glob-to-regexp.js";
10
+ export { globToRegExp } from "./glob-to-regexp.js";
11
+ export { GuardedFilesApi } from "./guarded-files-api.js";
2
12
  export type { FileGuard, FileOperation } from "./types.js";
package/src/types.ts CHANGED
@@ -1,10 +1,59 @@
1
+ /**
2
+ * Filesystem operations a {@link FileGuard} can intercept.
3
+ *
4
+ * - `read` — reading file content (`FilesApi.read`). Also implicitly checked
5
+ * on the source side of `move`/`copy`, and on every `exists` call.
6
+ * - `write` — writing file content (`FilesApi.write`). Also implicitly
7
+ * checked on the target side of `move`/`copy`.
8
+ * - `mkdir` — creating directories (`FilesApi.mkdir`).
9
+ * - `list` — listing directory contents (`FilesApi.list`). Also implicitly
10
+ * checked on every `stats` call and on each directory entry encountered
11
+ * while iterating a listing.
12
+ * - `remove` — deleting files or directories (`FilesApi.remove`).
13
+ * - `move` — relocating a file or directory (`FilesApi.move`).
14
+ * - `copy` — copying a file or directory (`FilesApi.copy`).
15
+ *
16
+ * Only the operations listed in a guard's {@link FileGuard.operations} array
17
+ * trigger that guard's predicate.
18
+ */
1
19
  export type FileOperation = "read" | "write" | "remove" | "move" | "copy" | "list" | "mkdir";
2
20
 
21
+ /**
22
+ * Per-operation, per-path access policy applied by `GuardedFilesApi`.
23
+ *
24
+ * Guards are evaluated in the order they are passed to the wrapper, and the
25
+ * first guard whose `check` returns `false` for a matching operation aborts
26
+ * the call by throwing an `Error` carrying its `message`.
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * const guard: FileGuard = {
31
+ * operations: ["write", "remove", "move"],
32
+ * check: (path) => !path.startsWith("/.system/"),
33
+ * message: "system folder is read-only",
34
+ * };
35
+ * ```
36
+ */
3
37
  export interface FileGuard {
4
- /** Which filesystem operations this guard intercepts. */
38
+ /**
39
+ * Filesystem operations this guard applies to. The guard's `check` is
40
+ * invoked only for calls whose effective operation set intersects this
41
+ * list (e.g. a guard listing `"read"` also fires on `move`/`copy` source
42
+ * paths and on every `exists` call).
43
+ */
5
44
  operations: FileOperation[];
6
- /** Returns true to allow, false to deny. */
45
+
46
+ /**
47
+ * Predicate invoked with the **normalized** path (single leading slash, no
48
+ * trailing slash, collapsed `.` segments and double slashes). Returning
49
+ * `true` allows the operation; returning `false` denies it and the wrapper
50
+ * throws an `Error` with this guard's {@link message}.
51
+ */
7
52
  check: (path: string) => boolean;
8
- /** Error message when access is denied. */
53
+
54
+ /**
55
+ * Optional message used as the prefix of the thrown `Error`. The wrapper
56
+ * appends `: <normalized-path>`. Defaults to `"Access denied"`.
57
+ */
9
58
  message?: string;
10
59
  }