@openclaw/fs-safe 0.5.6 → 0.7.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 +89 -0
- package/README.md +23 -10
- package/dist/advanced.d.ts +2 -1
- package/dist/advanced.d.ts.map +1 -1
- package/dist/advanced.js +1 -0
- package/dist/archive-crc32.d.ts +2 -0
- package/dist/archive-crc32.d.ts.map +1 -0
- package/dist/archive-crc32.js +14 -0
- package/dist/archive-deadline.d.ts +3 -0
- package/dist/archive-deadline.d.ts.map +1 -1
- package/dist/archive-deadline.js +44 -8
- package/dist/archive-entry.d.ts.map +1 -1
- package/dist/archive-entry.js +1 -0
- package/dist/archive-errors.d.ts +1 -0
- package/dist/archive-errors.d.ts.map +1 -1
- package/dist/archive-errors.js +3 -0
- package/dist/archive-input.d.ts.map +1 -1
- package/dist/archive-input.js +26 -16
- package/dist/archive-kind.js +2 -2
- package/dist/archive-limits.d.ts +11 -3
- package/dist/archive-limits.d.ts.map +1 -1
- package/dist/archive-limits.js +24 -0
- package/dist/archive-native.d.ts +3 -2
- package/dist/archive-native.d.ts.map +1 -1
- package/dist/archive-native.js +31 -9
- package/dist/archive-policy.d.ts +2 -0
- package/dist/archive-policy.d.ts.map +1 -1
- package/dist/archive-policy.js +9 -1
- package/dist/archive-read.d.ts.map +1 -1
- package/dist/archive-read.js +88 -41
- package/dist/archive-staging.d.ts +3 -0
- package/dist/archive-staging.d.ts.map +1 -1
- package/dist/archive-staging.js +91 -43
- package/dist/archive-tar-admission.d.ts +7 -0
- package/dist/archive-tar-admission.d.ts.map +1 -0
- package/dist/archive-tar-admission.js +43 -0
- package/dist/archive-tar-gnu.d.ts +2 -0
- package/dist/archive-tar-gnu.d.ts.map +1 -0
- package/dist/archive-tar-gnu.js +20 -0
- package/dist/archive-tar-header.d.ts +8 -0
- package/dist/archive-tar-header.d.ts.map +1 -0
- package/dist/archive-tar-header.js +47 -0
- package/dist/archive-tar-meta.d.ts +29 -1
- package/dist/archive-tar-meta.d.ts.map +1 -1
- package/dist/archive-tar-meta.js +135 -19
- package/dist/archive-tar-pax.d.ts +8 -0
- package/dist/archive-tar-pax.d.ts.map +1 -0
- package/dist/archive-tar-pax.js +100 -0
- package/dist/archive-tar-runtime.d.ts +4 -0
- package/dist/archive-tar-runtime.d.ts.map +1 -1
- package/dist/archive-tar-runtime.js +3 -0
- package/dist/archive-tar.d.ts.map +1 -1
- package/dist/archive-tar.js +8 -2
- package/dist/archive-zip-admission.d.ts +7 -0
- package/dist/archive-zip-admission.d.ts.map +1 -0
- package/dist/archive-zip-admission.js +60 -0
- package/dist/archive-zip-count.d.ts +2 -0
- package/dist/archive-zip-count.d.ts.map +1 -0
- package/dist/archive-zip-count.js +140 -0
- package/dist/archive-zip-directory.d.ts +8 -0
- package/dist/archive-zip-directory.d.ts.map +1 -0
- package/dist/archive-zip-directory.js +224 -0
- package/dist/archive-zip-integrity.d.ts.map +1 -1
- package/dist/archive-zip-integrity.js +1 -14
- package/dist/archive-zip-names.d.ts +12 -0
- package/dist/archive-zip-names.d.ts.map +1 -0
- package/dist/archive-zip-names.js +99 -0
- package/dist/archive-zip-preflight.d.ts +1 -1
- package/dist/archive-zip-preflight.d.ts.map +1 -1
- package/dist/archive-zip-preflight.js +8 -145
- package/dist/archive.d.ts.map +1 -1
- package/dist/archive.js +63 -42
- package/dist/atomic.d.ts +1 -1
- package/dist/atomic.d.ts.map +1 -1
- package/dist/bounded-read-stream.d.ts.map +1 -1
- package/dist/bounded-read-stream.js +2 -4
- package/dist/bounded-read.d.ts.map +1 -1
- package/dist/bounded-read.js +3 -10
- package/dist/byte-budget.d.ts +5 -0
- package/dist/byte-budget.d.ts.map +1 -0
- package/dist/byte-budget.js +9 -0
- package/dist/file-hash.d.ts.map +1 -1
- package/dist/file-hash.js +25 -19
- package/dist/file-lock-sync.d.ts.map +1 -1
- package/dist/file-lock-sync.js +26 -9
- package/dist/file-store-boundary.d.ts.map +1 -1
- package/dist/file-store-boundary.js +9 -5
- package/dist/file-store-limit.d.ts +2 -0
- package/dist/file-store-limit.d.ts.map +1 -0
- package/dist/file-store-limit.js +8 -0
- package/dist/file-store-sync-write.d.ts.map +1 -1
- package/dist/file-store-sync-write.js +38 -8
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +26 -26
- package/dist/json-durable-queue-directory.d.ts +2 -0
- package/dist/json-durable-queue-directory.d.ts.map +1 -0
- package/dist/json-durable-queue-directory.js +20 -0
- package/dist/json-durable-queue-ownership.d.ts +14 -0
- package/dist/json-durable-queue-ownership.d.ts.map +1 -0
- package/dist/json-durable-queue-ownership.js +168 -0
- package/dist/json-durable-queue-retirement.d.ts +9 -0
- package/dist/json-durable-queue-retirement.d.ts.map +1 -0
- package/dist/json-durable-queue-retirement.js +126 -0
- package/dist/json-durable-queue-transfer-lock.d.ts +2 -0
- package/dist/json-durable-queue-transfer-lock.d.ts.map +1 -0
- package/dist/json-durable-queue-transfer-lock.js +19 -0
- package/dist/json-durable-queue.d.ts +1 -0
- package/dist/json-durable-queue.d.ts.map +1 -1
- package/dist/json-durable-queue.js +90 -57
- package/dist/json.d.ts.map +1 -1
- package/dist/json.js +27 -8
- package/dist/local-roots.d.ts.map +1 -1
- package/dist/local-roots.js +4 -2
- package/dist/native-binding.d.ts +15 -3
- package/dist/native-binding.d.ts.map +1 -1
- package/dist/native-operations.d.ts +4 -1
- package/dist/native-operations.d.ts.map +1 -1
- package/dist/native-operations.js +22 -6
- package/dist/native-pinned-write-windows.d.ts +8 -0
- package/dist/native-pinned-write-windows.d.ts.map +1 -0
- package/dist/native-pinned-write-windows.js +92 -0
- package/dist/native-pinned-write.d.ts.map +1 -1
- package/dist/native-pinned-write.js +136 -127
- package/dist/native-staged-file.d.ts +24 -0
- package/dist/native-staged-file.d.ts.map +1 -0
- package/dist/native-staged-file.js +337 -0
- package/dist/native.d.ts.map +1 -1
- package/dist/native.js +4 -4
- package/dist/opened-realpath.d.ts +2 -0
- package/dist/opened-realpath.d.ts.map +1 -1
- package/dist/opened-realpath.js +12 -7
- package/dist/output-sibling.d.ts.map +1 -1
- package/dist/output-sibling.js +11 -110
- package/dist/output.d.ts.map +1 -1
- package/dist/output.js +4 -2
- package/dist/owner-dacl.d.ts.map +1 -1
- package/dist/owner-dacl.js +2 -1
- package/dist/permission-exec.d.ts +19 -0
- package/dist/permission-exec.d.ts.map +1 -1
- package/dist/permission-exec.js +57 -11
- package/dist/permissions-public.d.ts +1 -1
- package/dist/permissions-public.d.ts.map +1 -1
- package/dist/permissions-windows.d.ts +3 -0
- package/dist/permissions-windows.d.ts.map +1 -1
- package/dist/permissions-windows.js +16 -5
- package/dist/permissions.d.ts +5 -0
- package/dist/permissions.d.ts.map +1 -1
- package/dist/pinned-open.d.ts.map +1 -1
- package/dist/pinned-open.js +27 -48
- package/dist/pinned-write.d.ts +6 -0
- package/dist/pinned-write.d.ts.map +1 -1
- package/dist/pinned-write.js +26 -37
- package/dist/private-directory.d.ts.map +1 -1
- package/dist/private-directory.js +3 -2
- package/dist/private-temp-workspace.d.ts +3 -1
- package/dist/private-temp-workspace.d.ts.map +1 -1
- package/dist/private-temp-workspace.js +81 -56
- package/dist/publish-file.d.ts.map +1 -1
- package/dist/publish-file.js +2 -4
- package/dist/read-opened-file.d.ts.map +1 -1
- package/dist/read-opened-file.js +6 -4
- package/dist/regular-file.d.ts.map +1 -1
- package/dist/regular-file.js +136 -82
- package/dist/replace-file-copy-fallback.d.ts +3 -1
- package/dist/replace-file-copy-fallback.d.ts.map +1 -1
- package/dist/replace-file-copy-fallback.js +21 -36
- package/dist/replace-file-copy-source.d.ts +21 -0
- package/dist/replace-file-copy-source.d.ts.map +1 -0
- package/dist/replace-file-copy-source.js +112 -0
- package/dist/replace-file-descriptor.d.ts +13 -3
- package/dist/replace-file-descriptor.d.ts.map +1 -1
- package/dist/replace-file-descriptor.js +32 -7
- package/dist/replace-file-rename-policy.d.ts +7 -0
- package/dist/replace-file-rename-policy.d.ts.map +1 -0
- package/dist/replace-file-rename-policy.js +30 -0
- package/dist/replace-file-temp-owner.d.ts +46 -0
- package/dist/replace-file-temp-owner.d.ts.map +1 -0
- package/dist/replace-file-temp-owner.js +346 -0
- package/dist/replace-file.d.ts +6 -1
- package/dist/replace-file.d.ts.map +1 -1
- package/dist/replace-file.js +72 -58
- package/dist/root-impl.d.ts.map +1 -1
- package/dist/root-impl.js +118 -98
- package/dist/root-paths.d.ts +11 -14
- package/dist/root-paths.d.ts.map +1 -1
- package/dist/root-paths.js +36 -27
- package/dist/root-write-verification.d.ts +11 -0
- package/dist/root-write-verification.d.ts.map +1 -0
- package/dist/root-write-verification.js +91 -0
- package/dist/secret-file.d.ts +1 -6
- package/dist/secret-file.d.ts.map +1 -1
- package/dist/secret-file.js +49 -120
- package/dist/secret-read-async.d.ts +1 -1
- package/dist/secret-read-async.d.ts.map +1 -1
- package/dist/secret-read-async.js +51 -72
- package/dist/secret-read-policy.d.ts +13 -0
- package/dist/secret-read-policy.d.ts.map +1 -0
- package/dist/secret-read-policy.js +28 -0
- package/dist/secret.d.ts +2 -1
- package/dist/secret.d.ts.map +1 -1
- package/dist/secret.js +2 -1
- package/dist/secure-file.d.ts.map +1 -1
- package/dist/secure-file.js +42 -29
- package/dist/sibling-staged-file.d.ts +15 -0
- package/dist/sibling-staged-file.d.ts.map +1 -0
- package/dist/sibling-staged-file.js +148 -0
- package/dist/sibling-temp.d.ts +3 -0
- package/dist/sibling-temp.d.ts.map +1 -1
- package/dist/sibling-temp.js +30 -74
- package/dist/sidecar-lock-acquire.d.ts.map +1 -1
- package/dist/sidecar-lock-acquire.js +48 -27
- package/dist/sidecar-lock-handle.d.ts +6 -2
- package/dist/sidecar-lock-handle.d.ts.map +1 -1
- package/dist/sidecar-lock-handle.js +17 -3
- package/dist/sidecar-lock-policy.d.ts +2 -0
- package/dist/sidecar-lock-policy.d.ts.map +1 -1
- package/dist/sidecar-lock-policy.js +29 -0
- package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
- package/dist/sidecar-lock-reclaim.js +29 -7
- package/dist/sidecar-lock.d.ts.map +1 -1
- package/dist/sidecar-lock.js +28 -16
- package/dist/staged-directory.d.ts +16 -0
- package/dist/staged-directory.d.ts.map +1 -0
- package/dist/staged-directory.js +60 -0
- package/dist/staged-file-types.d.ts +56 -0
- package/dist/staged-file-types.d.ts.map +1 -0
- package/dist/staged-file-types.js +1 -0
- package/dist/staged-file.d.ts +10 -0
- package/dist/staged-file.d.ts.map +1 -0
- package/dist/staged-file.js +15 -0
- package/dist/strict-file-identity.d.ts +6 -0
- package/dist/strict-file-identity.d.ts.map +1 -0
- package/dist/strict-file-identity.js +48 -0
- package/dist/suppressed-error.d.ts +6 -0
- package/dist/suppressed-error.d.ts.map +1 -0
- package/dist/suppressed-error.js +15 -0
- package/dist/temp-cleanup.d.ts +2 -0
- package/dist/temp-cleanup.d.ts.map +1 -1
- package/dist/temp-cleanup.js +25 -10
- package/dist/temp-workspace-owner.d.ts +23 -0
- package/dist/temp-workspace-owner.d.ts.map +1 -0
- package/dist/temp-workspace-owner.js +320 -0
- package/dist/temp.d.ts +1 -1
- package/dist/temp.d.ts.map +1 -1
- package/dist/test-hooks.d.ts +5 -0
- package/dist/test-hooks.d.ts.map +1 -1
- package/dist/windows-owner.d.ts +3 -0
- package/dist/windows-owner.d.ts.map +1 -1
- package/dist/windows-owner.js +10 -2
- package/docs/advanced.md +19 -2
- package/docs/archive.md +250 -35
- package/docs/atomic.md +11 -2
- package/docs/config.md +7 -0
- package/docs/contributing.md +45 -7
- package/docs/durability.md +18 -5
- package/docs/errors.md +16 -1
- package/docs/file-store.md +2 -0
- package/docs/index.md +3 -1
- package/docs/install.md +20 -8
- package/docs/json.md +8 -4
- package/docs/migrating-to-0.5.md +7 -7
- package/docs/migrating-to-0.6.md +43 -0
- package/docs/native-helper.md +30 -8
- package/docs/native.md +73 -17
- package/docs/output.md +10 -0
- package/docs/path-scope.md +28 -2
- package/docs/permissions.md +13 -2
- package/docs/public-api.md +6 -3
- package/docs/quickstart.md +1 -1
- package/docs/reading.md +1 -1
- package/docs/regular-file.md +9 -2
- package/docs/root.md +3 -1
- package/docs/secret-file.md +12 -0
- package/docs/secure-file.md +21 -3
- package/docs/security-model.md +33 -1
- package/docs/sidecar-lock.md +15 -1
- package/docs/staged-file.md +178 -0
- package/docs/store.md +13 -0
- package/docs/temp.md +128 -14
- package/docs/testing.md +1 -1
- package/docs/writing.md +19 -0
- package/package.json +16 -9
- 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/docs/secure-file.md
CHANGED
|
@@ -18,8 +18,8 @@ const { buffer, realPath, permissions } = await readSecureFile({
|
|
|
18
18
|
The helper:
|
|
19
19
|
|
|
20
20
|
- requires a local absolute path and rejects UNC/network paths by default
|
|
21
|
-
- rejects
|
|
22
|
-
- opens
|
|
21
|
+
- rejects every non-regular preview and, by default, symlink paths
|
|
22
|
+
- opens POSIX paths no-follow and nonblocking before reading, then verifies the opened fd still matches the path and realpath; a FIFO swap cannot block before `timeoutMs` owns the byte read
|
|
23
23
|
- optionally requires the real path to live under one of `trust.trustedDirs`
|
|
24
24
|
- rejects hard-to-verify or unsafe permissions unless `permissions.allowInsecure` is set
|
|
25
25
|
- rejects files owned by another POSIX uid
|
|
@@ -28,6 +28,8 @@ The helper:
|
|
|
28
28
|
|
|
29
29
|
On POSIX, unsafe permissions mean group/world writable, and group/world readable unless `permissions.allowReadableByOthers` is true. On Windows, the helper uses the ACL inspection helpers from [`permissions`](permissions.md) and refuses the read if ACLs cannot be verified.
|
|
30
30
|
|
|
31
|
+
Descriptor, pathname, and realpath identity checks use lossless bigint stats internally. The returned `stat` remains a normal Node `Stats` object with numeric fields. A zero Windows device or inode is unverified, never a match: the helper re-inspects that identity once using the same descriptor or pathname, then rejects persistent ambiguity with `path-mismatch`. A definite mismatch rejects immediately; retries retain known identity components and still enforce symlink policy.
|
|
32
|
+
|
|
31
33
|
## Options
|
|
32
34
|
|
|
33
35
|
```ts
|
|
@@ -55,8 +57,12 @@ type SecureFileReadOptions = {
|
|
|
55
57
|
};
|
|
56
58
|
```
|
|
57
59
|
|
|
60
|
+
`io.maxBytes` must be a non-negative safe integer or positive `Infinity`; zero is an active cap and `Infinity` disables the cap. Invalid limits reject before filesystem admission.
|
|
61
|
+
|
|
58
62
|
`permissions.allowInsecure` is a migration escape hatch. Prefer fixing permissions and using [`formatPermissionRemediation`](permissions.md) to show the user what to run. `trust.allowNetworkPath` is off by default because UNC paths are remote authority, not local filesystem input. `inject` is for tests and platform adapters; production callers usually leave it unset.
|
|
59
63
|
|
|
64
|
+
`permissions.allowInsecure` bypasses only permission checks. Neither it nor `inject.platform` changes filesystem identity verification, which always uses the actual process platform. `trust.allowSymlink` permits an alias but still requires its target and realpath to match the opened descriptor.
|
|
65
|
+
|
|
60
66
|
## Errors
|
|
61
67
|
|
|
62
68
|
`readSecureFile()` throws `FsSafeError` with codes such as:
|
|
@@ -67,7 +73,7 @@ type SecureFileReadOptions = {
|
|
|
67
73
|
| `not-found` | The path could not be stat'd before open. |
|
|
68
74
|
| `not-file` | The opened target is not a regular file. |
|
|
69
75
|
| `symlink` | The path is a symlink and `trust.allowSymlink` is false. |
|
|
70
|
-
| `path-mismatch` | The path or realpath changed between open and verification. |
|
|
76
|
+
| `path-mismatch` | The path or realpath changed between open and verification, or filesystem identity could not be verified after bounded re-inspection. |
|
|
71
77
|
| `outside-workspace` | `realPath` is outside `trust.trustedDirs`. |
|
|
72
78
|
| `permission-unverified` | Required mode/ACL checks could not be completed. |
|
|
73
79
|
| `insecure-permissions` | Mode bits or ACLs grant broader access than allowed. |
|
|
@@ -75,6 +81,18 @@ type SecureFileReadOptions = {
|
|
|
75
81
|
| `too-large` | File size or bytes read exceeded `maxBytes`. |
|
|
76
82
|
| `timeout` | `timeoutMs` elapsed while reading. |
|
|
77
83
|
|
|
84
|
+
Windows inspection failures remain operational `permission-unverified` errors
|
|
85
|
+
and still refuse the read. Their message includes the underlying reason when
|
|
86
|
+
available. `details` includes `ownerError` for owner-query failures and, when
|
|
87
|
+
command diagnostics are available, `command`, `durationMs`, `timedOut`,
|
|
88
|
+
`exitCode`, `signal`, and `stderr`. Reasons and stderr are control-character
|
|
89
|
+
escaped and limited to 400 characters each (including a truncation marker).
|
|
90
|
+
No stdout or target file contents are copied into these display diagnostics.
|
|
91
|
+
The original inspection exception is retained as `cause`; built-in command
|
|
92
|
+
errors also retain their original execFile exception in the cause chain.
|
|
93
|
+
Treat causes as restricted local diagnostic data. No retries are performed,
|
|
94
|
+
and verification order and rejection conditions are unchanged.
|
|
95
|
+
|
|
78
96
|
## See also
|
|
79
97
|
|
|
80
98
|
- [Permissions](permissions.md) — standalone POSIX mode and Windows ACL checks.
|
package/docs/security-model.md
CHANGED
|
@@ -49,10 +49,19 @@ Every path is resolved against the canonicalized real path of the root, then che
|
|
|
49
49
|
|
|
50
50
|
### Symlinks (read side)
|
|
51
51
|
|
|
52
|
-
`open()` and `read()` use `fs.open` with `O_NOFOLLOW` on POSIX where available. The library then `fstat`s the open fd and `realpath`s the original input, asserting the two refer to the same inode
|
|
52
|
+
`open()` and `read()` use `fs.open` with `O_NOFOLLOW` on POSIX where available. The library then `fstat`s the open fd and `realpath`s the original input, asserting the two refer to the same inode. A symlink swap that happens between resolve and open will fail at the identity check rather than silently following the new target.
|
|
53
53
|
|
|
54
54
|
Per-call `symlinks: "follow-within-root"` allows symlinks whose final target is still inside the root. The default is `"reject"`.
|
|
55
55
|
|
|
56
|
+
Guarded root reads compare lossless bigint identities from before open, the opened
|
|
57
|
+
descriptor, the input path, and the canonical target; numeric public `Stats`
|
|
58
|
+
receipts are not used as identity evidence. Unknown Windows device/inode values
|
|
59
|
+
receive one re-inspection without reopening the file. A definite mismatch or
|
|
60
|
+
persistent unknown identity rejects with `path-mismatch` before reading bytes.
|
|
61
|
+
Regular-file readers, root-file adapters, and archive input staging use the same
|
|
62
|
+
exact admission policy. `copyIn()` retains the admitted source identity for its
|
|
63
|
+
checks before and after copying, independently of its numeric metadata receipt.
|
|
64
|
+
|
|
56
65
|
### Symlinks (write side)
|
|
57
66
|
|
|
58
67
|
With the native binding loaded, `write()`, `create()`, and `copyIn()` use a
|
|
@@ -70,6 +79,14 @@ parent symlink. A same-privilege peer with write access to the parent can
|
|
|
70
79
|
therefore cause an out-of-root side effect before the operation throws. Use
|
|
71
80
|
native `require` mode when concurrent hostile mutation is in scope.
|
|
72
81
|
|
|
82
|
+
The separate [retained-directory staging lifecycle](staged-file.md) keeps abort
|
|
83
|
+
cleanup anchored to the original directory after a parent or ancestor move.
|
|
84
|
+
It preserves observed substituted temporary entries and never cleans a recorded
|
|
85
|
+
publication. Directory anchoring is not expected-inode/CAS replacement, and
|
|
86
|
+
identity-check-then-unlink is not atomic conditional unlink. The guarantee
|
|
87
|
+
requires the temporary name to remain owned and removal to remain permitted;
|
|
88
|
+
application authorization and cooperative coordination remain necessary.
|
|
89
|
+
|
|
73
90
|
### Hardlink aliasing
|
|
74
91
|
|
|
75
92
|
When `hardlinks: "reject"` is set, reads stat the target and refuse if `nlink > 1`, on the conservative assumption that a hardlinked file might alias an out-of-tree inode. This is defense-in-depth: the link count check is best-effort and platform-dependent. Treat it as a tripwire, not authorization.
|
|
@@ -107,6 +124,21 @@ durability guarantee.
|
|
|
107
124
|
|
|
108
125
|
`extractArchive` first stages into a private temp directory (mode 0700) outside the destination, validates each entry path against `..` and absolute prefixes, refuses link-type entries by default, enforces entry count and byte budgets, and only then merges the staged tree into the destination through the same boundary checks used by direct writes.
|
|
109
126
|
|
|
127
|
+
TAR extraction and bounded reads validate the complete decompressed raw framing
|
|
128
|
+
before parser normalization: directory/link sizes must be zero, EOF must contain
|
|
129
|
+
two consecutive zero blocks, and only zero padding may follow EOF. This rejects
|
|
130
|
+
hidden headers and body/header smuggling consistently across the JavaScript and
|
|
131
|
+
native backends. The meter checks logical member count before consuming bodies,
|
|
132
|
+
even for unrequested or filtered members. Per-entry/cumulative payload budgets
|
|
133
|
+
apply only to entries accepted after strip/filter policy, outside the raw meter.
|
|
134
|
+
A separate absolute decoded cap includes every member body, headers, metadata,
|
|
135
|
+
all padding, and EOF, closing cumulative-metadata and zero-tail bypasses. Native
|
|
136
|
+
extraction/read passes drain through physical EOF before publication or
|
|
137
|
+
returning selected bytes, even when the TAR parser stops earlier. Bounded
|
|
138
|
+
reads use default archive admission limits and keep `maxBytes` scoped to the
|
|
139
|
+
requested output. Metadata retains its per-record bound. See [archive framing](archive.md#raw-tar-framing) for supported
|
|
140
|
+
metadata and compression formats.
|
|
141
|
+
|
|
110
142
|
## What "library-level" means
|
|
111
143
|
|
|
112
144
|
A library cannot revoke its own caller's authority. If your code chooses to bypass `fs-safe` and call `fs.writeFile` directly with the same path, you bypass the defenses too. The contract `fs-safe` enforces is: *every filesystem operation that touches caller-controlled input goes through the boundary*. That contract is yours to keep.
|
package/docs/sidecar-lock.md
CHANGED
|
@@ -27,6 +27,8 @@ Each new sidecar also carries an internal random ownership token encoded as JSON
|
|
|
27
27
|
|
|
28
28
|
The raw sidecar bytes are not a canonical JSON representation: tools that trim or rewrite the trailing whitespace invalidate the ownership token, so release leaves the changed sidecar in place and fails closed. The token distinguishes cooperating acquisitions; it is not a secret and does not make pathname compare-and-remove atomic against a hostile process that can replace files outside the lock protocol.
|
|
29
29
|
|
|
30
|
+
`release()` propagates an I/O failure that prevents deletion of an unchanged, owned sidecar; it never reports successful cleanup while leaving that lock behind. The handle and manager retain the exact cleanup receipt after a failure, so the same handle can retry `release()` and `manager.drain()` can retry retained cleanup. A changed sidecar remains an ownership mismatch rather than a deletion failure and is left untouched. If both a `withFileLock()` callback and release fail, the release error is the primary `SuppressedError.error` and the callback failure remains available as `SuppressedError.suppressed`. Failed acquisition cleanup uses the same shape, with the cleanup error primary and the acquisition failure suppressed. On Node runtimes without the global `SuppressedError` constructor, fs-safe returns the equivalent `Error` shape with the same name and properties.
|
|
31
|
+
|
|
30
32
|
## API
|
|
31
33
|
|
|
32
34
|
```ts
|
|
@@ -92,6 +94,7 @@ type FileLockRetryOptions = {
|
|
|
92
94
|
```
|
|
93
95
|
|
|
94
96
|
`payload` is a function so you can re-evaluate it on each retry (e.g. timestamp, PID).
|
|
97
|
+
Retry counts must be non-negative safe integers. Retry factors and delays must be finite and non-negative, and when both delay bounds are provided `minTimeout` cannot exceed `maxTimeout`. `timeoutMs` accepts a finite non-negative deadline or positive infinity for an unbounded wait; invalid numeric values reject before filesystem acquisition starts.
|
|
95
98
|
`parsePayload` replaces JSON parsing for legacy or custom sidecars. Its `unknown`
|
|
96
99
|
result is passed to `shouldReclaim` and `shouldRemoveStaleLock`, allowing PID,
|
|
97
100
|
process-start, argv, or role schemas to remain application-owned.
|
|
@@ -176,7 +179,18 @@ detection, not revocation of work already in progress.
|
|
|
176
179
|
retry, payload parsing, stale policy, owner-scoped reentrancy, guarded
|
|
177
180
|
identity-conditioned reclaim, verification, and compromise monitoring. They do
|
|
178
181
|
not use the async manager queue or support async callbacks. Retry waits block
|
|
179
|
-
the calling thread; use the async API in request-serving code.
|
|
182
|
+
the calling thread; use the async API in request-serving code. The sync
|
|
183
|
+
compromise interval treats a thrown verification I/O error as a lost lock and
|
|
184
|
+
invokes `onCompromised` once, matching the asynchronous `.catch(() => false)`
|
|
185
|
+
contract. An explicit `verifyStillHeld()` call still propagates that I/O error.
|
|
186
|
+
|
|
187
|
+
Both synchronous helpers consume the [process-wide lock defaults](config.md#configurefssafelocks-config).
|
|
188
|
+
A synchronous retry sleep is clamped to the remaining finite deadline, so a long or jittered backoff cannot extend the configured timeout or block forever.
|
|
189
|
+
Per-call options take precedence, including zero values; a per-call `retry`
|
|
190
|
+
object replaces the entire configured retry object. A configured
|
|
191
|
+
`staleRecovery: "remove-if-unchanged"` still needs per-call
|
|
192
|
+
`shouldRemoveStaleLock` approval, and per-call `staleRecovery: "fail-closed"`
|
|
193
|
+
disables recovery even when the process configuration opts in.
|
|
180
194
|
|
|
181
195
|
Always release in a `finally`:
|
|
182
196
|
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Retained-directory staging
|
|
3
|
+
description: "Prepare and publish one file while retaining its original directory for abort cleanup."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retained-directory staging
|
|
7
|
+
|
|
8
|
+
`stageFileInDirectory()` from `@openclaw/fs-safe/advanced` prepares one file in
|
|
9
|
+
an existing directory and keeps that directory open until cleanup or disposal.
|
|
10
|
+
It requires the native binding on Linux or macOS, even in native mode `auto`.
|
|
11
|
+
Native off, an unavailable binding, or a binding without the required mechanism
|
|
12
|
+
fails before file creation. Windows is explicitly unsupported for this API.
|
|
13
|
+
Existing root, output, temp, lock, and durability APIs retain their documented
|
|
14
|
+
platform and fallback contracts.
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import type { DirectoryReceipt } from "@openclaw/fs-safe/durability";
|
|
18
|
+
import {
|
|
19
|
+
stageFileInDirectory,
|
|
20
|
+
type PublishedFileReceipt,
|
|
21
|
+
} from "@openclaw/fs-safe/advanced";
|
|
22
|
+
|
|
23
|
+
// Call while holding the application's cooperative writer lock. The caller
|
|
24
|
+
// supplies its authorization, manager, snapshot, and fingerprint checks.
|
|
25
|
+
export async function publishReviewedUpdate(options: {
|
|
26
|
+
directory: DirectoryReceipt;
|
|
27
|
+
basename: string;
|
|
28
|
+
bytes: Uint8Array;
|
|
29
|
+
assertAuthorizedAndCurrent(): Promise<void>;
|
|
30
|
+
}): Promise<PublishedFileReceipt> {
|
|
31
|
+
await options.assertAuthorizedAndCurrent();
|
|
32
|
+
await using staged = await stageFileInDirectory({
|
|
33
|
+
directory: options.directory,
|
|
34
|
+
content: options.bytes,
|
|
35
|
+
});
|
|
36
|
+
await options.assertAuthorizedAndCurrent();
|
|
37
|
+
await staged.assertCurrent();
|
|
38
|
+
return await staged.publish(options.basename, { overwrite: true });
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`await using` always disposes, including on exceptions. If both the body and
|
|
43
|
+
disposal fail, JavaScript preserves both errors in a `SuppressedError`. A
|
|
44
|
+
successful return means publication was observed and verified, not that a
|
|
45
|
+
separate application transaction committed. Application rollback and recovery
|
|
46
|
+
remain with the caller.
|
|
47
|
+
|
|
48
|
+
## API
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
function stageFileInDirectory(options: {
|
|
52
|
+
directory: string | DirectoryReceipt;
|
|
53
|
+
content: string | Uint8Array;
|
|
54
|
+
mode?: number;
|
|
55
|
+
}): Promise<StagedFile>;
|
|
56
|
+
|
|
57
|
+
interface StagedFile extends AsyncDisposable {
|
|
58
|
+
readonly receipt: StagedFileReceipt;
|
|
59
|
+
assertCurrent(): Promise<void>;
|
|
60
|
+
publish(basename: string, options: { overwrite: boolean }): Promise<PublishedFileReceipt>;
|
|
61
|
+
cleanup(): Promise<StagedFileCleanupReceipt>;
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Strings are UTF-8. `mode` is the requested **published** mode and defaults to
|
|
66
|
+
`0600`; exact final modes, including `000`, are supported. The unpublished file
|
|
67
|
+
stays at `0600` throughout preparation and any awaited application checks.
|
|
68
|
+
After rename succeeds and the published entry passes identity validation, the
|
|
69
|
+
owner applies the requested mode through its retained file descriptor and
|
|
70
|
+
synchronizes the file. No parent is created or chmodded.
|
|
71
|
+
Creation uses an exclusive, no-follow, close-on-exec open of a generated direct
|
|
72
|
+
child name. Writes use that descriptor. Inspection uses non-following metadata
|
|
73
|
+
operations, never a potentially blocking reopen of the leaf.
|
|
74
|
+
|
|
75
|
+
A supplied directory receipt must still match at admission. Its numeric
|
|
76
|
+
identity must be exactly representable; ambiguous identity fails closed.
|
|
77
|
+
Returned receipts are frozen descriptive snapshots, not mutable authority.
|
|
78
|
+
Changing a supplied receipt after admission cannot retarget the lifecycle.
|
|
79
|
+
|
|
80
|
+
`StagedFileReceipt` contains `directory` (`path`, `realPath`, and exact bigint
|
|
81
|
+
`identity.dev`/`identity.ino`), `temporaryBasename`, and the prepared file's
|
|
82
|
+
`identity` (`dev`, `ino`, `size`, `nlink`, `mtimeNs`, `ctimeNs` as bigint;
|
|
83
|
+
`mode`, `uid`, `gid` as numbers). This is a preparation-time snapshot of the
|
|
84
|
+
private stage: its mode is `0600`. Publication does not refresh this snapshot;
|
|
85
|
+
neither its mode nor its timestamps are a final-file fingerprint. No raw
|
|
86
|
+
descriptors are exposed.
|
|
87
|
+
|
|
88
|
+
`assertCurrent()` verifies the original pathname's directory identity and the
|
|
89
|
+
staged name against the retained file. A failed check does not disable cleanup.
|
|
90
|
+
After successful publication there is no staged name to check, so further
|
|
91
|
+
checks or publication reject. Cleanup closes the lifecycle; later checks and
|
|
92
|
+
publication reject before descriptor use. Rejected publication still carries its
|
|
93
|
+
phase, cause, and recorded publication outcome after closure. Concurrent calls
|
|
94
|
+
are serialized in invocation order, including cleanup and disposal. Repeated
|
|
95
|
+
cleanup returns the recorded outcome, or repeats the recorded error, without
|
|
96
|
+
touching descriptors.
|
|
97
|
+
|
|
98
|
+
## Publication and failure evidence
|
|
99
|
+
|
|
100
|
+
`publish()` requires an explicit boolean `overwrite` and one direct-child
|
|
101
|
+
basename. Empty, dot, dotdot, separators, absolute paths, NUL, control characters,
|
|
102
|
+
drive-relative spellings, and the stage's own name are rejected.
|
|
103
|
+
|
|
104
|
+
With `overwrite: false`, publication is genuine kernel no-replace rename; a
|
|
105
|
+
collision leaves both names unchanged and raises `FsSafeError("already-exists")`.
|
|
106
|
+
The stage may then be cleaned or published under another name. With
|
|
107
|
+
`overwrite: true`, publication is plain atomic replacement. Neither route
|
|
108
|
+
copies. Both source and destination resolve through the retained original
|
|
109
|
+
parent, with checks immediately before rename and after publication.
|
|
110
|
+
|
|
111
|
+
`PublishedFileReceipt` has `status: "published"`, `staged`, `basename`, and
|
|
112
|
+
`overwrite`. Its `staged` field retains the private preparation snapshot, not
|
|
113
|
+
final metadata. Errors from publication carry typed `StagedFileFailureDetails`
|
|
114
|
+
in `FsSafeError.details`, including `phase` and `publication`. Publication is
|
|
115
|
+
`not-published`, `published` (with its receipt), or `indeterminate` (with the
|
|
116
|
+
attempted basename and overwrite policy) if a rename error cannot establish
|
|
117
|
+
whether it committed. The underlying error remains in `cause`.
|
|
118
|
+
|
|
119
|
+
A parent move after the final pre-check cannot divert the rename to a
|
|
120
|
+
replacement parent. It can publish inside the moved original and then fail
|
|
121
|
+
post-validation. Successful rename is recorded before those checks; cleanup
|
|
122
|
+
never deletes or rolls back a published final name. Indeterminate publication
|
|
123
|
+
also preserves names for caller-directed recovery.
|
|
124
|
+
|
|
125
|
+
The same rule applies if applying the published mode, synchronizing, or a later
|
|
126
|
+
check fails: the error reports `published`, and cleanup preserves the final
|
|
127
|
+
name. The file may still have mode `0600` or may already have the requested
|
|
128
|
+
mode, depending on which operation failed. A `published` failure receipt records
|
|
129
|
+
rename success, not successful permission finalization.
|
|
130
|
+
|
|
131
|
+
## Cleanup guarantee and limits
|
|
132
|
+
|
|
133
|
+
If the temporary basename still names the object created by this lifecycle and
|
|
134
|
+
filesystem removal remains permitted, moving or replacing the actual parent
|
|
135
|
+
(or an ancestor) does not strand the unpublished temp. Cleanup resolves through
|
|
136
|
+
the retained original directory, even after `assertCurrent()` rejects drift.
|
|
137
|
+
Same-name sentinels in the replacement parent are not touched.
|
|
138
|
+
|
|
139
|
+
`StagedFileCleanupReceipt` records `temporaryBasename`, `publication`,
|
|
140
|
+
`resources` (`closed` or `close-failed`), and `status`:
|
|
141
|
+
|
|
142
|
+
| Status | Meaning |
|
|
143
|
+
|---|---|
|
|
144
|
+
| `removed` | The owned recorded name was unlinked through the retained directory. |
|
|
145
|
+
| `name-absent` | That name was absent in the original directory; this does not prove the inode has no other names. |
|
|
146
|
+
| `preserved` | An observed substitute or an indeterminate publication was left alone. |
|
|
147
|
+
| `failed` | Inspection or removal failed. |
|
|
148
|
+
| `not-needed` | Publication was recorded, or preparation failed before creating a file. |
|
|
149
|
+
|
|
150
|
+
Explicit cleanup returns preservation outcomes. Removal or close failures throw
|
|
151
|
+
an `FsSafeError` with the receipt in `details.cleanup` and underlying errors in
|
|
152
|
+
`cause`. Disposal also throws for preservation, so ignoring its return value
|
|
153
|
+
cannot hide incomplete cleanup. Setup failures preserve the original error;
|
|
154
|
+
when cleanup also fails, an aggregate cause retains both failures. Descriptors
|
|
155
|
+
are closed on every cleanup outcome, with no retry through recycled numbers.
|
|
156
|
+
|
|
157
|
+
This is **directory-relative targeting**, not expected-destination-inode/CAS
|
|
158
|
+
publication. Checking an identity before rename does not make rename CAS.
|
|
159
|
+
Likewise, the identity check followed by `unlinkat` is not an atomic conditional
|
|
160
|
+
unlink: an adversary can replace the leaf in that final syscall gap. Observed
|
|
161
|
+
substitutions are preserved, but this API does not guarantee recovery after
|
|
162
|
+
arbitrary child renames, permission revocation, I/O failure, or process death.
|
|
163
|
+
Keep application authorization, manager checks, snapshots, fingerprints,
|
|
164
|
+
cooperative locks, and conditional rollback.
|
|
165
|
+
|
|
166
|
+
Namespace cleanup is not crash durability. Staging retains the native writer's
|
|
167
|
+
file synchronization behavior, and publication synchronizes its directory
|
|
168
|
+
(with the existing `EPERM` exception); cleanup does not promise a durable unlink.
|
|
169
|
+
No successful receipt promises survival across a crash. See [Directory
|
|
170
|
+
durability](durability.md) when an application needs a separate durability proof.
|
|
171
|
+
`pinDirectory().assertCurrent()` and `.sync()` still require a current pathname;
|
|
172
|
+
they do not gain this cleanup authority.
|
|
173
|
+
|
|
174
|
+
## Related pages
|
|
175
|
+
|
|
176
|
+
- [Advanced composition](advanced.md)
|
|
177
|
+
- [Native architecture](native.md)
|
|
178
|
+
- [Security model](security-model.md)
|
package/docs/store.md
CHANGED
|
@@ -68,6 +68,19 @@ Use `ackJsonDurableQueueEntry()` after durable processing succeeds and
|
|
|
68
68
|
`moveJsonDurableQueueEntryToFailed()` when the caller wants to quarantine an
|
|
69
69
|
entry for inspection.
|
|
70
70
|
|
|
71
|
+
Loading serializes consumers for one ID through a sidecar lock, then creates `processingPath` with a no-replace hardlink and retires the source through a recoverable sibling record. Whichever producer generation occupies `.json` at the hardlink operation becomes the claim; a replacement published afterward remains pending. After a crash, `.processing` is retried before that pending replacement. Transfer locks are fail-closed and are never reclaimed automatically, so an operator must remove a proven-stale lock after a hard process crash. Acknowledgement atomically moves `.processing` through the short-lived `.delivered` marker, and existing delivered markers are cleaned during batch loading. `ackJsonDurableQueueEntry()` rejects while `.json` exists without a processing claim: callers that previously paired direct `readJsonDurableQueueEntry()` with acknowledgement must load through `loadJsonDurableQueueEntry()` before processing so acknowledgement is generation-bound.
|
|
72
|
+
|
|
73
|
+
Queue and failed directory creation fsyncs every newly-created parent edge from the leaf toward the trusted root. Enqueue and migration writes fsync the temp file and parent; claim, acknowledgement, quarantine, delivered-marker cleanup, and retirement transitions fsync every affected directory and propagate real sync failures. A transition may already be visible when a post-mutation sync fails, so retry the same operation to complete its crash-recovery state; quarantine retries with only failed evidence resync that destination before repairing the vanished queue source.
|
|
74
|
+
|
|
75
|
+
Failed destinations are create-only. Quarantine publishes the claimed file by hardlink, so the queue and failed directories must share a filesystem with hardlink support. If `failed/<id>.json` already exists, quarantine rejects while preserving both that earlier evidence and the current claimed entry instead of overwriting either file. The `read` callback continues to receive the logical `.json` path even though bytes are read and migrations are written through the claimed path.
|
|
76
|
+
|
|
77
|
+
Queue entry reads verify lossless file identities before opening, on the opened
|
|
78
|
+
descriptor, and at the current pathname before reading bytes. POSIX opens are
|
|
79
|
+
nonblocking, so a raced FIFO is rejected rather than stalling a consumer. On Windows, an
|
|
80
|
+
unknown identity gets one bounded reinspection; persistent ambiguity or a
|
|
81
|
+
mismatch rejects with `queue entry changed during read`. Each inspection still
|
|
82
|
+
rejects non-files, symlinks, hardlinks, and entries over the byte limit.
|
|
83
|
+
|
|
71
84
|
## Related pages
|
|
72
85
|
|
|
73
86
|
- [`fileStore`](file-store.md) — full API for the multi-file store.
|
package/docs/temp.md
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
|
|
15
15
|
## Private temp workspaces
|
|
16
16
|
|
|
17
|
-
A private workspace is a directory created at mode `0o700` under a caller-provided temp root. It is unique per call (random suffix)
|
|
17
|
+
A private workspace is a directory created at mode `0o700` under a caller-provided temp root. It is unique per call (random suffix). Calling `cleanup()` or leaving an `await using` scope moves an unchanged workspace through a private quarantine before removal. Descriptor-bounded cleanup prevents recursive traversal of substitutions; the compatible JavaScript fallback has the narrower race contract documented below.
|
|
18
18
|
|
|
19
19
|
### `tempWorkspace`
|
|
20
20
|
|
|
@@ -31,7 +31,7 @@ type TempWorkspace = {
|
|
|
31
31
|
writeJson(fileName: string, data: unknown, options?: { trailingNewline?: boolean }): Promise<string>;
|
|
32
32
|
copyIn(fileName: string, sourcePath: string): Promise<string>;
|
|
33
33
|
read(fileName: string): Promise<Buffer>;
|
|
34
|
-
cleanup(): Promise<"removed" | "missing" | "identity-mismatch">;
|
|
34
|
+
cleanup(): Promise<"removed" | "missing" | "identity-mismatch" | "indeterminate">;
|
|
35
35
|
[Symbol.asyncDispose](): Promise<void>;
|
|
36
36
|
};
|
|
37
37
|
```
|
|
@@ -64,12 +64,66 @@ await state.write({ ready: true });
|
|
|
64
64
|
The workspace owns cleanup; the store is only a view over the workspace
|
|
65
65
|
directory.
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
67
|
+
**Compatibility and security:** workspace creation remains available in native
|
|
68
|
+
`auto`, `off`, and unavailable-native environments. The default
|
|
69
|
+
`cleanupSafety: "compatible"` preserves the JavaScript cleanup behavior from
|
|
70
|
+
0.6: it verifies the workspace identity, moves the public name to a fresh
|
|
71
|
+
`.fs-safe-workspace-cleanup-<uuid>` sibling, verifies that quarantine, and then
|
|
72
|
+
uses guarded pathname-recursive removal. This fallback never recursively
|
|
73
|
+
removes the public workspace name, but it is not atomic conditional deletion: a
|
|
74
|
+
same-privilege peer that discovers and replaces the private quarantine after
|
|
75
|
+
verification can still redirect the final pathname removal.
|
|
76
|
+
|
|
77
|
+
Set `cleanupSafety: "require-bounded"` when that concurrent attacker is in scope.
|
|
78
|
+
Creation then requires native no-replace directory rename, native owned-tree
|
|
79
|
+
removal, and a retained parent descriptor **before** `mkdtemp` creates
|
|
80
|
+
a child. If any capability is unavailable, creation throws
|
|
81
|
+
`FsSafeError("helper-unavailable")`; no child is created and a scoped callback is
|
|
82
|
+
not called. The compatible default retains its fallback even if process-global
|
|
83
|
+
native mode is `require`; select `require-bounded` to make cleanup capability
|
|
84
|
+
mandatory for this API.
|
|
85
|
+
|
|
86
|
+
On Linux, bounded cleanup requires a successful runtime probe of the exact
|
|
87
|
+
`openat2` child-directory flags, including `RESOLVE_NO_XDEV`, against the retained
|
|
88
|
+
parent descriptor. If the kernel or seccomp policy denies that capability,
|
|
89
|
+
compatible mode uses the guarded JavaScript fallback; `require-bounded` rejects
|
|
90
|
+
before child creation. The probe runs once at creation, without filesystem mutation.
|
|
91
|
+
|
|
92
|
+
Bounded cleanup checks the parent and public workspace identity, quarantines
|
|
93
|
+
the direct child without replacement, and verifies the quarantine against the
|
|
94
|
+
retained workspace descriptor. It binds every enumerated child to its native
|
|
95
|
+
identity before opening it, rejects mount crossings, and traverses descendants
|
|
96
|
+
only through opened directory handles; symlinks/reparse entries are removed as
|
|
97
|
+
leaves and never traversed. Windows marks the exact opened objects for deletion by handle.
|
|
98
|
+
|
|
99
|
+
POSIX has no unlink-by-fd or expected-inode unlink for directory entries. After
|
|
100
|
+
the final identity check, each `unlinkat` can still be raced; the possible side
|
|
101
|
+
effect is bounded to one substituted non-directory leaf or one empty directory
|
|
102
|
+
entry per raced syscall. A substituted nonempty directory is never recursively
|
|
103
|
+
traversed and is preserved as `"indeterminate"`, but a leaf replacement removed
|
|
104
|
+
in that irreducible final gap cannot be distinguished after the syscall.
|
|
105
|
+
|
|
106
|
+
The workspace captures its identity, binding, and descriptors until cleanup.
|
|
107
|
+
Later process-global mode changes or loader resets do not revoke that authority.
|
|
108
|
+
Manual, disposal, and process-exit cleanup share one serialized owner,
|
|
109
|
+
registered before store construction; a construction failure after registration
|
|
110
|
+
remains exit-cleanable. Earlier creation failures close retained descriptors
|
|
111
|
+
without deleting an unverified child.
|
|
112
|
+
|
|
113
|
+
If the quarantine does not match the creation descriptor, cleanup leaves it in
|
|
114
|
+
place without restoring the public name or recursively deleting it and returns
|
|
115
|
+
`"indeterminate"`. A collision, uncertain rename outcome, changed parent,
|
|
116
|
+
mount/device crossing, changed reparse state, or detected concurrent mutation also
|
|
117
|
+
preserves the remaining artifact. Recover `.fs-safe-workspace-cleanup-<uuid>` entries only
|
|
118
|
+
after excluding competing mutators and re-establishing ownership.
|
|
119
|
+
|
|
120
|
+
A missing workspace returns `"missing"`. A replacement observed at the public
|
|
121
|
+
name before quarantine returns `"identity-mismatch"` when the parent is stable;
|
|
122
|
+
an ambiguous parent returns `"indeterminate"`. After successful removal,
|
|
123
|
+
repeated cleanup returns `"missing"` without touching a recreated public name.
|
|
124
|
+
Other statuses remain stable. Operational removal errors propagate and later
|
|
125
|
+
cleanup returns `"indeterminate"` without retrying. Disposal and scoped helpers
|
|
126
|
+
ignore returned statuses, while manual cleanup exposes the result.
|
|
73
127
|
|
|
74
128
|
When cleanup is part of a retention or audit decision, inspect the receipt
|
|
75
129
|
instead of treating cleanup as fire-and-forget:
|
|
@@ -82,6 +136,8 @@ try {
|
|
|
82
136
|
const cleanup = await workspace.cleanup();
|
|
83
137
|
if (cleanup === "identity-mismatch") {
|
|
84
138
|
alertOperator("restore workspace path was replaced; replacement preserved");
|
|
139
|
+
} else if (cleanup === "indeterminate") {
|
|
140
|
+
alertOperator("restore workspace cleanup could not establish safe completion; inspect retained entries");
|
|
85
141
|
}
|
|
86
142
|
}
|
|
87
143
|
```
|
|
@@ -91,7 +147,7 @@ types and a `FileStoreSync` at `workspace.store`.
|
|
|
91
147
|
|
|
92
148
|
### `withTempWorkspace`
|
|
93
149
|
|
|
94
|
-
The recommended shape.
|
|
150
|
+
The recommended shape. Attempts cleanup on every exit path:
|
|
95
151
|
|
|
96
152
|
```ts
|
|
97
153
|
import { withTempWorkspace } from "@openclaw/fs-safe/temp";
|
|
@@ -129,6 +185,7 @@ type TempWorkspaceOptions = {
|
|
|
129
185
|
prefix: string; // dir prefix (sanitized)
|
|
130
186
|
dirMode?: number; // dir mode; default 0o700
|
|
131
187
|
mode?: number; // file write mode; default 0o600
|
|
188
|
+
cleanupSafety?: "compatible" | "require-bounded"; // default compatible
|
|
132
189
|
};
|
|
133
190
|
```
|
|
134
191
|
|
|
@@ -205,11 +262,65 @@ const result = await writeSiblingTempFile<string>({
|
|
|
205
262
|
// result.filePath, result.result (returned by writeTemp)
|
|
206
263
|
```
|
|
207
264
|
|
|
208
|
-
`writeSiblingTempFile` chooses a random sibling name in `dir
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
265
|
+
`writeSiblingTempFile` chooses a random, initially absent sibling name in `dir`
|
|
266
|
+
and calls `writeTemp()`. After the callback succeeds, it validates the produced
|
|
267
|
+
regular file before taking ownership: symlinks, directories, other non-regular
|
|
268
|
+
files, hardlinks, and changes between the pre-open pathname, opened descriptor,
|
|
269
|
+
and current pathname are rejected. The callback must finish and close its
|
|
270
|
+
writer before returning. Its return value is preserved as `result`.
|
|
271
|
+
|
|
272
|
+
The helper retains one descriptor through requested mode application, opt-in
|
|
273
|
+
file synchronization, rename, and publication verification. It opens read-only
|
|
274
|
+
unless file synchronization is requested, so closed read-only producer output
|
|
275
|
+
remains publishable under the historical default. Omitting
|
|
276
|
+
`mode` preserves the callback-produced mode without chmod; explicit modes,
|
|
277
|
+
including `0`, are applied through that descriptor. File-mode errors are
|
|
278
|
+
tolerated for compatibility with the helper's historical best-effort behavior.
|
|
279
|
+
No chmod, content read, or reopen follows
|
|
280
|
+
the staged or published pathname. `resolveFinalPath(result)` must resolve to a
|
|
281
|
+
distinct direct child of the same directory. Final-path writes are serialized
|
|
282
|
+
within the process, and the retained descriptor and current name must still
|
|
283
|
+
have the admitted exact bigint identity and exactly one link before rename and
|
|
284
|
+
after publication. A verification failure after rename does not roll back or
|
|
285
|
+
delete the final name.
|
|
286
|
+
|
|
287
|
+
`syncTempFile` and `syncParentDir` retain their historical `false` defaults.
|
|
288
|
+
Explicit `syncTempFile: true` synchronizes the descriptor before rename;
|
|
289
|
+
file-sync errors propagate except for the existing `EPERM` compatibility case.
|
|
290
|
+
Explicit `syncParentDir: true` requests best-effort parent sync after rename.
|
|
291
|
+
Omitting either option or passing `false` skips that sync, never the identity
|
|
292
|
+
checks. Parent synchronization can be unsupported or fail without rejecting
|
|
293
|
+
the write, so success is not a strict crash-durability receipt.
|
|
294
|
+
|
|
295
|
+
Cleanup only unlinks an admitted file while the parent, pathname identity, and
|
|
296
|
+
single-link regular-file checks still agree. Observed substitutes are preserved,
|
|
297
|
+
including during process-exit cleanup. Operational cleanup failures retain an
|
|
298
|
+
identity-bound exit retry. If the callback throws or admission fails, no file
|
|
299
|
+
has been adopted: even a regular partial file is left for caller-directed
|
|
300
|
+
recovery. The helper never recursively removes a sibling temp.
|
|
301
|
+
|
|
302
|
+
On POSIX, admission uses no-follow and nonblocking open flags, so a FIFO swap
|
|
303
|
+
does not block the helper. Windows retains Node's guarded pathname-open behavior
|
|
304
|
+
because Node has no portable no-follow flag there; metadata is checked before
|
|
305
|
+
and after opening, and unknown Windows identities fail closed after one bounded
|
|
306
|
+
re-inspection without reopening. These helpers remain available with native
|
|
307
|
+
mode `off`; they do not acquire the native-required retained-directory contract
|
|
308
|
+
of [`stageFileInDirectory`](staged-file.md).
|
|
309
|
+
|
|
310
|
+
Identity checks and pathname rename/unlink are separate syscalls, not atomic
|
|
311
|
+
conditional mutations. A hostile process can still replace a leaf or parent in
|
|
312
|
+
the final syscall gap or mutate an open file's contents. Use an approved writable
|
|
313
|
+
directory and cooperative locking or OS isolation; a moved parent can leave an
|
|
314
|
+
unpublished original temp behind. Observed replacements are preserved, but
|
|
315
|
+
arbitrary concurrent namespace changes cannot be prevented by these helpers.
|
|
316
|
+
|
|
317
|
+
By default the helper attempts to set `dir` to `dirMode` (default `0o700`)
|
|
318
|
+
through the shared verified POSIX directory-descriptor helper. Only the actual
|
|
319
|
+
descriptor chmod error is tolerated, preserving the historical best-effort
|
|
320
|
+
directory-mode behavior. Directory lstat, open, type, identity, and close errors
|
|
321
|
+
still propagate; there is no pathname chmod fallback. Windows only passes the
|
|
322
|
+
directory mode to `mkdir`. Pass
|
|
323
|
+
`chmodDir: false` when an existing staging/output directory mode must be preserved.
|
|
213
324
|
|
|
214
325
|
### `writeViaSiblingTempPath`
|
|
215
326
|
|
|
@@ -232,6 +343,9 @@ await writeViaSiblingTempPath({
|
|
|
232
343
|
If `replaceFileAtomic` does what you need, prefer that. Use
|
|
233
344
|
`writeViaSiblingTempPath` when the producer needs a concrete temp pathname but
|
|
234
345
|
the final destination still needs root-boundary checks.
|
|
346
|
+
Its private workspace uses the same identity-aware directory cleanup as
|
|
347
|
+
`tempFile()`: moving and replacing the workspace preserves the replacement.
|
|
348
|
+
This workspace owns its contents, unlike the unadmitted sibling pathname above.
|
|
235
349
|
|
|
236
350
|
## Secure temp root
|
|
237
351
|
|
package/docs/testing.md
CHANGED
|
@@ -17,7 +17,7 @@ The double-underscore prefix is a deliberate "hands off" signal: production code
|
|
|
17
17
|
## When to reach for hooks
|
|
18
18
|
|
|
19
19
|
- Reproduce a TOCTOU race deterministically: simulate a symlink swap between resolve and open, or between write and rename.
|
|
20
|
-
- Force guarded JavaScript behavior without removing
|
|
20
|
+
- Force guarded JavaScript behavior without removing platform packages from your runners.
|
|
21
21
|
- Inject latency to test cancellation/timeout paths.
|
|
22
22
|
|
|
23
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.
|
package/docs/writing.md
CHANGED
|
@@ -33,6 +33,10 @@ old-or-new guarantee does not apply to `append()` or `openWritable()`, which
|
|
|
33
33
|
write in place, or to lower-level atomic helpers when their explicitly
|
|
34
34
|
non-atomic permission-error copy fallback is enabled.
|
|
35
35
|
|
|
36
|
+
Post-publication verification can still reject after a complete replacement has
|
|
37
|
+
been committed. Rejection does not promise that a successful rename was rolled
|
|
38
|
+
back; the published file or a raced replacement may remain at the destination.
|
|
39
|
+
|
|
36
40
|
## Denying mutations
|
|
37
41
|
|
|
38
42
|
All mutation verbs accept `denyMutations?: DenyMutationPolicy`, either as a root default or per-call option:
|
|
@@ -64,6 +68,21 @@ await fs.write("notes/today.txt", "hello\n", { encoding: "utf8" });
|
|
|
64
68
|
|
|
65
69
|
`data` accepts `string | Buffer`. `options` are `{ denyMutations?: DenyMutationPolicy; encoding?: BufferEncoding; mkdir?: boolean; mode?: number; overwrite?: boolean; renameIdentity?: RenameIdentityPolicy }`. `mode` sets the file's POSIX mode. If neither the call nor `RootDefaults` supplies it, a replacement preserves the existing file mode and a new file uses `0o600`. `mkdir` and `overwrite` both default to `true`; set `overwrite: false` for the same no-clobber behavior as `create()`.
|
|
66
70
|
|
|
71
|
+
POSIX modes without read permission, including `0o000` and `0o200`, succeed:
|
|
72
|
+
final verification uses a descriptor retained by the writer rather than reopening
|
|
73
|
+
the published file. The requested mode is not relaxed for verification.
|
|
74
|
+
Publication verification compares exact bigint descriptor and pathname identities,
|
|
75
|
+
including large file indexes that cannot be represented by a JavaScript number.
|
|
76
|
+
Later reads still obey OS permissions, and access checks on a pre-existing
|
|
77
|
+
destination are unchanged. The explicit FUSE compatibility policy still requires
|
|
78
|
+
a readable destination to prove matching content when rename changes its identity.
|
|
79
|
+
|
|
80
|
+
When Windows cannot report a pathname's identity, the publication verifier reopens
|
|
81
|
+
the name and compares that descriptor's exact identity with the original retained
|
|
82
|
+
file, without reading bytes. It also rechecks links and root/parent containment.
|
|
83
|
+
This write-specific proof does not relax ordinary reads: unknown pathname metadata
|
|
84
|
+
alone is never proof that the name still refers to the expected file.
|
|
85
|
+
|
|
67
86
|
### `fs.create(rel, data, options?)`
|
|
68
87
|
|
|
69
88
|
Don't-clobber variant of `write()`. Throws `already-exists` if the target is there.
|