@poe-platform/safe-fs 0.1.726 → 0.1.728

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 (57) hide show
  1. package/README.md +8 -1
  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 +183 -27
  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 +34 -48
  19. package/dist/safe-fs/fs/memory/index.js +1150 -235
  20. package/dist/safe-fs/fs/memory/ledger.d.ts +2 -0
  21. package/dist/safe-fs/fs/memory/ledger.js +15 -3
  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 +22 -2
  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 +1 -1
  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 +48 -16
  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/platform/browser.d.ts +5 -0
  48. package/dist/safe-fs/platform/browser.js +17 -0
  49. package/dist/safe-fs/platform/node.d.ts +5 -0
  50. package/dist/safe-fs/platform/node.js +28 -0
  51. package/dist/safe-fs/platform/transport-budget.d.ts +7 -0
  52. package/dist/safe-fs/platform/transport-budget.js +4 -0
  53. package/dist/safe-fs/testing/object-io-metrics.d.ts +3 -0
  54. package/dist/safe-fs/testing/object-io-metrics.js +29 -0
  55. package/dist/safe-fs/testing/object-publication.d.ts +3 -1
  56. package/dist/safe-fs/testing/object-publication.js +2 -2
  57. package/package.json +1 -1
@@ -15,6 +15,8 @@ export function withObjectFileDescriptors(filesystem, store, options = {}) {
15
15
  || store.createStaging !== undefined && typeof store.createStaging !== "function")
16
16
  throw new TypeError("Invalid object descriptor configuration");
17
17
  const createStaging = store.createStaging?.bind(store);
18
+ const defaultIdentityScope = Symbol("object-file-descriptors");
19
+ const withIdentity = (stat) => stat.type === "file" && stat.identityScope === undefined ? { ...stat, identityScope: defaultIdentityScope } : stat;
18
20
  let stagedBytes = 0;
19
21
  let openFiles = 0;
20
22
  const stagingWaiters = new Set();
@@ -58,7 +60,7 @@ export function withObjectFileDescriptors(filesystem, store, options = {}) {
58
60
  throw new FsError("EIO", { message: "Invalid immutable object version" });
59
61
  if (value.stat.size > maxFileBytes)
60
62
  throw new FsError("EFBIG", { message: "Object descriptor file limit exceeded" });
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) });
63
+ return Object.freeze({ revision: value.revision, ...(value.publicationToken === undefined ? {} : { publicationToken: value.publicationToken }), stat: Object.freeze(withIdentity(value.stat)), read: value.read.bind(value), close: value.close.bind(value) });
62
64
  };
63
65
  const clearPages = (state) => {
64
66
  stagedBytes -= state.pages.size * chunkBytes;
@@ -434,17 +436,91 @@ export function withObjectFileDescriptors(filesystem, store, options = {}) {
434
436
  throw error;
435
437
  }
436
438
  });
