@poe-platform/safe-fs 0.1.727 → 0.1.729

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 +15 -8
  2. package/dist/safe-fs/contracts/errors.js +8 -1
  3. package/dist/safe-fs/contracts/filesystem.d.ts +68 -2
  4. package/dist/safe-fs/contracts/io.js +210 -28
  5. package/dist/safe-fs/contracts/virtual-path.d.ts +3 -1
  6. package/dist/safe-fs/contracts/virtual-path.js +20 -1
  7. package/dist/safe-fs/core.d.ts +1 -0
  8. package/dist/safe-fs/core.js +1 -0
  9. package/dist/safe-fs/fs/capabilities.d.ts +1 -1
  10. package/dist/safe-fs/fs/capabilities.js +59 -21
  11. package/dist/safe-fs/fs/conditional-chmod.d.ts +7 -0
  12. package/dist/safe-fs/fs/conditional-chmod.js +19 -0
  13. package/dist/safe-fs/fs/descriptor.js +51 -25
  14. package/dist/safe-fs/fs/devices/index.d.ts +7 -2
  15. package/dist/safe-fs/fs/devices/index.js +210 -29
  16. package/dist/safe-fs/fs/devices/path.d.ts +3 -1
  17. package/dist/safe-fs/fs/devices/path.js +70 -12
  18. package/dist/safe-fs/fs/memory/index.d.ts +70 -48
  19. package/dist/safe-fs/fs/memory/index.js +1595 -244
  20. package/dist/safe-fs/fs/memory/ledger.d.ts +8 -1
  21. package/dist/safe-fs/fs/memory/ledger.js +46 -6
  22. package/dist/safe-fs/fs/memory/missing-target.js +21 -1
  23. package/dist/safe-fs/fs/mount/index.d.ts +11 -3
  24. package/dist/safe-fs/fs/mount/index.js +326 -15
  25. package/dist/safe-fs/fs/object-publication/index.js +79 -3
  26. package/dist/safe-fs/fs/overlay/index.d.ts +2 -2
  27. package/dist/safe-fs/fs/overlay/index.js +27 -5
  28. package/dist/safe-fs/fs/overlay/memory-publication.d.ts +2 -1
  29. package/dist/safe-fs/fs/overlay/memory-publication.js +39 -2
  30. package/dist/safe-fs/fs/quota/index.js +5 -5
  31. package/dist/safe-fs/fs/readonly/index.d.ts +1 -0
  32. package/dist/safe-fs/fs/readonly/index.js +13 -0
  33. package/dist/safe-fs/fs/real/index.d.ts +6 -2
  34. package/dist/safe-fs/fs/real/index.js +78 -8
  35. package/dist/safe-fs/fs/s3/filesystem.d.ts +3 -0
  36. package/dist/safe-fs/fs/s3/filesystem.js +57 -25
  37. package/dist/safe-fs/fs/s3/http/transport.js +20 -3
  38. package/dist/safe-fs/fs/s3/mock.js +19 -2
  39. package/dist/safe-fs/fs/s3/namespace.js +8 -2
  40. package/dist/safe-fs/fs/s3/registry.js +2 -0
  41. package/dist/safe-fs/fs/scoped.d.ts +1 -0
  42. package/dist/safe-fs/fs/scoped.js +261 -74
  43. package/dist/safe-fs/fs/staging-ancestry.d.ts +8 -0
  44. package/dist/safe-fs/fs/staging-ancestry.js +115 -0
  45. package/dist/safe-fs/fs/staging-cleanup.d.ts +5 -0
  46. package/dist/safe-fs/fs/staging-cleanup.js +63 -0
  47. package/dist/safe-fs/fs/webdav/webdav.js +7 -5
  48. package/dist/safe-fs/platform/browser.d.ts +5 -0
  49. package/dist/safe-fs/platform/browser.js +17 -0
  50. package/dist/safe-fs/platform/node.d.ts +5 -0
  51. package/dist/safe-fs/platform/node.js +28 -0
  52. package/dist/safe-fs/platform/transport-budget.d.ts +7 -0
  53. package/dist/safe-fs/platform/transport-budget.js +4 -0
  54. package/dist/safe-fs/testing/object-io-metrics.d.ts +3 -0
  55. package/dist/safe-fs/testing/object-io-metrics.js +29 -0
  56. package/dist/safe-fs/testing/object-publication.d.ts +3 -1
  57. package/dist/safe-fs/testing/object-publication.js +2 -2
  58. package/package.json +1 -1
@@ -36,7 +36,9 @@ function hasCode(error, code) {
36
36
  function fail(code, path, message) {
37
37
  throw new FsError(code, { syscall: "overlay", path, ...(message === undefined ? {} : { message }) });
38
38
  }
39
- function integer(value, path) {
39
+ function integer(value, path, unlimited = false) {
40
+ if (unlimited && value === Infinity)
41
+ return;
40
42
  if (!Number.isSafeInteger(value) || value < 0)
41
43
  fail("EINVAL", path);
42
44
  }
@@ -86,7 +88,7 @@ export class OverlayFileSystem {
86
88
  }, false));
87
89
  this.maxBufferBytes = options.maxBufferBytes ?? Infinity;
88
90
  if (options.maxBufferBytes !== undefined)
89
- integer(options.maxBufferBytes, "/");
91
+ integer(options.maxBufferBytes, "/", true);
90
92
  const writable = !this.#upper.capabilities.readOnly && this.#upper.capabilities.atomicRename === true;
