@openclaw/fs-safe 0.20.0 → 0.21.1

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 (100) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +9 -1
  3. package/dist/advanced.d.ts +2 -0
  4. package/dist/advanced.js +1 -0
  5. package/dist/archive-zip-directory.js +4 -0
  6. package/dist/archive-zip-loader.js +3 -1
  7. package/dist/archive-zip-manifest.js +3 -1
  8. package/dist/atomic.d.ts +1 -1
  9. package/dist/copy-publication.d.ts +1 -3
  10. package/dist/copy-publication.js +2 -6
  11. package/dist/deny-mutation-match.d.ts +2 -0
  12. package/dist/deny-mutation-match.js +71 -0
  13. package/dist/deny-mutations.js +4 -3
  14. package/dist/file-identity.js +10 -2
  15. package/dist/file-lock-sync-admission.js +5 -1
  16. package/dist/file-lock-sync-root-io.d.ts +2 -2
  17. package/dist/file-lock-sync-root-io.js +1 -1
  18. package/dist/file-lock-sync.js +2 -2
  19. package/dist/file-store-prune.js +4 -4
  20. package/dist/mutation-authority.js +5 -0
  21. package/dist/native-binding.d.ts +33 -0
  22. package/dist/native-pinned-write.js +8 -2
  23. package/dist/pinned-mutation-admission.js +4 -2
  24. package/dist/replace-file-buffer.d.ts +4 -0
  25. package/dist/replace-file-buffer.js +36 -0
  26. package/dist/replace-file-copy-fallback.d.ts +2 -0
  27. package/dist/replace-file-copy-fallback.js +66 -38
  28. package/dist/replace-file-descriptor.d.ts +4 -0
  29. package/dist/replace-file-descriptor.js +9 -1
  30. package/dist/replace-file-destination.d.ts +21 -0
  31. package/dist/replace-file-destination.js +123 -0
  32. package/dist/replace-file-mutation.d.ts +26 -0
  33. package/dist/replace-file-mutation.js +47 -0
  34. package/dist/replace-file-temp-owner.d.ts +2 -2
  35. package/dist/replace-file-temp-owner.js +16 -4
  36. package/dist/replace-file-types.d.ts +55 -0
  37. package/dist/replace-file-types.js +1 -0
  38. package/dist/replace-file.d.ts +3 -55
  39. package/dist/replace-file.js +31 -11
  40. package/dist/retained-file-types.d.ts +50 -0
  41. package/dist/retained-file-types.js +1 -0
  42. package/dist/retained-file.d.ts +3 -0
  43. package/dist/retained-file.js +121 -0
  44. package/dist/root-directory-entry.d.ts +9 -0
  45. package/dist/root-directory-entry.js +28 -0
  46. package/dist/root-directory-list.d.ts +9 -1
  47. package/dist/root-directory-list.js +88 -37
  48. package/dist/root-handle-context.d.ts +4 -0
  49. package/dist/root-handle-context.js +12 -0
  50. package/dist/root-impl.d.ts +3 -3
  51. package/dist/root-impl.js +8 -2
  52. package/dist/root-move-noreplace.js +3 -3
  53. package/dist/root-walk.d.ts +19 -12
  54. package/dist/root-walk.js +49 -18
  55. package/dist/sidecar-lock-acquire.js +3 -3
  56. package/dist/sidecar-lock-reclaim.d.ts +2 -2
  57. package/dist/sidecar-lock-reclaim.js +6 -6
  58. package/dist/sidecar-lock.js +4 -4
  59. package/dist/staged-symlink-types.d.ts +4 -14
  60. package/dist/temp-target.js +3 -2
  61. package/dist/temp-workspace-admission.js +22 -21
  62. package/dist/temp-workspace-child-admission.d.ts +1 -1
  63. package/dist/temp-workspace-child-admission.js +14 -9
  64. package/dist/temp-workspace-ownership.d.ts +8 -0
  65. package/dist/temp-workspace-ownership.js +52 -0
  66. package/dist/test-hooks.d.ts +4 -0
  67. package/dist/watch-alias.d.ts +6 -0
  68. package/dist/watch-alias.js +88 -0
  69. package/dist/watch-hints.d.ts +9 -0
  70. package/dist/watch-hints.js +93 -0
  71. package/dist/watch-native.d.ts +36 -0
  72. package/dist/watch-native.js +73 -0
  73. package/dist/watch-scan.d.ts +28 -0
  74. package/dist/watch-scan.js +300 -0
  75. package/dist/watch-stream.d.ts +8 -0
  76. package/dist/watch-stream.js +32 -0
  77. package/dist/watch-types.d.ts +60 -0
  78. package/dist/watch-types.js +1 -0
  79. package/dist/watch.d.ts +5 -0
  80. package/dist/watch.js +530 -0
  81. package/docs/advanced.md +1 -0
  82. package/docs/archive.md +6 -0
  83. package/docs/atomic.md +82 -0
  84. package/docs/contributing.md +74 -6
  85. package/docs/durability.md +7 -0
  86. package/docs/index.md +1 -0
  87. package/docs/install.md +2 -0
  88. package/docs/native-helper.md +9 -0
  89. package/docs/native.md +24 -6
  90. package/docs/public-api.md +23 -2
  91. package/docs/retained-file.md +115 -0
  92. package/docs/root.md +25 -8
  93. package/docs/sidecar-lock.md +1 -1
  94. package/docs/staged-symlink.md +2 -1
  95. package/docs/temp.md +24 -4
  96. package/docs/testing.md +186 -4
  97. package/docs/types.md +6 -0
  98. package/docs/walk.md +22 -1
  99. package/docs/watch.md +251 -0
  100. package/package.json +12 -8
