@openclaw/fs-safe 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +58 -2
  3. package/dist/absolute-path.d.ts +14 -0
  4. package/dist/absolute-path.d.ts.map +1 -1
  5. package/dist/absolute-path.js +203 -2
  6. package/dist/advanced.d.ts +2 -1
  7. package/dist/advanced.d.ts.map +1 -1
  8. package/dist/advanced.js +2 -1
  9. package/dist/archive-staging.d.ts.map +1 -1
  10. package/dist/archive-staging.js +81 -7
  11. package/dist/archive.d.ts.map +1 -1
  12. package/dist/archive.js +25 -42
  13. package/dist/bounded-read-stream.d.ts +8 -0
  14. package/dist/bounded-read-stream.d.ts.map +1 -0
  15. package/dist/bounded-read-stream.js +20 -0
  16. package/dist/config.d.ts +1 -0
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/config.js +1 -0
  19. package/dist/directory-guard.d.ts +18 -0
  20. package/dist/directory-guard.d.ts.map +1 -0
  21. package/dist/directory-guard.js +70 -0
  22. package/dist/file-lock.d.ts +4 -2
  23. package/dist/file-lock.d.ts.map +1 -1
  24. package/dist/file-lock.js +13 -2
  25. package/dist/file-store-boundary.d.ts +25 -0
  26. package/dist/file-store-boundary.d.ts.map +1 -0
  27. package/dist/file-store-boundary.js +168 -0
  28. package/dist/file-store-prune.d.ts +12 -0
  29. package/dist/file-store-prune.d.ts.map +1 -0
  30. package/dist/file-store-prune.js +86 -0
  31. package/dist/file-store-source.d.ts +5 -0
  32. package/dist/file-store-source.d.ts.map +1 -0
  33. package/dist/file-store-source.js +30 -0
  34. package/dist/file-store.d.ts +2 -6
  35. package/dist/file-store.d.ts.map +1 -1
  36. package/dist/file-store.js +122 -125
  37. package/dist/guarded-mkdir.d.ts +6 -0
  38. package/dist/guarded-mkdir.d.ts.map +1 -0
  39. package/dist/guarded-mkdir.js +45 -0
  40. package/dist/guarded-mutation.d.ts +33 -0
  41. package/dist/guarded-mutation.d.ts.map +1 -0
  42. package/dist/guarded-mutation.js +76 -0
  43. package/dist/index.d.ts +2 -0
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +2 -0
  46. package/dist/json-document-store.d.ts +2 -0
  47. package/dist/json-document-store.d.ts.map +1 -1
  48. package/dist/json-document-store.js +7 -3
  49. package/dist/json-durable-queue.d.ts +45 -0
  50. package/dist/json-durable-queue.d.ts.map +1 -0
  51. package/dist/json-durable-queue.js +200 -0
  52. package/dist/json.d.ts +35 -5
  53. package/dist/json.d.ts.map +1 -1
  54. package/dist/json.js +69 -2
  55. package/dist/local-roots.d.ts.map +1 -1
  56. package/dist/local-roots.js +17 -3
  57. package/dist/lock-config.d.ts +10 -0
  58. package/dist/lock-config.d.ts.map +1 -0
  59. package/dist/lock-config.js +12 -0
  60. package/dist/move-path.d.ts +1 -0
  61. package/dist/move-path.d.ts.map +1 -1
  62. package/dist/move-path.js +195 -7
  63. package/dist/output.d.ts +13 -0
  64. package/dist/output.d.ts.map +1 -0
  65. package/dist/output.js +69 -0
  66. package/dist/path-stat.d.ts +4 -0
  67. package/dist/path-stat.d.ts.map +1 -0
  68. package/dist/path-stat.js +15 -0
  69. package/dist/path.d.ts +1 -0
  70. package/dist/path.d.ts.map +1 -1
  71. package/dist/path.js +6 -3
  72. package/dist/pinned-write.d.ts.map +1 -1
  73. package/dist/pinned-write.js +44 -19
  74. package/dist/private-file-store.d.ts +5 -7
  75. package/dist/private-file-store.d.ts.map +1 -1
  76. package/dist/private-file-store.js +21 -34
  77. package/dist/private-temp-workspace.d.ts.map +1 -1
  78. package/dist/private-temp-workspace.js +28 -15
  79. package/dist/regular-file.d.ts.map +1 -1
  80. package/dist/regular-file.js +52 -8
  81. package/dist/replace-directory.d.ts.map +1 -1
  82. package/dist/replace-directory.js +5 -4
  83. package/dist/replace-file.d.ts +1 -1
  84. package/dist/replace-file.d.ts.map +1 -1
  85. package/dist/replace-file.js +93 -31
  86. package/dist/root-context.d.ts +23 -0
  87. package/dist/root-context.d.ts.map +1 -0
  88. package/dist/root-context.js +62 -0
  89. package/dist/root-errors.d.ts +4 -0
  90. package/dist/root-errors.d.ts.map +1 -0
  91. package/dist/root-errors.js +21 -0
  92. package/dist/root-impl.d.ts +108 -0
  93. package/dist/root-impl.d.ts.map +1 -0
  94. package/dist/{safe-root.js → root-impl.js} +484 -265
  95. package/dist/root.d.ts +1 -107
  96. package/dist/root.d.ts.map +1 -1
  97. package/dist/root.js +1 -1332
  98. package/dist/safe-path-segment.d.ts +9 -0
  99. package/dist/safe-path-segment.d.ts.map +1 -0
  100. package/dist/safe-path-segment.js +51 -0
  101. package/dist/secret-file.d.ts.map +1 -1
  102. package/dist/secret-file.js +6 -1
  103. package/dist/sibling-temp.d.ts +1 -0
  104. package/dist/sibling-temp.d.ts.map +1 -1
  105. package/dist/sibling-temp.js +32 -7
  106. package/dist/sidecar-lock.d.ts +2 -0
  107. package/dist/sidecar-lock.d.ts.map +1 -1
  108. package/dist/sidecar-lock.js +93 -13
  109. package/dist/store.d.ts +1 -0
  110. package/dist/store.d.ts.map +1 -1
  111. package/dist/store.js +1 -0
  112. package/dist/temp-target.d.ts.map +1 -1
  113. package/dist/temp-target.js +7 -4
  114. package/dist/test-hooks.d.ts +6 -0
  115. package/dist/test-hooks.d.ts.map +1 -1
  116. package/dist/text-atomic.d.ts +7 -0
  117. package/dist/text-atomic.d.ts.map +1 -1
  118. package/dist/text-atomic.js +3 -2
  119. package/dist/trash.d.ts.map +1 -1
  120. package/dist/trash.js +48 -13
  121. package/docs/advanced.md +110 -0
  122. package/docs/archive.md +199 -0
  123. package/docs/atomic.md +191 -0
  124. package/docs/config.md +94 -0
  125. package/docs/contributing.md +80 -0
  126. package/docs/errors.md +154 -0
  127. package/docs/file-store.md +213 -0
  128. package/docs/filename.md +0 -0
  129. package/docs/index.md +77 -0
  130. package/docs/install-path.md +167 -0
  131. package/docs/install.md +150 -0
  132. package/docs/json-store.md +204 -0
  133. package/docs/json.md +192 -0
  134. package/docs/local-roots.md +164 -0
  135. package/docs/output.md +92 -0
  136. package/docs/path-scope.md +130 -0
  137. package/docs/path.md +191 -0
  138. package/docs/permissions.md +95 -0
  139. package/docs/private-file-store.md +45 -0
  140. package/docs/python-helper.md +103 -0
  141. package/docs/quickstart.md +147 -0
  142. package/docs/reading.md +174 -0
  143. package/docs/regular-file.md +175 -0
  144. package/docs/root.md +205 -0
  145. package/docs/secret-file.md +154 -0
  146. package/docs/secure-file.md +82 -0
  147. package/docs/security-model.md +99 -0
  148. package/docs/sidecar-lock.md +230 -0
  149. package/docs/store.md +77 -0
  150. package/docs/temp.md +284 -0
  151. package/docs/test-hooks.md +74 -0
  152. package/docs/testing.md +178 -0
  153. package/docs/timing.md +121 -0
  154. package/docs/types.md +155 -0
  155. package/docs/walk.md +63 -0
  156. package/docs/writing.md +222 -0
  157. package/package.json +10 -2
  158. package/dist/archive-utils.d.ts +0 -3
  159. package/dist/archive-utils.d.ts.map +0 -1
  160. package/dist/archive-utils.js +0 -26
  161. package/dist/boundary-file-read.d.ts +0 -44
  162. package/dist/boundary-file-read.d.ts.map +0 -1
  163. package/dist/boundary-file-read.js +0 -129
  164. package/dist/boundary-path.d.ts +0 -39
  165. package/dist/boundary-path.d.ts.map +0 -1
  166. package/dist/boundary-path.js +0 -598
  167. package/dist/fs-pinned-path-helper.d.ts +0 -7
  168. package/dist/fs-pinned-path-helper.d.ts.map +0 -1
  169. package/dist/fs-pinned-path-helper.js +0 -182
  170. package/dist/fs-pinned-write-helper.d.ts +0 -21
  171. package/dist/fs-pinned-write-helper.d.ts.map +0 -1
  172. package/dist/fs-pinned-write-helper.js +0 -263
  173. package/dist/hardlink-guards.d.ts +0 -7
  174. package/dist/hardlink-guards.d.ts.map +0 -1
  175. package/dist/hardlink-guards.js +0 -30
  176. package/dist/install-safe-path.d.ts +0 -20
  177. package/dist/install-safe-path.d.ts.map +0 -1
  178. package/dist/install-safe-path.js +0 -94
  179. package/dist/json-file.d.ts +0 -3
  180. package/dist/json-file.d.ts.map +0 -1
  181. package/dist/json-file.js +0 -123
  182. package/dist/json-files.d.ts +0 -20
  183. package/dist/json-files.d.ts.map +0 -1
  184. package/dist/json-files.js +0 -153
  185. package/dist/path-alias-guards.d.ts +0 -19
  186. package/dist/path-alias-guards.d.ts.map +0 -1
  187. package/dist/path-alias-guards.js +0 -21
  188. package/dist/path-guards.d.ts +0 -7
  189. package/dist/path-guards.d.ts.map +0 -1
  190. package/dist/path-guards.js +0 -49
  191. package/dist/path-safety.d.ts +0 -12
  192. package/dist/path-safety.d.ts.map +0 -1
  193. package/dist/path-safety.js +0 -50
  194. package/dist/safe-open-sync.d.ts +0 -24
  195. package/dist/safe-open-sync.d.ts.map +0 -1
  196. package/dist/safe-open-sync.js +0 -71
  197. package/dist/safe-root.d.ts +0 -123
  198. package/dist/safe-root.d.ts.map +0 -1
  199. package/dist/secure-temp-workspace.d.ts +0 -25
  200. package/dist/secure-temp-workspace.d.ts.map +0 -1
  201. package/dist/secure-temp-workspace.js +0 -136
  202. package/dist/sibling-temp-file.d.ts +0 -16
  203. package/dist/sibling-temp-file.d.ts.map +0 -1
  204. package/dist/sibling-temp-file.js +0 -73
  205. package/dist/sibling-temp-write.d.ts +0 -8
  206. package/dist/sibling-temp-write.d.ts.map +0 -1
  207. package/dist/sibling-temp-write.js +0 -40
