@openclaw/fs-safe 0.5.1 → 0.5.2

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 (210) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +18 -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 +25 -5
  8. package/dist/archive-input.d.ts +7 -0
  9. package/dist/archive-input.d.ts.map +1 -1
  10. package/dist/archive-input.js +11 -1
  11. package/dist/archive-limits.js +1 -1
  12. package/dist/archive-native.d.ts.map +1 -1
  13. package/dist/archive-native.js +6 -3
  14. package/dist/archive-policy.d.ts.map +1 -1
  15. package/dist/archive-policy.js +2 -1
  16. package/dist/archive-read.d.ts.map +1 -1
  17. package/dist/archive-read.js +30 -12
  18. package/dist/archive-staging.d.ts.map +1 -1
  19. package/dist/archive-staging.js +3 -2
  20. package/dist/archive-tar-meta.d.ts.map +1 -1
  21. package/dist/archive-tar-meta.js +7 -1
  22. package/dist/archive-tar.d.ts.map +1 -1
  23. package/dist/archive-tar.js +7 -4
  24. package/dist/archive-zip-entry.d.ts +1 -0
  25. package/dist/archive-zip-entry.d.ts.map +1 -1
  26. package/dist/archive-zip-integrity.d.ts +4 -0
  27. package/dist/archive-zip-integrity.d.ts.map +1 -0
  28. package/dist/archive-zip-integrity.js +44 -0
  29. package/dist/archive-zip-preflight.d.ts.map +1 -1
  30. package/dist/archive-zip-preflight.js +6 -1
  31. package/dist/archive.d.ts.map +1 -1
  32. package/dist/archive.js +5 -2
  33. package/dist/bounded-read-stream.d.ts.map +1 -1
  34. package/dist/bounded-read-stream.js +13 -2
  35. package/dist/deny-mutations.d.ts +1 -0
  36. package/dist/deny-mutations.d.ts.map +1 -1
  37. package/dist/deny-mutations.js +6 -39
  38. package/dist/device-path.d.ts +1 -0
  39. package/dist/device-path.d.ts.map +1 -1
  40. package/dist/device-path.js +6 -6
  41. package/dist/directory-guard.d.ts.map +1 -1
  42. package/dist/directory-guard.js +5 -4
  43. package/dist/error-detail.d.ts +2 -0
  44. package/dist/error-detail.d.ts.map +1 -0
  45. package/dist/error-detail.js +4 -0
  46. package/dist/errors.d.ts.map +1 -1
  47. package/dist/errors.js +3 -0
  48. package/dist/file-hash.d.ts.map +1 -1
  49. package/dist/file-hash.js +4 -1
  50. package/dist/file-lock-sync.d.ts.map +1 -1
  51. package/dist/file-lock-sync.js +88 -35
  52. package/dist/file-store-boundary.d.ts +6 -0
  53. package/dist/file-store-boundary.d.ts.map +1 -1
  54. package/dist/file-store-boundary.js +20 -5
  55. package/dist/file-store-sync-write.d.ts +9 -0
  56. package/dist/file-store-sync-write.d.ts.map +1 -0
  57. package/dist/file-store-sync-write.js +97 -0
  58. package/dist/file-store.d.ts.map +1 -1
  59. package/dist/file-store.js +36 -128
  60. package/dist/filename.d.ts.map +1 -1
  61. package/dist/filename.js +27 -0
  62. package/dist/guarded-mkdir.d.ts.map +1 -1
  63. package/dist/guarded-mkdir.js +5 -6
  64. package/dist/install-path.js +1 -1
  65. package/dist/json-document-store.d.ts.map +1 -1
  66. package/dist/json-document-store.js +19 -13
  67. package/dist/json.d.ts.map +1 -1
  68. package/dist/json.js +1 -3
  69. package/dist/local-file-access.d.ts +3 -2
  70. package/dist/local-file-access.d.ts.map +1 -1
  71. package/dist/local-file-access.js +12 -6
  72. package/dist/local-roots.d.ts.map +1 -1
  73. package/dist/local-roots.js +35 -30
  74. package/dist/move-path.d.ts.map +1 -1
  75. package/dist/move-path.js +51 -12
  76. package/dist/native/darwin-arm64/fs-safe-native.node +0 -0
  77. package/dist/native/darwin-x64/fs-safe-native.node +0 -0
  78. package/dist/native/linux-arm64-gnu/fs-safe-native.node +0 -0
  79. package/dist/native/linux-arm64-musl/fs-safe-native.node +0 -0
  80. package/dist/native/linux-x64-gnu/fs-safe-native.node +0 -0
  81. package/dist/native/linux-x64-musl/fs-safe-native.node +0 -0
  82. package/dist/native/win32-x64-msvc/fs-safe-native.node +0 -0
  83. package/dist/native-binding.d.ts +1 -0
  84. package/dist/native-binding.d.ts.map +1 -1
  85. package/dist/native-operations.d.ts.map +1 -1
  86. package/dist/native-operations.js +14 -1
  87. package/dist/native-pinned-write.d.ts.map +1 -1
  88. package/dist/native-pinned-write.js +44 -17
  89. package/dist/output-sibling.d.ts.map +1 -1
  90. package/dist/output-sibling.js +6 -5
  91. package/dist/path-policy.d.ts.map +1 -1
  92. package/dist/path-policy.js +1 -7
  93. package/dist/path.d.ts.map +1 -1
  94. package/dist/path.js +4 -0
  95. package/dist/permission-exec.d.ts +6 -0
  96. package/dist/permission-exec.d.ts.map +1 -0
  97. package/dist/permission-exec.js +28 -0
  98. package/dist/permissions-windows.d.ts +56 -0
  99. package/dist/permissions-windows.d.ts.map +1 -0
  100. package/dist/permissions-windows.js +285 -0
  101. package/dist/permissions.d.ts +3 -46
  102. package/dist/permissions.d.ts.map +1 -1
  103. package/dist/permissions.js +17 -328
  104. package/dist/pinned-write.js +2 -1
  105. package/dist/regular-file.d.ts.map +1 -1
  106. package/dist/regular-file.js +21 -2
  107. package/dist/replace-directory.d.ts.map +1 -1
  108. package/dist/replace-directory.js +10 -1
  109. package/dist/replace-file-copy-fallback.d.ts +3 -0
  110. package/dist/replace-file-copy-fallback.d.ts.map +1 -1
  111. package/dist/replace-file-copy-fallback.js +18 -11
  112. package/dist/replace-file-descriptor.d.ts +33 -0
  113. package/dist/replace-file-descriptor.d.ts.map +1 -0
  114. package/dist/replace-file-descriptor.js +96 -0
  115. package/dist/replace-file.d.ts +9 -2
  116. package/dist/replace-file.d.ts.map +1 -1
  117. package/dist/replace-file.js +64 -72
  118. package/dist/root-context.d.ts +8 -0
  119. package/dist/root-context.d.ts.map +1 -1
  120. package/dist/root-context.js +43 -2
  121. package/dist/root-errors.d.ts +7 -0
  122. package/dist/root-errors.d.ts.map +1 -1
  123. package/dist/root-errors.js +46 -1
  124. package/dist/root-file.d.ts +1 -0
  125. package/dist/root-file.d.ts.map +1 -1
  126. package/dist/root-file.js +2 -0
  127. package/dist/root-impl.d.ts.map +1 -1
  128. package/dist/root-impl.js +213 -168
  129. package/dist/root-path-symlink.d.ts +3 -0
  130. package/dist/root-path-symlink.d.ts.map +1 -0
  131. package/dist/root-path-symlink.js +35 -0
  132. package/dist/root-path.d.ts +1 -0
  133. package/dist/root-path.d.ts.map +1 -1
  134. package/dist/root-path.js +129 -190
  135. package/dist/root-walk.d.ts.map +1 -1
  136. package/dist/root-walk.js +15 -0
  137. package/dist/safe-path-segment.d.ts +2 -0
  138. package/dist/safe-path-segment.d.ts.map +1 -1
  139. package/dist/safe-path-segment.js +12 -0
  140. package/dist/secret-file.d.ts.map +1 -1
  141. package/dist/secret-file.js +19 -3
  142. package/dist/secure-temp-dir.d.ts.map +1 -1
  143. package/dist/secure-temp-dir.js +6 -1
  144. package/dist/short-path.d.ts +2 -0
  145. package/dist/short-path.d.ts.map +1 -0
  146. package/dist/short-path.js +7 -0
  147. package/dist/sidecar-lock-acquire.d.ts +30 -0
  148. package/dist/sidecar-lock-acquire.d.ts.map +1 -0
  149. package/dist/sidecar-lock-acquire.js +260 -0
  150. package/dist/sidecar-lock-policy.d.ts +3 -0
  151. package/dist/sidecar-lock-policy.d.ts.map +1 -1
  152. package/dist/sidecar-lock-policy.js +20 -3
  153. package/dist/sidecar-lock-reclaim.d.ts +5 -1
  154. package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
  155. package/dist/sidecar-lock-reclaim.js +59 -9
  156. package/dist/sidecar-lock.d.ts.map +1 -1
  157. package/dist/sidecar-lock.js +27 -226
  158. package/dist/temp-target.d.ts.map +1 -1
  159. package/dist/temp-target.js +15 -4
  160. package/dist/test-hooks.d.ts +1 -0
  161. package/dist/test-hooks.d.ts.map +1 -1
  162. package/dist/timing.d.ts +2 -0
  163. package/dist/timing.d.ts.map +1 -1
  164. package/dist/timing.js +9 -0
  165. package/dist/walk.d.ts.map +1 -1
  166. package/dist/walk.js +15 -0
  167. package/docs/advanced.md +1 -1
  168. package/docs/archive.md +27 -20
  169. package/docs/assets/readme-banner.jpg +0 -0
  170. package/docs/atomic.md +48 -19
  171. package/docs/errors.md +31 -7
  172. package/docs/file-store.md +10 -1
  173. package/docs/filename.md +17 -16
  174. package/docs/index.md +1 -0
  175. package/docs/install-path.md +9 -6
  176. package/docs/json.md +8 -2
  177. package/docs/local-roots.md +79 -103
  178. package/docs/native-helper.md +11 -5
  179. package/docs/native.md +2 -2
  180. package/docs/output.md +8 -3
  181. package/docs/path-scope.md +4 -1
  182. package/docs/path.md +7 -3
  183. package/docs/permissions.md +8 -0
  184. package/docs/public-api.md +109 -0
  185. package/docs/quickstart.md +6 -2
  186. package/docs/reading.md +13 -10
  187. package/docs/root.md +26 -4
  188. package/docs/secret-file.md +17 -4
  189. package/docs/security-model.md +21 -4
  190. package/docs/sidecar-lock.md +15 -5
  191. package/docs/temp.md +37 -5
  192. package/docs/test-hooks.md +2 -0
  193. package/docs/types.md +32 -15
  194. package/docs/writing.md +25 -12
  195. package/package.json +7 -3
  196. package/dist/archive-file-io.d.ts +0 -9
  197. package/dist/archive-file-io.d.ts.map +0 -1
  198. package/dist/archive-file-io.js +0 -11
  199. package/dist/file-store-source.d.ts +0 -5
  200. package/dist/file-store-source.d.ts.map +0 -1
  201. package/dist/file-store-source.js +0 -30
  202. package/dist/file-url.d.ts +0 -2
  203. package/dist/file-url.d.ts.map +0 -1
  204. package/dist/file-url.js +0 -1
  205. package/dist/path-stat.d.ts +0 -4
  206. package/dist/path-stat.d.ts.map +0 -1
  207. package/dist/path-stat.js +0 -15
  208. package/dist/windows-permissions-native.d.ts +0 -9
  209. package/dist/windows-permissions-native.d.ts.map +0 -1
  210. package/dist/windows-permissions-native.js +0 -31
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`, `timeout`,
66
+ and `unsupported-platform`. Every other current `FsSafeErrorCode`, including
67
+ `store-reentrant-update`, is categorized as `policy`.
55
68
 
56
69
  ## Code union
57
70
 
@@ -75,6 +88,7 @@ type FsSafeErrorCode =
75
88
  | "path-mismatch"
76
89
  | "permission-unverified"
77
90
  | "secret-exists"
91
+ | "store-reentrant-update"
78
92
  | "symlink"
79
93
  | "timeout"
80
94
  | "too-large"
@@ -92,7 +106,7 @@ type FsSafeErrorCode =
92
106
  | `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
