@openclaw/fs-safe 0.20.0 → 0.21.1

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 (100) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +9 -1
  3. package/dist/advanced.d.ts +2 -0
  4. package/dist/advanced.js +1 -0
  5. package/dist/archive-zip-directory.js +4 -0
  6. package/dist/archive-zip-loader.js +3 -1
  7. package/dist/archive-zip-manifest.js +3 -1
  8. package/dist/atomic.d.ts +1 -1
  9. package/dist/copy-publication.d.ts +1 -3
  10. package/dist/copy-publication.js +2 -6
  11. package/dist/deny-mutation-match.d.ts +2 -0
  12. package/dist/deny-mutation-match.js +71 -0
  13. package/dist/deny-mutations.js +4 -3
  14. package/dist/file-identity.js +10 -2
  15. package/dist/file-lock-sync-admission.js +5 -1
  16. package/dist/file-lock-sync-root-io.d.ts +2 -2
  17. package/dist/file-lock-sync-root-io.js +1 -1
  18. package/dist/file-lock-sync.js +2 -2
  19. package/dist/file-store-prune.js +4 -4
  20. package/dist/mutation-authority.js +5 -0
  21. package/dist/native-binding.d.ts +33 -0
  22. package/dist/native-pinned-write.js +8 -2
  23. package/dist/pinned-mutation-admission.js +4 -2
  24. package/dist/replace-file-buffer.d.ts +4 -0
  25. package/dist/replace-file-buffer.js +36 -0
  26. package/dist/replace-file-copy-fallback.d.ts +2 -0
  27. package/dist/replace-file-copy-fallback.js +66 -38
  28. package/dist/replace-file-descriptor.d.ts +4 -0
  29. package/dist/replace-file-descriptor.js +9 -1
  30. package/dist/replace-file-destination.d.ts +21 -0
  31. package/dist/replace-file-destination.js +123 -0
  32. package/dist/replace-file-mutation.d.ts +26 -0
  33. package/dist/replace-file-mutation.js +47 -0
  34. package/dist/replace-file-temp-owner.d.ts +2 -2
  35. package/dist/replace-file-temp-owner.js +16 -4
  36. package/dist/replace-file-types.d.ts +55 -0
  37. package/dist/replace-file-types.js +1 -0
  38. package/dist/replace-file.d.ts +3 -55
  39. package/dist/replace-file.js +31 -11
  40. package/dist/retained-file-types.d.ts +50 -0
  41. package/dist/retained-file-types.js +1 -0
  42. package/dist/retained-file.d.ts +3 -0
  43. package/dist/retained-file.js +121 -0
  44. package/dist/root-directory-entry.d.ts +9 -0
  45. package/dist/root-directory-entry.js +28 -0
  46. package/dist/root-directory-list.d.ts +9 -1
  47. package/dist/root-directory-list.js +88 -37
  48. package/dist/root-handle-context.d.ts +4 -0
  49. package/dist/root-handle-context.js +12 -0
  50. package/dist/root-impl.d.ts +3 -3
  51. package/dist/root-impl.js +8 -2
  52. package/dist/root-move-noreplace.js +3 -3
  53. package/dist/root-walk.d.ts +19 -12
  54. package/dist/root-walk.js +49 -18
  55. package/dist/sidecar-lock-acquire.js +3 -3
  56. package/dist/sidecar-lock-reclaim.d.ts +2 -2
  57. package/dist/sidecar-lock-reclaim.js +6 -6
  58. package/dist/sidecar-lock.js +4 -4
  59. package/dist/staged-symlink-types.d.ts +4 -14
  60. package/dist/temp-target.js +3 -2
  61. package/dist/temp-workspace-admission.js +22 -21
  62. package/dist/temp-workspace-child-admission.d.ts +1 -1
  63. package/dist/temp-workspace-child-admission.js +14 -9
  64. package/dist/temp-workspace-ownership.d.ts +8 -0
  65. package/dist/temp-workspace-ownership.js +52 -0
  66. package/dist/test-hooks.d.ts +4 -0
  67. package/dist/watch-alias.d.ts +6 -0
  68. package/dist/watch-alias.js +88 -0
  69. package/dist/watch-hints.d.ts +9 -0
  70. package/dist/watch-hints.js +93 -0
  71. package/dist/watch-native.d.ts +36 -0
  72. package/dist/watch-native.js +73 -0
  73. package/dist/watch-scan.d.ts +28 -0
  74. package/dist/watch-scan.js +300 -0
  75. package/dist/watch-stream.d.ts +8 -0
  76. package/dist/watch-stream.js +32 -0
  77. package/dist/watch-types.d.ts +60 -0
  78. package/dist/watch-types.js +1 -0
  79. package/dist/watch.d.ts +5 -0
  80. package/dist/watch.js +530 -0
  81. package/docs/advanced.md +1 -0
  82. package/docs/archive.md +6 -0
  83. package/docs/atomic.md +82 -0
  84. package/docs/contributing.md +74 -6
  85. package/docs/durability.md +7 -0
  86. package/docs/index.md +1 -0
  87. package/docs/install.md +2 -0
  88. package/docs/native-helper.md +9 -0
  89. package/docs/native.md +24 -6
  90. package/docs/public-api.md +23 -2
  91. package/docs/retained-file.md +115 -0
  92. package/docs/root.md +25 -8
  93. package/docs/sidecar-lock.md +1 -1
  94. package/docs/staged-symlink.md +2 -1
  95. package/docs/temp.md +24 -4
  96. package/docs/testing.md +186 -4
  97. package/docs/types.md +6 -0
  98. package/docs/walk.md +22 -1
  99. package/docs/watch.md +251 -0
  100. package/package.json +12 -8
