@poe-platform/safe-fs 0.1.721 → 0.1.723

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 (58) hide show
  1. package/README.md +67 -21
  2. package/dist/safe-fs/bridge/filesystem.d.ts +4 -0
  3. package/dist/safe-fs/bridge/filesystem.js +49 -10
  4. package/dist/safe-fs/bridge/types.d.ts +6 -0
  5. package/dist/safe-fs/contracts/descriptor.d.ts +5 -0
  6. package/dist/safe-fs/contracts/filesystem.d.ts +15 -0
  7. package/dist/safe-fs/contracts/io.d.ts +3 -1
  8. package/dist/safe-fs/contracts/io.js +48 -16
  9. package/dist/safe-fs/core.d.ts +2 -1
  10. package/dist/safe-fs/core.js +1 -1
  11. package/dist/safe-fs/fs/capabilities.js +6 -3
  12. package/dist/safe-fs/fs/descriptor.js +9 -2
  13. package/dist/safe-fs/fs/devices/index.d.ts +1 -0
  14. package/dist/safe-fs/fs/devices/index.js +24 -6
  15. package/dist/safe-fs/fs/memory/index.d.ts +2 -0
  16. package/dist/safe-fs/fs/memory/index.js +95 -4
  17. package/dist/safe-fs/fs/memory/limits.js +6 -4
  18. package/dist/safe-fs/fs/mount/index.d.ts +4 -0
  19. package/dist/safe-fs/fs/mount/index.js +76 -12
  20. package/dist/safe-fs/fs/object-publication/index.d.ts +4 -0
  21. package/dist/safe-fs/fs/object-publication/index.js +9 -8
  22. package/dist/safe-fs/fs/overlay/index.d.ts +4 -2
  23. package/dist/safe-fs/fs/overlay/index.js +92 -36
  24. package/dist/safe-fs/fs/quota/index.js +83 -23
  25. package/dist/safe-fs/fs/readonly/index.js +5 -3
  26. package/dist/safe-fs/fs/real/index.d.ts +21 -1
  27. package/dist/safe-fs/fs/real/index.js +266 -22
  28. package/dist/safe-fs/fs/s3/filesystem.d.ts +8 -0
  29. package/dist/safe-fs/fs/s3/filesystem.js +59 -12
  30. package/dist/safe-fs/fs/s3/http/request.d.ts +1 -1
  31. package/dist/safe-fs/fs/s3/http/request.js +1 -1
  32. package/dist/safe-fs/fs/s3/http/transport.js +8 -6
  33. package/dist/safe-fs/fs/s3/http/xml.d.ts +4 -1
  34. package/dist/safe-fs/fs/s3/http/xml.js +6 -2
  35. package/dist/safe-fs/fs/s3/manifest-admission.d.ts +8 -0
  36. package/dist/safe-fs/fs/s3/manifest-admission.js +171 -0
  37. package/dist/safe-fs/fs/s3/namespace.js +13 -10
  38. package/dist/safe-fs/fs/scoped.d.ts +1 -0
  39. package/dist/safe-fs/fs/scoped.js +54 -35
  40. package/dist/safe-fs/fs/webdav/webdav.d.ts +4 -1
  41. package/dist/safe-fs/fs/webdav/webdav.js +101 -23
  42. package/dist/safe-fs/node/filesystem.d.ts +8 -1
  43. package/dist/safe-fs/node/filesystem.js +7 -1
  44. package/dist/safe-fs/node/host.d.ts +3 -0
  45. package/dist/safe-fs/node/host.js +78 -0
  46. package/dist/safe-fs/node/index.d.ts +2 -1
  47. package/dist/safe-fs/node/index.js +2 -1
  48. package/dist/safe-fs/platform/browser.d.ts +1 -0
  49. package/dist/safe-fs/platform/browser.js +1 -0
  50. package/dist/safe-fs/platform/node.d.ts +1 -0
  51. package/dist/safe-fs/platform/node.js +1 -0
  52. package/dist/safe-fs/python/emscripten.d.ts +1 -0
  53. package/dist/safe-fs/python/emscripten.js +5 -5
  54. package/dist/safe-fs/python/filesystem.js +5 -5
  55. package/dist/safe-fs/python/native.d.ts +1 -0
  56. package/dist/safe-fs/python/native.js +3 -2
  57. package/dist/safe-fs/xml.js +32 -21
  58. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  import { constants } from "node:fs";
2
+ import * as immediate from "node:fs";
2
3
  import * as native from "node:fs/promises";