107
  | `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
108
  | `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. |
109
+ | `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
110
  | `not-empty` | `remove()` on a non-empty directory. | Use `replaceDirectoryAtomic` or remove children first. |
97
111
  | `not-file` | Read or copy targeted a non-regular file. | Target was a directory, FIFO, socket, device. |
98
112
  | `not-found` | The target does not exist (or its parent does not, with `mkdir: false`). | Typical missing-file case. |
@@ -103,10 +117,11 @@ type FsSafeErrorCode =
103
117
  | `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
118
  | `permission-unverified` | A secure file check could not verify required permissions. | Windows ACL inspection failed; POSIX ownership/mode was unavailable. |
105
119
  | `secret-exists` | `createSecretFileAtomic()` found an existing final path. | First-writer-wins secret creation lost a race or the credential was already initialized. |
120
+ | `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
121
  | `symlink` | Path component is a symlink, policy is `reject`. | Caller followed a symlink they shouldn't have, or `symlinks: "reject"` is set. |
107
122
  | `timeout` | An operation with a wall-clock budget overran. | Secure file read or timed operation exceeded `timeoutMs`. |
108
123
  | `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. |
124
+ | `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
125
 
111
126
  ## Branching
112
127
 
@@ -138,11 +153,13 @@ try {
138
153
  }
139
154
  ```
