@poe-platform/safe-fs 0.1.727 → 0.1.729

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 +15 -8
  2. package/dist/safe-fs/contracts/errors.js +8 -1
  3. package/dist/safe-fs/contracts/filesystem.d.ts +68 -2
  4. package/dist/safe-fs/contracts/io.js +210 -28
  5. package/dist/safe-fs/contracts/virtual-path.d.ts +3 -1
  6. package/dist/safe-fs/contracts/virtual-path.js +20 -1
  7. package/dist/safe-fs/core.d.ts +1 -0
  8. package/dist/safe-fs/core.js +1 -0
  9. package/dist/safe-fs/fs/capabilities.d.ts +1 -1
  10. package/dist/safe-fs/fs/capabilities.js +59 -21
  11. package/dist/safe-fs/fs/conditional-chmod.d.ts +7 -0
  12. package/dist/safe-fs/fs/conditional-chmod.js +19 -0
  13. package/dist/safe-fs/fs/descriptor.js +51 -25
  14. package/dist/safe-fs/fs/devices/index.d.ts +7 -2
  15. package/dist/safe-fs/fs/devices/index.js +210 -29
  16. package/dist/safe-fs/fs/devices/path.d.ts +3 -1
  17. package/dist/safe-fs/fs/devices/path.js +70 -12
  18. package/dist/safe-fs/fs/memory/index.d.ts +70 -48
  19. package/dist/safe-fs/fs/memory/index.js +1595 -244
  20. package/dist/safe-fs/fs/memory/ledger.d.ts +8 -1
  21. package/dist/safe-fs/fs/memory/ledger.js +46 -6
  22. package/dist/safe-fs/fs/memory/missing-target.js +21 -1
  23. package/dist/safe-fs/fs/mount/index.d.ts +11 -3
  24. package/dist/safe-fs/fs/mount/index.js +326 -15
  25. package/dist/safe-fs/fs/object-publication/index.js +79 -3
  26. package/dist/safe-fs/fs/overlay/index.d.ts +2 -2
  27. package/dist/safe-fs/fs/overlay/index.js +27 -5
  28. package/dist/safe-fs/fs/overlay/memory-publication.d.ts +2 -1
  29. package/dist/safe-fs/fs/overlay/memory-publication.js +39 -2
  30. package/dist/safe-fs/fs/quota/index.js +5 -5
  31. package/dist/safe-fs/fs/readonly/index.d.ts +1 -0
  32. package/dist/safe-fs/fs/readonly/index.js +13 -0
  33. package/dist/safe-fs/fs/real/index.d.ts +6 -2
  34. package/dist/safe-fs/fs/real/index.js +78 -8
  35. package/dist/safe-fs/fs/s3/filesystem.d.ts +3 -0
  36. package/dist/safe-fs/fs/s3/filesystem.js +57 -25
  37. package/dist/safe-fs/fs/s3/http/transport.js +20 -3
  38. package/dist/safe-fs/fs/s3/mock.js +19 -2
  39. package/dist/safe-fs/fs/s3/namespace.js +8 -2
  40. package/dist/safe-fs/fs/s3/registry.js +2 -0
  41. package/dist/safe-fs/fs/scoped.d.ts +1 -0
  42. package/dist/safe-fs/fs/scoped.js +261 -74
  43. package/dist/safe-fs/fs/staging-ancestry.d.ts +8 -0
  44. package/dist/safe-fs/fs/staging-ancestry.js +115 -0
  45. package/dist/safe-fs/fs/staging-cleanup.d.ts +5 -0
  46. package/dist/safe-fs/fs/staging-cleanup.js +63 -0
  47. package/dist/safe-fs/fs/webdav/webdav.js +7 -5
  48. package/dist/safe-fs/platform/browser.d.ts +5 -0
  49. package/dist/safe-fs/platform/browser.js +17 -0
  50. package/dist/safe-fs/platform/node.d.ts +5 -0
  51. package/dist/safe-fs/platform/node.js +28 -0
  52. package/dist/safe-fs/platform/transport-budget.d.ts +7 -0
  53. package/dist/safe-fs/platform/transport-budget.js +4 -0
  54. package/dist/safe-fs/testing/object-io-metrics.d.ts +3 -0
  55. package/dist/safe-fs/testing/object-io-metrics.js +29 -0
  56. package/dist/safe-fs/testing/object-publication.d.ts +3 -1
  57. package/dist/safe-fs/testing/object-publication.js +2 -2
  58. package/package.json +1 -1
@@ -1,29 +1,37 @@
1
+ import { snapshotStagingCreation } from "../staging-cleanup.js";
1
2
  import { FsError, isFsError } from "../../contracts/errors.js";
2
3
  import { finishCleanup } from "../../contracts/cleanup.js";
3
4
  import { admitDirectoryEntries, directoryEntryLimit } from "../directory-admission.js";
4
5
  import { compareEntries, registerEntryAuthority, registerEntryView } from "../mount/comparison.js";
