@openclaw/fs-safe 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +15 -5
  3. package/dist/advanced.d.ts +2 -0
  4. package/dist/advanced.js +1 -0
  5. package/dist/archive-plan.d.ts +2 -7
  6. package/dist/archive-read.js +9 -18
  7. package/dist/archive-zip-entry.d.ts +11 -11
  8. package/dist/archive-zip-entry.js +3 -35
  9. package/dist/archive-zip-integrity.d.ts +2 -2
  10. package/dist/archive-zip-integrity.js +2 -12
  11. package/dist/archive-zip-loader.d.ts +7 -3
  12. package/dist/archive-zip-loader.js +10 -9
  13. package/dist/archive-zip-preflight.d.ts +2 -1
  14. package/dist/archive-zip-preflight.js +16 -7
  15. package/dist/archive.js +17 -16
  16. package/dist/directory-receipt.js +5 -7
  17. package/dist/effective-uid.js +1 -4
  18. package/dist/errors.d.ts +3 -1
  19. package/dist/errors.js +3 -2
  20. package/dist/file-lock-sync-root-held.js +1 -4
  21. package/dist/file-store.d.ts +4 -7
  22. package/dist/json-document-store.d.ts +4 -9
  23. package/dist/local-file-access.js +2 -5
  24. package/dist/move-path-cleanup.js +4 -4
  25. package/dist/native-binding.d.ts +6 -1
  26. package/dist/native-staged-symlink.d.ts +13 -0
  27. package/dist/native-staged-symlink.js +303 -0
  28. package/dist/owner-dacl-batch-worker.d.ts +1 -0
  29. package/dist/owner-dacl-batch-worker.js +54 -0
  30. package/dist/owner-dacl-batch.d.ts +5 -0
  31. package/dist/owner-dacl-batch.js +64 -0
  32. package/dist/owner-dacl.d.ts +2 -0
  33. package/dist/owner-dacl.js +3 -0
  34. package/dist/path.js +17 -1
  35. package/dist/permission-exec.js +3 -6
  36. package/dist/permissions-public.d.ts +1 -0
  37. package/dist/permissions-public.js +1 -0
  38. package/dist/pinned-mutation-admission.d.ts +0 -1
  39. package/dist/pinned-open.d.ts +0 -1
  40. package/dist/pinned-open.js +1 -2
  41. package/dist/publish-copy-stage.js +4 -0
  42. package/dist/read-opened-file.d.ts +2 -5
  43. package/dist/regular-file.js +3 -3
  44. package/dist/replace-file-copy-fallback.d.ts +1 -2
  45. package/dist/root-impl.js +0 -3
  46. package/dist/root-observed-path.d.ts +0 -1
  47. package/dist/root-observed-path.js +0 -3
  48. package/dist/root-path-observation.d.ts +4 -11
  49. package/dist/root-path.js +7 -10
  50. package/dist/root-write-admission.js +0 -2
  51. package/dist/safe-path-segment.d.ts +1 -0
  52. package/dist/safe-path-segment.js +8 -2
  53. package/dist/secure-file.js +3 -2
  54. package/dist/sidecar-lock.js +5 -3
  55. package/dist/staged-symlink-types.d.ts +49 -0
  56. package/dist/staged-symlink-types.js +1 -0
  57. package/dist/symlink-parents.js +58 -7
  58. package/dist/temp-target.js +4 -2
  59. package/dist/temp-workspace-owner.js +4 -9
  60. package/dist/text-atomic.d.ts +2 -1
  61. package/dist/text-atomic.js +2 -0
  62. package/dist/trash.js +27 -1
  63. package/dist/walk.d.ts +2 -5
  64. package/dist/windows-owner.d.ts +0 -1
  65. package/dist/windows-owner.js +0 -1
  66. package/dist/windows-security-bridge.cs +6 -4
  67. package/dist/windows-security-bridge.ps1 +78 -3
  68. package/dist/windows-security-command.d.ts +8 -0
  69. package/dist/windows-security-command.js +66 -12
  70. package/dist/windows-security-facts.d.ts +3 -0
  71. package/dist/windows-security-facts.js +4 -0
  72. package/docs/advanced.md +3 -2
  73. package/docs/archive.md +8 -0
  74. package/docs/atomic.md +11 -3
  75. package/docs/contributing.md +30 -0
  76. package/docs/install.md +28 -0
  77. package/docs/native-helper.md +5 -4
  78. package/docs/native.md +45 -1
  79. package/docs/permissions.md +66 -0
  80. package/docs/public-api.md +7 -1
  81. package/docs/security-model.md +4 -1
  82. package/docs/sidecar-lock.md +2 -0
  83. package/docs/staged-symlink.md +123 -0
  84. package/docs/store.md +3 -1
  85. package/docs/testing.md +28 -0
  86. package/docs/writing.md +10 -0
  87. package/package.json +9 -9
