@poe-platform/safe-fs 0.1.724 → 0.1.726

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
@@ -339,6 +339,8 @@ Each batch lists from the beginning because previous keys have been deleted; it
339
339
 
340
340
  Known identity Content-Length responses use one result buffer; other responses grow storage up to the configured ceiling and return a view without a final copy. Growth can temporarily retain the old and new buffers (up to three times the response size), plus transport chunks. These per-response defaults leave headroom in Workers; hosts must still budget for metadata parsing, text decoding, transport buffers, and concurrent reads, especially when raising the limits. Use streaming reads for large files.
341
341
 
342
+ WebDAV metadata parsing limits the document to 100,000 elements, 100,000 content nodes, 100,000 attributes, and 256 levels of nesting, independently of `maxEntries`. Text is bounded by `maxXmlBytes`. Exceeding a structural budget reports `EFBIG`; parsing yields cooperatively so caller cancellation and `timeoutMs` remain active.
343
+
342
344
  See the [binding types](src/fs/webdav/webdav.ts) before implementing atomic directory removal. A recursive WebDAV DELETE does not satisfy that contract.
343
345
 
344
346
  </details>
@@ -346,7 +348,7 @@ See the [binding types](src/fs/webdav/webdav.ts) before implementing atomic dire
346
348
  ## Safety boundary and limitations
347
349
 
