@openclaw/fs-safe 0.1.2 → 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 (116) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +39 -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 +2 -4
  26. package/dist/file-store-boundary.d.ts.map +1 -1
  27. package/dist/file-store-boundary.js +11 -7
  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 +60 -53
  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/pinned-write.d.ts.map +1 -1
  70. package/dist/pinned-write.js +44 -19
  71. package/dist/private-temp-workspace.d.ts.map +1 -1
  72. package/dist/private-temp-workspace.js +28 -15
  73. package/dist/regular-file.d.ts.map +1 -1
  74. package/dist/regular-file.js +52 -8
  75. package/dist/replace-directory.d.ts.map +1 -1
  76. package/dist/replace-directory.js +5 -4
  77. package/dist/replace-file.d.ts +1 -1
  78. package/dist/replace-file.d.ts.map +1 -1
  79. package/dist/replace-file.js +93 -31
  80. package/dist/root-impl.d.ts.map +1 -1
  81. package/dist/root-impl.js +57 -51
  82. package/dist/safe-path-segment.d.ts +9 -0
  83. package/dist/safe-path-segment.d.ts.map +1 -0
  84. package/dist/safe-path-segment.js +51 -0
  85. package/dist/secret-file.d.ts.map +1 -1
  86. package/dist/secret-file.js +6 -1
  87. package/dist/sibling-temp.d.ts +1 -0
  88. package/dist/sibling-temp.d.ts.map +1 -1
  89. package/dist/sibling-temp.js +32 -7
  90. package/dist/sidecar-lock.d.ts +2 -0
  91. package/dist/sidecar-lock.d.ts.map +1 -1
  92. package/dist/sidecar-lock.js +93 -13
  93. package/dist/store.d.ts +1 -0
  94. package/dist/store.d.ts.map +1 -1
  95. package/dist/store.js +1 -0
  96. package/dist/temp-target.d.ts.map +1 -1
  97. package/dist/temp-target.js +7 -4
  98. package/dist/test-hooks.d.ts +6 -0
  99. package/dist/test-hooks.d.ts.map +1 -1
  100. package/dist/text-atomic.d.ts +7 -0
  101. package/dist/text-atomic.d.ts.map +1 -1
  102. package/dist/text-atomic.js +3 -2
  103. package/dist/trash.d.ts.map +1 -1
  104. package/dist/trash.js +48 -13
  105. package/docs/advanced.md +10 -0
  106. package/docs/atomic.md +33 -5
  107. package/docs/config.md +32 -3
  108. package/docs/index.md +3 -2
  109. package/docs/json-store.md +4 -0
  110. package/docs/json.md +34 -0
  111. package/docs/output.md +92 -0
  112. package/docs/sidecar-lock.md +13 -12
  113. package/docs/store.md +33 -0
  114. package/docs/temp.md +10 -2
  115. package/docs/testing.md +10 -0
  116. package/package.json +7 -2
