@openclaw/fs-safe 0.4.7 → 0.5.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 (247) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +82 -43
  3. package/dist/archive-entry.d.ts.map +1 -1
  4. package/dist/archive-entry.js +5 -4
  5. package/dist/archive-errors.d.ts +11 -0
  6. package/dist/archive-errors.d.ts.map +1 -0
  7. package/dist/archive-errors.js +16 -0
  8. package/dist/archive-input.d.ts +12 -0
  9. package/dist/archive-input.d.ts.map +1 -0
  10. package/dist/archive-input.js +72 -0
  11. package/dist/archive-kind.d.ts +1 -1
  12. package/dist/archive-kind.d.ts.map +1 -1
  13. package/dist/archive-kind.js +18 -0
  14. package/dist/archive-limits.d.ts +10 -0
  15. package/dist/archive-limits.d.ts.map +1 -1
  16. package/dist/archive-limits.js +18 -0
  17. package/dist/archive-native.d.ts +18 -0
  18. package/dist/archive-native.d.ts.map +1 -0
  19. package/dist/archive-native.js +115 -0
  20. package/dist/archive-options.d.ts +21 -0
  21. package/dist/archive-options.d.ts.map +1 -0
  22. package/dist/archive-options.js +1 -0
  23. package/dist/archive-policy.d.ts +20 -0
  24. package/dist/archive-policy.d.ts.map +1 -0
  25. package/dist/archive-policy.js +29 -0
  26. package/dist/archive-read.d.ts +6 -0
  27. package/dist/archive-read.d.ts.map +1 -0
  28. package/dist/archive-read.js +200 -0
  29. package/dist/archive-staging.d.ts +2 -5
  30. package/dist/archive-staging.d.ts.map +1 -1
  31. package/dist/archive-staging.js +19 -10
  32. package/dist/archive-tar-meta.d.ts +6 -0
  33. package/dist/archive-tar-meta.d.ts.map +1 -0
  34. package/dist/archive-tar-meta.js +143 -0
  35. package/dist/archive-tar-runtime.d.ts +43 -0
  36. package/dist/archive-tar-runtime.d.ts.map +1 -0
  37. package/dist/archive-tar-runtime.js +8 -0
  38. package/dist/archive-tar.d.ts +5 -1
  39. package/dist/archive-tar.d.ts.map +1 -1
  40. package/dist/archive-tar.js +24 -6
  41. package/dist/archive-zip-entry.d.ts +15 -0
  42. package/dist/archive-zip-entry.d.ts.map +1 -0
  43. package/dist/archive-zip-entry.js +17 -0
  44. package/dist/archive.d.ts +7 -17
  45. package/dist/archive.d.ts.map +1 -1
  46. package/dist/archive.js +111 -108
  47. package/dist/atomic.d.ts +1 -1
  48. package/dist/atomic.d.ts.map +1 -1
  49. package/dist/config.d.ts +1 -1
  50. package/dist/config.d.ts.map +1 -1
  51. package/dist/config.js +1 -1
  52. package/dist/containment.d.ts +2 -0
  53. package/dist/containment.d.ts.map +1 -0
  54. package/dist/containment.js +1 -0
  55. package/dist/device-path.d.ts.map +1 -1
  56. package/dist/device-path.js +24 -2
  57. package/dist/durability.d.ts +2 -0
  58. package/dist/durability.d.ts.map +1 -1
  59. package/dist/durability.js +2 -0
  60. package/dist/errors.d.ts +4 -1
  61. package/dist/errors.d.ts.map +1 -1
  62. package/dist/errors.js +2 -0
  63. package/dist/file-hash.d.ts +10 -0
  64. package/dist/file-hash.d.ts.map +1 -0
  65. package/dist/file-hash.js +73 -0
  66. package/dist/file-lock-sync.d.ts +35 -0
  67. package/dist/file-lock-sync.d.ts.map +1 -0
  68. package/dist/file-lock-sync.js +233 -0
  69. package/dist/file-lock.d.ts +2 -0
  70. package/dist/file-lock.d.ts.map +1 -1
  71. package/dist/file-lock.js +1 -0
  72. package/dist/filename.d.ts.map +1 -1
  73. package/dist/filename.js +4 -1
  74. package/dist/guarded-mkdir.d.ts +7 -1
  75. package/dist/guarded-mkdir.d.ts.map +1 -1
  76. package/dist/guarded-mkdir.js +45 -3
  77. package/dist/index.d.ts +4 -3
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +1 -1
  80. package/dist/json-document-store.d.ts.map +1 -1
  81. package/dist/json-document-store.js +30 -15
  82. package/dist/move-path.d.ts.map +1 -1
  83. package/dist/move-path.js +101 -17
  84. package/dist/native/darwin-arm64/fs-safe-native.node +0 -0
  85. package/dist/native/darwin-x64/fs-safe-native.node +0 -0
  86. package/dist/native/linux-arm64-gnu/fs-safe-native.node +0 -0
  87. package/dist/native/linux-arm64-musl/fs-safe-native.node +0 -0
  88. package/dist/native/linux-x64-gnu/fs-safe-native.node +0 -0
  89. package/dist/native/linux-x64-musl/fs-safe-native.node +0 -0
  90. package/dist/native/win32-x64-msvc/fs-safe-native.node +0 -0
  91. package/dist/native-binding.d.ts +79 -0
  92. package/dist/native-binding.d.ts.map +1 -0
  93. package/dist/native-binding.js +1 -0
  94. package/dist/native-config.d.ts +17 -0
  95. package/dist/native-config.d.ts.map +1 -0
  96. package/dist/native-config.js +71 -0
  97. package/dist/native-operations.d.ts +21 -0
  98. package/dist/native-operations.d.ts.map +1 -0
  99. package/dist/native-operations.js +115 -0
  100. package/dist/native-pinned-write.d.ts +5 -0
  101. package/dist/native-pinned-write.d.ts.map +1 -0
  102. package/dist/native-pinned-write.js +115 -0
  103. package/dist/native.d.ts +14 -0
  104. package/dist/native.d.ts.map +1 -0
  105. package/dist/native.js +204 -0
  106. package/dist/output-sibling.d.ts +8 -0
  107. package/dist/output-sibling.d.ts.map +1 -0
  108. package/dist/output-sibling.js +122 -0
  109. package/dist/output.d.ts +2 -0
  110. package/dist/output.d.ts.map +1 -1
  111. package/dist/output.js +32 -5
  112. package/dist/owner-dacl.d.ts +31 -0
  113. package/dist/owner-dacl.d.ts.map +1 -0
  114. package/dist/owner-dacl.js +27 -0
  115. package/dist/path-policy.js +1 -1
  116. package/dist/path.d.ts.map +1 -1
  117. package/dist/path.js +3 -2
  118. package/dist/permissions-public.d.ts +2 -0
  119. package/dist/permissions-public.d.ts.map +1 -1
  120. package/dist/permissions-public.js +2 -0
  121. package/dist/permissions.d.ts.map +1 -1
  122. package/dist/permissions.js +6 -0
  123. package/dist/pinned-operation.d.ts +2 -0
  124. package/dist/pinned-operation.d.ts.map +1 -0
  125. package/dist/pinned-operation.js +35 -0
  126. package/dist/pinned-write.d.ts +3 -16
  127. package/dist/pinned-write.d.ts.map +1 -1
  128. package/dist/pinned-write.js +20 -85
  129. package/dist/private-directory.d.ts +5 -0
  130. package/dist/private-directory.d.ts.map +1 -0
  131. package/dist/private-directory.js +13 -0
  132. package/dist/private-temp-workspace.d.ts +6 -2
  133. package/dist/private-temp-workspace.d.ts.map +1 -1
  134. package/dist/private-temp-workspace.js +40 -13
  135. package/dist/publish-file-failure.d.ts +27 -0
  136. package/dist/publish-file-failure.d.ts.map +1 -0
  137. package/dist/publish-file-failure.js +21 -0
  138. package/dist/publish-file.d.ts +21 -0
  139. package/dist/publish-file.d.ts.map +1 -0
  140. package/dist/publish-file.js +351 -0
  141. package/dist/read-opened-file.d.ts +3 -0
  142. package/dist/read-opened-file.d.ts.map +1 -1
  143. package/dist/read-opened-file.js +1 -0
  144. package/dist/replace-file-copy-fallback.d.ts +34 -0
  145. package/dist/replace-file-copy-fallback.d.ts.map +1 -0
  146. package/dist/replace-file-copy-fallback.js +339 -0
  147. package/dist/replace-file.d.ts +6 -2
  148. package/dist/replace-file.d.ts.map +1 -1
  149. package/dist/replace-file.js +39 -90
  150. package/dist/root-context.d.ts +4 -1
  151. package/dist/root-context.d.ts.map +1 -1
  152. package/dist/root-context.js +20 -1
  153. package/dist/root-impl.d.ts +5 -0
  154. package/dist/root-impl.d.ts.map +1 -1
  155. package/dist/root-impl.js +128 -225
  156. package/dist/root-path-existing.d.ts +3 -0
  157. package/dist/root-path-existing.d.ts.map +1 -0
  158. package/dist/root-path-existing.js +69 -0
  159. package/dist/root-path.d.ts +1 -2
  160. package/dist/root-path.d.ts.map +1 -1
  161. package/dist/root-path.js +67 -81
  162. package/dist/root-walk.d.ts +41 -0
  163. package/dist/root-walk.d.ts.map +1 -0
  164. package/dist/root-walk.js +114 -0
  165. package/dist/root.d.ts +2 -0
  166. package/dist/root.d.ts.map +1 -1
  167. package/dist/safe-path-segment.d.ts.map +1 -1
  168. package/dist/safe-path-segment.js +16 -4
  169. package/dist/secret-file.d.ts +5 -2
  170. package/dist/secret-file.d.ts.map +1 -1
  171. package/dist/secret-file.js +20 -2
  172. package/dist/secret-read-async.d.ts +4 -0
  173. package/dist/secret-read-async.d.ts.map +1 -0
  174. package/dist/secret-read-async.js +95 -0
  175. package/dist/secret.d.ts +2 -1
  176. package/dist/secret.d.ts.map +1 -1
  177. package/dist/secret.js +2 -1
  178. package/dist/sibling-temp.d.ts.map +1 -1
  179. package/dist/sibling-temp.js +1 -0
  180. package/dist/sidecar-lock-handle.d.ts +20 -0
  181. package/dist/sidecar-lock-handle.d.ts.map +1 -0
  182. package/dist/sidecar-lock-handle.js +28 -0
  183. package/dist/sidecar-lock-policy.d.ts +10 -0
  184. package/dist/sidecar-lock-policy.d.ts.map +1 -0
  185. package/dist/sidecar-lock-policy.js +29 -0
  186. package/dist/sidecar-lock-reclaim.d.ts +22 -6
  187. package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
  188. package/dist/sidecar-lock-reclaim.js +106 -20
  189. package/dist/sidecar-lock-types.d.ts +56 -0
  190. package/dist/sidecar-lock-types.d.ts.map +1 -0
  191. package/dist/sidecar-lock-types.js +1 -0
  192. package/dist/sidecar-lock.d.ts +2 -45
  193. package/dist/sidecar-lock.d.ts.map +1 -1
  194. package/dist/sidecar-lock.js +94 -55
  195. package/dist/temp-cleanup.d.ts +7 -1
  196. package/dist/temp-cleanup.d.ts.map +1 -1
  197. package/dist/temp-cleanup.js +35 -5
  198. package/dist/temp-target.d.ts.map +1 -1
  199. package/dist/temp-target.js +51 -3
  200. package/dist/temp.d.ts +2 -1
  201. package/dist/temp.d.ts.map +1 -1
  202. package/dist/test-hooks.d.ts +3 -0
  203. package/dist/test-hooks.d.ts.map +1 -1
  204. package/dist/windows-permissions-native.d.ts +9 -0
  205. package/dist/windows-permissions-native.d.ts.map +1 -0
  206. package/dist/windows-permissions-native.js +31 -0
  207. package/docs/archive.md +131 -13
  208. package/docs/atomic.md +30 -1
  209. package/docs/config.md +51 -21
  210. package/docs/durability.md +211 -1
  211. package/docs/errors.md +36 -6
  212. package/docs/filename.md +0 -0
  213. package/docs/index.md +11 -8
  214. package/docs/install.md +27 -26
  215. package/docs/json-store.md +18 -3
  216. package/docs/migrating-to-0.5.md +196 -0
  217. package/docs/native-helper.md +85 -0
  218. package/docs/native.md +134 -0
  219. package/docs/output.md +37 -11
  220. package/docs/path.md +1 -1
  221. package/docs/permissions.md +84 -1
  222. package/docs/quickstart.md +5 -2
  223. package/docs/reading.md +4 -4
  224. package/docs/root.md +31 -15
  225. package/docs/secret-file.md +50 -1
  226. package/docs/security-model.md +29 -8
  227. package/docs/sidecar-lock.md +92 -7
  228. package/docs/temp.md +24 -1
  229. package/docs/test-hooks.md +22 -2
  230. package/docs/testing.md +18 -7
  231. package/docs/types.md +3 -1
  232. package/docs/walk.md +55 -0
  233. package/docs/writing.md +3 -3
  234. package/package.json +10 -4
  235. package/dist/pinned-helper.d.ts +0 -9
  236. package/dist/pinned-helper.d.ts.map +0 -1
  237. package/dist/pinned-helper.js +0 -19
  238. package/dist/pinned-path.d.ts +0 -7
  239. package/dist/pinned-path.d.ts.map +0 -1
  240. package/dist/pinned-path.js +0 -21
  241. package/dist/pinned-python-config.d.ts +0 -9
  242. package/dist/pinned-python-config.d.ts.map +0 -1
  243. package/dist/pinned-python-config.js +0 -38
  244. package/dist/pinned-python.d.ts +0 -12
  245. package/dist/pinned-python.d.ts.map +0 -1
  246. package/dist/pinned-python.js +0 -688
  247. package/docs/python-helper.md +0 -103
