@openclaw/fs-safe 0.1.1 → 0.2.0

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 (207) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +58 -2
  3. package/dist/absolute-path.d.ts +14 -0
  4. package/dist/absolute-path.d.ts.map +1 -1
  5. package/dist/absolute-path.js +203 -2
  6. package/dist/advanced.d.ts +2 -1
  7. package/dist/advanced.d.ts.map +1 -1
  8. package/dist/advanced.js +2 -1
  9. package/dist/archive-staging.d.ts.map +1 -1
  10. package/dist/archive-staging.js +81 -7
  11. package/dist/archive.d.ts.map +1 -1
  12. package/dist/archive.js +25 -42
  13. package/dist/bounded-read-stream.d.ts +8 -0
  14. package/dist/bounded-read-stream.d.ts.map +1 -0
  15. package/dist/bounded-read-stream.js +20 -0
  16. package/dist/config.d.ts +1 -0
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/config.js +1 -0
  19. package/dist/directory-guard.d.ts +18 -0
  20. package/dist/directory-guard.d.ts.map +1 -0
  21. package/dist/directory-guard.js +70 -0
  22. package/dist/file-lock.d.ts +4 -2
  23. package/dist/file-lock.d.ts.map +1 -1
  24. package/dist/file-lock.js +13 -2
  25. package/dist/file-store-boundary.d.ts +25 -0
  26. package/dist/file-store-boundary.d.ts.map +1 -0
  27. package/dist/file-store-boundary.js +168 -0
  28. package/dist/file-store-prune.d.ts +12 -0
  29. package/dist/file-store-prune.d.ts.map +1 -0
  30. package/dist/file-store-prune.js +86 -0
  31. package/dist/file-store-source.d.ts +5 -0
  32. package/dist/file-store-source.d.ts.map +1 -0
  33. package/dist/file-store-source.js +30 -0
  34. package/dist/file-store.d.ts +2 -6
  35. package/dist/file-store.d.ts.map +1 -1
  36. package/dist/file-store.js +122 -125
  37. package/dist/guarded-mkdir.d.ts +6 -0
  38. package/dist/guarded-mkdir.d.ts.map +1 -0
  39. package/dist/guarded-mkdir.js +45 -0
  40. package/dist/guarded-mutation.d.ts +33 -0
  41. package/dist/guarded-mutation.d.ts.map +1 -0
  42. package/dist/guarded-mutation.js +76 -0
  43. package/dist/index.d.ts +2 -0
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +2 -0
  46. package/dist/json-document-store.d.ts +2 -0
  47. package/dist/json-document-store.d.ts.map +1 -1
  48. package/dist/json-document-store.js +7 -3
  49. package/dist/json-durable-queue.d.ts +45 -0
  50. package/dist/json-durable-queue.d.ts.map +1 -0
  51. package/dist/json-durable-queue.js +200 -0
  52. package/dist/json.d.ts +35 -5
  53. package/dist/json.d.ts.map +1 -1
  54. package/dist/json.js +69 -2
  55. package/dist/local-roots.d.ts.map +1 -1
  56. package/dist/local-roots.js +17 -3
  57. package/dist/lock-config.d.ts +10 -0
  58. package/dist/lock-config.d.ts.map +1 -0
  59. package/dist/lock-config.js +12 -0
  60. package/dist/move-path.d.ts +1 -0
  61. package/dist/move-path.d.ts.map +1 -1
  62. package/dist/move-path.js +195 -7
  63. package/dist/output.d.ts +13 -0
  64. package/dist/output.d.ts.map +1 -0
  65. package/dist/output.js +69 -0
  66. package/dist/path-stat.d.ts +4 -0
  67. package/dist/path-stat.d.ts.map +1 -0
  68. package/dist/path-stat.js +15 -0
  69. package/dist/path.d.ts +1 -0
  70. package/dist/path.d.ts.map +1 -1
  71. package/dist/path.js +6 -3
  72. package/dist/pinned-write.d.ts.map +1 -1
  73. package/dist/pinned-write.js +44 -19
  74. package/dist/private-file-store.d.ts +5 -7
  75. package/dist/private-file-store.d.ts.map +1 -1
  76. package/dist/private-file-store.js +21 -34
  77. package/dist/private-temp-workspace.d.ts.map +1 -1
  78. package/dist/private-temp-workspace.js +28 -15
  79. package/dist/regular-file.d.ts.map +1 -1
  80. package/dist/regular-file.js +52 -8
  81. package/dist/replace-directory.d.ts.map +1 -1
  82. package/dist/replace-directory.js +5 -4
  83. package/dist/replace-file.d.ts +1 -1
  84. package/dist/replace-file.d.ts.map +1 -1
  85. package/dist/replace-file.js +93 -31
  86. package/dist/root-context.d.ts +23 -0
  87. package/dist/root-context.d.ts.map +1 -0
  88. package/dist/root-context.js +62 -0
  89. package/dist/root-errors.d.ts +4 -0
  90. package/dist/root-errors.d.ts.map +1 -0
  91. package/dist/root-errors.js +21 -0
  92. package/dist/root-impl.d.ts +108 -0
  93. package/dist/root-impl.d.ts.map +1 -0
  94. package/dist/{safe-root.js → root-impl.js} +484 -265
  95. package/dist/root.d.ts +1 -107
  96. package/dist/root.d.ts.map +1 -1
  97. package/dist/root.js +1 -1332
  98. package/dist/safe-path-segment.d.ts +9 -0
  99. package/dist/safe-path-segment.d.ts.map +1 -0
  100. package/dist/safe-path-segment.js +51 -0
  101. package/dist/secret-file.d.ts.map +1 -1
  102. package/dist/secret-file.js +6 -1
  103. package/dist/sibling-temp.d.ts +1 -0
  104. package/dist/sibling-temp.d.ts.map +1 -1
  105. package/dist/sibling-temp.js +32 -7
  106. package/dist/sidecar-lock.d.ts +2 -0
  107. package/dist/sidecar-lock.d.ts.map +1 -1
  108. package/dist/sidecar-lock.js +93 -13
  109. package/dist/store.d.ts +1 -0
  110. package/dist/store.d.ts.map +1 -1
  111. package/dist/store.js +1 -0
  112. package/dist/temp-target.d.ts.map +1 -1
  113. package/dist/temp-target.js +7 -4
  114. package/dist/test-hooks.d.ts +6 -0
  115. package/dist/test-hooks.d.ts.map +1 -1
  116. package/dist/text-atomic.d.ts +7 -0
  117. package/dist/text-atomic.d.ts.map +1 -1
  118. package/dist/text-atomic.js +3 -2
  119. package/dist/trash.d.ts.map +1 -1
  120. package/dist/trash.js +48 -13
  121. package/docs/advanced.md +110 -0
  122. package/docs/archive.md +199 -0
  123. package/docs/atomic.md +191 -0
  124. package/docs/config.md +94 -0
  125. package/docs/contributing.md +80 -0
  126. package/docs/errors.md +154 -0
  127. package/docs/file-store.md +213 -0
  128. package/docs/filename.md +0 -0
  129. package/docs/index.md +77 -0
  130. package/docs/install-path.md +167 -0
  131. package/docs/install.md +150 -0
  132. package/docs/json-store.md +204 -0
  133. package/docs/json.md +192 -0
  134. package/docs/local-roots.md +164 -0
  135. package/docs/output.md +92 -0
  136. package/docs/path-scope.md +130 -0
  137. package/docs/path.md +191 -0
  138. package/docs/permissions.md +95 -0
  139. package/docs/private-file-store.md +45 -0
  140. package/docs/python-helper.md +103 -0
  141. package/docs/quickstart.md +147 -0
  142. package/docs/reading.md +174 -0
  143. package/docs/regular-file.md +175 -0
  144. package/docs/root.md +205 -0
  145. package/docs/secret-file.md +154 -0
  146. package/docs/secure-file.md +82 -0
  147. package/docs/security-model.md +99 -0
  148. package/docs/sidecar-lock.md +230 -0
  149. package/docs/store.md +77 -0
  150. package/docs/temp.md +284 -0
  151. package/docs/test-hooks.md +74 -0
  152. package/docs/testing.md +178 -0
  153. package/docs/timing.md +121 -0
  154. package/docs/types.md +155 -0
  155. package/docs/walk.md +63 -0
  156. package/docs/writing.md +222 -0
  157. package/package.json +10 -2
  158. package/dist/archive-utils.d.ts +0 -3
  159. package/dist/archive-utils.d.ts.map +0 -1
  160. package/dist/archive-utils.js +0 -26
  161. package/dist/boundary-file-read.d.ts +0 -44
  162. package/dist/boundary-file-read.d.ts.map +0 -1
  163. package/dist/boundary-file-read.js +0 -129
  164. package/dist/boundary-path.d.ts +0 -39
  165. package/dist/boundary-path.d.ts.map +0 -1
  166. package/dist/boundary-path.js +0 -598
  167. package/dist/fs-pinned-path-helper.d.ts +0 -7
  168. package/dist/fs-pinned-path-helper.d.ts.map +0 -1
  169. package/dist/fs-pinned-path-helper.js +0 -182
  170. package/dist/fs-pinned-write-helper.d.ts +0 -21
  171. package/dist/fs-pinned-write-helper.d.ts.map +0 -1
  172. package/dist/fs-pinned-write-helper.js +0 -263
  173. package/dist/hardlink-guards.d.ts +0 -7
  174. package/dist/hardlink-guards.d.ts.map +0 -1
  175. package/dist/hardlink-guards.js +0 -30
  176. package/dist/install-safe-path.d.ts +0 -20
  177. package/dist/install-safe-path.d.ts.map +0 -1
  178. package/dist/install-safe-path.js +0 -94
  179. package/dist/json-file.d.ts +0 -3
  180. package/dist/json-file.d.ts.map +0 -1
  181. package/dist/json-file.js +0 -123
  182. package/dist/json-files.d.ts +0 -20
  183. package/dist/json-files.d.ts.map +0 -1
  184. package/dist/json-files.js +0 -153
  185. package/dist/path-alias-guards.d.ts +0 -19
  186. package/dist/path-alias-guards.d.ts.map +0 -1
  187. package/dist/path-alias-guards.js +0 -21
  188. package/dist/path-guards.d.ts +0 -7
  189. package/dist/path-guards.d.ts.map +0 -1
  190. package/dist/path-guards.js +0 -49
  191. package/dist/path-safety.d.ts +0 -12
  192. package/dist/path-safety.d.ts.map +0 -1
  193. package/dist/path-safety.js +0 -50
  194. package/dist/safe-open-sync.d.ts +0 -24
  195. package/dist/safe-open-sync.d.ts.map +0 -1
  196. package/dist/safe-open-sync.js +0 -71
  197. package/dist/safe-root.d.ts +0 -123
  198. package/dist/safe-root.d.ts.map +0 -1
  199. package/dist/secure-temp-workspace.d.ts +0 -25
  200. package/dist/secure-temp-workspace.d.ts.map +0 -1
  201. package/dist/secure-temp-workspace.js +0 -136
  202. package/dist/sibling-temp-file.d.ts +0 -16
  203. package/dist/sibling-temp-file.d.ts.map +0 -1
  204. package/dist/sibling-temp-file.js +0 -73
  205. package/dist/sibling-temp-write.d.ts +0 -8
  206. package/dist/sibling-temp-write.d.ts.map +0 -1
  207. package/dist/sibling-temp-write.js +0 -40
