@openclaw/fs-safe 0.10.0 → 0.12.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 (302) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/LICENSE +1 -0
  3. package/README.md +39 -6
  4. package/dist/absolute-path.d.ts.map +1 -1
  5. package/dist/absolute-path.js +3 -2
  6. package/dist/advanced.d.ts +5 -1
  7. package/dist/advanced.d.ts.map +1 -1
  8. package/dist/advanced.js +5 -1
  9. package/dist/archive-durability.d.ts +6 -6
  10. package/dist/archive-durability.d.ts.map +1 -1
  11. package/dist/archive-durability.js +1 -1
  12. package/dist/archive-entry.d.ts.map +1 -1
  13. package/dist/archive-entry.js +8 -8
  14. package/dist/archive-gzip-tail.d.ts +1 -0
  15. package/dist/archive-gzip-tail.d.ts.map +1 -1
  16. package/dist/archive-gzip-tail.js +16 -9
  17. package/dist/archive-input.d.ts.map +1 -1
  18. package/dist/archive-input.js +4 -2
  19. package/dist/archive-merge.d.ts +5 -1
  20. package/dist/archive-merge.d.ts.map +1 -1
  21. package/dist/archive-merge.js +15 -12
  22. package/dist/archive-native.js +4 -4
  23. package/dist/archive-parser.wasm +0 -0
  24. package/dist/archive-read.d.ts.map +1 -1
  25. package/dist/archive-read.js +54 -42
  26. package/dist/archive-staging.d.ts +6 -3
  27. package/dist/archive-staging.d.ts.map +1 -1
  28. package/dist/archive-staging.js +42 -22
  29. package/dist/archive-tar-stream.d.ts.map +1 -1
  30. package/dist/archive-tar-stream.js +7 -6
  31. package/dist/archive-tar-wasm.d.ts.map +1 -1
  32. package/dist/archive-tar-wasm.js +16 -13
  33. package/dist/archive-zip-admission.d.ts +1 -1
  34. package/dist/archive-zip-admission.d.ts.map +1 -1
  35. package/dist/archive-zip-admission.js +48 -12
  36. package/dist/archive-zip-loader.d.ts +6 -0
  37. package/dist/archive-zip-loader.d.ts.map +1 -0
  38. package/dist/archive-zip-loader.js +38 -0
  39. package/dist/archive-zip-names.d.ts.map +1 -1
  40. package/dist/archive-zip-names.js +26 -9
  41. package/dist/archive-zip-preflight.d.ts +2 -3
  42. package/dist/archive-zip-preflight.d.ts.map +1 -1
  43. package/dist/archive-zip-preflight.js +2 -34
  44. package/dist/archive.d.ts.map +1 -1
  45. package/dist/archive.js +12 -10
  46. package/dist/bounded-read.d.ts +5 -0
  47. package/dist/bounded-read.d.ts.map +1 -1
  48. package/dist/bounded-read.js +18 -11
  49. package/dist/copy-file-input.d.ts +0 -1
  50. package/dist/copy-file-input.d.ts.map +1 -1
  51. package/dist/copy-file-input.js +4 -26
  52. package/dist/copy-tree-portable.d.ts.map +1 -1
  53. package/dist/copy-tree-portable.js +57 -26
  54. package/dist/copy.d.ts +1 -1
  55. package/dist/copy.d.ts.map +1 -1
  56. package/dist/copy.js +3 -1
  57. package/dist/device-path.d.ts.map +1 -1
  58. package/dist/device-path.js +5 -3
  59. package/dist/directory-durability.d.ts.map +1 -1
  60. package/dist/directory-durability.js +5 -4
  61. package/dist/directory-guard.d.ts +11 -1
  62. package/dist/directory-guard.d.ts.map +1 -1
  63. package/dist/directory-guard.js +53 -11
  64. package/dist/durability.d.ts +1 -1
  65. package/dist/durability.d.ts.map +1 -1
  66. package/dist/durability.js +1 -1
  67. package/dist/file-handle-transfer.d.ts +14 -0
  68. package/dist/file-handle-transfer.d.ts.map +1 -0
  69. package/dist/file-handle-transfer.js +64 -0
  70. package/dist/file-hash.d.ts +3 -0
  71. package/dist/file-hash.d.ts.map +1 -1
  72. package/dist/file-hash.js +99 -32
  73. package/dist/file-lock-sync.d.ts.map +1 -1
  74. package/dist/file-lock-sync.js +8 -4
  75. package/dist/file-store-boundary.d.ts.map +1 -1
  76. package/dist/file-store-boundary.js +7 -5
  77. package/dist/file-store-path.d.ts +3 -0
  78. package/dist/file-store-path.d.ts.map +1 -0
  79. package/dist/file-store-path.js +27 -0
  80. package/dist/file-store-prune.d.ts.map +1 -1
  81. package/dist/file-store-prune.js +15 -5
  82. package/dist/file-store-sync-write.d.ts.map +1 -1
  83. package/dist/file-store-sync-write.js +56 -44
  84. package/dist/file-store.d.ts.map +1 -1
  85. package/dist/file-store.js +2 -18
  86. package/dist/filename.d.ts +1 -0
  87. package/dist/filename.d.ts.map +1 -1
  88. package/dist/filename.js +32 -14
  89. package/dist/guarded-mkdir.d.ts +2 -0
  90. package/dist/guarded-mkdir.d.ts.map +1 -1
  91. package/dist/guarded-mkdir.js +52 -16
  92. package/dist/guest-dispatch-python.d.ts +2 -0
  93. package/dist/guest-dispatch-python.d.ts.map +1 -0
  94. package/dist/guest-dispatch-python.js +117 -0
  95. package/dist/guest-native-python.d.ts +4 -0
  96. package/dist/guest-native-python.d.ts.map +1 -0
  97. package/dist/guest-native-python.js +135 -0
  98. package/dist/guest.d.ts +9 -0
  99. package/dist/guest.d.ts.map +1 -0
  100. package/dist/guest.js +421 -0
  101. package/dist/index.d.ts +1 -1
  102. package/dist/index.d.ts.map +1 -1
  103. package/dist/install-path.d.ts +6 -0
  104. package/dist/install-path.d.ts.map +1 -1
  105. package/dist/install-path.js +16 -2
  106. package/dist/json-durable-queue-directory.js +3 -3
  107. package/dist/json-durable-queue-ownership.d.ts +2 -0
  108. package/dist/json-durable-queue-ownership.d.ts.map +1 -1
  109. package/dist/json-durable-queue-ownership.js +47 -3
  110. package/dist/json-durable-queue-read.d.ts +6 -0
  111. package/dist/json-durable-queue-read.d.ts.map +1 -0
  112. package/dist/json-durable-queue-read.js +59 -0
  113. package/dist/json-durable-queue.d.ts +1 -1
  114. package/dist/json-durable-queue.d.ts.map +1 -1
  115. package/dist/json-durable-queue.js +43 -84
  116. package/dist/json.d.ts.map +1 -1
  117. package/dist/json.js +2 -1
  118. package/dist/local-roots.d.ts.map +1 -1
  119. package/dist/local-roots.js +2 -1
  120. package/dist/move-path-stage.d.ts.map +1 -1
  121. package/dist/move-path-stage.js +2 -1
  122. package/dist/move-path.d.ts.map +1 -1
  123. package/dist/move-path.js +4 -3
  124. package/dist/mutation-authority.d.ts +1 -0
  125. package/dist/mutation-authority.d.ts.map +1 -1
  126. package/dist/mutation-authority.js +4 -4
  127. package/dist/native-binding.d.ts +14 -2
  128. package/dist/native-binding.d.ts.map +1 -1
  129. package/dist/native-pinned-write-windows.d.ts.map +1 -1
  130. package/dist/native-pinned-write-windows.js +3 -2
  131. package/dist/native-pinned-write.d.ts.map +1 -1
  132. package/dist/native-pinned-write.js +2 -1
  133. package/dist/opened-realpath.d.ts.map +1 -1
  134. package/dist/opened-realpath.js +5 -4
  135. package/dist/output.d.ts +2 -0
  136. package/dist/output.d.ts.map +1 -1
  137. package/dist/output.js +2 -0
  138. package/dist/overwrite-file-handle.d.ts +8 -0
  139. package/dist/overwrite-file-handle.d.ts.map +1 -0
  140. package/dist/overwrite-file-handle.js +42 -0
  141. package/dist/path-case.d.ts +7 -0
  142. package/dist/path-case.d.ts.map +1 -0
  143. package/dist/path-case.js +136 -0
  144. package/dist/path-scope-lexical.d.ts +14 -0
  145. package/dist/path-scope-lexical.d.ts.map +1 -0
  146. package/dist/path-scope-lexical.js +27 -0
  147. package/dist/path.d.ts.map +1 -1
  148. package/dist/path.js +22 -3
  149. package/dist/permissions-windows.d.ts +1 -1
  150. package/dist/permissions-windows.d.ts.map +1 -1
  151. package/dist/permissions-windows.js +48 -6
  152. package/dist/pinned-open.d.ts.map +1 -1
  153. package/dist/pinned-open.js +3 -1
  154. package/dist/pinned-write.d.ts +2 -2
  155. package/dist/pinned-write.d.ts.map +1 -1
  156. package/dist/pinned-write.js +3 -1
  157. package/dist/private-temp-workspace.d.ts.map +1 -1
  158. package/dist/private-temp-workspace.js +6 -4
  159. package/dist/realpath.d.ts +4 -0
  160. package/dist/realpath.d.ts.map +1 -0
  161. package/dist/realpath.js +43 -0
  162. package/dist/recursive-mkdir-path.d.ts +3 -0
  163. package/dist/recursive-mkdir-path.d.ts.map +1 -0
  164. package/dist/recursive-mkdir-path.js +8 -0
  165. package/dist/replace-directory.d.ts.map +1 -1
  166. package/dist/replace-directory.js +2 -1
  167. package/dist/replace-file-copy-fallback.d.ts.map +1 -1
  168. package/dist/replace-file-copy-fallback.js +23 -31
  169. package/dist/replace-file-copy-source.d.ts.map +1 -1
  170. package/dist/replace-file-copy-source.js +7 -12
  171. package/dist/replace-file-mode.d.ts +3 -0
  172. package/dist/replace-file-mode.d.ts.map +1 -0
  173. package/dist/replace-file-mode.js +10 -0
  174. package/dist/replace-file.d.ts +1 -0
  175. package/dist/replace-file.d.ts.map +1 -1
  176. package/dist/replace-file.js +14 -8
  177. package/dist/root-boundary.d.ts +25 -0
  178. package/dist/root-boundary.d.ts.map +1 -0
  179. package/dist/root-boundary.js +177 -0
  180. package/dist/root-context.d.ts.map +1 -1
  181. package/dist/root-context.js +40 -13
  182. package/dist/root-create-input.d.ts +10 -0
  183. package/dist/root-create-input.d.ts.map +1 -0
  184. package/dist/root-create-input.js +80 -0
  185. package/dist/root-directory-list.d.ts +3 -1
  186. package/dist/root-directory-list.d.ts.map +1 -1
  187. package/dist/root-directory-list.js +31 -5
  188. package/dist/root-entries.d.ts +11 -0
  189. package/dist/root-entries.d.ts.map +1 -0
  190. package/dist/root-entries.js +61 -0
  191. package/dist/root-errors.d.ts +5 -5
  192. package/dist/root-errors.d.ts.map +1 -1
  193. package/dist/root-errors.js +13 -12
  194. package/dist/root-impl.d.ts +13 -3
  195. package/dist/root-impl.d.ts.map +1 -1
  196. package/dist/root-impl.js +93 -70
  197. package/dist/root-move-preflight.d.ts +8 -0
  198. package/dist/root-move-preflight.d.ts.map +1 -0
  199. package/dist/root-move-preflight.js +16 -0
  200. package/dist/root-options.d.ts +12 -1
  201. package/dist/root-options.d.ts.map +1 -1
  202. package/dist/root-path-existing.d.ts +2 -0
  203. package/dist/root-path-existing.d.ts.map +1 -1
  204. package/dist/root-path-existing.js +14 -5
  205. package/dist/root-path-symlink.d.ts.map +1 -1
  206. package/dist/root-path-symlink.js +3 -2
  207. package/dist/root-path.d.ts +2 -0
  208. package/dist/root-path.d.ts.map +1 -1
  209. package/dist/root-path.js +54 -26
  210. package/dist/root-paths.d.ts +2 -6
  211. package/dist/root-paths.d.ts.map +1 -1
  212. package/dist/root-paths.js +24 -32
  213. package/dist/root-remove.d.ts +5 -0
  214. package/dist/root-remove.d.ts.map +1 -0
  215. package/dist/root-remove.js +286 -0
  216. package/dist/root-symlink-policy.d.ts +2 -1
  217. package/dist/root-symlink-policy.d.ts.map +1 -1
  218. package/dist/root-symlink-policy.js +2 -2
  219. package/dist/root-walk.d.ts.map +1 -1
  220. package/dist/root-walk.js +2 -1
  221. package/dist/root-write-mode.d.ts +2 -0
  222. package/dist/root-write-mode.d.ts.map +1 -1
  223. package/dist/root-write-mode.js +21 -7
  224. package/dist/root-write-verification.d.ts.map +1 -1
  225. package/dist/root-write-verification.js +12 -3
  226. package/dist/root.d.ts +2 -1
  227. package/dist/root.d.ts.map +1 -1
  228. package/dist/safe-path-segment.d.ts.map +1 -1
  229. package/dist/safe-path-segment.js +3 -1
  230. package/dist/secret-file.d.ts.map +1 -1
  231. package/dist/secret-file.js +2 -1
  232. package/dist/secret-read-async.d.ts.map +1 -1
  233. package/dist/secret-read-async.js +2 -1
  234. package/dist/secure-file-windows.d.ts +10 -0
  235. package/dist/secure-file-windows.d.ts.map +1 -0
  236. package/dist/secure-file-windows.js +186 -0
  237. package/dist/secure-file.d.ts.map +1 -1
  238. package/dist/secure-file.js +27 -7
  239. package/dist/secure-temp-dir.d.ts.map +1 -1
  240. package/dist/secure-temp-dir.js +2 -1
  241. package/dist/sibling-staged-file.d.ts +2 -0
  242. package/dist/sibling-staged-file.d.ts.map +1 -1
  243. package/dist/sibling-staged-file.js +49 -8
  244. package/dist/sibling-temp.d.ts +2 -0
  245. package/dist/sibling-temp.d.ts.map +1 -1
  246. package/dist/sibling-temp.js +8 -5
  247. package/dist/sidecar-lock-acquire.d.ts.map +1 -1
  248. package/dist/sidecar-lock-acquire.js +16 -5
  249. package/dist/sidecar-lock-policy.d.ts +2 -0
  250. package/dist/sidecar-lock-policy.d.ts.map +1 -1
  251. package/dist/sidecar-lock-policy.js +17 -0
  252. package/dist/sidecar-lock.d.ts.map +1 -1
  253. package/dist/sidecar-lock.js +2 -3
  254. package/dist/staged-directory.d.ts.map +1 -1
  255. package/dist/staged-directory.js +4 -3
  256. package/dist/temp-target.d.ts +14 -12
  257. package/dist/temp-target.d.ts.map +1 -1
  258. package/dist/temp-target.js +15 -7
  259. package/dist/trash.d.ts.map +1 -1
  260. package/dist/trash.js +7 -5
  261. package/dist/unicode-path.d.ts +3 -0
  262. package/dist/unicode-path.d.ts.map +1 -0
  263. package/dist/unicode-path.js +13 -0
  264. package/dist/walk.d.ts.map +1 -1
  265. package/dist/walk.js +14 -12
  266. package/dist/write-file-handle.d.ts +1 -0
  267. package/dist/write-file-handle.d.ts.map +1 -1
  268. package/dist/write-file-handle.js +3 -2
  269. package/docs/advanced.md +7 -1
  270. package/docs/archive.md +31 -5
  271. package/docs/atomic.md +17 -1
  272. package/docs/config.md +1 -0
  273. package/docs/contributing.md +33 -2
  274. package/docs/copy.md +75 -6
  275. package/docs/directory-identity.md +85 -0
  276. package/docs/durability.md +40 -3
  277. package/docs/entries.md +109 -0
  278. package/docs/errors.md +3 -3
  279. package/docs/file-store.md +21 -0
  280. package/docs/filename.md +9 -2
  281. package/docs/guest.md +146 -0
  282. package/docs/in-place-write.md +81 -0
  283. package/docs/index.md +2 -0
  284. package/docs/install-path.md +59 -13
  285. package/docs/install.md +34 -0
  286. package/docs/native-helper.md +14 -5
  287. package/docs/native.md +18 -2
  288. package/docs/output.md +32 -6
  289. package/docs/path-case.md +64 -0
  290. package/docs/path-scope.md +1 -1
  291. package/docs/path.md +1 -1
  292. package/docs/permissions.md +37 -3
  293. package/docs/public-api.md +36 -2
  294. package/docs/root.md +33 -3
  295. package/docs/secure-file.md +17 -14
  296. package/docs/security-model.md +1 -1
  297. package/docs/sidecar-lock.md +12 -3
  298. package/docs/store.md +22 -1
  299. package/docs/temp.md +39 -6
  300. package/docs/types.md +1 -1
  301. package/docs/writing.md +153 -3
  302. package/package.json +14 -8