3
- import { isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { dirname, basename, isAbsolute, join, relative, resolve, sep } from "node:path";
4
5
  import { nativeAllocatedBytes } from "./allocation.js";
5
6
  import { openFileDescriptor } from "../descriptor.js";
6
7
  import { finishCleanup } from "../../contracts/cleanup.js";
@@ -17,19 +18,24 @@ function fileType(stats) {
17
18
  throw new FsError("ENOTSUP", { message: "special filesystem nodes are not supported" });
18
19
  }
19
20
  function fileStat(stats) {
20
- const allocatedBytes = nativeAllocatedBytes(stats.blocks, process.platform);
21
- const preferredIoBlockSize = stats.blksize;
22
- return {
23
- type: fileType(stats), size: stats.size, mode: stats.mode,
21
+ const allocatedBytes = nativeAllocatedBytes(typeof stats.size === "bigint" && typeof stats.blocks === "bigint" ? Number(stats.blocks) : stats.blocks, process.platform);
22
+ const blockSize = typeof stats.size === "bigint" && typeof stats.blksize === "bigint" ? Number(stats.blksize) : stats.blksize;
23
+ const dev = Number(stats.dev), ino = Number(stats.ino);
24
+ const snapshot = {
25
+ type: fileType(stats), size: Number(stats.size), mode: Number(stats.mode),
24
26
  ...(allocatedBytes === undefined ? {} : { allocatedBytes }),
25
- ...(Number.isSafeInteger(stats.blksize) && stats.blksize > 0 ? { ioBlockSize: stats.blksize } : {}),
26
- ...(Number.isSafeInteger(preferredIoBlockSize) && preferredIoBlockSize > 0 ? { preferredIoBlockSize } : {}),
27
- atimeMs: stats.atimeMs, mtimeMs: stats.mtimeMs, ctimeMs: stats.ctimeMs,
28
- birthtimeMs: stats.birthtimeMs, ino: stats.ino, dev: stats.dev,
29
- ...(Number.isSafeInteger(stats.dev) && stats.dev >= 0 && Number.isSafeInteger(stats.ino) && stats.ino >= 0
27
+ ...(typeof blockSize === "number" && Number.isSafeInteger(blockSize) && blockSize > 0 ? { ioBlockSize: blockSize, preferredIoBlockSize: blockSize } : {}),
28
+ atimeMs: "atimeNs" in stats ? Number(stats.atimeNs / 1000000n) + Number(stats.atimeNs % 1000000n) / 1e6 : stats.atimeMs,
29
+ mtimeMs: "mtimeNs" in stats ? Number(stats.mtimeNs / 1000000n) + Number(stats.mtimeNs % 1000000n) / 1e6 : stats.mtimeMs,
30
+ ctimeMs: "ctimeNs" in stats ? Number(stats.ctimeNs / 1000000n) + Number(stats.ctimeNs % 1000000n) / 1e6 : stats.ctimeMs,
31
+ birthtimeMs: "birthtimeNs" in stats ? Number(stats.birthtimeNs / 1000000n) + Number(stats.birthtimeNs % 1000000n) / 1e6 : stats.birthtimeMs, ino, dev,
32
+ ...(Number.isSafeInteger(dev) && dev >= 0 && Number.isSafeInteger(ino) && ino >= 0
30
33
  ? { identityScope: Symbol.for("virtual-bash.fs.native") } : {}),
31
- nlink: stats.nlink, uid: stats.uid, gid: stats.gid,
34
+ nlink: Number(stats.nlink), uid: Number(stats.uid), gid: Number(stats.gid),
32
35
  };
36
+ if ("ctimeNs" in stats && !stats.isDirectory())
37
+ Object.defineProperty(snapshot, "opaqueVersion", { value: `${stats.ctimeNs}:${stats.mtimeNs}`, enumerable: true });
38
+ return snapshot;
33
39
  }
34
40
  function integer(value, minimum = 0) {
35
41
  if (!Number.isSafeInteger(value) || value < minimum) {
@@ -92,9 +98,10 @@ export class RealFileSystem {
92
98
  rename: true, atomicRenameNoReplace: false, copy: true, exclusiveCopy: true, readlink: true, truncate: true,
93
99
  streamingAppend: true, randomAccessWrite: true,
94
100
  readOnly: false, symlinks: true, hardlinks: true, permissions: true,
95
- timestamps: true, atomicRename: true, streamingRead: true, streamingWrite: true, retainedRead: true, retainedResize: true,
101
+ timestamps: true, atomicRename: true, streamingRead: true, streamingWrite: true, retainedRead: true, retainedResize: true, trustedOwnedStaging: true,
96
102
  });
97
103
  configuredRoot;
104
+ renameNoReplace;
98
105
  rootPromise;
99
106
  constructor(options) {
100
107
  const root = typeof options === "string" ? options : options.root;
@@ -106,6 +113,12 @@ export class RealFileSystem {
106
113
  throw new FsError("ENOTSUP", { syscall: "root", message: "this backend requires a POSIX host" });
107
114
  }
108
115
  this.configuredRoot = root;
116
+ this.renameNoReplace = typeof options === "string" ? undefined : options.renameNoReplace;
117
+ if (this.renameNoReplace !== undefined) {
118
+ if (typeof this.renameNoReplace !== "function")
119
+ throw new FsError("EINVAL", { syscall: "root", message: "renameNoReplace must be a function" });
120
+ this.capabilities = Object.freeze({ ...this.capabilities, atomicRenameNoReplace: true });
121
+ }
109
122
  }
110
123
  async root(options = {}) {
111
124
  options.signal?.throwIfAborted();
@@ -253,14 +266,223 @@ export class RealFileSystem {
253
266
  if (resolve(path) === root)
254
267
  throw new FsError("EBUSY", { message: "the filesystem root cannot be removed or replaced" });
255
268
  }
269
+ // These critical sections contain no await. They serialize with all JavaScript
270
+ // callers, under this adapter's existing trusted-host (externally isolated tree)
271
+ // boundary. They deliberately do not advertise the stronger atomic capabilities.
272
+ stagingSnapshot(path) {
273
+ try {
274
+ return fileStat(immediate.lstatSync(path, { bigint: true }));
275
+ }
276
+ catch (error) {
277
+ if (nativeError(error).code === "ENOENT")
278
+ return null;
279
+ throw error;
280
+ }
281
+ }
282
+ expectStaging(path, expected, identityOnly = false) {
283
+ if (expected && (expected.identityScope !== Symbol.for("virtual-bash.fs.native")
284
+ || expected.dev === undefined || expected.ino === undefined || !identityOnly && expected.opaqueVersion === undefined))
285
+ throw new FsError("ENOTSUP");
286
+ const current = this.stagingSnapshot(path);
287
+ if (expected === null ? current !== null : !current || current.dev !== expected.dev || current.ino !== expected.ino || current.type !== expected.type
288
+ || !identityOnly && (current.opaqueVersion !== expected.opaqueVersion || current.size !== expected.size || current.mode !== expected.mode || current.nlink !== expected.nlink))
289
+ throw new FsError("EAGAIN");
290
+ if (immediate.realpathSync(dirname(path)) !== dirname(path))
291
+ throw new FsError("EAGAIN");
292
+ return current;
293
+ }
294
+ stagingMetadata(options) {
295
+ if (options.mode !== undefined)
296
+ integer(options.mode);
297
+ for (const time of [options.atimeMs, options.mtimeMs])
298
+ if (time !== undefined && (!Number.isFinite(time) || Math.abs(time) > 8.64e15))
299
+ throw new FsError("EINVAL");
300
+ }
301
+ async createStagedFile(directoryPath, name, content, options) {
302
+ // Unlike operation(), this must return its receipt after commit even if the
303
+ // signal is aborted before the promise settles.
304
+ options.signal?.throwIfAborted();
305
+ try {
306
+ if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\0"))
307
+ throw new FsError("EINVAL");
308
+ this.stagingMetadata(options);
309
+ if (content.type === "file" && !(content.data instanceof Uint8Array))
310
+ throw new FsError("EINVAL");
311
+ const directory = await this.path(directoryPath, { ...options, followFinal: false, missing: "final" });
312
+ const root = await this.root(options);
313
+ const target = content.type === "symlink" ? (validatePath(content.target), isAbsolute(content.target) ? `${root}/${content.target.slice(1)}` : content.target) : undefined;
314
+ if (content.type === "symlink")
315
+ await this.walk(root, (isAbsolute(content.target) ? content.target.slice(1).split("/") : relative(root, directory).split("/").concat(content.target.split("/"))).map(name => ({ name, fromLink: true })), { ...options, checkTarget: true });
316
+ options.signal?.throwIfAborted();
317
+ this.protectTerminal(directoryPath);
318
+ this.expectStaging(dirname(directory), options.parent, true);
319
+ immediate.mkdirSync(directory, { mode: 0o700 });
320
+ const file = join(directory, name);
321
+ try {
322
+ if (content.type === "file") {
323
+ const fd = immediate.openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, options.mode ?? 0o666);
324
+ try {
325
+ immediate.writeFileSync(fd, content.data);
326
+ if (options.mode !== undefined)
327
+ immediate.fchmodSync(fd, options.mode);
328
+ if (options.atimeMs !== undefined || options.mtimeMs !== undefined) {
329
+ const stat = immediate.fstatSync(fd);
330
+ immediate.futimesSync(fd, (options.atimeMs ?? stat.atimeMs) / 1000, (options.mtimeMs ?? stat.mtimeMs) / 1000);
331
+ }
332
+ }
333
+ finally {
334
+ immediate.closeSync(fd);
335
+ }
336
+ }
337
+ else
338
+ immediate.symlinkSync(target, file);
339
+ const virtualDirectory = `/${relative(root, directory)}`;
340
+ const receipt = (path, host) => Object.freeze({ path, stat: Object.freeze(this.stagingSnapshot(host)) });
341
+ return Object.freeze({ parent: receipt(dirname(virtualDirectory), dirname(directory)), directory: receipt(virtualDirectory, directory), file: receipt(`${virtualDirectory}/${name}`, file) });
342
+ }
343
+ catch (error) {
344
+ // Only entries just acquired by this synchronous section can be removed.
345
+ if (immediate.existsSync(file))
346
+ immediate.unlinkSync(file);
347
+ immediate.rmdirSync(directory);
348
+ throw error;
349
+ }
350
+ }
351
+ catch (error) {
352
+ options.signal?.throwIfAborted();
353
+ throw new FsError(nativeError(error).code, { syscall: "createStagedFile", path: directoryPath });
354
+ }
355
+ }
356
+ async stagingPaths(staging, options) {
357
+ const directory = await this.path(staging.directory.path, { ...options, followFinal: false });
358
+ const file = await this.path(staging.file.path, { ...options, followFinal: false, missing: "final" });
359
+ const parent = await this.path(staging.parent.path, { ...options, followFinal: false });
360
+ if (dirname(directory) !== parent || dirname(file) !== directory)
361
+ throw new FsError("EAGAIN");
362
+ return { directory, file, parent };
363
+ }
364
+ checkStaging(staging, paths) {
365
+ this.expectStaging(paths.parent, staging.parent.stat, true);
366
+ const directory = this.expectStaging(paths.directory, staging.directory.stat, true);
367
+ if (directory?.type !== "directory" || (directory.mode & 0o777) !== 0o700)
368
+ throw new FsError("EAGAIN");
369
+ }
370
+ async publishStagedFile(staging, destination, options) {
371
+ return this.operation("publishStagedFile", staging.file.path, options, async () => {
372
+ const paths = await this.stagingPaths(staging, options);
373
+ const target = await this.path(destination, { ...options, followFinal: false, missing: "final" });
374
+ options.signal?.throwIfAborted();
375
+ this.protectTerminal(destination);
376
+ this.checkStaging(staging, paths);
377
+ this.expectStaging(paths.file, staging.file.stat);
378
+ this.expectStaging(dirname(target), options.parent, true);
379
+ const existing = this.expectStaging(target, options.destination);
380
+ if (existing && (existing.type !== "file" || existing.nlink !== 1))
381
+ throw new FsError("EAGAIN");
382
+ if (target === paths.file || target === paths.directory || dirname(target) === paths.directory)
383
+ throw new FsError("EINVAL");
384
+ immediate.renameSync(paths.file, target);
385
+ }, destination);
386
+ }
387
+ async removeStagedFile(staging, options = {}) {
388
+ return this.operation("removeStagedFile", staging.directory.path, options, async () => {
389
+ const paths = await this.stagingPaths(staging, options);
390
+ options.signal?.throwIfAborted();
391
+ this.checkStaging(staging, paths);
392
+ const file = this.stagingSnapshot(paths.file);
393
+ if (file)
394
+ this.expectStaging(paths.file, staging.file.stat);
395
+ const children = immediate.readdirSync(paths.directory);
396
+ if (children.length !== (file ? 1 : 0) || file && children[0] !== basename(paths.file))
397
+ throw new FsError("ENOTEMPTY");
398
+ if (file)
399
+ immediate.unlinkSync(paths.file);
400
+ immediate.rmdirSync(paths.directory);
401
+ });
402
+ }
403
+ async writeFileConditional(path, data, options) {
404
+ options.signal?.throwIfAborted();
405
+ try {
406
+ if (!(data instanceof Uint8Array))
407
+ throw new FsError("EINVAL");
408
+ this.stagingMetadata(options);
409
+ const target = await this.path(path, { ...options, followFinal: false, missing: "final" });
410
+ options.signal?.throwIfAborted();
411
+ this.expectStaging(dirname(target), options.parent, true);
412
+ const existing = this.expectStaging(target, options.expected);
413
+ if (existing && (existing.type !== "file" || existing.nlink !== 1))
414
+ throw new FsError("EAGAIN");
415
+ const fd = immediate.openSync(target, constants.O_WRONLY | constants.O_NOFOLLOW | (existing ? 0 : constants.O_CREAT | constants.O_EXCL), options.mode ?? 0o666);
416
+ try {
417
+ if (!options.append)
418
+ immediate.ftruncateSync(fd, 0);
419
+ let offset = options.append ? Number(immediate.fstatSync(fd).size) : 0;
420
+ let written = 0;
421
+ while (written < data.length) {
422
+ const count = immediate.writeSync(fd, data, written, data.length - written, offset);
423
+ if (count === 0)
424
+ throw new FsError("EIO");
425
+ written += count;
426
+ offset += count;
427
+ }
428
+ if (options.mode !== undefined)
429
+ immediate.fchmodSync(fd, options.mode);
430
+ return fileStat(immediate.fstatSync(fd, { bigint: true }));
431
+ }
432
+ finally {
433
+ immediate.closeSync(fd);
434
+ }
435
+ }
436
+ catch (error) {
437
+ options.signal?.throwIfAborted();
438
+ throw new FsError(nativeError(error).code, { syscall: "writeFileConditional", path });
439
+ }
440
+ }
441
+ async removeFileConditional(path, options) {
442
+ return this.operation("removeFileConditional", path, options, async () => {
443
+ const target = await this.path(path, { ...options, followFinal: false });
444
+ options.signal?.throwIfAborted();
445
+ this.expectStaging(dirname(target), options.parent, true);
446
+ const file = this.expectStaging(target, options.expected);
447
+ if (file?.type !== "file" || file.nlink !== 1)
448
+ throw new FsError("EAGAIN");
449
+ immediate.unlinkSync(target);
450
+ });
451
+ }
452
+ async prepareDirectory(path, options) {
453
+ options.signal?.throwIfAborted();
454
+ try {
455
+ this.stagingMetadata(options);
456
+ const target = await this.path(path, { ...options, followFinal: false, missing: "final" });
457
+ options.signal?.throwIfAborted();
458
+ this.protectTerminal(path);
459
+ this.expectStaging(dirname(target), options.parent, true);
460
+ const existing = this.expectStaging(target, options.expected, true);
461
+ if (existing && existing.type !== "directory")
462
+ throw new FsError("EAGAIN");
463
+ if (!existing)
464
+ immediate.mkdirSync(target, { mode: options.mode ?? 0o777 });
465
+ if (options.mode !== undefined)
466
+ immediate.chmodSync(target, options.mode);
467
+ if (options.atimeMs !== undefined || options.mtimeMs !== undefined) {
468
+ const stat = immediate.statSync(target);
469
+ immediate.utimesSync(target, (options.atimeMs ?? stat.atimeMs) / 1000, (options.mtimeMs ?? stat.mtimeMs) / 1000);
470
+ }
471
+ return this.stagingSnapshot(target);
472
+ }
473
+ catch (error) {
474
+ options.signal?.throwIfAborted();
475
+ throw new FsError(nativeError(error).code, { syscall: "prepareDirectory", path });
476
+ }
477
+ }
256
478
  open(path, options) {
257
479
  return openFileDescriptor(path, options, {
258
- positionedRead: true, positionedWrite: true, truncate: true, synchronization: "storage",
480
+ noFollow: true, positionedRead: true, positionedWrite: true, truncate: true, synchronization: "storage",
259
481
  }, async (admitted) => {
260
482
  let handle;
261
483
  try {
262
484
  const target = await this.path(path, {
263
- ...admitted, followFinal: admitted.creation !== "exclusive",
485
+ ...admitted, followFinal: !admitted.noFollow && admitted.creation !== "exclusive",
264
486
  ...(admitted.creation === "never" ? {} : { missing: "final" }),
265
487
  });
266
488
  let flags = (admitted.access === "read" ? constants.O_RDONLY : admitted.access === "write" ? constants.O_WRONLY : constants.O_RDWR)
@@ -271,7 +493,22 @@ export class RealFileSystem {
271
493
  flags |= constants.O_EXCL;
272
494
  if (admitted.append)
273
495
  flags |= constants.O_APPEND;
274
- handle = await native.open(target, flags, admitted.mode);
496
+ let created = false;
497
+ if (admitted.exactMode && admitted.creation !== "never") {
498
+ try {
499
+ handle = await native.open(target, flags | constants.O_EXCL, admitted.mode);
500
+ created = true;
501
+ }
502
+ catch (error) {
503
+ if (admitted.creation === "exclusive" || nativeError(error).code !== "EEXIST")
504
+ throw error;
505
+ handle = await native.open(target, flags & ~constants.O_CREAT, admitted.mode);
506
+ }
507
+ }
508
+ else
509
+ handle = await native.open(target, flags, admitted.mode);
510
+ if (created)
511
+ await handle.chmod(admitted.mode);
275
512
  admitted.signal?.throwIfAborted();
276
513
  const stat = await handle.stat();
277
514
  if (stat.isDirectory())
@@ -285,7 +522,7 @@ export class RealFileSystem {
285
522
  handle = undefined;
286
523
  return {
287
524
  resource,
288
- stat: (retained, forwarded) => this.operation("fstat", path, forwarded, async () => fileStat(await retained.handle.stat())),
525
+ stat: (retained, forwarded) => this.operation("fstat", path, forwarded, async () => fileStat(await retained.handle.stat({ bigint: true }))),
289
526
  read: (retained, buffer, position, forwarded) => this.operation("read", path, forwarded, async () => (await retained.handle.read(buffer, 0, buffer.byteLength, position)).bytesRead),
290
527
  write: (retained, buffer, position, forwarded) => this.operation("write", path, forwarded, async () => (await retained.handle.write(buffer, 0, buffer.byteLength, position)).bytesWritten),
291
528
  truncate: (retained, length, forwarded) => this.operation("ftruncate", path, forwarded, () => retained.handle.truncate(length)),
@@ -335,14 +572,14 @@ export class RealFileSystem {
335
572
  return this.operation("stat", path, options, async () => {
336
573
  const target = await this.path(path, options);
337
574
  options.signal?.throwIfAborted();
338
- return fileStat(await native.stat(target));
575
+ return fileStat(await native.stat(target, { bigint: true }));
339
576
  });
340
577
  }
341
578
  async lstat(path, options = {}) {
342
579
  return this.operation("lstat", path, options, async () => {
343
580
  const target = await this.path(path, { ...options, followFinal: false });
344
581
  options.signal?.throwIfAborted();
345
- return fileStat(await native.lstat(target));
582
+ return fileStat(await native.lstat(target, { bigint: true }));
346
583
  });
347
584
  }
348
585
  async readdir(path, options = {}) {
@@ -381,6 +618,8 @@ export class RealFileSystem {
381
618
  return this.operation("mkdir", path, options, async () => {
382
619
  if (options.mode !== undefined)
383
620
  integer(options.mode);
621
+ if (options.exactMode && options.recursive)
622
+ throw new FsError("ENOTSUP");
384
623
  const target = await this.path(path, {
385
624
  ...options,
386
625
  missing: "final", followFinal: !!options.recursive, deferTrailingSeparator: true,
@@ -388,6 +627,8 @@ export class RealFileSystem {
388
627
  });
389
628
  options.signal?.throwIfAborted();
390
629
  await native.mkdir(target, { recursive: options.recursive ?? false, ...(options.mode === undefined ? {} : { mode: options.mode }) });
630
+ if (options.exactMode)
631
+ await native.chmod(target, options.mode ?? 0o777);
391
632
  });
392
633
  }
393
634
  async rmdir(path, options = {}) {
@@ -427,7 +668,7 @@ export class RealFileSystem {
427
668
  }
428
669
  async rename(source, destination, options = {}) {
429
670
  return this.operation("rename", source, options, async () => {
430
- if (options.noReplace)
671
+ if (options.noReplace && !this.renameNoReplace)
431
672
  throw new FsError("ENOTSUP", { syscall: "rename", path: source, dest: destination });
432
673
  const from = await this.path(source, { ...options, followFinal: false });
433
674
  const to = await this.path(destination, { ...options, followFinal: false, missing: "final" });
@@ -437,7 +678,10 @@ export class RealFileSystem {
437
678
  this.protectRoot(from, root);
438
679
  this.protectRoot(to, root);
439
680
  options.signal?.throwIfAborted();
440
- await native.rename(from, to);
681
+ if (options.noReplace)
682
+ await this.renameNoReplace(from, to, options.signal ? { signal: options.signal } : {});
683
+ else
684
+ await native.rename(from, to);
441
685
  }, destination);
442
686
  }
443
687
  async copyFile(source, destination, options = {}) {
@@ -641,7 +885,7 @@ export class RealFileSystem {
641
885
  };
642
886
  return {
643
887
  stat(options = {}) {
644
- return perform(options, "fstat", async () => fileStat(await handle.stat()));
888
+ return perform(options, "fstat", async () => fileStat(await handle.stat({ bigint: true })));
645
889
  },
646
890
  async truncate(length, options = {}) {
647
891
  assertOpen(options, "ftruncate");
@@ -762,7 +1006,7 @@ export class RealFileSystem {
762
1006
  };
763
1007
  return {
764
1008
  stat(options = {}) {
765
- return perform(options, "fstat", async () => fileStat(await handle.stat()));
1009
+ return perform(options, "fstat", async () => fileStat(await handle.stat({ bigint: true })));
766
1010
  },
767
1011
  async read(position, maxBytes, options = {}) {
768
1012
  assertOpen(options, "read");
@@ -14,6 +14,12 @@ export interface S3FileSystemOptions {
14
14
  readonly maxReadBytes?: number;
15
15
  readonly maxStreamBytes?: number;
16
16
  readonly maxListEntries?: number;
17
+ /** Aggregate budgets for each rm operation, including ancestor inspection. */
18
+ readonly removalLimits?: {
19
+ readonly maxRequests?: number;
20
+ readonly maxListEntries?: number;
21
+ readonly maxDeleteObjects?: number;
22
+ };
17
23
  }
18
24
  export declare class S3RenameError extends FsError {
19
25
  readonly phase: "copy" | "delete";
@@ -68,6 +74,8 @@ export declare class S3FileSystem implements FileSystem {
68
74
  private readonly maxReadBytes;
69
75
  private readonly maxStreamBytes;
70
76
  private readonly maxListEntries;
77
+ private readonly removalLimits;
78
+ private readonly removalBudgets;
71
79
  constructor(options: S3FileSystemOptions);
72
80
  compareEntry(path: string, peer: FileSystem, peerPath: string, options?: FsOptions): Promise<EntryComparison>;
73
81
  private path;
@@ -82,6 +82,8 @@ export class S3FileSystem {
82
82
  maxReadBytes;
83
83
  maxStreamBytes;
84
84
  maxListEntries;
85
+ removalLimits;
86
+ removalBudgets = new WeakMap();
85
87
  constructor(options) {
86
88
  if (!options?.transport || ["headObject", "getObject", "putObject", "deleteObject", "copyObject", "listObjectsV2"]
87
89
  .some((method) => typeof options.transport[method] !== "function")) {
@@ -105,9 +107,14 @@ export class S3FileSystem {
105
107
  this.bucket = options.bucket;
106
108
  this.allowRename = options.allowNonAtomicRename ?? true;
107
109
  this.pageSize = validateLimit(options.pageSize ?? 1000, "pageSize", 1, 1000);
108
- this.maxReadBytes = validateLimit(options.maxReadBytes ?? 64 * 1024 * 1024, "maxReadBytes", 0);
109
- this.maxStreamBytes = validateLimit(options.maxStreamBytes ?? 5_000_000_000, "maxStreamBytes", 0, 5_000_000_000);
110
- this.maxListEntries = validateLimit(options.maxListEntries ?? 100_000, "maxListEntries", 1);
110
+ this.maxReadBytes = options.maxReadBytes === undefined ? Infinity : validateLimit(options.maxReadBytes, "maxReadBytes", 0);
111
+ this.maxStreamBytes = options.maxStreamBytes === undefined ? Infinity : validateLimit(options.maxStreamBytes, "maxStreamBytes", 0);
112
+ this.maxListEntries = options.maxListEntries === undefined ? Infinity : validateLimit(options.maxListEntries, "maxListEntries", 1);
113
+ this.removalLimits = {
114
+ maxRequests: validateLimit(options.removalLimits?.maxRequests ?? 32, "removalLimits.maxRequests", 1),
115
+ maxListEntries: validateLimit(options.removalLimits?.maxListEntries ?? 32, "removalLimits.maxListEntries", 1),
116
+ maxDeleteObjects: validateLimit(options.removalLimits?.maxDeleteObjects ?? 16, "removalLimits.maxDeleteObjects", 1),
117
+ };
111
118
  this.capabilities = Object.freeze({
112
119
  open: false,
113
120
  read: true, stat: true, readdir: true, realpath: true, access: true,
@@ -182,6 +189,10 @@ export class S3FileSystem {
182
189
  }
183
190
  async call(syscall, path, options, action, precondition) {
184
191
  this.checkAbort(options, syscall, path);
192
+ const budget = this.removalBudgets.get(options);
193
+ if (budget && ++budget.requests > this.removalLimits.maxRequests) {
194
+ fail("EFBIG", "rm", path, "S3 removal request budget exceeded");
195
+ }
185
196
  let onAbort;
186
197
  try {
187
198
  const pending = action();
@@ -254,11 +265,19 @@ export class S3FileSystem {
254
265
  mtimeMs: timestamp("virtual-bash-mtime", modified), atimeMs: timestamp("virtual-bash-atime", 0), ctimeMs: modified };
255
266
  }
256
267
  async page(prefix, path, options, delimiter, token, maxKeys = this.pageSize) {
268
+ const budget = this.removalBudgets.get(options);
269
+ if (budget)
270
+ maxKeys = Math.min(maxKeys, this.removalLimits.maxListEntries - budget.entries + 1);
257
271
  const result = await this.call("listObjectsV2", path, options, () => this.transport.listObjectsV2({
258
272
  Bucket: this.bucket, Prefix: prefix, MaxKeys: maxKeys,
259
273
  ...(delimiter === undefined ? {} : { Delimiter: delimiter }),
260
274
  ...(token === undefined ? {} : { ContinuationToken: token }),
261
275
  }, this.requestOptions(options)));
276
+ if (budget) {
277
+ budget.entries += (result.Contents?.length ?? 0) + (result.CommonPrefixes?.length ?? 0);
278
+ if (budget.entries > this.removalLimits.maxListEntries)
279
+ fail("EFBIG", "rm", path, "S3 removal listing budget exceeded");
280
+ }
262
281
  for (const item of result.Contents ?? []) {
263
282
  if (typeof item.Key !== "string" || !item.Key.startsWith(prefix))
264
283
  fail("EIO", "listObjectsV2", path, "transport returned an object outside the requested prefix");
@@ -376,8 +395,11 @@ export class S3FileSystem {
376
395
  return this.stat(path, options);
377
396
  }
378
397
  async body(output, path, options) {
379
- const limit = Math.min(this.maxReadBytes, validateLimit(options.maxBytes ?? this.maxReadBytes, "maxBytes", 0));
398
+ const limit = Math.min(this.maxReadBytes, options.maxBytes === undefined ? this.maxReadBytes : validateLimit(options.maxBytes, "maxBytes", 0));
399
+ let iterator;
380
400
  return this.call("readFile", path, options, async () => {
401
+ if (output.Body && Symbol.asyncIterator in output.Body)
402
+ iterator = output.Body[Symbol.asyncIterator]();
381
403
  if (output.ContentLength !== undefined && output.ContentLength > limit)
382
404
  fail("EFBIG", "readFile", path);
383
405
  const body = output.Body;
@@ -390,7 +412,7 @@ export class S3FileSystem {
390
412
  bytes = new Uint8Array(body);
391
413
  }
392
414
  else if (Symbol.asyncIterator in body) {
393
- bytes = await collectBytes(body, { maxBytes: limit, ...(options.signal ? { signal: options.signal } : {}) });
415
+ bytes = await collectBytes({ [Symbol.asyncIterator]: () => iterator }, { ...(limit === Infinity ? {} : { maxBytes: limit }), ...(options.signal ? { signal: options.signal } : {}) });
394
416
  }
395
417
  else if ("transformToByteArray" in body) {
396
418
  const converted = await body.transformToByteArray();
@@ -407,7 +429,8 @@ export class S3FileSystem {
407
429
  fail("EIO", "readFile", path, "response body length does not match ContentLength");
408
430
  return bytes;
409
431
  }).catch((error) => {
410
- this.dispose(output.Body);
432
+ this.dispose(iterator);
433
+ this.dispose(output.Body, iterator === undefined);
411
434
  throw error;
412
435
  });
413
436
  }
@@ -416,13 +439,17 @@ export class S3FileSystem {
416
439
  }
417
440
  async readFile(input, options = {}) {
418
441
  const path = this.path(input);
419
- validateLimit(options.maxBytes ?? this.maxReadBytes, "maxBytes", 0);
442
+ if (options.maxBytes !== undefined)
443
+ validateLimit(options.maxBytes, "maxBytes", 0);
420
444
  const info = await this.stat(input, options);
421
445
  if (info.type === "directory")
422
446
  fail("EISDIR", "readFile", path);
423
447
  if (info.size > Math.min(this.maxReadBytes, options.maxBytes ?? this.maxReadBytes))
424
448
  fail("EFBIG", "readFile", path);
425
- return this.body(await this.get(path, options), path, options);
449
+ const output = this.capabilities.streamingRead
450
+ ? await this.call("getObject", path, options, () => this.transport.getObjectStream({ Bucket: this.bucket, Key: this.key(path) }, this.requestOptions(options)))
451
+ : await this.get(path, options);
452
+ return this.body(output, path, options);
426
453
  }
427
454
  etag(metadata, path) {
428
455
  if (!metadata?.ETag)
@@ -594,6 +621,10 @@ export class S3FileSystem {
594
621
  }
595
622
  }
596
623
  async rm(input, options = {}) {
624
+ // A distinct options object keeps concurrent and nested operations isolated.
625
+ options = { ...options };
626
+ const budget = { requests: 0, entries: 0 };
627
+ this.removalBudgets.set(options, budget);
597
628
  const path = this.path(input);
598
629
  this.writable(path);
599
630
  if (path === "/")
@@ -613,9 +644,25 @@ export class S3FileSystem {
613
644
  return;
614
645
  fail("ENOENT", "rm", path);
615
646
  }
616
- const objects = info.stat.type === "directory" ? await this.tree(path, options) : [{ Key: this.key(path) }];
617
- if (info.stat.type === "directory" && !options.recursive && objects.some((item) => item.Key !== this.directoryKey(path)))
618
- fail("ENOTEMPTY", "rm", path);
647
+ let objects;
648
+ if (info.stat.type === "directory" && !options.recursive) {
649
+ const prefix = this.directoryKey(path);
650
+ objects = [];
651
+ for await (const page of this.pages(prefix, path, options, undefined, 2)) {
652
+ if (page.Contents?.some(item => item.Key !== prefix))
653
+ fail("ENOTEMPTY", "rm", path);
654
+ if (page.Contents?.length)
655
+ objects = [{ Key: prefix }];
656
+ }
657
+ }
658
+ else {
659
+ objects = info.stat.type === "directory" ? await this.tree(path, options) : [{ Key: this.key(path) }];
660
+ }
661
+ // Reserve the complete mutation phase before issuing any deletes.
662
+ if (objects.length > this.removalLimits.maxDeleteObjects
663
+ || budget.requests + objects.length > this.removalLimits.maxRequests) {
664
+ fail("EFBIG", "rm", path, "S3 removal delete or request budget exceeded; use bounded batches");
665
+ }
619
666
  for (const item of objects) {
620
667
  await this.call("deleteObject", path, options, () => this.transport.deleteObject({ Bucket: this.bucket, Key: item.Key }, this.requestOptions(options)));
621
668
  }
@@ -1008,7 +1055,7 @@ export class S3FileSystem {
1008
1055
  const limit = Math.min(this.maxReadBytes, this.maxStreamBytes);
1009
1056
  if (previous.length > limit)
1010
1057
  fail("EFBIG", "writeStream", path);
1011
- const bytes = await this.call("writeStream", path, options, () => collectBytes(source, { maxBytes: limit - previous.length, ...options }));
1058
+ const bytes = await this.call("writeStream", path, options, () => collectBytes(source, { ...(limit === Infinity ? {} : { maxBytes: limit - previous.length }), ...options }));
1012
1059
  const body = new Uint8Array(previous.length + bytes.length);
1013
1060
  body.set(previous);
1014
1061
  body.set(bytes, previous.length);
@@ -4,7 +4,7 @@ export interface RequestScope {
4
4
  readonly signal: AbortSignal;
5
5
  readonly finish: () => void;
6
6
  }
7
- export declare function scopeFor(signal: AbortSignal | undefined, timeout: number): RequestScope;
7
+ export declare function scopeFor(signal: AbortSignal | undefined, timeout: number | undefined): RequestScope;
8
8
  export declare function abortable<Value>(pending: Promise<Value>, signal: AbortSignal): Promise<Value>;
9
9
  export interface WireResponse {
10
10
  readonly message: IncomingMessage;
@@ -8,7 +8,7 @@ export function scopeFor(signal, timeout) {
8
8
  signal?.addEventListener("abort", abort, { once: true });
9
9
  if (signal?.aborted)
10
10
  abort();
11
- const timer = setTimeout(() => controller.abort(new S3ServiceError("RequestTimeout", 408)), timeout);
11
+ const timer = timeout === undefined ? undefined : setTimeout(() => controller.abort(new S3ServiceError("RequestTimeout", 408)), timeout);
12
12
  return {
13
13
  signal: controller.signal,
14
14
  finish: () => { clearTimeout(timer); signal?.removeEventListener("abort", abort); },
@@ -4,8 +4,10 @@ import { abortable, collect, limitedBody, scopeFor, sendRequest } from "./reques
4
4
  import { canonicalQuery, headerValue, invalid, signRequest, uriEncode } from "./signature.js";
5
5
  import { children, integer, malformed, parseXml, text, timestamp } from "./xml.js";
6
6
  const empty = new Uint8Array();
7
- function limit(value, fallback, name, maximum = 1024 * 1024 * 1024) {
8
- const result = value ?? fallback;
7
+ function limit(value, fallback, name, maximum = Number.MAX_SAFE_INTEGER) {
8
+ if (value === undefined)
9
+ return fallback;
10
+ const result = value;
9
11
  if (!Number.isSafeInteger(result) || result < 1 || result > maximum)
10
12
  invalid(`invalid ${name}`);
11
13
  return result;
@@ -161,10 +163,10 @@ export function createS3HttpTransport(options) {
161
163
  invalid("unsupported LIST URL encoding");
162
164
  if (addressing === "virtual-hosted" && (isIP(endpoint.hostname.replace(/^\[|\]$/g, "")) || endpoint.hostname === "localhost"))
163
165
  invalid("virtual-hosted addressing requires a DNS endpoint");
164
- const maxPut = limit(options.maxPutBytes, 64 * 1024 * 1024, "maxPutBytes");
165
- const maxGet = limit(options.maxGetBytes, 64 * 1024 * 1024, "maxGetBytes");
166
- const maxXml = limit(options.maxXmlBytes, 4 * 1024 * 1024, "maxXmlBytes", 16 * 1024 * 1024);
167
- const timeout = limit(options.requestTimeoutMs, 30_000, "requestTimeoutMs", 2_147_483_647);
166
+ const maxPut = limit(options.maxPutBytes, Infinity, "maxPutBytes");
167
+ const maxGet = limit(options.maxGetBytes, Infinity, "maxGetBytes");
168
+ const maxXml = limit(options.maxXmlBytes, Infinity, "maxXmlBytes");
169
+ const timeout = options.requestTimeoutMs === undefined ? undefined : limit(options.requestTimeoutMs, 0, "requestTimeoutMs", 2_147_483_647);
168
170
  const enabledCopy = boolean(options.enableCopy, true, "enableCopy");
169
171
  const verified = options.verifiedConditionalOperations;
170
172
  const conditionalPut = boolean(verified?.put, false, "verifiedConditionalOperations.put");
@@ -4,7 +4,10 @@ export interface XmlNode {
4
4
  text: string;
5
5
  }
6
6
  export declare function malformed(message?: string): never;
7
- export declare function parseXml(bytes: Uint8Array): XmlNode;
7
+ export declare function parseXml(bytes: Uint8Array, limits?: {
8
+ maxNodes?: number;
9
+ maxDepth?: number;
10
+ }): XmlNode;
8
11
  export declare function children(node: XmlNode, name: string): XmlNode[];
9
12
  export declare function text(node: XmlNode, name: string, required?: boolean): string | undefined;
10
13
  export declare function integer(value: string): number;