@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
package/dist/root-impl.js CHANGED
@@ -11,14 +11,14 @@ import { withAsyncDirectoryGuards } from "./guarded-mutation.js";
11
11
  import { assertMutationNotDenied, mergeDenyMutationPolicies, } from "./deny-mutations.js";
12
12
  import { resolveOpenedFileRealPathForHandle } from "./opened-realpath.js";
13
13
  import { runPinnedWriteHelper, runPinnedWriteWithRenamePolicy, } from "./pinned-write.js";
14
+ import { getNativeBinding } from "./native.js";
14
15
  import { validatePinnedOperationPayload } from "./pinned-operation.js";
15
16
  import { assertNoPathAliasEscape, PATH_ALIAS_POLICIES } from "./path-policy.js";
16
17
  import { assertNoNulPathInput, assertNoUnsafeDeviceReadPath, hasNodeErrorCode, isNotFoundPathError, isPathInside, isSymlinkOpenError, } from "./path.js";
17
18
  import { readOpenedFileSafely } from "./read-opened-file.js";
18
- import { pathStatFromStats } from "./path-stat.js";
19
19
  import { resolveRootPath } from "./root-path.js";
20
- import { assertValidRootRelativePath, ensureTrailingSep, expandRelativePathWithHome, resolvePathInRoot, resolveRootContext, } from "./root-context.js";
21
- import { isAlreadyExistsError, normalizePinnedPathError, normalizePinnedWriteError, } from "./root-errors.js";
20
+ import { assertRootIdentityCurrent, assertValidRootDestinationPath, assertValidRootRelativePath, ensureTrailingSep, expandRelativePathWithHome, resolvePathInRoot, resolveRootContext, } from "./root-context.js";
21
+ import { fileNotFoundError, hardlinkedPathNotAllowedError, isAlreadyExistsError, normalizePinnedPathError, normalizePinnedWriteError, normalizeRemoveGuardError, normalizeRemovePathError, outsideWorkspaceError, } from "./root-errors.js";
22
22
  import { getFsSafeTestHooks } from "./test-hooks.js";
23
23
  import { stringifyJsonDocument } from "./json-stringify.js";
24
24
  import { walkRoot } from "./root-walk.js";
@@ -31,11 +31,11 @@ function logWarn(message) {
31
31
  }
32
32
  }
33
33
  const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants;
34
- const NONBLOCK_OPEN_FLAG = "O_NONBLOCK" in fsConstants ? fsConstants.O_NONBLOCK : 0;
35
- const OPEN_READ_FLAGS = fsConstants.O_RDONLY | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
36
- const OPEN_READ_NONBLOCK_FLAGS = OPEN_READ_FLAGS | NONBLOCK_OPEN_FLAG;
37
- const OPEN_READ_FOLLOW_FLAGS = fsConstants.O_RDONLY;
38
- const OPEN_READ_FOLLOW_NONBLOCK_FLAGS = OPEN_READ_FOLLOW_FLAGS | NONBLOCK_OPEN_FLAG;
34
+ const NONBLOCK_OPEN_FLAG = process.platform !== "win32" && "O_NONBLOCK" in fsConstants ? fsConstants.O_NONBLOCK : 0;
35
+ const OPEN_READ_FLAGS = fsConstants.O_RDONLY |
36
+ (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0) |
37
+ NONBLOCK_OPEN_FLAG;
38
+ const OPEN_READ_FOLLOW_FLAGS = fsConstants.O_RDONLY | NONBLOCK_OPEN_FLAG;
39
39
  const OPEN_WRITE_EXISTING_FLAGS = fsConstants.O_WRONLY | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
40
40
  const OPEN_WRITE_CREATE_FLAGS = fsConstants.O_WRONLY |
41
41
  fsConstants.O_CREAT |
@@ -48,6 +48,21 @@ const OPEN_APPEND_CREATE_FLAGS = fsConstants.O_RDWR |
48
48
  fsConstants.O_EXCL |