@@ -232,13 +232,16 @@ The library does not modify or constrain the global Node.js `fs` namespace, and
232
232
 
233
233
  | Mechanism | Reported containment | Boundary |
234
234
  |---|---|---|
235
- | Linux native | `kernel-atomic` | `openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS)` resolves and opens under the root in one kernel operation. |
235
+ | Linux native with `openat2` | `kernel-atomic` | `openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS)` resolves and opens under the root in one kernel operation. |
236
+ | Linux native without `openat2` | `best-effort` | A no-follow `openat` component walk retains each parent and verifies exact identity associations before and after the final open; all symlink components are rejected. |
236
237
  | macOS native | `best-effort` | macOS 15.4 and newer use `O_RESOLVE_BENEATH` first; older kernels use the guarded `openat(O_NOFOLLOW)` component walk. Both verify the opened descriptor with `F_GETPATH`. |
237
238
  | Windows native | `best-effort` | Handle-relative `NtCreateFile` rejects reparse points, but this package does not claim a Linux-style atomic beneath guarantee. |
238
239
  | JavaScript fallback | `best-effort` | Canonical checks, no-follow opens where Node exposes them, and post-open identity checks form a check-then-use sequence. |
239
240
 
240
241
  The macOS `F_GETPATH` verification is an escape detector, not a race-atomic guarantee. A hostile same-UID process can rename a directory after `O_RESOLVE_BENEATH` or the manual walk and race the post-open sample or a later descriptor-relative mutation. The native result therefore remains `best-effort` on macOS even when the kernel flag is available. No policy decision is attached to these labels; callers can inspect the fact and decide what their own threat model requires.
241
242
 
243
+ The Linux fallback's identity checks are also detection-based: a directory can be renamed between chain samples or before a later descriptor-relative mutation. It cannot provide atomic resolution, and rejection after a mutating open does not promise rollback. See [Linux without openat2](native.md#linux-without-openat2) for the cached capability probe, conservative symlink rejection, and operations that remain unavailable.
244
+
242
245
  The public `OpenResult`, `ReadResult`, and `WritableOpenResult` expose `containment`. Those root APIs currently report `best-effort`; direct native `openBeneath()` reports the platform value above. No-replace publication uses `renameat2(RENAME_NOREPLACE)` on Linux, `renameatx_np(RENAME_EXCL)` on macOS, and `FileRenameInfoEx` with replacement disabled on Windows, but those separate mutation semantics do not upgrade an open result's containment label.
243
246
 
244
247
  ## Limitations to keep in mind
@@ -63,6 +63,8 @@ Exit cleanup tolerates shared managers created by older package copies that lack
63
63
 
64
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.
65
65
 
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
+
66
68
  The raw sidecar bytes are not a canonical JSON representation: tools that trim or rewrite the trailing whitespace invalidate the ownership token, so release leaves the changed sidecar in place and fails closed. The token distinguishes cooperating acquisitions; it is not a secret and does not make pathname compare-and-remove atomic against a hostile process that can replace files outside the lock protocol.
67
69
 
68
70
  `release()` propagates an I/O failure that prevents deletion of an unchanged, owned sidecar; it never reports successful cleanup while leaving that lock behind. The handle and manager retain the exact cleanup receipt after a failure, so the same handle can retry `release()` and `manager.drain()` can retry retained cleanup. A changed sidecar remains an ownership mismatch rather than a deletion failure and is left untouched. If both a `withFileLock()` or `withFileLockSync()` callback and release fail, the release error is the primary `SuppressedError.error` and the callback failure remains available as `SuppressedError.suppressed`. Failed asynchronous acquisition cleanup uses the same shape, with the cleanup error primary and the acquisition failure suppressed. On Node runtimes without the global `SuppressedError` constructor, fs-safe returns the equivalent `Error` shape with the same name and properties.
