@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
@@ -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
 
package/docs/root.md CHANGED
@@ -21,9 +21,10 @@ type RootDefaults = {
21
21
  hardlinks?: "reject" | "allow"; // refuse files with nlink > 1 on read; defaults to "reject"
22
22
  denyMutations?: DenyMutationPolicy; // absolute paths/prefixes mutation methods may not change
23
23
  maxBytes?: number; // refuse reads larger than this many bytes; defaults to 16 MiB
24
- mkdir?: boolean; // create missing parent dirs on write/openWritable/append
24
+ mkdir?: boolean; // create missing parent dirs on write/openWritable/append; default true
25
25
  mode?: number; // file mode applied to new writes; per-call override available
26
- nonBlockingRead?: boolean; // schedule reads on a worker; useful for large files
26
+ nonBlockingRead?: boolean; // compatibility hint; safe opens are already nonblocking where supported
27
+ renameIdentity?: "strict" | "verify-content-with-lock"; // default "strict"
27
28
  symlinks?: "reject" | "follow-within-root"; // policy when a path component is a symlink
28
29
  };
29
30
 
@@ -83,6 +84,10 @@ await using opened = await fs.open("large.log");
83
84
  `containment: "best-effort"`. The field reports the mechanism used; see the
84
85
  [security model](security-model.md#containment-guarantees-by-platform).
85
86
 
87
+ The read methods also accept an absolute spelling that already resolves inside
88
+ the root. `readAbsolute()` and `reader()` make that intent explicit; an absolute
89
+ path outside the root is still rejected.
90
+
86
91
  ### Writes
87
92
 
88
93
  ```ts
@@ -103,6 +108,14 @@ fs.ensureRoot(options?) // accepts "" / "." as the root itself
103
108
 
104
109
  `copyIn` is a one-shot ingest from a trusted absolute source path: it streams the source through the boundary, atomically renames into the root, and respects `maxBytes`.
105
110
 
111
+ Root operations that choose a new destination reject a leading Windows
112
+ drive-relative spelling such as `C:name` on every platform. This applies to
113
+ `write`, `create`, `append`, `openWritable`, `mkdir`, `copyIn`, and the
114
+ destination argument of `move`. In particular, `copyIn(path.basename(source),
115
+ source)` can reject a legal POSIX basename such as `c:photo.png`; callers that
116
+ derive portable destination names from host files must sanitize or map that
117
+ basename first.
118
+
106
119
  `openWritable` opens a writable file with options `mode?: number` and `writeMode?: "replace" | "append" | "update"`. `replace` truncates existing files and is the default; `update` keeps existing contents. Use it for streaming output. Prefer `await using` for cleanup.
107
120
 
108
121
  All mutation methods accept `denyMutations?: { paths?: string[]; prefixes?: string[] }`. Entries must be absolute paths. `paths` blocks those exact paths; `prefixes` blocks those paths and their descendants. fs-safe preserves path strings exactly and canonicalizes through existing ancestors before comparing, so a symlinked ancestor to a denied location is still denied. Denied mutations throw `FsSafeError` with code `denied-path`. Use this for caller-specific sensitive paths, not as a replacement for the root boundary, symlink, or hardlink checks.
@@ -119,6 +132,12 @@ fs.resolve(rel) // absolute path inside the root, after canonic
119
132
 
120
133
  These do not pin a later operation. They are safe to expose to UIs and decision points; for the actual read or write, use the verb methods so the operation pins identity at the point of use.
121
134
 
135
+ `resolve()` is the exception to the existing-object rule: because it selects a
136
+ location for later use, it rejects a leading drive-relative spelling. Reads,
137
+ `stat`, `exists`, `list`, `walk`, `remove`, and the source argument of `move`
138
+ accept an existing POSIX filename such as `c:notes.txt`. For `move`, only the
139
+ new destination name is subject to the portable guard.
140
+
122
141
  ## Native helper mode
123
142
 
124
143
  Create-only writes prefer the bundled native helper for fd-relative opens and
@@ -147,11 +166,14 @@ fs.defaults // the RootDefaults you passed
147
166
 
148
167
  ## Failure semantics
149
168
 
150
- Every method throws `FsSafeError` with a `code`. Branch on `err.code`, not message text. Common codes:
169
+ Boundary and policy failures throw `FsSafeError` with a `code`. Parsing callbacks
170
+ and underlying filesystem operations can also surface `SyntaxError` or native
171
+ `NodeJS.ErrnoException` values. Branch on `err.code`, not message text, after
172
+ checking `err instanceof FsSafeError`. Common fs-safe codes:
151
173
 
152
174
  | Code | When it fires |
153
175
  |---|---|
154
- | `invalid-path` | The input path is malformed, including embedded NUL bytes. |
176
+ | `invalid-path` | The input path is malformed, including embedded NUL bytes. Portable relative-path helpers and `FileStore` keys reject drive-relative segments; Root destination and resolution operations reject a leading drive-relative spelling such as `C:name`. |
155
177
  | `outside-workspace` | The input resolves outside the root, or contains a `..` segment that would escape it. |
156
178
  | `not-found` | The target does not exist (or its parent does not, with `mkdir: false`). |
157
179
  | `not-file` | A read or copy targeted a non-regular file (directory, FIFO, socket, …). |
@@ -23,7 +23,7 @@ import {
23
23
  | The file is a credential (token, key, password). | The file is application state. |
24
24
  | You want the parent directory created at `0o700` if missing. | You don't care about the parent directory mode. |
25
25
  | You want a hard size cap on reads (to defend against bogus input). | You're reading bounded JSON state. |
26
- | Mode `0o600` is mandatory, not just nice. | Mode is whatever umask gives you. |
26
+ | Mode `0o600` and the private parent policy are mandatory. | Standalone `writeJson` also defaults to `0o600`, but it does not enforce the secret-directory ownership and permission policy. |
27
27
 
28
28
  ## Constants
29
29
 
@@ -39,7 +39,10 @@ The 16 KiB cap is intentionally aggressive — credentials should be small. If y
39
39
 
40
40
  ### `tryReadSecretFileSync(filePath, label, options?)`
41
41
 
42
- The lenient reader. Returns the trimmed secret string, or `undefined` when the path is missing or blank. Validation failures, unreadable files, oversized files, symlinks, and hardlinks throw `FsSafeError` so callers fail closed on suspicious credential state.
42
+ The missing-is-optional reader. It returns the trimmed secret string, or
43
+ `undefined` when the `filePath` argument is absent/blank or the target does not
44
+ exist. An existing empty file is invalid and throws, as do unreadable,
45
+ oversized, symlink, hardlink, and other validation failures.
43
46
 
44
47
  ```ts
45
48
  import { tryReadSecretFileSync } from "@openclaw/fs-safe/secret";
@@ -65,12 +68,22 @@ const token = readSecretFileSync("/var/lib/app/auth.token", "auth token");
65
68
  ```ts
66
69
  type SecretFileReadOptions = {
67
70
  maxBytes?: number; // default DEFAULT_SECRET_FILE_MAX_BYTES (16 KiB)
68
- rejectSymlink?: boolean;
71
+ rejectSymlink?: boolean; // default false
69
72
  rejectHardlinks?: boolean; // default true
70
73
  };
71
74
  ```
72
75
 
73
- The reader trims the file content and rejects empty results. `rejectSymlink` blocks a symlink path before the pinned read. Hardlinks are rejected by default so another in-tree name cannot alias the credential; pass `rejectHardlinks: false` only when you explicitly trust that layout.
76
+ The reader trims the file content and rejects empty results. Symlink paths are
77
+ followed and pinned by default; set `rejectSymlink: true` when the pathname
78
+ itself must not be an alias. Hardlinks are rejected by default so another
79
+ in-tree name cannot alias the credential; pass `rejectHardlinks: false` only
80
+ when you explicitly trust that layout.
81
+
82
+ These readers do not enforce ownership or mode bits on an existing file. Their
83
+ read contract covers pinned identity, file type, link policy, and byte bounds;
84
+ the `0o600` guarantee belongs to the write helpers below. Use
85
+ [`readSecureFile`](secure-file.md) when reading an externally managed
86
+ credential must also fail on broad permissions or unexpected ownership.
74
87
 
75
88
  `readSecretFile()` and `tryReadSecretFile()` are asynchronous counterparts with
76
89
  the same pinned-handle validation, byte cap, trimming, error codes, and strict
@@ -22,7 +22,8 @@ case since `5ddca80`. Exposure is limited to consumers that call
22
22
  You hand a `root()` boundary to a piece of code that takes caller-controlled relative paths. The library defends against a caller that:
23
23
 
24
24
  - supplies `..` traversal segments to escape the boundary
25
- - supplies an absolute path where a relative one is expected
25
+ - supplies an absolute path outside a configured root, or to an API whose input
26
+ contract is strictly relative/portable
26
27
  - replaces a path component with a symlink between check and use (TOCTOU)
27
28
  - replaces the destination directory with a symlink right before a write
28
29
  - creates a hardlink that aliases an out-of-tree inode and asks you to read or replace it
@@ -44,7 +45,7 @@ If you need full sandboxing, run the worker under reduced privileges (uid, conta
44
45
 
45
46
  ### Path traversal and absolute paths
46
47
 
47
- Every relative path is resolved against the canonicalized real path of the root, then checked with `isPathInside`. Alias resolution walks components before applying a later `..`, so a symlink cannot change what that parent segment means after validation. Parent traversal that escapes, leading `/` (without `pathScope` opt-in), or any canonical result outside the root throws `outside-workspace`.
48
+ Every path is resolved against the canonicalized real path of the root, then checked with `isPathInside`. Alias resolution walks components before applying a later `..`, so a symlink cannot change what that parent segment means after validation. Parent traversal, an absolute spelling, or any alias whose canonical result is outside the root throws `outside-workspace`; absolute spellings that remain inside the root are accepted.
48
49
 
49
50
  ### Symlinks (read side)
50
51
 
@@ -54,7 +55,20 @@ Per-call `symlinks: "follow-within-root"` allows symlinks whose final target is
54
55
 
55
56
  ### Symlinks (write side)
56
57
 
57
- Writes use a sibling-temp + rename helper that opens the parent directory by fd, then performs the rename `at` the parent fd. Replacing the parent directory with a symlink between the parent-fd open and the rename does not divert the write.
58
+ With the native binding loaded, `write()`, `create()`, and `copyIn()` use a
59
+ sibling temp file and create parents and publish the target relative to pinned
60
+ directory descriptors. Replacement uses descriptor-relative rename just like
61
+ no-replace publication, so replacing the parent pathname does not divert the
62
+ mutation.
63
+
64
+ The JavaScript fallback used by `off`, by `auto` when no binding loads, and by
65
+ the explicit `renameIdentity: "verify-content-with-lock"` compatibility policy
66
+ cannot provide that guarantee because Node exposes no `mkdirat` or `renameat`.
67
+ It asserts directory identity around a pathname mutation and detects many
68
+ swaps, but detection occurs after the kernel may already have followed a new
69
+ parent symlink. A same-privilege peer with write access to the parent can
70
+ therefore cause an out-of-root side effect before the operation throws. Use
71
+ native `require` mode when concurrent hostile mutation is in scope.
58
72
 
59
73
  ### Hardlink aliasing
60
74
 
@@ -64,13 +78,15 @@ When `hardlinks: "reject"` is set, reads stat the target and refuse if `nlink >
64
78
 
65
79
  `resolve()`, `exists()`, `stat()`, and `list()` are explicitly advisory — they answer a question and return. To act on a path with operation-local identity checks, use `read()`, `open()`, `write()`, `create()`, `copyIn()`, `move()`, or `remove()`. The containment table below states which opens are kernel-atomic and which remain best-effort.
66
80
 
81
+ A `root()` handle also remembers the canonical root directory identity. Calls fail with `path-mismatch` if that canonical pathname is replaced, including advisory inspection and walking calls, rather than following a replacement root into another tree.
82
+
67
83
  ### Denied mutations
68
84
 
69
85
  `denyMutations` is an opt-in application policy for `root()` mutation methods. It blocks exact absolute paths with `paths` and whole subtrees with `prefixes`, merging root defaults with per-call entries so a call cannot clear root-level denies. This is not an OS permission boundary: code with access to `node:fs`, a shell, or another process with the same filesystem privileges can bypass it.
70
86
 
71
87
  ### Atomic writes
72
88
 
73
- `replaceFileAtomic` writes to a sibling temp file in the destination directory, optionally `fsync`s it, optionally `fsync`s the parent directory after rename, and atomically renames over the destination. On failure mid-write, the destination is either the old contents (rename never happened) or the new contents (rename succeeded). There is no half-written intermediate state visible at the destination path.
89
+ `replaceFileAtomic` writes to a sibling temp file in the destination directory, optionally `fsync`s it, optionally `fsync`s the parent directory after rename, and atomically renames over the destination. On failure mid-write, the destination is either the old contents (rename never happened) or the new contents (rename succeeded). There is no half-written intermediate state visible at the destination path unless the caller explicitly enables `copyFallbackOnPermissionError`, whose default `copyFallbackRestore: "none"` contract may leave a partial destination after a failed in-place fallback.
74
90
 
75
91
  Within one process, async writes to the same target are queued so their temp-write/rename phases do not overlap. Cross-process writers still need an external protocol such as the sidecar lock helpers.
76
92
 
@@ -120,6 +136,7 @@ The public `OpenResult`, `ReadResult`, and `WritableOpenResult` expose `containm
120
136
  | Absolute paths are escape hatches | APIs that accept or return absolute paths exist for audit, ingest, and advanced composition. Prefer root-relative names in normal application flow. |
121
137
  | Not a mount boundary | `root()` keeps path traversal inside the directory tree and blocks known unsafe read device paths, but it does not make bind mounts or virtual filesystems safe to expose wholesale. |
122
138
  | Per-call, not per-session | Another process with the same privileges can still mutate the tree between calls, and best-effort mechanisms retain documented same-call race windows. Use one verb method to minimize the window and inspect its reported containment class. |
139
+ | JavaScript mutations are detection-based | Without the native binding, Node pathname mutations retain a check-to-syscall race. A writable parent can be swapped so a create, rename, or removal affects an out-of-root path before the fallback detects identity drift. |
123
140
  | Hardlink rejection is best-effort | Link-count checks depend on platform metadata. Treat `hardlinks: "reject"` as a tripwire, not an authorization primitive. |
124
141
  | Mode bits are not a full policy engine | `replaceFileAtomic` and secret-file helpers set requested modes, but you should still set umask and inspect modes when policy requires it. |
125
142
  | Archive extraction is path safety, not content safety | Unsafe entry paths and links are rejected; malicious payload contents remain your application layer's problem. |
@@ -96,6 +96,12 @@ type FileLockRetryOptions = {
96
96
  result is passed to `shouldReclaim` and `shouldRemoveStaleLock`, allowing PID,
97
97
  process-start, argv, or role schemas to remain application-owned.
98
98
 
99
+ On Windows, a pathed `EPERM` from creating or opening the lock file can be a
100
+ short teardown race after another holder unlinks it. The async lock retries that
101
+ specific denial at most eight times. A parent-directory denial, a denial from a
102
+ callback, or a ninth consecutive lock-file denial surfaces as the original
103
+ `EPERM`; it is not converted to `file_lock_timeout`.
104
+
99
105
  ## Owner-scoped reentrancy
100
106
 
101
107
  Version 0.5 removes the unsound process-scoped `allowReentrant` boolean and
@@ -167,10 +173,10 @@ progress.
167
173
  ## Synchronous locks
168
174
 
169
175
  `acquireFileLockSync()` and `withFileLockSync()` mirror filesystem arbitration,
170
- retry, payload parsing, stale policy, guarded identity-conditioned reclaim,
171
- verification, and compromise monitoring. They do not use the async manager
172
- queue, support async callbacks, or provide same-process reentrancy. Retry waits
173
- block the calling thread; use the async API in request-serving code.
176
+ retry, payload parsing, stale policy, owner-scoped reentrancy, guarded
177
+ identity-conditioned reclaim, verification, and compromise monitoring. They do
178
+ not use the async manager queue or support async callbacks. Retry waits block
179
+ the calling thread; use the async API in request-serving code.
174
180
 
175
181
  Always release in a `finally`:
176
182
 
@@ -236,7 +242,11 @@ await locks.drain();
236
242
 
237
243
  ## Stale policy: `shouldReclaim`
238
244
 
239
- The default policy treats locks whose `createdAt` is older than `staleMs` as stale. Pass a custom callback when you want a richer notion of "is the holder still alive":
245
+ The default policy treats locks whose valid `createdAt` is older than `staleMs`
246
+ as stale. A valid current or future timestamp remains authoritative under
247
+ filesystem clock skew; only absent or malformed timestamps fall back to the
248
+ sidecar `mtime`. Pass a custom callback when you want a richer notion of "is the
249
+ holder still alive":
240
250
 
241
251
  ```ts
242
252
  import { kill } from "node:process";
package/docs/temp.md CHANGED
@@ -157,11 +157,15 @@ type TempFile = {
157
157
  path: string; // absolute path; safe to write to
158
158
  dir: string; // the enclosing private workspace dir
159
159
  file(fileName?: string): string; // resolve another file in the same dir
160
- cleanup(): Promise<void>; // removes the private workspace dir
160
+ cleanup(): Promise<void>; // removes the original private workspace dir
161
161
  [Symbol.asyncDispose](): Promise<void>; // alias of cleanup()
162
162
  };
163
163
  ```
164
164
 
165
+ Cleanup captures the directory identity at creation time. If that path is
166
+ renamed away and replaced, cleanup preserves the replacement rather than
167
+ recursively deleting a directory it did not create.
168
+
165
169
  ### `withTempFile`
166
170
 
167
171
  Same shape with auto-cleanup:
@@ -239,13 +243,41 @@ const tempRoot = resolveSecureTempRoot({ fallbackPrefix: "my-app" });
239
243
 
240
244
  ```ts
241
245
  type ResolveSecureTempRootOptions = {
242
- fallbackPrefix: string; // base name for the per-user fallback dir
243
- preferredDir?: string; // optional preferred secure temp root
244
- tmpdir?: () => string; // override os.tmpdir()
246
+ fallbackPrefix: string; // one portable path segment; invalid values throw
247
+ preferredDir?: string; // preferred secure temp root
248
+ skipPreferredOnWindows?: boolean;
249
+ unsafeFallbackLabel?: string; // text used in thrown errors
250
+ warningPrefix?: string; // default "[fs-safe]"
251
+ warn?: (message: string) => void; // default console.warn
252
+
253
+ // Platform/test adapters; production callers normally omit these.
254
+ platform?: NodeJS.Platform;
255
+ getuid?: () => number | undefined;
256
+ tmpdir?: () => string;
257
+ accessSync?: typeof import("node:fs").accessSync;
258
+ chmodSync?: typeof import("node:fs").chmodSync;
259
+ lstatSync?: (path: string) => {
260
+ isDirectory(): boolean;
261
+ isSymbolicLink(): boolean;
262
+ mode?: number;
263
+ uid?: number;
264
+ };
265
+ mkdirSync?: (
266
+ path: string,
267
+ options: { recursive: boolean; mode?: number },
268
+ ) => void;
245
269
  };
246
270
  ```
247
271
 
248
- The directory name embeds the user's UID (POSIX) or username so multi-user systems don't collide. On unsupported platforms, falls back to `os.tmpdir()` directly with a `helper-unavailable` error code surfaced to callers that explicitly required the secure root.
272
+ When `process.getuid()` is available, the fallback is
273
+ `<tmpdir>/<fallbackPrefix>-<uid>`. Without a UID (including Windows), it is
274
+ `<tmpdir>/<fallbackPrefix>`; no username is appended. The helper never returns
275
+ the shared `os.tmpdir()` directory itself. It requires the selected path to be
276
+ a writable, non-symlink directory and, when UID/mode facts are available,
277
+ owned by the current user without group/world write bits. It creates or repairs
278
+ the fallback to mode `0o700` where mode bits apply. If it cannot establish that
279
+ state, it throws an ordinary `Error`; there is no native mode or
280
+ `helper-unavailable` branch on this API.
249
281
 
250
282
  ## Common patterns
251
283