@statewalker/webrun-files-node 0.10.2 → 0.10.3

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/README.md CHANGED
@@ -1,167 +1,118 @@
1
1
  # @statewalker/webrun-files-node
2
2
 
3
- Node.js filesystem implementation of the `FilesApi` interface from `@statewalker/webrun-files`.
3
+ ## What it is
4
4
 
5
- ## Overview
5
+ `NodeFilesApi`, an implementation of `FilesApi` from `@statewalker/webrun-files` over Node.js
6
+ `fs/promises`. Virtual paths (`/users/alice.json`) are mapped to real files under a root directory.
6
7
 
7
- This package provides a `FilesApi` implementation that works with the real filesystem using Node.js `fs/promises`. It maps virtual paths (starting with `/`) to a root directory on disk.
8
+ ## Why it exists
8
9
 
9
- ## Installation
10
+ Server code, CLIs and tests that work against `FilesApi` need a backend that stores real files on
11
+ disk. With this package the same code that runs on `MemFilesApi` in tests, or on S3 in production,
12
+ reads and writes a local directory.
13
+
14
+ ## How to use
10
15
 
11
16
  ```bash
12
- npm install @statewalker/webrun-files-node @statewalker/webrun-files
17
+ pnpm add @statewalker/webrun-files-node @statewalker/webrun-files
13
18
  ```
14
19
 
15
- ## Usage
16
-
17
- ### Basic Usage
20
+ One entry point, `@statewalker/webrun-files-node`: ESM (`dist/esm/index.js`), CommonJS
21
+ (`dist/cjs/index.cjs`), types (`dist/index.d.ts`); sources in `src/`. Node.js only (it imports
22
+ `node:fs/promises`).
18
23
 
19
24
  ```typescript
20
- import { NodeFilesApi } from '@statewalker/webrun-files-node';
21
- import { readText, writeText } from '@statewalker/webrun-files';
22
-
23
- // Create API rooted at a specific directory
24
- const files = new NodeFilesApi({ rootDir: '/var/app/data' });
25
+ import { NodeFilesApi, type NodeFilesApiOptions } from '@statewalker/webrun-files-node';
25
26
 
26
- // Write a file - creates /var/app/data/config.json
27
- await writeText(files, '/config.json', '{"debug": true}');
28
-
29
- // Read it back
30
- const content = await readText(files, '/config.json');
31
- console.log(content); // {"debug": true}
27
+ const files = new NodeFilesApi({ rootDir: '/var/app/data' }); // rootDir defaults to process.cwd()
28
+ ```
32
29
 
33
- // List files
34
- for await (const entry of files.list('/')) {
35
- if (entry.kind === 'file') {
36
- console.log(entry.name, entry.kind, entry.size, entry.lastModified);
37
- } else {
38
- console.log(entry.name, entry.kind); // directories carry no size or time
39
- }
40
- }
30
+ ```
31
+ rootDir: /var/app/data
32
+ virtual path: /users/alice.json
33
+ real path: /var/app/data/users/alice.json
41
34
  ```
42
35
 
43
- ### Default Root Directory
36
+ ## Examples
44
37
 
45
- If no `rootDir` is specified, the current working directory is used:
38
+ ### Read, write, list
46
39
 
47
40
  ```typescript
48
41
  import { NodeFilesApi } from '@statewalker/webrun-files-node';
42
+ import { readText, writeText } from '@statewalker/webrun-files';
49
43
 
50
- const files = new NodeFilesApi();
51
- // Virtual path /data/file.txt maps to ./data/file.txt
52
- ```
53
-
54
- ### Path Mapping
55
-
56
- Virtual paths are mapped directly to the filesystem:
57
-
58
- ```
59
- rootDir: "/var/app/data"
60
- virtual path: "/users/alice.json"
61
- real path: "/var/app/data/users/alice.json"
62
- ```
63
-
64
- Paths are appended to `rootDir` without confinement: `..` segments are kept, not resolved, so a
65
- virtual path such as `/../etc/passwd` reaches outside `rootDir`. Do not pass untrusted paths.
66
-
67
- ## API Reference
68
-
69
- ### NodeFilesApi
44
+ const files = new NodeFilesApi({ rootDir: '/var/app/data' });
70
45
 