@@ -1,19 +1,27 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { constants as fsConstants } from "node:fs";
3
3
  import fs from "node:fs/promises";
4
- import os from "node:os";
5
4
  import path from "node:path";
6
5
  import { pipeline } from "node:stream/promises";
6
+ import { createBoundedReadStream } from "./bounded-read-stream.js";
7
+ import { assertAsyncDirectoryGuard, createAsyncDirectoryGuard, createNearestExistingDirectoryGuard } from "./directory-guard.js";
7
8
  import { FsSafeError } from "./errors.js";
8
9
  import { sameFileIdentity } from "./file-identity.js";
10
+ import { mkdirPathComponentsWithGuards } from "./guarded-mkdir.js";
11
+ import { withAsyncDirectoryGuards } from "./guarded-mutation.js";
9
12
  import { isPinnedPathHelperSpawnError, runPinnedPathHelper } from "./pinned-path.js";
10
- import { runPinnedWriteHelper } from "./pinned-write.js";
11
- import { expandHomePrefix } from "./home-dir.js";
13
+ import { runPinnedCopyHelper, runPinnedWriteHelper } from "./pinned-write.js";
14
+ import { canFallbackFromPythonError, getFsSafePythonConfig } from "./pinned-python-config.js";
12
15
  import { assertNoPathAliasEscape, PATH_ALIAS_POLICIES } from "./path-policy.js";
13
- import { hasNodeErrorCode, isNotFoundPathError, isPathInside, isSymlinkOpenError, } from "./path.js";
14
- import { helperReaddir, helperStat, runPinnedHelper } from "./pinned-helper.js";
16
+ import { assertNoNulPathInput, hasNodeErrorCode, isNotFoundPathError, isPathInside, isSymlinkOpenError, } from "./path.js";
17
+ import { helperReaddir, helperStat, runPinnedHelper, } from "./pinned-helper.js";
18
+ import { pathStatFromStats } from "./path-stat.js";
15
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";
16
22
  import { getFsSafeTestHooks } from "./test-hooks.js";
