@openclaw/fs-safe 0.7.1 → 0.8.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 +71 -0
- package/README.md +5 -1
- package/dist/absolute-path.d.ts.map +1 -1
- package/dist/absolute-path.js +9 -10
- package/dist/archive-merge.d.ts +18 -0
- package/dist/archive-merge.d.ts.map +1 -0
- package/dist/archive-merge.js +157 -0
- package/dist/archive-native.d.ts.map +1 -1
- package/dist/archive-native.js +25 -5
- package/dist/archive-policy.d.ts +1 -1
- package/dist/archive-policy.d.ts.map +1 -1
- package/dist/archive-policy.js +1 -1
- package/dist/archive-read.d.ts.map +1 -1
- package/dist/archive-read.js +3 -2
- package/dist/archive-staging.d.ts +13 -8
- package/dist/archive-staging.d.ts.map +1 -1
- package/dist/archive-staging.js +44 -192
- package/dist/archive-tar-meta.d.ts.map +1 -1
- package/dist/archive-tar-meta.js +9 -5
- package/dist/archive-tar-runtime.d.ts +1 -0
- package/dist/archive-tar-runtime.d.ts.map +1 -1
- package/dist/archive-zip-admission.d.ts +2 -1
- package/dist/archive-zip-admission.d.ts.map +1 -1
- package/dist/archive-zip-admission.js +2 -2
- package/dist/archive-zip-directory.d.ts +8 -1
- package/dist/archive-zip-directory.d.ts.map +1 -1
- package/dist/archive-zip-directory.js +6 -2
- package/dist/archive-zip-entry.d.ts +1 -1
- package/dist/archive-zip-entry.d.ts.map +1 -1
- package/dist/archive-zip-names.d.ts +1 -1
- package/dist/archive-zip-names.d.ts.map +1 -1
- package/dist/archive-zip-names.js +1 -0
- package/dist/archive.d.ts.map +1 -1
- package/dist/archive.js +17 -25
- package/dist/directory-guard.d.ts +17 -5
- package/dist/directory-guard.d.ts.map +1 -1
- package/dist/directory-guard.js +8 -6
- package/dist/directory-mode-node.d.ts +8 -0
- package/dist/directory-mode-node.d.ts.map +1 -0
- package/dist/directory-mode-node.js +93 -0
- package/dist/directory-mode-owner.d.ts +21 -0
- package/dist/directory-mode-owner.d.ts.map +1 -0
- package/dist/directory-mode-owner.js +73 -0
- package/dist/file-observation.d.ts +1 -1
- package/dist/file-observation.d.ts.map +1 -1
- package/dist/file-store-boundary.d.ts +2 -0
- package/dist/file-store-boundary.d.ts.map +1 -1
- package/dist/file-store-boundary.js +13 -0
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +8 -11
- package/dist/filename.d.ts +6 -0
- package/dist/filename.d.ts.map +1 -1
- package/dist/filename.js +36 -0
- package/dist/guarded-mkdir.d.ts +2 -0
- package/dist/guarded-mkdir.d.ts.map +1 -1
- package/dist/guarded-mkdir.js +2 -2
- package/dist/guarded-mutation.d.ts +3 -2
- package/dist/guarded-mutation.d.ts.map +1 -1
- package/dist/guarded-mutation.js +2 -0
- package/dist/json-document-store.d.ts +3 -0
- package/dist/json-document-store.d.ts.map +1 -1
- package/dist/json-document-store.js +2 -0
- package/dist/json-durable-queue-ownership.d.ts +3 -1
- package/dist/json-durable-queue-ownership.d.ts.map +1 -1
- package/dist/json-durable-queue-ownership.js +7 -4
- package/dist/json-durable-queue.d.ts.map +1 -1
- package/dist/json-durable-queue.js +25 -28
- package/dist/local-roots.d.ts.map +1 -1
- package/dist/local-roots.js +18 -56
- package/dist/move-path-cleanup.d.ts +43 -0
- package/dist/move-path-cleanup.d.ts.map +1 -0
- package/dist/move-path-cleanup.js +159 -0
- package/dist/move-path.d.ts +2 -0
- package/dist/move-path.d.ts.map +1 -1
- package/dist/move-path.js +47 -83
- package/dist/native-operations.d.ts +2 -4
- package/dist/native-operations.d.ts.map +1 -1
- package/dist/native-operations.js +7 -9
- package/dist/native-pinned-write-windows.d.ts +2 -2
- package/dist/native-pinned-write-windows.d.ts.map +1 -1
- package/dist/native-pinned-write-windows.js +5 -5
- package/dist/native-pinned-write.d.ts.map +1 -1
- package/dist/native-pinned-write.js +14 -4
- package/dist/native-staged-file.d.ts +2 -2
- package/dist/native-staged-file.d.ts.map +1 -1
- package/dist/opened-file-failure.d.ts +1 -0
- package/dist/opened-file-failure.d.ts.map +1 -1
- package/dist/opened-file-failure.js +4 -0
- package/dist/output-sibling.d.ts.map +1 -1
- package/dist/output-sibling.js +9 -3
- package/dist/pinned-write.d.ts +2 -2
- package/dist/pinned-write.d.ts.map +1 -1
- package/dist/pinned-write.js +32 -13
- package/dist/publish-file.d.ts.map +1 -1
- package/dist/publish-file.js +7 -3
- package/dist/regular-file.d.ts +1 -1
- package/dist/regular-file.d.ts.map +1 -1
- package/dist/regular-file.js +35 -6
- package/dist/replace-file-descriptor.d.ts +9 -1
- package/dist/replace-file-descriptor.d.ts.map +1 -1
- package/dist/replace-file-descriptor.js +58 -10
- package/dist/replace-file-temp-owner.d.ts +7 -0
- package/dist/replace-file-temp-owner.d.ts.map +1 -1
- package/dist/replace-file-temp-owner.js +29 -0
- package/dist/replace-file.d.ts +1 -0
- package/dist/replace-file.d.ts.map +1 -1
- package/dist/replace-file.js +12 -38
- package/dist/root-context.d.ts +4 -0
- package/dist/root-context.d.ts.map +1 -1
- package/dist/root-context.js +19 -0
- package/dist/root-impl.d.ts +43 -0
- package/dist/root-impl.d.ts.map +1 -1
- package/dist/root-impl.js +13 -11
- package/dist/root-path-symlink.d.ts +6 -2
- package/dist/root-path-symlink.d.ts.map +1 -1
- package/dist/root-path-symlink.js +7 -6
- package/dist/root-path.d.ts +1 -0
- package/dist/root-path.d.ts.map +1 -1
- package/dist/root-path.js +6 -2
- package/dist/root-walk.d.ts.map +1 -1
- package/dist/root-walk.js +4 -0
- package/dist/root-write-verification.d.ts +3 -2
- package/dist/root-write-verification.d.ts.map +1 -1
- package/dist/root-write-verification.js +6 -0
- package/dist/secret-file.d.ts +9 -0
- package/dist/secret-file.d.ts.map +1 -1
- package/dist/secret-file.js +136 -74
- package/dist/sibling-temp.d.ts.map +1 -1
- package/dist/sibling-temp.js +9 -3
- package/dist/sidecar-lock-acquire.d.ts.map +1 -1
- package/dist/sidecar-lock-acquire.js +18 -5
- package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
- package/dist/sidecar-lock-reclaim.js +6 -4
- package/dist/sidecar-lock.d.ts.map +1 -1
- package/dist/sidecar-lock.js +26 -0
- package/dist/write-open-flags.d.ts +5 -0
- package/dist/write-open-flags.d.ts.map +1 -0
- package/dist/write-open-flags.js +35 -0
- package/docs/advanced.md +1 -1
- package/docs/archive.md +56 -7
- package/docs/atomic.md +42 -2
- package/docs/contributing.md +13 -0
- package/docs/durability.md +3 -2
- package/docs/errors.md +3 -1
- package/docs/file-store.md +38 -8
- package/docs/local-roots.md +4 -3
- package/docs/output.md +4 -1
- package/docs/private-file-store.md +11 -2
- package/docs/reading.md +3 -1
- package/docs/regular-file.md +16 -2
- package/docs/root.md +4 -2
- package/docs/secret-file.md +37 -2
- package/docs/sidecar-lock.md +32 -5
- package/docs/store.md +5 -1
- package/docs/temp.md +19 -1
- package/docs/writing.md +24 -3
- package/package.json +14 -10
package/docs/archive.md
CHANGED
|
@@ -61,9 +61,42 @@ type ExtractArchiveOptions = {
|
|
|
61
61
|
`entryModes` defaults to `"clamp"`: directories become `0o755`; files become
|
|
62
62
|
`0o644`, or `0o755` when the archived owner-execute bit is set. `"preserve"`
|
|
63
63
|
keeps archived read/write/execute bits. Both policies strip setuid, setgid, and
|
|
64
|
-
sticky bits, and neither applies archived ownership.
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
sticky bits, and neither applies archived ownership. An explicit zero mode,
|
|
65
|
+
including a mode containing only stripped special bits, stays zero under
|
|
66
|
+
`"preserve"`. Absent metadata defaults to `0o644` for files and `0o755` for
|
|
67
|
+
directories; ZIP UNIX creator records with zero attributes are explicit zero,
|
|
68
|
+
while non-UNIX ZIP records use the absent-metadata defaults.
|
|
69
|
+
|
|
70
|
+
TAR mode fields containing only NUL/ASCII-space padding use absent defaults.
|
|
71
|
+
Native extraction also recognizes GNU binary modes, including signed values,
|
|
72
|
+
within node-tar's JavaScript safe-integer range before masking permission bits.
|
|
73
|
+
Malformed or unsupported mode representations retain existing decoder behavior:
|
|
74
|
+
native falls back to zero, while JavaScript may default, parse an octal prefix,
|
|
75
|
+
or reject. These representations are not newly admitted or standardized by the
|
|
76
|
+
mode repair; raw framing and other numeric-field checks remain unchanged.
|
|
77
|
+
|
|
78
|
+
Final modes remain separate from private working staging permissions: files
|
|
79
|
+
stay `0o600` and directories `0o700` until publication. Files receive their final
|
|
80
|
+
mode through the guarded copy's owned writer descriptor. Directories are pinned
|
|
81
|
+
before descending and finalized after their children, including empty and
|
|
82
|
+
restrictive directories. Explicit accepted directory modes win regardless of
|
|
83
|
+
archive order; implicit parents receive `0o755`. Existing destination directories
|
|
84
|
+
also receive the requested final mode. They are never temporarily widened to
|
|
85
|
+
allow child writes; insufficient write/search access still rejects.
|
|
86
|
+
|
|
87
|
+
Directory mode changes use a retained no-follow read descriptor when possible.
|
|
88
|
+
On macOS x64/arm64, read-denied directories can use a retained search descriptor.
|
|
89
|
+
On Linux x64/arm64, the Node-only search route retains an `O_PATH` descriptor and
|
|
90
|
+
changes modes through its exact `/proc/self/fd/N` reference after verifying the
|
|
91
|
+
procfs namespace and followed identity. The descriptor stays open through the
|
|
92
|
+
operation and verification; original root, ancestor and named-directory checks
|
|
93
|
+
still apply. This route trusts host mount-namespace integrity and does not claim
|
|
94
|
+
atomic ancestry checks or protection against privileged mount replacement.
|
|
95
|
+
Readable directories do not depend on procfs. A Linux search-only directory
|
|
96
|
+
needing a mode change requires accessible, genuine procfs; unavailable or
|
|
97
|
+
untrusted authority rejects explicitly instead of silently accepting a wrong
|
|
98
|
+
mode. Other unsupported search-only routes also fail closed. Windows retains
|
|
99
|
+
its existing bounded lack of POSIX mode enforcement.
|
|
67
100
|
|
|
68
101
|
Native extraction is deliberately split into two phases. Rust first reports an
|
|
69
102
|
entry manifest without creating paths. TypeScript validates paths, applies
|
|
@@ -157,6 +190,22 @@ record and are then cleared; local PAX on unsupported types and GNU sparse
|
|
|
157
190
|
|
|
158
191
|
If `kind` is omitted, the helper calls `resolveArchiveKind(archivePath)` and throws if the extension is not recognized. Pass `kind` explicitly when the archive name doesn't carry the type (e.g. content-addressed names). Archive inputs must remain regular files from preview through descriptor admission; POSIX opens are no-follow and nonblocking, so a FIFO swap cannot stall before deadline checks resume. A positive finite `timeoutMs` is a wall-clock budget; zero, negative, `NaN`, and infinity disable the deadline. Non-mutating work rejects promptly when the budget expires. If a live destination mutation is already in flight, rejection waits only for that mutation and any rollback to finish; no later destination mutation can begin.
|
|
159
192
|
|
|
193
|
+
The destination merge is nontransactional: each file is published atomically,
|
|
194
|
+
but completed files and directories can remain when a later copy, post-copy
|
|
195
|
+
check, mode application, or deadline fails. This also applies to
|
|
196
|
+
`mergeExtractedTreeIntoDestination()`. Guarded `Root.copyIn()` owns cleanup for
|
|
197
|
+
its operation; the archive merge does not unlink the current destination name
|
|
198
|
+
on error because it has no publication receipt proving ownership. A failure
|
|
199
|
+
before publication preserves a pre-existing file, and rejection does not grant
|
|
200
|
+
authority to delete a substituted file or alias. Failed extraction does not
|
|
201
|
+
restore overwritten contents. Active destination mutations and their guarded
|
|
202
|
+
cleanup still finish before rejection; no later destination mutation begins.
|
|
203
|
+
New directories whose postorder finalization was never reached can retain their
|
|
204
|
+
private working mode after failure. Failure cleanup closes retained descriptors;
|
|
205
|
+
it does not run a final chmod sweep or roll back the archive. The public merge
|
|
206
|
+
helper still derives modes from its external source tree and must be able to
|
|
207
|
+
read that source; it never chmods an unreadable external source to admit it.
|
|
208
|
+
|
|
160
209
|
### Limits
|
|
161
210
|
|
|
162
211
|
```ts
|
|
@@ -453,9 +502,9 @@ The archive subpath also exports the helpers `extractArchive` is built on. Most
|
|
|
453
502
|
| `withStagedArchiveDestination(opts)` | Creates a private staging dir outside the destination, calls your `run(stagingDir)`, then cleans it up. |
|
|
454
503
|
| `mergeExtractedTreeIntoDestination(opts)` | The merge step alone — staged tree → destination through boundary checks. |
|
|
455
504
|
| `prepareArchiveDestinationDir(destDir)` | Canonicalizes and asserts the destination directory. |
|
|
456
|
-
| `prepareArchiveOutputPath(
|
|
457
|
-
| `loadZipArchiveWithPreflight(
|
|
458
|
-
| `readZipCentralDirectoryEntryCount(
|
|
505
|
+
| `prepareArchiveOutputPath({ destinationDir, destinationRealDir, relPath, outPath, originalPath, isDirectory, deadline? })` | Validates and prepares parents for an already-resolved entry output path. |
|
|
506
|
+
| `loadZipArchiveWithPreflight(buffer, limits?)` | Loads a JSZip from a `Buffer` or `Uint8Array` with size/entry-count preflight before unzipping. |
|
|
507
|
+
| `readZipCentralDirectoryEntryCount(buffer)` | Returns the entry count from an already-loaded ZIP `Buffer` or `Uint8Array` without decoding payloads. |
|
|
459
508
|
| `createTarEntryPreflightChecker(opts)` | Returns a per-entry checker for use as a `tar.x` `onReadEntry` hook. |
|
|
460
509
|
|
|
461
510
|
These let you build custom extractors that share the same safety machinery — for example, a streaming uploader that wants to refuse archives with too many entries before reading any payloads.
|
|
@@ -477,7 +526,7 @@ import {
|
|
|
477
526
|
- `validateArchiveEntryPath(raw, opts)` — throws `ArchiveSecurityError` for `..`, absolute, NUL-containing, drive-relative, or otherwise unsafe entry paths, including alternate data stream names on Windows.
|
|
478
527
|
- `normalizeArchiveEntryPath(raw)` — converts backslashes in the entry path to forward slashes.
|
|
479
528
|
- `stripArchivePath(entryPath, n)` — normalize separators, drop empty and `.` components, then strip the leading N components, returning `null` if none remain.
|
|
480
|
-
- `resolveArchiveOutputPath({
|
|
529
|
+
- `resolveArchiveOutputPath({ rootDir, relPath, originalPath, escapeLabel? })` — combines the validated relative path with the root and rejects escapes using the original archive path for diagnostics.
|
|
481
530
|
- `isWindowsDrivePath(value)` — detects drive-relative segments such as `C:secret` or `nested/C:secret` that should be rejected.
|
|
482
531
|
|
|
483
532
|
Validate attacker-controlled paths before calling normalization or stripping
|
package/docs/atomic.md
CHANGED
|
@@ -184,7 +184,9 @@ an identity-bound process-exit cleanup retry.
|
|
|
184
184
|
On POSIX, staged directory modes are applied through no-follow directory
|
|
185
185
|
descriptors; on Windows, Node cannot portably open those descriptors and no
|
|
186
186
|
pathname `chmod` fallback is attempted, so directory modes remain subject to
|
|
187
|
-
Windows' `mkdir(mode)` behavior.
|
|
187
|
+
Windows' `mkdir(mode)` behavior. Symlink sources are copied as links rather than
|
|
188
|
+
followed, including when the referent is absent; only a source proven to be a
|
|
189
|
+
directory is dereferenced for the destination-descendant guard.
|
|
188
190
|
|
|
189
191
|
```ts
|
|
190
192
|
import { movePathWithCopyFallback } from "@openclaw/fs-safe/atomic";
|
|
@@ -207,7 +209,45 @@ the preflight cap fails with `FsSafeError("too-large")`.
|
|
|
207
209
|
If another writer changes source entries during the fallback, the staged copy
|
|
208
210
|
throws `ESTALE` before commit when possible. If the destination has already
|
|
209
211
|
been committed, cleanup still preserves the changed source entries and throws
|
|
210
|
-
`ESTALE`.
|
|
212
|
+
`ESTALE`. When allowed source names are hardlinks to the same inode, each owned
|
|
213
|
+
unlink is verified through a remaining manifested alias and its exact resulting
|
|
214
|
+
identity becomes the next cleanup receipt. This accounts for the operation's
|
|
215
|
+
own link-count and ctime changes without suppressing unexpected external
|
|
216
|
+
mutations.
|
|
217
|
+
|
|
218
|
+
### Final rename authorization
|
|
219
|
+
|
|
220
|
+
Pass `assertBeforeRename` when a move depends on a revocable lease or another
|
|
221
|
+
caller-owned authorization. The helper captures this callback when called and
|
|
222
|
+
runs it synchronously after asynchronous preparation and directory checks,
|
|
223
|
+
immediately before each rename is dispatched:
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
type MovePathWithCopyFallbackOptions = {
|
|
227
|
+
from: string;
|
|
228
|
+
sourceHardlinks?: "allow" | "reject";
|
|
229
|
+
to: string;
|
|
230
|
+
assertBeforeRename?: () => void;
|
|
231
|
+
};
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Throw to refuse publication. The original error is propagated, including errors
|
|
235
|
+
with `EXDEV` or `EPERM` codes; an authorization failure never starts a copy
|
|
236
|
+
fallback. A genuine rename failure may still require a second authorization
|
|
237
|
+
check before publishing the staged copy.
|
|
238
|
+
|
|
239
|
+
The callback must return `undefined` synchronously. Returning a Promise, thenable,
|
|
240
|
+
or any other value refuses the rename with a `TypeError`; rejected asynchronous
|
|
241
|
+
results are consumed without authorizing the operation. Perform asynchronous
|
|
242
|
+
policy checks before calling the helper and use this callback to recheck the
|
|
243
|
+
current owner at the mutation boundary.
|
|
244
|
+
|
|
245
|
+
A refused rename leaves the source and destination unchanged; any private
|
|
246
|
+
staged copy follows the helper's normal cleanup. The check does not cancel an
|
|
247
|
+
already-dispatched rename or make an external lease store atomic with the
|
|
248
|
+
filesystem. Cleanup after a successful move retains the existing source-identity
|
|
249
|
+
checks. Omitting the
|
|
250
|
+
callback preserves the usual move behavior.
|
|
211
251
|
|
|
212
252
|
## Difference from `root()`
|
|
213
253
|
|
package/docs/contributing.md
CHANGED
|
@@ -35,6 +35,19 @@ pnpm test test/archive.test.ts
|
|
|
35
35
|
|
|
36
36
|
Use `vi.mock` sparingly. Most tests should drive real disk operations in a `mkdtemp`-created scratch directory, asserting on observable behavior. The library has [test hooks](testing.md) for the rare cases where you need to inject a TOCTOU race deterministically.
|
|
37
37
|
|
|
38
|
+
Vitest timeouts do not cancel filesystem promises. Shared fixtures with expensive
|
|
39
|
+
setup use `useSuiteFixture` from `test/helpers/suite-fixture.ts`: setup has a separate
|
|
40
|
+
30-second hook budget, and teardown waits for tracked setup and test work before
|
|
41
|
+
removing directories. Run shared-state corpora sequentially with a deadline per
|
|
42
|
+
payload. Keep child-process liveness limits separate from fixture preparation.
|
|
43
|
+
The Windows CI slow-copy proof runs the real package-copy process-exit test with a
|
|
44
|
+
six-second copy delay, retaining its four-second child deadline:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pnpm build
|
|
48
|
+
pnpm test --config scripts/slow-package-copy.config.ts
|
|
49
|
+
```
|
|
50
|
+
|
|
38
51
|
## Checks
|
|
39
52
|
|
|
40
53
|
Run the complete repository gate before handoff:
|
package/docs/durability.md
CHANGED
|
@@ -142,8 +142,9 @@ success. `parentReceipt`, when supplied, must name the target's direct parent.
|
|
|
142
142
|
With a native binding, the copy fallback first attempts a copy-on-write clone
|
|
143
143
|
(`fclonefileat` on macOS, `FICLONE` on Linux), then Linux
|
|
144
144
|
`copy_file_range`, and finally the existing JavaScript byte loop. Every route
|
|
145
|
-
creates the target exclusively,
|
|
146
|
-
|
|
145
|
+
creates the target exclusively, applies mode `0o600` through its owned
|
|
146
|
+
descriptor independently of the process umask, and goes through the same
|
|
147
|
+
post-copy identity and SHA-256 fencing. Hashing uses an async
|
|
147
148
|
native task when available, so large verification reads do not occupy the
|
|
148
149
|
JavaScript event loop.
|
|
149
150
|
|
package/docs/errors.md
CHANGED
|
@@ -116,7 +116,7 @@ type FsSafeErrorCode =
|
|
|
116
116
|
| `helper-failed` | A native mechanism or multi-step operational helper failed. | Inspect `cause` and any operation-specific `details`; retrying may be unsafe if the operation partially completed. |
|
|
117
117
|
| `helper-unavailable` | Required native binding could not be loaded. | Unsupported platform, omitted/missing/incompatible platform package, or `FS_SAFE_NATIVE_MODE=require`. `auto` falls back where possible; `require` fails closed. |
|
|
118
118
|
| `insecure-permissions` | A secure file or path permission check found a mode/ACL that allows broader access than requested. | File or directory is group/world writable/readable; Windows ACL grants broad read. |
|
|
119
|
-
| `invalid-path` | Input was empty, contained NUL, was an unparseable URL, or otherwise unusable. |
|
|
119
|
+
| `invalid-path` | Input was empty, contained NUL, was an unparseable URL, or otherwise unusable; a FileStore key used a noncanonical spelling. | Noncanonical FileStore aliases, backslashes, or complete parent segments; a network path on Windows; a drive-relative segment in a portable relative path or store key; or a leading drive-relative spelling such as `C:name` used as a Root destination. Existing-object Root lookups retain broader confined path compatibility, including legal POSIX drive-like names. |
|
|
120
120
|
| `not-empty` | `remove()` on a non-empty directory. | Use `replaceDirectoryAtomic` or remove children first. |
|
|
121
121
|
| `not-file` | Read or copy targeted a non-regular file, or a path walk found a non-directory ancestor. | Target was a directory, FIFO, socket, device, or an existing file was followed by another segment. |
|
|
122
122
|
| `not-found` | The target does not exist (or its parent does not, with `mkdir: false`). | Typical missing-file case. |
|
|
@@ -134,6 +134,8 @@ type FsSafeErrorCode =
|
|
|
134
134
|
| `too-large` | A read or bounded walk exceeded its configured budget. | Caller gave a too-permissive file or traversal limit. |
|
|
135
135
|
| `unsupported-platform` | Reserved compatibility code for a platform-specific operation. | No current public helper emits this `FsSafeError` code. Platform-specific APIs currently return a typed unsupported result or use `helper-unavailable`; keep the union member when exhaustively switching across supported package versions. |
|
|
136
136
|
|
|
137
|
+
Secret writes reject invalid `mode` / `dirMode` values with `invalid-path` before directory creation. Existing secret directories with a mode different from the requested `dirMode` report `insecure-permissions` without chmod; a created directory whose descriptor ownership no longer matches its initializing effective user reports `not-owned`.
|
|
138
|
+
|
|
137
139
|
Pathname `sha256File()` also reports `path-mismatch` when pre-open, descriptor,
|
|
138
140
|
or current-path identity remains unknown after one bounded Windows retry, even
|
|
139
141
|
if the file is benign. It never reopens to recover identity. Preview symlinks
|
package/docs/file-store.md
CHANGED
|
@@ -66,14 +66,36 @@ type FileStore = {
|
|
|
66
66
|
|
|
67
67
|
`path()` returns the absolute path the store would use, after asserting it stays inside `rootDir`. Useful for logging or for handing to other libraries.
|
|
68
68
|
|
|
69
|
-
Every `relativePath` is a portable store key
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
Keys
|
|
76
|
-
|
|
69
|
+
Every `relativePath` is a portable store key. The same lexical policy applies
|
|
70
|
+
to every keyed async and sync method, including reads, `exists`, `remove`,
|
|
71
|
+
`path()`, and `json()` construction, with either `private: false` or `true`.
|
|
72
|
+
Keys must use their exact canonical spelling; the store never trims, normalizes,
|
|
73
|
+
or converts one caller-supplied key onto another:
|
|
74
|
+
|
|
75
|
+
- Keys are nonempty NFC Unicode strings without surrounding whitespace or NUL.
|
|
76
|
+
- Segments are separated by a single forward slash. Empty segments, repeated or
|
|
77
|
+
trailing slashes, and complete `.` or `..` segments are rejected, including
|
|
78
|
+
`./b`, `a/./b`, and `a/../b`.
|
|
79
|
+
- Every backslash is rejected on every platform, including a literal POSIX
|
|
80
|
+
`a\b` filename. POSIX and Windows absolute, rooted, UNC, and extended paths
|
|
81
|
+
are rejected.
|
|
82
|
+
- Windows drive-relative segments such as `C:name` or `C:` are rejected
|
|
83
|
+
anywhere in a key, including `a/C:name`.
|
|
84
|
+
- No segment may end in an ASCII dot or space.
|
|
85
|
+
|
|
86
|
+
Violations report `invalid-path` when key validation is reached. Ordinary nested
|
|
87
|
+
keys, NFC Unicode such as `café/日本語.txt`, `.hidden`, `a..b`, and internal spaces
|
|
88
|
+
such as `internal space/a b.txt` are accepted. Colons elsewhere, such as the
|
|
89
|
+
timestamp in `logs/2026-08-02T10:30:00Z.log`, remain lexically valid; filesystem
|
|
90
|
+
success still depends on the platform and the underlying Root policies.
|
|
91
|
+
|
|
92
|
+
Validation retains each method's operation order. Async reads, `exists`, and
|
|
93
|
+
`remove` open the root first: if the root is missing, strict methods report
|
|
94
|
+
`not-found` and `readTextIfExists` / `readJsonIfExists` return `null`, even for an
|
|
95
|
+
invalid key. With an existing root, those same invalid keys report `invalid-path`.
|
|
96
|
+
Private `copyIn` checks and reads its source before validating the destination
|
|
97
|
+
key, and `writeJson` serializes its value before validating the key. Key rejection
|
|
98
|
+
therefore does not imply that no filesystem access or serialization occurred.
|
|
77
99
|
|
|
78
100
|
`root()` returns a [`Root`](root.md) handle for the same directory when you need the full surface (move, list, mkdir). It's a fresh handle per call and is safe to call frequently.
|
|
79
101
|
|
|
@@ -187,6 +209,14 @@ Symlinks are skipped. The walk is best-effort — failures on individual entries
|
|
|
187
209
|
|
|
188
210
|
If you need richer ops (move, list, append, mkdir), call `store.root()` to get a `Root` and use that.
|
|
189
211
|
|
|
212
|
+
`FileStore` owns portable key identity; `Root` owns filesystem confinement.
|
|
213
|
+
Existing-object Root lookups intentionally retain broader confined path
|
|
214
|
+
compatibility, including in-root absolute paths and parent-segment spellings,
|
|
215
|
+
and literal backslashes on POSIX. FileStore rejects those spellings even when
|
|
216
|
+
Root could resolve them safely inside the directory. This key policy does not
|
|
217
|
+
change Root's containment checks or the absolute-path `jsonStore({ filePath })`
|
|
218
|
+
contract.
|
|
219
|
+
|
|
190
220
|
## Common patterns
|
|
191
221
|
|
|
192
222
|
### Cache with TTL prune
|
package/docs/local-roots.md
CHANGED
|
@@ -66,9 +66,10 @@ console.log(r.root); // canonical /srv/uploads
|
|
|
66
66
|
By default the candidate must exist. `allowMissing: true` instead canonicalizes
|
|
67
67
|
the nearest existing ancestor and validates the missing tail, which is useful
|
|
68
68
|
when selecting a future output location. `requireFile: true` rejects existing
|
|
69
|
-
directories and other non-file leaves.
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
directories and other non-file leaves. A missing suffix begins only at a
|
|
70
|
+
component that does not exist: dangling symlinks, descendants of dangling
|
|
71
|
+
symlinks, and candidates whose existing ancestors cannot be canonicalized are
|
|
72
|
+
rejected rather than treated as safe missing paths.
|
|
72
73
|
|
|
73
74
|
## `readLocalFileFromRoots(options)`
|
|
74
75
|
|
package/docs/output.md
CHANGED
|
@@ -60,7 +60,10 @@ hazards but does not trim Windows-normalized trailing dots or spaces; reject or
|
|
|
60
60
|
rewrite those when cross-platform filename uniqueness matters.
|
|
61
61
|
`staging: "workspace"` passes the sanitized basename to the producer.
|
|
62
62
|
`staging: "sibling"` embeds that basename in its randomized temporary name.
|
|
63
|
-
|
|
63
|
+
When the complete temporary component would exceed 255 bytes under NFC or NFD,
|
|
64
|
+
only the embedded tail is shortened, preserving its extension when possible;
|
|
65
|
+
short callback paths remain unchanged. The final target and returned `path` use
|
|
66
|
+
the destination basename, sanitized
|
|
64
67
|
when needed as described above. Guarded temporary files used only inside
|
|
65
68
|
fs-safe have independent names so their length does not grow with the
|
|
66
69
|
destination basename.
|
|
@@ -17,8 +17,17 @@ const loaded = await store.readJsonIfExists<State>("state.json");
|
|
|
17
17
|
|
|
18
18
|
- Writes create parent directories at `0o700` and files at `0o600` unless you
|
|
19
19
|
pass stricter `dirMode` / `mode` options.
|
|
20
|
-
-
|
|
21
|
-
symlink parent components and re-asserts mode after rename.
|
|
20
|
+
- Async private-mode writes route through the secret-file atomic path, which refuses
|
|
21
|
+
symlink parent components and re-asserts mode after rename. Existing directories
|
|
22
|
+
must already have the requested mode; writes do not repair their permissions.
|
|
23
|
+
New-directory initialization requires guarded descriptor authority and may
|
|
24
|
+
fail closed under restrictive platform/umask combinations; see the
|
|
25
|
+
[secret-directory policy](secret-file.md#parameters).
|
|
26
|
+
- Locked JSON mutations prepare private directories before acquiring their
|
|
27
|
+
sidecar and bind the lock to the admitted parent identity. Lock normalization
|
|
28
|
+
is read-only: a deleted or replaced admitted parent is rejected, not recreated.
|
|
29
|
+
The writer still revalidates directory admission afterward; reads do not create
|
|
30
|
+
directories.
|
|
22
31
|
- `readText()` and `readJson()` are strict and throw on missing files.
|
|
23
32
|
- `readTextIfExists()` and `readJsonIfExists()` return `null` on missing files.
|
|
24
33
|
- `write()`, `writeText()`, `writeJson()`, `writeStream()`, and `copyIn()` all
|
package/docs/reading.md
CHANGED
|
@@ -102,7 +102,9 @@ fs.readAbsolute(absPath, options?) // ReadResult, abs path must be inside the
|
|
|
102
102
|
fs.reader(options?) // (path) => Promise<Buffer>
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
-
`readAbsolute` accepts absolute paths
|
|
105
|
+
`readAbsolute` accepts absolute paths using either the configured root spelling
|
|
106
|
+
or its canonical real path. This includes roots configured through a directory
|
|
107
|
+
symlink or Windows junction. Anything outside the root throws
|
|
106
108
|
`outside-workspace`. It also accepts relative paths for compatibility, but use
|
|
107
109
|
`read()`/`readBytes()` when the input contract is explicitly relative.
|
|
108
110
|
|
package/docs/regular-file.md
CHANGED
|
@@ -111,14 +111,28 @@ append boundary; rounded-equal replacements and persistent unknown Windows
|
|
|
111
111
|
identities reject before chmod or writing bytes. With
|
|
112
112
|
`rejectSymlinkParents: true`, it also rejects symlinked ancestor directories.
|
|
113
113
|
|
|
114
|
+
On POSIX, `O_NONBLOCK` prevents a no-reader FIFO substituted before open from
|
|
115
|
+
stalling admission. A confirmed non-regular target is refused before chmod or
|
|
116
|
+
append; other open errors propagate unchanged. This safeguard does not change
|
|
117
|
+
ordinary regular-file append semantics or require read permission.
|
|
118
|
+
|
|
119
|
+
The requested mode is applied through the admitted descriptor **before** any
|
|
120
|
+
content is appended, so an existing file is tightened first. On successful
|
|
121
|
+
completion, explicitly requested POSIX special bits are reapplied after the
|
|
122
|
+
content write, which can otherwise clear set-ID bits. An initial chmod failure
|
|
123
|
+
leaves the content untouched; a write or final chmod failure can leave appended
|
|
124
|
+
content and is not rolled back. Windows does not enforce POSIX mode semantics.
|
|
125
|
+
|
|
114
126
|
### `appendRegularFileSync(options)`
|
|
115
127
|
|
|
116
|
-
Synchronous. Same options.
|
|
128
|
+
Synchronous. Same options and mode ordering. Writes the complete input through
|
|
129
|
+
the already-open descriptor, including when an individual write is short.
|
|
117
130
|
|
|
118
131
|
### `resolveRegularFileAppendFlags()`
|
|
119
132
|
|
|
120
133
|
Helper that returns the append helpers' `O_WRONLY | O_APPEND | O_CREAT` flags,
|
|
121
|
-
plus `O_NOFOLLOW` where the platform provides it
|
|
134
|
+
plus `O_NOFOLLOW` where the platform provides it and `O_NONBLOCK` on POSIX
|
|
135
|
+
where available. Windows flags are unchanged:
|
|
122
136
|
|
|
123
137
|
```ts
|
|
124
138
|
import { resolveRegularFileAppendFlags } from "@openclaw/fs-safe/advanced";
|
package/docs/root.md
CHANGED
|
@@ -87,8 +87,10 @@ await using opened = await fs.open("large.log");
|
|
|
87
87
|
[security model](security-model.md#containment-guarantees-by-platform).
|
|
88
88
|
|
|
89
89
|
The read methods also accept an absolute spelling that already resolves inside
|
|
90
|
-
the root. `readAbsolute()` and `reader()` make that intent explicit
|
|
91
|
-
|
|
90
|
+
the root. `readAbsolute()` and `reader()` make that intent explicit and accept
|
|
91
|
+
both the configured root spelling and its canonical real path when the Root was
|
|
92
|
+
created through a directory symlink or Windows junction. An absolute path
|
|
93
|
+
outside the root is still rejected.
|
|
92
94
|
|
|
93
95
|
### Writes
|
|
94
96
|
|
package/docs/secret-file.md
CHANGED
|
@@ -123,7 +123,30 @@ startWebhookVerifier(signingKey);
|
|
|
123
123
|
|
|
124
124
|
### `writeSecretFileAtomic(params)`
|
|
125
125
|
|
|
126
|
-
Async. Creates the parent directory at `dirMode` (default `0o700`) if missing, writes content to a sibling temp file
|
|
126
|
+
Async. Creates the parent directory at `dirMode` (default `0o700`) if missing, writes content to a sibling temp file, finalizes `mode` (default `0o600`) through an owned descriptor after content writes, and atomically renames over the destination. Publication verification checks the final file identity and mode.
|
|
127
|
+
|
|
128
|
+
Concurrent writes to distinct leaves may share creation of a missing parent.
|
|
129
|
+
After a parent-creation race, the helper re-inspects the entry and requires a
|
|
130
|
+
non-symlink directory, then revalidates root/parent guards, containment, and
|
|
131
|
+
the requested directory mode before writing either leaf.
|
|
132
|
+
|
|
133
|
+
Publication verification borrows the writer's still-open descriptor to check
|
|
134
|
+
the exact file identity, regular-file and link policy, requested POSIX mode,
|
|
135
|
+
and root/parent ancestry before the writer closes it. All `0o7777` mode bits
|
|
136
|
+
must match, including explicitly requested special bits; unexpected special
|
|
137
|
+
bits are rejected. POSIX mode overrides such as `0o000` and `0o200` do not
|
|
138
|
+
require read permission or a readonly reopen, and verification does not widen
|
|
139
|
+
the final mode. Windows retains pathname-identity verification without enforcing
|
|
140
|
+
POSIX mode bits; its native writer checks the reopened descriptor against the
|
|
141
|
+
original lossless file identity before changing the final mode.
|
|
142
|
+
|
|
143
|
+
Failed JavaScript fallback writes attempt cleanup while retaining the original
|
|
144
|
+
descriptor and only after checking parent and file identities. Native cleanup
|
|
145
|
+
also compares lossless parent and file identities. Unverifiable paths are left
|
|
146
|
+
for caller-managed cleanup, and cleanup failures do not replace the original
|
|
147
|
+
write error. These are best-effort identity checks followed by name-based
|
|
148
|
+
removal, not atomic conditional unlink. A publication-verification failure
|
|
149
|
+
after a completed write does not authorize deleting the published file.
|
|
127
150
|
|
|
128
151
|
```ts
|
|
129
152
|
import { writeSecretFileAtomic } from "@openclaw/fs-safe/secret";
|
|
@@ -147,7 +170,15 @@ type WriteSecretFileParams = {
|
|
|
147
170
|
};
|
|
148
171
|
```
|
|
149
172
|
|
|
150
|
-
The directory mode is asserted on each component along the path: `rootDir`, then any intermediate dirs, then the parent.
|
|
173
|
+
The full POSIX directory mode is asserted on each component along the path: `rootDir`, then any intermediate dirs, then the parent. Existing directories, including another creator's `EEXIST` winner, must already match `dirMode` exactly or the write fails with `insecure-permissions`; they are never chmod-repaired. An explicitly requested directory mode such as `0o2750` preserves its setgid bit. Audit and adjust existing secret directories yourself. The admitted directory guards are retained through traversal and the final writer/lock handoff; a fresh pathname lookup cannot silently authorize a replacement. The caller must still trust the selected root and its owners; matching permission bits alone do not establish that trust.
|
|
174
|
+
|
|
175
|
+
Directory admission and its retained guards use lossless bigint identities, including through private locks and native writes. On Windows, an unknown zero device or inode gets one reinspection that retains known components; a definite mismatch or persistent ambiguity fails with `path-mismatch` rather than authorizing a replacement.
|
|
176
|
+
|
|
177
|
+
Both mode options must resolve to integers between `0o0000` and `0o7777`; invalid values fail with `invalid-path` before directory creation or file publication. Windows validates the options but does not enforce POSIX permission bits.
|
|
178
|
+
|
|
179
|
+
After this operation wins directory creation, initialization uses a pinned descriptor bound to the admitted identity and effective user, with ancestor checks before chmod. It does not chmod the caller's pathname. Creation and descriptor admission are separate operations, not an atomic create-and-pin guarantee. A raced directory that has not reached its requested mode yet is rejected rather than repaired; callers may retry after its creator finishes initialization.
|
|
180
|
+
|
|
181
|
+
Initialization fails closed if the platform cannot safely pin a created directory. In particular, a non-root macOS process cannot pin a new `000` directory produced by `umask(0o777)`; the write fails without repairing that directory or writing a secret. Restrictive masks retaining owner search permission remain usable. Linux x64/arm64 can use the guarded `O_PATH`/procfs descriptor route where available. There is no unguarded pathname-chmod fallback, and a failure may leave a created directory for caller-managed cleanup.
|
|
151
182
|
|
|
152
183
|
### `createSecretFileAtomic(params)`
|
|
153
184
|
|
|
@@ -157,6 +188,10 @@ if anything already occupies the target path it throws
|
|
|
157
188
|
`FsSafeError("secret-exists")` without modifying that entry. Use the distinct
|
|
158
189
|
name when first-writer-wins is part of the credential protocol.
|
|
159
190
|
|
|
191
|
+
Distinct leaves can share missing-parent creation without a `secret-exists`
|
|
192
|
+
error. Concurrent creates at the same leaf still have exactly one winner;
|
|
193
|
+
the loser receives `secret-exists` and leaves the winner's bytes intact.
|
|
194
|
+
|
|
160
195
|
For example, two onboarding requests may race to install the first refresh
|
|
161
196
|
token. Exactly one should win, and the loser must not overwrite it:
|
|
162
197
|
|
package/docs/sidecar-lock.md
CHANGED
|
@@ -21,7 +21,9 @@ try {
|
|
|
21
21
|
|
|
22
22
|
The lock file sits next to the protected resource. If a process crashes mid-lock, the next acquirer notices the held entry, inspects its payload (PID, host, acquired-at timestamp), and decides — via `shouldReclaim` (defaulting to "is the lock older than `staleMs`?") — whether it should keep waiting or fail.
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
On natural event-loop shutdown, a globally deduplicated `process.on("beforeExit")` handler attempts asynchronous cleanup of held Root-backed locks through their retained Root capability and ownership receipt. The synchronous `process.on("exit")` handler provides last-chance cleanup for raw locks and reclaim guards. Changed sidecars and failed Root cleanup remain in place; cleanup does not keep retrying during shutdown unless another acquisition re-arms it.
|
|
25
|
+
|
|
26
|
+
Always release locks in a `finally` block. Application-managed graceful shutdown can await `release()` or `manager.drain()` before terminating. Explicit `process.exit()`, uncaught failures, crashes, default signal handling, and fatal termination (including `SIGKILL`) do not reliably run asynchronous Root cleanup and may leave sidecars. Recover only after an application-owned liveness policy proves the holder cannot still be writing.
|
|
25
27
|
|
|
26
28
|
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.
|
|
27
29
|
|
|
@@ -94,10 +96,23 @@ type FileLockRetryOptions = {
|
|
|
94
96
|
```
|
|
95
97
|
|
|
96
98
|
`payload` is a function so you can re-evaluate it on each retry (e.g. timestamp, PID).
|
|
99
|
+
|
|
100
|
+
The complete serialized sidecar must fit within 1 MiB (1,048,576 UTF-8 bytes),
|
|
101
|
+
including pretty-printed JSON, newlines, and the internal ownership token's
|
|
102
|
+
trailing whitespace. The limit counts bytes, not string characters. Oversized
|
|
103
|
+
payloads reject with `FsSafeError` code `too-large` before sidecar creation or
|
|
104
|
+
acquisition, without retrying serialization or reclaiming an existing sidecar.
|
|
105
|
+
This bound applies to raw and Root-backed locks, both async and sync.
|
|
106
|
+
|
|
97
107
|
Errors thrown by `payload`, its JSON serialization (including `toJSON`), or
|
|
98
108
|
`parsePayload` propagate unchanged without retrying the callback. Rethrowing an
|
|
99
109
|
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
|
|
110
|
+
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 no deadline; invalid numeric values reject before filesystem acquisition starts.
|
|
111
|
+
Both async and sync locks enforce retry counts and deadlines independently: an
|
|
112
|
+
explicit `retry.retries` still applies with `timeoutMs: Infinity`, and zero allows
|
|
113
|
+
only the initial attempt. After process defaults are applied, an omitted retry
|
|
114
|
+
count means unlimited retries, and an omitted or infinite timeout means no
|
|
115
|
+
deadline. With neither budget bounded, contention can wait indefinitely.
|
|
101
116
|
`parsePayload` replaces JSON parsing for legacy or custom sidecars. Its `unknown`
|
|
102
117
|
result is passed to `shouldReclaim` and `shouldRemoveStaleLock`, allowing PID,
|
|
103
118
|
process-start, argv, or role schemas to remain application-owned.
|
|
@@ -107,8 +122,11 @@ short teardown race after another holder unlinks it. Both async and sync locks
|
|
|
107
122
|
retry that specific open denial at most eight times per acquisition, within the
|
|
108
123
|
caller's retry/deadline budget. A parent-directory denial, a callback/read/stat
|
|
109
124
|
failure, or exhaustion of either budget surfaces the original error; a denied
|
|
110
|
-
open is not converted to `file_lock_timeout`.
|
|
111
|
-
|
|
125
|
+
open is not converted to `file_lock_timeout`. Root-backed async creation uses
|
|
126
|
+
this same policy for the Windows fallback's exclusive-open denial, captured
|
|
127
|
+
within that individual create call. A generic `Root.create()` error or an error
|
|
128
|
+
replayed from an earlier call is not exclusive-open evidence. Retrying always
|
|
129
|
+
requires fresh exclusive creation and grants no ownership or removal authority.
|
|
112
130
|
|
|
113
131
|
## Owner-scoped reentrancy
|
|
114
132
|
|
|
@@ -160,6 +178,15 @@ an existing `Root` capability. `lockPath` must resolve inside that root.
|
|
|
160
178
|
Identity-conditioned removal remains the only release and reclaim deletion
|
|
161
179
|
path.
|
|
162
180
|
|
|
181
|
+
Async Root-backed acquisition normalizes the target's parent without creating
|
|
182
|
+
it, checking the retained Root before and after normalization. A deleted or
|
|
183
|
+
replaced Root fails before payload execution or held-entry reuse. Missing lock
|
|
184
|
+
subdirectories are still created through `Root.create`, never by target-key
|
|
185
|
+
normalization. The target is an arbitration key and may be outside the Root
|
|
186
|
+
when an explicit in-root `lockPath` is supplied; normalization does not follow a
|
|
187
|
+
target-leaf symlink. Non-Root acquisition retains its existing parent-creation
|
|
188
|
+
behavior.
|
|
189
|
+
|
|
163
190
|
An owner can finish releasing while another async acquirer inspects its record.
|
|
164
191
|
Create-only Root writes do not open an existing record merely to inherit its
|
|
165
192
|
mode. Once a pathname sample and opened descriptor agree, a failed acquisition
|
|
@@ -267,7 +294,7 @@ The sync payload, reclaim, and parsing callbacks must also be synchronous. This
|
|
|
267
294
|
shape is appropriate for a short boot migration; it is a poor fit for a server
|
|
268
295
|
request because retry backoff uses a blocking wait.
|
|
269
296
|
|
|
270
|
-
If
|
|
297
|
+
If termination skips the relevant cleanup handler or cleanup fails, the sidecar remains. In particular, `process.exit()` skips asynchronous Root cleanup; await explicit release or drain during application-managed graceful shutdown. Once `staleMs` elapses (or your `shouldReclaim` returns true), acquisition fails closed by default instead of deleting by path.
|
|
271
298
|
|
|
272
299
|
## `withFileLock` — common shape made one-liner
|
|
273
300
|
|
package/docs/store.md
CHANGED
|
@@ -70,7 +70,11 @@ entry for inspection.
|
|
|
70
70
|
|
|
71
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
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.
|
|
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. Acknowledgement retries resync the queue directory even when both `.processing` and `.delivered` marker names are already absent, before reporting completion or rejecting a newer pending generation; quarantine retries with only failed evidence resync that destination before repairing the vanished queue source.
|
|
74
|
+
|
|
75
|
+
`writeJsonDurableQueueEntry()` and migrations share strict parent synchronization inside the atomic writer's retained descriptor and per-path serialization lifetime, followed by published-file identity verification. If sync fails after publication, the write rejects without rolling back the published JSON; retrying writes the entry again and must complete its own sync. This is not a rollback, deduplication, or exactly-once guarantee. The generic `replaceFileAtomic({ syncParentDir: true })` option remains best-effort.
|
|
76
|
+
|
|
77
|
+
Batch loading skips invalid entry names, malformed, oversized, or unreadable entry content, and caller `read` callback failures. Initially unowned pending entries (hardlinks or unverifiable identities), symlinks, non-files, and absent pending entries are also skipped. Claim, transfer-lock, retirement, and migration write/publication/durability failures reject the batch with the original error, even if earlier entries succeeded. Migration in both loaders strictly syncs the parent directory after successful publication. Visible transitions and earlier processing claims remain for retry; a rejected batch does not acknowledge or roll them back.
|
|
74
78
|
|
|
75
79
|
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
80
|
|
package/docs/temp.md
CHANGED
|
@@ -345,7 +345,11 @@ If `replaceFileAtomic` does what you need, prefer that. Use
|
|
|
345
345
|
the final destination still needs root-boundary checks.
|
|
346
346
|
Its private workspace uses the same identity-aware directory cleanup as
|
|
347
347
|
`tempFile()`: moving and replacing the workspace preserves the replacement.
|
|
348
|
-
|
|
348
|
+
The callback staging component is capped at 255 bytes under NFC and NFD by
|
|
349
|
+
shortening only an overlong embedded destination tail, while preserving an
|
|
350
|
+
extension when possible. Short callback paths and the final target stay
|
|
351
|
+
unchanged. This workspace owns its contents, unlike the unadmitted sibling
|
|
352
|
+
pathname above.
|
|
349
353
|
|
|
350
354
|
## Secure temp root
|
|
351
355
|
|
|
@@ -358,6 +362,20 @@ const tempRoot = resolveSecureTempRoot({ fallbackPrefix: "my-app" });
|
|
|
358
362
|
// e.g. /tmp/my-app-501
|
|
359
363
|
```
|
|
360
364
|
|
|
365
|
+
Consumers that only need this resolver can use the narrow package subpath:
|
|
366
|
+
|
|
367
|
+
```ts
|
|
368
|
+
import {
|
|
369
|
+
resolveSecureTempRoot,
|
|
370
|
+
type ResolveSecureTempRootOptions,
|
|
371
|
+
} from "@openclaw/fs-safe/secure-temp-root";
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
This entry excludes the temp workspace and store implementations from the
|
|
375
|
+
module graph, keeping the import closure small for browser-aware builds that
|
|
376
|
+
shim or exclude Node built-ins. The resolver remains a Node filesystem API; the
|
|
377
|
+
narrow entry does not make it runnable in a browser.
|
|
378
|
+
|
|
361
379
|
### Options
|
|
362
380
|
|
|
363
381
|
```ts
|
package/docs/writing.md
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
# Writing
|
|
2
2
|
|
|
3
3
|
The `Root` handle exposes a tight set of mutation verbs. Replacement writes
|
|
4
|
-
(`write`, `
|
|
5
|
-
|
|
4
|
+
(`write`, `writeJson`, and `copyIn`) publish with a sibling-temp commit so no
|
|
5
|
+
half-written replacement appears at the destination. Create-only writes
|
|
6
|
+
(`create`, `createJson`, and `write` with `overwrite: false`) use sibling-temp
|
|
7
|
+
staging with an atomic no-replace rename only on backends that provide one —
|
|
8
|
+
the native binding, which `require` mode guarantees and `auto` mode uses when
|
|
9
|
+
the binding loads. The pure-JavaScript fallback has no atomic no-clobber
|
|
10
|
+
rename and does not stage: it claims the final name exclusively with `O_EXCL`
|
|
11
|
+
and writes content in place, so a concurrent observer can see the new file
|
|
12
|
+
before its content is complete. Use `require` mode when that visibility window
|
|
13
|
+
matters.
|
|
6
14
|
`append` and `openWritable` intentionally modify an opened file in place;
|
|
7
15
|
`move`, `remove`, and `mkdir` mutate directory entries rather than file bytes.
|
|
8
16
|
Each verb applies the boundary checks appropriate to its operation.
|
|
@@ -91,7 +99,13 @@ alone is never proof that the name still refers to the expected file.
|
|
|
91
99
|
Don't-clobber variant of `write()`. Throws `already-exists` if the target is there.
|
|
92
100
|
Create-only preflight preserves boundary, alias, hardlink, and type checks without
|
|
93
101
|
opening an existing target to inherit its mode; a fresh file uses the requested
|
|
94
|
-
mode or the normal new-file default.
|
|
102
|
+
mode or the normal new-file default. When the native binding is in use
|
|
103
|
+
(`require` mode, or `auto` mode with a successfully loaded binding), content is
|
|
104
|
+
staged privately and published with an atomic no-replace rename, so the name
|
|
105
|
+
never appears before its bytes. In the pure-JavaScript fallback the name is
|
|
106
|
+
claimed exclusively first and content is written afterward, so observers can
|
|
107
|
+
briefly see an empty file; failure cleanup removes a claimed file only when its
|
|
108
|
+
identity is unchanged.
|
|
95
109
|
|
|
96
110
|
```ts
|
|
97
111
|
try {
|
|
@@ -207,6 +221,13 @@ destination — there is no atomic-rename step. If you need both streaming and
|
|
|
207
221
|
atomicity, write to a sibling temp yourself and rename when done; the
|
|
208
222
|
[`atomic`](atomic.md) helpers can do this for you.
|
|
209
223
|
|
|
224
|
+
On POSIX, existing-target opens use `O_NONBLOCK` as an admission safeguard so
|
|
225
|
+
a no-reader FIFO cannot stall regular-file validation. This does not change
|
|
226
|
+
ordinary regular-file write semantics. `replace` and `update` remain write-only
|
|
227
|
+
opens, including for mode `0o200` files; replacement truncation happens only
|
|
228
|
+
after type, identity, and boundary checks pass. Rejected existing paths are
|
|
229
|
+
never cleanup-owned or unlinked.
|
|
230
|
+
|
|
210
231
|
## Write defaults vs per-call options
|
|
211
232
|
|
|
212
233
|
Set `mkdir: true` once on `root()`; pass text encodings per call when needed:
|