@@ -0,0 +1,81 @@
1
+ ---
2
+ title: In-place writes
3
+ description: "Replace bytes through a borrowed file handle while preserving its inode and attempting rollback on failure."
4
+ ---
5
+
6
+ # In-place writes
7
+
8
+ `overwriteFileHandle()` replaces a regular file's contents through a handle the
9
+ caller already owns. Use it when replacing the inode would break hardlinked
10
+ aliases, or when an existing writable file lives in a directory where the caller
11
+ cannot create a sibling temporary file.
12
+
13
+ ```ts
14
+ import { open } from "node:fs/promises";
15
+ import { overwriteFileHandle } from "@openclaw/fs-safe/advanced";
16
+
17
+ const handle = await open(filePath, "r+");
18
+ try {
19
+ await overwriteFileHandle(handle, Buffer.from(nextContents, "utf8"), {
20
+ beforeWrite: () => assertCurrentOwner(),
21
+ });
22
+ } finally {
23
+ await handle.close();
24
+ }
25
+ ```
26
+
27
+ ## Contract
28
+
29
+ ```ts
30
+ overwriteFileHandle(
31
+ handle: FileHandle,
32
+ data: Uint8Array,
33
+ options?: { beforeWrite?: () => void },
34
+ ): Promise<void>;
35
+ ```
36
+
37
+ The handle must be readable and writable, refer to a regular file, and have been
38
+ opened **without append mode**. Node cannot portably inspect a handle's append
39
+ flag, and some operating systems ignore positioned-write offsets for append
40
+ handles. Opening, path admission, symlink and hardlink policy, and closing remain
41
+ the caller's responsibility. The helper never reopens or replaces the inode,
42
+ changes the handle's current position, or closes it. Every hardlinked alias sees
43
+ the in-place changes.
44
+
45
+ The payload is borrowed, including its byte offset and length. Keep it unchanged,
46
+ attached, and accessible until the returned promise settles. Do not close the
47
+ handle or run concurrent I/O against the file, including through other aliases,
48
+ during preparation, writing, or rollback. This helper does not acquire a lock.
49
+
50
+ ## Preparation, ordering, and failure
51
+
52
+ The helper checks the file type and original size, then saves only the prefix
53
+ that will be overwritten: `min(data.byteLength, originalSize)` bytes. A short
54
+ prefix read fails with `FsSafeError("read-failed")` before writing. Memory for the
55
+ backup is bounded by that prefix length; a short replacement of a large file does
56
+ not read or buffer the untouched tail.
57
+
58
+ After preparation, `beforeWrite` runs synchronously once. A thrown value is
59
+ propagated unchanged without any file mutation. A Promise or thenable return is
60
+ rejected with `TypeError`; asynchronous callbacks cannot admit a write. Once
61
+ admitted, the operation finishes the write or its failure recovery without
62
+ calling `beforeWrite` again. This callback is a whole-operation admission point,
63
+ not the per-mutation `assertBeforeMutation` callback used by Root operations.
64
+ It must not change the file, handle, or payload. There is no cancellation option.
65
+
66
+ For growth, additional tail bytes are written before the existing prefix is
67
+ touched. The prefix is then overwritten, completing partial writes. For shrinkage,
68
+ truncation is last. If a write or truncation fails, the helper attempts to restore
69
+ the saved prefix, if touched, and the original length. It waits for both recovery
70
+ attempts and then rethrows the original error, even when recovery also fails.
71
+ Zero-progress writes fail with `FsSafeError("helper-failed")`.
72
+
73
+ Recovery is best effort, not atomic publication or a crash-recovery guarantee.
74
+ Other observers can see intermediate contents; failed recovery can leave partial
75
+ bytes. The helper does not chmod or synchronize the file or its directory.
76
+ Normal filesystem effects such as timestamp updates or clearing special mode
77
+ bits can still occur. Callers own any durability or broader transaction policy.
78
+
79
+ The implementation uses Node positional reads and writes in every native mode;
80
+ it does not load a native binding. Prefer [`Root.write`](writing.md) when atomic
81
+ replacement and root-based path admission are the intended contract.
package/docs/index.md CHANGED
@@ -52,6 +52,7 @@ await fs.remove("notes/archive/today.txt");
52
52
  |---|---|