49
49
  (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
50
50
  export const DEFAULT_ROOT_MAX_BYTES = 16 * 1024 * 1024;
51
+ function pathStatFromStats(stat) {
52
+ return {
53
+ dev: Number(stat.dev),
54
+ gid: Number(stat.gid),
55
+ ino: Number(stat.ino),
56
+ isDirectory: stat.isDirectory(),
57
+ isFile: stat.isFile(),
58
+ isSymbolicLink: stat.isSymbolicLink(),
59
+ mode: stat.mode,
60
+ mtimeMs: stat.mtimeMs,
61
+ nlink: stat.nlink,
62
+ size: stat.size,
63
+ uid: stat.uid,
64
+ };
65
+ }
51
66
  function openResult(params) {
52
67
  return {
53
68
  handle: params.handle,
@@ -60,11 +75,15 @@ function openResult(params) {
60
75
  async function openVerifiedLocalFile(filePath, options) {
61
76
  assertNoUnsafeDeviceReadPath(filePath);
62
77
  const fsSafeTestHooks = getFsSafeTestHooks();
78
+ let preOpenStat;
63
79
  // Reject directories before opening so we never surface EISDIR to callers (e.g. tool
64
80
  // results that get sent to messaging channels). See openclaw/openclaw#31186.
65
81
  try {
66
- const preStat = await fs.lstat(filePath);
67
- if (preStat.isDirectory()) {
82
+ preOpenStat = await fs.lstat(filePath);
83
+ if (preOpenStat.isSymbolicLink() && options?.symlinks !== "follow-within-root") {
84
+ throw new FsSafeError("symlink", "symlink not allowed");
85
+ }
86
+ if (!preOpenStat.isFile() && !preOpenStat.isSymbolicLink()) {
68
87
  throw new FsSafeError("not-file", "not a file");
69
88
  }
70
89
  await fsSafeTestHooks?.afterPreOpenLstat?.(filePath);
@@ -78,12 +97,8 @@ async function openVerifiedLocalFile(filePath, options) {
78
97
  let handle;
79
98
  try {
80
99
  const openFlags = options?.symlinks === "follow-within-root"
81
- ? options?.nonBlockingRead
82
- ? OPEN_READ_FOLLOW_NONBLOCK_FLAGS
83
- : OPEN_READ_FOLLOW_FLAGS
84
- : options?.nonBlockingRead
85
- ? OPEN_READ_NONBLOCK_FLAGS
86
- : OPEN_READ_FLAGS;
100
+ ? OPEN_READ_FOLLOW_FLAGS
101
+ : OPEN_READ_FLAGS;
87
102
  await fsSafeTestHooks?.beforeOpen?.(filePath, openFlags);
88
103
  handle = await fs.open(filePath, openFlags);
89
104
  try {
@@ -96,7 +111,7 @@ async function openVerifiedLocalFile(filePath, options) {
96
111
  }
97
112
  catch (err) {
98
113
  if (isNotFoundPathError(err)) {
99
- throw new FsSafeError("not-found", "file not found");
114
+ throw fileNotFoundError();
100
115
  }
101
116
  if (isSymlinkOpenError(err)) {
102
117
  throw new FsSafeError("symlink", "symlink open blocked", { cause: err });
@@ -112,8 +127,13 @@ async function openVerifiedLocalFile(filePath, options) {
112
127
  if (!stat.isFile()) {
113
128
  throw new FsSafeError("not-file", "not a file");
114
129
  }
130
+ if (preOpenStat &&
131
+ !preOpenStat.isSymbolicLink() &&
132
+ !sameFileIdentity(stat, preOpenStat)) {
133
+ throw new FsSafeError("path-mismatch", "path changed before open");
134
+ }
115
135
  if (options?.hardlinks === "reject" && stat.nlink > 1) {
116
- throw new FsSafeError("hardlink", "hardlinked path not allowed");
136
+ throw hardlinkedPathNotAllowedError();
117
137
  }
118
138
  if (options?.symlinks === "follow-within-root") {
119
139
  const pathStat = await fs.stat(filePath);
@@ -133,7 +153,7 @@ async function openVerifiedLocalFile(filePath, options) {
133
153
  const realPath = await resolveOpenedFileRealPathForHandle(handle, filePath);
134
154
  const realStat = await fs.stat(realPath);
135
155
  if (options?.hardlinks === "reject" && realStat.nlink > 1) {
136
- throw new FsSafeError("hardlink", "hardlinked path not allowed");
156
+ throw hardlinkedPathNotAllowedError();
137
157
  }
138
158
  if (!sameFileIdentity(stat, realStat)) {
139
159
  throw new FsSafeError("path-mismatch", "path mismatch");
@@ -146,17 +166,19 @@ async function openVerifiedLocalFile(filePath, options) {
146
166
  throw err;
147
167
  }
148
168
  if (isNotFoundPathError(err)) {
149
- throw new FsSafeError("not-found", "file not found");
169
+ throw fileNotFoundError();
150
170
  }
151
171
  throw err;
152
172
  }
153
173
  }
154
174
  class RootHandle {
175
+ rootIdentity;
155
176
  rootDir;
156
177
  rootReal;
157
178
  rootWithSep;
158
179
  defaults;
159
180
  constructor(context, defaults = {}) {
181
+ this.rootIdentity = context.rootIdentity;
160
182
  this.rootDir = context.rootDir;
161
183
  this.rootReal = context.rootReal;
162
184
  this.rootWithSep = context.rootWithSep;
@@ -165,11 +187,19 @@ class RootHandle {
165
187
  get context() {
166
188
  return {
167
189
  rootDir: this.rootDir,
190
+ rootIdentity: this.rootIdentity,
168
191
  rootReal: this.rootReal,
169
192
  rootWithSep: this.rootWithSep,
170
193
  };
171
194
  }
195
+ mutationOptions(options) {
196
+ return {
197
+ ...options,
198
+ denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
199
+ };
200
+ }
172
201
  async resolve(relativePath) {
202
+ assertValidRootDestinationPath(relativePath);
173
203
  return (await resolvePathInRoot(this.context, relativePath, { allowFinalSymlink: true })).resolved;
174
204
  }
175
205
  async open(relativePath, options = {}) {
@@ -209,67 +239,67 @@ class RootHandle {
209
239
  };
210
240
  }
211
241
  async openWritable(relativePath, options = {}) {
242
+ assertValidRootDestinationPath(relativePath);
212
243
  const writeMode = options.writeMode ?? "replace";
213
244
  return await openWritableFileInRoot(this.context, {
214
245
  relativePath,
215
246
  mkdir: this.defaults.mkdir,
216
247
  mode: this.defaults.mode,
217
- ...options,
218
- denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
248
+ ...this.mutationOptions(options),
219
249
  append: writeMode === "append",
220
250
  truncateExisting: writeMode === "replace",
221
251
  });
222
252
  }
223
253
  async append(relativePath, data, options = {}) {
254
+ assertValidRootDestinationPath(relativePath);
224
255
  await appendFileInRoot(this.context, {
225
256
  relativePath,
226
257
  data,
227
258
  mkdir: this.defaults.mkdir,
228
259
  mode: this.defaults.mode,
229
- ...options,
230
- denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
260
+ ...this.mutationOptions(options),
231
261
  });
232
262
  }
233
263
  async remove(relativePath, options = {}) {
234
264
  assertValidRootRelativePath(relativePath);
235
265
  await removePathInRoot(this.context, {
236
266
  relativePath,
237
- denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
267
+ ...this.mutationOptions(options),
238
268
  });
239
269
  }
240
270
  async mkdir(relativePath, options = {}) {
241
- assertValidRootRelativePath(relativePath);
271
+ assertValidRootDestinationPath(relativePath);
242
272
  await mkdirPathInRoot(this.context, {
243
273
  relativePath,
244
- denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
274
+ ...this.mutationOptions(options),
245
275
  });
246
276
  }
247
277
  async ensureRoot(options = {}) {
248
278
  await mkdirPathInRoot(this.context, {
249
279
  relativePath: "",
250
280
  allowRoot: true,
251
- denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
281
+ ...this.mutationOptions(options),
252
282
  });
253
283
  }
254
284
  async write(relativePath, data, options = {}) {
285
+ assertValidRootDestinationPath(relativePath);
255
286
  await writeFileInRoot(this.context, {
256
287
  relativePath,
257
288
  data,
258
289
  mkdir: this.defaults.mkdir,
259
290
  mode: this.defaults.mode,
260
291
  renameIdentity: this.defaults.renameIdentity,
261
- ...options,
262
- denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
292
+ ...this.mutationOptions(options),
263
293
  });
264
294
  }
265
295
  async create(relativePath, data, options = {}) {
296
+ assertValidRootDestinationPath(relativePath);
266
297
  await writeFileInRoot(this.context, {
267
298
  relativePath,
268
299
  data,
269
300
  mkdir: this.defaults.mkdir,
270
301
  mode: this.defaults.mode,
271
- ...options,
272
- denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
302
+ ...this.mutationOptions(options),
273
303
  overwrite: false,
274
304
  });
275
305
  }
@@ -284,15 +314,14 @@ class RootHandle {
284
314
  await this.create(relativePath, trailingNewline ? `${json}\n` : json, writeOptions);
285
315
  }
286
316
  async copyIn(relativePath, sourcePath, options = {}) {
287
- assertValidRootRelativePath(relativePath);
317
+ assertValidRootDestinationPath(relativePath);
288
318
  await copyFileInRoot(this.context, {
289
319
  sourcePath,
290
320
  relativePath,
291
321
  maxBytes: this.defaults.maxBytes,
292
322
  mkdir: this.defaults.mkdir,
293
323
  mode: this.defaults.mode,
294
- ...options,
295
- denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
324
+ ...this.mutationOptions(options),
296
325
  });
297
326
  }
298
327
  async exists(relativePath) {
@@ -319,9 +348,9 @@ class RootHandle {
319
348
  }
320
349
  async move(fromRelative, toRelative, options = {}) {
321
350
  assertValidRootRelativePath(fromRelative);
322
- assertValidRootRelativePath(toRelative);
351
+ assertValidRootDestinationPath(toRelative);
323
352
  validatePinnedOperationPayload({ from: fromRelative, to: toRelative });
324
- const denyMutations = mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations);
353
+ const { denyMutations } = this.mutationOptions(options);
325
354
  await assertMoveMutationAllowed(this.context, {
326
355
  fromRelative,
327
356
  toRelative,
@@ -353,6 +382,8 @@ export async function root(rootDir, defaults = {}) {
353
382
  async function openFileInRoot(root, params) {
354
383
  const { rootWithSep, resolved } = await resolvePathInRoot(root, params.relativePath, {
355
384
  allowFinalSymlink: true,
385
+ rejectUnsafeDeviceReads: true,
386
+ rejectSymlinks: params.symlinks !== "follow-within-root",
356
387
  });
357
388
  let opened;
358
389
  try {
@@ -369,11 +400,11 @@ async function openFileInRoot(root, params) {
369
400
  }
370
401
  if (params.hardlinks !== "allow" && opened.stat.nlink > 1) {
371
402
  await opened.handle.close().catch(() => { });
372
- throw new FsSafeError("hardlink", "hardlinked path not allowed");
403
+ throw hardlinkedPathNotAllowedError();
373
404
  }
374
405
  if (!isPathInside(rootWithSep, opened.realPath)) {
375
406
  await opened.handle.close().catch(() => { });
376
- throw new FsSafeError("outside-workspace", "file is outside workspace root");
407
+ throw outsideWorkspaceError();
377
408
  }
378
409
  return opened;
379
410
  }
@@ -455,33 +486,47 @@ async function verifyAtomicWriteResult(params) {
455
486
  throw new FsSafeError("path-mismatch", "path changed during write");
456
487
  }
457
488
  if (!isPathInside(params.root.rootWithSep, opened.realPath)) {
458
- throw new FsSafeError("outside-workspace", "file is outside workspace root");
489
+ throw outsideWorkspaceError();
459
490
  }
460
491
  }
461
492
  finally {
462
493
  await opened.handle.close().catch(() => { });
463
494
  }
464
495
  }
465
- async function openWritableFileInRoot(root, params) {
466
- const { rootReal, rootWithSep, resolved } = await resolvePathInRoot(root, params.relativePath, { aliasErrorCode: "path-alias" });
467
- await assertMutationNotDenied(resolved, params.denyMutations);
468
- try {
469
- await assertNoPathAliasEscape({
470
- absolutePath: resolved,
471
- rootPath: rootReal,
472
- boundaryLabel: "root",
473
- });
474
- }
475
- catch (err) {
476
- throw new FsSafeError("path-alias", "path alias escape blocked", { cause: err });
496
+ async function resolveGuardedWritePathInRoot(root, params) {
497
+ const resolvedPath = await resolvePathInRoot(root, params.relativePath, {
498
+ aliasErrorCode: "path-alias",
499
+ allowFinalSymlink: params.allowFinalSymlink,
500
+ });
501
+ await assertMutationNotDenied(resolvedPath.resolved, params.denyMutations, params.protectDeniedAncestors ? { protectAncestors: true } : undefined);
502
+ if (await (params.shouldAssertNoPathAlias?.(resolvedPath) ?? true)) {
503
+ try {
504
+ await assertNoPathAliasEscape({
505
+ absolutePath: resolvedPath.resolved,
506
+ rootPath: resolvedPath.rootReal,
507
+ boundaryLabel: "root",
508
+ });
509
+ }
510
+ catch (error) {
511
+ throw new FsSafeError("path-alias", "path alias escape blocked", {
512
+ cause: error instanceof Error ? error : undefined,
513
+ });
514
+ }
477
515
  }
516
+ return resolvedPath;
517
+ }
518
+ async function openWritableFileInRoot(root, params) {
519
+ const { rootReal, rootWithSep, resolved } = await resolveGuardedWritePathInRoot(root, {
520
+ relativePath: params.relativePath,
521
+ denyMutations: params.denyMutations,
522
+ });
478
523
  let ioPath = params.mkdir === false
479
524
  ? resolved
480
525
  : await prepareRootWriteTarget(rootReal, resolved);
481
526
  try {
482
527
  const resolvedRealPath = await fs.realpath(ioPath);
483
528
  if (!isPathInside(rootWithSep, resolvedRealPath)) {
484
- throw new FsSafeError("outside-workspace", "file is outside workspace root");
529
+ throw outsideWorkspaceError();
485
530
  }
486
531
  ioPath = resolvedRealPath;
487
532
  }
@@ -512,7 +557,7 @@ async function openWritableFileInRoot(root, params) {
512
557
  }
513
558
  catch (err) {
514
559
  if (isNotFoundPathError(err)) {
515
- throw new FsSafeError("not-found", "file not found");
560
+ throw fileNotFoundError();
516
561
  }
517
562
  if (isSymlinkOpenError(err)) {
518
563
  throw new FsSafeError("symlink", "symlink open blocked", { cause: err });
@@ -523,13 +568,17 @@ async function openWritableFileInRoot(root, params) {
523
568
  throw err;
524
569
  }
525
570
  let realPathForCleanup = null;
571
+ let createdIdentity = null;
526
572
  try {
527
573
  const stat = await handle.stat();
574
+ if (createdForWrite) {
575
+ createdIdentity = stat;
576
+ }
528
577
  if (!stat.isFile()) {
529
- throw new FsSafeError("invalid-path", "path is not a regular file under root");
578
+ throw new FsSafeError("not-file", "path is not a regular file under root");
530
579
  }
531
580
  if (stat.nlink > 1) {
532
- throw new FsSafeError("hardlink", "hardlinked path not allowed");
581
+ throw hardlinkedPathNotAllowedError();
533
582
  }
534
583
  try {
535
584
  const lstat = await fs.lstat(ioPath);
@@ -552,10 +601,10 @@ async function openWritableFileInRoot(root, params) {
552
601
  throw new FsSafeError("path-mismatch", "path mismatch");
553
602
  }
554
603
  if (realStat.nlink > 1) {
555
- throw new FsSafeError("hardlink", "hardlinked path not allowed");
604
+ throw hardlinkedPathNotAllowedError();
556
605
  }
557
606
  if (!isPathInside(rootWithSep, realPath)) {
558
- throw new FsSafeError("outside-workspace", "file is outside workspace root");
607
+ throw outsideWorkspaceError();
559
608
  }
560
609
  // Truncate only after boundary and identity checks complete. This avoids
561
610
  // irreversible side effects if a symlink target changes before validation.
@@ -575,8 +624,8 @@ async function openWritableFileInRoot(root, params) {
575
624
  const cleanupCreatedPath = createdForWrite && err instanceof FsSafeError;
576
625
  const cleanupPath = realPathForCleanup ?? ioPath;
577
626
  await handle.close().catch(() => { });
578
- if (cleanupCreatedPath) {
579
- await fs.rm(cleanupPath, { force: true }).catch(() => { });
627
+ if (cleanupCreatedPath && createdIdentity) {
628
+ await removePathIfIdentityUnchanged(cleanupPath, createdIdentity).catch(() => { });
580
629
  }
581
630
  throw err;
582
631
  }
@@ -621,7 +670,11 @@ async function appendFileInRoot(root, params) {
621
670
  }
622
671
  async function removePathInRoot(root, params) {
623
672
  validatePinnedOperationPayload({ relativePath: params.relativePath });
624
- const resolved = await resolvePinnedRemovePathInRoot(root, params.relativePath, params.denyMutations);
673
+ const resolved = await resolvePinnedPathInRoot(root, {
674
+ relativePath: params.relativePath,
675
+ denyMutations: params.denyMutations,
676
+ remove: true,
677
+ });
625
678
  try {
626
679
  await removePathFallback(resolved);
627
680
  }
@@ -640,15 +693,16 @@ async function mkdirPathInRoot(root, params) {
640
693
  }
641
694
  }
642
695
  async function writeFileInRoot(root, params) {
643
- if (process.platform === "win32") {
644
- await serializePathWrite(rootWriteQueueKey(root, params.relativePath), async () => {
696
+ await serializePathWrite(rootWriteQueueKey(root, params.relativePath), async () => {
697
+ if (process.platform === "win32" &&
698
+ (params.renameIdentity === "verify-content-with-lock" || !getNativeBinding())) {
645
699
  await writeFileFallback(root, params);
700
+ return;
701
+ }
702
+ const pinned = await resolvePinnedWriteTargetInRoot(root, params.relativePath, params.mode, params.denyMutations);
703
+ await serializePathWrite(pinned.targetPath, async () => {
704
+ await commitPinnedWriteInRoot(root, pinned, params);
646
705
  });
647
- return;
648
- }
649
- const pinned = await resolvePinnedWriteTargetInRoot(root, params.relativePath, params.mode, params.denyMutations);
650
- await serializePathWrite(pinned.targetPath, async () => {
651
- await commitPinnedWriteInRoot(root, pinned, params);
652
706
  });
653
707
  }
654
708
  async function commitPinnedWriteInRoot(root, pinned, params) {
@@ -664,6 +718,7 @@ async function commitPinnedWriteInRoot(root, pinned, params) {
664
718
  mode: params.mode ?? pinned.mode,
665
719
  overwrite: params.overwrite,
666
720
  input: { kind: "buffer", data: params.data, encoding: params.encoding },
721
+ rootIdentity: root.rootIdentity,
667
722
  });
668
723
  }
669
724
  catch (error) {
@@ -701,26 +756,35 @@ async function copyFileInRoot(root, params) {
701
756
  throw new FsSafeError("too-large", `file exceeds limit of ${params.maxBytes} bytes (got ${source.stat.size})`);
702
757
  }
703
758
  try {
704
- const pinned = await resolvePinnedWriteTargetInRoot(root, params.relativePath, params.mode, params.denyMutations);
705
- await serializePathWrite(pinned.targetPath, async () => {
706
- await assertCopySourceCurrent(source);
707
- const identity = await runPinnedWriteHelper({
708
- rootPath: pinned.rootReal,
709
- relativeParentPath: pinned.relativeParentPath,
710
- basename: pinned.basename,
711
- mkdir: params.mkdir !== false,
712
- mode: pinned.mode,
713
- overwrite: true,
714
- maxBytes: params.maxBytes,
715
- input: { kind: "stream", stream: source.handle.createReadStream() },
759
+ await serializePathWrite(rootWriteQueueKey(root, params.relativePath), async () => {
760
+ const pinned = await resolvePinnedWriteTargetInRoot(root, params.relativePath, params.mode, params.denyMutations);
761
+ await serializePathWrite(pinned.targetPath, async () => {
762
+ await assertCopySourceCurrent(source);
763
+ let identity;
764
+ try {
765
+ identity = await runPinnedWriteHelper({
766
+ rootPath: pinned.rootReal,
767
+ relativeParentPath: pinned.relativeParentPath,
768
+ basename: pinned.basename,
769
+ mkdir: params.mkdir !== false,
770
+ mode: pinned.mode,
771
+ overwrite: true,
772
+ maxBytes: params.maxBytes,
773
+ input: { kind: "stream", stream: source.handle.createReadStream() },
774
+ rootIdentity: root.rootIdentity,
775
+ });
776
+ }
777
+ catch (error) {
778
+ throw normalizePinnedWriteError(error);
779
+ }
780
+ try {
781
+ await assertCopySourcePathCurrent(source);
782
+ }
783
+ catch (error) {
784
+ await removePathIfIdentityUnchanged(pinned.targetPath, identity).catch(() => undefined);
785
+ throw error;
786
+ }
716
787
  });
717
- try {
718
- await assertCopySourcePathCurrent(source);
719
- }
720
- catch (error) {
721
- await removeCopyTargetIfUnchanged(pinned.targetPath, identity).catch(() => undefined);
722
- throw error;
723
- }
724
788
  });
725
789
  }
726
790
  finally {
@@ -740,7 +804,7 @@ async function assertCopySourcePathCurrent(source) {
740
804
  throw new FsSafeError("path-mismatch", "copy source path changed");
741
805
  }
742
806
  }
743
- async function removeCopyTargetIfUnchanged(targetPath, identity) {
807
+ async function removePathIfIdentityUnchanged(targetPath, identity) {
744
808
  const parentGuard = await createAsyncDirectoryGuard(path.dirname(targetPath));
745
809
  const current = await fs.lstat(targetPath);
746
810
  if (current.isSymbolicLink() || !sameFileIdentity(current, identity)) {
@@ -751,25 +815,15 @@ async function removeCopyTargetIfUnchanged(targetPath, identity) {
751
815
  });
752
816
  }
753
817
  async function resolvePinnedWriteTargetInRoot(root, relativePath, requestedMode, denyMutations) {
754
- const { rootReal, rootWithSep, resolved } = await resolvePathInRoot(root, relativePath, {
755
- aliasErrorCode: "path-alias",
818
+ const { rootReal, rootWithSep, resolved } = await resolveGuardedWritePathInRoot(root, {
819
+ relativePath,
820
+ denyMutations,
756
821
  });
757
- await assertMutationNotDenied(resolved, denyMutations);
758
- try {
759
- await assertNoPathAliasEscape({
760
- absolutePath: resolved,
761
- rootPath: rootReal,
762
- boundaryLabel: "root",
763
- });
764
- }
765
- catch (err) {
766
- throw new FsSafeError("path-alias", "path alias escape blocked", { cause: err });
767
- }
768
822
  // resolvePathInRoot already enforces isPathInside, so any actual escape
769
823
  // is rejected upstream.
770
824
  const relativeResolved = path.relative(rootReal, resolved);
771
825
  if (path.isAbsolute(relativeResolved)) {
772
- throw new FsSafeError("outside-workspace", "file is outside workspace root");
826
+ throw outsideWorkspaceError();
773
827
  }
774
828
  const relativePosix = relativeResolved
775
829
  ? relativeResolved.split(path.sep).join(path.posix.sep)
@@ -784,11 +838,12 @@ async function resolvePinnedWriteTargetInRoot(root, relativePath, requestedMode,
784
838
  relativePath,
785
839
  hardlinks: "reject",
786
840
  nonBlockingRead: true,
841
+ symlinks: "follow-within-root",
787
842
  });
788
843
  try {
789
844
  mode = requestedMode ?? (opened.stat.mode & 0o777);
790
845
  if (!isPathInside(rootWithSep, opened.realPath)) {
791
- throw new FsSafeError("outside-workspace", "file is outside workspace root");
846
+ throw outsideWorkspaceError();
792
847
  }
793
848
  }
794
849
  finally {
@@ -812,17 +867,9 @@ async function resolvePinnedPathInRoot(root, params) {
812
867
  return await resolvePinnedOperationPathInRoot(root, {
813
868
  allowRoot: params.allowRoot,
814
869
  denyMutations: params.denyMutations,
815
- protectDenyMutationAncestors: false,
870
+ protectDenyMutationAncestors: params.remove === true,
816
871
  relativePath: params.relativePath,
817
- policy: PATH_ALIAS_POLICIES.strict,
818
- });
819
- }
820
- async function resolvePinnedRemovePathInRoot(root, relativePath, denyMutations) {
821
- return await resolvePinnedOperationPathInRoot(root, {
822
- denyMutations,
823
- protectDenyMutationAncestors: true,
824
- relativePath,
825
- policy: PATH_ALIAS_POLICIES.unlinkTarget,
872
+ policy: params.remove ? PATH_ALIAS_POLICIES.unlinkTarget : PATH_ALIAS_POLICIES.strict,
826
873
  });
827
874
  }
828
875
  async function resolvePinnedOperationPathInRoot(root, params) {
@@ -840,11 +887,11 @@ async function resolvePinnedOperationPathInRoot(root, params) {
840
887
  relativeResolved === "." ||
841
888
  firstSegment === ".." ||
842
889
  path.isAbsolute(relativeResolved)) {
843
- throw new FsSafeError("outside-workspace", "file is outside workspace root");
890
+ throw outsideWorkspaceError();
844
891
  }
845
892
  const relativePosix = relativeResolved.split(path.sep).join(path.posix.sep);
846
893
  if (!isPathInside(resolved.rootWithSep, resolved.canonicalPath)) {
847
- throw new FsSafeError("outside-workspace", "file is outside workspace root");
894
+ throw outsideWorkspaceError();
848
895
  }
849
896
  await assertMutationNotDenied(resolved.canonicalPath, params.denyMutations, {
850
897
  protectAncestors: params.protectDenyMutationAncestors,
@@ -852,6 +899,7 @@ async function resolvePinnedOperationPathInRoot(root, params) {
852
899
  return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix };
853
900
  }
854
901
  async function resolvePinnedRootPathInRoot(root, params) {
902
+ await assertRootIdentityCurrent(root);
855
903
  const rootReal = root.rootReal;
856
904
  let resolved;
857
905
  try {
@@ -876,11 +924,25 @@ async function resolvePinnedRootPathInRoot(root, params) {
876
924
  canonicalPath: resolved.canonicalPath,
877
925
  };
878
926
  }
927
+ async function prepareRemoveGuard(targetPath) {
928
+ try {
929
+ const guard = await createAsyncDirectoryGuard(path.dirname(targetPath));
930
+ await getFsSafeTestHooks()?.beforeRootFallbackMutation?.("remove", targetPath);
931
+ await assertAsyncDirectoryGuard(guard);
932
+ return guard;
933
+ }
934
+ catch (error) {
935
+ throw normalizeRemoveGuardError(error);
936
+ }
937
+ }
879
938
  async function removePathFallback(resolved) {
880
- const guard = await createAsyncDirectoryGuard(path.dirname(resolved.resolved));
881
- await getFsSafeTestHooks()?.beforeRootFallbackMutation?.("remove", resolved.resolved);
882
- await assertAsyncDirectoryGuard(guard);
883
- await ((await fs.lstat(resolved.resolved)).isDirectory() ? fs.rmdir(resolved.resolved) : fs.rm(resolved.resolved));
939
+ const guard = await prepareRemoveGuard(resolved.resolved);
940
+ try {
941
+ await ((await fs.lstat(resolved.resolved)).isDirectory() ? fs.rmdir(resolved.resolved) : fs.rm(resolved.resolved));
942
+ }
943
+ catch (error) {
944
+ throw normalizeRemovePathError(error);
945
+ }
884
946
  await assertAsyncDirectoryGuard(guard).catch(() => undefined);
885
947
  }
886
948
  async function mkdirPathFallback(resolved) {
@@ -892,13 +954,13 @@ async function mkdirPathFallback(resolved) {
892
954
  async function statPathFallback(root, relativePath) {
893
955
  const resolved = await resolvePinnedPathInRoot(root, { relativePath, allowRoot: true });
894
956
  try {
895
- return pathStatFromStats(await fs.lstat(resolved.resolved));
957
+ const stat = pathStatFromStats(await fs.lstat(resolved.resolved));
958
+ await assertRootIdentityCurrent(root);
959
+ return stat;
896
960
  }
897
961
  catch (error) {
898
962
  if (isNotFoundPathError(error)) {
899
- throw new FsSafeError("not-found", "file not found", {
900
- cause: error instanceof Error ? error : undefined,
901
- });
963
+ throw fileNotFoundError(error instanceof Error ? error : undefined);
902
964
  }
903
965
  throw error;
904
966
  }
@@ -909,6 +971,7 @@ async function listPathFallback(root, relativePath, withFileTypes) {
909
971
  const names = await fs.readdir(resolved.resolved);
910
972
  const sortedNames = names.toSorted();
911
973
  if (!withFileTypes) {
974
+ await assertRootIdentityCurrent(root);
912
975
  return sortedNames;
913
976
  }
914
977
  const entries = [];
@@ -918,6 +981,7 @@ async function listPathFallback(root, relativePath, withFileTypes) {
918
981
  ...pathStatFromStats(await fs.lstat(path.join(resolved.resolved, name))),
919
982
  });
920
983
  }
984
+ await assertRootIdentityCurrent(root);
921
985
  return entries;
922
986
  }
923
987
  catch (error) {
@@ -930,6 +994,8 @@ async function listPathFallback(root, relativePath, withFileTypes) {
930
994
  }
931
995
  }
932
996
  async function assertMoveMutationAllowed(root, params) {
997
+ // Keep this preflight separate from the pinned resolutions in movePathFallback:
998
+ // mutation denials must take precedence over source alias or identity failures.
933
999
  const source = await resolvePathInRoot(root, params.fromRelative, {
934
1000
  aliasErrorCode: "path-alias",
935
1001
  allowFinalSymlink: true,
@@ -951,40 +1017,29 @@ async function movePathFallback(root, params) {
951
1017
  relativePath: params.fromRelative,
952
1018
  policy: PATH_ALIAS_POLICIES.strict,
953
1019
  });
954
- const target = await resolvePathInRoot(root, params.toRelative, {
955
- aliasErrorCode: "path-alias",
956
- allowFinalSymlink: true,
957
- });
958
- await assertMutationNotDenied(target.resolved, params.denyMutations, { protectAncestors: true });
959
- await resolvePinnedRootPathInRoot(root, {
1020
+ const target = await resolveGuardedWritePathInRoot(root, {
960
1021
  relativePath: params.toRelative,
961
- policy: PATH_ALIAS_POLICIES.unlinkTarget,
962
- });
963
- const targetStat = await fs.lstat(target.resolved).catch(() => undefined);
964
- const replacesFinalSymlink = process.platform !== "win32" && params.overwrite && targetStat?.isSymbolicLink() === true;
965
- if (!replacesFinalSymlink) {
966
- try {
967
- await assertNoPathAliasEscape({
968
- absolutePath: target.resolved,
969
- rootPath: target.rootReal,
970
- boundaryLabel: "root",
971
- });
972
- }
973
- catch (error) {
974
- throw new FsSafeError("path-alias", "path alias escape blocked", {
975
- cause: error instanceof Error ? error : undefined,
1022
+ denyMutations: params.denyMutations,
1023
+ allowFinalSymlink: true,
1024
+ protectDeniedAncestors: true,
1025
+ shouldAssertNoPathAlias: async (resolvedTarget) => {
1026
+ await resolvePinnedRootPathInRoot(root, {
1027
+ relativePath: params.toRelative,
1028
+ policy: PATH_ALIAS_POLICIES.unlinkTarget,
976
1029
  });
977
- }
978
- }
1030
+ const targetStat = await fs.lstat(resolvedTarget.resolved).catch(() => undefined);
1031
+ return !(process.platform !== "win32" &&
1032
+ params.overwrite &&
1033
+ targetStat?.isSymbolicLink() === true);
1034
+ },
1035
+ });
979
1036
  let sourceStat;
980
1037
  try {
981
1038
  sourceStat = await fs.lstat(source.resolved);
982
1039
  }
983
1040
  catch (error) {
984
1041
  if (isNotFoundPathError(error)) {
985
- throw new FsSafeError("not-found", "file not found", {
986
- cause: error instanceof Error ? error : undefined,
987
- });
1042
+ throw fileNotFoundError(error instanceof Error ? error : undefined);
988
1043
  }
989
1044
  throw error;
990
1045
  }
@@ -992,7 +1047,7 @@ async function movePathFallback(root, params) {
992
1047
  throw new FsSafeError("symlink", "symlink not allowed");
993
1048
  }
994
1049
  if (sourceStat.isFile() && sourceStat.nlink > 1) {
995
- throw new FsSafeError("hardlink", "hardlinked path not allowed");
1050
+ throw hardlinkedPathNotAllowedError();
996
1051
  }
997
1052
  if (!params.overwrite && sourceStat.isDirectory()) {
998
1053
  throw new FsSafeError("invalid-path", "directory moves require overwrite: true");
@@ -1021,9 +1076,7 @@ async function movePathFallback(root, params) {
1021
1076
  }
1022
1077
  catch (error) {
1023
1078
  if (isNotFoundPathError(error)) {
1024
- throw new FsSafeError("not-found", "file not found", {
1025
- cause: error instanceof Error ? error : undefined,
1026
- });
1079
+ throw fileNotFoundError(error instanceof Error ? error : undefined);
1027
1080
  }
1028
1081
  if (hasNodeErrorCode(error, "EEXIST")) {
1029
1082
  throw new FsSafeError("already-exists", "destination exists", {
@@ -1089,30 +1142,22 @@ async function writeFileFallback(root, params) {
1089
1142
  }
1090
1143
  }
1091
1144
  async function writeMissingFileFallback(root, params) {
1092
- const { rootReal, resolved } = await resolvePathInRoot(root, params.relativePath, {
1093
- aliasErrorCode: "path-alias",
1145
+ const { rootReal, resolved } = await resolveGuardedWritePathInRoot(root, {
1146
+ relativePath: params.relativePath,
1147
+ denyMutations: params.denyMutations,
1094
1148
  });
1095
- await assertMutationNotDenied(resolved, params.denyMutations);
1096
- try {
1097
- await assertNoPathAliasEscape({
1098
- absolutePath: resolved,
1099
- rootPath: rootReal,
1100
- boundaryLabel: "root",
1101
- });
1102
- }
1103
- catch (err) {
1104
- throw new FsSafeError("path-alias", "path alias escape blocked", { cause: err });
1105
- }
1106
1149
  const targetPath = params.mkdir === false
1107
1150
  ? resolved
1108
1151
  : await prepareRootWriteTarget(rootReal, resolved);
1109
1152
  const parentGuard = await createAsyncDirectoryGuard(path.dirname(targetPath));
1110
1153
  let created = false;
1154
+ let createdIdentity;
1111
1155
  try {
1112
1156
  const { handle, writtenStat } = await withAsyncDirectoryGuards([parentGuard], async () => {
1113
1157
  const handle = await fs.open(targetPath, OPEN_WRITE_CREATE_FLAGS, params.mode ?? 0o600);
1114
1158
  created = true;
1115
1159
  try {
1160
+ createdIdentity = await handle.stat();
1116
1161
  if (typeof params.data === "string") {
1117
1162
  await handle.writeFile(params.data, params.encoding ?? "utf8");
1118
1163
  }
@@ -1148,8 +1193,8 @@ async function writeMissingFileFallback(root, params) {
1148
1193
  throw err;
1149
1194
  }
1150
1195
  finally {
1151
- if (created) {
1152
- await fs.rm(targetPath, { force: true }).catch(() => undefined);
1196
+ if (created && createdIdentity) {
1197
+ await removePathIfIdentityUnchanged(targetPath, createdIdentity).catch(() => undefined);
1153
1198
  }
1154
1199
  }
1155
1200
  }