@openclaw/fs-safe 0.6.0 → 0.7.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.
- package/CHANGELOG.md +79 -0
- package/README.md +7 -0
- package/dist/archive-deadline.d.ts +3 -0
- package/dist/archive-deadline.d.ts.map +1 -1
- package/dist/archive-deadline.js +44 -8
- package/dist/archive-entry.d.ts.map +1 -1
- package/dist/archive-entry.js +1 -0
- package/dist/archive-errors.d.ts +1 -0
- package/dist/archive-errors.d.ts.map +1 -1
- package/dist/archive-errors.js +3 -0
- package/dist/archive-input.d.ts.map +1 -1
- package/dist/archive-input.js +26 -16
- package/dist/archive-limits.d.ts +11 -3
- package/dist/archive-limits.d.ts.map +1 -1
- package/dist/archive-limits.js +24 -0
- package/dist/archive-native.d.ts +3 -2
- package/dist/archive-native.d.ts.map +1 -1
- package/dist/archive-native.js +24 -9
- package/dist/archive-policy.d.ts +2 -0
- package/dist/archive-policy.d.ts.map +1 -1
- package/dist/archive-policy.js +9 -1
- package/dist/archive-read.d.ts.map +1 -1
- package/dist/archive-read.js +69 -41
- package/dist/archive-staging.d.ts +3 -0
- package/dist/archive-staging.d.ts.map +1 -1
- package/dist/archive-staging.js +91 -43
- package/dist/archive-tar-admission.d.ts +7 -0
- package/dist/archive-tar-admission.d.ts.map +1 -0
- package/dist/archive-tar-admission.js +43 -0
- package/dist/archive-tar-gnu.d.ts +2 -0
- package/dist/archive-tar-gnu.d.ts.map +1 -0
- package/dist/archive-tar-gnu.js +20 -0
- package/dist/archive-tar-header.d.ts +8 -0
- package/dist/archive-tar-header.d.ts.map +1 -0
- package/dist/archive-tar-header.js +47 -0
- package/dist/archive-tar-meta.d.ts +29 -1
- package/dist/archive-tar-meta.d.ts.map +1 -1
- package/dist/archive-tar-meta.js +104 -35
- package/dist/archive-tar-runtime.d.ts +3 -0
- package/dist/archive-tar-runtime.d.ts.map +1 -1
- package/dist/archive-tar-runtime.js +3 -0
- package/dist/archive-tar.d.ts.map +1 -1
- package/dist/archive-tar.js +8 -2
- package/dist/archive-zip-names.js +2 -2
- package/dist/archive.d.ts.map +1 -1
- package/dist/archive.js +38 -27
- package/dist/atomic.d.ts +1 -1
- package/dist/atomic.d.ts.map +1 -1
- package/dist/bounded-read-stream.d.ts.map +1 -1
- package/dist/bounded-read-stream.js +2 -4
- package/dist/bounded-read.d.ts.map +1 -1
- package/dist/bounded-read.js +3 -10
- package/dist/byte-budget.d.ts +5 -0
- package/dist/byte-budget.d.ts.map +1 -0
- package/dist/byte-budget.js +9 -0
- package/dist/directory-guard.d.ts +2 -1
- package/dist/directory-guard.d.ts.map +1 -1
- package/dist/directory-guard.js +10 -0
- package/dist/file-lock-sync.d.ts.map +1 -1
- package/dist/file-lock-sync.js +81 -30
- package/dist/file-observation.d.ts +9 -0
- package/dist/file-observation.d.ts.map +1 -0
- package/dist/file-observation.js +22 -0
- package/dist/file-store-boundary.d.ts.map +1 -1
- package/dist/file-store-boundary.js +9 -5
- package/dist/file-store-limit.d.ts +2 -0
- package/dist/file-store-limit.d.ts.map +1 -0
- package/dist/file-store-limit.js +8 -0
- package/dist/file-store-sync-write.d.ts.map +1 -1
- package/dist/file-store-sync-write.js +38 -8
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +26 -26
- package/dist/json-durable-queue-directory.d.ts +2 -0
- package/dist/json-durable-queue-directory.d.ts.map +1 -0
- package/dist/json-durable-queue-directory.js +20 -0
- package/dist/json-durable-queue-ownership.d.ts +14 -0
- package/dist/json-durable-queue-ownership.d.ts.map +1 -0
- package/dist/json-durable-queue-ownership.js +168 -0
- package/dist/json-durable-queue-retirement.d.ts +9 -0
- package/dist/json-durable-queue-retirement.d.ts.map +1 -0
- package/dist/json-durable-queue-retirement.js +126 -0
- package/dist/json-durable-queue-transfer-lock.d.ts +2 -0
- package/dist/json-durable-queue-transfer-lock.d.ts.map +1 -0
- package/dist/json-durable-queue-transfer-lock.js +19 -0
- package/dist/json-durable-queue.d.ts +1 -0
- package/dist/json-durable-queue.d.ts.map +1 -1
- package/dist/json-durable-queue.js +90 -57
- package/dist/json.d.ts.map +1 -1
- package/dist/json.js +32 -13
- package/dist/local-roots.d.ts.map +1 -1
- package/dist/local-roots.js +4 -2
- package/dist/native-binding.d.ts +12 -3
- package/dist/native-binding.d.ts.map +1 -1
- package/dist/native-pinned-write-windows.js +1 -1
- package/dist/opened-file-failure.d.ts +7 -0
- package/dist/opened-file-failure.d.ts.map +1 -0
- package/dist/opened-file-failure.js +41 -0
- package/dist/opened-realpath.d.ts.map +1 -1
- package/dist/opened-realpath.js +8 -2
- package/dist/output-sibling.d.ts.map +1 -1
- package/dist/output-sibling.js +11 -110
- package/dist/output.d.ts.map +1 -1
- package/dist/output.js +4 -2
- package/dist/pinned-open.d.ts.map +1 -1
- package/dist/pinned-open.js +27 -48
- package/dist/pinned-write.d.ts.map +1 -1
- package/dist/pinned-write.js +10 -10
- package/dist/private-temp-workspace.d.ts +3 -1
- package/dist/private-temp-workspace.d.ts.map +1 -1
- package/dist/private-temp-workspace.js +81 -56
- package/dist/publish-file.d.ts.map +1 -1
- package/dist/publish-file.js +2 -4
- package/dist/read-opened-file.d.ts.map +1 -1
- package/dist/read-opened-file.js +6 -4
- package/dist/regular-file.d.ts.map +1 -1
- package/dist/regular-file.js +136 -82
- package/dist/replace-file-copy-fallback.d.ts +3 -1
- package/dist/replace-file-copy-fallback.d.ts.map +1 -1
- package/dist/replace-file-copy-fallback.js +21 -36
- package/dist/replace-file-copy-source.d.ts +21 -0
- package/dist/replace-file-copy-source.d.ts.map +1 -0
- package/dist/replace-file-copy-source.js +112 -0
- package/dist/replace-file-descriptor.d.ts +13 -3
- package/dist/replace-file-descriptor.d.ts.map +1 -1
- package/dist/replace-file-descriptor.js +32 -7
- package/dist/replace-file-rename-policy.d.ts +7 -0
- package/dist/replace-file-rename-policy.d.ts.map +1 -0
- package/dist/replace-file-rename-policy.js +30 -0
- package/dist/replace-file-temp-owner.d.ts +46 -0
- package/dist/replace-file-temp-owner.d.ts.map +1 -0
- package/dist/replace-file-temp-owner.js +346 -0
- package/dist/replace-file.d.ts +6 -1
- package/dist/replace-file.d.ts.map +1 -1
- package/dist/replace-file.js +72 -58
- package/dist/root-impl.d.ts.map +1 -1
- package/dist/root-impl.js +113 -82
- package/dist/secret-file.d.ts.map +1 -1
- package/dist/secret-file.js +6 -3
- package/dist/secret-read-async.d.ts.map +1 -1
- package/dist/secret-read-async.js +6 -5
- package/dist/secure-file.d.ts.map +1 -1
- package/dist/secure-file.js +17 -13
- package/dist/sibling-staged-file.d.ts +15 -0
- package/dist/sibling-staged-file.d.ts.map +1 -0
- package/dist/sibling-staged-file.js +148 -0
- package/dist/sibling-temp.d.ts +3 -0
- package/dist/sibling-temp.d.ts.map +1 -1
- package/dist/sibling-temp.js +30 -74
- package/dist/sidecar-lock-acquire.d.ts.map +1 -1
- package/dist/sidecar-lock-acquire.js +91 -37
- package/dist/sidecar-lock-handle.d.ts +6 -2
- package/dist/sidecar-lock-handle.d.ts.map +1 -1
- package/dist/sidecar-lock-handle.js +17 -3
- package/dist/sidecar-lock-policy.d.ts +2 -0
- package/dist/sidecar-lock-policy.d.ts.map +1 -1
- package/dist/sidecar-lock-policy.js +29 -0
- package/dist/sidecar-lock-reclaim.d.ts +16 -5
- package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
- package/dist/sidecar-lock-reclaim.js +60 -28
- package/dist/sidecar-lock-root.d.ts +3 -0
- package/dist/sidecar-lock-root.d.ts.map +1 -0
- package/dist/sidecar-lock-root.js +76 -0
- package/dist/sidecar-lock.d.ts.map +1 -1
- package/dist/sidecar-lock.js +28 -16
- package/dist/strict-file-identity.d.ts.map +1 -1
- package/dist/strict-file-identity.js +4 -1
- package/dist/suppressed-error.d.ts +6 -0
- package/dist/suppressed-error.d.ts.map +1 -0
- package/dist/suppressed-error.js +15 -0
- package/dist/temp-cleanup.d.ts +2 -0
- package/dist/temp-cleanup.d.ts.map +1 -1
- package/dist/temp-cleanup.js +25 -10
- package/dist/temp-workspace-owner.d.ts +23 -0
- package/dist/temp-workspace-owner.d.ts.map +1 -0
- package/dist/temp-workspace-owner.js +320 -0
- package/dist/temp.d.ts +1 -1
- package/dist/temp.d.ts.map +1 -1
- package/dist/test-hooks.d.ts +5 -0
- package/dist/test-hooks.d.ts.map +1 -1
- package/docs/advanced.md +8 -1
- package/docs/archive.md +202 -26
- package/docs/atomic.md +11 -2
- package/docs/durability.md +1 -1
- package/docs/file-store.md +2 -0
- package/docs/install.md +8 -4
- package/docs/json.md +11 -4
- package/docs/native-helper.md +15 -3
- package/docs/native.md +33 -6
- package/docs/output.md +16 -3
- package/docs/public-api.md +6 -3
- package/docs/quickstart.md +1 -1
- package/docs/reading.md +1 -1
- package/docs/regular-file.md +9 -2
- package/docs/root.md +2 -0
- package/docs/secret-file.md +5 -1
- package/docs/secure-file.md +4 -2
- package/docs/security-model.md +18 -0
- package/docs/sidecar-lock.md +60 -4
- package/docs/store.md +13 -0
- package/docs/temp.md +128 -14
- package/docs/writing.md +6 -0
- package/package.json +10 -10
package/docs/root.md
CHANGED
|
@@ -38,6 +38,8 @@ type DenyMutationPolicy = {
|
|
|
38
38
|
|
|
39
39
|
`defaults` apply to every method on the returned handle. Per-call options on individual methods override the defaults for that call only, except `denyMutations`: root and per-call deny entries are merged so a call cannot clear a root-level deny.
|
|
40
40
|
|
|
41
|
+
Every `maxBytes` value must be a non-negative safe integer or positive `Infinity`. Zero is an active zero-byte cap; `Infinity` explicitly disables the cap. An omitted or explicitly `undefined` per-call value preserves the configured Root default instead of clearing it.
|
|
42
|
+
|
|
41
43
|
## The `Root` interface
|
|
42
44
|
|
|
43
45
|
Every method on the returned handle accepts paths relative to the root and rejects anything that would escape it.
|
package/docs/secret-file.md
CHANGED
|
@@ -73,6 +73,8 @@ type SecretFileReadOptions = {
|
|
|
73
73
|
};
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
+
`maxBytes` must be a non-negative safe integer or positive `Infinity`; zero is an active cap, `Infinity` disables it, and omitted or explicitly `undefined` values preserve the 16 KiB default.
|
|
77
|
+
|
|
76
78
|
The reader trims the file content and rejects empty results. Symlink paths are
|
|
77
79
|
followed and pinned by default; set `rejectSymlink: true` when the pathname
|
|
78
80
|
itself must not be an alias. Hardlinks are rejected by default so another
|
|
@@ -90,7 +92,9 @@ the same pinned-handle validation, byte cap, trimming, error codes, and strict
|
|
|
90
92
|
versus missing-is-undefined naming semantics.
|
|
91
93
|
|
|
92
94
|
Both sync and async readers compare lossless bigint identities from the preview,
|
|
93
|
-
opened descriptor, resolved target, and current input path before reading.
|
|
95
|
+
opened descriptor, resolved target, and current input path before reading. POSIX
|
|
96
|
+
opens are nonblocking, so a raced FIFO is rejected by descriptor type instead of
|
|
97
|
+
waiting for a writer. An
|
|
94
98
|
allowed symlink must still point to the opened file. On Windows, a zero device
|
|
95
99
|
or inode is unverified: that inspection is retried once without reopening the
|
|
96
100
|
file, preserving known identity components and link checks. Definite mismatches
|
package/docs/secure-file.md
CHANGED
|
@@ -18,8 +18,8 @@ const { buffer, realPath, permissions } = await readSecureFile({
|
|
|
18
18
|
The helper:
|
|
19
19
|
|
|
20
20
|
- requires a local absolute path and rejects UNC/network paths by default
|
|
21
|
-
- rejects
|
|
22
|
-
- opens
|
|
21
|
+
- rejects every non-regular preview and, by default, symlink paths
|
|
22
|
+
- opens POSIX paths no-follow and nonblocking before reading, then verifies the opened fd still matches the path and realpath; a FIFO swap cannot block before `timeoutMs` owns the byte read
|
|
23
23
|
- optionally requires the real path to live under one of `trust.trustedDirs`
|
|
24
24
|
- rejects hard-to-verify or unsafe permissions unless `permissions.allowInsecure` is set
|
|
25
25
|
- rejects files owned by another POSIX uid
|
|
@@ -57,6 +57,8 @@ type SecureFileReadOptions = {
|
|
|
57
57
|
};
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
+
`io.maxBytes` must be a non-negative safe integer or positive `Infinity`; zero is an active cap and `Infinity` disables the cap. Invalid limits reject before filesystem admission.
|
|
61
|
+
|
|
60
62
|
`permissions.allowInsecure` is a migration escape hatch. Prefer fixing permissions and using [`formatPermissionRemediation`](permissions.md) to show the user what to run. `trust.allowNetworkPath` is off by default because UNC paths are remote authority, not local filesystem input. `inject` is for tests and platform adapters; production callers usually leave it unset.
|
|
61
63
|
|
|
62
64
|
`permissions.allowInsecure` bypasses only permission checks. Neither it nor `inject.platform` changes filesystem identity verification, which always uses the actual process platform. `trust.allowSymlink` permits an alias but still requires its target and realpath to match the opened descriptor.
|
package/docs/security-model.md
CHANGED
|
@@ -58,6 +58,9 @@ descriptor, the input path, and the canonical target; numeric public `Stats`
|
|
|
58
58
|
receipts are not used as identity evidence. Unknown Windows device/inode values
|
|
59
59
|
receive one re-inspection without reopening the file. A definite mismatch or
|
|
60
60
|
persistent unknown identity rejects with `path-mismatch` before reading bytes.
|
|
61
|
+
Regular-file readers, root-file adapters, and archive input staging use the same
|
|
62
|
+
exact admission policy. `copyIn()` retains the admitted source identity for its
|
|
63
|
+
checks before and after copying, independently of its numeric metadata receipt.
|
|
61
64
|
|
|
62
65
|
### Symlinks (write side)
|
|
63
66
|
|
|
@@ -121,6 +124,21 @@ durability guarantee.
|
|
|
121
124
|
|
|
122
125
|
`extractArchive` first stages into a private temp directory (mode 0700) outside the destination, validates each entry path against `..` and absolute prefixes, refuses link-type entries by default, enforces entry count and byte budgets, and only then merges the staged tree into the destination through the same boundary checks used by direct writes.
|
|
123
126
|
|
|
127
|
+
TAR extraction and bounded reads validate the complete decompressed raw framing
|
|
128
|
+
before parser normalization: directory/link sizes must be zero, EOF must contain
|
|
129
|
+
two consecutive zero blocks, and only zero padding may follow EOF. This rejects
|
|
130
|
+
hidden headers and body/header smuggling consistently across the JavaScript and
|
|
131
|
+
native backends. The meter checks logical member count before consuming bodies,
|
|
132
|
+
even for unrequested or filtered members. Per-entry/cumulative payload budgets
|
|
133
|
+
apply only to entries accepted after strip/filter policy, outside the raw meter.
|
|
134
|
+
A separate absolute decoded cap includes every member body, headers, metadata,
|
|
135
|
+
all padding, and EOF, closing cumulative-metadata and zero-tail bypasses. Native
|
|
136
|
+
extraction/read passes drain through physical EOF before publication or
|
|
137
|
+
returning selected bytes, even when the TAR parser stops earlier. Bounded
|
|
138
|
+
reads use default archive admission limits and keep `maxBytes` scoped to the
|
|
139
|
+
requested output. Metadata retains its per-record bound. See [archive framing](archive.md#raw-tar-framing) for supported
|
|
140
|
+
metadata and compression formats.
|
|
141
|
+
|
|
124
142
|
## What "library-level" means
|
|
125
143
|
|
|
126
144
|
A library cannot revoke its own caller's authority. If your code chooses to bypass `fs-safe` and call `fs.writeFile` directly with the same path, you bypass the defenses too. The contract `fs-safe` enforces is: *every filesystem operation that touches caller-controlled input goes through the boundary*. That contract is yours to keep.
|
package/docs/sidecar-lock.md
CHANGED
|
@@ -27,6 +27,8 @@ Each new sidecar also carries an internal random ownership token encoded as JSON
|
|
|
27
27
|
|
|
28
28
|
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.
|
|
29
29
|
|
|
30
|
+
`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()` callback and release fail, the release error is the primary `SuppressedError.error` and the callback failure remains available as `SuppressedError.suppressed`. Failed 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.
|
|
31
|
+
|
|
30
32
|
## API
|
|
31
33
|
|
|
32
34
|
```ts
|
|
@@ -92,15 +94,21 @@ type FileLockRetryOptions = {
|
|
|
92
94
|
```
|
|
93
95
|
|
|
94
96
|
`payload` is a function so you can re-evaluate it on each retry (e.g. timestamp, PID).
|
|
97
|
+
Errors thrown by `payload`, its JSON serialization (including `toJSON`), or
|
|
98
|
+
`parsePayload` propagate unchanged without retrying the callback. Rethrowing an
|
|
99
|
+
error saved from an earlier filesystem operation does not grant retry authority.
|
|
100
|
+
Retry counts must be non-negative safe integers. Retry factors and delays must be finite and non-negative, and when both delay bounds are provided `minTimeout` cannot exceed `maxTimeout`. `timeoutMs` accepts a finite non-negative deadline or positive infinity for an unbounded wait; invalid numeric values reject before filesystem acquisition starts.
|
|
95
101
|
`parsePayload` replaces JSON parsing for legacy or custom sidecars. Its `unknown`
|
|
96
102
|
result is passed to `shouldReclaim` and `shouldRemoveStaleLock`, allowing PID,
|
|
97
103
|
process-start, argv, or role schemas to remain application-owned.
|
|
98
104
|
|
|
99
105
|
On Windows, a pathed `EPERM` from creating or opening the lock file can be a
|
|
100
|
-
short teardown race after another holder unlinks it.
|
|
101
|
-
specific denial at most eight times
|
|
102
|
-
|
|
103
|
-
|
|
106
|
+
short teardown race after another holder unlinks it. Both async and sync locks
|
|
107
|
+
retry that specific open denial at most eight times per acquisition, within the
|
|
108
|
+
caller's retry/deadline budget. A parent-directory denial, a callback/read/stat
|
|
109
|
+
failure, or exhaustion of either budget surfaces the original error; a denied
|
|
110
|
+
open is not converted to `file_lock_timeout`. Retrying always requires fresh
|
|
111
|
+
exclusive creation and grants no ownership or removal authority.
|
|
104
112
|
|
|
105
113
|
## Owner-scoped reentrancy
|
|
106
114
|
|
|
@@ -152,6 +160,45 @@ an existing `Root` capability. `lockPath` must resolve inside that root.
|
|
|
152
160
|
Identity-conditioned removal remains the only release and reclaim deletion
|
|
153
161
|
path.
|
|
154
162
|
|
|
163
|
+
An owner can finish releasing while another async acquirer inspects its record.
|
|
164
|
+
Create-only Root writes do not open an existing record merely to inherit its
|
|
165
|
+
mode. Once a pathname sample and opened descriptor agree, a failed acquisition
|
|
166
|
+
snapshot can be discarded only when the original descriptor has exact identity,
|
|
167
|
+
was not observed with multiple links, and proves it was unlinked (`nlink === 0`).
|
|
168
|
+
This includes Windows resolver
|
|
169
|
+
`EPERM`/`EBADF` failures, with evidence captured at the failing operation before
|
|
170
|
+
closing the descriptor. The canonical in-root ancestor chain and Root are
|
|
171
|
+
rechecked; permitted in-root parent symlinks are resolved before those checks.
|
|
172
|
+
|
|
173
|
+
A contending waiter may also encounter a new holder between its pre-open
|
|
174
|
+
pathname inspection and opening the file. It may discard that stale observation
|
|
175
|
+
only when the old sample and opened descriptor have different, strictly known
|
|
176
|
+
regular-file identities, neither was observed with multiple links, and the
|
|
177
|
+
opened descriptor and complete canonical ancestry pass reinspection. This does
|
|
178
|
+
not prove the old pathname sample was unlinked rather than moved. The new
|
|
179
|
+
holder's payload is not read or adopted. Post-create admission never opts into
|
|
180
|
+
this pre-open-change policy.
|
|
181
|
+
|
|
182
|
+
Discarding an acquisition observation is not proof that the pathname is absent:
|
|
183
|
+
another owner may already have created the next record. Every discarded
|
|
184
|
+
observation consumes the normal retry/deadline budget and requires fresh
|
|
185
|
+
exclusive creation. It supplies no release, reclaim, or held-lock authority.
|
|
186
|
+
Generic `Root.open()` and held-owner/reclaim reads still reject failed opens.
|
|
187
|
+
Moving an already-matched pinned descriptor without unlinking it, unknown or
|
|
188
|
+
inexact identities, retargeted ancestors, and unrelated filesystem or caller
|
|
189
|
+
errors fail closed. Failure receipts belong only to the current Root observation,
|
|
190
|
+
including during nested or concurrent acquisitions; historical error identity
|
|
191
|
+
is not changed-file, unlink, or open-denial evidence.
|
|
192
|
+
|
|
193
|
+
After creating a record, the async Root-backed acquirer checks the reopened
|
|
194
|
+
bytes against its exact serialized payload and ownership token. A replacement
|
|
195
|
+
is never adopted; a descriptor observed unlinked at the end of admission is
|
|
196
|
+
never registered as held. Failed admission cleanup retains the original creator
|
|
197
|
+
receipt, so it cannot remove a replacement using a later stat alone. Native
|
|
198
|
+
mode changes the create mechanism, not these Root-backed admission checks.
|
|
199
|
+
Non-Root and synchronous snapshots retain their descriptor/read/path checks
|
|
200
|
+
and do not use the Root opened-path resolver.
|
|
201
|
+
|
|
155
202
|
## Release handle
|
|
156
203
|
|
|
157
204
|
```ts
|
|
@@ -181,7 +228,16 @@ compromise interval treats a thrown verification I/O error as a lost lock and
|
|
|
181
228
|
invokes `onCompromised` once, matching the asynchronous `.catch(() => false)`
|
|
182
229
|
contract. An explicit `verifyStillHeld()` call still propagates that I/O error.
|
|
183
230
|
|
|
231
|
+
Windows synchronous lock parents use the same canonical path spelling as
|
|
232
|
+
`root()`, including short-name expansion. With `lockRoot`, a failed parent
|
|
233
|
+
canonicalization or an out-of-root parent still rejects. Missing-path observations from snapshot
|
|
234
|
+
`lstat`/`open`, and identity-mismatched snapshots, consume the normal retry and
|
|
235
|
+
deadline budget. Errors from descriptor reads/stats or parsing are not treated
|
|
236
|
+
as missing snapshots, even when their code is `ENOENT`. Held verification,
|
|
237
|
+
release, and reclaim do not retry open denials.
|
|
238
|
+
|
|
184
239
|
Both synchronous helpers consume the [process-wide lock defaults](config.md#configurefssafelocks-config).
|
|
240
|
+
A synchronous retry sleep is clamped to the remaining finite deadline, so a long or jittered backoff cannot extend the configured timeout or block forever.
|
|
185
241
|
Per-call options take precedence, including zero values; a per-call `retry`
|
|
186
242
|
object replaces the entire configured retry object. A configured
|
|
187
243
|
`staleRecovery: "remove-if-unchanged"` still needs per-call
|
package/docs/store.md
CHANGED
|
@@ -68,6 +68,19 @@ Use `ackJsonDurableQueueEntry()` after durable processing succeeds and
|
|
|
68
68
|
`moveJsonDurableQueueEntryToFailed()` when the caller wants to quarantine an
|
|
69
69
|
entry for inspection.
|
|
70
70
|
|
|
71
|
+
Loading serializes consumers for one ID through a sidecar lock, then creates `processingPath` with a no-replace hardlink and retires the source through a recoverable sibling record. Whichever producer generation occupies `.json` at the hardlink operation becomes the claim; a replacement published afterward remains pending. After a crash, `.processing` is retried before that pending replacement. Transfer locks are fail-closed and are never reclaimed automatically, so an operator must remove a proven-stale lock after a hard process crash. Acknowledgement atomically moves `.processing` through the short-lived `.delivered` marker, and existing delivered markers are cleaned during batch loading. `ackJsonDurableQueueEntry()` rejects while `.json` exists without a processing claim: callers that previously paired direct `readJsonDurableQueueEntry()` with acknowledgement must load through `loadJsonDurableQueueEntry()` before processing so acknowledgement is generation-bound.
|
|
72
|
+
|
|
73
|
+
Queue and failed directory creation fsyncs every newly-created parent edge from the leaf toward the trusted root. Enqueue and migration writes fsync the temp file and parent; claim, acknowledgement, quarantine, delivered-marker cleanup, and retirement transitions fsync every affected directory and propagate real sync failures. A transition may already be visible when a post-mutation sync fails, so retry the same operation to complete its crash-recovery state; quarantine retries with only failed evidence resync that destination before repairing the vanished queue source.
|
|
74
|
+
|
|
75
|
+
Failed destinations are create-only. Quarantine publishes the claimed file by hardlink, so the queue and failed directories must share a filesystem with hardlink support. If `failed/<id>.json` already exists, quarantine rejects while preserving both that earlier evidence and the current claimed entry instead of overwriting either file. The `read` callback continues to receive the logical `.json` path even though bytes are read and migrations are written through the claimed path.
|
|
76
|
+
|
|
77
|
+
Queue entry reads verify lossless file identities before opening, on the opened
|
|
78
|
+
descriptor, and at the current pathname before reading bytes. POSIX opens are
|
|
79
|
+
nonblocking, so a raced FIFO is rejected rather than stalling a consumer. On Windows, an
|
|
80
|
+
unknown identity gets one bounded reinspection; persistent ambiguity or a
|
|
81
|
+
mismatch rejects with `queue entry changed during read`. Each inspection still
|
|
82
|
+
rejects non-files, symlinks, hardlinks, and entries over the byte limit.
|
|
83
|
+
|
|
71
84
|
## Related pages
|
|
72
85
|
|
|
73
86
|
- [`fileStore`](file-store.md) — full API for the multi-file store.
|
package/docs/temp.md
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
|
|
15
15
|
## Private temp workspaces
|
|
16
16
|
|
|
17
|
-
A private workspace is a directory created at mode `0o700` under a caller-provided temp root. It is unique per call (random suffix)
|
|
17
|
+
A private workspace is a directory created at mode `0o700` under a caller-provided temp root. It is unique per call (random suffix). Calling `cleanup()` or leaving an `await using` scope moves an unchanged workspace through a private quarantine before removal. Descriptor-bounded cleanup prevents recursive traversal of substitutions; the compatible JavaScript fallback has the narrower race contract documented below.
|
|
18
18
|
|
|
19
19
|
### `tempWorkspace`
|
|
20
20
|
|
|
@@ -31,7 +31,7 @@ type TempWorkspace = {
|
|
|
31
31
|
writeJson(fileName: string, data: unknown, options?: { trailingNewline?: boolean }): Promise<string>;
|
|
32
32
|
copyIn(fileName: string, sourcePath: string): Promise<string>;
|
|
33
33
|
read(fileName: string): Promise<Buffer>;
|
|
34
|
-
cleanup(): Promise<"removed" | "missing" | "identity-mismatch">;
|
|
34
|
+
cleanup(): Promise<"removed" | "missing" | "identity-mismatch" | "indeterminate">;
|
|
35
35
|
[Symbol.asyncDispose](): Promise<void>;
|
|
36
36
|
};
|
|
37
37
|
```
|
|
@@ -64,12 +64,66 @@ await state.write({ ready: true });
|
|
|
64
64
|
The workspace owns cleanup; the store is only a view over the workspace
|
|
65
65
|
directory.
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
67
|
+
**Compatibility and security:** workspace creation remains available in native
|
|
68
|
+
`auto`, `off`, and unavailable-native environments. The default
|
|
69
|
+
`cleanupSafety: "compatible"` preserves the JavaScript cleanup behavior from
|
|
70
|
+
0.6: it verifies the workspace identity, moves the public name to a fresh
|
|
71
|
+
`.fs-safe-workspace-cleanup-<uuid>` sibling, verifies that quarantine, and then
|
|
72
|
+
uses guarded pathname-recursive removal. This fallback never recursively
|
|
73
|
+
removes the public workspace name, but it is not atomic conditional deletion: a
|
|
74
|
+
same-privilege peer that discovers and replaces the private quarantine after
|
|
75
|
+
verification can still redirect the final pathname removal.
|
|
76
|
+
|
|
77
|
+
Set `cleanupSafety: "require-bounded"` when that concurrent attacker is in scope.
|
|
78
|
+
Creation then requires native no-replace directory rename, native owned-tree
|
|
79
|
+
removal, and a retained parent descriptor **before** `mkdtemp` creates
|
|
80
|
+
a child. If any capability is unavailable, creation throws
|
|
81
|
+
`FsSafeError("helper-unavailable")`; no child is created and a scoped callback is
|
|
82
|
+
not called. The compatible default retains its fallback even if process-global
|
|
83
|
+
native mode is `require`; select `require-bounded` to make cleanup capability
|
|
84
|
+
mandatory for this API.
|
|
85
|
+
|
|
86
|
+
On Linux, bounded cleanup requires a successful runtime probe of the exact
|
|
87
|
+
`openat2` child-directory flags, including `RESOLVE_NO_XDEV`, against the retained
|
|
88
|
+
parent descriptor. If the kernel or seccomp policy denies that capability,
|
|
89
|
+
compatible mode uses the guarded JavaScript fallback; `require-bounded` rejects
|
|
90
|
+
before child creation. The probe runs once at creation, without filesystem mutation.
|
|
91
|
+
|
|
92
|
+
Bounded cleanup checks the parent and public workspace identity, quarantines
|
|
93
|
+
the direct child without replacement, and verifies the quarantine against the
|
|
94
|
+
retained workspace descriptor. It binds every enumerated child to its native
|
|
95
|
+
identity before opening it, rejects mount crossings, and traverses descendants
|
|
96
|
+
only through opened directory handles; symlinks/reparse entries are removed as
|
|
97
|
+
leaves and never traversed. Windows marks the exact opened objects for deletion by handle.
|
|
98
|
+
|
|
99
|
+
POSIX has no unlink-by-fd or expected-inode unlink for directory entries. After
|
|
100
|
+
the final identity check, each `unlinkat` can still be raced; the possible side
|
|
101
|
+
effect is bounded to one substituted non-directory leaf or one empty directory
|
|
102
|
+
entry per raced syscall. A substituted nonempty directory is never recursively
|
|
103
|
+
traversed and is preserved as `"indeterminate"`, but a leaf replacement removed
|
|
104
|
+
in that irreducible final gap cannot be distinguished after the syscall.
|
|
105
|
+
|
|
106
|
+
The workspace captures its identity, binding, and descriptors until cleanup.
|
|
107
|
+
Later process-global mode changes or loader resets do not revoke that authority.
|
|
108
|
+
Manual, disposal, and process-exit cleanup share one serialized owner,
|
|
109
|
+
registered before store construction; a construction failure after registration
|
|
110
|
+
remains exit-cleanable. Earlier creation failures close retained descriptors
|
|
111
|
+
without deleting an unverified child.
|
|
112
|
+
|
|
113
|
+
If the quarantine does not match the creation descriptor, cleanup leaves it in
|
|
114
|
+
place without restoring the public name or recursively deleting it and returns
|
|
115
|
+
`"indeterminate"`. A collision, uncertain rename outcome, changed parent,
|
|
116
|
+
mount/device crossing, changed reparse state, or detected concurrent mutation also
|
|
117
|
+
preserves the remaining artifact. Recover `.fs-safe-workspace-cleanup-<uuid>` entries only
|
|
118
|
+
after excluding competing mutators and re-establishing ownership.
|
|
119
|
+
|
|
120
|
+
A missing workspace returns `"missing"`. A replacement observed at the public
|
|
121
|
+
name before quarantine returns `"identity-mismatch"` when the parent is stable;
|
|
122
|
+
an ambiguous parent returns `"indeterminate"`. After successful removal,
|
|
123
|
+
repeated cleanup returns `"missing"` without touching a recreated public name.
|
|
124
|
+
Other statuses remain stable. Operational removal errors propagate and later
|
|
125
|
+
cleanup returns `"indeterminate"` without retrying. Disposal and scoped helpers
|
|
126
|
+
ignore returned statuses, while manual cleanup exposes the result.
|
|
73
127
|
|
|
74
128
|
When cleanup is part of a retention or audit decision, inspect the receipt
|
|
75
129
|
instead of treating cleanup as fire-and-forget:
|
|
@@ -82,6 +136,8 @@ try {
|
|
|
82
136
|
const cleanup = await workspace.cleanup();
|
|
83
137
|
if (cleanup === "identity-mismatch") {
|
|
84
138
|
alertOperator("restore workspace path was replaced; replacement preserved");
|
|
139
|
+
} else if (cleanup === "indeterminate") {
|
|
140
|
+
alertOperator("restore workspace cleanup could not establish safe completion; inspect retained entries");
|
|
85
141
|
}
|
|
86
142
|
}
|
|
87
143
|
```
|
|
@@ -91,7 +147,7 @@ types and a `FileStoreSync` at `workspace.store`.
|
|
|
91
147
|
|
|
92
148
|
### `withTempWorkspace`
|
|
93
149
|
|
|
94
|
-
The recommended shape.
|
|
150
|
+
The recommended shape. Attempts cleanup on every exit path:
|
|
95
151
|
|
|
96
152
|
```ts
|
|
97
153
|
import { withTempWorkspace } from "@openclaw/fs-safe/temp";
|
|
@@ -129,6 +185,7 @@ type TempWorkspaceOptions = {
|
|
|
129
185
|
prefix: string; // dir prefix (sanitized)
|
|
130
186
|
dirMode?: number; // dir mode; default 0o700
|
|
131
187
|
mode?: number; // file write mode; default 0o600
|
|
188
|
+
cleanupSafety?: "compatible" | "require-bounded"; // default compatible
|
|
132
189
|
};
|
|
133
190
|
```
|
|
134
191
|
|
|
@@ -205,11 +262,65 @@ const result = await writeSiblingTempFile<string>({
|
|
|
205
262
|
// result.filePath, result.result (returned by writeTemp)
|
|
206
263
|
```
|
|
207
264
|
|
|
208
|
-
`writeSiblingTempFile` chooses a random sibling name in `dir
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
265
|
+
`writeSiblingTempFile` chooses a random, initially absent sibling name in `dir`
|
|
266
|
+
and calls `writeTemp()`. After the callback succeeds, it validates the produced
|
|
267
|
+
regular file before taking ownership: symlinks, directories, other non-regular
|
|
268
|
+
files, hardlinks, and changes between the pre-open pathname, opened descriptor,
|
|
269
|
+
and current pathname are rejected. The callback must finish and close its
|
|
270
|
+
writer before returning. Its return value is preserved as `result`.
|
|
271
|
+
|
|
272
|
+
The helper retains one descriptor through requested mode application, opt-in
|
|
273
|
+
file synchronization, rename, and publication verification. It opens read-only
|
|
274
|
+
unless file synchronization is requested, so closed read-only producer output
|
|
275
|
+
remains publishable under the historical default. Omitting
|
|
276
|
+
`mode` preserves the callback-produced mode without chmod; explicit modes,
|
|
277
|
+
including `0`, are applied through that descriptor. File-mode errors are
|
|
278
|
+
tolerated for compatibility with the helper's historical best-effort behavior.
|
|
279
|
+
No chmod, content read, or reopen follows
|
|
280
|
+
the staged or published pathname. `resolveFinalPath(result)` must resolve to a
|
|
281
|
+
distinct direct child of the same directory. Final-path writes are serialized
|
|
282
|
+
within the process, and the retained descriptor and current name must still
|
|
283
|
+
have the admitted exact bigint identity and exactly one link before rename and
|
|
284
|
+
after publication. A verification failure after rename does not roll back or
|
|
285
|
+
delete the final name.
|
|
286
|
+
|
|
287
|
+
`syncTempFile` and `syncParentDir` retain their historical `false` defaults.
|
|
288
|
+
Explicit `syncTempFile: true` synchronizes the descriptor before rename;
|
|
289
|
+
file-sync errors propagate except for the existing `EPERM` compatibility case.
|
|
290
|
+
Explicit `syncParentDir: true` requests best-effort parent sync after rename.
|
|
291
|
+
Omitting either option or passing `false` skips that sync, never the identity
|
|
292
|
+
checks. Parent synchronization can be unsupported or fail without rejecting
|
|
293
|
+
the write, so success is not a strict crash-durability receipt.
|
|
294
|
+
|
|
295
|
+
Cleanup only unlinks an admitted file while the parent, pathname identity, and
|
|
296
|
+
single-link regular-file checks still agree. Observed substitutes are preserved,
|
|
297
|
+
including during process-exit cleanup. Operational cleanup failures retain an
|
|
298
|
+
identity-bound exit retry. If the callback throws or admission fails, no file
|
|
299
|
+
has been adopted: even a regular partial file is left for caller-directed
|
|
300
|
+
recovery. The helper never recursively removes a sibling temp.
|
|
301
|
+
|
|
302
|
+
On POSIX, admission uses no-follow and nonblocking open flags, so a FIFO swap
|
|
303
|
+
does not block the helper. Windows retains Node's guarded pathname-open behavior
|
|
304
|
+
because Node has no portable no-follow flag there; metadata is checked before
|
|
305
|
+
and after opening, and unknown Windows identities fail closed after one bounded
|
|
306
|
+
re-inspection without reopening. These helpers remain available with native
|
|
307
|
+
mode `off`; they do not acquire the native-required retained-directory contract
|
|
308
|
+
of [`stageFileInDirectory`](staged-file.md).
|
|
309
|
+
|
|
310
|
+
Identity checks and pathname rename/unlink are separate syscalls, not atomic
|
|
311
|
+
conditional mutations. A hostile process can still replace a leaf or parent in
|
|
312
|
+
the final syscall gap or mutate an open file's contents. Use an approved writable
|
|
313
|
+
directory and cooperative locking or OS isolation; a moved parent can leave an
|
|
314
|
+
unpublished original temp behind. Observed replacements are preserved, but
|
|
315
|
+
arbitrary concurrent namespace changes cannot be prevented by these helpers.
|
|
316
|
+
|
|
317
|
+
By default the helper attempts to set `dir` to `dirMode` (default `0o700`)
|
|
318
|
+
through the shared verified POSIX directory-descriptor helper. Only the actual
|
|
319
|
+
descriptor chmod error is tolerated, preserving the historical best-effort
|
|
320
|
+
directory-mode behavior. Directory lstat, open, type, identity, and close errors
|
|
321
|
+
still propagate; there is no pathname chmod fallback. Windows only passes the
|
|
322
|
+
directory mode to `mkdir`. Pass
|
|
323
|
+
`chmodDir: false` when an existing staging/output directory mode must be preserved.
|
|
213
324
|
|
|
214
325
|
### `writeViaSiblingTempPath`
|
|
215
326
|
|
|
@@ -232,6 +343,9 @@ await writeViaSiblingTempPath({
|
|
|
232
343
|
If `replaceFileAtomic` does what you need, prefer that. Use
|
|
233
344
|
`writeViaSiblingTempPath` when the producer needs a concrete temp pathname but
|
|
234
345
|
the final destination still needs root-boundary checks.
|
|
346
|
+
Its private workspace uses the same identity-aware directory cleanup as
|
|
347
|
+
`tempFile()`: moving and replacing the workspace preserves the replacement.
|
|
348
|
+
This workspace owns its contents, unlike the unadmitted sibling pathname above.
|
|
235
349
|
|
|
236
350
|
## Secure temp root
|
|
237
351
|
|
package/docs/writing.md
CHANGED
|
@@ -27,6 +27,9 @@ await fs.mkdir("snapshots/2026/05");
|
|
|
27
27
|
5. Atomically rename the temp file over the destination.
|
|
28
28
|
6. Stat the resulting fd and verify identity.
|
|
29
29
|
|
|
30
|
+
Private sibling temporary names are independent of the destination basename,
|
|
31
|
+
so staging does not add a suffix to an otherwise valid long filename.
|
|
32
|
+
|
|
30
33
|
A failure before the final rename leaves the destination at its previous
|
|
31
34
|
contents. A successful rename publishes the complete replacement. This
|
|
32
35
|
old-or-new guarantee does not apply to `append()` or `openWritable()`, which
|
|
@@ -86,6 +89,9 @@ alone is never proof that the name still refers to the expected file.
|
|
|
86
89
|
### `fs.create(rel, data, options?)`
|
|
87
90
|
|
|
88
91
|
Don't-clobber variant of `write()`. Throws `already-exists` if the target is there.
|
|
92
|
+
Create-only preflight preserves boundary, alias, hardlink, and type checks without
|
|
93
|
+
opening an existing target to inherit its mode; a fresh file uses the requested
|
|
94
|
+
mode or the normal new-file default.
|
|
89
95
|
|
|
90
96
|
```ts
|
|
91
97
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/fs-safe",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"filesystem",
|
|
@@ -128,7 +128,7 @@
|
|
|
128
128
|
"prepack": "node scripts/prepack-build.mjs",
|
|
129
129
|
"test": "vitest run",
|
|
130
130
|
"test:coverage": "vitest run --coverage",
|
|
131
|
-
"test:coverage:collect": "vitest run --coverage --coverage.reporter=json --coverage.thresholds.lines=0 --coverage.thresholds.functions=0 --coverage.thresholds.statements=0 --coverage.thresholds.branches=0",
|
|
131
|
+
"test:coverage:collect": "pnpm build && vitest run --coverage --coverage.reporter=json --coverage.thresholds.lines=0 --coverage.thresholds.functions=0 --coverage.thresholds.statements=0 --coverage.thresholds.branches=0",
|
|
132
132
|
"test:coverage:merge": "node scripts/merge-coverage.mjs",
|
|
133
133
|
"test:security": "vitest run test/fs-safe.test.ts test/read-boundary-bypass.test.ts test/write-boundary-bypass.test.ts test/additional-boundary-bypass.test.ts test/adversarial-boundary-payloads.test.ts",
|
|
134
134
|
"check": "pnpm lint:file-size && pnpm lint:fs-boundary && pnpm build && pnpm docs:check && pnpm test && node scripts/check-pack.mjs",
|
|
@@ -149,20 +149,20 @@
|
|
|
149
149
|
"crabbox:warmup": "crabbox warmup"
|
|
150
150
|
},
|
|
151
151
|
"optionalDependencies": {
|
|
152
|
-
"@openclaw/fs-safe-darwin-arm64": "0.
|
|
153
|
-
"@openclaw/fs-safe-darwin-x64": "0.
|
|
154
|
-
"@openclaw/fs-safe-linux-arm64-gnu": "0.
|
|
155
|
-
"@openclaw/fs-safe-linux-arm64-musl": "0.
|
|
156
|
-
"@openclaw/fs-safe-linux-x64-gnu": "0.
|
|
157
|
-
"@openclaw/fs-safe-linux-x64-musl": "0.
|
|
158
|
-
"@openclaw/fs-safe-win32-x64-msvc": "0.
|
|
152
|
+
"@openclaw/fs-safe-darwin-arm64": "0.7.1",
|
|
153
|
+
"@openclaw/fs-safe-darwin-x64": "0.7.1",
|
|
154
|
+
"@openclaw/fs-safe-linux-arm64-gnu": "0.7.1",
|
|
155
|
+
"@openclaw/fs-safe-linux-arm64-musl": "0.7.1",
|
|
156
|
+
"@openclaw/fs-safe-linux-x64-gnu": "0.7.1",
|
|
157
|
+
"@openclaw/fs-safe-linux-x64-musl": "0.7.1",
|
|
158
|
+
"@openclaw/fs-safe-win32-x64-msvc": "0.7.1",
|
|
159
159
|
"jszip": "^3.10.1",
|
|
160
160
|
"tar": "7.5.22"
|
|
161
161
|
},
|
|
162
162
|
"devDependencies": {
|
|
163
163
|
"@emnapi/runtime": "2.0.0-alpha.4",
|
|
164
164
|
"@napi-rs/cli": "3.8.6",
|
|
165
|
-
"@types/node": "^26.
|
|
165
|
+
"@types/node": "^26.4.0",
|
|
166
166
|
"@vitest/coverage-v8": "4.1.11",
|
|
167
167
|
"fast-check": "^4.9.0",
|
|
168
168
|
"istanbul-lib-coverage": "3.2.2",
|