53
53
  | [`root()`](root.md) | One boundary for read/write/move/remove and bounded recursive walking inside a trusted directory. |
54
54
  | [`@openclaw/fs-safe/config`](config.md) | Process-global native helper and lock-option defaults. |
55
+ | [`@openclaw/fs-safe/guest`](guest.md) | Python filesystem source for caller-owned guest transports, with admitted roots and descriptor-relative operations. |
55
56
  | [Native helper policy](native-helper.md) | Choose `auto`, `off`, or `require` for platform-native primitives. |
56
57
  | [Native architecture](native.md) | Understand the thin syscall layer, beneath model, platform mechanisms, and fallback boundary. |
57
58
  | [`replaceFileAtomic`](atomic.md) | Sibling-temp + rename, fsync hooks, mode preservation, copy fallback. |
@@ -65,6 +66,7 @@ await fs.remove("notes/archive/today.txt");
65
66
  | [`tempWorkspace`](temp.md) | 0700 scratch dir with auto-cleanup. |
66
67
  | [`readSecureFile`](secure-file.md) | Absolute file reads with fd pinning, permissions, owner, size, and timeout checks. |
67
68
  | [`walkDirectory` / `Root.walk`](walk.md) | Standalone inventories plus root-bounded pruning, budgets, and partial-error reporting. |