@@ -0,0 +1,123 @@
1
+ ---
2
+ title: Retained symlink publication
3
+ description: "Admit and retain an identified staged symlink for no-replace publication and explicit recovery."
4
+ ---
5
+
6
+ # Retained symlink publication
7
+
8
+ `retainSymlinkInDirectory()` from `@openclaw/fs-safe/advanced` admits an
9
+ **existing** direct-child symlink against caller-captured identity, ownership,
10
+ change time and target. It holds a no-follow descriptor to that inode and its
11
+ parent until cleanup. It does not create a symlink or infer ownership from a
12
+ same-target pathname observation. This dedicated lifecycle does not relax
13
+ `Root.move()` or regular-file staging's symlink rejection.
14
+
15
+ The caller owns staging and admission evidence. Create and inspect the stage
16
+ under the application's cooperative writer lock, and keep that lock through
17
+ admission. A historical stat is not a lifetime handle: if peers can recycle
18
+ inodes between capture and admission, identity numbers alone cannot establish
19
+ historical ownership. Once admitted, the retained descriptor prevents inode
20
+ reuse until this owner closes. Failed admission closes descriptors without
21
+ unlinking anything; all staging cleanup remains with the caller.
22
+
23
+ Linux uses `O_PATH | O_NOFOLLOW`; macOS uses metadata-only `O_EVTONLY | O_SYMLINK`
24
+ with nonblocking/no-controlling-terminal flags. A non-following preflight rejects
25
+ observed special files before open; the opened descriptor must still match.
26
+ Windows, native mode off, and missing or older bindings reject before namespace
27
+ mutation. No fallback interpreter, copy, following open, or public descriptor
28
+ is used. Symlinks must have one link and a UTF-8 target; their targets are never
29
+ opened or confined by this API. The application must authorize the target.
30
+
31
+ ## Example
32
+
33
+ ```ts
34
+ import {
35
+ retainSymlinkInDirectory,
36
+ type StagedSymlinkExpected,
37
+ type PublishedSymlinkReceipt,
38
+ } from "@openclaw/fs-safe/advanced";
39
+ import type { DirectoryReceipt } from "@openclaw/fs-safe/durability";
40
+
41
+ // Application owns stage creation, capture, locking and transaction policy.
42
+ export async function publishInstallerLink(options: {
43
+ directory: DirectoryReceipt;
44
+ stagedBasename: string;
45
+ expected: StagedSymlinkExpected;
46
+ finalBasename: string;
47
+ assertAuthorizedAndCurrent(): void;
48
+ }): Promise<PublishedSymlinkReceipt> {
49
+ await using staged = await retainSymlinkInDirectory({
50
+ directory: options.directory,
51
+ basename: options.stagedBasename,
52
+ expected: options.expected,
53
+ assertBeforeMutation: options.assertAuthorizedAndCurrent,
54
+ });
55
+ return await staged.publish(options.finalBasename);
56
+ }
57
+ ```
58
+
59
+ `expected` requires exact bigint `dev`, `ino`, `ctimeNs`, integer `uid`
60
+ and `gid`, and `target`. Admission snapshots these values and the parent
61
+ receipt. The frozen returned receipt contains `directory`,
62
+ `temporaryBasename`, `target`, and preparation-time `identity` metadata,
63
+ like [regular-file staging](staged-file.md). Rename may change timestamps;
64
+ the original receipt is never replaced by a post-publication identity.
65
+
66
+ ## Lifetime and outcomes
67
+
68
+ - `assertCurrent()` checks the admitted parent pathname, original name, retained
69
+ inode, single-link count, ownership, mode and target before publication.
70
+ - `publish(basename)` accepts a distinct portable direct-child name. It performs
71
+ the synchronous authority assertion, rechecks source and parent, and dispatches
72
+ guarded native no-replace rename. An existing file, symlink or directory is
73
+ never overwritten, and the link is never nested inside a destination directory.
74
+ - Successful dispatch is recorded as `published` **before** fallible postchecks.
75
+ A foreign same-target replacement fails verification without becoming owned.
76
+ `assertPublished()` checks against the still-retained inode.
77
+ - `removePublished()` is an explicit recovery action, not automatic rollback.
78
+ It reasserts authority and removes only an observed matching published link
79
+ through the retained original parent. It returns `removed`, `name-absent`,
80
+ or `preserved`. Its outcome or error is cached: it never retries an uncertain
81
+ unlink or touches a later replacement. It does not restore an old destination.
82
+ - `cleanup()` removes only an unattempted, still-owned stage, then closes both
83
+ handles. Published names are preserved. Indeterminate publication preserves
84
+ both names. Authority rejection during cleanup preserves the stage and still
85
+ closes handles. Repeated cleanup replays the cached result/error without
86
+ touching descriptor numbers.
87
+ - `await using` invokes cleanup and raises on a `preserved` outcome. Invocation
88
+ order serializes descriptor work; reentrant calls from the authority callback
89
+ reject. Callbacks must be synchronous; returned thenables are refused.
90
+
91
+ Errors carry `StagedSymlinkFailureDetails` in `FsSafeError.details`: the phase,
92
+ recorded `publication`, and a cleanup receipt when applicable. Publication is
93
+ `not-published`, `published` (with the precaptured receipt), or
94
+ `indeterminate` (with the attempted name). Native errno, including a collision,
95
+ does not rule out a committed remote rename whose response was lost. Only
96
+ pre-dispatch rejection leaves publication `not-published`. After an indeterminate
97
+ result, this owner cannot publish again or remove the possibly published name.
98
+ If inspecting a native rename error itself fails, publication is likewise
99
+ `indeterminate`; the original thrown value remains the reported cause.
100
+
101
+ Cleanup records `temporaryBasename`, `publication`, `resources`
102
+ (`closed` or `close-failed`), and `status` (`removed`, `name-absent`,
103
+ `preserved`, `failed`, or `not-needed`). Failure closes every retained handle
104
+ once and aggregates cleanup/close errors. A failed explicit recovery unlink is
105
+ reported to its caller and is not converted into successful recovery by cleanup.
106
+ Thrown values whose error metadata cannot be inspected are reported as
107
+ `helper-failed` with the original value as `cause`; cleanup and recovery still
108
+ cache their terminal error without retrying callbacks or descriptor closes.
109
+
110
+ ## Limits
111
+
112
+ This is directory-relative targeting, **not CAS**. Identity checks followed by
113
+ rename or unlink are not atomic conditional mutations. An uncooperative writer
114
+ can replace a child in the final syscall gap; observed substitutes are preserved,
115
+ but callers must coordinate writers to exclude that gap. The retained parent
116
+ prevents redirection into a replacement parent; a parent move can still cause
117
+ publication in the moved original and a `published` postcheck failure.
118
+
119
+ No receipt promises crash durability or recovery after process death, arbitrary
120
+ renames, permission revocation or I/O failure. This API does not sync directories
121
+ or persist descriptors. Caller journals, state capture, conditional restoration,
122
+ durability, and transaction settlement remain separate requirements. Keep the
123
+ owner alive until the application's publication or recovery decision settles.
package/docs/store.md CHANGED
@@ -62,7 +62,9 @@ const pending = await loadPendingJsonDurableQueueEntries({ queueDir, tempPrefix:
62
62
 
63
63
  `id` must be a single safe path segment: non-empty, not dot-prefixed, and made
64
64
  from letters, numbers, `_`, `-`, and `.`. Slashes, backslashes, NUL bytes, `.`,
65
- and `..` are rejected.
65
+ `..`, and Windows reserved device names such as `CON`, `NUL`, and `COM1` are
66
+ rejected on every platform so queue IDs remain portable. Temporary filenames
67
+ continue to suffix reserved stems (for example, `CON.txt` becomes `CON_.txt`).
66
68
 
67
69
  Use `ackJsonDurableQueueEntry()` after durable processing succeeds and
68
70
  `moveJsonDurableQueueEntryToFailed()` when the caller wants to quarantine an
package/docs/testing.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Testing
2
2
 
3
+ ## Linux openat2 fallback
4
+
5
+ Build the host addon and package first. The test hook is cached with the native
6
+ capability probe; set it before starting the process, rather than changing it
7
+ between tests in one process:
8
+
9
+ ```bash
10
+ pnpm native:build
11
+ pnpm build
12
+ FS_SAFE_TEST_NO_OPENAT2=1 FS_SAFE_NATIVE_MODE=require pnpm test test/linux-openat2-fallback.test.ts test/root-move-noreplace.test.ts test/root-move-native-integration.test.ts test/native-write-containment.test.ts
13
+ ```
14
+
15
+ On Linux, the seccomp harness also exercises the real syscall failure without
16
+ the environment hook. It needs a C compiler and permission to install an
17
+ unprivileged seccomp filter; it affects only its child process:
18
+
19
+ ```bash
20
+ cc test/fixtures/deny-openat2.c -o /tmp/fs-safe-deny-openat2
21
+ /tmp/fs-safe-deny-openat2 ENOSYS node test/fixtures/linux-openat2-fallback.mjs "$PWD/native/fs-safe-native.linux-x64-gnu.node"
22
+ /tmp/fs-safe-deny-openat2 EPERM node test/fixtures/linux-openat2-fallback.mjs "$PWD/native/fs-safe-native.linux-x64-gnu.node"
23
+ ```
24
+
25
+ Use the matching native artifact filename on other Linux architectures/libcs.
26
+ The fixture proves nested moves, collisions, read/write, traversal, symlink and
27
+ hardlink rejection, cached selection, and `helper-unavailable` for strict
28
+ bounded cleanup. Bounded-cleanup success tests require real `openat2`; run the
29
+ full suite with the environment hook unset.
30
+
3
31
  `@openclaw/fs-safe/test-hooks` exposes test-only injection points. Registration
4
32
  is allowed only when `process.env.NODE_ENV === "test"` or
5
33
  `process.env.VITEST === "true"`; registering a non-empty hook set elsewhere
package/docs/writing.md CHANGED
@@ -211,6 +211,11 @@ can establish them; native disposal can retain them inside a `SuppressedError`
211
211
  cause. Preserve those details when handling errors: a rejection can follow
212
212
  complete publication, and an indeterminate link or native rename must preserve names for
213
213
  recovery. A cleanup or close failure also retains the original operation failure.
214
+ The JavaScript fallback checks the destination again immediately before attempting
215
+ publication. A collision observed there leaves publication unattempted and cleans
216
+ the owned stage. A later collision or other error from the link call remains
217
+ indeterminate, including `EEXIST`; the error code alone does not prove that the
218
+ filesystem left both names unchanged.
214
219
  No later verification, mode, or synchronization failure authorizes deleting an
215
220
  already published complete destination. See [receipt meanings](staged-file.md).
216
221
 
@@ -354,6 +359,11 @@ replacing rename. After dispatch it rechecks both parent identities, so a
354
359
  post-operation rejection can mean the no-replace rename completed. Directory
355
360
  moves continue to require `overwrite: true`.
356
361
 
362
+ Linux without `openat2` uses the [guarded native parent walk](native.md#linux-without-openat2).
363
+ The move still uses `renameat2(RENAME_NOREPLACE)` and preserves collisions;
364
+ parent resolution reports the documented `best-effort` containment class.
365
+ Disabling the addon still makes no-clobber moves unavailable.
366
+
357
367
  Both selected canonical endpoints are admitted inside the retained Root after
358
368
  native parent admission. With `mutationSymlinks: "reject"`, both full operation
359
369
  paths are rechecked after the live mutation-authority callback and before
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
5
5
  "keywords": [
6
6
  "filesystem",
@@ -169,18 +169,18 @@
169
169
  "archive:producer-smoke": "node scripts/archive-producer-smoke.mjs"
170
170
  },
171
171
  "optionalDependencies": {
172
- "@openclaw/fs-safe-darwin-arm64": "0.19.0",
173
- "@openclaw/fs-safe-darwin-x64": "0.19.0",
174
- "@openclaw/fs-safe-linux-arm64-gnu": "0.19.0",
175
- "@openclaw/fs-safe-linux-arm64-musl": "0.19.0",
176
- "@openclaw/fs-safe-linux-x64-gnu": "0.19.0",
177
- "@openclaw/fs-safe-linux-x64-musl": "0.19.0",
178
- "@openclaw/fs-safe-win32-x64-msvc": "0.19.0",
172
+ "@openclaw/fs-safe-darwin-arm64": "0.20.0",
173
+ "@openclaw/fs-safe-darwin-x64": "0.20.0",
174
+ "@openclaw/fs-safe-linux-arm64-gnu": "0.20.0",
175
+ "@openclaw/fs-safe-linux-arm64-musl": "0.20.0",
176
+ "@openclaw/fs-safe-linux-x64-gnu": "0.20.0",
177
+ "@openclaw/fs-safe-linux-x64-musl": "0.20.0",
178
+ "@openclaw/fs-safe-win32-x64-msvc": "0.20.0",
179
179
  "jszip": "^3.10.2"
180
180
  },
181
181
  "devDependencies": {
182
182
  "@emnapi/runtime": "2.0.0-alpha.5",
183
- "@napi-rs/cli": "3.10.3",
183
+ "@napi-rs/cli": "3.10.5",
184
184
  "@types/node": "^26.6.1",
185
185
  "@vitest/coverage-v8": "5.0.1",
186
186
  "fast-check": "^4.10.1",