@poe-platform/safe-fs 0.1.727 → 0.1.728

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 (57) hide show
  1. package/README.md +8 -1
  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 +183 -27
  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 +34 -48
  19. package/dist/safe-fs/fs/memory/index.js +1150 -235
  20. package/dist/safe-fs/fs/memory/ledger.d.ts +2 -0
  21. package/dist/safe-fs/fs/memory/ledger.js +15 -3
  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 +22 -2
  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 +1 -1
  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 +48 -16
  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/platform/browser.d.ts +5 -0
  48. package/dist/safe-fs/platform/browser.js +17 -0
  49. package/dist/safe-fs/platform/node.d.ts +5 -0
  50. package/dist/safe-fs/platform/node.js +28 -0
  51. package/dist/safe-fs/platform/transport-budget.d.ts +7 -0
  52. package/dist/safe-fs/platform/transport-budget.js +4 -0
  53. package/dist/safe-fs/testing/object-io-metrics.d.ts +3 -0
  54. package/dist/safe-fs/testing/object-io-metrics.js +29 -0
  55. package/dist/safe-fs/testing/object-publication.d.ts +3 -1
  56. package/dist/safe-fs/testing/object-publication.js +2 -2
  57. package/package.json +1 -1
@@ -1,9 +1,15 @@
1
- import { FsError } from "../contracts/errors.js";
1
+ import { FsError, toFsError } from "../contracts/errors.js";
2
+ import { validatePath } from "../contracts/virtual-path.js";
2
3
  import { finishCleanup } from "../contracts/cleanup.js";
3
4
  import { registerEntryView } from "./mount/comparison.js";
5
+ import { getScopedTransportBudget, runScopedTransportBudget, withScopedTransportBudget } from "#safe-fs-platform";
6
+ import { hasRegisteredS3FileSystem } from "../platform/transport-budget.js";
4
7
  import { openRetainedResizeFile, retainedResizeCapabilities, ownedMutationCapabilities, requireOwnedMutation } from "./capabilities.js";
8
+ import { createStagingCleanup, snapshotStagingCreation } from "./staging-cleanup.js";
9
+ import { inspectStagingBindings, runStagingGuard, snapshotDirectoryAncestry, snapshotStagingResolution } from "./staging-ancestry.js";
5
10
  const originals = new WeakMap();