91
93
  const readable = [this.#upper, this.#lower].map((backend) => retainedReadCapabilities(backend).retainedRead);
92
94
  const streamingRead = readable.every((capability) => capability === true) ? true
@@ -123,7 +125,8 @@ export class OverlayFileSystem {
123
125
  this.capabilities = Object.freeze({
124
126
  ...semantics,
125
127
  open: false,
126
- atomicFilePublication: false, atomicFileMutation: false, atomicFileStaging: this.publication.supported(), atomicStagingAncestry: this.publication.supported(), atomicDirectoryMetadata: false, trustedOwnedStaging: false,
128
+ conditionalChmod: this.publication.supported(),
129
+ atomicFilePublication: false, atomicFileMutation: false, atomicEntryRemoval: false, atomicEntryRemovalReceipt: false, atomicFileStaging: this.publication.supported(), atomicStagingAncestry: this.publication.supported(), atomicDirectoryMetadata: false, trustedOwnedStaging: false,
127
130
  implicitDirectories: false,
128
131
  readlink: upper.readlink === true && this.#lower.capabilities.readlink === true ? true
129
132
  : upper.readlink === false && this.#lower.capabilities.readlink === false ? false : undefined,
@@ -519,7 +522,7 @@ export class OverlayFileSystem {
519
522
  fail("EISDIR", entry.path);
520
523
  this.permission(entry, 4);
521
524
  if (options.maxBytes !== undefined)
522
- integer(options.maxBytes, entry.path);
525
+ integer(options.maxBytes, entry.path, true);
523
526
  const limit = Math.min(options.maxBytes ?? this.maxBufferBytes, this.maxBufferBytes);
524
527
  if (entry.stat.size > limit)
525
528
  fail("EFBIG", entry.path);
@@ -683,13 +686,14 @@ export class OverlayFileSystem {
683
686
  }
684
687
  return location;
685
688
  }
686
- async replace(location, options, operation, streaming = false) {
689
+ async replace(location, options, operation, streaming = false, commitGuard) {
687
690
  await this.parent(location.path, options);
688
691
  await this.staged(options, async (temporary) => {
689
692
  if (location.entry)
690
693
  await this.clone(location.entry, temporary, options, streaming);
691
694
  await operation(temporary);
692
695
  options.signal?.throwIfAborted();
696
+ commitGuard?.();
693
697
  await this.#upper.rename(temporary, location.path, options);
694
698
  this.linkMetadata.delete(location.path);
695
699
  this.linkOrigins.delete(location.path);
@@ -1003,6 +1007,24 @@ export class OverlayFileSystem {
1003
1007
  if (permissions === undefined)
1004
1008
  fail("EINVAL", path);
1005
1009
  mode(permissions, path);
1010
+ const guard = this.publication.prepareChmod(path, options);
1011
+ if (guard) {
1012
+ const controls = options.signal === undefined ? {} : { signal: options.signal };
1013
+ return this.run(controls, async () => {
1014
+ this.writable(path);
1015
+ guard();
1016
+ const entry = await this.required(path, controls, false);
1017
+ guard();
1018
+ if (entry.stat.type === "directory") {
1019
+ await this.copyUp(entry, controls);
1020
+ guard();
1021
+ await this.#upper.chmod(entry.path, permissions, controls);
1022
+ }
1023
+ else {
1024
+ await this.replace({ path: entry.path, entry }, controls, temporary => this.#upper.chmod(temporary, permissions, controls), false, guard);
1025
+ }
1026
+ });
1027
+ }
1006
1028
  return this.metadata(path, "permissions", options, (target) => this.#upper.chmod(target, permissions, options));
1007
1029
  }
1008
1030
  async utimes(path, atimeMs, mtimeMs, options = {}) {
@@ -1,4 +1,4 @@
1
- import type { CreateStagedFileOptions, FileStaging, FileStat, FileSystem, FsOptions, PublishStagedFileOptions, StagedFileContent } from "../../contracts/filesystem.js";
1
+ import type { CreateStagedFileOptions, FileStaging, FileStat, FileSystem, FsOptions, ChmodOptions, PublishStagedFileOptions, StagedFileContent } from "../../contracts/filesystem.js";
2
2
  /** Stock Memory mutations commit before returning their promise. No user code or
3
3
  * await is allowed between inspecting the two stores and invoking a mutation. */
4
4
  export declare class OverlayMemoryPublication {
@@ -26,6 +26,7 @@ export declare class OverlayMemoryPublication {
26
26
  private inspect;
27
27
  private capture;
28
28
  private check;
29
+ prepareChmod(path: string, options: ChmodOptions): (() => true) | undefined;
29
30
  private copyParents;
30
31
  create(directory: string, name: string, content: StagedFileContent, options: CreateStagedFileOptions): Promise<FileStaging>;
31
32
  publish(staging: FileStaging, path: string, options: PublishStagedFileOptions): Promise<void>;
@@ -2,6 +2,8 @@ import { FsError, isFsError } from "../../contracts/errors.js";
2
2
  import { dirname, isPathWithin, validatePath } from "../../contracts/virtual-path.js";
3
3
  import { memoryAtomicView } from "../memory/atomic-view.js";
4
4
  import { compareIdentity } from "../mount/identity.js";
5
+ import { snapshotConditionalChmod } from "../conditional-chmod.js";
6
+ import { runStagingGuard } from "../staging-ancestry.js";
5
7
  /** Stock Memory mutations commit before returning their promise. No user code or
6
8
  * await is allowed between inspecting the two stores and invoking a mutation. */
7
9
  export class OverlayMemoryPublication {
@@ -21,10 +23,10 @@ export class OverlayMemoryPublication {
21
23
  this.opaque = opaque;
22
24
  this.hidden = hidden;
23
25
  }
24
- supported() { return !!memoryAtomicView(this.upper) && !!memoryAtomicView(this.lower); }
26
+ supported() { return this.upper.capabilities.readOnly !== true && !!memoryAtomicView(this.upper) && !!memoryAtomicView(this.lower); }
25
27
  stores() {
26
28
  const upper = memoryAtomicView(this.upper), lower = memoryAtomicView(this.lower);
27
- if (!upper || !lower)
29
+ if (!upper || !lower || this.upper.capabilities.readOnly === true)
28
30
  throw new FsError("ENOTSUP", { syscall: "overlayPublication" });
29
31
  return { upper, lower };
30
32
  }
@@ -138,6 +140,41 @@ export class OverlayMemoryPublication {
138
140
  throw new FsError("ENOTDIR", { path: entry.path });
139
141
  }
140
142
  }
143
+ prepareChmod(path, options) {
144
+ const { signal, parent, expected, ancestors, commitGuard } = options;
145
+ const sources = ancestors?.map(entry => ({ path: entry.path, stat: entry.stat }));
146
+ const captured = snapshotConditionalChmod(path, {
147
+ ...(signal === undefined ? {} : { signal }), ...(parent === undefined ? {} : { parent }),
148
+ ...(expected === undefined ? {} : { expected }), ...(sources === undefined ? {} : { ancestors: sources }),
149
+ ...(commitGuard === undefined ? {} : { commitGuard }),
150
+ });
151
+ if (!captured)
152
+ return undefined;
153
+ this.stores();
154
+ this.path(path);
155
+ const retain = (source, snapshot) => {
156
+ const receipt = this.receipts.get(source);
157
+ if (!receipt)
158
+ throw new FsError("ENOTSUP", { path, message: "unowned overlay chmod receipt" });
159
+ this.receipts.set(snapshot, receipt);
160
+ };
161
+ retain(parent, captured.parent);
162
+ retain(expected, captured.expected);
163
+ for (const [index, entry] of captured.ancestors.entries())
164
+ retain(sources[index].stat, entry.stat);
165
+ return () => {
166
+ signal?.throwIfAborted();
167
+ if (captured.commitGuard)
168
+ runStagingGuard(captured.commitGuard);
169
+ this.stores();
170
+ this.check(captured.ancestors);
171
+ this.expect(dirname(path), captured.parent);
172
+ this.expect(path, captured.expected, captured.expected.type !== "directory");
173
+ if (this.inspect(path)?.type !== captured.expected.type)
174
+ throw new FsError("EAGAIN", { path });
175
+ return true;
176
+ };
177
+ }
141
178
  async copyParents(entries, options) {
142
179
  for (const entry of entries) {
143
180
  options.signal?.throwIfAborted();
@@ -153,15 +153,15 @@ async function existingBytes(fs, path, options) {
153
153
  }
154
154
  }
155
155
  export function withFileSystemQuota(fs, options) {
156
- if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 0)
157
- throw new RangeError("maxBytes must be a nonnegative safe integer");
156
+ if (options.maxBytes !== Infinity && (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 0))
157
+ throw new RangeError("maxBytes must be a nonnegative safe integer or Infinity");
158
158
  const scanLimits = {
159
159
  maxScanEntries: options.maxScanEntries ?? Infinity,
160
160
  maxScanDepth: options.maxScanDepth ?? Infinity,
161
161
  };
162
162
  for (const [name, value] of Object.entries({ maxScanEntries: options.maxScanEntries, maxScanDepth: options.maxScanDepth })) {
163
- if (value !== undefined && (!Number.isSafeInteger(value) || value < 0))
164
- throw new RangeError(`${name} must be a nonnegative safe integer`);
163
+ if (value !== undefined && value !== Infinity && (!Number.isSafeInteger(value) || value < 0))
164
+ throw new RangeError(`${name} must be a nonnegative safe integer or Infinity`);
165
165
  }
166
166
  let queue = Promise.resolve();
167
167
  let reservedBytes = 0;
@@ -499,7 +499,7 @@ export function withFileSystemQuota(fs, options) {
499
499
  // capabilities and methods without violating invariants on own properties.
500
500
  return new Proxy(Object.create(fs), {
501
501
  get(_target, property) {
502
- if (property === "publishFileConditional" || property === "removeEntryConditional" || property === "writeFileConditional" || property === "removeFileConditional" || property === "resizeFile" || property === "canonicalizeMissingTarget" || property === "createStagedFile" || property === "publishStagedFile" || property === "removeStagedFile" || property === "prepareDirectory")
502
+ if (property === "prepareStagingResolution" || property === "prepareDirectoryAncestry" || property === "publishFileConditional" || property === "removeEntryConditional" || property === "writeFileConditional" || property === "removeFileConditional" || property === "resizeFile" || property === "canonicalizeMissingTarget" || property === "createStagedFile" || property === "publishStagedFile" || property === "removeStagedFile" || property === "prepareDirectory")
503
503
  return undefined;
504
504
  if (property === "capabilities")
505
505
  return quotaCapabilities({ ...retainedResizeCapabilities(fs, retainedReadCapabilities(fs)), ...(typeof fs.open === "function" ? {} : { open: false }) });
@@ -3,6 +3,7 @@ import type { AppendFileOptions, CapabilityQueryOptions, CopyFileOptions, Direct
3
3
  import type { FileDescriptor, OpenFileOptions } from "../../contracts/descriptor.js";
4
4
  export declare class ReadOnlyFileSystem implements FileSystem {
5
5
  #private;
6
+ readonly removeEntryConditional?: NonNullable<FileSystem["removeEntryConditional"]>;
6
7
  constructor(filesystem: FileSystem);
7
8
  get capabilities(): FileSystemCapabilities;
8
9
  capabilitiesFor(path: string, options?: CapabilityQueryOptions): Promise<FileSystemCapabilities>;
@@ -7,6 +7,7 @@ import { openRetainedReadFile, readOnlyCapabilities, retainedReadCapabilities }
7
7
  import { admitDirectoryEntries, directoryEntryLimit } from "../directory-admission.js";
8
8
  import { forwardFileDescriptor, openFileDescriptor } from "../descriptor.js";
9
9
  import { pathNamespace, readOnlyPathNamespace } from "../path-namespace.js";
10
+ import { memoryAtomicView, registerMemoryAtomicView } from "../memory/atomic-view.js";
10
11
  function readOnly(syscall, path, dest) {
11
12
  throw new FsError("EROFS", { syscall, path, ...(dest === undefined ? {} : { dest }) });
12
13
  }
@@ -37,6 +38,17 @@ export class ReadOnlyFileSystem {
37
38
  #capabilities;
38
39
  constructor(filesystem) {
39
40
  this.#filesystem = filesystem;
41
+ const atomic = memoryAtomicView(filesystem);
42
+ if (atomic)
43
+ registerMemoryAtomicView(this, atomic, () => {
44
+ if (Object.getPrototypeOf(this) !== ReadOnlyFileSystem.prototype || memoryAtomicView(filesystem) !== atomic)
45
+ return false;
46
+ return Object.entries(readOnlyImplementation).every(([name, expected]) => {
47
+ const actual = Object.getOwnPropertyDescriptor(this, name)
48
+ ?? Object.getOwnPropertyDescriptor(ReadOnlyFileSystem.prototype, name);
49
+ return actual?.value === expected.value && actual?.get === expected.get && actual?.set === expected.set;
50
+ });
51
+ });
40
52
  if (Reflect.has(filesystem, pathNamespace))
41
53
  Object.defineProperty(this, pathNamespace, {
42
54
  get: () => readOnlyPathNamespace(Reflect.get(filesystem, pathNamespace)),
@@ -190,6 +202,7 @@ export class ReadOnlyFileSystem {
190
202
  readOnly("openResizeFile", path);
191
203
  }
192
204
  }
205
+ const readOnlyImplementation = Object.getOwnPropertyDescriptors(ReadOnlyFileSystem.prototype);
193
206
  export function createReadOnlyFileSystem(filesystem) {
194
207
  return new ReadOnlyFileSystem(filesystem);
195
208
  }
@@ -1,5 +1,5 @@
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, ConditionalWriteFileOptions, ConditionalRemoveFileOptions, CreateStagedFileOptions, FileStaging, PublishStagedFileOptions, PrepareDirectoryOptions, StagedFileContent, 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, ChmodOptions, FileStagingEntry, 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;
@@ -36,6 +36,9 @@ export interface RealFileSystemOptions {
36
36
  * concurrent renames, mount changes, and preexisting hardlinks cannot be made
37
37
  * safe by these APIs. This is NOT a race-proof sandbox or an isolation boundary
38
38
  * against another process modifying the tree. Use an OS sandbox for that.
39
+ * Conditional chmod and synchronous ancestry validation use the same trusted,
40
+ * externally isolated tree boundary as owned staging. Their final checks and
41
+ * metadata commit do not yield to JavaScript; they do not prevent OS races.
39
42
  *
40
43
  * Only regular files, directories, and symlinks are represented. Permissions,
41
44
  * ownership, timestamp precision, case sensitivity, umask, and rename behavior
@@ -62,6 +65,7 @@ export declare class RealFileSystem implements FileSystem {
62
65
  private stagingSnapshot;
63
66
  private expectStaging;
64
67
  private stagingMetadata;
68
+ prepareDirectoryAncestry(ancestors: readonly FileStagingEntry[], options?: FsOptions): Promise<() => true>;
65
69
  createStagedFile(directoryPath: string, name: string, content: StagedFileContent, options: CreateStagedFileOptions): Promise<FileStaging>;
66
70
  private stagingPaths;
67
71
  private checkStaging;
@@ -89,7 +93,7 @@ export declare class RealFileSystem implements FileSystem {
89
93
  readlink(path: string, options?: FsOptions): Promise<string>;
90
94
  symlink(target: string, path: string, options?: FsOptions): Promise<void>;
91
95
  link(existingPath: string, newPath: string, options?: FsOptions): Promise<void>;
92
- chmod(path: string, mode: number, options?: FsOptions): Promise<void>;
96
+ chmod(path: string, mode: number, options?: ChmodOptions): Promise<void>;
93
97
  utimes(path: string, atimeMs: number, mtimeMs: number, options?: FsOptions): Promise<void>;
94
98
  truncate(path: string, length?: number, options?: FsOptions): Promise<void>;
95
99
  openResizeFile(path: string, options?: OpenResizeFileOptions): Promise<FileResizeHandle>;
@@ -1,3 +1,6 @@
1
+ import { snapshotStagingCreation } from "../staging-cleanup.js";
2
+ import { snapshotConditionalChmod } from "../conditional-chmod.js";
3
+ import { runStagingGuard, snapshotDirectoryAncestry } from "../staging-ancestry.js";
1
4
  import { constants } from "node:fs";
2
5
  import * as immediate from "node:fs";
3
6
  import * as native from "node:fs/promises";
@@ -79,6 +82,9 @@ function nativeError(error) {
79
82
  * concurrent renames, mount changes, and preexisting hardlinks cannot be made
80
83
  * safe by these APIs. This is NOT a race-proof sandbox or an isolation boundary
81
84
  * against another process modifying the tree. Use an OS sandbox for that.
85
+ * Conditional chmod and synchronous ancestry validation use the same trusted,
86
+ * externally isolated tree boundary as owned staging. Their final checks and
87
+ * metadata commit do not yield to JavaScript; they do not prevent OS races.
82
88
  *
83
89
  * Only regular files, directories, and symlinks are represented. Permissions,
84
90
  * ownership, timestamp precision, case sensitivity, umask, and rename behavior
@@ -98,6 +104,7 @@ export class RealFileSystem {
98
104
  rename: true, atomicRenameNoReplace: false, copy: true, exclusiveCopy: true, readlink: true, truncate: true,
99
105
  streamingAppend: true, randomAccessWrite: true,
100
106
  readOnly: false, symlinks: true, hardlinks: true, permissions: true,
107
+ conditionalChmod: true,
101
108
  timestamps: true, atomicRename: true, streamingRead: true, streamingWrite: true, retainedRead: true, retainedResize: true, trustedOwnedStaging: true,
102
109
  });
103
110
  configuredRoot;
@@ -244,18 +251,22 @@ export class RealFileSystem {
244
251
  throw new FsError("ENOENT");
245
252
  return this.walk(await this.root(options), path.split("/").map((name) => ({ name, fromLink: false })), options);
246
253
  }
247
- async operation(syscall, path, options, action, dest) {
254
+ async operation(syscall, path, options, action, dest, preserveReceipt = false) {
248
255
  options.signal?.throwIfAborted();
249
256
  try {
250
257
  validatePath(path);
251
258
  if (dest !== undefined)
252
259
  validatePath(dest);
253
260
  const result = await action();
254
- options.signal?.throwIfAborted();
261
+ if (!preserveReceipt)
262
+ options.signal?.throwIfAborted();
255
263
  return result;
256
264
  }
257
265
  catch (error) {
258
- options.signal?.throwIfAborted();
266
+ if (!preserveReceipt)
267
+ options.signal?.throwIfAborted();
268
+ else if (options.signal?.aborted && Object.is(error, options.signal.reason))
269
+ throw error;
259
270
  const converted = nativeError(error);
260
271
  throw new FsError(converted.code, {
261
272
  syscall, path, ...(dest === undefined ? {} : { dest }),
@@ -298,11 +309,37 @@ export class RealFileSystem {
298
309
  if (time !== undefined && (!Number.isFinite(time) || Math.abs(time) > 8.64e15))
299
310
  throw new FsError("EINVAL");
300
311
  }
312
+ async prepareDirectoryAncestry(ancestors, options = {}) {
313
+ const controls = options.signal === undefined ? {} : { signal: options.signal };
314
+ controls.signal?.throwIfAborted();
315
+ const entries = snapshotDirectoryAncestry(ancestors);
316
+ const path = entries.at(-1).path;
317
+ return this.operation("prepareDirectoryAncestry", path, controls, async () => {
318
+ const root = await this.root(controls);
319
+ const validate = () => {
320
+ controls.signal?.throwIfAborted();
321
+ try {
322
+ for (const entry of entries)
323
+ this.expectStaging(join(root, entry.path.slice(1)), entry.stat, true);
324
+ return true;
325
+ }
326
+ catch (error) {
327
+ controls.signal?.throwIfAborted();
328
+ throw new FsError(nativeError(error).code, { syscall: "prepareDirectoryAncestry", path });
329
+ }
330
+ };
331
+ validate();
332
+ return validate;
333
+ });
334
+ }
301
335
  async createStagedFile(directoryPath, name, content, options) {
336
+ options = snapshotStagingCreation(options, directoryPath);
302
337
  // Unlike operation(), this must return its receipt after commit even if the
303
338
  // signal is aborted before the promise settles.
304
339
  options.signal?.throwIfAborted();
305
340
  try {
341
+ if (options.retainCleanup === true)
342
+ throw new FsError("ENOTSUP");
306
343
  if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\0"))
307
344
  throw new FsError("EINVAL");
308
345
  this.stagingMetadata(options);
@@ -369,6 +406,9 @@ export class RealFileSystem {
369
406
  }
370
407
  async publishStagedFile(staging, destination, options) {
371
408
  return this.operation("publishStagedFile", staging.file.path, options, async () => {
409
+ options.signal?.throwIfAborted();
410
+ if (options.ancestors !== undefined || options.commitGuard !== undefined)
411
+ throw new FsError("ENOTSUP");
372
412
  const paths = await this.stagingPaths(staging, options);
373
413
  const target = await this.path(destination, { ...options, followFinal: false, missing: "final" });
374
414
  options.signal?.throwIfAborted();
@@ -377,12 +417,13 @@ export class RealFileSystem {
377
417
  this.expectStaging(paths.file, staging.file.stat);
378
418
  this.expectStaging(dirname(target), options.parent, true);
379
419
  const existing = this.expectStaging(target, options.destination);
380
- if (existing && (existing.type !== "file" || existing.nlink !== 1))
381
- throw new FsError("EAGAIN");
382
- if (target === paths.file || target === paths.directory || dirname(target) === paths.directory)
420
+ if (existing && existing.dev === staging.file.stat.dev && existing.ino === staging.file.stat.ino
421
+ || target === paths.file || target === paths.directory || dirname(target) === paths.directory)
383
422
  throw new FsError("EINVAL");
423
+ if (existing && existing.type !== "file")
424
+ throw new FsError("EAGAIN");
384
425
  immediate.renameSync(paths.file, target);
385
- }, destination);
426
+ }, destination, true);
386
427
  }
387
428
  async removeStagedFile(staging, options = {}) {
388
429
  return this.operation("removeStagedFile", staging.directory.path, options, async () => {
@@ -410,7 +451,7 @@ export class RealFileSystem {
410
451
  options.signal?.throwIfAborted();
411
452
  this.expectStaging(dirname(target), options.parent, true);
412
453
  const existing = this.expectStaging(target, options.expected);
413
- if (existing && (existing.type !== "file" || existing.nlink !== 1))
454
+ if (existing && existing.type !== "file")
414
455
  throw new FsError("EAGAIN");
415
456
  const fd = immediate.openSync(target, constants.O_WRONLY | constants.O_NOFOLLOW | (existing ? 0 : constants.O_CREAT | constants.O_EXCL), options.mode ?? 0o666);
416
457
  try {
@@ -427,6 +468,10 @@ export class RealFileSystem {
427
468
  }
428
469
  if (options.mode !== undefined)
429
470
  immediate.fchmodSync(fd, options.mode);
471
+ if (options.atimeMs !== undefined || options.mtimeMs !== undefined) {
472
+ const stat = immediate.fstatSync(fd);
473
+ immediate.futimesSync(fd, (options.atimeMs ?? stat.atimeMs) / 1000, (options.mtimeMs ?? stat.mtimeMs) / 1000);
474
+ }
430
475
  return fileStat(immediate.fstatSync(fd, { bigint: true }));
431
476
  }
432
477
  finally {
@@ -769,6 +814,31 @@ export class RealFileSystem {
769
814
  async chmod(path, mode, options = {}) {
770
815
  return this.operation("chmod", path, options, async () => {
771
816
  integer(mode);
817
+ const conditional = snapshotConditionalChmod(path, options);
818
+ if (conditional) {
819
+ const target = await this.path(path, { ...conditional, followFinal: false });
820
+ const root = await this.root(conditional);
821
+ if ((target === root ? "/" : `/${relative(root, target)}`) !== path)
822
+ throw new FsError("EAGAIN");
823
+ if (conditional.expected.type !== "file" && conditional.expected.type !== "directory")
824
+ throw new FsError("ENOTSUP");
825
+ const parent = target === root ? root : dirname(target);
826
+ const validate = () => {
827
+ conditional.signal?.throwIfAborted();
828
+ for (const entry of conditional.ancestors)
829
+ this.expectStaging(join(root, entry.path.slice(1)), entry.stat, true);
830
+ this.expectStaging(parent, conditional.parent, true);
831
+ const current = this.expectStaging(target, conditional.expected, conditional.expected.type === "directory");
832
+ if (current?.mode !== conditional.expected.mode)
833
+ throw new FsError("EAGAIN");
834
+ };
835
+ validate();
836
+ if (conditional.commitGuard)
837
+ runStagingGuard(conditional.commitGuard);
838
+ validate();
839
+ immediate.chmodSync(target, mode);
840
+ return;
841
+ }
772
842
  const target = await this.path(path, options);
773
843
  options.signal?.throwIfAborted();
774
844
  await native.chmod(target, mode);
@@ -14,6 +14,7 @@ export interface S3FileSystemOptions {
14
14
  readonly maxReadBytes?: number;
15
15
  readonly maxStreamBytes?: number;
16
16
  readonly maxListEntries?: number;
17
+ readonly maxRequests?: number;
17
18
  /** Aggregate budgets for each rm operation, including ancestor inspection. */
18
19
  readonly removalLimits?: {
19
20
  readonly maxRequests?: number;
@@ -74,6 +75,8 @@ export declare class S3FileSystem implements FileSystem {
74
75
  private readonly maxReadBytes;
75
76
  private readonly maxStreamBytes;
76
77
  private readonly maxListEntries;
78
+ private readonly maxRequests;
79
+ private requests;
77
80
  private readonly removalLimits;
78
81
  private readonly removalBudgets;
79
82
  constructor(options: S3FileSystemOptions);
@@ -3,6 +3,7 @@ import { FsError, isFsError } from "../../contracts/errors.js";
3
3
  import { composeAbortSignals } from "../../contracts/abort.js";
4
4
  import { collectBytes, readBytes } from "../../contracts/io.js";
5
5
  import { compareEntries, registerEntryAuthority } from "../mount/comparison.js";
6
+ import { chargeScopedTransportCall } from "#safe-fs-platform";
6
7
  import { compareOwnedS3Entries, queryS3Head, recordS3Stat, registerS3EntryOwner } from "./authority.js";
7
8
  import { encodeCopySource } from "./transport.js";
8
9
  import { admitDirectoryEntries, directoryEntryLimit } from "../directory-admission.js";
@@ -58,11 +59,31 @@ function fail(code, syscall, path, message) {
58
59
  throw new FsError(code, { syscall, path, ...(message === undefined ? {} : { message }) });
59
60
  }
60
61
  function validateLimit(value, name, minimum, maximum = Number.MAX_SAFE_INTEGER) {
61
- if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
62
+ if ((value !== Infinity || maximum !== Infinity) && (!Number.isSafeInteger(value) || value < minimum || value > maximum)) {
62
63
  throw new FsError("EINVAL", { message: `${name} must be an integer from ${minimum} to ${maximum}` });
63
64
  }
64
65
  return value;
65
66
  }
67
+ function utf8ByteLength(value) {
68
+ let bytes = 0;
69
+ for (let i = 0; i < value.length; i++) {
70
+ const code = value.charCodeAt(i);
71
+ if (code < 0x80)
72
+ bytes += 1;
73
+ else if (code < 0x800)
74
+ bytes += 2;
75
+ else if (code >= 0xd800 && code <= 0xdbff && i + 1 < value.length && value.charCodeAt(i + 1) >= 0xdc00 && value.charCodeAt(i + 1) <= 0xdfff) {
76
+ bytes += 4;
77
+ i++;
78
+ }
79
+ else
80
+ bytes += 3;
81
+ }
82
+ return bytes;
83
+ }
84
+ function exceedsComponentByteLimit(value) {
85
+ return value.length > 85 && (value.length > 255 || utf8ByteLength(value) > 255);
86
+ }
66
87
  function isWellFormed(value) {
67
88
  return !/[\uD800-\uDFFF]/u.test(value);
68
89
  }
@@ -82,6 +103,8 @@ export class S3FileSystem {
82
103
  maxReadBytes;
83
104
  maxStreamBytes;
84
105
  maxListEntries;
106
+ maxRequests;
107
+ requests = 0;
85
108
  removalLimits;
86
109
  removalBudgets = new WeakMap();
87
110
  constructor(options) {
@@ -101,19 +124,20 @@ export class S3FileSystem {
101
124
  throw new FsError("EINVAL", { message: "prefix must be a canonical relative object-key prefix" });
102
125
  }
103
126
  this.prefix = prefix === "" || prefix.endsWith("/") ? prefix : `${prefix}/`;
104
- if (Buffer.byteLength(this.prefix) > 1024)
127
+ if (utf8ByteLength(this.prefix) > 1024)
105
128
  throw new FsError("ENAMETOOLONG", { message: "S3 prefix exceeds 1024 UTF-8 bytes" });
106
129
  this.transport = options.transport;
107
130
  this.bucket = options.bucket;
108
131
  this.allowRename = options.allowNonAtomicRename ?? true;
109
132
  this.pageSize = validateLimit(options.pageSize ?? 1000, "pageSize", 1, 1000);
110
- this.maxReadBytes = options.maxReadBytes === undefined ? Infinity : validateLimit(options.maxReadBytes, "maxReadBytes", 0);
111
- this.maxStreamBytes = options.maxStreamBytes === undefined ? Infinity : validateLimit(options.maxStreamBytes, "maxStreamBytes", 0);
112
- this.maxListEntries = options.maxListEntries === undefined ? Infinity : validateLimit(options.maxListEntries, "maxListEntries", 1);
133
+ this.maxReadBytes = options.maxReadBytes === undefined ? Infinity : validateLimit(options.maxReadBytes, "maxReadBytes", 0, Infinity);
134
+ this.maxStreamBytes = options.maxStreamBytes === undefined ? Infinity : validateLimit(options.maxStreamBytes, "maxStreamBytes", 0, Infinity);
135
+ this.maxListEntries = options.maxListEntries === undefined ? Infinity : validateLimit(options.maxListEntries, "maxListEntries", 1, Infinity);
136
+ this.maxRequests = options.maxRequests === undefined ? Infinity : validateLimit(options.maxRequests, "maxRequests", 1, Infinity);
113
137
  this.removalLimits = {
114
- maxRequests: validateLimit(options.removalLimits?.maxRequests ?? 32, "removalLimits.maxRequests", 1),
115
- maxListEntries: validateLimit(options.removalLimits?.maxListEntries ?? 32, "removalLimits.maxListEntries", 1),
116
- maxDeleteObjects: validateLimit(options.removalLimits?.maxDeleteObjects ?? 16, "removalLimits.maxDeleteObjects", 1),
138
+ maxRequests: validateLimit(options.removalLimits?.maxRequests ?? Infinity, "removalLimits.maxRequests", 1, Infinity),
139
+ maxListEntries: validateLimit(options.removalLimits?.maxListEntries ?? Infinity, "removalLimits.maxListEntries", 1, Infinity),
140
+ maxDeleteObjects: validateLimit(options.removalLimits?.maxDeleteObjects ?? Infinity, "removalLimits.maxDeleteObjects", 1, Infinity),
117
141
  };
118
142
  this.capabilities = Object.freeze({
119
143
  open: false,
@@ -167,7 +191,7 @@ export class S3FileSystem {
167
191
  components.push(part);
168
192
  }
169
193
  const path = `/${components.join("/")}`;
170
- if (Buffer.byteLength(this.key(path)) > 1024)
194
+ if (utf8ByteLength(this.key(path)) > 1024)
171
195
  fail("ENAMETOOLONG", "resolve", input);
172
196
  return path;
173
197
  }
@@ -176,7 +200,7 @@ export class S3FileSystem {
176
200
  }
177
201
  directoryKey(path) {
178
202
  const key = path === "/" ? this.prefix : `${this.key(path)}/`;
179
- if (Buffer.byteLength(key) > 1024)
203
+ if (utf8ByteLength(key) > 1024)
180
204
  fail("ENAMETOOLONG", "resolve", path);
181
205
  return key;
182
206
  }
@@ -188,11 +212,17 @@ export class S3FileSystem {
188
212
  return options.signal === undefined ? {} : { abortSignal: options.signal };
189
213
  }
190
214
  async call(syscall, path, options, action, precondition) {
215
+ this.checkAbort(options, syscall, path);
216
+ chargeScopedTransportCall(options);
191
217
  this.checkAbort(options, syscall, path);
192
218
  const budget = this.removalBudgets.get(options);
193
219
  if (budget && ++budget.requests > this.removalLimits.maxRequests) {
194
220
  fail("EFBIG", "rm", path, "S3 removal request budget exceeded");
195
221
  }
222
+ if (this.requests >= this.maxRequests) {
223
+ fail("EFBIG", syscall, path, "S3 transport request budget exceeded");
224
+ }
225
+ this.requests++;
196
226
  let onAbort;
197
227
  try {
198
228
  const pending = action();
@@ -297,7 +327,7 @@ export class S3FileSystem {
297
327
  if (!key.startsWith(this.prefix))
298
328
  fail("EIO", "listObjectsV2", path, "key escapes the configured prefix");
299
329
  const relative = key.slice(this.prefix.length).replace(/\/$/, "");
300
- if (!isWellFormed(key) || key.includes("\0") || Buffer.byteLength(key) > 1024
330
+ if (!isWellFormed(key) || key.includes("\0") || utf8ByteLength(key) > 1024
301
331
  || (relative !== "" && relative.split("/").some((part) => !part || part === "." || part === ".."))) {
302
332
  fail("ENOTSUP", "listObjectsV2", path, "object key cannot be represented as a canonical filesystem path");
303
333
  }
@@ -338,22 +368,22 @@ export class S3FileSystem {
338
368
  return { stat: this.makeStat("directory", marker), ...(marker ? { metadata: marker } : {}) };
339
369
  }
340
370
  const file = await this.head(this.key(path), path, options);
341
- if (Buffer.byteLength(this.key(path)) === 1024) {
371
+ if (utf8ByteLength(this.key(path)) === 1024) {
342
372
  return file ? { stat: this.makeStat("file", file), metadata: file } : undefined;
343
373
  }
344
374
  const directoryKey = this.directoryKey(path);
345
- const marker = await this.head(directoryKey, path, options);
346
- if (marker && marker.ContentLength !== 0)
347
- this.unsupported("nonempty directory markers", path);
348
- let directory = marker !== undefined;
349
- if (!directory) {
350
- for await (const children of this.pages(directoryKey, path, options, undefined, 1)) {
351
- if ((children.Contents?.length ?? 0) > 0) {
352
- directory = true;
353
- break;
354
- }
375
+ let directory = false;
376
+ let hasMarker = false;
377
+ for await (const children of this.pages(directoryKey, path, options, undefined, 1)) {
378
+ if ((children.Contents?.length ?? 0) > 0) {
379
+ directory = true;
380
+ hasMarker = children.Contents.some(object => object.Key === directoryKey);
381
+ break;
355
382
  }
356
383
  }
384
+ const marker = hasMarker ? await this.head(directoryKey, path, options) : undefined;
385
+ if (marker && marker.ContentLength !== 0)
386
+ this.unsupported("nonempty directory markers", path);
357
387
  if (file && directory)
358
388
  this.unsupported("file/prefix collisions", path);
359
389
  if (file)
@@ -370,6 +400,8 @@ export class S3FileSystem {
370
400
  break;
371
401
  }
372
402
  for (const parent of ancestors) {
403
+ if (parent === "/" && this.prefix === "")
404
+ continue;
373
405
  const ancestor = await this.inspect(parent, options);
374
406
  if (!ancestor)
375
407
  fail("ENOENT", "stat", parent);
@@ -395,7 +427,7 @@ export class S3FileSystem {
395
427
  return this.stat(path, options);
396
428
  }
397
429
  async body(output, path, options) {
398
- const limit = Math.min(this.maxReadBytes, options.maxBytes === undefined ? this.maxReadBytes : validateLimit(options.maxBytes, "maxBytes", 0));
430
+ const limit = Math.min(this.maxReadBytes, options.maxBytes === undefined ? this.maxReadBytes : validateLimit(options.maxBytes, "maxBytes", 0, Infinity));
399
431
  let iterator;
400
432
  return this.call("readFile", path, options, async () => {
401
433
  if (output.Body && Symbol.asyncIterator in output.Body)
@@ -441,7 +473,7 @@ export class S3FileSystem {
441
473
  async readFile(input, options = {}) {
442
474
  const path = this.path(input);
443
475
  if (options.maxBytes !== undefined)
444
- validateLimit(options.maxBytes, "maxBytes", 0);
476
+ validateLimit(options.maxBytes, "maxBytes", 0, Infinity);
445
477
  const info = await this.stat(input, options);
446
478
  if (info.type === "directory")
447
479
  fail("EISDIR", "readFile", path);
@@ -776,7 +808,7 @@ export class S3FileSystem {
776
808
  if (!conditionalCopy && object.Size > (streamFallback ? this.maxStreamBytes : this.maxReadBytes)) {
777
809
  fail("EFBIG", "rename", source, "conditional PUT fallback exceeds its configured transfer or buffered read limit");
778
810
  }
779
- if (Buffer.byteLength(this.key(destination) + object.Key.slice(this.key(source).length)) > 1024)
811
+ if (utf8ByteLength(this.key(destination) + object.Key.slice(this.key(source).length)) > 1024)
780
812
  fail("ENAMETOOLONG", "rename", destination);
781
813
  }
782
814
  const copied = [];