@openclaw/fs-safe 0.5.1 → 0.5.2

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 (210) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +18 -6
  3. package/dist/absolute-path.d.ts.map +1 -1
  4. package/dist/absolute-path.js +20 -12
  5. package/dist/archive-entry.d.ts +1 -0
  6. package/dist/archive-entry.d.ts.map +1 -1
  7. package/dist/archive-entry.js +25 -5
  8. package/dist/archive-input.d.ts +7 -0
  9. package/dist/archive-input.d.ts.map +1 -1
  10. package/dist/archive-input.js +11 -1
  11. package/dist/archive-limits.js +1 -1
  12. package/dist/archive-native.d.ts.map +1 -1
  13. package/dist/archive-native.js +6 -3
  14. package/dist/archive-policy.d.ts.map +1 -1
  15. package/dist/archive-policy.js +2 -1
  16. package/dist/archive-read.d.ts.map +1 -1
  17. package/dist/archive-read.js +30 -12
  18. package/dist/archive-staging.d.ts.map +1 -1
  19. package/dist/archive-staging.js +3 -2
  20. package/dist/archive-tar-meta.d.ts.map +1 -1
  21. package/dist/archive-tar-meta.js +7 -1
  22. package/dist/archive-tar.d.ts.map +1 -1
  23. package/dist/archive-tar.js +7 -4
  24. package/dist/archive-zip-entry.d.ts +1 -0
  25. package/dist/archive-zip-entry.d.ts.map +1 -1
  26. package/dist/archive-zip-integrity.d.ts +4 -0
  27. package/dist/archive-zip-integrity.d.ts.map +1 -0
  28. package/dist/archive-zip-integrity.js +44 -0
  29. package/dist/archive-zip-preflight.d.ts.map +1 -1
  30. package/dist/archive-zip-preflight.js +6 -1
  31. package/dist/archive.d.ts.map +1 -1
  32. package/dist/archive.js +5 -2
  33. package/dist/bounded-read-stream.d.ts.map +1 -1
  34. package/dist/bounded-read-stream.js +13 -2
  35. package/dist/deny-mutations.d.ts +1 -0
  36. package/dist/deny-mutations.d.ts.map +1 -1
  37. package/dist/deny-mutations.js +6 -39
  38. package/dist/device-path.d.ts +1 -0
  39. package/dist/device-path.d.ts.map +1 -1
  40. package/dist/device-path.js +6 -6
  41. package/dist/directory-guard.d.ts.map +1 -1
  42. package/dist/directory-guard.js +5 -4
  43. package/dist/error-detail.d.ts +2 -0
  44. package/dist/error-detail.d.ts.map +1 -0
  45. package/dist/error-detail.js +4 -0
  46. package/dist/errors.d.ts.map +1 -1
  47. package/dist/errors.js +3 -0
  48. package/dist/file-hash.d.ts.map +1 -1
  49. package/dist/file-hash.js +4 -1
  50. package/dist/file-lock-sync.d.ts.map +1 -1
  51. package/dist/file-lock-sync.js +88 -35
  52. package/dist/file-store-boundary.d.ts +6 -0
  53. package/dist/file-store-boundary.d.ts.map +1 -1
  54. package/dist/file-store-boundary.js +20 -5
  55. package/dist/file-store-sync-write.d.ts +9 -0
  56. package/dist/file-store-sync-write.d.ts.map +1 -0
  57. package/dist/file-store-sync-write.js +97 -0
  58. package/dist/file-store.d.ts.map +1 -1
  59. package/dist/file-store.js +36 -128
  60. package/dist/filename.d.ts.map +1 -1
  61. package/dist/filename.js +27 -0
  62. package/dist/guarded-mkdir.d.ts.map +1 -1
  63. package/dist/guarded-mkdir.js +5 -6
  64. package/dist/install-path.js +1 -1
  65. package/dist/json-document-store.d.ts.map +1 -1
  66. package/dist/json-document-store.js +19 -13
  67. package/dist/json.d.ts.map +1 -1
  68. package/dist/json.js +1 -3
  69. package/dist/local-file-access.d.ts +3 -2
  70. package/dist/local-file-access.d.ts.map +1 -1
  71. package/dist/local-file-access.js +12 -6
  72. package/dist/local-roots.d.ts.map +1 -1
  73. package/dist/local-roots.js +35 -30
  74. package/dist/move-path.d.ts.map +1 -1
  75. package/dist/move-path.js +51 -12
  76. package/dist/native/darwin-arm64/fs-safe-native.node +0 -0
  77. package/dist/native/darwin-x64/fs-safe-native.node +0 -0
  78. package/dist/native/linux-arm64-gnu/fs-safe-native.node +0 -0
  79. package/dist/native/linux-arm64-musl/fs-safe-native.node +0 -0
  80. package/dist/native/linux-x64-gnu/fs-safe-native.node +0 -0
  81. package/dist/native/linux-x64-musl/fs-safe-native.node +0 -0
  82. package/dist/native/win32-x64-msvc/fs-safe-native.node +0 -0
  83. package/dist/native-binding.d.ts +1 -0
  84. package/dist/native-binding.d.ts.map +1 -1
  85. package/dist/native-operations.d.ts.map +1 -1
  86. package/dist/native-operations.js +14 -1
  87. package/dist/native-pinned-write.d.ts.map +1 -1
  88. package/dist/native-pinned-write.js +44 -17
  89. package/dist/output-sibling.d.ts.map +1 -1
  90. package/dist/output-sibling.js +6 -5
  91. package/dist/path-policy.d.ts.map +1 -1
  92. package/dist/path-policy.js +1 -7
  93. package/dist/path.d.ts.map +1 -1
  94. package/dist/path.js +4 -0
  95. package/dist/permission-exec.d.ts +6 -0
  96. package/dist/permission-exec.d.ts.map +1 -0
  97. package/dist/permission-exec.js +28 -0
  98. package/dist/permissions-windows.d.ts +56 -0
  99. package/dist/permissions-windows.d.ts.map +1 -0
  100. package/dist/permissions-windows.js +285 -0
  101. package/dist/permissions.d.ts +3 -46
  102. package/dist/permissions.d.ts.map +1 -1
  103. package/dist/permissions.js +17 -328
  104. package/dist/pinned-write.js +2 -1
  105. package/dist/regular-file.d.ts.map +1 -1
  106. package/dist/regular-file.js +21 -2
  107. package/dist/replace-directory.d.ts.map +1 -1
  108. package/dist/replace-directory.js +10 -1
  109. package/dist/replace-file-copy-fallback.d.ts +3 -0
  110. package/dist/replace-file-copy-fallback.d.ts.map +1 -1
  111. package/dist/replace-file-copy-fallback.js +18 -11
  112. package/dist/replace-file-descriptor.d.ts +33 -0
  113. package/dist/replace-file-descriptor.d.ts.map +1 -0
  114. package/dist/replace-file-descriptor.js +96 -0
  115. package/dist/replace-file.d.ts +9 -2
  116. package/dist/replace-file.d.ts.map +1 -1
  117. package/dist/replace-file.js +64 -72
  118. package/dist/root-context.d.ts +8 -0
  119. package/dist/root-context.d.ts.map +1 -1
  120. package/dist/root-context.js +43 -2
  121. package/dist/root-errors.d.ts +7 -0
  122. package/dist/root-errors.d.ts.map +1 -1
  123. package/dist/root-errors.js +46 -1
  124. package/dist/root-file.d.ts +1 -0
  125. package/dist/root-file.d.ts.map +1 -1
  126. package/dist/root-file.js +2 -0
  127. package/dist/root-impl.d.ts.map +1 -1
  128. package/dist/root-impl.js +213 -168
  129. package/dist/root-path-symlink.d.ts +3 -0
  130. package/dist/root-path-symlink.d.ts.map +1 -0
  131. package/dist/root-path-symlink.js +35 -0
  132. package/dist/root-path.d.ts +1 -0
  133. package/dist/root-path.d.ts.map +1 -1
  134. package/dist/root-path.js +129 -190
  135. package/dist/root-walk.d.ts.map +1 -1
  136. package/dist/root-walk.js +15 -0
  137. package/dist/safe-path-segment.d.ts +2 -0
  138. package/dist/safe-path-segment.d.ts.map +1 -1
  139. package/dist/safe-path-segment.js +12 -0
  140. package/dist/secret-file.d.ts.map +1 -1
  141. package/dist/secret-file.js +19 -3
  142. package/dist/secure-temp-dir.d.ts.map +1 -1
  143. package/dist/secure-temp-dir.js +6 -1
  144. package/dist/short-path.d.ts +2 -0
  145. package/dist/short-path.d.ts.map +1 -0
  146. package/dist/short-path.js +7 -0
  147. package/dist/sidecar-lock-acquire.d.ts +30 -0
  148. package/dist/sidecar-lock-acquire.d.ts.map +1 -0
  149. package/dist/sidecar-lock-acquire.js +260 -0
  150. package/dist/sidecar-lock-policy.d.ts +3 -0
  151. package/dist/sidecar-lock-policy.d.ts.map +1 -1
  152. package/dist/sidecar-lock-policy.js +20 -3
  153. package/dist/sidecar-lock-reclaim.d.ts +5 -1
  154. package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
  155. package/dist/sidecar-lock-reclaim.js +59 -9
  156. package/dist/sidecar-lock.d.ts.map +1 -1
  157. package/dist/sidecar-lock.js +27 -226
  158. package/dist/temp-target.d.ts.map +1 -1
  159. package/dist/temp-target.js +15 -4
  160. package/dist/test-hooks.d.ts +1 -0
  161. package/dist/test-hooks.d.ts.map +1 -1
  162. package/dist/timing.d.ts +2 -0
  163. package/dist/timing.d.ts.map +1 -1
  164. package/dist/timing.js +9 -0
  165. package/dist/walk.d.ts.map +1 -1
  166. package/dist/walk.js +15 -0
  167. package/docs/advanced.md +1 -1
  168. package/docs/archive.md +27 -20
  169. package/docs/assets/readme-banner.jpg +0 -0
  170. package/docs/atomic.md +48 -19
  171. package/docs/errors.md +31 -7
  172. package/docs/file-store.md +10 -1
  173. package/docs/filename.md +17 -16
  174. package/docs/index.md +1 -0
  175. package/docs/install-path.md +9 -6
  176. package/docs/json.md +8 -2
  177. package/docs/local-roots.md +79 -103
  178. package/docs/native-helper.md +11 -5
  179. package/docs/native.md +2 -2
  180. package/docs/output.md +8 -3
  181. package/docs/path-scope.md +4 -1
  182. package/docs/path.md +7 -3
  183. package/docs/permissions.md +8 -0
  184. package/docs/public-api.md +109 -0
  185. package/docs/quickstart.md +6 -2
  186. package/docs/reading.md +13 -10
  187. package/docs/root.md +26 -4
  188. package/docs/secret-file.md +17 -4
  189. package/docs/security-model.md +21 -4
  190. package/docs/sidecar-lock.md +15 -5
  191. package/docs/temp.md +37 -5
  192. package/docs/test-hooks.md +2 -0
  193. package/docs/types.md +32 -15
  194. package/docs/writing.md +25 -12
  195. package/package.json +7 -3
  196. package/dist/archive-file-io.d.ts +0 -9
  197. package/dist/archive-file-io.d.ts.map +0 -1
  198. package/dist/archive-file-io.js +0 -11
  199. package/dist/file-store-source.d.ts +0 -5
  200. package/dist/file-store-source.d.ts.map +0 -1
  201. package/dist/file-store-source.js +0 -30
  202. package/dist/file-url.d.ts +0 -2
  203. package/dist/file-url.d.ts.map +0 -1
  204. package/dist/file-url.js +0 -1
  205. package/dist/path-stat.d.ts +0 -4
  206. package/dist/path-stat.d.ts.map +0 -1
  207. package/dist/path-stat.js +0 -15
  208. package/dist/windows-permissions-native.d.ts +0 -9
  209. package/dist/windows-permissions-native.d.ts.map +0 -1
  210. package/dist/windows-permissions-native.js +0 -31
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import fsp from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { canonicalPathFromExistingAncestor } from "./absolute-path.js";
4
5
  import { readFileDescriptorBoundedSync } from "./bounded-read.js";
