@openclaw/fs-safe 0.1.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 (260) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/LICENSE +21 -0
  3. package/README.md +390 -0
  4. package/SECURITY.md +12 -0
  5. package/dist/absolute-path.d.ts +19 -0
  6. package/dist/absolute-path.d.ts.map +1 -0
  7. package/dist/absolute-path.js +98 -0
  8. package/dist/advanced.d.ts +22 -0
  9. package/dist/advanced.d.ts.map +1 -0
  10. package/dist/advanced.js +24 -0
  11. package/dist/archive-entry.d.ts +13 -0
  12. package/dist/archive-entry.d.ts.map +1 -0
  13. package/dist/archive-entry.js +47 -0
  14. package/dist/archive-kind.d.ts +8 -0
  15. package/dist/archive-kind.d.ts.map +1 -0
  16. package/dist/archive-kind.js +58 -0
  17. package/dist/archive-limits.d.ts +40 -0
  18. package/dist/archive-limits.d.ts.map +1 -0
  19. package/dist/archive-limits.js +92 -0
  20. package/dist/archive-staging.d.ts +26 -0
  21. package/dist/archive-staging.d.ts.map +1 -0
  22. package/dist/archive-staging.js +171 -0
  23. package/dist/archive-tar.d.ts +14 -0
  24. package/dist/archive-tar.d.ts.map +1 -0
  25. package/dist/archive-tar.js +52 -0
  26. package/dist/archive-utils.d.ts +3 -0
  27. package/dist/archive-utils.d.ts.map +1 -0
  28. package/dist/archive-utils.js +26 -0
  29. package/dist/archive-zip-preflight.d.ts +7 -0
  30. package/dist/archive-zip-preflight.d.ts.map +1 -0
  31. package/dist/archive-zip-preflight.js +170 -0
  32. package/dist/archive.d.ts +24 -0
  33. package/dist/archive.d.ts.map +1 -0
  34. package/dist/archive.js +251 -0
  35. package/dist/async-lock.d.ts +2 -0
  36. package/dist/async-lock.d.ts.map +1 -0
  37. package/dist/async-lock.js +17 -0
  38. package/dist/atomic.d.ts +5 -0
  39. package/dist/atomic.d.ts.map +1 -0
  40. package/dist/atomic.js +4 -0
  41. package/dist/boundary-file-read.d.ts +44 -0
  42. package/dist/boundary-file-read.d.ts.map +1 -0
  43. package/dist/boundary-file-read.js +129 -0
  44. package/dist/boundary-path.d.ts +39 -0
  45. package/dist/boundary-path.d.ts.map +1 -0
  46. package/dist/boundary-path.js +598 -0
  47. package/dist/config.d.ts +2 -0
  48. package/dist/config.d.ts.map +1 -0
  49. package/dist/config.js +1 -0
  50. package/dist/errors.d.ts +11 -0
  51. package/dist/errors.d.ts.map +1 -0
  52. package/dist/errors.js +20 -0
  53. package/dist/file-identity.d.ts +6 -0
  54. package/dist/file-identity.d.ts.map +1 -0
  55. package/dist/file-identity.js +14 -0
  56. package/dist/file-lock.d.ts +20 -0
  57. package/dist/file-lock.d.ts.map +1 -0
  58. package/dist/file-lock.js +34 -0
  59. package/dist/file-store.d.ts +66 -0
  60. package/dist/file-store.d.ts.map +1 -0
  61. package/dist/file-store.js +436 -0
  62. package/dist/file-url.d.ts +2 -0
  63. package/dist/file-url.d.ts.map +1 -0
  64. package/dist/file-url.js +1 -0
  65. package/dist/filename.d.ts +2 -0
  66. package/dist/filename.d.ts.map +1 -0
  67. package/dist/filename.js +25 -0
  68. package/dist/fs-pinned-path-helper.d.ts +7 -0
  69. package/dist/fs-pinned-path-helper.d.ts.map +1 -0
  70. package/dist/fs-pinned-path-helper.js +182 -0
  71. package/dist/fs-pinned-write-helper.d.ts +21 -0
  72. package/dist/fs-pinned-write-helper.d.ts.map +1 -0
  73. package/dist/fs-pinned-write-helper.js +263 -0
  74. package/dist/fs.d.ts +13 -0
  75. package/dist/fs.d.ts.map +1 -0
  76. package/dist/fs.js +30 -0
  77. package/dist/hardlink-guards.d.ts +7 -0
  78. package/dist/hardlink-guards.d.ts.map +1 -0
  79. package/dist/hardlink-guards.js +30 -0
  80. package/dist/home-dir.d.ts +22 -0
  81. package/dist/home-dir.d.ts.map +1 -0
  82. package/dist/home-dir.js +107 -0
  83. package/dist/index.d.ts +4 -0
  84. package/dist/index.d.ts.map +1 -0
  85. package/dist/index.js +3 -0
  86. package/dist/install-path.d.ts +20 -0
  87. package/dist/install-path.d.ts.map +1 -0
  88. package/dist/install-path.js +94 -0
  89. package/dist/install-safe-path.d.ts +20 -0
  90. package/dist/install-safe-path.d.ts.map +1 -0
  91. package/dist/install-safe-path.js +94 -0
  92. package/dist/json-document-store.d.ts +30 -0
  93. package/dist/json-document-store.d.ts.map +1 -0
  94. package/dist/json-document-store.js +68 -0
  95. package/dist/json-file.d.ts +3 -0
  96. package/dist/json-file.d.ts.map +1 -0
  97. package/dist/json-file.js +123 -0
  98. package/dist/json-files.d.ts +20 -0
  99. package/dist/json-files.d.ts.map +1 -0
  100. package/dist/json-files.js +153 -0
  101. package/dist/json-store.d.ts +9 -0
  102. package/dist/json-store.d.ts.map +1 -0
  103. package/dist/json-store.js +12 -0
  104. package/dist/json.d.ts +17 -0
  105. package/dist/json.d.ts.map +1 -0
  106. package/dist/json.js +198 -0
  107. package/dist/local-file-access.d.ts +8 -0
  108. package/dist/local-file-access.d.ts.map +1 -0
  109. package/dist/local-file-access.js +73 -0
  110. package/dist/local-roots.d.ts +26 -0
  111. package/dist/local-roots.d.ts.map +1 -0
  112. package/dist/local-roots.js +150 -0
  113. package/dist/mode.d.ts +2 -0
  114. package/dist/mode.d.ts.map +1 -0
  115. package/dist/mode.js +3 -0
  116. package/dist/move-path.d.ts +6 -0
  117. package/dist/move-path.d.ts.map +1 -0
  118. package/dist/move-path.js +18 -0
  119. package/dist/path-alias-guards.d.ts +19 -0
  120. package/dist/path-alias-guards.d.ts.map +1 -0
  121. package/dist/path-alias-guards.js +21 -0
  122. package/dist/path-guards.d.ts +7 -0
  123. package/dist/path-guards.d.ts.map +1 -0
  124. package/dist/path-guards.js +49 -0
  125. package/dist/path-policy.d.ts +25 -0
  126. package/dist/path-policy.d.ts.map +1 -0
  127. package/dist/path-policy.js +50 -0
  128. package/dist/path-safety.d.ts +12 -0
  129. package/dist/path-safety.d.ts.map +1 -0
  130. package/dist/path-safety.js +50 -0
  131. package/dist/path-scope.d.ts +2 -0
  132. package/dist/path-scope.d.ts.map +1 -0
  133. package/dist/path-scope.js +1 -0
  134. package/dist/path.d.ts +18 -0
  135. package/dist/path.d.ts.map +1 -0
  136. package/dist/path.js +123 -0
  137. package/dist/permissions-public.d.ts +2 -0
  138. package/dist/permissions-public.d.ts.map +1 -0
  139. package/dist/permissions-public.js +1 -0
  140. package/dist/permissions.d.ts +86 -0
  141. package/dist/permissions.d.ts.map +1 -0
  142. package/dist/permissions.js +435 -0
  143. package/dist/pinned-helper.d.ts +9 -0
  144. package/dist/pinned-helper.d.ts.map +1 -0
  145. package/dist/pinned-helper.js +19 -0
  146. package/dist/pinned-open.d.ts +24 -0
  147. package/dist/pinned-open.d.ts.map +1 -0
  148. package/dist/pinned-open.js +71 -0
  149. package/dist/pinned-path.d.ts +7 -0
  150. package/dist/pinned-path.d.ts.map +1 -0
  151. package/dist/pinned-path.js +21 -0
  152. package/dist/pinned-python-config.d.ts +9 -0
  153. package/dist/pinned-python-config.d.ts.map +1 -0
  154. package/dist/pinned-python-config.js +39 -0
  155. package/dist/pinned-python.d.ts +12 -0
  156. package/dist/pinned-python.d.ts.map +1 -0
  157. package/dist/pinned-python.js +599 -0
  158. package/dist/pinned-write.d.ts +33 -0
  159. package/dist/pinned-write.d.ts.map +1 -0
  160. package/dist/pinned-write.js +198 -0
  161. package/dist/private-file-store.d.ts +60 -0
  162. package/dist/private-file-store.d.ts.map +1 -0
  163. package/dist/private-file-store.js +233 -0
  164. package/dist/private-temp-workspace.d.ts +39 -0
  165. package/dist/private-temp-workspace.d.ts.map +1 -0
  166. package/dist/private-temp-workspace.js +185 -0
  167. package/dist/regular-file.d.ts +38 -0
  168. package/dist/regular-file.d.ts.map +1 -0
  169. package/dist/regular-file.js +239 -0
  170. package/dist/replace-directory.d.ts +7 -0
  171. package/dist/replace-directory.d.ts.map +1 -0
  172. package/dist/replace-directory.js +32 -0
  173. package/dist/replace-file.d.ts +41 -0
  174. package/dist/replace-file.d.ts.map +1 -0
  175. package/dist/replace-file.js +325 -0
  176. package/dist/root-file.d.ts +44 -0
  177. package/dist/root-file.d.ts.map +1 -0
  178. package/dist/root-file.js +129 -0
  179. package/dist/root-path.d.ts +39 -0
  180. package/dist/root-path.d.ts.map +1 -0
  181. package/dist/root-path.js +598 -0
  182. package/dist/root-paths.d.ts +92 -0
  183. package/dist/root-paths.d.ts.map +1 -0
  184. package/dist/root-paths.js +338 -0
  185. package/dist/root.d.ts +108 -0
  186. package/dist/root.d.ts.map +1 -0
  187. package/dist/root.js +1332 -0
  188. package/dist/safe-open-sync.d.ts +24 -0
  189. package/dist/safe-open-sync.d.ts.map +1 -0
  190. package/dist/safe-open-sync.js +71 -0
  191. package/dist/safe-root.d.ts +123 -0
  192. package/dist/safe-root.d.ts.map +1 -0
  193. package/dist/safe-root.js +1060 -0
  194. package/dist/secret-file.d.ts +17 -0
  195. package/dist/secret-file.d.ts.map +1 -0
  196. package/dist/secret-file.js +232 -0
  197. package/dist/secret.d.ts +2 -0
  198. package/dist/secret.d.ts.map +1 -0
  199. package/dist/secret.js +1 -0
  200. package/dist/secure-file.d.ts +32 -0
  201. package/dist/secure-file.d.ts.map +1 -0
  202. package/dist/secure-file.js +163 -0
  203. package/dist/secure-temp-dir.d.ts +27 -0
  204. package/dist/secure-temp-dir.d.ts.map +1 -0
  205. package/dist/secure-temp-dir.js +155 -0
  206. package/dist/secure-temp-workspace.d.ts +25 -0
  207. package/dist/secure-temp-workspace.d.ts.map +1 -0
  208. package/dist/secure-temp-workspace.js +136 -0
  209. package/dist/sibling-temp-file.d.ts +16 -0
  210. package/dist/sibling-temp-file.d.ts.map +1 -0
  211. package/dist/sibling-temp-file.js +73 -0
  212. package/dist/sibling-temp-write.d.ts +8 -0
  213. package/dist/sibling-temp-write.d.ts.map +1 -0
  214. package/dist/sibling-temp-write.js +40 -0
  215. package/dist/sibling-temp.d.ts +23 -0
  216. package/dist/sibling-temp.d.ts.map +1 -0
  217. package/dist/sibling-temp.js +119 -0
  218. package/dist/sidecar-lock.d.ts +50 -0
  219. package/dist/sidecar-lock.d.ts.map +1 -0
  220. package/dist/sidecar-lock.js +235 -0
  221. package/dist/store.d.ts +3 -0
  222. package/dist/store.d.ts.map +1 -0
  223. package/dist/store.js +2 -0
  224. package/dist/string-coerce.d.ts +14 -0
  225. package/dist/string-coerce.d.ts.map +1 -0
  226. package/dist/string-coerce.js +72 -0
  227. package/dist/symlink-parents.d.ts +12 -0
  228. package/dist/symlink-parents.d.ts.map +1 -0
  229. package/dist/symlink-parents.js +78 -0
  230. package/dist/temp-cleanup.d.ts +6 -0
  231. package/dist/temp-cleanup.d.ts.map +1 -0
  232. package/dist/temp-cleanup.js +42 -0
  233. package/dist/temp-target.d.ts +28 -0
  234. package/dist/temp-target.d.ts.map +1 -0
  235. package/dist/temp-target.js +84 -0
  236. package/dist/temp.d.ts +3 -0
  237. package/dist/temp.d.ts.map +1 -0
  238. package/dist/temp.js +2 -0
  239. package/dist/test-hooks.d.ts +9 -0
  240. package/dist/test-hooks.d.ts.map +1 -0
  241. package/dist/test-hooks.js +13 -0
  242. package/dist/text-atomic.d.ts +7 -0
  243. package/dist/text-atomic.d.ts.map +1 -0
  244. package/dist/text-atomic.js +13 -0
  245. package/dist/timing.d.ts +6 -0
  246. package/dist/timing.d.ts.map +1 -0
  247. package/dist/timing.js +24 -0
  248. package/dist/trash.d.ts +5 -0
  249. package/dist/trash.d.ts.map +1 -0
  250. package/dist/trash.js +127 -0
  251. package/dist/types.d.ts +23 -0
  252. package/dist/types.d.ts.map +1 -0
  253. package/dist/types.js +1 -0
  254. package/dist/walk.d.ts +26 -0
  255. package/dist/walk.d.ts.map +1 -0
  256. package/dist/walk.js +169 -0
  257. package/dist/write-queue.d.ts +2 -0
  258. package/dist/write-queue.d.ts.map +1 -0
  259. package/dist/write-queue.js +18 -0
  260. package/package.json +117 -0
