@poe-platform/safe-fs 0.1.721 → 0.1.723

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 (58) hide show
  1. package/README.md +67 -21
  2. package/dist/safe-fs/bridge/filesystem.d.ts +4 -0
  3. package/dist/safe-fs/bridge/filesystem.js +49 -10
  4. package/dist/safe-fs/bridge/types.d.ts +6 -0
  5. package/dist/safe-fs/contracts/descriptor.d.ts +5 -0
  6. package/dist/safe-fs/contracts/filesystem.d.ts +15 -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 +2 -0
  16. package/dist/safe-fs/fs/memory/index.js +95 -4
  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 +76 -12
  20. package/dist/safe-fs/fs/object-publication/index.d.ts +4 -0
  21. package/dist/safe-fs/fs/object-publication/index.js +9 -8
  22. package/dist/safe-fs/fs/overlay/index.d.ts +4 -2
  23. package/dist/safe-fs/fs/overlay/index.js +92 -36
  24. package/dist/safe-fs/fs/quota/index.js +83 -23
  25. package/dist/safe-fs/fs/readonly/index.js +5 -3
  26. package/dist/safe-fs/fs/real/index.d.ts +21 -1
  27. package/dist/safe-fs/fs/real/index.js +266 -22
  28. package/dist/safe-fs/fs/s3/filesystem.d.ts +8 -0
  29. package/dist/safe-fs/fs/s3/filesystem.js +59 -12
  30. package/dist/safe-fs/fs/s3/http/request.d.ts +1 -1
  31. package/dist/safe-fs/fs/s3/http/request.js +1 -1
  32. package/dist/safe-fs/fs/s3/http/transport.js +8 -6
  33. package/dist/safe-fs/fs/s3/http/xml.d.ts +4 -1
  34. package/dist/safe-fs/fs/s3/http/xml.js +6 -2
  35. package/dist/safe-fs/fs/s3/manifest-admission.d.ts +8 -0
  36. package/dist/safe-fs/fs/s3/manifest-admission.js +171 -0
  37. package/dist/safe-fs/fs/s3/namespace.js +13 -10
  38. package/dist/safe-fs/fs/scoped.d.ts +1 -0
  39. package/dist/safe-fs/fs/scoped.js +54 -35
  40. package/dist/safe-fs/fs/webdav/webdav.d.ts +4 -1
  41. package/dist/safe-fs/fs/webdav/webdav.js +101 -23
  42. package/dist/safe-fs/node/filesystem.d.ts +8 -1
  43. package/dist/safe-fs/node/filesystem.js +7 -1
  44. package/dist/safe-fs/node/host.d.ts +3 -0
  45. package/dist/safe-fs/node/host.js +78 -0
  46. package/dist/safe-fs/node/index.d.ts +2 -1
  47. package/dist/safe-fs/node/index.js +2 -1
  48. package/dist/safe-fs/platform/browser.d.ts +1 -0
  49. package/dist/safe-fs/platform/browser.js +1 -0
  50. package/dist/safe-fs/platform/node.d.ts +1 -0
  51. package/dist/safe-fs/platform/node.js +1 -0
  52. package/dist/safe-fs/python/emscripten.d.ts +1 -0
  53. package/dist/safe-fs/python/emscripten.js +5 -5
  54. package/dist/safe-fs/python/filesystem.js +5 -5
  55. package/dist/safe-fs/python/native.d.ts +1 -0
  56. package/dist/safe-fs/python/native.js +3 -2
  57. package/dist/safe-fs/xml.js +32 -21
  58. package/package.json +1 -1