71
- ```typescript
72
- interface NodeFilesApiOptions {
73
- /**
74
- * Root directory for file operations.
75
- * Virtual paths are appended to this directory (see "Path Mapping" for `..`).
76
- * Defaults to current working directory if not specified.
77
- */
78
- rootDir?: string;
79
- }
46
+ await writeText(files, '/deep/nested/config.json', '{"debug": true}'); // parents are created
47
+ console.log(await readText(files, '/deep/nested/config.json'));
80
48
 
81
- class NodeFilesApi implements FilesApi {
82
- constructor(options?: NodeFilesApiOptions);
83
-
84
- // All FilesApi methods
85
- read(path: string, options?: ReadOptions): AsyncIterable<Uint8Array>;
86
- write(path: string, content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>): Promise<void>;
87
- mkdir(path: string): Promise<void>;
88
- list(path: string, options?: ListOptions): AsyncIterable<FileInfo>;
89
- stats(path: string): Promise<FileStats | undefined>;
90
- exists(path: string): Promise<boolean>;
91
- remove(path: string): Promise<boolean>;
92
- move(source: string, target: string): Promise<boolean>;
93
- copy(source: string, target: string): Promise<boolean>;
49
+ for await (const entry of files.list('/', { recursive: true })) {
50
+ console.log(entry.path, entry.kind, entry.kind === 'file' ? entry.size : '');
94
51
  }
95
52
  ```
96
53
 
97
- ## Features
98
-
99
- ### Efficient Streaming
100
-
101
- Large files are read in chunks without loading entirely into memory:
54
+ ### Stream a range
102
55
 
103
56
  ```typescript
104
- // Stream a large file
105
- for await (const chunk of files.read('/large-video.mp4')) {
106
- // Process each chunk (default 8KB chunks)
107
- }
108
-
109
- // Read a specific range
110
57
  for await (const chunk of files.read('/large-file.bin', { start: 1000, length: 500 })) {
111
- // Only bytes 1000-1499
58
+ // bytes 1000-1499, in chunks of at most 8 KiB
112
59
  }
113
60
  ```
114
61
 
115
- ### Automatic Directory Creation
116
-
117
- Parent directories are created automatically when writing files:
62
+ ### A temporary directory per test
118
63
 
119
64
  ```typescript
120
- // Creates /var/app/data/deep/nested/path/ automatically
121
- await writeText(files, '/deep/nested/path/file.txt', 'content');
122
- ```
65
+ import { mkdtemp, rm } from 'node:fs/promises';
66
+ import { tmpdir } from 'node:os';
67
+ import { join } from 'node:path';
68
+ import { NodeFilesApi } from '@statewalker/webrun-files-node';
123
69
 
124
- ### Ordered Listings
70
+ const rootDir = await mkdtemp(join(tmpdir(), 'test-'));
71
+ const files = new NodeFilesApi({ rootDir });
72
+ try {
73
+ // ...
74
+ } finally {
75
+ await rm(rootDir, { recursive: true, force: true });
76
+ }
77
+ ```
125
78
 
126
- `readdir` returns names in no guaranteed order, so `list()` reads each directory, stats its entries
127
- and yields them in path order (as every `FilesApi` does). A recursive listing reads a directory only
128
- when the listing reaches it, and with `{ after }` skips any directory whose whole subtree sorts at
129
- or before the cursor.
79
+ ## Internals
130
80
 
131
- ### Recursive Operations
81
+ ### How each call maps to `fs`
132
82
 
133
- Copy and remove work recursively on directories. `move()` is a single `fs.rename`: when the rename
134
- fails — for example across devices — it returns `false` rather than falling back to copying.
83
+ | Call | Implementation |
84
+ | --- | --- |
85
+ | `read` | `fs.open` + positioned `handle.read` in 8 KiB buffers, only the requested range |
86
+ | `write` | `fs.mkdir(parent, { recursive: true })`, then collects every chunk and calls `fs.writeFile` once |
87
+ | `list` | `fs.readdir` + `fs.stat` per entry, walked through `listInPathOrder` |
88
+ | `move` | `fs.rename` after creating the target's parent |
89
+ | `copy` | `fs.cp(..., { recursive: true })` |
90
+ | `remove` | `fs.rm(..., { recursive: true, force: true })` for a directory, `fs.unlink` for a file |
135
91
 
136
- ```typescript
137
- // Copy entire directory tree
138
- await files.copy('/source', '/destination');
92
+ ### Why listings stat every entry
139
93
 
