@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
@@ -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.
@@ -30,21 +30,27 @@ Configure the mode once during startup. Loading is lazy and cached; changing fro
30
30
  ## Native boundary
31
31
 
32
32
  The bundled native layer exposes policy-free filesystem mechanisms: beneath-root
33
- open/mkdir/link, no-replace rename, identity reads, archive decode/execution,
33
+ open/mkdir/link, replace and no-replace rename, identity reads, archive decode/execution,
34
34
  clone/copy/hash workers, and Windows security descriptor calls. The TypeScript
35
35
  layer owns policy, retries, filters, budgets, modes, cleanup, error
36
36
  normalization, and the decision to fall back.
37
37
 
38
- - Linux uses `openat2` with `RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS` and `renameat2(RENAME_NOREPLACE)`.
39
- - macOS 15.4 and newer prefer `O_RESOLVE_BENEATH`; older kernels resolve components with `O_NOFOLLOW` and restart in-root symlinks from the pinned root descriptor. Both routes use an `F_GETPATH` post-open escape detector and report `best-effort` because directory rename races are not atomic with that check. No-replace publication uses `renameatx_np(RENAME_EXCL)`.
40
- - Windows uses handle-relative `NtCreateFile`, rejects reparse points, and uses `FileRenameInfoEx` with replacement disabled.
38
+ - Linux uses `openat2` with `RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS`, `renameat`, and `renameat2(RENAME_NOREPLACE)`.
39
+ - macOS 15.4 and newer prefer `O_RESOLVE_BENEATH`; older kernels resolve components with `O_NOFOLLOW` and restart in-root symlinks from the pinned root descriptor. Both routes use an `F_GETPATH` post-open escape detector and report `best-effort` because directory rename races are not atomic with that check. Publication uses `renameat` for replacement and `renameatx_np(RENAME_EXCL)` for no-replace.
40
+ - Windows uses handle-relative `NtCreateFile`, rejects reparse points, and uses `FileRenameInfoEx` with replacement selected explicitly by the TypeScript policy layer.
41
41
 
42
- Native primitives back create-only pinned writes, async sidecar creation,
42
+ Native primitives back create-only and replacing pinned writes, async sidecar creation,
43
43
  guarded publication, archive acceleration, and direct Windows ACL operations.
44
44
  Equivalent JavaScript paths remain available for documented fallback-capable