23
+ import { registerTempPathForExit } from "./temp-cleanup.js";
24
+ import { serializePathWrite } from "./write-queue.js";
17
25
  function logWarn(message) {
18
26
  if (process.env.FS_SAFE_DEBUG_WARNINGS === "1") {
19
27
  console.warn(message);
@@ -36,21 +44,19 @@ const OPEN_APPEND_CREATE_FLAGS = fsConstants.O_RDWR |
36
44
  fsConstants.O_CREAT |
37
45
  fsConstants.O_EXCL |
38
46
  (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
39
- const ensureTrailingSep = (value) => (value.endsWith(path.sep) ? value : value + path.sep);
40
- let cachedHomePath;
41
- async function expandRelativePathWithHome(relativePath) {
42
- const rawHome = process.env.HOME || process.env.USERPROFILE || os.homedir();
43
- if (cachedHomePath?.raw !== rawHome) {
44
- let realHome = rawHome;
45
- try {
46
- realHome = await fs.realpath(rawHome);
47
- }
48
- catch {
49
- // If the home dir cannot be canonicalized, keep lexical expansion behavior.
50
- }
51
- cachedHomePath = { raw: rawHome, real: realHome };
52
- }
53
- return expandHomePrefix(relativePath, { home: cachedHomePath.real });
47
+ export const DEFAULT_ROOT_MAX_BYTES = 16 * 1024 * 1024;
48
+ function closeHandleForDispose(handle) {
49
+ return handle.close().catch(() => undefined);
50
+ }
51
+ function openResult(params) {
52
+ return {
53
+ handle: params.handle,
54
+ realPath: params.realPath,
55
+ stat: params.stat,
56
+ [Symbol.asyncDispose]: async () => {
57
+ await closeHandleForDispose(params.handle);
58
+ },
59
+ };
54
60
  }
55
61
  async function openVerifiedLocalFile(filePath, options) {
56
62
  const fsSafeTestHooks = getFsSafeTestHooks();
@@ -71,7 +77,7 @@ async function openVerifiedLocalFile(filePath, options) {
71
77
  }
72
78
  let handle;
73
79
  try {
74
- const openFlags = options?.symlinkPolicy === "follow-within-root"
80
+ const openFlags = options?.symlinks === "follow-within-root"
75
81
  ? options?.nonBlockingRead
76
82
  ? OPEN_READ_FOLLOW_NONBLOCK_FLAGS
77
83
  : OPEN_READ_FOLLOW_FLAGS
@@ -106,10 +112,10 @@ async function openVerifiedLocalFile(filePath, options) {
106
112
  if (!stat.isFile()) {
107
113
  throw new FsSafeError("not-file", "not a file");
108
114
  }
109
- if (options?.rejectHardlinks && stat.nlink > 1) {
115
+ if (options?.hardlinks === "reject" && stat.nlink > 1) {
110
116
  throw new FsSafeError("hardlink", "hardlinked path not allowed");
111
117
  }
112
- if (options?.symlinkPolicy === "follow-within-root") {
118
+ if (options?.symlinks === "follow-within-root") {
113
119
  const pathStat = await fs.stat(filePath);
114
120
  if (!sameFileIdentity(stat, pathStat)) {
115
121
  throw new FsSafeError("path-mismatch", "path changed during read");
@@ -126,13 +132,13 @@ async function openVerifiedLocalFile(filePath, options) {
126
132
  }
127
133
  const realPath = await resolveOpenedFileRealPathForHandle(handle, filePath);
128
134
  const realStat = await fs.stat(realPath);
129
- if (options?.rejectHardlinks && realStat.nlink > 1) {
135
+ if (options?.hardlinks === "reject" && realStat.nlink > 1) {
130
136
  throw new FsSafeError("hardlink", "hardlinked path not allowed");
131
137
  }
132
138
  if (!sameFileIdentity(stat, realStat)) {
133
139
  throw new FsSafeError("path-mismatch", "path mismatch");
134
140
  }
135
- return { handle, realPath, stat };
141
+ return openResult({ handle, realPath, stat });
136
142
  }
137
143
  catch (err) {
138
144
  await handle.close().catch(() => { });
@@ -145,35 +151,7 @@ async function openVerifiedLocalFile(filePath, options) {
145
151
  throw err;
146
152
  }
147
153
  }
148
- async function resolveSafeRootContext(rootDir) {
149
- let rootReal;
150
- try {
151
- rootReal = await fs.realpath(rootDir);
152
- }
153
- catch (err) {
154
- if (isNotFoundPathError(err)) {
155
- throw new FsSafeError("not-found", "root dir not found");
156
- }
157
- throw err;
158
- }
159
- return {
160
- rootDir: path.resolve(rootDir),
161
- rootReal,
162
- rootWithSep: ensureTrailingSep(rootReal),
163
- };
164
- }
165
- async function resolvePathInSafeRoot(root, relativePath) {
166
- const expanded = await expandRelativePathWithHome(relativePath);
167
- const resolved = path.resolve(root.rootWithSep, expanded);
168
- if (!isPathInside(root.rootWithSep, resolved)) {
169
- throw new FsSafeError("outside-workspace", "file is outside workspace root");
170
- }
171
- return { rootReal: root.rootReal, rootWithSep: root.rootWithSep, resolved };
172
- }
173
- async function resolvePathWithinRoot(params) {
174
- return await resolvePathInSafeRoot(await resolveSafeRootContext(params.rootDir), params.relativePath);
175
- }
176
- export class SafeRoot {
154
+ class RootHandle {
177
155
  rootDir;
178
156
  rootReal;
179
157
  rootWithSep;
@@ -192,75 +170,90 @@ export class SafeRoot {
192
170
  };
193
171
  }
194
172
  async resolve(relativePath) {
195
- return (await resolvePathInSafeRoot(this.context, relativePath)).resolved;
173
+ return (await resolvePathInRoot(this.context, relativePath)).resolved;
196
174
  }
197
- async openFile(relativePath, options = {}) {
198
- return await openFileInSafeRoot(this.context, {
175
+ async open(relativePath, options = {}) {
176
+ return await openFileInRoot(this.context, {
199
177
  relativePath,
200
- ...this.defaults.read,
201
- ...this.defaults.open,
178
+ ...readDefaults(this.defaults),
202
179
  ...options,
203
180
  });
204
181
  }
205
- async readFile(relativePath, options = {}) {
206
- return await readFileInSafeRoot(this.context, {
182
+ async read(relativePath, options = {}) {
183
+ return await readFileInRoot(this.context, {
207
184
  relativePath,
208
- ...this.defaults.read,
185
+ ...readDefaults(this.defaults),
209
186
  ...options,
210
187
  });
211
188
  }
212
- async readFileBytes(relativePath, options = {}) {
213
- return (await this.readFile(relativePath, options)).buffer;
189
+ async readBytes(relativePath, options = {}) {
190
+ return (await this.read(relativePath, options)).buffer;
214
191
  }
215
192
  async readText(relativePath, options = {}) {
216
193
  const { encoding = "utf8", ...readOptions } = options;
217
- return (await this.readFile(relativePath, readOptions)).buffer.toString(encoding);
194
+ return (await this.read(relativePath, readOptions)).buffer.toString(encoding);
218
195
  }
219
196
  async readJson(relativePath, options = {}) {
220
197
  return JSON.parse(await this.readText(relativePath, options));
221
198
  }
222
- async readPath(filePath, options = {}) {
223
- return await readPathInSafeRoot(this.context, { filePath, ...this.defaults.read, ...options });
199
+ async readAbsolute(filePath, options = {}) {
200
+ return await readPathInRoot(this.context, {
201
+ filePath,
202
+ ...readDefaults(this.defaults),
203
+ ...options,
204
+ });
224
205
  }
225
206
  reader(options = {}) {
226
207
  return async (filePath) => {
227
- return (await this.readPath(filePath, options)).buffer;
208
+ return (await this.readAbsolute(filePath, options)).buffer;
228
209
  };
229
210
  }
230
211
  async openWritable(relativePath, options = {}) {
231
- return await openWritableFileInSafeRoot(this.context, {
212
+ const writeMode = options.writeMode ?? "replace";
213
+ return await openWritableFileInRoot(this.context, {
232
214
  relativePath,
233
- ...this.defaults.openWritable,
215
+ mkdir: this.defaults.mkdir,
216
+ mode: this.defaults.mode,
234
217
  ...options,
218
+ append: writeMode === "append",
219
+ truncateExisting: writeMode === "replace",
235
220
  });
236
221
  }
237
222
  async append(relativePath, data, options = {}) {
238
- await appendFileInSafeRoot(this.context, {
223
+ await appendFileInRoot(this.context, {
239
224
  relativePath,
240
225
  data,
241
- ...this.defaults.append,
226
+ mkdir: this.defaults.mkdir,
227
+ mode: this.defaults.mode,
242
228
  ...options,
243
229
  });
244
230
  }
245
- async removeFile(relativePath) {
246
- await removePathInSafeRoot(this.context, relativePath);
231
+ async remove(relativePath) {
232
+ assertValidRootRelativePath(relativePath);
233
+ await removePathInRoot(this.context, relativePath);
234
+ }
235
+ async mkdir(relativePath) {
236
+ assertValidRootRelativePath(relativePath);
237
+ await mkdirPathInRoot(this.context, { relativePath });
247
238
  }
248
- async mkdirp(relativePath, options = {}) {
249
- await mkdirPathInSafeRoot(this.context, { relativePath, ...options });
239
+ async ensureRoot() {
240
+ await mkdirPathInRoot(this.context, { relativePath: "", allowRoot: true });
250
241
  }
251
- async writeFile(relativePath, data, options = {}) {
252
- await writeFileInSafeRoot(this.context, {
242
+ async write(relativePath, data, options = {}) {
243
+ await writeFileInRoot(this.context, {
253
244
  relativePath,
254
245
  data,
255
- ...this.defaults.write,
246
+ mkdir: this.defaults.mkdir,
247
+ mode: this.defaults.mode,
256
248
  ...options,
257
249
  });
258
250
  }
259
- async createFile(relativePath, data, options = {}) {
260
- return await writeFileInSafeRoot(this.context, {
251
+ async create(relativePath, data, options = {}) {
252
+ await writeFileInRoot(this.context, {
261
253
  relativePath,
262
254
  data,
263
- ...this.defaults.write,
255
+ mkdir: this.defaults.mkdir,
256
+ mode: this.defaults.mode,
264
257
  ...options,
265
258
  overwrite: false,
266
259
  });
@@ -268,47 +261,103 @@ export class SafeRoot {
268
261
  async writeJson(relativePath, data, options = {}) {
269
262
  const { replacer, space, trailingNewline = true, ...writeOptions } = options;
270
263
  const json = JSON.stringify(data, replacer, space);
271
- await this.writeFile(relativePath, trailingNewline ? `${json}\n` : json, writeOptions);
264
+ await this.write(relativePath, trailingNewline ? `${json}\n` : json, writeOptions);
272
265
  }
273
266
  async createJson(relativePath, data, options = {}) {
274
267
  const { replacer, space, trailingNewline = true, ...writeOptions } = options;
275
268
  const json = JSON.stringify(data, replacer, space);
276
- return await this.createFile(relativePath, trailingNewline ? `${json}\n` : json, writeOptions);
269
+ await this.create(relativePath, trailingNewline ? `${json}\n` : json, writeOptions);
277
270
  }
278
- async copyFileFrom(sourcePath, relativePath, options = {}) {
279
- await copyFileInSafeRoot(this.context, {
271
+ async copyIn(relativePath, sourcePath, options = {}) {
272
+ assertValidRootRelativePath(relativePath);
273
+ await copyFileInRoot(this.context, {
280
274
  sourcePath,
281
275
  relativePath,
282
- ...this.defaults.copy,
276
+ maxBytes: this.defaults.maxBytes,
277
+ mkdir: this.defaults.mkdir,
278
+ mode: this.defaults.mode,
283
279
  ...options,
284
280
  });
285
281
  }
286
- async statPath(relativePath) {
287
- return await helperStat(this.rootReal, relativePath);
282
+ async exists(relativePath) {
283
+ try {
284
+ await this.stat(relativePath);
285
+ return true;
286
+ }
287
+ catch (err) {
288
+ if (err instanceof FsSafeError && err.code === "not-found") {
289
+ return false;
290
+ }
291
+ throw err;
292
+ }
288
293
  }
289
- async listDir(relativePath, options = {}) {
290
- return options.withFileTypes === true
291
- ? await helperReaddir(this.rootReal, relativePath, true)
292
- : await helperReaddir(this.rootReal, relativePath, false);
294
+ async stat(relativePath) {
295
+ assertValidRootRelativePath(relativePath);
296
+ try {
297
+ return await helperStat(this.rootReal, relativePath);
298
+ }
299
+ catch (error) {
300
+ if (canFallbackFromPythonError(error)) {
301
+ return await statPathFallback(this.context, relativePath);
302
+ }
303
+ throw error;
304
+ }
293
305
  }
294
- async move(from, to, options = {}) {
295
- await runPinnedHelper("rename", this.rootReal, {
296
- from,
297
- overwrite: options.overwrite ?? true,
298
- to,
299
- });
306
+ async list(relativePath, options = {}) {
307
+ assertValidRootRelativePath(relativePath);
308
+ try {
309
+ return options.withFileTypes === true
310
+ ? await helperReaddir(this.rootReal, relativePath, true)
311
+ : await helperReaddir(this.rootReal, relativePath, false);
312
+ }
313
+ catch (error) {
314
+ if (canFallbackFromPythonError(error)) {
315
+ return await listPathFallback(this.context, relativePath, options.withFileTypes === true);
316
+ }
317
+ throw error;
318
+ }
319
+ }
320
+ async move(fromRelative, toRelative, options = {}) {
321
+ assertValidRootRelativePath(fromRelative);
322
+ assertValidRootRelativePath(toRelative);
323
+ try {
324
+ await runPinnedHelper("rename", this.rootReal, {
325
+ from: fromRelative,
326
+ overwrite: options.overwrite ?? false,
327
+ to: toRelative,
328
+ });
329
+ }
330
+ catch (error) {
331
+ if (canFallbackFromPythonError(error)) {
332
+ await movePathFallback(this.context, {
333
+ fromRelative,
334
+ overwrite: options.overwrite ?? false,
335
+ toRelative,
336
+ });
337
+ return;
338
+ }
339
+ throw error;
340
+ }
300
341
  }
301
342
  }
302
- export async function safeRoot(rootDir, defaults = {}) {
303
- return new SafeRoot(await resolveSafeRootContext(rootDir), defaults);
343
+ function readDefaults(defaults) {
344
+ return {
345
+ hardlinks: defaults.hardlinks,
346
+ maxBytes: defaults.maxBytes ?? DEFAULT_ROOT_MAX_BYTES,
347
+ nonBlockingRead: defaults.nonBlockingRead,
348
+ symlinks: defaults.symlinks,
349
+ };
350
+ }
351
+ export async function root(rootDir, defaults = {}) {
352
+ return new RootHandle(await resolveRootContext(rootDir), defaults);
304
353
  }
305
- async function openFileInSafeRoot(root, params) {
306
- const { rootWithSep, resolved } = await resolvePathInSafeRoot(root, params.relativePath);
354
+ async function openFileInRoot(root, params) {
355
+ const { rootWithSep, resolved } = await resolvePathInRoot(root, params.relativePath);
307
356
  let opened;
308
357
  try {
309
358
  opened = await openVerifiedLocalFile(resolved, {
310
359
  nonBlockingRead: params.nonBlockingRead,
311
- symlinkPolicy: params.symlinkPolicy,
360
+ symlinks: params.symlinks,
312
361
  });
313
362
  }
314
363
  catch (err) {
@@ -317,7 +366,7 @@ async function openFileInSafeRoot(root, params) {
317
366
  }
318
367
  throw err;
319
368
  }
320
- if (params.rejectHardlinks !== false && opened.stat.nlink > 1) {
369
+ if (params.hardlinks !== "allow" && opened.stat.nlink > 1) {
321
370
  await opened.handle.close().catch(() => { });
322
371
  throw new FsSafeError("hardlink", "hardlinked path not allowed");
323
372
  }
@@ -327,8 +376,8 @@ async function openFileInSafeRoot(root, params) {
327
376
  }
328
377
  return opened;
329
378
  }
330
- async function readFileInSafeRoot(root, params) {
331
- const opened = await openFileInSafeRoot(root, params);
379
+ async function readFileInRoot(root, params) {
380
+ const opened = await openFileInRoot(root, params);
332
381
  try {
333
382
  return await readOpenedFileSafely({ opened, maxBytes: params.maxBytes });
334
383
  }
@@ -336,16 +385,18 @@ async function readFileInSafeRoot(root, params) {
336
385
  await opened.handle.close().catch(() => { });
337
386
  }
338
387
  }
339
- async function readPathInSafeRoot(root, params) {
388
+ async function readPathInRoot(root, params) {
340
389
  const rootDir = root.rootDir;
341
390
  const candidatePath = path.isAbsolute(params.filePath)
342
391
  ? path.resolve(params.filePath)
343
392
  : path.resolve(rootDir, params.filePath);
344
393
  const relativePath = path.relative(rootDir, candidatePath);
345
- return await readFileInSafeRoot(root, {
394
+ return await readFileInRoot(root, {
346
395
  relativePath,
347
- rejectHardlinks: params.rejectHardlinks,
396
+ hardlinks: params.hardlinks,
348
397
  maxBytes: params.maxBytes,
398
+ nonBlockingRead: params.nonBlockingRead,
399
+ symlinks: params.symlinks,
349
400
  });
350
401
  }
351
402
  export async function readLocalFileSafely(params) {
@@ -358,6 +409,7 @@ export async function readLocalFileSafely(params) {
358
409
  }
359
410
  }
360
411
  export async function openLocalFileSafely(params) {
412
+ assertNoNulPathInput(params.filePath, "file path contains a NUL byte");
361
413
  return await openVerifiedLocalFile(params.filePath);
362
414
  }
363
415
  async function readOpenedFileSafely(params) {
@@ -365,6 +417,9 @@ async function readOpenedFileSafely(params) {
365
417
  throw new FsSafeError("too-large", `file exceeds limit of ${params.maxBytes} bytes (got ${params.opened.stat.size})`);
366
418
  }
367
419
  const buffer = await params.opened.handle.readFile();
420
+ if (params.maxBytes !== undefined && buffer.byteLength > params.maxBytes) {
421
+ throw new FsSafeError("too-large", `file exceeds limit of ${params.maxBytes} bytes (got ${buffer.byteLength})`);
422
+ }
368
423
  return {
369
424
  buffer,
370
425
  realPath: params.opened.realPath,
@@ -379,6 +434,9 @@ function buildAtomicWriteTempPath(targetPath) {
379
434
  const base = path.basename(targetPath);
380
435
  return path.join(dir, `.${base}.${process.pid}.${randomUUID()}.tmp`);
381
436
  }
437
+ function rootWriteQueueKey(root, relativePath) {
438
+ return `${root.rootReal}\0${relativePath}`;
439
+ }
382
440
  async function writeTempFileForAtomicReplace(params) {
383
441
  const tempHandle = await fs.open(params.tempPath, OPEN_WRITE_CREATE_FLAGS, params.mode);
384
442
  try {
@@ -395,7 +453,7 @@ async function writeTempFileForAtomicReplace(params) {
395
453
  }
396
454
  }
397
455
  async function verifyAtomicWriteResult(params) {
398
- const opened = await openVerifiedLocalFile(params.targetPath, { rejectHardlinks: true });
456
+ const opened = await openVerifiedLocalFile(params.targetPath, { hardlinks: "reject" });
399
457
  try {
400
458
  if (!sameFileIdentity(opened.stat, params.expectedIdentity)) {
401
459
  throw new FsSafeError("path-mismatch", "path changed during write");
@@ -482,8 +540,8 @@ async function resolveOpenedFileRealPathFromParent(handleStat, ioPath) {
482
540
  }
483
541
  return null;
484
542
  }
485
- async function openWritableFileInSafeRoot(root, params) {
486
- const { rootReal, rootWithSep, resolved } = await resolvePathInSafeRoot(root, params.relativePath);
543
+ async function openWritableFileInRoot(root, params) {
544
+ const { rootReal, rootWithSep, resolved } = await resolvePathInRoot(root, params.relativePath);
487
545
  try {
488
546
  await assertNoPathAliasEscape({
489
547
  absolutePath: resolved,
@@ -495,7 +553,10 @@ async function openWritableFileInSafeRoot(root, params) {
495
553
  throw new FsSafeError("path-alias", "path alias escape blocked", { cause: err });
496
554
  }
497
555
  if (params.mkdir !== false) {
498
- await fs.mkdir(path.dirname(resolved), { recursive: true });
556
+ const parentGuard = await createNearestExistingDirectoryGuard(rootReal, path.dirname(resolved));
557
+ await withAsyncDirectoryGuards([parentGuard], async () => {
558
+ await fs.mkdir(path.dirname(resolved), { recursive: true });
559
+ });
499
560
  }
500
561
  let ioPath = resolved;
501
562
  try {
@@ -513,20 +574,20 @@ async function openWritableFileInSafeRoot(root, params) {
513
574
  throw err;
514
575
  }
515
576
  }
516
- const fileMode = params.mode ?? 0o600;
577
+ const mode = params.mode ?? 0o600;
517
578
  let handle;
518
579
  let createdForWrite = false;
519
580
  const existingFlags = params.append ? OPEN_APPEND_EXISTING_FLAGS : OPEN_WRITE_EXISTING_FLAGS;
520
581
  const createFlags = params.append ? OPEN_APPEND_CREATE_FLAGS : OPEN_WRITE_CREATE_FLAGS;
521
582
  try {
522
583
  try {
523
- handle = await fs.open(ioPath, existingFlags, fileMode);
584
+ handle = await fs.open(ioPath, existingFlags, mode);
524
585
  }
525
586
  catch (err) {
526
587
  if (!isNotFoundPathError(err)) {
527
588
  throw err;
528
589
  }
529
- handle = await fs.open(ioPath, createFlags, fileMode);
590
+ handle = await fs.open(ioPath, createFlags, mode);
530
591
  createdForWrite = true;
531
592
  }
532
593
  }
@@ -537,6 +598,9 @@ async function openWritableFileInSafeRoot(root, params) {
537
598
  if (isSymlinkOpenError(err)) {
538
599
  throw new FsSafeError("symlink", "symlink open blocked", { cause: err });
539
600
  }
601
+ if (hasNodeErrorCode(err, "EISDIR")) {
602
+ throw new FsSafeError("not-file", "not a file", { cause: err });
603
+ }
540
604
  throw err;
541
605
  }
542
606
  let realPathForCleanup = null;
@@ -584,6 +648,9 @@ async function openWritableFileInSafeRoot(root, params) {
584
648
  createdForWrite,
585
649
  realPath,
586
650
  stat,
651
+ [Symbol.asyncDispose]: async () => {
652
+ await closeHandleForDispose(handle);
653
+ },
587
654
  };
588
655
  }
589
656
  catch (err) {
@@ -596,10 +663,11 @@ async function openWritableFileInSafeRoot(root, params) {
596
663
  throw err;
597
664
  }
598
665
  }
599
- async function appendFileInSafeRoot(root, params) {
600
- const target = await openWritableFileInSafeRoot(root, {
666
+ async function appendFileInRoot(root, params) {
667
+ const target = await openWritableFileInRoot(root, {
601
668
  relativePath: params.relativePath,
602
669
  mkdir: params.mkdir,
670
+ mode: params.mode,
603
671
  truncateExisting: false,
604
672
  append: true,
605
673
  });
@@ -627,8 +695,8 @@ async function appendFileInSafeRoot(root, params) {
627
695
  await target.handle.close().catch(() => { });
628
696
  }
629
697
  }
630
- async function removePathInSafeRoot(root, relativePath) {
631
- const resolved = await resolvePinnedRemovePathInSafeRoot(root, relativePath);
698
+ async function removePathInRoot(root, relativePath) {
699
+ const resolved = await resolvePinnedRemovePathInRoot(root, relativePath);
632
700
  if (process.platform === "win32") {
633
701
  await removePathFallback(resolved);
634
702
  return;
@@ -648,8 +716,8 @@ async function removePathInSafeRoot(root, relativePath) {
648
716
  throw normalizePinnedPathError(error);
649
717
  }
650
718
  }
651
- async function mkdirPathInSafeRoot(root, params) {
652
- const resolved = await resolvePinnedPathInSafeRoot(root, params);
719
+ async function mkdirPathInRoot(root, params) {
720
+ const resolved = await resolvePinnedPathInRoot(root, params);
653
721
  if (process.platform === "win32") {
654
722
  await mkdirPathFallback(resolved);
655
723
  return;
@@ -669,49 +737,57 @@ async function mkdirPathInSafeRoot(root, params) {
669
737
  throw normalizePinnedPathError(error);
670
738
  }
671
739
  }
672
- async function writeFileInSafeRoot(root, params) {
740
+ async function writeFileInRoot(root, params) {
673
741
  if (process.platform === "win32") {
674
- return await writeFileFallback(root, params);
675
- }
676
- const pinned = await resolvePinnedWriteTargetInSafeRoot(root, params.relativePath);
677
- let identity;
678
- try {
679
- identity = await runPinnedWriteHelper({
680
- rootPath: pinned.rootReal,
681
- relativeParentPath: pinned.relativeParentPath,
682
- basename: pinned.basename,
683
- mkdir: params.mkdir !== false,
684
- mode: pinned.mode,
685
- overwrite: params.overwrite,
686
- input: {
687
- kind: "buffer",
688
- data: params.data,
689
- encoding: params.encoding,
690
- },
742
+ await serializePathWrite(rootWriteQueueKey(root, params.relativePath), async () => {
743
+ await writeFileFallback(root, params);
691
744
  });
745
+ return;
692
746
  }
693
- catch (error) {
694
- if (params.overwrite === false && isAlreadyExistsError(error)) {
695
- return false;
747
+ const pinned = await resolvePinnedWriteTargetInRoot(root, params.relativePath, params.mode);
748
+ await serializePathWrite(pinned.targetPath, async () => {
749
+ let identity;
750
+ try {
751
+ identity = await runPinnedWriteHelper({
752
+ rootPath: pinned.rootReal,
753
+ relativeParentPath: pinned.relativeParentPath,
754
+ basename: pinned.basename,
755
+ mkdir: params.mkdir !== false,
756
+ mode: params.mode ?? pinned.mode,
757
+ overwrite: params.overwrite,
758
+ input: {
759
+ kind: "buffer",
760
+ data: params.data,
761
+ encoding: params.encoding,
762
+ },
763
+ });
696
764
  }
697
- throw normalizePinnedWriteError(error);
698
- }
699
- try {
700
- await verifyAtomicWriteResult({
701
- root,
702
- targetPath: pinned.targetPath,
703
- expectedIdentity: identity,
704
- });
705
- }
706
- catch (err) {
707
- emitWriteBoundaryWarning(`post-write verification failed: ${String(err)}`);
708
- throw err;
709
- }
710
- return true;
765
+ catch (error) {
766
+ if (params.overwrite === false && isAlreadyExistsError(error)) {
767
+ throw new FsSafeError("already-exists", "file already exists", {
768
+ cause: error instanceof Error ? error : undefined,
769
+ });
770
+ }
771
+ throw normalizePinnedWriteError(error);
772
+ }
773
+ try {
774
+ await verifyAtomicWriteResult({
775
+ root,
776
+ targetPath: pinned.targetPath,
777
+ expectedIdentity: identity,
778
+ });
779
+ }
780
+ catch (err) {
781
+ emitWriteBoundaryWarning(`post-write verification failed: ${String(err)}`);
782
+ throw err;
783
+ }
784
+ });
711
785
  }
712
- async function copyFileInSafeRoot(root, params) {
786
+ async function copyFileInRoot(root, params) {
787
+ assertValidRootRelativePath(params.relativePath);
788
+ assertNoNulPathInput(params.sourcePath, "source path contains a NUL byte");
713
789
  const source = await openVerifiedLocalFile(params.sourcePath, {
714
- rejectHardlinks: params.rejectSourceHardlinks,
790
+ hardlinks: params.sourceHardlinks,
715
791
  });
716
792
  if (params.maxBytes !== undefined && source.stat.size > params.maxBytes) {
717
793
  await source.handle.close().catch(() => { });
@@ -719,43 +795,57 @@ async function copyFileInSafeRoot(root, params) {
719
795
  }
720
796
  try {
721
797
  if (process.platform === "win32") {
722
- await copyFileFallback(root, params, source);
798
+ await serializePathWrite(rootWriteQueueKey(root, params.relativePath), async () => {
799
+ await copyFileFallback(root, params, source);
800
+ });
723
801
  return;
724
802
  }
725
- const pinned = await resolvePinnedWriteTargetInSafeRoot(root, params.relativePath);
726
- const sourceStream = source.handle.createReadStream();
727
- const identity = await runPinnedWriteHelper({
728
- rootPath: pinned.rootReal,
729
- relativeParentPath: pinned.relativeParentPath,
730
- basename: pinned.basename,
731
- mkdir: params.mkdir !== false,
732
- mode: pinned.mode,
733
- overwrite: true,
734
- input: {
735
- kind: "stream",
736
- stream: sourceStream,
737
- },
738
- }).catch((error) => {
739
- throw normalizePinnedWriteError(error);
803
+ const pinned = await resolvePinnedWriteTargetInRoot(root, params.relativePath, params.mode);
804
+ await serializePathWrite(pinned.targetPath, async () => {
805
+ let identity;
806
+ try {
807
+ if (getFsSafePythonConfig().mode === "off") {
808
+ await copyFileFallback(root, params, source);
809
+ return;
810
+ }
811
+ identity = await runPinnedCopyHelper({
812
+ rootPath: pinned.rootReal,
813
+ relativeParentPath: pinned.relativeParentPath,
814
+ basename: pinned.basename,
815
+ mkdir: params.mkdir !== false,
816
+ mode: pinned.mode,
817
+ overwrite: true,
818
+ maxBytes: params.maxBytes,
819
+ sourcePath: source.realPath,
820
+ sourceIdentity: { dev: source.stat.dev, ino: source.stat.ino },
821
+ });
822
+ }
823
+ catch (error) {
824
+ if (canFallbackFromPythonError(error)) {
825
+ await copyFileFallback(root, params, source);
826
+ return;
827
+ }
828
+ throw normalizePinnedWriteError(error);
829
+ }
830
+ try {
831
+ await verifyAtomicWriteResult({
832
+ root,
833
+ targetPath: pinned.targetPath,
834
+ expectedIdentity: identity,
835
+ });
836
+ }
837
+ catch (err) {
838
+ emitWriteBoundaryWarning(`post-copy verification failed: ${String(err)}`);
839
+ throw err;
840
+ }
740
841
  });
741
- try {
742
- await verifyAtomicWriteResult({
743
- root,
744
- targetPath: pinned.targetPath,
745
- expectedIdentity: identity,
746
- });
747
- }
748
- catch (err) {
749
- emitWriteBoundaryWarning(`post-copy verification failed: ${String(err)}`);
750
- throw err;
751
- }
752
842
  }
753
843
  finally {
754
844
  await source.handle.close().catch(() => { });
755
845
  }
756
846
  }
757
- async function resolvePinnedWriteTargetInSafeRoot(root, relativePath) {
758
- const { rootReal, rootWithSep, resolved } = await resolvePathInSafeRoot(root, relativePath);
847
+ async function resolvePinnedWriteTargetInRoot(root, relativePath, requestedMode) {
848
+ const { rootReal, rootWithSep, resolved } = await resolvePathInRoot(root, relativePath);
759
849
  try {
760
850
  await assertNoPathAliasEscape({
761
851
  absolutePath: resolved,
@@ -777,15 +867,15 @@ async function resolvePinnedWriteTargetInSafeRoot(root, relativePath) {
777
867
  if (!basename || basename === "." || basename === "/") {
778
868
  throw new FsSafeError("invalid-path", "invalid target path");
779
869
  }
780
- let mode = 0o600;
870
+ let mode = requestedMode ?? 0o600;
781
871
  try {
782
- const opened = await openFileInSafeRoot(root, {
872
+ const opened = await openFileInRoot(root, {
783
873
  relativePath,
784
- rejectHardlinks: true,
874
+ hardlinks: "reject",
785
875
  nonBlockingRead: true,
786
876
  });
787
877
  try {
788
- mode = opened.stat.mode & 0o777;
878
+ mode = requestedMode ?? (opened.stat.mode & 0o777);
789
879
  if (!isPathInside(rootWithSep, opened.realPath)) {
790
880
  throw new FsSafeError("outside-workspace", "file is outside workspace root");
791
881
  }
@@ -807,33 +897,28 @@ async function resolvePinnedWriteTargetInSafeRoot(root, relativePath) {
807
897
  mode: mode || 0o600,
808
898
  };
809
899
  }
810
- async function resolvePinnedPathInSafeRoot(root, params) {
811
- const resolved = await resolvePinnedRootPathInSafeRoot(root, {
900
+ async function resolvePinnedPathInRoot(root, params) {
901
+ return await resolvePinnedOperationPathInRoot(root, {
902
+ allowRoot: params.allowRoot,
812
903
  relativePath: params.relativePath,
813
904
  policy: PATH_ALIAS_POLICIES.strict,
814
905
  });
815
- const relativeResolved = path.relative(resolved.rootReal, resolved.canonicalPath);
816
- if ((relativeResolved === "" || relativeResolved === ".") && params.allowRoot === true) {
817
- return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix: "" };
818
- }
819
- if (relativeResolved === "" ||
820
- relativeResolved === "." ||
821
- relativeResolved.startsWith("..") ||
822
- path.isAbsolute(relativeResolved)) {
823
- throw new FsSafeError("outside-workspace", "file is outside workspace root");
824
- }
825
- const relativePosix = relativeResolved.split(path.sep).join(path.posix.sep);
826
- if (!isPathInside(resolved.rootWithSep, resolved.canonicalPath)) {
827
- throw new FsSafeError("outside-workspace", "file is outside workspace root");
828
- }
829
- return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix };
830
906
  }
831
- async function resolvePinnedRemovePathInSafeRoot(root, relativePath) {
832
- const resolved = await resolvePinnedRootPathInSafeRoot(root, {
907
+ async function resolvePinnedRemovePathInRoot(root, relativePath) {
908
+ return await resolvePinnedOperationPathInRoot(root, {
833
909
  relativePath,
834
910
  policy: PATH_ALIAS_POLICIES.unlinkTarget,
835
911
  });
912
+ }
913
+ async function resolvePinnedOperationPathInRoot(root, params) {
914
+ const resolved = await resolvePinnedRootPathInRoot(root, {
915
+ relativePath: params.relativePath,
916
+ policy: params.policy,
917
+ });
836
918
  const relativeResolved = path.relative(resolved.rootReal, resolved.canonicalPath);
919
+ if ((relativeResolved === "" || relativeResolved === ".") && params.allowRoot === true) {
920
+ return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix: "" };
921
+ }
837
922
  if (relativeResolved === "" ||
838
923
  relativeResolved === "." ||
839
924
  relativeResolved.startsWith("..") ||
@@ -844,13 +929,9 @@ async function resolvePinnedRemovePathInSafeRoot(root, relativePath) {
844
929
  if (!isPathInside(resolved.rootWithSep, resolved.canonicalPath)) {
845
930
  throw new FsSafeError("outside-workspace", "file is outside workspace root");
846
931
  }
847
- const parentRelative = path.posix.dirname(relativePosix);
848
- if (parentRelative === "." || parentRelative === "") {
849
- return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix };
850
- }
851
932
  return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix };
852
933
  }
853
- async function resolvePinnedRootPathInSafeRoot(root, params) {
934
+ async function resolvePinnedRootPathInRoot(root, params) {
854
935
  const rootReal = root.rootReal;
855
936
  let resolved;
856
937
  try {
@@ -872,54 +953,170 @@ async function resolvePinnedRootPathInSafeRoot(root, params) {
872
953
  canonicalPath: resolved.canonicalPath,
873
954
  };
874
955
  }
875
- function normalizePinnedWriteError(error) {
876
- if (error instanceof FsSafeError) {
877
- return error;
878
- }
879
- return new FsSafeError("invalid-path", "path is not a regular file under root", {
880
- cause: error instanceof Error ? error : undefined,
881
- });
956
+ async function removePathFallback(resolved) {
957
+ const guard = await createAsyncDirectoryGuard(path.dirname(resolved.resolved));
958
+ await getFsSafeTestHooks()?.beforeRootFallbackMutation?.("remove", resolved.resolved);
959
+ await assertAsyncDirectoryGuard(guard);
960
+ await ((await fs.lstat(resolved.resolved)).isDirectory() ? fs.rmdir(resolved.resolved) : fs.rm(resolved.resolved));
961
+ await assertAsyncDirectoryGuard(guard).catch(() => undefined);
882
962
  }
883
- function isAlreadyExistsError(error) {
884
- return hasNodeErrorCode(error, "EEXIST") || /File exists|EEXIST/i.test(String(error));
963
+ async function mkdirPathFallback(resolved) {
964
+ await mkdirPathComponentsWithGuards({
965
+ rootReal: resolved.rootReal, targetPath: resolved.resolved,
966
+ beforeComponent: async (componentPath) => await getFsSafeTestHooks()?.beforeRootFallbackMutation?.("mkdir", componentPath),
967
+ });
885
968
  }
886
- function normalizePinnedPathError(error) {
887
- if (error instanceof FsSafeError) {
888
- return error;
969
+ async function statPathFallback(root, relativePath) {
970
+ const resolved = await resolvePinnedPathInRoot(root, { relativePath, allowRoot: true });
971
+ try {
972
+ return pathStatFromStats(await fs.lstat(resolved.resolved));
973
+ }
974
+ catch (error) {
975
+ if (isNotFoundPathError(error)) {
976
+ throw new FsSafeError("not-found", "file not found", {
977
+ cause: error instanceof Error ? error : undefined,
978
+ });
979
+ }
980
+ throw error;
889
981
  }
890
- return new FsSafeError("path-alias", "path is not under root", {
891
- cause: error instanceof Error ? error : undefined,
892
- });
893
982
  }
894
- async function removePathFallback(resolved) {
895
- await fs.rm(resolved.resolved);
983
+ async function listPathFallback(root, relativePath, withFileTypes) {
984
+ const resolved = await resolvePinnedPathInRoot(root, { relativePath, allowRoot: true });
985
+ try {
986
+ const names = await fs.readdir(resolved.resolved);
987
+ const sortedNames = names.toSorted();
988
+ if (!withFileTypes) {
989
+ return sortedNames;
990
+ }
991
+ const entries = [];
992
+ for (const name of sortedNames) {
993
+ entries.push({
994
+ name,
995
+ ...pathStatFromStats(await fs.lstat(path.join(resolved.resolved, name))),
996
+ });
997
+ }
998
+ return entries;
999
+ }
1000
+ catch (error) {
1001
+ if (isNotFoundPathError(error)) {
1002
+ throw new FsSafeError("not-found", "directory not found", {
1003
+ cause: error instanceof Error ? error : undefined,
1004
+ });
1005
+ }
1006
+ throw error;
1007
+ }
896
1008
  }
897
- async function mkdirPathFallback(resolved) {
898
- await fs.mkdir(resolved.resolved, { recursive: true });
1009
+ async function movePathFallback(root, params) {
1010
+ const source = await resolvePathInRoot(root, params.fromRelative);
1011
+ await resolvePinnedRootPathInRoot(root, {
1012
+ relativePath: params.fromRelative,
1013
+ policy: PATH_ALIAS_POLICIES.strict,
1014
+ });
1015
+ const target = await resolvePathInRoot(root, params.toRelative);
1016
+ await resolvePinnedRootPathInRoot(root, {
1017
+ relativePath: params.toRelative,
1018
+ policy: PATH_ALIAS_POLICIES.unlinkTarget,
1019
+ });
1020
+ try {
1021
+ await assertNoPathAliasEscape({
1022
+ absolutePath: target.resolved,
1023
+ rootPath: target.rootReal,
1024
+ boundaryLabel: "root",
1025
+ });
1026
+ }
1027
+ catch (error) {
1028
+ throw new FsSafeError("path-alias", "path alias escape blocked", {
1029
+ cause: error instanceof Error ? error : undefined,
1030
+ });
1031
+ }
1032
+ let sourceStat;
1033
+ try {
1034
+ sourceStat = await fs.lstat(source.resolved);
1035
+ }
1036
+ catch (error) {
1037
+ if (isNotFoundPathError(error)) {
1038
+ throw new FsSafeError("not-found", "file not found", {
1039
+ cause: error instanceof Error ? error : undefined,
1040
+ });
1041
+ }
1042
+ throw error;
1043
+ }
1044
+ if (sourceStat.isSymbolicLink()) {
1045
+ throw new FsSafeError("symlink", "symlink not allowed");
1046
+ }
1047
+ if (sourceStat.isFile() && sourceStat.nlink > 1) {
1048
+ throw new FsSafeError("hardlink", "hardlinked path not allowed");
1049
+ }
1050
+ if (!params.overwrite) {
1051
+ try {
1052
+ await fs.lstat(target.resolved);
1053
+ throw new FsSafeError("already-exists", "destination exists");
1054
+ }
1055
+ catch (error) {
1056
+ if (error instanceof FsSafeError) {
1057
+ throw error;
1058
+ }
1059
+ if (!isNotFoundPathError(error)) {
1060
+ throw error;
1061
+ }
1062
+ }
1063
+ }
1064
+ const sourceParentGuard = await createAsyncDirectoryGuard(path.dirname(source.resolved));
1065
+ const targetParentGuard = await createNearestExistingDirectoryGuard(target.rootReal, path.dirname(target.resolved));
1066
+ await getFsSafeTestHooks()?.beforeRootFallbackMutation?.("move", target.resolved);
1067
+ await assertAsyncDirectoryGuard(sourceParentGuard);
1068
+ await assertAsyncDirectoryGuard(targetParentGuard);
1069
+ try {
1070
+ await fs.rename(source.resolved, target.resolved);
1071
+ }
1072
+ catch (error) {
1073
+ if (isNotFoundPathError(error)) {
1074
+ throw new FsSafeError("not-found", "file not found", {
1075
+ cause: error instanceof Error ? error : undefined,
1076
+ });
1077
+ }
1078
+ if (hasNodeErrorCode(error, "EEXIST")) {
1079
+ throw new FsSafeError("already-exists", "destination exists", {
1080
+ cause: error instanceof Error ? error : undefined,
1081
+ });
1082
+ }
1083
+ throw error;
1084
+ }
1085
+ await assertAsyncDirectoryGuard(targetParentGuard).catch(() => undefined);
899
1086
  }
900
1087
  async function writeFileFallback(root, params) {
901
1088
  if (params.overwrite === false) {
902
- return await writeMissingFileFallback(root, params);
1089
+ await writeMissingFileFallback(root, params);
1090
+ return;
903
1091
  }
904
- const target = await openWritableFileInSafeRoot(root, {
1092
+ const target = await openWritableFileInRoot(root, {
905
1093
  relativePath: params.relativePath,
906
1094
  mkdir: params.mkdir,
1095
+ mode: params.mode,
907
1096
  truncateExisting: false,
908
1097
  });
909
1098
  const destinationPath = target.realPath;
910
- const targetMode = target.stat.mode & 0o777;
1099
+ const mode = params.mode ?? (target.stat.mode & 0o777);
911
1100
  await target.handle.close().catch(() => { });
1101
+ const destinationGuard = await createAsyncDirectoryGuard(path.dirname(destinationPath));
912
1102
  let tempPath = null;
1103
+ let unregisterTempPath = null;
913
1104
  try {
914
1105
  tempPath = buildAtomicWriteTempPath(destinationPath);
1106
+ unregisterTempPath = registerTempPathForExit(tempPath);
915
1107
  const writtenStat = await writeTempFileForAtomicReplace({
916
1108
  tempPath,
917
1109
  data: params.data,
918
1110
  encoding: params.encoding,
919
- mode: targetMode || 0o600,
1111
+ mode: mode || 0o600,
1112
+ });
1113
+ const commitTempPath = tempPath;
1114
+ await withAsyncDirectoryGuards([destinationGuard], async () => {
1115
+ await fs.rename(commitTempPath, destinationPath);
920
1116
  });
921
- await fs.rename(tempPath, destinationPath);
922
1117
  tempPath = null;
1118
+ unregisterTempPath();
1119
+ unregisterTempPath = null;
923
1120
  try {
924
1121
  await verifyAtomicWriteResult({
925
1122
  root,
@@ -936,11 +1133,11 @@ async function writeFileFallback(root, params) {
936
1133
  if (tempPath) {
937
1134
  await fs.rm(tempPath, { force: true }).catch(() => { });
938
1135
  }
1136
+ unregisterTempPath?.();
939
1137
  }
940
- return true;
941
1138
  }
942
1139
  async function writeMissingFileFallback(root, params) {
943
- const { rootReal, resolved } = await resolvePathInSafeRoot(root, params.relativePath);
1140
+ const { rootReal, resolved } = await resolvePathInRoot(root, params.relativePath);
944
1141
  try {
945
1142
  await assertNoPathAliasEscape({
946
1143
  absolutePath: resolved,
@@ -954,36 +1151,48 @@ async function writeMissingFileFallback(root, params) {
954
1151
  if (params.mkdir !== false) {
955
1152
  await fs.mkdir(path.dirname(resolved), { recursive: true });
956
1153
  }
957
- let handle = null;
1154
+ const parentGuard = await createAsyncDirectoryGuard(path.dirname(resolved));
958
1155
  let created = false;
959
1156
  try {
960
- handle = await fs.open(resolved, OPEN_WRITE_CREATE_FLAGS, 0o600);
961
- created = true;
962
- if (typeof params.data === "string") {
963
- await handle.writeFile(params.data, params.encoding ?? "utf8");
964
- }
965
- else {
966
- await handle.writeFile(params.data);
967
- }
968
- const writtenStat = await handle.stat();
1157
+ const { handle, writtenStat } = await withAsyncDirectoryGuards([parentGuard], async () => {
1158
+ const handle = await fs.open(resolved, OPEN_WRITE_CREATE_FLAGS, params.mode ?? 0o600);
1159
+ created = true;
1160
+ try {
1161
+ if (typeof params.data === "string") {
1162
+ await handle.writeFile(params.data, params.encoding ?? "utf8");
1163
+ }
1164
+ else {
1165
+ await handle.writeFile(params.data);
1166
+ }
1167
+ return { handle, writtenStat: await handle.stat() };
1168
+ }
1169
+ catch (error) {
1170
+ await handle.close().catch(() => undefined);
1171
+ throw error;
1172
+ }
1173
+ }, {
1174
+ onPostGuardFailure: async ({ handle }) => {
1175
+ created = false; // Parent is untrusted now; skip outer path cleanup by name.
1176
+ await handle.close().catch(() => undefined);
1177
+ },
1178
+ });
969
1179
  await handle.close();
970
- handle = null;
971
1180
  await verifyAtomicWriteResult({
972
1181
  root,
973
1182
  targetPath: resolved,
974
1183
  expectedIdentity: writtenStat,
975
1184
  });
976
1185
  created = false;
977
- return true;
978
1186
  }
979
1187
  catch (err) {
980
1188
  if (hasNodeErrorCode(err, "EEXIST")) {
981
- return false;
1189
+ throw new FsSafeError("already-exists", "file already exists", {
1190
+ cause: err instanceof Error ? err : undefined,
1191
+ });
982
1192
  }
983
1193
  throw err;
984
1194
  }
985
1195
  finally {
986
- await handle?.close().catch(() => undefined);
987
1196
  if (created) {
988
1197
  await fs.rm(resolved, { force: true }).catch(() => undefined);
989
1198
  }
@@ -995,20 +1204,24 @@ async function copyFileFallback(root, params, source) {
995
1204
  let targetClosedByUs = false;
996
1205
  let tempHandle = null;
997
1206
  let tempPath = null;
1207
+ let unregisterTempPath = null;
998
1208
  let tempClosedByStream = false;
999
1209
  try {
1000
- target = await openWritableFileInSafeRoot(root, {
1210
+ target = await openWritableFileInRoot(root, {
1001
1211
  relativePath: params.relativePath,
1002
1212
  mkdir: params.mkdir,
1213
+ mode: params.mode,
1003
1214
  truncateExisting: false,
1004
1215
  });
1005
1216
  const destinationPath = target.realPath;
1006
- const targetMode = target.stat.mode & 0o777;
1217
+ const mode = params.mode ?? (target.stat.mode & 0o777);
1007
1218
  await target.handle.close().catch(() => { });
1008
1219
  targetClosedByUs = true;
1220
+ const destinationGuard = await createAsyncDirectoryGuard(path.dirname(destinationPath));
1009
1221
  tempPath = buildAtomicWriteTempPath(destinationPath);
1010
- tempHandle = await fs.open(tempPath, OPEN_WRITE_CREATE_FLAGS, targetMode || 0o600);
1011
- const sourceStream = source.handle.createReadStream();
1222
+ unregisterTempPath = registerTempPathForExit(tempPath);
1223
+ tempHandle = await fs.open(tempPath, OPEN_WRITE_CREATE_FLAGS, mode || 0o600);
1224
+ const sourceStream = createBoundedReadStream(source, params.maxBytes);
1012
1225
  const targetStream = tempHandle.createWriteStream();
1013
1226
  sourceStream.once("close", () => {
1014
1227
  sourceClosedByStream = true;
@@ -1023,8 +1236,13 @@ async function copyFileFallback(root, params, source) {
1023
1236
  tempClosedByStream = true;
1024
1237
  }
1025
1238
  tempHandle = null;
1026
- await fs.rename(tempPath, destinationPath);
1239
+ const commitTempPath = tempPath;
1240
+ await withAsyncDirectoryGuards([destinationGuard], async () => {
1241
+ await fs.rename(commitTempPath, destinationPath);
1242
+ });
1027
1243
  tempPath = null;
1244
+ unregisterTempPath();
1245
+ unregisterTempPath = null;
1028
1246
  try {
1029
1247
  await verifyAtomicWriteResult({
1030
1248
  root,
@@ -1044,15 +1262,16 @@ async function copyFileFallback(root, params, source) {
1044
1262
  throw err;
1045
1263
  }
1046
1264
  finally {
1047
- if (tempPath) {
1048
- await fs.rm(tempPath, { force: true }).catch(() => { });
1049
- }
1050
1265
  if (!sourceClosedByStream) {
1051
1266
  await source.handle.close().catch(() => { });
1052
1267
  }
1053
1268
  if (tempHandle && !tempClosedByStream) {
1054
1269
  await tempHandle.close().catch(() => { });
1055
1270
  }
1271
+ if (tempPath) {
1272
+ await fs.rm(tempPath, { force: true }).catch(() => { });
1273
+ }
1274
+ unregisterTempPath?.();
1056
1275
  if (target && !targetClosedByUs) {
1057
1276
  await target.handle.close().catch(() => { });
1058
1277
  }