@@ -0,0 +1,339 @@
1
+ import syncFs, {} from "node:fs";
2
+ import { FsSafeError } from "./errors.js";
3
+ import { sameFileIdentity } from "./file-identity.js";
4
+ const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in syncFs.constants;
5
+ const NOFOLLOW = SUPPORTS_NOFOLLOW ? syncFs.constants.O_NOFOLLOW : 0;
6
+ const OPEN_READ_FLAGS = syncFs.constants.O_RDONLY | NOFOLLOW;
7
+ const OPEN_READ_WRITE_FLAGS = syncFs.constants.O_RDWR | NOFOLLOW;
8
+ const OPEN_WRITE_EXCLUSIVE_FLAGS = syncFs.constants.O_WRONLY | syncFs.constants.O_CREAT | syncFs.constants.O_EXCL | NOFOLLOW;
9
+ const READ_CHUNK_BYTES = 64 * 1024;
10
+ function notFound(error) {
11
+ return error.code === "ENOENT";
12
+ }
13
+ function assertPinnedDestination(pathname, opened, dest, hardlinks) {
14
+ if (pathname.isSymbolicLink()) {
15
+ throw new FsSafeError("symlink", `Refusing copy fallback through symlink destination: ${dest}`);
16
+ }
17
+ if (!pathname.isFile() || !opened.isFile()) {
18
+ throw new FsSafeError("not-file", `Copy fallback destination must be a regular file: ${dest}`);
19
+ }
20
+ if (!sameFileIdentity(pathname, opened)) {
21
+ throw new FsSafeError("path-mismatch", `Copy fallback destination changed while opening: ${dest}`);
22
+ }
23
+ if (hardlinks === "reject" && opened.nlink > 1) {
24
+ throw new FsSafeError("hardlink", `Hardlinked copy fallback destination not allowed: ${dest}`);
25
+ }
26
+ }
27
+ async function openPinnedDestination(fsModule, dest, hardlinks) {
28
+ const preview = await fsModule.lstat(dest).catch((error) => {
29
+ if (notFound(error))
30
+ return null;
31
+ throw error;
32
+ });
33
+ if (!preview)
34
+ return null;
35
+ if (preview.isSymbolicLink()) {
36
+ throw new FsSafeError("symlink", `Refusing copy fallback through symlink destination: ${dest}`);
37
+ }
38
+ const handle = await fsModule.open(dest, OPEN_READ_WRITE_FLAGS);
39
+ try {
40
+ const opened = await handle.stat();
41
+ const current = await fsModule.lstat(dest);
42
+ assertPinnedDestination(current, opened, dest, hardlinks);
43
+ return { handle, stat: opened };
44
+ }
45
+ catch (error) {
46
+ await handle.close().catch(() => undefined);
47
+ throw error;
48
+ }
49
+ }
50
+ function openPinnedDestinationSync(fsModule, dest, hardlinks) {
51
+ let preview;
52
+ try {
53
+ preview = fsModule.lstatSync(dest);
54
+ }
55
+ catch (error) {
56
+ if (notFound(error))
57
+ return null;
58
+ throw error;
59
+ }
60
+ if (preview.isSymbolicLink()) {
61
+ throw new FsSafeError("symlink", `Refusing copy fallback through symlink destination: ${dest}`);
62
+ }
63
+ const fd = fsModule.openSync(dest, OPEN_READ_WRITE_FLAGS);
64
+ try {
65
+ const opened = fsModule.fstatSync(fd);
66
+ assertPinnedDestination(fsModule.lstatSync(dest), opened, dest, hardlinks);
67
+ return { fd, stat: opened };
68
+ }
69
+ catch (error) {
70
+ fsModule.closeSync(fd);
71
+ throw error;
72
+ }
73
+ }
74
+ export async function assertDestinationHardlinkPolicy(fsModule, dest, policy) {
75
+ if (policy !== "reject")
76
+ return;
77
+ const preview = await fsModule.lstat(dest).catch((error) => {
78
+ if (notFound(error))
79
+ return null;
80
+ throw error;
81
+ });
82
+ if (!preview || preview.isSymbolicLink() || !preview.isFile())
83
+ return;
84
+ const handle = await fsModule.open(dest, OPEN_READ_FLAGS);
85
+ try {
86
+ const opened = await handle.stat();
87
+ const current = await fsModule.lstat(dest);
88
+ if (current.isSymbolicLink() || !current.isFile() || !sameFileIdentity(current, opened)) {
89
+ throw new FsSafeError("path-mismatch", `Atomic replace destination changed while opening: ${dest}`);
90
+ }
91
+ if (opened.nlink > 1) {
92
+ throw new FsSafeError("hardlink", `Hardlinked atomic replace destination not allowed: ${dest}`);
93
+ }
94
+ }
95
+ finally {
96
+ await handle.close().catch(() => undefined);
97
+ }
98
+ }
99
+ export function assertDestinationHardlinkPolicySync(fsModule, dest, policy) {
100
+ if (policy !== "reject")
101
+ return;
102
+ let preview;
103
+ try {
104
+ preview = fsModule.lstatSync(dest);
105
+ }
106
+ catch (error) {
107
+ if (notFound(error))
108
+ return;
109
+ throw error;
110
+ }
111
+ if (preview.isSymbolicLink() || !preview.isFile())
112
+ return;
113
+ const fd = fsModule.openSync(dest, OPEN_READ_FLAGS);
114
+ try {
115
+ const opened = fsModule.fstatSync(fd);
116
+ const current = fsModule.lstatSync(dest);
117
+ if (current.isSymbolicLink() || !current.isFile() || !sameFileIdentity(current, opened)) {
118
+ throw new FsSafeError("path-mismatch", `Atomic replace destination changed while opening: ${dest}`);
119
+ }
120
+ if (opened.nlink > 1) {
121
+ throw new FsSafeError("hardlink", `Hardlinked atomic replace destination not allowed: ${dest}`);
122
+ }
123
+ }
124
+ finally {
125
+ fsModule.closeSync(fd);
126
+ }
127
+ }
128
+ async function readBounded(handle, maxBytes) {
129
+ const chunks = [];
130
+ let position = 0;
131
+ while (position <= maxBytes) {
132
+ const buffer = Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, maxBytes - position + 1));
133
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
134
+ if (bytesRead === 0)
135
+ break;
136
+ position += bytesRead;
137
+ if (position > maxBytes) {
138
+ throw new FsSafeError("too-large", `Atomic replace restore snapshot exceeds maxRestoreBytes (${maxBytes})`);
139
+ }
140
+ chunks.push(buffer.subarray(0, bytesRead));
141
+ }
142
+ return Buffer.concat(chunks, position);
143
+ }
144
+ function readBoundedSync(fsModule, fd, maxBytes) {
145
+ const chunks = [];
146
+ let position = 0;
147
+ while (position <= maxBytes) {
148
+ const buffer = Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, maxBytes - position + 1));
149
+ const bytesRead = fsModule.readSync(fd, buffer, 0, buffer.length, position);
150
+ if (bytesRead === 0)
151
+ break;
152
+ position += bytesRead;
153
+ if (position > maxBytes) {
154
+ throw new FsSafeError("too-large", `Atomic replace restore snapshot exceeds maxRestoreBytes (${maxBytes})`);
155
+ }
156
+ chunks.push(buffer.subarray(0, bytesRead));
157
+ }
158
+ return Buffer.concat(chunks, position);
159
+ }
160
+ async function writeAll(handle, data) {
161
+ await handle.truncate(0);
162
+ let written = 0;
163
+ while (written < data.length) {
164
+ const result = await handle.write(data, written, data.length - written, written);
165
+ if (result.bytesWritten === 0)
166
+ throw new Error("Copy fallback write made no progress");
167
+ written += result.bytesWritten;
168
+ }
169
+ await handle.truncate(data.length);
170
+ }
171
+ function writeAllSync(fsModule, fd, data) {
172
+ fsModule.ftruncateSync(fd, 0);
173
+ let written = 0;
174
+ while (written < data.length) {
175
+ const bytesWritten = fsModule.writeSync(fd, data, written, data.length - written, written);
176
+ if (bytesWritten === 0)
177
+ throw new Error("Copy fallback write made no progress");
178
+ written += bytesWritten;
179
+ }
180
+ fsModule.ftruncateSync(fd, data.length);
181
+ }
182
+ function restoreFailure(writeError, cleanup, restoreError) {
183
+ const primary = writeError instanceof Error ? writeError : new Error(String(writeError));
184
+ const details = { cleanup };
185
+ const cause = restoreError
186
+ ? new AggregateError([primary, restoreError], "copy fallback and original restoration both failed")
187
+ : primary;
188
+ return new FsSafeError("helper-failed", cleanup === "restored"
189
+ ? `Atomic copy fallback failed; original destination restored: ${primary.message}`
190
+ : `Atomic copy fallback failed and original restoration failed: ${primary.message}`, { cause, details });
191
+ }
192
+ async function replacePinnedWithRestore(handle, replacement, maxRestoreBytes) {
193
+ const original = await readBounded(handle, maxRestoreBytes);
194
+ try {
195
+ await writeAll(handle, replacement);
196
+ await handle.sync();
197
+ }
198
+ catch (writeError) {
199
+ try {
200
+ await writeAll(handle, original);
201
+ await handle.sync();
202
+ throw restoreFailure(writeError, "restored");
203
+ }
204
+ catch (restoreError) {
205
+ if (restoreError instanceof FsSafeError && restoreError.details?.cleanup === "restored") {
206
+ throw restoreError;
207
+ }
208
+ throw restoreFailure(writeError, "restore-failed", restoreError);
209
+ }
210
+ }
211
+ }
212
+ function replacePinnedWithRestoreSync(fsModule, fd, replacement, maxRestoreBytes) {
213
+ const original = readBoundedSync(fsModule, fd, maxRestoreBytes);
214
+ try {
215
+ writeAllSync(fsModule, fd, replacement);
216
+ fsModule.fsyncSync(fd);
217
+ }
218
+ catch (writeError) {
219
+ try {
220
+ writeAllSync(fsModule, fd, original);
221
+ fsModule.fsyncSync(fd);
222
+ throw restoreFailure(writeError, "restored");
223
+ }
224
+ catch (restoreError) {
225
+ if (restoreError instanceof FsSafeError && restoreError.details?.cleanup === "restored") {
226
+ throw restoreError;
227
+ }
228
+ throw restoreFailure(writeError, "restore-failed", restoreError);
229
+ }
230
+ }
231
+ }
232
+ export async function copyFallbackReplace(params) {
233
+ const sourcePreview = await params.fsModule.lstat(params.src);
234
+ if (sourcePreview.isSymbolicLink() || !sourcePreview.isFile()) {
235
+ throw new Error(`Refusing copy fallback from non-file source: ${params.src}`);
236
+ }
237
+ const sourceHandle = await params.fsModule.open(params.src, OPEN_READ_FLAGS);
238
+ let destHandle = null;
239
+ try {
240
+ const sourceStat = await sourceHandle.stat();
241
+ const sourceCurrent = await params.fsModule.lstat(params.src);
242
+ if (!sourceStat.isFile() || sourceCurrent.isSymbolicLink() || !sameFileIdentity(sourceStat, sourceCurrent)) {
243
+ throw new FsSafeError("path-mismatch", `Copy fallback source changed while opening: ${params.src}`);
244
+ }
245
+ const replacement = await sourceHandle.readFile();
246
+ if (params.restore === "restore-original") {
247
+ const pinned = await openPinnedDestination(params.fsModule, params.dest, params.destinationHardlinks);
248
+ if (pinned) {
249
+ destHandle = pinned.handle;
250
+ await replacePinnedWithRestore(destHandle, replacement, params.maxRestoreBytes);
251
+ }
252
+ }
253
+ if (!destHandle) {
254
+ const destStat = await params.fsModule.lstat(params.dest).catch((error) => {
255
+ if (notFound(error))
256
+ return null;
257
+ throw error;
258
+ });
259
+ if (destStat?.isSymbolicLink()) {
260
+ throw new FsSafeError("symlink", `Refusing copy fallback through symlink destination: ${params.dest}`);
261
+ }
262
+ if (destStat) {
263
+ await assertDestinationHardlinkPolicy(params.fsModule, params.dest, params.destinationHardlinks);
264
+ await params.fsModule.rm(params.dest, { force: true });
265
+ }
266
+ destHandle = await params.fsModule.open(params.dest, OPEN_WRITE_EXCLUSIVE_FLAGS, sourceStat.mode & 0o777);
267
+ await destHandle.writeFile(replacement);
268
+ }
269
+ }
270
+ finally {
271
+ await destHandle?.close().catch(() => undefined);
272
+ await sourceHandle.close().catch(() => undefined);
273
+ }
274
+ await params.fsModule.unlink(params.src).catch(() => undefined);
275
+ }
276
+ export function copyFallbackReplaceSync(params) {
277
+ const sourcePreview = params.fsModule.lstatSync(params.src);
278
+ if (sourcePreview.isSymbolicLink() || !sourcePreview.isFile()) {
279
+ throw new Error(`Refusing copy fallback from non-file source: ${params.src}`);
280
+ }
281
+ const sourceFd = params.fsModule.openSync(params.src, OPEN_READ_FLAGS);
282
+ let destFd;
283
+ try {
284
+ const sourceStat = params.fsModule.fstatSync(sourceFd);
285
+ const sourceCurrent = params.fsModule.lstatSync(params.src);
286
+ if (!sourceStat.isFile() || sourceCurrent.isSymbolicLink() || !sameFileIdentity(sourceStat, sourceCurrent)) {
287
+ throw new FsSafeError("path-mismatch", `Copy fallback source changed while opening: ${params.src}`);
288
+ }
289
+ const replacement = readBoundedSync(params.fsModule, sourceFd, Number.MAX_SAFE_INTEGER);
290
+ if (params.restore === "restore-original") {
291
+ const pinned = openPinnedDestinationSync(params.fsModule, params.dest, params.destinationHardlinks);
292
+ if (pinned) {
293
+ destFd = pinned.fd;
294
+ replacePinnedWithRestoreSync(params.fsModule, destFd, replacement, params.maxRestoreBytes);
295
+ }
296
+ }
297
+ if (destFd === undefined) {
298
+ let destStat = null;
299
+ try {
300
+ destStat = params.fsModule.lstatSync(params.dest);
301
+ }
302
+ catch (error) {
303
+ if (!notFound(error))
304
+ throw error;
305
+ }
306
+ if (destStat?.isSymbolicLink()) {
307
+ throw new FsSafeError("symlink", `Refusing copy fallback through symlink destination: ${params.dest}`);
308
+ }
309
+ if (destStat) {
310
+ assertDestinationHardlinkPolicySync(params.fsModule, params.dest, params.destinationHardlinks);
311
+ params.fsModule.rmSync(params.dest, { force: true });
312
+ }
313
+ destFd = params.fsModule.openSync(params.dest, OPEN_WRITE_EXCLUSIVE_FLAGS, sourceStat.mode & 0o777);
314
+ writeAllSync(params.fsModule, destFd, replacement);
315
+ }
316
+ }
317
+ finally {
318
+ if (destFd !== undefined) {
319
+ try {
320
+ params.fsModule.closeSync(destFd);
321
+ }
322
+ catch {
323
+ // Best-effort close after fallback replacement.
324
+ }
325
+ }
326
+ try {
327
+ params.fsModule.closeSync(sourceFd);
328
+ }
329
+ catch {
330
+ // Best-effort close after fallback replacement.
331
+ }
332
+ }
333
+ try {
334
+ params.fsModule.unlinkSync(params.src);
335
+ }
336
+ catch {
337
+ // Best-effort cleanup after fallback replacement.
338
+ }
339
+ }
@@ -1,9 +1,11 @@
1
1
  import syncFs from "node:fs";