5
6
  import { deviceDirectory, lexicalDevicePath, nullPath, resolveDevicePath } from "./path.js";
7
+ import { tryResolveMemoryDevicePath } from "../memory/index.js";
6
8
  import { createDeviceYield, deviceReadStream, drainDeviceFile, drainDeviceInput } from "./stream.js";
7
9
  import { openRetainedReadFile, openRetainedResizeFile, retainedResizeCapabilities, ownedMutationCapabilities, requireOwnedMutation } from "../capabilities.js";
8
10
  import { pathNamespace } from "../path-namespace.js";
11
+ import { runStagingGuard, snapshotDirectoryAncestry, snapshotStagingResolution } from "../staging-ancestry.js";
9
12
  import { openFileDescriptor } from "../descriptor.js";
13
+ import { normalizePath } from "../../contracts/virtual-path.js";
10
14
  const views = new WeakMap();
11
15
  const deviceCapabilities = Object.freeze({
16
+ synchronousStagingResolution: false,
12
17
  readOnly: false, read: true, stat: true, realpath: true, access: true, readdir: false,
13
18
  write: true, append: true, exclusiveCreate: true, streamingRead: true, retainedRead: true,
14
19
  streamingWrite: true, streamingAppend: true, independentWriteStreams: true, copy: true, exclusiveCopy: true,
15
20
  remove: false, removeDirectory: false, recursiveRemove: false, rename: false,
16
21
  mkdir: false, recursiveMkdir: false, symlinks: false, hardlinks: false, readlink: false,
17
22
  permissions: false, timestamps: false, truncate: false, randomAccessWrite: false,
18
- open: true, atomicFilePublication: false, atomicFileMutation: false, atomicEntryRemoval: false, atomicTreeRemoval: false, atomicFileStaging: false, atomicDirectoryMetadata: false, trustedOwnedStaging: false,
23
+ open: true, retainedStagingCleanup: false, atomicFilePublication: false, atomicFileMutation: false, atomicEntryRemoval: false, atomicEntryRemovalReceipt: false, atomicTreeRemoval: false, atomicFileStaging: false, atomicDirectoryMetadata: false, trustedOwnedStaging: false,
19
24
  atomicRename: false, atomicRenameNoReplace: false, descriptorWriteStream: true, retainedResize: true, atomicResize: false,
20
25
  });