5
6
  import { assertAsyncDirectoryGuard, createAsyncDirectoryGuard } from "./directory-guard.js";
6
7
  import { FsSafeError } from "./errors.js";
@@ -8,6 +9,7 @@ import { sameFileIdentity } from "./file-identity.js";
8
9
  import { resolveHomeRelativePath } from "./home-dir.js";
9
10
  import { openPinnedFileSync } from "./pinned-open.js";
10
11
  import { runPinnedWriteHelper } from "./pinned-write.js";
12
+ import { serializePathWrite } from "./write-queue.js";
11
13
  export const DEFAULT_SECRET_FILE_MAX_BYTES = 16 * 1024;
12
14
  export const PRIVATE_SECRET_DIR_MODE = 0o700;
13
15
  export const PRIVATE_SECRET_FILE_MODE = 0o600;
@@ -89,7 +91,6 @@ function readSecretFileOutcomeSync(filePath, label, options = {}) {
89
91
  filePath: resolvedPath,
90
92
  rejectPathSymlink: options.rejectSymlink,
91
93
  rejectHardlinks: options.rejectHardlinks !== false,
92
- maxBytes,
93
94
  });
94
95
  if (!opened.ok) {
95
96
  const error = normalizeSecretReadError(opened.reason === "validation" ? new Error("security validation failed") : opened.error);
@@ -257,6 +258,15 @@ async function ensurePrivateDirectory(rootDir, targetDir, mode) {
257
258
  }
258
259
  return { rootGuard, targetReal: await fsp.realpath(resolvedTarget) };
259
260
  }
