@openclaw/fs-safe 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +58 -2
  3. package/dist/absolute-path.d.ts +14 -0
  4. package/dist/absolute-path.d.ts.map +1 -1
  5. package/dist/absolute-path.js +203 -2
  6. package/dist/advanced.d.ts +2 -1
  7. package/dist/advanced.d.ts.map +1 -1
  8. package/dist/advanced.js +2 -1
  9. package/dist/archive-staging.d.ts.map +1 -1
  10. package/dist/archive-staging.js +81 -7
  11. package/dist/archive.d.ts.map +1 -1
  12. package/dist/archive.js +25 -42
  13. package/dist/bounded-read-stream.d.ts +8 -0
  14. package/dist/bounded-read-stream.d.ts.map +1 -0
  15. package/dist/bounded-read-stream.js +20 -0
  16. package/dist/config.d.ts +1 -0
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/config.js +1 -0
  19. package/dist/directory-guard.d.ts +18 -0
  20. package/dist/directory-guard.d.ts.map +1 -0
  21. package/dist/directory-guard.js +70 -0
  22. package/dist/file-lock.d.ts +4 -2
  23. package/dist/file-lock.d.ts.map +1 -1
  24. package/dist/file-lock.js +13 -2
  25. package/dist/file-store-boundary.d.ts +25 -0
  26. package/dist/file-store-boundary.d.ts.map +1 -0
  27. package/dist/file-store-boundary.js +168 -0
  28. package/dist/file-store-prune.d.ts +12 -0
  29. package/dist/file-store-prune.d.ts.map +1 -0
  30. package/dist/file-store-prune.js +86 -0
  31. package/dist/file-store-source.d.ts +5 -0
  32. package/dist/file-store-source.d.ts.map +1 -0
  33. package/dist/file-store-source.js +30 -0
  34. package/dist/file-store.d.ts +2 -6
  35. package/dist/file-store.d.ts.map +1 -1
  36. package/dist/file-store.js +122 -125
  37. package/dist/guarded-mkdir.d.ts +6 -0
  38. package/dist/guarded-mkdir.d.ts.map +1 -0
  39. package/dist/guarded-mkdir.js +45 -0
  40. package/dist/guarded-mutation.d.ts +33 -0
  41. package/dist/guarded-mutation.d.ts.map +1 -0
  42. package/dist/guarded-mutation.js +76 -0
  43. package/dist/index.d.ts +2 -0
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +2 -0
  46. package/dist/json-document-store.d.ts +2 -0
  47. package/dist/json-document-store.d.ts.map +1 -1
  48. package/dist/json-document-store.js +7 -3
  49. package/dist/json-durable-queue.d.ts +45 -0
  50. package/dist/json-durable-queue.d.ts.map +1 -0
  51. package/dist/json-durable-queue.js +200 -0
  52. package/dist/json.d.ts +35 -5
  53. package/dist/json.d.ts.map +1 -1
  54. package/dist/json.js +69 -2
  55. package/dist/local-roots.d.ts.map +1 -1
  56. package/dist/local-roots.js +17 -3
  57. package/dist/lock-config.d.ts +10 -0
  58. package/dist/lock-config.d.ts.map +1 -0
  59. package/dist/lock-config.js +12 -0
  60. package/dist/move-path.d.ts +1 -0
  61. package/dist/move-path.d.ts.map +1 -1
  62. package/dist/move-path.js +195 -7
  63. package/dist/output.d.ts +13 -0
  64. package/dist/output.d.ts.map +1 -0
  65. package/dist/output.js +69 -0
  66. package/dist/path-stat.d.ts +4 -0
  67. package/dist/path-stat.d.ts.map +1 -0
  68. package/dist/path-stat.js +15 -0
  69. package/dist/path.d.ts +1 -0
  70. package/dist/path.d.ts.map +1 -1
  71. package/dist/path.js +6 -3
  72. package/dist/pinned-write.d.ts.map +1 -1
  73. package/dist/pinned-write.js +44 -19
  74. package/dist/private-file-store.d.ts +5 -7
  75. package/dist/private-file-store.d.ts.map +1 -1
  76. package/dist/private-file-store.js +21 -34
  77. package/dist/private-temp-workspace.d.ts.map +1 -1
  78. package/dist/private-temp-workspace.js +28 -15
  79. package/dist/regular-file.d.ts.map +1 -1
  80. package/dist/regular-file.js +52 -8
  81. package/dist/replace-directory.d.ts.map +1 -1
  82. package/dist/replace-directory.js +5 -4
  83. package/dist/replace-file.d.ts +1 -1
  84. package/dist/replace-file.d.ts.map +1 -1
  85. package/dist/replace-file.js +93 -31
  86. package/dist/root-context.d.ts +23 -0
  87. package/dist/root-context.d.ts.map +1 -0
  88. package/dist/root-context.js +62 -0
  89. package/dist/root-errors.d.ts +4 -0
  90. package/dist/root-errors.d.ts.map +1 -0
  91. package/dist/root-errors.js +21 -0
  92. package/dist/root-impl.d.ts +108 -0
  93. package/dist/root-impl.d.ts.map +1 -0
  94. package/dist/{safe-root.js → root-impl.js} +484 -265
  95. package/dist/root.d.ts +1 -107
  96. package/dist/root.d.ts.map +1 -1
  97. package/dist/root.js +1 -1332
  98. package/dist/safe-path-segment.d.ts +9 -0
  99. package/dist/safe-path-segment.d.ts.map +1 -0
  100. package/dist/safe-path-segment.js +51 -0
  101. package/dist/secret-file.d.ts.map +1 -1
  102. package/dist/secret-file.js +6 -1
  103. package/dist/sibling-temp.d.ts +1 -0
  104. package/dist/sibling-temp.d.ts.map +1 -1
  105. package/dist/sibling-temp.js +32 -7
  106. package/dist/sidecar-lock.d.ts +2 -0
  107. package/dist/sidecar-lock.d.ts.map +1 -1
  108. package/dist/sidecar-lock.js +93 -13
  109. package/dist/store.d.ts +1 -0
  110. package/dist/store.d.ts.map +1 -1
  111. package/dist/store.js +1 -0
  112. package/dist/temp-target.d.ts.map +1 -1
  113. package/dist/temp-target.js +7 -4
  114. package/dist/test-hooks.d.ts +6 -0
  115. package/dist/test-hooks.d.ts.map +1 -1
  116. package/dist/text-atomic.d.ts +7 -0
  117. package/dist/text-atomic.d.ts.map +1 -1
  118. package/dist/text-atomic.js +3 -2
  119. package/dist/trash.d.ts.map +1 -1
  120. package/dist/trash.js +48 -13
  121. package/docs/advanced.md +110 -0
  122. package/docs/archive.md +199 -0
  123. package/docs/atomic.md +191 -0
  124. package/docs/config.md +94 -0
  125. package/docs/contributing.md +80 -0
  126. package/docs/errors.md +154 -0
  127. package/docs/file-store.md +213 -0
  128. package/docs/filename.md +0 -0
  129. package/docs/index.md +77 -0
  130. package/docs/install-path.md +167 -0
  131. package/docs/install.md +150 -0
  132. package/docs/json-store.md +204 -0
  133. package/docs/json.md +192 -0
  134. package/docs/local-roots.md +164 -0
  135. package/docs/output.md +92 -0
  136. package/docs/path-scope.md +130 -0
  137. package/docs/path.md +191 -0
  138. package/docs/permissions.md +95 -0
  139. package/docs/private-file-store.md +45 -0
  140. package/docs/python-helper.md +103 -0
  141. package/docs/quickstart.md +147 -0
  142. package/docs/reading.md +174 -0
  143. package/docs/regular-file.md +175 -0
  144. package/docs/root.md +205 -0
  145. package/docs/secret-file.md +154 -0
  146. package/docs/secure-file.md +82 -0
  147. package/docs/security-model.md +99 -0
  148. package/docs/sidecar-lock.md +230 -0
  149. package/docs/store.md +77 -0
  150. package/docs/temp.md +284 -0
  151. package/docs/test-hooks.md +74 -0
  152. package/docs/testing.md +178 -0
  153. package/docs/timing.md +121 -0
  154. package/docs/types.md +155 -0
  155. package/docs/walk.md +63 -0
  156. package/docs/writing.md +222 -0
  157. package/package.json +10 -2
  158. package/dist/archive-utils.d.ts +0 -3
  159. package/dist/archive-utils.d.ts.map +0 -1
  160. package/dist/archive-utils.js +0 -26
  161. package/dist/boundary-file-read.d.ts +0 -44
  162. package/dist/boundary-file-read.d.ts.map +0 -1
  163. package/dist/boundary-file-read.js +0 -129
  164. package/dist/boundary-path.d.ts +0 -39
  165. package/dist/boundary-path.d.ts.map +0 -1
  166. package/dist/boundary-path.js +0 -598
  167. package/dist/fs-pinned-path-helper.d.ts +0 -7
  168. package/dist/fs-pinned-path-helper.d.ts.map +0 -1
  169. package/dist/fs-pinned-path-helper.js +0 -182
  170. package/dist/fs-pinned-write-helper.d.ts +0 -21
  171. package/dist/fs-pinned-write-helper.d.ts.map +0 -1
  172. package/dist/fs-pinned-write-helper.js +0 -263
  173. package/dist/hardlink-guards.d.ts +0 -7
  174. package/dist/hardlink-guards.d.ts.map +0 -1
  175. package/dist/hardlink-guards.js +0 -30
  176. package/dist/install-safe-path.d.ts +0 -20
  177. package/dist/install-safe-path.d.ts.map +0 -1
  178. package/dist/install-safe-path.js +0 -94
  179. package/dist/json-file.d.ts +0 -3
  180. package/dist/json-file.d.ts.map +0 -1
  181. package/dist/json-file.js +0 -123
  182. package/dist/json-files.d.ts +0 -20
  183. package/dist/json-files.d.ts.map +0 -1
  184. package/dist/json-files.js +0 -153
  185. package/dist/path-alias-guards.d.ts +0 -19
  186. package/dist/path-alias-guards.d.ts.map +0 -1
  187. package/dist/path-alias-guards.js +0 -21
  188. package/dist/path-guards.d.ts +0 -7
  189. package/dist/path-guards.d.ts.map +0 -1
  190. package/dist/path-guards.js +0 -49
  191. package/dist/path-safety.d.ts +0 -12
  192. package/dist/path-safety.d.ts.map +0 -1
  193. package/dist/path-safety.js +0 -50
  194. package/dist/safe-open-sync.d.ts +0 -24
  195. package/dist/safe-open-sync.d.ts.map +0 -1
  196. package/dist/safe-open-sync.js +0 -71
  197. package/dist/safe-root.d.ts +0 -123
  198. package/dist/safe-root.d.ts.map +0 -1
  199. package/dist/secure-temp-workspace.d.ts +0 -25
  200. package/dist/secure-temp-workspace.d.ts.map +0 -1
  201. package/dist/secure-temp-workspace.js +0 -136
  202. package/dist/sibling-temp-file.d.ts +0 -16
  203. package/dist/sibling-temp-file.d.ts.map +0 -1
  204. package/dist/sibling-temp-file.js +0 -73
  205. package/dist/sibling-temp-write.d.ts +0 -8
  206. package/dist/sibling-temp-write.d.ts.map +0 -1
  207. package/dist/sibling-temp-write.js +0 -40
