@openclaw/fs-safe 0.5.1 → 0.5.3

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 (231) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +19 -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 +37 -6
  8. package/dist/archive-errors.d.ts +1 -0
  9. package/dist/archive-errors.d.ts.map +1 -1
  10. package/dist/archive-errors.js +4 -0
  11. package/dist/archive-input.d.ts +7 -0
  12. package/dist/archive-input.d.ts.map +1 -1
  13. package/dist/archive-input.js +11 -1
  14. package/dist/archive-limits.js +1 -1
  15. package/dist/archive-native.d.ts.map +1 -1
  16. package/dist/archive-native.js +12 -6
  17. package/dist/archive-policy.d.ts.map +1 -1
  18. package/dist/archive-policy.js +2 -1
  19. package/dist/archive-read.d.ts.map +1 -1
  20. package/dist/archive-read.js +62 -37
  21. package/dist/archive-staging.d.ts.map +1 -1
  22. package/dist/archive-staging.js +3 -2
  23. package/dist/archive-tar-meta.d.ts.map +1 -1
  24. package/dist/archive-tar-meta.js +15 -1
  25. package/dist/archive-tar-runtime.d.ts +1 -0
  26. package/dist/archive-tar-runtime.d.ts.map +1 -1
  27. package/dist/archive-tar-runtime.js +11 -0
  28. package/dist/archive-tar.d.ts.map +1 -1
  29. package/dist/archive-tar.js +8 -4
  30. package/dist/archive-zip-entry.d.ts +7 -1
  31. package/dist/archive-zip-entry.d.ts.map +1 -1
  32. package/dist/archive-zip-entry.js +11 -1
  33. package/dist/archive-zip-integrity.d.ts +5 -0
  34. package/dist/archive-zip-integrity.d.ts.map +1 -0
  35. package/dist/archive-zip-integrity.js +54 -0
  36. package/dist/archive-zip-preflight.d.ts.map +1 -1
  37. package/dist/archive-zip-preflight.js +12 -1
  38. package/dist/archive.d.ts.map +1 -1
  39. package/dist/archive.js +9 -5
  40. package/dist/bounded-read-stream.d.ts.map +1 -1
  41. package/dist/bounded-read-stream.js +13 -2
  42. package/dist/deny-mutations.d.ts +1 -0
  43. package/dist/deny-mutations.d.ts.map +1 -1
  44. package/dist/deny-mutations.js +6 -39
  45. package/dist/device-path.d.ts +1 -0
  46. package/dist/device-path.d.ts.map +1 -1
  47. package/dist/device-path.js +6 -6
  48. package/dist/directory-guard.d.ts.map +1 -1
  49. package/dist/directory-guard.js +5 -4
  50. package/dist/error-detail.d.ts +2 -0
  51. package/dist/error-detail.d.ts.map +1 -0
  52. package/dist/error-detail.js +4 -0
  53. package/dist/errors.d.ts +1 -1
  54. package/dist/errors.d.ts.map +1 -1
  55. package/dist/errors.js +4 -0
  56. package/dist/file-hash.d.ts.map +1 -1
  57. package/dist/file-hash.js +4 -1
  58. package/dist/file-lock-sync.d.ts.map +1 -1
  59. package/dist/file-lock-sync.js +88 -35
  60. package/dist/file-store-boundary.d.ts +6 -0
  61. package/dist/file-store-boundary.d.ts.map +1 -1
  62. package/dist/file-store-boundary.js +20 -5
  63. package/dist/file-store-sync-write.d.ts +9 -0
  64. package/dist/file-store-sync-write.d.ts.map +1 -0
  65. package/dist/file-store-sync-write.js +97 -0
  66. package/dist/file-store.d.ts.map +1 -1
  67. package/dist/file-store.js +62 -139
  68. package/dist/filename.d.ts.map +1 -1
  69. package/dist/filename.js +27 -0
  70. package/dist/guarded-mkdir.d.ts.map +1 -1
  71. package/dist/guarded-mkdir.js +5 -6
  72. package/dist/install-path.js +1 -1
  73. package/dist/json-document-store.d.ts.map +1 -1
  74. package/dist/json-document-store.js +19 -13
  75. package/dist/json.d.ts.map +1 -1
  76. package/dist/json.js +1 -3
  77. package/dist/local-file-access.d.ts +3 -2
  78. package/dist/local-file-access.d.ts.map +1 -1
  79. package/dist/local-file-access.js +12 -6
  80. package/dist/local-roots.d.ts.map +1 -1
  81. package/dist/local-roots.js +35 -30
  82. package/dist/move-path.d.ts.map +1 -1
  83. package/dist/move-path.js +51 -12
  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 +1 -0
  92. package/dist/native-binding.d.ts.map +1 -1
  93. package/dist/native-operations.d.ts.map +1 -1
  94. package/dist/native-operations.js +14 -1
  95. package/dist/native-pinned-write.d.ts.map +1 -1
  96. package/dist/native-pinned-write.js +44 -17
  97. package/dist/output-sibling.d.ts.map +1 -1
  98. package/dist/output-sibling.js +6 -5
  99. package/dist/path-policy.d.ts.map +1 -1
  100. package/dist/path-policy.js +1 -7
  101. package/dist/path.d.ts.map +1 -1
  102. package/dist/path.js +4 -0
  103. package/dist/permission-exec.d.ts +6 -0
  104. package/dist/permission-exec.d.ts.map +1 -0
  105. package/dist/permission-exec.js +28 -0
  106. package/dist/permissions-windows.d.ts +56 -0
  107. package/dist/permissions-windows.d.ts.map +1 -0
  108. package/dist/permissions-windows.js +285 -0
  109. package/dist/permissions.d.ts +3 -46
  110. package/dist/permissions.d.ts.map +1 -1
  111. package/dist/permissions.js +17 -328
  112. package/dist/pinned-open.d.ts.map +1 -1
  113. package/dist/pinned-open.js +21 -4
  114. package/dist/pinned-write.js +2 -1
  115. package/dist/private-temp-workspace.d.ts.map +1 -1
  116. package/dist/private-temp-workspace.js +45 -4
  117. package/dist/read-error.d.ts +2 -0
  118. package/dist/read-error.d.ts.map +1 -0
  119. package/dist/read-error.js +11 -0
  120. package/dist/regular-file.d.ts.map +1 -1
  121. package/dist/regular-file.js +21 -2
  122. package/dist/replace-directory.d.ts.map +1 -1
  123. package/dist/replace-directory.js +10 -1
  124. package/dist/replace-file-copy-fallback.d.ts +3 -0
  125. package/dist/replace-file-copy-fallback.d.ts.map +1 -1
  126. package/dist/replace-file-copy-fallback.js +18 -11
  127. package/dist/replace-file-descriptor.d.ts +33 -0
  128. package/dist/replace-file-descriptor.d.ts.map +1 -0
  129. package/dist/replace-file-descriptor.js +96 -0
  130. package/dist/replace-file.d.ts +9 -2
  131. package/dist/replace-file.d.ts.map +1 -1
  132. package/dist/replace-file.js +64 -72
  133. package/dist/root-context.d.ts +8 -0
  134. package/dist/root-context.d.ts.map +1 -1
  135. package/dist/root-context.js +43 -2
  136. package/dist/root-errors.d.ts +7 -0
  137. package/dist/root-errors.d.ts.map +1 -1
  138. package/dist/root-errors.js +46 -1
  139. package/dist/root-file.d.ts +1 -0
  140. package/dist/root-file.d.ts.map +1 -1
  141. package/dist/root-file.js +2 -0
  142. package/dist/root-impl.d.ts.map +1 -1
  143. package/dist/root-impl.js +216 -169
  144. package/dist/root-path-symlink.d.ts +3 -0
  145. package/dist/root-path-symlink.d.ts.map +1 -0
  146. package/dist/root-path-symlink.js +35 -0
  147. package/dist/root-path.d.ts +1 -0
  148. package/dist/root-path.d.ts.map +1 -1
  149. package/dist/root-path.js +129 -190
  150. package/dist/root-walk.d.ts.map +1 -1
  151. package/dist/root-walk.js +15 -0
  152. package/dist/safe-path-segment.d.ts +2 -0
  153. package/dist/safe-path-segment.d.ts.map +1 -1
  154. package/dist/safe-path-segment.js +12 -0
  155. package/dist/secret-file.d.ts.map +1 -1
  156. package/dist/secret-file.js +29 -6
  157. package/dist/secret-read-async.d.ts.map +1 -1
  158. package/dist/secret-read-async.js +5 -1
  159. package/dist/secure-temp-dir.d.ts.map +1 -1
  160. package/dist/secure-temp-dir.js +6 -1
  161. package/dist/short-path.d.ts +2 -0
  162. package/dist/short-path.d.ts.map +1 -0
  163. package/dist/short-path.js +7 -0
  164. package/dist/sidecar-lock-acquire.d.ts +30 -0
  165. package/dist/sidecar-lock-acquire.d.ts.map +1 -0
  166. package/dist/sidecar-lock-acquire.js +260 -0
  167. package/dist/sidecar-lock-policy.d.ts +3 -0
  168. package/dist/sidecar-lock-policy.d.ts.map +1 -1
  169. package/dist/sidecar-lock-policy.js +20 -3
  170. package/dist/sidecar-lock-reclaim.d.ts +5 -1
  171. package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
  172. package/dist/sidecar-lock-reclaim.js +59 -9
  173. package/dist/sidecar-lock.d.ts.map +1 -1
  174. package/dist/sidecar-lock.js +27 -226
  175. package/dist/symlink-parents.d.ts.map +1 -1
  176. package/dist/symlink-parents.js +9 -8
  177. package/dist/temp-target.d.ts.map +1 -1
  178. package/dist/temp-target.js +15 -4
  179. package/dist/test-hooks.d.ts +1 -0
  180. package/dist/test-hooks.d.ts.map +1 -1
  181. package/dist/timing.d.ts +2 -0
  182. package/dist/timing.d.ts.map +1 -1
  183. package/dist/timing.js +9 -0
  184. package/dist/trash.d.ts.map +1 -1
  185. package/dist/trash.js +10 -1
  186. package/dist/walk.d.ts.map +1 -1
  187. package/dist/walk.js +15 -0
  188. package/docs/advanced.md +7 -1
  189. package/docs/archive.md +27 -20
  190. package/docs/assets/readme-banner.jpg +0 -0
  191. package/docs/atomic.md +48 -19
  192. package/docs/errors.md +34 -8
  193. package/docs/file-store.md +14 -1
  194. package/docs/filename.md +17 -16
  195. package/docs/index.md +1 -0
  196. package/docs/install-path.md +9 -6
  197. package/docs/json.md +8 -2
  198. package/docs/local-roots.md +79 -103
  199. package/docs/native-helper.md +11 -5
  200. package/docs/native.md +2 -2
  201. package/docs/output.md +8 -3
  202. package/docs/path-scope.md +4 -1
  203. package/docs/path.md +7 -3
  204. package/docs/permissions.md +8 -0
  205. package/docs/public-api.md +109 -0
  206. package/docs/quickstart.md +6 -2
  207. package/docs/reading.md +13 -10
  208. package/docs/root.md +26 -4
  209. package/docs/secret-file.md +21 -4
  210. package/docs/security-model.md +21 -4
  211. package/docs/sidecar-lock.md +15 -5
  212. package/docs/temp.md +42 -5
  213. package/docs/test-hooks.md +2 -0
  214. package/docs/types.md +32 -15
  215. package/docs/writing.md +25 -12
  216. package/package.json +15 -4
  217. package/dist/archive-file-io.d.ts +0 -9
  218. package/dist/archive-file-io.d.ts.map +0 -1
  219. package/dist/archive-file-io.js +0 -11
  220. package/dist/file-store-source.d.ts +0 -5
  221. package/dist/file-store-source.d.ts.map +0 -1
  222. package/dist/file-store-source.js +0 -30
  223. package/dist/file-url.d.ts +0 -2
  224. package/dist/file-url.d.ts.map +0 -1
  225. package/dist/file-url.js +0 -1
  226. package/dist/path-stat.d.ts +0 -4
  227. package/dist/path-stat.d.ts.map +0 -1
  228. package/dist/path-stat.js +0 -15
  229. package/dist/windows-permissions-native.d.ts +0 -9
  230. package/dist/windows-permissions-native.d.ts.map +0 -1
  231. 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,14 +75,17 @@ 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