45
45
  features. See [Native architecture](native.md#javascript-fallback-guarantees-and-delta)
46
46
  for the exact difference.
47
47
 
48
+ The guarded JavaScript mutation path is detection-based, not containment-atomic.
49
+ If a same-privilege peer can replace a writable parent after its identity guard
50
+ but before Node resolves a pathname mutation, the mutation can land outside the
51
+ intended root before the post-operation guard throws. Select `require` rather
52
+ than `auto` or `off` when that concurrent attacker is part of the threat model.
53
+
48
54
  `openBeneath()` returns `{ fd, containment }`. `containment` is
49
55
  `"kernel-atomic"` for Linux `openat2` and `"best-effort"` for macOS and
50
56
  Windows. Public JavaScript root open/read/writable results also expose the
package/docs/native.md CHANGED
@@ -41,7 +41,7 @@ The TypeScript layer validates and decides. The native layer never decides
41
41
  whether a path, archive entry, mode, owner, or cleanup policy is acceptable.
42
42
 
43
43
  - Linux uses `openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS)`, fd-relative
44
- `mkdirat`/`linkat`/`renameat2`, `FICLONE`, and `copy_file_range`.
44
+ `mkdirat`/`linkat`/`renameat`/`renameat2`, `FICLONE`, and `copy_file_range`.
45
45
  - macOS 15.4 and newer first use `openat(O_RESOLVE_BENEATH)`; older kernels walk
46
46
  components with `openat(O_NOFOLLOW)` and restart in-root symlinks from the
47
47
  pinned root. Both routes apply an `F_GETPATH` post-open containment detector,
@@ -99,7 +99,7 @@ remain TypeScript-owned. What changes is the syscall strength or availability:
99
99
 
100
100
  | Capability | Native path | Guarded JavaScript path |
101
101
  |---|---|---|
102
- | Root-relative opens/mutations | Descriptor-relative beneath operations. Linux reports `kernel-atomic`; macOS and Windows report `best-effort`. macOS uses `O_RESOLVE_BENEATH` when available plus an `F_GETPATH` detector, while Windows rejects reparse traversal in the object-manager call. | Reports `best-effort`: component-wise alias checks, no-follow opens where Node exposes them, private temp/rename, and post-operation identity verification. A hostile same-UID peer has a wider pathname race window. |
102
+ | Root-relative opens/mutations | Descriptor-relative beneath operations. Pinned writes create parents and publish both replacement and no-replace targets relative to open directory descriptors. Linux reports `kernel-atomic`; macOS and Windows report `best-effort`. macOS uses `O_RESOLVE_BENEATH` when available plus an `F_GETPATH` detector, while Windows rejects reparse traversal in the object-manager call. | Reports `best-effort`: component-wise alias checks, no-follow opens where Node exposes them, private temp/rename, and post-operation identity verification. A same-privilege peer can replace a writable parent after a guard assertion but before Node resolves the pathname mutation; the mutation may land outside the intended root before the post-check detects it. |
103
103
  | ZIP/TAR/gzip | Rust streaming decode and fd-relative output creation. | JSZip/node-tar into a private stage, then the same guarded merge policy. |
104
104
  | Zstd/bzip2 TAR | Supported. | Unsupported; typed `helper-unavailable`. |
105
105
  | Publication copy | Clone, Linux `copy_file_range`, async native SHA-256. | Exclusive `wx` byte loop and Node SHA-256 with the same content/identity fences. |
package/docs/output.md CHANGED
@@ -51,8 +51,11 @@ root"; callers should choose the filename before calling this API.
51
51
  Use `maxBytes` when the external producer can create arbitrarily large files,
52
52
  and `mode` when the finalized file needs a specific POSIX mode. Both staging
53
53
  modes enforce them after the producer returns and before committing the target.
54
- Requested basenames containing C0/C1 controls or Windows-invalid characters are
55
- sanitized portably; `fallbackFileName` supplies the name when nothing remains.
54
+ Requested basenames containing C0/C1 controls or Windows-invalid characters go
55
+ through the package's filename sanitizer; `fallbackFileName` supplies the name
56
+ when nothing remains. This removes traversal, device-name, and invalid-character
57
+ hazards but does not trim Windows-normalized trailing dots or spaces; reject or
58
+ rewrite those when cross-platform filename uniqueness matters.
56
59
  The same sanitized basename is used for producer staging, guarded internal
57
60
  temps, the final rename target, and the returned `path`; raw and staged names
58
61
  never diverge.
@@ -73,7 +76,9 @@ and size-limit violations, applies `mode`, fsyncs it, and atomically renames it
73
76
  over the target. Existing files and symlink entries are replaced without
74
77
  following their contents or referents. The parent identity is guarded across
75
78
  the operation and the parent directory is synchronized best-effort after
76
- rename.
79
+ rename. If an error leaves the sibling temp in place and immediate cleanup
80
+ fails, its verified identity remains registered for a best-effort process-exit
81
+ cleanup retry.
77
82
 
78
83
  ## Why not pass the final path to the library?
79
84
 
@@ -77,7 +77,10 @@ Async. Strict variant: every input must already exist as a regular file inside t
77
77
 
78
78
  ### `writable(rel, options?)`
79
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.
80
+ Async. Resolves a writable target: requires the parent directory to already
81
+ exist canonically inside the scope, refuses targets whose existing inode is a
82
+ symlink or hardlinked, and returns the absolute path to use for the write. Use
83
+ `ensureDir()` first when the parent needs to be created.
81
84
 
82
85
  ```ts
83
86
  const t = await uploads.writable("reports/2026/05/report.pdf");
package/docs/path.md CHANGED
@@ -19,7 +19,9 @@ import {
19
19
  } from "@openclaw/fs-safe/path";
20
20
  ```
21
21
 
22
- Only `root()`, `FsSafeError`, and the native helper config live on the main entry. Path helpers are deliberately a subpath import so the main entry stays small.
22
+ Path helpers are deliberately a subpath import so the main entry stays focused
23
+ on the root capability, its shared configuration, errors, and external-output
24
+ helper.
23
25
 
24
26
  ## Boundary checks
25
27
 
@@ -62,7 +64,7 @@ Convenience wrapper around `isPathInside`. Same semantics, different name kept f
62
64
  Resolve a base directory to an absolute, normalized form ready for prefix comparison. Pre-normalized directories make subsequent `isPathInside` checks unambiguous.
63
65
 
64
66
  ```ts
65
- const base = resolveSafeBaseDir("/srv/uploads/."); // "/srv/uploads"
67
+ const base = resolveSafeBaseDir("/srv/uploads/."); // "/srv/uploads/"
66
68
  ```
67
69
 
68
70
  ## Realpath and stat
@@ -80,7 +82,9 @@ All `realpath` failures collapse to `null` — there is no distinction between `
80
82
 
81
83
  ### `safeStatSync(targetPath)`
82
84
 
83
- Synchronous `stat` that returns `null` instead of throwing on missing paths. Returns `Stats` on success.
85
+ Synchronous `stat` that returns `Stats` on success and `null` on any failure,
86
+ including missing paths and permission errors. Use `fs.statSync` directly when
87
+ the distinction matters.
84
88
 
85
89
  ```ts
86
90
  const stat = safeStatSync("/srv/uploads/photo.jpg");
@@ -38,6 +38,10 @@ isWorldReadable(bits);
38
38
  isGroupReadable(bits);
39
39
  ```
40
40
 
41
+ POSIX remediation strings shell-quote paths with whitespace or metacharacters
42
+ and protect option-like paths with `--`, so they can be presented as commands
43
+ without letting the inspected pathname add shell syntax.
44
+
41
45
  `inspectPathPermissions()` follows symlink targets for the effective mode but tells you whether the original path was a symlink. On POSIX it reports owner/group/world bits. On Windows it delegates to the ACL helpers below and also reports `ownerSid` plus `ownerTrusted` when ownership can be verified. `ownerTrusted` is true only for a local volume owned by the current user, LocalSystem, or built-in Administrators; remote filesystems fail closed. Secure reads and callers that protect credential-bearing execution require `ownerTrusted === true`.
42
46
 
43
47
  ## Advanced Windows ACL helpers
@@ -67,6 +71,10 @@ resolveWindowsUserPrincipal(env);
67
71
  The fallback Windows inspector calls `icacls.exe <path>` using its supported
68
72
  path-only inspection syntax and classifies principals as trusted, world, or
69
73
  group. Trusted defaults include the current user, SYSTEM, and Administrators.
74
+ Built-in PowerShell, `icacls.exe`, and `whoami.exe` invocations have a fixed
75
+ 30-second per-process deadline. A command failure or timeout returns an
76
+ unverified result (`source: "unknown"`) so callers fail closed. Advanced callers
77
+ that inject a custom `exec` implementation own that executor's deadline.
70
78
  The parser is on the advanced surface so tests and CLIs can process captured
71
79
  `icacls` output without spawning a process.
72
80
 
@@ -0,0 +1,109 @@
1
+ # Public API inventory
2
+
3
+ This page closes the gap between the narrative guides and the complete package
4
+ surface. The generated declarations define the exact signatures; the lists
5
+ below name the lower-frequency exports that are easy to miss when reading only
6
+ the focused guides. `test/public-api.json` guards the same inventory during
7
+ pack checks.
8
+
9
+ ## Main entry and `root`
10
+
11
+ The main entry and `@openclaw/fs-safe/root` expose the root capability types,
12
+ including `ContainmentGuarantee`, `RootOpenOptions`, `RootCreateOptions`,
13
+ `RootCreateJsonOptions`, and `WritableOpenMode`. The root-bounded iterator uses
14
+ `RootWalkOptions`, `RootWalkEntry`, `RootWalkDataEntry`, `RootWalkEntryKind`,
15
+ `RootWalkDataEntryKind`, `RootWalkSymlinkPolicy`, `RootWalkLimitBehavior`,
16
+ `RootWalkDirectoryErrorBehavior`, `RootWalkEntryFilter`, and
17
+ `RootWalkEntryFilterResult`.
18
+
19
+ The root subpath also exports `openLocalFileSafely`, `readLocalFileSafely`, and
20
+ `resolveOpenedFileRealPathForHandle` for trusted absolute-file composition.
21
+ They do not create a root boundary around arbitrary caller input; prefer
22
+ `root()` for untrusted paths.
23
+
24
+ The error helpers are `categorizeFsSafeError` and `FsSafeErrorDetails`. The
25
+ deprecated native-configuration bridge retains the `FsSafePythonConfig` type.
26
+
27
+ ## `path` and `advanced`
28
+
29
+ The lexical path surface additionally exports `isNodeError`,
30
+ `isPathRelativeEscape`, `normalizeWindowsPathForComparison`,
31
+ `resolveSafeRelativePath`, `splitSafeRelativePath`, and
32
+ `matchUnsafeDeviceReadPath`. The device matcher is described by
33
+ `UnsafeDeviceReadPathMatch`, `UnsafeDeviceReadPathOptions`, and
34
+ `UnsafeDeviceReadPathReason`.
35
+
36
+ The advanced root-file primitive exports `OpenRootFileParams`,
37
+ `OpenRootFileSyncParams`, `RootFileOpenResult`, and
38
+ `RootFileOpenFailureReason`. These are composition types for callers building
39
+ their own pinned-open flow, not substitutes for the higher-level `Root` verbs.
40
+
41
+ ## `json` and `store`
42
+
43
+ Standalone structured reads use `ReadJsonOptions`, `ReadRootJsonSyncOptions`,
44
+ `ReadRootStructuredFileSyncOptions`, and `RootStructuredFileReadResult`.
45
+
46
+ The store surface additionally exports `FileStoreReadOptions` and
47
+ `JsonFileStoreOptions`. Durable-queue inspection and recovery use
48
+ `jsonDurableQueueEntryExists`, `loadJsonDurableQueueEntry`,
49
+ `readJsonDurableQueueEntry`, and the `JsonDurableQueueLoadOptions` and
50
+ `JsonDurableQueueReadResult` types. `unlinkBestEffort` is the explicitly
51
+ best-effort cleanup helper used by those queue flows.
52
+
53
+ ## Permissions and secure files
54
+
55
+ Permission inspection exposes `PermissionCheckOptions` and `SafeStatResult`.
56
+ Private-directory creation uses `CreatePrivateDirectoryOptions`. Raw Windows
57
+ descriptor facts use `OwnerAndDaclResult`, `WindowsAccessControlEntry`, and
58
+ `WindowsAceFlags`.
59
+
60
+ Secure reads split their option and result shapes into
61
+ `SecureFileTrustOptions`, `SecureFilePermissionOptions`,
62
+ `SecureFileInjectOptions`, `SecureFileIoOptions`, and `SecureFileReadResult`.
63
+
64
+ ## Locks, walking, and temp workspaces
65
+
66
+ The file-lock diagnostics surface includes `FileLockHeldEntry`,
67
+ `FileLockStaleRecovery`, and `SidecarLockCompromisedInfo`.
68
+ `drainFileLockManagerForTest` and `resetFileLockManagerForTest` are test-only
69
+ manager controls; production code should not use them as lock recovery.
70
+
71
+ Standalone walkers use the `WalkEntryKind` and `WalkSymlinkPolicy` unions.
72
+ Private workspaces expose `TempPathIdentityReceipt` and the
73
+ `TempWorkspaceCleanupResult` union so callers can distinguish removal,
74
+ absence, and identity mismatch.
75
+
76
+ ## Atomic replacement and durability
77
+
78
+ Atomic helper option and receipt types include
79
+ `MovePathWithCopyFallbackOptions`, `ReplaceDirectoryAtomicOptions`,
80
+ `ReplaceFileAtomicSyncOptions`, `ReplaceFileAtomicResult`,
81
+ `ReplaceFileAtomicRestoreCleanup`, `ReplaceFileCopyFallbackRestorePolicy`, and
82
+ `ReplaceFileDestinationHardlinkPolicy`.
83
+
84
+ The durability surface also exports the synchronous strict
85
+ `syncDirectorySync`, plus `DirectoryReceipt`, `DurableDirectoryReceipt`,
86
+ `EnsureDurableDirectoryOptions`, `PublishFileExclusiveResult`,
87
+ `PublishFileExclusiveStrategy`, `PublishFileExclusiveCleanup`,
88
+ `PublishFileExclusiveFailurePhase`,
89
+ `PublishFileExclusiveDirectorySyncFailure`, `Sha256FileInput`, and
90
+ `Sha256FileResult`.
91
+
92
+ ## Archives
93
+
94
+ Archive option and policy types are `ExtractArchiveOptions`,
95
+ `ArchiveEntryFilter`, `ArchiveEntryModePolicy`, and
96
+ `ArchiveFilteredEntryPolicy`. Typed error-code unions are
97
+ `ArchiveFormatErrorCode`, `ArchiveLimitErrorCode`, and
98
+ `ArchiveSecurityErrorCode`. TAR and ZIP preflight composition uses
99
+ `TarEntryInfo` and `ZipArchiveWithFiles`.
100
+
101
+ `createArchiveSymlinkTraversalError` constructs the typed traversal failure
102
+ used by extractors. `resolvePackedRootDir` finds the single packed root when an
103
+ archive layout permits it; neither helper weakens entry validation.
104
+
105
+ ## Keeping this list honest
106
+
107
+ Every runtime and type name in `test/public-api.json` must appear somewhere in
108
+ `README.md` or `docs/`. Documentation examples are also checked so a named
109
+ import cannot silently move to another package subpath.
@@ -18,7 +18,11 @@ const fs = await root("/srv/jobs/incoming", {
18
18
 
19
19
  `root()` resolves the directory through the real filesystem (so symlinked roots become canonical) and verifies it exists. The defaults you pass apply to every call below; per-call options override them.
20
20
 
21
- If the root directory itself does not exist yet, `root()` throws `FsSafeError` with code `not-found`. Either create the directory before calling `root()`, or call `await fs.ensureRoot()` after a successful `root()` to create empty subpaths.
21
+ If the root directory itself does not exist yet, `root()` throws `FsSafeError`
22
+ with code `not-found`; create it before constructing the capability.
23
+ `fs.ensureRoot()` is for generic code that needs to assert or recreate the root
24
+ itself after a handle was successfully constructed. It does not accept a
25
+ subpath and cannot bootstrap a root that prevented `root()` from returning.
22
26
 
23
27
  ## 2. Read and write text
24
28
 
@@ -62,7 +66,7 @@ await fs.remove("notes/archive/today.txt");
62
66
 
63
67
  ```ts
64
68
  const here = await fs.exists("state/config.json"); // boolean
65
- const stat = await fs.stat("state/config.json"); // { kind, size, mtimeMs, ... }
69
+ const stat = await fs.stat("state/config.json"); // { isFile, isDirectory, size, mtimeMs, ... }
66
70
  const names = await fs.list("state"); // string[]
67
71
  const entries = await fs.list("state", { withFileTypes: true }); // DirEntry[]
68
72
  ```
package/docs/reading.md CHANGED
@@ -14,12 +14,12 @@ const opened = await fs.open("large.log"); // FileHandle for strea
14
14
 
15
15
  Regardless of shape, every read goes through the same boundary checks:
16
16
 
17
- 1. Resolve the relative path against the canonical real root.
18
- 2. Reject anything that escapes the root (`outside-workspace`).
19
- 3. Reject `..` segments and absolute inputs (unless via `readAbsolute` with an in-root absolute path).
20
- 4. Reject known unsafe device and process-fd paths before opening (`device-path`).
21
- 5. Open with `O_NOFOLLOW` where available. A symlink in the path triggers `symlink` unless the call's `symlinks` policy is `follow-within-root`.
22
- 6. Stat the open fd and compare to the resolved path's identity (`sameFileIdentity`). A swap mid-call triggers `path-mismatch`.
17
+ 1. Resolve the input lexically against the canonical real root.
18
+ 2. Reject a lexically explicit unsafe device or process-fd namespace (`device-path`). This check precedes component alias policy because paths such as `/dev/fd` are themselves symlinks on common Linux hosts.
19
+ 3. Resolve path components and reject anything that escapes the root (`outside-workspace`).
20
+ 4. Reject `..` traversal and absolute spellings when they resolve outside the root. In-root absolute spellings remain accepted; `readAbsolute` makes that intent explicit.
21
+ 5. Open with `O_NOFOLLOW` where available. Any remaining symlink in the path triggers `symlink` unless the call's `symlinks` policy is `follow-within-root`.
22
+ 6. Compare the pre-open path identity, the open fd, and the post-open resolved path (`sameFileIdentity`). A swap mid-call triggers `path-mismatch`.
23
23
  7. If `hardlinks: "reject"`, refuse files with `nlink > 1` (`hardlink`).
24
24
  8. If `maxBytes` is set, refuse reads larger than the cap (`too-large`).
25
25
 
@@ -36,7 +36,8 @@ console.log(`${stat.size} bytes at ${realPath}`);
36
36
 
37
37
  ### `fs.readText(rel, options?)`
38
38
 
39
- `buffer.toString(encoding)`. Defaults to `defaults.encoding ?? "utf8"`. Pass `encoding` per call to override:
39
+ `buffer.toString(encoding)`. Defaults to `"utf8"`; encoding is a per-call text
40
+ option, not a `RootDefaults` field:
40
41
 
41
42
  ```ts
42
43
  const utf16 = await fs.readText("doc.txt", { encoding: "utf16le" });
@@ -83,14 +84,14 @@ try {
83
84
  type RootReadOptions = {
84
85
  hardlinks?: "reject" | "allow"; // override defaults.hardlinks
85
86
  maxBytes?: number; // refuse reads larger than this many bytes
86
- nonBlockingRead?: boolean; // schedule the read off the main loop
87
+ nonBlockingRead?: boolean; // compatibility hint; safe opens are already nonblocking where supported
87
88
  symlinks?: "reject" | "follow-within-root"; // override defaults.symlinks
88
89
  };
89
90
  ```
90
91
 
91
92
  `maxBytes` is enforced eagerly: the library reads up to `maxBytes + 1` and throws `too-large` if there is more, so a hostile target cannot silently exhaust memory.
92
93
 
93
- `nonBlockingRead` is a scheduling hint. It does not affect safety it lets you keep the event loop responsive when reading large files.
94
+ `nonBlockingRead` remains as a compatibility hint. Safe reads always add the platform's nonblocking open flag where available so a raced FIFO cannot pin a worker indefinitely; regular-file descriptor reads retain normal Node behavior.
94
95
 
95
96
  ## `readAbsolute()` and `reader()`
96
97
 
@@ -101,7 +102,9 @@ fs.readAbsolute(absPath, options?) // ReadResult, abs path must be inside the
101
102
  fs.reader(options?) // (path) => Promise<Buffer>
102
103
  ```
103
104
 
104
- `readAbsolute` accepts absolute paths. Anything outside the root throws `outside-workspace`.
105
+ `readAbsolute` accepts absolute paths. Anything outside the root throws
106
+ `outside-workspace`. It also accepts relative paths for compatibility, but use
107
+ `read()`/`readBytes()` when the input contract is explicitly relative.
105
108
 
106
109
  `reader()` returns a closure that takes either a relative or an absolute path and returns a Buffer. Useful for plugging `fs-safe` into framework loader hooks:
107
110