140
155
 
141
- The compiler will flag missing cases when you exhaust the union — keep your switch up-to-date as the library adds new codes.
156
+ The `default` above deliberately rethrows unknown codes, so it remains safe as
157
+ the union grows. If you want the compiler to flag every newly added code, end
158
+ an exhaustive switch with a `never` assertion instead of a general `default`.
142
159
 
143
160
  ## Distinguishing from `NodeJS.ErrnoException`
144
161
 
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:
162
+ 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
163
 
147
164
  ```ts
148
165
  import { FsSafeError } from "@openclaw/fs-safe";
@@ -154,7 +171,8 @@ try {
154
171
  handleFsSafe(err);
155
172
  return;
156
173
  }
157
- if ((err as NodeJS.ErrnoException).code === "EACCES") {
174
+ const code = (err as NodeJS.ErrnoException).code;
175
+ if (code === "EACCES" || code === "EPERM") {
158
176
  handleAccess();
159
177
  return;
160
178
  }
@@ -164,6 +182,12 @@ try {
164
182
 
165
183
  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
184
 
185
+ On Windows, access-denied failures from native root/open filesystem operations
186
+ use `EPERM` to match Node/libuv and the JavaScript fallback. Security-descriptor
187
+ inspection has its own failure surface. Older fs-safe versions could report
188
+ `EACCES` for the same root/open condition, so consumers spanning versions
189
+ should accept both codes.
190
+
167
191
  ## Specialty errors
168
192
 
169
193
  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
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
 
@@ -1,11 +1,9 @@
1
1
  # Local roots
2
2
 
3
- `local-roots` is a small set of helpers for code that holds a list of trusted base directories ("roots") and wants to look up an absolute path or a relative-to-some-root reference against any of them.
4
-
5
- The shape covers two needs:
6
-
7
- - "Resolve this path string to an absolute path that lives inside one of the configured roots, or refuse it."
8
- - "Read this path, where it could be `/abs/path`, `~/relative`, `file://…`, or simply the basename of a file in one of my roots."
3
+ The local-roots helpers accept a file path plus a list of trusted absolute
4
+ directories and return the first canonical root that contains the file. Use
5
+ them when configuration may name one of several approved media, cache, or
6
+ workspace roots.
9
7
 