@@ -94,11 +94,11 @@ GLIBC versions. To inspect an existing binding, use
94
94
  `pnpm native:build` remains a host-toolchain development build; it does not
95
95
  establish the GNU release ABI floor.
96
96
 
97
- On Linux x64 with Docker, run `pnpm build`, copy the GNU x64 artifact from
97
+ On Linux x64 or arm64 with Docker, run `pnpm build`, copy the matching GNU artifact from
98
98
  `artifacts/` to `native/`, run `node scripts/stage-host-native.mjs`, then run
99
99
  `bash scripts/test-linux-glibc-floor.sh`. CI uses this command to load the actual
100
100
  artifact and run native security and no-replace move tests in Rocky Linux 8
101
- (glibc 2.28). GNU arm64 is cross-built and symbol-checked in the same CI matrix.
101
+ (glibc 2.28). Both GNU architectures execute this load test on matching runners.
102
102
 
103
103
  ## Test
104
104
 
@@ -167,6 +167,52 @@ pnpm check
167
167
  This runs the filesystem boundary checks, build, tests, and package
168
168
  tarball/import validation.
169
169
 
170
+ The native watch lane requires events on Linux, macOS, and Windows and reports
171
+ actual edit latency. Run `FS_SAFE_TEST_SERIAL=1 pnpm check` to isolate local timing
172
+ checks from the other filesystem stress suites. Watch fixtures use normal OS
173
+ temporary storage; session scratch trees may suppress macOS filesystem events.
174
+
175
+ ### Optional Linux Testbox
176
+
177
+ The manual `testbox-validation.yml` workflow prepares a 16-vCPU Ubuntu 24.04
178
+ Blacksmith Testbox with Node 24.21.0, pnpm 12.4.2, dependencies, the Rust WASM
179
+ target, and the pinned portable archive compiler. It leaves library builds and
180
+ validation commands to the caller and does not replace required CI checks.
181
+
182
+ Use an authenticated Blacksmith CLI with access to the repository and its
183
+ Blacksmith organization. From a full repository checkout, warm one session
184
+ through Crabbox:
185
+
186
+ ```sh
187
+ CRABBOX_BLACKSMITH_IDLE_TIMEOUT=240m crabbox warmup --provider blacksmith-testbox \
188
+ --blacksmith-org openclaw \
189
+ --blacksmith-workflow .github/workflows/testbox-validation.yml \
190
+ --blacksmith-job validate --blacksmith-ref main \
191
+ --idle-timeout 240m --timing-json
192
+ ```
193
+
194
+ Use a branch or tag containing the workflow for `--blacksmith-ref`; GitHub must
195
+ first have registered the workflow on the default branch. The job has a fixed
196
+ 240-minute limit. Keep the idle timeout at that limit (240 minutes in the native
197
+ Blacksmith CLI) because the pinned Testbox action can miss active SSH sessions
198
+ behind a forwarded port. Bound commands by the remaining job time and leave time
199
+ to collect results and stop before the deadline.
200
+
201
+ Use the returned `tbx_...` ID for subsequent commands. The `fs-safe-testbox`
202
+ wrapper restores the prepared tool paths and WASM compiler settings in the SSH
203
+ shell. For example:
204
+
205
+ ```sh
206
+ crabbox run --provider blacksmith-testbox --id <tbx_id> --timing-json -- \
207
+ fs-safe-testbox pnpm check
208
+ crabbox stop --provider blacksmith-testbox <tbx_id>
209
+ ```
210
+
211
+ Stop the session when finished and verify its terminal status. Blacksmith owns
212
+ checkout synchronization; record the tested source revision or diff, Testbox ID,
213
+ and Actions run. This backend is Linux-only and does not accept Crabbox's direct
214
+ SSH `--script` or `--download` flags. The workflow provides no application secrets.
215
+
170
216
  ### Method benchmarks
171
217
 
172
218
  `pnpm benchmark:methods` measures the callable library surface against synthetic
@@ -231,10 +277,32 @@ require the pnpm lifecycle CLI path; JavaScript CLIs run through Node and standa
231
277
  direct `node` invocation without lifecycle metadata is unsupported. Archive
232
278
  codecs and their dependencies are packed from the installed dependency graph.
233
279
 
