@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.
- package/README.md +8 -1
- package/dist/safe-fs/contracts/errors.js +8 -1
- package/dist/safe-fs/contracts/filesystem.d.ts +68 -2
- package/dist/safe-fs/contracts/io.js +183 -27
- package/dist/safe-fs/contracts/virtual-path.d.ts +3 -1
- package/dist/safe-fs/contracts/virtual-path.js +20 -1
- package/dist/safe-fs/core.d.ts +1 -0
- package/dist/safe-fs/core.js +1 -0
- package/dist/safe-fs/fs/capabilities.d.ts +1 -1
- package/dist/safe-fs/fs/capabilities.js +59 -21
- package/dist/safe-fs/fs/conditional-chmod.d.ts +7 -0
- package/dist/safe-fs/fs/conditional-chmod.js +19 -0
- package/dist/safe-fs/fs/descriptor.js +51 -25
- package/dist/safe-fs/fs/devices/index.d.ts +7 -2
- package/dist/safe-fs/fs/devices/index.js +210 -29
- package/dist/safe-fs/fs/devices/path.d.ts +3 -1
- package/dist/safe-fs/fs/devices/path.js +70 -12
- package/dist/safe-fs/fs/memory/index.d.ts +34 -48
- package/dist/safe-fs/fs/memory/index.js +1150 -235
- package/dist/safe-fs/fs/memory/ledger.d.ts +2 -0
- package/dist/safe-fs/fs/memory/ledger.js +15 -3
- package/dist/safe-fs/fs/memory/missing-target.js +21 -1
- package/dist/safe-fs/fs/mount/index.d.ts +11 -3
- package/dist/safe-fs/fs/mount/index.js +326 -15
- package/dist/safe-fs/fs/object-publication/index.js +79 -3
- package/dist/safe-fs/fs/overlay/index.d.ts +2 -2
- package/dist/safe-fs/fs/overlay/index.js +22 -2
- package/dist/safe-fs/fs/overlay/memory-publication.d.ts +2 -1
- package/dist/safe-fs/fs/overlay/memory-publication.js +39 -2
- package/dist/safe-fs/fs/quota/index.js +1 -1
- package/dist/safe-fs/fs/readonly/index.d.ts +1 -0
- package/dist/safe-fs/fs/readonly/index.js +13 -0
- package/dist/safe-fs/fs/real/index.d.ts +6 -2
- package/dist/safe-fs/fs/real/index.js +78 -8
- package/dist/safe-fs/fs/s3/filesystem.d.ts +3 -0
- package/dist/safe-fs/fs/s3/filesystem.js +48 -16
- package/dist/safe-fs/fs/s3/http/transport.js +20 -3
- package/dist/safe-fs/fs/s3/mock.js +19 -2
- package/dist/safe-fs/fs/s3/namespace.js +8 -2
- package/dist/safe-fs/fs/s3/registry.js +2 -0
- package/dist/safe-fs/fs/scoped.d.ts +1 -0
- package/dist/safe-fs/fs/scoped.js +261 -74
- package/dist/safe-fs/fs/staging-ancestry.d.ts +8 -0
- package/dist/safe-fs/fs/staging-ancestry.js +115 -0
- package/dist/safe-fs/fs/staging-cleanup.d.ts +5 -0
- package/dist/safe-fs/fs/staging-cleanup.js +63 -0
- package/dist/safe-fs/platform/browser.d.ts +5 -0
- package/dist/safe-fs/platform/browser.js +17 -0
- package/dist/safe-fs/platform/node.d.ts +5 -0
- package/dist/safe-fs/platform/node.js +28 -0
- package/dist/safe-fs/platform/transport-budget.d.ts +7 -0
- package/dist/safe-fs/platform/transport-budget.js +4 -0
- package/dist/safe-fs/testing/object-io-metrics.d.ts +3 -0
- package/dist/safe-fs/testing/object-io-metrics.js +29 -0
- package/dist/safe-fs/testing/object-publication.d.ts +3 -1
- package/dist/safe-fs/testing/object-publication.js +2 -2
- 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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
await closing;
|
|
181
|
-
return { done: true, value: undefined };
|
|
194
|
+
catch (error) {
|
|
195
|
+
await finishCleanup(close, true);
|
|
196
|
+
throw error;
|
|
182
197
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
throw error
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
if (property === "
|
|
227
|
-
args[
|
|
228
|
-
|
|
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
|
-
|
|
368
|
+
const result = Reflect.apply(method, original, args);
|
|
369
|
+
return property === "readStream" ? wrapStream(result, args[1]) : result;
|
|
248
370
|
};
|
|
249
|
-
const
|
|
250
|
-
?
|
|
251
|
-
:
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
263
|
-
await finishCleanup(() => descriptor.close(), true);
|
|
264
|
-
assertOpen(options);
|
|
426
|
+
controls.signal?.throwIfAborted();
|
|
265
427
|
throw error;
|
|
266
428
|
}
|
|
267
429
|
}
|
|
268
|
-
: property === "
|
|
269
|
-
? async (
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
+
}
|