2
2
  import fs from "node:fs/promises";
3
+ import { type ReplaceFileAtomicRestoreCleanup, type ReplaceFileAtomicRestoreFailureDetails, type ReplaceFileCopyFallbackRestorePolicy, type ReplaceFileDestinationHardlinkPolicy } from "./replace-file-copy-fallback.js";
3
4
  export type ReplaceFileAtomicFileSystem = {
4
5
  promises: Pick<typeof fs, "mkdir" | "chmod" | "writeFile" | "rename" | "copyFile" | "unlink" | "rm" | "open" | "stat" | "lstat">;
5
6
  };
6
- export type ReplaceFileAtomicSyncFileSystem = Pick<typeof syncFs, "mkdirSync" | "chmodSync" | "readFileSync" | "writeFileSync" | "renameSync" | "copyFileSync" | "unlinkSync" | "rmSync" | "openSync" | "fsyncSync" | "closeSync" | "statSync" | "lstatSync">;
7
+ export type ReplaceFileAtomicSyncFileSystem = Pick<typeof syncFs, "mkdirSync" | "chmodSync" | "readFileSync" | "writeFileSync" | "renameSync" | "copyFileSync" | "unlinkSync" | "rmSync" | "openSync" | "fsyncSync" | "closeSync" | "fstatSync" | "statSync" | "lstatSync" | "ftruncateSync" | "readSync" | "writeSync">;
8
+ export type { ReplaceFileAtomicRestoreCleanup, ReplaceFileAtomicRestoreFailureDetails, ReplaceFileCopyFallbackRestorePolicy, ReplaceFileDestinationHardlinkPolicy, };
7
9
  type ReplaceFileAtomicBaseOptions = {
8
10
  filePath: string;
9
11
  content: string | Uint8Array;
@@ -14,6 +16,9 @@ type ReplaceFileAtomicBaseOptions = {
14
16
  renameMaxRetries?: number;
15
17
  renameRetryBaseDelayMs?: number;
16
18
  copyFallbackOnPermissionError?: boolean;
19
+ copyFallbackRestore?: ReplaceFileCopyFallbackRestorePolicy;
20
+ maxRestoreBytes?: number;
21
+ destinationHardlinks?: ReplaceFileDestinationHardlinkPolicy;
17
22
  syncTempFile?: boolean;
18
23
  syncParentDir?: boolean;
19
24
  throwOnCleanupError?: boolean;
@@ -37,5 +42,4 @@ export type ReplaceFileAtomicResult = {
37
42
  };
38
43
  export declare function replaceFileAtomic(options: ReplaceFileAtomicOptions): Promise<ReplaceFileAtomicResult>;
39
44
  export declare function replaceFileAtomicSync(options: ReplaceFileAtomicSyncOptions): ReplaceFileAtomicResult;
40
- export {};
41
45
  //# sourceMappingURL=replace-file.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"replace-file.d.ts","sourceRoot":"","sources":["../src/replace-file.ts"],"names":[],"mappings":"AACA,OAAO,MAAM,MAAM,SAAS,CAAC;AAE7B,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAMlC,MAAM,MAAM,2BAA2B,GAAG;IACxC,QAAQ,EAAE,IAAI,CACZ,OAAO,EAAE,EACP,OAAO,GACP,OAAO,GACP,WAAW,GACX,QAAQ,GACR,UAAU,GACV,QAAQ,GACR,IAAI,GACJ,MAAM,GACN,MAAM,GACN,OAAO,CACV,CAAC;CACH,CAAC;AAEF,MAAM,MAAM,+BAA+B,GAAG,IAAI,CAChD,OAAO,MAAM,EACX,WAAW,GACX,WAAW,GACX,cAAc,GACd,eAAe,GACf,YAAY,GACZ,cAAc,GACd,YAAY,GACZ,QAAQ,GACR,UAAU,GACV,WAAW,GACX,WAAW,GACX,UAAU,GACV,WAAW,CACd,CAAC;AAEF,KAAK,4BAA4B,GAAG;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,UAAU,CAAC;IAC7B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,6BAA6B,CAAC,EAAE,OAAO,CAAC;IACxC,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,mBAAmB,CAAC,EAAE,OAAO,CAAC;CAC/B,CAAC;AAEF,MAAM,MAAM,wBAAwB,GAAG,4BAA4B,GAAG;IACpE,UAAU,CAAC,EAAE,2BAA2B,CAAC;IACzC,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAClF,CAAC;AAEF,MAAM,MAAM,4BAA4B,GAAG,4BAA4B,GAAG;IACxE,UAAU,CAAC,EAAE,+BAA+B,CAAC;IAC7C,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;CACzE,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG;IACpC,MAAM,EAAE,QAAQ,GAAG,eAAe,CAAC;CACpC,CAAC;AAySF,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,wBAAwB,GAChC,OAAO,CAAC,uBAAuB,CAAC,CAMlC;AAyDD,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,4BAA4B,GACpC,uBAAuB,CAiEzB"}
1
+ {"version":3,"file":"replace-file.d.ts","sourceRoot":"","sources":["../src/replace-file.ts"],"names":[],"mappings":"AACA,OAAO,MAAM,MAAM,SAAS,CAAC;AAE7B,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAElC,OAAO,EAKL,KAAK,+BAA+B,EACpC,KAAK,sCAAsC,EAC3C,KAAK,oCAAoC,EACzC,KAAK,oCAAoC,EAC1C,MAAM,iCAAiC,CAAC;AAKzC,MAAM,MAAM,2BAA2B,GAAG;IACxC,QAAQ,EAAE,IAAI,CACZ,OAAO,EAAE,EACP,OAAO,GACP,OAAO,GACP,WAAW,GACX,QAAQ,GACR,UAAU,GACV,QAAQ,GACR,IAAI,GACJ,MAAM,GACN,MAAM,GACN,OAAO,CACV,CAAC;CACH,CAAC;AAEF,MAAM,MAAM,+BAA+B,GAAG,IAAI,CAChD,OAAO,MAAM,EACX,WAAW,GACX,WAAW,GACX,cAAc,GACd,eAAe,GACf,YAAY,GACZ,cAAc,GACd,YAAY,GACZ,QAAQ,GACR,UAAU,GACV,WAAW,GACX,WAAW,GACX,WAAW,GACX,UAAU,GACV,WAAW,GACX,eAAe,GACf,UAAU,GACV,WAAW,CACd,CAAC;AAEF,YAAY,EACV,+BAA+B,EAC/B,sCAAsC,EACtC,oCAAoC,EACpC,oCAAoC,GACrC,CAAC;AAEF,KAAK,4BAA4B,GAAG;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,UAAU,CAAC;IAC7B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,6BAA6B,CAAC,EAAE,OAAO,CAAC;IACxC,mBAAmB,CAAC,EAAE,oCAAoC,CAAC;IAC3D,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,oBAAoB,CAAC,EAAE,oCAAoC,CAAC;IAC5D,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,mBAAmB,CAAC,EAAE,OAAO,CAAC;CAC/B,CAAC;AAEF,MAAM,MAAM,wBAAwB,GAAG,4BAA4B,GAAG;IACpE,UAAU,CAAC,EAAE,2BAA2B,CAAC;IACzC,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAClF,CAAC;AAEF,MAAM,MAAM,4BAA4B,GAAG,4BAA4B,GAAG;IACxE,UAAU,CAAC,EAAE,+BAA+B,CAAC;IAC7C,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;CACzE,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG;IACpC,MAAM,EAAE,QAAQ,GAAG,eAAe,CAAC;CACpC,CAAC;AAoOF,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,wBAAwB,GAChC,OAAO,CAAC,uBAAuB,CAAC,CAOlC;AA6DD,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,4BAA4B,GACpC,uBAAuB,CAsEzB"}
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import syncFs from "node:fs";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
+ import { assertDestinationHardlinkPolicy, assertDestinationHardlinkPolicySync, copyFallbackReplace, copyFallbackReplaceSync, } from "./replace-file-copy-fallback.js";
5
6
  import { assertSafePathPrefix } from "./safe-path-segment.js";
6
7
  import { registerTempPathForExit } from "./temp-cleanup.js";
7
8
  import { serializePathWrite } from "./write-queue.js";
@@ -12,12 +13,6 @@ function isPermissionRenameError(error) {
12
13
  const code = error.code;
13
14
  return code === "EPERM" || code === "EEXIST";
14
15
  }
15
- const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in syncFs.constants;
16
- const OPEN_READ_FLAGS = syncFs.constants.O_RDONLY | (SUPPORTS_NOFOLLOW ? syncFs.constants.O_NOFOLLOW : 0);
17
- const OPEN_WRITE_EXCLUSIVE_FLAGS = syncFs.constants.O_WRONLY |
18
- syncFs.constants.O_CREAT |
19
- syncFs.constants.O_EXCL |
20
- (SUPPORTS_NOFOLLOW ? syncFs.constants.O_NOFOLLOW : 0);
21
16
  async function sleep(ms) {
22
17
  await new Promise((resolve) => setTimeout(resolve, ms));
23
18
  }
@@ -33,7 +28,14 @@ async function renameWithRetry(params) {
33
28
  continue;
34
29
  }
35
30
  if (params.copyFallbackOnPermissionError && isPermissionRenameError(error)) {
36
- await copyFallbackReplace(params.fsModule, params.src, params.dest);
31
+ await copyFallbackReplace({
32
+ fsModule: params.fsModule,
33
+ src: params.src,
34
+ dest: params.dest,
35
+ destinationHardlinks: params.destinationHardlinks,
36
+ restore: params.copyFallbackRestore,
37
+ maxRestoreBytes: params.maxRestoreBytes,
38
+ });
37
39
  return { method: "copy-fallback" };
38
40
  }
39
41
  throw error;
@@ -59,7 +61,14 @@ function renameWithRetrySync(params) {
59
61
  continue;
60
62
  }
61
63
  if (params.copyFallbackOnPermissionError && isPermissionRenameError(error)) {
62
- copyFallbackReplaceSync(params.fsModule, params.src, params.dest);
64
+ copyFallbackReplaceSync({
65
+ fsModule: params.fsModule,
66
+ src: params.src,
67
+ dest: params.dest,
68
+ destinationHardlinks: params.destinationHardlinks,
69
+ restore: params.copyFallbackRestore,
70
+ maxRestoreBytes: params.maxRestoreBytes,
71
+ });
63
72
  return { method: "copy-fallback" };
64
73
  }
65
74
  throw error;
@@ -67,93 +76,21 @@ function renameWithRetrySync(params) {
67
76
  }
68
77
  throw new Error("Atomic rename retry loop exhausted.");
69
78
  }
70
- async function copyFallbackReplace(fsModule, src, dest) {
71
- const sourceStat = await fsModule.lstat(src);
72
- if (sourceStat.isSymbolicLink() || !sourceStat.isFile()) {
73
- throw new Error(`Refusing copy fallback from non-file source: ${src}`);
74
- }
75
- const destStat = await fsModule.lstat(dest).catch((lstatError) => {
76
- if (lstatError.code === "ENOENT") {
77
- return null;
78
- }
79
- throw lstatError;
80
- });
81
- if (destStat?.isSymbolicLink()) {
82
- throw new Error(`Refusing copy fallback through symlink destination: ${dest}`);
83
- }
84
- if (destStat) {
85
- await fsModule.rm(dest, { force: true });
86
- }
87
- let sourceHandle = null;
88
- let destHandle = null;
89
- try {
90
- sourceHandle = await fsModule.open(src, OPEN_READ_FLAGS);
91
- destHandle = await fsModule.open(dest, OPEN_WRITE_EXCLUSIVE_FLAGS, sourceStat.mode & 0o777);
92
- await destHandle.writeFile(await sourceHandle.readFile());
93
- }
94
- finally {
95
- await destHandle?.close().catch(() => undefined);
96
- await sourceHandle?.close().catch(() => undefined);
97
- }
98
- await fsModule.unlink(src).catch(() => undefined);
99
- }
100
- function copyFallbackReplaceSync(fsModule, src, dest) {
101
- const sourceStat = fsModule.lstatSync(src);
102
- if (sourceStat.isSymbolicLink() || !sourceStat.isFile()) {
103
- throw new Error(`Refusing copy fallback from non-file source: ${src}`);
104
- }
105
- let destStat = null;
106
- try {
107
- destStat = fsModule.lstatSync(dest);
108
- }
109
- catch (lstatError) {
110
- if (lstatError.code !== "ENOENT") {
111
- throw lstatError;
112
- }
113
- }
114
- if (destStat?.isSymbolicLink()) {
115
- throw new Error(`Refusing copy fallback through symlink destination: ${dest}`);
116
- }
117
- if (destStat) {
118
- fsModule.rmSync(dest, { force: true });
119
- }
120
- let sourceFd;
121
- let destFd;
122
- try {
123
- sourceFd = fsModule.openSync(src, OPEN_READ_FLAGS);
124
- destFd = fsModule.openSync(dest, OPEN_WRITE_EXCLUSIVE_FLAGS, sourceStat.mode & 0o777);
125
- fsModule.writeFileSync(destFd, fsModule.readFileSync(sourceFd));
126
- }
127
- finally {
128
- if (destFd !== undefined) {
129
- try {
130
- fsModule.closeSync(destFd);
131
- }
132
- catch {
133
- // Best-effort close after fallback replacement.
134
- }
135
- }
136
- if (sourceFd !== undefined) {
137
- try {
138
- fsModule.closeSync(sourceFd);
139
- }
140
- catch {
141
- // Best-effort close after fallback replacement.
142
- }
143
- }
144
- }
145
- try {
146
- fsModule.unlinkSync(src);
147
- }
148
- catch {
149
- // Best-effort cleanup after fallback replacement.
150
- }
151
- }
152
79
  function validateReplaceFilePath(filePath) {
153
80
  if (!filePath || filePath.includes("\0")) {
154
81
  throw new Error("Atomic replace file path must be non-empty.");
155
82
  }
156
83
  }
84
+ function validateRestoreOptions(options) {
85
+ if (options.copyFallbackRestore !== "restore-original")
86
+ return;
87
+ if (options.maxRestoreBytes === undefined) {
88
+ throw new RangeError("maxRestoreBytes is required when copyFallbackRestore is restore-original");
89
+ }
90
+ if (!Number.isSafeInteger(options.maxRestoreBytes) || options.maxRestoreBytes < 0) {
91
+ throw new RangeError("maxRestoreBytes must be a non-negative safe integer");
92
+ }
93
+ }
157
94
  function buildReplaceTempPath(filePath, tempPrefix) {
158
95
  const dir = path.dirname(filePath);
159
96
  const safePrefix = assertSafePathPrefix(tempPrefix ?? ".fs-safe-replace", { label: "atomic replace temp prefix" });
@@ -261,6 +198,7 @@ async function cleanupTempFile(params) {
261
198
  export async function replaceFileAtomic(options) {
262
199
  const filePath = options.filePath;
263
200
  validateReplaceFilePath(filePath);
201
+ validateRestoreOptions(options);
264
202
  return await serializePathWrite(path.resolve(filePath), async () => {
265
203
  return await replaceFileAtomicUnserialized(options);
266
204
  });
@@ -280,12 +218,14 @@ async function replaceFileAtomicUnserialized(options) {
280
218
  try {
281
219
  tempExists = true;
282
220
  await fsModule.writeFile(tempPath, options.content, { mode, flag: "wx" });
221
+ unregisterTempPath.setIdentity(await fsModule.lstat(tempPath));
283
222
  if (options.syncTempFile) {
284
223
  await syncTempFile(fsModule, tempPath);
285
224
  }
286
225
  if (options.beforeRename) {
287
226
  await options.beforeRename({ filePath, tempPath });
288
227
  }
228
+ await assertDestinationHardlinkPolicy(fsModule, filePath, options.destinationHardlinks);
289
229
  const result = await renameWithRetry({
290
230
  fsModule,
291
231
  src: tempPath,
@@ -293,6 +233,9 @@ async function replaceFileAtomicUnserialized(options) {
293
233
  maxRetries: options.renameMaxRetries ?? 0,
294
234
  baseDelayMs: options.renameRetryBaseDelayMs ?? 50,
295
235
  copyFallbackOnPermissionError: options.copyFallbackOnPermissionError === true,
236
+ copyFallbackRestore: options.copyFallbackRestore ?? "none",
237
+ maxRestoreBytes: options.maxRestoreBytes,
238
+ destinationHardlinks: options.destinationHardlinks,
296
239
  });
297
240
  tempExists = false;
298
241
  unregisterTempPath();
@@ -321,6 +264,7 @@ async function replaceFileAtomicUnserialized(options) {
321
264
  export function replaceFileAtomicSync(options) {
322
265
  const filePath = options.filePath;
323
266
  validateReplaceFilePath(filePath);
267
+ validateRestoreOptions(options);
324
268
  const fsModule = options.fileSystem ?? syncFs;
325
269
  const dir = path.dirname(filePath);
326
270
  const dirMode = options.dirMode ?? 0o700;
@@ -339,12 +283,14 @@ export function replaceFileAtomicSync(options) {
339
283
  try {
340
284
  tempExists = true;
341
285
  fsModule.writeFileSync(tempPath, options.content, { mode, flag: "wx" });
286
+ unregisterTempPath.setIdentity(fsModule.lstatSync(tempPath));
342
287
  if (options.syncTempFile) {
343
288
  syncTempFileSync(fsModule, tempPath);
344
289
  }
345
290
  if (options.beforeRename) {
346
291
  options.beforeRename({ filePath, tempPath });
347
292
  }
293
+ assertDestinationHardlinkPolicySync(fsModule, filePath, options.destinationHardlinks);
348
294
  const result = renameWithRetrySync({
349
295
  fsModule,
350
296
  src: tempPath,
@@ -352,6 +298,9 @@ export function replaceFileAtomicSync(options) {
352
298
  maxRetries: options.renameMaxRetries ?? 0,
353
299
  baseDelayMs: options.renameRetryBaseDelayMs ?? 50,
354
300
  copyFallbackOnPermissionError: options.copyFallbackOnPermissionError === true,
301
+ copyFallbackRestore: options.copyFallbackRestore ?? "none",
302
+ maxRestoreBytes: options.maxRestoreBytes,
303
+ destinationHardlinks: options.destinationHardlinks,
355
304
  });
356
305
  tempExists = false;
357
306
  unregisterTempPath();
@@ -7,7 +7,10 @@ export declare const ensureTrailingSep: (value: string) => string;
7
7
  export declare function assertValidRootRelativePath(relativePath: string): void;
8
8
  export declare function expandRelativePathWithHome(relativePath: string): Promise<string>;
9
9
  export declare function resolveRootContext(rootDir: string): Promise<RootContext>;
10
- export declare function resolvePathInRoot(root: RootContext, relativePath: string): Promise<{
10
+ export declare function resolvePathInRoot(root: RootContext, relativePath: string, options?: {
11
+ aliasErrorCode?: "outside-workspace" | "path-alias";
12
+ allowFinalSymlink?: boolean;
13
+ }): Promise<{
11
14
  rootReal: string;
12
15
  rootWithSep: string;
13
16
  resolved: string;
@@ -1 +1 @@
1
- {"version":3,"file":"root-context.d.ts","sourceRoot":"","sources":["../src/root-context.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,WAAW,GAAG;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,eAAO,MAAM,iBAAiB,UAAW,MAAM,WACM,CAAC;AAEtD,wBAAgB,2BAA2B,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAEtE;AAID,wBAAsB,0BAA0B,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAYtF;AAED,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAuB9E;AAED,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,WAAW,EACjB,YAAY,EAAE,MAAM,GACnB,OAAO,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC,CAQtE;AAED,wBAAsB,qBAAqB,CAAC,MAAM,EAAE;IAClD,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;CACtB,GAAG,OAAO,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC,CAEvE"}
1
+ {"version":3,"file":"root-context.d.ts","sourceRoot":"","sources":["../src/root-context.ts"],"names":[],"mappings":"AAQA,MAAM,MAAM,WAAW,GAAG;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,eAAO,MAAM,iBAAiB,UAAW,MAAM,WACM,CAAC;AAEtD,wBAAgB,2BAA2B,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAEtE;AAID,wBAAsB,0BAA0B,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAYtF;AAED,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAuB9E;AAED,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,WAAW,EACjB,YAAY,EAAE,MAAM,EACpB,OAAO,CAAC,EAAE;IACR,cAAc,CAAC,EAAE,mBAAmB,GAAG,YAAY,CAAC;IACpD,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC7B,GACA,OAAO,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC,CA6BtE;AAED,wBAAsB,qBAAqB,CAAC,MAAM,EAAE;IAClD,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;CACtB,GAAG,OAAO,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC,CAEvE"}
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { FsSafeError } from "./errors.js";
5
5
  import { expandHomePrefix } from "./home-dir.js";
6
6
  import { assertNoNulPathInput, isNotFoundPathError, isPathInside } from "./path.js";
7
+ import { ROOT_PATH_ALIAS_POLICIES, resolveRootPath } from "./root-path.js";
7
8
  export const ensureTrailingSep = (value) => value.endsWith(path.sep) ? value : value + path.sep;
8
9
  export function assertValidRootRelativePath(relativePath) {
9
10
  assertNoNulPathInput(relativePath, "relative path contains a NUL byte");
@@ -48,13 +49,31 @@ export async function resolveRootContext(rootDir) {
48
49
  rootWithSep: ensureTrailingSep(rootReal),
49
50
  };
50
51
  }
51
- export async function resolvePathInRoot(root, relativePath) {
52
+ export async function resolvePathInRoot(root, relativePath, options) {
52
53
  assertValidRootRelativePath(relativePath);
53
54
  const expanded = await expandRelativePathWithHome(relativePath);
54
55
  const resolved = path.resolve(root.rootWithSep, expanded);
55
56
  if (!isPathInside(root.rootWithSep, resolved)) {
56
57
  throw new FsSafeError("outside-workspace", "file is outside workspace root");
57
58
  }
59
+ const rawAbsolutePath = path.isAbsolute(expanded)
60
+ ? expanded
61
+ : `${root.rootWithSep}${expanded}`;
62
+ try {
63
+ await resolveRootPath({
64
+ absolutePath: rawAbsolutePath,
65
+ rootPath: root.rootReal,
66
+ rootCanonicalPath: root.rootReal,
67
+ boundaryLabel: "root",
68
+ policy: options?.allowFinalSymlink ? ROOT_PATH_ALIAS_POLICIES.unlinkTarget : undefined,
69
+ });
70
+ }
71
+ catch (error) {
72
+ const code = options?.aliasErrorCode ?? "outside-workspace";
73
+ throw new FsSafeError(code, code === "path-alias" ? "path alias escape blocked" : "file is outside workspace root", {
74
+ cause: error instanceof Error ? error : undefined,
75
+ });
76
+ }
58
77
  return { rootReal: root.rootReal, rootWithSep: root.rootWithSep, resolved };
59
78
  }
60
79
  export async function resolvePathWithinRoot(params) {