@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 +3 -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/dist/safe-fs/fs/webdav/webdav.js +29 -2
- package/dist/safe-fs/fs/webdav/xml.d.ts +3 -0
- package/dist/safe-fs/fs/webdav/xml.js +19 -3
- package/dist/safe-fs/xml.js +8 -6
- package/package.json +1 -1
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:
|
|
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
|
+
}
|
|
@@ -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,
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/dist/safe-fs/xml.js
CHANGED
|
@@ -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 (
|
|
291
|
+
if (text.length || kind === "cdata") {
|
|
292
292
|
admitContent();
|
|
293
|
-
|
|
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 (
|
|
306
|
+
if (text.length) {
|
|
306
307
|
admitContent();
|
|
307
|
-
|
|
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
|
}
|