- await fsSafeTestHooks?.afterPreOpenLstat?.(filePath);
71
89
  }
72
90
  catch (err) {
73
91
  if (err instanceof FsSafeError) {
@@ -75,15 +93,14 @@ async function openVerifiedLocalFile(filePath, options) {
75
93
  }
76
94
  // ENOENT and other lstat errors: fall through and let fs.open handle.
77
95
  }
96
+ if (preOpenStat) {
97
+ await fsSafeTestHooks?.afterPreOpenLstat?.(filePath);
98
+ }
78
99
  let handle;
79
100
  try {
80
101
  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;
102
+ ? OPEN_READ_FOLLOW_FLAGS
103
+ : OPEN_READ_FLAGS;
87
104
  await fsSafeTestHooks?.beforeOpen?.(filePath, openFlags);
88
105
  handle = await fs.open(filePath, openFlags);
89
106
  try {
@@ -96,7 +113,7 @@ async function openVerifiedLocalFile(filePath, options) {
96
113
  }
97
114
  catch (err) {
98
115
  if (isNotFoundPathError(err)) {
99
- throw new FsSafeError("not-found", "file not found");
116
+ throw fileNotFoundError();
100
117
  }
101
118
  if (isSymlinkOpenError(err)) {
102
119
  throw new FsSafeError("symlink", "symlink open blocked", { cause: err });
@@ -112,8 +129,13 @@ async function openVerifiedLocalFile(filePath, options) {
112
129
  if (!stat.isFile()) {
113
130
  throw new FsSafeError("not-file", "not a file");
114
131
  }
132
+ if (preOpenStat &&
133
+ !preOpenStat.isSymbolicLink() &&
134
+ !sameFileIdentity(stat, preOpenStat)) {
135
+ throw new FsSafeError("path-mismatch", "path changed before open");
136
+ }
115
137
  if (options?.hardlinks === "reject" && stat.nlink > 1) {
116
- throw new FsSafeError("hardlink", "hardlinked path not allowed");
138
+ throw hardlinkedPathNotAllowedError();
117
139
  }
118
140
  if (options?.symlinks === "follow-within-root") {
119
141
  const pathStat = await fs.stat(filePath);
@@ -133,7 +155,7 @@ async function openVerifiedLocalFile(filePath, options) {
133
155
  const realPath = await resolveOpenedFileRealPathForHandle(handle, filePath);
134
156
  const realStat = await fs.stat(realPath);
135
157
  if (options?.hardlinks === "reject" && realStat.nlink > 1) {
136
- throw new FsSafeError("hardlink", "hardlinked path not allowed");
158
+ throw hardlinkedPathNotAllowedError();
137
159
  }
138
160
  if (!sameFileIdentity(stat, realStat)) {
139
161
  throw new FsSafeError("path-mismatch", "path mismatch");
@@ -146,17 +168,19 @@ async function openVerifiedLocalFile(filePath, options) {
146
168
  throw err;
147
169
  }
148
170
  if (isNotFoundPathError(err)) {
149
- throw new FsSafeError("not-found", "file not found");
171
+ throw fileNotFoundError();
150
172
  }
151
173
  throw err;
152
174
  }
153
175
  }
154
176
  class RootHandle {
177
+ rootIdentity;
155
178
  rootDir;
156
179
  rootReal;
157
180
  rootWithSep;
158
181
  defaults;
159
182
  constructor(context, defaults = {}) {
183
+ this.rootIdentity = context.rootIdentity;
160
184
  this.rootDir = context.rootDir;
161
185
  this.rootReal = context.rootReal;
162
186
  this.rootWithSep = context.rootWithSep;
@@ -165,11 +189,19 @@ class RootHandle {
165
189
  get context() {
166
190
  return {
167
191
  rootDir: this.rootDir,
192
+ rootIdentity: this.rootIdentity,
168
193
  rootReal: this.rootReal,
169
194
  rootWithSep: this.rootWithSep,
170
195
  };
171
196
  }
197
+ mutationOptions(options) {
198
+ return {
199
+ ...options,
200
+ denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
201
+ };
202
+ }
172
203
  async resolve(relativePath) {
204
+ assertValidRootDestinationPath(relativePath);
173
205
  return (await resolvePathInRoot(this.context, relativePath, { allowFinalSymlink: true })).resolved;
174
206
  }
175
207
  async open(relativePath, options = {}) {
@@ -209,67 +241,67 @@ class RootHandle {
209
241
  };
210
242
  }
211
243
  async openWritable(relativePath, options = {}) {
244
+ assertValidRootDestinationPath(relativePath);
212
245
  const writeMode = options.writeMode ?? "replace";
213
246
  return await openWritableFileInRoot(this.context, {
214
247
  relativePath,
215
248
  mkdir: this.defaults.mkdir,
216
249
  mode: this.defaults.mode,
217
- ...options,
218
- denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
250
+ ...this.mutationOptions(options),
219
251
  append: writeMode === "append",
220
252
  truncateExisting: writeMode === "replace",
221
253
  });
222
254
  }
223
255
  async append(relativePath, data, options = {}) {
256
+ assertValidRootDestinationPath(relativePath);
224
257
  await appendFileInRoot(this.context, {
225
258
  relativePath,
226
259
  data,
227
260
  mkdir: this.defaults.mkdir,
228
261
  mode: this.defaults.mode,
229
- ...options,
230
- denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
262
+ ...this.mutationOptions(options),
231
263
  });
232
264
  }
233
265
  async remove(relativePath, options = {}) {
234
266
  assertValidRootRelativePath(relativePath);
235
267
  await removePathInRoot(this.context, {
236
268
  relativePath,
237
- denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
269
+ ...this.mutationOptions(options),
238
270
  });
239
271
  }
240
272
  async mkdir(relativePath, options = {}) {
241
- assertValidRootRelativePath(relativePath);
273
+ assertValidRootDestinationPath(relativePath);
242
274
  await mkdirPathInRoot(this.context, {
243
275
  relativePath,
244
- denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
276
+ ...this.mutationOptions(options),
245
277
  });
246
278
  }
247
279
  async ensureRoot(options = {}) {
248
280
  await mkdirPathInRoot(this.context, {
249
281
  relativePath: "",
250
282
  allowRoot: true,
251
- denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
283
+ ...this.mutationOptions(options),
252
284
  });
253
285
  }
254
286
  async write(relativePath, data, options = {}) {
287
+ assertValidRootDestinationPath(relativePath);
255
288
  await writeFileInRoot(this.context, {
256
289
  relativePath,
257
290
  data,
258
291
  mkdir: this.defaults.mkdir,
259
292
  mode: this.defaults.mode,
260
293
  renameIdentity: this.defaults.renameIdentity,
261
- ...options,
262
- denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
294
+ ...this.mutationOptions(options),
263
295
  });
264
296
  }
265
297
  async create(relativePath, data, options = {}) {
298
+ assertValidRootDestinationPath(relativePath);
266
299
  await writeFileInRoot(this.context, {
267
300
  relativePath,
268
301
  data,
269
302
  mkdir: this.defaults.mkdir,
270
303
  mode: this.defaults.mode,
271
- ...options,
272
- denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
304
+ ...this.mutationOptions(options),
273
305
  overwrite: false,
274
306
  });
275
307
  }
@@ -284,15 +316,14 @@ class RootHandle {
284
316
  await this.create(relativePath, trailingNewline ? `${json}\n` : json, writeOptions);
285
317
  }
286
318
  async copyIn(relativePath, sourcePath, options = {}) {
287
- assertValidRootRelativePath(relativePath);
319
+ assertValidRootDestinationPath(relativePath);
288
320
  await copyFileInRoot(this.context, {
289
321
  sourcePath,
290
322
  relativePath,
291
323
  maxBytes: this.defaults.maxBytes,
292
324
  mkdir: this.defaults.mkdir,
293
325
  mode: this.defaults.mode,
294
- ...options,
295
- denyMutations: mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations),
326
+ ...this.mutationOptions(options),
296
327
  });
297
328
  }
298
329
  async exists(relativePath) {
@@ -319,9 +350,9 @@ class RootHandle {
319
350
  }
320
351
  async move(fromRelative, toRelative, options = {}) {
321
352
  assertValidRootRelativePath(fromRelative);
322
- assertValidRootRelativePath(toRelative);
353
+ assertValidRootDestinationPath(toRelative);
323
354
  validatePinnedOperationPayload({ from: fromRelative, to: toRelative });
324
- const denyMutations = mergeDenyMutationPolicies(this.defaults.denyMutations, options.denyMutations);
355
+ const { denyMutations } = this.mutationOptions(options);
325
356
  await assertMoveMutationAllowed(this.context, {
326
357
  fromRelative,
327
358
  toRelative,
@@ -353,6 +384,8 @@ export async function root(rootDir, defaults = {}) {
353
384
  async function openFileInRoot(root, params) {
354
385
  const { rootWithSep, resolved } = await resolvePathInRoot(root, params.relativePath, {
355
386
  allowFinalSymlink: true,
387
+ rejectUnsafeDeviceReads: true,
388
+ rejectSymlinks: params.symlinks !== "follow-within-root",
356
389
  });
357
390
  let opened;
358
391
  try {
@@ -369,11 +402,11 @@ async function openFileInRoot(root, params) {
369
402
  }
370
403
  if (params.hardlinks !== "allow" && opened.stat.nlink > 1) {
371
404
  await opened.handle.close().catch(() => { });
372
- throw new FsSafeError("hardlink", "hardlinked path not allowed");
405
+ throw hardlinkedPathNotAllowedError();
373
406
  }
374
407
  if (!isPathInside(rootWithSep, opened.realPath)) {
375
408
  await opened.handle.close().catch(() => { });
376
- throw new FsSafeError("outside-workspace", "file is outside workspace root");
409
+ throw outsideWorkspaceError();
377
410
  }
378
411
  return opened;
379
412
  }
@@ -455,33 +488,47 @@ async function verifyAtomicWriteResult(params) {
455
488
  throw new FsSafeError("path-mismatch", "path changed during write");
456
489
  }
457
490
  if (!isPathInside(params.root.rootWithSep, opened.realPath)) {
458
- throw new FsSafeError("outside-workspace", "file is outside workspace root");
491
+ throw outsideWorkspaceError();
459
492
  }
460
493
  }
461
494
  finally {
462
495
  await opened.handle.close().catch(() => { });
463
496
  }
464
497
  }
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 });
498
+ async function resolveGuardedWritePathInRoot(root, params) {
499
+ const resolvedPath = await resolvePathInRoot(root, params.relativePath, {
500
+ aliasErrorCode: "path-alias",
501
+ allowFinalSymlink: params.allowFinalSymlink,
502
+ });
503
+ await assertMutationNotDenied(resolvedPath.resolved, params.denyMutations, params.protectDeniedAncestors ? { protectAncestors: true } : undefined);
504
+ if (await (params.shouldAssertNoPathAlias?.(resolvedPath) ?? true)) {
505
+ try {
506
+ await assertNoPathAliasEscape({
507
+ absolutePath: resolvedPath.resolved,
508
+ rootPath: resolvedPath.rootReal,
509
+ boundaryLabel: "root",
510
+ });
511
+ }
512
+ catch (error) {
513
+ throw new FsSafeError("path-alias", "path alias escape blocked", {
514
+ cause: error instanceof Error ? error : undefined,
515
+ });
516
+ }
477
517
  }
518
+ return resolvedPath;
519
+ }
520
+ async function openWritableFileInRoot(root, params) {
521
+ const { rootReal, rootWithSep, resolved } = await resolveGuardedWritePathInRoot(root, {
522
+ relativePath: params.relativePath,
523
+ denyMutations: params.denyMutations,
524
+ });
478
525
  let ioPath = params.mkdir === false
479
526
  ? resolved
480
527
  : await prepareRootWriteTarget(rootReal, resolved);
481
528
  try {
482
529
  const resolvedRealPath = await fs.realpath(ioPath);
483
530
  if (!isPathInside(rootWithSep, resolvedRealPath)) {
484
- throw new FsSafeError("outside-workspace", "file is outside workspace root");
531
+ throw outsideWorkspaceError();
485
532
  }
486
533
  ioPath = resolvedRealPath;
487
534
  }
@@ -512,7 +559,7 @@ async function openWritableFileInRoot(root, params) {
512
559
  }
513
560
  catch (err) {
514
561
  if (isNotFoundPathError(err)) {
515
- throw new FsSafeError("not-found", "file not found");
562
+ throw fileNotFoundError();
516
563
  }
517
564
  if (isSymlinkOpenError(err)) {
518
565
  throw new FsSafeError("symlink", "symlink open blocked", { cause: err });
@@ -523,13 +570,17 @@ async function openWritableFileInRoot(root, params) {
523
570
  throw err;
524
571
  }
525
572
  let realPathForCleanup = null;
573
+ let createdIdentity = null;
526
574
  try {
527
575
  const stat = await handle.stat();
576
+ if (createdForWrite) {
577
+ createdIdentity = stat;
578
+ }
528
579
  if (!stat.isFile()) {
529
- throw new FsSafeError("invalid-path", "path is not a regular file under root");
580
+ throw new FsSafeError("not-file", "path is not a regular file under root");
530
581
  }
531
582
  if (stat.nlink > 1) {
532
- throw new FsSafeError("hardlink", "hardlinked path not allowed");
583
+ throw hardlinkedPathNotAllowedError();
533
584
  }
534
585
  try {
535
586
  const lstat = await fs.lstat(ioPath);
@@ -552,10 +603,10 @@ async function openWritableFileInRoot(root, params) {
552
603
  throw new FsSafeError("path-mismatch", "path mismatch");
553
604
  }
554
605
  if (realStat.nlink > 1) {
555
- throw new FsSafeError("hardlink", "hardlinked path not allowed");
606
+ throw hardlinkedPathNotAllowedError();
556
607
  }
557
608
  if (!isPathInside(rootWithSep, realPath)) {
558
- throw new FsSafeError("outside-workspace", "file is outside workspace root");
609
+ throw outsideWorkspaceError();
559
610
  }
560
611
  // Truncate only after boundary and identity checks complete. This avoids
561
612
  // irreversible side effects if a symlink target changes before validation.
@@ -575,8 +626,8 @@ async function openWritableFileInRoot(root, params) {
575
626
  const cleanupCreatedPath = createdForWrite && err instanceof FsSafeError;
576
627
  const cleanupPath = realPathForCleanup ?? ioPath;
577
628
  await handle.close().catch(() => { });
578
- if (cleanupCreatedPath) {
579
- await fs.rm(cleanupPath, { force: true }).catch(() => { });
629
+ if (cleanupCreatedPath && createdIdentity) {
630
+ await removePathIfIdentityUnchanged(cleanupPath, createdIdentity).catch(() => { });
580
631
  }
581
632
  throw err;
582
633
  }
@@ -621,7 +672,11 @@ async function appendFileInRoot(root, params) {
621
672
  }
622
673
  async function removePathInRoot(root, params) {
623
674
  validatePinnedOperationPayload({ relativePath: params.relativePath });
624
- const resolved = await resolvePinnedRemovePathInRoot(root, params.relativePath, params.denyMutations);
675
+ const resolved = await resolvePinnedPathInRoot(root, {
676
+ relativePath: params.relativePath,
677
+ denyMutations: params.denyMutations,
678
+ remove: true,
679
+ });
625
680
  try {
626
681
  await removePathFallback(resolved);
627
682
  }
@@ -640,15 +695,16 @@ async function mkdirPathInRoot(root, params) {
640
695
  }
641
696
  }
642
697
  async function writeFileInRoot(root, params) {
643
- if (process.platform === "win32") {
644
- await serializePathWrite(rootWriteQueueKey(root, params.relativePath), async () => {
698
+ await serializePathWrite(rootWriteQueueKey(root, params.relativePath), async () => {
699
+ if (process.platform === "win32" &&
700
+ (params.renameIdentity === "verify-content-with-lock" || !getNativeBinding())) {
645
701
  await writeFileFallback(root, params);
702
+ return;
703
+ }
704
+ const pinned = await resolvePinnedWriteTargetInRoot(root, params.relativePath, params.mode, params.denyMutations);
705
+ await serializePathWrite(pinned.targetPath, async () => {
706
+ await commitPinnedWriteInRoot(root, pinned, params);
646
707
  });
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
708
  });
653
709
  }
654
710
  async function commitPinnedWriteInRoot(root, pinned, params) {
@@ -664,6 +720,7 @@ async function commitPinnedWriteInRoot(root, pinned, params) {
664
720
  mode: params.mode ?? pinned.mode,
665
721
  overwrite: params.overwrite,
666
722
  input: { kind: "buffer", data: params.data, encoding: params.encoding },
723
+ rootIdentity: root.rootIdentity,
667
724
  });
668
725
  }
669
726
  catch (error) {
@@ -701,26 +758,35 @@ async function copyFileInRoot(root, params) {
701
758
  throw new FsSafeError("too-large", `file exceeds limit of ${params.maxBytes} bytes (got ${source.stat.size})`);
702
759
  }
703
760
  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() },
761
+ await serializePathWrite(rootWriteQueueKey(root, params.relativePath), async () => {
762
+ const pinned = await resolvePinnedWriteTargetInRoot(root, params.relativePath, params.mode, params.denyMutations);
763
+ await serializePathWrite(pinned.targetPath, async () => {
764
+ await assertCopySourceCurrent(source);
765
+ let identity;
766
+ try {
767
+ identity = await runPinnedWriteHelper({
768
+ rootPath: pinned.rootReal,
769
+ relativeParentPath: pinned.relativeParentPath,
770
+ basename: pinned.basename,
771
+ mkdir: params.mkdir !== false,
772
+ mode: pinned.mode,
773
+ overwrite: true,
774
+ maxBytes: params.maxBytes,
775
+ input: { kind: "stream", stream: source.handle.createReadStream() },
776
+ rootIdentity: root.rootIdentity,
777
+ });
778
+ }
779
+ catch (error) {
780
+ throw normalizePinnedWriteError(error);
781
+ }
782
+ try {
783
+ await assertCopySourcePathCurrent(source);
784
+ }
785
+ catch (error) {
786
+ await removePathIfIdentityUnchanged(pinned.targetPath, identity).catch(() => undefined);
787
+ throw error;
788
+ }
716
789
  });
717
- try {
718
- await assertCopySourcePathCurrent(source);
719
- }
720
- catch (error) {
721
- await removeCopyTargetIfUnchanged(pinned.targetPath, identity).catch(() => undefined);
722
- throw error;
723
- }
724
790
  });
725
791
  }
726
792
  finally {
@@ -740,7 +806,7 @@ async function assertCopySourcePathCurrent(source) {
740
806
  throw new FsSafeError("path-mismatch", "copy source path changed");
741
807
  }
742
808
  }
743
- async function removeCopyTargetIfUnchanged(targetPath, identity) {
809
+ async function removePathIfIdentityUnchanged(targetPath, identity) {
744
810
  const parentGuard = await createAsyncDirectoryGuard(path.dirname(targetPath));
745
811
  const current = await fs.lstat(targetPath);
746
812
  if (current.isSymbolicLink() || !sameFileIdentity(current, identity)) {
@@ -751,25 +817,15 @@ async function removeCopyTargetIfUnchanged(targetPath, identity) {
751
817
  });
752
818
  }
753
819
  async function resolvePinnedWriteTargetInRoot(root, relativePath, requestedMode, denyMutations) {
754
- const { rootReal, rootWithSep, resolved } = await resolvePathInRoot(root, relativePath, {
755
- aliasErrorCode: "path-alias",
820
+ const { rootReal, rootWithSep, resolved } = await resolveGuardedWritePathInRoot(root, {
821
+ relativePath,
822
+ denyMutations,
756
823
  });
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
824
  // resolvePathInRoot already enforces isPathInside, so any actual escape
769
825
  // is rejected upstream.
770
826
  const relativeResolved = path.relative(rootReal, resolved);
771
827
  if (path.isAbsolute(relativeResolved)) {
772
- throw new FsSafeError("outside-workspace", "file is outside workspace root");
828
+ throw outsideWorkspaceError();
773
829
  }
774
830
  const relativePosix = relativeResolved
775
831
  ? relativeResolved.split(path.sep).join(path.posix.sep)
@@ -784,11 +840,12 @@ async function resolvePinnedWriteTargetInRoot(root, relativePath, requestedMode,
784
840
  relativePath,
785
841
  hardlinks: "reject",
786
842
  nonBlockingRead: true,
843
+ symlinks: "follow-within-root",
787
844
  });
788
845
  try {
789
846
  mode = requestedMode ?? (opened.stat.mode & 0o777);
790
847
  if (!isPathInside(rootWithSep, opened.realPath)) {
791
- throw new FsSafeError("outside-workspace", "file is outside workspace root");
848
+ throw outsideWorkspaceError();
792
849
  }
793
850
  }
794
851
  finally {
@@ -812,17 +869,9 @@ async function resolvePinnedPathInRoot(root, params) {
812
869
  return await resolvePinnedOperationPathInRoot(root, {
813
870
  allowRoot: params.allowRoot,
814
871
  denyMutations: params.denyMutations,
815
- protectDenyMutationAncestors: false,
872
+ protectDenyMutationAncestors: params.remove === true,
816
873
  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,
874
+ policy: params.remove ? PATH_ALIAS_POLICIES.unlinkTarget : PATH_ALIAS_POLICIES.strict,
826
875
  });
827
876
  }
828
877
  async function resolvePinnedOperationPathInRoot(root, params) {
@@ -840,11 +889,11 @@ async function resolvePinnedOperationPathInRoot(root, params) {
840
889
  relativeResolved === "." ||
841
890
  firstSegment === ".." ||
842
891
  path.isAbsolute(relativeResolved)) {
843
- throw new FsSafeError("outside-workspace", "file is outside workspace root");
892
+ throw outsideWorkspaceError();
844
893
  }
845
894
  const relativePosix = relativeResolved.split(path.sep).join(path.posix.sep);
846
895
  if (!isPathInside(resolved.rootWithSep, resolved.canonicalPath)) {
847
- throw new FsSafeError("outside-workspace", "file is outside workspace root");
896
+ throw outsideWorkspaceError();
848
897
  }
849
898
  await assertMutationNotDenied(resolved.canonicalPath, params.denyMutations, {
850
899
  protectAncestors: params.protectDenyMutationAncestors,
@@ -852,6 +901,7 @@ async function resolvePinnedOperationPathInRoot(root, params) {
852
901
  return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix };
853
902
  }
854
903
  async function resolvePinnedRootPathInRoot(root, params) {
904
+ await assertRootIdentityCurrent(root);
855
905
  const rootReal = root.rootReal;
856
906
  let resolved;
857
907
  try {
@@ -876,11 +926,25 @@ async function resolvePinnedRootPathInRoot(root, params) {
876
926
  canonicalPath: resolved.canonicalPath,
877
927
  };
878
928
  }
929
+ async function prepareRemoveGuard(targetPath) {
930
+ try {
931
+ const guard = await createAsyncDirectoryGuard(path.dirname(targetPath));
932
+ await getFsSafeTestHooks()?.beforeRootFallbackMutation?.("remove", targetPath);
933
+ await assertAsyncDirectoryGuard(guard);
934
+ return guard;
935
+ }
936
+ catch (error) {
937
+ throw normalizeRemoveGuardError(error);
938
+ }
939
+ }
879
940
  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));
941
+ const guard = await prepareRemoveGuard(resolved.resolved);
942
+ try {
943
+ await ((await fs.lstat(resolved.resolved)).isDirectory() ? fs.rmdir(resolved.resolved) : fs.rm(resolved.resolved));
944
+ }
945
+ catch (error) {
946
+ throw normalizeRemovePathError(error);
947
+ }
884
948
  await assertAsyncDirectoryGuard(guard).catch(() => undefined);
885
949
  }
886
950
  async function mkdirPathFallback(resolved) {
@@ -892,13 +956,13 @@ async function mkdirPathFallback(resolved) {
892
956
  async function statPathFallback(root, relativePath) {
893
957
  const resolved = await resolvePinnedPathInRoot(root, { relativePath, allowRoot: true });
894
958
  try {
895
- return pathStatFromStats(await fs.lstat(resolved.resolved));
959
+ const stat = pathStatFromStats(await fs.lstat(resolved.resolved));
960
+ await assertRootIdentityCurrent(root);
961
+ return stat;
896
962
  }
897
963
  catch (error) {
898
964
  if (isNotFoundPathError(error)) {
899
- throw new FsSafeError("not-found", "file not found", {
900
- cause: error instanceof Error ? error : undefined,
901
- });
965
+ throw fileNotFoundError(error instanceof Error ? error : undefined);
902
966
  }
903
967
  throw error;
904
968
  }
@@ -909,6 +973,7 @@ async function listPathFallback(root, relativePath, withFileTypes) {
909
973
  const names = await fs.readdir(resolved.resolved);
910
974
  const sortedNames = names.toSorted();
911
975
  if (!withFileTypes) {
976
+ await assertRootIdentityCurrent(root);
912
977
  return sortedNames;
913
978
  }
914
979
  const entries = [];
@@ -918,6 +983,7 @@ async function listPathFallback(root, relativePath, withFileTypes) {
918
983
  ...pathStatFromStats(await fs.lstat(path.join(resolved.resolved, name))),
919
984
  });
920
985
  }
986
+ await assertRootIdentityCurrent(root);
921
987
  return entries;
922
988
  }
923
989
  catch (error) {
@@ -930,6 +996,8 @@ async function listPathFallback(root, relativePath, withFileTypes) {
930
996
  }
931
997
  }
932
998
  async function assertMoveMutationAllowed(root, params) {
999
+ // Keep this preflight separate from the pinned resolutions in movePathFallback:
1000
+ // mutation denials must take precedence over source alias or identity failures.
933
1001
  const source = await resolvePathInRoot(root, params.fromRelative, {
934
1002
  aliasErrorCode: "path-alias",
935
1003
  allowFinalSymlink: true,
@@ -951,40 +1019,29 @@ async function movePathFallback(root, params) {
951
1019
  relativePath: params.fromRelative,
952
1020
  policy: PATH_ALIAS_POLICIES.strict,
953
1021
  });
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, {
1022
+ const target = await resolveGuardedWritePathInRoot(root, {
960
1023
  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,
1024
+ denyMutations: params.denyMutations,
1025
+ allowFinalSymlink: true,
1026
+ protectDeniedAncestors: true,
1027
+ shouldAssertNoPathAlias: async (resolvedTarget) => {
1028
+ await resolvePinnedRootPathInRoot(root, {
1029
+ relativePath: params.toRelative,
1030
+ policy: PATH_ALIAS_POLICIES.unlinkTarget,
976
1031
  });
977
- }
978
- }
1032
+ const targetStat = await fs.lstat(resolvedTarget.resolved).catch(() => undefined);
1033
+ return !(process.platform !== "win32" &&
1034
+ params.overwrite &&
1035
+ targetStat?.isSymbolicLink() === true);
1036
+ },
1037
+ });
979
1038
  let sourceStat;
980
1039
  try {
981
1040
  sourceStat = await fs.lstat(source.resolved);
982
1041
  }
983
1042
  catch (error) {
984
1043
  if (isNotFoundPathError(error)) {
985
- throw new FsSafeError("not-found", "file not found", {
986
- cause: error instanceof Error ? error : undefined,
987
- });
1044
+ throw fileNotFoundError(error instanceof Error ? error : undefined);
988
1045
  }
989
1046
  throw error;
990
1047
  }
@@ -992,7 +1049,7 @@ async function movePathFallback(root, params) {
992
1049
  throw new FsSafeError("symlink", "symlink not allowed");
993
1050
  }
994
1051
  if (sourceStat.isFile() && sourceStat.nlink > 1) {
995
- throw new FsSafeError("hardlink", "hardlinked path not allowed");
1052
+ throw hardlinkedPathNotAllowedError();
996
1053
  }
997
1054
  if (!params.overwrite && sourceStat.isDirectory()) {
998
1055
  throw new FsSafeError("invalid-path", "directory moves require overwrite: true");
@@ -1021,9 +1078,7 @@ async function movePathFallback(root, params) {
1021
1078
  }
1022
1079
  catch (error) {
1023
1080
  if (isNotFoundPathError(error)) {
1024
- throw new FsSafeError("not-found", "file not found", {
1025
- cause: error instanceof Error ? error : undefined,
1026
- });
1081
+ throw fileNotFoundError(error instanceof Error ? error : undefined);
1027
1082
  }
1028
1083
  if (hasNodeErrorCode(error, "EEXIST")) {
1029
1084
  throw new FsSafeError("already-exists", "destination exists", {
@@ -1089,30 +1144,22 @@ async function writeFileFallback(root, params) {
1089
1144
  }
1090
1145
  }
1091
1146
  async function writeMissingFileFallback(root, params) {
1092
- const { rootReal, resolved } = await resolvePathInRoot(root, params.relativePath, {
1093
- aliasErrorCode: "path-alias",
1147
+ const { rootReal, resolved } = await resolveGuardedWritePathInRoot(root, {
1148
+ relativePath: params.relativePath,
1149
+ denyMutations: params.denyMutations,
1094
1150
  });
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
1151
  const targetPath = params.mkdir === false
1107
1152
  ? resolved
1108
1153
  : await prepareRootWriteTarget(rootReal, resolved);
1109
1154
  const parentGuard = await createAsyncDirectoryGuard(path.dirname(targetPath));
1110
1155
  let created = false;
1156
+ let createdIdentity;
1111
1157
  try {
1112
1158
  const { handle, writtenStat } = await withAsyncDirectoryGuards([parentGuard], async () => {
1113
1159
  const handle = await fs.open(targetPath, OPEN_WRITE_CREATE_FLAGS, params.mode ?? 0o600);
1114
1160
  created = true;
1115
1161
  try {
1162
+ createdIdentity = await handle.stat();
1116
1163
  if (typeof params.data === "string") {
1117
1164
  await handle.writeFile(params.data, params.encoding ?? "utf8");
1118
1165
  }
@@ -1148,8 +1195,8 @@ async function writeMissingFileFallback(root, params) {
1148
1195
  throw err;
1149
1196
  }
1150
1197
  finally {
1151
- if (created) {
1152
- await fs.rm(targetPath, { force: true }).catch(() => undefined);
1198
+ if (created && createdIdentity) {
1199
+ await removePathIfIdentityUnchanged(targetPath, createdIdentity).catch(() => undefined);
1153
1200
  }
1154
1201
  }
1155
1202
  }