69
+ | [`Root.entries`](entries.md) | Guarded nonrecursive entries, bounded name collection, and caller-owned symlink validation. |
68
70
  | [`extractArchive`](archive.md) | Policy-driven ZIP/TAR extraction with clamp/filter, metadata/path-depth, link, count, and byte limits. |
69
71
  | [Secret files](secret-file.md) | Mode-0600 credentials with size and TOCTOU defense. |
70
72
  | [Permissions](permissions.md) | POSIX mode helpers plus Windows ACL inspection, raw owner/ACE facts, remediation, and private-directory creation. |
@@ -8,6 +8,7 @@ import {
8
8
  resolveSafeInstallDir,
9
9
  safeDirName,
10
10
  safePathSegmentHashed,
11
+ safePathSegmentHashedV2,
11
12
  } from "@openclaw/fs-safe/advanced";
12
13
  ```
13
14
 
@@ -35,14 +36,17 @@ if (!r.ok) return reply(400, r.error);
35
36
  await fs.mkdir(r.path, { recursive: true });
36
37
  ```
37
38
 
38
- For ids whose default-sanitized form might collide (e.g. `"foo/bar"` and `"foo\\bar"` both map to `"foo__bar"`), pass `nameEncoder: safePathSegmentHashed` to append a content hash:
39
+ For untrusted IDs that must occupy separate install directories, pass
40
+ `nameEncoder: safePathSegmentHashedV2`. The default `safeDirName` and legacy
41
+ `safePathSegmentHashed` can map distinct IDs to the same directory; the boundary
42
+ check does not establish which ID owns an existing directory.
39
43
 
40
44
  ```ts
41
45
  const r = resolveSafeInstallDir({
42
- baseDir: "/srv/plugins",
46
+ baseDir: "/srv/plugins-v2",
43
47
  id: untrustedId,
44
48
  invalidNameMessage: "invalid plugin name",
45
- nameEncoder: safePathSegmentHashed,
49
+ nameEncoder: safePathSegmentHashedV2,
46
50
  });
47
51
  ```
48
52
 
@@ -87,11 +91,48 @@ safeDirName(""); // ""
87
91
 
88
92
  `safeDirName` does *not* try to be exhaustive about Windows-reserved names or special characters. It is purely a separator-stripping pass — `resolveSafeInstallDir` adds the boundary check on top so an `"../../etc"` input cannot escape `baseDir`.
89
93
 
90
- For stricter sanitization, use `safePathSegmentHashed`.
94
+ Use `safePathSegmentHashedV2` when distinct untrusted IDs need separate names.
95
+
96
+ ### `safePathSegmentHashedV2`
97
+
98
+ ```ts
99
+ function safePathSegmentHashedV2(input: string): string;
100
+ ```
101
+
102
+ Hashes every trimmed ID, including ordinary short names, into `id-v2-` followed
103
+ by 64 lowercase hexadecimal SHA-256 digits. The result is always 70 ASCII bytes,
104
+ contains no separators, and avoids Windows device names and ignored suffixes.
105
+ There is no readable prefix to truncate and no unchanged-name branch. Distinct
106
+ trimmed IDs, including an ID that looks like an encoded output, remain distinct
107
+ unless their full SHA-256 digests collide. The lowercase ASCII output also
108
+ preserves that distinction on case-insensitive and Unicode-normalizing volumes.
109
+
110
+ The stable V2 digest recipe is SHA-256 of the UTF-8 bytes of
111
+ `"@openclaw/fs-safe:install-path:v2\0"`, followed by the UTF-16LE bytes of
112
+ `input.trim()`, without a byte-order mark. The NUL-terminated prefix separates
113
+ this use of SHA-256 from other hash domains. UTF-16LE preserves exact JavaScript
114
+ code units, including lone surrogates. Inputs are not case-folded or Unicode
115
+ normalized. Surrounding whitespace, as removed by JavaScript `String.trim()`,
116
+ is the only intentional equivalence; internal whitespace remains significant.
117
+
118
+ ```ts
119
+ const segment = safePathSegmentHashedV2("plugin/v1"); // id-v2-<64 hex digits>
120
+ safePathSegmentHashedV2(" plugin/v1 ") === segment; // true
121
+ safePathSegmentHashedV2("Plugin/v1") === segment; // false
122
+ ```
123
+
124
+ This encoder computes a name; it does not authorize access or prove ownership
125
+ of a directory. Store the original trimmed ID in application-owned metadata and
126
+ verify it before reusing an existing install directory. Keep each install tree
127
+ on one encoding version. Switching to V2 changes existing paths: use a new base
128
+ directory or explicitly migrate directories after verifying their recorded IDs.
129
+ Do not silently fall back to a legacy path when the V2 path is missing.
91
130
 
92
131
  ### `safePathSegmentHashed`
93
132
 
94
- Returns a directory-safe segment **plus** a short content hash when sanitization changed the input or when the safe form is too long. Use this when input collisions matter:
133
+ Legacy readable encoding retained for path compatibility. It appends a short
134
+ content hash when sanitization changed the input or when the safe form is too
135
+ long; ordinary short names remain unchanged. Use V2 for new untrusted-ID mappings.
95
136
 
96
137
  ```ts
97
138
  safePathSegmentHashed("plugin-v1"); // "plugin-v1" (unchanged short input)
@@ -104,31 +145,35 @@ safePathSegmentHashed("."); // "skill-cdb4ee2aea"
104
145
 
105
146
  The sanitization is more aggressive than `safeDirName`: any character not in `[A-Za-z0-9._-]` becomes `-`, runs of `-` collapse, leading and trailing `-` are stripped, the empty/`.`/`..` fallback is `"skill"`. Long results are truncated to 50 chars before the hash is appended.
106
147
 
107
- The suffix is the first 10 hex characters of `sha256(trimmedInput)`. It makes
108
- collisions between distinct trimmed inputs unlikely, but it is a 40-bit
109
- identifier rather than a mathematical uniqueness guarantee. Inputs that differ
110
- only by surrounding whitespace intentionally map to the same output.
148
+ The suffix is the first 10 hex characters of `sha256(trimmedInput)`. This is a
149
+ 40-bit identifier, and the hash is not applied to every ID: short generated
150
+ outputs overlap with accepted literal inputs. Case variants can also share a
151
+ directory on case-insensitive filesystems. Distinct IDs can therefore alias
152
+ without a hash collision. Do not use this legacy encoder as an identity or
153
+ authorization boundary for untrusted IDs. Inputs that differ only by surrounding
154
+ whitespace intentionally map to the same output. Its output and the default
155
+ encoder selected by `resolveSafeInstallDir` remain unchanged for compatibility.
111
156
 
112
157
  ## Common patterns
113
158
 
114
159
  ### Install a plugin
115
160
 
116
161
  ```ts
117
- import { resolveSafeInstallDir, assertCanonicalPathWithinBase, safePathSegmentHashed } from "@openclaw/fs-safe/advanced";
162
+ import { resolveSafeInstallDir, assertCanonicalPathWithinBase, safePathSegmentHashedV2 } from "@openclaw/fs-safe/advanced";
118
163
  import { extractArchive } from "@openclaw/fs-safe/archive";
