@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 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: false, atomicDirectoryMetadata: false, trustedOwnedStaging: false,
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 () => snapshotStat((await this.required(path, options)).stat), false);
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 () => snapshotStat((await this.required(path, options, false)).stat), false);
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poe-platform/safe-fs",
3
- "version": "0.1.725",
3
+ "version": "0.1.727",
4
4
  "description": "Composable filesystem with a portable core and explicit Node adapters",
5
5
  "type": "module",
6
6
  "license": "MIT",