@openclaw/fs-safe 0.2.1 → 0.2.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 (58) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/archive-utils.d.ts +3 -0
  3. package/dist/archive-utils.d.ts.map +1 -0
  4. package/dist/archive-utils.js +26 -0
  5. package/dist/boundary-file-read.d.ts +44 -0
  6. package/dist/boundary-file-read.d.ts.map +1 -0
  7. package/dist/boundary-file-read.js +129 -0
  8. package/dist/boundary-path.d.ts +39 -0
  9. package/dist/boundary-path.d.ts.map +1 -0
  10. package/dist/boundary-path.js +598 -0
  11. package/dist/fs-pinned-path-helper.d.ts +7 -0
  12. package/dist/fs-pinned-path-helper.d.ts.map +1 -0
  13. package/dist/fs-pinned-path-helper.js +182 -0
  14. package/dist/fs-pinned-write-helper.d.ts +21 -0
  15. package/dist/fs-pinned-write-helper.d.ts.map +1 -0
  16. package/dist/fs-pinned-write-helper.js +263 -0
  17. package/dist/hardlink-guards.d.ts +7 -0
  18. package/dist/hardlink-guards.d.ts.map +1 -0
  19. package/dist/hardlink-guards.js +30 -0
  20. package/dist/install-safe-path.d.ts +20 -0
  21. package/dist/install-safe-path.d.ts.map +1 -0
  22. package/dist/install-safe-path.js +94 -0
  23. package/dist/json-file.d.ts +3 -0
  24. package/dist/json-file.d.ts.map +1 -0
  25. package/dist/json-file.js +123 -0
  26. package/dist/json-files.d.ts +20 -0
  27. package/dist/json-files.d.ts.map +1 -0
  28. package/dist/json-files.js +153 -0
  29. package/dist/path-alias-guards.d.ts +19 -0
  30. package/dist/path-alias-guards.d.ts.map +1 -0
  31. package/dist/path-alias-guards.js +21 -0
  32. package/dist/path-guards.d.ts +7 -0
  33. package/dist/path-guards.d.ts.map +1 -0
  34. package/dist/path-guards.js +49 -0
  35. package/dist/path-safety.d.ts +12 -0
  36. package/dist/path-safety.d.ts.map +1 -0
  37. package/dist/path-safety.js +50 -0
  38. package/dist/pinned-python-config.d.ts.map +1 -1
  39. package/dist/pinned-python-config.js +2 -3
  40. package/dist/private-file-store.d.ts +7 -5
  41. package/dist/private-file-store.d.ts.map +1 -1
  42. package/dist/private-file-store.js +34 -21
  43. package/dist/safe-open-sync.d.ts +24 -0
  44. package/dist/safe-open-sync.d.ts.map +1 -0
  45. package/dist/safe-open-sync.js +71 -0
  46. package/dist/safe-root.d.ts +123 -0
  47. package/dist/safe-root.d.ts.map +1 -0
  48. package/dist/safe-root.js +1060 -0
  49. package/dist/secure-temp-workspace.d.ts +25 -0
  50. package/dist/secure-temp-workspace.d.ts.map +1 -0
  51. package/dist/secure-temp-workspace.js +136 -0
  52. package/dist/sibling-temp-file.d.ts +16 -0
  53. package/dist/sibling-temp-file.d.ts.map +1 -0
  54. package/dist/sibling-temp-file.js +73 -0
  55. package/dist/sibling-temp-write.d.ts +8 -0
  56. package/dist/sibling-temp-write.d.ts.map +1 -0
  57. package/dist/sibling-temp-write.js +40 -0
  58. package/package.json +1 -1
