@openclaw/fs-safe 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +39 -2
  3. package/dist/absolute-path.d.ts +14 -0
  4. package/dist/absolute-path.d.ts.map +1 -1
  5. package/dist/absolute-path.js +203 -2
  6. package/dist/advanced.d.ts +2 -1
  7. package/dist/advanced.d.ts.map +1 -1
  8. package/dist/advanced.js +2 -1
  9. package/dist/archive-staging.d.ts.map +1 -1
  10. package/dist/archive-staging.js +81 -7
  11. package/dist/archive.d.ts.map +1 -1
  12. package/dist/archive.js +25 -42
  13. package/dist/bounded-read-stream.d.ts +8 -0
  14. package/dist/bounded-read-stream.d.ts.map +1 -0
  15. package/dist/bounded-read-stream.js +20 -0
  16. package/dist/config.d.ts +1 -0
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/config.js +1 -0
  19. package/dist/directory-guard.d.ts +18 -0
  20. package/dist/directory-guard.d.ts.map +1 -0
  21. package/dist/directory-guard.js +70 -0
  22. package/dist/file-lock.d.ts +4 -2
  23. package/dist/file-lock.d.ts.map +1 -1
  24. package/dist/file-lock.js +13 -2
  25. package/dist/file-store-boundary.d.ts +2 -4
  26. package/dist/file-store-boundary.d.ts.map +1 -1
  27. package/dist/file-store-boundary.js +11 -7
  28. package/dist/file-store-prune.d.ts +12 -0
  29. package/dist/file-store-prune.d.ts.map +1 -0
  30. package/dist/file-store-prune.js +86 -0
  31. package/dist/file-store-source.d.ts +5 -0
  32. package/dist/file-store-source.d.ts.map +1 -0
  33. package/dist/file-store-source.js +30 -0
  34. package/dist/file-store.d.ts +2 -6
  35. package/dist/file-store.d.ts.map +1 -1
  36. package/dist/file-store.js +60 -53
  37. package/dist/guarded-mkdir.d.ts +6 -0
  38. package/dist/guarded-mkdir.d.ts.map +1 -0
  39. package/dist/guarded-mkdir.js +45 -0
  40. package/dist/guarded-mutation.d.ts +33 -0
  41. package/dist/guarded-mutation.d.ts.map +1 -0
  42. package/dist/guarded-mutation.js +76 -0
  43. package/dist/home-dir.d.ts.map +1 -1
  44. package/dist/home-dir.js +38 -33
  45. package/dist/index.d.ts +2 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +2 -0
  48. package/dist/json-document-store.d.ts +2 -0
  49. package/dist/json-document-store.d.ts.map +1 -1
  50. package/dist/json-document-store.js +7 -3
  51. package/dist/json-durable-queue.d.ts +45 -0
  52. package/dist/json-durable-queue.d.ts.map +1 -0
  53. package/dist/json-durable-queue.js +200 -0
  54. package/dist/json.d.ts +35 -5
  55. package/dist/json.d.ts.map +1 -1
  56. package/dist/json.js +69 -2
  57. package/dist/local-roots.d.ts.map +1 -1
  58. package/dist/local-roots.js +17 -3
  59. package/dist/lock-config.d.ts +10 -0
  60. package/dist/lock-config.d.ts.map +1 -0
  61. package/dist/lock-config.js +12 -0
  62. package/dist/move-path.d.ts +1 -0
  63. package/dist/move-path.d.ts.map +1 -1
  64. package/dist/move-path.js +195 -7
  65. package/dist/output.d.ts +13 -0
  66. package/dist/output.d.ts.map +1 -0
  67. package/dist/output.js +69 -0
  68. package/dist/path-stat.d.ts +4 -0
  69. package/dist/path-stat.d.ts.map +1 -0
  70. package/dist/path-stat.js +15 -0
  71. package/dist/path.d.ts.map +1 -1
  72. package/dist/path.js +4 -3
  73. package/dist/pinned-write.d.ts.map +1 -1
  74. package/dist/pinned-write.js +44 -19
  75. package/dist/private-temp-workspace.d.ts.map +1 -1
  76. package/dist/private-temp-workspace.js +28 -15
  77. package/dist/regular-file.d.ts.map +1 -1
  78. package/dist/regular-file.js +52 -8
  79. package/dist/replace-directory.d.ts.map +1 -1
  80. package/dist/replace-directory.js +5 -4
  81. package/dist/replace-file.d.ts +1 -1
  82. package/dist/replace-file.d.ts.map +1 -1
  83. package/dist/replace-file.js +93 -31
  84. package/dist/root-impl.d.ts.map +1 -1
  85. package/dist/root-impl.js +62 -53
  86. package/dist/safe-path-segment.d.ts +9 -0
  87. package/dist/safe-path-segment.d.ts.map +1 -0
  88. package/dist/safe-path-segment.js +51 -0
  89. package/dist/secret-file.d.ts.map +1 -1
  90. package/dist/secret-file.js +28 -7
  91. package/dist/sibling-temp.d.ts +1 -0
  92. package/dist/sibling-temp.d.ts.map +1 -1
  93. package/dist/sibling-temp.js +32 -7
  94. package/dist/sidecar-lock.d.ts +2 -0
  95. package/dist/sidecar-lock.d.ts.map +1 -1
  96. package/dist/sidecar-lock.js +93 -13
  97. package/dist/store.d.ts +1 -0
  98. package/dist/store.d.ts.map +1 -1
  99. package/dist/store.js +1 -0
  100. package/dist/temp-target.d.ts.map +1 -1
  101. package/dist/temp-target.js +7 -4
  102. package/dist/test-hooks.d.ts +6 -0
  103. package/dist/test-hooks.d.ts.map +1 -1
  104. package/dist/text-atomic.d.ts +7 -0
  105. package/dist/text-atomic.d.ts.map +1 -1
  106. package/dist/text-atomic.js +3 -2
  107. package/dist/trash.d.ts.map +1 -1
  108. package/dist/trash.js +48 -13
  109. package/docs/advanced.md +10 -0
  110. package/docs/atomic.md +33 -5
  111. package/docs/config.md +32 -3
  112. package/docs/index.md +3 -2
  113. package/docs/json-store.md +4 -0
  114. package/docs/json.md +34 -0
  115. package/docs/output.md +92 -0
  116. package/docs/sidecar-lock.md +13 -12
  117. package/docs/store.md +33 -0
  118. package/docs/temp.md +10 -2
  119. package/docs/testing.md +10 -0
  120. package/package.json +7 -2
