@openclaw/fs-safe 0.5.1 → 0.5.3

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 (231) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +19 -6
  3. package/dist/absolute-path.d.ts.map +1 -1
  4. package/dist/absolute-path.js +20 -12
  5. package/dist/archive-entry.d.ts +1 -0
  6. package/dist/archive-entry.d.ts.map +1 -1
  7. package/dist/archive-entry.js +37 -6
  8. package/dist/archive-errors.d.ts +1 -0
  9. package/dist/archive-errors.d.ts.map +1 -1
  10. package/dist/archive-errors.js +4 -0
  11. package/dist/archive-input.d.ts +7 -0
  12. package/dist/archive-input.d.ts.map +1 -1
  13. package/dist/archive-input.js +11 -1
  14. package/dist/archive-limits.js +1 -1
  15. package/dist/archive-native.d.ts.map +1 -1
  16. package/dist/archive-native.js +12 -6
  17. package/dist/archive-policy.d.ts.map +1 -1
  18. package/dist/archive-policy.js +2 -1
  19. package/dist/archive-read.d.ts.map +1 -1
  20. package/dist/archive-read.js +62 -37
  21. package/dist/archive-staging.d.ts.map +1 -1
  22. package/dist/archive-staging.js +3 -2
  23. package/dist/archive-tar-meta.d.ts.map +1 -1
  24. package/dist/archive-tar-meta.js +15 -1
  25. package/dist/archive-tar-runtime.d.ts +1 -0
  26. package/dist/archive-tar-runtime.d.ts.map +1 -1
  27. package/dist/archive-tar-runtime.js +11 -0
  28. package/dist/archive-tar.d.ts.map +1 -1
  29. package/dist/archive-tar.js +8 -4
  30. package/dist/archive-zip-entry.d.ts +7 -1
  31. package/dist/archive-zip-entry.d.ts.map +1 -1
  32. package/dist/archive-zip-entry.js +11 -1
  33. package/dist/archive-zip-integrity.d.ts +5 -0
  34. package/dist/archive-zip-integrity.d.ts.map +1 -0
  35. package/dist/archive-zip-integrity.js +54 -0
  36. package/dist/archive-zip-preflight.d.ts.map +1 -1
  37. package/dist/archive-zip-preflight.js +12 -1
  38. package/dist/archive.d.ts.map +1 -1
  39. package/dist/archive.js +9 -5
  40. package/dist/bounded-read-stream.d.ts.map +1 -1
  41. package/dist/bounded-read-stream.js +13 -2
  42. package/dist/deny-mutations.d.ts +1 -0
  43. package/dist/deny-mutations.d.ts.map +1 -1
  44. package/dist/deny-mutations.js +6 -39
  45. package/dist/device-path.d.ts +1 -0
  46. package/dist/device-path.d.ts.map +1 -1
  47. package/dist/device-path.js +6 -6
  48. package/dist/directory-guard.d.ts.map +1 -1
  49. package/dist/directory-guard.js +5 -4
  50. package/dist/error-detail.d.ts +2 -0
  51. package/dist/error-detail.d.ts.map +1 -0
  52. package/dist/error-detail.js +4 -0
  53. package/dist/errors.d.ts +1 -1
  54. package/dist/errors.d.ts.map +1 -1
  55. package/dist/errors.js +4 -0
  56. package/dist/file-hash.d.ts.map +1 -1
  57. package/dist/file-hash.js +4 -1
  58. package/dist/file-lock-sync.d.ts.map +1 -1
  59. package/dist/file-lock-sync.js +88 -35
  60. package/dist/file-store-boundary.d.ts +6 -0
  61. package/dist/file-store-boundary.d.ts.map +1 -1
  62. package/dist/file-store-boundary.js +20 -5
  63. package/dist/file-store-sync-write.d.ts +9 -0
  64. package/dist/file-store-sync-write.d.ts.map +1 -0
  65. package/dist/file-store-sync-write.js +97 -0
  66. package/dist/file-store.d.ts.map +1 -1
  67. package/dist/file-store.js +62 -139
  68. package/dist/filename.d.ts.map +1 -1
  69. package/dist/filename.js +27 -0
  70. package/dist/guarded-mkdir.d.ts.map +1 -1
  71. package/dist/guarded-mkdir.js +5 -6
  72. package/dist/install-path.js +1 -1
  73. package/dist/json-document-store.d.ts.map +1 -1
  74. package/dist/json-document-store.js +19 -13
  75. package/dist/json.d.ts.map +1 -1
  76. package/dist/json.js +1 -3
  77. package/dist/local-file-access.d.ts +3 -2
  78. package/dist/local-file-access.d.ts.map +1 -1
  79. package/dist/local-file-access.js +12 -6
  80. package/dist/local-roots.d.ts.map +1 -1
  81. package/dist/local-roots.js +35 -30
  82. package/dist/move-path.d.ts.map +1 -1
  83. package/dist/move-path.js +51 -12
  84. package/dist/native/darwin-arm64/fs-safe-native.node +0 -0
  85. package/dist/native/darwin-x64/fs-safe-native.node +0 -0
  86. package/dist/native/linux-arm64-gnu/fs-safe-native.node +0 -0
  87. package/dist/native/linux-arm64-musl/fs-safe-native.node +0 -0
  88. package/dist/native/linux-x64-gnu/fs-safe-native.node +0 -0
  89. package/dist/native/linux-x64-musl/fs-safe-native.node +0 -0
  90. package/dist/native/win32-x64-msvc/fs-safe-native.node +0 -0
  91. package/dist/native-binding.d.ts +1 -0
  92. package/dist/native-binding.d.ts.map +1 -1
  93. package/dist/native-operations.d.ts.map +1 -1
  94. package/dist/native-operations.js +14 -1
  95. package/dist/native-pinned-write.d.ts.map +1 -1
  96. package/dist/native-pinned-write.js +44 -17
  97. package/dist/output-sibling.d.ts.map +1 -1
  98. package/dist/output-sibling.js +6 -5
  99. package/dist/path-policy.d.ts.map +1 -1
  100. package/dist/path-policy.js +1 -7
  101. package/dist/path.d.ts.map +1 -1
  102. package/dist/path.js +4 -0
  103. package/dist/permission-exec.d.ts +6 -0
  104. package/dist/permission-exec.d.ts.map +1 -0
  105. package/dist/permission-exec.js +28 -0
  106. package/dist/permissions-windows.d.ts +56 -0
  107. package/dist/permissions-windows.d.ts.map +1 -0
  108. package/dist/permissions-windows.js +285 -0
  109. package/dist/permissions.d.ts +3 -46
  110. package/dist/permissions.d.ts.map +1 -1
  111. package/dist/permissions.js +17 -328
  112. package/dist/pinned-open.d.ts.map +1 -1
  113. package/dist/pinned-open.js +21 -4
  114. package/dist/pinned-write.js +2 -1
  115. package/dist/private-temp-workspace.d.ts.map +1 -1
  116. package/dist/private-temp-workspace.js +45 -4
  117. package/dist/read-error.d.ts +2 -0
  118. package/dist/read-error.d.ts.map +1 -0
  119. package/dist/read-error.js +11 -0
  120. package/dist/regular-file.d.ts.map +1 -1
  121. package/dist/regular-file.js +21 -2
  122. package/dist/replace-directory.d.ts.map +1 -1
  123. package/dist/replace-directory.js +10 -1
  124. package/dist/replace-file-copy-fallback.d.ts +3 -0
  125. package/dist/replace-file-copy-fallback.d.ts.map +1 -1
  126. package/dist/replace-file-copy-fallback.js +18 -11
  127. package/dist/replace-file-descriptor.d.ts +33 -0
  128. package/dist/replace-file-descriptor.d.ts.map +1 -0
  129. package/dist/replace-file-descriptor.js +96 -0
  130. package/dist/replace-file.d.ts +9 -2
  131. package/dist/replace-file.d.ts.map +1 -1
  132. package/dist/replace-file.js +64 -72
  133. package/dist/root-context.d.ts +8 -0
  134. package/dist/root-context.d.ts.map +1 -1
  135. package/dist/root-context.js +43 -2
  136. package/dist/root-errors.d.ts +7 -0
  137. package/dist/root-errors.d.ts.map +1 -1
  138. package/dist/root-errors.js +46 -1
  139. package/dist/root-file.d.ts +1 -0
  140. package/dist/root-file.d.ts.map +1 -1
  141. package/dist/root-file.js +2 -0
  142. package/dist/root-impl.d.ts.map +1 -1
  143. package/dist/root-impl.js +216 -169
  144. package/dist/root-path-symlink.d.ts +3 -0
  145. package/dist/root-path-symlink.d.ts.map +1 -0
  146. package/dist/root-path-symlink.js +35 -0
  147. package/dist/root-path.d.ts +1 -0
  148. package/dist/root-path.d.ts.map +1 -1
  149. package/dist/root-path.js +129 -190
  150. package/dist/root-walk.d.ts.map +1 -1
  151. package/dist/root-walk.js +15 -0
  152. package/dist/safe-path-segment.d.ts +2 -0
  153. package/dist/safe-path-segment.d.ts.map +1 -1
  154. package/dist/safe-path-segment.js +12 -0
  155. package/dist/secret-file.d.ts.map +1 -1
  156. package/dist/secret-file.js +29 -6
  157. package/dist/secret-read-async.d.ts.map +1 -1
  158. package/dist/secret-read-async.js +5 -1
  159. package/dist/secure-temp-dir.d.ts.map +1 -1
  160. package/dist/secure-temp-dir.js +6 -1
  161. package/dist/short-path.d.ts +2 -0
  162. package/dist/short-path.d.ts.map +1 -0
  163. package/dist/short-path.js +7 -0
  164. package/dist/sidecar-lock-acquire.d.ts +30 -0
  165. package/dist/sidecar-lock-acquire.d.ts.map +1 -0
  166. package/dist/sidecar-lock-acquire.js +260 -0
  167. package/dist/sidecar-lock-policy.d.ts +3 -0
  168. package/dist/sidecar-lock-policy.d.ts.map +1 -1
  169. package/dist/sidecar-lock-policy.js +20 -3
  170. package/dist/sidecar-lock-reclaim.d.ts +5 -1
  171. package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
  172. package/dist/sidecar-lock-reclaim.js +59 -9
  173. package/dist/sidecar-lock.d.ts.map +1 -1
  174. package/dist/sidecar-lock.js +27 -226
  175. package/dist/symlink-parents.d.ts.map +1 -1
  176. package/dist/symlink-parents.js +9 -8
  177. package/dist/temp-target.d.ts.map +1 -1
  178. package/dist/temp-target.js +15 -4
  179. package/dist/test-hooks.d.ts +1 -0
  180. package/dist/test-hooks.d.ts.map +1 -1
  181. package/dist/timing.d.ts +2 -0
  182. package/dist/timing.d.ts.map +1 -1
  183. package/dist/timing.js +9 -0
  184. package/dist/trash.d.ts.map +1 -1
  185. package/dist/trash.js +10 -1
  186. package/dist/walk.d.ts.map +1 -1
  187. package/dist/walk.js +15 -0
  188. package/docs/advanced.md +7 -1
  189. package/docs/archive.md +27 -20
  190. package/docs/assets/readme-banner.jpg +0 -0
  191. package/docs/atomic.md +48 -19
  192. package/docs/errors.md +34 -8
  193. package/docs/file-store.md +14 -1
  194. package/docs/filename.md +17 -16
  195. package/docs/index.md +1 -0
  196. package/docs/install-path.md +9 -6
  197. package/docs/json.md +8 -2
  198. package/docs/local-roots.md +79 -103
  199. package/docs/native-helper.md +11 -5
  200. package/docs/native.md +2 -2
  201. package/docs/output.md +8 -3
  202. package/docs/path-scope.md +4 -1
  203. package/docs/path.md +7 -3
  204. package/docs/permissions.md +8 -0
  205. package/docs/public-api.md +109 -0
  206. package/docs/quickstart.md +6 -2
  207. package/docs/reading.md +13 -10
  208. package/docs/root.md +26 -4
  209. package/docs/secret-file.md +21 -4
  210. package/docs/security-model.md +21 -4
  211. package/docs/sidecar-lock.md +15 -5
  212. package/docs/temp.md +42 -5
  213. package/docs/test-hooks.md +2 -0
  214. package/docs/types.md +32 -15
  215. package/docs/writing.md +25 -12
  216. package/package.json +15 -4
  217. package/dist/archive-file-io.d.ts +0 -9
  218. package/dist/archive-file-io.d.ts.map +0 -1
  219. package/dist/archive-file-io.js +0 -11
  220. package/dist/file-store-source.d.ts +0 -5
  221. package/dist/file-store-source.d.ts.map +0 -1
  222. package/dist/file-store-source.js +0 -30
  223. package/dist/file-url.d.ts +0 -2
  224. package/dist/file-url.d.ts.map +0 -1
  225. package/dist/file-url.js +0 -1
  226. package/dist/path-stat.d.ts +0 -4
  227. package/dist/path-stat.d.ts.map +0 -1
  228. package/dist/path-stat.js +0 -15
  229. package/dist/windows-permissions-native.d.ts +0 -9
  230. package/dist/windows-permissions-native.d.ts.map +0 -1
  231. package/dist/windows-permissions-native.js +0 -31
