@statewalker/webrun-files-composite 0.7.1 → 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,167 @@
1
+ import type { FileInfo, FileStats, FilesApi, ListOptions, ReadOptions } from "@statewalker/webrun-files";
2
+ /**
3
+ * Predicate evaluated against a **normalized** path (single leading slash,
4
+ * no trailing slash) by `FilteredFilesApi` to decide visibility.
5
+ *
6
+ * - `true` → the path is visible (the wrapped operation runs).
7
+ * - `false` → the path is hidden (the wrapper short-circuits as if the path
8
+ * did not exist).
9
+ *
10
+ * The predicate may be synchronous or return a `Promise<boolean>`. When
11
+ * async, it is awaited on each call — so prefer pure / cheap checks.
12
+ */
13
+ export type PathFilter = (path: string) => boolean | Promise<boolean>;
14
+ /**
15
+ * Builds a {@link PathFilter} that hides any path whose normalized form
16
+ * equals one of the provided prefixes or lives under `${prefix}/`.
17
+ *
18
+ * Prefixes are normalized through `normalizePath` (so `"foo"`, `"/foo"`, and
19
+ * `"/foo/"` are equivalent). Empty / root entries are dropped — they would
20
+ * otherwise hide every path.
21
+ *
22
+ * Matching is **boundary-aware**: the prefix `"/priv"` does not match the
23
+ * path `"/private"` because there is no `/` boundary between them.
24
+ *
25
+ * @param pathPrefixes Path prefixes whose contents (and the prefix itself)
26
+ * should be hidden. Pass none to hide nothing.
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * const filter = newPathFilter("/.git", "/node_modules");
31
+ * filter("/src/index.ts"); // true
32
+ * filter("/.git"); // false
33
+ * filter("/.git/HEAD"); // false
34
+ * filter("/notgit"); // true (boundary-aware, no false match)
35
+ * ```
36
+ */
37
+ export declare function newPathFilter(...pathPrefixes: string[]): PathFilter;
38
+ /**
39
+ * Builds a {@link PathFilter} that hides any path whose normalized form
40
+ * matches at least one of the provided regular expressions.
41
+ *
42
+ * The path is normalized through `normalizePath` before matching, so a
43
+ * regexp anchored on `^/` always sees a leading slash and never a trailing
44
+ * one. The regexp's `lastIndex` is irrelevant — the filter calls `test`
45
+ * via a fresh evaluation each time, but stateful (`/g`, `/y`) regexps
46
+ * still mutate `lastIndex` across calls; pass non-stateful regexps unless
47
+ * you know what you are doing.
48
+ *
49
+ * @param pathRegexps Regular expressions whose match means "hide this
50
+ * path". Pass none to hide nothing.
51
+ *
52
+ * @example
53
+ * ```ts
54
+ * // Hide every dotfile and every *.log file
55
+ * const filter = newRegexpPathFilter(/\/\.[^/]+$/, /\.log$/);
56
+ * filter("/src/index.ts"); // true
57
+ * filter("/.env"); // false (matches /\/\.[^/]+$/)
58
+ * filter("/build.log"); // false (matches /\.log$/)
59
+ * ```
60
+ */
61
+ export declare function newRegexpPathFilter(...pathRegexps: RegExp[]): PathFilter;
62
+ /**
63
+ * Builds a {@link PathFilter} that hides any path whose normalized form
64
+ * matches at least one of the provided glob patterns.
65
+ *
66
+ * Each glob is compiled with `extended: true` and `globstar: true`, the
67
+ * standard "filesystem-style" mode:
68
+ *
69
+ * - `*` matches any number of characters within a single path segment
70
+ * (does **not** cross `/`).
71
+ * - `**` between slashes matches zero or more whole path segments.
72
+ * - `?` matches exactly one character.
73
+ * - `[abc]` / `[a-z]` matches a single character in the set / range.
74
+ * - `{a,b,c}` matches one of the alternatives.
75
+ *
76
+ * Because matching is done on the **normalized** path (which always starts
77
+ * with `/`), patterns that should match anywhere in the tree need a
78
+ * leading `**​/`, e.g. `**​/*.log` to hide every `.log` file at any depth.
79
+ *
80
+ * Gotcha: `/foo/**` matches descendants of `/foo` but **not** `/foo`
81
+ * itself, because the glob requires a `/` after `foo` before `**` can
82
+ * match. To hide both the directory and its contents, list both prefixes:
83
+ * `newGlobPathFilter("/foo", "/foo/**")`. {@link newPathFilter} doesn't
84
+ * have this problem and may be a better fit for prefix-only hiding.
85
+ *
86
+ * @param pathGlobs Glob patterns whose match means "hide this path". Pass
87
+ * none to hide nothing.
88
+ *
89
+ * @example
90
+ * ```ts
91
+ * const filter = newGlobPathFilter("**​/*.log", "/.git", "/.git/**");
92
+ * filter("/src/index.ts"); // true
93
+ * filter("/build.log"); // false (matches **​/*.log)
94
+ * filter("/.git"); // false (matches /.git)
95
+ * filter("/.git/HEAD"); // false (matches /.git/**)
96
+ * ```
97
+ */
98
+ export declare function newGlobPathFilter(...pathGlobs: string[]): PathFilter;
99
+ /**
100
+ * `FilesApi` decorator that hides every path the supplied {@link PathFilter}
101
+ * rejects. Hidden paths are treated as if they do not exist:
102
+ *
103
+ * - `read` / `list` yield empty iterables.
104
+ * - `stats` returns `undefined`; `exists` returns `false`.
105
+ * - `remove` returns `false` (no error, nothing changed).
106
+ * - `move` / `copy` return `false` if either endpoint is hidden.
107
+ * - `write` / `mkdir` reject with an `Error` (since silently dropping a
108
+ * write would lose data).
109
+ * - `list` recursively skips entries whose paths are hidden, so iterating a
110
+ * visible parent never reveals a hidden child.
111
+ *
112
+ * Wrap any `FilesApi` to scope its visibility without changing the
113
+ * underlying storage; the wrapped instance still holds the data, it is just
114
+ * not reachable through this decorator.
115
+ *
116
+ * Pair with one of the built-in {@link PathFilter} factories
117
+ * ({@link newPathFilter}, {@link newRegexpPathFilter},
118
+ * {@link newGlobPathFilter}) or pass any predicate of shape
119
+ * `(path) => boolean | Promise<boolean>`.
120
+ *
121
+ * @example
122
+ * ```ts
123
+ * import {
124
+ * FilteredFilesApi,
125
+ * newGlobPathFilter,
126
+ * newPathFilter,
127
+ * newRegexpPathFilter,
128
+ * } from "@statewalker/webrun-files-composite";
129
+ *
130
+ * // Hide by path prefix
131
+ * const noVcs = new FilteredFilesApi(sourceFiles, newPathFilter("/.git", "/.cache"));
132
+ * await noVcs.exists("/.git"); // false
133
+ * await noVcs.write("/.git/x", data); // throws "Path is hidden"
134
+ *
135
+ * // Hide by regexp
136
+ * const noLogs = new FilteredFilesApi(sourceFiles, newRegexpPathFilter(/\.log$/));
137
+ *
138
+ * // Hide by glob (extended + globstar mode)
139
+ * const noJunk = new FilteredFilesApi(
140
+ * sourceFiles,
141
+ * newGlobPathFilter("**​/*.log", "/.git", "/.git/**"),
142
+ * );
143
+ * ```
144
+ */
145
+ export declare class FilteredFilesApi implements FilesApi {
146
+ private readonly source;
147
+ private readonly pathFilter;
148
+ /**
149
+ * @param source The underlying `FilesApi` whose paths will be selectively
150
+ * hidden. Operations always delegate to this instance; the decorator
151
+ * only adds the visibility check.
152
+ * @param pathFilter Predicate that decides per-call whether a normalized
153
+ * path is visible. See {@link PathFilter}.
154
+ */
155
+ constructor(source: FilesApi, pathFilter: PathFilter);
156
+ protected isHidden(path: string): Promise<boolean>;
157
+ read(path: string, options?: ReadOptions): AsyncIterable<Uint8Array>;
158
+ write(path: string, content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>): Promise<void>;
159
+ mkdir(path: string): Promise<void>;
160
+ list(path: string, options?: ListOptions): AsyncIterable<FileInfo>;
161
+ stats(path: string): Promise<FileStats | undefined>;
162
+ exists(path: string): Promise<boolean>;
163
+ remove(path: string): Promise<boolean>;
164
+ move(source: string, target: string): Promise<boolean>;
165
+ copy(source: string, target: string): Promise<boolean>;
166
+ }
167
+ //# sourceMappingURL=filtered-files-api.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"filtered-files-api.d.ts","sourceRoot":"","sources":["../src/filtered-files-api.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,WAAW,EACX,WAAW,EACZ,MAAM,2BAA2B,CAAC;AAInC;;;;;;;;;;GAUG;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;AAEtE;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,aAAa,CAAC,GAAG,YAAY,EAAE,MAAM,EAAE,GAAG,UAAU,CAUnE;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,WAAW,EAAE,MAAM,EAAE,GAAG,UAAU,CAQxE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,SAAS,EAAE,MAAM,EAAE,GAAG,UAAU,CAGpE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AACH,qBAAa,gBAAiB,YAAW,QAAQ;IAC/C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAW;IAClC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAa;IAExC;;;;;;OAMG;gBACS,MAAM,EAAE,QAAQ,EAAE,UAAU,EAAE,UAAU;cAKpC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAIjD,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,UAAU,CAAC;IAKrE,KAAK,CACT,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,GACxD,OAAO,CAAC,IAAI,CAAC;IAOV,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAOjC,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,QAAQ,CAAC;IAQnE,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC;IAKnD,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAKtC,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAKtC,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAOtD,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;CAM7D"}
@@ -0,0 +1,56 @@
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
+ export interface GlobToRegExpOptions {
16
+ /**
17
+ * Enable bash-style extended globs. When `true`:
18
+ *
19
+ * - `?` matches exactly one character.
20
+ * - `[abc]` / `[a-z]` matches a single character in the set / range.
21
+ * - `{foo,bar}` matches one of the alternatives.
22
+ *
23
+ * When `false` (default), each of these characters is treated literally
24
+ * (they are escaped in the output regexp).
25
+ */
26
+ extended?: boolean;
27
+ /**
28
+ * Enable bash-style globstar semantics for `*` and `**`.
29
+ *
30
+ * - With `globstar: false` (default), every run of `*`s is translated to
31
+ * `.*` — so `*` matches any number of characters, including `/`.
32
+ * - With `globstar: true`, a single `*` only matches within one path
33
+ * segment (`[^/]*`), and a `**` segment (`**` between `/`s, or at the
34
+ * start/end of the pattern) matches zero or more whole segments.
35
+ */
36
+ globstar?: boolean;
37
+ /**
38
+ * RegExp flags passed to the `RegExp` constructor. When `flags` includes
39
+ * `"g"`, the produced regexp is **not** anchored with `^…$`, so the glob
40
+ * matches anywhere in the string instead of the whole string.
41
+ */
42
+ flags?: string;
43
+ }
44
+ /**
45
+ * Compiles a glob pattern into a `RegExp`.
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * globToRegExp("*.js"); // /^.*\.js$/
50
+ * globToRegExp("*.js", { globstar: true }); // /^([^/]*)\.js$/
51
+ * globToRegExp("/foo/**", { globstar: true }) // /^\/foo\/((?:[^/]*(?:\/|$))*)$/
52
+ * globToRegExp("foo{bar,baz}", { extended: true }); // /^foo(bar|baz)$/
53
+ * ```
54
+ */
55
+ export declare function globToRegExp(glob: string, opts?: GlobToRegExpOptions): RegExp;
56
+ //# sourceMappingURL=glob-to-regexp.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"glob-to-regexp.d.ts","sourceRoot":"","sources":["../src/glob-to-regexp.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,MAAM,WAAW,mBAAmB;IAClC;;;;;;;;;OASG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IAEnB;;;;;;;;OAQG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IAEnB;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,mBAAwB,GAAG,MAAM,CA2HjF"}
@@ -0,0 +1,64 @@
1
+ import type { FileInfo, FileStats, FilesApi, ListOptions, ReadOptions } from "@statewalker/webrun-files";
2
+ import type { FileGuard } from "./types.js";
3
+ /**
4
+ * `FilesApi` decorator that runs every call through an ordered list of
5
+ * {@link FileGuard}s. A guard fires when its `operations` set intersects the
6
+ * effective operation(s) for the current call. The first guard whose
7
+ * `check` returns `false` aborts the call by throwing an `Error` with that
8
+ * guard's `message` (defaulting to `"Access denied"`) followed by the
9
+ * normalized path.
10
+ *
11
+ * Effective operations per call:
12
+ *
13
+ * | Method | Operations checked |
14
+ * | ------------- | --------------------------------------------------- |
15
+ * | `read` | `read` |
16
+ * | `write` | `write` |
17
+ * | `mkdir` | `mkdir` |
18
+ * | `remove` | `remove` |
19
+ * | `list` | `list` on the path AND on each directory entry |
20
+ * | `stats` | `list` (a stat reveals existence like a tiny list) |
21
+ * | `exists` | `read` (existence is a read of metadata) |
22
+ * | `move(s, t)` | `move`+`read` on source; `move`+`write` on target |
23
+ * | `copy(s, t)` | `copy`+`read` on source; `copy`+`write` on target |
24
+ *
25
+ * The expanded checks for `move`/`copy` mean a guard that blocks `read` on
26
+ * a path also prevents move/copy *from* that path, and a `write`-blocking
27
+ * guard prevents move/copy *to* it. Likewise, an `exists` call respects any
28
+ * read guard, and `stats` respects any list guard.
29
+ *
30
+ * @example
31
+ * ```ts
32
+ * const api = new GuardedFilesApi(source, [
33
+ * {
34
+ * operations: ["write", "remove", "move", "mkdir"],
35
+ * check: (p) => !p.startsWith("/.system/"),
36
+ * message: "system folder is read-only",
37
+ * },
38
+ * ]);
39
+ * await api.write("/.system/cfg", data); // throws "system folder is read-only: /.system/cfg"
40
+ * ```
41
+ */
42
+ export declare class GuardedFilesApi implements FilesApi {
43
+ private readonly source;
44
+ private readonly guards;
45
+ /**
46
+ * @param source The underlying `FilesApi` whose calls will be policed.
47
+ * Allowed operations delegate straight through.
48
+ * @param guards Ordered list of access policies. The wrapper takes a
49
+ * defensive copy, so mutating the array afterwards has no effect.
50
+ * An empty list disables every check (the wrapper becomes a passthrough).
51
+ */
52
+ constructor(source: FilesApi, guards: FileGuard[]);
53
+ private checkGuard;
54
+ read(path: string, options?: ReadOptions): AsyncIterable<Uint8Array>;
55
+ write(path: string, content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>): Promise<void>;
56
+ mkdir(path: string): Promise<void>;
57
+ list(path: string, options?: ListOptions): AsyncIterable<FileInfo>;
58
+ stats(path: string): Promise<FileStats | undefined>;
59
+ exists(path: string): Promise<boolean>;
60
+ remove(path: string): Promise<boolean>;
61
+ move(source: string, target: string): Promise<boolean>;
62
+ copy(source: string, target: string): Promise<boolean>;
63
+ }
64
+ //# sourceMappingURL=guarded-files-api.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"guarded-files-api.d.ts","sourceRoot":"","sources":["../src/guarded-files-api.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,WAAW,EACX,WAAW,EACZ,MAAM,2BAA2B,CAAC;AAEnC,OAAO,KAAK,EAAE,SAAS,EAAiB,MAAM,YAAY,CAAC;AAE3D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,qBAAa,eAAgB,YAAW,QAAQ;IAC9C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAW;IAClC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;IAErC;;;;;;OAMG;gBACS,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE;IAKjD,OAAO,CAAC,UAAU;IAWlB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,UAAU,CAAC;IAK9D,KAAK,CACT,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,GACxD,OAAO,CAAC,IAAI,CAAC;IAKV,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAKjC,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,QAAQ,CAAC;IAUzE,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC;IAKnD,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAKhC,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAKtC,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAMtD,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;CAK7D"}
package/dist/index.d.ts CHANGED
@@ -1,3 +1,8 @@
1
1
  export { CompositeFilesApi } from "./composite-files-api.js";