119
164
  import fs from "node:fs/promises";
120
165
 
121
166
  const r = resolveSafeInstallDir({
122
- baseDir: "/srv/plugins",
167
+ baseDir: "/srv/plugins-v2",
123
168
  id: untrustedName,
124
169
  invalidNameMessage: "invalid plugin name",
125
- nameEncoder: safePathSegmentHashed,
170
+ nameEncoder: safePathSegmentHashedV2,
126
171
  });
127
172
  if (!r.ok) return reply(400, r.error);
128
173
 
129
174
  await fs.mkdir(r.path, { recursive: true, mode: 0o755 });
130
175
  await assertCanonicalPathWithinBase({
131
- baseDir: "/srv/plugins",
176
+ baseDir: "/srv/plugins-v2",
132
177
  candidatePath: r.path,
133
178
  boundaryLabel: "plugin install dir",
134
179
  });
@@ -148,6 +193,7 @@ const snap = resolveSafeInstallDir({
148
193
  baseDir: "/srv/snapshots",
149
194
  id: `${runId}-${version}`,
150
195
  invalidNameMessage: "invalid snapshot id",
196
+ nameEncoder: safePathSegmentHashedV2,
151
197
  });
152
198
  if (!snap.ok) throw new Error(snap.error);
153
199
  await fs.mkdir(snap.path, { recursive: true });
package/docs/install.md CHANGED
@@ -31,6 +31,40 @@ node --version
31
31
  # v22.0.0 or newer
32
32
  ```
33
33
 
34
+ ## Bun runtime
35
+
36
+ Bun 1.4.2 can run the same public APIs and load the matching native package.
37
+ On macOS and Linux, fs-safe uses its Rust N-API addon to call the system
38
+ `realpath` implementation for native resolution. Ordinary resolution follows
39
+ Node's component walk, including lexical normalization of expanded symlink
40
+ targets, in Rust, with a 1,024-link expansion limit that returns `ELOOP` for
41
+ excessive or cyclic expansion. This works around Bun path-resolution defects that
42
+ otherwise reject restrictive permissions and confuse literal POSIX backslashes
43
+ with directory separators. The OS still resolves symlinks and canonical file
44
+ names; fs-safe retains its confinement and file-identity checks.
45
+
46
+ This works with `bun --jitless` and needs no runtime FFI or JIT. Use native mode
47
+ `auto` or `require` with the matching addon installed. `FS_SAFE_NATIVE_MODE=off`
48
+ still disables all addon loading; `auto` without the addon falls back to Bun's
49
+ resolver. Those configurations retain Bun 1.4.2's limitations with restrictive
50
+ permissions, sockets, literal backslashes, and symlink/parent traversal. Use
51
+ Node if you need full compatibility without the addon. On Bun POSIX, `require`
52
+ also rejects canonicalization when the addon or its canonicalizer is unavailable.
53
+
54
+ Node and Windows use their existing runtime canonicalizers. On Windows, Bun's
55
+ recursive directory creation receives an absolute spelling that preserves raw
56
+ path components, working around its rejection of existing relative `.` and `..`
57
+ directories. Windows native descriptor-relative operations require Bun to expose
58
+ the paired libuv descriptor bridge from its host executable; a missing or partial
59
+ bridge fails explicitly with `ENOTSUP`. Public paths and caller-supplied filesystem
60
+ adapters remain unchanged.
61
+
62
+ The upstream fix is tracked in [Bun #42374](https://github.com/oven-sh/bun/pull/42374).
63
+ The adapter can be removed when the supported Bun baseline includes that fix.
64
+ Run native compatibility checks with `pnpm test:bun:native` after building the
65
+ package and addon. See [contributing](contributing.md) for the Node/pnpm toolchain
66
+ and the broader diagnostic suite.
67
+
34
68
  ## TypeScript
35
69
 
36
70
  Types ship with the package — no `@types/openclaw__fs-safe` needed. The `exports` map in `package.json` provides typed entries for every subpath:
@@ -30,10 +30,17 @@ The equivalent environment variables are `FS_SAFE_NATIVE_MODE` and `OPENCLAW_FS_
30
30
  | `require` | Throw `FsSafeError("helper-unavailable")` instead of falling back when an operation needs the native binding and it cannot load. |
31
31
 
32
32
  TAR/gzip in the guarded JavaScript path uses a bundled, import-free WASM build
33
- of the same Rust parser used by native. `off` still disables native filesystem
34
- code; it does not disable this portable parser. ZIP fallback still requires
33
+ of the same Rust parser used by native. `off` still disables the optional native
34
+ filesystem helper; it does not disable this portable parser. ZIP fallback still requires
35
35
  optional `jszip`, and zstd/bzip2 remain native-only.
36
36
 