package/docs/advanced.md CHANGED
@@ -56,7 +56,7 @@ Operational filesystem failures such as permissions or I/O errors are rethrown.
56
56
  | Export | Page | Notes |
57
57
  |---|---|---|
58
58
  | `readFileDescriptorBounded`, `readFileDescriptorBoundedSync`, `readFileHandleBounded` | – | Incremental whole-file reads for already-open descriptors/handles. They consume at most `maxBytes + 1`, do not close the input, and throw `FsSafeError("too-large")` on overflow. |
59
- | `openRootFile`, `openRootFileSync`, `canUseRootFileOpen`, `matchRootFileOpenFailure`, related types | – | Low-level no-follow open routed through the root-file path. |
59
+ | `openRootFile`, `openRootFileSync`, `canUseRootFileOpen`, `matchRootFileOpenFailure`, related types | – | Low-level root-bounded open; rejects every symlink component by default, with `rejectSymlinks: false` for explicit in-root following. |
60
60
  | `appendRegularFile`, `appendRegularFileSync`, `readRegularFile`, `readRegularFileSync`, `statRegularFile`, `statRegularFileSync`, `resolveRegularFileAppendFlags`, `AppendRegularFileOptions`, `RegularFileStatResult` | [regular-file.md](regular-file.md) | Type-checked regular-file I/O. |