10
8
  ```ts
11
9
  import {
@@ -14,92 +12,102 @@ import {
14
12
  } from "@openclaw/fs-safe/advanced";
15
13
  ```
16
14
 
17
- ## Shape of a "roots input"
15
+ ## Input shape
18
16
 
19
- Both helpers take roots as either an array of strings or a `LocalRootsInputOptions` record. Each root is an absolute path the caller already trusts:
17
+ Both helpers take one options object. `filePath` may be absolute, home-relative,
18
+ relative to the current working directory, or a local `file://` URL. Relative
19
+ inputs are resolved exactly as Node resolves them; they are not searched as a
20
+ basename under each root.
20
21
 
21
22
  ```ts
22
23
  type LocalRootsInputOptions = {
23
- roots: string[]; // absolute paths
24
- allowAbsolute?: boolean; // accept absolute inputs (default true)
25
- allowFileUrls?: boolean; // accept file:// URLs (default true)
26
- expandHome?: boolean; // expand ~ in inputs (default true)
24
+ filePath: string;
25
+ roots: readonly string[]; // trusted absolute paths, checked in order
26
+ label?: string; // used in validation errors
27
27
  };
28
28
  ```
29
29
 
30
- If a root is a symlink, it is canonicalized at lookup time. The helpers work in the order roots are listed: the first root that contains the resolved path wins.
31
-
32
- ## `resolveLocalPathFromRootsSync(input, options)`
30
+ Roots may use `~` or local `file://` spellings, but each resolved root must be
31
+ absolute. Existing root symlinks are canonicalized before containment is
32
+ checked. Invalid root entries throw `FsSafeError("invalid-path")`; an invalid
33
+ `file://` input throws `Error`. A path that is valid but does not fall inside
34
+ any usable root returns `null`.
33
35
 