2
+ export type { PathFilter } from "./filtered-files-api.js";
3
+ export { FilteredFilesApi, newGlobPathFilter, newPathFilter, newRegexpPathFilter, } from "./filtered-files-api.js";
4
+ export type { GlobToRegExpOptions } from "./glob-to-regexp.js";
5
+ export { globToRegExp } from "./glob-to-regexp.js";
6
+ export { GuardedFilesApi } from "./guarded-files-api.js";
2
7
  export type { FileGuard, FileOperation } from "./types.js";
3
8
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAC7D,YAAY,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAC7D,YAAY,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAC1D,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,aAAa,EACb,mBAAmB,GACpB,MAAM,yBAAyB,CAAC;AACjC,YAAY,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAC/D,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACnD,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,YAAY,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC"}
package/dist/types.d.ts CHANGED
@@ -1,10 +1,57 @@
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";
20
+ /**
21
+ * Per-operation, per-path access policy applied by `GuardedFilesApi`.
22
+ *
23
+ * Guards are evaluated in the order they are passed to the wrapper, and the
24
+ * first guard whose `check` returns `false` for a matching operation aborts
25
+ * the call by throwing an `Error` carrying its `message`.
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * const guard: FileGuard = {
30
+ * operations: ["write", "remove", "move"],
31
+ * check: (path) => !path.startsWith("/.system/"),
32
+ * message: "system folder is read-only",
33
+ * };
34
+ * ```
35
+ */
2
36
  export interface FileGuard {
3
- /** Which filesystem operations this guard intercepts. */
37
+ /**
38
+ * Filesystem operations this guard applies to. The guard's `check` is
39
+ * invoked only for calls whose effective operation set intersects this
40
+ * list (e.g. a guard listing `"read"` also fires on `move`/`copy` source
41
+ * paths and on every `exists` call).
42
+ */
4
43
  operations: FileOperation[];
5
- /** Returns true to allow, false to deny. */
44
+ /**
45
+ * Predicate invoked with the **normalized** path (single leading slash, no
46
+ * trailing slash, collapsed `.` segments and double slashes). Returning
47
+ * `true` allows the operation; returning `false` denies it and the wrapper
48
+ * throws an `Error` with this guard's {@link message}.
49
+ */
6
50
  check: (path: string) => boolean;
7
- /** Error message when access is denied. */
51
+ /**
52
+ * Optional message used as the prefix of the thrown `Error`. The wrapper
53
+ * appends `: <normalized-path>`. Defaults to `"Access denied"`.
54
+ */
8
55
  message?: string;
9
56
  }