234
- PR CI builds and executes four host targets: Linux x64 glibc, Linux x64 musl
235
- (Alpine), macOS arm64, and Windows x64. The root-only smoke runs on each. The
236
- seven-target source build matrix runs on release tags; packaging all seven is
237
- not execution proof for every architecture. The smoke writes manager versions,
280
+ PR CI builds and executes all seven shipped bindings. The existing check names
281
+ stay stable; extra runner/runtime combinations add checks without changing the
282
+ repository ruleset. Native lanes run Node 24; the GNU host lanes also exercise
283
+ Bun 1.4.2 (as do macOS and Windows).
284
+
285
+ | Lane | Runner | Architecture / libc | Runtime |
286
+ | --- | --- | --- | --- |
287
+ | JavaScript check | `ubuntu-latest` | x64 / glibc | Node 22, 24, 26 |
288
+ | JavaScript check | `macos-15` | arm64 | Node 22, 24, 26 |
289
+ | JavaScript check | `fs-safe-windows-16core` (`windows-latest`) | x64 | Node 22, 24, 26 |
290
+ | Native check | `ubuntu-latest` | x64 / glibc | Node 24, Bun 1.4.2 |
291
+ | Native check (forced no-openat2) | `ubuntu-latest` | x64 / glibc | Node 24 |
292
+ | Native check | `ubuntu-24.04-arm` | arm64 / glibc | Node 24, Bun 1.4.2 |
293
+ | Native check | `macos-15` | arm64 | Node 24, Bun 1.4.2 |
294
+ | Native check | `macos-15-intel` | x64 | Node 24, Bun 1.4.2 |
295
+ | Native check | `fs-safe-windows-16core` (`windows-latest`) | x64 | Node 24, Bun 1.4.2 |
296
+ | Native check | `windows-2022` (standard hosted) | x64 | Node 24, Bun 1.4.2 |
297
+ | Native check (Alpine 3.24) | `ubuntu-latest` | x64 / musl | Node 24 |
298
+ | Native check (Alpine 3.24) | `ubuntu-24.04-arm` | arm64 / musl | Node 24 |
299
+ | GNU glibc 2.28 build + Rocky Linux 8 load | `ubuntu-latest` | x64 / glibc | Node 24 |
300
+ | GNU glibc 2.28 build + Rocky Linux 8 load | `ubuntu-24.04-arm` | arm64 / glibc | Node 24 |
301
+ | Bundled package smoke | `ubuntu-latest`, `macos-15`, `fs-safe-windows-16core` | host | Node 22, 24 |
302
+ | Coverage | `ubuntu-latest`, `macos-15`, `fs-safe-windows-16core` | host | Node 22 |
303
+
304
+ Both musl lanes also run root-only package smoke with the real host binding.
305
+ The seven-target source build matrix still runs on release tags. The smoke writes manager versions,
238
306
  cases, and synthetic-fixture scope to `release-artifacts/consumer-proof.json`.
239
307
 
240
308
  ## Docs
@@ -436,3 +436,10 @@ owning product boundary.
436
436
  - [Migrating to 0.5](migrating-to-0.5.md) — choosing a publication policy during upgrade.
437
437
  - [Native architecture](native.md) — clone/copy/hash mechanisms and fallback guarantees.
438
438
  - [Errors](errors.md) — typed operational failure handling.
439
+
440
+ ## Existing Windows file retirement
441
+
442
+ [`retainFileInDirectory`](retained-file.md) describes identity-bound native
443
+ disposition and resource settlement separately from persistence. Its result is
444
+ always `persistence: "not-proven"`; neither accepted disposition nor observed
445
+ namespace absence is a directory/volume barrier or an application commit.
package/docs/index.md CHANGED
@@ -65,6 +65,7 @@ await fs.remove("notes/archive/today.txt");
65
65
  | [Private file-store mode](private-file-store.md) | `fileStore({ private: true })` for private JSON/text state at 0600 under 0700 dirs. |
66
66
  | [`tempWorkspace`](temp.md) | 0700 scratch dir with auto-cleanup. |
67
67
  | [`readSecureFile`](secure-file.md) | Absolute file reads with fd pinning, permissions, owner, size, and timeout checks. |
68
+ | [`watch`](watch.md) | Guarded filesystem observation with advisory native hints and polling. |
68
69
  | [`walkDirectory` / `Root.walk`](walk.md) | Standalone inventories plus root-bounded pruning, budgets, and partial-error reporting. |
69
70
  | [`Root.entries`](entries.md) | Guarded nonrecursive entries, bounded name collection, and caller-owned symlink validation. |
70
71
  | [`extractArchive`](archive.md) | Policy-driven ZIP/TAR extraction with clamp/filter, metadata/path-depth, link, count, and byte limits. |
package/docs/install.md CHANGED
@@ -130,6 +130,8 @@ features, including strict owned-tree temp cleanup, retained-directory staging,
130
130
  and atomic `rename-noreplace` (including the default no-clobber `Root.move()`),
131
131
  remain unavailable. Operations without a safe fallback fail with `helper-unavailable`
132
132
  when the matching package is absent, incompatible, or disabled.
133
+ No-clobber `Root.move()` preserves a native loader failure in the error's
134
+ `cause`, including the original missing-library or incompatible-glibc diagnostic.
133
135
 
134
136
  Upgrading an existing 0.5 consumer? Follow [Migrating to 0.6](migrating-to-0.6.md)