@@ -5,12 +5,14 @@ export type SidecarLockRetryOptions = {
5
5
  maxTimeout?: number;
6
6
  randomize?: boolean;
7
7
  };
8
+ export type SidecarLockStaleRecovery = "fail-closed";
8
9
  export type SidecarLockAcquireOptions<TPayload extends Record<string, unknown>> = {
9
10
  targetPath: string;
10
11
  lockPath?: string;
11
12
  staleMs: number;
12
13
  timeoutMs?: number;
13
14
  retry?: SidecarLockRetryOptions;
15
+ staleRecovery?: SidecarLockStaleRecovery;
14
16
  allowReentrant?: boolean;
15
17
  payload: () => TPayload | Promise<TPayload>;
16
18
  shouldReclaim?: (params: {
@@ -1 +1 @@
1
- {"version":3,"file":"sidecar-lock.d.ts","sourceRoot":"","sources":["../src/sidecar-lock.ts"],"names":[],"mappings":"AAKA,MAAM,MAAM,uBAAuB,GAAG;IACpC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,yBAAyB,CAAC,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,IAAI;IAChF,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,uBAAuB,CAAC;IAChC,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,OAAO,EAAE,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC5C,aAAa,CAAC,EAAE,CAAC,MAAM,EAAE;QACvB,QAAQ,EAAE,MAAM,CAAC;QACjB,oBAAoB,EAAE,MAAM,CAAC;QAC7B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;QACxC,OAAO,EAAE,MAAM,CAAC;QAChB,KAAK,EAAE,MAAM,CAAC;QACd,iBAAiB,EAAE,OAAO,CAAC;KAC5B,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACjC,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7B,CAAC,MAAM,CAAC,YAAY,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxC,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,oBAAoB,EAAE,MAAM,CAAC;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,YAAY,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;CACtC,CAAC;AAEF,MAAM,MAAM,sBAAsB,CAAC,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,IAAI,IAAI,CACjF,yBAAyB,CAAC,QAAQ,CAAC,EACnC,YAAY,CACb,GAAG;IACF,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAuIF,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM;cAW3B,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,WACpD,yBAAyB,CAAC,QAAQ,CAAC,KAC3C,OAAO,CAAC,iBAAiB,CAAC;eA6FL,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":"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"}
@@ -1,6 +1,7 @@
1
1
  import fsSync from "node:fs";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { sameFileIdentity } from "./file-identity.js";
4
5
  const GLOBAL_STATE_KEY = Symbol.for("fsSafe.sidecarLockManagers");
5
6
  function getGlobalManagers() {
6
7
  const globalWithState = globalThis;
@@ -18,17 +19,63 @@ function resolveManagerState(key) {
18
19
  }
19
20
  return state;
20
21
  }
21
- async function readJsonPayload(lockPath) {
22
+ async function readLockSnapshot(lockPath) {
22
23
  try {
23
- const parsed = JSON.parse(await fs.readFile(lockPath, "utf8"));
24
- return parsed && typeof parsed === "object" && !Array.isArray(parsed)
25
- ? parsed
26
- : null;
24
+ const stat = await fs.lstat(lockPath);
25
+ const raw = await fs.readFile(lockPath, "utf8");
26
+ try {
27
+ const parsed = JSON.parse(raw);
28
+ const payload = parsed && typeof parsed === "object" && !Array.isArray(parsed)
29
+ ? parsed
30
+ : null;
31
+ return { raw, payload, stat };
32
+ }
33
+ catch {
34
+ return { raw, payload: null, stat };
35
+ }
27
36
  }
28
37
  catch {
29
38
  return null;
30
39
  }
31
40
  }
41
+ function snapshotMatches(current, observed) {
42
+ if (observed.stat && current.stat && !sameFileIdentity(observed.stat, current.stat)) {
43
+ return false;
44
+ }
45
+ if (observed.raw !== undefined) {
46
+ return current.raw === observed.raw;
47
+ }
48
+ return observed.stat !== undefined && current.stat !== undefined;
49
+ }
50
+ async function removeLockIfUnchanged(lockPath, observed) {
51
+ const current = await readLockSnapshot(lockPath);
52
+ if (!current || !observed) {
53
+ return false;
54
+ }
55
+ if (!snapshotMatches(current, observed)) {
56
+ // The lock changed after we decided it was stale. Leave the fresh holder's
57
+ // file alone; deleting by path here would break mutual exclusion.
58
+ return false;
59
+ }
60
+ await fs.rm(lockPath, { force: true }).catch(() => undefined);
61
+ return true;
62
+ }
63
+ async function lockSnapshotStillPresent(lockPath, observed) {
64
+ const current = await readLockSnapshot(lockPath);
65
+ return !!current && !!observed && snapshotMatches(current, observed);
66
+ }
67
+ function snapshotMatchesSync(lockPath, observed) {
68
+ try {
69
+ const stat = fsSync.lstatSync(lockPath);
70
+ if (observed.stat && !sameFileIdentity(observed.stat, stat)) {
71
+ return false;
72
+ }
73
+ return observed.raw === undefined || fsSync.readFileSync(lockPath, "utf8") === observed.raw;
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }
32
79
  async function resolveNormalizedTargetPath(targetPath) {
33
80
  const resolved = path.resolve(targetPath);
34
81
  const dir = path.dirname(resolved);
@@ -66,7 +113,9 @@ function releaseAllLocksSync(state) {
66
113
  for (const [normalizedTargetPath, held] of state.held) {
67
114
  void held.handle.close().catch(() => undefined);
68
115
  try {
69
- fsSync.rmSync(held.lockPath, { force: true });
116
+ if (snapshotMatchesSync(held.lockPath, held.snapshot)) {
117
+ fsSync.rmSync(held.lockPath, { force: true });
118
+ }
70
119
  }
71
120
  catch {
72
121
  // Best-effort process-exit cleanup.
@@ -95,7 +144,7 @@ async function releaseHeldLock(state, normalizedTargetPath, held, opts = {}) {
95
144
  state.held.delete(normalizedTargetPath);
96
145
  held.releasePromise = (async () => {
97
146
  await held.handle.close().catch(() => undefined);
98
- await fs.rm(held.lockPath, { force: true }).catch(() => undefined);
147
+ await removeLockIfUnchanged(held.lockPath, held.snapshot);
99
148
  })();
100
149
  try {
101
150
  await held.releasePromise;
@@ -137,15 +186,19 @@ export function createSidecarLockManager(key) {
137
186
  let handle = null;
138
187
  try {
139
188
  handle = await fs.open(lockPath, "wx");
189
+ const payload = await options.payload();
190
+ const raw = `${JSON.stringify(payload, null, 2)}\n`;
191
+ await handle.writeFile(raw, "utf8");
192
+ const snapshot = { raw, payload, stat: await handle.stat() };
140
193
  const createdHeld = {
141
194
  count: 1,
142
195
  handle,
143
196
  lockPath,
197
+ snapshot,
144
198
  acquiredAt: Date.now(),
145
199
  metadata: options.metadata ?? {},
146
200
  };
147
201
  state.held.set(normalizedTargetPath, createdHeld);
148
- await handle.writeFile(`${JSON.stringify(await options.payload(), null, 2)}\n`, "utf8");
149
202
  const release = () => releaseHeldLock(state, normalizedTargetPath, createdHeld).then(() => undefined);
150
203
  return {
151
204
  lockPath,
@@ -156,29 +209,56 @@ export function createSidecarLockManager(key) {
156
209
  }
157
210
  catch (err) {
158
211
  if (handle) {
212
+ const failedSnapshot = { payload: null };
213
+ try {
214
+ failedSnapshot.stat = await handle.stat();
215
+ }
216
+ catch {
217
+ // Best-effort cleanup of a failed exclusive create.
218
+ }
159
219
  const current = state.held.get(normalizedTargetPath);
160
220
  if (current?.handle === handle) {
161
221
  state.held.delete(normalizedTargetPath);
162
222
  }
163
- await handle.close().catch(() => undefined);
223
+ // If payload serialization/write fails, the file may be empty or
224
+ // partial JSON, so remove while our exclusive handle is still open.
164
225
  await fs.rm(lockPath, { force: true }).catch(() => undefined);
226
+ await handle.close().catch(() => undefined);
227
+ // Windows can refuse removing an open file; retry after close but
228
+ // only if the path still points at the file identity we created.
229
+ await removeLockIfUnchanged(lockPath, failedSnapshot);
165
230
  }
166
231
  if (err.code !== "EEXIST") {
167
232
  throw err;
168
233
  }
169
234
  const nowMs = Date.now();
170
- const payload = await readJsonPayload(lockPath);
235
+ const snapshot = await readLockSnapshot(lockPath);
236
+ if (!snapshot) {
237
+ continue;
238
+ }
171
239
  const shouldReclaim = options.shouldReclaim ?? defaultShouldReclaim;
172
240
  if (await shouldReclaim({
173
241
  lockPath,
174
242
  normalizedTargetPath,
175
- payload,
243
+ payload: snapshot?.payload ?? null,
176
244
  staleMs: options.staleMs,
177
245
  nowMs,
178
246
  heldByThisProcess: state.held.has(normalizedTargetPath),
179
247
  })) {
180
- await fs.rm(lockPath, { force: true }).catch(() => undefined);
181
- continue;
248
+ if (!(await lockSnapshotStillPresent(lockPath, snapshot))) {
249
+ continue;
250
+ }
251
+ // Node exposes only path-based unlink/rename here. A stale-lock
252
+ // reclaimer cannot bind the delete to the file it inspected, so a
253
+ // concurrent release+fresh-acquire could otherwise lose its lock.
254
+ // Fail closed and let callers choose a higher-level recovery path.
255
+ if ((options.staleRecovery ?? "fail-closed") === "fail-closed") {
256
+ throw Object.assign(new Error(`file lock stale for ${normalizedTargetPath}`), {
257
+ code: "file_lock_stale",
258
+ lockPath,
259
+ normalizedTargetPath,
260
+ });
261
+ }
182
262
  }
183
263
  const elapsed = Date.now() - startedAt;
184
264
  if ((options.timeoutMs !== undefined &&
package/dist/store.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export { fileStore, fileStoreSync, type FileStore, type FileStoreOptions, type FileStorePruneOptions, type FileStoreReadOptions, type FileStoreSync, type FileStoreWriteOptions, } from "./file-store.js";
2
2
  export { jsonStore, type JsonStore, type JsonFileStoreOptions, type JsonStoreLockOptions, type JsonStoreOptions, } from "./json-store.js";
3
+ export { ackJsonDurableQueueEntry, ensureJsonDurableQueueDirs, jsonDurableQueueEntryExists, loadJsonDurableQueueEntry, loadPendingJsonDurableQueueEntries, moveJsonDurableQueueEntryToFailed, readJsonDurableQueueEntry, resolveJsonDurableQueueEntryPaths, unlinkBestEffort, writeJsonDurableQueueEntry, type JsonDurableQueueEntryPaths, type JsonDurableQueueLoadOptions, type JsonDurableQueueReadResult, } from "./json-durable-queue.js";
3
4
  //# sourceMappingURL=store.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,SAAS,EACT,aAAa,EACb,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,qBAAqB,EAC1B,KAAK,oBAAoB,EACzB,KAAK,aAAa,EAClB,KAAK,qBAAqB,GAC3B,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,SAAS,EACT,KAAK,SAAS,EACd,KAAK,oBAAoB,EACzB,KAAK,oBAAoB,EACzB,KAAK,gBAAgB,GACtB,MAAM,iBAAiB,CAAC"}
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,SAAS,EACT,aAAa,EACb,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,qBAAqB,EAC1B,KAAK,oBAAoB,EACzB,KAAK,aAAa,EAClB,KAAK,qBAAqB,GAC3B,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,SAAS,EACT,KAAK,SAAS,EACd,KAAK,oBAAoB,EACzB,KAAK,oBAAoB,EACzB,KAAK,gBAAgB,GACtB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,wBAAwB,EACxB,0BAA0B,EAC1B,2BAA2B,EAC3B,yBAAyB,EACzB,kCAAkC,EAClC,iCAAiC,EACjC,yBAAyB,EACzB,iCAAiC,EACjC,gBAAgB,EAChB,0BAA0B,EAC1B,KAAK,0BAA0B,EAC/B,KAAK,2BAA2B,EAChC,KAAK,0BAA0B,GAChC,MAAM,yBAAyB,CAAC"}
package/dist/store.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { fileStore, fileStoreSync, } from "./file-store.js";
2
2
  export { jsonStore, } from "./json-store.js";
3
+ export { ackJsonDurableQueueEntry, ensureJsonDurableQueueDirs, jsonDurableQueueEntryExists, loadJsonDurableQueueEntry, loadPendingJsonDurableQueueEntries, moveJsonDurableQueueEntryToFailed, readJsonDurableQueueEntry, resolveJsonDurableQueueEntryPaths, unlinkBestEffort, writeJsonDurableQueueEntry, } from "./json-durable-queue.js";
@@ -1 +1 @@
1
- {"version":3,"file":"temp-target.d.ts","sourceRoot":"","sources":["../src/temp-target.ts"],"names":[],"mappings":"AAMA,MAAM,MAAM,QAAQ,GAAG;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAChC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7B,CAAC,MAAM,CAAC,YAAY,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxC,CAAC;AAiBF,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAI7D;AAED,wBAAgB,uBAAuB,CAAC,MAAM,EAAE;IAC9C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,GAAG,MAAM,CAWT;AAyBD,wBAAsB,QAAQ,CAAC,MAAM,EAAE;IACrC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CAC3C,GAAG,OAAO,CAAC,QAAQ,CAAC,CAqBpB;AAED,wBAAsB,YAAY,CAAC,CAAC,EAClC,MAAM,EAAE;IACN,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CAC3C,EACD,EAAE,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,GAClC,OAAO,CAAC,CAAC,CAAC,CAOZ"}
1
+ {"version":3,"file":"temp-target.d.ts","sourceRoot":"","sources":["../src/temp-target.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,QAAQ,GAAG;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAChC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7B,CAAC,MAAM,CAAC,YAAY,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxC,CAAC;AAiBF,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAI7D;AAED,wBAAgB,uBAAuB,CAAC,MAAM,EAAE;IAC9C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,GAAG,MAAM,CAaT;AAyBD,wBAAsB,QAAQ,CAAC,MAAM,EAAE;IACrC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CAC3C,GAAG,OAAO,CAAC,QAAQ,CAAC,CAqBpB;AAED,wBAAsB,YAAY,CAAC,CAAC,EAClC,MAAM,EAAE;IACN,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CAC3C,EACD,EAAE,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,GAClC,OAAO,CAAC,CAAC,CAAC,CAOZ"}
@@ -1,6 +1,7 @@
1
1
  import crypto from "node:crypto";
2
2
  import { mkdtemp, rm } from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { assertSafePathSegment, sanitizeSafePathSegment } from "./safe-path-segment.js";
4
5
  import { resolveSecureTempRoot } from "./secure-temp-dir.js";
5
6
  import { registerTempPathForExit } from "./temp-cleanup.js";
6
7
  function sanitizePrefix(prefix) {
@@ -17,9 +18,9 @@ function sanitizeExtension(extension) {
17
18
  return token ? `.${token}` : "";
18
19
  }
19
20
  export function sanitizeTempFileName(fileName) {
20
- const base = path.basename(fileName).replace(/[^a-zA-Z0-9._-]+/g, "-");
21
- const normalized = base.replace(/^-+|-+$/g, "");
22
- return normalized || "download.bin";
21
+ return sanitizeSafePathSegment(path.basename(fileName), "download.bin", {
22
+ allowDotPrefix: true,
23
+ });
23
24
  }
24
25
  export function buildRandomTempFilePath(params) {
25
26
  const rootDir = resolveTempRoot(params.rootDir);
@@ -29,7 +30,9 @@ export function buildRandomTempFilePath(params) {
29
30
  const now = typeof nowCandidate === "number" && Number.isFinite(nowCandidate)
30
31
  ? Math.trunc(nowCandidate)
31
32
  : Date.now();
32
- const uuid = params.uuid?.trim() || crypto.randomUUID();
33
+ const uuid = params.uuid
34
+ ? assertSafePathSegment(params.uuid.trim(), { label: "temp uuid" })
35
+ : crypto.randomUUID();
33
36
  return path.join(rootDir, `${prefix}-${now}-${uuid}${extension}`);
34
37
  }
35
38
  function isNodeErrorWithCode(err, code) {
@@ -3,6 +3,12 @@ export type FsSafeTestHooks = {
3
3
  afterPreOpenLstat?: (filePath: string) => Promise<void> | void;
4
4
  beforeOpen?: (filePath: string, flags: number) => Promise<void> | void;
5
5
  afterOpen?: (filePath: string, handle: FileHandle) => Promise<void> | void;
6
+ beforeArchiveOutputMutation?: (operation: "mkdir" | "chmod", targetPath: string) => Promise<void> | void;
7
+ beforeFileStorePruneDescend?: (dirPath: string) => Promise<void> | void;
8
+ beforeFileStoreSyncPrivateWrite?: (filePath: string) => void;
9
+ beforeRootFallbackMutation?: (operation: "mkdir" | "move" | "remove", targetPath: string) => Promise<void> | void;
10
+ beforeSiblingTempWrite?: (tempPath: string) => Promise<void> | void;
11
+ beforeTrashMove?: (targetPath: string, destPath: string) => void;
6
12
  };
7
13
  export declare function getFsSafeTestHooks(): FsSafeTestHooks | undefined;
8
14
  export declare function __setFsSafeTestHooksForTest(hooks?: FsSafeTestHooks): void;
@@ -1 +1 @@
1
- {"version":3,"file":"test-hooks.d.ts","sourceRoot":"","sources":["../src/test-hooks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnD,MAAM,MAAM,eAAe,GAAG;IAC5B,iBAAiB,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAC/D,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IACvE,SAAS,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;CAC5E,CAAC;AAQF,wBAAgB,kBAAkB,IAAI,eAAe,GAAG,SAAS,CAEhE;AAED,wBAAgB,2BAA2B,CAAC,KAAK,CAAC,EAAE,eAAe,GAAG,IAAI,CAKzE"}
1
+ {"version":3,"file":"test-hooks.d.ts","sourceRoot":"","sources":["../src/test-hooks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnD,MAAM,MAAM,eAAe,GAAG;IAC5B,iBAAiB,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAC/D,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IACvE,SAAS,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAC3E,2BAA2B,CAAC,EAAE,CAC5B,SAAS,EAAE,OAAO,GAAG,OAAO,EAC5B,UAAU,EAAE,MAAM,KACf,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAC1B,2BAA2B,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IACxE,+BAA+B,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7D,0BAA0B,CAAC,EAAE,CAC3B,SAAS,EAAE,OAAO,GAAG,MAAM,GAAG,QAAQ,EACtC,UAAU,EAAE,MAAM,KACf,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAC1B,sBAAsB,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IACpE,eAAe,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;CAClE,CAAC;AAQF,wBAAgB,kBAAkB,IAAI,eAAe,GAAG,SAAS,CAEhE;AAED,wBAAgB,2BAA2B,CAAC,KAAK,CAAC,EAAE,eAAe,GAAG,IAAI,CAKzE"}
@@ -2,6 +2,13 @@ export type WriteTextAtomicOptions = {
2
2
  mode?: number;
3
3
  dirMode?: number;
4
4
  trailingNewline?: boolean;
5
+ /**
6
+ * When false, skip the temp-file and parent-directory fsync calls while
7
+ * preserving the temp-file replace/rename behavior.
8
+ *
9
+ * Defaults to true.
10
+ */
11
+ durable?: boolean;
5
12
  };
6
13
  export declare function writeTextAtomic(filePath: string, content: string, options?: WriteTextAtomicOptions): Promise<void>;
7
14
  //# sourceMappingURL=text-atomic.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"text-atomic.d.ts","sourceRoot":"","sources":["../src/text-atomic.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,sBAAsB,GAAG;IACnC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B,CAAC;AAEF,wBAAsB,eAAe,CACnC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE,sBAAsB,GAC/B,OAAO,CAAC,IAAI,CAAC,CAWf"}
1
+ {"version":3,"file":"text-atomic.d.ts","sourceRoot":"","sources":["../src/text-atomic.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,sBAAsB,GAAG;IACnC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B;;;;;OAKG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,CAAC;AAEF,wBAAsB,eAAe,CACnC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE,sBAAsB,GAC/B,OAAO,CAAC,IAAI,CAAC,CAYf"}
@@ -1,13 +1,14 @@
1
1
  import { replaceFileAtomic } from "./replace-file.js";
2
2
  export async function writeTextAtomic(filePath, content, options) {
3
3
  const payload = options?.trailingNewline && !content.endsWith("\n") ? `${content}\n` : content;
4
+ const durable = options?.durable ?? true;
4
5
  await replaceFileAtomic({
5
6
  filePath,
6
7
  content: payload,
7
8
  mode: options?.mode ?? 0o600,
8
9
  dirMode: options?.dirMode ?? (0o777 & ~process.umask()),
9
10
  copyFallbackOnPermissionError: true,
10
- syncTempFile: true,
11
- syncParentDir: true,
11
+ syncTempFile: durable,
12
+ syncParentDir: durable,
12
13
  });
13
14
  }
@@ -1 +1 @@
1
- {"version":3,"file":"trash.d.ts","sourceRoot":"","sources":["../src/trash.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,sBAAsB,GAAG;IACnC,YAAY,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC;CACjC,CAAC;AA4HF,wBAAsB,eAAe,CACnC,UAAU,EAAE,MAAM,EAClB,OAAO,GAAE,sBAA2B,GACnC,OAAO,CAAC,MAAM,CAAC,CAcjB"}
1
+ {"version":3,"file":"trash.d.ts","sourceRoot":"","sources":["../src/trash.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,sBAAsB,GAAG;IACnC,YAAY,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC;CACjC,CAAC;AAwKF,wBAAsB,eAAe,CACnC,UAAU,EAAE,MAAM,EAClB,OAAO,GAAE,sBAA2B,GACnC,OAAO,CAAC,MAAM,CAAC,CAcjB"}
package/dist/trash.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { sameFileIdentity } from "./file-identity.js";
5
+ import { guardedRenameSync, guardedRmSync } from "./guarded-mutation.js";
6
+ import { getFsSafeTestHooks } from "./test-hooks.js";
4
7
  const TRASH_DESTINATION_COLLISION_CODES = new Set(["EEXIST", "ENOTEMPTY", "ERR_FS_CP_EEXIST"]);
5
8
  const TRASH_DESTINATION_RETRY_LIMIT = 4;
6
9
  function getFsErrorCode(error) {
@@ -18,28 +21,56 @@ function isSameOrChildPath(candidate, parent) {
18
21
  return candidate === parent || candidate.startsWith(`${parent}${path.sep}`);
19
22
  }
20
23
  function resolveAllowedTrashRoots(allowedRoots) {
21
- const roots = [...(allowedRoots ?? [os.homedir(), os.tmpdir()])].map((root) => {
24
+ const roots = [...(allowedRoots ?? [os.homedir(), os.tmpdir()])].flatMap((root) => {
25
+ const lexicalRoot = path.resolve(root);
22
26
  try {
23
- return path.resolve(fs.realpathSync.native(root));
27
+ // Keep both spellings: broken symlink targets cannot be realpathed and
28
+ // may only compare equal to the caller's lexical allowed root.
29
+ return [path.resolve(fs.realpathSync.native(root)), lexicalRoot];
24
30
  }
25
31
  catch {
26
- return path.resolve(root);
32
+ return [lexicalRoot];
27
33
  }
28
34
  });
29
35
  return [...new Set(roots)];
30
36
  }
31
- function assertAllowedTrashTarget(targetPath, allowedRoots) {
32
- let resolvedTargetPath = path.resolve(targetPath);
37
+ function resolveTrashTargetPath(targetPath) {
33
38
  try {
34
- resolvedTargetPath = path.resolve(fs.realpathSync.native(targetPath));
39
+ return { path: path.resolve(fs.realpathSync.native(targetPath)), resolved: true };
35
40
  }
36
41
  catch {
37
- // The subsequent move will surface missing or inaccessible targets.
42
+ // Broken symlinks are valid trash targets. Fall back to the lexical path,
43
+ // then rely on lstat identity so the move renames the symlink itself.
44
+ return { path: path.resolve(targetPath), resolved: false };
38
45
  }
46
+ }
47
+ function assertAllowedTrashTarget(targetPath, allowedRoots) {
48
+ const stat = fs.lstatSync(path.resolve(targetPath));
49
+ const resolvedTarget = resolveTrashTargetPath(targetPath);
50
+ const resolvedTargetPath = resolvedTarget.path;
39
51
  const isAllowed = resolveAllowedTrashRoots(allowedRoots).some((root) => resolvedTargetPath !== root && isSameOrChildPath(resolvedTargetPath, root));
40
52
  if (!isAllowed) {
41
53
  throw new Error(`Refusing to trash path outside allowed roots: ${targetPath}`);
42
54
  }
55
+ return {
56
+ path: path.resolve(targetPath),
57
+ realPath: resolvedTargetPath,
58
+ realPathResolved: resolvedTarget.resolved,
59
+ stat,
60
+ };
61
+ }
62
+ function assertTrashTargetGuard(guard) {
63
+ const stat = fs.lstatSync(guard.path);
64
+ if (!sameFileIdentity(stat, guard.stat)) {
65
+ throw new Error(`Refusing to trash path after it changed: ${guard.path}`);
66
+ }
67
+ const current = resolveTrashTargetPath(guard.path);
68
+ if (guard.realPathResolved && (!current.resolved || current.path !== guard.realPath)) {
69
+ throw new Error(`Refusing to trash path after it changed: ${guard.path}`);
70
+ }
71
+ if (!guard.realPathResolved && current.resolved) {
72
+ throw new Error(`Refusing to trash path after it changed: ${guard.path}`);
73
+ }
43
74
  }
44
75
  function resolveTrashDir() {
45
76
  const homeDir = os.homedir();
@@ -86,9 +117,11 @@ function reserveTrashDestination(trashDir, base, timestamp) {
86
117
  }
87
118
  return resolveContainedPath(container, base);
88
119
  }
89
- function movePathToDestination(targetPath, dest) {
120
+ function movePathToDestination(target, dest) {
121
+ getFsSafeTestHooks()?.beforeTrashMove?.(target.path, dest);
122
+ assertTrashTargetGuard(target);
90
123
  try {
91
- fs.renameSync(targetPath, dest);
124
+ guardedRenameSync({ from: target.path, to: dest });
92
125
  return true;
93
126
  }
94
127
  catch (error) {
@@ -100,8 +133,10 @@ function movePathToDestination(targetPath, dest) {
100
133
  }
101
134
  }
102
135
  try {
103
- fs.cpSync(targetPath, dest, { recursive: true, force: false, errorOnExist: true });
104
- fs.rmSync(targetPath, { recursive: true, force: false });
136
+ assertTrashTargetGuard(target);
137
+ fs.cpSync(target.path, dest, { recursive: true, force: false, errorOnExist: true });
138
+ assertTrashTargetGuard(target);
139
+ guardedRmSync({ target: target.path, recursive: true, force: false, verifyAfter: false });
105
140
  return true;
106
141
  }
107
142
  catch (error) {
@@ -114,12 +149,12 @@ function movePathToDestination(targetPath, dest) {
114
149
  export async function movePathToTrash(targetPath, options = {}) {
115
150
  // Avoid resolving external trash helpers through the service PATH during cleanup.
116
151
  const base = trashBaseName(targetPath);
117
- assertAllowedTrashTarget(targetPath, options.allowedRoots);
152
+ const target = assertAllowedTrashTarget(targetPath, options.allowedRoots);
118
153
  const trashDir = resolveTrashDir();
119
154
  const timestamp = Date.now();
120
155
  for (let attempt = 0; attempt < TRASH_DESTINATION_RETRY_LIMIT; attempt += 1) {
121
156
  const dest = reserveTrashDestination(trashDir, base, timestamp);
122
- if (movePathToDestination(targetPath, dest)) {
157
+ if (movePathToDestination(target, dest)) {
123
158
  return dest;
124
159
  }
125
160
  }
package/docs/advanced.md CHANGED
@@ -38,9 +38,19 @@ The exports group into a handful of themes. Each documented helper has its own p
38
38
  | Export | Page | Notes |
39
39
  |---|---|---|
40
40
  | `assertAbsolutePathInput` | – | Validate a caller-supplied absolute path string. |
41
+ | `ensureAbsoluteDirectory`, `EnsureAbsoluteDirectoryOptions`, `EnsureAbsoluteDirectoryResult` | – | Create a trusted absolute directory path one segment at a time, rejecting symlink or non-directory segments. |
41
42
  | `canonicalPathFromExistingAncestor`, `findExistingAncestor` | – | Canonicalize without requiring the leaf to exist. |
42
43
  | `resolveAbsolutePathForRead`, `resolveAbsolutePathForWrite`, `ResolvedAbsolutePath`, `ResolvedWritableAbsolutePath`, `AbsolutePathSymlinkPolicy` | – | Validate an absolute path against a symlink policy before opening. |
43
44
 
45
+ `ensureAbsoluteDirectory()` is for paths you already intend to trust as absolute
46
+ locations, such as a configured output root. It does not enforce a root boundary;
47
+ use `pathScope().ensureDir()` or `ensureDirectoryWithinRoot()` when the caller
48
+ supplies a path that must stay under a root.
49
+
50
+ The helper returns `{ ok: false, code, error }` for path-policy failures such as
51
+ relative paths, symlinks, non-directories, or directory swaps during creation.
52
+ Operational filesystem failures such as permissions or I/O errors are rethrown.
53
+
44
54
  ### Files and identity
45
55
 
46
56
  | Export | Page | Notes |
package/docs/atomic.md CHANGED
@@ -68,7 +68,12 @@ If `beforeRename` throws, the rename is skipped and the temp file is removed —
68
68
 
69
69
  ### `EPERM` and copy fallback
70
70
 
71
- On systems where `rename` fails with `EPERM`/`EEXIST`, pass `copyFallbackOnPermissionError: true` to fall back to copy + unlink. The fallback refuses symlink destinations before copying so it does not write through a replaced destination link.
71
+ On systems where `rename` fails with `EPERM`/`EEXIST`, pass
72
+ `copyFallbackOnPermissionError: true` to fall back to a non-atomic copy
73
+ replacement. The fallback removes the old destination, opens the replacement
74
+ with exclusive/no-follow flags where the platform supports them, and refuses
75
+ known symlink destinations so it does not write through a replaced destination
76
+ link.
72
77
 
73
78
  ### Sync variant
74
79
 
@@ -109,21 +114,44 @@ await writeTextAtomic("/srv/workspace/rendered.md", rendered, {
109
114
  });
110
115
  ```
111
116
 
117
+ Options:
118
+
119
+ ```ts
120
+ type WriteTextAtomicOptions = {
121
+ mode?: number; // file mode (default 0o600)
122
+ dirMode?: number; // mode for parent dirs created on demand
123
+ trailingNewline?: boolean; // append "\n" if missing
124
+ durable?: boolean; // default true; false skips temp/parent fsync
125
+ };
126
+ ```
127
+
128
+ `durable: false` keeps the sibling-temp replace/rename behavior but skips the
129
+ temp-file and parent-directory `fsync` calls. Use it only for reconstructible
130
+ metadata where lower latency matters more than crash-durability.
131
+
112
132
  ## `movePathWithCopyFallback`
113
133
 
114
- Rename a path. If the rename fails with `EXDEV` (cross-device) or `EPERM`, fall back to copy + remove. Preserves atomicity at the destination by writing the copy through `replaceFileAtomic` (for files) or staged-rename (for directories).
134
+ Rename a path. If the rename fails with `EXDEV` (cross-device), fall back to
135
+ copying into a staged sibling path, renaming that staged path into place, and
136
+ then removing only the source entries that were copied. The fallback avoids
137
+ buffering regular files into memory and does not tighten the destination parent
138
+ directory mode.
115
139
 
116
140
  ```ts
117
141
  import { movePathWithCopyFallback } from "@openclaw/fs-safe/atomic";
118
142
 
119
143
  await movePathWithCopyFallback({
120
- source: "/srv/cache/blob.bin",
121
- destination: "/srv/persistent/blob.bin",
122
- overwrite: true,
144
+ from: "/srv/cache/blob.bin",
145
+ sourceHardlinks: "reject",
146
+ to: "/srv/persistent/blob.bin",
123
147
  });
124
148
  ```
125
149
 
126
150
  Use it when source and destination might live on different filesystems (containers, tmpfs, separate volumes).
151
+ If another writer changes source entries during the fallback, the staged copy
152
+ throws `ESTALE` before commit when possible. If the destination has already
153
+ been committed, cleanup still preserves the changed source entries and throws
154
+ `ESTALE`.
127
155
 
128
156
  ## Difference from `root()`
129
157
 
package/docs/config.md CHANGED
@@ -1,22 +1,25 @@
1
1
  ---
2
2
  title: Config
3
- description: "Process-global configuration for the optional Python helper used by fs-safe on POSIX."
3
+ description: "Process-global defaults for optional fs-safe helpers."
4
4
  ---
5
5
 
6
6
  # `@openclaw/fs-safe/config`
7
7
 
8
- Process-global configuration knobs for the optional persistent Python helper that backs POSIX fd-relative operations in `root()`. The whole helper policy is described in the [Python helper policy](python-helper.md); this page is the API reference.
8
+ Process-global configuration knobs for optional fs-safe helpers. The Python helper policy is described in the [Python helper policy](python-helper.md); this page is the API reference.
9
9
 
10
10
  ```ts
11
11
  import {
12
12
  configureFsSafePython,
13
+ configureFsSafeLocks,
13
14
  getFsSafePythonConfig,
15
+ getFsSafeLockConfig,
16
+ type FsSafeLockConfig,
14
17
  type FsSafePythonConfig,
15
18
  type FsSafePythonMode,
16
19
  } from "@openclaw/fs-safe/config";
17
20
  ```
18
21
 
19
- `configureFsSafePython` is also re-exported from the main entry point, so `import { configureFsSafePython } from "@openclaw/fs-safe"` works too. Prefer the subpath when you only need helper configuration and want the smallest import surface.
22
+ These functions are also re-exported from the main entry point. Prefer the subpath when you only need helper configuration and want the smallest import surface.
20
23
 
21
24
  ## `configureFsSafePython(config)`
22
25
 
@@ -47,6 +50,31 @@ function getFsSafePythonConfig(): FsSafePythonConfig;
47
50
 
48
51
  Return the effective configuration: programmatic overrides win, then env vars, then the package default (`auto`).
49
52
 
53
+ ## `configureFsSafeLocks(config)`
54
+
55
+ ```ts
56
+ function configureFsSafeLocks(config: Partial<FsSafeLockConfig>): void;
57
+
58
+ type FsSafeLockConfig = {
59
+ staleRecovery: "fail-closed";
60
+ staleMs?: number;
61
+ timeoutMs?: number;
62
+ retry?: FileLockRetryOptions;
63
+ };
64
+ ```
65
+
66
+ Set process-wide defaults for sidecar lock options. This does **not** turn locking on globally; callers still need to pass `lock: true` or a lock options object for the specific JSON store/resource that needs cross-process coordination.
67
+
68
+ `staleRecovery` currently supports `"fail-closed"` only. Stale third-party sidecars are not deleted by path because Node cannot atomically bind that deletion to the file that was inspected.
69
+
70
+ ## `getFsSafeLockConfig()`
71
+
72
+ ```ts
73
+ function getFsSafeLockConfig(): FsSafeLockConfig;
74
+ ```
75
+
76
+ Return the current sidecar lock defaults.
77
+
50
78
  ## Environment variables
51
79
 
52
80
  The same policy can be set without code:
@@ -61,5 +89,6 @@ OpenClaw compatibility aliases are accepted: `OPENCLAW_FS_SAFE_PYTHON_MODE`, `OP
61
89
  ## Related pages
62
90
 
63
91
  - [Python helper policy](python-helper.md) — when to pick `auto`, `off`, or `require`, and what each mode protects.
92
+ - [File lock](sidecar-lock.md) — the per-resource lock API that consumes lock defaults.
64
93
  - [Root API](root.md) — the API whose POSIX hardening the helper backs.
65
94
  - [Errors](errors.md) — `helper-unavailable` and `helper-failed`.
package/docs/index.md CHANGED
@@ -49,9 +49,10 @@ await fs.remove("notes/archive/today.txt");
49
49
  | Surface | Use it for |
50
50
  |---|---|
51
51
  | [`root()`](root.md) | One boundary for read/write/move/remove inside a trusted directory. |
52
- | [`@openclaw/fs-safe/config`](config.md) | Process-global Python helper configuration (`configureFsSafePython`, `getFsSafePythonConfig`). |
52
+ | [`@openclaw/fs-safe/config`](config.md) | Process-global Python helper and lock-option defaults. |
53
53
  | [Python helper policy](python-helper.md) | Choose `auto`, `off`, or `require` for POSIX fd-relative hardening. |
54
54
  | [`replaceFileAtomic`](atomic.md) | Sibling-temp + rename, fsync hooks, mode preservation, copy fallback. |
55
+ | [`writeExternalFileWithinRoot`](output.md) | Stage external-library file output in private temp storage, then finalize under a root. |
55
56
  | [`writeJson` / `readJson*`](json.md) | JSON state files with strict and lenient read variants. |
56
57
  | [`@openclaw/fs-safe/store`](store.md) | Overview of `fileStore`, `fileStoreSync`, and `jsonStore`. |
57
58
  | [`jsonStore`](json-store.md) | Single JSON state file with explicit fallback, atomic writes, and optional locking. |
@@ -63,7 +64,7 @@ await fs.remove("notes/archive/today.txt");
63
64
  | [`extractArchive`](archive.md) | ZIP/TAR extraction with size, count, link, and traversal limits. |
64
65
  | [Secret files](secret-file.md) | Mode-0600 credentials with size and TOCTOU defense. |
65
66
  | [Permissions](permissions.md) | POSIX mode and Windows ACL inspection/remediation helpers. |
66
- | [`acquireFileLock`](sidecar-lock.md) | Cross-process file lock with retry and stale-lock recovery. |
67
+ | [`acquireFileLock`](sidecar-lock.md) | Cross-process file lock with retry and fail-closed stale-lock handling. |
67
68
  | [`FsSafeError`](errors.md) | Closed code union (with `policy` / `operational` category) you can branch on. |
68
69
  | [`pathScope()`](path-scope.md) | Lower-level absolute-path boundary helper; lives behind `@openclaw/fs-safe/advanced`. |
69
70
  | [`@openclaw/fs-safe/advanced`](advanced.md) | Directory of lower-level composition helpers (path scopes, regular-file I/O, install paths, sibling-temp writes, …). |
@@ -53,6 +53,7 @@ type JsonStoreLockOptions = {
53
53
  staleMs?: number; // default 30_000
54
54
  timeoutMs?: number; // default 30_000
55
55
  retry?: FileLockRetryOptions;
56
+ staleRecovery?: "fail-closed";
56
57
  managerKey?: string; // default `fs-safe.json-store:<filePath>`
57
58
  };
58
59
 
@@ -130,6 +131,7 @@ const counter = jsonStore<{ count: number }>({
130
131
  lock: {
131
132
  staleMs: 60_000,
132
133
  timeoutMs: 10_000,
134
+ staleRecovery: "fail-closed",
133
135
  retry: { retries: 30, minTimeout: 100, maxTimeout: 5_000, randomize: true },
134
136
  },
135
137
  });
@@ -137,6 +139,8 @@ const counter = jsonStore<{ count: number }>({
137
139
 
138
140
  When `lock` is falsy, `read` / `write` / `update` are unlocked. The `update` shape is still useful — it gives you a single function for the read-modify-write pattern — but it offers no concurrency guarantees if other processes also write to the file.
139
141
 
142
+ Process-wide lock defaults from `configureFsSafeLocks()` apply only after locking is explicitly enabled. They do not make JSON stores lock by default.
143
+
140
144
  The default `managerKey` namespaces the in-process `FileLockManager` per absolute file path, so two `jsonStore` calls on the same file share lock state automatically.
141
145
 
142
146
  ## Common patterns