@poe-platform/safe-fs 0.1.720 → 0.1.722
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 +64 -18
- package/dist/safe-fs/bridge/filesystem.d.ts +2 -0
- package/dist/safe-fs/bridge/filesystem.js +17 -7
- package/dist/safe-fs/bridge/types.d.ts +2 -0
- package/dist/safe-fs/contracts/descriptor.d.ts +5 -0
- package/dist/safe-fs/contracts/filesystem.d.ts +12 -0
- package/dist/safe-fs/contracts/io.d.ts +3 -1
- package/dist/safe-fs/contracts/io.js +48 -16
- package/dist/safe-fs/core.d.ts +2 -1
- package/dist/safe-fs/core.js +1 -1
- package/dist/safe-fs/fs/capabilities.js +6 -3
- package/dist/safe-fs/fs/descriptor.js +9 -2
- package/dist/safe-fs/fs/devices/index.d.ts +1 -0
- package/dist/safe-fs/fs/devices/index.js +24 -6
- package/dist/safe-fs/fs/memory/index.d.ts +1 -0
- package/dist/safe-fs/fs/memory/index.js +75 -3
- package/dist/safe-fs/fs/memory/limits.js +6 -4
- package/dist/safe-fs/fs/mount/index.d.ts +4 -0
- package/dist/safe-fs/fs/mount/index.js +74 -11
- package/dist/safe-fs/fs/object-publication/index.js +6 -6
- package/dist/safe-fs/fs/overlay/index.d.ts +4 -2
- package/dist/safe-fs/fs/overlay/index.js +92 -36
- package/dist/safe-fs/fs/quota/index.js +18 -16
- package/dist/safe-fs/fs/readonly/index.js +5 -3
- package/dist/safe-fs/fs/real/index.d.ts +21 -1
- package/dist/safe-fs/fs/real/index.js +266 -22
- package/dist/safe-fs/fs/s3/filesystem.d.ts +8 -0
- package/dist/safe-fs/fs/s3/filesystem.js +58 -12
- package/dist/safe-fs/fs/s3/http/request.d.ts +1 -1
- package/dist/safe-fs/fs/s3/http/request.js +1 -1
- package/dist/safe-fs/fs/s3/http/transport.js +8 -6
- package/dist/safe-fs/fs/s3/http/xml.d.ts +4 -1
- package/dist/safe-fs/fs/s3/http/xml.js +6 -2
- package/dist/safe-fs/fs/s3/namespace.js +12 -10
- package/dist/safe-fs/fs/scoped.d.ts +1 -0
- package/dist/safe-fs/fs/scoped.js +54 -35
- package/dist/safe-fs/fs/webdav/webdav.d.ts +4 -1
- package/dist/safe-fs/fs/webdav/webdav.js +30 -22
- package/dist/safe-fs/node/filesystem.d.ts +4 -1
- package/dist/safe-fs/node/filesystem.js +7 -1
- package/dist/safe-fs/node/host.d.ts +3 -0
- package/dist/safe-fs/node/host.js +78 -0
- package/dist/safe-fs/node/index.d.ts +2 -1
- package/dist/safe-fs/node/index.js +2 -1
- package/dist/safe-fs/platform/browser.d.ts +1 -0
- package/dist/safe-fs/platform/browser.js +1 -0
- package/dist/safe-fs/platform/node.d.ts +1 -0
- package/dist/safe-fs/platform/node.js +1 -0
- package/dist/safe-fs/python/emscripten.d.ts +1 -0
- package/dist/safe-fs/python/emscripten.js +5 -5
- package/dist/safe-fs/python/filesystem.js +5 -5
- package/dist/safe-fs/python/native.d.ts +1 -0
- package/dist/safe-fs/python/native.js +3 -2
- package/dist/safe-fs/xml.js +32 -21
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { constants } from "node:fs";
|
|
2
|
+
import * as immediate from "node:fs";
|
|
2
3
|
import * as native from "node:fs/promises";
|
|
3
|
-
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { dirname, basename, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
5
|
import { nativeAllocatedBytes } from "./allocation.js";
|
|
5
6
|
import { openFileDescriptor } from "../descriptor.js";
|
|
6
7
|
import { finishCleanup } from "../../contracts/cleanup.js";
|
|
@@ -17,19 +18,24 @@ function fileType(stats) {
|
|
|
17
18
|
throw new FsError("ENOTSUP", { message: "special filesystem nodes are not supported" });
|
|
18
19
|
}
|
|
19
20
|
function fileStat(stats) {
|
|
20
|
-
const allocatedBytes = nativeAllocatedBytes(stats.blocks, process.platform);
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
const allocatedBytes = nativeAllocatedBytes(typeof stats.size === "bigint" && typeof stats.blocks === "bigint" ? Number(stats.blocks) : stats.blocks, process.platform);
|
|
22
|
+
const blockSize = typeof stats.size === "bigint" && typeof stats.blksize === "bigint" ? Number(stats.blksize) : stats.blksize;
|
|
23
|
+
const dev = Number(stats.dev), ino = Number(stats.ino);
|
|
24
|
+
const snapshot = {
|
|
25
|
+
type: fileType(stats), size: Number(stats.size), mode: Number(stats.mode),
|
|
24
26
|
...(allocatedBytes === undefined ? {} : { allocatedBytes }),
|
|
25
|
-
...(Number.isSafeInteger(
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
...(typeof blockSize === "number" && Number.isSafeInteger(blockSize) && blockSize > 0 ? { ioBlockSize: blockSize, preferredIoBlockSize: blockSize } : {}),
|
|
28
|
+
atimeMs: "atimeNs" in stats ? Number(stats.atimeNs / 1000000n) + Number(stats.atimeNs % 1000000n) / 1e6 : stats.atimeMs,
|
|
29
|
+
mtimeMs: "mtimeNs" in stats ? Number(stats.mtimeNs / 1000000n) + Number(stats.mtimeNs % 1000000n) / 1e6 : stats.mtimeMs,
|
|
30
|
+
ctimeMs: "ctimeNs" in stats ? Number(stats.ctimeNs / 1000000n) + Number(stats.ctimeNs % 1000000n) / 1e6 : stats.ctimeMs,
|
|
31
|
+
birthtimeMs: "birthtimeNs" in stats ? Number(stats.birthtimeNs / 1000000n) + Number(stats.birthtimeNs % 1000000n) / 1e6 : stats.birthtimeMs, ino, dev,
|
|
32
|
+
...(Number.isSafeInteger(dev) && dev >= 0 && Number.isSafeInteger(ino) && ino >= 0
|
|
30
33
|
? { identityScope: Symbol.for("virtual-bash.fs.native") } : {}),
|
|
31
|
-
nlink: stats.nlink, uid: stats.uid, gid: stats.gid,
|
|
34
|
+
nlink: Number(stats.nlink), uid: Number(stats.uid), gid: Number(stats.gid),
|
|
32
35
|
};
|
|
36
|
+
if ("ctimeNs" in stats && !stats.isDirectory())
|
|
37
|
+
Object.defineProperty(snapshot, "opaqueVersion", { value: `${stats.ctimeNs}:${stats.mtimeNs}`, enumerable: true });
|
|
38
|
+
return snapshot;
|
|
33
39
|
}
|
|
34
40
|
function integer(value, minimum = 0) {
|
|
35
41
|
if (!Number.isSafeInteger(value) || value < minimum) {
|
|
@@ -92,9 +98,10 @@ export class RealFileSystem {
|
|
|
92
98
|
rename: true, atomicRenameNoReplace: false, copy: true, exclusiveCopy: true, readlink: true, truncate: true,
|
|
93
99
|
streamingAppend: true, randomAccessWrite: true,
|
|
94
100
|
readOnly: false, symlinks: true, hardlinks: true, permissions: true,
|
|
95
|
-
timestamps: true, atomicRename: true, streamingRead: true, streamingWrite: true, retainedRead: true, retainedResize: true,
|
|
101
|
+
timestamps: true, atomicRename: true, streamingRead: true, streamingWrite: true, retainedRead: true, retainedResize: true, trustedOwnedStaging: true,
|
|
96
102
|
});
|
|
97
103
|
configuredRoot;
|
|
104
|
+
renameNoReplace;
|
|
98
105
|
rootPromise;
|
|
99
106
|
constructor(options) {
|
|
100
107
|
const root = typeof options === "string" ? options : options.root;
|
|
@@ -106,6 +113,12 @@ export class RealFileSystem {
|
|
|
106
113
|
throw new FsError("ENOTSUP", { syscall: "root", message: "this backend requires a POSIX host" });
|
|
107
114
|
}
|
|
108
115
|
this.configuredRoot = root;
|
|
116
|
+
this.renameNoReplace = typeof options === "string" ? undefined : options.renameNoReplace;
|
|
117
|
+
if (this.renameNoReplace !== undefined) {
|
|
118
|
+
if (typeof this.renameNoReplace !== "function")
|
|
119
|
+
throw new FsError("EINVAL", { syscall: "root", message: "renameNoReplace must be a function" });
|
|
120
|
+
this.capabilities = Object.freeze({ ...this.capabilities, atomicRenameNoReplace: true });
|
|
121
|
+
}
|
|
109
122
|
}
|
|
110
123
|
async root(options = {}) {
|
|
111
124
|
options.signal?.throwIfAborted();
|
|
@@ -253,14 +266,223 @@ export class RealFileSystem {
|
|
|
253
266
|
if (resolve(path) === root)
|
|
254
267
|
throw new FsError("EBUSY", { message: "the filesystem root cannot be removed or replaced" });
|
|
255
268
|
}
|
|
269
|
+
// These critical sections contain no await. They serialize with all JavaScript
|
|
270
|
+
// callers, under this adapter's existing trusted-host (externally isolated tree)
|
|
271
|
+
// boundary. They deliberately do not advertise the stronger atomic capabilities.
|
|
272
|
+
stagingSnapshot(path) {
|
|
273
|
+
try {
|
|
274
|
+
return fileStat(immediate.lstatSync(path, { bigint: true }));
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
if (nativeError(error).code === "ENOENT")
|
|
278
|
+
return null;
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
expectStaging(path, expected, identityOnly = false) {
|
|
283
|
+
if (expected && (expected.identityScope !== Symbol.for("virtual-bash.fs.native")
|
|
284
|
+
|| expected.dev === undefined || expected.ino === undefined || !identityOnly && expected.opaqueVersion === undefined))
|
|
285
|
+
throw new FsError("ENOTSUP");
|
|
286
|
+
const current = this.stagingSnapshot(path);
|
|
287
|
+
if (expected === null ? current !== null : !current || current.dev !== expected.dev || current.ino !== expected.ino || current.type !== expected.type
|
|
288
|
+
|| !identityOnly && (current.opaqueVersion !== expected.opaqueVersion || current.size !== expected.size || current.mode !== expected.mode || current.nlink !== expected.nlink))
|
|
289
|
+
throw new FsError("EAGAIN");
|
|
290
|
+
if (immediate.realpathSync(dirname(path)) !== dirname(path))
|
|
291
|
+
throw new FsError("EAGAIN");
|
|
292
|
+
return current;
|
|
293
|
+
}
|
|
294
|
+
stagingMetadata(options) {
|
|
295
|
+
if (options.mode !== undefined)
|
|
296
|
+
integer(options.mode);
|
|
297
|
+
for (const time of [options.atimeMs, options.mtimeMs])
|
|
298
|
+
if (time !== undefined && (!Number.isFinite(time) || Math.abs(time) > 8.64e15))
|
|
299
|
+
throw new FsError("EINVAL");
|
|
300
|
+
}
|
|
301
|
+
async createStagedFile(directoryPath, name, content, options) {
|
|
302
|
+
// Unlike operation(), this must return its receipt after commit even if the
|
|
303
|
+
// signal is aborted before the promise settles.
|
|
304
|
+
options.signal?.throwIfAborted();
|
|
305
|
+
try {
|
|
306
|
+
if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\0"))
|
|
307
|
+
throw new FsError("EINVAL");
|
|
308
|
+
this.stagingMetadata(options);
|
|
309
|
+
if (content.type === "file" && !(content.data instanceof Uint8Array))
|
|
310
|
+
throw new FsError("EINVAL");
|
|
311
|
+
const directory = await this.path(directoryPath, { ...options, followFinal: false, missing: "final" });
|
|
312
|
+
const root = await this.root(options);
|
|
313
|
+
const target = content.type === "symlink" ? (validatePath(content.target), isAbsolute(content.target) ? `${root}/${content.target.slice(1)}` : content.target) : undefined;
|
|
314
|
+
if (content.type === "symlink")
|
|
315
|
+
await this.walk(root, (isAbsolute(content.target) ? content.target.slice(1).split("/") : relative(root, directory).split("/").concat(content.target.split("/"))).map(name => ({ name, fromLink: true })), { ...options, checkTarget: true });
|
|
316
|
+
options.signal?.throwIfAborted();
|
|
317
|
+
this.protectTerminal(directoryPath);
|
|
318
|
+
this.expectStaging(dirname(directory), options.parent, true);
|
|
319
|
+
immediate.mkdirSync(directory, { mode: 0o700 });
|
|
320
|
+
const file = join(directory, name);
|
|
321
|
+
try {
|
|
322
|
+
if (content.type === "file") {
|
|
323
|
+
const fd = immediate.openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, options.mode ?? 0o666);
|
|
324
|
+
try {
|
|
325
|
+
immediate.writeFileSync(fd, content.data);
|
|
326
|
+
if (options.mode !== undefined)
|
|
327
|
+
immediate.fchmodSync(fd, options.mode);
|
|
328
|
+
if (options.atimeMs !== undefined || options.mtimeMs !== undefined) {
|
|
329
|
+
const stat = immediate.fstatSync(fd);
|
|
330
|
+
immediate.futimesSync(fd, (options.atimeMs ?? stat.atimeMs) / 1000, (options.mtimeMs ?? stat.mtimeMs) / 1000);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
finally {
|
|
334
|
+
immediate.closeSync(fd);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
else
|
|
338
|
+
immediate.symlinkSync(target, file);
|
|
339
|
+
const virtualDirectory = `/${relative(root, directory)}`;
|
|
340
|
+
const receipt = (path, host) => Object.freeze({ path, stat: Object.freeze(this.stagingSnapshot(host)) });
|
|
341
|
+
return Object.freeze({ parent: receipt(dirname(virtualDirectory), dirname(directory)), directory: receipt(virtualDirectory, directory), file: receipt(`${virtualDirectory}/${name}`, file) });
|
|
342
|
+
}
|
|
343
|
+
catch (error) {
|
|
344
|
+
// Only entries just acquired by this synchronous section can be removed.
|
|
345
|
+
if (immediate.existsSync(file))
|
|
346
|
+
immediate.unlinkSync(file);
|
|
347
|
+
immediate.rmdirSync(directory);
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
options.signal?.throwIfAborted();
|
|
353
|
+
throw new FsError(nativeError(error).code, { syscall: "createStagedFile", path: directoryPath });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
async stagingPaths(staging, options) {
|
|
357
|
+
const directory = await this.path(staging.directory.path, { ...options, followFinal: false });
|
|
358
|
+
const file = await this.path(staging.file.path, { ...options, followFinal: false, missing: "final" });
|
|
359
|
+
const parent = await this.path(staging.parent.path, { ...options, followFinal: false });
|
|
360
|
+
if (dirname(directory) !== parent || dirname(file) !== directory)
|
|
361
|
+
throw new FsError("EAGAIN");
|
|
362
|
+
return { directory, file, parent };
|
|
363
|
+
}
|
|
364
|
+
checkStaging(staging, paths) {
|
|
365
|
+
this.expectStaging(paths.parent, staging.parent.stat, true);
|
|
366
|
+
const directory = this.expectStaging(paths.directory, staging.directory.stat, true);
|
|
367
|
+
if (directory?.type !== "directory" || (directory.mode & 0o777) !== 0o700)
|
|
368
|
+
throw new FsError("EAGAIN");
|
|
369
|
+
}
|
|
370
|
+
async publishStagedFile(staging, destination, options) {
|
|
371
|
+
return this.operation("publishStagedFile", staging.file.path, options, async () => {
|
|
372
|
+
const paths = await this.stagingPaths(staging, options);
|
|
373
|
+
const target = await this.path(destination, { ...options, followFinal: false, missing: "final" });
|
|
374
|
+
options.signal?.throwIfAborted();
|
|
375
|
+
this.protectTerminal(destination);
|
|
376
|
+
this.checkStaging(staging, paths);
|
|
377
|
+
this.expectStaging(paths.file, staging.file.stat);
|
|
378
|
+
this.expectStaging(dirname(target), options.parent, true);
|
|
379
|
+
const existing = this.expectStaging(target, options.destination);
|
|
380
|
+
if (existing && (existing.type !== "file" || existing.nlink !== 1))
|
|
381
|
+
throw new FsError("EAGAIN");
|
|
382
|
+
if (target === paths.file || target === paths.directory || dirname(target) === paths.directory)
|
|
383
|
+
throw new FsError("EINVAL");
|
|
384
|
+
immediate.renameSync(paths.file, target);
|
|
385
|
+
}, destination);
|
|
386
|
+
}
|
|
387
|
+
async removeStagedFile(staging, options = {}) {
|
|
388
|
+
return this.operation("removeStagedFile", staging.directory.path, options, async () => {
|
|
389
|
+
const paths = await this.stagingPaths(staging, options);
|
|
390
|
+
options.signal?.throwIfAborted();
|
|
391
|
+
this.checkStaging(staging, paths);
|
|
392
|
+
const file = this.stagingSnapshot(paths.file);
|
|
393
|
+
if (file)
|
|
394
|
+
this.expectStaging(paths.file, staging.file.stat);
|
|
395
|
+
const children = immediate.readdirSync(paths.directory);
|
|
396
|
+
if (children.length !== (file ? 1 : 0) || file && children[0] !== basename(paths.file))
|
|
397
|
+
throw new FsError("ENOTEMPTY");
|
|
398
|
+
if (file)
|
|
399
|
+
immediate.unlinkSync(paths.file);
|
|
400
|
+
immediate.rmdirSync(paths.directory);
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
async writeFileConditional(path, data, options) {
|
|
404
|
+
options.signal?.throwIfAborted();
|
|
405
|
+
try {
|
|
406
|
+
if (!(data instanceof Uint8Array))
|
|
407
|
+
throw new FsError("EINVAL");
|
|
408
|
+
this.stagingMetadata(options);
|
|
409
|
+
const target = await this.path(path, { ...options, followFinal: false, missing: "final" });
|
|
410
|
+
options.signal?.throwIfAborted();
|
|
411
|
+
this.expectStaging(dirname(target), options.parent, true);
|
|
412
|
+
const existing = this.expectStaging(target, options.expected);
|
|
413
|
+
if (existing && (existing.type !== "file" || existing.nlink !== 1))
|
|
414
|
+
throw new FsError("EAGAIN");
|
|
415
|
+
const fd = immediate.openSync(target, constants.O_WRONLY | constants.O_NOFOLLOW | (existing ? 0 : constants.O_CREAT | constants.O_EXCL), options.mode ?? 0o666);
|
|
416
|
+
try {
|
|
417
|
+
if (!options.append)
|
|
418
|
+
immediate.ftruncateSync(fd, 0);
|
|
419
|
+
let offset = options.append ? Number(immediate.fstatSync(fd).size) : 0;
|
|
420
|
+
let written = 0;
|
|
421
|
+
while (written < data.length) {
|
|
422
|
+
const count = immediate.writeSync(fd, data, written, data.length - written, offset);
|
|
423
|
+
if (count === 0)
|
|
424
|
+
throw new FsError("EIO");
|
|
425
|
+
written += count;
|
|
426
|
+
offset += count;
|
|
427
|
+
}
|
|
428
|
+
if (options.mode !== undefined)
|
|
429
|
+
immediate.fchmodSync(fd, options.mode);
|
|
430
|
+
return fileStat(immediate.fstatSync(fd, { bigint: true }));
|
|
431
|
+
}
|
|
432
|
+
finally {
|
|
433
|
+
immediate.closeSync(fd);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
catch (error) {
|
|
437
|
+
options.signal?.throwIfAborted();
|
|
438
|
+
throw new FsError(nativeError(error).code, { syscall: "writeFileConditional", path });
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
async removeFileConditional(path, options) {
|
|
442
|
+
return this.operation("removeFileConditional", path, options, async () => {
|
|
443
|
+
const target = await this.path(path, { ...options, followFinal: false });
|
|
444
|
+
options.signal?.throwIfAborted();
|
|
445
|
+
this.expectStaging(dirname(target), options.parent, true);
|
|
446
|
+
const file = this.expectStaging(target, options.expected);
|
|
447
|
+
if (file?.type !== "file" || file.nlink !== 1)
|
|
448
|
+
throw new FsError("EAGAIN");
|
|
449
|
+
immediate.unlinkSync(target);
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
async prepareDirectory(path, options) {
|
|
453
|
+
options.signal?.throwIfAborted();
|
|
454
|
+
try {
|
|
455
|
+
this.stagingMetadata(options);
|
|
456
|
+
const target = await this.path(path, { ...options, followFinal: false, missing: "final" });
|
|
457
|
+
options.signal?.throwIfAborted();
|
|
458
|
+
this.protectTerminal(path);
|
|
459
|
+
this.expectStaging(dirname(target), options.parent, true);
|
|
460
|
+
const existing = this.expectStaging(target, options.expected, true);
|
|
461
|
+
if (existing && existing.type !== "directory")
|
|
462
|
+
throw new FsError("EAGAIN");
|
|
463
|
+
if (!existing)
|
|
464
|
+
immediate.mkdirSync(target, { mode: options.mode ?? 0o777 });
|
|
465
|
+
if (options.mode !== undefined)
|
|
466
|
+
immediate.chmodSync(target, options.mode);
|
|
467
|
+
if (options.atimeMs !== undefined || options.mtimeMs !== undefined) {
|
|
468
|
+
const stat = immediate.statSync(target);
|
|
469
|
+
immediate.utimesSync(target, (options.atimeMs ?? stat.atimeMs) / 1000, (options.mtimeMs ?? stat.mtimeMs) / 1000);
|
|
470
|
+
}
|
|
471
|
+
return this.stagingSnapshot(target);
|
|
472
|
+
}
|
|
473
|
+
catch (error) {
|
|
474
|
+
options.signal?.throwIfAborted();
|
|
475
|
+
throw new FsError(nativeError(error).code, { syscall: "prepareDirectory", path });
|
|
476
|
+
}
|
|
477
|
+
}
|
|
256
478
|
open(path, options) {
|
|
257
479
|
return openFileDescriptor(path, options, {
|
|
258
|
-
positionedRead: true, positionedWrite: true, truncate: true, synchronization: "storage",
|
|
480
|
+
noFollow: true, positionedRead: true, positionedWrite: true, truncate: true, synchronization: "storage",
|
|
259
481
|
}, async (admitted) => {
|
|
260
482
|
let handle;
|
|
261
483
|
try {
|
|
262
484
|
const target = await this.path(path, {
|
|
263
|
-
...admitted, followFinal: admitted.creation !== "exclusive",
|
|
485
|
+
...admitted, followFinal: !admitted.noFollow && admitted.creation !== "exclusive",
|
|
264
486
|
...(admitted.creation === "never" ? {} : { missing: "final" }),
|
|
265
487
|
});
|
|
266
488
|
let flags = (admitted.access === "read" ? constants.O_RDONLY : admitted.access === "write" ? constants.O_WRONLY : constants.O_RDWR)
|
|
@@ -271,7 +493,22 @@ export class RealFileSystem {
|
|
|
271
493
|
flags |= constants.O_EXCL;
|
|
272
494
|
if (admitted.append)
|
|
273
495
|
flags |= constants.O_APPEND;
|
|
274
|
-
|
|
496
|
+
let created = false;
|
|
497
|
+
if (admitted.exactMode && admitted.creation !== "never") {
|
|
498
|
+
try {
|
|
499
|
+
handle = await native.open(target, flags | constants.O_EXCL, admitted.mode);
|
|
500
|
+
created = true;
|
|
501
|
+
}
|
|
502
|
+
catch (error) {
|
|
503
|
+
if (admitted.creation === "exclusive" || nativeError(error).code !== "EEXIST")
|
|
504
|
+
throw error;
|
|
505
|
+
handle = await native.open(target, flags & ~constants.O_CREAT, admitted.mode);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
else
|
|
509
|
+
handle = await native.open(target, flags, admitted.mode);
|
|
510
|
+
if (created)
|
|
511
|
+
await handle.chmod(admitted.mode);
|
|
275
512
|
admitted.signal?.throwIfAborted();
|
|
276
513
|
const stat = await handle.stat();
|
|
277
514
|
if (stat.isDirectory())
|
|
@@ -285,7 +522,7 @@ export class RealFileSystem {
|
|
|
285
522
|
handle = undefined;
|
|
286
523
|
return {
|
|
287
524
|
resource,
|
|
288
|
-
stat: (retained, forwarded) => this.operation("fstat", path, forwarded, async () => fileStat(await retained.handle.stat())),
|
|
525
|
+
stat: (retained, forwarded) => this.operation("fstat", path, forwarded, async () => fileStat(await retained.handle.stat({ bigint: true }))),
|
|
289
526
|
read: (retained, buffer, position, forwarded) => this.operation("read", path, forwarded, async () => (await retained.handle.read(buffer, 0, buffer.byteLength, position)).bytesRead),
|
|
290
527
|
write: (retained, buffer, position, forwarded) => this.operation("write", path, forwarded, async () => (await retained.handle.write(buffer, 0, buffer.byteLength, position)).bytesWritten),
|
|
291
528
|
truncate: (retained, length, forwarded) => this.operation("ftruncate", path, forwarded, () => retained.handle.truncate(length)),
|
|
@@ -335,14 +572,14 @@ export class RealFileSystem {
|
|
|
335
572
|
return this.operation("stat", path, options, async () => {
|
|
336
573
|
const target = await this.path(path, options);
|
|
337
574
|
options.signal?.throwIfAborted();
|
|
338
|
-
return fileStat(await native.stat(target));
|
|
575
|
+
return fileStat(await native.stat(target, { bigint: true }));
|
|
339
576
|
});
|
|
340
577
|
}
|
|
341
578
|
async lstat(path, options = {}) {
|
|
342
579
|
return this.operation("lstat", path, options, async () => {
|
|
343
580
|
const target = await this.path(path, { ...options, followFinal: false });
|
|
344
581
|
options.signal?.throwIfAborted();
|
|
345
|
-
return fileStat(await native.lstat(target));
|
|
582
|
+
return fileStat(await native.lstat(target, { bigint: true }));
|
|
346
583
|
});
|
|
347
584
|
}
|
|
348
585
|
async readdir(path, options = {}) {
|
|
@@ -381,6 +618,8 @@ export class RealFileSystem {
|
|
|
381
618
|
return this.operation("mkdir", path, options, async () => {
|
|
382
619
|
if (options.mode !== undefined)
|
|
383
620
|
integer(options.mode);
|
|
621
|
+
if (options.exactMode && options.recursive)
|
|
622
|
+
throw new FsError("ENOTSUP");
|
|
384
623
|
const target = await this.path(path, {
|
|
385
624
|
...options,
|
|
386
625
|
missing: "final", followFinal: !!options.recursive, deferTrailingSeparator: true,
|
|
@@ -388,6 +627,8 @@ export class RealFileSystem {
|
|
|
388
627
|
});
|
|
389
628
|
options.signal?.throwIfAborted();
|
|
390
629
|
await native.mkdir(target, { recursive: options.recursive ?? false, ...(options.mode === undefined ? {} : { mode: options.mode }) });
|
|
630
|
+
if (options.exactMode)
|
|
631
|
+
await native.chmod(target, options.mode ?? 0o777);
|
|
391
632
|
});
|
|
392
633
|
}
|
|
393
634
|
async rmdir(path, options = {}) {
|
|
@@ -427,7 +668,7 @@ export class RealFileSystem {
|
|
|
427
668
|
}
|
|
428
669
|
async rename(source, destination, options = {}) {
|
|
429
670
|
return this.operation("rename", source, options, async () => {
|
|
430
|
-
if (options.noReplace)
|
|
671
|
+
if (options.noReplace && !this.renameNoReplace)
|
|
431
672
|
throw new FsError("ENOTSUP", { syscall: "rename", path: source, dest: destination });
|
|
432
673
|
const from = await this.path(source, { ...options, followFinal: false });
|
|
433
674
|
const to = await this.path(destination, { ...options, followFinal: false, missing: "final" });
|
|
@@ -437,7 +678,10 @@ export class RealFileSystem {
|
|
|
437
678
|
this.protectRoot(from, root);
|
|
438
679
|
this.protectRoot(to, root);
|
|
439
680
|
options.signal?.throwIfAborted();
|
|
440
|
-
|
|
681
|
+
if (options.noReplace)
|
|
682
|
+
await this.renameNoReplace(from, to, options.signal ? { signal: options.signal } : {});
|
|
683
|
+
else
|
|
684
|
+
await native.rename(from, to);
|
|
441
685
|
}, destination);
|
|
442
686
|
}
|
|
443
687
|
async copyFile(source, destination, options = {}) {
|
|
@@ -641,7 +885,7 @@ export class RealFileSystem {
|
|
|
641
885
|
};
|
|
642
886
|
return {
|
|
643
887
|
stat(options = {}) {
|
|
644
|
-
return perform(options, "fstat", async () => fileStat(await handle.stat()));
|
|
888
|
+
return perform(options, "fstat", async () => fileStat(await handle.stat({ bigint: true })));
|
|
645
889
|
},
|
|
646
890
|
async truncate(length, options = {}) {
|
|
647
891
|
assertOpen(options, "ftruncate");
|
|
@@ -762,7 +1006,7 @@ export class RealFileSystem {
|
|
|
762
1006
|
};
|
|
763
1007
|
return {
|
|
764
1008
|
stat(options = {}) {
|
|
765
|
-
return perform(options, "fstat", async () => fileStat(await handle.stat()));
|
|
1009
|
+
return perform(options, "fstat", async () => fileStat(await handle.stat({ bigint: true })));
|
|
766
1010
|
},
|
|
767
1011
|
async read(position, maxBytes, options = {}) {
|
|
768
1012
|
assertOpen(options, "read");
|
|
@@ -14,6 +14,12 @@ export interface S3FileSystemOptions {
|
|
|
14
14
|
readonly maxReadBytes?: number;
|
|
15
15
|
readonly maxStreamBytes?: number;
|
|
16
16
|
readonly maxListEntries?: number;
|
|
17
|
+
/** Aggregate budgets for each rm operation, including ancestor inspection. */
|
|
18
|
+
readonly removalLimits?: {
|
|
19
|
+
readonly maxRequests?: number;
|
|
20
|
+
readonly maxListEntries?: number;
|
|
21
|
+
readonly maxDeleteObjects?: number;
|
|
22
|
+
};
|
|
17
23
|
}
|
|
18
24
|
export declare class S3RenameError extends FsError {
|
|
19
25
|
readonly phase: "copy" | "delete";
|
|
@@ -68,6 +74,8 @@ export declare class S3FileSystem implements FileSystem {
|
|
|
68
74
|
private readonly maxReadBytes;
|
|
69
75
|
private readonly maxStreamBytes;
|
|
70
76
|
private readonly maxListEntries;
|
|
77
|
+
private readonly removalLimits;
|
|
78
|
+
private readonly removalBudgets;
|
|
71
79
|
constructor(options: S3FileSystemOptions);
|
|
72
80
|
compareEntry(path: string, peer: FileSystem, peerPath: string, options?: FsOptions): Promise<EntryComparison>;
|
|
73
81
|
private path;
|
|
@@ -82,6 +82,8 @@ export class S3FileSystem {
|
|
|
82
82
|
maxReadBytes;
|
|
83
83
|
maxStreamBytes;
|
|
84
84
|
maxListEntries;
|
|
85
|
+
removalLimits;
|
|
86
|
+
removalBudgets = new WeakMap();
|
|
85
87
|
constructor(options) {
|
|
86
88
|
if (!options?.transport || ["headObject", "getObject", "putObject", "deleteObject", "copyObject", "listObjectsV2"]
|
|
87
89
|
.some((method) => typeof options.transport[method] !== "function")) {
|
|
@@ -105,9 +107,14 @@ export class S3FileSystem {
|
|
|
105
107
|
this.bucket = options.bucket;
|
|
106
108
|
this.allowRename = options.allowNonAtomicRename ?? true;
|
|
107
109
|
this.pageSize = validateLimit(options.pageSize ?? 1000, "pageSize", 1, 1000);
|
|
108
|
-
this.maxReadBytes =
|
|
109
|
-
this.maxStreamBytes = validateLimit(options.maxStreamBytes
|
|
110
|
-
this.maxListEntries = validateLimit(options.maxListEntries
|
|
110
|
+
this.maxReadBytes = options.maxReadBytes === undefined ? Infinity : validateLimit(options.maxReadBytes, "maxReadBytes", 0);
|
|
111
|
+
this.maxStreamBytes = options.maxStreamBytes === undefined ? Infinity : validateLimit(options.maxStreamBytes, "maxStreamBytes", 0);
|
|
112
|
+
this.maxListEntries = options.maxListEntries === undefined ? Infinity : validateLimit(options.maxListEntries, "maxListEntries", 1);
|
|
113
|
+
this.removalLimits = {
|
|
114
|
+
maxRequests: validateLimit(options.removalLimits?.maxRequests ?? 32, "removalLimits.maxRequests", 1),
|
|
115
|
+
maxListEntries: validateLimit(options.removalLimits?.maxListEntries ?? 32, "removalLimits.maxListEntries", 1),
|
|
116
|
+
maxDeleteObjects: validateLimit(options.removalLimits?.maxDeleteObjects ?? 16, "removalLimits.maxDeleteObjects", 1),
|
|
117
|
+
};
|
|
111
118
|
this.capabilities = Object.freeze({
|
|
112
119
|
open: false,
|
|
113
120
|
read: true, stat: true, readdir: true, realpath: true, access: true,
|
|
@@ -182,6 +189,10 @@ export class S3FileSystem {
|
|
|
182
189
|
}
|
|
183
190
|
async call(syscall, path, options, action, precondition) {
|
|
184
191
|
this.checkAbort(options, syscall, path);
|
|
192
|
+
const budget = this.removalBudgets.get(options);
|
|
193
|
+
if (budget && ++budget.requests > this.removalLimits.maxRequests) {
|
|
194
|
+
fail("EFBIG", "rm", path, "S3 removal request budget exceeded");
|
|
195
|
+
}
|
|
185
196
|
let onAbort;
|
|
186
197
|
try {
|
|
187
198
|
const pending = action();
|
|
@@ -254,11 +265,19 @@ export class S3FileSystem {
|
|
|
254
265
|
mtimeMs: timestamp("virtual-bash-mtime", modified), atimeMs: timestamp("virtual-bash-atime", 0), ctimeMs: modified };
|
|
255
266
|
}
|
|
256
267
|
async page(prefix, path, options, delimiter, token, maxKeys = this.pageSize) {
|
|
268
|
+
const budget = this.removalBudgets.get(options);
|
|
269
|
+
if (budget)
|
|
270
|
+
maxKeys = Math.min(maxKeys, this.removalLimits.maxListEntries - budget.entries + 1);
|
|
257
271
|
const result = await this.call("listObjectsV2", path, options, () => this.transport.listObjectsV2({
|
|
258
272
|
Bucket: this.bucket, Prefix: prefix, MaxKeys: maxKeys,
|
|
259
273
|
...(delimiter === undefined ? {} : { Delimiter: delimiter }),
|
|
260
274
|
...(token === undefined ? {} : { ContinuationToken: token }),
|
|
261
275
|
}, this.requestOptions(options)));
|
|
276
|
+
if (budget) {
|
|
277
|
+
budget.entries += (result.Contents?.length ?? 0) + (result.CommonPrefixes?.length ?? 0);
|
|
278
|
+
if (budget.entries > this.removalLimits.maxListEntries)
|
|
279
|
+
fail("EFBIG", "rm", path, "S3 removal listing budget exceeded");
|
|
280
|
+
}
|
|
262
281
|
for (const item of result.Contents ?? []) {
|
|
263
282
|
if (typeof item.Key !== "string" || !item.Key.startsWith(prefix))
|
|
264
283
|
fail("EIO", "listObjectsV2", path, "transport returned an object outside the requested prefix");
|
|
@@ -376,8 +395,11 @@ export class S3FileSystem {
|
|
|
376
395
|
return this.stat(path, options);
|
|
377
396
|
}
|
|
378
397
|
async body(output, path, options) {
|
|
379
|
-
const limit = Math.min(this.maxReadBytes,
|
|
398
|
+
const limit = Math.min(this.maxReadBytes, options.maxBytes === undefined ? this.maxReadBytes : validateLimit(options.maxBytes, "maxBytes", 0));
|
|
399
|
+
let iterator;
|
|
380
400
|
return this.call("readFile", path, options, async () => {
|
|
401
|
+
if (output.Body && Symbol.asyncIterator in output.Body)
|
|
402
|
+
iterator = output.Body[Symbol.asyncIterator]();
|
|
381
403
|
if (output.ContentLength !== undefined && output.ContentLength > limit)
|
|
382
404
|
fail("EFBIG", "readFile", path);
|
|
383
405
|
const body = output.Body;
|
|
@@ -390,7 +412,7 @@ export class S3FileSystem {
|
|
|
390
412
|
bytes = new Uint8Array(body);
|
|
391
413
|
}
|
|
392
414
|
else if (Symbol.asyncIterator in body) {
|
|
393
|
-
bytes = await collectBytes(
|
|
415
|
+
bytes = await collectBytes({ [Symbol.asyncIterator]: () => iterator }, { ...(limit === Infinity ? {} : { maxBytes: limit }), ...(options.signal ? { signal: options.signal } : {}) });
|
|
394
416
|
}
|
|
395
417
|
else if ("transformToByteArray" in body) {
|
|
396
418
|
const converted = await body.transformToByteArray();
|
|
@@ -407,7 +429,7 @@ export class S3FileSystem {
|
|
|
407
429
|
fail("EIO", "readFile", path, "response body length does not match ContentLength");
|
|
408
430
|
return bytes;
|
|
409
431
|
}).catch((error) => {
|
|
410
|
-
this.dispose(output.Body);
|
|
432
|
+
this.dispose(iterator ?? output.Body);
|
|
411
433
|
throw error;
|
|
412
434
|
});
|
|
413
435
|
}
|
|
@@ -416,13 +438,17 @@ export class S3FileSystem {
|
|
|
416
438
|
}
|
|
417
439
|
async readFile(input, options = {}) {
|
|
418
440
|
const path = this.path(input);
|
|
419
|
-
|
|
441
|
+
if (options.maxBytes !== undefined)
|
|
442
|
+
validateLimit(options.maxBytes, "maxBytes", 0);
|
|
420
443
|
const info = await this.stat(input, options);
|
|
421
444
|
if (info.type === "directory")
|
|
422
445
|
fail("EISDIR", "readFile", path);
|
|
423
446
|
if (info.size > Math.min(this.maxReadBytes, options.maxBytes ?? this.maxReadBytes))
|
|
424
447
|
fail("EFBIG", "readFile", path);
|
|
425
|
-
|
|
448
|
+
const output = this.capabilities.streamingRead
|
|
449
|
+
? await this.call("getObject", path, options, () => this.transport.getObjectStream({ Bucket: this.bucket, Key: this.key(path) }, this.requestOptions(options)))
|
|
450
|
+
: await this.get(path, options);
|
|
451
|
+
return this.body(output, path, options);
|
|
426
452
|
}
|
|
427
453
|
etag(metadata, path) {
|
|
428
454
|
if (!metadata?.ETag)
|
|
@@ -594,6 +620,10 @@ export class S3FileSystem {
|
|
|
594
620
|
}
|
|
595
621
|
}
|
|
596
622
|
async rm(input, options = {}) {
|
|
623
|
+
// A distinct options object keeps concurrent and nested operations isolated.
|
|
624
|
+
options = { ...options };
|
|
625
|
+
const budget = { requests: 0, entries: 0 };
|
|
626
|
+
this.removalBudgets.set(options, budget);
|
|
597
627
|
const path = this.path(input);
|
|
598
628
|
this.writable(path);
|
|
599
629
|
if (path === "/")
|
|
@@ -613,9 +643,25 @@ export class S3FileSystem {
|
|
|
613
643
|
return;
|
|
614
644
|
fail("ENOENT", "rm", path);
|
|
615
645
|
}
|
|
616
|
-
|
|
617
|
-
if (info.stat.type === "directory" && !options.recursive
|
|
618
|
-
|
|
646
|
+
let objects;
|
|
647
|
+
if (info.stat.type === "directory" && !options.recursive) {
|
|
648
|
+
const prefix = this.directoryKey(path);
|
|
649
|
+
objects = [];
|
|
650
|
+
for await (const page of this.pages(prefix, path, options, undefined, 2)) {
|
|
651
|
+
if (page.Contents?.some(item => item.Key !== prefix))
|
|
652
|
+
fail("ENOTEMPTY", "rm", path);
|
|
653
|
+
if (page.Contents?.length)
|
|
654
|
+
objects = [{ Key: prefix }];
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
else {
|
|
658
|
+
objects = info.stat.type === "directory" ? await this.tree(path, options) : [{ Key: this.key(path) }];
|
|
659
|
+
}
|
|
660
|
+
// Reserve the complete mutation phase before issuing any deletes.
|
|
661
|
+
if (objects.length > this.removalLimits.maxDeleteObjects
|
|
662
|
+
|| budget.requests + objects.length > this.removalLimits.maxRequests) {
|
|
663
|
+
fail("EFBIG", "rm", path, "S3 removal delete or request budget exceeded; use bounded batches");
|
|
664
|
+
}
|
|
619
665
|
for (const item of objects) {
|
|
620
666
|
await this.call("deleteObject", path, options, () => this.transport.deleteObject({ Bucket: this.bucket, Key: item.Key }, this.requestOptions(options)));
|
|
621
667
|
}
|
|
@@ -1008,7 +1054,7 @@ export class S3FileSystem {
|
|
|
1008
1054
|
const limit = Math.min(this.maxReadBytes, this.maxStreamBytes);
|
|
1009
1055
|
if (previous.length > limit)
|
|
1010
1056
|
fail("EFBIG", "writeStream", path);
|
|
1011
|
-
const bytes = await this.call("writeStream", path, options, () => collectBytes(source, { maxBytes: limit - previous.length, ...options }));
|
|
1057
|
+
const bytes = await this.call("writeStream", path, options, () => collectBytes(source, { ...(limit === Infinity ? {} : { maxBytes: limit - previous.length }), ...options }));
|
|
1012
1058
|
const body = new Uint8Array(previous.length + bytes.length);
|
|
1013
1059
|
body.set(previous);
|
|
1014
1060
|
body.set(bytes, previous.length);
|
|
@@ -4,7 +4,7 @@ export interface RequestScope {
|
|
|
4
4
|
readonly signal: AbortSignal;
|
|
5
5
|
readonly finish: () => void;
|
|
6
6
|
}
|
|
7
|
-
export declare function scopeFor(signal: AbortSignal | undefined, timeout: number): RequestScope;
|
|
7
|
+
export declare function scopeFor(signal: AbortSignal | undefined, timeout: number | undefined): RequestScope;
|
|
8
8
|
export declare function abortable<Value>(pending: Promise<Value>, signal: AbortSignal): Promise<Value>;
|
|
9
9
|
export interface WireResponse {
|
|
10
10
|
readonly message: IncomingMessage;
|
|
@@ -8,7 +8,7 @@ export function scopeFor(signal, timeout) {
|
|
|
8
8
|
signal?.addEventListener("abort", abort, { once: true });
|
|
9
9
|
if (signal?.aborted)
|
|
10
10
|
abort();
|
|
11
|
-
const timer = setTimeout(() => controller.abort(new S3ServiceError("RequestTimeout", 408)), timeout);
|
|
11
|
+
const timer = timeout === undefined ? undefined : setTimeout(() => controller.abort(new S3ServiceError("RequestTimeout", 408)), timeout);
|
|
12
12
|
return {
|
|
13
13
|
signal: controller.signal,
|
|
14
14
|
finish: () => { clearTimeout(timer); signal?.removeEventListener("abort", abort); },
|
|
@@ -4,8 +4,10 @@ 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 limit(value, fallback, name, maximum =
|
|
8
|
-
|
|
7
|
+
function limit(value, fallback, name, maximum = Number.MAX_SAFE_INTEGER) {
|
|
8
|
+
if (value === undefined)
|
|
9
|
+
return fallback;
|
|
10
|
+
const result = value;
|
|
9
11
|
if (!Number.isSafeInteger(result) || result < 1 || result > maximum)
|
|
10
12
|
invalid(`invalid ${name}`);
|
|
11
13
|
return result;
|
|
@@ -161,10 +163,10 @@ export function createS3HttpTransport(options) {
|
|
|
161
163
|
invalid("unsupported LIST URL encoding");
|
|
162
164
|
if (addressing === "virtual-hosted" && (isIP(endpoint.hostname.replace(/^\[|\]$/g, "")) || endpoint.hostname === "localhost"))
|
|
163
165
|
invalid("virtual-hosted addressing requires a DNS endpoint");
|
|
164
|
-
const maxPut = limit(options.maxPutBytes,
|
|
165
|
-
const maxGet = limit(options.maxGetBytes,
|
|
166
|
-
const maxXml = limit(options.maxXmlBytes,
|
|
167
|
-
const timeout = limit(options.requestTimeoutMs,
|
|
166
|
+
const maxPut = limit(options.maxPutBytes, Infinity, "maxPutBytes");
|
|
167
|
+
const maxGet = limit(options.maxGetBytes, Infinity, "maxGetBytes");
|
|
168
|
+
const maxXml = limit(options.maxXmlBytes, Infinity, "maxXmlBytes");
|
|
169
|
+
const timeout = options.requestTimeoutMs === undefined ? undefined : limit(options.requestTimeoutMs, 0, "requestTimeoutMs", 2_147_483_647);
|
|
168
170
|
const enabledCopy = boolean(options.enableCopy, true, "enableCopy");
|
|
169
171
|
const verified = options.verifiedConditionalOperations;
|
|
170
172
|
const conditionalPut = boolean(verified?.put, false, "verifiedConditionalOperations.put");
|
|
@@ -4,7 +4,10 @@ export interface XmlNode {
|
|
|
4
4
|
text: string;
|
|
5
5
|
}
|
|
6
6
|
export declare function malformed(message?: string): never;
|
|
7
|
-
export declare function parseXml(bytes: Uint8Array
|
|
7
|
+
export declare function parseXml(bytes: Uint8Array, limits?: {
|
|
8
|
+
maxNodes?: number;
|
|
9
|
+
maxDepth?: number;
|
|
10
|
+
}): XmlNode;
|
|
8
11
|
export declare function children(node: XmlNode, name: string): XmlNode[];
|
|
9
12
|
export declare function text(node: XmlNode, name: string, required?: boolean): string | undefined;
|
|
10
13
|
export declare function integer(value: string): number;
|