@openclaw/fs-safe 0.7.1 → 0.8.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 (157) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +5 -1
  3. package/dist/absolute-path.d.ts.map +1 -1
  4. package/dist/absolute-path.js +9 -10
  5. package/dist/archive-merge.d.ts +18 -0
  6. package/dist/archive-merge.d.ts.map +1 -0
  7. package/dist/archive-merge.js +157 -0
  8. package/dist/archive-native.d.ts.map +1 -1
  9. package/dist/archive-native.js +25 -5
  10. package/dist/archive-policy.d.ts +1 -1
  11. package/dist/archive-policy.d.ts.map +1 -1
  12. package/dist/archive-policy.js +1 -1
  13. package/dist/archive-read.d.ts.map +1 -1
  14. package/dist/archive-read.js +3 -2
  15. package/dist/archive-staging.d.ts +13 -8
  16. package/dist/archive-staging.d.ts.map +1 -1
  17. package/dist/archive-staging.js +44 -192
  18. package/dist/archive-tar-meta.d.ts.map +1 -1
  19. package/dist/archive-tar-meta.js +9 -5
  20. package/dist/archive-tar-runtime.d.ts +1 -0
  21. package/dist/archive-tar-runtime.d.ts.map +1 -1
  22. package/dist/archive-zip-admission.d.ts +2 -1
  23. package/dist/archive-zip-admission.d.ts.map +1 -1
  24. package/dist/archive-zip-admission.js +2 -2
  25. package/dist/archive-zip-directory.d.ts +8 -1
  26. package/dist/archive-zip-directory.d.ts.map +1 -1
  27. package/dist/archive-zip-directory.js +6 -2
  28. package/dist/archive-zip-entry.d.ts +1 -1
  29. package/dist/archive-zip-entry.d.ts.map +1 -1
  30. package/dist/archive-zip-names.d.ts +1 -1
  31. package/dist/archive-zip-names.d.ts.map +1 -1
  32. package/dist/archive-zip-names.js +1 -0
  33. package/dist/archive.d.ts.map +1 -1
  34. package/dist/archive.js +17 -25
  35. package/dist/directory-guard.d.ts +17 -5
  36. package/dist/directory-guard.d.ts.map +1 -1
  37. package/dist/directory-guard.js +8 -6
  38. package/dist/directory-mode-node.d.ts +8 -0
  39. package/dist/directory-mode-node.d.ts.map +1 -0
  40. package/dist/directory-mode-node.js +93 -0
  41. package/dist/directory-mode-owner.d.ts +21 -0
  42. package/dist/directory-mode-owner.d.ts.map +1 -0
  43. package/dist/directory-mode-owner.js +73 -0
  44. package/dist/file-observation.d.ts +1 -1
  45. package/dist/file-observation.d.ts.map +1 -1
  46. package/dist/file-store-boundary.d.ts +2 -0
  47. package/dist/file-store-boundary.d.ts.map +1 -1
  48. package/dist/file-store-boundary.js +13 -0
  49. package/dist/file-store.d.ts.map +1 -1
  50. package/dist/file-store.js +8 -11
  51. package/dist/filename.d.ts +6 -0
  52. package/dist/filename.d.ts.map +1 -1
  53. package/dist/filename.js +36 -0
  54. package/dist/guarded-mkdir.d.ts +2 -0
  55. package/dist/guarded-mkdir.d.ts.map +1 -1
  56. package/dist/guarded-mkdir.js +2 -2
  57. package/dist/guarded-mutation.d.ts +3 -2
  58. package/dist/guarded-mutation.d.ts.map +1 -1
  59. package/dist/guarded-mutation.js +2 -0
  60. package/dist/json-document-store.d.ts +3 -0
  61. package/dist/json-document-store.d.ts.map +1 -1
  62. package/dist/json-document-store.js +2 -0
  63. package/dist/json-durable-queue-ownership.d.ts +3 -1
  64. package/dist/json-durable-queue-ownership.d.ts.map +1 -1
  65. package/dist/json-durable-queue-ownership.js +7 -4
  66. package/dist/json-durable-queue.d.ts.map +1 -1
  67. package/dist/json-durable-queue.js +25 -28
  68. package/dist/local-roots.d.ts.map +1 -1
  69. package/dist/local-roots.js +18 -56
  70. package/dist/move-path-cleanup.d.ts +43 -0
  71. package/dist/move-path-cleanup.d.ts.map +1 -0
  72. package/dist/move-path-cleanup.js +159 -0
  73. package/dist/move-path.d.ts +2 -0
  74. package/dist/move-path.d.ts.map +1 -1
  75. package/dist/move-path.js +47 -83
  76. package/dist/native-operations.d.ts +2 -4
  77. package/dist/native-operations.d.ts.map +1 -1
  78. package/dist/native-operations.js +7 -9
  79. package/dist/native-pinned-write-windows.d.ts +2 -2
  80. package/dist/native-pinned-write-windows.d.ts.map +1 -1
  81. package/dist/native-pinned-write-windows.js +5 -5
  82. package/dist/native-pinned-write.d.ts.map +1 -1
  83. package/dist/native-pinned-write.js +14 -4
  84. package/dist/native-staged-file.d.ts +2 -2
  85. package/dist/native-staged-file.d.ts.map +1 -1
  86. package/dist/opened-file-failure.d.ts +1 -0
  87. package/dist/opened-file-failure.d.ts.map +1 -1
  88. package/dist/opened-file-failure.js +4 -0
  89. package/dist/output-sibling.d.ts.map +1 -1
  90. package/dist/output-sibling.js +9 -3
  91. package/dist/pinned-write.d.ts +2 -2
  92. package/dist/pinned-write.d.ts.map +1 -1
  93. package/dist/pinned-write.js +32 -13
  94. package/dist/publish-file.d.ts.map +1 -1
  95. package/dist/publish-file.js +7 -3
  96. package/dist/regular-file.d.ts +1 -1
  97. package/dist/regular-file.d.ts.map +1 -1
  98. package/dist/regular-file.js +35 -6
  99. package/dist/replace-file-descriptor.d.ts +9 -1
  100. package/dist/replace-file-descriptor.d.ts.map +1 -1
  101. package/dist/replace-file-descriptor.js +58 -10
  102. package/dist/replace-file-temp-owner.d.ts +7 -0
  103. package/dist/replace-file-temp-owner.d.ts.map +1 -1
  104. package/dist/replace-file-temp-owner.js +29 -0
  105. package/dist/replace-file.d.ts +1 -0
  106. package/dist/replace-file.d.ts.map +1 -1
  107. package/dist/replace-file.js +12 -38
  108. package/dist/root-context.d.ts +4 -0
  109. package/dist/root-context.d.ts.map +1 -1
  110. package/dist/root-context.js +19 -0
  111. package/dist/root-impl.d.ts +43 -0
  112. package/dist/root-impl.d.ts.map +1 -1
  113. package/dist/root-impl.js +13 -11
  114. package/dist/root-path-symlink.d.ts +6 -2
  115. package/dist/root-path-symlink.d.ts.map +1 -1
  116. package/dist/root-path-symlink.js +7 -6
  117. package/dist/root-path.d.ts +1 -0
  118. package/dist/root-path.d.ts.map +1 -1
  119. package/dist/root-path.js +6 -2
  120. package/dist/root-walk.d.ts.map +1 -1
  121. package/dist/root-walk.js +4 -0
  122. package/dist/root-write-verification.d.ts +3 -2
  123. package/dist/root-write-verification.d.ts.map +1 -1
  124. package/dist/root-write-verification.js +6 -0
  125. package/dist/secret-file.d.ts +9 -0
  126. package/dist/secret-file.d.ts.map +1 -1
  127. package/dist/secret-file.js +136 -74
  128. package/dist/sibling-temp.d.ts.map +1 -1
  129. package/dist/sibling-temp.js +9 -3
  130. package/dist/sidecar-lock-acquire.d.ts.map +1 -1
  131. package/dist/sidecar-lock-acquire.js +18 -5
  132. package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
  133. package/dist/sidecar-lock-reclaim.js +6 -4
  134. package/dist/sidecar-lock.d.ts.map +1 -1
  135. package/dist/sidecar-lock.js +26 -0
  136. package/dist/write-open-flags.d.ts +5 -0
  137. package/dist/write-open-flags.d.ts.map +1 -0
  138. package/dist/write-open-flags.js +35 -0
  139. package/docs/advanced.md +1 -1
  140. package/docs/archive.md +56 -7
  141. package/docs/atomic.md +42 -2
  142. package/docs/contributing.md +13 -0
  143. package/docs/durability.md +3 -2
  144. package/docs/errors.md +3 -1
  145. package/docs/file-store.md +38 -8
  146. package/docs/local-roots.md +4 -3
  147. package/docs/output.md +4 -1
  148. package/docs/private-file-store.md +11 -2
  149. package/docs/reading.md +3 -1
  150. package/docs/regular-file.md +16 -2
  151. package/docs/root.md +4 -2
  152. package/docs/secret-file.md +37 -2
  153. package/docs/sidecar-lock.md +32 -5
  154. package/docs/store.md +5 -1
  155. package/docs/temp.md +19 -1
  156. package/docs/writing.md +24 -3
  157. package/package.json +14 -10