140
- // Remove directory and all contents
141
- await files.remove('/old-data');
142
- ```
94
+ `readdir` returns names in no guaranteed order, and every `FilesApi` must list in `comparePaths`
95
+ order with `after` support. `list()` therefore reads each directory, stats its entries and yields
96
+ them sorted. A recursive listing reads a directory only when the listing reaches it, and skips any
97
+ directory whose whole subtree sorts at or before `after`.
143
98
 
144
- ## Testing with Temporary Directories
99
+ ### What breaks, and how it looks
145
100
 
146
- ```typescript
147
- import { mkdtemp, rm } from 'node:fs/promises';
148
- import { tmpdir } from 'node:os';
149
- import { join } from 'node:path';
150
- import { NodeFilesApi } from '@statewalker/webrun-files-node';
151
- import { writeText } from '@statewalker/webrun-files';
101
+ - **A path that leaves `rootDir` is refused.** `..` segments are resolved against `rootDir`; when
102
+ the result is outside it, the call throws
103
+ `NodeFilesApi: path is outside rootDir: /../etc/passwd` (reads throw when iterated). `..` that
104
+ stays inside (`/a/../b.txt`) is fine. Symbolic links inside `rootDir` are followed as the
105
+ operating system follows them, so a link pointing out of `rootDir` still leads out.
106
+ - **`write` holds the whole file in memory** before writing it, so a very large upload costs its
107
+ full size in RAM. Reads stream.
108
+ - **`move` across devices returns `false`.** It is a single `fs.rename`; when that fails (`EXDEV`,
109
+ permissions) the method returns `false` instead of falling back to copy and delete.
110
+ - **Unreadable reads look like missing files.** `read` swallows every error (missing file,
111
+ permission denied, a directory) and yields nothing; `copy` and `move` return `false`.
152
112
 
153
- // Create a temp directory for testing
154
- const tempDir = await mkdtemp(join(tmpdir(), 'test-'));
155
- const files = new NodeFilesApi({ rootDir: tempDir });
113
+ ### Dependencies
156
114
 
