@poe-platform/safe-fs 0.1.722 → 0.1.724
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 +4 -4
- package/dist/safe-fs/bridge/filesystem.d.ts +2 -0
- package/dist/safe-fs/bridge/filesystem.js +32 -3
- package/dist/safe-fs/bridge/types.d.ts +4 -0
- package/dist/safe-fs/contracts/filesystem.d.ts +3 -0
- package/dist/safe-fs/fs/memory/index.d.ts +1 -0
- package/dist/safe-fs/fs/memory/index.js +22 -3
- package/dist/safe-fs/fs/mount/index.js +2 -1
- package/dist/safe-fs/fs/object-publication/index.d.ts +4 -0
- package/dist/safe-fs/fs/object-publication/index.js +3 -2
- package/dist/safe-fs/fs/quota/index.js +65 -7
- package/dist/safe-fs/fs/s3/filesystem.js +3 -1
- package/dist/safe-fs/fs/s3/manifest-admission.d.ts +8 -0
- package/dist/safe-fs/fs/s3/manifest-admission.js +171 -0
- package/dist/safe-fs/fs/s3/namespace.js +3 -2
- package/dist/safe-fs/fs/webdav/webdav.js +71 -1
- package/dist/safe-fs/node/filesystem.d.ts +4 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ console.log(await fs.readFile("note.txt", "utf8"));
|
|
|
25
25
|
|
|
26
26
|
Output: `hello`, then `hello world`. Nothing touches the host filesystem. Raw adapters exchange `Uint8Array` values; the Node bridge adds strings, encodings, `Buffer` results, and stat predicates. Its `cwd` is the relative-path base and, by default, the confinement boundary. Set an explicit `root` to use a different boundary, for example `{ cwd: "/work", root: "/" }` to address a complete provider namespace. The bridge supports `unlink` when the provider offers atomic file removal; unsupported methods fail without host fallback. `createHostFileSystem()` provides trusted, unrestricted native host access when a rooted adapter is not desired.
|
|
27
27
|
|
|
28
|
-
For host storage, use `await createRealFileSystem({ root: "/absolute/existing/directory" })` instead. The root must already exist; virtual `/` maps to that directory. ZIP creation
|
|
28
|
+
For host storage, use `await createRealFileSystem({ root: "/absolute/existing/directory" })` instead. The root must already exist; virtual `/` maps to that directory. ZIP creation and updates can use this adapter's private owned staging in an isolated host tree. Unzip extraction requires atomic ancestry verification, currently supported by MemoryFileSystem; the real adapter and mount views refuse it. Its `trustedOwnedStaging` capability checks original entries before publication and cleanup, and preserves foreign staging children. It does not advertise atomic conditional mutations: keep external writers and other in-flight writes away from the tree during these operations. Read the safety boundary below before exposing it to untrusted code.
|
|
29
29
|
|
|
30
30
|
To create new outputs with Safe Bash's `dos2unix`, `unix2dos`, or compression commands, host adapters need atomic no-replace publication. Supply `createRealFileSystem({ root, renameNoReplace })` only when that callback binds a qualified native primitive such as Linux `renameat2` with `RENAME_NOREPLACE`. It receives resolved absolute host paths and an optional signal in its third argument. Existing destinations must reject `EEXIST` atomically; existence checks followed by rename and copy/delete are insufficient. Without this binding the adapter explicitly refuses those operations. See the [no-replace contract](src/contracts/filesystem.md#atomic-no-replace-rename).
|
|
31
31
|
|
|
@@ -109,7 +109,7 @@ before implementing the host operation; ordinary writes do not provide it.
|
|
|
109
109
|
| `createOverlayFileSystem({ upper, lower })` | Reading through to a lower layer and writing changes to an upper layer |
|
|
110
110
|
| `withFileSystemQuota(filesystem, { maxBytes })` | Enforcing a cumulative logical-byte ceiling across every write path |
|
|
111
111
|
|
|
112
|
-
`createNodeFsBridge` offers a promises-shaped subset, including recursive `cp` and `mkdtemp`. The portable `createFsBridge` from `@poe-platform/safe-fs/core` instead requires a caller-supplied text codec and returns `Uint8Array` values.
|
|
112
|
+
`createNodeFsBridge` offers a promises-shaped subset, including recursive `cp` and `mkdtemp`. A trusted `reserveReadFile` callback can reserve host resources and return a release function; cancellation retains the reservation until the backend read settles. The portable `createFsBridge` from `@poe-platform/safe-fs/core` instead requires a caller-supplied text codec and returns `Uint8Array` values.
|
|
113
113
|
|
|
114
114
|
## Write an adapter
|
|
115
115
|
|
|
@@ -233,8 +233,8 @@ There are no package environment variables, implicit credentials, or automatic `
|
|
|
233
233
|
| Mount | Required `root`: fallback filesystem. `mounts` defaults to `{}` and maps absolute virtual paths to filesystems. |
|
|
234
234
|
| Overlay | Required `upper` and `lower`; `maxBufferBytes` is unlimited unless configured. |
|
|
235
235
|
| Quota | `withFileSystemQuota` requires a nonnegative safe-integer `maxBytes`. It serializes mutations and counts files, symlinks, copies, hard links, truncation, and streaming writes. |
|
|
236
|
-
| Node bridge | `cwd` defaults to `/`, must be an absolute virtual path; optional lifetime `signal
|
|
237
|
-
| Portable bridge | Same `cwd` and `
|
|
236
|
+
| Node bridge | `cwd` defaults to `/`, must be an absolute virtual path; optional lifetime `signal` and trusted `readFileMaxBytes` cap forwarded to backend reads before copy/decode. |
|
|
237
|
+
| Portable bridge | Same `cwd`, `signal` and `readFileMaxBytes`, plus required `codec` with `isEncoding`, `encode`, and `decode` functions. |
|
|
238
238
|
| Catalog example | Required `files`: a plain record of single-component filenames to text strings; no other options. |
|
|
239
239
|
|
|
240
240
|
### Per-operation options
|
|
@@ -16,6 +16,8 @@ export declare class FileSystemBridge<Binary extends Uint8Array> {
|
|
|
16
16
|
readonly cwd?: string;
|
|
17
17
|
readonly root?: string;
|
|
18
18
|
readonly signal?: AbortSignal;
|
|
19
|
+
readonly readFileMaxBytes?: number;
|
|
20
|
+
readonly reserveReadFile?: () => () => void;
|
|
19
21
|
}, primitives: BridgePrimitives<Binary>);
|
|
20
22
|
readFile(path: unknown, options?: {
|
|
21
23
|
encoding?: null | undefined;
|
|
@@ -44,6 +44,8 @@ export class FileSystemBridge {
|
|
|
44
44
|
#cwd;
|
|
45
45
|
#root;
|
|
46
46
|
#signal;
|
|
47
|
+
#readFileMaxBytes;
|
|
48
|
+
#reserveReadFile;
|
|
47
49
|
#primitives;
|
|
48
50
|
#codec;
|
|
49
51
|
constructor(fs, options, primitives) {
|
|
@@ -67,6 +69,13 @@ export class FileSystemBridge {
|
|
|
67
69
|
this.#root = primitives.paths.resolve("/", options.root ?? cwd);
|
|
68
70
|
assertBridgePath(this.#root, this.#cwd);
|
|
69
71
|
this.#signal = options.signal;
|
|
72
|
+
const maximum = options.readFileMaxBytes;
|
|
73
|
+
if (maximum !== undefined && (!Number.isSafeInteger(maximum) || maximum < 0))
|
|
74
|
+
throw new TypeError("Invalid readFileMaxBytes");
|
|
75
|
+
this.#readFileMaxBytes = maximum;
|
|
76
|
+
if (options.reserveReadFile !== undefined && typeof options.reserveReadFile !== "function")
|
|
77
|
+
throw new TypeError("Invalid reserveReadFile");
|
|
78
|
+
this.#reserveReadFile = options.reserveReadFile;
|
|
70
79
|
}
|
|
71
80
|
#encoding(value, fallback, names = false) {
|
|
72
81
|
const selected = value == null ? fallback : value;
|
|
@@ -121,9 +130,29 @@ export class FileSystemBridge {
|
|
|
121
130
|
if (options.encoding === "buffer")
|
|
122
131
|
throw new TypeError("Invalid read encoding");
|
|
123
132
|
const codec = this.#encoding(options.encoding, "buffer");
|
|
124
|
-
const
|
|
125
|
-
const
|
|
126
|
-
|
|
133
|
+
const maxBytes = this.#readFileMaxBytes;
|
|
134
|
+
const release = this.#reserveReadFile?.();
|
|
135
|
+
let pending;
|
|
136
|
+
try {
|
|
137
|
+
const bytes = await this.#call([path], (signal, resolved) => {
|
|
138
|
+
pending = this.#fs.readFile(resolved[0], { ...signal, ...(maxBytes === undefined ? {} : { maxBytes }) });
|
|
139
|
+
return pending;
|
|
140
|
+
}, options.signal);
|
|
141
|
+
if (maxBytes !== undefined && bytes.byteLength > maxBytes)
|
|
142
|
+
throw fsError("EFBIG", "readFile");
|
|
143
|
+
const buffer = this.#primitives.copyBytes(bytes);
|
|
144
|
+
return codec === "buffer" ? buffer : this.#codec.decode(buffer, codec);
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
// Cancellation may reject #call before a backend acknowledges it. Keep
|
|
148
|
+
// its reservation until the actual read settles, as well as through decode.
|
|
149
|
+
if (release !== undefined) {
|
|
150
|
+
if (pending === undefined)
|
|
151
|
+
release();
|
|
152
|
+
else
|
|
153
|
+
void pending.then(release, release);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
127
156
|
}
|
|
128
157
|
async #write(path, data, value, fallback) {
|
|
129
158
|
const options = optionsRecord(value, ["encoding", "flag", "mode", "flush", "signal"]);
|
|
@@ -10,6 +10,10 @@ export interface FsBridgeOptions {
|
|
|
10
10
|
/** Optional confinement boundary; defaults to cwd. */
|
|
11
11
|
readonly root?: string;
|
|
12
12
|
readonly signal?: AbortSignal;
|
|
13
|
+
/** Trusted per-read backend byte cap, enforced before copying or decoding. */
|
|
14
|
+
readonly readFileMaxBytes?: number;
|
|
15
|
+
/** Reserve host resources; release after decoding and actual backend settlement, including cancellation. */
|
|
16
|
+
readonly reserveReadFile?: () => () => void;
|
|
13
17
|
}
|
|
14
18
|
export interface FsBridgeFileSystem extends FileSystem {
|
|
15
19
|
rmdir?(path: string, options?: FsOptions): Promise<void>;
|
|
@@ -70,6 +70,8 @@ export interface FileSystemCapabilities {
|
|
|
70
70
|
/** Owned staging serialized within a trusted host; requires external tree isolation. */
|
|
71
71
|
readonly trustedOwnedStaging?: boolean;
|
|
72
72
|
readonly atomicFileStaging?: boolean;
|
|
73
|
+
/** Atomically verifies every supplied root-to-parent directory identity at publication. */
|
|
74
|
+
readonly atomicStagingAncestry?: boolean;
|
|
73
75
|
readonly atomicFilePublication?: boolean;
|
|
74
76
|
readonly atomicFileMutation?: boolean;
|
|
75
77
|
readonly atomicEntryRemoval?: boolean;
|
|
@@ -206,6 +208,7 @@ export interface CreateStagedFileOptions extends FsOptions {
|
|
|
206
208
|
readonly mtimeMs?: number;
|
|
207
209
|
}
|
|
208
210
|
export interface PublishStagedFileOptions extends FsOptions {
|
|
211
|
+
readonly ancestors?: readonly FileStagingEntry[];
|
|
209
212
|
readonly parent: FileStat;
|
|
210
213
|
readonly destination: FileStat | null;
|
|
211
214
|
}
|
|
@@ -68,7 +68,7 @@ export class MemoryFileSystem {
|
|
|
68
68
|
permissions: true,
|
|
69
69
|
timestamps: true,
|
|
70
70
|
atomicRename: true,
|
|
71
|
-
atomicFileStaging: true, atomicFileMutation: true, atomicEntryRemoval: true, atomicTreeRemoval: true,
|
|
71
|
+
atomicFileStaging: true, atomicStagingAncestry: true, atomicFileMutation: true, atomicEntryRemoval: true, atomicTreeRemoval: true,
|
|
72
72
|
atomicDirectoryMetadata: true,
|
|
73
73
|
streamingRead: true,
|
|
74
74
|
retainedRead: true,
|
|
@@ -134,7 +134,7 @@ export class MemoryFileSystem {
|
|
|
134
134
|
}
|
|
135
135
|
retained.set("/", this.root);
|
|
136
136
|
}
|
|
137
|
-
const allowed = new Set(["mkdir", "rm", "rmdir", "rename", "symlink", "link", "chmod", "utimes", "writeFile", "appendFile", "writeStream"]);
|
|
137
|
+
const allowed = new Set(["mkdir", "rm", "rmdir", "rename", "symlink", "link", "chmod", "utimes", "writeFile", "appendFile", "writeStream", "writeFileConditional", "removeFileConditional"]);
|
|
138
138
|
const reads = new Set(["access", "capabilitiesFor", "compareEntry", "lstat", "stat", "readFile", "readStream", "readdir", "readlink", "realpath"]);
|
|
139
139
|
const check = (path, followFinal) => {
|
|
140
140
|
if (!roots.some(root => root === "/" || path === root || path.startsWith(`${root}/`)))
|
|
@@ -172,7 +172,7 @@ export class MemoryFileSystem {
|
|
|
172
172
|
for (const path of paths) {
|
|
173
173
|
if (typeof path !== "string")
|
|
174
174
|
throw new FsError("EINVAL");
|
|
175
|
-
check(path, ["chmod", "utimes", "appendFile", "writeFile", "writeStream", "link"].includes(String(property)));
|
|
175
|
+
check(path, ["chmod", "utimes", "appendFile", "writeFile", "writeStream", "writeFileConditional", "link"].includes(String(property)));
|
|
176
176
|
}
|
|
177
177
|
return Reflect.apply(value, target, args);
|
|
178
178
|
};
|
|
@@ -655,6 +655,25 @@ export class MemoryFileSystem {
|
|
|
655
655
|
}
|
|
656
656
|
async publishStagedFile(staging, destination, options) {
|
|
657
657
|
options.signal?.throwIfAborted();
|
|
658
|
+
if (options.ancestors) {
|
|
659
|
+
let path = "/";
|
|
660
|
+
const parentPath = destination.slice(0, destination.lastIndexOf("/")) || "/";
|
|
661
|
+
const paths = ["/", ...parentPath.split("/").filter(Boolean).map(component => {
|
|
662
|
+
path = path === "/" ? `/${component}` : `${path}/${component}`;
|
|
663
|
+
return path;
|
|
664
|
+
})];
|
|
665
|
+
if (paths.length !== options.ancestors.length)
|
|
666
|
+
this.fail("EINVAL", "publishStagedFile", destination);
|
|
667
|
+
for (let index = 0; index < paths.length; index++) {
|
|
668
|
+
const expected = options.ancestors[index];
|
|
669
|
+
if (expected.path !== paths[index])
|
|
670
|
+
this.fail("EINVAL", "publishStagedFile", destination);
|
|
671
|
+
const entry = this.entry(expected.path, "publishStagedFile");
|
|
672
|
+
if (entry.node?.type !== "directory")
|
|
673
|
+
this.fail("EAGAIN", "publishStagedFile", destination);
|
|
674
|
+
this.expectEntry(entry.node, expected.stat, expected.path, false);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
658
677
|
const { directory, file } = this.stagingLocations(staging);
|
|
659
678
|
this.expectEntry(file.node, staging.file.stat, staging.file.path);
|
|
660
679
|
const target = this.entry(destination, "publishStagedFile", true);
|
|
@@ -131,6 +131,7 @@ export class MountFileSystem {
|
|
|
131
131
|
readOnly: all("readOnly"),
|
|
132
132
|
...(append === undefined ? {} : { append }),
|
|
133
133
|
...semantics,
|
|
134
|
+
atomicStagingAncestry: false,
|
|
134
135
|
atomicRename: mounts.length === 1 && all("atomicRename"),
|
|
135
136
|
...(streamingRead === undefined ? {} : { streamingRead }),
|
|
136
137
|
...(streamingWrite === undefined ? {} : { streamingWrite }),
|
|
@@ -154,7 +155,7 @@ export class MountFileSystem {
|
|
|
154
155
|
? { ...observed, descriptorWriteStream: false } : observed;
|
|
155
156
|
const resize = declared.atomicResize === true && (typeof location.mount.backend.resizeFile !== "function" || declared.readOnly === true)
|
|
156
157
|
? { ...declared, atomicResize: false } : declared;
|
|
157
|
-
const withOpen = { ...resize, ...(typeof location.mount.backend.open === "function" ? {} : { open: false }) };
|
|
158
|
+
const withOpen = { ...resize, atomicStagingAncestry: false, ...(typeof location.mount.backend.open === "function" ? {} : { open: false }) };
|
|
158
159
|
const capabilities = location.synthetic ? { ...withOpen, open: false, retainedRead: false }
|
|
159
160
|
: retainedResizeCapabilities(location.mount.backend, retainedReadCapabilities(location.mount.backend, withOpen));
|
|
160
161
|
if (location.synthetic)
|
|
@@ -2,11 +2,15 @@ import type { FileStat, FileSystem, FsOptions, OpenFileOptions } from "../../con
|
|
|
2
2
|
import type { ByteSource } from "../../contracts/io.js";
|
|
3
3
|
export interface ObjectFileVersion {
|
|
4
4
|
readonly revision: string;
|
|
5
|
+
/** Opaque lease/reservation token; independent of namespace revision. */
|
|
6
|
+
readonly publicationToken?: string;
|
|
5
7
|
readonly stat: FileStat;
|
|
6
8
|
read(position: number, maxBytes: number, options?: FsOptions): Promise<Uint8Array>;
|
|
7
9
|
close(): Promise<void>;
|
|
8
10
|
}
|
|
9
11
|
export interface ObjectFilePublicationOptions extends FsOptions {
|
|
12
|
+
/** Token from this descriptor's current retained version, if supplied by its store. */
|
|
13
|
+
readonly previousToken?: string;
|
|
10
14
|
readonly size: number;
|
|
11
15
|
readonly mode: number;
|
|
12
16
|
}
|
|
@@ -50,6 +50,7 @@ export function withObjectFileDescriptors(filesystem, store, options = {}) {
|
|
|
50
50
|
};
|
|
51
51
|
const version = (value) => {
|
|
52
52
|
if (!value || typeof value.revision !== "string" || value.revision.length === 0 || value.revision.length > 4096
|
|
53
|
+
|| value.publicationToken !== undefined && (typeof value.publicationToken !== "string" || value.publicationToken.length === 0 || value.publicationToken.length > 4096)
|
|
53
54
|
|| !value.stat || value.stat.type !== "file" || !Number.isSafeInteger(value.stat.size) || value.stat.size < 0
|
|
54
55
|
|| !Number.isSafeInteger(value.stat.mode) || value.stat.mode < 0 || value.stat.mode > 0o177777
|
|
55
56
|
|| ![value.stat.mtimeMs, value.stat.atimeMs, value.stat.ctimeMs].every(Number.isFinite)
|
|
@@ -57,7 +58,7 @@ export function withObjectFileDescriptors(filesystem, store, options = {}) {
|
|
|
57
58
|
throw new FsError("EIO", { message: "Invalid immutable object version" });
|
|
58
59
|
if (value.stat.size > maxFileBytes)
|
|
59
60
|
throw new FsError("EFBIG", { message: "Object descriptor file limit exceeded" });
|
|
60
|
-
return Object.freeze({ revision: value.revision, stat: Object.freeze({ ...value.stat }), read: value.read.bind(value), close: value.close.bind(value) });
|
|
61
|
+
return Object.freeze({ revision: value.revision, ...(value.publicationToken === undefined ? {} : { publicationToken: value.publicationToken }), stat: Object.freeze({ ...value.stat }), read: value.read.bind(value), close: value.close.bind(value) });
|
|
61
62
|
};
|
|
62
63
|
const clearPages = (state) => {
|
|
63
64
|
stagedBytes -= state.pages.size * chunkBytes;
|
|
@@ -181,7 +182,7 @@ export function withObjectFileDescriptors(filesystem, store, options = {}) {
|
|
|
181
182
|
yield bytes;
|
|
182
183
|
}
|
|
183
184
|
})();
|
|
184
|
-
received = await store.publish(path, expected, source, { ...selected, size: state.size, mode: (state.head?.stat.mode ?? admitted.mode) & 0o7777 });
|
|
185
|
+
received = await store.publish(path, expected, source, { ...selected, ...(state.head?.publicationToken === undefined ? {} : { previousToken: state.head.publicationToken }), size: state.size, mode: (state.head?.stat.mode ?? admitted.mode) & 0o7777 });
|
|
185
186
|
});
|
|
186
187
|
const published = version(received);
|
|
187
188
|
if (emitted !== state.size || published.stat.size !== state.size || published.revision === expected)
|
|
@@ -27,7 +27,9 @@ function namespaceMetadata(metadata, signal) {
|
|
|
27
27
|
abort();
|
|
28
28
|
});
|
|
29
29
|
}
|
|
30
|
-
async function scanUsedBytes(fs, limits, options,
|
|
30
|
+
async function scanUsedBytes(fs, limits, options, changeInput, retained = typeof changeInput !== "function" && changeInput?.retained === true) {
|
|
31
|
+
let change = typeof changeInput === "function" ? undefined : changeInput;
|
|
32
|
+
let prepare = typeof changeInput === "function" ? changeInput : undefined;
|
|
31
33
|
let total = 0;
|
|
32
34
|
let retainedTotal = 0n;
|
|
33
35
|
let shrinkCredited = false;
|
|
@@ -46,6 +48,24 @@ async function scanUsedBytes(fs, limits, options, change, retained = change?.ret
|
|
|
46
48
|
const count = entries.length;
|
|
47
49
|
admitDirectoryEntries(count, remaining, directory.path);
|
|
48
50
|
remaining -= count;
|
|
51
|
+
if (prepare) {
|
|
52
|
+
change = await prepare();
|
|
53
|
+
prepare = undefined;
|
|
54
|
+
}
|
|
55
|
+
if (change?.stat.type === "directory") {
|
|
56
|
+
const stat = await fs.stat(directory.path, options);
|
|
57
|
+
const comparable = completeIdentity(stat) && completeIdentity(change.stat);
|
|
58
|
+
const comparison = comparable
|
|
59
|
+
? stat.identityScope === change.stat.identityScope && stat.dev === change.stat.dev && stat.ino === change.stat.ino ? "same" : "distinct"
|
|
60
|
+
: fs.compareEntry ? await fs.compareEntry(change.path, fs, directory.path, options) : "unknown";
|
|
61
|
+
options?.signal?.throwIfAborted();
|
|
62
|
+
if (comparison !== "same" && comparison !== "distinct" && comparison !== "unknown")
|
|
63
|
+
throw new FsError("EIO", { syscall: "compareEntry", path: change.path });
|
|
64
|
+
if (comparison !== "distinct") {
|
|
65
|
+
total += Math.max(0, change.delta);
|
|
66
|
+
possibleAliases++;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
49
69
|
let processed = 0;
|
|
50
70
|
for (const entry of entries) {
|
|
51
71
|
options?.signal?.throwIfAborted();
|
|
@@ -69,7 +89,7 @@ async function scanUsedBytes(fs, limits, options, change, retained = change?.ret
|
|
|
69
89
|
if (!Number.isSafeInteger(stat.size) || stat.size < 0)
|
|
70
90
|
throw new FsError("EIO", { syscall: "lstat", path, message: "invalid quota entry size" });
|
|
71
91
|
retainedTotal += BigInt(stat.size);
|
|
72
|
-
if (!change || stat.type !== "file")
|
|
92
|
+
if (!change || change.stat.type !== "file" || stat.type !== "file")
|
|
73
93
|
continue;
|
|
74
94
|
const known = completeIdentity(stat);
|
|
75
95
|
const same = known && change.stat.identityScope === stat.identityScope && change.stat.dev === stat.dev && change.stat.ino === stat.ino;
|
|
@@ -89,7 +109,7 @@ async function scanUsedBytes(fs, limits, options, change, retained = change?.ret
|
|
|
89
109
|
continue;
|
|
90
110
|
}
|
|
91
111
|
total += stat.size;
|
|
92
|
-
if (!change || stat.type !== "file")
|
|
112
|
+
if (!change || change.stat.type !== "file" || stat.type !== "file")
|
|
93
113
|
continue;
|
|
94
114
|
const scope = change.stat.identityScope;
|
|
95
115
|
const comparable = [scope, stat.identityScope].every(value => typeof value === "symbol" || typeof value === "object" && value !== null)
|
|
@@ -169,6 +189,35 @@ export function withFileSystemQuota(fs, options) {
|
|
|
169
189
|
return typeof original === "function" ? original.bind(fs) : original;
|
|
170
190
|
},
|
|
171
191
|
});
|
|
192
|
+
const creationChange = async (path, delta, fsOptions) => {
|
|
193
|
+
// Resolve existing ancestors before comparing directory identities. A dangling
|
|
194
|
+
// final symlink cannot safely identify its publication directory here.
|
|
195
|
+
let target;
|
|
196
|
+
try {
|
|
197
|
+
target = fs.canonicalizeMissingTarget?.(path, fsOptions) ?? path;
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
201
|
+
throw new FileSystemQuotaError(options.maxBytes);
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
const entry = fs.canonicalizeMissingTarget ? undefined : await fs.lstat(target, fsOptions);
|
|
206
|
+
if (entry?.type === "symlink")
|
|
207
|
+
throw new FsError("ENOTSUP", { syscall: "write", path, message: "quota cannot locate missing symlink referent" });
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
const boundary = target.lastIndexOf("/");
|
|
214
|
+
const parent = await fs.realpath(boundary <= 0 ? "/" : target.slice(0, boundary), fsOptions);
|
|
215
|
+
const stat = await fs.stat(parent, fsOptions);
|
|
216
|
+
fsOptions?.signal?.throwIfAborted();
|
|
217
|
+
if (stat.type !== "directory")
|
|
218
|
+
throw new FsError("ENOTDIR", { syscall: "write", path });
|
|
219
|
+
return { path: parent, stat, delta };
|
|
220
|
+
};
|
|
172
221
|
const assertDelta = async (path, nextBytes, fsOptions) => {
|
|
173
222
|
fsOptions?.signal?.throwIfAborted();
|
|
174
223
|
let current;
|
|
@@ -181,7 +230,9 @@ export function withFileSystemQuota(fs, options) {
|
|
|
181
230
|
}
|
|
182
231
|
const projected = current?.type === "file" && nextBytes > current.size
|
|
183
232
|
? await usedBytes(fs, scanLimits, fsOptions, { path, stat: current, delta: nextBytes - current.size })
|
|
184
|
-
:
|
|
233
|
+
: !current && nextBytes > 0
|
|
234
|
+
? await usedBytes(fs, scanLimits, fsOptions, () => creationChange(path, nextBytes, fsOptions))
|
|
235
|
+
: await usedBytes(fs, scanLimits, fsOptions) - (current?.type === "directory" ? 0 : current?.size ?? 0) + nextBytes;
|
|
185
236
|
fsOptions?.signal?.throwIfAborted();
|
|
186
237
|
if (projected > options.maxBytes)
|
|
187
238
|
throw new FileSystemQuotaError(options.maxBytes);
|
|
@@ -205,8 +256,15 @@ export function withFileSystemQuota(fs, options) {
|
|
|
205
256
|
const conditional = descriptor.capabilities.publication === "conditional" && pinned !== undefined;
|
|
206
257
|
let publishedSize = pinned?.size ?? 0;
|
|
207
258
|
let reserved = 0;
|
|
259
|
+
let publicationAliases = 1;
|
|
260
|
+
if (conditional) {
|
|
261
|
+
const baseline = await usedBytes(fs, scanLimits, admitted);
|
|
262
|
+
publicationAliases = await usedBytes(fs, scanLimits, admitted, () => creationChange(path, 1, admitted)) - baseline;
|
|
263
|
+
if (!Number.isSafeInteger(publicationAliases) || publicationAliases < 1)
|
|
264
|
+
throw new FsError("ENOTSUP", { syscall: "open", path, message: "quota cannot determine publication aliases" });
|
|
265
|
+
}
|
|
208
266
|
const reserve = (size) => {
|
|
209
|
-
const next = Math.max(0, size - publishedSize);
|
|
267
|
+
const next = Math.max(0, size - publishedSize) * publicationAliases;
|
|
210
268
|
reservedBytes += next - reserved;
|
|
211
269
|
reserved = next;
|
|
212
270
|
};
|
|
@@ -231,13 +289,13 @@ export function withFileSystemQuota(fs, options) {
|
|
|
231
289
|
if (!Number.isSafeInteger(nextBytes) || nextBytes < 0)
|
|
232
290
|
throw new FsError("EFBIG", { syscall: "write", path });
|
|
233
291
|
const projected = conditional
|
|
234
|
-
? await usedBytes(fs, scanLimits, operationOptions) - reserved + Math.max(0, nextBytes - publishedSize)
|
|
292
|
+
? await usedBytes(fs, scanLimits, operationOptions) - reserved + Math.max(0, nextBytes - publishedSize) * publicationAliases
|
|
235
293
|
: await usedBytes(fs, scanLimits, operationOptions, { path, stat, delta: nextBytes - stat.size, retained: true });
|
|
236
294
|
operationOptions.signal?.throwIfAborted();
|
|
237
295
|
if (projected > options.maxBytes)
|
|
238
296
|
throw new FileSystemQuotaError(options.maxBytes);
|
|
239
297
|
if (conditional)
|
|
240
|
-
reserve(Math.max(nextBytes, publishedSize + reserved));
|
|
298
|
+
reserve(Math.max(nextBytes, publishedSize + reserved / publicationAliases));
|
|
241
299
|
};
|
|
242
300
|
const forwarded = forwardFileDescriptor(descriptor, (syscall, operationOptions, action) => syscall === "close" && !conditional ? action() : mutate(async () => {
|
|
243
301
|
try {
|
|
@@ -429,7 +429,9 @@ export class S3FileSystem {
|
|
|
429
429
|
fail("EIO", "readFile", path, "response body length does not match ContentLength");
|
|
430
430
|
return bytes;
|
|
431
431
|
}).catch((error) => {
|
|
432
|
-
this.dispose(iterator
|
|
432
|
+
this.dispose(iterator);
|
|
433
|
+
if (output.Body !== iterator)
|
|
434
|
+
this.dispose(output.Body, iterator === undefined);
|
|
433
435
|
throw error;
|
|
434
436
|
});
|
|
435
437
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ByteSource } from '../../contracts/io.js';
|
|
2
|
+
/** Admit the fixed manifest grammar before JSON.parse can allocate its graph. */
|
|
3
|
+
export declare function admitManifest(source: ByteSource, options: {
|
|
4
|
+
maxManifestBytes: number;
|
|
5
|
+
maxEntries: number;
|
|
6
|
+
maxBytes: number;
|
|
7
|
+
signal?: AbortSignal | undefined;
|
|
8
|
+
}): ByteSource;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { FsError } from '../../contracts/errors.js';
|
|
2
|
+
import { readBytes } from '../../contracts/io.js';
|
|
3
|
+
/** Admit the fixed manifest grammar before JSON.parse can allocate its graph. */
|
|
4
|
+
export async function* admitManifest(source, options) {
|
|
5
|
+
const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
6
|
+
const frames = [];
|
|
7
|
+
let started = false;
|
|
8
|
+
let done = false;
|
|
9
|
+
let token = '';
|
|
10
|
+
let quoted = false;
|
|
11
|
+
let escaped = false;
|
|
12
|
+
let entries = 0;
|
|
13
|
+
let bytes = 0;
|
|
14
|
+
let wireBytes = 0;
|
|
15
|
+
let allocation = 0;
|
|
16
|
+
// Conservative accounting for graph storage, array growth, and validation
|
|
17
|
+
// tables. This is an admission estimate, not a process-RSS guarantee.
|
|
18
|
+
const allocationLimit = options.maxManifestBytes * 4;
|
|
19
|
+
const invalid = () => { throw new FsError('EIO', { message: 'Invalid S3 namespace structure' }); };
|
|
20
|
+
const reserve = (amount) => {
|
|
21
|
+
if (amount > allocationLimit - allocation)
|
|
22
|
+
throw new FsError('EFBIG', { message: 'S3 namespace graph exceeds allocation budget' });
|
|
23
|
+
allocation += amount;
|
|
24
|
+
};
|
|
25
|
+
const open = (shape) => {
|
|
26
|
+
reserve(256);
|
|
27
|
+
frames.push({ shape, state: shape === 'bytes' ? 'valueOrEnd' : 'keyOrEnd', key: '', keys: new Set() });
|
|
28
|
+
};
|
|
29
|
+
const scalar = () => {
|
|
30
|
+
const frame = frames.at(-1) ?? invalid();
|
|
31
|
+
let value;
|
|
32
|
+
try {
|
|
33
|
+
value = JSON.parse(token);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
invalid();
|
|
37
|
+
}
|
|
38
|
+
token = '';
|
|
39
|
+
if (frame.state === 'key' || frame.state === 'keyOrEnd') {
|
|
40
|
+
if (typeof value !== 'string' || frame.keys.has(value))
|
|
41
|
+
invalid();
|
|
42
|
+
const key = value;
|
|
43
|
+
if (frame.shape === 'nodes') {
|
|
44
|
+
if (key.length > 4096)
|
|
45
|
+
invalid();
|
|
46
|
+
if (++entries > options.maxEntries)
|
|
47
|
+
throw new FsError('ENOSPC');
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
const fields = frame.shape === 'namespace' ? ['version', 'identity', 'nextInode', 'nodes']
|
|
51
|
+
: ['ino', 'revision', 'type', 'mode', 'time', 'bytes'];
|
|
52
|
+
if (!fields.includes(key))
|
|
53
|
+
invalid();
|
|
54
|
+
}
|
|
55
|
+
reserve(64 + key.length * 2);
|
|
56
|
+
frame.keys.add(key);
|
|
57
|
+
frame.key = key;
|
|
58
|
+
frame.state = 'colon';
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (frame.state !== 'value' && frame.state !== 'valueOrEnd')
|
|
62
|
+
invalid();
|
|
63
|
+
if (frame.shape === 'bytes') {
|
|
64
|
+
if (!Number.isInteger(value) || value < 0 || value > 255)
|
|
65
|
+
invalid();
|
|
66
|
+
if (++bytes > options.maxBytes)
|
|
67
|
+
throw new FsError('ENOSPC');
|
|
68
|
+
reserve(16);
|
|
69
|
+
}
|
|
70
|
+
else if (frame.shape === 'nodes' || frame.key === 'nodes' || frame.key === 'bytes')
|
|
71
|
+
invalid();
|
|
72
|
+
else if (frame.key === 'identity' || frame.key === 'type') {
|
|
73
|
+
if (typeof value !== 'string' || (frame.key === 'identity' ? value.length < 1 || value.length > 128 : !['file', 'directory'].includes(value)))
|
|
74
|
+
invalid();
|
|
75
|
+
reserve(value.length * 2);
|
|
76
|
+
}
|
|
77
|
+
else if (typeof value !== 'number' || !Number.isFinite(value))
|
|
78
|
+
invalid();
|
|
79
|
+
frame.state = 'commaOrEnd';
|
|
80
|
+
};
|
|
81
|
+
const feed = (text) => {
|
|
82
|
+
for (const character of text) {
|
|
83
|
+
if (token) {
|
|
84
|
+
if (quoted) {
|
|
85
|
+
token += character;
|
|
86
|
+
if (token.length > 4096 * 6 + 2)
|
|
87
|
+
invalid();
|
|
88
|
+
if (escaped)
|
|
89
|
+
escaped = false;
|
|
90
|
+
else if (character === '\\')
|
|
91
|
+
escaped = true;
|
|
92
|
+
else if (character === '"') {
|
|
93
|
+
quoted = false;
|
|
94
|
+
scalar();
|
|
95
|
+
}
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (![' ', '\n', '\r', '\t', ',', '}', ']'].includes(character)) {
|
|
99
|
+
if (token.length >= 64)
|
|
100
|
+
invalid();
|
|
101
|
+
token += character;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
scalar();
|
|
105
|
+
}
|
|
106
|
+
if ([' ', '\n', '\r', '\t'].includes(character))
|
|
107
|
+
continue;
|
|
108
|
+
if (done)
|
|
109
|
+
invalid();
|
|
110
|
+
if (!started) {
|
|
111
|
+
if (character !== '{')
|
|
112
|
+
invalid();
|
|
113
|
+
started = true;
|
|
114
|
+
open('namespace');
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const frame = frames.at(-1) ?? invalid();
|
|
118
|
+
if (character === '}' || character === ']') {
|
|
119
|
+
if ((character === ']') !== (frame.shape === 'bytes')
|
|
120
|
+
|| !['commaOrEnd', 'keyOrEnd', 'valueOrEnd'].includes(frame.state))
|
|
121
|
+
invalid();
|
|
122
|
+
if (frame.shape === 'namespace' && frame.keys.size !== 4 || frame.shape === 'node' && frame.keys.size !== 6)
|
|
123
|
+
invalid();
|
|
124
|
+
frames.pop();
|
|
125
|
+
if (!frames.length)
|
|
126
|
+
done = true;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (frame.state === 'commaOrEnd') {
|
|
130
|
+
if (character !== ',')
|
|
131
|
+
invalid();
|
|
132
|
+
frame.state = frame.shape === 'bytes' ? 'value' : 'key';
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (frame.state === 'colon') {
|
|
136
|
+
if (character !== ':')
|
|
137
|
+
invalid();
|
|
138
|
+
frame.state = 'value';
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (frame.state === 'key' || frame.state === 'keyOrEnd') {
|
|
142
|
+
if (character !== '"')
|
|
143
|
+
invalid();
|
|
144
|
+
}
|
|
145
|
+
else if (character === '{' || character === '[') {
|
|
146
|
+
const shape = frame.shape === 'namespace' && frame.key === 'nodes' ? 'nodes'
|
|
147
|
+
: frame.shape === 'nodes' ? 'node' : frame.shape === 'node' && frame.key === 'bytes' ? 'bytes' : invalid();
|
|
148
|
+
if ((character === '[') !== (shape === 'bytes'))
|
|
149
|
+
invalid();
|
|
150
|
+
frame.state = 'commaOrEnd';
|
|
151
|
+
open(shape);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
else if (frame.shape === 'nodes' || frame.key === 'nodes' || frame.key === 'bytes')
|
|
155
|
+
invalid();
|
|
156
|
+
quoted = character === '"';
|
|
157
|
+
escaped = false;
|
|
158
|
+
token = character;
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
for await (const chunk of readBytes(source, options.signal)) {
|
|
162
|
+
if (chunk.byteLength > options.maxManifestBytes - wireBytes)
|
|
163
|
+
throw new FsError('EFBIG');
|
|
164
|
+
wireBytes += chunk.byteLength;
|
|
165
|
+
feed(decoder.decode(chunk, { stream: true }));
|
|
166
|
+
yield chunk;
|
|
167
|
+
}
|
|
168
|
+
feed(decoder.decode());
|
|
169
|
+
if (!done || token || frames.length)
|
|
170
|
+
invalid();
|
|
171
|
+
}
|
|
@@ -2,6 +2,7 @@ import { FsError } from '../../contracts/errors.js';
|
|
|
2
2
|
import { collectBytes } from '../../contracts/io.js';
|
|
3
3
|
import { dirname, normalizePath, validatePath } from '../../contracts/virtual-path.js';
|
|
4
4
|
import { withObjectFileDescriptors } from '../object-publication/index.js';
|
|
5
|
+
import { admitManifest } from './manifest-admission.js';
|
|
5
6
|
function limit(value) {
|
|
6
7
|
if (value === undefined)
|
|
7
8
|
return Infinity;
|
|
@@ -17,7 +18,7 @@ export async function createS3NamespaceFileSystem(options) {
|
|
|
17
18
|
throw new TypeError('An explicit S3 bucket and manifest key are required');
|
|
18
19
|
const maxBytes = limit(options.maxBytes);
|
|
19
20
|
const maxEntries = limit(options.maxEntries);
|
|
20
|
-
const maxManifestBytes = limit(options.maxManifestBytes);
|
|
21
|
+
const maxManifestBytes = limit(options.maxManifestBytes ?? 4 * 1024 * 1024);
|
|
21
22
|
const maxAttempts = limit(options.maxAttempts);
|
|
22
23
|
const descriptorOptions = options;
|
|
23
24
|
for (const key of ['chunkBytes', 'maxStagedBytes', 'maxStagedPages', 'maxFileBytes', 'maxOpenFiles']) {
|
|
@@ -78,7 +79,7 @@ export async function createS3NamespaceFileSystem(options) {
|
|
|
78
79
|
forwarded.signal?.throwIfAborted();
|
|
79
80
|
try {
|
|
80
81
|
const response = await client.getObjectStream(object, forwarded.signal ? { abortSignal: forwarded.signal } : {});
|
|
81
|
-
const body = await collectBytes(response.Body, {
|
|
82
|
+
const body = await collectBytes(admitManifest(response.Body, { maxManifestBytes, maxEntries, maxBytes, ...(forwarded.signal ? { signal: forwarded.signal } : {}) }), { ...forwarded, maxBytes: maxManifestBytes });
|
|
82
83
|
if (typeof response.ETag !== 'string' || !response.ETag || response.ETag.startsWith('W/'))
|
|
83
84
|
throw new FsError('EIO', { message: 'S3 namespace requires a strong object validator' });
|
|
84
85
|
let decoded;
|
|
@@ -6,6 +6,76 @@ import { admitDirectoryEntries, directoryEntryLimit } from "../directory-admissi
|
|
|
6
6
|
import { assertCallbackAuthorityAllowed, compareEntries, registerEntryAuthority } from "../mount/comparison.js";
|
|
7
7
|
import { compareWebDavResources, ownedResponseIdentifier, recordOwnedResourceStat, registerResourceQuery, resourceIdentifier } from "./resource-id.js";
|
|
8
8
|
const timestampNamespace = "urn:virtual-bash:metadata";
|
|
9
|
+
const maxTimestampPropertyBytes = 4096;
|
|
10
|
+
/** Admit only five scalar members before native JSON parsing can allocate a graph. */
|
|
11
|
+
function preflightTimestamps(property) {
|
|
12
|
+
// Check the untrimmed text first; counting UTF-8 bytes does not allocate an encoded copy.
|
|
13
|
+
let bytes = 0;
|
|
14
|
+
for (const point of property.text) {
|
|
15
|
+
const code = point.codePointAt(0);
|
|
16
|
+
bytes += code <= 0x7f ? 1 : code <= 0x7ff ? 2 : code <= 0xffff ? 3 : 4;
|
|
17
|
+
if (bytes > maxTimestampPropertyBytes)
|
|
18
|
+
throw new Error("timestamp property exceeds 4KiB limit");
|
|
19
|
+
}
|
|
20
|
+
const text = scalar(property);
|
|
21
|
+
let offset = 0;
|
|
22
|
+
const invalid = () => { throw new Error("invalid timestamp property structure"); };
|
|
23
|
+
const whitespace = () => {
|
|
24
|
+
while (" \t\r\n".includes(text[offset] ?? "\0"))
|
|
25
|
+
offset++;
|
|
26
|
+
};
|
|
27
|
+
const stringEnd = () => {
|
|
28
|
+
if (text[offset++] !== '"')
|
|
29
|
+
invalid();
|
|
30
|
+
while (offset < text.length) {
|
|
31
|
+
const char = text[offset++];
|
|
32
|
+
if (char === '"')
|
|
33
|
+
return;
|
|
34
|
+
if (char === "\\")
|
|
35
|
+
offset++;
|
|
36
|
+
}
|
|
37
|
+
invalid();
|
|
38
|
+
};
|
|
39
|
+
if (text[offset++] !== "{")
|
|
40
|
+
invalid();
|
|
41
|
+
const fields = new Set();
|
|
42
|
+
while (true) {
|
|
43
|
+
whitespace();
|
|
44
|
+
const start = offset;
|
|
45
|
+
stringEnd();
|
|
46
|
+
const field = JSON.parse(text.slice(start, offset));
|
|
47
|
+
if (typeof field !== "string" || !["version", "etag", "type", "atimeMs", "mtimeMs"].includes(field)
|
|
48
|
+
|| fields.has(field) || fields.size >= 5)
|
|
49
|
+
return invalid();
|
|
50
|
+
fields.add(field);
|
|
51
|
+
whitespace();
|
|
52
|
+
if (text[offset++] !== ":")
|
|
53
|
+
invalid();
|
|
54
|
+
whitespace();
|
|
55
|
+
if (text[offset] === '"')
|
|
56
|
+
stringEnd();
|
|
57
|
+
else {
|
|
58
|
+
const start = offset;
|
|
59
|
+
while (offset < text.length && text[offset] !== "," && text[offset] !== "}") {
|
|
60
|
+
if ('{[:"\\]'.includes(text[offset]))
|
|
61
|
+
invalid();
|
|
62
|
+
offset++;
|
|
63
|
+
}
|
|
64
|
+
if (offset === start)
|
|
65
|
+
invalid();
|
|
66
|
+
}
|
|
67
|
+
whitespace();
|
|
68
|
+
const delimiter = text[offset++];
|
|
69
|
+
if (delimiter === "}")
|
|
70
|
+
break;
|
|
71
|
+
if (delimiter !== ",")
|
|
72
|
+
invalid();
|
|
73
|
+
}
|
|
74
|
+
whitespace();
|
|
75
|
+
if (offset !== text.length || fields.size !== 5)
|
|
76
|
+
invalid();
|
|
77
|
+
return text;
|
|
78
|
+
}
|
|
9
79
|
const propfindBody = '<?xml version="1.0" encoding="utf-8"?>'
|
|
10
80
|
+ '<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/><d:getcontentlength/>'
|
|
11
81
|
+ `<d:getlastmodified/><d:creationdate/><d:getetag/><v:timestamps xmlns:v="${timestampNamespace}"/></d:prop></d:propfind>`;
|
|
@@ -612,7 +682,7 @@ export class WebDavFileSystem {
|
|
|
612
682
|
let mtimeMs = date("getlastmodified") ?? 0;
|
|
613
683
|
let atimeMs = 0;
|
|
614
684
|
if (timestampProperty) {
|
|
615
|
-
const timestamps = JSON.parse(
|
|
685
|
+
const timestamps = JSON.parse(preflightTimestamps(timestampProperty));
|
|
616
686
|
if (typeof timestamps !== "object" || timestamps === null
|
|
617
687
|
|| !("version" in timestamps) || timestamps.version !== 1
|
|
618
688
|
|| !("etag" in timestamps) || typeof timestamps.etag !== "string" || !/^"[\x21\x23-\x7e\x80-\xff]*"$/.test(timestamps.etag)
|
|
@@ -6,6 +6,10 @@ export interface NodeFsBridgeOptions {
|
|
|
6
6
|
/** Optional confinement boundary; defaults to cwd. */
|
|
7
7
|
readonly root?: string;
|
|
8
8
|
readonly signal?: AbortSignal;
|
|
9
|
+
/** Trusted per-read backend byte cap, enforced before copying or decoding. */
|
|
10
|
+
readonly readFileMaxBytes?: number;
|
|
11
|
+
/** Reserve host resources; release after decoding and actual backend settlement, including cancellation. */
|
|
12
|
+
readonly reserveReadFile?: () => () => void;
|
|
9
13
|
}
|
|
10
14
|
export interface NodeFsBridgeFileSystem extends FileSystem {
|
|
11
15
|
rmdir?(path: string, options?: FsOptions): Promise<void>;
|