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