@@ -6,6 +6,9 @@ import { inspectFileIdentity, inspectFileIdentitySync } from "./strict-file-iden
6
6
  import { readOwnedCopySource, readOwnedCopySourceSync } from "./replace-file-copy-source.js";
7
7
  import { resolveReadOpenFlags } from "./read-open-flags.js";
8
8
  import { hasErrorCode } from "./file-cleanup.js";
9
+ import { writeAtomicDestination, writeAtomicDestinationSync } from "./replace-file-buffer.js";
10
+ import { AtomicMutation } from "./replace-file-mutation.js";
11
+ import { captureAtomicDestination, captureAtomicDestinationSync } from "./replace-file-destination.js";
9
12
  const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in syncFs.constants;
10
13
  const NOFOLLOW = SUPPORTS_NOFOLLOW ? syncFs.constants.O_NOFOLLOW : 0;
11
14
  const OPEN_READ_FLAGS = resolveReadOpenFlags();
@@ -44,7 +47,7 @@ function inspectPinnedDestinationSync(fsModule, fd, dest, admission, hardlinks)
44
47
  inspectFileIdentitySync(() => admitDestinationKind(fsModule.lstatSync(dest, { bigint: true }), opened, dest, admission), opened);
45
48
  assertDestinationLinks(opened, dest, admission, hardlinks);
46
49
  }
