@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
@@ -6,7 +6,10 @@ import { admitDirectoryEntries, directoryEntryLimit } from "../directory-admissi
6
6
  import { normalizePath, validatePath } from "../../contracts/virtual-path.js";
7
7
  import { forwardFileDescriptor, openFileDescriptor } from "../descriptor.js";
8
8
  import { pathNamespace } from "../path-namespace.js";
9
+ import { createStagingCleanup, snapshotStagingCreation } from "../staging-cleanup.js";
10
+ import { directoryAncestryPaths, inspectStagingBindings, runStagingGuard, snapshotDirectoryAncestry, snapshotStagingResolution } from "../staging-ancestry.js";
9
11
  import { compareIdentity } from "./identity.js";
12
+ import { snapshotConditionalChmod } from "../conditional-chmod.js";
10
13
  import { compareEntries, registerEntryAuthority, registerEntryView } from "./comparison.js";
11
14
  const syntheticStat = Object.freeze({
12
15
  type: "directory", size: 0, mode: 0o40555,
@@ -102,8 +105,10 @@ export class MountFileSystem {
102
105
  const common = (capability) => {
103
106
  const optional = {
104
107
  open: ["open"],
108
+ retainedStagingCleanup: ["createStagedFile", "publishStagedFile", "removeStagedFile"],
109
+ synchronousDirectoryValidation: ["prepareDirectoryAncestry"], guardedStagingPublication: ["createStagedFile", "publishStagedFile", "removeStagedFile"],
105
110
  trustedOwnedStaging: ["createStagedFile", "publishStagedFile", "removeStagedFile", "writeFileConditional", "removeFileConditional", "prepareDirectory"],
106
- atomicFilePublication: ["publishFileConditional"], atomicEntryRemoval: ["removeEntryConditional"], atomicTreeRemoval: ["removeTreeConditional"], atomicFileMutation: ["writeFileConditional", "removeFileConditional"], atomicFileStaging: ["createStagedFile", "publishStagedFile", "removeStagedFile"], atomicDirectoryMetadata: ["prepareDirectory"],
111
+ atomicFilePublication: ["publishFileConditional"], atomicEntryRemoval: ["removeEntryConditional"], atomicEntryRemovalReceipt: ["removeEntryConditional"], atomicTreeRemoval: ["removeTreeConditional"], atomicFileMutation: ["writeFileConditional", "removeFileConditional"], atomicFileStaging: ["createStagedFile", "publishStagedFile", "removeStagedFile"], atomicDirectoryMetadata: ["prepareDirectory"],
107
112
  symlinks: ["symlink", "readlink"], hardlinks: ["link"], permissions: ["chmod"], timestamps: ["utimes"], readlink: ["readlink"],
108
113
  descriptorWriteStream: ["writeStream"], retainedResize: ["openResizeFile"], atomicResize: ["resizeFile"],
109
114
  };
@@ -111,7 +116,8 @@ export class MountFileSystem {
111
116
  if (backend.capabilities.readOnly === true
112
117
  && !["open", "versionedDescriptors", "read", "stat", "readdir", "realpath", "access", "readlink", "explicitDirectories", "implicitDirectories"].includes(capability))
113
118
  return false;
114
- const declared = backend.capabilities[capability];
119
+ const declared = capability === "retainedStagingCleanup" || capability === "atomicEntryRemovalReceipt"
120
+ ? ownedMutationCapabilities(backend)[capability] : backend.capabilities[capability];
115
121
  if (capability === "descriptorWriteStream" && backend.capabilities.streamingWrite === false)
116
122
  return false;
117
123
  return declared === true && optional[capability]?.some(method => typeof backend[method] !== "function") ? false : declared;
@@ -121,6 +127,7 @@ export class MountFileSystem {
121
127
  return values.every(value => value === true) ? true : values.every(value => value === false) ? false : undefined;
122
128
  };
123
129
  const semantics = Object.fromEntries([
130
+ "guardedStagingPublication", "retainedStagingCleanup", "atomicEntryRemovalReceipt",
124
131
  "trustedOwnedStaging", "atomicFilePublication", "atomicEntryRemoval", "atomicTreeRemoval", "atomicFileMutation", "atomicFileStaging", "atomicDirectoryMetadata", "read", "stat", "readdir", "realpath", "access", "open", "versionedDescriptors",
125
132
  "write", "append", "exclusiveCreate", "explicitDirectories", "implicitDirectories", "mkdir", "recursiveMkdir",
126
133
  "remove", "removeDirectory", "recursiveRemove", "rename", "atomicRenameNoReplace", "copy", "exclusiveCopy", "readlink", "truncate",
@@ -131,8 +138,13 @@ export class MountFileSystem {
131
138
  readOnly: all("readOnly"),
132
139
  ...(append === undefined ? {} : { append }),
133
140
  ...semantics,
134
- atomicStagingAncestry: false,
141
+ // Real ancestry (including synthetic mount parents) is path-dependent.
142
+ synchronousStagingResolution: mounts.some(({ backend }) => typeof backend.prepareStagingResolution === "function") ? undefined : false,
143
+ atomicStagingAncestry: mounts.some(({ backend }) => typeof backend.prepareDirectoryAncestry === "function"
144
+ && typeof backend.publishStagedFile === "function" && (typeof backend.capabilitiesFor === "function"
145
+ || backend.capabilities.guardedStagingPublication !== false && backend.capabilities.synchronousDirectoryValidation !== false)) ? undefined : false,
135
146
  atomicRename: mounts.length === 1 && all("atomicRename"),
147
+ conditionalChmod: all("conditionalChmod", ["chmod", "prepareDirectoryAncestry"]),
136
148
  ...(streamingRead === undefined ? {} : { streamingRead }),
137
149
  ...(streamingWrite === undefined ? {} : { streamingWrite }),
138
150
  ...(retainedRead === undefined ? {} : { retainedRead }),
@@ -143,9 +155,11 @@ export class MountFileSystem {
143
155
  }
144
156
  async capabilitiesFor(path, options = {}) {
145
157
  return this.operation("capabilitiesFor", path, options, async () => {
158
+ if (options.stagingResolution === true && normalizePath(globalPath(path)) !== path)
159
+ fail("EINVAL");
146
160
  const location = await this.resolve(path, options, {
147
161
  allowMissing: options.create ?? true,
148
- followFinal: options.create !== undefined || options.creation !== "exclusive",
162
+ followFinal: options.stagingResolution !== true && (options.create !== undefined || options.creation !== "exclusive"),
149
163
  ...(options.create === undefined ? {} : { resizeCreate: options.create }),
150
164
  });
151
165
  const observed = ownedMutationCapabilities(location.mount.backend, await location.mount.backend.capabilitiesFor?.(location.local, options)
@@ -155,7 +169,26 @@ export class MountFileSystem {
155
169
  ? { ...observed, descriptorWriteStream: false } : observed;
156
170
  const resize = declared.atomicResize === true && (typeof location.mount.backend.resizeFile !== "function" || declared.readOnly === true)
157
171
  ? { ...declared, atomicResize: false } : declared;
158
- const withOpen = { ...resize, atomicStagingAncestry: false, ...(typeof location.mount.backend.open === "function" ? {} : { open: false }) };
172
+ const ancestry = options.stagingAncestry === true && location.path === normalizePath(globalPath(path))
173
+ && await this.supportsStagingAncestry(location, resize, options);
174
+ const validation = options.stagingAncestry === true ? location.stat?.type === "directory"
175
+ && location.path === normalizePath(globalPath(path)) && await this.supportsDirectoryValidation(location.path, options) : undefined;
176
+ let resolution;
177
+ if (options.stagingResolution === true) {
178
+ resolution = false;
179
+ if (within(location.mount.path, path) && typeof location.mount.backend.prepareStagingResolution === "function") {
180
+ const input = location.mount.path === "/" ? path : path.slice(location.mount.path.length) || "/";
181
+ const declared = input === location.local ? resize : ownedMutationCapabilities(location.mount.backend, await location.mount.backend.capabilitiesFor?.(input, options) ?? location.mount.backend.capabilities);
182
+ options.signal?.throwIfAborted();
183
+ resolution = declared.synchronousStagingResolution === true && await this.supportsStagingAncestry(location, resize, options);
184
+ }
185
+ }
186
+ const conditionalChmod = options.conditionalChmod === true
187
+ ? resize.conditionalChmod === true && typeof location.mount.backend.chmod === "function"
188
+ && await this.supportsDirectoryValidation(location.path.slice(0, location.path.lastIndexOf("/")) || "/", options, true)
189
+ : this.capabilities.conditionalChmod;
190
+ const { synchronousDirectoryValidation: ignoredValidation, synchronousStagingResolution: ignoredResolution, ...ordinary } = resize;
191
+ const withOpen = { ...ordinary, ...(conditionalChmod === undefined ? {} : { conditionalChmod }), ...(resolution === undefined ? {} : { synchronousStagingResolution: resolution }), ...(validation === undefined ? {} : { synchronousDirectoryValidation: validation }), atomicStagingAncestry: ancestry, ...(typeof location.mount.backend.open === "function" ? {} : { open: false }) };
159
192
  const capabilities = location.synthetic ? { ...withOpen, open: false, retainedRead: false }
160
193
  : retainedResizeCapabilities(location.mount.backend, retainedReadCapabilities(location.mount.backend, withOpen));
161
194
  if (location.synthetic)
@@ -171,6 +204,147 @@ export class MountFileSystem {
171
204
  });
172
205
  });
173
206
  }
207
+ async supportsStagingAncestry(target, capabilities, options) {
208
+ options.signal?.throwIfAborted();
209
+ if (target.synthetic || target.stat !== undefined && (target.stat.type !== "file" || compareIdentity(target.stat, target.stat) !== "same")
210
+ || capabilities.atomicFileStaging !== true || capabilities.atomicStagingAncestry !== true
211
+ || capabilities.guardedStagingPublication !== true || capabilities.readOnly === true)
212
+ return false;
213
+ const parent = target.path.slice(0, target.path.lastIndexOf("/")) || "/";
214
+ return this.supportsDirectoryValidation(parent, options);
215
+ }
216
+ async supportsDirectoryValidation(directory, options, conditionalChmod = false) {
217
+ const controls = options.signal === undefined ? {} : { signal: options.signal };
218
+ for (const path of directoryAncestryPaths(directory)) {
219
+ const entry = await this.lookup(path, controls);
220
+ if (entry.synthetic || entry.stat?.type !== "directory" || compareIdentity(entry.stat, entry.stat) !== "same")
221
+ return false;
222
+ const backend = entry.mount.backend;
223
+ const declared = await backend.capabilitiesFor?.(entry.local, { ...controls, stagingAncestry: true }) ?? backend.capabilities;
224
+ options.signal?.throwIfAborted();
225
+ if (declared.synchronousDirectoryValidation !== true && !(conditionalChmod && declared.conditionalChmod === true)
226
+ || typeof backend.prepareDirectoryAncestry !== "function")
227
+ return false;
228
+ }
229
+ return true;
230
+ }
231
+ prepareDirectoryAncestry(ancestors, options = {}) {
232
+ return this.operation("prepareDirectoryAncestry", ancestors.at(-1)?.path ?? "/", options, () => this.prepareAncestry(ancestors, options));
233
+ }
234
+ prepareStagingResolution(path, options = {}) {
235
+ const controls = options.signal === undefined ? {} : { signal: options.signal };
236
+ return this.operation("prepareStagingResolution", path, controls, async () => {
237
+ if (normalizePath(globalPath(path)) !== path)
238
+ fail("EINVAL");
239
+ const observed = [];
240
+ const location = await this.resolve(path, controls, { followFinal: false, allowMissing: true, resolutionSteps: observed });
241
+ if (location.synthetic || !within(location.mount.path, path))
242
+ fail("ENOTSUP");
243
+ const backend = location.mount.backend;
244
+ const input = location.mount.path === "/" ? path : path.slice(location.mount.path.length) || "/";
245
+ const declared = ownedMutationCapabilities(backend, await backend.capabilitiesFor?.(input, { ...controls, stagingResolution: true }) ?? backend.capabilities);
246
+ controls.signal?.throwIfAborted();
247
+ if (declared.synchronousStagingResolution !== true || !backend.prepareStagingResolution)
248
+ fail("ENOTSUP");
249
+ const prefix = directoryAncestryPaths(location.mount.path).map(ancestor => {
250
+ const entry = observed.find(step => step.path === ancestor);
251
+ if (!entry)
252
+ fail("ENOTSUP");
253
+ return entry;
254
+ });
255
+ const outerGuard = await this.prepareAncestry(prefix, controls);
256
+ const bound = snapshotStagingResolution(await backend.prepareStagingResolution(input, controls));
257
+ controls.signal?.throwIfAborted();
258
+ const mapPath = (local) => location.mount.path === "/" ? local : `${location.mount.path}${local === "/" ? "" : local}`;
259
+ const map = (entry) => ({ ...entry, path: mapPath(entry.path) });
260
+ const traversed = bound.traversed.map(map);
261
+ const localObserved = observed.filter(entry => within(location.mount.path, entry.path));
262
+ if (mapPath(bound.path) !== location.path || localObserved.length !== traversed.length)
263
+ fail("EAGAIN");
264
+ for (const [index, before] of localObserved.entries()) {
265
+ const after = traversed[index];
266
+ if (this.select(after.path) !== location.mount)
267
+ fail("EACCES");
268
+ if (before.path !== after.path || before.linkTarget !== after.linkTarget || before.stat.type !== after.stat.type
269
+ || compareIdentity(before.stat, after.stat) !== "same"
270
+ || before.stat.type !== "directory" && ["revision", "size", "mode", "nlink", "mtimeMs", "ctimeMs"].some(field => before.stat[field] !== after.stat[field]))
271
+ fail("EAGAIN");
272
+ }
273
+ const validate = () => {
274
+ controls.signal?.throwIfAborted();
275
+ runStagingGuard(outerGuard);
276
+ runStagingGuard(bound.validate);
277
+ return true;
278
+ };
279
+ const result = snapshotStagingResolution({ ...bound, path: mapPath(bound.path),
280
+ ancestors: [...prefix.slice(0, -1), ...bound.ancestors.map(map)], traversed: [...prefix.slice(0, -1), ...traversed], validate });
281
+ runStagingGuard(validate);
282
+ return result;
283
+ });
284
+ }
285
+ async prepareAncestry(ancestors, options, conditionalChmod = false) {
286
+ options.signal?.throwIfAborted();
287
+ try {
288
+ const entries = snapshotDirectoryAncestry(ancestors);
289
+ const controls = options.signal === undefined ? {} : { signal: options.signal };
290
+ const groups = [];
291
+ for (const entry of entries) {
292
+ const mount = this.select(entry.path);
293
+ let group = groups.at(-1);
294
+ if (group?.mount !== mount) {
295
+ group = { mount, entries: [] };
296
+ groups.push(group);
297
+ }
298
+ group.entries.push({ path: mount.path === "/" ? entry.path : entry.path.slice(mount.path.length) || "/", stat: entry.stat });
299
+ }
300
+ const guards = [];
301
+ for (const group of groups) {
302
+ controls.signal?.throwIfAborted();
303
+ const backend = group.mount.backend;
304
+ for (const entry of group.entries) {
305
+ let current;
306
+ try {
307
+ current = await backend.lstat(entry.path, controls);
308
+ }
309
+ catch (error) {
310
+ controls.signal?.throwIfAborted();
311
+ if (["ENOENT", "ENOTDIR", "ELOOP"].includes(toFsError(error).code))
312
+ fail("EAGAIN");
313
+ throw error;
314
+ }
315
+ controls.signal?.throwIfAborted();
316
+ if (current.type !== "directory")
317
+ fail("EAGAIN");
318
+ const identity = compareIdentity(current, entry.stat);
319
+ if (identity === "unknown")
320
+ fail("ENOTSUP");
321
+ if (identity !== "same")
322
+ fail("EAGAIN");
323
+ const declared = await backend.capabilitiesFor?.(entry.path, { ...controls, stagingAncestry: true }) ?? backend.capabilities;
324
+ controls.signal?.throwIfAborted();
325
+ if (declared.synchronousDirectoryValidation !== true && !(conditionalChmod && declared.conditionalChmod === true))
326
+ fail("ENOTSUP");
327
+ }
328
+ if (typeof backend.prepareDirectoryAncestry !== "function")
329
+ fail("ENOTSUP");
330
+ const guard = await backend.prepareDirectoryAncestry(group.entries, controls);
331
+ controls.signal?.throwIfAborted();
332
+ if (typeof guard !== "function")
333
+ fail("ENOTSUP");
334
+ guards.push(guard);
335
+ }
336
+ return () => {
337
+ controls.signal?.throwIfAborted();
338
+ for (const guard of guards)
339
+ runStagingGuard(guard);
340
+ return true;
341
+ };
342
+ }
343
+ catch (error) {
344
+ options.signal?.throwIfAborted();
345
+ throw error;
346
+ }
347
+ }
174
348
  protected(path) {
175
349
  return path === "/" || this.mounts.some((mount) => within(path, mount.path));
176
350
  }
@@ -324,23 +498,48 @@ export class MountFileSystem {
324
498
  async resolve(path, options, settings = {}) {
325
499
  let pending = this.components(path);
326
500
  const stack = [await this.lookup("/", options)];
501
+ let recordedUnits = 0;
502
+ const observe = (entry, linkTarget) => {
503
+ if (!settings.resolutionSteps || !entry.stat)
504
+ return;
505
+ recordedUnits += entry.path.length + (linkTarget?.length ?? 0);
506
+ if (settings.resolutionSteps.length >= 4096 || recordedUnits > 1_048_576)
507
+ fail("EFBIG");
508
+ settings.resolutionSteps.push({ path: entry.path, stat: snapshotStat(entry.stat), ...(linkTarget === undefined ? {} : { linkTarget }) });
509
+ };
510
+ observe(stack[0]);
327
511
  let boundary;
328
512
  let verification;
329
513
  let creationVerification;
514
+ let followedFinalLink = false;
330
515
  const verified = async (location) => {
331
- const inspection = !location.stat && settings.resizeCreate === true ? creationVerification : verification;
516
+ let inspection = !location.stat && settings.resizeCreate === true ? creationVerification : verification;
517
+ // A missing entry has no realpath. Verify its existing parent while
518
+ // leaving dangling final symlinks on the ordinary following path.
519
+ if (inspection && !location.stat && settings.allowMissing && !followedFinalLink && inspection.finalName === undefined) {
520
+ const separator = inspection.path.lastIndexOf("/");
521
+ const finalName = inspection.path.slice(separator + 1);
522
+ if (finalName && finalName !== "." && finalName !== "..") {
523
+ inspection = { ...inspection, path: inspection.path.slice(0, separator) || "/", finalName };
524
+ }
525
+ }
332
526
  if (inspection) {
333
527
  const canonical = await inspection.mount.backend.realpath(inspection.path, options);
528
+ options.signal?.throwIfAborted();
529
+ if (!location.stat && inspection.finalName === undefined)
530
+ fail("ENOTSUP");
334
531
  validatePath(canonical);
335
532
  if (!canonical.startsWith("/") || normalizePath(canonical) !== canonical)
336
533
  fail("EIO");
337
534
  if (inspection.finalName !== undefined) {
338
535
  try {
339
536
  await inspection.mount.backend.lstat(`${inspection.path}/${inspection.finalName}`, options);
340
- if (!location.stat && settings.resizeCreate === true)
537
+ options.signal?.throwIfAborted();
538
+ if (!location.stat && settings.allowMissing && settings.followFinal !== false)
341
539
  fail("ENOTSUP");
342
540
  }
343
541
  catch (error) {
542
+ options.signal?.throwIfAborted();
344
543
  if (location.stat || toFsError(error).code !== "ENOENT")
345
544
  throw error;
346
545
  }
@@ -406,6 +605,8 @@ export class MountFileSystem {
406
605
  if (next.stat?.type === "symlink" && (settings.followFinal !== false || !final)) {
407
606
  if (++links > 40)
408
607
  fail("ELOOP");
608
+ if (final)
609
+ followedFinalLink = true;
409
610
  boundary ??= next.mount;
410
611
  if (boundary !== next.mount)
411
612
  fail("EACCES");
@@ -422,6 +623,7 @@ export class MountFileSystem {
422
623
  }
423
624
  const readlink = this.optional(next, "readlink", "symlinks");
424
625
  const target = await readlink.call(next.mount.backend, next.local, options);
626
+ observe(next, target);
425
627
  const targetParts = this.components(target);
426
628
  if (settings.resizeCreate !== undefined && targetParts.at(-1)?.trailing && pending[0]?.trailing)
427
629
  targetParts.pop();
@@ -446,6 +648,7 @@ export class MountFileSystem {
446
648
  pending = [...targetParts, ...pending];
447
649
  }
448
650
  else {
651
+ observe(next);
449
652
  stack.push(next);
450
653
  }
451
654
  }
@@ -672,6 +875,8 @@ export class MountFileSystem {
672
875
  }, undefined, true);
673
876
  }
674
877
  removeEntryConditional(path, options) {
878
+ options = { ...options };
879
+ const requested = options.returnRemainingStat === true;
675
880
  return this.operation("removeEntryConditional", path, options, async () => {
676
881
  const location = await this.resolve(path, options, { followFinal: false, entry: true, allowMissing: true });
677
882
  if (this.protected(location.path))
@@ -679,10 +884,13 @@ export class MountFileSystem {
679
884
  this.mutable(location);
680
885
  const backend = location.mount.backend;
681
886
  await requireOwnedMutation(backend, location.local, "atomicEntryRemoval", options);
887
+ if (requested)
888
+ await requireOwnedMutation(backend, location.local, "atomicEntryRemovalReceipt", options);
682
889
  if (!backend.removeEntryConditional)
683
890
  fail("ENOTSUP");
684
- await backend.removeEntryConditional(location.local, options);
685
- });
891
+ const receipt = await backend.removeEntryConditional(location.local, options);
892
+ return requested && receipt !== undefined ? Object.freeze(snapshotStat(receipt)) : undefined;
893
+ }, undefined, requested);
686
894
  }
687
895
  removeFileConditional(path, options) {
688
896
  return this.operation("removeFileConditional", path, options, async () => {
@@ -710,7 +918,8 @@ export class MountFileSystem {
710
918
  await backend.removeTreeConditional(location.local, options);
711
919
  });
712
920
  }
713
- createStagedFile(path, name, content, options) {
921
+ async createStagedFile(path, name, content, options) {
922
+ options = snapshotStagingCreation(options, path);
714
923
  return this.operation("createStagedFile", path, options, async () => {
715
924
  const location = await this.resolve(path, options, { followFinal: false, entry: true, allowMissing: true, missingDirectory: true });
716
925
  if (this.protected(location.path))
@@ -718,6 +927,8 @@ export class MountFileSystem {
718
927
  this.mutable(location);
719
928
  const backend = location.mount.backend;
720
929
  await requireOwnedMutation(backend, location.local, "atomicFileStaging", options, true);
930
+ if (options.retainCleanup === true)
931
+ await requireOwnedMutation(backend, location.local, "retainedStagingCleanup", options, true);
721
932
  if (!backend.createStagedFile)
722
933
  fail("ENOTSUP");
723
934
  const receipt = await backend.createStagedFile(location.local, name, content, options);
@@ -725,23 +936,103 @@ export class MountFileSystem {
725
936
  path: location.mount.path === "/" ? entry.path : `${location.mount.path}${entry.path === "/" ? "" : entry.path}`,
726
937
  stat: Object.freeze(snapshotStat(entry.stat)),
727
938
  });
728
- return Object.freeze({ parent: map(receipt.parent), directory: map(receipt.directory), file: map(receipt.file) });
939
+ try {
940
+ const staging = { parent: map(receipt.parent), directory: map(receipt.directory), file: map(receipt.file) };
941
+ if (!receipt.cleanup)
942
+ return Object.freeze(staging);
943
+ const backendCleanup = receipt.cleanup;
944
+ const cleanup = createStagingCleanup(staging.directory.path, controls => this.operation("removeStagedFile", staging.directory.path, controls, () => backendCleanup.remove(controls), undefined, true), () => backendCleanup.close());
945
+ return Object.freeze({ ...staging, cleanup });
946
+ }
947
+ catch (error) {
948
+ await finishCleanup(() => receipt.cleanup?.close(), true);
949
+ throw error;
950
+ }
729
951
  }, undefined, true);
730
952
  }
731
953
  publishStagedFile(staging, destination, options) {
732
954
  return this.operation("publishStagedFile", staging.file.path, options, async () => {
955
+ const ancestors = options.ancestors === undefined ? undefined : snapshotDirectoryAncestry(options.ancestors);
956
+ const callerGuard = options.commitGuard;
957
+ let guard;
958
+ if (ancestors) {
959
+ const canonical = normalizePath(globalPath(destination));
960
+ const parent = canonical.slice(0, canonical.lastIndexOf("/")) || "/";
961
+ if (canonical !== destination || ancestors.at(-1)?.path !== parent)
962
+ fail("EINVAL");
963
+ guard = await this.prepareAncestry(ancestors, options);
964
+ runStagingGuard(guard);
965
+ }
733
966
  const local = await this.localStaging(staging, options);
734
967
  const target = await this.resolve(destination, options, { followFinal: false, entry: true, allowMissing: true });
968
+ if (ancestors) {
969
+ if (target.path !== destination)
970
+ fail("EAGAIN");
971
+ if (options.destination === null) {
972
+ if (target.stat !== undefined)
973
+ fail("EAGAIN");
974
+ }
975
+ else {
976
+ if (target.stat === undefined || target.stat.type !== options.destination.type)
977
+ fail("EAGAIN");
978
+ const identity = compareIdentity(target.stat, options.destination);
979
+ if (identity === "unknown")
980
+ fail("ENOTSUP");
981
+ if (identity !== "same")
982
+ fail("EAGAIN");
983
+ }
984
+ }
735
985
  if (this.protected(target.path))
736
986
  fail("EBUSY");
737
987
  if (local.mount !== target.mount)
738
988
  fail("EXDEV");
739
989
  this.mutable(target);
740
990
  await requireOwnedMutation(local.mount.backend, local.staging.directory.path, "atomicFileStaging", options);
991
+ await requireOwnedMutation(local.mount.backend, target.local, "atomicFileStaging", options, options.destination === null);
741
992
  if (!local.mount.backend.publishStagedFile)
742
993
  fail("ENOTSUP");
743
- await local.mount.backend.publishStagedFile(local.staging, target.local, options);
744
- }, destination);
994
+ if (ancestors || callerGuard !== undefined)
995
+ await requireOwnedMutation(local.mount.backend, target.local, "guardedStagingPublication", options, options.destination === null);
996
+ if (!ancestors) {
997
+ await local.mount.backend.publishStagedFile(local.staging, target.local, options);
998
+ return;
999
+ }
1000
+ const parent = target.path.slice(0, target.path.lastIndexOf("/")) || "/";
1001
+ if (target.path !== normalizePath(globalPath(destination)) || ancestors.at(-1)?.path !== parent)
1002
+ fail("EINVAL");
1003
+ try {
1004
+ const declared = await local.mount.backend.capabilitiesFor?.(target.local, { ...options, stagingAncestry: true, ...(options.destination === null ? { create: true } : {}) }) ?? local.mount.backend.capabilities;
1005
+ if (!await this.supportsStagingAncestry(target, declared, options))
1006
+ fail("ENOTSUP");
1007
+ }
1008
+ catch (error) {
1009
+ options.signal?.throwIfAborted();
1010
+ if (["ENOTSUP", "ENOENT", "ENOTDIR", "ELOOP"].includes(toFsError(error).code)) {
1011
+ await inspectStagingBindings(async (path) => {
1012
+ const entry = await this.lookup(path, options);
1013
+ if (!entry.stat)
1014
+ fail("ENOENT");
1015
+ return entry.stat;
1016
+ }, destination, options);
1017
+ }
1018
+ throw error;
1019
+ }
1020
+ const first = ancestors.findIndex(entry => entry.path === target.mount.path);
1021
+ if (first < 0)
1022
+ fail("EINVAL");
1023
+ const localAncestors = ancestors.slice(first).map(entry => ({
1024
+ path: target.mount.path === "/" ? entry.path : entry.path.slice(target.mount.path.length) || "/", stat: entry.stat,
1025
+ }));
1026
+ await local.mount.backend.publishStagedFile(local.staging, target.local, {
1027
+ ...options, ancestors: localAncestors,
1028
+ commitGuard: () => {
1029
+ if (callerGuard !== undefined)
1030
+ runStagingGuard(callerGuard);
1031
+ runStagingGuard(guard);
1032
+ return true;
1033
+ },
1034
+ });
1035
+ }, destination, true);
745
1036
  }
746
1037
  removeStagedFile(staging, options = {}) {
747
1038
  return this.operation("removeStagedFile", staging.directory.path, options, async () => {
@@ -895,9 +1186,29 @@ export class MountFileSystem {
895
1186
  }
896
1187
  chmod(path, mode, options = {}) {
897
1188
  return this.operation("chmod", path, options, async () => {
898
- const location = await this.resolve(path, options);
1189
+ const conditional = snapshotConditionalChmod(path, options);
1190
+ const controls = options.signal === undefined ? {} : { signal: options.signal };
1191
+ const location = await this.resolve(path, controls, conditional ? { followFinal: false } : {});
899
1192
  this.mutable(location);
900
- await this.optional(location, "chmod", "permissions").call(location.mount.backend, location.local, mode, options);
1193
+ const chmod = this.optional(location, "chmod", "permissions");
1194
+ if (!conditional)
1195
+ return chmod.call(location.mount.backend, location.local, mode, controls);
1196
+ if (location.path !== path)
1197
+ fail("EAGAIN");
1198
+ const backend = location.mount.backend;
1199
+ const capabilities = await backend.capabilitiesFor?.(location.local, { ...controls, conditionalChmod: true }) ?? backend.capabilities;
1200
+ if (capabilities.conditionalChmod !== true)
1201
+ fail("ENOTSUP");
1202
+ const guard = await this.prepareAncestry(conditional.ancestors, controls, true);
1203
+ const ancestors = conditional.ancestors.filter(entry => this.select(entry.path) === location.mount).map(entry => ({
1204
+ path: location.mount.path === "/" ? entry.path : entry.path.slice(location.mount.path.length) || "/", stat: entry.stat,
1205
+ }));
1206
+ await chmod.call(backend, location.local, mode, { ...conditional, ancestors, commitGuard: () => {
1207
+ if (conditional.commitGuard)
1208
+ runStagingGuard(conditional.commitGuard);
1209
+ runStagingGuard(guard);
1210
+ return true;
1211
+ } });
901
1212
  });
902
1213
  }
903
1214
  utimes(path, atimeMs, mtimeMs, options = {}) {
@@ -15,6 +15,8 @@ export function withObjectFileDescriptors(filesystem, store, options = {}) {
15
15
  || store.createStaging !== undefined && typeof store.createStaging !== "function")
16
16
  throw new TypeError("Invalid object descriptor configuration");
17
17
  const createStaging = store.createStaging?.bind(store);
18
+ const defaultIdentityScope = Symbol("object-file-descriptors");
19
+ const withIdentity = (stat) => stat.type === "file" && stat.identityScope === undefined ? { ...stat, identityScope: defaultIdentityScope } : stat;
18
20
  let stagedBytes = 0;
19
21
  let openFiles = 0;
20
22
  const stagingWaiters = new Set();
@@ -58,7 +60,7 @@ export function withObjectFileDescriptors(filesystem, store, options = {}) {
58
60
  throw new FsError("EIO", { message: "Invalid immutable object version" });
59
61
  if (value.stat.size > maxFileBytes)
60
62
  throw new FsError("EFBIG", { message: "Object descriptor file limit exceeded" });
61
- return Object.freeze({ revision: value.revision, ...(value.publicationToken === undefined ? {} : { publicationToken: value.publicationToken }), stat: Object.freeze({ ...value.stat }), read: value.read.bind(value), close: value.close.bind(value) });
63
+ return Object.freeze({ revision: value.revision, ...(value.publicationToken === undefined ? {} : { publicationToken: value.publicationToken }), stat: Object.freeze(withIdentity(value.stat)), read: value.read.bind(value), close: value.close.bind(value) });
62
64
  };
63
65
  const clearPages = (state) => {
64
66
  stagedBytes -= state.pages.size * chunkBytes;
@@ -434,17 +436,91 @@ export function withObjectFileDescriptors(filesystem, store, options = {}) {
434
436
  throw error;
435
437
  }
436
438
  });
439
+ const openReadFile = async (path, readOptions = {}) => {
440
+ if (!readOptions || typeof readOptions !== "object"
441
+ || Object.keys(readOptions).some(key => key !== "signal" && key !== "allowDirectory")
442
+ || readOptions.allowDirectory !== undefined && typeof readOptions.allowDirectory !== "boolean") {
443
+ throw new FsError("EINVAL", { syscall: "openReadFile", path });
444
+ }
445
+ readOptions.signal?.throwIfAborted();
446
+ if (readOptions.allowDirectory === true)
447
+ throw new FsError("ENOTSUP", { syscall: "openReadFile", path });
448
+ const descriptor = await open(path, {
449
+ access: "read",
450
+ ...(readOptions.signal === undefined ? {} : { signal: readOptions.signal }),
451
+ });
452
+ let retainedStat;
453
+ try {
454
+ retainedStat = await descriptor.stat(readOptions.signal === undefined ? {} : { signal: readOptions.signal });
455
+ }
456
+ catch (error) {
457
+ await finishCleanup(() => descriptor.close(), true);
458
+ throw error;
459
+ }
460
+ let closed = false;
461
+ let closing;
462
+ const assertOpen = (forwarded, syscall) => {
463
+ forwarded.signal?.throwIfAborted();
464
+ if (closed)
465
+ throw new FsError("EBADF", { syscall, path });
466
+ };
467
+ return {
468
+ async stat(forwarded = {}) {
469
+ assertOpen(forwarded, "fstat");
470
+ return descriptor.stat(forwarded);
471
+ },
472
+ async seekEnd(forwarded = {}) {
473
+ assertOpen(forwarded, "lseek");
474
+ const stat = await descriptor.stat(forwarded);
475
+ return BigInt(stat.size);
476
+ },
477
+ async read(position, maxBytes, forwarded = {}) {
478
+ assertOpen(forwarded, "read");
479
+ if (!Number.isSafeInteger(position) || position < 0
480
+ || !Number.isSafeInteger(maxBytes) || maxBytes <= 0
481
+ || maxBytes > Number.MAX_SAFE_INTEGER - position) {
482
+ throw new FsError("EINVAL", { syscall: "read", path });
483
+ }
484
+ const length = Math.min(maxBytes, Math.max(0, retainedStat.size - position));
485
+ if (length === 0) {
486
+ await descriptor.stat(forwarded);
487
+ return new Uint8Array();
488
+ }
489
+ const buffer = new Uint8Array(length);
490
+ const count = await descriptor.read(buffer, position, forwarded);
491
+ return count === buffer.byteLength ? buffer : buffer.subarray(0, count);
492
+ },
493
+ close() {
494
+ closed = true;
495
+ return closing ??= descriptor.close();
496
+ },
497
+ };
498
+ };
437
499
  return new Proxy(Object.create(filesystem), {
438
500
  get(_target, property) {
439
501
  if (property === "open")
440
502
  return open;
503
+ if (property === "openReadFile")
504
+ return openReadFile;
505
+ if (property === "stat" || property === "lstat")
506
+ return async (path, statOptions) => withIdentity(await filesystem[property](path, statOptions));
441
507
  if (property === "capabilities")
442
- return { ...filesystem.capabilities, open: true, versionedDescriptors: true };
508
+ return {
509
+ ...filesystem.capabilities,
510
+ open: true,
511
+ versionedDescriptors: true,
512
+ retainedRead: filesystem.capabilities.read !== false,
513
+ };
443
514
  if (property === "capabilitiesFor")
444
515
  return async (path, query) => {
445
516
  const capabilities = await filesystem.capabilitiesFor?.(path, query) ?? filesystem.capabilities;
446
517
  query?.signal?.throwIfAborted();
447
- return { ...capabilities, open: true, versionedDescriptors: true };
518
+ return {
519
+ ...capabilities,
520
+ open: true,
521
+ versionedDescriptors: true,
522
+ ...(query?.allowDirectory ? {} : { retainedRead: capabilities.read !== false }),
523
+ };
448
524
  };
449
525
  const value = Reflect.get(filesystem, property, filesystem);
450
526
  return typeof value === "function" ? value.bind(filesystem) : value;
@@ -1,6 +1,6 @@
1
1
  import type { ByteSource } from "../../contracts/io.js";
2
2
  import type { FileDescriptor, OpenFileOptions } from "../../contracts/descriptor.js";
3
- import type { CreateStagedFileOptions, FileStaging, PublishStagedFileOptions, StagedFileContent, AppendFileOptions, CapabilityQueryOptions, CopyFileOptions, DirectoryEntry, OpenReadFileOptions, FileReadHandle, FileStat, FileSystem, FileSystemCapabilities, FsOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, WriteFileOptions } from "../../contracts/filesystem.js";
3
+ import type { CreateStagedFileOptions, FileStaging, PublishStagedFileOptions, StagedFileContent, AppendFileOptions, CapabilityQueryOptions, CopyFileOptions, DirectoryEntry, OpenReadFileOptions, FileReadHandle, FileStat, FileSystem, FileSystemCapabilities, FsOptions, ChmodOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, WriteFileOptions } from "../../contracts/filesystem.js";
4
4
  export interface OverlayFileSystemOptions {
5
5
  readonly upper: FileSystem;
6
6
  readonly lower: FileSystem;
@@ -73,7 +73,7 @@ export declare class OverlayFileSystem implements FileSystem {
73
73
  symlink(target: string, path: string, options?: FsOptions): Promise<void>;
74
74
  link(_existingPath: string, newPath: string, options?: FsOptions): Promise<void>;
75
75
  private metadata;
76
- chmod(path: string, permissions: number, options?: FsOptions): Promise<void>;
76
+ chmod(path: string, permissions: number, options?: ChmodOptions): Promise<void>;
77
77
  utimes(path: string, atimeMs: number, mtimeMs: number, options?: FsOptions): Promise<void>;
78
78
  truncate(path: string, length?: number, options?: FsOptions): Promise<void>;
79
79
  readStream(path: string, options?: ReadStreamOptions): ByteSource;