@poe-platform/safe-fs 0.1.734 → 0.1.736
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 +10 -9
- package/dist/safe-fs/contracts/path.d.ts +1 -1
- package/dist/safe-fs/contracts/path.js +1 -1
- package/dist/safe-fs/fs/object-authority.d.ts +2 -2
- package/dist/safe-fs/fs/object-authority.js +7 -6
- package/dist/safe-fs/fs/s3/authority.d.ts +1 -1
- package/dist/safe-fs/fs/s3/authority.js +14 -7
- package/dist/safe-fs/fs/s3/filesystem.js +4 -3
- package/dist/safe-fs/fs/s3/key-order.d.ts +2 -0
- package/dist/safe-fs/fs/s3/key-order.js +11 -0
- package/dist/safe-fs/fs/s3/mock.js +9 -10
- package/dist/safe-fs/fs/scoped.js +11 -4
- package/dist/safe-fs/fs/webdav/webdav.d.ts +6 -2
- package/dist/safe-fs/fs/webdav/webdav.js +7 -4
- package/dist/safe-fs/platform/browser.d.ts +2 -1
- package/dist/safe-fs/platform/browser.js +5 -4
- package/dist/safe-fs/platform/node.d.ts +2 -2
- package/dist/safe-fs/platform/node.js +2 -1
- package/dist/safe-fs/platform/transport-budget.d.ts +2 -0
- package/dist/safe-fs/platform/transport-budget.js +13 -0
- package/dist/safe-fs/xml.js +211 -119
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -41,10 +41,10 @@ To create new outputs with Safe Bash's `dos2unix`, `unix2dos`, or compression co
|
|
|
41
41
|
|
|
42
42
|
| Import | Use |
|
|
43
43
|
| --- | --- |
|
|
44
|
-
| `@poe-platform/safe-fs` | Full Node API; selects the portable core under the `browser`
|
|
44
|
+
| `@poe-platform/safe-fs` | Full Node API; selects the portable core under the `browser` and `workerd` conditions. |
|
|
45
45
|
| `@poe-platform/safe-fs/core` | Memory, mounts, overlays, read-only, WebDAV, portable bridges, types, and errors. Bundle with the `browser` condition for browsers and Workers. |
|
|
46
46
|
| `@poe-platform/safe-fs/node` | Host filesystem, Node bridge, S3, and configuration helpers; unavailable in browsers. |
|
|
47
|
-
| `@poe-platform/safe-fs/fs/*` | Individual adapters: `memory`, `readonly`, `mount`, `overlay`, `webdav`, `real`, `s3`, and `s3/http`.
|
|
47
|
+
| `@poe-platform/safe-fs/fs/*` | Individual adapters: `memory`, `readonly`, `mount`, `overlay`, `webdav`, `real`, `s3`, and `s3/http`. S3 and its mock accept an injected transport in browsers and Workers; `s3/http` is Node-only. |
|
|
48
48
|
|
|
49
49
|
```ts
|
|
50
50
|
import { createMemoryFileSystem, FsError, type FileSystem } from "@poe-platform/safe-fs/core";
|
|
@@ -117,7 +117,7 @@ See the [staging contract](src/contracts/filesystem.md#atomic-owned-staging).
|
|
|
117
117
|
| --- | --- |
|
|
118
118
|
| `createMemoryFileSystem()` | Isolated, nonpersistent storage with links, permissions, timestamps, and streams; each path resolution admits at most 65,536 cumulative UTF-16 code units across the input and followed symlink targets, rejecting excess with `ENAMETOOLONG` before component allocation |
|
|
119
119
|
| `createRealFileSystem({ root })` | An existing host directory, with virtual paths rooted inside it; Node or qualified Workerd operations |
|
|
120
|
-
| `new S3FileSystem({ transport, bucket, … })` | Bucket/prefix storage through an explicitly supplied transport
|
|
120
|
+
| `new S3FileSystem({ transport, bucket, … })` | Bucket/prefix storage through an explicitly supplied transport in Node, browsers, and Workers |
|
|
121
121
|
| `new WebDavFileSystem({ baseUrl, fetch, … })` | A WebDAV namespace through an explicitly supplied Fetch implementation |
|
|
122
122
|
| `createReadOnlyFileSystem(filesystem)` | Rejecting writes through one view of an existing filesystem |
|
|
123
123
|
| `createMountFileSystem({ root, mounts })` | Routing absolute virtual mount paths to different filesystems |
|
|
@@ -268,7 +268,7 @@ Every raw filesystem operation accepts an optional `signal`. Additional fields a
|
|
|
268
268
|
|
|
269
269
|
`access` takes a separate mode bitmask from `ACCESS_MODES`. `chmod` takes a mode, `utimes` takes millisecond timestamps, and `truncate` takes a byte length (default 0). For conditional chmod, check `capabilitiesFor(path, { conditionalChmod: true })` (or `capabilities`) and require `conditionalChmod: true`; supply `parent`, `expected`, and complete root-to-parent `ancestors` together. An optional mutation-free `commitGuard` must return literal `true` synchronously. Memory validates at its metadata commit; mount and supported Memory overlays preserve wrapper ancestry. Real uses its existing externally isolated host-tree boundary and does not prevent races with other processes. Backend limits still apply. Node-shaped bridge methods translate their own options rather than accepting these raw option objects; see the [bridge signatures](src/bridge/filesystem.ts).
|
|
270
270
|
|
|
271
|
-
`collectBytes(source, { maxBytes, maxMemoryBytes, signal })` snapshots streamed chunks into one growing buffer. `maxBytes` and `maxMemoryBytes` accept explicit `Infinity`. `maxMemoryBytes` limits owned capacity, the current input's full backing buffer, and overlapping allocations during growth; exhaustion throws `EFBIG`.
|
|
271
|
+
`collectBytes(source, { maxBytes, maxMemoryBytes, signal })` snapshots streamed chunks into one growing buffer. `maxBytes` and `maxMemoryBytes` accept explicit `Infinity`. `maxMemoryBytes` limits owned capacity, the current input's full backing buffer, and overlapping allocations during growth; exhaustion throws `EFBIG`. Collection limits are disabled by default on Node, browser, and Worker targets. The returned view may retain geometric spare capacity. Configured budgets cover collection, not caller-retained results, transport buffering, archive decoding, strings, or the rest of the runtime; use streaming APIs and limit concurrent workloads for larger inputs.
|
|
272
272
|
|
|
273
273
|
<details>
|
|
274
274
|
<summary>S3 filesystem and HTTP transport options</summary>
|
|
@@ -344,17 +344,18 @@ Each batch lists from the beginning because previous keys have been deleted; it
|
|
|
344
344
|
| --- | --- |
|
|
345
345
|
| `headers` | Empty; explicit authentication/custom headers. Protocol-reserved headers are rejected; authorization and cookies require HTTPS. |
|
|
346
346
|
| `requestStreamSupport` | `native` for global Fetch, otherwise false; accepts `native` or a boolean declaration for the injected transport |
|
|
347
|
-
| `maxResponseBytes` |
|
|
348
|
-
| `maxXmlBytes` |
|
|
347
|
+
| `maxResponseBytes` | Unlimited unless configured; applies to decoded response bytes |
|
|
348
|
+
| `maxXmlBytes` | Unlimited unless configured; applies before metadata decoding/parsing |
|
|
349
|
+
| `xmlLimits` | Optional `maxNodes`, `maxContentNodes`, `maxDepth`, and `maxAttributes`; each accepts a positive safe integer or `Infinity`, and is unlimited unless configured |
|
|
349
350
|
| `maxEntries` | Unlimited unless configured |
|
|
350
351
|
| `timeoutMs` | Unlimited unless configured |
|
|
351
352
|
| `overwritePolicy` | `lock`; alternative `etag` uses conditional overwrites |
|
|
352
353
|
| `atomicEmptyDirectory` | Optional trusted binding with the canonical `namespaceUrl` and `removeEmptyDirectory` callback; required for strict empty-only `rmdir` |
|
|
353
354
|
| `compareEntry` | Optional trusted backing-identity callback on Node; unavailable under browser policy |
|
|
354
355
|
|
|
355
|
-
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.
|
|
356
|
+
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. Hosts can configure per-response limits to budget for metadata parsing, text decoding, transport buffers, and concurrent reads. Use streaming reads for large files.
|
|
356
357
|
|
|
357
|
-
WebDAV `maxEntries`, `maxResponseBytes`, `maxXmlBytes`, and per-read `maxBytes` accept explicit `Infinity`. WebDAV metadata
|
|
358
|
+
WebDAV `maxEntries`, `maxResponseBytes`, `maxXmlBytes`, and per-read `maxBytes` accept explicit `Infinity`. WebDAV metadata structure limits are disabled by default; configure `xmlLimits` independently of `maxEntries`. Text is bounded by `maxXmlBytes`. Exceeding a structural budget reports `EFBIG`; parsing yields cooperatively so caller cancellation and `timeoutMs` remain active.
|
|
358
359
|
|
|
359
360
|
See the [binding types](src/fs/webdav/webdav.ts) before implementing atomic directory removal. A recursive WebDAV DELETE does not satisfy that contract.
|
|
360
361
|
|
|
@@ -366,7 +367,7 @@ See the [binding types](src/fs/webdav/webdav.ts) before implementing atomic dire
|
|
|
366
367
|
- **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`.
|
|
367
368
|
- **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.
|
|
368
369
|
- **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.
|
|
369
|
-
- **Browser support is filesystem-only.** The `browser` export
|
|
370
|
+
- **Browser support is filesystem-only.** The `browser` and `workerd` export conditions select the portable surface: memory, mounts, overlays, read-only, WebDAV, and the codec-based bridge. S3 is available through `fs/s3` with an explicit transport, including its portable mock. Real storage, 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.
|
|
370
371
|
- **Portable temporary names require secure randomness.** Browser overlay staging and portable bridge `mkdtemp` require `crypto.randomUUID` or `crypto.getRandomValues`; without either, they fail with `ENOTSUP` rather than use `Math.random`.
|
|
371
372
|
- **Allocation and identity may be unknown.** Optional `FileStat.allocatedBytes` is provider-reported allocation, not logical length or reclaimable space. Do not infer identity from size, timestamps, or inode numbers across unrelated backends.
|
|
372
373
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { posix } from "
|
|
1
|
+
import { posix } from "#safe-fs-platform";
|
|
2
2
|
export { assertPathWithin, isPathWithin, normalizePath, relativePath, resolvePath, validatePath } from "./virtual-path.js";
|
|
3
3
|
export declare const posixPath: typeof posix;
|
|
4
4
|
export declare const basename: typeof posix.basename;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { posix } from "
|
|
1
|
+
import { posix } from "#safe-fs-platform";
|
|
2
2
|
export { assertPathWithin, isPathWithin, normalizePath, relativePath, resolvePath, validatePath } from "./virtual-path.js";
|
|
3
3
|
export const posixPath = posix;
|
|
4
4
|
export const basename = posix.basename;
|
|
@@ -5,8 +5,8 @@ import type { WireObjectMetadata, OpenFileObjectOptions, WireExactFileStat, Wire
|
|
|
5
5
|
* not bearer authorization. All admitted operations drain before disposal. */
|
|
6
6
|
export declare class ObjectAuthority {
|
|
7
7
|
#private;
|
|
8
|
-
constructor(filesystem: FileSystem | Pick<FileSystem, "objects">, options
|
|
9
|
-
readonly maxHandles
|
|
8
|
+
constructor(filesystem: FileSystem | Pick<FileSystem, "objects">, options?: {
|
|
9
|
+
readonly maxHandles?: number;
|
|
10
10
|
readonly maxIoBytes?: number;
|
|
11
11
|
});
|
|
12
12
|
open(path: BytePath, options?: OpenFileObjectOptions): Promise<WireObjectHandle>;
|
|
@@ -13,7 +13,7 @@ export class ObjectAuthority {
|
|
|
13
13
|
#sequence = 0;
|
|
14
14
|
#tail = Promise.resolve();
|
|
15
15
|
#disposal;
|
|
16
|
-
constructor(filesystem, options) {
|
|
16
|
+
constructor(filesystem, options = {}) {
|
|
17
17
|
const backend = filesystem.objects;
|
|
18
18
|
// One issued authority keeps its qualified namespace. Bound operations
|
|
19
19
|
// observe live backend state without accepting replacement capabilities.
|
|
@@ -27,11 +27,12 @@ export class ObjectAuthority {
|
|
|
27
27
|
specialFiles: Object.freeze({ ...specialFiles }),
|
|
28
28
|
});
|
|
29
29
|
}
|
|
30
|
-
this.#maxHandles = options.maxHandles;
|
|
31
|
-
this.#maxIoBytes = options.maxIoBytes ??
|
|
32
|
-
|
|
33
|
-
!Number.isSafeInteger(
|
|
34
|
-
|
|
30
|
+
this.#maxHandles = options.maxHandles ?? Infinity;
|
|
31
|
+
this.#maxIoBytes = options.maxIoBytes ?? Infinity;
|
|
32
|
+
for (const limit of [this.#maxHandles, this.#maxIoBytes]) {
|
|
33
|
+
if (limit !== Infinity && (!Number.isSafeInteger(limit) || limit < 1))
|
|
34
|
+
throw new FsError("EINVAL");
|
|
35
|
+
}
|
|
35
36
|
}
|
|
36
37
|
#run(operation) {
|
|
37
38
|
if (this.#disposal)
|
|
@@ -3,5 +3,5 @@ import type { S3HeadOutput, S3ObjectInput } from "./transport.js";
|
|
|
3
3
|
export { registerS3EntryOwner, getOwnedS3Entry, compareOwnedS3Entries } from "./registry.js";
|
|
4
4
|
export type { OwnedS3Entry } from "./registry.js";
|
|
5
5
|
export declare function recordMockS3Head(output: S3HeadOutput, input: S3ObjectInput, storage: object): void;
|
|
6
|
-
export declare function queryS3Head(input: S3ObjectInput, action: () => Promise<S3HeadOutput>): Promise<S3HeadOutput>;
|
|
6
|
+
export declare function queryS3Head(input: S3ObjectInput, action: (query: S3ObjectInput) => Promise<S3HeadOutput>): Promise<S3HeadOutput>;
|
|
7
7
|
export declare function recordS3Stat(filesystem: FileSystem, path: string, stat: FileStat, metadata: S3HeadOutput | undefined): void;
|
|
@@ -1,25 +1,32 @@
|
|
|
1
|
-
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
1
|
import { recordS3Observation } from "./registry.js";
|
|
3
2
|
export { registerS3EntryOwner, getOwnedS3Entry, compareOwnedS3Entries } from "./registry.js";
|
|
4
|
-
const
|
|
3
|
+
const queryKey = Symbol("s3-head-query");
|
|
4
|
+
const queries = new WeakMap();
|
|
5
5
|
const providerHeads = new WeakMap();
|
|
6
6
|
const acceptedHeads = new WeakMap();
|
|
7
7
|
export function recordMockS3Head(output, input, storage) {
|
|
8
|
-
const
|
|
8
|
+
const token = input[queryKey];
|
|
9
|
+
const query = token && queries.get(token);
|
|
9
10
|
if (query && query.Bucket === input.Bucket && query.Key === input.Key) {
|
|
10
11
|
providerHeads.set(output, { query, entry: { storage, key: input.Key } });
|
|
11
12
|
}
|
|
12
13
|
}
|
|
13
14
|
export async function queryS3Head(input, action) {
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
const token = Object.freeze({});
|
|
16
|
+
const query = Object.freeze({ ...input });
|
|
17
|
+
const forwarded = { ...query, [queryKey]: token };
|
|
18
|
+
queries.set(token, query);
|
|
19
|
+
try {
|
|
20
|
+
const output = await action(forwarded);
|
|
17
21
|
acceptedHeads.delete(output);
|
|
18
22
|
const proof = providerHeads.get(output);
|
|
19
23
|
if (proof?.query === query)
|
|
20
24
|
acceptedHeads.set(output, proof.entry);
|
|
21
25
|
return output;
|
|
22
|
-
}
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
queries.delete(token);
|
|
29
|
+
}
|
|
23
30
|
}
|
|
24
31
|
export function recordS3Stat(filesystem, path, stat, metadata) {
|
|
25
32
|
if (!metadata)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { posix } from "
|
|
1
|
+
import { posixPath as posix } from "../../contracts/portable-path.js";
|
|
2
|
+
import { compareKeys } from "./key-order.js";
|
|
2
3
|
import { FsError, isFsError } from "../../contracts/errors.js";
|
|
3
4
|
import { composeAbortSignals } from "../../contracts/abort.js";
|
|
4
5
|
import { collectBytes, readBytes } from "../../contracts/io.js";
|
|
@@ -263,7 +264,7 @@ export class S3FileSystem {
|
|
|
263
264
|
async head(key, path, options) {
|
|
264
265
|
try {
|
|
265
266
|
const input = { Bucket: this.bucket, Key: key };
|
|
266
|
-
return await this.call("headObject", path, options, () => queryS3Head(input,
|
|
267
|
+
return await this.call("headObject", path, options, () => queryS3Head(input, query => this.transport.headObject(query, this.requestOptions(options))));
|
|
267
268
|
}
|
|
268
269
|
catch (error) {
|
|
269
270
|
if (isFsError(error, "ENOENT") && serviceCode(error.cause) !== "NoSuchBucket")
|
|
@@ -593,7 +594,7 @@ export class S3FileSystem {
|
|
|
593
594
|
for (const item of page.CommonPrefixes ?? [])
|
|
594
595
|
add(item.Prefix.slice(prefix.length, -1), "directory");
|
|
595
596
|
}
|
|
596
|
-
return [...entries.values()].sort((left, right) =>
|
|
597
|
+
return [...entries.values()].sort((left, right) => compareKeys(left.name, right.name));
|
|
597
598
|
}
|
|
598
599
|
async tree(path, options) {
|
|
599
600
|
const objects = new Map();
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** S3 orders keys lexicographically by their UTF-8 bytes. */
|
|
2
|
+
export function compareKeys(left, right) {
|
|
3
|
+
const encoder = new TextEncoder();
|
|
4
|
+
const a = encoder.encode(left), b = encoder.encode(right);
|
|
5
|
+
for (let index = 0; index < Math.min(a.length, b.length); index++) {
|
|
6
|
+
const difference = a[index] - b[index];
|
|
7
|
+
if (difference)
|
|
8
|
+
return difference;
|
|
9
|
+
}
|
|
10
|
+
return a.length - b.length;
|
|
11
|
+
}
|
|
@@ -15,13 +15,10 @@ function utf8ByteLength(value) {
|
|
|
15
15
|
}
|
|
16
16
|
return bytes;
|
|
17
17
|
}
|
|
18
|
-
import {
|
|
18
|
+
import { compareKeys } from "./key-order.js";
|
|
19
19
|
import { collectBytes } from "../../contracts/io.js";
|
|
20
20
|
import { recordMockS3Head } from "./authority.js";
|
|
21
21
|
import { S3ServiceError } from "./transport.js";
|
|
22
|
-
function compareKeys(left, right) {
|
|
23
|
-
return Buffer.compare(Buffer.from(left), Buffer.from(right));
|
|
24
|
-
}
|
|
25
22
|
export class MockS3Client {
|
|
26
23
|
capabilities = Object.freeze({ conditionalPut: true, conditionalCopy: true, conditionalDelete: true, streamingRead: true, streamingWrite: true });
|
|
27
24
|
buckets = new Map();
|
|
@@ -78,13 +75,13 @@ export class MockS3Client {
|
|
|
78
75
|
Metadata: { ...object.metadata },
|
|
79
76
|
};
|
|
80
77
|
}
|
|
81
|
-
store(body, metadata = {}) {
|
|
78
|
+
async store(body, metadata = {}) {
|
|
82
79
|
const metadataBytes = Object.entries(metadata).reduce((total, [key, value]) => total + utf8ByteLength(key) + utf8ByteLength(value), 0);
|
|
83
80
|
if (metadataBytes > 2048)
|
|
84
81
|
throw new S3ServiceError("MetadataTooLarge", 400);
|
|
85
82
|
return {
|
|
86
83
|
body: new Uint8Array(body),
|
|
87
|
-
etag: `"${
|
|
84
|
+
etag: `"${Array.from(new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", new Uint8Array(body))), byte => byte.toString(16).padStart(2, "0")).join("")}"`,
|
|
88
85
|
modified: new Date(this.now()),
|
|
89
86
|
metadata: { ...metadata },
|
|
90
87
|
};
|
|
@@ -104,6 +101,7 @@ export class MockS3Client {
|
|
|
104
101
|
if (!(input.Body instanceof Uint8Array))
|
|
105
102
|
throw new S3ServiceError("InvalidArgument", 400);
|
|
106
103
|
input = { ...input, Body: new Uint8Array(input.Body), ...(input.Metadata ? { Metadata: { ...input.Metadata } } : {}) };
|
|
104
|
+
const object = await this.store(input.Body, input.Metadata);
|
|
107
105
|
await this.begin("putObject", input, options);
|
|
108
106
|
const bucket = this.bucket(input.Bucket);
|
|
109
107
|
const previous = bucket.get(input.Key);
|
|
@@ -113,7 +111,6 @@ export class MockS3Client {
|
|
|
113
111
|
|| (input.IfMatch !== undefined && previous?.etag !== input.IfMatch)) {
|
|
114
112
|
throw new S3ServiceError("PreconditionFailed", 412);
|
|
115
113
|
}
|
|
116
|
-
const object = this.store(input.Body, input.Metadata);
|
|
117
114
|
bucket.set(input.Key, object);
|
|
118
115
|
return { ETag: object.etag };
|
|
119
116
|
}
|
|
@@ -148,6 +145,8 @@ export class MockS3Client {
|
|
|
148
145
|
const snapshot = { ...input, Body: new Uint8Array(), ...(input.Metadata ? { Metadata: { ...input.Metadata } } : {}) };
|
|
149
146
|
await this.begin("putObject", snapshot, options);
|
|
150
147
|
const body = await collectBytes(input.Body, { maxBytes: 5_000_000_000, ...(options?.abortSignal ? { signal: options.abortSignal } : {}) });
|
|
148
|
+
const object = await this.store(body, snapshot.Metadata);
|
|
149
|
+
options?.abortSignal?.throwIfAborted();
|
|
151
150
|
const bucket = this.bucket(input.Bucket);
|
|
152
151
|
const previous = bucket.get(input.Key);
|
|
153
152
|
if (input.IfMatch !== undefined && !previous)
|
|
@@ -155,7 +154,6 @@ export class MockS3Client {
|
|
|
155
154
|
if ((input.IfNoneMatch === "*" && previous) || (input.IfMatch !== undefined && previous?.etag !== input.IfMatch)) {
|
|
156
155
|
throw new S3ServiceError("PreconditionFailed", 412);
|
|
157
156
|
}
|
|
158
|
-
const object = this.store(body, snapshot.Metadata);
|
|
159
157
|
bucket.set(input.Key, object);
|
|
160
158
|
return { ETag: object.etag };
|
|
161
159
|
}
|
|
@@ -184,7 +182,9 @@ export class MockS3Client {
|
|
|
184
182
|
if (separator < 1)
|
|
185
183
|
throw new S3ServiceError("InvalidArgument", 400);
|
|
186
184
|
const source = this.object({ Bucket: decoded.slice(0, separator), Key: decoded.slice(separator + 1) });
|
|
187
|
-
|
|
185
|
+
const copy = await this.store(source.body, input.MetadataDirective === "REPLACE" ? input.Metadata : source.metadata);
|
|
186
|
+
options?.abortSignal?.throwIfAborted();
|
|
187
|
+
if (input.CopySourceIfMatch !== undefined && input.CopySourceIfMatch !== this.object({ Bucket: decoded.slice(0, separator), Key: decoded.slice(separator + 1) }).etag) {
|
|
188
188
|
throw new S3ServiceError("PreconditionFailed", 412);
|
|
189
189
|
}
|
|
190
190
|
const bucket = this.bucket(input.Bucket);
|
|
@@ -192,7 +192,6 @@ export class MockS3Client {
|
|
|
192
192
|
throw new S3ServiceError("PreconditionFailed", 412);
|
|
193
193
|
if (input.IfMatch !== undefined && bucket.get(input.Key)?.etag !== input.IfMatch)
|
|
194
194
|
throw new S3ServiceError("PreconditionFailed", 412);
|
|
195
|
-
const copy = this.store(source.body, input.MetadataDirective === "REPLACE" ? input.Metadata : source.metadata);
|
|
196
195
|
bucket.set(input.Key, copy);
|
|
197
196
|
return { CopyObjectResult: { ETag: copy.etag, LastModified: new Date(copy.modified) } };
|
|
198
197
|
}
|
|
@@ -3,7 +3,7 @@ import { validatePath } from "../contracts/virtual-path.js";
|
|
|
3
3
|
import { finishCleanup } from "../contracts/cleanup.js";
|
|
4
4
|
import { registerEntryView } from "./mount/comparison.js";
|
|
5
5
|
import { getScopedTransportBudget, runScopedTransportBudget, withScopedTransportBudget } from "#safe-fs-platform";
|
|
6
|
-
import { hasRegisteredS3FileSystem } from "../platform/transport-budget.js";
|
|
6
|
+
import { hasRegisteredS3FileSystem, scopeTransportOptions } from "../platform/transport-budget.js";
|
|
7
7
|
import { openRetainedResizeFile, retainedResizeCapabilities, ownedMutationCapabilities, requireOwnedMutation } from "./capabilities.js";
|
|
8
8
|
import { createStagingCleanup, snapshotStagingCreation } from "./staging-cleanup.js";
|
|
9
9
|
import { inspectStagingBindings, runStagingGuard, snapshotDirectoryAncestry, snapshotStagingResolution } from "./staging-ancestry.js";
|
|
@@ -400,9 +400,16 @@ export function scopeFileSystem(filesystem, charge, signal, cleanupCharge = char
|
|
|
400
400
|
const result = Reflect.apply(method, original, args);
|
|
401
401
|
return property === "readStream" ? wrapStream(result, args[1]) : result;
|
|
402
402
|
};
|
|
403
|
-
const dispatch = (...args) =>
|
|
404
|
-
|
|
405
|
-
|
|
403
|
+
const dispatch = (...args) => {
|
|
404
|
+
if (!hasRegisteredS3FileSystem)
|
|
405
|
+
return executeDispatch(args);
|
|
406
|
+
if (operations.has(property)) {
|
|
407
|
+
const optionIndex = ["compareEntry", "createStagedFile", "utimes"].includes(String(property)) ? 3
|
|
408
|
+
: ["writeFile", "appendFile", "writeStream", "rename", "copyFile", "link", "symlink", "access", "chmod", "truncate", "resizeFile", "publishFileConditional", "writeFileConditional", "publishStagedFile"].includes(String(property)) ? 2 : 1;
|
|
409
|
+
args[optionIndex] = scopeTransportOptions((args[optionIndex] ?? {}), admit, credit);
|
|
410
|
+
}
|
|
411
|
+
return runScopedTransportBudget(admit, () => executeDispatch(args), credit);
|
|
412
|
+
};
|
|
406
413
|
const scoped = property === "prepareStagingResolution"
|
|
407
414
|
? async (path, options = {}) => {
|
|
408
415
|
const controls = resizeOptions({ ...options });
|
|
@@ -2,6 +2,7 @@ import type { PlatformComparisonCallback } from "#safe-fs-platform";
|
|
|
2
2
|
import type { ByteSource } from "../../contracts/io.js";
|
|
3
3
|
import type { AppendFileOptions, CopyFileOptions, DirectoryEntry, EntryComparison, FileStat, FileSystem, FsOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, WriteFileOptions } from "../../contracts/filesystem.js";
|
|
4
4
|
import type { FileDescriptor, OpenFileOptions } from "../../contracts/descriptor.js";
|
|
5
|
+
import type { XmlLimits } from "./xml.js";
|
|
5
6
|
export type WebDavFetch = (url: string, init: RequestInit) => Promise<Response>;
|
|
6
7
|
export interface WebDavAtomicEmptyDirectoryRequest {
|
|
7
8
|
readonly operation: "atomic-empty-rmdir/v1";
|
|
@@ -24,11 +25,13 @@ export interface WebDavFileSystemOptions {
|
|
|
24
25
|
readonly fetch: WebDavFetch;
|
|
25
26
|
readonly requestStreamSupport?: "native" | boolean;
|
|
26
27
|
readonly headers?: Readonly<Record<string, string>>;
|
|
27
|
-
/** Response ceiling;
|
|
28
|
+
/** Response ceiling; unlimited unless configured. */
|
|
28
29
|
readonly maxResponseBytes?: number;
|
|
29
|
-
/** Metadata response ceiling
|
|
30
|
+
/** Metadata response ceiling before decoding/parsing; unlimited unless configured. */
|
|
30
31
|
readonly maxXmlBytes?: number;
|
|
31
32
|
readonly maxEntries?: number;
|
|
33
|
+
/** XML structure ceilings; unlimited unless configured. */
|
|
34
|
+
readonly xmlLimits?: Pick<XmlLimits, "maxNodes" | "maxContentNodes" | "maxDepth" | "maxAttributes">;
|
|
32
35
|
/** Per-request and aggregate stat/write-preflight walk timeout; unlimited unless configured. */
|
|
33
36
|
readonly timeoutMs?: number;
|
|
34
37
|
readonly overwritePolicy?: "lock" | "etag";
|
|
@@ -78,6 +81,7 @@ export declare class WebDavFileSystem implements FileSystem {
|
|
|
78
81
|
private readonly maxResponseBytes;
|
|
79
82
|
private readonly maxXmlBytes;
|
|
80
83
|
private readonly maxEntries;
|
|
84
|
+
private readonly xmlLimits;
|
|
81
85
|
private readonly timeoutMs;
|
|
82
86
|
private readonly walkDeadlines;
|
|
83
87
|
private readonly overwritePolicy;
|
|
@@ -234,6 +234,7 @@ export class WebDavFileSystem {
|
|
|
234
234
|
maxResponseBytes;
|
|
235
235
|
maxXmlBytes;
|
|
236
236
|
maxEntries;
|
|
237
|
+
xmlLimits;
|
|
237
238
|
timeoutMs;
|
|
238
239
|
walkDeadlines = new WeakMap();
|
|
239
240
|
overwritePolicy;
|
|
@@ -288,8 +289,11 @@ export class WebDavFileSystem {
|
|
|
288
289
|
removeDirectory: this.atomicEmptyDirectory !== undefined,
|
|
289
290
|
streamingAppend: this.requestStreamSupport !== false,
|
|
290
291
|
});
|
|
291
|
-
this.maxResponseBytes = options.maxResponseBytes === undefined ?
|
|
292
|
-
this.maxXmlBytes = options.maxXmlBytes === undefined ?
|
|
292
|
+
this.maxResponseBytes = options.maxResponseBytes === undefined ? Infinity : positive(options.maxResponseBytes, "maxResponseBytes", false, true);
|
|
293
|
+
this.maxXmlBytes = options.maxXmlBytes === undefined ? Infinity : positive(options.maxXmlBytes, "maxXmlBytes", false, true);
|
|
294
|
+
this.xmlLimits = Object.freeze({ ...options.xmlLimits });
|
|
295
|
+
for (const [name, value] of Object.entries(this.xmlLimits))
|
|
296
|
+
positive(value, name, false, true);
|
|
293
297
|
this.maxEntries = options.maxEntries === undefined ? Infinity : positive(options.maxEntries, "maxEntries", false, true);
|
|
294
298
|
this.timeoutMs = options.timeoutMs === undefined ? undefined : positive(options.timeoutMs, "timeoutMs");
|
|
295
299
|
this.overwritePolicy = options.overwritePolicy ?? "lock";
|
|
@@ -565,8 +569,7 @@ export class WebDavFileSystem {
|
|
|
565
569
|
? "utf-16le" : (data[0] === 0xfe && data[1] === 0xff) || (data[0] === 0 && data[1] === 0x3c) ? "utf-16be" : "utf-8";
|
|
566
570
|
const parser = parseXmlSteps(new TextDecoder(encoding, { fatal: true }).decode(data), {
|
|
567
571
|
...(maxResponses === undefined ? {} : { maxResponses }),
|
|
568
|
-
|
|
569
|
-
maxAttributes: 100_000, maxTextLength: this.maxXmlBytes,
|
|
572
|
+
...this.xmlLimits, maxTextLength: this.maxXmlBytes,
|
|
570
573
|
});
|
|
571
574
|
let work = 0;
|
|
572
575
|
try {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { posixPath as posix } from "../contracts/portable-path.js";
|
|
1
2
|
import type { FsOptions } from "../contracts/filesystem.js";
|
|
2
3
|
import type { ScopedTransportBudgetFrame } from "./transport-budget.js";
|
|
3
4
|
export type PlatformErrno = number | undefined;
|
|
@@ -23,4 +24,4 @@ export declare const comparisonContext: Readonly<{
|
|
|
23
24
|
export declare function getScopedTransportBudget(): readonly ScopedTransportBudgetFrame[] | undefined;
|
|
24
25
|
export declare function withScopedTransportBudget<Result>(frames: readonly ScopedTransportBudgetFrame[] | undefined, action: () => Result): Result;
|
|
25
26
|
export declare function runScopedTransportBudget<Result>(_admit: (options?: FsOptions) => void, action: () => Result, _credit?: number): Result;
|
|
26
|
-
export declare function chargeScopedTransportCall(options?: FsOptions):
|
|
27
|
+
export declare function chargeScopedTransportCall(options?: FsOptions): void;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { FsError } from "../contracts/errors.js";
|
|
2
|
+
export { posixPath as posix } from "../contracts/portable-path.js";
|
|
3
|
+
import { chargeTransportOptions } from "./transport-budget.js";
|
|
2
4
|
export const platform = Object.freeze({
|
|
3
5
|
nativeFileSystem: Object.freeze({ open: false, permissions: false, timestamps: false, conditionalChmod: false, trustedOwnedStaging: false, atomicRename: false }),
|
|
4
|
-
maxCollectionBytes:
|
|
6
|
+
maxCollectionBytes: Infinity,
|
|
5
7
|
errno(_code) {
|
|
6
8
|
return undefined;
|
|
7
9
|
},
|
|
@@ -39,8 +41,7 @@ export const comparisonContext = Object.freeze({
|
|
|
39
41
|
}
|
|
40
42
|
}
|
|
41
43
|
});
|
|
42
|
-
//
|
|
43
|
-
// Node-only; never pretend to provide its async transport budget context here.
|
|
44
|
+
// Browser operations carry request budgets in their options, including streams.
|
|
44
45
|
export function getScopedTransportBudget() {
|
|
45
46
|
return undefined;
|
|
46
47
|
}
|
|
@@ -54,5 +55,5 @@ export function runScopedTransportBudget(_admit, action, _credit = 1) {
|
|
|
54
55
|
}
|
|
55
56
|
export function chargeScopedTransportCall(options) {
|
|
56
57
|
options?.signal?.throwIfAborted();
|
|
57
|
-
|
|
58
|
+
chargeTransportOptions(options);
|
|
58
59
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
export { posix } from "node:path";
|
|
2
2
|
import type { FsOptions } from "../contracts/filesystem.js";
|
|
3
3
|
import { type ScopedTransportBudgetFrame } from "./transport-budget.js";
|
|
4
4
|
export type PlatformErrno = number;
|
|
@@ -15,7 +15,7 @@ export declare const platform: Readonly<{
|
|
|
15
15
|
maxCollectionBytes: number;
|
|
16
16
|
errno(code: string): PlatformErrno;
|
|
17
17
|
callbackAuthorities: true;
|
|
18
|
-
randomUUID:
|
|
18
|
+
randomUUID: () => string;
|
|
19
19
|
}>;
|
|
20
20
|
export declare const comparisonContext: Readonly<{
|
|
21
21
|
active(_options: FsOptions): boolean;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
export { posix } from "node:path";
|
|
2
3
|
import { randomUUID } from "node:crypto";
|
|
3
4
|
import * as util from "node:util";
|
|
4
5
|
import { constants } from "node:os";
|
|
@@ -25,7 +26,7 @@ export const platform = Object.freeze({
|
|
|
25
26
|
return errno;
|
|
26
27
|
},
|
|
27
28
|
callbackAuthorities: true,
|
|
28
|
-
randomUUID
|
|
29
|
+
randomUUID: randomUUID
|
|
29
30
|
});
|
|
30
31
|
export const comparisonContext = Object.freeze({
|
|
31
32
|
active(_options) {
|
|
@@ -5,3 +5,5 @@ export interface ScopedTransportBudgetFrame {
|
|
|
5
5
|
}
|
|
6
6
|
export declare let hasRegisteredS3FileSystem: boolean;
|
|
7
7
|
export declare function enableS3TransportBudget(): void;
|
|
8
|
+
export declare function scopeTransportOptions(options: FsOptions, admit: (options?: FsOptions) => void, credit: number): FsOptions;
|
|
9
|
+
export declare function chargeTransportOptions(options?: FsOptions): void;
|
|
@@ -2,3 +2,16 @@ export let hasRegisteredS3FileSystem = false;
|
|
|
2
2
|
export function enableS3TransportBudget() {
|
|
3
3
|
hasRegisteredS3FileSystem = true;
|
|
4
4
|
}
|
|
5
|
+
const budgetKey = Symbol("scoped-transport-budget");
|
|
6
|
+
export function scopeTransportOptions(options, admit, credit) {
|
|
7
|
+
const parent = options[budgetKey] ?? [];
|
|
8
|
+
return { ...options, [budgetKey]: [...parent, { credit, admit }] };
|
|
9
|
+
}
|
|
10
|
+
export function chargeTransportOptions(options) {
|
|
11
|
+
for (const frame of options?.[budgetKey] ?? []) {
|
|
12
|
+
if (frame.credit > 0)
|
|
13
|
+
frame.credit--;
|
|
14
|
+
else
|
|
15
|
+
frame.admit(options);
|
|
16
|
+
}
|
|
17
|
+
}
|
package/dist/safe-fs/xml.js
CHANGED
|
@@ -6,21 +6,16 @@ export class XmlLimitError extends SyntaxError {
|
|
|
6
6
|
}
|
|
7
7
|
}
|
|
8
8
|
function* find(source, needle, start) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
if (++work === 512) {
|
|
17
|
-
yield work;
|
|
18
|
-
work = 0;
|
|
19
|
-
}
|
|
9
|
+
const found = source.indexOf(needle, start);
|
|
10
|
+
const end = found < 0 ? source.length : found;
|
|
11
|
+
let remaining = end - start;
|
|
12
|
+
while (remaining >= 512) {
|
|
13
|
+
yield 512;
|
|
14
|
+
remaining -= 512;
|
|
20
15
|
}
|
|
21
|
-
if (
|
|
22
|
-
yield
|
|
23
|
-
return
|
|
16
|
+
if (remaining > 0)
|
|
17
|
+
yield remaining;
|
|
18
|
+
return found;
|
|
24
19
|
}
|
|
25
20
|
const xmlNamespace = "http://www.w3.org/XML/1998/namespace";
|
|
26
21
|
const xmlnsNamespace = "http://www.w3.org/2000/xmlns/";
|
|
@@ -47,6 +42,30 @@ function namePart(point) {
|
|
|
47
42
|
|| (point >= 48 && point <= 57) || (point >= 0x300 && point <= 0x36f)
|
|
48
43
|
|| (point >= 0x203f && point <= 0x2040);
|
|
49
44
|
}
|
|
45
|
+
function qualifiedNameSync(name) {
|
|
46
|
+
let prefix = "";
|
|
47
|
+
let start = 0;
|
|
48
|
+
let first = true;
|
|
49
|
+
for (let offset = 0; offset < name.length;) {
|
|
50
|
+
const point = name.codePointAt(offset);
|
|
51
|
+
if (point === 58) {
|
|
52
|
+
if (start !== 0 || first)
|
|
53
|
+
invalid("invalid qualified name");
|
|
54
|
+
prefix = name.slice(0, offset);
|
|
55
|
+
start = offset + 1;
|
|
56
|
+
first = true;
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
if (!(first ? nameStart(point) : namePart(point)))
|
|
60
|
+
invalid("invalid qualified name");
|
|
61
|
+
first = false;
|
|
62
|
+
}
|
|
63
|
+
offset += point > 0xffff ? 2 : 1;
|
|
64
|
+
}
|
|
65
|
+
if (first)
|
|
66
|
+
invalid("invalid qualified name");
|
|
67
|
+
return [prefix, name.slice(start)];
|
|
68
|
+
}
|
|
50
69
|
function* qualifiedName(name) {
|
|
51
70
|
let prefix = "";
|
|
52
71
|
let start = 0;
|
|
@@ -258,67 +277,81 @@ export function* parseXmlSteps(input, limits = {}) {
|
|
|
258
277
|
if (++contentNodes > maxContentNodes)
|
|
259
278
|
throw new XmlLimitError("maxContentNodes", "XML content node limit exceeded");
|
|
260
279
|
};
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
280
|
+
let pendingWork = 0;
|
|
281
|
+
const skipWhitespace = () => {
|
|
282
|
+
const start = offset;
|
|
283
|
+
while (offset < source.length) {
|
|
284
|
+
const c = source.charCodeAt(offset);
|
|
285
|
+
if (c !== 32 && c !== 9 && c !== 10 && c !== 13)
|
|
286
|
+
break;
|
|
264
287
|
offset++;
|
|
265
|
-
if (++work === 512) {
|
|
266
|
-
yield work;
|
|
267
|
-
work = 0;
|
|
268
|
-
}
|
|
269
288
|
}
|
|
270
|
-
|
|
271
|
-
yield work;
|
|
289
|
+
return offset - start;
|
|
272
290
|
};
|
|
273
|
-
const
|
|
291
|
+
const scanName = () => {
|
|
274
292
|
const start = offset;
|
|
275
|
-
while (offset < source.length
|
|
293
|
+
while (offset < source.length) {
|
|
294
|
+
const c = source.charCodeAt(offset);
|
|
295
|
+
if (c === 32 || c === 9 || c === 13 || c === 10 || c === 47 || c === 61 || c === 62 || c === 63)
|
|
296
|
+
break;
|
|
276
297
|
offset++;
|
|
277
|
-
if ((offset - start) % 512 === 0)
|
|
278
|
-
yield 512;
|
|
279
298
|
}
|
|
280
|
-
if ((offset - start) % 512)
|
|
281
|
-
yield (offset - start) % 512;
|
|
282
299
|
const name = source.slice(start, offset);
|
|
283
|
-
|
|
284
|
-
return name;
|
|
300
|
+
const [prefix, localName] = qualifiedNameSync(name);
|
|
301
|
+
return [name, prefix, localName, (offset - start) * 2];
|
|
285
302
|
};
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
admitContent();
|
|
293
|
-
if (retainContent)
|
|
294
|
-
parent.content.push({ kind, text });
|
|
303
|
+
while (offset < source.length) {
|
|
304
|
+
pendingWork += 1;
|
|
305
|
+
if (pendingWork >= 512) {
|
|
306
|
+
while (pendingWork >= 512) {
|
|
307
|
+
yield 512;
|
|
308
|
+
pendingWork -= 512;
|
|
295
309
|
}
|
|
296
310
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
yield text.length % 512;
|
|
306
|
-
if (text.length) {
|
|
307
|
-
admitContent();
|
|
308
|
-
if (retainContent)
|
|
309
|
-
(root ? epilog : prolog).push({ kind, text });
|
|
311
|
+
if (source.charCodeAt(offset) !== 60) {
|
|
312
|
+
const next = source.indexOf("<", offset);
|
|
313
|
+
const endPos = next < 0 ? source.length : next;
|
|
314
|
+
const text = source.slice(offset, endPos);
|
|
315
|
+
pendingWork += (endPos - offset) + text.length;
|
|
316
|
+
while (pendingWork >= 512) {
|
|
317
|
+
yield 512;
|
|
318
|
+
pendingWork -= 512;
|
|
310
319
|
}
|
|
311
|
-
|
|
312
|
-
};
|
|
313
|
-
while (offset < source.length) {
|
|
314
|
-
yield 1;
|
|
315
|
-
if (source[offset] !== "<") {
|
|
316
|
-
const next = yield* find(source, "<", offset);
|
|
317
|
-
const text = source.slice(offset, next < 0 ? source.length : next);
|
|
318
|
-
if ((yield* find(text, "]]>", 0)) >= 0)
|
|
320
|
+
if (text.indexOf("]]>") >= 0)
|
|
319
321
|
invalid("CDATA terminator in text");
|
|
320
|
-
|
|
321
|
-
|
|
322
|
+
const resolved = stack.length ? (text.indexOf("&") < 0 ? (pendingWork += text.length, text) : yield* entities(text)) : text;
|
|
323
|
+
while (pendingWork >= 512) {
|
|
324
|
+
yield 512;
|
|
325
|
+
pendingWork -= 512;
|
|
326
|
+
}
|
|
327
|
+
admitText(resolved);
|
|
328
|
+
const parent = stack.at(-1);
|
|
329
|
+
if (parent) {
|
|
330
|
+
parent.element.text += resolved;
|
|
331
|
+
if (resolved.length) {
|
|
332
|
+
admitContent();
|
|
333
|
+
if (retainContent)
|
|
334
|
+
parent.content.push({ kind: "text", text: resolved });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
for (let index = 0; index < resolved.length; index++) {
|
|
339
|
+
const c = resolved.charCodeAt(index);
|
|
340
|
+
if (c !== 32 && c !== 9 && c !== 13 && c !== 10)
|
|
341
|
+
invalid("text outside the root");
|
|
342
|
+
}
|
|
343
|
+
pendingWork += resolved.length;
|
|
344
|
+
while (pendingWork >= 512) {
|
|
345
|
+
yield 512;
|
|
346
|
+
pendingWork -= 512;
|
|
347
|
+
}
|
|
348
|
+
if (resolved.length) {
|
|
349
|
+
admitContent();
|
|
350
|
+
if (retainContent)
|
|
351
|
+
(root ? epilog : prolog).push({ kind: "text", text: resolved });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
offset = endPos;
|
|
322
355
|
}
|
|
323
356
|
else if (source.startsWith("<!--", offset)) {
|
|
324
357
|
const end = yield* find(source, "-->", offset + 4);
|
|
@@ -340,13 +373,24 @@ export function* parseXmlSteps(input, limits = {}) {
|
|
|
340
373
|
const end = yield* find(source, "]]>", offset + 9);
|
|
341
374
|
if (end < 0)
|
|
342
375
|
invalid("unterminated CDATA");
|
|
343
|
-
|
|
376
|
+
const cdataText = source.slice(offset + 9, end);
|
|
377
|
+
admitText(cdataText);
|
|
378
|
+
const parent = stack.at(-1);
|
|
379
|
+
parent.element.text += cdataText;
|
|
380
|
+
admitContent();
|
|
381
|
+
if (retainContent)
|
|
382
|
+
parent.content.push({ kind: "cdata", text: cdataText });
|
|
344
383
|
offset = end + 3;
|
|
345
384
|
}
|
|
346
385
|
else if (source.startsWith("<?", offset)) {
|
|
347
386
|
const start = offset;
|
|
348
387
|
offset += 2;
|
|
349
|
-
const target =
|
|
388
|
+
const [target, , , wName] = scanName();
|
|
389
|
+
pendingWork += wName;
|
|
390
|
+
while (pendingWork >= 512) {
|
|
391
|
+
yield 512;
|
|
392
|
+
pendingWork -= 512;
|
|
393
|
+
}
|
|
350
394
|
const end = yield* find(source, "?>", offset);
|
|
351
395
|
if (end < 0)
|
|
352
396
|
invalid("unterminated processing instruction");
|
|
@@ -363,15 +407,15 @@ export function* parseXmlSteps(input, limits = {}) {
|
|
|
363
407
|
invalid("invalid processing instruction");
|
|
364
408
|
if (!(target.length === 3 && target.toLowerCase() === "xml")) {
|
|
365
409
|
const parent = stack.at(-1);
|
|
366
|
-
let
|
|
367
|
-
while (
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
410
|
+
let wsStart = 0;
|
|
411
|
+
while (wsStart < content.length && " \t\n\r".includes(content[wsStart]))
|
|
412
|
+
wsStart++;
|
|
413
|
+
pendingWork += wsStart;
|
|
414
|
+
while (pendingWork >= 512) {
|
|
415
|
+
yield 512;
|
|
416
|
+
pendingWork -= 512;
|
|
371
417
|
}
|
|
372
|
-
|
|
373
|
-
yield start % 512;
|
|
374
|
-
const text = content.slice(start);
|
|
418
|
+
const text = content.slice(wsStart);
|
|
375
419
|
admitText(text);
|
|
376
420
|
admitContent();
|
|
377
421
|
if (retainContent) {
|
|
@@ -383,92 +427,129 @@ export function* parseXmlSteps(input, limits = {}) {
|
|
|
383
427
|
else if (source.startsWith("<!", offset)) {
|
|
384
428
|
invalid("DTD and entity declarations are forbidden");
|
|
385
429
|
}
|
|
386
|
-
else if (source.
|
|
430
|
+
else if (source.charCodeAt(offset + 1) === 47) {
|
|
387
431
|
offset += 2;
|
|
388
|
-
const name =
|
|
389
|
-
|
|
432
|
+
const [name, , , wName] = scanName();
|
|
433
|
+
pendingWork += wName + skipWhitespace();
|
|
434
|
+
while (pendingWork >= 512) {
|
|
435
|
+
yield 512;
|
|
436
|
+
pendingWork -= 512;
|
|
437
|
+
}
|
|
390
438
|
if (source[offset++] !== ">" || stack.pop()?.name !== name)
|
|
391
439
|
invalid("mismatched closing tag");
|
|
392
440
|
}
|
|
393
441
|
else {
|
|
394
442
|
offset++;
|
|
395
|
-
const name =
|
|
443
|
+
const [name, prefix, localName, wName] = scanName();
|
|
444
|
+
pendingWork += wName;
|
|
445
|
+
while (pendingWork >= 512) {
|
|
446
|
+
yield 512;
|
|
447
|
+
pendingWork -= 512;
|
|
448
|
+
}
|
|
396
449
|
const attributes = new Map();
|
|
450
|
+
const attrMeta = [];
|
|
397
451
|
let namespaces = stack.at(-1)?.namespaces ?? new Map([["xml", xmlNamespace]]);
|
|
398
452
|
let ownsNamespaces = stack.length === 0;
|
|
399
453
|
while (true) {
|
|
400
|
-
const
|
|
401
|
-
|
|
402
|
-
|
|
454
|
+
const ws = skipWhitespace();
|
|
455
|
+
pendingWork += ws;
|
|
456
|
+
while (pendingWork >= 512) {
|
|
457
|
+
yield 512;
|
|
458
|
+
pendingWork -= 512;
|
|
459
|
+
}
|
|
460
|
+
const ch = source.charCodeAt(offset);
|
|
461
|
+
if (ch === 47 || ch === 62)
|
|
403
462
|
break;
|
|
404
|
-
if (
|
|
463
|
+
if (ws === 0)
|
|
405
464
|
invalid("attributes require whitespace");
|
|
406
|
-
const attribute =
|
|
465
|
+
const [attribute, attrPrefix, attrLocal, wAttr] = scanName();
|
|
466
|
+
pendingWork += wAttr;
|
|
467
|
+
while (pendingWork >= 512) {
|
|
468
|
+
yield 512;
|
|
469
|
+
pendingWork -= 512;
|
|
470
|
+
}
|
|
407
471
|
if (attributes.has(attribute))
|
|
408
472
|
invalid("duplicate attribute");
|
|
409
473
|
if (++attributeCount > maxAttributes)
|
|
410
474
|
throw new XmlLimitError("maxAttributes", "XML attribute limit exceeded");
|
|
411
475
|
if (attributes.size >= maxAttributesPerElement)
|
|
412
476
|
throw new XmlLimitError("maxAttributesPerElement", "XML attribute limit exceeded");
|
|
413
|
-
|
|
477
|
+
pendingWork += skipWhitespace();
|
|
414
478
|
if (source[offset++] !== "=")
|
|
415
479
|
invalid("missing attribute equals");
|
|
416
|
-
|
|
480
|
+
pendingWork += skipWhitespace();
|
|
417
481
|
const quote = source[offset++];
|
|
418
482
|
if (quote !== '"' && quote !== "'")
|
|
419
483
|
invalid("unquoted attribute");
|
|
420
|
-
const end =
|
|
484
|
+
const end = source.indexOf(quote, offset);
|
|
485
|
+
const scanLen = (end < 0 ? source.length : end) - offset;
|
|
486
|
+
pendingWork += scanLen;
|
|
487
|
+
while (pendingWork >= 512) {
|
|
488
|
+
yield 512;
|
|
489
|
+
pendingWork -= 512;
|
|
490
|
+
}
|
|
421
491
|
if (end < 0)
|
|
422
492
|
invalid("unterminated attribute");
|
|
423
493
|
const raw = source.slice(offset, end);
|
|
424
|
-
|
|
494
|
+
pendingWork += raw.length * 2;
|
|
495
|
+
while (pendingWork >= 512) {
|
|
496
|
+
yield 512;
|
|
497
|
+
pendingWork -= 512;
|
|
498
|
+
}
|
|
499
|
+
if (raw.indexOf("<") >= 0)
|
|
425
500
|
invalid("less-than in attribute");
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
yield 512;
|
|
501
|
+
const normalized = /[\t\n\r]/.test(raw) ? raw.replace(/[\t\n\r]/g, " ") : raw;
|
|
502
|
+
const value = normalized.indexOf("&") < 0 ? (pendingWork += normalized.length, normalized) : yield* entities(normalized);
|
|
503
|
+
while (pendingWork >= 512) {
|
|
504
|
+
yield 512;
|
|
505
|
+
pendingWork -= 512;
|
|
432
506
|
}
|
|
433
|
-
if (raw.length % 512)
|
|
434
|
-
yield raw.length % 512;
|
|
435
|
-
const value = yield* entities(normalized);
|
|
436
507
|
admitText(value);
|
|
437
508
|
attributes.set(attribute, value);
|
|
509
|
+
attrMeta.push([attribute, attrPrefix, attrLocal]);
|
|
438
510
|
offset = end + 1;
|
|
439
511
|
if (attribute === "xmlns" || attribute.startsWith("xmlns:")) {
|
|
440
|
-
const
|
|
441
|
-
if (
|
|
442
|
-
|| (
|
|
443
|
-
|| (
|
|
512
|
+
const nsPrefix = attribute === "xmlns" ? "" : attribute.slice(6);
|
|
513
|
+
if (nsPrefix === "xmlns" || value === xmlnsNamespace
|
|
514
|
+
|| (nsPrefix === "xml") !== (value === xmlNamespace)
|
|
515
|
+
|| (nsPrefix !== "" && value === ""))
|
|
444
516
|
invalid("invalid namespace binding");
|
|
445
517
|
if (!ownsNamespaces) {
|
|
446
518
|
const copy = new Map();
|
|
447
519
|
for (const [key, uri] of namespaces) {
|
|
448
520
|
copy.set(key, uri);
|
|
449
|
-
|
|
521
|
+
pendingWork += 1;
|
|
450
522
|
}
|
|
451
523
|
namespaces = copy;
|
|
452
524
|
ownsNamespaces = true;
|
|
453
525
|
}
|
|
454
|
-
namespaces.set(
|
|
526
|
+
namespaces.set(nsPrefix, value);
|
|
455
527
|
if (namespaces.size > maxNamespaces)
|
|
456
528
|
throw new XmlLimitError("maxNamespaces", "XML namespace scope limit exceeded");
|
|
457
529
|
}
|
|
458
530
|
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
531
|
+
if (attrMeta.length > 0) {
|
|
532
|
+
const expanded = attrMeta.length > 1 ? new Set() : undefined;
|
|
533
|
+
for (let i = 0; i < attrMeta.length; i++) {
|
|
534
|
+
const [attribute, attrPrefix, attrLocal] = attrMeta[i];
|
|
535
|
+
if (attribute === "xmlns" || attribute.startsWith("xmlns:"))
|
|
536
|
+
continue;
|
|
537
|
+
pendingWork += attribute.length;
|
|
538
|
+
if (attrPrefix && !namespaces.has(attrPrefix))
|
|
539
|
+
invalid("unbound attribute prefix");
|
|
540
|
+
if (expanded) {
|
|
541
|
+
const key = `${attrPrefix ? namespaces.get(attrPrefix) : ""}\0${attrLocal}`;
|
|
542
|
+
if (expanded.has(key))
|
|
543
|
+
invalid("duplicate expanded attribute");
|
|
544
|
+
expanded.add(key);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
pendingWork += name.length;
|
|
549
|
+
while (pendingWork >= 512) {
|
|
550
|
+
yield 512;
|
|
551
|
+
pendingWork -= 512;
|
|
470
552
|
}
|
|
471
|
-
const [prefix, localName] = yield* qualifiedName(name);
|
|
472
553
|
if (prefix === "xmlns" || (prefix && !namespaces.has(prefix)))
|
|
473
554
|
invalid("unbound element prefix");
|
|
474
555
|
if (++nodes > maxNodes)
|
|
@@ -479,12 +560,19 @@ export function* parseXmlSteps(input, limits = {}) {
|
|
|
479
560
|
limits.onElement?.({ name, namespace, localName }, stack.at(-1)?.element, stack.length + 1);
|
|
480
561
|
admitContent();
|
|
481
562
|
const retainedAttributes = [];
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
563
|
+
if (retainContent) {
|
|
564
|
+
for (let i = 0; i < attrMeta.length; i++) {
|
|
565
|
+
const [attribute, attrPrefix, attrLocal] = attrMeta[i];
|
|
566
|
+
const value = attributes.get(attribute);
|
|
567
|
+
admitContent();
|
|
568
|
+
retainedAttributes.push({ name: attribute, localName: attrLocal,
|
|
569
|
+
namespace: attribute === "xmlns" || attrPrefix === "xmlns" ? xmlnsNamespace : attrPrefix ? namespaces.get(attrPrefix) : "", value });
|
|
570
|
+
pendingWork += attribute.length + 1;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
while (pendingWork >= 512) {
|
|
574
|
+
yield 512;
|
|
575
|
+
pendingWork -= 512;
|
|
488
576
|
}
|
|
489
577
|
const content = retainContent ? [] : undefined;
|
|
490
578
|
const element = { kind: "element", name, namespace, localName, children: [], text: "", content: content ?? emptyContent, attributes: retainContent ? retainedAttributes : emptyAttributes, namespaces: retainContent ? namespaces : emptyNamespaces, ...(root === undefined && retainContent ? { prolog, epilog, ...(declaration === undefined ? {} : { declaration }) } : {}) };
|
|
@@ -506,6 +594,10 @@ export function* parseXmlSteps(input, limits = {}) {
|
|
|
506
594
|
stack.push({ element, content, name, namespaces });
|
|
507
595
|
}
|
|
508
596
|
}
|
|
597
|
+
if (pendingWork > 0) {
|
|
598
|
+
yield pendingWork;
|
|
599
|
+
pendingWork = 0;
|
|
600
|
+
}
|
|
509
601
|
if (stack.length || !root)
|
|
510
602
|
invalid("incomplete document");
|
|
511
603
|
return root;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poe-platform/safe-fs",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.736",
|
|
4
4
|
"description": "Composable filesystem with a portable core and explicit Node adapters",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -13,17 +13,21 @@
|
|
|
13
13
|
"exports": {
|
|
14
14
|
".": {
|
|
15
15
|
"types": {
|
|
16
|
+
"workerd": "./dist/safe-fs/core.d.ts",
|
|
16
17
|
"browser": "./dist/safe-fs/core.d.ts",
|
|
17
18
|
"default": "./dist/safe-fs/index.d.ts"
|
|
18
19
|
},
|
|
20
|
+
"workerd": "./dist/safe-fs/core.js",
|
|
19
21
|
"browser": "./dist/safe-fs/core.js",
|
|
20
22
|
"import": "./dist/safe-fs/index.js"
|
|
21
23
|
},
|
|
22
24
|
"./contracts": {
|
|
23
25
|
"types": {
|
|
26
|
+
"workerd": "./dist/safe-fs/core.d.ts",
|
|
24
27
|
"browser": "./dist/safe-fs/core.d.ts",
|
|
25
28
|
"default": "./dist/safe-fs/contracts/index.d.ts"
|
|
26
29
|
},
|
|
30
|
+
"workerd": "./dist/safe-fs/core.js",
|
|
27
31
|
"browser": "./dist/safe-fs/core.js",
|
|
28
32
|
"import": "./dist/safe-fs/contracts/index.js"
|
|
29
33
|
},
|
|
@@ -86,11 +90,7 @@
|
|
|
86
90
|
"import": "./dist/safe-fs/fs/s3/http/index.js"
|
|
87
91
|
},
|
|
88
92
|
"./fs/s3": {
|
|
89
|
-
"types":
|
|
90
|
-
"browser": "./dist/safe-fs/node-unavailable.d.ts",
|
|
91
|
-
"default": "./dist/safe-fs/fs/s3/index.d.ts"
|
|
92
|
-
},
|
|
93
|
-
"browser": null,
|
|
93
|
+
"types": "./dist/safe-fs/fs/s3/index.d.ts",
|
|
94
94
|
"import": "./dist/safe-fs/fs/s3/index.js"
|
|
95
95
|
},
|
|
96
96
|
"./fs/webdav": {
|