@openclaw/fs-safe 0.5.6 → 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 (289) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/README.md +23 -10
  3. package/dist/advanced.d.ts +2 -1
  4. package/dist/advanced.d.ts.map +1 -1
  5. package/dist/advanced.js +1 -0
  6. package/dist/archive-crc32.d.ts +2 -0
  7. package/dist/archive-crc32.d.ts.map +1 -0
  8. package/dist/archive-crc32.js +14 -0
  9. package/dist/archive-deadline.d.ts +3 -0
  10. package/dist/archive-deadline.d.ts.map +1 -1
  11. package/dist/archive-deadline.js +44 -8
  12. package/dist/archive-entry.d.ts.map +1 -1
  13. package/dist/archive-entry.js +1 -0
  14. package/dist/archive-errors.d.ts +1 -0
  15. package/dist/archive-errors.d.ts.map +1 -1
  16. package/dist/archive-errors.js +3 -0
  17. package/dist/archive-input.d.ts.map +1 -1
  18. package/dist/archive-input.js +26 -16
  19. package/dist/archive-kind.js +2 -2
  20. package/dist/archive-limits.d.ts +11 -3
  21. package/dist/archive-limits.d.ts.map +1 -1
  22. package/dist/archive-limits.js +24 -0
  23. package/dist/archive-native.d.ts +3 -2
  24. package/dist/archive-native.d.ts.map +1 -1
  25. package/dist/archive-native.js +31 -9
  26. package/dist/archive-policy.d.ts +2 -0
  27. package/dist/archive-policy.d.ts.map +1 -1
  28. package/dist/archive-policy.js +9 -1
  29. package/dist/archive-read.d.ts.map +1 -1
  30. package/dist/archive-read.js +88 -41
  31. package/dist/archive-staging.d.ts +3 -0
  32. package/dist/archive-staging.d.ts.map +1 -1
  33. package/dist/archive-staging.js +91 -43
  34. package/dist/archive-tar-admission.d.ts +7 -0
  35. package/dist/archive-tar-admission.d.ts.map +1 -0
  36. package/dist/archive-tar-admission.js +43 -0
  37. package/dist/archive-tar-gnu.d.ts +2 -0
  38. package/dist/archive-tar-gnu.d.ts.map +1 -0
  39. package/dist/archive-tar-gnu.js +20 -0
  40. package/dist/archive-tar-header.d.ts +8 -0
  41. package/dist/archive-tar-header.d.ts.map +1 -0
  42. package/dist/archive-tar-header.js +47 -0
  43. package/dist/archive-tar-meta.d.ts +29 -1
  44. package/dist/archive-tar-meta.d.ts.map +1 -1
  45. package/dist/archive-tar-meta.js +135 -19
  46. package/dist/archive-tar-pax.d.ts +8 -0
  47. package/dist/archive-tar-pax.d.ts.map +1 -0
  48. package/dist/archive-tar-pax.js +100 -0
  49. package/dist/archive-tar-runtime.d.ts +4 -0
  50. package/dist/archive-tar-runtime.d.ts.map +1 -1
  51. package/dist/archive-tar-runtime.js +3 -0
  52. package/dist/archive-tar.d.ts.map +1 -1
  53. package/dist/archive-tar.js +8 -2
  54. package/dist/archive-zip-admission.d.ts +7 -0
  55. package/dist/archive-zip-admission.d.ts.map +1 -0
  56. package/dist/archive-zip-admission.js +60 -0
  57. package/dist/archive-zip-count.d.ts +2 -0
  58. package/dist/archive-zip-count.d.ts.map +1 -0
  59. package/dist/archive-zip-count.js +140 -0
  60. package/dist/archive-zip-directory.d.ts +8 -0
  61. package/dist/archive-zip-directory.d.ts.map +1 -0
  62. package/dist/archive-zip-directory.js +224 -0
  63. package/dist/archive-zip-integrity.d.ts.map +1 -1
  64. package/dist/archive-zip-integrity.js +1 -14
  65. package/dist/archive-zip-names.d.ts +12 -0
  66. package/dist/archive-zip-names.d.ts.map +1 -0
  67. package/dist/archive-zip-names.js +99 -0
  68. package/dist/archive-zip-preflight.d.ts +1 -1
  69. package/dist/archive-zip-preflight.d.ts.map +1 -1
  70. package/dist/archive-zip-preflight.js +8 -145
  71. package/dist/archive.d.ts.map +1 -1
  72. package/dist/archive.js +63 -42
  73. package/dist/atomic.d.ts +1 -1
  74. package/dist/atomic.d.ts.map +1 -1
  75. package/dist/bounded-read-stream.d.ts.map +1 -1
  76. package/dist/bounded-read-stream.js +2 -4
  77. package/dist/bounded-read.d.ts.map +1 -1
  78. package/dist/bounded-read.js +3 -10
  79. package/dist/byte-budget.d.ts +5 -0
  80. package/dist/byte-budget.d.ts.map +1 -0
  81. package/dist/byte-budget.js +9 -0
  82. package/dist/file-hash.d.ts.map +1 -1
  83. package/dist/file-hash.js +25 -19
  84. package/dist/file-lock-sync.d.ts.map +1 -1
  85. package/dist/file-lock-sync.js +26 -9
  86. package/dist/file-store-boundary.d.ts.map +1 -1
  87. package/dist/file-store-boundary.js +9 -5
  88. package/dist/file-store-limit.d.ts +2 -0
  89. package/dist/file-store-limit.d.ts.map +1 -0
  90. package/dist/file-store-limit.js +8 -0
  91. package/dist/file-store-sync-write.d.ts.map +1 -1
  92. package/dist/file-store-sync-write.js +38 -8
  93. package/dist/file-store.d.ts.map +1 -1
  94. package/dist/file-store.js +26 -26
  95. package/dist/json-durable-queue-directory.d.ts +2 -0
  96. package/dist/json-durable-queue-directory.d.ts.map +1 -0
  97. package/dist/json-durable-queue-directory.js +20 -0
  98. package/dist/json-durable-queue-ownership.d.ts +14 -0
  99. package/dist/json-durable-queue-ownership.d.ts.map +1 -0
  100. package/dist/json-durable-queue-ownership.js +168 -0
  101. package/dist/json-durable-queue-retirement.d.ts +9 -0
  102. package/dist/json-durable-queue-retirement.d.ts.map +1 -0
  103. package/dist/json-durable-queue-retirement.js +126 -0
  104. package/dist/json-durable-queue-transfer-lock.d.ts +2 -0
  105. package/dist/json-durable-queue-transfer-lock.d.ts.map +1 -0
  106. package/dist/json-durable-queue-transfer-lock.js +19 -0
  107. package/dist/json-durable-queue.d.ts +1 -0
  108. package/dist/json-durable-queue.d.ts.map +1 -1
  109. package/dist/json-durable-queue.js +90 -57
  110. package/dist/json.d.ts.map +1 -1
  111. package/dist/json.js +27 -8
  112. package/dist/local-roots.d.ts.map +1 -1
  113. package/dist/local-roots.js +4 -2
  114. package/dist/native-binding.d.ts +15 -3
  115. package/dist/native-binding.d.ts.map +1 -1
  116. package/dist/native-operations.d.ts +4 -1
  117. package/dist/native-operations.d.ts.map +1 -1
  118. package/dist/native-operations.js +22 -6
  119. package/dist/native-pinned-write-windows.d.ts +8 -0
  120. package/dist/native-pinned-write-windows.d.ts.map +1 -0
  121. package/dist/native-pinned-write-windows.js +92 -0
  122. package/dist/native-pinned-write.d.ts.map +1 -1
  123. package/dist/native-pinned-write.js +136 -127
  124. package/dist/native-staged-file.d.ts +24 -0
  125. package/dist/native-staged-file.d.ts.map +1 -0
  126. package/dist/native-staged-file.js +337 -0
  127. package/dist/native.d.ts.map +1 -1
  128. package/dist/native.js +4 -4
  129. package/dist/opened-realpath.d.ts +2 -0
  130. package/dist/opened-realpath.d.ts.map +1 -1
  131. package/dist/opened-realpath.js +12 -7
  132. package/dist/output-sibling.d.ts.map +1 -1
  133. package/dist/output-sibling.js +11 -110
  134. package/dist/output.d.ts.map +1 -1
  135. package/dist/output.js +4 -2
  136. package/dist/owner-dacl.d.ts.map +1 -1
  137. package/dist/owner-dacl.js +2 -1
  138. package/dist/permission-exec.d.ts +19 -0
  139. package/dist/permission-exec.d.ts.map +1 -1
  140. package/dist/permission-exec.js +57 -11
  141. package/dist/permissions-public.d.ts +1 -1
  142. package/dist/permissions-public.d.ts.map +1 -1
  143. package/dist/permissions-windows.d.ts +3 -0
  144. package/dist/permissions-windows.d.ts.map +1 -1
  145. package/dist/permissions-windows.js +16 -5
  146. package/dist/permissions.d.ts +5 -0
  147. package/dist/permissions.d.ts.map +1 -1
  148. package/dist/pinned-open.d.ts.map +1 -1
  149. package/dist/pinned-open.js +27 -48
  150. package/dist/pinned-write.d.ts +6 -0
  151. package/dist/pinned-write.d.ts.map +1 -1
  152. package/dist/pinned-write.js +26 -37
  153. package/dist/private-directory.d.ts.map +1 -1
  154. package/dist/private-directory.js +3 -2
  155. package/dist/private-temp-workspace.d.ts +3 -1
  156. package/dist/private-temp-workspace.d.ts.map +1 -1
  157. package/dist/private-temp-workspace.js +81 -56
  158. package/dist/publish-file.d.ts.map +1 -1
  159. package/dist/publish-file.js +2 -4
  160. package/dist/read-opened-file.d.ts.map +1 -1
  161. package/dist/read-opened-file.js +6 -4
  162. package/dist/regular-file.d.ts.map +1 -1
  163. package/dist/regular-file.js +136 -82
  164. package/dist/replace-file-copy-fallback.d.ts +3 -1
  165. package/dist/replace-file-copy-fallback.d.ts.map +1 -1
  166. package/dist/replace-file-copy-fallback.js +21 -36
  167. package/dist/replace-file-copy-source.d.ts +21 -0
  168. package/dist/replace-file-copy-source.d.ts.map +1 -0
  169. package/dist/replace-file-copy-source.js +112 -0
  170. package/dist/replace-file-descriptor.d.ts +13 -3
  171. package/dist/replace-file-descriptor.d.ts.map +1 -1
  172. package/dist/replace-file-descriptor.js +32 -7
  173. package/dist/replace-file-rename-policy.d.ts +7 -0
  174. package/dist/replace-file-rename-policy.d.ts.map +1 -0
  175. package/dist/replace-file-rename-policy.js +30 -0
  176. package/dist/replace-file-temp-owner.d.ts +46 -0
  177. package/dist/replace-file-temp-owner.d.ts.map +1 -0
  178. package/dist/replace-file-temp-owner.js +346 -0
  179. package/dist/replace-file.d.ts +6 -1
  180. package/dist/replace-file.d.ts.map +1 -1
  181. package/dist/replace-file.js +72 -58
  182. package/dist/root-impl.d.ts.map +1 -1
  183. package/dist/root-impl.js +118 -98
  184. package/dist/root-paths.d.ts +11 -14
  185. package/dist/root-paths.d.ts.map +1 -1
  186. package/dist/root-paths.js +36 -27
  187. package/dist/root-write-verification.d.ts +11 -0
  188. package/dist/root-write-verification.d.ts.map +1 -0
  189. package/dist/root-write-verification.js +91 -0
  190. package/dist/secret-file.d.ts +1 -6
  191. package/dist/secret-file.d.ts.map +1 -1
  192. package/dist/secret-file.js +49 -120
  193. package/dist/secret-read-async.d.ts +1 -1
  194. package/dist/secret-read-async.d.ts.map +1 -1
  195. package/dist/secret-read-async.js +51 -72
  196. package/dist/secret-read-policy.d.ts +13 -0
  197. package/dist/secret-read-policy.d.ts.map +1 -0
  198. package/dist/secret-read-policy.js +28 -0
  199. package/dist/secret.d.ts +2 -1
  200. package/dist/secret.d.ts.map +1 -1
  201. package/dist/secret.js +2 -1
  202. package/dist/secure-file.d.ts.map +1 -1
  203. package/dist/secure-file.js +42 -29
  204. package/dist/sibling-staged-file.d.ts +15 -0
  205. package/dist/sibling-staged-file.d.ts.map +1 -0
  206. package/dist/sibling-staged-file.js +148 -0
  207. package/dist/sibling-temp.d.ts +3 -0
  208. package/dist/sibling-temp.d.ts.map +1 -1
  209. package/dist/sibling-temp.js +30 -74
  210. package/dist/sidecar-lock-acquire.d.ts.map +1 -1
  211. package/dist/sidecar-lock-acquire.js +48 -27
  212. package/dist/sidecar-lock-handle.d.ts +6 -2
  213. package/dist/sidecar-lock-handle.d.ts.map +1 -1
  214. package/dist/sidecar-lock-handle.js +17 -3
  215. package/dist/sidecar-lock-policy.d.ts +2 -0
  216. package/dist/sidecar-lock-policy.d.ts.map +1 -1
  217. package/dist/sidecar-lock-policy.js +29 -0
  218. package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
  219. package/dist/sidecar-lock-reclaim.js +29 -7
  220. package/dist/sidecar-lock.d.ts.map +1 -1
  221. package/dist/sidecar-lock.js +28 -16
  222. package/dist/staged-directory.d.ts +16 -0
  223. package/dist/staged-directory.d.ts.map +1 -0
  224. package/dist/staged-directory.js +60 -0
  225. package/dist/staged-file-types.d.ts +56 -0
  226. package/dist/staged-file-types.d.ts.map +1 -0
  227. package/dist/staged-file-types.js +1 -0
  228. package/dist/staged-file.d.ts +10 -0
  229. package/dist/staged-file.d.ts.map +1 -0
  230. package/dist/staged-file.js +15 -0
  231. package/dist/strict-file-identity.d.ts +6 -0
  232. package/dist/strict-file-identity.d.ts.map +1 -0
  233. package/dist/strict-file-identity.js +48 -0
  234. package/dist/suppressed-error.d.ts +6 -0
  235. package/dist/suppressed-error.d.ts.map +1 -0
  236. package/dist/suppressed-error.js +15 -0
  237. package/dist/temp-cleanup.d.ts +2 -0
  238. package/dist/temp-cleanup.d.ts.map +1 -1
  239. package/dist/temp-cleanup.js +25 -10
  240. package/dist/temp-workspace-owner.d.ts +23 -0
  241. package/dist/temp-workspace-owner.d.ts.map +1 -0
  242. package/dist/temp-workspace-owner.js +320 -0
  243. package/dist/temp.d.ts +1 -1
  244. package/dist/temp.d.ts.map +1 -1
  245. package/dist/test-hooks.d.ts +5 -0
  246. package/dist/test-hooks.d.ts.map +1 -1
  247. package/dist/windows-owner.d.ts +3 -0
  248. package/dist/windows-owner.d.ts.map +1 -1
  249. package/dist/windows-owner.js +10 -2
  250. package/docs/advanced.md +19 -2
  251. package/docs/archive.md +250 -35
  252. package/docs/atomic.md +11 -2
  253. package/docs/config.md +7 -0
  254. package/docs/contributing.md +45 -7
  255. package/docs/durability.md +18 -5
  256. package/docs/errors.md +16 -1
  257. package/docs/file-store.md +2 -0
  258. package/docs/index.md +3 -1
  259. package/docs/install.md +20 -8
  260. package/docs/json.md +8 -4
  261. package/docs/migrating-to-0.5.md +7 -7
  262. package/docs/migrating-to-0.6.md +43 -0
  263. package/docs/native-helper.md +30 -8
  264. package/docs/native.md +73 -17
  265. package/docs/output.md +10 -0
  266. package/docs/path-scope.md +28 -2
  267. package/docs/permissions.md +13 -2
  268. package/docs/public-api.md +6 -3
  269. package/docs/quickstart.md +1 -1
  270. package/docs/reading.md +1 -1
  271. package/docs/regular-file.md +9 -2
  272. package/docs/root.md +3 -1
  273. package/docs/secret-file.md +12 -0
  274. package/docs/secure-file.md +21 -3
  275. package/docs/security-model.md +33 -1
  276. package/docs/sidecar-lock.md +15 -1
  277. package/docs/staged-file.md +178 -0
  278. package/docs/store.md +13 -0
  279. package/docs/temp.md +128 -14
  280. package/docs/testing.md +1 -1
  281. package/docs/writing.md +19 -0
  282. package/package.json +16 -9
  283. package/dist/native/darwin-arm64/fs-safe-native.node +0 -0
  284. package/dist/native/darwin-x64/fs-safe-native.node +0 -0
  285. package/dist/native/linux-arm64-gnu/fs-safe-native.node +0 -0
  286. package/dist/native/linux-arm64-musl/fs-safe-native.node +0 -0
  287. package/dist/native/linux-x64-gnu/fs-safe-native.node +0 -0
  288. package/dist/native/linux-x64-musl/fs-safe-native.node +0 -0
  289. package/dist/native/win32-x64-msvc/fs-safe-native.node +0 -0