@@ -1 +1 @@
1
- {"version":3,"file":"json-durable-queue.d.ts","sourceRoot":"","sources":["../src/json-durable-queue.ts"],"names":[],"mappings":"AAiBA,MAAM,MAAM,0BAA0B,GAAG;IACvC,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,0BAA0B,CAAC,CAAC,IAAI;IAC1C,KAAK,EAAE,CAAC,CAAC;IACT,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,2BAA2B,CAAC,CAAC,IAAI;IAC3C,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,0BAA0B,CAAC,CAAC,CAAC,CAAC,CAAC;IAC9E,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAOF,eAAO,MAAM,0CAA0C,QAAmB,CAAC;AAY3E,wBAAsB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAEtE;AAED,wBAAsB,2BAA2B,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAUpF;AAmBD,wBAAgB,iCAAiC,CAC/C,QAAQ,EAAE,MAAM,EAChB,EAAE,EAAE,MAAM,GACT,0BAA0B,CAO5B;AAED,wBAAsB,0BAA0B,CAAC,MAAM,EAAE;IACvD,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB,GAAG,OAAO,CAAC,IAAI,CAAC,CAIhB;AAgLD,wBAAsB,0BAA0B,CAAC,MAAM,EAAE;IACvD,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;CACpB,GAAG,OAAO,CAAC,IAAI,CAAC,CAShB;AA4ED,wBAAsB,yBAAyB,CAAC,CAAC,EAC/C,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAO,GAClC,OAAO,CAAC,CAAC,CAAC,CASZ;AAED,wBAAsB,wBAAwB,CAAC,KAAK,EAAE,0BAA0B,GAAG,OAAO,CAAC,IAAI,CAAC,CAE/F;AAED,wBAAsB,yBAAyB,CAAC,CAAC,EAAE,MAAM,EAAE;IACzD,KAAK,EAAE,0BAA0B,CAAC;IAClC,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,0BAA0B,CAAC,CAAC,CAAC,CAAC,CAAC;IAC9E,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAsBpB;AAED,wBAAsB,kCAAkC,CAAC,CAAC,EACxD,OAAO,EAAE,2BAA2B,CAAC,CAAC,CAAC,GACtC,OAAO,CAAC,CAAC,EAAE,CAAC,CA4Dd;AAED,wBAAsB,iCAAiC,CAAC,MAAM,EAAE;IAC9D,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,EAAE,EAAE,MAAM,CAAC;CACZ,GAAG,OAAO,CAAC,IAAI,CAAC,CAShB"}
1
+ {"version":3,"file":"json-durable-queue.d.ts","sourceRoot":"","sources":["../src/json-durable-queue.ts"],"names":[],"mappings":"AAkBA,MAAM,MAAM,0BAA0B,GAAG;IACvC,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,0BAA0B,CAAC,CAAC,IAAI;IAC1C,KAAK,EAAE,CAAC,CAAC;IACT,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,2BAA2B,CAAC,CAAC,IAAI;IAC3C,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,0BAA0B,CAAC,CAAC,CAAC,CAAC,CAAC;IAC9E,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAOF,eAAO,MAAM,0CAA0C,QAAmB,CAAC;AAY3E,wBAAsB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAEtE;AAED,wBAAsB,2BAA2B,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAUpF;AAmBD,wBAAgB,iCAAiC,CAC/C,QAAQ,EAAE,MAAM,EAChB,EAAE,EAAE,MAAM,GACT,0BAA0B,CAO5B;AAED,wBAAsB,0BAA0B,CAAC,MAAM,EAAE;IACvD,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB,GAAG,OAAO,CAAC,IAAI,CAAC,CAIhB;AAgLD,wBAAsB,0BAA0B,CAAC,MAAM,EAAE;IACvD,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;CACpB,GAAG,OAAO,CAAC,IAAI,CAAC,CAQhB;AA6DD,wBAAsB,yBAAyB,CAAC,CAAC,EAC/C,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAO,GAClC,OAAO,CAAC,CAAC,CAAC,CASZ;AAED,wBAAsB,wBAAwB,CAAC,KAAK,EAAE,0BAA0B,GAAG,OAAO,CAAC,IAAI,CAAC,CAE/F;AAED,wBAAsB,yBAAyB,CAAC,CAAC,EAAE,MAAM,EAAE;IACzD,KAAK,EAAE,0BAA0B,CAAC;IAClC,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,0BAA0B,CAAC,CAAC,CAAC,CAAC,CAAC;IAC9E,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAsBpB;AAED,wBAAsB,kCAAkC,CAAC,CAAC,EACxD,OAAO,EAAE,2BAA2B,CAAC,CAAC,CAAC,GACtC,OAAO,CAAC,CAAC,EAAE,CAAC,CA8Dd;AAED,wBAAsB,iCAAiC,CAAC,MAAM,EAAE;IAC9D,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,EAAE,EAAE,MAAM,CAAC;CACZ,GAAG,OAAO,CAAC,IAAI,CAAC,CAShB"}
@@ -1,11 +1,12 @@
1
1
  import fs, {} from "node:fs";
