@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.
Files changed (55) hide show
  1. package/README.md +64 -18
  2. package/dist/safe-fs/bridge/filesystem.d.ts +2 -0
  3. package/dist/safe-fs/bridge/filesystem.js +17 -7
  4. package/dist/safe-fs/bridge/types.d.ts +2 -0
  5. package/dist/safe-fs/contracts/descriptor.d.ts +5 -0
  6. package/dist/safe-fs/contracts/filesystem.d.ts +12 -0
  7. package/dist/safe-fs/contracts/io.d.ts +3 -1
  8. package/dist/safe-fs/contracts/io.js +48 -16
  9. package/dist/safe-fs/core.d.ts +2 -1
  10. package/dist/safe-fs/core.js +1 -1
  11. package/dist/safe-fs/fs/capabilities.js +6 -3
  12. package/dist/safe-fs/fs/descriptor.js +9 -2
  13. package/dist/safe-fs/fs/devices/index.d.ts +1 -0
  14. package/dist/safe-fs/fs/devices/index.js +24 -6
  15. package/dist/safe-fs/fs/memory/index.d.ts +1 -0
  16. package/dist/safe-fs/fs/memory/index.js +75 -3
  17. package/dist/safe-fs/fs/memory/limits.js +6 -4
  18. package/dist/safe-fs/fs/mount/index.d.ts +4 -0
  19. package/dist/safe-fs/fs/mount/index.js +74 -11
  20. package/dist/safe-fs/fs/object-publication/index.js +6 -6
  21. package/dist/safe-fs/fs/overlay/index.d.ts +4 -2
  22. package/dist/safe-fs/fs/overlay/index.js +92 -36
  23. package/dist/safe-fs/fs/quota/index.js +18 -16
  24. package/dist/safe-fs/fs/readonly/index.js +5 -3
  25. package/dist/safe-fs/fs/real/index.d.ts +21 -1
  26. package/dist/safe-fs/fs/real/index.js +266 -22
  27. package/dist/safe-fs/fs/s3/filesystem.d.ts +8 -0
  28. package/dist/safe-fs/fs/s3/filesystem.js +58 -12
  29. package/dist/safe-fs/fs/s3/http/request.d.ts +1 -1
  30. package/dist/safe-fs/fs/s3/http/request.js +1 -1
  31. package/dist/safe-fs/fs/s3/http/transport.js +8 -6
  32. package/dist/safe-fs/fs/s3/http/xml.d.ts +4 -1
  33. package/dist/safe-fs/fs/s3/http/xml.js +6 -2
  34. package/dist/safe-fs/fs/s3/namespace.js +12 -10
  35. package/dist/safe-fs/fs/scoped.d.ts +1 -0
  36. package/dist/safe-fs/fs/scoped.js +54 -35
  37. package/dist/safe-fs/fs/webdav/webdav.d.ts +4 -1
  38. package/dist/safe-fs/fs/webdav/webdav.js +30 -22
  39. package/dist/safe-fs/node/filesystem.d.ts +4 -1
  40. package/dist/safe-fs/node/filesystem.js +7 -1
  41. package/dist/safe-fs/node/host.d.ts +3 -0
  42. package/dist/safe-fs/node/host.js +78 -0
  43. package/dist/safe-fs/node/index.d.ts +2 -1
  44. package/dist/safe-fs/node/index.js +2 -1
  45. package/dist/safe-fs/platform/browser.d.ts +1 -0
  46. package/dist/safe-fs/platform/browser.js +1 -0
  47. package/dist/safe-fs/platform/node.d.ts +1 -0
  48. package/dist/safe-fs/platform/node.js +1 -0
  49. package/dist/safe-fs/python/emscripten.d.ts +1 -0
  50. package/dist/safe-fs/python/emscripten.js +5 -5
  51. package/dist/safe-fs/python/filesystem.js +5 -5
  52. package/dist/safe-fs/python/native.d.ts +1 -0
  53. package/dist/safe-fs/python/native.js +3 -2
  54. package/dist/safe-fs/xml.js +32 -21
  55. 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 > 32_768 || stack.length >= 32)
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, maximum) {
6
- if (!Number.isSafeInteger(value) || value < 1 || value > maximum)
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 ?? 1048576, 67108864);
17
- const maxEntries = limit(options.maxEntries ?? 1024, 65536);
18
- const maxManifestBytes = limit(options.maxManifestBytes ?? 8388608, 268435456);
19
- const maxAttempts = limit(options.maxAttempts ?? 8, 64);
20
- const descriptorOptions = { ...options, maxFileBytes: options.maxFileBytes ?? maxBytes, maxOpenFiles: options.maxOpenFiles ?? 16 };
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], key === 'chunkBytes' ? 1048576 : Number.MAX_SAFE_INTEGER);
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 === "open"
233
- ? async (path, options) => {
234
- admit(options);
235
- const selected = resizeOptions(options);
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
- assertOpen(options);
253
- return wrapResizeHandle(await openRetainedResizeFile(original, path, resizeOptions(options)));
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 === "capabilitiesFor"
256
- ? async (...args) => ownedMutationCapabilities(original, retainedResizeCapabilities(original, await dispatch(...args)))
257
- : property === "openReadFile"
258
- ? async (...args) => wrapHandle(await dispatch(...args))
259
- : property === "readStream"
260
- ? (...args) => wrapStream(dispatch(...args), args[1])
261
- : operations.has(property) && property !== "canonicalizeMissingTarget"
262
- ? async (...args) => dispatch(...args)
263
- : dispatch;
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 === undefined ? 256 : options.maxOperations;
277
- if (!Number.isSafeInteger(maximum) || maximum < 0 || maximum > 4096)
278
- throw new RangeError("cleanup maxOperations must be an integer between 0 and 4096");
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; defaults to 30,000 ms. */
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 = positive(options.maxResponseBytes ?? 64 * 1024 * 1024, "maxResponseBytes");
220
- this.maxXmlBytes = positive(options.maxXmlBytes ?? 2 * 1024 * 1024, "maxXmlBytes");
221
- this.maxEntries = positive(options.maxEntries ?? 10_000, "maxEntries");
222
- this.timeoutMs = positive(options.timeoutMs ?? 30_000, "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 chunks = [];
427
+ const expected = this.responseLength(response, limit);
428
+ let data = new Uint8Array(expected ?? 0);
428
429
  let size = 0;
429
- for await (const chunk of this.bodyChunks(response, limit, signal)) {
430
- chunks.push(chunk);
431
- size += chunk.byteLength;
432
- }
433
- const data = new Uint8Array(size);
434
- let offset = 0;
435
- for (const chunk of chunks) {
436
- data.set(chunk, offset);
437
- offset += chunk.byteLength;
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
- return data;
441
+ // A view avoids a second full allocation when the length was unknown.
442
+ return data.subarray(0, size);
440
443
  }
441
- async *bodyChunks(response, limit, signal) {
444
+ responseLength(response, limit) {
442
445
  const length = response.headers.get("content-length");
443
- if (length !== null && (!/^\d+$/.test(length) || !Number.isSafeInteger(Number(length)))) {
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, positive(options.maxBytes ?? this.maxResponseBytes, "maxBytes", true));
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
- return new FileSystemBridge(fs, options, {
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,3 @@
1
+ import type { FileSystem } from "../contracts/filesystem.js";
2
+ /** Trusted host access with native paths and symlink semantics; this is not a rooted sandbox. */
3
+ export declare function createHostFileSystem(): FileSystem;
@@ -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;
@@ -1,5 +1,6 @@
1
1
  import { FsError } from "../contracts/errors.js";
2
2
  export const platform = Object.freeze({
3
+ maxCollectionBytes: 32 * 1024 * 1024,
3
4
  errno(_code) {
4
5
  return undefined;
5
6
  },
@@ -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 ?? 256;
28
- this.#directoryLimit = options.maxDirectoryEntries ?? 65536;
29
- for (const limit of [this.#transfer, this.#limit, this.#directoryLimit])
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 openOptions = translatePythonOpenFlags(third, fourth ? view().getUint32(fourth, true) : 0o666);
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');