@@ -0,0 +1,13 @@
1
+ export type ExternalFileWriteOptions<T = void> = {
2
+ rootDir: string;
3
+ path: string;
4
+ write: (filePath: string) => Promise<T>;
5
+ maxBytes?: number;
6
+ mode?: number;
7
+ };
8
+ export type ExternalFileWriteResult<T = void> = {
9
+ path: string;
10
+ result: T;
11
+ };
12
+ export declare function writeExternalFileWithinRoot<T = void>(options: ExternalFileWriteOptions<T>): Promise<ExternalFileWriteResult<T>>;
13
+ //# sourceMappingURL=output.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"output.d.ts","sourceRoot":"","sources":["../src/output.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,wBAAwB,CAAC,CAAC,GAAG,IAAI,IAAI;IAC/C,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;IACxC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,MAAM,MAAM,uBAAuB,CAAC,CAAC,GAAG,IAAI,IAAI;IAC9C,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,CAAC,CAAC;CACX,CAAC;AA6CF,wBAAsB,2BAA2B,CAAC,CAAC,GAAG,IAAI,EACxD,OAAO,EAAE,wBAAwB,CAAC,CAAC,CAAC,GACnC,OAAO,CAAC,uBAAuB,CAAC,CAAC,CAAC,CAAC,CA8BrC"}
package/dist/output.js ADDED
@@ -0,0 +1,69 @@
1
+ import path from "node:path";
2
+ import { FsSafeError } from "./errors.js";
3
+ import { sanitizeUntrustedFileName } from "./filename.js";
4
+ import { isPathInside } from "./path.js";
5
+ import { root } from "./root.js";
6
+ import { tempFile } from "./temp-target.js";
7
+ function tempFileNameForTarget(targetPath) {
8
+ return sanitizeUntrustedFileName(path.basename(targetPath), "output.bin");
9
+ }
10
+ function ensureTrailingSep(value) {
11
+ return value.endsWith(path.sep) ? value : `${value}${path.sep}`;
12
+ }
13
+ function toRootPathInput(params) {
14
+ if (!path.isAbsolute(params.targetPath)) {
15
+ return params.targetPath;
16
+ }
17
+ const absoluteTarget = path.resolve(params.targetPath);
18
+ const rootDir = path.resolve(params.rootDir);
19
+ if (isPathInside(ensureTrailingSep(rootDir), absoluteTarget)) {
20
+ return path.relative(rootDir, absoluteTarget);
21
+ }
22
+ if (isPathInside(ensureTrailingSep(params.rootReal), absoluteTarget)) {
23
+ return path.relative(params.rootReal, absoluteTarget);
24
+ }
25
+ return params.targetPath;
26
+ }
27
+ function assertFileTargetPath(targetPath) {
28
+ const basename = path.basename(targetPath);
29
+ if (!targetPath ||
30
+ targetPath === "." ||
31
+ targetPath.endsWith("/") ||
32
+ targetPath.endsWith("\\") ||
33
+ !basename ||
34
+ basename === "." ||
35
+ basename === "..") {
36
+ throw new FsSafeError("invalid-path", "target path must name a file");
37
+ }
38
+ }
39
+ export async function writeExternalFileWithinRoot(options) {
40
+ const targetRoot = await root(options.rootDir);
41
+ const requestedTargetPath = options.path;
42
+ if (requestedTargetPath.length === 0) {
43
+ throw new FsSafeError("invalid-path", "target path is required");
44
+ }
45
+ const targetPath = toRootPathInput({
46
+ rootDir: targetRoot.rootDir,
47
+ rootReal: targetRoot.rootReal,
48
+ targetPath: requestedTargetPath,
49
+ });
50
+ assertFileTargetPath(targetPath);
51
+ const finalPath = await targetRoot.resolve(targetPath);
52
+ const staged = await tempFile({
53
+ prefix: "fs-safe-output",
54
+ fileName: tempFileNameForTarget(targetPath),
55
+ });
56
+ try {
57
+ const result = await options.write(staged.path);
58
+ await targetRoot.copyIn(targetPath, staged.path, {
59
+ maxBytes: options.maxBytes,
60
+ mode: options.mode,
61
+ mkdir: true,
62
+ sourceHardlinks: "reject",
63
+ });
64
+ return { path: finalPath, result };
65
+ }
66
+ finally {
67
+ await staged.cleanup();
68
+ }
69
+ }
@@ -0,0 +1,4 @@
1
+ import type { Stats } from "node:fs";
2
+ import type { PathStat } from "./types.js";
3
+ export declare function pathStatFromStats(stat: Stats): PathStat;
4
+ //# sourceMappingURL=path-stat.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"path-stat.d.ts","sourceRoot":"","sources":["../src/path-stat.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAE3C,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,KAAK,GAAG,QAAQ,CAcvD"}
@@ -0,0 +1,15 @@
1
+ export function pathStatFromStats(stat) {
2
+ return {
3
+ dev: Number(stat.dev),
4
+ gid: Number(stat.gid),
5
+ ino: Number(stat.ino),
6
+ isDirectory: stat.isDirectory(),
7
+ isFile: stat.isFile(),
8
+ isSymbolicLink: stat.isSymbolicLink(),
9
+ mode: stat.mode,
10
+ mtimeMs: stat.mtimeMs,
11
+ nlink: stat.nlink,
12
+ size: stat.size,
13
+ uid: stat.uid,
14
+ };
15
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"pinned-write.d.ts","sourceRoot":"","sources":["../src/pinned-write.ts"],"names":[],"mappings":"AAGA,OAAO,EAAa,KAAK,QAAQ,EAAE,MAAM,aAAa,CAAC;AAGvD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAQ3D,KAAK,gBAAgB,GACjB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,cAAc,CAAA;CAAE,GACpE;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,QAAQ,CAAA;CAAE,CAAC;AAuFzC,wBAAsB,oBAAoB,CAAC,MAAM,EAAE;IACjD,QAAQ,EAAE,MAAM,CAAC;IACjB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,gBAAgB,CAAC;CACzB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAuC5B;AAED,wBAAsB,mBAAmB,CAAC,MAAM,EAAE;IAChD,QAAQ,EAAE,MAAM,CAAC;IACjB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,gBAAgB,CAAC;CAClC,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAoB5B"}
1
+ {"version":3,"file":"pinned-write.d.ts","sourceRoot":"","sources":["../src/pinned-write.ts"],"names":[],"mappings":"AAIA,OAAO,EAAa,KAAK,QAAQ,EAAE,MAAM,aAAa,CAAC;AAIvD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAS3D,KAAK,gBAAgB,GACjB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,cAAc,CAAA;CAAE,GACpE;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,QAAQ,CAAA;CAAE,CAAC;AAuFzC,wBAAsB,oBAAoB,CAAC,MAAM,EAAE;IACjD,QAAQ,EAAE,MAAM,CAAC;IACjB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,gBAAgB,CAAC;CACzB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAuC5B;AAED,wBAAsB,mBAAmB,CAAC,MAAM,EAAE;IAChD,QAAQ,EAAE,MAAM,CAAC;IACjB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,gBAAgB,CAAC;CAClC,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAoB5B"}
@@ -1,9 +1,12 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import fsSync from "node:fs";
2
3
  import fs from "node:fs/promises";
3
4
  import path from "node:path";
4
5
  import { Transform } from "node:stream";
5
6
  import { pipeline } from "node:stream/promises";
7
+ import { createNearestExistingDirectoryGuard } from "./directory-guard.js";
6
8
  import { FsSafeError } from "./errors.js";
9
+ import { withAsyncDirectoryGuards } from "./guarded-mutation.js";
7
10
  import { canFallbackFromPythonError, getFsSafePythonConfig } from "./pinned-python-config.js";
8
11
  import { assertPinnedPythonOperationAvailable, runPinnedPythonOperation, validatePinnedOperationPayload, } from "./pinned-python.js";