2
2
  import path from "node:path";
3
3
  import { normalizeMaxBytes } from "./byte-budget.js";
4
+ import { syncDirectory } from "./directory-durability.js";
4
5
  import { syncQueueDirectoryCreation } from "./json-durable-queue-directory.js";
5
6
  import { acknowledgeDurableQueueEntry, claimDurableQueueEntry, completeDeliveredQueueEntry, moveDurableQueueEntryToFailed, } from "./json-durable-queue-ownership.js";
6
7
  import { stringifyJsonDocument } from "./json-stringify.js";
7
8
  import { resolveReadOpenFlags } from "./read-open-flags.js";
8
- import { replaceFileAtomic } from "./replace-file.js";
9
+ import { replaceFileAtomicWithDirectorySync } from "./replace-file.js";
9
10
  import { assertSafePathSegment } from "./safe-path-segment.js";
10
11
  import { inspectFileIdentity } from "./strict-file-identity.js";
11
12
  export const DEFAULT_JSON_DURABLE_QUEUE_ENTRY_MAX_BYTES = 16 * 1024 * 1024;
@@ -209,24 +210,13 @@ async function chmodQueueDirectory(dir) {
209
210
  }
210
211
  }
211
212
  export async function writeJsonDurableQueueEntry(params) {
212
- await replaceFileAtomic({
213
+ await replaceFileAtomicWithDirectorySync({
213
214
  filePath: params.filePath,
214
215
  content: stringifyJsonDocument(params.entry, null, 2),
215
216
  mode: 0o600,
216
217
  tempPrefix: params.tempPrefix,
217
218
  syncTempFile: true,
218
- syncParentDir: true,
219
- });
220
- }
221
- async function replaceJsonDurableQueueEntry(params) {
222
- await replaceFileAtomic({
223
- filePath: params.filePath,
224
- content: stringifyJsonDocument(params.entry, null, 2),
225
- mode: 0o600,
226
- tempPrefix: params.tempPrefix,
227
- syncTempFile: true,
228
- syncParentDir: true,
229
- });
219
+ }, syncDirectory);
230
220
  }
231
221
  async function inspectQueueEntry(inspect, maxBytes, expected) {
232
222
  let inspectionFailed = false;
@@ -303,7 +293,7 @@ export async function loadJsonDurableQueueEntry(params) {
303
293
  });
304
294
  const result = params.read ? await params.read(raw, params.paths.jsonPath) : { entry: raw };