package/dist/root.js CHANGED
@@ -1,1332 +1 @@
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 { Transform } from "node:stream";
7
- import { pipeline } from "node:stream/promises";
8
- import { FsSafeError } from "./errors.js";
9
- import { sameFileIdentity } from "./file-identity.js";
10
- import { isPinnedPathHelperSpawnError, runPinnedPathHelper } from "./pinned-path.js";
11
- import { runPinnedCopyHelper, runPinnedWriteHelper } from "./pinned-write.js";
12
- import { canFallbackFromPythonError, getFsSafePythonConfig } from "./pinned-python-config.js";
13
- import { expandHomePrefix } from "./home-dir.js";
14
- import { assertNoPathAliasEscape, PATH_ALIAS_POLICIES } from "./path-policy.js";
15
- import { hasNodeErrorCode, isNotFoundPathError, isPathInside, isSymlinkOpenError, } from "./path.js";
16
- import { helperReaddir, helperStat, runPinnedHelper, } from "./pinned-helper.js";
17
- import { resolveRootPath } from "./root-path.js";
18
- import { getFsSafeTestHooks } from "./test-hooks.js";
19
- import { registerTempPathForExit } from "./temp-cleanup.js";
20
- import { serializePathWrite } from "./write-queue.js";
21
- function logWarn(message) {
22
- if (process.env.FS_SAFE_DEBUG_WARNINGS === "1") {
23
- console.warn(message);
24
- }
25
- }
26
- const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants;
27
- const NONBLOCK_OPEN_FLAG = "O_NONBLOCK" in fsConstants ? fsConstants.O_NONBLOCK : 0;
28
- const OPEN_READ_FLAGS = fsConstants.O_RDONLY | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
29
- const OPEN_READ_NONBLOCK_FLAGS = OPEN_READ_FLAGS | NONBLOCK_OPEN_FLAG;
30
- const OPEN_READ_FOLLOW_FLAGS = fsConstants.O_RDONLY;
31
- const OPEN_READ_FOLLOW_NONBLOCK_FLAGS = OPEN_READ_FOLLOW_FLAGS | NONBLOCK_OPEN_FLAG;
32
- const OPEN_WRITE_EXISTING_FLAGS = fsConstants.O_WRONLY | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
33
- const OPEN_WRITE_CREATE_FLAGS = fsConstants.O_WRONLY |
34
- fsConstants.O_CREAT |
35
- fsConstants.O_EXCL |
36
- (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
37
- const OPEN_APPEND_EXISTING_FLAGS = fsConstants.O_RDWR | fsConstants.O_APPEND | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
38
- const OPEN_APPEND_CREATE_FLAGS = fsConstants.O_RDWR |
39
- fsConstants.O_APPEND |
40
- fsConstants.O_CREAT |
41
- fsConstants.O_EXCL |
42
- (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
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
- };
56
- }
57
- const ensureTrailingSep = (value) => (value.endsWith(path.sep) ? value : value + path.sep);
58
- let cachedHomePath;
59
- async function expandRelativePathWithHome(relativePath) {
60
- const rawHome = process.env.HOME || process.env.USERPROFILE || os.homedir();
61
- if (cachedHomePath?.raw !== rawHome) {
62
- let realHome = rawHome;
63
- try {
64
- realHome = await fs.realpath(rawHome);
65
- }
66
- catch {
67
- // If the home dir cannot be canonicalized, keep lexical expansion behavior.
68
- }
69
- cachedHomePath = { raw: rawHome, real: realHome };
70
- }
71
- return expandHomePrefix(relativePath, { home: cachedHomePath.real });
72
- }
73
- async function openVerifiedLocalFile(filePath, options) {
74
- const fsSafeTestHooks = getFsSafeTestHooks();
75
- // Reject directories before opening so we never surface EISDIR to callers (e.g. tool
76
- // results that get sent to messaging channels). See openclaw/openclaw#31186.
77
- try {
78
- const preStat = await fs.lstat(filePath);
79
- if (preStat.isDirectory()) {
80
- throw new FsSafeError("not-file", "not a file");
81
- }
82
- await fsSafeTestHooks?.afterPreOpenLstat?.(filePath);
83
- }
84
- catch (err) {
85
- if (err instanceof FsSafeError) {
86
- throw err;
87
- }
88
- // ENOENT and other lstat errors: fall through and let fs.open handle.
89
- }
90
- let handle;
91
- try {
92
- const openFlags = options?.symlinks === "follow-within-root"
93
- ? options?.nonBlockingRead
94
- ? OPEN_READ_FOLLOW_NONBLOCK_FLAGS
95
- : OPEN_READ_FOLLOW_FLAGS
96
- : options?.nonBlockingRead
97
- ? OPEN_READ_NONBLOCK_FLAGS
98
- : OPEN_READ_FLAGS;
99
- await fsSafeTestHooks?.beforeOpen?.(filePath, openFlags);
100
- handle = await fs.open(filePath, openFlags);
101
- try {
102
- await fsSafeTestHooks?.afterOpen?.(filePath, handle);
103
- }
104
- catch (err) {
105
- await handle.close().catch(() => { });
106
- throw err;
107
- }
108
- }
109
- catch (err) {
110
- if (isNotFoundPathError(err)) {
111
- throw new FsSafeError("not-found", "file not found");
112
- }
113
- if (isSymlinkOpenError(err)) {
114
- throw new FsSafeError("symlink", "symlink open blocked", { cause: err });
115
- }
116
- // Defensive: if open still throws EISDIR (e.g. race), sanitize so it never leaks.
117
- if (hasNodeErrorCode(err, "EISDIR")) {
118
- throw new FsSafeError("not-file", "not a file");
119
- }
120
- throw err;
121
- }
122
- try {
123
- const stat = await handle.stat();
124
- if (!stat.isFile()) {
125
- throw new FsSafeError("not-file", "not a file");
126
- }
127
- if (options?.hardlinks === "reject" && stat.nlink > 1) {
128
- throw new FsSafeError("hardlink", "hardlinked path not allowed");
129
- }
130
- if (options?.symlinks === "follow-within-root") {
131
- const pathStat = await fs.stat(filePath);
132
- if (!sameFileIdentity(stat, pathStat)) {
133
- throw new FsSafeError("path-mismatch", "path changed during read");
134
- }
135
- }
136
- else {
137
- const pathStat = await fs.lstat(filePath);
138
- if (pathStat.isSymbolicLink()) {
139
- throw new FsSafeError("symlink", "symlink not allowed");
140
- }
141
- if (!sameFileIdentity(stat, pathStat)) {
142
- throw new FsSafeError("path-mismatch", "path changed during read");
143
- }
144
- }
145
- const realPath = await resolveOpenedFileRealPathForHandle(handle, filePath);
146
- const realStat = await fs.stat(realPath);
147
- if (options?.hardlinks === "reject" && realStat.nlink > 1) {
148
- throw new FsSafeError("hardlink", "hardlinked path not allowed");
149
- }
150
- if (!sameFileIdentity(stat, realStat)) {
151
- throw new FsSafeError("path-mismatch", "path mismatch");
152
- }
153
- return openResult({ handle, realPath, stat });
154
- }
155
- catch (err) {
156
- await handle.close().catch(() => { });
157
- if (err instanceof FsSafeError) {
158
- throw err;
159
- }
160
- if (isNotFoundPathError(err)) {
161
- throw new FsSafeError("not-found", "file not found");
162
- }
163
- throw err;
164
- }
165
- }
166
- async function resolveRootContext(rootDir) {
167
- let rootReal;
168
- try {
169
- rootReal = await fs.realpath(rootDir);
170
- }
171
- catch (err) {
172
- if (isNotFoundPathError(err)) {
173
- throw new FsSafeError("not-found", "root dir not found");
174
- }
175
- throw err;
176
- }
177
- return {
178
- rootDir: path.resolve(rootDir),
179
- rootReal,
180
- rootWithSep: ensureTrailingSep(rootReal),
181
- };
182
- }
183
- async function resolvePathInRoot(root, relativePath) {
184
- const expanded = await expandRelativePathWithHome(relativePath);
185
- const resolved = path.resolve(root.rootWithSep, expanded);
186
- if (!isPathInside(root.rootWithSep, resolved)) {
187
- throw new FsSafeError("outside-workspace", "file is outside workspace root");
188
- }
189
- return { rootReal: root.rootReal, rootWithSep: root.rootWithSep, resolved };
190
- }
191
- async function resolvePathWithinRoot(params) {
192
- return await resolvePathInRoot(await resolveRootContext(params.rootDir), params.relativePath);
193
- }
194
- class RootHandle {
195
- rootDir;
196
- rootReal;
197
- rootWithSep;
198
- defaults;
199
- constructor(context, defaults = {}) {
200
- this.rootDir = context.rootDir;
201
- this.rootReal = context.rootReal;
202
- this.rootWithSep = context.rootWithSep;
203
- this.defaults = defaults;
204
- }
205
- get context() {
206
- return {
207
- rootDir: this.rootDir,
208
- rootReal: this.rootReal,
209
- rootWithSep: this.rootWithSep,
210
- };
211
- }
212
- async resolve(relativePath) {
213
- return (await resolvePathInRoot(this.context, relativePath)).resolved;
214
- }
215
- async open(relativePath, options = {}) {
216
- return await openFileInRoot(this.context, {
217
- relativePath,
218
- ...readDefaults(this.defaults),
219
- ...options,
220
- });
221
- }
222
- async read(relativePath, options = {}) {
223
- return await readFileInRoot(this.context, {
224
- relativePath,
225
- ...readDefaults(this.defaults),
226
- ...options,
227
- });
228
- }
229
- async readBytes(relativePath, options = {}) {
230
- return (await this.read(relativePath, options)).buffer;
231
- }
232
- async readText(relativePath, options = {}) {
233
- const { encoding = "utf8", ...readOptions } = options;
234
- return (await this.read(relativePath, readOptions)).buffer.toString(encoding);
235
- }
236
- async readJson(relativePath, options = {}) {
237
- return JSON.parse(await this.readText(relativePath, options));
238
- }
239
- async readAbsolute(filePath, options = {}) {
240
- return await readPathInRoot(this.context, {
241
- filePath,
242
- ...readDefaults(this.defaults),
243
- ...options,
244
- });
245
- }
246
- reader(options = {}) {
247
- return async (filePath) => {
248
- return (await this.readAbsolute(filePath, options)).buffer;
249
- };
250
- }
251
- async openWritable(relativePath, options = {}) {
252
- const writeMode = options.writeMode ?? "replace";
253
- return await openWritableFileInRoot(this.context, {
254
- relativePath,
255
- mkdir: this.defaults.mkdir,
256
- mode: this.defaults.mode,
257
- ...options,
258
- append: writeMode === "append",
259
- truncateExisting: writeMode === "replace",
260
- });
261
- }
262
- async append(relativePath, data, options = {}) {
263
- await appendFileInRoot(this.context, {
264
- relativePath,
265
- data,
266
- mkdir: this.defaults.mkdir,
267
- mode: this.defaults.mode,
268
- ...options,
269
- });
270
- }
271
- async remove(relativePath) {
272
- await removePathInRoot(this.context, relativePath);
273
- }
274
- async mkdir(relativePath) {
275
- await mkdirPathInRoot(this.context, { relativePath });
276
- }
277
- async ensureRoot() {
278
- await mkdirPathInRoot(this.context, { relativePath: "", allowRoot: true });
279
- }
280
- async write(relativePath, data, options = {}) {
281
- await writeFileInRoot(this.context, {
282
- relativePath,
283
- data,
284
- mkdir: this.defaults.mkdir,
285
- mode: this.defaults.mode,
286
- ...options,
287
- });
288
- }
289
- async create(relativePath, data, options = {}) {
290
- await writeFileInRoot(this.context, {
291
- relativePath,
292
- data,
293
- mkdir: this.defaults.mkdir,
294
- mode: this.defaults.mode,
295
- ...options,
296
- overwrite: false,
297
- });
298
- }
299
- async writeJson(relativePath, data, options = {}) {
300
- const { replacer, space, trailingNewline = true, ...writeOptions } = options;
301
- const json = JSON.stringify(data, replacer, space);
302
- await this.write(relativePath, trailingNewline ? `${json}\n` : json, writeOptions);
303
- }
304
- async createJson(relativePath, data, options = {}) {
305
- const { replacer, space, trailingNewline = true, ...writeOptions } = options;
306
- const json = JSON.stringify(data, replacer, space);
307
- await this.create(relativePath, trailingNewline ? `${json}\n` : json, writeOptions);
308
- }
309
- async copyIn(relativePath, sourcePath, options = {}) {
310
- await copyFileInRoot(this.context, {
311
- sourcePath,
312
- relativePath,
313
- maxBytes: this.defaults.maxBytes,
314
- mkdir: this.defaults.mkdir,
315
- mode: this.defaults.mode,
316
- ...options,
317
- });
318
- }
319
- async exists(relativePath) {
320
- try {
321
- await this.stat(relativePath);
322
- return true;
323
- }
324
- catch (err) {
325
- if (err instanceof FsSafeError && err.code === "not-found") {
326
- return false;
327
- }
328
- throw err;
329
- }
330
- }
331
- async stat(relativePath) {
332
- try {
333
- return await helperStat(this.rootReal, relativePath);
334
- }
335
- catch (error) {
336
- if (canFallbackFromPythonError(error)) {
337
- return await statPathFallback(this.context, relativePath);
338
- }
339
- throw error;
340
- }
341
- }
342
- async list(relativePath, options = {}) {
343
- try {
344
- return options.withFileTypes === true
345
- ? await helperReaddir(this.rootReal, relativePath, true)
346
- : await helperReaddir(this.rootReal, relativePath, false);
347
- }
348
- catch (error) {
349
- if (canFallbackFromPythonError(error)) {
350
- return await listPathFallback(this.context, relativePath, options.withFileTypes === true);
351
- }
352
- throw error;
353
- }
354
- }
355
- async move(fromRelative, toRelative, options = {}) {
356
- try {
357
- await runPinnedHelper("rename", this.rootReal, {
358
- from: fromRelative,
359
- overwrite: options.overwrite ?? false,
360
- to: toRelative,
361
- });
362
- }
363
- catch (error) {
364
- if (canFallbackFromPythonError(error)) {
365
- await movePathFallback(this.context, {
366
- fromRelative,
367
- overwrite: options.overwrite ?? false,
368
- toRelative,
369
- });
370
- return;
371
- }
372
- throw error;
373
- }
374
- }
375
- }
376
- function readDefaults(defaults) {
377
- return {
378
- hardlinks: defaults.hardlinks,
379
- maxBytes: defaults.maxBytes ?? DEFAULT_ROOT_MAX_BYTES,
380
- nonBlockingRead: defaults.nonBlockingRead,
381
- symlinks: defaults.symlinks,
382
- };
383
- }
384
- export async function root(rootDir, defaults = {}) {
385
- return new RootHandle(await resolveRootContext(rootDir), defaults);
386
- }
387
- async function openFileInRoot(root, params) {
388
- const { rootWithSep, resolved } = await resolvePathInRoot(root, params.relativePath);
389
- let opened;
390
- try {
391
- opened = await openVerifiedLocalFile(resolved, {
392
- nonBlockingRead: params.nonBlockingRead,
393
- symlinks: params.symlinks,
394
- });
395
- }
396
- catch (err) {
397
- if (err instanceof FsSafeError) {
398
- throw err;
399
- }
400
- throw err;
401
- }
402
- if (params.hardlinks !== "allow" && opened.stat.nlink > 1) {
403
- await opened.handle.close().catch(() => { });
404
- throw new FsSafeError("hardlink", "hardlinked path not allowed");
405
- }
406
- if (!isPathInside(rootWithSep, opened.realPath)) {
407
- await opened.handle.close().catch(() => { });
408
- throw new FsSafeError("outside-workspace", "file is outside workspace root");
409
- }
410
- return opened;
411
- }
412
- async function readFileInRoot(root, params) {
413
- const opened = await openFileInRoot(root, params);
414
- try {
415
- return await readOpenedFileSafely({ opened, maxBytes: params.maxBytes });
416
- }
417
- finally {
418
- await opened.handle.close().catch(() => { });
419
- }
420
- }
421
- async function readPathInRoot(root, params) {
422
- const rootDir = root.rootDir;
423
- const candidatePath = path.isAbsolute(params.filePath)
424
- ? path.resolve(params.filePath)
425
- : path.resolve(rootDir, params.filePath);
426
- const relativePath = path.relative(rootDir, candidatePath);
427
- return await readFileInRoot(root, {
428
- relativePath,
429
- hardlinks: params.hardlinks,
430
- maxBytes: params.maxBytes,
431
- nonBlockingRead: params.nonBlockingRead,
432
- symlinks: params.symlinks,
433
- });
434
- }
435
- export async function readLocalFileSafely(params) {
436
- const opened = await openLocalFileSafely({ filePath: params.filePath });
437
- try {
438
- return await readOpenedFileSafely({ opened, maxBytes: params.maxBytes });
439
- }
440
- finally {
441
- await opened.handle.close().catch(() => { });
442
- }
443
- }
444
- export async function openLocalFileSafely(params) {
445
- return await openVerifiedLocalFile(params.filePath);
446
- }
447
- async function readOpenedFileSafely(params) {
448
- if (params.maxBytes !== undefined && params.opened.stat.size > params.maxBytes) {
449
- throw new FsSafeError("too-large", `file exceeds limit of ${params.maxBytes} bytes (got ${params.opened.stat.size})`);
450
- }
451
- const buffer = await params.opened.handle.readFile();
452
- if (params.maxBytes !== undefined && buffer.byteLength > params.maxBytes) {
453
- throw new FsSafeError("too-large", `file exceeds limit of ${params.maxBytes} bytes (got ${buffer.byteLength})`);
454
- }
455
- return {
456
- buffer,
457
- realPath: params.opened.realPath,
458
- stat: params.opened.stat,
459
- };
460
- }
461
- function emitWriteBoundaryWarning(reason) {
462
- logWarn(`security: fs-safe write boundary warning (${reason})`);
463
- }
464
- function buildAtomicWriteTempPath(targetPath) {
465
- const dir = path.dirname(targetPath);
466
- const base = path.basename(targetPath);
467
- return path.join(dir, `.${base}.${process.pid}.${randomUUID()}.tmp`);
468
- }
469
- function rootWriteQueueKey(root, relativePath) {
470
- return `${root.rootReal}\0${relativePath}`;
471
- }
472
- function createMaxBytesTransform(maxBytes) {
473
- let bytes = 0;
474
- return new Transform({
475
- transform(chunk, _encoding, callback) {
476
- const buffer = chunk instanceof Buffer ? chunk : Buffer.from(chunk);
477
- bytes += buffer.byteLength;
478
- if (bytes > maxBytes) {
479
- callback(new FsSafeError("too-large", `file exceeds limit of ${maxBytes} bytes (got at least ${bytes})`));
480
- return;
481
- }
482
- callback(null, buffer);
483
- },
484
- });
485
- }
486
- function createBoundedReadStream(opened, maxBytes) {
487
- const stream = opened.handle.createReadStream();
488
- return maxBytes === undefined ? stream : stream.pipe(createMaxBytesTransform(maxBytes));
489
- }
490
- async function writeTempFileForAtomicReplace(params) {
491
- const tempHandle = await fs.open(params.tempPath, OPEN_WRITE_CREATE_FLAGS, params.mode);
492
- try {
493
- if (typeof params.data === "string") {
494
- await tempHandle.writeFile(params.data, params.encoding ?? "utf8");
495
- }
496
- else {
497
- await tempHandle.writeFile(params.data);
498
- }
499
- return await tempHandle.stat();
500
- }
501
- finally {
502
- await tempHandle.close().catch(() => { });
503
- }
504
- }
505
- async function verifyAtomicWriteResult(params) {
506
- const opened = await openVerifiedLocalFile(params.targetPath, { hardlinks: "reject" });
507
- try {
508
- if (!sameFileIdentity(opened.stat, params.expectedIdentity)) {
509
- throw new FsSafeError("path-mismatch", "path changed during write");
510
- }
511
- if (!isPathInside(params.root.rootWithSep, opened.realPath)) {
512
- throw new FsSafeError("outside-workspace", "file is outside workspace root");
513
- }
514
- }
515
- finally {
516
- await opened.handle.close().catch(() => { });
517
- }
518
- }
519
- export async function resolveOpenedFileRealPathForHandle(handle, ioPath) {
520
- const handleStat = await handle.stat();
521
- const fdCandidates = process.platform === "linux"
522
- ? [`/proc/self/fd/${handle.fd}`, `/dev/fd/${handle.fd}`]
523
- : process.platform === "win32"
524
- ? []
525
- : [`/dev/fd/${handle.fd}`];
526
- for (const fdPath of fdCandidates) {
527
- try {
528
- const fdRealPath = await fs.realpath(fdPath);
529
- const fdRealStat = await fs.stat(fdRealPath);
530
- if (sameFileIdentity(handleStat, fdRealStat)) {
531
- return fdRealPath;
532
- }
533
- }
534
- catch {
535
- // try next fd path
536
- }
537
- }
538
- try {
539
- const ioRealPath = await fs.realpath(ioPath);
540
- const ioRealStat = await fs.stat(ioRealPath);
541
- if (sameFileIdentity(handleStat, ioRealStat)) {
542
- return ioRealPath;
543
- }
544
- }
545
- catch (err) {
546
- if (!isNotFoundPathError(err)) {
547
- throw err;
548
- }
549
- }
550
- const parentResolved = await resolveOpenedFileRealPathFromParent(handleStat, ioPath);
551
- if (parentResolved) {
552
- return parentResolved;
553
- }
554
- throw new FsSafeError("path-mismatch", "unable to resolve opened file path");
555
- }
556
- async function resolveOpenedFileRealPathFromParent(handleStat, ioPath) {
557
- let parentReal;
558
- try {
559
- parentReal = await fs.realpath(path.dirname(ioPath));
560
- }
561
- catch (err) {
562
- if (isNotFoundPathError(err)) {
563
- return null;
564
- }
565
- throw err;
566
- }
567
- let entries;
568
- try {
569
- entries = await fs.readdir(parentReal);
570
- }
571
- catch (err) {
572
- if (isNotFoundPathError(err)) {
573
- return null;
574
- }
575
- throw err;
576
- }
577
- for (const entry of entries.toSorted()) {
578
- const candidatePath = path.join(parentReal, entry);
579
- try {
580
- const candidateStat = await fs.lstat(candidatePath);
581
- if (candidateStat.isFile() && sameFileIdentity(handleStat, candidateStat)) {
582
- return await fs.realpath(candidatePath);
583
- }
584
- }
585
- catch (err) {
586
- if (!isNotFoundPathError(err)) {
587
- throw err;
588
- }
589
- }
590
- }
591
- return null;
592
- }
593
- async function openWritableFileInRoot(root, params) {
594
- const { rootReal, rootWithSep, resolved } = await resolvePathInRoot(root, params.relativePath);
595
- try {
596
- await assertNoPathAliasEscape({
597
- absolutePath: resolved,
598
- rootPath: rootReal,
599
- boundaryLabel: "root",
600
- });
601
- }
602
- catch (err) {
603
- throw new FsSafeError("path-alias", "path alias escape blocked", { cause: err });
604
- }
605
- if (params.mkdir !== false) {
606
- await fs.mkdir(path.dirname(resolved), { recursive: true });
607
- }
608
- let ioPath = resolved;
609
- try {
610
- const resolvedRealPath = await fs.realpath(resolved);
611
- if (!isPathInside(rootWithSep, resolvedRealPath)) {
612
- throw new FsSafeError("outside-workspace", "file is outside workspace root");
613
- }
614
- ioPath = resolvedRealPath;
615
- }
616
- catch (err) {
617
- if (err instanceof FsSafeError) {
618
- throw err;
619
- }
620
- if (!isNotFoundPathError(err)) {
621
- throw err;
622
- }
623
- }
624
- const mode = params.mode ?? 0o600;
625
- let handle;
626
- let createdForWrite = false;
627
- const existingFlags = params.append ? OPEN_APPEND_EXISTING_FLAGS : OPEN_WRITE_EXISTING_FLAGS;
628
- const createFlags = params.append ? OPEN_APPEND_CREATE_FLAGS : OPEN_WRITE_CREATE_FLAGS;
629
- try {
630
- try {
631
- handle = await fs.open(ioPath, existingFlags, mode);
632
- }
633
- catch (err) {
634
- if (!isNotFoundPathError(err)) {
635
- throw err;
636
- }
637
- handle = await fs.open(ioPath, createFlags, mode);
638
- createdForWrite = true;
639
- }
640
- }
641
- catch (err) {
642
- if (isNotFoundPathError(err)) {
643
- throw new FsSafeError("not-found", "file not found");
644
- }
645
- if (isSymlinkOpenError(err)) {
646
- throw new FsSafeError("symlink", "symlink open blocked", { cause: err });
647
- }
648
- throw err;
649
- }
650
- let realPathForCleanup = null;
651
- try {
652
- const stat = await handle.stat();
653
- if (!stat.isFile()) {
654
- throw new FsSafeError("invalid-path", "path is not a regular file under root");
655
- }
656
- if (stat.nlink > 1) {
657
- throw new FsSafeError("hardlink", "hardlinked path not allowed");
658
- }
659
- try {
660
- const lstat = await fs.lstat(ioPath);
661
- if (lstat.isSymbolicLink() || !lstat.isFile()) {
662
- throw new FsSafeError(lstat.isSymbolicLink() ? "symlink" : "not-file", "path is not a regular file under root");
663
- }
664
- if (!sameFileIdentity(stat, lstat)) {
665
- throw new FsSafeError("path-mismatch", "path changed during write");
666
- }
667
- }
668
- catch (err) {
669
- if (!isNotFoundPathError(err)) {
670
- throw err;
671
- }
672
- }
673
- const realPath = await resolveOpenedFileRealPathForHandle(handle, ioPath);
674
- realPathForCleanup = realPath;
675
- const realStat = await fs.stat(realPath);
676
- if (!sameFileIdentity(stat, realStat)) {
677
- throw new FsSafeError("path-mismatch", "path mismatch");
678
- }
679
- if (realStat.nlink > 1) {
680
- throw new FsSafeError("hardlink", "hardlinked path not allowed");
681
- }
682
- if (!isPathInside(rootWithSep, realPath)) {
683
- throw new FsSafeError("outside-workspace", "file is outside workspace root");
684
- }
685
- // Truncate only after boundary and identity checks complete. This avoids
686
- // irreversible side effects if a symlink target changes before validation.
687
- if (params.append !== true && params.truncateExisting !== false && !createdForWrite) {
688
- await handle.truncate(0);
689
- }
690
- return {
691
- handle,
692
- createdForWrite,
693
- realPath,
694
- stat,
695
- [Symbol.asyncDispose]: async () => {
696
- await closeHandleForDispose(handle);
697
- },
698
- };
699
- }
700
- catch (err) {
701
- const cleanupCreatedPath = createdForWrite && err instanceof FsSafeError;
702
- const cleanupPath = realPathForCleanup ?? ioPath;
703
- await handle.close().catch(() => { });
704
- if (cleanupCreatedPath) {
705
- await fs.rm(cleanupPath, { force: true }).catch(() => { });
706
- }
707
- throw err;
708
- }
709
- }
710
- async function appendFileInRoot(root, params) {
711
- const target = await openWritableFileInRoot(root, {
712
- relativePath: params.relativePath,
713
- mkdir: params.mkdir,
714
- mode: params.mode,
715
- truncateExisting: false,
716
- append: true,
717
- });
718
- try {
719
- let prefix = "";
720
- if (params.prependNewlineIfNeeded === true &&
721
- !target.createdForWrite &&
722
- target.stat.size > 0 &&
723
- ((typeof params.data === "string" && !params.data.startsWith("\n")) ||
724
- (Buffer.isBuffer(params.data) && params.data.length > 0 && params.data[0] !== 0x0a))) {
725
- const lastByte = Buffer.alloc(1);
726
- const { bytesRead } = await target.handle.read(lastByte, 0, 1, target.stat.size - 1);
727
- if (bytesRead === 1 && lastByte[0] !== 0x0a) {
728
- prefix = "\n";
729
- }
730
- }
731
- if (typeof params.data === "string") {
732
- await target.handle.appendFile(`${prefix}${params.data}`, params.encoding ?? "utf8");
733
- return;
734
- }
735
- const payload = prefix.length > 0 ? Buffer.concat([Buffer.from(prefix, "utf8"), params.data]) : params.data;
736
- await target.handle.appendFile(payload);
737
- }
738
- finally {
739
- await target.handle.close().catch(() => { });
740
- }
741
- }
742
- async function removePathInRoot(root, relativePath) {
743
- const resolved = await resolvePinnedRemovePathInRoot(root, relativePath);
744
- if (process.platform === "win32") {
745
- await removePathFallback(resolved);
746
- return;
747
- }
748
- try {
749
- await runPinnedPathHelper({
750
- operation: "remove",
751
- rootPath: resolved.rootReal,
752
- relativePath: resolved.relativePosix,
753
- });
754
- }
755
- catch (error) {
756
- if (isPinnedPathHelperSpawnError(error)) {
757
- await removePathFallback(resolved);
758
- return;
759
- }
760
- throw normalizePinnedPathError(error);
761
- }
762
- }
763
- async function mkdirPathInRoot(root, params) {
764
- const resolved = await resolvePinnedPathInRoot(root, params);
765
- if (process.platform === "win32") {
766
- await mkdirPathFallback(resolved);
767
- return;
768
- }
769
- try {
770
- await runPinnedPathHelper({
771
- operation: "mkdirp",
772
- rootPath: resolved.rootReal,
773
- relativePath: resolved.relativePosix,
774
- });
775
- }
776
- catch (error) {
777
- if (isPinnedPathHelperSpawnError(error)) {
778
- await mkdirPathFallback(resolved);
779
- return;
780
- }
781
- throw normalizePinnedPathError(error);
782
- }
783
- }
784
- async function writeFileInRoot(root, params) {
785
- if (process.platform === "win32") {
786
- await serializePathWrite(rootWriteQueueKey(root, params.relativePath), async () => {
787
- await writeFileFallback(root, params);
788
- });
789
- return;
790
- }
791
- const pinned = await resolvePinnedWriteTargetInRoot(root, params.relativePath, params.mode);
792
- await serializePathWrite(pinned.targetPath, async () => {
793
- let identity;
794
- try {
795
- identity = await runPinnedWriteHelper({
796
- rootPath: pinned.rootReal,
797
- relativeParentPath: pinned.relativeParentPath,
798
- basename: pinned.basename,
799
- mkdir: params.mkdir !== false,
800
- mode: params.mode ?? pinned.mode,
801
- overwrite: params.overwrite,
802
- input: {
803
- kind: "buffer",
804
- data: params.data,
805
- encoding: params.encoding,
806
- },
807
- });
808
- }
809
- catch (error) {
810
- if (params.overwrite === false && isAlreadyExistsError(error)) {
811
- throw new FsSafeError("already-exists", "file already exists", {
812
- cause: error instanceof Error ? error : undefined,
813
- });
814
- }
815
- throw normalizePinnedWriteError(error);
816
- }
817
- try {
818
- await verifyAtomicWriteResult({
819
- root,
820
- targetPath: pinned.targetPath,
821
- expectedIdentity: identity,
822
- });
823
- }
824
- catch (err) {
825
- emitWriteBoundaryWarning(`post-write verification failed: ${String(err)}`);
826
- throw err;
827
- }
828
- });
829
- }
830
- async function copyFileInRoot(root, params) {
831
- const source = await openVerifiedLocalFile(params.sourcePath, {
832
- hardlinks: params.sourceHardlinks,
833
- });
834
- if (params.maxBytes !== undefined && source.stat.size > params.maxBytes) {
835
- await source.handle.close().catch(() => { });
836
- throw new FsSafeError("too-large", `file exceeds limit of ${params.maxBytes} bytes (got ${source.stat.size})`);
837
- }
838
- try {
839
- if (process.platform === "win32") {
840
- await serializePathWrite(rootWriteQueueKey(root, params.relativePath), async () => {
841
- await copyFileFallback(root, params, source);
842
- });
843
- return;
844
- }
845
- const pinned = await resolvePinnedWriteTargetInRoot(root, params.relativePath, params.mode);
846
- await serializePathWrite(pinned.targetPath, async () => {
847
- let identity;
848
- try {
849
- if (getFsSafePythonConfig().mode === "off") {
850
- await copyFileFallback(root, params, source);
851
- return;
852
- }
853
- identity = await runPinnedCopyHelper({
854
- rootPath: pinned.rootReal,
855
- relativeParentPath: pinned.relativeParentPath,
856
- basename: pinned.basename,
857
- mkdir: params.mkdir !== false,
858
- mode: pinned.mode,
859
- overwrite: true,
860
- maxBytes: params.maxBytes,
861
- sourcePath: source.realPath,
862
- sourceIdentity: { dev: source.stat.dev, ino: source.stat.ino },
863
- });
864
- }
865
- catch (error) {
866
- if (canFallbackFromPythonError(error)) {
867
- await copyFileFallback(root, params, source);
868
- return;
869
- }
870
- throw normalizePinnedWriteError(error);
871
- }
872
- try {
873
- await verifyAtomicWriteResult({
874
- root,
875
- targetPath: pinned.targetPath,
876
- expectedIdentity: identity,
877
- });
878
- }
879
- catch (err) {
880
- emitWriteBoundaryWarning(`post-copy verification failed: ${String(err)}`);
881
- throw err;
882
- }
883
- });
884
- }
885
- finally {
886
- await source.handle.close().catch(() => { });
887
- }
888
- }
889
- async function resolvePinnedWriteTargetInRoot(root, relativePath, requestedMode) {
890
- const { rootReal, rootWithSep, resolved } = await resolvePathInRoot(root, relativePath);
891
- try {
892
- await assertNoPathAliasEscape({
893
- absolutePath: resolved,
894
- rootPath: rootReal,
895
- boundaryLabel: "root",
896
- });
897
- }
898
- catch (err) {
899
- throw new FsSafeError("path-alias", "path alias escape blocked", { cause: err });
900
- }
901
- const relativeResolved = path.relative(rootReal, resolved);
902
- if (relativeResolved.startsWith("..") || path.isAbsolute(relativeResolved)) {
903
- throw new FsSafeError("outside-workspace", "file is outside workspace root");
904
- }
905
- const relativePosix = relativeResolved
906
- ? relativeResolved.split(path.sep).join(path.posix.sep)
907
- : "";
908
- const basename = path.posix.basename(relativePosix);
909
- if (!basename || basename === "." || basename === "/") {
910
- throw new FsSafeError("invalid-path", "invalid target path");
911
- }
912
- let mode = requestedMode ?? 0o600;
913
- try {
914
- const opened = await openFileInRoot(root, {
915
- relativePath,
916
- hardlinks: "reject",
917
- nonBlockingRead: true,
918
- });
919
- try {
920
- mode = requestedMode ?? (opened.stat.mode & 0o777);
921
- if (!isPathInside(rootWithSep, opened.realPath)) {
922
- throw new FsSafeError("outside-workspace", "file is outside workspace root");
923
- }
924
- }
925
- finally {
926
- await opened.handle.close().catch(() => { });
927
- }
928
- }
929
- catch (err) {
930
- if (!(err instanceof FsSafeError) || err.code !== "not-found") {
931
- throw err;
932
- }
933
- }
934
- return {
935
- rootReal,
936
- targetPath: resolved,
937
- relativeParentPath: path.posix.dirname(relativePosix) === "." ? "" : path.posix.dirname(relativePosix),
938
- basename,
939
- mode: mode || 0o600,
940
- };
941
- }
942
- async function resolvePinnedPathInRoot(root, params) {
943
- const resolved = await resolvePinnedRootPathInRoot(root, {
944
- relativePath: params.relativePath,
945
- policy: PATH_ALIAS_POLICIES.strict,
946
- });
947
- const relativeResolved = path.relative(resolved.rootReal, resolved.canonicalPath);
948
- if ((relativeResolved === "" || relativeResolved === ".") && params.allowRoot === true) {
949
- return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix: "" };
950
- }
951
- if (relativeResolved === "" ||
952
- relativeResolved === "." ||
953
- relativeResolved.startsWith("..") ||
954
- path.isAbsolute(relativeResolved)) {
955
- throw new FsSafeError("outside-workspace", "file is outside workspace root");
956
- }
957
- const relativePosix = relativeResolved.split(path.sep).join(path.posix.sep);
958
- if (!isPathInside(resolved.rootWithSep, resolved.canonicalPath)) {
959
- throw new FsSafeError("outside-workspace", "file is outside workspace root");
960
- }
961
- return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix };
962
- }
963
- async function resolvePinnedRemovePathInRoot(root, relativePath) {
964
- const resolved = await resolvePinnedRootPathInRoot(root, {
965
- relativePath,
966
- policy: PATH_ALIAS_POLICIES.unlinkTarget,
967
- });
968
- const relativeResolved = path.relative(resolved.rootReal, resolved.canonicalPath);
969
- if (relativeResolved === "" ||
970
- relativeResolved === "." ||
971
- relativeResolved.startsWith("..") ||
972
- path.isAbsolute(relativeResolved)) {
973
- throw new FsSafeError("outside-workspace", "file is outside workspace root");
974
- }
975
- const relativePosix = relativeResolved.split(path.sep).join(path.posix.sep);
976
- if (!isPathInside(resolved.rootWithSep, resolved.canonicalPath)) {
977
- throw new FsSafeError("outside-workspace", "file is outside workspace root");
978
- }
979
- const parentRelative = path.posix.dirname(relativePosix);
980
- if (parentRelative === "." || parentRelative === "") {
981
- return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix };
982
- }
983
- return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix };
984
- }
985
- async function resolvePinnedRootPathInRoot(root, params) {
986
- const rootReal = root.rootReal;
987
- let resolved;
988
- try {
989
- resolved = await resolveRootPath({
990
- absolutePath: path.resolve(rootReal, await expandRelativePathWithHome(params.relativePath)),
991
- rootPath: rootReal,
992
- rootCanonicalPath: rootReal,
993
- boundaryLabel: "root",
994
- policy: params.policy,
995
- });
996
- }
997
- catch (err) {
998
- throw new FsSafeError("path-alias", "path alias escape blocked", { cause: err });
999
- }
1000
- const rootWithSep = ensureTrailingSep(resolved.rootCanonicalPath);
1001
- return {
1002
- rootReal: resolved.rootCanonicalPath,
1003
- rootWithSep,
1004
- canonicalPath: resolved.canonicalPath,
1005
- };
1006
- }
1007
- function normalizePinnedWriteError(error) {
1008
- if (error instanceof FsSafeError) {
1009
- return error;
1010
- }
1011
- return new FsSafeError("invalid-path", "path is not a regular file under root", {
1012
- cause: error instanceof Error ? error : undefined,
1013
- });
1014
- }
1015
- function isAlreadyExistsError(error) {
1016
- return hasNodeErrorCode(error, "EEXIST") || /File exists|EEXIST/i.test(String(error));
1017
- }
1018
- function normalizePinnedPathError(error) {
1019
- if (error instanceof FsSafeError) {
1020
- return error;
1021
- }
1022
- return new FsSafeError("path-alias", "path is not under root", {
1023
- cause: error instanceof Error ? error : undefined,
1024
- });
1025
- }
1026
- async function removePathFallback(resolved) {
1027
- await fs.rm(resolved.resolved);
1028
- }
1029
- async function mkdirPathFallback(resolved) {
1030
- await fs.mkdir(resolved.resolved, { recursive: true });
1031
- }
1032
- function pathStatFromStats(stat) {
1033
- return {
1034
- dev: Number(stat.dev),
1035
- gid: Number(stat.gid),
1036
- ino: Number(stat.ino),
1037
- isDirectory: stat.isDirectory(),
1038
- isFile: stat.isFile(),
1039
- isSymbolicLink: stat.isSymbolicLink(),
1040
- mode: stat.mode,
1041
- mtimeMs: stat.mtimeMs,
1042
- nlink: stat.nlink,
1043
- size: stat.size,
1044
- uid: stat.uid,
1045
- };
1046
- }
1047
- async function statPathFallback(root, relativePath) {
1048
- const resolved = await resolvePinnedPathInRoot(root, { relativePath, allowRoot: true });
1049
- try {
1050
- return pathStatFromStats(await fs.lstat(resolved.resolved));
1051
- }
1052
- catch (error) {
1053
- if (isNotFoundPathError(error)) {
1054
- throw new FsSafeError("not-found", "file not found", {
1055
- cause: error instanceof Error ? error : undefined,
1056
- });
1057
- }
1058
- throw error;
1059
- }
1060
- }
1061
- async function listPathFallback(root, relativePath, withFileTypes) {
1062
- const resolved = await resolvePinnedPathInRoot(root, { relativePath, allowRoot: true });
1063
- try {
1064
- const names = await fs.readdir(resolved.resolved);
1065
- const sortedNames = names.toSorted();
1066
- if (!withFileTypes) {
1067
- return sortedNames;
1068
- }
1069
- const entries = [];
1070
- for (const name of sortedNames) {
1071
- entries.push({
1072
- name,
1073
- ...pathStatFromStats(await fs.lstat(path.join(resolved.resolved, name))),
1074
- });
1075
- }
1076
- return entries;
1077
- }
1078
- catch (error) {
1079
- if (isNotFoundPathError(error)) {
1080
- throw new FsSafeError("not-found", "directory not found", {
1081
- cause: error instanceof Error ? error : undefined,
1082
- });
1083
- }
1084
- throw error;
1085
- }
1086
- }
1087
- async function movePathFallback(root, params) {
1088
- const source = await resolvePathInRoot(root, params.fromRelative);
1089
- await resolvePinnedRootPathInRoot(root, {
1090
- relativePath: params.fromRelative,
1091
- policy: PATH_ALIAS_POLICIES.strict,
1092
- });
1093
- const target = await resolvePathInRoot(root, params.toRelative);
1094
- await resolvePinnedRootPathInRoot(root, {
1095
- relativePath: params.toRelative,
1096
- policy: PATH_ALIAS_POLICIES.unlinkTarget,
1097
- });
1098
- try {
1099
- await assertNoPathAliasEscape({
1100
- absolutePath: target.resolved,
1101
- rootPath: target.rootReal,
1102
- boundaryLabel: "root",
1103
- });
1104
- }
1105
- catch (error) {
1106
- throw new FsSafeError("path-alias", "path alias escape blocked", {
1107
- cause: error instanceof Error ? error : undefined,
1108
- });
1109
- }
1110
- let sourceStat;
1111
- try {
1112
- sourceStat = await fs.lstat(source.resolved);
1113
- }
1114
- catch (error) {
1115
- if (isNotFoundPathError(error)) {
1116
- throw new FsSafeError("not-found", "file not found", {
1117
- cause: error instanceof Error ? error : undefined,
1118
- });
1119
- }
1120
- throw error;
1121
- }
1122
- if (sourceStat.isSymbolicLink()) {
1123
- throw new FsSafeError("symlink", "symlink not allowed");
1124
- }
1125
- if (sourceStat.isFile() && sourceStat.nlink > 1) {
1126
- throw new FsSafeError("hardlink", "hardlinked path not allowed");
1127
- }
1128
- if (!params.overwrite) {
1129
- try {
1130
- await fs.lstat(target.resolved);
1131
- throw new FsSafeError("already-exists", "destination exists");
1132
- }
1133
- catch (error) {
1134
- if (error instanceof FsSafeError) {
1135
- throw error;
1136
- }
1137
- if (!isNotFoundPathError(error)) {
1138
- throw error;
1139
- }
1140
- }
1141
- }
1142
- try {
1143
- await fs.rename(source.resolved, target.resolved);
1144
- }
1145
- catch (error) {
1146
- if (isNotFoundPathError(error)) {
1147
- throw new FsSafeError("not-found", "file not found", {
1148
- cause: error instanceof Error ? error : undefined,
1149
- });
1150
- }
1151
- if (hasNodeErrorCode(error, "EEXIST")) {
1152
- throw new FsSafeError("already-exists", "destination exists", {
1153
- cause: error instanceof Error ? error : undefined,
1154
- });
1155
- }
1156
- throw error;
1157
- }
1158
- }
1159
- async function writeFileFallback(root, params) {
1160
- if (params.overwrite === false) {
1161
- await writeMissingFileFallback(root, params);
1162
- return;
1163
- }
1164
- const target = await openWritableFileInRoot(root, {
1165
- relativePath: params.relativePath,
1166
- mkdir: params.mkdir,
1167
- mode: params.mode,
1168
- truncateExisting: false,
1169
- });
1170
- const destinationPath = target.realPath;
1171
- const mode = params.mode ?? (target.stat.mode & 0o777);
1172
- await target.handle.close().catch(() => { });
1173
- let tempPath = null;
1174
- let unregisterTempPath = null;
1175
- try {
1176
- tempPath = buildAtomicWriteTempPath(destinationPath);
1177
- unregisterTempPath = registerTempPathForExit(tempPath);
1178
- const writtenStat = await writeTempFileForAtomicReplace({
1179
- tempPath,
1180
- data: params.data,
1181
- encoding: params.encoding,
1182
- mode: mode || 0o600,
1183
- });
1184
- await fs.rename(tempPath, destinationPath);
1185
- tempPath = null;
1186
- unregisterTempPath();
1187
- unregisterTempPath = null;
1188
- try {
1189
- await verifyAtomicWriteResult({
1190
- root,
1191
- targetPath: destinationPath,
1192
- expectedIdentity: writtenStat,
1193
- });
1194
- }
1195
- catch (err) {
1196
- emitWriteBoundaryWarning(`post-write verification failed: ${String(err)}`);
1197
- throw err;
1198
- }
1199
- }
1200
- finally {
1201
- if (tempPath) {
1202
- await fs.rm(tempPath, { force: true }).catch(() => { });
1203
- }
1204
- unregisterTempPath?.();
1205
- }
1206
- }
1207
- async function writeMissingFileFallback(root, params) {
1208
- const { rootReal, resolved } = await resolvePathInRoot(root, params.relativePath);
1209
- try {
1210
- await assertNoPathAliasEscape({
1211
- absolutePath: resolved,
1212
- rootPath: rootReal,
1213
- boundaryLabel: "root",
1214
- });
1215
- }
1216
- catch (err) {
1217
- throw new FsSafeError("path-alias", "path alias escape blocked", { cause: err });
1218
- }
1219
- if (params.mkdir !== false) {
1220
- await fs.mkdir(path.dirname(resolved), { recursive: true });
1221
- }
1222
- let handle = null;
1223
- let created = false;
1224
- try {
1225
- handle = await fs.open(resolved, OPEN_WRITE_CREATE_FLAGS, params.mode ?? 0o600);
1226
- created = true;
1227
- if (typeof params.data === "string") {
1228
- await handle.writeFile(params.data, params.encoding ?? "utf8");
1229
- }
1230
- else {
1231
- await handle.writeFile(params.data);
1232
- }
1233
- const writtenStat = await handle.stat();
1234
- await handle.close();
1235
- handle = null;
1236
- await verifyAtomicWriteResult({
1237
- root,
1238
- targetPath: resolved,
1239
- expectedIdentity: writtenStat,
1240
- });
1241
- created = false;
1242
- }
1243
- catch (err) {
1244
- if (hasNodeErrorCode(err, "EEXIST")) {
1245
- throw new FsSafeError("already-exists", "file already exists", {
1246
- cause: err instanceof Error ? err : undefined,
1247
- });
1248
- }
1249
- throw err;
1250
- }
1251
- finally {
1252
- await handle?.close().catch(() => undefined);
1253
- if (created) {
1254
- await fs.rm(resolved, { force: true }).catch(() => undefined);
1255
- }
1256
- }
1257
- }
1258
- async function copyFileFallback(root, params, source) {
1259
- let target = null;
1260
- let sourceClosedByStream = false;
1261
- let targetClosedByUs = false;
1262
- let tempHandle = null;
1263
- let tempPath = null;
1264
- let unregisterTempPath = null;
1265
- let tempClosedByStream = false;
1266
- try {
1267
- target = await openWritableFileInRoot(root, {
1268
- relativePath: params.relativePath,
1269
- mkdir: params.mkdir,
1270
- mode: params.mode,
1271
- truncateExisting: false,
1272
- });
1273
- const destinationPath = target.realPath;
1274
- const mode = params.mode ?? (target.stat.mode & 0o777);
1275
- await target.handle.close().catch(() => { });
1276
- targetClosedByUs = true;
1277
- tempPath = buildAtomicWriteTempPath(destinationPath);
1278
- unregisterTempPath = registerTempPathForExit(tempPath);
1279
- tempHandle = await fs.open(tempPath, OPEN_WRITE_CREATE_FLAGS, mode || 0o600);
1280
- const sourceStream = createBoundedReadStream(source, params.maxBytes);
1281
- const targetStream = tempHandle.createWriteStream();
1282
- sourceStream.once("close", () => {
1283
- sourceClosedByStream = true;
1284
- });
1285
- targetStream.once("close", () => {
1286
- tempClosedByStream = true;
1287
- });
1288
- await pipeline(sourceStream, targetStream);
1289
- const writtenStat = await fs.stat(tempPath);
1290
- if (!tempClosedByStream) {
1291
- await tempHandle.close().catch(() => { });
1292
- tempClosedByStream = true;
1293
- }
1294
- tempHandle = null;
1295
- await fs.rename(tempPath, destinationPath);
1296
- tempPath = null;
1297
- unregisterTempPath();
1298
- unregisterTempPath = null;
1299
- try {
1300
- await verifyAtomicWriteResult({
1301
- root,
1302
- targetPath: destinationPath,
1303
- expectedIdentity: writtenStat,
1304
- });
1305
- }
1306
- catch (err) {
1307
- emitWriteBoundaryWarning(`post-copy verification failed: ${String(err)}`);
1308
- throw err;
1309
- }
1310
- }
1311
- catch (err) {
1312
- if (target?.createdForWrite) {
1313
- await fs.rm(target.realPath, { force: true }).catch(() => { });
1314
- }
1315
- throw err;
1316
- }
1317
- finally {
1318
- if (!sourceClosedByStream) {
1319
- await source.handle.close().catch(() => { });
1320
- }
1321
- if (tempHandle && !tempClosedByStream) {
1322
- await tempHandle.close().catch(() => { });
1323
- }
1324
- if (tempPath) {
1325
- await fs.rm(tempPath, { force: true }).catch(() => { });
1326
- }
1327
- unregisterTempPath?.();
1328
- if (target && !targetClosedByUs) {
1329
- await target.handle.close().catch(() => { });
1330
- }
1331
- }
1332
- }
1
+ export { DEFAULT_ROOT_MAX_BYTES, openLocalFileSafely, readLocalFileSafely, resolveOpenedFileRealPathForHandle, root, } from "./root-impl.js";