135
137
  before deploying with native mode `require` or native-only features.
@@ -182,3 +182,12 @@ consumer performs its 0.5 upgrade.
182
182
  - [Durability](durability.md)
183
183
  - [Migrating to 0.5](migrating-to-0.5.md)
184
184
  - [Migrating to 0.6](migrating-to-0.6.md)
185
+
186
+ ### Retained existing Windows file
187
+
188
+ The maintained Windows helper supports the public
189
+ [`retainFileInDirectory`](retained-file.md) lifecycle on fixed local NTFS.
190
+ Private native handles, exact identity/generation checks, writable-section
191
+ admission and explicit close results stay behind that public API. Older helpers
192
+ without this capability are unsupported; there is no pathname deletion fallback.
193
+ No Windows namespace persistence barrier is provided.
package/docs/native.md CHANGED
@@ -96,12 +96,30 @@ operation is still a permission error and never triggers a retry. Install
96
96
  syscall filters before the first native operation; a later `ENOSYS` fails with
97
97
  `ENOTSUP`, rather than changing the cached mechanism during a call.
98
98
 
99
- The fallback opens each directory relative to a retained descriptor with
100
- `O_PATH | O_DIRECTORY | O_NOFOLLOW`, compares exact device/inode/type identities,
101
- and rechecks the retained parent chain before and after the final no-follow
102
- open. It rejects all symlink components, including procfs magic links, even
103
- when the low-level caller requests symlink following. Public Root policy and
104
- canonical-path admission, hardlink rejection, pinned-file checks, and mutation
99
+ The fallback inspects components with `O_PATH | O_NOFOLLOW`, follows relative
100
+ symlink targets using a stack of retained directory descriptors, and rejects
101
+ absolute targets or `..` past the retained root with `EXDEV`. It permits at most
102
+ 40 link expansions (`ELOOP` beyond that limit). Final `O_NOFOLLOW` and exclusive
103
+ creation retain their syscall semantics, including opening the link itself with
104
+ `O_PATH | O_NOFOLLOW` and creating through a dangling in-root relative link.
105
+ Exact device/inode/type identities are checked before and after the final
106
+ no-follow open; followed links also retain their descriptors and have their
107
+ named identities and target strings rechecked. Detected replacements fail with
108
+ `EXDEV`, including changes to directories left behind by a link's `..` target.
109
+
110
+ The fallback also honors `nosymfollow` mount restrictions on retained links.
111
+ Two conservative restrictions remain: the fallback refuses to follow **any
112
+ procfs symlink** with `ELOOP`, including ordinary links such as `/proc/mounts`.
113
+ Userspace metadata cannot distinguish these from procfs magic links, which
114
+ `RESOLVE_NO_MAGICLINKS` must never follow. Opening a final link itself with
115
+ `O_PATH | O_NOFOLLOW` remains allowed. In a sticky, world-writable directory,
116
+ the fallback refuses all symlink following with `EACCES`, even when the calling
117
+ thread or directory owner owns the link, or `fs.protected_symlinks=0`. This
118
+ preserves Linux's protected-symlink restriction without assuming the thread's
119
+ filesystem UID or treating equal mapped `stat` UIDs as proof of equal kernel
120
+ owners (distinct unmapped owners can both appear as the overflow UID).
121
+
122
+ Public Root policy and canonical-path admission, hardlink rejection, pinned-file checks, and mutation
105
123
  identity fences remain in place. `openBeneath()` reports `best-effort`: these
106
124
  identity samples detect replacements but cannot make a multi-component walk
107
125
  atomic against a hostile process renaming directories between samples. This
@@ -79,6 +79,16 @@ missing relative suffixes beneath an existing directory using bounded temporary
79
79
  directory probes. The caller owns Unicode-pair policy, caching, and the fallback
80
80
  for `undefined`. See [path suffix alias probing](path-suffix-aliases.md).
81
81
 
82
+ `retainSymlinkInDirectory` holds an explicitly identified POSIX symlink through
83
+ exact-slot no-replace publication; its receipts use the `StagedSymlink*` and
84
+ `PublishedSymlinkReceipt` types. See [staged symlinks](staged-symlink.md).
85
+
86
+ `retainFileInDirectory` retains an existing Windows NTFS file through a native
87
+ handle for explicit identity-bound retirement. It is described by
88
+ `RetainFileInDirectoryOptions`, `RetainedFile`, `RetainedFileAdmission`,
89
+ `RetainedFileExpected`, `RetainedFileIssue`, `RetainedFileReceipt`, and
90
+ `RetainedFileResult`. See [retained Windows files](retained-file.md).
91
+
82
92
  ## Guest source
83
93
 
84
94
  `@openclaw/fs-safe/guest` exports `GUEST_FILESYSTEM_PYTHON`,
@@ -132,8 +142,11 @@ parent/workspace descriptors; see the
132
142
  Atomic helper option and receipt types include
133
143
  `MovePathWithCopyFallbackOptions`, `ReplaceDirectoryAtomicOptions`,
134
144
  `ReplaceFileAtomicSyncOptions`, `ReplaceFileAtomicResult`,