47
- async function openPinnedDestination(fsModule, dest, admission, hardlinks) {
50
+ async function openPinnedDestination(fsModule, dest, admission, hardlinks, mutation) {
48
51
  let preview;
49
52
  try {
50
53
  preview = fsModule === fs ? syncFs.lstatSync(dest) : await fsModule.lstat(dest);
@@ -61,6 +64,7 @@ async function openPinnedDestination(fsModule, dest, admission, hardlinks) {
61
64
  if (admission === "restore" && preview.isSymbolicLink()) {
62
65
  throw new FsSafeError("symlink", `Refusing copy fallback through symlink destination: ${dest}`);
63
66
  }
67
+ mutation?.assert();
64
68
  const handle = await fsModule.open(dest, admission === "restore" ? OPEN_READ_WRITE_FLAGS : OPEN_READ_FLAGS);
65
69
  try {
66
70
  if (fsModule === fs)
@@ -82,7 +86,7 @@ async function openPinnedDestination(fsModule, dest, admission, hardlinks) {
82
86
  throw error;
83
87
  }
84
88
  }
85
- function openPinnedDestinationSync(fsModule, dest, admission, hardlinks) {
89
+ function openPinnedDestinationSync(fsModule, dest, admission, hardlinks, mutation) {
86
90
  let preview;
87
91
  try {
88
92
  preview = fsModule.lstatSync(dest);
@@ -97,6 +101,7 @@ function openPinnedDestinationSync(fsModule, dest, admission, hardlinks) {
97
101
  if (admission === "restore" && preview.isSymbolicLink()) {
98
102
  throw new FsSafeError("symlink", `Refusing copy fallback through symlink destination: ${dest}`);
99
103
  }
104
+ mutation?.assert();
100
105
  const fd = fsModule.openSync(dest, admission === "restore" ? OPEN_READ_WRITE_FLAGS : OPEN_READ_FLAGS);
101
106
  try {
102
107
  inspectPinnedDestinationSync(fsModule, fd, dest, admission, hardlinks);
@@ -148,28 +153,6 @@ function readRestoreSnapshotSync(fsModule, fd, maxBytes, stat) {
148
153
  return bytesRead;
149
154
  }, restoreReadOptions(stat, maxBytes));
150
155
  }
151
- async function writeAll(handle, data) {
152
- await handle.truncate(0);
153
- let written = 0;
154
- while (written < data.length) {
155
- const result = await handle.write(data, written, data.length - written, written);
156
- if (result.bytesWritten === 0)
157
- throw new Error("Copy fallback write made no progress");
158
- written += result.bytesWritten;
159
- }
160
- await handle.truncate(data.length);
161
- }
162
- function writeAllSync(fsModule, fd, data) {
163
- fsModule.ftruncateSync(fd, 0);
164
- let written = 0;
165
- while (written < data.length) {
166
- const bytesWritten = fsModule.writeSync(fd, data, written, data.length - written, written);
167
- if (bytesWritten === 0)
168
- throw new Error("Copy fallback write made no progress");
169
- written += bytesWritten;
170
- }
171
- fsModule.ftruncateSync(fd, data.length);
172
- }
173
156
  function restoreFailure(writeError, cleanup, restoreError) {
174
157
  const primary = writeError instanceof Error ? writeError : new Error(String(writeError));
175
158
  const details = { cleanup };
@@ -180,23 +163,29 @@ function restoreFailure(writeError, cleanup, restoreError) {
180
163
  ? `Atomic copy fallback failed; original destination restored: ${primary.message}`
181
164
  : `Atomic copy fallback failed and original restoration failed: ${primary.message}`, { cause, details });
182
165
  }
183
- async function replacePinnedWithRestore(fsModule, handle, replacement, maxRestoreBytes, replacementMode) {
166
+ async function replacePinnedWithRestore(fsModule, handle, replacement, maxRestoreBytes, replacementMode, destination, mutation) {
184
167
  const originalStat = fsModule === fs ? syncFs.fstatSync(handle.fd) : await handle.stat();
185
168
  const originalMode = originalStat.mode;
186
169
  const original = await readRestoreSnapshot(handle, maxRestoreBytes, originalStat);
187
170
  try {
188
- await writeAll(handle, replacement);
171
+ await writeAtomicDestination(handle, replacement, destination?.beforeWrite, destination?.assertBeforeMutation, destination?.writing);
172
+ if (destination)
173
+ await destination.verify();
189
174
  await handle.chmod(replacementMode);
190
175
  await handle.sync();
191
176
  }
192
177
  catch (writeError) {
178
+ mutation.rethrowRefusal();
193
179
  try {
194
- await writeAll(handle, original);
180
+ await writeAtomicDestination(handle, original, destination?.beforeRestore, destination?.assertBeforeMutation, destination?.writing);
181
+ if (destination)
182
+ await destination.verifyRestore();
195
183
  await handle.chmod(originalMode);
196
184
  await handle.sync();
197
185
  throw restoreFailure(writeError, "restored");
198
186
  }
199
187
  catch (restoreError) {
188
+ mutation.rethrowRefusal();
200
189
  if (restoreError instanceof FsSafeError && restoreError.details?.cleanup === "restored") {
201
190
  throw restoreError;
202
191
  }
@@ -204,23 +193,27 @@ async function replacePinnedWithRestore(fsModule, handle, replacement, maxRestor
204
193
  }
205
194
  }
206
195
  }
207
- function replacePinnedWithRestoreSync(fsModule, fd, replacement, maxRestoreBytes, replacementMode, fchmodSync) {
196
+ function replacePinnedWithRestoreSync(fsModule, fd, replacement, maxRestoreBytes, replacementMode, fchmodSync, destination, mutation) {
208
197
  const originalStat = fsModule.fstatSync(fd);
209
198
  const originalMode = originalStat.mode;
210
199
  const original = readRestoreSnapshotSync(fsModule, fd, maxRestoreBytes, originalStat);
211
200
  try {
212
- writeAllSync(fsModule, fd, replacement);
201
+ writeAtomicDestinationSync(fsModule, fd, replacement, destination?.beforeWrite, destination?.writing);
202
+ destination?.verify();
213
203
  fchmodSync?.(fd, replacementMode);
214
204
  fsModule.fsyncSync(fd);
215
205
  }
216
206
  catch (writeError) {
207
+ mutation?.rethrowRefusal();
217
208
  try {
218
- writeAllSync(fsModule, fd, original);
209
+ writeAtomicDestinationSync(fsModule, fd, original, destination?.beforeRestore, destination?.writing);
210
+ destination?.verifyRestore();
219
211
  fchmodSync?.(fd, originalMode);
220
212
  fsModule.fsyncSync(fd);
221
213
  throw restoreFailure(writeError, "restored");
222
214
  }
223
215
  catch (restoreError) {
216
+ mutation?.rethrowRefusal();
224
217
  if (restoreError instanceof FsSafeError && restoreError.details?.cleanup === "restored") {
225
218
  throw restoreError;
226
219
  }
@@ -229,6 +222,7 @@ function replacePinnedWithRestoreSync(fsModule, fd, replacement, maxRestoreBytes
229
222
  }
230
223
  }
231
224
  export async function copyFallbackReplace(params) {
225
+ const mutation = params.mutation ?? new AtomicMutation({});
232
226
  const source = await readOwnedCopySource({
233
227
  fsModule: params.fsModule,
234
228
  src: params.src,
@@ -237,12 +231,16 @@ export async function copyFallbackReplace(params) {
237
231
  const { replacement } = source;
238
232
  let destHandle = null;
239
233
  let closeRequiredForSuccess = false;
234
+ let completed = false;
235
+ let destination;
240
236
  try {
241
237
  if (params.restore === "restore-original") {
242
- const pinned = await openPinnedDestination(params.fsModule, params.dest, "restore", params.destinationHardlinks);
238
+ const pinned = await openPinnedDestination(params.fsModule, params.dest, "restore", params.destinationHardlinks, mutation);
243
239
  if (pinned) {
244
240
  destHandle = pinned;
245
- await replacePinnedWithRestore(params.fsModule, destHandle, replacement, params.maxRestoreBytes, source.mode);
241
+ if (mutation.active)
242
+ destination = await captureAtomicDestination(params.fsModule, pinned, params.dest, mutation, params.destinationHardlinks === "reject");
243
+ await replacePinnedWithRestore(params.fsModule, destHandle, replacement, params.maxRestoreBytes, source.mode, destination, mutation);
246
244
  }
247
245
  }
248
246
  if (!destHandle) {
@@ -260,16 +258,31 @@ export async function copyFallbackReplace(params) {
260
258
  }
261
259
  if (destStat) {
262
260
  await assertDestinationHardlinkPolicy(params.fsModule, params.dest, params.destinationHardlinks);
261
+ mutation.assert();
263
262
  await params.fsModule.rm(params.dest, { force: true });
263
+ mutation.removed(params.dest);
264
264
  }
265
+ mutation.assert();
265
266
  destHandle = await params.fsModule.open(params.dest, OPEN_WRITE_EXCLUSIVE_FLAGS, source.mode & 0o777);
266
- await destHandle.writeFile(replacement);
267
+ if (mutation.active) {
268
+ destination = await captureAtomicDestination(params.fsModule, destHandle, params.dest, mutation, params.destinationHardlinks === "reject");
269
+ destination.writing();
270
+ await writeAtomicDestination(destHandle, replacement, destination.beforeWrite, destination.assertBeforeMutation);
271
+ await destination.verify();
272
+ }
273
+ else {
274
+ await destHandle.writeFile(replacement);
275
+ }
267
276
  await destHandle.chmod(source.mode);
268
277
  if (params.sync) {
269
278
  await destHandle.sync();
270
279
  }
271
280
  closeRequiredForSuccess = !params.sync;
272
281
  }
282
+ if (destination)
283
+ await destination.verify();
284
+ destination?.published();
285
+ completed = true;
273
286
  }
274
287
  finally {
275
288
  if (destHandle) {
@@ -277,7 +290,7 @@ export async function copyFallbackReplace(params) {
277
290
  await destHandle.close();
278
291
  }
279
292
  catch (closeError) {
280
- if (closeRequiredForSuccess) {
293
+ if (closeRequiredForSuccess && completed) {
281
294
  throw closeError;
282
295
  }
283
296
  }
@@ -285,6 +298,7 @@ export async function copyFallbackReplace(params) {
285
298
  }
286
299
  }
287
300
  export function copyFallbackReplaceSync(params) {
301
+ const mutation = params.mutation ?? new AtomicMutation({});
288
302
  const source = readOwnedCopySourceSync({
289
303
  fsModule: params.fsModule,
290
304
  src: params.src,
@@ -293,12 +307,16 @@ export function copyFallbackReplaceSync(params) {
293
307
  const { replacement } = source;
294
308
  let destFd;
295
309
  let closeRequiredForSuccess = false;
310
+ let completed = false;
311
+ let destination;
296
312
  try {
297
313
  if (params.restore === "restore-original") {
298
- const pinned = openPinnedDestinationSync(params.fsModule, params.dest, "restore", params.destinationHardlinks);
314
+ const pinned = openPinnedDestinationSync(params.fsModule, params.dest, "restore", params.destinationHardlinks, mutation);
299
315
  if (pinned !== null) {
300
316
  destFd = pinned;
301
- replacePinnedWithRestoreSync(params.fsModule, destFd, replacement, params.maxRestoreBytes, source.mode, params.fchmodSync);
317
+ if (mutation.active)
318
+ destination = captureAtomicDestinationSync(params.fsModule, pinned, params.dest, mutation, params.destinationHardlinks === "reject");
319
+ replacePinnedWithRestoreSync(params.fsModule, destFd, replacement, params.maxRestoreBytes, source.mode, params.fchmodSync, destination, mutation);
302
320
  }
303
321
  }
304
322
  if (destFd === undefined) {
@@ -315,16 +333,26 @@ export function copyFallbackReplaceSync(params) {
315
333
  }
316
334
  if (destStat) {
317
335
  assertDestinationHardlinkPolicySync(params.fsModule, params.dest, params.destinationHardlinks);
336
+ mutation.assert();
318
337
  params.fsModule.rmSync(params.dest, { force: true });
338
+ mutation.removed(params.dest);
319
339
  }
340
+ mutation.assert();
320
341
  destFd = params.fsModule.openSync(params.dest, OPEN_WRITE_EXCLUSIVE_FLAGS, source.mode & 0o777);
321
- writeAllSync(params.fsModule, destFd, replacement);
342
+ if (mutation.active)
343
+ destination = captureAtomicDestinationSync(params.fsModule, destFd, params.dest, mutation, params.destinationHardlinks === "reject");
344
+ destination?.writing();
345
+ writeAtomicDestinationSync(params.fsModule, destFd, replacement, destination?.beforeWrite);
346
+ destination?.verify();
322
347
  params.fchmodSync?.(destFd, source.mode);
323
348
  if (params.sync) {
324
349
  params.fsModule.fsyncSync(destFd);
325
350
  }
326
351
  closeRequiredForSuccess = !params.sync;
327
352
  }
353
+ destination?.verify();
354
+ destination?.published();
355
+ completed = true;
328
356
  }
329
357
  finally {
330
358
  if (destFd !== undefined) {
@@ -332,7 +360,7 @@ export function copyFallbackReplaceSync(params) {
332
360
  params.fsModule.closeSync(destFd);
333
361
  }
334
362
  catch (closeError) {
335
- if (closeRequiredForSuccess) {
363
+ if (closeRequiredForSuccess && completed) {
336
364
  throw closeError;
337
365
  }
338
366
  }
@@ -1,6 +1,7 @@
1
1
  import syncFs, { type BigIntStats } from "node:fs";
2
2
  import fs, { type FileHandle } from "node:fs/promises";
3
3
  import { type DirectoryModeOwner } from "./directory-mode-node.js";
4
+ import type { AtomicMutation } from "./replace-file-mutation.js";
4
5
  type AsyncTempFileSystem = Pick<typeof fs, "lstat" | "open" | "writeFile">;
5
6
  type SyncTempFileSystem = Pick<typeof syncFs, "closeSync" | "fstatSync" | "fsyncSync" | "lstatSync" | "openSync" | "writeFileSync">;
6
7
  export type SyncFchmod = (fd: number, mode: number) => void;
@@ -11,6 +12,7 @@ export declare function pinDirectoryForMode(params: {
11
12
  dirPath: string;
12
13
  /** Compatibility for best-effort directory modes; admission and close still fail closed. */
13
14
  ignoreChmodError?: boolean;
15
+ mutation?: AtomicMutation;
14
16
  }): Promise<DirectoryModeOwner | undefined>;
15
17
  export declare function applyDirectoryMode(params: Parameters<typeof pinDirectoryForMode>[0] & {
16
18
  mode: number;
@@ -20,6 +22,7 @@ export declare function applyDirectoryModeSync(params: {
20
22
  dirPath: string;
21
23
  mode: number;
22
24
  fchmodSync?: SyncFchmod;
25
+ mutation?: AtomicMutation;
23
26
  }): void;
24
27
  export declare function writeTempFile(params: {
25
28
  fsModule: AsyncTempFileSystem;
@@ -28,6 +31,7 @@ export declare function writeTempFile(params: {
28
31
  mode: number;
29
32
  sync: boolean;
30
33
  onIdentity?: (identity: BigIntStats) => void;
34
+ mutation?: AtomicMutation;
31
35
  }): Promise<{
32
36
  handle: FileHandle;
33
37
  identity: BigIntStats;
@@ -73,7 +73,10 @@ export async function pinDirectoryForMode(params) {
73
73
  : await inspectFileIdentity(async () => assertDirectory(await handle.stat({ bigint: true }), params.dirPath), expected);
74
74
  return Number(opened.mode & 4095n);
75
75
  },
76
- chmod: (mode) => handle.chmod(mode),
76
+ chmod: (mode) => {
77
+ params.mutation?.assert();
78
+ return handle.chmod(mode);
79
+ },
77
80
  close: () => handle.close(),
78
81
  ignoreChmodError: params.ignoreChmodError,
79
82
  });
@@ -103,6 +106,7 @@ export function applyDirectoryModeSync(params) {
103
106
  try {
104
107
  inspectFileIdentitySync(() => assertDirectory(params.fsModule.fstatSync(fd, { bigint: true }), params.dirPath), expected);
105
108
  // chmod ignores file-type bits; mask so raw stat modes are tolerated.
109
+ params.mutation?.assert();
106
110
  params.fchmodSync?.(fd, params.mode & 0o7777);
107
111
  }
108
112
  finally {
@@ -110,6 +114,7 @@ export function applyDirectoryModeSync(params) {
110
114
  }
111
115
  }
112
116
  export async function writeTempFile(params) {
117
+ params.mutation?.assert();
113
118
  const handle = await params.fsModule.open(params.tempPath, "wx", params.mode);
114
119
  try {
115
120
  // Custom adapters retain their async-only metadata contract.
@@ -117,6 +122,7 @@ export async function writeTempFile(params) {
117
122
  ? syncFs.fstatSync(handle.fd, { bigint: true }) : handle.stat({ bigint: true });
118
123
  const identity = await inspectFileIdentity(inspect);
119
124
  params.onIdentity?.(identity);
125
+ params.mutation?.assert();
120
126
  await params.fsModule.writeFile(handle, params.content);
121
127
  await handle.chmod(params.mode);
122
128
  if (params.sync) {
@@ -136,10 +142,12 @@ export async function writeTempFile(params) {
136
142
  }
137
143
  }
138
144
  export function writeTempFileSync(params) {
145
+ params.mutation?.assert();
139
146
  const fd = params.fsModule.openSync(params.tempPath, "wx", params.mode);
140
147
  try {
141
148
  const identity = inspectFileIdentitySync(() => params.fsModule.fstatSync(fd, { bigint: true }));
142
149
  params.onIdentity?.(identity);
150
+ params.mutation?.assert();
143
151
  params.fsModule.writeFileSync(fd, params.content);
144
152
  params.fchmodSync?.(fd, params.mode);
145
153
  if (params.sync) {
@@ -0,0 +1,21 @@
1
+ import fsSync from "node:fs";
2
+ import fs, { type FileHandle } from "node:fs/promises";
3
+ import type { AtomicMutation } from "./replace-file-mutation.js";
4
+ /** Borrow the writer's descriptor; never adopt an identity from a later pathname open. */
5
+ export declare function captureAtomicDestination(fsModule: Pick<typeof fs, "lstat">, handle: FileHandle, pathname: string, mutation: AtomicMutation, rejectHardlinks: boolean): Promise<{
6
+ verify: () => Promise<void>;
7
+ writing: () => void;
8
+ beforeWrite: () => Promise<void>;
9
+ beforeRestore: () => Promise<void>;
10
+ verifyRestore: () => Promise<void>;
11
+ assertBeforeMutation: () => void;
12
+ published: () => void;
13
+ }>;
14
+ export declare function captureAtomicDestinationSync(fsModule: Pick<typeof fsSync, "lstatSync" | "fstatSync">, fd: number, pathname: string, mutation: AtomicMutation, rejectHardlinks: boolean): {
15
+ verify: () => void;
16
+ writing: () => void;
17
+ beforeWrite: () => void;
18
+ beforeRestore: () => void;
19
+ verifyRestore: () => void;
20
+ published: () => void;
21
+ };
@@ -0,0 +1,123 @@
1
+ import fsSync, {} from "node:fs";
2
+ import fs, {} from "node:fs/promises";
3
+ import { hasErrorCode } from "./file-cleanup.js";
4
+ import { FsSafeError } from "./errors.js";
5
+ import { inspectFileIdentity, inspectFileIdentitySync } from "./strict-file-identity.js";
6
+ function regular(stat, pathname, rejectHardlinks) {
7
+ if (stat.isSymbolicLink() || !stat.isFile()) {
8
+ throw new FsSafeError("path-mismatch", `Atomic replace destination changed: ${pathname}`);
9
+ }
10
+ if (rejectHardlinks && stat.nlink !== 1n) {
11
+ throw new FsSafeError("hardlink", `Hardlinked atomic replace destination not allowed: ${pathname}`);
12
+ }
13
+ return stat;
14
+ }
15
+ /** Borrow the writer's descriptor; never adopt an identity from a later pathname open. */
16
+ export async function captureAtomicDestination(fsModule, handle, pathname, mutation, rejectHardlinks) {
17
+ const inspect = () => fsModule === fs
18
+ ? fsSync.fstatSync(handle.fd, { bigint: true }) : handle.stat({ bigint: true });
19
+ const identity = await inspectFileIdentity(inspect);
20
+ regular(identity, pathname, rejectHardlinks);
21
+ let writing = false;
22
+ let restoreDescriptorOnly = false;
23
+ const verifyDescriptor = async () => {
24
+ await inspectFileIdentity(async () => regular(await inspect(), pathname, rejectHardlinks), identity);
25
+ };
26
+ const verify = async () => {
27
+ let metadataFailure = false;
28
+ const observe = async (read) => {
29
+ try {
30
+ return await read();
31
+ }
32
+ catch (error) {
33
+ metadataFailure = hasErrorCode(error, "EIO");
34
+ throw error;
35
+ }
36
+ };
37
+ try {
38
+ await inspectFileIdentity(async () => regular(await observe(inspect), pathname, rejectHardlinks), identity);
39
+ await inspectFileIdentity(async () => regular(await observe(() => fsModule.lstat(pathname, { bigint: true })), pathname, rejectHardlinks), identity);
40
+ }
41
+ catch (error) {
42
+ if (writing && metadataFailure) {
43
+ // A metadata observation failed, but the already-owned descriptor can
44
+ // still restore its bytes after fresh exact descriptor verification.
45
+ restoreDescriptorOnly = true;
46
+ throw error;
47
+ }
48
+ mutation.refuse(error);
49
+ }
50
+ };
51
+ const verifyRestore = async () => {
52
+ if (!restoreDescriptorOnly)
53
+ return await verify();
54
+ try {
55
+ await verifyDescriptor();
56
+ }
57
+ catch (error) {
58
+ mutation.refuse(error);
59
+ }
60
+ };
61
+ return {
62
+ verify,
63
+ writing: () => { writing = true; mutation.destination("writing", pathname, identity); },
64
+ beforeWrite: async () => {
65
+ mutation.assert();
66
+ await verify();
67
+ },
68
+ beforeRestore: async () => { mutation.assert(); await verifyRestore(); },
69
+ verifyRestore,
70
+ assertBeforeMutation: () => mutation.assert(),
71
+ published: () => mutation.destination("published", pathname, identity),
72
+ };
73
+ }
74
+ export function captureAtomicDestinationSync(fsModule, fd, pathname, mutation, rejectHardlinks) {
75
+ const identity = inspectFileIdentitySync(() => fsModule.fstatSync(fd, { bigint: true }));
76
+ regular(identity, pathname, rejectHardlinks);
77
+ let writing = false;
78
+ let restoreDescriptorOnly = false;
79
+ const verify = () => {
80
+ let metadataFailure = false;
81
+ const observe = (read) => {
82
+ try {
83
+ return read();
84
+ }
85
+ catch (error) {
86
+ metadataFailure = hasErrorCode(error, "EIO");
87
+ throw error;
88
+ }
89
+ };
90
+ try {
91
+ inspectFileIdentitySync(() => regular(observe(() => fsModule.fstatSync(fd, { bigint: true })), pathname, rejectHardlinks), identity);
92
+ inspectFileIdentitySync(() => regular(observe(() => fsModule.lstatSync(pathname, { bigint: true })), pathname, rejectHardlinks), identity);
93
+ }
94
+ catch (error) {
95
+ if (writing && metadataFailure) {
96
+ restoreDescriptorOnly = true;
97
+ throw error;
98
+ }
99
+ mutation.refuse(error);
100
+ }
101
+ };
102
+ const verifyRestore = () => {
103
+ if (!restoreDescriptorOnly)
104
+ return verify();
105
+ try {
106
+ inspectFileIdentitySync(() => regular(fsModule.fstatSync(fd, { bigint: true }), pathname, rejectHardlinks), identity);
107
+ }
108
+ catch (error) {
109
+ mutation.refuse(error);
110
+ }
111
+ };
112
+ return {
113
+ verify,
114
+ writing: () => { writing = true; mutation.destination("writing", pathname, identity); },
115
+ beforeWrite: () => {
116
+ mutation.assert();
117
+ verify();
118
+ },
119
+ beforeRestore: () => { mutation.assert(); verifyRestore(); },
120
+ verifyRestore,
121
+ published: () => mutation.destination("published", pathname, identity),
122
+ };
123
+ }
@@ -0,0 +1,26 @@
1
+ import type { BigIntStats } from "node:fs";
2
+ export type ReplaceFileAtomicDestinationState = Readonly<{
3
+ state: "removed";
4
+ path: string;
5
+ }> | Readonly<{
6
+ state: "writing" | "published";
7
+ path: string;
8
+ dev: bigint;
9
+ ino: bigint;
10
+ }>;
11
+ export type AtomicMutationOptions = {
12
+ /** Recheck live authority before new effects; retained descriptor completion may settle. */
13
+ assertBeforeMutation?: () => void;
14
+ /** Retain observed destination facts even when later completion fails. */
15
+ onDestinationState?: (state: ReplaceFileAtomicDestinationState) => void;
16
+ };
17
+ export declare class AtomicMutation {
18
+ #private;
19
+ readonly active: boolean;
20
+ constructor(options: AtomicMutationOptions);
21
+ rethrowRefusal(): void;
22
+ refuse(error: unknown): never;
23
+ assert(): void;
24
+ destination(state: "writing" | "published", path: string, identity: BigIntStats): void;
25
+ removed(path: string): void;
26
+ }
@@ -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;