34
- Synchronous resolution. Returns:
36
+ ## `resolveLocalPathFromRootsSync(options)`
35
37
 
36
38
  ```ts
37
- type LocalRootsPathResult =
38
- | { ok: true; absolutePath: string; rootDir: string; relativePath: string }
39
- | { ok: false; reason: "outside-roots" | "invalid-input" };
39
+ type ResolveLocalPathFromRootsSyncOptions = LocalRootsInputOptions & {
40
+ allowMissing?: boolean; // default false
41
+ requireFile?: boolean; // default false
42
+ };
43
+
44
+ type LocalRootsPathResult = {
45
+ path: string; // canonical candidate path
46
+ root: string; // canonical containing root
47
+ };
40
48
  ```
41
49
 
50
+ For an existing upload:
51
+
42
52
  ```ts
43
53
  import { resolveLocalPathFromRootsSync } from "@openclaw/fs-safe/advanced";
44
54
 
45
- const r = resolveLocalPathFromRootsSync("photo.jpg", {
55
+ const r = resolveLocalPathFromRootsSync({
56
+ filePath: "/srv/uploads/photo.jpg",
46
57
  roots: ["/srv/uploads", "/srv/cache"],
58
+ requireFile: true,
47
59
  });
48
60
 
49
- if (!r.ok) return reply(400, r.reason);
50
- console.log(r.absolutePath); // /srv/uploads/photo.jpg (assuming it's there)
51
- console.log(r.rootDir); // /srv/uploads
52
- console.log(r.relativePath); // photo.jpg
61
+ if (!r) throw new Error("photo is outside the configured roots");
62
+ console.log(r.path); // canonical path to photo.jpg
63
+ console.log(r.root); // canonical /srv/uploads
53
64
  ```