135
- `ReplaceFileAtomicRestoreCleanup`, `ReplaceFileCopyFallbackRestorePolicy`, and
136
- `ReplaceFileDestinationHardlinkPolicy`.
145
+ `ReplaceFileAtomicRestoreCleanup`, `ReplaceFileCopyFallbackRestorePolicy`,
146
+ `ReplaceFileDestinationHardlinkPolicy`, and `ReplaceFileAtomicDestinationState`.
147
+ The `assertBeforeMutation` option rechecks caller authority before new effects,
148
+ and `onDestinationState` reports retained destination identities as
149
+ `ReplaceFileAtomicDestinationState` values; see [atomic writes](atomic.md).
137
150
 
138
151
  The durability surface also exports the synchronous strict
139
152
  `syncDirectorySync`, plus `DirectoryReceipt`, `DurableDirectoryReceipt`,
@@ -160,6 +173,14 @@ Archive option and policy types are `ExtractArchiveOptions`,
160
173
  used by extractors. `resolvePackedRootDir` finds the single packed root when an
161
174
  archive layout permits it; neither helper weakens entry validation.
162
175
 
176
+ ## `watch`
177
+
178
+ `@openclaw/fs-safe/watch` exports `watch()` plus `WatchScope`, `WatchEntry`,
179
+ `WatchChange`, `WatchInvalidation`, `WatchFailure`, `WatchHealth`,
180
+ `WatchOptions`, and `WatchSubscription`. Invalidations are advisory; guarded
181
+ scans stay authoritative. See [filesystem observation](watch.md) for modes,
182
+ budgets, `persistent`, and lifecycle.
183
+
163
184
  ## Keeping this list honest
164
185
 
165
186
  Every runtime and type name in `test/public-api.json` must appear somewhere in
