@openclaw/fs-safe 0.18.2 → 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.
- package/CHANGELOG.md +47 -0
- package/README.md +15 -5
- package/dist/advanced.d.ts +2 -0
- package/dist/advanced.js +1 -0
- package/dist/archive-durability.js +1 -1
- package/dist/archive-merge.js +1 -1
- package/dist/archive-plan.d.ts +2 -7
- package/dist/archive-read.js +9 -18
- package/dist/archive-staging.js +3 -1
- package/dist/archive-zip-entry.d.ts +11 -11
- package/dist/archive-zip-entry.js +3 -35
- package/dist/archive-zip-integrity.d.ts +2 -2
- package/dist/archive-zip-integrity.js +2 -12
- package/dist/archive-zip-loader.d.ts +7 -3
- package/dist/archive-zip-loader.js +10 -9
- package/dist/archive-zip-preflight.d.ts +2 -1
- package/dist/archive-zip-preflight.js +16 -7
- package/dist/archive.js +17 -16
- package/dist/directory-receipt.js +5 -7
- package/dist/effective-uid.js +1 -4
- package/dist/errors.d.ts +3 -1
- package/dist/errors.js +3 -2
- package/dist/file-lock-sync-root-held.d.ts +4 -11
- package/dist/file-lock-sync-root-held.js +1 -4
- package/dist/file-lock-sync-root-io.d.ts +1 -4
- package/dist/file-lock-sync-root.d.ts +2 -4
- package/dist/file-store-boundary.d.ts +3 -7
- package/dist/file-store-boundary.js +7 -10
- package/dist/file-store-prune.js +3 -2
- package/dist/file-store.d.ts +4 -7
- package/dist/file-store.js +19 -21
- package/dist/guest-native-python.js +23 -31
- package/dist/guest.js +21 -12
- package/dist/json-document-store.d.ts +4 -9
- package/dist/json-durable-queue.js +2 -6
- package/dist/local-file-access.js +2 -5
- package/dist/local-file-descriptor.d.ts +2 -5
- package/dist/local-roots.d.ts +2 -7
- package/dist/move-path-cleanup.js +4 -4
- package/dist/native-binding.d.ts +18 -14
- package/dist/native-staged-symlink.d.ts +13 -0
- package/dist/native-staged-symlink.js +303 -0
- package/dist/owner-dacl-batch-worker.d.ts +1 -0
- package/dist/owner-dacl-batch-worker.js +54 -0
- package/dist/owner-dacl-batch.d.ts +5 -0
- package/dist/owner-dacl-batch.js +64 -0
- package/dist/owner-dacl.d.ts +2 -0
- package/dist/owner-dacl.js +3 -0
- package/dist/path.js +17 -1
- package/dist/permission-exec.js +3 -6
- package/dist/permissions-public.d.ts +1 -0
- package/dist/permissions-public.js +1 -0
- package/dist/pinned-mutation-admission.d.ts +0 -1
- package/dist/pinned-mutation-shared-route.d.ts +2 -8
- package/dist/pinned-open.d.ts +0 -1
- package/dist/pinned-open.js +1 -2
- package/dist/publish-copy-stage.js +4 -0
- package/dist/read-opened-file.d.ts +2 -5
- package/dist/regular-file.js +3 -3
- package/dist/replace-file-copy-fallback.d.ts +1 -2
- package/dist/root-context.js +3 -2
- package/dist/root-impl.js +0 -3
- package/dist/root-move-noreplace.d.ts +2 -7
- package/dist/root-observed-path.d.ts +0 -1
- package/dist/root-observed-path.js +0 -3
- package/dist/root-path-observation.d.ts +4 -11
- package/dist/root-path.js +7 -10
- package/dist/root-paths.d.ts +2 -6
- package/dist/root-remove-identity.d.ts +1 -3
- package/dist/root-walk.js +8 -3
- package/dist/root-write-admission.js +1 -6
- package/dist/root-write-complete-parent.d.ts +2 -0
- package/dist/root-write-complete-parent.js +1 -1
- package/dist/safe-path-segment.d.ts +1 -0
- package/dist/safe-path-segment.js +8 -2
- package/dist/secret-file.d.ts +6 -2
- package/dist/secret-file.js +1 -0
- package/dist/secure-file-windows.js +1 -5
- package/dist/secure-file.js +3 -2
- package/dist/sidecar-lock-admission-parser.d.ts +1 -2
- package/dist/sidecar-lock-handle.d.ts +2 -8
- package/dist/sidecar-lock-policy.d.ts +2 -7
- package/dist/sidecar-lock-stale-admission.d.ts +1 -5
- package/dist/sidecar-lock.js +5 -3
- package/dist/staged-symlink-types.d.ts +49 -0
- package/dist/staged-symlink-types.js +1 -0
- package/dist/symlink-parents.js +58 -7
- package/dist/temp-target.js +4 -2
- package/dist/temp-workspace-owner.js +4 -9
- package/dist/test-hooks.d.ts +1 -1
- package/dist/text-atomic.d.ts +2 -1
- package/dist/text-atomic.js +2 -0
- package/dist/trash.js +27 -1
- package/dist/walk.d.ts +2 -5
- package/dist/windows-owner.d.ts +0 -1
- package/dist/windows-owner.js +0 -1
- package/dist/windows-security-bridge.cs +6 -4
- package/dist/windows-security-bridge.ps1 +78 -3
- package/dist/windows-security-command.d.ts +8 -0
- package/dist/windows-security-command.js +66 -15
- package/dist/windows-security-facts.d.ts +4 -0
- package/dist/windows-security-facts.js +6 -2
- package/docs/advanced.md +3 -2
- package/docs/archive.md +10 -0
- package/docs/atomic.md +11 -3
- package/docs/contributing.md +30 -0
- package/docs/copy.md +2 -0
- package/docs/file-store.md +5 -0
- package/docs/guest.md +7 -1
- package/docs/install.md +28 -0
- package/docs/native-helper.md +11 -4
- package/docs/native.md +45 -1
- package/docs/permissions.md +66 -0
- package/docs/public-api.md +7 -1
- package/docs/root.md +10 -1
- package/docs/secret-file.md +10 -0
- package/docs/security-model.md +4 -1
- package/docs/sidecar-lock.md +2 -0
- package/docs/staged-symlink.md +123 -0
- package/docs/store.md +3 -1
- package/docs/testing.md +28 -0
- package/docs/walk.md +12 -0
- package/docs/writing.md +21 -0
- package/package.json +9 -9
package/docs/archive.md
CHANGED
|
@@ -211,6 +211,14 @@ loading, including failure. Public preflight still returns ordinary JSZip entry
|
|
|
211
211
|
objects, with directory keys ending in `/` and recognizable symlink type bits.
|
|
212
212
|
Compressed data is retained even for declared-zero entries, so an empty-size
|
|
213
213
|
claim cannot bypass payload-size or CRC verification during extraction or reads.
|
|
214
|
+
Extraction and bounded reads retain the admitted CRC and size independently of
|
|
215
|
+
mutable decoder objects, so later decoder changes cannot redefine the expected
|
|
216
|
+
payload integrity. Public preflight archives retain ordinary JSZip mutation
|
|
217
|
+
and entry-reading behavior.
|
|
218
|
+
Portable bounded reads select the admitted canonical name and reject a selected
|
|
219
|
+
decoder entry that no longer belongs to that archive and name before reading its
|
|
220
|
+
payload. Extraction retains the admitted names and physical order independently
|
|
221
|
+
of later changes to the decoder's public `files` object.
|
|
214
222
|
|
|
215
223
|
Within one ZIP entry, identical local and central name bytes reuse the same
|
|
216
224
|
decoded validation. Unicode Path admission is shared only when both the raw names
|
|
@@ -238,6 +246,8 @@ directory paths. For example, `./pkg//state\cache/value` is presented as
|
|
|
238
246
|
`pkg/state/cache/value`, even with `stripComponents: 1`. Case and Unicode
|
|
239
247
|
spelling are preserved. Local PAX `path`, GNU long-name, and supported ZIP
|
|
240
248
|
Unicode Path names use the same canonicalization.
|
|
249
|
+
A leading `~` is a literal archive entry name and is never expanded to the
|
|
250
|
+
user's home directory during extraction, publication, or durability checks.
|
|
241
251
|
Callbacks follow physical archive order, including ZIP names that look like
|
|
242
252
|
integer object keys. The public ZIP loader's `files` object retains ordinary
|
|
243
253
|
JavaScript object enumeration and mutation behavior.
|
package/docs/atomic.md
CHANGED
|
@@ -295,9 +295,9 @@ semantics. For single-file replacement, `replaceFileAtomic` is the right tool.
|
|
|
295
295
|
|
|
296
296
|
Atomic UTF-8 text write with the same secure defaults as `writeJson`: sibling
|
|
297
297
|
temp file, descriptor-bound mode setting and fsync, rename, and parent fsync.
|
|
298
|
-
It delegates to `replaceFileAtomic()` with a smaller call shape
|
|
299
|
-
|
|
300
|
-
or custom copy-fallback policy.
|
|
298
|
+
It delegates to `replaceFileAtomic()` with a smaller call shape, including its
|
|
299
|
+
pre-publication hook and staging-prefix options. Use `replaceFileAtomic()` when
|
|
300
|
+
you need mode preservation or a custom copy-fallback policy.
|
|
301
301
|
|
|
302
302
|
```ts
|
|
303
303
|
import { writeTextAtomic } from "@openclaw/fs-safe/atomic";
|
|
@@ -317,9 +317,17 @@ type WriteTextAtomicOptions = {
|
|
|
317
317
|
dirMode?: number; // parent mode (default 0o777 masked by process umask)
|
|
318
318
|
trailingNewline?: boolean; // append "\n" if missing; default false
|
|
319
319
|
durable?: boolean; // default true; false skips temp/parent fsync
|
|
320
|
+
beforeRename?: (params: { filePath: string; tempPath: string }) => Promise<void>;
|
|
321
|
+
tempPrefix?: string; // default ".fs-safe-replace"
|
|
320
322
|
};
|
|
321
323
|
```
|
|
322
324
|
|
|
325
|
+
`beforeRename` is awaited after the complete text is staged and before
|
|
326
|
+
publication, with the same [stage identity and refusal cleanup](#beforerename)
|
|
327
|
+
checks as `replaceFileAtomic`. Pass `tempPrefix` to identify staged files; it
|
|
328
|
+
uses the same prefix validation, including rejection of empty prefixes and path
|
|
329
|
+
separators.
|
|
330
|
+
|
|
323
331
|
`durable: false` keeps the sibling-temp replace/rename behavior but skips the
|
|
324
332
|
temp-file and parent-directory `fsync` calls. Use it only for reconstructible
|
|
325
333
|
metadata where lower latency matters more than crash-durability.
|
package/docs/contributing.md
CHANGED
|
@@ -70,6 +70,36 @@ Consumers receive the asset in the npm package and need no compiler.
|
|
|
70
70
|
|
|
71
71
|
Output lands in `dist/`. The package's `prepack` hook re-runs the build before publishing — manual `pnpm build` is only required when you want to inspect the output or run a freshly-built copy locally.
|
|
72
72
|
|
|
73
|
+
### Linux GNU release bindings
|
|
74
|
+
|
|
75
|
+
GNU x64 and arm64 artifacts use Zig 0.16.0 and `cargo-zigbuild` 0.23.4 with an
|
|
76
|
+
explicit glibc 2.28 target, independent of the runner's libc. This matches the
|
|
77
|
+
Node Linux runtime baseline and supports RHEL 8-family users without adding a
|
|
78
|
+
second legacy package. Run the same build and ABI gate used in CI and releases:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
cargo install cargo-zigbuild --version 0.23.4 --locked
|
|
82
|
+
rustup target add x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu
|
|
83
|
+
node scripts/build-linux-gnu.mjs x86_64-unknown-linux-gnu
|
|
84
|
+
node scripts/build-linux-gnu.mjs aarch64-unknown-linux-gnu
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
These commands require Zig on `PATH` and GNU `objdump`. They build the N-API
|
|
88
|
+
cdylib with `cargo zigbuild --target <triple>.2.28`, copy it to the existing
|
|
89
|
+
`artifacts/fs-safe-native.<platform>.node` name, and reject any GLIBC symbol
|
|
90
|
+
requirement above 2.28 before upload. The gate also rejects missing or unknown
|
|
91
|
+
GLIBC versions. To inspect an existing binding, use
|
|
92
|
+
`node scripts/check-linux-glibc.mjs <binding.node>`.
|
|
93
|
+
|
|
94
|
+
`pnpm native:build` remains a host-toolchain development build; it does not
|
|
95
|
+
establish the GNU release ABI floor.
|
|
96
|
+
|
|
97
|
+
On Linux x64 with Docker, run `pnpm build`, copy the GNU x64 artifact from
|
|
98
|
+
`artifacts/` to `native/`, run `node scripts/stage-host-native.mjs`, then run
|
|
99
|
+
`bash scripts/test-linux-glibc-floor.sh`. CI uses this command to load the actual
|
|
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.
|
|
102
|
+
|
|
73
103
|
## Test
|
|
74
104
|
|
|
75
105
|
```bash
|
package/docs/copy.md
CHANGED
|
@@ -74,6 +74,8 @@ Native Windows byte copies can store large zero-filled chunks as sparse ranges w
|
|
|
74
74
|
|
|
75
75
|
The ReFS backend rejects files with alternate data streams and unsupported reparse-point types instead of silently losing their contents. Symbolic links and junctions are preserved.
|
|
76
76
|
|
|
77
|
+
Failed ReFS clones attempt to remove their partial output through the retained directory handles. On Windows versions that reject the ignore-readonly deletion flag, clone rollback retries without that flag so ordinary output can be removed. It never clears readonly attributes: readonly output can remain, and the original clone error includes the cleanup failure. Other processes retaining output handles can delay deletion beyond settlement; a failed call does not guarantee an absent destination.
|
|
78
|
+
|
|
77
79
|
XFS and ZFS preserve regular-file and directory modes, timestamps, extended attributes, and ACLs. They reject special files, symlink extended attributes, non-UTF-8 names, and directory nesting deeper than 128 levels. Hardlinked source files become independent reflinked files. Portable byte copying preserves file contents, empty directories, modes where supported, file and directory timestamps, and literal symbolic links; it does not promise ownership, ACL, extended-attribute, alternate-stream, or sparse-layout preservation. On Windows, byte copying rejects unresolved symbolic links because Node does not expose their file/directory link type; resolved links keep their literal target and source type. POSIX dangling links are preserved. Choose a copying policy that meets the caller's metadata requirements; automatic copying can select either path.
|
|
78
80
|
|
|
79
81
|
`readCloneFileMetadata(files)` asynchronously reads APFS data-stream identities and file metadata in one native batch. Results correspond to input order; missing or unsupported entries return `undefined`. The returned `CloneFileMetadata` includes clone ID, device/inode, size, mode, ownership, and timestamps. These are point-in-time observations, not authorization or proof that later reads remain unchanged. Consumers such as Git index adapters must validate their own content and timestamp invariants. The reader does not follow leaf symbolic links.
|
package/docs/file-store.md
CHANGED
|
@@ -101,6 +101,11 @@ such as `internal space/a b.txt` are accepted. On POSIX, colons elsewhere, such
|
|
|
101
101
|
as the timestamp in `logs/2026-08-02T10:30:00Z.log`, remain lexically valid;
|
|
102
102
|
Windows rejects that spelling as stream syntax.
|
|
103
103
|
|
|
104
|
+
Keys such as `~` and `~/state.json` name literal entries inside the store; they
|
|
105
|
+
do not expand the user's home directory. Reads, writes, removal, and pruning
|
|
106
|
+
all use that literal identity. The `Root` returned by `root()` retains its own
|
|
107
|
+
home-expansion behavior.
|
|
108
|
+
|
|
104
109
|
Validation retains each method's operation order. Async reads, `exists`, and
|
|
105
110
|
`remove` open the root first: if the root is missing, strict methods report
|
|
106
111
|
`not-found` and `readTextIfExists` / `readJsonIfExists` return `null`, even for an
|
package/docs/guest.md
CHANGED
|
@@ -119,7 +119,13 @@ publication failure preserves the existing destination and source link; ordinary
|
|
|
119
119
|
failure cleanup removes the staging directory.
|
|
120
120
|
|
|
121
121
|
Cross-device directory moves build a copy manifest and check it during source
|
|
122
|
-
cleanup.
|
|
122
|
+
cleanup. Directory creation keeps the source mode subject to the guest's umask;
|
|
123
|
+
mode `000` is not replaced with a default. A top-level mode-000 directory uses
|
|
124
|
+
owner-only staging until publication, then restores zero through its retained
|
|
125
|
+
descriptor. Reading a mode-000 source still requires sufficient OS privileges;
|
|
126
|
+
the guest does not change source permissions to gain access. A permission error
|
|
127
|
+
after publication preserves the source and published copy for reconciliation.
|
|
128
|
+
Source changes can leave the published destination and some or all
|
|
123
129
|
of the source. Regular-file and symlink move fallbacks unlink the source
|
|
124
130
|
pathname after publication; they do not perform the directory manifest's
|
|
125
131
|
identity checks. Directory cleanup also has check-to-unlink race windows.
|
package/docs/install.md
CHANGED
|
@@ -134,6 +134,16 @@ when the matching package is absent, incompatible, or disabled.
|
|
|
134
134
|
Upgrading an existing 0.5 consumer? Follow [Migrating to 0.6](migrating-to-0.6.md)
|
|
135
135
|
before deploying with native mode `require` or native-only features.
|
|
136
136
|
|
|
137
|
+
### Older Linux kernels and seccomp
|
|
138
|
+
|
|
139
|
+
The native addon supports kernels without `openat2` (before Linux 5.6) and
|
|
140
|
+
containers returning `ENOSYS` or probe-time `EPERM` for that syscall. Beneath
|
|
141
|
+
opens use a guarded no-follow component walk and report `best-effort`.
|
|
142
|
+
Nested no-clobber moves retain atomic `renameat2(RENAME_NOREPLACE)` and exact
|
|
143
|
+
identity checks; keep native mode enabled. Strict bounded cleanup still
|
|
144
|
+
requires `openat2` with `RESOLVE_NO_XDEV` and reports `helper-unavailable`
|
|
145
|
+
without it. See [Linux capability behavior and limits](native.md#linux-without-openat2).
|
|
146
|
+
|
|
137
147
|
### Windows security fallback
|
|
138
148
|
|
|
139
149
|
Windows raw owner/DACL inspection, private-directory creation, and secure-file
|
|
@@ -161,6 +171,24 @@ available native operation's error never triggers a command retry. See
|
|
|
161
171
|
|
|
162
172
|
## Native helper policy
|
|
163
173
|
|
|
174
|
+
### Supported native platforms
|
|
175
|
+
|
|
176
|
+
Prebuilt bindings cover Linux x64/arm64 (GNU glibc **2.28 or newer**, or musl),
|
|
177
|
+
macOS x64/arm64, and Windows x64. The GNU baseline includes RHEL 8, Rocky Linux 8,
|
|
178
|
+
and AlmaLinux 8. A compatible Node 22+ runtime and the kernel/filesystem features
|
|
179
|
+
required by each operation are still necessary; a loadable addon alone does not
|
|
180
|
+
guarantee every native capability. Systems older than glibc 2.28 are outside the
|
|
181
|
+
GNU binary support floor.
|
|
182
|
+
|
|
183
|
+
A missing or incompatible optional binding (including `ERR_DLOPEN_FAILED` from
|
|
184
|
+
glibc) does not prevent importing fs-safe. In `auto`, supported JavaScript/WASM
|
|
185
|
+
fallbacks remain available. Native-only operations such as the default
|
|
186
|
+
no-clobber `Root.move()` still fail closed with `helper-unavailable`; `require`
|
|
187
|
+
also rejects fallback-capable operations and retains the binding load error as
|
|
188
|
+
the cause.
|
|
189
|
+
|
|
190
|
+
### Loading modes
|
|
191
|
+
|
|
164
192
|
The platform native binaries provide fd-relative open/link/mkdir primitives,
|
|
165
193
|
atomic no-replace rename, and file identity checks. The default is `auto`: use
|
|
166
194
|
the matching binary when it loads, otherwise use the guarded JavaScript path
|
package/docs/native-helper.md
CHANGED
|
@@ -103,10 +103,16 @@ clone/copy/hash workers, POSIX canonicalization, and Windows security descriptor
|
|
|
103
103
|
layer owns policy, retries, filters, budgets, modes, cleanup, error
|
|
104
104
|
normalization, and the decision to fall back.
|
|
105
105
|
|
|
106
|
-
- Linux uses `openat2` with `RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS`, `renameat`, and `renameat2(RENAME_NOREPLACE)`. Direct-child no-replace renames borrow already-retained parent descriptors; deeper relative paths retain the guarded reopen. Owned-tree cleanup
|
|
106
|
+
- Linux uses `openat2` with `RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS`, `renameat`, and `renameat2(RENAME_NOREPLACE)`. Without `openat2`, beneath opens use the [no-follow component walk](native.md#linux-without-openat2) with exact identity checks and report `best-effort`. Direct-child no-replace renames borrow already-retained parent descriptors; deeper relative paths retain the guarded reopen. Owned-tree cleanup still requires `openat2` with `RESOLVE_NO_XDEV` and fails closed when unavailable.
|
|
107
107
|
- 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. Direct-child no-replace renames borrow already-retained parents without another `F_GETPATH`; deeper paths retain the guarded reopen. Publication uses `renameat` for replacement and `renameatx_np(RENAME_EXCL)` for no-replace; owned-tree cleanup uses descriptor-relative `openat`/`unlinkat`.
|
|
108
108
|
- Windows uses handle-relative `NtCreateFile`, rejects reparse points during root-bounded traversal, and uses `FileRenameInfoEx` with replacement selected explicitly by the TypeScript policy layer. The dedicated retained-directory `renameNoReplaceWithIdentity` primitive compares its required exact source receipt with the handle opened for rename before mutation. Its internal mismatch status is normalized to public `path-mismatch`; the legacy four-argument `renameNoReplace` export and its other callers are unchanged. Owned trees are deleted through exact opened handles with `FileDispositionInfoEx`; symlink/reparse entries in owned trees are removed as leaves and never traversed. Descriptors crossing N-API are converted only by the host executable's paired `uv_get_osfhandle` and `uv_open_osfhandle` exports. A runtime without both exports is unsupported for these native operations; the binding never guesses a raw HANDLE or uses a foreign CRT descriptor table. Descriptor-producing operations also require that same host's synchronous libuv close and request-management APIs before exporting an owned descriptor.
|
|
109
109
|
|
|
110
|
+
Linux root lookups reject negative descriptor sentinels before borrowing a handle or resolving a relative path; they never substitute the process working directory for an admitted root. Public Root operations already supply retained, admitted handles.
|
|
111
|
+
|
|
112
|
+
On Linux and macOS, native asynchronous file-copy admission rejects negative source and parent descriptors before creating a stage, preserving source-before-parent error ordering. Callers must keep nonnegative source and parent descriptors open until the operation settles.
|
|
113
|
+
|
|
114
|
+
Low-level Unix query, hash, copy, clone, staging, and owned-tree cleanup calls also reject negative descriptors before using them, preserving each operation's path-validation, cancellation, and cleanup order. Descriptor-relative mutations never accept a working-directory sentinel as a retained capability. On modern macOS, beneath opens reject negative roots with `EBADF` before calling `openat`, rather than returning `EIO` after an OS failure or working-directory operation. This check does not establish the validity of arbitrary nonnegative integers: callers must supply live descriptors and retain them until synchronous calls return or asynchronous operations settle.
|
|
115
|
+
|
|
110
116
|
`replaceDirectoryAtomic()` requires `renameNoReplaceWithIdentity` before it
|
|
111
117
|
creates a missing target parent. On POSIX the dedicated entry point keeps the
|
|
112
118
|
existing pre-dispatch exact receipt fence but dispatches direct-child names
|
|
@@ -133,11 +139,12 @@ for the exact difference.
|
|
|
133
139
|
The guarded JavaScript mutation path is detection-based, not containment-atomic.
|
|
134
140
|
If a same-privilege peer can replace a writable parent after its identity guard
|
|
135
141
|
but before Node resolves a pathname mutation, the mutation can land outside the
|
|
136
|
-
intended root before the post-operation guard throws.
|
|
137
|
-
|
|
142
|
+
intended root before the post-operation guard throws. Native `require` ensures the addon is present, but does not require a
|
|
143
|
+
`kernel-atomic` resolver; inspect containment and use OS isolation when that
|
|
144
|
+
concurrent attacker is part of the threat model.
|
|
138
145
|
|
|
139
146
|
`openBeneath()` returns `{ fd, containment }`. `containment` is
|
|
140
|
-
`"kernel-atomic"` for Linux `openat2` and `"best-effort"` for macOS and
|
|
147
|
+
`"kernel-atomic"` for Linux `openat2` and `"best-effort"` for the Linux fallback, macOS and
|
|
141
148
|
Windows. Public JavaScript root open/read/writable results also expose the
|
|
142
149
|
field and report `"best-effort"`; the label reports mechanism, not policy.
|
|
143
150
|
|
package/docs/native.md
CHANGED
|
@@ -43,6 +43,7 @@ whether a path, archive entry, mode, owner, or cleanup policy is acceptable.
|
|
|
43
43
|
|
|
44
44
|
- Linux uses `openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS)`, fd-relative
|
|
45
45
|
`mkdirat`/`linkat`/`renameat`/`renameat2`, `FICLONE`, and `copy_file_range`.
|
|
46
|
+
Without `openat2`, beneath opens use the [guarded fallback](#linux-without-openat2).
|
|
46
47
|
- macOS 15.4 and newer first use `openat(O_RESOLVE_BENEATH)`; older kernels walk
|
|
47
48
|
components with `openat(O_NOFOLLOW)` and restart in-root symlinks from the
|
|
48
49
|
pinned root. Both routes apply an `F_GETPATH` post-open containment detector,
|
|
@@ -85,6 +86,49 @@ privately owned empty ACL. Unsupported, malformed, and failed inspection is not
|
|
|
85
86
|
reported as absence. These facts do not classify individual ACE permissions or
|
|
86
87
|
prove volume ownership enforcement; each caller applies its own security policy.
|
|
87
88
|
|
|
89
|
+
## Linux without openat2
|
|
90
|
+
|
|
91
|
+
Linux kernels before 5.6 and containers whose seccomp policy denies `openat2`
|
|
92
|
+
can keep native mode `auto` or `require`. The addon caches one harmless
|
|
93
|
+
`openat2(".")` capability probe per process. `ENOSYS`, or `EPERM` on that probe,
|
|
94
|
+
selects the native `openat` fallback. An `EPERM`/`EACCES` from an application
|
|
95
|
+
operation is still a permission error and never triggers a retry. Install
|
|
96
|
+
syscall filters before the first native operation; a later `ENOSYS` fails with
|
|
97
|
+
`ENOTSUP`, rather than changing the cached mechanism during a call.
|
|
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
|
|
105
|
+
identity fences remain in place. `openBeneath()` reports `best-effort`: these
|
|
106
|
+
identity samples detect replacements but cannot make a multi-component walk
|
|
107
|
+
atomic against a hostile process renaming directories between samples. This
|
|
108
|
+
is the same documented containment class as the macOS and JavaScript paths;
|
|
109
|
+
applications requiring atomic beneath resolution must check the result or use
|
|
110
|
+
OS isolation. Rejection after a mutating open does not promise rollback.
|
|
111
|
+
|
|
112
|
+
Nested no-clobber `Root.move()` still admits both parents and uses
|
|
113
|
+
`renameat2(RENAME_NOREPLACE)`. Existing destinations are never overwritten.
|
|
114
|
+
That separate syscall/filesystem capability remains required; if unavailable,
|
|
115
|
+
the operation fails with `helper-unavailable`. Turning native mode `off` still
|
|
116
|
+
disables no-clobber moves because Node has no equivalent atomic rename API.
|
|
117
|
+
|
|
118
|
+
Bounded owned-tree cleanup deliberately has no `openat` fallback:
|
|
119
|
+
`RESOLVE_NO_XDEV` rejects bind mounts even when device numbers match, which
|
|
120
|
+
ordinary identity checks cannot reproduce. `cleanupSafety: "require-bounded"`
|
|
121
|
+
fails before workspace creation with `helper-unavailable`; low-level cleanup
|
|
122
|
+
opens report `ENOTSUP`. Compatible cleanup retains its documented behavior.
|
|
123
|
+
Low-level `O_TMPFILE` anonymous opens also fail before creation with `ENOTSUP`
|
|
124
|
+
in the fallback, because named-entry identity checks cannot verify an unnamed
|
|
125
|
+
file. Public staged-write APIs use exclusive named files and remain available.
|
|
126
|
+
|
|
127
|
+
For tests, set `FS_SAFE_TEST_NO_OPENAT2=1` before starting Node to force the
|
|
128
|
+
fallback (including bounded-cleanup refusal). It is read only at the first
|
|
129
|
+
capability probe. It has no effect on macOS or Windows.
|
|
130
|
+
See [Linux fallback testing](testing.md#linux-openat2-fallback).
|
|
131
|
+
|
|
88
132
|
## Archives
|
|
89
133
|
|
|
90
134
|
Native ZIP and TAR entry reads retain a private, unpooled input Buffer in
|
|
@@ -252,7 +296,7 @@ See [Root containment guarantees](security-model.md#containment-guarantees-by-pl
|
|
|
252
296
|
|
|
253
297
|
| Capability | Native path | Guarded JavaScript path |
|
|
254
298
|
|---|---|---|
|
|
255
|
-
| Native beneath opens and Root mutations | Descriptor-relative beneath operations. Pinned writes create parents and publish both replacement and no-replace targets relative to open directory descriptors. No-clobber `Root.move()` admits both parents and uses the native no-replace rename. Native `openBeneath()` reports `kernel-atomic`
|
|
299
|
+
| Native beneath opens and Root mutations | Descriptor-relative beneath operations. Pinned writes create parents and publish both replacement and no-replace targets relative to open directory descriptors. No-clobber `Root.move()` admits both parents and uses the native no-replace rename. Native `openBeneath()` reports `kernel-atomic` with Linux `openat2` and `best-effort` with the guarded Linux fallback, macOS, and Windows. 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. No-clobber `Root.move()` is unsupported because a check followed by a replacing rename is unsafe. A same-privilege peer can replace a writable parent after a guard assertion but before Node resolves another pathname mutation; the mutation may land outside the intended root before the post-check detects it. |
|
|
256
300
|
| ZIP/TAR/gzip | Rust streaming decode and fd-relative output creation. | Optional JSZip or bundled WASM TAR into guarded private staging, then the same guarded merge policy. |
|
|
257
301
|
| Zstd/bzip2 TAR | Rust streaming decode and fd-relative output creation. | Bundled WASM codecs feed the shared Rust TAR parser, then guarded private staging and the same merge policy; no optional codec dependency. |
|
|
258
302
|
| 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/permissions.md
CHANGED
|
@@ -185,6 +185,72 @@ the same raw ACE projection. Native `require` rejects either absence with
|
|
|
185
185
|
query's failure is terminal. The existing coarse `inspectPathPermissions()` API
|
|
186
186
|
still owns its compatibility fallback and trust classification.
|
|
187
187
|
|
|
188
|
+
### Asynchronous batches
|
|
189
|
+
|
|
190
|
+
Use `readOwnerAndDaclBatch()` when several paths, such as a directory and its
|
|
191
|
+
ancestors, need inspection without blocking the caller's event loop:
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
import { readOwnerAndDaclBatch } from "@openclaw/fs-safe/permissions";
|
|
195
|
+
|
|
196
|
+
const facts = await readOwnerAndDaclBatch(stagingDirectories, { timeoutMs: 60_000 });
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
function readOwnerAndDaclBatch(
|
|
201
|
+
paths: readonly string[],
|
|
202
|
+
options?: { timeoutMs?: number },
|
|
203
|
+
): Promise<OwnerAndDaclResult[]>;
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Results use the same raw fact shape and SID spelling as `readOwnerAndDacl()`.
|
|
207
|
+
They correspond one-for-one to input order, including duplicate paths. An empty
|
|
208
|
+
array returns `[]` without dispatch. Other platforms return an
|
|
209
|
+
`unsupported-platform` result for each path. Query failures, malformed facts,
|
|
210
|
+
or missing/reordered response rows reject the entire batch; no partial facts
|
|
211
|
+
are returned. Null DACLs, incomplete ACE lists and nonlocal observations remain
|
|
212
|
+
raw facts for the caller's policy to evaluate.
|
|
213
|
+
|
|
214
|
+
The call captures paths, options and native mode before awaiting. Windows
|
|
215
|
+
relative paths are anchored to the current directory or selected drive at
|
|
216
|
+
entry, without normalizing their remaining `.` or `..` components. Empty,
|
|
217
|
+
nonstring, sparse or NUL-containing path entries and Windows namespace aliases
|
|
218
|
+
reject before dispatch. The UTF-8 JSON input is limited to 16 MiB.
|
|
219
|
+
|
|
220
|
+
An available native capability runs all queries in one isolated process using
|
|
221
|
+
the current runtime executable. Its resolved native mode is forwarded explicitly;
|
|
222
|
+
Node preload and module-search environment overrides are not inherited. An
|
|
223
|
+
available native query's failure is terminal. In `auto` when the binding or
|
|
224
|
+
capability is absent, or in `off`, one packaged PowerShell process reads the
|
|
225
|
+
path array from stdin and compiles the existing C# bridge once. Native `require`
|
|
226
|
+
rejects missing support before launching a process. No route launches one
|
|
227
|
+
process per path or wraps synchronous parent-process queries in promises.
|
|
228
|
+
|
|
229
|
+
`timeoutMs` defaults to 60,000 and applies to the whole process, including
|
|
230
|
+
startup and fallback compilation. It must be an integer from 1 through
|
|
231
|
+
2,147,483,647. Combined stdout and stderr are limited to 16 MiB. Success waits
|
|
232
|
+
for process exit and both output pipes to close. Timeout and transport failure
|
|
233
|
+
request termination, then retain the existing one-second settlement grace.
|
|
234
|
+
The error's `processExitConfirmed` field, also retained in its cause receipt,
|
|
235
|
+
distinguishes observed exit from an unconfirmed termination attempt. An
|
|
236
|
+
unconfirmed child can still be reading after rejection; the operation returns
|
|
237
|
+
no facts and owns no output files or artifact cleanup. Neither timing out nor
|
|
238
|
+
receiving a successful kill request is reported as confirmed exit.
|
|
239
|
+
|
|
240
|
+
The PowerShell fallback encodes and budgets each result while collecting it,
|
|
241
|
+
instead of retaining every descriptor graph until the entire batch finishes.
|
|
242
|
+
An encoded response that would exceed 16 MiB rejects with `too-large` before
|
|
243
|
+
inspecting later paths. A query failure observed earlier retains its original
|
|
244
|
+
error, and no partial facts are returned. Duplicate paths remain independent
|
|
245
|
+
observations. This bounds accumulated encoded results, not total process memory:
|
|
246
|
+
the current descriptor and row, decoded input, and runtime overhead still exist.
|
|
247
|
+
|
|
248
|
+
Batch observations are point-in-time pathname facts, not a snapshot or a
|
|
249
|
+
retained filesystem capability. The caller still owns ancestry trust, principal
|
|
250
|
+
policy and authorization for subsequent operations. Existing singular queries
|
|
251
|
+
and other command operations retain their 30-second deadline, 1 MiB output
|
|
252
|
+
budget and documented failure behavior.
|
|
253
|
+
|
|
188
254
|
## Private directories
|
|
189
255
|
|
|
190
256
|
```ts
|
package/docs/public-api.md
CHANGED
|
@@ -41,6 +41,11 @@ The lexical path surface additionally exports `isNodeError`,
|
|
|
41
41
|
`UnsafeDeviceReadPathMatch`, `UnsafeDeviceReadPathOptions`, and
|
|
42
42
|
`UnsafeDeviceReadPathReason`.
|
|
43
43
|
|
|
44
|
+
`isPathRelativeEscape()` rejects absolute paths and relative paths that step
|
|
45
|
+
above their starting directory at any point. Contained paths such as
|
|
46
|
+
`dir/../file` remain relative. It accepts both separators on Windows; on POSIX,
|
|
47
|
+
a backslash remains a literal filename character.
|
|
48
|
+
|
|
44
49
|
The advanced root-file primitive exports `OpenRootFileParams`,
|
|
45
50
|
`OpenRootFileSyncParams`, `RootFileOpenResult`, and
|
|
46
51
|
`RootFileOpenFailureReason`. These are composition types for callers building
|
|
@@ -100,7 +105,8 @@ best-effort cleanup helper used by those queue flows.
|
|
|
100
105
|
Permission inspection exposes `PermissionCheckOptions` and `SafeStatResult`.
|
|
101
106
|
Private-directory creation uses `CreatePrivateDirectoryOptions`. Raw Windows
|
|
102
107
|
descriptor facts use `OwnerAndDaclResult`, `WindowsAccessControlEntry`, and
|
|
103
|
-
`WindowsAceFlags`.
|
|
108
|
+
`WindowsAceFlags`. `readOwnerAndDaclBatch()` returns those same facts in input
|
|
109
|
+
order through an isolated asynchronous batch with a whole-process timeout.
|
|
104
110
|
|
|
105
111
|
Secure reads split their option and result shapes into
|
|
106
112
|
`SecureFileTrustOptions`, `SecureFilePermissionOptions`,
|
package/docs/root.md
CHANGED
|
@@ -24,7 +24,7 @@ type RootDefaults = {
|
|
|
24
24
|
denyMutations?: DenyMutationPolicy; // absolute paths/prefixes mutation methods may not change
|
|
25
25
|
maxBytes?: number; // refuse reads larger than this many bytes; defaults to 16 MiB
|
|
26
26
|
mkdir?: boolean; // create missing parent dirs on write/openWritable/append; default true
|
|
27
|
-
mode?: number; // file mode
|
|
27
|
+
mode?: number; // requested file mode; per-call override available
|
|
28
28
|
nonBlockingRead?: boolean; // compatibility hint; safe opens are already nonblocking where supported
|
|
29
29
|
renameIdentity?: "strict" | "verify-content-with-lock"; // default "strict"
|
|
30
30
|
symlinks?: "reject" | "follow-within-root" | "follow-parents-within-root"; // read policy
|
|
@@ -117,6 +117,10 @@ created through a directory symlink or Windows junction. An absolute path
|
|
|
117
117
|
outside the root is still rejected. On Windows, alternate casing is accepted
|
|
118
118
|
only when the differently cased Root prefix has the Root's exact directory
|
|
119
119
|
identity; the operation then continues under the trusted Root spelling.
|
|
120
|
+
Absolute paths keep literal `~` components: `readAbsolute("/srv/root/~/file")`
|
|
121
|
+
reads that entry under the root, without expanding the user's home directory.
|
|
122
|
+
Relative `~/file` inputs still expand the home directory and must remain inside
|
|
123
|
+
the Root; use `./~/file` for a literal relative `~` directory.
|
|
120
124
|
|
|
121
125
|
### Writes
|
|
122
126
|
|
|
@@ -153,6 +157,11 @@ await fs.create("private-data/credential", "synthetic credential", { private: tr
|
|
|
153
157
|
|
|
154
158
|
`write`, `create`, `append`, `writeJson`, and `createJson` accept `mode?: number`; use `0o600` for credentials and other private state. `writeJson` also accepts the same options as `JSON.stringify` plus `trailingNewline?: boolean` (defaults `true` so the file ends in `\n`).
|
|
155
159
|
|
|
160
|
+
For `append` and `openWritable`, `mode` only affects new-file creation: POSIX
|
|
161
|
+
permissions remain subject to the process umask. These methods do not chmod
|
|
162
|
+
existing files. Replacement and create-only writes apply their final mode
|
|
163
|
+
through the retained descriptor; see [Writing](writing.md#write-options).
|
|
164
|
+
|
|
156
165
|
Buffered `create` and `createJson` also accept `atomic?: boolean`. With `true`,
|
|
157
166
|
complete content is staged before exclusive publication even in native-off mode;
|
|
158
167
|
the fallback requires hardlinks. Omitted or `false` keeps the existing buffered
|
package/docs/secret-file.md
CHANGED
|
@@ -228,6 +228,15 @@ if anything already occupies the target path it throws
|
|
|
228
228
|
`FsSafeError("secret-exists")` without modifying that entry. Use the distinct
|
|
229
229
|
name when first-writer-wins is part of the credential protocol.
|
|
230
230
|
|
|
231
|
+
Its `durable` option also accepts `"file"`, matching `Root.create()`. This
|
|
232
|
+
requires every file `fsync` to succeed, including on `EPERM`, while parent-directory
|
|
233
|
+
synchronization remains best effort. The default and boolean options retain their
|
|
234
|
+
existing behavior. `writeSecretFileAtomic()` continues to accept boolean durability.
|
|
235
|
+
|
|
236
|
+
Strict file synchronization preserves the existing publication strategy and
|
|
237
|
+
identity-checked cleanup. A failed file flush before staged publication prevents
|
|
238
|
+
publication; a failure after publication can leave the complete file present.
|
|
239
|
+
|
|
231
240
|
Distinct leaves can share missing-parent creation without a `secret-exists`
|
|
232
241
|
error. Concurrent creates at the same leaf still have exactly one winner;
|
|
233
242
|
the loser receives `secret-exists` and leaves the winner's bytes intact.
|
|
@@ -244,6 +253,7 @@ try {
|
|
|
244
253
|
rootDir: "/var/lib/app/credentials",
|
|
245
254
|
filePath: "/var/lib/app/credentials/provider.refresh-token",
|
|
246
255
|
content: refreshToken,
|
|
256
|
+
durable: "file",
|
|
247
257
|
});
|
|
248
258
|
} catch (error) {
|
|
249
259
|
if (!(error instanceof FsSafeError) || error.code !== "secret-exists") throw error;
|
package/docs/security-model.md
CHANGED
|
@@ -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
|
package/docs/sidecar-lock.md
CHANGED
|
@@ -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
|
|
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
|