6
11
  const operations = new Set([
12
+ "prepareDirectoryAncestry", "prepareStagingResolution",
7
13
  "publishFileConditional", "removeEntryConditional", "removeTreeConditional", "writeFileConditional", "removeFileConditional", "createStagedFile", "publishStagedFile", "removeStagedFile", "prepareDirectory",
8
14
  "confineExtraction", "access", "appendFile", "canonicalizeMissingTarget", "capabilitiesFor", "chmod", "compareEntry",
9
15
  "copyFile", "link", "lstat", "mkdir", "openReadFile", "openResizeFile", "readFile", "readStream", "readdir",
@@ -13,6 +19,7 @@ const operations = new Set([
13
19
  export function scopeFileSystem(filesystem, charge, signal, cleanupCharge = charge, options = {}) {
14
20
  const original = originals.get(filesystem)?.filesystem ?? filesystem;
15
21
  const creationMask = options.creationMask ?? originals.get(filesystem)?.creationMask;
22
+ const maxPathComponents = options.maxPathComponents ?? originals.get(filesystem)?.maxPathComponents;
16
23
  if (creationMask !== undefined && (!Number.isInteger(creationMask) || creationMask < 0 || creationMask > 0o777))
17
24
  throw new RangeError("creationMask must be a permission mask between 0 and 0777");
18
25
  const creationOptions = (settings, directory = false) => {
@@ -161,56 +168,96 @@ export function scopeFileSystem(filesystem, charge, signal, cleanupCharge = char
161
168
  close: () => closing ??= Promise.resolve().then(() => descriptor.close()),
162
169
  };
163
170
  };
164
- const wrapStream = (source, options) => ({
165
- [Symbol.asyncIterator]() {
166
- assertOpen(options);
167
- const iterator = source[Symbol.asyncIterator]();
168
- let closing;
169
- const close = (value) => closing ??= Promise.resolve().then(() => iterator.return ? iterator.return(value) : { done: true, value: undefined });
170
- const advance = async (operation) => {
171
- try {
172
- assertOpen(options);
173
- if (closing) {
174
- await closing;
175
- return { done: true, value: undefined };
171
+ const wrapStream = (source, options) => {
172
+ const budgetFrames = getScopedTransportBudget();
173
+ return {
174
+ [Symbol.asyncIterator]() {
175
+ assertOpen(options);
176
+ const iterator = source[Symbol.asyncIterator]();
177
+ let closing;
178
+ const close = (value) => closing ??= Promise.resolve().then(() => iterator.return ? iterator.return(value) : { done: true, value: undefined });
179
+ const advance = async (operation) => {
180
+ try {
181
+ assertOpen(options);
182
+ if (closing) {
183
+ await closing;
184
+ return { done: true, value: undefined };
185
+ }
186
+ const result = await withScopedTransportBudget(budgetFrames, operation);
187
+ assertOpen(options);
188
+ if (closing) {
189
+ await closing;
190
+ return { done: true, value: undefined };
191
+ }
192
+ return result;
176
193
  }
177
- const result = await operation();
178
- assertOpen(options);
179
- if (closing) {
180
- await closing;
181
- return { done: true, value: undefined };
194
+ catch (error) {
195
+ await finishCleanup(close, true);
196
+ throw error;
182
197
  }
183
- return result;
184
- }
185
- catch (error) {
186
- await finishCleanup(close, true);
187
- throw error;
188
- }
189
- };
190
- return {
191
- next: () => advance(() => iterator.next()),
192
- return: close,
193
- ...(iterator.throw ? { throw: (error) => advance(() => iterator.throw(error)) } : {}),
194
- };
195
- },
196
- });
198
+ };
199
+ return {
200
+ next: () => advance(() => iterator.next()),
201
+ return: close,
202
+ ...(iterator.throw ? { throw: (error) => advance(() => iterator.throw(error)) } : {}),
203
+ };
204
+ },
205
+ };
206
+ };
207
+ let cachedRawCaps;
208
+ let cachedCreateStaged;
209
+ let cachedOpenResize;
210
+ let cachedComputedCaps;
197
211
  const view = new Proxy(Object.create(original), {
198
212
  set(_target, property, value) {
213
+ cachedComputedCaps = undefined;
199
214
  return Reflect.set(original, property, value, original);
200
215
  },
201
216
  get(_target, property) {
202
- if (property === "capabilities")
203
- return ownedMutationCapabilities(original, retainedResizeCapabilities(original));
217
+ if (property === "capabilities") {
218
+ const rawCaps = original.capabilities;
219
+ if (cachedComputedCaps !== undefined &&
220
+ cachedRawCaps === rawCaps &&
221
+ cachedCreateStaged === original.createStagedFile &&
222
+ cachedOpenResize === original.openResizeFile) {
223
+ return cachedComputedCaps;
224
+ }
225
+ const computed = ownedMutationCapabilities(original, retainedResizeCapabilities(original, rawCaps));
226
+ cachedRawCaps = rawCaps;
227
+ cachedCreateStaged = original.createStagedFile;
228
+ cachedOpenResize = original.openResizeFile;
229
+ cachedComputedCaps = computed;
230
+ return computed;
231
+ }
204
232
  const method = Reflect.get(original, property, original);
205
233
  if (typeof method !== "function")
206
234
  return method;
207
235
  const cached = methods.get(property);
208
236
  if (cached?.original === method)
209
237
  return cached.scoped;
210
- const dispatch = (...args) => {
238
+ const credit = operations.has(property) ? 1 : 0;
239
+ const executeDispatch = (args) => {
240
+ if (property === "removeEntryConditional")
241
+ args[1] = resizeOptions({ ...args[1] });
242
+ let stagingSignal;
243
+ if (property === "createStagedFile" || property === "publishStagedFile") {
244
+ signal.throwIfAborted();
245
+ if (property === "createStagedFile")
246
+ args[3] = snapshotStagingCreation(args[3], args[0]);
247
+ const callerSignal = args[property === "createStagedFile" ? 3 : 2]?.signal;
248
+ stagingSignal = callerSignal && callerSignal !== signal ? AbortSignal.any([signal, callerSignal]) : signal;
249
+ stagingSignal.throwIfAborted();
250
+ }
211
251
  if (operations.has(property)) {
212
252
  const options = args.at(-1);
213
- admit(options && typeof options === "object" && "signal" in options ? options : undefined);
253
+ admit(stagingSignal ? { signal: stagingSignal }
254
+ : options && typeof options === "object" && "signal" in options ? options : undefined);
255
+ if (property !== "symlink" && typeof args[0] === "string")
256
+ validatePath(args[0], maxPathComponents);
257
+ if (["copyFile", "rename", "link", "compareEntry"].includes(String(property)) && typeof args[1] === "string")
258
+ validatePath(args[1], maxPathComponents);
259
+ if (property === "symlink" && typeof args[1] === "string")
260
+ validatePath(args[1], maxPathComponents);
214
261
  }
215
262
  if (["writeFile", "appendFile", "writeStream", "mkdir"].includes(String(property))) {
216
263
  const index = property === "mkdir" ? 1 : 2;
@@ -219,15 +266,89 @@ export function scopeFileSystem(filesystem, charge, signal, cleanupCharge = char
219
266
  if (["publishFileConditional", "removeEntryConditional", "removeTreeConditional", "writeFileConditional", "removeFileConditional", "createStagedFile", "publishStagedFile", "removeStagedFile", "prepareDirectory"].includes(String(property)))
220
267
  return (async () => {
221
268
  const path = typeof args[0] === "string" ? args[0] : args[0].directory.path;
222
- const options = args[property === "createStagedFile" ? 3 : property === "publishFileConditional" || property === "publishStagedFile" || property === "writeFileConditional" ? 2 : 1];
223
- const create = property === "createStagedFile" || (property === "publishFileConditional" || property === "writeFileConditional" || property === "prepareDirectory") && options !== undefined && "expected" in options && options.expected === null;
224
- await requireOwnedMutation(original, path, property === "publishFileConditional" ? "atomicFilePublication" : property === "removeEntryConditional" ? "atomicEntryRemoval" : property === "removeTreeConditional" ? "atomicTreeRemoval" : property === "prepareDirectory" ? "atomicDirectoryMetadata" : property === "writeFileConditional" || property === "removeFileConditional" ? "atomicFileMutation" : "atomicFileStaging", options ?? {}, create);
225
- assertOpen(options);
226
- if (property === "publishFileConditional") {
227
- args[1] = wrapStream(args[1], options);
228
- args[2] = resizeOptions(options ?? {});
269
+ const optionIndex = property === "createStagedFile" ? 3 : property === "publishFileConditional" || property === "publishStagedFile" || property === "writeFileConditional" ? 2 : 1;
270
+ const supplied = args[optionIndex];
271
+ const removalReceipt = property === "removeEntryConditional" && supplied !== undefined && "returnRemainingStat" in supplied && supplied.returnRemainingStat === true;
272
+ const options = stagingSignal ? { ...supplied, signal: stagingSignal } : supplied;
273
+ if (property === "createStagedFile" || property === "removeEntryConditional")
274
+ args[optionIndex] = options;
275
+ const assertReady = () => {
276
+ if (property === "createStagedFile" || property === "publishStagedFile")
277
+ options?.signal?.throwIfAborted();
278
+ assertOpen(options);
279
+ };
280
+ const callerGuard = property === "publishStagedFile" ? supplied?.commitGuard : undefined;
281
+ try {
282
+ const create = property === "createStagedFile" || (property === "publishFileConditional" || property === "writeFileConditional" || property === "prepareDirectory") && options !== undefined && "expected" in options && options.expected === null;
283
+ await requireOwnedMutation(original, path, property === "publishFileConditional" ? "atomicFilePublication" : property === "removeEntryConditional" ? "atomicEntryRemoval" : property === "removeTreeConditional" ? "atomicTreeRemoval" : property === "prepareDirectory" ? "atomicDirectoryMetadata" : property === "writeFileConditional" || property === "removeFileConditional" ? "atomicFileMutation" : "atomicFileStaging", options ?? {}, create);
284
+ if (removalReceipt)
285
+ await requireOwnedMutation(original, path, "atomicEntryRemovalReceipt", options);
286
+ if (property === "createStagedFile" && options && "retainCleanup" in options && options.retainCleanup === true) {
287
+ await requireOwnedMutation(original, path, "retainedStagingCleanup", options, true);
288
+ }
289
+ if (property === "publishStagedFile") {
290
+ const publishOptions = (options ?? {});
291
+ await requireOwnedMutation(original, args[1], "atomicFileStaging", publishOptions, publishOptions.destination === null);
292
+ const query = options.destination === null ? { ...options, create: true } : options;
293
+ const declared = ownedMutationCapabilities(original, await original.capabilitiesFor?.(args[1], query) ?? original.capabilities);
294
+ assertReady();
295
+ if (callerGuard !== undefined && declared.guardedStagingPublication !== true)
296
+ throw new FsError("ENOTSUP", { path: args[1], syscall: "guardedStagingPublication" });
297
+ // Guarded publication preserves the caller signal and observes scope
298
+ // cancellation at commit. Unguarded backends need a composed signal.
299
+ if (declared.guardedStagingPublication === true)
300
+ args[optionIndex] = {
301
+ ...supplied,
302
+ commitGuard: () => {
303
+ assertReady();
304
+ if (callerGuard !== undefined)
305
+ runStagingGuard(callerGuard);
306
+ assertReady();
307
+ return true;
308
+ },
309
+ };
310
+ else
311
+ args[optionIndex] = options;
312
+ }
313
+ if (property === "publishStagedFile" && options && "ancestors" in options && options.ancestors !== undefined)
314
+ await requireOwnedMutation(original, args[1], "atomicStagingAncestry", options, options.destination === null);
315
+ assertReady();
316
+ if (property === "publishFileConditional") {
317
+ args[1] = wrapStream(args[1], options);
318
+ args[2] = resizeOptions(options ?? {});
319
+ }
320
+ }
321
+ catch (error) {
322
+ assertReady();
323
+ if (property === "publishStagedFile" && supplied && "ancestors" in supplied && supplied.ancestors !== undefined
324
+ && ["ENOTSUP", "ENOENT", "ENOTDIR", "ELOOP"].includes(toFsError(error).code)) {
325
+ await inspectStagingBindings(path => original.lstat(path, options), args[1], options);
326
+ }
327
+ throw error;
328
+ }
329
+ if (property === "removeEntryConditional") {
330
+ const receipt = await Reflect.apply(method, original, args);
331
+ return removalReceipt && receipt !== undefined ? Object.freeze({ ...receipt }) : undefined;
332
+ }
333
+ if (property !== "createStagedFile")
334
+ return Reflect.apply(method, original, args);
335
+ const staging = await Reflect.apply(method, original, args);
336
+ if (!staging.cleanup)
337
+ return staging;
338
+ const backendCleanup = staging.cleanup;
339
+ try {
340
+ const cleanup = createStagingCleanup(staging.directory.path, controls => {
341
+ controls.signal?.throwIfAborted();
342
+ cleanupCharge();
343
+ controls.signal?.throwIfAborted();
344
+ return backendCleanup.remove(controls);
345
+ }, () => backendCleanup.close());
346
+ return Object.freeze({ ...staging, cleanup });
347
+ }
348
+ catch (error) {
349
+ await finishCleanup(() => backendCleanup.close(), true);
350
+ throw error;
229
351
  }
230
- return Reflect.apply(method, original, args);
231
352
  })();
232
353
  if (property === "compareEntry") {
233
354
  const peer = args[1];
@@ -244,47 +365,113 @@ export function scopeFileSystem(filesystem, charge, signal, cleanupCharge = char
244
365
  });
245
366
  return Reflect.apply(method, original, args);
246
367
  })();
247
- return Reflect.apply(method, original, args);
368
+ const result = Reflect.apply(method, original, args);
369
+ return property === "readStream" ? wrapStream(result, args[1]) : result;
248
370
  };
249
- const scoped = property === "confineExtraction"
250
- ? async (...args) => scopeFileSystem(await dispatch(...args), charge, signal, cleanupCharge, options)
251
- : property === "open"
252
- ? async (path, options) => {
253
- admit(options);
254
- const selected = resizeOptions(creationOptions(options));
255
- let descriptor;
371
+ const dispatch = (...args) => hasRegisteredS3FileSystem
372
+ ? runScopedTransportBudget(admit, () => executeDispatch(args), credit)
373
+ : executeDispatch(args);
374
+ const scoped = property === "prepareStagingResolution"
375
+ ? async (path, options = {}) => {
376
+ const controls = resizeOptions({ ...options });
377
+ controls.signal?.throwIfAborted();
378
+ admit(controls);
379
+ try {
380
+ const declared = ownedMutationCapabilities(original, await original.capabilitiesFor?.(path, { ...controls, stagingResolution: true }) ?? original.capabilities);
381
+ controls.signal?.throwIfAborted();
382
+ if (declared.synchronousStagingResolution !== true)
383
+ throw new FsError("ENOTSUP", { path });
384
+ const resolution = snapshotStagingResolution(await Reflect.apply(method, original, [path, controls]));
385
+ controls.signal?.throwIfAborted();
386
+ return snapshotStagingResolution({ ...resolution, validate: () => {
387
+ controls.signal?.throwIfAborted();
388
+ admit(controls);
389
+ controls.signal?.throwIfAborted();
390
+ runStagingGuard(resolution.validate);
391
+ return true;
392
+ } });
393
+ }
394
+ catch (error) {
395
+ controls.signal?.throwIfAborted();
396
+ throw error;
397
+ }
398
+ }
399
+ : property === "prepareDirectoryAncestry"
400
+ ? async (...args) => {
401
+ const options = (args[1] ?? {});
402
+ const controls = resizeOptions(options);
403
+ admit(controls);
404
+ controls.signal?.throwIfAborted();
405
+ const entries = snapshotDirectoryAncestry(args[0]);
256
406
  try {
257
- descriptor = await Reflect.apply(method, original, [path, selected]);
258
- assertOpen(options);
259
- return wrapDescriptor(descriptor);
407
+ for (const entry of entries) {
408
+ const declared = await original.capabilitiesFor?.(entry.path, { ...controls, stagingAncestry: true }) ?? original.capabilities;
409
+ controls.signal?.throwIfAborted();
410
+ if (declared.synchronousDirectoryValidation !== true)
411
+ throw new FsError("ENOTSUP", { path: entry.path });
412
+ }
413
+ const guard = await Reflect.apply(method, original, [entries, controls]);
414
+ controls.signal?.throwIfAborted();
415
+ if (typeof guard !== "function")
416
+ throw new FsError("ENOTSUP", { syscall: "prepareDirectoryAncestry" });
417
+ return () => {
418
+ controls.signal?.throwIfAborted();
419
+ admit(controls);
420
+ controls.signal?.throwIfAborted();
421
+ runStagingGuard(guard);
422
+ return true;
423
+ };
260
424
  }
261
425
  catch (error) {
262
- if (descriptor)
263
- await finishCleanup(() => descriptor.close(), true);
264
- assertOpen(options);
426
+ controls.signal?.throwIfAborted();
265
427
  throw error;
266
428
  }
267
429
  }
268
- : property === "openResizeFile"
269
- ? async (path, options = {}) => {
270
- admit(options);
271
- assertOpen(options);
272
- return wrapResizeHandle(await openRetainedResizeFile(original, path, resizeOptions(creationOptions(options))));
273
- }
274
- : property === "capabilitiesFor"
275
- ? async (...args) => ownedMutationCapabilities(original, retainedResizeCapabilities(original, await dispatch(...args)))
276
- : property === "openReadFile"
277
- ? async (...args) => wrapHandle(await dispatch(...args))
278
- : property === "readStream"
279
- ? (...args) => wrapStream(dispatch(...args), args[1])
280
- : operations.has(property) && property !== "canonicalizeMissingTarget"
281
- ? async (...args) => dispatch(...args)
282
- : dispatch;
430
+ : property === "confineExtraction"
431
+ ? async (...args) => scopeFileSystem(await dispatch(...args), charge, signal, cleanupCharge, options)
432
+ : property === "open"
433
+ ? async (path, options) => {
434
+ admit(options);
435
+ const selected = resizeOptions(creationOptions(options));
436
+ let descriptor;
437
+ try {
438
+ descriptor = await Reflect.apply(method, original, [path, selected]);
439
+ assertOpen(options);
440
+ return wrapDescriptor(descriptor);
441
+ }
442
+ catch (error) {
443
+ if (descriptor)
444
+ await finishCleanup(() => descriptor.close(), true);
445
+ assertOpen(options);
446
+ throw error;
447
+ }
448
+ }
449
+ : property === "openResizeFile"
450
+ ? async (path, options = {}) => {
451
+ admit(options);
452
+ assertOpen(options);
453
+ return wrapResizeHandle(await openRetainedResizeFile(original, path, resizeOptions(creationOptions(options))));
454
+ }
455
+ : property === "capabilitiesFor"
456
+ ? async (...args) => ownedMutationCapabilities(original, retainedResizeCapabilities(original, await dispatch(...args)))
457
+ : property === "openReadFile"
458
+ ? async (...args) => wrapHandle(await dispatch(...args))
459
+ : operations.has(property) && property !== "canonicalizeMissingTarget" && property !== "readStream"
460
+ ? (...args) => {
461
+ try {
462
+ const res = dispatch(...args);
463
+ return res instanceof Promise ? res : Promise.resolve(res);
464
+ }
465
+ catch (error) {
466
+ return Promise.reject(error);
467
+ }
468
+ }
469
+ : dispatch;
283
470
  methods.set(property, { original: method, scoped });
284
471
  return scoped;
285
472
  },
286
473
  });
287
- originals.set(view, { filesystem: original, signal, cleanupCharge, creationMask });
474
+ originals.set(view, { filesystem: original, signal, cleanupCharge, creationMask, maxPathComponents });
288
475
  registerEntryView(view, async (path, options) => {
289
476
  assertOpen(options);
290
477
  return { filesystem: original, path };
@@ -0,0 +1,8 @@
1
+ import type { FileStagingEntry, FileStagingResolution, FileStat, PublishStagedFileOptions } from "../contracts/filesystem.js";
2
+ export declare function directoryAncestryPaths(directory: string): string[];
3
+ export declare function snapshotDirectoryAncestry(ancestors: readonly FileStagingEntry[]): readonly FileStagingEntry[];
4
+ export declare function runStagingGuard(guard: () => true): void;
5
+ export declare function snapshotStagingResolution(resolution: FileStagingResolution): FileStagingResolution;
6
+ /** Diagnose failed admission against supplied receipts; this is never a commit
7
+ * guard. Successful publication still requires the backend's atomic checks. */
8
+ export declare function inspectStagingBindings(read: (path: string) => Promise<FileStat>, destination: string, options: PublishStagedFileOptions): Promise<void>;
@@ -0,0 +1,115 @@
1
+ import { FsError, toFsError } from "../contracts/errors.js";
2
+ import { compareIdentity } from "./mount/identity.js";
3
+ import { normalizePath, validatePath } from "../contracts/virtual-path.js";
4
+ export function directoryAncestryPaths(directory) {
5
+ validatePath(directory);
6
+ if (!directory.startsWith("/") || normalizePath(directory) !== directory)
7
+ throw new FsError("EINVAL", { path: directory });
8
+ let path = "";
9
+ return ["/", ...directory.split("/").filter(Boolean).map(component => path += `/${component}`)];
10
+ }
11
+ export function snapshotDirectoryAncestry(ancestors) {
12
+ const entries = ancestors.map(entry => Object.freeze({ path: entry.path, stat: Object.freeze({ ...entry.stat }) }));
13
+ const paths = directoryAncestryPaths(entries.at(-1)?.path ?? "");
14
+ if (entries.length !== paths.length || entries.some((entry, index) => entry.path !== paths[index])) {
15
+ throw new FsError("EINVAL", { message: "directory ancestry requires a complete ordered root-to-directory list" });
16
+ }
17
+ for (const entry of entries) {
18
+ if (entry.stat.type !== "directory")
19
+ throw new FsError("EINVAL", { path: entry.path });
20
+ if (compareIdentity(entry.stat, entry.stat) !== "same")
21
+ throw new FsError("ENOTSUP", { path: entry.path });
22
+ }
23
+ return Object.freeze(entries);
24
+ }
25
+ export function runStagingGuard(guard) {
26
+ const result = guard();
27
+ if (result !== true) {
28
+ // A void callback would accept async implementations. Require a literal
29
+ // success value and observe a misdeclared asynchronous rejection.
30
+ void Promise.resolve(result).catch(() => { });
31
+ throw new FsError("ENOTSUP", { message: "staging validation must return true synchronously" });
32
+ }
33
+ }
34
+ export function snapshotStagingResolution(resolution) {
35
+ const path = resolution.path;
36
+ validatePath(path);
37
+ const ancestors = snapshotDirectoryAncestry(resolution.ancestors);
38
+ const validate = resolution.validate;
39
+ if (normalizePath(path) !== path || !path.startsWith("/") || path === "/"
40
+ || ancestors.at(-1).path !== (path.slice(0, path.lastIndexOf("/")) || "/")
41
+ || typeof validate !== "function")
42
+ throw new FsError("EINVAL", { path });
43
+ const parent = Object.freeze({ ...resolution.parent });
44
+ if (parent.type !== "directory")
45
+ throw new FsError("EINVAL", { path });
46
+ if (compareIdentity(parent, ancestors.at(-1).stat) !== "same")
47
+ throw new FsError("ENOTSUP", { path });
48
+ const supplied = resolution.destination;
49
+ const destination = supplied === null ? null : Object.freeze({ ...supplied });
50
+ const requireSnapshot = (stat) => {
51
+ if (compareIdentity(stat, stat) !== "same" || stat.type !== "directory" && (!Number.isSafeInteger(stat.revision) || stat.revision < 0)) {
52
+ throw new FsError("ENOTSUP", { path });
53
+ }
54
+ };
55
+ if (destination !== null) {
56
+ if (destination.type !== "file")
57
+ throw new FsError("EINVAL", { path });
58
+ requireSnapshot(destination);
59
+ }
60
+ if (!resolution.traversed.length)
61
+ throw new FsError("EINVAL", { path });
62
+ if (resolution.traversed.length > 4096)
63
+ throw new FsError("EFBIG", { path });
64
+ let recordedUnits = 0;
65
+ const traversed = resolution.traversed.map(entry => {
66
+ const entryPath = entry.path, stat = Object.freeze({ ...entry.stat }), linkTarget = entry.linkTarget;
67
+ validatePath(entryPath);
68
+ if (!entryPath.startsWith("/") || normalizePath(entryPath) !== entryPath
69
+ || !["directory", "file", "symlink"].includes(stat.type)
70
+ || stat.type === "symlink" && typeof linkTarget !== "string"
71
+ || stat.type !== "symlink" && linkTarget !== undefined)
72
+ throw new FsError("EINVAL", { path: entryPath });
73
+ requireSnapshot(stat);
74
+ recordedUnits += entryPath.length + (linkTarget?.length ?? 0);
75
+ if (recordedUnits > 1_048_576)
76
+ throw new FsError("EFBIG", { path });
77
+ return Object.freeze({ path: entryPath, stat, ...(linkTarget === undefined ? {} : { linkTarget }) });
78
+ });
79
+ return Object.freeze({ path, parent, destination, ancestors, traversed: Object.freeze(traversed), validate });
80
+ }
81
+ /** Diagnose failed admission against supplied receipts; this is never a commit
82
+ * guard. Successful publication still requires the backend's atomic checks. */
83
+ export async function inspectStagingBindings(read, destination, options) {
84
+ options.signal?.throwIfAborted();
85
+ const ancestors = snapshotDirectoryAncestry(options.ancestors);
86
+ const parent = destination.slice(0, destination.lastIndexOf("/")) || "/";
87
+ if (normalizePath(destination) !== destination || ancestors.at(-1)?.path !== parent)
88
+ throw new FsError("EINVAL", { path: destination });
89
+ const expected = options.destination === null ? null : { ...options.destination };
90
+ if (expected !== null && (compareIdentity(expected, expected) !== "same"
91
+ || expected.type === "file" && (!Number.isSafeInteger(expected.revision) || expected.revision < 0)))
92
+ throw new FsError("ENOTSUP", { path: destination });
93
+ const entries = [...ancestors.map(entry => ({ ...entry, directory: true })), { path: destination, stat: expected, directory: false }];
94
+ for (const entry of entries) {
95
+ let current;
96
+ try {
97
+ current = await read(entry.path);
98
+ }
99
+ catch (error) {
100
+ options.signal?.throwIfAborted();
101
+ const code = toFsError(error).code;
102
+ if (code === "ENOENT" && entry.stat === null)
103
+ continue;
104
+ if (["ENOENT", "ENOTDIR", "ELOOP"].includes(code))
105
+ throw new FsError("EAGAIN", { path: entry.path, cause: error });
106
+ throw error;
107
+ }
108
+ options.signal?.throwIfAborted();
109
+ if (entry.stat === null || current.type !== entry.stat.type || compareIdentity(current, entry.stat) !== "same")
110
+ throw new FsError("EAGAIN", { path: entry.path });
111
+ if (!entry.directory && ["revision", "size", "mode", "nlink", "mtimeMs", "ctimeMs"].some(field => current[field] !== entry.stat[field])) {
112
+ throw new FsError("EAGAIN", { path: entry.path });
113
+ }
114
+ }
115
+ }
@@ -0,0 +1,5 @@
1
+ import type { CreateStagedFileOptions, FileStagingCleanup, FsOptions } from "../contracts/filesystem.js";
2
+ /** Capture creation controls before awaits or allocation can split their meaning. */
3
+ export declare function snapshotStagingCreation(options: CreateStagedFileOptions, path: string): CreateStagedFileOptions;
4
+ /** Own admission and release outside wrappers that can fail before dispatch. */
5
+ export declare function createStagingCleanup(path: string, remove: (options: FsOptions) => void | Promise<void>, release: () => void | Promise<void>): FileStagingCleanup;
@@ -0,0 +1,63 @@
1
+ import { FsError } from "../contracts/errors.js";
2
+ import { finishCleanup } from "../contracts/cleanup.js";
3
+ /** Capture creation controls before awaits or allocation can split their meaning. */
4
+ export function snapshotStagingCreation(options, path) {
5
+ const captured = { ...options };
6
+ for (const key of ["signal", "retainCleanup", "parent", "mode", "atimeMs", "mtimeMs"]) {
7
+ if (!Object.hasOwn(captured, key))
8
+ Object.defineProperty(captured, key, { value: options[key], enumerable: true });
9
+ }
10
+ const { signal, retainCleanup, parent, mode, atimeMs, mtimeMs, ...extra } = captured;
11
+ signal?.throwIfAborted();
12
+ if (retainCleanup !== undefined && typeof retainCleanup !== "boolean")
13
+ throw new FsError("EINVAL", { syscall: "createStagedFile", path });
14
+ return {
15
+ ...extra, parent,
16
+ ...(signal === undefined ? {} : { signal }),
17
+ ...(retainCleanup === undefined ? {} : { retainCleanup }),
18
+ ...(mode === undefined ? {} : { mode }),
19
+ ...(atimeMs === undefined ? {} : { atimeMs }),
20
+ ...(mtimeMs === undefined ? {} : { mtimeMs }),
21
+ };
22
+ }
23
+ /** Own admission and release outside wrappers that can fail before dispatch. */
24
+ export function createStagingCleanup(path, remove, release) {
25
+ let accepting = true;
26
+ let removal;
27
+ let closing;
28
+ let released;
29
+ const dispose = () => released ??= Promise.resolve().then(release);
30
+ return Object.freeze({
31
+ remove(options = {}) {
32
+ if (removal)
33
+ return removal;
34
+ if (!accepting) {
35
+ const rejected = Promise.reject(new FsError("EBADF", { syscall: "removeStagedFile", path }));
36
+ void rejected.catch(() => { });
37
+ return rejected;
38
+ }
39
+ accepting = false;
40
+ removal = Promise.resolve().then(async () => {
41
+ let failed = true;
42
+ try {
43
+ await remove(options);
44
+ failed = false;
45
+ }
46
+ finally {
47
+ await finishCleanup(dispose, failed);
48
+ }
49
+ });
50
+ void removal.catch(() => { });
51
+ return removal;
52
+ },
53
+ close() {
54
+ accepting = false;
55
+ closing ??= (async () => {
56
+ await removal?.catch(() => { });
57
+ await dispose();
58
+ })();
59
+ void closing.catch(() => { });
60
+ return closing;
61
+ },
62
+ });
63
+ }
@@ -1,4 +1,5 @@
1
1
  import type { FsOptions } from "../contracts/filesystem.js";
2
+ import type { ScopedTransportBudgetFrame } from "./transport-budget.js";
2
3
  export type PlatformErrno = number | undefined;
3
4
  export type PlatformComparisonCallback<Callback> = Callback & never;
4
5
  export declare const platform: Readonly<{
@@ -11,3 +12,7 @@ export declare const comparisonContext: Readonly<{
11
12
  active(options: FsOptions): boolean;
12
13
  run<Result>(options: FsOptions, action: (nested: FsOptions) => Promise<Result>): Promise<Result>;
13
14
  }>;
15
+ export declare function getScopedTransportBudget(): readonly ScopedTransportBudgetFrame[] | undefined;
16
+ export declare function withScopedTransportBudget<Result>(frames: readonly ScopedTransportBudgetFrame[] | undefined, action: () => Result): Result;
17
+ export declare function runScopedTransportBudget<Result>(_admit: (options?: FsOptions) => void, action: () => Result, _credit?: number): Result;
18
+ export declare function chargeScopedTransportCall(options?: FsOptions): never;
@@ -38,3 +38,20 @@ export const comparisonContext = Object.freeze({
38
38
  }
39
39
  }
40
40
  });
41
+ // Scoped browser filesystems charge logical operations directly. S3 remains
42
+ // Node-only; never pretend to provide its async transport budget context here.
43
+ export function getScopedTransportBudget() {
44
+ return undefined;
45
+ }
46
+ export function withScopedTransportBudget(frames, action) {
47
+ if (frames !== undefined)
48
+ throw new FsError("ENOTSUP", { message: "S3 transport budget contexts require Node" });
49
+ return action();
50
+ }
51
+ export function runScopedTransportBudget(_admit, action, _credit = 1) {
52
+ return action();
53
+ }
54
+ export function chargeScopedTransportCall(options) {
55
+ options?.signal?.throwIfAborted();
56
+ throw new FsError("ENOTSUP", { message: "S3 transport budget contexts require Node" });
57
+ }