348
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.
349
- - **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`.
350
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.
351
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.
352
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
+ }
@@ -1,7 +1,7 @@
1
1
  import { FsError, isErrnoCode, isFsError } from "../../contracts/errors.js";
2
2
  import { composeAbortSignals } from "../../contracts/abort.js";
3
3
  import { readBytes } from "../../contracts/io.js";
4
- import { davChild, davChildren, parseXml, scalar, XmlResponseLimitError } from "./xml.js";
4
+ import { davChild, davChildren, parseXmlSteps, scalar, XmlResponseLimitError, XmlResourceLimitError } from "./xml.js";
5
5
  import { admitDirectoryEntries, directoryEntryLimit } from "../directory-admission.js";
6
6
  import { assertCallbackAuthorityAllowed, compareEntries, registerEntryAuthority } from "../mount/comparison.js";
7
7
  import { compareWebDavResources, ownedResponseIdentifier, recordOwnedResourceStat, registerResourceQuery, resourceIdentifier } from "./resource-id.js";
@@ -561,7 +561,34 @@ export class WebDavFileSystem {
561
561
  const data = await this.bytes(response, this.maxXmlBytes, signal);
562
562
  const encoding = (data[0] === 0xff && data[1] === 0xfe) || (data[0] === 0x3c && data[1] === 0)
563
563
  ? "utf-16le" : (data[0] === 0xfe && data[1] === 0xff) || (data[0] === 0 && data[1] === 0x3c) ? "utf-16be" : "utf-8";
564
- return parseXml(new TextDecoder(encoding, { fatal: true }).decode(data), maxResponses === undefined ? undefined : { maxResponses });
564
+ const parser = parseXmlSteps(new TextDecoder(encoding, { fatal: true }).decode(data), {
565
+ ...(maxResponses === undefined ? {} : { maxResponses }),
566
+ maxNodes: 100_000, maxContentNodes: 100_000, maxDepth: 256,
567
+ maxAttributes: 100_000, maxTextLength: this.maxXmlBytes,
568
+ });
569
+ let work = 0;
570
+ try {
571
+ while (true) {
572
+ signal.throwIfAborted();
573
+ const step = parser.next();
574
+ if (step.done)
575
+ return step.value;
576
+ work += step.value;
577
+ if (work >= 4096) {
578
+ work = 0;
579
+ // Yield to the host task queue so request deadlines and caller aborts can fire.
580
+ await new Promise(resolve => setTimeout(resolve, 0));
581
+ }
582
+ }
583
+ }
584
+ catch (error) {
585
+ if (error instanceof XmlResourceLimitError)
586
+ fail("EFBIG", "webdav", "", error.message);
587
+ throw error;
588
+ }
589
+ finally {
590
+ parser.return(undefined);
591
+ }
565
592
  }
566
593
  async multistatus(response, signal, method = "PROPFIND", path = "") {
567
594
  const link = response.headers.get("link");
@@ -6,7 +6,10 @@ export interface XmlLimits extends Omit<DocumentLimits, "onElement"> {
6
6
  }
7
7
  export declare class XmlResponseLimitError extends SyntaxError {
8
8
  }
9
+ export declare class XmlResourceLimitError extends SyntaxError {
10
+ }
9
11
  export declare function parseXml(input: string, limits?: XmlLimits): XmlElement;
12
+ export declare function parseXmlSteps(input: string, limits?: XmlLimits): Generator<number, XmlElement, void>;
10
13
  export declare function davChildren(element: XmlElement, localName: string): XmlElement[];
11
14
  export declare function davChild(element: XmlElement, localName: string): XmlElement | undefined;
12
15
  export declare function scalar(element: XmlElement): string;
@@ -1,15 +1,31 @@
1
- import { parseXml as parseDocument, XmlLimitError } from "../../xml.js";
1
+ import { parseXmlSteps as parseDocumentSteps, XmlLimitError } from "../../xml.js";
2
2
  export class XmlResponseLimitError extends SyntaxError {
3
3
  }
4
+ export class XmlResourceLimitError extends SyntaxError {
5
+ }
4
6
  function invalid(message) { throw new SyntaxError(`Invalid WebDAV XML: ${message}`); }
5
7
  export function parseXml(input, limits = {}) {
8
+ const parser = parseXmlSteps(input, limits);
9
+ try {
10
+ let step = parser.next();
11
+ while (!step.done)
12
+ step = parser.next();
13
+ return step.value;
14
+ }
15
+ catch (error) {
16
+ if (error instanceof XmlResourceLimitError)
17
+ throw new SyntaxError(error.message);
18
+ throw error;
19
+ }
20
+ }
21
+ export function* parseXmlSteps(input, limits = {}) {
6
22
  const { maxResponses, ...documentLimits } = limits;
7
23
  if (maxResponses !== undefined && (!Number.isSafeInteger(maxResponses) || maxResponses < 1)) {
8
24
  throw new RangeError("XML limits must be positive integers");
9
25
  }
10
26
  let responses = 0;
11
27
  try {
12
- return parseDocument(input, { ...documentLimits, retainContent: false,
28
+ return yield* parseDocumentSteps(input, { ...documentLimits, retainContent: false,
13
29
  onElement(element, parent, depth) {
14
30
  if (maxResponses !== undefined && depth === 2 && parent?.namespace === "DAV:" && parent.localName === "multistatus"
15
31
  && element.namespace === "DAV:" && element.localName === "response" && ++responses > maxResponses) {
@@ -20,7 +36,7 @@ export function parseXml(input, limits = {}) {
20
36
  }
21
37
  catch (error) {
22
38
  if (error instanceof XmlLimitError)
23
- invalid(error.message);
39
+ throw new XmlResourceLimitError(`Invalid WebDAV XML: ${error.message}`);
24
40
  if (error instanceof SyntaxError && error.message.startsWith("Invalid XML:")) {
25
41
  error.message = error.message.replace("Invalid XML:", "Invalid WebDAV XML:");
26
42
  }
@@ -288,9 +288,10 @@ export function* parseXmlSteps(input, limits = {}) {
288
288
  const parent = stack.at(-1);
289
289
  if (parent) {
290
290
  parent.element.text += text;
291
- if (retainContent && (text.length || kind === "cdata")) {
291
+ if (text.length || kind === "cdata") {
292
292
  admitContent();
293
- parent.content.push({ kind, text });
293
+ if (retainContent)
294
+ parent.content.push({ kind, text });
294
295
  }
295
296
  }
296
297
  else {
@@ -302,9 +303,10 @@ export function* parseXmlSteps(input, limits = {}) {
302
303
  }
303
304
  if (text.length % 512)
304
305
  yield text.length % 512;
305
- if (retainContent && text.length) {
306
+ if (text.length) {
306
307
  admitContent();
307
- (root ? epilog : prolog).push({ kind, text });
308
+ if (retainContent)
309
+ (root ? epilog : prolog).push({ kind, text });
308
310
  }
309
311
  }
310
312
  };
@@ -326,8 +328,8 @@ export function* parseXmlSteps(input, limits = {}) {
326
328
  const parent = stack.at(-1);
327
329
  const text = source.slice(offset + 4, end);
328
330
  admitText(text);
331
+ admitContent();
329
332
  if (retainContent) {
330
- admitContent();
331
333
  (parent?.content ?? (root ? epilog : prolog)).push({ kind: "comment", text });
332
334
  }
333
335
  offset = end + 3;
@@ -371,8 +373,8 @@ export function* parseXmlSteps(input, limits = {}) {
371
373
  yield start % 512;
372
374
  const text = content.slice(start);
373
375
  admitText(text);
376
+ admitContent();
374
377
  if (retainContent) {
375
- admitContent();
376
378
  (parent?.content ?? (root ? epilog : prolog)).push({ kind: "processing-instruction", target, text });
377
379
  }
378
380
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poe-platform/safe-fs",
3
- "version": "0.1.724",
3
+ "version": "0.1.726",
4
4
  "description": "Composable filesystem with a portable core and explicit Node adapters",
5
5
  "type": "module",
6
6
  "license": "MIT",