@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
@@ -8,9 +8,10 @@ import { compareIdentity } from "../mount/identity.js";
8
8
  import { compareEntries, registerEntryView } from "../mount/comparison.js";
9
9
  import { admitDirectoryEntries, directoryEntryLimit } from "../directory-admission.js";
10
10
  function snapshotStat(stat) {
11
- const { type, size, allocatedBytes, ioBlockSize, preferredIoBlockSize, mode, mtimeMs, atimeMs, ctimeMs, birthtimeMs, revision, identityScope, opaqueIdentity, opaqueVersion, ino, dev, rdevMajor, rdevMinor, nlink, uid, gid } = stat;
11
+ const { type, filesystemType, size, allocatedBytes, ioBlockSize, preferredIoBlockSize, mode, mtimeMs, atimeMs, ctimeMs, birthtimeMs, revision, identityScope, opaqueIdentity, opaqueVersion, ino, dev, rdevMajor, rdevMinor, nlink, uid, gid } = stat;
12
12
  return {
13
13
  type, size, mode, mtimeMs, atimeMs, ctimeMs,
14
+ ...(filesystemType === undefined ? {} : { filesystemType }),
14
15
  ...(allocatedBytes === undefined ? {} : { allocatedBytes }),
15
16
  ...(ioBlockSize === undefined ? {} : { ioBlockSize }),
16
17
  ...(preferredIoBlockSize === undefined ? {} : { preferredIoBlockSize }),
@@ -80,15 +81,14 @@ export class OverlayFileSystem {
80
81
  const entry = await this.required(path, options);
81
82
  return { filesystem: entry.backend, path: entry.path, readOnly: this.capabilities.readOnly === true };
82
83
  }, false));
83
- this.maxBufferBytes = options.maxBufferBytes ?? 64 * 1024 * 1024;
84
- integer(this.maxBufferBytes, "/");
84
+ this.maxBufferBytes = options.maxBufferBytes ?? Infinity;
85
+ if (options.maxBufferBytes !== undefined)
86
+ integer(options.maxBufferBytes, "/");
85
87
  const writable = !this.#upper.capabilities.readOnly && this.#upper.capabilities.atomicRename === true;