305
295
  if (result.migrated) {
306
- await replaceJsonDurableQueueEntry({
296
+ await writeJsonDurableQueueEntry({
307
297
  filePath: claimedPath,
308
298
  entry: result.entry,
309
299
  tempPrefix: params.tempPrefix,
@@ -350,6 +340,12 @@ export async function loadPendingJsonDurableQueueEntries(options) {
350
340
  if (!suffix)
351
341
  continue;
352
342
  const id = file.slice(0, -suffix.length);
343
+ try {
344
+ assertSafeQueueEntryId(id);
345
+ }
346
+ catch {
347
+ continue;
348
+ }
353
349
  if (seenIds.has(id))
354
350
  continue;
355
351
  seenIds.add(id);
@@ -357,25 +353,26 @@ export async function loadPendingJsonDurableQueueEntries(options) {
357
353
  }
358
354
  const entries = [];
359
355
  for (const id of ids) {
356
+ const paths = resolveJsonDurableQueueEntryPaths(options.queueDir, id);
357
+ const claimedPath = await claimDurableQueueEntry(paths, { skipUnowned: true });
358
+ if (!claimedPath)
359
+ continue;
360
+ let result;
360
361
  try {
361
- const paths = resolveJsonDurableQueueEntryPaths(options.queueDir, id);
362
- const claimedPath = await claimDurableQueueEntry(paths);
363
- if (!claimedPath)
364
- continue;
365
362
  const raw = await readJsonDurableQueueEntry(claimedPath, { maxBytes: options.maxBytes });
366
- const result = options.read ? await options.read(raw, paths.jsonPath) : { entry: raw };
367
- if (result.migrated) {
368
- await replaceJsonDurableQueueEntry({
369
- filePath: claimedPath,
370
- entry: result.entry,
371
- tempPrefix: options.tempPrefix,
372
- });
373
- }
374
- entries.push(result.entry);
363
+ result = options.read ? await options.read(raw, paths.jsonPath) : { entry: raw };
375
364
  }
376
365
  catch {
377
366
  continue;
378
367
  }
368
+ if (result.migrated) {
369
+ await writeJsonDurableQueueEntry({
370
+ filePath: claimedPath,
371
+ entry: result.entry,
372
+ tempPrefix: options.tempPrefix,
373
+ });
374
+ }
375
+ entries.push(result.entry);
379
376
  }
380
377
  return entries;
381
378
  }
@@ -1 +1 @@
1
- {"version":3,"file":"local-roots.d.ts","sourceRoot":"","sources":["../src/local-roots.ts"],"names":[],"mappings":"AAOA,OAAO,EAAQ,KAAK,cAAc,EAAE,KAAK,UAAU,EAAE,KAAK,aAAa,EAAE,MAAM,WAAW,CAAC;AAE3F,MAAM,MAAM,oBAAoB,GAAG;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG,UAAU,GAAG;IAC9C,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAAG;IACnC,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,oCAAoC,GAAG,sBAAsB,GAAG;IAC1E,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,6BAA6B,GAAG,sBAAsB,GAAG;IACnE,SAAS,CAAC,EAAE,cAAc,CAAC;IAC3B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,EAAE,aAAa,CAAC;CAC1B,CAAC;AAqGF,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,oCAAoC,GAC5C,oBAAoB,GAAG,IAAI,CA6B7B;AAED,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,6BAA6B,GACrC,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC,CA4CtC"}
1
+ {"version":3,"file":"local-roots.d.ts","sourceRoot":"","sources":["../src/local-roots.ts"],"names":[],"mappings":"AAQA,OAAO,EAAQ,KAAK,cAAc,EAAE,KAAK,UAAU,EAAE,KAAK,aAAa,EAAE,MAAM,WAAW,CAAC;AAE3F,MAAM,MAAM,oBAAoB,GAAG;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG,UAAU,GAAG;IAC9C,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAAG;IACnC,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,oCAAoC,GAAG,sBAAsB,GAAG;IAC1E,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,6BAA6B,GAAG,sBAAsB,GAAG;IACnE,SAAS,CAAC,EAAE,cAAc,CAAC;IAC3B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,EAAE,aAAa,CAAC;CAC1B,CAAC;AAgDF,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,oCAAoC,GAC5C,oBAAoB,GAAG,IAAI,CAuC7B;AAED,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,6BAA6B,GACrC,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC,CA4CtC"}
@@ -5,6 +5,7 @@ import { FsSafeError } from "./errors.js";
5
5
  import { expandHomePrefix, resolveUserPath } from "./home-dir.js";
6
6
  import { isFileUrl, safeFileURLToPath } from "./local-file-access.js";
7
7
  import { isPathInside } from "./path.js";
8
+ import { resolveRootPathSync } from "./root-path.js";
8
9
  import { root } from "./root.js";
9
10
  function resolveLocalPathInput(input, label) {
10
11
  if (isFileUrl(input)) {
@@ -37,9 +38,6 @@ function resolveLocalRootInput(input, label) {
37
38
  }
38
39
  return path.resolve(resolved);
39
40
  }
40
- function isPathInsideRoot(candidate, rootDir) {
41
- return isPathInside(rootDir, candidate);
42
- }
43
41
  function resolveRootRealSync(rootDir) {
44
42
  try {
45
43
  // Configured roots may themselves be symlinks. Follow only this trusted
@@ -54,53 +52,6 @@ function resolveRootRealSync(rootDir) {
54
52
  return null;
55
53
  }
56
54
  }
57
- function resolveCandidateCanonicalSync(filePath) {
58
- let sawExistingLeaf = false;
59
- try {
60
- const stat = fsSync.lstatSync(filePath);
61
- sawExistingLeaf = true;
62
- return {
63
- exists: true,
64
- canonicalPath: fsSync.realpathSync(filePath),
65
- isFile: stat.isFile(),
66
- };
67
- }
68
- catch (err) {
69
- if (err.code !== "ENOENT") {
70
- throw err;
71
- }
72
- }
73
- if (sawExistingLeaf) {
74
- // lstat succeeded but realpath failed: this is an existing dangling
75
- // symlink, not a missing path callers may safely create through.
76
- throw new FsSafeError("symlink", "local roots candidate is a dangling symlink");
77
- }
78
- let cursor = filePath;
79
- const missingSegments = [];
80
- while (true) {
81
- const parent = path.dirname(cursor);
82
- if (parent === cursor) {
83
- return { exists: false, canonicalPath: filePath };
84
- }
85
- missingSegments.unshift(path.basename(cursor));
86
- cursor = parent;
87
- try {
88
- fsSync.lstatSync(cursor);
89
- const ancestorReal = fsSync.realpathSync(cursor);
90
- return {
91
- exists: false,
92
- canonicalPath: path.join(ancestorReal, ...missingSegments),
93
- };
94
- }
95
- catch (err) {
96
- if (err.code !== "ENOENT") {
97
- // Existing ancestors that cannot be canonicalized are symlink/error
98
- // terrain; do not reconstruct a trusted missing path through them.
99
- throw err;
100
- }
101
- }
102
- }
103
- }
104
55
  export function resolveLocalPathFromRootsSync(options) {
105
56
  const label = options.label ?? "local roots";
106
57
  const requestedPath = path.resolve(resolveLocalPathInput(options.filePath, "file path"));
@@ -112,7 +63,13 @@ export function resolveLocalPathFromRootsSync(options) {
112
63
  }
113
64
  let candidate;
114
65
  try {
115
- candidate = resolveCandidateCanonicalSync(requestedPath);
66
+ candidate = resolveRootPathSync({
67
+ absolutePath: requestedPath,
68
+ rootPath: rootDir,
69
+ rootCanonicalPath: rootReal,
70
+ boundaryLabel: label,
71
+ rejectUnresolvedSymlinks: true,
72
+ });
116
73
  }
117
74
  catch {
118
75
  continue;
@@ -120,12 +77,17 @@ export function resolveLocalPathFromRootsSync(options) {
120
77
  if (!candidate.exists && options.allowMissing !== true) {
121
78
  continue;
122
79
  }
123
- if (candidate.exists && options.requireFile === true && !candidate.isFile) {
124
- continue;
125
- }
126
- if (isPathInsideRoot(candidate.canonicalPath, rootReal)) {
127
- return { path: candidate.canonicalPath, root: rootReal };
80
+ if (candidate.exists && options.requireFile === true) {
81
+ try {
82
+ if (!fsSync.lstatSync(requestedPath).isFile()) {
83
+ continue;
84
+ }
85
+ }
86
+ catch {
87
+ continue;
88
+ }
128
89
  }
90
+ return { path: candidate.canonicalPath, root: rootReal };
129
91
  }
130
92
  return null;
131
93
  }
@@ -0,0 +1,43 @@
1
+ export type EntryIdentity = {
2
+ ctimeMs: number;
3
+ dev: number;
4
+ ino: number;
5
+ mode: number;
6
+ mtimeMs: number;
7
+ nlink: number;
8
+ size: number;
9
+ };
10
+ export type CopiedEntryManifest = (EntryIdentity & {
11
+ children: Array<{
12
+ name: string;
13
+ manifest: CopiedEntryManifest;
14
+ }>;
15
+ kind: "directory";
16
+ }) | (EntryIdentity & {
17
+ kind: "leaf";
18
+ });
19
+ type CleanupCopiedEntryResult = "removed" | "stale";
20
+ type CleanupAliasGroup = {
21
+ expected: EntryIdentity;
22
+ remainingPaths: Set<string>;
23
+ stale: boolean;
24
+ };
25
+ export type CleanupCopiedEntryState = {
26
+ aliasGroups: Map<string, CleanupAliasGroup>;
27
+ };
28
+ export declare function entryIdentity(stat: {
29
+ ctimeMs: number;
30
+ dev: number;
31
+ ino: number;
32
+ mode: number;
33
+ mtimeMs: number;
34
+ nlink: number;
35
+ size: number;
36
+ }): EntryIdentity;
37
+ export declare function sameIdentity(a: EntryIdentity, b: EntryIdentity): boolean;
38
+ export declare function sourceChangedError(sourcePath: string): Error;
39
+ export declare function assertSourceStillMatches(sourcePath: string, identity: EntryIdentity): Promise<void>;
40
+ export declare function createCleanupCopiedEntryState(sourcePath: string, manifest: CopiedEntryManifest): CleanupCopiedEntryState;
41
+ export declare function cleanupCopiedEntry(sourcePath: string, manifest: CopiedEntryManifest, state: CleanupCopiedEntryState): Promise<CleanupCopiedEntryResult>;
42
+ export {};
43
+ //# sourceMappingURL=move-path-cleanup.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"move-path-cleanup.d.ts","sourceRoot":"","sources":["../src/move-path-cleanup.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,aAAa,GAAG;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAC3B,CAAC,aAAa,GAAG;IACf,QAAQ,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,mBAAmB,CAAA;KAAE,CAAC,CAAC;IACjE,IAAI,EAAE,WAAW,CAAC;CACnB,CAAC,GACF,CAAC,aAAa,GAAG;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AAEvC,KAAK,wBAAwB,GAAG,SAAS,GAAG,OAAO,CAAC;AAEpD,KAAK,iBAAiB,GAAG;IACvB,QAAQ,EAAE,aAAa,CAAC;IACxB,cAAc,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC5B,KAAK,EAAE,OAAO,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG;IACpC,WAAW,EAAE,GAAG,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC;CAC7C,CAAC;AAEF,wBAAgB,aAAa,CAAC,IAAI,EAAE;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;CACd,GAAG,aAAa,CAUhB;AAED,wBAAgB,YAAY,CAAC,CAAC,EAAE,aAAa,EAAE,CAAC,EAAE,aAAa,GAAG,OAAO,CAUxE;AAMD,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,MAAM,GAAG,KAAK,CAI5D;AAED,wBAAsB,wBAAwB,CAC5C,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,aAAa,GACtB,OAAO,CAAC,IAAI,CAAC,CAIf;AA0BD,wBAAgB,6BAA6B,CAC3C,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,mBAAmB,GAC5B,uBAAuB,CAuBzB;AAsDD,wBAAsB,kBAAkB,CACtC,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,mBAAmB,EAC7B,KAAK,EAAE,uBAAuB,GAC7B,OAAO,CAAC,wBAAwB,CAAC,CAkDnC"}
@@ -0,0 +1,159 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ export function entryIdentity(stat) {
4
+ return {
5
+ ctimeMs: stat.ctimeMs,
6
+ dev: stat.dev,
7
+ ino: stat.ino,
8
+ mode: stat.mode,
9
+ mtimeMs: stat.mtimeMs,
10
+ nlink: stat.nlink,
11
+ size: stat.size,
12
+ };
13
+ }
14
+ export function sameIdentity(a, b) {
15
+ return (a.dev === b.dev &&
16
+ a.ino === b.ino &&
17
+ a.mode === b.mode &&
18
+ a.nlink === b.nlink &&
19
+ a.size === b.size &&
20
+ a.mtimeMs === b.mtimeMs &&
21
+ a.ctimeMs === b.ctimeMs);
22
+ }
23
+ function sameDirectoryNode(a, b) {
24
+ return a.dev === b.dev && a.ino === b.ino;
25
+ }
26
+ export function sourceChangedError(sourcePath) {
27
+ return Object.assign(new Error(`Source changed during move fallback: ${sourcePath}`), {
28
+ code: "ESTALE",
29
+ });
30
+ }
31
+ export async function assertSourceStillMatches(sourcePath, identity) {
32
+ if (!sameIdentity(identity, entryIdentity(await fs.lstat(sourcePath)))) {
33
+ throw sourceChangedError(sourcePath);
34
+ }
35
+ }
36
+ function identityKey(identity) {
37
+ return `${identity.dev}:${identity.ino}`;
38
+ }
39
+ function collectAliasCandidates(sourcePath, manifest, candidates) {
40
+ if (manifest.kind === "directory") {
41
+ for (const child of manifest.children) {
42
+ collectAliasCandidates(path.join(sourcePath, child.name), child.manifest, candidates);
43
+ }
44
+ return;
45
+ }
46
+ if (manifest.nlink <= 1) {
47
+ return;
48
+ }
49
+ const key = identityKey(manifest);
50
+ const entries = candidates.get(key) ?? [];
51
+ entries.push({ manifest, path: sourcePath });
52
+ candidates.set(key, entries);
53
+ }
54
+ export function createCleanupCopiedEntryState(sourcePath, manifest) {
55
+ const candidates = new Map();
56
+ collectAliasCandidates(sourcePath, manifest, candidates);
57
+ const aliasGroups = new Map();
58
+ for (const [key, entries] of candidates) {
59
+ if (entries.length < 2) {
60
+ continue;
61
+ }
62
+ const first = entries[0];
63
+ if (!first || entries.some((entry) => !sameIdentity(first.manifest, entry.manifest))) {
64
+ throw sourceChangedError(sourcePath);
65
+ }
66
+ aliasGroups.set(key, {
67
+ expected: first.manifest,
68
+ remainingPaths: new Set(entries.map((entry) => entry.path)),
69
+ stale: false,
70
+ });
71
+ }
72
+ return { aliasGroups };
73
+ }
74
+ function sameOwnedUnlinkTransition(before, after) {
75
+ return (before.dev === after.dev &&
76
+ before.ino === after.ino &&
77
+ before.mode === after.mode &&
78
+ before.size === after.size &&
79
+ before.mtimeMs === after.mtimeMs &&
80
+ before.nlink > 0 &&
81
+ after.nlink === before.nlink - 1 &&
82
+ after.ctimeMs >= before.ctimeMs);
83
+ }
84
+ function poisonAliasGroup(group) {
85
+ group.stale = true;
86
+ return "stale";
87
+ }
88
+ async function observeOwnedAliasUnlink(sourcePath, group) {
89
+ group.remainingPaths.delete(sourcePath);
90
+ const remainingPath = group.remainingPaths.values().next().value;
91
+ if (!remainingPath) {
92
+ return "removed";
93
+ }
94
+ let observed;
95
+ try {
96
+ observed = await fs.lstat(remainingPath);
97
+ }
98
+ catch (error) {
99
+ if (error?.code === "ENOENT") {
100
+ return poisonAliasGroup(group);
101
+ }
102
+ throw error;
103
+ }
104
+ const observedIdentity = entryIdentity(observed);
105
+ if (!sameOwnedUnlinkTransition(group.expected, observedIdentity)) {
106
+ return poisonAliasGroup(group);
107
+ }
108
+ group.expected = observedIdentity;
109
+ return "removed";
110
+ }
111
+ function mergeCleanupResults(a, b) {
112
+ return a === "stale" || b === "stale" ? "stale" : "removed";
113
+ }
114
+ export async function cleanupCopiedEntry(sourcePath, manifest, state) {
115
+ const aliasGroup = manifest.kind === "leaf" ? state.aliasGroups.get(identityKey(manifest)) : undefined;
116
+ if (aliasGroup?.stale) {
117
+ return "stale";
118
+ }
119
+ let currentStat;
120
+ try {
121
+ currentStat = await fs.lstat(sourcePath);
122
+ }
123
+ catch (error) {
124
+ if (error?.code === "ENOENT") {
125
+ return aliasGroup ? poisonAliasGroup(aliasGroup) : "removed";
126
+ }
127
+ throw error;
128
+ }
129
+ if (manifest.kind === "directory") {
130
+ if (!currentStat.isDirectory() || !sameDirectoryNode(manifest, entryIdentity(currentStat))) {
131
+ return "stale";
132
+ }
133
+ // A same-inode directory can gain unrelated children after commit. Still
134
+ // clean manifest children so the fallback does not duplicate copied files.
135
+ let result = "removed";
136
+ for (const child of manifest.children) {
137
+ result = mergeCleanupResults(result, await cleanupCopiedEntry(path.join(sourcePath, child.name), child.manifest, state));
138
+ }
139
+ try {
140
+ await fs.rmdir(sourcePath);
141
+ }
142
+ catch (error) {
143
+ const code = error?.code;
144
+ if (code === "ENOTEMPTY" || code === "EEXIST") {
145
+ return "stale";
146
+ }
147
+ throw error;
148
+ }
149
+ return result;
150
+ }
151
+ const expected = aliasGroup?.expected ?? manifest;
152
+ if (!sameIdentity(expected, entryIdentity(currentStat))) {
153
+ return aliasGroup ? poisonAliasGroup(aliasGroup) : "stale";
154
+ }
155
+ await fs.unlink(sourcePath);
156
+ return aliasGroup
157
+ ? await observeOwnedAliasUnlink(sourcePath, aliasGroup)
158
+ : "removed";
159
+ }
@@ -1,4 +1,6 @@
1
1
  export type MovePathWithCopyFallbackOptions = {
2
+ /** Rechecks caller authority synchronously immediately before each rename dispatch. */
3
+ assertBeforeRename?: () => void;
2
4
  from: string;
3
5
  sourceHardlinks?: "allow" | "reject";
4
6
  to: string;
@@ -1 +1 @@
1
- {"version":3,"file":"move-path.d.ts","sourceRoot":"","sources":["../src/move-path.ts"],"names":[],"mappings":"AAUA,MAAM,MAAM,+BAA+B,GAAG;IAC5C,IAAI,EAAE,MAAM,CAAC;IACb,eAAe,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAC;IACrC,EAAE,EAAE,MAAM,CAAC;CACZ,CAAC;AAEF,KAAK,sBAAsB,GAAG,cAAc,GAAG,uBAAuB,CAAC;AAEvE,wBAAgB,oCAAoC,CAClD,KAAK,EAAE,OAAO,EACd,QAAQ,GAAE,MAAM,CAAC,QAA2B,GAC3C,sBAAsB,GAAG,SAAS,CASpC;AAiVD,wBAAsB,wBAAwB,CAC5C,OAAO,EAAE,+BAA+B,GACvC,OAAO,CAAC,IAAI,CAAC,CA2Df"}
1
+ {"version":3,"file":"move-path.d.ts","sourceRoot":"","sources":["../src/move-path.ts"],"names":[],"mappings":"AAoBA,MAAM,MAAM,+BAA+B,GAAG;IAC5C,uFAAuF;IACvF,kBAAkB,CAAC,EAAE,MAAM,IAAI,CAAC;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,eAAe,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAC;IACrC,EAAE,EAAE,MAAM,CAAC;CACZ,CAAC;AAEF,KAAK,sBAAsB,GAAG,cAAc,GAAG,uBAAuB,CAAC;AAEvE,wBAAgB,oCAAoC,CAClD,KAAK,EAAE,OAAO,EACd,QAAQ,GAAE,MAAM,CAAC,QAA2B,GAC3C,sBAAsB,GAAG,SAAS,CASpC;AA8OD,wBAAsB,wBAAwB,CAC5C,OAAO,EAAE,+BAA+B,GACvC,OAAO,CAAC,IAAI,CAAC,CAkFf"}
package/dist/move-path.js CHANGED
@@ -4,6 +4,7 @@ import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { FsSafeError } from "./errors.js";
6
6
  import { guardedRename } from "./guarded-mutation.js";
7
+ import { assertSourceStillMatches, cleanupCopiedEntry, createCleanupCopiedEntryState, entryIdentity, sameIdentity, sourceChangedError, } from "./move-path-cleanup.js";
7
8
  import { resolveReadOpenFlags } from "./read-open-flags.js";
8
9
  import { registerTempPathForExit } from "./temp-cleanup.js";
9
10
  export function moveCopyFallbackReasonForRenameError(error, platform = process.platform) {
@@ -50,51 +51,34 @@ function isSameOrDescendant(parentPath, candidatePath) {
50
51
  return (relative === "" ||
51
52
  (!path.isAbsolute(relative) && !relative.startsWith(`..${path.sep}`) && relative !== ".."));
52
53
  }
53
- async function assertCopyDestinationOutsideSource(sourcePath, targetPath) {
54
- const sourceReal = await fs.realpath(sourcePath);
54
+ async function assertCopyDestinationOutsideSource(sourcePath, targetPath, expectedIdentity) {
55
+ const sourceStat = await fs.lstat(sourcePath);
56
+ const sourceIdentity = entryIdentity(sourceStat);
57
+ if (expectedIdentity && !sameIdentity(expectedIdentity, sourceIdentity)) {
58
+ throw sourceChangedError(sourcePath);
59
+ }
60
+ const normalizedSource = path.resolve(sourcePath);
55
61
  const normalizedTarget = path.resolve(targetPath);
56
- const targetParentReal = await fs.realpath(path.dirname(normalizedTarget));
62
+ const [sourceParentReal, targetParentReal] = await Promise.all([
63
+ fs.realpath(path.dirname(normalizedSource)),
64
+ fs.realpath(path.dirname(normalizedTarget)),
65
+ ]);
66
+ const sourceCandidate = path.join(sourceParentReal, path.basename(normalizedSource));
57
67
  const targetCandidate = path.join(targetParentReal, path.basename(normalizedTarget));
58
- if (isSameOrDescendant(sourceReal, targetCandidate)) {
68
+ const sourceBoundary = sourceStat.isDirectory()
69
+ ? await fs.realpath(sourcePath)
70
+ : sourceCandidate;
71
+ const unsafeTarget = sourceStat.isDirectory()
72
+ ? isSameOrDescendant(sourceBoundary, targetCandidate)
73
+ : path.relative(sourceBoundary, targetCandidate) === "";
74
+ if (unsafeTarget) {
59
75
  throw new FsSafeError("invalid-path", "Move destination must not be inside the source");
60
76
  }
61
- }
62
- function entryIdentity(stat) {
63
- return {
64
- ctimeMs: stat.ctimeMs,
65
- dev: stat.dev,
66
- ino: stat.ino,
67
- mode: stat.mode,
68
- mtimeMs: stat.mtimeMs,
69
- nlink: stat.nlink,
70
- size: stat.size,
71
- };
72
- }
73
- function sameIdentity(a, b) {
74
- return (a.dev === b.dev &&
75
- a.ino === b.ino &&
76
- a.mode === b.mode &&
77
- a.nlink === b.nlink &&
78
- a.size === b.size &&
79
- a.mtimeMs === b.mtimeMs &&
80
- a.ctimeMs === b.ctimeMs);
81
- }
82
- function sameDirectoryNode(a, b) {
83
- return a.dev === b.dev && a.ino === b.ino;
77
+ return sourceIdentity;
84
78
  }
85
79
  function modeBits(mode) {
86
80
  return mode & 0o777;
87
81
  }
88
- function sourceChangedError(sourcePath) {
89
- return Object.assign(new Error(`Source changed during move fallback: ${sourcePath}`), {
90
- code: "ESTALE",
91
- });
92
- }
93
- async function assertSourceStillMatches(sourcePath, identity) {
94
- if (!sameIdentity(identity, entryIdentity(await fs.lstat(sourcePath)))) {
95
- throw sourceChangedError(sourcePath);
96
- }
97
- }
98
82
  async function chmodDirectoryPinned(directoryPath, mode) {
99
83
  if (process.platform === "win32") {
100
84
  // Node cannot portably open a Windows directory for descriptor-bound
@@ -174,9 +158,12 @@ async function copyRegularFilePinned(params) {
174
158
  await sourceHandle.close();
175
159
  }
176
160
  }
177
- async function copyEntryWithManifest(from, to, options) {
161
+ async function copyEntryWithManifest(from, to, options, expectedIdentity) {
178
162
  const sourceStat = await fs.lstat(from);
179
163
  const identity = entryIdentity(sourceStat);
164
+ if (expectedIdentity && !sameIdentity(expectedIdentity, identity)) {
165
+ throw sourceChangedError(from);
166
+ }
180
167
  if (sourceStat.isSymbolicLink()) {
181
168
  await fs.symlink(await fs.readlink(from), to);
182
169
  // readlink() is path-based; verify the symlink we copied is still the one
@@ -224,49 +211,24 @@ async function copyEntryWithManifest(from, to, options) {
224
211
  });
225
212
  return { ...identity, kind: "leaf" };
226
213
  }
227
- function mergeCleanupResults(a, b) {
228
- return a === "stale" || b === "stale" ? "stale" : "removed";
229
- }
230
- async function cleanupCopiedEntry(sourcePath, manifest) {
231
- let currentStat;
232
- try {
233
- currentStat = await fs.lstat(sourcePath);
234
- }
235
- catch (error) {
236
- if (error?.code === "ENOENT") {
237
- return "removed";
238
- }
239
- throw error;
240
- }
241
- if (manifest.kind === "directory") {
242
- if (!currentStat.isDirectory() || !sameDirectoryNode(manifest, entryIdentity(currentStat))) {
243
- return "stale";
244
- }
245
- // A same-inode directory can gain unrelated children after commit. Still
246
- // clean manifest children so the fallback does not duplicate copied files.
247
- let result = "removed";
248
- for (const child of manifest.children) {
249
- result = mergeCleanupResults(result, await cleanupCopiedEntry(path.join(sourcePath, child.name), child.manifest));
250
- }
214
+ export async function movePathWithCopyFallback(options) {
215
+ // Keep the initiating owner's callback across preparation and copy fallback.
216
+ const callerAssert = options.assertBeforeRename;
217
+ let assertionRejected = false;
218
+ const assertBeforeRename = () => {
251
219
  try {
252
- await fs.rmdir(sourcePath);
220
+ const returned = callerAssert?.();
221
+ if (returned !== undefined) {
222
+ // TypeScript permits async functions for () => void; they cannot authorize a rename.
223
+ void Promise.resolve(returned).catch(() => { });
224
+ throw new TypeError("assertBeforeRename must return undefined synchronously");
225
+ }
253
226
  }
254
227
  catch (error) {
255
- const code = error?.code;
256
- if (code === "ENOTEMPTY" || code === "EEXIST") {
257
- return "stale";
258
- }
228
+ assertionRejected = true;
259
229
  throw error;
260
230
  }
261
- return result;
262
- }
263
- if (!sameIdentity(manifest, entryIdentity(currentStat))) {
264
- return "stale";
265
- }
266
- await fs.unlink(sourcePath);
267
- return "removed";
268
- }
269
- export async function movePathWithCopyFallback(options) {
231
+ };
270
232
  const sourcePath = path.resolve(options.from);
271
233
  const targetPath = path.resolve(options.to);
272
234
  const rejectHardlinks = options.sourceHardlinks === "reject";
@@ -275,11 +237,12 @@ export async function movePathWithCopyFallback(options) {
275
237
  }
276
238
  if (!rejectHardlinks) {
277
239
  try {
278
- await guardedRename({ from: sourcePath, to: targetPath });
240
+ await guardedRename({ from: sourcePath, to: targetPath, assertBeforeRename });
279
241
  return;
280
242
  }
281
243
  catch (error) {
282
- if (!moveCopyFallbackReasonForRenameError(error)) {
244
+ // An owner's EXDEV/EPERM refusal is not permission to copy instead.
245
+ if (assertionRejected || !moveCopyFallbackReasonForRenameError(error)) {
283
246
  throw error;
284
247
  }
285
248
  }
@@ -289,7 +252,7 @@ export async function movePathWithCopyFallback(options) {
289
252
  // Commit a fresh inode through the copy path so a post-scan hardlink can
290
253
  // never become the published target; the copy loop fences nlink again.
291
254
  }
292
- await assertCopyDestinationOutsideSource(sourcePath, targetPath);
255
+ const sourceIdentity = await assertCopyDestinationOutsideSource(sourcePath, targetPath);
293
256
  const targetDir = path.dirname(targetPath);
294
257
  const staged = path.join(targetDir, `.fs-safe-move-${process.pid}-${randomUUID()}.tmp`);
295
258
  const unregisterStaged = registerTempPathForExit(staged, { recursive: true });
@@ -298,13 +261,14 @@ export async function movePathWithCopyFallback(options) {
298
261
  const manifest = await copyEntryWithManifest(sourcePath, staged, {
299
262
  sourceHardlinks: options.sourceHardlinks ?? "allow",
300
263
  ...(rejectHardlinks ? { budget: { discovered: 1 } } : {}),
301
- });
264
+ }, sourceIdentity);
265
+ const cleanupState = createCleanupCopiedEntryState(sourcePath, manifest);
302
266
  unregisterStaged.setIdentity(await fs.lstat(staged, { bigint: true }));
303
- await assertCopyDestinationOutsideSource(sourcePath, targetPath);
304
- await guardedRename({ from: staged, to: targetPath });
267
+ await assertCopyDestinationOutsideSource(sourcePath, targetPath, manifest);
268
+ await guardedRename({ from: staged, to: targetPath, assertBeforeRename });
305
269
  stagedCommitted = true;
306
270
  unregisterStaged();
307
- const cleanupResult = await cleanupCopiedEntry(sourcePath, manifest);
271
+ const cleanupResult = await cleanupCopiedEntry(sourcePath, manifest, cleanupState);
308
272
  if (cleanupResult === "stale") {
309
273
  throw sourceChangedError(sourcePath);
310
274
  }
@@ -1,6 +1,5 @@
1
- import { type Stats } from "node:fs";
1
+ import { type BigIntStats, type Stats } from "node:fs";
2
2
  import type { ContainmentGuarantee } from "./containment.js";
3
- import { type NativeBinding } from "./native.js";
4
3
  import type { PinnedWriteInput } from "./pinned-write.js";
5
4
  export type NativeFileHandle = {
6
5
  readonly fd: number;
@@ -13,11 +12,10 @@ export declare function nativeOpenFlags(flags: number): number;
13
12
  export declare function writeNativeFd(fd: number, data: Buffer): void;
14
13
  export declare function writeNativeInput(fd: number, input: PinnedWriteInput, maxBytes?: number): Promise<void>;
15
14
  export declare function removeNativeCreatedFileIfStillPinned(params: {
16
- binding: NativeBinding;
17
15
  parentPath: string;
18
16
  parentFd: number;
19
17
  basename: string;
20
- created?: Stats;
18
+ created?: BigIntStats;
21
19
  }): void;
22
20
  export declare function createNativeExclusiveFile(targetPath: string, mode: number): Promise<NativeFileHandle | undefined>;
23
21
  export declare function syncNativeFileBestEffort(fd: number): void;