9
12
  function byteLength(input, encoding) {
@@ -67,13 +70,13 @@ async function inputToBase64(input, maxBytes) {
67
70
  return Buffer.concat(chunks, bytes).toString("base64");
68
71
  }
69
72
  export async function runPinnedWriteHelper(params) {
70
- if (getFsSafePythonConfig().mode === "off") {
71
- return await runPinnedWriteFallback(params);
72
- }
73
73
  assertSafeBasename(params.basename);
74
74
  validatePinnedOperationPayload({
75
75
  relativeParentPath: params.relativeParentPath,
76
76
  });
77
+ if (getFsSafePythonConfig().mode === "off") {
78
+ return await runPinnedWriteFallback(params);
79
+ }
77
80
  if (params.input.kind === "stream") {
78
81
  try {
79
82
  assertPinnedPythonOperationAvailable();
@@ -133,12 +136,21 @@ async function runPinnedWriteFallback(params) {
133
136
  const parentPath = params.relativeParentPath
134
137
  ? path.join(params.rootPath, ...params.relativeParentPath.split("/"))
135
138
  : params.rootPath;
139
+ const parentGuard = await createNearestExistingDirectoryGuard(params.rootPath, parentPath);
136
140
  if (params.mkdir) {
137
- await fs.mkdir(parentPath, { recursive: true });
141
+ await withAsyncDirectoryGuards([parentGuard], async () => {
142
+ await fs.mkdir(parentPath, { recursive: true });
143
+ });
138
144
  }
139
145
  const targetPath = path.join(parentPath, params.basename);
140
146
  if (params.overwrite === false) {
141
- const handle = await fs.open(targetPath, fsSync.constants.O_WRONLY | fsSync.constants.O_CREAT | fsSync.constants.O_EXCL, params.mode);
147
+ let handle = await withAsyncDirectoryGuards([parentGuard], async () => await fs.open(targetPath, fsSync.constants.O_WRONLY | fsSync.constants.O_CREAT | fsSync.constants.O_EXCL, params.mode), {
148
+ onPostGuardFailure: async (openedHandle) => {
149
+ // The parent failed verification, so targetPath may now resolve
150
+ // somewhere else. Close the fd, but do not clean up by path.
151
+ await openedHandle.close().catch(() => undefined);
152
+ },
153
+ });
142
154
  let created = true;
143
155
  try {
144
156
  if (params.input.kind === "buffer") {
@@ -164,32 +176,45 @@ async function runPinnedWriteFallback(params) {
164
176
  }
165
177
  }
166
178
  }
167
- const tempPath = path.join(parentPath, `.${params.basename}.fallback.tmp`);
179
+ const tempPath = path.join(parentPath, `.${params.basename}.${randomUUID()}.fallback.tmp`);
180
+ const tempFlags = fsSync.constants.O_WRONLY |
181
+ fsSync.constants.O_CREAT |
182
+ fsSync.constants.O_EXCL |
183
+ (process.platform !== "win32" && "O_NOFOLLOW" in fsSync.constants
184
+ ? fsSync.constants.O_NOFOLLOW
185
+ : 0);
186
+ let handle;
187
+ let handleClosedByStream = false;
168
188
  try {
189
+ handle = await fs.open(tempPath, tempFlags, params.mode);
169
190
  if (params.input.kind === "buffer") {
170
191
  assertWithinMaxBytes(byteLength(params.input.data, params.input.encoding), params.maxBytes);
171
192
  if (typeof params.input.data === "string") {
172
- await fs.writeFile(tempPath, params.input.data, {
173
- encoding: params.input.encoding ?? "utf8",
174
- mode: params.mode,
175
- });
193
+ await handle.writeFile(params.input.data, params.input.encoding ?? "utf8");
176
194
  }
177
195
  else {
178
- await fs.writeFile(tempPath, params.input.data, { mode: params.mode });
196
+ await handle.writeFile(params.input.data);
179
197
  }
180
198
  }
181
199
  else {
182
- const handle = await fs.open(tempPath, "w", params.mode);
183
- try {
184
- await pipelineWithMaxBytes(params.input.stream, handle.createWriteStream(), params.maxBytes);
185
- }
186
- finally {
187
- await handle.close().catch(() => { });
188
- }
200
+ const writable = handle.createWriteStream();
201
+ writable.once("close", () => {
202
+ handleClosedByStream = true;
203
+ });
204
+ await pipelineWithMaxBytes(params.input.stream, writable, params.maxBytes);
205
+ }
206
+ if (!handleClosedByStream) {
207
+ await handle.close().catch(() => undefined);
208
+ handle = undefined;
189
209
  }
190
- await fs.rename(tempPath, targetPath);
210
+ await withAsyncDirectoryGuards([parentGuard], async () => {
211
+ await fs.rename(tempPath, targetPath);
212
+ });
191
213
  }
192
214
  catch (error) {
215
+ if (handle && !handleClosedByStream) {
216
+ await handle.close().catch(() => undefined);
217
+ }
193
218
  await fs.rm(tempPath, { force: true }).catch(() => undefined);
194
219
  throw error;
195
220
  }
@@ -1 +1 @@
1
- {"version":3,"file":"private-temp-workspace.d.ts","sourceRoot":"","sources":["../src/private-temp-workspace.ts"],"names":[],"mappings":"AAIA,OAAO,EAGL,KAAK,SAAS,EACd,KAAK,aAAa,EACnB,MAAM,iBAAiB,CAAC;AAGzB,MAAM,MAAM,oBAAoB,GAAG;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,SAAS,CAAC;IACjB,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC;IAC/B,KAAK,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACpE,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC3D,SAAS,CACP,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,OAAO,EACb,OAAO,CAAC,EAAE;QAAE,eAAe,CAAC,EAAE,OAAO,CAAA;KAAE,GACtC,OAAO,CAAC,MAAM,CAAC,CAAC;IACnB,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC9D,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACxC,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACzB,CAAC,MAAM,CAAC,YAAY,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxC,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,aAAa,CAAC;IACrB,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC;IAC/B,KAAK,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,MAAM,CAAC;IAC3D,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IAClD,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE;QAAE,eAAe,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,MAAM,CAAC;IAC5F,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC;IAC/B,OAAO,IAAI,IAAI,CAAC;IAChB,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI,CAAC;CAC1B,CAAC;AAuGF,wBAAsB,aAAa,CACjC,OAAO,EAAE,oBAAoB,GAC5B,OAAO,CAAC,aAAa,CAAC,CAExB;AAED,wBAAsB,iBAAiB,CAAC,CAAC,EACvC,OAAO,EAAE,oBAAoB,EAC7B,GAAG,EAAE,CAAC,SAAS,EAAE,aAAa,KAAK,OAAO,CAAC,CAAC,CAAC,GAC5C,OAAO,CAAC,CAAC,CAAC,CAUZ;AAED,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,oBAAoB,GAC5B,iBAAiB,CA4DnB;AAED,wBAAgB,qBAAqB,CAAC,CAAC,EACrC,OAAO,EAAE,oBAAoB,EAC7B,GAAG,EAAE,CAAC,SAAS,EAAE,iBAAiB,KAAK,CAAC,GACvC,CAAC,CAUH"}
1
+ {"version":3,"file":"private-temp-workspace.d.ts","sourceRoot":"","sources":["../src/private-temp-workspace.ts"],"names":[],"mappings":"AAIA,OAAO,EAGL,KAAK,SAAS,EACd,KAAK,aAAa,EACnB,MAAM,iBAAiB,CAAC;AAIzB,MAAM,MAAM,oBAAoB,GAAG;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,SAAS,CAAC;IACjB,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC;IAC/B,KAAK,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACpE,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC3D,SAAS,CACP,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,OAAO,EACb,OAAO,CAAC,EAAE;QAAE,eAAe,CAAC,EAAE,OAAO,CAAA;KAAE,GACtC,OAAO,CAAC,MAAM,CAAC,CAAC;IACnB,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC9D,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACxC,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACzB,CAAC,MAAM,CAAC,YAAY,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxC,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,aAAa,CAAC;IACrB,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC;IAC/B,KAAK,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,MAAM,CAAC;IAC3D,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IAClD,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE;QAAE,eAAe,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,MAAM,CAAC;IAC5F,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC;IAC/B,OAAO,IAAI,IAAI,CAAC;IAChB,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI,CAAC;CAC1B,CAAC;AAsGF,wBAAsB,aAAa,CACjC,OAAO,EAAE,oBAAoB,GAC5B,OAAO,CAAC,aAAa,CAAC,CAExB;AAED,wBAAsB,iBAAiB,CAAC,CAAC,EACvC,OAAO,EAAE,oBAAoB,EAC7B,GAAG,EAAE,CAAC,SAAS,EAAE,aAAa,KAAK,OAAO,CAAC,CAAC,CAAC,GAC5C,OAAO,CAAC,CAAC,CAAC,CAUZ;AAED,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,oBAAoB,GAC5B,iBAAiB,CAwEnB;AAED,wBAAgB,qBAAqB,CAAC,CAAC,EACrC,OAAO,EAAE,oBAAoB,EAC7B,GAAG,EAAE,CAAC,SAAS,EAAE,iBAAiB,KAAK,CAAC,GACvC,CAAC,CAUH"}
@@ -3,6 +3,7 @@ import fsSync from "node:fs";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { fileStore, fileStoreSync, } from "./file-store.js";
6
+ import { openRootFileSync } from "./root-file.js";
6
7
  import { registerTempPathForExit } from "./temp-cleanup.js";
7
8
  function sanitizeTempPrefix(prefix) {
8
9
  const sanitized = prefix.trim().replace(/[^a-zA-Z0-9._-]/g, "-");
@@ -12,21 +13,20 @@ function sanitizeTempPrefix(prefix) {
12
13
  return sanitized.endsWith("-") ? sanitized : `${sanitized}-`;
13
14
  }
14
15
  function resolveWorkspaceLeaf(dir, fileName) {
15
- const raw = fileName.trim();
16
- if (!raw ||
17
- raw === "." ||
18
- raw === ".." ||
19
- raw.includes("\0") ||
20
- raw.includes("/") ||
21
- raw.includes("\\") ||
22
- path.basename(raw) !== raw) {
23
- throw new Error(`Invalid temp workspace file name: ${JSON.stringify(fileName)}`);
24
- }
25
- return path.join(dir, raw);
16
+ return path.join(dir, assertWorkspaceFileName(fileName));
26
17
  }
27
18
  function assertWorkspaceFileName(fileName) {
28
- resolveWorkspaceLeaf(".", fileName);
29
- return fileName.trim();
19
+ const value = fileName.trim();
20
+ if (!value ||
21
+ value === "." ||
22
+ value === ".." ||
23
+ value.includes("\0") ||
24
+ value.includes("/") ||
25
+ value.includes("\\") ||
26
+ path.basename(value) !== value) {
27
+ throw new Error(`Invalid temp workspace file name: ${JSON.stringify(fileName)}`);
28
+ }
29
+ return value;
30
30
  }
31
31
  async function ensurePrivateDirectory(dir, mode) {
32
32
  await fs.mkdir(dir, { recursive: true, mode });
@@ -144,8 +144,21 @@ export function tempWorkspaceSync(options) {
144
144
  trailingNewline: writeOptions?.trailingNewline,
145
145
  }),
146
146
  read: (fileName) => {
147
- const filePath = store.path(assertWorkspaceFileName(fileName));
148
- return fsSync.readFileSync(filePath);
147
+ const opened = openRootFileSync({
148
+ absolutePath: store.path(assertWorkspaceFileName(fileName)),
149
+ rootPath: dir,
150
+ boundaryLabel: "temp workspace",
151
+ rejectHardlinks: true,
152
+ });
153
+ if (!opened.ok) {
154
+ throw Object.assign(new Error(`File not found: ${fileName}`), { code: "ENOENT" });
155
+ }
156
+ try {
157
+ return fsSync.readFileSync(opened.fd);
158
+ }
159
+ finally {
160
+ fsSync.closeSync(opened.fd);
161
+ }
149
162
  },
150
163
  cleanup: () => {
151
164
  try {
@@ -1 +1 @@
1
- {"version":3,"file":"regular-file.d.ts","sourceRoot":"","sources":["../src/regular-file.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,MAAM,MAAM,SAAS,CAAC;AAO7B,MAAM,MAAM,qBAAqB,GAAG;IAAE,OAAO,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,KAAK,CAAC;IAAC,IAAI,EAAE,KAAK,CAAA;CAAE,CAAC;AAExF,KAAK,8BAA8B,GAAG,IAAI,CACxC,OAAO,MAAM,CAAC,SAAS,EACvB,UAAU,GAAG,SAAS,GAAG,UAAU,CACpC,GACC,OAAO,CAAC,IAAI,CAAC,OAAO,MAAM,CAAC,SAAS,EAAE,YAAY,CAAC,CAAC,CAAC;AAEvD,MAAM,MAAM,wBAAwB,GAAG;IACrC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,UAAU,CAAC;IAC7B,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oBAAoB,CAAC,EAAE,OAAO,CAAC;CAChC,CAAC;AAEF,wBAAgB,6BAA6B,CAC3C,SAAS,GAAE,8BAAiD,GAC3D,MAAM,CAQR;AAWD,wBAAsB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,qBAAqB,CAAC,CActF;AAED,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,qBAAqB,CAc3E;AAED,wBAAsB,eAAe,CAAC,MAAM,EAAE;IAC5C,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,GAAG,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,KAAK,CAAA;CAAE,CAAC,CA6B3C;AA0CD,wBAAgB,mBAAmB,CAAC,MAAM,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG;IACpF,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,KAAK,CAAC;CACb,CAoBA;AAmBD,wBAAsB,iBAAiB,CAAC,OAAO,EAAE,wBAAwB,GAAG,OAAO,CAAC,IAAI,CAAC,CAuDxF;AAED,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,wBAAwB,GAAG,IAAI,CA2D7E"}
1
+ {"version":3,"file":"regular-file.d.ts","sourceRoot":"","sources":["../src/regular-file.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,MAAM,MAAM,SAAS,CAAC;AAQ7B,MAAM,MAAM,qBAAqB,GAAG;IAAE,OAAO,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,KAAK,CAAC;IAAC,IAAI,EAAE,KAAK,CAAA;CAAE,CAAC;AAExF,KAAK,8BAA8B,GAAG,IAAI,CACxC,OAAO,MAAM,CAAC,SAAS,EACvB,UAAU,GAAG,SAAS,GAAG,UAAU,CACpC,GACC,OAAO,CAAC,IAAI,CAAC,OAAO,MAAM,CAAC,SAAS,EAAE,YAAY,CAAC,CAAC,CAAC;AAEvD,MAAM,MAAM,wBAAwB,GAAG;IACrC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,UAAU,CAAC;IAC7B,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oBAAoB,CAAC,EAAE,OAAO,CAAC;CAChC,CAAC;AAEF,wBAAgB,6BAA6B,CAC3C,SAAS,GAAE,8BAAiD,GAC3D,MAAM,CAQR;AA2DD,wBAAsB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,qBAAqB,CAAC,CActF;AAED,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,qBAAqB,CAc3E;AAED,wBAAsB,eAAe,CAAC,MAAM,EAAE;IAC5C,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,GAAG,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,KAAK,CAAA;CAAE,CAAC,CAgC3C;AA6CD,wBAAgB,mBAAmB,CAAC,MAAM,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG;IACpF,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,KAAK,CAAC;CACb,CAoBA;AAmBD,wBAAsB,iBAAiB,CAAC,OAAO,EAAE,wBAAwB,GAAG,OAAO,CAAC,IAAI,CAAC,CAuDxF;AAED,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,wBAAwB,GAAG,IAAI,CA2D7E"}
@@ -17,6 +17,44 @@ function resolveRegularFileReadFlags() {
17
17
  ? fsSync.constants.O_NOFOLLOW
18
18
  : 0));
19
19
  }
20
+ async function readFileHandleBounded(params) {
21
+ if (params.maxBytes === undefined) {
22
+ return await params.handle.readFile();
23
+ }
24
+ const chunks = [];
25
+ const scratch = Buffer.allocUnsafe(Math.min(64 * 1024, Math.max(1, params.maxBytes + 1)));
26
+ let total = 0;
27
+ while (true) {
28
+ const { bytesRead } = await params.handle.read(scratch, 0, scratch.length, null);
29
+ if (bytesRead === 0) {
30
+ return Buffer.concat(chunks, total);
31
+ }
32
+ total += bytesRead;
33
+ if (total > params.maxBytes) {
34
+ throw new Error(`File exceeds ${params.maxBytes} bytes: ${params.filePath}`);
35
+ }
36
+ chunks.push(Buffer.from(scratch.subarray(0, bytesRead)));
37
+ }
38
+ }
39
+ function readFileDescriptorBounded(params) {
40
+ if (params.maxBytes === undefined) {
41
+ return fsSync.readFileSync(params.fd);
42
+ }
43
+ const chunks = [];
44
+ const scratch = Buffer.allocUnsafe(Math.min(64 * 1024, Math.max(1, params.maxBytes + 1)));
45
+ let total = 0;
46
+ while (true) {
47
+ const bytesRead = fsSync.readSync(params.fd, scratch, 0, scratch.length, null);
48
+ if (bytesRead === 0) {
49
+ return Buffer.concat(chunks, total);
50
+ }
51
+ total += bytesRead;
52
+ if (total > params.maxBytes) {
53
+ throw new Error(`File exceeds ${params.maxBytes} bytes: ${params.filePath}`);
54
+ }
55
+ chunks.push(Buffer.from(scratch.subarray(0, bytesRead)));
56
+ }
57
+ }
20
58
  export async function statRegularFile(filePath) {
21
59
  let stat;
22
60
  try {
@@ -69,10 +107,13 @@ export async function readRegularFile(params) {
69
107
  if (params.maxBytes !== undefined && stat.size > params.maxBytes) {
70
108
  throw new Error(`File exceeds ${params.maxBytes} bytes: ${params.filePath}`);
71
109
  }
72
- const buffer = await handle.readFile();
73
- if (params.maxBytes !== undefined && buffer.byteLength > params.maxBytes) {
74
- throw new Error(`File exceeds ${params.maxBytes} bytes: ${params.filePath}`);
75
- }
110
+ // With a byte cap, avoid readFile(): a raced file growth would allocate
111
+ // the oversized content before the post-read check could reject it.
112
+ const buffer = await readFileHandleBounded({
113
+ handle,
114
+ filePath: params.filePath,
115
+ maxBytes: params.maxBytes,
116
+ });
76
117
  return { buffer, stat };
77
118
  }
78
119
  finally {
@@ -99,10 +140,13 @@ function readOpenedRegularFileSync(params) {
99
140
  if (params.maxBytes !== undefined && stat.size > params.maxBytes) {
100
141
  throw new Error(`File exceeds ${params.maxBytes} bytes: ${params.filePath}`);
101
142
  }
102
- const buffer = fsSync.readFileSync(params.fd);
103
- if (params.maxBytes !== undefined && buffer.byteLength > params.maxBytes) {
104
- throw new Error(`File exceeds ${params.maxBytes} bytes: ${params.filePath}`);
105
- }
143
+ // Keep capped sync reads incremental for the same reason as async reads:
144
+ // readFileSync(fd) would buffer a raced oversized file before throwing.
145
+ const buffer = readFileDescriptorBounded({
146
+ fd: params.fd,
147
+ filePath: params.filePath,
148
+ maxBytes: params.maxBytes,
149
+ });
106
150
  return { buffer, stat };
107
151
  }
108
152
  export function readRegularFileSync(params) {
@@ -1 +1 @@
1
- {"version":3,"file":"replace-directory.d.ts","sourceRoot":"","sources":["../src/replace-directory.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,6BAA6B,GAAG;IAC1C,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,6BAA6B,GACrC,OAAO,CAAC,IAAI,CAAC,CAiCf"}
1
+ {"version":3,"file":"replace-directory.d.ts","sourceRoot":"","sources":["../src/replace-directory.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,6BAA6B,GAAG;IAC1C,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,6BAA6B,GACrC,OAAO,CAAC,IAAI,CAAC,CAiCf"}
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { guardedRename, guardedRm } from "./guarded-mutation.js";
3
4
  export async function replaceDirectoryAtomic(options) {
4
5
  const targetDir = path.resolve(options.targetDir);
5
6
  const stagedDir = path.resolve(options.stagedDir);
@@ -8,7 +9,7 @@ export async function replaceDirectoryAtomic(options) {
8
9
  let backupCreated = false;
9
10
  await fs.mkdir(parentDir, { recursive: true });
10
11
  try {
11
- await fs.rename(targetDir, backupDir);
12
+ await guardedRename({ from: targetDir, to: backupDir });
12
13
  backupCreated = true;
13
14
  }
14
15
  catch (err) {
@@ -17,16 +18,16 @@ export async function replaceDirectoryAtomic(options) {
17
18
  }
18
19
  }
19
20
  try {
20
- await fs.rename(stagedDir, targetDir);
21
+ await guardedRename({ from: stagedDir, to: targetDir });
21
22
  }
22
23
  catch (err) {
23
24
  if (backupCreated) {
24
- await fs.rename(backupDir, targetDir).catch(() => undefined);
25
+ await guardedRename({ from: backupDir, to: targetDir }).catch(() => undefined);
25
26
  backupCreated = false;
26
27
  }
27
28
  throw err;
28
29
  }
29
30
  if (backupCreated) {
30
- await fs.rm(backupDir, { recursive: true, force: true });
31
+ await guardedRm({ target: backupDir, recursive: true, force: true, verifyAfter: false });
31
32
  }
32
33
  }
@@ -3,7 +3,7 @@ import fs from "node:fs/promises";
3
3
  export type ReplaceFileAtomicFileSystem = {
4
4
  promises: Pick<typeof fs, "mkdir" | "chmod" | "writeFile" | "rename" | "copyFile" | "unlink" | "rm" | "open" | "stat" | "lstat">;
5
5
  };
6
- export type ReplaceFileAtomicSyncFileSystem = Pick<typeof syncFs, "mkdirSync" | "chmodSync" | "writeFileSync" | "renameSync" | "copyFileSync" | "unlinkSync" | "rmSync" | "openSync" | "fsyncSync" | "closeSync" | "statSync" | "lstatSync">;
6
+ export type ReplaceFileAtomicSyncFileSystem = Pick<typeof syncFs, "mkdirSync" | "chmodSync" | "readFileSync" | "writeFileSync" | "renameSync" | "copyFileSync" | "unlinkSync" | "rmSync" | "openSync" | "fsyncSync" | "closeSync" | "statSync" | "lstatSync">;
7
7
  type ReplaceFileAtomicBaseOptions = {
8
8
  filePath: string;
9
9
  content: string | Uint8Array;
@@ -1 +1 @@
1
- {"version":3,"file":"replace-file.d.ts","sourceRoot":"","sources":["../src/replace-file.ts"],"names":[],"mappings":"AACA,OAAO,MAAM,MAAM,SAAS,CAAC;AAE7B,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAKlC,MAAM,MAAM,2BAA2B,GAAG;IACxC,QAAQ,EAAE,IAAI,CACZ,OAAO,EAAE,EACP,OAAO,GACP,OAAO,GACP,WAAW,GACX,QAAQ,GACR,UAAU,GACV,QAAQ,GACR,IAAI,GACJ,MAAM,GACN,MAAM,GACN,OAAO,CACV,CAAC;CACH,CAAC;AAEF,MAAM,MAAM,+BAA+B,GAAG,IAAI,CAChD,OAAO,MAAM,EACX,WAAW,GACX,WAAW,GACX,eAAe,GACf,YAAY,GACZ,cAAc,GACd,YAAY,GACZ,QAAQ,GACR,UAAU,GACV,WAAW,GACX,WAAW,GACX,UAAU,GACV,WAAW,CACd,CAAC;AAEF,KAAK,4BAA4B,GAAG;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,UAAU,CAAC;IAC7B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,6BAA6B,CAAC,EAAE,OAAO,CAAC;IACxC,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,mBAAmB,CAAC,EAAE,OAAO,CAAC;CAC/B,CAAC;AAEF,MAAM,MAAM,wBAAwB,GAAG,4BAA4B,GAAG;IACpE,UAAU,CAAC,EAAE,2BAA2B,CAAC;IACzC,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAClF,CAAC;AAEF,MAAM,MAAM,4BAA4B,GAAG,4BAA4B,GAAG;IACxE,UAAU,CAAC,EAAE,+BAA+B,CAAC;IAC7C,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;CACzE,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG;IACpC,MAAM,EAAE,QAAQ,GAAG,eAAe,CAAC;CACpC,CAAC;AA+NF,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,wBAAwB,GAChC,OAAO,CAAC,uBAAuB,CAAC,CAMlC;AAyDD,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,4BAA4B,GACpC,uBAAuB,CAiEzB"}
1
+ {"version":3,"file":"replace-file.d.ts","sourceRoot":"","sources":["../src/replace-file.ts"],"names":[],"mappings":"AACA,OAAO,MAAM,MAAM,SAAS,CAAC;AAE7B,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAMlC,MAAM,MAAM,2BAA2B,GAAG;IACxC,QAAQ,EAAE,IAAI,CACZ,OAAO,EAAE,EACP,OAAO,GACP,OAAO,GACP,WAAW,GACX,QAAQ,GACR,UAAU,GACV,QAAQ,GACR,IAAI,GACJ,MAAM,GACN,MAAM,GACN,OAAO,CACV,CAAC;CACH,CAAC;AAEF,MAAM,MAAM,+BAA+B,GAAG,IAAI,CAChD,OAAO,MAAM,EACX,WAAW,GACX,WAAW,GACX,cAAc,GACd,eAAe,GACf,YAAY,GACZ,cAAc,GACd,YAAY,GACZ,QAAQ,GACR,UAAU,GACV,WAAW,GACX,WAAW,GACX,UAAU,GACV,WAAW,CACd,CAAC;AAEF,KAAK,4BAA4B,GAAG;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,UAAU,CAAC;IAC7B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,6BAA6B,CAAC,EAAE,OAAO,CAAC;IACxC,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,mBAAmB,CAAC,EAAE,OAAO,CAAC;CAC/B,CAAC;AAEF,MAAM,MAAM,wBAAwB,GAAG,4BAA4B,GAAG;IACpE,UAAU,CAAC,EAAE,2BAA2B,CAAC;IACzC,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAClF,CAAC;AAEF,MAAM,MAAM,4BAA4B,GAAG,4BAA4B,GAAG;IACxE,UAAU,CAAC,EAAE,+BAA+B,CAAC;IAC7C,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;CACzE,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG;IACpC,MAAM,EAAE,QAAQ,GAAG,eAAe,CAAC;CACpC,CAAC;AAySF,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,wBAAwB,GAChC,OAAO,CAAC,uBAAuB,CAAC,CAMlC;AAyDD,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,4BAA4B,GACpC,uBAAuB,CAiEzB"}
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import syncFs from "node:fs";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
+ import { assertSafePathPrefix } from "./safe-path-segment.js";
5
6
  import { registerTempPathForExit } from "./temp-cleanup.js";
6
7
  import { serializePathWrite } from "./write-queue.js";
7
8
  function isRetryableRenameError(error) {
@@ -11,6 +12,12 @@ function isPermissionRenameError(error) {
11
12
  const code = error.code;
12
13
  return code === "EPERM" || code === "EEXIST";
13
14
  }
15
+ const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in syncFs.constants;
16
+ const OPEN_READ_FLAGS = syncFs.constants.O_RDONLY | (SUPPORTS_NOFOLLOW ? syncFs.constants.O_NOFOLLOW : 0);
17
+ const OPEN_WRITE_EXCLUSIVE_FLAGS = syncFs.constants.O_WRONLY |
18
+ syncFs.constants.O_CREAT |
19
+ syncFs.constants.O_EXCL |
20
+ (SUPPORTS_NOFOLLOW ? syncFs.constants.O_NOFOLLOW : 0);
14
21
  async function sleep(ms) {
15
22
  await new Promise((resolve) => setTimeout(resolve, ms));
16
23
  }
@@ -26,17 +33,7 @@ async function renameWithRetry(params) {
26
33
  continue;
27
34
  }
28
35
  if (params.copyFallbackOnPermissionError && isPermissionRenameError(error)) {
29
- const stat = await params.fsModule.lstat(params.dest).catch((lstatError) => {
30
- if (lstatError.code === "ENOENT") {
31
- return null;
32
- }
33
- throw lstatError;
34
- });
35
- if (stat?.isSymbolicLink()) {
36
- throw new Error(`Refusing copy fallback through symlink destination: ${params.dest}`);
37
- }
38
- await params.fsModule.copyFile(params.src, params.dest);
39
- await params.fsModule.unlink(params.src).catch(() => undefined);
36
+ await copyFallbackReplace(params.fsModule, params.src, params.dest);
40
37
  return { method: "copy-fallback" };
41
38
  }
42
39
  throw error;
@@ -62,25 +59,7 @@ function renameWithRetrySync(params) {
62
59
  continue;
63
60
  }
64
61
  if (params.copyFallbackOnPermissionError && isPermissionRenameError(error)) {
65
- let stat = null;
66
- try {
67
- stat = params.fsModule.lstatSync(params.dest);
68
- }
69
- catch (lstatError) {
70
- if (lstatError.code !== "ENOENT") {
71
- throw lstatError;
72
- }
73
- }
74
- if (stat?.isSymbolicLink()) {
75
- throw new Error(`Refusing copy fallback through symlink destination: ${params.dest}`);
76
- }
77
- params.fsModule.copyFileSync(params.src, params.dest);
78
- try {
79
- params.fsModule.unlinkSync(params.src);
80
- }
81
- catch {
82
- // Best-effort cleanup after fallback replacement.
83
- }
62
+ copyFallbackReplaceSync(params.fsModule, params.src, params.dest);
84
63
  return { method: "copy-fallback" };
85
64
  }
86
65
  throw error;
@@ -88,6 +67,88 @@ function renameWithRetrySync(params) {
88
67
  }
89
68
  throw new Error("Atomic rename retry loop exhausted.");
90
69
  }
70
+ async function copyFallbackReplace(fsModule, src, dest) {
71
+ const sourceStat = await fsModule.lstat(src);
72
+ if (sourceStat.isSymbolicLink() || !sourceStat.isFile()) {
73
+ throw new Error(`Refusing copy fallback from non-file source: ${src}`);
74
+ }
75
+ const destStat = await fsModule.lstat(dest).catch((lstatError) => {
76
+ if (lstatError.code === "ENOENT") {
77
+ return null;
78
+ }
79
+ throw lstatError;
80
+ });
81
+ if (destStat?.isSymbolicLink()) {
82
+ throw new Error(`Refusing copy fallback through symlink destination: ${dest}`);
83
+ }
84
+ if (destStat) {
85
+ await fsModule.rm(dest, { force: true });
86
+ }
87
+ let sourceHandle = null;
88
+ let destHandle = null;
89
+ try {
90
+ sourceHandle = await fsModule.open(src, OPEN_READ_FLAGS);
91
+ destHandle = await fsModule.open(dest, OPEN_WRITE_EXCLUSIVE_FLAGS, sourceStat.mode & 0o777);
92
+ await destHandle.writeFile(await sourceHandle.readFile());
93
+ }
94
+ finally {
95
+ await destHandle?.close().catch(() => undefined);
96
+ await sourceHandle?.close().catch(() => undefined);
97
+ }
98
+ await fsModule.unlink(src).catch(() => undefined);
99
+ }
100
+ function copyFallbackReplaceSync(fsModule, src, dest) {
101
+ const sourceStat = fsModule.lstatSync(src);
102
+ if (sourceStat.isSymbolicLink() || !sourceStat.isFile()) {
103
+ throw new Error(`Refusing copy fallback from non-file source: ${src}`);
104
+ }
105
+ let destStat = null;
106
+ try {
107
+ destStat = fsModule.lstatSync(dest);
108
+ }
109
+ catch (lstatError) {
110
+ if (lstatError.code !== "ENOENT") {
111
+ throw lstatError;
112
+ }
113
+ }
114
+ if (destStat?.isSymbolicLink()) {
115
+ throw new Error(`Refusing copy fallback through symlink destination: ${dest}`);
116
+ }
117
+ if (destStat) {
118
+ fsModule.rmSync(dest, { force: true });
119
+ }
120
+ let sourceFd;
121
+ let destFd;
122
+ try {
123
+ sourceFd = fsModule.openSync(src, OPEN_READ_FLAGS);
124
+ destFd = fsModule.openSync(dest, OPEN_WRITE_EXCLUSIVE_FLAGS, sourceStat.mode & 0o777);
125
+ fsModule.writeFileSync(destFd, fsModule.readFileSync(sourceFd));
126
+ }
127
+ finally {
128
+ if (destFd !== undefined) {
129
+ try {
130
+ fsModule.closeSync(destFd);
131
+ }
132
+ catch {
133
+ // Best-effort close after fallback replacement.
134
+ }
135
+ }
136
+ if (sourceFd !== undefined) {
137
+ try {
138
+ fsModule.closeSync(sourceFd);
139
+ }
140
+ catch {
141
+ // Best-effort close after fallback replacement.
142
+ }
143
+ }
144
+ }
145
+ try {
146
+ fsModule.unlinkSync(src);
147
+ }
148
+ catch {
149
+ // Best-effort cleanup after fallback replacement.
150
+ }
151
+ }
91
152
  function validateReplaceFilePath(filePath) {
92
153
  if (!filePath || filePath.includes("\0")) {
93
154
  throw new Error("Atomic replace file path must be non-empty.");
@@ -95,7 +156,8 @@ function validateReplaceFilePath(filePath) {
95
156
  }
96
157
  function buildReplaceTempPath(filePath, tempPrefix) {
97
158
  const dir = path.dirname(filePath);
98
- return path.join(dir, `${tempPrefix ?? ".fs-safe-replace"}.${process.pid}.${randomUUID()}.tmp`);
159
+ const safePrefix = assertSafePathPrefix(tempPrefix ?? ".fs-safe-replace", { label: "atomic replace temp prefix" });
160
+ return path.join(dir, `${safePrefix}.${process.pid}.${randomUUID()}.tmp`);
99
161
  }
100
162
  async function resolveMode(options) {
101
163
  const defaultMode = options.mode ?? 0o600;