@@ -0,0 +1,1060 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants as fsConstants } from "node:fs";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { pipeline } from "node:stream/promises";
7
+ import { FsSafeError } from "./errors.js";
8
+ import { sameFileIdentity } from "./file-identity.js";
9
+ import { isPinnedPathHelperSpawnError, runPinnedPathHelper } from "./pinned-path.js";
10
+ import { runPinnedWriteHelper } from "./pinned-write.js";
11
+ import { expandHomePrefix } from "./home-dir.js";
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";
15
+ import { resolveRootPath } from "./root-path.js";
16
+ import { getFsSafeTestHooks } from "./test-hooks.js";
17
+ function logWarn(message) {
18
+ if (process.env.FS_SAFE_DEBUG_WARNINGS === "1") {
19
+ console.warn(message);
20
+ }
21
+ }
22
+ const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants;
23
+ const NONBLOCK_OPEN_FLAG = "O_NONBLOCK" in fsConstants ? fsConstants.O_NONBLOCK : 0;
24
+ const OPEN_READ_FLAGS = fsConstants.O_RDONLY | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
25
+ const OPEN_READ_NONBLOCK_FLAGS = OPEN_READ_FLAGS | NONBLOCK_OPEN_FLAG;
26
+ const OPEN_READ_FOLLOW_FLAGS = fsConstants.O_RDONLY;
27
+ const OPEN_READ_FOLLOW_NONBLOCK_FLAGS = OPEN_READ_FOLLOW_FLAGS | NONBLOCK_OPEN_FLAG;
28
+ const OPEN_WRITE_EXISTING_FLAGS = fsConstants.O_WRONLY | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
29
+ const OPEN_WRITE_CREATE_FLAGS = fsConstants.O_WRONLY |
30
+ fsConstants.O_CREAT |
31
+ fsConstants.O_EXCL |
32
+ (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
33
+ const OPEN_APPEND_EXISTING_FLAGS = fsConstants.O_RDWR | fsConstants.O_APPEND | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
34
+ const OPEN_APPEND_CREATE_FLAGS = fsConstants.O_RDWR |
35
+ fsConstants.O_APPEND |
36
+ fsConstants.O_CREAT |
37
+ fsConstants.O_EXCL |
38
+ (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 });
54
+ }
55
+ async function openVerifiedLocalFile(filePath, options) {
56
+ const fsSafeTestHooks = getFsSafeTestHooks();
57
+ // Reject directories before opening so we never surface EISDIR to callers (e.g. tool
58
+ // results that get sent to messaging channels). See openclaw/openclaw#31186.
59
+ try {
60
+ const preStat = await fs.lstat(filePath);
61
+ if (preStat.isDirectory()) {
62
+ throw new FsSafeError("not-file", "not a file");
63
+ }
64
+ await fsSafeTestHooks?.afterPreOpenLstat?.(filePath);
65
+ }
66
+ catch (err) {
67
+ if (err instanceof FsSafeError) {
68
+ throw err;
69
+ }
70
+ // ENOENT and other lstat errors: fall through and let fs.open handle.
71
+ }
72
+ let handle;
73
+ try {
74
+ const openFlags = options?.symlinkPolicy === "follow-within-root"
75
+ ? options?.nonBlockingRead
76
+ ? OPEN_READ_FOLLOW_NONBLOCK_FLAGS
77
+ : OPEN_READ_FOLLOW_FLAGS
78
+ : options?.nonBlockingRead
79
+ ? OPEN_READ_NONBLOCK_FLAGS
80
+ : OPEN_READ_FLAGS;
81
+ await fsSafeTestHooks?.beforeOpen?.(filePath, openFlags);
82
+ handle = await fs.open(filePath, openFlags);
83
+ try {
84
+ await fsSafeTestHooks?.afterOpen?.(filePath, handle);
85
+ }
86
+ catch (err) {
87
+ await handle.close().catch(() => { });
88
+ throw err;
89
+ }
90
+ }
91
+ catch (err) {
92
+ if (isNotFoundPathError(err)) {
93
+ throw new FsSafeError("not-found", "file not found");
94
+ }
95
+ if (isSymlinkOpenError(err)) {
96
+ throw new FsSafeError("symlink", "symlink open blocked", { cause: err });
97
+ }
98
+ // Defensive: if open still throws EISDIR (e.g. race), sanitize so it never leaks.
99
+ if (hasNodeErrorCode(err, "EISDIR")) {
100
+ throw new FsSafeError("not-file", "not a file");
101
+ }
102
+ throw err;
103
+ }
104
+ try {
105
+ const stat = await handle.stat();
106
+ if (!stat.isFile()) {
107
+ throw new FsSafeError("not-file", "not a file");
108
+ }
109
+ if (options?.rejectHardlinks && stat.nlink > 1) {
110
+ throw new FsSafeError("hardlink", "hardlinked path not allowed");
111
+ }
112
+ if (options?.symlinkPolicy === "follow-within-root") {
113
+ const pathStat = await fs.stat(filePath);
114
+ if (!sameFileIdentity(stat, pathStat)) {
115
+ throw new FsSafeError("path-mismatch", "path changed during read");
116
+ }
117
+ }
118
+ else {
119
+ const pathStat = await fs.lstat(filePath);
120
+ if (pathStat.isSymbolicLink()) {
121
+ throw new FsSafeError("symlink", "symlink not allowed");
122
+ }
123
+ if (!sameFileIdentity(stat, pathStat)) {
124
+ throw new FsSafeError("path-mismatch", "path changed during read");
125
+ }
126
+ }
127
+ const realPath = await resolveOpenedFileRealPathForHandle(handle, filePath);
128
+ const realStat = await fs.stat(realPath);
129
+ if (options?.rejectHardlinks && realStat.nlink > 1) {
130
+ throw new FsSafeError("hardlink", "hardlinked path not allowed");
131
+ }
132
+ if (!sameFileIdentity(stat, realStat)) {
133
+ throw new FsSafeError("path-mismatch", "path mismatch");
134
+ }
135
+ return { handle, realPath, stat };
136
+ }
137
+ catch (err) {
138
+ await handle.close().catch(() => { });
139
+ if (err instanceof FsSafeError) {
140
+ throw err;
141
+ }
142
+ if (isNotFoundPathError(err)) {
143
+ throw new FsSafeError("not-found", "file not found");
144
+ }
145
+ throw err;
146
+ }
147
+ }
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 {
177
+ rootDir;
178
+ rootReal;
179
+ rootWithSep;
180
+ defaults;
181
+ constructor(context, defaults = {}) {
182
+ this.rootDir = context.rootDir;
183
+ this.rootReal = context.rootReal;
184
+ this.rootWithSep = context.rootWithSep;
185
+ this.defaults = defaults;
186
+ }
187
+ get context() {
188
+ return {
189
+ rootDir: this.rootDir,
190
+ rootReal: this.rootReal,
191
+ rootWithSep: this.rootWithSep,
192
+ };
193
+ }
194
+ async resolve(relativePath) {
195
+ return (await resolvePathInSafeRoot(this.context, relativePath)).resolved;
196
+ }
197
+ async openFile(relativePath, options = {}) {
198
+ return await openFileInSafeRoot(this.context, {
199
+ relativePath,
200
+ ...this.defaults.read,
201
+ ...this.defaults.open,
202
+ ...options,
203
+ });
204
+ }
205
+ async readFile(relativePath, options = {}) {
206
+ return await readFileInSafeRoot(this.context, {
207
+ relativePath,
208
+ ...this.defaults.read,
209
+ ...options,
210
+ });
211
+ }
212
+ async readFileBytes(relativePath, options = {}) {
213
+ return (await this.readFile(relativePath, options)).buffer;
214
+ }
215
+ async readText(relativePath, options = {}) {
216
+ const { encoding = "utf8", ...readOptions } = options;
217
+ return (await this.readFile(relativePath, readOptions)).buffer.toString(encoding);
218
+ }
219
+ async readJson(relativePath, options = {}) {
220
+ return JSON.parse(await this.readText(relativePath, options));
221
+ }
222
+ async readPath(filePath, options = {}) {
223
+ return await readPathInSafeRoot(this.context, { filePath, ...this.defaults.read, ...options });
224
+ }
225
+ reader(options = {}) {
226
+ return async (filePath) => {
227
+ return (await this.readPath(filePath, options)).buffer;
228
+ };
229
+ }
230
+ async openWritable(relativePath, options = {}) {
231
+ return await openWritableFileInSafeRoot(this.context, {
232
+ relativePath,
233
+ ...this.defaults.openWritable,
234
+ ...options,
235
+ });
236
+ }
237
+ async append(relativePath, data, options = {}) {
238
+ await appendFileInSafeRoot(this.context, {
239
+ relativePath,
240
+ data,
241
+ ...this.defaults.append,
242
+ ...options,
243
+ });
244
+ }
245
+ async removeFile(relativePath) {
246
+ await removePathInSafeRoot(this.context, relativePath);
247
+ }
248
+ async mkdirp(relativePath, options = {}) {
249
+ await mkdirPathInSafeRoot(this.context, { relativePath, ...options });
250
+ }
251
+ async writeFile(relativePath, data, options = {}) {
252
+ await writeFileInSafeRoot(this.context, {
253
+ relativePath,
254
+ data,
255
+ ...this.defaults.write,
256
+ ...options,
257
+ });
258
+ }
259
+ async createFile(relativePath, data, options = {}) {
260
+ return await writeFileInSafeRoot(this.context, {
261
+ relativePath,
262
+ data,
263
+ ...this.defaults.write,
264
+ ...options,
265
+ overwrite: false,
266
+ });
267
+ }
268
+ async writeJson(relativePath, data, options = {}) {
269
+ const { replacer, space, trailingNewline = true, ...writeOptions } = options;
270
+ const json = JSON.stringify(data, replacer, space);
271
+ await this.writeFile(relativePath, trailingNewline ? `${json}\n` : json, writeOptions);
272
+ }
273
+ async createJson(relativePath, data, options = {}) {
274
+ const { replacer, space, trailingNewline = true, ...writeOptions } = options;
275
+ const json = JSON.stringify(data, replacer, space);
276
+ return await this.createFile(relativePath, trailingNewline ? `${json}\n` : json, writeOptions);
277
+ }
278
+ async copyFileFrom(sourcePath, relativePath, options = {}) {
279
+ await copyFileInSafeRoot(this.context, {
280
+ sourcePath,
281
+ relativePath,
282
+ ...this.defaults.copy,
283
+ ...options,
284
+ });
285
+ }
286
+ async statPath(relativePath) {
287
+ return await helperStat(this.rootReal, relativePath);
288
+ }
289
+ async listDir(relativePath, options = {}) {
290
+ return options.withFileTypes === true
291
+ ? await helperReaddir(this.rootReal, relativePath, true)
292
+ : await helperReaddir(this.rootReal, relativePath, false);
293
+ }
294
+ async move(from, to, options = {}) {
295
+ await runPinnedHelper("rename", this.rootReal, {
296
+ from,
297
+ overwrite: options.overwrite ?? true,
298
+ to,
299
+ });
300
+ }
301
+ }
302
+ export async function safeRoot(rootDir, defaults = {}) {
303
+ return new SafeRoot(await resolveSafeRootContext(rootDir), defaults);
304
+ }
305
+ async function openFileInSafeRoot(root, params) {
306
+ const { rootWithSep, resolved } = await resolvePathInSafeRoot(root, params.relativePath);
307
+ let opened;
308
+ try {
309
+ opened = await openVerifiedLocalFile(resolved, {
310
+ nonBlockingRead: params.nonBlockingRead,
311
+ symlinkPolicy: params.symlinkPolicy,
312
+ });
313
+ }
314
+ catch (err) {
315
+ if (err instanceof FsSafeError) {
316
+ throw err;
317
+ }
318
+ throw err;
319
+ }
320
+ if (params.rejectHardlinks !== false && opened.stat.nlink > 1) {
321
+ await opened.handle.close().catch(() => { });
322
+ throw new FsSafeError("hardlink", "hardlinked path not allowed");
323
+ }
324
+ if (!isPathInside(rootWithSep, opened.realPath)) {
325
+ await opened.handle.close().catch(() => { });
326
+ throw new FsSafeError("outside-workspace", "file is outside workspace root");
327
+ }
328
+ return opened;
329
+ }
330
+ async function readFileInSafeRoot(root, params) {
331
+ const opened = await openFileInSafeRoot(root, params);
332
+ try {
333
+ return await readOpenedFileSafely({ opened, maxBytes: params.maxBytes });
334
+ }
335
+ finally {
336
+ await opened.handle.close().catch(() => { });
337
+ }
338
+ }
339
+ async function readPathInSafeRoot(root, params) {
340
+ const rootDir = root.rootDir;
341
+ const candidatePath = path.isAbsolute(params.filePath)
342
+ ? path.resolve(params.filePath)
343
+ : path.resolve(rootDir, params.filePath);
344
+ const relativePath = path.relative(rootDir, candidatePath);
345
+ return await readFileInSafeRoot(root, {
346
+ relativePath,
347
+ rejectHardlinks: params.rejectHardlinks,
348
+ maxBytes: params.maxBytes,
349
+ });
350
+ }
351
+ export async function readLocalFileSafely(params) {
352
+ const opened = await openLocalFileSafely({ filePath: params.filePath });
353
+ try {
354
+ return await readOpenedFileSafely({ opened, maxBytes: params.maxBytes });
355
+ }
356
+ finally {
357
+ await opened.handle.close().catch(() => { });
358
+ }
359
+ }
360
+ export async function openLocalFileSafely(params) {
361
+ return await openVerifiedLocalFile(params.filePath);
362
+ }
363
+ async function readOpenedFileSafely(params) {
364
+ if (params.maxBytes !== undefined && params.opened.stat.size > params.maxBytes) {
365
+ throw new FsSafeError("too-large", `file exceeds limit of ${params.maxBytes} bytes (got ${params.opened.stat.size})`);
366
+ }
367
+ const buffer = await params.opened.handle.readFile();
368
+ return {
369
+ buffer,
370
+ realPath: params.opened.realPath,
371
+ stat: params.opened.stat,
372
+ };
373
+ }
374
+ function emitWriteBoundaryWarning(reason) {
375
+ logWarn(`security: fs-safe write boundary warning (${reason})`);
376
+ }
377
+ function buildAtomicWriteTempPath(targetPath) {
378
+ const dir = path.dirname(targetPath);
379
+ const base = path.basename(targetPath);
380
+ return path.join(dir, `.${base}.${process.pid}.${randomUUID()}.tmp`);
381
+ }
382
+ async function writeTempFileForAtomicReplace(params) {
383
+ const tempHandle = await fs.open(params.tempPath, OPEN_WRITE_CREATE_FLAGS, params.mode);
384
+ try {
385
+ if (typeof params.data === "string") {
386
+ await tempHandle.writeFile(params.data, params.encoding ?? "utf8");
387
+ }
388
+ else {
389
+ await tempHandle.writeFile(params.data);
390
+ }
391
+ return await tempHandle.stat();
392
+ }
393
+ finally {
394
+ await tempHandle.close().catch(() => { });
395
+ }
396
+ }
397
+ async function verifyAtomicWriteResult(params) {
398
+ const opened = await openVerifiedLocalFile(params.targetPath, { rejectHardlinks: true });
399
+ try {
400
+ if (!sameFileIdentity(opened.stat, params.expectedIdentity)) {
401
+ throw new FsSafeError("path-mismatch", "path changed during write");
402
+ }
403
+ if (!isPathInside(params.root.rootWithSep, opened.realPath)) {
404
+ throw new FsSafeError("outside-workspace", "file is outside workspace root");
405
+ }
406
+ }
407
+ finally {
408
+ await opened.handle.close().catch(() => { });
409
+ }
410
+ }
411
+ export async function resolveOpenedFileRealPathForHandle(handle, ioPath) {
412
+ const handleStat = await handle.stat();
413
+ const fdCandidates = process.platform === "linux"
414
+ ? [`/proc/self/fd/${handle.fd}`, `/dev/fd/${handle.fd}`]
415
+ : process.platform === "win32"
416
+ ? []
417
+ : [`/dev/fd/${handle.fd}`];
418
+ for (const fdPath of fdCandidates) {
419
+ try {
420
+ const fdRealPath = await fs.realpath(fdPath);
421
+ const fdRealStat = await fs.stat(fdRealPath);
422
+ if (sameFileIdentity(handleStat, fdRealStat)) {
423
+ return fdRealPath;
424
+ }
425
+ }
426
+ catch {
427
+ // try next fd path
428
+ }
429
+ }
430
+ try {
431
+ const ioRealPath = await fs.realpath(ioPath);
432
+ const ioRealStat = await fs.stat(ioRealPath);
433
+ if (sameFileIdentity(handleStat, ioRealStat)) {
434
+ return ioRealPath;
435
+ }
436
+ }
437
+ catch (err) {
438
+ if (!isNotFoundPathError(err)) {
439
+ throw err;
440
+ }
441
+ }
442
+ const parentResolved = await resolveOpenedFileRealPathFromParent(handleStat, ioPath);
443
+ if (parentResolved) {
444
+ return parentResolved;
445
+ }
446
+ throw new FsSafeError("path-mismatch", "unable to resolve opened file path");
447
+ }
448
+ async function resolveOpenedFileRealPathFromParent(handleStat, ioPath) {
449
+ let parentReal;
450
+ try {
451
+ parentReal = await fs.realpath(path.dirname(ioPath));
452
+ }
453
+ catch (err) {
454
+ if (isNotFoundPathError(err)) {
455
+ return null;
456
+ }
457
+ throw err;
458
+ }
459
+ let entries;
460
+ try {
461
+ entries = await fs.readdir(parentReal);
462
+ }
463
+ catch (err) {
464
+ if (isNotFoundPathError(err)) {
465
+ return null;
466
+ }
467
+ throw err;
468
+ }
469
+ for (const entry of entries.toSorted()) {
470
+ const candidatePath = path.join(parentReal, entry);
471
+ try {
472
+ const candidateStat = await fs.lstat(candidatePath);
473
+ if (candidateStat.isFile() && sameFileIdentity(handleStat, candidateStat)) {
474
+ return await fs.realpath(candidatePath);
475
+ }
476
+ }
477
+ catch (err) {
478
+ if (!isNotFoundPathError(err)) {
479
+ throw err;
480
+ }
481
+ }
482
+ }
483
+ return null;
484
+ }
485
+ async function openWritableFileInSafeRoot(root, params) {
486
+ const { rootReal, rootWithSep, resolved } = await resolvePathInSafeRoot(root, params.relativePath);
487
+ try {
488
+ await assertNoPathAliasEscape({
489
+ absolutePath: resolved,
490
+ rootPath: rootReal,
491
+ boundaryLabel: "root",
492
+ });
493
+ }
494
+ catch (err) {
495
+ throw new FsSafeError("path-alias", "path alias escape blocked", { cause: err });
496
+ }
497
+ if (params.mkdir !== false) {
498
+ await fs.mkdir(path.dirname(resolved), { recursive: true });
499
+ }
500
+ let ioPath = resolved;
501
+ try {
502
+ const resolvedRealPath = await fs.realpath(resolved);
503
+ if (!isPathInside(rootWithSep, resolvedRealPath)) {
504
+ throw new FsSafeError("outside-workspace", "file is outside workspace root");
505
+ }
506
+ ioPath = resolvedRealPath;
507
+ }
508
+ catch (err) {
509
+ if (err instanceof FsSafeError) {
510
+ throw err;
511
+ }
512
+ if (!isNotFoundPathError(err)) {
513
+ throw err;
514
+ }
515
+ }
516
+ const fileMode = params.mode ?? 0o600;
517
+ let handle;
518
+ let createdForWrite = false;
519
+ const existingFlags = params.append ? OPEN_APPEND_EXISTING_FLAGS : OPEN_WRITE_EXISTING_FLAGS;
520
+ const createFlags = params.append ? OPEN_APPEND_CREATE_FLAGS : OPEN_WRITE_CREATE_FLAGS;
521
+ try {
522
+ try {
523
+ handle = await fs.open(ioPath, existingFlags, fileMode);
524
+ }
525
+ catch (err) {
526
+ if (!isNotFoundPathError(err)) {
527
+ throw err;
528
+ }
529
+ handle = await fs.open(ioPath, createFlags, fileMode);
530
+ createdForWrite = true;
531
+ }
532
+ }
533
+ catch (err) {
534
+ if (isNotFoundPathError(err)) {
535
+ throw new FsSafeError("not-found", "file not found");
536
+ }
537
+ if (isSymlinkOpenError(err)) {
538
+ throw new FsSafeError("symlink", "symlink open blocked", { cause: err });
539
+ }
540
+ throw err;
541
+ }
542
+ let realPathForCleanup = null;
543
+ try {
544
+ const stat = await handle.stat();
545
+ if (!stat.isFile()) {
546
+ throw new FsSafeError("invalid-path", "path is not a regular file under root");
547
+ }
548
+ if (stat.nlink > 1) {
549
+ throw new FsSafeError("hardlink", "hardlinked path not allowed");
550
+ }
551
+ try {
552
+ const lstat = await fs.lstat(ioPath);
553
+ if (lstat.isSymbolicLink() || !lstat.isFile()) {
554
+ throw new FsSafeError(lstat.isSymbolicLink() ? "symlink" : "not-file", "path is not a regular file under root");
555
+ }
556
+ if (!sameFileIdentity(stat, lstat)) {
557
+ throw new FsSafeError("path-mismatch", "path changed during write");
558
+ }
559
+ }
560
+ catch (err) {
561
+ if (!isNotFoundPathError(err)) {
562
+ throw err;
563
+ }
564
+ }
565
+ const realPath = await resolveOpenedFileRealPathForHandle(handle, ioPath);
566
+ realPathForCleanup = realPath;
567
+ const realStat = await fs.stat(realPath);
568
+ if (!sameFileIdentity(stat, realStat)) {
569
+ throw new FsSafeError("path-mismatch", "path mismatch");
570
+ }
571
+ if (realStat.nlink > 1) {
572
+ throw new FsSafeError("hardlink", "hardlinked path not allowed");
573
+ }
574
+ if (!isPathInside(rootWithSep, realPath)) {
575
+ throw new FsSafeError("outside-workspace", "file is outside workspace root");
576
+ }
577
+ // Truncate only after boundary and identity checks complete. This avoids
578
+ // irreversible side effects if a symlink target changes before validation.
579
+ if (params.append !== true && params.truncateExisting !== false && !createdForWrite) {
580
+ await handle.truncate(0);
581
+ }
582
+ return {
583
+ handle,
584
+ createdForWrite,
585
+ realPath,
586
+ stat,
587
+ };
588
+ }
589
+ catch (err) {
590
+ const cleanupCreatedPath = createdForWrite && err instanceof FsSafeError;
591
+ const cleanupPath = realPathForCleanup ?? ioPath;
592
+ await handle.close().catch(() => { });
593
+ if (cleanupCreatedPath) {
594
+ await fs.rm(cleanupPath, { force: true }).catch(() => { });
595
+ }
596
+ throw err;
597
+ }
598
+ }
599
+ async function appendFileInSafeRoot(root, params) {
600
+ const target = await openWritableFileInSafeRoot(root, {
601
+ relativePath: params.relativePath,
602
+ mkdir: params.mkdir,
603
+ truncateExisting: false,
604
+ append: true,
605
+ });
606
+ try {
607
+ let prefix = "";
608
+ if (params.prependNewlineIfNeeded === true &&
609
+ !target.createdForWrite &&
610
+ target.stat.size > 0 &&
611
+ ((typeof params.data === "string" && !params.data.startsWith("\n")) ||
612
+ (Buffer.isBuffer(params.data) && params.data.length > 0 && params.data[0] !== 0x0a))) {
613
+ const lastByte = Buffer.alloc(1);
614
+ const { bytesRead } = await target.handle.read(lastByte, 0, 1, target.stat.size - 1);
615
+ if (bytesRead === 1 && lastByte[0] !== 0x0a) {
616
+ prefix = "\n";
617
+ }
618
+ }
619
+ if (typeof params.data === "string") {
620
+ await target.handle.appendFile(`${prefix}${params.data}`, params.encoding ?? "utf8");
621
+ return;
622
+ }
623
+ const payload = prefix.length > 0 ? Buffer.concat([Buffer.from(prefix, "utf8"), params.data]) : params.data;
624
+ await target.handle.appendFile(payload);
625
+ }
626
+ finally {
627
+ await target.handle.close().catch(() => { });
628
+ }
629
+ }
630
+ async function removePathInSafeRoot(root, relativePath) {
631
+ const resolved = await resolvePinnedRemovePathInSafeRoot(root, relativePath);
632
+ if (process.platform === "win32") {
633
+ await removePathFallback(resolved);
634
+ return;
635
+ }
636
+ try {
637
+ await runPinnedPathHelper({
638
+ operation: "remove",
639
+ rootPath: resolved.rootReal,
640
+ relativePath: resolved.relativePosix,
641
+ });
642
+ }
643
+ catch (error) {
644
+ if (isPinnedPathHelperSpawnError(error)) {
645
+ await removePathFallback(resolved);
646
+ return;
647
+ }
648
+ throw normalizePinnedPathError(error);
649
+ }
650
+ }
651
+ async function mkdirPathInSafeRoot(root, params) {
652
+ const resolved = await resolvePinnedPathInSafeRoot(root, params);
653
+ if (process.platform === "win32") {
654
+ await mkdirPathFallback(resolved);
655
+ return;
656
+ }
657
+ try {
658
+ await runPinnedPathHelper({
659
+ operation: "mkdirp",
660
+ rootPath: resolved.rootReal,
661
+ relativePath: resolved.relativePosix,
662
+ });
663
+ }
664
+ catch (error) {
665
+ if (isPinnedPathHelperSpawnError(error)) {
666
+ await mkdirPathFallback(resolved);
667
+ return;
668
+ }
669
+ throw normalizePinnedPathError(error);
670
+ }
671
+ }
672
+ async function writeFileInSafeRoot(root, params) {
673
+ 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
+ },
691
+ });
692
+ }
693
+ catch (error) {
694
+ if (params.overwrite === false && isAlreadyExistsError(error)) {
695
+ return false;
696
+ }
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;
711
+ }
712
+ async function copyFileInSafeRoot(root, params) {
713
+ const source = await openVerifiedLocalFile(params.sourcePath, {
714
+ rejectHardlinks: params.rejectSourceHardlinks,
715
+ });
716
+ if (params.maxBytes !== undefined && source.stat.size > params.maxBytes) {
717
+ await source.handle.close().catch(() => { });
718
+ throw new FsSafeError("too-large", `file exceeds limit of ${params.maxBytes} bytes (got ${source.stat.size})`);
719
+ }
720
+ try {
721
+ if (process.platform === "win32") {
722
+ await copyFileFallback(root, params, source);
723
+ return;
724
+ }
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);
740
+ });
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
+ }
753
+ finally {
754
+ await source.handle.close().catch(() => { });
755
+ }
756
+ }
757
+ async function resolvePinnedWriteTargetInSafeRoot(root, relativePath) {
758
+ const { rootReal, rootWithSep, resolved } = await resolvePathInSafeRoot(root, relativePath);
759
+ try {
760
+ await assertNoPathAliasEscape({
761
+ absolutePath: resolved,
762
+ rootPath: rootReal,
763
+ boundaryLabel: "root",
764
+ });
765
+ }
766
+ catch (err) {
767
+ throw new FsSafeError("path-alias", "path alias escape blocked", { cause: err });
768
+ }
769
+ const relativeResolved = path.relative(rootReal, resolved);
770
+ if (relativeResolved.startsWith("..") || path.isAbsolute(relativeResolved)) {
771
+ throw new FsSafeError("outside-workspace", "file is outside workspace root");
772
+ }
773
+ const relativePosix = relativeResolved
774
+ ? relativeResolved.split(path.sep).join(path.posix.sep)
775
+ : "";
776
+ const basename = path.posix.basename(relativePosix);
777
+ if (!basename || basename === "." || basename === "/") {
778
+ throw new FsSafeError("invalid-path", "invalid target path");
779
+ }
780
+ let mode = 0o600;
781
+ try {
782
+ const opened = await openFileInSafeRoot(root, {
783
+ relativePath,
784
+ rejectHardlinks: true,
785
+ nonBlockingRead: true,
786
+ });
787
+ try {
788
+ mode = opened.stat.mode & 0o777;
789
+ if (!isPathInside(rootWithSep, opened.realPath)) {
790
+ throw new FsSafeError("outside-workspace", "file is outside workspace root");
791
+ }
792
+ }
793
+ finally {
794
+ await opened.handle.close().catch(() => { });
795
+ }
796
+ }
797
+ catch (err) {
798
+ if (!(err instanceof FsSafeError) || err.code !== "not-found") {
799
+ throw err;
800
+ }
801
+ }
802
+ return {
803
+ rootReal,
804
+ targetPath: resolved,
805
+ relativeParentPath: path.posix.dirname(relativePosix) === "." ? "" : path.posix.dirname(relativePosix),
806
+ basename,
807
+ mode: mode || 0o600,
808
+ };
809
+ }
810
+ async function resolvePinnedPathInSafeRoot(root, params) {
811
+ const resolved = await resolvePinnedRootPathInSafeRoot(root, {
812
+ relativePath: params.relativePath,
813
+ policy: PATH_ALIAS_POLICIES.strict,
814
+ });
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
+ }
831
+ async function resolvePinnedRemovePathInSafeRoot(root, relativePath) {
832
+ const resolved = await resolvePinnedRootPathInSafeRoot(root, {
833
+ relativePath,
834
+ policy: PATH_ALIAS_POLICIES.unlinkTarget,
835
+ });
836
+ const relativeResolved = path.relative(resolved.rootReal, resolved.canonicalPath);
837
+ if (relativeResolved === "" ||
838
+ relativeResolved === "." ||
839
+ relativeResolved.startsWith("..") ||
840
+ path.isAbsolute(relativeResolved)) {
841
+ throw new FsSafeError("outside-workspace", "file is outside workspace root");
842
+ }
843
+ const relativePosix = relativeResolved.split(path.sep).join(path.posix.sep);
844
+ if (!isPathInside(resolved.rootWithSep, resolved.canonicalPath)) {
845
+ throw new FsSafeError("outside-workspace", "file is outside workspace root");
846
+ }
847
+ const parentRelative = path.posix.dirname(relativePosix);
848
+ if (parentRelative === "." || parentRelative === "") {
849
+ return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix };
850
+ }
851
+ return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix };
852
+ }
853
+ async function resolvePinnedRootPathInSafeRoot(root, params) {
854
+ const rootReal = root.rootReal;
855
+ let resolved;
856
+ try {
857
+ resolved = await resolveRootPath({
858
+ absolutePath: path.resolve(rootReal, await expandRelativePathWithHome(params.relativePath)),
859
+ rootPath: rootReal,
860
+ rootCanonicalPath: rootReal,
861
+ boundaryLabel: "root",
862
+ policy: params.policy,
863
+ });
864
+ }
865
+ catch (err) {
866
+ throw new FsSafeError("path-alias", "path alias escape blocked", { cause: err });
867
+ }
868
+ const rootWithSep = ensureTrailingSep(resolved.rootCanonicalPath);
869
+ return {
870
+ rootReal: resolved.rootCanonicalPath,
871
+ rootWithSep,
872
+ canonicalPath: resolved.canonicalPath,
873
+ };
874
+ }
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
+ async function removePathFallback(resolved) {
895
+ await fs.rm(resolved.resolved);
896
+ }
897
+ async function mkdirPathFallback(resolved) {
898
+ await fs.mkdir(resolved.resolved, { recursive: true });
899
+ }
900
+ async function writeFileFallback(root, params) {
901
+ if (params.overwrite === false) {
902
+ return await writeMissingFileFallback(root, params);
903
+ }
904
+ const target = await openWritableFileInSafeRoot(root, {
905
+ relativePath: params.relativePath,
906
+ mkdir: params.mkdir,
907
+ truncateExisting: false,
908
+ });
909
+ const destinationPath = target.realPath;
910
+ const targetMode = target.stat.mode & 0o777;
911
+ await target.handle.close().catch(() => { });
912
+ let tempPath = null;
913
+ try {
914
+ tempPath = buildAtomicWriteTempPath(destinationPath);
915
+ const writtenStat = await writeTempFileForAtomicReplace({
916
+ tempPath,
917
+ data: params.data,
918
+ encoding: params.encoding,
919
+ mode: targetMode || 0o600,
920
+ });
921
+ await fs.rename(tempPath, destinationPath);
922
+ tempPath = null;
923
+ try {
924
+ await verifyAtomicWriteResult({
925
+ root,
926
+ targetPath: destinationPath,
927
+ expectedIdentity: writtenStat,
928
+ });
929
+ }
930
+ catch (err) {
931
+ emitWriteBoundaryWarning(`post-write verification failed: ${String(err)}`);
932
+ throw err;
933
+ }
934
+ }
935
+ finally {
936
+ if (tempPath) {
937
+ await fs.rm(tempPath, { force: true }).catch(() => { });
938
+ }
939
+ }
940
+ return true;
941
+ }
942
+ async function writeMissingFileFallback(root, params) {
943
+ const { rootReal, resolved } = await resolvePathInSafeRoot(root, params.relativePath);
944
+ try {
945
+ await assertNoPathAliasEscape({
946
+ absolutePath: resolved,
947
+ rootPath: rootReal,
948
+ boundaryLabel: "root",
949
+ });
950
+ }
951
+ catch (err) {
952
+ throw new FsSafeError("path-alias", "path alias escape blocked", { cause: err });
953
+ }
954
+ if (params.mkdir !== false) {
955
+ await fs.mkdir(path.dirname(resolved), { recursive: true });
956
+ }
957
+ let handle = null;
958
+ let created = false;
959
+ 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();
969
+ await handle.close();
970
+ handle = null;
971
+ await verifyAtomicWriteResult({
972
+ root,
973
+ targetPath: resolved,
974
+ expectedIdentity: writtenStat,
975
+ });
976
+ created = false;
977
+ return true;
978
+ }
979
+ catch (err) {
980
+ if (hasNodeErrorCode(err, "EEXIST")) {
981
+ return false;
982
+ }
983
+ throw err;
984
+ }
985
+ finally {
986
+ await handle?.close().catch(() => undefined);
987
+ if (created) {
988
+ await fs.rm(resolved, { force: true }).catch(() => undefined);
989
+ }
990
+ }
991
+ }
992
+ async function copyFileFallback(root, params, source) {
993
+ let target = null;
994
+ let sourceClosedByStream = false;
995
+ let targetClosedByUs = false;
996
+ let tempHandle = null;
997
+ let tempPath = null;
998
+ let tempClosedByStream = false;
999
+ try {
1000
+ target = await openWritableFileInSafeRoot(root, {
1001
+ relativePath: params.relativePath,
1002
+ mkdir: params.mkdir,
1003
+ truncateExisting: false,
1004
+ });
1005
+ const destinationPath = target.realPath;
1006
+ const targetMode = target.stat.mode & 0o777;
1007
+ await target.handle.close().catch(() => { });
1008
+ targetClosedByUs = true;
1009
+ tempPath = buildAtomicWriteTempPath(destinationPath);
1010
+ tempHandle = await fs.open(tempPath, OPEN_WRITE_CREATE_FLAGS, targetMode || 0o600);
1011
+ const sourceStream = source.handle.createReadStream();
1012
+ const targetStream = tempHandle.createWriteStream();
1013
+ sourceStream.once("close", () => {
1014
+ sourceClosedByStream = true;
1015
+ });
1016
+ targetStream.once("close", () => {
1017
+ tempClosedByStream = true;
1018
+ });
1019
+ await pipeline(sourceStream, targetStream);
1020
+ const writtenStat = await fs.stat(tempPath);
1021
+ if (!tempClosedByStream) {
1022
+ await tempHandle.close().catch(() => { });
1023
+ tempClosedByStream = true;
1024
+ }
1025
+ tempHandle = null;
1026
+ await fs.rename(tempPath, destinationPath);
1027
+ tempPath = null;
1028
+ try {
1029
+ await verifyAtomicWriteResult({
1030
+ root,
1031
+ targetPath: destinationPath,
1032
+ expectedIdentity: writtenStat,
1033
+ });
1034
+ }
1035
+ catch (err) {
1036
+ emitWriteBoundaryWarning(`post-copy verification failed: ${String(err)}`);
1037
+ throw err;
1038
+ }
1039
+ }
1040
+ catch (err) {
1041
+ if (target?.createdForWrite) {
1042
+ await fs.rm(target.realPath, { force: true }).catch(() => { });
1043
+ }
1044
+ throw err;
1045
+ }
1046
+ finally {
1047
+ if (tempPath) {
1048
+ await fs.rm(tempPath, { force: true }).catch(() => { });
1049
+ }
1050
+ if (!sourceClosedByStream) {
1051
+ await source.handle.close().catch(() => { });
1052
+ }
1053
+ if (tempHandle && !tempClosedByStream) {
1054
+ await tempHandle.close().catch(() => { });
1055
+ }
1056
+ if (target && !targetClosedByUs) {
1057
+ await target.handle.close().catch(() => { });
1058
+ }
1059
+ }
1060
+ }