@openclaw/fs-safe 0.6.0 → 0.7.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 (183) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +7 -0
  3. package/dist/archive-deadline.d.ts +3 -0
  4. package/dist/archive-deadline.d.ts.map +1 -1
  5. package/dist/archive-deadline.js +44 -8
  6. package/dist/archive-entry.d.ts.map +1 -1
  7. package/dist/archive-entry.js +1 -0
  8. package/dist/archive-errors.d.ts +1 -0
  9. package/dist/archive-errors.d.ts.map +1 -1
  10. package/dist/archive-errors.js +3 -0
  11. package/dist/archive-input.d.ts.map +1 -1
  12. package/dist/archive-input.js +26 -16
  13. package/dist/archive-limits.d.ts +11 -3
  14. package/dist/archive-limits.d.ts.map +1 -1
  15. package/dist/archive-limits.js +24 -0
  16. package/dist/archive-native.d.ts +3 -2
  17. package/dist/archive-native.d.ts.map +1 -1
  18. package/dist/archive-native.js +24 -9
  19. package/dist/archive-policy.d.ts +2 -0
  20. package/dist/archive-policy.d.ts.map +1 -1
  21. package/dist/archive-policy.js +9 -1
  22. package/dist/archive-read.d.ts.map +1 -1
  23. package/dist/archive-read.js +69 -41
  24. package/dist/archive-staging.d.ts +3 -0
  25. package/dist/archive-staging.d.ts.map +1 -1
  26. package/dist/archive-staging.js +91 -43
  27. package/dist/archive-tar-admission.d.ts +7 -0
  28. package/dist/archive-tar-admission.d.ts.map +1 -0
  29. package/dist/archive-tar-admission.js +43 -0
  30. package/dist/archive-tar-gnu.d.ts +2 -0
  31. package/dist/archive-tar-gnu.d.ts.map +1 -0
  32. package/dist/archive-tar-gnu.js +20 -0
  33. package/dist/archive-tar-header.d.ts +8 -0
  34. package/dist/archive-tar-header.d.ts.map +1 -0
  35. package/dist/archive-tar-header.js +47 -0
  36. package/dist/archive-tar-meta.d.ts +29 -1
  37. package/dist/archive-tar-meta.d.ts.map +1 -1
  38. package/dist/archive-tar-meta.js +104 -35
  39. package/dist/archive-tar-runtime.d.ts +3 -0
  40. package/dist/archive-tar-runtime.d.ts.map +1 -1
  41. package/dist/archive-tar-runtime.js +3 -0
  42. package/dist/archive-tar.d.ts.map +1 -1
  43. package/dist/archive-tar.js +8 -2
  44. package/dist/archive-zip-names.js +2 -2
  45. package/dist/archive.d.ts.map +1 -1
  46. package/dist/archive.js +38 -26
  47. package/dist/atomic.d.ts +1 -1
  48. package/dist/atomic.d.ts.map +1 -1
  49. package/dist/bounded-read-stream.d.ts.map +1 -1
  50. package/dist/bounded-read-stream.js +2 -4
  51. package/dist/bounded-read.d.ts.map +1 -1
  52. package/dist/bounded-read.js +3 -10
  53. package/dist/byte-budget.d.ts +5 -0
  54. package/dist/byte-budget.d.ts.map +1 -0
  55. package/dist/byte-budget.js +9 -0
  56. package/dist/file-lock-sync.d.ts.map +1 -1
  57. package/dist/file-lock-sync.js +10 -5
  58. package/dist/file-store-boundary.d.ts.map +1 -1
  59. package/dist/file-store-boundary.js +9 -5
  60. package/dist/file-store-limit.d.ts +2 -0
  61. package/dist/file-store-limit.d.ts.map +1 -0
  62. package/dist/file-store-limit.js +8 -0
  63. package/dist/file-store-sync-write.d.ts.map +1 -1
  64. package/dist/file-store-sync-write.js +38 -8
  65. package/dist/file-store.d.ts.map +1 -1
  66. package/dist/file-store.js +26 -26
  67. package/dist/json-durable-queue-directory.d.ts +2 -0
  68. package/dist/json-durable-queue-directory.d.ts.map +1 -0
  69. package/dist/json-durable-queue-directory.js +20 -0
  70. package/dist/json-durable-queue-ownership.d.ts +14 -0
  71. package/dist/json-durable-queue-ownership.d.ts.map +1 -0
  72. package/dist/json-durable-queue-ownership.js +168 -0
  73. package/dist/json-durable-queue-retirement.d.ts +9 -0
  74. package/dist/json-durable-queue-retirement.d.ts.map +1 -0
  75. package/dist/json-durable-queue-retirement.js +126 -0
  76. package/dist/json-durable-queue-transfer-lock.d.ts +2 -0
  77. package/dist/json-durable-queue-transfer-lock.d.ts.map +1 -0
  78. package/dist/json-durable-queue-transfer-lock.js +19 -0
  79. package/dist/json-durable-queue.d.ts +1 -0
  80. package/dist/json-durable-queue.d.ts.map +1 -1
  81. package/dist/json-durable-queue.js +90 -57
  82. package/dist/json.d.ts.map +1 -1
  83. package/dist/json.js +27 -8
  84. package/dist/local-roots.d.ts.map +1 -1
  85. package/dist/local-roots.js +4 -2
  86. package/dist/native-binding.d.ts +12 -3
  87. package/dist/native-binding.d.ts.map +1 -1
  88. package/dist/output-sibling.d.ts.map +1 -1
  89. package/dist/output-sibling.js +11 -110
  90. package/dist/output.d.ts.map +1 -1
  91. package/dist/output.js +4 -2
  92. package/dist/pinned-open.d.ts.map +1 -1
  93. package/dist/pinned-open.js +27 -48
  94. package/dist/pinned-write.d.ts.map +1 -1
  95. package/dist/pinned-write.js +8 -9
  96. package/dist/private-temp-workspace.d.ts +3 -1
  97. package/dist/private-temp-workspace.d.ts.map +1 -1
  98. package/dist/private-temp-workspace.js +81 -56
  99. package/dist/publish-file.d.ts.map +1 -1
  100. package/dist/publish-file.js +2 -4
  101. package/dist/read-opened-file.d.ts.map +1 -1
  102. package/dist/read-opened-file.js +6 -4
  103. package/dist/regular-file.d.ts.map +1 -1
  104. package/dist/regular-file.js +136 -82
  105. package/dist/replace-file-copy-fallback.d.ts +3 -1
  106. package/dist/replace-file-copy-fallback.d.ts.map +1 -1
  107. package/dist/replace-file-copy-fallback.js +21 -36
  108. package/dist/replace-file-copy-source.d.ts +21 -0
  109. package/dist/replace-file-copy-source.d.ts.map +1 -0
  110. package/dist/replace-file-copy-source.js +112 -0
  111. package/dist/replace-file-descriptor.d.ts +13 -3
  112. package/dist/replace-file-descriptor.d.ts.map +1 -1
  113. package/dist/replace-file-descriptor.js +32 -7
  114. package/dist/replace-file-rename-policy.d.ts +7 -0
  115. package/dist/replace-file-rename-policy.d.ts.map +1 -0
  116. package/dist/replace-file-rename-policy.js +30 -0
  117. package/dist/replace-file-temp-owner.d.ts +46 -0
  118. package/dist/replace-file-temp-owner.d.ts.map +1 -0
  119. package/dist/replace-file-temp-owner.js +346 -0
  120. package/dist/replace-file.d.ts +6 -1
  121. package/dist/replace-file.d.ts.map +1 -1
  122. package/dist/replace-file.js +72 -58
  123. package/dist/root-impl.d.ts.map +1 -1
  124. package/dist/root-impl.js +40 -26
  125. package/dist/secret-file.d.ts.map +1 -1
  126. package/dist/secret-file.js +6 -3
  127. package/dist/secret-read-async.d.ts.map +1 -1
  128. package/dist/secret-read-async.js +6 -5
  129. package/dist/secure-file.d.ts.map +1 -1
  130. package/dist/secure-file.js +17 -13
  131. package/dist/sibling-staged-file.d.ts +15 -0
  132. package/dist/sibling-staged-file.d.ts.map +1 -0
  133. package/dist/sibling-staged-file.js +148 -0
  134. package/dist/sibling-temp.d.ts +3 -0
  135. package/dist/sibling-temp.d.ts.map +1 -1
  136. package/dist/sibling-temp.js +30 -74
  137. package/dist/sidecar-lock-acquire.d.ts.map +1 -1
  138. package/dist/sidecar-lock-acquire.js +48 -27
  139. package/dist/sidecar-lock-handle.d.ts +6 -2
  140. package/dist/sidecar-lock-handle.d.ts.map +1 -1
  141. package/dist/sidecar-lock-handle.js +17 -3
  142. package/dist/sidecar-lock-policy.d.ts +2 -0
  143. package/dist/sidecar-lock-policy.d.ts.map +1 -1
  144. package/dist/sidecar-lock-policy.js +29 -0
  145. package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
  146. package/dist/sidecar-lock-reclaim.js +29 -7
  147. package/dist/sidecar-lock.d.ts.map +1 -1
  148. package/dist/sidecar-lock.js +28 -16
  149. package/dist/suppressed-error.d.ts +6 -0
  150. package/dist/suppressed-error.d.ts.map +1 -0
  151. package/dist/suppressed-error.js +15 -0
  152. package/dist/temp-cleanup.d.ts +2 -0
  153. package/dist/temp-cleanup.d.ts.map +1 -1
  154. package/dist/temp-cleanup.js +25 -10
  155. package/dist/temp-workspace-owner.d.ts +23 -0
  156. package/dist/temp-workspace-owner.d.ts.map +1 -0
  157. package/dist/temp-workspace-owner.js +320 -0
  158. package/dist/temp.d.ts +1 -1
  159. package/dist/temp.d.ts.map +1 -1
  160. package/dist/test-hooks.d.ts +5 -0
  161. package/dist/test-hooks.d.ts.map +1 -1
  162. package/docs/advanced.md +8 -1
  163. package/docs/archive.md +194 -26
  164. package/docs/atomic.md +11 -2
  165. package/docs/durability.md +1 -1
  166. package/docs/file-store.md +2 -0
  167. package/docs/install.md +8 -4
  168. package/docs/json.md +8 -4
  169. package/docs/native-helper.md +15 -3
  170. package/docs/native.md +33 -6
  171. package/docs/output.md +10 -0
  172. package/docs/public-api.md +6 -3
  173. package/docs/quickstart.md +1 -1
  174. package/docs/reading.md +1 -1
  175. package/docs/regular-file.md +9 -2
  176. package/docs/root.md +2 -0
  177. package/docs/secret-file.md +5 -1
  178. package/docs/secure-file.md +4 -2
  179. package/docs/security-model.md +18 -0
  180. package/docs/sidecar-lock.md +4 -0
  181. package/docs/store.md +13 -0
  182. package/docs/temp.md +128 -14
  183. package/package.json +10 -10