37
+ On Bun macOS/Linux, the [runtime path adapter](install.md#bun-runtime) uses the
38
+ same Rust addon for system canonicalization in `auto` and `require`. No JIT is
39
+ needed. With `off` or a missing addon in `auto`, Bun's own resolver retains its
40
+ path and permission limitations. Canonicalization in `require` fails with
41
+ `helper-unavailable` if the addon or its canonicalizer is missing, including
42
+ when admitting a temp workspace. Containment and identity checks stay intact.
43
+
37
44
  Configure the mode once during startup. Loading is lazy and cached; changing from `auto` to `require` after a failed load changes failure policy but does not repeatedly probe the binary.
38
45
 
39
46
  [`tempWorkspace()` and its scoped/sync variants](temp.md#private-temp-workspaces)
@@ -57,16 +64,18 @@ change the mode policy of existing fallback-capable APIs.
57
64
 
58
65
  The native layer exposes policy-free filesystem mechanisms: beneath-root
59
66
  open/mkdir/link, replace and no-replace rename, identity reads, archive decode/execution,
60
- clone/copy/hash workers, and Windows security descriptor calls. The TypeScript
67
+ clone/copy/hash workers, POSIX canonicalization, and Windows security descriptor calls. The TypeScript
61
68
  layer owns policy, retries, filters, budgets, modes, cleanup, error
62
69
  normalization, and the decision to fall back.
63
70
 
64
71
  - Linux uses `openat2` with `RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS`, `renameat`, and `renameat2(RENAME_NOREPLACE)`. Owned-tree cleanup enumerates and unlinks through retained directory descriptors and rejects device crossings.
65
72
  - macOS 15.4 and newer prefer `O_RESOLVE_BENEATH`; older kernels resolve components with `O_NOFOLLOW` and restart in-root symlinks from the pinned root descriptor. Both routes use an `F_GETPATH` post-open escape detector and report `best-effort` because directory rename races are not atomic with that check. Publication uses `renameat` for replacement and `renameatx_np(RENAME_EXCL)` for no-replace; owned-tree cleanup uses descriptor-relative `openat`/`unlinkat`.
66
- - Windows uses handle-relative `NtCreateFile`, rejects reparse points during root-bounded traversal, uses `FileRenameInfoEx` with replacement selected explicitly by the TypeScript policy layer, and deletes owned trees through exact opened handles with `FileDispositionInfoEx`; symlink/reparse entries in owned trees are removed as leaves and never traversed.
73
+ - Windows uses handle-relative `NtCreateFile`, rejects reparse points during root-bounded traversal, uses `FileRenameInfoEx` with replacement selected explicitly by the TypeScript policy layer, and deletes owned trees through exact opened handles with `FileDispositionInfoEx`; symlink/reparse entries in owned trees are removed as leaves and never traversed. Descriptors crossing N-API are converted only by the host executable's paired `uv_get_osfhandle` and `uv_open_osfhandle` exports. A runtime without both exports is unsupported for these native operations; the binding never guesses a raw HANDLE or uses a foreign CRT descriptor table.
67
74
 
68
75
  Native primitives back create-only and replacing pinned writes, async sidecar creation,
69
- guarded publication, archive acceleration, and direct Windows ACL operations.
76
+ guarded publication, archive acceleration, and direct Windows ACL operations. Windows
77
+ secure-file reads require descriptor-bound owner/DACL facts from the current helper;
78
+ they do not use the standalone pathname inspector's command fallback.
70
79
  Equivalent JavaScript paths remain available for documented fallback-capable
71
80
  features. See [Native architecture](native.md#javascript-fallback-guarantees-and-delta)
72
81
  for the exact difference.
package/docs/native.md CHANGED
@@ -55,7 +55,12 @@ whether a path, archive entry, mode, owner, or cleanup policy is acceptable.
55
55
  `FILE_OPEN_REPARSE_POINT`, then explicitly rejects reparse points. Rename and
56
56
  hardlink operations stay rooted in already-open handles. Owner/DACL reads
57
57
  use `GetSecurityInfo`; private directories receive their protected DACL in
58
- the `CreateDirectoryW` call itself.
58
+ an exclusive, handle-relative `NtCreateFile` call. Their created handles remain
59
+ open through ACL and pathname-association checks and own any failure cleanup.
60
+ N-API descriptors cross into and out of
61
+ this layer only through the host executable's paired libuv descriptor bridge;
62
+ missing or partial exports fail with `ENOTSUP` instead of trying a raw HANDLE
63
+ or add-on CRT descriptor namespace.
59
64
 
60
65
  ## Archives
61
66
 
@@ -125,6 +130,13 @@ All routes preserve `wx` semantics and the same source/target identity and
125
130
  SHA-256 fencing. Native hashing and Linux whole-file copying run on N-API async
126
131
  workers rather than the JavaScript event loop.
127
132
 
133
+ Linux range copying confirms every zero-byte result with a positioned source
134
+ read at the current transfer offset, including after earlier calls copied data.
135
+ If readable bytes remain, automatic Root copying resumes its byte loop from
136
+ that offset; exclusive publication removes its partial target before retrying
137
+ the guarded byte-copy fallback. EOF checks preserve descriptor cursors and do
138
+ not bypass the byte limit.
139
+
128
140
  ## Mode semantics
129
141
 
130
142
  | Mode | Native loading | Fallback |
@@ -133,6 +145,10 @@ workers rather than the JavaScript event loop.
133
145
  | `require` | Try once, cache the result | Throw `FsSafeError("helper-unavailable")` |
134
146
  | `off` | Never attempt a binding load | Always use guarded JavaScript |
135
147
 
148
+ `sha256FileSync()` is a synchronous Node implementation in all three modes and
149
+ does not load the binding. Use asynchronous `sha256File()` for native hashing
150
+ and cancellation that can respond while JavaScript callbacks run.
151
+
136
152
  Features without a safe JavaScript implementation, including zstd/bzip2 TAR,
137
153
  Windows private-directory creation, and [retained-directory staging](staged-file.md),
138
154
  fail with `helper-unavailable` when native support is absent or off. Staging
@@ -178,7 +194,7 @@ remain TypeScript-owned. What changes is the syscall strength or availability:
178
194
  | Zstd/bzip2 TAR | Supported. | Unsupported; typed `helper-unavailable`. |
179
195
  | Publication copy | Clone, Linux `copy_file_range`, async native SHA-256. | Exclusive `wx` byte loop and Node SHA-256 with the same content/identity fences. |
180
196
  | `rename-noreplace` | Atomic platform no-replace rename. | Unsupported; no emulation by check-then-rename. |
181
- | Windows DACL read | Direct `GetSecurityInfo`; the public facts API exposes ordered basic allow/deny ACE SIDs, masks, and decoded flags without trust policy. | Structured .NET owner/DACL inspection for coarse permission checks; the public raw ACE facts API remains native-only. |
197
+ | Windows DACL read | Direct `GetSecurityInfo`; the public facts API exposes ordered basic allow/deny ACE SIDs, masks, and decoded flags without trust policy. Secure-file reads query the borrowed open descriptor and compare its 32-bit volume serial and 64-bit file-index projection with Node's bigint receipt. | Structured .NET owner/DACL inspection remains available to standalone pathname reporting. Secure-file reads fail closed without the descriptor capability. |
182
198
  | Windows private directory | Creation-time protected DACL. | Unsupported; no weaker pathname-only substitute. |
183
199
 
184
200
  Use `off` in CI to keep the fallback contract exercised. Use `require` when a
package/docs/output.md CHANGED
@@ -35,6 +35,7 @@ type ExternalFileWriteOptions<T = void> = {
35
35
  maxBytes?: number;
36
36
  mode?: number;
37
37
  staging?: "workspace" | "sibling"; // default: "workspace"
38
+ producerIsolation?: "private-directory"; // opt-in for sibling staging
38
39
  fallbackFileName?: string; // safe staged-name fallback
39
40
  };
40
41
 
@@ -60,7 +61,7 @@ hazards but does not trim Windows-normalized trailing dots or spaces; reject or
60
61
  rewrite those when cross-platform filename uniqueness matters.
61
62
  `staging: "workspace"` passes the sanitized basename to the producer.
62
63
  `staging: "sibling"` embeds that basename in its randomized temporary name.
63
- When the complete temporary component would exceed 255 bytes under NFC or NFD,
64
+ When the complete temporary component would exceed 255 bytes as written or under NFC or NFD,
64
65
  only the embedded tail is shortened, preserving its extension when possible;
65
66
  short callback paths remain unchanged. The final target and returned `path` use
66
67
  the destination basename, sanitized
@@ -76,8 +77,8 @@ the temp and destination filesystems may differ, or when an externally produced
76
77
  partial file must never appear in the destination directory. The final target
77
78
  still appears only after guarded finalization.
78
79
 
79
- `staging: "sibling"` gives the producer a randomized temp path in the target
80
- directory. Choose it only when that directory itself is the approved writable
80
+ By default, `staging: "sibling"` gives the producer a randomized temp path in
81
+ the target directory. Choose it only when that directory itself is the approved writable
81
82
  boundary and same-filesystem atomic replacement is required. After the callback
82
83
  returns, fs-safe pins and validates the staged regular file, rejects hardlinks
83
84
  and size-limit violations, applies `mode`, fsyncs it, and atomically renames it
@@ -91,11 +92,36 @@ cleanup retry.
91
92
  Sibling staging shares the [callback sibling owner](temp.md#sibling-temp-writes):
92
93
  it checks exact pre-open, descriptor, and current-path identities, retains the
93
94
  descriptor through publication, and never chmods or reads a replacement by path.
94
- Cleanup preserves unverified paths, including partial output when the callback
95
- throws before admission. Native-off and Windows operation remain supported with
96
- the platform limits and non-atomic rename/unlink identity checks described there.
95
+ Without producer isolation, cleanup preserves unverified paths, including partial
96
+ output when the callback throws before admission. Native-off and Windows
97
+ operation remain supported with the platform limits and non-atomic
98
+ rename/unlink identity checks described there.
97
99
  When `mode` is omitted, output-sibling staging preserves the producer's mode.
98
100
 
101
+ Add `producerIsolation: "private-directory"` to sibling staging when the
102
+ producer can leave partial output before throwing. It receives an initially
103
+ absent file path inside a private child workspace under the target parent, on
104
+ the target filesystem. Directory cleanup ownership is captured before the
105
+ callback. A callback exception triggers owned workspace cleanup, including
106
+ partial output, subject to directory identity checks and I/O failures.
107
+ After success, `Root.move` checks source aliases and moves the output to the
108
+ ordinary sibling path; an escaping symlink can fail with `path-alias` here.
109
+ Rejected output still inside the workspace follows its cleanup contract. Once
110
+ output moves to the sibling path, the existing unadmitted-file retention and
111
+ single-link regular-file admission, mode, file sync, and final rename rules apply.
112
+
113
+ Exact bigint parent and workspace identities are rechecked before moving
114
+ output to the sibling path to reject observed replacements. Cleanup uses the
115
+ existing [`withTempFile` ownership contract](temp.md#withtempfile). A moved or replaced parent or workspace can
116
+ leave original or replacement paths behind; the option does not promise
117
+ cleanup through a retained directory after a rename. The existing Windows,
118
+ native-off, and JavaScript guard limitations remain, with no additional
119
+ permissions or durability guarantee. See the [producer-isolation contract](temp.md#sibling-temp-writes)
120
+ for cleanup and pathname-race details. The option affects only `staging: "sibling"`;
121
+ with `staging: "workspace"`, it is redundant and harmless because the producer
122
+ already uses a private workspace. Omitting it leaves both staging defaults
123
+ unchanged.
124
+
99
125
  ## Why not pass the final path to the library?
100
126
 
101
127
  If a target parent can be swapped after validation, handing an external library
@@ -0,0 +1,64 @@
1
+ # Path case probing
2
+
3
+ `probePathCaseInsensitiveSync()` observes whether a path's lookup location
4
+ folds ASCII case. It returns `true`, `false`, or `undefined` when the observation
5
+ cannot establish an answer. It does not infer a filesystem property from the
6
+ operating system or cache its result.
7
+
8
+ ```ts
9
+ import { probePathCaseInsensitiveSync } from "@openclaw/fs-safe/advanced";
10
+
11
+ const insensitive = probePathCaseInsensitiveSync("/srv/data/future.json", {
12
+ allowTemporaryProbe: false,
13
+ });
14
+ if (insensitive === undefined) {
15
+ // The application decides how to handle an unavailable observation.
16
+ }
17
+ ```
18
+
19
+ ## Lookup location
20
+
21
+ The input is resolved with Node's `path.resolve()`. Existing targets, including
22
+ directories and final symlinks, are first compared by basename in their parent.
23
+ The probe does not follow a final symlink to decide the target's case behavior.
24
+ Parent aliases are followed. For a missing path, it walks to the nearest existing
25
+ directory whose metadata can be read, without creating the missing directories.
26
+ An unreadable directory listing returns `undefined`.
27
+
28
+ The probe checks existing directory entries before considering a temporary
29
+ file. Separately listed case variants count as distinct entries even when they
30
+ are hardlinks to the same inode. Identity comparisons retain bigint precision;
31
+ unknown Windows identities do not count as matches. The original entry is
32
+ rechecked after looking up its case variant, so its disappearance or replacement
33
+ invalidates the observation. A detected directory replacement also returns
34
+ `undefined`.
35
+
36
+ ## Temporary probes and cleanup
37
+
38
+ `allowTemporaryProbe` defaults to `true`. When existing entries give no answer,
39
+ the helper exclusively creates one empty `.fs-safe-case-probe-*` file in the
40
+ selected directory at mode `0o600`. It uses the existing temporary-file owner
41
+ to retain the descriptor and exact cleanup identity. Successful ordinary
42
+ completion removes the probe and closes the descriptor.
43
+
44
+ Set `allowTemporaryProbe: false` for strictly read-only observation. In that
45
+ mode an empty directory, or one with no useful ASCII-case names, returns
46
+ `undefined` without creating a temporary file. Temporary probing can change
47
+ directory timestamps and trigger filesystem watchers even when cleanup succeeds.
48
+
49
+ Operational failures, unverified identities, changed entries, and cleanup
50
+ failures return `undefined`. A substituted or hardlinked temporary entry is
51
+ preserved. When cleanup fails operationally, the existing owner retains its
52
+ identity-bound process-exit retry. A creation whose identity cannot be obtained
53
+ may leave an empty file; the helper never guesses cleanup ownership. Therefore
54
+ `undefined` does not promise that no temporary artifact remains.
55
+
56
+ ## Limits
57
+
58
+ This is a local ASCII-case observation, not a Unicode-normalization test,
59
+ filesystem-wide guarantee, lock, or authorization receipt. Directory enumeration
60
+ and metadata lookups are separate operations. Concurrent changes can invalidate
61
+ or immediately stale a result, and the final identity check and unlink are not
62
+ an atomic conditional deletion. Use temporary probing only where creating a
63
+ temporary file is permitted. The caller retains any admission, serialization,
64
+ fallback, or later mutation policy.
@@ -1,6 +1,6 @@
1
1
  # pathScope()
2
2
 
3
- `pathScope()` is an advanced helper with the same boundary semantics as `root()`, but it operates on **absolute paths** the caller already trusts and returns plain `{ ok, path }` results instead of throwing. Use it when you want the boundary check up front before handing an absolute path to another library.
3
+ `pathScope()` prepares absolute paths and returns plain `{ ok, path }` results. `resolve()` and `resolveAll()` check lexical containment without touching the filesystem; `existing()`, `files()`, and `writable()` add the filesystem checks described below. Use it to prepare paths before handing them to another library, whose file-opening and mutation behavior still applies.
4
4
 
5
5
  ```ts
6
6
  import { pathScope } from "@openclaw/fs-safe/advanced";
package/docs/path.md CHANGED
@@ -36,7 +36,7 @@ isPathInside("/srv/uploads", "/srv/uploads-other/x"); // false
36
36
  isPathInside("/srv/uploads", "/srv/uploads"); // true (root itself counts)
37
37
  ```
38
38
 
39
- The check is platform-aware: on Windows, paths are normalized for case and separator before comparison.
39
+ The check is platform-aware: on Windows, paths are normalized for case and separator before comparison. This is deliberately a string-only, lexical answer; case folding does not prove that two differently cased prefixes name the same directory on a case-sensitive Windows directory. Root operations perform their own filesystem-identity admission when containment depends on case folding.
40
40
 
41
41
  ### `isPathInsideWithRealpath(rootDir, target, opts?)`
42
42
 
@@ -42,7 +42,7 @@ POSIX remediation strings shell-quote paths with whitespace or metacharacters
42
42
  and protect option-like paths with `--`, so they can be presented as commands
43
43
  without letting the inspected pathname add shell syntax.
44
44
 
45
- `inspectPathPermissions()` follows symlink targets for the effective mode but tells you whether the original path was a symlink. On POSIX it reports owner/group/world bits. On Windows it delegates to the ACL helpers below and also reports `ownerSid` plus `ownerTrusted` when ownership can be verified. `ownerTrusted` is true only for a local volume owned by the current user, LocalSystem, or built-in Administrators; remote filesystems fail closed. Secure reads and callers that protect credential-bearing execution require `ownerTrusted === true`.
45
+ `inspectPathPermissions()` follows symlink targets for the effective mode but tells you whether the original path was a symlink. On POSIX it reports owner/group/world bits. On Windows it delegates to the ACL helpers below and also reports `ownerSid` plus `ownerTrusted` when ownership can be verified. `ownerTrusted` is true only for a local volume owned by the current user, LocalSystem, or built-in Administrators; remote filesystems fail closed. This remains a pathname reporting API with the fallbacks described below. `readSecureFile()` does not use those pathname fallbacks on Windows: it requires descriptor-bound native owner/DACL facts for the exact handle it reads.
46
46
 
47
47
  ## Advanced Windows ACL helpers
48
48
 
@@ -71,8 +71,17 @@ resolveWindowsUserPrincipal(env);
71
71
  The fallback Windows inspector reads the owner and DACL together through one
72
72
  built-in Windows PowerShell/.NET query. It returns canonical SIDs and numeric
73
73
  access masks, so Unicode paths and account names do not pass through lossy
74
- console display text. `inspectWindowsAcl()` uses the same query and returns
75
- canonical SIDs in its `principal` fields, with normalized rights tokens.
74
+ console display text. `inspectWindowsAcl()` uses native descriptor facts for
75
+ complete local ACLs with nonzero inherited ACEs (or empty/null DACLs) when the
76
+ optional Windows binding is available. It applies
77
+ the same classifier to native facts and the fallback query, returning canonical
78
+ SIDs in its `principal` fields with normalized rights tokens. Explicit `env` or
79
+ `exec` options retain the query path. Disabled or unavailable native helpers,
80
+ remote or incomplete descriptors, leaf symbolic links, and native query errors
81
+ use the fallback. Explicit ACEs and zero-mask entries also retain the query so
82
+ .NET continues to own its ACE ordering and normalization.
83
+ Structured ACLs containing only canonical SIDs are classified directly from
84
+ the current-user SID without requiring a separate account-name lookup.
76
85
  The advanced options retain `currentUserSid` as an explicit classification
77
86
  override and `principalTranslationFailed: true` as an immediate unverified
78
87
  result. The optional `principalSids` translation cache is still accepted but
@@ -169,6 +178,31 @@ await openSqlite(path.join(sqliteDirectory, "sessions.sqlite"));
169
178
  On Windows with native support, this creates the directory and applies a
170
179
  protected owner + LocalSystem + Administrators full-control DACL directly with
171
180
  an atomic security descriptor; no PowerShell or `icacls` process is launched.
181
+ The native operation retains the parent and exact created-directory handles
182
+ through ACL and final pathname validation. If validation fails, it attempts only
183
+ nonrecursive deletion through the created handle, preserving any pathname
184
+ replacement. If cleanup also fails, the error retains the original failure and
185
+ includes the cleanup failure.
186
+
187
+ Directory association checks compare the complete 64-bit volume serial and
188
+ 128-bit `FILE_ID_INFO` identity, including on ReFS. If that identity class is
189
+ unavailable, the operation fails closed without a narrower file-index fallback.
190
+ Validation confirms that the created directory is local, its DACL is protected
191
+ from inheritance, and its final public pathname opens the same local directory.
192
+
193
+ This is a point-in-time pathname association check. The function closes its
194
+ handles before returning; callers must keep the pathname's ancestry trusted
195
+ during subsequent use, including opening SQLite databases in the example above.
196
+ The immediate parent and final directory must not be reparse points. Earlier
197
+ ancestor reparse points can be followed; this API does not reject every reparse
198
+ point in the full ancestry.
199
+
200
+ Path components ending in a space or period are rejected before filesystem
201
+ operations to avoid differing Win32 and native pathname interpretations. This
202
+ also rejects explicit `.` and `..` components, including spellings such as
203
+ `.\private` and `parent\..\private`, as a compatibility restriction. Simple
204
+ relative names without these components remain supported.
205
+
172
206
  This API is Windows-only and native-only; it fails closed with
173
207
  `FsSafeError("helper-unavailable")` on other platforms, when native mode is off,
174
208
  or when the binding is unavailable. POSIX callers should create private
@@ -26,6 +26,11 @@ deprecated native-configuration bridge retains the `FsSafePythonConfig` type.
26
26
 
27
27
  ## `path` and `advanced`
28
28
 
29
+ `safePathSegmentHashedV2` encodes every trimmed install ID with domain-separated
30
+ SHA-256 into a fixed lowercase segment. The legacy `safePathSegmentHashed` keeps
31
+ its existing output but can alias distinct IDs. See [install paths](install-path.md)
32
+ for the exact encoding and migration contract.
33
+
29
34
  The lexical path surface additionally exports `isNodeError`,
30
35
  `isPathRelativeEscape`, `normalizeWindowsPathForComparison`,
31
36
  `resolveSafeRelativePath`, `splitSafeRelativePath`, and
@@ -38,6 +43,33 @@ The advanced root-file primitive exports `OpenRootFileParams`,
38
43
  `RootFileOpenFailureReason`. These are composition types for callers building
39
44
  their own pinned-open flow, not substitutes for the higher-level `Root` verbs.
40
45
 
46
+ `copyFileHandle` and `CopyFileHandleOptions` transfer bytes between already-open
47
+ regular files without taking over their cursors, lifetime, or publication.
48
+ See [borrowed-handle transfers](copy.md#borrowed-filehandle-transfers).
49
+
50
+ `readDirectoryIdentity`, `assertDirectoryIdentitySync`, and `DirectoryIdentity`
51
+ provide exact directory observations without owning a descriptor or a mutation.
52
+ The assertion accepts an observed path and optional expected canonical path;
53
+ see [directory identity](directory-identity.md).
54
+
55
+ `overwriteFileHandle` and `OverwriteFileHandleOptions` provide in-place byte
56
+ replacement through a borrowed regular-file handle. Its once-only `beforeWrite`
57
+ callback admits the complete write and any required best-effort rollback after
58
+ prefix preparation. See [in-place writes](in-place-write.md).
59
+
60
+ `probePathCaseInsensitiveSync` and `ProbePathCaseOptions` are advanced exports
61
+ for local ASCII-case observations. An unavailable answer remains `undefined`;
62
+ the caller selects any fallback. See [path case probing](path-case.md).
63
+
64
+ ## Guest source
65
+
66
+ `@openclaw/fs-safe/guest` exports `GUEST_FILESYSTEM_PYTHON`,
67
+ `GUEST_FILESYSTEM_CREATE_EXISTS_EXIT_CODE`,
68
+ `GUEST_FILESYSTEM_READ_NOT_FOUND_EXIT_CODE`, and
69
+ `GUEST_FILESYSTEM_RENAME_NO_REPLACE_PYTHON`. These are source and protocol
70
+ constants; the caller launches the Python guest and owns authorization and
71
+ transport lifetime. See the [guest protocol](guest.md).
72
+
41
73
  ## `json` and `store`
42
74
 
43
75
  Standalone structured reads use `ReadJsonOptions`, `ReadRootJsonSyncOptions`,
@@ -89,8 +121,10 @@ The durability surface also exports the synchronous strict
89
121
  `EnsureDurableDirectoryOptions`, `PublishFileExclusiveResult`,
90
122
  `PublishFileExclusiveStrategy`, `PublishFileExclusiveCleanup`,
91
123
  `PublishFileExclusiveFailurePhase`,
92
- `PublishFileExclusiveDirectorySyncFailure`, `Sha256FileInput`, and
93
- `Sha256FileResult`.
124
+ `PublishFileExclusiveDirectorySyncFailure`, `Sha256FileInput`,
125
+ `Sha256FileSyncInput`, `Sha256FileOptions`, and `Sha256FileResult`.
126
+ `sha256FileSync()` provides synchronous pathname or borrowed-descriptor hashing
127
+ with the same byte-budget and digest-result contracts as `sha256File()`.
94
128
 
95
129
  ## Archives
96
130