@openclaw/fs-safe 0.4.6 → 0.5.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 +69 -0
- package/README.md +110 -41
- package/dist/archive-entry.d.ts.map +1 -1
- package/dist/archive-entry.js +5 -4
- package/dist/archive-errors.d.ts +11 -0
- package/dist/archive-errors.d.ts.map +1 -0
- package/dist/archive-errors.js +16 -0
- package/dist/archive-input.d.ts +12 -0
- package/dist/archive-input.d.ts.map +1 -0
- package/dist/archive-input.js +72 -0
- package/dist/archive-kind.d.ts +1 -1
- package/dist/archive-kind.d.ts.map +1 -1
- package/dist/archive-kind.js +18 -0
- package/dist/archive-limits.d.ts +10 -0
- package/dist/archive-limits.d.ts.map +1 -1
- package/dist/archive-limits.js +18 -0
- package/dist/archive-native.d.ts +18 -0
- package/dist/archive-native.d.ts.map +1 -0
- package/dist/archive-native.js +115 -0
- package/dist/archive-options.d.ts +21 -0
- package/dist/archive-options.d.ts.map +1 -0
- package/dist/archive-options.js +1 -0
- package/dist/archive-policy.d.ts +20 -0
- package/dist/archive-policy.d.ts.map +1 -0
- package/dist/archive-policy.js +29 -0
- package/dist/archive-read.d.ts +6 -0
- package/dist/archive-read.d.ts.map +1 -0
- package/dist/archive-read.js +200 -0
- package/dist/archive-staging.d.ts +2 -5
- package/dist/archive-staging.d.ts.map +1 -1
- package/dist/archive-staging.js +19 -10
- package/dist/archive-tar-meta.d.ts +6 -0
- package/dist/archive-tar-meta.d.ts.map +1 -0
- package/dist/archive-tar-meta.js +143 -0
- package/dist/archive-tar-runtime.d.ts +43 -0
- package/dist/archive-tar-runtime.d.ts.map +1 -0
- package/dist/archive-tar-runtime.js +8 -0
- package/dist/archive-tar.d.ts +5 -1
- package/dist/archive-tar.d.ts.map +1 -1
- package/dist/archive-tar.js +24 -6
- package/dist/archive-zip-entry.d.ts +15 -0
- package/dist/archive-zip-entry.d.ts.map +1 -0
- package/dist/archive-zip-entry.js +17 -0
- package/dist/archive.d.ts +7 -17
- package/dist/archive.d.ts.map +1 -1
- package/dist/archive.js +111 -108
- package/dist/atomic.d.ts +1 -1
- package/dist/atomic.d.ts.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -1
- package/dist/containment.d.ts +2 -0
- package/dist/containment.d.ts.map +1 -0
- package/dist/containment.js +1 -0
- package/dist/directory-durability.d.ts +44 -0
- package/dist/directory-durability.d.ts.map +1 -0
- package/dist/directory-durability.js +299 -0
- package/dist/durability.d.ts +4 -0
- package/dist/durability.d.ts.map +1 -0
- package/dist/durability.js +3 -0
- package/dist/errors.d.ts +4 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +2 -0
- package/dist/file-hash.d.ts +10 -0
- package/dist/file-hash.d.ts.map +1 -0
- package/dist/file-hash.js +73 -0
- package/dist/file-lock-sync.d.ts +35 -0
- package/dist/file-lock-sync.d.ts.map +1 -0
- package/dist/file-lock-sync.js +233 -0
- package/dist/file-lock.d.ts +2 -0
- package/dist/file-lock.d.ts.map +1 -1
- package/dist/file-lock.js +1 -0
- package/dist/filename.d.ts.map +1 -1
- package/dist/filename.js +4 -1
- package/dist/fsync.d.ts +1 -1
- package/dist/fsync.d.ts.map +1 -1
- package/dist/fsync.js +1 -21
- package/dist/guarded-mkdir.d.ts +7 -1
- package/dist/guarded-mkdir.d.ts.map +1 -1
- package/dist/guarded-mkdir.js +45 -3
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/json-document-store.d.ts.map +1 -1
- package/dist/json-document-store.js +30 -15
- package/dist/move-path.d.ts.map +1 -1
- package/dist/move-path.js +101 -17
- package/dist/native/darwin-arm64/fs-safe-native.node +0 -0
- package/dist/native/darwin-x64/fs-safe-native.node +0 -0
- package/dist/native/linux-arm64-gnu/fs-safe-native.node +0 -0
- package/dist/native/linux-arm64-musl/fs-safe-native.node +0 -0
- package/dist/native/linux-x64-gnu/fs-safe-native.node +0 -0
- package/dist/native/linux-x64-musl/fs-safe-native.node +0 -0
- package/dist/native/win32-x64-msvc/fs-safe-native.node +0 -0
- package/dist/native-binding.d.ts +79 -0
- package/dist/native-binding.d.ts.map +1 -0
- package/dist/native-binding.js +1 -0
- package/dist/native-config.d.ts +17 -0
- package/dist/native-config.d.ts.map +1 -0
- package/dist/native-config.js +71 -0
- package/dist/native-operations.d.ts +21 -0
- package/dist/native-operations.d.ts.map +1 -0
- package/dist/native-operations.js +115 -0
- package/dist/native-pinned-write.d.ts +5 -0
- package/dist/native-pinned-write.d.ts.map +1 -0
- package/dist/native-pinned-write.js +115 -0
- package/dist/native.d.ts +14 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/native.js +204 -0
- package/dist/output-sibling.d.ts +8 -0
- package/dist/output-sibling.d.ts.map +1 -0
- package/dist/output-sibling.js +122 -0
- package/dist/output.d.ts +2 -0
- package/dist/output.d.ts.map +1 -1
- package/dist/output.js +32 -5
- package/dist/owner-dacl.d.ts +31 -0
- package/dist/owner-dacl.d.ts.map +1 -0
- package/dist/owner-dacl.js +27 -0
- package/dist/path-policy.js +1 -1
- package/dist/permissions-public.d.ts +2 -0
- package/dist/permissions-public.d.ts.map +1 -1
- package/dist/permissions-public.js +2 -0
- package/dist/permissions.d.ts.map +1 -1
- package/dist/permissions.js +6 -0
- package/dist/pinned-operation.d.ts +2 -0
- package/dist/pinned-operation.d.ts.map +1 -0
- package/dist/pinned-operation.js +35 -0
- package/dist/pinned-write.d.ts +3 -16
- package/dist/pinned-write.d.ts.map +1 -1
- package/dist/pinned-write.js +20 -85
- package/dist/private-directory.d.ts +5 -0
- package/dist/private-directory.d.ts.map +1 -0
- package/dist/private-directory.js +13 -0
- package/dist/private-temp-workspace.d.ts +6 -2
- package/dist/private-temp-workspace.d.ts.map +1 -1
- package/dist/private-temp-workspace.js +40 -13
- package/dist/publish-file-failure.d.ts +27 -0
- package/dist/publish-file-failure.d.ts.map +1 -0
- package/dist/publish-file-failure.js +21 -0
- package/dist/publish-file.d.ts +21 -0
- package/dist/publish-file.d.ts.map +1 -0
- package/dist/publish-file.js +351 -0
- package/dist/read-opened-file.d.ts +3 -0
- package/dist/read-opened-file.d.ts.map +1 -1
- package/dist/read-opened-file.js +1 -0
- package/dist/replace-file-copy-fallback.d.ts +34 -0
- package/dist/replace-file-copy-fallback.d.ts.map +1 -0
- package/dist/replace-file-copy-fallback.js +339 -0
- package/dist/replace-file.d.ts +6 -2
- package/dist/replace-file.d.ts.map +1 -1
- package/dist/replace-file.js +39 -90
- package/dist/root-context.d.ts +4 -1
- package/dist/root-context.d.ts.map +1 -1
- package/dist/root-context.js +20 -1
- package/dist/root-impl.d.ts +5 -0
- package/dist/root-impl.d.ts.map +1 -1
- package/dist/root-impl.js +128 -225
- package/dist/root-path-existing.d.ts +3 -0
- package/dist/root-path-existing.d.ts.map +1 -0
- package/dist/root-path-existing.js +69 -0
- package/dist/root-path.d.ts +1 -2
- package/dist/root-path.d.ts.map +1 -1
- package/dist/root-path.js +67 -81
- package/dist/root-walk.d.ts +41 -0
- package/dist/root-walk.d.ts.map +1 -0
- package/dist/root-walk.js +114 -0
- package/dist/root.d.ts +2 -0
- package/dist/root.d.ts.map +1 -1
- package/dist/secret-file.d.ts +5 -2
- package/dist/secret-file.d.ts.map +1 -1
- package/dist/secret-file.js +20 -2
- package/dist/secret-read-async.d.ts +4 -0
- package/dist/secret-read-async.d.ts.map +1 -0
- package/dist/secret-read-async.js +95 -0
- package/dist/secret.d.ts +2 -1
- package/dist/secret.d.ts.map +1 -1
- package/dist/secret.js +2 -1
- package/dist/sibling-temp.d.ts.map +1 -1
- package/dist/sibling-temp.js +2 -13
- package/dist/sidecar-lock-handle.d.ts +20 -0
- package/dist/sidecar-lock-handle.d.ts.map +1 -0
- package/dist/sidecar-lock-handle.js +28 -0
- package/dist/sidecar-lock-policy.d.ts +10 -0
- package/dist/sidecar-lock-policy.d.ts.map +1 -0
- package/dist/sidecar-lock-policy.js +29 -0
- package/dist/sidecar-lock-reclaim.d.ts +22 -6
- package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
- package/dist/sidecar-lock-reclaim.js +106 -20
- package/dist/sidecar-lock-types.d.ts +56 -0
- package/dist/sidecar-lock-types.d.ts.map +1 -0
- package/dist/sidecar-lock-types.js +1 -0
- package/dist/sidecar-lock.d.ts +2 -45
- package/dist/sidecar-lock.d.ts.map +1 -1
- package/dist/sidecar-lock.js +94 -55
- package/dist/temp-cleanup.d.ts +7 -1
- package/dist/temp-cleanup.d.ts.map +1 -1
- package/dist/temp-cleanup.js +35 -5
- package/dist/temp.d.ts +2 -1
- package/dist/temp.d.ts.map +1 -1
- package/dist/test-hooks.d.ts +3 -0
- package/dist/test-hooks.d.ts.map +1 -1
- package/dist/windows-permissions-native.d.ts +9 -0
- package/dist/windows-permissions-native.d.ts.map +1 -0
- package/dist/windows-permissions-native.js +31 -0
- package/docs/advanced.md +1 -1
- package/docs/archive.md +131 -13
- package/docs/atomic.md +30 -1
- package/docs/config.md +51 -21
- package/docs/durability.md +299 -0
- package/docs/errors.md +36 -6
- package/docs/filename.md +0 -0
- package/docs/index.md +11 -7
- package/docs/install.md +27 -26
- package/docs/json-store.md +18 -3
- package/docs/migrating-to-0.5.md +196 -0
- package/docs/native-helper.md +85 -0
- package/docs/native.md +134 -0
- package/docs/output.md +37 -11
- package/docs/path.md +1 -1
- package/docs/permissions.md +84 -1
- package/docs/quickstart.md +5 -2
- package/docs/reading.md +4 -4
- package/docs/root.md +31 -15
- package/docs/secret-file.md +50 -1
- package/docs/security-model.md +42 -8
- package/docs/sidecar-lock.md +92 -7
- package/docs/temp.md +24 -1
- package/docs/test-hooks.md +22 -2
- package/docs/testing.md +18 -7
- package/docs/types.md +3 -1
- package/docs/walk.md +55 -0
- package/docs/writing.md +3 -3
- package/package.json +12 -2
- package/dist/pinned-helper.d.ts +0 -9
- package/dist/pinned-helper.d.ts.map +0 -1
- package/dist/pinned-helper.js +0 -19
- package/dist/pinned-path.d.ts +0 -7
- package/dist/pinned-path.d.ts.map +0 -1
- package/dist/pinned-path.js +0 -21
- package/dist/pinned-python-config.d.ts +0 -9
- package/dist/pinned-python-config.d.ts.map +0 -1
- package/dist/pinned-python-config.js +0 -38
- package/dist/pinned-python.d.ts +0 -12
- package/dist/pinned-python.d.ts.map +0 -1
- package/dist/pinned-python.js +0 -688
- package/docs/python-helper.md +0 -103
package/docs/root.md
CHANGED
|
@@ -44,15 +44,30 @@ Every method on the returned handle accepts paths relative to the root and rejec
|
|
|
44
44
|
### Reads
|
|
45
45
|
|
|
46
46
|
```ts
|
|
47
|
-
fs.read(rel, options?) // { buffer, realPath, stat }
|
|
47
|
+
fs.read(rel, options?) // { buffer, containment, realPath, stat }
|
|
48
48
|
fs.readBytes(rel, options?) // Buffer
|
|
49
49
|
fs.readText(rel, options?) // string
|
|
50
50
|
fs.readJson<T>(rel, options?) // parsed T
|
|
51
|
-
fs.open(rel, options?) // { handle, realPath, stat, [Symbol.asyncDispose] }
|
|
51
|
+
fs.open(rel, options?) // { handle, containment, realPath, stat, [Symbol.asyncDispose] }
|
|
52
52
|
fs.readAbsolute(absPath, options?) // ReadResult; absPath must already be inside the root
|
|
53
53
|
fs.reader(options?) // (path) => Promise<Buffer>; useful for loader APIs
|
|
54
|
+
fs.walk(rel, options) // root-bounded AsyncIterable<{ relativePath, kind, size }>
|
|
54
55
|
```
|
|
55
56
|
|
|
57
|
+
`walk()` is the incremental, root-bounded recursive scan. It supports entry and
|
|
58
|
+
depth budgets, cancellation, and `symlinkPolicy: "skip" |
|
|
59
|
+
"follow-within-root"`. Budget exhaustion yields a `"truncated"` marker by
|
|
60
|
+
default or throws `FsSafeError("too-large")` with `limitBehavior: "throw"`.
|
|
61
|
+
Use `entryFilter(entry)` to return `"include"`, `"skip"`, or
|
|
62
|
+
`"skip-subtree"`. `"skip"` omits the current entry but still descends into a
|
|
63
|
+
directory; `"skip-subtree"` omits a directory and all of its descendants.
|
|
64
|
+
Directory reads remain fail-fast by default. With
|
|
65
|
+
`onDirectoryError: "skip-and-report"`, the iterator instead yields
|
|
66
|
+
`{ relativePath, kind: "directory-error", size: 0, error }` and continues with
|
|
67
|
+
the remaining tree.
|
|
68
|
+
See [Directory walking](walk.md) for the pure-Node guarantees and the contrast
|
|
69
|
+
with the standalone best-effort walkers.
|
|
70
|
+
|
|
56
71
|
`open()` returns a Node `FileHandle` for streaming. Prefer `await using` for cleanup:
|
|
57
72
|
|
|
58
73
|
```ts
|
|
@@ -64,6 +79,10 @@ await using opened = await fs.open("large.log");
|
|
|
64
79
|
}
|
|
65
80
|
```
|
|
66
81
|
|
|
82
|
+
`open()`, `read()`, and `openWritable()` results include
|
|
83
|
+
`containment: "best-effort"`. The field reports the mechanism used; see the
|
|
84
|
+
[security model](security-model.md#containment-guarantees-by-platform).
|
|
85
|
+
|
|
67
86
|
### Writes
|
|
68
87
|
|
|
69
88
|
```ts
|
|
@@ -100,25 +119,22 @@ fs.resolve(rel) // absolute path inside the root, after canonic
|
|
|
100
119
|
|
|
101
120
|
These do not pin a later operation. They are safe to expose to UIs and decision points; for the actual read or write, use the verb methods so the operation pins identity at the point of use.
|
|
102
121
|
|
|
103
|
-
##
|
|
122
|
+
## Native helper mode
|
|
104
123
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
operations that Node's `fs` API does not expose ergonomically.
|
|
124
|
+
Create-only writes prefer the bundled native helper for fd-relative opens and
|
|
125
|
+
atomic no-replace rename. Operations without native wiring retain their guarded
|
|
126
|
+
JavaScript implementations.
|
|
109
127
|
|
|
110
128
|
```ts
|
|
111
|
-
import {
|
|
129
|
+
import { configureFsSafeNative } from "@openclaw/fs-safe/config";
|
|
112
130
|
|
|
113
|
-
|
|
114
|
-
|
|
131
|
+
configureFsSafeNative({ mode: "off" }); // guarded JavaScript path
|
|
132
|
+
configureFsSafeNative({ mode: "require" }); // fail if the binding is unavailable
|
|
115
133
|
```
|
|
116
134
|
|
|
117
|
-
`auto` is the default. Configure the mode before creating roots.
|
|
118
|
-
helper
|
|
119
|
-
|
|
120
|
-
when that downgrade should be treated as a deployment failure. See
|
|
121
|
-
[Python helper policy](python-helper.md) for deployment guidance.
|
|
135
|
+
`auto` is the default. Configure the mode before creating roots. See the
|
|
136
|
+
[native helper policy](native-helper.md) for supported platforms, the native
|
|
137
|
+
surface, and the precise fallback boundary.
|
|
122
138
|
|
|
123
139
|
### Properties
|
|
124
140
|
|
package/docs/secret-file.md
CHANGED
|
@@ -4,7 +4,10 @@ Helpers for reading and writing credentials. Files are written at mode `0o600`,
|
|
|
4
4
|
|
|
5
5
|
```ts
|
|
6
6
|
import {
|
|
7
|
+
createSecretFileAtomic,
|
|
8
|
+
readSecretFile,
|
|
7
9
|
readSecretFileSync,
|
|
10
|
+
tryReadSecretFile,
|
|
8
11
|
tryReadSecretFileSync,
|
|
9
12
|
writeSecretFileAtomic,
|
|
10
13
|
DEFAULT_SECRET_FILE_MAX_BYTES,
|
|
@@ -54,7 +57,7 @@ if (token) {
|
|
|
54
57
|
Strict reader. Throws `FsSafeError` when the file is missing, too large, empty, unreadable, or rejected by the validation checks. Use when failing loudly is the right call:
|
|
55
58
|
|
|
56
59
|
```ts
|
|
57
|
-
const token = readSecretFileSync("/var/lib/app/auth.token");
|
|
60
|
+
const token = readSecretFileSync("/var/lib/app/auth.token", "auth token");
|
|
58
61
|
```
|
|
59
62
|
|
|
60
63
|
### Read options
|
|
@@ -69,6 +72,24 @@ type SecretFileReadOptions = {
|
|
|
69
72
|
|
|
70
73
|
The reader trims the file content and rejects empty results. `rejectSymlink` blocks a symlink path before the pinned read. Hardlinks are rejected by default so another in-tree name cannot alias the credential; pass `rejectHardlinks: false` only when you explicitly trust that layout.
|
|
71
74
|
|
|
75
|
+
`readSecretFile()` and `tryReadSecretFile()` are asynchronous counterparts with
|
|
76
|
+
the same pinned-handle validation, byte cap, trimming, error codes, and strict
|
|
77
|
+
versus missing-is-undefined naming semantics.
|
|
78
|
+
|
|
79
|
+
Use the async strict reader when a service cannot start safely without the
|
|
80
|
+
credential:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
import { readSecretFile } from "@openclaw/fs-safe/secret";
|
|
84
|
+
|
|
85
|
+
const signingKey = await readSecretFile(
|
|
86
|
+
"/var/lib/app/keys/webhook-signing.key",
|
|
87
|
+
"webhook signing key",
|
|
88
|
+
{ maxBytes: 8 * 1024, rejectSymlink: true },
|
|
89
|
+
);
|
|
90
|
+
startWebhookVerifier(signingKey);
|
|
91
|
+
```
|
|
92
|
+
|
|
72
93
|
## Writing
|
|
73
94
|
|
|
74
95
|
### `writeSecretFileAtomic(params)`
|
|
@@ -99,6 +120,33 @@ type WriteSecretFileParams = {
|
|
|
99
120
|
|
|
100
121
|
The directory mode is asserted on each component along the path: `rootDir`, then any intermediate dirs, then the parent. The helper enforces that every component matches `dirMode` — wider permissions on an existing directory cause the write to fail. Audit and tighten existing secret directories yourself.
|
|
101
122
|
|
|
123
|
+
### `createSecretFileAtomic(params)`
|
|
124
|
+
|
|
125
|
+
This create-only sibling has the same directory, mode, pinned-write, and
|
|
126
|
+
post-write verification policy. Final materialization uses exclusive create;
|
|
127
|
+
if anything already occupies the target path it throws
|
|
128
|
+
`FsSafeError("secret-exists")` without modifying that entry. Use the distinct
|
|
129
|
+
name when first-writer-wins is part of the credential protocol.
|
|
130
|
+
|
|
131
|
+
For example, two onboarding requests may race to install the first refresh
|
|
132
|
+
token. Exactly one should win, and the loser must not overwrite it:
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
import { FsSafeError } from "@openclaw/fs-safe/errors";
|
|
136
|
+
import { createSecretFileAtomic } from "@openclaw/fs-safe/secret";
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
await createSecretFileAtomic({
|
|
140
|
+
rootDir: "/var/lib/app/credentials",
|
|
141
|
+
filePath: "/var/lib/app/credentials/provider.refresh-token",
|
|
142
|
+
content: refreshToken,
|
|
143
|
+
});
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (!(error instanceof FsSafeError) || error.code !== "secret-exists") throw error;
|
|
146
|
+
// Another initializer won. Read and validate the installed credential.
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
102
150
|
For more permissive credentials, override `mode`:
|
|
103
151
|
|
|
104
152
|
```ts
|
|
@@ -153,3 +201,4 @@ await withTimeout(
|
|
|
153
201
|
- [JSON files](json.md) — `writeJson` accepts `mode: 0o600` for non-secret JSON state.
|
|
154
202
|
- [Atomic writes](atomic.md) — the lower-level `replaceFileAtomic` used by these helpers.
|
|
155
203
|
- [Private file-store mode](private-file-store.md) — root-bounded JSON+text stores using secret-file write policy.
|
|
204
|
+
- [Migrating to 0.5](migrating-to-0.5.md) — strict/try reads and create-only adoption checklist.
|
package/docs/security-model.md
CHANGED
|
@@ -4,6 +4,19 @@
|
|
|
4
4
|
|
|
5
5
|
The same shape exists in other languages: Go's [`os.Root` / `OpenInRoot`](https://go.dev/blog/osroot) and Rust's [`cap-std`](https://github.com/bytecodealliance/cap-std) both expose a root handle whose operations refuse to escape it. `fs-safe` is the Node-side equivalent: a single `root()` capability that carries the boundary across every read, write, move, and remove, instead of leaving each call site to redo `path.resolve(...).startsWith(...)` and hope.
|
|
6
6
|
|
|
7
|
+
## Affected versions / exposure
|
|
8
|
+
|
|
9
|
+
In published releases through 0.4.7, the exported `resolveRootPath()` and
|
|
10
|
+
`resolveRootPathSync()` helpers validated a lexically normalized path spelling.
|
|
11
|
+
A caller-supplied path traversing an in-root symlink could therefore pass
|
|
12
|
+
validation while resolving outside the root. Version 0.5 fixes this with
|
|
13
|
+
component-wise alias resolution, resolving each alias before applying later
|
|
14
|
+
path components.
|
|
15
|
+
|
|
16
|
+
`root()` handles were **not** affected: their operations have contained this
|
|
17
|
+
case since `5ddca80`. Exposure is limited to consumers that call
|
|
18
|
+
`resolveRootPath()` or `resolveRootPathSync()` directly.
|
|
19
|
+
|
|
7
20
|
## Threat model
|
|
8
21
|
|
|
9
22
|
You hand a `root()` boundary to a piece of code that takes caller-controlled relative paths. The library defends against a caller that:
|
|
@@ -31,7 +44,7 @@ If you need full sandboxing, run the worker under reduced privileges (uid, conta
|
|
|
31
44
|
|
|
32
45
|
### Path traversal and absolute paths
|
|
33
46
|
|
|
34
|
-
Every relative path is resolved against the canonicalized real path of the root, then checked with `isPathInside`.
|
|
47
|
+
Every relative path is resolved against the canonicalized real path of the root, then checked with `isPathInside`. Alias resolution walks components before applying a later `..`, so a symlink cannot change what that parent segment means after validation. Parent traversal that escapes, leading `/` (without `pathScope` opt-in), or any canonical result outside the root throws `outside-workspace`.
|
|
35
48
|
|
|
36
49
|
### Symlinks (read side)
|
|
37
50
|
|
|
@@ -49,7 +62,7 @@ When `hardlinks: "reject"` is set, reads stat the target and refuse if `nlink >
|
|
|
49
62
|
|
|
50
63
|
### TOCTOU between resolve and use
|
|
51
64
|
|
|
52
|
-
`resolve()`, `exists()`, `stat()`, and `list()` are explicitly
|
|
65
|
+
`resolve()`, `exists()`, `stat()`, and `list()` are explicitly advisory — they answer a question and return. To act on a path with operation-local identity checks, use `read()`, `open()`, `write()`, `create()`, `copyIn()`, `move()`, or `remove()`. The containment table below states which opens are kernel-atomic and which remain best-effort.
|
|
53
66
|
|
|
54
67
|
### Denied mutations
|
|
55
68
|
|
|
@@ -61,6 +74,19 @@ When `hardlinks: "reject"` is set, reads stat the target and refuse if `nlink >
|
|
|
61
74
|
|
|
62
75
|
Within one process, async writes to the same target are queued so their temp-write/rename phases do not overlap. Cross-process writers still need an external protocol such as the sidecar lock helpers.
|
|
63
76
|
|
|
77
|
+
### Directory durability
|
|
78
|
+
|
|
79
|
+
`pinDirectory()` opens a directory without following its final component on
|
|
80
|
+
POSIX, verifies the descriptor against the pathname identity and canonical
|
|
81
|
+
path, and repeats those checks around synchronization. `ensureDurableDirectory()`
|
|
82
|
+
pins the nearest existing ancestor and each newly created segment before
|
|
83
|
+
synchronizing every new directory edge from the leaf upward.
|
|
84
|
+
|
|
85
|
+
Known Windows directory-flush limitations are returned as an explicit
|
|
86
|
+
`unsupported` outcome. POSIX and other I/O failures propagate from the strict
|
|
87
|
+
API. The separately named best-effort helpers intentionally provide no crash
|
|
88
|
+
durability guarantee.
|
|
89
|
+
|
|
64
90
|
### Archive extraction
|
|
65
91
|
|
|
66
92
|
`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.
|
|
@@ -71,12 +97,20 @@ A library cannot revoke its own caller's authority. If your code chooses to bypa
|
|
|
71
97
|
|
|
72
98
|
The library does not modify or constrain the global Node.js `fs` namespace, and it does not patch the runtime. Other code in the same process retains its normal filesystem authority.
|
|
73
99
|
|
|
74
|
-
##
|
|
100
|
+
## Containment guarantees by platform
|
|
101
|
+
|
|
102
|
+
`openBeneath()` and JavaScript open results report one of two factual containment classes:
|
|
103
|
+
|
|
104
|
+
| Mechanism | Reported containment | Boundary |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| Linux native | `kernel-atomic` | `openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS)` resolves and opens under the root in one kernel operation. |
|
|
107
|
+
| macOS native | `best-effort` | macOS 15.4 and newer use `O_RESOLVE_BENEATH` first; older kernels use the guarded `openat(O_NOFOLLOW)` component walk. Both verify the opened descriptor with `F_GETPATH`. |
|
|
108
|
+
| Windows native | `best-effort` | Handle-relative `NtCreateFile` rejects reparse points, but this package does not claim a Linux-style atomic beneath guarantee. |
|
|
109
|
+
| JavaScript fallback | `best-effort` | Canonical checks, no-follow opens where Node exposes them, and post-open identity checks form a check-then-use sequence. |
|
|
75
110
|
|
|
76
|
-
|
|
77
|
-
- **Windows:** Falls back to the safest Node-level behavior available. `O_NOFOLLOW` is not honored. Some fd-relative POSIX hardening is unavailable. The library does the path canonicalization, identity, and atomic-rename checks it can.
|
|
111
|
+
The macOS `F_GETPATH` verification is an escape detector, not a race-atomic guarantee. A hostile same-UID process can rename a directory after `O_RESOLVE_BENEATH` or the manual walk and race the post-open sample or a later descriptor-relative mutation. The native result therefore remains `best-effort` on macOS even when the kernel flag is available. No policy decision is attached to these labels; callers can inspect the fact and decide what their own threat model requires.
|
|
78
112
|
|
|
79
|
-
The
|
|
113
|
+
The public `OpenResult`, `ReadResult`, and `WritableOpenResult` expose `containment`. Those root APIs currently report `best-effort`; direct native `openBeneath()` reports the platform value above. No-replace publication uses `renameat2(RENAME_NOREPLACE)` on Linux, `renameatx_np(RENAME_EXCL)` on macOS, and `FileRenameInfoEx` with replacement disabled on Windows, but those separate mutation semantics do not upgrade an open result's containment label.
|
|
80
114
|
|
|
81
115
|
## Limitations to keep in mind
|
|
82
116
|
|
|
@@ -85,11 +119,11 @@ The library does not advertise different security guarantees per platform — it
|
|
|
85
119
|
| Not ambient authority removal | Code that can import `node:fs` can still bypass the handle. Keep caller-controlled path operations behind `root()` by convention, review, and tests. |
|
|
86
120
|
| Absolute paths are escape hatches | APIs that accept or return absolute paths exist for audit, ingest, and advanced composition. Prefer root-relative names in normal application flow. |
|
|
87
121
|
| Not a mount boundary | `root()` keeps path traversal inside the directory tree and blocks known unsafe read device paths, but it does not make bind mounts or virtual filesystems safe to expose wholesale. |
|
|
88
|
-
| Per-call, not per-session | Another process with the same privileges can still mutate the tree between
|
|
122
|
+
| Per-call, not per-session | Another process with the same privileges can still mutate the tree between calls, and best-effort mechanisms retain documented same-call race windows. Use one verb method to minimize the window and inspect its reported containment class. |
|
|
89
123
|
| Hardlink rejection is best-effort | Link-count checks depend on platform metadata. Treat `hardlinks: "reject"` as a tripwire, not an authorization primitive. |
|
|
90
124
|
| Mode bits are not a full policy engine | `replaceFileAtomic` and secret-file helpers set requested modes, but you should still set umask and inspect modes when policy requires it. |
|
|
91
125
|
| Archive extraction is path safety, not content safety | Unsafe entry paths and links are rejected; malicious payload contents remain your application layer's problem. |
|
|
92
|
-
|
|
|
126
|
+
| Native package unavailable | `helper-unavailable` falls back in `auto` mode and fails closed in `require` mode for native-backed operations. Guarded JavaScript atomicity and identity checks remain. |
|
|
93
127
|
| FUSE mounts with rename-unstable inode numbers | Some FUSE mounts (rclone is a confirmed example) do not preserve source inode identity at the rename destination. The explicit `renameIdentity: "verify-content-with-lock"` compatibility mode verifies content under a cooperative lock for that boundary only; subsequent path identity checks and the default remain strict. See [Writing](writing.md) for the weaker opt-in contract. |
|
|
94
128
|
|
|
95
129
|
## Recommended deployment shape
|
package/docs/sidecar-lock.md
CHANGED
|
@@ -42,6 +42,9 @@ function withFileLock<T, TPayload>(
|
|
|
42
42
|
): Promise<T>;
|
|
43
43
|
|
|
44
44
|
function createFileLockManager(key: string): FileLockManager;
|
|
45
|
+
|
|
46
|
+
function acquireFileLockSync<TPayload>(targetPath: string, options: FileLockSyncAcquireOptions<TPayload>): FileLockSyncHandle;
|
|
47
|
+
function withFileLockSync<T, TPayload>(targetPath: string, options: FileLockSyncAcquireOptions<TPayload>, fn: () => T): T;
|
|
45
48
|
```
|
|
46
49
|
|
|
47
50
|
`managerKey` is an optional identifier used to keep state isolated across multiple lock domains in the same process. Use distinct keys for distinct domains (`"snapshot"`, `"compact"`, `"build"`). If omitted, fs-safe derives one from the target path.
|
|
@@ -56,7 +59,7 @@ type FileLockAcquireOptions<TPayload extends Record<string, unknown>> = {
|
|
|
56
59
|
timeoutMs?: number; // overall acquire deadline; default unbounded
|
|
57
60
|
retry?: FileLockRetryOptions;
|
|
58
61
|
staleRecovery?: "fail-closed" | "remove-if-unchanged"; // default "fail-closed"
|
|
59
|
-
|
|
62
|
+
reentrantOwner?: string; // logical holder identity for owner-scoped nesting
|
|
60
63
|
payload: () => TPayload | Promise<TPayload>;
|
|
61
64
|
shouldReclaim?: (params: {
|
|
62
65
|
lockPath: string;
|
|
@@ -73,11 +76,15 @@ type FileLockAcquireOptions<TPayload extends Record<string, unknown>> = {
|
|
|
73
76
|
payload: Record<string, unknown> | null;
|
|
74
77
|
}) => boolean | Promise<boolean>;
|
|
75
78
|
metadata?: Record<string, unknown>; // attached to heldEntries() output for diagnostics
|
|
79
|
+
parsePayload?: (raw: string) => unknown;
|
|
80
|
+
lockRoot?: Root;
|
|
81
|
+
onCompromised?: (info: { lockPath: string; normalizedTargetPath: string }) => void;
|
|
82
|
+
compromiseCheckIntervalMs?: number;
|
|
76
83
|
};
|
|
77
84
|
|
|
78
85
|
type FileLockRetryOptions = {
|
|
79
86
|
retries?: number; // number of retry attempts after the first failure
|
|
80
|
-
factor?: number; // exponential backoff factor (default
|
|
87
|
+
factor?: number; // exponential backoff factor (default 1: constant delay)
|
|
81
88
|
minTimeout?: number; // initial delay (ms)
|
|
82
89
|
maxTimeout?: number; // delay cap (ms)
|
|
83
90
|
randomize?: boolean; // jitter
|
|
@@ -85,6 +92,59 @@ type FileLockRetryOptions = {
|
|
|
85
92
|
```
|
|
86
93
|
|
|
87
94
|
`payload` is a function so you can re-evaluate it on each retry (e.g. timestamp, PID).
|
|
95
|
+
`parsePayload` replaces JSON parsing for legacy or custom sidecars. Its `unknown`
|
|
96
|
+
result is passed to `shouldReclaim` and `shouldRemoveStaleLock`, allowing PID,
|
|
97
|
+
process-start, argv, or role schemas to remain application-owned.
|
|
98
|
+
|
|
99
|
+
## Owner-scoped reentrancy
|
|
100
|
+
|
|
101
|
+
Version 0.5 removes the unsound process-scoped `allowReentrant` boolean and
|
|
102
|
+
replaces it with `reentrantOwner`. When a manager already holds the canonical
|
|
103
|
+
target path, another acquisition reuses that sidecar only when both acquisitions
|
|
104
|
+
provide the same owner string. Each acquisition gets an idempotent release
|
|
105
|
+
handle; the sidecar remains until the last reference is released. A different or
|
|
106
|
+
missing owner waits under the normal contention, retry, and timeout policy. A
|
|
107
|
+
known live in-process holder is never stale-reclaimed by its own manager.
|
|
108
|
+
|
|
109
|
+
This supports logical session writers that may reach one file through real and
|
|
110
|
+
symlinked parent paths:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
const managerKey = "session-write-locks";
|
|
114
|
+
const reentrantOwner = `session:${sessionId}:operation:${operationId}`;
|
|
115
|
+
|
|
116
|
+
const outer = await acquireFileLock(realSessionPath, {
|
|
117
|
+
managerKey,
|
|
118
|
+
reentrantOwner,
|
|
119
|
+
staleMs: 60_000,
|
|
120
|
+
payload: () => ({ pid: process.pid, operationId }),
|
|
121
|
+
});
|
|
122
|
+
const nested = await acquireFileLock(symlinkedSessionPath, {
|
|
123
|
+
managerKey,
|
|
124
|
+
reentrantOwner,
|
|
125
|
+
staleMs: 60_000,
|
|
126
|
+
payload: () => ({ pid: process.pid, operationId }),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await nested.release(); // sidecar remains for outer
|
|
130
|
+
await outer.release(); // final reference removes it
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The manager domain and canonical target path are part of the identity, so
|
|
134
|
+
aliased paths must use the same `managerKey`. The owner key must identify one
|
|
135
|
+
logical holder or call chain. **Never use a process-wide or other shared constant
|
|
136
|
+
for unrelated tasks**: doing so would admit concurrent work to the same critical
|
|
137
|
+
section and recreate the lost-update bug that removed `allowReentrant`.
|
|
138
|
+
|
|
139
|
+
Omit `reentrantOwner` for ordinary acquisitions. `jsonStore` does so and keeps
|
|
140
|
+
its separate canonical-path mutation queue. The synchronous APIs implement the
|
|
141
|
+
same owner/refcount rules; a mismatched synchronous acquisition blocks the
|
|
142
|
+
calling thread according to its retry and timeout options.
|
|
143
|
+
|
|
144
|
+
Pass `lockRoot` to place sidecar create, read, verification, and removal behind
|
|
145
|
+
an existing `Root` capability. `lockPath` must resolve inside that root.
|
|
146
|
+
Identity-conditioned removal remains the only release and reclaim deletion
|
|
147
|
+
path.
|
|
88
148
|
|
|
89
149
|
## Release handle
|
|
90
150
|
|
|
@@ -92,25 +152,49 @@ type FileLockRetryOptions = {
|
|
|
92
152
|
type FileLockHandle = {
|
|
93
153
|
lockPath: string;
|
|
94
154
|
normalizedTargetPath: string;
|
|
155
|
+
verifyStillHeld: () => Promise<boolean>;
|
|
95
156
|
release: () => Promise<void>;
|
|
96
157
|
[Symbol.asyncDispose](): Promise<void>;
|
|
97
158
|
};
|
|
98
159
|
```
|
|
99
160
|
|
|
161
|
+
`verifyStillHeld()` compares the current sidecar with the ownership snapshot
|
|
162
|
+
captured at acquisition. Set `compromiseCheckIntervalMs` together with
|
|
163
|
+
`onCompromised` for a cheap periodic check; the callback fires once after the
|
|
164
|
+
sidecar no longer matches. This is detection, not revocation of work already in
|
|
165
|
+
progress.
|
|
166
|
+
|
|
167
|
+
## Synchronous locks
|
|
168
|
+
|
|
169
|
+
`acquireFileLockSync()` and `withFileLockSync()` mirror filesystem arbitration,
|
|
170
|
+
retry, payload parsing, stale policy, guarded identity-conditioned reclaim,
|
|
171
|
+
verification, and compromise monitoring. They do not use the async manager
|
|
172
|
+
queue, support async callbacks, or provide same-process reentrancy. Retry waits
|
|
173
|
+
block the calling thread; use the async API in request-serving code.
|
|
174
|
+
|
|
100
175
|
Always release in a `finally`:
|
|
101
176
|
|
|
102
177
|
```ts
|
|
103
|
-
|
|
178
|
+
import { acquireFileLockSync } from "@openclaw/fs-safe/file-lock";
|
|
179
|
+
|
|
180
|
+
const handle = acquireFileLockSync("/var/lib/app/schema.json", {
|
|
104
181
|
staleMs: 60_000,
|
|
105
|
-
|
|
182
|
+
timeoutMs: 5_000,
|
|
183
|
+
retry: { retries: 20, minTimeout: 25, maxTimeout: 250 },
|
|
184
|
+
payload: () => ({ pid: process.pid, operation: "schema-migration" }),
|
|
106
185
|
});
|
|
107
186
|
try {
|
|
108
|
-
|
|
187
|
+
if (!handle.verifyStillHeld()) throw new Error("migration lock was replaced");
|
|
188
|
+
migrateSchemaSynchronously();
|
|
109
189
|
} finally {
|
|
110
|
-
|
|
190
|
+
handle.release();
|
|
111
191
|
}
|
|
112
192
|
```
|
|
113
193
|
|
|
194
|
+
The sync payload, reclaim, and parsing callbacks must also be synchronous. This
|
|
195
|
+
shape is appropriate for a short boot migration; it is a poor fit for a server
|
|
196
|
+
request because retry backoff uses a blocking wait.
|
|
197
|
+
|
|
114
198
|
If your process dies before `release()` runs and skips the exit handler, the sidecar remains. Once `staleMs` elapses (or your `shouldReclaim` returns true), acquisition fails closed by default instead of deleting by path.
|
|
115
199
|
|
|
116
200
|
## `withFileLock` — common shape made one-liner
|
|
@@ -174,7 +258,7 @@ const handle = await acquireFileLock(targetPath, {
|
|
|
174
258
|
});
|
|
175
259
|
```
|
|
176
260
|
|
|
177
|
-
`heldByThisProcess` is true when this manager already holds the lock
|
|
261
|
+
`heldByThisProcess` is true when this manager already holds the lock. A `true` result marks the observed sidecar as stale; `staleRecovery` then decides whether acquisition fails closed or attempts caller-approved removal.
|
|
178
262
|
|
|
179
263
|
## Stale recovery: guarded `remove-if-unchanged`
|
|
180
264
|
|
|
@@ -248,3 +332,4 @@ await withFileLock(
|
|
|
248
332
|
|
|
249
333
|
- [Atomic writes](atomic.md) — single-writer atomicity that often replaces the need for a lock entirely.
|
|
250
334
|
- `createAsyncLock` from `@openclaw/fs-safe/advanced` — in-process serialization for a single Node process.
|
|
335
|
+
- [Migrating to 0.5](migrating-to-0.5.md) — choosing sync versus async lock APIs.
|
package/docs/temp.md
CHANGED
|
@@ -23,6 +23,7 @@ The compact factory. Returns:
|
|
|
23
23
|
```ts
|
|
24
24
|
type TempWorkspace = {
|
|
25
25
|
dir: string;
|
|
26
|
+
identity: { dev: number | bigint; ino: number | bigint };
|
|
26
27
|
store: FileStore;
|
|
27
28
|
path(fileName: string): string;
|
|
28
29
|
write(fileName: string, data: string | Uint8Array): Promise<string>;
|
|
@@ -30,7 +31,7 @@ type TempWorkspace = {
|
|
|
30
31
|
writeJson(fileName: string, data: unknown, options?: { trailingNewline?: boolean }): Promise<string>;
|
|
31
32
|
copyIn(fileName: string, sourcePath: string): Promise<string>;
|
|
32
33
|
read(fileName: string): Promise<Buffer>;
|
|
33
|
-
cleanup(): Promise<
|
|
34
|
+
cleanup(): Promise<"removed" | "missing" | "identity-mismatch">;
|
|
34
35
|
[Symbol.asyncDispose](): Promise<void>;
|
|
35
36
|
};
|
|
36
37
|
```
|
|
@@ -58,6 +59,28 @@ await state.write({ ready: true });
|
|
|
58
59
|
The workspace owns cleanup; the store is only a view over the workspace
|
|
59
60
|
directory.
|
|
60
61
|
|
|
62
|
+
The identity receipt is captured when the workspace is created. Manual,
|
|
63
|
+
disposal, and process-exit cleanup remove the path only while `lstat` still
|
|
64
|
+
matches that receipt. If another actor renames the workspace away and places a
|
|
65
|
+
new directory at the old name, cleanup returns `"identity-mismatch"` and leaves
|
|
66
|
+
the replacement untouched. Disposal hooks perform the same check and ignore
|
|
67
|
+
the returned status.
|
|
68
|
+
|
|
69
|
+
When cleanup is part of a retention or audit decision, inspect the receipt
|
|
70
|
+
instead of treating cleanup as fire-and-forget:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
const workspace = await tempWorkspace({ rootDir: "/var/lib/app/tmp", prefix: "restore-" });
|
|
74
|
+
try {
|
|
75
|
+
await restoreInto(workspace.dir);
|
|
76
|
+
} finally {
|
|
77
|
+
const cleanup = await workspace.cleanup();
|
|
78
|
+
if (cleanup === "identity-mismatch") {
|
|
79
|
+
alertOperator("restore workspace path was replaced; replacement preserved");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
61
84
|
The sync variant `tempWorkspaceSync` exposes the same surface with sync return
|
|
62
85
|
types and a `FileStoreSync` at `workspace.store`.
|
|
63
86
|
|
package/docs/test-hooks.md
CHANGED
|
@@ -31,6 +31,15 @@ type FsSafeTestHooks = {
|
|
|
31
31
|
afterPreOpenLstat?: (filePath: string) => Promise<void> | void;
|
|
32
32
|
beforeOpen?: (filePath: string, flags: number) => Promise<void> | void;
|
|
33
33
|
afterOpen?: (filePath: string, handle: FileHandle) => Promise<void> | void;
|
|
34
|
+
beforeArchiveOutputMutation?: (operation: "mkdir" | "chmod", targetPath: string) => Promise<void> | void;
|
|
35
|
+
beforeFileStorePruneDescend?: (dirPath: string) => Promise<void> | void;
|
|
36
|
+
beforeFileStoreSyncPrivateWrite?: (filePath: string) => void;
|
|
37
|
+
beforeRootFallbackMutation?: (operation: "mkdir" | "move" | "remove", targetPath: string) => Promise<void> | void;
|
|
38
|
+
afterPinnedWriteFallbackRename?: (targetPath: string) => Promise<void> | void;
|
|
39
|
+
beforeSiblingTempWrite?: (tempPath: string) => Promise<void> | void;
|
|
40
|
+
beforeTrashMove?: (targetPath: string, destPath: string) => void;
|
|
41
|
+
afterPublishTargetCreated?: (method, targetPath, identity) => Promise<void> | void;
|
|
42
|
+
beforePublishDirectorySync?: (method, targetPath, identity) => Promise<void> | void;
|
|
34
43
|
};
|
|
35
44
|
```
|
|
36
45
|
|
|
@@ -39,8 +48,19 @@ type FsSafeTestHooks = {
|
|
|
39
48
|
| `afterPreOpenLstat` | A pre-open `lstat` has just resolved. Use this to swap a path between validation and open. |
|
|
40
49
|
| `beforeOpen` | The library is about to call `open(path, flags)`. Use this to inject a TOCTOU window. |
|
|
41
50
|
| `afterOpen` | An open just succeeded. Use this to mutate state before the post-open identity check runs. |
|
|
42
|
-
|
|
43
|
-
|
|
51
|
+
| `beforeArchiveOutputMutation` | Archive staging is about to create a directory or apply a mode. |
|
|
52
|
+
| `beforeFileStorePruneDescend` | File-store pruning is about to descend into a directory. |
|
|
53
|
+
| `beforeFileStoreSyncPrivateWrite` | A synchronous private-store write is about to mutate its target. |
|
|
54
|
+
| `beforeRootFallbackMutation` | A guarded JS root fallback is about to mkdir, move, or remove. |
|
|
55
|
+
| `afterPinnedWriteFallbackRename` | A fallback rename committed and post-commit identity checks have not run yet. |
|
|
56
|
+
| `beforeSiblingTempWrite` | A sibling temp file exists and its writer is about to run. |
|
|
57
|
+
| `beforeTrashMove` | Trash handling is about to move the target. |
|
|
58
|
+
| `afterPublishTargetCreated` | Exclusive publication created its target and final fences have not run yet. |
|
|
59
|
+
| `beforePublishDirectorySync` | Publication verified the target and is about to sync its parent directory. |
|
|
60
|
+
|
|
61
|
+
Hooks typed `Promise<void> | void` may be sync or async and are awaited.
|
|
62
|
+
Hooks used by synchronous code paths are typed `void` and must not return a
|
|
63
|
+
promise.
|
|
44
64
|
|
|
45
65
|
## Usage
|
|
46
66
|
|
package/docs/testing.md
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# Testing
|
|
2
2
|
|
|
3
|
-
`@openclaw/fs-safe/test-hooks` exposes
|
|
3
|
+
`@openclaw/fs-safe/test-hooks` exposes test-only injection points. Registration
|
|
4
|
+
is allowed only when `process.env.NODE_ENV === "test"` or
|
|
5
|
+
`process.env.VITEST === "true"`; registering a non-empty hook set elsewhere
|
|
6
|
+
throws. Production code must not import this subpath.
|
|
4
7
|
|
|
5
8
|
```ts
|
|
6
9
|
import {
|
|
@@ -14,7 +17,7 @@ The double-underscore prefix is a deliberate "hands off" signal: production code
|
|
|
14
17
|
## When to reach for hooks
|
|
15
18
|
|
|
16
19
|
- Reproduce a TOCTOU race deterministically: simulate a symlink swap between resolve and open, or between write and rename.
|
|
17
|
-
- Force
|
|
20
|
+
- Force guarded JavaScript behavior without removing bundled binaries from your runners.
|
|
18
21
|
- Inject latency to test cancellation/timeout paths.
|
|
19
22
|
|
|
20
23
|
If you don't need to inject a race, you don't need hooks — most tests should drive the library through normal calls and assert on observable behavior.
|
|
@@ -26,6 +29,10 @@ type FsSafeTestHooks = {
|
|
|
26
29
|
afterPreOpenLstat?: (filePath: string) => Promise<void> | void;
|
|
27
30
|
beforeOpen?: (filePath: string, flags: number) => Promise<void> | void;
|
|
28
31
|
afterOpen?: (filePath: string, handle: import("node:fs/promises").FileHandle) => Promise<void> | void;
|
|
32
|
+
afterPublishTargetCreated?: (method, targetPath, identity) => Promise<void> | void;
|
|
33
|
+
beforePublishDirectorySync?: (method, targetPath, identity) => Promise<void> | void;
|
|
34
|
+
// Additional archive, store, root-fallback, temp, and trash race hooks are
|
|
35
|
+
// documented on the focused Test hooks reference page.
|
|
29
36
|
};
|
|
30
37
|
|
|
31
38
|
function __setFsSafeTestHooksForTest(hooks?: FsSafeTestHooks): void;
|
|
@@ -37,6 +44,8 @@ Hooks are called at well-defined points in the library's hot paths:
|
|
|
37
44
|
- **`afterPreOpenLstat`** — runs after the pre-open `lstat`. A common use is to swap the path's target via `fs.symlink`/`fs.unlink` to drive a TOCTOU race.
|
|
38
45
|
- **`beforeOpen`** — runs before `fs.open` with the exact flags the root read path will use.
|
|
39
46
|
- **`afterOpen`** — runs after the file handle is opened. Useful to wrap handle methods or inject a size race before a stream is consumed.
|
|
47
|
+
- **`afterPublishTargetCreated`** — runs after exclusive publication created a target but before its final fences.
|
|
48
|
+
- **`beforePublishDirectorySync`** — runs after target verification and immediately before strict parent sync; useful for exercising `onSyncFailure`.
|
|
40
49
|
|
|
41
50
|
`__setFsSafeTestHooksForTest(undefined)` clears all hooks. Always clean up between tests.
|
|
42
51
|
|
|
@@ -81,20 +90,20 @@ it("rejects a swap between resolve and open", async () => {
|
|
|
81
90
|
|
|
82
91
|
The `code` may be `symlink` (caught at open by `O_NOFOLLOW`) or `path-mismatch` (caught by the post-open identity check) depending on platform — both are correct refusals.
|
|
83
92
|
|
|
84
|
-
## Example: force
|
|
93
|
+
## Example: force guarded JavaScript fallback behavior
|
|
85
94
|
|
|
86
95
|
```ts
|
|
87
|
-
import {
|
|
96
|
+
import { configureFsSafeNative } from "@openclaw/fs-safe/config";
|
|
88
97
|
|
|
89
98
|
beforeEach(() => {
|
|
90
|
-
|
|
99
|
+
configureFsSafeNative({ mode: "off" });
|
|
91
100
|
});
|
|
92
101
|
|
|
93
102
|
afterEach(() => {
|
|
94
|
-
|
|
103
|
+
configureFsSafeNative({ mode: "auto" });
|
|
95
104
|
});
|
|
96
105
|
|
|
97
|
-
it("runs without the
|
|
106
|
+
it("runs without the native helper", async () => {
|
|
98
107
|
const fs = await root(dir);
|
|
99
108
|
await fs.write("file.txt", "ok");
|
|
100
109
|
await expect(fs.readText("file.txt")).resolves.toBe("ok");
|
|
@@ -116,6 +125,8 @@ afterEach(() => {
|
|
|
116
125
|
|
|
117
126
|
A global hook clear in your test setup file is a good safety net.
|
|
118
127
|
|
|
128
|
+
See the [complete Test hooks reference](test-hooks.md) for every optional hook.
|
|
129
|
+
|
|
119
130
|
## Patterns for testing fs-safe-using code
|
|
120
131
|
|
|
121
132
|
You usually don't need hooks. Most tests follow this shape:
|
package/docs/types.md
CHANGED
|
@@ -65,18 +65,20 @@ Returned by `Root.open()` and `Root.read()`:
|
|
|
65
65
|
```ts
|
|
66
66
|
type OpenResult = {
|
|
67
67
|
handle: import("node:fs/promises").FileHandle;
|
|
68
|
+
containment: "kernel-atomic" | "best-effort";
|
|
68
69
|
realPath: string;
|
|
69
70
|
stat: import("node:fs").Stats;
|
|
70
71
|
};
|
|
71
72
|
|
|
72
73
|
type ReadResult = {
|
|
73
74
|
buffer: Buffer;
|
|
75
|
+
containment: "kernel-atomic" | "best-effort";
|
|
74
76
|
realPath: string;
|
|
75
77
|
stat: import("node:fs").Stats;
|
|
76
78
|
};
|
|
77
79
|
```
|
|
78
80
|
|
|
79
|
-
`realPath` is the canonical real path the read or open landed on, after symlink resolution; `stat` is the verified `fstat` result.
|
|
81
|
+
`realPath` is the canonical real path the read or open landed on, after symlink resolution; `stat` is the verified `fstat` result. Public root results currently report `containment: "best-effort"`; the union also describes direct native `openBeneath()` results, which report `"kernel-atomic"` on Linux. See the [security model](security-model.md#containment-guarantees-by-platform).
|
|
80
82
|
|
|
81
83
|
## `RootDefaults` / `RootOptions`
|
|
82
84
|
|