@openclaw/fs-safe 0.19.0 → 0.21.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 +50 -0
- package/README.md +24 -6
- package/dist/advanced.d.ts +4 -0
- package/dist/advanced.js +2 -0
- package/dist/archive-plan.d.ts +2 -7
- package/dist/archive-read.js +9 -18
- package/dist/archive-zip-entry.d.ts +11 -11
- package/dist/archive-zip-entry.js +3 -35
- package/dist/archive-zip-integrity.d.ts +2 -2
- package/dist/archive-zip-integrity.js +2 -12
- package/dist/archive-zip-loader.d.ts +7 -3
- package/dist/archive-zip-loader.js +10 -9
- package/dist/archive-zip-preflight.d.ts +2 -1
- package/dist/archive-zip-preflight.js +16 -7
- package/dist/archive.js +17 -16
- package/dist/atomic.d.ts +1 -1
- package/dist/directory-receipt.js +5 -7
- package/dist/effective-uid.js +1 -4
- package/dist/errors.d.ts +3 -1
- package/dist/errors.js +3 -2
- package/dist/file-lock-sync-root-held.js +1 -4
- package/dist/file-store.d.ts +4 -7
- package/dist/json-document-store.d.ts +4 -9
- package/dist/local-file-access.js +2 -5
- package/dist/move-path-cleanup.js +4 -4
- package/dist/native-binding.d.ts +28 -1
- package/dist/native-staged-symlink.d.ts +13 -0
- package/dist/native-staged-symlink.js +303 -0
- package/dist/owner-dacl-batch-worker.d.ts +1 -0
- package/dist/owner-dacl-batch-worker.js +54 -0
- package/dist/owner-dacl-batch.d.ts +5 -0
- package/dist/owner-dacl-batch.js +64 -0
- package/dist/owner-dacl.d.ts +2 -0
- package/dist/owner-dacl.js +3 -0
- package/dist/path.js +17 -1
- package/dist/permission-exec.js +3 -6
- package/dist/permissions-public.d.ts +1 -0
- package/dist/permissions-public.js +1 -0
- package/dist/pinned-mutation-admission.d.ts +0 -1
- package/dist/pinned-open.d.ts +0 -1
- package/dist/pinned-open.js +1 -2
- package/dist/publish-copy-stage.js +4 -0
- package/dist/read-opened-file.d.ts +2 -5
- package/dist/regular-file.js +3 -3
- package/dist/replace-file-buffer.d.ts +4 -0
- package/dist/replace-file-buffer.js +36 -0
- package/dist/replace-file-copy-fallback.d.ts +3 -2
- package/dist/replace-file-copy-fallback.js +66 -38
- package/dist/replace-file-descriptor.d.ts +4 -0
- package/dist/replace-file-descriptor.js +9 -1
- package/dist/replace-file-destination.d.ts +17 -0
- package/dist/replace-file-destination.js +61 -0
- package/dist/replace-file-mutation.d.ts +26 -0
- package/dist/replace-file-mutation.js +47 -0
- package/dist/replace-file-temp-owner.d.ts +2 -2
- package/dist/replace-file-temp-owner.js +16 -4
- package/dist/replace-file-types.d.ts +55 -0
- package/dist/replace-file-types.js +1 -0
- package/dist/replace-file.d.ts +3 -55
- package/dist/replace-file.js +29 -10
- package/dist/retained-file-types.d.ts +61 -0
- package/dist/retained-file-types.js +1 -0
- package/dist/retained-file.d.ts +3 -0
- package/dist/retained-file.js +121 -0
- package/dist/root-directory-entry.d.ts +9 -0
- package/dist/root-directory-entry.js +28 -0
- package/dist/root-directory-list.d.ts +7 -1
- package/dist/root-directory-list.js +48 -23
- package/dist/root-handle-context.d.ts +4 -0
- package/dist/root-handle-context.js +12 -0
- package/dist/root-impl.d.ts +3 -3
- package/dist/root-impl.js +8 -5
- package/dist/root-observed-path.d.ts +0 -1
- package/dist/root-observed-path.js +0 -3
- package/dist/root-path-observation.d.ts +4 -11
- package/dist/root-path.js +7 -10
- package/dist/root-walk.d.ts +19 -12
- package/dist/root-walk.js +49 -18
- package/dist/root-write-admission.js +0 -2
- package/dist/safe-path-segment.d.ts +1 -0
- package/dist/safe-path-segment.js +8 -2
- package/dist/secure-file.js +3 -2
- package/dist/sidecar-lock.js +5 -3
- package/dist/staged-symlink-types.d.ts +49 -0
- package/dist/staged-symlink-types.js +1 -0
- package/dist/symlink-parents.js +58 -7
- package/dist/temp-target.js +5 -2
- package/dist/temp-workspace-admission.js +22 -21
- package/dist/temp-workspace-child-admission.d.ts +1 -1
- package/dist/temp-workspace-child-admission.js +14 -9
- package/dist/temp-workspace-owner.js +4 -9
- package/dist/temp-workspace-ownership.d.ts +8 -0
- package/dist/temp-workspace-ownership.js +52 -0
- package/dist/test-hooks.d.ts +3 -0
- package/dist/text-atomic.d.ts +2 -1
- package/dist/text-atomic.js +2 -0
- package/dist/trash.js +27 -1
- package/dist/walk.d.ts +2 -5
- package/dist/watch-alias.d.ts +6 -0
- package/dist/watch-alias.js +80 -0
- package/dist/watch-hints.d.ts +8 -0
- package/dist/watch-hints.js +77 -0
- package/dist/watch-native.d.ts +32 -0
- package/dist/watch-native.js +56 -0
- package/dist/watch-scan.d.ts +24 -0
- package/dist/watch-scan.js +269 -0
- package/dist/watch-types.d.ts +58 -0
- package/dist/watch-types.js +1 -0
- package/dist/watch.d.ts +5 -0
- package/dist/watch.js +502 -0
- package/dist/windows-owner.d.ts +0 -1
- package/dist/windows-owner.js +0 -1
- package/dist/windows-security-bridge.cs +6 -4
- package/dist/windows-security-bridge.ps1 +78 -3
- package/dist/windows-security-command.d.ts +8 -0
- package/dist/windows-security-command.js +66 -12
- package/dist/windows-security-facts.d.ts +3 -0
- package/dist/windows-security-facts.js +4 -0
- package/docs/advanced.md +4 -2
- package/docs/archive.md +8 -0
- package/docs/atomic.md +72 -3
- package/docs/contributing.md +35 -0
- package/docs/durability.md +7 -0
- package/docs/index.md +1 -0
- package/docs/install.md +28 -0
- package/docs/native-helper.md +14 -4
- package/docs/native.md +45 -1
- package/docs/permissions.md +66 -0
- package/docs/public-api.md +7 -1
- package/docs/retained-file.md +113 -0
- package/docs/root.md +6 -1
- package/docs/security-model.md +4 -1
- package/docs/sidecar-lock.md +2 -0
- package/docs/staged-symlink.md +123 -0
- package/docs/store.md +3 -1
- package/docs/temp.md +24 -4
- package/docs/testing.md +88 -0
- package/docs/types.md +6 -0
- package/docs/walk.md +22 -1
- package/docs/watch.md +184 -0
- package/docs/writing.md +10 -0
- package/package.json +13 -9
package/docs/public-api.md
CHANGED
|
@@ -41,6 +41,11 @@ The lexical path surface additionally exports `isNodeError`,
|
|
|
41
41
|
`UnsafeDeviceReadPathMatch`, `UnsafeDeviceReadPathOptions`, and
|
|
42
42
|
`UnsafeDeviceReadPathReason`.
|
|
43
43
|
|
|
44
|
+
`isPathRelativeEscape()` rejects absolute paths and relative paths that step
|
|
45
|
+
above their starting directory at any point. Contained paths such as
|
|
46
|
+
`dir/../file` remain relative. It accepts both separators on Windows; on POSIX,
|
|
47
|
+
a backslash remains a literal filename character.
|
|
48
|
+
|
|
44
49
|
The advanced root-file primitive exports `OpenRootFileParams`,
|
|
45
50
|
`OpenRootFileSyncParams`, `RootFileOpenResult`, and
|
|
46
51
|
`RootFileOpenFailureReason`. These are composition types for callers building
|
|
@@ -100,7 +105,8 @@ best-effort cleanup helper used by those queue flows.
|
|
|
100
105
|
Permission inspection exposes `PermissionCheckOptions` and `SafeStatResult`.
|
|
101
106
|
Private-directory creation uses `CreatePrivateDirectoryOptions`. Raw Windows
|
|
102
107
|
descriptor facts use `OwnerAndDaclResult`, `WindowsAccessControlEntry`, and
|
|
103
|
-
`WindowsAceFlags`.
|
|
108
|
+
`WindowsAceFlags`. `readOwnerAndDaclBatch()` returns those same facts in input
|
|
109
|
+
order through an isolated asynchronous batch with a whole-process timeout.
|
|
104
110
|
|
|
105
111
|
Secure reads split their option and result shapes into
|
|
106
112
|
`SecureFileTrustOptions`, `SecureFilePermissionOptions`,
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Retained Windows files
|
|
3
|
+
description: "Explicit identity-bound retirement of an existing file; resource settlement is not persistence."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retain an existing Windows file
|
|
7
|
+
|
|
8
|
+
`retainFileInDirectory` from `@openclaw/fs-safe/advanced` retains **one existing
|
|
9
|
+
regular direct child**, without creating or deleting anything at admission.
|
|
10
|
+
It requires the matching native package. There is no pathname-unlink fallback.
|
|
11
|
+
The initial implementation supports fixed local NTFS drives only; other systems
|
|
12
|
+
return `unsupported`.
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { retainFileInDirectory } from "@openclaw/fs-safe/advanced";
|
|
16
|
+
|
|
17
|
+
const admission = retainFileInDirectory({
|
|
18
|
+
directory: producer.directory, // canonical C:\... spelling
|
|
19
|
+
parent: producer.parentIdentity, // exact bigint dev/ino
|
|
20
|
+
basename: producer.basename,
|
|
21
|
+
expected: producer.expected, // bigint dev/ino/size/mtimeNs/ctimeNs and SHA-256
|
|
22
|
+
assertBeforeMutation: () => producer.assertCurrentExclusiveAuthority(),
|
|
23
|
+
});
|
|
24
|
+
if (admission.status === "retained") {
|
|
25
|
+
using file = admission.file;
|
|
26
|
+
const outcome = file.remove(); // explicit; using alone does NOT delete
|
|
27
|
+
// outcome.persistence is always "not-proven".
|
|
28
|
+
producer.recordRetirementObservation(outcome);
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The producer must capture its expected identity, generation and bytes during its
|
|
33
|
+
own creation/ownership protocol. Reading an arbitrary current pathname immediately
|
|
34
|
+
before admission does **not** authenticate the producer. `Number` identities,
|
|
35
|
+
zero/unknown identities, negative/overflowing identities, and malformed digests
|
|
36
|
+
are rejected. A full opaque native volume/file ID is included in the receipt;
|
|
37
|
+
no native handle or numeric descriptor is exposed. Receipts are immutable facts,
|
|
38
|
+
not transferable authority.
|
|
39
|
+
|
|
40
|
+
## Public types
|
|
41
|
+
|
|
42
|
+
- `RetainFileInDirectoryOptions`: immutable admission inputs and synchronous authority.
|
|
43
|
+
- `RetainedFileExpected`: exact producer identity, write generation and expected digest.
|
|
44
|
+
- `RetainedFileAdmission`: a retained `RetainedFile` or a refusal/settlement result.
|
|
45
|
+
- `RetainedFileReceipt`: immutable admitted facts, never reusable authority.
|
|
46
|
+
- `RetainedFileResult`: separate disposition, namespace, resources and persistence facts.
|
|
47
|
+
- `RetainedFileIssue`: phase, native code/message and optional original authority cause.
|
|
48
|
+
|
|
49
|
+
## Admission and mutation custody
|
|
50
|
+
|
|
51
|
+
The directory's ancestry is opened component by component without following
|
|
52
|
+
reparse points, with delete sharing denied. Its exact expected parent identity
|
|
53
|
+
and canonical path are checked. The file is opened relative to that retained
|
|
54
|
+
parent, with write/delete sharing denied. Existing writer handles refuse
|
|
55
|
+
admission; a read-oplock grant also excludes preexisting writable mapped
|
|
56
|
+
sections. That oplock request is cancelled and joined before admission returns,
|
|
57
|
+
while the no-write-sharing file handle remains open. No detached request remains.
|
|
58
|
+
|
|
59
|
+
The opened file must match the expected exact NTFS identity, single-link regular
|
|
60
|
+
type, write/change generation, size and SHA-256. Names with stream/device syntax,
|
|
61
|
+
trailing-dot/space aliases and observed named data streams are unsupported.
|
|
62
|
+
Readonly attributes and ACL denials are not repaired or overridden. Verification
|
|
63
|
+
is synchronous and bounded by `maxBytes` (default 16 MiB, maximum 64 MiB).
|
|
64
|
+
|
|
65
|
+
The original producer must retain **exclusive mutation custody for the entire
|
|
66
|
+
file**, including alternate streams, attributes, security and hardlink creation.
|
|
67
|
+
Windows read/write sharing is per-stream; it is not an application lock over
|
|
68
|
+
all possible aliases. The helper rejects observed alternate streams and changed
|
|
69
|
+
generations, but does not turn a point-in-time check into exclusion of concurrent
|
|
70
|
+
alias/attribute operations. The synchronous `assertBeforeMutation` must check
|
|
71
|
+
that this original authority is still current. Callers unable to establish that
|
|
72
|
+
custody must not call `remove`. Privileged/raw-volume/kernel modifications are
|
|
73
|
+
outside this capability's threat model.
|
|
74
|
+
|
|
75
|
+
`remove()` admits authority once, revalidates the retained object, sets native
|
|
76
|
+
handle disposition, closes the file, observes the name under the still-retained
|
|
77
|
+
parent, then closes ancestry. No pathname is passed to unlink. Ordinary
|
|
78
|
+
`dispose()` and `[Symbol.dispose]()` only close resources. Asynchronous authority
|
|
79
|
+
callbacks and reentrancy are refused; even caught reentrancy poisons that attempt.
|
|
80
|
+
Repeated settlement returns the original receipt without another mutation or
|
|
81
|
+
another authority call. A copied receipt cannot be used to remove a replacement.
|
|
82
|
+
`[Symbol.dispose]()` throws `FsSafeError` with the complete result in
|
|
83
|
+
`error.details.result` if resource settlement is uncertain; `dispose()` returns
|
|
84
|
+
that result directly. GC closes only and produces no settlement receipt; use
|
|
85
|
+
explicit disposal.
|
|
86
|
+
|
|
87
|
+
## Read the facts separately
|
|
88
|
+
|
|
89
|
+
| Field | Meaning |
|
|
90
|
+
| --- | --- |
|
|
91
|
+
| `status` | `unsupported`, `not-attempted`, `preserved-mismatch`, `disposition-accepted`, `name-absent-after-settlement`, `failed`, or `indeterminate`. |
|
|
92
|
+
| `disposition` | Whether native deletion was unattempted, accepted, rejected, or indeterminate. |
|
|
93
|
+
| `namespace` | A post-file-close observation: absent, original, foreign, unknown, or not observed. Absence alone never authenticates prior deletion. |
|
|
94
|
+
| `resources` | `closed` or `close-failed`; operation and close errors are retained together. An uncertain close is never retried against a possibly recycled handle. |
|
|
95
|
+
| `persistence` | Always `not-proven`; no namespace persistence barrier was performed. |
|
|
96
|
+
|
|
97
|
+
An accepted disposition can still have an unknown/foreign/original namespace
|
|
98
|
+
observation or failed close. A later replacement may exist even after observed
|
|
99
|
+
absence. Existing read handles can retain access to old bytes after the name is
|
|
100
|
+
absent. Namespace observation does not certify all foreign handles are closed.
|
|
101
|
+
An unexpected native binding failure is indeterminate, not success.
|
|
102
|
+
|
|
103
|
+
## No persistence or service-transaction guarantee
|
|
104
|
+
|
|
105
|
+
This API does not flush a volume, implement a journal, certify power-loss-safe
|
|
106
|
+
unlink, or issue an application dependency release. Process-termination tests
|
|
107
|
+
establish process-owned handle lifetime only, not crash/storage durability.
|
|
108
|
+
|
|
109
|
+
An update transaction must independently qualify either a real namespace
|
|
110
|
+
persistence barrier or original-owned durable recovery with correct ordering,
|
|
111
|
+
generation binding and restart consumption. Until then it must retain its
|
|
112
|
+
uncertain recovery/dependency state. Successful removal of one payload does not
|
|
113
|
+
establish a committed multi-file transaction or justify deleting its receipt.
|
package/docs/root.md
CHANGED
|
@@ -68,7 +68,10 @@ fs.walk(rel, options) // root-bounded AsyncIterable<{ relativePath, kin
|
|
|
68
68
|
|
|
69
69
|
`walk()` is the incremental, root-bounded recursive scan. It supports entry and
|
|
70
70
|
depth budgets, cancellation, and `symlinkPolicy: "skip" |
|
|
71
|
-
"follow-within-root"
|
|
71
|
+
"follow-within-root" | "include"`. Include mode returns links as
|
|
72
|
+
`{ relativePath, kind: "symlink", size }` without resolving or entering their
|
|
73
|
+
targets, including dangling and outside-root links. `size` describes the link,
|
|
74
|
+
not its target. With an entry budget, sorted walks prepare small metadata
|
|
72
75
|
batches within the remaining budget; unbounded sorted walks reuse the full
|
|
73
76
|
directory snapshot.
|
|
74
77
|
The default `order: "sorted"` enumerates and sorts each directory's names;
|
|
@@ -92,6 +95,8 @@ Directory reads remain fail-fast by default. With
|
|
|
92
95
|
`{ relativePath, kind: "directory-error", size: 0, error }` and continues with
|
|
93
96
|
the remaining tree. That policy also covers identity-check failures after an
|
|
94
97
|
awaited filter, while callback failures always reject.
|
|
98
|
+
In include mode, a directory that becomes a symlink before descent is a
|
|
99
|
+
`path-mismatch` directory error; it is never silently omitted or followed.
|
|
95
100
|
See [Directory walking](walk.md) for the pure-Node guarantees and the contrast
|
|
96
101
|
with the standalone best-effort walkers.
|
|
97
102
|
|
package/docs/security-model.md
CHANGED
|
@@ -232,13 +232,16 @@ The library does not modify or constrain the global Node.js `fs` namespace, and
|
|
|
232
232
|
|
|
233
233
|
| Mechanism | Reported containment | Boundary |
|
|
234
234
|
|---|---|---|
|
|
235
|
-
| Linux native | `kernel-atomic` | `openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS)` resolves and opens under the root in one kernel operation. |
|
|
235
|
+
| Linux native with `openat2` | `kernel-atomic` | `openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS)` resolves and opens under the root in one kernel operation. |
|
|
236
|
+
| Linux native without `openat2` | `best-effort` | A no-follow `openat` component walk retains each parent and verifies exact identity associations before and after the final open; all symlink components are rejected. |
|
|
236
237
|
| macOS native | `best-effort` | macOS 15.4 and newer use `O_RESOLVE_BENEATH` first; older kernels use the guarded `openat(O_NOFOLLOW)` component walk. Both verify the opened descriptor with `F_GETPATH`. |
|
|
237
238
|
| Windows native | `best-effort` | Handle-relative `NtCreateFile` rejects reparse points, but this package does not claim a Linux-style atomic beneath guarantee. |
|
|
238
239
|
| JavaScript fallback | `best-effort` | Canonical checks, no-follow opens where Node exposes them, and post-open identity checks form a check-then-use sequence. |
|
|
239
240
|
|
|
240
241
|
The macOS `F_GETPATH` verification is an escape detector, not a race-atomic guarantee. A hostile same-UID process can rename a directory after `O_RESOLVE_BENEATH` or the manual walk and race the post-open sample or a later descriptor-relative mutation. The native result therefore remains `best-effort` on macOS even when the kernel flag is available. No policy decision is attached to these labels; callers can inspect the fact and decide what their own threat model requires.
|
|
241
242
|
|
|
243
|
+
The Linux fallback's identity checks are also detection-based: a directory can be renamed between chain samples or before a later descriptor-relative mutation. It cannot provide atomic resolution, and rejection after a mutating open does not promise rollback. See [Linux without openat2](native.md#linux-without-openat2) for the cached capability probe, conservative symlink rejection, and operations that remain unavailable.
|
|
244
|
+
|
|
242
245
|
The public `OpenResult`, `ReadResult`, and `WritableOpenResult` expose `containment`. Those root APIs currently report `best-effort`; direct native `openBeneath()` reports the platform value above. No-replace publication uses `renameat2(RENAME_NOREPLACE)` on Linux, `renameatx_np(RENAME_EXCL)` on macOS, and `FileRenameInfoEx` with replacement disabled on Windows, but those separate mutation semantics do not upgrade an open result's containment label.
|
|
243
246
|
|
|
244
247
|
## Limitations to keep in mind
|
package/docs/sidecar-lock.md
CHANGED
|
@@ -63,6 +63,8 @@ Exit cleanup tolerates shared managers created by older package copies that lack
|
|
|
63
63
|
|
|
64
64
|
Each new sidecar also carries an internal random ownership token encoded as JSON trailing whitespace. `JSON.parse()` and every payload callback still see exactly the caller-provided object. Only the process that successfully created the sidecar keeps that token as release authority; merely reading token-shaped bytes from disk does not enable this mode. Release compares the in-memory token and exact serialized bytes, and requires the pathname to remain a regular file, instead of requiring an opened descriptor and pathname lookup to report the same inode identity. This preserves ownership checks on filesystems such as Docker Desktop VirtioFS where those two views can legitimately differ. Sidecars created by older releases have no token and retain the legacy identity-plus-content check. If Windows reports a zero device or inode for either identity observation, that check is inconclusive and removal is skipped.
|
|
65
65
|
|
|
66
|
+
Last-chance raw-lock exit cleanup requires known Windows device and inode values from both pathname observations and the opened descriptor. A zero value leaves the sidecar in place, including for token-owned locks. Known descriptor/path identity differences remain supported; a pathname identity change during the read still prevents cleanup.
|
|
67
|
+
|
|
66
68
|
The raw sidecar bytes are not a canonical JSON representation: tools that trim or rewrite the trailing whitespace invalidate the ownership token, so release leaves the changed sidecar in place and fails closed. The token distinguishes cooperating acquisitions; it is not a secret and does not make pathname compare-and-remove atomic against a hostile process that can replace files outside the lock protocol.
|
|
67
69
|
|
|
68
70
|
`release()` propagates an I/O failure that prevents deletion of an unchanged, owned sidecar; it never reports successful cleanup while leaving that lock behind. The handle and manager retain the exact cleanup receipt after a failure, so the same handle can retry `release()` and `manager.drain()` can retry retained cleanup. A changed sidecar remains an ownership mismatch rather than a deletion failure and is left untouched. If both a `withFileLock()` or `withFileLockSync()` callback and release fail, the release error is the primary `SuppressedError.error` and the callback failure remains available as `SuppressedError.suppressed`. Failed asynchronous acquisition cleanup uses the same shape, with the cleanup error primary and the acquisition failure suppressed. On Node runtimes without the global `SuppressedError` constructor, fs-safe returns the equivalent `Error` shape with the same name and properties.
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Retained symlink publication
|
|
3
|
+
description: "Admit and retain an identified staged symlink for no-replace publication and explicit recovery."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retained symlink publication
|
|
7
|
+
|
|
8
|
+
`retainSymlinkInDirectory()` from `@openclaw/fs-safe/advanced` admits an
|
|
9
|
+
**existing** direct-child symlink against caller-captured identity, ownership,
|
|
10
|
+
change time and target. It holds a no-follow descriptor to that inode and its
|
|
11
|
+
parent until cleanup. It does not create a symlink or infer ownership from a
|
|
12
|
+
same-target pathname observation. This dedicated lifecycle does not relax
|
|
13
|
+
`Root.move()` or regular-file staging's symlink rejection.
|
|
14
|
+
|
|
15
|
+
The caller owns staging and admission evidence. Create and inspect the stage
|
|
16
|
+
under the application's cooperative writer lock, and keep that lock through
|
|
17
|
+
admission. A historical stat is not a lifetime handle: if peers can recycle
|
|
18
|
+
inodes between capture and admission, identity numbers alone cannot establish
|
|
19
|
+
historical ownership. Once admitted, the retained descriptor prevents inode
|
|
20
|
+
reuse until this owner closes. Failed admission closes descriptors without
|
|
21
|
+
unlinking anything; all staging cleanup remains with the caller.
|
|
22
|
+
|
|
23
|
+
Linux uses `O_PATH | O_NOFOLLOW`; macOS uses metadata-only `O_EVTONLY | O_SYMLINK`
|
|
24
|
+
with nonblocking/no-controlling-terminal flags. A non-following preflight rejects
|
|
25
|
+
observed special files before open; the opened descriptor must still match.
|
|
26
|
+
Windows, native mode off, and missing or older bindings reject before namespace
|
|
27
|
+
mutation. No fallback interpreter, copy, following open, or public descriptor
|
|
28
|
+
is used. Symlinks must have one link and a UTF-8 target; their targets are never
|
|
29
|
+
opened or confined by this API. The application must authorize the target.
|
|
30
|
+
|
|
31
|
+
## Example
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import {
|
|
35
|
+
retainSymlinkInDirectory,
|
|
36
|
+
type StagedSymlinkExpected,
|
|
37
|
+
type PublishedSymlinkReceipt,
|
|
38
|
+
} from "@openclaw/fs-safe/advanced";
|
|
39
|
+
import type { DirectoryReceipt } from "@openclaw/fs-safe/durability";
|
|
40
|
+
|
|
41
|
+
// Application owns stage creation, capture, locking and transaction policy.
|
|
42
|
+
export async function publishInstallerLink(options: {
|
|
43
|
+
directory: DirectoryReceipt;
|
|
44
|
+
stagedBasename: string;
|
|
45
|
+
expected: StagedSymlinkExpected;
|
|
46
|
+
finalBasename: string;
|
|
47
|
+
assertAuthorizedAndCurrent(): void;
|
|
48
|
+
}): Promise<PublishedSymlinkReceipt> {
|
|
49
|
+
await using staged = await retainSymlinkInDirectory({
|
|
50
|
+
directory: options.directory,
|
|
51
|
+
basename: options.stagedBasename,
|
|
52
|
+
expected: options.expected,
|
|
53
|
+
assertBeforeMutation: options.assertAuthorizedAndCurrent,
|
|
54
|
+
});
|
|
55
|
+
return await staged.publish(options.finalBasename);
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`expected` requires exact bigint `dev`, `ino`, `ctimeNs`, integer `uid`
|
|
60
|
+
and `gid`, and `target`. Admission snapshots these values and the parent
|
|
61
|
+
receipt. The frozen returned receipt contains `directory`,
|
|
62
|
+
`temporaryBasename`, `target`, and preparation-time `identity` metadata,
|
|
63
|
+
like [regular-file staging](staged-file.md). Rename may change timestamps;
|
|
64
|
+
the original receipt is never replaced by a post-publication identity.
|
|
65
|
+
|
|
66
|
+
## Lifetime and outcomes
|
|
67
|
+
|
|
68
|
+
- `assertCurrent()` checks the admitted parent pathname, original name, retained
|
|
69
|
+
inode, single-link count, ownership, mode and target before publication.
|
|
70
|
+
- `publish(basename)` accepts a distinct portable direct-child name. It performs
|
|
71
|
+
the synchronous authority assertion, rechecks source and parent, and dispatches
|
|
72
|
+
guarded native no-replace rename. An existing file, symlink or directory is
|
|
73
|
+
never overwritten, and the link is never nested inside a destination directory.
|
|
74
|
+
- Successful dispatch is recorded as `published` **before** fallible postchecks.
|
|
75
|
+
A foreign same-target replacement fails verification without becoming owned.
|
|
76
|
+
`assertPublished()` checks against the still-retained inode.
|
|
77
|
+
- `removePublished()` is an explicit recovery action, not automatic rollback.
|
|
78
|
+
It reasserts authority and removes only an observed matching published link
|
|
79
|
+
through the retained original parent. It returns `removed`, `name-absent`,
|
|
80
|
+
or `preserved`. Its outcome or error is cached: it never retries an uncertain
|
|
81
|
+
unlink or touches a later replacement. It does not restore an old destination.
|
|
82
|
+
- `cleanup()` removes only an unattempted, still-owned stage, then closes both
|
|
83
|
+
handles. Published names are preserved. Indeterminate publication preserves
|
|
84
|
+
both names. Authority rejection during cleanup preserves the stage and still
|
|
85
|
+
closes handles. Repeated cleanup replays the cached result/error without
|
|
86
|
+
touching descriptor numbers.
|
|
87
|
+
- `await using` invokes cleanup and raises on a `preserved` outcome. Invocation
|
|
88
|
+
order serializes descriptor work; reentrant calls from the authority callback
|
|
89
|
+
reject. Callbacks must be synchronous; returned thenables are refused.
|
|
90
|
+
|
|
91
|
+
Errors carry `StagedSymlinkFailureDetails` in `FsSafeError.details`: the phase,
|
|
92
|
+
recorded `publication`, and a cleanup receipt when applicable. Publication is
|
|
93
|
+
`not-published`, `published` (with the precaptured receipt), or
|
|
94
|
+
`indeterminate` (with the attempted name). Native errno, including a collision,
|
|
95
|
+
does not rule out a committed remote rename whose response was lost. Only
|
|
96
|
+
pre-dispatch rejection leaves publication `not-published`. After an indeterminate
|
|
97
|
+
result, this owner cannot publish again or remove the possibly published name.
|
|
98
|
+
If inspecting a native rename error itself fails, publication is likewise
|
|
99
|
+
`indeterminate`; the original thrown value remains the reported cause.
|
|
100
|
+
|
|
101
|
+
Cleanup records `temporaryBasename`, `publication`, `resources`
|
|
102
|
+
(`closed` or `close-failed`), and `status` (`removed`, `name-absent`,
|
|
103
|
+
`preserved`, `failed`, or `not-needed`). Failure closes every retained handle
|
|
104
|
+
once and aggregates cleanup/close errors. A failed explicit recovery unlink is
|
|
105
|
+
reported to its caller and is not converted into successful recovery by cleanup.
|
|
106
|
+
Thrown values whose error metadata cannot be inspected are reported as
|
|
107
|
+
`helper-failed` with the original value as `cause`; cleanup and recovery still
|
|
108
|
+
cache their terminal error without retrying callbacks or descriptor closes.
|
|
109
|
+
|
|
110
|
+
## Limits
|
|
111
|
+
|
|
112
|
+
This is directory-relative targeting, **not CAS**. Identity checks followed by
|
|
113
|
+
rename or unlink are not atomic conditional mutations. An uncooperative writer
|
|
114
|
+
can replace a child in the final syscall gap; observed substitutes are preserved,
|
|
115
|
+
but callers must coordinate writers to exclude that gap. The retained parent
|
|
116
|
+
prevents redirection into a replacement parent; a parent move can still cause
|
|
117
|
+
publication in the moved original and a `published` postcheck failure.
|
|
118
|
+
|
|
119
|
+
No receipt promises crash durability or recovery after process death, arbitrary
|
|
120
|
+
renames, permission revocation or I/O failure. This API does not sync directories
|
|
121
|
+
or persist descriptors. Caller journals, state capture, conditional restoration,
|
|
122
|
+
durability, and transaction settlement remain separate requirements. Keep the
|
|
123
|
+
owner alive until the application's publication or recovery decision settles.
|
package/docs/store.md
CHANGED
|
@@ -62,7 +62,9 @@ const pending = await loadPendingJsonDurableQueueEntries({ queueDir, tempPrefix:
|
|
|
62
62
|
|
|
63
63
|
`id` must be a single safe path segment: non-empty, not dot-prefixed, and made
|
|
64
64
|
from letters, numbers, `_`, `-`, and `.`. Slashes, backslashes, NUL bytes, `.`,
|
|
65
|
-
and
|
|
65
|
+
`..`, and Windows reserved device names such as `CON`, `NUL`, and `COM1` are
|
|
66
|
+
rejected on every platform so queue IDs remain portable. Temporary filenames
|
|
67
|
+
continue to suffix reserved stems (for example, `CON.txt` becomes `CON_.txt`).
|
|
66
68
|
|
|
67
69
|
Use `ackJsonDurableQueueEntry()` after durable processing succeeds and
|
|
68
70
|
`moveJsonDurableQueueEntryToFailed()` when the caller wants to quarantine an
|
package/docs/temp.md
CHANGED
|
@@ -22,10 +22,11 @@ substitutions; the compatible JavaScript fallback has the narrower race
|
|
|
22
22
|
contract documented below.
|
|
23
23
|
|
|
24
24
|
On POSIX, workspace creation verifies the supplied root and its canonical
|
|
25
|
-
ancestors before creating a child.
|
|
26
|
-
effective user
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
ancestors before creating a child. The supplied root must be owned by the
|
|
26
|
+
effective user and must not be group/world writable, even with the sticky bit.
|
|
27
|
+
Use a private per-user directory rather than supplying a shared `/tmp` directly.
|
|
28
|
+
Ancestors must be owned by the effective user or root; group/world-writable
|
|
29
|
+
ancestors must have the sticky bit. Foreign-owned directories and non-sticky writable ancestors reject
|
|
29
30
|
with `not-owned` or `insecure-permissions`; unavailable effective-user identity
|
|
30
31
|
rejects with `permission-unverified`. Existing supplied directories keep their
|
|
31
32
|
permissions. Missing root components are created at `0o700` and initialized
|
|
@@ -34,6 +35,25 @@ mode, correction uses a verified directory descriptor.
|
|
|
34
35
|
An initial mode-descriptor admission error is preserved if closing that rejected
|
|
35
36
|
descriptor also fails; close failures after successful admission remain reportable.
|
|
36
37
|
|
|
38
|
+
On Linux, non-identity `/proc/self/uid_map` and `/proc/self/gid_map` evidence
|
|
39
|
+
permits ancestors whose UID and GID equal unmapped kernel overflow IDs,
|
|
40
|
+
provided the same ancestor mode checks pass.
|
|
41
|
+
These owners are classified as **unmapped**, not verified root owners:
|
|
42
|
+
[Linux maps all unmapped owners to overflow IDs](https://man7.org/linux/man-pages/man7/user_namespaces.7.html).
|
|
43
|
+
Supporting systemd user services with `PrivateUsers=true` therefore trusts the
|
|
44
|
+
host directory hierarchy against unmapped host peers who own an ancestor and
|
|
45
|
+
can rename it. Sticky world-writable ancestors remain admitted because host
|
|
46
|
+
`/tmp` and `PrivateTmp` appear unmapped under `PrivateUsers`; refusing them would
|
|
47
|
+
disable the default system-temp layout even with a private per-user leaf root.
|
|
48
|
+
An unmapped host owner of such a sticky ancestor could rename its children,
|
|
49
|
+
but a normal host's `/tmp` is root-owned by construction. Mapped foreign owners
|
|
50
|
+
still reject, and this exception never applies to the supplied root or newly
|
|
51
|
+
created workspace. Both maps and mapped-owner exclusions are checked afresh
|
|
52
|
+
whenever admission relies on unmapped ownership, including identity replay;
|
|
53
|
+
unavailable namespace evidence leaves admission unchanged.
|
|
54
|
+
The first admitted unmapped ancestor emits `FS_SAFE_UNMAPPED_TEMP_ANCESTOR`
|
|
55
|
+
through Node's warning event. The warning contains no caller paths.
|
|
56
|
+
|
|
37
57
|
For an already existing canonical root, discovery retains only its immutable
|
|
38
58
|
exact identity. Cleanup-parent retention is provisional: after any native
|
|
39
59
|
capability probe, creation captures and validates the complete ancestry,
|
package/docs/testing.md
CHANGED
|
@@ -1,5 +1,86 @@
|
|
|
1
1
|
# Testing
|
|
2
2
|
|
|
3
|
+
## Manual watch stress campaign
|
|
4
|
+
|
|
5
|
+
Build from the exact revision being qualified with `pnpm install --frozen-lockfile`,
|
|
6
|
+
`pnpm native:build`, and `pnpm build`, then run on a disposable machine:
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
node scripts/watch-stress.mjs --scenario all
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Individual scenario names are `scale`, `fanout`, `churn`, `lifecycle`,
|
|
13
|
+
`adversarial`, `limits`, `idle`, and `soak`. The runner uses plain Node and no
|
|
14
|
+
additional dependencies. Each scenario prints one JSON result line; progress
|
|
15
|
+
goes to stderr. `all` isolates scenarios in child processes and stops at the
|
|
16
|
+
first failure. This suite is manual and is not part of per-PR CI.
|
|
17
|
+
|
|
18
|
+
Run `node scripts/watch-stress.mjs --scenario oracle-selftest` first to check
|
|
19
|
+
that a poisoned cache fails comparison and can recover only after invalidation.
|
|
20
|
+
All fixture Roots live in `os.tmpdir()`. The consumer cache refreshes only from
|
|
21
|
+
`onInvalidate`, using guarded Root reads of invalidated paths/scopes. After
|
|
22
|
+
quiescence and a fresh `reconcile()`, checkpoints compare it with an independent
|
|
23
|
+
filesystem walk, including file-content hashes. A mismatch is a failure, with
|
|
24
|
+
no comparison retry or checkpoint-triggered cache refresh. Transient guarded
|
|
25
|
+
read failures retain already-invalidated consumer work and settle at 25 ms
|
|
26
|
+
intervals, with a 120-second flush deadline; these errors are counted in results.
|
|
27
|
+
|
|
28
|
+
Scale uses 50,000 files in 2,000 child directories. Fan-out checks 64 and 256
|
|
29
|
+
distinct Roots, one shared hub thread, and return to the warmed handle baseline.
|
|
30
|
+
Churn first creates 1,024 entries while JavaScript is blocked to exceed the
|
|
31
|
+
default 256-path detail budget, then runs at least five minutes with persistent
|
|
32
|
+
differences across 10,000-mutation batches and isolated edit latency measurements.
|
|
33
|
+
Lifecycle performs 10,000 ready/close cycles plus admission
|
|
34
|
+
cancellation, close-during-ready, 1,000 scope replacements, and callback-close.
|
|
35
|
+
Adversarial cases exercise Root swaps, outside symlinks, recursive deletion and
|
|
36
|
+
10,000 same-name create/delete pairs. Soak runs 30 minutes, checking the oracle
|
|
37
|
+
each minute. Linux needs passwordless `sudo` for the limits scenario; it lowers
|
|
38
|
+
`fs.inotify.max_user_watches` to 1 in a child shell with a restoration trap and
|
|
39
|
+
verifies restoration. Never run that scenario on a shared production host.
|
|
40
|
+
|
|
41
|
+
RSS limits are fixed before execution: 512 MiB peak for churn/soak, at most
|
|
42
|
+
64 MiB growth after warmup, and at most 32 MiB lifecycle growth after 3,000
|
|
43
|
+
cycles. Reports include samples and fitted slopes. Idle runs for ten minutes
|
|
44
|
+
with 16 subscriptions and a one-hour reconciliation interval to isolate native
|
|
45
|
+
hub wakeups, requiring less than 1% of one CPU and, on Linux, at most 30 hub
|
|
46
|
+
context switches. macOS captures `ps -M`; Windows captures PowerShell thread
|
|
47
|
+
CPU time and handle counts. macOS descriptor counts use `lsof`.
|
|
48
|
+
|
|
49
|
+
The macOS limits case also exercises injected UserDropped/KernelDropped flags
|
|
50
|
+
through the native decoder and labels these as synthetic. Natural FSEvents drop
|
|
51
|
+
flags are not independently observable through the current public batch. Windows
|
|
52
|
+
records native overflow and recovery, but the shared batch does not distinguish
|
|
53
|
+
RDCW kernel-buffer loss from bounded native queue loss; a Windows qualification
|
|
54
|
+
must retain that limitation rather than call it proved kernel overflow.
|
|
55
|
+
|
|
56
|
+
## Linux openat2 fallback
|
|
57
|
+
|
|
58
|
+
Build the host addon and package first. The test hook is cached with the native
|
|
59
|
+
capability probe; set it before starting the process, rather than changing it
|
|
60
|
+
between tests in one process:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pnpm native:build
|
|
64
|
+
pnpm build
|
|
65
|
+
FS_SAFE_TEST_NO_OPENAT2=1 FS_SAFE_NATIVE_MODE=require pnpm test test/linux-openat2-fallback.test.ts test/root-move-noreplace.test.ts test/root-move-native-integration.test.ts test/native-write-containment.test.ts
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
On Linux, the seccomp harness also exercises the real syscall failure without
|
|
69
|
+
the environment hook. It needs a C compiler and permission to install an
|
|
70
|
+
unprivileged seccomp filter; it affects only its child process:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
cc test/fixtures/deny-openat2.c -o /tmp/fs-safe-deny-openat2
|
|
74
|
+
/tmp/fs-safe-deny-openat2 ENOSYS node test/fixtures/linux-openat2-fallback.mjs "$PWD/native/fs-safe-native.linux-x64-gnu.node"
|
|
75
|
+
/tmp/fs-safe-deny-openat2 EPERM node test/fixtures/linux-openat2-fallback.mjs "$PWD/native/fs-safe-native.linux-x64-gnu.node"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Use the matching native artifact filename on other Linux architectures/libcs.
|
|
79
|
+
The fixture proves nested moves, collisions, read/write, traversal, symlink and
|
|
80
|
+
hardlink rejection, cached selection, and `helper-unavailable` for strict
|
|
81
|
+
bounded cleanup. Bounded-cleanup success tests require real `openat2`; run the
|
|
82
|
+
full suite with the environment hook unset.
|
|
83
|
+
|
|
3
84
|
`@openclaw/fs-safe/test-hooks` exposes test-only injection points. Registration
|
|
4
85
|
is allowed only when `process.env.NODE_ENV === "test"` or
|
|
5
86
|
`process.env.VITEST === "true"`; registering a non-empty hook set elsewhere
|
|
@@ -158,6 +239,13 @@ For tests that need a private temp workspace, [`withTempWorkspace`](temp.md) mak
|
|
|
158
239
|
|
|
159
240
|
## Repo test shards
|
|
160
241
|
|
|
242
|
+
On macOS, after building the native addon, run the native watch cleanup allocation
|
|
243
|
+
regression with `MallocStackLogging=1 node --expose-gc scripts/watch-cleanup-leak-proof.mjs`.
|
|
244
|
+
It compares `leaks` results before and after 100 and 1,000 subscription cycles,
|
|
245
|
+
requiring zero growth in leaked allocations. Allocation stacks are saved under
|
|
246
|
+
`.artifacts/watch-cleanup-leaks`. The native macOS CI lane runs this short proof;
|
|
247
|
+
the full stress campaign remains manual.
|
|
248
|
+
|
|
161
249
|
Run the full local gate before handoff:
|
|
162
250
|
|
|
163
251
|
```sh
|
package/docs/types.md
CHANGED
|
@@ -4,6 +4,12 @@ The types most callers reach for. Shared data shapes are exported from `@opencla
|
|
|
4
4
|
|
|
5
5
|
For atomic replacement, `ReplaceFileAtomicFileSystem` and `ReplaceFileAtomicSyncFileSystem` are exported from `@openclaw/fs-safe/atomic`. Async adapters use `chmod()` on the `FileHandle` returned by their required `open()` operation. The synchronous type adds optional `fchmodSync(fd, mode)`; custom sync adapters that explicitly request `mode` or `preserveExistingMode` must implement it. See [Atomic writes](atomic.md#test-injection).
|
|
6
6
|
|
|
7
|
+
`ReplaceFileAtomicDestinationState` is also exported from that subpath. It is a
|
|
8
|
+
readonly union of `{ state: "removed", path }` and
|
|
9
|
+
`{ state: "writing" | "published", path, dev: bigint, ino: bigint }`. Both atomic
|
|
10
|
+
replacement option types accept synchronous `assertBeforeMutation` and
|
|
11
|
+
`onDestinationState` callbacks. See [authority and destination state](atomic.md#atomic-write-authority-and-destination-state).
|
|
12
|
+
|
|
7
13
|
```ts
|
|
8
14
|
import type {
|
|
9
15
|
BasePathOptions,
|
package/docs/walk.md
CHANGED
|
@@ -107,11 +107,32 @@ Unreadable directories are skipped rather than throwing, but every skipped direc
|
|
|
107
107
|
`Root.walk(rel, options)` is the root-bounded counterpart to these standalone
|
|
108
108
|
inventory helpers. It yields `{ relativePath, kind, size }` incrementally and
|
|
109
109
|
accepts `maxDepth`, `maxEntries`, `symlinkPolicy: "skip" |
|
|
110
|
-
"follow-within-root"`, `order: "sorted" | "filesystem"`, and an `AbortSignal`. The default budget behavior yields
|
|
110
|
+
"follow-within-root" | "include"`, `order: "sorted" | "filesystem"`, and an `AbortSignal`. The default budget behavior yields
|
|
111
111
|
one `kind: "truncated"` marker and ends; pass `limitBehavior: "throw"` for a
|
|
112
112
|
typed `FsSafeError("too-large")` instead.
|
|
113
113
|
|
|
114
114
|
For followed symlinks, both `kind` and `size` describe the resolved target.
|
|
115
|
+
With `symlinkPolicy: "include"`, links retain `kind: "symlink"` and their own
|
|
116
|
+
size. Targets are neither resolved nor visited; dangling and outside-root links
|
|
117
|
+
are included. Filters receive these entries, and links consume the same entry
|
|
118
|
+
budget as other names. A directory replaced by a symlink after observation
|
|
119
|
+
fails with `path-mismatch` before descent, or produces a `directory-error`
|
|
120
|
+
entry when `onDirectoryError` is `"skip-and-report"`.
|
|
121
|
+
The starting directory retains existing Root path resolution; include mode
|
|
122
|
+
controls the entries beneath that directory.
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
for await (const entry of capability.walk("", { symlinkPolicy: "include" })) {
|
|
126
|
+
if (entry.kind === "symlink") reportLink(entry.relativePath);
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Existing skip/follow calls keep their result types without a symlink variant.
|
|
131
|
+
For explicitly annotated include-mode values, use `RootWalkOptions<"include">`
|
|
132
|
+
and `RootWalkEntry<"include">`. `RootWalkSymlinkPolicy` describes all
|
|
133
|
+
three policies when the policy is selected dynamically; the unparameterized
|
|
134
|
+
entry and options types retain their previous shapes. Use
|
|
135
|
+
`RootWalkOptions<RootWalkSymlinkPolicy>` for a dynamically selected policy.
|
|
115
136
|
|
|
116
137
|
The caller's starting path retains Root home shorthand: `~` and `~/dir` expand
|
|
117
138
|
the home directory when iteration starts and must resolve inside the Root.
|