86
- const readable = [this.#upper, this.#lower].map((backend) => typeof backend.readStream === "function" ? backend.capabilities.streamingRead : false);
88
+ const readable = [this.#upper, this.#lower].map((backend) => retainedReadCapabilities(backend).retainedRead);
87
89
  const streamingRead = readable.every((capability) => capability === true) ? true
88
90
  : readable.every((capability) => capability === false) ? false : undefined;
89
- const retained = [this.#upper, this.#lower].map((backend) => retainedReadCapabilities(backend).retainedRead);
90
- const retainedRead = retained.every((capability) => capability === true) ? true
91
- : retained.every((capability) => capability === false) ? false : undefined;
91
+ const retainedRead = streamingRead;
92
92
  const streamingWrite = writable && this.#upper.capabilities.streamingWrite === true
93
93
  && this.#upper.capabilities.streamingRead === true
94
94
  && typeof this.#upper.writeStream === "function" && typeof this.#upper.readStream === "function"
@@ -101,7 +101,7 @@ export class OverlayFileSystem {
101
101
  const effectiveStreamingWrite = requireCapabilities(streamingWrite, mutation);
102
102
  const semantics = Object.fromEntries([
103
103
  ...["read", "stat", "readdir", "realpath", "access"].map(capability => [capability,
104
- requireCapabilities(upper[capability], this.#lower.capabilities[capability])]),
104
+ requireCapabilities(upper[capability], this.#lower.capabilities[capability], ...(capability === "read" ? readable : []))]),
105
105
  ["write", requireCapabilities(mutation, upper.write)],
106
106
  ["exclusiveCreate", requireCapabilities(mutation, upper.exclusiveCreate)],
107
107
  ["mkdir", requireCapabilities(mutation, upper.mkdir)],
@@ -120,7 +120,7 @@ export class OverlayFileSystem {
120
120
  this.capabilities = Object.freeze({
121
121
  ...semantics,
122
122
  open: false,
123
- atomicFilePublication: false, atomicFileMutation: false, atomicFileStaging: false, atomicDirectoryMetadata: false,
123
+ atomicFilePublication: false, atomicFileMutation: false, atomicFileStaging: false, atomicDirectoryMetadata: false, trustedOwnedStaging: false,
124
124
  implicitDirectories: false,
125
125
  readlink: upper.readlink === true && this.#lower.capabilities.readlink === true ? true
126
126
  : upper.readlink === false && this.#lower.capabilities.readlink === false ? false : undefined,
@@ -169,7 +169,7 @@ export class OverlayFileSystem {
169
169
  }
170
170
  capabilitiesFor(path, options = {}) {
171
171
  return this.run(options, async () => {
172
- const location = await this.resolve(path, options, true, true);
172
+ const location = await this.resolve(path, options, options.create !== undefined || options.creation !== "exclusive", true);
173
173
  const backend = location.entry?.backend ?? this.#upper;
174
174
  const capabilities = await backend.capabilitiesFor?.(location.path, options) ?? backend.capabilities;
175
175
  options.signal?.throwIfAborted();
@@ -188,9 +188,60 @@ export class OverlayFileSystem {
188
188
  fail("EISDIR", path);
189
189
  }
190
190
  this.permission(entry, 4);
191
- return openRetainedReadFile(entry.backend, entry.path, options);
191
+ return this.pinRead(entry, options);
192
192
  }, false);
193
193
  }
194
+ async pinRead(entry, options) {
195
+ // A retained object, not the resolved pathname, carries read authority.
196
+ if (compareIdentity(entry.stat, entry.stat) !== "same")
197
+ fail("ENOTSUP", entry.path, "overlay reads require stable backend identity");
198
+ const ancestors = [];
199
+ let parent = dirname(entry.path);
200
+ while (true) {
201
+ const stat = await entry.backend.lstat(parent, options);
202
+ if (stat.type !== "directory" || compareIdentity(stat, stat) !== "same")
203
+ fail("ENOTSUP", entry.path, "overlay reads require stable directory ancestry");
204
+ ancestors.push({ backend: entry.backend, path: parent, stat });
205
+ if (parent === "/")
206
+ break;
207
+ parent = dirname(parent);
208
+ }
209
+ const handle = await openRetainedReadFile(entry.backend, entry.path, options);
210
+ try {
211
+ if (compareIdentity(entry.stat, await handle.stat(options)) !== "same")
212
+ fail("ENOTSUP", entry.path, "backend read object changed during admission");
213
+ for (const ancestor of ancestors) {
214
+ const stat = await entry.backend.lstat(ancestor.path, options);
215
+ if (stat.type !== "directory" || compareIdentity(ancestor.stat, stat) !== "same")
216
+ fail("ENOTSUP", entry.path, "backend read ancestry changed during admission");
217
+ }
218
+ const current = await this.required(entry.path, options);
219
+ if (current.backend !== entry.backend || compareIdentity(entry.stat, current.stat) !== "same")
220
+ fail("ENOTSUP", entry.path, "overlay read layer changed during admission");
221
+ options.signal?.throwIfAborted();
222
+ return handle;
223
+ }
224
+ catch (error) {
225
+ await finishCleanup(() => handle.close(), true);
226
+ throw error;
227
+ }
228
+ }
229
+ async *handleBytes(handle, options) {
230
+ let position = options.start ?? 0;
231
+ const end = options.endExclusive ?? Number.MAX_SAFE_INTEGER;
232
+ const chunkSize = options.chunkSize ?? 64 * 1024;
233
+ while (position < end) {
234
+ const size = Math.min(chunkSize, end - position);
235
+ const chunk = await handle.read(position, size, options);
236
+ options.signal?.throwIfAborted();
237
+ if (!(chunk instanceof Uint8Array) || chunk.byteLength > size)
238
+ fail("EIO", "", "backend returned invalid retained bytes");
239
+ if (!chunk.byteLength)
240
+ break;
241
+ position += chunk.byteLength;
242
+ yield new Uint8Array(chunk);
243
+ }
244
+ }
194
245
  writable(path) {
195
246
  if (this.#upper.capabilities.readOnly)
196
247
  fail("EROFS", path);
@@ -435,13 +486,16 @@ export class OverlayFileSystem {
435
486
  const limit = Math.min(options.maxBytes ?? this.maxBufferBytes, this.maxBufferBytes);
436
487
  if (entry.stat.size > limit)
437
488
  fail("EFBIG", entry.path);
438
- const result = await entry.backend.readFile(entry.path, { ...options, maxBytes: limit });
439
- options.signal?.throwIfAborted();
440
- if (!(result instanceof Uint8Array))
441
- fail("EIO", entry.path, "backend returned non-byte data");
442
- if (result.byteLength > limit)
443
- fail("EFBIG", entry.path);
444
- return new Uint8Array(result);
489
+ const handle = await this.pinRead(entry, options);
490
+ let failed = true;
491
+ try {
492
+ const result = await collectBytes(this.handleBytes(handle, options), { ...options, ...(limit === Infinity ? {} : { maxBytes: limit }) });
493
+ failed = false;
494
+ return result;
495
+ }
496
+ finally {
497
+ await finishCleanup(() => handle.close(), failed);
498
+ }
445
499
  }
446
500
  async cleanGarbage(strict) {
447
501
  const failures = [];
@@ -501,8 +555,16 @@ export class OverlayFileSystem {
501
555
  this.permission(entry, 4);
502
556
  if (entry.stat.size > this.maxBufferBytes)
503
557
  fail("EFBIG", entry.path);
504
- const source = this.bounded(entry.backend.readStream(entry.path, options), entry.path, options);
505
- await this.streamToUpper(destination, source, { ...options, mode: 0o600, flag: "wx" });
558
+ const handle = await this.pinRead(entry, options);
559
+ let failed = true;
560
+ try {
561
+ const source = this.bounded(this.handleBytes(handle, options), entry.path, options);
562
+ await this.streamToUpper(destination, source, { ...options, mode: 0o600, flag: "wx" });
563
+ failed = false;
564
+ }
565
+ finally {
566
+ await finishCleanup(() => handle.close(), failed);
567
+ }
506
568
  }
507
569
  else
508
570
  await this.#upper.writeFile(destination, await this.bytes(entry, options), { ...options, mode: 0o600, flag: "wx" });
@@ -918,7 +980,7 @@ export class OverlayFileSystem {
918
980
  if (this.#upper.truncate)
919
981
  await this.#upper.truncate(temporary, length, options);
920
982
  else {
921
- const previous = await this.#upper.readFile(temporary, { ...options, maxBytes: this.maxBufferBytes });
983
+ const previous = await this.#upper.readFile(temporary, { ...options, ...(this.maxBufferBytes === Infinity ? {} : { maxBytes: this.maxBufferBytes }) });
922
984
  const bytes = new Uint8Array(length);
923
985
  bytes.set(previous.subarray(0, length));
924
986
  await this.#upper.writeFile(temporary, bytes, options);
@@ -939,27 +1001,21 @@ export class OverlayFileSystem {
939
1001
  if (options.endExclusive < start)
940
1002
  fail("EINVAL", path);
941
1003
  }
942
- const entry = await this.run(options, async () => {
1004
+ const handle = await this.run(options, async () => {
943
1005
  const entry = await this.required(path, options);
944
1006
  if (entry.stat.type !== "file")
945
1007
  fail("EISDIR", path);
946
1008
  this.permission(entry, 4);
947
- return entry;
1009
+ return this.pinRead(entry, options);
948
1010
  });
949
- if (entry.backend.readStream && entry.backend.capabilities.streamingRead !== false) {
950
- for await (const chunk of readBytes(entry.backend.readStream(entry.path, options), options.signal)) {
951
- yield new Uint8Array(chunk);
952
- }
953
- options.signal?.throwIfAborted();
954
- return;
1011
+ let failed = true;
1012
+ try {
1013
+ yield* readBytes(this.handleBytes(handle, options), options.signal);
1014
+ failed = false;
955
1015
  }
956
- const bytes = await this.bytes(entry, options);
957
- const end = Math.min(bytes.byteLength, options.endExclusive ?? bytes.byteLength);
958
- for (let offset = start; offset < end; offset += chunkSize) {
959
- options.signal?.throwIfAborted();
960
- yield bytes.slice(offset, Math.min(end, offset + chunkSize));
1016
+ finally {
1017
+ await finishCleanup(() => handle.close(), failed);
961
1018
  }
962
- options.signal?.throwIfAborted();
963
1019
  }
964
1020
  async *bounded(source, path, options) {
965
1021
  let size = 0;
@@ -1007,7 +1063,7 @@ export class OverlayFileSystem {
1007
1063
  });
1008
1064
  return;
1009
1065
  }
1010
- const bytes = await collectBytes(source, { maxBytes: this.maxBufferBytes, ...(options.signal ? { signal: options.signal } : {}) });
1066
+ const bytes = await collectBytes(source, { ...(this.maxBufferBytes === Infinity ? {} : { maxBytes: this.maxBufferBytes }), ...(options.signal ? { signal: options.signal } : {}) });
1011
1067
  await this.writeFile(path, bytes, options);
1012
1068
  }
1013
1069
  }
@@ -27,7 +27,9 @@ function namespaceMetadata(metadata, signal) {
27
27
  abort();
28
28
  });
29
29
  }
30
- async function scanUsedBytes(fs, limits, options, change, retained = change?.retained === true) {
30
+ async function scanUsedBytes(fs, limits, options, changeInput, retained = typeof changeInput !== "function" && changeInput?.retained === true) {
31
+ let change = typeof changeInput === "function" ? undefined : changeInput;
32
+ let prepare = typeof changeInput === "function" ? changeInput : undefined;
31
33
  let total = 0;
32
34
  let retainedTotal = 0n;
33
35
  let shrinkCredited = false;
@@ -40,12 +42,30 @@ async function scanUsedBytes(fs, limits, options, change, retained = change?.ret
40
42
  if (!directory)
41
43
  break;
42
44
  const signal = options?.signal;
43
- const listing = fs.readdir(directory.path, { ...options, ...(signal ? { signal } : {}), maxEntries: remaining });
45
+ const listing = fs.readdir(directory.path, { ...options, ...(signal ? { signal } : {}), ...(remaining === Infinity ? {} : { maxEntries: remaining }) });
44
46
  const entries = await (retained ? namespaceMetadata(listing, signal) : listing);
45
47
  options?.signal?.throwIfAborted();
46
48
  const count = entries.length;
47
49
  admitDirectoryEntries(count, remaining, directory.path);
48
50
  remaining -= count;
51
+ if (prepare) {
52
+ change = await prepare();
53
+ prepare = undefined;
54
+ }
55
+ if (change?.stat.type === "directory") {
56
+ const stat = await fs.stat(directory.path, options);
57
+ const comparable = completeIdentity(stat) && completeIdentity(change.stat);
58
+ const comparison = comparable
59
+ ? stat.identityScope === change.stat.identityScope && stat.dev === change.stat.dev && stat.ino === change.stat.ino ? "same" : "distinct"
60
+ : fs.compareEntry ? await fs.compareEntry(change.path, fs, directory.path, options) : "unknown";
61
+ options?.signal?.throwIfAborted();
62
+ if (comparison !== "same" && comparison !== "distinct" && comparison !== "unknown")
63
+ throw new FsError("EIO", { syscall: "compareEntry", path: change.path });
64
+ if (comparison !== "distinct") {
65
+ total += Math.max(0, change.delta);
66
+ possibleAliases++;
67
+ }
68
+ }
49
69
  let processed = 0;
50
70
  for (const entry of entries) {
51
71
  options?.signal?.throwIfAborted();
@@ -69,7 +89,7 @@ async function scanUsedBytes(fs, limits, options, change, retained = change?.ret
69
89
  if (!Number.isSafeInteger(stat.size) || stat.size < 0)
70
90
  throw new FsError("EIO", { syscall: "lstat", path, message: "invalid quota entry size" });
71
91
  retainedTotal += BigInt(stat.size);
72
- if (!change || stat.type !== "file")
92
+ if (!change || change.stat.type !== "file" || stat.type !== "file")
73
93
  continue;
74
94
  const known = completeIdentity(stat);
75
95
  const same = known && change.stat.identityScope === stat.identityScope && change.stat.dev === stat.dev && change.stat.ino === stat.ino;
@@ -89,7 +109,7 @@ async function scanUsedBytes(fs, limits, options, change, retained = change?.ret
89
109
  continue;
90
110
  }
91
111
  total += stat.size;
92
- if (!change || stat.type !== "file")
112
+ if (!change || change.stat.type !== "file" || stat.type !== "file")
93
113
  continue;
94
114
  const scope = change.stat.identityScope;
95
115
  const comparable = [scope, stat.identityScope].every(value => typeof value === "symbol" || typeof value === "object" && value !== null)
@@ -136,11 +156,11 @@ export function withFileSystemQuota(fs, options) {
136
156
  if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 0)
137
157
  throw new RangeError("maxBytes must be a nonnegative safe integer");
138
158
  const scanLimits = {
139
- maxScanEntries: options.maxScanEntries === undefined ? 4096 : options.maxScanEntries,
140
- maxScanDepth: options.maxScanDepth === undefined ? 64 : options.maxScanDepth,
159
+ maxScanEntries: options.maxScanEntries ?? Infinity,
160
+ maxScanDepth: options.maxScanDepth ?? Infinity,
141
161
  };
142
- for (const [name, value] of Object.entries(scanLimits)) {
143
- if (!Number.isSafeInteger(value) || value < 0)
162
+ for (const [name, value] of Object.entries({ maxScanEntries: options.maxScanEntries, maxScanDepth: options.maxScanDepth })) {
163
+ if (value !== undefined && (!Number.isSafeInteger(value) || value < 0))
144
164
  throw new RangeError(`${name} must be a nonnegative safe integer`);
145
165
  }
146
166
  let queue = Promise.resolve();
@@ -169,6 +189,35 @@ export function withFileSystemQuota(fs, options) {
169
189
  return typeof original === "function" ? original.bind(fs) : original;
170
190
  },
171
191
  });
192
+ const creationChange = async (path, delta, fsOptions) => {
193
+ // Resolve existing ancestors before comparing directory identities. A dangling
194
+ // final symlink cannot safely identify its publication directory here.
195
+ let target;
196
+ try {
197
+ target = fs.canonicalizeMissingTarget?.(path, fsOptions) ?? path;
198
+ }
199
+ catch (error) {
200
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
201
+ throw new FileSystemQuotaError(options.maxBytes);
202
+ throw error;
203
+ }
204
+ try {
205
+ const entry = fs.canonicalizeMissingTarget ? undefined : await fs.lstat(target, fsOptions);
206
+ if (entry?.type === "symlink")
207
+ throw new FsError("ENOTSUP", { syscall: "write", path, message: "quota cannot locate missing symlink referent" });
208
+ }
209
+ catch (error) {
210
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
211
+ throw error;
212
+ }
213
+ const boundary = target.lastIndexOf("/");
214
+ const parent = await fs.realpath(boundary <= 0 ? "/" : target.slice(0, boundary), fsOptions);
215
+ const stat = await fs.stat(parent, fsOptions);
216
+ fsOptions?.signal?.throwIfAborted();
217
+ if (stat.type !== "directory")
218
+ throw new FsError("ENOTDIR", { syscall: "write", path });
219
+ return { path: parent, stat, delta };
220
+ };
172
221
  const assertDelta = async (path, nextBytes, fsOptions) => {
173
222
  fsOptions?.signal?.throwIfAborted();
174
223
  let current;
@@ -181,7 +230,9 @@ export function withFileSystemQuota(fs, options) {
181
230
  }
182
231
  const projected = current?.type === "file" && nextBytes > current.size
183
232
  ? await usedBytes(fs, scanLimits, fsOptions, { path, stat: current, delta: nextBytes - current.size })
184
- : await usedBytes(fs, scanLimits, fsOptions) - (current?.type === "directory" ? 0 : current?.size ?? 0) + nextBytes;
233
+ : !current && nextBytes > 0
234
+ ? await usedBytes(fs, scanLimits, fsOptions, () => creationChange(path, nextBytes, fsOptions))
235
+ : await usedBytes(fs, scanLimits, fsOptions) - (current?.type === "directory" ? 0 : current?.size ?? 0) + nextBytes;
185
236
  fsOptions?.signal?.throwIfAborted();
186
237
  if (projected > options.maxBytes)
187
238
  throw new FileSystemQuotaError(options.maxBytes);
@@ -189,11 +240,11 @@ export function withFileSystemQuota(fs, options) {
189
240
  const mutations = {
190
241
  open(path, openOptions) {
191
242
  return openFileDescriptor(path, openOptions, {
192
- positionedRead: true, positionedWrite: true, truncate: true, synchronization: "storage",
243
+ noFollow: true, positionedRead: true, positionedWrite: true, truncate: true, synchronization: "storage",
193
244
  }, admitted => mutate(async () => {
194
245
  admitted.signal?.throwIfAborted();
195
246
  const open = fs.open;
196
- const capabilities = admitted.creation === "exclusive" ? fs.capabilities : await fs.capabilitiesFor?.(path, admitted) ?? fs.capabilities;
247
+ const capabilities = admitted.noFollow || admitted.creation === "exclusive" ? fs.capabilities : await fs.capabilitiesFor?.(path, admitted) ?? fs.capabilities;
197
248
  admitted.signal?.throwIfAborted();
198
249
  if (!open || capabilities.open === false)
199
250
  throw new FsError("ENOTSUP", { syscall: "open", path });
@@ -205,8 +256,15 @@ export function withFileSystemQuota(fs, options) {
205
256
  const conditional = descriptor.capabilities.publication === "conditional" && pinned !== undefined;
206
257
  let publishedSize = pinned?.size ?? 0;
207
258
  let reserved = 0;
259
+ let publicationAliases = 1;
260
+ if (conditional) {
261
+ const baseline = await usedBytes(fs, scanLimits, admitted);
262
+ publicationAliases = await usedBytes(fs, scanLimits, admitted, () => creationChange(path, 1, admitted)) - baseline;
263
+ if (!Number.isSafeInteger(publicationAliases) || publicationAliases < 1)
264
+ throw new FsError("ENOTSUP", { syscall: "open", path, message: "quota cannot determine publication aliases" });
265
+ }
208
266
  const reserve = (size) => {
209
- const next = Math.max(0, size - publishedSize);
267
+ const next = Math.max(0, size - publishedSize) * publicationAliases;
210
268
  reservedBytes += next - reserved;
211
269
  reserved = next;
212
270
  };
@@ -231,13 +289,13 @@ export function withFileSystemQuota(fs, options) {
231
289
  if (!Number.isSafeInteger(nextBytes) || nextBytes < 0)
232
290
  throw new FsError("EFBIG", { syscall: "write", path });
233
291
  const projected = conditional
234
- ? await usedBytes(fs, scanLimits, operationOptions) - reserved + Math.max(0, nextBytes - publishedSize)
292
+ ? await usedBytes(fs, scanLimits, operationOptions) - reserved + Math.max(0, nextBytes - publishedSize) * publicationAliases
235
293
  : await usedBytes(fs, scanLimits, operationOptions, { path, stat, delta: nextBytes - stat.size, retained: true });
236
294
  operationOptions.signal?.throwIfAborted();
237
295
  if (projected > options.maxBytes)
238
296
  throw new FileSystemQuotaError(options.maxBytes);
239
297
  if (conditional)
240
- reserve(Math.max(nextBytes, publishedSize + reserved));
298
+ reserve(Math.max(nextBytes, publishedSize + reserved / publicationAliases));
241
299
  };
242
300
  const forwarded = forwardFileDescriptor(descriptor, (syscall, operationOptions, action) => syscall === "close" && !conditional ? action() : mutate(async () => {
243
301
  try {
@@ -389,15 +447,17 @@ export function withFileSystemQuota(fs, options) {
389
447
  await fs.appendFile(path, data, appendOptions);
390
448
  });
391
449
  },
392
- async rename(source, destination, renameOptions) {
393
- if (renameOptions?.noReplace) {
394
- renameOptions.signal?.throwIfAborted();
395
- const capabilities = await fs.capabilitiesFor?.(destination, renameOptions) ?? fs.capabilities;
396
- renameOptions?.signal?.throwIfAborted();
397
- if (capabilities.atomicRenameNoReplace !== true)
398
- throw new FsError("ENOTSUP", { syscall: "rename", path: source, dest: destination });
399
- }
400
- await fs.rename(source, destination, renameOptions);
450
+ rename(source, destination, renameOptions) {
451
+ return mutate(async () => {
452
+ if (renameOptions?.noReplace) {
453
+ renameOptions.signal?.throwIfAborted();
454
+ const capabilities = await fs.capabilitiesFor?.(destination, renameOptions) ?? fs.capabilities;
455
+ renameOptions.signal?.throwIfAborted();
456
+ if (capabilities.atomicRenameNoReplace !== true)
457
+ throw new FsError("ENOTSUP", { syscall: "rename", path: source, dest: destination });
458
+ }
459
+ await fs.rename(source, destination, renameOptions);
460
+ });
401
461
  },
402
462
  copyFile(source, destination, copyOptions) {
403
463
  return mutate(async () => {
@@ -11,9 +11,10 @@ function readOnly(syscall, path, dest) {
11
11
  throw new FsError("EROFS", { syscall, path, ...(dest === undefined ? {} : { dest }) });
12
12
  }
13
13
  function snapshotStat(stat) {
14
- const { type, size, allocatedBytes, ioBlockSize, preferredIoBlockSize, mode, mtimeMs, atimeMs, ctimeMs, birthtimeMs, revision, identityScope, opaqueIdentity, opaqueVersion, ino, dev, rdevMajor, rdevMinor, nlink, uid, gid } = stat;
14
+ const { type, filesystemType, size, allocatedBytes, ioBlockSize, preferredIoBlockSize, mode, mtimeMs, atimeMs, ctimeMs, birthtimeMs, revision, identityScope, opaqueIdentity, opaqueVersion, ino, dev, rdevMajor, rdevMinor, nlink, uid, gid } = stat;
15
15
  return {
16
16
  type, size, mode, mtimeMs, atimeMs, ctimeMs,
17
+ ...(filesystemType === undefined ? {} : { filesystemType }),
17
18
  ...(revision === undefined ? {} : { revision }),
18
19
  ...(allocatedBytes === undefined ? {} : { allocatedBytes }),
19
20
  ...(ioBlockSize === undefined ? {} : { ioBlockSize }),
@@ -72,9 +73,10 @@ export class ReadOnlyFileSystem {
72
73
  || options.creation === "ifMissing" || options.creation === "exclusive"))
73
74
  readOnly("open", path);
74
75
  return openFileDescriptor(path, options, {
75
- positionedRead: true, positionedWrite: false, truncate: false, synchronization: "none",
76
+ noFollow: true, positionedRead: true, positionedWrite: false, truncate: false, synchronization: "none",
76
77
  }, async (admitted) => {
77
- const capabilities = await this.#filesystem.capabilitiesFor?.(path, admitted) ?? this.#filesystem.capabilities;
78
+ const capabilities = admitted.noFollow ? this.#filesystem.capabilities
79
+ : await this.#filesystem.capabilitiesFor?.(path, admitted) ?? this.#filesystem.capabilities;
78
80
  if (!this.#filesystem.open || capabilities.open === false)
79
81
  throw new FsError("ENOTSUP", { syscall: "open", path });
80
82
  admitted.signal?.throwIfAborted();
@@ -1,8 +1,16 @@
1
1
  import type { FileDescriptor, OpenFileOptions } from "../../contracts/descriptor.js";
2
- import type { AppendFileOptions, ByteSource, CopyFileOptions, DirectoryEntry, FileReadHandle, FileResizeHandle, FileStat, FileSystem, FileSystemCapabilities, FsOptions, RenameOptions, MkdirOptions, OpenReadFileOptions, OpenResizeFileOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, WriteFileOptions } from "../../contracts/index.js";
2
+ import type { AppendFileOptions, ByteSource, CopyFileOptions, DirectoryEntry, FileReadHandle, FileResizeHandle, FileStat, FileSystem, FileSystemCapabilities, FsOptions, RenameOptions, MkdirOptions, ConditionalWriteFileOptions, ConditionalRemoveFileOptions, CreateStagedFileOptions, FileStaging, PublishStagedFileOptions, PrepareDirectoryOptions, StagedFileContent, OpenReadFileOptions, OpenResizeFileOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, WriteFileOptions } from "../../contracts/index.js";
3
3
  export interface RealFileSystemOptions {
4
4
  /** An existing, absolute host directory. It is never created implicitly. */
5
5
  readonly root: string;
6
+ /**
7
+ * Trusted host primitive for an atomic no-replace move (e.g. renameat2 with
8
+ * RENAME_NOREPLACE). Receives resolved absolute host paths. Must reject EEXIST
9
+ * for any existing entry, including dangling symlinks, without mutation.
10
+ * Never implement with an existence check, ordinary rename, or copy/delete.
11
+ * Unsupported filesystems must reject ENOTSUP; cross-device moves reject EXDEV.
12
+ */
13
+ readonly renameNoReplace?: (source: string, destination: string, options: FsOptions) => Promise<void>;
6
14
  }
7
15
  /**
8
16
  * Async byte filesystem for trusted POSIX hosts (Windows is unsupported).
@@ -42,6 +50,7 @@ export interface RealFileSystemOptions {
42
50
  export declare class RealFileSystem implements FileSystem {
43
51
  readonly capabilities: FileSystemCapabilities;
44
52
  private readonly configuredRoot;
53
+ private readonly renameNoReplace;
45
54
  private rootPromise;
46
55
  constructor(options: RealFileSystemOptions | string);
47
56
  private root;
@@ -50,6 +59,17 @@ export declare class RealFileSystem implements FileSystem {
50
59
  private path;
51
60
  private operation;
52
61
  private protectRoot;
62
+ private stagingSnapshot;
63
+ private expectStaging;
64
+ private stagingMetadata;
65
+ createStagedFile(directoryPath: string, name: string, content: StagedFileContent, options: CreateStagedFileOptions): Promise<FileStaging>;
66
+ private stagingPaths;
67
+ private checkStaging;
68
+ publishStagedFile(staging: FileStaging, destination: string, options: PublishStagedFileOptions): Promise<void>;
69
+ removeStagedFile(staging: FileStaging, options?: FsOptions): Promise<void>;
70
+ writeFileConditional(path: string, data: Uint8Array, options: ConditionalWriteFileOptions): Promise<FileStat>;
71
+ removeFileConditional(path: string, options: ConditionalRemoveFileOptions): Promise<void>;
72
+ prepareDirectory(path: string, options: PrepareDirectoryOptions): Promise<FileStat>;
53
73
  open(path: string, options: OpenFileOptions): Promise<FileDescriptor>;
54
74
  private protectTerminal;
55
75
  readFile(path: string, options?: ReadFileOptions): Promise<Uint8Array>;