157
- try {
158
- // Run tests...
159
- await writeText(files, '/test.txt', 'test content');
160
- } finally {
161
- // Cleanup
162
- await rm(tempDir, { recursive: true, force: true });
163
- }
164
- ```
115
+ `@statewalker/webrun-files` for the types and path helpers; Node.js built-ins otherwise.
165
116
 
166
117
  ## License
167
118
 
@@ -23,6 +23,8 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
23
23
  //#endregion
24
24
  let node_fs_promises = require("node:fs/promises");
25
25
  node_fs_promises = __toESM(node_fs_promises, 1);
26
+ let node_path = require("node:path");
27
+ node_path = __toESM(node_path, 1);
26
28
  //#region ../webrun-files/dist/esm/index.js
27
29
  /**
28
30
  * The order every `FilesApi.list()` yields: paths compared by Unicode code
@@ -147,11 +149,13 @@ function dirname(path) {
147
149
  var NodeFilesApi = class {
148
150
  rootDir;
149
151
  constructor(options) {
150
- this.rootDir = options?.rootDir ?? process.cwd();
152
+ this.rootDir = node_path.resolve(options?.rootDir ?? process.cwd());
151
153
  }
154
+ /** The real path of a virtual one; throws when `..` segments would take it outside rootDir. */
152
155
  resolvePath(virtualPath) {
153
- const normalized = normalizePath(virtualPath);
154
- return this.rootDir + normalized;
156
+ const real = node_path.resolve(this.rootDir, `.${normalizePath(virtualPath)}`);
157
+ if (real !== this.rootDir && !real.startsWith(this.rootDir + node_path.sep)) throw new Error(`NodeFilesApi: path is outside rootDir: ${virtualPath}`);
158
+ return real;
155
159
  }
156
160
  async *read(path, options) {
157
161
  const realPath = this.resolvePath(path);
@@ -186,7 +190,7 @@ var NodeFilesApi = class {
186
190
  async write(path, content) {
187
191
  const realPath = this.resolvePath(path);
188
192
  const normalized = normalizePath(path);
189
- const dir = this.rootDir + dirname(normalized);
193
+ const dir = this.resolvePath(dirname(normalized));
190
194
  await node_fs_promises.mkdir(dir, { recursive: true });
191
195
  const chunks = [];
192
196
  let totalLength = 0;
@@ -287,7 +291,7 @@ var NodeFilesApi = class {
287
291
  return false;
288
292
  }
289
293
  try {
290
- const targetDir = this.rootDir + dirname(normalizedTarget);
294
+ const targetDir = this.resolvePath(dirname(normalizedTarget));
291
295
  await node_fs_promises.mkdir(targetDir, { recursive: true });
292
296
  await node_fs_promises.rename(sourcePath, targetPath);
293
297
  return true;
@@ -305,7 +309,7 @@ var NodeFilesApi = class {
305
309
  return false;
306
310
  }
307
311
  try {
308
- const targetDir = this.rootDir + dirname(normalizedTarget);
312
+ const targetDir = this.resolvePath(dirname(normalizedTarget));
309
313
  await node_fs_promises.mkdir(targetDir, { recursive: true });
310
314
  await node_fs_promises.cp(sourcePath, targetPath, { recursive: true });
311
315
  return true;
package/dist/esm/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs/promises";
2
+ import * as nodePath from "node:path";
2
3
  //#region ../webrun-files/dist/esm/index.js
3
4
  /**
4
5
  * The order every `FilesApi.list()` yields: paths compared by Unicode code
@@ -123,11 +124,13 @@ function dirname(path) {
123
124
  var NodeFilesApi = class {
124
125
  rootDir;
125
126
  constructor(options) {
126
- this.rootDir = options?.rootDir ?? process.cwd();
127
+ this.rootDir = nodePath.resolve(options?.rootDir ?? process.cwd());
127
128
  }
129
+ /** The real path of a virtual one; throws when `..` segments would take it outside rootDir. */
128
130
  resolvePath(virtualPath) {
129
- const normalized = normalizePath(virtualPath);
130
- return this.rootDir + normalized;
131
+ const real = nodePath.resolve(this.rootDir, `.${normalizePath(virtualPath)}`);
132
+ if (real !== this.rootDir && !real.startsWith(this.rootDir + nodePath.sep)) throw new Error(`NodeFilesApi: path is outside rootDir: ${virtualPath}`);
133
+ return real;
131
134
  }
132
135
  async *read(path, options) {
133
136
  const realPath = this.resolvePath(path);
@@ -162,7 +165,7 @@ var NodeFilesApi = class {
162
165
  async write(path, content) {
163
166
  const realPath = this.resolvePath(path);
164
167
  const normalized = normalizePath(path);
165
- const dir = this.rootDir + dirname(normalized);
168
+ const dir = this.resolvePath(dirname(normalized));
166
169
  await fs.mkdir(dir, { recursive: true });
167
170
  const chunks = [];
168
171
  let totalLength = 0;
@@ -263,7 +266,7 @@ var NodeFilesApi = class {
263
266
  return false;
264
267
  }
265
268
  try {
266
- const targetDir = this.rootDir + dirname(normalizedTarget);
269
+ const targetDir = this.resolvePath(dirname(normalizedTarget));
267
270
  await fs.mkdir(targetDir, { recursive: true });
268
271
  await fs.rename(sourcePath, targetPath);
269
272
  return true;
@@ -281,7 +284,7 @@ var NodeFilesApi = class {
281
284
  return false;
282
285
  }
283
286
  try {
284
- const targetDir = this.rootDir + dirname(normalizedTarget);
287
+ const targetDir = this.resolvePath(dirname(normalizedTarget));
285
288
  await fs.mkdir(targetDir, { recursive: true });
286
289
  await fs.cp(sourcePath, targetPath, { recursive: true });
287
290
  return true;
@@ -1,8 +1,8 @@
1
1
  import type { FileInfo, FileStats, FilesApi, ListOptions, ReadOptions } from "@statewalker/webrun-files";
2
2
  export interface NodeFilesApiOptions {
3
3
  /**
4
- * Root directory for file operations. All paths are resolved relative to this.
5
- * Defaults to current working directory if not specified.
4
+ * Root directory for file operations. All paths are resolved relative to this, and a path
5
+ * whose `..` segments would leave it is refused. Defaults to the current working directory.
6
6
  */
7
7
  rootDir?: string;
8
8
  }
@@ -13,6 +13,7 @@ export interface NodeFilesApiOptions {
13
13
  export declare class NodeFilesApi implements FilesApi {
14
14
  private rootDir;
15
15
  constructor(options?: NodeFilesApiOptions);
16
+ /** The real path of a virtual one; throws when `..` segments would take it outside rootDir. */
16
17
  private resolvePath;
17
18
  read(path: string, options?: ReadOptions): AsyncIterable<Uint8Array>;
18
19
  write(path: string, content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"node-files-api.d.ts","sourceRoot":"","sources":["../src/node-files-api.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,WAAW,EACX,WAAW,EACZ,MAAM,2BAA2B,CAAC;AAGnC,MAAM,WAAW,mBAAmB;IAClC;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;GAGG;AACH,qBAAa,YAAa,YAAW,QAAQ;IAC3C,OAAO,CAAC,OAAO,CAAS;IAExB,YAAY,OAAO,CAAC,EAAE,mBAAmB,EAExC;IAED,OAAO,CAAC,WAAW;IAKZ,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,UAAU,CAAC,CAyC1E;IAEK,KAAK,CACT,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,GACxD,OAAO,CAAC,IAAI,CAAC,CAuBf;IAEK,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAGvC;IAED,qFAAqF;IAC9E,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,QAAQ,CAAC,CAExE;IAED,mFAAmF;YACrE,YAAY;IAgCpB,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC,CAkBxD;IAEK,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAS3C;IAEK,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAc3C;IAEK,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAmB3D;IAEK,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAmB3D;CACF"}
1
+ {"version":3,"file":"node-files-api.d.ts","sourceRoot":"","sources":["../src/node-files-api.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACV,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,WAAW,EACX,WAAW,EACZ,MAAM,2BAA2B,CAAC;AAGnC,MAAM,WAAW,mBAAmB;IAClC;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;GAGG;AACH,qBAAa,YAAa,YAAW,QAAQ;IAC3C,OAAO,CAAC,OAAO,CAAS;IAExB,YAAY,OAAO,CAAC,EAAE,mBAAmB,EAExC;IAED,+FAA+F;IAC/F,OAAO,CAAC,WAAW;IAQZ,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,UAAU,CAAC,CAyC1E;IAEK,KAAK,CACT,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,GACxD,OAAO,CAAC,IAAI,CAAC,CAuBf;IAEK,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAGvC;IAED,qFAAqF;IAC9E,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,QAAQ,CAAC,CAExE;IAED,mFAAmF;YACrE,YAAY;IAgCpB,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC,CAkBxD;IAEK,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAS3C;IAEK,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAc3C;IAEK,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAmB3D;IAEK,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAmB3D;CACF"}
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "@statewalker/webrun-files-node",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Node.js FilesApi implementation",
7
- "homepage": "https://github.com/statewalker/webrun-files",
7
+ "bugs": {
8
+ "url": "https://github.com/statewalker/webrun-files/issues"
9
+ },
10
+ "homepage": "https://github.com/statewalker/webrun-files/tree/main/packages/webrun-files-node#readme",
8
11
  "author": {
9
12
  "name": "Mikhail Kotelnikov",
10
13
  "email": "mikhail.kotelnikov@gmail.com"
@@ -12,7 +15,8 @@
12
15
  "license": "MIT",
13
16
  "repository": {
14
17
  "type": "git",
15
- "url": "git@github.com:statewalker/webrun-files.git"
18
+ "url": "git+https://github.com/statewalker/webrun-files.git",
19
+ "directory": "packages/webrun-files-node"
16
20
  },
17
21
  "main": "./dist/cjs/index.cjs",
18
22
  "module": "./dist/esm/index.js",
@@ -30,7 +34,7 @@
30
34
  "src"
31
35
  ],
32
36
  "dependencies": {
33
- "@statewalker/webrun-files": "^0.10.1"
37
+ "@statewalker/webrun-files": "^0.10.2"
34
38
  },
35
39
  "devDependencies": {
36
40
  "@types/node": "^26.6.4",
@@ -1,5 +1,6 @@
1
1
  import type { Dirent, Stats } from "node:fs";
2
2
  import * as fs from "node:fs/promises";
3
+ import * as nodePath from "node:path";
3
4
  import type {
4
5
  FileInfo,
5
6
  FileStats,
@@ -11,8 +12,8 @@ import { dirname, joinPath, listInPathOrder, normalizePath } from "@statewalker/
11
12
 
12
13
  export interface NodeFilesApiOptions {
13
14
  /**
14
- * Root directory for file operations. All paths are resolved relative to this.
15
- * Defaults to current working directory if not specified.
15
+ * Root directory for file operations. All paths are resolved relative to this, and a path
16
+ * whose `..` segments would leave it is refused. Defaults to the current working directory.
16
17
  */
17
18
  rootDir?: string;
18
19
  }
@@ -25,12 +26,16 @@ export class NodeFilesApi implements FilesApi {
25
26
  private rootDir: string;
26
27
 
27
28
  constructor(options?: NodeFilesApiOptions) {
28
- this.rootDir = options?.rootDir ?? process.cwd();
29
+ this.rootDir = nodePath.resolve(options?.rootDir ?? process.cwd());
29
30
  }
30
31
 
32
+ /** The real path of a virtual one; throws when `..` segments would take it outside rootDir. */
31
33
  private resolvePath(virtualPath: string): string {
32
- const normalized = normalizePath(virtualPath);
33
- return this.rootDir + normalized;
34
+ const real = nodePath.resolve(this.rootDir, `.${normalizePath(virtualPath)}`);
35
+ if (real !== this.rootDir && !real.startsWith(this.rootDir + nodePath.sep)) {
36
+ throw new Error(`NodeFilesApi: path is outside rootDir: ${virtualPath}`);
37
+ }
38
+ return real;
34
39
  }
35
40
 
36
41
  async *read(path: string, options?: ReadOptions): AsyncIterable<Uint8Array> {
@@ -82,7 +87,7 @@ export class NodeFilesApi implements FilesApi {
82
87
  ): Promise<void> {
83
88
  const realPath = this.resolvePath(path);
84
89
  const normalized = normalizePath(path);
85
- const dir = this.rootDir + dirname(normalized);
90
+ const dir = this.resolvePath(dirname(normalized));
86
91
 
87
92
  await fs.mkdir(dir, { recursive: true });
88
93
 
@@ -206,7 +211,7 @@ export class NodeFilesApi implements FilesApi {
206
211
  }
207
212
 
208
213
  try {
209
- const targetDir = this.rootDir + dirname(normalizedTarget);
214
+ const targetDir = this.resolvePath(dirname(normalizedTarget));
210
215
  await fs.mkdir(targetDir, { recursive: true });
211
216
  await fs.rename(sourcePath, targetPath);
212
217
  return true;
@@ -227,7 +232,7 @@ export class NodeFilesApi implements FilesApi {
227
232
  }
228
233
 
229
234
  try {
230
- const targetDir = this.rootDir + dirname(normalizedTarget);
235
+ const targetDir = this.resolvePath(dirname(normalizedTarget));
231
236
  await fs.mkdir(targetDir, { recursive: true });
232
237
  await fs.cp(sourcePath, targetPath, { recursive: true });
233
238
  return true;