21
26
  function globalCapabilities(filesystem) {
22
27
  const capabilities = { readOnly: false };
23
28
  const optional = {
24
29
  open: ["open"],
30
+ retainedStagingCleanup: ["createStagedFile", "publishStagedFile", "removeStagedFile"],
31
+ synchronousDirectoryValidation: ["prepareDirectoryAncestry"], guardedStagingPublication: ["createStagedFile", "publishStagedFile", "removeStagedFile"],
32
+ synchronousStagingResolution: ["prepareStagingResolution"],
25
33
  trustedOwnedStaging: ["createStagedFile", "publishStagedFile", "removeStagedFile", "writeFileConditional", "removeFileConditional", "prepareDirectory"],
26
- atomicFilePublication: ["publishFileConditional"], atomicEntryRemoval: ["removeEntryConditional"], atomicTreeRemoval: ["removeTreeConditional"], atomicFileMutation: ["writeFileConditional", "removeFileConditional"], atomicFileStaging: ["createStagedFile", "publishStagedFile", "removeStagedFile"], atomicDirectoryMetadata: ["prepareDirectory"],
34
+ atomicFilePublication: ["publishFileConditional"], atomicEntryRemoval: ["removeEntryConditional"], atomicEntryRemovalReceipt: ["removeEntryConditional"], atomicTreeRemoval: ["removeTreeConditional"], atomicFileMutation: ["writeFileConditional", "removeFileConditional"], atomicFileStaging: ["createStagedFile", "publishStagedFile", "removeStagedFile"], atomicDirectoryMetadata: ["prepareDirectory"],
27
35
  streamingRead: ["readStream"], streamingWrite: ["writeStream"], retainedRead: ["openReadFile"],
28
36
  streamingAppend: ["writeStream"], descriptorWriteStream: ["writeStream"], retainedResize: ["openResizeFile"], atomicResize: ["resizeFile"],
29
37
  symlinks: ["symlink", "readlink"], hardlinks: ["link"], permissions: ["chmod"],
@@ -36,6 +44,8 @@ function globalCapabilities(filesystem) {
36
44
  if (key === "copy" || key === "exclusiveCopy")
37
45
  return undefined;
38
46
  let declared = filesystem.capabilities[key];
47
+ if (key === "atomicEntryRemovalReceipt")
48
+ declared = ownedMutationCapabilities(filesystem).atomicEntryRemovalReceipt;
39
49
  if (declared === true && optional[key]?.some(method => typeof filesystem[method] !== "function"))
40
50
  declared = false;
41
51
  if (key === "retainedResize" && filesystem.capabilities.readOnly === true)
@@ -94,8 +104,16 @@ export class DeviceFileSystem {
94
104
  registerEntryAuthority(this, async () => "unknown");
95
105
  return view;
96
106
  }
97
- #resolve(path, options, followFinal = true, resizeCreate) {
98
- return resolveDevicePath(this.#filesystem, path, options, followFinal, resizeCreate);
107
+ #resolve(path, options, followFinal = true, resizeCreate, traversal) {
108
+ return resolveDevicePath(this.#filesystem, path, options, followFinal, resizeCreate, traversal);
109
+ }
110
+ #tryResolveSync(path, options) {
111
+ if (options.signal?.aborted)
112
+ return undefined;
113
+ if (Reflect.get(this.#filesystem, pathNamespace) === undefined) {
114
+ return tryResolveMemoryDevicePath(this.#filesystem, path);
115
+ }
116
+ return undefined;
99
117
  }
100
118
  async #mutable(path, options, followFinal = true) {
101
119
  if (reserved(await this.#resolve(path, options, followFinal)))
@@ -129,10 +147,14 @@ export class DeviceFileSystem {
129
147
  return reserved(resolved) ? resolved : this.#filesystem.canonicalizeMissingTarget?.(path, options);
130
148
  }
131
149
  async capabilitiesFor(path, options = {}) {
150
+ options.signal?.throwIfAborted();
151
+ if (options.stagingResolution === true && (normalizePath(path) !== path || !path.startsWith("/")))
152
+ throw new FsError("EINVAL", { path });
132
153
  let resolved;
133
154
  let selected;
155
+ const traversal = options.stagingResolution === true ? { virtual: false } : undefined;
134
156
  try {
135
- resolved = await this.#resolve(path, options, options.create !== undefined || options.creation !== "exclusive", options.create);
157
+ resolved = await this.#resolve(path, options, options.stagingResolution !== true && (options.create !== undefined || options.creation !== "exclusive"), options.create, traversal);
136
158
  }
137
159
  catch (error) {
138
160
  options.signal?.throwIfAborted();
@@ -160,6 +182,10 @@ export class DeviceFileSystem {
160
182
  return { ...deviceCapabilities, open: false, readdir: true, write: false, append: false,
161
183
  exclusiveCreate: false, streamingWrite: false, streamingAppend: false, independentWriteStreams: false, descriptorWriteStream: false,
162
184
  retainedRead: false, retainedResize: false, streamingRead: false, copy: false, exclusiveCopy: false };
185
+ // This intent cannot bind virtual traversal. Do not infer other path
186
+ // capabilities by resolving the shadowed entry in the backing filesystem.
187
+ if (traversal?.virtual)
188
+ return Object.freeze({ synchronousStagingResolution: false });
163
189
  if (selected === undefined) {
164
190
  const query = this.#filesystem.capabilitiesFor;
165
191
  options.signal?.throwIfAborted();
@@ -169,26 +195,58 @@ export class DeviceFileSystem {
169
195
  const observed = ownedMutationCapabilities(this.#filesystem, selected ?? this.#filesystem.capabilities);
170
196
  options.signal?.throwIfAborted();
171
197
  const capabilities = observed.retainedResize === true ? retainedResizeCapabilities(this.#filesystem, observed) : observed;
172
- const unavailable = {};
173
- if (typeof this.#filesystem.open !== "function")
174
- unavailable.open = false;
175
- if (typeof this.#filesystem.readStream !== "function")
176
- unavailable.streamingRead = false;
198
+ let unavailable;
199
+ if (options.stagingResolution === true && observed.synchronousStagingResolution === true) {
200
+ try {
201
+ const resolution = snapshotStagingResolution(await this.#filesystem.prepareStagingResolution(path, options));
202
+ options.signal?.throwIfAborted();
203
+ if (resolution.path !== resolved)
204
+ throw new FsError("EAGAIN", { path });
205
+ if ([resolution.path, ...resolution.traversed.map(entry => entry.path)].some(entry => entry === deviceDirectory || entry.startsWith(`${deviceDirectory}/`))) {
206
+ (unavailable ??= {}).synchronousStagingResolution = false;
207
+ }
208
+ runStagingGuard(resolution.validate);
209
+ }
210
+ catch (error) {
211
+ options.signal?.throwIfAborted();
212
+ if (!isFsError(error, "ENOTSUP"))
213
+ throw error;
214
+ (unavailable ??= {}).synchronousStagingResolution = false;
215
+ }
216
+ }
217
+ if (resolved.startsWith(`${deviceDirectory}/`)) {
218
+ const u = unavailable ??= {};
219
+ u.atomicStagingAncestry = false;
220
+ u.synchronousDirectoryValidation = false;
221
+ u.synchronousStagingResolution = false;
222
+ }
223
+ if (typeof this.#filesystem.open !== "function" && capabilities.open !== false)
224
+ (unavailable ??= {}).open = false;
225
+ if (typeof this.#filesystem.readStream !== "function" && capabilities.streamingRead !== false)
226
+ (unavailable ??= {}).streamingRead = false;
177
227
  if (typeof this.#filesystem.writeStream !== "function") {
178
- unavailable.streamingWrite = false;
179
- unavailable.streamingAppend = false;
180
- unavailable.descriptorWriteStream = false;
181
- }
182
- if (typeof this.#filesystem.openReadFile !== "function")
183
- unavailable.retainedRead = false;
184
- if (observed.atomicResize === true && (typeof this.#filesystem.resizeFile !== "function" || observed.readOnly === true))
185
- unavailable.atomicResize = false;
186
- const result = Object.keys(unavailable).some(key => capabilities[key] !== false)
187
- ? { ...capabilities, ...unavailable } : capabilities;
228
+ if (capabilities.streamingWrite !== false)
229
+ (unavailable ??= {}).streamingWrite = false;
230
+ if (capabilities.streamingAppend !== false)
231
+ (unavailable ??= {}).streamingAppend = false;
232
+ if (capabilities.descriptorWriteStream !== false)
233
+ (unavailable ??= {}).descriptorWriteStream = false;
234
+ }
235
+ if (typeof this.#filesystem.openReadFile !== "function" && capabilities.retainedRead !== false)
236
+ (unavailable ??= {}).retainedRead = false;
237
+ if (observed.atomicResize === true && (typeof this.#filesystem.resizeFile !== "function" || observed.readOnly === true) && capabilities.atomicResize !== false)
238
+ (unavailable ??= {}).atomicResize = false;
239
+ const result = unavailable ? { ...capabilities, ...unavailable } : capabilities;
188
240
  options.signal?.throwIfAborted();
189
241
  return result;
190
242
  }
191
- async stat(path, options = {}) {
243
+ stat(path, options = {}) {
244
+ if (this.#tryResolveSync(path, options) !== undefined) {
245
+ return this.#filesystem.stat(path, options);
246
+ }
247
+ return this.#statSlow(path, options);
248
+ }
249
+ async #statSlow(path, options) {
192
250
  const resolved = await this.#resolve(path, options);
193
251
  options.signal?.throwIfAborted();
194
252
  if (resolved === nullPath)
@@ -201,7 +259,13 @@ export class DeviceFileSystem {
201
259
  options.signal?.throwIfAborted();
202
260
  return result;
203
261
  }
204
- async lstat(path, options = {}) {
262
+ lstat(path, options = {}) {
263
+ if (this.#tryResolveSync(path, options) !== undefined) {
264
+ return this.#filesystem.lstat(path, options);
265
+ }
266
+ return this.#lstatSlow(path, options);
267
+ }
268
+ async #lstatSlow(path, options) {
205
269
  const resolved = await this.#resolve(path, options, false);
206
270
  options.signal?.throwIfAborted();
207
271
  if (resolved === nullPath)
@@ -217,11 +281,23 @@ export class DeviceFileSystem {
217
281
  compareEntry(path, peer, peerPath, options = {}) {
218
282
  return compareEntries(this, path, peer, peerPath, options);
219
283
  }
220
- async realpath(path, options = {}) {
284
+ realpath(path, options = {}) {
285
+ if (this.#tryResolveSync(path, options) !== undefined) {
286
+ return this.#filesystem.realpath(path, options);
287
+ }
288
+ return this.#realpathSlow(path, options);
289
+ }
290
+ async #realpathSlow(path, options) {
221
291
  const resolved = await this.#resolve(path, options);
222
292
  return reserved(resolved) ? resolved : this.#filesystem.realpath(path, options);
223
293
  }
224
- async readFile(path, options = {}) {
294
+ readFile(path, options = {}) {
295
+ if (this.#tryResolveSync(path, options) !== undefined) {
296
+ return this.#filesystem.readFile(path, options);
297
+ }
298
+ return this.#readFileSlow(path, options);
299
+ }
300
+ async #readFileSlow(path, options) {
225
301
  const resolved = await this.#resolve(path, options);
226
302
  if (resolved === nullPath) {
227
303
  nonnegative(options.maxBytes, path);
@@ -389,7 +465,14 @@ export class DeviceFileSystem {
389
465
  throw new FsError("ENOTSUP", { path });
390
466
  await this.#filesystem.writeStream(path, source, options);
391
467
  }
392
- async readdir(path, options = {}) {
468
+ readdir(path, options = {}) {
469
+ const fast = this.#tryResolveSync(path, options);
470
+ if (fast !== undefined && fast !== "/") {
471
+ return this.#filesystem.readdir(path, options);
472
+ }
473
+ return this.#readdirSlow(path, options);
474
+ }
475
+ async #readdirSlow(path, options) {
393
476
  const resolved = await this.#resolve(path, options);
394
477
  if (resolved === nullPath)
395
478
  throw new FsError("ENOTDIR", { path });
@@ -421,7 +504,13 @@ export class DeviceFileSystem {
421
504
  admitDirectoryEntries(merged.size, limit, path);
422
505
  return [...merged.values()];
423
506
  }
424
- async mkdir(path, options = {}) {
507
+ mkdir(path, options = {}) {
508
+ if (this.#tryResolveSync(path, options) !== undefined) {
509
+ return this.#filesystem.mkdir(path, options);
510
+ }
511
+ return this.#mkdirSlow(path, options);
512
+ }
513
+ async #mkdirSlow(path, options) {
425
514
  const resolved = await this.#resolve(path, options);
426
515
  if (reserved(resolved)) {
427
516
  if (resolved !== nullPath && options.recursive)
@@ -430,7 +519,13 @@ export class DeviceFileSystem {
430
519
  }
431
520
  await this.#filesystem.mkdir(path, options);
432
521
  }
433
- async rm(path, options = {}) {
522
+ rm(path, options = {}) {
523
+ if (this.#tryResolveSync(path, options) !== undefined) {
524
+ return this.#filesystem.rm(path, options);
525
+ }
526
+ return this.#rmSlow(path, options);
527
+ }
528
+ async #rmSlow(path, options) {
434
529
  await this.#mutable(path, options, false);
435
530
  await this.#filesystem.rm(path, options);
436
531
  }
@@ -466,11 +561,16 @@ export class DeviceFileSystem {
466
561
  return this.#filesystem.writeFileConditional(path, data, options);
467
562
  }
468
563
  async removeEntryConditional(path, options) {
564
+ options = { ...options };
565
+ const requested = options.returnRemainingStat === true;
469
566
  await this.#mutable(path, options, false);
470
567
  await requireOwnedMutation(this.#filesystem, path, "atomicEntryRemoval", options);
568
+ if (requested)
569
+ await requireOwnedMutation(this.#filesystem, path, "atomicEntryRemovalReceipt", options);
471
570
  if (!this.#filesystem.removeEntryConditional)
472
571
  throw new FsError("ENOTSUP", { path });
473
- await this.#filesystem.removeEntryConditional(path, options);
572
+ const receipt = await this.#filesystem.removeEntryConditional(path, options);
573
+ return requested && receipt !== undefined ? Object.freeze({ ...receipt }) : undefined;
474
574
  }
475
575
  async removeFileConditional(path, options) {
476
576
  await this.#mutable(path, options, false);
@@ -487,20 +587,95 @@ export class DeviceFileSystem {
487
587
  await this.#filesystem.removeTreeConditional(path, options);
488
588
  }
489
589
  async createStagedFile(path, name, content, options) {
590
+ options = snapshotStagingCreation(options, path);
490
591
  await this.#mutable(path, options, false);
491
592
  await requireOwnedMutation(this.#filesystem, path, "atomicFileStaging", options, true);
593
+ if (options.retainCleanup === true)
594
+ await requireOwnedMutation(this.#filesystem, path, "retainedStagingCleanup", options, true);
492
595
  if (!this.#filesystem.createStagedFile)
493
596
  throw new FsError("ENOTSUP", { path });
494
597
  return this.#filesystem.createStagedFile(path, name, content, options);
495
598
  }
496
599
  async publishStagedFile(staging, destination, options) {
600
+ options.signal?.throwIfAborted();
601
+ if (options.ancestors !== undefined) {
602
+ const entries = snapshotDirectoryAncestry(options.ancestors);
603
+ if (entries.some(entry => entry.path === deviceDirectory || entry.path.startsWith(`${deviceDirectory}/`)))
604
+ throw new FsError("ENOTSUP", { path: destination });
605
+ await requireOwnedMutation(this.#filesystem, destination, "atomicStagingAncestry", options, options.destination === null);
606
+ }
497
607
  for (const path of [staging.directory.path, staging.file.path, destination])
498
608
  await this.#mutable(path, options, false);
499
609
  await requireOwnedMutation(this.#filesystem, staging.directory.path, "atomicFileStaging", options);
610
+ await requireOwnedMutation(this.#filesystem, destination, "atomicFileStaging", options, options.destination === null);
500
611
  if (!this.#filesystem.publishStagedFile)
501
612
  throw new FsError("ENOTSUP", { path: destination });
613
+ if (options.commitGuard !== undefined)
614
+ await requireOwnedMutation(this.#filesystem, destination, "guardedStagingPublication", options, options.destination === null);
502
615
  await this.#filesystem.publishStagedFile(staging, destination, options);
503
616
  }
617
+ async prepareDirectoryAncestry(ancestors, options = {}) {
618
+ options.signal?.throwIfAborted();
619
+ try {
620
+ const entries = snapshotDirectoryAncestry(ancestors);
621
+ const controls = options.signal === undefined ? {} : { signal: options.signal };
622
+ controls.signal?.throwIfAborted();
623
+ if (entries.some(entry => entry.path === deviceDirectory || entry.path.startsWith(`${deviceDirectory}/`))
624
+ || !this.#filesystem.prepareDirectoryAncestry) {
625
+ throw new FsError("ENOTSUP", { syscall: "prepareDirectoryAncestry" });
626
+ }
627
+ for (const entry of entries) {
628
+ const declared = await this.#filesystem.capabilitiesFor?.(entry.path, { ...controls, stagingAncestry: true }) ?? this.#filesystem.capabilities;
629
+ controls.signal?.throwIfAborted();
630
+ if (declared.synchronousDirectoryValidation !== true)
631
+ throw new FsError("ENOTSUP", { syscall: "prepareDirectoryAncestry", path: entry.path });
632
+ }
633
+ const guard = await this.#filesystem.prepareDirectoryAncestry(entries, controls);
634
+ controls.signal?.throwIfAborted();
635
+ if (typeof guard !== "function")
636
+ throw new FsError("ENOTSUP", { syscall: "prepareDirectoryAncestry" });
637
+ return () => {
638
+ controls.signal?.throwIfAborted();
639
+ runStagingGuard(guard);
640
+ return true;
641
+ };
642
+ }
643
+ catch (error) {
644
+ options.signal?.throwIfAborted();
645
+ throw error;
646
+ }
647
+ }
648
+ async prepareStagingResolution(path, options = {}) {
649
+ const controls = options.signal === undefined ? {} : { signal: options.signal };
650
+ controls.signal?.throwIfAborted();
651
+ if (normalizePath(path) !== path || !path.startsWith("/"))
652
+ throw new FsError("EINVAL", { path });
653
+ try {
654
+ const traversal = { virtual: false };
655
+ const resolved = await this.#resolve(path, controls, false, undefined, traversal);
656
+ controls.signal?.throwIfAborted();
657
+ if (traversal.virtual)
658
+ throw new FsError("ENOTSUP", { path });
659
+ const declared = ownedMutationCapabilities(this.#filesystem, await this.#filesystem.capabilitiesFor?.(path, { ...controls, stagingResolution: true }) ?? this.#filesystem.capabilities);
660
+ controls.signal?.throwIfAborted();
661
+ if (declared.synchronousStagingResolution !== true || !this.#filesystem.prepareStagingResolution)
662
+ throw new FsError("ENOTSUP", { path });
663
+ const receipt = snapshotStagingResolution(await this.#filesystem.prepareStagingResolution(path, controls));
664
+ controls.signal?.throwIfAborted();
665
+ if (receipt.path !== resolved)
666
+ throw new FsError("EAGAIN", { path });
667
+ if ([receipt.path, ...receipt.traversed.map(entry => entry.path)].some(entry => entry === deviceDirectory || entry.startsWith(`${deviceDirectory}/`))) {
668
+ throw new FsError("ENOTSUP", { path });
669
+ }
670
+ const validate = () => { controls.signal?.throwIfAborted(); runStagingGuard(receipt.validate); return true; };
671
+ runStagingGuard(validate);
672
+ return snapshotStagingResolution({ ...receipt, validate });
673
+ }
674
+ catch (error) {
675
+ controls.signal?.throwIfAborted();
676
+ throw error;
677
+ }
678
+ }
504
679
  async removeStagedFile(staging, options = {}) {
505
680
  for (const path of [staging.directory.path, staging.file.path])
506
681
  await this.#mutable(path, options, false);
@@ -558,7 +733,13 @@ export class DeviceFileSystem {
558
733
  throw new FsError("EISDIR", { path: destination });
559
734
  await this.#filesystem.writeFile(destination, new Uint8Array(), { ...options, flag: options.exclusive ? "wx" : "w" });
560
735
  }
561
- async access(path, mode = 0, options = {}) {
736
+ access(path, mode = 0, options = {}) {
737
+ if (this.#tryResolveSync(path, options) !== undefined) {
738
+ return this.#filesystem.access(path, mode, options);
739
+ }
740
+ return this.#accessSlow(path, mode, options);
741
+ }
742
+ async #accessSlow(path, mode, options) {
562
743
  const resolved = await this.#resolve(path, options);
563
744
  if (resolved === nullPath || resolved === deviceDirectory) {
564
745
  if (!Number.isInteger(mode) || mode < 0 || mode > 7)
@@ -2,4 +2,6 @@ import type { FileSystem, FsOptions } from "../../contracts/filesystem.js";
2
2
  export declare const nullPath = "/dev/null";
3
3
  export declare const deviceDirectory = "/dev";
4
4
  export declare function lexicalDevicePath(path: string): string;
5
- export declare function resolveDevicePath(filesystem: FileSystem, path: string, options: FsOptions, followFinal?: boolean, resizeCreate?: boolean): Promise<string>;
5
+ export declare function resolveDevicePath(filesystem: FileSystem, path: string, options: FsOptions, followFinal?: boolean, resizeCreate?: boolean, traversal?: {
6
+ virtual: boolean;
7
+ }): Promise<string>;
@@ -1,13 +1,19 @@
1
1
  import { FsError, isFsError } from "../../contracts/errors.js";
2
- import { validatePath } from "../../contracts/virtual-path.js";
3
- import { capturePathNamespace } from "../path-namespace.js";
2
+ import { MAX_PATH_COMPONENTS, validatePath } from "../../contracts/virtual-path.js";
3
+ import { capturePathNamespace, pathNamespace } from "../path-namespace.js";
4
+ import { isCleanAbsolutePath, tryResolveMemoryDevicePath } from "../memory/index.js";
4
5
  export const nullPath = "/dev/null";
5
6
  export const deviceDirectory = "/dev";
7
+ function isNullParts(parts) {
8
+ return parts.length === 2 && parts[0] === "dev" && parts[1] === "null";
9
+ }
6
10
  export function lexicalDevicePath(path) {
7
11
  validatePath(path);
12
+ if (isCleanAbsolutePath(path) && !path.startsWith("/dev/null/"))
13
+ return path;
8
14
  const parts = [];
9
15
  for (const component of path.split("/")) {
10
- if (`/${parts.join("/")}` === nullPath)
16
+ if (isNullParts(parts))
11
17
  throw new FsError("ENOTDIR", { path });
12
18
  if (component === "..")
13
19
  parts.pop();
@@ -16,7 +22,7 @@ export function lexicalDevicePath(path) {
16
22
  }
17
23
  return `/${parts.join("/")}`;
18
24
  }
19
- async function resolveResizeDevicePath(filesystem, path, options, create, namespace) {
25
+ async function resolveResizeDevicePath(filesystem, path, options, create, namespace, traversal) {
20
26
  validatePath(path);
21
27
  const components = (value) => {
22
28
  const result = value.split("/").filter(Boolean);
@@ -25,6 +31,9 @@ async function resolveResizeDevicePath(filesystem, path, options, create, namesp
25
31
  return result;
26
32
  };
27
33
  const pending = components(path);
34
+ let remainingComponents = MAX_PATH_COMPONENTS - pending.length;
35
+ if (remainingComponents < 0)
36
+ throw new FsError("ENAMETOOLONG", { path });
28
37
  const parts = [];
29
38
  let links = 0;
30
39
  let expanded = path.length;
@@ -57,12 +66,14 @@ async function resolveResizeDevicePath(filesystem, path, options, create, namesp
57
66
  parts.pop();
58
67
  continue;
59
68
  }
60
- if (new TextEncoder().encode(component).byteLength > 255)
69
+ if (exceedsComponentByteLimit(component))
61
70
  throw new FsError("ENAMETOOLONG", { path });
62
71
  const candidate = `/${[...parts, component].join("/")}`;
63
72
  const selected = namespace === undefined ? undefined : candidate === deviceDirectory || candidate === nullPath ? "/" : namespace(candidate);
64
73
  if (boundary !== undefined && selected !== boundary)
65
74
  throw new FsError("EACCES", { path });
75
+ if (traversal && (candidate === deviceDirectory || candidate === nullPath))
76
+ traversal.virtual = true;
66
77
  if (create && pending.length === 1 && pending[0] === "")
67
78
  throw new FsError("EISDIR", { path });
68
79
  if (candidate !== deviceDirectory && candidate !== nullPath) {
@@ -97,6 +108,9 @@ async function resolveResizeDevicePath(filesystem, path, options, create, namesp
97
108
  const targetParts = components(target);
98
109
  if (targetParts.at(-1) === "" && pending[0] === "")
99
110
  targetParts.pop();
111
+ if (targetParts.length > remainingComponents)
112
+ throw new FsError("ENAMETOOLONG", { path });
113
+ remainingComponents -= targetParts.length;
100
114
  pending.unshift(...targetParts);
101
115
  continue;
102
116
  }
@@ -105,11 +119,36 @@ async function resolveResizeDevicePath(filesystem, path, options, create, namesp
105
119
  }
106
120
  return `/${parts.join("/")}`;
107
121
  }
108
- export async function resolveDevicePath(filesystem, path, options, followFinal = true, resizeCreate) {
122
+ function utf8ByteLength(value) {
123
+ let bytes = 0;
124
+ for (let i = 0; i < value.length; i++) {
125
+ const code = value.charCodeAt(i);
126
+ if (code < 0x80)
127
+ bytes += 1;
128
+ else if (code < 0x800)
129
+ bytes += 2;
130
+ else if (code >= 0xd800 && code <= 0xdbff && i + 1 < value.length && value.charCodeAt(i + 1) >= 0xdc00 && value.charCodeAt(i + 1) <= 0xdfff) {
131
+ bytes += 4;
132
+ i++;
133
+ }
134
+ else
135
+ bytes += 3;
136
+ }
137
+ return bytes;
138
+ }
139
+ function exceedsComponentByteLimit(value) {
140
+ return value.length > 85 && (value.length > 255 || utf8ByteLength(value) > 255);
141
+ }
142
+ export async function resolveDevicePath(filesystem, path, options, followFinal = true, resizeCreate, traversal) {
109
143
  options.signal?.throwIfAborted();
144
+ if (Reflect.get(filesystem, pathNamespace) === undefined) {
145
+ const fast = tryResolveMemoryDevicePath(filesystem, path, resizeCreate);
146
+ if (fast !== undefined)
147
+ return fast;
148
+ }
110
149
  const namespace = capturePathNamespace(filesystem, options);
111
150
  if (resizeCreate !== undefined)
112
- return resolveResizeDevicePath(filesystem, path, options, resizeCreate, namespace);
151
+ return resolveResizeDevicePath(filesystem, path, options, resizeCreate, namespace, traversal);
113
152
  const lexical = lexicalDevicePath(path);
114
153
  const lstat = filesystem.lstat;
115
154
  options.signal?.throwIfAborted();
@@ -117,15 +156,19 @@ export async function resolveDevicePath(filesystem, path, options, followFinal =
117
156
  if (!aliases && lexical !== nullPath && lexical !== deviceDirectory && lexical !== "/")
118
157
  return lexical;
119
158
  const pending = path.split("/");
159
+ let remainingComponents = MAX_PATH_COMPONENTS - pending.filter(Boolean).length;
160
+ if (remainingComponents < 0)
161
+ throw new FsError("ENAMETOOLONG", { path });
120
162
  const parts = [];
121
163
  let links = 0;
122
164
  let expanded = path.length;
123
165
  let absolute = path.startsWith("/");
124
166
  let traversalFailure;
167
+ let failedDepth = Infinity;
125
168
  let boundary;
126
169
  while (pending.length) {
127
170
  options.signal?.throwIfAborted();
128
- if (`/${parts.join("/")}` === nullPath)
171
+ if (isNullParts(parts))
129
172
  throw new FsError("ENOTDIR", { path });
130
173
  const component = pending.shift();
131
174
  if (!component || component === ".")
@@ -134,13 +177,18 @@ export async function resolveDevicePath(filesystem, path, options, followFinal =
134
177
  if (boundary !== undefined && `/${parts.join("/")}` === boundary)
135
178
  throw new FsError("EACCES", { path });
136
179
  parts.pop();
180
+ if (parts.length < failedDepth) {
181
+ failedDepth = Infinity;
182
+ }
137
183
  continue;
138
184
  }
139
185
  const candidate = `/${[...parts, component].join("/")}`;
140
186
  const selected = namespace === undefined ? undefined : candidate === deviceDirectory || candidate === nullPath ? "/" : namespace(candidate);
141
187
  if (boundary !== undefined && selected !== boundary)
142
188
  throw new FsError("EACCES", { path });
143
- if (candidate !== deviceDirectory && candidate !== nullPath && ((followFinal && aliases) || pending.length)) {
189
+ if (traversal && (candidate === deviceDirectory || candidate === nullPath))
190
+ traversal.virtual = true;
191
+ if (parts.length < failedDepth && candidate !== deviceDirectory && candidate !== nullPath && ((followFinal && aliases) || pending.length)) {
144
192
  const lookup = absolute ? candidate : candidate.slice(1);
145
193
  let stat;
146
194
  try {
@@ -154,8 +202,10 @@ export async function resolveDevicePath(filesystem, path, options, followFinal =
154
202
  return lexical;
155
203
  if (!isFsError(error, "ENOENT"))
156
204
  throw error;
157
- if (pending.length)
205
+ if (pending.length) {
158
206
  traversalFailure ??= error;
207
+ failedDepth = Math.min(failedDepth, parts.length + 1);
208
+ }
159
209
  }
160
210
  options.signal?.throwIfAborted();
161
211
  if (stat?.type === "symlink") {
@@ -172,15 +222,23 @@ export async function resolveDevicePath(filesystem, path, options, followFinal =
172
222
  expanded += target.length;
173
223
  if (expanded > 65536)
174
224
  throw new FsError("ENAMETOOLONG", { path });
225
+ const targetSplit = target.split("/");
226
+ const nonEmptyCount = targetSplit.filter(Boolean).length;
227
+ if (nonEmptyCount > remainingComponents)
228
+ throw new FsError("ENAMETOOLONG", { path });
229
+ remainingComponents -= nonEmptyCount;
175
230
  if (target.startsWith("/")) {
176
231
  parts.splice(0, parts.length, ...(boundary ?? "/").split("/").filter(Boolean));
177
232
  absolute = true;
233
+ failedDepth = Infinity;
178
234
  }
179
- pending.unshift(...target.split("/"));
235
+ pending.unshift(...targetSplit);
180
236
  continue;
181
237
  }
182
- if (stat && pending.length && stat.type !== "directory")
238
+ if (stat && pending.length && stat.type !== "directory") {
183
239
  traversalFailure ??= new FsError("ENOTDIR", { path });
240
+ failedDepth = Math.min(failedDepth, parts.length + 1);
241
+ }
184
242
  }
185
243
  parts.push(component);
186
244
  }