@openclaw/fs-safe 0.19.0 → 0.21.0

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 (142) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +24 -6
  3. package/dist/advanced.d.ts +4 -0
  4. package/dist/advanced.js +2 -0
  5. package/dist/archive-plan.d.ts +2 -7
  6. package/dist/archive-read.js +9 -18
  7. package/dist/archive-zip-entry.d.ts +11 -11
  8. package/dist/archive-zip-entry.js +3 -35
  9. package/dist/archive-zip-integrity.d.ts +2 -2
  10. package/dist/archive-zip-integrity.js +2 -12
  11. package/dist/archive-zip-loader.d.ts +7 -3
  12. package/dist/archive-zip-loader.js +10 -9
  13. package/dist/archive-zip-preflight.d.ts +2 -1
  14. package/dist/archive-zip-preflight.js +16 -7
  15. package/dist/archive.js +17 -16
  16. package/dist/atomic.d.ts +1 -1
  17. package/dist/directory-receipt.js +5 -7
  18. package/dist/effective-uid.js +1 -4
  19. package/dist/errors.d.ts +3 -1
  20. package/dist/errors.js +3 -2
  21. package/dist/file-lock-sync-root-held.js +1 -4
  22. package/dist/file-store.d.ts +4 -7
  23. package/dist/json-document-store.d.ts +4 -9
  24. package/dist/local-file-access.js +2 -5
  25. package/dist/move-path-cleanup.js +4 -4
  26. package/dist/native-binding.d.ts +28 -1
  27. package/dist/native-staged-symlink.d.ts +13 -0
  28. package/dist/native-staged-symlink.js +303 -0
  29. package/dist/owner-dacl-batch-worker.d.ts +1 -0
  30. package/dist/owner-dacl-batch-worker.js +54 -0
  31. package/dist/owner-dacl-batch.d.ts +5 -0
  32. package/dist/owner-dacl-batch.js +64 -0
  33. package/dist/owner-dacl.d.ts +2 -0
  34. package/dist/owner-dacl.js +3 -0
  35. package/dist/path.js +17 -1
  36. package/dist/permission-exec.js +3 -6
  37. package/dist/permissions-public.d.ts +1 -0
  38. package/dist/permissions-public.js +1 -0
  39. package/dist/pinned-mutation-admission.d.ts +0 -1
  40. package/dist/pinned-open.d.ts +0 -1
  41. package/dist/pinned-open.js +1 -2
  42. package/dist/publish-copy-stage.js +4 -0
  43. package/dist/read-opened-file.d.ts +2 -5
  44. package/dist/regular-file.js +3 -3
  45. package/dist/replace-file-buffer.d.ts +4 -0
  46. package/dist/replace-file-buffer.js +36 -0
  47. package/dist/replace-file-copy-fallback.d.ts +3 -2
  48. package/dist/replace-file-copy-fallback.js +66 -38
  49. package/dist/replace-file-descriptor.d.ts +4 -0
  50. package/dist/replace-file-descriptor.js +9 -1
  51. package/dist/replace-file-destination.d.ts +17 -0
  52. package/dist/replace-file-destination.js +61 -0
  53. package/dist/replace-file-mutation.d.ts +26 -0
  54. package/dist/replace-file-mutation.js +47 -0
  55. package/dist/replace-file-temp-owner.d.ts +2 -2
  56. package/dist/replace-file-temp-owner.js +16 -4
  57. package/dist/replace-file-types.d.ts +55 -0
  58. package/dist/replace-file-types.js +1 -0
  59. package/dist/replace-file.d.ts +3 -55
  60. package/dist/replace-file.js +29 -10
  61. package/dist/retained-file-types.d.ts +61 -0
  62. package/dist/retained-file-types.js +1 -0
  63. package/dist/retained-file.d.ts +3 -0
  64. package/dist/retained-file.js +121 -0
  65. package/dist/root-directory-entry.d.ts +9 -0
  66. package/dist/root-directory-entry.js +28 -0
  67. package/dist/root-directory-list.d.ts +7 -1
  68. package/dist/root-directory-list.js +48 -23
  69. package/dist/root-handle-context.d.ts +4 -0
  70. package/dist/root-handle-context.js +12 -0
  71. package/dist/root-impl.d.ts +3 -3
  72. package/dist/root-impl.js +8 -5
  73. package/dist/root-observed-path.d.ts +0 -1
  74. package/dist/root-observed-path.js +0 -3
  75. package/dist/root-path-observation.d.ts +4 -11
  76. package/dist/root-path.js +7 -10
  77. package/dist/root-walk.d.ts +19 -12
  78. package/dist/root-walk.js +49 -18
  79. package/dist/root-write-admission.js +0 -2
  80. package/dist/safe-path-segment.d.ts +1 -0
  81. package/dist/safe-path-segment.js +8 -2
  82. package/dist/secure-file.js +3 -2
  83. package/dist/sidecar-lock.js +5 -3
  84. package/dist/staged-symlink-types.d.ts +49 -0
  85. package/dist/staged-symlink-types.js +1 -0
  86. package/dist/symlink-parents.js +58 -7
  87. package/dist/temp-target.js +5 -2
  88. package/dist/temp-workspace-admission.js +22 -21
  89. package/dist/temp-workspace-child-admission.d.ts +1 -1
  90. package/dist/temp-workspace-child-admission.js +14 -9
  91. package/dist/temp-workspace-owner.js +4 -9
  92. package/dist/temp-workspace-ownership.d.ts +8 -0
  93. package/dist/temp-workspace-ownership.js +52 -0
  94. package/dist/test-hooks.d.ts +3 -0
  95. package/dist/text-atomic.d.ts +2 -1
  96. package/dist/text-atomic.js +2 -0
  97. package/dist/trash.js +27 -1
  98. package/dist/walk.d.ts +2 -5
  99. package/dist/watch-alias.d.ts +6 -0
  100. package/dist/watch-alias.js +80 -0
  101. package/dist/watch-hints.d.ts +8 -0
  102. package/dist/watch-hints.js +77 -0
  103. package/dist/watch-native.d.ts +32 -0
  104. package/dist/watch-native.js +56 -0
  105. package/dist/watch-scan.d.ts +24 -0
  106. package/dist/watch-scan.js +269 -0
  107. package/dist/watch-types.d.ts +58 -0
  108. package/dist/watch-types.js +1 -0
  109. package/dist/watch.d.ts +5 -0
  110. package/dist/watch.js +502 -0
  111. package/dist/windows-owner.d.ts +0 -1
  112. package/dist/windows-owner.js +0 -1
  113. package/dist/windows-security-bridge.cs +6 -4
  114. package/dist/windows-security-bridge.ps1 +78 -3
  115. package/dist/windows-security-command.d.ts +8 -0
  116. package/dist/windows-security-command.js +66 -12
  117. package/dist/windows-security-facts.d.ts +3 -0
  118. package/dist/windows-security-facts.js +4 -0
  119. package/docs/advanced.md +4 -2
  120. package/docs/archive.md +8 -0
  121. package/docs/atomic.md +72 -3
  122. package/docs/contributing.md +35 -0
  123. package/docs/durability.md +7 -0
  124. package/docs/index.md +1 -0
  125. package/docs/install.md +28 -0
  126. package/docs/native-helper.md +14 -4
  127. package/docs/native.md +45 -1
  128. package/docs/permissions.md +66 -0
  129. package/docs/public-api.md +7 -1
  130. package/docs/retained-file.md +113 -0
  131. package/docs/root.md +6 -1
  132. package/docs/security-model.md +4 -1
  133. package/docs/sidecar-lock.md +2 -0
  134. package/docs/staged-symlink.md +123 -0
  135. package/docs/store.md +3 -1
  136. package/docs/temp.md +24 -4
  137. package/docs/testing.md +88 -0
  138. package/docs/types.md +6 -0
  139. package/docs/walk.md +22 -1
  140. package/docs/watch.md +184 -0
  141. package/docs/writing.md +10 -0
  142. package/package.json +13 -9
