@poe-platform/safe-fs 0.1.725 → 0.1.727
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 +1 -1
- package/dist/safe-fs/contracts/filesystem.d.ts +3 -0
- package/dist/safe-fs/fs/memory/atomic-view.d.ts +8 -0
- package/dist/safe-fs/fs/memory/atomic-view.js +8 -0
- package/dist/safe-fs/fs/memory/index.js +35 -0
- package/dist/safe-fs/fs/overlay/index.d.ts +6 -1
- package/dist/safe-fs/fs/overlay/index.js +48 -3
- package/dist/safe-fs/fs/overlay/memory-publication.d.ts +34 -0
- package/dist/safe-fs/fs/overlay/memory-publication.js +295 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -348,7 +348,7 @@ See the [binding types](src/fs/webdav/webdav.ts) before implementing atomic dire
|
|
|
348
348
|
## Safety boundary and limitations
|
|
349
349
|
|
|
350
350
|
- **Not an OS sandbox.** Rooted storage and bridge confinement check paths and symlinks, but checks and host operations can race concurrent changes. Use a separate OS isolation boundary for hostile workloads. A custom adapter or transport is trusted code with its own host authority.
|
|
351
|
-
- **A read-only view is not an immutable store.** Mount views serialize guest symlink, rename, and removal operations against active path operations. Finish or close read streams before awaiting these namespace changes. Other references can still change the backing filesystem; this coordination applies only to operations through the same mount view. Overlay reads require retained handles and stable object and ancestor identities; unsupported backends or changed read admission fail with `ENOTSUP`. Overlays are not transactions; cancellation and cleanup do not guarantee rollback. Cross-mount rename can fail with `EXDEV`.
|
|
351
|
+
- **A read-only view is not an immutable store.** Mount views serialize guest symlink, rename, and removal operations against active path operations. Finish or close read streams before awaiting these namespace changes. Other references can still change the backing filesystem; this coordination applies only to operations through the same mount view. Overlay reads require retained handles and stable object and ancestor identities; unsupported backends or changed read admission fail with `ENOTSUP`. Overlays with two stock MemoryFileSystem layers support race-safe patch publication and confined directory creation/removal. Publication checks both layers, retains logical directory identity across copy-up, and leaves lower bytes intact; wrapped or customized layers remain unsupported. Overlays are not transactions; cancellation and cleanup do not guarantee rollback. Cross-mount rename can fail with `EXDEV`.
|
|
352
352
|
- **Remote storage is not a POSIX disk.** S3 and WebDAV do not provide hardlinks or symlinks. S3 rename is non-atomic and may leave partial copies/deletions; `S3RenameError` reports the phase and affected keys. Strong empty-only removal and conditional writes depend on backend support, not a prior listing.
|
|
353
353
|
- **The bridges are partial Node compatibility layers.** No synchronous/callback API, file handles, watchers, bigint stats, flush/retry support, or `cp` dereference/timestamp-preservation options. Missing optional operations fail rather than being approximated with destructive alternatives.
|
|
354
354
|
- **Browser support is filesystem-only.** The `browser` export condition selects the portable surface: memory, mounts, overlays, read-only, WebDAV, and the codec-based bridge. Real storage, S3, the Node bridge, and the configuration registry are not browser exports. WebDAV still needs server CORS support; no OPFS or directory-handle adapter is included. This does not make the SafeJS runtime browser-compatible.
|
|
@@ -38,6 +38,9 @@ export interface DirectoryEntry {
|
|
|
38
38
|
export interface FileSystemCapabilities {
|
|
39
39
|
readonly open?: boolean;
|
|
40
40
|
readonly versionedDescriptors?: boolean;
|
|
41
|
+
/** Prefer pathname streaming for shell > / >> when supported, retaining open for explicit handles.
|
|
42
|
+
* The host owns stream atomicity; this does not provide retained descriptor identity for redirected output. */
|
|
43
|
+
readonly preferStreamingRedirection?: boolean;
|
|
41
44
|
readonly readOnly?: boolean;
|
|
42
45
|
readonly read?: boolean;
|
|
43
46
|
readonly stat?: boolean;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { FileStat, FileSystem } from "../../contracts/filesystem.js";
|
|
2
|
+
/** Internal authority: inspections and stock mutations share one JavaScript turn. */
|
|
3
|
+
export interface MemoryAtomicView {
|
|
4
|
+
stat(path: string): FileStat | undefined;
|
|
5
|
+
names(path: string): readonly string[];
|
|
6
|
+
}
|
|
7
|
+
export declare function registerMemoryAtomicView(fs: FileSystem, view: MemoryAtomicView, intact: () => boolean): void;
|
|
8
|
+
export declare function memoryAtomicView(fs: FileSystem): MemoryAtomicView | undefined;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
const views = new WeakMap();
|
|
2
|
+
export function registerMemoryAtomicView(fs, view, intact) {
|
|
3
|
+
views.set(fs, { view, intact });
|
|
4
|
+
}
|
|
5
|
+
export function memoryAtomicView(fs) {
|
|
6
|
+
const registered = views.get(fs);
|
|
7
|
+
return registered?.intact() ? registered.view : undefined;
|
|
8
|
+
}
|
|
@@ -5,6 +5,7 @@ import { getOwnedWebDavEntry } from "../webdav/resource-id.js";
|
|
|
5
5
|
import { admitDirectoryEntries, directoryEntryLimit } from "../directory-admission.js";
|
|
6
6
|
import { openFileDescriptor } from "../descriptor.js";
|
|
7
7
|
import { resolveMissingTarget } from "./missing-target.js";
|
|
8
|
+
import { registerMemoryAtomicView } from "./atomic-view.js";
|
|
8
9
|
import { MemoryAllocation, MemoryLedger } from "./ledger.js";
|
|
9
10
|
import { normalizeMemoryFileSystemLimits } from "./limits.js";
|
|
10
11
|
export { defaultMemoryFileSystemLimits } from "./limits.js";
|
|
@@ -93,6 +94,40 @@ export class MemoryFileSystem {
|
|
|
93
94
|
capabilities: this.capabilities,
|
|
94
95
|
intact: () => this.root === root,
|
|
95
96
|
});
|
|
97
|
+
registerMemoryAtomicView(this, {
|
|
98
|
+
stat: (path) => {
|
|
99
|
+
this.validatePath(path, "overlayAtomicView");
|
|
100
|
+
let node = this.root;
|
|
101
|
+
for (const component of path.split("/").filter(Boolean)) {
|
|
102
|
+
if (component === "." || component === "..")
|
|
103
|
+
this.fail("EINVAL", "overlayAtomicView", path);
|
|
104
|
+
if (!node)
|
|
105
|
+
return undefined;
|
|
106
|
+
if (node.type !== "directory")
|
|
107
|
+
this.fail("ENOTDIR", "overlayAtomicView", path);
|
|
108
|
+
this.permission(node, 1, "overlayAtomicView", path);
|
|
109
|
+
node = node.entries.get(component);
|
|
110
|
+
}
|
|
111
|
+
return node ? this.snapshot(node) : undefined;
|
|
112
|
+
},
|
|
113
|
+
names: (path) => {
|
|
114
|
+
const node = this.entry(path, "overlayAtomicView").node;
|
|
115
|
+
if (node.type !== "directory")
|
|
116
|
+
this.fail("ENOTDIR", "overlayAtomicView", path);
|
|
117
|
+
return [...node.entries.keys()];
|
|
118
|
+
},
|
|
119
|
+
}, () => {
|
|
120
|
+
if (Object.getPrototypeOf(this) !== MemoryFileSystem.prototype
|
|
121
|
+
|| Object.getOwnPropertyDescriptor(this, "root")?.value !== root
|
|
122
|
+
|| Object.getOwnPropertyDescriptor(this, "ledger")?.value !== ownedStores.get(this)?.ledger
|
|
123
|
+
|| Object.getOwnPropertyDescriptor(this, "capabilities")?.value !== ownedStores.get(this)?.capabilities)
|
|
124
|
+
return false;
|
|
125
|
+
return Object.entries(memoryImplementation).every(([name, expected]) => {
|
|
126
|
+
const actual = Object.getOwnPropertyDescriptor(this, name)
|
|
127
|
+
?? Object.getOwnPropertyDescriptor(MemoryFileSystem.prototype, name);
|
|
128
|
+
return actual?.value === expected.value && actual?.get === expected.get && actual?.set === expected.set;
|
|
129
|
+
});
|
|
130
|
+
});
|
|
96
131
|
if (this.compareEntry === memoryImplementation.compareEntry?.value) {
|
|
97
132
|
registeredAuthorities.add(this);
|
|
98
133
|
registerEntryAuthority(this, compareOwnedMemory);
|
|
@@ -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 { 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, 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;
|
|
@@ -16,8 +16,13 @@ export declare class OverlayFileSystem implements FileSystem {
|
|
|
16
16
|
private readonly activeStages;
|
|
17
17
|
private readonly linkMetadata;
|
|
18
18
|
private readonly linkOrigins;
|
|
19
|
+
private readonly publication;
|
|
19
20
|
private queue;
|
|
20
21
|
constructor(options: OverlayFileSystemOptions);
|
|
22
|
+
confineExtraction(roots: readonly string[], options?: FsOptions): Promise<FileSystem>;
|
|
23
|
+
createStagedFile(directory: string, name: string, content: StagedFileContent, options: CreateStagedFileOptions): Promise<FileStaging>;
|
|
24
|
+
publishStagedFile(staging: FileStaging, destination: string, options: PublishStagedFileOptions): Promise<void>;
|
|
25
|
+
removeStagedFile(staging: FileStaging, options?: FsOptions): Promise<void>;
|
|
21
26
|
open(path: string, options: OpenFileOptions): Promise<FileDescriptor>;
|
|
22
27
|
private run;
|
|
23
28
|
capabilitiesFor(path: string, options?: CapabilityQueryOptions): Promise<FileSystemCapabilities>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { OverlayMemoryPublication } from "./memory-publication.js";
|
|
1
2
|
import { platform } from "#safe-fs-platform";
|
|
2
3
|
import { openRetainedReadFile, requireCapabilities, retainedReadCapabilities } from "../capabilities.js";
|
|
3
4
|
import { finishCleanup } from "../../contracts/cleanup.js";
|
|
@@ -71,12 +72,14 @@ export class OverlayFileSystem {
|
|
|
71
72
|
activeStages = new Set();
|
|
72
73
|
linkMetadata = new Map();
|
|
73
74
|
linkOrigins = new Map();
|
|
75
|
+
publication;
|
|
74
76
|
queue = Promise.resolve();
|
|
75
77
|
constructor(options) {
|
|
76
78
|
if (options.upper === options.lower)
|
|
77
79
|
throw new TypeError("Overlay upper and lower must be distinct backends");
|
|
78
80
|
this.#upper = options.upper;
|
|
79
81
|
this.#lower = options.lower;
|
|
82
|
+
this.publication = new OverlayMemoryPublication(this.#upper, this.#lower, this.whiteouts, this.opaque, this.activeStages);
|
|
80
83
|
registerEntryView(this, (path, options) => this.run(options, async () => {
|
|
81
84
|
const entry = await this.required(path, options);
|
|
82
85
|
return { filesystem: entry.backend, path: entry.path, readOnly: this.capabilities.readOnly === true };
|
|
@@ -120,7 +123,7 @@ export class OverlayFileSystem {
|
|
|
120
123
|
this.capabilities = Object.freeze({
|
|
121
124
|
...semantics,
|
|
122
125
|
open: false,
|
|
123
|
-
atomicFilePublication: false, atomicFileMutation: false, atomicFileStaging:
|
|
126
|
+
atomicFilePublication: false, atomicFileMutation: false, atomicFileStaging: this.publication.supported(), atomicStagingAncestry: this.publication.supported(), atomicDirectoryMetadata: false, trustedOwnedStaging: false,
|
|
124
127
|
implicitDirectories: false,
|
|
125
128
|
readlink: upper.readlink === true && this.#lower.capabilities.readlink === true ? true
|
|
126
129
|
: upper.readlink === false && this.#lower.capabilities.readlink === false ? false : undefined,
|
|
@@ -141,6 +144,22 @@ export class OverlayFileSystem {
|
|
|
141
144
|
});
|
|
142
145
|
Object.defineProperty(this, "capabilities", { writable: false, configurable: false });
|
|
143
146
|
}
|
|
147
|
+
confineExtraction(roots, options = {}) {
|
|
148
|
+
return this.run(options, async () => this.publication.confine(roots, options, (options, operation) => this.run(options, operation, false), this), false);
|
|
149
|
+
}
|
|
150
|
+
createStagedFile(directory, name, content, options) {
|
|
151
|
+
return this.run(options, async () => {
|
|
152
|
+
if (content.type === "file" && content.data.byteLength > this.maxBufferBytes)
|
|
153
|
+
fail("EFBIG", directory);
|
|
154
|
+
return this.publication.create(directory, name, content, options);
|
|
155
|
+
}, false);
|
|
156
|
+
}
|
|
157
|
+
publishStagedFile(staging, destination, options) {
|
|
158
|
+
return this.run(options, () => this.publication.publish(staging, destination, options), false);
|
|
159
|
+
}
|
|
160
|
+
removeStagedFile(staging, options = {}) {
|
|
161
|
+
return this.run(options, () => this.publication.cleanup(staging, options), false);
|
|
162
|
+
}
|
|
144
163
|
async open(path, options) {
|
|
145
164
|
options.signal?.throwIfAborted();
|
|
146
165
|
throw new FsError("ENOTSUP", { syscall: "open", path });
|
|
@@ -169,6 +188,11 @@ export class OverlayFileSystem {
|
|
|
169
188
|
}
|
|
170
189
|
capabilitiesFor(path, options = {}) {
|
|
171
190
|
return this.run(options, async () => {
|
|
191
|
+
validatePath(path);
|
|
192
|
+
// Owned cleanup queries capabilities before using its retained receipt;
|
|
193
|
+
// private staging is deliberately unavailable to pathname resolution.
|
|
194
|
+
if ([...this.activeStages].some(root => isPathWithin(root, path)))
|
|
195
|
+
return this.capabilities;
|
|
172
196
|
const location = await this.resolve(path, options, options.create !== undefined || options.creation !== "exclusive", true);
|
|
173
197
|
const backend = location.entry?.backend ?? this.#upper;
|
|
174
198
|
const capabilities = await backend.capabilitiesFor?.(location.path, options) ?? backend.capabilities;
|
|
@@ -219,6 +243,19 @@ export class OverlayFileSystem {
|
|
|
219
243
|
if (current.backend !== entry.backend || compareIdentity(entry.stat, current.stat) !== "same")
|
|
220
244
|
fail("ENOTSUP", entry.path, "overlay read layer changed during admission");
|
|
221
245
|
options.signal?.throwIfAborted();
|
|
246
|
+
if (entry.stat.type === "directory" && this.publication.supported()) {
|
|
247
|
+
const logical = this.publication.stat(entry.path, snapshotStat(entry.stat));
|
|
248
|
+
return new Proxy(handle, {
|
|
249
|
+
get(target, property) {
|
|
250
|
+
if (property === "stat")
|
|
251
|
+
return async (options = {}) => ({
|
|
252
|
+
...await target.stat(options), identityScope: logical.identityScope, dev: logical.dev, ino: logical.ino,
|
|
253
|
+
});
|
|
254
|
+
const value = Reflect.get(target, property, target);
|
|
255
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
}
|
|
222
259
|
return handle;
|
|
223
260
|
}
|
|
224
261
|
catch (error) {
|
|
@@ -601,6 +638,8 @@ export class OverlayFileSystem {
|
|
|
601
638
|
await this.clone(entry, temporary, options);
|
|
602
639
|
options.signal?.throwIfAborted();
|
|
603
640
|
await this.#upper.rename(temporary, entry.path, options);
|
|
641
|
+
if (entry.stat.type === "directory")
|
|
642
|
+
this.publication.copiedDirectory(entry.path, entry.stat);
|
|
604
643
|
this.rememberLink(entry, entry.path);
|
|
605
644
|
if (origin)
|
|
606
645
|
this.linkOrigins.set(entry.path, origin);
|
|
@@ -681,10 +720,16 @@ export class OverlayFileSystem {
|
|
|
681
720
|
return this.writeFile(path, data, { ...options, flag: "a" });
|
|
682
721
|
}
|
|
683
722
|
async stat(path, options = {}) {
|
|
684
|
-
return this.run(options, async () =>
|
|
723
|
+
return this.run(options, async () => {
|
|
724
|
+
const entry = await this.required(path, options);
|
|
725
|
+
return this.publication.stat(entry.path, snapshotStat(entry.stat));
|
|
726
|
+
}, false);
|
|
685
727
|
}
|
|
686
728
|
async lstat(path, options = {}) {
|
|
687
|
-
return this.run(options, async () =>
|
|
729
|
+
return this.run(options, async () => {
|
|
730
|
+
const entry = await this.required(path, options, false);
|
|
731
|
+
return this.publication.stat(entry.path, snapshotStat(entry.stat));
|
|
732
|
+
}, false);
|
|
688
733
|
}
|
|
689
734
|
compareEntry(path, peer, peerPath, options = {}) {
|
|
690
735
|
return compareEntries(this, path, peer, peerPath, options);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { CreateStagedFileOptions, FileStaging, FileStat, FileSystem, FsOptions, PublishStagedFileOptions, StagedFileContent } from "../../contracts/filesystem.js";
|
|
2
|
+
/** Stock Memory mutations commit before returning their promise. No user code or
|
|
3
|
+
* await is allowed between inspecting the two stores and invoking a mutation. */
|
|
4
|
+
export declare class OverlayMemoryPublication {
|
|
5
|
+
private readonly upper;
|
|
6
|
+
private readonly lower;
|
|
7
|
+
private readonly whiteouts;
|
|
8
|
+
private readonly opaque;
|
|
9
|
+
private readonly hidden;
|
|
10
|
+
private readonly receipts;
|
|
11
|
+
private readonly directories;
|
|
12
|
+
private readonly origins;
|
|
13
|
+
private readonly stages;
|
|
14
|
+
constructor(upper: FileSystem, lower: FileSystem, whiteouts: Set<string>, opaque: Set<string>, hidden: Set<string>);
|
|
15
|
+
supported(): boolean;
|
|
16
|
+
private stores;
|
|
17
|
+
private path;
|
|
18
|
+
private physical;
|
|
19
|
+
private observation;
|
|
20
|
+
private visible;
|
|
21
|
+
copiedDirectory(path: string, lower: FileStat): void;
|
|
22
|
+
stat(path: string, physical: FileStat): FileStat;
|
|
23
|
+
private same;
|
|
24
|
+
private expect;
|
|
25
|
+
private ancestors;
|
|
26
|
+
private inspect;
|
|
27
|
+
private capture;
|
|
28
|
+
private check;
|
|
29
|
+
private copyParents;
|
|
30
|
+
create(directory: string, name: string, content: StagedFileContent, options: CreateStagedFileOptions): Promise<FileStaging>;
|
|
31
|
+
publish(staging: FileStaging, path: string, options: PublishStagedFileOptions): Promise<void>;
|
|
32
|
+
cleanup(staging: FileStaging, options: FsOptions): Promise<void>;
|
|
33
|
+
confine(roots: readonly string[], options: FsOptions, run: <T>(options: FsOptions, operation: () => Promise<T>) => Promise<T>, fs: FileSystem): FileSystem;
|
|
34
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { FsError, isFsError } from "../../contracts/errors.js";
|
|
2
|
+
import { dirname, isPathWithin, validatePath } from "../../contracts/virtual-path.js";
|
|
3
|
+
import { memoryAtomicView } from "../memory/atomic-view.js";
|
|
4
|
+
import { compareIdentity } from "../mount/identity.js";
|
|
5
|
+
/** Stock Memory mutations commit before returning their promise. No user code or
|
|
6
|
+
* await is allowed between inspecting the two stores and invoking a mutation. */
|
|
7
|
+
export class OverlayMemoryPublication {
|
|
8
|
+
upper;
|
|
9
|
+
lower;
|
|
10
|
+
whiteouts;
|
|
11
|
+
opaque;
|
|
12
|
+
hidden;
|
|
13
|
+
receipts = new WeakMap();
|
|
14
|
+
directories = new Map();
|
|
15
|
+
origins = new Map();
|
|
16
|
+
stages = new WeakMap();
|
|
17
|
+
constructor(upper, lower, whiteouts, opaque, hidden) {
|
|
18
|
+
this.upper = upper;
|
|
19
|
+
this.lower = lower;
|
|
20
|
+
this.whiteouts = whiteouts;
|
|
21
|
+
this.opaque = opaque;
|
|
22
|
+
this.hidden = hidden;
|
|
23
|
+
}
|
|
24
|
+
supported() { return !!memoryAtomicView(this.upper) && !!memoryAtomicView(this.lower); }
|
|
25
|
+
stores() {
|
|
26
|
+
const upper = memoryAtomicView(this.upper), lower = memoryAtomicView(this.lower);
|
|
27
|
+
if (!upper || !lower)
|
|
28
|
+
throw new FsError("ENOTSUP", { syscall: "overlayPublication" });
|
|
29
|
+
return { upper, lower };
|
|
30
|
+
}
|
|
31
|
+
path(path) {
|
|
32
|
+
validatePath(path);
|
|
33
|
+
if (!path.startsWith("/") || (path !== "/" && path.endsWith("/"))
|
|
34
|
+
|| path.split("/").some(component => component === "." || component === "..")) {
|
|
35
|
+
throw new FsError("EINVAL", { path });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
physical(view, path) {
|
|
39
|
+
try {
|
|
40
|
+
return view.stat(path);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (isFsError(error, "ENOENT") || isFsError(error, "ENOTDIR"))
|
|
44
|
+
return undefined;
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
observation(path) {
|
|
49
|
+
const stores = this.stores();
|
|
50
|
+
const upper = this.physical(stores.upper, path);
|
|
51
|
+
const lowerHidden = [...this.whiteouts].some(root => isPathWithin(root, path))
|
|
52
|
+
|| [...this.opaque].some(root => root !== path && isPathWithin(root, path));
|
|
53
|
+
const lower = lowerHidden ? undefined : this.physical(stores.lower, path);
|
|
54
|
+
return { path, ...(upper ? { upper } : {}), ...(lower ? { lower } : {}) };
|
|
55
|
+
}
|
|
56
|
+
visible(observation) {
|
|
57
|
+
return observation.upper ?? observation.lower;
|
|
58
|
+
}
|
|
59
|
+
copiedDirectory(path, lower) {
|
|
60
|
+
if (!this.supported())
|
|
61
|
+
return;
|
|
62
|
+
const upper = this.physical(this.stores().upper, path);
|
|
63
|
+
if (upper?.type === "directory") {
|
|
64
|
+
this.origins.set(path, { upper, lower });
|
|
65
|
+
const directory = this.directories.get(path);
|
|
66
|
+
if (directory && !directory.upper && this.same(directory.lower, lower))
|
|
67
|
+
directory.upper = upper;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
stat(path, physical) {
|
|
71
|
+
if (!this.supported())
|
|
72
|
+
return physical;
|
|
73
|
+
const observation = this.observation(path);
|
|
74
|
+
if (!this.same(physical, this.visible(observation), physical.type === "file"))
|
|
75
|
+
throw new FsError("EAGAIN", { path });
|
|
76
|
+
let logical = physical;
|
|
77
|
+
if (physical.type === "directory") {
|
|
78
|
+
let directory = this.directories.get(path);
|
|
79
|
+
if (!directory || !this.same(directory.upper, observation.upper) || !this.same(directory.lower, observation.lower)) {
|
|
80
|
+
directory = { ...observation, identityScope: Symbol("overlay directory"), ino: physical.ino, dev: physical.dev };
|
|
81
|
+
this.directories.set(path, directory);
|
|
82
|
+
}
|
|
83
|
+
logical = { ...physical, identityScope: directory.identityScope, dev: directory.dev, ino: directory.ino };
|
|
84
|
+
}
|
|
85
|
+
this.receipts.set(logical, observation);
|
|
86
|
+
return logical;
|
|
87
|
+
}
|
|
88
|
+
same(actual, expected, unchanged = false) {
|
|
89
|
+
if (!actual || !expected)
|
|
90
|
+
return actual === expected;
|
|
91
|
+
return compareIdentity(actual, expected) === "same" && actual.type === expected.type
|
|
92
|
+
&& (!unchanged || ["revision", "size", "mode", "nlink", "mtimeMs", "ctimeMs"].every(key => actual[key] === expected[key]));
|
|
93
|
+
}
|
|
94
|
+
expect(path, expected, unchanged = false) {
|
|
95
|
+
const receipt = this.receipts.get(expected);
|
|
96
|
+
if (!receipt || receipt.path !== path)
|
|
97
|
+
throw new FsError("ENOTSUP", { path, message: "unowned overlay publication receipt" });
|
|
98
|
+
const actual = this.observation(path);
|
|
99
|
+
const origin = this.origins.get(path);
|
|
100
|
+
const copied = !receipt.upper && receipt.lower?.type === "directory" && origin
|
|
101
|
+
&& this.same(origin.upper, actual.upper) && this.same(origin.lower, receipt.lower);
|
|
102
|
+
if ((!copied && !this.same(actual.upper, receipt.upper, unchanged))
|
|
103
|
+
|| !this.same(actual.lower, receipt.lower, unchanged))
|
|
104
|
+
throw new FsError("EAGAIN", { path });
|
|
105
|
+
}
|
|
106
|
+
ancestors(path) {
|
|
107
|
+
const paths = ["/"];
|
|
108
|
+
for (const component of dirname(path).split("/").filter(Boolean)) {
|
|
109
|
+
paths.push(`${paths.at(-1) === "/" ? "" : paths.at(-1)}/${component}`);
|
|
110
|
+
}
|
|
111
|
+
return paths;
|
|
112
|
+
}
|
|
113
|
+
inspect(path) {
|
|
114
|
+
this.path(path);
|
|
115
|
+
for (const parent of this.ancestors(path)) {
|
|
116
|
+
const stat = this.visible(this.observation(parent));
|
|
117
|
+
if (!stat)
|
|
118
|
+
throw new FsError("ENOENT", { path: parent });
|
|
119
|
+
if (stat.type !== "directory")
|
|
120
|
+
throw new FsError("ENOTDIR", { path: parent });
|
|
121
|
+
if (((stat.mode >> 6) & 1) !== 1)
|
|
122
|
+
throw new FsError("EACCES", { path: parent });
|
|
123
|
+
}
|
|
124
|
+
return this.visible(this.observation(path));
|
|
125
|
+
}
|
|
126
|
+
capture(path) {
|
|
127
|
+
return this.ancestors(path).map(parent => {
|
|
128
|
+
const physical = this.inspect(parent);
|
|
129
|
+
if (physical?.type !== "directory")
|
|
130
|
+
throw new FsError("ENOTDIR", { path: parent });
|
|
131
|
+
return { path: parent, stat: this.stat(parent, physical) };
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
check(entries) {
|
|
135
|
+
for (const entry of entries) {
|
|
136
|
+
this.expect(entry.path, entry.stat);
|
|
137
|
+
if (this.inspect(entry.path)?.type !== "directory")
|
|
138
|
+
throw new FsError("ENOTDIR", { path: entry.path });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async copyParents(entries, options) {
|
|
142
|
+
for (const entry of entries) {
|
|
143
|
+
options.signal?.throwIfAborted();
|
|
144
|
+
this.check(entries);
|
|
145
|
+
const observation = this.observation(entry.path);
|
|
146
|
+
if (observation.upper)
|
|
147
|
+
continue;
|
|
148
|
+
const lower = observation.lower;
|
|
149
|
+
// Stock mkdir performs its complete mutation synchronously.
|
|
150
|
+
const pending = this.upper.mkdir(entry.path, { ...options, mode: lower.mode & 0o7777 });
|
|
151
|
+
this.copiedDirectory(entry.path, lower);
|
|
152
|
+
await pending;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async create(directory, name, content, options) {
|
|
156
|
+
options.signal?.throwIfAborted();
|
|
157
|
+
this.path(directory);
|
|
158
|
+
this.expect(dirname(directory), options.parent);
|
|
159
|
+
if (this.inspect(directory))
|
|
160
|
+
throw new FsError("EEXIST", { path: directory });
|
|
161
|
+
const ancestors = this.capture(directory);
|
|
162
|
+
await this.copyParents(ancestors, options);
|
|
163
|
+
options.signal?.throwIfAborted();
|
|
164
|
+
this.check(ancestors);
|
|
165
|
+
this.expect(dirname(directory), options.parent);
|
|
166
|
+
if (this.inspect(directory))
|
|
167
|
+
throw new FsError("EEXIST", { path: directory });
|
|
168
|
+
const parent = this.observation(dirname(directory)).upper;
|
|
169
|
+
const staging = await this.upper.createStagedFile(directory, name, content, { ...options, parent });
|
|
170
|
+
this.stages.set(staging, staging);
|
|
171
|
+
this.hidden.add(directory);
|
|
172
|
+
return staging;
|
|
173
|
+
}
|
|
174
|
+
async publish(staging, path, options) {
|
|
175
|
+
options.signal?.throwIfAborted();
|
|
176
|
+
this.path(path);
|
|
177
|
+
const owned = this.stages.get(staging);
|
|
178
|
+
if (!owned)
|
|
179
|
+
throw new FsError("ENOTSUP", { path });
|
|
180
|
+
const paths = this.ancestors(path);
|
|
181
|
+
const ancestors = options.ancestors;
|
|
182
|
+
if (!ancestors || ancestors.length !== paths.length || ancestors.some((entry, index) => entry.path !== paths[index])) {
|
|
183
|
+
throw new FsError("EINVAL", { path, message: "complete overlay ancestry is required" });
|
|
184
|
+
}
|
|
185
|
+
this.check(ancestors);
|
|
186
|
+
this.expect(dirname(path), options.parent);
|
|
187
|
+
const current = this.inspect(path);
|
|
188
|
+
if (options.destination === null) {
|
|
189
|
+
if (current)
|
|
190
|
+
throw new FsError("EAGAIN", { path });
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
this.expect(path, options.destination, true);
|
|
194
|
+
if (current?.type !== "file" || current.nlink !== 1)
|
|
195
|
+
throw new FsError("EAGAIN", { path });
|
|
196
|
+
}
|
|
197
|
+
const translated = ancestors.map(entry => ({ path: entry.path, stat: this.observation(entry.path).upper }));
|
|
198
|
+
const target = this.observation(path).upper;
|
|
199
|
+
// No await between the logical checks above and the physical commit below.
|
|
200
|
+
await this.upper.publishStagedFile(owned, path, {
|
|
201
|
+
...options, ancestors: translated, parent: translated.at(-1).stat, destination: target ?? null,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
async cleanup(staging, options) {
|
|
205
|
+
this.stores();
|
|
206
|
+
const owned = this.stages.get(staging);
|
|
207
|
+
if (!owned)
|
|
208
|
+
throw new FsError("ENOTSUP", { path: staging.directory.path });
|
|
209
|
+
await this.upper.removeStagedFile(owned, options);
|
|
210
|
+
this.hidden.delete(owned.directory.path);
|
|
211
|
+
this.stages.delete(staging);
|
|
212
|
+
}
|
|
213
|
+
confine(roots, options, run, fs) {
|
|
214
|
+
options.signal?.throwIfAborted();
|
|
215
|
+
const retained = new Map();
|
|
216
|
+
for (const root of roots) {
|
|
217
|
+
this.path(root);
|
|
218
|
+
const entries = [...this.capture(root), { path: root, stat: this.stat(root, this.inspect(root)) }];
|
|
219
|
+
for (const entry of entries) {
|
|
220
|
+
if (entry.stat.type !== "directory")
|
|
221
|
+
throw new FsError("ENOTDIR", { path: entry.path });
|
|
222
|
+
retained.set(entry.path, entry.stat);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return new Proxy(fs, {
|
|
226
|
+
get: (target, property) => {
|
|
227
|
+
if (property === "confineExtraction" || property === "objects")
|
|
228
|
+
return undefined;
|
|
229
|
+
if (["mkdir", "rm", "rmdir"].includes(String(property)))
|
|
230
|
+
return (path, mutation = {}) => run(mutation, async () => {
|
|
231
|
+
mutation.signal?.throwIfAborted();
|
|
232
|
+
this.path(path);
|
|
233
|
+
if (!roots.some(root => isPathWithin(root, path)))
|
|
234
|
+
throw new FsError("EPERM", { path });
|
|
235
|
+
this.check([...retained].filter(([root]) => isPathWithin(root, path)).map(([path, stat]) => ({ path, stat })));
|
|
236
|
+
const current = this.inspect(path);
|
|
237
|
+
if (property === "mkdir") {
|
|
238
|
+
if (current) {
|
|
239
|
+
if (mutation.recursive && current.type === "directory")
|
|
240
|
+
return;
|
|
241
|
+
throw new FsError("EEXIST", { path });
|
|
242
|
+
}
|
|
243
|
+
const entries = this.capture(path);
|
|
244
|
+
await this.copyParents(entries, mutation);
|
|
245
|
+
this.check([...retained].filter(([root]) => isPathWithin(root, path)).map(([path, stat]) => ({ path, stat })));
|
|
246
|
+
this.check(entries);
|
|
247
|
+
if (this.inspect(path))
|
|
248
|
+
throw new FsError("EAGAIN", { path });
|
|
249
|
+
await this.upper.mkdir(path, mutation);
|
|
250
|
+
this.opaque.add(path);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (!current) {
|
|
254
|
+
if (property === "rm" && mutation.force)
|
|
255
|
+
return;
|
|
256
|
+
throw new FsError("ENOENT", { path });
|
|
257
|
+
}
|
|
258
|
+
if (path === "/")
|
|
259
|
+
throw new FsError("EBUSY", { path });
|
|
260
|
+
if (current.type === "directory") {
|
|
261
|
+
const stores = this.stores();
|
|
262
|
+
const observation = this.observation(path);
|
|
263
|
+
const names = new Set([
|
|
264
|
+
...(observation.upper ? stores.upper.names(path) : []),
|
|
265
|
+
...(observation.lower && !this.opaque.has(path) ? stores.lower.names(path) : []),
|
|
266
|
+
]);
|
|
267
|
+
if ([...names].some(name => this.inspect(`${path}/${name}`)))
|
|
268
|
+
throw new FsError("ENOTEMPTY", { path });
|
|
269
|
+
}
|
|
270
|
+
else if (property === "rmdir")
|
|
271
|
+
throw new FsError("ENOTDIR", { path });
|
|
272
|
+
const parent = this.inspect(dirname(path));
|
|
273
|
+
if (((parent.mode >> 6) & 3) !== 3)
|
|
274
|
+
throw new FsError("EACCES", { path });
|
|
275
|
+
const upper = this.observation(path).upper;
|
|
276
|
+
const removal = upper ? property === "rmdir"
|
|
277
|
+
? this.upper.rmdir(path, mutation) : this.upper.rm(path, mutation) : undefined;
|
|
278
|
+
// Bind the whiteout in the same turn as the stock upper removal.
|
|
279
|
+
// Rejected Memory mutations leave the entry present.
|
|
280
|
+
if (!upper || !this.physical(this.stores().upper, path)) {
|
|
281
|
+
this.whiteouts.add(path);
|
|
282
|
+
this.origins.delete(path);
|
|
283
|
+
}
|
|
284
|
+
await removal;
|
|
285
|
+
});
|
|
286
|
+
const value = Reflect.get(target, property, target);
|
|
287
|
+
if (typeof value !== "function")
|
|
288
|
+
return value;
|
|
289
|
+
if (["lstat", "stat", "readFile", "readStream", "openReadFile", "readdir", "realpath", "access", "capabilitiesFor", "readlink"].includes(String(property)))
|
|
290
|
+
return value.bind(target);
|
|
291
|
+
return () => { throw new FsError("ENOTSUP", { syscall: String(property) }); };
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|