@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
@@ -4,6 +4,23 @@ import { abortable, collect, limitedBody, scopeFor, sendRequest } from "./reques
4
4
  import { canonicalQuery, headerValue, invalid, signRequest, uriEncode } from "./signature.js";
5
5
  import { children, integer, malformed, parseXml, text, timestamp } from "./xml.js";
6
6
  const empty = new Uint8Array();
7
+ function utf8ByteLength(value) {
8
+ let bytes = 0;
9
+ for (let i = 0; i < value.length; i++) {
10
+ const code = value.charCodeAt(i);
11
+ if (code < 0x80)
12
+ bytes += 1;
13
+ else if (code < 0x800)
14
+ bytes += 2;
15
+ else if (code >= 0xd800 && code <= 0xdbff && i + 1 < value.length && value.charCodeAt(i + 1) >= 0xdc00 && value.charCodeAt(i + 1) <= 0xdfff) {
16
+ bytes += 4;
17
+ i++;
18
+ }
19
+ else
20
+ bytes += 3;
21
+ }
22
+ return bytes;
23
+ }
7
24
  function limit(value, fallback, name, maximum = Number.MAX_SAFE_INTEGER) {
8
25
  if (value === undefined)
9
26
  return fallback;
@@ -23,7 +40,7 @@ function bucketName(bucket) {
23
40
  invalid("expected a standard S3 bucket name");
24
41
  }
25
42
  function objectKey(key) {
26
- if (typeof key !== "string" || !key || Buffer.byteLength(key) > 1024)
43
+ if (typeof key !== "string" || !key || utf8ByteLength(key) > 1024)
27
44
  invalid("object keys must contain 1..1024 UTF-8 bytes");
28
45
  uriEncode(key);
29
46
  }
@@ -57,7 +74,7 @@ function metadataHeaders(metadata) {
57
74
  if (!/^[a-z0-9][a-z0-9_-]*$/.test(lower) || `x-amz-meta-${lower}` in headers)
58
75
  invalid("invalid or duplicate metadata key");
59
76
  const normalized = headerValue(value);
60
- size += Buffer.byteLength(lower) + Buffer.byteLength(normalized);
77
+ size += utf8ByteLength(lower) + utf8ByteLength(normalized);
61
78
  if (size > 2048)
62
79
  invalid("metadata exceeds 2 KiB");
63
80
  headers[`x-amz-meta-${lower}`] = normalized;
@@ -413,7 +430,7 @@ export function createS3HttpTransport(options) {
413
430
  const query = [["list-type", "2"], ["encoding-type", "url"]];
414
431
  for (const [name, value] of [["prefix", input.Prefix], ["delimiter", input.Delimiter], ["continuation-token", input.ContinuationToken]]) {
415
432
  if (value !== undefined) {
416
- if (typeof value !== "string" || Buffer.byteLength(value) > 8192)
433
+ if (typeof value !== "string" || utf8ByteLength(value) > 8192)
417
434
  invalid(`invalid ${name}`);
418
435
  query.push([name, value]);
419
436
  }
@@ -1,3 +1,20 @@
1
+ function utf8ByteLength(value) {
2
+ let bytes = 0;
3
+ for (let i = 0; i < value.length; i++) {
4
+ const code = value.charCodeAt(i);
5
+ if (code < 0x80)
6
+ bytes += 1;
7
+ else if (code < 0x800)
8
+ bytes += 2;
9
+ else if (code >= 0xd800 && code <= 0xdbff && i + 1 < value.length && value.charCodeAt(i + 1) >= 0xdc00 && value.charCodeAt(i + 1) <= 0xdfff) {
10
+ bytes += 4;
11
+ i++;
12
+ }
13
+ else
14
+ bytes += 3;
15
+ }
16
+ return bytes;
17
+ }
1
18
  import { createHash } from "node:crypto";
2
19
  import { collectBytes } from "../../contracts/io.js";
3
20
  import { recordMockS3Head } from "./authority.js";
@@ -37,7 +54,7 @@ export class MockS3Client {
37
54
  throw new S3ServiceError("AbortError", 499);
38
55
  if (!this.buckets.has(input.Bucket))
39
56
  throw new S3ServiceError("NoSuchBucket", 404);
40
- if ("Key" in input && (input.Key.length === 0 || Buffer.byteLength(input.Key) > 1024)) {
57
+ if ("Key" in input && (input.Key.length === 0 || utf8ByteLength(input.Key) > 1024)) {
41
58
  throw new S3ServiceError("InvalidArgument", 400);
42
59
  }
43
60
  }
@@ -62,7 +79,7 @@ export class MockS3Client {
62
79
  };
63
80
  }
64
81
  store(body, metadata = {}) {
65
- const metadataBytes = Object.entries(metadata).reduce((total, [key, value]) => total + Buffer.byteLength(key) + Buffer.byteLength(value), 0);
82
+ const metadataBytes = Object.entries(metadata).reduce((total, [key, value]) => total + utf8ByteLength(key) + utf8ByteLength(value), 0);
66
83
  if (metadataBytes > 2048)
67
84
  throw new S3ServiceError("MetadataTooLarge", 400);
68
85
  return {
@@ -250,7 +250,7 @@ export async function createS3NamespaceFileSystem(options) {
250
250
  const filesystem = {
251
251
  capabilities: Object.freeze({ read: true, write: true, append: true, stat: true, readdir: true, realpath: true, access: true,
252
252
  mkdir: true, recursiveMkdir: true, explicitDirectories: true, implicitDirectories: false, exclusiveCreate: true,
253
- remove: true, removeDirectory: true, recursiveRemove: true, atomicTreeRemoval: true, atomicEntryRemoval: true,
253
+ remove: true, removeDirectory: true, recursiveRemove: true, atomicTreeRemoval: true, atomicEntryRemoval: true, atomicEntryRemovalReceipt: false,
254
254
  rename: true, atomicRename: true, atomicRenameNoReplace: true, copy: true, exclusiveCopy: true,
255
255
  readOnly: false, symlinks: false, hardlinks: false, permissions: false, timestamps: false }),
256
256
  stat: observe,
@@ -313,7 +313,13 @@ export async function createS3NamespaceFileSystem(options) {
313
313
  async unlink(input, forwarded = {}) { entryPath(input); await mutate(forwarded, value => remove(value, resolveName(value, input), false, 'file')); },
314
314
  async rmdir(input, forwarded = {}) { entryPath(input); await mutate(forwarded, value => remove(value, resolveName(value, input), false, 'directory')); },
315
315
  async removeTreeConditional(input, forwarded) { entryPath(input); return conditionalRemove(input, forwarded, true); },
316
- async removeEntryConditional(input, forwarded) { entryPath(input); return conditionalRemove(input, forwarded, false); },
316
+ async removeEntryConditional(input, forwarded) {
317
+ forwarded.signal?.throwIfAborted();
318
+ if (forwarded.returnRemainingStat === true)
319
+ throw new FsError('ENOTSUP');
320
+ entryPath(input);
321
+ return conditionalRemove(input, forwarded, false);
322
+ },
317
323
  async rename(source, destination, forwarded = {}) {
318
324
  entryPath(source);
319
325
  entryPath(destination);
@@ -1,5 +1,6 @@
1
1
  import { FsError } from "../../contracts/errors.js";
2
2
  import { assertCallbackAuthorityAllowed } from "../mount/comparison.js";
3
+ import { enableS3TransportBudget } from "../../platform/transport-budget.js";
3
4
  const observedStats = new WeakMap();
4
5
  const entries = new WeakMap();
5
6
  const comparisons = new WeakMap();
@@ -8,6 +9,7 @@ export function recordS3Observation(filesystem, path, stat, entry) {
8
9
  observedStats.set(stat, { filesystem, path, entry });
9
10
  }
10
11
  export function registerS3EntryOwner(filesystem, normalize, intact, baseComparison, comparison) {
12
+ enableS3TransportBudget();
11
13
  comparisons.set(filesystem, baseComparison);
12
14
  if (comparison)
13
15
  configuredComparisons.set(filesystem, comparison);
@@ -2,6 +2,7 @@ import type { FileSystem, FsOptions } from "../contracts/filesystem.js";
2
2
  export declare function scopeFileSystem(filesystem: FileSystem, charge: () => void, signal: AbortSignal, cleanupCharge?: () => void, options?: {
3
3
  readonly preserveDescriptorWriteReceipt?: boolean;
4
4
  readonly creationMask?: number;
5
+ readonly maxPathComponents?: number;
5
6
  }): FileSystem;
6
7
  export interface RetainedFileSystemCleanupView {
7
8
  readonly removeFileConditional?: NonNullable<FileSystem["removeFileConditional"]>;
@@ -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>;