261
+ async function secretFileWriteQueueKey(filePath) {
262
+ try {
263
+ return await canonicalPathFromExistingAncestor(filePath);
264
+ }
265
+ catch {
266
+ // Keep validation and its public error shape owned by the write path below.
267
+ return path.resolve(filePath);
268
+ }
269
+ }
260
270
  async function materializeSecretFileAtomic(params, createOnly) {
261
271
  const mode = params.mode ?? PRIVATE_SECRET_FILE_MODE;
262
272
  const dirMode = params.dirMode ?? PRIVATE_SECRET_DIR_MODE;
@@ -304,11 +314,17 @@ async function materializeSecretFileAtomic(params, createOnly) {
304
314
  await enforcePrivateFileIdentityAndMode(finalFilePath, identity, mode);
305
315
  }
306
316
  export async function writeSecretFileAtomic(params) {
307
- await materializeSecretFileAtomic(params, false);
317
+ const canonicalPath = await secretFileWriteQueueKey(params.filePath);
318
+ await serializePathWrite(canonicalPath, async () => {
319
+ await materializeSecretFileAtomic(params, false);
320
+ });
308
321
  }
309
322
  export async function createSecretFileAtomic(params) {
310
323
  try {
311
- await materializeSecretFileAtomic(params, true);
324
+ const canonicalPath = await secretFileWriteQueueKey(params.filePath);
325
+ await serializePathWrite(canonicalPath, async () => {
326
+ await materializeSecretFileAtomic(params, true);
327
+ });
312
328
  }
313
329
  catch (error) {
314
330
  if ((error instanceof FsSafeError && error.code === "already-exists") ||
@@ -1 +1 @@
1
- {"version":3,"file":"secure-temp-dir.d.ts","sourceRoot":"","sources":["../src/secure-temp-dir.ts"],"names":[],"mappings":"AAMA,KAAK,aAAa,GAAG;IACnB,WAAW,IAAI,OAAO,CAAC;IACvB,cAAc,IAAI,OAAO,CAAC;IAC1B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,MAAM,MAAM,4BAA4B,GAAG;IACzC,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;IACnD,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACjD,cAAc,EAAE,MAAM,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,MAAM,GAAG,SAAS,CAAC;IAClC,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,aAAa,CAAC;IAC5C,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE;QAAE,SAAS,EAAE,OAAO,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IAChF,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC;IAC3B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,sBAAsB,CAAC,EAAE,OAAO,CAAC;IACjC,MAAM,CAAC,EAAE,MAAM,MAAM,CAAC;IACtB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,CAAC;AAWF,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,4BAA4B,GAAG,MAAM,CA0JnF"}
1
+ {"version":3,"file":"secure-temp-dir.d.ts","sourceRoot":"","sources":["../src/secure-temp-dir.ts"],"names":[],"mappings":"AAOA,KAAK,aAAa,GAAG;IACnB,WAAW,IAAI,OAAO,CAAC;IACvB,cAAc,IAAI,OAAO,CAAC;IAC1B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,MAAM,MAAM,4BAA4B,GAAG;IACzC,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;IACnD,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACjD,cAAc,EAAE,MAAM,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,MAAM,GAAG,SAAS,CAAC;IAClC,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,aAAa,CAAC;IAC5C,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE;QAAE,SAAS,EAAE,OAAO,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IAChF,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC;IAC3B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,sBAAsB,CAAC,EAAE,OAAO,CAAC;IACjC,MAAM,CAAC,EAAE,MAAM,MAAM,CAAC;IACtB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,CAAC;AAWF,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,4BAA4B,GAAG,MAAM,CA8JnF"}
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import { tmpdir as getOsTmpDir } from "node:os";
3
3
  import path from "node:path";
4
+ import { assertSafePathSegment } from "./safe-path-segment.js";
4
5
  function isNodeErrorWithCode(err, code) {
5
6
  return (typeof err === "object" &&
6
7
  err !== null &&
@@ -8,6 +9,10 @@ function isNodeErrorWithCode(err, code) {
8
9
  err.code === code);
9
10
  }
10
11
  export function resolveSecureTempRoot(options) {
12
+ const fallbackPrefix = assertSafePathSegment(options.fallbackPrefix, {
13
+ allowDotPrefix: true,
14
+ label: "fallback temp prefix",
15
+ });
11
16
  const TMP_DIR_ACCESS_MODE = fs.constants.W_OK | fs.constants.X_OK;
12
17
  const accessSync = options.accessSync ?? fs.accessSync;
13
18
  const chmodSync = options.chmodSync ?? fs.chmodSync;
@@ -42,7 +47,7 @@ export function resolveSecureTempRoot(options) {
42
47
  };
43
48
  const fallback = () => {
44
49
  const base = tmpdir();
45
- const suffix = uid === undefined ? options.fallbackPrefix : `${options.fallbackPrefix}-${uid}`;
50
+ const suffix = uid === undefined ? fallbackPrefix : `${fallbackPrefix}-${uid}`;
46
51
  const joiner = platform === "win32" ? path.win32.join : path.join;
47
52
  return joiner(base, suffix);
48
53
  };
@@ -0,0 +1,2 @@
1
+ export declare function shortPath(value: string): string;
2
+ //# sourceMappingURL=short-path.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"short-path.d.ts","sourceRoot":"","sources":["../src/short-path.ts"],"names":[],"mappings":"AAGA,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAI/C"}
@@ -0,0 +1,7 @@
1
+ import os from "node:os";
2
+ import { formatErrorDetail } from "./error-detail.js";
3
+ export function shortPath(value) {
4
+ const home = os.homedir();
5
+ const shortened = value.startsWith(home) ? `~${value.slice(home.length)}` : value;
6
+ return formatErrorDetail(shortened);
7
+ }
@@ -0,0 +1,30 @@
1
+ import { type NativeFileHandle } from "./native-operations.js";
2
+ import type { Root } from "./root-impl.js";
3
+ import { type SidecarLockSnapshot } from "./sidecar-lock-reclaim.js";
4
+ import type { SidecarLockAcquireOptions, SidecarLockHandle } from "./sidecar-lock-types.js";
5
+ type SidecarFileHandle = Pick<NativeFileHandle, "close" | "stat" | "writeFile">;
6
+ export type HeldSidecarLock = {
7
+ refCount: number;
8
+ reentrantOwner?: string;
9
+ handle: SidecarFileHandle;
10
+ lockPath: string;
11
+ snapshot: SidecarLockSnapshot;
12
+ acquiredAt: number;
13
+ metadata: Record<string, unknown>;
14
+ releasePromise?: Promise<void>;
15
+ lockRoot?: Root;
16
+ parsePayload?: (raw: string) => unknown;
17
+ compromiseTimer?: NodeJS.Timeout;
18
+ };
19
+ type SidecarLockAcquisitionContext = {
20
+ held: Map<string, HeldSidecarLock>;
21
+ reclaimGuards: Set<string>;
22
+ ensureExitCleanupRegistered(): void;
23
+ handleForHeldLock(normalizedTargetPath: string, held: HeldSidecarLock): SidecarLockHandle;
24
+ releaseHeldLock(normalizedTargetPath: string, held: HeldSidecarLock, options?: {
25
+ force?: boolean;
26
+ }): Promise<boolean>;
27
+ };
28
+ export declare function acquireSidecarLock<TPayload extends Record<string, unknown>>(options: SidecarLockAcquireOptions<TPayload>, context: SidecarLockAcquisitionContext): Promise<SidecarLockHandle>;
29
+ export {};
30
+ //# sourceMappingURL=sidecar-lock-acquire.d.ts.map
@@ -0,0 +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,CAmP5B"}
@@ -0,0 +1,260 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { FsSafeError } from "./errors.js";
4
+ import { createNativeExclusiveFile } from "./native-operations.js";
5
+ import { computeSidecarLockDelayMs, defaultSidecarLockShouldReclaim, isTransientLockFileDenial, maxTransientLockDenials, } from "./sidecar-lock-policy.js";
6
+ import { readSidecarLockSnapshot, relativeSidecarLockPath, releaseSidecarReclaimGuard, removeSidecarLockIfUnchanged, removeStaleSidecarLockIfAllowed, serializeSidecarLockPayload, sidecarLockSnapshotStillPresent, sidecarReclaimGuardExists, tryAcquireSidecarReclaimGuard, } from "./sidecar-lock-reclaim.js";
7
+ async function resolveNormalizedTargetPath(targetPath) {
8
+ const resolved = path.resolve(targetPath);
9
+ const dir = path.dirname(resolved);
10
+ await fs.mkdir(dir, { recursive: true });
11
+ try {
12
+ return path.join(await fs.realpath(dir), path.basename(resolved));
13
+ }
14
+ catch {
15
+ return resolved;
16
+ }
17
+ }
18
+ export async function acquireSidecarLock(options, context) {
19
+ context.ensureExitCleanupRegistered();
20
+ const normalizedTargetPath = await resolveNormalizedTargetPath(options.targetPath);
21
+ const lockPath = options.lockPath ?? `${normalizedTargetPath}.lock`;
22
+ const held = context.held.get(normalizedTargetPath);
23
+ if (held &&
24
+ options.reentrantOwner !== undefined &&
25
+ held.reentrantOwner !== undefined &&
26
+ options.reentrantOwner === held.reentrantOwner) {
27
+ held.refCount += 1;
28
+ return context.handleForHeldLock(normalizedTargetPath, held);
29
+ }
30
+ const startedAt = Date.now();
31
+ const retry = options.retry ?? {};
32
+ const maxRetries = options.timeoutMs === Number.POSITIVE_INFINITY ? undefined : retry.retries;
33
+ const reclaimGuardPath = `${lockPath}.reclaim`;
34
+ let ownsReclaimGuard = false;
35
+ let attempt = 0;
36
+ // Bounded so a genuine denial still surfaces as EPERM, not a lock timeout.
37
+ let transientDenials = 0;
38
+ const withinDenialBudget = () => ++transientDenials <= maxTransientLockDenials;
39
+ const waitForRetry = async () => {
40
+ const elapsed = Date.now() - startedAt;
41
+ if ((options.timeoutMs !== undefined &&
42
+ options.timeoutMs !== Number.POSITIVE_INFINITY &&
43
+ elapsed >= options.timeoutMs) ||
44
+ (maxRetries !== undefined && attempt >= maxRetries)) {
45
+ throw Object.assign(new Error(`file lock timeout for ${normalizedTargetPath}`), {
46
+ code: "file_lock_timeout",
47
+ lockPath,
48
+ normalizedTargetPath,
49
+ });
50
+ }
51
+ const remaining = options.timeoutMs === undefined || options.timeoutMs === Number.POSITIVE_INFINITY
52
+ ? Number.POSITIVE_INFINITY
53
+ : Math.max(0, options.timeoutMs - elapsed);
54
+ const delay = Math.min(computeSidecarLockDelayMs(retry, attempt), remaining);
55
+ attempt += 1;
56
+ await new Promise((resolve) => setTimeout(resolve, delay));
57
+ };
58
+ // Waiting can fail on the caller's own retry or deadline limits. Classifying
59
+ // a denial as contention must not cost them the original diagnosis, so hand
60
+ // the denial back when no further attempt can be scheduled.
61
+ const retryOrRethrowDenial = async (denial) => {
62
+ try {
63
+ await waitForRetry();
64
+ }
65
+ catch (waitError) {
66
+ if (waitError.code === "file_lock_timeout")
67
+ throw denial;
68
+ throw waitError;
69
+ }
70
+ };
71
+ try {
72
+ while (true) {
73
+ if (!ownsReclaimGuard && (await sidecarReclaimGuardExists(reclaimGuardPath))) {
74
+ await waitForRetry();
75
+ continue;
76
+ }
77
+ let handle = null;
78
+ let createdSnapshot = null;
79
+ let lockFileCreateDenied = false;
80
+ try {
81
+ const payload = await options.payload();
82
+ const { raw, ownershipToken } = serializeSidecarLockPayload(payload);
83
+ if (options.lockRoot) {
84
+ const relativeLockPath = relativeSidecarLockPath(options.lockRoot, lockPath);
85
+ try {
86
+ await options.lockRoot.create(relativeLockPath, raw, { mkdir: true, mode: 0o600 });
87
+ }
88
+ catch (error) {
89
+ if (error instanceof FsSafeError && error.code === "already-exists") {
90
+ throw Object.assign(new Error("sidecar lock exists"), { code: "EEXIST" });
91
+ }
92
+ throw error;
93
+ }
94
+ createdSnapshot = { raw, payload, ownershipToken };
95
+ handle = (await options.lockRoot.open(relativeLockPath)).handle;
96
+ }
97
+ else {
98
+ try {
99
+ handle =
100
+ (await createNativeExclusiveFile(lockPath, 0o600)) ??
101
+ (await fs.open(lockPath, "wx"));
102
+ }
103
+ catch (createError) {
104
+ lockFileCreateDenied = isTransientLockFileDenial(createError, lockPath);
105
+ throw createError;
106
+ }
107
+ await handle.writeFile(raw, "utf8");
108
+ }
109
+ const snapshot = { raw, payload, stat: await handle.stat(), ownershipToken };
110
+ const createdHeld = {
111
+ refCount: 1,
112
+ reentrantOwner: options.reentrantOwner,
113
+ handle,
114
+ lockPath,
115
+ snapshot,
116
+ acquiredAt: Date.now(),
117
+ metadata: options.metadata ?? {},
118
+ lockRoot: options.lockRoot,
119
+ parsePayload: options.parsePayload,
120
+ };
121
+ context.held.set(normalizedTargetPath, createdHeld);
122
+ if (ownsReclaimGuard) {
123
+ try {
124
+ await releaseSidecarReclaimGuard(context.reclaimGuards, reclaimGuardPath);
125
+ ownsReclaimGuard = false;
126
+ }
127
+ catch (err) {
128
+ await context.releaseHeldLock(normalizedTargetPath, createdHeld, { force: true });
129
+ throw err;
130
+ }
131
+ }
132
+ const returnedHandle = context.handleForHeldLock(normalizedTargetPath, createdHeld);
133
+ const interval = options.compromiseCheckIntervalMs;
134
+ if (options.onCompromised && interval !== undefined && interval > 0) {
135
+ createdHeld.compromiseTimer = setInterval(() => {
136
+ void returnedHandle.verifyStillHeld().then((stillHeld) => {
137
+ if (!stillHeld && createdHeld.compromiseTimer) {
138
+ clearInterval(createdHeld.compromiseTimer);
139
+ createdHeld.compromiseTimer = undefined;
140
+ options.onCompromised?.({ lockPath, normalizedTargetPath });
141
+ }
142
+ });
143
+ }, interval);
144
+ createdHeld.compromiseTimer.unref();
145
+ }
146
+ return returnedHandle;
147
+ }
148
+ catch (err) {
149
+ if (handle) {
150
+ const failedSnapshot = { payload: null };
151
+ try {
152
+ failedSnapshot.stat = await handle.stat();
153
+ }
154
+ catch {
155
+ // Best-effort cleanup of a failed exclusive create.
156
+ }
157
+ const current = context.held.get(normalizedTargetPath);
158
+ if (current?.handle === handle) {
159
+ context.held.delete(normalizedTargetPath);
160
+ }
161
+ await handle.close().catch(() => undefined);
162
+ // The file may be empty or partial JSON, so remove by the identity
163
+ // captured from our exclusive handle rather than by pathname alone.
164
+ await removeSidecarLockIfUnchanged(lockPath, failedSnapshot, {
165
+ lockRoot: options.lockRoot,
166
+ parsePayload: options.parsePayload,
167
+ });
168
+ }
169
+ else if (createdSnapshot) {
170
+ await removeSidecarLockIfUnchanged(lockPath, createdSnapshot, {
171
+ lockRoot: options.lockRoot,
172
+ parsePayload: options.parsePayload,
173
+ });
174
+ }
175
+ if (lockFileCreateDenied && withinDenialBudget()) {
176
+ await retryOrRethrowDenial(err);
177
+ continue;
178
+ }
179
+ if (err.code !== "EEXIST") {
180
+ throw err;
181
+ }
182
+ if (ownsReclaimGuard) {
183
+ await releaseSidecarReclaimGuard(context.reclaimGuards, reclaimGuardPath);
184
+ ownsReclaimGuard = false;
185
+ continue;
186
+ }
187
+ const nowMs = Date.now();
188
+ let snapshot;
189
+ try {
190
+ snapshot = await readSidecarLockSnapshot(lockPath, {
191
+ lockRoot: options.lockRoot,
192
+ parsePayload: options.parsePayload,
193
+ rejectNonFile: true,
194
+ });
195
+ }
196
+ catch (readErr) {
197
+ if (!isTransientLockFileDenial(readErr, lockPath) || !withinDenialBudget())
198
+ throw readErr;
199
+ await retryOrRethrowDenial(readErr);
200
+ continue;
201
+ }
202
+ if (!snapshot) {
203
+ continue;
204
+ }
205
+ if (context.held.has(normalizedTargetPath)) {
206
+ await waitForRetry();
207
+ continue;
208
+ }
209
+ const shouldReclaim = options.shouldReclaim ?? defaultSidecarLockShouldReclaim;
210
+ if (await shouldReclaim({
211
+ lockPath,
212
+ normalizedTargetPath,
213
+ payload: snapshot?.payload ?? null,
214
+ staleMs: options.staleMs,
215
+ nowMs,
216
+ heldByThisProcess: context.held.has(normalizedTargetPath),
217
+ })) {
218
+ if (!(await sidecarLockSnapshotStillPresent(lockPath, snapshot, {
219
+ lockRoot: options.lockRoot,
220
+ parsePayload: options.parsePayload,
221
+ }))) {
222
+ continue;
223
+ }
224
+ const staleRecovery = options.staleRecovery ?? "fail-closed";
225
+ if (staleRecovery === "remove-if-unchanged") {
226
+ if (!(await tryAcquireSidecarReclaimGuard(context.reclaimGuards, reclaimGuardPath))) {
227
+ await waitForRetry();
228
+ continue;
229
+ }
230
+ ownsReclaimGuard = true;
231
+ const removal = await removeStaleSidecarLockIfAllowed({
232
+ lockPath,
233
+ normalizedTargetPath,
234
+ snapshot,
235
+ shouldRemoveStaleLock: options.shouldRemoveStaleLock,
236
+ lockRoot: options.lockRoot,
237
+ parsePayload: options.parsePayload,
238
+ });
239
+ if (removal === "removed" || removal === "changed") {
240
+ continue;
241
+ }
242
+ await releaseSidecarReclaimGuard(context.reclaimGuards, reclaimGuardPath);
243
+ ownsReclaimGuard = false;
244
+ }
245
+ throw Object.assign(new Error(`file lock stale for ${normalizedTargetPath}`), {
246
+ code: "file_lock_stale",
247
+ lockPath,
248
+ normalizedTargetPath,
249
+ });
250
+ }
251
+ await waitForRetry();
252
+ }
253
+ }
254
+ }
255
+ finally {
256
+ if (ownsReclaimGuard) {
257
+ await releaseSidecarReclaimGuard(context.reclaimGuards, reclaimGuardPath).catch(() => undefined);
258
+ }
259
+ }
260
+ }
@@ -1,6 +1,9 @@
1
1
  import type { SidecarLockRetryOptions } from "./sidecar-lock-types.js";
2
2
  export declare function computeSidecarLockDelayMs(retry: SidecarLockRetryOptions, attempt: number): number;
3
+ export declare const maxTransientLockDenials = 8;
4
+ export declare function isTransientLockFileDenial(error: unknown, lockPath: string): boolean;
3
5
  export declare function sidecarLockPayloadIsStale(payload: unknown, staleMs: number, nowMs: number): boolean;
6
+ export declare function sidecarLockPayloadCreatedAtMs(payload: unknown): number | null;
4
7
  export declare function defaultSidecarLockShouldReclaim(params: {
5
8
  lockPath: string;
6
9
  payload: unknown;
@@ -1 +1 @@
1
- {"version":3,"file":"sidecar-lock-policy.d.ts","sourceRoot":"","sources":["../src/sidecar-lock-policy.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,yBAAyB,CAAC;AAEvE,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,uBAAuB,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAOjG;AAED,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,GACZ,OAAO,CAUT;AAED,wBAAsB,+BAA+B,CAAC,MAAM,EAAE;IAC5D,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;CACf,GAAG,OAAO,CAAC,OAAO,CAAC,CAOnB"}
1
+ {"version":3,"file":"sidecar-lock-policy.d.ts","sourceRoot":"","sources":["../src/sidecar-lock-policy.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,yBAAyB,CAAC;AAEvE,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,uBAAuB,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAOjG;AASD,eAAO,MAAM,uBAAuB,IAAI,CAAC;AAEzC,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAGnF;AAED,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,GACZ,OAAO,CAGT;AAED,wBAAgB,6BAA6B,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAU7E;AAED,wBAAsB,+BAA+B,CAAC,MAAM,EAAE;IAC5D,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;CACf,GAAG,OAAO,CAAC,OAAO,CAAC,CAQnB"}
@@ -7,7 +7,23 @@ export function computeSidecarLockDelayMs(retry, attempt) {
7
7
  const jitter = retry.randomize ? 1 + Math.random() : 1;
8
8
  return Math.min(maxTimeout, Math.round(base * jitter));
9
9
  }
10
+ // Windows denies access to a lock file while a just-unlinked directory entry
11
+ // is still being torn down, so a contended acquire sees EPERM on a name that is
12
+ // already gone -- both when creating it exclusively and when reading the
13
+ // holder's snapshot. The next attempt succeeds, so this is contention rather
14
+ // than a permission failure. The error must name the lock file itself: the
15
+ // exclusive-create helper opens the parent directory first, and a denial from
16
+ // that setup step carries no teardown evidence and has to reach the caller.
17
+ export const maxTransientLockDenials = 8;
18
+ export function isTransientLockFileDenial(error, lockPath) {
19
+ const denial = error;
20
+ return process.platform === "win32" && denial?.code === "EPERM" && denial.path === lockPath;
21
+ }
10
22
  export function sidecarLockPayloadIsStale(payload, staleMs, nowMs) {
23
+ const createdAtMs = sidecarLockPayloadCreatedAtMs(payload);
24
+ return createdAtMs !== null && nowMs - createdAtMs > staleMs;
25
+ }
26
+ export function sidecarLockPayloadCreatedAtMs(payload) {
11
27
  const createdAt = payload &&
12
28
  typeof payload === "object" &&
13
29
  "createdAt" in payload &&
@@ -15,11 +31,12 @@ export function sidecarLockPayloadIsStale(payload, staleMs, nowMs) {
15
31
  ? payload.createdAt
16
32
  : "";
17
33
  const createdAtMs = Date.parse(createdAt);
18
- return Number.isFinite(createdAtMs) && nowMs - createdAtMs > staleMs;
34
+ return Number.isFinite(createdAtMs) ? createdAtMs : null;
19
35
  }
20
36
  export async function defaultSidecarLockShouldReclaim(params) {
21
- if (sidecarLockPayloadIsStale(params.payload, params.staleMs, params.nowMs))
22
- return true;
37
+ const createdAtMs = sidecarLockPayloadCreatedAtMs(params.payload);
38
+ if (createdAtMs !== null)
39
+ return params.nowMs - createdAtMs > params.staleMs;
23
40
  try {
24
41
  return params.nowMs - (await fs.stat(params.lockPath)).mtimeMs > params.staleMs;
25
42
  }
@@ -22,8 +22,12 @@ export declare function parseSidecarLockPayload(raw: string, parser?: (raw: stri
22
22
  export declare function readSidecarLockSnapshot(lockPath: string, options?: {
23
23
  lockRoot?: Root;
24
24
  parsePayload?: (raw: string) => unknown;
25
+ rejectNonFile?: boolean;
26
+ allowDescriptorIdentityDrift?: boolean;
25
27
  }): Promise<SidecarLockSnapshot | null>;
26
- export declare function readSidecarLockSnapshotSync(lockPath: string, parsePayload?: (raw: string) => unknown): SidecarLockSnapshot | null;
28
+ export declare function readSidecarLockSnapshotSync(lockPath: string, parsePayload?: (raw: string) => unknown, options?: {
29
+ rejectNonFile?: boolean;
30
+ }): SidecarLockSnapshot | null;
27
31
  export declare function removeSidecarLockIfUnchangedSync(lockPath: string, observed: SidecarLockSnapshot): boolean;
28
32
  export declare function sidecarLockSnapshotMatches(current: SidecarLockSnapshot, observed: SidecarLockSnapshot): boolean;
29
33
  export declare function removeSidecarLockIfUnchanged(lockPath: string, observed: SidecarLockSnapshot | null, options?: {
@@ -1 +1 @@
1
- {"version":3,"file":"sidecar-lock-reclaim.d.ts","sourceRoot":"","sources":["../src/sidecar-lock-reclaim.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"sidecar-lock-reclaim.d.ts","sourceRoot":"","sources":["../src/sidecar-lock-reclaim.ts"],"names":[],"mappings":"AACA,OAAe,EAAE,KAAK,KAAK,EAAE,MAAM,SAAS,CAAC;AAM7C,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,gBAAgB,CAAC;AAY3C,MAAM,MAAM,wBAAwB,GAAG;IACrC,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,OAAO,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,CAAC,EAAE,KAAK,CAAC;IACb,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AAYF,wBAAgB,6BAA6B,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAE7E;AAED,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG;IAC7E,GAAG,EAAE,MAAM,CAAC;IACZ,cAAc,EAAE,MAAM,CAAC;CACxB,CAMA;AAED,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAahF;AAED,wBAAgB,uBAAuB,CACrC,GAAG,EAAE,MAAM,EACX,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,GAChC,OAAO,CAUT;AAED,wBAAsB,uBAAuB,CAC3C,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE;IACP,QAAQ,CAAC,EAAE,IAAI,CAAC;IAChB,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;IACxC,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,4BAA4B,CAAC,EAAE,OAAO,CAAC;CACnC,GACL,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC,CAkErC;AAED,wBAAgB,2BAA2B,CACzC,QAAQ,EAAE,MAAM,EAChB,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,EACvC,OAAO,GAAE;IAAE,aAAa,CAAC,EAAE,OAAO,CAAA;CAAO,GACxC,mBAAmB,GAAG,IAAI,CA+B5B;AAED,wBAAgB,gCAAgC,CAC9C,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,mBAAmB,GAC5B,OAAO,CAKT;AAED,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,mBAAmB,EAC5B,QAAQ,EAAE,mBAAmB,GAC5B,OAAO,CAkBT;AAED,wBAAsB,4BAA4B,CAChD,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,mBAAmB,GAAG,IAAI,EACpC,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,IAAI,CAAC;IAAC,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAA;CAAO,GACzE,OAAO,CAAC,OAAO,CAAC,CAclB;AAED,wBAAsB,+BAA+B,CACnD,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,mBAAmB,GAAG,IAAI,EACpC,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,IAAI,CAAC;IAAC,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAA;CAAO,GACzE,OAAO,CAAC,OAAO,CAAC,CAMlB;AAED,wBAAsB,yBAAyB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAUlF;AAED,wBAAsB,6BAA6B,CACjD,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,EAC1B,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAAC,OAAO,CAAC,CAWlB;AAED,wBAAsB,0BAA0B,CAC9C,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,EAC1B,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAAC,IAAI,CAAC,CAGf;AAED,wBAAsB,+BAA+B,CAAC,MAAM,EAAE;IAC5D,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,QAAQ,EAAE,mBAAmB,CAAC;IAC9B,qBAAqB,CAAC,EAAE,CAAC,QAAQ,EAAE,wBAAwB,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC3F,QAAQ,CAAC,EAAE,IAAI,CAAC;IAChB,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;CACzC,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,GAAG,cAAc,CAAC,CAkClD"}
@@ -2,9 +2,10 @@ import { randomBytes } from "node:crypto";
2
2
  import fsSync, {} from "node:fs";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
- import { readFileHandleBounded } from "./bounded-read.js";
5
+ import { readFileDescriptorBoundedSync, readFileHandleBounded } from "./bounded-read.js";
6
6
  import { FsSafeError } from "./errors.js";
7
7
  import { sameFileIdentity } from "./file-identity.js";
8
+ import { getFsSafeTestHooks } from "./test-hooks.js";
8
9
  const MAX_LOCK_PAYLOAD_BYTES = 1024 * 1024;
9
10
  const SIDECAR_LOCK_OWNERSHIP_TOKEN_BYTES = 16;
10
11
  const SIDECAR_LOCK_OWNERSHIP_TOKEN_BITS = SIDECAR_LOCK_OWNERSHIP_TOKEN_BYTES * 8;
@@ -55,6 +56,7 @@ export function parseSidecarLockPayload(raw, parser) {
55
56
  }
56
57
  }
57
58
  export async function readSidecarLockSnapshot(lockPath, options = {}) {
59
+ let handle;
58
60
  try {
59
61
  if (options.lockRoot) {
60
62
  const opened = await options.lockRoot.open(relativeSidecarLockPath(options.lockRoot, lockPath));
@@ -70,9 +72,44 @@ export async function readSidecarLockSnapshot(lockPath, options = {}) {
70
72
  await opened.handle.close().catch(() => undefined);
71
73
  }
72
74
  }
73
- const stat = await fs.lstat(lockPath);
74
- const raw = await fs.readFile(lockPath, "utf8");
75
- return { raw, payload: parseSidecarLockPayload(raw, options.parsePayload), stat };
75
+ const before = await fs.lstat(lockPath);
76
+ if (!before.isFile() || before.isSymbolicLink()) {
77
+ if (options.rejectNonFile) {
78
+ throw new FsSafeError("not-file", `sidecar lock is not a regular file: ${lockPath}`);
79
+ }
80
+ return null;
81
+ }
82
+ await getFsSafeTestHooks()?.beforeSidecarLockSnapshotOpen?.(lockPath);
83
+ const noFollow = process.platform !== "win32" && typeof fsSync.constants.O_NOFOLLOW === "number"
84
+ ? fsSync.constants.O_NOFOLLOW
85
+ : 0;
86
+ try {
87
+ handle = await fs.open(lockPath, fsSync.constants.O_RDONLY |
88
+ noFollow |
89
+ (typeof fsSync.constants.O_NONBLOCK === "number" ? fsSync.constants.O_NONBLOCK : 0));
90
+ }
91
+ catch (error) {
92
+ if (options.rejectNonFile && error.code === "ELOOP") {
93
+ throw new FsSafeError("not-file", `sidecar lock is not a regular file: ${lockPath}`, {
94
+ cause: error,
95
+ });
96
+ }
97
+ throw error;
98
+ }
99
+ const opened = await handle.stat();
100
+ if (!opened.isFile()) {
101
+ if (options.rejectNonFile) {
102
+ throw new FsSafeError("not-file", `sidecar lock is not a regular file: ${lockPath}`);
103
+ }
104
+ return null;
105
+ }
106
+ if (!options.allowDescriptorIdentityDrift && !sameFileIdentity(before, opened))
107
+ return null;
108
+ const raw = (await readFileHandleBounded(handle, MAX_LOCK_PAYLOAD_BYTES)).toString("utf8");
109
+ const after = await fs.lstat(lockPath);
110
+ if (!after.isFile() || !sameFileIdentity(before, after))
111
+ return null;
112
+ return { raw, payload: parseSidecarLockPayload(raw, options.parsePayload), stat: after };
76
113
  }
77
114
  catch (err) {
78
115
  if (err.code === "ENOENT" ||
@@ -81,19 +118,26 @@ export async function readSidecarLockSnapshot(lockPath, options = {}) {
81
118
  }
82
119
  throw err;
83
120
  }
121
+ finally {
122
+ await handle?.close().catch(() => undefined);
123
+ }
84
124
  }
85
- export function readSidecarLockSnapshotSync(lockPath, parsePayload) {
125
+ export function readSidecarLockSnapshotSync(lockPath, parsePayload, options = {}) {
86
126
  let fd;
87
127
  try {
88
128
  const before = fsSync.lstatSync(lockPath);
89
- if (!before.isFile() || before.isSymbolicLink())
129
+ if (!before.isFile() || before.isSymbolicLink()) {
130
+ if (options.rejectNonFile) {
131
+ throw new FsSafeError("not-file", `sidecar lock is not a regular file: ${lockPath}`);
132
+ }
90
133
  return null;
134
+ }
91
135
  const noFollow = process.platform !== "win32" && typeof fsSync.constants.O_NOFOLLOW === "number"
92
136
  ? fsSync.constants.O_NOFOLLOW
93
137
  : 0;
94
138
  fd = fsSync.openSync(lockPath, fsSync.constants.O_RDONLY | noFollow);
95
139
  const opened = fsSync.fstatSync(fd);
96
- const raw = fsSync.readFileSync(fd, "utf8");
140
+ const raw = readFileDescriptorBoundedSync(fd, MAX_LOCK_PAYLOAD_BYTES).toString("utf8");
97
141
  const after = fsSync.lstatSync(lockPath);
98
142
  if (!sameFileIdentity(before, opened) || !sameFileIdentity(opened, after))
99
143
  return null;
@@ -139,7 +183,10 @@ export function sidecarLockSnapshotMatches(current, observed) {
139
183
  return observed.stat !== undefined && current.stat !== undefined;
140
184
  }
141
185
  export async function removeSidecarLockIfUnchanged(lockPath, observed, options = {}) {
142
- const current = await readSidecarLockSnapshot(lockPath, options);
186
+ const current = await readSidecarLockSnapshot(lockPath, {
187
+ ...options,
188
+ allowDescriptorIdentityDrift: observed?.ownershipToken !== undefined,
189
+ });
143
190
  if (!current || !observed || !sidecarLockSnapshotMatches(current, observed)) {
144
191
  return false;
145
192
  }
@@ -152,7 +199,10 @@ export async function removeSidecarLockIfUnchanged(lockPath, observed, options =
152
199
  return true;
153
200
  }
154
201
  export async function sidecarLockSnapshotStillPresent(lockPath, observed, options = {}) {
155
- const current = await readSidecarLockSnapshot(lockPath, options);
202
+ const current = await readSidecarLockSnapshot(lockPath, {
203
+ ...options,
204
+ allowDescriptorIdentityDrift: observed?.ownershipToken !== undefined,
205
+ });
156
206
  return !!current && !!observed && sidecarLockSnapshotMatches(current, observed);
157
207
  }
158
208
  export async function sidecarReclaimGuardExists(pathname) {
@@ -1 +1 @@
1
- {"version":3,"file":"sidecar-lock.d.ts","sourceRoot":"","sources":["../src/sidecar-lock.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EACV,yBAAyB,EACzB,iBAAiB,EACjB,oBAAoB,EAEpB,sBAAsB,EACvB,MAAM,yBAAyB,CAAC;AAKjC,YAAY,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AAC1E,YAAY,EACV,yBAAyB,EACzB,0BAA0B,EAC1B,iBAAiB,EACjB,oBAAoB,EACpB,uBAAuB,EACvB,wBAAwB,EACxB,sBAAsB,GACvB,MAAM,yBAAyB,CAAC;AAsLjC,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM;cAgB3B,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,WACpD,yBAAyB,CAAC,QAAQ,CAAC,KAC3C,OAAO,CAAC,iBAAiB,CAAC;eAgNL,CAAC,EAAE,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,WACxD,yBAAyB,CAAC,QAAQ,CAAC,MACxC,MAAM,OAAO,CAAC,CAAC,CAAC,KACnB,OAAO,CAAC,CAAC,CAAC;iBASW,OAAO,CAAC,IAAI,CAAC;iBAQnB,IAAI;uBAIE,oBAAoB,EAAE;EAW/C;AAED,wBAAsB,eAAe,CAAC,CAAC,EAAE,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/E,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,sBAAsB,CAAC,QAAQ,CAAC,EACzC,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GACnB,OAAO,CAAC,CAAC,CAAC,CAMZ"}
1
+ {"version":3,"file":"sidecar-lock.d.ts","sourceRoot":"","sources":["../src/sidecar-lock.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EACV,yBAAyB,EACzB,iBAAiB,EACjB,oBAAoB,EACpB,sBAAsB,EACvB,MAAM,yBAAyB,CAAC;AACjC,YAAY,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AAC1E,YAAY,EACV,yBAAyB,EACzB,0BAA0B,EAC1B,iBAAiB,EACjB,oBAAoB,EACpB,uBAAuB,EACvB,wBAAwB,EACxB,sBAAsB,GACvB,MAAM,yBAAyB,CAAC;AAmLjC,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM;cAS3B,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,WACpD,yBAAyB,CAAC,QAAQ,CAAC,KAC3C,OAAO,CAAC,iBAAiB,CAAC;eAYL,CAAC,EAAE,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,WACxD,yBAAyB,CAAC,QAAQ,CAAC,MACxC,MAAM,OAAO,CAAC,CAAC,CAAC,KACnB,OAAO,CAAC,CAAC,CAAC;iBASW,OAAO,CAAC,IAAI,CAAC;iBAQnB,IAAI;uBAIE,oBAAoB,EAAE;EAW/C;AAED,wBAAsB,eAAe,CAAC,CAAC,EAAE,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/E,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,sBAAsB,CAAC,QAAQ,CAAC,EACzC,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GACnB,OAAO,CAAC,CAAC,CAAC,CAMZ"}