@poe-platform/safe-fs 0.1.735 → 0.1.737
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 +1 -0
- package/dist/safe-fs/platform/node.js +1 -0
- package/dist/safe-fs/platform/transport-budget.d.ts +2 -0
- package/dist/safe-fs/platform/transport-budget.js +13 -0
- 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
|
}
|
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poe-platform/safe-fs",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.737",
|
|
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": {
|