@statewalker/webrun-files-composite 0.7.1 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +219 -144
- package/dist/cjs/index.cjs +770 -28
- package/dist/composite-files-api.d.ts +50 -4
- package/dist/composite-files-api.d.ts.map +1 -1
- package/dist/cow-files-api.d.ts +36 -0
- package/dist/cow-files-api.d.ts.map +1 -0
- package/dist/esm/index.js +762 -29
- package/dist/filtered-files-api.d.ts +167 -0
- package/dist/filtered-files-api.d.ts.map +1 -0
- package/dist/glob-to-regexp.d.ts +56 -0
- package/dist/glob-to-regexp.d.ts.map +1 -0
- package/dist/guarded-files-api.d.ts +64 -0
- package/dist/guarded-files-api.d.ts.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/overlay-files-api.d.ts +20 -0
- package/dist/overlay-files-api.d.ts.map +1 -0
- package/dist/read-only-files-api.d.ts +21 -0
- package/dist/read-only-files-api.d.ts.map +1 -0
- package/dist/types.d.ts +50 -3
- package/dist/types.d.ts.map +1 -1
- package/package.json +15 -15
- package/src/composite-files-api.ts +50 -29
- package/src/cow-files-api.ts +244 -0
- package/src/filtered-files-api.ts +257 -0
- package/src/glob-to-regexp.ts +182 -0
- package/src/guarded-files-api.ts +131 -0
- package/src/index.ts +14 -0
- package/src/overlay-files-api.ts +121 -0
- package/src/read-only-files-api.ts +30 -0
- package/src/types.ts +52 -3
|
@@ -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,16 @@
|
|
|
1
1
|
export { CompositeFilesApi } from "./composite-files-api.js";
|
|
2
|
+
export type { CowOptions } from "./cow-files-api.js";
|
|
3
|
+
export { cow } from "./cow-files-api.js";
|
|
4
|
+
export type { PathFilter } from "./filtered-files-api.js";
|
|
5
|
+
export {
|
|
6
|
+
FilteredFilesApi,
|
|
7
|
+
newGlobPathFilter,
|
|
8
|
+
newPathFilter,
|
|
9
|
+
newRegexpPathFilter,
|
|
10
|
+
} from "./filtered-files-api.js";
|
|
11
|
+
export type { GlobToRegExpOptions } from "./glob-to-regexp.js";
|
|
12
|
+
export { globToRegExp } from "./glob-to-regexp.js";
|
|
13
|
+
export { GuardedFilesApi } from "./guarded-files-api.js";
|
|
14
|
+
export { overlay } from "./overlay-files-api.js";
|
|
15
|
+
export { readOnly } from "./read-only-files-api.js";
|
|
2
16
|
export type { FileGuard, FileOperation } from "./types.js";
|
|
@@ -0,0 +1,121 @@
|
|
|
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
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Read-only union of several `FilesApi` layers. A path is resolved
|
|
12
|
+
* **top → bottom**: the first layer that has it wins for
|
|
13
|
+
* `read` / `stats` / `exists`, and its entry (including `kind`) wins any
|
|
14
|
+
* name clash in `list`. Listings merge and dedupe across every layer.
|
|
15
|
+
*
|
|
16
|
+
* The union never writes — every mutating call throws. Use
|
|
17
|
+
* {@link overlay} to construct one.
|
|
18
|
+
*/
|
|
19
|
+
class OverlayFilesApi implements FilesApi {
|
|
20
|
+
private readonly layers: FilesApi[];
|
|
21
|
+
|
|
22
|
+
constructor(layers: FilesApi[]) {
|
|
23
|
+
this.layers = layers;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
private deny(op: string, path: string): never {
|
|
27
|
+
throw new Error(`overlay is read-only (${op}): ${normalizePath(path)}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async *read(path: string, options?: ReadOptions): AsyncIterable<Uint8Array> {
|
|
31
|
+
for (const layer of this.layers) {
|
|
32
|
+
if (await layer.exists(path)) {
|
|
33
|
+
yield* layer.read(path, options);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async stats(path: string): Promise<FileStats | undefined> {
|
|
40
|
+
for (const layer of this.layers) {
|
|
41
|
+
const stats = await layer.stats(path);
|
|
42
|
+
if (stats) return stats;
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async exists(path: string): Promise<boolean> {
|
|
48
|
+
for (const layer of this.layers) {
|
|
49
|
+
if (await layer.exists(path)) return true;
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async *list(path: string, options?: ListOptions): AsyncIterable<FileInfo> {
|
|
55
|
+
yield* this.listDir(normalizePath(path), options?.recursive ?? false);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private async *listDir(dir: string, recursive: boolean): AsyncIterable<FileInfo> {
|
|
59
|
+
const stats = await this.stats(dir);
|
|
60
|
+
if (stats?.kind !== "directory") return;
|
|
61
|
+
for (const entry of await this.mergeChildren(dir)) {
|
|
62
|
+
yield entry;
|
|
63
|
+
if (recursive && entry.kind === "directory") {
|
|
64
|
+
yield* this.listDir(entry.path, true);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Direct children of `dir`, deduped by name with the topmost layer winning. */
|
|
70
|
+
private async mergeChildren(dir: string): Promise<FileInfo[]> {
|
|
71
|
+
const merged = new Map<string, FileInfo>();
|
|
72
|
+
for (const layer of this.layers) {
|
|
73
|
+
if ((await layer.stats(dir))?.kind !== "directory") continue;
|
|
74
|
+
for await (const entry of layer.list(dir)) {
|
|
75
|
+
if (!merged.has(entry.name)) merged.set(entry.name, entry);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return [...merged.values()];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async write(path: string): Promise<void> {
|
|
82
|
+
this.deny("write", path);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async mkdir(path: string): Promise<void> {
|
|
86
|
+
this.deny("mkdir", path);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async remove(path: string): Promise<boolean> {
|
|
90
|
+
return this.deny("remove", path);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async move(source: string): Promise<boolean> {
|
|
94
|
+
return this.deny("move", source);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async copy(source: string): Promise<boolean> {
|
|
98
|
+
return this.deny("copy", source);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Builds a **read-only** union view over `top` and any number of `lower`
|
|
104
|
+
* layers. Reads resolve top → bottom (first layer that has the path wins);
|
|
105
|
+
* `list` merges and dedupes across all layers with `top` winning a clash
|
|
106
|
+
* (including a file-vs-directory `kind` clash). Every write is denied.
|
|
107
|
+
*
|
|
108
|
+
* @param top The highest-priority layer; its entries shadow the rest.
|
|
109
|
+
* @param lower Additional layers, consulted in order after `top`.
|
|
110
|
+
* @returns A read-only `FilesApi` union.
|
|
111
|
+
*
|
|
112
|
+
* @example
|
|
113
|
+
* ```ts
|
|
114
|
+
* const view = overlay(userFiles, defaultFiles);
|
|
115
|
+
* await view.read("/config.json"); // userFiles if present, else defaultFiles
|
|
116
|
+
* await view.write("/x", data); // throws (read-only)
|
|
117
|
+
* ```
|
|
118
|
+
*/
|
|
119
|
+
export function overlay(top: FilesApi, ...lower: FilesApi[]): FilesApi {
|
|
120
|
+
return new OverlayFilesApi([top, ...lower]);
|
|
121
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { FilesApi } from "@statewalker/webrun-files";
|
|
2
|
+
import { GuardedFilesApi } from "./guarded-files-api.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Wraps a `FilesApi` in a read-only view: every mutating operation
|
|
6
|
+
* (`write`, `mkdir`, `remove`, `move`, `copy`) throws, while reads
|
|
7
|
+
* (`read`, `list`, `stats`, `exists`) pass straight through to `api`.
|
|
8
|
+
*
|
|
9
|
+
* Implemented as a {@link GuardedFilesApi} with a single deny-all guard on
|
|
10
|
+
* the mutating operations, so `move`/`copy` are blocked on either endpoint.
|
|
11
|
+
*
|
|
12
|
+
* @param api The underlying `FilesApi` to expose read-only.
|
|
13
|
+
* @returns A `FilesApi` that never mutates `api`.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* const ro = readOnly(sourceFiles);
|
|
18
|
+
* await ro.read("/a.txt"); // ok
|
|
19
|
+
* await ro.write("/a.txt", data); // throws "read-only: /a.txt"
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export function readOnly(api: FilesApi): FilesApi {
|
|
23
|
+
return new GuardedFilesApi(api, [
|
|
24
|
+
{
|
|
25
|
+
operations: ["write", "mkdir", "remove", "move", "copy"],
|
|
26
|
+
check: () => false,
|
|
27
|
+
message: "read-only",
|
|
28
|
+
},
|
|
29
|
+
]);
|
|
30
|
+
}
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|