package/docs/json.md ADDED
@@ -0,0 +1,192 @@
1
+ # JSON files
2
+
3
+ `@openclaw/fs-safe/json` is the standalone JSON surface: strict and lenient read variants plus atomic JSON writes.
4
+
5
+ ```ts
6
+ import {
7
+ tryReadJson,
8
+ readJson,
9
+ readJsonIfExists,
10
+ readJsonSync,
11
+ tryReadJsonSync,
12
+ readRootJsonSync,
13
+ readRootJsonObjectSync,
14
+ readRootStructuredFileSync,
15
+ writeJson,
16
+ writeJsonSync,
17
+ JsonFileReadError,
18
+ } from "@openclaw/fs-safe/json";
19
+ ```
20
+
21
+ ## Three reads, three failure shapes
22
+
23
+ Same input, three distinct contracts — pick the one whose error story matches your call site:
24
+
25
+ ```ts
26
+ await readJson<T>("./manifest.json"); // throws JsonFileReadError on missing or invalid
27
+ await readJsonIfExists<T>("./cache.json"); // returns null on missing; throws on invalid
28
+ await tryReadJson<T>("./optional.json"); // returns null on missing or invalid
29
+ ```
30
+
31
+ | Helper | Missing file | Invalid JSON |
32
+ |---|---|---|
33
+ | `readJson` | throws | throws |
34
+ | `readJsonIfExists` | `null` | throws |
35
+ | `tryReadJson` | `null` | `null` |
36
+
37
+ Use `readJson` when missing-or-malformed is a programmer error you want to surface immediately. Use `readJsonIfExists` when "file not there" is normal but malformed bytes should still page someone. Use `tryReadJson` when neither outcome should crash the caller.
38
+
39
+ `JsonFileReadError` carries `cause` so you can inspect whether the underlying failure was an `ENOENT`, a `SyntaxError`, or something else.
40
+
41
+ ## Reading
42
+
43
+ ### `readJson<T>(filePath)`
44
+
45
+ Async strict reader. Throws `JsonFileReadError` on missing or invalid input. The cast is unchecked — validate the shape with your own schema (zod, valibot, …) if it came from an untrusted source.
46
+
47
+ ```ts
48
+ const manifest = await readJson<Manifest>("./manifest.json");
49
+ ```
50
+
51
+ ### `readJsonIfExists<T>(filePath)`
52
+
53
+ Async semi-lenient reader. Returns `null` if the file is missing; throws `JsonFileReadError` if the file exists but cannot be parsed.
54
+
55
+ ```ts
56
+ const cache = (await readJsonIfExists<Cache>("./cache.json")) ?? freshCache();
57
+ ```
58
+
59
+ ### `tryReadJson<T>(filePath)`
60
+
61
+ Async lenient reader. Returns `null` for any failure (missing, unreadable, invalid). The "no fuss" sibling.
62
+
63
+ ```ts
64
+ const optional = (await tryReadJson<Settings>("./settings.json")) ?? defaults;
65
+ ```
66
+
67
+ ### `readJsonSync<T>(filePath)`
68
+
69
+ Synchronous strict reader. Throws `JsonFileReadError` on missing or invalid input, matching the async `readJson` contract.
70
+
71
+ ### `tryReadJsonSync<T>(pathname)`
72
+
73
+ Synchronous, generic, lenient. Returns `T | null`. Useful in boot paths where you want a typed result without async.
74
+
75
+ ## Root-bounded structured reads
76
+
77
+ Use the root-bounded readers when you already have a trusted root directory and
78
+ a caller-controlled relative path, but you only need one synchronous structured
79
+ read instead of a full `root()` handle.
80
+
81
+ ```ts
82
+ const result = readRootJsonObjectSync({
83
+ rootDir: "/safe/workspace",
84
+ relativePath: "plugin/openclaw.plugin.json",
85
+ boundaryLabel: "plugin manifest",
86
+ });
87
+
88
+ if (!result.ok) {
89
+ // reason is "open", "parse", or "invalid"
90
+ throw new Error(result.reason);
91
+ }
92
+
93
+ console.log(result.value);
94
+ ```
95
+
96
+ `readRootJsonSync()` parses any JSON value. `readRootJsonObjectSync()` only
97
+ accepts objects. `readRootStructuredFileSync()` accepts a custom parser and
98
+ validator so callers can layer JSON5, TOML, YAML, or domain-specific validation
99
+ without making `fs-safe` depend on those formats.
100
+
101
+ ## Writing
102
+
103
+ ### `writeJson(filePath, value, options?)`
104
+
105
+ Async atomic JSON write. `JSON.stringify(value, null, 2)` + sibling-temp + rename. Defaults to file mode `0o600`.
106
+
107
+ ```ts
108
+ await writeJson("./state.json", state, { trailingNewline: true });
109
+ ```
110
+
111
+ Options:
112
+
113
+ ```ts
114
+ type WriteJsonOptions = {
115
+ mode?: number; // file mode (default 0o600)
116
+ dirMode?: number; // mode for parent dirs created on demand
117
+ trailingNewline?: boolean; // append "\n" if missing (default false)
118
+ durable?: boolean; // default true; false skips temp/parent fsync
119
+ };
120
+ ```
121
+
122
+ `durable: false` preserves atomic temp-file replacement but skips the temp-file
123
+ and parent-directory `fsync` calls. Use it only for reconstructible JSON state
124
+ where lower latency matters more than crash-durability.
125
+
126
+ ### `writeJsonSync(pathname, data)`
127
+
128
+ Synchronous variant. Convenience wrapper that uses the sync atomic-write path with sensible defaults.
129
+
130
+ ```ts
131
+ writeJsonSync("./prefs.json", { theme: "dark" });
132
+ ```
133
+
134
+ For atomic text writes, use [`writeTextAtomic`](atomic.md) from `@openclaw/fs-safe/atomic`. For in-process serialization, use `createAsyncLock` from the advanced surface, or prefer [`jsonStore`](json-store.md) when you want a JSON-specific read-modify-write helper.
135
+
136
+ ## Common patterns
137
+
138
+ ### Read-modify-write
139
+
140
+ ```ts
141
+ const state = (await readJsonIfExists<State>("./state.json")) ?? initialState();
142
+ state.lastRun = Date.now();
143
+ await writeJson("./state.json", state, { mode: 0o600, dirMode: 0o700 });
144
+ ```
145
+
146
+ ### Atomic with secure mode
147
+
148
+ For credentials or other sensitive JSON, write at mode `0o600`:
149
+
150
+ ```ts
151
+ await writeJson("./auth.json", token, { mode: 0o600, dirMode: 0o700 });
152
+ ```
153
+
154
+ For higher-assurance secrets, prefer the dedicated [secret-file helpers](secret-file.md) — they create the parent directory at `0o700` if missing.
155
+
156
+ ### Strict load on boot
157
+
158
+ ```ts
159
+ let manifest: Manifest;
160
+ try {
161
+ manifest = await readJson<Manifest>("./manifest.json");
162
+ } catch (err) {
163
+ if (err instanceof JsonFileReadError) {
164
+ console.error("manifest unreadable:", err.cause);
165
+ process.exit(1);
166
+ }
167
+ throw err;
168
+ }
169
+ ```
170
+
171
+ ### Concurrent readers, single writer
172
+
173
+ ```ts
174
+ const state = await readJsonIfExists<State>("./state.json");
175
+ // missing returns null; malformed JSON still throws
176
+ ```
177
+
178
+ ## Error reference
179
+
180
+ | Throw / return | When |
181
+ |---|---|
182
+ | `null` (lenient reads) | File missing or contents are not valid JSON. |
183
+ | `JsonFileReadError` | `readJson` or `readJsonIfExists` saw unreadable or invalid input. Inspect `cause`. |
184
+ | Native `NodeJS.ErrnoException` | Lower-level fs errors not wrapped. |
185
+
186
+ ## See also
187
+
188
+ - [JSON store](json-store.md) — a single-file state wrapper with explicit per-call fallback (`readOr` / `updateOr`) and optional sidecar locking.
189
+ - [Atomic writes](atomic.md) — lower-level sibling-temp replacement helpers.
190
+ - [Secret files](secret-file.md) — JSON-or-text writes with mode 0600 in mode 0700 dirs.
191
+ - [Private file-store mode](private-file-store.md) — root-bounded JSON+text state stores.
192
+ - [File lock](sidecar-lock.md) — cross-process coordination.
@@ -0,0 +1,164 @@
1
+ # Local roots
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."
9
+
10
+ ```ts
11
+ import {
12
+ readLocalFileFromRoots,
13
+ resolveLocalPathFromRootsSync,
14
+ } from "@openclaw/fs-safe/advanced";
15
+ ```
16
+
17
+ ## Shape of a "roots input"
18
+
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:
20
+
21
+ ```ts
22
+ 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)
27
+ };
28
+ ```
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)`
33
+
34
+ Synchronous resolution. Returns:
35
+
36
+ ```ts
37
+ type LocalRootsPathResult =
38
+ | { ok: true; absolutePath: string; rootDir: string; relativePath: string }
39
+ | { ok: false; reason: "outside-roots" | "invalid-input" };
40
+ ```
41
+
42
+ ```ts
43
+ import { resolveLocalPathFromRootsSync } from "@openclaw/fs-safe/advanced";
44
+
45
+ const r = resolveLocalPathFromRootsSync("photo.jpg", {
46
+ roots: ["/srv/uploads", "/srv/cache"],
47
+ });
48
+
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
53
+ ```
54
+
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.
67
+
68
+ ## `readLocalFileFromRoots(input, options)`
69
+
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.
71
+
72
+ ```ts
73
+ type LocalRootsReadResult = ReadResult & {
74
+ rootDir: string;
75
+ relativePath: string;
76
+ };
77
+
78
+ const r = await readLocalFileFromRoots("photo.jpg", {
79
+ roots: ["/srv/uploads", "/srv/cache"],
80
+ maxBytes: 8 * 1024 * 1024,
81
+ });
82
+ if (!r) return reply(404);
83
+ process.stdout.write(r.buffer);
84
+ ```
85
+
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.
99
+
100
+ ## `local-file-access` companions
101
+
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:
103
+
104
+ ```ts
105
+ import {
106
+ assertNoWindowsNetworkPath,
107
+ basenameFromMediaSource,
108
+ hasEncodedFileUrlSeparator,
109
+ isWindowsDriveLetterPath,
110
+ isWindowsNetworkPath,
111
+ safeFileURLToPath,
112
+ trySafeFileURLToPath,
113
+ } from "@openclaw/fs-safe/advanced";
114
+ ```
115
+
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
+ ```
159
+
160
+ ## See also
161
+
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.
package/docs/output.md ADDED
@@ -0,0 +1,92 @@
1
+ # External outputs
2
+
3
+ `@openclaw/fs-safe/output` covers the case where another library insists on
4
+ writing to an absolute path you give it. Browser downloads, renderers, media
5
+ tools, and native libraries often have this shape:
6
+
7
+ ```ts
8
+ import { writeExternalFileWithinRoot } from "@openclaw/fs-safe/output";
9
+
10
+ await writeExternalFileWithinRoot({
11
+ rootDir: "/srv/workspace/downloads",
12
+ path: "reports/today.pdf",
13
+ write: async (filePath) => {
14
+ await download.saveAs(filePath);
15
+ },
16
+ });
17
+ ```
18
+
19
+ The external writer never receives the final destination path. It receives a
20
+ private temp file path instead. After the callback returns, fs-safe copies that
21
+ staged file into the requested target through the same root boundary used by
22
+ `Root.copyIn()`.
23
+
24
+ ## Signature
25
+
26
+ ```ts
27
+ function writeExternalFileWithinRoot<T = void>(
28
+ options: ExternalFileWriteOptions<T>,
29
+ ): Promise<ExternalFileWriteResult<T>>;
30
+
31
+ type ExternalFileWriteOptions<T = void> = {
32
+ rootDir: string;
33
+ path: string; // relative or absolute, but must stay under rootDir
34
+ write: (filePath: string) => Promise<T>;
35
+ maxBytes?: number;
36
+ mode?: number;
37
+ };
38
+
39
+ type ExternalFileWriteResult<T = void> = {
40
+ path: string; // final absolute path under the canonical root
41
+ result: T; // value returned by write()
42
+ };
43
+ ```
44
+
45
+ The requested `path` must name a file. Missing destination parents are created
46
+ by the helper because the operation is "produce this output file under the
47
+ root"; callers should choose the filename before calling this API.
48
+
49
+ Use `maxBytes` when the external producer can create arbitrarily large files.
50
+ Use `mode` when the finalized file needs a specific POSIX mode. Both are
51
+ enforced during the `Root.copyIn()` finalization step, after the external writer
52
+ has produced the staged file and before the final target is committed.
53
+
54
+ ## Why not pass the final path to the library?
55
+
56
+ If a target parent can be swapped after validation, handing an external library
57
+ the final path can make the library write outside the intended root before
58
+ fs-safe has a chance to finalize or reject the operation. This helper stages in
59
+ a private temp workspace first, then finalizes with `Root.copyIn()`. That keeps
60
+ the trust-boundary write inside fs-safe's root-aware copy/atomic-write path.
61
+
62
+ ## Browser download example
63
+
64
+ ```ts
65
+ const outputPath = requestedOutputPath || sanitizeBrowserSuggestedName(suggestedFilename);
66
+
67
+ await writeExternalFileWithinRoot({
68
+ rootDir: downloadsRoot,
69
+ path: outputPath,
70
+ maxBytes: 512 * 1024 * 1024,
71
+ write: async (filePath) => {
72
+ await download.saveAs(filePath);
73
+ },
74
+ });
75
+ ```
76
+
77
+ The chosen path may be absolute if it is already inside `downloadsRoot`, or
78
+ relative to `downloadsRoot`. Traversal, symlink parent escapes, hardlinked final
79
+ targets, over-large staged files, and missing temp files surface as
80
+ `FsSafeError`s.
81
+
82
+ This helper is not the right fit when the final filename depends on inspecting
83
+ the produced bytes. In that case, write to a private temp workspace, sniff or
84
+ validate the file, choose the final name, then copy or write into the root with
85
+ the normal root APIs.
86
+
87
+ ## See also
88
+
89
+ - [Root writes](writing.md) — `write`, `copyIn`, `move`, and `mkdir`.
90
+ - [Temp workspaces](temp.md) — private scratch directories for longer workflows.
91
+ - [`pathScope()`](path-scope.md) — validation-only helper when you must pass an
92
+ absolute path directly to another library.
@@ -0,0 +1,130 @@
1
+ # pathScope()
2
+
3
+ `pathScope()` is an advanced helper with the same boundary semantics as `root()`, but it operates on **absolute paths** the caller already trusts and returns plain `{ ok, path }` results instead of throwing. Use it when you want the boundary check up front before handing an absolute path to another library.
4
+
5
+ ```ts
6
+ import { pathScope } from "@openclaw/fs-safe/advanced";
7
+
8
+ const uploads = pathScope("/srv/uploads", { label: "uploads directory" });
9
+
10
+ const photo = uploads.resolve("user/photo.jpg");
11
+ if (!photo.ok) throw new Error(photo.error);
12
+
13
+ await sharp(photo.path).resize(800).toFile(/* … */);
14
+ ```
15
+
16
+ ## When to reach for it
17
+
18
+ `root()` is the right answer when you want method-style I/O (`fs.write`, `fs.readText`). `pathScope()` is the right answer when:
19
+
20
+ - You're handing absolute paths to **other libraries** that take absolute path strings (Sharp, FFmpeg, `tar`, native modules) and just want the boundary check up front.
21
+ - You want to validate user-supplied paths in bulk and return a typed `Result` rather than catching exceptions.
22
+ - You want the scope's `label` to appear in the error message: `"Invalid path: must stay within uploads directory"`.
23
+
24
+ ## Signature
25
+
26
+ ```ts
27
+ function pathScope(rootDir: string, options: PathScopeOptions): PathScope;
28
+
29
+ type PathScopeOptions = {
30
+ label: string; // appears in error messages
31
+ };
32
+
33
+ type PathScopeResolveOptions = {
34
+ defaultName?: string; // used when the requested path is empty
35
+ };
36
+
37
+ type PathResult = { ok: true; path: string } | { ok: false; error: string };
38
+ type PathsResult = { ok: true; paths: string[] } | { ok: false; error: string };
39
+
40
+ type PathScope = {
41
+ rootDir: string;
42
+ label: string;
43
+ resolve(requestedPath: string, options?: PathScopeResolveOptions): PathResult;
44
+ resolveAll(requestedPaths: string[]): PathsResult;
45
+ existing(requestedPaths: string[]): Promise<PathsResult>;
46
+ files(requestedPaths: string[]): Promise<PathsResult>;
47
+ writable(requestedPath: string, options?: PathScopeResolveOptions): Promise<PathResult>;
48
+ ensureDir(requestedPath: string, options?: PathScopeResolveOptions & { mode?: number }): Promise<PathResult>;
49
+ };
50
+ ```
51
+
52
+ ## Methods
53
+
54
+ ### `resolve(rel, options?)`
55
+
56
+ Synchronous, lexical only — no filesystem touch. Resolves the relative path against `rootDir` and verifies the result stays inside the scope. Use when you only need the path string:
57
+
58
+ ```ts
59
+ const r = uploads.resolve("user/photo.jpg");
60
+ if (!r.ok) return reply(400, r.error);
61
+ console.log(r.path); // /srv/uploads/user/photo.jpg
62
+ ```
63
+
64
+ If the requested path is empty, `defaultName` (when set) is used.
65
+
66
+ ### `resolveAll(rels)`
67
+
68
+ Synchronous bulk resolve. Returns `{ ok: true, paths }` if every input is in scope, or `{ ok: false, error }` on the first failure.
69
+
70
+ ### `existing(rels)`
71
+
72
+ Async. Like `resolveAll`, but additionally allows trusted absolute paths whose `realpath` is inside the scope. Useful when callers pass either a relative path or an absolute path you've already validated.
73
+
74
+ ### `files(rels)`
75
+
76
+ Async. Strict variant: every input must already exist as a regular file inside the scope, with `nlink === 1`. Symlinked entries are rejected.
77
+
78
+ ### `writable(rel, options?)`
79
+
80
+ Async. Resolves a writable target: ensures the parent directory exists inside the scope, refuses targets whose existing inode is a symlink or hardlinked, and returns the absolute path to use for the write.
81
+
82
+ ```ts
83
+ const t = await uploads.writable("reports/2026/05/report.pdf");
84
+ if (!t.ok) return reply(400, t.error);
85
+ await fs.writeFile(t.path, body);
86
+ ```
87
+
88
+ ### `ensureDir(rel, options?)`
89
+
90
+ Async. `mkdir -p` inside the scope. Walks each segment, refuses any symlink in the path, creates missing directories. Optional `mode` sets the directory mode.
91
+
92
+ ```ts
93
+ const dir = await uploads.ensureDir("inbox", { mode: 0o755 });
94
+ if (!dir.ok) return reply(500, dir.error);
95
+ ```
96
+
97
+ ## Result type vs throwing
98
+
99
+ `pathScope` returns `{ ok: true, path }` / `{ ok: false, error }` instead of throwing. This makes it pleasant to use at validation boundaries:
100
+
101
+ ```ts
102
+ function handle(req: Req, res: Res) {
103
+ const r = uploads.resolve(req.body.path);
104
+ if (!r.ok) return res.status(400).json({ error: r.error });
105
+ return doWork(r.path);
106
+ }
107
+ ```
108
+
109
+ For exception-flavored APIs, use `root()` instead — it throws `FsSafeError` with a typed `code`.
110
+
111
+ ## Combine with `root()`
112
+
113
+ A common pattern at the edge of an HTTP handler: validate the input with `pathScope`, do the I/O with `root()`:
114
+
115
+ ```ts
116
+ const uploads = pathScope("/srv/uploads", { label: "uploads" });
117
+ const fs = await root("/srv/uploads", { hardlinks: "reject" });
118
+
119
+ const r = uploads.resolve(req.body.path);
120
+ if (!r.ok) return reply(400, r.error);
121
+ const rel = path.relative(fs.rootReal, r.path);
122
+
123
+ const text = await fs.readText(rel);
124
+ ```
125
+
126
+ ## See also
127
+
128
+ - [`root()`](root.md) — method-style boundary that throws on failure.
129
+ - [Path helpers](path.md) — `isPathInside`, `safeRealpathSync`, `isWithinDir` for ad-hoc checks.
130
+ - [Errors](errors.md) — error semantics (note: `pathScope` returns errors, doesn't throw).