@@ -39,6 +39,7 @@ export declare class DeviceFileSystem implements FileSystem {
39
39
  access(path: string, mode?: number, options?: FsOptions): Promise<void>;
40
40
  readlink(path: string, options?: FsOptions): Promise<string>;
41
41
  symlink(target: string, path: string, options?: FsOptions): Promise<void>;
42
+ confineExtraction(roots: readonly string[], options?: FsOptions): Promise<FileSystem>;
42
43
  link(existingPath: string, newPath: string, options?: FsOptions): Promise<void>;
43
44
  chmod(path: string, mode: number, options?: FsOptions): Promise<void>;
44
45
  utimes(path: string, atimeMs: number, mtimeMs: number, options?: FsOptions): Promise<void>;
@@ -15,13 +15,14 @@ const deviceCapabilities = Object.freeze({
15
15
  remove: false, removeDirectory: false, recursiveRemove: false, rename: false,
16
16
  mkdir: false, recursiveMkdir: false, symlinks: false, hardlinks: false, readlink: false,
17
17
  permissions: false, timestamps: false, truncate: false, randomAccessWrite: false,
18
- open: true, atomicFilePublication: false, atomicFileMutation: false, atomicEntryRemoval: false, atomicTreeRemoval: false, atomicFileStaging: false, atomicDirectoryMetadata: false,
18
+ open: true, atomicFilePublication: false, atomicFileMutation: false, atomicEntryRemoval: false, atomicTreeRemoval: false, atomicFileStaging: false, atomicDirectoryMetadata: false, trustedOwnedStaging: false,
19
19
  atomicRename: false, atomicRenameNoReplace: false, descriptorWriteStream: true, retainedResize: true, atomicResize: false,
20
20
  });
21
21
  function globalCapabilities(filesystem) {
22
22
  const capabilities = { readOnly: false };
23
23
  const optional = {
24
24
  open: ["open"],
25
+ trustedOwnedStaging: ["createStagedFile", "publishStagedFile", "removeStagedFile", "writeFileConditional", "removeFileConditional", "prepareDirectory"],
25
26
  atomicFilePublication: ["publishFileConditional"], atomicEntryRemoval: ["removeEntryConditional"], atomicTreeRemoval: ["removeTreeConditional"], atomicFileMutation: ["writeFileConditional", "removeFileConditional"], atomicFileStaging: ["createStagedFile", "publishStagedFile", "removeStagedFile"], atomicDirectoryMetadata: ["prepareDirectory"],
26
27
  streamingRead: ["readStream"], streamingWrite: ["writeStream"], retainedRead: ["openReadFile"],
27
28
  streamingAppend: ["writeStream"], descriptorWriteStream: ["writeStream"], retainedResize: ["openResizeFile"], atomicResize: ["resizeFile"],
@@ -101,7 +102,16 @@ export class DeviceFileSystem {
101
102
  throw new FsError("EBUSY", { path });
102
103
  }
103
104
  async #writeTarget(path, options) {
104
- const resolved = await this.#resolve(path, options);
105
+ let resolved;
106
+ try {
107
+ resolved = await this.#resolve(path, options);
108
+ }
109
+ catch (error) {
110
+ options.signal?.throwIfAborted();
111
+ if ((options.flag === "wx" || options.flag === "ax") && isFsError(error, "ELOOP"))
112
+ return false;
113
+ throw error;
114
+ }
105
115
  if (resolved === nullPath) {
106
116
  if (options.flag === "wx" || options.flag === "ax")
107
117
  throw new FsError("EEXIST", { path });
@@ -122,7 +132,7 @@ export class DeviceFileSystem {
122
132
  let resolved;
123
133
  let selected;
124
134
  try {
125
- resolved = await this.#resolve(path, options, true, options.create);
135
+ resolved = await this.#resolve(path, options, options.create !== undefined || options.creation !== "exclusive", options.create);
126
136
  }
127
137
  catch (error) {
128
138
  options.signal?.throwIfAborted();
@@ -261,11 +271,11 @@ export class DeviceFileSystem {
261
271
  options?.signal?.throwIfAborted();
262
272
  if (!options || typeof options !== "object")
263
273
  throw new FsError("EINVAL", { syscall: "open", path });
264
- const resolved = await this.#resolve(path, options, options.creation !== "exclusive");
274
+ const resolved = await this.#resolve(path, options, !options.noFollow && options.creation !== "exclusive");
265
275
  options.signal?.throwIfAborted();
266
276
  if (resolved === nullPath)
267
277
  return openFileDescriptor(path, options, {
268
- position: true, positionedRead: true, positionedWrite: true, positionedAppendWrite: true,
278
+ noFollow: true, position: true, positionedRead: true, positionedWrite: true, positionedAppendWrite: true,
269
279
  openTruncate: true, delegateZeroLengthWrite: true, truncate: false, synchronization: "none",
270
280
  }, async (admitted) => {
271
281
  if (admitted.creation === "exclusive")
@@ -289,7 +299,7 @@ export class DeviceFileSystem {
289
299
  throw new FsError("ENOTSUP", { syscall: "open", path });
290
300
  const query = this.#filesystem.capabilitiesFor;
291
301
  options.signal?.throwIfAborted();
292
- const capabilities = query && options.creation !== "exclusive" ? await Reflect.apply(query, this.#filesystem, [path, options]) : this.#filesystem.capabilities;
302
+ const capabilities = query && !options.noFollow && options.creation !== "exclusive" ? await Reflect.apply(query, this.#filesystem, [path, options]) : this.#filesystem.capabilities;
293
303
  options.signal?.throwIfAborted();
294
304
  const open = this.#filesystem.open;
295
305
  options.signal?.throwIfAborted();
@@ -572,6 +582,14 @@ export class DeviceFileSystem {
572
582
  throw new FsError("ENOTSUP", { path });
573
583
  await this.#filesystem.symlink(target, path, options);
574
584
  }
585
+ async confineExtraction(roots, options = {}) {
586
+ options.signal?.throwIfAborted();
587
+ if (!this.#filesystem.confineExtraction)
588
+ throw new FsError("ENOTSUP", { syscall: "confineExtraction" });
589
+ // Extraction uses only the backing namespace; synthetic devices must never
590
+ // become archive publication destinations.
591
+ return this.#filesystem.confineExtraction(roots, options);
592
+ }
575
593
  async link(existingPath, newPath, options = {}) {
576
594
  await this.#mutable(existingPath, options, false);
577
595
  await this.#mutable(newPath, options, false);
@@ -35,6 +35,7 @@ export declare class MemoryFileSystem implements FileSystem {
35
35
  timestamps: true;
36
36
  atomicRename: true;
37
37
  atomicFileStaging: true;
38
+ atomicStagingAncestry: true;
38
39
  atomicFileMutation: true;
39
40
  atomicEntryRemoval: true;
40
41
  atomicTreeRemoval: true;
@@ -53,6 +54,7 @@ export declare class MemoryFileSystem implements FileSystem {
53
54
  constructor(options?: MemoryFileSystemOptions);
54
55
  compareEntry(path: string, peer: FileSystem, peerPath: string, options?: FsOptions): Promise<EntryComparison>;
55
56
  canonicalizeMissingTarget(path: string, options?: FsOptions): string | undefined;
57
+ confineExtraction(roots: readonly string[], options?: FsOptions): Promise<FileSystem>;
56
58
  private metadata;
57
59
  private directory;
58
60
  private addNode;
@@ -68,7 +68,7 @@ export class MemoryFileSystem {
68
68
  permissions: true,
69
69
  timestamps: true,
70
70
  atomicRename: true,
71
- atomicFileStaging: true, atomicFileMutation: true, atomicEntryRemoval: true, atomicTreeRemoval: true,
71
+ atomicFileStaging: true, atomicStagingAncestry: true, atomicFileMutation: true, atomicEntryRemoval: true, atomicTreeRemoval: true,
72
72
  atomicDirectoryMetadata: true,
73
73
  streamingRead: true,
74
74
  retainedRead: true,
@@ -117,6 +117,68 @@ export class MemoryFileSystem {
117
117
  this.validatePath(path, "realpath");
118
118
  return resolveMissingTarget(owner.root, path || ".", options.signal);
119
119
  }
120
+ async confineExtraction(roots, options = {}) {
121
+ options.signal?.throwIfAborted();
122
+ const retained = new Map();
123
+ for (const root of roots) {
124
+ this.validatePath(root, "confineExtraction");
125
+ let path = "";
126
+ for (const component of root.split("/").filter(Boolean)) {
127
+ if (component === "." || component === "..")
128
+ this.fail("EINVAL", "confineExtraction", root);
129
+ path += `/${component}`;
130
+ const node = this.entry(path, "confineExtraction").node;
131
+ if (node.type !== "directory")
132
+ this.fail("ENOTDIR", "confineExtraction", path);
133
+ retained.set(path, node);
134
+ }
135
+ retained.set("/", this.root);
136
+ }
137
+ const allowed = new Set(["mkdir", "rm", "rmdir", "rename", "symlink", "link", "chmod", "utimes", "writeFile", "appendFile", "writeStream", "writeFileConditional", "removeFileConditional"]);
138
+ const reads = new Set(["access", "capabilitiesFor", "compareEntry", "lstat", "stat", "readFile", "readStream", "readdir", "readlink", "realpath"]);
139
+ const check = (path, followFinal) => {
140
+ if (!roots.some(root => root === "/" || path === root || path.startsWith(`${root}/`)))
141
+ this.fail("EPERM", "confineExtraction", path);
142
+ const components = path.split("/").filter(Boolean);
143
+ if (components.some(component => component === "." || component === ".."))
144
+ this.fail("EINVAL", "confineExtraction", path);
145
+ let current = "";
146
+ for (const [index, component] of components.entries()) {
147
+ current += `/${component}`;
148
+ const node = this.entry(current, "confineExtraction", true).node;
149
+ if (retained.has(current) && node !== retained.get(current))
150
+ this.fail("EAGAIN", "confineExtraction", current);
151
+ if (index < components.length - 1 && node?.type !== "directory")
152
+ this.fail("ENOTDIR", "confineExtraction", current);
153
+ if (followFinal && index === components.length - 1 && node?.type === "symlink")
154
+ this.fail("ELOOP", "confineExtraction", current);
155
+ }
156
+ };
157
+ return new Proxy(this, {
158
+ get: (target, property) => {
159
+ if (property === "objects" || property === "confineExtraction")
160
+ return undefined;
161
+ const value = Reflect.get(target, property);
162
+ if (typeof value !== "function")
163
+ return value;
164
+ if (reads.has(String(property)))
165
+ return value.bind(target);
166
+ return (...args) => {
167
+ if (!allowed.has(String(property)))
168
+ throw new FsError("ENOTSUP", { syscall: String(property) });
169
+ // Stock mutations resolve and commit synchronously; writeStream retains
170
+ // its file node before its first await, so later chunks cannot redirect.
171
+ const paths = property === "symlink" ? [args[1]] : property === "link" || property === "rename" ? args.slice(0, 2) : [args[0]];
172
+ for (const path of paths) {
173
+ if (typeof path !== "string")
174
+ throw new FsError("EINVAL");
175
+ check(path, ["chmod", "utimes", "appendFile", "writeFile", "writeStream", "writeFileConditional", "link"].includes(String(property)));
176
+ }
177
+ return Reflect.apply(value, target, args);
178
+ };
179
+ },
180
+ });
181
+ }
120
182
  metadata(mode) {
121
183
  const now = Date.now();
122
184
  return {
@@ -201,6 +263,10 @@ export class MemoryFileSystem {
201
263
  }
202
264
  resolve(path, syscall, options = {}) {
203
265
  this.validatePath(path, syscall);
266
+ // Bound all component arrays allocated by this resolution, including cycles.
267
+ let remainingPathUnits = 65_536 - path.length;
268
+ if (remainingPathUnits < 0)
269
+ this.fail("ENAMETOOLONG", syscall, path);
204
270
  const pending = path.split("/").filter(Boolean);
205
271
  if (path.endsWith("/"))
206
272
  pending.push("");
@@ -239,6 +305,9 @@ export class MemoryFileSystem {
239
305
  if (node.type === "symlink" && (options.followFinal !== false || pending.length > 0)) {
240
306
  if (++links > 40)
241
307
  this.fail("ELOOP", syscall, path);
308
+ if (node.target.length > remainingPathUnits)
309
+ this.fail("ENAMETOOLONG", syscall, path);
310
+ remainingPathUnits -= node.target.length;
242
311
  const target = node.target.split("/").filter(Boolean);
243
312
  if (node.target.endsWith("/"))
244
313
  target.push("");
@@ -278,6 +347,7 @@ export class MemoryFileSystem {
278
347
  snapshot(node) {
279
348
  return {
280
349
  type: node.type,
350
+ filesystemType: "memory",
281
351
  ioBlockSize: preferredIoBlockSize,
282
352
  ...(Number.isSafeInteger(node.revision) ? { revision: node.revision } : {}),
283
353
  preferredIoBlockSize: 4096,
@@ -314,15 +384,17 @@ export class MemoryFileSystem {
314
384
  }
315
385
  open(path, options) {
316
386
  return openFileDescriptor(path, options, {
317
- positionedRead: true, positionedWrite: true, truncate: true, synchronization: "volatile", position: true,
387
+ noFollow: true, positionedRead: true, positionedWrite: true, truncate: true, synchronization: "volatile", position: true,
318
388
  }, async (admitted) => {
319
389
  const location = this.resolve(path, "open", {
320
- followFinal: admitted.creation !== "exclusive", allowMissing: admitted.creation !== "never",
390
+ followFinal: !admitted.noFollow && admitted.creation !== "exclusive", allowMissing: admitted.creation !== "never",
321
391
  });
322
392
  let node = location.node;
323
393
  if (node) {
324
394
  if (admitted.creation === "exclusive")
325
395
  this.fail("EEXIST", "open", path);
396
+ if (node.type === "symlink" && admitted.noFollow)
397
+ this.fail("ELOOP", "open", path);
326
398
  if (node.type !== "file")
327
399
  this.fail("EISDIR", "open", path);
328
400
  this.permission(node, admitted.access === "read" ? 4 : admitted.access === "write" ? 2 : 6, "open", path);
@@ -583,6 +655,25 @@ export class MemoryFileSystem {
583
655
  }
584
656
  async publishStagedFile(staging, destination, options) {
585
657
  options.signal?.throwIfAborted();
658
+ if (options.ancestors) {
659
+ let path = "/";
660
+ const parentPath = destination.slice(0, destination.lastIndexOf("/")) || "/";
661
+ const paths = ["/", ...parentPath.split("/").filter(Boolean).map(component => {
662
+ path = path === "/" ? `/${component}` : `${path}/${component}`;
663
+ return path;
664
+ })];
665
+ if (paths.length !== options.ancestors.length)
666
+ this.fail("EINVAL", "publishStagedFile", destination);
667
+ for (let index = 0; index < paths.length; index++) {
668
+ const expected = options.ancestors[index];
669
+ if (expected.path !== paths[index])
670
+ this.fail("EINVAL", "publishStagedFile", destination);
671
+ const entry = this.entry(expected.path, "publishStagedFile");
672
+ if (entry.node?.type !== "directory")
673
+ this.fail("EAGAIN", "publishStagedFile", destination);
674
+ this.expectEntry(entry.node, expected.stat, expected.path, false);
675
+ }
676
+ }
586
677
  const { directory, file } = this.stagingLocations(staging);
587
678
  this.expectEntry(file.node, staging.file.stat, staging.file.path);
588
679
  const target = this.entry(destination, "publishStagedFile", true);
@@ -1212,5 +1303,5 @@ function stockRetainedResize(filesystem) {
1212
1303
  return true;
1213
1304
  }
1214
1305
  export function createMemoryFileSystem(options = {}) {
1215
- return new MemoryFileSystem(normalizeMemoryFileSystemLimits(options));
1306
+ return new MemoryFileSystem(options);
1216
1307
  }
@@ -1,8 +1,8 @@
1
1
  import { readConfigRecord } from "../../config.js";
2
2
  export const defaultMemoryFileSystemLimits = Object.freeze({
3
- maxFileBytes: 16 * 1024 * 1024,
4
- maxRetainedBytes: 64 * 1024 * 1024,
5
- maxMetadataUnits: 10_000,
3
+ maxFileBytes: Infinity,
4
+ maxRetainedBytes: Infinity,
5
+ maxMetadataUnits: Infinity,
6
6
  });
7
7
  export function normalizeMemoryFileSystemLimits(options) {
8
8
  const keys = ["maxFileBytes", "maxRetainedBytes", "maxMetadataUnits"];
@@ -15,7 +15,9 @@ export function normalizeMemoryFileSystemLimits(options) {
15
15
  limits.maxBytes = record.maxBytes;
16
16
  }
17
17
  for (const key of keys) {
18
- const value = Object.hasOwn(record, key) ? record[key] : limits[key];
18
+ if (!Object.hasOwn(record, key))
19
+ continue;
20
+ const value = record[key];
19
21
  if (typeof value !== "number" || !Number.isSafeInteger(value) || value < (key === "maxMetadataUnits" ? 1 : 0)) {
20
22
  throw new RangeError(`${key} must be a ${key === "maxMetadataUnits" ? "positive" : "nonnegative"} safe integer`);
21
23
  }
@@ -8,6 +8,9 @@ export interface MountFileSystemOptions {
8
8
  export declare class MountFileSystem implements FileSystem {
9
9
  readonly capabilities: FileSystemCapabilities;
10
10
  private readonly mounts;
11
+ private activeNamespaceOperations;
12
+ private namespaceMutation;
13
+ private readonly namespaceWaiters;
11
14
  constructor(options: MountFileSystemOptions);
12
15
  private select;
13
16
  capabilitiesFor(path: string, options?: CapabilityQueryOptions): Promise<FileSystemCapabilities>;
@@ -16,6 +19,7 @@ export declare class MountFileSystem implements FileSystem {
16
19
  resizeFile(path: string, operation: FileResizeOperation, options?: FileResizeOptions): Promise<void>;
17
20
  openResizeFile(path: string, options?: OpenResizeFileOptions): Promise<FileResizeHandle>;
18
21
  private error;
22
+ private acquireNamespace;
19
23
  private operation;
20
24
  private components;
21
25
  private lookup;
@@ -22,9 +22,10 @@ function fail(code) {
22
22
  throw new FsError(code);
23
23
  }
24
24
  function snapshotStat(stat) {
25
- const { type, size, allocatedBytes, ioBlockSize, preferredIoBlockSize, mode, mtimeMs, atimeMs, ctimeMs, birthtimeMs, revision, identityScope, opaqueIdentity, opaqueVersion, ino, dev, rdevMajor, rdevMinor, nlink, uid, gid } = stat;
25
+ const { type, filesystemType, size, allocatedBytes, ioBlockSize, preferredIoBlockSize, mode, mtimeMs, atimeMs, ctimeMs, birthtimeMs, revision, identityScope, opaqueIdentity, opaqueVersion, ino, dev, rdevMajor, rdevMinor, nlink, uid, gid } = stat;
26
26
  return {
27
27
  type, size, mode, mtimeMs, atimeMs, ctimeMs,
28
+ ...(filesystemType === undefined ? {} : { filesystemType }),
28
29
  ...(revision === undefined ? {} : { revision }),
29
30
  ...(allocatedBytes === undefined ? {} : { allocatedBytes }),
30
31
  ...(ioBlockSize === undefined ? {} : { ioBlockSize }),
@@ -45,6 +46,9 @@ function snapshotStat(stat) {
45
46
  export class MountFileSystem {
46
47
  capabilities;
47
48
  mounts;
49
+ activeNamespaceOperations = 0;
50
+ namespaceMutation = false;
51
+ namespaceWaiters = new Set();
48
52
  constructor(options) {
49
53
  const mounts = [];
50
54
  const paths = new Set();
@@ -98,6 +102,7 @@ export class MountFileSystem {
98
102
  const common = (capability) => {
99
103
  const optional = {
100
104
  open: ["open"],
105
+ trustedOwnedStaging: ["createStagedFile", "publishStagedFile", "removeStagedFile", "writeFileConditional", "removeFileConditional", "prepareDirectory"],
101
106
  atomicFilePublication: ["publishFileConditional"], atomicEntryRemoval: ["removeEntryConditional"], atomicTreeRemoval: ["removeTreeConditional"], atomicFileMutation: ["writeFileConditional", "removeFileConditional"], atomicFileStaging: ["createStagedFile", "publishStagedFile", "removeStagedFile"], atomicDirectoryMetadata: ["prepareDirectory"],
102
107
  symlinks: ["symlink", "readlink"], hardlinks: ["link"], permissions: ["chmod"], timestamps: ["utimes"], readlink: ["readlink"],
103
108
  descriptorWriteStream: ["writeStream"], retainedResize: ["openResizeFile"], atomicResize: ["resizeFile"],
@@ -116,7 +121,7 @@ export class MountFileSystem {
116
121
  return values.every(value => value === true) ? true : values.every(value => value === false) ? false : undefined;
117
122
  };
118
123
  const semantics = Object.fromEntries([
119
- "atomicFilePublication", "atomicEntryRemoval", "atomicTreeRemoval", "atomicFileMutation", "atomicFileStaging", "atomicDirectoryMetadata", "read", "stat", "readdir", "realpath", "access", "open", "versionedDescriptors",
124
+ "trustedOwnedStaging", "atomicFilePublication", "atomicEntryRemoval", "atomicTreeRemoval", "atomicFileMutation", "atomicFileStaging", "atomicDirectoryMetadata", "read", "stat", "readdir", "realpath", "access", "open", "versionedDescriptors",
120
125
  "write", "append", "exclusiveCreate", "explicitDirectories", "implicitDirectories", "mkdir", "recursiveMkdir",
121
126
  "remove", "removeDirectory", "recursiveRemove", "rename", "atomicRenameNoReplace", "copy", "exclusiveCopy", "readlink", "truncate",
122
127
  "streamingAppend", "randomAccessWrite", "descriptorWriteStream", "retainedResize", "atomicResize", "symlinks", "hardlinks", "permissions", "timestamps",
@@ -126,6 +131,7 @@ export class MountFileSystem {
126
131
  readOnly: all("readOnly"),
127
132
  ...(append === undefined ? {} : { append }),
128
133
  ...semantics,
134
+ atomicStagingAncestry: false,
129
135
  atomicRename: mounts.length === 1 && all("atomicRename"),
130
136
  ...(streamingRead === undefined ? {} : { streamingRead }),
131
137
  ...(streamingWrite === undefined ? {} : { streamingWrite }),
@@ -139,6 +145,7 @@ export class MountFileSystem {
139
145
  return this.operation("capabilitiesFor", path, options, async () => {
140
146
  const location = await this.resolve(path, options, {
141
147
  allowMissing: options.create ?? true,
148
+ followFinal: options.create !== undefined || options.creation !== "exclusive",
142
149
  ...(options.create === undefined ? {} : { resizeCreate: options.create }),
143
150
  });
144
151
  const observed = ownedMutationCapabilities(location.mount.backend, await location.mount.backend.capabilitiesFor?.(location.local, options)
@@ -148,7 +155,7 @@ export class MountFileSystem {
148
155
  ? { ...observed, descriptorWriteStream: false } : observed;
149
156
  const resize = declared.atomicResize === true && (typeof location.mount.backend.resizeFile !== "function" || declared.readOnly === true)
150
157
  ? { ...declared, atomicResize: false } : declared;
151
- const withOpen = { ...resize, ...(typeof location.mount.backend.open === "function" ? {} : { open: false }) };
158
+ const withOpen = { ...resize, atomicStagingAncestry: false, ...(typeof location.mount.backend.open === "function" ? {} : { open: false }) };
152
159
  const capabilities = location.synthetic ? { ...withOpen, open: false, retainedRead: false }
153
160
  : retainedResizeCapabilities(location.mount.backend, retainedReadCapabilities(location.mount.backend, withOpen));
154
161
  if (location.synthetic)
@@ -168,7 +175,9 @@ export class MountFileSystem {
168
175
  return path === "/" || this.mounts.some((mount) => within(path, mount.path));
169
176
  }
170
177
  async openReadFile(path, options = {}) {
178
+ let release;
171
179
  try {
180
+ release = await this.acquireNamespace(options);
172
181
  options.signal?.throwIfAborted();
173
182
  const location = await this.resolve(path, options);
174
183
  if (location.synthetic)
@@ -178,6 +187,9 @@ export class MountFileSystem {
178
187
  catch (error) {
179
188
  throw error ? this.error(error, "openReadFile", path, options) : error;
180
189
  }
190
+ finally {
191
+ release?.();
192
+ }
181
193
  }
182
194
  async resizeFile(path, operation, options = {}) {
183
195
  return this.operation("resizeFile", path, options, async () => {
@@ -197,7 +209,9 @@ export class MountFileSystem {
197
209
  });
198
210
  }
199
211
  async openResizeFile(path, options = {}) {
212
+ let release;
200
213
  try {
214
+ release = await this.acquireNamespace(options);
201
215
  options.signal?.throwIfAborted();
202
216
  const resolution = this.resolve(path, options, { allowMissing: options.create === true, resizeCreate: options.create ?? false });
203
217
  const signal = options.signal;
@@ -216,17 +230,56 @@ export class MountFileSystem {
216
230
  catch (error) {
217
231
  throw error ? this.error(error, "openResizeFile", path, options) : error;
218
232
  }
233
+ finally {
234
+ release?.();
235
+ }
219
236
  }
220
237
  error(error, syscall, path, options, dest) {
221
- if (options.signal?.aborted && error === options.signal.reason)
238
+ if (options.signal?.aborted && Object.is(error, options.signal.reason))
222
239
  return error;
223
240
  return new FsError(toFsError(error).code, {
224
241
  cause: error, syscall, path: globalPath(path), ...(dest === undefined ? {} : { dest: globalPath(dest) }),
225
242
  });
226
243
  }
244
+ // Keep guest namespace changes outside every path admission/action interval.
245
+ // Backend references held outside this view still require backend confinement.
246
+ async acquireNamespace(options, mutation = false) {
247
+ const signal = options.signal;
248
+ signal?.throwIfAborted();
249
+ while (this.namespaceMutation || mutation && this.activeNamespaceOperations > 0) {
250
+ await new Promise((resolve, reject) => {
251
+ const wake = () => { cleanup(); resolve(); };
252
+ const abort = () => { cleanup(); reject(signal.reason); };
253
+ const cleanup = () => {
254
+ this.namespaceWaiters.delete(wake);
255
+ signal?.removeEventListener("abort", abort);
256
+ };
257
+ this.namespaceWaiters.add(wake);
258
+ signal?.addEventListener("abort", abort, { once: true });
259
+ if (signal?.aborted)
260
+ abort();
261
+ });
262
+ signal?.throwIfAborted();
263
+ }
264
+ this.activeNamespaceOperations++;
265
+ if (mutation)
266
+ this.namespaceMutation = true;
267
+ return () => {
268
+ this.activeNamespaceOperations--;
269
+ if (mutation)
270
+ this.namespaceMutation = false;
271
+ for (const wake of this.namespaceWaiters)
272
+ wake();
273
+ };
274
+ }
227
275
  async operation(syscall, path, options, action, dest, preserveReceipt = false) {
276
+ let release;
228
277
  try {
229
- options.signal?.throwIfAborted();
278
+ release = await this.acquireNamespace(options, [
279
+ "rename", "symlink", "link", "unlink", "rm", "rmdir",
280
+ "removeEntryConditional", "removeFileConditional", "removeTreeConditional",
281
+ "publishStagedFile", "removeStagedFile",
282
+ ].includes(syscall));
230
283
  const result = await action();
231
284
  if (!preserveReceipt)
232
285
  options.signal?.throwIfAborted();
@@ -235,6 +288,9 @@ export class MountFileSystem {
235
288
  catch (error) {
236
289
  throw this.error(error, syscall, path, options, dest);
237
290
  }
291
+ finally {
292
+ release?.();
293
+ }
238
294
  }
239
295
  components(path) {
240
296
  validatePath(path);
@@ -423,18 +479,21 @@ export class MountFileSystem {
423
479
  }
424
480
  open(path, options) {
425
481
  return openFileDescriptor(globalPath(path), options, {
426
- positionedRead: true, positionedWrite: true, truncate: true, synchronization: "storage",
482
+ noFollow: true, positionedRead: true, positionedWrite: true, truncate: true, synchronization: "storage",
427
483
  }, async (admitted) => {
484
+ let release;
428
485
  try {
486
+ release = await this.acquireNamespace(admitted);
429
487
  const location = await this.resolve(path, admitted, {
430
- allowMissing: admitted.creation !== "never", followFinal: admitted.creation !== "exclusive",
488
+ allowMissing: admitted.creation !== "never", followFinal: !admitted.noFollow && admitted.creation !== "exclusive",
431
489
  });
432
490
  if (location.synthetic)
433
491
  fail("EISDIR");
434
492
  if (admitted.access !== "read" || admitted.creation !== "never" || admitted.truncate || admitted.append)
435
493
  this.mutable(location);
436
494
  const backend = location.mount.backend;
437
- const capabilities = await backend.capabilitiesFor?.(location.local, admitted) ?? backend.capabilities;
495
+ const capabilities = admitted.noFollow ? backend.capabilities
496
+ : await backend.capabilitiesFor?.(location.local, admitted) ?? backend.capabilities;
438
497
  if (!backend.open || capabilities.open === false)
439
498
  fail("ENOTSUP");
440
499
  admitted.signal?.throwIfAborted();
@@ -451,6 +510,9 @@ export class MountFileSystem {
451
510
  admitted.signal?.throwIfAborted();
452
511
  throw this.error(error, "open", path, admitted);
453
512
  }
513
+ finally {
514
+ release?.();
515
+ }
454
516
  });
455
517
  }
456
518
  writeFile(path, data, options = {}) {
@@ -776,10 +838,7 @@ export class MountFileSystem {
776
838
  }
777
839
  }
778
840
  else {
779
- const maxBytes = 64 * 1024 * 1024;
780
- const data = await reader.readFile(origin.local, { ...options, maxBytes });
781
- if (data.byteLength > maxBytes)
782
- fail("EFBIG");
841
+ const data = await reader.readFile(origin.local, options);
783
842
  options.signal?.throwIfAborted();
784
843
  await writer.writeFile(target.local, data, writeOptions);
785
844
  }
@@ -856,7 +915,9 @@ export class MountFileSystem {
856
915
  });
857
916
  }
858
917
  async *readStream(path, options = {}) {
918
+ let release;
859
919
  try {
920
+ release = await this.acquireNamespace(options);
860
921
  options.signal?.throwIfAborted();
861
922
  const location = await this.resolve(path, options);
862
923
  if (location.synthetic)
@@ -871,6 +932,9 @@ export class MountFileSystem {
871
932
  catch (error) {
872
933
  throw this.error(error, "readStream", path, options);
873
934
  }
935
+ finally {
936
+ release?.();
937
+ }
874
938
  }
875
939
  writeStream(path, source, options = {}) {
876
940
  return this.operation("writeStream", path, options, async () => {
@@ -2,11 +2,15 @@ import type { FileStat, FileSystem, FsOptions, OpenFileOptions } from "../../con
2
2
  import type { ByteSource } from "../../contracts/io.js";
3
3
  export interface ObjectFileVersion {
4
4
  readonly revision: string;
5
+ /** Opaque lease/reservation token; independent of namespace revision. */
6
+ readonly publicationToken?: string;
5
7
  readonly stat: FileStat;
6
8
  read(position: number, maxBytes: number, options?: FsOptions): Promise<Uint8Array>;
7
9
  close(): Promise<void>;
8
10
  }
9
11
  export interface ObjectFilePublicationOptions extends FsOptions {
12
+ /** Token from this descriptor's current retained version, if supplied by its store. */
13
+ readonly previousToken?: string;
10
14
  readonly size: number;
11
15
  readonly mode: number;
12
16
  }
@@ -5,12 +5,12 @@ import { validatePath } from "../../contracts/virtual-path.js";
5
5
  import { openFileDescriptor } from "../descriptor.js";
6
6
  export function withObjectFileDescriptors(filesystem, store, options = {}) {
7
7
  const chunkBytes = options.chunkBytes ?? 65536;
8
- const maxStagedBytes = options.maxStagedBytes ?? 8 * 1024 * 1024;
9
- const maxStagedPages = options.maxStagedPages ?? 4096;
10
- const maxFileBytes = options.maxFileBytes ?? 256 * 1024 * 1024;
11
- const maxOpenFiles = options.maxOpenFiles ?? 64;
12
- if (![chunkBytes, maxStagedBytes, maxStagedPages, maxFileBytes, maxOpenFiles].every(value => Number.isSafeInteger(value) && value > 0)
13
- || chunkBytes > 1048576 || typeof store.acquire !== "function"
8
+ const maxStagedBytes = options.maxStagedBytes ?? Infinity;
9
+ const maxStagedPages = options.maxStagedPages ?? Infinity;
10
+ const maxFileBytes = options.maxFileBytes ?? Infinity;
11
+ const maxOpenFiles = options.maxOpenFiles ?? Infinity;
12
+ if (![chunkBytes, options.maxStagedBytes, options.maxStagedPages, options.maxFileBytes, options.maxOpenFiles].every(value => value === undefined || Number.isSafeInteger(value) && value > 0)
13
+ || typeof store.acquire !== "function"
14
14
  || store.publish !== undefined && typeof store.publish !== "function"
15
15
  || store.createStaging !== undefined && typeof store.createStaging !== "function")
16
16
  throw new TypeError("Invalid object descriptor configuration");
@@ -50,6 +50,7 @@ export function withObjectFileDescriptors(filesystem, store, options = {}) {
50
50
  };
51
51
  const version = (value) => {
52
52
  if (!value || typeof value.revision !== "string" || value.revision.length === 0 || value.revision.length > 4096
53
+ || value.publicationToken !== undefined && (typeof value.publicationToken !== "string" || value.publicationToken.length === 0 || value.publicationToken.length > 4096)
53
54
  || !value.stat || value.stat.type !== "file" || !Number.isSafeInteger(value.stat.size) || value.stat.size < 0
54
55
  || !Number.isSafeInteger(value.stat.mode) || value.stat.mode < 0 || value.stat.mode > 0o177777
55
56
  || ![value.stat.mtimeMs, value.stat.atimeMs, value.stat.ctimeMs].every(Number.isFinite)
@@ -57,7 +58,7 @@ export function withObjectFileDescriptors(filesystem, store, options = {}) {
57
58
  throw new FsError("EIO", { message: "Invalid immutable object version" });
58
59
  if (value.stat.size > maxFileBytes)
59
60
  throw new FsError("EFBIG", { message: "Object descriptor file limit exceeded" });
60
- return Object.freeze({ revision: value.revision, stat: Object.freeze({ ...value.stat }), read: value.read.bind(value), close: value.close.bind(value) });
61
+ return Object.freeze({ revision: value.revision, ...(value.publicationToken === undefined ? {} : { publicationToken: value.publicationToken }), stat: Object.freeze({ ...value.stat }), read: value.read.bind(value), close: value.close.bind(value) });
61
62
  };
62
63
  const clearPages = (state) => {
63
64
  stagedBytes -= state.pages.size * chunkBytes;
@@ -181,7 +182,7 @@ export function withObjectFileDescriptors(filesystem, store, options = {}) {
181
182
  yield bytes;
182
183
  }
183
184
  })();
184
- received = await store.publish(path, expected, source, { ...selected, size: state.size, mode: (state.head?.stat.mode ?? admitted.mode) & 0o7777 });
185
+ received = await store.publish(path, expected, source, { ...selected, ...(state.head?.publicationToken === undefined ? {} : { previousToken: state.head.publicationToken }), size: state.size, mode: (state.head?.stat.mode ?? admitted.mode) & 0o7777 });
185
186
  });
186
187
  const published = version(received);
187
188
  if (emitted !== state.size || published.stat.size !== state.size || published.revision === expected)
@@ -1,6 +1,6 @@
1
1
  import type { ByteSource } from "../../contracts/io.js";
2
2
  import type { FileDescriptor, OpenFileOptions } from "../../contracts/descriptor.js";
3
- import type { AppendFileOptions, CapabilityQueryOptions, CopyFileOptions, DirectoryEntry, OpenReadFileOptions, FileStat, FileSystem, FileSystemCapabilities, FsOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, WriteFileOptions } from "../../contracts/filesystem.js";
3
+ import type { AppendFileOptions, CapabilityQueryOptions, CopyFileOptions, DirectoryEntry, OpenReadFileOptions, FileReadHandle, FileStat, FileSystem, FileSystemCapabilities, FsOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, WriteFileOptions } from "../../contracts/filesystem.js";
4
4
  export interface OverlayFileSystemOptions {
5
5
  readonly upper: FileSystem;
6
6
  readonly lower: FileSystem;
@@ -21,7 +21,9 @@ export declare class OverlayFileSystem implements FileSystem {
21
21
  open(path: string, options: OpenFileOptions): Promise<FileDescriptor>;
22
22
  private run;
23
23
  capabilitiesFor(path: string, options?: CapabilityQueryOptions): Promise<FileSystemCapabilities>;
24
- openReadFile(path: string, options?: OpenReadFileOptions): Promise<import("../../contracts/filesystem.js").FileReadHandle>;
24
+ openReadFile(path: string, options?: OpenReadFileOptions): Promise<FileReadHandle>;
25
+ private pinRead;
26
+ private handleBytes;
25
27
  private writable;
26
28
  private permission;
27
29
  private maybeStat;