@@ -0,0 +1,115 @@
1
+ ---
2
+ title: Retained Windows files
3
+ description: "Explicit identity-bound retirement of an existing file; resource settlement is not persistence."
4
+ ---
5
+
6
+ # Retain an existing Windows file
7
+
8
+ `retainFileInDirectory` from `@openclaw/fs-safe/advanced` retains **one existing
9
+ regular direct child**, without creating or deleting anything at admission.
10
+ It requires the matching native package. There is no pathname-unlink fallback.
11
+ The initial implementation supports fixed local NTFS drives only; other systems
12
+ return `unsupported`.
13
+
14
+ ```ts
15
+ import { retainFileInDirectory } from "@openclaw/fs-safe/advanced";
16
+
17
+ const admission = retainFileInDirectory({
18
+ directory: producer.directory, // canonical C:\... spelling
19
+ parent: producer.parentIdentity, // exact bigint dev/ino
20
+ basename: producer.basename,
21
+ expected: producer.expected, // bigint dev/ino/size/mtimeNs/ctimeNs and SHA-256
22
+ assertBeforeMutation: () => producer.assertCurrentExclusiveAuthority(),
23
+ });
24
+ if (admission.status === "retained") {
25
+ using file = admission.file;
26
+ const outcome = file.remove(); // explicit; using alone does NOT delete
27
+ // outcome.persistence is always "not-proven".
28
+ producer.recordRetirementObservation(outcome);
29
+ }
30
+ ```
31
+
32
+ The producer must capture its expected identity, generation and bytes during its
33
+ own creation/ownership protocol. Reading an arbitrary current pathname immediately
34
+ before admission does **not** authenticate the producer. `Number` identities,
35
+ zero/unknown identities, negative/overflowing identities, and malformed digests
36
+ are rejected. A full opaque native volume/file ID is included in the receipt;
37
+ no native handle or numeric descriptor is exposed. Receipts are immutable facts,
38
+ not transferable authority.
39
+
40
+ ## Public types
41
+
42
+ - `RetainFileInDirectoryOptions`: immutable admission inputs and synchronous authority.
43
+ - `RetainedFileExpected`: exact producer identity, write generation and expected digest.
44
+ - `RetainedFileAdmission`: a retained `RetainedFile` or a refusal/settlement result.
45
+ - `RetainedFileReceipt`: immutable admitted facts, never reusable authority.
46
+ - `RetainedFileResult`: separate disposition, namespace, resources and persistence facts.
47
+ - `RetainedFileIssue`: phase, native code/message and optional original authority cause.
48
+
49
+ ## Admission and mutation custody
50
+
51
+ The directory's ancestry is opened component by component without following
52
+ reparse points, with delete sharing denied. Its exact expected parent identity
53
+ and canonical path are checked. The file is opened relative to that retained
54
+ parent, with write/delete sharing denied. Existing writer handles refuse
55
+ admission; a read-oplock grant also excludes preexisting writable mapped
56
+ sections. That oplock request is cancelled and joined before admission returns,
57
+ while the no-write-sharing file handle remains open. No detached request remains.
58
+
59
+ The opened file must match the expected exact NTFS identity, single-link regular
60
+ type, write/change generation, size and SHA-256. Names with stream/device syntax,
61
+ trailing-dot/space aliases and observed named data streams are unsupported.
62
+ Readonly attributes and ACL denials are not repaired or overridden. Verification
63
+ is synchronous and bounded by `maxBytes` (default 16 MiB, maximum 64 MiB).
64
+
65
+ The original producer must retain **exclusive mutation custody for the entire
66
+ file**, including alternate streams, attributes, security and hardlink creation.
67
+ Windows read/write sharing is per-stream; it is not an application lock over
68
+ all possible aliases. The helper rejects observed alternate streams and changed
69
+ generations, but does not turn a point-in-time check into exclusion of concurrent
70
+ alias/attribute operations. The synchronous `assertBeforeMutation` must check
71
+ that this original authority is still current. Callers unable to establish that
72
+ custody must not call `remove`. Privileged/raw-volume/kernel modifications are
73
+ outside this capability's threat model.
74
+
75
+ `remove()` admits authority once, revalidates the retained object, sets native
76
+ handle disposition, closes the file, observes the name under the still-retained
77
+ parent, then closes ancestry. No pathname is passed to unlink. Ordinary
78
+ `dispose()` and `[Symbol.dispose]()` only close resources. Authority callbacks
79
+ returning Promises, thenables, or synchronous or asynchronous generator objects
80
+ are refused without deletion; generators are never advanced. Reentrancy is also
81
+ refused, and even caught reentrancy poisons that attempt.
82
+ Repeated settlement returns the original receipt without another mutation or
83
+ another authority call. A copied receipt cannot be used to remove a replacement.
84
+ `[Symbol.dispose]()` throws `FsSafeError` with the complete result in
85
+ `error.details.result` if resource settlement is uncertain; `dispose()` returns
86
+ that result directly. GC closes only and produces no settlement receipt; use
87
+ explicit disposal.
88
+
89
+ ## Read the facts separately
90
+
91
+ | Field | Meaning |
92
+ | --- | --- |
93
+ | `status` | `unsupported`, `not-attempted`, `preserved-mismatch`, `disposition-accepted`, `name-absent-after-settlement`, `failed`, or `indeterminate`. |
94
+ | `disposition` | Whether native deletion was unattempted, accepted, rejected, or indeterminate. |
95
+ | `namespace` | A post-file-close observation: absent, original, foreign, unknown, or not observed. Absence alone never authenticates prior deletion. |
96
+ | `resources` | `closed` or `close-failed`; operation and close errors are retained together. An uncertain close is never retried against a possibly recycled handle. |
97
+ | `persistence` | Always `not-proven`; no namespace persistence barrier was performed. |
98
+
99
+ An accepted disposition can still have an unknown/foreign/original namespace
100
+ observation or failed close. A later replacement may exist even after observed
101
+ absence. Existing read handles can retain access to old bytes after the name is
102
+ absent. Namespace observation does not certify all foreign handles are closed.
103
+ An unexpected native binding failure is indeterminate, not success.
104
+
105
+ ## No persistence or service-transaction guarantee
106
+
107
+ This API does not flush a volume, implement a journal, certify power-loss-safe
108
+ unlink, or issue an application dependency release. Process-termination tests
109
+ establish process-owned handle lifetime only, not crash/storage durability.
110
+
111
+ An update transaction must independently qualify either a real namespace
112
+ persistence barrier or original-owned durable recovery with correct ordering,
113
+ generation binding and restart consumption. Until then it must retain its
114
+ uncertain recovery/dependency state. Successful removal of one payload does not
115
+ establish a committed multi-file transaction or justify deleting its receipt.
package/docs/root.md CHANGED
@@ -68,7 +68,10 @@ fs.walk(rel, options) // root-bounded AsyncIterable<{ relativePath, kin
68
68
 
69
69
  `walk()` is the incremental, root-bounded recursive scan. It supports entry and
