@openclaw/fs-safe 0.1.0 → 0.1.2

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