54
65
 
55
- ### Resolution order
56
-
57
- For each candidate input:
58
-
59
- 1. If the input is a `file://` URL and `allowFileUrls` is true, decode to an absolute path.
60
- 2. If the input begins with `~/` and `expandHome` is true, expand to the user's home dir.
61
- 3. If the input is absolute (`/...` or Windows drive) and `allowAbsolute` is true, accept it as-is and check it falls under one of the roots.
62
- 4. Otherwise, treat the input as relative and resolve it against each root in order until one contains the resulting path.
63
-
64
- If no root contains the result, returns `{ ok: false, reason: "outside-roots" }`.
65
-
66
- `"invalid-input"` covers empty strings, embedded NULs, encoded `..` traversal, Windows network paths (`\\server\share`), and other constructs that should not be resolved at all.
66
+ By default the candidate must exist. `allowMissing: true` instead canonicalizes
67
+ the nearest existing ancestor and validates the missing tail, which is useful
68
+ when selecting a future output location. `requireFile: true` rejects existing
69
+ directories and other non-file leaves. Dangling symlinks and candidates whose
70
+ ancestors cannot be canonicalized are rejected rather than treated as safe
71
+ missing paths.
67
72
 
68
- ## `readLocalFileFromRoots(input, options)`
73
+ ## `readLocalFileFromRoots(options)`
69
74
 
70
- Async. Resolves through the same logic, then reads the file via [`Root`](root.md) so the read benefits from boundary checks, `O_NOFOLLOW`, and fd identity verification.
75
+ The asynchronous helper opens the candidate through the matched [`Root`](root.md),
76
+ so no-follow, identity, hardlink, device-path, and byte-limit checks happen at
77
+ the read itself.
71
78
 
72
79
  ```ts
80
+ type ReadLocalFileFromRootsOptions = LocalRootsInputOptions & {
81
+ hardlinks?: "reject" | "allow";
82
+ maxBytes?: number;
83
+ nonBlockingRead?: boolean;
84
+ symlinks?: "reject" | "follow-within-root";
85
+ };
86
+
73
87
  type LocalRootsReadResult = ReadResult & {
74
- rootDir: string;
75
- relativePath: string;
88
+ root: string; // canonical containing root
76
89
  };
90
+ ```
77
91
 
