@poe-platform/safe-fs 0.1.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.
Files changed (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +290 -0
  3. package/dist/safe-fs/bridge/confinement.d.ts +3 -0
  4. package/dist/safe-fs/bridge/confinement.js +171 -0
  5. package/dist/safe-fs/bridge/filesystem.d.ts +88 -0
  6. package/dist/safe-fs/bridge/filesystem.js +411 -0
  7. package/dist/safe-fs/bridge/index.d.ts +6 -0
  8. package/dist/safe-fs/bridge/index.js +18 -0
  9. package/dist/safe-fs/bridge/stats.d.ts +4 -0
  10. package/dist/safe-fs/bridge/stats.js +37 -0
  11. package/dist/safe-fs/bridge/types.d.ts +75 -0
  12. package/dist/safe-fs/bridge/types.js +1 -0
  13. package/dist/safe-fs/bridge/values.d.ts +6 -0
  14. package/dist/safe-fs/bridge/values.js +65 -0
  15. package/dist/safe-fs/config/memory.d.ts +3 -0
  16. package/dist/safe-fs/config/memory.js +9 -0
  17. package/dist/safe-fs/config/real.d.ts +5 -0
  18. package/dist/safe-fs/config/real.js +16 -0
  19. package/dist/safe-fs/config.d.ts +15 -0
  20. package/dist/safe-fs/config.js +46 -0
  21. package/dist/safe-fs/config.node.d.ts +2 -0
  22. package/dist/safe-fs/config.node.js +22 -0
  23. package/dist/safe-fs/contracts/abort.d.ts +5 -0
  24. package/dist/safe-fs/contracts/abort.js +34 -0
  25. package/dist/safe-fs/contracts/cleanup.d.ts +1 -0
  26. package/dist/safe-fs/contracts/cleanup.js +9 -0
  27. package/dist/safe-fs/contracts/errors.d.ts +50 -0
  28. package/dist/safe-fs/contracts/errors.js +77 -0
  29. package/dist/safe-fs/contracts/filesystem.d.ts +96 -0
  30. package/dist/safe-fs/contracts/filesystem.js +1 -0
  31. package/dist/safe-fs/contracts/index.d.ts +4 -0
  32. package/dist/safe-fs/contracts/index.js +4 -0
  33. package/dist/safe-fs/contracts/io.d.ts +8 -0
  34. package/dist/safe-fs/contracts/io.js +92 -0
  35. package/dist/safe-fs/contracts/path.d.ts +8 -0
  36. package/dist/safe-fs/contracts/path.js +8 -0
  37. package/dist/safe-fs/contracts/portable-path.d.ts +13 -0
  38. package/dist/safe-fs/contracts/portable-path.js +57 -0
  39. package/dist/safe-fs/contracts/virtual-path.d.ts +7 -0
  40. package/dist/safe-fs/contracts/virtual-path.js +66 -0
  41. package/dist/safe-fs/core.d.ts +12 -0
  42. package/dist/safe-fs/core.js +12 -0
  43. package/dist/safe-fs/fs/memory/index.d.ts +58 -0
  44. package/dist/safe-fs/fs/memory/index.js +532 -0
  45. package/dist/safe-fs/fs/mount/comparison.d.ts +19 -0
  46. package/dist/safe-fs/fs/mount/comparison.js +118 -0
  47. package/dist/safe-fs/fs/mount/identity.d.ts +2 -0
  48. package/dist/safe-fs/fs/mount/identity.js +12 -0
  49. package/dist/safe-fs/fs/mount/index.d.ts +44 -0
  50. package/dist/safe-fs/fs/mount/index.js +546 -0
  51. package/dist/safe-fs/fs/overlay/index.d.ts +73 -0
  52. package/dist/safe-fs/fs/overlay/index.js +927 -0
  53. package/dist/safe-fs/fs/readonly/index.d.ts +31 -0
  54. package/dist/safe-fs/fs/readonly/index.js +128 -0
  55. package/dist/safe-fs/fs/real/allocation.d.ts +1 -0
  56. package/dist/safe-fs/fs/real/allocation.js +8 -0
  57. package/dist/safe-fs/fs/real/index.d.ts +76 -0
  58. package/dist/safe-fs/fs/real/index.js +543 -0
  59. package/dist/safe-fs/fs/s3/authority.d.ts +7 -0
  60. package/dist/safe-fs/fs/s3/authority.js +31 -0
  61. package/dist/safe-fs/fs/s3/filesystem.d.ts +93 -0
  62. package/dist/safe-fs/fs/s3/filesystem.js +1024 -0
  63. package/dist/safe-fs/fs/s3/http/index.d.ts +2 -0
  64. package/dist/safe-fs/fs/s3/http/index.js +1 -0
  65. package/dist/safe-fs/fs/s3/http/request.d.ts +16 -0
  66. package/dist/safe-fs/fs/s3/http/request.js +196 -0
  67. package/dist/safe-fs/fs/s3/http/signature.d.ts +20 -0
  68. package/dist/safe-fs/fs/s3/http/signature.js +74 -0
  69. package/dist/safe-fs/fs/s3/http/transport.d.ts +3 -0
  70. package/dist/safe-fs/fs/s3/http/transport.js +455 -0
  71. package/dist/safe-fs/fs/s3/http/types.d.ts +30 -0
  72. package/dist/safe-fs/fs/s3/http/types.js +1 -0
  73. package/dist/safe-fs/fs/s3/http/xml.d.ts +11 -0
  74. package/dist/safe-fs/fs/s3/http/xml.js +188 -0
  75. package/dist/safe-fs/fs/s3/index.d.ts +6 -0
  76. package/dist/safe-fs/fs/s3/index.js +3 -0
  77. package/dist/safe-fs/fs/s3/mock.d.ts +48 -0
  78. package/dist/safe-fs/fs/s3/mock.js +233 -0
  79. package/dist/safe-fs/fs/s3/registry.d.ts +10 -0
  80. package/dist/safe-fs/fs/s3/registry.js +71 -0
  81. package/dist/safe-fs/fs/s3/transport.d.ts +103 -0
  82. package/dist/safe-fs/fs/s3/transport.js +28 -0
  83. package/dist/safe-fs/fs/webdav/index.d.ts +2 -0
  84. package/dist/safe-fs/fs/webdav/index.js +1 -0
  85. package/dist/safe-fs/fs/webdav/resource-id.d.ts +18 -0
  86. package/dist/safe-fs/fs/webdav/resource-id.js +109 -0
  87. package/dist/safe-fs/fs/webdav/webdav.d.ts +101 -0
  88. package/dist/safe-fs/fs/webdav/webdav.js +1226 -0
  89. package/dist/safe-fs/fs/webdav/xml.d.ts +15 -0
  90. package/dist/safe-fs/fs/webdav/xml.js +250 -0
  91. package/dist/safe-fs/index.d.ts +14 -0
  92. package/dist/safe-fs/index.js +12 -0
  93. package/dist/safe-fs/node/filesystem.d.ts +11 -0
  94. package/dist/safe-fs/node/filesystem.js +28 -0
  95. package/dist/safe-fs/node/index.d.ts +2 -0
  96. package/dist/safe-fs/node/index.js +1 -0
  97. package/dist/safe-fs/node/stats.d.ts +1 -0
  98. package/dist/safe-fs/node/stats.js +1 -0
  99. package/dist/safe-fs/node/values.d.ts +1 -0
  100. package/dist/safe-fs/node/values.js +1 -0
  101. package/dist/safe-fs/node-host.d.ts +1 -0
  102. package/dist/safe-fs/node-host.js +1 -0
  103. package/dist/safe-fs/node-unavailable.d.ts +1 -0
  104. package/dist/safe-fs/node-unavailable.js +1 -0
  105. package/dist/safe-fs/platform/browser.d.ts +12 -0
  106. package/dist/safe-fs/platform/browser.js +39 -0
  107. package/dist/safe-fs/platform/node.d.ts +13 -0
  108. package/dist/safe-fs/platform/node.js +26 -0
  109. package/package.json +108 -0
@@ -0,0 +1,543 @@
1
+ import { constants } from "node:fs";
2
+ import * as native from "node:fs/promises";
3
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { nativeAllocatedBytes } from "./allocation.js";
5
+ import { finishCleanup } from "../../contracts/cleanup.js";
6
+ import { FsError, collectBytes, isErrnoCode, toByteSource, toFsError, validatePath, } from "../../contracts/index.js";
7
+ function fileType(stats) {
8
+ if (stats.isFile())
9
+ return "file";
10
+ if (stats.isDirectory())
11
+ return "directory";
12
+ if (stats.isSymbolicLink())
13
+ return "symlink";
14
+ throw new FsError("ENOTSUP", { message: "special filesystem nodes are not supported" });
15
+ }
16
+ function fileStat(stats) {
17
+ const allocatedBytes = nativeAllocatedBytes(stats.blocks, process.platform);
18
+ return {
19
+ type: fileType(stats), size: stats.size, mode: stats.mode,
20
+ ...(allocatedBytes === undefined ? {} : { allocatedBytes }),
21
+ atimeMs: stats.atimeMs, mtimeMs: stats.mtimeMs, ctimeMs: stats.ctimeMs,
22
+ birthtimeMs: stats.birthtimeMs, ino: stats.ino, dev: stats.dev,
23
+ ...(Number.isSafeInteger(stats.dev) && stats.dev >= 0 && Number.isSafeInteger(stats.ino) && stats.ino >= 0
24
+ ? { identityScope: Symbol.for("virtual-bash.fs.native") } : {}),
25
+ nlink: stats.nlink, uid: stats.uid, gid: stats.gid,
26
+ };
27
+ }
28
+ function integer(value, minimum = 0) {
29
+ if (!Number.isSafeInteger(value) || value < minimum) {
30
+ throw new FsError("EINVAL", { message: "expected a nonnegative safe integer" });
31
+ }
32
+ }
33
+ function nativeError(error) {
34
+ if (typeof error === "object" && error !== null && "code" in error
35
+ && (error.code === "ERR_INVALID_ARG_TYPE" || error.code === "ERR_INVALID_ARG_VALUE" || error.code === "ERR_OUT_OF_RANGE")) {
36
+ return new FsError("EINVAL", { cause: error });
37
+ }
38
+ if (typeof error === "object" && error !== null && "info" in error) {
39
+ const info = error.info;
40
+ if (typeof info === "object" && info !== null && "code" in info && isErrnoCode(info.code)) {
41
+ return new FsError(info.code, { cause: error });
42
+ }
43
+ }
44
+ return toFsError(error);
45
+ }
46
+ /**
47
+ * Async byte filesystem for trusted POSIX hosts (Windows is unsupported).
48
+ * Both relative and absolute input paths are virtual POSIX paths rooted at `/`;
49
+ * components resolve in order, following symlinks before subsequent `..` and
50
+ * preserving trailing directory requirements. Empty paths are ENOENT. Excess
51
+ * input `..` clamps at the virtual root; symlink targets may never cross it.
52
+ * There is no process cwd dependency. The configured host root must already
53
+ * exist; its canonical location is pinned on first use or by the async factory.
54
+ *
55
+ * Absolute symlink targets created here are virtual paths, stored as rooted
56
+ * host targets without lexical normalization. Relative targets retain their
57
+ * text; safe dangling, non-directory, and looping targets may be created.
58
+ * Existing host symlinks are
59
+ * followed only when every traversed component remains beneath the root, with
60
+ * a 40-link traversal limit. Existing absolute host targets must start with the
61
+ * canonical root, not a different host alias of that root. lstat, removal, and
62
+ * rename inspect or modify a final symlink itself rather than its target. Absolute readlink results are
63
+ * translated back into virtual paths; external absolute targets are refused.
64
+ *
65
+ * SECURITY LIMIT: containment checks and subsequent Node path operations are
66
+ * not atomic. O_NOFOLLOW narrows final-file open races, but ancestor swaps,
67
+ * concurrent renames, mount changes, and preexisting hardlinks cannot be made
68
+ * safe by these APIs. This is NOT a race-proof sandbox or an isolation boundary
69
+ * against another process modifying the tree. Use an OS sandbox for that.
70
+ *
71
+ * Only regular files, directories, and symlinks are represented. Permissions,
72
+ * ownership, timestamp precision, case sensitivity, umask, and rename behavior
73
+ * are those of the host filesystem. Copy is not atomic; rename can fail EXDEV.
74
+ * Cancellation is cooperative between operations/chunks, not rollback: failed
75
+ * or canceled writes/copies may leave partial data. No native commands execute.
76
+ * Destructive rm/rename operands ending in `.` or `..` are refused with EINVAL,
77
+ * including native recursive-rm edge cases that could otherwise delete content.
78
+ * Public filesystem errors carry only virtual operands; native causes are
79
+ * intentionally omitted so host paths cannot escape through nested errors.
80
+ */
81
+ export class RealFileSystem {
82
+ capabilities = Object.freeze({
83
+ readOnly: false, symlinks: true, hardlinks: true, permissions: true,
84
+ timestamps: true, atomicRename: true, streamingRead: true, streamingWrite: true,
85
+ });
86
+ configuredRoot;
87
+ rootPromise;
88
+ constructor(options) {
89
+ const root = typeof options === "string" ? options : options.root;
90
+ validatePath(root);
91
+ if (!isAbsolute(root)) {
92
+ throw new FsError("EINVAL", { syscall: "root", path: root, message: "root must be an absolute host path" });
93
+ }
94
+ if (sep !== "/") {
95
+ throw new FsError("ENOTSUP", { syscall: "root", message: "this backend requires a POSIX host" });
96
+ }
97
+ this.configuredRoot = root;
98
+ }
99
+ async root(options = {}) {
100
+ options.signal?.throwIfAborted();
101
+ this.rootPromise ??= (async () => {
102
+ const root = await native.realpath(this.configuredRoot);
103
+ if (!(await native.stat(root)).isDirectory())
104
+ throw new FsError("ENOTDIR");
105
+ return root;
106
+ })();
107
+ const root = await this.rootPromise;
108
+ options.signal?.throwIfAborted();
109
+ if (await native.realpath(root) !== root)
110
+ throw new FsError("EACCES");
111
+ options.signal?.throwIfAborted();
112
+ if (!(await native.stat(root)).isDirectory())
113
+ throw new FsError("ENOTDIR");
114
+ options.signal?.throwIfAborted();
115
+ return root;
116
+ }
117
+ absoluteTarget(root, target) {
118
+ if (target === root)
119
+ return [];
120
+ const prefix = root === "/" ? "/" : `${root}/`;
121
+ if (!target.startsWith(prefix)) {
122
+ throw new FsError("EACCES", { message: "symlink target escapes the configured root" });
123
+ }
124
+ return target.slice(prefix.length).split("/");
125
+ }
126
+ async walk(root, components, options) {
127
+ let current = root;
128
+ let links = 0;
129
+ const pending = [...components];
130
+ while (pending.length > 0) {
131
+ options.signal?.throwIfAborted();
132
+ const { name: component, fromLink } = pending.shift();
133
+ if (component === "" || component === ".")
134
+ continue;
135
+ if (component === "..") {
136
+ if (current === root) {
137
+ if (fromLink)
138
+ throw new FsError("EACCES", { message: "symlink target escapes the configured root" });
139
+ }
140
+ else {
141
+ current = resolve(current, "..");
142
+ }
143
+ continue;
144
+ }
145
+ const candidate = join(current, component);
146
+ let stats;
147
+ try {
148
+ stats = await native.lstat(candidate);
149
+ }
150
+ catch (error) {
151
+ options.signal?.throwIfAborted();
152
+ const code = error.code;
153
+ if (options.checkTarget && (code === "ENOENT" || code === "ENOTDIR")) {
154
+ current = candidate;
155
+ continue;
156
+ }
157
+ if (code !== "ENOENT")
158
+ throw error;
159
+ if (options.createDirectories && !fromLink) {
160
+ try {
161
+ options.signal?.throwIfAborted();
162
+ await native.mkdir(candidate, { mode: options.createDirectories.mode });
163
+ }
164
+ catch (creationError) {
165
+ if (nativeError(creationError).code !== "EEXIST")
166
+ throw creationError;
167
+ }
168
+ options.signal?.throwIfAborted();
169
+ stats = await native.lstat(candidate);
170
+ }
171
+ else if (!options.createDirectories && options.missing === "final" && pending.every((part) => part.name === "")) {
172
+ return pending.length > 0 ? `${candidate}/` : candidate;
173
+ }
174
+ else {
175
+ throw error;
176
+ }
177
+ }
178
+ options.signal?.throwIfAborted();
179
+ if (stats.isSymbolicLink() && (pending.length > 0 || options.followFinal !== false)) {
180
+ if (++links > 40)
181
+ throw new FsError("ELOOP");
182
+ const target = await native.readlink(candidate);
183
+ if (isAbsolute(target)) {
184
+ pending.unshift(...this.absoluteTarget(root, target).map((name) => ({ name, fromLink: true })));
185
+ current = root;
186
+ }
187
+ else {
188
+ pending.unshift(...target.split("/").map((name) => ({ name, fromLink: true })));
189
+ }
190
+ continue;
191
+ }
192
+ if (pending.length > 0 && !stats.isDirectory() && !options.checkTarget) {
193
+ if (options.deferTrailingSeparator && stats.isFile() && pending.every((part) => part.name === "")) {
194
+ return `${candidate}/`;
195
+ }
196
+ throw new FsError("ENOTDIR");
197
+ }
198
+ if (!options.checkTarget)
199
+ fileType(stats);
200
+ current = candidate;
201
+ }
202
+ options.signal?.throwIfAborted();
203
+ return components.at(-1)?.name === "" && current !== root ? `${current}/` : current;
204
+ }
205
+ async path(path, options = {}) {
206
+ options.signal?.throwIfAborted();
207
+ validatePath(path);
208
+ if (path === "")
209
+ throw new FsError("ENOENT");
210
+ return this.walk(await this.root(options), path.split("/").map((name) => ({ name, fromLink: false })), options);
211
+ }
212
+ async operation(syscall, path, options, action, dest) {
213
+ options.signal?.throwIfAborted();
214
+ try {
215
+ validatePath(path);
216
+ if (dest !== undefined)
217
+ validatePath(dest);
218
+ const result = await action();
219
+ options.signal?.throwIfAborted();
220
+ return result;
221
+ }
222
+ catch (error) {
223
+ options.signal?.throwIfAborted();
224
+ const converted = nativeError(error);
225
+ throw new FsError(converted.code, {
226
+ syscall, path, ...(dest === undefined ? {} : { dest }),
227
+ });
228
+ }
229
+ }
230
+ protectRoot(path, root) {
231
+ if (resolve(path) === root)
232
+ throw new FsError("EBUSY", { message: "the filesystem root cannot be removed or replaced" });
233
+ }
234
+ protectTerminal(path) {
235
+ const terminal = path.split("/").filter(Boolean).at(-1);
236
+ if (terminal === "." || terminal === "..")
237
+ throw new FsError("EINVAL");
238
+ }
239
+ async readFile(path, options = {}) {
240
+ return this.operation("readFile", path, options, async () => {
241
+ const maxBytes = options.maxBytes ?? Number.MAX_SAFE_INTEGER;
242
+ integer(maxBytes);
243
+ return collectBytes(this.readStream(path, options), { maxBytes, ...options });
244
+ });
245
+ }
246
+ async writeFile(path, data, options = {}) {
247
+ return this.operation("writeFile", path, options, async () => {
248
+ if (!(data instanceof Uint8Array))
249
+ throw new FsError("EINVAL");
250
+ await this.writeStream(path, toByteSource(data), options);
251
+ });
252
+ }
253
+ async appendFile(path, data, options = {}) {
254
+ return this.operation("appendFile", path, options, () => this.writeFile(path, data, { ...options, flag: "a" }));
255
+ }
256
+ async stat(path, options = {}) {
257
+ return this.operation("stat", path, options, async () => {
258
+ const target = await this.path(path, options);
259
+ options.signal?.throwIfAborted();
260
+ return fileStat(await native.stat(target));
261
+ });
262
+ }
263
+ async lstat(path, options = {}) {
264
+ return this.operation("lstat", path, options, async () => {
265
+ const target = await this.path(path, { ...options, followFinal: false });
266
+ options.signal?.throwIfAborted();
267
+ return fileStat(await native.lstat(target));
268
+ });
269
+ }
270
+ async readdir(path, options = {}) {
271
+ return this.operation("readdir", path, options, async () => {
272
+ const target = await this.path(path, options);
273
+ options.signal?.throwIfAborted();
274
+ const entries = await native.readdir(target, { withFileTypes: true });
275
+ return entries.map((entry) => ({ name: entry.name, type: fileType(entry) }));
276
+ });
277
+ }
278
+ async mkdir(path, options = {}) {
279
+ return this.operation("mkdir", path, options, async () => {
280
+ if (options.mode !== undefined)
281
+ integer(options.mode);
282
+ const target = await this.path(path, {
283
+ ...options,
284
+ missing: "final", followFinal: !!options.recursive, deferTrailingSeparator: true,
285
+ ...(options.recursive ? { createDirectories: { mode: options.mode ?? 0o777 } } : {}),
286
+ });
287
+ options.signal?.throwIfAborted();
288
+ await native.mkdir(target, { recursive: options.recursive ?? false, ...(options.mode === undefined ? {} : { mode: options.mode }) });
289
+ });
290
+ }
291
+ async rmdir(path, options = {}) {
292
+ return this.operation("rmdir", path, options, async () => {
293
+ const target = await this.path(path.replace(/\/+$/, "") || (path ? "/" : ""), { ...options, followFinal: false });
294
+ this.protectTerminal(path);
295
+ this.protectRoot(target, await this.root(options));
296
+ options.signal?.throwIfAborted();
297
+ await native.rmdir(target);
298
+ });
299
+ }
300
+ async rm(path, options = {}) {
301
+ return this.operation("rm", path, options, async () => {
302
+ let target;
303
+ try {
304
+ target = await this.path(path, { ...options, followFinal: false });
305
+ }
306
+ catch (error) {
307
+ if (options.force && toFsError(error).code === "ENOENT")
308
+ return;
309
+ throw error;
310
+ }
311
+ this.protectTerminal(path);
312
+ this.protectRoot(target, await this.root(options));
313
+ options.signal?.throwIfAborted();
314
+ await native.rm(target, { recursive: options.recursive ?? false, force: options.force ?? false });
315
+ });
316
+ }
317
+ async rename(source, destination, options = {}) {
318
+ return this.operation("rename", source, options, async () => {
319
+ const from = await this.path(source, { ...options, followFinal: false });
320
+ const to = await this.path(destination, { ...options, followFinal: false, missing: "final" });
321
+ this.protectTerminal(source);
322
+ this.protectTerminal(destination);
323
+ const root = await this.root(options);
324
+ this.protectRoot(from, root);
325
+ this.protectRoot(to, root);
326
+ options.signal?.throwIfAborted();
327
+ await native.rename(from, to);
328
+ }, destination);
329
+ }
330
+ async copyFile(source, destination, options = {}) {
331
+ return this.operation("copyFile", source, options, async () => {
332
+ const from = await this.path(source, options);
333
+ const to = await this.path(destination, { ...options, missing: "final", followFinal: !options.exclusive, deferTrailingSeparator: true });
334
+ let flags = options.exclusive ? constants.COPYFILE_EXCL : 0;
335
+ if (!to.endsWith("/")) {
336
+ const origin = await native.stat(from, { bigint: true });
337
+ let target;
338
+ try {
339
+ target = await native.lstat(to, { bigint: true });
340
+ }
341
+ catch (error) {
342
+ if (nativeError(error).code !== "ENOENT")
343
+ throw error;
344
+ }
345
+ if (target && options.exclusive)
346
+ throw new FsError("EEXIST");
347
+ if (target && origin.isFile() && origin.dev === target.dev && origin.ino === target.ino)
348
+ throw new FsError("EINVAL");
349
+ if (!target)
350
+ flags |= constants.COPYFILE_EXCL;
351
+ }
352
+ options.signal?.throwIfAborted();
353
+ await native.copyFile(from, to, flags);
354
+ }, destination);
355
+ }
356
+ async realpath(path, options = {}) {
357
+ return this.operation("realpath", path, options, async () => {
358
+ const target = await this.path(path, options);
359
+ return `/${relative(await this.root(options), target)}`;
360
+ });
361
+ }
362
+ async access(path, mode = constants.F_OK, options = {}) {
363
+ return this.operation("access", path, options, async () => {
364
+ integer(mode);
365
+ if (mode > 7)
366
+ throw new FsError("EINVAL");
367
+ const target = await this.path(path, options);
368
+ options.signal?.throwIfAborted();
369
+ await native.access(target, mode);
370
+ });
371
+ }
372
+ async readlink(path, options = {}) {
373
+ return this.operation("readlink", path, options, async () => {
374
+ const resolved = await this.path(path, { ...options, followFinal: false });
375
+ options.signal?.throwIfAborted();
376
+ const target = await native.readlink(resolved);
377
+ if (!isAbsolute(target))
378
+ return target;
379
+ return `/${this.absoluteTarget(await this.root(options), target).join("/")}`;
380
+ });
381
+ }
382
+ async symlink(target, path, options = {}) {
383
+ return this.operation("symlink", path, options, async () => {
384
+ validatePath(target);
385
+ if (!target)
386
+ throw new FsError("ENOENT");
387
+ const destination = await this.path(path, { ...options, followFinal: false, missing: "final" });
388
+ const root = await this.root(options);
389
+ const stored = target.startsWith("/") ? `${root === "/" ? "" : root}${target}` : target;
390
+ const components = target.startsWith("/")
391
+ ? target.split("/")
392
+ : [...relative(root, resolve(destination, "..")).split("/"), ...target.split("/")];
393
+ try {
394
+ await this.walk(root, components.map((name) => ({ name, fromLink: true })), { ...options, checkTarget: true });
395
+ }
396
+ catch (error) {
397
+ if (nativeError(error).code !== "ELOOP")
398
+ throw error;
399
+ }
400
+ options.signal?.throwIfAborted();
401
+ await native.symlink(stored, destination);
402
+ });
403
+ }
404
+ async link(existingPath, newPath, options = {}) {
405
+ return this.operation("link", existingPath, options, async () => {
406
+ const source = await this.path(existingPath, { ...options, followFinal: false });
407
+ const destination = await this.path(newPath, { ...options, followFinal: false, missing: "final" });
408
+ options.signal?.throwIfAborted();
409
+ await native.link(source, destination);
410
+ }, newPath);
411
+ }
412
+ async chmod(path, mode, options = {}) {
413
+ return this.operation("chmod", path, options, async () => {
414
+ integer(mode);
415
+ const target = await this.path(path, options);
416
+ options.signal?.throwIfAborted();
417
+ await native.chmod(target, mode);
418
+ });
419
+ }
420
+ async utimes(path, atimeMs, mtimeMs, options = {}) {
421
+ return this.operation("utimes", path, options, async () => {
422
+ if (!Number.isFinite(atimeMs) || !Number.isFinite(mtimeMs))
423
+ throw new FsError("EINVAL");
424
+ const target = await this.path(path, options);
425
+ options.signal?.throwIfAborted();
426
+ await native.utimes(target, new Date(atimeMs), new Date(mtimeMs));
427
+ });
428
+ }
429
+ async truncate(path, length = 0, options = {}) {
430
+ return this.operation("truncate", path, options, async () => {
431
+ integer(length);
432
+ const target = await this.path(path, options);
433
+ options.signal?.throwIfAborted();
434
+ const handle = await native.open(target, constants.O_WRONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
435
+ try {
436
+ options.signal?.throwIfAborted();
437
+ if (!(await handle.stat()).isFile())
438
+ throw new FsError("ENOTSUP");
439
+ options.signal?.throwIfAborted();
440
+ await handle.truncate(length);
441
+ }
442
+ finally {
443
+ await handle.close();
444
+ }
445
+ });
446
+ }
447
+ async *readStream(path, options = {}) {
448
+ const syscall = "readStream";
449
+ let handle;
450
+ let failed = false;
451
+ try {
452
+ options.signal?.throwIfAborted();
453
+ const start = options.start ?? 0;
454
+ const end = options.endExclusive ?? Number.MAX_SAFE_INTEGER;
455
+ const chunkSize = options.chunkSize ?? 64 * 1024;
456
+ integer(start);
457
+ integer(end);
458
+ integer(chunkSize, 1);
459
+ if (end < start)
460
+ throw new FsError("EINVAL");
461
+ const target = await this.path(path, options);
462
+ options.signal?.throwIfAborted();
463
+ handle = await native.open(target, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
464
+ options.signal?.throwIfAborted();
465
+ const stats = await handle.stat();
466
+ if (stats.isDirectory())
467
+ throw new FsError("EISDIR");
468
+ if (!stats.isFile())
469
+ throw new FsError("ENOTSUP");
470
+ let position = start;
471
+ while (position < end) {
472
+ options.signal?.throwIfAborted();
473
+ const bytes = new Uint8Array(Math.min(chunkSize, end - position));
474
+ const { bytesRead } = await handle.read(bytes, 0, bytes.byteLength, position);
475
+ options.signal?.throwIfAborted();
476
+ if (bytesRead === 0)
477
+ break;
478
+ position += bytesRead;
479
+ yield bytes.subarray(0, bytesRead);
480
+ }
481
+ options.signal?.throwIfAborted();
482
+ }
483
+ catch (error) {
484
+ failed = true;
485
+ options.signal?.throwIfAborted();
486
+ throw new FsError(nativeError(error).code, { syscall, path });
487
+ }
488
+ finally {
489
+ await finishCleanup(async () => {
490
+ try {
491
+ await handle?.close();
492
+ }
493
+ catch (error) {
494
+ throw new FsError(nativeError(error).code, { syscall, path });
495
+ }
496
+ }, failed);
497
+ }
498
+ }
499
+ async writeStream(path, source, options = {}) {
500
+ return this.operation("writeStream", path, options, async () => {
501
+ const flag = options.flag ?? "w";
502
+ if (!["w", "wx", "a", "ax"].includes(flag))
503
+ throw new FsError("EINVAL");
504
+ if (options.mode !== undefined)
505
+ integer(options.mode);
506
+ const exclusive = flag.endsWith("x");
507
+ const destination = await this.path(path, { ...options, missing: "final", followFinal: !exclusive, deferTrailingSeparator: true });
508
+ const flags = constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK
509
+ | (flag.startsWith("a") ? constants.O_APPEND : constants.O_TRUNC)
510
+ | (exclusive ? constants.O_EXCL : 0);
511
+ options.signal?.throwIfAborted();
512
+ const handle = await native.open(destination, flags, options.mode ?? 0o666);
513
+ try {
514
+ options.signal?.throwIfAborted();
515
+ if (!(await handle.stat()).isFile())
516
+ throw new FsError("ENOTSUP");
517
+ options.signal?.throwIfAborted();
518
+ for await (const chunk of source) {
519
+ options.signal?.throwIfAborted();
520
+ if (!(chunk instanceof Uint8Array))
521
+ throw new FsError("EINVAL");
522
+ let offset = 0;
523
+ while (offset < chunk.byteLength) {
524
+ options.signal?.throwIfAborted();
525
+ const { bytesWritten } = await handle.write(chunk, offset, chunk.byteLength - offset, null);
526
+ if (bytesWritten === 0)
527
+ throw new FsError("EIO");
528
+ offset += bytesWritten;
529
+ }
530
+ }
531
+ }
532
+ finally {
533
+ await handle.close();
534
+ }
535
+ });
536
+ }
537
+ }
538
+ /** Construct and validate an existing root before returning the backend. */
539
+ export async function createRealFileSystem(options) {
540
+ const filesystem = new RealFileSystem(options);
541
+ await filesystem.stat("/");
542
+ return filesystem;
543
+ }
@@ -0,0 +1,7 @@
1
+ import type { FileStat, FileSystem } from "../../contracts/filesystem.js";
2
+ import type { S3HeadOutput, S3ObjectInput } from "./transport.js";
3
+ export { registerS3EntryOwner, getOwnedS3Entry, compareOwnedS3Entries } from "./registry.js";
4
+ export type { OwnedS3Entry } from "./registry.js";
5
+ export declare function recordMockS3Head(output: S3HeadOutput, input: S3ObjectInput, storage: object): void;
6
+ export declare function queryS3Head(input: S3ObjectInput, action: () => Promise<S3HeadOutput>): Promise<S3HeadOutput>;
7
+ export declare function recordS3Stat(filesystem: FileSystem, path: string, stat: FileStat, metadata: S3HeadOutput | undefined): void;
@@ -0,0 +1,31 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { recordS3Observation } from "./registry.js";
3
+ export { registerS3EntryOwner, getOwnedS3Entry, compareOwnedS3Entries } from "./registry.js";
4
+ const queries = new AsyncLocalStorage();
5
+ const providerHeads = new WeakMap();
6
+ const acceptedHeads = new WeakMap();
7
+ export function recordMockS3Head(output, input, storage) {
8
+ const query = queries.getStore();
9
+ if (query && query.Bucket === input.Bucket && query.Key === input.Key) {
10
+ providerHeads.set(output, { query, entry: { storage, key: input.Key } });
11
+ }
12
+ }
13
+ export async function queryS3Head(input, action) {
14
+ const query = { ...input };
15
+ return queries.run(query, async () => {
16
+ const output = await action();
17
+ acceptedHeads.delete(output);
18
+ const proof = providerHeads.get(output);
19
+ if (proof?.query === query)
20
+ acceptedHeads.set(output, proof.entry);
21
+ return output;
22
+ });
23
+ }
24
+ export function recordS3Stat(filesystem, path, stat, metadata) {
25
+ if (!metadata)
26
+ return;
27
+ const entry = acceptedHeads.get(metadata);
28
+ acceptedHeads.delete(metadata);
29
+ if (entry)
30
+ recordS3Observation(filesystem, path, stat, entry);
31
+ }
@@ -0,0 +1,93 @@
1
+ import { FsError } from "../../contracts/errors.js";
2
+ import type { AppendFileOptions, CopyFileOptions, DirectoryEntry, EntryComparison, FileStat, FileSystem, FsOptions, MkdirOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, WriteFileOptions } from "../../contracts/filesystem.js";
3
+ import type { ByteSource } from "../../contracts/io.js";
4
+ import type { S3Transport } from "./transport.js";
5
+ export interface S3FileSystemOptions {
6
+ readonly transport: S3Transport;
7
+ readonly bucket: string;
8
+ readonly prefix?: string;
9
+ readonly compareEntry?: (this: FileSystem, ...args: Parameters<NonNullable<FileSystem["compareEntry"]>>) => ReturnType<NonNullable<FileSystem["compareEntry"]>>;
10
+ readonly readOnly?: boolean;
11
+ readonly allowNonAtomicRename?: boolean;
12
+ readonly pageSize?: number;
13
+ readonly maxReadBytes?: number;
14
+ readonly maxStreamBytes?: number;
15
+ readonly maxListEntries?: number;
16
+ }
17
+ export declare class S3RenameError extends FsError {
18
+ readonly phase: "copy" | "delete";
19
+ readonly copiedKeys: readonly string[];
20
+ readonly deletedKeys: readonly string[];
21
+ constructor(source: string, destination: string, phase: "copy" | "delete", copied: string[], deleted: string[], cause: FsError);
22
+ }
23
+ export declare class S3FileSystem implements FileSystem {
24
+ readonly capabilities: Readonly<{
25
+ readOnly: boolean;
26
+ symlinks: false;
27
+ hardlinks: false;
28
+ permissions: false;
29
+ timestamps: boolean;
30
+ atomicRename: false;
31
+ snapshotRmdir: true;
32
+ streamingRead: boolean;
33
+ streamingWrite: boolean;
34
+ }>;
35
+ readonly readStream?: (path: string, options?: ReadStreamOptions) => ByteSource;
36
+ readonly writeStream?: (path: string, source: ByteSource, options?: WriteFileOptions) => Promise<void>;
37
+ private readonly transport;
38
+ private readonly bucket;
39
+ private readonly prefix;
40
+ private readonly allowRename;
41
+ private readonly pageSize;
42
+ private readonly maxReadBytes;
43
+ private readonly maxStreamBytes;
44
+ private readonly maxListEntries;
45
+ constructor(options: S3FileSystemOptions);
46
+ compareEntry(path: string, peer: FileSystem, peerPath: string, options?: FsOptions): Promise<EntryComparison>;
47
+ private path;
48
+ private key;
49
+ private directoryKey;
50
+ private checkAbort;
51
+ private requestOptions;
52
+ private call;
53
+ private writable;
54
+ private unsupported;
55
+ private head;
56
+ private makeStat;
57
+ private page;
58
+ private validateKey;
59
+ private validateObject;
60
+ private pages;
61
+ private inspect;
62
+ private lookup;
63
+ private requireDirectorySuffix;
64
+ stat(input: string, options?: FsOptions): Promise<FileStat>;
65
+ lstat(path: string, options?: FsOptions): Promise<FileStat>;
66
+ private body;
67
+ private get;
68
+ readFile(input: string, options?: ReadFileOptions): Promise<Uint8Array>;
69
+ private etag;
70
+ writeFile(input: string, data: Uint8Array, options?: WriteFileOptions): Promise<void>;
71
+ private writeMetadata;
72
+ appendFile(path: string, data: Uint8Array, options?: AppendFileOptions): Promise<void>;
73
+ mkdir(input: string, options?: MkdirOptions): Promise<void>;
74
+ readdir(input: string, options?: FsOptions): Promise<DirectoryEntry[]>;
75
+ private tree;
76
+ rmdir(input: string, options?: FsOptions): Promise<void>;
77
+ rm(input: string, options?: RemoveOptions): Promise<void>;
78
+ private copy;
79
+ copyFile(sourceInput: string, destinationInput: string, options?: CopyFileOptions): Promise<void>;
80
+ rename(sourceInput: string, destinationInput: string, options?: FsOptions): Promise<void>;
81
+ private copyWithPut;
82
+ realpath(input: string, options?: FsOptions): Promise<string>;
83
+ access(path: string, mode?: number, options?: FsOptions): Promise<void>;
84
+ readlink(path: string): Promise<string>;
85
+ symlink(_target: string, path: string): Promise<void>;
86
+ link(_existingPath: string, path: string): Promise<void>;
87
+ chmod(path: string, _mode: number): Promise<void>;
88
+ utimes(input: string, atimeMs: number, mtimeMs: number, options?: FsOptions): Promise<void>;
89
+ truncate(input: string, length?: number, options?: FsOptions): Promise<void>;
90
+ private streamRead;
91
+ private dispose;
92
+ private streamWrite;
93
+ }