@openclaw/fs-safe 0.10.0 → 0.12.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 +114 -0
- package/LICENSE +1 -0
- package/README.md +39 -6
- package/dist/absolute-path.d.ts.map +1 -1
- package/dist/absolute-path.js +3 -2
- package/dist/advanced.d.ts +5 -1
- package/dist/advanced.d.ts.map +1 -1
- package/dist/advanced.js +5 -1
- package/dist/archive-durability.d.ts +6 -6
- package/dist/archive-durability.d.ts.map +1 -1
- package/dist/archive-durability.js +1 -1
- package/dist/archive-entry.d.ts.map +1 -1
- package/dist/archive-entry.js +8 -8
- package/dist/archive-gzip-tail.d.ts +1 -0
- package/dist/archive-gzip-tail.d.ts.map +1 -1
- package/dist/archive-gzip-tail.js +16 -9
- package/dist/archive-input.d.ts.map +1 -1
- package/dist/archive-input.js +4 -2
- package/dist/archive-merge.d.ts +5 -1
- package/dist/archive-merge.d.ts.map +1 -1
- package/dist/archive-merge.js +15 -12
- package/dist/archive-native.js +4 -4
- package/dist/archive-parser.wasm +0 -0
- package/dist/archive-read.d.ts.map +1 -1
- package/dist/archive-read.js +54 -42
- package/dist/archive-staging.d.ts +6 -3
- package/dist/archive-staging.d.ts.map +1 -1
- package/dist/archive-staging.js +42 -22
- package/dist/archive-tar-stream.d.ts.map +1 -1
- package/dist/archive-tar-stream.js +7 -6
- package/dist/archive-tar-wasm.d.ts.map +1 -1
- package/dist/archive-tar-wasm.js +16 -13
- package/dist/archive-zip-admission.d.ts +1 -1
- package/dist/archive-zip-admission.d.ts.map +1 -1
- package/dist/archive-zip-admission.js +48 -12
- package/dist/archive-zip-loader.d.ts +6 -0
- package/dist/archive-zip-loader.d.ts.map +1 -0
- package/dist/archive-zip-loader.js +38 -0
- package/dist/archive-zip-names.d.ts.map +1 -1
- package/dist/archive-zip-names.js +26 -9
- package/dist/archive-zip-preflight.d.ts +2 -3
- package/dist/archive-zip-preflight.d.ts.map +1 -1
- package/dist/archive-zip-preflight.js +2 -34
- package/dist/archive.d.ts.map +1 -1
- package/dist/archive.js +12 -10
- package/dist/bounded-read.d.ts +5 -0
- package/dist/bounded-read.d.ts.map +1 -1
- package/dist/bounded-read.js +18 -11
- package/dist/copy-file-input.d.ts +0 -1
- package/dist/copy-file-input.d.ts.map +1 -1
- package/dist/copy-file-input.js +4 -26
- package/dist/copy-tree-portable.d.ts.map +1 -1
- package/dist/copy-tree-portable.js +57 -26
- package/dist/copy.d.ts +1 -1
- package/dist/copy.d.ts.map +1 -1
- package/dist/copy.js +3 -1
- package/dist/device-path.d.ts.map +1 -1
- package/dist/device-path.js +5 -3
- package/dist/directory-durability.d.ts.map +1 -1
- package/dist/directory-durability.js +5 -4
- package/dist/directory-guard.d.ts +11 -1
- package/dist/directory-guard.d.ts.map +1 -1
- package/dist/directory-guard.js +53 -11
- package/dist/durability.d.ts +1 -1
- package/dist/durability.d.ts.map +1 -1
- package/dist/durability.js +1 -1
- package/dist/file-handle-transfer.d.ts +14 -0
- package/dist/file-handle-transfer.d.ts.map +1 -0
- package/dist/file-handle-transfer.js +64 -0
- package/dist/file-hash.d.ts +3 -0
- package/dist/file-hash.d.ts.map +1 -1
- package/dist/file-hash.js +99 -32
- package/dist/file-lock-sync.d.ts.map +1 -1
- package/dist/file-lock-sync.js +8 -4
- package/dist/file-store-boundary.d.ts.map +1 -1
- package/dist/file-store-boundary.js +7 -5
- package/dist/file-store-path.d.ts +3 -0
- package/dist/file-store-path.d.ts.map +1 -0
- package/dist/file-store-path.js +27 -0
- package/dist/file-store-prune.d.ts.map +1 -1
- package/dist/file-store-prune.js +15 -5
- package/dist/file-store-sync-write.d.ts.map +1 -1
- package/dist/file-store-sync-write.js +56 -44
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +2 -18
- package/dist/filename.d.ts +1 -0
- package/dist/filename.d.ts.map +1 -1
- package/dist/filename.js +32 -14
- package/dist/guarded-mkdir.d.ts +2 -0
- package/dist/guarded-mkdir.d.ts.map +1 -1
- package/dist/guarded-mkdir.js +52 -16
- package/dist/guest-dispatch-python.d.ts +2 -0
- package/dist/guest-dispatch-python.d.ts.map +1 -0
- package/dist/guest-dispatch-python.js +117 -0
- package/dist/guest-native-python.d.ts +4 -0
- package/dist/guest-native-python.d.ts.map +1 -0
- package/dist/guest-native-python.js +135 -0
- package/dist/guest.d.ts +9 -0
- package/dist/guest.d.ts.map +1 -0
- package/dist/guest.js +421 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/install-path.d.ts +6 -0
- package/dist/install-path.d.ts.map +1 -1
- package/dist/install-path.js +16 -2
- package/dist/json-durable-queue-directory.js +3 -3
- package/dist/json-durable-queue-ownership.d.ts +2 -0
- package/dist/json-durable-queue-ownership.d.ts.map +1 -1
- package/dist/json-durable-queue-ownership.js +47 -3
- package/dist/json-durable-queue-read.d.ts +6 -0
- package/dist/json-durable-queue-read.d.ts.map +1 -0
- package/dist/json-durable-queue-read.js +59 -0
- package/dist/json-durable-queue.d.ts +1 -1
- package/dist/json-durable-queue.d.ts.map +1 -1
- package/dist/json-durable-queue.js +43 -84
- package/dist/json.d.ts.map +1 -1
- package/dist/json.js +2 -1
- package/dist/local-roots.d.ts.map +1 -1
- package/dist/local-roots.js +2 -1
- package/dist/move-path-stage.d.ts.map +1 -1
- package/dist/move-path-stage.js +2 -1
- package/dist/move-path.d.ts.map +1 -1
- package/dist/move-path.js +4 -3
- package/dist/mutation-authority.d.ts +1 -0
- package/dist/mutation-authority.d.ts.map +1 -1
- package/dist/mutation-authority.js +4 -4
- package/dist/native-binding.d.ts +14 -2
- package/dist/native-binding.d.ts.map +1 -1
- package/dist/native-pinned-write-windows.d.ts.map +1 -1
- package/dist/native-pinned-write-windows.js +3 -2
- package/dist/native-pinned-write.d.ts.map +1 -1
- package/dist/native-pinned-write.js +2 -1
- package/dist/opened-realpath.d.ts.map +1 -1
- package/dist/opened-realpath.js +5 -4
- package/dist/output.d.ts +2 -0
- package/dist/output.d.ts.map +1 -1
- package/dist/output.js +2 -0
- package/dist/overwrite-file-handle.d.ts +8 -0
- package/dist/overwrite-file-handle.d.ts.map +1 -0
- package/dist/overwrite-file-handle.js +42 -0
- package/dist/path-case.d.ts +7 -0
- package/dist/path-case.d.ts.map +1 -0
- package/dist/path-case.js +136 -0
- package/dist/path-scope-lexical.d.ts +14 -0
- package/dist/path-scope-lexical.d.ts.map +1 -0
- package/dist/path-scope-lexical.js +27 -0
- package/dist/path.d.ts.map +1 -1
- package/dist/path.js +22 -3
- package/dist/permissions-windows.d.ts +1 -1
- package/dist/permissions-windows.d.ts.map +1 -1
- package/dist/permissions-windows.js +48 -6
- package/dist/pinned-open.d.ts.map +1 -1
- package/dist/pinned-open.js +3 -1
- package/dist/pinned-write.d.ts +2 -2
- package/dist/pinned-write.d.ts.map +1 -1
- package/dist/pinned-write.js +3 -1
- package/dist/private-temp-workspace.d.ts.map +1 -1
- package/dist/private-temp-workspace.js +6 -4
- package/dist/realpath.d.ts +4 -0
- package/dist/realpath.d.ts.map +1 -0
- package/dist/realpath.js +43 -0
- package/dist/recursive-mkdir-path.d.ts +3 -0
- package/dist/recursive-mkdir-path.d.ts.map +1 -0
- package/dist/recursive-mkdir-path.js +8 -0
- package/dist/replace-directory.d.ts.map +1 -1
- package/dist/replace-directory.js +2 -1
- package/dist/replace-file-copy-fallback.d.ts.map +1 -1
- package/dist/replace-file-copy-fallback.js +23 -31
- package/dist/replace-file-copy-source.d.ts.map +1 -1
- package/dist/replace-file-copy-source.js +7 -12
- package/dist/replace-file-mode.d.ts +3 -0
- package/dist/replace-file-mode.d.ts.map +1 -0
- package/dist/replace-file-mode.js +10 -0
- package/dist/replace-file.d.ts +1 -0
- package/dist/replace-file.d.ts.map +1 -1
- package/dist/replace-file.js +14 -8
- package/dist/root-boundary.d.ts +25 -0
- package/dist/root-boundary.d.ts.map +1 -0
- package/dist/root-boundary.js +177 -0
- package/dist/root-context.d.ts.map +1 -1
- package/dist/root-context.js +40 -13
- package/dist/root-create-input.d.ts +10 -0
- package/dist/root-create-input.d.ts.map +1 -0
- package/dist/root-create-input.js +80 -0
- package/dist/root-directory-list.d.ts +3 -1
- package/dist/root-directory-list.d.ts.map +1 -1
- package/dist/root-directory-list.js +31 -5
- package/dist/root-entries.d.ts +11 -0
- package/dist/root-entries.d.ts.map +1 -0
- package/dist/root-entries.js +61 -0
- package/dist/root-errors.d.ts +5 -5
- package/dist/root-errors.d.ts.map +1 -1
- package/dist/root-errors.js +13 -12
- package/dist/root-impl.d.ts +13 -3
- package/dist/root-impl.d.ts.map +1 -1
- package/dist/root-impl.js +93 -70
- package/dist/root-move-preflight.d.ts +8 -0
- package/dist/root-move-preflight.d.ts.map +1 -0
- package/dist/root-move-preflight.js +16 -0
- package/dist/root-options.d.ts +12 -1
- package/dist/root-options.d.ts.map +1 -1
- package/dist/root-path-existing.d.ts +2 -0
- package/dist/root-path-existing.d.ts.map +1 -1
- package/dist/root-path-existing.js +14 -5
- package/dist/root-path-symlink.d.ts.map +1 -1
- package/dist/root-path-symlink.js +3 -2
- package/dist/root-path.d.ts +2 -0
- package/dist/root-path.d.ts.map +1 -1
- package/dist/root-path.js +54 -26
- package/dist/root-paths.d.ts +2 -6
- package/dist/root-paths.d.ts.map +1 -1
- package/dist/root-paths.js +24 -32
- package/dist/root-remove.d.ts +5 -0
- package/dist/root-remove.d.ts.map +1 -0
- package/dist/root-remove.js +286 -0
- package/dist/root-symlink-policy.d.ts +2 -1
- package/dist/root-symlink-policy.d.ts.map +1 -1
- package/dist/root-symlink-policy.js +2 -2
- package/dist/root-walk.d.ts.map +1 -1
- package/dist/root-walk.js +2 -1
- package/dist/root-write-mode.d.ts +2 -0
- package/dist/root-write-mode.d.ts.map +1 -1
- package/dist/root-write-mode.js +21 -7
- package/dist/root-write-verification.d.ts.map +1 -1
- package/dist/root-write-verification.js +12 -3
- package/dist/root.d.ts +2 -1
- package/dist/root.d.ts.map +1 -1
- package/dist/safe-path-segment.d.ts.map +1 -1
- package/dist/safe-path-segment.js +3 -1
- package/dist/secret-file.d.ts.map +1 -1
- package/dist/secret-file.js +2 -1
- package/dist/secret-read-async.d.ts.map +1 -1
- package/dist/secret-read-async.js +2 -1
- package/dist/secure-file-windows.d.ts +10 -0
- package/dist/secure-file-windows.d.ts.map +1 -0
- package/dist/secure-file-windows.js +186 -0
- package/dist/secure-file.d.ts.map +1 -1
- package/dist/secure-file.js +27 -7
- package/dist/secure-temp-dir.d.ts.map +1 -1
- package/dist/secure-temp-dir.js +2 -1
- package/dist/sibling-staged-file.d.ts +2 -0
- package/dist/sibling-staged-file.d.ts.map +1 -1
- package/dist/sibling-staged-file.js +49 -8
- package/dist/sibling-temp.d.ts +2 -0
- package/dist/sibling-temp.d.ts.map +1 -1
- package/dist/sibling-temp.js +8 -5
- package/dist/sidecar-lock-acquire.d.ts.map +1 -1
- package/dist/sidecar-lock-acquire.js +16 -5
- package/dist/sidecar-lock-policy.d.ts +2 -0
- package/dist/sidecar-lock-policy.d.ts.map +1 -1
- package/dist/sidecar-lock-policy.js +17 -0
- package/dist/sidecar-lock.d.ts.map +1 -1
- package/dist/sidecar-lock.js +2 -3
- package/dist/staged-directory.d.ts.map +1 -1
- package/dist/staged-directory.js +4 -3
- package/dist/temp-target.d.ts +14 -12
- package/dist/temp-target.d.ts.map +1 -1
- package/dist/temp-target.js +15 -7
- package/dist/trash.d.ts.map +1 -1
- package/dist/trash.js +7 -5
- package/dist/unicode-path.d.ts +3 -0
- package/dist/unicode-path.d.ts.map +1 -0
- package/dist/unicode-path.js +13 -0
- package/dist/walk.d.ts.map +1 -1
- package/dist/walk.js +14 -12
- package/dist/write-file-handle.d.ts +1 -0
- package/dist/write-file-handle.d.ts.map +1 -1
- package/dist/write-file-handle.js +3 -2
- package/docs/advanced.md +7 -1
- package/docs/archive.md +31 -5
- package/docs/atomic.md +17 -1
- package/docs/config.md +1 -0
- package/docs/contributing.md +33 -2
- package/docs/copy.md +75 -6
- package/docs/directory-identity.md +85 -0
- package/docs/durability.md +40 -3
- package/docs/entries.md +109 -0
- package/docs/errors.md +3 -3
- package/docs/file-store.md +21 -0
- package/docs/filename.md +9 -2
- package/docs/guest.md +146 -0
- package/docs/in-place-write.md +81 -0
- package/docs/index.md +2 -0
- package/docs/install-path.md +59 -13
- package/docs/install.md +34 -0
- package/docs/native-helper.md +14 -5
- package/docs/native.md +18 -2
- package/docs/output.md +32 -6
- package/docs/path-case.md +64 -0
- package/docs/path-scope.md +1 -1
- package/docs/path.md +1 -1
- package/docs/permissions.md +37 -3
- package/docs/public-api.md +36 -2
- package/docs/root.md +33 -3
- package/docs/secure-file.md +17 -14
- package/docs/security-model.md +1 -1
- package/docs/sidecar-lock.md +12 -3
- package/docs/store.md +22 -1
- package/docs/temp.md +39 -6
- package/docs/types.md +1 -1
- package/docs/writing.md +153 -3
- package/package.json +14 -8
package/docs/copy.md
CHANGED
|
@@ -26,8 +26,11 @@ if (backend) {
|
|
|
26
26
|
| `btrfs` | One native writable subvolume snapshot | `createCloneSource` creates a subvolume; an ordinary directory is not a snapshot source. No `btrfs` executable is required. |
|
|
27
27
|
| `refs` | Native directory traversal with parallel file block clones | `createCloneSource` creates an empty directory on ReFS, including Dev Drive volumes. |
|
|
28
28
|
| `xfs` | Native directory traversal with parallel file reflinks | `createCloneSource` creates an empty directory. The XFS volume must support reflinks. |
|
|
29
|
+
| `zfs` | Native directory traversal with parallel file reflinks | `createCloneSource` creates an empty directory. Requires Linux OpenZFS file reflinks and the pool block-cloning feature. |
|
|
29
30
|
|
|
30
|
-
Native cloning requires source and destination filesystems that support cloning between them. Automatic and ordinary copying can cross filesystems. The source repository used to populate a template can live elsewhere. ReFS and
|
|
31
|
+
Native cloning requires source and destination filesystems that support cloning between them. Automatic and ordinary copying can cross filesystems. The source repository used to populate a template can live elsewhere. ReFS, XFS, and ZFS share file data rather than the whole directory metadata tree, so creating many small files still has a cost.
|
|
32
|
+
|
|
33
|
+
ZFS uses strict file reflinks within one dataset, not dataset snapshots. The installed Linux OpenZFS version must implement `FICLONE`, and the pool must enable `feature@block_cloning`. The probe identifies ZFS even when that feature is unavailable; `clone: "always"` then fails and `"auto"` can copy bytes. Native cloning was verified on OpenZFS 2.4.1 with POSIX ACLs. See the [OpenZFS block-cloning contract](https://openzfs.github.io/openzfs-docs/Basic%20Concepts/Data%20Storage/Block%20Cloning.html) for filesystem limits and pool sharing counters.
|
|
31
34
|
|
|
32
35
|
Btrfs preserves native subvolume snapshot semantics: nested subvolume contents are not included. Prepare source-only templates without nested subvolumes. This API does not recursively snapshot a hierarchy of subvolumes.
|
|
33
36
|
|
|
@@ -41,7 +44,7 @@ Apple [strongly discourages general directory cloning](https://github.com/apple-
|
|
|
41
44
|
|
|
42
45
|
## API
|
|
43
46
|
|
|
44
|
-
`TreeCloneBackend` is the `"apfs" | "btrfs" | "refs" | "xfs"` union returned by the probe. `CopyTreeOptions` contains the optional `clone`, `signal`, and `concurrency` arguments. `CopyCloneMode` is the `"auto" | "always" | "never"` strategy shared with [`Root.copyIn`](root.md#writes); tree copies default to `"auto"`, while guarded file copies default to `"never"`.
|
|
47
|
+
`TreeCloneBackend` is the `"apfs" | "btrfs" | "refs" | "xfs" | "zfs"` union returned by the probe. `CopyTreeOptions` contains the optional `clone`, `signal`, and `concurrency` arguments. `CopyCloneMode` is the `"auto" | "always" | "never"` strategy shared with [`Root.copyIn`](root.md#writes); tree copies default to `"auto"`, while guarded file copies default to `"never"`.
|
|
45
48
|
|
|
46
49
|
`probeTreeClone(parentPath)` synchronously inspects an existing directory and returns its supported backend name or `undefined`. It creates no probe artifacts. A filesystem name identifies a candidate backend; for example, an older XFS volume may have reflinks disabled. The actual operation determines availability. An unavailable native binding produces `undefined` in automatic mode; the package's explicit native `require` mode still reports a missing binding as an error.
|
|
47
50
|
|
|
@@ -57,18 +60,80 @@ Apple [strongly discourages general directory cloning](https://github.com/apple-
|
|
|
57
60
|
|
|
58
61
|
Automatic copying does not recover from permission errors, I/O errors, cancellation, or rejected source contents such as ReFS named streams. A failed clone must leave the destination absent before fallback can create it; otherwise copying fails rather than merging into a partial tree.
|
|
59
62
|
|
|
60
|
-
`concurrency` accepts integers from 1 through 32 and bounds active file copies. ReFS and
|
|
63
|
+
`concurrency` accepts integers from 1 through 32 and bounds active file copies. ReFS, XFS, and ZFS cloning default to 16 workers. Byte copying defaults to four concurrent files on Windows and one elsewhere. Btrfs uses its bulk operation. APFS uses a bulk clone followed by native directory-entry enumeration to restore directory timestamps; known regular files and symbolic links need no additional stat or open.
|
|
64
|
+
|
|
65
|
+
On Windows, automatic byte copying uses the native binding when available to transfer data between the already-checked file handles in chunks of at most 1 MiB, with smaller buffers for small files. This accelerates NTFS and cross-volume copies without reopening source or destination pathnames. The native worker finishes before its descriptors are closed or cancellation is reported. `clone: "never"` and native-disabled copies use JavaScript read/write loops with reusable buffers: at most 1 MiB per active file on Windows, or 128 KiB elsewhere. Both paths share the file-worker budget across sibling directories, wait for all admitted writes after cancellation or failure, and restore directory timestamps after their descendants finish. Completed directory traversals awaiting writes or metadata are bounded by concurrency; ancestors remain pinned while traversing their children.
|
|
61
66
|
|
|
62
|
-
On
|
|
67
|
+
On Linux, automatic byte copying also uses the native binding when available. It reads in chunks up to 1 MiB, using smaller buffers for smaller source-size hints, and leaves leading and trailing zero-filled portions of each chunk unwritten in the new file, avoiding their allocation on filesystems that support sparse files. A final size update preserves trailing holes and all-zero files. This path uses reads and writes, without cloning or copy offload; it still reads the full logical contents and does not promise identical sparse extent layout. `clone: "never"` retains the JavaScript byte-copy path.
|
|
63
68
|
|
|
64
69
|
Clones preserve file contents, empty directories, timestamps, executable modes where supported, and literal symbolic links. Editing a clone does not modify its source. Unsupported filesystem operations fail; callers may choose their own copy or checkout fallback after the failed operation has settled.
|
|
65
70
|
|
|
71
|
+
Native Windows byte copies can store large zero-filled chunks as sparse ranges when the destination is initially empty and its filesystem supports sparse files. This still reads every source byte and creates an independent copy.
|
|
72
|
+
|
|
66
73
|
The ReFS backend rejects files with alternate data streams and unsupported reparse-point types instead of silently losing their contents. Symbolic links and junctions are preserved.
|
|
67
74
|
|
|
68
|
-
XFS
|
|
75
|
+
XFS and ZFS preserve regular-file and directory modes, timestamps, extended attributes, and ACLs. They reject special files, symlink extended attributes, non-UTF-8 names, and directory nesting deeper than 128 levels. Hardlinked source files become independent reflinked files. Portable byte copying preserves file contents, empty directories, modes where supported, file and directory timestamps, and literal symbolic links; it does not promise ownership, ACL, extended-attribute, alternate-stream, or sparse-layout preservation. On Windows, byte copying rejects unresolved symbolic links because Node does not expose their file/directory link type; resolved links keep their literal target and source type. POSIX dangling links are preserved. Choose a copying policy that meets the caller's metadata requirements; automatic copying can select either path.
|
|
69
76
|
|
|
70
77
|
`readCloneFileMetadata(files)` asynchronously reads APFS data-stream identities and file metadata in one native batch. Results correspond to input order; missing or unsupported entries return `undefined`. The returned `CloneFileMetadata` includes clone ID, device/inode, size, mode, ownership, and timestamps. These are point-in-time observations, not authorization or proof that later reads remain unchanged. Consumers such as Git index adapters must validate their own content and timestamp invariants. The reader does not follow leaf symbolic links.
|
|
71
78
|
|
|
79
|
+
## Borrowed FileHandle transfers
|
|
80
|
+
|
|
81
|
+
`copyFileHandle` from `@openclaw/fs-safe/advanced` copies bytes between two
|
|
82
|
+
already-open regular files. Use it when a snapshot or materialization owner
|
|
83
|
+
has admitted the source and opened its own destination:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { createHash } from "node:crypto";
|
|
87
|
+
import { copyFileHandle } from "@openclaw/fs-safe/advanced";
|
|
88
|
+
|
|
89
|
+
const digest = createHash("sha256");
|
|
90
|
+
const bytes = await copyFileHandle(sourceHandle, targetHandle, {
|
|
91
|
+
maxBytes: expectedSize,
|
|
92
|
+
signal: AbortSignal.timeout(30_000),
|
|
93
|
+
onChunk: (chunk) => { digest.update(chunk); },
|
|
94
|
+
assertBeforeMutation: assertSnapshotOwnerCurrent,
|
|
95
|
+
});
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`CopyFileHandleOptions` contains optional `maxBytes`, `signal`, `onChunk`, and
|
|
99
|
+
`assertBeforeMutation`. The result is the actual byte count copied through EOF.
|
|
100
|
+
The byte limit is not a prefix length: excess data rejects with `too-large`,
|
|
101
|
+
including data added after admission. Omitted limits are unlimited; Root's
|
|
102
|
+
default read cap does not apply. Zero accepts only an empty source. Invalid
|
|
103
|
+
limits reject before descriptor inspection.
|
|
104
|
+
|
|
105
|
+
The source and target must be distinct regular files; exact device/inode
|
|
106
|
+
aliases, including two handles to hardlinked names, reject before writing.
|
|
107
|
+
Both reads and writes start at position zero and preserve the handles' current
|
|
108
|
+
cursors. Existing destination bytes beyond the copied prefix remain intact.
|
|
109
|
+
The target must have been opened **without append mode**: some platforms ignore
|
|
110
|
+
positional writes on append handles, and Node exposes no portable open-flags
|
|
111
|
+
query. Keep both handles open and free of concurrent I/O through settlement.
|
|
112
|
+
|
|
113
|
+
The synchronous `onChunk` observer sees each source chunk before any target
|
|
114
|
+
write for that chunk. It receives a borrowed view reused by later reads; consume
|
|
115
|
+
it immediately without retaining or mutating it. This supports source hashing;
|
|
116
|
+
it does not verify bytes persisted by the destination. Callers that require a
|
|
117
|
+
destination digest must still hash the destination handle afterward. Observer
|
|
118
|
+
and authority callbacks may throw; thenable returns reject with `TypeError`
|
|
119
|
+
before the affected write. `assertBeforeMutation` runs immediately before every
|
|
120
|
+
partial-write submission and must inspect current authority each time.
|
|
121
|
+
|
|
122
|
+
The helper reuses Root copying's bounded read buffer and completes positive
|
|
123
|
+
short reads and writes. JavaScript file transfers use at most 512 KiB of scratch
|
|
124
|
+
space, reduced for smaller source-size hints and capped by a finite byte budget
|
|
125
|
+
plus its one-byte overflow probe. A zero-progress write rejects with `helper-failed`.
|
|
126
|
+
Cancellation is checked before I/O, after source reads, and before each write;
|
|
127
|
+
admitted reads and writes settle before rejection. A rejected operation can
|
|
128
|
+
leave a copied prefix. There is no rollback or pathname cleanup.
|
|
129
|
+
|
|
130
|
+
This helper never opens or closes a file, truncates, chmods, syncs, renames, or
|
|
131
|
+
publishes it. Source admission, immutability checks, destination preparation,
|
|
132
|
+
durability, publication, and failure recovery stay with the caller. Initial
|
|
133
|
+
descriptor inspection does not prove that the source remained unchanged while
|
|
134
|
+
copying. Keep existing source-fingerprint and publication checks around the
|
|
135
|
+
transfer when building snapshot operations.
|
|
136
|
+
|
|
72
137
|
## Ownership and cancellation
|
|
73
138
|
|
|
74
139
|
These are low-level operations on caller-owned absolute paths, not Root-relative methods. The source and destination parent must be real directories. The library pins their descriptors and verifies their identities; it does not establish the caller's authorization to use them. Keep the source immutable for the operation, including writes through other aliases, and keep the destination namespace under the caller's control. Literal symlinks in the cloned contents are preserved rather than followed or sanitized.
|
|
@@ -77,10 +142,14 @@ An already aborted signal prevents dispatch. In-flight cancellation stops cancel
|
|
|
77
142
|
|
|
78
143
|
Completion is not a crash-durability guarantee. The API is suitable for reconstructible templates and checkouts; it does not sync every file or replace application-level publication and recovery rules.
|
|
79
144
|
|
|
145
|
+
Byte copying retains fractional file and directory access/modification timestamps to the precision supported by Node's timestamp APIs and the destination filesystem. This includes dates before 1970 on Unix. On Windows, [Node's unsigned stat seconds](https://github.com/nodejs/node/blob/v26.8.2/src/node_file-inl.h#L93-L104) can report pre-1970 timestamps as dates about 136 years later; byte copying inherits that upstream limitation.
|
|
146
|
+
|
|
80
147
|
## Platform tests and benchmarks
|
|
81
148
|
|
|
82
|
-
After building the host native binding, run `pnpm test test/clone.test.ts test/copy-tree.test.ts`. APFS tests can use the normal macOS temporary directory. For Btrfs, ReFS, or
|
|
149
|
+
After building the host native binding, run `pnpm test test/clone.test.ts test/copy-tree.test.ts`. APFS tests can use the normal macOS temporary directory. For Btrfs, ReFS, XFS, or ZFS, set `FS_SAFE_CLONE_TEST_ROOT` to an existing writable directory on that filesystem. The test creates and cleans only its own temporary children. An explicitly configured unsupported directory fails the test rather than silently skipping platform proof. XFS and ZFS metadata tests require the `attr` and `acl` utilities.
|
|
83
150
|
|
|
84
151
|
Run `node scripts/clone-xfs-proof.mjs MOUNT` on a real XFS volume to verify the public API, hashes, independent writes, and shared physical extents. It requires `filefrag` from `e2fsprogs`. Add `no-reflink` for an XFS fixture formatted with reflinks disabled; strict copying must fail and automatic copying must succeed through byte copying.
|
|
85
152
|
|
|
153
|
+
Run `node scripts/clone-zfs-proof.mjs MOUNT POOL` on a dedicated, otherwise idle Linux ZFS pool with compression and deduplication disabled. It verifies both `copyTree` and `Root.copyIn` through hashes and changes in the documented `bclonesaved` pool counter. It requires `zfs`, `zpool`, and `findmnt`, including permission to run `zpool sync`. Add `no-reflink` for a pool without block cloning to verify strict refusal and automatic byte fallback. The script creates and removes only its temporary directory; it does not create pools or change their properties.
|
|
154
|
+
|
|
86
155
|
Run `node benchmarks/clone.mjs SOURCE DESTINATION_PARENT` after `pnpm build` to compare one, four, and 16 workers on the same immutable source. Add `3 auto` or `3 never` to measure three samples of ordinary copying, including NTFS destinations. It records copying time separately from fixture preparation and full file-hash verification, and retains its uniquely named output directory for inspection. Prepare Btrfs sources with `createCloneSource` first.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Directory identity
|
|
3
|
+
description: "Exact directory observations and synchronous identity assertions for application-owned workflows."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Directory identity
|
|
7
|
+
|
|
8
|
+
Use `readDirectoryIdentity()` and `assertDirectoryIdentitySync()` from
|
|
9
|
+
`@openclaw/fs-safe/advanced` when an application owns a staging or recovery flow
|
|
10
|
+
and needs to verify that a pathname still identifies an observed directory.
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import {
|
|
14
|
+
readDirectoryIdentity,
|
|
15
|
+
assertDirectoryIdentitySync,
|
|
16
|
+
} from "@openclaw/fs-safe/advanced";
|
|
17
|
+
|
|
18
|
+
const expected = await readDirectoryIdentity(directoryPath);
|
|
19
|
+
await prepareOutput();
|
|
20
|
+
assertDirectoryIdentitySync(directoryPath, expected);
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`readDirectoryIdentity(path)` returns a frozen `DirectoryIdentity`:
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
type DirectoryIdentity = Readonly<{
|
|
27
|
+
dev: bigint;
|
|
28
|
+
ino: bigint;
|
|
29
|
+
realPath: string;
|
|
30
|
+
}>;
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Both operations reject a final symlink or a non-directory, including paths with
|
|
34
|
+
one or more trailing separators. Filesystem, drive, and UNC roots remain valid.
|
|
35
|
+
Parent aliases and `..` retain their filesystem traversal meaning; POSIX
|
|
36
|
+
backslashes and whitespace remain literal filename characters. These helpers do
|
|
37
|
+
not confine a path to a root or reject every symlink ancestor. Keep the
|
|
38
|
+
application's path policy, or use the [Root API](root.md) for paths that must
|
|
39
|
+
remain beneath a root.
|
|
40
|
+
|
|
41
|
+
## Checking the selected path
|
|
42
|
+
|
|
43
|
+
`assertDirectoryIdentitySync(observedPath, expected)` reads the supplied path
|
|
44
|
+
and compares its exact `dev` and `ino` against the expected bigint values. It
|
|
45
|
+
returns `undefined` on success and throws synchronously on failure.
|
|
46
|
+
|
|
47
|
+
If `expected.realPath` is present, the current canonical path must also match
|
|
48
|
+
that string exactly. Pass the complete observation to keep both checks:
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
assertDirectoryIdentitySync(newlyOpenedRootPath, expected);
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
For a directory intentionally moved to another name, omit `realPath` while
|
|
55
|
+
retaining the expected identity:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
assertDirectoryIdentitySync(movedPath, { dev: expected.dev, ino: expected.ino });
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Only `dev`, `ino`, and optional `realPath` participate in the assertion. A
|
|
62
|
+
`MovePathPublicationReceipt` can supply the identity after a move; its `path`
|
|
63
|
+
does not implicitly require the previous pathname to remain current.
|
|
64
|
+
|
|
65
|
+
## Errors and ownership
|
|
66
|
+
|
|
67
|
+
| Condition | Result |
|
|
68
|
+
|---|---|
|
|
69
|
+
| Final symlink or non-directory | `FsSafeError("not-file")` |
|
|
70
|
+
| Different expected identity or supplied canonical path | `FsSafeError("path-mismatch")` |
|
|
71
|
+
| Numeric expected identity or persistently unknown Windows identity | `FsSafeError("path-mismatch")` |
|
|
72
|
+
| Filesystem failure such as `ENOENT` or `EACCES` | The original error, unchanged |
|
|
73
|
+
|
|
74
|
+
Windows can temporarily report a zero device or inode. The shared identity
|
|
75
|
+
owner permits one re-inspection, retaining every known component so a later
|
|
76
|
+
observation cannot erase a definite mismatch. It rejects identities that remain
|
|
77
|
+
unknown and does not retry operational filesystem errors.
|
|
78
|
+
|
|
79
|
+
These helpers observe directory identity. They do not open a retained handle,
|
|
80
|
+
hold a lock, create or remove directories, change permissions, or authorize
|
|
81
|
+
application work. A pathname can change after an assertion; continue to use
|
|
82
|
+
guarded mutation APIs and recheck application authority at the operation's
|
|
83
|
+
existing submission point. Keep publication, rollback, and cleanup decisions
|
|
84
|
+
with the caller. Use [pinned directories](durability.md#pinned-directories) when
|
|
85
|
+
the operation specifically needs the directory synchronization lifecycle.
|
package/docs/durability.md
CHANGED
|
@@ -242,10 +242,47 @@ When the optional binding is active, hashing runs as an async native task and
|
|
|
242
242
|
does not occupy the JavaScript event loop with digest updates. With native mode
|
|
243
243
|
`off`, or in `auto` when no binding loads, the fallback performs asynchronous
|
|
244
244
|
positioned reads in chunks of up to 256 KiB but updates Node's `Hash` on the JavaScript
|
|
245
|
-
thread. Both paths stream
|
|
246
|
-
|
|
245
|
+
thread. Both paths stream bounded buffers rather than loading the file into memory.
|
|
246
|
+
The fallback sizes its scratch buffer to small files and grows it if a stale
|
|
247
|
+
size hint is exceeded, while still probing for actual EOF and byte-limit overflow.
|
|
248
|
+
Native mode `require` keeps its usual fail-closed loader semantics.
|
|
249
|
+
|
|
250
|
+
### Synchronous hashing
|
|
251
|
+
|
|
252
|
+
`sha256FileSync()` accepts a pathname or a borrowed numeric file descriptor and
|
|
253
|
+
returns the same `{ bytes, digest }` result. It shares `Sha256FileOptions`,
|
|
254
|
+
including the default unlimited byte budget and `too-large` errors for growth
|
|
255
|
+
beyond `maxBytes`. It always reads from offset zero with bounded positional
|
|
256
|
+
`readSync` calls and leaves a borrowed descriptor open at its original position.
|
|
257
|
+
Path inputs use the same regular-file, final-symlink, nonblocking-open, and exact
|
|
258
|
+
bigint admission checks described above, then close their owned descriptor.
|
|
259
|
+
These checks do not provide ancestor confinement or a snapshot of concurrent edits.
|
|
247
260
|
|
|
248
|
-
|
|
261
|
+
```ts
|
|
262
|
+
import { closeSync, openSync } from "node:fs";
|
|
263
|
+
import { sha256FileSync } from "@openclaw/fs-safe/durability";
|
|
264
|
+
|
|
265
|
+
const fd = openSync(stagedArchive, "r");
|
|
266
|
+
try {
|
|
267
|
+
const hash = sha256FileSync(fd, { maxBytes: manifest.sizeBytes });
|
|
268
|
+
if (hash.bytes !== manifest.sizeBytes || hash.digest !== manifest.sha256) {
|
|
269
|
+
throw new Error("staged backup does not match its manifest");
|
|
270
|
+
}
|
|
271
|
+
} finally {
|
|
272
|
+
closeSync(fd);
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
The synchronous API uses Node's crypto implementation in every native mode,
|
|
277
|
+
including `require`; it never loads a native binding. It blocks the calling
|
|
278
|
+
thread until hashing finishes or throws. A pre-aborted signal fails before I/O,
|
|
279
|
+
and synchronous signal changes are checked between operations with the original
|
|
280
|
+
reason preserved. Timers and other JavaScript callbacks cannot run while the
|
|
281
|
+
hash is executing; use `sha256File()` when responsive cancellation is needed.
|
|
282
|
+
|
|
283
|
+
## Publication failure receipts
|
|
284
|
+
|
|
285
|
+
If `publishFileExclusive()` fails after creating the target, it throws an
|
|
249
286
|
`FsSafeError` with a `details` receipt:
|
|
250
287
|
|
|
251
288
|
```ts
|
package/docs/entries.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Directory entries
|
|
2
|
+
|
|
3
|
+
`Root.entries()` observes one directory at a time. Use it when the application
|
|
4
|
+
owns traversal order or must inspect symlinks itself, such as an installer that
|
|
5
|
+
validates selected dependency links or a manifest builder that rejects all links.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { root } from "@openclaw/fs-safe";
|
|
9
|
+
|
|
10
|
+
const workspace = await root("/srv/workspace");
|
|
11
|
+
for await (const entry of workspace.entries("plugins", {
|
|
12
|
+
maxEntries: 1_000,
|
|
13
|
+
signal: AbortSignal.timeout(5_000),
|
|
14
|
+
})) {
|
|
15
|
+
if (entry.isSymbolicLink) {
|
|
16
|
+
throw new Error(`unexpected link: ${entry.name}`);
|
|
17
|
+
}
|
|
18
|
+
console.log(entry.name, entry.size);
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## API
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
interface Root {
|
|
26
|
+
entries(relativePath: string, options?: RootEntriesOptions): AsyncIterableIterator<DirEntry>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type RootEntriesOptions = {
|
|
30
|
+
maxEntries?: number;
|
|
31
|
+
order?: "filesystem" | "sorted";
|
|
32
|
+
signal?: AbortSignal;
|
|
33
|
+
symlinks?: "reject" | "follow-within-root" | "follow-parents-within-root";
|
|
34
|
+
};
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Each result is the existing [`DirEntry`](types.md) shape: a basename and advisory
|
|
38
|
+
`lstat` metadata, including `isFile`, `isDirectory`, `isSymbolicLink`, `size`,
|
|
39
|
+
`mode`, `nlink`, `dev`, and `ino`. Entries are immediate children; the iterator
|
|
40
|
+
never descends. An empty path or `"."` selects the root directory.
|
|
41
|
+
|
|
42
|
+
Child symlinks are always reported without following them, including dangling
|
|
43
|
+
links and links whose targets lie outside the root. Their metadata describes
|
|
44
|
+
the link, not its target. Hardlinked files are also reported regardless of the
|
|
45
|
+
Root's read hardlink policy. Reporting a name grants no read or mutation access.
|
|
46
|
+
Use the Root operation methods when consuming or changing an entry.
|
|
47
|
+
|
|
48
|
+
The `symlinks` option applies only to the path of the selected directory. It
|
|
49
|
+
inherits the Root's read policy and defaults to `"reject"`. The two follow
|
|
50
|
+
policies allow only contained aliases; `"follow-parents-within-root"` also
|
|
51
|
+
rejects a link as the final directory component. Child-link reporting is
|
|
52
|
+
independent of this path policy.
|
|
53
|
+
|
|
54
|
+
## Work limits and ordering
|
|
55
|
+
|
|
56
|
+
`maxEntries` is an optional non-negative safe integer. Every child counts,
|
|
57
|
+
including directories, symlinks, special files, and entries the caller later
|
|
58
|
+
ignores. Omit it to leave the count unbounded. An empty directory satisfies a
|
|
59
|
+
zero limit. Exceeding the limit throws `FsSafeError` with code `"too-large"`;
|
|
60
|
+
there is no silent truncation or success marker for a partial scan.
|
|
61
|
+
|
|
62
|
+
The default `order: "filesystem"` reads names incrementally in the filesystem's
|
|
63
|
+
nondeterministic order. It observes one child's metadata per iterator step,
|
|
64
|
+
with one name of lookahead to distinguish an exact limit from an overflow.
|
|
65
|
+
Entries already yielded before overflow remain partial observations. A caller
|
|
66
|
+
that stops early has not established that the whole directory fits the limit.
|
|
67
|
+
|
|
68
|
+
`order: "sorted"` collects names first and orders them with JavaScript's default
|
|
69
|
+
string sort, not locale collation. With `maxEntries`, names are collected from
|
|
70
|
+
a bounded directory stream; overflow rejects before yielding any entries or
|
|
71
|
+
requesting their full metadata. Without a limit, sorted mode enumerates the
|
|
72
|
+
complete name list. Metadata is observed only as each sorted entry is consumed.
|
|
73
|
+
Applications that need locale-specific ordering can collect with an explicit
|
|
74
|
+
limit and apply their own comparator.
|
|
75
|
+
|
|
76
|
+
The count bounds logical directory reads and fs-safe metadata requests. Node
|
|
77
|
+
may classify a directory entry with `lstat` when the filesystem omits type
|
|
78
|
+
information, including the single lookahead entry. It does not bound elapsed
|
|
79
|
+
time for an individual filesystem operation or the size of one filename.
|
|
80
|
+
|
|
81
|
+
Cancellation is checked before setup and around awaited work. Directory
|
|
82
|
+
handles close on completion, overflow, cancellation, failure, and early
|
|
83
|
+
`break` or iterator return. In-flight filesystem work settles before rejection;
|
|
84
|
+
an individual syscall cannot be interrupted. If iteration and disposal both
|
|
85
|
+
fail, a `SuppressedError` retains the close failure in `error` and the original
|
|
86
|
+
failure in `suppressed`.
|
|
87
|
+
|
|
88
|
+
## Identity and caller responsibilities
|
|
89
|
+
|
|
90
|
+
The iterator reuses Root's guarded directory-listing owner. It validates the
|
|
91
|
+
selected path, pins exact Root and directory identities, and checks them around
|
|
92
|
+
directory observations, including after control returns from the caller. A
|
|
93
|
+
replaced directory rejects instead of continuing under the replacement.
|
|
94
|
+
|
|
95
|
+
These are pure-Node, best-effort checks. The iterator does not hold descriptors
|
|
96
|
+
for every path component and cannot sandbox a hostile process that repeatedly
|
|
97
|
+
swaps and restores directories. Results are not an atomic snapshot, an exact
|
|
98
|
+
identity receipt, or permission to use the name later. Contents and metadata
|
|
99
|
+
can change between entries; a removed entry may cause iteration to reject.
|
|
100
|
+
Use an admitted descriptor for metadata and hashing that must refer to the same
|
|
101
|
+
opened file, and keep snapshot consistency or cooperative locking with its
|
|
102
|
+
application owner.
|
|
103
|
+
|
|
104
|
+
Traversal strategy, global budgets, ignored names, and approved external peers
|
|
105
|
+
remain application policy. A per-directory physical-entry limit cannot replace
|
|
106
|
+
a global file-only or unique-directory budget. Full metadata also requires a
|
|
107
|
+
child `lstat`; a caller that previously needed only `Dirent` types should assess
|
|
108
|
+
that cost. `Root.walk()` remains the recursive option for its supported link,
|
|
109
|
+
pruning, and error policies; `Root.list()` returns an eager advisory listing.
|
package/docs/errors.md
CHANGED
|
@@ -117,11 +117,11 @@ type FsSafeErrorCode =
|
|
|
117
117
|
| `helper-unavailable` | Required native binding could not be loaded. | Unsupported platform, omitted/missing/incompatible platform package, or `FS_SAFE_NATIVE_MODE=require`. `auto` falls back where possible; `require` fails closed. |
|
|
118
118
|
| `insecure-permissions` | A secure file or path permission check found a mode/ACL that allows broader access than requested. | File or directory is group/world writable/readable; Windows ACL grants broad read. |
|
|
119
119
|
| `invalid-path` | Input was empty, contained NUL, was an unparseable URL, or otherwise unusable; a FileStore key used a noncanonical spelling. | Noncanonical FileStore aliases, backslashes, or complete parent segments; a network path on Windows; a drive-relative segment in a portable relative path or store key; or a leading drive-relative spelling such as `C:name` used as a Root destination. Existing-object Root lookups retain broader confined path compatibility, including legal POSIX drive-like names. |
|
|
120
|
-
| `not-empty` | `remove()` on a non-empty directory. | Use `
|
|
120
|
+
| `not-empty` | Nonrecursive `remove()` on a non-empty directory, or new children appeared during recursive removal. | Use bounded `recursive: true` removal or coordinate concurrent writers. |
|
|
121
121
|
| `not-file` | Read or copy targeted a non-regular file, or a path walk found a non-directory ancestor. | Target was a directory, FIFO, socket, device, or an existing file was followed by another segment. |
|
|
122
122
|
| `not-found` | The target does not exist (or its parent does not, with `mkdir: false`). | Typical missing-file case. |
|
|
123
123
|
| `not-owned` | A secure file owner check failed. | File is owned by another UID. |
|
|
124
|
-
| `not-removable` | `remove()` couldn't `unlink`/`rmdir` for a reason other than non-empty. | Permissions, device busy, immutable bit
|
|
124
|
+
| `not-removable` | `remove()` couldn't inspect a directory stream or `unlink`/`rmdir` for a reason other than non-empty. | Permissions, device busy, immutable bit; the original filesystem error remains in `cause`. |
|
|
125
125
|
| `outside-workspace` | Path resolves outside the configured root. | `..` traversal; absolute path outside the root; symlink resolved out. |
|
|
126
126
|
| `path-alias` | A path alias check failed (e.g. canonical-real-path moved out of the root). | Symlink resolution lands outside the root. |
|
|
127
127
|
| `path-mismatch` | Post-open identity check failed: the opened fd does not match the resolved path. | TOCTOU — something else swapped the path between resolve and open. |
|
|
@@ -131,7 +131,7 @@ type FsSafeErrorCode =
|
|
|
131
131
|
| `store-reentrant-update` | A `JsonStore.update()` callback called `update()` or `updateOr()` for the same canonical store before returning. | Reentrant mutation would deadlock or lose an update; return the complete next value from the outer callback. |
|
|
132
132
|
| `symlink` | Path component is a symlink, policy is `reject`. | Caller followed a symlink they shouldn't have, or `symlinks: "reject"` is set. |
|
|
133
133
|
| `timeout` | An operation with a wall-clock budget overran. | Secure file read or timed operation exceeded `timeoutMs`. |
|
|
134
|
-
| `too-large` | A read
|
|
134
|
+
| `too-large` | A read, bounded walk, or recursive removal exceeded its configured budget. | Review the expected file or tree size before increasing the limit; recursive removal may have completed earlier entries. |
|
|
135
135
|
| `unsupported-platform` | The platform or filesystem cannot perform the requested operation. | `createCloneSource` and `copyTree({ clone: "always" })` require native cloning support. The default `copyTree({ clone: "auto" })` selects portable byte copying when cloning is unavailable; unsupported source contents or metadata still fail. See [directory copying](copy.md) for backend limits and fallback behavior. |
|
|
136
136
|
|
|
137
137
|
Secret writes reject invalid `mode` / `dirMode` values with `invalid-path` before directory creation. Existing secret directories with a mode different from the requested `dirMode` report `insecure-permissions` without chmod; a created directory whose descriptor ownership no longer matches its initializing effective user reports `not-owned`.
|
package/docs/file-store.md
CHANGED
|
@@ -121,6 +121,21 @@ metadata where lower latency matters more than crash-durability. Per-call
|
|
|
121
121
|
`undefined` override preserves the store default. Modes, path confinement,
|
|
122
122
|
and publication identity checks are unchanged.
|
|
123
123
|
|
|
124
|
+
Synchronous writes retain their original write-only descriptor through rename
|
|
125
|
+
and publication checks, using exact bigint file identities. When Windows cannot
|
|
126
|
+
report a pathname's identity, verification reopens the name only to compare its
|
|
127
|
+
descriptor with the retained writer; it never reads file contents. A substituted
|
|
128
|
+
file is rejected even if its bytes match, and a post-publication failure leaves
|
|
129
|
+
the published entry intact for caller-owned recovery. Ordinary write-only and
|
|
130
|
+
mode-000 outputs do not require a readable descriptor when pathname metadata is
|
|
131
|
+
available.
|
|
132
|
+
|
|
133
|
+
If an opaque pathname cannot be reopened because of an ACL denial or sharing
|
|
134
|
+
restriction, the synchronous writer intentionally rejects with `path-mismatch`:
|
|
135
|
+
its exact publication identity cannot be verified. There is no equal-content
|
|
136
|
+
fallback. The published entry remains present, so callers must inspect or
|
|
137
|
+
recover that outcome instead of assuming the write did not occur.
|
|
138
|
+
|
|
124
139
|
| Method | Durability support |
|
|
125
140
|
|---|---|
|
|
126
141
|
| `write`, `writeText`, `writeJson` (async and sync) | Per-call option overrides store default. |
|
|
@@ -241,6 +256,12 @@ type FileStorePruneOptions = {
|
|
|
241
256
|
|
|
242
257
|
Symlinks are skipped. The walk is best-effort — failures on individual entries don't abort the whole prune. Compares against `mtimeMs`.
|
|
243
258
|
|
|
259
|
+
Pruning rechecks that a selected entry is still a regular file and still expired
|
|
260
|
+
immediately before guarded removal. Fresh replacements and in-place timestamp
|
|
261
|
+
refreshes are preserved; replacements that are themselves expired remain
|
|
262
|
+
eligible. This does not require read permission. The existing best-effort
|
|
263
|
+
external-process race window after dispatch still applies.
|
|
264
|
+
|
|
244
265
|
## Difference from `Root`
|
|
245
266
|
|
|
246
267
|
| `FileStore` | `Root` |
|
package/docs/filename.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Filenames
|
|
2
2
|
|
|
3
|
-
`sanitizeUntrustedFileName(name, fallback)` reduces a filename string from an untrusted source to one traversal-free path segment. Use it as a thin first pass before storing user-supplied names;
|
|
3
|
+
`sanitizeUntrustedFileName(name, fallback)` reduces a filename string from an untrusted source to one traversal-free path segment. Use it as a thin first pass before storing user-supplied names; use [`safePathSegmentHashedV2`](install-path.md#safepathsegmenthashedv2) when mapping untrusted install IDs to separate directory names.
|
|
4
4
|
|
|
5
5
|
```ts
|
|
6
6
|
import { sanitizeUntrustedFileName } from "@openclaw/fs-safe/advanced";
|
|
@@ -27,6 +27,13 @@ In order:
|
|
|
27
27
|
6. **Suffix Windows reserved basenames.** Compare the part before the first `.` case-insensitively with the Windows device-name set, including `CON`, `PRN`, `AUX`, `NUL`, `CLOCK$`, `CONIN$`, `CONOUT$`, `COM1..9`, `LPT1..9`, and their superscript `¹`, `²`, and `³` variants. Windows-ignored spaces and dots at the end of that basename do not disguise a device name. A match gains `_` before its extension, preserving the original case and extension on every platform.
|
|
28
28
|
7. **Truncate.** If the cleaned segment is longer than 200 UTF-16 code units, take up to the first 200 without splitting a valid Unicode surrogate pair.
|
|
29
29
|
|
|
30
|
+
If truncation itself exposes a reserved-device basename after Windows ignores
|
|
31
|
+
trailing spaces or dots, the result is shortened once more and receives the
|
|
32
|
+
same underscore suffix. A name that reaches the sanitization branch therefore
|
|
33
|
+
remains at most 200 UTF-16 code units and is never a Windows reserved-device
|
|
34
|
+
alias. `fallbackName` is returned verbatim for empty or path-alias input, so
|
|
35
|
+
callers must supply a fallback that already satisfies their filename policy.
|
|
36
|
+
|
|
30
37
|
That's it. The function stays intentionally small: it removes traversal and
|
|
31
38
|
the most obvious cross-platform device and character hazards, but it is not a
|
|
32
39
|
complete portable-filename or uniqueness policy.
|
|
@@ -92,5 +99,5 @@ await fs.write(`uploads/${safe}`, body); // fs is a Root() handle; rejects trave
|
|
|
92
99
|
|
|
93
100
|
## See also
|
|
94
101
|
|
|
95
|
-
- [Install path helpers](install-path.md) —
|
|
102
|
+
- [Install path helpers](install-path.md) — legacy directory-segment sanitizers and `safePathSegmentHashedV2` for untrusted install IDs.
|
|
96
103
|
- [`root()`](root.md) — the boundary you'll write into after sanitizing.
|
package/docs/guest.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# Guest filesystem source
|
|
2
|
+
|
|
3
|
+
`@openclaw/fs-safe/guest` exports Python 3 source for filesystem operations in
|
|
4
|
+
Linux and macOS guests that do not have Node installed. The host imports a
|
|
5
|
+
string and passes it to its existing container, SSH, or process transport.
|
|
6
|
+
Importing this subpath performs no I/O and launches no process.
|
|
7
|
+
|
|
8
|
+
This is the filesystem engine extracted from OpenClaw's sandbox bridge. The
|
|
9
|
+
caller still owns root admission, mount selection, read-only policy, canonical
|
|
10
|
+
path authorization, live authority, argument framing, and process lifetime.
|
|
11
|
+
Use [Root](root.md) for ordinary filesystem operations in the Node process.
|
|
12
|
+
This source artifact is separate from the retired host-side Python worker
|
|
13
|
+
described in [Migrating to 0.5](migrating-to-0.5.md).
|
|
14
|
+
|
|
15
|
+
## Exports and requirements
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import {
|
|
19
|
+
GUEST_FILESYSTEM_PYTHON,
|
|
20
|
+
GUEST_FILESYSTEM_CREATE_EXISTS_EXIT_CODE,
|
|
21
|
+
GUEST_FILESYSTEM_READ_NOT_FOUND_EXIT_CODE,
|
|
22
|
+
GUEST_FILESYSTEM_RENAME_NO_REPLACE_PYTHON,
|
|
23
|
+
} from "@openclaw/fs-safe/guest";
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`GUEST_FILESYSTEM_PYTHON` is the complete single-invocation program. The two
|
|
27
|
+
exit constants are `17` for an exclusive-create collision and `2` for a
|
|
28
|
+
missing read parent or leaf after the root opens. Other nonzero statuses are
|
|
29
|
+
errors; stderr is diagnostic text, not a structured error protocol.
|
|
30
|
+
|
|
31
|
+
The guest needs Python 3 with `ctypes`, descriptor-relative POSIX operations,
|
|
32
|
+
`O_DIRECTORY`, and `O_NOFOLLOW`. Linux and macOS are the supported guest
|
|
33
|
+
platforms. The guest does not need fs-safe's Node package, native addon, Rust,
|
|
34
|
+
or WASM. Native mode configuration in the host does not configure this program.
|
|
35
|
+
|
|
36
|
+
`GUEST_FILESYSTEM_RENAME_NO_REPLACE_PYTHON` contains just the
|
|
37
|
+
`rename_no_replace(src_parent_fd, src_basename, dst_parent_fd, dst_basename)`
|
|
38
|
+
definition. OpenClaw's workspace bootstrap consumes this same fragment when
|
|
39
|
+
publishing a prepared workspace; exporting it avoids a second source owner.
|
|
40
|
+
Its embedding program must import `ctypes`, `errno`, `os`, and `sys`, admit
|
|
41
|
+
the descriptors and single-component names, and own cleanup and syncing.
|
|
42
|
+
The fragment has no argument preflight of its own.
|
|
43
|
+
|
|
44
|
+
On Linux it uses `renameat2(RENAME_NOREPLACE)`; on macOS it uses
|
|
45
|
+
`renameatx_np(RENAME_EXCL)`. If Linux lacks that function or reports it as
|
|
46
|
+
unsupported, it falls back to `link(..., follow_symlinks=False)`, leaving the
|
|
47
|
+
source entry for caller cleanup. That fallback supports files, not directory
|
|
48
|
+
publication. Existing destinations are never replaced by this fragment.
|
|
49
|
+
|
|
50
|
+
## Invocation protocol
|
|
51
|
+
|
|
52
|
+
Pass the source through `python3 -c` and arguments as literal argv elements.
|
|
53
|
+
Do not concatenate untrusted values into a shell command. Transport adapters
|
|
54
|
+
that require a shell must apply their existing quoting rules to every element.
|
|
55
|
+
The following example uses an already admitted guest root and parent path:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { spawnSync } from "node:child_process";
|
|
59
|
+
import { GUEST_FILESYSTEM_PYTHON } from "@openclaw/fs-safe/guest";
|
|
60
|
+
|
|
61
|
+
const result = spawnSync("python3", [
|
|
62
|
+
"-c", GUEST_FILESYSTEM_PYTHON,
|
|
63
|
+
"write", "/srv/admitted-workspace", "notes", "today.txt", "1",
|
|
64
|
+
], { input: Buffer.from("hello\n"), timeout: 10_000 });
|
|
65
|
+
if (result.error) throw result.error;
|
|
66
|
+
if (result.status !== 0) throw new Error(result.stderr.toString());
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
All frames below start at `sys.argv[1]`. `root` is an admitted guest directory;
|
|
70
|
+
`parent` and `directory` are paths relative to that root. An empty relative
|
|
71
|
+
path selects the root itself. Flags are strings: `"1"` enables and `"0"`
|
|
72
|
+
disables. Do not omit required fields.
|
|
73
|
+
|
|
74
|
+
| Operation | Positional frame | Input / output |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| Read | `read root parent basename [maxBytes]` | Raw bytes on stdout; optional nonnegative integer byte limit. |
|
|
77
|
+
| Write | `write root parent basename mkdir` | Raw stdin; atomically replaces a file entry. |
|
|
78
|
+
| Create | `create root parent basename mkdir` | Raw stdin; exclusively publishes a completed file. |
|
|
79
|
+
| Copy | `copy srcRoot srcParent srcBasename dstRoot dstParent dstBasename mkdir` | Regular-file copy, atomically replaces destination. |
|
|
80
|
+
| Rename | `rename srcRoot srcParent srcBasename dstRoot dstParent dstBasename mkdir` | Rename with cross-device copy/delete fallback. |
|
|
81
|
+
| Remove | `remove root parent basename recursive force` | Removes the leaf, or recursively removes its tree. |
|
|
82
|
+
| Make directories | `mkdirp root directory` | Creates missing relative directory components. |
|
|
83
|
+
| List directory | `readdir root directory` | JSON array of `{ name, isDirectory }`; no sorting guarantee. |
|
|
84
|
+
|
|
85
|
+
The complete program rejects empty, `.`, `..`, slash-containing, and NUL
|
|
86
|
+
basenames before opening roots or creating parents. Both leaf operands of copy
|
|
87
|
+
and rename are checked. Backslashes, colons, quotes, and newlines remain legal
|
|
88
|
+
POSIX basename characters. Relative directory traversal rejects `..`; empty
|
|
89
|
+
and `.` components are skipped. Supply admitted relative paths, not arbitrary
|
|
90
|
+
absolute paths whose spelling happens to pass that component walk.
|
|
91
|
+
|
|
92
|
+
## Boundary and operation behavior
|
|
93
|
+
|
|
94
|
+
The supplied root spelling is trusted. Opening it does not prove the root is
|
|
95
|
+
the mount or inode previously authorized by the caller. Basename syntax checks
|
|
96
|
+
are not authorization. Callers must admit their roots and paths, preserve
|
|
97
|
+
read-only shadows and live authority, and run the program inside the intended
|
|
98
|
+
OS isolation boundary.
|
|
99
|
+
|
|
100
|
+
Parent traversal and operation bodies use directory descriptors. Reads and
|
|
101
|
+
copies refuse final symlinks, hardlinked files, and nonregular files. Bounded
|
|
102
|
+
reads check both admitted size and consumed bytes; a growing file can produce
|
|
103
|
+
partial stdout before rejection. Consumers must discard read output when the
|
|
104
|
+
exit status indicates failure. Removal unlinks a final symlink without
|
|
105
|
+
following it; rename moves symlink entries. Write and copy replace destination
|
|
106
|
+
entries, including symlinks, without following them. A force removal tolerates
|
|
107
|
+
a missing leaf but does not suppress failure to open its parent.
|
|
108
|
+
|
|
109
|
+
Write preserves an existing regular file's mode and otherwise creates private
|
|
110
|
+
files. Copy preserves the source mode. Exclusive create publishes a mode-0600
|
|
111
|
+
file from a private staging directory. Staging names retain the `.openclaw-*`
|
|
112
|
+
prefixes and use short random suffixes independent of the destination basename,
|
|
113
|
+
so legal names near the filesystem's component limit also work for writes and
|
|
114
|
+
cross-device moves.
|
|
115
|
+
|
|
116
|
+
Cross-device symlink moves create the new link in a private destination-side
|
|
117
|
+
staging directory before atomically replacing the destination. Link creation or
|
|
118
|
+
publication failure preserves the existing destination and source link; ordinary
|
|
119
|
+
failure cleanup removes the staging directory.
|
|
120
|
+
|
|
121
|
+
Cross-device directory moves build a copy manifest and check it during source
|
|
122
|
+
cleanup. Source changes can leave the published destination and some or all
|
|
123
|
+
of the source. Regular-file and symlink move fallbacks unlink the source
|
|
124
|
+
pathname after publication; they do not perform the directory manifest's
|
|
125
|
+
identity checks. Directory cleanup also has check-to-unlink race windows.
|
|
126
|
+
These mechanics do not promise content integrity against same-UID peers.
|
|
127
|
+
|
|
128
|
+
## Failure, cancellation, and budgets
|
|
129
|
+
|
|
130
|
+
Nonzero exit is not proof that nothing was published. A post-publication
|
|
131
|
+
identity or sync failure, or cross-device source-cleanup failure, can leave a
|
|
132
|
+
destination. The caller owns reconciliation and retry policy; do not assume
|
|
133
|
+
it is safe to remove that destination or replay a mutation blindly.
|
|
134
|
+
|
|
135
|
+
Ordinary Python failures run `finally` cleanup. There is no cancellation frame
|
|
136
|
+
or signal handler. Process death closes descriptors, but forced termination
|
|
137
|
+
can leave staging names. The transport owns termination, waiting for children
|
|
138
|
+
to settle, and any application-specific recovery.
|
|
139
|
+
|
|
140
|
+
Read byte limits are optional. Copy, write, recursive removal, directory
|
|
141
|
+
listing, and cross-device tree moves have no byte, entry, or depth budget in
|
|
142
|
+
this protocol. Recursive traversal and listing collect entries eagerly.
|
|
143
|
+
Use caller-owned isolation and resource limits appropriate to the operation.
|
|
144
|
+
The existing fsync sequence is preserved; this is not a recursive transaction,
|
|
145
|
+
rollback facility, or a stronger durability guarantee than the underlying
|
|
146
|
+
filesystem provides.
|