@poe-platform/safe-fs 0.1.726 → 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
|
@@ -15,6 +15,8 @@ export function withObjectFileDescriptors(filesystem, store, options = {}) {
|
|
|
15
15
|
|| store.createStaging !== undefined && typeof store.createStaging !== "function")
|
|
16
16
|
throw new TypeError("Invalid object descriptor configuration");
|
|
17
17
|
const createStaging = store.createStaging?.bind(store);
|
|
18
|
+
const defaultIdentityScope = Symbol("object-file-descriptors");
|
|
19
|
+
const withIdentity = (stat) => stat.type === "file" && stat.identityScope === undefined ? { ...stat, identityScope: defaultIdentityScope } : stat;
|
|
18
20
|
let stagedBytes = 0;
|
|
19
21
|
let openFiles = 0;
|
|
20
22
|
const stagingWaiters = new Set();
|
|
@@ -58,7 +60,7 @@ export function withObjectFileDescriptors(filesystem, store, options = {}) {
|
|
|
58
60
|
throw new FsError("EIO", { message: "Invalid immutable object version" });
|
|
59
61
|
if (value.stat.size > maxFileBytes)
|
|
60
62
|
throw new FsError("EFBIG", { message: "Object descriptor file limit exceeded" });
|
|
61
|
-
return Object.freeze({ revision: value.revision, ...(value.publicationToken === undefined ? {} : { publicationToken: value.publicationToken }), stat: Object.freeze(
|
|
63
|
+
return Object.freeze({ revision: value.revision, ...(value.publicationToken === undefined ? {} : { publicationToken: value.publicationToken }), stat: Object.freeze(withIdentity(value.stat)), read: value.read.bind(value), close: value.close.bind(value) });
|
|
62
64
|
};
|
|
63
65
|
const clearPages = (state) => {
|
|
64
66
|
stagedBytes -= state.pages.size * chunkBytes;
|
|
@@ -434,17 +436,91 @@ export function withObjectFileDescriptors(filesystem, store, options = {}) {
|
|
|
434
436
|
throw error;
|
|
435
437
|
}
|
|
436
438
|
});
|
|
439
|
+
const openReadFile = async (path, readOptions = {}) => {
|
|
440
|
+
if (!readOptions || typeof readOptions !== "object"
|
|
441
|
+
|| Object.keys(readOptions).some(key => key !== "signal" && key !== "allowDirectory")
|
|
442
|
+
|| readOptions.allowDirectory !== undefined && typeof readOptions.allowDirectory !== "boolean") {
|
|
443
|
+
throw new FsError("EINVAL", { syscall: "openReadFile", path });
|
|
444
|
+
}
|
|
445
|
+
readOptions.signal?.throwIfAborted();
|
|
446
|
+
if (readOptions.allowDirectory === true)
|
|
447
|
+
throw new FsError("ENOTSUP", { syscall: "openReadFile", path });
|
|
448
|
+
const descriptor = await open(path, {
|
|
449
|
+
access: "read",
|
|
450
|
+
...(readOptions.signal === undefined ? {} : { signal: readOptions.signal }),
|
|
451
|
+
});
|
|
452
|
+
let retainedStat;
|
|
453
|
+
try {
|
|
454
|
+
retainedStat = await descriptor.stat(readOptions.signal === undefined ? {} : { signal: readOptions.signal });
|
|
455
|
+
}
|
|
456
|
+
catch (error) {
|
|
457
|
+
await finishCleanup(() => descriptor.close(), true);
|
|
458
|
+
throw error;
|
|
459
|
+
}
|
|
460
|
+
let closed = false;
|
|
461
|
+
let closing;
|
|
462
|
+
const assertOpen = (forwarded, syscall) => {
|
|
463
|
+
forwarded.signal?.throwIfAborted();
|
|
464
|
+
if (closed)
|
|
465
|
+
throw new FsError("EBADF", { syscall, path });
|
|
466
|
+
};
|
|
467
|
+
return {
|
|
468
|
+
async stat(forwarded = {}) {
|
|
469
|
+
assertOpen(forwarded, "fstat");
|
|
470
|
+
return descriptor.stat(forwarded);
|
|
471
|
+
},
|
|
472
|
+
async seekEnd(forwarded = {}) {
|
|
473
|
+
assertOpen(forwarded, "lseek");
|
|
474
|
+
const stat = await descriptor.stat(forwarded);
|
|
475
|
+
return BigInt(stat.size);
|
|
476
|
+
},
|
|
477
|
+
async read(position, maxBytes, forwarded = {}) {
|
|
478
|
+
assertOpen(forwarded, "read");
|
|
479
|
+
if (!Number.isSafeInteger(position) || position < 0
|
|
480
|
+
|| !Number.isSafeInteger(maxBytes) || maxBytes <= 0
|
|
481
|
+
|| maxBytes > Number.MAX_SAFE_INTEGER - position) {
|
|
482
|
+
throw new FsError("EINVAL", { syscall: "read", path });
|
|
483
|
+
}
|
|
484
|
+
const length = Math.min(maxBytes, Math.max(0, retainedStat.size - position));
|
|
485
|
+
if (length === 0) {
|
|
486
|
+
await descriptor.stat(forwarded);
|
|
487
|
+
return new Uint8Array();
|
|
488
|
+
}
|
|
489
|
+
const buffer = new Uint8Array(length);
|
|
490
|
+
const count = await descriptor.read(buffer, position, forwarded);
|
|
491
|
+
return count === buffer.byteLength ? buffer : buffer.subarray(0, count);
|
|
492
|
+
},
|
|
493
|
+
close() {
|
|
494
|
+
closed = true;
|
|
495
|
+
return closing ??= descriptor.close();
|
|
496
|
+
},
|
|
497
|
+
};
|
|
498
|
+
};
|
|
437
499
|
return new Proxy(Object.create(filesystem), {
|
|
438
500
|
get(_target, property) {
|
|
439
501
|
if (property === "open")
|
|
440
502
|
return open;
|
|
503
|
+
if (property === "openReadFile")
|
|
504
|
+
return openReadFile;
|
|
505
|
+
if (property === "stat" || property === "lstat")
|
|
506
|
+
return async (path, statOptions) => withIdentity(await filesystem[property](path, statOptions));
|
|
441
507
|
if (property === "capabilities")
|
|
442
|
-
return {
|
|
508
|
+
return {
|
|
509
|
+
...filesystem.capabilities,
|
|
510
|
+
open: true,
|
|
511
|
+
versionedDescriptors: true,
|
|
512
|
+
retainedRead: filesystem.capabilities.read !== false,
|
|
513
|
+
};
|
|
443
514
|
if (property === "capabilitiesFor")
|
|
444
515
|
return async (path, query) => {
|
|
445
516
|
const capabilities = await filesystem.capabilitiesFor?.(path, query) ?? filesystem.capabilities;
|
|
446
517
|
query?.signal?.throwIfAborted();
|
|
447
|
-
return {
|
|
518
|
+
return {
|
|
519
|
+
...capabilities,
|
|
520
|
+
open: true,
|
|
521
|
+
versionedDescriptors: true,
|
|
522
|
+
...(query?.allowDirectory ? {} : { retainedRead: capabilities.read !== false }),
|
|
523
|
+
};
|
|
448
524
|
};
|
|
449
525
|
const value = Reflect.get(filesystem, property, filesystem);
|
|
450
526
|
return typeof value === "function" ? value.bind(filesystem) : value;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ByteSource } from "../../contracts/io.js";
|
|
2
2
|
import type { FileDescriptor, OpenFileOptions } from "../../contracts/descriptor.js";
|
|
3
|
-
import type { CreateStagedFileOptions, FileStaging, PublishStagedFileOptions, StagedFileContent, AppendFileOptions, CapabilityQueryOptions, CopyFileOptions, DirectoryEntry, OpenReadFileOptions, FileReadHandle, FileStat, FileSystem, FileSystemCapabilities, FsOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, WriteFileOptions } from "../../contracts/filesystem.js";
|
|
3
|
+
import type { CreateStagedFileOptions, FileStaging, PublishStagedFileOptions, StagedFileContent, AppendFileOptions, CapabilityQueryOptions, CopyFileOptions, DirectoryEntry, OpenReadFileOptions, FileReadHandle, FileStat, FileSystem, FileSystemCapabilities, FsOptions, ChmodOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, WriteFileOptions } from "../../contracts/filesystem.js";
|
|
4
4
|
export interface OverlayFileSystemOptions {
|
|
5
5
|
readonly upper: FileSystem;
|
|
6
6
|
readonly lower: FileSystem;
|
|
@@ -73,7 +73,7 @@ export declare class OverlayFileSystem implements FileSystem {
|
|
|
73
73
|
symlink(target: string, path: string, options?: FsOptions): Promise<void>;
|
|
74
74
|
link(_existingPath: string, newPath: string, options?: FsOptions): Promise<void>;
|
|
75
75
|
private metadata;
|
|
76
|
-
chmod(path: string, permissions: number, options?:
|
|
76
|
+
chmod(path: string, permissions: number, options?: ChmodOptions): Promise<void>;
|
|
77
77
|
utimes(path: string, atimeMs: number, mtimeMs: number, options?: FsOptions): Promise<void>;
|
|
78
78
|
truncate(path: string, length?: number, options?: FsOptions): Promise<void>;
|
|
79
79
|
readStream(path: string, options?: ReadStreamOptions): ByteSource;
|
|
@@ -123,7 +123,8 @@ export class OverlayFileSystem {
|
|
|
123
123
|
this.capabilities = Object.freeze({
|
|
124
124
|
...semantics,
|
|
125
125
|
open: false,
|
|
126
|
-
|
|
126
|
+
conditionalChmod: this.publication.supported(),
|
|
127
|
+
atomicFilePublication: false, atomicFileMutation: false, atomicEntryRemoval: false, atomicEntryRemovalReceipt: false, atomicFileStaging: this.publication.supported(), atomicStagingAncestry: this.publication.supported(), atomicDirectoryMetadata: false, trustedOwnedStaging: false,
|
|
127
128
|
implicitDirectories: false,
|
|
128
129
|
readlink: upper.readlink === true && this.#lower.capabilities.readlink === true ? true
|
|
129
130
|
: upper.readlink === false && this.#lower.capabilities.readlink === false ? false : undefined,
|
|
@@ -683,13 +684,14 @@ export class OverlayFileSystem {
|
|
|
683
684
|
}
|
|
684
685
|
return location;
|
|
685
686
|
}
|
|
686
|
-
async replace(location, options, operation, streaming = false) {
|
|
687
|
+
async replace(location, options, operation, streaming = false, commitGuard) {
|
|
687
688
|
await this.parent(location.path, options);
|
|
688
689
|
await this.staged(options, async (temporary) => {
|
|
689
690
|
if (location.entry)
|
|
690
691
|
await this.clone(location.entry, temporary, options, streaming);
|
|
691
692
|
await operation(temporary);
|
|
692
693
|
options.signal?.throwIfAborted();
|
|
694
|
+
commitGuard?.();
|
|
693
695
|
await this.#upper.rename(temporary, location.path, options);
|
|
694
696
|
this.linkMetadata.delete(location.path);
|
|
695
697
|
this.linkOrigins.delete(location.path);
|
|
@@ -1003,6 +1005,24 @@ export class OverlayFileSystem {
|
|
|
1003
1005
|
if (permissions === undefined)
|
|
1004
1006
|
fail("EINVAL", path);
|
|
1005
1007
|
mode(permissions, path);
|
|
1008
|
+
const guard = this.publication.prepareChmod(path, options);
|
|
1009
|
+
if (guard) {
|
|
1010
|
+
const controls = options.signal === undefined ? {} : { signal: options.signal };
|
|
1011
|
+
return this.run(controls, async () => {
|
|
1012
|
+
this.writable(path);
|
|
1013
|
+
guard();
|
|
1014
|
+
const entry = await this.required(path, controls, false);
|
|
1015
|
+
guard();
|
|
1016
|
+
if (entry.stat.type === "directory") {
|
|
1017
|
+
await this.copyUp(entry, controls);
|
|
1018
|
+
guard();
|
|
1019
|
+
await this.#upper.chmod(entry.path, permissions, controls);
|
|
1020
|
+
}
|
|
1021
|
+
else {
|
|
1022
|
+
await this.replace({ path: entry.path, entry }, controls, temporary => this.#upper.chmod(temporary, permissions, controls), false, guard);
|
|
1023
|
+
}
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1006
1026
|
return this.metadata(path, "permissions", options, (target) => this.#upper.chmod(target, permissions, options));
|
|
1007
1027
|
}
|
|
1008
1028
|
async utimes(path, atimeMs, mtimeMs, options = {}) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CreateStagedFileOptions, FileStaging, FileStat, FileSystem, FsOptions, PublishStagedFileOptions, StagedFileContent } from "../../contracts/filesystem.js";
|
|
1
|
+
import type { CreateStagedFileOptions, FileStaging, FileStat, FileSystem, FsOptions, ChmodOptions, PublishStagedFileOptions, StagedFileContent } from "../../contracts/filesystem.js";
|
|
2
2
|
/** Stock Memory mutations commit before returning their promise. No user code or
|
|
3
3
|
* await is allowed between inspecting the two stores and invoking a mutation. */
|
|
4
4
|
export declare class OverlayMemoryPublication {
|
|
@@ -26,6 +26,7 @@ export declare class OverlayMemoryPublication {
|
|
|
26
26
|
private inspect;
|
|
27
27
|
private capture;
|
|
28
28
|
private check;
|
|
29
|
+
prepareChmod(path: string, options: ChmodOptions): (() => true) | undefined;
|
|
29
30
|
private copyParents;
|
|
30
31
|
create(directory: string, name: string, content: StagedFileContent, options: CreateStagedFileOptions): Promise<FileStaging>;
|
|
31
32
|
publish(staging: FileStaging, path: string, options: PublishStagedFileOptions): Promise<void>;
|
|
@@ -2,6 +2,8 @@ import { FsError, isFsError } from "../../contracts/errors.js";
|
|
|
2
2
|
import { dirname, isPathWithin, validatePath } from "../../contracts/virtual-path.js";
|
|
3
3
|
import { memoryAtomicView } from "../memory/atomic-view.js";
|
|
4
4
|
import { compareIdentity } from "../mount/identity.js";
|
|
5
|
+
import { snapshotConditionalChmod } from "../conditional-chmod.js";
|
|
6
|
+
import { runStagingGuard } from "../staging-ancestry.js";
|
|
5
7
|
/** Stock Memory mutations commit before returning their promise. No user code or
|
|
6
8
|
* await is allowed between inspecting the two stores and invoking a mutation. */
|
|
7
9
|
export class OverlayMemoryPublication {
|
|
@@ -21,10 +23,10 @@ export class OverlayMemoryPublication {
|
|
|
21
23
|
this.opaque = opaque;
|
|
22
24
|
this.hidden = hidden;
|
|
23
25
|
}
|
|
24
|
-
supported() { return !!memoryAtomicView(this.upper) && !!memoryAtomicView(this.lower); }
|
|
26
|
+
supported() { return this.upper.capabilities.readOnly !== true && !!memoryAtomicView(this.upper) && !!memoryAtomicView(this.lower); }
|
|
25
27
|
stores() {
|
|
26
28
|
const upper = memoryAtomicView(this.upper), lower = memoryAtomicView(this.lower);
|
|
27
|
-
if (!upper || !lower)
|
|
29
|
+
if (!upper || !lower || this.upper.capabilities.readOnly === true)
|
|
28
30
|
throw new FsError("ENOTSUP", { syscall: "overlayPublication" });
|
|
29
31
|
return { upper, lower };
|
|
30
32
|
}
|
|
@@ -138,6 +140,41 @@ export class OverlayMemoryPublication {
|
|
|
138
140
|
throw new FsError("ENOTDIR", { path: entry.path });
|
|
139
141
|
}
|
|
140
142
|
}
|
|
143
|
+
prepareChmod(path, options) {
|
|
144
|
+
const { signal, parent, expected, ancestors, commitGuard } = options;
|
|
145
|
+
const sources = ancestors?.map(entry => ({ path: entry.path, stat: entry.stat }));
|
|
146
|
+
const captured = snapshotConditionalChmod(path, {
|
|
147
|
+
...(signal === undefined ? {} : { signal }), ...(parent === undefined ? {} : { parent }),
|
|
148
|
+
...(expected === undefined ? {} : { expected }), ...(sources === undefined ? {} : { ancestors: sources }),
|
|
149
|
+
...(commitGuard === undefined ? {} : { commitGuard }),
|
|
150
|
+
});
|
|
151
|
+
if (!captured)
|
|
152
|
+
return undefined;
|
|
153
|
+
this.stores();
|
|
154
|
+
this.path(path);
|
|
155
|
+
const retain = (source, snapshot) => {
|
|
156
|
+
const receipt = this.receipts.get(source);
|
|
157
|
+
if (!receipt)
|
|
158
|
+
throw new FsError("ENOTSUP", { path, message: "unowned overlay chmod receipt" });
|
|
159
|
+
this.receipts.set(snapshot, receipt);
|
|
160
|
+
};
|
|
161
|
+
retain(parent, captured.parent);
|
|
162
|
+
retain(expected, captured.expected);
|
|
163
|
+
for (const [index, entry] of captured.ancestors.entries())
|
|
164
|
+
retain(sources[index].stat, entry.stat);
|
|
165
|
+
return () => {
|
|
166
|
+
signal?.throwIfAborted();
|
|
167
|
+
if (captured.commitGuard)
|
|
168
|
+
runStagingGuard(captured.commitGuard);
|
|
169
|
+
this.stores();
|
|
170
|
+
this.check(captured.ancestors);
|
|
171
|
+
this.expect(dirname(path), captured.parent);
|
|
172
|
+
this.expect(path, captured.expected, captured.expected.type !== "directory");
|
|
173
|
+
if (this.inspect(path)?.type !== captured.expected.type)
|
|
174
|
+
throw new FsError("EAGAIN", { path });
|
|
175
|
+
return true;
|
|
176
|
+
};
|
|
177
|
+
}
|
|
141
178
|
async copyParents(entries, options) {
|
|
142
179
|
for (const entry of entries) {
|
|
143
180
|
options.signal?.throwIfAborted();
|
|
@@ -499,7 +499,7 @@ export function withFileSystemQuota(fs, options) {
|
|
|
499
499
|
// capabilities and methods without violating invariants on own properties.
|
|
500
500
|
return new Proxy(Object.create(fs), {
|
|
501
501
|
get(_target, property) {
|
|
502
|
-
if (property === "publishFileConditional" || property === "removeEntryConditional" || property === "writeFileConditional" || property === "removeFileConditional" || property === "resizeFile" || property === "canonicalizeMissingTarget" || property === "createStagedFile" || property === "publishStagedFile" || property === "removeStagedFile" || property === "prepareDirectory")
|
|
502
|
+
if (property === "prepareStagingResolution" || property === "prepareDirectoryAncestry" || property === "publishFileConditional" || property === "removeEntryConditional" || property === "writeFileConditional" || property === "removeFileConditional" || property === "resizeFile" || property === "canonicalizeMissingTarget" || property === "createStagedFile" || property === "publishStagedFile" || property === "removeStagedFile" || property === "prepareDirectory")
|
|
503
503
|
return undefined;
|
|
504
504
|
if (property === "capabilities")
|
|
505
505
|
return quotaCapabilities({ ...retainedResizeCapabilities(fs, retainedReadCapabilities(fs)), ...(typeof fs.open === "function" ? {} : { open: false }) });
|
|
@@ -3,6 +3,7 @@ import type { AppendFileOptions, CapabilityQueryOptions, CopyFileOptions, Direct
|
|
|
3
3
|
import type { FileDescriptor, OpenFileOptions } from "../../contracts/descriptor.js";
|
|
4
4
|
export declare class ReadOnlyFileSystem implements FileSystem {
|
|
5
5
|
#private;
|
|
6
|
+
readonly removeEntryConditional?: NonNullable<FileSystem["removeEntryConditional"]>;
|
|
6
7
|
constructor(filesystem: FileSystem);
|
|
7
8
|
get capabilities(): FileSystemCapabilities;
|
|
8
9
|
capabilitiesFor(path: string, options?: CapabilityQueryOptions): Promise<FileSystemCapabilities>;
|
|
@@ -7,6 +7,7 @@ import { openRetainedReadFile, readOnlyCapabilities, retainedReadCapabilities }
|
|
|
7
7
|
import { admitDirectoryEntries, directoryEntryLimit } from "../directory-admission.js";
|
|
8
8
|
import { forwardFileDescriptor, openFileDescriptor } from "../descriptor.js";
|
|
9
9
|
import { pathNamespace, readOnlyPathNamespace } from "../path-namespace.js";
|
|
10
|
+
import { memoryAtomicView, registerMemoryAtomicView } from "../memory/atomic-view.js";
|
|
10
11
|
function readOnly(syscall, path, dest) {
|
|
11
12
|
throw new FsError("EROFS", { syscall, path, ...(dest === undefined ? {} : { dest }) });
|
|
12
13
|
}
|
|
@@ -37,6 +38,17 @@ export class ReadOnlyFileSystem {
|
|
|
37
38
|
#capabilities;
|
|
38
39
|
constructor(filesystem) {
|
|
39
40
|
this.#filesystem = filesystem;
|
|
41
|
+
const atomic = memoryAtomicView(filesystem);
|
|
42
|
+
if (atomic)
|
|
43
|
+
registerMemoryAtomicView(this, atomic, () => {
|
|
44
|
+
if (Object.getPrototypeOf(this) !== ReadOnlyFileSystem.prototype || memoryAtomicView(filesystem) !== atomic)
|
|
45
|
+
return false;
|
|
46
|
+
return Object.entries(readOnlyImplementation).every(([name, expected]) => {
|
|
47
|
+
const actual = Object.getOwnPropertyDescriptor(this, name)
|
|
48
|
+
?? Object.getOwnPropertyDescriptor(ReadOnlyFileSystem.prototype, name);
|
|
49
|
+
return actual?.value === expected.value && actual?.get === expected.get && actual?.set === expected.set;
|
|
50
|
+
});
|
|
51
|
+
});
|
|
40
52
|
if (Reflect.has(filesystem, pathNamespace))
|
|
41
53
|
Object.defineProperty(this, pathNamespace, {
|
|
42
54
|
get: () => readOnlyPathNamespace(Reflect.get(filesystem, pathNamespace)),
|
|
@@ -190,6 +202,7 @@ export class ReadOnlyFileSystem {
|
|
|
190
202
|
readOnly("openResizeFile", path);
|
|
191
203
|
}
|
|
192
204
|
}
|
|
205
|
+
const readOnlyImplementation = Object.getOwnPropertyDescriptors(ReadOnlyFileSystem.prototype);
|
|
193
206
|
export function createReadOnlyFileSystem(filesystem) {
|
|
194
207
|
return new ReadOnlyFileSystem(filesystem);
|
|
195
208
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { FileDescriptor, OpenFileOptions } from "../../contracts/descriptor.js";
|
|
2
|
-
import type { AppendFileOptions, ByteSource, CopyFileOptions, DirectoryEntry, FileReadHandle, FileResizeHandle, FileStat, FileSystem, FileSystemCapabilities, FsOptions, RenameOptions, MkdirOptions, ConditionalWriteFileOptions, ConditionalRemoveFileOptions, CreateStagedFileOptions, FileStaging, PublishStagedFileOptions, PrepareDirectoryOptions, StagedFileContent, OpenReadFileOptions, OpenResizeFileOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, WriteFileOptions } from "../../contracts/index.js";
|
|
2
|
+
import type { AppendFileOptions, ByteSource, CopyFileOptions, DirectoryEntry, FileReadHandle, FileResizeHandle, FileStat, FileSystem, FileSystemCapabilities, FsOptions, RenameOptions, MkdirOptions, ChmodOptions, FileStagingEntry, ConditionalWriteFileOptions, ConditionalRemoveFileOptions, CreateStagedFileOptions, FileStaging, PublishStagedFileOptions, PrepareDirectoryOptions, StagedFileContent, OpenReadFileOptions, OpenResizeFileOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, WriteFileOptions } from "../../contracts/index.js";
|
|
3
3
|
export interface RealFileSystemOptions {
|
|
4
4
|
/** An existing, absolute host directory. It is never created implicitly. */
|
|
5
5
|
readonly root: string;
|
|
@@ -36,6 +36,9 @@ export interface RealFileSystemOptions {
|
|
|
36
36
|
* concurrent renames, mount changes, and preexisting hardlinks cannot be made
|
|
37
37
|
* safe by these APIs. This is NOT a race-proof sandbox or an isolation boundary
|
|
38
38
|
* against another process modifying the tree. Use an OS sandbox for that.
|
|
39
|
+
* Conditional chmod and synchronous ancestry validation use the same trusted,
|
|
40
|
+
* externally isolated tree boundary as owned staging. Their final checks and
|
|
41
|
+
* metadata commit do not yield to JavaScript; they do not prevent OS races.
|
|
39
42
|
*
|
|
40
43
|
* Only regular files, directories, and symlinks are represented. Permissions,
|
|
41
44
|
* ownership, timestamp precision, case sensitivity, umask, and rename behavior
|
|
@@ -62,6 +65,7 @@ export declare class RealFileSystem implements FileSystem {
|
|
|
62
65
|
private stagingSnapshot;
|
|
63
66
|
private expectStaging;
|
|
64
67
|
private stagingMetadata;
|
|
68
|
+
prepareDirectoryAncestry(ancestors: readonly FileStagingEntry[], options?: FsOptions): Promise<() => true>;
|
|
65
69
|
createStagedFile(directoryPath: string, name: string, content: StagedFileContent, options: CreateStagedFileOptions): Promise<FileStaging>;
|
|
66
70
|
private stagingPaths;
|
|
67
71
|
private checkStaging;
|
|
@@ -89,7 +93,7 @@ export declare class RealFileSystem implements FileSystem {
|
|
|
89
93
|
readlink(path: string, options?: FsOptions): Promise<string>;
|
|
90
94
|
symlink(target: string, path: string, options?: FsOptions): Promise<void>;
|
|
91
95
|
link(existingPath: string, newPath: string, options?: FsOptions): Promise<void>;
|
|
92
|
-
chmod(path: string, mode: number, options?:
|
|
96
|
+
chmod(path: string, mode: number, options?: ChmodOptions): Promise<void>;
|
|
93
97
|
utimes(path: string, atimeMs: number, mtimeMs: number, options?: FsOptions): Promise<void>;
|
|
94
98
|
truncate(path: string, length?: number, options?: FsOptions): Promise<void>;
|
|
95
99
|
openResizeFile(path: string, options?: OpenResizeFileOptions): Promise<FileResizeHandle>;
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { snapshotStagingCreation } from "../staging-cleanup.js";
|
|
2
|
+
import { snapshotConditionalChmod } from "../conditional-chmod.js";
|
|
3
|
+
import { runStagingGuard, snapshotDirectoryAncestry } from "../staging-ancestry.js";
|
|
1
4
|
import { constants } from "node:fs";
|
|
2
5
|
import * as immediate from "node:fs";
|
|
3
6
|
import * as native from "node:fs/promises";
|
|
@@ -79,6 +82,9 @@ function nativeError(error) {
|
|
|
79
82
|
* concurrent renames, mount changes, and preexisting hardlinks cannot be made
|
|
80
83
|
* safe by these APIs. This is NOT a race-proof sandbox or an isolation boundary
|
|
81
84
|
* against another process modifying the tree. Use an OS sandbox for that.
|
|
85
|
+
* Conditional chmod and synchronous ancestry validation use the same trusted,
|
|
86
|
+
* externally isolated tree boundary as owned staging. Their final checks and
|
|
87
|
+
* metadata commit do not yield to JavaScript; they do not prevent OS races.
|
|
82
88
|
*
|
|
83
89
|
* Only regular files, directories, and symlinks are represented. Permissions,
|
|
84
90
|
* ownership, timestamp precision, case sensitivity, umask, and rename behavior
|
|
@@ -98,6 +104,7 @@ export class RealFileSystem {
|
|
|
98
104
|
rename: true, atomicRenameNoReplace: false, copy: true, exclusiveCopy: true, readlink: true, truncate: true,
|
|
99
105
|
streamingAppend: true, randomAccessWrite: true,
|
|
100
106
|
readOnly: false, symlinks: true, hardlinks: true, permissions: true,
|
|
107
|
+
conditionalChmod: true,
|
|
101
108
|
timestamps: true, atomicRename: true, streamingRead: true, streamingWrite: true, retainedRead: true, retainedResize: true, trustedOwnedStaging: true,
|
|
102
109
|
});
|
|
103
110
|
configuredRoot;
|
|
@@ -244,18 +251,22 @@ export class RealFileSystem {
|
|
|
244
251
|
throw new FsError("ENOENT");
|
|
245
252
|
return this.walk(await this.root(options), path.split("/").map((name) => ({ name, fromLink: false })), options);
|
|
246
253
|
}
|
|
247
|
-
async operation(syscall, path, options, action, dest) {
|
|
254
|
+
async operation(syscall, path, options, action, dest, preserveReceipt = false) {
|
|
248
255
|
options.signal?.throwIfAborted();
|
|
249
256
|
try {
|
|
250
257
|
validatePath(path);
|
|
251
258
|
if (dest !== undefined)
|
|
252
259
|
validatePath(dest);
|
|
253
260
|
const result = await action();
|
|
254
|
-
|
|
261
|
+
if (!preserveReceipt)
|
|
262
|
+
options.signal?.throwIfAborted();
|
|
255
263
|
return result;
|
|
256
264
|
}
|
|
257
265
|
catch (error) {
|
|
258
|
-
|
|
266
|
+
if (!preserveReceipt)
|
|
267
|
+
options.signal?.throwIfAborted();
|
|
268
|
+
else if (options.signal?.aborted && Object.is(error, options.signal.reason))
|
|
269
|
+
throw error;
|
|
259
270
|
const converted = nativeError(error);
|
|
260
271
|
throw new FsError(converted.code, {
|
|
261
272
|
syscall, path, ...(dest === undefined ? {} : { dest }),
|
|
@@ -298,11 +309,37 @@ export class RealFileSystem {
|
|
|
298
309
|
if (time !== undefined && (!Number.isFinite(time) || Math.abs(time) > 8.64e15))
|
|
299
310
|
throw new FsError("EINVAL");
|
|
300
311
|
}
|
|
312
|
+
async prepareDirectoryAncestry(ancestors, options = {}) {
|
|
313
|
+
const controls = options.signal === undefined ? {} : { signal: options.signal };
|
|
314
|
+
controls.signal?.throwIfAborted();
|
|
315
|
+
const entries = snapshotDirectoryAncestry(ancestors);
|
|
316
|
+
const path = entries.at(-1).path;
|
|
317
|
+
return this.operation("prepareDirectoryAncestry", path, controls, async () => {
|
|
318
|
+
const root = await this.root(controls);
|
|
319
|
+
const validate = () => {
|
|
320
|
+
controls.signal?.throwIfAborted();
|
|
321
|
+
try {
|
|
322
|
+
for (const entry of entries)
|
|
323
|
+
this.expectStaging(join(root, entry.path.slice(1)), entry.stat, true);
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
controls.signal?.throwIfAborted();
|
|
328
|
+
throw new FsError(nativeError(error).code, { syscall: "prepareDirectoryAncestry", path });
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
validate();
|
|
332
|
+
return validate;
|
|
333
|
+
});
|
|
334
|
+
}
|
|
301
335
|
async createStagedFile(directoryPath, name, content, options) {
|
|
336
|
+
options = snapshotStagingCreation(options, directoryPath);
|
|
302
337
|
// Unlike operation(), this must return its receipt after commit even if the
|
|
303
338
|
// signal is aborted before the promise settles.
|
|
304
339
|
options.signal?.throwIfAborted();
|
|
305
340
|
try {
|
|
341
|
+
if (options.retainCleanup === true)
|
|
342
|
+
throw new FsError("ENOTSUP");
|
|
306
343
|
if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\0"))
|
|
307
344
|
throw new FsError("EINVAL");
|
|
308
345
|
this.stagingMetadata(options);
|
|
@@ -369,6 +406,9 @@ export class RealFileSystem {
|
|
|
369
406
|
}
|
|
370
407
|
async publishStagedFile(staging, destination, options) {
|
|
371
408
|
return this.operation("publishStagedFile", staging.file.path, options, async () => {
|
|
409
|
+
options.signal?.throwIfAborted();
|
|
410
|
+
if (options.ancestors !== undefined || options.commitGuard !== undefined)
|
|
411
|
+
throw new FsError("ENOTSUP");
|
|
372
412
|
const paths = await this.stagingPaths(staging, options);
|
|
373
413
|
const target = await this.path(destination, { ...options, followFinal: false, missing: "final" });
|
|
374
414
|
options.signal?.throwIfAborted();
|
|
@@ -377,12 +417,13 @@ export class RealFileSystem {
|
|
|
377
417
|
this.expectStaging(paths.file, staging.file.stat);
|
|
378
418
|
this.expectStaging(dirname(target), options.parent, true);
|
|
379
419
|
const existing = this.expectStaging(target, options.destination);
|
|
380
|
-
if (existing &&
|
|
381
|
-
|
|
382
|
-
if (target === paths.file || target === paths.directory || dirname(target) === paths.directory)
|
|
420
|
+
if (existing && existing.dev === staging.file.stat.dev && existing.ino === staging.file.stat.ino
|
|
421
|
+
|| target === paths.file || target === paths.directory || dirname(target) === paths.directory)
|
|
383
422
|
throw new FsError("EINVAL");
|
|
423
|
+
if (existing && existing.type !== "file")
|
|
424
|
+
throw new FsError("EAGAIN");
|
|
384
425
|
immediate.renameSync(paths.file, target);
|
|
385
|
-
}, destination);
|
|
426
|
+
}, destination, true);
|
|
386
427
|
}
|
|
387
428
|
async removeStagedFile(staging, options = {}) {
|
|
388
429
|
return this.operation("removeStagedFile", staging.directory.path, options, async () => {
|
|
@@ -410,7 +451,7 @@ export class RealFileSystem {
|
|
|
410
451
|
options.signal?.throwIfAborted();
|
|
411
452
|
this.expectStaging(dirname(target), options.parent, true);
|
|
412
453
|
const existing = this.expectStaging(target, options.expected);
|
|
413
|
-
if (existing &&
|
|
454
|
+
if (existing && existing.type !== "file")
|
|
414
455
|
throw new FsError("EAGAIN");
|
|
415
456
|
const fd = immediate.openSync(target, constants.O_WRONLY | constants.O_NOFOLLOW | (existing ? 0 : constants.O_CREAT | constants.O_EXCL), options.mode ?? 0o666);
|
|
416
457
|
try {
|
|
@@ -427,6 +468,10 @@ export class RealFileSystem {
|
|
|
427
468
|
}
|
|
428
469
|
if (options.mode !== undefined)
|
|
429
470
|
immediate.fchmodSync(fd, options.mode);
|
|
471
|
+
if (options.atimeMs !== undefined || options.mtimeMs !== undefined) {
|
|
472
|
+
const stat = immediate.fstatSync(fd);
|
|
473
|
+
immediate.futimesSync(fd, (options.atimeMs ?? stat.atimeMs) / 1000, (options.mtimeMs ?? stat.mtimeMs) / 1000);
|
|
474
|
+
}
|
|
430
475
|
return fileStat(immediate.fstatSync(fd, { bigint: true }));
|
|
431
476
|
}
|
|
432
477
|
finally {
|
|
@@ -769,6 +814,31 @@ export class RealFileSystem {
|
|
|
769
814
|
async chmod(path, mode, options = {}) {
|
|
770
815
|
return this.operation("chmod", path, options, async () => {
|
|
771
816
|
integer(mode);
|
|
817
|
+
const conditional = snapshotConditionalChmod(path, options);
|
|
818
|
+
if (conditional) {
|
|
819
|
+
const target = await this.path(path, { ...conditional, followFinal: false });
|
|
820
|
+
const root = await this.root(conditional);
|
|
821
|
+
if ((target === root ? "/" : `/${relative(root, target)}`) !== path)
|
|
822
|
+
throw new FsError("EAGAIN");
|
|
823
|
+
if (conditional.expected.type !== "file" && conditional.expected.type !== "directory")
|
|
824
|
+
throw new FsError("ENOTSUP");
|
|
825
|
+
const parent = target === root ? root : dirname(target);
|
|
826
|
+
const validate = () => {
|
|
827
|
+
conditional.signal?.throwIfAborted();
|
|
828
|
+
for (const entry of conditional.ancestors)
|
|
829
|
+
this.expectStaging(join(root, entry.path.slice(1)), entry.stat, true);
|
|
830
|
+
this.expectStaging(parent, conditional.parent, true);
|
|
831
|
+
const current = this.expectStaging(target, conditional.expected, conditional.expected.type === "directory");
|
|
832
|
+
if (current?.mode !== conditional.expected.mode)
|
|
833
|
+
throw new FsError("EAGAIN");
|
|
834
|
+
};
|
|
835
|
+
validate();
|
|
836
|
+
if (conditional.commitGuard)
|
|
837
|
+
runStagingGuard(conditional.commitGuard);
|
|
838
|
+
validate();
|
|
839
|
+
immediate.chmodSync(target, mode);
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
772
842
|
const target = await this.path(path, options);
|
|
773
843
|
options.signal?.throwIfAborted();
|
|
774
844
|
await native.chmod(target, mode);
|
|
@@ -14,6 +14,7 @@ export interface S3FileSystemOptions {
|
|
|
14
14
|
readonly maxReadBytes?: number;
|
|
15
15
|
readonly maxStreamBytes?: number;
|
|
16
16
|
readonly maxListEntries?: number;
|
|
17
|
+
readonly maxRequests?: number;
|
|
17
18
|
/** Aggregate budgets for each rm operation, including ancestor inspection. */
|
|
18
19
|
readonly removalLimits?: {
|
|
19
20
|
readonly maxRequests?: number;
|
|
@@ -74,6 +75,8 @@ export declare class S3FileSystem implements FileSystem {
|
|
|
74
75
|
private readonly maxReadBytes;
|
|
75
76
|
private readonly maxStreamBytes;
|
|
76
77
|
private readonly maxListEntries;
|
|
78
|
+
private readonly maxRequests;
|
|
79
|
+
private requests;
|
|
77
80
|
private readonly removalLimits;
|
|
78
81
|
private readonly removalBudgets;
|
|
79
82
|
constructor(options: S3FileSystemOptions);
|