@openclaw/fs-safe 0.2.4 → 0.2.6

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 (104) hide show
  1. package/CHANGELOG.md +21 -1
  2. package/README.md +8 -9
  3. package/dist/archive-staging.d.ts.map +1 -1
  4. package/dist/archive-staging.js +12 -1
  5. package/dist/file-store-boundary.js +2 -2
  6. package/dist/file-store.d.ts.map +1 -1
  7. package/dist/file-store.js +6 -5
  8. package/dist/guarded-mkdir.d.ts.map +1 -1
  9. package/dist/guarded-mkdir.js +6 -6
  10. package/dist/json-document-store.d.ts +1 -1
  11. package/dist/json-document-store.d.ts.map +1 -1
  12. package/dist/json-document-store.js +5 -3
  13. package/dist/json-durable-queue.d.ts.map +1 -1
  14. package/dist/json-durable-queue.js +157 -4
  15. package/dist/json-stringify.d.ts +2 -0
  16. package/dist/json-stringify.d.ts.map +1 -0
  17. package/dist/json-stringify.js +7 -0
  18. package/dist/json.d.ts.map +1 -1
  19. package/dist/json.js +3 -2
  20. package/dist/local-roots.d.ts.map +1 -1
  21. package/dist/local-roots.js +4 -1
  22. package/dist/path.d.ts +1 -0
  23. package/dist/path.d.ts.map +1 -1
  24. package/dist/path.js +4 -1
  25. package/dist/permissions.d.ts.map +1 -1
  26. package/dist/permissions.js +7 -10
  27. package/dist/pinned-python.d.ts.map +1 -1
  28. package/dist/pinned-python.js +158 -69
  29. package/dist/pinned-write.d.ts +3 -1
  30. package/dist/pinned-write.d.ts.map +1 -1
  31. package/dist/pinned-write.js +54 -45
  32. package/dist/root-impl.d.ts.map +1 -1
  33. package/dist/root-impl.js +6 -2
  34. package/dist/root-path.js +2 -2
  35. package/dist/root-paths.js +25 -3
  36. package/dist/secret-file.d.ts.map +1 -1
  37. package/dist/secret-file.js +76 -50
  38. package/dist/secure-file.d.ts.map +1 -1
  39. package/dist/secure-file.js +3 -1
  40. package/dist/temp-target.js +1 -1
  41. package/dist/timing.d.ts.map +1 -1
  42. package/dist/timing.js +8 -1
  43. package/docs/index.md +1 -1
  44. package/docs/json-store.md +3 -1
  45. package/docs/python-helper.md +3 -3
  46. package/docs/root.md +5 -6
  47. package/package.json +4 -4
  48. package/dist/archive-utils.d.ts +0 -3
  49. package/dist/archive-utils.d.ts.map +0 -1
  50. package/dist/archive-utils.js +0 -26
  51. package/dist/boundary-file-read.d.ts +0 -44
  52. package/dist/boundary-file-read.d.ts.map +0 -1
  53. package/dist/boundary-file-read.js +0 -129
  54. package/dist/boundary-path.d.ts +0 -39
  55. package/dist/boundary-path.d.ts.map +0 -1
  56. package/dist/boundary-path.js +0 -598
  57. package/dist/fs-pinned-path-helper.d.ts +0 -7
  58. package/dist/fs-pinned-path-helper.d.ts.map +0 -1
  59. package/dist/fs-pinned-path-helper.js +0 -182
  60. package/dist/fs-pinned-write-helper.d.ts +0 -21
  61. package/dist/fs-pinned-write-helper.d.ts.map +0 -1
  62. package/dist/fs-pinned-write-helper.js +0 -263
  63. package/dist/hardlink-guards.d.ts +0 -7
  64. package/dist/hardlink-guards.d.ts.map +0 -1
  65. package/dist/hardlink-guards.js +0 -30
  66. package/dist/install-safe-path.d.ts +0 -20
  67. package/dist/install-safe-path.d.ts.map +0 -1
  68. package/dist/install-safe-path.js +0 -94
  69. package/dist/json-file.d.ts +0 -3
  70. package/dist/json-file.d.ts.map +0 -1
  71. package/dist/json-file.js +0 -123
  72. package/dist/json-files.d.ts +0 -20
  73. package/dist/json-files.d.ts.map +0 -1
  74. package/dist/json-files.js +0 -153
  75. package/dist/path-alias-guards.d.ts +0 -19
  76. package/dist/path-alias-guards.d.ts.map +0 -1
  77. package/dist/path-alias-guards.js +0 -21
  78. package/dist/path-guards.d.ts +0 -7
  79. package/dist/path-guards.d.ts.map +0 -1
  80. package/dist/path-guards.js +0 -49
  81. package/dist/path-safety.d.ts +0 -12
  82. package/dist/path-safety.d.ts.map +0 -1
  83. package/dist/path-safety.js +0 -50
  84. package/dist/path-scope.d.ts +0 -2
  85. package/dist/path-scope.d.ts.map +0 -1
  86. package/dist/path-scope.js +0 -1
  87. package/dist/private-file-store.d.ts +0 -60
  88. package/dist/private-file-store.d.ts.map +0 -1
  89. package/dist/private-file-store.js +0 -233
  90. package/dist/safe-open-sync.d.ts +0 -24
  91. package/dist/safe-open-sync.d.ts.map +0 -1
  92. package/dist/safe-open-sync.js +0 -71
  93. package/dist/safe-root.d.ts +0 -123
  94. package/dist/safe-root.d.ts.map +0 -1
  95. package/dist/safe-root.js +0 -1060
  96. package/dist/secure-temp-workspace.d.ts +0 -25
  97. package/dist/secure-temp-workspace.d.ts.map +0 -1
  98. package/dist/secure-temp-workspace.js +0 -136
  99. package/dist/sibling-temp-file.d.ts +0 -16
  100. package/dist/sibling-temp-file.d.ts.map +0 -1
  101. package/dist/sibling-temp-file.js +0 -73
  102. package/dist/sibling-temp-write.d.ts +0 -8
  103. package/dist/sibling-temp-write.d.ts.map +0 -1
  104. package/dist/sibling-temp-write.js +0 -40