70
70
  depth budgets, cancellation, and `symlinkPolicy: "skip" |
71
- "follow-within-root"`. With an entry budget, sorted walks prepare small metadata
71
+ "follow-within-root" | "include"`. Include mode returns links as
72
+ `{ relativePath, kind: "symlink", size }` without resolving or entering their
73
+ targets, including dangling and outside-root links. `size` describes the link,
74
+ not its target. With an entry budget, sorted walks prepare small metadata
72
75
  batches within the remaining budget; unbounded sorted walks reuse the full
73
76
  directory snapshot.
74
77
  The default `order: "sorted"` enumerates and sorts each directory's names;
@@ -92,6 +95,8 @@ Directory reads remain fail-fast by default. With
92
95
  `{ relativePath, kind: "directory-error", size: 0, error }` and continues with
93
96
  the remaining tree. That policy also covers identity-check failures after an
94
97
  awaited filter, while callback failures always reject.
98
+ In include mode, a directory that becomes a symlink before descent is a
99
+ `path-mismatch` directory error; it is never silently omitted or followed.
95
100
  See [Directory walking](walk.md) for the pure-Node guarantees and the contrast
96
101
  with the standalone best-effort walkers.
97
102
 
@@ -258,8 +263,10 @@ occurred, later cancellation or verification failure preserves the destination.
258
263
  The synchronous optional `onDestinationPublished` callback receives a frozen
259
264
  `RootCopyPublicationReceipt` containing `{ path, dev, ino }`, with exact bigint identity immediately after
260
265
  publication, before later checks can fail. Callback errors also preserve the
261
- published file. This receipt records an outcome; it does not authorize removing
262
- a file that another actor may have edited. Application recovery and cooperative
266
+ published file. Promise, thenable, and synchronous or asynchronous generator
267
+ results reject with `TypeError`; returned generators are never advanced. Other
268
+ synchronous return values are ignored. This receipt records an outcome; it does
269
+ not authorize removing a file that another actor may have edited. Application recovery and cooperative
263
270
  locking remain caller-owned.
264
271
 
265
272
  Existing `copyIn` callers must account for completed destinations retained after
@@ -319,10 +326,12 @@ truncation, append, move, and removal. Buffered writes use bounded chunks and
319
326
  recheck before every partial-write submission; file removal submits a direct
320
327
  unlink request. Native calls that perform multiple filesystem steps are one
321
328
  dispatch. No asynchronous wait separates the check
322
- from that dispatch. A thrown value rejects the operation unchanged; an async
323
- or thenable-returning callback rejects with `TypeError` before that mutation.
324
- Synchronous return values are ignored. Callbacks can run multiple times and
325
- must inspect current authority each time.
329
+ from that dispatch. A thrown value rejects the operation unchanged; a Promise,
330
+ thenable, or synchronous or asynchronous generator result rejects with `TypeError`
331
+ before that mutation. Returned generators are never advanced. Other synchronous
332
+ return values are ignored. Generator detection applies to generator objects
333
+ themselves, not proxy wrappers. Callbacks can run multiple times and must inspect
334
+ current authority during each call; return-value validation does not establish it.
326
335
  Directory creation rechecks the retained parent after the callback and before
327
336
  submitting mkdir, so a replacement is rejected before creating that component.
328
337
  Overwrite moves recheck the retained root, parents, source identity and both
@@ -349,7 +358,9 @@ For `openWritable()`, the callback covers the library's parent creation,
349
358
  exclusive creation, and truncation. The returned raw `FileHandle` belongs to
350
359
  the caller, which must check authority before its own later writes.
351
360
 
352
- 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.
361
+ 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. Missing suffixes also match prospective case and canonical Unicode normalization aliases: case-folding and Unicode NFC/NFD-equivalent spellings cannot bypass a denied path or prefix. A read-only observation of the existing parent can establish that ASCII case variants are distinct. Admission never creates temporary probe files or directories. If sensitivity cannot be established (including empty or unreadable parents, future directories, and Unicode normalization), equivalent suffixes are denied conservatively, even on a filesystem that would allow distinct names. Distinct existing canonical ancestors and unrelated names remain distinct. Observations are local to each synchronous policy check and are refreshed after callbacks or mutations. This conservative fallback does not model other filesystem-specific equivalences, such as HFS+ ignorable formatting characters.
362
+
363
+ 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.
353
364
 
354
365
  `move()` snapshots its merged default and per-call mutation policy before
355
366
  asynchronous preparation. Later changes to the original policy objects or arrays
@@ -384,6 +395,12 @@ fs.entries(rel, options?) // nonrecursive AsyncIterable<DirEntry>, includ
384
395
  fs.resolve(rel) // absolute path inside the root, after canonicalization
385
396
  ```
386
397
 
398
+ Directory enumeration (`list`, `entries`, and `walk`) requires UTF-8 filenames.
399
+ A discovered name that cannot be represented losslessly as a JavaScript string
400
+ rejects with `invalid-path`, before looking up metadata through that name.
401
+ Streaming traversal may already have yielded earlier entries. Literal Unicode
402
+ replacement characters (`U+FFFD`) remain valid names.
403
+
387
404
  These do not pin a later operation. During `stat()`, the exact selected target and
388
405
  parent are checked around metadata collection; `list()` checks one exact selected
389
406
  directory around the complete name/metadata batch instead of repeating containment
@@ -61,7 +61,7 @@ Always release locks in a `finally` block. Application-managed graceful shutdown
61
61
 
62
62
  Exit cleanup tolerates shared managers created by older package copies that lack reclaim-guard state, and continues through later manager domains. Handler ownership remains first-registration-wins: loading an updated copy does not replace an older copy's registered handler. First registration is committed only after Node accepts the listener; synchronous `newListener` reentry fails closed and a thrown registration rolls back so a later acquisition can retry. Restart with the updated copy registering first to obtain the fix. After building, `node scripts/legacy-lock-exit-proof.mjs` checks clean exit, removal of legacy and modern raw locks, and preservation of a retained lock using synthetic temporary files.
63
63
 
