@poe-platform/safe-fs 0.1.720 → 0.1.722
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 +64 -18
- package/dist/safe-fs/bridge/filesystem.d.ts +2 -0
- package/dist/safe-fs/bridge/filesystem.js +17 -7
- package/dist/safe-fs/bridge/types.d.ts +2 -0
- package/dist/safe-fs/contracts/descriptor.d.ts +5 -0
- package/dist/safe-fs/contracts/filesystem.d.ts +12 -0
- package/dist/safe-fs/contracts/io.d.ts +3 -1
- package/dist/safe-fs/contracts/io.js +48 -16
- package/dist/safe-fs/core.d.ts +2 -1
- package/dist/safe-fs/core.js +1 -1
- package/dist/safe-fs/fs/capabilities.js +6 -3
- package/dist/safe-fs/fs/descriptor.js +9 -2
- package/dist/safe-fs/fs/devices/index.d.ts +1 -0
- package/dist/safe-fs/fs/devices/index.js +24 -6
- package/dist/safe-fs/fs/memory/index.d.ts +1 -0
- package/dist/safe-fs/fs/memory/index.js +75 -3
- package/dist/safe-fs/fs/memory/limits.js +6 -4
- package/dist/safe-fs/fs/mount/index.d.ts +4 -0
- package/dist/safe-fs/fs/mount/index.js +74 -11
- package/dist/safe-fs/fs/object-publication/index.js +6 -6
- package/dist/safe-fs/fs/overlay/index.d.ts +4 -2
- package/dist/safe-fs/fs/overlay/index.js +92 -36
- package/dist/safe-fs/fs/quota/index.js +18 -16
- package/dist/safe-fs/fs/readonly/index.js +5 -3
- package/dist/safe-fs/fs/real/index.d.ts +21 -1
- package/dist/safe-fs/fs/real/index.js +266 -22
- package/dist/safe-fs/fs/s3/filesystem.d.ts +8 -0
- package/dist/safe-fs/fs/s3/filesystem.js +58 -12
- package/dist/safe-fs/fs/s3/http/request.d.ts +1 -1
- package/dist/safe-fs/fs/s3/http/request.js +1 -1
- package/dist/safe-fs/fs/s3/http/transport.js +8 -6
- package/dist/safe-fs/fs/s3/http/xml.d.ts +4 -1
- package/dist/safe-fs/fs/s3/http/xml.js +6 -2
- package/dist/safe-fs/fs/s3/namespace.js +12 -10
- package/dist/safe-fs/fs/scoped.d.ts +1 -0
- package/dist/safe-fs/fs/scoped.js +54 -35
- package/dist/safe-fs/fs/webdav/webdav.d.ts +4 -1
- package/dist/safe-fs/fs/webdav/webdav.js +30 -22
- package/dist/safe-fs/node/filesystem.d.ts +4 -1
- package/dist/safe-fs/node/filesystem.js +7 -1
- package/dist/safe-fs/node/host.d.ts +3 -0
- package/dist/safe-fs/node/host.js +78 -0
- package/dist/safe-fs/node/index.d.ts +2 -1
- package/dist/safe-fs/node/index.js +2 -1
- package/dist/safe-fs/platform/browser.d.ts +1 -0
- package/dist/safe-fs/platform/browser.js +1 -0
- package/dist/safe-fs/platform/node.d.ts +1 -0
- package/dist/safe-fs/platform/node.js +1 -0
- package/dist/safe-fs/python/emscripten.d.ts +1 -0
- package/dist/safe-fs/python/emscripten.js +5 -5
- package/dist/safe-fs/python/filesystem.js +5 -5
- package/dist/safe-fs/python/native.d.ts +1 -0
- package/dist/safe-fs/python/native.js +3 -2
- package/dist/safe-fs/xml.js +32 -21
- package/package.json +1 -1
|
@@ -35,7 +35,11 @@ function decodeEntities(text) {
|
|
|
35
35
|
}
|
|
36
36
|
return result;
|
|
37
37
|
}
|
|
38
|
-
export function parseXml(bytes) {
|
|
38
|
+
export function parseXml(bytes, limits = {}) {
|
|
39
|
+
for (const limit of [limits.maxNodes, limits.maxDepth]) {
|
|
40
|
+
if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1))
|
|
41
|
+
throw new RangeError("XML limits must be positive safe integers");
|
|
42
|
+
}
|
|
39
43
|
let xml;
|
|
40
44
|
try {
|
|
41
45
|
xml = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
@@ -146,7 +150,7 @@ export function parseXml(bytes) {
|
|
|
146
150
|
position++;
|
|
147
151
|
if (xml[position++] !== ">")
|
|
148
152
|
malformed();
|
|
149
|
-
if (++nodes >
|
|
153
|
+
if (++nodes > (limits.maxNodes ?? Infinity) || stack.length >= (limits.maxDepth ?? Infinity))
|
|
150
154
|
malformed("XML structure limit exceeded");
|
|
151
155
|
const node = { name: tag, text: "", children: [] };
|
|
152
156
|
const parent = stack.at(-1);
|
|
@@ -2,8 +2,10 @@ 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
|
-
function limit(value
|
|
6
|
-
if (
|
|
5
|
+
function limit(value) {
|
|
6
|
+
if (value === undefined)
|
|
7
|
+
return Infinity;
|
|
8
|
+
if (!Number.isSafeInteger(value) || value < 1)
|
|
7
9
|
throw new RangeError('Invalid S3 namespace limit');
|
|
8
10
|
return value;
|
|
9
11
|
}
|
|
@@ -13,14 +15,14 @@ export async function createS3NamespaceFileSystem(options) {
|
|
|
13
15
|
throw new FsError('ENOTSUP', { message: 'S3 namespaces require verified conditional PUT and bounded streaming reads' });
|
|
14
16
|
if (![options.bucket, options.key].every(value => typeof value === 'string' && value.length > 0 && !value.includes('\0')))
|
|
15
17
|
throw new TypeError('An explicit S3 bucket and manifest key are required');
|
|
16
|
-
const maxBytes = limit(options.maxBytes
|
|
17
|
-
const maxEntries = limit(options.maxEntries
|
|
18
|
-
const maxManifestBytes = limit(options.maxManifestBytes
|
|
19
|
-
const maxAttempts = limit(options.maxAttempts
|
|
20
|
-
const descriptorOptions =
|
|
18
|
+
const maxBytes = limit(options.maxBytes);
|
|
19
|
+
const maxEntries = limit(options.maxEntries);
|
|
20
|
+
const maxManifestBytes = limit(options.maxManifestBytes);
|
|
21
|
+
const maxAttempts = limit(options.maxAttempts);
|
|
22
|
+
const descriptorOptions = options;
|
|
21
23
|
for (const key of ['chunkBytes', 'maxStagedBytes', 'maxStagedPages', 'maxFileBytes', 'maxOpenFiles']) {
|
|
22
24
|
if (descriptorOptions[key] !== undefined)
|
|
23
|
-
limit(descriptorOptions[key]
|
|
25
|
+
limit(descriptorOptions[key]);
|
|
24
26
|
}
|
|
25
27
|
const object = Object.freeze({ Bucket: options.bucket, Key: options.key });
|
|
26
28
|
const scope = Object.freeze({});
|
|
@@ -76,7 +78,7 @@ export async function createS3NamespaceFileSystem(options) {
|
|
|
76
78
|
forwarded.signal?.throwIfAborted();
|
|
77
79
|
try {
|
|
78
80
|
const response = await client.getObjectStream(object, forwarded.signal ? { abortSignal: forwarded.signal } : {});
|
|
79
|
-
const body = await collectBytes(response.Body, { ...forwarded, maxBytes: maxManifestBytes });
|
|
81
|
+
const body = await collectBytes(response.Body, { ...forwarded, ...(maxManifestBytes === Infinity ? {} : { maxBytes: maxManifestBytes }) });
|
|
80
82
|
if (typeof response.ETag !== 'string' || !response.ETag || response.ETag.startsWith('W/'))
|
|
81
83
|
throw new FsError('EIO', { message: 'S3 namespace requires a strong object validator' });
|
|
82
84
|
let decoded;
|
|
@@ -378,7 +380,7 @@ export async function createS3NamespaceFileSystem(options) {
|
|
|
378
380
|
return version(node);
|
|
379
381
|
},
|
|
380
382
|
async publish(input, expectedRevision, source, forwarded) {
|
|
381
|
-
const bytes = await collectBytes(source, { ...forwarded, maxBytes });
|
|
383
|
+
const bytes = await collectBytes(source, { ...forwarded, ...(maxBytes === Infinity ? {} : { maxBytes: maxBytes }) });
|
|
382
384
|
if (bytes.length !== forwarded.size)
|
|
383
385
|
throw new FsError('EIO');
|
|
384
386
|
const published = await mutate(forwarded, value => {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { FileSystem, FsOptions } from "../contracts/filesystem.js";
|
|
2
2
|
export declare function scopeFileSystem(filesystem: FileSystem, charge: () => void, signal: AbortSignal, cleanupCharge?: () => void, options?: {
|
|
3
3
|
readonly preserveDescriptorWriteReceipt?: boolean;
|
|
4
|
+
readonly creationMask?: number;
|
|
4
5
|
}): FileSystem;
|
|
5
6
|
export interface RetainedFileSystemCleanupView {
|
|
6
7
|
readonly removeFileConditional?: NonNullable<FileSystem["removeFileConditional"]>;
|
|
@@ -5,13 +5,26 @@ import { openRetainedResizeFile, retainedResizeCapabilities, ownedMutationCapabi
|
|
|
5
5
|
const originals = new WeakMap();
|
|
6
6
|
const operations = new Set([
|
|
7
7
|
"publishFileConditional", "removeEntryConditional", "removeTreeConditional", "writeFileConditional", "removeFileConditional", "createStagedFile", "publishStagedFile", "removeStagedFile", "prepareDirectory",
|
|
8
|
-
"access", "appendFile", "canonicalizeMissingTarget", "capabilitiesFor", "chmod", "compareEntry",
|
|
8
|
+
"confineExtraction", "access", "appendFile", "canonicalizeMissingTarget", "capabilitiesFor", "chmod", "compareEntry",
|
|
9
9
|
"copyFile", "link", "lstat", "mkdir", "openReadFile", "openResizeFile", "readFile", "readStream", "readdir",
|
|
10
10
|
"readlink", "realpath", "rename", "resizeFile", "rm", "rmdir", "unlink", "stat", "symlink", "truncate", "utimes",
|
|
11
11
|
"writeFile", "writeStream",
|
|
12
12
|
]);
|
|
13
13
|
export function scopeFileSystem(filesystem, charge, signal, cleanupCharge = charge, options = {}) {
|
|
14
14
|
const original = originals.get(filesystem)?.filesystem ?? filesystem;
|
|
15
|
+
const creationMask = options.creationMask ?? originals.get(filesystem)?.creationMask;
|
|
16
|
+
if (creationMask !== undefined && (!Number.isInteger(creationMask) || creationMask < 0 || creationMask > 0o777))
|
|
17
|
+
throw new RangeError("creationMask must be a permission mask between 0 and 0777");
|
|
18
|
+
const creationOptions = (settings, directory = false) => {
|
|
19
|
+
// Explicit directory modes (mkdir -m) are independent of the shell mask.
|
|
20
|
+
// Modes remain advisory on adapters that do not enforce permissions.
|
|
21
|
+
if (creationMask === undefined || directory && settings.mode !== undefined)
|
|
22
|
+
return settings;
|
|
23
|
+
const mode = settings.mode ?? (directory ? 0o777 : 0o666);
|
|
24
|
+
if (!Number.isSafeInteger(mode) || mode < 0 || mode > 0o7777)
|
|
25
|
+
throw new FsError("EINVAL");
|
|
26
|
+
return { ...settings, mode: mode & ~creationMask };
|
|
27
|
+
};
|
|
15
28
|
const methods = new Map();
|
|
16
29
|
const assertOpen = (options) => {
|
|
17
30
|
signal.throwIfAborted();
|
|
@@ -199,6 +212,10 @@ export function scopeFileSystem(filesystem, charge, signal, cleanupCharge = char
|
|
|
199
212
|
const options = args.at(-1);
|
|
200
213
|
admit(options && typeof options === "object" && "signal" in options ? options : undefined);
|
|
201
214
|
}
|
|
215
|
+
if (["writeFile", "appendFile", "writeStream", "mkdir"].includes(String(property))) {
|
|
216
|
+
const index = property === "mkdir" ? 1 : 2;
|
|
217
|
+
args[index] = creationOptions((args[index] ?? {}), property === "mkdir");
|
|
218
|
+
}
|
|
202
219
|
if (["publishFileConditional", "removeEntryConditional", "removeTreeConditional", "writeFileConditional", "removeFileConditional", "createStagedFile", "publishStagedFile", "removeStagedFile", "prepareDirectory"].includes(String(property)))
|
|
203
220
|
return (async () => {
|
|
204
221
|
const path = typeof args[0] === "string" ? args[0] : args[0].directory.path;
|
|
@@ -229,43 +246,45 @@ export function scopeFileSystem(filesystem, charge, signal, cleanupCharge = char
|
|
|
229
246
|
})();
|
|
230
247
|
return Reflect.apply(method, original, args);
|
|
231
248
|
};
|
|
232
|
-
const scoped = property === "
|
|
233
|
-
? async (
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
let descriptor;
|
|
237
|
-
try {
|
|
238
|
-
descriptor = await Reflect.apply(method, original, [path, selected]);
|
|
239
|
-
assertOpen(options);
|
|
240
|
-
return wrapDescriptor(descriptor);
|
|
241
|
-
}
|
|
242
|
-
catch (error) {
|
|
243
|
-
if (descriptor)
|
|
244
|
-
await finishCleanup(() => descriptor.close(), true);
|
|
245
|
-
assertOpen(options);
|
|
246
|
-
throw error;
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
: property === "openResizeFile"
|
|
250
|
-
? async (path, options = {}) => {
|
|
249
|
+
const scoped = property === "confineExtraction"
|
|
250
|
+
? async (...args) => scopeFileSystem(await dispatch(...args), charge, signal, cleanupCharge, options)
|
|
251
|
+
: property === "open"
|
|
252
|
+
? async (path, options) => {
|
|
251
253
|
admit(options);
|
|
252
|
-
|
|
253
|
-
|
|
254
|
+
const selected = resizeOptions(creationOptions(options));
|
|
255
|
+
let descriptor;
|
|
256
|
+
try {
|
|
257
|
+
descriptor = await Reflect.apply(method, original, [path, selected]);
|
|
258
|
+
assertOpen(options);
|
|
259
|
+
return wrapDescriptor(descriptor);
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
if (descriptor)
|
|
263
|
+
await finishCleanup(() => descriptor.close(), true);
|
|
264
|
+
assertOpen(options);
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
254
267
|
}
|
|
255
|
-
: property === "
|
|
256
|
-
? async (
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
268
|
+
: property === "openResizeFile"
|
|
269
|
+
? async (path, options = {}) => {
|
|
270
|
+
admit(options);
|
|
271
|
+
assertOpen(options);
|
|
272
|
+
return wrapResizeHandle(await openRetainedResizeFile(original, path, resizeOptions(creationOptions(options))));
|
|
273
|
+
}
|
|
274
|
+
: property === "capabilitiesFor"
|
|
275
|
+
? async (...args) => ownedMutationCapabilities(original, retainedResizeCapabilities(original, await dispatch(...args)))
|
|
276
|
+
: property === "openReadFile"
|
|
277
|
+
? async (...args) => wrapHandle(await dispatch(...args))
|
|
278
|
+
: property === "readStream"
|
|
279
|
+
? (...args) => wrapStream(dispatch(...args), args[1])
|
|
280
|
+
: operations.has(property) && property !== "canonicalizeMissingTarget"
|
|
281
|
+
? async (...args) => dispatch(...args)
|
|
282
|
+
: dispatch;
|
|
264
283
|
methods.set(property, { original: method, scoped });
|
|
265
284
|
return scoped;
|
|
266
285
|
},
|
|
267
286
|
});
|
|
268
|
-
originals.set(view, { filesystem: original, signal, cleanupCharge });
|
|
287
|
+
originals.set(view, { filesystem: original, signal, cleanupCharge, creationMask });
|
|
269
288
|
registerEntryView(view, async (path, options) => {
|
|
270
289
|
assertOpen(options);
|
|
271
290
|
return { filesystem: original, path };
|
|
@@ -273,9 +292,9 @@ export function scopeFileSystem(filesystem, charge, signal, cleanupCharge = char
|
|
|
273
292
|
return view;
|
|
274
293
|
}
|
|
275
294
|
export function retainFileSystemCleanup(filesystem, cleanupCallback, options) {
|
|
276
|
-
const maximum = options?.maxOperations
|
|
277
|
-
if (!Number.isSafeInteger(maximum) || maximum < 0
|
|
278
|
-
throw new RangeError("cleanup maxOperations must be
|
|
295
|
+
const maximum = options?.maxOperations ?? Infinity;
|
|
296
|
+
if (options?.maxOperations !== undefined && (!Number.isSafeInteger(maximum) || maximum < 0))
|
|
297
|
+
throw new RangeError("cleanup maxOperations must be a nonnegative safe integer");
|
|
279
298
|
if (typeof cleanupCallback !== "function")
|
|
280
299
|
throw new TypeError("cleanup callback must be a function");
|
|
281
300
|
const scope = originals.get(filesystem);
|
|
@@ -24,10 +24,12 @@ export interface WebDavFileSystemOptions {
|
|
|
24
24
|
readonly fetch: WebDavFetch;
|
|
25
25
|
readonly requestStreamSupport?: "native" | boolean;
|
|
26
26
|
readonly headers?: Readonly<Record<string, string>>;
|
|
27
|
+
/** Response ceiling; defaults to 16 MiB. Hosts must budget for concurrent reads. */
|
|
27
28
|
readonly maxResponseBytes?: number;
|
|
29
|
+
/** Metadata response ceiling; defaults to 1 MiB before decoding/parsing. */
|
|
28
30
|
readonly maxXmlBytes?: number;
|
|
29
31
|
readonly maxEntries?: number;
|
|
30
|
-
/** Per-request and aggregate stat/write-preflight walk timeout;
|
|
32
|
+
/** Per-request and aggregate stat/write-preflight walk timeout; unlimited unless configured. */
|
|
31
33
|
readonly timeoutMs?: number;
|
|
32
34
|
readonly overwritePolicy?: "lock" | "etag";
|
|
33
35
|
readonly atomicEmptyDirectory?: WebDavAtomicEmptyDirectoryBinding;
|
|
@@ -91,6 +93,7 @@ export declare class WebDavFileSystem implements FileSystem {
|
|
|
91
93
|
private request;
|
|
92
94
|
private requestStream;
|
|
93
95
|
private bytes;
|
|
96
|
+
private responseLength;
|
|
94
97
|
private bodyChunks;
|
|
95
98
|
private xml;
|
|
96
99
|
private multistatus;
|
|
@@ -24,7 +24,7 @@ function positive(value, name, zero = false) {
|
|
|
24
24
|
}
|
|
25
25
|
function createRequestTimeout(timeoutMs) {
|
|
26
26
|
const controller = new AbortController();
|
|
27
|
-
const timer = setTimeout(() => {
|
|
27
|
+
const timer = timeoutMs === undefined ? undefined : setTimeout(() => {
|
|
28
28
|
controller.abort(new DOMException("The operation was aborted due to timeout", "TimeoutError"));
|
|
29
29
|
}, timeoutMs);
|
|
30
30
|
if (typeof timer === "object" && timer !== null) {
|
|
@@ -216,15 +216,15 @@ export class WebDavFileSystem {
|
|
|
216
216
|
removeDirectory: this.atomicEmptyDirectory !== undefined,
|
|
217
217
|
streamingAppend: this.requestStreamSupport !== false,
|
|
218
218
|
});
|
|
219
|
-
this.maxResponseBytes =
|
|
220
|
-
this.maxXmlBytes =
|
|
221
|
-
this.maxEntries = positive(options.maxEntries
|
|
222
|
-
this.timeoutMs = positive(options.timeoutMs
|
|
219
|
+
this.maxResponseBytes = options.maxResponseBytes === undefined ? 16 * 1024 * 1024 : positive(options.maxResponseBytes, "maxResponseBytes");
|
|
220
|
+
this.maxXmlBytes = options.maxXmlBytes === undefined ? 1024 * 1024 : positive(options.maxXmlBytes, "maxXmlBytes");
|
|
221
|
+
this.maxEntries = options.maxEntries === undefined ? Infinity : positive(options.maxEntries, "maxEntries");
|
|
222
|
+
this.timeoutMs = options.timeoutMs === undefined ? undefined : positive(options.timeoutMs, "timeoutMs");
|
|
223
223
|
this.overwritePolicy = options.overwritePolicy ?? "lock";
|
|
224
224
|
this.configuredComparison = options.compareEntry !== undefined;
|
|
225
225
|
if (!["lock", "etag"].includes(this.overwritePolicy))
|
|
226
226
|
throw new FsError("EINVAL", { message: "invalid overwritePolicy" });
|
|
227
|
-
if (this.timeoutMs > 2_147_483_647)
|
|
227
|
+
if (this.timeoutMs !== undefined && this.timeoutMs > 2_147_483_647)
|
|
228
228
|
throw new FsError("EINVAL", { message: "timeoutMs exceeds timer range" });
|
|
229
229
|
registerResourceQuery(this, (path, settings) => this.resourceId(path, settings), originalWebDavComparison, options.compareEntry);
|
|
230
230
|
registerEntryAuthority(this, compareWebDavResources);
|
|
@@ -424,29 +424,37 @@ export class WebDavFileSystem {
|
|
|
424
424
|
}
|
|
425
425
|
}
|
|
426
426
|
async bytes(response, limit, signal) {
|
|
427
|
-
const
|
|
427
|
+
const expected = this.responseLength(response, limit);
|
|
428
|
+
let data = new Uint8Array(expected ?? 0);
|
|
428
429
|
let size = 0;
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
size
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
430
|
+
// Consume borrowed chunks immediately; only streaming callers need owned copies.
|
|
431
|
+
for await (const chunk of this.bodyChunks(response, limit, signal, false)) {
|
|
432
|
+
const nextSize = size + chunk.byteLength;
|
|
433
|
+
if (nextSize > data.byteLength) {
|
|
434
|
+
const grown = new Uint8Array(Math.min(limit, Math.max(nextSize, data.byteLength * 2)));
|
|
435
|
+
grown.set(data);
|
|
436
|
+
data = grown;
|
|
437
|
+
}
|
|
438
|
+
data.set(chunk, size);
|
|
439
|
+
size = nextSize;
|
|
438
440
|
}
|
|
439
|
-
|
|
441
|
+
// A view avoids a second full allocation when the length was unknown.
|
|
442
|
+
return data.subarray(0, size);
|
|
440
443
|
}
|
|
441
|
-
|
|
444
|
+
responseLength(response, limit) {
|
|
442
445
|
const length = response.headers.get("content-length");
|
|
443
|
-
if (length !== null && (
|
|
446
|
+
if (length !== null && (!length.length || [...length].some(char => char < "0" || char > "9")
|
|
447
|
+
|| !Number.isSafeInteger(Number(length)))) {
|
|
444
448
|
fail("EIO", "webdav", "", "invalid response Content-Length");
|
|
445
449
|
}
|
|
446
450
|
const encoding = response.headers.get("content-encoding");
|
|
447
451
|
const expected = length !== null && (!encoding || encoding.toLowerCase() === "identity") ? Number(length) : undefined;
|
|
448
452
|
if (expected !== undefined && expected > limit)
|
|
449
453
|
fail("EFBIG", "webdav", "", "response exceeds byte limit");
|
|
454
|
+
return expected;
|
|
455
|
+
}
|
|
456
|
+
async *bodyChunks(response, limit, signal, copy = true) {
|
|
457
|
+
const expected = this.responseLength(response, limit);
|
|
450
458
|
if (!response.body) {
|
|
451
459
|
if (expected !== undefined && expected !== 0)
|
|
452
460
|
fail("EIO", "webdav", "", "response body length differs from Content-Length");
|
|
@@ -468,7 +476,7 @@ export class WebDavFileSystem {
|
|
|
468
476
|
fail("EFBIG", "webdav", "", "response exceeds byte limit");
|
|
469
477
|
if (expected !== undefined && size > expected)
|
|
470
478
|
fail("EIO", "webdav", "", "response body length differs from Content-Length");
|
|
471
|
-
yield new Uint8Array(result.value);
|
|
479
|
+
yield copy ? new Uint8Array(result.value) : result.value;
|
|
472
480
|
}
|
|
473
481
|
if (expected !== undefined && size !== expected)
|
|
474
482
|
fail("EIO", "webdav", "", "response body length differs from Content-Length");
|
|
@@ -492,7 +500,7 @@ export class WebDavFileSystem {
|
|
|
492
500
|
}
|
|
493
501
|
let root;
|
|
494
502
|
try {
|
|
495
|
-
root = await this.xml(response, signal, this.maxEntries);
|
|
503
|
+
root = await this.xml(response, signal, this.maxEntries === Infinity ? undefined : this.maxEntries);
|
|
496
504
|
}
|
|
497
505
|
catch (error) {
|
|
498
506
|
if (error instanceof XmlResponseLimitError)
|
|
@@ -762,7 +770,7 @@ export class WebDavFileSystem {
|
|
|
762
770
|
}
|
|
763
771
|
async readFile(path, options = {}) {
|
|
764
772
|
const normalized = normalize(path);
|
|
765
|
-
const limit = Math.min(this.maxResponseBytes,
|
|
773
|
+
const limit = Math.min(this.maxResponseBytes, options.maxBytes === undefined ? this.maxResponseBytes : positive(options.maxBytes, "maxBytes", true));
|
|
766
774
|
if ((await this.stat(path, options)).type === "directory")
|
|
767
775
|
fail("EISDIR", "readFile", path);
|
|
768
776
|
return this.request("GET", normalized, options, { headers: { "Accept-Encoding": "identity" } }, async (response, signal) => {
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import type * as FsPromises from "node:fs/promises";
|
|
2
2
|
import type { FileSystem, FsOptions } from "../contracts/filesystem.js";
|
|
3
|
-
export type NodeFsImplementation = Pick<typeof FsPromises, "access" | "appendFile" | "chmod" | "copyFile" | "cp" | "link" | "lstat" | "mkdir" | "mkdtemp" | "readFile" | "readdir" | "readlink" | "realpath" | "rename" | "rm" | "rmdir" | "stat" | "symlink" | "truncate" | "utimes" | "writeFile">;
|
|
3
|
+
export type NodeFsImplementation = Pick<typeof FsPromises, "access" | "appendFile" | "chmod" | "copyFile" | "cp" | "link" | "lstat" | "mkdir" | "mkdtemp" | "readFile" | "readdir" | "readlink" | "realpath" | "unlink" | "rename" | "rm" | "rmdir" | "stat" | "symlink" | "truncate" | "utimes" | "writeFile">;
|
|
4
4
|
export interface NodeFsBridgeOptions {
|
|
5
5
|
readonly cwd?: string;
|
|
6
|
+
/** Optional confinement boundary; defaults to cwd. */
|
|
7
|
+
readonly root?: string;
|
|
6
8
|
readonly signal?: AbortSignal;
|
|
7
9
|
}
|
|
8
10
|
export interface NodeFsBridgeFileSystem extends FileSystem {
|
|
9
11
|
rmdir?(path: string, options?: FsOptions): Promise<void>;
|
|
10
12
|
}
|
|
13
|
+
export declare function getNodeFsBridgeProvider(bridge: object): FileSystem | undefined;
|
|
11
14
|
export declare function createNodeFsBridge(fs: FileSystem, options?: NodeFsBridgeOptions): NodeFsImplementation;
|
|
@@ -3,8 +3,12 @@ import { randomBytes } from "node:crypto";
|
|
|
3
3
|
import { posix } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { FileSystemBridge } from "../bridge/filesystem.js";
|
|
6
|
+
const providers = new WeakMap();
|
|
7
|
+
export function getNodeFsBridgeProvider(bridge) {
|
|
8
|
+
return providers.get(bridge);
|
|
9
|
+
}
|
|
6
10
|
export function createNodeFsBridge(fs, options = {}) {
|
|
7
|
-
|
|
11
|
+
const bridge = new FileSystemBridge(fs, options, {
|
|
8
12
|
codec: {
|
|
9
13
|
isEncoding: Buffer.isEncoding,
|
|
10
14
|
encode(text, encoding) {
|
|
@@ -25,4 +29,6 @@ export function createNodeFsBridge(fs, options = {}) {
|
|
|
25
29
|
randomSuffix() { return randomBytes(6).toString("hex").slice(0, 6); },
|
|
26
30
|
paths: posix
|
|
27
31
|
});
|
|
32
|
+
providers.set(bridge, fs);
|
|
33
|
+
return bridge;
|
|
28
34
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
function snapshot(stat) {
|
|
3
|
+
return {
|
|
4
|
+
type: stat.isDirectory()
|
|
5
|
+
? "directory"
|
|
6
|
+
: stat.isSymbolicLink()
|
|
7
|
+
? "symlink"
|
|
8
|
+
: stat.isFile()
|
|
9
|
+
? "file"
|
|
10
|
+
: "character",
|
|
11
|
+
size: stat.size,
|
|
12
|
+
mode: stat.mode,
|
|
13
|
+
mtimeMs: stat.mtimeMs,
|
|
14
|
+
atimeMs: stat.atimeMs,
|
|
15
|
+
ctimeMs: stat.ctimeMs,
|
|
16
|
+
birthtimeMs: stat.birthtimeMs
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/** Trusted host access with native paths and symlink semantics; this is not a rooted sandbox. */
|
|
20
|
+
export function createHostFileSystem() {
|
|
21
|
+
async function perform(options, action) {
|
|
22
|
+
options?.signal?.throwIfAborted();
|
|
23
|
+
const result = await action();
|
|
24
|
+
options?.signal?.throwIfAborted();
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
capabilities: {
|
|
29
|
+
read: true,
|
|
30
|
+
write: true,
|
|
31
|
+
append: true,
|
|
32
|
+
stat: true,
|
|
33
|
+
readdir: true,
|
|
34
|
+
mkdir: true,
|
|
35
|
+
recursiveMkdir: true,
|
|
36
|
+
rename: true,
|
|
37
|
+
remove: true,
|
|
38
|
+
removeDirectory: true,
|
|
39
|
+
recursiveRemove: true,
|
|
40
|
+
symlinks: true,
|
|
41
|
+
hardlinks: true,
|
|
42
|
+
permissions: true,
|
|
43
|
+
timestamps: true,
|
|
44
|
+
exclusiveCreate: true
|
|
45
|
+
},
|
|
46
|
+
readFile: (path, options) => perform(options, () => fs.readFile(path, options?.signal ? { signal: options.signal } : undefined)),
|
|
47
|
+
writeFile: (path, data, options) => perform(options, () => fs.writeFile(path, data, options)),
|
|
48
|
+
appendFile: (path, data, options) => perform(options, () => fs.appendFile(path, data, options)),
|
|
49
|
+
stat: (path, options) => perform(options, async () => snapshot(await fs.stat(path))),
|
|
50
|
+
lstat: (path, options) => perform(options, async () => snapshot(await fs.lstat(path))),
|
|
51
|
+
readdir: (path, options) => perform(options, async () => (await fs.readdir(path, { withFileTypes: true })).map((entry) => ({
|
|
52
|
+
name: entry.name,
|
|
53
|
+
type: entry.isDirectory()
|
|
54
|
+
? "directory"
|
|
55
|
+
: entry.isSymbolicLink()
|
|
56
|
+
? "symlink"
|
|
57
|
+
: entry.isFile()
|
|
58
|
+
? "file"
|
|
59
|
+
: "character"
|
|
60
|
+
}))),
|
|
61
|
+
mkdir: (path, options) => perform(options, async () => {
|
|
62
|
+
await fs.mkdir(path, options);
|
|
63
|
+
}),
|
|
64
|
+
rm: (path, options) => perform(options, () => fs.rm(path, options)),
|
|
65
|
+
unlink: (path, options) => perform(options, () => fs.unlink(path)),
|
|
66
|
+
rmdir: (path, options) => perform(options, () => fs.rmdir(path)),
|
|
67
|
+
rename: (source, destination, options) => perform(options, () => fs.rename(source, destination)),
|
|
68
|
+
copyFile: (source, destination, options) => perform(options, () => fs.copyFile(source, destination, options?.exclusive ? fs.constants.COPYFILE_EXCL : 0)),
|
|
69
|
+
realpath: (path, options) => perform(options, () => fs.realpath(path)),
|
|
70
|
+
access: (path, mode, options) => perform(options, () => fs.access(path, mode)),
|
|
71
|
+
readlink: (path, options) => perform(options, () => fs.readlink(path)),
|
|
72
|
+
symlink: (target, path, options) => perform(options, () => fs.symlink(target, path)),
|
|
73
|
+
link: (source, destination, options) => perform(options, () => fs.link(source, destination)),
|
|
74
|
+
chmod: (path, mode, options) => perform(options, () => fs.chmod(path, mode)),
|
|
75
|
+
utimes: (path, atimeMs, mtimeMs, options) => perform(options, () => fs.utimes(path, atimeMs / 1000, mtimeMs / 1000)),
|
|
76
|
+
truncate: (path, length, options) => perform(options, () => fs.truncate(path, length))
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
export { createNodeFsBridge } from "./filesystem.js";
|
|
1
|
+
export { createNodeFsBridge, getNodeFsBridgeProvider } from "./filesystem.js";
|
|
2
2
|
export type { NodeFsBridgeOptions, NodeFsBridgeFileSystem, NodeFsImplementation } from "./filesystem.js";
|
|
3
|
+
export { createHostFileSystem } from "./host.js";
|
|
@@ -1 +1,2 @@
|
|
|
1
|
-
export { createNodeFsBridge } from "./filesystem.js";
|
|
1
|
+
export { createNodeFsBridge, getNodeFsBridgeProvider } from "./filesystem.js";
|
|
2
|
+
export { createHostFileSystem } from "./host.js";
|
|
@@ -2,6 +2,7 @@ import type { FsOptions } from "../contracts/filesystem.js";
|
|
|
2
2
|
export type PlatformErrno = number | undefined;
|
|
3
3
|
export type PlatformComparisonCallback<Callback> = Callback & never;
|
|
4
4
|
export declare const platform: Readonly<{
|
|
5
|
+
maxCollectionBytes: number;
|
|
5
6
|
errno(_code: string): PlatformErrno;
|
|
6
7
|
callbackAuthorities: false;
|
|
7
8
|
randomUUID(): string;
|
|
@@ -3,6 +3,7 @@ import type { FsOptions } from "../contracts/filesystem.js";
|
|
|
3
3
|
export type PlatformErrno = number;
|
|
4
4
|
export type PlatformComparisonCallback<Callback> = Callback;
|
|
5
5
|
export declare const platform: Readonly<{
|
|
6
|
+
maxCollectionBytes: number;
|
|
6
7
|
errno(code: string): PlatformErrno;
|
|
7
8
|
callbackAuthorities: true;
|
|
8
9
|
randomUUID: typeof randomUUID;
|
|
@@ -15,6 +15,7 @@ const systemErrnos = (() => {
|
|
|
15
15
|
})();
|
|
16
16
|
const negotiating = new AsyncLocalStorage();
|
|
17
17
|
export const platform = Object.freeze({
|
|
18
|
+
maxCollectionBytes: Infinity,
|
|
18
19
|
errno(code) {
|
|
19
20
|
const errno = systemErrnos.get(code === "EOPNOTSUPP" ? "ENOTSUP" : code);
|
|
20
21
|
if (errno === undefined)
|
|
@@ -6,6 +6,7 @@ export interface PythonMountOptions {
|
|
|
6
6
|
errno: Record<string, number>;
|
|
7
7
|
synchronizationFlags: number;
|
|
8
8
|
runtimeModule: any;
|
|
9
|
+
getUmask?: () => number;
|
|
9
10
|
}
|
|
10
11
|
export declare function mountPythonFileSystem(FS: any, options: PythonMountOptions): void;
|
|
11
12
|
/** Encode the pinned wasm32 musl stat layout without truncating canonical values. */
|
|
@@ -59,9 +59,9 @@ export function mountPythonFileSystem(FS, options) {
|
|
|
59
59
|
mknod(parent, name, mode) {
|
|
60
60
|
const target = `${path(parent)}/${name}`;
|
|
61
61
|
if (FS.isDir(mode))
|
|
62
|
-
rpc('mkdir', target, { mode: mode & 4095 });
|
|
62
|
+
rpc('mkdir', target, { mode: mode & 4095 & ~(options.getUmask?.() ?? 0), exactMode: true });
|
|
63
63
|
else {
|
|
64
|
-
const handle = rpc('open', target, { access: 'write', creation: 'exclusive', mode: mode & 4095 });
|
|
64
|
+
const handle = rpc('open', target, { access: 'write', creation: 'exclusive', mode: mode & 4095 & ~(options.getUmask?.() ?? 0), exactMode: true });
|
|
65
65
|
rpc('close', handle);
|
|
66
66
|
}
|
|
67
67
|
return node(parent, name, rpc('lstat', target));
|
|
@@ -145,7 +145,7 @@ export function mountPythonFileSystem(FS, options) {
|
|
|
145
145
|
throw new FS.ErrnoError(54);
|
|
146
146
|
let openOptions;
|
|
147
147
|
try {
|
|
148
|
-
openOptions = translatePythonOpenFlags(flags, mode);
|
|
148
|
+
openOptions = { ...translatePythonOpenFlags(flags, mode & ~(options.getUmask?.() ?? 0)), exactMode: true };
|
|
149
149
|
}
|
|
150
150
|
catch (error) {
|
|
151
151
|
throw new FS.ErrnoError(errno[error.code] ?? errno.EIO);
|
|
@@ -199,7 +199,7 @@ export function mountPythonFileSystem(FS, options) {
|
|
|
199
199
|
if (bootstrapPath(target))
|
|
200
200
|
return originalMkdir(target, mode);
|
|
201
201
|
const absolute = absolutePath(target);
|
|
202
|
-
rpc('mkdir', absolute, { mode });
|
|
202
|
+
rpc('mkdir', absolute, { mode: mode & ~(options.getUmask?.() ?? 0), exactMode: true });
|
|
203
203
|
return node(FS.root, absolute.slice(1), rpc('lstat', absolute));
|
|
204
204
|
};
|
|
205
205
|
const originalRename = FS.rename.bind(FS);
|
|
@@ -238,7 +238,7 @@ export function mountPythonFileSystem(FS, options) {
|
|
|
238
238
|
return originalMknod(target, mode, device);
|
|
239
239
|
if (!FS.isFile(mode))
|
|
240
240
|
throw new FS.ErrnoError(errno.ENOTSUP);
|
|
241
|
-
const handle = rpc('open', absolutePath(target), { access: 'write', creation: 'exclusive', mode: mode & 4095 });
|
|
241
|
+
const handle = rpc('open', absolutePath(target), { access: 'write', creation: 'exclusive', mode: mode & 4095 & ~(options.getUmask?.() ?? 0), exactMode: true });
|
|
242
242
|
rpc('close', handle);
|
|
243
243
|
return node(FS.root, absolutePath(target).slice(1), rpc('lstat', absolutePath(target)));
|
|
244
244
|
};
|
|
@@ -24,10 +24,10 @@ export class PythonFileSystem {
|
|
|
24
24
|
throw new FsError("EINVAL", { syscall: "python filesystem", path: options.cwd, message: "cwd must be absolute" });
|
|
25
25
|
this.#cwd = options.cwd;
|
|
26
26
|
this.#transfer = options.maxTransferBytes ?? 65536;
|
|
27
|
-
this.#limit = options.maxOpenFiles ??
|
|
28
|
-
this.#directoryLimit = options.maxDirectoryEntries ??
|
|
29
|
-
for (const limit of [this.#transfer,
|
|
30
|
-
if (!Number.isSafeInteger(limit) || limit < 1)
|
|
27
|
+
this.#limit = options.maxOpenFiles ?? Infinity;
|
|
28
|
+
this.#directoryLimit = options.maxDirectoryEntries ?? Infinity;
|
|
29
|
+
for (const limit of [this.#transfer, options.maxOpenFiles, options.maxDirectoryEntries])
|
|
30
|
+
if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1))
|
|
31
31
|
throw new FsError("EINVAL", { syscall: "python filesystem" });
|
|
32
32
|
this.#open = options.open;
|
|
33
33
|
this.#scope = composeAbortSignals([...(options.signal === undefined ? [] : [options.signal]), this.#abort.signal]);
|
|
@@ -153,7 +153,7 @@ export class PythonFileSystem {
|
|
|
153
153
|
case "realpath": return fs.realpath(this.#path(request.args[0]), options);
|
|
154
154
|
case "readdir": {
|
|
155
155
|
const path = this.#path(request.args[0]);
|
|
156
|
-
const entries = await fs.readdir(path, { ...options, maxEntries: this.#directoryLimit });
|
|
156
|
+
const entries = await fs.readdir(path, { ...options, ...(this.#directoryLimit === Infinity ? {} : { maxEntries: this.#directoryLimit }) });
|
|
157
157
|
signal.throwIfAborted();
|
|
158
158
|
if (entries.length > this.#directoryLimit)
|
|
159
159
|
throw new FsError("EFBIG", { syscall: "readdir", path, message: "Python directory listing exceeds configured limit" });
|
|
@@ -8,6 +8,7 @@ export interface PythonNativeSyscallOptions {
|
|
|
8
8
|
readonly cwd: string;
|
|
9
9
|
readonly runtimeMount: string;
|
|
10
10
|
readonly maxTransferBytes: number;
|
|
11
|
+
readonly getUmask?: () => number;
|
|
11
12
|
readonly original?: (name: string, args: readonly (number | bigint)[]) => number;
|
|
12
13
|
}
|
|
13
14
|
export declare function createPythonNativeSyscalls(options: PythonNativeSyscallOptions): {
|
|
@@ -195,7 +195,8 @@ export function createPythonNativeSyscalls(options) {
|
|
|
195
195
|
}
|
|
196
196
|
if (fourth)
|
|
197
197
|
range(fourth, 4);
|
|
198
|
-
const
|
|
198
|
+
const mode = (fourth ? view().getUint32(fourth, true) : 0o666) & ~(options.getUmask?.() ?? 0);
|
|
199
|
+
const openOptions = { ...translatePythonOpenFlags(third, mode), exactMode: true };
|
|
199
200
|
const handle = await options.dispatch({ op: 'open', args: [path, openOptions] });
|
|
200
201
|
try {
|
|
201
202
|
signal.throwIfAborted();
|
|
@@ -338,7 +339,7 @@ export function createPythonNativeSyscalls(options) {
|
|
|
338
339
|
if (privatePath(path))
|
|
339
340
|
fail('EROFS');
|
|
340
341
|
if (name === '__syscall_mkdirat')
|
|
341
|
-
await request('mkdir', path, { mode: third & 0o7777 });
|
|
342
|
+
await request('mkdir', path, { mode: third & 0o7777 & ~(options.getUmask?.() ?? 0), exactMode: true });
|
|
342
343
|
else {
|
|
343
344
|
if (third !== 0 && third !== 512)
|
|
344
345
|
fail('EINVAL');
|