@@ -1,9 +1,11 @@
1
1
  import syncFs, {} from "node:fs";
2
2
  import { FsSafeError } from "./errors.js";
3
3
  import { sameFileIdentity } from "./file-identity.js";
4
+ import { readOwnedCopySource, readOwnedCopySourceSync } from "./replace-file-copy-source.js";
5
+ import { resolveReadOpenFlags } from "./read-open-flags.js";
4
6
  const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in syncFs.constants;
5
7
  const NOFOLLOW = SUPPORTS_NOFOLLOW ? syncFs.constants.O_NOFOLLOW : 0;
6
- const OPEN_READ_FLAGS = syncFs.constants.O_RDONLY | NOFOLLOW;
8
+ const OPEN_READ_FLAGS = resolveReadOpenFlags();
7
9
  const OPEN_READ_WRITE_FLAGS = syncFs.constants.O_RDWR | NOFOLLOW;
8
10
  const OPEN_WRITE_EXCLUSIVE_FLAGS = syncFs.constants.O_WRONLY | syncFs.constants.O_CREAT | syncFs.constants.O_EXCL | NOFOLLOW;
9
11
  const READ_CHUNK_BYTES = 64 * 1024;
@@ -236,24 +238,19 @@ function replacePinnedWithRestoreSync(fsModule, fd, replacement, maxRestoreBytes
236
238
  }