61
61
  | `sameFileIdentity`, `FileIdentityStat` | – | Compare two stats for same-inode equality. |
62
62
  | `pathExists`, `pathExistsSync` | – | Boolean existence check that does not throw on `ENOENT`. |
@@ -82,6 +82,12 @@ try {
82
82
  }
83
83
  ```
84
84
 
85
+ For the symlink-parent guards, `allowMissing` defaults to `true` and permits the
86
+ walk to stop only at an actually absent suffix. When an existing non-directory
87
+ component is followed by another segment, both helpers throw
88
+ `FsSafeError("not-file")` before the platform can expose that state as POSIX
89
+ `ENOTDIR` or Windows `ENOENT`.
90
+
85
91
  ### Local roots and file URLs
86
92
 
87
93
  | Export | Page | Notes |
package/docs/archive.md CHANGED
@@ -42,10 +42,10 @@ await extractArchive({
42
42
  ### Parameters
43
43
 
44
44
  ```ts
45
- type ExtractArchiveParams = {
45
+ type ExtractArchiveOptions = {
46
46
  archivePath: string; // absolute path to the archive
47
47
  destDir: string; // absolute destination directory; must already exist
48
- timeoutMs: number; // wall-clock cap; throws on overrun
48
+ timeoutMs: number; // positive wall-clock cap; <= 0/non-finite disables it
49
49
  kind?: ArchiveKind; // "zip" | "tar" | "tar-zstd" | "tar-bzip2"
50
50
  stripComponents?: number; // strip N leading dirs from entry paths
51
51
  tarGzip?: boolean; // when archive is .tar.gz/.tgz
@@ -107,7 +107,7 @@ of leaving a paused parser to drain indefinitely. The native path finishes its
107
107
  bounded manifest read before TypeScript policy evaluation, so a rejected plan
108
108
  never starts the extraction worker.
109
109
 
110
- If `kind` is omitted, the helper calls `resolveArchiveKind(archivePath)` and throws if the extension is not recognized. Pass `kind` explicitly when the archive name doesn't carry the type (e.g. content-addressed names).
110
+ If `kind` is omitted, the helper calls `resolveArchiveKind(archivePath)` and throws if the extension is not recognized. Pass `kind` explicitly when the archive name doesn't carry the type (e.g. content-addressed names). A positive finite `timeoutMs` is a wall-clock budget; zero, negative, `NaN`, and infinity disable the deadline.
111
111
 
112
112
  ### Limits
113
113
 
@@ -122,18 +122,22 @@ type ArchiveExtractLimits = {
122
122
  };
123
123
  ```
124
124
 
125
- Defaults exist for each (`DEFAULT_MAX_ARCHIVE_BYTES_ZIP`, `DEFAULT_MAX_ENTRIES`, `DEFAULT_MAX_EXTRACTED_BYTES`, `DEFAULT_MAX_ENTRY_BYTES`, `DEFAULT_MAX_META_ENTRY_BYTES`, `DEFAULT_MAX_ENTRY_PATH_COMPONENTS`). The path-component default is 256. It is evaluated after `stripComponents` and before TypeScript accepts an entry for either JavaScript or native extraction, so rejected entries cannot cause implicit parent-directory creation. The 1 MiB metadata default matches node-tar's `maxMetaEntrySize`; fs-safe passes the same resolved value to node-tar and the native TAR meter.
125
+ Defaults exist for each (`DEFAULT_MAX_ARCHIVE_BYTES_ZIP`, `DEFAULT_MAX_ENTRIES`, `DEFAULT_MAX_EXTRACTED_BYTES`, `DEFAULT_MAX_ENTRY_BYTES`, `DEFAULT_MAX_META_ENTRY_BYTES`, `DEFAULT_MAX_ENTRY_PATH_COMPONENTS`). An explicit zero remains zero rather than selecting the default. `maxEntries` counts every archive entry, including entries removed by `stripComponents` or an explicit filter. The path-component default is 256. It is evaluated after `stripComponents` and before TypeScript accepts an entry for either JavaScript or native extraction, so rejected entries cannot cause implicit parent-directory creation. The 1 MiB metadata default matches node-tar's `maxMetaEntrySize`; fs-safe passes the same resolved value to node-tar and the native TAR meter.
126
126
 
127
- A limit violation throws `ArchiveLimitError`. The error's code is one of:
127
+ A limit violation throws `ArchiveLimitError`. Its constant and string code are:
128
128
 
129
- ```ts
130
- ARCHIVE_LIMIT_ERROR_CODE.ARCHIVE_SIZE_EXCEEDS_LIMIT
131
- ARCHIVE_LIMIT_ERROR_CODE.ENTRY_COUNT_EXCEEDS_LIMIT
132
- ARCHIVE_LIMIT_ERROR_CODE.EXTRACTED_SIZE_EXCEEDS_LIMIT
133
- ARCHIVE_LIMIT_ERROR_CODE.ENTRY_EXTRACTED_SIZE_EXCEEDS_LIMIT
134
- ARCHIVE_LIMIT_ERROR_CODE.META_ENTRY_SIZE_EXCEEDS_LIMIT
135
- ARCHIVE_LIMIT_ERROR_CODE.ENTRY_PATH_COMPONENTS_EXCEEDS_LIMIT
136
- ```
129
+ | Constant | Code |
130
+ |---|---|
131
+ | `ARCHIVE_SIZE_EXCEEDS_LIMIT` | `archive-size-exceeds-limit` |
132
+ | `ENTRY_COUNT_EXCEEDS_LIMIT` | `archive-entry-count-exceeds-limit` |
133
+ | `EXTRACTED_SIZE_EXCEEDS_LIMIT` | `archive-extracted-size-exceeds-limit` |
134
+ | `ENTRY_EXTRACTED_SIZE_EXCEEDS_LIMIT` | `archive-entry-extracted-size-exceeds-limit` |
135
+ | `META_ENTRY_SIZE_EXCEEDS_LIMIT` | `archive-meta-entry-size-exceeds-limit` |
136
+ | `ENTRY_PATH_COMPONENTS_EXCEEDS_LIMIT` | `archive-entry-path-components-exceeds-limit` |
137
+ | `MANIFEST_SIZE_EXCEEDS_LIMIT` | `archive-manifest-size-exceeds-limit` |
138
+
139
+ `MANIFEST_SIZE_EXCEEDS_LIMIT` is retained in the public compatibility union;
140
+ no current public extractor emits it.
137
141
 
138
142
  Catch and branch on the code to surface a meaningful response to the caller.
139
143
 
@@ -144,10 +148,12 @@ codes remain `"destination-not-directory"`, `"destination-symlink"`, and
144
148
 
145
149
  ## What it defends against
146
150
 
147
- - **Path traversal:** entries with `..`, absolute paths, or Windows drive prefixes are rejected (`ArchiveSecurityError`).
151
+ - **Path traversal:** entries with `..`, absolute paths, NUL bytes, or Windows drive-relative segments such as `C:secret` and `nested/C:secret` are rejected (`ArchiveSecurityError`). On Windows, path segments containing `:` are also rejected as alternate data stream names before either backend writes to the filesystem.
148
152
  - **Symlink/hardlink entries:** rejected by default. Some archives ship symlink/hardlink entries that point outside the destination once resolved; `extractArchive` does not follow them.
153
+ - **Ambiguous output names:** duplicate names and distinct names that collide after `stripComponents`, case normalization, or Unicode normalization are rejected instead of relying on backend- or volume-specific overwrite order.
149
154
  - **TOCTOU during merge:** extraction first writes to a private temp dir, then merges into `destDir` using the same boundary checks as `root().write()`. Destination symlink swaps are checked with the selected platform mechanism; non-Linux routes retain the best-effort race window documented in the [security model](security-model.md#containment-guarantees-by-platform).
150
155
  - **Zip bombs:** `maxExtractedBytes` and `maxEntryBytes` apply to *post-decompression* bytes, so highly-compressed payloads hit the cap before they exhaust disk.
156
+ - **Corrupt ZIP payloads:** streamed output must match both the central-directory CRC and declared uncompressed size before it can leave private staging.
151
157
  - **Slow-loris archives:** `timeoutMs` is a hard wall-clock budget. Extraction is aborted on overrun.
152
158
  - **Metadata bombs:** a fixed-header pass-through reader rejects oversized PAX, GNU long-name, and GNU long-link bodies before either TAR implementation buffers them. It understands octal and base-256 size fields without interpreting metadata content.
153
159
 
@@ -168,7 +174,7 @@ import { resolveArchiveKind, type ArchiveKind } from "@openclaw/fs-safe/archive"
168
174
  const kind = resolveArchiveKind("upload.zip"); // "zip"
169
175
  const tar = resolveArchiveKind("upload.tar.gz"); // "tar"
170
176
  const zstd = resolveArchiveKind("upload.tar.zst"); // "tar-zstd" when native is available
171
- const unknown = resolveArchiveKind("upload.bin"); // undefined
177
+ const unknown = resolveArchiveKind("upload.bin"); // null
172
178
  ```
173
179
 
174
180
  Recognizes:
@@ -178,7 +184,7 @@ Recognizes:
178
184
  - `*.tar.zst`, `*.tar.zstd`, `*.tzst` → `"tar-zstd"` (native only)
179
185
  - `*.tar.bz2`, `*.tbz2`, `*.tbz` → `"tar-bzip2"` (native only)
180
186
 
181
- Returns `undefined` for unknown extensions; check the result before calling
187
+ Returns `null` for unknown extensions; check the result before calling
182
188
  `extractArchive` if the filename is caller-controlled. A recognized zstd or
183
189
  bzip2 TAR extension with no native binding throws the typed
184
190
  `FsSafeError("helper-unavailable")` with installation guidance. This includes
@@ -204,7 +210,8 @@ await extractArchive({
204
210
 
205
211
  `readArchiveEntry(archivePath, entryPath, { maxBytes, kind? })` reads one
206
212
  regular-file entry into a bounded `Buffer` without extracting a tree. It pins
207
- and privately stages the archive input, rejects link and directory entries,
213
+ and privately stages the archive input, rejects link, directory, and duplicate
214
+ entries, verifies ZIP CRC and declared size,
208
215
  and throws `ArchiveLimitError` if decompressed bytes exceed `maxBytes`. ZIP
209
216
  inputs retain the archive subpath's 256 MiB compressed-input ceiling.
210
217
  With a native binding it uses the same Rust decoders as extraction, including
@@ -248,11 +255,11 @@ import {
248
255
  } from "@openclaw/fs-safe/archive";
249
256
  ```
250
257
 
251
- - `validateArchiveEntryPath(raw, opts)` — throws `ArchiveSecurityError` for `..`, absolute, drive-prefixed, or otherwise unsafe entry paths.
252
- - `normalizeArchiveEntryPath(raw)` — POSIX-normalizes the entry path (forward slashes, no `.` segments).
258
+ - `validateArchiveEntryPath(raw, opts)` — throws `ArchiveSecurityError` for `..`, absolute, NUL-containing, drive-relative, or otherwise unsafe entry paths, including alternate data stream names on Windows.
259
+ - `normalizeArchiveEntryPath(raw)` — converts backslashes in the entry path to forward slashes.
253
260
  - `stripArchivePath(entryPath, n)` — strip the leading N path components, returning `null` if not enough remain.
254
261
  - `resolveArchiveOutputPath({ destDir, entryPath })` — combines the entry path with the destination, after validation.
255
- - `isWindowsDrivePath(value)` — detects `C:\…` style entries that should be rejected.
262
+ - `isWindowsDrivePath(value)` — detects drive-relative segments such as `C:secret` or `nested/C:secret` that should be rejected.
256
263
 
257
264
  ## Common patterns
258
265
 
Binary file
package/docs/atomic.md CHANGED
@@ -14,7 +14,9 @@ import {
14
14
 
15
15
  ## `replaceFileAtomic` / `replaceFileAtomicSync`
16
16
 
17
- Write `content` to a sibling temp file in the destination directory, optionally `fsync` the temp file, optionally `fsync` the parent directory after rename, then atomically rename over the destination.
17
+ Write `content` to a sibling temp file in the destination directory, apply the parent-directory and final file modes through verified descriptors, optionally `fsync` the file descriptor, optionally `fsync` the parent directory after rename, then atomically rename over the destination. No permission change follows a caller-supplied pathname.
18
+
19
+ On POSIX, the parent is opened with no-follow and directory-only flags, checked against its pre-open identity, and mode-adjusted through that descriptor. A replacement symlink is rejected rather than followed. If the directory cannot be opened for descriptor access, the operation fails closed instead of retrying by pathname. Windows does not enforce POSIX directory modes and Node cannot consistently open directory descriptors there, so `dirMode` is passed only to `mkdir`; no pathname `chmod` fallback is attempted.
18
20
 
19
21
  Async replacements to the same destination are serialized inside the current process, so two overlapping `replaceFileAtomic()` calls do not interleave their temp-write/rename phases. Use a sidecar lock when multiple processes may write the same target.
20
22
 
@@ -36,18 +38,19 @@ await replaceFileAtomic({
36
38
  type ReplaceFileAtomicOptions = {
37
39
  filePath: string; // destination
38
40
  content: string | Uint8Array;
39
- dirMode?: number; // mode for parent dirs created by the helper
40
- mode?: number; // explicit mode for the new file (e.g. 0o600)
41
- preserveExistingMode?: boolean; // copy mode from existing destination, when present
42
- tempPrefix?: string;
43
- renameMaxRetries?: number;
44
- renameRetryBaseDelayMs?: number;
45
- copyFallbackOnPermissionError?: boolean;
41
+ dirMode?: number; // parent-directory mode (POSIX; default 0o700)
42
+ mode?: number; // new-file mode (default 0o600)
43
+ preserveExistingMode?: boolean; // copy existing mode; default false
44
+ tempPrefix?: string; // default ".fs-safe-replace"
45
+ renameMaxRetries?: number; // EBUSY retries; default 0
46
+ renameRetryBaseDelayMs?: number; // exponential base; default 50
47
+ copyFallbackOnPermissionError?: boolean; // default false
46
48
  copyFallbackRestore?: "restore-original" | "none"; // default: "none"
47
49
  maxRestoreBytes?: number; // required with "restore-original"
48
- destinationHardlinks?: "reject";
49
- syncTempFile?: boolean; // fsync(temp) before rename
50
- syncParentDir?: boolean; // fsync(parent) after rename (POSIX only)
50
+ destinationHardlinks?: "reject"; // default unset (no destination nlink policy)
51
+ syncTempFile?: boolean; // fsync(temp) before rename, or the final file after copy fallback; default false
52
+ syncParentDir?: boolean; // fsync(parent) after rename, POSIX only; default false
53
+ throwOnCleanupError?: boolean; // report temp cleanup failure; default false
51
54
  beforeRename?: (params: { filePath: string; tempPath: string }) => Promise<void>;
52
55
  fileSystem?: ReplaceFileAtomicFileSystem; // injectable fs for tests
53
56
  };
@@ -87,9 +90,10 @@ The default `copyFallbackRestore: "none"` preserves the existing fallback
87
90
  contract: a failed copy can leave a partial destination. For state files where
88
91
  preserving the old bytes is more important, choose `"restore-original"` and set
89
92
  an explicit `maxRestoreBytes` memory budget. If the destination exists, fs-safe
90
- snapshots it through a pinned descriptor, overwrites through that same
91
- descriptor, and synchronizes the result. Any write or sync failure triggers a
92
- restore and another sync through the same descriptor.
93
+ snapshots it through a pinned descriptor, overwrites and mode-adjusts through
94
+ that same descriptor, and synchronizes the result. Any write, mode, or sync
95
+ failure triggers a byte-and-mode restore and another sync through the same
96
+ descriptor.
93
97
 
94
98
  Restore failures are `FsSafeError("helper-failed")` values with typed
95
99
  `details.cleanup` set to `"restored"` or `"restore-failed"`. An original larger
@@ -99,7 +103,10 @@ fallback.
99
103
 
100
104
  ### Sync variant
101
105
 
102
- `replaceFileAtomicSync` accepts the same options shape, with the obvious removal of the async-only hooks. Use it inside synchronous boot paths or test setup code.
106
+ `replaceFileAtomicSync` accepts the same base options, a synchronous
107
+ `beforeRename` callback, and `ReplaceFileAtomicSyncFileSystem`. Use it inside
108
+ synchronous boot paths or test setup code. It returns the same
109
+ `{ method: "rename" | "copy-fallback" }` receipt as the async variant.
103
110
 
104
111
  ## `replaceDirectoryAtomic`
105
112
 
@@ -115,13 +122,17 @@ await replaceDirectoryAtomic({
115
122
  ```
116
123
 
117
124
  The helper renames `targetDir` to a generated backup path, renames `stagedDir → targetDir`, then removes the backup. If the second rename fails, it tries to restore the original target before rethrowing.
125
+ Concurrent replacements of the same resolved target are serialized inside the
126
+ current process so their backup, commit, and cleanup phases cannot interleave.
127
+ `backupPrefix`, when supplied, is sanitized as one path prefix and cannot contain
128
+ path separators or NUL bytes; the generated backup tail is randomized.
118
129
 
119
130
  Use it when callers must see a whole staged tree at the target path. For single-file replacement, `replaceFileAtomic` is the right tool.
120
131
 
121
132
  ## `writeTextAtomic`
122
133
 
123
134
  Atomic UTF-8 text write with the same secure defaults as `writeJson`: sibling
124
- temp file, temp fsync, rename, parent fsync, and final chmod best-effort.
135
+ temp file, descriptor-bound mode setting and fsync, rename, and parent fsync.
125
136
  It delegates to `replaceFileAtomic()` with a smaller call shape. Use it when
126
137
  you do not need replacement hooks such as `beforeRename`, `preserveExistingMode`,
127
138
  or custom copy-fallback policy.
@@ -141,8 +152,8 @@ Options:
141
152
  ```ts
142
153
  type WriteTextAtomicOptions = {
143
154
  mode?: number; // file mode (default 0o600)
144
- dirMode?: number; // mode for parent dirs created on demand
145
- trailingNewline?: boolean; // append "\n" if missing
155
+ dirMode?: number; // parent mode (default 0o777 masked by process umask)
156
+ trailingNewline?: boolean; // append "\n" if missing; default false
146
157
  durable?: boolean; // default true; false skips temp/parent fsync
147
158
  };
148
159
  ```
@@ -157,7 +168,14 @@ Rename a path. If the rename fails with `EXDEV` (cross-device), fall back to
157
168
  copying into a staged sibling path, renaming that staged path into place, and
158
169
  then removing only the source entries that were copied. The fallback avoids
159
170
  buffering regular files into memory and does not tighten the destination parent
160
- directory mode.
171
+ directory mode. Staged file modes are applied through their still-open handles.
172
+ If descriptor-bound mode application fails, the staged path is removed and the
173
+ move fails before publication. A transient staged-path cleanup failure retains
174
+ an identity-bound process-exit cleanup retry.
175
+ On POSIX, staged directory modes are applied through no-follow directory
176
+ descriptors; on Windows, Node cannot portably open those descriptors and no
177
+ pathname `chmod` fallback is attempted, so directory modes remain subject to
178
+ Windows' `mkdir(mode)` behavior.
161
179
 
162
180
  ```ts
163
181
  import { movePathWithCopyFallback } from "@openclaw/fs-safe/atomic";
@@ -212,6 +230,17 @@ await replaceFileAtomic({
212
230
  });
213
231
  ```
214
232
 
233
+ The synchronous injectable interface has one optional descriptor-mode operation:
234
+
235
+ ```ts
236
+ type ReplaceFileAtomicSyncFileSystem = {
237
+ // other required operations omitted
238
+ fchmodSync?: typeof import("node:fs").fchmodSync;
239
+ };
240
+ ```
241
+
242
+ The async interface already requires `open()`, whose `FileHandle` supplies `chmod()`, so injecting `node:fs` or another conforming adapter needs no new async member. On POSIX, that `open()` must support no-follow directory descriptors as Node does. A custom synchronous filesystem that passes `mode`, `dirMode`, or `preserveExistingMode` must supply `fchmodSync`; omission fails before any file or directory is created and never falls back to a pathname `chmod`. Existing synchronous adapters that request none of those options may omit it; their parent is still opened and identity-checked through a no-follow directory descriptor. Injecting plain `node:fs` supports explicit file and directory modes. Older adapter literals may continue to include `chmod` or `chmodSync` for source compatibility, but those operations are ignored. Copy fallback applies the file mode through its pinned destination descriptor as well, preserving exact modes despite the process umask.
243
+
215
244
  ## See also
216
245
 
217
246
  - [`root()`](root.md) — when you want method-style writes with the boundary baked in.
package/docs/errors.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  Every failure that's the library's job to surface lands as an `FsSafeError` with a closed `code` union you can branch on. Catch by code, not by message text — messages may change, codes will not.
4
4
 
5
+ Path and archive-entry details embedded in diagnostics escape control characters
6
+ as `\\uXXXX` sequences. This keeps attacker-controlled names on one log line;
7
+ the escaped message is for diagnosis, not for reconstructing the original path.
8
+
5
9
  ```ts
6
10
  import { FsSafeError, type FsSafeErrorCode } from "@openclaw/fs-safe";
7
11
  ```
@@ -50,8 +54,17 @@ destination.
50
54
 
51
55
  `category` separates caller-policy failures from operational failures:
52
56
 
53
- - `"policy"` — unsafe input or target state, such as `outside-workspace`, `symlink`, `hardlink`, or `too-large`.
54
- - `"operational"` — environment/runtime failures, such as helper startup, platform support, timeout, or unverifiable permissions.
57
+ - `"policy"` — unsafe input or target state rejected by a safety policy, such as `outside-workspace`, `symlink`, `hardlink`, or `too-large`.
58
+ - `"operational"` — routine filesystem outcomes or environment/runtime failures, such as `not-found`, `not-empty`, `not-removable`, helper startup, platform support, timeout, or unverifiable permissions.
59
+
60
+ Routine absence or inability to remove a path does not by itself indicate a
61
+ filesystem boundary violation. Branch on the specific code when the distinction
62
+ between those operational outcomes matters.
63
+
64
+ The operational set is exactly `helper-failed`, `helper-unavailable`,
65
+ `not-empty`, `not-found`, `not-removable`, `permission-unverified`, `read-failed`,
66
+ `timeout`, and `unsupported-platform`. Every other current `FsSafeErrorCode`, including
67
+ `store-reentrant-update`, is categorized as `policy`.
55
68
 
56
69
  ## Code union
57
70
 
@@ -74,7 +87,9 @@ type FsSafeErrorCode =
74
87
  | "path-alias"
75
88
  | "path-mismatch"
76
89
  | "permission-unverified"
90
+ | "read-failed"
77
91
  | "secret-exists"
92
+ | "store-reentrant-update"
78
93
  | "symlink"
79
94
  | "timeout"
80
95
  | "too-large"
@@ -92,9 +107,9 @@ type FsSafeErrorCode =
92
107
  | `helper-failed` | A native mechanism or multi-step operational helper failed. | Inspect `cause` and any operation-specific `details`; retrying may be unsafe if the operation partially completed. |
93
108
  | `helper-unavailable` | Required native binding could not be loaded. | Unsupported platform, missing/incompatible bundled binary, or `FS_SAFE_NATIVE_MODE=require`. `auto` falls back where possible; `require` fails closed. |
94
109
  | `insecure-permissions` | A secure file or path permission check found a mode/ACL that allows broader access than requested. | File or directory is group/world writable/readable; Windows ACL grants broad read. |
95
- | `invalid-path` | Input was empty, contained NUL, was an unparseable URL, or otherwise unusable. | Caller didn't validate input; input was a network path on Windows. |
110
+ | `invalid-path` | Input was empty, contained NUL, was an unparseable URL, or otherwise unusable. | Caller didn't validate input; input was a network path on Windows, a drive-relative segment in a portable relative path or store key, or a leading drive-relative spelling such as `C:name` used as a Root destination. Existing-object Root lookups retain legal POSIX drive-like names. |
96
111
  | `not-empty` | `remove()` on a non-empty directory. | Use `replaceDirectoryAtomic` or remove children first. |
97
- | `not-file` | Read or copy targeted a non-regular file. | Target was a directory, FIFO, socket, device. |
112
+ | `not-file` | Read or copy targeted a non-regular file, or a path walk found a non-directory ancestor. | Target was a directory, FIFO, socket, device, or an existing file was followed by another segment. |
98
113
  | `not-found` | The target does not exist (or its parent does not, with `mkdir: false`). | Typical missing-file case. |
99
114
  | `not-owned` | A secure file owner check failed. | File is owned by another UID. |
100
115
  | `not-removable` | `remove()` couldn't `unlink`/`rmdir` for a reason other than non-empty. | Permissions, device busy, immutable bit. |
@@ -102,11 +117,13 @@ type FsSafeErrorCode =
102
117
  | `path-alias` | A path alias check failed (e.g. canonical-real-path moved out of the root). | Symlink resolution lands outside the root. |
103
118
  | `path-mismatch` | Post-open identity check failed: the opened fd does not match the resolved path. | TOCTOU — something else swapped the path between resolve and open. |
104
119
  | `permission-unverified` | A secure file check could not verify required permissions. | Windows ACL inspection failed; POSIX ownership/mode was unavailable. |
120
+ | `read-failed` | A validated file could not be read because of an operational filesystem or device failure. | I/O error, media failure, or another runtime read failure; inspect `cause`. |
105
121
  | `secret-exists` | `createSecretFileAtomic()` found an existing final path. | First-writer-wins secret creation lost a race or the credential was already initialized. |
122
+ | `store-reentrant-update` | A `JsonStore.update()` callback called `update()` or `updateOr()` for the same canonical store before returning. | Reentrant mutation would deadlock or lose an update; return the complete next value from the outer callback. |
106
123
  | `symlink` | Path component is a symlink, policy is `reject`. | Caller followed a symlink they shouldn't have, or `symlinks: "reject"` is set. |
107
124
  | `timeout` | An operation with a wall-clock budget overran. | Secure file read or timed operation exceeded `timeoutMs`. |
108
125
  | `too-large` | A read or bounded walk exceeded its configured budget. | Caller gave a too-permissive file or traversal limit. |
109
- | `unsupported-platform` | The requested operation is not supported on the current platform. | E.g. POSIX-only helper invoked on Windows. |
126
+ | `unsupported-platform` | Reserved compatibility code for a platform-specific operation. | No current public helper emits this `FsSafeError` code. Platform-specific APIs currently return a typed unsupported result or use `helper-unavailable`; keep the union member when exhaustively switching across supported package versions. |
110
127
 
111
128
  ## Branching
112
129
 
@@ -138,11 +155,13 @@ try {
138
155
  }
139
156
  ```
140
157
 
141
- The compiler will flag missing cases when you exhaust the union — keep your switch up-to-date as the library adds new codes.
158
+ The `default` above deliberately rethrows unknown codes, so it remains safe as
159
+ the union grows. If you want the compiler to flag every newly added code, end
160
+ an exhaustive switch with a `never` assertion instead of a general `default`.
142
161
 
143
162
  ## Distinguishing from `NodeJS.ErrnoException`
144
163
 
145
- Some failures bubble up as native Node errors (e.g. `EACCES`, `EISDIR`, `EBUSY`) when they don't map cleanly to a library code. Inspect both:
164
+ Some failures bubble up as native Node errors (e.g. `EACCES`, `EPERM`, `EISDIR`, `EBUSY`) when they don't map cleanly to a library code. Inspect both:
146
165
 
147
166
  ```ts
148
167
  import { FsSafeError } from "@openclaw/fs-safe";
@@ -154,7 +173,8 @@ try {
154
173
  handleFsSafe(err);
155
174
  return;
156
175
  }
157
- if ((err as NodeJS.ErrnoException).code === "EACCES") {
176
+ const code = (err as NodeJS.ErrnoException).code;
177
+ if (code === "EACCES" || code === "EPERM") {
158
178
  handleAccess();
159
179
  return;
160
180
  }
@@ -164,6 +184,12 @@ try {
164
184
 
165
185
  A common pattern is to wrap your domain code in a single try/catch that maps both shapes to your application's typed error format.
166
186
 
187
+ On Windows, access-denied failures from native root/open filesystem operations
188
+ use `EPERM` to match Node/libuv and the JavaScript fallback. Security-descriptor
189
+ inspection has its own failure surface. Older fs-safe versions could report
190
+ `EACCES` for the same root/open condition, so consumers spanning versions
191
+ should accept both codes.
192
+
167
193
  ## Specialty errors
168
194
 
169
195
  A handful of helpers throw their own typed errors instead of `FsSafeError`:
@@ -43,7 +43,7 @@ type FileStore = {
43
43
  readonly rootDir: string;
44
44
  path(relativePath: string): string;
45
45
  root(): Promise<Root>;
46
- write(rel, data: string | Buffer, options?): Promise<string>;
46
+ write(rel, data: string | Uint8Array, options?): Promise<string>;
47
47
  writeStream(rel, stream: Readable, options?): Promise<string>;
48
48
  copyIn(rel, sourcePath: string, options?): Promise<string>;
49
49
  open(rel, options?): Promise<OpenResult>;
@@ -64,6 +64,15 @@ type FileStore = {
64
64
 
65
65
  `path()` returns the absolute path the store would use, after asserting it stays inside `rootDir`. Useful for logging or for handing to other libraries.
66
66
 
67
+ Every `relativePath` is a portable store key, including keys passed to reads,
68
+ `exists`, and `remove`. A segment with a Windows drive-relative spelling such
69
+ as `C:name` (including an embedded segment such as `a/C:name`) throws
70
+ `invalid-path` on every platform. This prevents a key created on POSIX from
71
+ aliasing a different file when the store is moved to Windows. Colons elsewhere,
72
+ such as the timestamp in `logs/2026-08-02T10:30:00Z.log`, remain valid.
73
+ Keys with surrounding whitespace also throw `invalid-path`; the store never
74
+ silently trims one caller-supplied key onto another key.
75
+
67
76
  `root()` returns a [`Root`](root.md) handle for the same directory when you need the full surface (move, list, mkdir). It's a fresh handle per call and is safe to call frequently.
68
77
 
69
78
  ## Writes
@@ -129,6 +138,10 @@ type FileStoreWriteOptions = {
129
138
  ## Reads
130
139
 
131
140
  `open`, `read`, `readBytes`, `readText`, and `readJson` delegate to a fresh `Root` with `hardlinks: "reject"` and the store's `maxBytes`. Same return shapes as `Root`.
141
+ Async and sync reads both report `not-file` when the key names a directory;
142
+ stable hardlinks and symlinks retain `hardlink` and `symlink`. The `IfExists`
143
+ methods return their nullish result only for `not-found`. Operational filesystem
144
+ read failures use `read-failed` with the Node error retained in `cause`.
132
145
 
133
146
  ## `remove(rel)` / `exists(rel)`
134
147
 
package/docs/filename.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # Filenames
2
2
 
3
- `sanitizeUntrustedFileName(name, fallback)` reduces a filename string from an untrusted source to one portable path segment. Use it as a thin first pass before storing user-supplied names; pair with [`safeDirName`](install-path.md#safedirname) when you need stricter directory-name handling.
3
+ `sanitizeUntrustedFileName(name, fallback)` reduces a filename string from an untrusted source to one traversal-free path segment. Use it as a thin first pass before storing user-supplied names; pair with [`safeDirName`](install-path.md#safedirname) when you need stricter directory-name handling.
4
4
 
5
5
  ```ts
6
- import { sanitizeUntrustedFileName } from "@openclaw/fs-safe";
6
+ import { sanitizeUntrustedFileName } from "@openclaw/fs-safe/advanced";
7
7
 
8
8
  const safe = sanitizeUntrustedFileName(req.body.fileName, "upload");
9
9
  await fs.write(`uploads/${safe}`, body);
@@ -24,9 +24,12 @@ In order:
24
24
  3. **Strip non-portable characters.** C0/C1 controls (`0x00`–`0x1f`, `0x7f`–`0x9f`) and the Windows-invalid set `< > : " / \\ | ? *` are removed on every platform.
25
25
  4. **Trim again.**
26
26
  5. If the result is empty, `"."`, or `".."`, return `fallbackName`.
27
- 6. **Truncate.** If the cleaned segment is longer than 200 characters, take the first 200.
27
+ 6. **Suffix Windows reserved basenames.** Compare the part before the first `.` case-insensitively with the Windows device-name set, including `CON`, `PRN`, `AUX`, `NUL`, `CLOCK$`, `CONIN$`, `CONOUT$`, `COM1..9`, `LPT1..9`, and their superscript `¹`, `²`, and `³` variants. Windows-ignored spaces and dots at the end of that basename do not disguise a device name. A match gains `_` before its extension, preserving the original case and extension on every platform.
28
+ 7. **Truncate.** If the cleaned segment is longer than 200 UTF-16 code units, take up to the first 200 without splitting a valid Unicode surrogate pair.
28
29
 
29
- That's it. The function stays intentionally small: it produces one traversal-free segment whose characters work across the common POSIX and Windows filename surfaces.
30
+ That's it. The function stays intentionally small: it removes traversal and
31
+ the most obvious cross-platform device and character hazards, but it is not a
32
+ complete portable-filename or uniqueness policy.
30
33
 
31
34
  ## Examples
32
35
 
@@ -38,35 +41,33 @@ sanitizeUntrustedFileName("a\u0000b\tc", "upload"); // "abc"
38
41
  sanitizeUntrustedFileName(" ", "fallback"); // "fallback"
39
42
  sanitizeUntrustedFileName(".", "fallback"); // "fallback"
40
43
  sanitizeUntrustedFileName("..", "fallback"); // "fallback"
41
- sanitizeUntrustedFileName("a".repeat(300), "x"); // 200-char "aaa..."
44
+ sanitizeUntrustedFileName("CON", "fallback"); // "CON_"
45
+ sanitizeUntrustedFileName("nul.txt", "fallback"); // "nul_.txt"
46
+ sanitizeUntrustedFileName("aux.c", "fallback"); // "aux_.c"
47
+ sanitizeUntrustedFileName("conin$", "fallback"); // "conin$_"
48
+ sanitizeUntrustedFileName("a".repeat(300), "x"); // 200-char "aaa..."
42
49
  ```
43
50
 
44
51
  ## What it does **not** do
45
52
 
46
53
  The function is deliberately narrow. It will not:
47
54
 
48
- - Reject Windows reserved names (`CON`, `PRN`, `AUX`, `NUL`, `COM1..9`, `LPT1..9`).
49
55
  - Replace leading dots (so a name like `.config` stays hidden on POSIX systems).
50
- - Trim trailing dots or spaces (Windows tolerates them silently).
56
+ - Trim trailing dots. Surrounding spaces are removed by the documented trim
57
+ steps, but Windows-normalized dot/space spellings can still alias; reject or
58
+ rewrite them when Windows portability or cross-platform migration matters.
51
59
  - Add an extension or change case.
52
60
  - Validate file *content*. To enforce an extension allow-list, check after sanitization.
53
61
  - Deduplicate against existing files. Append a random suffix if you need uniqueness.
54
62
 
55
- If your domain needs stricter handling, layer it on top:
56
-
57
- ```ts
58
- const trimmed = sanitizeUntrustedFileName(input, "upload");
59
- const noWindowsReserved = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..+)?$/i.test(trimmed)
60
- ? "upload"
61
- : trimmed;
62
- ```
63
+ Windows reserved basenames are handled by the default portability pass; callers no longer need to layer a separate reserved-name recipe on top.
63
64
 
64
65
  ## Common patterns
65
66
 
66
67
  ### Make a unique filename
67
68
 
68
69
  ```ts
69
- import { sanitizeUntrustedFileName } from "@openclaw/fs-safe";
70
+ import { sanitizeUntrustedFileName } from "@openclaw/fs-safe/advanced";
70
71
  import { randomUUID } from "node:crypto";
71
72
 
72
73
  const base = sanitizeUntrustedFileName(req.body.fileName, "upload");
package/docs/index.md CHANGED
@@ -69,6 +69,7 @@ await fs.remove("notes/archive/today.txt");
69
69
  | [Permissions](permissions.md) | POSIX mode helpers plus Windows ACL inspection, raw owner/ACE facts, remediation, and private-directory creation. |
70
70
  | [`acquireFileLock`](sidecar-lock.md) | Cross-process file lock with retry and fail-closed stale-lock handling. |
71
71
  | [`FsSafeError`](errors.md) | Closed code union (with `policy` / `operational` category) you can branch on. |
72
+ | [Public API inventory](public-api.md) | Complete runtime/type export cross-check against the generated declarations. |
72
73
  | [`pathScope()`](path-scope.md) | Lower-level absolute-path boundary helper; lives behind `@openclaw/fs-safe/advanced`. |
73
74
  | [`@openclaw/fs-safe/advanced`](advanced.md) | Directory of lower-level composition helpers (path scopes, regular-file I/O, install paths, sibling-temp writes, …). |
74
75
  | [`@openclaw/fs-safe/test-hooks`](test-hooks.md) | Test-only injection hooks for reproducing open/lstat races. |
@@ -95,16 +95,19 @@ Returns a directory-safe segment **plus** a short content hash when sanitization
95
95
 
96
96
  ```ts
97
97
  safePathSegmentHashed("plugin-v1"); // "plugin-v1" (unchanged short input)
98
- safePathSegmentHashed("plugin/v1"); // "plugin-v1-3f2a..."
99
- safePathSegmentHashed("plugin\\v1"); // "plugin-v1-91c4..." (different hash; same safe form)
100
- safePathSegmentHashed("Über@"); // "ber-9aae..."
101
- safePathSegmentHashed(""); // "skill" (empty fallback)
102
- safePathSegmentHashed("."); // "skill"
98
+ safePathSegmentHashed("plugin/v1"); // "plugin-v1-d9ef8af2eb"
99
+ safePathSegmentHashed("plugin\\v1"); // "plugin-v1-bed33f465b"
100
+ safePathSegmentHashed("Über@"); // "ber-e392bba2b3"
101
+ safePathSegmentHashed(""); // "skill-e3b0c44298"
102
+ safePathSegmentHashed("."); // "skill-cdb4ee2aea"
103
103
  ```
104
104
 
105
105
  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
106
 
107
- The hash is the first 10 hex chars of `sha256(originalInput)`. It guarantees that two distinct inputs which sanitize to the same string yield distinct outputs.
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.
108
111
 
109
112
  ## Common patterns
110
113
 
package/docs/json.md CHANGED
@@ -133,7 +133,12 @@ where lower latency matters more than crash-durability.
133
133
 
134
134
  ### `writeJsonSync(pathname, data)`
135
135
 
136
- Synchronous variant. Convenience wrapper that uses the sync atomic-write path with sensible defaults.
136
+ Synchronous variant. It pretty-prints with two spaces, appends a newline,
137
+ creates parents at `0o700`, writes at `0o600`, and synchronizes the parent
138
+ directory best-effort. It has no options bag. On `EPERM`/`EEXIST`, its legacy
139
+ compatibility path may replace by copy rather than atomic rename; use the async
140
+ `writeJson()`/`replaceFileAtomic()` surfaces when fallback policy must be
141
+ explicit.
137
142
 
138
143
  ```ts
139
144
  writeJsonSync("./prefs.json", { theme: "dark" });
@@ -187,7 +192,8 @@ const state = await readJsonIfExists<State>("./state.json");
187
192
 
188
193
  | Throw / return | When |
189
194
  |---|---|
190
- | `null` (lenient reads) | File missing or contents are not valid JSON. |
195
+ | `null` from `tryReadJson` / `tryReadJsonSync` | Any read or parse failure. |
196
+ | `null` from `readJsonIfExists` | File is missing; invalid or unreadable input still throws. |
191
197
  | `JsonFileReadError` | `readJson` or `readJsonIfExists` saw unreadable or invalid input. Inspect `cause`. |
192
198
  | Native `NodeJS.ErrnoException` | Lower-level fs errors not wrapped. |
193
199