@openclaw/fs-safe 0.8.6 → 0.10.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 +93 -0
- package/README.md +17 -0
- package/dist/advanced.d.ts +1 -0
- package/dist/advanced.d.ts.map +1 -1
- package/dist/advanced.js +1 -0
- package/dist/archive-crc32.d.ts.map +1 -1
- package/dist/archive-crc32.js +6 -1
- package/dist/archive-deadline.d.ts.map +1 -1
- package/dist/archive-deadline.js +3 -4
- package/dist/archive-durability.d.ts +24 -0
- package/dist/archive-durability.d.ts.map +1 -0
- package/dist/archive-durability.js +180 -0
- package/dist/archive-gzip-tail.d.ts +2 -0
- package/dist/archive-gzip-tail.d.ts.map +1 -1
- package/dist/archive-gzip-tail.js +22 -4
- package/dist/archive-input.js +4 -4
- package/dist/archive-kind.d.ts.map +1 -1
- package/dist/archive-kind.js +3 -2
- package/dist/archive-merge.d.ts +1 -0
- package/dist/archive-merge.d.ts.map +1 -1
- package/dist/archive-merge.js +42 -9
- package/dist/archive-native.d.ts +1 -0
- package/dist/archive-native.d.ts.map +1 -1
- package/dist/archive-native.js +4 -2
- package/dist/archive-options.d.ts +2 -0
- package/dist/archive-options.d.ts.map +1 -1
- package/dist/archive-parser.wasm +0 -0
- package/dist/archive-read.d.ts.map +1 -1
- package/dist/archive-read.js +72 -70
- package/dist/archive-staging.d.ts +1 -1
- package/dist/archive-staging.d.ts.map +1 -1
- package/dist/archive-staging.js +24 -19
- package/dist/archive-tar-stream.d.ts +11 -4
- package/dist/archive-tar-stream.d.ts.map +1 -1
- package/dist/archive-tar-stream.js +20 -8
- package/dist/archive-tar-wasm.d.ts.map +1 -1
- package/dist/archive-tar-wasm.js +2 -1
- package/dist/archive.d.ts.map +1 -1
- package/dist/archive.js +9 -4
- package/dist/bounded-read.d.ts +7 -0
- package/dist/bounded-read.d.ts.map +1 -1
- package/dist/bounded-read.js +73 -43
- package/dist/clone-metadata.d.ts +19 -0
- package/dist/clone-metadata.d.ts.map +1 -0
- package/dist/clone-metadata.js +32 -0
- package/dist/copy-file-input.d.ts +23 -0
- package/dist/copy-file-input.d.ts.map +1 -0
- package/dist/copy-file-input.js +91 -0
- package/dist/copy-policy.d.ts +3 -0
- package/dist/copy-policy.d.ts.map +1 -0
- package/dist/copy-policy.js +8 -0
- package/dist/copy-publication.d.ts +15 -0
- package/dist/copy-publication.d.ts.map +1 -0
- package/dist/copy-publication.js +30 -0
- package/dist/copy-tree-portable.d.ts +9 -0
- package/dist/copy-tree-portable.d.ts.map +1 -0
- package/dist/copy-tree-portable.js +191 -0
- package/dist/copy.d.ts +16 -0
- package/dist/copy.d.ts.map +1 -0
- package/dist/copy.js +123 -0
- package/dist/directory-mode-node.d.ts.map +1 -1
- package/dist/directory-mode-node.js +4 -4
- package/dist/durability.d.ts +1 -1
- package/dist/durability.d.ts.map +1 -1
- package/dist/error-detail.d.ts.map +1 -1
- package/dist/error-detail.js +4 -1
- package/dist/file-hash.d.ts +6 -2
- package/dist/file-hash.d.ts.map +1 -1
- package/dist/file-hash.js +49 -12
- package/dist/file-store-sync-write.d.ts +1 -0
- package/dist/file-store-sync-write.d.ts.map +1 -1
- package/dist/file-store-sync-write.js +9 -6
- package/dist/file-store.d.ts +4 -0
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +10 -1
- package/dist/filename.d.ts.map +1 -1
- package/dist/filename.js +2 -12
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +1 -2
- package/dist/guarded-mkdir.d.ts +1 -0
- package/dist/guarded-mkdir.d.ts.map +1 -1
- package/dist/guarded-mkdir.js +1 -0
- package/dist/guarded-mutation.d.ts +2 -0
- package/dist/guarded-mutation.d.ts.map +1 -1
- package/dist/guarded-mutation.js +9 -5
- package/dist/home-dir.d.ts.map +1 -1
- package/dist/home-dir.js +9 -7
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/install-path.d.ts.map +1 -1
- package/dist/install-path.js +9 -13
- package/dist/json-document-store.d.ts +3 -0
- package/dist/json-document-store.d.ts.map +1 -1
- package/dist/json-document-store.js +1 -0
- package/dist/json-durable-queue-directory.js +3 -3
- package/dist/json-durable-queue-ownership.js +3 -2
- package/dist/json-durable-queue.d.ts.map +1 -1
- package/dist/json-durable-queue.js +31 -27
- package/dist/local-roots.d.ts.map +1 -1
- package/dist/local-roots.js +22 -24
- package/dist/move-path-cleanup.d.ts.map +1 -1
- package/dist/move-path-cleanup.js +4 -3
- package/dist/move-path-stage.d.ts +8 -0
- package/dist/move-path-stage.d.ts.map +1 -0
- package/dist/move-path-stage.js +55 -0
- package/dist/move-path.d.ts.map +1 -1
- package/dist/move-path.js +55 -47
- package/dist/mutation-authority.d.ts +8 -0
- package/dist/mutation-authority.d.ts.map +1 -0
- package/dist/mutation-authority.js +36 -0
- package/dist/native-binding.d.ts +24 -1
- package/dist/native-binding.d.ts.map +1 -1
- package/dist/native-operations.d.ts +1 -1
- package/dist/native-operations.d.ts.map +1 -1
- package/dist/native-operations.js +11 -5
- package/dist/native-pinned-write-windows.d.ts.map +1 -1
- package/dist/native-pinned-write-windows.js +18 -7
- package/dist/native-pinned-write.d.ts.map +1 -1
- package/dist/native-pinned-write.js +1 -0
- package/dist/native-staged-file.d.ts +3 -3
- package/dist/native-staged-file.d.ts.map +1 -1
- package/dist/native-staged-file.js +35 -8
- package/dist/opened-file-failure.d.ts +1 -1
- package/dist/opened-file-failure.d.ts.map +1 -1
- package/dist/opened-file-failure.js +3 -2
- package/dist/path.d.ts.map +1 -1
- package/dist/path.js +3 -1
- package/dist/permissions-windows.d.ts.map +1 -1
- package/dist/permissions-windows.js +38 -48
- package/dist/permissions.js +3 -3
- package/dist/pinned-operation.js +1 -1
- package/dist/pinned-write.d.ts +5 -1
- package/dist/pinned-write.d.ts.map +1 -1
- package/dist/pinned-write.js +50 -30
- package/dist/positional-read.d.ts +9 -0
- package/dist/positional-read.d.ts.map +1 -0
- package/dist/positional-read.js +36 -0
- package/dist/private-temp-workspace.d.ts.map +1 -1
- package/dist/private-temp-workspace.js +9 -3
- package/dist/publish-copy-stage.d.ts +13 -0
- package/dist/publish-copy-stage.d.ts.map +1 -0
- package/dist/publish-copy-stage.js +47 -0
- package/dist/publish-file.d.ts.map +1 -1
- package/dist/publish-file.js +26 -25
- package/dist/replace-file-copy-fallback.d.ts.map +1 -1
- package/dist/replace-file-copy-fallback.js +30 -17
- package/dist/replace-file-copy-source.d.ts.map +1 -1
- package/dist/replace-file-copy-source.js +10 -5
- package/dist/replace-file-temp-owner.d.ts.map +1 -1
- package/dist/replace-file-temp-owner.js +6 -2
- package/dist/replace-file.d.ts.map +1 -1
- package/dist/replace-file.js +8 -3
- package/dist/root-context.d.ts +3 -0
- package/dist/root-context.d.ts.map +1 -1
- package/dist/root-context.js +51 -26
- package/dist/root-directory-list.d.ts +24 -0
- package/dist/root-directory-list.d.ts.map +1 -0
- package/dist/root-directory-list.js +201 -0
- package/dist/root-errors.d.ts +1 -0
- package/dist/root-errors.d.ts.map +1 -1
- package/dist/root-errors.js +3 -0
- package/dist/root-file.d.ts +2 -0
- package/dist/root-file.d.ts.map +1 -1
- package/dist/root-file.js +12 -4
- package/dist/root-impl.d.ts +6 -50
- package/dist/root-impl.d.ts.map +1 -1
- package/dist/root-impl.js +342 -208
- package/dist/root-options.d.ts +66 -0
- package/dist/root-options.d.ts.map +1 -0
- package/dist/root-options.js +18 -0
- package/dist/root-path-existing.d.ts +5 -0
- package/dist/root-path-existing.d.ts.map +1 -1
- package/dist/root-path-existing.js +56 -1
- package/dist/root-path.d.ts +1 -0
- package/dist/root-path.d.ts.map +1 -1
- package/dist/root-path.js +74 -162
- package/dist/root-symlink-policy.d.ts +13 -0
- package/dist/root-symlink-policy.d.ts.map +1 -0
- package/dist/root-symlink-policy.js +34 -0
- package/dist/root-walk.d.ts +5 -4
- package/dist/root-walk.d.ts.map +1 -1
- package/dist/root-walk.js +99 -50
- package/dist/root-write-mode.d.ts.map +1 -1
- package/dist/root-write-mode.js +1 -4
- package/dist/root.d.ts +3 -1
- package/dist/root.d.ts.map +1 -1
- package/dist/secret-file.d.ts +1 -0
- package/dist/secret-file.d.ts.map +1 -1
- package/dist/secret-file.js +1 -0
- package/dist/secret-read-async.js +5 -5
- package/dist/secure-file.d.ts +1 -1
- package/dist/secure-file.d.ts.map +1 -1
- package/dist/secure-file.js +23 -13
- package/dist/sibling-staged-file.js +7 -7
- package/dist/sibling-temp.d.ts.map +1 -1
- package/dist/sibling-temp.js +16 -7
- package/dist/sidecar-lock-acquire.d.ts +1 -1
- package/dist/sidecar-lock-acquire.d.ts.map +1 -1
- package/dist/sidecar-lock-acquire.js +6 -4
- package/dist/sidecar-lock-policy.d.ts.map +1 -1
- package/dist/sidecar-lock-policy.js +5 -3
- package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
- package/dist/sidecar-lock-reclaim.js +16 -4
- package/dist/sidecar-lock.d.ts.map +1 -1
- package/dist/sidecar-lock.js +2 -1
- package/dist/temp-cleanup.d.ts.map +1 -1
- package/dist/temp-cleanup.js +3 -2
- package/dist/temp-target.d.ts.map +1 -1
- package/dist/temp-target.js +9 -4
- package/dist/timing.d.ts +1 -0
- package/dist/timing.d.ts.map +1 -1
- package/dist/timing.js +25 -6
- package/dist/trash.js +2 -2
- package/dist/walk.d.ts.map +1 -1
- package/dist/walk.js +7 -27
- package/dist/windows-owner.d.ts +9 -12
- package/dist/windows-owner.d.ts.map +1 -1
- package/dist/windows-owner.js +24 -58
- package/dist/write-file-handle.d.ts +6 -0
- package/dist/write-file-handle.d.ts.map +1 -0
- package/dist/write-file-handle.js +25 -0
- package/dist/write-open-flags.d.ts.map +1 -1
- package/dist/write-open-flags.js +1 -2
- package/docs/advanced.md +18 -4
- package/docs/archive.md +57 -9
- package/docs/atomic.md +7 -0
- package/docs/contributing.md +7 -0
- package/docs/copy.md +86 -0
- package/docs/durability.md +17 -2
- package/docs/errors.md +1 -1
- package/docs/file-store.md +48 -3
- package/docs/json-store.md +10 -0
- package/docs/local-roots.md +8 -1
- package/docs/native.md +14 -1
- package/docs/permissions.md +20 -10
- package/docs/positional-read.md +63 -0
- package/docs/reading.md +6 -0
- package/docs/root.md +168 -6
- package/docs/secret-file.md +6 -0
- package/docs/security-model.md +14 -0
- package/docs/sidecar-lock.md +2 -0
- package/docs/timing.md +2 -0
- package/docs/types.md +18 -11
- package/docs/walk.md +54 -5
- package/docs/writing.md +25 -5
- package/package.json +16 -11
package/docs/root.md
CHANGED
|
@@ -18,7 +18,8 @@ const fs = await root("/srv/workspace", {
|
|
|
18
18
|
function root(rootDir: string, defaults?: RootDefaults): Promise<Root>;
|
|
19
19
|
|
|
20
20
|
type RootDefaults = {
|
|
21
|
-
|
|
21
|
+
assertBeforeMutation?: () => void; // synchronous caller authority check at mutation dispatch
|
|
22
|
+
durable?: boolean; // fsync write/create/writeJson/createJson/append/copyIn; default true
|
|
22
23
|
hardlinks?: "reject" | "allow"; // refuse files with nlink > 1 on read; defaults to "reject"
|
|
23
24
|
denyMutations?: DenyMutationPolicy; // absolute paths/prefixes mutation methods may not change
|
|
24
25
|
maxBytes?: number; // refuse reads larger than this many bytes; defaults to 16 MiB
|
|
@@ -26,7 +27,8 @@ type RootDefaults = {
|
|
|
26
27
|
mode?: number; // file mode applied to new writes; per-call override available
|
|
27
28
|
nonBlockingRead?: boolean; // compatibility hint; safe opens are already nonblocking where supported
|
|
28
29
|
renameIdentity?: "strict" | "verify-content-with-lock"; // default "strict"
|
|
29
|
-
symlinks?: "reject" | "follow-within-root"; // policy
|
|
30
|
+
symlinks?: "reject" | "follow-within-root" | "follow-parents-within-root"; // read policy
|
|
31
|
+
mutationSymlinks?: "reject" | "follow-parents-within-root"; // opt-in mutation policy
|
|
30
32
|
};
|
|
31
33
|
|
|
32
34
|
type DenyMutationPolicy = {
|
|
@@ -37,7 +39,9 @@ type DenyMutationPolicy = {
|
|
|
37
39
|
|
|
38
40
|
`root()` resolves the directory through the real filesystem. A symlinked input becomes the canonical path; a non-existent root throws `FsSafeError` with code `not-found`, and malformed or non-directory roots throw `invalid-path`.
|
|
39
41
|
|
|
40
|
-
|
|
42
|
+
The root directory is pinned with exact bigint device/inode identities. A changed root rejects subsequent operations; an unknown Windows identity that remains unverifiable after bounded reinspection rejects construction with `path-mismatch`.
|
|
43
|
+
|
|
44
|
+
`defaults` apply to every method on the returned handle. Per-call options on individual methods override the defaults for that call only, except `denyMutations` and `assertBeforeMutation`: deny entries are merged, and the root assertion runs before the per-call assertion. A call cannot clear either root-level restriction.
|
|
41
45
|
|
|
42
46
|
Every `maxBytes` value must be a non-negative safe integer or positive `Infinity`. Zero is an active zero-byte cap; `Infinity` explicitly disables the cap. An omitted or explicitly `undefined` per-call value preserves the configured Root default instead of clearing it.
|
|
43
47
|
|
|
@@ -60,7 +64,12 @@ fs.walk(rel, options) // root-bounded AsyncIterable<{ relativePath, kin
|
|
|
60
64
|
|
|
61
65
|
`walk()` is the incremental, root-bounded recursive scan. It supports entry and
|
|
62
66
|
depth budgets, cancellation, and `symlinkPolicy: "skip" |
|
|
63
|
-
"follow-within-root"`.
|
|
67
|
+
"follow-within-root"`. With an entry budget, sorted walks prepare small metadata
|
|
68
|
+
batches within the remaining budget; unbounded sorted walks reuse the full
|
|
69
|
+
directory snapshot.
|
|
70
|
+
The default `order: "sorted"` enumerates and sorts each directory's names;
|
|
71
|
+
`order: "filesystem"` streams names in filesystem order for bounded work in
|
|
72
|
+
wide directories. Budget exhaustion yields a `"truncated"` marker by
|
|
64
73
|
default or throws `FsSafeError("too-large")` with `limitBehavior: "throw"`.
|
|
65
74
|
Use `entryFilter(entry)` to return `"include"`, `"skip"`, or
|
|
66
75
|
`"skip-subtree"`. `"skip"` omits the current entry but still descends into a
|
|
@@ -111,7 +120,11 @@ fs.ensureRoot(options?) // accepts "" / "." as the root itself
|
|
|
111
120
|
|
|
112
121
|
`write`, `create`, `append`, `writeJson`, and `createJson` accept `mode?: number`; use `0o600` for credentials and other private state. `writeJson` also accepts the same options as `JSON.stringify` plus `trailingNewline?: boolean` (defaults `true` so the file ends in `\n`).
|
|
113
122
|
|
|
114
|
-
|
|
123
|
+
`append` accepts `prependNewlineIfNeeded: true` to separate text from existing
|
|
124
|
+
content when neither side supplies a newline. String data uses its `encoding`
|
|
125
|
+
for the newline check, including UTF-16LE; Buffer data uses a single LF byte.
|
|
126
|
+
|
|
127
|
+
These five methods and `copyIn` also accept `durable?: boolean`: the per-call value overrides
|
|
115
128
|
`Root.defaults.durable`, which defaults to `true` when omitted. An explicitly
|
|
116
129
|
`undefined` per-call value preserves the root default. `durable: false` keeps
|
|
117
130
|
the existing publication behavior, modes, and identity checks but skips file
|
|
@@ -119,7 +132,76 @@ and parent-directory fsync calls. Use it only for reconstructible data: a crash
|
|
|
119
132
|
may lose the write or leave the previous file. See [Writing](writing.md#write-options)
|
|
120
133
|
for platform details.
|
|
121
134
|
|
|
122
|
-
`copyIn`
|
|
135
|
+
`copyIn` accepts a `RootCopySource`: a trusted absolute source path or a file
|
|
136
|
+
within another Root. The guarded form supplies `root` with only its `open` and
|
|
137
|
+
`stat` read capabilities, plus `relativePath`:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
const source = await root("/srv/templates");
|
|
141
|
+
const destination = await root("/srv/workspace");
|
|
142
|
+
await destination.copyIn("config/settings.json", {
|
|
143
|
+
root: source,
|
|
144
|
+
relativePath: "config/settings.json",
|
|
145
|
+
}, {
|
|
146
|
+
overwrite: false,
|
|
147
|
+
clone: "auto",
|
|
148
|
+
mode: 0o600,
|
|
149
|
+
signal: AbortSignal.timeout(30_000),
|
|
150
|
+
});
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The source Root applies its read policies, including confinement and symlink
|
|
154
|
+
handling. `sourceHardlinks` overrides its hardlink policy only when supplied;
|
|
155
|
+
otherwise the source Root default is retained. The admitted source
|
|
156
|
+
descriptor stays open through copying and source-identity verification; copying
|
|
157
|
+
does not consume its current file position. Both forms enforce `maxBytes` while
|
|
158
|
+
reading, including when a file grows after admission, and use bounded buffers.
|
|
159
|
+
Copies have independent file data; changing either file cannot change the other.
|
|
160
|
+
Set `preserveSourceMode: true` to select the mode from the admitted source
|
|
161
|
+
descriptor. An explicit numeric `mode`, including `Root.defaults.mode`, takes
|
|
162
|
+
precedence. By default, copying retains the existing destination-mode rules.
|
|
163
|
+
The operation verifies source identity, not a coherent snapshot of concurrent
|
|
164
|
+
in-place edits. Keep the source unchanged when snapshot consistency is required.
|
|
165
|
+
|
|
166
|
+
`overwrite` defaults to `true`, preserving the existing replacement behavior.
|
|
167
|
+
With `overwrite: false`, an existing destination produces `already-exists` and
|
|
168
|
+
is never altered. Copying prepares a private sibling file before publishing its
|
|
169
|
+
completed contents. Native mode uses no-replace rename. The guarded JavaScript
|
|
170
|
+
fallback links the completed stage and removes its temporary name in the same
|
|
171
|
+
JavaScript turn; the filesystem must support hardlinks. Other processes can
|
|
172
|
+
briefly observe both names. The source is never hardlinked to the destination.
|
|
173
|
+
|
|
174
|
+
`clone` chooses the file-data transfer strategy through `CopyCloneMode`, shared
|
|
175
|
+
with [`copyTree`](copy.md#api). File copies default to `"never"`; tree copies
|
|
176
|
+
default to `"auto"`:
|
|
177
|
+
|
|
178
|
+
| Value | Behavior |
|
|
179
|
+
| --- | --- |
|
|
180
|
+
| `never` | Copy regular file bytes using reads and writes, without explicit cloning or copy offload. |
|
|
181
|
+
| `auto` | Try native file cloning, then copy offload or ordinary byte copying when cloning is unavailable. |
|
|
182
|
+
| `always` | Require native cloning; fail when the binding or filesystem cannot provide it. |
|
|
183
|
+
|
|
184
|
+
Native file cloning supports APFS and supported Linux filesystems. Windows
|
|
185
|
+
currently uses byte copying for `never` and `auto`; `always` fails. Clone choice
|
|
186
|
+
does not change modes, durability, root confinement, or source and publication
|
|
187
|
+
identity checks. The shared strategy does not replace Root's guarded regular-file
|
|
188
|
+
contract with `copyTree`'s caller-owned immutable-tree and metadata contract.
|
|
189
|
+
|
|
190
|
+
An already aborted `signal` prevents I/O. Cancellation during copying waits for
|
|
191
|
+
admitted reads and native work to settle, then cleans only the owned unpublished
|
|
192
|
+
stage. The final authority check runs before publication. Once publication has
|
|
193
|
+
occurred, later cancellation or verification failure preserves the destination.
|
|
194
|
+
The synchronous optional `onDestinationPublished` callback receives a frozen
|
|
195
|
+
`RootCopyPublicationReceipt` containing `{ path, dev, ino }`, with exact bigint identity immediately after
|
|
196
|
+
publication, before later checks can fail. Callback errors also preserve the
|
|
197
|
+
published file. This receipt records an outcome; it does not authorize removing
|
|
198
|
+
a file that another actor may have edited. Application recovery and cooperative
|
|
199
|
+
locking remain caller-owned.
|
|
200
|
+
|
|
201
|
+
Existing `copyIn` callers must account for completed destinations retained after
|
|
202
|
+
a post-publication source-verification failure, even without the new options.
|
|
203
|
+
Recovery must inspect current destination state rather than assume a rejected
|
|
204
|
+
copy left no file.
|
|
123
205
|
|
|
124
206
|
Root operations that choose a new destination reject a leading Windows
|
|
125
207
|
drive-relative spelling such as `C:name` on every platform. This applies to
|
|
@@ -131,8 +213,59 @@ basename first.
|
|
|
131
213
|
|
|
132
214
|
`openWritable` opens a writable file with options `mode?: number` and `writeMode?: "replace" | "append" | "update"`. `replace` truncates existing files and is the default; `update` keeps existing contents. Use it for streaming output. Prefer `await using` for cleanup.
|
|
133
215
|
|
|
216
|
+
### Live mutation authority
|
|
217
|
+
|
|
218
|
+
All mutation methods accept `assertBeforeMutation?: () => void`. Use it when a
|
|
219
|
+
lease, operation owner, or cancellation state can expire while filesystem
|
|
220
|
+
preparation is awaiting I/O:
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
const controller = new AbortController();
|
|
224
|
+
await fs.write("config.json", "{}\n", {
|
|
225
|
+
assertBeforeMutation: () => controller.signal.throwIfAborted(),
|
|
226
|
+
});
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
The callback runs synchronously after awaited preparation, immediately before
|
|
230
|
+
each Root-owned mutation is dispatched: parent creation, file creation and
|
|
231
|
+
content writes (including private staging and streamed chunks), publication,
|
|
232
|
+
truncation, append, move, and removal. Buffered writes use bounded chunks and
|
|
233
|
+
recheck before every partial-write submission; file removal submits a direct
|
|
234
|
+
unlink request. Native calls that perform multiple filesystem steps are one
|
|
235
|
+
dispatch. No asynchronous wait separates the check
|
|
236
|
+
from that dispatch. A thrown value rejects the operation unchanged; an async
|
|
237
|
+
or thenable-returning callback rejects with `TypeError` before that mutation.
|
|
238
|
+
Synchronous return values are ignored. Callbacks can run multiple times and
|
|
239
|
+
must inspect current authority each time.
|
|
240
|
+
|
|
241
|
+
Already dispatched I/O cannot be revoked. Identity-checked cleanup, final
|
|
242
|
+
permissions, and durability finish under the existing operation owner even
|
|
243
|
+
after authority expires. Sidecar lock acquisition, recovery, and release for
|
|
244
|
+
`renameIdentity: "verify-content-with-lock"` are lock bookkeeping outside this
|
|
245
|
+
callback; content mutations still recheck after the lock is acquired. An
|
|
246
|
+
operation may leave already-created parent directories when a later check
|
|
247
|
+
rejects. A no-op such as `ensureRoot()` on the existing root does not require a
|
|
248
|
+
callback invocation. This is a dispatch fence, not a filesystem transaction or
|
|
249
|
+
a replacement for root confinement.
|
|
250
|
+
|
|
251
|
+
If cleanup also fails or cannot prove ownership of an entry, the existing
|
|
252
|
+
structured cleanup error takes precedence and retains the authority refusal
|
|
253
|
+
as its cause.
|
|
254
|
+
|
|
255
|
+
For `openWritable()`, the callback covers the library's parent creation,
|
|
256
|
+
exclusive creation, and truncation. The returned raw `FileHandle` belongs to
|
|
257
|
+
the caller, which must check authority before its own later writes.
|
|
258
|
+
|
|
134
259
|
All mutation methods accept `denyMutations?: { paths?: string[]; prefixes?: string[] }`. Entries must be absolute paths. `paths` blocks those exact paths; `prefixes` blocks those paths and their descendants. fs-safe preserves path strings exactly and canonicalizes through existing ancestors before comparing, so a symlinked ancestor to a denied location is still denied. Denied mutations throw `FsSafeError` with code `denied-path`. Use this for caller-specific sensitive paths, not as a replacement for the root boundary, symlink, or hardlink checks.
|
|
135
260
|
|
|
261
|
+
All mutation methods also accept `mutationSymlinks`. `"reject"` rejects symlink
|
|
262
|
+
components; `"follow-parents-within-root"` resolves contained parent directory
|
|
263
|
+
aliases but rejects the final component if it is a symlink, including a dangling
|
|
264
|
+
link. Missing parent directories can still be created through a contained alias.
|
|
265
|
+
`move()` applies the policy to both source and destination. An omitted value
|
|
266
|
+
preserves existing behavior, including `remove()` unlinking a final symlink.
|
|
267
|
+
The read-only `symlinks` default does not change mutation behavior.
|
|
268
|
+
|
|
136
269
|
### Inspection (advisory)
|
|
137
270
|
|
|
138
271
|
```ts
|
|
@@ -218,6 +351,35 @@ await fs.readText("config.toml");
|
|
|
218
351
|
await fs.readText("links/current.log", { symlinks: "follow-within-root" });
|
|
219
352
|
```
|
|
220
353
|
|
|
354
|
+
With `follow-within-root`, parent components after a symlink are applied to the
|
|
355
|
+
symlink's resolved target. Reads use that checked canonical path, including
|
|
356
|
+
after home expansion and through `readAbsolute` and `reader`; the default policy still rejects a symlink
|
|
357
|
+
even when a later `..` would hide it in a purely lexical normalization.
|
|
358
|
+
|
|
359
|
+
Use `follow-parents-within-root` when directory aliases are allowed but a final
|
|
360
|
+
file symlink should fail. Set each policy at the root to share that contract
|
|
361
|
+
between reads and mutations:
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
const workspace = await root("/srv/workspace", {
|
|
365
|
+
symlinks: "follow-parents-within-root",
|
|
366
|
+
mutationSymlinks: "follow-parents-within-root",
|
|
367
|
+
});
|
|
368
|
+
await workspace.readText("directory-alias/notes.txt");
|
|
369
|
+
await workspace.write("directory-alias/notes.txt", "updated\n");
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
The library uses the resolved parent for the operation and checks the final
|
|
373
|
+
component again before publication or removal. These checks preserve the existing
|
|
374
|
+
[platform containment guarantees](security-model.md#symlinks-write-side);
|
|
375
|
+
they do not make check-and-rename atomic against another process. Callers do not
|
|
376
|
+
need a separate `realpath()` or final `lstat()` preflight.
|
|
377
|
+
|
|
378
|
+
For methods that accept absolute paths, the same final-component rule applies
|
|
379
|
+
when a path enters the root through an alias outside its lexical spelling. A directory alias may
|
|
380
|
+
lead to a regular file inside the root; an absolute final file or directory
|
|
381
|
+
symlink is rejected before its canonical target replaces the original path.
|
|
382
|
+
|
|
221
383
|
Text helpers default to UTF-8. Pass `encoding` per call to `readText`, `readJson`, `write`, `create`, or `append` when you need another encoding.
|
|
222
384
|
|
|
223
385
|
## Common patterns
|
package/docs/secret-file.md
CHANGED
|
@@ -172,9 +172,15 @@ type WriteSecretFileParams = {
|
|
|
172
172
|
content: string | Uint8Array;
|
|
173
173
|
mode?: number; // file mode for the new file (default PRIVATE_SECRET_FILE_MODE = 0o600)
|
|
174
174
|
dirMode?: number; // mode for the root and intermediate dirs (default PRIVATE_SECRET_DIR_MODE = 0o700)
|
|
175
|
+
durable?: boolean; // default true; false skips file and parent fsync
|
|
175
176
|
};
|
|
176
177
|
```
|
|
177
178
|
|
|
179
|
+
`durable: false` preserves atomic publication, modes, and identity checks while
|
|
180
|
+
skipping file and parent-directory `fsync` calls. Use it only for reconstructible
|
|
181
|
+
data where lower latency matters more than crash-durability; private and JSON
|
|
182
|
+
stores forward their durability policy here.
|
|
183
|
+
|
|
178
184
|
The full POSIX directory mode is asserted on each component along the path: `rootDir`, then any intermediate dirs, then the parent. Existing directories, including another creator's `EEXIST` winner, must already match `dirMode` exactly or the write fails with `insecure-permissions`; they are never chmod-repaired. An explicitly requested directory mode such as `0o2750` preserves its setgid bit. Audit and adjust existing secret directories yourself. The admitted directory guards are retained through traversal and the final writer/lock handoff; a fresh pathname lookup cannot silently authorize a replacement. The caller must still trust the selected root and its owners; matching permission bits alone do not establish that trust.
|
|
179
185
|
|
|
180
186
|
Directory admission and its retained guards use lossless bigint identities, including through private locks and native writes. On Windows, an unknown zero device or inode gets one reinspection that retains known components; a definite mismatch or persistent ambiguity fails with `path-mismatch` rather than authorizing a replacement.
|
package/docs/security-model.md
CHANGED
|
@@ -53,6 +53,11 @@ Every path is resolved against the canonicalized real path of the root, then che
|
|
|
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
|
+
`symlinks: "follow-parents-within-root"` allows contained parent directory aliases
|
|
57
|
+
while rejecting a final symlink, including dangling links. Reads open the checked
|
|
58
|
+
canonical parent plus the final basename with the usual no-follow and identity
|
|
59
|
+
checks, so callers do not need their own parent canonicalization.
|
|
60
|
+
|
|
56
61
|
Guarded root reads compare lossless bigint identities from before open, the opened
|
|
57
62
|
descriptor, the input path, and the canonical target; numeric public `Stats`
|
|
58
63
|
receipts are not used as identity evidence. Unknown Windows device/inode values
|
|
@@ -70,6 +75,15 @@ directory descriptors. Replacement uses descriptor-relative rename just like
|
|
|
70
75
|
no-replace publication, so replacing the parent pathname does not divert the
|
|
71
76
|
mutation.
|
|
72
77
|
|
|
78
|
+
The opt-in `mutationSymlinks` policy applies independently of read policy.
|
|
79
|
+
`"reject"` rejects symlink components; `"follow-parents-within-root"` resolves
|
|
80
|
+
contained directory aliases and rejects final symlinks. Publication checks the
|
|
81
|
+
final component again after awaited staging and parent fences, immediately before
|
|
82
|
+
the rename or exclusive open. These are best-effort symlink checks, not an atomic
|
|
83
|
+
expected-entry/CAS replacement: a concurrent process can still replace the final
|
|
84
|
+
entry between its check and rename. Existing parent containment guarantees remain
|
|
85
|
+
as described below. Omitting `mutationSymlinks` preserves existing mutation behavior.
|
|
86
|
+
|
|
73
87
|
The JavaScript fallback used by `off`, by `auto` when no binding loads, and by
|
|
74
88
|
the explicit `renameIdentity: "verify-content-with-lock"` compatibility policy
|
|
75
89
|
cannot provide that guarantee because Node exposes no `mkdirat` or `renameat`.
|
package/docs/sidecar-lock.md
CHANGED
|
@@ -25,6 +25,8 @@ On natural event-loop shutdown, a globally deduplicated `process.on("beforeExit"
|
|
|
25
25
|
|
|
26
26
|
Always release locks in a `finally` block. Application-managed graceful shutdown can await `release()` or `manager.drain()` before terminating. Explicit `process.exit()`, uncaught failures, crashes, default signal handling, and fatal termination (including `SIGKILL`) do not reliably run asynchronous Root cleanup and may leave sidecars. Recover only after an application-owned liveness policy proves the holder cannot still be writing.
|
|
27
27
|
|
|
28
|
+
Exit cleanup tolerates shared managers created by older package copies that lack reclaim-guard state, and continues through later manager domains. Handler ownership remains first-registration-wins: loading an updated copy does not replace an older copy's registered handler. Restart with the updated copy registering first to obtain the fix. After building, `node scripts/legacy-lock-exit-proof.mjs` checks clean exit, removal of legacy and modern raw locks, and preservation of a retained lock using synthetic temporary files.
|
|
29
|
+
|
|
28
30
|
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.
|
|
29
31
|
|
|
30
32
|
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.
|
package/docs/timing.md
CHANGED
|
@@ -22,6 +22,8 @@ function withTimeout<T>(
|
|
|
22
22
|
|
|
23
23
|
If `timeoutMs` is `0`, negative, `Infinity`, or `NaN`, the helper is a no-op and simply awaits the original promise.
|
|
24
24
|
|
|
25
|
+
Finite delays above Node's single-timer limit (2,147,483,647 ms, about 24.9 days) are scheduled in bounded intervals without expiring early. The timer is still cleared if the wrapped promise settles first.
|
|
26
|
+
|
|
25
27
|
## Examples
|
|
26
28
|
|
|
27
29
|
### Simple ceiling
|
package/docs/types.md
CHANGED
|
@@ -99,6 +99,7 @@ type ReadResult = {
|
|
|
99
99
|
type RenameIdentityPolicy = "strict" | "verify-content-with-lock";
|
|
100
100
|
|
|
101
101
|
type RootDefaults = {
|
|
102
|
+
assertBeforeMutation?: () => void;
|
|
102
103
|
denyMutations?: DenyMutationPolicy;
|
|
103
104
|
durable?: boolean; // default true for write/create/writeJson/createJson/append
|
|
104
105
|
hardlinks?: "reject" | "allow";
|
|
@@ -107,7 +108,8 @@ type RootDefaults = {
|
|
|
107
108
|
mode?: number;
|
|
108
109
|
nonBlockingRead?: boolean;
|
|
109
110
|
renameIdentity?: RenameIdentityPolicy;
|
|
110
|
-
symlinks?: "reject" | "follow-within-root";
|
|
111
|
+
symlinks?: "reject" | "follow-within-root" | "follow-parents-within-root";
|
|
112
|
+
mutationSymlinks?: MutationSymlinkPolicy;
|
|
111
113
|
};
|
|
112
114
|
|
|
113
115
|
type DenyMutationPolicy = {
|
|
@@ -121,20 +123,20 @@ type RootOptions = {
|
|
|
121
123
|
};
|
|
122
124
|
```
|
|
123
125
|
|
|
124
|
-
`RootDefaults` is what `root(rootDir, defaults)` accepts. See [`root()`](root.md) for the per-method options that override these. `denyMutations`
|
|
126
|
+
`RootDefaults` is what `root(rootDir, defaults)` accepts. See [`root()`](root.md) for the per-method options that override these. `denyMutations` and `assertBeforeMutation` are exceptions: deny entries are merged, and the root authority assertion runs before the per-call assertion.
|
|
125
127
|
|
|
126
128
|
## `RootReadOptions` / `RootWriteOptions` / `RootCopyOptions`
|
|
127
129
|
|
|
128
130
|
```ts
|
|
129
131
|
type RootReadOptions = Pick<RootDefaults, "hardlinks" | "maxBytes" | "nonBlockingRead" | "symlinks">;
|
|
130
|
-
type RootWriteOptions = Pick<RootDefaults, "denyMutations" | "durable" | "mkdir" | "mode" | "renameIdentity"> & {
|
|
132
|
+
type RootWriteOptions = Pick<RootDefaults, "assertBeforeMutation" | "denyMutations" | "durable" | "mkdir" | "mode" | "renameIdentity" | "mutationSymlinks"> & {
|
|
131
133
|
encoding?: BufferEncoding;
|
|
132
134
|
overwrite?: boolean;
|
|
133
135
|
};
|
|
134
|
-
type RootCopyOptions = Pick<RootDefaults, "denyMutations" | "maxBytes" | "mkdir" | "mode"> & {
|
|
136
|
+
type RootCopyOptions = Pick<RootDefaults, "assertBeforeMutation" | "denyMutations" | "durable" | "maxBytes" | "mkdir" | "mode" | "mutationSymlinks"> & {
|
|
135
137
|
sourceHardlinks?: "reject" | "allow";
|
|
136
138
|
};
|
|
137
|
-
type RootOpenWritableOptions = Pick<RootDefaults, "denyMutations" | "mkdir" | "mode"> & {
|
|
139
|
+
type RootOpenWritableOptions = Pick<RootDefaults, "assertBeforeMutation" | "denyMutations" | "mkdir" | "mode" | "mutationSymlinks"> & {
|
|
138
140
|
writeMode?: "replace" | "append" | "update";
|
|
139
141
|
};
|
|
140
142
|
type RootWriteJsonOptions = RootWriteOptions & {
|
|
@@ -145,23 +147,28 @@ type RootWriteJsonOptions = RootWriteOptions & {
|
|
|
145
147
|
type RootAppendOptions = RootWriteOptions & {
|
|
146
148
|
prependNewlineIfNeeded?: boolean;
|
|
147
149
|
};
|
|
148
|
-
type RootMoveOptions = Pick<RootDefaults, "denyMutations"> & {
|
|
150
|
+
type RootMoveOptions = Pick<RootDefaults, "assertBeforeMutation" | "denyMutations" | "mutationSymlinks"> & {
|
|
149
151
|
overwrite?: boolean;
|
|
150
152
|
};
|
|
151
|
-
type RootRemoveOptions = Pick<RootDefaults, "denyMutations">;
|
|
152
|
-
type RootMkdirOptions = Pick<RootDefaults, "denyMutations">;
|
|
153
|
+
type RootRemoveOptions = Pick<RootDefaults, "assertBeforeMutation" | "denyMutations" | "mutationSymlinks">;
|
|
154
|
+
type RootMkdirOptions = Pick<RootDefaults, "assertBeforeMutation" | "denyMutations" | "mutationSymlinks">;
|
|
153
155
|
```
|
|
154
156
|
|
|
155
157
|
Per-method option shapes. Each picks the `RootDefaults` keys that apply, plus method-specific extras.
|
|
156
158
|
|
|
157
|
-
## `SymlinkPolicy` / `HardlinkPolicy`
|
|
159
|
+
## `SymlinkPolicy` / `MutationSymlinkPolicy` / `HardlinkPolicy`
|
|
158
160
|
|
|
159
161
|
```ts
|
|
160
|
-
type SymlinkPolicy = "reject" | "follow-within-root";
|
|
162
|
+
type SymlinkPolicy = "reject" | "follow-within-root" | "follow-parents-within-root";
|
|
163
|
+
type MutationSymlinkPolicy = "reject" | "follow-parents-within-root";
|
|
161
164
|
type HardlinkPolicy = "reject" | "allow";
|
|
162
165
|
```
|
|
163
166
|
|
|
164
|
-
|
|
167
|
+
`"reject"` is conservative; `"follow-within-root"` allows symlinks whose final target is still inside the root; `"allow"` (hardlinks only) is permissive. Defaults for read symlinks and hardlinks are `"reject"`; switch hardlinks to `"allow"` only when you intentionally accept hardlink aliases.
|
|
168
|
+
|
|
169
|
+
`"follow-parents-within-root"` allows contained parent directory aliases while
|
|
170
|
+
rejecting final symlinks. Mutation policy is opt-in and independent of read
|
|
171
|
+
policy; omission preserves each mutation method's existing behavior.
|
|
165
172
|
|
|
166
173
|
## `FsSafeErrorCode` / `FsSafeErrorCategory`
|
|
167
174
|
|
package/docs/walk.md
CHANGED
|
@@ -72,10 +72,57 @@ Unreadable directories are skipped rather than throwing, but every skipped direc
|
|
|
72
72
|
`Root.walk(rel, options)` is the root-bounded counterpart to these standalone
|
|
73
73
|
inventory helpers. It yields `{ relativePath, kind, size }` incrementally and
|
|
74
74
|
accepts `maxDepth`, `maxEntries`, `symlinkPolicy: "skip" |
|
|
75
|
-
"follow-within-root"`, and an `AbortSignal`. The default budget behavior yields
|
|
75
|
+
"follow-within-root"`, `order: "sorted" | "filesystem"`, and an `AbortSignal`. The default budget behavior yields
|
|
76
76
|
one `kind: "truncated"` marker and ends; pass `limitBehavior: "throw"` for a
|
|
77
77
|
typed `FsSafeError("too-large")` instead.
|
|
78
78
|
|
|
79
|
+
For followed symlinks, both `kind` and `size` describe the resolved target.
|
|
80
|
+
|
|
81
|
+
The default `order: "sorted"` visits each directory's names in lexicographic
|
|
82
|
+
order before descending depth first. It reads and sorts all names in each
|
|
83
|
+
visited directory. With `maxEntries`, it prepares small metadata batches capped
|
|
84
|
+
by the remaining global entry budget. Every batch stops at the first directory
|
|
85
|
+
or symlink, so recursive descent cannot spend a budget already used by later
|
|
86
|
+
siblings. An early `break` may leave metadata from the current batch unused;
|
|
87
|
+
the total still stays within `maxEntries`. Filtering requires metadata and
|
|
88
|
+
consumes the entry budget, including entries skipped by the filter.
|
|
89
|
+
|
|
90
|
+
Without `maxEntries`, sorted walks reuse a full directory metadata snapshot
|
|
91
|
+
from the `Root.list()` owner. This preserves the existing fast complete-scan
|
|
92
|
+
behavior and its snapshot semantics: changes made after a directory is listed
|
|
93
|
+
do not alter its already-captured entries. Supply an entry budget or use
|
|
94
|
+
filesystem order when metadata work must remain incremental. Sorted entries
|
|
95
|
+
describe the observations captured in their directory snapshot or batch.
|
|
96
|
+
|
|
97
|
+
Use `order: "filesystem"` when a wide directory must not be fully enumerated:
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
for await (const entry of capability.walk("", {
|
|
101
|
+
order: "filesystem",
|
|
102
|
+
maxEntries: 128,
|
|
103
|
+
symlinkPolicy: "skip",
|
|
104
|
+
})) {
|
|
105
|
+
consume(entry);
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
This order follows the filesystem's directory stream and is not deterministic.
|
|
110
|
+
It reads one entry at a time, including one name of lookahead to distinguish an
|
|
111
|
+
exactly exhausted budget from truncation. The lookahead does not request full
|
|
112
|
+
entry metadata from fs-safe, and an early `break` does not prefetch later child
|
|
113
|
+
metadata. If a filesystem does not supply directory-entry
|
|
114
|
+
types, Node may classify that one extra entry with a synchronous `lstat`.
|
|
115
|
+
Handles close on completion, truncation, cancellation, errors, or an
|
|
116
|
+
early `break`. Both orders keep the same depth-first traversal, entry filtering,
|
|
117
|
+
and truncation rules. Cancellation is checked between entries, with event-loop
|
|
118
|
+
handoffs between budgeted sorted batches. Root and directory checks and admitted
|
|
119
|
+
child metadata reads are synchronous; no mode can interrupt a filesystem
|
|
120
|
+
syscall already in progress or the sorted mode's name sorting.
|
|
121
|
+
|
|
122
|
+
If a thrown walk failure and directory close both fail, disposal throws a
|
|
123
|
+
`SuppressedError` with the close failure in `error` and the original failure in
|
|
124
|
+
`suppressed`, preserving both causes.
|
|
125
|
+
|
|
79
126
|
`entryFilter` is evaluated for each resolved file, directory, or other entry:
|
|
80
127
|
|
|
81
128
|
```ts
|
|
@@ -109,10 +156,12 @@ Every examined directory entry consumes `maxEntries` before filtering, so
|
|
|
109
156
|
`"truncated"` markers describe already-reached state and do not authorize
|
|
110
157
|
further descent.
|
|
111
158
|
|
|
112
|
-
The pure-Node path validates every directory
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
159
|
+
The pure-Node path validates every directory through the Root boundary, pins
|
|
160
|
+
its exact identity, and rechecks it and the Root identity around each metadata
|
|
161
|
+
batch or individual filesystem-order observation. Sorted batches contain no
|
|
162
|
+
await or caller code between their before/after checks. It tracks canonical
|
|
163
|
+
directories to stop symlink cycles.
|
|
164
|
+
Neither mode holds a descriptor for every path component, so it is not a process sandbox against a hostile peer that
|
|
116
165
|
can continuously swap and restore directories. Each individual lookup retains
|
|
117
166
|
the documented Node `Root` boundary checks.
|
|
118
167
|
|
package/docs/writing.md
CHANGED
|
@@ -38,6 +38,12 @@ await fs.mkdir("snapshots/2026/05");
|
|
|
38
38
|
Private sibling temporary names are independent of the destination basename,
|
|
39
39
|
so staging does not add a suffix to an otherwise valid long filename.
|
|
40
40
|
|
|
41
|
+
Write paths reject `path-alias` when symlinks and parent components select a
|
|
42
|
+
different target before and after lexical normalization, such as `link/../file`
|
|
43
|
+
where `link` points into a deeper directory. This avoids silently modifying the
|
|
44
|
+
wrong file. Resolve an intended alias explicitly with `Root.resolve()` before
|
|
45
|
+
passing its canonical path to a mutation.
|
|
46
|
+
|
|
41
47
|
A failure before the final rename leaves the destination at its previous
|
|
42
48
|
contents. A successful rename publishes the complete replacement. This
|
|
43
49
|
old-or-new guarantee does not apply to `append()` or `openWritable()`, which
|
|
@@ -48,6 +54,10 @@ Post-publication verification can still reject after a complete replacement has
|
|
|
48
54
|
been committed. Rejection does not promise that a successful rename was rolled
|
|
49
55
|
back; the published file or a raced replacement may remain at the destination.
|
|
50
56
|
|
|
57
|
+
Failed-write cleanup compares exact parent and file identities, including large
|
|
58
|
+
Windows file indexes. Replaced paths and paths whose ownership cannot be verified
|
|
59
|
+
are preserved.
|
|
60
|
+
|
|
51
61
|
## Denying mutations
|
|
52
62
|
|
|
53
63
|
All mutation verbs accept `denyMutations?: DenyMutationPolicy`, either as a root default or per-call option:
|
|
@@ -91,16 +101,17 @@ await fs.write("notes/today.txt", "hello\n", { encoding: "utf8" });
|
|
|
91
101
|
| `overwrite` | `boolean` | `true`; `false` is create-only. |
|
|
92
102
|
| `renameIdentity` | `RenameIdentityPolicy` | `"strict"`. |
|
|
93
103
|
|
|
94
|
-
`write`, `create`, `writeJson`, `createJson`, and `
|
|
104
|
+
`write`, `create`, `writeJson`, `createJson`, `append`, and `copyIn` accept `durable`.
|
|
95
105
|
Precedence is per-call option, then `Root.defaults.durable`, then `true`;
|
|
96
106
|
an explicitly `undefined` call option preserves the root default.
|
|
97
107
|
`durable: false` keeps the sibling-temp replace/rename behavior of replacement
|
|
98
108
|
writes but skips file and parent-directory fsync calls. Create-only and append
|
|
99
109
|
publication behavior, permissions, identity checks, and error codes are unchanged.
|
|
100
110
|
Use it only for reconstructible data: a crash may lose the write or leave the
|
|
101
|
-
previous file. `
|
|
102
|
-
|
|
103
|
-
|
|
111
|
+
previous file. `move` and streaming `openWritable` do not use this option.
|
|
112
|
+
Native and pure-JavaScript Windows writers honor the option. Replacement writes
|
|
113
|
+
sync staged content before rename and the final mode through the retained file
|
|
114
|
+
handle. Directory sync remains best-effort.
|
|
104
115
|
|
|
105
116
|
POSIX modes without read permission, including `0o000` and `0o200`, succeed:
|
|
106
117
|
final verification uses a descriptor retained by the writer rather than reopening
|
|
@@ -185,7 +196,9 @@ await fs.copyIn("inbox/upload.bin", "/tmp/incoming.bin", {
|
|
|
185
196
|
});
|
|
186
197
|
```
|
|
187
198
|
|
|
188
|
-
Options are `{ denyMutations?, maxBytes?, mkdir?, mode?, sourceHardlinks? }`.
|
|
199
|
+
Options are `{ denyMutations?, durable?, maxBytes?, mkdir?, mode?, sourceHardlinks? }`.
|
|
200
|
+
`durable` follows the root default and is `true` when omitted at both levels;
|
|
201
|
+
set it to `false` to skip file and parent-directory syncs for reconstructible data.
|
|
189
202
|
Use `sourceHardlinks: "reject"` to refuse if the source itself is a hardlinked
|
|
190
203
|
alias. There is no encoding option: copying preserves source bytes.
|
|
191
204
|
|
|
@@ -200,10 +213,17 @@ await fs.move("incoming/foo.txt", "archive/foo.txt", { overwrite: true });
|
|
|
200
213
|
|
|
201
214
|
Both `from` and `to` are bounded; `..` in either is rejected.
|
|
202
215
|
|
|
216
|
+
The JavaScript fallback checks both parent directories before and after the
|
|
217
|
+
rename. A failed post-operation check rejects even though the rename may
|
|
218
|
+
already have completed; rejection does not imply rollback.
|
|
219
|
+
|
|
203
220
|
### `fs.remove(rel)`
|
|
204
221
|
|
|
205
222
|
Unlink a file or `rmdir` an empty directory. Non-empty directories throw `not-empty`. For atomic directory replacement, use [`replaceDirectoryAtomic`](atomic.md#replacedirectoryatomic).
|
|
206
223
|
|
|
224
|
+
The JavaScript fallback reports failed parent-directory checks after removal.
|
|
225
|
+
The entry may already have been removed when this verification rejects.
|
|
226
|
+
|
|
207
227
|
```ts
|
|
208
228
|
await fs.remove("logs/yesterday.log");
|
|
209
229
|
await fs.remove("snapshots/empty-dir"); // ok
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/fs-safe",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"filesystem",
|
|
@@ -46,6 +46,10 @@
|
|
|
46
46
|
"types": "./dist/root.d.ts",
|
|
47
47
|
"default": "./dist/root.js"
|
|
48
48
|
},
|
|
49
|
+
"./copy": {
|
|
50
|
+
"types": "./dist/copy.d.ts",
|
|
51
|
+
"default": "./dist/copy.js"
|
|
52
|
+
},
|
|
49
53
|
"./config": {
|
|
50
54
|
"types": "./dist/config.d.ts",
|
|
51
55
|
"default": "./dist/config.js"
|
|
@@ -126,6 +130,7 @@
|
|
|
126
130
|
},
|
|
127
131
|
"scripts": {
|
|
128
132
|
"benchmark": "node scripts/benchmark.mjs",
|
|
133
|
+
"benchmark:methods": "node benchmarks/runner.mjs",
|
|
129
134
|
"benchmark:publish": "pnpm build && node scripts/bench-publish.mjs",
|
|
130
135
|
"build": "node scripts/prepack-build.mjs",
|
|
131
136
|
"lint:file-size": "node scripts/check-file-size.mjs",
|
|
@@ -156,19 +161,19 @@
|
|
|
156
161
|
"archive:producer-smoke": "node scripts/archive-producer-smoke.mjs"
|
|
157
162
|
},
|
|
158
163
|
"optionalDependencies": {
|
|
159
|
-
"@openclaw/fs-safe-darwin-arm64": "0.
|
|
160
|
-
"@openclaw/fs-safe-darwin-x64": "0.
|
|
161
|
-
"@openclaw/fs-safe-linux-arm64-gnu": "0.
|
|
162
|
-
"@openclaw/fs-safe-linux-arm64-musl": "0.
|
|
163
|
-
"@openclaw/fs-safe-linux-x64-gnu": "0.
|
|
164
|
-
"@openclaw/fs-safe-linux-x64-musl": "0.
|
|
165
|
-
"@openclaw/fs-safe-win32-x64-msvc": "0.
|
|
166
|
-
"jszip": "^3.10.
|
|
164
|
+
"@openclaw/fs-safe-darwin-arm64": "0.10.0",
|
|
165
|
+
"@openclaw/fs-safe-darwin-x64": "0.10.0",
|
|
166
|
+
"@openclaw/fs-safe-linux-arm64-gnu": "0.10.0",
|
|
167
|
+
"@openclaw/fs-safe-linux-arm64-musl": "0.10.0",
|
|
168
|
+
"@openclaw/fs-safe-linux-x64-gnu": "0.10.0",
|
|
169
|
+
"@openclaw/fs-safe-linux-x64-musl": "0.10.0",
|
|
170
|
+
"@openclaw/fs-safe-win32-x64-msvc": "0.10.0",
|
|
171
|
+
"jszip": "^3.10.2"
|
|
167
172
|
},
|
|
168
173
|
"devDependencies": {
|
|
169
|
-
"@emnapi/runtime": "2.0.0-alpha.
|
|
174
|
+
"@emnapi/runtime": "2.0.0-alpha.5",
|
|
170
175
|
"@napi-rs/cli": "3.9.0",
|
|
171
|
-
"@types/node": "^26.
|
|
176
|
+
"@types/node": "^26.5.1",
|
|
172
177
|
"@vitest/coverage-v8": "5.0.0",
|
|
173
178
|
"fast-check": "^4.9.0",
|
|
174
179
|
"istanbul-lib-coverage": "3.2.2",
|