237
239
  }
238
240
  export async function copyFallbackReplace(params) {
239
- const sourcePreview = await params.fsModule.lstat(params.src);
240
- if (sourcePreview.isSymbolicLink() || !sourcePreview.isFile()) {
241
- throw new Error(`Refusing copy fallback from non-file source: ${params.src}`);
242
- }
243
- const sourceHandle = await params.fsModule.open(params.src, OPEN_READ_FLAGS);
241
+ const source = await readOwnedCopySource({
242
+ fsModule: params.fsModule,
243
+ src: params.src,
244
+ expectedIdentity: params.expectedSourceIdentity,
245
+ });
246
+ const { replacement } = source;
244
247
  let destHandle = null;
245
248
  try {
246
- const sourceStat = await sourceHandle.stat();
247
- const sourceCurrent = await params.fsModule.lstat(params.src);
248
- if (!sourceStat.isFile() || sourceCurrent.isSymbolicLink() || !sameFileIdentity(sourceStat, sourceCurrent)) {
249
- throw new FsSafeError("path-mismatch", `Copy fallback source changed while opening: ${params.src}`);
250
- }
251
- const replacement = await sourceHandle.readFile();
252
249
  if (params.restore === "restore-original") {
253
250
  const pinned = await openPinnedDestination(params.fsModule, params.dest, params.destinationHardlinks);
254
251
  if (pinned) {
255
252
  destHandle = pinned.handle;
256
- await replacePinnedWithRestore(destHandle, replacement, params.maxRestoreBytes, sourceStat.mode);
253
+ await replacePinnedWithRestore(destHandle, replacement, params.maxRestoreBytes, source.mode);
257
254
  }
258
255
  }
259
256
  if (!destHandle) {
@@ -269,9 +266,9 @@ export async function copyFallbackReplace(params) {
269
266
  await assertDestinationHardlinkPolicy(params.fsModule, params.dest, params.destinationHardlinks);
270
267
  await params.fsModule.rm(params.dest, { force: true });
271
268
  }
272
- destHandle = await params.fsModule.open(params.dest, OPEN_WRITE_EXCLUSIVE_FLAGS, sourceStat.mode & 0o777);
269
+ destHandle = await params.fsModule.open(params.dest, OPEN_WRITE_EXCLUSIVE_FLAGS, source.mode & 0o777);
273
270
  await destHandle.writeFile(replacement);
274
- await destHandle.chmod(sourceStat.mode);
271
+ await destHandle.chmod(source.mode);
275
272
  if (params.sync) {
276
273
  await destHandle.sync();
277
274
  }
@@ -279,28 +276,22 @@ export async function copyFallbackReplace(params) {
279
276
  }
280
277
  finally {
281
278
  await destHandle?.close().catch(() => undefined);
282
- await sourceHandle.close().catch(() => undefined);
283
279
  }
284
280
  }
285
281
  export function copyFallbackReplaceSync(params) {
286
- const sourcePreview = params.fsModule.lstatSync(params.src);
287
- if (sourcePreview.isSymbolicLink() || !sourcePreview.isFile()) {
288
- throw new Error(`Refusing copy fallback from non-file source: ${params.src}`);
289
- }
290
- const sourceFd = params.fsModule.openSync(params.src, OPEN_READ_FLAGS);
282
+ const source = readOwnedCopySourceSync({
283
+ fsModule: params.fsModule,
284
+ src: params.src,
285
+ expectedIdentity: params.expectedSourceIdentity,
286
+ });
287
+ const { replacement } = source;
291
288
  let destFd;
292
289
  try {
293
- const sourceStat = params.fsModule.fstatSync(sourceFd);
294
- const sourceCurrent = params.fsModule.lstatSync(params.src);
295
- if (!sourceStat.isFile() || sourceCurrent.isSymbolicLink() || !sameFileIdentity(sourceStat, sourceCurrent)) {
296
- throw new FsSafeError("path-mismatch", `Copy fallback source changed while opening: ${params.src}`);
297
- }
298
- const replacement = readBoundedSync(params.fsModule, sourceFd, Number.MAX_SAFE_INTEGER);
299
290
  if (params.restore === "restore-original") {
300
291
  const pinned = openPinnedDestinationSync(params.fsModule, params.dest, params.destinationHardlinks);
301
292
  if (pinned) {
302
293
  destFd = pinned.fd;
303
- replacePinnedWithRestoreSync(params.fsModule, destFd, replacement, params.maxRestoreBytes, sourceStat.mode, params.fchmodSync);
294
+ replacePinnedWithRestoreSync(params.fsModule, destFd, replacement, params.maxRestoreBytes, source.mode, params.fchmodSync);
304
295
  }
305
296
  }
306
297
  if (destFd === undefined) {
@@ -319,9 +310,9 @@ export function copyFallbackReplaceSync(params) {
319
310
  assertDestinationHardlinkPolicySync(params.fsModule, params.dest, params.destinationHardlinks);
320
311
  params.fsModule.rmSync(params.dest, { force: true });
321
312
  }
322
- destFd = params.fsModule.openSync(params.dest, OPEN_WRITE_EXCLUSIVE_FLAGS, sourceStat.mode & 0o777);
313
+ destFd = params.fsModule.openSync(params.dest, OPEN_WRITE_EXCLUSIVE_FLAGS, source.mode & 0o777);
323
314
  writeAllSync(params.fsModule, destFd, replacement);
324
- params.fchmodSync?.(destFd, sourceStat.mode);
315
+ params.fchmodSync?.(destFd, source.mode);
325
316
  if (params.sync) {
326
317
  params.fsModule.fsyncSync(destFd);
327
318
  }
@@ -336,11 +327,5 @@ export function copyFallbackReplaceSync(params) {
336
327
  // Best-effort close after fallback replacement.
337
328
  }
338
329
  }
339
- try {
340
- params.fsModule.closeSync(sourceFd);
341
- }
342
- catch {
343
- // Best-effort close after fallback replacement.
344
- }
345
330
  }
346
331
  }
@@ -0,0 +1,21 @@
1
+ import syncFs, { type BigIntStats } from "node:fs";
2
+ type AsyncSourceFileSystem = Pick<typeof import("node:fs/promises"), "lstat" | "open">;
3
+ type SyncSourceFileSystem = Pick<typeof syncFs, "closeSync" | "fstatSync" | "lstatSync" | "openSync" | "readSync">;
4
+ export declare function readOwnedCopySource(params: {
5
+ fsModule: AsyncSourceFileSystem;
6
+ src: string;
7
+ expectedIdentity?: BigIntStats;
8
+ }): Promise<{
9
+ replacement: Buffer;
10
+ mode: number;
11
+ }>;
12
+ export declare function readOwnedCopySourceSync(params: {
13
+ fsModule: SyncSourceFileSystem;
14
+ src: string;
15
+ expectedIdentity?: BigIntStats;
16
+ }): {
17
+ replacement: Buffer;
18
+ mode: number;
19
+ };
20
+ export {};
21
+ //# sourceMappingURL=replace-file-copy-source.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"replace-file-copy-source.d.ts","sourceRoot":"","sources":["../src/replace-file-copy-source.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,EAAE,EAAE,KAAK,WAAW,EAAE,MAAM,SAAS,CAAC;AAMnD,KAAK,qBAAqB,GAAG,IAAI,CAAC,cAAc,kBAAkB,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC,CAAC;AACvF,KAAK,oBAAoB,GAAG,IAAI,CAC9B,OAAO,MAAM,EACb,WAAW,GAAG,WAAW,GAAG,WAAW,GAAG,UAAU,GAAG,UAAU,CAClE,CAAC;AAgEF,wBAAsB,mBAAmB,CAAC,MAAM,EAAE;IAChD,QAAQ,EAAE,qBAAqB,CAAC;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,gBAAgB,CAAC,EAAE,WAAW,CAAC;CAChC,GAAG,OAAO,CAAC;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CA0BjD;AAED,wBAAgB,uBAAuB,CAAC,MAAM,EAAE;IAC9C,QAAQ,EAAE,oBAAoB,CAAC;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,gBAAgB,CAAC,EAAE,WAAW,CAAC;CAChC,GAAG;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CA8BxC"}
@@ -0,0 +1,112 @@
1
+ import syncFs, {} from "node:fs";
2
+ import { FsSafeError } from "./errors.js";
3
+ import { sameFileIdentity } from "./file-identity.js";
4
+ import { inspectFileIdentity, inspectFileIdentitySync } from "./strict-file-identity.js";
5
+ const NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in syncFs.constants
6
+ ? syncFs.constants.O_NOFOLLOW
7
+ : 0;
8
+ const NONBLOCK = process.platform !== "win32" && "O_NONBLOCK" in syncFs.constants
9
+ ? syncFs.constants.O_NONBLOCK
10
+ : 0;
11
+ const OPEN_READ_FLAGS = syncFs.constants.O_RDONLY | NOFOLLOW | NONBLOCK;
12
+ const READ_CHUNK_BYTES = 64 * 1024;
13
+ function assertSourcePreview(source, src) {
14
+ if (source.isSymbolicLink()) {
15
+ throw new FsSafeError("symlink", `Refusing copy fallback from non-file source: ${src}`);
16
+ }
17
+ if (!source.isFile()) {
18
+ throw new FsSafeError("not-file", `Refusing copy fallback from non-file source: ${src}`);
19
+ }
20
+ if (source.nlink !== 1) {
21
+ throw new FsSafeError("hardlink", `Hardlinked copy fallback source not allowed: ${src}`);
22
+ }
23
+ }
24
+ function sourceSymlinkError(src, cause) {
25
+ return new FsSafeError("symlink", `Refusing copy fallback from non-file source: ${src}`, {
26
+ cause,
27
+ });
28
+ }
29
+ async function openSource(fsModule, src) {
30
+ try {
31
+ return await fsModule.open(src, OPEN_READ_FLAGS);
32
+ }
33
+ catch (error) {
34
+ if (error.code === "ELOOP") {
35
+ throw sourceSymlinkError(src, error);
36
+ }
37
+ throw error;
38
+ }
39
+ }
40
+ function openSourceSync(fsModule, src) {
41
+ try {
42
+ return fsModule.openSync(src, OPEN_READ_FLAGS);
43
+ }
44
+ catch (error) {
45
+ if (error.code === "ELOOP") {
46
+ throw sourceSymlinkError(src, error);
47
+ }
48
+ throw error;
49
+ }
50
+ }
51
+ function readAllSync(fsModule, fd) {
52
+ const chunks = [];
53
+ let position = 0;
54
+ while (true) {
55
+ const buffer = Buffer.allocUnsafe(READ_CHUNK_BYTES);
56
+ const bytesRead = fsModule.readSync(fd, buffer, 0, buffer.length, position);
57
+ if (bytesRead === 0)
58
+ break;
59
+ position += bytesRead;
60
+ chunks.push(buffer.subarray(0, bytesRead));
61
+ }
62
+ return Buffer.concat(chunks, position);
63
+ }
64
+ export async function readOwnedCopySource(params) {
65
+ assertSourcePreview(await params.fsModule.lstat(params.src), params.src);
66
+ const handle = await openSource(params.fsModule, params.src);
67
+ try {
68
+ if (params.expectedIdentity) {
69
+ const exact = await inspectFileIdentity(() => handle.stat({ bigint: true }), params.expectedIdentity);
70
+ await inspectFileIdentity(() => params.fsModule.lstat(params.src, { bigint: true }), exact);
71
+ }
72
+ const opened = await handle.stat();
73
+ const current = await params.fsModule.lstat(params.src);
74
+ if (!opened.isFile() || current.isSymbolicLink() || !sameFileIdentity(opened, current)) {
75
+ throw new FsSafeError("path-mismatch", `Copy fallback source changed while opening: ${params.src}`);
76
+ }
77
+ if (opened.nlink !== 1) {
78
+ throw new FsSafeError("hardlink", `Hardlinked copy fallback source not allowed: ${params.src}`);
79
+ }
80
+ return { replacement: await handle.readFile(), mode: opened.mode };
81
+ }
82
+ finally {
83
+ await handle.close().catch(() => undefined);
84
+ }
85
+ }
86
+ export function readOwnedCopySourceSync(params) {
87
+ assertSourcePreview(params.fsModule.lstatSync(params.src), params.src);
88
+ const fd = openSourceSync(params.fsModule, params.src);
89
+ try {
90
+ if (params.expectedIdentity) {
91
+ const exact = inspectFileIdentitySync(() => params.fsModule.fstatSync(fd, { bigint: true }), params.expectedIdentity);
92
+ inspectFileIdentitySync(() => params.fsModule.lstatSync(params.src, { bigint: true }), exact);
93
+ }
94
+ const opened = params.fsModule.fstatSync(fd);
95
+ const current = params.fsModule.lstatSync(params.src);
96
+ if (!opened.isFile() || current.isSymbolicLink() || !sameFileIdentity(opened, current)) {
97
+ throw new FsSafeError("path-mismatch", `Copy fallback source changed while opening: ${params.src}`);
98
+ }
99
+ if (opened.nlink !== 1) {
100
+ throw new FsSafeError("hardlink", `Hardlinked copy fallback source not allowed: ${params.src}`);
101
+ }
102
+ return { replacement: readAllSync(params.fsModule, fd), mode: opened.mode };
103
+ }
104
+ finally {
105
+ try {
106
+ params.fsModule.closeSync(fd);
107
+ }
108
+ catch {
109
+ // Best-effort close after reading a copy-fallback source.
110
+ }
111
+ }
112
+ }
@@ -1,5 +1,5 @@
1
1
  import syncFs, { type BigIntStats } from "node:fs";
2
- import fs from "node:fs/promises";
2
+ import fs, { type FileHandle } from "node:fs/promises";
3
3
  type AsyncTempFileSystem = Pick<typeof fs, "lstat" | "open" | "writeFile">;
4
4
  type SyncTempFileSystem = Pick<typeof syncFs, "closeSync" | "fstatSync" | "fsyncSync" | "lstatSync" | "openSync" | "writeFileSync">;
5
5
  export type SyncFchmod = (fd: number, mode: number) => void;
@@ -7,6 +7,8 @@ export declare function applyDirectoryMode(params: {
7
7
  fsModule: AsyncTempFileSystem;
8
8
  dirPath: string;
9
9
  mode: number;
10
+ /** Compatibility for best-effort directory modes; admission and close still fail closed. */
11
+ ignoreChmodError?: boolean;
10
12
  }): Promise<void>;
11
13
  export declare function applyDirectoryModeSync(params: {
12
14
  fsModule: SyncTempFileSystem;
@@ -20,7 +22,11 @@ export declare function writeTempFile(params: {
20
22
  content: string | Uint8Array;
21
23
  mode: number;
22
24
  sync: boolean;
23
- }): Promise<BigIntStats>;
25
+ onIdentity?: (identity: BigIntStats) => void;
26
+ }): Promise<{
27
+ handle: FileHandle;
28
+ identity: BigIntStats;
29
+ }>;
24
30
  export declare function writeTempFileSync(params: {
25
31
  fsModule: SyncTempFileSystem;
26
32
  tempPath: string;
@@ -28,6 +34,10 @@ export declare function writeTempFileSync(params: {
28
34
  mode: number;
29
35
  fchmodSync?: SyncFchmod;
30
36
  sync: boolean;
31
- }): BigIntStats;
37
+ onIdentity?: (identity: BigIntStats) => void;
38
+ }): {
39
+ fd: number;
40
+ identity: BigIntStats;
41
+ };
32
42
  export {};
33
43
  //# sourceMappingURL=replace-file-descriptor.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"replace-file-descriptor.d.ts","sourceRoot":"","sources":["../src/replace-file-descriptor.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,EAAE,EAAE,KAAK,WAAW,EAAc,MAAM,SAAS,CAAC;AAC/D,OAAO,EAAuB,MAAM,kBAAkB,CAAC;AAIvD,KAAK,mBAAmB,GAAG,IAAI,CAAC,OAAO,EAAE,EAAE,OAAO,GAAG,MAAM,GAAG,WAAW,CAAC,CAAC;AAC3E,KAAK,kBAAkB,GAAG,IAAI,CAC5B,OAAO,MAAM,EACb,WAAW,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,GAAG,UAAU,GAAG,eAAe,CACrF,CAAC;AAEF,MAAM,MAAM,UAAU,GAAG,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;AA2B5D,wBAAsB,kBAAkB,CAAC,MAAM,EAAE;IAC/C,QAAQ,EAAE,mBAAmB,CAAC;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;CACd,GAAG,OAAO,CAAC,IAAI,CAAC,CAiBhB;AAED,wBAAgB,sBAAsB,CAAC,MAAM,EAAE;IAC7C,QAAQ,EAAE,kBAAkB,CAAC;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,UAAU,CAAC;CACzB,GAAG,IAAI,CAcP;AAED,wBAAsB,aAAa,CAAC,MAAM,EAAE;IAC1C,QAAQ,EAAE,mBAAmB,CAAC;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,UAAU,CAAC;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,OAAO,CAAC;CACf,GAAG,OAAO,CAAC,WAAW,CAAC,CAkBvB;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE;IACxC,QAAQ,EAAE,kBAAkB,CAAC;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,UAAU,CAAC;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,IAAI,EAAE,OAAO,CAAC;CACf,GAAG,WAAW,CAkBd"}
1
+ {"version":3,"file":"replace-file-descriptor.d.ts","sourceRoot":"","sources":["../src/replace-file-descriptor.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,EAAE,EAAE,KAAK,WAAW,EAAc,MAAM,SAAS,CAAC;AAC/D,OAAO,EAAE,EAAE,EAAE,KAAK,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAKvD,KAAK,mBAAmB,GAAG,IAAI,CAAC,OAAO,EAAE,EAAE,OAAO,GAAG,MAAM,GAAG,WAAW,CAAC,CAAC;AAC3E,KAAK,kBAAkB,GAAG,IAAI,CAC5B,OAAO,MAAM,EACb,WAAW,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,GAAG,UAAU,GAAG,eAAe,CACrF,CAAC;AAEF,MAAM,MAAM,UAAU,GAAG,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;AA2B5D,wBAAsB,kBAAkB,CAAC,MAAM,EAAE;IAC/C,QAAQ,EAAE,mBAAmB,CAAC;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,4FAA4F;IAC5F,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B,GAAG,OAAO,CAAC,IAAI,CAAC,CAqBhB;AAED,wBAAgB,sBAAsB,CAAC,MAAM,EAAE;IAC7C,QAAQ,EAAE,kBAAkB,CAAC;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,UAAU,CAAC;CACzB,GAAG,IAAI,CAcP;AAED,wBAAsB,aAAa,CAAC,MAAM,EAAE;IAC1C,QAAQ,EAAE,mBAAmB,CAAC;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,UAAU,CAAC;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,OAAO,CAAC;IACd,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,WAAW,KAAK,IAAI,CAAC;CAC9C,GAAG,OAAO,CAAC;IAAE,MAAM,EAAE,UAAU,CAAC;IAAC,QAAQ,EAAE,WAAW,CAAA;CAAE,CAAC,CA0BzD;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE;IACxC,QAAQ,EAAE,kBAAkB,CAAC;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,UAAU,CAAC;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,IAAI,EAAE,OAAO,CAAC;IACd,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,WAAW,KAAK,IAAI,CAAC;CAC9C,GAAG;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,WAAW,CAAA;CAAE,CA0BxC"}
@@ -2,6 +2,7 @@ import syncFs, {} from "node:fs";
2
2
  import fs, {} from "node:fs/promises";
3
3
  import { FsSafeError } from "./errors.js";
4
4
  import { sameFileIdentity } from "./file-identity.js";
5
+ import { inspectFileIdentity, inspectFileIdentitySync } from "./strict-file-identity.js";
5
6
  function directoryOpenFlags() {
6
7
  return (syncFs.constants.O_RDONLY |
7
8
  syncFs.constants.O_DIRECTORY |
@@ -31,7 +32,13 @@ export async function applyDirectoryMode(params) {
31
32
  const handle = await params.fsModule.open(params.dirPath, directoryOpenFlags());
32
33
  try {
33
34
  assertSameDirectory(expected, await handle.stat(), params.dirPath);
34
- await handle.chmod(params.mode);
35
+ try {
36
+ await handle.chmod(params.mode);
37
+ }
38
+ catch (error) {
39
+ if (params.ignoreChmodError !== true)
40
+ throw error;
41
+ }
35
42
  }
36
43
  finally {
37
44
  await handle.close();
@@ -55,6 +62,8 @@ export function applyDirectoryModeSync(params) {
55
62
  export async function writeTempFile(params) {
56
63
  const handle = await params.fsModule.open(params.tempPath, "wx", params.mode);
57
64
  try {
65
+ const identity = await inspectFileIdentity(() => handle.stat({ bigint: true }));
66
+ params.onIdentity?.(identity);
58
67
  await params.fsModule.writeFile(handle, params.content);
59
68
  await handle.chmod(params.mode);
60
69
  if (params.sync) {
@@ -67,15 +76,24 @@ export async function writeTempFile(params) {
67
76
  }
68
77
  }
69
78
  }
70
- return await handle.stat({ bigint: true });
79
+ await inspectFileIdentity(() => handle.stat({ bigint: true }), identity);
80
+ return { handle, identity };
71
81
  }
72
- finally {
73
- await handle.close();
82
+ catch (error) {
83
+ try {
84
+ await handle.close();
85
+ }
86
+ catch (closeError) {
87
+ throw new AggregateError([error, closeError], "Atomic temp write and close failed");
88
+ }
89
+ throw error;
74
90
  }
75
91
  }
76
92
  export function writeTempFileSync(params) {
77
93
  const fd = params.fsModule.openSync(params.tempPath, "wx", params.mode);
78
94
  try {
95
+ const identity = inspectFileIdentitySync(() => params.fsModule.fstatSync(fd, { bigint: true }));
96
+ params.onIdentity?.(identity);
79
97
  params.fsModule.writeFileSync(fd, params.content);
80
98
  params.fchmodSync?.(fd, params.mode);
81
99
  if (params.sync) {
@@ -88,9 +106,16 @@ export function writeTempFileSync(params) {
88
106
  }
89
107
  }
90
108
  }
91
- return params.fsModule.fstatSync(fd, { bigint: true });
109
+ inspectFileIdentitySync(() => params.fsModule.fstatSync(fd, { bigint: true }), identity);
110
+ return { fd, identity };
92
111
  }
93
- finally {
94
- params.fsModule.closeSync(fd);
112
+ catch (error) {
113
+ try {
114
+ params.fsModule.closeSync(fd);
115
+ }
116
+ catch (closeError) {
117
+ throw new AggregateError([error, closeError], "Atomic temp write and close failed");
118
+ }
119
+ throw error;
95
120
  }
96
121
  }
@@ -0,0 +1,7 @@
1
+ import type { RenameIdentityPolicy } from "./pinned-write.js";
2
+ export type { RenameIdentityPolicy };
3
+ export declare function validateRenameIdentity(policy: RenameIdentityPolicy | undefined): void;
4
+ export declare function atomicExpectedContentHash(policy: RenameIdentityPolicy | undefined, content: string | Uint8Array): string | undefined;
5
+ export declare function withAtomicRenameIdentityLock<T>(filePath: string, fn: () => Promise<T>): Promise<T>;
6
+ export declare function withAtomicRenameIdentityLockSync<T>(filePath: string, fn: () => T): T;
7
+ //# sourceMappingURL=replace-file-rename-policy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"replace-file-rename-policy.d.ts","sourceRoot":"","sources":["../src/replace-file-rename-policy.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AAE9D,YAAY,EAAE,oBAAoB,EAAE,CAAC;AAErC,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,oBAAoB,GAAG,SAAS,GAAG,IAAI,CAIrF;AAED,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,oBAAoB,GAAG,SAAS,EACxC,OAAO,EAAE,MAAM,GAAG,UAAU,GAC3B,MAAM,GAAG,SAAS,CAGpB;AAcD,wBAAsB,4BAA4B,CAAC,CAAC,EAClD,QAAQ,EAAE,MAAM,EAChB,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GACnB,OAAO,CAAC,CAAC,CAAC,CAEZ;AAED,wBAAgB,gCAAgC,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,CAEpF"}
@@ -0,0 +1,30 @@
1
+ import path from "node:path";
2
+ import { withFileLock, withFileLockSync } from "./file-lock.js";
3
+ import { sha256Hex } from "./file-identity.js";
4
+ export function validateRenameIdentity(policy) {
5
+ if (policy !== undefined && policy !== "strict" && policy !== "verify-content-with-lock") {
6
+ throw new RangeError("renameIdentity must be strict or verify-content-with-lock");
7
+ }
8
+ }
9
+ export function atomicExpectedContentHash(policy, content) {
10
+ if (policy !== "verify-content-with-lock")
11
+ return undefined;
12
+ return sha256Hex(typeof content === "string" ? content : Buffer.from(content));
13
+ }
14
+ function lockOptions(filePath) {
15
+ const resolved = path.resolve(filePath);
16
+ return {
17
+ managerKey: `fs-safe.atomic:${resolved}`,
18
+ lockPath: path.join(path.dirname(resolved), `.fs-safe-atomic-${sha256Hex(resolved)}.lock`),
19
+ staleMs: 30_000,
20
+ timeoutMs: 5_000,
21
+ payload: () => ({ pid: process.pid, createdAt: new Date().toISOString() }),
22
+ retry: { retries: 5, minTimeout: 100, maxTimeout: 2_000, factor: 2 },
23
+ };
24
+ }
25
+ export async function withAtomicRenameIdentityLock(filePath, fn) {
26
+ return await withFileLock(filePath, lockOptions(filePath), fn);
27
+ }
28
+ export function withAtomicRenameIdentityLockSync(filePath, fn) {
29
+ return withFileLockSync(filePath, lockOptions(filePath), fn);
30
+ }
@@ -0,0 +1,46 @@
1
+ import syncFs, { type BigIntStats } from "node:fs";
2
+ import fs, { type FileHandle } from "node:fs/promises";
3
+ type AsyncOwnerFileSystem = Pick<typeof fs, "lstat" | "open" | "unlink">;
4
+ type SyncOwnerFileSystem = Pick<typeof syncFs, "closeSync" | "fstatSync" | "lstatSync" | "openSync" | "readFileSync" | "unlinkSync">;
5
+ export declare class AsyncAtomicTempOwner {
6
+ #private;
7
+ readonly pathname: string;
8
+ constructor(pathname: string);
9
+ start(): void;
10
+ readonly onIdentity: (identity: BigIntStats) => void;
11
+ adopt(temp: {
12
+ handle: FileHandle;
13
+ identity: BigIntStats;
14
+ }): void;
15
+ get identity(): BigIntStats;
16
+ assertCurrent(fsModule: AsyncOwnerFileSystem, pathname?: string): Promise<void>;
17
+ assertPublished(fsModule: AsyncOwnerFileSystem, pathname: string, expectedHash?: string): Promise<void>;
18
+ markRenamed(): void;
19
+ finish(params: {
20
+ fsModule: AsyncOwnerFileSystem;
21
+ originalError?: unknown;
22
+ throwOnCleanupError: boolean;
23
+ }): Promise<void>;
24
+ }
25
+ export declare class SyncAtomicTempOwner {
26
+ #private;
27
+ readonly pathname: string;
28
+ constructor(pathname: string);
29
+ start(): void;
30
+ readonly onIdentity: (identity: BigIntStats) => void;
31
+ adopt(temp: {
32
+ fd: number;
33
+ identity: BigIntStats;
34
+ }): void;
35
+ get identity(): BigIntStats;
36
+ assertCurrent(fsModule: SyncOwnerFileSystem, pathname?: string): void;
37
+ assertPublished(fsModule: SyncOwnerFileSystem, pathname: string, expectedHash?: string): void;
38
+ markRenamed(): void;
39
+ finish(params: {
40
+ fsModule: SyncOwnerFileSystem;
41
+ originalError?: unknown;
42
+ throwOnCleanupError: boolean;
43
+ }): void;
44
+ }
45
+ export {};
46
+ //# sourceMappingURL=replace-file-temp-owner.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"replace-file-temp-owner.d.ts","sourceRoot":"","sources":["../src/replace-file-temp-owner.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,EAAE,EAAE,KAAK,WAAW,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EAAE,EAAE,EAAE,KAAK,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAMvD,KAAK,oBAAoB,GAAG,IAAI,CAAC,OAAO,EAAE,EAAE,OAAO,GAAG,MAAM,GAAG,QAAQ,CAAC,CAAC;AACzE,KAAK,mBAAmB,GAAG,IAAI,CAC7B,OAAO,MAAM,EACb,WAAW,GAAG,WAAW,GAAG,WAAW,GAAG,UAAU,GAAG,cAAc,GAAG,YAAY,CACrF,CAAC;AAiGF,qBAAa,oBAAoB;;IAC/B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAM1B,YAAY,QAAQ,EAAE,MAAM,EAG3B;IAED,KAAK,IAAI,IAAI,CAEZ;IAED,QAAQ,CAAC,UAAU,aAAc,WAAW,KAAG,IAAI,CAGjD;IAEF,KAAK,CAAC,IAAI,EAAE;QAAE,MAAM,EAAE,UAAU,CAAC;QAAC,QAAQ,EAAE,WAAW,CAAA;KAAE,GAAG,IAAI,CAG/D;IAED,IAAI,QAAQ,IAAI,WAAW,CAG1B;IAEK,aAAa,CAAC,QAAQ,EAAE,oBAAoB,EAAE,QAAQ,SAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAkB3F;IAEK,eAAe,CACnB,QAAQ,EAAE,oBAAoB,EAC9B,QAAQ,EAAE,MAAM,EAChB,YAAY,CAAC,EAAE,MAAM,GACpB,OAAO,CAAC,IAAI,CAAC,CA0Cf;IAED,WAAW,IAAI,IAAI,CAGlB;IAEK,MAAM,CAAC,MAAM,EAAE;QACnB,QAAQ,EAAE,oBAAoB,CAAC;QAC/B,aAAa,CAAC,EAAE,OAAO,CAAC;QACxB,mBAAmB,EAAE,OAAO,CAAC;KAC9B,GAAG,OAAO,CAAC,IAAI,CAAC,CA8BhB;CACF;AAED,qBAAa,mBAAmB;;IAC9B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAM1B,YAAY,QAAQ,EAAE,MAAM,EAG3B;IAED,KAAK,IAAI,IAAI,CAEZ;IAED,QAAQ,CAAC,UAAU,aAAc,WAAW,KAAG,IAAI,CAGjD;IAEF,KAAK,CAAC,IAAI,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,WAAW,CAAA;KAAE,GAAG,IAAI,CAGvD;IAED,IAAI,QAAQ,IAAI,WAAW,CAG1B;IAED,aAAa,CAAC,QAAQ,EAAE,mBAAmB,EAAE,QAAQ,SAAgB,GAAG,IAAI,CAkB3E;IAED,eAAe,CACb,QAAQ,EAAE,mBAAmB,EAC7B,QAAQ,EAAE,MAAM,EAChB,YAAY,CAAC,EAAE,MAAM,GACpB,IAAI,CAgDN;IAED,WAAW,IAAI,IAAI,CAGlB;IAED,MAAM,CAAC,MAAM,EAAE;QACb,QAAQ,EAAE,mBAAmB,CAAC;QAC9B,aAAa,CAAC,EAAE,OAAO,CAAC;QACxB,mBAAmB,EAAE,OAAO,CAAC;KAC9B,GAAG,IAAI,CAgCP;CACF"}