64
- Each new sidecar also carries an internal random ownership token encoded as JSON trailing whitespace. `JSON.parse()` and every payload callback still see exactly the caller-provided object. Only the process that successfully created the sidecar keeps that token as release authority; merely reading token-shaped bytes from disk does not enable this mode. Release compares the in-memory token and exact serialized bytes, and requires the pathname to remain a regular file, instead of requiring an opened descriptor and pathname lookup to report the same inode identity. This preserves ownership checks on filesystems such as Docker Desktop VirtioFS where those two views can legitimately differ. Sidecars created by older releases have no token and retain the legacy identity-plus-content check. If Windows reports a zero device or inode for either identity observation, that check is inconclusive and removal is skipped.
64
+ Each new sidecar also carries an internal random ownership token encoded as JSON trailing whitespace. `JSON.parse()` and every payload callback still see exactly the caller-provided object. Only the process that successfully created the sidecar keeps that token as release authority; merely reading token-shaped bytes from disk does not enable this mode. Release compares the in-memory token and exact serialized bytes, and requires the pathname to remain a regular file, instead of requiring an opened descriptor and pathname lookup to report the same inode identity. This preserves ownership checks on filesystems such as Docker Desktop VirtioFS where those two views can legitimately differ. Sidecars created by older releases have no token and retain the legacy identity-plus-content check. Raw acquisition, snapshot, and exit cleanup capture bigint device/inode values; unsafe numeric identities from older in-process receipts cannot authorize cleanup. If Windows reports a zero device or inode for either identity observation, that check is inconclusive and removal is skipped.
65
65
 
66
66
  Last-chance raw-lock exit cleanup requires known Windows device and inode values from both pathname observations and the opened descriptor. A zero value leaves the sidecar in place, including for token-owned locks. Known descriptor/path identity differences remain supported; a pathname identity change during the read still prevents cleanup.
67
67
 
@@ -86,7 +86,8 @@ the original receipt is never replaced by a post-publication identity.
86
86
  touching descriptor numbers.
87
87
  - `await using` invokes cleanup and raises on a `preserved` outcome. Invocation
88
88
  order serializes descriptor work; reentrant calls from the authority callback
89
- reject. Callbacks must be synchronous; returned thenables are refused.
89
+ reject. Callbacks must be synchronous; returned thenables and synchronous or
90
+ asynchronous generator objects are refused. Generators are never advanced.
90
91
 
91
92
  Errors carry `StagedSymlinkFailureDetails` in `FsSafeError.details`: the phase,
92
93
  recorded `publication`, and a cleanup receipt when applicable. Publication is
package/docs/temp.md CHANGED
@@ -22,10 +22,11 @@ substitutions; the compatible JavaScript fallback has the narrower race
22
22
  contract documented below.
23
23
 
24
24
  On POSIX, workspace creation verifies the supplied root and its canonical
25
- ancestors before creating a child. Each existing directory must be owned by the
26
- effective user or root. Group/world-writable directories must also have the
27
- sticky bit, so ordinary system temp directories remain usable without changing
28
- their modes. Foreign-owned directories and non-sticky writable ancestors reject
25
+ ancestors before creating a child. The supplied root must be owned by the
26
+ effective user and must not be group/world writable, even with the sticky bit.
27
+ Use a private per-user directory rather than supplying a shared `/tmp` directly.
28
+ Ancestors must be owned by the effective user or root; group/world-writable
29
+ ancestors must have the sticky bit. Foreign-owned directories and non-sticky writable ancestors reject
29
30
  with `not-owned` or `insecure-permissions`; unavailable effective-user identity
30
31
  rejects with `permission-unverified`. Existing supplied directories keep their
31
32
  permissions. Missing root components are created at `0o700` and initialized
@@ -34,6 +35,25 @@ mode, correction uses a verified directory descriptor.
34
35
  An initial mode-descriptor admission error is preserved if closing that rejected
35
36
  descriptor also fails; close failures after successful admission remain reportable.
36
37
 
38
+ On Linux, non-identity `/proc/self/uid_map` and `/proc/self/gid_map` evidence
39
+ permits ancestors whose UID and GID equal unmapped kernel overflow IDs,
40
+ provided the same ancestor mode checks pass.
41
+ These owners are classified as **unmapped**, not verified root owners:
42
+ [Linux maps all unmapped owners to overflow IDs](https://man7.org/linux/man-pages/man7/user_namespaces.7.html).
43
+ Supporting systemd user services with `PrivateUsers=true` therefore trusts the
44
+ host directory hierarchy against unmapped host peers who own an ancestor and
45
+ can rename it. Sticky world-writable ancestors remain admitted because host
46
+ `/tmp` and `PrivateTmp` appear unmapped under `PrivateUsers`; refusing them would
47
+ disable the default system-temp layout even with a private per-user leaf root.
48
+ An unmapped host owner of such a sticky ancestor could rename its children,
49
+ but a normal host's `/tmp` is root-owned by construction. Mapped foreign owners
50
+ still reject, and this exception never applies to the supplied root or newly
51
+ created workspace. Both maps and mapped-owner exclusions are checked afresh
52
+ whenever admission relies on unmapped ownership, including identity replay;
53
+ unavailable namespace evidence leaves admission unchanged.
54
+ The first admitted unmapped ancestor emits `FS_SAFE_UNMAPPED_TEMP_ANCESTOR`
55
+ through Node's warning event. The warning contains no caller paths.
56
+
37
57
  For an already existing canonical root, discovery retains only its immutable
38
58
  exact identity. Cleanup-parent retention is provisional: after any native
39
59
  capability probe, creation captures and validates the complete ancestry,