10
57
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;AAE7F,MAAM,WAAW,SAAS;IACxB,yDAAyD;IACzD,UAAU,EAAE,aAAa,EAAE,CAAC;IAC5B,4CAA4C;IAC5C,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC;IACjC,2CAA2C;IAC3C,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;AAE7F;;;;;;;;;;;;;;;GAeG;AACH,MAAM,WAAW,SAAS;IACxB;;;;;OAKG;IACH,UAAU,EAAE,aAAa,EAAE,CAAC;IAE5B;;;;;OAKG;IACH,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC;IAEjC;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statewalker/webrun-files-composite",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Composite FilesApi with mount points and access guards",
@@ -6,7 +6,6 @@ import type {
6
6
  ReadOptions,
7
7
  } from "@statewalker/webrun-files";
8
8
  import { joinPath, normalizePath } from "@statewalker/webrun-files";
9
- import type { FileGuard, FileOperation } from "./types.js";
10
9
 
11
10
  interface MountEntry {
12
11
  prefix: string;
@@ -14,14 +13,63 @@ interface MountEntry {
14
13
  basePath: string;
15
14
  }
16
15
 
16
+ /**
17
+ * Composite `FilesApi` that routes calls to one of several backends based
18
+ * on a path prefix. Mounts are matched by **longest prefix wins**, so a
19
+ * mount at `/a/b` takes precedence over a mount at `/a` for paths under
20
+ * `/a/b/...`. The mount point itself appears in listings as a synthetic
21
+ * directory and cannot be removed.
22
+ *
23
+ * Each backend can use a sub-directory of its own filesystem as the mount
24
+ * root via `fsPath` (constructor `rootPath` for the implicit root mount,
25
+ * `fsPath` argument for additional mounts). Cross-mount `move` is
26
+ * implemented as copy-then-delete; there is no atomicity guarantee.
27
+ *
28
+ * Access control and visibility filtering are intentionally **not** part of
29
+ * this class — wrap with {@link GuardedFilesApi} or {@link FilteredFilesApi}
30
+ * (or both) instead.
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * const fs = new CompositeFilesApi(localFs, "/projects")
35
+ * .mount("/docs", s3Fs, "/documentation")
36
+ * .mount("/cache", memFs);
37
+ * await fs.write("/readme.md", data); // → localFs:/projects/readme.md
38
+ * await fs.write("/docs/api.md", data); // → s3Fs:/documentation/api.md
39
+ * ```
40
+ */
17
41
  export class CompositeFilesApi implements FilesApi {
18
42
  private mounts: MountEntry[];
19
- private guards: FileGuard[] = [];
20
43
 
44
+ /**
45
+ * @param root Default backend used for any path that does not match a
46
+ * more specific mount. All paths are routed here unless `mount()`
47
+ * intercepts them.
48
+ * @param rootPath Optional sub-directory of the root backend to use as
49
+ * the composite filesystem's `/`. For example, `rootPath = "/projects"`
50
+ * makes the composite path `/readme.md` resolve to `/projects/readme.md`
51
+ * in the root backend. Defaults to `"/"` (no remapping).
52
+ */
21
53
  constructor(root: FilesApi, rootPath?: string) {
22
54
  this.mounts = [{ prefix: "/", api: root, basePath: normalizePath(rootPath ?? "/") }];
23
55
  }
24
56
 
57
+ /**
58
+ * Attaches a backend to handle every composite path under `path`. The
59
+ * mount prefix is normalized; paths under it are resolved against the
60
+ * mount's `fsPath` sub-directory (defaulting to `"/"`).
61
+ *
62
+ * @param path Composite-namespace prefix (e.g. `"/docs"`). Mounting at
63
+ * `"/"` is forbidden — use the constructor `root` argument instead.
64
+ * @param api The backend `FilesApi` to delegate to for paths under
65
+ * `path`. Wrap it in {@link FilteredFilesApi} / {@link GuardedFilesApi}
66
+ * first if you want mount-local filtering or guards.
67
+ * @param fsPath Sub-directory of the mounted backend used as its mount
68
+ * root, e.g. `mount("/docs", s3, "/documentation")` makes
69
+ * `/docs/api.md` resolve to `/documentation/api.md` on `s3`.
70
+ * @returns `this`, for chaining.
71
+ * @throws If `path` normalizes to `"/"`.
72
+ */
25
73
  mount(path: string, api: FilesApi, fsPath?: string): this {
26
74
  const prefix = normalizePath(path);
27
75
  if (prefix === "/") {
@@ -33,11 +81,6 @@ export class CompositeFilesApi implements FilesApi {
33
81
  return this;
34
82
  }
35
83
 
36
- guard(operations: FileOperation[], check: (path: string) => boolean, message?: string): this {
37
- this.guards.push({ operations, check, message });
38
- return this;
39
- }
40
-
41
84
  // --- Mount resolution ---
42
85
 
43
86
  private resolve(path: string): { api: FilesApi; resolvedPath: string } {
@@ -78,23 +121,9 @@ export class CompositeFilesApi implements FilesApi {
78
121
  return result;
79
122
  }
80
123
 
81
- // --- Guard checking ---
82
-
83
- private checkGuard(operation: FileOperation, path: string): void {
84
- const normalized = normalizePath(path);
85
- for (const guard of this.guards) {
86
- if (!guard.operations.includes(operation)) continue;
87
- if (!guard.check(normalized)) {
88
- const msg = guard.message ?? "Access denied";
89
- throw new Error(`${msg}: ${normalized}`);
90
- }
91
- }
92
- }
93
-
94
124
  // --- FilesApi implementation ---
95
125
 
96
126
  read(path: string, options?: ReadOptions): AsyncIterable<Uint8Array> {
97
- this.checkGuard("read", path);
98
127
  const { api, resolvedPath } = this.resolve(path);
99
128
  return api.read(resolvedPath, options);
100
129
  }
@@ -103,19 +132,16 @@ export class CompositeFilesApi implements FilesApi {
103
132
  path: string,
104
133
  content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>,
105
134
  ): Promise<void> {
106
- this.checkGuard("write", path);
107
135
  const { api, resolvedPath } = this.resolve(path);
108
136
  return api.write(resolvedPath, content);
109
137
  }
110
138
 
111
139
  async mkdir(path: string): Promise<void> {
112
- this.checkGuard("mkdir", path);
113
140
  const { api, resolvedPath } = this.resolve(path);
114
141
  return api.mkdir(resolvedPath);
115
142
  }
116
143
 
117
144
  async *list(path: string, options?: ListOptions): AsyncIterable<FileInfo> {
118
- this.checkGuard("list", path);
119
145
  const normalized = normalizePath(path);
120
146
  const { api, resolvedPath } = this.resolve(path);
121
147
 
@@ -184,14 +210,11 @@ export class CompositeFilesApi implements FilesApi {
184
210
  if (this.isMountPoint(normalized)) {
185
211
  throw new Error(`Cannot remove mount point: ${normalized}`);
186
212
  }
187
- this.checkGuard("remove", path);
188
213
  const { api, resolvedPath } = this.resolve(path);
189
214
  return api.remove(resolvedPath);
190
215
  }
191
216
 
192
217
  async move(source: string, target: string): Promise<boolean> {
193
- this.checkGuard("move", source);
194
- this.checkGuard("move", target);
195
218
  const src = this.resolve(source);
196
219
  const tgt = this.resolve(target);
197
220
 
@@ -209,8 +232,6 @@ export class CompositeFilesApi implements FilesApi {
209
232
  }
210
233
 
211
234
  async copy(source: string, target: string): Promise<boolean> {
212
- this.checkGuard("copy", source);
213
- this.checkGuard("copy", target);
214
235
  const src = this.resolve(source);
215
236
  const tgt = this.resolve(target);
216
237