@@ -0,0 +1,599 @@
1
+ import { spawn } from "node:child_process";
2
+ import fsSync from "node:fs";
3
+ import { FsSafeError } from "./errors.js";
4
+ import { getFsSafePythonConfig } from "./pinned-python-config.js";
5
+ const PINNED_PYTHON_WORKER_SOURCE = String.raw `
6
+ import base64
7
+ import errno
8
+ import json
9
+ import os
10
+ import secrets
11
+ import stat
12
+ import sys
13
+
14
+ DIR_FLAGS = os.O_RDONLY
15
+ if hasattr(os, "O_DIRECTORY"):
16
+ DIR_FLAGS |= os.O_DIRECTORY
17
+ if hasattr(os, "O_NOFOLLOW"):
18
+ DIR_FLAGS |= os.O_NOFOLLOW
19
+ READ_FLAGS = os.O_RDONLY
20
+ if hasattr(os, "O_NOFOLLOW"):
21
+ READ_FLAGS |= os.O_NOFOLLOW
22
+ WRITE_FLAGS = os.O_WRONLY | os.O_CREAT | os.O_EXCL
23
+ if hasattr(os, "O_NOFOLLOW"):
24
+ WRITE_FLAGS |= os.O_NOFOLLOW
25
+
26
+ def split_relative(value):
27
+ if value in ("", "."):
28
+ return []
29
+ if "\x00" in value or value.startswith("/") or value.startswith("//"):
30
+ raise OSError(errno.EPERM, "invalid relative path")
31
+ if value.startswith("..\\"):
32
+ raise OSError(errno.EPERM, "path traversal is not allowed")
33
+ parts = [part for part in value.split("/") if part and part != "."]
34
+ for part in parts:
35
+ if part == "..":
36
+ raise OSError(errno.EPERM, "path traversal is not allowed")
37
+ return parts
38
+
39
+ def open_dir(path_value, dir_fd=None):
40
+ return os.open(path_value, DIR_FLAGS, dir_fd=dir_fd)
41
+
42
+ def walk_dir(root_fd, segments, mkdir_enabled=False):
43
+ current_fd = os.dup(root_fd)
44
+ try:
45
+ for segment in segments:
46
+ try:
47
+ next_fd = open_dir(segment, dir_fd=current_fd)
48
+ except FileNotFoundError:
49
+ if not mkdir_enabled:
50
+ raise
51
+ os.mkdir(segment, 0o777, dir_fd=current_fd)
52
+ next_fd = open_dir(segment, dir_fd=current_fd)
53
+ os.close(current_fd)
54
+ current_fd = next_fd
55
+ return current_fd
56
+ except Exception:
57
+ os.close(current_fd)
58
+ raise
59
+
60
+ def parent_and_basename(root_fd, relative):
61
+ segments = split_relative(relative)
62
+ if not segments:
63
+ raise OSError(errno.EPERM, "operation requires a non-root path")
64
+ parent_fd = walk_dir(root_fd, segments[:-1])
65
+ return parent_fd, segments[-1]
66
+
67
+ def encode_stat(st):
68
+ mode = st.st_mode
69
+ return {
70
+ "dev": st.st_dev,
71
+ "gid": st.st_gid,
72
+ "ino": st.st_ino,
73
+ "isDirectory": stat.S_ISDIR(mode),
74
+ "isFile": stat.S_ISREG(mode),
75
+ "isSymbolicLink": stat.S_ISLNK(mode),
76
+ "mode": mode,
77
+ "mtimeMs": st.st_mtime * 1000,
78
+ "nlink": st.st_nlink,
79
+ "size": st.st_size,
80
+ "uid": st.st_uid,
81
+ }
82
+
83
+ def reject_unsafe_endpoint(st):
84
+ mode = st.st_mode
85
+ if stat.S_ISLNK(mode):
86
+ raise OSError(errno.ELOOP, "symlink endpoint is not allowed")
87
+ if stat.S_ISREG(mode) and st.st_nlink > 1:
88
+ raise OSError(errno.EPERM, "hardlinked file endpoint is not allowed")
89
+
90
+ def stat_path(root_fd, payload):
91
+ relative = payload.get("relativePath", "")
92
+ segments = split_relative(relative)
93
+ if not segments:
94
+ return encode_stat(os.fstat(root_fd))
95
+ parent_fd, basename = parent_and_basename(root_fd, relative)
96
+ try:
97
+ st = os.lstat(basename, dir_fd=parent_fd)
98
+ if payload.get("rejectSymlink", True) and stat.S_ISLNK(st.st_mode):
99
+ raise OSError(errno.ELOOP, "symlink endpoint is not allowed")
100
+ return encode_stat(st)
101
+ finally:
102
+ os.close(parent_fd)
103
+
104
+ def readdir_path(root_fd, payload):
105
+ dir_fd = walk_dir(root_fd, split_relative(payload.get("relativePath", "")))
106
+ try:
107
+ names = sorted(os.listdir(dir_fd))
108
+ if not payload.get("withFileTypes", False):
109
+ return names
110
+ entries = []
111
+ for name in names:
112
+ st = os.lstat(name, dir_fd=dir_fd)
113
+ entry = encode_stat(st)
114
+ entry["name"] = name
115
+ entries.append(entry)
116
+ return entries
117
+ finally:
118
+ os.close(dir_fd)
119
+
120
+ def mkdirp_path(root_fd, payload):
121
+ dir_fd = walk_dir(root_fd, split_relative(payload.get("relativePath", "")), mkdir_enabled=True)
122
+ os.close(dir_fd)
123
+ return None
124
+
125
+ def remove_tree(parent_fd, basename):
126
+ st = os.lstat(basename, dir_fd=parent_fd)
127
+ if stat.S_ISDIR(st.st_mode) and not stat.S_ISLNK(st.st_mode):
128
+ dir_fd = open_dir(basename, dir_fd=parent_fd)
129
+ try:
130
+ for child in os.listdir(dir_fd):
131
+ remove_tree(dir_fd, child)
132
+ finally:
133
+ os.close(dir_fd)
134
+ os.rmdir(basename, dir_fd=parent_fd)
135
+ else:
136
+ os.unlink(basename, dir_fd=parent_fd)
137
+
138
+ def remove_path(root_fd, payload):
139
+ parent_fd, basename = parent_and_basename(root_fd, payload.get("relativePath", ""))
140
+ try:
141
+ try:
142
+ st = os.lstat(basename, dir_fd=parent_fd)
143
+ except FileNotFoundError:
144
+ if payload.get("force", True):
145
+ return None
146
+ raise
147
+ if stat.S_ISDIR(st.st_mode) and not stat.S_ISLNK(st.st_mode):
148
+ if payload.get("recursive", False):
149
+ remove_tree(parent_fd, basename)
150
+ else:
151
+ os.rmdir(basename, dir_fd=parent_fd)
152
+ else:
153
+ os.unlink(basename, dir_fd=parent_fd)
154
+ return None
155
+ finally:
156
+ os.close(parent_fd)
157
+
158
+ def rename_path(root_fd, payload):
159
+ from_parent_fd, from_base = parent_and_basename(root_fd, payload["from"])
160
+ to_parent_fd, to_base = parent_and_basename(root_fd, payload["to"])
161
+ try:
162
+ from_stat = os.lstat(from_base, dir_fd=from_parent_fd)
163
+ reject_unsafe_endpoint(from_stat)
164
+ if not payload.get("overwrite", True):
165
+ try:
166
+ os.lstat(to_base, dir_fd=to_parent_fd)
167
+ raise FileExistsError(errno.EEXIST, "destination exists", to_base)
168
+ except FileNotFoundError:
169
+ pass
170
+ os.rename(from_base, to_base, src_dir_fd=from_parent_fd, dst_dir_fd=to_parent_fd)
171
+ os.fsync(from_parent_fd)
172
+ if from_parent_fd != to_parent_fd:
173
+ os.fsync(to_parent_fd)
174
+ return None
175
+ finally:
176
+ os.close(from_parent_fd)
177
+ os.close(to_parent_fd)
178
+
179
+ def create_temp_file(parent_fd, basename, mode):
180
+ prefix = "." + basename + "."
181
+ for _ in range(128):
182
+ candidate = prefix + secrets.token_hex(6) + ".tmp"
183
+ try:
184
+ fd = os.open(candidate, WRITE_FLAGS, mode, dir_fd=parent_fd)
185
+ return candidate, fd
186
+ except FileExistsError:
187
+ continue
188
+ raise RuntimeError("failed to allocate pinned temp file")
189
+
190
+ def write_path(root_fd, payload):
191
+ parent_fd = walk_dir(root_fd, split_relative(payload.get("relativeParentPath", "")), bool(payload.get("mkdir", True)))
192
+ temp_fd = None
193
+ temp_name = None
194
+ basename = payload["basename"]
195
+ mode = int(payload.get("mode", 0o600))
196
+ overwrite = bool(payload.get("overwrite", True))
197
+ max_bytes = int(payload.get("maxBytes", -1))
198
+ data = base64.b64decode(payload.get("base64", ""))
199
+ try:
200
+ if max_bytes >= 0 and len(data) > max_bytes:
201
+ raise RuntimeError("fs-safe-too-large:%d:%d" % (max_bytes, len(data)))
202
+ if not overwrite:
203
+ try:
204
+ os.lstat(basename, dir_fd=parent_fd)
205
+ raise FileExistsError(errno.EEXIST, "destination exists", basename)
206
+ except FileNotFoundError:
207
+ pass
208
+ temp_name, temp_fd = create_temp_file(parent_fd, basename, mode)
209
+ view = memoryview(data)
210
+ while view:
211
+ written = os.write(temp_fd, view)
212
+ if written <= 0:
213
+ raise OSError(errno.EIO, "short write")
214
+ view = view[written:]
215
+ os.fsync(temp_fd)
216
+ os.close(temp_fd)
217
+ temp_fd = None
218
+ os.replace(temp_name, basename, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
219
+ temp_name = None
220
+ os.fsync(parent_fd)
221
+ result_stat = os.stat(basename, dir_fd=parent_fd, follow_symlinks=False)
222
+ return {"dev": result_stat.st_dev, "ino": result_stat.st_ino}
223
+ finally:
224
+ if temp_fd is not None:
225
+ os.close(temp_fd)
226
+ if temp_name is not None:
227
+ try:
228
+ os.unlink(temp_name, dir_fd=parent_fd)
229
+ except FileNotFoundError:
230
+ pass
231
+ os.close(parent_fd)
232
+
233
+ def copy_path(root_fd, payload):
234
+ source_fd = os.open(payload["sourcePath"], READ_FLAGS)
235
+ parent_fd = None
236
+ temp_fd = None
237
+ temp_name = None
238
+ try:
239
+ source_stat = os.fstat(source_fd)
240
+ if not stat.S_ISREG(source_stat.st_mode):
241
+ raise RuntimeError("fs-safe-not-file")
242
+ if source_stat.st_dev != int(payload["sourceDev"]) or source_stat.st_ino != int(payload["sourceIno"]):
243
+ raise RuntimeError("fs-safe-source-mismatch")
244
+ basename = payload["basename"]
245
+ mode = int(payload.get("mode", 0o600))
246
+ overwrite = bool(payload.get("overwrite", True))
247
+ max_bytes = int(payload.get("maxBytes", -1))
248
+ if max_bytes >= 0 and source_stat.st_size > max_bytes:
249
+ raise RuntimeError("fs-safe-too-large:%d:%d" % (max_bytes, source_stat.st_size))
250
+ parent_fd = walk_dir(root_fd, split_relative(payload.get("relativeParentPath", "")), bool(payload.get("mkdir", True)))
251
+ if not overwrite:
252
+ try:
253
+ os.lstat(basename, dir_fd=parent_fd)
254
+ raise FileExistsError(errno.EEXIST, "destination exists", basename)
255
+ except FileNotFoundError:
256
+ pass
257
+ temp_name, temp_fd = create_temp_file(parent_fd, basename, mode)
258
+ written_bytes = 0
259
+ while True:
260
+ chunk = os.read(source_fd, 65536)
261
+ if not chunk:
262
+ break
263
+ written_bytes += len(chunk)
264
+ if max_bytes >= 0 and written_bytes > max_bytes:
265
+ raise RuntimeError("fs-safe-too-large:%d:%d" % (max_bytes, written_bytes))
266
+ view = memoryview(chunk)
267
+ while view:
268
+ written = os.write(temp_fd, view)
269
+ if written <= 0:
270
+ raise OSError(errno.EIO, "short write")
271
+ view = view[written:]
272
+ os.fsync(temp_fd)
273
+ os.close(temp_fd)
274
+ temp_fd = None
275
+ os.replace(temp_name, basename, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
276
+ temp_name = None
277
+ os.fsync(parent_fd)
278
+ result_stat = os.stat(basename, dir_fd=parent_fd, follow_symlinks=False)
279
+ return {"dev": result_stat.st_dev, "ino": result_stat.st_ino}
280
+ finally:
281
+ os.close(source_fd)
282
+ if temp_fd is not None:
283
+ os.close(temp_fd)
284
+ if temp_name is not None and parent_fd is not None:
285
+ try:
286
+ os.unlink(temp_name, dir_fd=parent_fd)
287
+ except FileNotFoundError:
288
+ pass
289
+ if parent_fd is not None:
290
+ os.close(parent_fd)
291
+
292
+ def run_operation(operation, root_path, payload):
293
+ root_fd = open_dir(root_path)
294
+ try:
295
+ if operation == "stat":
296
+ return stat_path(root_fd, payload)
297
+ if operation == "readdir":
298
+ return readdir_path(root_fd, payload)
299
+ if operation == "mkdirp":
300
+ return mkdirp_path(root_fd, payload)
301
+ if operation == "remove":
302
+ return remove_path(root_fd, payload)
303
+ if operation == "rename":
304
+ return rename_path(root_fd, payload)
305
+ if operation == "write":
306
+ return write_path(root_fd, payload)
307
+ if operation == "copy":
308
+ return copy_path(root_fd, payload)
309
+ raise RuntimeError("unknown operation: " + operation)
310
+ finally:
311
+ os.close(root_fd)
312
+
313
+ for line in sys.stdin:
314
+ try:
315
+ request = json.loads(line)
316
+ result = run_operation(request["operation"], request["rootPath"], request.get("payload") or {})
317
+ response = {"id": request["id"], "ok": True, "result": result}
318
+ except Exception as exc:
319
+ response = {
320
+ "id": request.get("id") if isinstance(locals().get("request"), dict) else None,
321
+ "ok": False,
322
+ "code": exc.__class__.__name__,
323
+ "errno": getattr(exc, "errno", None),
324
+ "message": str(exc),
325
+ }
326
+ print(json.dumps(response, separators=(",", ":")), flush=True)
327
+ `;
328
+ let nextRequestId = 1;
329
+ let worker = null;
330
+ export function __resetPinnedPythonWorkerForTest() {
331
+ const currentWorker = worker;
332
+ worker = null;
333
+ if (!currentWorker) {
334
+ return;
335
+ }
336
+ currentWorker.pending.clear();
337
+ currentWorker.child.kill("SIGTERM");
338
+ }
339
+ const PYTHON_CANDIDATE_DEFAULTS = [
340
+ "/usr/bin/python3",
341
+ "/opt/homebrew/bin/python3",
342
+ "/usr/local/bin/python3",
343
+ ];
344
+ function canExecute(binPath) {
345
+ try {
346
+ fsSync.accessSync(binPath, fsSync.constants.X_OK);
347
+ return true;
348
+ }
349
+ catch {
350
+ return false;
351
+ }
352
+ }
353
+ function resolvePython() {
354
+ const configured = getFsSafePythonConfig().pythonPath;
355
+ if (configured) {
356
+ return configured;
357
+ }
358
+ for (const candidate of PYTHON_CANDIDATE_DEFAULTS) {
359
+ if (canExecute(candidate)) {
360
+ return candidate;
361
+ }
362
+ }
363
+ return "python3";
364
+ }
365
+ function assertPinnedHelperSupported() {
366
+ if (process.platform === "win32") {
367
+ throw new FsSafeError("unsupported-platform", "fd-relative pinned filesystem operations are not available on Windows");
368
+ }
369
+ if (getFsSafePythonConfig().mode === "off") {
370
+ throw new FsSafeError("helper-unavailable", "Python helper is disabled");
371
+ }
372
+ }
373
+ function isSpawnUnavailable(error) {
374
+ if (!(error instanceof Error)) {
375
+ return false;
376
+ }
377
+ const maybeErrno = error;
378
+ return (typeof maybeErrno.syscall === "string" &&
379
+ maybeErrno.syscall.startsWith("spawn") &&
380
+ ["EACCES", "ENOENT", "ENOEXEC"].includes(maybeErrno.code ?? ""));
381
+ }
382
+ function mapWorkerError(response) {
383
+ const code = typeof response.code === "string" ? response.code : "";
384
+ const errno = typeof response.errno === "number" ? response.errno : undefined;
385
+ const message = typeof response.message === "string" && response.message
386
+ ? response.message
387
+ : "pinned helper failed";
388
+ const tooLarge = message.match(/fs-safe-too-large:(\d+):(\d+)/);
389
+ if (tooLarge) {
390
+ const [, limit, got] = tooLarge;
391
+ return new FsSafeError("too-large", `file exceeds limit of ${limit} bytes (got at least ${got})`);
392
+ }
393
+ if (message.includes("fs-safe-not-file")) {
394
+ return new FsSafeError("not-file", "not a file");
395
+ }
396
+ if (message.includes("fs-safe-source-mismatch")) {
397
+ return new FsSafeError("path-mismatch", "source path changed during copy");
398
+ }
399
+ if (code === "FileNotFoundError" || errno === 2) {
400
+ return new FsSafeError("not-found", "file not found");
401
+ }
402
+ if (code === "FileExistsError" || errno === 17) {
403
+ return new FsSafeError("already-exists", message);
404
+ }
405
+ if (errno === 39) {
406
+ return new FsSafeError("not-empty", "directory is not empty");
407
+ }
408
+ if (errno === 1 || errno === 13 || errno === 21) {
409
+ return new FsSafeError("not-removable", "path is not removable under root");
410
+ }
411
+ if (code === "NotADirectoryError" || code === "OSError" || errno === 20 || errno === 40) {
412
+ return new FsSafeError("path-alias", message);
413
+ }
414
+ return new FsSafeError("helper-failed", message);
415
+ }
416
+ function rejectPending(error) {
417
+ if (!worker) {
418
+ return;
419
+ }
420
+ setWorkerRef(worker, false);
421
+ for (const pending of worker.pending.values()) {
422
+ pending.reject(error);
423
+ }
424
+ worker.pending.clear();
425
+ worker = null;
426
+ }
427
+ function handleWorkerLine(line) {
428
+ if (!worker || !line.trim()) {
429
+ return;
430
+ }
431
+ let decoded;
432
+ try {
433
+ decoded = JSON.parse(line);
434
+ }
435
+ catch {
436
+ rejectPending(new FsSafeError("helper-failed", `pinned helper returned invalid JSON: ${line}`));
437
+ return;
438
+ }
439
+ if (typeof decoded !== "object" || decoded === null || !("id" in decoded)) {
440
+ rejectPending(new FsSafeError("helper-failed", "pinned helper returned invalid response"));
441
+ return;
442
+ }
443
+ const response = decoded;
444
+ const id = typeof response.id === "number" ? response.id : undefined;
445
+ if (id === undefined) {
446
+ return;
447
+ }
448
+ const pending = worker.pending.get(id);
449
+ if (!pending) {
450
+ return;
451
+ }
452
+ worker.pending.delete(id);
453
+ if (worker.pending.size === 0) {
454
+ setWorkerRef(worker, false);
455
+ }
456
+ if (response.ok === true) {
457
+ pending.resolve(response.result);
458
+ return;
459
+ }
460
+ pending.reject(mapWorkerError(decoded));
461
+ }
462
+ function getWorker() {
463
+ assertPinnedHelperSupported();
464
+ if (worker) {
465
+ return worker;
466
+ }
467
+ const child = spawn(resolvePython(), ["-u", "-c", PINNED_PYTHON_WORKER_SOURCE], {
468
+ stdio: ["pipe", "pipe", "pipe"],
469
+ });
470
+ worker = { child, pending: new Map(), stderr: "", stdoutBuffer: "" };
471
+ child.stdout.setEncoding("utf8");
472
+ child.stderr.setEncoding("utf8");
473
+ child.stdout.on("data", (chunk) => {
474
+ const current = worker;
475
+ if (!current) {
476
+ return;
477
+ }
478
+ current.stdoutBuffer += chunk;
479
+ for (;;) {
480
+ const newline = current.stdoutBuffer.indexOf("\n");
481
+ if (newline < 0) {
482
+ break;
483
+ }
484
+ const line = current.stdoutBuffer.slice(0, newline);
485
+ current.stdoutBuffer = current.stdoutBuffer.slice(newline + 1);
486
+ handleWorkerLine(line);
487
+ }
488
+ });
489
+ child.stderr.on("data", (chunk) => {
490
+ if (worker) {
491
+ worker.stderr = `${worker.stderr}${chunk}`.slice(-4096);
492
+ }
493
+ });
494
+ child.once("error", (error) => {
495
+ const mapped = isSpawnUnavailable(error)
496
+ ? new FsSafeError("helper-unavailable", "Python helper is unavailable", { cause: error })
497
+ : error instanceof Error
498
+ ? error
499
+ : new Error(String(error));
500
+ rejectPending(mapped);
501
+ });
502
+ child.once("close", (code, signal) => {
503
+ const stderr = worker?.stderr.trim();
504
+ rejectPending(new FsSafeError("helper-failed", stderr || `pinned helper exited with code ${code ?? "null"} (${signal ?? "?"})`));
505
+ });
506
+ process.once("exit", () => {
507
+ child.kill("SIGTERM");
508
+ });
509
+ setWorkerRef(worker, false);
510
+ return worker;
511
+ }
512
+ function setRefable(value, ref) {
513
+ if (!value) {
514
+ return;
515
+ }
516
+ const method = ref ? "ref" : "unref";
517
+ const refable = value;
518
+ refable[method]?.();
519
+ }
520
+ function setWorkerRef(currentWorker, ref) {
521
+ setRefable(currentWorker.child, ref);
522
+ setRefable(currentWorker.child.stdin, ref);
523
+ setRefable(currentWorker.child.stdout, ref);
524
+ setRefable(currentWorker.child.stderr, ref);
525
+ }
526
+ export async function runPinnedPythonOperation(params) {
527
+ const requestId = nextRequestId++;
528
+ const currentWorker = getWorker();
529
+ if (typeof currentWorker.child.stdin?.write !== "function") {
530
+ throw new FsSafeError("helper-unavailable", "Python helper stdin is unavailable");
531
+ }
532
+ setWorkerRef(currentWorker, true);
533
+ return await new Promise((resolve, reject) => {
534
+ currentWorker.pending.set(requestId, {
535
+ reject,
536
+ resolve: (value) => resolve(value),
537
+ });
538
+ const request = JSON.stringify({
539
+ id: requestId,
540
+ operation: params.operation,
541
+ rootPath: params.rootPath,
542
+ payload: params.payload,
543
+ });
544
+ currentWorker.child.stdin.write(`${request}\n`, (error) => {
545
+ if (error) {
546
+ currentWorker.pending.delete(requestId);
547
+ if (currentWorker.pending.size === 0) {
548
+ setWorkerRef(currentWorker, false);
549
+ }
550
+ reject(error);
551
+ }
552
+ });
553
+ });
554
+ }
555
+ export function assertPinnedPythonOperationAvailable() {
556
+ const currentWorker = getWorker();
557
+ if (typeof currentWorker.child.stdin?.write !== "function") {
558
+ throw new FsSafeError("helper-unavailable", "Python helper stdin is unavailable");
559
+ }
560
+ }
561
+ export function validatePinnedOperationPayload(payload) {
562
+ if (typeof payload.relativePath === "string") {
563
+ validatePinnedRelativePath(payload.relativePath);
564
+ }
565
+ if (typeof payload.relativeParentPath === "string") {
566
+ validatePinnedRelativePath(payload.relativeParentPath);
567
+ }
568
+ if (typeof payload.from === "string") {
569
+ validatePinnedRelativePath(payload.from);
570
+ }
571
+ if (typeof payload.to === "string") {
572
+ validatePinnedRelativePath(payload.to);
573
+ }
574
+ }
575
+ export function isPinnedHelperUnavailable(error) {
576
+ return error instanceof Error &&
577
+ "code" in error &&
578
+ error.code === "helper-unavailable";
579
+ }
580
+ function validatePinnedRelativePath(relativePath) {
581
+ if (relativePath.length === 0 || relativePath === ".") {
582
+ return;
583
+ }
584
+ if (relativePath.includes("\0")) {
585
+ throw new FsSafeError("invalid-path", "relative path contains a NUL byte");
586
+ }
587
+ if (relativePath.startsWith("/") ||
588
+ relativePath.startsWith("//") ||
589
+ relativePath === ".." ||
590
+ relativePath.startsWith("../") ||
591
+ relativePath.startsWith("..\\")) {
592
+ throw new FsSafeError("invalid-path", "relative path must not escape root");
593
+ }
594
+ for (const segment of relativePath.split("/")) {
595
+ if (segment === "..") {
596
+ throw new FsSafeError("invalid-path", "relative path must not contain '..'");
597
+ }
598
+ }
599
+ }
@@ -0,0 +1,33 @@
1
+ import { type Readable } from "node:stream";
2
+ import type { FileIdentityStat } from "./file-identity.js";
3
+ type PinnedWriteInput = {
4
+ kind: "buffer";
5
+ data: string | Buffer;
6
+ encoding?: BufferEncoding;
7
+ } | {
8
+ kind: "stream";
9
+ stream: Readable;
10
+ };
11
+ export declare function runPinnedWriteHelper(params: {
12
+ rootPath: string;
13
+ relativeParentPath: string;
14
+ basename: string;
15
+ mkdir: boolean;
16
+ mode: number;
17
+ overwrite?: boolean;
18
+ maxBytes?: number;
19
+ input: PinnedWriteInput;
20
+ }): Promise<FileIdentityStat>;
21
+ export declare function runPinnedCopyHelper(params: {
22
+ rootPath: string;
23
+ relativeParentPath: string;
24
+ basename: string;
25
+ mkdir: boolean;
26
+ mode: number;
27
+ overwrite?: boolean;
28
+ maxBytes?: number;
29
+ sourcePath: string;
30
+ sourceIdentity: FileIdentityStat;
31
+ }): Promise<FileIdentityStat>;
32
+ export {};
33
+ //# sourceMappingURL=pinned-write.d.ts.map
@@ -0,0 +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"}