@@ -0,0 +1,47 @@
1
+ import { assertSynchronousCallbackResult } from "./mutation-authority.js";
2
+ export class AtomicMutation {
3
+ active;
4
+ #assertion;
5
+ #observer;
6
+ #refusal;
7
+ #writingReported = false;
8
+ constructor(options) {
9
+ this.#assertion = options.assertBeforeMutation;
10
+ this.#observer = options.onDestinationState;
11
+ this.active = Boolean(this.#assertion || this.#observer);
12
+ }
13
+ rethrowRefusal() {
14
+ if (this.#refusal)
15
+ throw this.#refusal.error;
16
+ }
17
+ refuse(error) {
18
+ this.#refusal ??= { error };
19
+ throw this.#refusal.error;
20
+ }
21
+ #invoke(operation, name) {
22
+ this.rethrowRefusal();
23
+ try {
24
+ const result = operation();
25
+ assertSynchronousCallbackResult(result, name);
26
+ }
27
+ catch (error) {
28
+ this.refuse(error);
29
+ }
30
+ }
31
+ assert() {
32
+ this.#invoke(() => this.#assertion?.(), "assertBeforeMutation");
33
+ }
34
+ destination(state, path, identity) {
35
+ if (state === "writing") {
36
+ if (this.#writingReported)
37
+ return;
38
+ this.#writingReported = true;
39
+ }
40
+ this.#invoke(() => this.#observer?.(Object.freeze({
41
+ state, path, dev: identity.dev, ino: identity.ino,
42
+ })), "onDestinationState");
43
+ }
44
+ removed(path) {
45
+ this.#invoke(() => this.#observer?.(Object.freeze({ state: "removed", path })), "onDestinationState");
46
+ }
47
+ }
@@ -26,7 +26,7 @@ export declare class AsyncAtomicTempOwner extends AtomicTempOwner<FileHandle> {
26
26
  identity: BigIntStats;
27
27
  }): void;
28
28
  assertCurrent(fsModule: AsyncOwnerFileSystem, pathname?: string): Promise<void>;
