@poe-platform/safe-fs 0.1.630 → 0.1.631

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 (49) hide show
  1. package/dist/safe-fs/bridge/stats.js +1 -1
  2. package/dist/safe-fs/contracts/descriptor.d.ts +31 -0
  3. package/dist/safe-fs/contracts/descriptor.js +1 -0
  4. package/dist/safe-fs/contracts/errors.d.ts +1 -1
  5. package/dist/safe-fs/contracts/errors.js +1 -1
  6. package/dist/safe-fs/contracts/filesystem.d.ts +14 -1
  7. package/dist/safe-fs/core.d.ts +3 -0
  8. package/dist/safe-fs/core.js +2 -0
  9. package/dist/safe-fs/fs/capabilities.d.ts +1 -1
  10. package/dist/safe-fs/fs/capabilities.js +5 -3
  11. package/dist/safe-fs/fs/descriptor.d.ts +21 -0
  12. package/dist/safe-fs/fs/descriptor.js +244 -0
  13. package/dist/safe-fs/fs/devices/index.d.ts +5 -1
  14. package/dist/safe-fs/fs/devices/index.js +53 -4
  15. package/dist/safe-fs/fs/memory/index.d.ts +8 -1
  16. package/dist/safe-fs/fs/memory/index.js +125 -8
  17. package/dist/safe-fs/fs/memory/limits.d.ts +1 -0
  18. package/dist/safe-fs/fs/memory/limits.js +7 -1
  19. package/dist/safe-fs/fs/mount/index.d.ts +5 -1
  20. package/dist/safe-fs/fs/mount/index.js +69 -6
  21. package/dist/safe-fs/fs/overlay/index.d.ts +2 -0
  22. package/dist/safe-fs/fs/overlay/index.js +9 -1
  23. package/dist/safe-fs/fs/quota/index.js +5 -1
  24. package/dist/safe-fs/fs/readonly/index.d.ts +2 -0
  25. package/dist/safe-fs/fs/readonly/index.js +35 -2
  26. package/dist/safe-fs/fs/real/index.d.ts +3 -0
  27. package/dist/safe-fs/fs/real/index.js +73 -3
  28. package/dist/safe-fs/fs/s3/filesystem.d.ts +3 -0
  29. package/dist/safe-fs/fs/s3/filesystem.js +5 -0
  30. package/dist/safe-fs/fs/scoped.js +68 -17
  31. package/dist/safe-fs/fs/webdav/webdav.d.ts +3 -0
  32. package/dist/safe-fs/fs/webdav/webdav.js +5 -0
  33. package/dist/safe-fs/index.d.ts +3 -0
  34. package/dist/safe-fs/index.js +2 -0
  35. package/dist/safe-fs/python/emscripten.d.ts +14 -0
  36. package/dist/safe-fs/python/emscripten.js +354 -0
  37. package/dist/safe-fs/python/filesystem.d.ts +51 -0
  38. package/dist/safe-fs/python/filesystem.js +216 -0
  39. package/dist/safe-fs/python/flags.d.ts +3 -0
  40. package/dist/safe-fs/python/flags.js +14 -0
  41. package/dist/safe-fs/python/index.d.ts +7 -0
  42. package/dist/safe-fs/python/index.js +4 -0
  43. package/dist/safe-fs/python/request.d.ts +2 -0
  44. package/dist/safe-fs/python/request.js +61 -0
  45. package/dist/safe-fs/python/stat.d.ts +8 -0
  46. package/dist/safe-fs/python/stat.js +31 -0
  47. package/dist/safe-fs/xml.d.ts +3 -0
  48. package/dist/safe-fs/xml.js +41 -19
  49. package/package.json +5 -1