@@ -1,15 +1,15 @@
1
- import { constants as fsConstants } from "node:fs";
2
1
  import fs from "node:fs/promises";
3
2
  import path from "node:path";
4
3
  import { readFileHandleBounded } from "./bounded-read.js";
4
+ import { normalizeMaxBytes } from "./byte-budget.js";
5
5
  import { assertNoUnsafeDeviceReadPath } from "./device-path.js";
6
6
  import { FsSafeError } from "./errors.js";
7
- import { sameFileIdentity } from "./file-identity.js";
8
7
  import { isWindowsDriveLetterPath, isWindowsNetworkPath } from "./local-file-access.js";
9
8
  import { isPathInside, isSymlinkOpenError } from "./path.js";
9
+ import { formatPermissionErrorDetail } from "./permission-exec.js";
10
+ import { resolveReadOpenFlags } from "./read-open-flags.js";
10
11
  import { inspectPathPermissions, isGroupReadable, isGroupWritable, isWorldReadable, isWorldWritable, modeBits, } from "./permissions.js";
11
- const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants;
12
- const OPEN_READ_FLAGS = fsConstants.O_RDONLY | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
12
+ import { inspectFileIdentity } from "./strict-file-identity.js";
13
13
  function isAbsolutePathname(value) {
14
14
  return (path.isAbsolute(value) ||
15
15
  (process.platform === "win32" &&
@@ -18,7 +18,7 @@ function isAbsolutePathname(value) {
18
18
  function label(options) {
19
19
  return options.label ?? "Secure file";
20
20
  }
21
- async function openSecureHandle(options) {
21
+ async function openSecureHandle(options, maxBytes) {
22
22
  assertNoUnsafeDeviceReadPath(options.filePath);
23
23
  if (isWindowsNetworkPath(options.filePath, "win32") && !options.trust?.allowNetworkPath) {
24
24
  throw new FsSafeError("invalid-path", `${label(options)} must be a local absolute path.`);
@@ -31,15 +31,19 @@ async function openSecureHandle(options) {
31
31
  cause: err,
32
32
  });
33
33
  });
34
- if (preStat.isDirectory()) {
35
- throw new FsSafeError("not-file", `${label(options)} must be a file: ${options.filePath}`);
34
+ if (preStat.isSymbolicLink()) {
35
+ if (!options.trust?.allowSymlink) {
36
+ throw new FsSafeError("symlink", `${label(options)} must not be a symlink: ${options.filePath}`);
37
+ }
36
38
  }
37
- if (preStat.isSymbolicLink() && !options.trust?.allowSymlink) {
38
- throw new FsSafeError("symlink", `${label(options)} must not be a symlink: ${options.filePath}`);
39
+ else if (!preStat.isFile()) {
40
+ throw new FsSafeError("not-file", `${label(options)} must be a file: ${options.filePath}`);
39
41
  }
40
42
  let handle;
41
43
  try {
42
- handle = await fs.open(options.filePath, options.trust?.allowSymlink ? fsConstants.O_RDONLY : OPEN_READ_FLAGS);
44
+ handle = await fs.open(options.filePath, resolveReadOpenFlags({
45
+ followSymlinks: options.trust?.allowSymlink === true,
46
+ }));
43
47
  }
44
48
  catch (err) {
45
49
  if (isSymlinkOpenError(err)) {
@@ -52,22 +56,20 @@ async function openSecureHandle(options) {
52
56
  if (!openedStat.isFile()) {
53
57
  throw new FsSafeError("not-file", `${label(options)} must be a file: ${options.filePath}`);
54
58
  }
55
- const pathStat = options.trust?.allowSymlink
56
- ? await fs.stat(options.filePath)
57
- : await fs.lstat(options.filePath);
58
- if (!options.trust?.allowSymlink && pathStat.isSymbolicLink()) {
59
- throw new FsSafeError("symlink", `${label(options)} must not be a symlink: ${options.filePath}`);
60
- }
61
- if (!sameFileIdentity(pathStat, openedStat)) {
62
- throw new FsSafeError("path-mismatch", `${label(options)} changed during open.`);
63
- }
59
+ const openedIdentity = await inspectFileIdentity(() => handle.stat({ bigint: true }));
60
+ await inspectFileIdentity(async () => {
61
+ const pathStat = options.trust?.allowSymlink
62
+ ? await fs.stat(options.filePath, { bigint: true })
63
+ : await fs.lstat(options.filePath, { bigint: true });
64
+ if (!options.trust?.allowSymlink && pathStat.isSymbolicLink()) {
65
+ throw new FsSafeError("symlink", `${label(options)} must not be a symlink: ${options.filePath}`);
66
+ }
67
+ return pathStat;
68
+ }, openedIdentity);
64
69
  const realPath = await fs.realpath(options.filePath);
65
- const realStat = await fs.stat(realPath);
66
- if (!sameFileIdentity(realStat, openedStat)) {
67
- throw new FsSafeError("path-mismatch", `${label(options)} real path changed during open.`);
68
- }
69
- if (options.io?.maxBytes !== undefined && openedStat.size > options.io.maxBytes) {
70
- throw new FsSafeError("too-large", `${label(options)} exceeded maxBytes (${options.io.maxBytes}).`);
70
+ await inspectFileIdentity(() => fs.stat(realPath, { bigint: true }), openedIdentity);
71
+ if (maxBytes !== undefined && openedStat.size > maxBytes) {
72
+ throw new FsSafeError("too-large", `${label(options)} exceeded maxBytes (${maxBytes}).`);
71
73
  }
72
74
  return { handle, pathStat: openedStat, realPath };
73
75
  }
@@ -111,11 +113,21 @@ async function assertSecurePermissions(options, stat, realPath) {
111
113
  const permissions = platform === "win32"
112
114
  ? await inspectPathPermissions(realPath, options.inject)
113
115
  : inspectOpenedPermissions(stat, platform);
116
+ const reason = permissions.error ? `: ${formatPermissionErrorDetail(permissions.error)}` : "";
117
+ const diagnostics = {
118
+ ...(permissions.errorCause !== undefined ? { cause: permissions.errorCause } : {}),
119
+ ...(permissions.ownerError || permissions.errorDetail ? {
120
+ details: {
121
+ ...(permissions.ownerError ? { ownerError: formatPermissionErrorDetail(permissions.ownerError) } : {}),
122
+ ...permissions.errorDetail,
123
+ },
124
+ } : {}),
125
+ };
114
126
  if (!permissions.ok) {
115
- throw new FsSafeError("permission-unverified", `${label(options)} permissions could not be verified: ${realPath}`);
127
+ throw new FsSafeError("permission-unverified", `${label(options)} permissions could not be verified: ${realPath}${reason}`, diagnostics);
116
128
  }
117
129
  if (platform === "win32" && permissions.source === "unknown") {
118
- throw new FsSafeError("permission-unverified", `${label(options)} ACL verification unavailable on Windows for ${realPath}.`);
130
+ throw new FsSafeError("permission-unverified", `${label(options)} ACL verification unavailable on Windows for ${realPath}${reason || "."}`, diagnostics);
119
131
  }
120
132
  if (platform === "win32" && permissions.ownerTrusted !== true) {
121
133
  throw new FsSafeError(permissions.ownerTrusted === false ? "not-owned" : "permission-unverified", `${label(options)} owner could not be trusted on Windows: ${realPath}`);
@@ -156,11 +168,12 @@ async function readHandleWithTimeout(handle, timeoutMs, maxBytes) {
156
168
  }
157
169
  }
158
170
  export async function readSecureFile(options) {
159
- const opened = await openSecureHandle(options);
171
+ const maxBytes = normalizeMaxBytes(options.io?.maxBytes);
172
+ const opened = await openSecureHandle(options, maxBytes);
160
173
  try {
161
174
  await assertTrustedDirs(options, opened.realPath);
162
175
  const permissions = await assertSecurePermissions(options, opened.pathStat, opened.realPath);
163
- const buffer = await readHandleWithTimeout(opened.handle, options.io?.timeoutMs, options.io?.maxBytes);
176
+ const buffer = await readHandleWithTimeout(opened.handle, options.io?.timeoutMs, maxBytes);
164
177
  return { buffer, realPath: opened.realPath, stat: opened.pathStat, permissions };
165
178
  }
166
179
  finally {
@@ -0,0 +1,15 @@
1
+ export declare function writeCallbackSibling<T>(params: {
2
+ tempPath: string;
3
+ write: (tempPath: string) => Promise<T>;
4
+ resolveFinalPath: (result: T) => string;
5
+ mode?: number;
6
+ /** Preserve the caller's historical best-effort mode behavior. */
7
+ ignoreModeError?: boolean;
8
+ maxBytes?: number;
9
+ syncTempFile: boolean;
10
+ syncParentDir: boolean;
11
+ }): Promise<{
12
+ filePath: string;
13
+ result: T;
14
+ }>;
15
+ //# sourceMappingURL=sibling-staged-file.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sibling-staged-file.d.ts","sourceRoot":"","sources":["../src/sibling-staged-file.ts"],"names":[],"mappings":"AAkCA,wBAAsB,oBAAoB,CAAC,CAAC,EAAE,MAAM,EAAE;IACpD,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;IACxC,gBAAgB,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,MAAM,CAAC;IACxC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,kEAAkE;IAClE,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,OAAO,CAAC;IACtB,aAAa,EAAE,OAAO,CAAC;CACxB,GAAG,OAAO,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,CAAC,CAAA;CAAE,CAAC,CA6G3C"}
@@ -0,0 +1,148 @@
1
+ import fsSync, {} from "node:fs";
2
+ import fs, {} from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { assertAsyncDirectoryGuard, createAsyncDirectoryGuard } from "./directory-guard.js";
5
+ import { syncDirectoryBestEffort } from "./directory-durability.js";
6
+ import { FsSafeError } from "./errors.js";
7
+ import { resolveReadOpenFlags } from "./read-open-flags.js";
8
+ import { inspectFileIdentity } from "./strict-file-identity.js";
9
+ import { registerTempPathForExit } from "./temp-cleanup.js";
10
+ import { serializePathWrite } from "./write-queue.js";
11
+ function assertRegularFile(stat) {
12
+ if (stat.isSymbolicLink()) {
13
+ throw new FsSafeError("symlink", "symlink sibling temp not allowed");
14
+ }
15
+ if (!stat.isFile()) {
16
+ throw new FsSafeError("not-file", "sibling temp must be a regular file");
17
+ }
18
+ if (stat.nlink !== 1n) {
19
+ throw new FsSafeError("hardlink", "sibling temp must have exactly one link");
20
+ }
21
+ }
22
+ async function inspectStage(inspect, expected) {
23
+ return await inspectFileIdentity(async () => {
24
+ const stat = await inspect();
25
+ assertRegularFile(stat);
26
+ return stat;
27
+ }, expected);
28
+ }
29
+ // Callback paths are not owned until all three admission observations agree.
30
+ // Keep one descriptor and one exact identity through mode, sync, rename and cleanup.
31
+ // Read/write access is needed only when the caller requests file synchronization.
32
+ export async function writeCallbackSibling(params) {
33
+ const parent = path.dirname(params.tempPath);
34
+ const guard = await createAsyncDirectoryGuard(parent);
35
+ const parentIdentity = await inspectFileIdentity(() => fs.lstat(parent, { bigint: true }));
36
+ const assertParent = async () => {
37
+ await assertAsyncDirectoryGuard(guard);
38
+ await inspectFileIdentity(() => fs.lstat(parent, { bigint: true }), parentIdentity);
39
+ };
40
+ let handle;
41
+ let identity;
42
+ let unregister;
43
+ let renamed = false;
44
+ let failure;
45
+ const inspectPath = (pathname, expected) => inspectStage(() => fs.lstat(pathname, { bigint: true }), expected);
46
+ const assertCurrent = async (pathname) => {
47
+ await assertParent();
48
+ const opened = await inspectStage(() => handle.stat({ bigint: true }), identity);
49
+ const current = await inspectPath(pathname, opened);
50
+ if (params.maxBytes !== undefined &&
51
+ (opened.size > params.maxBytes || current.size > params.maxBytes)) {
52
+ throw new FsSafeError("too-large", `sibling temp exceeds maxBytes (${params.maxBytes})`);
53
+ }
54
+ };
55
+ try {
56
+ const result = await params.write(params.tempPath);
57
+ await assertParent();
58
+ const before = await inspectPath(params.tempPath);
59
+ try {
60
+ // No create/truncate flags; O_NONBLOCK also bounds a FIFO swap during open.
61
+ const access = params.syncTempFile ? fsSync.constants.O_RDWR : fsSync.constants.O_RDONLY;
62
+ handle = await fs.open(params.tempPath, access | resolveReadOpenFlags());
63
+ }
64
+ catch (error) {
65
+ if (error?.code === "ELOOP") {
66
+ throw new FsSafeError("symlink", "symlink sibling temp not allowed", { cause: error });
67
+ }
68
+ throw error;
69
+ }
70
+ const opened = await inspectStage(() => handle.stat({ bigint: true }), before);
71
+ await inspectPath(params.tempPath, opened);
72
+ await assertParent();
73
+ identity = opened;
74
+ unregister = registerTempPathForExit(params.tempPath, { identity, singleLinkFile: true });
75
+ const filePath = path.resolve(params.resolveFinalPath(result));
76
+ if (path.dirname(filePath) !== parent) {
77
+ throw new Error("Final path must be in the sibling temp directory.");
78
+ }
79
+ if (filePath === params.tempPath) {
80
+ throw new FsSafeError("invalid-path", "final path must differ from the sibling temp path");
81
+ }
82
+ await serializePathWrite(filePath, async () => {
83
+ await assertCurrent(params.tempPath);
84
+ if (params.mode !== undefined) {
85
+ try {
86
+ await handle.chmod(params.mode);
87
+ }
88
+ catch (error) {
89
+ if (!params.ignoreModeError)
90
+ throw error;
91
+ }
92
+ }
93
+ if (params.syncTempFile) {
94
+ await assertCurrent(params.tempPath);
95
+ try {
96
+ await handle.sync();
97
+ }
98
+ catch (error) {
99
+ if (error?.code !== "EPERM")
100
+ throw error;
101
+ }
102
+ }
103
+ await assertCurrent(params.tempPath);
104
+ await fs.rename(params.tempPath, filePath);
105
+ // A later verification failure never authorizes rollback of the final name.
106
+ renamed = true;
107
+ unregister();
108
+ await assertCurrent(filePath);
109
+ if (params.syncParentDir)
110
+ await syncDirectoryBestEffort(parent);
111
+ await assertCurrent(filePath);
112
+ });
113
+ return { filePath, result };
114
+ }
115
+ catch (error) {
116
+ failure = { error };
117
+ throw error;
118
+ }
119
+ finally {
120
+ try {
121
+ if (!renamed && identity) {
122
+ try {
123
+ await assertParent();
124
+ await inspectStage(() => handle.stat({ bigint: true }), identity);
125
+ await inspectPath(params.tempPath, identity);
126
+ await fs.unlink(params.tempPath);
127
+ unregister?.();
128
+ }
129
+ catch (error) {
130
+ // Preserve observed substitutes; retry only operational cleanup failures.
131
+ if (error instanceof FsSafeError || error?.code === "ENOENT") {
132
+ unregister?.();
133
+ }
134
+ }
135
+ }
136
+ }
137
+ finally {
138
+ try {
139
+ await handle?.close();
140
+ }
141
+ catch (error) {
142
+ if (failure)
143
+ throw new AggregateError([failure.error, error], "sibling publication and close failed");
144
+ throw error;
145
+ }
146
+ }
147
+ }
148
+ }
@@ -5,8 +5,11 @@ export type WriteSiblingTempFileOptions<T> = {
5
5
  tempPrefix?: string;
6
6
  dirMode?: number;
7
7
  chmodDir?: boolean;
8
+ /** Final file mode; omitted preserves the producer's mode. Applied through the retained descriptor. */
8
9
  mode?: number;
10
+ /** Sync the staged descriptor before rename; defaults to false. */
9
11
  syncTempFile?: boolean;
12
+ /** Best-effort parent directory sync after rename; defaults to false. */
10
13
  syncParentDir?: boolean;
11
14
  };
12
15
  export type WriteSiblingTempFileResult<T> = {
@@ -1 +1 @@
1
- {"version":3,"file":"sibling-temp.d.ts","sourceRoot":"","sources":["../src/sibling-temp.ts"],"names":[],"mappings":"AAcA,MAAM,MAAM,2BAA2B,CAAC,CAAC,IAAI;IAC3C,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;IAC5C,gBAAgB,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,MAAM,CAAC;IACxC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,0BAA0B,CAAC,CAAC,IAAI;IAC1C,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,CAAC,CAAC;CACX,CAAC;AA8BF,wBAAsB,oBAAoB,CAAC,CAAC,EAC1C,OAAO,EAAE,2BAA2B,CAAC,CAAC,CAAC,GACtC,OAAO,CAAC,0BAA0B,CAAC,CAAC,CAAC,CAAC,CA0CxC;AAkBD,wBAAsB,uBAAuB,CAAC,MAAM,EAAE;IACpD,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,GAAG,OAAO,CAAC,IAAI,CAAC,CA8ChB"}
1
+ {"version":3,"file":"sibling-temp.d.ts","sourceRoot":"","sources":["../src/sibling-temp.ts"],"names":[],"mappings":"AAaA,MAAM,MAAM,2BAA2B,CAAC,CAAC,IAAI;IAC3C,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;IAC5C,gBAAgB,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,MAAM,CAAC;IACxC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,uGAAuG;IACvG,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,mEAAmE;IACnE,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,yEAAyE;IACzE,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,0BAA0B,CAAC,CAAC,IAAI;IAC1C,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,CAAC,CAAC;CACX,CAAC;AASF,wBAAsB,oBAAoB,CAAC,CAAC,EAC1C,OAAO,EAAE,2BAA2B,CAAC,CAAC,CAAC,GACtC,OAAO,CAAC,0BAA0B,CAAC,CAAC,CAAC,CAAC,CAoBxC;AAkBD,wBAAsB,uBAAuB,CAAC,MAAM,EAAE;IACpD,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,GAAG,OAAO,CAAC,IAAI,CAAC,CA0ChB"}
@@ -2,85 +2,40 @@ import crypto, { randomUUID } from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { assertAsyncDirectoryGuard, createAsyncDirectoryGuard } from "./directory-guard.js";
5
- import { syncDirectoryBestEffort } from "./directory-durability.js";
6
- import { withAsyncDirectoryGuards } from "./guarded-mutation.js";
7
5
  import { sanitizeUntrustedFileName } from "./filename.js";
6
+ import { applyDirectoryMode } from "./replace-file-descriptor.js";
8
7
  import { root } from "./root.js";
9
8
  import { assertSafePathPrefix } from "./safe-path-segment.js";
10
9
  import { resolveSecureTempRoot } from "./secure-temp-dir.js";
11
- import { registerTempPathForExit } from "./temp-cleanup.js";
10
+ import { writeCallbackSibling } from "./sibling-staged-file.js";
11
+ import { tempFile } from "./temp-target.js";
12
12
  import { getFsSafeTestHooks } from "./test-hooks.js";
13
- import { serializePathWrite } from "./write-queue.js";
14
13
  function buildTempPath(dir, tempPrefix) {
15
14
  const safePrefix = assertSafePathPrefix(tempPrefix ?? ".fs-safe-stream", {
16
15
  label: "sibling temp prefix",
17
16
  });
18
17
  return path.join(dir, `${safePrefix}.${process.pid}.${randomUUID()}.tmp`);
19
18
  }
20
- async function syncFileBestEffort(filePath) {
21
- const handle = await fs.open(filePath, "r+");
22
- try {
23
- await handle.sync();
24
- }
25
- catch (error) {
26
- if (error.code !== "EPERM") {
27
- throw error;
28
- }
29
- }
30
- finally {
31
- await handle.close();
32
- }
33
- }
34
- function assertFinalPathIsSibling(dir, filePath) {
35
- const resolvedDir = path.resolve(dir);
36
- const resolvedFile = path.resolve(filePath);
37
- if (path.dirname(resolvedFile) !== resolvedDir) {
38
- throw new Error("Final path must be in the sibling temp directory.");
39
- }
40
- }
41
19
  export async function writeSiblingTempFile(options) {
42
20
  const dir = path.resolve(options.dir);
43
21
  await fs.mkdir(dir, { recursive: true, mode: options.dirMode ?? 0o700 });
44
22
  if (options.chmodDir !== false) {
45
- await fs.chmod(dir, options.dirMode ?? 0o700).catch(() => undefined);
46
- }
47
- const dirGuard = await createAsyncDirectoryGuard(dir);
48
- const tempPath = buildTempPath(dir, options.tempPrefix);
49
- const unregisterTempPath = registerTempPathForExit(tempPath);
50
- let tempExists = false;
51
- try {
52
- tempExists = true;
53
- const result = await options.writeTemp(tempPath);
54
- unregisterTempPath.setIdentity(await fs.lstat(tempPath, { bigint: true }));
55
- if (options.mode !== undefined) {
56
- await fs.chmod(tempPath, options.mode).catch(() => undefined);
57
- }
58
- if (options.syncTempFile) {
59
- await syncFileBestEffort(tempPath);
60
- }
61
- const filePath = path.resolve(options.resolveFinalPath(result));
62
- assertFinalPathIsSibling(dir, filePath);
63
- await serializePathWrite(filePath, async () => {
64
- await withAsyncDirectoryGuards([dirGuard], async () => {
65
- await fs.rename(tempPath, filePath);
66
- });
67
- tempExists = false;
68
- unregisterTempPath();
69
- if (options.mode !== undefined) {
70
- await fs.chmod(filePath, options.mode).catch(() => undefined);
71
- }
72
- if (options.syncParentDir) {
73
- await syncDirectoryBestEffort(dir);
74
- }
23
+ await applyDirectoryMode({
24
+ fsModule: fs,
25
+ dirPath: dir,
26
+ mode: options.dirMode ?? 0o700,
27
+ ignoreChmodError: true,
75
28
  });
76
- return { filePath, result };
77
- }
78
- finally {
79
- if (tempExists) {
80
- await fs.rm(tempPath, { force: true }).catch(() => undefined);
81
- }
82
- unregisterTempPath();
83
29
  }
30
+ return await writeCallbackSibling({
31
+ tempPath: buildTempPath(dir, options.tempPrefix),
32
+ write: options.writeTemp,
33
+ resolveFinalPath: options.resolveFinalPath,
34
+ mode: options.mode,
35
+ ignoreModeError: true,
36
+ syncTempFile: options.syncTempFile === true,
37
+ syncParentDir: options.syncParentDir === true,
38
+ });
84
39
  }
85
40
  function buildSiblingTempPath(params) {
86
41
  const id = crypto.randomUUID();
@@ -107,18 +62,20 @@ export async function writeViaSiblingTempPath(params) {
107
62
  throw new Error("Target path is outside the allowed root");
108
63
  }
109
64
  const rootGuard = await createAsyncDirectoryGuard(rootDir);
110
- const tempDir = await fs.mkdtemp(path.join(resolveSecureTempRoot({
111
- fallbackPrefix: "fs-safe-output",
112
- unsafeFallbackLabel: "sibling temp output dir",
113
- warn: () => undefined,
114
- }), "fs-safe-output-"));
115
- const tempPath = buildSiblingTempPath({
116
- targetPath: path.join(tempDir, path.basename(targetPath)),
117
- fallbackFileName: params.fallbackFileName ?? "output.bin",
118
- tempPrefix: params.tempPrefix ?? ".fs-safe-output-",
65
+ const workspace = await tempFile({
66
+ rootDir: resolveSecureTempRoot({
67
+ fallbackPrefix: "fs-safe-output",
68
+ unsafeFallbackLabel: "sibling temp output dir",
69
+ warn: () => undefined,
70
+ }),
71
+ prefix: "fs-safe-output",
119
72
  });
120
- const unregisterTempPath = registerTempPathForExit(tempDir, { recursive: true });
121
73
  try {
74
+ const tempPath = buildSiblingTempPath({
75
+ targetPath: path.join(workspace.dir, path.basename(targetPath)),
76
+ fallbackFileName: params.fallbackFileName ?? "output.bin",
77
+ tempPrefix: params.tempPrefix ?? ".fs-safe-output-",
78
+ });
122
79
  await getFsSafeTestHooks()?.beforeSiblingTempWrite?.(tempPath);
123
80
  await params.writeTemp(tempPath);
124
81
  await assertAsyncDirectoryGuard(rootGuard);
@@ -127,7 +84,6 @@ export async function writeViaSiblingTempPath(params) {
127
84
  await assertAsyncDirectoryGuard(rootGuard);
128
85
  }
129
86
  finally {
130
- await fs.rm(tempDir, { recursive: true, force: true }).catch(() => { });
131
- unregisterTempPath();
87
+ await workspace.cleanup();
132
88
  }
133
89
  }
@@ -1 +1 @@
1
- {"version":3,"file":"sidecar-lock-acquire.d.ts","sourceRoot":"","sources":["../src/sidecar-lock-acquire.ts"],"names":[],"mappings":"AAGA,OAAO,EAA6B,KAAK,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1F,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,gBAAgB,CAAC;AAO3C,OAAO,EAUL,KAAK,mBAAmB,EACzB,MAAM,2BAA2B,CAAC;AACnC,OAAO,KAAK,EAAE,yBAAyB,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAE5F,KAAK,iBAAiB,GAAG,IAAI,CAAC,gBAAgB,EAAE,OAAO,GAAG,MAAM,GAAG,WAAW,CAAC,CAAC;AAEhF,MAAM,MAAM,eAAe,GAAG;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,MAAM,EAAE,iBAAiB,CAAC;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,mBAAmB,CAAC;IAC9B,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,cAAc,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/B,QAAQ,CAAC,EAAE,IAAI,CAAC;IAChB,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;IACxC,eAAe,CAAC,EAAE,MAAM,CAAC,OAAO,CAAC;CAClC,CAAC;AAEF,KAAK,6BAA6B,GAAG;IACnC,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;IACnC,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC3B,2BAA2B,IAAI,IAAI,CAAC;IACpC,iBAAiB,CAAC,oBAAoB,EAAE,MAAM,EAAE,IAAI,EAAE,eAAe,GAAG,iBAAiB,CAAC;IAC1F,eAAe,CACb,oBAAoB,EAAE,MAAM,EAC5B,IAAI,EAAE,eAAe,EACrB,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,GAC5B,OAAO,CAAC,OAAO,CAAC,CAAC;CACrB,CAAC;AAaF,wBAAsB,kBAAkB,CAAC,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/E,OAAO,EAAE,yBAAyB,CAAC,QAAQ,CAAC,EAC5C,OAAO,EAAE,6BAA6B,GACrC,OAAO,CAAC,iBAAiB,CAAC,CAsP5B"}
1
+ {"version":3,"file":"sidecar-lock-acquire.d.ts","sourceRoot":"","sources":["../src/sidecar-lock-acquire.ts"],"names":[],"mappings":"AAGA,OAAO,EAA6B,KAAK,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1F,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,gBAAgB,CAAC;AAS3C,OAAO,EAUL,KAAK,mBAAmB,EACzB,MAAM,2BAA2B,CAAC;AACnC,OAAO,KAAK,EAAE,yBAAyB,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAG5F,KAAK,iBAAiB,GAAG,IAAI,CAAC,gBAAgB,EAAE,OAAO,GAAG,MAAM,GAAG,WAAW,CAAC,CAAC;AAEhF,MAAM,MAAM,eAAe,GAAG;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,MAAM,EAAE,iBAAiB,CAAC;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,mBAAmB,CAAC;IAC9B,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,cAAc,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/B,QAAQ,CAAC,EAAE,IAAI,CAAC;IAChB,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;IACxC,eAAe,CAAC,EAAE,MAAM,CAAC,OAAO,CAAC;CAClC,CAAC;AAEF,KAAK,6BAA6B,GAAG;IACnC,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;IACnC,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC3B,2BAA2B,IAAI,IAAI,CAAC;IACpC,iBAAiB,CAAC,oBAAoB,EAAE,MAAM,EAAE,IAAI,EAAE,eAAe,GAAG,iBAAiB,CAAC;IAC1F,eAAe,CACb,oBAAoB,EAAE,MAAM,EAC5B,IAAI,EAAE,eAAe,EACrB,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,GAC5B,OAAO,CAAC,OAAO,CAAC,CAAC;CACrB,CAAC;AAaF,wBAAsB,kBAAkB,CAAC,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/E,OAAO,EAAE,yBAAyB,CAAC,QAAQ,CAAC,EAC5C,OAAO,EAAE,6BAA6B,GACrC,OAAO,CAAC,iBAAiB,CAAC,CA+Q5B"}
@@ -2,8 +2,9 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { FsSafeError } from "./errors.js";
4
4
  import { createNativeExclusiveFile } from "./native-operations.js";
5
- import { computeSidecarLockDelayMs, defaultSidecarLockShouldReclaim, isTransientLockFileDenial, maxTransientLockDenials, } from "./sidecar-lock-policy.js";
5
+ import { computeSidecarLockDelayMs, defaultSidecarLockShouldReclaim, isTransientLockFileDenial, maxTransientLockDenials, validateSidecarLockRetryOptions, validateSidecarLockTimeoutMs, } from "./sidecar-lock-policy.js";
6
6
  import { readSidecarLockSnapshot, relativeSidecarLockPath, releaseSidecarReclaimGuard, removeSidecarLockIfUnchanged, removeStaleSidecarLockIfAllowed, serializeSidecarLockPayload, sidecarLockSnapshotStillPresent, sidecarReclaimGuardExists, tryAcquireSidecarReclaimGuard, } from "./sidecar-lock-reclaim.js";
7
+ import { createSuppressedError } from "./suppressed-error.js";
7
8
  async function resolveNormalizedTargetPath(targetPath) {
8
9
  const resolved = path.resolve(targetPath);
9
10
  const dir = path.dirname(resolved);
@@ -16,19 +17,34 @@ async function resolveNormalizedTargetPath(targetPath) {
16
17
  }
17
18
  }
18
19
  export async function acquireSidecarLock(options, context) {
20
+ const retry = options.retry ?? {};
21
+ validateSidecarLockRetryOptions(retry);
22
+ validateSidecarLockTimeoutMs(options.timeoutMs);
19
23
  context.ensureExitCleanupRegistered();
20
24
  const normalizedTargetPath = await resolveNormalizedTargetPath(options.targetPath);
21
25
  const lockPath = options.lockPath ?? `${normalizedTargetPath}.lock`;
22
- const held = context.held.get(normalizedTargetPath);
26
+ let held = context.held.get(normalizedTargetPath);
23
27
  if (held &&
24
28
  options.reentrantOwner !== undefined &&
25
29
  held.reentrantOwner !== undefined &&
26
30
  options.reentrantOwner === held.reentrantOwner) {
27
- held.refCount += 1;
28
- return context.handleForHeldLock(normalizedTargetPath, held);
31
+ // A final release may already have decremented the count to zero and be
32
+ // removing the sidecar. Do not admit a new reentrant handle until that
33
+ // cleanup settles: successful cleanup requires a fresh acquisition, while
34
+ // failed cleanup leaves the existing sidecar held for a retry.
35
+ if (held.releasePromise) {
36
+ await held.releasePromise.catch(() => undefined);
37
+ held = context.held.get(normalizedTargetPath);
38
+ }
39
+ if (held &&
40
+ options.reentrantOwner !== undefined &&
41
+ held.reentrantOwner !== undefined &&
42
+ options.reentrantOwner === held.reentrantOwner) {
43
+ held.refCount += 1;
44
+ return context.handleForHeldLock(normalizedTargetPath, held);
45
+ }
29
46
  }
30
47
  const startedAt = Date.now();
31
- const retry = options.retry ?? {};
32
48
  const maxRetries = options.timeoutMs === Number.POSITIVE_INFINITY ? undefined : retry.retries;
33
49
  const reclaimGuardPath = `${lockPath}.reclaim`;
34
50
  let ownsReclaimGuard = false;
@@ -149,31 +165,36 @@ export async function acquireSidecarLock(options, context) {
149
165
  return returnedHandle;
150
166
  }
151
167
  catch (err) {
152
- if (handle) {
153
- const failedSnapshot = { payload: null };
154
- try {
155
- failedSnapshot.stat = await handle.stat();
156
- }
157
- catch {
158
- // Best-effort cleanup of a failed exclusive create.
168
+ try {
169
+ if (handle) {
170
+ const failedSnapshot = { payload: null };
171
+ try {
172
+ failedSnapshot.stat = await handle.stat();
173
+ }
174
+ catch {
175
+ // Best-effort cleanup of a failed exclusive create.
176
+ }
177
+ const current = context.held.get(normalizedTargetPath);
178
+ if (current?.handle === handle) {
179
+ context.held.delete(normalizedTargetPath);
180
+ }
181
+ await handle.close().catch(() => undefined);
182
+ // The file may be empty or partial JSON, so remove by the identity
183
+ // captured from our exclusive handle rather than by pathname alone.
184
+ await removeSidecarLockIfUnchanged(lockPath, failedSnapshot, {
185
+ lockRoot: options.lockRoot,
186
+ parsePayload: options.parsePayload,
187
+ });
159
188
  }
160
- const current = context.held.get(normalizedTargetPath);
161
- if (current?.handle === handle) {
162
- context.held.delete(normalizedTargetPath);
189
+ else if (createdSnapshot) {
190
+ await removeSidecarLockIfUnchanged(lockPath, createdSnapshot, {
191
+ lockRoot: options.lockRoot,
192
+ parsePayload: options.parsePayload,
193
+ });
163
194
  }
164
- await handle.close().catch(() => undefined);
165
- // The file may be empty or partial JSON, so remove by the identity
166
- // captured from our exclusive handle rather than by pathname alone.
167
- await removeSidecarLockIfUnchanged(lockPath, failedSnapshot, {
168
- lockRoot: options.lockRoot,
169
- parsePayload: options.parsePayload,
170
- });
171
195
  }
172
- else if (createdSnapshot) {
173
- await removeSidecarLockIfUnchanged(lockPath, createdSnapshot, {
174
- lockRoot: options.lockRoot,
175
- parsePayload: options.parsePayload,
176
- });
196
+ catch (cleanupError) {
197
+ throw createSuppressedError(cleanupError, err, "file lock acquisition and cleanup both failed");
177
198
  }
178
199
  if (lockFileCreateDenied && withinDenialBudget()) {
179
200
  await retryOrRethrowDenial(err);
@@ -5,7 +5,9 @@ export declare function createSidecarLockHandle(params: {
5
5
  lockPath: string;
6
6
  normalizedTargetPath: string;
7
7
  verifyStillHeld: () => Promise<boolean>;
8
- release: () => Promise<unknown>;
8
+ release: (options?: {
9
+ retry?: boolean;
10
+ }) => Promise<unknown>;
9
11
  }): SidecarLockHandle;
10
12
  export declare function createHeldSidecarLockHandle(params: {
11
13
  normalizedTargetPath: string;
@@ -15,6 +17,8 @@ export declare function createHeldSidecarLockHandle(params: {
15
17
  lockRoot?: Root;
16
18
  parsePayload?: (raw: string) => unknown;
17
19
  };
18
- release: () => Promise<unknown>;
20
+ release: (options?: {
21
+ retry?: boolean;
22
+ }) => Promise<unknown>;
19
23
  }): SidecarLockHandle;
20
24
  //# sourceMappingURL=sidecar-lock-handle.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"sidecar-lock-handle.d.ts","sourceRoot":"","sources":["../src/sidecar-lock-handle.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,gBAAgB,CAAC;AAC3C,OAAO,EAEL,KAAK,mBAAmB,EACzB,MAAM,2BAA2B,CAAC;AACnC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAEjE,wBAAgB,uBAAuB,CAAC,MAAM,EAAE;IAC9C,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,eAAe,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IACxC,OAAO,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;CACjC,GAAG,iBAAiB,CAcpB;AAED,wBAAgB,2BAA2B,CAAC,MAAM,EAAE;IAClD,oBAAoB,EAAE,MAAM,CAAC;IAC7B,IAAI,EAAE;QACJ,QAAQ,EAAE,MAAM,CAAC;QACjB,QAAQ,EAAE,mBAAmB,CAAC;QAC9B,QAAQ,CAAC,EAAE,IAAI,CAAC;QAChB,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;KACzC,CAAC;IACF,OAAO,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;CACjC,GAAG,iBAAiB,CAWpB"}
1
+ {"version":3,"file":"sidecar-lock-handle.d.ts","sourceRoot":"","sources":["../src/sidecar-lock-handle.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,gBAAgB,CAAC;AAC3C,OAAO,EAEL,KAAK,mBAAmB,EACzB,MAAM,2BAA2B,CAAC;AACnC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAEjE,wBAAgB,uBAAuB,CAAC,MAAM,EAAE;IAC9C,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,eAAe,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IACxC,OAAO,EAAE,CAAC,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;CAC9D,GAAG,iBAAiB,CA2BpB;AAED,wBAAgB,2BAA2B,CAAC,MAAM,EAAE;IAClD,oBAAoB,EAAE,MAAM,CAAC;IAC7B,IAAI,EAAE;QACJ,QAAQ,EAAE,MAAM,CAAC;QACjB,QAAQ,EAAE,mBAAmB,CAAC;QAC9B,QAAQ,CAAC,EAAE,IAAI,CAAC;QAChB,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;KACzC,CAAC;IACF,OAAO,EAAE,CAAC,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;CAC9D,GAAG,iBAAiB,CAWpB"}
@@ -1,11 +1,25 @@
1
1
  import { sidecarLockSnapshotStillPresent, } from "./sidecar-lock-reclaim.js";
2
2
  export function createSidecarLockHandle(params) {
3
3
  let released = false;
4
+ let releasePromise;
5
+ let releaseStarted = false;
4
6
  const release = async () => {
5
7
  if (released)
6
8
  return;
7
- released = true;
8
- await params.release();
9
+ if (!releasePromise) {
10
+ const retry = releaseStarted;
11
+ releaseStarted = true;
12
+ releasePromise = (async () => {
13
+ await params.release({ retry });
14
+ released = true;
15
+ })();
16
+ }
17
+ try {
18
+ await releasePromise;
19
+ }
20
+ finally {
21
+ releasePromise = undefined;
22
+ }
9
23
  };
10
24
  return {
11
25
  lockPath: params.lockPath,
@@ -23,6 +37,6 @@ export function createHeldSidecarLockHandle(params) {
23
37
  lockRoot: params.held.lockRoot,
24
38
  parsePayload: params.held.parsePayload,
25
39
  }),
26
- release: params.release,
40
+ release: (options) => params.release(options),
27
41
  });
28
42
  }
@@ -1,4 +1,6 @@
1
1
  import type { SidecarLockRetryOptions } from "./sidecar-lock-types.js";
2
+ export declare function validateSidecarLockRetryOptions(retry: SidecarLockRetryOptions): void;
3
+ export declare function validateSidecarLockTimeoutMs(timeoutMs: number | undefined): void;
2
4
  export declare function computeSidecarLockDelayMs(retry: SidecarLockRetryOptions, attempt: number): number;
3
5
  export declare const maxTransientLockDenials = 8;
4
6
  export declare function isTransientLockFileDenial(error: unknown, lockPath: string): boolean;