29
- assertPublished(fsModule: AsyncOwnerFileSystem, pathname: string, expectedHash?: string): Promise<void>;
29
+ assertPublished(fsModule: AsyncOwnerFileSystem, pathname: string, expectedHash?: string, onVerified?: (identity: BigIntStats) => void): Promise<void>;
30
30
  finish(params: {
31
31
  fsModule: AsyncOwnerFileSystem;
32
32
  originalFailure?: AtomicTempFailure;
@@ -39,7 +39,7 @@ export declare class SyncAtomicTempOwner extends AtomicTempOwner<number> {
39
39
  identity: BigIntStats;
40
40
  }): void;
41
41
  assertCurrent(fsModule: SyncOwnerFileSystem, pathname?: string): void;
42
- assertPublished(fsModule: SyncOwnerFileSystem, pathname: string, expectedHash?: string): void;
42
+ assertPublished(fsModule: SyncOwnerFileSystem, pathname: string, expectedHash?: string, onVerified?: (identity: BigIntStats) => void): void;
43
43
  finish(params: {
44
44
  fsModule: SyncOwnerFileSystem;
45
45
  originalFailure?: AtomicTempFailure;
@@ -161,16 +161,21 @@ export class AsyncAtomicTempOwner extends AtomicTempOwner {
161
161
  throw error;
162
162
  }
163
163
  }
164
- async assertPublished(fsModule, pathname, expectedHash) {
164
+ async assertPublished(fsModule, pathname, expectedHash, onVerified) {
165
+ let identityCurrent = false;
165
166
  try {
166
167
  await this.assertCurrent(fsModule, pathname);
167
- return;
168
+ identityCurrent = true;
168
169
  }
169
170
  catch (error) {
170
171
  if (!(error instanceof FsSafeError) || !hasErrorCode(error, "path-mismatch") || !expectedHash) {
171
172
  throw error;
172
173
  }
173
174
  }
175
+ if (identityCurrent) {
176
+ onVerified?.(this.identity);
177
+ return;
178
+ }
174
179
  let published;
175
180
  try {
176
181
  try {
@@ -199,6 +204,7 @@ export class AsyncAtomicTempOwner extends AtomicTempOwner {
199
204
  if (sha256Hex(await published.readFile()) !== expectedHash) {
200
205
  throw new FsSafeError("path-mismatch", `Atomic replace published content changed: ${pathname}`);
201
206
  }
207
+ onVerified?.(identity);
202
208
  const previousHandle = this.takeResource();
203
209
  await previousHandle?.close();
204
210
  this.resource = published;
@@ -269,16 +275,21 @@ export class SyncAtomicTempOwner extends AtomicTempOwner {
269
275
  throw error;
270
276
  }
271
277
  }
272
- assertPublished(fsModule, pathname, expectedHash) {
278
+ assertPublished(fsModule, pathname, expectedHash, onVerified) {
279
+ let identityCurrent = false;
273
280
  try {
274
281
  this.assertCurrent(fsModule, pathname);
275
- return;
282
+ identityCurrent = true;
276
283
  }
277
284
  catch (error) {
278
285
  if (!(error instanceof FsSafeError) || !hasErrorCode(error, "path-mismatch") || !expectedHash) {
279
286
  throw error;
280
287
  }
281
288
  }
289
+ if (identityCurrent) {
290
+ onVerified?.(this.identity);
291
+ return;
292
+ }
282
293
  let publishedFd;
283
294
  try {
284
295
  try {
@@ -305,6 +316,7 @@ export class SyncAtomicTempOwner extends AtomicTempOwner {
305
316
  if (sha256Hex(fsModule.readFileSync(publishedFd)) !== expectedHash) {
306
317
  throw new FsSafeError("path-mismatch", `Atomic replace published content changed: ${pathname}`);
307
318
  }
319
+ onVerified?.(identity);
308
320
  const previousFd = this.takeResource();
309
321
  fsModule.closeSync(previousFd);
310
322
  this.resource = publishedFd;
@@ -0,0 +1,55 @@
1
+ import type syncFs from "node:fs";
2
+ import type fs from "node:fs/promises";
3
+ import type { RenameIdentityPolicy } from "./pinned-write-types.js";
4
+ import type { AtomicMutationOptions } from "./replace-file-mutation.js";
5
+ import type { ReplaceFileCopyFallbackRestorePolicy, ReplaceFileDestinationHardlinkPolicy } from "./replace-file-copy-fallback.js";
6
+ export type ReplaceFileAtomicFileSystem = {
7
+ promises: Pick<typeof fs, "mkdir" | "writeFile" | "rename" | "copyFile" | "unlink" | "rm" | "open" | "stat" | "lstat"> & {
8
+ /** @deprecated Accepted for adapter compatibility but never called. */
9
+ chmod?: typeof fs.chmod;
10
+ };
11
+ };
12
+ export type ReplaceFileAtomicSyncFileSystem = Pick<typeof syncFs, "mkdirSync" | "readFileSync" | "writeFileSync" | "renameSync" | "copyFileSync" | "unlinkSync" | "rmSync" | "openSync" | "fsyncSync" | "closeSync" | "fstatSync" | "statSync" | "lstatSync" | "ftruncateSync" | "readSync" | "writeSync"> & {
13
+ /** @deprecated Accepted for adapter compatibility but never called. */
14
+ chmodSync?: typeof syncFs.chmodSync;
15
+ fchmodSync?: typeof syncFs.fchmodSync;
16
+ };
17
+ export type ReplaceFileAtomicBaseOptions = AtomicMutationOptions & {
18
+ filePath: string;
19
+ content: string | Uint8Array;
20
+ dirMode?: number;
21
+ mode?: number;
22
+ /** Inherit only rwx bits from an existing non-symlink regular file. */
23
+ preserveExistingMode?: boolean;
24
+ tempPrefix?: string;
25
+ renameMaxRetries?: number;
26
+ renameRetryBaseDelayMs?: number;
27
+ copyFallbackOnPermissionError?: boolean;
28
+ copyFallbackRestore?: ReplaceFileCopyFallbackRestorePolicy;
29
+ maxRestoreBytes?: number;
30
+ destinationHardlinks?: ReplaceFileDestinationHardlinkPolicy;
31
+ /** Strict by default; locked content verification is an explicit FUSE compatibility policy. */
32
+ renameIdentity?: RenameIdentityPolicy;
33
+ syncTempFile?: boolean;
34
+ syncParentDir?: boolean;
35
+ throwOnCleanupError?: boolean;
36
+ };
37
+ export type ReplaceFileAtomicOptions = ReplaceFileAtomicBaseOptions & {
38
+ fileSystem?: ReplaceFileAtomicFileSystem;
39
+ /** Runs while the exact staged file is retained; replacing or hardlinking it is rejected. */
40
+ beforeRename?: (params: {
41
+ filePath: string;
42
+ tempPath: string;
43
+ }) => Promise<void>;
44
+ };
45
+ export type ReplaceFileAtomicSyncOptions = ReplaceFileAtomicBaseOptions & {
46
+ fileSystem?: ReplaceFileAtomicSyncFileSystem;
47
+ /** Runs while the exact staged file is retained; replacing or hardlinking it is rejected. */
48
+ beforeRename?: (params: {
49
+ filePath: string;
50
+ tempPath: string;
51
+ }) => void;
52
+ };
53
+ export type ReplaceFileAtomicResult = {
54
+ method: "rename" | "copy-fallback";
55
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -1,58 +1,6 @@
1
- import syncFs from "node:fs";
2
- import fs from "node:fs/promises";
3
- import type { RenameIdentityPolicy } from "./pinned-write-types.js";
4
- import { type ReplaceFileCopyFallbackRestorePolicy, type ReplaceFileDestinationHardlinkPolicy } from "./replace-file-copy-fallback.js";
5
- export type ReplaceFileAtomicFileSystem = {
6
- promises: Pick<typeof fs, "mkdir" | "writeFile" | "rename" | "copyFile" | "unlink" | "rm" | "open" | "stat" | "lstat"> & {
7
- /** @deprecated Accepted for adapter compatibility but never called. */
8
- chmod?: typeof fs.chmod;
9
- };
10
- };
11
- export type ReplaceFileAtomicSyncFileSystem = Pick<typeof syncFs, "mkdirSync" | "readFileSync" | "writeFileSync" | "renameSync" | "copyFileSync" | "unlinkSync" | "rmSync" | "openSync" | "fsyncSync" | "closeSync" | "fstatSync" | "statSync" | "lstatSync" | "ftruncateSync" | "readSync" | "writeSync"> & {
12
- /** @deprecated Accepted for adapter compatibility but never called. */
13
- chmodSync?: typeof syncFs.chmodSync;
14
- fchmodSync?: typeof syncFs.fchmodSync;
15
- };
16
- type ReplaceFileAtomicBaseOptions = {
17
- filePath: string;
18
- content: string | Uint8Array;
19
- dirMode?: number;
20
- mode?: number;
21
- /** Inherit only rwx bits from an existing non-symlink regular file. */
22
- preserveExistingMode?: boolean;
23
- tempPrefix?: string;
24
- renameMaxRetries?: number;
25
- renameRetryBaseDelayMs?: number;
26
- copyFallbackOnPermissionError?: boolean;
27
- copyFallbackRestore?: ReplaceFileCopyFallbackRestorePolicy;
28
- maxRestoreBytes?: number;
29
- destinationHardlinks?: ReplaceFileDestinationHardlinkPolicy;
30
- /** Strict by default; locked content verification is an explicit FUSE compatibility policy. */
31
- renameIdentity?: RenameIdentityPolicy;
32
- syncTempFile?: boolean;
33
- syncParentDir?: boolean;
34
- throwOnCleanupError?: boolean;
35
- };
36
- export type ReplaceFileAtomicOptions = ReplaceFileAtomicBaseOptions & {
37
- fileSystem?: ReplaceFileAtomicFileSystem;
38
- /** Runs while the exact staged file is retained; replacing or hardlinking it is rejected. */
39
- beforeRename?: (params: {
40
- filePath: string;
41
- tempPath: string;
42
- }) => Promise<void>;
43
- };
44
- export type ReplaceFileAtomicSyncOptions = ReplaceFileAtomicBaseOptions & {
45
- fileSystem?: ReplaceFileAtomicSyncFileSystem;
46
- /** Runs while the exact staged file is retained; replacing or hardlinking it is rejected. */
47
- beforeRename?: (params: {
48
- filePath: string;
49
- tempPath: string;
50
- }) => void;
51
- };
52
- export type ReplaceFileAtomicResult = {
53
- method: "rename" | "copy-fallback";
54
- };
1
+ import type { ReplaceFileAtomicOptions, ReplaceFileAtomicSyncOptions, ReplaceFileAtomicResult } from "./replace-file-types.js";
2
+ export type { ReplaceFileAtomicDestinationState } from "./replace-file-mutation.js";
3
+ export type { ReplaceFileAtomicFileSystem, ReplaceFileAtomicSyncFileSystem, ReplaceFileAtomicOptions, ReplaceFileAtomicSyncOptions, ReplaceFileAtomicResult, } from "./replace-file-types.js";
55
4
  export declare function replaceFileAtomic(options: ReplaceFileAtomicOptions): Promise<ReplaceFileAtomicResult>;
56
5
  export declare function replaceFileAtomicWithDirectorySync(options: ReplaceFileAtomicOptions, syncParent?: (directoryPath: string) => Promise<unknown>): Promise<ReplaceFileAtomicResult>;
57
6
  export declare function replaceFileAtomicSync(options: ReplaceFileAtomicSyncOptions): ReplaceFileAtomicResult;
58
- export {};
@@ -13,15 +13,18 @@ import { admitStandalonePublicationPath } from "./windows-path-alias.js";
13
13
  import { sleep, sleepSync } from "./timing.js";
14
14
  import { serializePathWrite } from "./write-queue.js";
15
15
  import { hasErrorCode, readErrorCode } from "./file-cleanup.js";
16
+ import { AtomicMutation } from "./replace-file-mutation.js";
16
17
  async function renameWithRetry(params) {
17
18
  for (let attempt = 0; attempt <= params.maxRetries; attempt++) {
18
19
  if (attempt > 0)
19
20
  await params.assertSourceCurrent();
20
21
  try {
22
+ params.mutation.assert();
21
23
  await params.fsModule.rename(params.src, params.dest);
22
24
  return { method: "rename" };
23
25
  }
24
26
  catch (error) {
27
+ params.mutation.rethrowRefusal();
25
28
  const code = readErrorCode(error);
26
29
  if (code === "EBUSY" && attempt < params.maxRetries) {
27
30
  await sleep(params.baseDelayMs * 2 ** attempt);
@@ -37,6 +40,7 @@ async function renameWithRetry(params) {
37
40
  maxRestoreBytes: params.maxRestoreBytes,
38
41
  expectedSourceIdentity: params.sourceIdentity,
39
42
  sync: params.syncFallback,
43
+ mutation: params.mutation,
40
44
  });
41
45
  return { method: "copy-fallback" };
42
46
  }
@@ -50,10 +54,12 @@ function renameWithRetrySync(params) {
50
54
  if (attempt > 0)
51
55
  params.assertSourceCurrent();
52
56
  try {
57
+ params.mutation.assert();
53
58
  params.fsModule.renameSync(params.src, params.dest);
54
59
  return { method: "rename" };
55
60
  }
56
61
  catch (error) {
62
+ params.mutation.rethrowRefusal();
57
63
  const code = readErrorCode(error);
58
64
  if (code === "EBUSY" && attempt < params.maxRetries) {
59
65
  sleepSync(params.baseDelayMs * 2 ** attempt);
@@ -70,6 +76,7 @@ function renameWithRetrySync(params) {
70
76
  expectedSourceIdentity: params.sourceIdentity,
71
77
  fchmodSync: params.fchmodSync,
72
78
  sync: params.syncFallback,
79
+ mutation: params.mutation,
73
80
  });
74
81
  return { method: "copy-fallback" };
75
82
  }
@@ -142,24 +149,27 @@ export async function replaceFileAtomic(options) {
142
149
  }
143
150
  // Internal owner hook: keep directory durability inside publication verification and serialization.
144
151
  export async function replaceFileAtomicWithDirectorySync(options, syncParent) {
152
+ const mutation = new AtomicMutation(options);
145
153
  const filePath = validateReplaceFilePath(options.filePath);
146
154
  validateRestoreOptions(options);
147
155
  const renameIdentity = options.renameIdentity;
148
156
  validateRenameIdentity(renameIdentity);
149
157
  return await serializePathWrite(path.resolve(filePath), async () => {
150
158
  if (renameIdentity !== "verify-content-with-lock") {
151
- return await replaceFileAtomicUnserialized(options, filePath, renameIdentity, syncParent);
159
+ return await replaceFileAtomicUnserialized(options, filePath, renameIdentity, mutation, syncParent);
152
160
  }
153
161
  const fsModule = options.fileSystem?.promises ?? fs;
154
162
  const dir = path.dirname(filePath);
163
+ mutation.assert();
155
164
  await fsModule.mkdir(fsModule === fs ? recursiveMkdirPath(dir) : dir, {
156
165
  recursive: true,
157
166
  mode: options.dirMode ?? 0o700,
158
167
  });
159
- return await withAtomicRenameIdentityLock(filePath, async () => await replaceFileAtomicUnserialized(options, filePath, renameIdentity, syncParent));
168
+ mutation.assert();
169
+ return await withAtomicRenameIdentityLock(filePath, async () => await replaceFileAtomicUnserialized(options, filePath, renameIdentity, mutation, syncParent));
160
170
  });
161
171
  }
162
- async function replaceFileAtomicUnserialized(options, filePath, renameIdentity, syncParent) {
172
+ async function replaceFileAtomicUnserialized(options, filePath, renameIdentity, mutation, syncParent) {
163
173
  const fsModule = options.fileSystem?.promises ?? fs;
164
174
  const dir = path.dirname(filePath);
165
175
  const dirMode = options.dirMode ?? 0o700;
@@ -169,8 +179,9 @@ async function replaceFileAtomicUnserialized(options, filePath, renameIdentity,
169
179
  const tempOwner = new AsyncAtomicTempOwner(tempPath);
170
180
  let originalFailure;
171
181
  try {
182
+ mutation.assert();
172
183
  await fsModule.mkdir(fsModule === fs ? recursiveMkdirPath(dir) : dir, { recursive: true, mode: dirMode });
173
- await applyDirectoryMode({ fsModule, dirPath: dir, mode: dirMode });
184
+ await applyDirectoryMode({ fsModule, dirPath: dir, mode: dirMode, mutation });
174
185
  tempOwner.start();
175
186
  tempOwner.adopt(await writeTempFile({
176
187
  fsModule,
@@ -179,6 +190,7 @@ async function replaceFileAtomicUnserialized(options, filePath, renameIdentity,
179
190
  mode,
180
191
  sync: options.syncTempFile === true,
181
192
  onIdentity: tempOwner.onIdentity,
193
+ mutation,
182
194
  }));
183
195
  await tempOwner.assertCurrent(fsModule);
184
196
  if (options.beforeRename) {
@@ -202,10 +214,11 @@ async function replaceFileAtomicUnserialized(options, filePath, renameIdentity,
202
214
  sourceIdentity: tempOwner.identity,
203
215
  assertSourceCurrent: () => tempOwner.assertCurrent(fsModule),
204
216
  syncFallback: options.syncTempFile === true,
217
+ mutation,
205
218
  });
206
219
  if (result.method === "rename") {
207
220
  tempOwner.markRenamed();
208
- await tempOwner.assertPublished(fsModule, filePath, expectedHash);
221
+ await tempOwner.assertPublished(fsModule, filePath, expectedHash, identity => mutation.destination("published", filePath, identity));
209
222
  }
210
223
  else {
211
224
  await tempOwner.assertCurrent(fsModule);
@@ -237,22 +250,25 @@ async function replaceFileAtomicUnserialized(options, filePath, renameIdentity,
237
250
  }
238
251
  }
239
252
  export function replaceFileAtomicSync(options) {
253
+ const mutation = new AtomicMutation(options);
240
254
  const filePath = validateReplaceFilePath(options.filePath);
241
255
  validateRestoreOptions(options);
242
256
  const renameIdentity = options.renameIdentity;
243
257
  validateRenameIdentity(renameIdentity);
244
258
  if (renameIdentity !== "verify-content-with-lock") {
245
- return replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity);
259
+ return replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity, mutation);
246
260
  }
247
261
  const fsModule = options.fileSystem ?? syncFs;
248
262
  const dir = path.dirname(filePath);
263
+ mutation.assert();
249
264
  fsModule.mkdirSync(fsModule === syncFs ? recursiveMkdirPath(dir) : dir, {
250
265
  recursive: true,
251
266
  mode: options.dirMode ?? 0o700,
252
267
  });
253
- return withAtomicRenameIdentityLockSync(filePath, () => replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity));
268
+ mutation.assert();
269
+ return withAtomicRenameIdentityLockSync(filePath, () => replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity, mutation));
254
270
  }
255
- function replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity) {
271
+ function replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity, mutation) {
256
272
  const fsModule = options.fileSystem ?? syncFs;
257
273
  const dir = path.dirname(filePath);
258
274
  const dirMode = options.dirMode ?? 0o700;
@@ -269,8 +285,9 @@ function replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity) {
269
285
  const tempOwner = new SyncAtomicTempOwner(tempPath);
270
286
  let originalFailure;
271
287
  try {
288
+ mutation.assert();
272
289
  fsModule.mkdirSync(fsModule === syncFs ? recursiveMkdirPath(dir) : dir, { recursive: true, mode: dirMode });
273
- applyDirectoryModeSync({ fsModule, dirPath: dir, mode: dirMode, fchmodSync });
290
+ applyDirectoryModeSync({ fsModule, dirPath: dir, mode: dirMode, fchmodSync, mutation });
274
291
  tempOwner.start();
275
292
  tempOwner.adopt(writeTempFileSync({
276
293
  fsModule,
@@ -280,6 +297,7 @@ function replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity) {
280
297
  fchmodSync,
281
298
  sync: options.syncTempFile === true,
282
299
  onIdentity: tempOwner.onIdentity,
300
+ mutation,
283
301
  }));
284
302
  tempOwner.assertCurrent(fsModule);
285
303
  if (options.beforeRename) {
@@ -304,10 +322,11 @@ function replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity) {
304
322
  assertSourceCurrent: () => tempOwner.assertCurrent(fsModule),
305
323
  fchmodSync,
306
324
  syncFallback: options.syncTempFile === true,
325
+ mutation,
307
326
  });
308
327
  if (result.method === "rename") {
309
328
  tempOwner.markRenamed();
310
- tempOwner.assertPublished(fsModule, filePath, expectedHash);
329
+ tempOwner.assertPublished(fsModule, filePath, expectedHash, identity => mutation.destination("published", filePath, identity));
311
330
  }
312
331
  else {
313
332
  tempOwner.assertCurrent(fsModule);
@@ -0,0 +1,61 @@
1
+ /** Exact producer observations: never supply rounded Number identities. */
2
+ export type RetainedFileExpected = Readonly<{
3
+ dev: bigint;
4
+ ino: bigint;
5
+ size: bigint;
6
+ mtimeNs: bigint;
7
+ ctimeNs: bigint;
8
+ /** Lowercase SHA-256 of the producer's expected bytes. */
9
+ sha256: string;
10
+ }>;
11
+ export type RetainedFileIssue = Readonly<{
12
+ phase: string;
13
+ code: string;
14
+ message: string;
15
+ cause?: unknown;
16
+ }>;
17
+ /** Descriptive facts, not a deletion capability or a durable transaction receipt. */
18
+ export type RetainedFileResult = Readonly<{
19
+ status: "unsupported" | "not-attempted" | "preserved-mismatch" | "disposition-accepted" | "name-absent-after-settlement" | "failed" | "indeterminate";
20
+ phase: string;
21
+ /** Full native 64-bit volume serial and 128-bit file ID, when observed. */
22
+ identity?: string;
23
+ disposition: "not-attempted" | "accepted" | "rejected" | "indeterminate";
24
+ namespace: "not-observed" | "absent" | "original" | "foreign" | "unknown";
25
+ resources: "closed" | "close-failed";
26
+ persistence: "not-proven";
27
+ errors: readonly RetainedFileIssue[];
28
+ }>;
29
+ export type RetainedFileReceipt = Readonly<{
30
+ directory: string;
31
+ parent: Readonly<{
32
+ dev: bigint;
33
+ ino: bigint;
34
+ }>;
35
+ basename: string;
36
+ expected: RetainedFileExpected;
37
+ identity: string;
38
+ }>;
39
+ export interface RetainedFile extends Disposable {
40
+ readonly receipt: RetainedFileReceipt;
41
+ /** One-shot, synchronous authority admission; always settles owned resources. */
42
+ remove(): RetainedFileResult;
43
+ /** Closes only. Never requests deletion, including after an exception. */
44
+ dispose(): RetainedFileResult;
45
+ }
46
+ export type RetainedFileAdmission = Readonly<{
47
+ status: "retained";
48
+ file: RetainedFile;
49
+ }> | RetainedFileResult;
50
+ export type RetainFileInDirectoryOptions = Readonly<{
51
+ directory: string;
52
+ parent: Readonly<{
53
+ dev: bigint;
54
+ ino: bigint;
55
+ }>;
56
+ basename: string;
57
+ expected: RetainedFileExpected;
58
+ assertBeforeMutation: () => void;
59
+ /** Synchronous verification budget, default 16 MiB; maximum 64 MiB. */
60
+ maxBytes?: number;
61
+ }>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import type { RetainedFileAdmission, RetainFileInDirectoryOptions } from "./retained-file-types.js";
2
+ /** Retain one existing regular file on supported local Windows NTFS. No fallback. */
3
+ export declare function retainFileInDirectory(options: RetainFileInDirectoryOptions): RetainedFileAdmission;
@@ -0,0 +1,121 @@
1
+ import { FsSafeError } from "./errors.js";
2
+ import { getNativeBinding } from "./native.js";
3
+ import { assertSynchronousCallbackResult } from "./mutation-authority.js";
4
+ function freeze(result) {
5
+ return Object.freeze({ ...result, errors: Object.freeze(result.errors.map((error) => Object.freeze({ ...error }))) });
6
+ }
7
+ function unsupported(cause) {
8
+ return freeze({ status: "unsupported", phase: "admission", disposition: "not-attempted",
9
+ namespace: "not-observed", resources: "closed", persistence: "not-proven",
10
+ errors: [{ phase: "admission", code: "helper-unavailable",
11
+ message: "existing-file retention requires the maintained Windows native capability", cause }] });
12
+ }
13
+ function exact(value, name, zero = false) {
14
+ if (typeof value !== "bigint" || value < (zero ? 0n : 1n) || value > 0xffffffffffffffffn) {
15
+ throw new TypeError(`${name} must be an exact ${zero ? "nonnegative" : "positive"} unsigned 64-bit bigint`);
16
+ }
17
+ return value;
18
+ }
19
+ class Owner {
20
+ receipt;
21
+ #native;
22
+ #assert;
23
+ #result;
24
+ #running = false;
25
+ #reentered = false;
26
+ constructor(native, receipt, assertion) {
27
+ this.#native = native;
28
+ this.receipt = receipt;
29
+ this.#assert = assertion;
30
+ Object.freeze(this);
31
+ }
32
+ #settle(remove) {
33
+ if (this.#result)
34
+ return this.#result;
35
+ if (this.#running) {
36
+ this.#reentered = true;
37
+ throw new TypeError("retained-file settlement cannot be reentered");
38
+ }
39
+ this.#running = true;
40
+ let rejection;
41
+ if (remove) {
42
+ try {
43
+ assertSynchronousCallbackResult(this.#assert(), "assertBeforeMutation");
44
+ if (this.#reentered)
45
+ throw new TypeError("retained-file authority reentered settlement");
46
+ }
47
+ catch (cause) {
48
+ rejection = { cause };
49
+ }
50
+ }
51
+ try {
52
+ const result = this.#native.settle(remove && !rejection);
53
+ this.#result = freeze(rejection ? { ...result, phase: "authority", errors: [
54
+ { phase: "authority", code: "denied-path", message: "current mutation authority rejected removal", cause: rejection.cause },
55
+ ...result.errors,
56
+ ] } : result);
57
+ }
58
+ catch (cause) {
59
+ // An unexpected binding exception cannot establish native effect or close.
60
+ // Do not retry or release a dependency on an unknown native outcome.
61
+ this.#result = freeze({ status: "indeterminate", phase: "binding", identity: this.receipt.identity,
62
+ disposition: remove && !rejection ? "indeterminate" : "not-attempted", namespace: "unknown",
63
+ resources: "close-failed", persistence: "not-proven", errors: [
64
+ ...(rejection ? [{ phase: "authority", code: "denied-path", message: "current mutation authority rejected removal", cause: rejection.cause }] : []),
65
+ { phase: "binding", code: "helper-failed", message: "native settlement did not return a result", cause },
66
+ ] });
67
+ }
68
+ return this.#result;
69
+ }
70
+ remove() { return this.#settle(true); }
71
+ dispose() { return this.#settle(false); }
72
+ [Symbol.dispose]() {
73
+ const result = this.dispose();
74
+ if (result.resources !== "closed") {
75
+ throw new FsSafeError("helper-failed", "retained-file resource settlement is uncertain", { details: { result } });
76
+ }
77
+ }
78
+ }
79
+ /** Retain one existing regular file on supported local Windows NTFS. No fallback. */
80
+ export function retainFileInDirectory(options) {
81
+ // Snapshot once before any native resource admission or caller authority runs.
82
+ const directory = options.directory;
83
+ const basename = options.basename;
84
+ const sourceParent = options.parent;
85
+ const parent = Object.freeze({ dev: exact(sourceParent.dev, "parent.dev"), ino: exact(sourceParent.ino, "parent.ino") });
86
+ const source = options.expected;
87
+ const expected = Object.freeze({ dev: exact(source.dev, "expected.dev"), ino: exact(source.ino, "expected.ino"),
88
+ size: exact(source.size, "expected.size", true), mtimeNs: exact(source.mtimeNs, "expected.mtimeNs", true),
89
+ ctimeNs: exact(source.ctimeNs, "expected.ctimeNs", true), sha256: source.sha256 });
90
+ const assertion = options.assertBeforeMutation;
91
+ const maxBytes = options.maxBytes ?? 16 * 1024 * 1024;
92
+ if (typeof directory !== "string" || typeof basename !== "string" || typeof assertion !== "function"
93
+ || typeof expected.sha256 !== "string" || !/^[0-9a-f]{64}$/u.test(expected.sha256)
94
+ || !Number.isSafeInteger(maxBytes) || maxBytes <= 0 || maxBytes > 64 * 1024 * 1024 || expected.size > BigInt(maxBytes)) {
95
+ throw new TypeError("invalid retained-file directory, authority or bounded expected-byte contract");
96
+ }
97
+ if (process.platform !== "win32")
98
+ return unsupported();
99
+ let binding;
100
+ try {
101
+ binding = getNativeBinding();
102
+ }
103
+ catch (cause) {
104
+ return unsupported(cause);
105
+ }
106
+ if (!binding?.retainWindowsFile)
107
+ return unsupported();
108
+ let native;
109
+ try {
110
+ native = binding.retainWindowsFile(directory, basename, parent.dev, parent.ino, expected.dev, expected.ino, expected.size, expected.mtimeNs, expected.ctimeNs, expected.sha256, maxBytes);
111
+ }
112
+ catch (cause) {
113
+ // Factory errors have no returned owner; never infer absence or deletion.
114
+ return freeze({ ...unsupported(cause), status: "indeterminate", resources: "close-failed" });
115
+ }
116
+ const admission = native.admission;
117
+ if (admission.status !== "retained")
118
+ return freeze(admission);
119
+ const receipt = Object.freeze({ directory, parent, basename, expected, identity: admission.identity });
120
+ return Object.freeze({ status: "retained", file: new Owner(native, receipt, assertion) });
121
+ }
@@ -0,0 +1,9 @@
1
+ import { type RootContext } from "./root-context.js";
2
+ import { type RootDirectoryObservationGuard } from "./root-directory-list.js";
3
+ import { type ExactStatIdentity } from "./stat-observation.js";
4
+ import type { DirEntry } from "./types.js";
5
+ /** One literal entry lookup under an operation-local admitted parent; never follow the leaf. */
6
+ export declare function lookupRootDirectoryEntry(root: RootContext, guard: RootDirectoryObservationGuard, name: string): Promise<{
7
+ entry: DirEntry;
8
+ identity: ExactStatIdentity;
9
+ } | undefined>;
@@ -0,0 +1,28 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { FsSafeError } from "./errors.js";
4
+ import { isNotFoundPathError } from "./path.js";
5
+ import { assertValidRootRelativePath } from "./root-context.js";
6
+ import { assertRootDirectoryObservationGuard, pathStatFromStats } from "./root-directory-list.js";
7
+ import { inspectStatObservationSync } from "./stat-observation.js";
8
+ /** One literal entry lookup under an operation-local admitted parent; never follow the leaf. */
9
+ export async function lookupRootDirectoryEntry(root, guard, name) {
10
+ if (!name || name === "." || name === ".." || path.basename(name) !== name || path.isAbsolute(name)) {
11
+ throw new FsSafeError("invalid-path", "directory lookup requires one literal name");
12
+ }
13
+ assertValidRootRelativePath(name);
14
+ await assertRootDirectoryObservationGuard(root, guard);
15
+ try {
16
+ const pathname = path.join(guard.realPath, name);
17
+ const observed = inspectStatObservationSync(bigint => bigint
18
+ ? fs.lstatSync(pathname, { bigint: true }) : fs.lstatSync(pathname));
19
+ await assertRootDirectoryObservationGuard(root, guard);
20
+ return { entry: { name, ...pathStatFromStats(observed.stat) }, identity: observed.identity };
21
+ }
22
+ catch (error) {
23
+ await assertRootDirectoryObservationGuard(root, guard);
24
+ if (isNotFoundPathError(error))
25
+ return undefined;
26
+ throw error;
27
+ }
28
+ }