78
- const r = await readLocalFileFromRoots("photo.jpg", {
92
+ ```ts
93
+ const r = await readLocalFileFromRoots({
94
+ filePath: "/srv/uploads/photo.jpg",
79
95
  roots: ["/srv/uploads", "/srv/cache"],
80
96
  maxBytes: 8 * 1024 * 1024,
81
97
  });
82
- if (!r) return reply(404);
98
+ if (!r) throw new Error("photo is missing, unreadable, or outside the roots");
83
99
  process.stdout.write(r.buffer);
84
100
  ```
85
101
 
86
- The result extends `ReadResult` (`{ buffer, realPath, stat }`) with the matched `rootDir` and the path relative to it. Returns `null` if the input doesn't resolve into any root or the file is missing.
87
-
88
- ### Read options
89
-
90
- ```ts
91
- type ReadLocalFileFromRootsOptions = LocalRootsInputOptions & {
92
- hardlinks?: "reject" | "allow";
93
- maxBytes?: number;
94
- symlinks?: "reject" | "follow-within-root";
95
- };
96
- ```
97
-
98
- The read-side options are forwarded to `Root` for the actual read.
102
+ The helper returns `null` when no configured root can be opened or no safe read
103
+ succeeds. This intentionally collapses missing, outside-root, and per-root read
104
+ failures; use a single `Root` directly when the caller must distinguish those
105
+ outcomes. Omitting `maxBytes` preserves `Root`'s 16 MiB default.
99
106
 
100
- ## `local-file-access` companions
107
+ ## File URL and Windows-path companions
101
108
 
102
- The `local-file-access` module (re-exported from `@openclaw/fs-safe/advanced`) supplies a few small helpers for input normalization that the roots helpers use under the hood. They are also useful on their own:
109
+ The advanced surface also exports the normalization helpers used around this
110
+ API:
103
111
 
104
112
  ```ts
105
113
  import {
@@ -113,52 +121,20 @@ import {
113
121
  } from "@openclaw/fs-safe/advanced";
114
122
  ```
115
123
 
116
- - `safeFileURLToPath(fileUrl)` `url.fileURLToPath` with explicit error throwing. Refuses URLs that decode to network paths.
117
- - `trySafeFileURLToPath(fileUrl)` same, returns `undefined` instead of throwing.
118
- - `isWindowsDriveLetterPath(p, platform?)` true for `C:\...` style absolute paths when the platform is Windows.
119
- - `isWindowsNetworkPath(p, platform?)` — true for `\\server\share` and `//server/share` style paths when the platform is Windows.
120
- - `assertNoWindowsNetworkPath(p, label?)` — throws if it is.
121
- - `basenameFromMediaSource(source?)` best-effort filename extraction from URLs / data URIs / paths, for naming downloaded media.
122
- - `hasEncodedFileUrlSeparator(pathname)` true for paths containing percent-encoded `/` (`%2F` / `%5C`), which often indicate traversal attempts.
123
-
124
- ## Common patterns
125
-
126
- ### Multi-root config: search project, then user, then system
127
-
128
- ```ts
129
- const text = await readLocalFileFromRoots(name, {
130
- roots: [path.join(projectDir, "templates"), path.join(homedir(), ".app/templates"), "/etc/app/templates"],
131
- allowAbsolute: false, // only resolve names, never absolute paths
132
- maxBytes: 256 * 1024,
133
- });
134
- ```
135
-
136
- ### Validate a file:// URL at the API boundary
137
-
138
- ```ts
139
- import { safeFileURLToPath, isWindowsNetworkPath } from "@openclaw/fs-safe/advanced";
140
-
141
- let abs: string;
142
- try {
143
- abs = safeFileURLToPath(req.body.fileUrl);
144
- } catch {
145
- return reply(400, "invalid file URL");
146
- }
147
- if (isWindowsNetworkPath(abs)) return reply(400, "network paths not allowed");
148
- ```
149
-
150
- ### Deny absolute, allow relative-only
151
-
152
- ```ts
153
- const r = resolveLocalPathFromRootsSync(input, {
154
- roots: ["/srv/workspace"],
155
- allowAbsolute: false,
156
- allowFileUrls: false,
157
- });
158
- ```
124
+ - `safeFileURLToPath(fileUrl)` parses a local file URL and refuses remote hosts
125
+ or paths that decode to Windows network paths.
126
+ - `trySafeFileURLToPath(fileUrl)` returns `undefined` instead of throwing.
127
+ - `isWindowsDriveLetterPath()` and `isWindowsNetworkPath()` classify Windows
128
+ absolute and network spellings.
129
+ - `assertNoWindowsNetworkPath()` throws for a network path on Windows.
130
+ - `basenameFromMediaSource()` extracts a best-effort filename from a URL, data
131
+ URI, or path.
132
+ - `hasEncodedFileUrlSeparator()` detects percent-encoded slash or backslash
133
+ spellings.
159
134
 
160
135
  ## See also
161
136
 
162
- - [`root()`](root.md) — single-root variant of this multi-root setup.
163
- - [Path helpers](path.md) — `isPathInside`, `safeRealpathSync` for ad-hoc checks.
164
- - [`pathScope()`](path-scope.md) — single-root with `Result`-style returns.
137
+ - [`root()`](root.md) — use when one trusted root should preserve individual
138
+ failure codes.
139
+ - [Path helpers](path.md) — lexical and canonical containment primitives.
140
+ - [`pathScope()`](path-scope.md) — result-shaped single-root validation.