@@ -0,0 +1,354 @@
1
+ import { translatePythonOpenFlags } from './flags.js';
2
+ export function mountPythonFileSystem(FS, options) {
3
+ const { request: rpc, cwd, runtimeMount, maxTransferBytes, errno, synchronizationFlags } = options;
4
+ const canonicalStat = Symbol('canonical Python stat');
5
+ const runtimeDevices = createPythonRuntimeStatMapper();
6
+ const mapRuntimeStat = (stat) => {
7
+ try {
8
+ return runtimeDevices(stat);
9
+ }
10
+ catch {
11
+ throw new FS.ErrnoError(errno.EOVERFLOW);
12
+ }
13
+ };
14
+ const originalWriteStat = options.runtimeModule.SYSCALLS.writeStat;
15
+ options.runtimeModule.SYSCALLS.writeStat = (pointer, stat) => {
16
+ if (!stat[canonicalStat]) {
17
+ try {
18
+ return originalWriteStat(pointer, mapRuntimeStat(stat));
19
+ }
20
+ catch {
21
+ throw new FS.ErrnoError(errno.EOVERFLOW);
22
+ }
23
+ }
24
+ try {
25
+ return writePythonStat(options.runtimeModule.HEAPU8, pointer, stat);
26
+ }
27
+ catch {
28
+ throw new FS.ErrnoError(errno.EOVERFLOW);
29
+ }
30
+ };
31
+ function path(node) { return node.parent === node ? "/" : `${path(node.parent) === '/' ? '' : path(node.parent)}/${node.name}`; }
32
+ function attr(stat) {
33
+ return { [canonicalStat]: true, dev: stat.dev, ino: stat.ino, mode: stat.mode | (stat.type === 'directory' ? 16384 : stat.type === 'symlink' ? 40960 : stat.type === 'character' ? 8192 : 32768), nlink: stat.nlink, uid: stat.uid, gid: stat.gid, rdev: stat.type === 'character' ? undefined : 0, size: stat.size, atime: new Date(stat.atimeMs), mtime: new Date(stat.mtimeMs), ctime: new Date(stat.ctimeMs), atimeMs: stat.atimeMs, mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs, blksize: stat.ioBlockSize ?? stat.preferredIoBlockSize, blocks: stat.allocatedBytes === undefined ? undefined : Math.ceil(stat.allocatedBytes / 512) };
34
+ }
35
+ function node(parent, name, stat) {
36
+ const value = FS.createNode(parent, name, attr(stat).mode, 0);
37
+ value.node_ops = nodeOps;
38
+ value.stream_ops = streamOps;
39
+ return value;
40
+ }
41
+ let bootstrapMountNode;
42
+ const nodeOps = {
43
+ getattr: (n) => attr(n === bootstrapMountNode ? { type: 'directory', mode: 365, size: 0, atimeMs: 0, mtimeMs: 0, ctimeMs: 0 } : rpc('lstat', path(n))),
44
+ setattr(n, attributes) {
45
+ if (attributes.size !== undefined)
46
+ rpc('truncate', path(n), attributes.size);
47
+ if (attributes.mode !== undefined)
48
+ rpc('chmod', path(n), attributes.mode & 4095);
49
+ if (attributes.atime !== undefined || attributes.mtime !== undefined) {
50
+ const previous = attributes.atime === undefined || attributes.mtime === undefined ? rpc('stat', path(n)) : undefined;
51
+ rpc('utimes', path(n), attributes.atime ?? previous.atimeMs, attributes.mtime ?? previous.mtimeMs);
52
+ }
53
+ },
54
+ lookup(parent, name) {
55
+ if (parent.parent === parent && name === runtimeMount.slice(1))
56
+ return bootstrapMountNode ??= node(parent, name, { type: 'directory', mode: 365, size: 0, atimeMs: 0, mtimeMs: 0, ctimeMs: 0 });
57
+ return node(parent, name, rpc('lstat', `${path(parent)}/${name}`));
58
+ },
59
+ mknod(parent, name, mode) {
60
+ const target = `${path(parent)}/${name}`;
61
+ if (FS.isDir(mode))
62
+ rpc('mkdir', target, { mode: mode & 4095 });
63
+ else {
64
+ const handle = rpc('open', target, { access: 'write', creation: 'exclusive', mode: mode & 4095 });
65
+ rpc('close', handle);
66
+ }
67
+ return node(parent, name, rpc('lstat', target));
68
+ },
69
+ rename(n, parent, name) { rpc('rename', path(n), `${path(parent)}/${name}`); n.name = name; n.parent = parent; },
70
+ unlink: (parent, name) => rpc('rm', `${path(parent)}/${name}`),
71
+ rmdir: (parent, name) => rpc('rmdir', `${path(parent)}/${name}`),
72
+ readdir: (n) => ['.', '..', ...rpc('readdir', path(n)).map((entry) => entry.name)],
73
+ readlink: (n) => rpc('readlink', path(n)),
74
+ symlink(parent, name, target) { rpc('symlink', target, `${path(parent)}/${name}`); return node(parent, name, rpc('lstat', `${path(parent)}/${name}`)); },
75
+ };
76
+ const streamOps = {
77
+ getattr: (stream) => attr(stream.handle ? rpc('fstat', stream.handle) : rpc('stat', path(stream.node))),
78
+ setattr(stream, attributes) {
79
+ if (Object.keys(attributes).some(key => key !== 'size' && key !== 'ctime' && !(key === 'timestamp' && attributes.size !== undefined)))
80
+ throw new FS.ErrnoError(errno.ENOTSUP);
81
+ if (attributes.size !== undefined)
82
+ rpc('ftruncate', stream.handle, attributes.size);
83
+ },
84
+ open(stream) {
85
+ if (FS.isDir(stream.node.mode))
86
+ return;
87
+ stream.retained = { references: 1 };
88
+ stream.handle = rpc('open', path(stream.node), { access: (stream.flags & 3) === 0 ? 'read' : (stream.flags & 3) === 1 ? 'write' : 'readwrite', creation: 'never', append: Boolean(stream.flags & 1024) });
89
+ },
90
+ dup: (stream) => { if (stream.retained)
91
+ stream.retained.references++; },
92
+ close: (stream) => { if (stream.handle && --stream.retained.references === 0)
93
+ rpc('close', stream.handle); },
94
+ fsync: (stream) => { rpc('sync', stream.handle, false); return 0; },
95
+ read(stream, buffer, offset, length, position) { const data = rpc('read', stream.handle, Math.min(length, maxTransferBytes), stream.seekable ? position : null); buffer.set(data, offset); return data.length; },
96
+ write: (stream, buffer, offset, length, position) => rpc('write', stream.handle, Uint8Array.from(buffer.subarray(offset, offset + Math.min(length, maxTransferBytes))), stream.pythonExplicitPosition ?? (stream.flags & 1024 || !stream.seekable ? null : position)),
97
+ llseek(stream, offset, whence) { if (![0, 1, 2].includes(whence) || !Number.isSafeInteger(offset))
98
+ throw new FS.ErrnoError(errno.EINVAL); const position = whence === 0 ? offset : whence === 1 ? stream.position + offset : rpc('fstat', stream.handle).size + offset; if (!Number.isSafeInteger(position) || position < 0)
99
+ throw new FS.ErrnoError(28); return position; },
100
+ };
101
+ const originalLookupNode = FS.lookupNode.bind(FS);
102
+ FS.lookupNode = (parent, name) => {
103
+ const cached = originalLookupNode(parent, name);
104
+ return parent.node_ops === nodeOps && !cached.mounted ? nodeOps.lookup(parent, name) : cached;
105
+ };
106
+ const bootstrapRoot = FS.root;
107
+ FS.root = null;
108
+ FS.mount({ mount: () => node(null, '/', rpc('stat', '/')) }, {}, '/');
109
+ FS.mount({ mount: () => bootstrapRoot }, {}, runtimeMount);
110
+ FS.chdir(rpc('realpath', cwd));
111
+ // Intercept before Emscripten decomposes atomic exclusive/truncating open.
112
+ const originalOpen = FS.open.bind(FS);
113
+ FS.open = function (inputPath, flags, mode = 438) {
114
+ if (typeof flags === 'string') {
115
+ const modes = { r: 0, 'r+': 2, w: 577, 'w+': 578, a: 1089, 'a+': 1090 };
116
+ const numeric = modes[flags];
117
+ if (numeric === undefined)
118
+ throw new FS.ErrnoError(errno.EINVAL);
119
+ flags = numeric;
120
+ }
121
+ if (typeof inputPath !== 'string') {
122
+ if ((flags & 3) !== 0 || (flags & (64 | 512)))
123
+ throw new FS.ErrnoError(errno.EROFS);
124
+ return originalOpen(inputPath, flags, mode);
125
+ }
126
+ if (inputPath.length === 0)
127
+ throw new FS.ErrnoError(errno.ENOENT);
128
+ const absolute = inputPath.startsWith('/') ? inputPath : `${FS.cwd()}/${inputPath}`;
129
+ if (absolute === runtimeMount || absolute.startsWith(runtimeMount + '/')) {
130
+ if (absolute.split('/').includes('..'))
131
+ throw new FS.ErrnoError(errno.ENOTSUP);
132
+ if (typeof flags !== 'number' || (flags & 3) !== 0 || (flags & (64 | 512)))
133
+ throw new FS.ErrnoError(errno.EROFS);
134
+ return originalOpen(inputPath, flags, mode);
135
+ }
136
+ if (typeof flags !== 'number')
137
+ return originalOpen(inputPath, flags, mode);
138
+ if (flags & synchronizationFlags)
139
+ throw new FS.ErrnoError(errno.ENOTSUP);
140
+ // Atomic exclusive creation already refuses symlinks and all existing entries.
141
+ const exclusive = (flags & 192) === 192;
142
+ if ((flags & 131072) && !exclusive)
143
+ throw new FS.ErrnoError(138);
144
+ if (flags & 65536)
145
+ throw new FS.ErrnoError(54);
146
+ let openOptions;
147
+ try {
148
+ openOptions = translatePythonOpenFlags(flags, mode);
149
+ }
150
+ catch (error) {
151
+ throw new FS.ErrnoError(errno[error.code] ?? errno.EIO);
152
+ }
153
+ const handle = rpc('open', absolute, openOptions);
154
+ try {
155
+ // Bind the stream to the acquired identity, without re-opening its pathname.
156
+ // A concurrent rename or unlink after acquisition must not invalidate open().
157
+ const capabilities = rpc('descriptorCapabilities', handle);
158
+ if ((flags & 1024) && !capabilities.position)
159
+ throw new FS.ErrnoError(errno.ENOTSUP);
160
+ const seekable = ((flags & 3) === 1 || capabilities.positionedRead) && ((flags & 3) === 0 || ((flags & 1024) ? capabilities.position : capabilities.positionedWrite));
161
+ const retainedStat = rpc('fstat', handle);
162
+ if (retainedStat.type === 'directory')
163
+ throw new FS.ErrnoError(errno.ENOTSUP);
164
+ const retainedNode = node(FS.root, absolute.slice(1), retainedStat);
165
+ return FS.createStream({ node: retainedNode, path: absolute, flags: flags & ~(128 | 512), seekable, position: 0, stream_ops: streamOps, ungotten: [], error: false, handle, pythonCapabilities: capabilities, retained: { references: 1 } });
166
+ }
167
+ catch (error) {
168
+ rpc('close', handle);
169
+ throw error;
170
+ }
171
+ };
172
+ // Pass application operands intact to the authoritative resolver. Emscripten's
173
+ // lexical dot-segment normalization must not run before canonical symlink traversal.
174
+ const absolutePath = (target) => target.length === 0 || target.startsWith('/') ? target : `${FS.cwd()}/${target}`;
175
+ const bootstrapPath = (target) => {
176
+ const absolute = absolutePath(target);
177
+ const bootstrap = absolute === runtimeMount || absolute.startsWith(runtimeMount + '/');
178
+ if (bootstrap && absolute.split('/').includes('..'))
179
+ throw new FS.ErrnoError(errno.ENOTSUP);
180
+ return bootstrap;
181
+ };
182
+ for (const operation of ['stat', 'lstat']) {
183
+ const original = FS[operation].bind(FS);
184
+ FS[operation] = (target, ...args) => bootstrapPath(target)
185
+ ? mapRuntimeStat(original(target, ...args)) : attr(rpc(operation, absolutePath(target)));
186
+ }
187
+ const originalFstat = FS.fstat.bind(FS);
188
+ FS.fstat = (fd) => {
189
+ const value = originalFstat(fd);
190
+ return value[canonicalStat] ? value : mapRuntimeStat(value);
191
+ };
192
+ for (const [operation, canonical] of [['unlink', 'rm'], ['rmdir', 'rmdir'], ['chmod', 'chmod'], ['truncate', 'truncate'], ['utime', 'utimes']]) {
193
+ const original = FS[operation].bind(FS);
194
+ FS[operation] = (target, ...args) => bootstrapPath(target)
195
+ ? original(target, ...args) : rpc(canonical, absolutePath(target), ...args);
196
+ }
197
+ const originalMkdir = FS.mkdir.bind(FS);
198
+ FS.mkdir = (target, mode = 0o777) => {
199
+ if (bootstrapPath(target))
200
+ return originalMkdir(target, mode);
201
+ const absolute = absolutePath(target);
202
+ rpc('mkdir', absolute, { mode });
203
+ return node(FS.root, absolute.slice(1), rpc('lstat', absolute));
204
+ };
205
+ const originalRename = FS.rename.bind(FS);
206
+ FS.rename = (source, destination) => bootstrapPath(source) || bootstrapPath(destination)
207
+ ? originalRename(source, destination) : rpc('rename', absolutePath(source), absolutePath(destination));
208
+ const originalChdir = FS.chdir.bind(FS);
209
+ FS.chdir = (target) => bootstrapPath(target) ? originalChdir(target) : originalChdir(rpc('realpath', absolutePath(target)));
210
+ const runtimeNode = (node) => {
211
+ const seen = new Set();
212
+ while (node && !seen.has(node)) {
213
+ if (node === bootstrapRoot || node.mount?.mountpoint === runtimeMount)
214
+ return true;
215
+ seen.add(node);
216
+ node = node.parent;
217
+ }
218
+ return false;
219
+ };
220
+ const assertWritablePath = (target) => {
221
+ if (bootstrapPath(target))
222
+ throw new FS.ErrnoError(errno.EROFS);
223
+ };
224
+ const originalSymlink = FS.symlink.bind(FS);
225
+ FS.symlink = (source, destination) => bootstrapPath(destination)
226
+ ? originalSymlink(source, destination) : rpc('symlink', source, absolutePath(destination));
227
+ const originalReaddir = FS.readdir.bind(FS);
228
+ FS.readdir = (target) => bootstrapPath(target) ? originalReaddir(target)
229
+ : ['.', '..', ...rpc('readdir', absolutePath(target)).map((entry) => entry.name)];
230
+ const originalReadlink = FS.readlink.bind(FS);
231
+ FS.readlink = (target) => bootstrapPath(target) ? originalReadlink(target) : rpc('readlink', absolutePath(target));
232
+ for (const operation of ['chown', 'lchown', 'lchmod']) {
233
+ FS[operation] = (target) => { assertWritablePath(target); throw new FS.ErrnoError(errno.ENOTSUP); };
234
+ }
235
+ const originalMknod = FS.mknod.bind(FS);
236
+ FS.mknod = (target, mode, device) => {
237
+ if (bootstrapPath(target))
238
+ return originalMknod(target, mode, device);
239
+ if (!FS.isFile(mode))
240
+ throw new FS.ErrnoError(errno.ENOTSUP);
241
+ const handle = rpc('open', absolutePath(target), { access: 'write', creation: 'exclusive', mode: mode & 4095 });
242
+ rpc('close', handle);
243
+ return node(FS.root, absolutePath(target).slice(1), rpc('lstat', absolutePath(target)));
244
+ };
245
+ for (const operation of ['mkdir', 'mknod', 'unlink', 'rmdir', 'chmod', 'lchmod', 'chown', 'lchown', 'truncate', 'utime']) {
246
+ if (typeof FS[operation] !== 'function')
247
+ continue;
248
+ const original = FS[operation].bind(FS);
249
+ FS[operation] = (target, ...args) => { assertWritablePath(target); return original(target, ...args); };
250
+ }
251
+ for (const operation of ['rename', 'symlink']) {
252
+ const original = FS[operation].bind(FS);
253
+ FS[operation] = (source, destination, ...args) => {
254
+ if (operation === 'rename')
255
+ assertWritablePath(source);
256
+ assertWritablePath(destination);
257
+ return original(source, destination, ...args);
258
+ };
259
+ }
260
+ for (const operation of ['fchmod', 'fchown', 'ftruncate']) {
261
+ if (typeof FS[operation] !== 'function')
262
+ continue;
263
+ const original = FS[operation].bind(FS);
264
+ FS[operation] = (fd, ...args) => {
265
+ if (runtimeNode(FS.getStreamChecked(fd).node))
266
+ throw new FS.ErrnoError(errno.EROFS);
267
+ return original(fd, ...args);
268
+ };
269
+ }
270
+ const originalWrite = FS.write.bind(FS);
271
+ FS.write = (stream, ...args) => {
272
+ if (stream.fd > 2 && runtimeNode(stream.node))
273
+ throw new FS.ErrnoError(errno.EROFS);
274
+ const explicit = typeof args[3] === 'number';
275
+ if (stream.handle && (stream.flags & 1024) && explicit && !stream.pythonCapabilities.positionedAppendWrite)
276
+ throw new FS.ErrnoError(errno.EINVAL);
277
+ const previousPosition = stream.position;
278
+ stream.pythonExplicitPosition = explicit ? args[3] : undefined;
279
+ let written;
280
+ try {
281
+ written = originalWrite(stream, ...args);
282
+ }
283
+ finally {
284
+ stream.pythonExplicitPosition = undefined;
285
+ if (explicit)
286
+ stream.position = previousPosition;
287
+ }
288
+ if (stream.handle && (stream.flags & 1024) && args[3] === undefined)
289
+ stream.position = rpc('position', stream.handle);
290
+ return written;
291
+ };
292
+ }
293
+ /** Encode the pinned wasm32 musl stat layout without truncating canonical values. */
294
+ export function writePythonStat(heap, pointer, stat) {
295
+ const view = new DataView(heap.buffer, heap.byteOffset, heap.byteLength);
296
+ const integer = (key, max) => {
297
+ const value = stat[key];
298
+ if (!Number.isSafeInteger(value) || value < 0 || value > max)
299
+ throw new Error('EOVERFLOW');
300
+ return value;
301
+ };
302
+ const values = {
303
+ dev: integer('dev', 0xffffffff), mode: integer('mode', 0xffffffff),
304
+ nlink: integer('nlink', 0xffffffff), uid: integer('uid', 0xffffffff),
305
+ gid: integer('gid', 0xffffffff), rdev: integer('rdev', 0xffffffff),
306
+ size: integer('size', Number.MAX_SAFE_INTEGER), ino: integer('ino', Number.MAX_SAFE_INTEGER),
307
+ blksize: integer('blksize', 0x7fffffff), blocks: integer('blocks', 0x7fffffff),
308
+ };
309
+ const times = ['atime', 'mtime', 'ctime'].map(key => {
310
+ const time = stat[key + 'Ms'] ?? stat[key]?.getTime();
311
+ if (!Number.isFinite(time))
312
+ throw new Error('EOVERFLOW');
313
+ let seconds = Math.floor(time / 1000);
314
+ let nanoseconds = Math.round((time - seconds * 1000) * 1000000);
315
+ if (nanoseconds === 1000000000) {
316
+ seconds++;
317
+ nanoseconds = 0;
318
+ }
319
+ if (!Number.isSafeInteger(seconds) || nanoseconds < 0 || nanoseconds >= 1000000000)
320
+ throw new Error('EOVERFLOW');
321
+ return { seconds, nanoseconds };
322
+ });
323
+ if (!Number.isSafeInteger(pointer) || pointer < 0 || pointer + 96 > heap.byteLength)
324
+ throw new Error('EOVERFLOW');
325
+ for (const [index, key] of ['dev', 'mode', 'nlink', 'uid', 'gid', 'rdev'].entries())
326
+ view.setUint32(pointer + index * 4, values[key], true);
327
+ view.setBigInt64(pointer + 24, BigInt(values.size), true);
328
+ view.setInt32(pointer + 32, values.blksize, true);
329
+ view.setInt32(pointer + 36, values.blocks, true);
330
+ times.forEach((time, index) => { view.setBigInt64(pointer + 40 + index * 16, BigInt(time.seconds), true); view.setUint32(pointer + 48 + index * 16, time.nanoseconds, true); });
331
+ view.setBigInt64(pointer + 88, BigInt(values.ino), true);
332
+ return 0;
333
+ }
334
+ /** Runtime storage and application storage occupy disjoint guest device ranges. */
335
+ export function createPythonRuntimeStatMapper(maxDevices = 1024) {
336
+ if (!Number.isSafeInteger(maxDevices) || maxDevices < 1 || maxDevices > 1024)
337
+ throw new RangeError('Invalid runtime device limit');
338
+ const mapped = Symbol('mapped Python runtime device');
339
+ const devices = new Map();
340
+ return stat => {
341
+ if (stat[mapped])
342
+ return stat;
343
+ if (!Number.isSafeInteger(stat.dev) || stat.dev < 0 || stat.dev > 0xffffffff)
344
+ throw new Error('EOVERFLOW');
345
+ let device = devices.get(stat.dev);
346
+ if (device === undefined) {
347
+ if (devices.size >= maxDevices)
348
+ throw new Error('EOVERFLOW');
349
+ device = 0x80000000 + devices.size;
350
+ devices.set(stat.dev, device);
351
+ }
352
+ return { ...stat, dev: device, [mapped]: true };
353
+ };
354
+ }
@@ -0,0 +1,51 @@
1
+ import type { FileDescriptor, FileSystem, OpenFileOptions, MkdirOptions } from "../contracts/filesystem.js";
2
+ export type PythonFsRequest = {
3
+ readonly op: "open";
4
+ readonly args: readonly [string, OpenFileOptions];
5
+ } | {
6
+ readonly op: "read";
7
+ readonly args: readonly [number, number, number | null];
8
+ } | {
9
+ readonly op: "write";
10
+ readonly args: readonly [number, Uint8Array, number | null];
11
+ } | {
12
+ readonly op: "fstat" | "close" | "position" | "descriptorCapabilities";
13
+ readonly args: readonly [number];
14
+ } | {
15
+ readonly op: "ftruncate";
16
+ readonly args: readonly [number, number];
17
+ } | {
18
+ readonly op: "sync";
19
+ readonly args: readonly [number, boolean];
20
+ } | {
21
+ readonly op: "stat" | "lstat" | "readdir" | "realpath" | "readlink" | "rm" | "rmdir";
22
+ readonly args: readonly [string];
23
+ } | {
24
+ readonly op: "rename" | "symlink" | "link";
25
+ readonly args: readonly [string, string];
26
+ } | {
27
+ readonly op: "mkdir";
28
+ readonly args: readonly [string, MkdirOptions?];
29
+ } | {
30
+ readonly op: "chmod" | "truncate" | "access";
31
+ readonly args: readonly [string, number];
32
+ } | {
33
+ readonly op: "utimes";
34
+ readonly args: readonly [string, number, number];
35
+ };
36
+ export interface PythonFileSystemOptions {
37
+ readonly cwd: string;
38
+ readonly signal?: AbortSignal;
39
+ readonly maxTransferBytes?: number;
40
+ readonly maxOpenFiles?: number;
41
+ readonly maxDirectoryEntries?: number;
42
+ /** Caller-owned acquisition wrapper, e.g. shell descriptor quota enrollment. */
43
+ readonly open?: (path: string, options: OpenFileOptions) => Promise<FileDescriptor>;
44
+ }
45
+ /** Async half of a worker RPC filesystem. Never run this on an Atomics.wait-blocked interpreter thread. */
46
+ export declare class PythonFileSystem {
47
+ #private;
48
+ constructor(fs: FileSystem, options: PythonFileSystemOptions);
49
+ dispatch(value: unknown): Promise<unknown>;
50
+ close(): Promise<void>;
51
+ }
@@ -0,0 +1,216 @@
1
+ import { parsePythonFsRequest } from "./request.js";
2
+ import { composeAbortSignals } from "../contracts/abort.js";
3
+ import { FsError } from "../contracts/errors.js";
4
+ import { validatePath } from "../contracts/virtual-path.js";
5
+ /** Async half of a worker RPC filesystem. Never run this on an Atomics.wait-blocked interpreter thread. */
6
+ export class PythonFileSystem {
7
+ #fs;
8
+ #cwd;
9
+ #transfer;
10
+ #limit;
11
+ #directoryLimit;
12
+ #open;
13
+ #abort = new AbortController();
14
+ #scope;
15
+ #handles = new Map();
16
+ #pending = new Set();
17
+ #acquiring = 0;
18
+ #next = 1;
19
+ #closing;
20
+ constructor(fs, options) {
21
+ this.#fs = fs;
22
+ validatePath(options.cwd);
23
+ if (!options.cwd.startsWith("/"))
24
+ throw new FsError("EINVAL", { syscall: "python filesystem", path: options.cwd, message: "cwd must be absolute" });
25
+ this.#cwd = options.cwd;
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)
31
+ throw new FsError("EINVAL", { syscall: "python filesystem" });
32
+ this.#open = options.open;
33
+ this.#scope = composeAbortSignals([...(options.signal === undefined ? [] : [options.signal]), this.#abort.signal]);
34
+ }
35
+ dispatch(value) {
36
+ if (this.#closing)
37
+ return Promise.reject(new FsError("EBADF"));
38
+ let request;
39
+ try {
40
+ this.#scope.signal.throwIfAborted();
41
+ request = parsePythonFsRequest(value, this.#transfer);
42
+ }
43
+ catch (error) {
44
+ return Promise.reject(error);
45
+ }
46
+ const operation = this.#execute(request);
47
+ this.#pending.add(operation);
48
+ void operation.then(() => this.#pending.delete(operation), () => this.#pending.delete(operation));
49
+ return operation;
50
+ }
51
+ #path(path) {
52
+ validatePath(path);
53
+ if (path.length === 0)
54
+ throw new FsError("ENOENT", { syscall: "resolve", path });
55
+ return path.startsWith("/") ? path : `${this.#cwd === "/" ? "" : this.#cwd}/${path}`;
56
+ }
57
+ #handle(id) {
58
+ const handle = this.#handles.get(id);
59
+ if (!handle)
60
+ throw new FsError("EBADF");
61
+ return handle;
62
+ }
63
+ #size(size) {
64
+ if (!Number.isSafeInteger(size) || size < 0)
65
+ throw new FsError("EINVAL");
66
+ if (size > this.#transfer)
67
+ throw new FsError("EFBIG", { message: "Python filesystem transfer exceeds configured limit" });
68
+ }
69
+ async #execute(request) {
70
+ const signal = this.#scope.signal;
71
+ signal.throwIfAborted();
72
+ const options = { signal };
73
+ const fs = this.#fs;
74
+ switch (request.op) {
75
+ case "open": {
76
+ const [path, supplied] = request.args;
77
+ if (this.#handles.size + this.#acquiring >= this.#limit || !Number.isSafeInteger(this.#next))
78
+ throw new FsError("EMFILE", { syscall: "open" });
79
+ if (!this.#open && !fs.open)
80
+ throw new FsError("ENOTSUP", { syscall: "open", path });
81
+ this.#acquiring++;
82
+ let handle;
83
+ try {
84
+ // A following capability query cannot preflight exclusive final-entry acquisition.
85
+ // The canonical open remains authoritative for selected-path policy and atomic refusal.
86
+ const capabilities = supplied.creation !== "exclusive" && fs.capabilitiesFor ? await fs.capabilitiesFor(this.#path(path), { signal, ...(supplied.creation === "ifMissing" ? { create: true } : {}) }) : fs.capabilities;
87
+ if (capabilities.readOnly === true && (supplied.access === "write" || supplied.access === "readwrite" || supplied.creation === "ifMissing" || supplied.creation === "exclusive" || supplied.truncate === true || supplied.append === true))
88
+ throw new FsError("EROFS", { syscall: "open", path });
89
+ if (capabilities.open === false)
90
+ throw new FsError("ENOTSUP", { syscall: "open", path });
91
+ signal.throwIfAborted();
92
+ const open = this.#open ?? fs.open.bind(fs);
93
+ handle = await open(this.#path(path), { ...supplied, signal });
94
+ signal.throwIfAborted();
95
+ const id = this.#next++;
96
+ this.#handles.set(id, handle);
97
+ return id;
98
+ }
99
+ catch (error) {
100
+ if (handle)
101
+ try {
102
+ await handle.close();
103
+ }
104
+ catch { /* Preserve the acquisition failure. */ }
105
+ signal.throwIfAborted();
106
+ throw error;
107
+ }
108
+ finally {
109
+ this.#acquiring--;
110
+ }
111
+ }
112
+ case "close": {
113
+ const [id] = request.args;
114
+ const handle = this.#handle(id);
115
+ this.#handles.delete(id);
116
+ await handle.close();
117
+ return;
118
+ }
119
+ case "descriptorCapabilities": return { ...this.#handle(request.args[0]).capabilities };
120
+ case "fstat": return this.#handle(request.args[0]).stat(options);
121
+ case "position": {
122
+ const handle = this.#handle(request.args[0]);
123
+ if (handle.capabilities.position !== true || !handle.getPosition)
124
+ throw new FsError("ENOTSUP");
125
+ return handle.getPosition(options);
126
+ }
127
+ case "read": {
128
+ const [id, length, position] = request.args;
129
+ this.#size(length);
130
+ const buffer = new Uint8Array(length);
131
+ const count = await this.#handle(id).read(buffer, position, options);
132
+ if (!Number.isSafeInteger(count) || count < 0 || count > length)
133
+ throw new FsError("EIO");
134
+ return buffer.slice(0, count);
135
+ }
136
+ case "write": {
137
+ const [id, data, position] = request.args;
138
+ if (!(data instanceof Uint8Array))
139
+ throw new FsError("EINVAL");
140
+ this.#size(data.byteLength);
141
+ // The request may outlive its transport's shared reply/request slot.
142
+ const owned = data.slice();
143
+ const count = await this.#handle(id).write(owned, position, options);
144
+ if (!Number.isSafeInteger(count) || count < 0 || count > owned.byteLength)
145
+ throw new FsError("EIO");
146
+ return count;
147
+ }
148
+ case "ftruncate": return this.#handle(request.args[0]).truncate(request.args[1], options);
149
+ case "sync": return this.#handle(request.args[0]).sync(request.args[1], options);
150
+ case "stat": return fs.stat(this.#path(request.args[0]), options);
151
+ case "lstat": return fs.lstat(this.#path(request.args[0]), options);
152
+ case "realpath": return fs.realpath(this.#path(request.args[0]), options);
153
+ case "readdir": return fs.readdir(this.#path(request.args[0]), { ...options, maxEntries: this.#directoryLimit });
154
+ case "access": return fs.access(this.#path(request.args[0]), request.args[1], options);
155
+ case "mkdir": return fs.mkdir(this.#path(request.args[0]), { ...request.args[1], ...options });
156
+ case "rm": {
157
+ const path = this.#path(request.args[0]);
158
+ if (!fs.unlink)
159
+ throw new FsError("ENOTSUP", { syscall: "unlink", path });
160
+ return fs.unlink(path, options);
161
+ }
162
+ case "rmdir": {
163
+ const path = this.#path(request.args[0]);
164
+ const capabilities = fs.capabilitiesFor ? await fs.capabilitiesFor(path, options) : fs.capabilities;
165
+ signal.throwIfAborted();
166
+ if (capabilities.readOnly === true)
167
+ throw new FsError("EROFS", { syscall: "rmdir", path });
168
+ if (!fs.rmdir || capabilities.removeDirectory === false || capabilities.snapshotRmdir === true)
169
+ throw new FsError("ENOTSUP", { syscall: "rmdir", path });
170
+ return fs.rmdir(path, options);
171
+ }
172
+ case "rename": return fs.rename(this.#path(request.args[0]), this.#path(request.args[1]), options);
173
+ case "readlink":
174
+ if (fs.readlink)
175
+ return fs.readlink(this.#path(request.args[0]), options);
176
+ break;
177
+ case "symlink":
178
+ if (fs.symlink)
179
+ return fs.symlink(request.args[0], this.#path(request.args[1]), options);
180
+ break;
181
+ case "link":
182
+ if (fs.link)
183
+ return fs.link(this.#path(request.args[0]), this.#path(request.args[1]), options);
184
+ break;
185
+ case "chmod":
186
+ if (fs.chmod)
187
+ return fs.chmod(this.#path(request.args[0]), request.args[1], options);
188
+ break;
189
+ case "truncate":
190
+ if (fs.truncate)
191
+ return fs.truncate(this.#path(request.args[0]), request.args[1], options);
192
+ break;
193
+ case "utimes":
194
+ if (fs.utimes)
195
+ return fs.utimes(this.#path(request.args[0]), request.args[1], request.args[2], options);
196
+ break;
197
+ }
198
+ throw new FsError("ENOTSUP", { syscall: request.op });
199
+ }
200
+ close() {
201
+ if (!this.#closing) {
202
+ this.#closing = Promise.resolve().then(async () => {
203
+ await Promise.allSettled([...this.#pending]);
204
+ const handles = [...this.#handles.values()];
205
+ this.#handles.clear();
206
+ const results = await Promise.allSettled(handles.map(handle => handle.close()));
207
+ this.#scope.dispose();
208
+ const failed = results.find(result => result.status === "rejected");
209
+ if (failed?.status === "rejected")
210
+ throw failed.reason;
211
+ });
212
+ this.#abort.abort(new FsError("ECANCELED", { syscall: "python filesystem close" }));
213
+ }
214
+ return this.#closing;
215
+ }
216
+ }
@@ -0,0 +1,3 @@
1
+ import type { OpenFileOptions } from "../contracts/filesystem.js";
2
+ /** Emscripten's pinned musl flag ABI, not the host operating system's flags. */
3
+ export declare function translatePythonOpenFlags(flags: number, mode?: number): OpenFileOptions;
@@ -0,0 +1,14 @@
1
+ import { FsError } from "../contracts/errors.js";
2
+ /** Emscripten's pinned musl flag ABI, not the host operating system's flags. */
3
+ export function translatePythonOpenFlags(flags, mode = 0o666) {
4
+ if (!Number.isSafeInteger(flags) || flags < 0 || flags > 0x7fffffff || (flags & 3) === 3)
5
+ throw new FsError("EINVAL", { syscall: "open" });
6
+ const exclusive = (flags & (64 | 128)) === (64 | 128);
7
+ // CLOEXEC and LARGEFILE do not alter the lifetime or numeric range of a virtual descriptor.
8
+ const supported = 3 | 64 | 128 | 512 | 1024 | 32768 | 524288 | (exclusive ? 131072 : 0);
9
+ if ((flags & ~supported) !== 0)
10
+ throw new FsError("ENOTSUP", { syscall: "open" });
11
+ return { access: (flags & 3) === 0 ? "read" : (flags & 3) === 1 ? "write" : "readwrite",
12
+ creation: (flags & 64) !== 0 ? exclusive ? "exclusive" : "ifMissing" : "never",
13
+ truncate: (flags & 512) !== 0, append: (flags & 1024) !== 0, mode };
14
+ }
@@ -0,0 +1,7 @@
1
+ export { PythonFileSystem } from "./filesystem.js";
2
+ export type { PythonFileSystemOptions, PythonFsRequest } from "./filesystem.js";
3
+ export { translatePythonOpenFlags } from "./flags.js";
4
+ export { mountPythonFileSystem } from "./emscripten.js";
5
+ export type { PythonMountOptions } from "./emscripten.js";
6
+ export { PythonStatTranslator } from "./stat.js";
7
+ export type { PythonWireStat } from "./stat.js";
@@ -0,0 +1,4 @@
1
+ export { PythonFileSystem } from "./filesystem.js";
2
+ export { translatePythonOpenFlags } from "./flags.js";
3
+ export { mountPythonFileSystem } from "./emscripten.js";
4
+ export { PythonStatTranslator } from "./stat.js";
@@ -0,0 +1,2 @@
1
+ import type { PythonFsRequest } from "./filesystem.js";
2
+ export declare function parsePythonFsRequest(value: unknown, maxTransferBytes: number): PythonFsRequest;