@@ -18,7 +18,6 @@ const TRUSTED_BASE = new Set([
18
18
  "autoridade nt\\system",
19
19
  ]);
20
20
  const WORLD_SUFFIXES = ["\\users", "\\authenticated users"];
21
- const TRUSTED_SUFFIXES = ["\\administrators", "\\system", "\\système"];
22
21
  const SID_RE = /^\*?s-\d+-\d+(-\d+)+$/i;
23
22
  const TRUSTED_SIDS = new Set([
24
23
  "s-1-5-18",
@@ -32,6 +31,10 @@ const STATUS_PREFIXES = [
32
31
  "failed processing",
33
32
  "no mapping between account names",
34
33
  ];
34
+ function stripDiacritics(value) {
35
+ return value.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
36
+ }
37
+ const TRUSTED_BASE_ASCII = new Set([...TRUSTED_BASE].map(stripDiacritics));
35
38
  const normalize = (value) => normalizeLowercaseStringOrEmpty(value);
36
39
  const defaultWindowsUserInfo = () => os.userInfo();
37
40
  function defaultPermissionExec(command, args) {
@@ -241,21 +244,15 @@ function classifyPrincipal(principal, trustedPrincipals) {
241
244
  return "trusted";
242
245
  return "group";
243
246
  }
244
- if (trustedPrincipals.has(normalized) ||
245
- TRUSTED_SUFFIXES.some((suffix) => normalized.endsWith(suffix))) {
247
+ if (trustedPrincipals.has(normalized) || TRUSTED_BASE.has(normalized)) {
246
248
  return "trusted";
247
249
  }
248
250
  if (WORLD_PRINCIPALS.has(normalized) ||
249
251
  WORLD_SUFFIXES.some((suffix) => normalized.endsWith(suffix))) {
250
252
  return "world";
251
253
  }
252
- const stripped = normalized.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
253
- if (stripped !== normalized &&
254
- (TRUSTED_BASE.has(stripped) ||
255
- TRUSTED_SUFFIXES.some((suffix) => {
256
- const strippedSuffix = suffix.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
257
- return stripped.endsWith(strippedSuffix);
258
- }))) {
254
+ const stripped = stripDiacritics(normalized);
255
+ if (stripped !== normalized && TRUSTED_BASE_ASCII.has(stripped)) {
259
256
  return "trusted";
260
257
  }
261
258
  return "group";
@@ -1 +1 @@
1
- {"version":3,"file":"pinned-python.d.ts","sourceRoot":"","sources":["../src/pinned-python.ts"],"names":[],"mappings":"AAyUA,KAAK,qBAAqB,GACtB,MAAM,GACN,MAAM,GACN,SAAS,GACT,QAAQ,GACR,QAAQ,GACR,QAAQ,GACR,OAAO,CAAC;AAiBZ,wBAAgB,gCAAgC,IAAI,IAAI,CAQvD;AAiND,wBAAsB,wBAAwB,CAAC,CAAC,EAAE,MAAM,EAAE;IACxD,SAAS,EAAE,qBAAqB,CAAC;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC,GAAG,OAAO,CAAC,CAAC,CAAC,CA4Bb;AAED,wBAAgB,oCAAoC,IAAI,IAAI,CAK3D;AAED,wBAAgB,8BAA8B,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAarF;AAED,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAIjE"}
1
+ {"version":3,"file":"pinned-python.d.ts","sourceRoot":"","sources":["../src/pinned-python.ts"],"names":[],"mappings":"AA2ZA,KAAK,qBAAqB,GACtB,MAAM,GACN,MAAM,GACN,SAAS,GACT,QAAQ,GACR,QAAQ,GACR,QAAQ,GACR,OAAO,CAAC;AAiBZ,wBAAgB,gCAAgC,IAAI,IAAI,CAQvD;AAiOD,wBAAsB,wBAAwB,CAAC,CAAC,EAAE,MAAM,EAAE;IACxD,SAAS,EAAE,qBAAqB,CAAC;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC,GAAG,OAAO,CAAC,CAAC,CAAC,CA4Bb;AAED,wBAAgB,oCAAoC,IAAI,IAAI,CAK3D;AAED,wBAAgB,8BAA8B,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAarF;AAED,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAEjE"}
@@ -3,26 +3,20 @@ import fsSync from "node:fs";
3
3
  import { FsSafeError } from "./errors.js";
4
4
  import { getFsSafePythonConfig } from "./pinned-python-config.js";
5
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
-
6
+ import base64, errno, json, os, secrets, stat, sys
14
7
  DIR_FLAGS = os.O_RDONLY
15
8
  if hasattr(os, "O_DIRECTORY"):
16
9
  DIR_FLAGS |= os.O_DIRECTORY
17
10
  if hasattr(os, "O_NOFOLLOW"):
18
11
  DIR_FLAGS |= os.O_NOFOLLOW
19
12
  READ_FLAGS = os.O_RDONLY
13
+ if hasattr(os, "O_NONBLOCK"):
14
+ READ_FLAGS |= os.O_NONBLOCK
20
15
  if hasattr(os, "O_NOFOLLOW"):
21
16
  READ_FLAGS |= os.O_NOFOLLOW
22
17
  WRITE_FLAGS = os.O_WRONLY | os.O_CREAT | os.O_EXCL
23
18
  if hasattr(os, "O_NOFOLLOW"):
24
19
  WRITE_FLAGS |= os.O_NOFOLLOW
25
-
26
20
  def split_relative(value):
27
21
  if value in ("", "."):
28
22
  return []
@@ -35,10 +29,8 @@ def split_relative(value):
35
29
  if part == "..":
36
30
  raise OSError(errno.EPERM, "path traversal is not allowed")
37
31
  return parts
38
-
39
32
  def open_dir(path_value, dir_fd=None):
40
33
  return os.open(path_value, DIR_FLAGS, dir_fd=dir_fd)
41
-
42
34
  def walk_dir(root_fd, segments, mkdir_enabled=False):
43
35
  current_fd = os.dup(root_fd)
44
36
  try:
@@ -56,14 +48,12 @@ def walk_dir(root_fd, segments, mkdir_enabled=False):
56
48
  except Exception:
57
49
  os.close(current_fd)
58
50
  raise
59
-
60
51
  def parent_and_basename(root_fd, relative):
61
52
  segments = split_relative(relative)
62
53
  if not segments:
63
54
  raise OSError(errno.EPERM, "operation requires a non-root path")
64
55
  parent_fd = walk_dir(root_fd, segments[:-1])
65
56
  return parent_fd, segments[-1]
66
-
67
57
  def encode_stat(st):
68
58
  mode = st.st_mode
69
59
  return {
@@ -79,14 +69,108 @@ def encode_stat(st):
79
69
  "size": st.st_size,
80
70
  "uid": st.st_uid,
81
71
  }
82
-
83
72
  def reject_unsafe_endpoint(st):
84
73
  mode = st.st_mode
85
74
  if stat.S_ISLNK(mode):
86
75
  raise OSError(errno.ELOOP, "symlink endpoint is not allowed")
87
76
  if stat.S_ISREG(mode) and st.st_nlink > 1:
88
77
  raise OSError(errno.EPERM, "hardlinked file endpoint is not allowed")
89
-
78
+ def copy_bytes(source_fd, dest_fd):
79
+ while True:
80
+ chunk = os.read(source_fd, 65536)
81
+ if not chunk:
82
+ break
83
+ view = memoryview(chunk)
84
+ while view:
85
+ written = os.write(dest_fd, view)
86
+ if written <= 0:
87
+ raise OSError(errno.EIO, "short write")
88
+ view = view[written:]
89
+ def write_all(fd, data):
90
+ view = memoryview(data)
91
+ while view:
92
+ written = os.write(fd, view)
93
+ if written <= 0:
94
+ raise OSError(errno.EIO, "short write")
95
+ view = view[written:]
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")
90
174
  def stat_path(root_fd, payload):
91
175
  relative = payload.get("relativePath", "")
92
176
  segments = split_relative(relative)
@@ -100,7 +184,6 @@ def stat_path(root_fd, payload):
100
184
  return encode_stat(st)
101
185
  finally:
102
186
  os.close(parent_fd)
103
-
104
187
  def readdir_path(root_fd, payload):
105
188
  dir_fd = walk_dir(root_fd, split_relative(payload.get("relativePath", "")))
106
189
  try:
@@ -116,12 +199,9 @@ def readdir_path(root_fd, payload):
116
199
  return entries
117
200
  finally:
118
201
  os.close(dir_fd)
119
-
120
202
  def mkdirp_path(root_fd, payload):
121
203
  dir_fd = walk_dir(root_fd, split_relative(payload.get("relativePath", "")), mkdir_enabled=True)
122
- os.close(dir_fd)
123
- return None
124
-
204
+ os.close(dir_fd); return None
125
205
  def remove_tree(parent_fd, basename):
126
206
  st = os.lstat(basename, dir_fd=parent_fd)
127
207
  if stat.S_ISDIR(st.st_mode) and not stat.S_ISLNK(st.st_mode):
@@ -134,7 +214,6 @@ def remove_tree(parent_fd, basename):
134
214
  os.rmdir(basename, dir_fd=parent_fd)
135
215
  else:
136
216
  os.unlink(basename, dir_fd=parent_fd)
137
-
138
217
  def remove_path(root_fd, payload):
139
218
  parent_fd, basename = parent_and_basename(root_fd, payload.get("relativePath", ""))
140
219
  try:
@@ -161,7 +240,18 @@ def rename_path(root_fd, payload):
161
240
  try:
162
241
  from_stat = os.lstat(from_base, dir_fd=from_parent_fd)
163
242
  reject_unsafe_endpoint(from_stat)
164
- if not payload.get("overwrite", True):
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:
165
255
  try:
166
256
  os.lstat(to_base, dir_fd=to_parent_fd)
167
257
  raise FileExistsError(errno.EEXIST, "destination exists", to_base)
@@ -206,19 +296,15 @@ def write_path(root_fd, payload):
206
296
  except FileNotFoundError:
207
297
  pass
208
298
  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:]
299
+ os.fchmod(temp_fd, mode)
300
+ write_all(temp_fd, data)
215
301
  os.fsync(temp_fd)
302
+ temp_stat = os.fstat(temp_fd)
216
303
  os.close(temp_fd)
217
304
  temp_fd = None
218
- os.replace(temp_name, basename, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
305
+ result_stat = commit_temp_file(parent_fd, temp_name, basename, overwrite, mode, temp_stat)
219
306
  temp_name = None
220
307
  os.fsync(parent_fd)
221
- result_stat = os.stat(basename, dir_fd=parent_fd, follow_symlinks=False)
222
308
  return {"dev": result_stat.st_dev, "ino": result_stat.st_ino}
223
309
  finally:
224
310
  if temp_fd is not None:
@@ -248,13 +334,8 @@ def copy_path(root_fd, payload):
248
334
  if max_bytes >= 0 and source_stat.st_size > max_bytes:
249
335
  raise RuntimeError("fs-safe-too-large:%d:%d" % (max_bytes, source_stat.st_size))
250
336
  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
337
  temp_name, temp_fd = create_temp_file(parent_fd, basename, mode)
338
+ os.fchmod(temp_fd, mode)
258
339
  written_bytes = 0
259
340
  while True:
260
341
  chunk = os.read(source_fd, 65536)
@@ -270,12 +351,12 @@ def copy_path(root_fd, payload):
270
351
  raise OSError(errno.EIO, "short write")
271
352
  view = view[written:]
272
353
  os.fsync(temp_fd)
354
+ temp_stat = os.fstat(temp_fd)
273
355
  os.close(temp_fd)
274
356
  temp_fd = None
275
- os.replace(temp_name, basename, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
357
+ result_stat = commit_temp_file(parent_fd, temp_name, basename, overwrite, mode, temp_stat)
276
358
  temp_name = None
277
359
  os.fsync(parent_fd)
278
- result_stat = os.stat(basename, dir_fd=parent_fd, follow_symlinks=False)
279
360
  return {"dev": result_stat.st_dev, "ino": result_stat.st_ino}
280
361
  finally:
281
362
  os.close(source_fd)
@@ -292,6 +373,7 @@ def copy_path(root_fd, payload):
292
373
  def run_operation(operation, root_path, payload):
293
374
  root_fd = open_dir(root_path)
294
375
  try:
376
+ assert_expected_root(root_fd, payload)
295
377
  if operation == "stat":
296
378
  return stat_path(root_fd, payload)
297
379
  if operation == "readdir":
@@ -396,6 +478,15 @@ function mapWorkerError(response) {
396
478
  if (message.includes("fs-safe-source-mismatch")) {
397
479
  return new FsSafeError("path-mismatch", "source path changed during copy");
398
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
+ }
399
490
  if (code === "FileNotFoundError" || errno === 2) {
400
491
  return new FsSafeError("not-found", "file not found");
401
492
  }
@@ -413,19 +504,19 @@ function mapWorkerError(response) {
413
504
  }
414
505
  return new FsSafeError("helper-failed", message);
415
506
  }
416
- function rejectPending(error) {
417
- if (!worker) {
507
+ function rejectPending(error, targetWorker = worker) {
508
+ if (!targetWorker || worker !== targetWorker) {
418
509
  return;
419
510
  }
420
- setWorkerRef(worker, false);
421
- for (const pending of worker.pending.values()) {
511
+ setWorkerRef(targetWorker, false);
512
+ for (const pending of targetWorker.pending.values()) {
422
513
  pending.reject(error);
423
514
  }
424
- worker.pending.clear();
515
+ targetWorker.pending.clear();
425
516
  worker = null;
426
517
  }
427
- function handleWorkerLine(line) {
428
- if (!worker || !line.trim()) {
518
+ function handleWorkerLine(currentWorker, line) {
519
+ if (worker !== currentWorker || !line.trim()) {
429
520
  return;
430
521
  }
431
522
  let decoded;
@@ -433,11 +524,11 @@ function handleWorkerLine(line) {
433
524
  decoded = JSON.parse(line);
434
525
  }
435
526
  catch {
436
- rejectPending(new FsSafeError("helper-failed", `pinned helper returned invalid JSON: ${line}`));
527
+ rejectPending(new FsSafeError("helper-failed", `pinned helper returned invalid JSON: ${line}`), currentWorker);
437
528
  return;
438
529
  }
439
530
  if (typeof decoded !== "object" || decoded === null || !("id" in decoded)) {
440
- rejectPending(new FsSafeError("helper-failed", "pinned helper returned invalid response"));
531
+ rejectPending(new FsSafeError("helper-failed", "pinned helper returned invalid response"), currentWorker);
441
532
  return;
442
533
  }
443
534
  const response = decoded;
@@ -445,13 +536,13 @@ function handleWorkerLine(line) {
445
536
  if (id === undefined) {
446
537
  return;
447
538
  }
448
- const pending = worker.pending.get(id);
539
+ const pending = currentWorker.pending.get(id);
449
540
  if (!pending) {
450
541
  return;
451
542
  }
452
- worker.pending.delete(id);
453
- if (worker.pending.size === 0) {
454
- setWorkerRef(worker, false);
543
+ currentWorker.pending.delete(id);
544
+ if (currentWorker.pending.size === 0) {
545
+ setWorkerRef(currentWorker, false);
455
546
  }
456
547
  if (response.ok === true) {
457
548
  pending.resolve(response.result);
@@ -467,28 +558,28 @@ function getWorker() {
467
558
  const child = spawn(resolvePython(), ["-u", "-c", PINNED_PYTHON_WORKER_SOURCE], {
468
559
  stdio: ["pipe", "pipe", "pipe"],
469
560
  });
470
- worker = { child, pending: new Map(), stderr: "", stdoutBuffer: "" };
561
+ const currentWorker = { child, pending: new Map(), stderr: "", stdoutBuffer: "" };
562
+ worker = currentWorker;
471
563
  child.stdout.setEncoding("utf8");
472
564
  child.stderr.setEncoding("utf8");
473
565
  child.stdout.on("data", (chunk) => {
474
- const current = worker;
475
- if (!current) {
566
+ if (worker !== currentWorker) {
476
567
  return;
477
568
  }
478
- current.stdoutBuffer += chunk;
569
+ currentWorker.stdoutBuffer += chunk;
479
570
  for (;;) {
480
- const newline = current.stdoutBuffer.indexOf("\n");
571
+ const newline = currentWorker.stdoutBuffer.indexOf("\n");
481
572
  if (newline < 0) {
482
573
  break;
483
574
  }
484
- const line = current.stdoutBuffer.slice(0, newline);
485
- current.stdoutBuffer = current.stdoutBuffer.slice(newline + 1);
486
- handleWorkerLine(line);
575
+ const line = currentWorker.stdoutBuffer.slice(0, newline);
576
+ currentWorker.stdoutBuffer = currentWorker.stdoutBuffer.slice(newline + 1);
577
+ handleWorkerLine(currentWorker, line);
487
578
  }
488
579
  });
489
580
  child.stderr.on("data", (chunk) => {
490
- if (worker) {
491
- worker.stderr = `${worker.stderr}${chunk}`.slice(-4096);
581
+ if (worker === currentWorker) {
582
+ currentWorker.stderr = `${currentWorker.stderr}${chunk}`.slice(-4096);
492
583
  }
493
584
  });
494
585
  child.once("error", (error) => {
@@ -497,17 +588,17 @@ function getWorker() {
497
588
  : error instanceof Error
498
589
  ? error
499
590
  : new Error(String(error));
500
- rejectPending(mapped);
591
+ rejectPending(mapped, currentWorker);
501
592
  });
502
593
  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 ?? "?"})`));
594
+ const stderr = currentWorker.stderr.trim();
595
+ rejectPending(new FsSafeError("helper-failed", stderr || `pinned helper exited with code ${code ?? "null"} (${signal ?? "?"})`), currentWorker);
505
596
  });
506
597
  process.once("exit", () => {
507
598
  child.kill("SIGTERM");
508
599
  });
509
- setWorkerRef(worker, false);
510
- return worker;
600
+ setWorkerRef(currentWorker, false);
601
+ return currentWorker;
511
602
  }
512
603
  function setRefable(value, ref) {
513
604
  if (!value) {
@@ -573,9 +664,7 @@ export function validatePinnedOperationPayload(payload) {
573
664
  }
574
665
  }
575
666
  export function isPinnedHelperUnavailable(error) {
576
- return error instanceof Error &&
577
- "code" in error &&
578
- error.code === "helper-unavailable";
667
+ return error instanceof Error && "code" in error && error.code === "helper-unavailable";
579
668
  }
580
669
  function validatePinnedRelativePath(relativePath) {
581
670
  if (relativePath.length === 0 || relativePath === ".") {
@@ -1,4 +1,4 @@
1
- import { type Readable } from "node:stream";
1
+ import type { Readable } from "node:stream";
2
2
  import type { FileIdentityStat } from "./file-identity.js";
3
3
  type PinnedWriteInput = {
4
4
  kind: "buffer";
@@ -17,6 +17,7 @@ export declare function runPinnedWriteHelper(params: {
17
17
  overwrite?: boolean;
18
18
  maxBytes?: number;
19
19
  input: PinnedWriteInput;
20
+ rootIdentity?: FileIdentityStat;
20
21
  }): Promise<FileIdentityStat>;
21
22
  export declare function runPinnedCopyHelper(params: {
22
23
  rootPath: string;
@@ -28,6 +29,7 @@ export declare function runPinnedCopyHelper(params: {
28
29
  maxBytes?: number;
29
30
  sourcePath: string;
30
31
  sourceIdentity: FileIdentityStat;
32
+ rootIdentity?: FileIdentityStat;
31
33
  }): Promise<FileIdentityStat>;
32
34
  export {};
33
35
  //# sourceMappingURL=pinned-write.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"pinned-write.d.ts","sourceRoot":"","sources":["../src/pinned-write.ts"],"names":[],"mappings":"AAIA,OAAO,EAAa,KAAK,QAAQ,EAAE,MAAM,aAAa,CAAC;AAIvD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAS3D,KAAK,gBAAgB,GACjB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,cAAc,CAAA;CAAE,GACpE;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,QAAQ,CAAA;CAAE,CAAC;AAuFzC,wBAAsB,oBAAoB,CAAC,MAAM,EAAE;IACjD,QAAQ,EAAE,MAAM,CAAC;IACjB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,gBAAgB,CAAC;CACzB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAuC5B;AAED,wBAAsB,mBAAmB,CAAC,MAAM,EAAE;IAChD,QAAQ,EAAE,MAAM,CAAC;IACjB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,gBAAgB,CAAC;CAClC,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAoB5B"}
1
+ {"version":3,"file":"pinned-write.d.ts","sourceRoot":"","sources":["../src/pinned-write.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAG5C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAW3D,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;AA6EzC,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;IACxB,YAAY,CAAC,EAAE,gBAAgB,CAAC;CACjC,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAwC5B;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;IACjC,YAAY,CAAC,EAAE,gBAAgB,CAAC;CACjC,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAqB5B"}