439
+ const openReadFile = async (path, readOptions = {}) => {
440
+ if (!readOptions || typeof readOptions !== "object"
441
+ || Object.keys(readOptions).some(key => key !== "signal" && key !== "allowDirectory")
442
+ || readOptions.allowDirectory !== undefined && typeof readOptions.allowDirectory !== "boolean") {
443
+ throw new FsError("EINVAL", { syscall: "openReadFile", path });
444
+ }
445
+ readOptions.signal?.throwIfAborted();
446
+ if (readOptions.allowDirectory === true)
447
+ throw new FsError("ENOTSUP", { syscall: "openReadFile", path });
448
+ const descriptor = await open(path, {
449
+ access: "read",
450
+ ...(readOptions.signal === undefined ? {} : { signal: readOptions.signal }),
451
+ });
452
+ let retainedStat;
453
+ try {
454
+ retainedStat = await descriptor.stat(readOptions.signal === undefined ? {} : { signal: readOptions.signal });
455
+ }
456
+ catch (error) {
457
+ await finishCleanup(() => descriptor.close(), true);
458
+ throw error;
459
+ }
460
+ let closed = false;
461
+ let closing;
462
+ const assertOpen = (forwarded, syscall) => {
463
+ forwarded.signal?.throwIfAborted();
464
+ if (closed)
465
+ throw new FsError("EBADF", { syscall, path });
466
+ };
467
+ return {
468
+ async stat(forwarded = {}) {
469
+ assertOpen(forwarded, "fstat");
470
+ return descriptor.stat(forwarded);
471
+ },
472
+ async seekEnd(forwarded = {}) {
473
+ assertOpen(forwarded, "lseek");
474
+ const stat = await descriptor.stat(forwarded);
475
+ return BigInt(stat.size);
476
+ },
477
+ async read(position, maxBytes, forwarded = {}) {
478
+ assertOpen(forwarded, "read");
479
+ if (!Number.isSafeInteger(position) || position < 0
480
+ || !Number.isSafeInteger(maxBytes) || maxBytes <= 0
481
+ || maxBytes > Number.MAX_SAFE_INTEGER - position) {
482
+ throw new FsError("EINVAL", { syscall: "read", path });
483
+ }
484
+ const length = Math.min(maxBytes, Math.max(0, retainedStat.size - position));
485
+ if (length === 0) {
486
+ await descriptor.stat(forwarded);
487
+ return new Uint8Array();
488
+ }
489
+ const buffer = new Uint8Array(length);
490
+ const count = await descriptor.read(buffer, position, forwarded);
491
+ return count === buffer.byteLength ? buffer : buffer.subarray(0, count);
492
+ },
493
+ close() {
494
+ closed = true;
495
+ return closing ??= descriptor.close();
496
+ },
497
+ };
498
+ };
437
499
  return new Proxy(Object.create(filesystem), {
438
500
  get(_target, property) {
439
501
  if (property === "open")
440
502
  return open;
503
+ if (property === "openReadFile")
504
+ return openReadFile;
505
+ if (property === "stat" || property === "lstat")
506
+ return async (path, statOptions) => withIdentity(await filesystem[property](path, statOptions));
441
507
  if (property === "capabilities")
442
- return { ...filesystem.capabilities, open: true, versionedDescriptors: true };
508
+ return {
509
+ ...filesystem.capabilities,
510
+ open: true,
511
+ versionedDescriptors: true,
512
+ retainedRead: filesystem.capabilities.read !== false,
513
+ };
443
514
  if (property === "capabilitiesFor")
444
515
  return async (path, query) => {
445
516
  const capabilities = await filesystem.capabilitiesFor?.(path, query) ?? filesystem.capabilities;
446
517
  query?.signal?.throwIfAborted();
447
- return { ...capabilities, open: true, versionedDescriptors: true };
518
+ return {
519
+ ...capabilities,
520
+ open: true,
521
+ versionedDescriptors: true,
522
+ ...(query?.allowDirectory ? {} : { retainedRead: capabilities.read !== false }),
523
+ };
448
524
  };
449
525
  const value = Reflect.get(filesystem, property, filesystem);
450
526
  return typeof value === "function" ? value.bind(filesystem) : value;
@@ -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 { CreateStagedFileOptions, FileStaging, PublishStagedFileOptions, StagedFileContent, AppendFileOptions, CapabilityQueryOptions, CopyFileOptions, DirectoryEntry, OpenReadFileOptions, FileReadHandle, FileStat, FileSystem, FileSystemCapabilities, FsOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, WriteFileOptions } from "../../contracts/filesystem.js";
3
+ import type { CreateStagedFileOptions, FileStaging, PublishStagedFileOptions, StagedFileContent, AppendFileOptions, CapabilityQueryOptions, CopyFileOptions, DirectoryEntry, OpenReadFileOptions, FileReadHandle, FileStat, FileSystem, FileSystemCapabilities, FsOptions, ChmodOptions, 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;
@@ -73,7 +73,7 @@ export declare class OverlayFileSystem implements FileSystem {
73
73
  symlink(target: string, path: string, options?: FsOptions): Promise<void>;
74
74
  link(_existingPath: string, newPath: string, options?: FsOptions): Promise<void>;
75
75
  private metadata;
76
- chmod(path: string, permissions: number, options?: FsOptions): Promise<void>;
76
+ chmod(path: string, permissions: number, options?: ChmodOptions): Promise<void>;
77
77
  utimes(path: string, atimeMs: number, mtimeMs: number, options?: FsOptions): Promise<void>;
78
78
  truncate(path: string, length?: number, options?: FsOptions): Promise<void>;
79
79
  readStream(path: string, options?: ReadStreamOptions): ByteSource;
@@ -123,7 +123,8 @@ export class OverlayFileSystem {
123
123
  this.capabilities = Object.freeze({
124
124
  ...semantics,
125
125
  open: false,
126
- atomicFilePublication: false, atomicFileMutation: false, atomicFileStaging: this.publication.supported(), atomicStagingAncestry: this.publication.supported(), atomicDirectoryMetadata: false, trustedOwnedStaging: false,
126
+ conditionalChmod: this.publication.supported(),
127
+ atomicFilePublication: false, atomicFileMutation: false, atomicEntryRemoval: false, atomicEntryRemovalReceipt: false, atomicFileStaging: this.publication.supported(), atomicStagingAncestry: this.publication.supported(), atomicDirectoryMetadata: false, trustedOwnedStaging: false,
127
128
  implicitDirectories: false,
128
129
  readlink: upper.readlink === true && this.#lower.capabilities.readlink === true ? true
129
130
  : upper.readlink === false && this.#lower.capabilities.readlink === false ? false : undefined,
@@ -683,13 +684,14 @@ export class OverlayFileSystem {
683
684
  }
684
685
  return location;
685
686
  }
686
- async replace(location, options, operation, streaming = false) {
687
+ async replace(location, options, operation, streaming = false, commitGuard) {
687
688
  await this.parent(location.path, options);
688
689
  await this.staged(options, async (temporary) => {
689
690
  if (location.entry)
690
691
  await this.clone(location.entry, temporary, options, streaming);
691
692
  await operation(temporary);
692
693
  options.signal?.throwIfAborted();
694
+ commitGuard?.();
693
695
  await this.#upper.rename(temporary, location.path, options);
694
696
  this.linkMetadata.delete(location.path);
695
697
  this.linkOrigins.delete(location.path);
@@ -1003,6 +1005,24 @@ export class OverlayFileSystem {
1003
1005
  if (permissions === undefined)
1004
1006
  fail("EINVAL", path);
1005
1007
  mode(permissions, path);
1008
+ const guard = this.publication.prepareChmod(path, options);
1009
+ if (guard) {
1010
+ const controls = options.signal === undefined ? {} : { signal: options.signal };
1011
+ return this.run(controls, async () => {
1012
+ this.writable(path);
1013
+ guard();
1014
+ const entry = await this.required(path, controls, false);
1015
+ guard();
1016
+ if (entry.stat.type === "directory") {
1017
+ await this.copyUp(entry, controls);
1018
+ guard();
1019
+ await this.#upper.chmod(entry.path, permissions, controls);
1020
+ }
1021
+ else {
1022
+ await this.replace({ path: entry.path, entry }, controls, temporary => this.#upper.chmod(temporary, permissions, controls), false, guard);
1023
+ }
1024
+ });
1025
+ }
1006
1026
  return this.metadata(path, "permissions", options, (target) => this.#upper.chmod(target, permissions, options));
1007
1027
  }
1008
1028
  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();
@@ -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);